avwx.parsing.core

Contains the core parsing and indent functions of avwx.

  1"""Contains the core parsing and indent functions of avwx."""
  2
  3# stdlib
  4from __future__ import annotations
  5
  6import datetime as dt
  7import math
  8import re
  9from calendar import monthrange
 10from contextlib import suppress
 11from copy import copy
 12from typing import TYPE_CHECKING, Any
 13
 14# library
 15from dateutil.relativedelta import relativedelta
 16
 17# module
 18from avwx.static.core import (
 19    CARDINALS,
 20    CLOUD_LIST,
 21    FRACTIONS,
 22    NUMBER_REPL,
 23    SPECIAL_NUMBERS,
 24    WIND_UNITS,
 25)
 26from avwx.structs import Cloud, Fraction, Number, Timestamp, Units
 27
 28if TYPE_CHECKING:
 29    from collections.abc import Iterable
 30
 31
 32def dedupe(items: Iterable[Any], *, only_neighbors: bool = False) -> list[Any]:
 33    """Deduplicate a list while keeping order.
 34
 35    If only_neighbors is True, dedupe will only check neighboring values.
 36    """
 37    ret: list[Any] = []
 38    for item in items:
 39        if (only_neighbors and ret and ret[-1] != item) or item not in ret:
 40            ret.append(item)
 41    return ret
 42
 43
 44def is_unknown(value: str) -> bool:
 45    """Return True if val represents and unknown value."""
 46    if not isinstance(value, str):
 47        raise TypeError
 48    if not value or value.upper() in {"UNKN", "UNK", "UKN"}:
 49        return True
 50    for char in value:
 51        if char not in ("/", "X", "."):
 52            break
 53    else:
 54        return True
 55    return False
 56
 57
 58def get_digit_list(data: list[str], from_index: int) -> tuple[list[str], list[str]]:
 59    """Return a list of items removed from a given list of strings
 60    that are all digits from 'from_index' until hitting a non-digit item.
 61    """
 62    ret = []
 63    data.pop(from_index)
 64    while len(data) > from_index and data[from_index].isdigit():
 65        ret.append(data.pop(from_index))
 66    return data, ret
 67
 68
 69def unpack_fraction(num: str) -> str:
 70    """Return unpacked fraction string 5/2 -> 2 1/2."""
 71    numbers = [int(n) for n in num.split("/") if n]
 72    if len(numbers) != 2 or numbers[0] <= numbers[1]:
 73        return num
 74    numerator, denominator = numbers
 75    over = numerator // denominator
 76    rem = numerator % denominator
 77    return f"{over} {rem}/{denominator}"
 78
 79
 80def remove_leading_zeros(num: str) -> str:
 81    """Strip zeros while handling -, M, and empty strings."""
 82    if not num:
 83        return num
 84    if num.startswith("M"):
 85        ret = "M" + num[1:].lstrip("0")
 86    elif num.startswith("-"):
 87        ret = "-" + num[1:].lstrip("0")
 88    else:
 89        ret = num.lstrip("0")
 90    return "0" if ret in ("", "M", "-") else ret
 91
 92
 93SPOKEN_POSTFIX = (
 94    (" zero zero zero", " thousand"),
 95    (" zero zero", " hundred"),
 96)
 97
 98
 99def spoken_number(num: str, *, literal: bool = False) -> str:
100    """Return the spoken version of a number.
101
102    If literal, no conversion to hundreds/thousands
103
104    Ex: 1.2 -> one point two
105        1 1/2 -> one and one half
106        25000 -> two five thousand
107    """
108    ret = []
109    for part in num.split():
110        if part in FRACTIONS:
111            ret.append(FRACTIONS[part])
112        else:
113            val = " ".join(NUMBER_REPL[char] for char in part if char in NUMBER_REPL)
114            if not literal:
115                for target, replacement in SPOKEN_POSTFIX:
116                    if val.endswith(target):
117                        val = val[: -len(target)] + replacement
118            ret.append(val)
119    return " and ".join(ret)
120
121
122def make_fraction(
123    num: str,
124    repr: str | None = None,  # noqa: A002
125    *,
126    literal: bool = False,
127    speak_prefix: str = "",
128) -> Fraction:
129    """Return a fraction dataclass for numbers with / in them."""
130    num_str, den_str = num.split("/")
131    # 2-1/2 but not -2 1/2
132    if "-" in num_str and not num_str.startswith("-"):
133        num_str = num_str.replace("-", " ")
134    denominator = int(den_str)
135    # Multiply multi-digit numerator
136    if len(num_str) > 1:
137        numerator = int(num_str[:-1]) * denominator + int(num_str[-1])
138        num = f"{numerator}/{denominator}"
139    else:
140        numerator = int(num_str)
141    value = numerator / denominator
142    unpacked = unpack_fraction(num)
143    spoken = speak_prefix + spoken_number(unpacked, literal=literal)
144    return Fraction(repr or num, value, spoken, numerator, denominator, unpacked)
145
146
147def make_number(
148    num: str | None,
149    repr: str | None = None,  # noqa: A002
150    speak: str | None = None,
151    *,
152    literal: bool = False,
153    special: dict | None = None,
154    m_minus: bool = True,
155) -> Number | Fraction | None:
156    """Return a Number or Fraction dataclass for a number string.
157
158    If literal, spoken string will not convert to hundreds/thousands.
159
160    NOTE: Numerators are assumed to have a single digit. Additional are whole numbers.
161    """
162    if not num or is_unknown(num):
163        return None
164    # Check special
165    with suppress(KeyError):
166        item = (special or {}).get(num) or SPECIAL_NUMBERS[num]
167        if isinstance(item, tuple):
168            value, spoken = item
169        else:
170            value = item
171            spoken = spoken_number(str(value), literal=literal)
172        return Number(repr or num, value, spoken)
173    # Check cardinal direction
174    if num in CARDINALS:
175        if not repr:
176            repr = num  # noqa: A001
177        num = str(CARDINALS[num])
178    val_str = num
179    # Remove unit suffixes
180    if val_str.endswith("SM"):
181        repr = val_str[:]  # noqa: A001
182        val_str = val_str[:-2]
183    # Remove spurious characters from the end
184    num = num.rstrip("M.")
185    num = num.replace("O", "0")
186    num = num.replace("+", "")
187    num = num.replace(",", "")
188    # The value is parsed from val_str, which was snapshotted above, so the
189    # cleanup has to reach it too. Previously it only did when the m_minus
190    # branch below happened to reassign val_str from num, which meant "M1O"
191    # parsed as -10 while "1O" raised ValueError out of int().
192    val_str = val_str.rstrip("M.").replace("O", "0").replace("+", "").replace(",", "")
193    # Handle Minus values with errors like 0M04
194    if m_minus and "M" in num:
195        val_str = num.replace("MM", "-").replace("M", "-")
196        while val_str[0] != "-":
197            val_str = val_str[1:]
198    # Check value prefixes
199    speak_prefix = ""
200    if val_str.startswith("ABV "):
201        speak_prefix += "above "
202        val_str = val_str[4:]
203    if val_str.startswith("BLW "):
204        speak_prefix += "below "
205        val_str = val_str[4:]
206    if val_str.startswith("FL"):
207        speak_prefix += "flight level "
208        val_str, literal = val_str[2:], True
209    if val_str.startswith("M"):
210        speak_prefix += "less than "
211        repr = repr or val_str  # noqa: A001
212        val_str = val_str[1:]
213    if val_str.startswith("P"):
214        speak_prefix += "greater than "
215        repr = repr or val_str  # noqa: A001
216        val_str = val_str[1:]
217    # Create Number
218    if not val_str:
219        return None
220    ret: Number | Fraction | None = None
221    # Create Fraction
222    if "/" in val_str:
223        ret = make_fraction(val_str, repr, literal=literal, speak_prefix=speak_prefix)
224    else:
225        val_str = val_str.replace(",", "")
226        # Overwrite float 0 due to "0.0" literal
227        value = float(val_str) or 0 if "." in num else int(val_str)
228        spoken = speak_prefix + spoken_number(speak or str(value), literal=literal)
229        ret = Number(repr or num, value, spoken)
230    # Null the value if "greater than"/"less than"
231    if ret and not m_minus and repr and repr.startswith(("M", "P")):
232        ret.value = None
233    return ret
234
235
236def find_first_in_list(txt: str, str_list: list[str]) -> int:
237    """Return the index of the earliest occurrence of an item from a list in a string.
238
239    Ex: find_first_in_list('foobar', ['bar', 'fin']) -> 3
240    """
241    start = len(txt) + 1
242    for item in str_list:
243        if start > txt.find(item) > -1:
244            start = txt.find(item)
245    return start if len(txt) + 1 > start > -1 else -1
246
247
248def is_timestamp(item: str) -> bool:
249    """Return True if the item matches the timestamp format."""
250    return len(item) == 7 and item[-1] == "Z" and item[:-1].isdigit()
251
252
253def is_timerange(item: str) -> bool:
254    """Return True if the item is a TAF to-from time range."""
255    return len(item) == 9 and item[4] == "/" and item[:4].isdigit() and item[5:].isdigit()
256
257
258def is_possible_temp(temp: str) -> bool:
259    """Return True if all characters are digits or 'M' for minus."""
260    return all((char.isdigit() or char == "M") for char in temp)
261
262
263_Numeric = int | float
264
265
266def relative_humidity(temperature: _Numeric, dewpoint: _Numeric, unit: str = "C") -> float:
267    """Calculate the relative humidity as a 0 to 1 percentage."""
268
269    def saturation(value: _Numeric) -> float:
270        """Return the saturation vapor pressure without the C constant for humidity calc."""
271        return math.exp((17.67 * value) / (243.5 + value))
272
273    if unit == "F":
274        dewpoint = (dewpoint - 32) * 5 / 9
275        temperature = (temperature - 32) * 5 / 9
276    return saturation(dewpoint) / saturation(temperature)
277
278
279# https://aviation.stackexchange.com/questions/47971/how-do-i-calculate-density-altitude-by-hand
280
281
282def pressure_altitude(pressure: float, altitude: _Numeric, unit: str = "inHg") -> int:
283    """Calculate the pressure altitude in feet. Converts pressure units."""
284    if unit == "hPa":
285        pressure *= 0.02953
286    return round((29.92 - pressure) * 1000 + altitude)
287
288
289def density_altitude(pressure: float, temperature: _Numeric, altitude: _Numeric, units: Units) -> int:
290    """Calculate the density altitude in feet. Converts pressure and temperature units."""
291    if units.temperature == "F":
292        temperature = (temperature - 32) * 5 / 9
293    if units.altimeter == "hPa":
294        pressure *= 0.02953
295    pressure_alt = pressure_altitude(pressure, altitude)
296    standard = 15 - (2 * altitude / 1000)
297    return round(((temperature - standard) * 120) + pressure_alt)
298
299
300def get_station_and_time(
301    data: list[str],
302) -> tuple[list[str], str | None, str | None]:
303    """Return the report list and removed station ident and time strings."""
304    if not data:
305        return data, None, None
306    station = data.pop(0)
307    if not data:
308        return data, station, None
309    q_time, r_time = data[0], None
310    if data and q_time.endswith("Z") and q_time[:-1].isdigit():
311        r_time = data.pop(0)
312    elif data and len(q_time) == 6 and q_time.isdigit():
313        r_time = f"{data.pop(0)}Z"
314    return data, station, r_time
315
316
317def is_wind(text: str) -> bool:
318    """Return True if the text is likely a normal wind element."""
319    # Ignore wind shear
320    if text.startswith("WS"):
321        return False
322    # 09010KT, 09010G15KT
323    if len(text) > 4:
324        for ending in WIND_UNITS:
325            unit_index = text.find(ending)
326            if text.endswith(ending) and text[unit_index - 2 : unit_index].isdigit():
327                return True
328    # 09010  09010G15 VRB10
329    if len(text) != 5 and (len(text) < 8 or "G" not in text or "/" in text):
330        return False
331    return text[:5].isdigit() or (text.startswith("VRB") and text[3:5].isdigit())
332
333
334VARIABLE_DIRECTION_PATTERN = re.compile(r"\d{3}V\d{3}")
335
336
337def is_variable_wind_direction(text: str) -> bool:
338    """Return True if element looks like 350V040."""
339    if len(text) < 7:
340        return False
341    return VARIABLE_DIRECTION_PATTERN.match(text[:7]) is not None
342
343
344def separate_wind(text: str) -> tuple[str, str, str]:
345    """Extract the direction, speed, and gust from a wind element."""
346    direction, speed, gust = "", "", ""
347    # Remove gust
348    if "G" in text:
349        g_index = text.find("G")
350        start, end = g_index + 1, g_index + 3
351        # 16006GP99KT ie gust greater than
352        if "GP" in text:
353            end += 1
354        gust = text[start:end]
355        text = text[:g_index] + text[end:]
356    if text:
357        # 10G18KT
358        if len(text) == 2:
359            speed = text
360        else:
361            direction = text[:3]
362            speed = text[3:]
363    return direction, speed, gust
364
365
366def get_wind(
367    data: list[str], units: Units
368) -> tuple[
369    list[str],
370    Number | None,
371    Number | None,
372    Number | None,
373    list[Number],
374]:
375    """Return the report list, direction string, speed string, gust string, and variable direction list."""
376    direction, speed, gust = "", "", ""
377    variable: list[Number] = []
378    # Remove unit and split elements
379    if data:
380        item = copy(data[0])
381        if is_wind(item):
382            for key, unit in WIND_UNITS.items():
383                if item.endswith(key):
384                    units.wind_speed = unit
385                    item = item.replace(key, "")
386                    break
387            direction, speed, gust = separate_wind(item)
388            data.pop(0)
389    # Separated Gust
390    if data and 1 < len(data[0]) < 4 and data[0][0] == "G" and data[0][1:].isdigit():
391        gust = data.pop(0)[1:]
392    # Variable Wind Direction
393    if data and is_variable_wind_direction(data[0]):
394        for item in data.pop(0).split("V"):
395            value = make_number(item, speak=item, literal=True)
396            if value is not None:
397                variable.append(value)
398    # Convert to Number
399    direction_value = make_number(direction, speak=direction, literal=True)
400    speed_value = make_number(speed.strip("BV"), m_minus=False)
401    gust_value = make_number(gust, m_minus=False)
402    return data, direction_value, speed_value, gust_value, variable
403
404
405def get_visibility(data: list[str], units: Units) -> tuple[list[str], Number | None]:
406    """Return the report list and removed visibility string."""
407    visibility = ""
408    if data:
409        item = copy(data[0])
410        # Vis reported in statue miles
411        if item.endswith("SM"):  # 10SM
412            if item[:-2].isdigit():
413                visibility = str(int(item[:-2]))
414            elif "/" in item:
415                visibility = item[: item.find("SM")]  # 1/2SM
416            else:
417                visibility = item[:-2]
418            data.pop(0)
419            units.visibility = "sm"
420        # Vis reported in meters
421        elif len(item) == 4 and item.isdigit():
422            visibility = data.pop(0)
423            units.visibility = "m"
424        elif 7 >= len(item) >= 5 and item[:4].isdigit() and (item[4] in ["M", "N", "S", "E", "W"] or item[4:] == "NDV"):
425            visibility = data.pop(0)[:4]
426            units.visibility = "m"
427        elif len(item) == 5 and item[1:].isdigit() and item[0] in ["M", "P", "B"]:
428            visibility = data.pop(0)[1:]
429            units.visibility = "m"
430        elif item.endswith("KM"):
431            visibility = f"{item[:-2]}000"
432            data.pop(0)
433            units.visibility = "m"
434        # Vis statute miles but split Ex: 2 1/2SM
435        elif len(data) > 1 and data[1].endswith("SM") and "/" in data[1] and item.isdigit():
436            vis1 = data.pop(0)  # 2
437            vis2 = data.pop(0).replace("SM", "")  # 1/2
438            visibility = str(int(vis1) * int(vis2[2]) + int(vis2[0])) + vis2[1:]  # 5/2
439            units.visibility = "sm"
440    return data, make_number(visibility, m_minus=False)
441
442
443def sanitize_cloud(cloud: str) -> str:
444    """Fix rare cloud layer issues."""
445    if len(cloud) < 4:
446        return cloud
447    if not cloud[3].isdigit() and cloud[3] not in ("/", "-"):
448        # Bad "O": FEWO03 -> FEW003
449        if cloud[3] == "O":
450            cloud = f"{cloud[:3]}0{cloud[4:]}"
451        # Move modifiers to end: BKNC015 -> BKN015C
452        elif cloud[3] != "U" and cloud[:4] not in {"BASE", "UNKN"}:
453            cloud = cloud[:3] + cloud[4:] + cloud[3]
454    return cloud
455
456
457def _null_or_int(val: str | None) -> int | None:
458    """Nullify unknown elements and convert ints."""
459    return None if not isinstance(val, str) or is_unknown(val) else int(val)
460
461
462_TOP_OFFSETS = ("-TOPS", "-TOP")
463
464
465def make_cloud(cloud: str) -> Cloud:
466    """Return a Cloud dataclass for a cloud string.
467
468    This function assumes the input is potentially valid.
469    """
470    raw_cloud = cloud
471    cloud_type = ""
472    base: str | None = None
473    top: str | None = None
474    cloud = sanitize_cloud(cloud).replace("/", "")
475    # Separate top
476    for target in _TOP_OFFSETS:
477        topi = cloud.find(target)
478        if topi > -1:
479            top, cloud = cloud[topi + len(target) :], cloud[:topi]
480            break
481    # Separate type
482    ## BASE027
483    if cloud.startswith("BASES"):
484        cloud = cloud[5:]
485    elif cloud.startswith("BASE"):
486        cloud = cloud[4:]
487    ## VV003
488    elif cloud.startswith("VV"):
489        cloud_type, cloud = cloud[:2], cloud[2:]
490    ## FEW010
491    elif len(cloud) >= 3 and cloud[:3] in CLOUD_LIST:
492        cloud_type, cloud = cloud[:3], cloud[3:]
493    ## BKN-OVC065
494    if len(cloud) > 4 and cloud[0] == "-" and cloud[1:4] in CLOUD_LIST:
495        cloud_type += cloud[:4]
496        cloud = cloud[4:]
497    # Separate base
498    if len(cloud) >= 3 and cloud[:3].isdigit():
499        base, cloud = cloud[:3], cloud[3:]
500    elif len(cloud) >= 4 and cloud[:4] == "UNKN":
501        cloud = cloud[4:]
502    # Remainder is considered modifiers
503    modifier = cloud or None
504    # Make Cloud
505    return Cloud(raw_cloud, cloud_type or None, _null_or_int(base), _null_or_int(top), modifier)
506
507
508def get_clouds(data: list[str]) -> tuple[list[str], list]:
509    """Return the report list and removed list of split cloud layers."""
510    clouds = []
511    for i, item in reversed(list(enumerate(data))):
512        if item[:3] in CLOUD_LIST or item[:2] == "VV":
513            cloud = data.pop(i)
514            clouds.append(make_cloud(cloud))
515    # Attempt cloud sort. Fails if None values are present
516    try:
517        clouds.sort(key=lambda cloud: (cloud.base, cloud.type))
518    except TypeError:
519        clouds.reverse()  # Restores original report order
520    return data, clouds
521
522
523def get_flight_rules(visibility: Number | None, ceiling: Cloud | None) -> int:
524    """Return int based on current flight rules from parsed METAR data.
525
526    0=VFR, 1=MVFR, 2=IFR, 3=LIFR
527
528    Note: Common practice is to report no higher than IFR if visibility unavailable.
529    """
530    # Parse visibility
531    vis: _Numeric
532    if visibility is None:
533        vis = 2
534    elif visibility.repr == "CAVOK" or visibility.repr.startswith("P6"):
535        vis = 10
536    elif visibility.repr.startswith("M"):
537        vis = 0
538    elif visibility.value is None:
539        vis = 2
540    # Convert meters to miles
541    elif len(visibility.repr) == 4:
542        vis = (visibility.value or 0) * 0.000621371
543    else:
544        vis = visibility.value or 0
545    # Parse ceiling
546    cld = (ceiling.base if ceiling else 99) or 99
547    # Determine flight rules
548    if (vis <= 5) or (cld <= 30):
549        if (vis < 3) or (cld < 10):
550            if (vis < 1) or (cld < 5):
551                return 3  # LIFR
552            return 2  # IFR
553        return 1  # MVFR
554    return 0  # VFR
555
556
557def get_ceiling(clouds: list[Cloud]) -> Cloud | None:
558    """Return ceiling layer from Cloud-List or None if none found.
559
560    Assumes that the clouds are already sorted lowest to highest.
561
562    Only 'Broken', 'Overcast', and 'Vertical Visibility' are considered ceilings.
563
564    Prevents errors due to lack of cloud information (eg. '' or 'FEW///')
565    """
566    return next((c for c in clouds if c.base and c.type in {"OVC", "BKN", "VV"}), None)
567
568
569def is_altitude(value: str) -> bool:
570    """Return True if the value is a possible altitude."""
571    if len(value) < 5:
572        return False
573    if value.startswith("SFC/"):
574        return True
575    if value.startswith("FL") and value[2:5].isdigit():
576        return True
577    first, *_ = value.split("/")
578    return bool(first[-2:] == "FT" and first[-5:-2].isdigit())
579
580
581def make_altitude(
582    value: str,
583    units: Units,
584    repr: str | None = None,  # noqa: A002
585    *,
586    force_fl: bool = False,
587) -> tuple[Number | None, Units]:
588    """Convert altitude string into a number."""
589    if not value:
590        return None, units
591    raw = repr or value
592    for end in ("FT", "M"):
593        if value.endswith(end):
594            force_fl = False
595            units.altitude = end.lower()
596            value = value.removesuffix(end)
597    # F430
598    if value[0] == "F" and value[1:].isdigit():
599        value = f"FL{value[1:]}"
600    if force_fl and value[:2] != "FL":
601        value = f"FL{value}"
602    return make_number(value, repr=raw), units
603
604
605def parse_date(
606    date: str,
607    hour_threshold: int = 200,
608    *,
609    time_only: bool = False,
610    target: dt.date | None = None,
611) -> dt.datetime | None:
612    """Parse a report timestamp in ddhhZ or ddhhmmZ format.
613
614    If time_only, assumes hhmm format with current or previous day.
615
616    This function assumes the given timestamp is within the hour threshold from current date.
617    """
618    # Format date string
619    date = date.strip("Z")
620    if not date.isdigit():
621        return None
622    if time_only:
623        if len(date) != 4:
624            return None
625        index_hour = 0
626    else:
627        if len(date) == 4:
628            date += "00"
629        if len(date) != 6:
630            return None
631        index_hour = 2
632    # Create initial guess
633    if target:
634        target = dt.datetime(target.year, target.month, target.day, tzinfo=dt.timezone.utc)
635    else:
636        target = dt.datetime.now(tz=dt.timezone.utc)
637    day = target.day if time_only else int(date[:2])
638    hour = int(date[index_hour : index_hour + 2])
639    # Handle situation where next month has less days than current month
640    # Shifted value makes sure that a month shift doesn't happen twice
641    shifted = False
642    if day > monthrange(target.year, target.month)[1]:
643        target += relativedelta(months=-1)
644        shifted = True
645    try:
646        guess = target.replace(
647            day=day,
648            hour=hour % 24,
649            minute=int(date[index_hour + 2 : index_hour + 4]) % 60,
650            second=0,
651            microsecond=0,
652        )
653    except ValueError:
654        return None
655    # Handle overflow hour
656    if hour > 23:
657        guess += dt.timedelta(days=1)
658    # Handle changing months if not already shifted
659    if not shifted:
660        hourdiff = (guess - target) / dt.timedelta(minutes=1) / 60
661        if hourdiff > hour_threshold:
662            guess += relativedelta(months=-1)
663        elif hourdiff < -hour_threshold:
664            guess += relativedelta(months=+1)
665    return guess
666
667
668def make_timestamp(
669    timestamp: str | None,
670    *,
671    time_only: bool = False,
672    target_date: dt.date | None = None,
673) -> Timestamp | None:
674    """Return a Timestamp dataclass for a report timestamp in ddhhZ or ddhhmmZ format."""
675    if not timestamp:
676        return None
677    date_obj = parse_date(timestamp, time_only=time_only, target=target_date)
678    return Timestamp(timestamp, date_obj)
679
680
681def is_runway_visibility(item: str) -> bool:
682    """Return True if the item is a runway visibility range string."""
683    return (
684        len(item) > 4
685        and item[0] == "R"
686        and (item[3] == "/" or item[4] == "/")
687        and item[1:3].isdigit()
688        and "CLRD" not in item  # R28/CLRD70 Runway State
689    )
def dedupe( items: Iterable[typing.Any], *, only_neighbors: bool = False) -> list[typing.Any]:
33def dedupe(items: Iterable[Any], *, only_neighbors: bool = False) -> list[Any]:
34    """Deduplicate a list while keeping order.
35
36    If only_neighbors is True, dedupe will only check neighboring values.
37    """
38    ret: list[Any] = []
39    for item in items:
40        if (only_neighbors and ret and ret[-1] != item) or item not in ret:
41            ret.append(item)
42    return ret

Deduplicate a list while keeping order.

If only_neighbors is True, dedupe will only check neighboring values.

def is_unknown(value: str) -> bool:
45def is_unknown(value: str) -> bool:
46    """Return True if val represents and unknown value."""
47    if not isinstance(value, str):
48        raise TypeError
49    if not value or value.upper() in {"UNKN", "UNK", "UKN"}:
50        return True
51    for char in value:
52        if char not in ("/", "X", "."):
53            break
54    else:
55        return True
56    return False

Return True if val represents and unknown value.

def get_digit_list(data: list[str], from_index: int) -> tuple[list[str], list[str]]:
59def get_digit_list(data: list[str], from_index: int) -> tuple[list[str], list[str]]:
60    """Return a list of items removed from a given list of strings
61    that are all digits from 'from_index' until hitting a non-digit item.
62    """
63    ret = []
64    data.pop(from_index)
65    while len(data) > from_index and data[from_index].isdigit():
66        ret.append(data.pop(from_index))
67    return data, ret

Return a list of items removed from a given list of strings that are all digits from 'from_index' until hitting a non-digit item.

def unpack_fraction(num: str) -> str:
70def unpack_fraction(num: str) -> str:
71    """Return unpacked fraction string 5/2 -> 2 1/2."""
72    numbers = [int(n) for n in num.split("/") if n]
73    if len(numbers) != 2 or numbers[0] <= numbers[1]:
74        return num
75    numerator, denominator = numbers
76    over = numerator // denominator
77    rem = numerator % denominator
78    return f"{over} {rem}/{denominator}"

Return unpacked fraction string 5/2 -> 2 1/2.

def remove_leading_zeros(num: str) -> str:
81def remove_leading_zeros(num: str) -> str:
82    """Strip zeros while handling -, M, and empty strings."""
83    if not num:
84        return num
85    if num.startswith("M"):
86        ret = "M" + num[1:].lstrip("0")
87    elif num.startswith("-"):
88        ret = "-" + num[1:].lstrip("0")
89    else:
90        ret = num.lstrip("0")
91    return "0" if ret in ("", "M", "-") else ret

Strip zeros while handling -, M, and empty strings.

SPOKEN_POSTFIX = ((' zero zero zero', ' thousand'), (' zero zero', ' hundred'))
def spoken_number(num: str, *, literal: bool = False) -> str:
100def spoken_number(num: str, *, literal: bool = False) -> str:
101    """Return the spoken version of a number.
102
103    If literal, no conversion to hundreds/thousands
104
105    Ex: 1.2 -> one point two
106        1 1/2 -> one and one half
107        25000 -> two five thousand
108    """
109    ret = []
110    for part in num.split():
111        if part in FRACTIONS:
112            ret.append(FRACTIONS[part])
113        else:
114            val = " ".join(NUMBER_REPL[char] for char in part if char in NUMBER_REPL)
115            if not literal:
116                for target, replacement in SPOKEN_POSTFIX:
117                    if val.endswith(target):
118                        val = val[: -len(target)] + replacement
119            ret.append(val)
120    return " and ".join(ret)

Return the spoken version of a number.

If literal, no conversion to hundreds/thousands

Ex: 1.2 -> one point two 1 1/2 -> one and one half 25000 -> two five thousand

def make_fraction( num: str, repr: str | None = None, *, literal: bool = False, speak_prefix: str = '') -> avwx.structs.Fraction:
123def make_fraction(
124    num: str,
125    repr: str | None = None,  # noqa: A002
126    *,
127    literal: bool = False,
128    speak_prefix: str = "",
129) -> Fraction:
130    """Return a fraction dataclass for numbers with / in them."""
131    num_str, den_str = num.split("/")
132    # 2-1/2 but not -2 1/2
133    if "-" in num_str and not num_str.startswith("-"):
134        num_str = num_str.replace("-", " ")
135    denominator = int(den_str)
136    # Multiply multi-digit numerator
137    if len(num_str) > 1:
138        numerator = int(num_str[:-1]) * denominator + int(num_str[-1])
139        num = f"{numerator}/{denominator}"
140    else:
141        numerator = int(num_str)
142    value = numerator / denominator
143    unpacked = unpack_fraction(num)
144    spoken = speak_prefix + spoken_number(unpacked, literal=literal)
145    return Fraction(repr or num, value, spoken, numerator, denominator, unpacked)

Return a fraction dataclass for numbers with / in them.

def make_number( num: str | None, repr: str | None = None, speak: str | None = None, *, literal: bool = False, special: dict | None = None, m_minus: bool = True) -> avwx.structs.Number | avwx.structs.Fraction | None:
148def make_number(
149    num: str | None,
150    repr: str | None = None,  # noqa: A002
151    speak: str | None = None,
152    *,
153    literal: bool = False,
154    special: dict | None = None,
155    m_minus: bool = True,
156) -> Number | Fraction | None:
157    """Return a Number or Fraction dataclass for a number string.
158
159    If literal, spoken string will not convert to hundreds/thousands.
160
161    NOTE: Numerators are assumed to have a single digit. Additional are whole numbers.
162    """
163    if not num or is_unknown(num):
164        return None
165    # Check special
166    with suppress(KeyError):
167        item = (special or {}).get(num) or SPECIAL_NUMBERS[num]
168        if isinstance(item, tuple):
169            value, spoken = item
170        else:
171            value = item
172            spoken = spoken_number(str(value), literal=literal)
173        return Number(repr or num, value, spoken)
174    # Check cardinal direction
175    if num in CARDINALS:
176        if not repr:
177            repr = num  # noqa: A001
178        num = str(CARDINALS[num])
179    val_str = num
180    # Remove unit suffixes
181    if val_str.endswith("SM"):
182        repr = val_str[:]  # noqa: A001
183        val_str = val_str[:-2]
184    # Remove spurious characters from the end
185    num = num.rstrip("M.")
186    num = num.replace("O", "0")
187    num = num.replace("+", "")
188    num = num.replace(",", "")
189    # The value is parsed from val_str, which was snapshotted above, so the
190    # cleanup has to reach it too. Previously it only did when the m_minus
191    # branch below happened to reassign val_str from num, which meant "M1O"
192    # parsed as -10 while "1O" raised ValueError out of int().
193    val_str = val_str.rstrip("M.").replace("O", "0").replace("+", "").replace(",", "")
194    # Handle Minus values with errors like 0M04
195    if m_minus and "M" in num:
196        val_str = num.replace("MM", "-").replace("M", "-")
197        while val_str[0] != "-":
198            val_str = val_str[1:]
199    # Check value prefixes
200    speak_prefix = ""
201    if val_str.startswith("ABV "):
202        speak_prefix += "above "
203        val_str = val_str[4:]
204    if val_str.startswith("BLW "):
205        speak_prefix += "below "
206        val_str = val_str[4:]
207    if val_str.startswith("FL"):
208        speak_prefix += "flight level "
209        val_str, literal = val_str[2:], True
210    if val_str.startswith("M"):
211        speak_prefix += "less than "
212        repr = repr or val_str  # noqa: A001
213        val_str = val_str[1:]
214    if val_str.startswith("P"):
215        speak_prefix += "greater than "
216        repr = repr or val_str  # noqa: A001
217        val_str = val_str[1:]
218    # Create Number
219    if not val_str:
220        return None
221    ret: Number | Fraction | None = None
222    # Create Fraction
223    if "/" in val_str:
224        ret = make_fraction(val_str, repr, literal=literal, speak_prefix=speak_prefix)
225    else:
226        val_str = val_str.replace(",", "")
227        # Overwrite float 0 due to "0.0" literal
228        value = float(val_str) or 0 if "." in num else int(val_str)
229        spoken = speak_prefix + spoken_number(speak or str(value), literal=literal)
230        ret = Number(repr or num, value, spoken)
231    # Null the value if "greater than"/"less than"
232    if ret and not m_minus and repr and repr.startswith(("M", "P")):
233        ret.value = None
234    return ret

Return a Number or Fraction dataclass for a number string.

If literal, spoken string will not convert to hundreds/thousands.

NOTE: Numerators are assumed to have a single digit. Additional are whole numbers.

def find_first_in_list(txt: str, str_list: list[str]) -> int:
237def find_first_in_list(txt: str, str_list: list[str]) -> int:
238    """Return the index of the earliest occurrence of an item from a list in a string.
239
240    Ex: find_first_in_list('foobar', ['bar', 'fin']) -> 3
241    """
242    start = len(txt) + 1
243    for item in str_list:
244        if start > txt.find(item) > -1:
245            start = txt.find(item)
246    return start if len(txt) + 1 > start > -1 else -1

Return the index of the earliest occurrence of an item from a list in a string.

Ex: find_first_in_list('foobar', ['bar', 'fin']) -> 3

def is_timestamp(item: str) -> bool:
249def is_timestamp(item: str) -> bool:
250    """Return True if the item matches the timestamp format."""
251    return len(item) == 7 and item[-1] == "Z" and item[:-1].isdigit()

Return True if the item matches the timestamp format.

def is_timerange(item: str) -> bool:
254def is_timerange(item: str) -> bool:
255    """Return True if the item is a TAF to-from time range."""
256    return len(item) == 9 and item[4] == "/" and item[:4].isdigit() and item[5:].isdigit()

Return True if the item is a TAF to-from time range.

def is_possible_temp(temp: str) -> bool:
259def is_possible_temp(temp: str) -> bool:
260    """Return True if all characters are digits or 'M' for minus."""
261    return all((char.isdigit() or char == "M") for char in temp)

Return True if all characters are digits or 'M' for minus.

def relative_humidity( temperature: int | float, dewpoint: int | float, unit: str = 'C') -> float:
267def relative_humidity(temperature: _Numeric, dewpoint: _Numeric, unit: str = "C") -> float:
268    """Calculate the relative humidity as a 0 to 1 percentage."""
269
270    def saturation(value: _Numeric) -> float:
271        """Return the saturation vapor pressure without the C constant for humidity calc."""
272        return math.exp((17.67 * value) / (243.5 + value))
273
274    if unit == "F":
275        dewpoint = (dewpoint - 32) * 5 / 9
276        temperature = (temperature - 32) * 5 / 9
277    return saturation(dewpoint) / saturation(temperature)

Calculate the relative humidity as a 0 to 1 percentage.

def pressure_altitude(pressure: float, altitude: int | float, unit: str = 'inHg') -> int:
283def pressure_altitude(pressure: float, altitude: _Numeric, unit: str = "inHg") -> int:
284    """Calculate the pressure altitude in feet. Converts pressure units."""
285    if unit == "hPa":
286        pressure *= 0.02953
287    return round((29.92 - pressure) * 1000 + altitude)

Calculate the pressure altitude in feet. Converts pressure units.

def density_altitude( pressure: float, temperature: int | float, altitude: int | float, units: avwx.structs.Units) -> int:
290def density_altitude(pressure: float, temperature: _Numeric, altitude: _Numeric, units: Units) -> int:
291    """Calculate the density altitude in feet. Converts pressure and temperature units."""
292    if units.temperature == "F":
293        temperature = (temperature - 32) * 5 / 9
294    if units.altimeter == "hPa":
295        pressure *= 0.02953
296    pressure_alt = pressure_altitude(pressure, altitude)
297    standard = 15 - (2 * altitude / 1000)
298    return round(((temperature - standard) * 120) + pressure_alt)

Calculate the density altitude in feet. Converts pressure and temperature units.

def get_station_and_time(data: list[str]) -> tuple[list[str], str | None, str | None]:
301def get_station_and_time(
302    data: list[str],
303) -> tuple[list[str], str | None, str | None]:
304    """Return the report list and removed station ident and time strings."""
305    if not data:
306        return data, None, None
307    station = data.pop(0)
308    if not data:
309        return data, station, None
310    q_time, r_time = data[0], None
311    if data and q_time.endswith("Z") and q_time[:-1].isdigit():
312        r_time = data.pop(0)
313    elif data and len(q_time) == 6 and q_time.isdigit():
314        r_time = f"{data.pop(0)}Z"
315    return data, station, r_time

Return the report list and removed station ident and time strings.

def is_wind(text: str) -> bool:
318def is_wind(text: str) -> bool:
319    """Return True if the text is likely a normal wind element."""
320    # Ignore wind shear
321    if text.startswith("WS"):
322        return False
323    # 09010KT, 09010G15KT
324    if len(text) > 4:
325        for ending in WIND_UNITS:
326            unit_index = text.find(ending)
327            if text.endswith(ending) and text[unit_index - 2 : unit_index].isdigit():
328                return True
329    # 09010  09010G15 VRB10
330    if len(text) != 5 and (len(text) < 8 or "G" not in text or "/" in text):
331        return False
332    return text[:5].isdigit() or (text.startswith("VRB") and text[3:5].isdigit())

Return True if the text is likely a normal wind element.

VARIABLE_DIRECTION_PATTERN = re.compile('\\d{3}V\\d{3}')
def is_variable_wind_direction(text: str) -> bool:
338def is_variable_wind_direction(text: str) -> bool:
339    """Return True if element looks like 350V040."""
340    if len(text) < 7:
341        return False
342    return VARIABLE_DIRECTION_PATTERN.match(text[:7]) is not None

Return True if element looks like 350V040.

def separate_wind(text: str) -> tuple[str, str, str]:
345def separate_wind(text: str) -> tuple[str, str, str]:
346    """Extract the direction, speed, and gust from a wind element."""
347    direction, speed, gust = "", "", ""
348    # Remove gust
349    if "G" in text:
350        g_index = text.find("G")
351        start, end = g_index + 1, g_index + 3
352        # 16006GP99KT ie gust greater than
353        if "GP" in text:
354            end += 1
355        gust = text[start:end]
356        text = text[:g_index] + text[end:]
357    if text:
358        # 10G18KT
359        if len(text) == 2:
360            speed = text
361        else:
362            direction = text[:3]
363            speed = text[3:]
364    return direction, speed, gust

Extract the direction, speed, and gust from a wind element.

def get_wind( data: list[str], units: avwx.structs.Units) -> tuple[list[str], avwx.structs.Number | None, avwx.structs.Number | None, avwx.structs.Number | None, list[avwx.structs.Number]]:
367def get_wind(
368    data: list[str], units: Units
369) -> tuple[
370    list[str],
371    Number | None,
372    Number | None,
373    Number | None,
374    list[Number],
375]:
376    """Return the report list, direction string, speed string, gust string, and variable direction list."""
377    direction, speed, gust = "", "", ""
378    variable: list[Number] = []
379    # Remove unit and split elements
380    if data:
381        item = copy(data[0])
382        if is_wind(item):
383            for key, unit in WIND_UNITS.items():
384                if item.endswith(key):
385                    units.wind_speed = unit
386                    item = item.replace(key, "")
387                    break
388            direction, speed, gust = separate_wind(item)
389            data.pop(0)
390    # Separated Gust
391    if data and 1 < len(data[0]) < 4 and data[0][0] == "G" and data[0][1:].isdigit():
392        gust = data.pop(0)[1:]
393    # Variable Wind Direction
394    if data and is_variable_wind_direction(data[0]):
395        for item in data.pop(0).split("V"):
396            value = make_number(item, speak=item, literal=True)
397            if value is not None:
398                variable.append(value)
399    # Convert to Number
400    direction_value = make_number(direction, speak=direction, literal=True)
401    speed_value = make_number(speed.strip("BV"), m_minus=False)
402    gust_value = make_number(gust, m_minus=False)
403    return data, direction_value, speed_value, gust_value, variable

Return the report list, direction string, speed string, gust string, and variable direction list.

def get_visibility( data: list[str], units: avwx.structs.Units) -> tuple[list[str], avwx.structs.Number | None]:
406def get_visibility(data: list[str], units: Units) -> tuple[list[str], Number | None]:
407    """Return the report list and removed visibility string."""
408    visibility = ""
409    if data:
410        item = copy(data[0])
411        # Vis reported in statue miles
412        if item.endswith("SM"):  # 10SM
413            if item[:-2].isdigit():
414                visibility = str(int(item[:-2]))
415            elif "/" in item:
416                visibility = item[: item.find("SM")]  # 1/2SM
417            else:
418                visibility = item[:-2]
419            data.pop(0)
420            units.visibility = "sm"
421        # Vis reported in meters
422        elif len(item) == 4 and item.isdigit():
423            visibility = data.pop(0)
424            units.visibility = "m"
425        elif 7 >= len(item) >= 5 and item[:4].isdigit() and (item[4] in ["M", "N", "S", "E", "W"] or item[4:] == "NDV"):
426            visibility = data.pop(0)[:4]
427            units.visibility = "m"
428        elif len(item) == 5 and item[1:].isdigit() and item[0] in ["M", "P", "B"]:
429            visibility = data.pop(0)[1:]
430            units.visibility = "m"
431        elif item.endswith("KM"):
432            visibility = f"{item[:-2]}000"
433            data.pop(0)
434            units.visibility = "m"
435        # Vis statute miles but split Ex: 2 1/2SM
436        elif len(data) > 1 and data[1].endswith("SM") and "/" in data[1] and item.isdigit():
437            vis1 = data.pop(0)  # 2
438            vis2 = data.pop(0).replace("SM", "")  # 1/2
439            visibility = str(int(vis1) * int(vis2[2]) + int(vis2[0])) + vis2[1:]  # 5/2
440            units.visibility = "sm"
441    return data, make_number(visibility, m_minus=False)

Return the report list and removed visibility string.

def sanitize_cloud(cloud: str) -> str:
444def sanitize_cloud(cloud: str) -> str:
445    """Fix rare cloud layer issues."""
446    if len(cloud) < 4:
447        return cloud
448    if not cloud[3].isdigit() and cloud[3] not in ("/", "-"):
449        # Bad "O": FEWO03 -> FEW003
450        if cloud[3] == "O":
451            cloud = f"{cloud[:3]}0{cloud[4:]}"
452        # Move modifiers to end: BKNC015 -> BKN015C
453        elif cloud[3] != "U" and cloud[:4] not in {"BASE", "UNKN"}:
454            cloud = cloud[:3] + cloud[4:] + cloud[3]
455    return cloud

Fix rare cloud layer issues.

def make_cloud(cloud: str) -> avwx.structs.Cloud:
466def make_cloud(cloud: str) -> Cloud:
467    """Return a Cloud dataclass for a cloud string.
468
469    This function assumes the input is potentially valid.
470    """
471    raw_cloud = cloud
472    cloud_type = ""
473    base: str | None = None
474    top: str | None = None
475    cloud = sanitize_cloud(cloud).replace("/", "")
476    # Separate top
477    for target in _TOP_OFFSETS:
478        topi = cloud.find(target)
479        if topi > -1:
480            top, cloud = cloud[topi + len(target) :], cloud[:topi]
481            break
482    # Separate type
483    ## BASE027
484    if cloud.startswith("BASES"):
485        cloud = cloud[5:]
486    elif cloud.startswith("BASE"):
487        cloud = cloud[4:]
488    ## VV003
489    elif cloud.startswith("VV"):
490        cloud_type, cloud = cloud[:2], cloud[2:]
491    ## FEW010
492    elif len(cloud) >= 3 and cloud[:3] in CLOUD_LIST:
493        cloud_type, cloud = cloud[:3], cloud[3:]
494    ## BKN-OVC065
495    if len(cloud) > 4 and cloud[0] == "-" and cloud[1:4] in CLOUD_LIST:
496        cloud_type += cloud[:4]
497        cloud = cloud[4:]
498    # Separate base
499    if len(cloud) >= 3 and cloud[:3].isdigit():
500        base, cloud = cloud[:3], cloud[3:]
501    elif len(cloud) >= 4 and cloud[:4] == "UNKN":
502        cloud = cloud[4:]
503    # Remainder is considered modifiers
504    modifier = cloud or None
505    # Make Cloud
506    return Cloud(raw_cloud, cloud_type or None, _null_or_int(base), _null_or_int(top), modifier)

Return a Cloud dataclass for a cloud string.

This function assumes the input is potentially valid.

def get_clouds(data: list[str]) -> tuple[list[str], list]:
509def get_clouds(data: list[str]) -> tuple[list[str], list]:
510    """Return the report list and removed list of split cloud layers."""
511    clouds = []
512    for i, item in reversed(list(enumerate(data))):
513        if item[:3] in CLOUD_LIST or item[:2] == "VV":
514            cloud = data.pop(i)
515            clouds.append(make_cloud(cloud))
516    # Attempt cloud sort. Fails if None values are present
517    try:
518        clouds.sort(key=lambda cloud: (cloud.base, cloud.type))
519    except TypeError:
520        clouds.reverse()  # Restores original report order
521    return data, clouds

Return the report list and removed list of split cloud layers.

def get_flight_rules( visibility: avwx.structs.Number | None, ceiling: avwx.structs.Cloud | None) -> int:
524def get_flight_rules(visibility: Number | None, ceiling: Cloud | None) -> int:
525    """Return int based on current flight rules from parsed METAR data.
526
527    0=VFR, 1=MVFR, 2=IFR, 3=LIFR
528
529    Note: Common practice is to report no higher than IFR if visibility unavailable.
530    """
531    # Parse visibility
532    vis: _Numeric
533    if visibility is None:
534        vis = 2
535    elif visibility.repr == "CAVOK" or visibility.repr.startswith("P6"):
536        vis = 10
537    elif visibility.repr.startswith("M"):
538        vis = 0
539    elif visibility.value is None:
540        vis = 2
541    # Convert meters to miles
542    elif len(visibility.repr) == 4:
543        vis = (visibility.value or 0) * 0.000621371
544    else:
545        vis = visibility.value or 0
546    # Parse ceiling
547    cld = (ceiling.base if ceiling else 99) or 99
548    # Determine flight rules
549    if (vis <= 5) or (cld <= 30):
550        if (vis < 3) or (cld < 10):
551            if (vis < 1) or (cld < 5):
552                return 3  # LIFR
553            return 2  # IFR
554        return 1  # MVFR
555    return 0  # VFR

Return int based on current flight rules from parsed METAR data.

0=VFR, 1=MVFR, 2=IFR, 3=LIFR

Note: Common practice is to report no higher than IFR if visibility unavailable.

def get_ceiling(clouds: list[avwx.structs.Cloud]) -> avwx.structs.Cloud | None:
558def get_ceiling(clouds: list[Cloud]) -> Cloud | None:
559    """Return ceiling layer from Cloud-List or None if none found.
560
561    Assumes that the clouds are already sorted lowest to highest.
562
563    Only 'Broken', 'Overcast', and 'Vertical Visibility' are considered ceilings.
564
565    Prevents errors due to lack of cloud information (eg. '' or 'FEW///')
566    """
567    return next((c for c in clouds if c.base and c.type in {"OVC", "BKN", "VV"}), None)

Return ceiling layer from Cloud-List or None if none found.

Assumes that the clouds are already sorted lowest to highest.

Only 'Broken', 'Overcast', and 'Vertical Visibility' are considered ceilings.

Prevents errors due to lack of cloud information (eg. '' or 'FEW///')

def is_altitude(value: str) -> bool:
570def is_altitude(value: str) -> bool:
571    """Return True if the value is a possible altitude."""
572    if len(value) < 5:
573        return False
574    if value.startswith("SFC/"):
575        return True
576    if value.startswith("FL") and value[2:5].isdigit():
577        return True
578    first, *_ = value.split("/")
579    return bool(first[-2:] == "FT" and first[-5:-2].isdigit())

Return True if the value is a possible altitude.

def make_altitude( value: str, units: avwx.structs.Units, repr: str | None = None, *, force_fl: bool = False) -> tuple[avwx.structs.Number | None, avwx.structs.Units]:
582def make_altitude(
583    value: str,
584    units: Units,
585    repr: str | None = None,  # noqa: A002
586    *,
587    force_fl: bool = False,
588) -> tuple[Number | None, Units]:
589    """Convert altitude string into a number."""
590    if not value:
591        return None, units
592    raw = repr or value
593    for end in ("FT", "M"):
594        if value.endswith(end):
595            force_fl = False
596            units.altitude = end.lower()
597            value = value.removesuffix(end)
598    # F430
599    if value[0] == "F" and value[1:].isdigit():
600        value = f"FL{value[1:]}"
601    if force_fl and value[:2] != "FL":
602        value = f"FL{value}"
603    return make_number(value, repr=raw), units

Convert altitude string into a number.

def parse_date( date: str, hour_threshold: int = 200, *, time_only: bool = False, target: datetime.date | None = None) -> datetime.datetime | None:
606def parse_date(
607    date: str,
608    hour_threshold: int = 200,
609    *,
610    time_only: bool = False,
611    target: dt.date | None = None,
612) -> dt.datetime | None:
613    """Parse a report timestamp in ddhhZ or ddhhmmZ format.
614
615    If time_only, assumes hhmm format with current or previous day.
616
617    This function assumes the given timestamp is within the hour threshold from current date.
618    """
619    # Format date string
620    date = date.strip("Z")
621    if not date.isdigit():
622        return None
623    if time_only:
624        if len(date) != 4:
625            return None
626        index_hour = 0
627    else:
628        if len(date) == 4:
629            date += "00"
630        if len(date) != 6:
631            return None
632        index_hour = 2
633    # Create initial guess
634    if target:
635        target = dt.datetime(target.year, target.month, target.day, tzinfo=dt.timezone.utc)
636    else:
637        target = dt.datetime.now(tz=dt.timezone.utc)
638    day = target.day if time_only else int(date[:2])
639    hour = int(date[index_hour : index_hour + 2])
640    # Handle situation where next month has less days than current month
641    # Shifted value makes sure that a month shift doesn't happen twice
642    shifted = False
643    if day > monthrange(target.year, target.month)[1]:
644        target += relativedelta(months=-1)
645        shifted = True
646    try:
647        guess = target.replace(
648            day=day,
649            hour=hour % 24,
650            minute=int(date[index_hour + 2 : index_hour + 4]) % 60,
651            second=0,
652            microsecond=0,
653        )
654    except ValueError:
655        return None
656    # Handle overflow hour
657    if hour > 23:
658        guess += dt.timedelta(days=1)
659    # Handle changing months if not already shifted
660    if not shifted:
661        hourdiff = (guess - target) / dt.timedelta(minutes=1) / 60
662        if hourdiff > hour_threshold:
663            guess += relativedelta(months=-1)
664        elif hourdiff < -hour_threshold:
665            guess += relativedelta(months=+1)
666    return guess

Parse a report timestamp in ddhhZ or ddhhmmZ format.

If time_only, assumes hhmm format with current or previous day.

This function assumes the given timestamp is within the hour threshold from current date.

def make_timestamp( timestamp: str | None, *, time_only: bool = False, target_date: datetime.date | None = None) -> avwx.structs.Timestamp | None:
669def make_timestamp(
670    timestamp: str | None,
671    *,
672    time_only: bool = False,
673    target_date: dt.date | None = None,
674) -> Timestamp | None:
675    """Return a Timestamp dataclass for a report timestamp in ddhhZ or ddhhmmZ format."""
676    if not timestamp:
677        return None
678    date_obj = parse_date(timestamp, time_only=time_only, target=target_date)
679    return Timestamp(timestamp, date_obj)

Return a Timestamp dataclass for a report timestamp in ddhhZ or ddhhmmZ format.

def is_runway_visibility(item: str) -> bool:
682def is_runway_visibility(item: str) -> bool:
683    """Return True if the item is a runway visibility range string."""
684    return (
685        len(item) > 4
686        and item[0] == "R"
687        and (item[3] == "/" or item[4] == "/")
688        and item[1:3].isdigit()
689        and "CLRD" not in item  # R28/CLRD70 Runway State
690    )

Return True if the item is a runway visibility range string.