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
4 changes: 2 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ RSS 来源可以按地点 ID 限定范围,并配置正文清洗规则。`verba

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

`OpenMeteoProvider` 提供全球天气,并从独立接口读取空气质量和欧洲花粉数据。适配器在生成来源正文前把 WMO 天气代码转换为可读的中文天气现象;未知代码保留为“未识别天气现象”并写入安全日志。公开接口适用于非商业使用,没有 SLA;Base URL 和可选 API Key 可以替换。
`OpenMeteoProvider` 提供全球天气,并从独立接口读取空气质量和欧洲花粉数据。Open-Meteo API 返回语言中立的结构化数据,适配器把天气、空气质量和花粉统一转换为固定 `en` 来源正文。WMO 天气代码映射在模块加载时完成验证,未知代码保留为 “Unrecognized weather condition” 并写入安全日志。`SourceDocument` 记录英文来源语言,模型按地点配置的目标语言生成最终简报。公开接口适用于非商业使用,没有 SLA;Base URL 和可选 API Key 可以替换。

`AQICNProvider` 只在最终天气结果缺少空气质量时补充当前观测。它不参与未来日期查询,也不把 PM2.5 单项 AQI 换算为浓度。
`AQICNProvider` 只在最终天气结果缺少空气质量时补充当前观测。AQICN 的结构化数据也统一转换为固定 `en` 来源正文。它不参与未来日期查询,也不把 PM2.5 单项 AQI 换算为浓度。

`NEASingaporeNowcastProvider` 只提供新加坡两小时预报。`JMAJapanForecastProvider` 只提供地点所配置预报区的日本本地预报。它们没有完整天气服务所需的全部数据,因此与 Open-Meteo 组合,而不是作为完整天气 fallback。

Expand Down
6 changes: 4 additions & 2 deletions tests/test_air_quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@ def handler(request: httpx.Request) -> httpx.Response:

assert snapshot.aqi == 120
assert snapshot.aqi_standard == "US EPA"
assert snapshot.category == "Unhealthy for sensitive groups"
assert snapshot.effective_at is not None
assert snapshot.effective_at.to_iso8601_string() == "2026-07-13T08:00:00+08:00"
assert snapshot.time_kind is AirQualityTimeKind.OBSERVATION
assert snapshot.pm25_aqi == 100
assert snapshot.pm25_concentration is None
assert snapshot.output_language == "en"
document = air_quality_to_document(snapshot)
assert "AQI120(标准:US EPA" in document.content
assert "PM2.5 不可用" in document.content
assert "AQI: 120 (Standard: US EPA" in document.content
assert "PM2.5 concentration: Unavailable" in document.content
assert "原始浓度" not in document.content
assert "折算" not in document.content

Expand Down
10 changes: 5 additions & 5 deletions tests/test_allergen.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,28 @@

def test_allergen_guidance_zero_is_none() -> None:
category, guidance = allergen_guidance(0)
assert category == ""
assert category == "None"
assert guidance


def test_allergen_guidance_low_band() -> None:
category, _ = allergen_guidance(5)
assert category == ""
assert category == "Low"


def test_allergen_guidance_moderate_band() -> None:
category, _ = allergen_guidance(20)
assert category == ""
assert category == "Moderate"


def test_allergen_guidance_high_band() -> None:
category, _ = allergen_guidance(50)
assert category == ""
assert category == "High"


def test_allergen_guidance_extreme_band() -> None:
category, _ = allergen_guidance(500)
assert category == "极高"
assert category == "Very high"


@pytest.mark.parametrize("concentration", [-1, float("nan"), float("inf"), float("-inf")])
Expand Down
2 changes: 1 addition & 1 deletion tests/test_languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def test_weather_provider_language_metadata_distinguishes_selectable_and_fixed_s
assert QWEATHER_LANGUAGE_SUPPORT.select("ja") == "ja"
assert QWEATHER_LANGUAGE_SUPPORT.api_code("ja") == "ja"
assert OPEN_METEO_LANGUAGE_SUPPORT.selectable is False
assert OPEN_METEO_LANGUAGE_SUPPORT.default == "zh-CN"
assert OPEN_METEO_LANGUAGE_SUPPORT.default == "en"


@pytest.mark.parametrize(
Expand Down
14 changes: 7 additions & 7 deletions tests/test_reference_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def test_packaged_reference_data_is_available() -> None:
96,
99,
}
assert weather_codes[53] == "中等强度毛毛雨"
assert weather_codes[53] == "Moderate drizzle"
assert localization_table("weather_document")["ja"]["forecast"] == "天気予報"
assert localization_table("briefing")["zh-Hans"]["weather"] == "天气信息"
classification = telegram_error_classification()
Expand All @@ -90,12 +90,12 @@ def test_packaged_reference_data_is_available() -> None:
"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": {}},
{"descriptions_en": {}},
{"descriptions_en": {"unknown": "Clear sky"}},
{"descriptions_en": {"00": "Clear sky"}},
{"descriptions_en": {"9" * 5_000: "Unknown"}},
{"descriptions_en": {"0": ""}},
{"descriptions_en": {"0": "Clear sky"}, "unknown": {}},
),
)
def test_open_meteo_weather_codes_reject_invalid_data(monkeypatch, value) -> None:
Expand Down
10 changes: 6 additions & 4 deletions tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def test_renderers_fall_back_to_source_id_for_legacy_blank_name() -> None:
def test_telegram_html_renderer_uses_context_source_name_as_attribution() -> None:
context = SourceDocument(
"allergen:open-meteo",
"Open-Meteo / CAMS ENSEMBLE 花粉过敏原",
"Open-Meteo / CAMS ENSEMBLE pollen allergens",
"https://open-meteo.com/en/docs/air-quality-api",
"Pollen data",
)
Expand All @@ -93,14 +93,14 @@ def test_telegram_html_renderer_uses_context_source_name_as_attribution() -> Non
rendered = TelegramHTMLRenderer().render_briefing(result, (), (context,))

assert (
'<a href="https://open-meteo.com/en/docs/air-quality-api">Open-Meteo / CAMS ENSEMBLE 花粉过敏原</a>'
'<a href="https://open-meteo.com/en/docs/air-quality-api">Open-Meteo / CAMS ENSEMBLE pollen allergens</a>'
) in rendered.body


def test_plain_text_renderer_uses_context_source_name_as_attribution() -> None:
context = SourceDocument(
"allergen:open-meteo",
"Open-Meteo / CAMS ENSEMBLE 花粉过敏原",
"Open-Meteo / CAMS ENSEMBLE pollen allergens",
"https://open-meteo.com/en/docs/air-quality-api",
"Pollen data",
)
Expand All @@ -113,7 +113,9 @@ def test_plain_text_renderer_uses_context_source_name_as_attribution() -> None:

rendered = PlainTextRenderer().render_briefing(result, (), (context,))

assert ("Open-Meteo / CAMS ENSEMBLE 花粉过敏原: https://open-meteo.com/en/docs/air-quality-api") in rendered.body
assert (
"Open-Meteo / CAMS ENSEMBLE pollen allergens: https://open-meteo.com/en/docs/air-quality-api"
) in rendered.body


def test_telegram_html_renders_active_warnings_section() -> None:
Expand Down
35 changes: 24 additions & 11 deletions tests/test_weather_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,9 @@ def handler(request: httpx.Request) -> httpx.Response:
).fetch(52.52, 13.41)

assert len(snapshot.weather_forecast) == 2
assert "局部多云" in snapshot.weather_forecast[0]
assert snapshot.output_language == "en"
assert "Partly cloudy" in snapshot.weather_forecast[0]
assert "maximum precipitation probability 10%" 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"
Expand All @@ -544,22 +546,31 @@ def handler(request: httpx.Request) -> httpx.Response:


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


def test_open_meteo_weather_code_lookup_uses_cached_loader_boundary(monkeypatch) -> None:
monkeypatch.setattr(
"weather_briefing.weather_context.open_meteo_weather_code_descriptions",
lambda: {53: "Reloaded description"},
)

assert _open_meteo_weather_description(53) == "Reloaded description"


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 description == "Unrecognized weather condition"
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 description == "Unrecognized weather condition"
assert "Invalid Open-Meteo weather code value_type=str" in caplog.text


Expand Down Expand Up @@ -615,7 +626,7 @@ def handler(request: httpx.Request) -> httpx.Response:
).fetch_for_date(39.9, 116.3, target_date)

assert len(snapshot.weather_forecast) == 1
assert snapshot.weather_forecast[0].startswith("2026-07-15")
assert snapshot.weather_forecast[0].startswith("2026-07-15: ")
assert snapshot.air_quality is not None
assert snapshot.air_quality.aqi == 90
assert snapshot.air_quality.effective_at is not None
Expand Down Expand Up @@ -1884,15 +1895,17 @@ def handler(request: httpx.Request) -> httpx.Response:
assert snapshot.air_quality is not None
assert snapshot.allergen is not None
assert snapshot.allergen.source_id == "allergen:open-meteo"
assert snapshot.allergen.source_name == "Open-Meteo / CAMS ENSEMBLE 花粉过敏原"
assert snapshot.allergen.source_name == "Open-Meteo / CAMS ENSEMBLE pollen allergens"
assert snapshot.allergen.output_language == "en"
level_names = {level.name for level in snapshot.allergen.levels}
assert {"桤木", "桦木", "禾本"} == level_names
birch = next(level for level in snapshot.allergen.levels if level.name == "桦木")
assert birch.category == ""
assert {"Alder", "Birch", "Grass"} == level_names
birch = next(level for level in snapshot.allergen.levels if level.name == "Birch")
assert birch.category == "Moderate"
assert birch.concentration == 15
assert snapshot.allergen.overall_category == ""
assert snapshot.allergen.overall_category == "Moderate"
documents = snapshot_to_documents(snapshot)
assert "allergen:open-meteo" in [doc.id for doc in documents]
assert all(doc.language == "en" for doc in documents)


async def test_open_meteo_provider_returns_no_allergen_when_pollen_absent() -> None:
Expand Down Expand Up @@ -1947,7 +1960,7 @@ def handler(request: httpx.Request) -> httpx.Response:

assert snapshot.allergen is not None
level_names = {level.name for level in snapshot.allergen.levels}
assert level_names == {"禾本"}
assert level_names == {"Grass"}


async def test_open_meteo_allergen_handles_missing_time_gracefully() -> None:
Expand Down
2 changes: 1 addition & 1 deletion weather_briefing/air_quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ async def fetch(
pm25_unit=None,
category=category,
health_guidance=guidance,
output_language="zh-CN",
output_language="en",
)


Expand Down
24 changes: 12 additions & 12 deletions weather_briefing/data/air_quality_guidance.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,33 @@
"bands": [
{
"maximum_aqi": 50,
"category": "",
"guidance": "空气污染风险很低,一般无需因空气质量调整户外活动。"
"category": "Good",
"guidance": "Air pollution risk is low; no activity changes are generally needed."
},
{
"maximum_aqi": 100,
"category": "",
"guidance": "极少数异常敏感人群应减少长时间、高强度户外活动。"
"category": "Moderate",
"guidance": "Unusually sensitive people should consider reducing prolonged or heavy outdoor exertion."
},
{
"maximum_aqi": 150,
"category": "对敏感人群不健康",
"guidance": "儿童、活跃成年人和呼吸系统疾病人群应减少长时间户外活动。"
"category": "Unhealthy for sensitive groups",
"guidance": "Children, active adults, and people with respiratory disease should reduce prolonged outdoor exertion."
},
{
"maximum_aqi": 200,
"category": "不健康",
"guidance": "敏感人群应避免长时间户外活动,其他人群也应适当减少户外活动。"
"category": "Unhealthy",
"guidance": "Sensitive groups should avoid prolonged outdoor exertion; everyone else should reduce it."
},
{
"maximum_aqi": 300,
"category": "非常不健康",
"guidance": "所有人应减少户外活动,敏感人群尽量避免外出。"
"category": "Very unhealthy",
"guidance": "Everyone should reduce outdoor activity, and sensitive groups should avoid going outdoors."
},
{
"maximum_aqi": null,
"category": "危险",
"guidance": "所有人应避免户外活动,并关注当地健康部门建议。"
"category": "Hazardous",
"guidance": "Everyone should avoid outdoor activity and follow local public health advice."
}
]
}
32 changes: 16 additions & 16 deletions weather_briefing/data/allergen_guidance.json
Original file line number Diff line number Diff line change
@@ -1,37 +1,37 @@
{
"pollen_types": {
"alder": "桤木",
"birch": "桦木",
"grass": "禾本",
"mugwort": "蒿属",
"olive": "橄榄",
"ragweed": "豚草"
"alder": "Alder",
"birch": "Birch",
"grass": "Grass",
"mugwort": "Mugwort",
"olive": "Olive",
"ragweed": "Ragweed"
},
"bands": [
{
"maximum_concentration": 0,
"category": "",
"guidance": "花粉浓度极低,过敏体质人群一般无需因花粉调整户外活动。"
"category": "None",
"guidance": "Pollen concentrations are negligible; no activity changes are generally needed."
},
{
"maximum_concentration": 10,
"category": "",
"guidance": "花粉浓度较低,少数极度敏感者可做适当防护。"
"category": "Low",
"guidance": "Pollen concentrations are low; highly sensitive people may take routine precautions."
},
{
"maximum_concentration": 30,
"category": "",
"guidance": "花粉浓度中等,过敏体质人群户外活动后建议清洗面部和鼻腔。"
"category": "Moderate",
"guidance": "Pollen concentrations are moderate; sensitive people should wash their face and nose after outdoor activity."
},
{
"maximum_concentration": 100,
"category": "",
"guidance": "花粉浓度较高,花粉过敏者应减少户外活动,外出佩戴口罩。"
"category": "High",
"guidance": "Pollen concentrations are high; people with pollen allergies should limit outdoor activity and wear a mask outside."
},
{
"maximum_concentration": null,
"category": "极高",
"guidance": "花粉浓度很高,花粉过敏者应尽量避免外出,关好门窗并使用空气净化设备。"
"category": "Very high",
"guidance": "Pollen concentrations are very high; people with pollen allergies should avoid going outside, close windows, and use air filtration."
}
]
}
58 changes: 29 additions & 29 deletions weather_briefing/data/open_meteo_weather_codes.json
Original file line number Diff line number Diff line change
@@ -1,32 +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": "雷暴伴强冰雹"
"descriptions_en": {
"0": "Clear sky",
"1": "Mainly clear",
"2": "Partly cloudy",
"3": "Overcast",
"45": "Fog",
"48": "Depositing rime fog",
"51": "Light drizzle",
"53": "Moderate drizzle",
"55": "Dense drizzle",
"56": "Light freezing drizzle",
"57": "Dense freezing drizzle",
"61": "Slight rain",
"63": "Moderate rain",
"65": "Heavy rain",
"66": "Light freezing rain",
"67": "Heavy freezing rain",
"71": "Slight snowfall",
"73": "Moderate snowfall",
"75": "Heavy snowfall",
"77": "Snow grains",
"80": "Slight rain showers",
"81": "Moderate rain showers",
"82": "Violent rain showers",
"85": "Slight snow showers",
"86": "Heavy snow showers",
"95": "Thunderstorm",
"96": "Thunderstorm with slight hail",
"99": "Thunderstorm with heavy hail"
}
}
Loading