Add HKO official observation series for Hong Kong charts

This commit is contained in:
2569718930@qq.com
2026-03-23 21:22:41 +08:00
parent 5e31bc9ea3
commit b1102400f6
4 changed files with 134 additions and 10 deletions
@@ -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 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: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": "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}
+9 -1
View File
@@ -252,8 +252,10 @@ export function getTemperatureChartData(
const metarObservationSource = detail.metar_today_obs?.length const metarObservationSource = detail.metar_today_obs?.length
? detail.metar_today_obs ? detail.metar_today_obs
: detail.trend?.recent || []; : detail.trend?.recent || [];
const allowMetarFallback =
settlementSource && observationCode !== "hko";
const shouldUseMetarFallback = const shouldUseMetarFallback =
settlementSource && allowMetarFallback &&
officialObservationSource.length > 0 && officialObservationSource.length > 0 &&
officialObservationSource.length < 3 && officialObservationSource.length < 3 &&
metarObservationSource.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}.` ? `Official ${observationTag} feed is sparse today, so the continuous observation line switches to ${metarFallbackTag}.`
: `今日官方 ${observationTag} 点位较稀疏,连续实测线改用 ${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") { } else if (observationCode === "noaa") {
legendParts.push( legendParts.push(
isEnglish(locale) isEnglish(locale)
+99 -1
View File
@@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
import csv import csv
import json
import math import math
import os
import threading
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -113,6 +116,90 @@ class SettlementSourceMixin:
return row return row
return rows[0] if rows else None 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]]: def fetch_hko_settlement_current(self) -> Optional[Dict[str, Any]]:
cache_key = "hko:hong_kong" cache_key = "hko:hong_kong"
cached = self._get_settlement_cache(cache_key) cached = self._get_settlement_cache(cache_key)
@@ -154,6 +241,16 @@ class SettlementSourceMixin:
wind_dir = self._hko_compass_to_deg( wind_dir = self._hko_compass_to_deg(
wind_row.get("10-Minute Mean Wind Direction(Compass points)") if wind_row else None 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] = { payload: Dict[str, Any] = {
"source": "hko", "source": "hko",
@@ -164,12 +261,13 @@ class SettlementSourceMixin:
"current": { "current": {
"temp": round(current_temp, 1) if current_temp is not None else None, "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_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, "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, "humidity": round(humidity, 1) if humidity is not None else None,
"wind_speed_kt": wind_speed_kt, "wind_speed_kt": wind_speed_kt,
"wind_dir": wind_dir, "wind_dir": wind_dir,
}, },
"today_obs": today_obs,
"unit": "celsius", "unit": "celsius",
} }
self._set_settlement_cache(cache_key, payload) self._set_settlement_cache(cache_key, payload)
+25 -8
View File
@@ -138,14 +138,31 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
settlement_today_obs = [] settlement_today_obs = []
if use_settlement_current: if use_settlement_current:
if obs_time_str and cur_temp is not None: explicit_settlement_obs = settlement_current.get("today_obs") or []
settlement_today_obs.append({"time": obs_time_str, "temp": cur_temp}) normalized_obs = []
if ( for item in explicit_settlement_obs:
max_temp_time if isinstance(item, dict):
and max_so_far is not None raw_time = str(item.get("time") or "").strip()
and str(max_temp_time) != str(obs_time_str) raw_temp = _sf(item.get("temp"))
): elif isinstance(item, (list, tuple)) and len(item) >= 2:
settlement_today_obs.append({"time": str(max_temp_time), "temp": max_so_far}) 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 = [ metar_today_obs_payload = [
{"time": t, "temp": v} {"time": t, "temp": v}