diff --git a/data/probability_training_snapshots.jsonl b/data/probability_training_snapshots.jsonl index e9c1f1d9..3c1c98f5 100644 --- a/data/probability_training_snapshots.jsonl +++ b/data/probability_training_snapshots.jsonl @@ -116,3 +116,4 @@ {"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875} {"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375} {"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375} +{"city": "hong kong", "timestamp": "2026-03-23T21:10:00+08:00", "date": "2026-03-23", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.6806250000000001, "deb_prediction": 25.2, "ensemble": {"p10": 26.3, "median": 26.4, "p90": 26.6}, "multi_model": {"Open-Meteo": 24.8, "HKO(港天文)": 27.0, "ECMWF": 25.4, "GFS": 25.1, "ICON": 24.8, "GEM": 25.3, "JMA": 23.6}, "max_so_far": 27.4, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 108d0596..6c7ae5b5 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -252,8 +252,10 @@ export function getTemperatureChartData( const metarObservationSource = detail.metar_today_obs?.length ? detail.metar_today_obs : detail.trend?.recent || []; + const allowMetarFallback = + settlementSource && observationCode !== "hko"; const shouldUseMetarFallback = - settlementSource && + allowMetarFallback && officialObservationSource.length > 0 && officialObservationSource.length < 3 && metarObservationSource.length >= 3; @@ -366,6 +368,12 @@ export function getTemperatureChartData( ? `Official ${observationTag} feed is sparse today, so the continuous observation line switches to ${metarFallbackTag}.` : `今日官方 ${observationTag} 点位较稀疏,连续实测线改用 ${metarFallbackTag}。`, ); + } else if (observationCode === "hko") { + legendParts.push( + isEnglish(locale) + ? "Hong Kong uses HKO official readings. The chart keeps official HKO points instead of switching to airport METAR." + : "香港按 HKO 官方读数展示;图中保留 HKO 官方点位,不切换到机场 METAR 连续线。", + ); } else if (observationCode === "noaa") { legendParts.push( isEnglish(locale) diff --git a/src/data_collection/settlement_sources.py b/src/data_collection/settlement_sources.py index fa101112..e8eed266 100644 --- a/src/data_collection/settlement_sources.py +++ b/src/data_collection/settlement_sources.py @@ -1,7 +1,10 @@ from __future__ import annotations import csv +import json import math +import os +import threading import time from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional @@ -113,6 +116,90 @@ class SettlementSourceMixin: return row return rows[0] if rows else None + def _get_runtime_data_dir(self) -> str: + configured = str(os.getenv("POLYWEATHER_RUNTIME_DATA_DIR") or "").strip() + if configured: + return configured + project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + return os.path.join(project_root, "data") + + def _get_settlement_series_lock(self) -> threading.Lock: + lock = getattr(self, "_settlement_series_lock", None) + if lock is not None: + return lock + lock = threading.Lock() + setattr(self, "_settlement_series_lock", lock) + return lock + + def _hko_today_obs_path(self) -> str: + return os.path.join(self._get_runtime_data_dir(), "hko_hong_kong_today_obs.json") + + @staticmethod + def _sort_temp_points(points: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def _key(item: Dict[str, Any]) -> tuple: + raw = str(item.get("time") or "") + try: + hh, mm = raw.split(":") + return int(hh), int(mm) + except Exception: + return (99, 99) + + return sorted(points, key=_key) + + def _update_hko_today_obs( + self, + *, + obs_iso: Optional[str], + current_temp: Optional[float], + ) -> List[Dict[str, Any]]: + if not obs_iso or current_temp is None: + return [] + + try: + obs_dt = datetime.fromisoformat(str(obs_iso).replace("Z", "+00:00")) + except Exception: + return [] + if obs_dt.tzinfo is None: + obs_dt = obs_dt.replace(tzinfo=timezone(timedelta(hours=8))) + local_dt = obs_dt.astimezone(timezone(timedelta(hours=8))) + date_str = local_dt.strftime("%Y-%m-%d") + time_str = local_dt.strftime("%H:%M") + path = self._hko_today_obs_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + + lock = self._get_settlement_series_lock() + with lock: + payload: Dict[str, Any] = {} + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) or {} + except Exception: + payload = {} + + existing_date = str(payload.get("date") or "").strip() + if existing_date != date_str: + payload = {"date": date_str, "points": []} + + indexed: Dict[str, Dict[str, Any]] = {} + for raw_point in payload.get("points") or []: + if not isinstance(raw_point, dict): + continue + raw_time = str(raw_point.get("time") or "").strip() + raw_temp = self._safe_float(raw_point.get("temp")) + if not raw_time or raw_temp is None: + continue + indexed[raw_time] = {"time": raw_time, "temp": round(raw_temp, 1)} + + indexed[time_str] = {"time": time_str, "temp": round(float(current_temp), 1)} + points = self._sort_temp_points(list(indexed.values())) + payload = {"date": date_str, "station_code": "HKO", "points": points} + + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, ensure_ascii=False) + + return points + def fetch_hko_settlement_current(self) -> Optional[Dict[str, Any]]: cache_key = "hko:hong_kong" cached = self._get_settlement_cache(cache_key) @@ -154,6 +241,16 @@ class SettlementSourceMixin: wind_dir = self._hko_compass_to_deg( wind_row.get("10-Minute Mean Wind Direction(Compass points)") if wind_row else None ) + today_obs = self._update_hko_today_obs( + obs_iso=obs_iso, + current_temp=current_temp, + ) + derived_max_time = None + if today_obs and max_so_far is not None: + hottest = max(today_obs, key=lambda item: float(item.get("temp") or -999)) + hottest_temp = self._safe_float(hottest.get("temp")) + if hottest_temp is not None and abs(hottest_temp - float(max_so_far)) <= 0.05: + derived_max_time = str(hottest.get("time") or "").strip() or None payload: Dict[str, Any] = { "source": "hko", @@ -164,12 +261,13 @@ class SettlementSourceMixin: "current": { "temp": round(current_temp, 1) if current_temp is not None else None, "max_temp_so_far": round(max_so_far, 1) if max_so_far is not None else None, - "max_temp_time": None, + "max_temp_time": derived_max_time, "today_low": round(min_so_far, 1) if min_so_far is not None else None, "humidity": round(humidity, 1) if humidity is not None else None, "wind_speed_kt": wind_speed_kt, "wind_dir": wind_dir, }, + "today_obs": today_obs, "unit": "celsius", } self._set_settlement_cache(cache_key, payload) diff --git a/web/analysis_service.py b/web/analysis_service.py index e02209d8..d3bf5dbc 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -138,14 +138,31 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: settlement_today_obs = [] if use_settlement_current: - if obs_time_str and cur_temp is not None: - settlement_today_obs.append({"time": obs_time_str, "temp": cur_temp}) - if ( - max_temp_time - and max_so_far is not None - and str(max_temp_time) != str(obs_time_str) - ): - settlement_today_obs.append({"time": str(max_temp_time), "temp": max_so_far}) + explicit_settlement_obs = settlement_current.get("today_obs") or [] + normalized_obs = [] + for item in explicit_settlement_obs: + if isinstance(item, dict): + raw_time = str(item.get("time") or "").strip() + raw_temp = _sf(item.get("temp")) + elif isinstance(item, (list, tuple)) and len(item) >= 2: + raw_time = str(item[0] or "").strip() + raw_temp = _sf(item[1]) + else: + continue + if not raw_time or raw_temp is None: + continue + normalized_obs.append({"time": raw_time, "temp": raw_temp}) + if normalized_obs: + settlement_today_obs = normalized_obs + else: + if obs_time_str and cur_temp is not None: + settlement_today_obs.append({"time": obs_time_str, "temp": cur_temp}) + if ( + max_temp_time + and max_so_far is not None + and str(max_temp_time) != str(obs_time_str) + ): + settlement_today_obs.append({"time": str(max_temp_time), "temp": max_so_far}) metar_today_obs_payload = [ {"time": t, "temp": v}