From 71f7f93e26c425cae1077f6f8235b20c7dbc01bf Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 1 Oct 2026 21:46:41 -0700 Subject: [PATCH 1/2] fix(gwx): judge extreme-weather warnings from Open-Meteo's numbers, not the reply text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _check_extreme_conditions regex-searched the finished, translated reply: it only saw temperatures followed by °F (so -10°F read as 10 and Celsius replies never warned), took the number after a direction label as the wind speed and called it mph whatever the unit, and lost it when the direction letters were translated. The check now gets the current conditions from the same Open-Meteo request (via _get_open_meteo_weather_with_conditions; get_open_meteo_weather keeps its signature) and uses the weather codes the description match stood for (65/82 heavy rain, 95/96/99 thunderstorms, 75/85/86 snow), and the temperature and sustained wind converted to °F and mph for the unchanged thresholds. Warning text and the multi_message shape are unchanged. --- CHANGELOG.md | 1 + .../commands/alternatives/wx_international.py | 75 +++---- tests/unit/test_gwx_extreme_conditions.py | 197 ++++++++++++++++++ 3 files changed, 230 insertions(+), 43 deletions(-) create mode 100644 tests/unit/test_gwx_extreme_conditions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 585ee21..14cd8ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ semantic versioning. ### Fixed +- `gwx`'s extreme-weather warnings now work in Celsius, in metric wind units and in other languages. They were found by searching the finished, translated reply text: a temperature was only recognized when followed by °F (so `-10°F` read as 10, and Celsius replies never warned), the wind number was read off the direction label whatever its unit and called mph, and translated direction letters hid it entirely. The warnings now use Open-Meteo's own numbers and weather codes from the same request, converted to °F and mph for the existing thresholds (95°F, 20°F, 30 mph sustained wind), with the same warning text. - `gwx` multi-day forecasts no longer fail when the forecast runs out early. Open-Meteo returns 16 days but fills the ones past the model's horizon with nulls (the last one or two by default, nine with `weather_model = icon_seamless`), and the first null day made the whole reply the multi-day error, so `gwx 15d` failed for London and Reykjavik and `gwx 7d` failed under shorter-range models. The forecast now ends at the last day with data. A null precipitation probability no longer breaks the tomorrow forecast or the default reply either, and a tomorrow with null temperatures reports that it is not available. - `wx` shows wind directions properly. NOAA reports them as 16-point abbreviations (`WNW`, `SSE`), which `wx` only recognized as full words, so they fell through to a fallback that kept the first two letters and no arrow: `💨WN2` for a 2 mph west-northwest wind. They now keep all their letters with the nearest arrow (`↖️WNW2`). - A service plugin that does not start because it is not configured (Discord or Telegram with no channels, the map uploader with no key) is no longer restarted every 5 seconds with a warning each time. The health check restarted any service that was not running, and the restart counted as a success, which cleared its backoff. A service that does not start, or is still unhealthy after a restart, now waits out `service_restart_backoff_seconds` before the next try, so one that was only waiting for the radio still recovers. A restarted packet capture or map uploader service also gets its background work back (`stop()` left a shutdown flag set that `start()` never cleared), and the map uploader keeps logging to the bot's log file after a restart. The DARC MoWaS service no longer stops the whole bot when its port is taken (werkzeug called `sys.exit`), and no longer reports itself healthy when it could not listen. Restarts also no longer subscribe a service to radio events a second time: services never unsubscribed in `stop()`, so after a restart packet capture published every packet twice and the Discord, Telegram and prefix-collision services handled every event twice. diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index 7354cb5..26c65c0 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -560,12 +560,13 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): # Get weather forecast from Open-Meteo based on type # Pass location_prefix_len so weather formatting can account for it + current = {} if forecast_type == "tomorrow": weather_text = self.get_open_meteo_weather(lat, lon, forecast_type="tomorrow", message=message, location_prefix_len=location_prefix_len) elif forecast_type == "multiday": weather_text = self.get_open_meteo_weather(lat, lon, forecast_type="multiday", num_days=num_days, message=message, location_prefix_len=location_prefix_len) else: - weather_text = self.get_open_meteo_weather(lat, lon, message=message, location_prefix_len=location_prefix_len) + weather_text, current = self._get_open_meteo_weather_with_conditions(lat, lon, message=message, location_prefix_len=location_prefix_len) # Check if it's an error (translated error message) error_fetching = self.translate('commands.gwx.error_fetching') @@ -574,7 +575,7 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): # Check for severe weather warnings (only for default forecast type) if forecast_type == "default": - alert_text = self._check_extreme_conditions(weather_text) + alert_text = self._check_extreme_conditions(current) if alert_text: # Return multi-message format @@ -833,6 +834,13 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): Returns: str: Formatted weather string or error message. """ + weather, _ = self._get_open_meteo_weather_with_conditions( + lat, lon, forecast_type, num_days, message, location_prefix_len + ) + return weather + + def _get_open_meteo_weather_with_conditions(self, lat: float, lon: float, forecast_type: str = "default", num_days: int = 7, message: MeshMessage = None, location_prefix_len: int = 0) -> tuple[str, dict]: + """Return the reply and current conditions from the same Open-Meteo request.""" # Get max message length dynamically, then subtract location prefix length max_length = self.get_max_message_length(message) if message else 130 max_length = max_length - location_prefix_len # Account for location prefix @@ -867,14 +875,14 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): if not response.ok: self.logger.warning(f"Error fetching weather from Open-Meteo: {response.status_code}") - return self.translate('commands.gwx.error_fetching') + return self.translate('commands.gwx.error_fetching'), {} data = response.json() if forecast_type == "tomorrow": - return self.format_tomorrow_forecast(data) + return self.format_tomorrow_forecast(data), {} if forecast_type == "multiday": - return self.format_multiday_forecast(data, num_days) + return self.format_multiday_forecast(data, num_days), {} temp_symbol = "°F" if self.temperature_unit == 'fahrenheit' else "°C" weather = self._open_meteo_current(data, max_length, temp_symbol) @@ -882,11 +890,11 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): daily = data.get('daily', {}) if daily: weather = self._open_meteo_daily_tail(weather, daily, max_length, temp_symbol) - return weather + return weather, data.get('current', {}) except Exception as e: self.logger.error(f"Error fetching Open-Meteo weather: {e}") - return self.translate('commands.gwx.error_fetching') + return self.translate('commands.gwx.error_fetching'), {} def _open_meteo_current(self, data: dict, max_length: int, temp_symbol: str) -> str: """Current conditions for the default gwx reply, with extra conditions when they fit.""" @@ -1303,61 +1311,42 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): return emoji_map.get(code, "🌤️") - def _check_extreme_conditions(self, weather_text: str) -> Optional[str]: + def _check_extreme_conditions(self, current: dict) -> Optional[str]: """Check for extreme weather conditions that warrant warnings. Args: - weather_text: The formatted weather text to check. + current: Open-Meteo current conditions in the configured units. Returns: Optional[str]: Warning text if conditions found, None otherwise. """ warnings = [] - # Extract temperature from weather text - temp_match = re.search(r'(\d+)°F', weather_text) - if temp_match: - temp = int(temp_match.group(1)) + # Keep the thresholds in Fahrenheit and mph, regardless of reply units. + temp = current.get('temperature_2m') + if temp is not None: + if self.temperature_unit == 'celsius': + temp = temp * 9 / 5 + 32 if temp >= 95: warnings.append(self.translate('commands.gwx.warnings.extreme_heat')) elif temp <= 20: warnings.append(self.translate('commands.gwx.warnings.extreme_cold')) - # Check for severe weather indicators - # Note: We check for English strings here since weather descriptions might be in English - # In a fully localized version, we'd need to check translated strings too - heavy_rain_en = "Heavy Rain" - heavy_showers_en = "Heavy Showers" - thunderstorm_en = "Thunderstorm" - t_storm_en = "T-Storm" - heavy_snow_en = "Heavy Snow" - snow_showers_en = "Snow Showers" - - # Also get translated versions for checking - heavy_rain_trans = self.translate('commands.gwx.weather_descriptions.65') - heavy_showers_trans = self.translate('commands.gwx.weather_descriptions.82') - thunderstorm_trans = self.translate('commands.gwx.weather_descriptions.95') - t_storm_trans = self.translate('commands.gwx.weather_descriptions.96') - heavy_snow_trans = self.translate('commands.gwx.weather_descriptions.75') - snow_showers_trans = self.translate('commands.gwx.weather_descriptions.86') - - if (heavy_rain_en in weather_text or heavy_showers_en in weather_text or - heavy_rain_trans in weather_text or heavy_showers_trans in weather_text): + code = current.get('weather_code') + if code in (65, 82): warnings.append(self.translate('commands.gwx.warnings.heavy_rain')) - if (thunderstorm_en in weather_text or t_storm_en in weather_text or - thunderstorm_trans in weather_text or t_storm_trans in weather_text): + if code in (95, 96, 99): warnings.append(self.translate('commands.gwx.warnings.thunderstorms')) - if (heavy_snow_en in weather_text or snow_showers_en in weather_text or - heavy_snow_trans in weather_text or snow_showers_trans in weather_text): + # The old "Snow Showers" match also covered light snow showers (85). + if code in (75, 85, 86): warnings.append(self.translate('commands.gwx.warnings.heavy_snow')) - # Check for high winds - wind_match = re.search(r'[NESW]{1,2}(\d+)', weather_text) - if wind_match: - wind_speed = int(wind_match.group(1)) - if wind_speed >= 30: - warnings.append(self.translate('commands.gwx.warnings.high_winds', wind_speed=wind_speed)) + # Sustained wind only, as before; the warning's number is in mph like its threshold. + wind_factor = {'mph': 1, 'kmh': MI_TO_KM, 'ms': 0.44704}[self.wind_speed_unit] + wind_speed = (current.get('wind_speed_10m') or 0) / wind_factor + if wind_speed >= 30: + warnings.append(self.translate('commands.gwx.warnings.high_winds', wind_speed=int(wind_speed))) return " | ".join(warnings) if warnings else None diff --git a/tests/unit/test_gwx_extreme_conditions.py b/tests/unit/test_gwx_extreme_conditions.py new file mode 100644 index 0000000..4f2927d --- /dev/null +++ b/tests/unit/test_gwx_extreme_conditions.py @@ -0,0 +1,197 @@ +"""gwx warnings use Open-Meteo values, independently of reply units and wording.""" + +import configparser +from unittest.mock import Mock, patch + +import pytest + +from modules.commands.alternatives.wx_international import GlobalWxCommand +from modules.i18n import Translator + +MODULE = "modules.commands.alternatives.wx_international" + + +def _gwx(temperature_unit="fahrenheit", wind_unit="mph", language="en"): + config = configparser.ConfigParser() + config.read_dict({"Weather": { + "temperature_unit": temperature_unit, + "wind_speed_unit": wind_unit, + }, "Gwx_Command": {}, "Bot": {}}) + bot = Mock() + bot.config = config + bot.translator = Translator(language) + cmd = GlobalWxCommand(bot) + cmd.geocode_location = Mock(return_value=(1.0, 2.0, {}, None)) + cmd._format_location_display = Mock(return_value="Test City") + cmd.get_max_message_length = Mock(return_value=158) + return cmd + + +def _data(cmd, **current): + temp = 25.0 if cmd.temperature_unit == "celsius" else 77.0 + return { + "current": { + "temperature_2m": temp, + "apparent_temperature": temp, + "relative_humidity_2m": 50, + "wind_direction_10m": 0, + "wind_speed_10m": 0, + "wind_gusts_10m": 0, + "weather_code": 0, + **current, + }, + "daily": { + "temperature_2m_max": [temp, temp], + "temperature_2m_min": [temp, temp], + "weather_code": [0, 0], + }, + } + + +def _run(cmd, data, forecast_type="default", ok=True): + response = Mock(ok=ok, status_code=503 if not ok else 200) + response.json.return_value = data + with patch(f"{MODULE}.requests.get", return_value=response) as get: + result = cmd._get_weather_for_location_sync( + "Test City", forecast_type=forecast_type, num_days=2, message=Mock() + ) + get.assert_called_once() + assert get.call_args.kwargs["params"]["temperature_unit"] == cmd.temperature_unit + assert get.call_args.kwargs["params"]["wind_speed_unit"] == cmd.wind_speed_unit + cmd.logger.error.assert_not_called() + return result + + +def _assert_warning(result, expected): + if expected is None: + assert isinstance(result, str) + assert result.startswith("Test City: ") + else: + assert isinstance(result, tuple) + assert len(result) == 3 + assert result[0] == "multi_message" + assert result[1].startswith("Test City: ") + assert result[2] == expected + + +@pytest.mark.parametrize("temperature_unit", ["fahrenheit", "celsius"]) +@pytest.mark.parametrize("fahrenheit, expected", [ + (94.9, None), (95, "⚠️ Extreme heat"), (95.1, "⚠️ Extreme heat"), + (20.1, None), (20, "⚠️ Extreme cold"), (19.9, "⚠️ Extreme cold"), + (-1, "⚠️ Extreme cold"), (-100, "⚠️ Extreme cold"), +]) +def test_temperature_thresholds(temperature_unit, fahrenheit, expected): + cmd = _gwx(temperature_unit=temperature_unit) + temp = fahrenheit if temperature_unit == "fahrenheit" else (fahrenheit - 32) * 5 / 9 + result = _run(cmd, _data(cmd, temperature_2m=temp, apparent_temperature=temp)) + _assert_warning(result, expected) + + +@pytest.mark.parametrize("wind_unit, factor", [("mph", 1), ("kmh", 1.609344), ("ms", 0.44704)]) +@pytest.mark.parametrize("mph, expected", [ + (29.9, None), (30, "⚠️ High winds (30 mph)"), + (30.1, "⚠️ High winds (30 mph)"), (40, "⚠️ High winds (40 mph)"), +]) +def test_wind_thresholds(wind_unit, factor, mph, expected): + cmd = _gwx(wind_unit=wind_unit) + result = _run(cmd, _data(cmd, wind_speed_10m=mph * factor)) + _assert_warning(result, expected) + + +def test_40_kmh_is_below_the_high_wind_threshold(): + cmd = _gwx(wind_unit="kmh") + _assert_warning(_run(cmd, _data(cmd, wind_speed_10m=40)), None) + + +def test_sustained_wind_warning_keeps_its_speed_even_with_stronger_gusts(): + cmd = _gwx() + _assert_warning(_run(cmd, _data(cmd, wind_speed_10m=30, wind_gusts_10m=45)), + "⚠️ High winds (30 mph)") + + +def test_gusts_alone_do_not_warn(): + # Gusts of 30+ mph are common on ordinary windy days; the warning is about sustained wind. + cmd = _gwx() + _assert_warning(_run(cmd, _data(cmd, wind_speed_10m=10, wind_gusts_10m=45)), None) + + +@pytest.mark.parametrize("code, expected", [ + (65, "⚠️ Heavy rain"), (82, "⚠️ Heavy rain"), + (95, "⚠️ Thunderstorms"), (96, "⚠️ Thunderstorms"), (99, "⚠️ Thunderstorms"), + (75, "⚠️ Heavy snow"), (85, "⚠️ Heavy snow"), (86, "⚠️ Heavy snow"), + *[(code, None) for code in (0, 1, 2, 3, 45, 48, 51, 53, 55, 56, 57, 61, 63, 66, 67, 71, 73, 77, 80, 81, 999)], +]) +def test_wmo_warning_categories(code, expected): + cmd = _gwx() + _assert_warning(_run(cmd, _data(cmd, weather_code=code)), expected) + + +@pytest.mark.parametrize("code, warning", [ + (65, "heavy_rain"), (82, "heavy_rain"), + (95, "thunderstorms"), (96, "thunderstorms"), (99, "thunderstorms"), + (75, "heavy_snow"), (85, "heavy_snow"), (86, "heavy_snow"), +]) +def test_russian_directions_and_conditions(code, warning): + cmd = _gwx(temperature_unit="celsius", wind_unit="kmh", language="ru") + result = _run(cmd, _data(cmd, weather_code=code, wind_speed_10m=48.28032)) + expected = " | ".join([ + cmd.translate(f"commands.gwx.warnings.{warning}"), + cmd.translate("commands.gwx.warnings.high_winds", wind_speed=30), + ]) + _assert_warning(result, expected) + assert "С48" in result[1] + + +def test_warning_words_in_a_description_do_not_create_warnings(): + cmd = _gwx() + cmd.bot.translator.translations["commands"]["gwx"]["weather_descriptions"]["0"] = ( + "Heavy Rain Thunderstorm Heavy Snow" + ) + _assert_warning(_run(cmd, _data(cmd)), None) + + +def test_warning_does_not_depend_on_a_rendered_description(): + cmd = _gwx() + cmd._get_weather_description = Mock(return_value="Wet") + _assert_warning(_run(cmd, _data(cmd, weather_code=65)), "⚠️ Heavy rain") + + +def test_daily_extremes_do_not_replace_current_conditions(): + cmd = _gwx() + data = _data(cmd) + data["daily"].update( + temperature_2m_max=[110, 110], temperature_2m_min=[-100, -100], + weather_code=[99, 99], wind_speed_10m_max=[100, 100], wind_gusts_10m_max=[120, 120], + ) + _assert_warning(_run(cmd, data), None) + + +def test_combined_warnings_keep_the_existing_order_and_wording(): + cmd = _gwx() + result = _run(cmd, _data(cmd, temperature_2m=100, apparent_temperature=100, + weather_code=95, wind_speed_10m=40)) + _assert_warning(result, "⚠️ Extreme heat | ⚠️ Thunderstorms | ⚠️ High winds (40 mph)") + + +@pytest.mark.parametrize("forecast_type", ["tomorrow", "multiday"]) +def test_forecast_only_replies_do_not_add_current_warnings(forecast_type): + cmd = _gwx() + result = _run(cmd, _data(cmd, temperature_2m=100, weather_code=99, wind_speed_10m=50), + forecast_type=forecast_type) + _assert_warning(result, None) + + +def test_fetch_error_still_returns_the_api_error(): + cmd = _gwx() + assert _run(cmd, {}, ok=False) == cmd.translate("commands.gwx.error_fetching_api") + + +def test_public_weather_getter_still_returns_text(): + cmd = _gwx() + response = Mock(ok=True) + response.json.return_value = _data(cmd, temperature_2m=100, weather_code=99, wind_speed_10m=50) + with patch(f"{MODULE}.requests.get", return_value=response) as get: + text = cmd.get_open_meteo_weather(1.0, 2.0) + assert isinstance(text, str) + assert "100°F" in text + get.assert_called_once() From 9b9a0a06056d6dfc2e282e513e4d6843c1c2fb52 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 1 Oct 2026 22:21:48 -0700 Subject: [PATCH 2/2] fix(weather): recognize Open-Meteo's heavy-thunderstorm code 97 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review pointed out that Open-Meteo documents WMO code 97 as a heavy thunderstorm, and neither gwx nor the weather service knew it: the reply showed an unknown condition with the generic emoji and gwx gave no thunderstorm warning. It now has a description in every language, the ⛈️ emoji and the thunderstorm warning. --- CHANGELOG.md | 2 +- modules/commands/alternatives/wx_international.py | 4 +++- modules/service_plugins/weather_service.py | 2 +- tests/unit/test_gwx_extreme_conditions.py | 11 +++++++++-- translations/de.json | 1 + translations/en-GB.json | 1 + translations/en.json | 2 ++ translations/es.json | 1 + translations/fr-CA.json | 1 + translations/fr.json | 1 + translations/nl.json | 1 + translations/pl.json | 1 + translations/pt-BR.json | 1 + translations/pt.json | 1 + translations/ru.json | 2 ++ 15 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14cd8ea..716143a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ semantic versioning. ### Fixed -- `gwx`'s extreme-weather warnings now work in Celsius, in metric wind units and in other languages. They were found by searching the finished, translated reply text: a temperature was only recognized when followed by °F (so `-10°F` read as 10, and Celsius replies never warned), the wind number was read off the direction label whatever its unit and called mph, and translated direction letters hid it entirely. The warnings now use Open-Meteo's own numbers and weather codes from the same request, converted to °F and mph for the existing thresholds (95°F, 20°F, 30 mph sustained wind), with the same warning text. +- `gwx`'s extreme-weather warnings now work in Celsius, in metric wind units and in other languages. They were found by searching the finished, translated reply text: a temperature was only recognized when followed by °F (so `-10°F` read as 10, and Celsius replies never warned), the wind number was read off the direction label whatever its unit and called mph, and translated direction letters hid it entirely. The warnings now use Open-Meteo's own numbers and weather codes from the same request, converted to °F and mph for the existing thresholds (95°F, 20°F, 30 mph sustained wind), with the same warning text. Open-Meteo's heavy-thunderstorm code (97) was missing from gwx and the weather service: it now shows as "Heavy T-Storm" with ⛈️ and warns like the other thunderstorm codes, instead of an unknown condition. - `gwx` multi-day forecasts no longer fail when the forecast runs out early. Open-Meteo returns 16 days but fills the ones past the model's horizon with nulls (the last one or two by default, nine with `weather_model = icon_seamless`), and the first null day made the whole reply the multi-day error, so `gwx 15d` failed for London and Reykjavik and `gwx 7d` failed under shorter-range models. The forecast now ends at the last day with data. A null precipitation probability no longer breaks the tomorrow forecast or the default reply either, and a tomorrow with null temperatures reports that it is not available. - `wx` shows wind directions properly. NOAA reports them as 16-point abbreviations (`WNW`, `SSE`), which `wx` only recognized as full words, so they fell through to a fallback that kept the first two letters and no arrow: `💨WN2` for a 2 mph west-northwest wind. They now keep all their letters with the nearest arrow (`↖️WNW2`). - A service plugin that does not start because it is not configured (Discord or Telegram with no channels, the map uploader with no key) is no longer restarted every 5 seconds with a warning each time. The health check restarted any service that was not running, and the restart counted as a success, which cleared its backoff. A service that does not start, or is still unhealthy after a restart, now waits out `service_restart_backoff_seconds` before the next try, so one that was only waiting for the radio still recovers. A restarted packet capture or map uploader service also gets its background work back (`stop()` left a shutdown flag set that `start()` never cleared), and the map uploader keeps logging to the bot's log file after a restart. The DARC MoWaS service no longer stops the whole bot when its port is taken (werkzeug called `sys.exit`), and no longer reports itself healthy when it could not listen. Restarts also no longer subscribe a service to radio events a second time: services never unsubscribed in `stop()`, so after a restart packet capture published every packet twice and the Discord, Telegram and prefix-collision services handled every event twice. diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index 26c65c0..35874ef 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -1263,6 +1263,7 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): 86: "Snow Showers", 95: "Thunderstorm", 96: "T-Storm w/Hail", + 97: "Heavy T-Storm", 99: "Severe T-Storm" } return weather_codes.get(code, self.translate('commands.gwx.weather_descriptions.unknown')) @@ -1306,6 +1307,7 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): 86: "🌨️", # Snow Showers 95: "⛈️", # Thunderstorm 96: "⛈️", # Thunderstorm with Hail + 97: "⛈️", # Heavy Thunderstorm 99: "⛈️" # Severe Thunderstorm } @@ -1336,7 +1338,7 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): if code in (65, 82): warnings.append(self.translate('commands.gwx.warnings.heavy_rain')) - if code in (95, 96, 99): + if code in (95, 96, 97, 99): warnings.append(self.translate('commands.gwx.warnings.thunderstorms')) # The old "Snow Showers" match also covered light snow showers (85). diff --git a/modules/service_plugins/weather_service.py b/modules/service_plugins/weather_service.py index 5c34185..9b8779e 100644 --- a/modules/service_plugins/weather_service.py +++ b/modules/service_plugins/weather_service.py @@ -931,7 +931,7 @@ class WeatherService(BaseServicePlugin): return "🌧️" elif code in [71, 73, 75, 77, 85, 86]: return "❄️" - elif code in [95, 96, 99]: + elif code in [95, 96, 97, 99]: return "⛈️" else: return "🌤️" diff --git a/tests/unit/test_gwx_extreme_conditions.py b/tests/unit/test_gwx_extreme_conditions.py index 4f2927d..22946cc 100644 --- a/tests/unit/test_gwx_extreme_conditions.py +++ b/tests/unit/test_gwx_extreme_conditions.py @@ -117,7 +117,7 @@ def test_gusts_alone_do_not_warn(): @pytest.mark.parametrize("code, expected", [ (65, "⚠️ Heavy rain"), (82, "⚠️ Heavy rain"), - (95, "⚠️ Thunderstorms"), (96, "⚠️ Thunderstorms"), (99, "⚠️ Thunderstorms"), + (95, "⚠️ Thunderstorms"), (96, "⚠️ Thunderstorms"), (97, "⚠️ Thunderstorms"), (99, "⚠️ Thunderstorms"), (75, "⚠️ Heavy snow"), (85, "⚠️ Heavy snow"), (86, "⚠️ Heavy snow"), *[(code, None) for code in (0, 1, 2, 3, 45, 48, 51, 53, 55, 56, 57, 61, 63, 66, 67, 71, 73, 77, 80, 81, 999)], ]) @@ -128,7 +128,7 @@ def test_wmo_warning_categories(code, expected): @pytest.mark.parametrize("code, warning", [ (65, "heavy_rain"), (82, "heavy_rain"), - (95, "thunderstorms"), (96, "thunderstorms"), (99, "thunderstorms"), + (95, "thunderstorms"), (96, "thunderstorms"), (97, "thunderstorms"), (99, "thunderstorms"), (75, "heavy_snow"), (85, "heavy_snow"), (86, "heavy_snow"), ]) def test_russian_directions_and_conditions(code, warning): @@ -195,3 +195,10 @@ def test_public_weather_getter_still_returns_text(): assert isinstance(text, str) assert "100°F" in text get.assert_called_once() + + +def test_heavy_thunderstorm_code_has_its_own_description_and_emoji(): + # Open-Meteo documents WMO 97 as a heavy thunderstorm. + cmd = _gwx() + assert cmd._get_weather_description(97) == "Heavy T-Storm" + assert cmd._get_weather_emoji(97) == "⛈️" diff --git a/translations/de.json b/translations/de.json index 3df78c4..ee9234a 100644 --- a/translations/de.json +++ b/translations/de.json @@ -255,6 +255,7 @@ "86": "Schneeschauer", "95": "Gewitter", "96": "Gewitter m/Hagel", + "97": "Starkes Gewitter", "99": "Schweres Gewitter", "unknown": "Unbekannt" }, diff --git a/translations/en-GB.json b/translations/en-GB.json index cb30794..f572273 100644 --- a/translations/en-GB.json +++ b/translations/en-GB.json @@ -233,6 +233,7 @@ "86": "Snow Showers", "95": "Thunderstorm", "96": "T-Storm w/Hail", + "97": "Heavy T-Storm", "99": "Severe T-Storm", "unknown": "Unknown" }, diff --git a/translations/en.json b/translations/en.json index c8437d2..2d0f78a 100644 --- a/translations/en.json +++ b/translations/en.json @@ -300,6 +300,7 @@ "86": "Snow Showers", "95": "Thunderstorm", "96": "T-Storm w/Hail", + "97": "Heavy T-Storm", "99": "Severe T-Storm", "unknown": "Unknown" }, @@ -1527,6 +1528,7 @@ "86": "Heavy Snow Showers", "95": "Thunderstorm", "96": "Thunderstorm w/Hail", + "97": "Heavy Thunderstorm", "99": "Severe Thunderstorm" }, "wind_speed_units": { diff --git a/translations/es.json b/translations/es.json index 0f84154..6d51897 100644 --- a/translations/es.json +++ b/translations/es.json @@ -246,6 +246,7 @@ "86": "Chubascos de Nieve", "95": "Tormenta Eléctrica", "96": "Tormenta c/Granizo", + "97": "Tormenta Fuerte", "99": "Tormenta Severa", "unknown": "Desconocido" }, diff --git a/translations/fr-CA.json b/translations/fr-CA.json index 1911bbc..21dd466 100644 --- a/translations/fr-CA.json +++ b/translations/fr-CA.json @@ -307,6 +307,7 @@ "86": "Averses de neige", "95": "Orage", "96": "Orage avec grêle", + "97": "Orage fort", "99": "Orage violent", "unknown": "Inconnu" }, diff --git a/translations/fr.json b/translations/fr.json index 94c6c61..94c6290 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -235,6 +235,7 @@ "86": "Averses de neige", "95": "Orage", "96": "Orage avec grêle", + "97": "Orage fort", "99": "Orage violent", "unknown": "Inconnu" }, diff --git a/translations/nl.json b/translations/nl.json index a7a7387..00534c2 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -234,6 +234,7 @@ "86": "Sneeuwbuien", "95": "Onweer", "96": "Onweer m/hagel", + "97": "Hevig onweer", "99": "Zwaar onweer", "unknown": "Onbekend" }, diff --git a/translations/pl.json b/translations/pl.json index 4bbff97..7ae9ca0 100644 --- a/translations/pl.json +++ b/translations/pl.json @@ -254,6 +254,7 @@ "86": "Przelotne opady śniegu", "95": "Burza", "96": "Burza z gradem", + "97": "Silna burza", "99": "Silna burza z gradem", "unknown": "Unknown" }, diff --git a/translations/pt-BR.json b/translations/pt-BR.json index 28b534a..3d2d059 100644 --- a/translations/pt-BR.json +++ b/translations/pt-BR.json @@ -232,6 +232,7 @@ "86": "Pancadas de Neve", "95": "Tempestade", "96": "Tempestade c/Granizo", + "97": "Tempestade Forte", "99": "Tempestade Severa", "unknown": "Desconhecido" }, diff --git a/translations/pt.json b/translations/pt.json index 197b952..4571972 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -231,6 +231,7 @@ "86": "Pancadas de Neve", "95": "Tempestade", "96": "Tempestade c/Granizo", + "97": "Tempestade Forte", "99": "Tempestade Severa", "unknown": "Desconhecido" }, diff --git a/translations/ru.json b/translations/ru.json index 1c78660..0ee4b52 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -299,6 +299,7 @@ "86": "Снеж.ливень", "95": "Гроза", "96": "Гроза+град", + "97": "Сильная гроза", "99": "Сильн.гроза", "unknown": "Неизвестно" }, @@ -919,6 +920,7 @@ "86": "Снеж.ливень", "95": "Гроза", "96": "Гроза+град", + "97": "Сильная гроза", "99": "Сильн.гроза" }, "wind_speed_units": {