#!/usr/bin/env python
#
#  A python package for interpreting METAR and SPECI weather reports.
#
#  US conventions for METAR/SPECI reports are described in chapter 12 of
#  the Federal Meteorological Handbook No.1. (FMH-1 1995), issued by NOAA.
#  See <http://metar.noaa.gov/>
#
#  International conventions for the METAR and SPECI codes are specified in
#  the WMO Manual on Codes, vol I.1, Part A (WMO-306 I.i.A).
#
#  This module handles a reports that follow the US conventions, as well
#  the more general encodings in the WMO spec.  Other regional conventions
#  are not supported at present.
#
#  The current METAR report for a given station is available at the URL
#  http://weather.noaa.gov/pub/data/observations/metar/stations/<station>.TXT
#  where <station> is the four-letter ICAO station code.
#
#  The METAR reports for all reporting stations for any "cycle" (i.e., hour)
#  in the last 24 hours is available in a single file at the URL
#  http://weather.noaa.gov/pub/data/observations/metar/cycles/<cycle>Z.TXT
#  where <cycle> is a 2-digit cycle number (e.g., "00", "05" or "23").
#
#  Copyright 2004  Tom Pollard
#
"""
This module defines the Metar class.  A Metar object represents the weather report encoded by a single METAR code.
"""

__author__ = "Tom Pollard"

__email__ = "pollard@alum.mit.edu"

__LICENSE__ = """
Copyright (c) 2004-2016, %s
All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
""" % __author__

import re
import datetime
import warnings
import logging

from metar import __version__
from metar.Datatypes import *

## logger
_logger = logging.getLogger(__name__)

## Exceptions

class ParserError(Exception):
  """Exception raised when an unparseable group is found in body of the report."""
  pass

## regular expressions to decode various groups of the METAR code

MISSING_RE = re.compile(r"^[M/]+$")

TYPE_RE =     re.compile(r"^(?P<type>METAR|SPECI)\s+")
STATION_RE =  re.compile(r"^(?P<station>[A-Z][A-Z0-9]{3})\s+")
TIME_RE = re.compile(r"""^(?P<day>\d\d)
                          (?P<hour>\d\d)
                          (?P<min>\d\d)Z?\s+""",
                          re.VERBOSE)
MODIFIER_RE = re.compile(r"^(?P<mod>AUTO|FINO|NIL|TEST|CORR?|RTD|CC[A-G])\s+")
WIND_RE = re.compile(r"""^(?P<dir>[\dO]{3}|[0O]|///|MMM|VRB)
                          (?P<speed>P?[\dO]{2,3}|[/M]{2,3})
                        (G(?P<gust>P?(\d{1,3}|[/M]{1,3})))?
                          (?P<units>KTS?|LT|K|T|KMH|MPS)?
                      (\s+(?P<varfrom>\d\d\d)V
                          (?P<varto>\d\d\d))?\s+""",
                          re.VERBOSE)
VISIBILITY_RE = re.compile(r"""^(?P<vis>(?P<dist>(M|P)?\d\d\d\d|////)
                                        (?P<dir>[NSEW][EW]? | NDV)? |
                                        (?P<distu>(M|P)?(\d+|\d\d?/\d\d?|\d+\s+\d/\d))
                                        (?P<units>SM|KM|M|U) |
                                        CAVOK )\s+""",
                                 re.VERBOSE)
RUNWAY_RE = re.compile(r"""^(RVRNO |
                             R(?P<name>\d\d(RR?|LL?|C)?)/
                              (?P<low>(M|P)?\d\d\d\d)
                            (V(?P<high>(M|P)?\d\d\d\d))?
                              (?P<unit>FT)?[/NDU]*)\s+""",
                              re.VERBOSE)
WEATHER_RE = re.compile(r"""^(?P<int>(-|\+|VC)*)
                             (?P<desc>(MI|PR|BC|DR|BL|SH|TS|FZ)+)?
                             (?P<prec>(DZ|RA|SN|SG|IC|PL|GR|GS|UP|/)*)
                             (?P<obsc>BR|FG|FU|VA|DU|SA|HZ|PY)?
                             (?P<other>PO|SQ|FC|SS|DS|NSW|/+)?
                             (?P<int2>[-+])?\s+""",
                             re.VERBOSE)
SKY_RE= re.compile(r"""^(?P<cover>VV|CLR|SKC|SCK|NSC|NCD|BKN|SCT|FEW|[O0]VC|///)
                        (?P<height>[\dO]{2,4}|///)?
                        (?P<cloud>([A-Z][A-Z]+|///))?\s+""",
                        re.VERBOSE)
TEMP_RE = re.compile(r"""^(?P<temp>(M|-)?\d+|//|XX|MM)/
                          (?P<dewpt>(M|-)?\d+|//|XX|MM)?\s+""",
                          re.VERBOSE)
PRESS_RE = re.compile(r"""^(?P<unit>A|Q|QNH)?
                           (?P<press>[\dO]{3,4}|////)
                           (?P<unit2>INS)?\s+""",
                           re.VERBOSE)
RECENT_RE = re.compile(r"""^RE(?P<desc>MI|PR|BC|DR|BL|SH|TS|FZ)?
                              (?P<prec>(DZ|RA|SN|SG|IC|PL|GR|GS|UP)*)?
                              (?P<obsc>BR|FG|FU|VA|DU|SA|HZ|PY)?
                              (?P<other>PO|SQ|FC|SS|DS)?\s+""",
                              re.VERBOSE)
WINDSHEAR_RE = re.compile(r"^(WS\s+)?(ALL\s+RWY|RWY(?P<name>\d\d(RR?|L?|C)?))\s+")
COLOR_RE = re.compile(r"""^(BLACK)?(BLU|GRN|WHT|RED)\+?
                        (/?(BLACK)?(BLU|GRN|WHT|RED)\+?)*\s*""",
                        re.VERBOSE)
RUNWAYSTATE_RE = re.compile(r"""((?P<name>\d\d) | R(?P<namenew>\d\d)(RR?|LL?|C)?/?)
                                ((?P<special> SNOCLO|CLRD(\d\d|//)) |
                                 (?P<deposit>(\d|/))
                                 (?P<extent>(\d|/))
                                 (?P<depth>(\d\d|//))
                                 (?P<friction>(\d\d|//)))\s+""",
                             re.VERBOSE)
TREND_RE = re.compile(r"^(?P<trend>TEMPO|BECMG|FCST|NOSIG)\s+")

TRENDTIME_RE = re.compile(r"(?P<when>(FM|TL|AT))(?P<hour>\d\d)(?P<min>\d\d)\s+")

REMARK_RE = re.compile(r"^(RMKS?|NOSPECI|NOSIG)\s+")

## regular expressions for remark groups

AUTO_RE = re.compile(r"^AO(?P<type>\d)\s+")
SEALVL_PRESS_RE = re.compile(r"^SLP(?P<press>\d\d\d)\s+")
PEAK_WIND_RE = re.compile(r"""^P[A-Z]\s+WND\s+
                               (?P<dir>\d\d\d)
                               (?P<speed>P?\d\d\d?)/
                               (?P<hour>\d\d)?
                               (?P<min>\d\d)\s+""",
                               re.VERBOSE)
WIND_SHIFT_RE = re.compile(r"""^WSHFT\s+
                                (?P<hour>\d\d)?
                                (?P<min>\d\d)
                                (\s+(?P<front>FROPA))?\s+""",
                                re.VERBOSE)
PRECIP_1HR_RE = re.compile(r"^P(?P<precip>\d\d\d\d)\s+")
PRECIP_24HR_RE = re.compile(r"""^(?P<type>6|7)
                                 (?P<precip>\d\d\d\d)\s+""",
                                 re.VERBOSE)
PRESS_3HR_RE = re.compile(r"""^5(?P<tend>[0-8])
                                (?P<press>\d\d\d)\s+""",
                                re.VERBOSE)
TEMP_1HR_RE = re.compile(r"""^T(?P<tsign>0|1)
                               (?P<temp>\d\d\d)
                               ((?P<dsign>0|1)
                               (?P<dewpt>\d\d\d))?\s+""",
                               re.VERBOSE)
TEMP_6HR_RE = re.compile(r"""^(?P<type>1|2)
                              (?P<sign>0|1)
                              (?P<temp>\d\d\d)\s+""",
                              re.VERBOSE)
TEMP_24HR_RE = re.compile(r"""^4(?P<smaxt>0|1)
                                (?P<maxt>\d\d\d)
                                (?P<smint>0|1)
                                (?P<mint>\d\d\d)\s+""",
                                re.VERBOSE)
UNPARSED_RE = re.compile(r"(?P<group>\S+)\s+")

LIGHTNING_RE = re.compile(r"""^((?P<freq>OCNL|FRQ|CONS)\s+)?
                             LTG(?P<type>(IC|CC|CG|CA)*)
                                ( \s+(?P<loc>( OHD | VC | DSNT\s+ | \s+AND\s+ |
                                 [NSEW][EW]? (-[NSEW][EW]?)* )+) )?\s+""",
                                re.VERBOSE)

TS_LOC_RE = re.compile(r"""TS(\s+(?P<loc>( OHD | VC | DSNT\s+ | \s+AND\s+ |
                                           [NSEW][EW]? (-[NSEW][EW]?)* )+))?
                                          ( \s+MOV\s+(?P<dir>[NSEW][EW]?) )?\s+""",
                           re.VERBOSE)
SNOWDEPTH_RE = re.compile(r"""^4/(?P<snowdepth>\d\d\d)\s+""")

## translation of weather location codes

loc_terms = [ ("OHD", "overhead"),
              ("DSNT", "distant"),
              ("AND", "and"),
              ("VC", "nearby") ]

def xlate_loc( loc ):
  """Substitute English terms for the location codes in the given string."""
  for code, english in loc_terms:
      loc = loc.replace(code,english)
  return loc

## translation of the sky-condition codes into english

SKY_COVER = { "SKC":"clear",
              "CLR":"clear",
              "NSC":"clear",
              "NCD":"clear",
              "FEW":"a few ",
              "SCT":"scattered ",
              "BKN":"broken ",
              "OVC":"overcast",
              "///":"",
              "VV":"indefinite ceiling" }

CLOUD_TYPE = { "TCU":"towering cumulus",
               "CU":"cumulus",
               "CB":"cumulonimbus",
               "SC":"stratocumulus",
               "CBMAM":"cumulonimbus mammatus",
               "ACC":"altocumulus castellanus",
               "SCSL":"standing lenticular stratocumulus",
               "CCSL":"standing lenticular cirrocumulus",
               "ACSL":"standing lenticular altocumulus" }

## translation of the present-weather codes into english

WEATHER_INT = { "-":"light",
                "+":"heavy",
                "-VC":"nearby light",
                "+VC":"nearby heavy",
                "VC":"nearby" }
WEATHER_DESC = { "MI":"shallow",
                 "PR":"partial",
                 "BC":"patches of",
                 "DR":"low drifting",
                 "BL":"blowing",
                 "SH":"showers",
                 "TS":"thunderstorm",
                 "FZ":"freezing" }
WEATHER_PREC = { "DZ":"drizzle",
                 "RA":"rain",
                 "SN":"snow",
                 "SG":"snow grains",
                 "IC":"ice crystals",
                 "PL":"ice pellets",
                 "GR":"hail",
                 "GS":"snow pellets",
                 "UP":"unknown precipitation",
                 "//":"" }
WEATHER_OBSC = { "BR":"mist",
                 "FG":"fog",
                 "FU":"smoke",
                 "VA":"volcanic ash",
                 "DU":"dust",
                 "SA":"sand",
                 "HZ":"haze",
                 "PY":"spray" }
WEATHER_OTHER = { "PO":"sand whirls",
                  "SQ":"squalls",
                  "FC":"funnel cloud",
                  "SS":"sandstorm",
                  "DS":"dust storm" }

WEATHER_SPECIAL = { "+FC":"tornado" }

COLOR = { "BLU":"blue",
          "GRN":"green",
          "WHT":"white" }

## translation of various remark codes into English

PRESSURE_TENDENCY = { "0":"increasing, then decreasing",
                      "1":"increasing more slowly",
                      "2":"increasing",
                      "3":"increasing more quickly",
                      "4":"steady",
                      "5":"decreasing, then increasing",
                      "6":"decreasing more slowly",
                      "7":"decreasing",
                      "8":"decreasing more quickly" }

LIGHTNING_FREQUENCY = { "OCNL":"occasional",
                        "FRQ":"frequent",
                        "CONS":"constant" }
LIGHTNING_TYPE = { "IC":"intracloud",
                   "CC":"cloud-to-cloud",
                   "CG":"cloud-to-ground",
                   "CA":"cloud-to-air" }

REPORT_TYPE = { "METAR":"routine report",
                "SPECI":"special report",
                "AUTO":"automatic report",
                "COR":"manually corrected report" }

## Helper functions

def _report_match(handler, match):
  """Report success or failure of the given handler function. (DEBUG)"""
  if match:
      _logger.debug("%s matched '%s'", handler.__name__, match)
  else:
      _logger.debug("%s didn't match...", handler.__name__)


def _unparsedGroup( self, d ):
    """
    Handle otherwise unparseable main-body groups.
    """
    self._unparsed_groups.append(d['group'])

## METAR report objects

debug = False

class Metar(object):
  """METAR (aviation meteorology report)"""

  def __init__(self, metarcode, month=None, year=None, utcdelta=None,
               strict=True):
      """
      Parse raw METAR code.

      Parameters
      ----------
      metarcode : str
      month, year : int, optional
        Date values to be used when parsing a non-current METAR code. If not
        provided, then the month and year are guessed from the current date.
      utcdelta : int or datetime.timedelta, optional
        An int of hours or a timedelta object used to specify the timezone.
      strict : bool (default is True)
        This option determines if a ``ParserError`` is raised when
        unparsable groups are found or an unexpected exception is encountered.
        Setting this to `False` will prevent exceptions from being raised and
        only generate warning messages.
      """

      self.code = metarcode              # original METAR code
      self.type = 'METAR'                # METAR (routine) or SPECI (special)
      self.mod = "AUTO"                  # AUTO (automatic) or COR (corrected)
      self.station_id = None             # 4-character ICAO station code
      self.time = None                   # observation time [datetime]
      self.cycle = None                  # observation cycle (0-23) [int]
      self.wind_dir = None               # wind direction [direction]
      self.wind_speed = None             # wind speed [speed]
      self.wind_gust = None              # wind gust speed [speed]
      self.wind_dir_from = None          # beginning of range for win dir [direction]
      self.wind_dir_to = None            # end of range for wind dir [direction]
      self.vis = None                    # visibility [distance]
      self.vis_dir = None                # visibility direction [direction]
      self.max_vis = None                # visibility [distance]
      self.max_vis_dir = None            # visibility direction [direction]
      self.temp = None                   # temperature (C) [temperature]
      self.dewpt = None                  # dew point (C) [temperature]
      self.press = None                  # barometric pressure [pressure]
      self.runway = []                   # runway visibility (list of tuples)
      self.weather = []                  # present weather (list of tuples)
      self.recent = []                   # recent weather (list of tuples)
      self.sky = []                      # sky conditions (list of tuples)
      self.windshear = []                # runways w/ wind shear (list of strings)
      self.wind_speed_peak = None        # peak wind speed in last hour
      self.wind_dir_peak = None          # direction of peak wind speed in last hour
      self.peak_wind_time = None         # time of peak wind observation [datetime]
      self.wind_shift_time = None        # time of wind shift [datetime]
      self.max_temp_6hr = None           # max temp in last 6 hours
      self.min_temp_6hr = None           # min temp in last 6 hours
      self.max_temp_24hr = None          # max temp in last 24 hours
      self.min_temp_24hr = None          # min temp in last 24 hours
      self.press_sea_level = None        # sea-level pressure
      self.precip_1hr = None             # precipitation over the last hour
      self.precip_3hr = None             # precipitation over the last 3 hours
      self.precip_6hr = None             # precipitation over the last 6 hours
      self.precip_24hr = None            # precipitation over the last 24 hours
      self.snowdepth = None              # snow depth (distance)
      self._trend = False                # trend groups present (bool)
      self._trend_groups = []            # trend forecast groups
      self._remarks = []                 # remarks (list of strings)
      self._unparsed_groups = []
      self._unparsed_remarks = []

      self._now = datetime.datetime.utcnow()
      if utcdelta:
          self._utcdelta = utcdelta
      else:
          self._utcdelta = datetime.datetime.now() - self._now

      self._month = month
      self._year = year

      code = self.code+" "    # (the regexps all expect trailing spaces...)
      try:
          ngroup = len(self.handlers)
          igroup = 0
          ifailed = -1
          while igroup < ngroup and code:
              pattern, handler, repeatable = self.handlers[igroup]
              if debug:
                  _logger.debug("%s: %s", handler.__name__, code)
              m = pattern.match(code)
              while m:
                  ifailed = -1
                  if debug:
                      _report_match(handler, m.group())
                  handler(self,m.groupdict())
                  code = code[m.end():]
                  if self._trend:
                      code = self._do_trend_handlers(code)
                  if not repeatable:
                      break

                  if debug:
                      _logger.debug("%s: %s", handler.__name__, code)
                  m = pattern.match(code)
              if not m and ifailed < 0:
                  ifailed = igroup
              igroup += 1
              if igroup == ngroup and not m:
                  pattern, handler = (UNPARSED_RE, _unparsedGroup)
                  if debug:
                      _logger.debug("%s: %s", handler.__name__, code)
                  m = pattern.match(code)
                  if debug:
                      _report_match(handler, m.group())
                  handler(self,m.groupdict())
                  code = code[m.end():]
                  igroup = ifailed
                  ifailed = -2  # if it's still -2 when we run out of main-body
                                #  groups, we'll try parsing this group as a remark
          if pattern == REMARK_RE or self.press:
              while code:
                  for pattern, handler in self.remark_handlers:
                      if debug:
                          _logger.debug("%s: %s", handler.__name__, code)
                      m = pattern.match(code)
                      if m:
                          if debug:
                              _report_match(handler, m.group())
                          handler(self,m.groupdict())
                          code = pattern.sub("",code,1)
                          break

      except Exception as err:
          message = (
              ("%s failed while processing '%s'\n\t%s"
               ) % (handler.__name__, code, "\n\t".join(err.args))
          )
          if strict:
              raise ParserError(message)
          else:
              warnings.warn(message, RuntimeWarning)

      if self._unparsed_groups:
          code = ' '.join(self._unparsed_groups)
          message = "Unparsed groups in body '%s' while processing '%s'" % (code, metarcode)
          if strict:
              raise ParserError(message)
          else:
              warnings.warn(message, RuntimeWarning)

  @property
  def decode_completed(self):
      """
      Indicate whether the decoding was complete for non-remark elements.
      """
      return not self._unparsed_groups

  def _do_trend_handlers(self, code):
      for pattern, handler, repeatable in self.trend_handlers:
          if debug: print(handler.__name__,":",code)
          m = pattern.match(code)
          while m:
              if debug: _report_match(handler, m.group())
              self._trend_groups.append(m.group().strip())
              handler(self,m.groupdict())
              code = code[m.end():]
              if not repeatable: break
              m = pattern.match(code)
      return code

  def __str__(self):
      return self.string()

  def _handleType( self, d ):
      """
      Parse the code-type group.

      The following attributes are set:
          type   [string]
      """
      self.type = d['type']

  def _handleStation( self, d ):
      """
      Parse the station id group.

      The following attributes are set:
          station_id   [string]
      """
      self.station_id = d['station']

  def _handleModifier( self, d ):
      """
      Parse the report-modifier group.

      The following attributes are set:
          mod   [string]
      """
      mod = d['mod']
      if mod == 'CORR': mod = 'COR'
      if mod == 'NIL' or mod == 'FINO': mod = 'NO DATA'
      self.mod = mod

  def _handleTime( self, d ):
      """
      Parse the observation-time group.

      The following attributes are set:
          time   [datetime]
          cycle  [int]
          _day   [int]
          _hour  [int]
          _min   [int]
      """
      self._day = int(d['day'])
      if not self._month:
          self._month = self._now.month
          if self._day > self._now.day:
              if self._month == 1:
                  self._month = 12
              else:
                  self._month = self._month - 1
      if not self._year:
          self._year = self._now.year
          if self._month > self._now.month:
              self._year = self._year - 1
          elif self._month == self._now.month and self._day > self._now.day:
              self._year = self._year - 1
      self._hour = int(d['hour'])
      self._min = int(d['min'])
      self.time = datetime.datetime(self._year, self._month, self._day,
                                                                  self._hour, self._min)
      if self._min < 45:
          self.cycle = self._hour
      else:
          self.cycle = self._hour+1

  def _handleWind( self, d ):
      """
      Parse the wind and variable-wind groups.

      The following attributes are set:
          wind_dir           [direction]
          wind_speed         [speed]
          wind_gust          [speed]
          wind_dir_from      [int]
          wind_dir_to        [int]
      """
      wind_dir = d['dir'].replace('O','0')
      if wind_dir != "VRB" and wind_dir != "///" and wind_dir != "MMM":
          self.wind_dir = direction(wind_dir)
      wind_speed = d['speed'].replace('O','0')
      units = d['units']
      if units == 'KTS' or units == 'K' or units == 'T' or units == 'LT':
          units = 'KT'
      if wind_speed.startswith("P"):
          self.wind_speed = speed(wind_speed[1:], units, ">")
      elif not MISSING_RE.match(wind_speed):
          self.wind_speed = speed(wind_speed, units)
      if d['gust']:
          wind_gust = d['gust']
          if wind_gust.startswith("P"):
              self.wind_gust = speed(wind_gust[1:], units, ">")
          elif not MISSING_RE.match(wind_gust):
              self.wind_gust = speed(wind_gust, units)
      if d['varfrom']:
          self.wind_dir_from = direction(d['varfrom'])
          self.wind_dir_to = direction(d['varto'])

  def _handleVisibility( self, d ):
      """
      Parse the minimum and maximum visibility groups.

      The following attributes are set:
          vis          [distance]
          vis_dir      [direction]
          max_vis      [distance]
          max_vis_dir  [direction]
      """
      vis = d['vis']
      vis_less = None
      vis_dir = None
      vis_units = "M"
      vis_dist = "10000"
      if d['dist'] and d['dist'] != '////':
          vis_dist = d['dist']
          if d['dir'] and d['dir'] != 'NDV':
              vis_dir = d['dir']
      elif d['distu']:
          vis_dist = d['distu']
          if d['units'] and d['units'] != "U":
                  vis_units = d['units']
      if vis_dist == "9999":
          vis_dist = "10000"
          vis_less = ">"
      if self.vis:
          if vis_dir:
              self.max_vis_dir = direction(vis_dir)
          self.max_vis = distance(vis_dist, vis_units, vis_less)
      else:
          if vis_dir:
              self.vis_dir = direction(vis_dir)
          self.vis = distance(vis_dist, vis_units, vis_less)

  def _handleRunway(self, d):
      """
      Parse a runway visual range group.

      The following attributes are set:
          range   [list of tuples]
          . name  [string]
          . low   [distance]
          . high  [distance]
          . unit  [string]
      """
      if d['name'] is None:
          return
      unit = d['unit'] if d['unit'] is not None else 'FT'
      low = distance(d['low'], unit)
      if d['high'] is None:
          high = low
      else:
          high = distance(d['high'], unit)
      self.runway.append([d['name'], low, high, unit])

  def _handleWeather( self, d ):
      """
      Parse a present-weather group.

      The following attributes are set:
          weather    [list of tuples]
          .  intensity     [string]
          .  description   [string]
          .  precipitation [string]
          .  obscuration   [string]
          .  other         [string]
      """
      inteni = d['int']
      if not inteni and d['int2']:
          inteni = d['int2']
      desci = d['desc']
      preci = d['prec']
      obsci = d['obsc']
      otheri = d['other']
      self.weather.append((inteni,desci,preci,obsci,otheri))

  def _handleSky( self, d ):
      """
      Parse a sky-conditions group.

      The following attributes are set:
          sky        [list of tuples]
          .  cover   [string]
          .  height  [distance]
          .  cloud   [string]
      """
      height = d['height']
      if not height or height == "///":
          height = None
      else:
          height = height.replace('O','0')
          height = distance(int(height)*100,"FT")
      cover = d['cover']
      if cover == 'SCK' or cover == 'SKC' or cover == 'CL': cover = 'CLR'
      if cover == '0VC': cover = 'OVC'
      cloud = d['cloud']
      if cloud == '///': cloud = ""
      self.sky.append((cover,height,cloud))

  def _handleTemp( self, d ):
      """
      Parse a temperature-dewpoint group.

      The following attributes are set:
          temp    temperature (Celsius) [float]
          dewpt   dew point (Celsius) [float]
      """
      temp = d['temp']
      dewpt = d['dewpt']
      if  temp and temp != "//" and temp != "XX" and temp != "MM" :
          self.temp = temperature(temp)
      if dewpt and dewpt != "//" and dewpt != "XX" and dewpt != "MM" :
          self.dewpt = temperature(dewpt)

  def _handlePressure( self, d ):
      """
      Parse an altimeter-pressure group.

      The following attributes are set:
          press    [int]
      """
      press = d['press']
      if press != '////':
          press = float(press.replace('O','0'))
          if d['unit']:
              if d['unit'] == 'A' or (d['unit2'] and d['unit2'] == 'INS'):
                  self.press = pressure(press/100,'IN')
              elif d['unit'] == 'SLP':
                  if press < 500:
                      press = press/10 + 1000
                  else:
                      press = press/10 + 900
                  self.press = pressure(press,'MB')
                  self._remarks.append("sea-level pressure %.1fhPa" % press)
              else:
                  self.press = pressure(press,'MB')
          elif press > 2500:
              self.press = pressure(press/100,'IN')
          else:
              self.press = pressure(press,'MB')

  def _handleRecent( self, d ):
      """
      Parse a recent-weather group.

      The following attributes are set:
          weather    [list of tuples]
          .  intensity     [string]
          .  description   [string]
          .  precipitation [string]
          .  obscuration   [string]
          .  other         [string]
      """
      desci = d['desc']
      preci = d['prec']
      obsci = d['obsc']
      otheri = d['other']
      self.recent.append(("",desci,preci,obsci,otheri))

  def _handleWindShear( self, d ):
      """
      Parse wind-shear groups.

      The following attributes are set:
          windshear    [list of strings]
      """
      if d['name']:
          self.windshear.append(d['name'])
      else:
          self.windshear.append("ALL")

  def _handleColor( self, d ):
      """
      Parse (and ignore) the color groups.

      The following attributes are set:
          trend    [list of strings]
      """
      pass

  def _handleRunwayState( self, d ):
      """
      Parse (and ignore) the runway state.

      The following attributes are set:
      """
      pass

  def _handleTrend( self, d ):
      """
      Parse (and ignore) the trend groups.
      """
      if 'trend' in d:
          self._trend_groups.append(d['trend'])
      self._trend = True

  def _startRemarks( self, d ):
      """
      Found the start of the remarks section.
      """
      self._remarks = []

  def _handleSealvlPressRemark( self, d ):
      """
      Parse the sea-level pressure remark group.
      """
      value = float(d['press'])/10.0
      if value < 50:
          value += 1000
      else:
          value += 900
      self.press_sea_level = pressure(value,"MB")

  def _handlePrecip24hrRemark( self, d ):
      """
      Parse a 3-, 6- or 24-hour cumulative preciptation remark group.
      """
      value = float(d['precip'])/100.0
      if d['type'] == "6":
          if self.cycle == 3 or self.cycle == 9 or self.cycle == 15 or self.cycle == 21:
              self.precip_3hr = precipitation(value,"IN")
          else:
              self.precip_6hr = precipitation(value,"IN")
      else:
          self.precip_24hr = precipitation(value,"IN")

  def _handlePrecip1hrRemark( self, d ):
      """Parse an hourly precipitation remark group."""
      value = float(d['precip'])/100.0
      self.precip_1hr = precipitation(value,"IN")

  def _handleTemp1hrRemark( self, d ):
      """
      Parse a temperature & dewpoint remark group.

      These values replace the temp and dewpt from the body of the report.
      """
      value = float(d['temp'])/10.0
      if d['tsign'] == "1": value = -value
      self.temp = temperature(value)
      if d['dewpt']:
          value2 = float(d['dewpt'])/10.0
          if d['dsign'] == "1": value2 = -value2
          self.dewpt = temperature(value2)

  def _handleTemp6hrRemark( self, d ):
      """
      Parse a 6-hour maximum or minimum temperature remark group.
      """
      value = float(d['temp'])/10.0
      if d['sign'] == "1": value = -value
      if d['type'] == "1":
          self.max_temp_6hr = temperature(value,"C")
      else:
          self.min_temp_6hr = temperature(value,"C")

  def _handleTemp24hrRemark( self, d ):
      """
      Parse a 24-hour maximum/minimum temperature remark group.
      """
      value = float(d['maxt'])/10.0
      if d['smaxt'] == "1": value = -value
      value2 = float(d['mint'])/10.0
      if d['smint'] == "1": value2 = -value2
      self.max_temp_24hr = temperature(value,"C")
      self.min_temp_24hr = temperature(value2,"C")

  def _handlePress3hrRemark( self, d ):
      """
      Parse a pressure-tendency remark group.
      """
      value = float(d['press'])/10.0
      descrip = PRESSURE_TENDENCY[d['tend']]
      self._remarks.append("3-hr pressure change %.1fhPa, %s" % (value,descrip))

  def _handlePeakWindRemark( self, d ):
      """
      Parse a peak wind remark group.
      """
      peak_dir = int(d['dir'])
      peak_speed = int(d['speed'])
      self.wind_speed_peak = speed(peak_speed, "KT")
      self.wind_dir_peak = direction(peak_dir)
      peak_min = int(d['min'])
      if d['hour']:
          peak_hour = int(d['hour'])
      else:
          peak_hour = self._hour
      self.peak_wind_time = datetime.datetime(self._year, self._month, self._day,
                                                                  peak_hour, peak_min)
      if self.peak_wind_time > self.time:
          if peak_hour > self._hour:
              self.peak_wind_time -= datetime.timedelta(hours=24)
          else:
              self.peak_wind_time -= datetime.timedelta(hours=1)
      self._remarks.append("peak wind %dkt from %d degrees at %d:%02d" % \
                                              (peak_speed, peak_dir, peak_hour, peak_min))

  def _handleWindShiftRemark( self, d ):
      """
      Parse a wind shift remark group.
      """
      if d['hour']:
          wshft_hour = int(d['hour'])
      else:
          wshft_hour = self._hour
      wshft_min = int(d['min'])
      self.wind_shift_time = datetime.datetime(self._year, self._month, self._day,
                                                                  wshft_hour, wshft_min)
      if self.wind_shift_time > self.time:
          if wshft_hour > self._hour:
              self.wind_shift_time -= datetime.timedelta(hours=24)
          else:
              self.wind_shift_time -= datetime.timedelta(hours=1)
      text = "wind shift at %d:%02d" %  (wshft_hour, wshft_min)
      if d['front']:
          text += " (front)"
      self._remarks.append(text)

  def _handleLightningRemark( self, d ):
      """
      Parse a lightning observation remark group.
      """
      parts = []
      if d['freq']:
          parts.append(LIGHTNING_FREQUENCY[d['freq']])
      parts.append("lightning")
      if d['type']:
          ltg_types = []
          group = d['type']
          while group:
              ltg_types.append(LIGHTNING_TYPE[group[:2]])
              group = group[2:]
          parts.append("("+",".join(ltg_types)+")")
      if d['loc']:
          parts.append(xlate_loc(d['loc']))
      self._remarks.append(" ".join(parts))

  def _handleTSLocRemark( self, d ):
      """
      Parse a thunderstorm location remark group.
      """
      text = "thunderstorm"
      if d['loc']:
          text += " "+xlate_loc(d['loc'])
      if d['dir']:
          text += " moving %s" % d['dir']
      self._remarks.append(text)

  def _handleAutoRemark( self, d ):
      """
      Parse an automatic station remark group.
      """
      if d['type'] == "1":
          self._remarks.append("Automated station")
      elif d['type'] == "2":
          self._remarks.append("Automated station (type 2)")

  def _handleSnowDepthRemark(self, d):
      """
      Parse the 4/ group snowdepth report
      """
      self.snowdepth = distance(float(d['snowdepth']), 'IN')
      self._remarks.append(" snowdepth %s" % (self.snowdepth, ))

  def _unparsedRemark( self, d ):
      """
      Handle otherwise unparseable remark groups.
      """
      self._unparsed_remarks.append(d['group'])

  ## the list of handler functions to use (in order) to process a METAR report

  handlers = [ (TYPE_RE, _handleType, False),
               (STATION_RE, _handleStation, False),
               (TIME_RE, _handleTime, False),
               (MODIFIER_RE, _handleModifier, False),
               (WIND_RE, _handleWind, False),
               (VISIBILITY_RE, _handleVisibility, True),
               (RUNWAY_RE, _handleRunway, True),
               (WEATHER_RE, _handleWeather, True),
               (SKY_RE, _handleSky, True),
               (TEMP_RE, _handleTemp, False),
               (PRESS_RE, _handlePressure, True),
               (RECENT_RE,_handleRecent, True),
               (WINDSHEAR_RE, _handleWindShear, True),
               (COLOR_RE, _handleColor, True),
               (RUNWAYSTATE_RE, _handleRunwayState, True),
               (TREND_RE, _handleTrend, False),
               (REMARK_RE, _startRemarks, False) ]

  trend_handlers = [ (TRENDTIME_RE, _handleTrend, True),
                     (WIND_RE, _handleTrend, True),
                     (VISIBILITY_RE, _handleTrend, True),
                     (WEATHER_RE, _handleTrend, True),
                     (SKY_RE, _handleTrend, True),
                     (COLOR_RE, _handleTrend, True)]

  ## the list of patterns for the various remark groups,
  ## paired with the handler functions to use to record the decoded remark.

  remark_handlers = [ (AUTO_RE,         _handleAutoRemark),
                      (SEALVL_PRESS_RE, _handleSealvlPressRemark),
                      (PEAK_WIND_RE,    _handlePeakWindRemark),
                      (WIND_SHIFT_RE,   _handleWindShiftRemark),
                      (LIGHTNING_RE,    _handleLightningRemark),
                      (TS_LOC_RE,       _handleTSLocRemark),
                      (TEMP_1HR_RE,     _handleTemp1hrRemark),
                      (PRECIP_1HR_RE,   _handlePrecip1hrRemark),
                      (PRECIP_24HR_RE,  _handlePrecip24hrRemark),
                      (PRESS_3HR_RE,    _handlePress3hrRemark),
                      (TEMP_6HR_RE,     _handleTemp6hrRemark),
                      (TEMP_24HR_RE,    _handleTemp24hrRemark),
                      (SNOWDEPTH_RE,    _handleSnowDepthRemark),
                      (UNPARSED_RE,     _unparsedRemark) ]

  ## functions that return text representations of conditions for output

  def string( self ):
      """
      Return a human-readable version of the decoded report.
      """
      lines = []
      lines.append("station: %s" % self.station_id)
      if self.type:
          lines.append("type: %s" % self.report_type())
      if self.time:
          lines.append("time: %s" % self.time.ctime())
      if self.temp:
          lines.append("temperature: %s" % self.temp.string("C"))
      if self.dewpt:
          lines.append("dew point: %s" % self.dewpt.string("C"))
      if self.wind_speed:
          lines.append("wind: %s" % self.wind())
      if self.wind_speed_peak:
          lines.append("peak wind: %s" % self.peak_wind())
      if self.wind_shift_time:
          lines.append("wind shift: %s" % self.wind_shift())
      if self.vis:
          lines.append("visibility: %s" % self.visibility())
      if self.runway:
          lines.append("visual range: %s" % self.runway_visual_range())
      if self.press:
          lines.append("pressure: %s" % self.press.string("mb"))
      if self.weather:
          lines.append("weather: %s" % self.present_weather())
      if self.sky:
          lines.append("sky: %s" % self.sky_conditions("\n     "))
      if self.press_sea_level:
          lines.append("sea-level pressure: %s" % self.press_sea_level.string("mb"))
      if self.max_temp_6hr:
          lines.append("6-hour max temp: %s" % str(self.max_temp_6hr))
      if self.max_temp_6hr:
          lines.append("6-hour min temp: %s" % str(self.min_temp_6hr))
      if self.max_temp_24hr:
          lines.append("24-hour max temp: %s" % str(self.max_temp_24hr))
      if self.max_temp_24hr:
          lines.append("24-hour min temp: %s" % str(self.min_temp_24hr))
      if self.precip_1hr:
          lines.append("1-hour precipitation: %s" % str(self.precip_1hr))
      if self.precip_3hr:
          lines.append("3-hour precipitation: %s" % str(self.precip_3hr))
      if self.precip_6hr:
          lines.append("6-hour precipitation: %s" % str(self.precip_6hr))
      if self.precip_24hr:
          lines.append("24-hour precipitation: %s" % str(self.precip_24hr))
      if self._remarks:
          lines.append("remarks:")
          lines.append("- "+self.remarks("\n- "))
      if self._unparsed_remarks:
          lines.append("- "+' '.join(self._unparsed_remarks))
      lines.append("METAR: "+self.code)
      return "\n".join(lines)

  def report_type( self ):
      """
      Return a textual description of the report type.
      """
      if self.type == None:
          text = "unknown report type"
      elif self.type in REPORT_TYPE:
          text  = REPORT_TYPE[self.type]
      else:
          text = self.type+" report"
      if self.cycle:
          text += ", cycle %d" % self.cycle
      if self.mod:
          if self.mod in REPORT_TYPE:
              text += " (%s)" % REPORT_TYPE[self.mod]
          else:
              text += " (%s)" % self.mod
      return text

  def wind( self, units="KT" ):
      """
      Return a textual description of the wind conditions.

      Units may be specified as "MPS", "KT", "KMH", or "MPH".
      """
      if self.wind_speed == None:
          return "missing"
      elif self.wind_speed.value() == 0.0:
          text = "calm"
      else:
          wind_speed = self.wind_speed.string(units)
          if not self.wind_dir:
              text = "variable at %s" % wind_speed
          elif self.wind_dir_from:
              text = "%s to %s at %s" % \
                             (self.wind_dir_from.compass(), self.wind_dir_to.compass(), wind_speed)
          else:
              text = "%s at %s" % (self.wind_dir.compass(), wind_speed)
          if self.wind_gust:
              text += ", gusting to %s" % self.wind_gust.string(units)
      return text

  def peak_wind( self, units="KT" ):
      """
      Return a textual description of the peak wind conditions.

      Units may be specified as "MPS", "KT", "KMH", or "MPH".
      """
      if self.wind_speed_peak == None:
          return "missing"
      elif self.wind_speed_peak.value() == 0.0:
          text = "calm"
      else:
          wind_speed = self.wind_speed_peak.string(units)
          if not self.wind_dir_peak:
              text = wind_speed
          else:
              text = "%s at %s" % (self.wind_dir_peak.compass(), wind_speed)
              if not self.peak_wind_time == None:
                  text += " at %s" % self.peak_wind_time.strftime('%H:%M')
      return text

  def wind_shift( self, units="KT" ):
      """
      Return a textual description of the wind shift time

      Units may be specified as "MPS", "KT", "KMH", or "MPH".
      """
      if self.wind_shift_time == None:
          return "missing"
      else:
          return self.wind_shift_time.strftime('%H:%M')

  def visibility( self, units=None ):
      """
      Return a textual description of the visibility.

      Units may be statute miles ("SM") or meters ("M").
      """
      if self.vis == None:
          return "missing"
      if self.vis_dir:
          text = "%s to %s" % (self.vis.string(units), self.vis_dir.compass())
      else:
          text = self.vis.string(units)
      if self.max_vis:
          if self.max_vis_dir:
              text += "; %s to %s" % (self.max_vis.string(units), self.max_vis_dir.compass())
          else:
              text += "; %s" % self.max_vis.string(units)
      return text

  def runway_visual_range(self, units=None):
      """
      Return a textual description of the runway visual range.
      """
      lines = []
      for name, low, high, unit in self.runway:
          reportunits = unit if units is None else units
          if low != high:
              lines.append(
                  ("on runway %s, from %d to %s"
                   ) % (name, low.value(reportunits),
                        high.string(reportunits))
              )
          else:
              lines.append(
                  "on runway %s, %s" % (name, low.string(reportunits))
              )
      return "; ".join(lines)

  def present_weather( self ):
      """
      Return a textual description of the present weather.
      """
      return self._weather( self.weather )

  def recent_weather( self ):
      """
      Return a textual description of the recent weather.
      """
      return self._weather( self.recent )

  def _weather( self, weather ):
      """
      Return a textual description of weather.
      """
      text_list = []
      for weatheri in weather:
          (inteni,desci,preci,obsci,otheri) = weatheri
          text_parts = []
          code_parts = []
          if inteni:
              code_parts.append(inteni)
              text_parts.append(WEATHER_INT[inteni])
          if desci:
              code_parts.append(desci)
              if desci != "SH" or not preci:
                  text_parts.append(WEATHER_DESC[desci[0:2]])
                  if len(desci) == 4:
                      text_parts.append(WEATHER_DESC[desci[2:]])
          if preci:
              code_parts.append(preci)
              if len(preci) == 2:
                  precip_text = WEATHER_PREC[preci]
              elif len(preci) == 4:
                  precip_text = WEATHER_PREC[preci[:2]]+" and "
                  precip_text += WEATHER_PREC[preci[2:]]
              elif len(preci) == 6:
                  precip_text = WEATHER_PREC[preci[:2]]+", "
                  precip_text += WEATHER_PREC[preci[2:4]]+" and "
                  precip_text += WEATHER_PREC[preci[4:]]
              if desci == "TS":
                  text_parts.append("with")
              text_parts.append(precip_text)
              if desci == "SH":
                  text_parts.append(WEATHER_DESC[desci])
          if obsci:
              code_parts.append(obsci)
              text_parts.append(WEATHER_OBSC[obsci])

          if otheri:
              code_parts.append(otheri)
              text_parts.append(WEATHER_OTHER[otheri])
          code = " ".join(code_parts)
          if code in WEATHER_SPECIAL:
              text_list.append(WEATHER_SPECIAL[code])
          else:
              text_list.append(" ".join(text_parts))
      return "; ".join(text_list)

  def sky_conditions( self, sep="; " ):
      """
      Return a textual description of the sky conditions.
      """
      text_list = []
      for skyi in self.sky:
          (cover,height,cloud) = skyi
          if cover in ["SKC", "CLR", "NSC"]:
              text_list.append(SKY_COVER[cover])
          else:
              if cloud:
                  what = CLOUD_TYPE[cloud]
              elif SKY_COVER[cover].endswith(" "):
                  what = "clouds"
              else:
                  what = ""
              label = "%s %s" % (SKY_COVER[cover], what)
              # HACK here to account for 'empty' entries with above format
              label = " ".join(label.strip().split())
              if cover == "VV":
                  label += ", vertical visibility to %s" % (str(height), )
              else:
                  label += " at %s" % (str(height), )
              text_list.append(label)
      return sep.join(text_list)

  def trend( self ):
      """
      Return the trend forecast groups
      """
      return " ".join(self._trend_groups)

  def remarks( self, sep="; "):
      """
      Return the decoded remarks.
      """
      return sep.join(self._remarks)

