Skip to content
10 changes: 6 additions & 4 deletions metar_taf_parser/command/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,10 @@ def can_parse(self, visibility_string: str):


class MinimalVisibilityCommand:
regex = r'^(\d{4}[NnEeSsWw])$'
regex = r'^(\d{4})(N|NE|E|SE|S|SW|W|NW)$'

def __init__(self):
self._pattern = re.compile(MinimalVisibilityCommand.regex)
self._pattern = re.compile(MinimalVisibilityCommand.regex, re.IGNORECASE)

def can_parse(self, visibility_string: str):
return self._pattern.search(visibility_string)
Expand All @@ -179,8 +179,10 @@ def execute(self, container: AbstractWeatherContainer, visibility_string: str):
:return:
"""
matches = self._pattern.search(visibility_string).groups()
container.visibility.min_distance = int(matches[0][0:4])
container.visibility.min_direction = matches[0][4]
if container.visibility is None:
container.visibility = Visibility()
container.visibility.min_distance = int(matches[0])
container.visibility.min_direction = matches[1]
return True


Expand Down
80 changes: 62 additions & 18 deletions metar_taf_parser/parser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,31 @@
from metar_taf_parser.commons.exception import TranslationError
from metar_taf_parser.model.enum import Flag, Intensity, Descriptive, Phenomenon, TimeIndicator, WeatherChangeType
from metar_taf_parser.model.model import WeatherCondition, Visibility, Metar, TemperatureDated, \
AbstractWeatherContainer, TAF, TAFTrend, MetarTrend, Validity, FMValidity, MetarTrendTime

AbstractWeatherContainer, TAF as TAFData, TAFTrend, MetarTrend, Validity, FMValidity, MetarTrendTime

def parse_delivery_time(abstract_weather_code, time_string):
"""
Parses the delivery time of a METAR/TAF
Parses the delivery time of a METAR/TAF. It will return False
if it is not a delivery time but a validity time. If the delivery time
is not specified, we can assume the start of the validity time is the delivery time.

This occurred in the line TEMPO 2308/2312 9999/8000 RA/DZ BKN020, where the delivery time is
2300/2312, but the validity time that follows, 9999/9000 was parsed as a delivery time,
causing the parser to give erroneous results.

:param abstract_weather_code: The TAF or METAR object
:param time_string: The string representing the delivery time
:return: None
"""
abstract_weather_code.day = int(time_string[0:2])
abstract_weather_code.time = time(int(time_string[2:4]), int(time_string[4:6]))
if len(time_string) > 6 and "/" in time_string:
# This is a validity string, not a delivery time.
abstract_weather_code.day = int(time_string[0:2])
abstract_weather_code.time = time(hour=int(time_string[2:4]))
return False
else:
abstract_weather_code.day = int(time_string[0:2])
abstract_weather_code.time = time(int(time_string[2:4]), int(time_string[4:6]))
return True


def _parse_flags(abstract_weather_code, flag_string):
Expand Down Expand Up @@ -243,16 +256,11 @@ class TAFParser(AbstractParser):

def __init__(self):
super().__init__()
self._validity_pattern = re.compile(r'^\d{4}/\d{4}$')
self._validity_or_visibility_pattern = re.compile(r'^\d{4}/\d{4}$')
self._taf_command_supplier = TAFCommandSupplier()

def parse(self, input: str):
"""
Parses a message into a TAF
:param input: the message to parse
:return: a TAF object or None if the message is invalid
"""
taf = TAF()
def _parse_initial_taf(self, input: str):
taf = TAFData()
lines = self._extract_lines_tokens(input)
if TAFParser.TAF != lines[0][0]:
return
Expand All @@ -265,10 +273,20 @@ def parse(self, input: str):
taf.station = lines[0][index]
index += 1
taf.message = input
parse_delivery_time(taf, lines[0][index])
index += 1
if parse_delivery_time(taf, lines[0][index]):
index += 1
taf.validity = _parse_validity(lines[0][index])

return taf, lines, index

def parse(self, input: str):
"""
Parses a message into a TAF
:param input: the message to parse
:return: a TAF object or None if the message is invalid
"""
taf, lines, index = self._parse_initial_taf(input)

for i in range(index + 1, len(lines[0])):
token = lines[0][i]
command = self._taf_command_supplier.get(token)
Expand Down Expand Up @@ -311,7 +329,7 @@ def _extract_lines_tokens(self, taf_code: str):
lines_token[len(lines) - 1] = list(filter(lambda x: not x.startswith(TAFParser.TX) and not x.startswith(TAFParser.TN), last_line))
return lines_token

def _parse_line(self, taf: TAF, line_tokens: list):
def _parse_line(self, taf: TAFData, line_tokens: list):
"""
Parses the tokens of the line and updates the TAF object.
:param taf: TAF object to update
Expand All @@ -333,6 +351,28 @@ def _parse_line(self, taf: TAF, line_tokens: list):
self._parse_trend(index, line_tokens, trend)
taf.add_trend(trend)

def _is_valid_validity(self, validity: Validity):
return validity.start_day < 32 and validity.start_hour < 24 and validity.end_day < 32 and validity.end_hour < 24

def _parse_visibility(self, visibility_string: str):
"""For certain weather conditions, the visibility is given as a range such as DDDD/DDDD,
e.g. 9999/8000
We will interpret this as a minimum distance and distance in meters.
"""
parts = visibility_string.split('/')
if len(parts) == 2:
first_part = int(parts[0])
second_part = int(parts[1])
min_distance = min(first_part, second_part)
distance = max(first_part, second_part)

visibility = Visibility()
visibility.min_distance = min_distance if min_distance < 9999 else '> 10km'
visibility.distance = f"{distance}m" if distance < 9999 else '> 10km'

return visibility
return None

def _parse_trend(self, index: int, line: list, trend: TAFTrend):
"""
Parses a trend of the TAF
Expand All @@ -349,8 +389,12 @@ def _parse_trend(self, index: int, line: list, trend: TAFTrend):
elif AbstractParser.RMK == line[i]:
parse_remark(trend, line, i)
break
elif self._validity_pattern.search(line[i]):
trend.validity = _parse_validity(line[i])
elif self._validity_or_visibility_pattern.search(line[i]):
validity = _parse_validity(line[i])
if self._is_valid_validity(validity) and getattr(trend, '_validity', None) is None:
trend.validity = validity
elif visibility := self._parse_visibility(line[i]):
trend.visibility = visibility
else:
super().general_parse(trend, line[i])

Expand Down
23 changes: 21 additions & 2 deletions metar_taf_parser/tests/command/test_common.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import unittest

from metar_taf_parser.command.common import CloudCommand, MainVisibilityNauticalMilesCommand, WindCommand, CommandSupplier
from metar_taf_parser.command.common import (
CloudCommand,
CommandSupplier,
MainVisibilityNauticalMilesCommand,
MinimalVisibilityCommand,
WindCommand
)
from metar_taf_parser.model.enum import CloudQuantity, CloudType
from metar_taf_parser.model.model import Metar
from metar_taf_parser.model.model import TAF, Metar


class CommonTestCase(unittest.TestCase):
Expand Down Expand Up @@ -90,6 +96,19 @@ def test_execute(self):
metar = Metar()
self.assertTrue(command.execute(metar, 'VRB08KT'))

def test_minimal_visibility_command(self):
command = MinimalVisibilityCommand()

for dir in ['N', 'ne', 's', 'SW']:
with self.subTest(dir):
vis_str = f'3000{dir}'
self.assertTrue(command.can_parse(vis_str))

taf = TAF()
self.assertTrue(command.execute(taf, vis_str))
self.assertEqual(taf.visibility.min_distance, 3000)
self.assertEqual(taf.visibility.min_direction, dir)

def test_main_visibility_nautical_miles_command_with_greater_than(self):
command = MainVisibilityNauticalMilesCommand()
self.assertTrue(command.can_parse('P3SM'))
Expand Down
76 changes: 75 additions & 1 deletion metar_taf_parser/tests/parser/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ def test_parse_recent_rain(self):
self.assertEqual('LTAE', metar.station)
self.assertEqual(1, len(metar.weather_conditions))
self.assertEqual(Intensity.RECENT, metar.weather_conditions[0].intensity)
self.assertEquals(Descriptive.SHOWERS, metar.weather_conditions[0].descriptive)
self.assertEqual(Descriptive.SHOWERS, metar.weather_conditions[0].descriptive)
self.assertEqual(1, len(metar.weather_conditions[0].phenomenons))
self.assertEqual(Phenomenon.RAIN, metar.weather_conditions[0].phenomenons[0])

Expand Down Expand Up @@ -333,6 +333,80 @@ def test_parse_temperature_min(self):


class TAFParserTestCase(unittest.TestCase):
def test_parse_tempo_with_visibility(self):
code = """TAF MNMG 260600Z 2306/2406 VRB04KT 9999 FEW020 SCT070
TEMPO 2308/2312 9999/8000 RA/DZ BKN020
TEMPO 2314/2316 09010KT 9800/9000 -DZ SCT022 SCT070
BECMG 2321/2323 7000 -TSRA/RA FEW020CB SCT070
"""
taf = TAFParser().parse(code)

self.assertEqual(len(taf.trends), 3)
trend_1, trend_2, trend_3 = taf.trends

self.assertEqual(trend_1.type, WeatherChangeType.TEMPO)
self.assertEqual(trend_1.validity.start_day, 23)
self.assertEqual(trend_1.validity.start_hour, 8)
self.assertEqual(trend_1.validity.end_day, 23)
self.assertEqual(trend_1.validity.end_hour, 12)
self.assertEqual(trend_1.visibility.distance, '> 10km')
self.assertEqual(trend_1.visibility.min_distance, 8000)
self.assertEqual(len(trend_1.clouds), 1)
self.assertEqual(trend_1.clouds[0].height, 2000)
self.assertEqual(trend_1.clouds[0].quantity, CloudQuantity.BKN)

self.assertEqual(trend_2.type, WeatherChangeType.TEMPO)
self.assertEqual(trend_2.validity.start_day, 23)
self.assertEqual(trend_2.validity.start_hour, 14)
self.assertEqual(trend_2.validity.end_day, 23)
self.assertEqual(trend_2.validity.end_hour, 16)
self.assertEqual(trend_2.wind.degrees, 90)
self.assertEqual(trend_2.wind.speed, 10)
self.assertEqual(trend_2.visibility.distance, '9800m')
self.assertEqual(trend_2.visibility.min_distance, 9000)
self.assertEqual(trend_2.wind.speed, 10)
self.assertEqual(trend_2.wind.unit, 'KT')
self.assertEqual(len(trend_2.clouds), 2)
self.assertEqual(trend_2.clouds[0].height, 2200)
self.assertEqual(trend_2.clouds[0].quantity, CloudQuantity.SCT)
self.assertEqual(trend_2.clouds[1].height, 7000)
self.assertEqual(trend_2.clouds[1].quantity, CloudQuantity.SCT)
self.assertEqual(len(trend_2.weather_conditions), 1)
self.assertEqual(trend_2.weather_conditions[0].intensity, Intensity.LIGHT)
self.assertEqual(len(trend_2.weather_conditions[0].phenomenons), 1)
self.assertEqual(trend_2.weather_conditions[0].phenomenons[0], Phenomenon.DRIZZLE)

self.assertEqual(trend_3.type, WeatherChangeType.BECMG)
self.assertEqual(trend_3.validity.start_day, 23)
self.assertEqual(trend_3.validity.start_hour, 21)
self.assertEqual(trend_3.validity.end_day, 23)
self.assertEqual(trend_3.validity.end_hour, 23)
self.assertEqual(trend_3.visibility.distance, '7000m')
self.assertEqual(len(trend_3.clouds), 2)
self.assertEqual(trend_3.clouds[0].height, 2000)
self.assertEqual(trend_3.clouds[0].quantity, CloudQuantity.FEW)
self.assertEqual(trend_3.clouds[0].type, CloudType.CB)
self.assertEqual(trend_3.clouds[1].height, 7000)
self.assertEqual(trend_3.clouds[1].quantity, CloudQuantity.SCT)

def test_parse_without_delivery_time(self):
code = """
TAF KNYL 2603/2703 20006KT 9999 SKC QNH2974INS
FM261000 14004KT 9999 SKC QNH2977INS
FM261700 17007KT 9999 SKC QNH2974INS
FM262100 19013KT 9999 SKC QNH2967INS AUTOMATED SENSOR METWATCH 2606 TIL 2614 TX42/2623Z TN24/2614Z
"""
taf = TAFParser().parse(code)

self.assertEqual('KNYL', taf.station)
self.assertEqual(26, taf.day)
self.assertEqual(3, taf.time.hour)
self.assertEqual(0, taf.time.minute)

self.assertEqual(26, taf.validity.start_day)
self.assertEqual(3, taf.validity.start_hour)
self.assertEqual(27, taf.validity.end_day)
self.assertEqual(3, taf.validity.end_hour)

def test_parse_with_invalid_line_breaks(self):
code = 'TAF LFPG 150500Z 1506/1612 17005KT 6000 SCT012 \n' + 'TEMPO 1506/1509 3000 BR BKN006 PROB40 \n' + 'TEMPO 1506/1508 0400 BCFG BKN002 PROB40 \n' + 'TEMPO 1512/1516 4000 -SHRA FEW030TCU BKN040 \n' + 'BECMG 1520/1522 CAVOK \n' + 'TEMPO 1603/1608 3000 BR BKN006 PROB40 \n TEMPO 1604/1607 0400 BCFG BKN002 TX17/1512Z TN07/1605Z'
Expand Down
Loading