Add RP5 forecast scraping support

This commit is contained in:
2569718930@qq.com
2026-03-17 23:15:13 +08:00
parent 9ac0a13937
commit 1ae9b55509
19 changed files with 1113 additions and 278 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ const TELEGRAM_GROUP_URL = String(
const FAQ_ITEMS = [
{
q: "Pro 包含哪些功能?",
a: "开通后可解锁:今日日内深度分析(含高温时段)、历史对账 + 未来日期分析、全平台智能气象推送。",
a: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、历史对账 + 未来日期分析、全平台智能气象推送。",
},
{
q: "当前订阅价格是多少?",
@@ -60,7 +60,9 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
label:
chartData.observationLabel ||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
pointHoverRadius: 6,
pointRadius: 3.8,
showLine: false,
@@ -248,7 +248,9 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderWidth: 0,
data: todayChartData.datasets.metarPoints,
fill: false,
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
label:
todayChartData.observationLabel ||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
order: 0,
pointHoverRadius: 7,
pointRadius: 5,
+44 -3
View File
@@ -19,6 +19,10 @@ function HistoryChart() {
const hasMgm =
store.selectedCity === "ankara" &&
summary.mgms.some((value) => value != null);
const hasBestBaseline =
Boolean(summary.bestModelName) &&
summary.bestModelName !== "MGM" &&
summary.bestModelSeries.some((value) => value != null);
const canvasRef = useChart(() => {
const datasets: NonNullable<
@@ -62,6 +66,23 @@ function HistoryChart() {
});
}
if (hasBestBaseline) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#60a5fa",
borderDash: [4, 3],
borderWidth: 2,
data: summary.bestModelSeries,
label:
locale === "en-US"
? `Best Baseline (${summary.bestModelName})`
: `最佳单模型 (${summary.bestModelName})`,
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
return {
data: {
datasets,
@@ -114,7 +135,7 @@ function HistoryChart() {
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [hasMgm, summary, locale]);
}, [hasBestBaseline, hasMgm, summary, locale]);
if (!summary.recentData.length) return null;
@@ -195,17 +216,37 @@ export function HistoryModal() {
) : (
<>
<div className="h-stat-card">
<span className="label">{t("history.hitRate")}</span>
<span className="label">{t("history.debHitRate")}</span>
<span className="val">
{summary.hitRate != null ? `${summary.hitRate}%` : "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.mae")}</span>
<span className="label">{t("history.debMae")}</span>
<span className="val">
{summary.debMae != null ? `${summary.debMae}°` : "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.bestModelMae")}</span>
<span className="val">
{summary.bestModelMae != null
? `${summary.bestModelMae}°${
summary.bestModelName
? ` (${summary.bestModelName})`
: ""
}`
: "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.debVsBest")}</span>
<span className="val">
{summary.debWinRateVsBest != null
? `${summary.debWinRateVsBest}% (${summary.debWinDaysVsBest}/${summary.debVsBestComparableDays})`
: "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.sample")}</span>
<span className="val">
@@ -287,7 +287,9 @@ export function TemperatureChart() {
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
label:
chartData.observationLabel ||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
order: 0,
pointHoverRadius: 7,
pointRadius: 5,
@@ -56,12 +56,12 @@ type UnlockProOverlayProps = {
const FEATURES = {
"zh-CN": [
"今日日内深度分析(含高温时段)",
"今日日内机场报文规则分析(含高温时段)",
"历史对账 + 未来日期分析",
"全平台智能气象推送",
],
"en-US": [
"Intraday deep analysis with peak-time window",
"Intraday METAR rule-based analysis with peak-time window",
"Historical reconciliation + future-date analysis",
"Cross-platform alerts",
],
+6
View File
@@ -287,6 +287,10 @@ export interface CityDetail {
time?: string;
temp?: number | null;
}>;
settlement_today_obs?: Array<{
time?: string;
temp?: number | null;
}>;
trend?: TrendInfo;
peak?: PeakInfo;
ai_analysis?: string | AiAnalysisStructured | null;
@@ -300,7 +304,9 @@ export interface HistoryPoint {
date: string;
actual: number | null;
deb: number | null;
mu?: number | null;
mgm?: number | null;
forecasts?: Record<string, number | null>;
}
export interface LoadingState {
+115 -12
View File
@@ -30,6 +30,24 @@ function isEnglish(locale: Locale) {
return locale === "en-US";
}
function getObservationSourceCode(detail: CityDetail): string {
return String(detail.current?.settlement_source || "metar")
.trim()
.toLowerCase();
}
function getObservationSourceTag(detail: CityDetail): string {
const label = String(detail.current?.settlement_source_label || "")
.trim()
.toUpperCase();
if (label) return label;
const code = getObservationSourceCode(detail);
if (code === "hko") return "HKO";
if (code === "cwa") return "CWA";
if (code === "mgm") return "MGM";
return "METAR";
}
function normalizeCloudSummary(
cloudDesc: string | null | undefined,
locale: Locale,
@@ -119,6 +137,7 @@ export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN")
export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
const current = detail.current || {};
const parts: string[] = [];
const sourceTag = getObservationSourceTag(detail);
if (current.obs_time) {
const ageText =
@@ -127,7 +146,7 @@ export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
? ` (${current.obs_age_min} min ago)`
: `${current.obs_age_min} 分钟前)`
: "";
parts.push(`✈️ METAR ${current.obs_time}${ageText}`);
parts.push(`✈️ ${sourceTag} ${current.obs_time}${ageText}`);
}
if (current.wx_desc) {
@@ -209,12 +228,18 @@ export function getTemperatureChartData(
currentIndex < 0 || index >= currentIndex ? temp : null,
);
const metarPoints = new Array(times.length).fill(null);
const metarSource = detail.metar_today_obs?.length
? detail.metar_today_obs
: detail.trend?.recent || [];
const observationTag = getObservationSourceTag(detail);
const observationCode = getObservationSourceCode(detail);
const settlementSource =
observationCode === "hko" || observationCode === "cwa";
const observationSource = settlementSource
? detail.settlement_today_obs || []
: detail.metar_today_obs?.length
? detail.metar_today_obs
: detail.trend?.recent || [];
metarSource.forEach((item) => {
const metarPoints = new Array(times.length).fill(null);
observationSource.forEach((item) => {
const parts = String(item.time || "").split(":");
let hour = Number.parseInt(parts[0], 10);
const minute = Number.parseInt(parts[1] || "0", 10);
@@ -286,13 +311,17 @@ export function getTemperatureChartData(
: "已使用 MGM 小时预报替代 DEB 曲线",
);
}
if (detail.trend?.recent?.length) {
const recentText = [...detail.trend.recent]
if ((detail.trend?.recent?.length || 0) > 0 || observationSource.length > 0) {
const recentData =
observationSource.length > 0
? [...observationSource]
: [...(detail.trend?.recent || [])];
const recentText = recentData
.slice(0, 4)
.reverse()
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
.join(" -> ");
legendParts.push(`METAR: ${recentText}`);
legendParts.push(`${observationTag}: ${recentText}`);
}
return {
@@ -306,6 +335,9 @@ export function getTemperatureChartData(
offset,
temps,
},
observationLabel: isEnglish(locale)
? `${observationTag} Observation`
: `${observationTag} 实况`,
legendText: legendParts.join(" | "),
max,
min,
@@ -973,6 +1005,13 @@ export function getHistorySummary(
history: HistoryPoint[],
cityLocalDate?: string | null,
) {
const toFinite = (value: unknown): number | null => {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
};
const isExcludedModel = (name: string) =>
String(name || "").toLowerCase().includes("meteoblue");
const cutoff = new Date();
cutoff.setHours(0, 0, 0, 0);
cutoff.setDate(cutoff.getDate() - 14);
@@ -992,15 +1031,65 @@ export function getHistorySummary(
let hits = 0;
const debErrors: number[] = [];
const modelErrors: Record<string, number[]> = {};
settledData.forEach((row) => {
if (row.actual != null && row.deb != null) {
debErrors.push(Math.abs(row.actual - row.deb));
if (wuRound(row.actual) === wuRound(row.deb)) {
const actual = toFinite(row.actual);
const deb = toFinite(row.deb);
if (actual != null && deb != null) {
debErrors.push(Math.abs(actual - deb));
if (wuRound(actual) === wuRound(deb)) {
hits += 1;
}
}
const forecasts = row.forecasts || {};
Object.entries(forecasts).forEach(([modelName, modelValue]) => {
if (isExcludedModel(modelName)) return;
const mv = toFinite(modelValue);
if (actual == null || mv == null) return;
if (!modelErrors[modelName]) {
modelErrors[modelName] = [];
}
modelErrors[modelName].push(Math.abs(actual - mv));
});
});
const modelMaeList = Object.entries(modelErrors)
.map(([name, errors]) => ({
mae:
errors.length > 0
? errors.reduce((sum, value) => sum + value, 0) / errors.length
: Number.POSITIVE_INFINITY,
model: name,
sampleCount: errors.length,
}))
.filter((row) => Number.isFinite(row.mae) && row.sampleCount > 0)
.sort((a, b) => a.mae - b.mae);
const primaryModelMaeList = modelMaeList.filter((row) => row.sampleCount >= 2);
const bestModel = (primaryModelMaeList[0] || modelMaeList[0]) ?? null;
const bestModelName = bestModel?.model || null;
const bestModelMae = bestModel ? Number(bestModel.mae.toFixed(1)) : null;
const bestModelSeries = recentData.map((row) =>
bestModelName ? toFinite(row.forecasts?.[bestModelName]) : null,
);
let debWinDaysVsBest = 0;
let debVsBestComparableDays = 0;
if (bestModelName) {
settledData.forEach((row) => {
const actual = toFinite(row.actual);
const deb = toFinite(row.deb);
const bestModelVal = toFinite(row.forecasts?.[bestModelName]);
if (actual == null || deb == null || bestModelVal == null) return;
debVsBestComparableDays += 1;
if (Math.abs(deb - actual) <= Math.abs(bestModelVal - actual)) {
debWinDaysVsBest += 1;
}
});
}
return {
dates: recentData.map((row) => row.date),
debMae: debErrors.length
@@ -1011,6 +1100,20 @@ export function getHistorySummary(
)
: null,
debs: recentData.map((row) => row.deb),
bestModelName,
bestModelMae,
bestModelSeries,
modelMaeRanks: modelMaeList.map((row) => ({
model: row.model,
mae: Number(row.mae.toFixed(1)),
sampleCount: row.sampleCount,
})),
debWinDaysVsBest,
debVsBestComparableDays,
debWinRateVsBest:
debVsBestComparableDays > 0
? Number(((debWinDaysVsBest / debVsBestComparableDays) * 100).toFixed(0))
: null,
hitRate: debErrors.length
? Number(((hits / debErrors.length) * 100).toFixed(0))
: null,
+18 -8
View File
@@ -59,6 +59,11 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"history.empty": "近 15 天暂无该城市历史数据",
"history.hitRate": "DEB 结算胜率 (WU)",
"history.mae": "DEB MAE",
"history.debHitRate": "DEB 结算胜率 (WU)",
"history.debMae": "DEB MAE",
"history.muMae": "μ MAE",
"history.bestModelMae": "最佳单模型 MAE",
"history.debVsBest": "DEB 优于最佳模型",
"history.sample": "近 15 天已结算样本",
"history.sampleDays": "{count} 天",
@@ -86,8 +91,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"future.judgement": "判断",
"future.confidence": "置信度",
"future.maxPrecip": "最大降水概率",
"future.ai": "AI 深度分析",
"future.noAi": "暂无 AI 分析,当前以结构化气象与模型数据为主。",
"future.ai": "机场报文解读",
"future.noAi": "暂无机场报文解读,当前以结构化气象与模型数据为主。",
"future.weatherGov": "weather.gov 文本",
"future.risk": "结算与偏差风险",
"future.climate": "当地气候主要受什么影响",
@@ -104,8 +109,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"section.noProb": "暂无概率数据",
"section.models": "多模型预报",
"section.noModels": "暂无多模型预报",
"section.ai": "AI 深度分析",
"section.aiEmpty": "暂无 AI 分析,当前以结构化气象与模型数据为主。",
"section.ai": "机场报文解读",
"section.aiEmpty": "暂无机场报文解读,当前以结构化气象与模型数据为主。",
"section.risk": "数据偏差风险",
"section.noRiskProfile": "暂无风险档案",
"section.airport": "机场",
@@ -214,6 +219,11 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"history.empty": "No historical records for this city in the last 15 days",
"history.hitRate": "DEB Settlement Hit Rate (WU)",
"history.mae": "DEB MAE",
"history.debHitRate": "DEB Settlement Hit Rate (WU)",
"history.debMae": "DEB MAE",
"history.muMae": "μ MAE",
"history.bestModelMae": "Best Single-model MAE",
"history.debVsBest": "DEB vs Best Model",
"history.sample": "Settled Samples (Last 15 Days)",
"history.sampleDays": "{count} days",
@@ -241,9 +251,9 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"future.judgement": "Judgement",
"future.confidence": "Confidence",
"future.maxPrecip": "Max Precip Probability",
"future.ai": "AI Deep Analysis",
"future.ai": "Airport METAR Narrative",
"future.noAi":
"No AI analysis available. Structured meteorological and model data are used as baseline.",
"No airport bulletin narrative is available. Structured meteorological and model data are used as baseline.",
"future.weatherGov": "weather.gov text",
"future.risk": "Settlement & Deviation Risk",
"future.climate": "What Mainly Drives Local Climate",
@@ -261,9 +271,9 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"section.noProb": "No probability data available",
"section.models": "Multi-model Forecast",
"section.noModels": "No multi-model forecast available",
"section.ai": "AI Deep Analysis",
"section.ai": "Airport METAR Narrative",
"section.aiEmpty":
"No AI analysis available. Structured meteorological and model data are currently used.",
"No airport bulletin narrative is available. Structured meteorological data are currently used.",
"section.risk": "Data Deviation Risk",
"section.noRiskProfile": "No risk profile available",
"section.airport": "Airport",
+1
View File
@@ -343,6 +343,7 @@ export interface CityDetail {
timeseries: {
metar_recent_obs: any[];
metar_today_obs: any[];
settlement_today_obs?: any[];
hourly: any;
mgm_hourly: any[];
forecast_daily: any[];
+1 -1
View File
@@ -106,7 +106,7 @@
</section>
<section class="ai-section">
<h3>AI 深度分析</h3>
<h3>机场报文解读</h3>
<div id="aiAnalysis" class="ai-box">
<span class="ai-placeholder">点击城市后加载...</span>
</div>