Align Taipei settlement to NOAA RCTP across web and extension
This commit is contained in:
+30
-4
@@ -20,6 +20,7 @@ const I18N = {
|
|||||||
settlementAirport: "结算机场",
|
settlementAirport: "结算机场",
|
||||||
hko: "香港天文台 (HKO)",
|
hko: "香港天文台 (HKO)",
|
||||||
cwa: "交通部中央气象署 (CWA)",
|
cwa: "交通部中央气象署 (CWA)",
|
||||||
|
noaa: "NOAA RCTP(台湾桃园国际机场)",
|
||||||
city: "城市",
|
city: "城市",
|
||||||
refresh: "刷新数据",
|
refresh: "刷新数据",
|
||||||
cityProfile: "城市档案",
|
cityProfile: "城市档案",
|
||||||
@@ -35,6 +36,9 @@ const I18N = {
|
|||||||
nearbyMonitoringSuffix: "个参与监控",
|
nearbyMonitoringSuffix: "个参与监控",
|
||||||
today: "今天",
|
today: "今天",
|
||||||
omSeries: "OM预测",
|
omSeries: "OM预测",
|
||||||
|
noaaSettlementRef: "NOAA RCTP 结算参考",
|
||||||
|
noaaSettlementLegend:
|
||||||
|
"台北按 NOAA RCTP 最终完成质控后的最高整度摄氏值结算;图中曲线仅作结算参考。",
|
||||||
loadCityDetailFailed: "加载城市详情失败",
|
loadCityDetailFailed: "加载城市详情失败",
|
||||||
refreshFailed: "刷新温度数据失败",
|
refreshFailed: "刷新温度数据失败",
|
||||||
initFailed: "初始化失败",
|
initFailed: "初始化失败",
|
||||||
@@ -55,6 +59,7 @@ const I18N = {
|
|||||||
settlementAirport: "Settlement Airport",
|
settlementAirport: "Settlement Airport",
|
||||||
hko: "Hong Kong Observatory (HKO)",
|
hko: "Hong Kong Observatory (HKO)",
|
||||||
cwa: "Central Weather Administration (CWA)",
|
cwa: "Central Weather Administration (CWA)",
|
||||||
|
noaa: "NOAA RCTP (Taiwan Taoyuan International Airport)",
|
||||||
city: "City",
|
city: "City",
|
||||||
refresh: "Refresh data",
|
refresh: "Refresh data",
|
||||||
cityProfile: "City Profile",
|
cityProfile: "City Profile",
|
||||||
@@ -70,6 +75,9 @@ const I18N = {
|
|||||||
nearbyMonitoringSuffix: " stations monitored",
|
nearbyMonitoringSuffix: " stations monitored",
|
||||||
today: "Today",
|
today: "Today",
|
||||||
omSeries: "OM Forecast",
|
omSeries: "OM Forecast",
|
||||||
|
noaaSettlementRef: "NOAA RCTP Settlement Reference",
|
||||||
|
noaaSettlementLegend:
|
||||||
|
"Taipei settles on NOAA RCTP using the finalized highest rounded whole-degree Celsius reading; the plotted line is only a settlement reference.",
|
||||||
loadCityDetailFailed: "Failed to load city detail",
|
loadCityDetailFailed: "Failed to load city detail",
|
||||||
refreshFailed: "Failed to refresh weather data",
|
refreshFailed: "Failed to refresh weather data",
|
||||||
initFailed: "Initialization failed",
|
initFailed: "Initialization failed",
|
||||||
@@ -394,6 +402,12 @@ function getSettlementSourceDisplay(detail) {
|
|||||||
value: t("cwa")
|
value: t("cwa")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (source === "noaa") {
|
||||||
|
return {
|
||||||
|
label: t("settlementSource"),
|
||||||
|
value: t("noaa")
|
||||||
|
};
|
||||||
|
}
|
||||||
const airport = detail?.risk?.airport || "--";
|
const airport = detail?.risk?.airport || "--";
|
||||||
const icao = detail?.risk?.icao ? ` (${detail.risk.icao})` : "";
|
const icao = detail?.risk?.icao ? ` (${detail.risk.icao})` : "";
|
||||||
return {
|
return {
|
||||||
@@ -629,7 +643,10 @@ function drawTrendChart(detail) {
|
|||||||
const points = [...trend, ...obs];
|
const points = [...trend, ...obs];
|
||||||
const hoverPoints = [];
|
const hoverPoints = [];
|
||||||
const tempSymbol = detail?.temp_symbol || "°C";
|
const tempSymbol = detail?.temp_symbol || "°C";
|
||||||
const obsSeriesLabel = String(detail?.current?.settlement_source_label || "OBS").toUpperCase();
|
const sourceCode = String(detail?.current?.settlement_source || "").toLowerCase();
|
||||||
|
const obsSeriesLabel = sourceCode === "noaa"
|
||||||
|
? t("noaaSettlementRef")
|
||||||
|
: String(detail?.current?.settlement_source_label || "OBS").toUpperCase();
|
||||||
if (!points.length) {
|
if (!points.length) {
|
||||||
setChartHover([], tempSymbol);
|
setChartHover([], tempSymbol);
|
||||||
ctx.fillStyle = "#8ba0be";
|
ctx.fillStyle = "#8ba0be";
|
||||||
@@ -788,14 +805,23 @@ function renderDetail(detail) {
|
|||||||
drawTrendChart(detail);
|
drawTrendChart(detail);
|
||||||
renderForecast(detail);
|
renderForecast(detail);
|
||||||
|
|
||||||
const sourceTag = String(detail?.current?.settlement_source_label || "").toUpperCase() || "OBS";
|
const sourceCode = String(detail?.current?.settlement_source || "").toLowerCase();
|
||||||
|
const sourceTag = sourceCode === "noaa"
|
||||||
|
? t("noaaSettlementRef")
|
||||||
|
: String(detail?.current?.settlement_source_label || "").toUpperCase() || "OBS";
|
||||||
const obs = getObservationRows(detail);
|
const obs = getObservationRows(detail);
|
||||||
if (obs.length >= 2) {
|
if (obs.length >= 2) {
|
||||||
const first = obs[0];
|
const first = obs[0];
|
||||||
const last = obs[obs.length - 1];
|
const last = obs[obs.length - 1];
|
||||||
els.chartLegend.textContent = `${sourceTag}: ${first.temp}°C@${first.time} -> ${last.temp}°C@${last.time}`;
|
els.chartLegend.textContent =
|
||||||
|
sourceCode === "noaa"
|
||||||
|
? `${sourceTag}: ${first.temp}°C@${first.time} -> ${last.temp}°C@${last.time} | ${t("noaaSettlementLegend")}`
|
||||||
|
: `${sourceTag}: ${first.temp}°C@${first.time} -> ${last.temp}°C@${last.time}`;
|
||||||
} else {
|
} else {
|
||||||
els.chartLegend.textContent = `${sourceTag}: ${t("noContinuousObs")}`;
|
els.chartLegend.textContent =
|
||||||
|
sourceCode === "noaa"
|
||||||
|
? `${sourceTag}: ${t("noContinuousObs")} | ${t("noaaSettlementLegend")}`
|
||||||
|
: `${sourceTag}: ${t("noContinuousObs")}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -452,6 +452,10 @@ export function FutureForecastModal() {
|
|||||||
"--score-position": scorePosition,
|
"--score-position": scorePosition,
|
||||||
} as CSSProperties & { "--score-position": string };
|
} as CSSProperties & { "--score-position": string };
|
||||||
const weatherSummary = getWeatherSummary(detail, locale);
|
const weatherSummary = getWeatherSummary(detail, locale);
|
||||||
|
const isTaipeiNoaa =
|
||||||
|
store.selectedCity === "taipei" &&
|
||||||
|
(detail.current?.settlement_source === "noaa" ||
|
||||||
|
detail.current?.settlement_source_label === "NOAA");
|
||||||
const marketMidpoint = formatMarketPercent(
|
const marketMidpoint = formatMarketPercent(
|
||||||
marketScan?.market_price ?? marketScan?.yes_token?.implied_probability,
|
marketScan?.market_price ?? marketScan?.yes_token?.implied_probability,
|
||||||
);
|
);
|
||||||
@@ -551,11 +555,15 @@ export function FutureForecastModal() {
|
|||||||
? locale === "en-US"
|
? locale === "en-US"
|
||||||
? "Hong Kong Observatory (HKO)"
|
? "Hong Kong Observatory (HKO)"
|
||||||
: "香港天文台 (HKO)"
|
: "香港天文台 (HKO)"
|
||||||
: settlementSourceCode === "cwa"
|
: settlementSourceCode === "noaa"
|
||||||
? locale === "en-US"
|
? locale === "en-US"
|
||||||
? "Central Weather Administration (CWA)"
|
? "NOAA RCTP (Taiwan Taoyuan International Airport)"
|
||||||
: "交通部中央气象署 (CWA)"
|
: "NOAA RCTP(台湾桃园国际机场)"
|
||||||
: risk.airport
|
: settlementSourceCode === "cwa"
|
||||||
|
? locale === "en-US"
|
||||||
|
? "Central Weather Administration (CWA)"
|
||||||
|
: "交通部中央气象署 (CWA)"
|
||||||
|
: risk.airport
|
||||||
? `${risk.airport}${risk.icao ? ` (${risk.icao})` : ""}`
|
? `${risk.airport}${risk.icao ? ` (${risk.icao})` : ""}`
|
||||||
: "--";
|
: "--";
|
||||||
|
|
||||||
@@ -652,6 +660,24 @@ export function FutureForecastModal() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body future-modal-body">
|
<div className="modal-body future-modal-body">
|
||||||
|
{isTaipeiNoaa && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "16px",
|
||||||
|
padding: "12px 14px",
|
||||||
|
border: "1px solid rgba(56, 189, 248, 0.24)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "rgba(14, 165, 233, 0.08)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locale === "en-US"
|
||||||
|
? "Taipei now settles against NOAA RCTP (Taiwan Taoyuan International Airport). The market uses the highest rounded whole-degree Celsius reading in the Temp column after the day is finalized."
|
||||||
|
: "台北当前按 NOAA RCTP(台湾桃园国际机场)结算。市场最终采用该日 Temp 列完成质控后的最高整度摄氏值,不按小数温度结算。"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isToday ? (
|
{isToday ? (
|
||||||
<div className="future-v2-layout">
|
<div className="future-v2-layout">
|
||||||
<aside className="future-v2-left">
|
<aside className="future-v2-left">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ function HistoryChart() {
|
|||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const { data } = useHistoryData();
|
const { data } = useHistoryData();
|
||||||
|
const isTaipei = store.selectedCity === "taipei";
|
||||||
const summary = useMemo(
|
const summary = useMemo(
|
||||||
() => getHistorySummary(data, store.selectedDetail?.local_date),
|
() => getHistorySummary(data, store.selectedDetail?.local_date),
|
||||||
[data, store.selectedDetail?.local_date],
|
[data, store.selectedDetail?.local_date],
|
||||||
@@ -33,7 +34,13 @@ function HistoryChart() {
|
|||||||
borderColor: "#f87171",
|
borderColor: "#f87171",
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
data: summary.actuals,
|
data: summary.actuals,
|
||||||
label: locale === "en-US" ? "Observed High" : "实测最高温",
|
label: isTaipei
|
||||||
|
? locale === "en-US"
|
||||||
|
? "NOAA Settled High (RCTP)"
|
||||||
|
: "NOAA 结算最高温 (RCTP)"
|
||||||
|
: locale === "en-US"
|
||||||
|
? "Observed High"
|
||||||
|
: "实测最高温",
|
||||||
pointBackgroundColor: "#f87171",
|
pointBackgroundColor: "#f87171",
|
||||||
pointBorderColor: "#fff",
|
pointBorderColor: "#fff",
|
||||||
pointHoverRadius: 7,
|
pointHoverRadius: 7,
|
||||||
@@ -135,7 +142,7 @@ function HistoryChart() {
|
|||||||
},
|
},
|
||||||
type: "line",
|
type: "line",
|
||||||
} satisfies ChartConfiguration<"line">;
|
} satisfies ChartConfiguration<"line">;
|
||||||
}, [hasBestBaseline, hasMgm, summary, locale]);
|
}, [hasBestBaseline, hasMgm, isTaipei, summary, locale]);
|
||||||
|
|
||||||
if (!summary.recentData.length) return null;
|
if (!summary.recentData.length) return null;
|
||||||
|
|
||||||
@@ -152,6 +159,7 @@ export function HistoryModal() {
|
|||||||
const { data, error, isLoading, isOpen } = useHistoryData();
|
const { data, error, isLoading, isOpen } = useHistoryData();
|
||||||
const isPro = store.proAccess.subscriptionActive;
|
const isPro = store.proAccess.subscriptionActive;
|
||||||
const isProLoading = store.proAccess.loading;
|
const isProLoading = store.proAccess.loading;
|
||||||
|
const isTaipei = store.selectedCity === "taipei";
|
||||||
const summary = useMemo(
|
const summary = useMemo(
|
||||||
() => getHistorySummary(data, store.selectedDetail?.local_date),
|
() => getHistorySummary(data, store.selectedDetail?.local_date),
|
||||||
[data, store.selectedDetail?.local_date],
|
[data, store.selectedDetail?.local_date],
|
||||||
@@ -200,6 +208,24 @@ export function HistoryModal() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
|
{isTaipei && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "16px",
|
||||||
|
padding: "12px 14px",
|
||||||
|
border: "1px solid rgba(56, 189, 248, 0.24)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "rgba(14, 165, 233, 0.08)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 结算口径对齐:采用该日最终完成质控后的最高整度摄氏值。"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="history-stats">
|
<div className="history-stats">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span style={{ color: "var(--text-muted)" }}>
|
<span style={{ color: "var(--text-muted)" }}>
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
|||||||
],
|
],
|
||||||
taipei: [
|
taipei: [
|
||||||
{
|
{
|
||||||
label: "CWA 中央气象署",
|
label: "NOAA RCTP Timeseries",
|
||||||
href: "https://www.cwa.gov.tw/V8/E/",
|
href: "https://www.weather.gov/wrh/timeseries?site=RCTP",
|
||||||
kind: "agency",
|
kind: "agency",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,8 +70,8 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
|||||||
kind: "airport",
|
kind: "airport",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "RCSS METAR",
|
label: "RCTP METAR",
|
||||||
href: "https://aviationweather.gov/data/metar/?id=RCSS&decoded=1&taf=1",
|
href: "https://aviationweather.gov/data/metar/?id=RCTP&decoded=1&taf=1",
|
||||||
kind: "metar",
|
kind: "metar",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function getObservationSourceCode(detail: CityDetail): string {
|
|||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (city === "hong kong") return "hko";
|
if (city === "hong kong") return "hko";
|
||||||
if (city === "taipei") return "cwa";
|
if (city === "taipei") return "noaa";
|
||||||
return "metar";
|
return "metar";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +52,7 @@ function getObservationSourceTag(detail: CityDetail): string {
|
|||||||
const code = getObservationSourceCode(detail);
|
const code = getObservationSourceCode(detail);
|
||||||
if (code === "hko") return "HKO";
|
if (code === "hko") return "HKO";
|
||||||
if (code === "cwa") return "CWA";
|
if (code === "cwa") return "CWA";
|
||||||
|
if (code === "noaa") return "NOAA";
|
||||||
if (code === "mgm") return "MGM";
|
if (code === "mgm") return "MGM";
|
||||||
return "METAR";
|
return "METAR";
|
||||||
}
|
}
|
||||||
@@ -239,7 +240,7 @@ export function getTemperatureChartData(
|
|||||||
const observationTag = getObservationSourceTag(detail);
|
const observationTag = getObservationSourceTag(detail);
|
||||||
const observationCode = getObservationSourceCode(detail);
|
const observationCode = getObservationSourceCode(detail);
|
||||||
const settlementSource =
|
const settlementSource =
|
||||||
observationCode === "hko" || observationCode === "cwa";
|
observationCode === "hko" || observationCode === "cwa" || observationCode === "noaa";
|
||||||
const officialObservationSource =
|
const officialObservationSource =
|
||||||
settlementSource
|
settlementSource
|
||||||
? detail.settlement_today_obs?.length
|
? detail.settlement_today_obs?.length
|
||||||
@@ -267,7 +268,11 @@ export function getTemperatureChartData(
|
|||||||
return `${icao} METAR`;
|
return `${icao} METAR`;
|
||||||
})();
|
})();
|
||||||
const observationDisplayTag =
|
const observationDisplayTag =
|
||||||
settlementSource && shouldUseMetarFallback ? metarFallbackTag : observationTag;
|
settlementSource && shouldUseMetarFallback
|
||||||
|
? metarFallbackTag
|
||||||
|
: observationCode === "noaa"
|
||||||
|
? "NOAA RCTP"
|
||||||
|
: observationTag;
|
||||||
|
|
||||||
const metarPoints = new Array(times.length).fill(null);
|
const metarPoints = new Array(times.length).fill(null);
|
||||||
observationSource.forEach((item) => {
|
observationSource.forEach((item) => {
|
||||||
@@ -361,6 +366,12 @@ export function getTemperatureChartData(
|
|||||||
? `Official ${observationTag} feed is sparse today, so the continuous observation line switches to ${metarFallbackTag}.`
|
? `Official ${observationTag} feed is sparse today, so the continuous observation line switches to ${metarFallbackTag}.`
|
||||||
: `今日官方 ${observationTag} 点位较稀疏,连续实测线改用 ${metarFallbackTag}。`,
|
: `今日官方 ${observationTag} 点位较稀疏,连续实测线改用 ${metarFallbackTag}。`,
|
||||||
);
|
);
|
||||||
|
} else if (observationCode === "noaa") {
|
||||||
|
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 最终完成质控后的最高整度摄氏值结算;图中曲线仅作为结算参考线。",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -374,9 +385,14 @@ export function getTemperatureChartData(
|
|||||||
offset,
|
offset,
|
||||||
temps,
|
temps,
|
||||||
},
|
},
|
||||||
observationLabel: isEnglish(locale)
|
observationLabel:
|
||||||
? `${observationDisplayTag} Observation`
|
observationCode === "noaa" && !shouldUseMetarFallback
|
||||||
: `${observationDisplayTag} 实况`,
|
? isEnglish(locale)
|
||||||
|
? `${observationDisplayTag} Settlement Reference`
|
||||||
|
: `${observationDisplayTag} 结算参考`
|
||||||
|
: isEnglish(locale)
|
||||||
|
? `${observationDisplayTag} Observation`
|
||||||
|
: `${observationDisplayTag} 实况`,
|
||||||
legendText: legendParts.join(" | "),
|
legendText: legendParts.join(" | "),
|
||||||
max,
|
max,
|
||||||
min,
|
min,
|
||||||
@@ -1416,7 +1432,7 @@ export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN"
|
|||||||
const current = detail.current || {};
|
const current = detail.current || {};
|
||||||
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
||||||
const sourceCode = getObservationSourceCode(detail);
|
const sourceCode = getObservationSourceCode(detail);
|
||||||
const isOfficialSource = sourceCode === "hko" || sourceCode === "cwa";
|
const isOfficialSource = sourceCode === "hko" || sourceCode === "cwa" || sourceCode === "noaa";
|
||||||
|
|
||||||
const sourceDisplay = (() => {
|
const sourceDisplay = (() => {
|
||||||
if (sourceCode === "hko") {
|
if (sourceCode === "hko") {
|
||||||
@@ -1429,6 +1445,11 @@ export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN"
|
|||||||
? "Central Weather Administration (CWA)"
|
? "Central Weather Administration (CWA)"
|
||||||
: "交通部中央气象署 (CWA)";
|
: "交通部中央气象署 (CWA)";
|
||||||
}
|
}
|
||||||
|
if (sourceCode === "noaa") {
|
||||||
|
return isEnglish(locale)
|
||||||
|
? "NOAA RCTP (Taiwan Taoyuan)"
|
||||||
|
: "NOAA RCTP(台湾桃园国际机场)";
|
||||||
|
}
|
||||||
const tag = getObservationSourceTag(detail);
|
const tag = getObservationSourceTag(detail);
|
||||||
if (sourceCode === "mgm") {
|
if (sourceCode === "mgm") {
|
||||||
return isEnglish(locale) ? `MGM (${tag})` : `MGM (${tag})`;
|
return isEnglish(locale) ? `MGM (${tag})` : `MGM (${tag})`;
|
||||||
@@ -1491,7 +1512,7 @@ export function getSettlementRiskNarrative(
|
|||||||
) {
|
) {
|
||||||
const risk = detail.risk || {};
|
const risk = detail.risk || {};
|
||||||
const sourceCode = getObservationSourceCode(detail);
|
const sourceCode = getObservationSourceCode(detail);
|
||||||
const stationTerm = sourceCode === "hko" || sourceCode === "cwa"
|
const stationTerm = sourceCode === "hko" || sourceCode === "cwa" || sourceCode === "noaa"
|
||||||
? isEnglish(locale)
|
? isEnglish(locale)
|
||||||
? "settlement reference station"
|
? "settlement reference station"
|
||||||
: "结算参考站"
|
: "结算参考站"
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
|||||||
"guide.title": "📎 PolyWeather 系统技术说明",
|
"guide.title": "📎 PolyWeather 系统技术说明",
|
||||||
"guide.closeAria": "关闭技术说明",
|
"guide.closeAria": "关闭技术说明",
|
||||||
"guide.footer":
|
"guide.footer":
|
||||||
"数据源以 METAR、香港天文台(HKO)、中央气象署(CWA)、Turkish MGM、Open-Meteo、weather.gov 为主。",
|
"数据源以 METAR、香港天文台(HKO)、NOAA RCTP、Turkish MGM、Open-Meteo、weather.gov 为主。",
|
||||||
|
|
||||||
"history.title": "📊 历史准确率对账 - {city}",
|
"history.title": "📊 历史准确率对账 - {city}",
|
||||||
"history.closeAria": "关闭历史对账",
|
"history.closeAria": "关闭历史对账",
|
||||||
@@ -214,7 +214,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
|||||||
"guide.title": "📎 PolyWeather Technical Overview",
|
"guide.title": "📎 PolyWeather Technical Overview",
|
||||||
"guide.closeAria": "Close technical overview",
|
"guide.closeAria": "Close technical overview",
|
||||||
"guide.footer":
|
"guide.footer":
|
||||||
"Primary data sources are METAR, Hong Kong Observatory (HKO), CWA (Taiwan), Turkish MGM, Open-Meteo, and weather.gov.",
|
"Primary data sources are METAR, Hong Kong Observatory (HKO), NOAA RCTP, Turkish MGM, Open-Meteo, and weather.gov.",
|
||||||
|
|
||||||
"history.title": "📊 Historical Reconciliation - {city}",
|
"history.title": "📊 Historical Reconciliation - {city}",
|
||||||
"history.closeAria": "Close history reconciliation",
|
"history.closeAria": "Close history reconciliation",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def _resolve_settlement_source(city_meta: Dict[str, Any]) -> Tuple[str, str]:
|
|||||||
"metar": "METAR",
|
"metar": "METAR",
|
||||||
"hko": "HKO",
|
"hko": "HKO",
|
||||||
"cwa": "CWA",
|
"cwa": "CWA",
|
||||||
|
"noaa": "NOAA",
|
||||||
"mgm": "MGM",
|
"mgm": "MGM",
|
||||||
}
|
}
|
||||||
return source, source_label_map.get(source, source.upper())
|
return source, source_label_map.get(source, source.upper())
|
||||||
@@ -333,7 +334,7 @@ def build_city_query_report(
|
|||||||
sc_current = {}
|
sc_current = {}
|
||||||
city_meta = CITY_REGISTRY.get(city_name.lower(), {})
|
city_meta = CITY_REGISTRY.get(city_name.lower(), {})
|
||||||
settlement_source, settlement_source_label = _resolve_settlement_source(city_meta)
|
settlement_source, settlement_source_label = _resolve_settlement_source(city_meta)
|
||||||
use_settlement_current = settlement_source in {"hko", "cwa"} and bool(sc_current)
|
use_settlement_current = settlement_source in {"hko", "cwa", "noaa"} and bool(sc_current)
|
||||||
fallback_utc_offset = int(city_meta.get("tz_offset", 0))
|
fallback_utc_offset = int(city_meta.get("tz_offset", 0))
|
||||||
nws_periods = ((weather_data.get("nws") or {}).get("forecast_periods") or [])
|
nws_periods = ((weather_data.get("nws") or {}).get("forecast_periods") or [])
|
||||||
if nws_periods:
|
if nws_periods:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ def is_exact_settlement_city(city: str) -> bool:
|
|||||||
if not city:
|
if not city:
|
||||||
return False
|
return False
|
||||||
c = str(city).lower().strip()
|
c = str(city).lower().strip()
|
||||||
return c in ["hong kong", "hk", "taipei", "tpe", "臺北", "台北", "香港"]
|
return c in ["hong kong", "hk", "香港"]
|
||||||
|
|
||||||
|
|
||||||
def apply_city_settlement(city: str, value: Optional[Number]) -> Optional[int]:
|
def apply_city_settlement(city: str, value: Optional[Number]) -> Optional[int]:
|
||||||
@@ -38,4 +38,3 @@ def apply_city_settlement(city: str, value: Optional[Number]) -> Optional[int]:
|
|||||||
if is_exact_settlement_city(city):
|
if is_exact_settlement_city(city):
|
||||||
return int(math.floor(float(value)))
|
return int(math.floor(float(value)))
|
||||||
return wu_round(value)
|
return wu_round(value)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ SETTLEMENT_SOURCE_LABELS = {
|
|||||||
"metar": "METAR",
|
"metar": "METAR",
|
||||||
"hko": "HKO",
|
"hko": "HKO",
|
||||||
"cwa": "CWA",
|
"cwa": "CWA",
|
||||||
|
"noaa": "NOAA",
|
||||||
"mgm": "MGM",
|
"mgm": "MGM",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,18 +75,18 @@ CITY_REGISTRY = {
|
|||||||
},
|
},
|
||||||
"taipei": {
|
"taipei": {
|
||||||
"name": "Taipei",
|
"name": "Taipei",
|
||||||
"lat": 25.0377,
|
"lat": 25.0777,
|
||||||
"lon": 121.5149,
|
"lon": 121.2330,
|
||||||
"icao": "RCSS",
|
"icao": "RCTP",
|
||||||
"settlement_source": "cwa",
|
"settlement_source": "noaa",
|
||||||
"tz_offset": 28800,
|
"tz_offset": 28800,
|
||||||
"use_fahrenheit": False,
|
"use_fahrenheit": False,
|
||||||
"is_major": True,
|
"is_major": True,
|
||||||
"risk_level": "low",
|
"risk_level": "low",
|
||||||
"risk_emoji": "🟢",
|
"risk_emoji": "🟢",
|
||||||
"airport_name": "中央气象署台北站",
|
"airport_name": "台湾桃园国际机场",
|
||||||
"distance_km": 1.5,
|
"distance_km": 28.1,
|
||||||
"warning": "盆地地形叠加都市热岛,夏季午后对流前后温度波动较快。",
|
"warning": "市场现按 NOAA RCTP 整度°C口径结算,机场与台北市区温度不可混用。",
|
||||||
},
|
},
|
||||||
"shanghai": {
|
"shanghai": {
|
||||||
"name": "Shanghai",
|
"name": "Shanghai",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
@@ -11,6 +12,8 @@ from loguru import logger
|
|||||||
class SettlementSourceMixin:
|
class SettlementSourceMixin:
|
||||||
IMGW_METEO_API_BASE = "https://meteo.imgw.pl/api/v1"
|
IMGW_METEO_API_BASE = "https://meteo.imgw.pl/api/v1"
|
||||||
IMGW_METEO_API_TOKEN = "p4DXKjsYadfBV21TYrDk"
|
IMGW_METEO_API_TOKEN = "p4DXKjsYadfBV21TYrDk"
|
||||||
|
NOAA_WRH_MESO_TOKEN = "7c76618b66c74aee913bdbae4b448bdd"
|
||||||
|
NOAA_WRH_TIMESERIES_REFERER = "https://www.weather.gov/wrh/timeseries?site=RCTP"
|
||||||
|
|
||||||
def _get_settlement_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
def _get_settlement_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
||||||
now_ts = time.time()
|
now_ts = time.time()
|
||||||
@@ -81,6 +84,13 @@ class SettlementSourceMixin:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _js_round(value: Any) -> Optional[int]:
|
||||||
|
parsed = SettlementSourceMixin._safe_float(value)
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
return int(math.floor(float(parsed) + 0.5))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _pick_station_row(
|
def _pick_station_row(
|
||||||
rows: List[Dict[str, str]], candidates: List[str]
|
rows: List[Dict[str, str]], candidates: List[str]
|
||||||
@@ -263,6 +273,109 @@ class SettlementSourceMixin:
|
|||||||
logger.warning(f"CWA Forecast request failed: {exc}")
|
logger.warning(f"CWA Forecast request failed: {exc}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def fetch_noaa_rctp_settlement_current(self) -> Optional[Dict[str, Any]]:
|
||||||
|
cache_key = "noaa:rctp"
|
||||||
|
cached = self._get_settlement_cache(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(
|
||||||
|
"https://api.synopticdata.com/v2/stations/timeseries",
|
||||||
|
params={
|
||||||
|
"STID": "RCTP",
|
||||||
|
"showemptystations": 1,
|
||||||
|
"recent": 2880,
|
||||||
|
"complete": 1,
|
||||||
|
"token": self.NOAA_WRH_MESO_TOKEN,
|
||||||
|
"obtimezone": "local",
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Referer": self.NOAA_WRH_TIMESERIES_REFERER,
|
||||||
|
"Origin": "https://www.weather.gov",
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
},
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
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 None
|
||||||
|
|
||||||
|
obs = station.get("OBSERVATIONS") or {}
|
||||||
|
stamps = obs.get("date_time") or []
|
||||||
|
temps = obs.get("air_temp_set_1") or []
|
||||||
|
humidity_list = obs.get("relative_humidity_set_1") or []
|
||||||
|
wind_speed_list = obs.get("wind_speed_set_1") or []
|
||||||
|
wind_dir_list = obs.get("wind_direction_set_1") or []
|
||||||
|
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
|
||||||
|
latest_humidity: Optional[float] = None
|
||||||
|
latest_wind_speed_ms: Optional[float] = None
|
||||||
|
latest_wind_dir: Optional[float] = None
|
||||||
|
|
||||||
|
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))
|
||||||
|
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
|
||||||
|
|
||||||
|
max_so_far = None
|
||||||
|
max_temp_time = None
|
||||||
|
today_low = None
|
||||||
|
if today_rows:
|
||||||
|
max_so_far = max(temp for _, temp in today_rows)
|
||||||
|
today_low = min(temp for _, temp in today_rows)
|
||||||
|
for dt, temp in today_rows:
|
||||||
|
if temp == max_so_far:
|
||||||
|
max_temp_time = dt.strftime("%H:%M")
|
||||||
|
break
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"source": "noaa",
|
||||||
|
"source_label": "NOAA",
|
||||||
|
"station_code": "RCTP",
|
||||||
|
"station_name": str(station.get("NAME") or "Taiwan Taoyuan International Airport"),
|
||||||
|
"observation_time": latest_dt.isoformat(),
|
||||||
|
"current": {
|
||||||
|
"temp": latest_temp,
|
||||||
|
"max_temp_so_far": max_so_far,
|
||||||
|
"max_temp_time": max_temp_time,
|
||||||
|
"today_low": today_low,
|
||||||
|
"humidity": round(latest_humidity, 1) if latest_humidity is not None else None,
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
"unit": "celsius",
|
||||||
|
}
|
||||||
|
self._set_settlement_cache(cache_key, result)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"NOAA RCTP settlement fetch failed: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
def _imgw_api_get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
def _imgw_api_get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
query = {"token": self.IMGW_METEO_API_TOKEN}
|
query = {"token": self.IMGW_METEO_API_TOKEN}
|
||||||
@@ -346,5 +459,5 @@ class SettlementSourceMixin:
|
|||||||
if normalized == "hong kong":
|
if normalized == "hong kong":
|
||||||
return self.fetch_hko_settlement_current()
|
return self.fetch_hko_settlement_current()
|
||||||
if normalized == "taipei":
|
if normalized == "taipei":
|
||||||
return self.fetch_cwa_taipei_settlement_current()
|
return self.fetch_noaa_rctp_settlement_current()
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -539,7 +539,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
if normalized == "hong kong":
|
if normalized == "hong kong":
|
||||||
self._settlement_cache.pop("hko:hong_kong", None)
|
self._settlement_cache.pop("hko:hong_kong", None)
|
||||||
elif normalized == "taipei":
|
elif normalized == "taipei":
|
||||||
self._settlement_cache.pop("cwa:taipei:466920", None)
|
self._settlement_cache.pop("noaa:rctp", None)
|
||||||
|
|
||||||
def _uses_fahrenheit(self, city_lower: str) -> bool:
|
def _uses_fahrenheit(self, city_lower: str) -> bool:
|
||||||
return city_lower in self.US_CITIES
|
return city_lower in self.US_CITIES
|
||||||
@@ -557,10 +557,6 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
hko_forecast = self.fetch_hko_forecast()
|
hko_forecast = self.fetch_hko_forecast()
|
||||||
if hko_forecast:
|
if hko_forecast:
|
||||||
results["hko_forecast"] = hko_forecast
|
results["hko_forecast"] = hko_forecast
|
||||||
elif city_lower in ["taipei", "台北", "臺北", "tpe"]:
|
|
||||||
cwa_forecast = self.fetch_cwa_taipei_forecast()
|
|
||||||
if cwa_forecast:
|
|
||||||
results["cwa_forecast"] = cwa_forecast
|
|
||||||
|
|
||||||
def _attach_turkish_mgm_data(self, results: Dict, city_lower: str) -> None:
|
def _attach_turkish_mgm_data(self, results: Dict, city_lower: str) -> None:
|
||||||
if city_lower not in self.TURKISH_PROVINCES:
|
if city_lower not in self.TURKISH_PROVINCES:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This Synoptic API key is for weather.gov websites/queries
|
||||||
|
var mesoToken='7c76618b66c74aee913bdbae4b448bdd';
|
||||||
|
|
||||||
+3098
File diff suppressed because it is too large
Load Diff
+1238
File diff suppressed because it is too large
Load Diff
@@ -74,7 +74,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
|||||||
mc = metar.get("current", {}) if metar else {}
|
mc = metar.get("current", {}) if metar else {}
|
||||||
mg_cur = mgm.get("current", {}) if mgm else {}
|
mg_cur = mgm.get("current", {}) if mgm else {}
|
||||||
sc_cur = settlement_current.get("current", {}) if settlement_current else {}
|
sc_cur = settlement_current.get("current", {}) if settlement_current else {}
|
||||||
use_settlement_current = settlement_source in {"hko", "cwa"} and bool(sc_cur)
|
use_settlement_current = settlement_source in {"hko", "cwa", "noaa"} and bool(sc_cur)
|
||||||
primary_current = sc_cur if use_settlement_current else mc
|
primary_current = sc_cur if use_settlement_current else mc
|
||||||
cur_temp = _sf(primary_current.get("temp"))
|
cur_temp = _sf(primary_current.get("temp"))
|
||||||
if cur_temp is None:
|
if cur_temp is None:
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ SETTLEMENT_SOURCE_LABELS: Dict[str, str] = {
|
|||||||
"metar": "METAR",
|
"metar": "METAR",
|
||||||
"hko": "HKO",
|
"hko": "HKO",
|
||||||
"cwa": "CWA",
|
"cwa": "CWA",
|
||||||
|
"noaa": "NOAA",
|
||||||
"mgm": "MGM",
|
"mgm": "MGM",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user