This version of Antigravity is no longer supported. Please upgrade to receive the latest features.

This commit is contained in:
2569718930@qq.com
2026-05-01 11:27:16 +08:00
parent 113c0131b5
commit 6c203bee60
5 changed files with 376 additions and 51 deletions
@@ -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) {
@@ -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 ? (
<div className="scan-ai-city-chart-legend">
<span><i className="forecast" />{forecastLabel}</span>
{hasCalibratedPath ? (
<span><i className="calibrated" />{calibratedLabel}</span>
) : null}
<span><i className="observation" />{observationLabel}</span>
</div>
) : null}
+142 -15
View File
@@ -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
+86
View File
@@ -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
+72
View File
@@ -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(