feat: Implement Wunderground data integration, enhance dashboard weather data processing, and add future forecast modal.

This commit is contained in:
2569718930@qq.com
2026-03-25 18:03:56 +08:00
parent fd210ac730
commit 7589fd74bf
6 changed files with 331 additions and 32 deletions
@@ -119,3 +119,4 @@
{"city": "hong kong", "timestamp": "2026-03-23T21:10:00+08:00", "date": "2026-03-23", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.6806250000000001, "deb_prediction": 25.2, "ensemble": {"p10": 26.3, "median": 26.4, "p90": 26.6}, "multi_model": {"Open-Meteo": 24.8, "HKO(港天文)": 27.0, "ECMWF": 25.4, "GFS": 25.1, "ICON": 24.8, "GEM": 25.3, "JMA": 23.6}, "max_so_far": 27.4, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} {"city": "hong kong", "timestamp": "2026-03-23T21:10:00+08:00", "date": "2026-03-23", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.6806250000000001, "deb_prediction": 25.2, "ensemble": {"p10": 26.3, "median": 26.4, "p90": 26.6}, "multi_model": {"Open-Meteo": 24.8, "HKO(港天文)": 27.0, "ECMWF": 25.4, "GFS": 25.1, "ICON": 24.8, "GEM": 25.3, "JMA": 23.6}, "max_so_far": 27.4, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "shenzhen", "timestamp": "2026-03-25T08:43:15.528748+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 29.0, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} {"city": "shenzhen", "timestamp": "2026-03-25T08:43:15.528748+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 29.0, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "shenzhen", "timestamp": "2026-03-25T08:57:11.783182+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": 26.7, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 26.7, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [{"v": 27, "p": 1.0}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 26.7, "calibrated_sigma": 0.24322631835937514} {"city": "shenzhen", "timestamp": "2026-03-25T08:57:11.783182+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": 26.7, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 26.7, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [{"v": 27, "p": 1.0}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 26.7, "calibrated_sigma": 0.24322631835937514}
{"city": "shenzhen", "timestamp": "2026-03-25T09:32:32+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.16637912326388898, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
@@ -256,6 +256,24 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
pointRadius: 5, pointRadius: 5,
}); });
if (
todayChartData.datasets.airportMetarPoints?.some((value) => value != null)
) {
datasets.push({
backgroundColor: "#60a5fa",
borderColor: "#60a5fa",
borderWidth: 1,
data: todayChartData.datasets.airportMetarPoints,
fill: false,
label:
locale === "en-US" ? "Airport METAR" : "机场 METAR",
order: 0,
pointHoverRadius: 6,
pointRadius: 4,
showLine: false,
});
}
if (todayChartData.datasets.mgmPoints.some((value) => value != null)) { if (todayChartData.datasets.mgmPoints.some((value) => value != null)) {
datasets.push({ datasets.push({
backgroundColor: "#facc15", backgroundColor: "#facc15",
@@ -759,6 +777,22 @@ export function FutureForecastModal() {
: risk.airport : risk.airport
? `${risk.airport}${risk.icao ? ` (${risk.icao})` : ""}` ? `${risk.airport}${risk.icao ? ` (${risk.icao})` : ""}`
: "--"; : "--";
const airportCurrentText =
detail.airport_current?.temp != null
? `${detail.airport_current.temp}${detail.temp_symbol}${
detail.airport_current?.obs_time
? ` @${detail.airport_current.obs_time}`
: ""
}`
: "--";
const airportMaxText =
detail.airport_current?.max_so_far != null
? `${detail.airport_current.max_so_far}${detail.temp_symbol}${
detail.airport_current?.max_temp_time
? ` @${detail.airport_current.max_temp_time}`
: ""
}`
: "--";
const displayedUpperAirSummary = const displayedUpperAirSummary =
marketAwareUpperAirCue?.summary || view.front.upperAirSummary; marketAwareUpperAirCue?.summary || view.front.upperAirSummary;
const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map( const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map(
@@ -1061,6 +1095,26 @@ export function FutureForecastModal() {
</span> </span>
<span>{settlementProfileValue}</span> <span>{settlementProfileValue}</span>
</div> </div>
{settlementSourceCode === "wunderground" ? (
<div className="risk-row">
<span className="risk-label">
{locale === "en-US"
? "Airport METAR"
: "机场 METAR"}
</span>
<span>{airportCurrentText}</span>
</div>
) : null}
{settlementSourceCode === "wunderground" ? (
<div className="risk-row">
<span className="risk-label">
{locale === "en-US"
? "Airport high"
: "机场目前最高温"}
</span>
<span>{airportMaxText}</span>
</div>
) : null}
<div className="risk-row"> <div className="risk-row">
<span className="risk-label"> <span className="risk-label">
{t("section.distance")} {t("section.distance")}
+20
View File
@@ -66,6 +66,25 @@ export interface CurrentConditions {
dewpoint?: number | null; dewpoint?: number | null;
} }
export interface AirportCurrentConditions {
temp: number | null;
obs_time: string | null;
max_so_far?: number | null;
max_temp_time?: string | null;
obs_age_min?: number | null;
report_time?: string | null;
receipt_time?: string | null;
obs_time_epoch?: number | null;
wind_speed_kt?: number | null;
wind_dir?: number | null;
humidity?: number | null;
cloud_desc?: string | null;
visibility_mi?: number | null;
wx_desc?: string | null;
raw_metar?: string | null;
source_label?: string | null;
}
export interface NearbyStation { export interface NearbyStation {
name?: string; name?: string;
icao?: string; icao?: string;
@@ -271,6 +290,7 @@ export interface CityDetail {
local_date: string; local_date: string;
risk: DashboardRisk; risk: DashboardRisk;
current: CurrentConditions; current: CurrentConditions;
airport_current?: AirportCurrentConditions;
mgm?: MgmData; mgm?: MgmData;
mgm_nearby?: NearbyStation[]; mgm_nearby?: NearbyStation[];
nearby_source?: string; nearby_source?: string;
+32
View File
@@ -284,6 +284,10 @@ export function getTemperatureChartData(
? metarObservationSource ? metarObservationSource
: officialObservationSource : officialObservationSource
: metarObservationSource; : metarObservationSource;
const airportMetarSource =
settlementSource && observationCode === "wunderground"
? metarObservationSource
: [];
const metarFallbackTag = (() => { const metarFallbackTag = (() => {
const icao = String(detail.risk?.icao || "").trim().toUpperCase(); const icao = String(detail.risk?.icao || "").trim().toUpperCase();
if (!icao) return "METAR"; if (!icao) return "METAR";
@@ -314,6 +318,20 @@ export function getTemperatureChartData(
existing == null ? temp : Math.max(Number(existing), Number(temp)); existing == null ? temp : Math.max(Number(existing), Number(temp));
} }
}); });
const airportMetarPoints = new Array(times.length).fill(null);
airportMetarSource.forEach((item) => {
const parts = String(item.time || "").split(":");
const hour = Number.parseInt(parts[0], 10);
if (Number.isNaN(hour)) return;
const key = `${String(hour).padStart(2, "0")}:00`;
const index = times.indexOf(key);
const temp = item.temp ?? null;
if (index >= 0 && temp != null) {
const existing = airportMetarPoints[index];
airportMetarPoints[index] =
existing == null ? temp : Math.max(Number(existing), Number(temp));
}
});
const mgmPoints = new Array(times.length).fill(null); const mgmPoints = new Array(times.length).fill(null);
if (detail.mgm?.temp != null && detail.mgm?.time) { if (detail.mgm?.temp != null && detail.mgm?.time) {
@@ -344,6 +362,7 @@ export function getTemperatureChartData(
const allValues = [ const allValues = [
...debTemps.filter((value) => value != null), ...debTemps.filter((value) => value != null),
...metarPoints.filter((value) => value != null), ...metarPoints.filter((value) => value != null),
...airportMetarPoints.filter((value) => value != null),
...mgmPoints.filter((value) => value != null), ...mgmPoints.filter((value) => value != null),
...mgmHourlyPoints.filter((value) => value != null), ...mgmHourlyPoints.filter((value) => value != null),
] as number[]; ] as number[];
@@ -414,6 +433,18 @@ export function getTemperatureChartData(
.join(" -> "); .join(" -> ");
legendParts.push(`${observationDisplayTag}: ${recentText}`); legendParts.push(`${observationDisplayTag}: ${recentText}`);
} }
if (airportMetarSource.length > 0) {
const airportRecentText = [...airportMetarSource]
.slice(-4)
.reverse()
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
.join(" -> ");
legendParts.push(
isEnglish(locale)
? `${metarFallbackTag}: ${airportRecentText}`
: `${metarFallbackTag}: ${airportRecentText}`,
);
}
if (shouldUseMetarFallback) { if (shouldUseMetarFallback) {
legendParts.push( legendParts.push(
isEnglish(locale) isEnglish(locale)
@@ -453,6 +484,7 @@ export function getTemperatureChartData(
return { return {
datasets: { datasets: {
airportMetarPoints,
debFuture, debFuture,
debPast, debPast,
hasMgmHourly, hasMgmHourly,
+186 -22
View File
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json
import re import re
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from loguru import logger from loguru import logger
@@ -43,6 +44,65 @@ class WundergroundSourceMixin:
logger.warning(f"Wunderground page fetch failed url={url}: {exc}") logger.warning(f"Wunderground page fetch failed url={url}: {exc}")
return None return None
@staticmethod
def _wu_extract_app_state(html: str) -> Optional[Dict[str, Any]]:
match = re.search(
r'<script id="app-root-state" type="application/json">(.*?)</script>',
html,
re.IGNORECASE | re.DOTALL,
)
if not match:
return None
try:
payload = json.loads(str(match.group(1) or ""))
return payload if isinstance(payload, dict) else None
except Exception:
return None
@staticmethod
def _wu_extract_station_history_url(html: str) -> Optional[str]:
match = re.search(
r'<div[^>]*class="station-name"[^>]*>\s*<a[^>]+href="([^"]*?/history/daily/[^"]+)"',
html,
re.IGNORECASE | re.DOTALL,
)
if not match:
return None
href = str(match.group(1) or "").strip()
if not href:
return None
if href.startswith("http://") or href.startswith("https://"):
return href
return f"https://www.wunderground.com{href}"
@staticmethod
def _wu_extract_station_id_from_history_url(url: Optional[str]) -> Optional[str]:
text = str(url or "").strip()
if not text:
return None
match = re.search(r"/history/daily/(?:[^/]+/){1,3}([^/]+)/date/", text, re.IGNORECASE)
return str(match.group(1) or "").strip() or None if match else None
@staticmethod
def _wu_extract_station_name_from_history_url(url: Optional[str]) -> Optional[str]:
text = str(url or "").strip()
if not text:
return None
match = re.search(r"/history/daily/(?:[^/]+/){1,3}([^/]+)/date/", text, re.IGNORECASE)
return str(match.group(1) or "").strip() or None if match else None
@staticmethod
def _safe_float(value: Any) -> Optional[float]:
if value is None:
return None
text = str(value).strip()
if not text:
return None
try:
return float(text)
except Exception:
return None
@staticmethod @staticmethod
def _wu_extract_station_name(html: str, fallback_icao: str) -> Optional[str]: def _wu_extract_station_name(html: str, fallback_icao: str) -> Optional[str]:
pattern = re.compile( pattern = re.compile(
@@ -94,6 +154,44 @@ class WundergroundSourceMixin:
return round((float(value) - 32.0) * 5.0 / 9.0, 1) return round((float(value) - 32.0) * 5.0 / 9.0, 1)
return round(float(value), 1) return round(float(value), 1)
@staticmethod
def _wu_parse_observation_iso(raw: Optional[str], utc_offset_seconds: int) -> Optional[str]:
text = str(raw or "").strip()
if not text:
return None
try:
parsed = datetime.strptime(text, "%Y-%m-%dT%H:%M:%S%z")
return parsed.astimezone(timezone.utc).isoformat()
except Exception:
pass
try:
parsed = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
parsed = parsed.replace(tzinfo=timezone(timedelta(seconds=utc_offset_seconds)))
return parsed.astimezone(timezone.utc).isoformat()
except Exception:
return None
@staticmethod
def _wu_find_current_observation_block(
app_state: Dict[str, Any],
*,
icao: Optional[str],
) -> Optional[Dict[str, Any]]:
normalized_icao = str(icao or "").strip().upper()
fallback_block: Optional[Dict[str, Any]] = None
for value in app_state.values():
if not isinstance(value, dict):
continue
url = str(value.get("u") or "")
body = value.get("b")
if not isinstance(body, dict) or "observations/current" not in url:
continue
if normalized_icao and f"icaoCode={normalized_icao}" in url:
return body
if fallback_block is None:
fallback_block = body
return fallback_block
def fetch_wunderground_settlement_current( def fetch_wunderground_settlement_current(
self, self,
city: str, city: str,
@@ -112,44 +210,108 @@ class WundergroundSourceMixin:
if not html: if not html:
return None return None
fallback_icao = str(icao or "").strip() city_meta = CITY_REGISTRY.get(normalized_city) or {}
station_name = station_label or self._wu_extract_station_name(html, fallback_icao) utc_offset_seconds = int(city_meta.get("tz_offset") or 0)
display_temp, display_unit = self._wu_extract_station_temperature( fallback_icao = str(icao or "").strip().upper()
html, history_url = self._wu_extract_station_history_url(html)
station_name=station_name, station_id = self._wu_extract_station_id_from_history_url(history_url)
station_name = (
station_label
or self._wu_extract_station_name(html, fallback_icao)
or self._wu_extract_station_name_from_history_url(history_url)
or fallback_icao
or str(city or "").title()
) )
temp_c = self._wu_to_celsius(display_temp, display_unit)
app_state = self._wu_extract_app_state(html) or {}
current_block = self._wu_find_current_observation_block(app_state, icao=fallback_icao)
display_temp = None
display_unit = "F"
obs_iso = None
humidity = None
wind_speed_kt = None
wind_dir = None
wx_phrase = None
dewpoint_c = None
official_high_c = None
official_low_c = None
if current_block:
display_temp = self._safe_float(current_block.get("temperature"))
obs_iso = self._wu_parse_observation_iso(
current_block.get("validTimeLocal"),
utc_offset_seconds,
)
humidity = self._safe_float(current_block.get("relativeHumidity"))
wind_speed_kt = self._safe_float(current_block.get("windSpeed"))
wind_dir = str(current_block.get("windDirectionCardinal") or "").strip() or None
wx_phrase = str(current_block.get("wxPhraseLong") or "").strip() or None
dewpoint_c = self._wu_to_celsius(
self._safe_float(current_block.get("temperatureDewPoint")),
"F",
)
official_high_c = self._wu_to_celsius(
self._safe_float(
current_block.get("temperatureMaxSince7Am")
or current_block.get("temperatureMax24Hour")
),
"F",
)
official_low_c = self._wu_to_celsius(
self._safe_float(current_block.get("temperatureMin24Hour")),
"F",
)
if display_temp is None:
display_temp, display_unit = self._wu_extract_station_temperature(
html,
station_name=station_name,
)
temp_c = self._wu_to_celsius(self._safe_float(display_temp), display_unit)
if temp_c is None: if temp_c is None:
logger.warning(f"Wunderground temperature parse failed city={city} url={url}") logger.warning(f"Wunderground temperature parse failed city={city} url={url}")
return None return None
city_meta = CITY_REGISTRY.get(normalized_city) or {} obs_iso = obs_iso or datetime.now(timezone.utc).isoformat()
utc_offset_seconds = int(city_meta.get("tz_offset") or 0)
obs_iso = datetime.now(timezone.utc).isoformat()
today_obs = self._update_official_today_obs( today_obs = self._update_official_today_obs(
source_code="wunderground", source_code="wunderground",
station_code=fallback_icao or normalized_city, station_code=station_id or fallback_icao or normalized_city,
obs_iso=obs_iso, obs_iso=obs_iso,
current_temp=temp_c, current_temp=temp_c,
utc_offset_seconds=utc_offset_seconds, utc_offset_seconds=utc_offset_seconds,
) )
max_so_far = None
max_temp_time = None sampled_max = None
today_low = None sampled_max_time = None
sampled_low = None
if today_obs: if today_obs:
hottest = max(today_obs, key=lambda item: float(item.get("temp") or -999)) hottest = max(today_obs, key=lambda item: float(item.get("temp") or -999))
coldest = min(today_obs, key=lambda item: float(item.get("temp") or 999)) coldest = min(today_obs, key=lambda item: float(item.get("temp") or 999))
max_so_far = self._wu_to_celsius(float(hottest.get("temp")), "C") sampled_max = self._wu_to_celsius(float(hottest.get("temp")), "C")
today_low = self._wu_to_celsius(float(coldest.get("temp")), "C") sampled_max_time = str(hottest.get("time") or "").strip() or None
max_temp_time = str(hottest.get("time") or "").strip() or None sampled_low = self._wu_to_celsius(float(coldest.get("temp")), "C")
max_so_far = official_high_c if official_high_c is not None else sampled_max
today_low = official_low_c if official_low_c is not None else sampled_low
max_temp_time = sampled_max_time
if max_so_far is not None and abs(float(max_so_far) - float(temp_c)) < 0.05:
try:
local_obs = datetime.fromisoformat(obs_iso.replace("Z", "+00:00")).astimezone(
timezone(timedelta(seconds=utc_offset_seconds))
)
max_temp_time = local_obs.strftime("%H:%M")
except Exception:
pass
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"source": "wunderground", "source": "wunderground",
"source_label": "Wunderground", "source_label": "Wunderground",
"station_code": fallback_icao or None, "station_code": station_id or fallback_icao or None,
"station_name": station_name or fallback_icao or str(city or "").title(), "station_name": station_name,
"observation_time": obs_iso, "observation_time": obs_iso,
"source_url": url, "source_url": url,
"history_url": history_url,
"current": { "current": {
"temp": temp_c, "temp": temp_c,
"display_temp": display_temp, "display_temp": display_temp,
@@ -157,9 +319,11 @@ class WundergroundSourceMixin:
"max_temp_so_far": max_so_far, "max_temp_so_far": max_so_far,
"max_temp_time": max_temp_time, "max_temp_time": max_temp_time,
"today_low": today_low, "today_low": today_low,
"humidity": None, "humidity": humidity,
"wind_speed_kt": None, "wind_speed_kt": wind_speed_kt,
"wind_dir": None, "wind_dir": wind_dir,
"cloud_desc": wx_phrase,
"dewpoint": dewpoint_c,
}, },
"today_obs": today_obs, "today_obs": today_obs,
"unit": "celsius", "unit": "celsius",
+38 -10
View File
@@ -760,6 +760,15 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
for t, v in (metar.get("today_obs", []) if metar else []) for t, v in (metar.get("today_obs", []) if metar else [])
] ]
metar_recent_obs_payload = metar.get("recent_obs", []) if metar else [] metar_recent_obs_payload = metar.get("recent_obs", []) if metar else []
airport_max_so_far = None
airport_max_temp_time = None
for point in metar_today_obs_payload:
value = _sf(point.get("temp")) if isinstance(point, dict) else None
if value is None:
continue
if airport_max_so_far is None or value >= airport_max_so_far:
airport_max_so_far = value
airport_max_temp_time = str(point.get("time") or "") or None
# ── 3. Local time parsing ── # ── 3. Local time parsing ──
local_time_full = om.get("current", {}).get("local_time", "") local_time_full = om.get("current", {}).get("local_time", "")
@@ -1272,10 +1281,10 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"settlement_source": settlement_source, "settlement_source": settlement_source,
"settlement_source_label": settlement_source_label, "settlement_source_label": settlement_source_label,
"obs_time": obs_time_str, "obs_time": obs_time_str,
"obs_age_min": metar_age_min, "obs_age_min": None if use_settlement_current else metar_age_min,
"report_time": metar.get("report_time") if metar else None, "report_time": primary_current.get("report_time"),
"receipt_time": metar.get("receipt_time") if metar else None, "receipt_time": primary_current.get("receipt_time"),
"obs_time_epoch": metar.get("obs_time_epoch") if metar else None, "obs_time_epoch": primary_current.get("obs_time_epoch"),
"wind_speed_kt": _sf(primary_current.get("wind_speed_kt")), "wind_speed_kt": _sf(primary_current.get("wind_speed_kt")),
"wind_dir": _sf(primary_current.get("wind_dir")), "wind_dir": _sf(primary_current.get("wind_dir")),
"humidity": _sf(primary_current.get("humidity")), "humidity": _sf(primary_current.get("humidity")),
@@ -1287,6 +1296,24 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"wx_desc": primary_current.get("wx_desc"), "wx_desc": primary_current.get("wx_desc"),
"raw_metar": primary_current.get("raw_metar"), "raw_metar": primary_current.get("raw_metar"),
}, },
"airport_current": {
"temp": _sf(mc.get("temp")),
"obs_time": metar.get("obs_time"),
"max_so_far": airport_max_so_far,
"max_temp_time": airport_max_temp_time,
"obs_age_min": metar_age_min,
"report_time": metar.get("report_time") if metar else None,
"receipt_time": metar.get("receipt_time") if metar else None,
"obs_time_epoch": metar.get("obs_time_epoch") if metar else None,
"wind_speed_kt": _sf(mc.get("wind_speed_kt")),
"wind_dir": _sf(mc.get("wind_dir")),
"humidity": _sf(mc.get("humidity")),
"cloud_desc": metar.get("cloud_desc") if metar else None,
"visibility_mi": _sf(mc.get("visibility_mi")),
"wx_desc": mc.get("wx_desc"),
"raw_metar": mc.get("raw_metar"),
"source_label": "METAR",
},
"mgm": mgm_data, "mgm": mgm_data,
"mgm_nearby": raw.get("mgm_nearby", []), "mgm_nearby": raw.get("mgm_nearby", []),
"nearby_source": raw.get("nearby_source") or ("mgm" if city.lower() == "ankara" else "metar_cluster"), "nearby_source": raw.get("nearby_source") or ("mgm" if city.lower() == "ankara" else "metar_cluster"),
@@ -1489,12 +1516,12 @@ def _build_city_detail_payload(
"official": { "official": {
"available": bool(data.get("current", {}).get("temp") is not None), "available": bool(data.get("current", {}).get("temp") is not None),
"metar": { "metar": {
"observation_time": data.get("current", {}).get("obs_time"), "observation_time": data.get("airport_current", {}).get("obs_time"),
"obs_age_min": data.get("current", {}).get("obs_age_min"), "obs_age_min": data.get("airport_current", {}).get("obs_age_min"),
"report_time": data.get("current", {}).get("report_time"), "report_time": data.get("airport_current", {}).get("report_time"),
"receipt_time": data.get("current", {}).get("receipt_time"), "receipt_time": data.get("airport_current", {}).get("receipt_time"),
"raw_metar": data.get("current", {}).get("raw_metar"), "raw_metar": data.get("airport_current", {}).get("raw_metar"),
"current": data.get("current"), "current": data.get("airport_current") or {},
}, },
"taf": data.get("taf") or {}, "taf": data.get("taf") or {},
"weather_gov": {}, "weather_gov": {},
@@ -1521,6 +1548,7 @@ def _build_city_detail_payload(
"taf": data.get("taf") or {}, "taf": data.get("taf") or {},
"market_scan": market_scan, "market_scan": market_scan,
"risk": data.get("risk"), "risk": data.get("risk"),
"airport_current": data.get("airport_current") or {},
"nearby_source": data.get("nearby_source") or ("mgm" if data.get("name") == "ankara" else "metar_cluster"), "nearby_source": data.get("nearby_source") or ("mgm" if data.get("name") == "ankara" else "metar_cluster"),
"ai_analysis": data.get("ai_analysis") or "", "ai_analysis": data.get("ai_analysis") or "",
"errors": {}, "errors": {},