diff --git a/frontend/components/dashboard/ScanTerminalCard.module.css b/frontend/components/dashboard/ScanTerminalCard.module.css index 781c5c35..b6b905c5 100644 --- a/frontend/components/dashboard/ScanTerminalCard.module.css +++ b/frontend/components/dashboard/ScanTerminalCard.module.css @@ -994,7 +994,19 @@ width: 18px; height: 3px; border-radius: 999px; - background: #4da3ff; + background: rgba(100, 116, 139, 0.72); +} + +.root :global(.scan-ai-city-chart-legend i.forecast) { + background: repeating-linear-gradient( + 90deg, + rgba(100, 116, 139, 0.72) 0 6px, + transparent 6px 10px + ); +} + +.root :global(.scan-ai-city-chart-legend i.calibrated) { + background: #38bdf8; } .root :global(.scan-ai-city-chart-legend i.observation) { diff --git a/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx b/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx index 854fef92..410d5eca 100644 --- a/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx +++ b/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx @@ -86,16 +86,17 @@ export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) { (lastChartDataRef.current?.cityKey === cityKey ? lastChartDataRef.current.data : null); - const forecastLabel = chartData?.datasets.hasMgmHourly - ? locale === "en-US" - ? "MGM forecast" - : "MGM 预测" - : locale === "en-US" - ? "DEB forecast" - : "DEB 预测"; + const forecastLabel = locale === "en-US" ? "DEB baseline" : "DEB 原始路径"; + const calibratedLabel = + locale === "en-US" + ? "METAR-calibrated path" + : "METAR 修正路径"; const observationLabel = chartData?.observationLabel || (locale === "en-US" ? "METAR obs" : "METAR 实况"); + const hasCalibratedPath = Boolean( + chartData?.datasets.calibratedFuture.some((value) => value != null), + ); const canvasRef = useChart(() => { if (!chartData) { return { @@ -103,36 +104,53 @@ export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) { type: "line", } satisfies ChartConfiguration<"line">; } - const forecastPoints = chartData.datasets.hasMgmHourly - ? chartData.datasets.mgmHourlyPoints - : chartData.datasets.debPast.map( + const datasets: NonNullable< + ChartConfiguration<"line">["data"] + >["datasets"] = [ + { + borderColor: "rgba(100, 116, 139, 0.72)", + borderDash: [6, 4], + borderWidth: 1.6, + data: chartData.datasets.debPast.map( (value, index) => value ?? chartData.datasets.debFuture[index], - ); + ), + fill: false, + label: forecastLabel, + pointRadius: 0, + spanGaps: true, + tension: 0.28, + }, + ]; + + if (hasCalibratedPath) { + datasets.push({ + borderColor: "#38bdf8", + borderWidth: 2.3, + data: chartData.datasets.calibratedFuture, + fill: false, + label: calibratedLabel, + pointHoverRadius: 5, + pointRadius: 0, + spanGaps: true, + tension: 0.32, + }); + } + + datasets.push({ + backgroundColor: "#22C55E", + borderColor: "#22C55E", + borderWidth: 0, + data: chartData.datasets.metarPoints, + fill: false, + label: observationLabel, + pointHoverRadius: 5, + pointRadius: 3.5, + showLine: false, + }); + return { data: { - datasets: [ - { - borderColor: "#4DA3FF", - borderWidth: 2, - data: forecastPoints, - fill: false, - label: forecastLabel, - pointRadius: 0, - spanGaps: true, - tension: 0.32, - }, - { - backgroundColor: "#22C55E", - borderColor: "#22C55E", - borderWidth: 0, - data: chartData.datasets.metarPoints, - fill: false, - label: observationLabel, - pointHoverRadius: 5, - pointRadius: 3.5, - showLine: false, - }, - ], + datasets, labels: chartData.times, }, options: { @@ -186,7 +204,14 @@ export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) { }, type: "line", } satisfies ChartConfiguration<"line">; - }, [chartData, detail.temp_symbol, forecastLabel, observationLabel]); + }, [ + calibratedLabel, + chartData, + detail.temp_symbol, + forecastLabel, + hasCalibratedPath, + observationLabel, + ]); useEffect(() => { if (shouldRenderChart) return; @@ -226,6 +251,9 @@ export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) { {chartData ? (
{forecastLabel} + {hasCalibratedPath ? ( + {calibratedLabel} + ) : null} {observationLabel}
) : null} diff --git a/scripts/backtest_metar_calibrated_path.py b/scripts/backtest_metar_calibrated_path.py index d779cdaf..d388c9a5 100644 --- a/scripts/backtest_metar_calibrated_path.py +++ b/scripts/backtest_metar_calibrated_path.py @@ -358,6 +358,114 @@ def evaluate_city_date(con: sqlite3.Connection, city: str, date: str, min_obs: i return results +def load_path_snapshot_rows( + con: sqlite3.Connection, + *, + cities: list[str], + dates_filter: set[str], +) -> list[dict[str, Any]]: + try: + con.execute("select 1 from intraday_path_snapshots_store limit 1").fetchone() + except sqlite3.Error: + return [] + params: list[Any] = [] + clauses: list[str] = [] + if cities: + clauses.append("city in (" + ",".join("?" for _ in cities) + ")") + params.extend(cities) + if dates_filter: + clauses.append("target_date in (" + ",".join("?" for _ in dates_filter) + ")") + params.extend(sorted(dates_filter)) + where = " where " + " and ".join(clauses) if clauses else "" + rows = con.execute( + f"select payload_json from intraday_path_snapshots_store{where} order by id asc", + params, + ).fetchall() + out: list[dict[str, Any]] = [] + for row in rows: + try: + payload = json.loads(row["payload_json"]) + except Exception: + continue + if isinstance(payload, dict): + out.append(payload) + return out + + +def observation_rows_from_snapshot(snapshot: dict[str, Any]) -> list[Observation]: + rows: list[Observation] = [] + for key in ("metar_today_obs", "settlement_today_obs"): + raw_rows = snapshot.get(key) + if not isinstance(raw_rows, list): + continue + for item in raw_rows: + if not isinstance(item, dict): + continue + temp = sf(item.get("temp")) + time_text = str(item.get("time") or "").strip()[:5] + if temp is not None and hm_to_minutes(time_text) is not None: + rows.append(Observation(time_text, temp)) + return dedupe_observations(rows) + + +def evaluate_path_snapshot( + con: sqlite3.Connection, + snapshot: dict[str, Any], + min_obs: int, +) -> SampleResult | None: + city = str(snapshot.get("city") or "").strip().lower() + date = str(snapshot.get("target_date") or snapshot.get("date") or "").strip() + if not city or not date: + return None + actual_high = get_actual_high(con, city, date) + deb_high = sf(snapshot.get("deb_prediction")) + if actual_high is None or deb_high is None: + return None + path = snapshot.get("deb_base_path") or {} + times = path.get("times") if isinstance(path, dict) else [] + deb_path = path.get("temps") if isinstance(path, dict) else [] + if not isinstance(times, list) or not isinstance(deb_path, list) or not times: + return None + deb_values = [sf(v) for v in deb_path] + observations = observation_rows_from_snapshot(snapshot) + if len(observations) < min_obs: + return None + local_time = str(snapshot.get("local_time") or "").strip() + current_minute = hm_to_minutes(local_time) + if current_minute is None: + current_minute = hm_to_minutes(observations[-1].time) + if current_minute is None: + return None + forecast = snapshot.get("forecast") if isinstance(snapshot.get("forecast"), dict) else {} + reversion_minute = hm_to_minutes(forecast.get("sunset")) or hm_to_minutes("18:00") or 18 * 60 + calibrated_path, adjustment = calibrated_future_path( + times=[str(t) for t in times], + deb_path=deb_values, + observations=observations, + current_minute=current_minute, + reversion_minute=reversion_minute, + ) + future_values = [v for v in calibrated_path if v is not None] + current = snapshot.get("current") if isinstance(snapshot.get("current"), dict) else {} + max_so_far = sf(current.get("max_so_far")) + observed_so_far = max([o.temp for o in observations] + ([max_so_far] if max_so_far is not None else [])) + calibrated_high = max([observed_so_far, *future_values], default=observed_so_far) + return SampleResult( + city=city, + date=date, + current_time=local_time or observations[-1].time, + obs_count=len(observations), + actual_high=actual_high, + deb_high=deb_high, + calibrated_high=calibrated_high, + deb_abs_error=abs(deb_high - actual_high), + calibrated_abs_error=abs(calibrated_high - actual_high), + delta_vs_deb=adjustment if adjustment is not None else 0.0, + bucket_deb_hit=bucket_hit(city, deb_high, actual_high), + bucket_calibrated_hit=bucket_hit(city, calibrated_high, actual_high), + ) + + def summarize(samples: list[SampleResult]) -> dict[str, Any]: if not samples: return {"samples": 0} @@ -396,6 +504,12 @@ def main() -> int: parser.add_argument("--city", action="append", help="City key; can be repeated. Defaults to cities found in daily_records_store.") parser.add_argument("--date", action="append", help="YYYY-MM-DD; can be repeated.") parser.add_argument("--min-obs", type=int, default=2) + parser.add_argument( + "--source", + choices=("strict", "snapshots", "both"), + default="both", + help="strict uses reconstructed legacy stores; snapshots uses intraday_path_snapshots_store.", + ) parser.add_argument("--output", default=str(ROOT / "tmp_metar_calibration_backtest.csv")) args = parser.parse_args() @@ -406,28 +520,41 @@ def main() -> int: dates_filter = set(args.date or []) all_samples: list[SampleResult] = [] - skipped = {"no_records_or_inputs": 0} - for city in cities: - rows = con.execute( - "select distinct target_date from daily_records_store where city=? order by target_date", - (city,), - ).fetchall() - for row in rows: - date = row[0] - if dates_filter and date not in dates_filter: - continue - samples = evaluate_city_date(con, city, date, args.min_obs) - if samples: - all_samples.extend(samples) + skipped = {"no_records_or_inputs": 0, "snapshots_unusable": 0} + if args.source in {"strict", "both"}: + for city in cities: + rows = con.execute( + "select distinct target_date from daily_records_store where city=? order by target_date", + (city,), + ).fetchall() + for row in rows: + date = row[0] + if dates_filter and date not in dates_filter: + continue + samples = evaluate_city_date(con, city, date, args.min_obs) + if samples: + all_samples.extend(samples) + else: + skipped["no_records_or_inputs"] += 1 + if args.source in {"snapshots", "both"}: + snapshot_rows = load_path_snapshot_rows( + con, + cities=cities or [], + dates_filter=dates_filter, + ) + for snapshot in snapshot_rows: + sample = evaluate_path_snapshot(con, snapshot, args.min_obs) + if sample: + all_samples.append(sample) else: - skipped["no_records_or_inputs"] += 1 + skipped["snapshots_unusable"] += 1 summary = summarize(all_samples) write_csv(Path(args.output), all_samples) print(json.dumps({"summary": summary, "skipped": skipped, "output": args.output}, ensure_ascii=False, indent=2)) if not all_samples: print( - "No usable samples. Need matching daily_records + open_meteo_cache hourly forecast + intraday observations for the same city/date.", + "No usable samples. Need strict store matches or rows in intraday_path_snapshots_store with later actual_high.", file=sys.stderr, ) return 2 diff --git a/src/database/runtime_state.py b/src/database/runtime_state.py index 033c4da0..a7109374 100644 --- a/src/database/runtime_state.py +++ b/src/database/runtime_state.py @@ -209,6 +209,26 @@ class RuntimeStateDB: conn.execute( "CREATE INDEX IF NOT EXISTS idx_official_intraday_obs_station_date ON official_intraday_observations_store(source_code, station_code, target_date, observation_time)" ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS intraday_path_snapshots_store ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + city TEXT NOT NULL, + target_date TEXT NOT NULL, + snapshot_time TEXT NOT NULL, + local_time TEXT, + deb_prediction REAL, + forecast_today_high REAL, + current_temp REAL, + max_so_far REAL, + observation_count INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_intraday_path_snapshots_city_date ON intraday_path_snapshots_store(city, target_date, id DESC)" + ) conn.commit() @@ -1053,6 +1073,72 @@ class OfficialIntradayObservationRepository: return out +class IntradayPathSnapshotRepository: + def __init__(self, db: Optional[RuntimeStateDB] = None): + self.db = db or RuntimeStateDB.instance() + + def append_snapshot(self, payload: Dict[str, Any]) -> None: + observations = [] + for key in ("metar_today_obs", "settlement_today_obs"): + rows = payload.get(key) + if isinstance(rows, list): + observations.extend(rows) + with self.db.connect() as conn: + conn.execute( + """ + INSERT INTO intraday_path_snapshots_store ( + city, target_date, snapshot_time, local_time, + deb_prediction, forecast_today_high, current_temp, + max_so_far, observation_count, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + payload.get("city"), + payload.get("target_date"), + payload.get("snapshot_time"), + payload.get("local_time"), + payload.get("deb_prediction"), + payload.get("forecast_today_high"), + payload.get("current_temp"), + payload.get("max_so_far"), + len(observations), + json.dumps(payload, ensure_ascii=False), + ), + ) + conn.commit() + + 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 intraday_path_snapshots_store + WHERE city = ? AND target_date = ? + ORDER BY id ASC + """, + (city, target_date), + ).fetchall() + out: List[Dict[str, Any]] = [] + 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( + "SELECT payload_json FROM intraday_path_snapshots_store ORDER BY id ASC" + ).fetchall() + out: List[Dict[str, Any]] = [] + for row in rows: + try: + out.append(json.loads(row["payload_json"])) + except Exception: + continue + return out + def _top_bucket(snapshot: Optional[List[Dict[str, Any]]]) -> Optional[int]: best_value = None best_prob = -1.0 diff --git a/web/analysis_service.py b/web/analysis_service.py index 4b9d12ef..ae015891 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -32,6 +32,7 @@ from src.data_collection.country_networks import build_country_network_snapshot from src.data_collection.city_registry import ALIASES, CITY_REGISTRY from src.data_collection.city_time import get_city_utc_offset_seconds from src.data_collection.nmc_sources import NMC_CITY_REFERENCES +from src.database.runtime_state import IntradayPathSnapshotRepository from src.models.lgbm_daily_high import predict_lgbm_daily_high TURKISH_MGM_CITIES = {"ankara", "istanbul"} @@ -1539,6 +1540,75 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: } +def _archive_intraday_path_snapshot(city: str, result: Dict[str, Any]) -> None: + """Persist replayable intraday path inputs visible at analysis time.""" + hourly = result.get("hourly") or {} + times = hourly.get("times") if isinstance(hourly, dict) else [] + temps = hourly.get("temps") if isinstance(hourly, dict) else [] + if not isinstance(times, list) or not isinstance(temps, list) or not times: + return + + forecast = result.get("forecast") or {} + deb = result.get("deb") or {} + current = result.get("current") or {} + forecast_today_high = _sf(forecast.get("today_high")) + deb_prediction = _sf(deb.get("prediction")) + offset = ( + deb_prediction - forecast_today_high + if deb_prediction is not None and forecast_today_high is not None + else 0.0 + ) + deb_base_temps = [ + round(float(value) + offset, 1) if _sf(value) is not None else None + for value in temps + ] + utc_offset = int(result.get("utc_offset_seconds") or 0) + snapshot_time = datetime.now(timezone.utc).astimezone( + timezone(timedelta(seconds=utc_offset)) + ).isoformat(timespec="seconds") + payload = { + "schema_version": 1, + "city": city, + "target_date": str(result.get("local_date") or "").strip(), + "snapshot_time": snapshot_time, + "local_time": str(result.get("local_time") or "").strip(), + "utc_offset_seconds": utc_offset, + "temp_symbol": result.get("temp_symbol"), + "deb_prediction": deb_prediction, + "forecast_today_high": forecast_today_high, + "deb_base_path": { + "times": [str(item) for item in times], + "temps": deb_base_temps, + "source": "hourly_plus_deb_offset", + "offset": round(offset, 3), + }, + "hourly": { + "times": [str(item) for item in times], + "temps": temps, + }, + "metar_today_obs": result.get("metar_today_obs") or [], + "settlement_today_obs": result.get("settlement_today_obs") or [], + "current": { + "temp": _sf(current.get("temp")), + "max_so_far": _sf(current.get("max_so_far")), + "obs_time": current.get("obs_time"), + "settlement_source": current.get("settlement_source"), + "settlement_source_label": current.get("settlement_source_label"), + }, + "forecast": { + "today_high": forecast_today_high, + "sunrise": forecast.get("sunrise"), + "sunset": forecast.get("sunset"), + }, + "peak": result.get("peak") or {}, + "metar_status": result.get("metar_status") or {}, + } + try: + IntradayPathSnapshotRepository().append_snapshot(payload) + except Exception as exc: + logger.debug(f"intraday path snapshot archive skipped for {city}: {exc}") + + def _analyze( city: str, force_refresh: bool = False, @@ -2474,6 +2544,8 @@ def _analyze( "updated_at": datetime.now(timezone.utc).isoformat(), } result["intraday_meteorology"] = _build_intraday_meteorology(result) + if normalized_detail_mode == "full": + _archive_intraday_path_snapshot(city, result) if include_llm_commentary: result["dynamic_commentary"] = _maybe_enrich_dynamic_commentary_with_groq(