Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ RSS 来源可以按地点 ID 限定范围,并配置正文清洗规则。`verba

和风天气使用 `QWeatherJWTAuthenticator` 签发短期 JWT。认证输入包括项目 ID、凭据 ID、专属 API Host 和 Base64 编码的 Ed25519 私钥。实现不支持用长期 API Key 代替该流程。

`OpenMeteoProvider` 提供全球天气,并从独立接口读取空气质量和欧洲花粉数据。公开接口适用于非商业使用,没有 SLA;Base URL 和可选 API Key 可以替换。
`OpenMeteoProvider` 提供全球天气,并从独立接口读取空气质量和欧洲花粉数据。适配器在生成来源正文前把 WMO 天气代码转换为可读的中文天气现象;未知代码保留为“未识别天气现象”并写入安全日志。公开接口适用于非商业使用,没有 SLA;Base URL 和可选 API Key 可以替换。

`AQICNProvider` 只在最终天气结果缺少空气质量时补充当前观测。它不参与未来日期查询,也不把 PM2.5 单项 AQI 换算为浓度。

Expand Down
53 changes: 53 additions & 0 deletions tests/test_reference_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
ReferenceDataError,
load_reference_data,
localization_table,
open_meteo_weather_code_descriptions,
reference_string,
reference_string_tuple,
reference_value,
Expand Down Expand Up @@ -46,13 +47,65 @@ def test_packaged_reference_data_is_available() -> None:
assert reference_string_tuple("content_cleaning.json", "default_remove_selectors")
assert reference_string_tuple("provider_defaults.json", "qweather_lifestyle_index_types")
assert reference_string("provider_defaults.json", "qweather_allergen_index_type") == "7"
weather_codes = open_meteo_weather_code_descriptions()
assert set(weather_codes) == {
0,
1,
2,
3,
45,
48,
51,
53,
55,
56,
57,
61,
63,
65,
66,
67,
71,
73,
75,
77,
80,
81,
82,
85,
86,
95,
96,
99,
}
assert weather_codes[53] == "中等强度毛毛雨"
assert localization_table("weather_document")["ja"]["forecast"] == "天気予報"
assert localization_table("briefing")["zh-Hans"]["weather"] == "天气信息"
classification = telegram_error_classification()
assert ("chat not found", "chat-not-found") in classification.description_markers
assert classification.status_reasons[401] == "bot-token-rejected"


@pytest.mark.parametrize(
"value",
(
{},
{"descriptions_zh_CN": {}},
{"descriptions_zh_CN": {"unknown": "晴朗"}},
{"descriptions_zh_CN": {"00": "晴朗"}},
{"descriptions_zh_CN": {"9" * 5_000: "未知"}},
{"descriptions_zh_CN": {"0": ""}},
{"descriptions_zh_CN": {"0": "晴朗"}, "unknown": {}},
),
)
def test_open_meteo_weather_codes_reject_invalid_data(monkeypatch, value) -> None:
monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
open_meteo_weather_code_descriptions.cache_clear()

with pytest.raises(ReferenceDataError, match="Open-Meteo weather codes"):
open_meteo_weather_code_descriptions()


def test_air_quality_guidance_covers_values_above_last_bounded_band() -> None:
category, guidance = health_guidance(10_000)

Expand Down
24 changes: 23 additions & 1 deletion tests/test_weather_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
_format_qweather_day,
_format_qweather_lifestyle,
_open_meteo_daily_peak_values,
_open_meteo_weather_description,
snapshot_to_documents,
)

Expand Down Expand Up @@ -532,7 +533,8 @@ def handler(request: httpx.Request) -> httpx.Response:
).fetch(52.52, 13.41)

assert len(snapshot.weather_forecast) == 2
assert "WMO天气代码2" in snapshot.weather_forecast[0]
assert "局部多云" in snapshot.weather_forecast[0]
assert "WMO" not in snapshot.weather_forecast[0]
assert snapshot.observed_at.to_iso8601_string() == "2026-07-13T08:00:00+02:00"
assert snapshot.observed_at.timezone_name == "Europe/Berlin"
assert snapshot.air_quality is not None
Expand All @@ -541,6 +543,26 @@ def handler(request: httpx.Request) -> httpx.Response:
assert snapshot.air_quality.pm25_concentration == 9.5


def test_open_meteo_weather_code_uses_readable_description() -> None:
assert _open_meteo_weather_description(53) == "中等强度毛毛雨"


def test_open_meteo_unknown_weather_code_uses_readable_fallback(caplog) -> None:
with caplog.at_level("WARNING", logger="weather_briefing.weather_context"):
description = _open_meteo_weather_description(100)

assert description == "未识别天气现象"
assert "Unknown Open-Meteo weather code code=100" in caplog.text


def test_open_meteo_invalid_weather_code_uses_readable_fallback(caplog) -> None:
with caplog.at_level("WARNING", logger="weather_briefing.weather_context"):
description = _open_meteo_weather_description("53")

assert description == "未识别天气现象"
assert "Invalid Open-Meteo weather code value_type=str" in caplog.text


async def test_open_meteo_provider_requests_only_selected_future_date() -> None:
target_date = pendulum.date(2026, 7, 15)

Expand Down
32 changes: 32 additions & 0 deletions weather_briefing/data/open_meteo_weather_codes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"descriptions_zh_CN": {
"0": "晴朗",
"1": "大致晴朗",
"2": "局部多云",
"3": "阴天",
"45": "有雾",
"48": "雾凇",
"51": "轻微毛毛雨",
"53": "中等强度毛毛雨",
"55": "强毛毛雨",
"56": "轻微冻毛毛雨",
"57": "强冻毛毛雨",
"61": "小雨",
"63": "中雨",
"65": "大雨",
"66": "轻微冻雨",
"67": "强冻雨",
"71": "小雪",
"73": "中雪",
"75": "大雪",
"77": "米雪",
"80": "轻微阵雨",
"81": "中等强度阵雨",
"82": "强阵雨",
"85": "轻微阵雪",
"86": "强阵雪",
"95": "雷暴",
"96": "雷暴伴轻微冰雹",
"99": "雷暴伴强冰雹"
}
}
26 changes: 26 additions & 0 deletions weather_briefing/reference_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,32 @@ def reference_string_tuple(filename: str, *path: str) -> tuple[str, ...]:
return tuple(value)


@cache
def open_meteo_weather_code_descriptions() -> Mapping[int, str]:
"""Return validated Chinese descriptions for Open-Meteo WMO weather codes."""
value = load_reference_data("open_meteo_weather_codes.json")
descriptions = value.get("descriptions_zh_CN")
if set(value) != {"descriptions_zh_CN"} or not isinstance(descriptions, dict) or not descriptions:
raise ReferenceDataError("Open-Meteo weather codes must contain Chinese descriptions")

validated: dict[int, str] = {}
for code, description in descriptions.items():
if (
not isinstance(code, str)
or not code.isascii()
or not code.isdigit()
or len(code) > 2
or not isinstance(description, str)
or not description.strip()
):
raise ReferenceDataError("Open-Meteo weather codes must map numeric codes to descriptions")
numeric_code = int(code)
if str(numeric_code) != code:
raise ReferenceDataError("Open-Meteo weather codes must map numeric codes to descriptions")
validated[numeric_code] = description
return MappingProxyType(validated)


@cache
def telegram_error_classification() -> TelegramErrorClassification:
"""Return validated Telegram API error classification data."""
Expand Down
21 changes: 19 additions & 2 deletions weather_briefing/weather_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@
SourceDocument,
WeatherContextSnapshot,
)
from .reference_data import ReferenceDataError, localization_table, reference_string, reference_string_tuple
from .reference_data import (
ReferenceDataError,
localization_table,
open_meteo_weather_code_descriptions,
reference_string,
reference_string_tuple,
)
from .time_utils import (
datetime_timezone_specifier,
parse_datetime_with_default_timezone,
Expand Down Expand Up @@ -1020,7 +1026,7 @@ def _float_value(value: object) -> float:
def _format_open_meteo_day(daily: dict[str, object], index: int) -> str:
return (
f"{_open_meteo_daily_value(daily, 'time', index)}:"
f"WMO天气代码{_open_meteo_daily_value(daily, 'weather_code', index)},"
f"{_open_meteo_weather_description(_open_meteo_daily_value(daily, 'weather_code', index))},"
f"{_open_meteo_daily_value(daily, 'temperature_2m_min', index)}~"
f"{_open_meteo_daily_value(daily, 'temperature_2m_max', index)}℃,"
f"体感{_open_meteo_daily_value(daily, 'apparent_temperature_min', index)}~"
Expand All @@ -1032,3 +1038,14 @@ def _format_open_meteo_day(daily: dict[str, object], index: int) -> str:
f"主导风向{_open_meteo_daily_value(daily, 'wind_direction_10m_dominant', index)}°,"
f"最高紫外线指数{_open_meteo_daily_value(daily, 'uv_index_max', index)}"
)


def _open_meteo_weather_description(value: object) -> str:
Comment thread
IceCodeNew marked this conversation as resolved.
descriptions = open_meteo_weather_code_descriptions()
if type(value) is int and value in descriptions:
return descriptions[value]
if type(value) is int:
_LOGGER.warning("Unknown Open-Meteo weather code code=%d", value)
else:
_LOGGER.warning("Invalid Open-Meteo weather code value_type=%s", type(value).__name__)
return "未识别天气现象"