From 7237278f5a3874e0bf8bb11cb1e455a874bb86d6 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Fri, 27 Mar 2026 20:58:38 +0800
Subject: [PATCH] Generalize NOAA settlement handling across dashboard and docs
---
.../dashboard/FutureForecastModal.tsx | 28 ++--
.../components/dashboard/HistoryModal.tsx | 40 +++--
frontend/content/docs/docs.ts | 8 +-
frontend/hooks/useLeafletMap.ts | 1 +
frontend/lib/dashboard-official-sources.ts | 34 +++++
frontend/lib/dashboard-types.ts | 2 +
frontend/lib/dashboard-utils.ts | 39 ++++-
frontend/lib/i18n.ts | 4 +-
src/analysis/deb_algorithm.py | 141 +++++++++++++++++-
src/data_collection/city_registry.py | 43 +++++-
src/data_collection/settlement_sources.py | 65 ++++++--
src/data_collection/weather_sources.py | 18 ++-
web/analysis_service.py | 2 +
13 files changed, 374 insertions(+), 51 deletions(-)
diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx
index 46d9d1d5..5fbd2e8f 100644
--- a/frontend/components/dashboard/FutureForecastModal.tsx
+++ b/frontend/components/dashboard/FutureForecastModal.tsx
@@ -549,10 +549,18 @@ export function FutureForecastModal() {
"--score-position": scorePosition,
} as CSSProperties & { "--score-position": string };
const weatherSummary = getWeatherSummary(detail, locale);
- const isTaipeiNoaa =
- store.selectedCity === "taipei" &&
- (detail.current?.settlement_source === "noaa" ||
- detail.current?.settlement_source_label === "NOAA");
+ const isNoaaSettlement =
+ detail.current?.settlement_source === "noaa" ||
+ detail.current?.settlement_source_label === "NOAA";
+ const noaaStationCode = String(
+ detail.current?.station_code || detail.risk?.icao || "NOAA",
+ )
+ .trim()
+ .toUpperCase();
+ const noaaStationName =
+ String(detail.current?.station_name || "").trim() ||
+ String(detail.risk?.airport || "").trim() ||
+ noaaStationCode;
const marketMidpoint = formatMarketPercent(
marketScan?.market_price ?? marketScan?.yes_token?.implied_probability,
);
@@ -810,9 +818,9 @@ export function FutureForecastModal() {
: "香港天文台 (HKO)"
: settlementSourceCode === "noaa"
? locale === "en-US"
- ? "NOAA RCTP (Taiwan Taoyuan International Airport)"
- : "NOAA RCTP(台湾桃园国际机场)"
- : settlementSourceCode === "cwa"
+ ? `NOAA ${noaaStationCode} (${noaaStationName})`
+ : `NOAA ${noaaStationCode}(${noaaStationName})`
+ : settlementSourceCode === "cwa"
? locale === "en-US"
? "Central Weather Administration (CWA)"
: "交通部中央气象署 (CWA)"
@@ -948,7 +956,7 @@ export function FutureForecastModal() {
- {isTaipei && (
+ {isNoaaSettlement && (
{t("lang") === "en-US"
- ? "Taipei historical actuals are aligned to NOAA RCTP settlement rules: use the highest rounded whole-degree Celsius reading after the date is finalized."
- : "台北历史对账已按 NOAA RCTP 结算口径对齐:采用该日最终完成质控后的最高整度摄氏值。"}
+ ? `${store.selectedDetail?.display_name || store.selectedCity || "This city"} historical actuals are aligned to NOAA ${noaaStationCode} (${noaaStationName}) settlement rules: use the highest rounded whole-degree Celsius reading after the date is finalized.`
+ : `${store.selectedDetail?.display_name || store.selectedCity || "该城市"}历史对账已按 NOAA ${noaaStationCode}(${noaaStationName})结算口径对齐:采用该日最终完成质控后的最高整度摄氏值。`}
)}
diff --git a/frontend/content/docs/docs.ts b/frontend/content/docs/docs.ts
index d3928d15..5e51cbdc 100644
--- a/frontend/content/docs/docs.ts
+++ b/frontend/content/docs/docs.ts
@@ -188,7 +188,7 @@ export const DOCS_PAGES: DocsPage[] = [
title: "什么叫机场端压温风险偏高",
blocks: [
{ type: "paragraph", text: "它的意思不是整座城市一定更冷,而是作为结算依据的机场站点,在峰值窗口里更可能因为云、阵雨或雷暴扰动,冲不到本来可能达到的更高温度。" },
- { type: "callout", tone: "warning", title: "重点区别", text: "TAF 负责告诉你机场侧未来几个小时会不会出现压温扰动,不直接等于结算温度本身。结算仍然看 METAR、HKO、MGM、NOAA RCTP、Wunderground 指定站点等实际结算源。" },
+ { type: "callout", tone: "warning", title: "重点区别", text: "TAF 负责告诉你机场侧未来几个小时会不会出现压温扰动,不直接等于结算温度本身。结算仍然看 METAR、HKO、MGM、NOAA 指定站点、Wunderground 指定站点等实际结算源。" },
],
},
],
@@ -216,7 +216,7 @@ export const DOCS_PAGES: DocsPage[] = [
title: "What airport-side suppression risk means",
blocks: [
{ type: "paragraph", text: "It does not mean the entire city must run cooler. It means the airport station used for settlement is more likely to get capped by clouds, showers, or thunderstorm disruption during the peak window and fail to reach the next warmer bucket." },
- { type: "callout", tone: "warning", title: "Important distinction", text: "TAF explains whether the airport side may face suppressive weather over the next few hours. Settlement still comes from the actual settlement source such as METAR, HKO, MGM, NOAA RCTP, or a designated Wunderground station." },
+ { type: "callout", tone: "warning", title: "Important distinction", text: "TAF explains whether the airport side may face suppressive weather over the next few hours. Settlement still comes from the actual settlement source such as METAR, HKO, MGM, a designated NOAA station, or a designated Wunderground station." },
],
},
],
@@ -242,7 +242,7 @@ export const DOCS_PAGES: DocsPage[] = [
id: "city-rules",
title: "当前主要口径",
blocks: [
- { type: "bullets", items: ["多数欧美机场市场:按机场 METAR 或机场主站实况结算。", "香港:按香港天文台 HKO 主口径,不接机场 TAF 作为主结算逻辑。", "台北:按 NOAA RCTP(台湾桃园国际机场)最终完成质控后的最高整度摄氏值结算,机场观测和市区体感不可混用。", "Shenzhen:当前按 Wunderground 指定站点(Shenzhen Bao'an Intl Airport Station)镜像结算页主值。", "Ankara:结算主站以 LTAC / Esenboğa 为准,同时保留 Turkish MGM 作为领先结构参考。"] },
+ { type: "bullets", items: ["多数欧美机场市场:按机场 METAR 或机场主站实况结算。", "香港:按香港天文台 HKO 主口径,不接机场 TAF 作为主结算逻辑。", "台北、伊斯坦布尔等 NOAA 市场:按 weather.gov / NOAA 指定站点最终完成质控后的最高整度摄氏值结算,机场观测和市区体感不可混用。", "Shenzhen:当前按 Wunderground 指定站点(Shenzhen Bao'an Intl Airport Station)镜像结算页主值。", "Ankara:结算主站以 LTAC / Esenboğa 为准,同时保留 Turkish MGM 作为领先结构参考。"] },
],
},
{
@@ -269,7 +269,7 @@ export const DOCS_PAGES: DocsPage[] = [
id: "city-rules",
title: "Current primary rules",
blocks: [
- { type: "bullets", items: ["Most airport-linked Western markets: settle on airport METAR or the airport primary observing site.", "Hong Kong: settles on HKO, not on airport TAF as the main settlement logic.", "Taipei: settles against NOAA RCTP using the finalized highest rounded whole-degree Celsius reading; airport observations and downtown feel should not be mixed.", "Shenzhen: currently mirrors the designated Wunderground station value for Shenzhen Bao'an Intl Airport Station.", "Ankara: settlement centers on LTAC / Esenboğa, with Turkish MGM retained as a leading-structure reference."] },
+ { type: "bullets", items: ["Most airport-linked Western markets: settle on airport METAR or the airport primary observing site.", "Hong Kong: settles on HKO, not on airport TAF as the main settlement logic.", "NOAA markets such as Taipei and Istanbul settle against the designated weather.gov / NOAA station using the finalized highest rounded whole-degree Celsius reading; airport observations and downtown feel should not be mixed.", "Shenzhen: currently mirrors the designated Wunderground station value for Shenzhen Bao'an Intl Airport Station.", "Ankara: settlement centers on LTAC / Esenboğa, with Turkish MGM retained as a leading-structure reference."] },
],
},
{
diff --git a/frontend/hooks/useLeafletMap.ts b/frontend/hooks/useLeafletMap.ts
index e1deb770..6ee22bae 100644
--- a/frontend/hooks/useLeafletMap.ts
+++ b/frontend/hooks/useLeafletMap.ts
@@ -37,6 +37,7 @@ const CITY_MARKER_DISPLAY_OFFSETS: Record<
// Shek Kong sits between the Hong Kong and Shenzhen cards and gets visually buried
// by their wide marker bubbles. Shift only the rendered marker, not the true point.
"shek kong": { x: 34, y: -26, zIndexOffset: 320 },
+ "lau fau shan": { x: -40, y: 14, zIndexOffset: 300 },
};
function getMarkerDisplayOffset(cityName: string) {
diff --git a/frontend/lib/dashboard-official-sources.ts b/frontend/lib/dashboard-official-sources.ts
index 89cf01dd..87d063fc 100644
--- a/frontend/lib/dashboard-official-sources.ts
+++ b/frontend/lib/dashboard-official-sources.ts
@@ -75,6 +75,23 @@ const CITY_SPECIFIC_SOURCES: Record = {
kind: "metar",
},
],
+ "lau fau shan": [
+ {
+ label: "香港天文台",
+ href: "https://www.hko.gov.hk/en/index.html",
+ kind: "agency",
+ },
+ {
+ label: "HKO 区域天气数据",
+ href: "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather/latest_1min_temperature.csv",
+ kind: "agency",
+ },
+ {
+ label: "HKO 实时读数页",
+ href: "https://www.hko.gov.hk/textonly/v2/forecast/text_readings_e.htm",
+ kind: "agency",
+ },
+ ],
taipei: [
{
label: "NOAA RCTP Timeseries",
@@ -92,6 +109,23 @@ const CITY_SPECIFIC_SOURCES: Record = {
kind: "metar",
},
],
+ istanbul: [
+ {
+ label: "NOAA LTFM Timeseries",
+ href: "https://www.weather.gov/wrh/timeseries?site=LTFM",
+ kind: "agency",
+ },
+ {
+ label: "Istanbul Airport",
+ href: "https://www.istairport.com/en",
+ kind: "airport",
+ },
+ {
+ label: "LTFM METAR",
+ href: "https://aviationweather.gov/data/metar/?id=LTFM&decoded=1&taf=1",
+ kind: "metar",
+ },
+ ],
london: [
{
label: "Met Office",
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index ea63017a..34201758 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -50,6 +50,8 @@ export interface CurrentConditions {
wu_settlement: number | null;
settlement_source?: string | null;
settlement_source_label?: string | null;
+ station_code?: string | null;
+ station_name?: string | null;
obs_time: string | null;
obs_age_min: number | null;
wind_speed_kt: number | null;
diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts
index 36bb7c23..4d3cffa0 100644
--- a/frontend/lib/dashboard-utils.ts
+++ b/frontend/lib/dashboard-utils.ts
@@ -53,7 +53,13 @@ function getObservationSourceCode(detail: CityDetail): string {
const city = String(detail.name || detail.display_name || "")
.trim()
.toLowerCase();
- if (city === "hong kong") return "hko";
+ if (
+ city === "hong kong" ||
+ city === "shek kong" ||
+ city === "lau fau shan"
+ ) {
+ return "hko";
+ }
if (city === "taipei") return "noaa";
return "metar";
}
@@ -72,6 +78,20 @@ function getObservationSourceTag(detail: CityDetail): string {
return "METAR";
}
+function getNoaaStationCode(detail: CityDetail): string {
+ return String(detail.current?.station_code || detail.risk?.icao || "NOAA")
+ .trim()
+ .toUpperCase();
+}
+
+function getNoaaStationName(detail: CityDetail): string {
+ return (
+ String(detail.current?.station_name || "").trim() ||
+ String(detail.risk?.airport || "").trim() ||
+ getNoaaStationCode(detail)
+ );
+}
+
function normalizeCloudSummary(
cloudDesc: string | null | undefined,
locale: Locale,
@@ -297,7 +317,7 @@ export function getTemperatureChartData(
settlementSource && shouldUseMetarFallback
? metarFallbackTag
: observationCode === "noaa"
- ? "NOAA RCTP"
+ ? `NOAA ${getNoaaStationCode(detail)}`
: observationCode === "wunderground"
? "Wunderground"
: observationTag;
@@ -574,14 +594,15 @@ export function getTemperatureChartData(
} else if (observationCode === "hko") {
legendParts.push(
isEnglish(locale)
- ? "Hong Kong uses HKO official readings. The chart keeps official HKO points instead of switching to airport METAR."
- : "香港按 HKO 官方读数展示;图中保留 HKO 官方点位,不切换到机场 METAR 连续线。",
+ ? "This city uses HKO official readings. The chart keeps official HKO points instead of switching to airport METAR."
+ : "该城市按 HKO 官方读数展示;图中保留 HKO 官方点位,不切换到机场 METAR 连续线。",
);
} else if (observationCode === "noaa") {
+ const noaaCode = getNoaaStationCode(detail);
legendParts.push(
isEnglish(locale)
- ? "Taipei settles on NOAA RCTP using the finalized highest rounded whole-degree Celsius reading; the plotted line is a settlement reference."
- : "台北按 NOAA RCTP 最终完成质控后的最高整度摄氏值结算;图中曲线仅作为结算参考线。",
+ ? `This city settles on NOAA ${noaaCode} using the finalized highest rounded whole-degree Celsius Temp reading; the plotted line is a settlement reference.`
+ : `该城市按 NOAA ${noaaCode} 最终完成质控后的最高整度摄氏 Temp 读数结算;图中曲线仅作为结算参考线。`,
);
} else if (observationCode === "wunderground") {
legendParts.push(
@@ -2275,9 +2296,11 @@ export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN"
: "交通部中央气象署 (CWA)";
}
if (sourceCode === "noaa") {
+ const noaaCode = getNoaaStationCode(detail);
+ const noaaName = getNoaaStationName(detail);
return isEnglish(locale)
- ? "NOAA RCTP (Taiwan Taoyuan)"
- : "NOAA RCTP(台湾桃园国际机场)";
+ ? `NOAA ${noaaCode} (${noaaName})`
+ : `NOAA ${noaaCode}(${noaaName})`;
}
if (sourceCode === "wunderground") {
const stationName = String(
diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts
index b8d5f144..496e3b30 100644
--- a/frontend/lib/i18n.ts
+++ b/frontend/lib/i18n.ts
@@ -54,7 +54,7 @@ const MESSAGES: Record> = {
"guide.title": "📎 PolyWeather 系统技术说明",
"guide.closeAria": "关闭技术说明",
"guide.footer":
- "数据源以 METAR、香港天文台(HKO)、NOAA RCTP、Turkish MGM、Open-Meteo、weather.gov 为主。",
+ "数据源以 METAR、香港天文台(HKO)、NOAA 指定站点、Turkish MGM、Open-Meteo、weather.gov 为主。",
"history.title": "📊 历史准确率对账 - {city}",
"history.closeAria": "关闭历史对账",
@@ -218,7 +218,7 @@ const MESSAGES: Record> = {
"guide.title": "📎 PolyWeather Technical Overview",
"guide.closeAria": "Close technical overview",
"guide.footer":
- "Primary data sources are METAR, Hong Kong Observatory (HKO), NOAA RCTP, Turkish MGM, Open-Meteo, and weather.gov.",
+ "Primary data sources are METAR, Hong Kong Observatory (HKO), designated NOAA stations, Turkish MGM, Open-Meteo, and weather.gov.",
"history.title": "📊 Historical Reconciliation - {city}",
"history.closeAria": "Close history reconciliation",
diff --git a/src/analysis/deb_algorithm.py b/src/analysis/deb_algorithm.py
index ed21bf03..a67599c9 100644
--- a/src/analysis/deb_algorithm.py
+++ b/src/analysis/deb_algorithm.py
@@ -180,6 +180,22 @@ def _parse_hko_ryes_max_temp(payload):
return None
+def _parse_noaa_timeseries_stamp(raw_value):
+ try:
+ return datetime.strptime(str(raw_value), "%Y-%m-%dT%H:%M:%S%z")
+ except Exception:
+ return None
+
+
+def _noaa_round_temp(value):
+ if value is None:
+ return None
+ try:
+ return int(float(value) + 0.5)
+ except Exception:
+ return None
+
+
def _reconcile_recent_metar_actual_highs(city_name: str, lookback_days: int = 7):
"""
Reconcile recent `actual_high` values using historical METAR data from
@@ -378,6 +394,125 @@ def _reconcile_recent_hko_actual_highs(city_name: str, lookback_days: int = 14):
return {"ok": False, "reason": str(e), "updated": 0}
+def _reconcile_recent_noaa_actual_highs(city_name: str, lookback_days: int = 14):
+ """
+ Reconcile recent `actual_high` values using NOAA weather.gov timeseries data.
+ The settlement rule uses the highest rounded whole-degree Celsius Temp reading
+ once the date is finalized.
+ """
+ try:
+ city_key, city_meta = _resolve_city_history_context(city_name)
+ if not city_key or not isinstance(city_meta, dict):
+ return {"ok": False, "reason": "unknown_city", "updated": 0}
+
+ station_code = str(city_meta.get("settlement_station_code") or "").strip().upper()
+ if not station_code:
+ return {"ok": False, "reason": "missing_station_code", "updated": 0}
+
+ tz_offset = int(city_meta.get("tz_offset") or 0)
+ use_fahrenheit = bool(city_meta.get("use_fahrenheit"))
+ history_file = _get_history_file_path()
+ data = load_history(history_file)
+ city_data = data.get(city_key) or {}
+ if not isinstance(city_data, dict) or not city_data:
+ return {"ok": True, "reason": "no_city_history", "updated": 0}
+
+ local_now = datetime.utcnow() + timedelta(seconds=tz_offset)
+ local_today = local_now.strftime("%Y-%m-%d")
+ cutoff = (local_now - timedelta(days=max(lookback_days, 1) + 1)).strftime(
+ "%Y-%m-%d"
+ )
+ target_dates = sorted(
+ d for d in city_data.keys() if isinstance(d, str) and cutoff <= d < local_today
+ )
+ if not target_dates:
+ return {"ok": True, "reason": "no_target_dates", "updated": 0}
+
+ recent_minutes = max(4320, min(28800, (lookback_days + 3) * 1440))
+ response = requests.get(
+ "https://api.synopticdata.com/v2/stations/timeseries",
+ params={
+ "STID": station_code,
+ "showemptystations": 1,
+ "recent": recent_minutes,
+ "complete": 1,
+ "token": "7c76618b66c74aee913bdbae4b448bdd",
+ "obtimezone": "local",
+ },
+ headers={
+ "Referer": f"https://www.weather.gov/wrh/timeseries?site={station_code}",
+ "Origin": "https://www.weather.gov",
+ "User-Agent": "Mozilla/5.0",
+ },
+ timeout=15,
+ )
+ response.raise_for_status()
+ payload = response.json() if response.content else {}
+ stations = payload.get("STATION") or []
+ station = stations[0] if isinstance(stations, list) and stations else None
+ if not isinstance(station, dict):
+ return {"ok": True, "reason": "no_station_payload", "updated": 0}
+
+ obs = station.get("OBSERVATIONS") or {}
+ stamps = obs.get("date_time") or []
+ temps = obs.get("air_temp_set_1") or []
+ if not isinstance(stamps, list) or not isinstance(temps, list):
+ return {"ok": True, "reason": "missing_observations", "updated": 0}
+
+ daily_max = {}
+ scanned_rows = 0
+ for idx, stamp in enumerate(stamps):
+ rounded_temp = _noaa_round_temp(temps[idx] if idx < len(temps) else None)
+ if rounded_temp is None:
+ continue
+ dt = _parse_noaa_timeseries_stamp(stamp)
+ if dt is None:
+ continue
+ date_key = dt.date().strftime("%Y-%m-%d")
+ if date_key < cutoff or date_key >= local_today:
+ continue
+ scanned_rows += 1
+ prev = daily_max.get(date_key)
+ if prev is None or rounded_temp > prev:
+ daily_max[date_key] = rounded_temp
+
+ updated = 0
+ for date_key in target_dates:
+ corrected = daily_max.get(date_key)
+ if corrected is None:
+ continue
+ next_value = (
+ round((corrected - 32) * 5 / 9, 1)
+ if use_fahrenheit
+ else int(corrected)
+ )
+ rec = city_data.get(date_key) or {}
+ old = rec.get("actual_high")
+ try:
+ old_val = float(old) if old is not None else None
+ except Exception:
+ old_val = None
+ if old_val is None or abs(old_val - next_value) >= 0.1:
+ rec["actual_high"] = next_value
+ city_data[date_key] = rec
+ updated += 1
+
+ if updated > 0:
+ data[city_key] = city_data
+ save_history(history_file, data)
+
+ return {
+ "ok": True,
+ "updated": updated,
+ "scanned_dates": len(target_dates),
+ "rows": scanned_rows,
+ "station_code": station_code,
+ "source": "noaa",
+ }
+ except Exception as e:
+ return {"ok": False, "reason": str(e), "updated": 0}
+
+
def reconcile_recent_actual_highs(city_name: str, lookback_days: int = 7):
"""
Reconcile recent `actual_high` values using the city's official settlement source.
@@ -389,6 +524,8 @@ def reconcile_recent_actual_highs(city_name: str, lookback_days: int = 7):
settlement_source = str(city_meta.get("settlement_source") or "metar").strip().lower()
if settlement_source == "hko":
return _reconcile_recent_hko_actual_highs(city_key, lookback_days=lookback_days)
+ if settlement_source == "noaa":
+ return _reconcile_recent_noaa_actual_highs(city_key, lookback_days=lookback_days)
return _reconcile_recent_metar_actual_highs(city_key, lookback_days=lookback_days)
@@ -404,14 +541,14 @@ def bootstrap_recent_daily_history_if_missing(city_name: str, lookback_days: int
return {"ok": False, "reason": "unknown_city", "seeded": 0, "updated": 0}
settlement_source = str(city_meta.get("settlement_source") or "metar").strip().lower()
- if settlement_source not in {"metar", "hko"}:
+ if settlement_source not in {"metar", "hko", "noaa"}:
return {"ok": True, "reason": "unsupported_settlement_source", "seeded": 0, "updated": 0}
icao = str(city_meta.get("icao") or "").strip().upper()
station_code = str(city_meta.get("settlement_station_code") or "").strip().upper()
if settlement_source == "metar" and not icao:
return {"ok": False, "reason": "missing_icao", "seeded": 0, "updated": 0}
- if settlement_source == "hko" and not station_code:
+ if settlement_source in {"hko", "noaa"} and not station_code:
return {"ok": False, "reason": "missing_station_code", "seeded": 0, "updated": 0}
tz_offset = int(city_meta.get("tz_offset") or 0)
diff --git a/src/data_collection/city_registry.py b/src/data_collection/city_registry.py
index e69e7211..40a99a73 100644
--- a/src/data_collection/city_registry.py
+++ b/src/data_collection/city_registry.py
@@ -16,6 +16,23 @@ CITY_REGISTRY = {
"distance_km": 24.5,
"warning": "内陆高原城市,昼夜温差大(可达15°C+); 激进取整效应明显。",
},
+ "istanbul": {
+ "name": "Istanbul",
+ "lat": 41.2749,
+ "lon": 28.7323,
+ "icao": "LTFM",
+ "settlement_source": "noaa",
+ "settlement_station_code": "LTFM",
+ "settlement_station_label": "Istanbul Airport",
+ "tz_offset": 10800,
+ "use_fahrenheit": False,
+ "is_major": True,
+ "risk_level": "medium",
+ "risk_emoji": "🟡",
+ "airport_name": "Istanbul Airport",
+ "distance_km": 34.0,
+ "warning": "市场现按 NOAA LTFM 整度°C口径结算;以 weather.gov Temp 列当天最终完成质控后的最高整度摄氏值为准。",
+ },
"london": {
"name": "London",
"lat": 51.5048,
@@ -95,12 +112,33 @@ CITY_REGISTRY = {
"distance_km": 0.8,
"warning": "HKO 结算取石岗站分钟级观测,机场报文与 HKO 站点最高温不可直接混用。",
},
+ "lau fau shan": {
+ "name": "Lau Fau Shan",
+ "lat": 22.4674,
+ "lon": 113.9841,
+ "icao": "LFS",
+ "settlement_source": "hko",
+ "settlement_station_code": "LFS",
+ "settlement_station_label": "Lau Fau Shan",
+ "settlement_station_candidates": ["Lau Fau Shan"],
+ "disable_aviationweather": True,
+ "tz_offset": 28800,
+ "use_fahrenheit": False,
+ "is_major": False,
+ "risk_level": "medium",
+ "risk_emoji": "🟡",
+ "airport_name": "流浮山站",
+ "distance_km": 0.6,
+ "warning": "HKO 结算取流浮山站分钟级观测;该站不是机场 METAR,不能与 VHHH/VHSK 报文混用。",
+ },
"taipei": {
"name": "Taipei",
"lat": 25.0777,
"lon": 121.2330,
"icao": "RCTP",
"settlement_source": "noaa",
+ "settlement_station_code": "RCTP",
+ "settlement_station_label": "Taiwan Taoyuan International Airport",
"tz_offset": 28800,
"use_fahrenheit": False,
"is_major": True,
@@ -457,12 +495,13 @@ CITY_REGISTRY = {
ALIASES = {
# English shortcuts
- "ank": "ankara", "lon": "london", "par": "paris",
+ "ank": "ankara", "ist": "istanbul", "ltfm": "istanbul", "lon": "london", "par": "paris",
"nyc": "new york", "ny": "new york", "chi": "chicago",
"dal": "dallas", "mia": "miami", "atl": "atlanta",
"sea": "seattle", "tor": "toronto", "sel": "seoul",
"seo": "seoul", "hkg": "hong kong", "hk": "hong kong",
"vhsk": "shek kong", "shekkong": "shek kong",
+ "lfs": "lau fau shan", "laufaushan": "lau fau shan",
"tpe": "taipei", "tp": "taipei", "taipei": "taipei",
"sha": "shanghai", "sh": "shanghai", "sin": "singapore",
"sg": "singapore", "tok": "tokyo", "tyo": "tokyo",
@@ -474,6 +513,7 @@ ALIASES = {
# Chinese names
"安卡拉": "ankara",
+ "伊斯坦布尔": "istanbul",
"伦敦": "london",
"巴黎": "paris",
"纽约": "new york",
@@ -487,6 +527,7 @@ ALIASES = {
"香港": "hong kong",
"石岗": "shek kong",
"石崗": "shek kong",
+ "流浮山": "lau fau shan",
"台北": "taipei",
"臺北": "taipei",
"上海": "shanghai",
diff --git a/src/data_collection/settlement_sources.py b/src/data_collection/settlement_sources.py
index 63d4df4e..e9a6e52e 100644
--- a/src/data_collection/settlement_sources.py
+++ b/src/data_collection/settlement_sources.py
@@ -24,7 +24,7 @@ class SettlementSourceMixin:
IMGW_METEO_API_BASE = "https://meteo.imgw.pl/api/v1"
IMGW_METEO_API_TOKEN = "p4DXKjsYadfBV21TYrDk"
NOAA_WRH_MESO_TOKEN = "7c76618b66c74aee913bdbae4b448bdd"
- NOAA_WRH_TIMESERIES_REFERER = "https://www.weather.gov/wrh/timeseries?site=RCTP"
+ NOAA_WRH_TIMESERIES_REFERER_BASE = "https://www.weather.gov/wrh/timeseries?site="
def _get_settlement_cache(self, key: str) -> Optional[Dict[str, Any]]:
now_ts = time.time()
@@ -389,8 +389,14 @@ class SettlementSourceMixin:
logger.warning(f"CWA Forecast request failed: {exc}")
return None
- def fetch_noaa_rctp_settlement_current(self) -> Optional[Dict[str, Any]]:
- cache_key = "noaa:rctp"
+ def fetch_noaa_station_settlement_current(
+ self,
+ *,
+ station_code: str = "RCTP",
+ station_name: Optional[str] = None,
+ ) -> Optional[Dict[str, Any]]:
+ normalized_station_code = str(station_code or "RCTP").strip().upper() or "RCTP"
+ cache_key = f"noaa:{normalized_station_code.lower()}"
cached = self._get_settlement_cache(cache_key)
if cached:
return cached
@@ -399,7 +405,7 @@ class SettlementSourceMixin:
response = self.session.get(
"https://api.synopticdata.com/v2/stations/timeseries",
params={
- "STID": "RCTP",
+ "STID": normalized_station_code,
"showemptystations": 1,
"recent": 2880,
"complete": 1,
@@ -407,7 +413,7 @@ class SettlementSourceMixin:
"obtimezone": "local",
},
headers={
- "Referer": self.NOAA_WRH_TIMESERIES_REFERER,
+ "Referer": f"{self.NOAA_WRH_TIMESERIES_REFERER_BASE}{normalized_station_code}",
"Origin": "https://www.weather.gov",
"User-Agent": "Mozilla/5.0",
},
@@ -429,7 +435,6 @@ class SettlementSourceMixin:
if not isinstance(stamps, list) or not isinstance(temps, list) or not stamps or not temps:
return None
- target_date = datetime.now(timezone(timedelta(hours=8))).date()
today_rows: List[tuple[datetime, int]] = []
latest_dt: Optional[datetime] = None
latest_temp: Optional[int] = None
@@ -446,18 +451,28 @@ class SettlementSourceMixin:
dt = datetime.strptime(str(stamp), "%Y-%m-%dT%H:%M:%S%z")
except Exception:
continue
- if dt.date() == target_date:
- today_rows.append((dt, rounded_temp))
if latest_dt is None or dt >= latest_dt:
latest_dt = dt
latest_temp = rounded_temp
latest_humidity = self._safe_float(humidity_list[idx] if idx < len(humidity_list) else None)
latest_wind_speed_ms = self._safe_float(wind_speed_list[idx] if idx < len(wind_speed_list) else None)
latest_wind_dir = self._safe_float(wind_dir_list[idx] if idx < len(wind_dir_list) else None)
-
if latest_dt is None or latest_temp is None:
return None
+ target_date = latest_dt.date()
+ for idx, stamp in enumerate(stamps):
+ raw_temp = temps[idx] if idx < len(temps) else None
+ rounded_temp = self._js_round(raw_temp)
+ if rounded_temp is None:
+ continue
+ try:
+ dt = datetime.strptime(str(stamp), "%Y-%m-%dT%H:%M:%S%z")
+ except Exception:
+ continue
+ if dt.date() == target_date:
+ today_rows.append((dt, rounded_temp))
+
max_so_far = None
max_temp_time = None
today_low = None
@@ -468,12 +483,16 @@ class SettlementSourceMixin:
if temp == max_so_far:
max_temp_time = dt.strftime("%H:%M")
break
+ today_obs = [
+ {"time": dt.strftime("%H:%M"), "temp": temp}
+ for dt, temp in today_rows
+ ]
result = {
"source": "noaa",
"source_label": "NOAA",
- "station_code": "RCTP",
- "station_name": str(station.get("NAME") or "Taiwan Taoyuan International Airport"),
+ "station_code": normalized_station_code,
+ "station_name": str(station_name or station.get("NAME") or normalized_station_code),
"observation_time": latest_dt.isoformat(),
"current": {
"temp": latest_temp,
@@ -484,12 +503,15 @@ class SettlementSourceMixin:
"wind_speed_kt": round(float(latest_wind_speed_ms) * 1.943844, 1) if latest_wind_speed_ms is not None else None,
"wind_dir": latest_wind_dir,
},
+ "today_obs": today_obs,
"unit": "celsius",
}
self._set_settlement_cache(cache_key, result)
return result
except Exception as exc:
- logger.warning(f"NOAA RCTP settlement fetch failed: {exc}")
+ logger.warning(
+ f"NOAA settlement fetch failed station={normalized_station_code}: {exc}"
+ )
return None
def _imgw_api_get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
@@ -610,8 +632,25 @@ class SettlementSourceMixin:
station_name=station_name,
station_candidates=station_candidates,
)
+ if settlement_source == "noaa":
+ station_code = (
+ str(city_meta.get("settlement_station_code") or "").strip()
+ or str(city_meta.get("icao") or "").strip()
+ or normalized.upper()
+ )
+ station_name = (
+ str(city_meta.get("settlement_station_label") or "").strip()
+ or station_code
+ )
+ return self.fetch_noaa_station_settlement_current(
+ station_code=station_code,
+ station_name=station_name,
+ )
except Exception as exc:
logger.warning(f"Settlement source dispatch failed city={city}: {exc}")
if normalized == "taipei":
- return self.fetch_noaa_rctp_settlement_current()
+ return self.fetch_noaa_station_settlement_current(
+ station_code="RCTP",
+ station_name="Taiwan Taoyuan International Airport",
+ )
return None
diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py
index 563976c6..7098b7df 100644
--- a/src/data_collection/weather_sources.py
+++ b/src/data_collection/weather_sources.py
@@ -32,12 +32,14 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
# 城市周边 METAR 集群(用于在全球城市模拟类似安卡拉的多测站地图分布)
CITY_METAR_CLUSTERS = {
"buenos aires": ["SAEZ", "SABE", "SADP", "SADF", "SADL", "SADJ"],
+ "istanbul": ["LTFM", "LTBA", "LTFJ"],
"london": ["EGLL", "EGLC", "EGKK", "EGSS", "EGGW"],
"new york": ["KLGA", "KJFK", "KEWR", "KTEB", "KHPN"],
"paris": ["LFPG", "LFPO", "LFPB"],
"seoul": ["RKSI", "RKSS"],
"hong kong": ["VHHH", "VMMC", "ZGSZ"],
"shek kong": ["VHSK", "VHHH", "VMMC", "ZGSZ"],
+ "lau fau shan": ["VHHH", "VMMC", "ZGSZ"],
"taipei": ["RCSS", "RCTP"],
"chengdu": ["ZUUU", "ZUTF"],
"chongqing": ["ZUCK", "ZUPS"],
@@ -425,6 +427,10 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
"迈阿密": "Miami",
"atlanta": "Atlanta",
"亚特兰大": "Atlanta",
+ "istanbul": "Istanbul",
+ "ist": "Istanbul",
+ "ltfm": "Istanbul",
+ "伊斯坦布尔": "Istanbul",
"seoul": "Seoul",
"首尔": "Seoul",
"hong kong": "Hong Kong",
@@ -434,6 +440,9 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
"vhsk": "Shek Kong",
"石岗": "Shek Kong",
"石崗": "Shek Kong",
+ "lau fau shan": "Lau Fau Shan",
+ "lfs": "Lau Fau Shan",
+ "流浮山": "Lau Fau Shan",
"taipei": "Taipei",
"台北": "Taipei",
"臺北": "Taipei",
@@ -556,8 +565,13 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
or normalized
)
self._settlement_cache.pop(f"hko:{station_code.lower()}", None)
- elif normalized == "taipei":
- self._settlement_cache.pop("noaa:rctp", None)
+ elif settlement_source == "noaa":
+ station_code = (
+ str(city_meta.get("settlement_station_code") or "").strip()
+ or str(city_meta.get("icao") or "").strip()
+ or normalized
+ )
+ self._settlement_cache.pop(f"noaa:{station_code.lower()}", None)
def _uses_fahrenheit(self, city_lower: str) -> bool:
return city_lower in self.US_CITIES
diff --git a/web/analysis_service.py b/web/analysis_service.py
index c1fc6006..968cf130 100644
--- a/web/analysis_service.py
+++ b/web/analysis_service.py
@@ -1283,6 +1283,8 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"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"),
"obs_time": obs_time_str,
"obs_age_min": None if use_settlement_current else metar_age_min,
"report_time": primary_current.get("report_time"),