Restore observed temperatures on map and trend charts

This commit is contained in:
2569718930@qq.com
2026-04-16 15:29:26 +08:00
parent 767f7ed6bf
commit 25424700ce
3 changed files with 191 additions and 11 deletions
+23 -3
View File
@@ -52,6 +52,26 @@ function getMarkerDisplayOffset(cityName: string) {
};
}
function pickMarkerTemperature(
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
) {
if (!snapshot) return null;
const detail = snapshot as Partial<CityDetail>;
const candidates = [
snapshot.current?.temp,
detail.airport_primary?.temp,
detail.airport_current?.temp,
detail.center_station_candidate?.temp,
detail.official_nearby?.[0]?.temp,
detail.mgm_nearby?.[0]?.temp,
];
for (const value of candidates) {
const numeric = Number(value);
if (Number.isFinite(numeric)) return numeric;
}
return null;
}
function createMarkerIcon(
city: CityListItem,
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
@@ -60,8 +80,8 @@ function createMarkerIcon(
const label = city.display_name;
const unit = city.temp_unit === "fahrenheit" ? "°F" : "°C";
const shortName = label.length > 10 ? `${label.substring(0, 8)}...` : label;
const tempText =
snapshot?.current?.temp != null ? `${snapshot.current.temp}${unit}` : "--";
const markerTemp = pickMarkerTemperature(snapshot);
const tempText = markerTemp != null ? `${markerTemp}${unit}` : "--";
const offset = getMarkerDisplayOffset(city.name);
const styleAttr =
offset.x || offset.y
@@ -91,7 +111,7 @@ function getMarkerSignature(
city.temp_unit,
city.lat,
city.lon,
snapshot?.current?.temp ?? "",
pickMarkerTemperature(snapshot) ?? "",
].join("|");
}
+86 -2
View File
@@ -323,6 +323,78 @@ function sortObservationItemsByTime<T extends { time?: string | null }>(items: T
});
}
function normalizeObservationTimeForChart(
value: unknown,
detail: CityDetail,
) {
const raw = String(value || "").trim();
if (raw && !raw.includes("T")) {
return normalizeHm(raw) || raw;
}
return normalizeHm(detail.local_time) || normalizeHm(raw) || raw;
}
function buildCurrentObservationFallback(
detail: CityDetail,
): Array<{ time?: string; temp?: number | null; sourceLabel?: string | null }> {
const candidates: Array<{
sourceLabel?: string | null;
temp?: number | null;
time?: string | null;
}> = [
{
sourceLabel: detail.current?.settlement_source_label,
temp: detail.current?.temp,
time: detail.current?.obs_time || detail.current?.report_time,
},
{
sourceLabel: detail.airport_primary?.source_label || "METAR",
temp: detail.airport_primary?.temp,
time: detail.airport_primary?.obs_time || detail.airport_primary?.report_time,
},
{
sourceLabel: detail.airport_current?.source_label || "METAR",
temp: detail.airport_current?.temp,
time: detail.airport_current?.obs_time || detail.airport_current?.report_time,
},
{
sourceLabel:
detail.center_station_candidate?.source_label ||
detail.center_station_candidate?.source_code,
temp: detail.center_station_candidate?.temp,
time: String((detail.center_station_candidate as Record<string, unknown> | null | undefined)?.obs_time || ""),
},
{
sourceLabel:
detail.official_nearby?.[0]?.source_label ||
detail.official_nearby?.[0]?.source_code,
temp: detail.official_nearby?.[0]?.temp,
time: String((detail.official_nearby?.[0] as Record<string, unknown> | null | undefined)?.obs_time || ""),
},
{
sourceLabel:
detail.mgm_nearby?.[0]?.source_label ||
detail.mgm_nearby?.[0]?.source_code,
temp: detail.mgm_nearby?.[0]?.temp,
time: String((detail.mgm_nearby?.[0] as Record<string, unknown> | null | undefined)?.obs_time || ""),
},
];
const first = candidates.find((item) => {
const numeric = Number(item.temp);
return Number.isFinite(numeric);
});
if (!first) return [];
return [
{
sourceLabel: first.sourceLabel,
temp: Number(first.temp),
time: normalizeObservationTimeForChart(first.time, detail),
},
];
}
function interpolateSeriesAtMinutes(
times: string[],
values: Array<number | null | undefined>,
@@ -623,9 +695,19 @@ export function getTemperatureChartData(
? [{ time: detail.current.obs_time, temp: detail.current.temp }]
: []
: [];
const currentObservationFallback = buildCurrentObservationFallback(detail);
const metarObservationSource = detail.metar_today_obs?.length
? detail.metar_today_obs
: detail.trend?.recent || [];
: detail.trend?.recent?.length
? detail.trend.recent
: currentObservationFallback;
const usingCurrentObservationFallback =
!detail.metar_today_obs?.length &&
!detail.trend?.recent?.length &&
currentObservationFallback.length > 0;
const currentFallbackTag =
currentObservationFallback[0]?.sourceLabel ||
getObservationSourceTag(detail);
const allowMetarFallback = settlementSource && observationCode !== "hko";
const shouldUseMetarFallback =
allowMetarFallback &&
@@ -644,7 +726,9 @@ export function getTemperatureChartData(
return `${icao} METAR`;
})();
const observationDisplayTag =
observationCode === "wunderground"
usingCurrentObservationFallback
? String(currentFallbackTag).toUpperCase()
: observationCode === "wunderground"
? metarFallbackTag
: useSettlementObservationSource && shouldUseMetarFallback
? metarFallbackTag
+82 -6
View File
@@ -30,6 +30,7 @@ from src.analysis.deb_algorithm import calculate_dynamic_weights
from src.analysis.settlement_rounding import apply_city_settlement
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.nmc_sources import NMC_CITY_REFERENCES
from src.models.lgbm_daily_high import predict_lgbm_daily_high
TURKISH_MGM_CITIES = {"ankara", "istanbul"}
@@ -52,6 +53,39 @@ _GROQ_COMMENTARY_CACHE_TTL_SEC = int(
)
def _format_observation_time_local(value: Any, utc_offset: int) -> str:
raw = str(value or "").strip()
if not raw:
return ""
if "T" in raw:
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone(timedelta(seconds=utc_offset))).strftime("%H:%M")
except Exception:
pass
match = re.search(r"(\d{1,2}):(\d{2})", raw)
if match:
return f"{int(match.group(1)):02d}:{match.group(2)}"
return raw[:16]
def _fetch_nmc_current_fallback(city: str, *, use_fahrenheit: bool) -> Dict[str, Any]:
city_key = str(city or "").strip().lower()
if city_key not in NMC_CITY_REFERENCES:
return {}
try:
payload = _weather.fetch_nmc_region_current(
city_key,
use_fahrenheit=use_fahrenheit,
)
return payload if isinstance(payload, dict) else {}
except Exception as exc:
logger.debug("NMC current fallback failed city={}: {}", city_key, exc)
return {}
def _record_analysis_cache_event(*, city: str, hit: bool, force_refresh: bool) -> None:
now = datetime.now(timezone.utc).isoformat()
with _ANALYSIS_CACHE_STATS_LOCK:
@@ -1176,17 +1210,33 @@ def _analyze(
sc_cur = settlement_current.get("current", {}) if settlement_current else {}
use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur)
primary_current = sc_cur if use_settlement_current else mc
current_source = settlement_source
current_source_label = settlement_source_label
current_station_code = settlement_current.get("station_code")
current_station_name = settlement_current.get("station_name")
cur_temp = _sf(primary_current.get("temp"))
if cur_temp is None:
cur_temp = _sf(mc.get("temp"))
if cur_temp is None:
cur_temp = _sf(mg_cur.get("temp"))
if cur_temp is None:
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
nmc_cur = nmc_fallback.get("current") or {}
nmc_temp = _sf(nmc_cur.get("temp"))
if nmc_temp is not None:
cur_temp = nmc_temp
current_source = "nmc"
current_source_label = "NMC"
current_station_code = nmc_fallback.get("station_code")
current_station_name = nmc_fallback.get("station_name")
max_so_far = _sf(primary_current.get("max_temp_so_far"))
if max_so_far is None:
max_so_far = _sf(mc.get("max_temp_so_far"))
if max_so_far is None:
max_so_far = _sf(mg_cur.get("mgm_max_temp"))
if max_so_far is None:
max_so_far = cur_temp
max_temp_time = primary_current.get("max_temp_time")
if not max_temp_time and not use_settlement_current:
@@ -1237,6 +1287,12 @@ def _analyze(
)
except Exception:
obs_time_str = str(obs_t)[:16]
if not obs_time_str and current_source == "nmc":
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
obs_time_str = _format_observation_time_local(
nmc_fallback.get("publish_time") or nmc_fallback.get("timestamp"),
int(utc_offset or 0),
)
settlement_today_obs = []
if use_settlement_current:
@@ -1826,10 +1882,10 @@ def _analyze(
"max_temp_time": max_temp_time,
"raw_max_so_far": raw_settlement_max,
"wu_settlement": wu_settle,
"settlement_source": settlement_source,
"settlement_source_label": settlement_source_label,
"station_code": settlement_current.get("station_code"),
"station_name": settlement_current.get("station_name"),
"settlement_source": current_source,
"settlement_source_label": current_source_label,
"station_code": current_station_code,
"station_name": current_station_name,
"obs_time": obs_time_str,
"obs_age_min": None if use_settlement_current else metar_age_min,
"report_time": primary_current.get("report_time"),
@@ -2033,17 +2089,30 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur)
primary_current = sc_cur if use_settlement_current else mc
current_source = settlement_source
current_source_label = settlement_source_label
nmc_fallback: Dict[str, Any] = {}
cur_temp = _sf(primary_current.get("temp"))
if cur_temp is None:
cur_temp = _sf(mc.get("temp"))
if cur_temp is None:
cur_temp = _sf(mg_cur.get("temp"))
if cur_temp is None:
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
nmc_cur = nmc_fallback.get("current") or {}
nmc_temp = _sf(nmc_cur.get("temp"))
if nmc_temp is not None:
cur_temp = nmc_temp
current_source = "nmc"
current_source_label = "NMC"
max_so_far = _sf(primary_current.get("max_temp_so_far"))
if max_so_far is None:
max_so_far = _sf(mc.get("max_temp_so_far"))
if max_so_far is None:
max_so_far = _sf(mg_cur.get("mgm_max_temp"))
if max_so_far is None:
max_so_far = cur_temp
max_temp_time = primary_current.get("max_temp_time")
if not max_temp_time and not use_settlement_current:
@@ -2084,6 +2153,13 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
)
except Exception:
obs_time_str = str(obs_t)[:16]
if not obs_time_str and current_source == "nmc":
if not nmc_fallback:
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
obs_time_str = _format_observation_time_local(
nmc_fallback.get("publish_time") or nmc_fallback.get("timestamp"),
int(utc_offset or 0),
)
om_daily = (open_meteo.get("daily") or {}) if isinstance(open_meteo, dict) else {}
om_hourly = (open_meteo.get("hourly") or {}) if isinstance(open_meteo, dict) else {}
@@ -2200,8 +2276,8 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"max_so_far": _sf(display_settlement_max),
"max_temp_time": max_temp_time,
"wu_settlement": _sf(wu_settle),
"settlement_source": settlement_source,
"settlement_source_label": settlement_source_label,
"settlement_source": current_source,
"settlement_source_label": current_source_label,
"obs_time": obs_time_str or None,
"obs_age_min": obs_age_min,
},