Add nearby station timing sync labels

This commit is contained in:
2569718930@qq.com
2026-04-19 02:38:34 +08:00
parent 80072e3a7f
commit fbbd13b1c3
10 changed files with 328 additions and 19 deletions
@@ -2158,6 +2158,27 @@
color: var(--text-muted); color: var(--text-muted);
} }
.root :global(.nearby-time) {
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
font-size: 8px;
font-weight: 700;
color: rgba(148, 163, 184, 0.88);
white-space: nowrap;
}
.root :global(.nearby-time span + span)::before {
content: "·";
margin-right: 4px;
color: rgba(148, 163, 184, 0.55);
}
.root :global(.nearby-time.is-stale) {
color: #fbbf24;
}
.root :global(.nearby-wind) { .root :global(.nearby-wind) {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -866,6 +866,22 @@ export function FutureForecastModal() {
const leaderLabel = const leaderLabel =
String(leadSignal?.leader_station_label || "").trim() || String(leadSignal?.leader_station_label || "").trim() ||
String(leadSignal?.leader_station_code || "").trim(); String(leadSignal?.leader_station_code || "").trim();
const leaderSyncStatus = String(leadSignal?.leader_sync_status || "").trim().toLowerCase();
const leaderSyncDelta = Number(leadSignal?.leader_time_delta_vs_anchor_minutes);
const syncNote =
leaderSyncStatus === "near_realtime" || leaderSyncStatus === "lagged"
? Number.isFinite(leaderSyncDelta)
? locale === "en-US"
? ` Timing offset versus the airport anchor is about ${Math.round(leaderSyncDelta)} minutes.`
: ` 与机场锚点存在约 ${Math.round(leaderSyncDelta)} 分钟时间差。`
: locale === "en-US"
? " Nearby observations are not fully synchronized."
: " 周边观测并非完全同步。"
: leaderSyncStatus === "unknown"
? locale === "en-US"
? " Nearby station timing is not fully verified."
: " 周边站观测时间尚未完全校验。"
: "";
const absDelta = Math.abs(delta); const absDelta = Math.abs(delta);
const status = const status =
delta <= -0.4 delta <= -0.4
@@ -884,12 +900,12 @@ export function FutureForecastModal() {
const note = const note =
delta <= -0.4 delta <= -0.4
? locale === "en-US" ? locale === "en-US"
? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} cooler than the nearby official network${leaderLabel ? `, led by ${leaderLabel}` : ""}.` ? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} cooler than the nearby official network${leaderLabel ? `, led by ${leaderLabel}` : ""}.${syncNote}`
: `机场主站当前比周边官方站网低 ${absDelta.toFixed(1)}${detail.temp_symbol}${leaderLabel ? `,领先点位是 ${leaderLabel}` : ""}` : `机场主站当前比周边官方站网低 ${absDelta.toFixed(1)}${detail.temp_symbol}${leaderLabel ? `,领先点位是 ${leaderLabel}` : ""}${syncNote}`
: delta >= 0.4 : delta >= 0.4
? locale === "en-US" ? locale === "en-US"
? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} hotter than the nearby official network.` ? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} hotter than the nearby official network.${syncNote}`
: `机场主站当前比周边官方站网高 ${absDelta.toFixed(1)}${detail.temp_symbol}` : `机场主站当前比周边官方站网高 ${absDelta.toFixed(1)}${detail.temp_symbol}${syncNote}`
: locale === "en-US" : locale === "en-US"
? "Airport anchor and nearby official network are broadly aligned." ? "Airport anchor and nearby official network are broadly aligned."
: "机场主站与周边官方站网当前大体齐平。"; : "机场主站与周边官方站网当前大体齐平。";
+53
View File
@@ -134,7 +134,59 @@ function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
if (!text || text === "9999") return ""; if (!text || text === "9999") return "";
return text; return text;
}; };
const isEnglishUi =
typeof document !== "undefined" &&
String(document.documentElement.lang || "").toLowerCase().startsWith("en");
const symbol = detail.temp_symbol || "°C"; const symbol = detail.temp_symbol || "°C";
const formatObsTime = () => {
const label = String(station.obs_time_label || "").trim();
if (label) return label;
const raw = String(station.obs_time || "").trim();
if (!raw) return "";
if (raw.endsWith("Z") || raw.includes("+00:00")) {
const parsed = new Date(raw);
if (!Number.isNaN(parsed.getTime())) {
return `${String(parsed.getUTCHours()).padStart(2, "0")}:${String(parsed.getUTCMinutes()).padStart(2, "0")}Z`;
}
}
if (raw.includes("T")) return raw.split("T").pop()?.slice(0, 5) || "";
if (raw.includes(" ")) return raw.split(" ").pop()?.slice(0, 5) || "";
return raw.slice(0, 5);
};
const syncLabel = () => {
const status = String(station.sync_status || "").trim().toLowerCase();
const delta = Number(station.time_delta_vs_anchor_minutes);
if (status === "synced") return isEnglishUi ? "synced" : "同步";
if (status === "near_realtime") {
return Number.isFinite(delta)
? isEnglishUi
? `${Math.round(delta)}m off`
: `${Math.round(delta)}m`
: isEnglishUi
? "near-live"
: "近实时";
}
if (status === "lagged") {
return Number.isFinite(delta)
? isEnglishUi
? `${Math.round(delta)}m lag`
: `滞后${Math.round(delta)}m`
: isEnglishUi
? "lagged"
: "滞后";
}
if (status === "stale") return isEnglishUi ? "stale" : "过期";
return "";
};
const obsTime = formatObsTime();
const syncText = syncLabel();
const timingHtml =
obsTime || syncText
? `<div class="nearby-time ${station.sync_status === "stale" ? "is-stale" : ""}">
${obsTime ? `<span>${obsTime}</span>` : ""}
${syncText ? `<span>${syncText}</span>` : ""}
</div>`
: "";
const rawLabel = const rawLabel =
station.station_label || station.station_label ||
station.name || station.name ||
@@ -182,6 +234,7 @@ function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
<span class="nearby-temp-val">${station.temp ?? "--"}</span> <span class="nearby-temp-val">${station.temp ?? "--"}</span>
<span class="nearby-temp-unit">${symbol}</span> <span class="nearby-temp-unit">${symbol}</span>
</div> </div>
${timingHtml}
</div> </div>
${windHtml} ${windHtml}
</div> </div>
+10
View File
@@ -117,6 +117,12 @@ export interface NearbyStation {
is_official?: boolean; is_official?: boolean;
is_airport_station?: boolean; is_airport_station?: boolean;
is_settlement_anchor?: boolean; is_settlement_anchor?: boolean;
obs_time?: string | null;
obs_time_label?: string | null;
age_minutes?: number | null;
time_delta_vs_anchor_minutes?: number | null;
sync_status?: "synced" | "near_realtime" | "lagged" | "stale" | "unknown" | string | null;
usable_for_intraday?: boolean;
wind_direction_text?: string | null; wind_direction_text?: string | null;
wind_power_text?: string | null; wind_power_text?: string | null;
} }
@@ -413,6 +419,10 @@ export interface CityDetail {
leader_station_code?: string | null; leader_station_code?: string | null;
leader_station_label?: string | null; leader_station_label?: string | null;
leader_temp?: number | null; leader_temp?: number | null;
leader_obs_time?: string | null;
leader_obs_time_label?: string | null;
leader_sync_status?: string | null;
leader_time_delta_vs_anchor_minutes?: number | null;
}; };
network_spread_signal?: { network_spread_signal?: {
available?: boolean; available?: boolean;
+33 -6
View File
@@ -2786,7 +2786,11 @@ export function getShortTermNowcastLines(
const recent = Array.isArray(detail.metar_recent_obs) const recent = Array.isArray(detail.metar_recent_obs)
? detail.metar_recent_obs.slice(-4) ? detail.metar_recent_obs.slice(-4)
: []; : [];
const nearby = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : []; const nearby = Array.isArray(detail.official_nearby) && detail.official_nearby.length
? detail.official_nearby
: Array.isArray(detail.mgm_nearby)
? detail.mgm_nearby
: [];
const nearbySource = String(detail.nearby_source || "").toLowerCase(); const nearbySource = String(detail.nearby_source || "").toLowerCase();
const sourceLabel = const sourceLabel =
nearbySource === "mgm" || isTurkishMgmCity(detail) nearbySource === "mgm" || isTurkishMgmCity(detail)
@@ -2809,12 +2813,33 @@ export function getShortTermNowcastLines(
Number.isFinite(currentTemp) && Number.isFinite(baseline) Number.isFinite(currentTemp) && Number.isFinite(baseline)
? currentTemp - baseline ? currentTemp - baseline
: 0; : 0;
let nearbyLead: { diff: number; name: string; temp: number } | null = null; let nearbyLead: { diff: number; name: string; temp: number; syncText: string } | null = null;
for (const station of nearby) { for (const station of nearby) {
const temp = Number(station.temp); const temp = Number(station.temp);
if (station.usable_for_intraday === false) continue;
if (!Number.isFinite(temp) || !Number.isFinite(currentTemp)) continue; if (!Number.isFinite(temp) || !Number.isFinite(currentTemp)) continue;
const diff = temp - currentTemp; const diff = temp - currentTemp;
const syncStatus = String(station.sync_status || "").toLowerCase();
const syncDelta = Number(station.time_delta_vs_anchor_minutes);
const syncText =
syncStatus === "synced"
? isEnglish(locale)
? "time-synced"
: "时间同步"
: syncStatus === "near_realtime" || syncStatus === "lagged"
? Number.isFinite(syncDelta)
? isEnglish(locale)
? `${Math.round(syncDelta)} min offset`
: `时间差 ${Math.round(syncDelta)} 分钟`
: isEnglish(locale)
? "not fully synchronized"
: "非完全同步"
: syncStatus === "unknown"
? isEnglish(locale)
? "timing unverified"
: "时间待校验"
: "";
if (!nearbyLead || Math.abs(diff) > Math.abs(nearbyLead.diff)) { if (!nearbyLead || Math.abs(diff) > Math.abs(nearbyLead.diff)) {
nearbyLead = { nearbyLead = {
diff, diff,
@@ -2823,9 +2848,11 @@ export function getShortTermNowcastLines(
station.icao || station.icao ||
(isEnglish(locale) ? "Nearby station" : "周边站"), (isEnglish(locale) ? "Nearby station" : "周边站"),
temp, temp,
syncText,
}; };
} }
} }
const usableNearbyCount = nearby.filter((station) => station.usable_for_intraday !== false).length;
const rows: Array<readonly [string, string]> = [ const rows: Array<readonly [string, string]> = [
[ [
@@ -2845,8 +2872,8 @@ export function getShortTermNowcastLines(
[ [
sourceLabel, sourceLabel,
isEnglish(locale) isEnglish(locale)
? `${nearby.length} stations joined the nearby scan.` ? `${usableNearbyCount}/${nearby.length} stations usable for the nearby scan; station timestamps may differ.`
: `${nearby.length} 个站点参与邻近监控。`, : `${usableNearbyCount}/${nearby.length} 个站点参与邻近监控;周边站观测时间可能不同步`,
], ],
]; ];
@@ -2865,8 +2892,8 @@ export function getShortTermNowcastLines(
rows.push([ rows.push([
isEnglish(locale) ? "Leading station" : "领先站", isEnglish(locale) ? "Leading station" : "领先站",
isEnglish(locale) isEnglish(locale)
? `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol}, relative to primary station ${formatDelta(nearbyLead.diff, detail.temp_symbol)} (${tone}).` ? `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol}, relative to primary station ${formatDelta(nearbyLead.diff, detail.temp_symbol)} (${tone})${nearbyLead.syncText ? `; ${nearbyLead.syncText}` : ""}.`
: `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol},相对主站 ${formatDelta(nearbyLead.diff, detail.temp_symbol)}${tone})。`, : `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol},相对主站 ${formatDelta(nearbyLead.diff, detail.temp_symbol)}${tone}${nearbyLead.syncText ? `${nearbyLead.syncText}` : ""}`,
]); ]);
} }
+125 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from src.data_collection.city_registry import CITY_REGISTRY from src.data_collection.city_registry import CITY_REGISTRY
@@ -30,6 +31,106 @@ def _safe_float(value: Any) -> Optional[float]:
return None return None
def _parse_obs_datetime(value: Any, epoch_value: Any = None) -> Optional[datetime]:
for candidate in (epoch_value, value):
if candidate is None or candidate == "":
continue
try:
if isinstance(candidate, (int, float)):
numeric = float(candidate)
if numeric > 1_000_000_000_000:
numeric /= 1000.0
if numeric > 1_000_000_000:
return datetime.fromtimestamp(numeric, tz=timezone.utc)
text = str(candidate).strip()
if not text:
continue
if text.isdigit():
numeric = float(text)
if numeric > 1_000_000_000_000:
numeric /= 1000.0
if numeric > 1_000_000_000:
return datetime.fromtimestamp(numeric, tz=timezone.utc)
normalized = text.replace(" ", "T")
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
return datetime.fromisoformat(normalized)
except Exception:
continue
return None
def _format_obs_time_label(value: Any, epoch_value: Any = None) -> Optional[str]:
text = str(value or "").strip()
parsed = _parse_obs_datetime(value, epoch_value)
if parsed is not None:
suffix = "Z" if parsed.tzinfo is not None and parsed.utcoffset() == timezone.utc.utcoffset(parsed) else ""
return parsed.strftime("%H:%M") + suffix
if not text:
return None
if "T" in text:
return text.split("T")[-1][:5]
if " " in text:
return text.split()[-1][:5]
return text[:5] if len(text) >= 5 else text
def _timing_delta_minutes(anchor_dt: Optional[datetime], station_dt: Optional[datetime]) -> Optional[int]:
if anchor_dt is None or station_dt is None:
return None
try:
if (anchor_dt.tzinfo is None) != (station_dt.tzinfo is None):
return None
delta = abs((station_dt - anchor_dt).total_seconds()) / 60.0
return int(round(delta))
except Exception:
return None
def _station_age_minutes(station_dt: Optional[datetime]) -> Optional[int]:
if station_dt is None or station_dt.tzinfo is None:
return None
try:
return int(round((datetime.now(timezone.utc) - station_dt.astimezone(timezone.utc)).total_seconds() / 60.0))
except Exception:
return None
def _sync_status(delta_minutes: Optional[int], age_minutes: Optional[int]) -> str:
reference = delta_minutes if delta_minutes is not None else age_minutes
if reference is None:
return "unknown"
if reference <= 10:
return "synced"
if reference <= 30:
return "near_realtime"
if reference <= 60:
return "lagged"
return "stale"
def _enrich_station_timing(
anchor: Optional[Dict[str, Any]],
rows: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
anchor = anchor or {}
anchor_dt = _parse_obs_datetime(anchor.get("obs_time"), anchor.get("obs_time_epoch"))
enriched: List[Dict[str, Any]] = []
for row in rows:
station_dt = _parse_obs_datetime(row.get("obs_time"), row.get("obs_time_epoch"))
delta_minutes = _timing_delta_minutes(anchor_dt, station_dt)
age_minutes = _station_age_minutes(station_dt)
status = _sync_status(delta_minutes, age_minutes)
enriched_row = dict(row)
enriched_row["obs_time_label"] = _format_obs_time_label(row.get("obs_time"), row.get("obs_time_epoch"))
enriched_row["age_minutes"] = age_minutes
enriched_row["time_delta_vs_anchor_minutes"] = delta_minutes
enriched_row["sync_status"] = status
enriched_row["usable_for_intraday"] = status != "stale"
enriched.append(enriched_row)
return enriched
def _city_meta(city: str) -> Dict[str, Any]: def _city_meta(city: str) -> Dict[str, Any]:
return CITY_REGISTRY.get(str(city or "").strip().lower(), {}) or {} return CITY_REGISTRY.get(str(city or "").strip().lower(), {}) or {}
@@ -145,6 +246,7 @@ def _metar_cluster_rows(raw: Dict[str, Any]) -> List[Dict[str, Any]]:
is_airport_station=True, is_airport_station=True,
is_settlement_anchor=False, is_settlement_anchor=False,
extra={ extra={
"obs_time_epoch": row.get("obs_time_epoch"),
"wind_dir": _safe_float(row.get("wind_dir")), "wind_dir": _safe_float(row.get("wind_dir")),
"wind_speed_kt": _safe_float(row.get("wind_speed_kt") or row.get("wind_speed")), "wind_speed_kt": _safe_float(row.get("wind_speed_kt") or row.get("wind_speed")),
"raw_metar": row.get("raw_metar"), "raw_metar": row.get("raw_metar"),
@@ -284,6 +386,7 @@ def _mgm_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]:
temp=row.get("temp"), temp=row.get("temp"),
lat=row.get("lat"), lat=row.get("lat"),
lon=row.get("lon"), lon=row.get("lon"),
obs_time=row.get("obs_time"),
source_code="mgm", source_code="mgm",
source_label="MGM", source_label="MGM",
is_official=True, is_official=True,
@@ -368,7 +471,11 @@ def _network_signals(
official_rows: List[Dict[str, Any]], official_rows: List[Dict[str, Any]],
) -> Dict[str, Any]: ) -> Dict[str, Any]:
airport_temp = _safe_float((airport_primary or {}).get("temp")) airport_temp = _safe_float((airport_primary or {}).get("temp"))
valid_rows = [row for row in official_rows if _safe_float(row.get("temp")) is not None] valid_rows = [
row
for row in official_rows
if _safe_float(row.get("temp")) is not None and row.get("usable_for_intraday") is not False
]
if not valid_rows: if not valid_rows:
return { return {
"network_lead_signal": {"available": False}, "network_lead_signal": {"available": False},
@@ -394,6 +501,10 @@ def _network_signals(
"leader_station_code": hottest.get("station_code"), "leader_station_code": hottest.get("station_code"),
"leader_station_label": hottest.get("station_label"), "leader_station_label": hottest.get("station_label"),
"leader_temp": hottest_temp, "leader_temp": hottest_temp,
"leader_obs_time": hottest.get("obs_time"),
"leader_obs_time_label": hottest.get("obs_time_label"),
"leader_sync_status": hottest.get("sync_status"),
"leader_time_delta_vs_anchor_minutes": hottest.get("time_delta_vs_anchor_minutes"),
}, },
"network_spread_signal": { "network_spread_signal": {
"available": spread is not None, "available": spread is not None,
@@ -588,9 +699,21 @@ def build_country_network_snapshot(city: str, raw: Dict[str, Any]) -> Dict[str,
provider = get_country_network_provider(city) provider = get_country_network_provider(city)
metadata = provider.settlement_station_metadata(city) metadata = provider.settlement_station_metadata(city)
airport_primary = provider.airport_primary_current(city, raw) or {} airport_primary = provider.airport_primary_current(city, raw) or {}
official_nearby = provider.official_nearby_current(city, raw) official_nearby = _enrich_station_timing(
airport_primary,
provider.official_nearby_current(city, raw),
)
status = provider.official_network_status(city, raw) status = provider.official_network_status(city, raw)
signals = _network_signals(airport_primary, official_nearby) signals = _network_signals(airport_primary, official_nearby)
usable_count = len([row for row in official_nearby if row.get("usable_for_intraday") is not False])
stale_count = len([row for row in official_nearby if row.get("sync_status") == "stale"])
unknown_count = len([row for row in official_nearby if row.get("sync_status") == "unknown"])
status = {
**status,
"usable_row_count": usable_count,
"stale_row_count": stale_count,
"unknown_timing_count": unknown_count,
}
return { return {
"provider_code": provider.provider_code, "provider_code": provider.provider_code,
"provider_label": provider.provider_label, "provider_label": provider.provider_label,
+1
View File
@@ -446,6 +446,7 @@ class MetarSourceMixin:
"wind_speed": obs.get("wspd"), "wind_speed": obs.get("wspd"),
"wind_speed_kt": obs.get("wspd"), "wind_speed_kt": obs.get("wspd"),
"obs_time": obs.get("obsTime") or obs.get("reportTime"), "obs_time": obs.get("obsTime") or obs.get("reportTime"),
"obs_time_epoch": obs.get("obsTime"),
"raw_metar": obs.get("rawOb"), "raw_metar": obs.get("rawOb"),
} }
) )
+8 -1
View File
@@ -305,8 +305,14 @@ class MgmSourceMixin:
temp = obs.get("sicaklik") temp = obs.get("sicaklik")
wind_speed = obs.get("ruzgarHiz") wind_speed = obs.get("ruzgarHiz")
wind_dir = obs.get("ruzgarYon") wind_dir = obs.get("ruzgarYon")
obs_time = obs.get("veriZamani")
if temp is not None and temp > -9000: if temp is not None and temp > -9000:
return ist_no, {"temp": temp, "wind_speed": wind_speed, "wind_dir": wind_dir} return ist_no, {
"temp": temp,
"wind_speed": wind_speed,
"wind_dir": wind_dir,
"obs_time": obs_time,
}
except Exception: except Exception:
pass pass
return None, None return None, None
@@ -344,6 +350,7 @@ class MgmSourceMixin:
"lat": lat, "lat": lat,
"lon": lon, "lon": lon,
"temp": temp.get("temp") if isinstance(temp, dict) else temp, "temp": temp.get("temp") if isinstance(temp, dict) else temp,
"obs_time": temp.get("obs_time") if isinstance(temp, dict) else None,
"wind_speed": temp.get("wind_speed") if isinstance(temp, dict) else None, "wind_speed": temp.get("wind_speed") if isinstance(temp, dict) else None,
"wind_dir": temp.get("wind_dir") if isinstance(temp, dict) else None, "wind_dir": temp.get("wind_dir") if isinstance(temp, dict) else None,
"istNo": ist_no "istNo": ist_no
+41
View File
@@ -48,6 +48,7 @@ def test_turkey_mgm_provider_returns_official_nearby_rows():
"lat": 40.1, "lat": 40.1,
"lon": 32.9, "lon": 32.9,
"temp": 17.1, "temp": 17.1,
"obs_time": "2026-04-06T10:08:00.000Z",
} }
], ],
} }
@@ -58,6 +59,46 @@ def test_turkey_mgm_provider_returns_official_nearby_rows():
assert snapshot["official_network_status"]["available"] is True assert snapshot["official_network_status"]["available"] is True
assert snapshot["official_nearby"][0]["source_code"] == "mgm" assert snapshot["official_nearby"][0]["source_code"] == "mgm"
assert snapshot["official_nearby"][0]["is_official"] is True assert snapshot["official_nearby"][0]["is_official"] is True
assert snapshot["official_nearby"][0]["time_delta_vs_anchor_minutes"] == 8
assert snapshot["official_nearby"][0]["sync_status"] == "synced"
assert snapshot["official_nearby"][0]["usable_for_intraday"] is True
assert snapshot["official_network_status"]["usable_row_count"] == 1
def test_nearby_station_timing_marks_stale_rows_unusable_for_network_signal():
raw = {
"metar": {
"observation_time": "2026-04-06T10:00:00.000Z",
"current": {"temp": 20.0},
},
"mgm_nearby": [
{
"name": "Fresh",
"istNo": "100",
"lat": 40.1,
"lon": 32.9,
"temp": 21.0,
"obs_time": "2026-04-06T10:20:00.000Z",
},
{
"name": "Stale Hot",
"istNo": "101",
"lat": 40.2,
"lon": 33.0,
"temp": 26.0,
"obs_time": "2026-04-06T08:30:00.000Z",
},
],
}
snapshot = build_country_network_snapshot("ankara", raw)
stale = next(row for row in snapshot["official_nearby"] if row["station_label"] == "Stale Hot")
assert stale["sync_status"] == "stale"
assert stale["usable_for_intraday"] is False
assert snapshot["official_network_status"]["stale_row_count"] == 1
assert snapshot["network_lead_signal"]["leader_station_label"] == "Fresh"
assert snapshot["airport_vs_network_delta"] == 1.0
def test_china_provider_falls_back_to_metar_cluster_without_replacing_airport_anchor(): def test_china_provider_falls_back_to_metar_cluster_without_replacing_airport_anchor():
+16 -6
View File
@@ -1349,6 +1349,16 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
if airport_delta is not None: if airport_delta is not None:
available_layers += 1 available_layers += 1
leader = str(lead_signal.get("leader_station_label") or lead_signal.get("leader_station_code") or "").strip() leader = str(lead_signal.get("leader_station_label") or lead_signal.get("leader_station_code") or "").strip()
sync_status = str(lead_signal.get("leader_sync_status") or "").strip().lower()
sync_delta = _sf(lead_signal.get("leader_time_delta_vs_anchor_minutes"))
sync_suffix_zh = ""
sync_suffix_en = ""
if sync_status in {"near_realtime", "lagged"} and sync_delta is not None:
sync_suffix_zh = f";但与机场锚点约差 {sync_delta:.0f} 分钟,作为降权信号处理"
sync_suffix_en = f"; timing differs from the airport anchor by about {sync_delta:.0f} minutes, so this signal is down-weighted"
elif sync_status == "unknown":
sync_suffix_zh = ";周边站观测时间不可完全校验,作为弱参考"
sync_suffix_en = "; station timing is not fully verified, so this is treated as a weak reference"
if airport_delta <= -0.4: if airport_delta <= -0.4:
support_score += 1 support_score += 1
_add_signal( _add_signal(
@@ -1356,9 +1366,9 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
label="站网对比", label="站网对比",
label_en="Station-network comparison", label_en="Station-network comparison",
direction="support", direction="support",
strength="medium", strength="weak" if sync_suffix_zh else "medium",
summary=f"周边站网较机场锚点偏热 {abs(airport_delta):.1f}{unit}{f',领先点位 {leader}' if leader else ''}", summary=f"周边站网较机场锚点偏热 {abs(airport_delta):.1f}{unit}{f',领先点位 {leader}' if leader else ''}{sync_suffix_zh}",
summary_en=f"Nearby stations are {abs(airport_delta):.1f}{unit} warmer than the airport anchor{f'; leading site: {leader}' if leader else ''}.", summary_en=f"Nearby stations are {abs(airport_delta):.1f}{unit} warmer than the airport anchor{f'; leading site: {leader}' if leader else ''}{sync_suffix_en}.",
) )
elif airport_delta >= 0.4: elif airport_delta >= 0.4:
suppress_score += 1 suppress_score += 1
@@ -1367,9 +1377,9 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
label="站网对比", label="站网对比",
label_en="Station-network comparison", label_en="Station-network comparison",
direction="suppress", direction="suppress",
strength="medium", strength="weak" if sync_suffix_zh else "medium",
summary=f"机场锚点较周边站网偏热 {abs(airport_delta):.1f}{unit},继续上修需要机场自身后续报文确认。", summary=f"机场锚点较周边站网偏热 {abs(airport_delta):.1f}{unit},继续上修需要机场自身后续报文确认{sync_suffix_zh}",
summary_en=f"The airport anchor is {abs(airport_delta):.1f}{unit} warmer than nearby stations; further upside needs confirmation from later airport reports.", summary_en=f"The airport anchor is {abs(airport_delta):.1f}{unit} warmer than nearby stations; further upside needs confirmation from later airport reports{sync_suffix_en}.",
) )
else: else:
_add_signal( _add_signal(