Merge remote-tracking branch 'origin/fix/gwx-warnings-structured' into feat/wx-gwx-convergence

fix/gwx-missing-current returns the fetch error as a bare string when the current temperature is missing, and this branch moved that code into a method whose callers unpack (reply, current conditions). Git merged it without a conflict; the merged early return now returns the pair, so the reply is the fetch error again instead of an unpacking error.
This commit is contained in:
agessaman committed 2026-10-01 22:32:56 -07:00
commit 4ab2a9ae48
15 files changed
+254 -45

No files matched your search

+1
View File
@@ -37,6 +37,7 @@ semantic versioning.
- Weather forecasts are dated by the location, not by the bot's clock. On a bot whose clock is in another time zone (a server on UTC, or a bot answering for a distant city), `wx`'s tomorrow and multi-day forecasts could start a day late, `wx`'s hourly forecast could list an hour that had already passed, and `gwx` labeled the current period Today or Tonight, and the multi-day lines' weekdays, by the bot's time. Both commands now take today's date and the time of day from the forecast itself (NOAA's period start times, Open-Meteo's local times). `wx`'s multi-day forecast also keeps holiday-named NOAA periods ("Columbus Day"), which it used to skip, and `gwx 7d` shows seven days instead of six: it asked Open-Meteo for seven days counting today and then left today out.
- `wx`'s gust reading from the nearest weather station is no longer about 3.6 times too high. NOAA reports station gusts in km/h, and `wx` converted them as if they were meters per second, so a 36 km/h (22 mph) gust showed as `💨80`. Gusts are now converted from the unit NOAA declares with them. A forecast period at exactly 0° is also no longer dropped from tomorrow's and the multi-day forecast (it was taken for a missing temperature, which `temperature_unit = celsius` makes common).
- `gwx` no longer invents weather when Open-Meteo leaves out current conditions. A response without them was shown as clear skies at 0° and 0% humidity, and a single null field (humidity, wind, gusts, feels-like) failed the whole reply. A missing current temperature now gets the usual "could not fetch" reply, and other missing current fields are simply left out of it.
- `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.
@@ -545,12 +545,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')
@@ -559,7 +560,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
@@ -818,6 +819,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
@@ -854,28 +862,28 @@ 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)
if weather is None:
return self.translate('commands.gwx.error_fetching')
return self.translate('commands.gwx.error_fetching'), {}
# Forecast high/low for today (the current conditions already name the period).
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) -> Optional[str]:
"""Current conditions for the default gwx reply, with extra conditions when they fit.
@@ -1280,6 +1288,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'))
@@ -1323,66 +1332,48 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand):
86: "🌨️", # Snow Showers
95: "⛈️", # Thunderstorm
96: "⛈️", # Thunderstorm with Hail
97: "⛈️", # Heavy Thunderstorm
99: "⛈️" # Severe Thunderstorm
}
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, 97, 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
+1 -1
View File
@@ -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 "🌤️"
+204
View File
@@ -0,0 +1,204 @@
"""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"), (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)],
])
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"), (97, "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()
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) == "⛈️"
+1
View File
@@ -255,6 +255,7 @@
"86": "Schneeschauer",
"95": "Gewitter",
"96": "Gewitter m/Hagel",
"97": "Starkes Gewitter",
"99": "Schweres Gewitter",
"unknown": "Unbekannt"
},
+1
View File
@@ -233,6 +233,7 @@
"86": "Snow Showers",
"95": "Thunderstorm",
"96": "T-Storm w/Hail",
"97": "Heavy T-Storm",
"99": "Severe T-Storm",
"unknown": "Unknown"
},
+2
View File
@@ -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": {
+1
View File
@@ -246,6 +246,7 @@
"86": "Chubascos de Nieve",
"95": "Tormenta Eléctrica",
"96": "Tormenta c/Granizo",
"97": "Tormenta Fuerte",
"99": "Tormenta Severa",
"unknown": "Desconocido"
},
+1
View File
@@ -307,6 +307,7 @@
"86": "Averses de neige",
"95": "Orage",
"96": "Orage avec grêle",
"97": "Orage fort",
"99": "Orage violent",
"unknown": "Inconnu"
},
+1
View File
@@ -235,6 +235,7 @@
"86": "Averses de neige",
"95": "Orage",
"96": "Orage avec grêle",
"97": "Orage fort",
"99": "Orage violent",
"unknown": "Inconnu"
},
+1
View File
@@ -234,6 +234,7 @@
"86": "Sneeuwbuien",
"95": "Onweer",
"96": "Onweer m/hagel",
"97": "Hevig onweer",
"99": "Zwaar onweer",
"unknown": "Onbekend"
},
+1
View File
@@ -254,6 +254,7 @@
"86": "Przelotne opady śniegu",
"95": "Burza",
"96": "Burza z gradem",
"97": "Silna burza",
"99": "Silna burza z gradem",
"unknown": "Unknown"
},
+1
View File
@@ -232,6 +232,7 @@
"86": "Pancadas de Neve",
"95": "Tempestade",
"96": "Tempestade c/Granizo",
"97": "Tempestade Forte",
"99": "Tempestade Severa",
"unknown": "Desconhecido"
},
+1
View File
@@ -231,6 +231,7 @@
"86": "Pancadas de Neve",
"95": "Tempestade",
"96": "Tempestade c/Granizo",
"97": "Tempestade Forte",
"99": "Tempestade Severa",
"unknown": "Desconhecido"
},
+2
View File
@@ -299,6 +299,7 @@
"86": "Снеж.ливень",
"95": "Гроза",
"96": "Гроза+град",
"97": "Сильная гроза",
"99": "Сильн.гроза",
"unknown": "Неизвестно"
},
@@ -919,6 +920,7 @@
"86": "Снеж.ливень",
"95": "Гроза",
"96": "Гроза+град",
"97": "Сильная гроза",
"99": "Сильн.гроза"
},
"wind_speed_units": {