avwx.current.notam
A NOTAM (Notice to Air Missions) is a report detailing special events or conditions affecting airport and flight operations. These can include, but are in no way limitted to:
- Runway closures
- Lack of radar services
- Rocket launches
- Hazard locations
- Airspace restrictions
- Construction updates
- Unusual aircraft activity
NOTAMs have varius classifications and apply to certain types or size of aircraft. Some apply only to IFR operations, like when an ILS is out of service. Others apply only to airport operations the en route aircraft can ignore.
Every NOTAM has a start and end date and time. Additional NOTAMs may be issued to update, replace, or cancel existing NOTAMs as well. Some NOTAMs may still be served up to 10 days after the end date, so it's up to the developer to include or filter these reports.
1""" 2A NOTAM (Notice to Air Missions) is a report detailing special events or 3conditions affecting airport and flight operations. These can include, but are 4in no way limitted to: 5 6- Runway closures 7- Lack of radar services 8- Rocket launches 9- Hazard locations 10- Airspace restrictions 11- Construction updates 12- Unusual aircraft activity 13 14NOTAMs have varius classifications and apply to certain types or size of 15aircraft. Some apply only to IFR operations, like when an ILS is out of 16service. Others apply only to airport operations the en route aircraft can 17ignore. 18 19Every NOTAM has a start and end date and time. Additional NOTAMs may be issued 20to update, replace, or cancel existing NOTAMs as well. Some NOTAMs may still be 21served up to 10 days after the end date, so it's up to the developer to include 22or filter these reports. 23""" 24 25# stdlib 26from __future__ import annotations 27 28import re 29from contextlib import suppress 30from datetime import datetime, timezone 31 32# library 33from dateutil.tz import gettz 34 35# module 36from avwx import exceptions 37from avwx.current.base import Reports 38from avwx.parsing import core 39 40# from avwx.service import FaaNotam 41from avwx.static.core import SPECIAL_NUMBERS 42from avwx.static.notam import ( 43 CODES, 44 CONDITION, 45 PURPOSE, 46 REPORT_TYPE, 47 SCOPE, 48 SUBJECT, 49 TRAFFIC_TYPE, 50) 51from avwx.structs import ( 52 Altitude, 53 Code, 54 Coord, 55 NotamData, 56 Number, 57 Qualifiers, 58 Timestamp, 59 Units, 60) 61 62# https://www.navcanada.ca/en/briefing-on-the-transition-to-icao-notam-format.pdf 63# https://www.faa.gov/air_traffic/flight_info/aeronav/notams/media/2021-09-07_ICAO_NOTAM_101_Presentation_for_Airport_Operators.pdf 64 65 66_DEP_MSG = "This method is temporarily deprecated until non-auth source can be found." 67 68 69class Notams(Reports): 70 ''' 71 The Notams class provides two ways of requesting all applicable NOTAMs in 72 an area: airport code and coordinate. The service will fetch all reports 73 within 10 nautical miles of the desired center point. 74 75 *Update methods are temporarily deprecated until non-auth source can be found.* 76 You can change the distance by updating the `Notams.radius` member before calling `update()`. 77 78 ```python 79 >>> from pprint import pprint 80 >>> from avwx import Notams 81 >>> from avwx.structs import Coord 82 >>> 83 >>> kjfk = Notams("KJFK") 84 >>> kjfk.update() 85 True 86 >>> kjfk.last_updated 87 datetime.datetime(2022, 5, 26, 0, 43, 22, 44753, tzinfo=datetime.timezone.utc) 88 >>> print(kjfk.data[0].raw) 89 01/113 NOTAMN 90 Q) ZNY/QMXLC/IV/NBO/A/000/999/4038N07346W005 91 A) KJFK 92 B) 2101081328 93 C) 2209301100 94 95 E) TWY TB BTN TERMINAL 8 RAMP AND TWY A CLSD 96 >>> pprint(kjfk.data[0].qualifiers) 97 Qualifiers(repr='ZNY/QMXLC/IV/NBO/A/000/999/4038N07346W005', 98 fir='ZNY', 99 subject=Code(repr='MX', value='Taxiway'), 100 condition=Code(repr='LC', value='Closed'), 101 traffic=Code(repr='IV', value='IFR and VFR'), 102 purpose=[Code(repr='N', value='Immediate'), 103 Code(repr='B', value='Briefing'), 104 Code(repr='O', value='Flight Operations')], 105 scope=[Code(repr='A', value='Aerodrome')], 106 lower=Number(repr='000', value=0, spoken='zero'), 107 upper=Number(repr='999', value=999, spoken='nine nine nine'), 108 coord=Coord(lat=40.38, lon=-73.46, repr='4038N07346W'), 109 radius=Number(repr='005', value=5, spoken='five')) 110 >>> 111 >>> coord = Notams(coord=Coord(lat=52, lon=-0.23)) 112 >>> coord.update() 113 True 114 >>> coord.data[0].station 115 'EGSS' 116 >>> print(coord.data[0].body) 117 LONDON STANSTED ATC SURVEILLANCE MINIMUM ALTITUDE CHART - IN 118 FREQUENCY BOX RENAME ESSEX RADAR TO STANSTED RADAR. 119 UK AIP AD 2.EGSS-5-1 REFERS 120 ``` 121 122 The `parse` and `from_report` methods can parse a report string if you want 123 to override the normal fetching process. 124 125 ```python 126 >>> from avwx import Notams 127 >>> report = """ 128 05/295 NOTAMR 129 Q) ZNY/QMNHW/IV/NBO/A/000/999/4038N07346W005 130 A) KJFK 131 B) 2205201527 132 C) 2205271100 133 134 E) APRON TERMINAL 4 RAMP CONST WIP S SIDE TAXILANE G LGTD AND BARRICADED 135 """ 136 >>> kjfk = Notams.from_report(report) 137 >>> kjfk.data[0].type 138 Code(repr='NOTAMR', value='Replace') 139 >>> kjfk.data[0].start_time 140 Timestamp(repr='2205201527', dt=datetime.datetime(2022, 5, 20, 15, 27, tzinfo=datetime.timezone.utc)) 141 ``` 142 ''' 143 144 data: list[NotamData] | None = None # type: ignore 145 radius: int = 10 146 147 def __init__(self, code: str | None = None, coord: Coord | None = None): 148 super().__init__(code, coord) 149 # self.service = FaaNotam("notam") 150 151 async def _post_update(self) -> None: 152 self._post_parse() 153 154 def _post_parse(self) -> None: 155 self.data, units = [], None 156 if self.raw is None: 157 return 158 for report in self.raw: 159 if "||" in report: 160 issue_text, report = report.split("||") # noqa: PLW2901 161 issued_value = datetime.strptime(issue_text, r"%m/%d/%Y %H%M").replace(tzinfo=timezone.utc) 162 issued = Timestamp(issue_text, issued_value) 163 else: 164 issued = None 165 try: 166 data, units = parse(report, issued=issued) 167 self.data.append(data) 168 except Exception as exc: # noqa: BLE001 169 exceptions.exception_intercept(exc, raw=report) # type: ignore 170 if units: 171 self.units = units 172 173 @staticmethod 174 def sanitize(report: str) -> str: 175 """Sanitize a NOTAM string.""" 176 return sanitize(report) 177 178 # @deprecated(_DEP_MSG) 179 def update(self, timeout: int = 10, *, disable_post: bool = False) -> bool: 180 raise NotImplementedError(_DEP_MSG) 181 182 # @deprecated(_DEP_MSG) 183 async def async_update(self, timeout: int = 10, *, disable_post: bool = False) -> bool: 184 """Async updates report data by fetching and parsing the report.""" 185 raise NotImplementedError(_DEP_MSG) 186 # reports = await self.service.async_fetch( # type: ignore 187 # icao=self.code, coord=self.coord, radius=self.radius, timeout=timeout 188 # ) 189 # self.source = self.service.root 190 # return await self._update(reports, None, disable_post=disable_post) 191 192 193ALL_KEYS_PATTERN = re.compile(r"\b[A-GQ]\) ") 194MISSING_KEY_SPACE_PATTERN = re.compile(r"(^|\s)([A-GQ])\)(?=\S)") 195# "FL150" and the shorthand "F150" both name a flight level 196FLIGHT_LEVEL_PATTERN = re.compile(r"^FL?\d+$") 197KEY_PATTERNS = { 198 "Q": re.compile(r"\b[A-G]\) "), 199 "A": re.compile(r"\b[B-G]\) "), 200 "B": re.compile(r"\b[C-G]\) "), 201 "C": re.compile(r"\b[D-G]\) "), 202 "D": re.compile(r"\b[E-G]\) "), 203 "E": re.compile(r"\b[FG]\) "), 204 "F": re.compile(r"\bG\) "), 205 # No "G" 206} 207 208 209def _rear_coord(value: str) -> Coord | None: 210 """Convert coord strings with direction characters at the end: 5126N00036W.""" 211 if len(value) != 11: 212 return None 213 try: 214 lat = float(f"{value[:2]}.{value[2:4]}") 215 lon = float(f"{value[5:8]}.{value[8:10]}") 216 except ValueError: 217 return None 218 if value[4] == "S": 219 lat *= -1 220 if value[10] == "W": 221 lon *= -1 222 return Coord(lat, lon, value) 223 224 225def _split_location( 226 location: str | None, 227) -> tuple[Coord | None, Number | None]: 228 """Identify coordinate and radius from location element.""" 229 if not location: 230 return None, None 231 coord, radius = None, None 232 if len(location) == 14 and location[-3:].isdigit(): 233 radius = core.make_number(location[-3:]) 234 location = location[:-3] 235 if len(location) == 11 and location[-1] in {"E", "W"}: 236 coord = _rear_coord(location) 237 return coord, radius 238 239 240def _header(value: str) -> tuple[str, Code | None, str | None]: 241 """Parse pre-tag headers.""" 242 header = value.strip().split() 243 replaces = None 244 if len(header) == 3: 245 number, type_text, replaces = header 246 else: 247 number, type_text = header 248 report_type = Code.from_dict(type_text, REPORT_TYPE) 249 return number, report_type, replaces 250 251 252def _find_q_codes( 253 codes: list[str], 254) -> tuple[ 255 Code | None, 256 list[Code], 257 list[Code], 258 str | None, 259 str | None, 260 str | None, 261]: 262 """Identify traffic, purpose, and scope codes.""" 263 # The 'K' code can be both purpose and scope, but they have the same value 264 traffic, lower, upper, location = None, None, None, None 265 purpose: list[Code] = [] 266 scope: list[Code] = [] 267 for code in codes: 268 if not code: 269 continue 270 # Altitudes can be int or float values 271 with suppress(ValueError): 272 float(code) 273 if not lower: 274 lower = code 275 else: 276 upper = code 277 continue 278 # location will always be the longest element if available 279 if len(code) > 10: 280 location = code 281 continue 282 # Remaining elements must match known value dictionary combinations 283 if not traffic and code in TRAFFIC_TYPE: 284 traffic = Code.from_dict(code, TRAFFIC_TYPE) 285 continue 286 if not purpose: 287 purpose = Code.from_list(code, PURPOSE, exclusive=True) 288 if not scope: 289 scope = Code.from_list(code, SCOPE, exclusive=True) 290 return traffic, purpose, scope, lower, upper, location 291 292 293def _qualifiers(value: str, units: Units) -> Qualifiers: 294 """Parse the NOTAM Q) line into components.""" 295 fir, q_code, *codes = (i.strip() for i in re.split("/| ", value.strip())) 296 traffic, purpose, scope, lower, upper, location = _find_q_codes(codes) 297 subject, condition = None, None 298 if q_code.startswith("Q") and len(q_code) >= 5: 299 subject = Code(q_code[1:3], "Other") if q_code[1] == "Q" else Code.from_dict(q_code[1:3], SUBJECT) 300 condition_code = q_code[3:] 301 if condition_code.startswith("XX"): 302 condition = Code("XX", (condition_code[2:] or "Unknown").strip()) 303 else: 304 condition = Code.from_dict(condition_code, CONDITION, error=False) 305 coord, radius = _split_location(location) 306 return Qualifiers( 307 repr=value, 308 fir=fir, 309 subject=subject, 310 condition=condition, 311 traffic=traffic, 312 purpose=purpose, 313 scope=scope, 314 lower=make_altitude(lower, units), 315 upper=make_altitude(upper, units), 316 coord=coord, 317 radius=radius, 318 ) 319 320 321def _tz_offset_for(name: str | None) -> timezone | None: 322 """Generate a timezone from tz string name.""" 323 if not name: 324 return None 325 if tz := gettz(name): # noqa: SIM102 326 if offset := tz.utcoffset(datetime.now(timezone.utc)): 327 return timezone(offset) 328 return None 329 330 331def make_year_timestamp( 332 value: str, 333 repr: str, # noqa: A002 334 tzname: str | None = None, 335) -> Timestamp | Code | None: 336 """Convert NOTAM timestamp which includes year and month.""" 337 values = value.strip().split() 338 if not values: 339 return None 340 value = values[0] 341 if code := CODES.get(value): 342 return Code(value, code) 343 tz = _tz_offset_for(tzname) or timezone.utc 344 raw = datetime.strptime(value[:10], r"%y%m%d%H%M") # noqa: DTZ007 345 date = datetime(raw.year, raw.month, raw.day, raw.hour, raw.minute, tzinfo=tz) 346 return Timestamp(repr, date) 347 348 349def parse_linked_times(start: str, end: str) -> tuple[Timestamp | Code | None, Timestamp | Code | None]: 350 """Parse start and end times sharing any found timezone.""" 351 start, end = start.strip(), end.strip() 352 start_raw, end_raw, tzname = start, end, None 353 if len(start) > 10: 354 start, tzname = start[:-3], start[-3:] 355 if len(end) > 10: 356 end, tzname = end[:-3], end[-3:] 357 return make_year_timestamp(start, start_raw, tzname), make_year_timestamp(end, end_raw, tzname) 358 359 360def make_altitude(value: str | None, units: Units) -> Altitude | None: 361 """Parse NOTAM altitudes. 362 363 A value is only a flight level when it says so, ie "FL150" or "F150" in a G) line. 364 365 ICAO Annex 15 defines the Q) line limits as flight levels, but they are not treated 366 as such here. Producers fill them with the F) and G) values rounded up to the next 367 hundred feet against those items' own datum, which is AMSL far more often than it is 368 a pressure altitude, and the spec's own "000"/"999" default means the subject carries 369 no height information at all rather than FL000 to FL999. 370 """ 371 if not value: 372 return None 373 trimmed = value.split()[0].strip(" .") 374 if not trimmed: 375 return None 376 if "(" in trimmed: 377 trimmed = trimmed[trimmed.find("(") + 1 :] 378 is_flight_level = FLIGHT_LEVEL_PATTERN.match(trimmed) is not None 379 if not (is_flight_level or trimmed in SPECIAL_NUMBERS or trimmed[0].isdigit()): 380 return None 381 number = core.make_altitude(trimmed, units, repr=value)[0] 382 if number is None: 383 return None 384 return Altitude( 385 repr=number.repr, 386 value=number.value, 387 spoken=number.spoken, 388 flight_level=is_flight_level, 389 ) 390 391 392def parse(report: str, issued: Timestamp | None = None) -> tuple[NotamData, Units]: 393 """Parse NOTAM report string.""" 394 units = Units.international() 395 sanitized = sanitize(report) 396 qualifiers, station, start_time, end_time = None, None, None, None 397 body, number, replaces, report_type = "", None, None, None 398 schedule, lower, upper, text = None, None, None, sanitized 399 match = ALL_KEYS_PATTERN.search(text) 400 # Type and number here 401 if match and match.start() > 0: 402 number, report_type, replaces = _header(text[: match.start()]) 403 start_text, end_text = "", "" 404 while match: 405 tag = match.group()[0] 406 text = text[match.end() :] 407 try: 408 match = KEY_PATTERNS[tag].search(text) 409 except KeyError: 410 match = None 411 item = (text[: match.start()] if match else text).strip() 412 if tag == "Q": 413 qualifiers = _qualifiers(item, units) 414 elif tag == "A": 415 station = item 416 elif tag == "B": 417 start_text = item 418 elif tag == "C": 419 end_text = item 420 elif tag == "D": 421 schedule = item 422 elif tag == "E": 423 body = item 424 elif tag == "F": 425 lower = make_altitude(item, units) 426 elif tag == "G": 427 upper = make_altitude(item, units) 428 start_time, end_time = parse_linked_times(start_text, end_text) 429 return ( 430 NotamData( 431 raw=report, 432 sanitized=sanitized, 433 station=station, 434 time=issued, 435 remarks=None, 436 number=number, 437 replaces=replaces, 438 type=report_type, 439 qualifiers=qualifiers, 440 start_time=start_time, 441 end_time=end_time, 442 schedule=schedule, 443 body=body, 444 lower=lower, 445 upper=upper, 446 ), 447 units, 448 ) 449 450 451def sanitize(report: str) -> str: 452 """Retun a sanitized report ready for parsing.""" 453 report = report.replace("\r", "").strip() 454 # Some sources omit the space after a key, ie "E)TWY CLSD" instead of "E) TWY CLSD", 455 # which the key patterns above require. Only repair a key that starts a line or 456 # follows whitespace so keys quoted inside body text are left alone. 457 return MISSING_KEY_SPACE_PATTERN.sub(r"\1\2) ", report)
70class Notams(Reports): 71 ''' 72 The Notams class provides two ways of requesting all applicable NOTAMs in 73 an area: airport code and coordinate. The service will fetch all reports 74 within 10 nautical miles of the desired center point. 75 76 *Update methods are temporarily deprecated until non-auth source can be found.* 77 You can change the distance by updating the `Notams.radius` member before calling `update()`. 78 79 ```python 80 >>> from pprint import pprint 81 >>> from avwx import Notams 82 >>> from avwx.structs import Coord 83 >>> 84 >>> kjfk = Notams("KJFK") 85 >>> kjfk.update() 86 True 87 >>> kjfk.last_updated 88 datetime.datetime(2022, 5, 26, 0, 43, 22, 44753, tzinfo=datetime.timezone.utc) 89 >>> print(kjfk.data[0].raw) 90 01/113 NOTAMN 91 Q) ZNY/QMXLC/IV/NBO/A/000/999/4038N07346W005 92 A) KJFK 93 B) 2101081328 94 C) 2209301100 95 96 E) TWY TB BTN TERMINAL 8 RAMP AND TWY A CLSD 97 >>> pprint(kjfk.data[0].qualifiers) 98 Qualifiers(repr='ZNY/QMXLC/IV/NBO/A/000/999/4038N07346W005', 99 fir='ZNY', 100 subject=Code(repr='MX', value='Taxiway'), 101 condition=Code(repr='LC', value='Closed'), 102 traffic=Code(repr='IV', value='IFR and VFR'), 103 purpose=[Code(repr='N', value='Immediate'), 104 Code(repr='B', value='Briefing'), 105 Code(repr='O', value='Flight Operations')], 106 scope=[Code(repr='A', value='Aerodrome')], 107 lower=Number(repr='000', value=0, spoken='zero'), 108 upper=Number(repr='999', value=999, spoken='nine nine nine'), 109 coord=Coord(lat=40.38, lon=-73.46, repr='4038N07346W'), 110 radius=Number(repr='005', value=5, spoken='five')) 111 >>> 112 >>> coord = Notams(coord=Coord(lat=52, lon=-0.23)) 113 >>> coord.update() 114 True 115 >>> coord.data[0].station 116 'EGSS' 117 >>> print(coord.data[0].body) 118 LONDON STANSTED ATC SURVEILLANCE MINIMUM ALTITUDE CHART - IN 119 FREQUENCY BOX RENAME ESSEX RADAR TO STANSTED RADAR. 120 UK AIP AD 2.EGSS-5-1 REFERS 121 ``` 122 123 The `parse` and `from_report` methods can parse a report string if you want 124 to override the normal fetching process. 125 126 ```python 127 >>> from avwx import Notams 128 >>> report = """ 129 05/295 NOTAMR 130 Q) ZNY/QMNHW/IV/NBO/A/000/999/4038N07346W005 131 A) KJFK 132 B) 2205201527 133 C) 2205271100 134 135 E) APRON TERMINAL 4 RAMP CONST WIP S SIDE TAXILANE G LGTD AND BARRICADED 136 """ 137 >>> kjfk = Notams.from_report(report) 138 >>> kjfk.data[0].type 139 Code(repr='NOTAMR', value='Replace') 140 >>> kjfk.data[0].start_time 141 Timestamp(repr='2205201527', dt=datetime.datetime(2022, 5, 20, 15, 27, tzinfo=datetime.timezone.utc)) 142 ``` 143 ''' 144 145 data: list[NotamData] | None = None # type: ignore 146 radius: int = 10 147 148 def __init__(self, code: str | None = None, coord: Coord | None = None): 149 super().__init__(code, coord) 150 # self.service = FaaNotam("notam") 151 152 async def _post_update(self) -> None: 153 self._post_parse() 154 155 def _post_parse(self) -> None: 156 self.data, units = [], None 157 if self.raw is None: 158 return 159 for report in self.raw: 160 if "||" in report: 161 issue_text, report = report.split("||") # noqa: PLW2901 162 issued_value = datetime.strptime(issue_text, r"%m/%d/%Y %H%M").replace(tzinfo=timezone.utc) 163 issued = Timestamp(issue_text, issued_value) 164 else: 165 issued = None 166 try: 167 data, units = parse(report, issued=issued) 168 self.data.append(data) 169 except Exception as exc: # noqa: BLE001 170 exceptions.exception_intercept(exc, raw=report) # type: ignore 171 if units: 172 self.units = units 173 174 @staticmethod 175 def sanitize(report: str) -> str: 176 """Sanitize a NOTAM string.""" 177 return sanitize(report) 178 179 # @deprecated(_DEP_MSG) 180 def update(self, timeout: int = 10, *, disable_post: bool = False) -> bool: 181 raise NotImplementedError(_DEP_MSG) 182 183 # @deprecated(_DEP_MSG) 184 async def async_update(self, timeout: int = 10, *, disable_post: bool = False) -> bool: 185 """Async updates report data by fetching and parsing the report.""" 186 raise NotImplementedError(_DEP_MSG) 187 # reports = await self.service.async_fetch( # type: ignore 188 # icao=self.code, coord=self.coord, radius=self.radius, timeout=timeout 189 # ) 190 # self.source = self.service.root 191 # return await self._update(reports, None, disable_post=disable_post)
The Notams class provides two ways of requesting all applicable NOTAMs in an area: airport code and coordinate. The service will fetch all reports within 10 nautical miles of the desired center point.
Update methods are temporarily deprecated until non-auth source can be found.
You can change the distance by updating the Notams.radius member before calling update().
>>> from pprint import pprint
>>> from avwx import Notams
>>> from avwx.structs import Coord
>>>
>>> kjfk = Notams("KJFK")
>>> kjfk.update()
True
>>> kjfk.last_updated
datetime.datetime(2022, 5, 26, 0, 43, 22, 44753, tzinfo=datetime.timezone.utc)
>>> print(kjfk.data[0].raw)
01/113 NOTAMN
Q) ZNY/QMXLC/IV/NBO/A/000/999/4038N07346W005
A) KJFK
B) 2101081328
C) 2209301100
E) TWY TB BTN TERMINAL 8 RAMP AND TWY A CLSD
>>> pprint(kjfk.data[0].qualifiers)
Qualifiers(repr='ZNY/QMXLC/IV/NBO/A/000/999/4038N07346W005',
fir='ZNY',
subject=Code(repr='MX', value='Taxiway'),
condition=Code(repr='LC', value='Closed'),
traffic=Code(repr='IV', value='IFR and VFR'),
purpose=[Code(repr='N', value='Immediate'),
Code(repr='B', value='Briefing'),
Code(repr='O', value='Flight Operations')],
scope=[Code(repr='A', value='Aerodrome')],
lower=Number(repr='000', value=0, spoken='zero'),
upper=Number(repr='999', value=999, spoken='nine nine nine'),
coord=Coord(lat=40.38, lon=-73.46, repr='4038N07346W'),
radius=Number(repr='005', value=5, spoken='five'))
>>>
>>> coord = Notams(coord=Coord(lat=52, lon=-0.23))
>>> coord.update()
True
>>> coord.data[0].station
'EGSS'
>>> print(coord.data[0].body)
LONDON STANSTED ATC SURVEILLANCE MINIMUM ALTITUDE CHART - IN
FREQUENCY BOX RENAME ESSEX RADAR TO STANSTED RADAR.
UK AIP AD 2.EGSS-5-1 REFERS
The parse and from_report methods can parse a report string if you want
to override the normal fetching process.
>>> from avwx import Notams
>>> report = """
05/295 NOTAMR
Q) ZNY/QMNHW/IV/NBO/A/000/999/4038N07346W005
A) KJFK
B) 2205201527
C) 2205271100
E) APRON TERMINAL 4 RAMP CONST WIP S SIDE TAXILANE G LGTD AND BARRICADED
"""
>>> kjfk = Notams.from_report(report)
>>> kjfk.data[0].type
Code(repr='NOTAMR', value='Replace')
>>> kjfk.data[0].start_time
Timestamp(repr='2205201527', dt=datetime.datetime(2022, 5, 20, 15, 27, tzinfo=datetime.timezone.utc))
174 @staticmethod 175 def sanitize(report: str) -> str: 176 """Sanitize a NOTAM string.""" 177 return sanitize(report)
Sanitize a NOTAM string.
180 def update(self, timeout: int = 10, *, disable_post: bool = False) -> bool: 181 raise NotImplementedError(_DEP_MSG)
Update report data by fetching and parsing the report.
Returns True if new reports are available, else False
184 async def async_update(self, timeout: int = 10, *, disable_post: bool = False) -> bool: 185 """Async updates report data by fetching and parsing the report.""" 186 raise NotImplementedError(_DEP_MSG) 187 # reports = await self.service.async_fetch( # type: ignore 188 # icao=self.code, coord=self.coord, radius=self.radius, timeout=timeout 189 # ) 190 # self.source = self.service.root 191 # return await self._update(reports, None, disable_post=disable_post)
Async updates report data by fetching and parsing the report.
332def make_year_timestamp( 333 value: str, 334 repr: str, # noqa: A002 335 tzname: str | None = None, 336) -> Timestamp | Code | None: 337 """Convert NOTAM timestamp which includes year and month.""" 338 values = value.strip().split() 339 if not values: 340 return None 341 value = values[0] 342 if code := CODES.get(value): 343 return Code(value, code) 344 tz = _tz_offset_for(tzname) or timezone.utc 345 raw = datetime.strptime(value[:10], r"%y%m%d%H%M") # noqa: DTZ007 346 date = datetime(raw.year, raw.month, raw.day, raw.hour, raw.minute, tzinfo=tz) 347 return Timestamp(repr, date)
Convert NOTAM timestamp which includes year and month.
350def parse_linked_times(start: str, end: str) -> tuple[Timestamp | Code | None, Timestamp | Code | None]: 351 """Parse start and end times sharing any found timezone.""" 352 start, end = start.strip(), end.strip() 353 start_raw, end_raw, tzname = start, end, None 354 if len(start) > 10: 355 start, tzname = start[:-3], start[-3:] 356 if len(end) > 10: 357 end, tzname = end[:-3], end[-3:] 358 return make_year_timestamp(start, start_raw, tzname), make_year_timestamp(end, end_raw, tzname)
Parse start and end times sharing any found timezone.
361def make_altitude(value: str | None, units: Units) -> Altitude | None: 362 """Parse NOTAM altitudes. 363 364 A value is only a flight level when it says so, ie "FL150" or "F150" in a G) line. 365 366 ICAO Annex 15 defines the Q) line limits as flight levels, but they are not treated 367 as such here. Producers fill them with the F) and G) values rounded up to the next 368 hundred feet against those items' own datum, which is AMSL far more often than it is 369 a pressure altitude, and the spec's own "000"/"999" default means the subject carries 370 no height information at all rather than FL000 to FL999. 371 """ 372 if not value: 373 return None 374 trimmed = value.split()[0].strip(" .") 375 if not trimmed: 376 return None 377 if "(" in trimmed: 378 trimmed = trimmed[trimmed.find("(") + 1 :] 379 is_flight_level = FLIGHT_LEVEL_PATTERN.match(trimmed) is not None 380 if not (is_flight_level or trimmed in SPECIAL_NUMBERS or trimmed[0].isdigit()): 381 return None 382 number = core.make_altitude(trimmed, units, repr=value)[0] 383 if number is None: 384 return None 385 return Altitude( 386 repr=number.repr, 387 value=number.value, 388 spoken=number.spoken, 389 flight_level=is_flight_level, 390 )
Parse NOTAM altitudes.
A value is only a flight level when it says so, ie "FL150" or "F150" in a G) line.
ICAO Annex 15 defines the Q) line limits as flight levels, but they are not treated as such here. Producers fill them with the F) and G) values rounded up to the next hundred feet against those items' own datum, which is AMSL far more often than it is a pressure altitude, and the spec's own "000"/"999" default means the subject carries no height information at all rather than FL000 to FL999.
393def parse(report: str, issued: Timestamp | None = None) -> tuple[NotamData, Units]: 394 """Parse NOTAM report string.""" 395 units = Units.international() 396 sanitized = sanitize(report) 397 qualifiers, station, start_time, end_time = None, None, None, None 398 body, number, replaces, report_type = "", None, None, None 399 schedule, lower, upper, text = None, None, None, sanitized 400 match = ALL_KEYS_PATTERN.search(text) 401 # Type and number here 402 if match and match.start() > 0: 403 number, report_type, replaces = _header(text[: match.start()]) 404 start_text, end_text = "", "" 405 while match: 406 tag = match.group()[0] 407 text = text[match.end() :] 408 try: 409 match = KEY_PATTERNS[tag].search(text) 410 except KeyError: 411 match = None 412 item = (text[: match.start()] if match else text).strip() 413 if tag == "Q": 414 qualifiers = _qualifiers(item, units) 415 elif tag == "A": 416 station = item 417 elif tag == "B": 418 start_text = item 419 elif tag == "C": 420 end_text = item 421 elif tag == "D": 422 schedule = item 423 elif tag == "E": 424 body = item 425 elif tag == "F": 426 lower = make_altitude(item, units) 427 elif tag == "G": 428 upper = make_altitude(item, units) 429 start_time, end_time = parse_linked_times(start_text, end_text) 430 return ( 431 NotamData( 432 raw=report, 433 sanitized=sanitized, 434 station=station, 435 time=issued, 436 remarks=None, 437 number=number, 438 replaces=replaces, 439 type=report_type, 440 qualifiers=qualifiers, 441 start_time=start_time, 442 end_time=end_time, 443 schedule=schedule, 444 body=body, 445 lower=lower, 446 upper=upper, 447 ), 448 units, 449 )
Parse NOTAM report string.
452def sanitize(report: str) -> str: 453 """Retun a sanitized report ready for parsing.""" 454 report = report.replace("\r", "").strip() 455 # Some sources omit the space after a key, ie "E)TWY CLSD" instead of "E) TWY CLSD", 456 # which the key patterns above require. Only repair a key that starts a line or 457 # follows whitespace so keys quoted inside body text are left alone. 458 return MISSING_KEY_SPACE_PATTERN.sub(r"\1\2) ", report)
Retun a sanitized report ready for parsing.