avwx.current.airsigmet

A SIGMET (Significant Meteorological Information) is a weather advisory for the safety of all aircraft. They are divided into:

  • Convective - thunderstorms, hail, and cyclones
  • Non-Convective - turbulence, icing, dust clouds, volcanic activity, and radiation

An AIRMET (Airman's Meteorological Information) is a weather advisory for smaller aircraft or VFR navigation. They are divided into:

  • Sierra - IFR conditions like low ceilings and mountain obscuration
  • Tango - turbulence and high surface winds
  • Zulu - icing and freezing levels

Both types share a similar report format and therefore are combined into a single handling class. The Bulletin and weather type can be used to classify each as a SIGMET or AIRMET for filtering purposes.

  1"""
  2A SIGMET (Significant Meteorological Information) is a weather advisory for the
  3safety of all aircraft. They are divided into:
  4
  5- Convective - thunderstorms, hail, and cyclones
  6- Non-Convective - turbulence, icing, dust clouds, volcanic activity, and radiation
  7
  8An AIRMET (Airman's Meteorological Information) is a weather advisory for
  9smaller aircraft or VFR navigation. They are divided into:
 10
 11- Sierra - IFR conditions like low ceilings and mountain obscuration
 12- Tango - turbulence and high surface winds
 13- Zulu - icing and freezing levels
 14
 15Both types share a similar report format and therefore are combined into a
 16single handling class. The `Bulletin` and weather type can be used to classify
 17each as a SIGMET or AIRMET for filtering purposes.
 18"""
 19
 20# stdlib
 21from __future__ import annotations
 22
 23import asyncio as aio
 24import re
 25from contextlib import suppress
 26from datetime import date, datetime, timezone
 27from itertools import chain
 28from typing import TypeAlias
 29
 30# library
 31from geopy.distance import distance as geo_distance  # type: ignore
 32
 33# module
 34from avwx import exceptions
 35from avwx.base import AVWXBase
 36from avwx.exceptions import MissingExtraModule
 37from avwx.flight_path import to_coordinates
 38from avwx.load_utils import LazyLoad
 39from avwx.parsing import core
 40from avwx.service.bulk import NoaaBulk, NoaaIntl, Service
 41from avwx.static.airsigmet import BULLETIN_TYPES, INTENSITY, WEATHER_TYPES
 42from avwx.static.core import CARDINAL_DEGREES, CARDINALS
 43from avwx.structs import (
 44    AirSigmetData,
 45    AirSigObservation,
 46    Bulletin,
 47    Code,
 48    Coord,
 49    Movement,
 50    Number,
 51    Timestamp,
 52    Units,
 53)
 54
 55try:
 56    from shapely.geometry import LineString
 57except ModuleNotFoundError:
 58    LineString = TypeAlias  # type: ignore
 59
 60
 61class AirSigmet(AVWXBase):
 62    """
 63    In addition to the manager, you can use the `avwx.AirSigmet` class like any
 64    other report when you supply the report string via `parse` or
 65    `from_report`.
 66
 67    ```python
 68    >>> from avwx import AirSigmet
 69    >>> report = 'WSPR31 SPJC 270529 SPIM SIGMET 3 VALID 270530/270830 SPJC- SPIM LIMA FIR EMBD TS OBS AT 0510Z NE OF LINE S0406 W07103 - S0358 W07225 - S0235 W07432 - S0114 W07503 TOP FL410 MOV SW NC='
 70    >>> sigmet = AirSigmet.from_report(report)
 71    True
 72    >>> sigmet.last_updated
 73    datetime.datetime(2022, 3, 27, 6, 29, 33, 300935, tzinfo=datetime.timezone.utc)
 74    >>> sigmet.data.observation.coords
 75    [Coord(lat=-4.06, lon=-71.03, repr='S0406 W07103'),
 76    Coord(lat=-3.58, lon=-72.25, repr='S0358 W07225'),
 77    Coord(lat=-2.35, lon=-74.32, repr='S0235 W07432'),
 78    Coord(lat=-1.14, lon=-75.03, repr='S0114 W07503')]
 79    >>> sigmet.data.observation.intensity
 80    Code(repr='NC', value='No change')
 81    >>> sigmet.data.observation.ceiling
 82    Number(repr='FL410', value=410, spoken='flight level four one zero')
 83    ```
 84    """
 85
 86    data: AirSigmetData | None = None
 87
 88    def _post_parse(self) -> None:
 89        if self.raw:
 90            self.data, self.units = parse(self.raw, self.issued)
 91
 92    @staticmethod
 93    def sanitize(report: str) -> str:
 94        """Sanitizes the report string"""
 95        return sanitize(report)
 96
 97    def intersects(self, path: LineString) -> bool:
 98        """Returns True if the report area intersects a flight path"""
 99        if LineString is None:
100            extra = "shape"
101            raise MissingExtraModule(extra)
102        if not self.data:
103            return False
104        for data in (self.data.observation, self.data.forecast):
105            if data:
106                poly = data.poly
107                if poly and path.intersects(poly):
108                    return True
109        return False
110
111    def contains(self, coord: Coord) -> bool:
112        """Returns True if the report area contains a coordinate"""
113        if not self.data:
114            return False
115        for data in (self.data.observation, self.data.forecast):
116            if data:
117                poly = data.poly
118                if poly and coord.point.within(poly):
119                    return True
120        return False
121
122
123class AirSigManager:
124    """
125    Because of the global nature of these report types, we don't initialize a
126    report class with a station ident like the other report types. Instead, we
127    use a class to manage and update the list of all active SIGMET and AIRMET
128    reports.
129
130    ```python
131    >>> from avwx import AirSigManager
132    >>> from avwx.structs import Coord
133    >>> manager = AirSigManager()
134    >>> manager.update()
135    True
136    >>> manager.last_updated
137    datetime.datetime(2022, 3, 27, 5, 54, 21, 516741, tzinfo=datetime.timezone.utc)
138    >>> len(manager.reports)
139    113
140    >>> len(manager.contains(Coord(lat=33.12, lon=-105)))
141    5
142    >>> manager.reports[0].data.bulletin.type
143    Code(repr='WA', value='airmet')
144    >>> manager.reports[0].data.type
145    'AIRMET SIERRA FOR IFR AND MTN OBSCN'
146    ```
147    """
148
149    _services: list[Service]
150    _raw: list[tuple[str, str | None]]
151    last_updated: datetime | None = None
152    raw: list[str]
153    reports: list[AirSigmet] | None = None
154
155    def __init__(self):  # type: ignore
156        self._services = [NoaaBulk("airsigmet"), NoaaIntl("airsigmet")]
157        self._raw, self.raw = [], []
158
159    async def _update(self, index: int, timeout: int) -> list[tuple[str, str | None]]:
160        source = self._services[index].root
161        reports = await self._services[index].async_fetch(timeout=timeout)  # type: ignore
162        raw: list[tuple[str, str | None]] = [(report, source) for report in reports if report]
163        return raw
164
165    def update(self, timeout: int = 10, *, disable_post: bool = False) -> bool:
166        """Updates fetched reports and returns whether they've changed"""
167        return aio.run(self.async_update(timeout, disable_post=disable_post))
168
169    async def async_update(self, timeout: int = 10, *, disable_post: bool = False) -> bool:
170        """Updates fetched reports and returns whether they've changed"""
171        coros = [self._update(i, timeout) for i in range(len(self._services))]
172        data = await aio.gather(*coros)
173        raw = list(chain.from_iterable(data))
174        reports = [i[0] for i in raw]
175        if raw == self._raw:
176            return False
177        self._raw, self.raw = raw, reports
178        self.last_updated = datetime.now(tz=timezone.utc)
179        # Parse reports if not disabled
180        if not disable_post:
181            parsed = []
182            for report, source in raw:
183                try:
184                    if obj := AirSigmet.from_report(report):
185                        obj.source = source
186                        parsed.append(obj)
187                except Exception as exc:  # noqa: BLE001
188                    exceptions.exception_intercept(exc, raw={"report": report})
189            self.reports = parsed
190        return True
191
192    def along(self, coords: list[Coord]) -> list[AirSigmet]:
193        """Returns available reports the intersect a flight path"""
194        if LineString is None:
195            extra = "shape"
196            raise MissingExtraModule(extra)
197        if self.reports is None:
198            return []
199        path = LineString([c.pair for c in coords])
200        return [r for r in self.reports if r.intersects(path)]
201
202    def contains(self, coord: Coord) -> list[AirSigmet]:
203        """Returns available reports that contain a coordinate"""
204        if self.reports is None:
205            return []
206        return [r for r in self.reports if r.contains(coord)]
207
208
209# N1429 W09053 - N1427 W09052 - N1411 W09139 - N1417 W09141
210_COORD_PATTERN = re.compile(r"\b[NS]\d{4} [EW]\d{5}\b( -)?")
211
212# FROM 60NW ISN-INL-TVC-GIJ-UIN-FSD-BIL-60NW ISN
213# FROM 70SSW ISN TO 20NNW FAR TO 70E DLH TO 40SE EAU TO 80SE RAP TO 40NNW BFF TO 70SSW
214_NAVAID_PATTERN = re.compile(r"\b(\d{1,3}[NESW]{1,3} [A-z]{3}-?\b)|((-|(TO )|(FROM ))[A-z]{3}\b)")
215
216# N OF N2050 AND S OF N2900
217_LATTERAL_PATTERN = re.compile(r"\b([NS] OF [NS]\d{2,4})|([EW] OF [EW]\d{3,5})( AND)?\b")
218
219NAVAIDS = LazyLoad("navaids")
220
221# Used to assist parsing after sanitized. Removed after parse
222_FLAGS = {
223    "...": " <elip> ",
224    "..": " <elip> ",
225    ". ": " <break> ",
226    "/VIS ": " <vis> VIS ",
227}
228
229
230def _parse_prep(report: str) -> list[str]:
231    """Prepares sanitized string by replacing elements with flags"""
232    report = report.rstrip(".")
233    for key, val in _FLAGS.items():
234        report = report.replace(key, val)
235    return report.split()
236
237
238def _clean_flags(data: list[str]) -> list[str]:
239    return [i for i in data if i[0] != "<"]
240
241
242def _bulletin(value: str) -> Bulletin:
243    # if len(value) != 6:
244    #     return None
245    type_key = value[:2]
246    return Bulletin(
247        repr=value,
248        type=Code(repr=type_key, value=BULLETIN_TYPES[type_key]),
249        country=value[2:4],
250        number=int(value[4:]),
251    )
252
253
254def _header(data: list[str]) -> tuple[list[str], Bulletin, str, str, str | None]:
255    bulletin = _bulletin(data[0])
256    correction, end = (data[3], 4) if len(data[3]) == 3 else (None, 3)
257    return data[end:], bulletin, data[1], data[2], correction
258
259
260def _spacetime(
261    data: list[str],
262) -> tuple[list[str], str, str, str | None, str, str | None]:
263    area = data.pop(0)
264    # Skip airmet type + time repeat
265    if data[0] == "WA" and data[1].isdigit():
266        data = data[2:]
267        area = area[:-1]  # Remove type label from 3-letter ident
268    valid_index = data.index("VALID")
269    report_type = " ".join(data[:valid_index])
270    data = data[valid_index + 1 :]
271    if data[0] == "UNTIL":
272        start_time = None
273        end_time = data[1]
274        data = data[2:]
275    else:
276        target = "-" if "-" in data[0] else "/"
277        start_time, end_time = data.pop(0).split(target)
278    # KMCO- ORL FIR
279    if data[0][-1] == "-":
280        station = data.pop(0)[:-1]
281    # KMCO - KMCO
282    elif data[1] == "-" and len(data[0]) == 4:
283        station = data.pop(0)
284        data.pop(0)
285    else:
286        station = None
287    return data, area, report_type, start_time, end_time, station
288
289
290def _first_index(data: list[str], *targets: str) -> int:
291    for target in targets:
292        with suppress(ValueError):
293            return data.index(target)
294    return -1
295
296
297def _region(data: list[str]) -> tuple[list[str], str]:
298    # FIR/CTA region name
299    # Or non-standard name using lookahead Ex: FL CSTL WTRS FROM 100SSW
300    name_end = _first_index(data, "FIR", "CTA") + 1 or _first_index(data, "FROM")
301    # State list
302    if not name_end:
303        for item in data:
304            if len(item) == 2:
305                name_end += 1
306            else:
307                break
308    name = " ".join(data[:name_end])
309    return data[name_end:], name
310
311
312def _time(data: list[str], issued: date | None = None) -> tuple[list[str], Timestamp | None, Timestamp | None]:
313    """Extracts the start and/or end time based on a couple starting elements"""
314    index = _first_index(data, "AT", "FCST", "UNTIL", "VALID", "OUTLOOK", "OTLK")
315    if index == -1:
316        return data, None, None
317    start_item = data.pop(index)
318    start, end, observed = None, None, None
319    if "-" in data[index]:
320        start_item, end_item = data.pop(index).split("-")
321        start = core.make_timestamp(start_item, time_only=len(start_item) < 6, target_date=issued)
322        end = core.make_timestamp(end_item, time_only=len(end_item) < 6, target_date=issued)
323    elif len(data[index]) >= 4 and data[index][:4].isdigit():
324        observed = core.make_timestamp(data.pop(index), time_only=True, target_date=issued)
325        if index > 0 and data[index - 1] == "OBS":
326            data.pop(index - 1)
327    for remv in ("FCST", "OUTLOOK", "OTLK", "VALID"):
328        with suppress(ValueError):
329            data.remove(remv)
330    if observed:
331        if start_item in ("UNTIL", "VALID"):
332            end = observed
333        else:
334            start = observed
335    return data, start, end
336
337
338def _coord_value(value: str) -> float:
339    if value[0] in ("N", "S"):
340        index, strip, replace = 3, "N", "S"
341    else:
342        index, strip, replace = 4, "E", "W"
343    num = f"{value[:index]}.{value[index:]}".lstrip(strip).replace(replace, "-")
344    return float(num)
345
346
347def _position(data: list[str]) -> tuple[list[str], Coord | None]:
348    try:
349        index = data.index("PSN")
350    except ValueError:
351        return data, None
352    data.pop(index)
353    raw = f"{data[index]} {data[index + 1]}"
354    lat = _coord_value(data.pop(index))
355    lon = _coord_value(data.pop(index))
356    return data, Coord(lat=lat, lon=lon, repr=raw)
357
358
359def _movement(data: list[str], units: Units) -> tuple[list[str], Units, Movement | None]:
360    with suppress(ValueError):
361        data.remove("STNR")
362        speed = core.make_number("STNR")
363        return data, units, Movement(repr="STNR", direction=None, speed=speed)
364    try:
365        index = data.index("MOV")
366    except ValueError:
367        return data, units, None
368    raw = data.pop(index)
369    direction_str = data.pop(index)
370    # MOV CNL
371    if direction_str == "CNL":
372        return data, units, None
373    # MOV SLOW ESE / MOV SLOW "SLOW" is a speed qualifier, not a direction
374    if direction_str == "SLOW":
375        raw += f" {direction_str}"
376        if index < len(data):
377            direction_str = data.pop(index)
378            raw += f" {direction_str}"
379        direction = core.make_number(direction_str.replace("/", ""), literal=True, special=CARDINAL_DEGREES)
380        return data, units, Movement(repr=raw.strip(), direction=direction, speed=None)
381    raw += f" {direction_str} "
382    # MOV FROM 23040KT
383    if direction_str == "FROM":
384        value = data[index][:3]
385        raw += value
386        direction = core.make_number(value)
387        data[index] = data[index][3:]
388    # MOV E 45KMH
389    else:
390        direction = core.make_number(direction_str.replace("/", ""), literal=True, special=CARDINAL_DEGREES)
391    speed = None
392    with suppress(IndexError):
393        kt_unit, kmh_unit = data[index].endswith("KT"), data[index].endswith("KMH")
394        if kt_unit or kmh_unit:
395            units.wind_speed = "kmh" if kmh_unit else "kt"
396            speed_str = data.pop(index)
397            raw += speed_str
398            # Remove bottom speed Ex: MOV W 05-10KT
399            if "-" in speed_str:
400                speed_str = speed_str[speed_str.find("-") + 1 :]
401            speed = core.make_number(speed_str[: -3 if kmh_unit else -2])
402    return data, units, Movement(repr=raw.strip(), direction=direction, speed=speed)
403
404
405def _info_from_match(match: re.Match, start: int) -> tuple[str, int]:
406    """Returns the matching text and starting location if none yet available"""
407    if start == -1:
408        start = match.start()
409    return match.group(), start
410
411
412def _pre_break(report: str) -> str:
413    break_index = report.find(" <break> ")
414    return report[:break_index] if break_index != -1 else report
415
416
417def _bounds_from_latterals(report: str, start: int) -> tuple[str, list[str], int]:
418    """Extract coordinate latterals from report Ex: N OF N2050"""
419    bounds = []
420    for match in _LATTERAL_PATTERN.finditer(_pre_break(report)):
421        group, start = _info_from_match(match, start)
422        bounds.append(group.removesuffix(" AND"))
423        report = report.replace(group, " ")
424    return report, bounds, start
425
426
427def _coords_from_text(report: str, start: int) -> tuple[str, list[Coord], int]:
428    """Extract raw coordinate values from report Ex: N4409 E01506"""
429    coords = []
430    for match in _COORD_PATTERN.finditer(_pre_break(report)):
431        group, start = _info_from_match(match, start)
432        text = group.strip(" -")
433        lat, lon = text.split()
434        coord = Coord(lat=_coord_value(lat), lon=_coord_value(lon), repr=text)
435        coords.append(coord)
436        report = report.replace(group, " ")
437    return report, coords, start
438
439
440def _coords_from_navaids(report: str, start: int) -> tuple[str, list[Coord], int]:
441    """Extract navaid referenced coordinates from report Ex: 30SSW BNA"""
442    coords, navs = [], []
443    for match in _NAVAID_PATTERN.finditer(_pre_break(report)):
444        group, start = _info_from_match(match, start)
445        report = report.replace(group, " ")
446        group = group.strip("-").removeprefix("FROM ").removeprefix("TO ")
447        navs.append((group, *group.split()))
448    locs = to_coordinates([n[2 if len(n) == 3 else 1] for n in navs])
449    for i, nav in enumerate(navs):
450        value = nav[0]
451        if len(nav) == 3:
452            vector, num_index = nav[1], 0
453            while vector[num_index].isdigit():
454                num_index += 1
455            distance, bearing = (
456                int(vector[:num_index]),
457                CARDINAL_DEGREES[vector[num_index:]],
458            )
459            loc = geo_distance(nautical=distance).destination(locs[i].pair, bearing=bearing)
460            coord = Coord(lat=loc.latitude, lon=loc.longitude, repr=value)
461        else:
462            coord = locs[i]
463            coord.repr = value
464        coords.append(coord)
465    return report, coords, start
466
467
468def _bounds(data: list[str]) -> tuple[list[str], list[Coord], list[str]]:
469    """Extract coordinate bounds by coord, navaid, and latterals"""
470    report, start = " ".join(data), -1
471    report, bounds, start = _bounds_from_latterals(report, start)
472    report, coords, start = _coords_from_text(report, start)
473    report, navs, start = _coords_from_navaids(report, start)
474    coords += navs
475    for target in ("FROM", "WI", "BOUNDED", "OBS"):
476        index = report.find(f"{target} ")
477        if index != -1 and index < start:
478            start = index
479    report = report[:start] + report[report.rfind("  ") :]
480    data = [s for s in report.split() if s]
481    return data, coords, bounds
482
483
484def _altitudes(data: list[str], units: Units) -> tuple[list[str], Units, Number | None, Number | None]:
485    """Extract the floor and ceiling altitudes"""
486    floor, ceiling = None, None
487    for i, item in enumerate(data):
488        # BTN FL180 AND FL330
489        if item == "BTN" and len(data) > i + 2 and data[i + 2] == "AND":
490            floor, units = core.make_altitude(data[i + 1], units)
491            ceiling, units = core.make_altitude(data[i + 3], units)
492            data = data[:i] + data[i + 4 :]
493            break
494        # TOPS ABV FL450
495        if item in ("TOP", "TOPS", "BLW"):
496            if data[i + 1] == "ABV":
497                ceiling = core.make_number(f"ABV {data[i + 2]}")
498                data = data[:i] + data[i + 3 :]
499                break
500            if data[i + 1] == "BLW":
501                ceiling = core.make_number(f"BLW {data[i + 2]}")
502                data = data[:i] + data[i + 3 :]
503                break
504            # TOPS TO FL310
505            if data[i + 1] == "TO":
506                data.pop(i)
507            ceiling, units = core.make_altitude(data[i + 1], units)
508            data = data[:i] + data[i + 2 :]
509            # CIG BLW 010
510            if data[i - 1] == "CIG":
511                data.pop(i - 1)
512            break
513        # FL060/300 SFC/FL160
514        if core.is_altitude(item):
515            if "/" in item:
516                floor_val, ceiling_val = item.split("/")
517                floor, units = core.make_altitude(floor_val, units)
518                if (floor_val == "SFC" or floor_val[:2] == "FL") and ceiling_val[:2] != "FL":
519                    ceiling, units = core.make_altitude(ceiling_val, units, force_fl=True)
520                else:
521                    ceiling, units = core.make_altitude(ceiling_val, units)
522            else:
523                ceiling, units = core.make_altitude(item, units)
524            data.pop(i)
525            break
526    return data, units, floor, ceiling
527
528
529def _weather_type(data: list[str]) -> tuple[list[str], Code | None]:
530    weather = None
531    report = " ".join(data)
532    for key, val in WEATHER_TYPES.items():
533        if key in report:
534            weather = Code(repr=key, value=val)
535            data = [i for i in report.replace(key, "").split() if i]
536            break
537    return data, weather
538
539
540def _intensity(data: list[str]) -> tuple[list[str], Code | None]:
541    if not data:
542        return data, None
543    try:
544        value = INTENSITY[data[-1]]
545        code = data.pop()
546        return data, Code(repr=code, value=value)
547    except KeyError:
548        return data, None
549
550
551def _sigmet_observation(data: list[str], units: Units, issued: date | None = None) -> tuple[AirSigObservation, Units]:
552    data, start_time, end_time = _time(data, issued)
553    data, position = _position(data)
554    data, coords, bounds = _bounds(data)
555    data, units, movement = _movement(data, units)
556    data, intensity = _intensity(data)
557    data, units, floor, ceiling = _altitudes(data, units)
558    data, weather = _weather_type(data)
559    struct = AirSigObservation(
560        type=weather,
561        start_time=start_time,
562        end_time=end_time,
563        position=position,
564        floor=floor,
565        ceiling=ceiling,
566        coords=coords,
567        bounds=bounds,
568        movement=movement,
569        intensity=intensity,
570        other=_clean_flags(data),
571    )
572    return struct, units
573
574
575def _observations(
576    data: list[str], units: Units, issued: date | None = None
577) -> tuple[Units, AirSigObservation | None, AirSigObservation | None]:
578    observation, forecast, forecast_index = None, None, -1
579    forecast_index = _first_index(data, "FCST", "OUTLOOK", "OTLK")
580    if forecast_index == -1:
581        observation, units = _sigmet_observation(data, units, issued)
582    # 6 is arbitrary. Will likely change or be more precise later
583    elif forecast_index < 6:
584        forecast, units = _sigmet_observation(data, units, issued)
585    else:
586        observation, units = _sigmet_observation(data[:forecast_index], units, issued)
587        forecast, units = _sigmet_observation(data[forecast_index:], units, issued)
588    return units, observation, forecast
589
590
591_REPLACE = {
592    " MO V ": " MOV ",
593    " STNRY": " STNR",
594    " STCNRY": " STNR",
595    " N-NE ": " NNE ",
596    " N-NW ": " NNW ",
597    " E-NE ": " ENE ",
598    " E-SE ": " ESE ",
599    " S-SE ": " SSE ",
600    " S-SW ": " SSW ",
601    " W-SW ": " WSW ",
602    " W-NW ": " WNW ",
603}
604
605
606def _find_first_digit(item: str) -> int:
607    return next((i for i, char in enumerate(item) if char.isdigit()), -1)
608
609
610def sanitize(report: str) -> str:
611    """Sanitized AIRMET / SIGMET report string"""
612    report = report.strip(" =")
613    for key, val in _REPLACE.items():
614        report = report.replace(key, val)
615    data = report.split()
616    for i, item in reversed(list(enumerate(data))):
617        # Remove extra element on altitude Ex: FL450Z skip 1000FT
618        if (
619            len(item) > 4
620            and not item[-1].isdigit()
621            and item[-2:] != "FT"
622            and item[-1] != "M"
623            and core.is_altitude(item[:-1])
624        ):
625            data[i] = item[:-1]
626        # Split attached movement direction Ex: NE05KT
627        if len(item) >= 4 and item.endswith(("KT", "KMH")) and item[: _find_first_digit(item)] in CARDINALS:
628            index = _find_first_digit(item)
629            direction = item[:index]
630            data.insert(i + 1, item[index:])
631            data[i] = direction
632    return " ".join(data)
633
634
635def parse(report: str, issued: date | None = None) -> tuple[AirSigmetData, Units]:
636    """Parse AIRMET / SIGMET report string"""
637    units = Units.international()
638    sanitized = sanitize(report)
639    data, bulletin, issuer, time, correction = _header(_parse_prep(sanitized))
640    data, area, report_type, start_time, end_time, station = _spacetime(data)
641    body = sanitized[sanitized.find(" ".join(data[:2])) :]
642    # Trim AIRMET type
643    if data[0] == "AIRMET":
644        with suppress(ValueError):
645            data = data[data.index("<elip>") + 1 :]
646    data, region = _region(data)
647    units, observation, forecast = _observations(data, units, issued)
648    struct = AirSigmetData(
649        raw=report,
650        sanitized=sanitized,
651        station=station,
652        time=core.make_timestamp(time, target_date=issued),
653        remarks=None,
654        bulletin=bulletin,
655        issuer=issuer,
656        correction=correction,
657        area=area,
658        type=report_type,
659        start_time=core.make_timestamp(start_time, target_date=issued),
660        end_time=core.make_timestamp(end_time, target_date=issued),
661        body=body,
662        region=region,
663        observation=observation,
664        forecast=forecast,
665    )
666    return struct, units
class AirSigmet(avwx.base.AVWXBase):
 62class AirSigmet(AVWXBase):
 63    """
 64    In addition to the manager, you can use the `avwx.AirSigmet` class like any
 65    other report when you supply the report string via `parse` or
 66    `from_report`.
 67
 68    ```python
 69    >>> from avwx import AirSigmet
 70    >>> report = 'WSPR31 SPJC 270529 SPIM SIGMET 3 VALID 270530/270830 SPJC- SPIM LIMA FIR EMBD TS OBS AT 0510Z NE OF LINE S0406 W07103 - S0358 W07225 - S0235 W07432 - S0114 W07503 TOP FL410 MOV SW NC='
 71    >>> sigmet = AirSigmet.from_report(report)
 72    True
 73    >>> sigmet.last_updated
 74    datetime.datetime(2022, 3, 27, 6, 29, 33, 300935, tzinfo=datetime.timezone.utc)
 75    >>> sigmet.data.observation.coords
 76    [Coord(lat=-4.06, lon=-71.03, repr='S0406 W07103'),
 77    Coord(lat=-3.58, lon=-72.25, repr='S0358 W07225'),
 78    Coord(lat=-2.35, lon=-74.32, repr='S0235 W07432'),
 79    Coord(lat=-1.14, lon=-75.03, repr='S0114 W07503')]
 80    >>> sigmet.data.observation.intensity
 81    Code(repr='NC', value='No change')
 82    >>> sigmet.data.observation.ceiling
 83    Number(repr='FL410', value=410, spoken='flight level four one zero')
 84    ```
 85    """
 86
 87    data: AirSigmetData | None = None
 88
 89    def _post_parse(self) -> None:
 90        if self.raw:
 91            self.data, self.units = parse(self.raw, self.issued)
 92
 93    @staticmethod
 94    def sanitize(report: str) -> str:
 95        """Sanitizes the report string"""
 96        return sanitize(report)
 97
 98    def intersects(self, path: LineString) -> bool:
 99        """Returns True if the report area intersects a flight path"""
100        if LineString is None:
101            extra = "shape"
102            raise MissingExtraModule(extra)
103        if not self.data:
104            return False
105        for data in (self.data.observation, self.data.forecast):
106            if data:
107                poly = data.poly
108                if poly and path.intersects(poly):
109                    return True
110        return False
111
112    def contains(self, coord: Coord) -> bool:
113        """Returns True if the report area contains a coordinate"""
114        if not self.data:
115            return False
116        for data in (self.data.observation, self.data.forecast):
117            if data:
118                poly = data.poly
119                if poly and coord.point.within(poly):
120                    return True
121        return False

In addition to the manager, you can use the avwx.AirSigmet class like any other report when you supply the report string via parse or from_report.

>>> from avwx import AirSigmet
>>> report = 'WSPR31 SPJC 270529 SPIM SIGMET 3 VALID 270530/270830 SPJC- SPIM LIMA FIR EMBD TS OBS AT 0510Z NE OF LINE S0406 W07103 - S0358 W07225 - S0235 W07432 - S0114 W07503 TOP FL410 MOV SW NC='
>>> sigmet = AirSigmet.from_report(report)
True
>>> sigmet.last_updated
datetime.datetime(2022, 3, 27, 6, 29, 33, 300935, tzinfo=datetime.timezone.utc)
>>> sigmet.data.observation.coords
[Coord(lat=-4.06, lon=-71.03, repr='S0406 W07103'),
Coord(lat=-3.58, lon=-72.25, repr='S0358 W07225'),
Coord(lat=-2.35, lon=-74.32, repr='S0235 W07432'),
Coord(lat=-1.14, lon=-75.03, repr='S0114 W07503')]
>>> sigmet.data.observation.intensity
Code(repr='NC', value='No change')
>>> sigmet.data.observation.ceiling
Number(repr='FL410', value=410, spoken='flight level four one zero')
data: avwx.structs.AirSigmetData | None = None
@staticmethod
def sanitize(report: str) -> str:
93    @staticmethod
94    def sanitize(report: str) -> str:
95        """Sanitizes the report string"""
96        return sanitize(report)

Sanitizes the report string

def intersects(self, path: shapely.geometry.linestring.LineString) -> bool:
 98    def intersects(self, path: LineString) -> bool:
 99        """Returns True if the report area intersects a flight path"""
100        if LineString is None:
101            extra = "shape"
102            raise MissingExtraModule(extra)
103        if not self.data:
104            return False
105        for data in (self.data.observation, self.data.forecast):
106            if data:
107                poly = data.poly
108                if poly and path.intersects(poly):
109                    return True
110        return False

Returns True if the report area intersects a flight path

def contains(self, coord: avwx.structs.Coord) -> bool:
112    def contains(self, coord: Coord) -> bool:
113        """Returns True if the report area contains a coordinate"""
114        if not self.data:
115            return False
116        for data in (self.data.observation, self.data.forecast):
117            if data:
118                poly = data.poly
119                if poly and coord.point.within(poly):
120                    return True
121        return False

Returns True if the report area contains a coordinate

class AirSigManager:
124class AirSigManager:
125    """
126    Because of the global nature of these report types, we don't initialize a
127    report class with a station ident like the other report types. Instead, we
128    use a class to manage and update the list of all active SIGMET and AIRMET
129    reports.
130
131    ```python
132    >>> from avwx import AirSigManager
133    >>> from avwx.structs import Coord
134    >>> manager = AirSigManager()
135    >>> manager.update()
136    True
137    >>> manager.last_updated
138    datetime.datetime(2022, 3, 27, 5, 54, 21, 516741, tzinfo=datetime.timezone.utc)
139    >>> len(manager.reports)
140    113
141    >>> len(manager.contains(Coord(lat=33.12, lon=-105)))
142    5
143    >>> manager.reports[0].data.bulletin.type
144    Code(repr='WA', value='airmet')
145    >>> manager.reports[0].data.type
146    'AIRMET SIERRA FOR IFR AND MTN OBSCN'
147    ```
148    """
149
150    _services: list[Service]
151    _raw: list[tuple[str, str | None]]
152    last_updated: datetime | None = None
153    raw: list[str]
154    reports: list[AirSigmet] | None = None
155
156    def __init__(self):  # type: ignore
157        self._services = [NoaaBulk("airsigmet"), NoaaIntl("airsigmet")]
158        self._raw, self.raw = [], []
159
160    async def _update(self, index: int, timeout: int) -> list[tuple[str, str | None]]:
161        source = self._services[index].root
162        reports = await self._services[index].async_fetch(timeout=timeout)  # type: ignore
163        raw: list[tuple[str, str | None]] = [(report, source) for report in reports if report]
164        return raw
165
166    def update(self, timeout: int = 10, *, disable_post: bool = False) -> bool:
167        """Updates fetched reports and returns whether they've changed"""
168        return aio.run(self.async_update(timeout, disable_post=disable_post))
169
170    async def async_update(self, timeout: int = 10, *, disable_post: bool = False) -> bool:
171        """Updates fetched reports and returns whether they've changed"""
172        coros = [self._update(i, timeout) for i in range(len(self._services))]
173        data = await aio.gather(*coros)
174        raw = list(chain.from_iterable(data))
175        reports = [i[0] for i in raw]
176        if raw == self._raw:
177            return False
178        self._raw, self.raw = raw, reports
179        self.last_updated = datetime.now(tz=timezone.utc)
180        # Parse reports if not disabled
181        if not disable_post:
182            parsed = []
183            for report, source in raw:
184                try:
185                    if obj := AirSigmet.from_report(report):
186                        obj.source = source
187                        parsed.append(obj)
188                except Exception as exc:  # noqa: BLE001
189                    exceptions.exception_intercept(exc, raw={"report": report})
190            self.reports = parsed
191        return True
192
193    def along(self, coords: list[Coord]) -> list[AirSigmet]:
194        """Returns available reports the intersect a flight path"""
195        if LineString is None:
196            extra = "shape"
197            raise MissingExtraModule(extra)
198        if self.reports is None:
199            return []
200        path = LineString([c.pair for c in coords])
201        return [r for r in self.reports if r.intersects(path)]
202
203    def contains(self, coord: Coord) -> list[AirSigmet]:
204        """Returns available reports that contain a coordinate"""
205        if self.reports is None:
206            return []
207        return [r for r in self.reports if r.contains(coord)]

Because of the global nature of these report types, we don't initialize a report class with a station ident like the other report types. Instead, we use a class to manage and update the list of all active SIGMET and AIRMET reports.

>>> from avwx import AirSigManager
>>> from avwx.structs import Coord
>>> manager = AirSigManager()
>>> manager.update()
True
>>> manager.last_updated
datetime.datetime(2022, 3, 27, 5, 54, 21, 516741, tzinfo=datetime.timezone.utc)
>>> len(manager.reports)
113
>>> len(manager.contains(Coord(lat=33.12, lon=-105)))
5
>>> manager.reports[0].data.bulletin.type
Code(repr='WA', value='airmet')
>>> manager.reports[0].data.type
'AIRMET SIERRA FOR IFR AND MTN OBSCN'
last_updated: datetime.datetime | None = None
raw: list[str]
reports: list[AirSigmet] | None = None
def update(self, timeout: int = 10, *, disable_post: bool = False) -> bool:
166    def update(self, timeout: int = 10, *, disable_post: bool = False) -> bool:
167        """Updates fetched reports and returns whether they've changed"""
168        return aio.run(self.async_update(timeout, disable_post=disable_post))

Updates fetched reports and returns whether they've changed

async def async_update(self, timeout: int = 10, *, disable_post: bool = False) -> bool:
170    async def async_update(self, timeout: int = 10, *, disable_post: bool = False) -> bool:
171        """Updates fetched reports and returns whether they've changed"""
172        coros = [self._update(i, timeout) for i in range(len(self._services))]
173        data = await aio.gather(*coros)
174        raw = list(chain.from_iterable(data))
175        reports = [i[0] for i in raw]
176        if raw == self._raw:
177            return False
178        self._raw, self.raw = raw, reports
179        self.last_updated = datetime.now(tz=timezone.utc)
180        # Parse reports if not disabled
181        if not disable_post:
182            parsed = []
183            for report, source in raw:
184                try:
185                    if obj := AirSigmet.from_report(report):
186                        obj.source = source
187                        parsed.append(obj)
188                except Exception as exc:  # noqa: BLE001
189                    exceptions.exception_intercept(exc, raw={"report": report})
190            self.reports = parsed
191        return True

Updates fetched reports and returns whether they've changed

def along( self, coords: list[avwx.structs.Coord]) -> list[AirSigmet]:
193    def along(self, coords: list[Coord]) -> list[AirSigmet]:
194        """Returns available reports the intersect a flight path"""
195        if LineString is None:
196            extra = "shape"
197            raise MissingExtraModule(extra)
198        if self.reports is None:
199            return []
200        path = LineString([c.pair for c in coords])
201        return [r for r in self.reports if r.intersects(path)]

Returns available reports the intersect a flight path

def contains( self, coord: avwx.structs.Coord) -> list[AirSigmet]:
203    def contains(self, coord: Coord) -> list[AirSigmet]:
204        """Returns available reports that contain a coordinate"""
205        if self.reports is None:
206            return []
207        return [r for r in self.reports if r.contains(coord)]

Returns available reports that contain a coordinate

def sanitize(report: str) -> str:
611def sanitize(report: str) -> str:
612    """Sanitized AIRMET / SIGMET report string"""
613    report = report.strip(" =")
614    for key, val in _REPLACE.items():
615        report = report.replace(key, val)
616    data = report.split()
617    for i, item in reversed(list(enumerate(data))):
618        # Remove extra element on altitude Ex: FL450Z skip 1000FT
619        if (
620            len(item) > 4
621            and not item[-1].isdigit()
622            and item[-2:] != "FT"
623            and item[-1] != "M"
624            and core.is_altitude(item[:-1])
625        ):
626            data[i] = item[:-1]
627        # Split attached movement direction Ex: NE05KT
628        if len(item) >= 4 and item.endswith(("KT", "KMH")) and item[: _find_first_digit(item)] in CARDINALS:
629            index = _find_first_digit(item)
630            direction = item[:index]
631            data.insert(i + 1, item[index:])
632            data[i] = direction
633    return " ".join(data)

Sanitized AIRMET / SIGMET report string

def parse( report: str, issued: datetime.date | None = None) -> tuple[avwx.structs.AirSigmetData, avwx.structs.Units]:
636def parse(report: str, issued: date | None = None) -> tuple[AirSigmetData, Units]:
637    """Parse AIRMET / SIGMET report string"""
638    units = Units.international()
639    sanitized = sanitize(report)
640    data, bulletin, issuer, time, correction = _header(_parse_prep(sanitized))
641    data, area, report_type, start_time, end_time, station = _spacetime(data)
642    body = sanitized[sanitized.find(" ".join(data[:2])) :]
643    # Trim AIRMET type
644    if data[0] == "AIRMET":
645        with suppress(ValueError):
646            data = data[data.index("<elip>") + 1 :]
647    data, region = _region(data)
648    units, observation, forecast = _observations(data, units, issued)
649    struct = AirSigmetData(
650        raw=report,
651        sanitized=sanitized,
652        station=station,
653        time=core.make_timestamp(time, target_date=issued),
654        remarks=None,
655        bulletin=bulletin,
656        issuer=issuer,
657        correction=correction,
658        area=area,
659        type=report_type,
660        start_time=core.make_timestamp(start_time, target_date=issued),
661        end_time=core.make_timestamp(end_time, target_date=issued),
662        body=body,
663        region=region,
664        observation=observation,
665        forecast=forecast,
666    )
667    return struct, units

Parse AIRMET / SIGMET report string