Add HKO official observation series for Hong Kong charts
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+25
-8
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user