From 7adec4e17cf2b7f4af6eda2b6e9c24f191322fce Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 23 Mar 2026 23:17:48 +0800 Subject: [PATCH] Add peak-12h DEB history reference and bump extension to 0.1.5 --- extension/manifest.json | 2 +- .../components/dashboard/HistoryModal.tsx | 115 +++++++++++++++++- frontend/lib/dashboard-types.ts | 4 + src/analysis/probability_snapshot_archive.py | 46 +++++++ src/database/runtime_state.py | 19 +++ web/routes.py | 84 +++++++++++++ 6 files changed, 268 insertions(+), 2 deletions(-) diff --git a/extension/manifest.json b/extension/manifest.json index 425d4953..3f07ae22 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "PolyWeather Side Panel", "description": "PolyWeather 右侧城市卡片(浏览器侧边栏)", - "version": "0.1.4", + "version": "0.1.5", "icons": { "16": "icon-16.png", "32": "icon-32.png", diff --git a/frontend/components/dashboard/HistoryModal.tsx b/frontend/components/dashboard/HistoryModal.tsx index 93e693e5..43734d07 100644 --- a/frontend/components/dashboard/HistoryModal.tsx +++ b/frontend/components/dashboard/HistoryModal.tsx @@ -155,7 +155,7 @@ function HistoryChart() { export function HistoryModal() { const store = useDashboardStore(); - const { t } = useI18n(); + const { t, locale } = useI18n(); const { data, error, isLoading, isOpen } = useHistoryData(); const isPro = store.proAccess.subscriptionActive; const isProLoading = store.proAccess.loading; @@ -164,6 +164,19 @@ export function HistoryModal() { () => getHistorySummary(data, store.selectedDetail?.local_date), [data, store.selectedDetail?.local_date], ); + const settledPeakRows = useMemo( + () => + summary.recentData + .filter( + (row) => + row.actual != null && + row.actual_peak_time && + row.deb_at_peak_minus_12h != null, + ) + .slice(-8) + .reverse(), + [summary.recentData], + ); if (!isOpen) return null; @@ -283,6 +296,106 @@ export function HistoryModal() { )} {!isLoading && !error && } + {!isLoading && !error && settledPeakRows.length > 0 && ( +
+
+ {locale === "en-US" + ? "Peak-12h DEB Reference (Approx.)" + : "峰值前 12 小时 DEB 参考(近似)"} +
+
+ {settledPeakRows.map((row) => ( +
+
+ {row.date} +
+
+
+ {locale === "en-US" ? "Peak ref" : "峰值参考"}:{" "} + + {row.actual} + {store.selectedDetail?.temp_symbol || "°C"} @{" "} + {row.actual_peak_time} + +
+
+ {locale === "en-US" ? "DEB@-12h" : "峰值前12小时 DEB"}:{" "} + + {row.deb_at_peak_minus_12h} + {store.selectedDetail?.temp_symbol || "°C"} @{" "} + {row.deb_at_peak_minus_12h_time} + +
+
+ {locale === "en-US" ? "Actual" : "最终实测"}:{" "} + + {row.actual} + {store.selectedDetail?.temp_symbol || "°C"} + +
+
+ {locale === "en-US" ? "Error" : "误差"}:{" "} + 0 + ? "#f59e0b" + : "#34d399", + }} + > + {row.deb_at_peak_minus_12h_error != null + ? `${row.deb_at_peak_minus_12h_error > 0 ? "+" : ""}${row.deb_at_peak_minus_12h_error}${store.selectedDetail?.temp_symbol || "°C"}` + : "--"} + +
+
+
+ ))} +
+
+ )} )} diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index c8f208f3..25f5e075 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -312,6 +312,10 @@ export interface HistoryPoint { mu?: number | null; mgm?: number | null; forecasts?: Record; + actual_peak_time?: string | null; + deb_at_peak_minus_12h?: number | null; + deb_at_peak_minus_12h_time?: string | null; + deb_at_peak_minus_12h_error?: number | null; } export interface LoadingState { diff --git a/src/analysis/probability_snapshot_archive.py b/src/analysis/probability_snapshot_archive.py index 02823cf8..fe554306 100644 --- a/src/analysis/probability_snapshot_archive.py +++ b/src/analysis/probability_snapshot_archive.py @@ -86,6 +86,52 @@ def _load_recent_rows(path: str, max_lines: int = DEDUP_SCAN_LINES) -> List[Dict return rows +def load_snapshot_rows_for_day( + city_name: str, + target_date: str, + archive_path: Optional[str] = None, +) -> List[Dict[str, Any]]: + city_key = str(city_name or "").strip().lower() + date_key = str(target_date or "").strip() + if not city_key or not date_key: + return [] + + mode = get_state_storage_mode() + if mode == STATE_STORAGE_SQLITE: + return _snapshot_repo.load_rows_by_city_date(city_key, date_key) + + root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + path = archive_path or os.path.join(root_dir, "data", "probability_training_snapshots.jsonl") + if not os.path.exists(path): + if mode == STATE_STORAGE_DUAL: + return _snapshot_repo.load_rows_by_city_date(city_key, date_key) + return [] + + rows: List[Dict[str, Any]] = [] + try: + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except Exception: + continue + if not isinstance(row, dict): + continue + if str(row.get("city") or "").strip().lower() != city_key: + continue + if str(row.get("date") or "").strip() != date_key: + continue + rows.append(row) + except Exception: + return [] + + rows.sort(key=lambda row: str(row.get("timestamp") or "")) + return rows + + def _should_skip_append(path: str, payload: Dict[str, Any]) -> bool: mode = get_state_storage_mode() if mode == STATE_STORAGE_SQLITE: diff --git a/src/database/runtime_state.py b/src/database/runtime_state.py index fba44700..3896ae51 100644 --- a/src/database/runtime_state.py +++ b/src/database/runtime_state.py @@ -362,6 +362,25 @@ class ProbabilitySnapshotRepository: continue return out + def load_rows_by_city_date(self, city: str, target_date: str) -> List[Dict[str, Any]]: + with self.db.connect() as conn: + rows = conn.execute( + """ + SELECT payload_json + FROM probability_training_snapshots_store + WHERE city = ? AND target_date = ? + ORDER BY timestamp ASC, id ASC + """, + (city, target_date), + ).fetchall() + out = [] + for row in rows: + try: + out.append(json.loads(row["payload_json"])) + except Exception: + continue + return out + def load_all_rows(self) -> List[Dict[str, Any]]: with self.db.connect() as conn: rows = conn.execute( diff --git a/web/routes.py b/web/routes.py index 5776f3fc..32d03c71 100644 --- a/web/routes.py +++ b/web/routes.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, HTTPException, Request @@ -8,6 +9,7 @@ from fastapi.responses import PlainTextResponse from loguru import logger from src.analysis.deb_algorithm import load_history +from src.analysis.probability_snapshot_archive import load_snapshot_rows_for_day from src.data_collection.city_registry import ALIASES from src.utils.metrics import export_prometheus_metrics from web.analysis_service import ( @@ -47,6 +49,79 @@ from web.core import ( router = APIRouter() +def _parse_snapshot_dt(value: object) -> Optional[datetime]: + raw = str(value or "").strip() + if not raw: + return None + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + return None + + +def _build_peak_minus_12h_reference( + *, + actual_high: object, + snapshots: list[dict], +) -> dict: + actual = _sf(actual_high) + if actual is None or not snapshots: + return {} + + tolerance = 0.11 + normalized = [] + for row in snapshots: + if not isinstance(row, dict): + continue + dt = _parse_snapshot_dt(row.get("timestamp")) + if dt is None: + continue + normalized.append( + { + "dt": dt, + "max_so_far": _sf(row.get("max_so_far")), + "deb_prediction": _sf(row.get("deb_prediction")), + } + ) + if not normalized: + return {} + + peak_row = next( + ( + row + for row in normalized + if row["max_so_far"] is not None and row["max_so_far"] >= actual - tolerance + ), + None, + ) + if peak_row is None: + return {} + + peak_dt = peak_row["dt"] + anchor_dt = peak_dt - timedelta(hours=12) + anchor_row = None + for row in normalized: + if row["dt"] <= anchor_dt and row["deb_prediction"] is not None: + anchor_row = row + elif row["dt"] > anchor_dt: + break + + peak_time = peak_dt.strftime("%H:%M") + result = { + "actual_peak_time": peak_time, + } + if anchor_row and anchor_row["deb_prediction"] is not None: + deb_value = float(anchor_row["deb_prediction"]) + result.update( + { + "deb_at_peak_minus_12h": deb_value, + "deb_at_peak_minus_12h_time": anchor_row["dt"].strftime("%H:%M"), + "deb_at_peak_minus_12h_error": round(deb_value - actual, 1), + } + ) + return result + + def _normalize_city_or_404(name: str) -> str: city = name.lower().strip().replace("-", " ") city = ALIASES.get(city, city) @@ -138,6 +213,11 @@ async def city_history(request: Request, name: str): act = rec.get("actual_high") deb = rec.get("deb_prediction") mu = rec.get("mu") + snapshots = load_snapshot_rows_for_day(city, day) + peak_ref = _build_peak_minus_12h_reference( + actual_high=act, + snapshots=snapshots, + ) forecasts_raw = rec.get("forecasts", {}) or {} forecasts = {} if isinstance(forecasts_raw, dict): @@ -155,6 +235,10 @@ async def city_history(request: Request, name: str): "mu": float(mu) if mu is not None else None, "mgm": float(mgm) if mgm is not None else None, "forecasts": forecasts, + "actual_peak_time": peak_ref.get("actual_peak_time"), + "deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"), + "deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"), + "deb_at_peak_minus_12h_error": peak_ref.get("deb_at_peak_minus_12h_error"), } )