Completed
Pull Request — master (#100)
by Ryan
11:45
created

process_time()   A

Complexity

Conditions 2

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
dl 0
loc 6
rs 9.4285
c 1
b 0
f 0
1
from __future__ import division
2
import logging
3
import re
4
from collections import namedtuple
5
from datetime import datetime
6
7
from ..package_tools import Exporter
8
from .text import ParseError, RegexParser, WMOTextProduct, parse_wmo_time
9
from ..units import units
10
11
exporter = Exporter(globals())
12
13
log = logging.getLogger('metpy.io.metar')
14
log.addHandler(logging.StreamHandler())  # Python 2.7 needs a handler set
15
log.setLevel(logging.WARNING)
16
17
18
class MetarProduct(WMOTextProduct):
19
    def _parse(self, it):
20
        # Handle NWS style where it's just specified once at the top rather than per METAR
21
        if it.peek() in ('METAR', 'SPECI'):
22
            def_kind = next(it)
23
        else:
24
            def_kind = 'METAR'
25
26
        it.linesep = '=[\n]{0,2}'
27
        self.reports = []
28
29
        parser = MetarParser(default_kind=def_kind, ref_time=self.datetime)
30
        for l in it:
31
            # Skip SAOs
32
            if l[3:7] != ' SA ':
33
                try:
34
                    report = parser.parse(l)
35
                    # Only add the report if it's not empty
36
                    if report:
37
                        self.reports.append(report)
38
                except ParseError as e:
39
                    if self.strict:
40
                        raise
41
                    else:
42
                        log.warning('Error parsing report: %s (%s)', l, e.message)
43
44
    def __str__(self):
45
        return (super(MetarProduct, self).__str__() + '\n\tReports:' +
46
                '\n\t\t'.join(map(str, self.reports)))
47
48
49
def as_value(val, units):
50
    'Parse a value from a METAR report, attaching units'
51
    try:
52
        if val is None:
53
            return None
54
        elif val[0] in 'MP':
55
            log.warning('Got unhandled M/P value: %s', val)
56
            val = val[1:]
57
        elif val == '/' * len(val):
58
            val = 'NaN'
59
        return float(val) * units
60
    except (AttributeError, TypeError, ValueError):
61
        raise ParseError('Could not parse "%s" as a value' % val)
62
63
64
# Helper for parsing. Generates a function to grab a given group from the matches, optionally
65
# applying a converter
66
def grab_group(group, conv=None):
67
    if conv:
68
        def process(matches, *args):
69
            return conv(matches[group])
70
    else:
71
        def process(matches, *args):
72
            return matches[group]
73
    return process
74
75
76
class MetarParser(object):
77
    'Class that parses a single METAR report'
78
    def __init__(self, default_kind='METAR', ref_time=None):
79
        # Reports should start with METAR/SPECI, but of course NWS doesn't actually
80
        # do this...
81
        self.default_kind = default_kind
82
83
        # Can specify the appropriate date for year/month. Defaults to using current
84
        self.ref_time = ref_time if ref_time else datetime.utcnow()
85
86
        # Main expected groups in the report
87
        self.main_groups = [('kind', kind(default_kind)), ('stid', stid),
88
                            ('datetime', dt(ref_time)), ('null', null), ('auto', auto),
89
                            ('corrected', corrected),
90
                            ('wind', wind), ('visibility', vis), ('runway_range', rvr),
91
                            ('present_wx', wx), ('sky_coverage', sky_cover),
92
                            ('temperature', basic_temp), ('altimeter', altimeter),
93
                            ('runway_state', runway_state)]
94
95
        # Complete set of possible groups in the remarks section
96
        self.remarks = [('volcano', volcano), ('automated', automated_type),
97
                        ('peak_wind', peak_wind), ('wind_shift', wind_shift),
98
                        ('sfc_vis', sfc_vis),
99
                        ('variable_vis', var_vis), ('sector_vis', sector_vis),
100
                        ('lightning', lightning),
101
                        ('precip_times', precip_times), ('thunderstorm', thunderstorm),
102
                        ('virga', virga),
103
                        ('variable_ceiling', var_ceiling), ('variable_sky_cover', var_sky),
104
                        ('significant_clouds', sig_cloud), ('mountains', mountains),
105
                        ('pressure_change', pressure_change),
106
                        ('sea_level_pressure', slp), ('no_speci', nospeci),
107
                        ('report_sequence', report_sequence),
108
                        ('hourly_precip', hourly_precip), ('period_precip', period_precip),
109
                        ('snow_6hr', snow_6hr), ('snow_depth', snow_depth),
110
                        ('snow_liquid_equivalent', snow_liquid_equivalent),
111
                        ('hourly_ice', hourly_ice), ('ice_3hr', ice_3hr), ('ice_6hr', ice_6hr),
112
                        ('daily_precip', daily_precip), ('cloud_types', cloud_types),
113
                        ('hourly_temperature', hourly_temp), ('max_temp_6hr', max_temp_6hr),
114
                        ('min_temp_6hr', min_temp_6hr),
115
                        ('daily_temperature', daily_temp_range),
116
                        ('pressure_tendency_3hr', press_tend),
117
                        ('non-operational sensors', non_op_sensors),
118
                        ('pilot', pilot_remark), ('needs_maintenance', maint), ('null', null)]
119
120
        self.clean_whitespace = re.compile('\s+')
121
122
    def parse(self, report):
123
        'Parses the report and returns a dictionary of parsed results'
124
        report = self.clean_whitespace.sub(' ', report)
125
        ob = dict(report=report, null=False)
126
127
        # Split into main and remark sections so we can treat slightly differently
128
        if 'RMK' in report:
129
            main, remark = report.split('RMK', 1)
130
        else:
131
            main = report
132
            remark = ''
133
134
        # Handle badly formatted report where there is no main section
135
        if not main.strip():
136
            return dict()
137
138
        # Need to split out the trend forecast, otherwise will break parsing
139
        split = trend_forecast_regex.split(main, 1)
140
        if len(split) > 1:
141
            main, match, trend = trend_forecast_regex.split(main, 1)
142
            trend = trend.strip()
143
            if trend:
144
                trend_store = dict()
145
                trend = self._look_for_groups(trend, self.main_groups, trend_store)
146
                trend_store['unparsed'] = trend
147
                ob['trend_forecast'] = (match, trend_store)
148
            else:
149
                ob['trend_forecast'] = match
150
151
        # Start with the main groups. Get back what remains of the report
152
        main = self._look_for_groups(main, self.main_groups, ob)
153
154
        # If we have anything left now, it's un-parsed data and we should flag it. We check
155
        # to make sure it's actually useful leftovers
156
        if main and set(main) - set(' /'):
157
            ob['unparsed'] = main.strip()
158
159
        # If we have a remarks section, try to parse it
160
        if remark:
161
            # The groups in the remarks rely upon information from earlier in the report,
162
            # like the current time or units
163
            speed_units = ob['wind']['speed'].units if 'wind' in ob else units.knot
164
            context = dict(datetime=ob.get('datetime', self.ref_time),
165
                           speed_units=units.Quantity(1.0, speed_units))
166
167
            remark = self._look_for_groups_reduce(remark, self.remarks, ob, context)
168
            if remark:
169
                ob['remarks'] = remark
170
171
        # Handle parsing garbage by checking for either datetime or null report
172
        if ob['null'] or ('datetime' in ob and 'stid' in ob):
173
            return ob
174
        else:
175
            return dict()
176
177
    def _look_for_groups(self, string, groups, store, *context):
178
        # Walk through the list of (name, group) and try parsing the report with the group.
179
        # This will return the string that was parsed, so that we can keep track of where
180
        # we are in the string. We use a while loop so that we can repeat a group if
181
        # appropriate.
182
        string = string.strip()
183
        cursor = 0
184
        leftover = []
185
        groups = iter(groups)
186
        name, group = next(groups)
187
        while True:
188
            # Skip spaces and newlines, won't exceed end because no trailing whitespace
189
            while string[cursor] == ' ':
190
                cursor += 1
191
192
            # Try to parse using the group.
193
            try:
194
                rng, data = group.parse(string, cursor, *context)
195
            except ParseError as e:
196
                log.warning('Error while parsing: %s (%s)', string, e.message)
197
                rng = data = None
198
199
            # If we got back a range, that means the group succeeded in parsing
200
            if rng:
201
                start, end = rng
202
                log.debug('%s parsed %s', name, string[start:end])
203
204
                # If the match didn't start at the cursor, that means we skipped some
205
                # data and should flag as necessary
206
                if start > cursor:
207
                    leftover.append(string[cursor:start].strip())
208
209
                # Update the cursor in the string to where the group finished parsing
210
                cursor = end
211
212
            # If we got back some data, we should store. Possible to get back a default
213
            # value even if no parsing done.
214
            if data is not None:
215
                log.debug('%s returned %s', name, data)
216
217
                # If it's a repeated group, we store in a list regardless
218
                if group.repeat and group.keepall:
219
                    store.setdefault(name, []).append(data)
220
                else:
221
                    store[name] = data
222
223
            # If we've finished the string, get out
224
            if cursor >= len(string):
225
                break
226
227
            # If we shouldn't repeat the group, get the next one
228
            if not group.repeat or data is None:
229
                try:
230
                    name, group = next(groups)
231
                except StopIteration:
232
                    break
233
234
        # Return what remains of the string (removing whitespace)
235
        leftover.append(string[cursor:].strip())
236
        return ' '.join(leftover)
237
238
    def _look_for_groups_reduce(self, string, groups, store, *context):
239
        # Walk through the list of (name, group) and try parsing the report with the group.
240
        # This will return the string that was parsed, so that we can keep track of where
241
        # we are in the string. We use a while loop so that we can repeat a group if
242
        # appropriate.
243
        string = string.strip()
244
        groups = iter(groups)
245
        name, group = next(groups)
246
        while True:
247
            # Try to parse using the group.
248
            rng, data = group.parse(string, 0, *context)
249
250
            # If we got back a range, that means the group succeeded in parsing
251
            if rng:
252
                start, end = rng
253
                log.debug('%s parsed %s', name, string[start:end])
254
255
                string = string[:start].strip() + ' ' + string[end:].strip()
256
257
            # If we got back some data, we should store. Possible to get back a default
258
            # value even if no parsing done.
259
            if data is not None:
260
                log.debug('%s returned %s', name, data)
261
262
                # If it's a repeated group, we store in a list regardless
263
                if group.repeat and group.keepall:
264
                    store.setdefault(name, []).append(data)
265
                else:
266
                    store[name] = data
267
268
            # If we shouldn't repeat the group, get the next one
269
            if not group.repeat or data is None:
270
                try:
271
                    name, group = next(groups)
272
                except StopIteration:
273
                    break
274
275
        # Return what remains of the string (removing whitespace)
276
        return string.strip()
277
278
#
279
# Parsers for METAR groups -- main report
280
#
281
282
283
# Parse out METAR/SPECI
284
def kind(default):
285
    return RegexParser(r'\b(?P<kind>METAR|SPECI)\b', grab_group('kind'), default=default)
286
287
# Grab STID (CCCC)
288
stid = RegexParser(r'\b(?P<stid>[0-9A-Z]{4})\b', grab_group('stid'))
289
290
291
# Process the datetime in METAR to a full datetime (YYGGggZ)
292
def dt(ref_time):
293
    return RegexParser(r'\b(?P<datetime>[0-3]\d[0-5]\d[0-5]\dZ)',
294
                       lambda matches: parse_wmo_time(matches['datetime'], ref_time))
295
296
# Look for AUTO
297
auto = RegexParser(r'\b(?P<auto>AUTO)', grab_group('auto', bool), default=False)
298
299
# Look for COR
300
corrected = RegexParser(r'\b(?P<cor>COR)\b', grab_group('cor', bool), default=False)
301
302
# Look for NIL reports
303
null = RegexParser(r'\b(?P<null>NIL)', grab_group('null', bool), default=False)
304
305
306
# Process the full wind group (dddfffGfffKT dddVddd)
307
def process_wind(matches):
308
    speed_unit = units('m/s') if matches.pop('units') == 'MPS' else units.knot
309
    if matches['direction'] != 'VRB':
310
        matches['direction'] = as_value(matches['direction'], units.deg)
311
    matches['speed'] = as_value(matches['speed'], speed_unit)
312
    matches['gust'] = as_value(matches['gust'], speed_unit)
313
    matches['dir1'] = as_value(matches['dir1'], units.deg)
314
    matches['dir2'] = as_value(matches['dir2'], units.deg)
315
    return matches
316
317
wind = RegexParser(r'''(?P<direction>VRB|///|[0-3]\d{2})
318
                       (?P<speed>P?[\d]{2,3}|//)
319
                       (G(?P<gust>P?\d{2,3}))?
320
                       ((?P<units>KT|MPS)|\b|\ )
321
                       (\ (?P<dir1>\d{3})V(?P<dir2>\d{3}))?''', process_wind)
322
323
324
# The visibilty group (VVVVV)
325
frac_conv = {'1/4': 1 / 4, '1/2': 1 / 2, '3/4': 3 / 4,
326
             '1/8': 1 / 8, '3/8': 3 / 8, '5/8': 5 / 8, '7/8': 7 / 8,
327
             '1/16': 1 / 16, '3/16': 3 / 16, '5/16': 5 / 16, '7/16': 7 / 16,
328
             '9/16': 9 / 16, '11/16': 11 / 16, '13/16': 13 / 16, '15/16': 15 / 16}
329
330
331
def frac_to_float(frac):
332
    try:
333
        return frac_conv[frac]
334
    except KeyError:
335
        raise ParseError('%s is not a valid visibility fraction' % frac)
336
337
338
def vis_to_float(dist, units):
339
    'Convert visibility, including fraction, to a value with units'
340
    if dist[0] == 'M':
341
        dist = dist[1:]
342
    dist = dist.strip()
343
344
    if '/' in dist:
345
        # Handle the case where the entire group is all '////'
346
        if dist[0] == '/' and all(c == '/' for c in dist):
347
            return float('nan') * units
348
        parts = dist.split(maxsplit=1)
349
        if len(parts) > 1:
350
            return as_value(parts[0], units) + frac_to_float(parts[1]) * units
351
        else:
352
            return frac_to_float(dist) * units
353
    else:
354
        return as_value(dist, units)
355
356
357
def process_vis(matches):
358
    if matches['cavok']:
359
        return 'CAVOK'
360
    elif matches['vismiles']:
361
        return vis_to_float(matches['vismiles'], units.mile)
362
    elif matches['vismetric']:
363
        return as_value(matches['vismetric'], units.meter)
364
365
vis = RegexParser(r'''(?P<cavok>CAVOK)|
366
                      ((?P<vismiles>M?(([1-9]\d?)|(([12][ ]?)?1?[13579]/1?[2468])|////))SM\b)|
367
                      (?P<vismetric>\b\d{4}\b)''', process_vis)
368
369
370
# Runway visual range (RDD/VVVV(VVVVV)FT)
371
def to_rvr_value(dist, units):
372
    if dist[0] in ('M', 'P'):
373
        dist = dist[1:]
374
    return as_value(dist, units)
375
376
377
def process_rvr(matches):
378
    dist_units = units(matches.pop('units').lower())
379
    ret = dict()
380
    ret[matches['runway']] = to_rvr_value(matches['distance'], dist_units)
381
    if matches['max_dist']:
382
        ret[matches['runway']] = (ret[matches['runway']],
383
                                  to_rvr_value(matches['max_dist'], dist_units))
384
    if matches['change']:
385
        change_map = dict(D='down', U='up', N='no change')
386
        ret[matches['runway']] = (ret[matches['runway']], change_map[matches['change']])
387
388
    return ret
389
390
rvr = RegexParser(r'''R(?P<runway>\d{2}[RLC]?)
391
                      /(?P<distance>[MP]?\d{4})
392
                       (V(?P<max_dist>[MP]?\d{4}))?
393
                       (?P<units>FT)/?(?P<change>[UDN])?''', process_rvr)
394
395
396
# Present weather (w'w')
397
precip_abbr = {'DZ': 'Drizzle', 'RA': 'Rain', 'SN': 'Snow', 'SG': 'Snow Grains',
398
               'IC': 'Ice Crystals', 'PL': 'Ice Pellets', 'GR': 'Hail',
399
               'GS': 'Small Hail or Snow Pellets', 'UP': 'Unknown Precipitation',
400
               'RASN': 'Rain and Snow'}
401
402
403
class Weather(namedtuple('WxBase', 'mod desc precip obscur other')):
404
    lookups = [{'-': 'Light', '+': 'Heavy', 'VC': 'In the vicinity'},
405
               {'MI': 'Shallow', 'PR': 'Partial', 'BC': 'Patches', 'DR': 'Low Drifting',
406
                'BL': 'Blowing', 'SH': 'Showers', 'TS': 'Thunderstorm', 'FZ': 'Freezing'},
407
               precip_abbr,
408
               {'BR': 'Mist', 'FG': 'Fog', 'FU': 'Smoke', 'VA': 'Volcanic Ash',
409
                'DU': 'Widespread Dust', 'SA': 'Sand', 'HZ': 'Haze', 'PY': 'Spray'},
410
               {'PO': 'Well-developed Dust/Sand Whirls', 'SQ': 'Squalls', 'FC': 'Funnel Cloud',
411
                'SS': 'Sandstorm', 'DS': 'Duststorm'}]
412
413
    @classmethod
414
    def fillin(cls, **kwargs):
415
        args = [None] * 5
416
        base = cls(*args)
417
        return base._replace(**kwargs)
418
419
    def __str__(self):
420
        if self.mod == '+' and self.other == 'FC':
421
            return 'Tornado'
422
423
        return ' '.join(lookup[val] for val, lookup in zip(self, self.lookups) if val)
424
425
426
def process_wx(matches):
427
    if matches['vdesc']:
428
        matches['mod'] = matches.pop('vicinity')
429
        matches['desc'] = matches.pop('vdesc')
430
        if matches['desc'] == 'ST':
431
            matches['desc'] = 'TS'
432
    else:
433
        matches.pop('vdesc')
434
        matches.pop('vicinity')
435
436
    return Weather(**matches)
437
438
wx = RegexParser(r'''(((?P<mod>[-+])|\b)  # Begin with one of these mods or nothing
439
                      (?P<desc>MI|PR|BC|DR|BL|SH|TS|FZ)?
440
                      ((?P<precip>(DZ|RA|SN|SG|IC|PL|GR|GS|UP){1,3})
441
                      |(?P<obscur>BR|FG|FU|VA|DU|SA|HZ|PY)
442
                      |(?P<other>PO|SQ|FC|SS|DS)))
443
                     |((?P<vicinity>VC)?(?P<vdesc>SH|TS|ST))''', process_wx, repeat=True)
444
445
446
# Sky condition (NNNhhh or VVhhh or SKC/CLR)
447
def process_sky(matches):
448
    coverage_to_value = dict(VV=8, FEW=2, SCT=4, BKN=6, BKM=6, OVC=8)
449
    if matches.pop('clear'):
450
        return 0, 0, None
451
    hgt = as_value(matches['height'], 100 * units.feet)
452
    return hgt, coverage_to_value[matches['coverage']], matches['cumulus']
453
454
sky_cover = RegexParser(r'''\b(?P<clear>SKC|CLR|NSC|NCD)\b|
455
                              ((?P<coverage>VV|FEW|SCT|BK[MN]|OVC)
456
                               \ ?(?P<height>\d{3})
457
                               (?P<cumulus>CB|TCU)?)''', process_sky, repeat=True)
458
459
460
# Temperature/Dewpoint group -- whole values (TT/TdTd)
461
def parse_whole_temp(temp):
462
    if temp in ('//', 'MM'):
463
        return float('NaN') * units.degC
464
    elif temp.startswith('M'):
465
        return -as_value(temp[1:], units.degC)
466
    else:
467
        return as_value(temp, units.degC)
468
469
470
def process_temp(matches):
471
    temp = parse_whole_temp(matches['temperature'])
472
    if matches['dewpoint']:
473
        dewpt = parse_whole_temp(matches['dewpoint'])
474
    else:
475
        dewpt = float('NaN') * units.degC
476
477
    return temp, dewpt
478
479
basic_temp = RegexParser(r'''(?P<temperature>(M?\d{2})|MM)/
480
                             (?P<dewpoint>(M?[\d]{1,2})|//|MM)?''', process_temp)
481
482
483
# Altimeter setting (APPPP)
484
def process_altimeter(matches):
485
    if matches['unit'] == 'A':
486
        alt_unit = 0.01 * units.inHg
487
    else:
488
        alt_unit = units('mbar')
489
    return as_value(matches['altimeter'], alt_unit)
490
491
altimeter = RegexParser(r'\b(?P<unit>[AQ])(?P<altimeter>\d{4})', process_altimeter,
492
                        repeat=True, keepall=False)
493
494
#
495
# Extended International groups
496
#
497
498
# Runway conditions
499
runway_extent = {'1': 0.1, '2': 0.25, '5': 0.5, '9': 1.0, '/': float('NaN')}
500
runway_contaminant = {'0': 'Clear and dry', '1': 'Damp', '2': 'Wet and water patches',
501
                      '3': 'Rime and frost covered', '4': 'Dry snow', '5': 'Wet snow',
502
                      '6': 'Slush', '7': 'Ice', '8': 'Compacted or rolled snow',
503
                      '9': 'Frozen ruts or ridges', '/': 'No Report'}
504
505
506
def runway_code_to_depth(code):
507
    if code == '//':
508
        return float('NaN') * units.mm
509
    code = int(code)
510
    if code < 91:
511
        return code * units.mm
512
    elif code < 99:
513
        return (code - 90) * 5 * units.cm
514
    else:
515
        return 'Inoperable'
516
517
518
def runway_code_to_braking(code):
519
    if code == '//':
520
        return float('NaN')
521
    code = int(code)
522
    if code < 91:
523
        return float(code) / 100
524
    else:
525
        return {91: 'poor', 92: 'medium/poor', 93: 'medium', 94: 'medium/good',
526
                95: 'good'}.get(code, 'unknown')
527
528
529
def process_runway_state(matches):
530
    if matches['deposit']:
531
        matches['deposit'] = runway_contaminant.get(matches['deposit'], 'Unknown')
532
    if matches['extent']:
533
        matches['extent'] = runway_extent.get(matches['extent'], 'Unknown')
534
    if matches['depth']:
535
        matches['depth'] = runway_code_to_depth(matches['depth'])
536
537
    matches['cleared'] = bool(matches['cleared'])
538
    matches['braking'] = runway_code_to_braking(matches['braking'])
539
540
    return matches
541
542
543
runway_state = RegexParser(r'''\bR(?P<runway>\d{2})
544
                                 /((?P<deposit>[\d/])(?P<extent>[\d/])(?P<depth>\d{2}|//)|(?P<cleared>CLRD))?
545
                                  (?P<braking>\d{2}|//)''', process_runway_state)
546
547
# Trend forecast (mostly international)
548
trend_forecast_regex = re.compile(r'\b(?P<trend>NOSIG|BECMG|TEMPO)')
549
550
#
551
# Parsers for METAR groups -- remarks
552
#
553
554
555
# Combine time in the remark with the report datetime to make a proper datetime object
556
def process_time(matches, context):
557
    repl = dict(minute=int(matches['minute']))
558
    if matches['hour']:
559
        repl['hour'] = int(matches['hour'])
560
561
    return context['datetime'].replace(**repl)
562
563
# Volcanic eruption, first in NWS reports
564
volcano = RegexParser(r'[A-Z0-9 .]*VOLCANO[A-Z0-9 .]*')
565
566
# Type of automatic station
567
automated_type = RegexParser(r'\bA[O0][12]A?')
568
569
570
# Peak wind remark (PK WND dddfff/hhmm)
571
def process_peak_wind(matches, context):
572
    peak_time = process_time(matches, context)
573
    return dict(time=peak_time, speed=as_value(matches['speed'], context['speed_units']),
574
                direction=as_value(matches['direction'], units.deg))
575
576
peak_wind = RegexParser(r'''\bPK\ WND\ ?(?P<direction>\d{3})
577
                              (?P<speed>\d{2,3})/
578
                              (?P<hour>\d{2})?
579
                              (?P<minute>\d{2})''', process_peak_wind)
580
581
582
# Wind shift (WSHFT hhmm)
583
def process_shift(matches, context):
584
    time = process_time(matches, context)
585
    front = bool(matches['frontal'])
586
    return dict(time=time, frontal=front)
587
588
wind_shift = RegexParser(r'''\bWSHFT\ (?P<hour>\d{2})?
589
                               (?P<minute>\d{2})
590
                               \ (?P<frontal>FROPA)?''', process_shift)
591
592
593
# Tower/surface visibility (TWR(SFC) VIS vvvvv)
594
def process_twrsfc_vis(matches, *args):
595
    abbr_to_kind = dict(TWR='tower', SFC='surface')
596
    return {abbr_to_kind[matches['kind']]: vis_to_float(matches['vis'], units.mile)}
597
598
sfc_vis = RegexParser(r'''(?P<kind>TWR|SFC)\ VIS
599
                          \ (?P<vis>[0-9 /]{1,5})''', process_twrsfc_vis)
600
601
602
# Variable prevailing visibility (VIS vvvvvVvvvvv)
603
def process_var_vis(matches, *args):
604
    vis1 = vis_to_float(matches['vis1'], units.mile)
605
    vis2 = vis_to_float(matches['vis2'], units.mile)
606
    return vis1, vis2
607
608
# (([1-9]\d?)|(([12][ ]?)?1?[13579]/1?[2468]))
609
var_vis = RegexParser(r'''VIS\ (?P<vis1>M?((([12][ ]?)?1?[13579]/1?[2468])|([1-9]\d?)))
610
                          V(?P<vis2>((([12][ ]?)?1?[13579]/1?[2468])|([1-9]\d?)))''', process_var_vis)
611
612
613
# Sector visibility (VIS DIR vvvvv)
614
def process_sector_vis(matches, *args):
615
    # compass_to_float = dict(N=0, NE=45, E=90, SE=135, S=180, SW=225, W=270, NW=315)
616
    vis = vis_to_float(matches['vis'], units.mile)
617
    return {matches['direc']: vis}
618
619
sector_vis = RegexParser(r'''VIS\ (?P<direc>[NSEW]{1,2})
620
                             \ (?P<vis>[0-9 /]{1,5})''', process_sector_vis)
621
622
623
# Lightning
624
def process_lightning(matches, *args):
625
    if not matches['dist']:
626
        matches.pop('dist')
627
628
    if not matches['loc']:
629
        matches.pop('loc')
630
631
    if not matches['type']:
632
        matches.pop('type')
633
    else:
634
        type_str = matches['type']
635
        matches['type'] = []
636
        while type_str:
637
            matches['type'].append(type_str[:2])
638
            type_str = type_str[2:]
639
640
    if not matches['frequency']:
641
        matches.pop('frequency')
642
643
    return matches
644
645
lightning = RegexParser(r'''((?P<frequency>OCNL|FRQ|CONS)\ )?
646
                            \bLTG(?P<type>(IC|CG|CC|CA)+)?
647
                            \ ((?P<dist>OHD|VC|DSNT)\ )?
648
                              (?P<loc>([NSEW\-]|ALQD?S|\ AND\ |\ THRU\ )+)?\b''',
649
                        process_lightning)
650
651
# Precipitation/Thunderstorm begin and end
652
precip_times_regex = re.compile(r'([BE])(\d{2,4})')
653
654
655
def process_precip_times(matches, context):
656
    ref_time = context['datetime']
657
    kind = matches['precip']
658
    times = []
659
    start = None
660
    for be, time in precip_times_regex.findall(matches['times']):
661
        if len(time) == 2:
662
            time = ref_time.replace(minute=int(time))
663
        else:
664
            time = ref_time.replace(hour=int(time[:2]), minute=int(time[2:4]))
665
666
        if be == 'B':
667
            start = time
668
        else:
669
            if start:
670
                times.append((start, time))
671
                start = None
672
            else:
673
                times.append((None, time))
674
675
    if start:
676
        times.append((start, None))
677
678
    return kind, times
679
680
precip_times = RegexParser(r'''(SH)?(?P<precip>TS|DZ|FZRA|RA|SN|SG|IC|PL|GR|GS|UP)
681
                                    (?P<times>([BE]([0-2]\d)?[0-5]\d)+)''',
682
                           process_precip_times, repeat=True)
683
684
685
# Thunderstorm (TS LOC MOV DIR)
686
def process_thunderstorm(matches, *args):
687
    return matches
688
689
thunderstorm = RegexParser(r'''\bTS\ (?P<loc>[NSEW\-]+)(\ MOV\ (?P<mov>[NSEW\-]+))?''',
690
                           process_thunderstorm)
691
692
# Virga
693
virga = RegexParser(r'''\bVIRGA\ (?P<direction>[NSEW\-])''', grab_group('direction'))
694
695
696
# Variable Ceiling
697
def process_var_ceiling(matches, *args):
698
    return (as_value(matches['ceil1'], 100 * units.feet),
699
            as_value(matches['ceil2'], 100 * units.feet))
700
701
var_ceiling = RegexParser(r'\bCIG\ (?P<ceil1>\d{3})V(?P<ceil2>\d{3})\b', process_var_ceiling)
702
703
704
# Variable sky cover
705
def process_var_sky(matches, *args):
706
    matches['height'] = as_value(matches['height'], 100 * units.feet)
707
    matches['cover'] = (matches.pop('cover1'), matches.pop('cover2'))
708
    return matches
709
710
var_sky = RegexParser(r'''\b(?P<cover1>CLR|FEW|SCT|BKN|OVC)
711
                            (?P<height>\d{3})?\ V
712
                            \ (?P<cover2>CLR|FEW|SCT|BKN|OVC)''', process_var_sky)
713
714
# Mountains obscured
715
mountains = RegexParser(r'''\bMTNS?(\ PTLY)?(\ OBSCD?)?(\ DSNT)?(\ [NSEW\-]+)?''')
716
717
# Significant cloud types (CLD DIR (MOV DIR))
718
sig_cloud = RegexParser(r'''(?P<cloudtype>CB(MAM)?|TCU|ACC|[ACS]CSL|(APRNT\ ROTOR\ CLD))
719
                            \ (?P<dir>VC\ ALQD?S|[NSEW-]+)(\ MOV\ (?P<movdir>[NSEW]{1,2}))?''')
720
721
722
# Cloud Types (8/ClCmCh)
723
def process_cloud_types(matches, *args):
724
    ret = dict()
725
    for k, v in matches.items():
726
        if v == '/':
727
            ret[k] = None
728
        else:
729
            ret[k] = int(v)
730
    return ret
731
732
cloud_types = RegexParser(r'''\b8/(?P<low>[\d/])(?P<middle>[\d/])(?P<high>[\d/])''',
733
                          process_cloud_types)
734
735
736
# Pressure changes (PRESRR/PRESFR)
737
def process_pressure_change(matches, *args):
738
    if matches['tend'] == 'R':
739
        return 'rising rapidly'
740
    else:
741
        return 'falling rapidly'
742
743
pressure_change = RegexParser(r'\bPRES(?P<tend>[FR])R\b', process_pressure_change)
744
745
746
# Sea-level pressure (SLPppp)
747
def process_slp(matches, *args):
748
    if matches['slp'] == 'NO':
749
        matches['slp'] = 'NaN'
750
751
    slp = as_value(matches['slp'], 0.1 * units('mbar'))
752
    if slp < 50 * units('mbar'):
753
        slp += 1000 * units('mbar')
754
    else:
755
        slp += 900 * units('mbar')
756
    return slp
757
758
slp = RegexParser(r'SLP(?P<slp>\d{3}|NO)', process_slp)
759
760
761
# No SPECI
762
nospeci = RegexParser(r'\bNO(\ )?SPECI')
763
764
# First/last report
765
report_sequence = RegexParser(r'''\b(FIRST|LAST)''')
766
767
768
# Parse precip report
769
def parse_rmk_precip(precip):
770
    return as_value(precip, 0.01 * units.inch)
771
772
773
# Hourly Precip (Prrrr)
774
hourly_precip = RegexParser(r'\bP(?P<precip>\d{4})\b', grab_group('precip', parse_rmk_precip))
775
776
# 3/6-hour precip (6RRRR)
777
period_precip = RegexParser(r'\b6(?P<precip>\d{4}|////)',
778
                            grab_group('precip', parse_rmk_precip))
779
780
781
# Parse snow report
782
def parse_rmk_snow(snow):
783
    return as_value(snow, 0.1 * units.inch)
784
785
# 6-hour snow (931RRR)
786
snow_6hr = RegexParser(r'\b931(?P<snow>\d{3})\b', grab_group('snow', parse_rmk_snow))
787
788
789
def parse_rmk_snow_depth(snow):
790
    return as_value(snow, units.inch)
791
792
# Snow depth
793
snow_depth = RegexParser(r'\b4/(?P<snow>\d{3})\b', grab_group('snow', parse_rmk_snow_depth))
794
795
# Snow liquid equivalent (933RRR)
796
snow_liquid_equivalent = RegexParser(r'\b933(?P<snow>\d{3})\b',
797
                                     grab_group('snow', parse_rmk_snow))
798
799
# 24-hour precip (7RRRR)
800
daily_precip = RegexParser(r'\b7(?P<precip>\d{4}|////)',
801
                           grab_group('precip', parse_rmk_precip))
802
803
# Hourly ice accretion (I1RRR)
804
hourly_ice = RegexParser(r'\bI1(?P<ice>\d{3})', grab_group('ice', parse_rmk_precip))
805
806
# 3-hour ice accretion (I3RRR)
807
ice_3hr = RegexParser(r'\bI3(?P<ice>\d{3})', grab_group('ice', parse_rmk_precip))
808
809
# 6-hour ice accretion (I6RRR)
810
ice_6hr = RegexParser(r'\bI6(?P<ice>\d{3})', grab_group('ice', parse_rmk_precip))
811
812
813
# Handles parsing temperature format from remarks
814
def parse_rmk_temp(temp):
815
    if temp.startswith('1'):
816
        return -as_value(temp[1:], 0.1 * units.degC)
817
    else:
818
        return as_value(temp, 0.1 * units.degC)
819
820
821
# Hourly temperature (TsTTTsTdTdTd)
822
def process_hourly_temp(matches, *args):
823
    temp = parse_rmk_temp(matches['temperature'])
824
    if matches['dewpoint']:
825
        dewpt = parse_rmk_temp(matches['dewpoint'])
826
    else:
827
        dewpt = float('NaN') * units.degC
828
    return temp, dewpt
829
830
hourly_temp = RegexParser(r'''\bT(?P<temperature>[01]\d{3})
831
                                 (?P<dewpoint>[01]\d{3})?''', process_hourly_temp)
832
833
834
# 6-hour max temp (1sTTT)
835
max_temp_6hr = RegexParser(r'\b1(?P<temperature>[01]\d{3})\b',
836
                           grab_group('temperature', parse_rmk_temp))
837
838
# 6-hour max temp (1sTTT)
839
min_temp_6hr = RegexParser(r'\b2(?P<temperature>[01]\d{3})\b',
840
                           grab_group('temperature', parse_rmk_temp))
841
842
843
# 24-hour temp (4sTTTsTTT)
844
def process_daily_temp(matches, *args):
845
    return parse_rmk_temp(matches['min']), parse_rmk_temp(matches['max'])
846
847
daily_temp_range = RegexParser(r'\b4(?P<max>[01]\d{3})\ ?(?P<min>[01]\d{3})\b',
848
                               process_daily_temp)
849
850
851
# 3-hour pressure tendency (5appp)
852
def process_press_tend(matches, *args):
853
    return int(matches['character']), as_value(matches['amount'], 0.1 * units.mbar)
854
855
press_tend = RegexParser(r'5(?P<character>[0-8])(?P<amount>\d{3})\b', process_press_tend)
856
857
858
# Parse non-operational sensors
859
def process_nonop_sensors(matches, *args):
860
    sensors = dict(RVRNO='Runway Visual Range', PWINO='Present Weather Identifier',
861
                   PNO='Precipitation', FZRANO='Freezing Rain Sensor',
862
                   TSNO='Lightning Detection System', VISNO='Secondary Visibility Sensor',
863
                   CHINO='Secondary Ceiling Height Indicator')
864
    if matches['nonop']:
865
        return sensors.get(matches['nonop'], matches['nonop'])
866
    if matches['nonop2']:
867
        return sensors.get(matches['nonop2'], matches['nonop2']), matches['loc']
868
869
non_op_sensors = RegexParser(r'''\b(?P<nonop>RVRNO|PWINO|PNO|FZRANO|TSNO)
870
                                  |((?P<nonop2>VISNO|CHINO)\ (?P<loc>\w+))''',
871
                             process_nonop_sensors, repeat=True)
872
873
# Some free-text remarks
874
pilot_remark = RegexParser(r'([\w\ ;\.]*ATIS\ \w[\w\ ;\.]*)|(QFE[\d\.\ ]+)')
875
876
# Parse maintenance flag
877
maint = RegexParser(r'(?P<maint>\$)', grab_group('maint', bool), default=False)
878