From 3883a75a77607e5ff6a883ed47cced3d5f12b854 Mon Sep 17 00:00:00 2001 From: Alex Lee Date: Mon, 2 Dec 2024 11:13:18 -0600 Subject: [PATCH 1/7] Fix directional visibility handling (#1) --- metar_taf_parser/command/common.py | 10 ++++---- metar_taf_parser/tests/command/test_common.py | 23 +++++++++++++++++-- metar_taf_parser/tests/parser/test_parser.py | 2 +- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/metar_taf_parser/command/common.py b/metar_taf_parser/command/common.py index d6efb42..00c2421 100644 --- a/metar_taf_parser/command/common.py +++ b/metar_taf_parser/command/common.py @@ -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) @@ -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 diff --git a/metar_taf_parser/tests/command/test_common.py b/metar_taf_parser/tests/command/test_common.py index 81990cc..d286235 100644 --- a/metar_taf_parser/tests/command/test_common.py +++ b/metar_taf_parser/tests/command/test_common.py @@ -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): @@ -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')) diff --git a/metar_taf_parser/tests/parser/test_parser.py b/metar_taf_parser/tests/parser/test_parser.py index a409060..2915558 100644 --- a/metar_taf_parser/tests/parser/test_parser.py +++ b/metar_taf_parser/tests/parser/test_parser.py @@ -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]) From b38f7674923bbeb51d2cb87f3947f4c1392e5709 Mon Sep 17 00:00:00 2001 From: Alex Lee Date: Mon, 2 Dec 2024 11:13:18 -0600 Subject: [PATCH 2/7] fix: directional visibility handling --- metar_taf_parser/command/common.py | 10 ++++---- metar_taf_parser/tests/command/test_common.py | 23 +++++++++++++++++-- metar_taf_parser/tests/parser/test_parser.py | 2 +- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/metar_taf_parser/command/common.py b/metar_taf_parser/command/common.py index d6efb42..00c2421 100644 --- a/metar_taf_parser/command/common.py +++ b/metar_taf_parser/command/common.py @@ -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) @@ -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 diff --git a/metar_taf_parser/tests/command/test_common.py b/metar_taf_parser/tests/command/test_common.py index 81990cc..d286235 100644 --- a/metar_taf_parser/tests/command/test_common.py +++ b/metar_taf_parser/tests/command/test_common.py @@ -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): @@ -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')) diff --git a/metar_taf_parser/tests/parser/test_parser.py b/metar_taf_parser/tests/parser/test_parser.py index a409060..2915558 100644 --- a/metar_taf_parser/tests/parser/test_parser.py +++ b/metar_taf_parser/tests/parser/test_parser.py @@ -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]) From 937837804d82a258c1fcb4909c3bf61789570f3b Mon Sep 17 00:00:00 2001 From: Benyakir Horowitz Date: Thu, 26 Jun 2025 16:47:04 +0200 Subject: [PATCH 3/7] feat: enhance visibility parsing to support validity-like format and meter units - Add parsing for visibility that looks like a validity --- metar_taf_parser/parser/parser.py | 38 +++++++++++++------- metar_taf_parser/tests/parser/test_parser.py | 19 ++++++++++ 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/metar_taf_parser/parser/parser.py b/metar_taf_parser/parser/parser.py index 59574b4..c328160 100644 --- a/metar_taf_parser/parser/parser.py +++ b/metar_taf_parser/parser/parser.py @@ -15,13 +15,22 @@ 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. :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): @@ -246,12 +255,7 @@ def __init__(self): self._validity_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 - """ + def _parse_initial_taf(self, input: str): taf = TAF() lines = self._extract_lines_tokens(input) if TAFParser.TAF != lines[0][0]: @@ -265,10 +269,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) @@ -311,7 +325,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: 'TAF', line_tokens: list): """ Parses the tokens of the line and updates the TAF object. :param taf: TAF object to update diff --git a/metar_taf_parser/tests/parser/test_parser.py b/metar_taf_parser/tests/parser/test_parser.py index 2915558..48f5c6c 100644 --- a/metar_taf_parser/tests/parser/test_parser.py +++ b/metar_taf_parser/tests/parser/test_parser.py @@ -334,6 +334,25 @@ def test_parse_temperature_min(self): class TAFParserTestCase(unittest.TestCase): + 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' From a165c8072c3353c7acb1d7b69605a700bd4b850f Mon Sep 17 00:00:00 2001 From: Alex Lee Date: Mon, 2 Dec 2024 11:13:18 -0600 Subject: [PATCH 4/7] fix: directional visibility handling --- metar_taf_parser/command/common.py | 10 ++++---- metar_taf_parser/tests/command/test_common.py | 23 +++++++++++++++++-- metar_taf_parser/tests/parser/test_parser.py | 2 +- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/metar_taf_parser/command/common.py b/metar_taf_parser/command/common.py index d6efb42..00c2421 100644 --- a/metar_taf_parser/command/common.py +++ b/metar_taf_parser/command/common.py @@ -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) @@ -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 diff --git a/metar_taf_parser/tests/command/test_common.py b/metar_taf_parser/tests/command/test_common.py index 81990cc..d286235 100644 --- a/metar_taf_parser/tests/command/test_common.py +++ b/metar_taf_parser/tests/command/test_common.py @@ -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): @@ -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')) diff --git a/metar_taf_parser/tests/parser/test_parser.py b/metar_taf_parser/tests/parser/test_parser.py index a409060..2915558 100644 --- a/metar_taf_parser/tests/parser/test_parser.py +++ b/metar_taf_parser/tests/parser/test_parser.py @@ -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]) From f567517cbb801902c53478b0aea6dd6795d2b83f Mon Sep 17 00:00:00 2001 From: Benyakir Horowitz Date: Thu, 26 Jun 2025 18:48:27 +0200 Subject: [PATCH 5/7] fix: add visibility parsing to support validity-like format and meter units - Add parsing for visibility that looks like a validity --- metar_taf_parser/parser/parser.py | 34 ++++++++++-- metar_taf_parser/tests/command/test_common.py | 2 +- metar_taf_parser/tests/parser/test_parser.py | 55 +++++++++++++++++++ 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/metar_taf_parser/parser/parser.py b/metar_taf_parser/parser/parser.py index 59574b4..3e597ea 100644 --- a/metar_taf_parser/parser/parser.py +++ b/metar_taf_parser/parser/parser.py @@ -243,7 +243,7 @@ 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): @@ -311,7 +311,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: 'TAF', line_tokens: list): """ Parses the tokens of the line and updates the TAF object. :param taf: TAF object to update @@ -333,6 +333,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 @@ -349,8 +371,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): + trend.validity = validity + elif visibility := self._parse_visibility(line[i]): + trend.visibility = visibility else: super().general_parse(trend, line[i]) diff --git a/metar_taf_parser/tests/command/test_common.py b/metar_taf_parser/tests/command/test_common.py index d286235..953cafa 100644 --- a/metar_taf_parser/tests/command/test_common.py +++ b/metar_taf_parser/tests/command/test_common.py @@ -5,7 +5,7 @@ CommandSupplier, MainVisibilityNauticalMilesCommand, MinimalVisibilityCommand, - WindCommand, + WindCommand ) from metar_taf_parser.model.enum import CloudQuantity, CloudType from metar_taf_parser.model.model import TAF, Metar diff --git a/metar_taf_parser/tests/parser/test_parser.py b/metar_taf_parser/tests/parser/test_parser.py index 2915558..a64e281 100644 --- a/metar_taf_parser/tests/parser/test_parser.py +++ b/metar_taf_parser/tests/parser/test_parser.py @@ -333,6 +333,61 @@ 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_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' From 72557babe73ce20cd444d1374a0771a96fe2d37f Mon Sep 17 00:00:00 2001 From: Benyakir Horowitz Date: Thu, 7 Aug 2025 08:49:25 +0200 Subject: [PATCH 6/7] docs(parser): update comment for TAF time string parsing Improve documentation in parse_delivery_time function to clarify the issue with validity time strings being parsed as delivery time. --- metar_taf_parser/parser/parser.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/metar_taf_parser/parser/parser.py b/metar_taf_parser/parser/parser.py index c328160..7c7007b 100644 --- a/metar_taf_parser/parser/parser.py +++ b/metar_taf_parser/parser/parser.py @@ -18,6 +18,11 @@ def parse_delivery_time(abstract_weather_code, time_string): 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 From 4baeea6e0d1591688a7d2ffc48c5b254ad04b74c Mon Sep 17 00:00:00 2001 From: Benyakir Horowitz Date: Thu, 7 Aug 2025 09:16:41 +0200 Subject: [PATCH 7/7] fix(parser): prevent multiple validity assignments in TAF trends --- metar_taf_parser/parser/parser.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/metar_taf_parser/parser/parser.py b/metar_taf_parser/parser/parser.py index af520eb..e22efb2 100644 --- a/metar_taf_parser/parser/parser.py +++ b/metar_taf_parser/parser/parser.py @@ -10,8 +10,7 @@ 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): """ @@ -261,7 +260,7 @@ def __init__(self): self._taf_command_supplier = TAFCommandSupplier() def _parse_initial_taf(self, input: str): - taf = TAF() + taf = TAFData() lines = self._extract_lines_tokens(input) if TAFParser.TAF != lines[0][0]: return @@ -330,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 @@ -392,7 +391,7 @@ def _parse_trend(self, index: int, line: list, trend: TAFTrend): break elif self._validity_or_visibility_pattern.search(line[i]): validity = _parse_validity(line[i]) - if self._is_valid_validity(validity): + 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