├── NEWS ├── MANIFEST.in ├── lib └── pytaf │ ├── __init__.py │ ├── taf.py │ └── tafdecoder.py ├── .gitignore ├── examples └── demo.py ├── setup.py ├── COPYING └── README /NEWS: -------------------------------------------------------------------------------- 1 | None at this time 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include COPYING 2 | include NEWS 3 | include examples/* 4 | -------------------------------------------------------------------------------- /lib/pytaf/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | from .taf import TAF, MalformedTAF 3 | from .tafdecoder import Decoder, DecodeError 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled python modules. 2 | *.pyc 3 | 4 | # Setuptools distribution folder. 5 | /dist/ 6 | 7 | # Python egg metadata, regenerated from source files by setuptools. 8 | /*.egg-info 9 | -------------------------------------------------------------------------------- /examples/demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import pytaf 4 | 5 | taf_str = """ 6 | TAF AMD KDEN 291134Z 2912/3018 32006KT 1/4SM FG OVC001 7 | TEMPO 2914/2915 1SM -BR CLR 8 | FM291500 04006KT P6SM SKC 9 | TEMPO 2915/2917 2SM BR OVC008 10 | FM291900 05007KT P6SM SCT050 BKN090 WS010/13040KT 11 | PROB30 2921/3001 VRB20G30KT -TSRA BKN050CB 12 | FM300100 31007KT P6SM SCT070 BKN120 +FC 13 | FM300500 23006KT P6SM SCT120 $ 14 | """ 15 | 16 | # Create a parsed TAF object from string 17 | t = pytaf.TAF(taf_str) 18 | 19 | # Create a decoder object from the TAF object 20 | d = pytaf.Decoder(t) 21 | 22 | # Print the raw string for the reference 23 | print(taf_str) 24 | 25 | # Decode and print the decoded string 26 | dec = d.decode_taf() 27 | print(dec) 28 | 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup(name='pytaf', 5 | version='1.2.1', 6 | description='TAF (Terminal Aerodrome Forecast) and METAR parser and decoder', 7 | url='http://github.com/dmbaturin/pytaf', 8 | author='Daniil Baturin', 9 | author_email='daniil@baturin.org', 10 | license='MIT', 11 | package_dir={'': 'lib'}, 12 | packages=['pytaf'], 13 | zip_safe=True, 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python :: 2.6", 19 | "Programming Language :: Python :: 2.7", 20 | "Programming Language :: Python :: 3", 21 | "Topic :: Scientific/Engineering" 22 | ], 23 | keywords="aviation weather meteorology taf metar" 24 | ) 25 | 26 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Daniil Baturin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | *pytaf is python module for parsing and decoding aviation weather forecasts* 2 | 3 | TAF 4 | --- 5 | 6 | TAF stands for "Terminal Aerodrome Forecast". It's the weather 7 | forecast reporting format used in aviation. 8 | 9 | Unlike "normal" weather forecasts for everyday use, TAF is issued for no more than 10 | next 24-30 hours, and includes information critical for flight safety, such as 11 | exact clouds type and ceiling. 12 | 13 | 14 | This is what a TAF report from the United States may look like: 15 | 16 | :: 17 | 18 | TAF 19 | AMD KMKE 172034Z 1721/1824 14013G19KT P6SM SCT028 BKN035 BKN250 20 | FM180100 17008KT P6SM SCT035 BKN120 21 | FM181000 17007KT P6SM VCSH BKN040 OVC080 22 | TEMPO 1811/1815 6SM -TSRA BR BKN030CB 23 | FM181500 18009KT P6SM VCSH BKN050 24 | FM182100 16012KT P6SM SCT050 BKN150 25 | 26 | What it means: 27 | 28 | :: 29 | 30 | "TAF AMD KMKE": TAF amended for General Mitchell International Airport, 31 | 32 | "172034Z": Issued at 20:34 Zulu time (UTC) on [September the] 17th 33 | 34 | "1721/1824": Valid from 21:00 UTC on 17th to 24:00 UTC on 18th 35 | 36 | "14013G19KT": Wind from 140 degrees at 13 knots, gusting to 19 knots 37 | 38 | "P6SM": Visibility more than 6 statute miles 39 | 40 | "SCT028 BKN035 BKN250": Clouds scattered at 2800 (28*100) feet, broken at 3500 feet, broken at 25000 feet 41 | 42 | "FM180100 ...": From 01:00 UTC on 18th [wind, visibility, clouds] 43 | 44 | "FM181000 17007KT P6SM VCSH BKN040 OVC080": From 10:00 UTC on 18th [wind, visibility], 45 | showers in the vicinity, clouds broken at 4000, overcast at 8000 46 | 47 | "TEMPO 1811/1815 6SM -TSRA BR BKN030CB": Temporarily between 11:00 UTC and 15:00 UTC on 18th 48 | visibility 6 statute miles, light thunderstorms and rain, mist, broken cumulonimbus clouds at 3000 49 | 50 | ... 51 | 52 | **Note:** despite the terse format, TAF reports are 53 | written by humans, and are supposed to be interpreted by humans too. 54 | 55 | This means the format (from computer point of view) is loosely 56 | standardized and is not guaranteed to be machine readable. 57 | 58 | Moreover, TAF format is not exactly the same in all countries. 59 | Not different enough for people to have problems understanding it, 60 | but different enough to make implementing a universal parser 61 | very hard, if not impossible. 62 | 63 | United Stated civil airports produce the most machine friendly and 64 | standardized reports and those are the most likely to be interpreted correctly. 65 | Effort was made to interpret European Union civil airport reports 66 | properly, but they exhibit more regional variations, so the interpretation 67 | may be incomplete. 68 | Remember that the interpretation is provided for information purposes only 69 | and should not be used for flight planning (at least not without inspecting 70 | the original undecoded report). 71 | 72 | Information about the US TAF format can be found at NOAA website: 73 | https://aviationweather.gov/taf/decoder 74 | 75 | You can get raw and interpreted reports from there too: 76 | http://www.aviationweather.gov/adds/tafs/ 77 | 78 | 79 | API 80 | --- 81 | 82 | pytaf contains two base classes: pytaf.TAF and pytaf.Decoder 83 | 84 | The constructor of the TAF class takes a string, takes it apart, and stores raw values in a TAF object. 85 | 86 | The Decoder is initialized with a TAF object and provides decode() method that returns a string that contains 87 | a human-readable interpretation. 88 | 89 | :: 90 | 91 | import pytaf 92 | 93 | taf = pytaf.TAF("") 94 | decoder = pytaf.Decoder(taf) 95 | print(decoder.decode_taf()) 96 | 97 | This is what a decoded string may look like: 98 | 99 | :: 100 | 101 | TAF for KSFO issued 05:46 UTC on the 20th, valid from 06:00 UTC on the 20th to 12:00 UTC on the 21st 102 | Wind: variable at 04 knots 103 | Visibility: more than 6 statute miles 104 | Sky conditions: few clouds at 1000 feet, scattered clouds at 1500 feet, broken clouds at 20000 feet 105 | 106 | 107 | Hacking 108 | ------- 109 | 110 | If you want to change the decoder output format (e.g. output to HTML), 111 | inherit from pytaf.Decoder and overload the _decode_taf() method. 112 | That method contains nothing but calls to other methods and output 113 | string formatting. 114 | 115 | The Decoder class provides methods for decoding every type of weather information independently, 116 | so you can easily combine them to wrap that information in any desirable format. 117 | 118 | If you want to redefine the interpretation, e.g. produce numeric values 119 | for display in a widget rather than plain english descriptions, you may want to use the TAF object directly. 120 | All its methods return dicts with pretty straightforward key names. 121 | -------------------------------------------------------------------------------- /lib/pytaf/taf.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | class MalformedTAF(Exception): 4 | def __init__(self, msg): 5 | self.strerror = msg 6 | 7 | class TAF(object): 8 | """ TAF "envelope" parser """ 9 | 10 | def __init__(self, string): 11 | """ 12 | Initializes the object with TAF/METAR report text. 13 | 14 | Args: 15 | string: TAF/METAR report string 16 | 17 | Raises: 18 | MalformedTAF: An error parsing the TAF/METAR report 19 | """ 20 | 21 | # Instance variables 22 | self._raw_taf = None 23 | self._taf_header = None 24 | self._raw_weather_groups = [] 25 | self._weather_groups = [] 26 | self._maintenance = None 27 | 28 | if isinstance(string, str) and string != "": 29 | self._raw_taf = string 30 | else: 31 | raise MalformedTAF("TAF/METAR string expected") 32 | 33 | # Patterns use ^ and $, so we don't want 34 | # leading/trailing spaces 35 | self._raw_taf = self._raw_taf.strip() 36 | 37 | # Initialize header part 38 | self._taf_header = self._init_header(self._raw_taf) 39 | 40 | if self._taf_header['form'] == 'metar': 41 | self._weather_groups.append(self._parse_group(self._raw_taf)) 42 | else: 43 | # Get all TAF weather groups 44 | self._raw_weather_groups = self._init_groups(self._raw_taf) 45 | 46 | for group in self._raw_weather_groups: 47 | parsed_group = self._parse_group(group) 48 | self._weather_groups.append(parsed_group) 49 | 50 | self._maintenance = self._parse_maintenance(self._raw_taf) 51 | 52 | def _init_header(self, string): 53 | """ Extracts header part from TAF/METAR string and populates header dict 54 | 55 | Args: 56 | TAF/METAR report string 57 | 58 | Raises: 59 | MalformedTAF: An error parsing the report 60 | 61 | Returns: 62 | Header dictionary 63 | """ 64 | 65 | taf_header_pattern = """ 66 | ^ 67 | (TAF)? # TAF header (at times missing or duplicate) 68 | \s* 69 | (?P (COR|AMD|AMD\sCOR|COR\sAMD|RTD)){0,1} 70 | 71 | \s* # There may or may not be space as COR/AMD/RTD is optional 72 | (?P [A-Z]{4}) # Station ICAO code 73 | 74 | \s* # at some aerodromes does not appear 75 | (?P \d{0,2}) # at some aerodromes does not appear 76 | (?P \d{0,2}) # at some aerodromes does not appear 77 | (?P \d{0,2}) # at some aerodromes does not appear 78 | Z? # Zulu time (UTC, that is) # at some aerodromes does not appear 79 | 80 | \s* 81 | (?P \d{0,2}) 82 | (?P \d{0,2}) 83 | / 84 | (?P \d{0,2}) 85 | (?P \d{0,2}) 86 | """ 87 | 88 | metar_header_pattern = """ 89 | ^ 90 | (METAR)? # METAR header (at times missing or duplicate) 91 | \s* 92 | (?P [A-Z]{4}) # Station ICAO code 93 | 94 | \s* # at some aerodromes does not appear 95 | (?P \d{0,2}) # at some aerodromes does not appear 96 | (?P \d{0,2}) # at some aerodromes does not appear 97 | (?P \d{0,2}) # at some aerodromes does not appear 98 | Z? # Zulu time (UTC, that is) # at some aerodromes does not appear 99 | \s+ 100 | (?P (COR){0,1}) # Corrected # TODO: Any other values possible? 101 | """ 102 | 103 | header_taf = re.match(taf_header_pattern, string, re.VERBOSE) 104 | header_metar = re.match(metar_header_pattern, string, re.VERBOSE) 105 | 106 | # The difference between a METAR and TAF header isn't that big 107 | # so it's likely to get both regex to match. TAF is a bit more specific so if 108 | # both regex match then we're most likely dealing with a TAF string. 109 | if header_taf: 110 | header_dict = header_taf.groupdict() 111 | header_dict['form'] = 'taf' 112 | elif header_metar: 113 | header_dict = header_metar.groupdict() 114 | header_dict['form'] = 'metar' 115 | else: 116 | raise MalformedTAF("No valid TAF/METAR header found") 117 | 118 | return header_dict 119 | 120 | 121 | def _init_groups(self, string): 122 | """ Extracts weather groups (FM, PROB etc.) and populates group list 123 | 124 | Args: 125 | TAF report string 126 | 127 | Raises: 128 | MalformedTAF: Group decoding error 129 | 130 | """ 131 | 132 | taf_group_pattern = """ 133 | (?:FM|(?:PROB(?:\d{1,2})\s*(?:TEMPO)?)|TEMPO|BECMG|[\S\s])[A-Z0-9\+\-/\s$]+?(?=FM|PROB|TEMPO|BECMG|$) 134 | """ 135 | 136 | group_list = [] 137 | 138 | groups = re.findall(taf_group_pattern, string, re.VERBOSE) 139 | if not groups: 140 | raise MalformedTAF("No valid groups found") 141 | 142 | for group in groups: 143 | group_list.append(group) 144 | 145 | return(group_list) 146 | 147 | def _parse_group(self, string): 148 | group = {} 149 | 150 | if self._taf_header['form'] == "taf": 151 | group["header"] = self._parse_group_header(string) 152 | 153 | if self._taf_header['form'] == "metar": 154 | group["temperature"] = self._parse_temperature(string) 155 | group["pressure"] = self._parse_pressure(string) 156 | 157 | group["wind"] = self._parse_wind(string) 158 | group["visibility"] = self._parse_visibility(string) 159 | group["clouds"] = self._parse_clouds(string) 160 | group["vertical_visibility"] = self._parse_vertical_visibility(string) 161 | group["weather"] = self._parse_weather_phenomena(string) 162 | group["windshear"] = self._parse_wind_shear(string) 163 | 164 | return(group) 165 | 166 | def _parse_group_header(self, string): 167 | # From header pattern 168 | fm_pattern = """ 169 | (?P FM) (?P\d{2}) (?P\d{2})(?P \d{2}) 170 | """ 171 | 172 | # PROB|TEMPO|BECMG header pattern, they have almost the same format 173 | ptb_pattern = """ 174 | (?P (?:PROB(?P\d{1,2})\s*(?:TEMPO)?)|TEMPO|BECMG) 175 | \s+ 176 | (?P \d{2}) 177 | (?P \d{2}) 178 | / 179 | (?P \d{2}) 180 | (?P \d{2}) 181 | """ 182 | 183 | header = {} 184 | 185 | # Get type and associated fields 186 | fm = re.search(fm_pattern, string, re.VERBOSE) 187 | if fm: 188 | header = fm.groupdict() 189 | 190 | ptb = re.search(ptb_pattern, string, re.VERBOSE) 191 | if ptb: 192 | header = ptb.groupdict() 193 | 194 | return(header) 195 | 196 | def _parse_wind(self, string): 197 | wind_pattern = """ 198 | (?<= \s ) 199 | (?P (\d{3}|VRB)) # Three digits or VRB 200 | (?P \d{2,3}) # Next two digits are speed in knots 201 | (G(?P \d{2,3})){0,1} # Optional gust data (Gxx) 202 | (?P KT|MPS) # Knots or meters per second 203 | (?= \s|$ ) 204 | """ 205 | 206 | wind = re.search(wind_pattern, string, re.VERBOSE) 207 | 208 | if wind: 209 | return(wind.groupdict()) 210 | else: 211 | return(None) 212 | 213 | def _parse_visibility(self, string): 214 | # Visibility in statute miles 215 | visibility_pattern = """ 216 | (?<= \s ) 217 | (?P P){0,1} # "P" prefix indicates visibility more than 218 | (?P \d | \d/\d | \d\s\d/\d) # More than 6 is always just P6SM 219 | (?P SM) # Statute miles 220 | (?= \s|$ ) 221 | """ 222 | 223 | # Visibility in meters 224 | # XXX: In case "TEMPO 1012" style reports still exist, 225 | # it will not work as is and I haven't came up with a fix yet 226 | visibility_meters_pattern = """ 227 | (?<= \s ) 228 | (?P \d{4}) 229 | (?= \s|$ ) 230 | """ 231 | 232 | visibility = {} 233 | 234 | # US-style 235 | visibility_sm = re.search(visibility_pattern, string, re.VERBOSE) 236 | if visibility_sm: 237 | visibility = visibility_sm.groupdict() 238 | 239 | # Metric style 240 | visibility_meters = re.search(visibility_meters_pattern, string, re.VERBOSE) 241 | if visibility_meters: 242 | visibility["range"] = visibility_meters.group("range") 243 | # 9999 in fact means "more than 10 km" 244 | if visibility_meters.group("range") == "9999": 245 | visibility["more"] = True 246 | visibility["range"] = "10 000" 247 | visibility["unit"] = "M" 248 | 249 | return(visibility) 250 | 251 | def _parse_clouds(self, string): 252 | clouds_pattern = """ 253 | (?<= \s ) 254 | (?P BKN|SCT|FEW|OVC) 255 | (?P \d{3}) 256 | (?P CU|CB|TCU|CI){0,1} 257 | (?= \s|$ ) 258 | """ 259 | 260 | special_case_pattern = """ (SKC|CLR|NSC|CAVOK|CAVU) """ 261 | special_case_vv = """VV///""" 262 | 263 | clouds = [] 264 | 265 | clear = re.search(special_case_pattern, string, re.VERBOSE) 266 | if clear: 267 | clouds.append({"layer": clear.group(0)}) 268 | return(clouds) 269 | 270 | vv = re.search(special_case_vv, string, re.VERBOSE) 271 | if vv: 272 | clouds.append({"layer": vv.group(0)}) 273 | return(clouds) 274 | 275 | 276 | cloud_layers = re.finditer(clouds_pattern, string, re.VERBOSE) 277 | for layer in cloud_layers: 278 | clouds.append(layer.groupdict()) 279 | 280 | return(clouds) 281 | 282 | def _parse_vertical_visibility(self, string): 283 | 284 | vertical_visibility_pattern = """ 285 | (?<= \s ) 286 | VV 287 | (?P \d{3} ) 288 | (?= \s|$ ) 289 | """ 290 | 291 | vertical_visibility = None 292 | 293 | vv = re.search(vertical_visibility_pattern, string, re.VERBOSE) 294 | if vv: 295 | vertical_visibility = vv.group("vertical_visibility") 296 | 297 | return(vertical_visibility) 298 | 299 | def _parse_weather_phenomena(self, string): 300 | 301 | weather_word_pattern = """ 302 | (?<= \s ) 303 | ( (?: \+|\-|VC|RE|MI|BC|DR|BL|SH|TS|FZ|PR|DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|DU|SA|HZ|PY|VA|PO|SQ|FC|SS|DS)+ ) 304 | (?= \s|$) 305 | """ 306 | 307 | weather = [] 308 | 309 | # At first, find all weather strings in the TAF weather group or METAR string. 310 | weather_words = re.findall(weather_word_pattern, string, re.VERBOSE) 311 | for word in weather_words: 312 | intensities = [] 313 | modifiers = [] 314 | phenomenons = [] 315 | 316 | # Find all intensity descriptors... 317 | while re.match('(\+|\-|VC|RE)', word): 318 | parsed_intensity = re.match('(\+|\-|VC|RE)', word) 319 | intensities.append(parsed_intensity.group(0)) 320 | chars_len = len(intensities[-1]) 321 | word = word[chars_len:] 322 | 323 | # Find all modifiers... 324 | while re.match('(MI|BC|DR|BL|SH|TS|FZ|PR)', word): 325 | parsed_modifier = re.match('(MI|BC|DR|BL|SH|TS|FZ|PR)', word) 326 | modifiers.append(parsed_modifier.group(0)) 327 | chars_len = len(modifiers[-1]) 328 | word = word[chars_len:] 329 | 330 | # Find all phenomenon descriptors... 331 | while re.match('(DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|DU|SA|HZ|PY|VA|PO|SQ|FC|SS|DS)', word): 332 | parsed_phenomenon = re.match('(DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|DU|SA|HZ|PY|VA|PO|SQ|FC|SS|DS)', word) 333 | phenomenons.append(parsed_phenomenon.group(0)) 334 | chars_len = len(phenomenons[-1]) 335 | word = word[chars_len:] 336 | 337 | # ...and put all three lists in a dictionary. 338 | # There's a dictionary for each weather string found in a TAF weather group or METAR string. 339 | group_dict = {'intensity' : intensities, 'modifier' : modifiers, 'phenomenon' : phenomenons} 340 | weather.append(group_dict) 341 | 342 | return(weather) 343 | 344 | def _parse_wind_shear(self, string): 345 | wind_shear_pattern = """ 346 | \s+ 347 | WS (?P \d{3}) 348 | / 349 | (?P \d{3}) 350 | (?P \d{2}) 351 | (?P KT|MPS) 352 | """ 353 | 354 | windshear = re.search(wind_shear_pattern, string, re.VERBOSE) 355 | 356 | if windshear: 357 | return(windshear.groupdict()) 358 | else: 359 | return(None) 360 | 361 | def _parse_maintenance(self, string): 362 | maintenance_pattern = """ ( \$ ) """ 363 | 364 | maintenance = re.search(maintenance_pattern, string, re.VERBOSE) 365 | 366 | if maintenance: 367 | return(maintenance.group(0)) 368 | else: 369 | return(None) 370 | 371 | # METAR specific functions 372 | # TODO: Condition of runway(s) 373 | # TODO: Parse North American METAR codes (RMK) 374 | def _parse_temperature(self, string): 375 | temperature_pattern = """ 376 | (?<= \s ) 377 | (?P M?) 378 | (?P \d{2}) 379 | / 380 | (?P M?) 381 | (?P \d{2}) 382 | (?= \s|$) 383 | """ 384 | 385 | temperature = re.search(temperature_pattern, string, re.VERBOSE) 386 | 387 | if temperature: 388 | return(temperature.groupdict()) 389 | else: 390 | return(None) 391 | 392 | def _parse_pressure(self, string): 393 | # FIXME: Any other possible values than 'Q' as altimeter setting? 394 | pressure_pattern = """ 395 | (?<= \s ) 396 | (?P Q) 397 | (?P \d{4}) 398 | (?= \s|$) 399 | """ 400 | 401 | pressure = re.search(pressure_pattern, string, re.VERBOSE) 402 | 403 | if pressure: 404 | return(pressure.groupdict()) 405 | else: 406 | return(None) 407 | 408 | # TODO: Calculate relative/absolute humidity 409 | # Nice-to-have - Not present in a METAR/TAF string, but it can be calculated by air temperature and dewpoint 410 | 411 | # Getters 412 | def get_taf(self): 413 | """ Return raw TAF string the object was initialized with """ 414 | return self._raw_taf 415 | 416 | def get_header(self): 417 | """ Return header dict """ 418 | return(self._taf_header) 419 | 420 | def get_groups(self): 421 | """ Return weather groups (initial and FM's) """ 422 | return(self._weather_groups) 423 | 424 | def get_maintenance(self): 425 | """ Return station maintenance indicator """ 426 | return(self._maintenance) 427 | -------------------------------------------------------------------------------- /lib/pytaf/tafdecoder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | from .taf import TAF 5 | 6 | class DecodeError(Exception): 7 | def __init__(self, msg): 8 | self.strerror = msg 9 | 10 | class Decoder(object): 11 | def __init__(self, taf): 12 | if isinstance(taf, TAF): 13 | self._taf = taf 14 | else: 15 | raise DecodeError("Argument is not a TAF parser object") 16 | 17 | def decode_taf(self): 18 | form = self._taf.get_header()["form"] 19 | result = "" 20 | 21 | result += self._decode_header(self._taf.get_header()) + "\n" 22 | 23 | for group in self._taf.get_groups(): 24 | # TAF specific stuff 25 | if form == "taf": 26 | if group["header"]: 27 | result += self._decode_group_header(group["header"]) + "\n" 28 | 29 | # METAR specific stuff 30 | if form == "metar": 31 | if group["temperature"]: 32 | result += " Temperature: %s\n" % self._decode_temperature(group["temperature"]) 33 | 34 | if group["pressure"]: 35 | result += " Pressure: %s\n" % self._decode_pressure(group["pressure"]) 36 | 37 | # Both TAF and METAR 38 | if group["wind"]: 39 | result += " Wind: %s \n" % self._decode_wind(group["wind"]) 40 | 41 | if group["visibility"]: 42 | result += " Visibility: %s \n" % self._decode_visibility(group["visibility"]) 43 | 44 | if group["clouds"]: 45 | result += " Sky conditions: %s \n" % self._decode_clouds(group["clouds"]) 46 | 47 | if group["weather"]: 48 | result += " Weather: %s \n" % self._decode_weather(group["weather"]) 49 | 50 | if group["windshear"]: 51 | result += " Windshear: %s\n" % self._decode_windshear(group["windshear"]) 52 | 53 | result += " \n" 54 | 55 | if self._taf.get_maintenance(): 56 | result += self._decode_maintenance(self._taf.get_maintenance()) 57 | 58 | return(result) 59 | 60 | def _decode_header(self, header): 61 | result = "" 62 | 63 | # Ensure it's side effect free 64 | _header = header 65 | 66 | if _header["form"] == 'taf': 67 | # Decode TAF header 68 | # Type 69 | if _header["type"] == "AMD": 70 | result += "TAF amended for " 71 | elif _header["type"] == "COR": 72 | result += "TAF corrected for " 73 | elif _header["type"] == "RTD": 74 | result += "TAF delayed for " 75 | elif _header["type"] == "AMD COR": 76 | result += "TAF amended and corrected for " 77 | elif _header["type"] == "COR AMD": 78 | result += "TAF corrected and amended for " 79 | else: 80 | result += "TAF for " 81 | 82 | # Add ordinal suffix 83 | _header["origin_date"] = _header["origin_date"] + self._get_ordinal_suffix(_header["origin_date"]) 84 | _header["valid_from_date"] = _header["valid_from_date"] + self._get_ordinal_suffix(_header["valid_from_date"]) 85 | _header["valid_till_date" ] = _header["valid_till_date"] + self._get_ordinal_suffix(_header["valid_till_date"]) 86 | 87 | result += ("%(icao_code)s issued %(origin_hours)s:%(origin_minutes)s UTC on the %(origin_date)s, " 88 | "valid from %(valid_from_hours)s:00 UTC on the %(valid_from_date)s to %(valid_till_hours)s:00 UTC on the %(valid_till_date)s") 89 | else: 90 | # Decode METAR header 91 | # Type 92 | if _header["type"] == "COR": 93 | result += "METAR corrected for " 94 | else: 95 | result += "METAR for " 96 | 97 | _header["origin_date"] = _header["origin_date"] + self._get_ordinal_suffix(_header["origin_date"]) 98 | 99 | result += ("%(icao_code)s issued %(origin_hours)s:%(origin_minutes)s UTC on the %(origin_date)s") 100 | 101 | result = result % _header 102 | 103 | return(result) 104 | 105 | def _decode_group_header(self, header): 106 | result = "" 107 | _header = header 108 | 109 | from_str = "From %(from_hours)s:%(from_minutes)s on the %(from_date)s: " 110 | prob_str = "Probability %(probability)s%% of the following between %(from_hours)s:00 on the %(from_date)s and %(till_hours)s:00 on the %(till_date)s: " 111 | tempo_str = "Temporarily between %(from_hours)s:00 on the %(from_date)s and %(till_hours)s:00 on the %(till_date)s: " 112 | prob_tempo_str = "Probability %(probability)s%% of the following temporarily between %(from_hours)s:00 on the %(from_date)s and %(till_hours)s:00 on the %(till_date)s: " 113 | becmg_str = "Gradual change to the following between %(from_hours)s:00 on the %(from_date)s and %(till_hours)s:00 on the %(till_date)s: " 114 | 115 | if "type" in _header: 116 | # Add ordinal suffix 117 | if "from_date" in _header: 118 | from_suffix = self._get_ordinal_suffix(_header["from_date"]) 119 | _header["from_date"] = _header["from_date"] + from_suffix 120 | if "till_date" in _header: 121 | till_suffix = self._get_ordinal_suffix(_header["till_date"]) 122 | _header["till_date"] = _header["till_date"] + till_suffix 123 | 124 | if _header["type"] == "FM": 125 | result += from_str % { "from_date": _header["from_date"], 126 | "from_hours": _header["from_hours"], 127 | "from_minutes": _header["from_minutes"] } 128 | elif _header["type"] == "PROB%s" % (_header["probability"]): 129 | result += prob_str % { "probability": _header["probability"], 130 | "from_date": _header["from_date"], 131 | "from_hours": _header["from_hours"], 132 | "till_date": _header["till_date"], 133 | "till_hours": _header["till_hours"] } 134 | elif "PROB" in _header["type"] and "TEMPO" in _header["type"]: 135 | result += prob_tempo_str % { "probability": _header["probability"], 136 | "from_date": _header["from_date"], 137 | "from_hours": _header["from_hours"], 138 | "till_date": _header["till_date"], 139 | "till_hours": _header["till_hours"] } 140 | elif _header["type"] == "TEMPO": 141 | result += tempo_str % { "from_date": _header["from_date"], 142 | "from_hours": _header["from_hours"], 143 | "till_date": _header["till_date"], 144 | "till_hours": _header["till_hours"] } 145 | elif _header["type"] == "BECMG": 146 | result += becmg_str % { "from_date": _header["from_date"], 147 | "from_hours": _header["from_hours"], 148 | "till_date": _header["till_date"], 149 | "till_hours": _header["till_hours"] } 150 | 151 | return(result) 152 | 153 | def _decode_wind(self, wind): 154 | unit = "" 155 | result = "" 156 | 157 | if wind["direction"] == "000": 158 | return("calm") 159 | elif wind["direction"] == "VRB": 160 | result += "variable" 161 | else: 162 | result += "from %s degrees" % wind["direction"] 163 | 164 | if wind["unit"] == "KT": 165 | unit = "knots" 166 | elif wind["unit"] == "MPS": 167 | unit = "meters per second" 168 | else: 169 | # Unlikely, but who knows 170 | unit = "(unknown unit)" 171 | 172 | result += " at %s %s" % (wind["speed"], unit) 173 | 174 | if wind["gust"]: 175 | result += " gusting to %s %s" % (wind["gust"], unit) 176 | 177 | return(result) 178 | 179 | def _decode_visibility(self, visibility): 180 | result = "" 181 | 182 | if "more" in visibility: 183 | if visibility["more"]: 184 | result += "more than " 185 | 186 | result += visibility["range"] 187 | 188 | if visibility["unit"] == "SM": 189 | result += " statute miles" 190 | elif visibility["unit"] == "M": 191 | result += " meters" 192 | 193 | return(result) 194 | 195 | def _decode_clouds(self, clouds): 196 | result = "" 197 | i_result = "" 198 | list = [] 199 | 200 | for layer in clouds: 201 | if layer["layer"] == "SKC" or layer["layer"] == "CLR": 202 | return "sky clear" 203 | 204 | if layer["layer"] == "NSC": 205 | return "no significant cloud" 206 | 207 | if layer["layer"] == "CAVOK": 208 | return "ceiling and visibility are OK" 209 | 210 | if layer["layer"] == "CAVU": 211 | return "ceiling and visibility unrestricted" 212 | 213 | if layer["layer"] == "VV///": 214 | return "Sky Obscured" 215 | 216 | if layer["layer"] == "SCT": 217 | layer_type = "scattered" 218 | elif layer["layer"] == "BKN": 219 | layer_type = "broken" 220 | elif layer["layer"] == "FEW": 221 | layer_type = "few" 222 | elif layer["layer"] == "OVC": 223 | layer_type = "overcast" 224 | 225 | if layer["type"] == "CB": 226 | type = "cumulonimbus" 227 | elif layer["type"] == "CU": 228 | type = "cumulus" 229 | elif layer["type"] == "TCU": 230 | type = "towering cumulus" 231 | elif layer["type"] == "CI": 232 | type = "cirrus" 233 | else: 234 | type = "" 235 | 236 | result = "%s %s clouds at %d feet" % (layer_type, type, int(layer["ceiling"])*100) 237 | 238 | # Remove extra whitespace, if any 239 | result = re.sub(r'\s+', ' ', result) 240 | 241 | list.append(result) 242 | 243 | layer = "" 244 | type = "" 245 | result = "" 246 | 247 | result = ", ".join(list) 248 | return(result) 249 | 250 | def _decode_weather(self, weather): 251 | # Dicts for translating the abbreviations 252 | dict_intensities = { 253 | "-" : "light", 254 | "+" : "heavy", 255 | "VC" : "in the vicinity", 256 | "RE" : "recent" 257 | } 258 | 259 | dict_modifiers = { 260 | "MI" : "shallow", 261 | "BC" : "patchy", 262 | "DR" : "low drifting", 263 | "BL" : "blowing", 264 | "SH" : "showers", 265 | "TS" : "thunderstorms", 266 | "FZ" : "freezing", 267 | "PR" : "partial" 268 | } 269 | 270 | dict_phenomenons = { 271 | "DZ" : "drizzle", 272 | "RA" : "rain", 273 | "SN" : "snow", 274 | "SG" : "snow grains", 275 | "IC" : "ice", 276 | "PL" : "ice pellets", 277 | "GR" : "hail", 278 | "GS" : "small snow/hail pellets", 279 | "UP" : "unknown precipitation", 280 | "BR" : "mist", 281 | "FG" : "fog", 282 | "FU" : "smoke", 283 | "DU" : "dust", 284 | "SA" : "sand", 285 | "HZ" : "haze", 286 | "PY" : "spray", 287 | "VA" : "volcanic ash", 288 | "PO" : "dust/sand whirl", 289 | "SQ" : "squall", 290 | "FC" : "funnel cloud", 291 | "SS" : "sand storm", 292 | "DS" : "dust storm", 293 | } 294 | 295 | weather_txt_blocks = [] 296 | 297 | # Check for special cases first. If a certain combination is found 298 | # then skip parsing the whole weather string and return a defined string 299 | # immediately 300 | for group in weather: 301 | # +FC = Tornado or Waterspout 302 | if "+" in group["intensity"] and "FC" in group["phenomenon"]: 303 | weather_txt_blocks.append("tornado or waterspout") 304 | continue 305 | 306 | # Sort the elements of the weather string, if no special combi- 307 | # nation is found. 308 | intensities_pre = [] 309 | intensities_post = [] 310 | if "RE" in group["intensity"]: 311 | intensities_pre.append("RE") 312 | group["intensity"].remove("RE") 313 | for intensity in group["intensity"]: 314 | if intensity != "VC": 315 | intensities_pre.append(intensity) 316 | else: 317 | intensities_post.append(intensity) 318 | 319 | modifiers_pre = [] 320 | modifiers_post = [] 321 | for modifier in group["modifier"]: 322 | if modifier != "TS" and modifier != "SH": 323 | modifiers_pre.append(modifier) 324 | else: 325 | modifiers_post.append(modifier) 326 | 327 | phenomenons_pre = [] 328 | phenomenons_post = [] 329 | for phenomenon in group["phenomenon"]: 330 | if phenomenon != "UP": 331 | phenomenons_pre.append(phenomenon) 332 | else: 333 | phenomenons_post.append(phenomenon) 334 | 335 | # Build the human readable text from the single weather string 336 | # and append it to a list containing all the interpreted text 337 | # blocks from a TAF group 338 | weather_txt = "" 339 | for intensity in intensities_pre: 340 | weather_txt += dict_intensities[intensity] + " " 341 | 342 | for modifier in modifiers_pre: 343 | weather_txt += dict_modifiers[modifier] + " " 344 | 345 | phenomenons = phenomenons_pre + phenomenons_post 346 | cnt = len(phenomenons) 347 | for phenomenon in phenomenons: 348 | weather_txt += dict_phenomenons[phenomenon] 349 | if cnt > 2: 350 | weather_txt += ", " 351 | if cnt == 2: 352 | weather_txt += " and " 353 | cnt = cnt-1 354 | weather_txt += " " 355 | 356 | for modifier in modifiers_post: 357 | weather_txt += dict_modifiers[modifier] + " " 358 | 359 | for intensity in intensities_post: 360 | weather_txt += dict_intensities[intensity] + " " 361 | 362 | weather_txt_blocks.append(weather_txt.strip()) 363 | 364 | # Put all the human readable stuff together and return the final 365 | # output as a string. 366 | weather_txt_full = "" 367 | for block in weather_txt_blocks[:-1]: 368 | weather_txt_full += block + " / " 369 | weather_txt_full += weather_txt_blocks[-1] 370 | 371 | return(weather_txt_full) 372 | 373 | def _decode_temperature(self, temperature, unit='C'): 374 | if temperature["air_prefix"] == 'M': 375 | air_c = int(temperature["air"])*-1 376 | else: 377 | air_c = int(temperature["air"]) 378 | 379 | if temperature["dewpoint_prefix"] == 'M': 380 | dew_c = int(temperature["dewpoint"])*-1 381 | else: 382 | dew_c = int(temperature["dewpoint"]) 383 | 384 | if unit == 'C': 385 | air_txt = air_c 386 | dew_txt = dew_c 387 | 388 | if unit == 'F': 389 | air_f = int(round(air_c*1.8+32)) 390 | dew_f = int(round(dew_c*1.8+32)) 391 | air_txt = air_f 392 | dew_txt = dew_f 393 | 394 | result = "air at %s%s, dewpoint at %s%s" % (air_txt, unit, dew_txt, unit) 395 | return(result) 396 | 397 | def _decode_pressure(self, pressure): 398 | result = "%s hPa" % (pressure["athm_pressure"]) 399 | return(result) 400 | 401 | def _decode_windshear(self, windshear): 402 | result = "at %s, wind %s at %s %s" % ((int(windshear["altitude"])*100), windshear["direction"], windshear["speed"], windshear["unit"]) 403 | return(result) 404 | 405 | def _decode_maintenance(self, maintenance): 406 | if maintenance: 407 | return "Station is under maintenance check\n" 408 | 409 | def _get_ordinal_suffix(self, date): 410 | _date = str(date) 411 | 412 | suffix = "" 413 | 414 | if re.match(".*(1[12]|[04-9])$", _date): 415 | suffix = "th" 416 | elif re.match(".*1$", _date): 417 | suffix = "st" 418 | elif re.match(".*2$", _date): 419 | suffix = "nd" 420 | elif re.match(".*3$", _date): 421 | suffix = "rd" 422 | 423 | return(suffix) 424 | --------------------------------------------------------------------------------