From 45365df6941b5dc31151aceed5c8f0a0ccb4dd72 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 26 May 2026 09:13:39 +0800 Subject: [PATCH] =?UTF-8?q?CoWIN=206087=20=E5=8E=86=E5=8F=B2=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=20+=20=E8=B7=91=E9=81=93=E6=9B=B2=E7=BA=BF?= =?UTF-8?q?=E8=81=9A=E5=90=88=20+=20=E6=A8=A1=E5=9E=8B=E5=8C=BA=E9=97=B4?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20HKO=20=E6=9B=B2=E7=BA=BF=E5=AF=B9?= =?UTF-8?q?=E8=B0=83=20+=20=E6=AF=9B=E7=8E=BB=E7=92=83=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 升级为毛玻璃全卡蒙层 --- .../LiveTemperatureThresholdChart.tsx | 113 ++++++++++++------ src/data_collection/country_networks.py | 19 ++- src/data_collection/weather_sources.py | 16 ++- web/analysis_service.py | 34 ++++++ web/scan_terminal_city_row.py | 4 +- web/services/city_payloads.py | 1 + 6 files changed, 149 insertions(+), 38 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 27d1e643..36a52dce 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -367,31 +367,62 @@ function getObservationDisplayMetrics( const tzOffset = row?.tz_offset_seconds ?? 0; 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 madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset); const latestSettlement = latestObservationValue(settlementObs); const latestMetar = latestObservationValue(metarObs); + const latestMadis = latestObservationValue(madisObs); const highSettlement = maxObservationValue(settlementObs); const highMetar = maxObservationValue(metarObs); + const highMadis = maxObservationValue(madisObs); 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 rowMetarHigh = validNumber(row?.metar_context?.airport_max_so_far ?? row?.metar_context?.max_temp ?? row?.current_max_so_far); - const currentRunwayTemp = - validNumber(hourly?.amos?.temp_c) ?? - settlementPlate?.maxTemp ?? - latestSettlement ?? - latestMetar ?? - airportCurrentTemp ?? - validNumber(row?.current_temp) ?? - null; + 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 currentRunwayTemp: number | 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 observedHighRunway = - settlementPlate?.maxTemp ?? - highSettlement ?? - airportHigh ?? - highMetar ?? - validNumber(row?.current_max_so_far) ?? - currentRunwayTemp ?? - null; return { currentRunwayTemp, observedHighMetar, observedHighRunway }; } @@ -845,6 +876,19 @@ function buildFullDayChartData( const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset); 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); if (!slots.length) return { data: [], series: [] }; const slotLabels = slots.map(formatTimestamp); @@ -870,17 +914,16 @@ function buildFullDayChartData( // ── Settlement observations ── // Determine if this is an HKO-sourced city to force the label - const settlementCityKey = normalizeCityKey(row?.city); const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan' || settlementCityKey === 'shenzhen' || (row?.city || '').toLowerCase().includes('hong kong') || (row?.city || '').toLowerCase().includes('lau fau shan'); - if (settlementObs.length) { - const svals = binObservationsToSlots(slots, settlementObs); + if (finalSettlementObs.length) { + const svals = binObservationsToSlots(slots, finalSettlementObs); if (svals.some((v) => v !== null)) { series.push({ key: "settlement", - label: isHKOCity ? "HKO" : (row?.metar_context?.station_label || row?.metar_context?.station || "Settlement"), - source: row?.metar_context?.station || row?.airport || "Settlement", + label: isHKO ? "CoWIN 6087" : (isHKOCity ? "HKO" : (row?.metar_context?.station_label || row?.metar_context?.station || "Settlement")), + source: isHKO ? "cowin_obs" : (row?.metar_context?.station || row?.airport || "Settlement"), color: "#009688", featured: true, values: svals, @@ -894,26 +937,26 @@ function buildFullDayChartData( const isAmscSource = (hourly?.airportPrimary as any)?.source === "amsc_awos" || String(hourly?.airportPrimary?.source_label || "").toLowerCase().includes("amsc"); - if (madisObs.length && !isAmscSource) { - const madisVals = binObservationsToSlots(slots, madisObs); + if (finalMadisObs.length && !isAmscSource) { + const madisVals = binObservationsToSlots(slots, finalMadisObs); if (madisVals.some((v) => v !== null)) { series.push({ key: "madis", - label: hourly?.airportPrimary?.source_label || "NOAA MADIS", - source: hourly?.airportPrimary?.station_code || row?.airport || "MADIS", + label: isHKO ? "HKO" : (hourly?.airportPrimary?.source_label || "NOAA MADIS"), + source: isHKO ? "HKO" : (hourly?.airportPrimary?.station_code || row?.airport || "MADIS"), color: "#0284c7", - dashed: false, + dashed: isHKO ? true : false, values: madisVals, }); } } - if (metarObs.length && !observationSetContains(madisObs, metarObs)) { + if (metarObs.length && !observationSetContains(finalMadisObs, metarObs)) { const mvals = binObservationsToSlots(slots, metarObs); if (mvals.some((v) => v !== null)) { series.push({ 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", color: "#0ea5e9", dashed: true, @@ -1416,13 +1459,15 @@ export function LiveTemperatureThresholdChart({ ); return ( - + {isFetching && ( - +
+ +
)}
{/* Compact stats bar */} diff --git a/src/data_collection/country_networks.py b/src/data_collection/country_networks.py index 95105487..fe072731 100644 --- a/src/data_collection/country_networks.py +++ b/src/data_collection/country_networks.py @@ -1201,12 +1201,29 @@ def build_country_network_snapshot(city: str, raw: Dict[str, Any]) -> Dict[str, "stale_row_count": stale_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 { "provider_code": provider.provider_code, "provider_label": provider.provider_label, "settlement_station": metadata, "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_network_source": status.get("provider_code"), "official_network_status": status, diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index 3e960aa7..a9663d48 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -1141,14 +1141,26 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour results["nearby_source"] = "cowin_obs" try: 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( icao=str(row.get("icao") or "COWIN6087"), city=city_lower, - temp_c=row.get("temp"), + temp_c=temp_c, 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: - 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( self, results: Dict, city_lower: str, use_fahrenheit: bool diff --git a/web/analysis_service.py b/web/analysis_service.py index c8227b9d..dd31b21d 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -1532,8 +1532,42 @@ def _analyze( } # ── 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 {} result = { + "runway_plate_history": runway_plate_history, "detail_depth": ( "panel" if is_panel_mode diff --git a/web/scan_terminal_city_row.py b/web/scan_terminal_city_row.py index dccd6f21..1ae267a8 100644 --- a/web/scan_terminal_city_row.py +++ b/web/scan_terminal_city_row.py @@ -114,7 +114,7 @@ def _build_terminal_row( "distribution_bias": scan.get("distribution_bias"), "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 [], - "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_score": row.get("window_score") if row.get("window_score") is not None else scan.get("window_score"), "signal_status": scan.get("signal_status"), @@ -129,6 +129,7 @@ def _build_terminal_row( "amos": data.get("amos") or None, "top_buckets": scan.get("top_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, "accepting_orders": False, "row_id": row_id, + "runway_plate_history": data.get("runway_plate_history") or {}, } # Compute a simple edge: model top probability vs neutral best_model_prob = max( diff --git a/web/services/city_payloads.py b/web/services/city_payloads.py index fa77b05f..476a8aeb 100644 --- a/web/services/city_payloads.py +++ b/web/services/city_payloads.py @@ -125,6 +125,7 @@ def build_city_detail_payload( or _build_intraday_meteorology(data), "vertical_profile_signal": data.get("vertical_profile_signal") or {}, "taf": data.get("taf") or {}, + "runway_plate_history": data.get("runway_plate_history") or {}, "risk": data.get("risk"), "settlement_station": data.get("settlement_station") or {},