Add nearby station timing sync labels
This commit is contained in:
@@ -2158,6 +2158,27 @@
|
||||
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) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -866,6 +866,22 @@ export function FutureForecastModal() {
|
||||
const leaderLabel =
|
||||
String(leadSignal?.leader_station_label || "").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 status =
|
||||
delta <= -0.4
|
||||
@@ -884,12 +900,12 @@ export function FutureForecastModal() {
|
||||
const note =
|
||||
delta <= -0.4
|
||||
? locale === "en-US"
|
||||
? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} cooler than the nearby official network${leaderLabel ? `, led by ${leaderLabel}` : ""}.`
|
||||
: `机场主站当前比周边官方站网低 ${absDelta.toFixed(1)}${detail.temp_symbol}${leaderLabel ? `,领先点位是 ${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}` : ""}。${syncNote}`
|
||||
: delta >= 0.4
|
||||
? locale === "en-US"
|
||||
? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} hotter than the nearby official network.`
|
||||
: `机场主站当前比周边官方站网高 ${absDelta.toFixed(1)}${detail.temp_symbol}。`
|
||||
? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} hotter than the nearby official network.${syncNote}`
|
||||
: `机场主站当前比周边官方站网高 ${absDelta.toFixed(1)}${detail.temp_symbol}。${syncNote}`
|
||||
: locale === "en-US"
|
||||
? "Airport anchor and nearby official network are broadly aligned."
|
||||
: "机场主站与周边官方站网当前大体齐平。";
|
||||
|
||||
@@ -134,7 +134,59 @@ function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
|
||||
if (!text || text === "9999") return "";
|
||||
return text;
|
||||
};
|
||||
const isEnglishUi =
|
||||
typeof document !== "undefined" &&
|
||||
String(document.documentElement.lang || "").toLowerCase().startsWith("en");
|
||||
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 =
|
||||
station.station_label ||
|
||||
station.name ||
|
||||
@@ -182,6 +234,7 @@ function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
|
||||
<span class="nearby-temp-val">${station.temp ?? "--"}</span>
|
||||
<span class="nearby-temp-unit">${symbol}</span>
|
||||
</div>
|
||||
${timingHtml}
|
||||
</div>
|
||||
${windHtml}
|
||||
</div>
|
||||
|
||||
@@ -117,6 +117,12 @@ export interface NearbyStation {
|
||||
is_official?: boolean;
|
||||
is_airport_station?: 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_power_text?: string | null;
|
||||
}
|
||||
@@ -413,6 +419,10 @@ export interface CityDetail {
|
||||
leader_station_code?: string | null;
|
||||
leader_station_label?: string | 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?: {
|
||||
available?: boolean;
|
||||
|
||||
@@ -2786,7 +2786,11 @@ export function getShortTermNowcastLines(
|
||||
const recent = Array.isArray(detail.metar_recent_obs)
|
||||
? 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 sourceLabel =
|
||||
nearbySource === "mgm" || isTurkishMgmCity(detail)
|
||||
@@ -2809,12 +2813,33 @@ export function getShortTermNowcastLines(
|
||||
Number.isFinite(currentTemp) && Number.isFinite(baseline)
|
||||
? currentTemp - baseline
|
||||
: 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) {
|
||||
const temp = Number(station.temp);
|
||||
if (station.usable_for_intraday === false) continue;
|
||||
if (!Number.isFinite(temp) || !Number.isFinite(currentTemp)) continue;
|
||||
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)) {
|
||||
nearbyLead = {
|
||||
diff,
|
||||
@@ -2823,9 +2848,11 @@ export function getShortTermNowcastLines(
|
||||
station.icao ||
|
||||
(isEnglish(locale) ? "Nearby station" : "周边站"),
|
||||
temp,
|
||||
syncText,
|
||||
};
|
||||
}
|
||||
}
|
||||
const usableNearbyCount = nearby.filter((station) => station.usable_for_intraday !== false).length;
|
||||
|
||||
const rows: Array<readonly [string, string]> = [
|
||||
[
|
||||
@@ -2845,8 +2872,8 @@ export function getShortTermNowcastLines(
|
||||
[
|
||||
sourceLabel,
|
||||
isEnglish(locale)
|
||||
? `${nearby.length} stations joined the nearby scan.`
|
||||
: `${nearby.length} 个站点参与邻近监控。`,
|
||||
? `${usableNearbyCount}/${nearby.length} stations usable for the nearby scan; station timestamps may differ.`
|
||||
: `${usableNearbyCount}/${nearby.length} 个站点可参与邻近监控;周边站观测时间可能不同步。`,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -2865,8 +2892,8 @@ export function getShortTermNowcastLines(
|
||||
rows.push([
|
||||
isEnglish(locale) ? "Leading station" : "领先站",
|
||||
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},相对主站 ${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.syncText ? `;${nearbyLead.syncText}` : ""}。`,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.data_collection.city_registry import CITY_REGISTRY
|
||||
@@ -30,6 +31,106 @@ def _safe_float(value: Any) -> Optional[float]:
|
||||
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]:
|
||||
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_settlement_anchor=False,
|
||||
extra={
|
||||
"obs_time_epoch": row.get("obs_time_epoch"),
|
||||
"wind_dir": _safe_float(row.get("wind_dir")),
|
||||
"wind_speed_kt": _safe_float(row.get("wind_speed_kt") or row.get("wind_speed")),
|
||||
"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"),
|
||||
lat=row.get("lat"),
|
||||
lon=row.get("lon"),
|
||||
obs_time=row.get("obs_time"),
|
||||
source_code="mgm",
|
||||
source_label="MGM",
|
||||
is_official=True,
|
||||
@@ -368,7 +471,11 @@ def _network_signals(
|
||||
official_rows: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
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:
|
||||
return {
|
||||
"network_lead_signal": {"available": False},
|
||||
@@ -394,6 +501,10 @@ def _network_signals(
|
||||
"leader_station_code": hottest.get("station_code"),
|
||||
"leader_station_label": hottest.get("station_label"),
|
||||
"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": {
|
||||
"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)
|
||||
metadata = provider.settlement_station_metadata(city)
|
||||
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)
|
||||
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 {
|
||||
"provider_code": provider.provider_code,
|
||||
"provider_label": provider.provider_label,
|
||||
|
||||
@@ -446,6 +446,7 @@ class MetarSourceMixin:
|
||||
"wind_speed": obs.get("wspd"),
|
||||
"wind_speed_kt": obs.get("wspd"),
|
||||
"obs_time": obs.get("obsTime") or obs.get("reportTime"),
|
||||
"obs_time_epoch": obs.get("obsTime"),
|
||||
"raw_metar": obs.get("rawOb"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -305,8 +305,14 @@ class MgmSourceMixin:
|
||||
temp = obs.get("sicaklik")
|
||||
wind_speed = obs.get("ruzgarHiz")
|
||||
wind_dir = obs.get("ruzgarYon")
|
||||
obs_time = obs.get("veriZamani")
|
||||
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:
|
||||
pass
|
||||
return None, None
|
||||
@@ -344,6 +350,7 @@ class MgmSourceMixin:
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"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_dir": temp.get("wind_dir") if isinstance(temp, dict) else None,
|
||||
"istNo": ist_no
|
||||
|
||||
@@ -48,6 +48,7 @@ def test_turkey_mgm_provider_returns_official_nearby_rows():
|
||||
"lat": 40.1,
|
||||
"lon": 32.9,
|
||||
"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_nearby"][0]["source_code"] == "mgm"
|
||||
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():
|
||||
|
||||
+16
-6
@@ -1349,6 +1349,16 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if airport_delta is not None:
|
||||
available_layers += 1
|
||||
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:
|
||||
support_score += 1
|
||||
_add_signal(
|
||||
@@ -1356,9 +1366,9 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
label="站网对比",
|
||||
label_en="Station-network comparison",
|
||||
direction="support",
|
||||
strength="medium",
|
||||
summary=f"周边站网较机场锚点偏热 {abs(airport_delta):.1f}{unit}{f',领先点位 {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 ''}.",
|
||||
strength="weak" if sync_suffix_zh else "medium",
|
||||
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 ''}{sync_suffix_en}.",
|
||||
)
|
||||
elif airport_delta >= 0.4:
|
||||
suppress_score += 1
|
||||
@@ -1367,9 +1377,9 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
label="站网对比",
|
||||
label_en="Station-network comparison",
|
||||
direction="suppress",
|
||||
strength="medium",
|
||||
summary=f"机场锚点较周边站网偏热 {abs(airport_delta):.1f}{unit},继续上修需要机场自身后续报文确认。",
|
||||
summary_en=f"The airport anchor is {abs(airport_delta):.1f}{unit} warmer than nearby stations; further upside needs confirmation from later airport reports.",
|
||||
strength="weak" if sync_suffix_zh else "medium",
|
||||
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{sync_suffix_en}.",
|
||||
)
|
||||
else:
|
||||
_add_signal(
|
||||
|
||||
Reference in New Issue
Block a user