Refresh product docs and observation freshness
This commit is contained in:
@@ -5,7 +5,7 @@ Production weather-intelligence stack for temperature settlement markets.
|
||||
Official dashboard: [polyweather.top](https://polyweather.top/)
|
||||
中文说明: [README_ZH.md](README_ZH.md)
|
||||
|
||||
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday analysis, calibrated probability, model stack, TAF, settlement sources, history, and extension).
|
||||
Public docs center: `/docs/intro` on the main site (bilingual product documentation for the current terminal, chart reading, realtime source cadence, settlement stations, and the browser extension).
|
||||
|
||||
## Product Screenshots
|
||||
|
||||
@@ -21,12 +21,11 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
|
||||
[](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
|
||||
|
||||
## Product Status (2026-05-28)
|
||||
## Product Status (2026-06-06)
|
||||
|
||||
- Subscription live: `Pro Monthly 10 USDC`.
|
||||
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
|
||||
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
|
||||
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
|
||||
- Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`.
|
||||
- Referral pricing live: invited users can get the first monthly Pro at `20 USDC`; inviters receive `3500` points after a valid first Pro payment, capped at 10 paid invites per month.
|
||||
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`).
|
||||
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
|
||||
- Auto-reconciliation live: event listener + periodic confirm loop.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
|
||||
@@ -44,10 +43,10 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
|
||||
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
|
||||
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
|
||||
- Terminal city cards now combine settlement observations, DEB hourly consensus, model cluster context, calibrated probability, and market-bucket mapping without blocking the chart on AI text generation.
|
||||
- Terminal chart/detail workflow now combines settlement observations, DEB hourly consensus, model context, probability bands, and market-bucket mapping without blocking the chart on AI text generation.
|
||||
- Terminal data uses page memory cache, browser `localStorage`, backend short-TTL cache, SSE patch replay, and foreground refresh so returning from another tab restores the latest visible chart state quickly.
|
||||
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
|
||||
- The card label “model-market difference” means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- The market-signal difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference.
|
||||
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
|
||||
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
||||
@@ -73,7 +72,7 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
||||
- Builds a DEB-weighted hourly consensus path for peak-window logic and chart display.
|
||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration.
|
||||
- Adds city decision cards that combine live observations, expected-high centers, full market-bucket mapping, and model-market difference in one view.
|
||||
- Adds terminal chart/detail workflows that combine live observations, DEB-centered high-temperature context, market-bucket mapping, and model-market difference.
|
||||
- Reuses one analysis core across web dashboard and Telegram bot.
|
||||
- Adds payment audit trails, replay tooling, and incident visibility in ops.
|
||||
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
|
||||
|
||||
+4
-4
@@ -49,10 +49,10 @@
|
||||
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。
|
||||
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
|
||||
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
|
||||
- 终端城市卡已改为结构化实况 + DEB hourly consensus + 多模型集群 + 校准概率 + 市场温度桶,不再让图表等待 AI 文案生成。
|
||||
- 终端图表/详情工作流已改为结构化实况 + DEB hourly consensus + 多模型集群 + 概率温度带 + 市场温度桶,不再让图表等待 AI 文案生成。
|
||||
- 终端数据同时使用页面内存缓存、浏览器 `localStorage`、后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态。
|
||||
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
|
||||
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 市场信号中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
|
||||
- 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑。
|
||||
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。
|
||||
@@ -73,7 +73,7 @@
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
|
||||
- 地图城市决策卡把结构化实况、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。
|
||||
- 天气决策台把结构化实况、DEB 高温路径、完整市场温度桶和模型-市场差放进图表/详情工作流。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
||||
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
|
||||
@@ -242,7 +242,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- 模型栈与 DEB:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- DEB 模型家族去重规则:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
+10
-21
@@ -9,7 +9,7 @@ PolyWeather Pro 的生产前端工程。
|
||||
|
||||
- Next.js App Router
|
||||
- React + Tailwind
|
||||
- Leaflet + Recharts
|
||||
- 自研温度图表 + Recharts 运营图表
|
||||
- Supabase Auth
|
||||
- WalletConnect + 浏览器 EVM 钱包
|
||||
|
||||
@@ -21,30 +21,19 @@ PolyWeather Pro 的生产前端工程。
|
||||
|
||||
## 当前前端能力
|
||||
|
||||
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
|
||||
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源
|
||||
- 主站为实时天气决策台,包含 `天气决策 / 训练数据 / 使用指南` 三个主入口
|
||||
- `/docs` 提供公开双语产品文档中心,当前保留简介、图表阅读、实时数据频率、结算站点和浏览器插件说明
|
||||
- 天气决策台支持 1x1 到 3x3 图表槽位,可按区域、搜索和城市选择进行多城市巡检
|
||||
- 终端图表默认展示全天,可切换高温窗口;所有横轴与 tooltip 时间按城市当地时间渲染
|
||||
- 可见终端图表通过 SSE patch 无痛增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
|
||||
- 可见终端图表通过 SSE patch 增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
|
||||
- AMSC/AMOS 跑道城市默认展示结算跑道曲线并高亮,辅助跑道弱化展示;单跑道机场不重复展示聚合线
|
||||
- 香港默认展示 CoWIN 6087 参考站 1 分钟曲线,并保留 HKO 10 分钟官方气象层
|
||||
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
|
||||
- 今日日内分析支持:
|
||||
- `锚点状态`
|
||||
- `当前节奏`
|
||||
- `校准模型概率`
|
||||
- `模型区间与分歧`
|
||||
- `专业气象结论条`
|
||||
- `气象证据链 / 失效条件 / 确认条件`
|
||||
- 非香港机场城市的 `TAF` 时段提示与走势图联动
|
||||
- 使用指南内置图表阅读顺序、图层含义、常用操作和默认可见性规则
|
||||
- `/ops` 已支持桌面表格 + 手机端卡片化视图
|
||||
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
|
||||
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
- 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据
|
||||
- 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据
|
||||
- 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口
|
||||
- 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
|
||||
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
|
||||
- 城市 detail 自动识别稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,市场信号作为交易判断层,不替代实测结算源
|
||||
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
|
||||
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
|
||||
@@ -191,7 +180,7 @@ Ops:
|
||||
- `summary?force_refresh=true`:`no-store`
|
||||
- 支付相关路由:`no-store`
|
||||
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡
|
||||
- 今日日内分析打开时如果正在切换城市、日期或 detail 深度,弹窗会阻断旧内容点击并显示刷新锁
|
||||
- detail 正在切换城市、日期或分辨率时,图表保留已有曲线并显示同步提示,避免把旧数据当作当前状态
|
||||
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=按可见城市数动态限制`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
|
||||
- 前端只消费 HTTP snapshot + SSE patch,不直接感知 Redis;Redis Stream / SQLite event log 都由后端统一封装
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ const FAQ_ITEMS = [
|
||||
{
|
||||
q_zh: "Pro 包含哪些功能?",
|
||||
q_en: "What features does Pro include?",
|
||||
a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、未来日期分析、城市决策卡、全平台智能气象推送。",
|
||||
a_en: "Unlocks: intraday METAR rule analysis (including peak window), future-date analysis, city decision cards, and cross-platform smart weather push.",
|
||||
a_zh: "开通后可解锁:天气决策台、多城市图表巡检、未来日期分析、全平台智能气象推送。",
|
||||
a_en: "Unlocks: weather decision terminal, multi-city chart monitoring, future-date analysis, and cross-platform smart weather push.",
|
||||
},
|
||||
{
|
||||
q_zh: "当前订阅价格是多少?",
|
||||
|
||||
@@ -15,7 +15,7 @@ const ScanTerminalDashboard = dynamic(
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather Terminal | Paid Product",
|
||||
description:
|
||||
"Paid PolyWeather decision terminal for weather-signal analysis and city decision cards.",
|
||||
"Paid PolyWeather decision terminal for weather-signal analysis and multi-city chart monitoring.",
|
||||
};
|
||||
|
||||
export default function TerminalPage() {
|
||||
|
||||
@@ -19,8 +19,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
weatherEngine: isEn ? "Weather Engine" : "气象引擎",
|
||||
intradayAnalysis: isEn ? "Intraday Analysis" : "今日内分析",
|
||||
historyFuture: isEn
|
||||
? "Future-date + Decision Card Analysis"
|
||||
: "未来日期分析 + 城市决策卡",
|
||||
? "Future-date + Multi-city Chart Monitoring"
|
||||
: "未来日期分析 + 多城市图表巡检",
|
||||
smartPush: isEn
|
||||
? "Cross-platform Smart Weather Push"
|
||||
: "全平台智能气象查询",
|
||||
|
||||
@@ -12,6 +12,7 @@ const OBSERVATION_LABEL_EN: Record<string, string> = {
|
||||
"CWA (10分钟)": "CWA (10m)",
|
||||
"气象站实测": "Weather Station Live",
|
||||
"跑道实测 (1分钟)": "Runway Live (1m)",
|
||||
"跑道实测 (3分钟)": "Runway Live (3m)",
|
||||
"机场报文": "Airport METAR",
|
||||
"METAR 结算 (30分钟)": "METAR Settlement (30m)",
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getDocsPage } from "@/content/docs/docs";
|
||||
import { DOCS_PAGES } from "@/content/docs/docs";
|
||||
import { DOCS_GROUPS } from "@/content/docs/docs.config";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
@@ -24,16 +26,42 @@ function pageText(slug: string, locale: "zh-CN" | "en-US") {
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const publicDocSlugs = DOCS_PAGES.map((page) => page.slug);
|
||||
assert(
|
||||
publicDocSlugs.join(",") === "intro,chart-guide,realtime-sources,settlement-sources,extension",
|
||||
"public docs navigation should only expose current shipped user-facing surfaces",
|
||||
);
|
||||
for (const group of DOCS_GROUPS) {
|
||||
assert(
|
||||
DOCS_PAGES.some((page) => page.group === group.id),
|
||||
`docs navigation group should not be empty: ${group.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const allDocsZh = publicDocSlugs.map((slug) => pageText(slug, "zh-CN")).join("\n");
|
||||
const allDocsEn = publicDocSlugs.map((slug) => pageText(slug, "en-US")).join("\n");
|
||||
for (const staleTerm of ["从地图进入", "机会榜", "日历", "EMOS", "LGBM", "城市决策卡", "付费判断台", "刷新锁"]) {
|
||||
assert(!allDocsZh.includes(staleTerm), `public Chinese docs should not expose stale term: ${staleTerm}`);
|
||||
}
|
||||
for (const staleTerm of ["map-launched", "opportunity board", "calendar", "EMOS", "LGBM", "city decision card", "paid decision workspace", "refresh lock"]) {
|
||||
assert(!allDocsEn.includes(staleTerm), `public English docs should not expose stale term: ${staleTerm}`);
|
||||
}
|
||||
|
||||
const introZh = pageText("intro", "zh-CN");
|
||||
assert(
|
||||
introZh.includes("结算源优先") && introZh.includes("实时实测终端"),
|
||||
"intro should position PolyWeather as a settlement-source-first live terminal",
|
||||
introZh.includes("结算源优先") && introZh.includes("天气决策台"),
|
||||
"intro should position PolyWeather as a settlement-source-first decision terminal",
|
||||
);
|
||||
assert(
|
||||
introZh.includes("1-9 个图表槽位") && introZh.includes("天气决策 / 训练数据 / 使用指南"),
|
||||
"intro should match the current terminal navigation and chart-slot workflow",
|
||||
);
|
||||
|
||||
const chartGuideZh = pageText("chart-guide", "zh-CN");
|
||||
assert(chartGuideZh.includes("如何读 PolyWeather 图表"), "chart guide title should be present");
|
||||
assert(chartGuideZh.includes("高级气象变量") && chartGuideZh.includes("默认隐藏"), "chart guide should explain advanced variables as hidden-by-default context");
|
||||
assert(chartGuideZh.includes("不要把概率温度带当成实测曲线"), "chart guide should warn against reading probability as observation");
|
||||
assert(chartGuideZh.includes("高温 / 全天"), "chart guide should document the current chart view modes");
|
||||
|
||||
const realtimeSourcesZh = pageText("realtime-sources", "zh-CN");
|
||||
assert(realtimeSourcesZh.includes("AMSC 180s"), "realtime sources should document the AMSC 180s cadence");
|
||||
@@ -54,4 +82,5 @@ export function runTests() {
|
||||
const chartGuideEn = pageText("chart-guide", "en-US");
|
||||
assert(chartGuideEn.includes("How To Read PolyWeather Charts"), "English chart guide should exist");
|
||||
assert(chartGuideEn.includes("hidden by default"), "English chart guide should describe hidden-by-default advanced variables");
|
||||
assert(chartGuideEn.includes("Peak / All Day"), "English chart guide should document the current chart view modes");
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export function runTests() {
|
||||
const now = new Date("2026-05-14T12:00:00Z");
|
||||
|
||||
assert.equal(getObservationSourceProfile("amos").nativeUpdateIntervalSec, 60);
|
||||
assert.equal(getObservationSourceProfile("amsc_awos").nativeUpdateIntervalSec, 180);
|
||||
assert.equal(getObservationSourceProfile("metar").nativeUpdateIntervalSec, 900);
|
||||
|
||||
const amosFresh = buildObservationFreshness({
|
||||
@@ -109,4 +110,28 @@ export function runTests() {
|
||||
},
|
||||
});
|
||||
assert.equal(getObservationFreshness(amosDetail)?.source_code, "amos");
|
||||
|
||||
const amscDetail = detail({
|
||||
amos: {
|
||||
observation_time: "2026-05-14T11:57:20Z",
|
||||
runway_obs: {
|
||||
runway_pairs: [["35R", "17L"]],
|
||||
temperatures: [[25.2, null]],
|
||||
},
|
||||
source: "amsc_awos",
|
||||
source_label: "AMSC AWOS",
|
||||
},
|
||||
});
|
||||
const amscFreshness = getObservationFreshness(amscDetail);
|
||||
assert.equal(amscFreshness?.source_code, "amsc_awos");
|
||||
assert.equal(amscFreshness?.native_update_interval_sec, 180);
|
||||
|
||||
const amscExpectedWait = buildObservationFreshness({
|
||||
now,
|
||||
observedAt: "2026-05-14T11:56:40Z",
|
||||
sourceCode: "amsc_awos",
|
||||
sourceLabel: "AMSC AWOS",
|
||||
});
|
||||
assert.equal(amscExpectedWait.native_update_interval_sec, 180);
|
||||
assert.equal(amscExpectedWait.freshness_status, "expected_wait");
|
||||
}
|
||||
|
||||
+23
@@ -1170,6 +1170,29 @@ export function runTests() {
|
||||
"Panama City high label should use airport METAR report wording, not weather-station or runway wording",
|
||||
);
|
||||
|
||||
const shanghaiLabels = __getLiveObservationLabelsForTest(
|
||||
{
|
||||
city: "shanghai",
|
||||
local_date: "2026-06-06",
|
||||
local_time: "21:03",
|
||||
tz_offset_seconds: 8 * 60 * 60,
|
||||
} as any,
|
||||
{
|
||||
amos: {
|
||||
source: "amsc_awos",
|
||||
source_label: "AMSC AWOS",
|
||||
},
|
||||
airportPrimary: {
|
||||
source_code: "amsc_awos",
|
||||
source_label: "AMSC AWOS",
|
||||
},
|
||||
} as any,
|
||||
);
|
||||
assert(
|
||||
shanghaiLabels.runwayHeaderLabel === "跑道实测 (3分钟)",
|
||||
"AMSC runway cities should advertise the 3-minute source cadence instead of the AMOS 1-minute cadence",
|
||||
);
|
||||
|
||||
const newYorkWithMadis = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "new york",
|
||||
|
||||
@@ -36,6 +36,18 @@ export function runTests() {
|
||||
assert(shenzhen.runwayHigh === "HKO Live", "Shenzhen high summary should translate 天文台实测");
|
||||
assert(shenzhen.metarHigh === "HKO", "Shenzhen high summary should translate 天文台");
|
||||
|
||||
const shanghai = __buildTemperatureStatsLabelsForTest({
|
||||
isEn: true,
|
||||
isShenzhen: false,
|
||||
runwayHeaderLabel: "跑道实测 (3分钟)",
|
||||
metarHeaderLabel: "METAR 结算 (30分钟)",
|
||||
runwayHighLabel: "跑道实测",
|
||||
metarHighLabel: "METAR 官方",
|
||||
});
|
||||
|
||||
assert(shanghai.primary === "Runway Live (3m)", "AMSC English primary label should match 跑道实测 (3分钟)");
|
||||
assert(shanghai.runwayHigh === "Runway", "AMSC runway high label should remain Runway");
|
||||
|
||||
const zh = __buildTemperatureStatsLabelsForTest({
|
||||
isEn: false,
|
||||
isShenzhen: true,
|
||||
|
||||
@@ -16,6 +16,10 @@ const ROLLING_WINDOW_BEFORE_MS = 12 * 60 * 60 * 1000;
|
||||
const ROLLING_WINDOW_AFTER_LIVE_MS = 2 * 60 * 60 * 1000;
|
||||
const ROLLING_WINDOW_AFTER_FORECAST_MS = 8 * 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const AMSC_RUNWAY_CITIES = new Set([
|
||||
"beijing", "shanghai", "guangzhou", "qingdao",
|
||||
"chengdu", "chongqing", "wuhan",
|
||||
]);
|
||||
|
||||
const SETTLEMENT_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
|
||||
shanghai: [["17L", "35R"]],
|
||||
@@ -1298,6 +1302,9 @@ function getLiveObservationLabels(
|
||||
weatherStationCities.has(normalizedKey) ||
|
||||
/\b(mgm|turkey_mgm|jma_amedas|fmi|knmi|cowin_obs|ims|ncm|aeroweb|madis_hfmetar|singapore_mss)\b/.test(sourceTokens);
|
||||
const isRunwaySensorCity = runwaySensorCities.has(normalizedKey);
|
||||
const isAmscRunwayCity =
|
||||
AMSC_RUNWAY_CITIES.has(normalizedKey) ||
|
||||
/\bamsc(?:_awos)?\b|\bawos\b/.test(sourceTokens);
|
||||
const isWeatherStation = !runwaySensorCities.has(normalizedKey)
|
||||
&& !isHKO && !isShenzhen && !isTokyo && !isSingapore && !isParis && !isTaipei
|
||||
&& hasRealStationNetwork;
|
||||
@@ -1309,7 +1316,7 @@ function getLiveObservationLabels(
|
||||
: isParis ? "官方机场观测 (15分钟)"
|
||||
: isTaipei ? "CWA (10分钟)"
|
||||
: isWeatherStation ? "气象站实测"
|
||||
: isRunwaySensorCity ? "跑道实测 (1分钟)"
|
||||
: isRunwaySensorCity ? `跑道实测 (${isAmscRunwayCity ? "3分钟" : "1分钟"})`
|
||||
: "机场报文";
|
||||
|
||||
const metarHeaderLabel = (isShenzhen || isHKO) ? "天文台实测 (10分钟)"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
.docsShell {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||||
color: #0f172a;
|
||||
linear-gradient(90deg, rgba(216, 224, 236, 0.38) 1px, transparent 1px),
|
||||
linear-gradient(180deg, #f8fbff 0%, #eef4fb 54%, #f5f8fc 100%);
|
||||
background-size: 44px 44px, auto;
|
||||
color: var(--color-text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.docsHeader {
|
||||
@@ -12,35 +14,44 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-bottom: 1px solid #d9e3ef;
|
||||
gap: 18px;
|
||||
min-height: 58px;
|
||||
padding: 10px 24px;
|
||||
border-bottom: 1px solid rgba(184, 196, 214, 0.74);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
|
||||
backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.brandWrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brandLink {
|
||||
color: #0891b2;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
font-size: 1.24rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brandLink::first-letter {
|
||||
color: var(--color-accent-primary, #2563eb);
|
||||
}
|
||||
|
||||
.brandSubtitle {
|
||||
color: #64748b;
|
||||
font-size: 0.92rem;
|
||||
font-size: 0.88rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -49,101 +60,121 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--color-border-default, #d8e0ec);
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg-raised, #ffffff);
|
||||
color: #1e293b;
|
||||
text-decoration: none;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
color 160ms ease,
|
||||
background 160ms ease;
|
||||
background 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.headerButton:hover,
|
||||
.headerGhost:hover {
|
||||
border-color: #38bdf8;
|
||||
color: #075985;
|
||||
background: #f0f9ff;
|
||||
border-color: #93c5fd;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.headerGhost svg,
|
||||
.mobileMenuButton svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.langSwitch {
|
||||
display: flex;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--color-border-default, #d8e0ec);
|
||||
border-radius: 10px;
|
||||
background: #e8edf5;
|
||||
}
|
||||
|
||||
.langButton {
|
||||
min-width: 54px;
|
||||
min-height: 38px;
|
||||
min-width: 48px;
|
||||
min-height: 32px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-weight: 800;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.langButtonActive {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.docsFrame {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 820px) 220px;
|
||||
grid-template-columns: 268px minmax(0, 850px) 226px;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
padding: 28px 24px 68px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 84px;
|
||||
top: 82px;
|
||||
align-self: start;
|
||||
max-height: calc(100vh - 108px);
|
||||
max-height: calc(100vh - 106px);
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
border: 1px solid #d8e3ef;
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(184, 196, 214, 0.72);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
|
||||
box-shadow: var(--shadow-elevation-2, 0 8px 24px rgba(15, 23, 42, 0.06));
|
||||
}
|
||||
|
||||
.sidebarGroup + .sidebarGroup {
|
||||
margin-top: 22px;
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid #e8edf5;
|
||||
}
|
||||
|
||||
.sidebarTitle {
|
||||
margin: 0 0 8px;
|
||||
padding: 0 8px;
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebarLink {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
padding: 9px 10px;
|
||||
margin-bottom: 3px;
|
||||
padding: 9px 10px 9px 14px;
|
||||
border-radius: 8px;
|
||||
color: #334155;
|
||||
text-decoration: none;
|
||||
font-size: 0.94rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease;
|
||||
color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.sidebarLink:hover {
|
||||
@@ -152,9 +183,10 @@
|
||||
}
|
||||
|
||||
.sidebarLinkActive {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
font-weight: 800;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-weight: 850;
|
||||
box-shadow: inset 3px 0 0 var(--color-accent-primary, #2563eb);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -162,39 +194,106 @@
|
||||
}
|
||||
|
||||
.contentInner {
|
||||
padding: 16px 0 56px;
|
||||
padding: 0 0 52px;
|
||||
}
|
||||
|
||||
.pageIntro {
|
||||
padding: 28px 30px;
|
||||
border: 1px solid rgba(184, 196, 214, 0.72);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 251, 255, 0.96));
|
||||
box-shadow: var(--shadow-elevation-2, 0 8px 24px rgba(15, 23, 42, 0.06));
|
||||
}
|
||||
|
||||
.introKicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 14px;
|
||||
color: #047857;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.introKicker svg {
|
||||
color: #00897b;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: clamp(2rem, 4vw, 3.05rem);
|
||||
font-size: clamp(2rem, 3vw, 2.72rem);
|
||||
font-weight: 850;
|
||||
line-height: 1.12;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.pageDescription {
|
||||
max-width: 760px;
|
||||
margin: 14px 0 0;
|
||||
color: #475569;
|
||||
font-size: 1.03rem;
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.pageMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.pageMeta span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: 40px;
|
||||
scroll-margin-top: 104px;
|
||||
margin-top: 0;
|
||||
padding: 34px 2px 30px;
|
||||
border-bottom: 1px solid rgba(216, 224, 236, 0.78);
|
||||
scroll-margin-top: 92px;
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0 0 14px;
|
||||
color: #1e293b;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
position: relative;
|
||||
margin: 0 0 16px;
|
||||
padding-left: 14px;
|
||||
color: #172033;
|
||||
font-size: 1.34rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.sectionTitle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.16em;
|
||||
bottom: 0.16em;
|
||||
width: 4px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #2563eb, #00897b);
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
max-width: 780px;
|
||||
margin: 0 0 15px;
|
||||
color: #334155;
|
||||
font-size: 1rem;
|
||||
@@ -202,34 +301,37 @@
|
||||
}
|
||||
|
||||
.callout {
|
||||
position: relative;
|
||||
max-width: 790px;
|
||||
margin: 20px 0;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #d8e3ef;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
|
||||
padding: 17px 18px 17px 20px;
|
||||
border: 1px solid #d8e0ec;
|
||||
border-left-width: 4px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.calloutInfo {
|
||||
border-color: #bae6fd;
|
||||
background: #f0f9ff;
|
||||
border-left-color: var(--color-accent-primary, #2563eb);
|
||||
background: #f5f9ff;
|
||||
}
|
||||
|
||||
.calloutWarning {
|
||||
border-color: #fde68a;
|
||||
background: #fffbeb;
|
||||
border-left-color: #d97706;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.calloutSuccess {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
border-left-color: #00897b;
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.calloutTitle {
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 7px;
|
||||
color: #0f172a;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 800;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.calloutText {
|
||||
@@ -239,14 +341,68 @@
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0 0 16px;
|
||||
padding-left: 22px;
|
||||
max-width: 800px;
|
||||
margin: 0 0 18px;
|
||||
padding: 0;
|
||||
color: #334155;
|
||||
line-height: 1.8;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.list li + li {
|
||||
margin-top: 8px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.bulletList {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.bulletList li {
|
||||
position: relative;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.bulletList li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 0.72em;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #00897b;
|
||||
box-shadow: 0 0 0 4px rgba(0, 137, 123, 0.1);
|
||||
}
|
||||
|
||||
.stepList {
|
||||
list-style: none;
|
||||
counter-reset: docs-step;
|
||||
}
|
||||
|
||||
.stepList li {
|
||||
counter-increment: docs-step;
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(216, 224, 236, 0.88);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.stepList li::before {
|
||||
content: counter(docs-step);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
color: #ffffff;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.linkCard {
|
||||
@@ -254,17 +410,24 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 42px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #38bdf8;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
padding: 0 15px;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 10px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
font-weight: 800;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.linkCard::after {
|
||||
content: "->";
|
||||
margin-left: 8px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.linkCard:hover {
|
||||
background: #bae6fd;
|
||||
border-color: #60a5fa;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.linkCaption {
|
||||
@@ -273,42 +436,65 @@
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.content figure {
|
||||
max-width: 800px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
|
||||
.content img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid #d8e0ec;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.content figcaption {
|
||||
margin-top: 9px;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.toc {
|
||||
position: sticky;
|
||||
top: 84px;
|
||||
top: 82px;
|
||||
align-self: start;
|
||||
padding: 16px;
|
||||
border: 1px solid #d8e3ef;
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(184, 196, 214, 0.72);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
|
||||
box-shadow: var(--shadow-elevation-1, 0 1px 3px rgba(15, 23, 42, 0.05));
|
||||
}
|
||||
|
||||
.tocTitle {
|
||||
margin: 0 0 10px;
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tocLink {
|
||||
display: block;
|
||||
padding: 5px 0;
|
||||
padding: 7px 0 7px 12px;
|
||||
border-left: 2px solid #e8edf5;
|
||||
color: #475569;
|
||||
text-decoration: none;
|
||||
line-height: 1.55;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.tocLink:hover {
|
||||
color: #0369a1;
|
||||
border-left-color: #2563eb;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.mobileMenuButton {
|
||||
display: none;
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.mobileSidebarBackdrop {
|
||||
@@ -317,7 +503,7 @@
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.docsFrame {
|
||||
grid-template-columns: 240px minmax(0, 820px);
|
||||
grid-template-columns: 248px minmax(0, 850px);
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
@@ -330,10 +516,12 @@
|
||||
.docsHeader {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.docsFrame {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.mobileMenuButton {
|
||||
@@ -345,10 +533,12 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 60;
|
||||
width: min(320px, 88vw);
|
||||
width: min(326px, 88vw);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
border-radius: 0 16px 16px 0;
|
||||
padding: 18px 14px;
|
||||
border-radius: 0 10px 10px 0;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 180ms ease-out;
|
||||
}
|
||||
@@ -361,37 +551,65 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
display: block;
|
||||
border: 0;
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.docsHeader {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.docsFrame {
|
||||
padding: 16px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.brandWrap {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.brandSubtitle {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.headerGhost,
|
||||
.langSwitch {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.langSwitch {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.docsFrame {
|
||||
padding: 14px 14px 48px;
|
||||
}
|
||||
|
||||
.pageIntro {
|
||||
padding: 22px 18px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 2rem;
|
||||
font-size: 1.9rem;
|
||||
}
|
||||
|
||||
.pageDescription,
|
||||
.paragraph {
|
||||
font-size: 1rem;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 28px 0 26px;
|
||||
}
|
||||
|
||||
.stepList li {
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
padding: 11px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import { ArrowLeft, BookOpen, Menu } from "lucide-react";
|
||||
import styles from "./DocsLayout.module.css";
|
||||
import {
|
||||
DocsLocale,
|
||||
@@ -30,6 +31,7 @@ function DocsHeader() {
|
||||
|
||||
<div className={styles.headerActions}>
|
||||
<Link href="/" className={styles.headerGhost}>
|
||||
<ArrowLeft size={16} aria-hidden="true" />
|
||||
{locale === "zh-CN" ? "返回主站" : "Back to App"}
|
||||
</Link>
|
||||
<div className={styles.langSwitch} role="group" aria-label="Language switch">
|
||||
@@ -81,6 +83,7 @@ function DocsSidebar({
|
||||
key={page.slug}
|
||||
href={href}
|
||||
className={clsx(styles.sidebarLink, currentSlug === page.slug && styles.sidebarLinkActive)}
|
||||
aria-current={currentSlug === page.slug ? "page" : undefined}
|
||||
onClick={onClose}
|
||||
>
|
||||
{title}
|
||||
@@ -114,20 +117,30 @@ function BlockRenderer({ block }: { block: DocsPageContent["sections"][number]["
|
||||
return <p className={styles.paragraph}>{block.text}</p>;
|
||||
case "callout":
|
||||
return (
|
||||
<div className={clsx(styles.callout, block.tone === "warning" && styles.calloutWarning, block.tone === "success" && styles.calloutSuccess, (!block.tone || block.tone === "info") && styles.calloutInfo)}>
|
||||
<div
|
||||
className={clsx(styles.callout, block.tone === "warning" && styles.calloutWarning, block.tone === "success" && styles.calloutSuccess, (!block.tone || block.tone === "info") && styles.calloutInfo)}
|
||||
role="note"
|
||||
>
|
||||
{block.title ? <div className={styles.calloutTitle}>{block.title}</div> : null}
|
||||
<p className={styles.calloutText}>{block.text}</p>
|
||||
</div>
|
||||
);
|
||||
case "bullets":
|
||||
case "steps":
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
<ul className={clsx(styles.list, styles.bulletList)}>
|
||||
{block.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
case "steps":
|
||||
return (
|
||||
<ol className={clsx(styles.list, styles.stepList)}>
|
||||
{block.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
case "link":
|
||||
return (
|
||||
<div>
|
||||
@@ -175,10 +188,22 @@ export function DocsScreen({ page }: { page: DocsPage }) {
|
||||
<main className={styles.content}>
|
||||
<div className={styles.contentInner}>
|
||||
<button type="button" className={clsx(styles.headerButton, styles.mobileMenuButton)} onClick={() => setMobileSidebarOpen(true)}>
|
||||
{locale === "zh-CN" ? "打开导航" : "Open navigation"}
|
||||
<Menu size={16} aria-hidden="true" />
|
||||
{locale === "zh-CN" ? "目录" : "Contents"}
|
||||
</button>
|
||||
<h1 className={styles.pageTitle}>{localizedPage.title}</h1>
|
||||
<p className={styles.pageDescription}>{localizedPage.description}</p>
|
||||
<div className={styles.pageIntro}>
|
||||
<div className={styles.introKicker}>
|
||||
<BookOpen size={16} aria-hidden="true" />
|
||||
{locale === "zh-CN" ? "PolyWeather 产品手册" : "PolyWeather Product Manual"}
|
||||
</div>
|
||||
<h1 className={styles.pageTitle}>{localizedPage.title}</h1>
|
||||
<p className={styles.pageDescription}>{localizedPage.description}</p>
|
||||
<div className={styles.pageMeta} aria-label={locale === "zh-CN" ? "文档范围" : "Document scope"}>
|
||||
<span>{locale === "zh-CN" ? "当前工作台" : "Current terminal"}</span>
|
||||
<span>{locale === "zh-CN" ? `${DOCS_PAGES.length} 篇文档` : `${DOCS_PAGES.length} docs`}</span>
|
||||
<span>{locale === "zh-CN" ? `${localizedPage.sections.length} 个章节` : `${localizedPage.sections.length} sections`}</span>
|
||||
</div>
|
||||
</div>
|
||||
{localizedPage.sections.map((section) => (
|
||||
<section key={section.id} id={section.id} className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>{section.title}</h2>
|
||||
|
||||
@@ -60,13 +60,13 @@ const FEATURES = {
|
||||
"zh-CN": [
|
||||
"市场扫描台 + V4-Pro 深度复核",
|
||||
"今日日内机场报文规则分析(含高温时段)",
|
||||
"未来日期分析 + 城市决策卡",
|
||||
"多城市图表巡检 + 未来日期分析",
|
||||
"全平台智能气象推送",
|
||||
],
|
||||
"en-US": [
|
||||
"Market Scan Terminal + V4-Pro review",
|
||||
"Intraday METAR rule-based analysis with peak-time window",
|
||||
"Future-date analysis + city decision cards",
|
||||
"Multi-city chart monitoring + future-date analysis",
|
||||
"Cross-platform alerts",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,14 +5,11 @@ export const DOCS_GROUPS: DocsNavGroup[] = [
|
||||
id: "getting-started",
|
||||
title: { "zh-CN": "开始", "en-US": "Getting Started" },
|
||||
},
|
||||
{
|
||||
id: "analysis",
|
||||
title: { "zh-CN": "分析逻辑", "en-US": "Analysis Logic" },
|
||||
},
|
||||
{
|
||||
id: "settlement",
|
||||
title: { "zh-CN": "结算与数据", "en-US": "Settlement & Data" },
|
||||
},];
|
||||
},
|
||||
];
|
||||
|
||||
export function getDocsGroupTitle(groupId: DocsNavGroup["id"], locale: DocsLocale) {
|
||||
return DOCS_GROUPS.find((group) => group.id === groupId)?.title[locale] || groupId;
|
||||
|
||||
+265
-409
@@ -41,56 +41,108 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "简介",
|
||||
description: "PolyWeather 文档中心解释核心产品概念、结算口径、日内气象判断、校准概率和模型栈,帮助用户把机场锚点、官方增强层和模型判断转成可执行判断。",
|
||||
description: "PolyWeather 是结算源优先的天气决策台,帮助用户围绕真实观测、DEB 路径、市场信号和结算规则判断温度市场。",
|
||||
sections: [
|
||||
{
|
||||
id: "what-is-polyweather",
|
||||
title: "PolyWeather 是什么",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "PolyWeather 不是通用天气 App。它是结算源优先的温度市场实时实测终端,面向天气衍生品和温度市场,重点回答三个问题:今天最高温大概会落在哪个区间、机场或官方结算站会不会被压温、市场有没有明显错定价。" },
|
||||
{ type: "callout", tone: "info", title: "产品定位", text: "主站的核心价值不是报天气,而是把模型、机场主站实况、官方增强站网、机场预报和结算规则整合成交易可用的信息。先确认结算源,再看 DEB、市场桶和概率。" },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "PolyWeather 不是通用天气 App,也不是单纯的预报展示页。它把结算跑道、官方站、机场报文、DEB Forecast、市场阈值和源头刷新状态放到同一个工作流里,服务温度市场的日内判断。",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "产品定位",
|
||||
text: "先确认结算源和实测更新,再比较 DEB 与市场信号。市场价格是判断层,不替代真实结算站点或跑道观测。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "core-modules",
|
||||
title: "你会在页面上看到什么",
|
||||
id: "current-terminal",
|
||||
title: "当前工作台有哪些",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["锚点状态:先确认当前机场主站实测、日内已见高点和结算时钟。", "当前节奏:把“此刻应到温度”和“机场实测”放在一张卡里,判断今天跑得快还是慢。", "专业气象结论条:先给今日主判断、置信度、基准/上修/下修路径和下一观测点。", "城市决策卡:从地图进入城市简报,读取结构化实况、最高温中枢、市场温度桶和模型-市场差。", "校准模型概率 / 模型区间与分歧:概率层看当前生产概率引擎输出;EMOS / LGBM 只有在评估通过或 shadow 对照时进入解释层,模型区间用于解释分歧。", "气象证据链 / 失效条件 / 确认条件:解释为什么这么判断,以及什么情况会让判断降级。"] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"天气决策 / 训练数据 / 使用指南:左侧导航对应当前主工作台的三个入口。",
|
||||
"1-9 个图表槽位:可用 1x1 到 3x3 布局同时观察多个城市,并在每个槽位切换城市。",
|
||||
"实测锚点:优先显示结算跑道、官方站或关键机场报文,并展示当前温度、当日已见高点和更新时间。",
|
||||
"DEB Forecast:橙色路径用于判断后续升温或降温空间,以及峰值窗口附近是否偏离主路径。",
|
||||
"市场信号:阈值、价格、流动性和优势用于交易判断,但要先由天气证据确认。",
|
||||
"训练数据:用于复盘 DEB 和概率引擎近期表现,帮助判断哪些城市当前更可靠。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "how-to-read",
|
||||
id: "quick-read",
|
||||
title: "如何快速读懂主站",
|
||||
blocks: [
|
||||
{ type: "steps", items: ["先看专业气象结论条或城市决策卡,确认今日主判断、最高温中枢和下一观测点。", "再看锚点状态和今日气温预测图,确认机场实测、DEB、峰值窗口和关键档位线。", "接着看气象证据链、失效条件和确认条件,判断这个路径有没有被新观测破坏。", "最后看校准模型概率、模型区间、市场温度桶和模型-市场差,判断概率是否已经被市场充分计价。"] },
|
||||
{
|
||||
type: "steps",
|
||||
items: [
|
||||
"先选择区域和城市,把重点城市放进图表槽位。",
|
||||
"先看青绿色实测锚点,确认当前温度、当日最高和数据新鲜度。",
|
||||
"再看 DEB Forecast 与高温 / 全天视图,判断剩余升温空间和峰值窗口。",
|
||||
"最后打开模型线、跑道明细或市场信号,确认分歧来自天气还是价格。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "Introduction",
|
||||
description: "The PolyWeather docs explain the product's core concepts, settlement logic, intraday meteorology, calibrated probability, and model stack so users can turn airport anchors, official nearby networks, and model context into actionable decisions.",
|
||||
description: "PolyWeather is a settlement-source-first weather decision terminal for reading live observations, the DEB path, market signals, and settlement rules.",
|
||||
sections: [
|
||||
{
|
||||
id: "what-is-polyweather",
|
||||
title: "What PolyWeather is",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "PolyWeather is not a generic weather app. It is a settlement-source-first live terminal for temperature markets, built to estimate the likely high-temperature bucket, explain whether the airport or official settlement site may get capped, and surface whether the market is mispricing that outcome." },
|
||||
{ type: "callout", tone: "info", title: "Product focus", text: "The core value is not raw weather reporting. It is the conversion of models, airport-primary observations, official nearby networks, airport forecasts, and settlement rules into usable trading context. Confirm the settlement source first, then read DEB, market buckets, and calibrated probability." },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "PolyWeather is not a general weather app or a raw forecast page. It puts settlement runways, official stations, airport reports, DEB Forecast, market thresholds, and source freshness into one workflow for intraday temperature-market decisions.",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "Product focus",
|
||||
text: "Confirm the settlement source and live update state first, then compare DEB with market signals. Market price is a decision layer, not a replacement for the actual settlement station or runway observation.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "core-modules",
|
||||
title: "What you see on the site",
|
||||
id: "current-terminal",
|
||||
title: "What the current terminal exposes",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Anchor status: current airport-primary observation, day-high-so-far, and the settlement clock.", "Current pace: compares where the airport should be by now versus the actual observation.", "Professional meteorology read: headline, confidence, base/upside/downside path, and next observation point.", "City decision cards: map-launched city briefs with the AI airport read, expected-high center, market bucket, and model-market difference.", "Calibrated model probability / model spread: probability comes from the calibrated engine; spread explains model disagreement.", "Evidence chain / failure modes / confirmation: why the read is valid and what would downgrade it."] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"Weather Decisions / Training / Guide: the three entries in the current left-side terminal navigation.",
|
||||
"1-9 chart slots: use 1x1 through 3x3 layouts to monitor several cities and switch each slot independently.",
|
||||
"Live anchors: settlement runway, official station, or useful airport report, with current temperature, day high, and freshness.",
|
||||
"DEB Forecast: the orange path for remaining upside or downside and for checking the peak window.",
|
||||
"Market signals: thresholds, price, liquidity, and edge support trade decisions after the weather evidence is checked.",
|
||||
"Training data: recent DEB and probability-engine performance for judging which cities are more reliable now.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "how-to-read",
|
||||
title: "How to read the dashboard quickly",
|
||||
id: "quick-read",
|
||||
title: "How to read the terminal quickly",
|
||||
blocks: [
|
||||
{ type: "steps", items: ["Start with the professional meteorology read or city decision card: headline, expected-high center, base path, and next observation point.", "Use anchor status and the intraday chart to check current observations, DEB, peak window, and key bucket lines.", "Read the AI airport read, evidence chain, failure modes, and confirmation rules to see whether the path is still valid.", "Then compare calibrated probability, model spread, market bucket, and model-market difference."] },
|
||||
{
|
||||
type: "steps",
|
||||
items: [
|
||||
"Pick a region and city, then place the important cities into chart slots.",
|
||||
"Start with the teal live anchor: current temperature, day high, and freshness.",
|
||||
"Compare DEB Forecast in Peak / All Day views to judge remaining move and peak-window risk.",
|
||||
"Use model lines, runway detail, or market signals last to separate weather disagreement from price disagreement.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -103,70 +155,134 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "如何读 PolyWeather 图表",
|
||||
description: "这页解释终端温度图的图层、默认显示策略、高级气象变量和常见误读。",
|
||||
description: "这页解释当前温度图的阅读顺序、图层、视图模式和常见误读。",
|
||||
sections: [
|
||||
{
|
||||
id: "read-order",
|
||||
title: "先读什么",
|
||||
blocks: [
|
||||
{ type: "steps", items: ["先看结算源实测:机场主站、官方站或跑道端点是不是还在更新。", "再看 DEB Forecast:它是今日最高温路径,不是已经发生的实测。", "然后看结算跑道或官方增强站网:判断当前实测是否领先、滞后或被压温。", "最后看市场桶和校准概率:判断天气概率是否已经被价格充分反映。"] },
|
||||
{
|
||||
type: "steps",
|
||||
items: [
|
||||
"先看结算源实测:结算跑道、官方站或当前更可靠的机场报文是不是还在更新。",
|
||||
"再看 DEB Forecast:它是融合模型和日内修正后的预期路径,不是已经发生的实测。",
|
||||
"切换高温 / 全天:高温视图聚焦峰值窗口,全天视图用于复盘完整日内走势。",
|
||||
"最后看图层和市场信号:只有当实测与 DEB 或价格明显分叉时,才需要打开更多辅助层。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "layers",
|
||||
title: "图层怎么理解",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["结算源或参考站:默认显示,代表当前最接近结算口径的实测曲线。", "跑道明细:AMSC / AMOS 城市默认展示结算跑道,并允许展开辅助跑道作为空间背景。", "METAR / 官方增强层:用于确认机场或官方站状态,不替代明确命名的结算站。", "DEB Forecast:模型融合路径,帮助判断未来峰值窗口。", "原始模型曲线:默认隐藏,用户需要解释模型分歧时再打开。"] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"实测 / 结算线:默认优先展示,代表最接近结算口径的实时温度。",
|
||||
"DEB Forecast:橙色预测路径,重点看它和实测线在峰值窗口前后的差距。",
|
||||
"机场报文:METAR / MGM 等作为机场站参考,只在适合的城市默认显示。",
|
||||
"模型线:ECMWF、GFS、ICON、GEM 等提供背景,默认作为辅助判断。",
|
||||
"跑道明细:打开后可看各跑道传感器;关闭后仍保留结算跑道或主参考站。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "advanced-variables",
|
||||
title: "高级气象变量",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "风速、风向、露点、湿度、气压这些变量对解释压温、海风、云雨和边界层结构很有用,但它们不是温度结算曲线。" },
|
||||
{ type: "callout", tone: "info", title: "默认隐藏", text: "高级气象变量默认隐藏,只作为上下文辅助展示。这样详情面板不会继续变重,用户需要时再展开查看。" },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "风速、风向、露点、湿度和气压用于解释压温、海风、云雨和边界层变化,但它们不是温度结算曲线。",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "默认隐藏",
|
||||
text: "高级气象变量默认隐藏,只在需要解释结构变化时作为上下文展开,避免主图被非温度曲线挤占。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "common-misreads",
|
||||
title: "常见误读",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["不要把概率温度带当成实测曲线。概率层只是解释概率中心和温度区间,不代表某个时刻的温度。", "不要把城区体感当成结算温度。机场市场先看机场主站或规则指定站。", "不要用固定 1 分钟预期要求所有城市。图表更新频率取决于源头原生频率。"] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"不要把概率温度带当成实测曲线。概率层用于市场判断和后台分析,不代表某一刻真实温度。",
|
||||
"不要把市场信号当成结算温度。结算仍然看规则指定的站点或跑道。",
|
||||
"不要要求所有城市固定 1 分钟刷新。图表更新频率取决于源头原生频率和当前可用数据。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "How To Read PolyWeather Charts",
|
||||
description: "A practical guide to the terminal chart layers, default visibility, advanced weather variables, and common misreads.",
|
||||
description: "A guide to the current temperature chart reading order, layers, view modes, and common misreads.",
|
||||
sections: [
|
||||
{
|
||||
id: "read-order",
|
||||
title: "Read order",
|
||||
blocks: [
|
||||
{ type: "steps", items: ["Start with the settlement-source observation: airport primary, official station, or runway endpoint.", "Read DEB Forecast next. It is the expected high-temperature path, not an observation that already happened.", "Use runway or official network overlays to decide whether the live path is leading, lagging, or capped.", "Then compare market buckets and calibrated probability to see whether the weather path is already priced."] },
|
||||
{
|
||||
type: "steps",
|
||||
items: [
|
||||
"Start with the settlement-source observation: settlement runway, official station, or the most useful airport report.",
|
||||
"Read DEB Forecast next. It is the model-and-intraday adjusted path, not an observation that already happened.",
|
||||
"Switch Peak / All Day views: Peak focuses the payoff window, while All Day reviews the full intraday path.",
|
||||
"Use layers and market signals last, mainly when live observations diverge from DEB or price.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "layers",
|
||||
title: "Chart layers",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Settlement or reference station: visible by default and closest to the settlement rule.", "Runway details: AMSC / AMOS cities show the settlement runway by default, with auxiliary runways available as spatial context.", "METAR / official enhancement: confirmation context for the airport or official station, not a replacement for an explicitly named settlement site.", "DEB Forecast: blended forecast path for the peak window.", "Raw model curves: hidden by default and opened only when users need model-spread context."] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"Live / settlement line: visible by default and closest to the settlement rule.",
|
||||
"DEB Forecast: orange forecast path; focus on its gap versus live observations near the peak window.",
|
||||
"Airport reports: METAR / MGM are airport references and are auto-shown only where useful.",
|
||||
"Model lines: ECMWF, GFS, ICON, GEM, and related layers provide background context.",
|
||||
"Runway detail: enable it to inspect runway sensors; disabling it still keeps the settlement runway or primary reference.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "advanced-variables",
|
||||
title: "Advanced weather variables",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "Wind speed, wind direction, dew point, humidity, and pressure help explain suppression, sea-breeze timing, cloud/rain risk, and boundary-layer structure, but they are not settlement-temperature curves." },
|
||||
{ type: "callout", tone: "info", title: "Hidden by default", text: "Advanced variables stay hidden by default and are shown as context only. This keeps the detail panel readable while still making the extra variables available when needed." },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Wind speed, wind direction, dew point, humidity, and pressure help explain suppression, sea-breeze timing, cloud/rain risk, and boundary-layer structure, but they are not settlement-temperature curves.",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "Hidden by default",
|
||||
text: "Advanced variables stay hidden by default and appear only as context when structure needs explanation, so the main chart is not crowded by non-temperature lines.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "common-misreads",
|
||||
title: "Common misreads",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Do not read probability bands as observation curves. They explain probability center and bucket context, not a timestamped temperature.", "Do not trade broad city feel as settlement temperature. Airport markets start with the airport primary or named settlement site.", "Do not expect every city to update every minute. Refresh cadence follows the source's native cadence."] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"Do not read probability bands as observation curves. Probability supports market analysis and background scoring, not timestamped live temperature.",
|
||||
"Do not treat market signals as settlement temperature. Settlement still comes from the station or runway named by the rule.",
|
||||
"Do not expect every city to update every minute. Refresh cadence follows source-native frequency and current data availability.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -179,366 +295,98 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "实时数据频率",
|
||||
description: "这页解释为什么不同城市刷新频率不同,以及网站、Telegram 和 SSE patch 之间的关系。",
|
||||
description: "这页解释为什么不同城市刷新频率不同,以及网站、缓存和 SSE patch 之间的关系。",
|
||||
sections: [
|
||||
{
|
||||
id: "source-cadence",
|
||||
title: "按源频率采集",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["AMOS 60s:首尔、釜山跑道传感器。", "AMSC 180s:中国内地跑道端点观测城市。", "MADIS 300s:美国高频机场观测城市。", "CoWIN 60s:香港 6087 参考站。", "HKO 600s:香港天文台官方 10 分钟层。", "CWA / JMA / FMI / KNMI / MGM:按各自官方或可用频率采集。"] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"AMOS 60s:首尔、釜山等韩国跑道传感器。",
|
||||
"AMSC 180s:中国内地跑道端点观测城市。",
|
||||
"MADIS 300s:美国高频机场观测城市。",
|
||||
"CoWIN 60s:香港 6087 参考站。",
|
||||
"HKO 600s:香港天文台官方 10 分钟层。",
|
||||
"CWA / JMA / FMI / KNMI / MGM:按各自官方或可用频率采集。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pipeline",
|
||||
title: "网站怎么更新",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "网站不是靠 Telegram 推送循环刷新图表。独立 observation collector 按源频率采集,写缓存、DB 或 Redis,再通过 SSE patch 把 city_observation_patch.v1 推给前端。" },
|
||||
{ type: "callout", tone: "info", title: "SSE patch", text: "前端先读取完整 snapshot,之后只合并 SSE patch。只有可见图表长时间没有 patch 时,才做轻量兜底刷新。" },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "观测 collector 按源头原生频率采集,并写入缓存或数据库。前端先读取完整快照,之后通过 SSE patch 合并 city_observation_patch.v1 增量更新。",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "SSE patch",
|
||||
text: "可见图表会订阅实时 patch;如果一段时间没有收到 patch,才做轻量兜底刷新,避免多个入口同时强刷同一外部源。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "telegram",
|
||||
title: "Telegram 和网站的关系",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "Telegram 只读最新缓存或 DB,不再主动强制刷新观测源。除非完全没有缓存,才允许非强制兜底分析。" },
|
||||
{ type: "paragraph", text: "Web、collector、Telegram 共享 per-city/source singleflight 和冷却,避免同时打同一个外部源。" },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Telegram 默认读取最新缓存或数据库,不主动强制刷新观测源。只有完全没有缓存时,才允许兜底分析。",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "Realtime Source Cadence",
|
||||
description: "Why cities update at different speeds, and how the website, Telegram, and SSE patches relate to each other.",
|
||||
description: "Why cities update at different speeds, and how the website, cache, and SSE patches relate to each other.",
|
||||
sections: [
|
||||
{
|
||||
id: "source-cadence",
|
||||
title: "Source-native cadence",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["AMOS 60s: Seoul and Busan runway sensors.", "AMSC 180s: mainland China runway endpoint observations.", "MADIS 300s: US high-frequency airport observations.", "CoWIN 60s: Hong Kong 6087 reference station.", "HKO 600s: Hong Kong Observatory official 10-minute layer.", "CWA / JMA / FMI / KNMI / MGM: collected at their own official or available cadence."] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"AMOS 60s: Korean runway sensors such as Seoul and Busan.",
|
||||
"AMSC 180s: mainland China runway endpoint observations.",
|
||||
"MADIS 300s: US high-frequency airport observations.",
|
||||
"CoWIN 60s: Hong Kong 6087 reference station.",
|
||||
"HKO 600s: Hong Kong Observatory official 10-minute layer.",
|
||||
"CWA / JMA / FMI / KNMI / MGM: collected at each source's official or available cadence.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pipeline",
|
||||
title: "How the site updates",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "The website does not rely on the Telegram push loop to refresh charts. The independent observation collector samples each source at its own cadence, writes cache, DB, or Redis, then sends city_observation_patch.v1 to the frontend through SSE patch." },
|
||||
{ type: "callout", tone: "info", title: "SSE patch", text: "The frontend reads a full snapshot first, then merges SSE patches. Only visible charts use a lightweight fallback refresh if patches stop for too long." },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The observation collector samples each source at its native cadence and writes cache or database state. The frontend reads a full snapshot first, then merges city_observation_patch.v1 updates through SSE patch.",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "SSE patch",
|
||||
text: "Visible charts subscribe to live patches. If patches stop for a while, the chart can make a lightweight fallback refresh instead of making every entry point force-refresh the same external source.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "telegram",
|
||||
title: "How Telegram relates",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "Telegram reads the latest cache or DB state and no longer force-refreshes observation sources. It only falls back to a non-forced panel analysis when no cache exists at all." },
|
||||
{ type: "paragraph", text: "Web, collector, and Telegram share per-city/source singleflight and cooldown gates so they do not hit the same external source at the same time." },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "intraday-signal",
|
||||
group: "analysis",
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "今日日内分析",
|
||||
description: "这页解释今日日内分析如何从气象主判断、证据链、失效条件、TAF 和校准概率组成一套付费判断台。",
|
||||
sections: [
|
||||
{
|
||||
id: "professional-read",
|
||||
title: "顶部结论条怎么读",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "顶部结论条先给主判断和置信度,再给基准、上修、下修路径与下一观测点。它的作用是让用户先知道今天应重点验证哪条路径,而不是先被概率条和市场价格带偏。" },
|
||||
{ type: "callout", tone: "info", title: "刷新状态", text: "如果完整 detail 或 market scan 仍在同步,日内弹窗会先显示刷新锁,旧内容会降权且不可交互,避免用户把上一轮缓存当成当前结论。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "surface-vs-upper",
|
||||
title: "近地面信号和高空结构信号的区别",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "近地面信号主要来自小时级温度、露点、气压、风向、降水概率和云量变化。它回答的是:在当前到峰值窗口这几个小时里,地面结构更支持继续升温,还是更容易被压住。" },
|
||||
{ type: "paragraph", text: "高空结构信号主要来自高空派生字段、机场 TAF 与市场侧信息的综合判断。它回答的是:峰值窗口附近,高空和机场侧有没有新的扰动把最高温封顶。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "peak-window",
|
||||
title: "为什么总在讲峰值窗口",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "PolyWeather 不按固定下午时段做判断,而是尽量围绕当天预计最高温兑现的窗口来分析。这样不同城市的峰值时间差异才不会被硬套成同一套模板。" },
|
||||
{ type: "callout", tone: "success", title: "窗口感知", text: "页面里的“今日 12:00-16:00(约 5 小时,围绕峰值窗口)”就是在提示当前结构判断真正关注的时段。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trade-language",
|
||||
title: "交易语言怎么读",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["偏支持:结构仍支持继续升温,别太早押高温见顶。", "偏压制:高温继续上冲的把握不大,别盲目追热。", "先观察:现在还看不出明确方向,先等下一步走势确认。"] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "Intraday Analysis",
|
||||
description: "This page explains how intraday analysis combines the meteorology headline, evidence chain, failure modes, TAF, and calibrated probability into a paid decision workspace.",
|
||||
sections: [
|
||||
{
|
||||
id: "professional-read",
|
||||
title: "How to read the top read",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "The top read gives the headline and confidence first, then the base, upside, downside path, and next observation point. Its job is to tell users what path to verify before they look at probability bars or market prices." },
|
||||
{ type: "callout", tone: "info", title: "Refresh state", text: "If full detail or market scan is still syncing, the intraday modal shows a refresh lock first. Old content is de-emphasized and non-interactive so users do not treat stale cached data as the current read." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "surface-vs-upper",
|
||||
title: "Surface versus upper-air structure",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "The surface layer comes from hourly temperature, dew point, pressure, wind, precipitation probability, and cloud-cover changes. It answers a near-term question: between now and the peak window, does the local surface setup still support more warming or does it look easier to cap?" },
|
||||
{ type: "paragraph", text: "The upper-air layer combines derived profile signals, airport TAF, and market-side context. It answers a different question: around the peak window, is there a new airport-side or upper-air disturbance that could lock the high in place?" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "peak-window",
|
||||
title: "Why everything is framed around the peak window",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "PolyWeather does not force every city into the same afternoon template. It centers the analysis on the expected high-temperature payoff window for that city on that day, so different cities are not interpreted through the wrong hours." },
|
||||
{ type: "callout", tone: "success", title: "Window-aware reading", text: "When you see a line such as “12:00-16:00 (~5h, around the peak window)”, that is the actual window driving the current structural read." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trade-language",
|
||||
title: "How to read the trading language",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Supportive: the setup still supports more warming. Do not call the high too early.", "Suppressive: further upside looks less reliable. Do not chase the high blindly.", "Wait / confirm: the setup is still mixed. Let the next move decide first."] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "city-decision-cards",
|
||||
group: "analysis",
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "城市决策卡",
|
||||
description: "这页解释地图城市决策卡如何把结构化实况、最高温中枢、市场温度桶和模型-市场差组合成可验证判断。",
|
||||
sections: [
|
||||
{
|
||||
id: "entry-and-permission",
|
||||
title: "从地图进入决策卡",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "用户可以从地图点击城市进入城市决策卡。机会榜和日历属于 Pro 能力;地图探索和城市简报仍可作为轻量入口使用。" },
|
||||
{ type: "callout", tone: "info", title: "先天气、后市场", text: "决策卡顶部的天气判断层不读取市场价格,先用结构化实况、DEB 和多模型集合确定最高温中枢,再把该中枢映射到市场温度桶。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "structured-observations",
|
||||
title: "结构化实况包括什么",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["实测锚点:当前温度、当日已见高点、观测时间和数据新鲜度。", "模型区间:DEB、多模型范围,以及模型是否明显分散。", "日内节奏:把实测路径、峰值窗口和目标温度桶放在一起对比。", "市场映射:把最高温中枢映射到 YES/NO 温度桶,并计算模型-市场差。"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "market-layer",
|
||||
title: "信号价格层怎么读",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "温度桶标签来自完整信号桶列表,并会按 label / slug / question 识别 exact、or higher、or lower、range 等方向,避免把 30.5°C 错配到不合理的 16°C 或反向尾部桶。" },
|
||||
{ type: "callout", tone: "info", title: "模型-信号差", text: "模型-信号差 = 模型概率 − 信号隐含概率。正数表示天气概率高于信号报价;负数表示信号已经把该 YES 计价得更充分。它不是温度变化,也不是收益率。" },
|
||||
{ type: "paragraph", text: "YES 买入价以可执行报价为主;没有可靠模型概率或 YES 价格时,决策卡会显示报价已匹配但暂不计算模型-信号差。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cache-behavior",
|
||||
title: "为什么切换选项卡后不应重新空白加载",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "城市决策卡复用城市详情、市场扫描和图表数据缓存,不再单独请求 AI 解读。" },
|
||||
{ type: "bullets", items: ["城市详情缓存:保存实况、模型、概率和结算上下文。", "市场扫描缓存:完整 all_buckets 结果按城市和日期缓存,默认 TTL 为 10 分钟。", "前端图表缓存:切换城市或选项卡时优先复用已加载的结构化数据。"] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "City Decision Cards",
|
||||
description: "How the city card combines structured observations, expected-high center, market bucket mapping, and model-market difference into a verifiable decision.",
|
||||
sections: [
|
||||
{
|
||||
id: "entry-and-permission",
|
||||
title: "Opening a card from the map",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "Users can click a city on the map to open its city decision card. The opportunity board and calendar are Pro surfaces; map exploration and city briefs remain the lightweight entry point." },
|
||||
{ type: "callout", tone: "info", title: "Weather first, market second", text: "The weather decision layer does not use market price input. It first sets the expected-high center from structured observations, DEB, and the model cluster, then maps that center to the relevant market bucket." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "structured-observations",
|
||||
title: "What structured observations contain",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Observation anchor: current temperature, daily high so far, observation time, and freshness.", "Model range: DEB, multi-model range, and whether the model cluster is dispersed.", "Intraday pace: live path, peak window, and target temperature bucket in one comparison.", "Market mapping: maps the expected-high center to YES/NO buckets and calculates the model-market difference."] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "market-layer",
|
||||
title: "How to read the signal layer",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "Bucket labels come from the full signal bucket list. The card reads label / slug / question text to distinguish exact, or-higher, or-lower, and range buckets, so a 30.5°C weather center is not matched to an unreasonable 16°C or reverse-tail bucket." },
|
||||
{ type: "callout", tone: "info", title: "Model-signal difference", text: "Model-signal difference = model probability minus signal-implied probability. A positive value means the weather probability is above signal pricing; a negative value means the YES is already priced more fully by the signal. It is not a temperature delta or return." },
|
||||
{ type: "paragraph", text: "YES buy uses executable quote data when available. If either model probability or YES price is incomplete, the card shows the quote match but withholds the model-signal difference." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cache-behavior",
|
||||
title: "Why tab switching should not blank the card",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "The card reuses city detail, market scan, and chart-data caches. It no longer makes a separate AI-read request." },
|
||||
{ type: "bullets", items: ["City detail cache: stores observations, models, probabilities, and settlement context.", "Market-scan cache: stores full all_buckets results by city and date for 10 minutes by default.", "Frontend chart cache: reuses already loaded structured data when switching cities or tabs."] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "model-stack-deb",
|
||||
group: "analysis",
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "模型栈与 DEB",
|
||||
description: "这页说明 PolyWeather 当前接入哪些开放模型,不同地区为什么看到的模型不一样,以及 DEB 如何避免重复计权。",
|
||||
sections: [
|
||||
{
|
||||
id: "model-sources",
|
||||
title: "当前接入的开放模型",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "PolyWeather 的多模型层通过 Open-Meteo 模型接口接入开放 NWP / AIFS 等预报模型。这里的“来源 Open-Meteo”表示接入接口,不表示 Open-Meteo 是单一模型,也不替代 ECMWF、DWD、ECCC、NOAA、JMA 等机构来源。" },
|
||||
{ type: "bullets", items: ["ECMWF IFS:全球传统数值模式。", "ECMWF AIFS:ECMWF AIFS 模型,作为独立 AIFS 路径保留。", "DWD ICON:全球 ICON 基准层。", "DWD ICON-EU:欧洲区域高分辨率层。", "DWD ICON-D2:欧洲短时高分辨率层。", "ECCC GEM / GDPS:加拿大系全球模式。", "ECCC RDPS / HRDPS:北美区域与短时高分辨率层。", "GFS / JMA:继续作为全球参考模型保留。"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "regional-coverage",
|
||||
title: "为什么欧洲、亚洲、美国城市看到的模型不一样",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "模型覆盖域不同,所以同一套请求在不同坐标上返回的模型也不同。区域模型不覆盖某个城市时,不会返回空值,也不会在前端显示。" },
|
||||
{ type: "bullets", items: ["欧洲城市:通常会看到 ECMWF / AIFS / GFS / ICON / ICON-EU / ICON-D2 / GEM 或 GDPS / JMA。欧洲高分辨率重点来自 DWD ICON-EU 和 ICON-D2。", "北美城市:通常会看到 ECMWF / AIFS / GFS / ICON / GEM / GDPS / RDPS / HRDPS / JMA,并继续叠加 NWS。北美高分辨率重点来自 ECCC RDPS 和 HRDPS。", "亚洲城市:通常以 ECMWF / AIFS / GFS / ICON / GEM 或 GDPS / JMA 为主,通常不会有 ICON-EU、ICON-D2、RDPS、HRDPS;亚洲城市更依赖本地官方站、METAR、TAF、JMA、KMA、NMC、HKO、CWA 等观测增强层。"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deb-dedup",
|
||||
title: "DEB 如何处理新增模型",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "DEB 不会把所有新模型按“每个模型一票”直接等权加入。否则 ICON / ICON-EU / ICON-D2 会让 DWD 模型家族被重复放大,GEM / GDPS / RDPS / HRDPS 也会让加拿大模型家族被重复放大。" },
|
||||
{ type: "bullets", items: ["ICON / ICON-EU / ICON-D2 归为 DWD ICON 家族,优先级为 ICON-D2 > ICON-EU > ICON。", "GEM / GDPS / RDPS / HRDPS 归为 ECCC GEM 家族,优先级为 HRDPS > RDPS > GDPS > GEM。", "ECMWF IFS 与 ECMWF AIFS 分开保留,因为一个是传统数值模式,一个是 AIFS 模型。", "GFS、JMA、MGM、NWS、LGBM、Open-Meteo 等保持独立路径。", "DEB 权重信息中出现“家族去重”时,表示系统已经先折叠同家族模型,再进行历史 MAE 倒数加权。"] },
|
||||
{ type: "callout", tone: "info", title: "产品含义", text: "新增模型提升的是区域代表性和解释力,不是让某个地区因为模型数量更多就天然拥有更高权重。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "display",
|
||||
title: "网页上如何展示",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "网页的“模型区间与分歧”会按全球基准、AIFS 模型、欧洲高分辨率、北美高分辨率分组显示,并展示可用模型数量、模型分歧、模型机构、接入接口、模型名称、分辨率和预报时效。没有覆盖的区域模型不会显示。" },
|
||||
{ type: "callout", tone: "info", title: "概率怎么读", text: "模型票数只解释哪些模型支持某个温度档,不等于最终概率。最终概率优先读取校准模型概率;有 LGBM 时展示 LGBM 校准概率,市场价格只作为参考。" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "Model Stack & DEB",
|
||||
description: "This page explains which open models PolyWeather uses, why model coverage differs by region, and how DEB avoids duplicate family weighting.",
|
||||
sections: [
|
||||
{
|
||||
id: "model-sources",
|
||||
title: "Open models currently integrated",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "PolyWeather's multi-model layer uses the Open-Meteo model API to integrate open NWP and AIFS model forecasts. The label “source: Open-Meteo” means the integration API, not a single model source, and it does not replace ECMWF, DWD, ECCC, NOAA, or JMA attribution." },
|
||||
{ type: "bullets", items: ["ECMWF IFS: global traditional NWP.", "ECMWF AIFS: ECMWF AIFS model, kept as a separate AIFS path.", "DWD ICON: global ICON baseline.", "DWD ICON-EU: European regional high-resolution layer.", "DWD ICON-D2: European short-range high-resolution layer.", "ECCC GEM / GDPS: Canadian global model family.", "ECCC RDPS / HRDPS: North American regional and short-range high-resolution layers.", "GFS / JMA: retained as global reference models."] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "regional-coverage",
|
||||
title: "Why Europe, Asia, and US cities show different models",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "Model domains differ, so the same request can return different model fields depending on the city coordinates. If a regional model does not cover a city, it is omitted rather than shown as an empty value." },
|
||||
{ type: "bullets", items: ["European cities usually show ECMWF / AIFS / GFS / ICON / ICON-EU / ICON-D2 / GEM or GDPS / JMA. The high-resolution European layer comes mainly from DWD ICON-EU and ICON-D2.", "North American cities usually show ECMWF / AIFS / GFS / ICON / GEM / GDPS / RDPS / HRDPS / JMA, plus existing NWS context. The high-resolution North American layer comes mainly from ECCC RDPS and HRDPS.", "Asian cities usually rely on ECMWF / AIFS / GFS / ICON / GEM or GDPS / JMA. ICON-EU, ICON-D2, RDPS, and HRDPS usually do not cover Asia, so Asian reads lean more on local official stations, METAR, TAF, JMA, KMA, NMC, HKO, CWA, and other observation enhancement layers."] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deb-dedup",
|
||||
title: "How DEB handles the new models",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "DEB does not treat every new model as one independent vote. Otherwise ICON / ICON-EU / ICON-D2 would over-amplify the DWD family, and GEM / GDPS / RDPS / HRDPS would over-amplify the Canadian family." },
|
||||
{ type: "bullets", items: ["ICON / ICON-EU / ICON-D2 are collapsed into one DWD ICON family, with priority ICON-D2 > ICON-EU > ICON.", "GEM / GDPS / RDPS / HRDPS are collapsed into one ECCC GEM family, with priority HRDPS > RDPS > GDPS > GEM.", "ECMWF IFS and ECMWF AIFS are kept separate because one is traditional NWP and the other is the AIFS model.", "GFS, JMA, MGM, NWS, LGBM, and Open-Meteo remain independent paths.", "When the DEB weight string includes “family deduplication” or “家族去重”, the system has collapsed same-family models before applying historical inverse-MAE weighting."] },
|
||||
{ type: "callout", tone: "info", title: "Product meaning", text: "The new models improve regional representativeness and explanation quality. They do not let a region gain extra weight simply because more related model variants exist there." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "display",
|
||||
title: "How the site displays this",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "The Model Range & Spread panel groups the model stack into Global Baseline, AIFS Model, Europe High-resolution, and North America High-resolution. It shows available model count, spread, model agency, API, model name, resolution, and forecast horizon. Regional models outside their domain are simply not shown." },
|
||||
{ type: "callout", tone: "info", title: "How to read probability", text: "Model vote counts explain which models round into a bucket; they are not the final probability. The final probability should come from the calibrated engine. When LGBM is available, the site labels it as LGBM-calibrated probability, while market price remains only a reference." },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "taf-signal",
|
||||
group: "analysis",
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "TAF 信号",
|
||||
description: "TAF 不是结算温度,但它能告诉你机场侧在峰值窗口前后会不会有云雨、雷暴或风向切换,把最高温压住。",
|
||||
sections: [
|
||||
{
|
||||
id: "what-taf-does",
|
||||
title: "TAF 在 PolyWeather 里负责什么",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "TAF 在项目里是机场侧确认层,而不是温度主预测曲线。它主要补三类信息:峰值窗口有没有云雨压温、午后扰动是不是正在增强、机场风向是否发生阶段性切换。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "taf-periods",
|
||||
title: "图上的 TAF 时段是什么意思",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["基础时段(BASE):TAF 的默认主背景天气。", "明确切换(FM):从某个时刻开始,机场预报切换到一套新天气状态。", "临时波动(TEMPO):一段时间内可能临时出现扰动,但不代表主背景永久改变。", "逐步转变(BECMG):天气不是一下子切,而是在一段时间里渐变。", "30% / 40% 风险窗(PROB30/40):风险有概率出现,不代表一定发生。"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "airport-suppression",
|
||||
title: "什么叫机场端压温风险偏高",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "它的意思不是整座城市一定更冷,而是作为结算依据的机场站点,在峰值窗口里更可能因为云、阵雨或雷暴扰动,冲不到本来可能达到的更高温度。" },
|
||||
{ type: "callout", tone: "warning", title: "重点区别", text: "TAF 负责告诉你机场侧未来几个小时会不会出现压温扰动,不直接等于结算温度本身。结算仍然看实际结算站点读数;页面上的官方增强站网只负责领先、偏移和空间分布判断,不会替代机场主站或官方结算站本身。" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "TAF Signal",
|
||||
description: "TAF is not the settlement temperature itself, but it is useful for telling you whether the airport side may see clouds, showers, thunderstorms, or wind shifts that cap the high around the payoff window.",
|
||||
sections: [
|
||||
{
|
||||
id: "what-taf-does",
|
||||
title: "What TAF does inside PolyWeather",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "Within the product, TAF acts as an airport-side confirmation layer rather than the main temperature curve. Its job is to tell you whether clouds/rain may suppress the airport high, whether afternoon disruption is building, and whether the airport wind regime is about to shift in stages." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "taf-periods",
|
||||
title: "What the TAF timing labels mean",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Base regime: the default background forecast segment.", "Hard shift (FM): a new weather regime begins from an explicit time.", "Temporary swing (TEMPO): a temporary disturbance window that does not replace the background regime permanently.", "Gradual shift (BECMG): conditions transition across a window instead of flipping instantly.", "30% / 40% risk window (PROB30/40): a probabilistic risk window, not a certainty signal."] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "airport-suppression",
|
||||
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 station reading, while the official nearby network is only an enhancement layer for lead/lag and spread, not a replacement anchor." },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Telegram reads the latest cache or database state by default and does not force-refresh observation sources. It only falls back to analysis when no cache exists at all.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -551,54 +399,94 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "结算站点说明",
|
||||
description: "不同城市的结算口径不同。理解结算站点,比单纯看模型曲线更重要。",
|
||||
description: "不同市场的结算口径不同。先确认结算站点,再判断 DEB、实测和市场信号。",
|
||||
sections: [
|
||||
{
|
||||
id: "why-settlement-matters",
|
||||
title: "为什么先看结算站点",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "同样是“城市最高温”,市场真正结算看的往往不是城区平均温度,而是规则指定的机场或官方站点。交易上最常见的错觉,是把城市体感温度当成结算温度。" },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "同样是城市最高温,真正结算可能看机场 METAR、机场主站、结算跑道,也可能看明确指定的官方结算站点。城区更热或体感更热,不一定等于合约会结到更高温桶。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "city-rules",
|
||||
id: "primary-rules",
|
||||
title: "当前主要口径",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["多数机场市场:按机场 METAR 或机场主站实况结算。", "土耳其机场市场:机场主站仍以 METAR 为锚点,同时保留 Turkish MGM 作为领先结构参考。", "中国内地机场市场:机场主站仍以 METAR 为锚点,NMC 当前实况作为官方增强层,不直接替代机场结算站。", "日本 / 韩国机场市场:机场主站仍以 METAR 为锚点,同时可接入 JMA / KMA 官方增强层做领先结构参考。", "Manila、Karachi 等新增机场城市按对应 METAR / 机场主站作为锚点。", "香港 / 流浮山 / 台湾等明确官方站点市场:按规则指定的官方结算站点结算,不能拿机场 TAF 或城区体感替代。"] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"多数机场市场:先看机场 METAR、机场主站或项目标记的机场主参考站。",
|
||||
"跑道城市:优先看结算跑道或项目标记的主跑道端点,同时保留辅助跑道作为空间背景。",
|
||||
"明确官方站点市场:按规则指定的官方结算站点结算,不能用通用机场逻辑替代。",
|
||||
"本地官方增强层:JMA、KMA、NMC、HKO、CWA、MGM 等用于领先结构和交叉验证,是否能做结算锚点取决于合约规则。",
|
||||
"TAF:用于判断机场未来云雨、雷暴或风向变化,不是结算温度本身。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "common-mistakes",
|
||||
title: "最常见的误解",
|
||||
id: "how-to-check",
|
||||
title: "页面上怎么确认",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["TAF 不是结算站点,它只告诉你机场未来有没有压温扰动。", "市场按机场结算时,城区更热不代表市场就该结到更高温桶。", "Wunderground 是历史页面或参考入口,不是物理观测站;机场市场仍以 METAR / 机场主站为锚点。", "官方增强站网是领先参考层,不等于它可以替代机场主站做结算锚点。", "香港、流浮山、台湾等明确官方站点市场,不能简单套用通用机场 TAF / METAR 主链逻辑。"] },
|
||||
{
|
||||
type: "steps",
|
||||
items: [
|
||||
"先看图表里的实测 / 结算线名称和统计条,确认当前主锚点。",
|
||||
"再看更新时间和数据新鲜度,排除停更或滞后源。",
|
||||
"如果打开跑道明细,先读结算跑道,再用辅助跑道判断空间差异。",
|
||||
"最后才把 DEB、市场信号和概率判断叠上去。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "Settlement Stations",
|
||||
description: "Settlement rules differ by city. Understanding the settlement station matters more than staring only at model curves.",
|
||||
description: "Settlement rules differ by market. Confirm the station first, then read DEB, observations, and market signals.",
|
||||
sections: [
|
||||
{
|
||||
id: "why-settlement-matters",
|
||||
title: "Why the settlement station comes first",
|
||||
blocks: [
|
||||
{ type: "paragraph", text: "A market may say “city high”, but the true settlement often comes from a designated airport or official site rather than the broader urban feel. One of the most common mistakes is to trade the city feel instead of the actual settlement station." },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A city-high market may settle from airport METAR, an airport primary site, a settlement runway, or an explicitly named official settlement station. A hotter downtown feel does not automatically mean the contract settles into a warmer bucket.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "city-rules",
|
||||
id: "primary-rules",
|
||||
title: "Current primary rules",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Most airport-linked markets settle on airport METAR or the airport primary observing site.", "Turkish airport markets keep METAR as the airport anchor, with Turkish MGM retained as a leading-structure reference.", "Mainland China airport markets keep METAR as the airport anchor, while NMC current observations act as an official enhancement layer rather than a direct replacement anchor.", "Japanese and Korean airport markets can keep METAR as the anchor while using JMA / KMA nearby-network observations as an official enhancement layer.", "New airport cities such as Manila and Karachi are anchored to their corresponding METAR / airport-primary station.", "Markets with explicitly designated official sites, such as Hong Kong, Lau Fau Shan, and Taiwan station-driven contracts, should be anchored to those official settlement stations rather than generic airport logic."] },
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"Most airport-linked markets: start with airport METAR, the airport primary site, or the project's marked airport reference.",
|
||||
"Runway cities: prioritize the settlement runway or marked primary runway endpoint, while auxiliary runways remain spatial context.",
|
||||
"Explicit official-station markets: settle from the official settlement station named by the rule, not from generic airport logic.",
|
||||
"Local official enhancement layers: JMA, KMA, NMC, HKO, CWA, MGM, and similar sources help with lead/lag and cross-checks; whether they anchor settlement depends on the contract rule.",
|
||||
"TAF: useful for cloud, thunderstorm, or wind-shift risk near the airport, but not the settlement temperature itself.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "common-mistakes",
|
||||
title: "Common mistakes",
|
||||
id: "how-to-check",
|
||||
title: "How to check on the page",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["TAF is not the settlement station. It only tells you whether airport-side suppressive weather may appear.", "If the market settles on an airport site, a hotter downtown feel does not automatically justify a warmer settlement bucket.", "Wunderground is a history/reference page, not a physical station. Airport markets still anchor to METAR or the airport primary observing site.", "The official nearby network is a lead/lag and spread layer. It should not be mistaken for the final settlement anchor unless the market explicitly names that station.", "Hong Kong, Lau Fau Shan, and Taiwan station-driven contracts should not be forced into the generic airport TAF / METAR chain."] },
|
||||
{
|
||||
type: "steps",
|
||||
items: [
|
||||
"Read the live / settlement line name and summary stats to identify the current anchor.",
|
||||
"Check timestamp and freshness so stale sources do not drive the decision.",
|
||||
"When runway detail is enabled, read the settlement runway first and use auxiliary runways for spatial spread.",
|
||||
"Only then add DEB, market signals, and probability context.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -611,7 +499,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "浏览器插件",
|
||||
description: "PolyWeather Side Panel 是一个面向天气交易场景的浏览器侧边栏工具,负责自动识别城市、展示简版走势与城市档案,并把用户导回完整分析页面。",
|
||||
description: "PolyWeather Side Panel 是浏览器侧边栏工具,用于快速识别城市、查看简版走势,并跳回完整天气决策台。",
|
||||
sections: [
|
||||
{
|
||||
id: "extension-install",
|
||||
@@ -621,7 +509,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
type: "link",
|
||||
href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb",
|
||||
label: "打开 Chrome Web Store",
|
||||
caption: "安装插件后,可在侧边栏里快速跳回主站的今日日内分析。",
|
||||
caption: "安装后可在浏览器侧边栏查看简版城市走势,并跳回主站。",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -633,9 +521,9 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
type: "bullets",
|
||||
items: [
|
||||
"自动识别当前页面中的城市,也支持手动切换。",
|
||||
"展示城市档案:结算站点、站点距离、观测更新时间、周边站点数量。",
|
||||
"展示今日日内走势(简版):DEB 走势与机场主站实况 / 官方增强站网对照,可悬停查看时间与温度。",
|
||||
"展示多日最高温预报(简版),并提供一键刷新与跳转主站入口。",
|
||||
"展示城市档案:结算站点、站点距离、观测更新时间和周边站点数量。",
|
||||
"展示简版日内走势:DEB 与机场主站或官方参考站对照,可悬停查看时间与温度。",
|
||||
"展示简版多日最高温预报,并提供刷新和跳回主站入口。",
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -647,10 +535,10 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"`tabs`:用于识别当前活动标签页 URL 并自动匹配城市。",
|
||||
"`tabs`:用于识别当前活动标签页 URL 并匹配城市。",
|
||||
"`storage`:用于保存插件配置与本地缓存,仅存储在本地浏览器。",
|
||||
"`sidePanel`:用于在浏览器侧边栏展示界面。",
|
||||
"插件不要求用户登录,不收集个人身份信息,不上传浏览历史,仅在必要时请求天气接口数据。",
|
||||
"插件不要求用户登录,不收集个人身份信息,不上传浏览历史,仅在需要渲染侧边栏时请求天气接口数据。",
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -661,23 +549,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
blocks: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "插件不承担完整分析体验,也不承载支付链路。复杂结构判断和完整交易语境仍以主站为准。",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "当前定位",
|
||||
text: "插件是“监控 + 基础判断 + 导流回站”的轻量产品,而不是主站的 1:1 复制品。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "extension-forecast",
|
||||
title: "当前多日预报口径",
|
||||
blocks: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "插件的多日预报已改为 DEB 优先显示。只有某一天没有 DEB 值时,才回退到原始的日最高温预报值。",
|
||||
text: "插件不承担完整分析体验,也不承载支付链路。多图表巡检、训练数据、权益状态和完整市场判断仍以主站为准。",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -685,7 +557,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
},
|
||||
"en-US": {
|
||||
title: "Browser Extension",
|
||||
description: "PolyWeather Side Panel is a browser side-panel tool for weather trading workflows. It auto-detects cities, shows compact intraday and city-profile context, and routes users back to the full dashboard.",
|
||||
description: "PolyWeather Side Panel is a browser side-panel tool for quick city detection, compact chart context, and returning to the full weather decision terminal.",
|
||||
sections: [
|
||||
{
|
||||
id: "extension-install",
|
||||
@@ -695,7 +567,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
type: "link",
|
||||
href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb",
|
||||
label: "Open Chrome Web Store",
|
||||
caption: "Once installed, the side panel can route users back into the main intraday analysis.",
|
||||
caption: "After installation, the side panel can show compact city context and route back to the main site.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -708,8 +580,8 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
items: [
|
||||
"Auto-detects the current page city, with manual switching also available.",
|
||||
"Shows a city profile with settlement station, station distance, observation timestamp, and nearby station count.",
|
||||
"Shows a compact intraday chart with DEB versus airport-primary observations and official nearby-network observations, including hoverable time and temperature.",
|
||||
"Shows a compact multi-day daily-high forecast, plus refresh and jump-to-site actions.",
|
||||
"Shows a compact intraday chart with DEB against the airport primary or official reference station, including hoverable time and temperature.",
|
||||
"Shows a compact multi-day high forecast, plus refresh and return-to-site actions.",
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -724,7 +596,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
"`tabs`: used to inspect the active tab URL and match the current city.",
|
||||
"`storage`: used for local configuration and local cache only.",
|
||||
"`sidePanel`: used to render the browser side panel UI.",
|
||||
"The extension does not require login, does not collect personally identifiable information, and does not upload browsing history. It only requests weather endpoints when needed to render the panel.",
|
||||
"The extension does not require login, does not collect personally identifiable information, and does not upload browsing history. It only requests weather endpoints when rendering the side panel.",
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -735,23 +607,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
blocks: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The extension does not attempt to replicate the full analysis stack and does not carry the payment flow. Deeper structural reasoning and full trade context still live on the main site.",
|
||||
},
|
||||
{
|
||||
type: "callout",
|
||||
tone: "info",
|
||||
title: "Current positioning",
|
||||
text: "Think of the extension as monitoring plus lightweight bias, not as a full dashboard replacement.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "extension-forecast",
|
||||
title: "Current forecast logic",
|
||||
blocks: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The extension now prefers DEB for the multi-day forecast. It falls back to the original daily max only when a DEB value is missing for that date.",
|
||||
text: "The extension does not carry the full analysis experience or payment flow. Multi-chart monitoring, training data, entitlement state, and complete market context stay on the main site.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -35,11 +35,11 @@ const SOURCE_PROFILES: Record<string, SourceProfile> = {
|
||||
amsc_awos: {
|
||||
code: "amsc_awos",
|
||||
label: "AMSC AWOS",
|
||||
nativeUpdateIntervalSec: DASHBOARD_REFRESH_POLICY_SEC.observation,
|
||||
nativeUpdateIntervalSec: 180,
|
||||
freshWindowSec: 180,
|
||||
expectedGraceSec: 180,
|
||||
staleAfterSec: 900,
|
||||
pollIntervalSec: DASHBOARD_REFRESH_POLICY_SEC.observation,
|
||||
pollIntervalSec: 180,
|
||||
},
|
||||
amos: {
|
||||
code: "amos",
|
||||
|
||||
@@ -367,6 +367,66 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
for key in stale:
|
||||
cache.pop(key, None)
|
||||
|
||||
def _post_temperature_patch_payload(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
city_value: str,
|
||||
source_value: str,
|
||||
) -> bool:
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.getenv(name, str(default)) or default)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, str(default)) or default)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
attempts = max(1, _env_int("POLYWEATHER_COLLECTOR_PATCH_POST_ATTEMPTS", 3))
|
||||
timeout_sec = max(0.1, _env_float("POLYWEATHER_COLLECTOR_PATCH_POST_TIMEOUT_SEC", 2.0))
|
||||
backoff_sec = max(0.0, _env_float("POLYWEATHER_COLLECTOR_PATCH_POST_BACKOFF_SEC", 0.25))
|
||||
|
||||
last_error: Optional[BaseException] = None
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
response = requests.post(
|
||||
self.collector_patch_endpoint,
|
||||
json=payload,
|
||||
timeout=timeout_sec,
|
||||
)
|
||||
status_code = int(getattr(response, "status_code", 200) or 200)
|
||||
if 200 <= status_code < 300:
|
||||
return True
|
||||
text = str(getattr(response, "text", "") or "")[:240]
|
||||
last_error = RuntimeError(f"status={status_code} body={text}")
|
||||
if status_code < 500:
|
||||
logger.warning(
|
||||
"collector patch POST rejected city={} source={} status={} body={}",
|
||||
city_value,
|
||||
source_value,
|
||||
status_code,
|
||||
text,
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
|
||||
if attempt < attempts and backoff_sec > 0:
|
||||
time.sleep(backoff_sec * attempt)
|
||||
|
||||
logger.warning(
|
||||
"collector patch POST failed city={} source={} attempts={} error={}",
|
||||
city_value,
|
||||
source_value,
|
||||
attempts,
|
||||
last_error,
|
||||
)
|
||||
return False
|
||||
|
||||
def _emit_temperature_patch_if_changed(
|
||||
self,
|
||||
city: str,
|
||||
@@ -407,19 +467,16 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
payload = {"city": city_value, "changes": changes}
|
||||
|
||||
def post_patch() -> None:
|
||||
try:
|
||||
requests.post(
|
||||
self.collector_patch_endpoint,
|
||||
json=payload,
|
||||
timeout=2,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"collector patch POST failed city={} source={}: {}",
|
||||
city_value,
|
||||
source_value,
|
||||
exc,
|
||||
)
|
||||
sent = self._post_temperature_patch_payload(
|
||||
payload,
|
||||
city_value=city_value,
|
||||
source_value=source_value,
|
||||
)
|
||||
if sent:
|
||||
return
|
||||
with self._temperature_patch_lock:
|
||||
if self._last_emitted_temperature_patch.get(dedupe_key) == signature:
|
||||
self._last_emitted_temperature_patch.pop(dedupe_key, None)
|
||||
|
||||
threading.Thread(target=post_patch, daemon=True).start()
|
||||
# MADIS cache is a single list, not a keyed dict — expire on age
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from web.services.observation_freshness import build_observation_freshness
|
||||
|
||||
|
||||
def test_amsc_awos_freshness_uses_three_minute_native_cadence():
|
||||
freshness = build_observation_freshness(
|
||||
source_code="amsc_awos",
|
||||
source_label="AMSC AWOS",
|
||||
observed_at="2026-06-06T13:01:00Z",
|
||||
now_utc=datetime(2026, 6, 6, 13, 4, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert freshness["source_code"] == "amsc_awos"
|
||||
assert freshness["native_update_interval_sec"] == 180
|
||||
assert freshness["expected_next_update_at"] == "2026-06-06T13:04:00+00:00"
|
||||
assert freshness["freshness_status"] == "fresh"
|
||||
@@ -103,6 +103,47 @@ def test_patch_adds_city_local_time_contract_from_observation_time():
|
||||
assert event["payload"]["observed_at_local"] == "2026-05-27T19:16:00-04:00"
|
||||
|
||||
|
||||
def test_patch_records_received_time_and_latency_for_late_runway_points(monkeypatch):
|
||||
monkeypatch.setattr("web.realtime_patch_schema.time.time", lambda: 1780750864.062)
|
||||
|
||||
event = normalize_observation_patch(
|
||||
{
|
||||
"city": "Busan",
|
||||
"changes": {
|
||||
"temp": 23.0,
|
||||
"obs_time": "2026-06-06T12:59:00Z",
|
||||
"source": "amos",
|
||||
"amos": {
|
||||
"runway_obs": {
|
||||
"point_temperatures": [{"runway": "SR/SL", "temp": 22.7}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert event["received_at_utc"] == "2026-06-06T13:01:04Z"
|
||||
assert event["latency_sec"] == 124
|
||||
assert event["payload"]["received_at_utc"] == "2026-06-06T13:01:04Z"
|
||||
assert event["payload"]["latency_sec"] == 124
|
||||
|
||||
|
||||
def test_amsc_patch_uses_three_minute_source_cadence():
|
||||
event = normalize_observation_patch(
|
||||
{
|
||||
"city": "Shanghai",
|
||||
"changes": {
|
||||
"temp": 22.4,
|
||||
"obs_time": "2026-06-06T13:01:00Z",
|
||||
"source": "amsc_awos",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert event["source_cadence_sec"] == 180
|
||||
assert event["payload"]["source_cadence_sec"] == 180
|
||||
|
||||
|
||||
def test_invalid_patch_without_city_or_observation_data_is_rejected():
|
||||
with pytest.raises(PatchValidationError):
|
||||
normalize_observation_patch({"changes": {"temp": 21.0}})
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.data_collection.weather_sources import WeatherDataCollector
|
||||
|
||||
|
||||
def test_collector_patch_post_retries_transient_failures(monkeypatch):
|
||||
collector = WeatherDataCollector({})
|
||||
collector.collector_patch_endpoint = "http://internal.local/api/internal/collector-patch"
|
||||
calls = []
|
||||
|
||||
def fake_post(url, *, json, timeout):
|
||||
calls.append((url, json, timeout))
|
||||
if len(calls) < 3:
|
||||
raise RuntimeError("temporary 502")
|
||||
return SimpleNamespace(status_code=200, text="ok")
|
||||
|
||||
monkeypatch.setattr("src.data_collection.weather_sources.requests.post", fake_post)
|
||||
monkeypatch.setattr("src.data_collection.weather_sources.time.sleep", lambda _seconds: None)
|
||||
|
||||
sent = collector._post_temperature_patch_payload(
|
||||
{"city": "busan", "changes": {"temp": 23.0}},
|
||||
city_value="busan",
|
||||
source_value="amos",
|
||||
)
|
||||
|
||||
assert sent is True
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
def test_collector_patch_post_retries_internal_server_errors(monkeypatch):
|
||||
collector = WeatherDataCollector({})
|
||||
collector.collector_patch_endpoint = "http://internal.local/api/internal/collector-patch"
|
||||
calls = []
|
||||
|
||||
def fake_post(url, *, json, timeout):
|
||||
calls.append((url, json, timeout))
|
||||
status = 502 if len(calls) == 1 else 204
|
||||
return SimpleNamespace(status_code=status, text="bad gateway" if status == 502 else "")
|
||||
|
||||
monkeypatch.setattr("src.data_collection.weather_sources.requests.post", fake_post)
|
||||
monkeypatch.setattr("src.data_collection.weather_sources.time.sleep", lambda _seconds: None)
|
||||
|
||||
sent = collector._post_temperature_patch_payload(
|
||||
{"city": "shanghai", "changes": {"temp": 22.4}},
|
||||
city_value="shanghai",
|
||||
source_value="amsc_awos",
|
||||
)
|
||||
|
||||
assert sent is True
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_failed_collector_patch_clears_dedupe_for_next_attempt(monkeypatch):
|
||||
collector = WeatherDataCollector({})
|
||||
collector.collector_patch_endpoint = "http://internal.local/api/internal/collector-patch"
|
||||
calls = []
|
||||
|
||||
class ImmediateThread:
|
||||
def __init__(self, target, daemon):
|
||||
self._target = target
|
||||
self.daemon = daemon
|
||||
|
||||
def start(self):
|
||||
self._target()
|
||||
|
||||
def fake_post(payload, *, city_value, source_value):
|
||||
calls.append((payload, city_value, source_value))
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("src.data_collection.weather_sources.threading.Thread", ImmediateThread)
|
||||
monkeypatch.setattr(collector, "_post_temperature_patch_payload", fake_post)
|
||||
|
||||
collector._emit_temperature_patch_if_changed(
|
||||
"busan",
|
||||
23.0,
|
||||
"2026-06-06T13:01:00Z",
|
||||
source="amos",
|
||||
)
|
||||
collector._emit_temperature_patch_if_changed(
|
||||
"busan",
|
||||
23.0,
|
||||
"2026-06-06T13:01:00Z",
|
||||
source="amos",
|
||||
)
|
||||
|
||||
assert len(calls) == 2
|
||||
@@ -80,6 +80,15 @@ def _parse_datetime(value: Any) -> Optional[datetime]:
|
||||
return None
|
||||
|
||||
|
||||
def _latency_seconds(received_at: datetime, observed_at: Optional[str]) -> Optional[int]:
|
||||
observed = _parse_datetime(observed_at)
|
||||
if observed is None:
|
||||
return None
|
||||
if observed.tzinfo is None:
|
||||
observed = observed.replace(tzinfo=timezone.utc)
|
||||
return max(0, int((received_at.astimezone(timezone.utc) - observed.astimezone(timezone.utc)).total_seconds()))
|
||||
|
||||
|
||||
def _normalize_observation_time_contract(city: str, source: str, obs_time: Optional[str]) -> Dict[str, Any]:
|
||||
parsed = _parse_datetime(obs_time)
|
||||
if parsed is None:
|
||||
@@ -281,6 +290,17 @@ def normalize_observation_patch(patch: Dict[str, Any]) -> Dict[str, Any]:
|
||||
}
|
||||
obs_time = str(time_contract.get("observed_at_utc") or obs_time or "").strip() or None
|
||||
|
||||
received_at = datetime.fromtimestamp(time.time(), tz=timezone.utc).replace(microsecond=0)
|
||||
received_at_utc = _format_utc_iso(received_at)
|
||||
latency_sec = _latency_seconds(received_at, obs_time)
|
||||
received_contract: Dict[str, Any] = {
|
||||
"received_at_utc": received_at_utc,
|
||||
}
|
||||
if latency_sec is not None:
|
||||
received_contract["latency_sec"] = latency_sec
|
||||
if received_contract:
|
||||
payload = {**payload, **received_contract}
|
||||
|
||||
return {
|
||||
"type": EVENT_TYPE,
|
||||
"schema_type": SCHEMA_TYPE,
|
||||
@@ -289,6 +309,7 @@ def normalize_observation_patch(patch: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"source": source,
|
||||
"obs_time": obs_time,
|
||||
**time_contract,
|
||||
"ts": int(time.time() * 1000),
|
||||
**received_contract,
|
||||
"ts": int(received_at.timestamp() * 1000),
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ _OBSERVATION_SOURCE_PROFILES: Dict[str, Dict[str, Any]] = {
|
||||
},
|
||||
"amsc_awos": {
|
||||
"label": "AMSC AWOS",
|
||||
"native_update_interval_sec": 60,
|
||||
"native_update_interval_sec": 180,
|
||||
"fresh_window_sec": 180,
|
||||
"expected_grace_sec": 180,
|
||||
"stale_after_sec": 900,
|
||||
@@ -138,6 +138,8 @@ def canonical_observation_source_code(value: Any) -> str:
|
||||
raw = str(value or "").strip().lower()
|
||||
if not raw:
|
||||
return "metar"
|
||||
if "amsc" in raw:
|
||||
return "amsc_awos"
|
||||
if "amos" in raw:
|
||||
return "amos"
|
||||
if "jma" in raw:
|
||||
|
||||
Reference in New Issue
Block a user