CoWIN 6087 历史持久化 + 跑道曲线聚合 + 模型区间修复 + HKO 曲线对调 + 毛玻璃加载层
- weather_sources: CoWIN 1min 观测写入 official_intraday_observations_store - country_networks: cowin_obs 源从 DB 回读 1min 历史,不复用 VHHH METAR - analysis_service: 跑道实测 36h 历史按跑道分组,透传至前端 - scan_terminal_city_row: model_cluster_sources fallback 修复 + runway_plate_history - 前端: CoWIN 6087 升级为主轴实线,HKO 退为虚线,VHHH METAR 轻虚线 - 前端: Loading 升级为毛玻璃全卡蒙层
This commit is contained in:
@@ -367,31 +367,62 @@ function getObservationDisplayMetrics(
|
|||||||
const tzOffset = row?.tz_offset_seconds ?? 0;
|
const tzOffset = row?.tz_offset_seconds ?? 0;
|
||||||
const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
||||||
const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset);
|
const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset);
|
||||||
|
const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset);
|
||||||
const latestSettlement = latestObservationValue(settlementObs);
|
const latestSettlement = latestObservationValue(settlementObs);
|
||||||
const latestMetar = latestObservationValue(metarObs);
|
const latestMetar = latestObservationValue(metarObs);
|
||||||
|
const latestMadis = latestObservationValue(madisObs);
|
||||||
const highSettlement = maxObservationValue(settlementObs);
|
const highSettlement = maxObservationValue(settlementObs);
|
||||||
const highMetar = maxObservationValue(metarObs);
|
const highMetar = maxObservationValue(metarObs);
|
||||||
|
const highMadis = maxObservationValue(madisObs);
|
||||||
const airportCurrentTemp = validNumber(hourly?.airportCurrent?.temp) ?? validNumber(hourly?.airportPrimary?.temp);
|
const airportCurrentTemp = validNumber(hourly?.airportCurrent?.temp) ?? validNumber(hourly?.airportPrimary?.temp);
|
||||||
const airportHigh = validNumber(hourly?.airportCurrent?.max_so_far) ?? validNumber(hourly?.airportPrimary?.max_so_far);
|
const airportHigh = validNumber(hourly?.airportCurrent?.max_so_far) ?? validNumber(hourly?.airportPrimary?.max_so_far);
|
||||||
const rowMetarHigh = validNumber(row?.metar_context?.airport_max_so_far ?? row?.metar_context?.max_temp ?? row?.current_max_so_far);
|
const rowMetarHigh = validNumber(row?.metar_context?.airport_max_so_far ?? row?.metar_context?.max_temp ?? row?.current_max_so_far);
|
||||||
|
|
||||||
const currentRunwayTemp =
|
const settlementCityKey = normalizeCityKey(row?.city);
|
||||||
validNumber(hourly?.amos?.temp_c) ??
|
const isShenzhen = settlementCityKey === 'shenzhen';
|
||||||
settlementPlate?.maxTemp ??
|
const isHKO = (settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan'
|
||||||
latestSettlement ??
|
|| (row?.city || '').toLowerCase().includes('hong kong')
|
||||||
latestMetar ??
|
|| (row?.city || '').toLowerCase().includes('lau fau shan')) && !isShenzhen;
|
||||||
airportCurrentTemp ??
|
|
||||||
validNumber(row?.current_temp) ??
|
let currentRunwayTemp: number | null = null;
|
||||||
null;
|
let observedHighRunway: number | null = null;
|
||||||
|
|
||||||
|
if (isHKO) {
|
||||||
|
currentRunwayTemp =
|
||||||
|
latestMadis ??
|
||||||
|
latestSettlement ??
|
||||||
|
latestMetar ??
|
||||||
|
airportCurrentTemp ??
|
||||||
|
validNumber(row?.current_temp) ??
|
||||||
|
null;
|
||||||
|
observedHighRunway =
|
||||||
|
highMadis ??
|
||||||
|
highSettlement ??
|
||||||
|
airportHigh ??
|
||||||
|
highMetar ??
|
||||||
|
validNumber(row?.current_max_so_far) ??
|
||||||
|
currentRunwayTemp ??
|
||||||
|
null;
|
||||||
|
} else {
|
||||||
|
currentRunwayTemp =
|
||||||
|
validNumber(hourly?.amos?.temp_c) ??
|
||||||
|
settlementPlate?.maxTemp ??
|
||||||
|
latestSettlement ??
|
||||||
|
latestMetar ??
|
||||||
|
airportCurrentTemp ??
|
||||||
|
validNumber(row?.current_temp) ??
|
||||||
|
null;
|
||||||
|
observedHighRunway =
|
||||||
|
settlementPlate?.maxTemp ??
|
||||||
|
highSettlement ??
|
||||||
|
airportHigh ??
|
||||||
|
highMetar ??
|
||||||
|
validNumber(row?.current_max_so_far) ??
|
||||||
|
currentRunwayTemp ??
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
const observedHighMetar = airportHigh ?? highSettlement ?? highMetar ?? rowMetarHigh ?? null;
|
const observedHighMetar = airportHigh ?? highSettlement ?? highMetar ?? rowMetarHigh ?? null;
|
||||||
const observedHighRunway =
|
|
||||||
settlementPlate?.maxTemp ??
|
|
||||||
highSettlement ??
|
|
||||||
airportHigh ??
|
|
||||||
highMetar ??
|
|
||||||
validNumber(row?.current_max_so_far) ??
|
|
||||||
currentRunwayTemp ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
return { currentRunwayTemp, observedHighMetar, observedHighRunway };
|
return { currentRunwayTemp, observedHighMetar, observedHighRunway };
|
||||||
}
|
}
|
||||||
@@ -845,6 +876,19 @@ function buildFullDayChartData(
|
|||||||
const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset);
|
const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset);
|
||||||
const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr);
|
const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr);
|
||||||
|
|
||||||
|
const settlementCityKey = normalizeCityKey(row?.city);
|
||||||
|
const isShenzhen = settlementCityKey === 'shenzhen';
|
||||||
|
const isHKO = (settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan'
|
||||||
|
|| (row?.city || '').toLowerCase().includes('hong kong')
|
||||||
|
|| (row?.city || '').toLowerCase().includes('lau fau shan')) && !isShenzhen;
|
||||||
|
|
||||||
|
let finalSettlementObs = settlementObs;
|
||||||
|
let finalMadisObs = madisObs;
|
||||||
|
if (isHKO) {
|
||||||
|
finalSettlementObs = madisObs;
|
||||||
|
finalMadisObs = settlementObs;
|
||||||
|
}
|
||||||
|
|
||||||
const slots = generateFullDaySlots(localDateStr);
|
const slots = generateFullDaySlots(localDateStr);
|
||||||
if (!slots.length) return { data: [], series: [] };
|
if (!slots.length) return { data: [], series: [] };
|
||||||
const slotLabels = slots.map(formatTimestamp);
|
const slotLabels = slots.map(formatTimestamp);
|
||||||
@@ -870,17 +914,16 @@ function buildFullDayChartData(
|
|||||||
|
|
||||||
// ── Settlement observations ──
|
// ── Settlement observations ──
|
||||||
// Determine if this is an HKO-sourced city to force the label
|
// Determine if this is an HKO-sourced city to force the label
|
||||||
const settlementCityKey = normalizeCityKey(row?.city);
|
|
||||||
const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan'
|
const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan'
|
||||||
|| settlementCityKey === 'shenzhen' || (row?.city || '').toLowerCase().includes('hong kong')
|
|| settlementCityKey === 'shenzhen' || (row?.city || '').toLowerCase().includes('hong kong')
|
||||||
|| (row?.city || '').toLowerCase().includes('lau fau shan');
|
|| (row?.city || '').toLowerCase().includes('lau fau shan');
|
||||||
if (settlementObs.length) {
|
if (finalSettlementObs.length) {
|
||||||
const svals = binObservationsToSlots(slots, settlementObs);
|
const svals = binObservationsToSlots(slots, finalSettlementObs);
|
||||||
if (svals.some((v) => v !== null)) {
|
if (svals.some((v) => v !== null)) {
|
||||||
series.push({
|
series.push({
|
||||||
key: "settlement",
|
key: "settlement",
|
||||||
label: isHKOCity ? "HKO" : (row?.metar_context?.station_label || row?.metar_context?.station || "Settlement"),
|
label: isHKO ? "CoWIN 6087" : (isHKOCity ? "HKO" : (row?.metar_context?.station_label || row?.metar_context?.station || "Settlement")),
|
||||||
source: row?.metar_context?.station || row?.airport || "Settlement",
|
source: isHKO ? "cowin_obs" : (row?.metar_context?.station || row?.airport || "Settlement"),
|
||||||
color: "#009688",
|
color: "#009688",
|
||||||
featured: true,
|
featured: true,
|
||||||
values: svals,
|
values: svals,
|
||||||
@@ -894,26 +937,26 @@ function buildFullDayChartData(
|
|||||||
const isAmscSource =
|
const isAmscSource =
|
||||||
(hourly?.airportPrimary as any)?.source === "amsc_awos" ||
|
(hourly?.airportPrimary as any)?.source === "amsc_awos" ||
|
||||||
String(hourly?.airportPrimary?.source_label || "").toLowerCase().includes("amsc");
|
String(hourly?.airportPrimary?.source_label || "").toLowerCase().includes("amsc");
|
||||||
if (madisObs.length && !isAmscSource) {
|
if (finalMadisObs.length && !isAmscSource) {
|
||||||
const madisVals = binObservationsToSlots(slots, madisObs);
|
const madisVals = binObservationsToSlots(slots, finalMadisObs);
|
||||||
if (madisVals.some((v) => v !== null)) {
|
if (madisVals.some((v) => v !== null)) {
|
||||||
series.push({
|
series.push({
|
||||||
key: "madis",
|
key: "madis",
|
||||||
label: hourly?.airportPrimary?.source_label || "NOAA MADIS",
|
label: isHKO ? "HKO" : (hourly?.airportPrimary?.source_label || "NOAA MADIS"),
|
||||||
source: hourly?.airportPrimary?.station_code || row?.airport || "MADIS",
|
source: isHKO ? "HKO" : (hourly?.airportPrimary?.station_code || row?.airport || "MADIS"),
|
||||||
color: "#0284c7",
|
color: "#0284c7",
|
||||||
dashed: false,
|
dashed: isHKO ? true : false,
|
||||||
values: madisVals,
|
values: madisVals,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metarObs.length && !observationSetContains(madisObs, metarObs)) {
|
if (metarObs.length && !observationSetContains(finalMadisObs, metarObs)) {
|
||||||
const mvals = binObservationsToSlots(slots, metarObs);
|
const mvals = binObservationsToSlots(slots, metarObs);
|
||||||
if (mvals.some((v) => v !== null)) {
|
if (mvals.some((v) => v !== null)) {
|
||||||
series.push({
|
series.push({
|
||||||
key: "metar",
|
key: "metar",
|
||||||
label: isHKOCity ? "HKO" : (row?.metar_context?.station_label || "METAR"),
|
label: isHKO ? "VHHH METAR" : (isHKOCity ? "HKO" : (row?.metar_context?.station_label || "METAR")),
|
||||||
source: row?.airport || "METAR",
|
source: row?.airport || "METAR",
|
||||||
color: "#0ea5e9",
|
color: "#0ea5e9",
|
||||||
dashed: true,
|
dashed: true,
|
||||||
@@ -1416,13 +1459,15 @@ export function LiveTemperatureThresholdChart({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title={panelTitle} actions={timeframeActions}>
|
<Panel title={panelTitle} actions={timeframeActions} className="relative">
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
<LoadingSignal
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/70 backdrop-blur-sm dark:bg-slate-950/70 rounded-[4px]">
|
||||||
title={isEn ? "Loading city data..." : "加载城市数据中..."}
|
<LoadingSignal
|
||||||
description={isEn ? `Fetching latest observations for ${row?.city_display_name || row?.city || "this city"}` : `正在获取 ${row?.city_display_name || row?.city || "该城市"} 的最新观测数据`}
|
title={isEn ? "Loading city data..." : "加载城市数据中..."}
|
||||||
compact={compact}
|
description={isEn ? `Fetching latest observations for ${row?.city_display_name || row?.city || "this city"}` : `正在获取 ${row?.city_display_name || row?.city || "该城市"} 的最新观测数据`}
|
||||||
/>
|
compact={compact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex h-full min-h-[300px] flex-col">
|
<div className="flex h-full min-h-[300px] flex-col">
|
||||||
{/* Compact stats bar */}
|
{/* Compact stats bar */}
|
||||||
|
|||||||
@@ -1201,12 +1201,29 @@ def build_country_network_snapshot(city: str, raw: Dict[str, Any]) -> Dict[str,
|
|||||||
"stale_row_count": stale_count,
|
"stale_row_count": stale_count,
|
||||||
"unknown_timing_count": unknown_count,
|
"unknown_timing_count": unknown_count,
|
||||||
}
|
}
|
||||||
|
airport_primary_today_obs = ((raw.get("metar") or {}).get("today_obs") or [])
|
||||||
|
if airport_primary.get("source_code") == "cowin_obs":
|
||||||
|
try:
|
||||||
|
from src.database.runtime_state import OfficialIntradayObservationRepository
|
||||||
|
repo = OfficialIntradayObservationRepository()
|
||||||
|
local_now = datetime.now(timezone.utc) + timedelta(seconds=city_offset or 0)
|
||||||
|
local_date_str = local_now.strftime("%Y-%m-%d")
|
||||||
|
points = repo.load_points(
|
||||||
|
source_code="cowin_obs",
|
||||||
|
station_code=airport_primary.get("station_code") or "6087",
|
||||||
|
target_date=local_date_str,
|
||||||
|
)
|
||||||
|
if points:
|
||||||
|
airport_primary_today_obs = [{"time": p["time"], "temp": p["temp"]} for p in points]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"provider_code": provider.provider_code,
|
"provider_code": provider.provider_code,
|
||||||
"provider_label": provider.provider_label,
|
"provider_label": provider.provider_label,
|
||||||
"settlement_station": metadata,
|
"settlement_station": metadata,
|
||||||
"airport_primary_current": airport_primary,
|
"airport_primary_current": airport_primary,
|
||||||
"airport_primary_today_obs": ((raw.get("metar") or {}).get("today_obs") or []),
|
"airport_primary_today_obs": airport_primary_today_obs,
|
||||||
"official_nearby": official_nearby,
|
"official_nearby": official_nearby,
|
||||||
"official_network_source": status.get("provider_code"),
|
"official_network_source": status.get("provider_code"),
|
||||||
"official_network_status": status,
|
"official_network_status": status,
|
||||||
|
|||||||
@@ -1141,14 +1141,26 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
results["nearby_source"] = "cowin_obs"
|
results["nearby_source"] = "cowin_obs"
|
||||||
try:
|
try:
|
||||||
row = rows[0] if rows else {}
|
row = rows[0] if rows else {}
|
||||||
|
temp_c = row.get("temp")
|
||||||
|
if use_fahrenheit and temp_c is not None:
|
||||||
|
temp_c = round((temp_c - 32.0) * 5.0 / 9.0, 1)
|
||||||
|
|
||||||
DBManager().append_airport_obs(
|
DBManager().append_airport_obs(
|
||||||
icao=str(row.get("icao") or "COWIN6087"),
|
icao=str(row.get("icao") or "COWIN6087"),
|
||||||
city=city_lower,
|
city=city_lower,
|
||||||
temp_c=row.get("temp"),
|
temp_c=temp_c,
|
||||||
obs_time=str(row.get("obs_time") or datetime.now().isoformat()),
|
obs_time=str(row.get("obs_time") or datetime.now().isoformat()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._update_official_today_obs(
|
||||||
|
source_code="cowin_obs",
|
||||||
|
station_code=str(row.get("istNo") or "6087"),
|
||||||
|
obs_iso=str(row.get("obs_time") or datetime.now(timezone.utc).isoformat()),
|
||||||
|
current_temp=temp_c,
|
||||||
|
utc_offset_seconds=28800,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("airport_obs_log append failed for cowin_obs city={}", city_lower)
|
logger.exception("airport_obs_log append/update failed for cowin_obs city={}", city_lower)
|
||||||
|
|
||||||
def _attach_cwa_settlement_nearby(
|
def _attach_cwa_settlement_nearby(
|
||||||
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
||||||
|
|||||||
@@ -1532,8 +1532,42 @@ def _analyze(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ── Assemble result ──
|
# ── Assemble result ──
|
||||||
|
runway_plate_history = {}
|
||||||
|
icao = risk.get("icao", "")
|
||||||
|
if icao:
|
||||||
|
try:
|
||||||
|
from src.database.db_manager import DBManager
|
||||||
|
raw_runway_obs = DBManager().get_runway_obs_recent(icao, minutes=36 * 60)
|
||||||
|
for r in raw_runway_obs:
|
||||||
|
rw = r.get("runway")
|
||||||
|
if not rw:
|
||||||
|
continue
|
||||||
|
temp_val = r.get("target_runway_max")
|
||||||
|
if temp_val is None:
|
||||||
|
temp_val = r.get("tdz_temp")
|
||||||
|
if temp_val is not None:
|
||||||
|
temp_val = float(temp_val)
|
||||||
|
if is_f:
|
||||||
|
temp_val = round(temp_val * 9.0 / 5.0 + 32.0, 1)
|
||||||
|
else:
|
||||||
|
temp_val = round(temp_val, 1)
|
||||||
|
|
||||||
|
time_val = r.get("otime_utc") or r.get("created_at")
|
||||||
|
if not time_val:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rw not in runway_plate_history:
|
||||||
|
runway_plate_history[rw] = []
|
||||||
|
runway_plate_history[rw].append({
|
||||||
|
"time": time_val,
|
||||||
|
"temp": temp_val
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch runway plate history for icao={}", icao)
|
||||||
|
|
||||||
city_meta = CITIES.get(city, {}) or {}
|
city_meta = CITIES.get(city, {}) or {}
|
||||||
result = {
|
result = {
|
||||||
|
"runway_plate_history": runway_plate_history,
|
||||||
"detail_depth": (
|
"detail_depth": (
|
||||||
"panel"
|
"panel"
|
||||||
if is_panel_mode
|
if is_panel_mode
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ def _build_terminal_row(
|
|||||||
"distribution_bias": scan.get("distribution_bias"),
|
"distribution_bias": scan.get("distribution_bias"),
|
||||||
"distribution_preview": scan.get("distribution_preview") or row.get("distribution_preview") or [],
|
"distribution_preview": scan.get("distribution_preview") or row.get("distribution_preview") or [],
|
||||||
"distribution_full": scan.get("distribution_full") or scan.get("distribution_preview") or row.get("distribution_preview") or [],
|
"distribution_full": scan.get("distribution_full") or scan.get("distribution_preview") or row.get("distribution_preview") or [],
|
||||||
"model_cluster_sources": daily_entry.get("models") if isinstance(daily_entry.get("models"), dict) else data.get("multi_model"),
|
"model_cluster_sources": daily_entry.get("models") if isinstance(daily_entry.get("models"), dict) else data.get("multi_model", {}).get("forecasts"),
|
||||||
"window_phase": row.get("window_phase") or scan.get("window_phase"),
|
"window_phase": row.get("window_phase") or scan.get("window_phase"),
|
||||||
"window_score": row.get("window_score") if row.get("window_score") is not None else scan.get("window_score"),
|
"window_score": row.get("window_score") if row.get("window_score") is not None else scan.get("window_score"),
|
||||||
"signal_status": scan.get("signal_status"),
|
"signal_status": scan.get("signal_status"),
|
||||||
@@ -129,6 +129,7 @@ def _build_terminal_row(
|
|||||||
"amos": data.get("amos") or None,
|
"amos": data.get("amos") or None,
|
||||||
"top_buckets": scan.get("top_buckets") or [],
|
"top_buckets": scan.get("top_buckets") or [],
|
||||||
"all_buckets": scan.get("all_buckets") or [],
|
"all_buckets": scan.get("all_buckets") or [],
|
||||||
|
"runway_plate_history": data.get("runway_plate_history") or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -215,6 +216,7 @@ def _build_quick_row(
|
|||||||
"is_primary_signal": True,
|
"is_primary_signal": True,
|
||||||
"accepting_orders": False,
|
"accepting_orders": False,
|
||||||
"row_id": row_id,
|
"row_id": row_id,
|
||||||
|
"runway_plate_history": data.get("runway_plate_history") or {},
|
||||||
}
|
}
|
||||||
# Compute a simple edge: model top probability vs neutral
|
# Compute a simple edge: model top probability vs neutral
|
||||||
best_model_prob = max(
|
best_model_prob = max(
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ def build_city_detail_payload(
|
|||||||
or _build_intraday_meteorology(data),
|
or _build_intraday_meteorology(data),
|
||||||
"vertical_profile_signal": data.get("vertical_profile_signal") or {},
|
"vertical_profile_signal": data.get("vertical_profile_signal") or {},
|
||||||
"taf": data.get("taf") or {},
|
"taf": data.get("taf") or {},
|
||||||
|
"runway_plate_history": data.get("runway_plate_history") or {},
|
||||||
|
|
||||||
"risk": data.get("risk"),
|
"risk": data.get("risk"),
|
||||||
"settlement_station": data.get("settlement_station") or {},
|
"settlement_station": data.get("settlement_station") or {},
|
||||||
|
|||||||
Reference in New Issue
Block a user