Add peak-12h DEB history reference and bump extension to 0.1.5

This commit is contained in:
2569718930@qq.com
2026-03-23 23:17:48 +08:00
parent 354d55f752
commit 7adec4e17c
6 changed files with 268 additions and 2 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "PolyWeather Side Panel", "name": "PolyWeather Side Panel",
"description": "PolyWeather 右侧城市卡片(浏览器侧边栏)", "description": "PolyWeather 右侧城市卡片(浏览器侧边栏)",
"version": "0.1.4", "version": "0.1.5",
"icons": { "icons": {
"16": "icon-16.png", "16": "icon-16.png",
"32": "icon-32.png", "32": "icon-32.png",
+114 -1
View File
@@ -155,7 +155,7 @@ function HistoryChart() {
export function HistoryModal() { export function HistoryModal() {
const store = useDashboardStore(); const store = useDashboardStore();
const { t } = useI18n(); const { t, locale } = useI18n();
const { data, error, isLoading, isOpen } = useHistoryData(); const { data, error, isLoading, isOpen } = useHistoryData();
const isPro = store.proAccess.subscriptionActive; const isPro = store.proAccess.subscriptionActive;
const isProLoading = store.proAccess.loading; const isProLoading = store.proAccess.loading;
@@ -164,6 +164,19 @@ export function HistoryModal() {
() => getHistorySummary(data, store.selectedDetail?.local_date), () => getHistorySummary(data, store.selectedDetail?.local_date),
[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; if (!isOpen) return null;
@@ -283,6 +296,106 @@ export function HistoryModal() {
)} )}
</div> </div>
{!isLoading && !error && <HistoryChart />} {!isLoading && !error && <HistoryChart />}
{!isLoading && !error && settledPeakRows.length > 0 && (
<div
style={{
marginTop: "18px",
padding: "14px 16px",
border: "1px solid rgba(148, 163, 184, 0.14)",
borderRadius: "16px",
background: "rgba(15, 23, 42, 0.42)",
}}
>
<div
style={{
color: "var(--text-primary)",
fontSize: "14px",
fontWeight: 700,
marginBottom: "10px",
}}
>
{locale === "en-US"
? "Peak-12h DEB Reference (Approx.)"
: "峰值前 12 小时 DEB 参考(近似)"}
</div>
<div
style={{
display: "grid",
gap: "8px",
}}
>
{settledPeakRows.map((row) => (
<div
key={row.date}
style={{
display: "grid",
gridTemplateColumns: "minmax(72px, 88px) 1fr",
gap: "10px",
padding: "10px 12px",
borderRadius: "12px",
background: "rgba(255,255,255,0.03)",
}}
>
<div
style={{
color: "var(--text-secondary)",
fontSize: "12px",
fontWeight: 700,
}}
>
{row.date}
</div>
<div
style={{
color: "var(--text-secondary)",
fontSize: "12px",
lineHeight: 1.7,
}}
>
<div>
{locale === "en-US" ? "Peak ref" : "峰值参考"}:{" "}
<span style={{ color: "var(--text-primary)" }}>
{row.actual}
{store.selectedDetail?.temp_symbol || "°C"} @{" "}
{row.actual_peak_time}
</span>
</div>
<div>
{locale === "en-US" ? "DEB@-12h" : "峰值前12小时 DEB"}:{" "}
<span style={{ color: "var(--text-primary)" }}>
{row.deb_at_peak_minus_12h}
{store.selectedDetail?.temp_symbol || "°C"} @{" "}
{row.deb_at_peak_minus_12h_time}
</span>
</div>
<div>
{locale === "en-US" ? "Actual" : "最终实测"}:{" "}
<span style={{ color: "var(--text-primary)" }}>
{row.actual}
{store.selectedDetail?.temp_symbol || "°C"}
</span>
</div>
<div>
{locale === "en-US" ? "Error" : "误差"}:{" "}
<span
style={{
color:
(row.deb_at_peak_minus_12h_error ?? 0) > 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"}`
: "--"}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div> </div>
</div> </div>
)} )}
+4
View File
@@ -312,6 +312,10 @@ export interface HistoryPoint {
mu?: number | null; mu?: number | null;
mgm?: number | null; mgm?: number | null;
forecasts?: Record<string, number | null>; forecasts?: Record<string, number | null>;
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 { export interface LoadingState {
@@ -86,6 +86,52 @@ def _load_recent_rows(path: str, max_lines: int = DEDUP_SCAN_LINES) -> List[Dict
return rows 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: def _should_skip_append(path: str, payload: Dict[str, Any]) -> bool:
mode = get_state_storage_mode() mode = get_state_storage_mode()
if mode == STATE_STORAGE_SQLITE: if mode == STATE_STORAGE_SQLITE:
+19
View File
@@ -362,6 +362,25 @@ class ProbabilitySnapshotRepository:
continue continue
return out 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]]: def load_all_rows(self) -> List[Dict[str, Any]]:
with self.db.connect() as conn: with self.db.connect() as conn:
rows = conn.execute( rows = conn.execute(
+84
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
@@ -8,6 +9,7 @@ from fastapi.responses import PlainTextResponse
from loguru import logger from loguru import logger
from src.analysis.deb_algorithm import load_history 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.data_collection.city_registry import ALIASES
from src.utils.metrics import export_prometheus_metrics from src.utils.metrics import export_prometheus_metrics
from web.analysis_service import ( from web.analysis_service import (
@@ -47,6 +49,79 @@ from web.core import (
router = APIRouter() 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: def _normalize_city_or_404(name: str) -> str:
city = name.lower().strip().replace("-", " ") city = name.lower().strip().replace("-", " ")
city = ALIASES.get(city, city) city = ALIASES.get(city, city)
@@ -138,6 +213,11 @@ async def city_history(request: Request, name: str):
act = rec.get("actual_high") act = rec.get("actual_high")
deb = rec.get("deb_prediction") deb = rec.get("deb_prediction")
mu = rec.get("mu") 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_raw = rec.get("forecasts", {}) or {}
forecasts = {} forecasts = {}
if isinstance(forecasts_raw, dict): 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, "mu": float(mu) if mu is not None else None,
"mgm": float(mgm) if mgm is not None else None, "mgm": float(mgm) if mgm is not None else None,
"forecasts": forecasts, "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"),
} }
) )