From 6f80d318520ab8646a5949f42293f08dbbc12d82 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 8 Apr 2026 16:20:02 +0800 Subject: [PATCH] Add JMA Haneda temps and fix stale detail loading --- data/probability_training_snapshots.jsonl | 2 + .../components/dashboard/Dashboard.module.css | 29 ++++ .../components/dashboard/PanelSections.tsx | 15 ++ .../dashboard/PolyWeatherDashboard.tsx | 3 +- frontend/hooks/useDashboardStore.tsx | 65 +++++++- frontend/hooks/useLeafletMap.ts | 15 +- frontend/lib/dashboard-client.ts | 7 + src/data_collection/country_networks.py | 54 +++++++ src/data_collection/jma_amedas_sources.py | 145 ++++++++++++++++++ src/data_collection/weather_sources.py | 25 ++- 10 files changed, 347 insertions(+), 13 deletions(-) create mode 100644 src/data_collection/jma_amedas_sources.py diff --git a/data/probability_training_snapshots.jsonl b/data/probability_training_snapshots.jsonl index 692e7f87..bde941b5 100644 --- a/data/probability_training_snapshots.jsonl +++ b/data/probability_training_snapshots.jsonl @@ -124,3 +124,5 @@ {"city": "shanghai", "timestamp": "2026-03-29T15:00:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.23736458333333335, "deb_prediction": 18.4, "ensemble": {"p10": 16.1, "median": 16.5, "p90": 16.9}, "multi_model": {"Open-Meteo": 17.0, "ECMWF": 19.2, "GFS": 19.1, "ICON": 17.0, "GEM": 17.6, "JMA": 15.8}, "max_so_far": 18.0, "observation": {"current_temp": 14.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": 3.11, "local_hour": 23.25}, "peak_status": "past", "prob_snapshot": [{"v": 18, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} {"city": "ankara", "timestamp": "2026-03-29T15:01:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.25176666666666664, "deb_prediction": 9.7, "ensemble": {"p10": 9.2, "median": 9.5, "p90": 10.2}, "multi_model": {"Open-Meteo": 9.2, "ECMWF": 9.5, "GFS": 10.1, "ICON": 9.2, "GEM": 11.0, "JMA": 10.0}, "max_so_far": 10.0, "observation": {"current_temp": 7.0, "humidity": null, "wind_speed_kt": 12.0, "visibility_mi": null, "local_hour": 18.25}, "peak_status": "past", "prob_snapshot": [{"v": 10, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} {"city": "chengdu", "timestamp": "2026-04-08T07:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 1.1821289062499998, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 23.0, "p90": 24.5}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 23.0, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 24.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 15.183333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 24, "p": 0.442}, {"v": 25, "p": 0.386}, {"v": 26, "p": 0.172}], "shadow_prob_snapshot": [{"v": 24, "p": 0.394}, {"v": 25, "p": 0.355}, {"v": 26, "p": 0.19}, {"v": 27, "p": 0.06}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 1.354078466151897} +{"city": "chengdu", "timestamp": "2026-04-08T08:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 0.7283767361111106, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 22.9, "p90": 24.2}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 22.5, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 16.133333333333333}, "peak_status": "in_window", "prob_snapshot": [{"v": 24, "p": 0.547}, {"v": 25, "p": 0.397}, {"v": 26, "p": 0.056}], "shadow_prob_snapshot": [{"v": 24, "p": 0.521}, {"v": 25, "p": 0.399}, {"v": 26, "p": 0.08}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 0.8140063140878262} +{"city": "tokyo", "timestamp": "2026-04-08T08:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 17.810000000000002, "raw_sigma": 0.23200683593750038, "deb_prediction": 17.1, "ensemble": {"p10": 17.4, "median": 18.3, "p90": 19.1}, "multi_model": {"Open-Meteo": 16.1, "ECMWF": 16.3, "GFS": 17.6, "ICON": 18.2, "GEM": 18.3, "JMA": 16.1}, "max_so_far": 17.0, "observation": {"current_temp": 16.0, "humidity": null, "wind_speed_kt": 17.0, "visibility_mi": null, "local_hour": 17.133333333333333}, "peak_status": "past", "prob_snapshot": [{"v": 18, "p": 0.909}, {"v": 17, "p": 0.091}], "shadow_prob_snapshot": [{"v": 18, "p": 0.892}, {"v": 17, "p": 0.108}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 17.810000000000002, "calibrated_sigma": 0.25} diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index ed38fb2a..0ab302f9 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -961,6 +961,10 @@ gap: 8px; } +.root :global(.forecast-inline-note) { + line-height: 1.45; +} + .root :global(.forecast-day) { background: rgba(255, 255, 255, 0.03); border: 1px solid var(--border-subtle); @@ -1000,6 +1004,31 @@ color: var(--accent-cyan); } +@media (max-width: 720px) { + .root :global(.forecast-table) { + display: flex; + gap: 10px; + overflow-x: auto; + padding-bottom: 4px; + scroll-snap-type: x proximity; + } + + .root :global(.forecast-table::-webkit-scrollbar) { + height: 6px; + } + + .root :global(.forecast-table::-webkit-scrollbar-thumb) { + background: rgba(148, 163, 184, 0.32); + border-radius: 999px; + } + + .root :global(.forecast-day) { + flex: 0 0 112px; + min-width: 112px; + scroll-snap-align: start; + } +} + .root :global(.sun-info) { margin-top: 10px; font-size: 12px; diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index bff9ddd4..256c3b5d 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -724,6 +724,7 @@ export function ForecastTable() { if (!data) return null; const daily = data.forecast?.daily || []; + const isSparseDaily = daily.length <= 1; const resolveForecastTemp = (date: string, fallback: number | null | undefined) => { const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction; return debPrediction ?? fallback ?? null; @@ -731,6 +732,20 @@ export function ForecastTable() { return (

{t("forecast.title")}

+ {isSparseDaily && ( +
+ {store.loadingState.cityDetail + ? "多日预报同步中,正在刷新完整日序列。" + : "当前只收到当日预报,其他日期结果暂未回传。"} +
+ )}
{daily.length === 0 ? ( diff --git a/frontend/components/dashboard/PolyWeatherDashboard.tsx b/frontend/components/dashboard/PolyWeatherDashboard.tsx index 2e70fa98..653c2121 100644 --- a/frontend/components/dashboard/PolyWeatherDashboard.tsx +++ b/frontend/components/dashboard/PolyWeatherDashboard.tsx @@ -77,7 +77,8 @@ function DashboardScreen() { // Avoid full-page flashing on initial load; only show this overlay for manual refresh. const showLoading = store.loadingState.cities || - store.loadingState.refresh; + store.loadingState.refresh || + store.loadingState.cityDetail; return (
diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 003da142..a466db34 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -106,6 +106,11 @@ function countAvailableModels( ).length; } +function countForecastDays(detail?: CityDetail | null): number { + const daily = detail?.forecast?.daily; + return Array.isArray(daily) ? daily.length : 0; +} + function hasSparseModelCoverage( detail?: CityDetail | null, targetDate?: string | null, @@ -113,6 +118,16 @@ function hasSparseModelCoverage( return countAvailableModels(detail, targetDate) <= 1; } +function hasSparseDetailCoverage( + detail?: CityDetail | null, + targetDate?: string | null, +): boolean { + if (!detail) return true; + return ( + hasSparseModelCoverage(detail, targetDate) || countForecastDays(detail) <= 1 + ); +} + export function DashboardStoreProvider({ children, }: { @@ -244,7 +259,13 @@ export function DashboardStoreProvider({ const ensureCityDetail = async (cityName: string, force = false) => { const cached = cityDetailsByName[cityName]; const cachedMeta = cityDetailMetaByName[cityName]; - if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) { + const cachedIsSparse = hasSparseDetailCoverage(cached, cached?.local_date); + if ( + !force && + cached && + !cachedIsSparse && + dashboardClient.isCityDetailFresh(cachedMeta) + ) { scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta); return cached; } @@ -254,6 +275,28 @@ export function DashboardStoreProvider({ const summary = await dashboardClient.getCitySummary(cityName); const revision = getCityRevision(summary); if (revision && revision === cachedMeta?.revision) { + if (cachedIsSparse) { + const latestDetail = await dashboardClient.getCityDetail(cityName, { + force: true, + }); + const detail = latestDetail; + setCityDetailsByName((current) => ({ + ...current, + [cityName]: detail, + })); + setCitySummariesByName((current) => ({ + ...current, + [cityName]: toCitySummary(detail), + })); + setCityDetailMetaByName((current) => ({ + ...current, + [cityName]: { + cachedAt: Date.now(), + revision: getCityRevision(detail), + }, + })); + return detail; + } setCityDetailMetaByName((current) => ({ ...current, [cityName]: { @@ -472,9 +515,14 @@ export function DashboardStoreProvider({ await refreshProAccess(); } const access = proAccessRef.current; + const cachedDetail = cityDetailsByName[cityName]; + const needsDetailRefresh = hasSparseDetailCoverage( + cachedDetail, + cachedDetail?.local_date, + ); setLoadingState((current) => ({ ...current, cityDetail: true })); try { - const detail = await ensureCityDetail(cityName); + const detail = await ensureCityDetail(cityName, needsDetailRefresh); setSelectedForecastDate(detail.local_date); if (access.authenticated && access.subscriptionActive) { // 预热市场数据,不做 await 阻塞,后台静默拉取 @@ -621,9 +669,9 @@ export function DashboardStoreProvider({ setFutureModalDate(dateStr); if (!selectedCity || !proAccess.subscriptionActive) return; const cachedDetail = cityDetailsByName[selectedCity]; - const needsModelRefresh = - !forceRefresh && hasSparseModelCoverage(cachedDetail, dateStr); - if (needsModelRefresh) { + const needsDetailRefresh = + !forceRefresh && hasSparseDetailCoverage(cachedDetail, dateStr); + if (needsDetailRefresh) { void ensureCityDetail(selectedCity, true).catch(() => {}); } const cacheKey = getMarketScanCacheKey(selectedCity, dateStr); @@ -652,8 +700,9 @@ export function DashboardStoreProvider({ setFutureModalDate(cachedDetail.local_date); } if (!proAccess.subscriptionActive) return; - const needsModelRefresh = - !forceRefresh && hasSparseModelCoverage(cachedDetail, cachedDetail?.local_date); + const needsDetailRefresh = + !forceRefresh && + hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date); setLoadingState((current) => ({ ...current, @@ -664,7 +713,7 @@ export function DashboardStoreProvider({ try { const detail = await ensureCityDetail( selectedCity, - Boolean(forceRefresh || needsModelRefresh), + Boolean(forceRefresh || needsDetailRefresh), ); setSelectedForecastDate(detail.local_date); setFutureModalDate(detail.local_date); diff --git a/frontend/hooks/useLeafletMap.ts b/frontend/hooks/useLeafletMap.ts index 7a2b5a77..96b39a23 100644 --- a/frontend/hooks/useLeafletMap.ts +++ b/frontend/hooks/useLeafletMap.ts @@ -342,9 +342,18 @@ export function useLeafletMap({ }).addTo(map); marker.on("click", () => { - map.stop(); - // Reset lastMovedCity so we can re-fly if needed - lastMovedCityRef.current = null; + const currentMap = mapRef.current; + currentMap?.stop(); + if (currentMap && !suspendMotion) { + currentMap.flyTo([city.lat, city.lon], 11, { + animate: true, + duration: 1.05, + easeLinearity: 0.22, + }); + lastMovedCityRef.current = city.name; + } else { + lastMovedCityRef.current = null; + } onSelectCityRef.current(city.name); }); diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index 1f3b7dd0..c9bc224c 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -57,6 +57,12 @@ export function getCityRevision(source?: CityDetail | CitySummary | null) { ? source.multi_model_daily?.[source.local_date || ""] : null; const modelFootprint = modelDaily?.models || ("multi_model" in source ? source.multi_model : null); + const forecastFootprint = + "forecast" in source && Array.isArray(source.forecast?.daily) + ? source.forecast.daily + .map((item) => `${normalizeRevisionPart(item?.date)}:${normalizeRevisionPart(item?.max_temp)}`) + .join("|") + : ""; return [ normalizeRevisionPart(source.updated_at), normalizeRevisionPart(source.current?.obs_time), @@ -70,6 +76,7 @@ export function getCityRevision(source?: CityDetail | CitySummary | null) { .join("|") : "", ), + normalizeRevisionPart(forecastFootprint), ].join("|"); } diff --git a/src/data_collection/country_networks.py b/src/data_collection/country_networks.py index c186625e..5c7cfd7b 100644 --- a/src/data_collection/country_networks.py +++ b/src/data_collection/country_networks.py @@ -16,6 +16,10 @@ CHINA_CMA_CITIES = { } +def _japan_jma_cities() -> set[str]: + return {"tokyo"} + + def _safe_float(value: Any) -> Optional[float]: try: if value is None or value == "": @@ -39,6 +43,8 @@ def _provider_code_for_city(city: str) -> str: return "hongkong_hko" if settlement_source == "cwa": return "taiwan_cwa" + if normalized in _japan_jma_cities(): + return "japan_jma" if normalized in CHINA_CMA_CITIES: return "china_cma" return "global_metar" @@ -175,6 +181,30 @@ def _nmc_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]: return out +def _jma_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]: + rows = raw.get("jma_official_nearby") or [] + out: List[Dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + out.append( + _normalize_station_row( + station_code=row.get("icao") or row.get("istNo"), + station_label=row.get("name"), + temp=row.get("temp"), + lat=row.get("lat"), + lon=row.get("lon"), + obs_time=row.get("obs_time"), + source_code="jma", + source_label="JMA", + is_official=True, + is_airport_station=False, + is_settlement_anchor=False, + ) + ) + return out + + def _mgm_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]: meta = _city_meta(city) rows = raw.get("mgm_nearby") or [] @@ -393,6 +423,28 @@ class ChinaCmaNetworkProvider(CountryNetworkProvider): } +class JapanJmaNetworkProvider(CountryNetworkProvider): + def __init__(self) -> None: + super().__init__("japan_jma", "JMA") + + def official_nearby_current(self, city: str, raw: Dict[str, Any]) -> List[Dict[str, Any]]: + rows = _jma_rows(raw, city) + if rows: + return rows + return _metar_cluster_rows(raw) + + def official_network_status(self, city: str, raw: Dict[str, Any]) -> Dict[str, Any]: + rows = self.official_nearby_current(city, raw) + has_jma = bool(_jma_rows(raw, city)) + return { + "provider_code": self.provider_code, + "provider_label": self.provider_label, + "available": has_jma, + "mode": "official_active" if has_jma else ("fallback_metar_cluster" if rows else "reference_only"), + "row_count": len(rows), + } + + class HongKongHkoNetworkProvider(CountryNetworkProvider): def __init__(self) -> None: super().__init__("hongkong_hko", "HKO") @@ -415,6 +467,8 @@ def get_country_network_provider(city: str) -> CountryNetworkProvider: provider_code = _provider_code_for_city(city) if provider_code == "turkey_mgm": return TurkeyMgmNetworkProvider() + if provider_code == "japan_jma": + return JapanJmaNetworkProvider() if provider_code == "china_cma": return ChinaCmaNetworkProvider() if provider_code == "hongkong_hko": diff --git a/src/data_collection/jma_amedas_sources.py b/src/data_collection/jma_amedas_sources.py new file mode 100644 index 00000000..ff2ac32f --- /dev/null +++ b/src/data_collection/jma_amedas_sources.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import threading +import time +from datetime import datetime +from typing import Any, Dict, List, Optional + +from loguru import logger + +from src.utils.metrics import record_source_call + + +JMA_AMEDAS_STATIONS: Dict[str, Dict[str, Any]] = { + "tokyo": { + "station_code": "44166", + "station_label": "羽田 10分实况 (JMA)", + "lat": 35.5533, + "lon": 139.78, + }, +} + + +class JmaAmedasSourceMixin: + def _jma_http_get_text(self, url: str) -> str: + getter = getattr(self, "_http_get", None) + if callable(getter): + response = getter(url) + else: + response = self.session.get(url, timeout=self.timeout) + response.raise_for_status() + return response.text + + @staticmethod + def _jma_safe_float(value: Any) -> Optional[float]: + try: + if value in (None, "", "///"): + return None + return float(value) + except Exception: + return None + + def fetch_jma_amedas_current( + self, + city: str, + use_fahrenheit: bool = False, + ) -> Optional[Dict[str, Any]]: + started = time.perf_counter() + city_key = str(city or "").strip().lower() + meta = JMA_AMEDAS_STATIONS.get(city_key) or {} + if not meta: + record_source_call("jma_amedas", "current", "unsupported_city", (time.perf_counter() - started) * 1000.0) + return None + + cache_key = f"{city_key}:{use_fahrenheit}" + now_ts = time.time() + with self._jma_cache_lock: + cached = self._jma_cache.get(cache_key) + if cached and now_ts - cached["t"] < self.jma_cache_ttl_sec: + record_source_call("jma_amedas", "current", "cache_hit", (time.perf_counter() - started) * 1000.0) + return cached["d"] + + try: + latest_time_text = self._jma_http_get_text( + "https://www.jma.go.jp/bosai/amedas/data/latest_time.txt" + ).strip() + latest_dt = datetime.fromisoformat(latest_time_text) + bucket_hour = (latest_dt.hour // 3) * 3 + bucket_key = f"{latest_dt.strftime('%Y%m%d')}_{bucket_hour:02d}" + station_code = str(meta.get("station_code") or "").strip() + url = f"https://www.jma.go.jp/bosai/amedas/data/point/{station_code}/{bucket_key}.json" + + getter = getattr(self, "_http_get_json", None) + if callable(getter): + payload = getter(url) + else: + response = self.session.get(url, timeout=self.timeout) + response.raise_for_status() + payload = response.json() + + if not isinstance(payload, dict) or not payload: + record_source_call("jma_amedas", "current", "empty", (time.perf_counter() - started) * 1000.0) + return None + + latest_key = sorted(payload.keys())[-1] + row = payload.get(latest_key) or {} + temp_pair = row.get("temp") or [] + temp_c = self._jma_safe_float(temp_pair[0] if isinstance(temp_pair, list) and temp_pair else None) + if temp_c is None: + record_source_call("jma_amedas", "current", "no_temperature", (time.perf_counter() - started) * 1000.0) + return None + + temp = round(temp_c * 9 / 5 + 32, 1) if use_fahrenheit else round(temp_c, 1) + obs_time = None + try: + obs_time = datetime.strptime(str(latest_key), "%Y%m%d%H%M%S").isoformat() + except Exception: + obs_time = str(latest_key) + + result = { + "source": "jma_amedas", + "timestamp": datetime.utcnow().isoformat(), + "station_code": station_code, + "station_name": meta.get("station_label") or "羽田 10分实况 (JMA)", + "obs_time": obs_time, + "current": { + "temp": temp, + }, + } + with self._jma_cache_lock: + self._jma_cache[cache_key] = {"d": result, "t": now_ts} + record_source_call("jma_amedas", "current", "success", (time.perf_counter() - started) * 1000.0) + return result + except Exception as exc: + logger.warning("JMA AMeDAS current fetch failed city={} error={}", city_key, exc) + with self._jma_cache_lock: + stale = self._jma_cache.get(cache_key) + if stale: + record_source_call("jma_amedas", "current", "stale_cache", (time.perf_counter() - started) * 1000.0) + return stale["d"] + record_source_call("jma_amedas", "current", "error", (time.perf_counter() - started) * 1000.0) + return None + + def fetch_jma_amedas_official_nearby( + self, + city: str, + use_fahrenheit: bool = False, + ) -> List[Dict[str, Any]]: + current = self.fetch_jma_amedas_current(city, use_fahrenheit=use_fahrenheit) + if not current: + return [] + meta = JMA_AMEDAS_STATIONS.get(str(city or "").strip().lower()) or {} + return [ + { + "name": meta.get("station_label") or "羽田 10分实况 (JMA)", + "station_label": meta.get("station_label") or "羽田 10分实况 (JMA)", + "lat": meta.get("lat"), + "lon": meta.get("lon"), + "temp": (current.get("current") or {}).get("temp"), + "icao": current.get("station_code"), + "istNo": current.get("station_code"), + "source": "jma", + "source_label": "JMA", + "obs_time": current.get("obs_time"), + } + ] diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index cf96eb33..995f182c 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -10,11 +10,12 @@ from src.data_collection.open_meteo_cache import OpenMeteoCacheMixin from src.data_collection.settlement_sources import SettlementSourceMixin from src.data_collection.metar_sources import MetarSourceMixin from src.data_collection.mgm_sources import MgmSourceMixin +from src.data_collection.jma_amedas_sources import JmaAmedasSourceMixin from src.data_collection.nmc_sources import NmcSourceMixin from src.data_collection.nws_open_meteo_sources import NwsOpenMeteoSourceMixin -class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin): +class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin): """ Multi-source weather data collector @@ -167,6 +168,11 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour ) self._nmc_cache: Dict[str, Dict] = {} self._nmc_cache_lock = threading.Lock() + self.jma_cache_ttl_sec = int( + os.getenv("JMA_AMEDAS_CACHE_TTL_SEC", "300") + ) + self._jma_cache: Dict[str, Dict] = {} + self._jma_cache_lock = threading.Lock() self.settlement_cache_ttl_sec = int( os.getenv("SETTLEMENT_SOURCE_CACHE_TTL_SEC", "120") ) @@ -756,6 +762,21 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour results["mgm_nearby"] = official_rows results["nearby_source"] = "nmc" + def _attach_japan_official_nearby( + self, results: Dict, city_lower: str, use_fahrenheit: bool + ) -> None: + if city_lower != "tokyo": + return + official_rows = self.fetch_jma_amedas_official_nearby( + city_lower, use_fahrenheit=use_fahrenheit + ) + if not official_rows: + return + results["jma_official_nearby"] = official_rows + if "mgm_nearby" not in results: + results["mgm_nearby"] = official_rows + results["nearby_source"] = "jma" + def _attach_warsaw_official_nearby( self, results: Dict, use_fahrenheit: bool ) -> None: @@ -851,6 +872,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour self._attach_turkish_mgm_data(results, city_lower) self._attach_china_official_nearby(results, city_lower, use_fahrenheit) + self._attach_japan_official_nearby(results, city_lower, use_fahrenheit) if city_lower == "warsaw": self._attach_warsaw_official_nearby(results, use_fahrenheit) self._attach_global_nearby_cluster( @@ -878,6 +900,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour self._attach_turkish_mgm_data(results, city_lower) self._attach_china_official_nearby(results, city_lower, use_fahrenheit) + self._attach_japan_official_nearby(results, city_lower, use_fahrenheit) if city_lower == "warsaw": self._attach_warsaw_official_nearby(results, use_fahrenheit) self._attach_global_nearby_cluster(