Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9823e3961f | |||
| 2598c5ac98 | |||
| e2cb0cfe5e | |||
| fd2b870d6a | |||
| 46b0e63ffd | |||
| 0eee5c8948 | |||
| 25424700ce | |||
| 767f7ed6bf | |||
| c0e5307893 | |||
| b635070106 | |||
| 2ead56f59d | |||
| 611e2d9009 | |||
| 53d2ae4c10 | |||
| 4bfd2534bb | |||
| df82234b66 | |||
| fcdece7ab4 | |||
| 2e81894fd1 | |||
| 26c4162f50 | |||
| 6249a00382 | |||
| da3acbbb6e | |||
| 62298eaf38 | |||
| c4b45be757 | |||
| c55b83a134 | |||
| aa6ed30987 | |||
| c66b9f898d | |||
| 2e2c55d77a | |||
| baaccc6636 | |||
| 41cfb65b92 | |||
| 57acbc41c9 | |||
| 1d54f44189 | |||
| f0940b9c8b | |||
| f7f6ba39f7 | |||
| a27ae564fb | |||
| a0adf05d1e | |||
| 627bb8aa06 | |||
| 77b4d7b341 | |||
| 69168d2fdf | |||
| faba38578e | |||
| faa5bc94a7 | |||
| 95e1fd50d2 | |||
| 373e7cfec6 | |||
| 15022c77c0 | |||
| 150c793b35 | |||
| a5eff0031c |
+11
-1
@@ -45,8 +45,15 @@ OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_VERSION=v2
|
||||
OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=900
|
||||
OPEN_METEO_RATE_CACHE_TTL_SEC=3600
|
||||
OPEN_METEO_MIN_CALL_INTERVAL_SEC=3
|
||||
OPEN_METEO_MIN_CALL_INTERVAL_SEC=1
|
||||
POLYWEATHER_HTTP_TIMEOUT_SEC=8
|
||||
POLYWEATHER_HTTP_RETRY_COUNT=0
|
||||
POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2
|
||||
POLYWEATHER_OPEN_METEO_TIMEOUT_SEC=5
|
||||
POLYWEATHER_METAR_TIMEOUT_SEC=4
|
||||
POLYWEATHER_METAR_CLUSTER_TIMEOUT_SEC=3.5
|
||||
METAR_CACHE_TTL_SEC=600
|
||||
JMA_AMEDAS_CACHE_TTL_SEC=120
|
||||
METEOBLUE_CACHE_TTL_SEC=7200
|
||||
POLYWEATHER_LGBM_ENABLED=false
|
||||
POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
|
||||
@@ -89,6 +96,8 @@ POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||
########################################
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
# Optional: disable homepage city summary preloading. Default is enabled.
|
||||
NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false
|
||||
|
||||
########################################
|
||||
# 7) Optional modules
|
||||
@@ -100,6 +109,7 @@ GROQ_API_KEY=
|
||||
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,guangzhou,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
|
||||
|
||||
# Weekly reward / leaderboard
|
||||
POLYWEATHER_WEEKLY_REWARD_ENABLED=true
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 1.5.3 - 2026-04-10
|
||||
|
||||
- 东京新增 `JMA AMeDAS` 羽田 10 分钟官方增强层,只取温度并作为机场周边官方参考
|
||||
- 韩国官方增强层补齐 `KMA` 接入链,与 `METAR` 锚点保持分离
|
||||
- 城市点击交互恢复地图 `flyTo` 放大动画,并补回明确的 loading 提示
|
||||
- 城市点击后新增地图顶部同步提醒与详情面板内同步徽标,降低“看起来像卡住”的误判
|
||||
- 城市 detail 现在会识别“单模型 / 单日”的稀疏缓存并自动强刷,修复“模型只剩 DEB / 多日预报只剩今天”这类残缺展示
|
||||
- 前端多日预报在窄面板下改为可横向滚动,并对稀疏日序列给出刷新提示
|
||||
- `/ops` 与 `/api/system/status` 新增 prewarm worker 运行态、heartbeat、summary/detail/market 统计,以及缓存桶状态与 summary cache hit/miss
|
||||
- 新增 Dashboard 定向预热脚本、后台 worker 和 docker service,支持热点城市 summary/detail/market 预热
|
||||
- 共享天气采集 HTTP 层进一步统一到 `httpx` helper,并补齐短重试与错误分类
|
||||
- 今日日内分析改造成更交易化的工作台结构:`锚点状态 / 当前节奏 / 当前命中胜率 / 模型区间与分歧 / 今日日内结构信号`
|
||||
- 今日日内结构解读新增可选 `Groq` 改写层,失败时自动回退到规则文案
|
||||
- 文档统一更新到 `v1.5.3`,补充预热 worker、Groq、Vercel 节流与官方增强站网说明
|
||||
|
||||
## 1.5.1 - 2026-03-23
|
||||
|
||||
- `/ops` 页面增加管理员守卫,前后端双层限制管理员访问
|
||||
|
||||
@@ -17,7 +17,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
|
||||

|
||||
|
||||
## Product Status (2026-03-24)
|
||||
## Product Status (2026-04-10)
|
||||
|
||||
- Subscription live: `Pro Monthly 5 USDC`.
|
||||
- Points redemption live: `500 points = 1 USDC`, max `3 USDC` off.
|
||||
@@ -32,6 +32,12 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
||||
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
|
||||
- Browser extension now uses `DEB` for multi-day forecast and stays positioned as a lightweight lead-in to the main site.
|
||||
- Official nearby-network layer now covers `MGM` (Turkey), `CMA/NMC` (Mainland China), `JMA AMeDAS` (Japan), `KMA` (Korea), `HKO` (Hong Kong), and `CWA` (Taiwan).
|
||||
- Tokyo now ingests Haneda `JMA AMeDAS` 10-minute temperature as the official enhancement layer.
|
||||
- Dashboard prewarm is now supported through a dedicated worker / cron path, with runtime status exposed in `/api/system/status` and `/ops`.
|
||||
- `/ops` now exposes cache bucket counts, summary cache hit / miss rate, and prewarm runtime heartbeat.
|
||||
- Intraday commentary can optionally use `Groq` as a bilingual rewrite layer, while rule-based commentary remains the fallback.
|
||||
- Vercel frontend guidance now includes cost controls for analytics, eager fetches, and edge-side scanner blocking.
|
||||
|
||||
## License & Commercial Boundary
|
||||
|
||||
@@ -45,7 +51,7 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- Aggregates observations and forecasts for 30 monitored cities.
|
||||
- Aggregates observations and forecasts for 45 monitored cities.
|
||||
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
||||
- Generates settlement-oriented probability buckets (`mu` + bucket distribution).
|
||||
- Maps weather view to Polymarket quotes for mispricing scan.
|
||||
@@ -53,6 +59,8 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
- Adds payment audit trails, replay tooling, and incident visibility in ops.
|
||||
- Adds peak-window-oriented intraday structure cards for surface + upper-air analysis.
|
||||
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
|
||||
- Adds official nearby-network enhancement layers for China, Japan, Korea, Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
|
||||
- Adds optional dashboard prewarm worker so hot cities can be refreshed before user clicks.
|
||||
|
||||
## Reference Architecture
|
||||
|
||||
@@ -68,20 +76,23 @@ flowchart LR
|
||||
WX --> TAF["Aviation Weather (TAF)"]
|
||||
WX --> MGM["MGM (Turkey station network)"]
|
||||
WX --> OM["Open-Meteo"]
|
||||
WX --> HKO["HKO / NOAA / Official settlement sources"]
|
||||
WX --> JMA["JMA AMeDAS (Japan)"]
|
||||
WX --> KMA["KMA (Korea)"]
|
||||
WX --> HKO["HKO / CWA / NOAA / Official settlement sources"]
|
||||
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket Read-only Layer"]
|
||||
ANA --> LLM["Optional Groq Commentary Rewrite"]
|
||||
API --> PREWARM["Dashboard Prewarm API / Worker"]
|
||||
```
|
||||
|
||||
## Monitored Cities (30)
|
||||
## Monitored Cities (45)
|
||||
|
||||
- Europe / Middle East: Ankara, London, Paris, Munich, Tel Aviv, Milan, Warsaw, Madrid
|
||||
- APAC: Seoul, Hong Kong, Taipei, Shanghai, Singapore, Tokyo, Wellington
|
||||
- Americas: Toronto, New York, Chicago, Dallas, Miami, Atlanta, Seattle, Buenos Aires, Sao Paulo
|
||||
- Europe / Middle East: Ankara, Istanbul, Moscow, London, Paris, Munich, Milan, Warsaw, Madrid, Tel Aviv, Amsterdam, Helsinki
|
||||
- APAC: Seoul, Busan, Hong Kong, Lau Fau Shan, Taipei, Shanghai, Beijing, Wuhan, Chengdu, Chongqing, Shenzhen, Singapore, Tokyo, Kuala Lumpur, Jakarta, Wellington
|
||||
- Americas: Toronto, New York, Los Angeles, San Francisco, Denver, Austin, Houston, Chicago, Dallas, Miami, Atlanta, Seattle, Mexico City, Buenos Aires, Sao Paulo, Panama City
|
||||
- South Asia: Lucknow
|
||||
- China extension: Chengdu, Chongqing, Shenzhen, Beijing, Wuhan
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -95,7 +106,7 @@ docker compose up -d --build
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
@@ -131,6 +142,20 @@ curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
### Dashboard prewarm worker
|
||||
|
||||
```bash
|
||||
docker compose --profile workers up -d polyweather_prewarm
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
- `prewarm.thread_alive`
|
||||
- `prewarm.runtime.cycle_count`
|
||||
- `cache.analysis.hit_rate`
|
||||
- `cache.open_meteo_forecast_entries`
|
||||
|
||||
### Frontend cache headers
|
||||
|
||||
```bash
|
||||
@@ -190,5 +215,5 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
|
||||
|
||||
## Version
|
||||
|
||||
- Version: `v1.5.1`
|
||||
- Last Updated: `2026-03-24`
|
||||
- Version: `v1.5.3`
|
||||
- Last Updated: `2026-04-10`
|
||||
|
||||
+42
-10
@@ -14,7 +14,7 @@
|
||||
|
||||

|
||||
|
||||
## 当前产品状态(2026-03-21)
|
||||
## 当前产品状态(2026-04-10)
|
||||
|
||||
- 已上线订阅制:`Pro 月付 5 USDC`。
|
||||
- 已上线积分抵扣:`500 积分 = 1 USDC`,最多抵扣 `3 USDC`。
|
||||
@@ -26,6 +26,18 @@
|
||||
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
|
||||
- 运行态状态、缓存与核心离线训练/回填链路已完成 SQLite 主路径收口;legacy JSON/JSONL 仅保留给迁移、导出与显式回退输入。
|
||||
- 已接入 EMOS/CRPS 校准链路,但当前仍保持 `emos_shadow`。
|
||||
- 官方增强站网已统一接入:
|
||||
- `MGM`(土耳其)
|
||||
- `CMA/NMC`(中国内地)
|
||||
- `JMA AMeDAS`(日本)
|
||||
- `KMA`(韩国)
|
||||
- `HKO`(香港)
|
||||
- `CWA`(台湾)
|
||||
- 东京现已接入羽田 `JMA AMeDAS` 10 分钟温度作为官方增强层。
|
||||
- 已支持 Dashboard 定向预热 worker / cron 路径,运行态在 `/api/system/status` 与 `/ops` 可见。
|
||||
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与 prewarm heartbeat。
|
||||
- 今日日内结构解读已支持可选 `Groq` 改写层,失败时自动回退规则文案。
|
||||
- 前端部署文档已补充 Vercel 节流建议,包括 analytics 关闭、eager fetch 开关与扫描流量防火墙规则。
|
||||
|
||||
## 许可证与商用边界(重要)
|
||||
|
||||
@@ -39,12 +51,14 @@
|
||||
|
||||
## 核心能力
|
||||
|
||||
- 聚合 30 个监控城市的实测与预报数据。
|
||||
- 聚合 45 个监控城市的实测与预报数据。
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 输出结算导向概率分布(`mu` + 温度桶)。
|
||||
- 将模型观点映射到 Polymarket 行情,做错价扫描。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
||||
- 官方增强层支持按国家 provider 统一接入,但不替代机场主站或明确官方结算站。
|
||||
- 支持后台预热热点城市,降低用户点击城市后的冷启动成本。
|
||||
|
||||
## 参考架构
|
||||
|
||||
@@ -58,22 +72,26 @@ flowchart LR
|
||||
API --> WX["Weather Collector"]
|
||||
WX --> METAR["Aviation Weather(METAR)"]
|
||||
WX --> MGM["MGM(土耳其站网)"]
|
||||
WX --> JMA["JMA AMeDAS(日本)"]
|
||||
WX --> KMA["KMA(韩国)"]
|
||||
WX --> OM["Open-Meteo"]
|
||||
WX --> HKO["HKO / CWA / NOAA 等官方结算源"]
|
||||
|
||||
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
|
||||
ANA --> PAY["支付状态(Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket 只读层"]
|
||||
API --> OBS["healthz / system status / metrics"]
|
||||
API --> PREWARM["Dashboard 预热接口 / Worker"]
|
||||
ANA --> LLM["可选 Groq 文案改写层"]
|
||||
ANA --> STATE["SQLite runtime state<br/>legacy files only for migration/export fallback"]
|
||||
```
|
||||
|
||||
## 监控城市(30)
|
||||
## 监控城市(45)
|
||||
|
||||
- 欧洲/中东:Ankara、London、Paris、Munich、Tel Aviv、Milan、Warsaw、Madrid
|
||||
- 亚太:Seoul、Hong Kong、Taipei、Shanghai、Singapore、Tokyo、Wellington
|
||||
- 美洲:Toronto、New York、Chicago、Dallas、Miami、Atlanta、Seattle、Buenos Aires、Sao Paulo
|
||||
- 欧洲/中东:Ankara、Istanbul、Moscow、London、Paris、Munich、Milan、Warsaw、Madrid、Tel Aviv、Amsterdam、Helsinki
|
||||
- 亚太:Seoul、Busan、Hong Kong、Lau Fau Shan、Taipei、Shanghai、Beijing、Wuhan、Chengdu、Chongqing、Shenzhen、Singapore、Tokyo、Kuala Lumpur、Jakarta、Wellington
|
||||
- 美洲:Toronto、New York、Los Angeles、San Francisco、Denver、Austin、Houston、Chicago、Dallas、Miami、Atlanta、Seattle、Mexico City、Buenos Aires、Sao Paulo、Panama City
|
||||
- 南亚:Lucknow
|
||||
- 中国扩展:Chengdu、Chongqing、Shenzhen、Beijing、Wuhan
|
||||
|
||||
## 快速启动
|
||||
|
||||
@@ -87,7 +105,7 @@ docker compose up -d --build
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
@@ -111,6 +129,20 @@ curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
### Dashboard 预热 Worker
|
||||
|
||||
```bash
|
||||
docker compose --profile workers up -d polyweather_prewarm
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
```
|
||||
|
||||
重点关注:
|
||||
|
||||
- `prewarm.thread_alive`
|
||||
- `prewarm.runtime.cycle_count`
|
||||
- `prewarm.runtime.last_summary_ok`
|
||||
- `cache.analysis.hit_rate`
|
||||
|
||||
### 前端缓存头
|
||||
|
||||
```bash
|
||||
@@ -196,5 +228,5 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
|
||||
|
||||
## 当前版本
|
||||
|
||||
- 版本:`v1.5.1`
|
||||
- 文档最后更新:`2026-03-21`
|
||||
- 版本:`v1.5.3`
|
||||
- 文档最后更新:`2026-04-10`
|
||||
|
||||
+276
-1326
File diff suppressed because it is too large
Load Diff
@@ -42,25 +42,25 @@
|
||||
"nws"
|
||||
],
|
||||
"model_path": "artifacts\\models\\lgbm_daily_high.txt",
|
||||
"sample_count": 54,
|
||||
"train_count": 42,
|
||||
"sample_count": 18,
|
||||
"train_count": 6,
|
||||
"validation_count": 12,
|
||||
"metrics": {
|
||||
"validation": {
|
||||
"sample_count": 12,
|
||||
"lgbm_mae": 1.349,
|
||||
"deb_mae": 0.875,
|
||||
"best_single_mae": 0.325,
|
||||
"median_mae": 0.758
|
||||
"lgbm_mae": 7.992,
|
||||
"deb_mae": 2.375,
|
||||
"best_single_mae": 1.05,
|
||||
"median_mae": 2.292
|
||||
},
|
||||
"full_sample": {
|
||||
"sample_count": 54,
|
||||
"lgbm_mae": 0.691,
|
||||
"deb_mae": 6.287,
|
||||
"best_single_mae": 5.431,
|
||||
"median_mae": 6.265
|
||||
"sample_count": 18,
|
||||
"lgbm_mae": 3.034,
|
||||
"deb_mae": 1.689,
|
||||
"best_single_mae": 0.822,
|
||||
"median_mae": 1.711
|
||||
}
|
||||
},
|
||||
"generated_at": "2026-04-02T16:27:44.816882Z",
|
||||
"trained_at": "2026-04-02T16:27:44.816882Z"
|
||||
"generated_at": "2026-04-15T16:41:56.814421Z",
|
||||
"trained_at": "2026-04-15T16:41:56.814421Z"
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"version": "emos-20260402162744",
|
||||
"trained_at": "2026-04-02T16:27:44.114836+00:00",
|
||||
"version": "emos-20260415170349",
|
||||
"trained_at": "2026-04-15T17:03:49.122865+00:00",
|
||||
"global": {
|
||||
"mu": {
|
||||
"intercept": 1.54512641,
|
||||
"raw_mu_coef": 2.96105052,
|
||||
"deb_coef": -1.53260815,
|
||||
"ens_median_coef": -0.72849343,
|
||||
"max_so_far_gap_coef": 9.52557689
|
||||
"intercept": 2.45853035,
|
||||
"raw_mu_coef": 2.03689031,
|
||||
"deb_coef": -1.41207502,
|
||||
"ens_median_coef": 0.08616811,
|
||||
"max_so_far_gap_coef": -0.5886898
|
||||
},
|
||||
"sigma": {
|
||||
"intercept": 0.67432479,
|
||||
"raw_sigma_coef": 0.6936692,
|
||||
"spread_coef": 0.08877484,
|
||||
"peak_flag_coef": -0.58374835,
|
||||
"max_so_far_gap_coef": -0.8172477
|
||||
"intercept": 0.61260118,
|
||||
"raw_sigma_coef": -1.48747774,
|
||||
"spread_coef": 2.24562634,
|
||||
"peak_flag_coef": -0.15906747,
|
||||
"max_so_far_gap_coef": -0.01835251
|
||||
}
|
||||
},
|
||||
"sigma_constraints": {
|
||||
@@ -29,62 +29,81 @@
|
||||
"max_bucket_brier_increase": 0.05
|
||||
},
|
||||
"blending": {
|
||||
"alpha_mu": 0.0,
|
||||
"alpha_sigma": 0.05
|
||||
"alpha_mu": 0.05,
|
||||
"alpha_sigma": 1.0
|
||||
},
|
||||
"cities": {
|
||||
"tokyo": {
|
||||
"samples": 5,
|
||||
"mu_bias": 2.223946,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"ankara": {
|
||||
"samples": 6,
|
||||
"mu_bias": -0.396953,
|
||||
"sigma_scale": 1.094474,
|
||||
"confidence": 0.75
|
||||
},
|
||||
"istanbul": {
|
||||
"samples": 3,
|
||||
"mu_bias": 1.271844,
|
||||
"sigma_scale": 1.477644,
|
||||
"mu_bias": -0.163153,
|
||||
"sigma_scale": 0.5,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 4,
|
||||
"mu_bias": 1.32008,
|
||||
"sigma_scale": 1.04023,
|
||||
"confidence": 0.5
|
||||
"samples": 7,
|
||||
"mu_bias": 3.267553,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.875
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 5,
|
||||
"mu_bias": 4.80044,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 3,
|
||||
"mu_bias": -3.080962,
|
||||
"sigma_scale": 1.437353,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"milan": {
|
||||
"samples": 3,
|
||||
"mu_bias": -3.935178,
|
||||
"mu_bias": -4.20255,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 3,
|
||||
"mu_bias": 1.810495,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 3,
|
||||
"mu_bias": 3.577828,
|
||||
"sigma_scale": 2.0,
|
||||
"mu_bias": 1.185335,
|
||||
"sigma_scale": 1.68386,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 3,
|
||||
"mu_bias": -0.625333,
|
||||
"sigma_scale": 1.25968,
|
||||
"mu_bias": -1.574246,
|
||||
"sigma_scale": 1.343402,
|
||||
"confidence": 0.375
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"sample_count": 54,
|
||||
"mean_crps": 3.792563,
|
||||
"legacy_mean_crps": 4.308029,
|
||||
"legacy_mean_mae": 4.51037,
|
||||
"legacy_bucket_hit_rate": 0.537037,
|
||||
"legacy_bucket_brier": 0.833294,
|
||||
"selected_mean_crps": 4.249828,
|
||||
"selected_mean_mae": 4.51037,
|
||||
"selected_bucket_hit_rate": 0.555556,
|
||||
"selected_bucket_brier": 0.831872,
|
||||
"selected_score": 5.991436,
|
||||
"legacy_score": 6.078481,
|
||||
"sample_count": 71,
|
||||
"mean_crps": 3.226613,
|
||||
"legacy_mean_crps": 3.608885,
|
||||
"legacy_mean_mae": 3.81507,
|
||||
"legacy_bucket_hit_rate": 0.492958,
|
||||
"legacy_bucket_brier": 0.873776,
|
||||
"selected_mean_crps": 3.44742,
|
||||
"selected_mean_mae": 3.749078,
|
||||
"selected_bucket_hit_rate": 0.492958,
|
||||
"selected_bucket_brier": 0.878621,
|
||||
"selected_score": 5.241857,
|
||||
"legacy_score": 5.406287,
|
||||
"filled_actual_from_history": 0,
|
||||
"settlement_history_city_count": 30
|
||||
"settlement_history_city_count": 30,
|
||||
"legacy_archive_samples": 54
|
||||
},
|
||||
"source": "artifacts\\probability_calibration\\default.json"
|
||||
}
|
||||
@@ -1,257 +1,284 @@
|
||||
{
|
||||
"summary": {
|
||||
"sample_count": 54,
|
||||
"filled_actual_from_history": 2,
|
||||
"sample_count": 71,
|
||||
"filled_actual_from_history": 0,
|
||||
"legacy": {
|
||||
"mean_crps": 4.300621,
|
||||
"mean_mae": 4.502963,
|
||||
"bucket_hit_rate": 0.537037
|
||||
"mean_crps": 3.608885,
|
||||
"mean_mae": 3.81507,
|
||||
"bucket_hit_rate": 0.492958
|
||||
},
|
||||
"emos": {
|
||||
"mean_crps": 4.213889,
|
||||
"mean_mae": 4.502963,
|
||||
"bucket_hit_rate": 0.537037
|
||||
"mean_crps": 3.881798,
|
||||
"mean_mae": 4.378282,
|
||||
"bucket_hit_rate": 0.225352
|
||||
},
|
||||
"delta": {
|
||||
"crps": -0.086732,
|
||||
"mae": 0.0,
|
||||
"bucket_hit_rate": 0.0
|
||||
"crps": 0.272913,
|
||||
"mae": 0.563211,
|
||||
"bucket_hit_rate": -0.267606
|
||||
}
|
||||
},
|
||||
"by_city": {
|
||||
"ankara": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.327701,
|
||||
"emos_mean_crps": 0.439705,
|
||||
"legacy_mean_mae": 0.066667,
|
||||
"emos_mean_mae": 0.066667,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"samples": 6,
|
||||
"legacy_mean_crps": 1.365135,
|
||||
"emos_mean_crps": 1.454394,
|
||||
"legacy_mean_mae": 1.466667,
|
||||
"emos_mean_mae": 1.781865,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"emos_bucket_hit_rate": 0.333333
|
||||
},
|
||||
"atlanta": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 30.449382,
|
||||
"emos_mean_crps": 30.578432,
|
||||
"emos_mean_crps": 30.2136,
|
||||
"legacy_mean_mae": 32.015,
|
||||
"emos_mean_mae": 32.015,
|
||||
"emos_mean_mae": 31.431544,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"buenos aires": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 9.113412,
|
||||
"emos_mean_crps": 8.759954,
|
||||
"emos_mean_crps": 9.25091,
|
||||
"legacy_mean_mae": 10.27,
|
||||
"emos_mean_mae": 10.27,
|
||||
"emos_mean_mae": 10.234011,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"busan": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.451701,
|
||||
"emos_mean_crps": 1.176676,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 1.887548,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"chengdu": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.306469,
|
||||
"emos_mean_crps": 0.555327,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.929502,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"chicago": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 1.250268,
|
||||
"emos_mean_crps": 0.701085,
|
||||
"emos_mean_crps": 2.090175,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.0,
|
||||
"emos_mean_mae": 3.396557,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"dallas": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 2.173363,
|
||||
"emos_mean_crps": 0.701085,
|
||||
"emos_mean_crps": 1.05572,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.0,
|
||||
"emos_mean_mae": 1.653475,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 4,
|
||||
"legacy_mean_crps": 0.29509,
|
||||
"emos_mean_crps": 0.387946,
|
||||
"legacy_mean_mae": 0.075,
|
||||
"emos_mean_mae": 0.075,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 0.75
|
||||
"samples": 7,
|
||||
"legacy_mean_crps": 0.706099,
|
||||
"emos_mean_crps": 1.573367,
|
||||
"legacy_mean_mae": 0.657143,
|
||||
"emos_mean_mae": 1.88803,
|
||||
"legacy_bucket_hit_rate": 0.714286,
|
||||
"emos_bucket_hit_rate": 0.142857
|
||||
},
|
||||
"istanbul": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 1.264103,
|
||||
"emos_mean_crps": 1.266844,
|
||||
"legacy_mean_mae": 1.5,
|
||||
"emos_mean_mae": 1.415758,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"london": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 3.885033,
|
||||
"emos_mean_crps": 3.866915,
|
||||
"emos_mean_crps": 4.057521,
|
||||
"legacy_mean_mae": 4.135,
|
||||
"emos_mean_mae": 4.135,
|
||||
"emos_mean_mae": 4.313103,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"lucknow": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 2.487193,
|
||||
"emos_mean_crps": 2.342342,
|
||||
"emos_mean_crps": 2.021264,
|
||||
"legacy_mean_mae": 3.205,
|
||||
"emos_mean_mae": 3.205,
|
||||
"emos_mean_mae": 2.47462,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"madrid": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 6.27726,
|
||||
"emos_mean_crps": 5.967277,
|
||||
"emos_mean_crps": 6.588692,
|
||||
"legacy_mean_mae": 7.33,
|
||||
"emos_mean_mae": 7.33,
|
||||
"emos_mean_mae": 7.486713,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"miami": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 28.637631,
|
||||
"emos_mean_crps": 28.482516,
|
||||
"emos_mean_crps": 28.800796,
|
||||
"legacy_mean_mae": 30.175,
|
||||
"emos_mean_mae": 30.175,
|
||||
"emos_mean_mae": 30.107593,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"milan": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 4.401392,
|
||||
"emos_mean_crps": 3.858031,
|
||||
"emos_mean_crps": 4.947416,
|
||||
"legacy_mean_mae": 4.06,
|
||||
"emos_mean_mae": 4.06,
|
||||
"emos_mean_mae": 5.6958,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
"emos_bucket_hit_rate": 0.333333
|
||||
},
|
||||
"munich": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 3.145192,
|
||||
"emos_mean_crps": 3.011312,
|
||||
"emos_mean_crps": 3.200946,
|
||||
"legacy_mean_mae": 3.64,
|
||||
"emos_mean_mae": 3.64,
|
||||
"emos_mean_mae": 3.634475,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"new york": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 3.692845,
|
||||
"emos_mean_crps": 3.407357,
|
||||
"emos_mean_crps": 3.881457,
|
||||
"legacy_mean_mae": 4.94,
|
||||
"emos_mean_mae": 4.94,
|
||||
"emos_mean_mae": 4.954702,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"paris": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 4.013782,
|
||||
"emos_mean_crps": 3.979293,
|
||||
"emos_mean_crps": 4.130614,
|
||||
"legacy_mean_mae": 4.265,
|
||||
"emos_mean_mae": 4.265,
|
||||
"emos_mean_mae": 4.314216,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"sao paulo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 5.540967,
|
||||
"emos_mean_crps": 5.272063,
|
||||
"emos_mean_crps": 5.483258,
|
||||
"legacy_mean_mae": 6.57,
|
||||
"emos_mean_mae": 6.57,
|
||||
"emos_mean_mae": 6.358434,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"seattle": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.315488,
|
||||
"emos_mean_crps": 0.425909,
|
||||
"emos_mean_crps": 0.271998,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.105037,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.313754,
|
||||
"emos_mean_crps": 0.412831,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.15,
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.508331,
|
||||
"emos_mean_crps": 1.718042,
|
||||
"legacy_mean_mae": 0.2,
|
||||
"emos_mean_mae": 2.57121,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.299116,
|
||||
"emos_mean_crps": 0.394855,
|
||||
"emos_mean_crps": 0.553444,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"emos_mean_mae": 0.1,
|
||||
"emos_mean_mae": 0.71746,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"shenzhen": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.798351,
|
||||
"emos_mean_crps": 0.762787,
|
||||
"legacy_mean_mae": 0.9,
|
||||
"emos_mean_mae": 0.9,
|
||||
"legacy_mean_crps": 1.198351,
|
||||
"emos_mean_crps": 0.507942,
|
||||
"legacy_mean_mae": 1.3,
|
||||
"emos_mean_mae": 0.644561,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"singapore": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.281993,
|
||||
"emos_mean_crps": 0.37264,
|
||||
"emos_mean_crps": 0.877023,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.15,
|
||||
"emos_mean_mae": 1.142765,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.356996,
|
||||
"emos_mean_crps": 0.472738,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"emos_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 0.950739,
|
||||
"emos_mean_crps": 1.257972,
|
||||
"legacy_mean_mae": 0.94,
|
||||
"emos_mean_mae": 1.687014,
|
||||
"legacy_bucket_hit_rate": 0.6,
|
||||
"emos_bucket_hit_rate": 0.2
|
||||
},
|
||||
"tel aviv": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.446758,
|
||||
"emos_mean_crps": 0.578006,
|
||||
"emos_mean_crps": 0.482341,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.62659,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"tokyo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.450128,
|
||||
"emos_mean_crps": 0.582151,
|
||||
"legacy_mean_mae": 0.25,
|
||||
"emos_mean_mae": 0.25,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 0.879366,
|
||||
"emos_mean_crps": 0.989336,
|
||||
"legacy_mean_mae": 1.022,
|
||||
"emos_mean_mae": 1.450474,
|
||||
"legacy_bucket_hit_rate": 0.2,
|
||||
"emos_bucket_hit_rate": 0.2
|
||||
},
|
||||
"toronto": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 5.497916,
|
||||
"emos_mean_crps": 5.240552,
|
||||
"emos_mean_crps": 6.296632,
|
||||
"legacy_mean_mae": 6.33,
|
||||
"emos_mean_mae": 6.33,
|
||||
"emos_mean_mae": 7.003984,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 1.618875,
|
||||
"emos_mean_crps": 1.553232,
|
||||
"emos_mean_crps": 1.649515,
|
||||
"legacy_mean_mae": 2.056667,
|
||||
"emos_mean_mae": 2.056667,
|
||||
"emos_mean_mae": 2.073494,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"emos_bucket_hit_rate": 0.333333
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"wellington": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.364919,
|
||||
"emos_mean_crps": 0.475875,
|
||||
"emos_mean_crps": 0.939463,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.15,
|
||||
"emos_mean_mae": 1.007263,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,11 @@ cities:
|
||||
country: China
|
||||
latitude: 22.6393
|
||||
longitude: 113.8107
|
||||
- id: guangzhou
|
||||
city: Guangzhou
|
||||
country: China
|
||||
latitude: 23.3924
|
||||
longitude: 113.2988
|
||||
- id: beijing
|
||||
city: Beijing
|
||||
country: China
|
||||
|
||||
@@ -126,3 +126,63 @@
|
||||
{"city": "chengdu", "timestamp": "2026-04-08T07:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 1.1821289062499998, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 23.0, "p90": 24.5}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 23.0, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 24.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 15.183333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 24, "p": 0.442}, {"v": 25, "p": 0.386}, {"v": 26, "p": 0.172}], "shadow_prob_snapshot": [{"v": 24, "p": 0.394}, {"v": 25, "p": 0.355}, {"v": 26, "p": 0.19}, {"v": 27, "p": 0.06}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 1.354078466151897}
|
||||
{"city": "chengdu", "timestamp": "2026-04-08T08:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 0.7283767361111106, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 22.9, "p90": 24.2}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 22.5, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 16.133333333333333}, "peak_status": "in_window", "prob_snapshot": [{"v": 24, "p": 0.547}, {"v": 25, "p": 0.397}, {"v": 26, "p": 0.056}], "shadow_prob_snapshot": [{"v": 24, "p": 0.521}, {"v": 25, "p": 0.399}, {"v": 26, "p": 0.08}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 0.8140063140878262}
|
||||
{"city": "tokyo", "timestamp": "2026-04-08T08:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 17.810000000000002, "raw_sigma": 0.23200683593750038, "deb_prediction": 17.1, "ensemble": {"p10": 17.4, "median": 18.3, "p90": 19.1}, "multi_model": {"Open-Meteo": 16.1, "ECMWF": 16.3, "GFS": 17.6, "ICON": 18.2, "GEM": 18.3, "JMA": 16.1}, "max_so_far": 17.0, "observation": {"current_temp": 16.0, "humidity": null, "wind_speed_kt": 17.0, "visibility_mi": null, "local_hour": 17.133333333333333}, "peak_status": "past", "prob_snapshot": [{"v": 18, "p": 0.909}, {"v": 17, "p": 0.091}], "shadow_prob_snapshot": [{"v": 18, "p": 0.892}, {"v": 17, "p": 0.108}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 17.810000000000002, "calibrated_sigma": 0.25}
|
||||
{"city": "ankara", "timestamp": "2026-04-11T11:20:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 0.5468749999999998, "deb_prediction": 7.9, "ensemble": {"p10": 6.8, "median": 7.4, "p90": 8.2}, "multi_model": {"Open-Meteo": 7.5, "ECMWF": 7.5, "GFS": 8.3, "ICON": 7.5, "GEM": 8.2, "JMA": 8.3}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 14.45}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.615}, {"v": 9, "p": 0.37}, {"v": 10, "p": 0.015}], "shadow_prob_snapshot": [{"v": 8, "p": 0.594}, {"v": 9, "p": 0.381}, {"v": 10, "p": 0.024}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 0.5978357923824302}
|
||||
{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 0.42968750000000056, "deb_prediction": 10.8, "ensemble": {"p10": 9.7, "median": 10.4, "p90": 10.8}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.3, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.45}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.671}, {"v": 12, "p": 0.329}], "shadow_prob_snapshot": [{"v": 11, "p": 0.654}, {"v": 12, "p": 0.346}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 0.46787549747087}
|
||||
{"city": "hong kong", "timestamp": "2026-04-11T19:10:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.10084635416666676, "deb_prediction": 27.3, "ensemble": {"p10": 27.0, "median": 27.3, "p90": 27.8}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 4.3, "visibility_mi": null, "local_hour": 19.45}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.839}, {"v": 28, "p": 0.161}], "shadow_prob_snapshot": [{"v": 27, "p": 0.769}, {"v": 28, "p": 0.231}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.13614257812500014}
|
||||
{"city": "taipei", "timestamp": "2026-04-11T11:00:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10876464843750025, "deb_prediction": 29.5, "ensemble": {"p10": 28.9, "median": 29.4, "p90": 29.8}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 2.0, "visibility_mi": null, "local_hour": 19.45}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.1, "raw_sigma": 0.8500000000000005, "deb_prediction": 10.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.8, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.85}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.582}, {"v": 12, "p": 0.355}, {"v": 13, "p": 0.063}], "shadow_prob_snapshot": [{"v": 11, "p": 0.563}, {"v": 12, "p": 0.361}, {"v": 13, "p": 0.076}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.1, "calibrated_sigma": 0.9012761535978395}
|
||||
{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 0.42968750000000056, "deb_prediction": 10.9, "ensemble": {"p10": 9.7, "median": 10.4, "p90": 10.8}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.8, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.85}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.671}, {"v": 12, "p": 0.329}], "shadow_prob_snapshot": [{"v": 11, "p": 0.656}, {"v": 12, "p": 0.344}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 0.4631927411757732}
|
||||
{"city": "hong kong", "timestamp": "2026-04-11T19:40:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.375, "deb_prediction": 27.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 2.7, "visibility_mi": null, "local_hour": 19.85}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.603}, {"v": 28, "p": 0.397}], "shadow_prob_snapshot": [{"v": 27, "p": 0.597}, {"v": 28, "p": 0.403}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.39311002429138}
|
||||
{"city": "hong kong", "timestamp": "2026-04-11T19:40:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.09750000000000009, "deb_prediction": 27.3, "ensemble": {"p10": 27.0, "median": 27.3, "p90": 27.8}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 2.7, "visibility_mi": null, "local_hour": 19.85}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.847}, {"v": 28, "p": 0.153}], "shadow_prob_snapshot": [{"v": 27, "p": 0.776}, {"v": 28, "p": 0.224}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.13162500000000013}
|
||||
{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.7199999999999995, "deb_prediction": 29.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 19.866666666666667}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10938964843750025, "deb_prediction": 29.5, "ensemble": {"p10": 28.9, "median": 29.4, "p90": 29.8}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 19.866666666666667}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "ankara", "timestamp": "2026-04-11T11:20:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 1.0, "deb_prediction": 7.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 7.5}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 14.883333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.475}, {"v": 9, "p": 0.395}, {"v": 10, "p": 0.131}], "shadow_prob_snapshot": [{"v": 8, "p": 0.451}, {"v": 9, "p": 0.389}, {"v": 10, "p": 0.16}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 1.1241186693749348}
|
||||
{"city": "ankara", "timestamp": "2026-04-11T11:20:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 0.5468749999999998, "deb_prediction": 7.9, "ensemble": {"p10": 6.8, "median": 7.4, "p90": 8.2}, "multi_model": {"Open-Meteo": 7.5, "ECMWF": 7.5, "GFS": 8.2, "ICON": 7.5, "GEM": 8.2, "JMA": 8.3}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 14.9}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.615}, {"v": 9, "p": 0.37}, {"v": 10, "p": 0.015}], "shadow_prob_snapshot": [{"v": 8, "p": 0.594}, {"v": 9, "p": 0.381}, {"v": 10, "p": 0.024}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 0.5978357923824302}
|
||||
{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 1.0, "deb_prediction": 11.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.3}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.9}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.475}, {"v": 12, "p": 0.395}, {"v": 13, "p": 0.131}], "shadow_prob_snapshot": [{"v": 11, "p": 0.469}, {"v": 12, "p": 0.394}, {"v": 13, "p": 0.137}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 1.0267976073543543}
|
||||
{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 0.42968750000000056, "deb_prediction": 10.9, "ensemble": {"p10": 9.7, "median": 10.4, "p90": 10.8}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.8, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.9}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.671}, {"v": 12, "p": 0.329}], "shadow_prob_snapshot": [{"v": 11, "p": 0.656}, {"v": 12, "p": 0.344}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 0.4631927411757732}
|
||||
{"city": "hong kong", "timestamp": "2026-04-11T19:40:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 28.999999999999996, "raw_sigma": 0.3299999999999999, "deb_prediction": 27.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 2.7, "visibility_mi": null, "local_hour": 19.9}, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 0.5}, {"v": 29, "p": 0.5}], "shadow_prob_snapshot": [{"v": 28, "p": 0.5}, {"v": 29, "p": 0.5}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 28.999999999999996, "calibrated_sigma": 0.33392493649594973}
|
||||
{"city": "hong kong", "timestamp": "2026-04-11T19:40:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.09750000000000009, "deb_prediction": 27.3, "ensemble": {"p10": 27.0, "median": 27.3, "p90": 27.8}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 2.7, "visibility_mi": null, "local_hour": 19.9}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.847}, {"v": 28, "p": 0.153}], "shadow_prob_snapshot": [{"v": 27, "p": 0.776}, {"v": 28, "p": 0.224}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.13162500000000013}
|
||||
{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.3, "deb_prediction": 27.6, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.6}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 19.9}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10938964843750025, "deb_prediction": 29.5, "ensemble": {"p10": 28.9, "median": 29.4, "p90": 29.8}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 19.9}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "ankara", "timestamp": "2026-04-11T12:00:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 1.0, "deb_prediction": 7.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 7.5}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 14.983333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.475}, {"v": 9, "p": 0.395}, {"v": 10, "p": 0.131}], "shadow_prob_snapshot": [{"v": 8, "p": 0.451}, {"v": 9, "p": 0.389}, {"v": 10, "p": 0.16}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 1.1241186693749348}
|
||||
{"city": "ankara", "timestamp": "2026-04-11T12:00:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 0.5468749999999998, "deb_prediction": 7.9, "ensemble": {"p10": 6.8, "median": 7.4, "p90": 8.2}, "multi_model": {"Open-Meteo": 7.5, "ECMWF": 7.5, "GFS": 8.2, "ICON": 7.5, "GEM": 8.2, "JMA": 8.3}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 14.983333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.615}, {"v": 9, "p": 0.37}, {"v": 10, "p": 0.015}], "shadow_prob_snapshot": [{"v": 8, "p": 0.594}, {"v": 9, "p": 0.381}, {"v": 10, "p": 0.024}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 0.5978357923824302}
|
||||
{"city": "istanbul", "timestamp": "2026-04-11T14:50:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 1.0, "deb_prediction": 11.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.3}, "max_so_far": 11.0, "observation": {"current_temp": 10.0, "humidity": 57.5, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.983333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.475}, {"v": 12, "p": 0.395}, {"v": 13, "p": 0.131}], "shadow_prob_snapshot": [{"v": 11, "p": 0.469}, {"v": 12, "p": 0.394}, {"v": 13, "p": 0.137}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 1.0267976073543543}
|
||||
{"city": "istanbul", "timestamp": "2026-04-11T14:50:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 0.4494809751157413, "deb_prediction": 10.9, "ensemble": {"p10": 9.7, "median": 10.4, "p90": 10.8}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.8, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 10.0, "humidity": 57.5, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 15.0}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.662}, {"v": 12, "p": 0.338}], "shadow_prob_snapshot": [{"v": 11, "p": 0.647}, {"v": 12, "p": 0.353}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 0.4837415114258855}
|
||||
{"city": "hong kong", "timestamp": "2026-04-11T19:50:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 28.999999999999996, "raw_sigma": 0.3, "deb_prediction": 29.0, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"HKO(港天文)": 29.0}, "max_so_far": 27.6, "observation": {"current_temp": 26.8, "humidity": 81.0, "wind_speed_kt": 3.2, "visibility_mi": null, "local_hour": 20.0}, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 0.5}, {"v": 29, "p": 0.5}], "shadow_prob_snapshot": [{"v": 28, "p": 0.5}, {"v": 29, "p": 0.5}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 28.999999999999996, "calibrated_sigma": 0.29193323618665046}
|
||||
{"city": "hong kong", "timestamp": "2026-04-11T19:50:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.09375000000000008, "deb_prediction": 27.4, "ensemble": {"p10": 27.0, "median": 27.3, "p90": 27.8}, "multi_model": {"HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.8, "humidity": 81.0, "wind_speed_kt": 3.2, "visibility_mi": null, "local_hour": 20.0}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.857}, {"v": 28, "p": 0.143}], "shadow_prob_snapshot": [{"v": 27, "p": 0.785}, {"v": 28, "p": 0.215}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.12656250000000013}
|
||||
{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.3, "deb_prediction": 27.6, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.6}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 20.0}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10938964843750025, "deb_prediction": 29.5, "ensemble": {"p10": 28.9, "median": 29.4, "p90": 29.8}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 20.0}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "taipei", "timestamp": "2026-04-11T19:50:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10938964843749983, "deb_prediction": 30.3, "ensemble": {"p10": 28.8, "median": 29.3, "p90": 29.7}, "multi_model": {"Open-Meteo": 29.7, "ECMWF": 30.9, "GFS": 32.4, "ICON": 29.7, "GEM": 29.6, "JMA": 29.3}, "max_so_far": 32.1, "observation": {"current_temp": 27.6, "humidity": 73.0, "wind_speed_kt": 2.9, "visibility_mi": null, "local_hour": 20.1}, "peak_status": "past", "prob_snapshot": [{"v": 32, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "ankara", "timestamp": "2026-04-12T02:00:00.000Z", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 8.5, "raw_sigma": 1.0, "deb_prediction": 8.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 8.5}, "max_so_far": 2.0, "observation": {"current_temp": 0.0, "humidity": null, "wind_speed_kt": 2.0, "visibility_mi": null, "local_hour": 5.166666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.35}, {"v": 9, "p": 0.35}, {"v": 10, "p": 0.139}, {"v": 7, "p": 0.139}], "shadow_prob_snapshot": [{"v": 9, "p": 0.359}, {"v": 8, "p": 0.359}, {"v": 7, "p": 0.132}, {"v": 10, "p": 0.132}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.5, "calibrated_sigma": 0.955}
|
||||
{"city": "ankara", "timestamp": "2026-04-12T02:00:00.000Z", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 8.93, "raw_sigma": 0.3515625000000001, "deb_prediction": 9.1, "ensemble": {"p10": 8.5, "median": 9.0, "p90": 9.4}, "multi_model": {"Open-Meteo": 8.5, "ECMWF": 7.9, "GFS": 10.0, "ICON": 8.5, "GEM": 8.9, "JMA": 9.2}, "max_so_far": 2.0, "observation": {"current_temp": 0.0, "humidity": null, "wind_speed_kt": 2.0, "visibility_mi": null, "local_hour": 5.166666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 9, "p": 0.837}, {"v": 8, "p": 0.111}, {"v": 10, "p": 0.052}], "shadow_prob_snapshot": [{"v": 9, "p": 0.851}, {"v": 8, "p": 0.102}, {"v": 10, "p": 0.046}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.93, "calibrated_sigma": 0.3389843750000001}
|
||||
{"city": "istanbul", "timestamp": "2026-04-12T04:50:00+03:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 11.7, "raw_sigma": 1.0, "deb_prediction": 11.7, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.7}, "max_so_far": 8.0, "observation": {"current_temp": 7.0, "humidity": 52.7, "wind_speed_kt": 1.0, "visibility_mi": null, "local_hour": 5.183333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 12, "p": 0.374}, {"v": 11, "p": 0.311}, {"v": 13, "p": 0.179}, {"v": 10, "p": 0.103}], "shadow_prob_snapshot": [{"v": 12, "p": 0.387}, {"v": 11, "p": 0.316}, {"v": 13, "p": 0.174}, {"v": 10, "p": 0.095}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.7, "calibrated_sigma": 0.955}
|
||||
{"city": "istanbul", "timestamp": "2026-04-12T04:50:00+03:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 11.52, "raw_sigma": 0.390625, "deb_prediction": 11.5, "ensemble": {"p10": 10.5, "median": 11.1, "p90": 11.5}, "multi_model": {"Open-Meteo": 11.7, "ECMWF": 11.9, "GFS": 11.1, "ICON": 11.7, "GEM": 12.8, "JMA": 10.1}, "max_so_far": 8.0, "observation": {"current_temp": 7.0, "humidity": 52.7, "wind_speed_kt": 1.0, "visibility_mi": null, "local_hour": 5.183333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 12, "p": 0.52}, {"v": 11, "p": 0.48}], "shadow_prob_snapshot": [{"v": 12, "p": 0.521}, {"v": 11, "p": 0.479}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.52, "calibrated_sigma": 0.37609375}
|
||||
{"city": "hong kong", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 28.999999999999996, "raw_sigma": 0.75, "deb_prediction": 28.2, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.5, "HKO(港天文)": 29.0}, "max_so_far": 27.2, "observation": {"current_temp": 27.2, "humidity": 77.0, "wind_speed_kt": 5.4, "visibility_mi": null, "local_hour": 10.2}, "peak_status": "before", "prob_snapshot": [{"v": 28, "p": 0.412}, {"v": 29, "p": 0.412}, {"v": 27, "p": 0.088}, {"v": 30, "p": 0.088}], "shadow_prob_snapshot": [{"v": 28, "p": 0.413}, {"v": 29, "p": 0.413}, {"v": 27, "p": 0.087}, {"v": 30, "p": 0.087}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 28.999999999999996, "calibrated_sigma": 0.7478013193453208}
|
||||
{"city": "hong kong", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 27.71, "raw_sigma": 0.30937500000000073, "deb_prediction": 27.6, "ensemble": {"p10": 28.1, "median": 28.2, "p90": 28.5}, "multi_model": {"Open-Meteo": 27.5, "HKO(港天文)": 29.0, "ECMWF": 27.0, "GFS": 27.2, "ICON": 27.5, "GEM": 27.1, "JMA": 27.6}, "max_so_far": 27.2, "observation": {"current_temp": 27.2, "humidity": 77.0, "wind_speed_kt": 5.4, "visibility_mi": null, "local_hour": 10.2}, "peak_status": "before", "prob_snapshot": [{"v": 27, "p": 0.824}, {"v": 28, "p": 0.176}], "shadow_prob_snapshot": [{"v": 27, "p": 0.814}, {"v": 28, "p": 0.186}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.71, "calibrated_sigma": 0.32103944874938267}
|
||||
{"city": "taipei", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 30.9, "raw_sigma": 2.6999999999999993, "deb_prediction": 30.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 30.9}, "max_so_far": 29.2, "observation": {"current_temp": 29.2, "humidity": 64.0, "wind_speed_kt": 1.2, "visibility_mi": null, "local_hour": 10.2}, "peak_status": "before", "prob_snapshot": [{"v": 31, "p": 0.182}, {"v": 30, "p": 0.173}, {"v": 32, "p": 0.168}, {"v": 29, "p": 0.143}], "shadow_prob_snapshot": [{"v": 31, "p": 0.185}, {"v": 30, "p": 0.175}, {"v": 32, "p": 0.17}, {"v": 29, "p": 0.143}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 30.9, "calibrated_sigma": 2.6381596739291915}
|
||||
{"city": "taipei", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 30.689999999999998, "raw_sigma": 2.8574999999999995, "deb_prediction": 30.9, "ensemble": {"p10": 29.7, "median": 30.2, "p90": 30.6}, "multi_model": {"Open-Meteo": 30.9, "ECMWF": 30.9, "GFS": 32.9, "ICON": 30.9, "GEM": 28.9, "JMA": 30.8}, "max_so_far": 29.2, "observation": {"current_temp": 29.2, "humidity": 64.0, "wind_speed_kt": 1.2, "visibility_mi": null, "local_hour": 10.2}, "peak_status": "before", "prob_snapshot": [{"v": 31, "p": 0.179}, {"v": 30, "p": 0.175}, {"v": 32, "p": 0.163}, {"v": 29, "p": 0.152}], "shadow_prob_snapshot": [{"v": 31, "p": 0.183}, {"v": 30, "p": 0.178}, {"v": 32, "p": 0.165}, {"v": 29, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 30.689999999999998, "calibrated_sigma": 2.778122088606427}
|
||||
{"city": "ankara", "timestamp": "2026-04-12T02:00:00.000Z", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 8.5, "raw_sigma": 1.0, "deb_prediction": 8.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 8.5}, "max_so_far": 2.0, "observation": {"current_temp": 0.0, "humidity": null, "wind_speed_kt": 2.0, "visibility_mi": null, "local_hour": 5.216666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.35}, {"v": 9, "p": 0.35}, {"v": 10, "p": 0.139}, {"v": 7, "p": 0.139}], "shadow_prob_snapshot": [{"v": 9, "p": 0.359}, {"v": 8, "p": 0.359}, {"v": 7, "p": 0.132}, {"v": 10, "p": 0.132}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.5, "calibrated_sigma": 0.955}
|
||||
{"city": "istanbul", "timestamp": "2026-04-12T04:50:00+03:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 11.7, "raw_sigma": 1.0, "deb_prediction": 11.7, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.7}, "max_so_far": 8.0, "observation": {"current_temp": 7.0, "humidity": 52.7, "wind_speed_kt": 1.0, "visibility_mi": null, "local_hour": 5.233333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 12, "p": 0.374}, {"v": 11, "p": 0.311}, {"v": 13, "p": 0.179}, {"v": 10, "p": 0.103}], "shadow_prob_snapshot": [{"v": 12, "p": 0.387}, {"v": 11, "p": 0.316}, {"v": 13, "p": 0.174}, {"v": 10, "p": 0.095}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.7, "calibrated_sigma": 0.955}
|
||||
{"city": "ankara", "timestamp": "2026-04-12T02:00:00.000Z", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 8.93, "raw_sigma": 0.3515625000000001, "deb_prediction": 9.1, "ensemble": {"p10": 8.5, "median": 9.0, "p90": 9.4}, "multi_model": {"Open-Meteo": 8.5, "ECMWF": 7.9, "GFS": 10.0, "ICON": 8.5, "GEM": 8.9, "JMA": 9.2}, "max_so_far": 2.0, "observation": {"current_temp": 0.0, "humidity": null, "wind_speed_kt": 2.0, "visibility_mi": null, "local_hour": 5.25}, "peak_status": "before", "prob_snapshot": [{"v": 9, "p": 0.837}, {"v": 8, "p": 0.111}, {"v": 10, "p": 0.052}], "shadow_prob_snapshot": [{"v": 9, "p": 0.851}, {"v": 8, "p": 0.102}, {"v": 10, "p": 0.046}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.93, "calibrated_sigma": 0.3389843750000001}
|
||||
{"city": "istanbul", "timestamp": "2026-04-12T04:50:00+03:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 11.52, "raw_sigma": 0.390625, "deb_prediction": 11.5, "ensemble": {"p10": 10.5, "median": 11.1, "p90": 11.5}, "multi_model": {"Open-Meteo": 11.7, "ECMWF": 11.9, "GFS": 11.1, "ICON": 11.7, "GEM": 12.8, "JMA": 10.1}, "max_so_far": 8.0, "observation": {"current_temp": 7.0, "humidity": 52.7, "wind_speed_kt": 1.0, "visibility_mi": null, "local_hour": 5.25}, "peak_status": "before", "prob_snapshot": [{"v": 12, "p": 0.52}, {"v": 11, "p": 0.48}], "shadow_prob_snapshot": [{"v": 12, "p": 0.521}, {"v": 11, "p": 0.479}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.52, "calibrated_sigma": 0.37609375}
|
||||
{"city": "taipei", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 30.9, "raw_sigma": 2.6999999999999993, "deb_prediction": 30.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 30.9}, "max_so_far": 29.2, "observation": {"current_temp": 29.2, "humidity": 64.0, "wind_speed_kt": 1.2, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 31, "p": 0.182}, {"v": 30, "p": 0.173}, {"v": 32, "p": 0.168}, {"v": 29, "p": 0.143}], "shadow_prob_snapshot": [{"v": 31, "p": 0.185}, {"v": 30, "p": 0.175}, {"v": 32, "p": 0.17}, {"v": 29, "p": 0.143}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 30.9, "calibrated_sigma": 2.6381596739291915}
|
||||
{"city": "hong kong", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 28.999999999999996, "raw_sigma": 0.75, "deb_prediction": 28.2, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.5, "HKO(港天文)": 29.0}, "max_so_far": 27.2, "observation": {"current_temp": 27.2, "humidity": 77.0, "wind_speed_kt": 5.4, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 28, "p": 0.412}, {"v": 29, "p": 0.412}, {"v": 27, "p": 0.088}, {"v": 30, "p": 0.088}], "shadow_prob_snapshot": [{"v": 28, "p": 0.413}, {"v": 29, "p": 0.413}, {"v": 27, "p": 0.087}, {"v": 30, "p": 0.087}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 28.999999999999996, "calibrated_sigma": 0.7478013193453208}
|
||||
{"city": "taipei", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 30.689999999999998, "raw_sigma": 2.8574999999999995, "deb_prediction": 30.9, "ensemble": {"p10": 29.7, "median": 30.2, "p90": 30.6}, "multi_model": {"Open-Meteo": 30.9, "ECMWF": 30.9, "GFS": 32.9, "ICON": 30.9, "GEM": 28.9, "JMA": 30.8}, "max_so_far": 29.2, "observation": {"current_temp": 29.2, "humidity": 64.0, "wind_speed_kt": 1.2, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 31, "p": 0.179}, {"v": 30, "p": 0.175}, {"v": 32, "p": 0.163}, {"v": 29, "p": 0.152}], "shadow_prob_snapshot": [{"v": 31, "p": 0.183}, {"v": 30, "p": 0.178}, {"v": 32, "p": 0.165}, {"v": 29, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 30.689999999999998, "calibrated_sigma": 2.778122088606427}
|
||||
{"city": "hong kong", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 27.71, "raw_sigma": 0.30937500000000073, "deb_prediction": 27.6, "ensemble": {"p10": 28.1, "median": 28.2, "p90": 28.5}, "multi_model": {"Open-Meteo": 27.5, "HKO(港天文)": 29.0, "ECMWF": 27.0, "GFS": 27.2, "ICON": 27.5, "GEM": 27.1, "JMA": 27.6}, "max_so_far": 27.2, "observation": {"current_temp": 27.2, "humidity": 77.0, "wind_speed_kt": 5.4, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 27, "p": 0.824}, {"v": 28, "p": 0.176}], "shadow_prob_snapshot": [{"v": 27, "p": 0.814}, {"v": 28, "p": 0.186}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.71, "calibrated_sigma": 0.32103944874938267}
|
||||
{"city": "ankara", "timestamp": "2026-04-13T10:20:00.000Z", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": 10.8, "raw_sigma": 2.5, "deb_prediction": 10.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 10.6, "ECMWF": 11.7, "GFS": 11.3, "ICON": 10.6, "GEM": 10.8, "JMA": 10.5}, "max_so_far": 9.0, "observation": {"current_temp": 9.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 13.566666666666666}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.195}, {"v": 10, "p": 0.186}, {"v": 12, "p": 0.175}, {"v": 9, "p": 0.152}], "shadow_prob_snapshot": [{"v": 11, "p": 0.199}, {"v": 10, "p": 0.189}, {"v": 12, "p": 0.177}, {"v": 9, "p": 0.152}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 10.8, "calibrated_sigma": 2.4191943428283595}
|
||||
{"city": "ankara", "timestamp": "2026-04-13T10:20:00.000Z", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": 10.77, "raw_sigma": 3.7124999999999995, "deb_prediction": 10.9, "ensemble": {"p10": 9.8, "median": 10.7, "p90": 11.6}, "multi_model": {"Open-Meteo": 10.6, "ECMWF": 11.7, "GFS": 11.3, "ICON": 10.6, "GEM": 10.8, "JMA": 10.5}, "max_so_far": 9.0, "observation": {"current_temp": 9.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 13.583333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.15}, {"v": 10, "p": 0.148}, {"v": 12, "p": 0.143}, {"v": 9, "p": 0.135}], "shadow_prob_snapshot": [{"v": 11, "p": 0.173}, {"v": 10, "p": 0.168}, {"v": 12, "p": 0.16}, {"v": 9, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 10.77, "calibrated_sigma": 3.0}
|
||||
{"city": "istanbul", "timestamp": "2026-04-13T13:20:00+03:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": 12.5, "raw_sigma": 1.7999999999999998, "deb_prediction": 12.2, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 12.5, "ECMWF": 12.4, "GFS": 12.3, "ICON": 12.5, "GEM": 13.5, "JMA": 10.4}, "max_so_far": 12.0, "observation": {"current_temp": 12.0, "humidity": 46.9, "wind_speed_kt": 14.0, "visibility_mi": null, "local_hour": 13.583333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 12, "p": 0.298}, {"v": 13, "p": 0.298}, {"v": 14, "p": 0.22}, {"v": 15, "p": 0.121}], "shadow_prob_snapshot": [{"v": 12, "p": 0.294}, {"v": 13, "p": 0.294}, {"v": 14, "p": 0.22}, {"v": 15, "p": 0.124}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 12.5, "calibrated_sigma": 1.8402601718515887}
|
||||
{"city": "hong kong", "timestamp": "2026-04-13T18:20:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.47999999999999987, "deb_prediction": 27.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.5, "humidity": 76.0, "wind_speed_kt": 4.3, "visibility_mi": null, "local_hour": 18.583333333333332}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "hong kong", "timestamp": "2026-04-13T18:20:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10952569444444488, "deb_prediction": 27.5, "ensemble": {"p10": 28.0, "median": 28.3, "p90": 28.6}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.5, "humidity": 76.0, "wind_speed_kt": 4.3, "visibility_mi": null, "local_hour": 18.583333333333332}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "tokyo", "timestamp": "2026-04-13T10:00:00.000Z", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.4349999999999998, "deb_prediction": 21.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 20.3, "ECMWF": 22.8, "GFS": 22.0, "ICON": 22.6, "GEM": 23.2, "JMA": 20.3}, "max_so_far": 23.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 19.583333333333332}, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "hong kong", "timestamp": "2026-04-13T18:30:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.47999999999999987, "deb_prediction": 27.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.4, "humidity": 77.0, "wind_speed_kt": 2.2, "visibility_mi": null, "local_hour": 18.666666666666668}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "hong kong", "timestamp": "2026-04-13T18:30:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10952569444444488, "deb_prediction": 27.5, "ensemble": {"p10": 28.0, "median": 28.3, "p90": 28.6}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.4, "humidity": 77.0, "wind_speed_kt": 2.2, "visibility_mi": null, "local_hour": 18.666666666666668}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "busan", "timestamp": "2026-04-14T03:00:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 19.3, "raw_sigma": 1.8499999999999996, "deb_prediction": 17.1, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 15.8, "ECMWF": 17.1, "GFS": 16.3, "ICON": 18.4, "GEM": 19.5, "JMA": 15.8}, "max_so_far": 19.0, "observation": {"current_temp": 19.0, "humidity": null, "wind_speed_kt": 8.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 19, "p": 0.321}, {"v": 20, "p": 0.303}, {"v": 21, "p": 0.215}, {"v": 22, "p": 0.115}], "shadow_prob_snapshot": [{"v": 19, "p": 0.254}, {"v": 20, "p": 0.247}, {"v": 21, "p": 0.204}, {"v": 22, "p": 0.144}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 19.3, "calibrated_sigma": 2.4974999999999996}
|
||||
{"city": "busan", "timestamp": "2026-04-14T03:00:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 19.3, "raw_sigma": 0.9765625, "deb_prediction": 17.1, "ensemble": {"p10": 15.0, "median": 16.0, "p90": 17.5}, "multi_model": {"Open-Meteo": 15.8, "ECMWF": 17.1, "GFS": 16.3, "ICON": 18.4, "GEM": 19.5, "JMA": 15.8}, "max_so_far": 19.0, "observation": {"current_temp": 19.0, "humidity": null, "wind_speed_kt": 8.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 19, "p": 0.48}, {"v": 20, "p": 0.396}, {"v": 21, "p": 0.125}], "shadow_prob_snapshot": [{"v": 19, "p": 0.4}, {"v": 20, "p": 0.359}, {"v": 21, "p": 0.186}, {"v": 22, "p": 0.055}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 19.3, "calibrated_sigma": 1.318359375}
|
||||
{"city": "seoul", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 23.3, "raw_sigma": 3.8000000000000007, "deb_prediction": 18.7, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 16.3, "ECMWF": 22.3, "GFS": 14.7, "ICON": 21.0, "GEM": 21.8, "JMA": 16.3}, "max_so_far": 23.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.184}, {"v": 24, "p": 0.181}, {"v": 25, "p": 0.167}, {"v": 26, "p": 0.143}], "shadow_prob_snapshot": [{"v": 23, "p": 0.221}, {"v": 24, "p": 0.216}, {"v": 25, "p": 0.189}, {"v": 26, "p": 0.148}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.3, "calibrated_sigma": 3.0}
|
||||
{"city": "seoul", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 23.3, "raw_sigma": 0.6666666666666673, "deb_prediction": 18.7, "ensemble": {"p10": 17.7, "median": 18.2, "p90": 19.3}, "multi_model": {"Open-Meteo": 16.3, "ECMWF": 22.3, "GFS": 14.7, "ICON": 21.0, "GEM": 21.8, "JMA": 16.3}, "max_so_far": 23.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.569}, {"v": 24, "p": 0.391}, {"v": 25, "p": 0.04}], "shadow_prob_snapshot": [{"v": 23, "p": 0.498}, {"v": 24, "p": 0.398}, {"v": 25, "p": 0.104}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.3, "calibrated_sigma": 0.9000000000000009}
|
||||
{"city": "tokyo", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 22.7, "raw_sigma": 1.3499999999999996, "deb_prediction": 22.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 20.6, "ECMWF": 22.9, "GFS": 22.7, "ICON": 21.7, "GEM": 23.3, "JMA": 20.6}, "max_so_far": 20.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 7.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.285}, {"v": 22, "p": 0.257}, {"v": 24, "p": 0.188}, {"v": 21, "p": 0.137}], "shadow_prob_snapshot": [{"v": 23, "p": 0.294}, {"v": 22, "p": 0.263}, {"v": 24, "p": 0.188}, {"v": 21, "p": 0.134}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 22.7, "calibrated_sigma": 1.3014441113240975}
|
||||
{"city": "tokyo", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 23.089999999999996, "raw_sigma": 1.2890624999999998, "deb_prediction": 22.3, "ensemble": {"p10": 21.8, "median": 24.0, "p90": 25.0}, "multi_model": {"Open-Meteo": 20.6, "ECMWF": 22.9, "GFS": 22.7, "ICON": 21.7, "GEM": 23.3, "JMA": 20.6}, "max_so_far": 20.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 7.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.303}, {"v": 24, "p": 0.24}, {"v": 22, "p": 0.216}, {"v": 25, "p": 0.107}], "shadow_prob_snapshot": [{"v": 23, "p": 0.313}, {"v": 24, "p": 0.244}, {"v": 22, "p": 0.218}, {"v": 25, "p": 0.103}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.089999999999996, "calibrated_sigma": 1.2428312132581776}
|
||||
{"city": "busan", "timestamp": "2026-04-15T05:00:00.000Z", "date": "2026-04-15", "temp_symbol": "°C", "raw_mu": 22.3, "raw_sigma": 0.5699999999999995, "deb_prediction": 21.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 21.9}, "max_so_far": 22.0, "observation": {"current_temp": 21.0, "humidity": null, "wind_speed_kt": 11.0, "visibility_mi": null, "local_hour": 14.866666666666667}, "peak_status": "past", "prob_snapshot": [{"v": 22, "p": 0.606}, {"v": 23, "p": 0.375}, {"v": 24, "p": 0.019}], "shadow_prob_snapshot": [{"v": 22, "p": 0.602}, {"v": 23, "p": 0.377}, {"v": 24, "p": 0.021}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 22.3, "calibrated_sigma": 0.5797635451114579}
|
||||
{"city": "busan", "timestamp": "2026-04-15T05:00:00.000Z", "date": "2026-04-15", "temp_symbol": "°C", "raw_mu": 23.4, "raw_sigma": 0.5699999999999995, "deb_prediction": 23.0, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 21.9, "ECMWF": 24.7, "GFS": 23.4, "ICON": 21.2, "GEM": 24.9, "JMA": 21.9}, "max_so_far": 22.0, "observation": {"current_temp": 21.0, "humidity": null, "wind_speed_kt": 11.0, "visibility_mi": null, "local_hour": 14.866666666666667}, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.513}, {"v": 24, "p": 0.404}, {"v": 22, "p": 0.057}, {"v": 25, "p": 0.027}], "shadow_prob_snapshot": [{"v": 23, "p": 0.518}, {"v": 24, "p": 0.405}, {"v": 22, "p": 0.053}, {"v": 25, "p": 0.024}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.4, "calibrated_sigma": 0.5587884763366653}
|
||||
{"city": "busan", "timestamp": "2026-04-15T05:00:00.000Z", "date": "2026-04-15", "temp_symbol": "°C", "raw_mu": 23.939999999999998, "raw_sigma": 0.6621236111111106, "deb_prediction": 23.1, "ensemble": {"p10": 23.5, "median": 23.8, "p90": 24.3}, "multi_model": {"Open-Meteo": 21.9, "ECMWF": 24.7, "GFS": 24.0, "ICON": 21.2, "GEM": 24.9, "JMA": 21.9}, "max_so_far": 22.0, "observation": {"current_temp": 21.0, "humidity": null, "wind_speed_kt": 11.0, "visibility_mi": null, "local_hour": 14.866666666666667}, "peak_status": "past", "prob_snapshot": [{"v": 24, "p": 0.553}, {"v": 23, "p": 0.241}, {"v": 25, "p": 0.191}, {"v": 22, "p": 0.015}], "shadow_prob_snapshot": [{"v": 24, "p": 0.565}, {"v": 23, "p": 0.236}, {"v": 25, "p": 0.186}, {"v": 22, "p": 0.013}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.939999999999998, "calibrated_sigma": 0.6441124755606287}
|
||||
|
||||
@@ -110,6 +110,8 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `TELEGRAM_ALERT_PUSH_ENABLED`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`(已退役,建议保持 `false`)
|
||||
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED`
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_ENABLED`
|
||||
|
||||
### 4.3 L3:运行调优项
|
||||
|
||||
@@ -129,12 +131,29 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- `TAF_CACHE_TTL_SEC`
|
||||
- `POLYWEATHER_PREWARM_INTERVAL_SEC`
|
||||
- `POLYWEATHER_PREWARM_JITTER_SEC`
|
||||
- `POLYWEATHER_PREWARM_CITIES`
|
||||
- `POLYWEATHER_PREWARM_INCLUDE_DETAIL`
|
||||
- `POLYWEATHER_PREWARM_INCLUDE_MARKET`
|
||||
- `POLYWEATHER_PREWARM_FORCE_REFRESH`
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_MODEL`
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC`
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC`
|
||||
|
||||
策略:
|
||||
|
||||
- 先用默认值
|
||||
- 出现性能或运维问题时再调
|
||||
|
||||
当前默认预热名单优先覆盖:
|
||||
|
||||
- 亚洲:Shanghai、Beijing、Shenzhen、Wuhan、Chengdu、Chongqing、Hong Kong、Taipei、Singapore、Tokyo、Seoul、Busan
|
||||
- 中东:Ankara、Istanbul
|
||||
- 欧洲:London、Paris、Madrid
|
||||
|
||||
默认不再包含美国城市;如果线上 `.env` 已手动设置 `POLYWEATHER_PREWARM_CITIES`,则会以你的显式配置为准。
|
||||
|
||||
### 4.4 L4:敏感项
|
||||
|
||||
这些变量不应写进公开文档截图,也不应提交到仓库。
|
||||
@@ -148,6 +167,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `METEOBLUE_API_KEY`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `POLYMARKET_SECRET_KEY`
|
||||
- `GROQ_API_KEY`
|
||||
|
||||
## 5. 推荐部署矩阵
|
||||
|
||||
@@ -173,6 +193,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL`
|
||||
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS`
|
||||
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS`
|
||||
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES`
|
||||
|
||||
说明:
|
||||
|
||||
@@ -203,6 +226,9 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
|
||||
NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
|
||||
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
|
||||
```
|
||||
|
||||
### 6.2 后端最小变量
|
||||
@@ -234,6 +260,16 @@ TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true
|
||||
POLYWEATHER_PREWARM_INTERVAL_SEC=300
|
||||
POLYWEATHER_PREWARM_JITTER_SEC=20
|
||||
POLYWEATHER_PREWARM_INCLUDE_DETAIL=true
|
||||
POLYWEATHER_PREWARM_INCLUDE_MARKET=true
|
||||
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
||||
POLYWEATHER_GROQ_COMMENTARY_ENABLED=false
|
||||
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
```
|
||||
|
||||
说明:
|
||||
@@ -247,8 +283,44 @@ POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
- 机器人市场监控当前以 `关注清单` 为主,按固定间隔主动推送。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。
|
||||
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true` 时,建议同时启用独立 worker 或 bot 内嵌预热线程。
|
||||
- `POLYWEATHER_BACKEND_URL` 仅在独立 `polyweather_prewarm` worker 容器中使用,建议设为 `http://polyweather_web:8000`,不要写 `127.0.0.1`。
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_ENABLED=false` 表示默认仍走规则文案;只有在确实配置了 `GROQ_API_KEY` 时才建议开启。
|
||||
|
||||
### 6.3 机器人市场监控建议配置
|
||||
### 6.3 Dashboard 预热 worker 推荐变量
|
||||
|
||||
```env
|
||||
POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true
|
||||
POLYWEATHER_PREWARM_INTERVAL_SEC=300
|
||||
POLYWEATHER_PREWARM_JITTER_SEC=20
|
||||
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
|
||||
POLYWEATHER_PREWARM_INCLUDE_DETAIL=true
|
||||
POLYWEATHER_PREWARM_INCLUDE_MARKET=true
|
||||
POLYWEATHER_PREWARM_FORCE_REFRESH=false
|
||||
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这组变量用于后台定向预热热点城市,避免用户点击城市时才冷启动拉 detail。
|
||||
- 如果使用独立 `polyweather_prewarm` 容器,`POLYWEATHER_BACKEND_URL` 必须指向容器网络中的 `polyweather_web`。
|
||||
|
||||
### 6.4 Groq 解读增强层
|
||||
|
||||
```env
|
||||
POLYWEATHER_GROQ_COMMENTARY_ENABLED=true
|
||||
GROQ_API_KEY=...
|
||||
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。
|
||||
- Groq 调用失败时,系统会自动回退到规则文案。
|
||||
|
||||
### 6.5 机器人市场监控建议配置
|
||||
|
||||
这套配置用于替代旧的钱包异动监听,围绕市场本身做两类推送:
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
- Framework Preset: `Next.js`
|
||||
- Root Directory: `frontend`
|
||||
- Build Command: `npm run build`
|
||||
- Install Command: `npm install`
|
||||
- Install Command: `npm ci`
|
||||
|
||||
如果仓库已经连接过 Vercel,通常只需要确认 `Root Directory` 仍然是 `frontend`。
|
||||
|
||||
@@ -98,6 +98,20 @@ NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
|
||||
只影响按钮跳转,不影响核心页面加载。
|
||||
|
||||
### 6. 前端观测与预热开关(推荐默认关闭)
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
|
||||
NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
|
||||
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false`:关闭前端自建埋点。
|
||||
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false`:关闭前端 Web Vitals 上报。
|
||||
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false`:关闭首页全量城市 summary 预热,避免白白消耗 Vercel function / edge 成本。
|
||||
|
||||
## 五、支付配置与旧部署治理
|
||||
|
||||
支付区现在有一层额外防护:
|
||||
@@ -209,3 +223,33 @@ NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||
```
|
||||
|
||||
这是钱包连接的必需项。
|
||||
|
||||
## 十、Vercel 成本与节流建议
|
||||
|
||||
### 1. 建议先关闭的项目级能力
|
||||
|
||||
- `Web Analytics`
|
||||
- `Speed Insights`
|
||||
|
||||
它们对排查前端体验有价值,但在 Hobby / 低预算阶段会额外消耗数据点和边缘资源。
|
||||
|
||||
### 2. 建议加的 Firewall 自定义规则
|
||||
|
||||
如果你的 Next.js 项目根本不提供 WordPress / PHP 路径,建议在 Vercel Firewall 里先 `Log` 再 `Deny` 这条规则:
|
||||
|
||||
```regex
|
||||
(^/(wp-admin|wp-includes|wp-content|wp-login|wordpress|xmlrpc\.php))|\.php($|\?)
|
||||
```
|
||||
|
||||
目的:
|
||||
|
||||
- 在边缘层提前拦截 WordPress / PHP 扫描流量
|
||||
- 避免无效请求继续触发 middleware 与 route handler
|
||||
|
||||
### 3. 建议的上线前检查
|
||||
|
||||
除了功能本身,额外确认:
|
||||
|
||||
1. `Web Analytics` 和 `Speed Insights` 是否真的关闭
|
||||
2. `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS` / `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS` / `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES` 是否保持关闭
|
||||
3. Firewall 自定义规则是否已从 `Log` 切到 `Deny`
|
||||
|
||||
+34
-2
@@ -1,6 +1,6 @@
|
||||
# 外部监控与告警说明
|
||||
|
||||
最后更新:`2026-04-01`
|
||||
最后更新:`2026-04-10`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
@@ -105,7 +105,39 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
|
||||
|
||||
任何一项失败都会非零退出,适合挂到 crontab 或 systemd timer。
|
||||
|
||||
## 8. 备注
|
||||
## 8. 当前内置运行态观测
|
||||
|
||||
除了 Prometheus / Grafana 这套外部监控,当前后端还内置了更贴业务的只读运行态:
|
||||
|
||||
- `/api/system/status`
|
||||
- `/ops`
|
||||
|
||||
目前已覆盖:
|
||||
|
||||
- `prewarm` worker 是否启用、线程 / heartbeat 是否活着
|
||||
- 最近一轮 prewarm 的:
|
||||
- `cycle_count`
|
||||
- `success_count / failure_count`
|
||||
- `last_started_at / last_finished_at`
|
||||
- `last_summary_ok / last_detail_ok / last_market_ok`
|
||||
- 缓存桶条目数:
|
||||
- `api_cache`
|
||||
- `metar`
|
||||
- `taf`
|
||||
- `nmc`
|
||||
- `settlement`
|
||||
- `open_meteo forecast / ensemble / multi-model`
|
||||
- `summary` 分析缓存命中率:
|
||||
- `total_requests`
|
||||
- `cache_hits / cache_misses`
|
||||
- `hit_rate / miss_rate`
|
||||
|
||||
这意味着:
|
||||
|
||||
- 外部监控负责“服务活没活、错误有没有暴增”
|
||||
- `/ops` 和 `/api/system/status` 负责“预热有没有真的跑、缓存有没有真的被打热”
|
||||
|
||||
## 9. 备注
|
||||
|
||||
这套监控现在已经具备:
|
||||
|
||||
|
||||
+28
-1
@@ -1,6 +1,6 @@
|
||||
# Ops 运营后台说明
|
||||
|
||||
最后更新:`2026-04-01`
|
||||
最后更新:`2026-04-10`
|
||||
|
||||
## 1. 入口
|
||||
|
||||
@@ -25,6 +25,8 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
- 系统健康
|
||||
- SQLite / rollout / metrics 摘要
|
||||
- 支付运行态
|
||||
- prewarm worker 运行态
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
- 当前会员
|
||||
- 周榜
|
||||
- 支付异常单
|
||||
@@ -99,6 +101,31 @@ python scripts/reconcile_subscription_by_email.py --email <user_email>
|
||||
|
||||
## 7. 备注
|
||||
|
||||
### 7.1 当前 prewarm / 缓存观测项
|
||||
|
||||
`/ops` 里的系统状态卡目前已额外展示:
|
||||
|
||||
- `prewarm` 是否启用
|
||||
- `thread_alive` / `heartbeat_age_sec`
|
||||
- 最近一轮:
|
||||
- `cycle_count`
|
||||
- `success_count / failure_count`
|
||||
- `last_started_at / last_finished_at`
|
||||
- `last_summary_ok / last_detail_ok / last_market_ok`
|
||||
- 缓存桶数量:
|
||||
- `api_cache`
|
||||
- `metar`
|
||||
- `taf`
|
||||
- `nmc`
|
||||
- `settlement`
|
||||
- `open_meteo forecast / ensemble / multi-model`
|
||||
- `summary` 层缓存命中率:
|
||||
- `total_requests`
|
||||
- `cache_hits / cache_misses`
|
||||
- `hit_rate / miss_rate`
|
||||
|
||||
### 7.2 当前用途边界
|
||||
|
||||
`/ops` 是运营后台最小版,不是完整 Admin 平台。当前目标是:
|
||||
|
||||
- 让会员、积分、支付事故、系统状态可查
|
||||
|
||||
+24
-5
@@ -24,21 +24,26 @@ PolyWeather Pro 的生产前端工程。
|
||||
- 主站 Dashboard 支持地图、城市详情、今日日内分析、历史准确率对账和账户中心
|
||||
- `/docs` 已提供公开双语产品文档中心,解释日内结构信号、TAF、结算来源和历史对账
|
||||
- 今日日内分析支持:
|
||||
- 峰值窗口感知的近地面结构信号
|
||||
- 高空结构信号
|
||||
- 交易动作卡
|
||||
- `锚点状态`
|
||||
- `当前节奏`
|
||||
- `当前命中胜率`
|
||||
- `模型区间与分歧`
|
||||
- `今日日内结构信号`
|
||||
- 非香港机场城市的 `TAF` 时段提示与走势图联动
|
||||
- 历史对账支持:
|
||||
- `DEB / 最佳单模型 / 实测最高温` 对比
|
||||
- 峰值前 12 小时 `DEB` 参考(近似)
|
||||
- `/ops` 已支持桌面表格 + 手机端卡片化视图
|
||||
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
|
||||
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
- `/ops` 现已展示 prewarm worker 运行态、缓存桶状态与 summary cache hit/miss
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env.local
|
||||
npm install
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
@@ -86,6 +91,11 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
# 社群入口
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
|
||||
# 推荐默认关闭的前端观测 / 预热开关
|
||||
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
|
||||
NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
|
||||
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
|
||||
```
|
||||
|
||||
更完整的 Vercel 配置说明见:
|
||||
@@ -136,6 +146,8 @@ Ops:
|
||||
|
||||
- 系统状态
|
||||
- SQLite / rollout / 支付运行态
|
||||
- prewarm worker 运行态
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
- 用户查询
|
||||
- 当前会员
|
||||
- 本周积分榜
|
||||
@@ -169,6 +181,13 @@ Ops:
|
||||
- `cities` / `summary` / `history`:`ETag + Cache-Control`
|
||||
- `summary?force_refresh=true`:`no-store`
|
||||
- 支付相关路由:`no-store`
|
||||
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail
|
||||
|
||||
## Vercel 节流建议
|
||||
|
||||
- 生产环境建议关闭 `Web Analytics` 和 `Speed Insights`
|
||||
- 建议把自建 `app analytics / web vitals / eager city summaries` 默认保持关闭
|
||||
- 如果你部署在 Vercel,可在 Firewall 中加一条 `WordPress / php scanner` 拦截规则,避免无效扫描白白触发 middleware
|
||||
|
||||
## AGPL 与商用边界说明
|
||||
|
||||
@@ -177,4 +196,4 @@ Ops:
|
||||
|
||||
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
|
||||
|
||||
最后更新:`2026-03-24`
|
||||
最后更新:`2026-04-10`
|
||||
|
||||
@@ -16,10 +16,18 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/auth/me`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/auth/me`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: false,
|
||||
@@ -30,6 +38,23 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
if (auth.authUserId) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: true,
|
||||
user_id: auth.authUserId,
|
||||
email: auth.authEmail || null,
|
||||
subscription_active: null,
|
||||
subscription_plan_code: null,
|
||||
subscription_expires_at: null,
|
||||
subscription_total_expires_at: null,
|
||||
subscription_queued_days: 0,
|
||||
subscription_queued_count: 0,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
degraded_reason: `backend_${res.status}`,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: res.status },
|
||||
@@ -40,9 +65,28 @@ export async function GET(req: NextRequest) {
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
if (auth.authUserId) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: true,
|
||||
user_id: auth.authUserId,
|
||||
email: auth.authEmail || null,
|
||||
subscription_active: null,
|
||||
subscription_plan_code: null,
|
||||
subscription_expires_at: null,
|
||||
subscription_total_expires_at: null,
|
||||
subscription_queued_days: 0,
|
||||
subscription_queued_count: 0,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
degraded_reason: String(error),
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch auth profile", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,9 @@ export async function GET(req: NextRequest) {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const fetchOptions = {
|
||||
headers: auth.headers,
|
||||
next: { revalidate: 300 },
|
||||
} as const;
|
||||
const res = await fetch(`${API_BASE}/api/cities`, {
|
||||
...fetchOptions,
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
@@ -40,7 +37,7 @@ export async function GET(req: NextRequest) {
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"public, max-age=0, s-maxage=300, stale-while-revalidate=1800",
|
||||
"no-store, max-age=0",
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -20,7 +20,8 @@ export async function GET(
|
||||
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`;
|
||||
const depth = req.nextUrl.searchParams.get("depth") ?? "panel";
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}&depth=${encodeURIComponent(depth)}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
|
||||
+169
-50
@@ -2,13 +2,101 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
PolyWeather — Unified Design Token System
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* ── Background Scale ── */
|
||||
--color-bg-base: #080c14;
|
||||
--color-bg-raised: #0d1321;
|
||||
--color-bg-overlay: #111827;
|
||||
--color-bg-card: rgba(15, 23, 42, 0.72);
|
||||
--color-bg-input: rgba(255, 255, 255, 0.04);
|
||||
|
||||
/* ── Text Scale ── */
|
||||
--color-text-primary: #f1f5f9;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-muted: #64748b;
|
||||
--color-text-disabled: #475569;
|
||||
|
||||
/* ── Accent Colors ── */
|
||||
--color-accent-primary: #00e5ff;
|
||||
--color-accent-secondary: #6366f1;
|
||||
--color-accent-tertiary: #a78bfa;
|
||||
|
||||
/* ── Signal / Semantic Colors ── */
|
||||
--color-signal-success: #22c55e;
|
||||
--color-signal-warning: #f59e0b;
|
||||
--color-signal-danger: #ef4444;
|
||||
--color-signal-info: #3b82f6;
|
||||
|
||||
/* ── Risk Colors (aliased from signal) ── */
|
||||
--color-risk-high: var(--color-signal-danger);
|
||||
--color-risk-medium: var(--color-signal-warning);
|
||||
--color-risk-low: var(--color-signal-success);
|
||||
|
||||
/* ── Border ── */
|
||||
--color-border-default: rgba(99, 102, 241, 0.12);
|
||||
--color-border-hover: rgba(99, 102, 241, 0.28);
|
||||
--color-border-subtle: rgba(255, 255, 255, 0.06);
|
||||
|
||||
/* ── Shadow / Elevation ── */
|
||||
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-elevation-2: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
--shadow-elevation-3: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
--shadow-glow-accent: 0 0 20px rgba(0, 229, 255, 0.2);
|
||||
--shadow-glow-secondary: 0 0 20px rgba(99, 102, 241, 0.25);
|
||||
|
||||
/* ── Typography ── */
|
||||
--font-data:
|
||||
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-display: "Geist", "Inter", -apple-system, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
|
||||
/* ── Spacing (4px grid) ── */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
|
||||
/* ── Border Radius ── */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ── Glass / Blur ── */
|
||||
--glass-blur-1: blur(10px);
|
||||
--glass-blur-2: blur(16px);
|
||||
--glass-blur-3: blur(24px);
|
||||
--glass-opacity-1: 0.72;
|
||||
--glass-opacity-2: 0.85;
|
||||
--glass-opacity-3: 0.92;
|
||||
|
||||
/* ── Layout ── */
|
||||
--header-height: 52px;
|
||||
--sidebar-width: 260px;
|
||||
--panel-width: 560px;
|
||||
|
||||
/* ── Motion ── */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* ── Legacy Compatibility (shadcn/ui HSL tokens) ── */
|
||||
--background: 223 53% 4%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 223 46% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 190 95% 56%;
|
||||
--primary: 187 100% 50%;
|
||||
--primary-foreground: 222 47% 8%;
|
||||
--secondary: 224 30% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
@@ -22,28 +110,32 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
|
||||
font-family: var(--font-data);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 10% -10%,
|
||||
rgba(34, 211, 238, 0.2),
|
||||
rgba(0, 229, 255, 0.12),
|
||||
transparent 40%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 90% 0%,
|
||||
rgba(59, 130, 246, 0.14),
|
||||
rgba(99, 102, 241, 0.1),
|
||||
transparent 36%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 100%,
|
||||
rgba(8, 47, 73, 0.5),
|
||||
rgba(8, 47, 73, 0.4),
|
||||
transparent 48%
|
||||
),
|
||||
hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Map Components (Leaflet overrides + map markers)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@layer components {
|
||||
.leaflet-container {
|
||||
width: 100%;
|
||||
@@ -52,38 +144,41 @@
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
background: rgba(2, 6, 23, 0.82) !important;
|
||||
border-color: rgba(71, 85, 105, 0.5) !important;
|
||||
color: #e2e8f0 !important;
|
||||
background: rgba(8, 12, 20, 0.85) !important;
|
||||
border-color: var(--color-border-default) !important;
|
||||
color: var(--color-text-primary) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:hover {
|
||||
background: rgba(15, 23, 42, 0.95) !important;
|
||||
background: rgba(13, 19, 33, 0.95) !important;
|
||||
border-color: var(--color-border-hover) !important;
|
||||
}
|
||||
|
||||
/* ── Map Pill (temperature labels on map) ── */
|
||||
.map-pill {
|
||||
min-width: 52px;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 5px 10px;
|
||||
color: #f8fafc;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
backdrop-filter: blur(8px);
|
||||
backdrop-filter: var(--glass-blur-1);
|
||||
box-shadow:
|
||||
0 2px 14px rgba(2, 6, 23, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.map-pill.high {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
background: linear-gradient(135deg, var(--color-signal-danger), #b91c1c);
|
||||
}
|
||||
|
||||
.map-pill.medium {
|
||||
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||
background: linear-gradient(135deg, var(--color-signal-warning), #b45309);
|
||||
}
|
||||
|
||||
.map-pill.low {
|
||||
@@ -93,21 +188,23 @@
|
||||
.map-pill.active {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(34, 211, 238, 0.4),
|
||||
0 8px 24px rgba(0, 229, 255, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* ── Glass utility ── */
|
||||
.glass {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(15, 23, 42, 0.9) 0%,
|
||||
rgba(2, 6, 23, 0.75) 100%
|
||||
rgba(13, 19, 33, 0.88) 0%,
|
||||
rgba(8, 12, 20, 0.75) 100%
|
||||
);
|
||||
backdrop-filter: blur(10px);
|
||||
backdrop-filter: var(--glass-blur-1);
|
||||
}
|
||||
|
||||
/* ── Fade-up entrance ── */
|
||||
.fade-up {
|
||||
animation: fadeUp 450ms ease-out;
|
||||
animation: fadeUp 400ms var(--transition-slow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,47 +221,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Map Marker Components (nearby stations, city bubbles)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@layer components {
|
||||
.nearby-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
color: rgba(226, 232, 240, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
gap: var(--space-2);
|
||||
background: rgba(13, 19, 33, 0.9);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 10px 28px rgba(2, 6, 23, 0.45);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow-elevation-2);
|
||||
backdrop-filter: var(--glass-blur-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nearby-name {
|
||||
color: rgba(148, 163, 184, 0.95);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.nearby-temp {
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.nearby-unit {
|
||||
color: rgba(148, 163, 184, 0.85);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.wind-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
padding-left: 6px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(34, 211, 238, 0.9);
|
||||
gap: var(--space-1);
|
||||
margin-left: var(--space-1);
|
||||
padding-left: var(--space-2);
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-accent-primary);
|
||||
font-size: 10px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.wind-arrow {
|
||||
@@ -172,6 +274,7 @@
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
/* ── City Marker (bubble on map) ── */
|
||||
.city-marker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -184,13 +287,14 @@
|
||||
position: relative;
|
||||
min-width: 46px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
color: white;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-elevation-2);
|
||||
}
|
||||
|
||||
.marker-bubble::after {
|
||||
@@ -207,48 +311,63 @@
|
||||
}
|
||||
|
||||
.marker-bubble.risk-high {
|
||||
background: linear-gradient(135deg, #dc2626, #ef4444);
|
||||
background: linear-gradient(135deg, #dc2626, var(--color-signal-danger));
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-high::after { border-top-color: #ef4444; }
|
||||
.marker-bubble.risk-high::after {
|
||||
border-top-color: var(--color-signal-danger);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-medium {
|
||||
background: linear-gradient(135deg, #d97706, #f59e0b);
|
||||
background: linear-gradient(135deg, #d97706, var(--color-signal-warning));
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-medium::after { border-top-color: #f59e0b; }
|
||||
.marker-bubble.risk-medium::after {
|
||||
border-top-color: var(--color-signal-warning);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-low {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-low::after { border-top-color: #10b981; }
|
||||
.marker-bubble.risk-low::after {
|
||||
border-top-color: #10b981;
|
||||
}
|
||||
|
||||
.marker-name {
|
||||
margin-top: 8px;
|
||||
margin-top: var(--space-2);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.city-marker.selected .marker-bubble {
|
||||
animation: markerGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(51, 65, 85, 0.75);
|
||||
border-radius: 9999px;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes markerGlow {
|
||||
0%, 100% { box-shadow: 0 6px 18px rgba(0,0,0,0.4); }
|
||||
50% { box-shadow: 0 10px 26px rgba(34,211,238,0.35); }
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 10px 26px rgba(0, 229, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ export const metadata: Metadata = {
|
||||
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||
],
|
||||
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
||||
apple: [
|
||||
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
|
||||
],
|
||||
shortcut: ["/favicon.ico"],
|
||||
},
|
||||
};
|
||||
@@ -24,9 +26,13 @@ export default function RootLayout({
|
||||
<html lang="zh-CN" className="dark">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossOrigin=""
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
|
||||
@@ -76,6 +76,9 @@ type AuthMeResponse = {
|
||||
subscription_plan_code?: string | null;
|
||||
subscription_starts_at?: string | null;
|
||||
subscription_expires_at?: string | null;
|
||||
subscription_total_expires_at?: string | null;
|
||||
subscription_queued_days?: number | null;
|
||||
subscription_queued_count?: number | null;
|
||||
};
|
||||
|
||||
type PaymentPlan = {
|
||||
@@ -708,6 +711,7 @@ export function AccountCenter() {
|
||||
boundEmail: isEn ? "Bound Email" : "绑定邮箱",
|
||||
loginMethod: isEn ? "Sign-in Method" : "登录方式",
|
||||
renewalDate: isEn ? "Renewal Date" : "续费日期",
|
||||
accessUntil: isEn ? "Access Until" : "可用至",
|
||||
authResult: isEn ? "Auth Result" : "鉴权结果",
|
||||
passed: isEn ? "Passed" : "通过",
|
||||
restricted: isEn ? "Restricted" : "受限",
|
||||
@@ -820,6 +824,9 @@ export function AccountCenter() {
|
||||
renewNow: isEn ? "Renew Now" : "立即续费",
|
||||
trialBadge: isEn ? "TRIAL" : "试用中",
|
||||
daysLeft: isEn ? "{days} days left" : "剩余 {days} 天",
|
||||
queuedExtensionSummary: isEn
|
||||
? "Current plan until {current}. Queued extension: +{days} days. Total access until {total}."
|
||||
: "当前订阅至 {current},已排队延长 +{days} 天,总可用至 {total}。",
|
||||
}),
|
||||
[isEn],
|
||||
);
|
||||
@@ -1469,19 +1476,39 @@ export function AccountCenter() {
|
||||
const isSubscribed = Boolean(backend?.subscription_active);
|
||||
const planCode = String(backend?.subscription_plan_code || "").trim();
|
||||
const isTrialPlan = /trial/i.test(planCode);
|
||||
const expiryRaw = String(
|
||||
const currentExpiryRaw = String(
|
||||
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
|
||||
).trim();
|
||||
const expiryInfo = parseSubscriptionExpiry(expiryRaw);
|
||||
const expiryFormatted = formatTime(expiryRaw, locale);
|
||||
const totalExpiryRaw = String(
|
||||
backend?.subscription_total_expires_at ||
|
||||
backend?.subscription_expires_at ||
|
||||
user?.user_metadata?.pro_expiry ||
|
||||
"",
|
||||
).trim();
|
||||
const queuedExtensionDays = Math.max(
|
||||
0,
|
||||
Number(backend?.subscription_queued_days || 0),
|
||||
);
|
||||
const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0);
|
||||
const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw;
|
||||
const reminderExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw || totalExpiryRaw;
|
||||
const expiryInfo = parseSubscriptionExpiry(reminderExpiryRaw);
|
||||
const expiryFormatted = formatTime(displayExpiryRaw, locale);
|
||||
const currentExpiryFormatted = formatTime(currentExpiryRaw, locale);
|
||||
const totalExpiryFormatted = formatTime(totalExpiryRaw, locale);
|
||||
const proExpiry = isSubscribed
|
||||
? expiryFormatted !== "--"
|
||||
? expiryFormatted
|
||||
: expiryRaw || copy.proPendingSync
|
||||
: displayExpiryRaw || copy.proPendingSync
|
||||
: copy.noProSubscription;
|
||||
const showExpiringSoon =
|
||||
Boolean(isSubscribed && expiryInfo && !expiryInfo.expired && expiryInfo.daysLeft <= 3);
|
||||
const showExpiredReminder = Boolean(!isSubscribed && expiryInfo && expiryInfo.expired);
|
||||
const paymentFeatureReady = paymentReadyForRecovery;
|
||||
const canOpenCheckoutOverlay = Boolean(
|
||||
paymentFeatureReady &&
|
||||
(!isSubscribed || showExpiringSoon || showExpiredReminder),
|
||||
);
|
||||
const subscriptionStatusTitle = showExpiredReminder
|
||||
? isTrialPlan
|
||||
? copy.trialExpiredTitle
|
||||
@@ -1504,9 +1531,16 @@ export function AccountCenter() {
|
||||
expiryInfo && (showExpiringSoon || showExpiredReminder)
|
||||
? `${formatTime(expiryInfo.raw, locale)} · ${copy.daysLeft.replace("{days}", String(Math.max(expiryInfo.daysLeft, 0)))}`
|
||||
: "";
|
||||
const queuedExtensionSummary = hasQueuedExtension
|
||||
? copy.queuedExtensionSummary
|
||||
.replace("{current}", currentExpiryFormatted)
|
||||
.replace("{days}", String(queuedExtensionDays))
|
||||
.replace("{total}", totalExpiryFormatted)
|
||||
: "";
|
||||
const expiryLabel = hasQueuedExtension ? copy.accessUntil : copy.renewalDate;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showOverlay || isSubscribed) return;
|
||||
if (!showOverlay || !canOpenCheckoutOverlay) return;
|
||||
trackAppEvent("paywall_viewed", {
|
||||
entry: "account_center",
|
||||
user_state: isAuthenticated ? "logged_in" : "guest",
|
||||
@@ -1516,12 +1550,12 @@ export function AccountCenter() {
|
||||
});
|
||||
}, [
|
||||
isAuthenticated,
|
||||
isSubscribed,
|
||||
planCode,
|
||||
showExpiredReminder,
|
||||
showExpiringSoon,
|
||||
showOverlay,
|
||||
]);
|
||||
canOpenCheckoutOverlay,
|
||||
planCode,
|
||||
showExpiredReminder,
|
||||
showExpiringSoon,
|
||||
showOverlay,
|
||||
]);
|
||||
|
||||
// Points Logic
|
||||
const backendPointsRaw = Number(backend?.points);
|
||||
@@ -1615,9 +1649,6 @@ export function AccountCenter() {
|
||||
(resolvedSelectedTokenAddress.startsWith("0x")
|
||||
? shortAddress(resolvedSelectedTokenAddress)
|
||||
: "USDC");
|
||||
const paymentFeatureReady = Boolean(
|
||||
paymentConfig?.enabled && paymentConfig?.configured,
|
||||
);
|
||||
const paymentReceiverAddress = String(
|
||||
selectedPaymentToken?.receiver_contract ||
|
||||
paymentConfig?.receiver_contract ||
|
||||
@@ -2397,15 +2428,17 @@ export function AccountCenter() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isSubscribed && !showOverlay && paymentFeatureReady && (
|
||||
<button
|
||||
onClick={() => setShowOverlay(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse"
|
||||
>
|
||||
<Crown size={16} />{" "}
|
||||
{showExpiredReminder ? copy.renewNow : copy.upgradePro}
|
||||
</button>
|
||||
)}
|
||||
{!showOverlay && canOpenCheckoutOverlay && (
|
||||
<button
|
||||
onClick={() => setShowOverlay(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse"
|
||||
>
|
||||
<Crown size={16} />{" "}
|
||||
{showExpiringSoon || showExpiredReminder
|
||||
? copy.renewNow
|
||||
: copy.upgradePro}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onRefresh()}
|
||||
@@ -2454,6 +2487,12 @@ export function AccountCenter() {
|
||||
{subscriptionStatusMeta}
|
||||
</p>
|
||||
) : null}
|
||||
{billing.canRedeem ? (
|
||||
<p className="mt-2 text-xs text-emerald-200/90">
|
||||
当前可用 {billing.pointsUsed} 积分抵扣 ${billing.discountAmount.toFixed(2)},
|
||||
续费时会自动生效。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2605,9 +2644,9 @@ export function AccountCenter() {
|
||||
|
||||
{/* Subscription Info & Paywall */}
|
||||
<div className="lg:col-span-12 relative">
|
||||
<div
|
||||
className={`grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700 ${!isSubscribed && showOverlay ? "blur-md grayscale-[0.3] opacity-30 select-none pointer-events-none" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700 ${canOpenCheckoutOverlay && showOverlay ? "blur-md grayscale-[0.3] opacity-30 select-none pointer-events-none" : ""}`}
|
||||
>
|
||||
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
|
||||
<h3 className="text-sm font-bold text-blue-400 uppercase tracking-widest mb-4">
|
||||
{copy.membershipDetails}
|
||||
@@ -2657,7 +2696,7 @@ export function AccountCenter() {
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Clock}
|
||||
label={copy.renewalDate}
|
||||
label={expiryLabel}
|
||||
value={proExpiry}
|
||||
isPrimary
|
||||
/>
|
||||
@@ -2666,11 +2705,16 @@ export function AccountCenter() {
|
||||
label={copy.authResult}
|
||||
value={backend?.authenticated ? copy.passed : copy.restricted}
|
||||
/>
|
||||
{queuedExtensionSummary ? (
|
||||
<p className="rounded-2xl border border-cyan-400/20 bg-cyan-500/10 px-4 py-3 text-xs text-cyan-100">
|
||||
{queuedExtensionSummary}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Paywall Mask */}
|
||||
{!isSubscribed && showOverlay && (
|
||||
{canOpenCheckoutOverlay && showOverlay && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center p-4">
|
||||
<UnlockProOverlay
|
||||
points={totalPoints}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { CityListItem, DeviationMonitor } from "@/lib/dashboard-types";
|
||||
@@ -71,8 +72,7 @@ export function CitySidebar() {
|
||||
const aSamples = Number(a.deb_recent_sample_count ?? 0);
|
||||
const bSamples = Number(b.deb_recent_sample_count ?? 0);
|
||||
return (
|
||||
(riskOrder[aGroup] ?? 3) -
|
||||
(riskOrder[bGroup] ?? 3) ||
|
||||
(riskOrder[aGroup] ?? 3) - (riskOrder[bGroup] ?? 3) ||
|
||||
bHitRate - aHitRate ||
|
||||
bSamples - aSamples ||
|
||||
a.display_name.localeCompare(b.display_name)
|
||||
@@ -126,8 +126,7 @@ export function CitySidebar() {
|
||||
|
||||
const formatDeviationText = (monitor?: DeviationMonitor | null) => {
|
||||
if (!monitor?.available) return "";
|
||||
const label =
|
||||
locale === "en-US" ? monitor.label_en : monitor.label_zh;
|
||||
const label = locale === "en-US" ? monitor.label_en : monitor.label_zh;
|
||||
const trendLabel =
|
||||
locale === "en-US" ? monitor.trend_label_en : monitor.trend_label_zh;
|
||||
if (!label) return "";
|
||||
@@ -170,10 +169,20 @@ export function CitySidebar() {
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className="city-group-title">{group.label}</span>
|
||||
<span className="city-group-title">
|
||||
<span
|
||||
className={clsx("city-group-indicator", group.key)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="city-group-meta">
|
||||
<span className="city-group-count">{citiesInGroup.length}</span>
|
||||
<span className={clsx("city-group-arrow", expanded && "expanded")}>
|
||||
<span className="city-group-count">
|
||||
{citiesInGroup.length}
|
||||
</span>
|
||||
<span
|
||||
className={clsx("city-group-arrow", expanded && "expanded")}
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</span>
|
||||
@@ -203,7 +212,9 @@ export function CitySidebar() {
|
||||
time: detail.current.max_temp_time,
|
||||
})
|
||||
: detail?.current?.max_temp_time
|
||||
? t("sidebar.peakAt", { time: detail.current.max_temp_time })
|
||||
? t("sidebar.peakAt", {
|
||||
time: detail.current.max_temp_time,
|
||||
})
|
||||
: "";
|
||||
const deviationDirection =
|
||||
snapshot?.deviation_monitor?.direction || "normal";
|
||||
@@ -225,7 +236,9 @@ export function CitySidebar() {
|
||||
>
|
||||
<div className="city-item-main">
|
||||
<span className={clsx("risk-dot", performanceTier)} />
|
||||
<span className="city-name-text">{city.display_name}</span>
|
||||
<span className="city-name-text">
|
||||
{city.display_name}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"city-temp",
|
||||
@@ -238,7 +251,18 @@ export function CitySidebar() {
|
||||
|
||||
<div className="city-item-info">
|
||||
<span className="city-local-time">
|
||||
{snapshot?.local_time ? `🕒 ${snapshot.local_time}` : ""}
|
||||
{snapshot?.local_time ? (
|
||||
<>
|
||||
<Clock
|
||||
size={10}
|
||||
strokeWidth={2}
|
||||
className="city-clock-icon"
|
||||
/>
|
||||
{snapshot.local_time}
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
|
||||
@@ -3,44 +3,44 @@
|
||||
────────────────────────────────────────────────────────── */
|
||||
|
||||
.root {
|
||||
/* Core palette */
|
||||
--bg-primary: #0a0e1a;
|
||||
--bg-secondary: #111827;
|
||||
--bg-card: rgba(17, 24, 39, 0.85);
|
||||
--bg-glass: rgba(15, 23, 42, 0.75);
|
||||
--border-glass: rgba(99, 102, 241, 0.15);
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
/* ── Palette (bridged from globals.css unified tokens) ── */
|
||||
--bg-primary: var(--color-bg-base);
|
||||
--bg-secondary: var(--color-bg-raised);
|
||||
--bg-card: var(--color-bg-card);
|
||||
--bg-glass: rgba(13, 19, 33, 0.75);
|
||||
--border-glass: var(--color-border-default);
|
||||
--border-subtle: var(--color-border-subtle);
|
||||
|
||||
/* Text */
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--text-primary: var(--color-text-primary);
|
||||
--text-secondary: var(--color-text-secondary);
|
||||
--text-muted: var(--color-text-muted);
|
||||
|
||||
/* Accents */
|
||||
--accent-cyan: #22d3ee;
|
||||
--accent-blue: #6366f1;
|
||||
/* Accents — upgraded primary cyan to match new token */
|
||||
--accent-cyan: var(--color-accent-primary);
|
||||
--accent-blue: var(--color-accent-secondary);
|
||||
--accent-green: #34d399;
|
||||
--accent-orange: #fb923c;
|
||||
--accent-red: #f87171;
|
||||
--accent-yellow: #fbbf24;
|
||||
--accent-purple: #a78bfa;
|
||||
--accent-purple: var(--color-accent-tertiary);
|
||||
|
||||
/* Risk colors */
|
||||
--risk-high: #ef4444;
|
||||
--risk-medium: #f59e0b;
|
||||
--risk-low: #22c55e;
|
||||
--risk-high: var(--color-risk-high);
|
||||
--risk-medium: var(--color-risk-medium);
|
||||
--risk-low: var(--color-risk-low);
|
||||
|
||||
/* Spacing */
|
||||
/* Spacing — aligned with global layout tokens */
|
||||
--panel-width: 560px;
|
||||
--header-height: 56px;
|
||||
--header-height: 52px;
|
||||
--sidebar-width: 260px;
|
||||
|
||||
/* Effects */
|
||||
/* Effects — using global elevation tokens */
|
||||
--glass-blur: 20px;
|
||||
--shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow-cyan: 0 0 20px rgba(34, 211, 238, 0.3);
|
||||
--shadow-glow-blue: 0 0 20px rgba(99, 102, 241, 0.3);
|
||||
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--shadow-lg: var(--shadow-elevation-3);
|
||||
--shadow-glow-cyan: var(--shadow-glow-accent);
|
||||
--shadow-glow-blue: var(--shadow-glow-secondary);
|
||||
--transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
/* ── Reset & Base ── */
|
||||
@@ -174,44 +174,46 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
padding: 0 20px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 14, 26, 0.95) 0%,
|
||||
rgba(10, 14, 26, 0.7) 100%
|
||||
rgba(8, 12, 20, 0.96) 0%,
|
||||
rgba(8, 12, 20, 0.82) 100%
|
||||
);
|
||||
backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--border-glass);
|
||||
}
|
||||
|
||||
.root :global(.brand) {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.brand h1) {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, var(--accent-blue) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.root :global(.subtitle) {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.root :global(.header-right) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.lang-switch) {
|
||||
@@ -244,27 +246,28 @@
|
||||
|
||||
.root :global(.lang-btn.active) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(34, 211, 238, 0.16);
|
||||
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.26);
|
||||
background: rgba(0, 229, 255, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 229, 255, 0.2);
|
||||
}
|
||||
|
||||
.root :global(.live-badge) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border: 1px solid rgba(34, 197, 94, 0.22);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-green);
|
||||
letter-spacing: 1px;
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.pulse-dot) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
@@ -283,23 +286,22 @@
|
||||
}
|
||||
|
||||
.root :global(.refresh-btn) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-glass);
|
||||
background: var(--bg-glass);
|
||||
background: rgba(13, 19, 33, 0.6);
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
transition: all 150ms ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.root :global(.refresh-btn:hover) {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
border-color: rgba(0, 229, 255, 0.3);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
.root :global(.refresh-btn.spinning) {
|
||||
animation: spin 1s linear infinite;
|
||||
@@ -335,20 +337,23 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.root :global(.city-count) {
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: var(--accent-blue);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.root :global(.city-list-items) {
|
||||
@@ -382,20 +387,52 @@
|
||||
.root :global(.city-group-header) {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.root :global(.city-group-header:hover) {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.city-group-title) {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.city-group-indicator) {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.root :global(.city-group-indicator.high) {
|
||||
background: var(--risk-high);
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.root :global(.city-group-indicator.medium) {
|
||||
background: var(--risk-medium);
|
||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.root :global(.city-group-indicator.low) {
|
||||
background: var(--risk-low);
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.root :global(.city-group-indicator.other) {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.root :global(.city-group-meta) {
|
||||
@@ -457,8 +494,9 @@
|
||||
border-color: var(--border-glass);
|
||||
}
|
||||
.root :global(.city-item.active) {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: var(--accent-blue);
|
||||
background: rgba(0, 229, 255, 0.08);
|
||||
border-color: rgba(0, 229, 255, 0.3);
|
||||
box-shadow: 0 0 16px rgba(0, 229, 255, 0.06), inset 0 0 0 1px rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.city-item-main) {
|
||||
@@ -531,13 +569,21 @@
|
||||
box-shadow: 0 0 6px var(--risk-low);
|
||||
}
|
||||
|
||||
.root :global(.city-clock-icon) {
|
||||
display: inline-block;
|
||||
vertical-align: -1px;
|
||||
margin-right: 3px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-temp) {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent-cyan);
|
||||
opacity: 0;
|
||||
transition: var(--transition);
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
.root :global(.city-item .city-temp.loaded) {
|
||||
opacity: 1;
|
||||
@@ -1319,13 +1365,12 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% 50%,
|
||||
rgba(8, 14, 32, 0.76) 0%,
|
||||
rgba(4, 8, 22, 0.9) 44%,
|
||||
rgba(2, 6, 20, 0.96) 100%
|
||||
);
|
||||
background: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
rgba(8, 14, 32, 0.76) 0%,
|
||||
rgba(4, 8, 22, 0.9) 44%,
|
||||
rgba(2, 6, 20, 0.96) 100%
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1340,12 +1385,11 @@
|
||||
min-width: 240px;
|
||||
padding: 28px 32px;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 18, 36, 0.78) 0%,
|
||||
rgba(8, 14, 30, 0.62) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 18, 36, 0.78) 0%,
|
||||
rgba(8, 14, 30, 0.62) 100%
|
||||
);
|
||||
border: 1px solid rgba(92, 142, 255, 0.16);
|
||||
box-shadow:
|
||||
0 24px 90px rgba(0, 0, 0, 0.42),
|
||||
@@ -1365,7 +1409,11 @@
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 30% 50%, rgba(186, 230, 253, 0.22), transparent 54%),
|
||||
radial-gradient(
|
||||
circle at 30% 50%,
|
||||
rgba(186, 230, 253, 0.22),
|
||||
transparent 54%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(148, 163, 184, 0.28) 0%,
|
||||
@@ -1463,14 +1511,13 @@
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle,
|
||||
rgba(22, 78, 99, 0.16) 0%,
|
||||
rgba(7, 18, 36, 0.08) 48%,
|
||||
rgba(5, 10, 22, 0.02) 76%,
|
||||
transparent 100%
|
||||
);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(22, 78, 99, 0.16) 0%,
|
||||
rgba(7, 18, 36, 0.08) 48%,
|
||||
rgba(5, 10, 22, 0.02) 76%,
|
||||
transparent 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(56, 189, 248, 0.12),
|
||||
0 0 40px rgba(34, 211, 238, 0.08);
|
||||
@@ -2281,7 +2328,8 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.root :global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
|
||||
.root
|
||||
:global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
@@ -2390,12 +2438,11 @@
|
||||
padding: 24px 26px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.16);
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 18, 36, 0.72) 0%,
|
||||
rgba(8, 14, 30, 0.5) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 18, 36, 0.72) 0%,
|
||||
rgba(8, 14, 30, 0.5) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 18px 48px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
@@ -2406,7 +2453,12 @@
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(14, 165, 233, 0.08) 0%, rgba(14, 165, 233, 0.02) 62%, transparent 100%);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(14, 165, 233, 0.08) 0%,
|
||||
rgba(14, 165, 233, 0.02) 62%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-ring) {
|
||||
@@ -2604,16 +2656,23 @@
|
||||
scrollbar-color: rgba(96, 165, 250, 0.5) rgba(15, 23, 42, 0.32);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar) {
|
||||
.root
|
||||
:global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-track) {
|
||||
.root
|
||||
:global(
|
||||
.history-modal .history-peak-reference-scroll::-webkit-scrollbar-track
|
||||
) {
|
||||
background: rgba(15, 23, 42, 0.32);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-thumb) {
|
||||
.root
|
||||
:global(
|
||||
.history-modal .history-peak-reference-scroll::-webkit-scrollbar-thumb
|
||||
) {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(56, 189, 248, 0.72) 0%,
|
||||
@@ -2856,6 +2915,166 @@
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-strip) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-strip-compact) {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-strip-compact .future-v2-sync-chip) {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-strip-compact .future-v2-sync-copy span) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-brief) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.56fr);
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.22);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(8, 47, 73, 0.42), rgba(15, 23, 42, 0.78));
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-copy) {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-copy h3) {
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-meta) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-meta span) {
|
||||
min-height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-paths) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-paths div) {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(2, 6, 23, 0.5);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-paths span) {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-paths strong) {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #67e8f9;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-chip) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(52, 211, 153, 0.16);
|
||||
background: rgba(16, 185, 129, 0.06);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-chip.syncing) {
|
||||
border-color: rgba(34, 211, 238, 0.2);
|
||||
background: rgba(34, 211, 238, 0.07);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-dot) {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: #34d399;
|
||||
margin-top: 5px;
|
||||
flex: 0 0 auto;
|
||||
box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-chip.syncing .future-v2-sync-dot) {
|
||||
background: #22d3ee;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.12);
|
||||
animation: pulseGlow 1.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-copy) {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-copy strong) {
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-copy span) {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.root :global(.future-v2-layout) {
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
@@ -2893,21 +3112,19 @@
|
||||
}
|
||||
|
||||
.root :global(.future-v2-focus-card) {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(34, 211, 238, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(34, 211, 238, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-support-card) {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(148, 163, 184, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(148, 163, 184, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-hero-card) {
|
||||
@@ -3006,15 +3223,14 @@
|
||||
position: relative;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(15, 23, 42, 0.85) 0%,
|
||||
rgba(56, 189, 248, 0.18) 15%,
|
||||
rgba(250, 204, 21, 0.24) 50%,
|
||||
rgba(56, 189, 248, 0.16) 85%,
|
||||
rgba(15, 23, 42, 0.85) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(15, 23, 42, 0.85) 0%,
|
||||
rgba(56, 189, 248, 0.18) 15%,
|
||||
rgba(250, 204, 21, 0.24) 50%,
|
||||
rgba(56, 189, 248, 0.16) 85%,
|
||||
rgba(15, 23, 42, 0.85) 100%
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -3240,14 +3456,13 @@
|
||||
margin-top: 12px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 211, 238, 0.12) 0%,
|
||||
rgba(255, 255, 255, 0.06) 46%,
|
||||
rgba(255, 255, 255, 0.06) 54%,
|
||||
rgba(251, 191, 36, 0.12) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 211, 238, 0.12) 0%,
|
||||
rgba(255, 255, 255, 0.06) 46%,
|
||||
rgba(255, 255, 255, 0.06) 54%,
|
||||
rgba(251, 191, 36, 0.12) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-pace-meter-midline) {
|
||||
@@ -3287,13 +3502,12 @@
|
||||
margin-bottom: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.14);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% 18%,
|
||||
rgba(34, 211, 238, 0.14) 0%,
|
||||
rgba(255, 255, 255, 0.02) 48%,
|
||||
rgba(255, 255, 255, 0.015) 100%
|
||||
);
|
||||
background: radial-gradient(
|
||||
circle at 50% 18%,
|
||||
rgba(34, 211, 238, 0.14) 0%,
|
||||
rgba(255, 255, 255, 0.02) 48%,
|
||||
rgba(255, 255, 255, 0.015) 100%
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -3565,6 +3779,124 @@
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-chart-thresholds) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-chart-thresholds span) {
|
||||
min-height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.16);
|
||||
border-radius: 8px;
|
||||
background: rgba(8, 47, 73, 0.24);
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.9fr) minmax(0, 0.9fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-list) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-row) {
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-left-width: 3px;
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-row.cyan) {
|
||||
border-left-color: #22d3ee;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-row.amber) {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-row.blue) {
|
||||
border-left-color: #60a5fa;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-head) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-head strong) {
|
||||
color: #f8fafc;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-head span) {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-row p) {
|
||||
margin: 7px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-rule-list) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-rule-list li) {
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-rule-list li::before) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.65em;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #22d3ee;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-rule-panel:nth-child(2) .future-v2-rule-list li::before) {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-model-note) {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.14);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.future-modal-section) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -3648,14 +3980,13 @@
|
||||
}
|
||||
|
||||
.root :global(.future-trend-meter.center) {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(56, 189, 248, 0.1) 0%,
|
||||
rgba(255, 255, 255, 0.05) 48%,
|
||||
rgba(255, 255, 255, 0.05) 52%,
|
||||
rgba(245, 158, 11, 0.1) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(56, 189, 248, 0.1) 0%,
|
||||
rgba(255, 255, 255, 0.05) 48%,
|
||||
rgba(255, 255, 255, 0.05) 52%,
|
||||
rgba(245, 158, 11, 0.1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.future-trend-meter-midline) {
|
||||
@@ -3787,6 +4118,15 @@
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.root :global(.future-v2-meteorology-brief),
|
||||
.root :global(.future-v2-meteorology-grid) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-paths) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.root :global(.future-v2-layout) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -3812,6 +4152,18 @@
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-brief) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-paths) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-copy h3) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-card),
|
||||
.root :global(.future-modal-section) {
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
||||
import { getCityScenery } from "@/lib/dashboard-scenery";
|
||||
import { CityDetail } from "@/lib/dashboard-types";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
|
||||
@@ -151,19 +150,11 @@ export function DetailPanel() {
|
||||
);
|
||||
const isPro = store.proAccess.subscriptionActive;
|
||||
const isAuthenticated = store.proAccess.authenticated;
|
||||
const isProStateLoading = store.proAccess.loading;
|
||||
const panelRef = useRef<HTMLElement | null>(null);
|
||||
const [heavyContentReady, setHeavyContentReady] = useState(false);
|
||||
const isOverlayOpen =
|
||||
Boolean(store.futureModalDate) ||
|
||||
store.historyState.isOpen;
|
||||
const isVisible =
|
||||
store.isPanelOpen &&
|
||||
Boolean(store.selectedCity) &&
|
||||
!isOverlayOpen;
|
||||
const hasBasicPanelContent = Boolean(
|
||||
detail || selectedSummary || selectedCityItem,
|
||||
);
|
||||
const isOverlayOpen = Boolean(store.futureModalDate) || store.historyState.isOpen;
|
||||
const isVisible = store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
|
||||
const hasBasicPanelContent = Boolean(detail || selectedSummary || selectedCityItem);
|
||||
const panelDisplayName =
|
||||
detail?.display_name ||
|
||||
selectedSummary?.display_name ||
|
||||
@@ -187,10 +178,6 @@ export function DetailPanel() {
|
||||
() => getTodayPolymarketUrl(detail, locale),
|
||||
[detail, locale],
|
||||
);
|
||||
const scenery = getCityScenery(detail?.name);
|
||||
const basicCurrentTemp = selectedSummary?.current?.temp;
|
||||
const basicObsTime = selectedSummary?.current?.obs_time;
|
||||
const basicDeb = selectedSummary?.deb?.prediction;
|
||||
const basicSettlementLabel =
|
||||
selectedSummary?.current?.settlement_source_label ||
|
||||
selectedCityItem?.settlement_source_label ||
|
||||
@@ -200,11 +187,10 @@ export function DetailPanel() {
|
||||
selectedCityItem?.airport ||
|
||||
selectedSummary?.icao ||
|
||||
(locale === "en-US" ? "Airport pending" : "机场待确认");
|
||||
const isBasicSummaryLoading =
|
||||
!detail && !selectedSummary && store.loadingState.cityDetail;
|
||||
const shouldShowSyncCard =
|
||||
!detail &&
|
||||
(store.loadingState.cityDetail || isProStateLoading || isAuthenticated);
|
||||
const heroSettlementLabel =
|
||||
detail?.current?.settlement_source_label || basicSettlementLabel;
|
||||
const heroAirportLabel = detail?.risk?.airport || basicAirportLabel;
|
||||
|
||||
const blurActiveElement = () => {
|
||||
if (typeof document === "undefined") return;
|
||||
const active = document.activeElement;
|
||||
@@ -212,6 +198,7 @@ export function DetailPanel() {
|
||||
active.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeatureAccess = (feature: "today" | "history") => {
|
||||
blurActiveElement();
|
||||
|
||||
@@ -251,10 +238,7 @@ export function DetailPanel() {
|
||||
|
||||
if (!isVisible) {
|
||||
panel.setAttribute("inert", "");
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
panel.contains(document.activeElement)
|
||||
) {
|
||||
if (typeof document !== "undefined" && panel.contains(document.activeElement)) {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement) {
|
||||
active.blur();
|
||||
@@ -303,10 +287,7 @@ export function DetailPanel() {
|
||||
}, [detail, isVisible]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className={clsx("detail-panel", isVisible && "visible")}
|
||||
>
|
||||
<aside ref={panelRef} className={clsx("detail-panel", isVisible && "visible")}>
|
||||
<div className="panel-header">
|
||||
<button
|
||||
type="button"
|
||||
@@ -319,8 +300,15 @@ export function DetailPanel() {
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="panel-title-area">
|
||||
<h2>{panelDisplayName.toUpperCase()}</h2>
|
||||
<div className="panel-title-area">
|
||||
<div className="panel-title-stack">
|
||||
<div className="panel-overline">
|
||||
<span>{locale === "en-US" ? "City briefing" : "城市简报"}</span>
|
||||
<span className="panel-overline-sep">•</span>
|
||||
<span>{panelDisplayName.toUpperCase()}</span>
|
||||
</div>
|
||||
<h2>{panelDisplayName}</h2>
|
||||
</div>
|
||||
{store.loadingState.cityDetail && (
|
||||
<div className="panel-loading-hint" role="status" aria-live="polite">
|
||||
<span className="panel-loading-spinner" aria-hidden="true" />
|
||||
@@ -335,202 +323,111 @@ export function DetailPanel() {
|
||||
<span className={clsx("risk-badge", panelRiskLevel)}>
|
||||
{getRiskBadgeLabel(panelRiskLevel, locale)}
|
||||
</span>
|
||||
<div className="relative group">
|
||||
{marketUrl ? (
|
||||
<a
|
||||
className="history-btn"
|
||||
href={marketUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={
|
||||
locale === "en-US"
|
||||
? "Open today's Polymarket market"
|
||||
: "打开今日 Polymarket 题目页"
|
||||
}
|
||||
>
|
||||
{locale === "en-US" ? "Open Market" : "打开 Polymarket"}
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("history-btn", !isPro && "pro-locked")}
|
||||
<span className="panel-meta-chip panel-meta-chip-strong">{heroSettlementLabel}</span>
|
||||
<span className="panel-meta-chip panel-meta-chip-muted">{heroAirportLabel}</span>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
{marketUrl ? (
|
||||
<a
|
||||
className="panel-action-button panel-action-button-ghost"
|
||||
href={marketUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={
|
||||
isPro
|
||||
? t("detail.todayAnalysis")
|
||||
: `${t("detail.todayAnalysis")} (Pro)`
|
||||
locale === "en-US"
|
||||
? "Open today's Polymarket market"
|
||||
: "打开今日 Polymarket 题目页"
|
||||
}
|
||||
onClick={() => handleFeatureAccess("today")}
|
||||
disabled={!store.selectedCity}
|
||||
>
|
||||
{isPro
|
||||
? t("detail.todayAnalysis")
|
||||
: `${t("detail.todayAnalysis")} · Pro`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("history-btn", !isPro && "pro-locked")}
|
||||
title={
|
||||
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
|
||||
}
|
||||
onClick={() => handleFeatureAccess("history")}
|
||||
disabled={!store.selectedCity}
|
||||
>
|
||||
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
|
||||
</button>
|
||||
</div>
|
||||
{locale === "en-US" ? "Market" : "市场页"}
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"panel-action-button",
|
||||
"panel-action-button-primary",
|
||||
!isPro && "pro-locked",
|
||||
)}
|
||||
title={
|
||||
isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} (Pro)`
|
||||
}
|
||||
onClick={() => handleFeatureAccess("today")}
|
||||
disabled={!store.selectedCity}
|
||||
>
|
||||
{isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} · Pro`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"panel-action-button",
|
||||
"panel-action-button-secondary",
|
||||
!isPro && "pro-locked",
|
||||
)}
|
||||
title={isPro ? t("detail.history") : `${t("detail.history")} (Pro)`}
|
||||
onClick={() => handleFeatureAccess("history")}
|
||||
disabled={!store.selectedCity}
|
||||
>
|
||||
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-body">
|
||||
{!hasBasicPanelContent ? (
|
||||
<section>
|
||||
<section className="detail-summary-shell detail-empty-state">
|
||||
<div className="detail-section-head">
|
||||
<div>
|
||||
<div className="detail-section-kicker">
|
||||
{locale === "en-US" ? "No city selected" : "尚未选择城市"}
|
||||
</div>
|
||||
<h3>{locale === "en-US" ? "Pick a city to start." : "先选择一个城市。"}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
|
||||
{store.loadingState.cityDetail
|
||||
? t("detail.loading")
|
||||
: t("detail.emptyHint")}
|
||||
{store.loadingState.cityDetail ? t("detail.loading") : t("detail.emptyHint")}
|
||||
</div>
|
||||
</section>
|
||||
) : !detail ? (
|
||||
<>
|
||||
<section className="detail-section">
|
||||
<h3>{locale === "en-US" ? "City Snapshot" : "城市概览"}</h3>
|
||||
{isBasicSummaryLoading ? (
|
||||
<div className="detail-mini-meta">
|
||||
{locale === "en-US"
|
||||
? "Syncing public city snapshot..."
|
||||
: "正在同步城市基础信息..."}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="detail-grid">
|
||||
<div className="detail-card">
|
||||
<span className="detail-label">
|
||||
{locale === "en-US" ? "Current temp" : "当前温度"}
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{basicCurrentTemp != null
|
||||
? `${basicCurrentTemp}${selectedSummary?.temp_symbol || ""}`
|
||||
: locale === "en-US"
|
||||
? "--"
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-card">
|
||||
<span className="detail-label">
|
||||
{locale === "en-US" ? "Observed" : "观测时间"}
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{basicObsTime || (locale === "en-US" ? "Pending" : "待更新")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-card">
|
||||
<span className="detail-label">DEB</span>
|
||||
<span className="detail-value">
|
||||
{basicDeb != null
|
||||
? `${basicDeb}${selectedSummary?.temp_symbol || ""}`
|
||||
: locale === "en-US"
|
||||
? isBasicSummaryLoading
|
||||
? "Syncing..."
|
||||
: "Pending"
|
||||
: isBasicSummaryLoading
|
||||
? "同步中..."
|
||||
: "待更新"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-card">
|
||||
<span className="detail-label">
|
||||
{locale === "en-US" ? "Settlement" : "结算口径"}
|
||||
</span>
|
||||
<span className="detail-value">{basicSettlementLabel}</span>
|
||||
</div>
|
||||
<div className="detail-card">
|
||||
<span className="detail-label">
|
||||
{locale === "en-US" ? "Airport" : "结算机场"}
|
||||
</span>
|
||||
<span className="detail-value">{basicAirportLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
<div className="detail-card">
|
||||
<span className="detail-label">
|
||||
{shouldShowSyncCard
|
||||
? locale === "en-US"
|
||||
? "Detail sync"
|
||||
: "详情同步"
|
||||
: locale === "en-US"
|
||||
? "Pro features"
|
||||
: "Pro 功能"}
|
||||
</span>
|
||||
<span className="detail-value" style={{ fontSize: "15px" }}>
|
||||
{shouldShowSyncCard
|
||||
? locale === "en-US"
|
||||
? "Full city detail is still syncing. The deeper panel will appear automatically."
|
||||
: "完整城市详情仍在同步中,深度面板会自动补齐。"
|
||||
: locale === "en-US"
|
||||
? "Intraday analysis, history reconciliation, and deeper structure signals require Pro."
|
||||
: "今日日内分析、历史对账和更深入的结构信号需要 Pro。"}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
<div className="detail-mini-meta" role="status" aria-live="polite">
|
||||
{store.loadingState.cityDetail
|
||||
? locale === "en-US"
|
||||
? "Loading city cards..."
|
||||
: "正在加载城市卡片..."
|
||||
: locale === "en-US"
|
||||
? "City cards will appear here."
|
||||
: "城市卡片会显示在这里。"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<section className="detail-scenery-card">
|
||||
{scenery ? (
|
||||
<>
|
||||
<img
|
||||
className="detail-scenery-image"
|
||||
src={scenery.imageUrl}
|
||||
alt={t("detail.sceneryAlt", { city: detail?.display_name || "" })}
|
||||
/>
|
||||
<div className="detail-scenery-overlay">
|
||||
<div className="detail-scenery-copy">
|
||||
<span className="detail-scenery-kicker">
|
||||
{detail?.display_name}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
className="detail-scenery-credit"
|
||||
href={scenery.creditUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{scenery.creditLabel}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="detail-scenery-fallback">
|
||||
<span className="detail-scenery-kicker">
|
||||
{detail?.display_name}
|
||||
</span>
|
||||
<strong className="detail-scenery-title">
|
||||
{t("detail.sceneryTitle")}
|
||||
</strong>
|
||||
<span className="detail-scenery-subtitle">
|
||||
{t("detail.sceneryFallback")}
|
||||
</span>
|
||||
<section className="detail-structured-section">
|
||||
<div className="detail-section-head">
|
||||
<div>
|
||||
<div className="detail-section-kicker">{locale === "en-US" ? "Profile" : "城市画像"}</div>
|
||||
<h3>{t("detail.profile")}</h3>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
<h3>{t("detail.profile")}</h3>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
{profileStats.map((item) => (
|
||||
<div key={item.label} className="detail-card">
|
||||
<article key={item.label} className="detail-card">
|
||||
<span className="detail-label">{item.label}</span>
|
||||
<span className="detail-value">{item.value}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{officialLinks.length > 0 ? (
|
||||
<section className="detail-section">
|
||||
<h3>{locale === "en-US" ? "Official Sources" : "官方参考"}</h3>
|
||||
<section className="detail-structured-section">
|
||||
<div className="detail-section-head">
|
||||
<div>
|
||||
<div className="detail-section-kicker">
|
||||
{locale === "en-US" ? "Primary references" : "官方参考"}
|
||||
</div>
|
||||
<h3>{locale === "en-US" ? "Settlement and observation references" : "结算与观测参考来源"}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="detail-source-note">
|
||||
{locale === "en-US"
|
||||
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
|
||||
@@ -545,9 +442,7 @@ export function DetailPanel() {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span className="detail-source-kind">
|
||||
{link.kind.toUpperCase()}
|
||||
</span>
|
||||
<span className="detail-source-kind">{link.kind.toUpperCase()}</span>
|
||||
<span className="detail-source-label">{link.label}</span>
|
||||
</a>
|
||||
))}
|
||||
@@ -555,10 +450,17 @@ export function DetailPanel() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="detail-section rounded-2xl">
|
||||
<h3>{t("detail.todayMiniTrend")}</h3>
|
||||
<section className="detail-structured-section rounded-2xl">
|
||||
<div className="detail-section-head">
|
||||
<div>
|
||||
<div className="detail-section-kicker">
|
||||
{locale === "en-US" ? "Mini trend" : "温度微趋势"}
|
||||
</div>
|
||||
<h3>{t("detail.todayMiniTrend")}</h3>
|
||||
</div>
|
||||
</div>
|
||||
{heavyContentReady ? (
|
||||
<DetailMiniTemperatureChart detail={detail!} />
|
||||
<DetailMiniTemperatureChart detail={detail} />
|
||||
) : (
|
||||
<div className="detail-mini-meta">{t("detail.loading")}</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
.root :global(.panel-title-stack) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.panel-overline) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.panel-overline-sep) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.root :global(.panel-subtitle) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.root :global(.panel-weather-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.panel-meta-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.root :global(.panel-meta-chip-strong) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
border-color: rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.root :global(.panel-meta-chip-muted) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.panel-actions) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button:hover) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button-primary) {
|
||||
color: #05111f;
|
||||
background: linear-gradient(135deg, #67e8f9, #38bdf8);
|
||||
border-color: rgba(103, 232, 249, 0.3);
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button-secondary) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button-ghost) {
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
border-color: rgba(34, 211, 238, 0.16);
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-shell) {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0.018));
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 18px 42px rgba(2, 6, 23, 0.18);
|
||||
}
|
||||
|
||||
.root :global(.panel-body section.detail-summary-shell) {
|
||||
padding: 16px;
|
||||
margin: 18px 0 0;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-shell-live) {
|
||||
background: linear-gradient(180deg, rgba(22, 78, 99, 0.18), rgba(15, 23, 42, 0.3));
|
||||
}
|
||||
|
||||
.root :global(.detail-section-head) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.root :global(.detail-section-kicker) {
|
||||
display: inline-block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-main) {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-hero) {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-temp) {
|
||||
color: var(--text-primary);
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-supporting) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-grid) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.root :global(.detail-card) {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.root :global(.detail-card-hero) {
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
border-color: rgba(34, 211, 238, 0.16);
|
||||
}
|
||||
|
||||
.root :global(.detail-card-wide) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.root :global(.detail-callout-card) {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.root :global(.detail-value-muted) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.root :global(.detail-structured-section) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 18px;
|
||||
padding: 16px 16px 18px;
|
||||
}
|
||||
|
||||
.root :global(.detail-empty-state) {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import { LogIn, UserRound } from "lucide-react";
|
||||
import { LogIn, UserRound, RotateCw, BookOpen, Sparkles } from "lucide-react";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
@@ -29,28 +29,34 @@ export function HeaderBar() {
|
||||
const docsHref = "/docs/intro";
|
||||
const docsActive = pathname?.startsWith("/docs");
|
||||
const trialPromoLabel =
|
||||
locale === "en-US" ? "New users get 3-day Pro trial" : "新用户可免费体验 3 天 Pro";
|
||||
locale === "en-US"
|
||||
? "New users get 3-day Pro trial"
|
||||
: "新用户可免费体验 3 天 Pro";
|
||||
|
||||
const accountHref = isAuthenticated
|
||||
? "/account"
|
||||
: "/auth/login?next=%2Faccount";
|
||||
const accountLabel = isAuthenticated ? t("header.account") : t("header.signIn");
|
||||
const accountLabel = isAuthenticated
|
||||
? t("header.account")
|
||||
: t("header.signIn");
|
||||
const accountAria = isAuthenticated
|
||||
? t("header.accountAria")
|
||||
: t("header.signInAria");
|
||||
const expiryInfo = parseExpiryInfo(store.proAccess.subscriptionExpiresAt);
|
||||
const effectiveExpiry = store.proAccess.subscriptionActive
|
||||
? store.proAccess.subscriptionTotalExpiresAt ||
|
||||
store.proAccess.subscriptionExpiresAt
|
||||
: store.proAccess.subscriptionExpiresAt;
|
||||
const expiryInfo = parseExpiryInfo(effectiveExpiry);
|
||||
const isTrialPlan = /trial/i.test(
|
||||
String(store.proAccess.subscriptionPlanCode || ""),
|
||||
);
|
||||
const showRenewReminder =
|
||||
isAuthenticated &&
|
||||
!store.proAccess.loading &&
|
||||
(
|
||||
(store.proAccess.subscriptionActive &&
|
||||
expiryInfo &&
|
||||
expiryInfo.daysLeft <= 3) ||
|
||||
(!store.proAccess.subscriptionActive && Boolean(expiryInfo))
|
||||
);
|
||||
((store.proAccess.subscriptionActive &&
|
||||
expiryInfo &&
|
||||
expiryInfo.daysLeft <= 3) ||
|
||||
(!store.proAccess.subscriptionActive && Boolean(expiryInfo)));
|
||||
const renewReminderLabel = !showRenewReminder
|
||||
? ""
|
||||
: !store.proAccess.subscriptionActive
|
||||
@@ -77,7 +83,11 @@ export function HeaderBar() {
|
||||
</div>
|
||||
|
||||
<div className="header-right">
|
||||
<div className="lang-switch" role="group" aria-label={t("header.langAria")}>
|
||||
<div
|
||||
className="lang-switch"
|
||||
role="group"
|
||||
aria-label={t("header.langAria")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("lang-btn", locale === "zh-CN" && "active")}
|
||||
@@ -100,6 +110,7 @@ export function HeaderBar() {
|
||||
title={t("header.docsAria")}
|
||||
aria-label={t("header.docsAria")}
|
||||
>
|
||||
<BookOpen size={14} strokeWidth={2} />
|
||||
{t("header.docs")}
|
||||
</Link>
|
||||
|
||||
@@ -109,6 +120,7 @@ export function HeaderBar() {
|
||||
title={trialPromoLabel}
|
||||
aria-label={trialPromoLabel}
|
||||
>
|
||||
<Sparkles size={12} strokeWidth={2} />
|
||||
<span>{trialPromoLabel}</span>
|
||||
</Link>
|
||||
|
||||
@@ -143,12 +155,15 @@ export function HeaderBar() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("refresh-btn", store.loadingState.refresh && "spinning")}
|
||||
className={clsx(
|
||||
"refresh-btn",
|
||||
store.loadingState.refresh && "spinning",
|
||||
)}
|
||||
title={t("header.refreshAria")}
|
||||
aria-label={t("header.refreshAria")}
|
||||
onClick={() => void store.refreshAll()}
|
||||
>
|
||||
↻
|
||||
<RotateCw size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -166,7 +166,7 @@ function HistoryChart() {
|
||||
export function HistoryModal() {
|
||||
const store = useDashboardStore();
|
||||
const { t, locale } = useI18n();
|
||||
const { data, error, isLoading, isOpen } = useHistoryData();
|
||||
const { data, error, isLoading, isOpen, isRecordsLoading, meta } = useHistoryData();
|
||||
const isPro = store.proAccess.subscriptionActive;
|
||||
const isProLoading = store.proAccess.loading;
|
||||
const isNoaaSettlement =
|
||||
@@ -228,11 +228,40 @@ export function HistoryModal() {
|
||||
) : (
|
||||
<div className="modal-content history-modal">
|
||||
<div className="modal-header">
|
||||
<h2 id="history-modal-title">
|
||||
{t("history.title", {
|
||||
city: store.selectedCity?.toUpperCase() || "",
|
||||
})}
|
||||
</h2>
|
||||
<div className="modal-title-stack">
|
||||
<div className="modal-overline">
|
||||
<span>{locale === "en-US" ? "Audit workspace" : "对账工作台"}</span>
|
||||
<span className="modal-overline-sep">•</span>
|
||||
<span>{store.selectedCity?.toUpperCase() || ""}</span>
|
||||
</div>
|
||||
<h2 id="history-modal-title">
|
||||
{t("history.title", {
|
||||
city: store.selectedCity?.toUpperCase() || "",
|
||||
})}
|
||||
</h2>
|
||||
<div className="modal-subtitle">
|
||||
{locale === "en-US"
|
||||
? "Observed highs, DEB path, and historical baseline consistency."
|
||||
: "查看实测最高温、DEB 路径与历史基线是否一致。"}
|
||||
</div>
|
||||
{meta?.mode === "preview" ? (
|
||||
<div className="modal-header-meta">
|
||||
<span className="modal-meta-pill">
|
||||
{isRecordsLoading
|
||||
? locale === "en-US"
|
||||
? "Full records syncing"
|
||||
: "完整记录补齐中"
|
||||
: meta.hasMore
|
||||
? locale === "en-US"
|
||||
? `Preview ${meta.previewCount}/${meta.fullCount}`
|
||||
: `预览 ${meta.previewCount}/${meta.fullCount}`
|
||||
: locale === "en-US"
|
||||
? "Full set loaded"
|
||||
: "完整记录已到齐"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
@@ -244,18 +273,7 @@ export function HistoryModal() {
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{isNoaaSettlement && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "16px",
|
||||
padding: "12px 14px",
|
||||
border: "1px solid rgba(56, 189, 248, 0.24)",
|
||||
borderRadius: "12px",
|
||||
background: "rgba(14, 165, 233, 0.08)",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "13px",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<div className="modal-callout modal-callout-info">
|
||||
{t("lang") === "en-US"
|
||||
? `${store.selectedDetail?.display_name || store.selectedCity || "This city"} historical actuals are aligned to NOAA ${noaaStationCode} (${noaaStationName}) settlement rules: use the highest rounded whole-degree Celsius reading after the date is finalized.`
|
||||
: `${store.selectedDetail?.display_name || store.selectedCity || "该城市"}历史对账已按 NOAA ${noaaStationCode}(${noaaStationName})结算口径对齐:采用该日最终完成质控后的最高整度摄氏值。`}
|
||||
@@ -297,6 +315,21 @@ export function HistoryModal() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Performance snapshot" : "表现快照"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US"
|
||||
? "Recent settlement performance"
|
||||
: "近期结算表现"}
|
||||
</h3>
|
||||
<div className="modal-section-note">
|
||||
{locale === "en-US"
|
||||
? "Hit rate, MAE, and baseline comparison across recent settled days."
|
||||
: "查看近期已结算样本里的命中率、误差与基线对照。"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="history-stats">
|
||||
{error ? (
|
||||
<span style={{ color: "var(--accent-red)" }}>
|
||||
@@ -352,10 +385,20 @@ export function HistoryModal() {
|
||||
{!error && <HistoryChart />}
|
||||
{!error && settledPeakRows.length > 0 && (
|
||||
<div className="history-peak-reference">
|
||||
<div className="history-peak-reference-title">
|
||||
{locale === "en-US"
|
||||
? "Peak-12h DEB Reference (Approx.)"
|
||||
: "峰值前 12 小时 DEB 参考(近似)"}
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Reference table" : "参考表"}
|
||||
</div>
|
||||
<div className="history-peak-reference-title">
|
||||
{locale === "en-US"
|
||||
? "Peak-12h DEB Reference (Approx.)"
|
||||
: "峰值前 12 小时 DEB 参考(近似)"}
|
||||
</div>
|
||||
<div className="modal-section-note">
|
||||
{locale === "en-US"
|
||||
? "Use peak-minus-12h DEB as a fast sanity check for settled highs."
|
||||
: "用峰值前 12 小时的 DEB 作为历史结算高温的快速校验。"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="history-peak-reference-scroll">
|
||||
{settledPeakRows.map((row) => (
|
||||
|
||||
@@ -13,6 +13,7 @@ export function MapCanvas() {
|
||||
citySummariesByName: store.citySummariesByName,
|
||||
onClosePanel: store.closePanel,
|
||||
onEnsureCityDetail: store.ensureCityDetail,
|
||||
onMapInteractionChange: store.setMapInteractionActive,
|
||||
onRegisterStopMotion: store.registerMapStopMotion,
|
||||
onSelectCity: (cityName) => {
|
||||
void store.selectCity(cityName);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
.root :global(.modal-title-stack) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.modal-overline) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.modal-overline-sep) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.root :global(.modal-subtitle) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.root :global(.modal-header-meta) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.modal-meta-pill) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.modal-callout) {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.root :global(.modal-callout-info) {
|
||||
border-color: rgba(56, 189, 248, 0.24);
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.modal-section-heading) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.root :global(.modal-section-kicker) {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.modal-section-note) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -485,12 +485,12 @@ export function ProbabilityDistribution({
|
||||
const buyNoText = toPriceCents(bucket.no_buy);
|
||||
const marketTag = buyYesText
|
||||
? locale === "en-US"
|
||||
? `Buy Yes: ${buyYesText}`
|
||||
: `买 Yes: ${buyYesText}`
|
||||
? `Market ref: ${buyYesText}`
|
||||
: `市场参考: ${buyYesText}`
|
||||
: buyNoText
|
||||
? locale === "en-US"
|
||||
? `Buy No: ${buyNoText}`
|
||||
: `买 No: ${buyNoText}`
|
||||
? `Market hedge: ${buyNoText}`
|
||||
: `市场反向: ${buyNoText}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -530,23 +530,23 @@ export function ProbabilityDistribution({
|
||||
Math.abs(bucketTemp - marketBucketTemp) < 0.26;
|
||||
const marketTag = isMarketBucket
|
||||
? locale === "en-US"
|
||||
? `Buy Yes: ${marketYesText || "--"}`
|
||||
: `买 Yes: ${marketYesText || "--"}`
|
||||
? `Market ref: ${marketYesText || "--"}`
|
||||
: `市场参考: ${marketYesText || "--"}`
|
||||
: marketNoText
|
||||
? locale === "en-US"
|
||||
? `Buy No: ${marketNoText}`
|
||||
: `买 No: ${marketNoText}`
|
||||
? `Market hedge: ${marketNoText}`
|
||||
: `市场反向: ${marketNoText}`
|
||||
: null;
|
||||
const yesPriceText = toPriceCents(marketYesPrice);
|
||||
const noPriceText = toPriceCents(marketNoPrice);
|
||||
const marketTagFinal = isMarketBucket
|
||||
? locale === "en-US"
|
||||
? `Buy Yes: ${yesPriceText || "--"}`
|
||||
: `买 Yes: ${yesPriceText || "--"}`
|
||||
? `Market ref: ${yesPriceText || "--"}`
|
||||
: `市场参考: ${yesPriceText || "--"}`
|
||||
: noPriceText
|
||||
? locale === "en-US"
|
||||
? `Buy No: ${noPriceText}`
|
||||
: `买 No: ${noPriceText}`
|
||||
? `Market hedge: ${noPriceText}`
|
||||
: `市场反向: ${noPriceText}`
|
||||
: marketTag;
|
||||
let bucketLabel =
|
||||
bucket.label || `${bucket.value}${detail.temp_symbol}`;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
import clsx from "clsx";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect } from "react";
|
||||
import styles from "./Dashboard.module.css";
|
||||
import detailChromeStyles from "./DetailPanelChrome.module.css";
|
||||
import modalChromeStyles from "./ModalChrome.module.css";
|
||||
import {
|
||||
DashboardStoreProvider,
|
||||
useDashboardStore,
|
||||
@@ -11,6 +14,16 @@ import { CitySidebar } from "@/components/dashboard/CitySidebar";
|
||||
import { DetailPanel } from "@/components/dashboard/DetailPanel";
|
||||
import { HeaderBar } from "@/components/dashboard/HeaderBar";
|
||||
|
||||
const loadHistoryModal = () =>
|
||||
import("@/components/dashboard/HistoryModal").then(
|
||||
(module) => module.HistoryModal,
|
||||
);
|
||||
|
||||
const loadFutureForecastModal = () =>
|
||||
import("@/components/dashboard/FutureForecastModal").then(
|
||||
(module) => module.FutureForecastModal,
|
||||
);
|
||||
|
||||
const MapCanvas = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/MapCanvas").then((module) => module.MapCanvas),
|
||||
@@ -21,10 +34,7 @@ const MapCanvas = dynamic(
|
||||
);
|
||||
|
||||
const HistoryModal = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/HistoryModal").then(
|
||||
(module) => module.HistoryModal,
|
||||
),
|
||||
loadHistoryModal,
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
@@ -32,10 +42,7 @@ const HistoryModal = dynamic(
|
||||
);
|
||||
|
||||
const FutureForecastModal = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/FutureForecastModal").then(
|
||||
(module) => module.FutureForecastModal,
|
||||
),
|
||||
loadFutureForecastModal,
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
@@ -45,8 +52,12 @@ const FutureForecastModal = dynamic(
|
||||
function DashboardScreen() {
|
||||
const store = useDashboardStore();
|
||||
const { t } = useI18n();
|
||||
const activeSummary = store.selectedCity
|
||||
? store.citySummariesByName[store.selectedCity] || null
|
||||
: null;
|
||||
const activeCityName =
|
||||
store.selectedDetail?.display_name ||
|
||||
activeSummary?.display_name ||
|
||||
store.cities.find((city) => city.name === store.selectedCity)?.display_name ||
|
||||
store.selectedCity ||
|
||||
"";
|
||||
@@ -77,14 +88,25 @@ function DashboardScreen() {
|
||||
const showLoading =
|
||||
store.loadingState.cities ||
|
||||
store.loadingState.refresh;
|
||||
const showCitySyncToast =
|
||||
store.loadingState.cityDetail &&
|
||||
activeCityName &&
|
||||
!store.selectedDetail &&
|
||||
!activeSummary;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.root,
|
||||
detailChromeStyles.root,
|
||||
modalChromeStyles.root,
|
||||
)}
|
||||
>
|
||||
<MapCanvas />
|
||||
<HeaderBar />
|
||||
<CitySidebar />
|
||||
<DetailPanel />
|
||||
{store.loadingState.cityDetail && activeCityName ? (
|
||||
{showCitySyncToast ? (
|
||||
<div className="city-loading-toast" role="status" aria-live="polite">
|
||||
<span className="city-loading-dot" aria-hidden="true" />
|
||||
<span className="city-loading-copy">
|
||||
|
||||
@@ -41,7 +41,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
content: {
|
||||
"zh-CN": {
|
||||
title: "简介",
|
||||
description: "PolyWeather 文档中心解释核心产品概念、结算口径和日内结构信号,帮助用户把天气分析转成可执行判断。",
|
||||
description: "PolyWeather 文档中心解释核心产品概念、结算口径和日内结构信号,帮助用户把机场锚点、官方增强层和模型判断转成可执行判断。",
|
||||
sections: [
|
||||
{
|
||||
id: "what-is-polyweather",
|
||||
@@ -55,21 +55,21 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
id: "core-modules",
|
||||
title: "你会在页面上看到什么",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["今日日内分析:围绕今日峰值窗口,解释近地面信号、高空结构和机场 TAF。", "多模型预报:展示 DEB 与多模型最高温预测,帮助判断市场当前最热桶是否合理。", "历史对账:查看近 15 天已结算样本、DEB MAE 与最佳单模型表现。", "站点结构:明确区分结算站点、机场主站和官方增强站网。"] },
|
||||
{ type: "bullets", items: ["锚点状态:先确认当前机场主站实测、日内已见高点和结算时钟。", "当前节奏:把“此刻应到温度”和“机场实测”放在一张卡里,判断今天跑得快还是慢。", "当前命中胜率 / 模型区间与分歧:快速看最可能命中的温度桶,以及多模型当前分歧。", "今日日内结构信号:用近地面结构、高空结构、机场 TAF 和官方增强层解释今天还会不会继续冲高。", "历史对账:查看近 15 天已结算样本、DEB MAE 与最佳单模型表现。"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "how-to-read",
|
||||
title: "如何快速读懂主站",
|
||||
blocks: [
|
||||
{ type: "steps", items: ["先看今日气温预测图,确认当前实测、DEB 预测和峰值窗口。", "再看今日日内结构信号,判断现在是偏支持继续升温、偏压制,还是先观察。", "最后看市场对照与概率分布,判断市场是否已经把天气结构计入价格。"] },
|
||||
{ type: "steps", items: ["先看锚点状态和今日气温预测图,确认当前机场实测、DEB 预测和峰值窗口。", "再看当前节奏,判断今天是偏热、偏冷,还是基本按预期在走。", "接着看今日日内结构信号,确认边界风险、机场 vs 周边站,以及 TAF 有没有压温扰动。", "最后看当前命中胜率与模型区间,判断最热桶是不是已经被市场充分计价。"] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"en-US": {
|
||||
title: "Introduction",
|
||||
description: "The PolyWeather docs explain the product's core concepts, settlement logic, and intraday structural signals so users can turn weather context into actionable decisions.",
|
||||
description: "The PolyWeather docs explain the product's core concepts, settlement logic, and intraday structural signals so users can turn airport anchors, official nearby networks, and model context into actionable decisions.",
|
||||
sections: [
|
||||
{
|
||||
id: "what-is-polyweather",
|
||||
@@ -83,14 +83,14 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
id: "core-modules",
|
||||
title: "What you see on the site",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["Intraday analysis: peak-window focused reading of surface structure, upper-air structure, and airport TAF.", "Multi-model forecast: DEB versus major model highs, useful for checking whether the hottest market bucket is justified.", "History reconciliation: settled-sample MAE and hit-rate over the last 15 days.", "Station structure: a clear split between the settlement station, airport primary observation, and official nearby network."] },
|
||||
{ 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.", "Current hit odds / model spread: the likeliest bucket now and how far the model family is split.", "Intraday structural signal: surface structure, upper-air structure, airport TAF, and official nearby-network context around the peak window.", "History reconciliation: settled-sample MAE and hit-rate over the last 15 days."] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "how-to-read",
|
||||
title: "How to read the dashboard quickly",
|
||||
blocks: [
|
||||
{ type: "steps", items: ["Start with the intraday temperature chart to anchor current observations, DEB, and the expected peak window.", "Read the structural signal next to see whether the setup still supports further warming, leans suppressive, or remains mixed.", "Then compare that weather structure against market buckets and settlement probabilities."] },
|
||||
{ type: "steps", items: ["Start with anchor status and the intraday chart to anchor current observations, DEB, and the expected peak window.", "Read the current pace card to see whether the airport is running hot, cold, or roughly on schedule.", "Use the structural signal to check boundary risk, airport versus nearby stations, and whether TAF introduces suppression risk.", "Then compare that weather structure against hit odds and the model spread."] },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -242,7 +242,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
id: "city-rules",
|
||||
title: "当前主要口径",
|
||||
blocks: [
|
||||
{ type: "bullets", items: ["多数机场市场:按机场 METAR 或机场主站实况结算。", "土耳其机场市场:机场主站仍以 METAR 为锚点,同时保留 Turkish MGM 作为领先结构参考。", "中国内地机场市场:机场主站仍以 METAR 为锚点,NMC 当前实况作为官方增强层,不直接替代机场结算站。", "香港 / 流浮山 / 台湾等明确官方站点市场:按规则指定的官方结算站点结算,不能拿机场 TAF 或城区体感替代。"] },
|
||||
{ type: "bullets", items: ["多数机场市场:按机场 METAR 或机场主站实况结算。", "土耳其机场市场:机场主站仍以 METAR 为锚点,同时保留 Turkish MGM 作为领先结构参考。", "中国内地机场市场:机场主站仍以 METAR 为锚点,NMC 当前实况作为官方增强层,不直接替代机场结算站。", "日本 / 韩国机场市场:机场主站仍以 METAR 为锚点,同时可接入 JMA / KMA 官方增强层做领先结构参考。", "香港 / 流浮山 / 台湾等明确官方站点市场:按规则指定的官方结算站点结算,不能拿机场 TAF 或城区体感替代。"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -269,7 +269,7 @@ export const DOCS_PAGES: DocsPage[] = [
|
||||
id: "city-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.", "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 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.", "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."] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Chart as ChartInstance, ChartConfiguration, ChartType } from "chart.js";
|
||||
|
||||
let chartModulePromise: Promise<typeof import("chart.js/auto")> | null = null;
|
||||
|
||||
export function preloadChartJs() {
|
||||
if (!chartModulePromise) {
|
||||
chartModulePromise = import("chart.js/auto");
|
||||
}
|
||||
return chartModulePromise;
|
||||
}
|
||||
|
||||
export function useChart<TType extends ChartType>(
|
||||
createConfig: () => ChartConfiguration<TType>,
|
||||
dependencies: React.DependencyList,
|
||||
@@ -16,7 +25,7 @@ export function useChart<TType extends ChartType>(
|
||||
let disposed = false;
|
||||
|
||||
const setupChart = async () => {
|
||||
const { Chart } = await import("chart.js/auto");
|
||||
const { Chart } = await preloadChartJs();
|
||||
if (disposed) return;
|
||||
|
||||
const config = createConfig();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,9 @@ interface UseLeafletMapArgs {
|
||||
onEnsureCityDetail: (
|
||||
cityName: string,
|
||||
force?: boolean,
|
||||
depth?: "panel" | "nearby" | "full",
|
||||
) => Promise<CityDetail>;
|
||||
onMapInteractionChange: (active: boolean) => void;
|
||||
onRegisterStopMotion: (stopMotion: () => void) => void;
|
||||
onSelectCity: (cityName: string) => void;
|
||||
selectedCity: string | null;
|
||||
@@ -29,6 +31,8 @@ interface UseLeafletMapArgs {
|
||||
|
||||
const AUTO_NEARBY_MIN_ZOOM = 8;
|
||||
const AUTO_NEARBY_MAX_DISTANCE_M = 120000;
|
||||
const AUTO_NEARBY_IDLE_REFRESH_DELAY_MS = 10_000;
|
||||
const AUTO_NEARBY_MIN_REFRESH_INTERVAL_MS = 60_000;
|
||||
const MAP_MAX_ZOOM = 19;
|
||||
const CITY_MARKER_DISPLAY_OFFSETS: Record<
|
||||
string,
|
||||
@@ -48,6 +52,40 @@ function getMarkerDisplayOffset(cityName: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function pickMarkerTemperature(
|
||||
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
|
||||
) {
|
||||
if (!snapshot) return null;
|
||||
const detail = snapshot as Partial<CityDetail>;
|
||||
const currentSource = String(
|
||||
snapshot.current?.settlement_source ||
|
||||
snapshot.current?.settlement_source_label ||
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const currentTemp =
|
||||
currentSource === "nmc" || currentSource.includes("nmc")
|
||||
? null
|
||||
: snapshot.current?.temp;
|
||||
const isNmcStation = (station?: { source_label?: string | null }) =>
|
||||
String(station?.source_label || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.includes("nmc");
|
||||
const candidates = [
|
||||
currentTemp,
|
||||
detail.airport_current?.temp,
|
||||
isNmcStation(detail.airport_primary) ? null : detail.airport_primary?.temp,
|
||||
detail.mgm_nearby?.[0]?.temp,
|
||||
];
|
||||
for (const value of candidates) {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) return numeric;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createMarkerIcon(
|
||||
city: CityListItem,
|
||||
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
|
||||
@@ -56,8 +94,8 @@ function createMarkerIcon(
|
||||
const label = city.display_name;
|
||||
const unit = city.temp_unit === "fahrenheit" ? "°F" : "°C";
|
||||
const shortName = label.length > 10 ? `${label.substring(0, 8)}...` : label;
|
||||
const tempText =
|
||||
snapshot?.current?.temp != null ? `${snapshot.current.temp}${unit}` : "--";
|
||||
const markerTemp = pickMarkerTemperature(snapshot);
|
||||
const tempText = markerTemp != null ? `${markerTemp}${unit}` : "--";
|
||||
const offset = getMarkerDisplayOffset(city.name);
|
||||
const styleAttr =
|
||||
offset.x || offset.y
|
||||
@@ -87,7 +125,7 @@ function getMarkerSignature(
|
||||
city.temp_unit,
|
||||
city.lat,
|
||||
city.lon,
|
||||
snapshot?.current?.temp ?? "",
|
||||
pickMarkerTemperature(snapshot) ?? "",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
@@ -195,6 +233,7 @@ export function useLeafletMap({
|
||||
citySummariesByName,
|
||||
onClosePanel,
|
||||
onEnsureCityDetail,
|
||||
onMapInteractionChange,
|
||||
onRegisterStopMotion,
|
||||
onSelectCity,
|
||||
selectedCity,
|
||||
@@ -218,6 +257,14 @@ export function useLeafletMap({
|
||||
const onRegisterStopMotionRef = useRef(onRegisterStopMotion);
|
||||
const onSelectCityRef = useRef(onSelectCity);
|
||||
const onEnsureCityDetailRef = useRef(onEnsureCityDetail);
|
||||
const onMapInteractionChangeRef = useRef(onMapInteractionChange);
|
||||
const interactionIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const nearbyRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const lastNearbyRefreshAtRef = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
onClosePanelRef.current = onClosePanel;
|
||||
@@ -235,6 +282,10 @@ export function useLeafletMap({
|
||||
onEnsureCityDetailRef.current = onEnsureCityDetail;
|
||||
}, [onEnsureCityDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
onMapInteractionChangeRef.current = onMapInteractionChange;
|
||||
}, [onMapInteractionChange]);
|
||||
|
||||
useEffect(() => {
|
||||
suspendMotionRef.current = suspendMotion;
|
||||
}, [suspendMotion]);
|
||||
@@ -278,8 +329,50 @@ export function useLeafletMap({
|
||||
};
|
||||
map.on("click", handleMapClick);
|
||||
|
||||
const markInteracting = () => {
|
||||
if (interactionIdleTimerRef.current) {
|
||||
clearTimeout(interactionIdleTimerRef.current);
|
||||
interactionIdleTimerRef.current = null;
|
||||
}
|
||||
if (nearbyRefreshTimerRef.current) {
|
||||
clearTimeout(nearbyRefreshTimerRef.current);
|
||||
nearbyRefreshTimerRef.current = null;
|
||||
}
|
||||
onMapInteractionChangeRef.current(true);
|
||||
};
|
||||
|
||||
const markIdleSoon = () => {
|
||||
if (interactionIdleTimerRef.current) {
|
||||
clearTimeout(interactionIdleTimerRef.current);
|
||||
}
|
||||
interactionIdleTimerRef.current = setTimeout(() => {
|
||||
interactionIdleTimerRef.current = null;
|
||||
onMapInteractionChangeRef.current(false);
|
||||
}, 700);
|
||||
};
|
||||
|
||||
map.on("movestart", markInteracting);
|
||||
map.on("zoomstart", markInteracting);
|
||||
map.on("dragstart", markInteracting);
|
||||
map.on("moveend", markIdleSoon);
|
||||
map.on("zoomend", markIdleSoon);
|
||||
|
||||
return () => {
|
||||
onRegisterStopMotionRef.current(() => {});
|
||||
if (interactionIdleTimerRef.current) {
|
||||
clearTimeout(interactionIdleTimerRef.current);
|
||||
interactionIdleTimerRef.current = null;
|
||||
}
|
||||
if (nearbyRefreshTimerRef.current) {
|
||||
clearTimeout(nearbyRefreshTimerRef.current);
|
||||
nearbyRefreshTimerRef.current = null;
|
||||
}
|
||||
onMapInteractionChangeRef.current(false);
|
||||
map.off("movestart", markInteracting);
|
||||
map.off("zoomstart", markInteracting);
|
||||
map.off("dragstart", markInteracting);
|
||||
map.off("moveend", markIdleSoon);
|
||||
map.off("zoomend", markIdleSoon);
|
||||
map.off("click", handleMapClick);
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
@@ -401,6 +494,12 @@ export function useLeafletMap({
|
||||
if (!mapRef.current || !nearbyLayerRef.current) return;
|
||||
const map = mapRef.current;
|
||||
const layer = nearbyLayerRef.current;
|
||||
const clearNearbyRefreshTimer = () => {
|
||||
if (nearbyRefreshTimerRef.current) {
|
||||
clearTimeout(nearbyRefreshTimerRef.current);
|
||||
nearbyRefreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
function renderNearbyStations(detail: CityDetail, preserveView = false) {
|
||||
layer.clearLayers();
|
||||
@@ -461,6 +560,39 @@ export function useLeafletMap({
|
||||
// This section is primarily for auto-discovery movement if needed.
|
||||
}
|
||||
|
||||
function scheduleIdleNearbyRefresh(targetCity: string | null) {
|
||||
clearNearbyRefreshTimer();
|
||||
if (!targetCity || suspendMotion) return;
|
||||
if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) return;
|
||||
|
||||
nearbyRefreshTimerRef.current = setTimeout(async () => {
|
||||
nearbyRefreshTimerRef.current = null;
|
||||
if (loadingAutoNearbyRef.current || handlingAutoNearbyRef.current) return;
|
||||
if (!mapRef.current || mapRef.current.getZoom() < AUTO_NEARBY_MIN_ZOOM) return;
|
||||
if (autoNearbyCityRef.current !== targetCity) return;
|
||||
|
||||
const lastRefreshAt = lastNearbyRefreshAtRef.current[targetCity] || 0;
|
||||
if (Date.now() - lastRefreshAt < AUTO_NEARBY_MIN_REFRESH_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingAutoNearbyRef.current = true;
|
||||
lastNearbyRefreshAtRef.current[targetCity] = Date.now();
|
||||
try {
|
||||
const detail = await onEnsureCityDetailRef.current(
|
||||
targetCity,
|
||||
true,
|
||||
"nearby",
|
||||
);
|
||||
if (autoNearbyCityRef.current !== targetCity) return;
|
||||
renderNearbyStations(detail, true);
|
||||
} catch {
|
||||
} finally {
|
||||
loadingAutoNearbyRef.current = false;
|
||||
}
|
||||
}, AUTO_NEARBY_IDLE_REFRESH_DELAY_MS);
|
||||
}
|
||||
|
||||
async function maybeAutoShowNearbyStations() {
|
||||
if (handlingAutoNearbyRef.current) {
|
||||
return;
|
||||
@@ -468,12 +600,18 @@ export function useLeafletMap({
|
||||
handlingAutoNearbyRef.current = true;
|
||||
try {
|
||||
if (selectedDetail) {
|
||||
// Just render stations, no camera move from here
|
||||
renderNearbyStations(selectedDetail, true);
|
||||
return;
|
||||
const selectedNearbyStations = pickMapNearbyStations(selectedDetail);
|
||||
if (selectedNearbyStations.length) {
|
||||
// Just render stations, no camera move from here
|
||||
renderNearbyStations(selectedDetail, true);
|
||||
autoNearbyCityRef.current = selectedCity || selectedDetail.name || null;
|
||||
scheduleIdleNearbyRefresh(autoNearbyCityRef.current);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (suspendMotion) {
|
||||
clearNearbyRefreshTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -482,6 +620,7 @@ export function useLeafletMap({
|
||||
|
||||
if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) {
|
||||
autoNearbyCityRef.current = null;
|
||||
clearNearbyRefreshTimer();
|
||||
layer.clearLayers();
|
||||
return;
|
||||
}
|
||||
@@ -499,9 +638,10 @@ export function useLeafletMap({
|
||||
}
|
||||
}
|
||||
|
||||
const targetCity = best?.cityName || null;
|
||||
const targetCity = selectedCity || best?.cityName || null;
|
||||
if (!targetCity) {
|
||||
autoNearbyCityRef.current = null;
|
||||
clearNearbyRefreshTimer();
|
||||
layer.clearLayers();
|
||||
return;
|
||||
}
|
||||
@@ -510,21 +650,28 @@ export function useLeafletMap({
|
||||
autoNearbyCityRef.current === targetCity &&
|
||||
layer.getLayers().length > 0
|
||||
) {
|
||||
scheduleIdleNearbyRefresh(targetCity);
|
||||
return;
|
||||
}
|
||||
|
||||
autoNearbyCityRef.current = targetCity;
|
||||
const cachedDetail = cityDetailsByName[targetCity];
|
||||
if (cachedDetail) {
|
||||
if (cachedDetail && pickMapNearbyStations(cachedDetail).length) {
|
||||
renderNearbyStations(cachedDetail, true);
|
||||
scheduleIdleNearbyRefresh(targetCity);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingAutoNearbyRef.current) return;
|
||||
loadingAutoNearbyRef.current = true;
|
||||
try {
|
||||
const detail = await onEnsureCityDetailRef.current(targetCity, false);
|
||||
const detail = await onEnsureCityDetailRef.current(
|
||||
targetCity,
|
||||
false,
|
||||
"nearby",
|
||||
);
|
||||
renderNearbyStations(detail, true);
|
||||
scheduleIdleNearbyRefresh(targetCity);
|
||||
} catch {
|
||||
} finally {
|
||||
loadingAutoNearbyRef.current = false;
|
||||
@@ -550,6 +697,7 @@ export function useLeafletMap({
|
||||
map.on("moveend", maybeAutoShowNearbyStations);
|
||||
|
||||
return () => {
|
||||
clearNearbyRefreshTimer();
|
||||
map.off("zoomend", syncVisibility);
|
||||
map.off("moveend", maybeAutoShowNearbyStations);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ export const FORWARDED_SUPABASE_EMAIL_HEADER = "x-polyweather-auth-email";
|
||||
type HeaderBuildResult = {
|
||||
headers: HeadersInit;
|
||||
response: NextResponse | null;
|
||||
authUserId?: string | null;
|
||||
authEmail?: string | null;
|
||||
};
|
||||
|
||||
type HeaderBuildOptions = {
|
||||
@@ -59,20 +61,20 @@ export async function buildBackendRequestHeaders(
|
||||
|
||||
if (incomingAuth) {
|
||||
headers.set("Authorization", `Bearer ${incomingAuth}`);
|
||||
return { headers, response: passthroughResponse };
|
||||
return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null };
|
||||
}
|
||||
const accessToken = session?.access_token || "";
|
||||
if (accessToken) {
|
||||
// Fallback to cookie-backed session when request does not carry bearer.
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
}
|
||||
return { headers, response: passthroughResponse };
|
||||
return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null };
|
||||
}
|
||||
|
||||
if (incomingAuth) {
|
||||
headers.set("Authorization", `Bearer ${incomingAuth}`);
|
||||
}
|
||||
return { headers, response: null };
|
||||
return { headers, response: null, authUserId: null, authEmail: null };
|
||||
}
|
||||
|
||||
export function applyAuthResponseCookies(
|
||||
@@ -87,3 +89,5 @@ export function applyAuthResponseCookies(
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
import {
|
||||
CityDetail,
|
||||
CityListItem,
|
||||
MarketScan,
|
||||
CitySummary,
|
||||
HistoryPoint,
|
||||
HistoryPayload,
|
||||
} from "@/lib/dashboard-types";
|
||||
|
||||
const CACHE_KEY = "polyWeather_v1";
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
|
||||
const pendingHistoryRequests = new Map<string, Promise<HistoryPoint[]>>();
|
||||
const pendingHistoryRequests = new Map<string, Promise<HistoryPayload>>();
|
||||
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
|
||||
const pendingMarketScanRequests = new Map<string, Promise<MarketScan | null>>();
|
||||
const PRIORITY_WARM_SESSION_KEY = "polyWeather_priority_warm_v1";
|
||||
|
||||
type CityCacheMeta = {
|
||||
cachedAt: number;
|
||||
@@ -29,6 +28,13 @@ function normalizeCityName(cityName: string) {
|
||||
return encodeURIComponent(String(cityName).replace(/\s/g, "-"));
|
||||
}
|
||||
|
||||
function normalizeDetailDepth(depth?: "panel" | "market" | "nearby" | "full") {
|
||||
if (depth === "full") return "full";
|
||||
if (depth === "nearby") return "nearby";
|
||||
if (depth === "market") return "market";
|
||||
return "panel";
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
@@ -137,6 +143,24 @@ export const dashboardClient = {
|
||||
return data.cities || [];
|
||||
},
|
||||
|
||||
sendPriorityWarmHint(timezone?: string | null) {
|
||||
if (!isClient()) return;
|
||||
const tz = String(
|
||||
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||
).trim();
|
||||
if (!tz) return;
|
||||
const cacheKey = `${PRIORITY_WARM_SESSION_KEY}:${tz}`;
|
||||
if (window.sessionStorage.getItem(cacheKey)) return;
|
||||
window.sessionStorage.setItem(cacheKey, "1");
|
||||
const params = new URLSearchParams({ timezone: tz });
|
||||
void fetch(`/api/system/priority-warm?${params.toString()}`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
async getCitySummary(cityName: string, options?: { force?: boolean }) {
|
||||
const force = options?.force ?? false;
|
||||
const requestKey = `${cityName}::${force ? "force" : "cached"}`;
|
||||
@@ -155,17 +179,21 @@ export const dashboardClient = {
|
||||
return request;
|
||||
},
|
||||
|
||||
async getCityDetail(cityName: string, options?: { force?: boolean }) {
|
||||
async getCityDetail(
|
||||
cityName: string,
|
||||
options?: { force?: boolean; depth?: "panel" | "market" | "nearby" | "full" },
|
||||
) {
|
||||
const force = options?.force ?? false;
|
||||
const depth = normalizeDetailDepth(options?.depth);
|
||||
if (!force) {
|
||||
const requestKey = `${cityName}::cached`;
|
||||
const requestKey = `${cityName}::${depth}::cached`;
|
||||
const existing = pendingCityDetailRequests.get(requestKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = fetchJson<CityDetail>(
|
||||
`/api/city/${normalizeCityName(cityName)}?force_refresh=false`,
|
||||
`/api/city/${normalizeCityName(cityName)}?force_refresh=false&depth=${depth}`,
|
||||
).finally(() => {
|
||||
pendingCityDetailRequests.delete(requestKey);
|
||||
});
|
||||
@@ -176,6 +204,7 @@ export const dashboardClient = {
|
||||
|
||||
const params = new URLSearchParams({
|
||||
force_refresh: "true",
|
||||
depth,
|
||||
_ts: String(Date.now()),
|
||||
});
|
||||
return fetchJson<CityDetail>(
|
||||
@@ -183,75 +212,36 @@ export const dashboardClient = {
|
||||
);
|
||||
},
|
||||
|
||||
async getCityMarketScan(
|
||||
cityName: string,
|
||||
options?: {
|
||||
force?: boolean;
|
||||
marketSlug?: string | null;
|
||||
targetDate?: string | null;
|
||||
},
|
||||
) {
|
||||
const force = options?.force ?? false;
|
||||
const marketSlug = options?.marketSlug || null;
|
||||
const targetDate = options?.targetDate || null;
|
||||
if (!force) {
|
||||
const requestKey = `${cityName}::cached::${marketSlug || "-"}::${
|
||||
targetDate || "-"
|
||||
}`;
|
||||
const existing = pendingMarketScanRequests.get(requestKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
force_refresh: "false",
|
||||
});
|
||||
if (marketSlug) {
|
||||
params.set("market_slug", marketSlug);
|
||||
}
|
||||
if (targetDate) {
|
||||
params.set("target_date", targetDate);
|
||||
}
|
||||
|
||||
const request = fetchJson<{ market_scan?: MarketScan }>(
|
||||
`/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
|
||||
)
|
||||
.then((data) => data.market_scan || null)
|
||||
.finally(() => {
|
||||
pendingMarketScanRequests.delete(requestKey);
|
||||
});
|
||||
|
||||
pendingMarketScanRequests.set(requestKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
force_refresh: "true",
|
||||
_ts: String(Date.now()),
|
||||
});
|
||||
if (marketSlug) {
|
||||
params.set("market_slug", marketSlug);
|
||||
}
|
||||
if (targetDate) {
|
||||
params.set("target_date", targetDate);
|
||||
}
|
||||
|
||||
return fetchJson<{ market_scan?: MarketScan }>(
|
||||
`/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
|
||||
).then((data) => data.market_scan || null);
|
||||
},
|
||||
|
||||
async getHistory(cityName: string) {
|
||||
const requestKey = normalizeCityName(cityName);
|
||||
async getHistory(cityName: string, options?: { includeRecords?: boolean }) {
|
||||
const includeRecords = options?.includeRecords === true;
|
||||
const requestKey = `${normalizeCityName(cityName)}::${
|
||||
includeRecords ? "full" : "preview"
|
||||
}`;
|
||||
const existing = pendingHistoryRequests.get(requestKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = fetchJson<{ history?: HistoryPoint[] }>(
|
||||
`/api/history/${requestKey}`,
|
||||
const params = new URLSearchParams();
|
||||
if (includeRecords) {
|
||||
params.set("include_records", "true");
|
||||
}
|
||||
|
||||
const request = fetchJson<HistoryPayload>(
|
||||
`/api/history/${normalizeCityName(cityName)}${
|
||||
params.size ? `?${params.toString()}` : ""
|
||||
}`,
|
||||
)
|
||||
.then((data) => data.history || [])
|
||||
.then((data) => ({
|
||||
...data,
|
||||
full_count: Number(data.full_count || 0),
|
||||
has_more: data.has_more === true,
|
||||
history: Array.isArray(data.history) ? data.history : [],
|
||||
mode: (data.mode === "full" ? "full" : "preview") as
|
||||
| "full"
|
||||
| "preview",
|
||||
preview_count: Number(data.preview_count || 0),
|
||||
}))
|
||||
.finally(() => {
|
||||
pendingHistoryRequests.delete(requestKey);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,23 @@ export type OfficialSourceLink = {
|
||||
kind: "agency" | "airport" | "metar";
|
||||
};
|
||||
|
||||
function buildJmaAmedasTenMinuteUrl(
|
||||
localDate?: string | null,
|
||||
options?: {
|
||||
blockNo?: string;
|
||||
precNo?: string;
|
||||
},
|
||||
) {
|
||||
const blockNo = String(options?.blockNo || "0371").trim() || "0371";
|
||||
const precNo = String(options?.precNo || "44").trim() || "44";
|
||||
const match = String(localDate || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
const now = new Date();
|
||||
const year = match?.[1] || String(now.getFullYear());
|
||||
const month = match?.[2] || String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = match?.[3] || String(now.getDate()).padStart(2, "0");
|
||||
return `https://www.data.jma.go.jp/stats/etrn/view/10min_a1.php?prec_no=${encodeURIComponent(precNo)}&block_no=${encodeURIComponent(blockNo)}&year=${encodeURIComponent(year)}&month=${encodeURIComponent(month)}&day=${encodeURIComponent(day)}&view=`;
|
||||
}
|
||||
|
||||
const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
||||
singapore: [
|
||||
{
|
||||
@@ -109,6 +126,47 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
||||
kind: "metar",
|
||||
},
|
||||
],
|
||||
manila: [
|
||||
{
|
||||
label: "Wunderground RPLL",
|
||||
href: "https://www.wunderground.com/history/daily/ph/manila/RPLL",
|
||||
kind: "agency",
|
||||
},
|
||||
{
|
||||
label: "Ninoy Aquino International Airport",
|
||||
href: "https://www.newnaia.com.ph/",
|
||||
kind: "airport",
|
||||
},
|
||||
{
|
||||
label: "RPLL METAR",
|
||||
href: "https://aviationweather.gov/data/metar/?id=RPLL&decoded=1&taf=1",
|
||||
kind: "metar",
|
||||
},
|
||||
],
|
||||
karachi: [
|
||||
{
|
||||
label: "Wunderground OPKC",
|
||||
href: "https://www.wunderground.com/history/daily/pk/karachi/OPKC",
|
||||
kind: "agency",
|
||||
},
|
||||
{
|
||||
label: "Jinnah International Airport",
|
||||
href: "https://www.caapakistan.com.pk/",
|
||||
kind: "airport",
|
||||
},
|
||||
{
|
||||
label: "OPKC METAR",
|
||||
href: "https://aviationweather.gov/data/metar/?id=OPKC&decoded=1&taf=1",
|
||||
kind: "metar",
|
||||
},
|
||||
],
|
||||
"masroor air base": [
|
||||
{
|
||||
label: "OPMR METAR",
|
||||
href: "https://aviationweather.gov/data/metar/?id=OPMR&decoded=1&taf=1",
|
||||
kind: "metar",
|
||||
},
|
||||
],
|
||||
istanbul: [
|
||||
{
|
||||
label: "MGM",
|
||||
@@ -329,8 +387,8 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
||||
],
|
||||
tokyo: [
|
||||
{
|
||||
label: "JMA",
|
||||
href: "https://www.jma.go.jp/jma/indexe.html",
|
||||
label: "JMA 羽田10分钟实况",
|
||||
href: "",
|
||||
kind: "agency",
|
||||
},
|
||||
{
|
||||
@@ -681,7 +739,18 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
||||
|
||||
export function getOfficialSourceLinks(detail: CityDetail): OfficialSourceLink[] {
|
||||
const cityKey = String(detail.name || "").trim().toLowerCase();
|
||||
const links = [...(CITY_SPECIFIC_SOURCES[cityKey] || [])];
|
||||
const links = [...(CITY_SPECIFIC_SOURCES[cityKey] || [])].map((link) => {
|
||||
if (cityKey === "tokyo" && link.kind === "agency" && link.label === "JMA 羽田10分钟实况") {
|
||||
return {
|
||||
...link,
|
||||
href: buildJmaAmedasTenMinuteUrl(detail.local_date, {
|
||||
blockNo: "0371",
|
||||
precNo: "44",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return link;
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
return links.filter((link) => {
|
||||
const key = `${link.label}|${link.href}`;
|
||||
|
||||
@@ -316,6 +316,31 @@ export interface MarketScan {
|
||||
websocket?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IntradayMeteorologySignal {
|
||||
label?: string | null;
|
||||
label_en?: string | null;
|
||||
direction?: "support" | "suppress" | "neutral" | string | null;
|
||||
strength?: "weak" | "medium" | "strong" | string | null;
|
||||
summary?: string | null;
|
||||
summary_en?: string | null;
|
||||
}
|
||||
|
||||
export interface IntradayMeteorology {
|
||||
headline?: string | null;
|
||||
headline_en?: string | null;
|
||||
confidence?: "low" | "medium" | "high" | string | null;
|
||||
base_case_bucket?: string | null;
|
||||
upside_bucket?: string | null;
|
||||
downside_bucket?: string | null;
|
||||
next_observation_time?: string | null;
|
||||
peak_window?: string | null;
|
||||
invalidation_rules?: string[] | null;
|
||||
invalidation_rules_en?: string[] | null;
|
||||
confirmation_rules?: string[] | null;
|
||||
confirmation_rules_en?: string[] | null;
|
||||
signal_contributions?: IntradayMeteorologySignal[] | null;
|
||||
}
|
||||
|
||||
export interface AiAnalysisStructured {
|
||||
summary?: string | null;
|
||||
text?: string | null;
|
||||
@@ -327,6 +352,7 @@ export interface AiAnalysisStructured {
|
||||
export interface CityDetail {
|
||||
name: string;
|
||||
display_name: string;
|
||||
detail_depth?: "panel" | "market" | "nearby" | "full";
|
||||
lat: number;
|
||||
lon: number;
|
||||
temp_symbol: string;
|
||||
@@ -472,6 +498,7 @@ export interface CityDetail {
|
||||
multi_model_daily?: Record<string, DailyModelForecast>;
|
||||
source_forecasts?: SourceForecasts;
|
||||
market_scan?: MarketScan;
|
||||
intraday_meteorology?: IntradayMeteorology;
|
||||
}
|
||||
|
||||
export interface HistoryPoint {
|
||||
@@ -493,19 +520,42 @@ export interface HistoryPoint {
|
||||
deb_at_peak_minus_12h_error?: number | null;
|
||||
}
|
||||
|
||||
export interface HistoryPayloadMeta {
|
||||
mode: "preview" | "full";
|
||||
hasMore: boolean;
|
||||
fullCount: number;
|
||||
previewCount: number;
|
||||
settlementSource?: string | null;
|
||||
settlementSourceLabel?: string | null;
|
||||
}
|
||||
|
||||
export interface HistoryPayload {
|
||||
history: HistoryPoint[];
|
||||
has_more?: boolean;
|
||||
full_count?: number;
|
||||
preview_count?: number;
|
||||
mode?: "preview" | "full";
|
||||
settlement_source?: string | null;
|
||||
settlement_source_label?: string | null;
|
||||
}
|
||||
|
||||
export interface LoadingState {
|
||||
cities: boolean;
|
||||
cityDetail: boolean;
|
||||
refresh: boolean;
|
||||
history: boolean;
|
||||
marketScan?: boolean;
|
||||
futureDeep?: boolean;
|
||||
historyRecords?: boolean;
|
||||
}
|
||||
|
||||
export interface HistoryState {
|
||||
isOpen: boolean;
|
||||
loading: boolean;
|
||||
recordsLoading: boolean;
|
||||
error: string | null;
|
||||
dataByCity: Record<string, HistoryPoint[]>;
|
||||
metaByCity: Record<string, HistoryPayloadMeta>;
|
||||
}
|
||||
|
||||
export interface ProAccessState {
|
||||
@@ -515,6 +565,8 @@ export interface ProAccessState {
|
||||
subscriptionActive: boolean;
|
||||
subscriptionPlanCode: string | null;
|
||||
subscriptionExpiresAt: string | null;
|
||||
subscriptionTotalExpiresAt: string | null;
|
||||
subscriptionQueuedDays: number;
|
||||
points: number;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -530,3 +582,4 @@ export interface DashboardState {
|
||||
historyState: HistoryState;
|
||||
proAccess: ProAccessState;
|
||||
}
|
||||
|
||||
|
||||
+526
-50
@@ -248,6 +248,153 @@ function hmToMinutes(value?: string | null) {
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
function findNearestTimeIndex(
|
||||
times: string[],
|
||||
targetTime?: string | null,
|
||||
) {
|
||||
const targetMinutes = hmToMinutes(targetTime);
|
||||
if (targetMinutes == null || !times.length) return -1;
|
||||
let nearestIndex = -1;
|
||||
let nearestDelta = Number.POSITIVE_INFINITY;
|
||||
times.forEach((time, index) => {
|
||||
const minute = hmToMinutes(time);
|
||||
if (minute == null) return;
|
||||
const delta = Math.abs(minute - targetMinutes);
|
||||
if (delta < nearestDelta) {
|
||||
nearestDelta = delta;
|
||||
nearestIndex = index;
|
||||
}
|
||||
});
|
||||
return nearestIndex;
|
||||
}
|
||||
|
||||
function buildTemperatureTickLabels(times: string[]) {
|
||||
const lastIndex = Math.max(0, times.length - 1);
|
||||
return times.map((time, index) => {
|
||||
if (index === 0 || index === lastIndex) return time;
|
||||
const minute = hmToMinutes(time);
|
||||
if (minute == null) return "";
|
||||
const hour = Math.floor(minute / 60);
|
||||
const minutePart = minute % 60;
|
||||
if (minutePart !== 0) return "";
|
||||
return hour % 2 === 0 ? time : "";
|
||||
});
|
||||
}
|
||||
|
||||
function buildSeriesPoints(
|
||||
times: string[],
|
||||
values: Array<number | null | undefined>,
|
||||
) {
|
||||
return times
|
||||
.map((time, index) => {
|
||||
const x = hmToMinutes(time);
|
||||
const y = values[index];
|
||||
return x != null && y != null && Number.isFinite(Number(y))
|
||||
? { index, labelTime: time, x, y: Number(y) }
|
||||
: null;
|
||||
})
|
||||
.filter(
|
||||
(point): point is { index: number; labelTime: string; x: number; y: number } =>
|
||||
point != null,
|
||||
);
|
||||
}
|
||||
|
||||
function buildObservationPoints(items: Array<{ time?: string; temp?: number | null }>) {
|
||||
return items
|
||||
.map((item) => {
|
||||
const labelTime = normalizeHm(String(item.time || ""));
|
||||
const x = hmToMinutes(labelTime);
|
||||
const y = item.temp;
|
||||
return x != null && y != null && Number.isFinite(Number(y))
|
||||
? { labelTime: labelTime || "", x, y: Number(y) }
|
||||
: null;
|
||||
})
|
||||
.filter((point): point is { labelTime: string; x: number; y: number } => point != null);
|
||||
}
|
||||
|
||||
function sortObservationItemsByTime<T extends { time?: string | null }>(items: T[]) {
|
||||
return [...items].sort((left, right) => {
|
||||
const leftMinutes = hmToMinutes(left.time);
|
||||
const rightMinutes = hmToMinutes(right.time);
|
||||
if (leftMinutes == null && rightMinutes == null) return 0;
|
||||
if (leftMinutes == null) return 1;
|
||||
if (rightMinutes == null) return -1;
|
||||
return leftMinutes - rightMinutes;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeObservationTimeForChart(
|
||||
value: unknown,
|
||||
detail: CityDetail,
|
||||
) {
|
||||
const raw = String(value || "").trim();
|
||||
if (raw && !raw.includes("T")) {
|
||||
return normalizeHm(raw) || raw;
|
||||
}
|
||||
return normalizeHm(detail.local_time) || normalizeHm(raw) || raw;
|
||||
}
|
||||
|
||||
function buildCurrentObservationFallback(
|
||||
detail: CityDetail,
|
||||
): Array<{ time?: string; temp?: number | null; sourceLabel?: string | null }> {
|
||||
const candidates: Array<{
|
||||
sourceLabel?: string | null;
|
||||
temp?: number | null;
|
||||
time?: string | null;
|
||||
}> = [
|
||||
{
|
||||
sourceLabel: detail.current?.settlement_source_label,
|
||||
temp: detail.current?.temp,
|
||||
time: detail.current?.obs_time || detail.current?.report_time,
|
||||
},
|
||||
{
|
||||
sourceLabel: detail.airport_primary?.source_label || "METAR",
|
||||
temp: detail.airport_primary?.temp,
|
||||
time: detail.airport_primary?.obs_time || detail.airport_primary?.report_time,
|
||||
},
|
||||
{
|
||||
sourceLabel: detail.airport_current?.source_label || "METAR",
|
||||
temp: detail.airport_current?.temp,
|
||||
time: detail.airport_current?.obs_time || detail.airport_current?.report_time,
|
||||
},
|
||||
{
|
||||
sourceLabel:
|
||||
detail.center_station_candidate?.source_label ||
|
||||
detail.center_station_candidate?.source_code,
|
||||
temp: detail.center_station_candidate?.temp,
|
||||
time: String((detail.center_station_candidate as Record<string, unknown> | null | undefined)?.obs_time || ""),
|
||||
},
|
||||
{
|
||||
sourceLabel:
|
||||
detail.official_nearby?.[0]?.source_label ||
|
||||
detail.official_nearby?.[0]?.source_code,
|
||||
temp: detail.official_nearby?.[0]?.temp,
|
||||
time: String((detail.official_nearby?.[0] as Record<string, unknown> | null | undefined)?.obs_time || ""),
|
||||
},
|
||||
{
|
||||
sourceLabel:
|
||||
detail.mgm_nearby?.[0]?.source_label ||
|
||||
detail.mgm_nearby?.[0]?.source_code,
|
||||
temp: detail.mgm_nearby?.[0]?.temp,
|
||||
time: String((detail.mgm_nearby?.[0] as Record<string, unknown> | null | undefined)?.obs_time || ""),
|
||||
},
|
||||
];
|
||||
|
||||
const first = candidates.find((item) => {
|
||||
const numeric = Number(item.temp);
|
||||
return Number.isFinite(numeric);
|
||||
});
|
||||
if (!first) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
sourceLabel: first.sourceLabel,
|
||||
temp: Number(first.temp),
|
||||
time: normalizeObservationTimeForChart(first.time, detail),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function interpolateSeriesAtMinutes(
|
||||
times: string[],
|
||||
values: Array<number | null | undefined>,
|
||||
@@ -516,10 +663,7 @@ export function getTemperatureChartData(
|
||||
|
||||
if (!times.length) return null;
|
||||
|
||||
const currentHour = detail.local_time
|
||||
? `${detail.local_time.split(":")[0]}:00`
|
||||
: null;
|
||||
const currentIndex = currentHour ? times.indexOf(currentHour) : -1;
|
||||
const currentIndex = findNearestTimeIndex(times, detail.local_time);
|
||||
const omMax = detail.forecast?.today_high;
|
||||
const debMax = detail.deb?.prediction;
|
||||
const offset =
|
||||
@@ -551,9 +695,38 @@ export function getTemperatureChartData(
|
||||
? [{ time: detail.current.obs_time, temp: detail.current.temp }]
|
||||
: []
|
||||
: [];
|
||||
const metarObservationSource = detail.metar_today_obs?.length
|
||||
? detail.metar_today_obs
|
||||
: detail.trend?.recent || [];
|
||||
const currentObservationFallback = buildCurrentObservationFallback(detail);
|
||||
const minPlausibleObservationTemp = (() => {
|
||||
const name = String(detail.name || "").trim().toLowerCase();
|
||||
const icao = String(detail.risk?.icao || "").trim().toUpperCase();
|
||||
if (name === "karachi" || name === "masroor air base" || icao === "OPKC" || icao === "OPMR") {
|
||||
return detail.temp_symbol === "°F" ? 41 : 5;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const filterPlausibleObservations = <T extends { temp?: number | null }>(
|
||||
rows?: T[] | null,
|
||||
) =>
|
||||
(Array.isArray(rows) ? rows : []).filter((row) => {
|
||||
const value = Number(row?.temp);
|
||||
if (!Number.isFinite(value)) return false;
|
||||
return minPlausibleObservationTemp == null || value >= minPlausibleObservationTemp;
|
||||
});
|
||||
const plausibleMetarTodayObs = filterPlausibleObservations(detail.metar_today_obs);
|
||||
const plausibleTrendRecent = filterPlausibleObservations(detail.trend?.recent);
|
||||
const plausibleCurrentFallback = filterPlausibleObservations(currentObservationFallback);
|
||||
const metarObservationSource = plausibleMetarTodayObs.length
|
||||
? plausibleMetarTodayObs
|
||||
: plausibleTrendRecent.length
|
||||
? plausibleTrendRecent
|
||||
: plausibleCurrentFallback;
|
||||
const usingCurrentObservationFallback =
|
||||
!plausibleMetarTodayObs.length &&
|
||||
!plausibleTrendRecent.length &&
|
||||
plausibleCurrentFallback.length > 0;
|
||||
const currentFallbackTag =
|
||||
currentObservationFallback[0]?.sourceLabel ||
|
||||
getObservationSourceTag(detail);
|
||||
const allowMetarFallback = settlementSource && observationCode !== "hko";
|
||||
const shouldUseMetarFallback =
|
||||
allowMetarFallback &&
|
||||
@@ -572,7 +745,9 @@ export function getTemperatureChartData(
|
||||
return `${icao} METAR`;
|
||||
})();
|
||||
const observationDisplayTag =
|
||||
observationCode === "wunderground"
|
||||
usingCurrentObservationFallback
|
||||
? String(currentFallbackTag).toUpperCase()
|
||||
: observationCode === "wunderground"
|
||||
? metarFallbackTag
|
||||
: useSettlementObservationSource && shouldUseMetarFallback
|
||||
? metarFallbackTag
|
||||
@@ -582,11 +757,7 @@ export function getTemperatureChartData(
|
||||
|
||||
const metarPoints = new Array(times.length).fill(null);
|
||||
observationSource.forEach((item) => {
|
||||
const parts = String(item.time || "").split(":");
|
||||
let hour = Number.parseInt(parts[0], 10);
|
||||
if (Number.isNaN(hour)) return;
|
||||
const key = `${String(hour).padStart(2, "0")}:00`;
|
||||
const index = times.indexOf(key);
|
||||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||||
const temp = item.temp ?? null;
|
||||
if (index >= 0 && temp != null) {
|
||||
const existing = metarPoints[index];
|
||||
@@ -598,11 +769,7 @@ export function getTemperatureChartData(
|
||||
});
|
||||
const airportMetarPoints = new Array(times.length).fill(null);
|
||||
airportMetarSource.forEach((item) => {
|
||||
const parts = String(item.time || "").split(":");
|
||||
const hour = Number.parseInt(parts[0], 10);
|
||||
if (Number.isNaN(hour)) return;
|
||||
const key = `${String(hour).padStart(2, "0")}:00`;
|
||||
const index = times.indexOf(key);
|
||||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||||
const temp = item.temp ?? null;
|
||||
if (index >= 0 && temp != null) {
|
||||
const existing = airportMetarPoints[index];
|
||||
@@ -617,24 +784,16 @@ export function getTemperatureChartData(
|
||||
detail.mgm?.temp != null &&
|
||||
detail.mgm?.time
|
||||
) {
|
||||
const match = detail.mgm.time.match(/T?(\d{2}):(\d{2})/);
|
||||
if (match) {
|
||||
let hour = Number.parseInt(match[1], 10);
|
||||
const key = `${String(hour).padStart(2, "0")}:00`;
|
||||
const index = times.indexOf(key);
|
||||
if (index >= 0) {
|
||||
mgmPoints[index] = detail.mgm.temp;
|
||||
}
|
||||
const index = findNearestTimeIndex(times, detail.mgm.time);
|
||||
if (index >= 0) {
|
||||
mgmPoints[index] = detail.mgm.temp;
|
||||
}
|
||||
}
|
||||
|
||||
const mgmHourlyPoints = new Array(times.length).fill(null);
|
||||
let hasMgmHourly = false;
|
||||
detail.mgm?.hourly?.forEach((item) => {
|
||||
const match = String(item.time || "").match(/T?(\d{2}):(\d{2})/);
|
||||
if (!match) return;
|
||||
const key = `${match[1]}:00`;
|
||||
const index = times.indexOf(key);
|
||||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||||
if (index >= 0) {
|
||||
mgmHourlyPoints[index] = item.temp ?? null;
|
||||
hasMgmHourly = true;
|
||||
@@ -723,7 +882,7 @@ export function getTemperatureChartData(
|
||||
const tafMarkers = tafMarkersRaw
|
||||
.map((marker) => {
|
||||
const labelTime = String(marker?.label_time || "").trim();
|
||||
const index = times.indexOf(labelTime);
|
||||
const index = findNearestTimeIndex(times, labelTime);
|
||||
if (index >= 0) {
|
||||
tafMarkerPoints[index] = tafMarkerValue;
|
||||
}
|
||||
@@ -824,21 +983,20 @@ export function getTemperatureChartData(
|
||||
);
|
||||
}
|
||||
if ((detail.trend?.recent?.length || 0) > 0 || observationSource.length > 0) {
|
||||
const recentData =
|
||||
const recentData = sortObservationItemsByTime(
|
||||
observationSource.length > 0
|
||||
? [...observationSource]
|
||||
: [...(detail.trend?.recent || [])];
|
||||
: [...(detail.trend?.recent || [])],
|
||||
);
|
||||
const recentText = recentData
|
||||
.slice(0, 4)
|
||||
.reverse()
|
||||
.slice(-4)
|
||||
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
|
||||
.join(" -> ");
|
||||
legendParts.push(`${observationDisplayTag}: ${recentText}`);
|
||||
}
|
||||
if (airportMetarSource.length > 0) {
|
||||
const airportRecentText = [...airportMetarSource]
|
||||
const airportRecentText = sortObservationItemsByTime([...airportMetarSource])
|
||||
.slice(-4)
|
||||
.reverse()
|
||||
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
|
||||
.join(" -> ");
|
||||
legendParts.push(
|
||||
@@ -890,20 +1048,66 @@ export function getTemperatureChartData(
|
||||
);
|
||||
}
|
||||
|
||||
const debPastSeries = buildSeriesPoints(times, debPast);
|
||||
const debFutureSeries = buildSeriesPoints(times, debFuture);
|
||||
const tempsSeries = buildSeriesPoints(times, temps);
|
||||
const mgmHourlySeries = buildSeriesPoints(times, mgmHourlyPoints);
|
||||
const metarSeries = buildObservationPoints(observationSource);
|
||||
const airportMetarSeries = buildObservationPoints(airportMetarSource);
|
||||
const mgmSeries =
|
||||
!suppressAnkaraMgmObservation && detail.mgm?.temp != null && detail.mgm?.time
|
||||
? buildObservationPoints([{ time: detail.mgm.time, temp: detail.mgm.temp }])
|
||||
: [];
|
||||
const tafCurrentMarkerSeries = tafMarkers
|
||||
.filter((marker) => marker.isCurrent)
|
||||
.map((marker) => ({
|
||||
marker,
|
||||
x: hmToMinutes(marker.labelTime) ?? 0,
|
||||
y: tafMarkerValue,
|
||||
}))
|
||||
.filter((point) => point.x > 0);
|
||||
const tafPeakWindowMarkerSeries = tafMarkers
|
||||
.filter((marker) => marker.isPeakWindow && !marker.isCurrent)
|
||||
.map((marker) => ({
|
||||
marker,
|
||||
x: hmToMinutes(marker.labelTime) ?? 0,
|
||||
y: tafMarkerValue - 0.15,
|
||||
}))
|
||||
.filter((point) => point.x > 0);
|
||||
const tafMarkerSeries = tafMarkers
|
||||
.map((marker) => ({
|
||||
marker,
|
||||
x: hmToMinutes(marker.labelTime) ?? 0,
|
||||
y: tafMarkerValue,
|
||||
}))
|
||||
.filter((point) => point.x > 0);
|
||||
const xMin = times.length ? hmToMinutes(times[0]) ?? 0 : 0;
|
||||
const xMax = times.length ? hmToMinutes(times[times.length - 1]) ?? 24 * 60 : 24 * 60;
|
||||
|
||||
return {
|
||||
datasets: {
|
||||
airportMetarPoints,
|
||||
airportMetarSeries,
|
||||
debFuture,
|
||||
debFutureSeries,
|
||||
debPast,
|
||||
debPastSeries,
|
||||
hasMgmHourly,
|
||||
metarPoints,
|
||||
metarSeries,
|
||||
mgmHourlyPoints,
|
||||
mgmHourlySeries,
|
||||
mgmPoints,
|
||||
mgmSeries,
|
||||
offset,
|
||||
tafCurrentMarkerPoints,
|
||||
tafCurrentMarkerSeries,
|
||||
tafMarkerPoints,
|
||||
tafMarkerSeries,
|
||||
tafPeakWindowMarkerPoints,
|
||||
tafPeakWindowMarkerSeries,
|
||||
temps,
|
||||
tempsSeries,
|
||||
},
|
||||
observationLabel:
|
||||
observationCode === "noaa" &&
|
||||
@@ -918,7 +1122,10 @@ export function getTemperatureChartData(
|
||||
max,
|
||||
min,
|
||||
tafMarkers,
|
||||
tickLabels: buildTemperatureTickLabels(times),
|
||||
times,
|
||||
xMax,
|
||||
xMin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1854,6 +2061,107 @@ export function computeFrontTrendSignal(
|
||||
(max, point) => Math.max(max, Number(point.precipProb) || 0),
|
||||
0,
|
||||
);
|
||||
const precipWindowSource =
|
||||
futureSlice.length >= 2 ? futureSlice : slice;
|
||||
const precipCandidates = precipWindowSource
|
||||
.map((point) => ({
|
||||
label: normalizeHm(point.label),
|
||||
minute: pointMinutes(point),
|
||||
precip: Number(point.precipProb) || 0,
|
||||
}))
|
||||
.filter(
|
||||
(point): point is { label: string; minute: number; precip: number } =>
|
||||
point.label != null && point.minute != null,
|
||||
);
|
||||
const precipThreshold = precipMax >= 70 ? 50 : precipMax >= 40 ? 40 : 0;
|
||||
const precipWindows: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
startLabel: string;
|
||||
endLabel: string;
|
||||
peak: number;
|
||||
}> = [];
|
||||
if (precipThreshold > 0) {
|
||||
let active:
|
||||
| {
|
||||
start: number;
|
||||
end: number;
|
||||
startLabel: string;
|
||||
endLabel: string;
|
||||
peak: number;
|
||||
}
|
||||
| null = null;
|
||||
for (const point of precipCandidates) {
|
||||
if (point.precip >= precipThreshold) {
|
||||
if (!active) {
|
||||
active = {
|
||||
start: point.minute,
|
||||
end: point.minute,
|
||||
startLabel: point.label,
|
||||
endLabel: point.label,
|
||||
peak: point.precip,
|
||||
};
|
||||
} else {
|
||||
active.end = point.minute;
|
||||
active.endLabel = point.label;
|
||||
active.peak = Math.max(active.peak, point.precip);
|
||||
}
|
||||
} else if (active) {
|
||||
precipWindows.push(active);
|
||||
active = null;
|
||||
}
|
||||
}
|
||||
if (active) precipWindows.push(active);
|
||||
}
|
||||
const primaryPrecipWindow =
|
||||
precipWindows.length > 0
|
||||
? [...precipWindows].sort((left, right) => {
|
||||
if (right.peak !== left.peak) return right.peak - left.peak;
|
||||
return (right.end - right.start) - (left.end - left.start);
|
||||
})[0]
|
||||
: null;
|
||||
const precipWindowLabel = primaryPrecipWindow
|
||||
? `${primaryPrecipWindow.startLabel}-${primaryPrecipWindow.endLabel}`
|
||||
: "";
|
||||
const precipPeakOverlap =
|
||||
primaryPrecipWindow &&
|
||||
peakWindowStartMinutes != null &&
|
||||
peakWindowEndMinutes != null
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(primaryPrecipWindow.end, peakWindowEndMinutes) -
|
||||
Math.max(primaryPrecipWindow.start, peakWindowStartMinutes),
|
||||
)
|
||||
: 0;
|
||||
const precipOverlapLevel =
|
||||
primaryPrecipWindow && peakWindowStartMinutes != null && peakWindowEndMinutes != null
|
||||
? precipPeakOverlap >= 120
|
||||
? "high"
|
||||
: precipPeakOverlap > 0
|
||||
? "medium"
|
||||
: "low"
|
||||
: "unknown";
|
||||
const precipWindowSummary = (() => {
|
||||
if (!primaryPrecipWindow) {
|
||||
return isEnglish(locale)
|
||||
? "No concentrated model precipitation window is visible yet."
|
||||
: "模型里暂时还看不出明显集中的降水窗口。";
|
||||
}
|
||||
const overlapText = isEnglish(locale)
|
||||
? precipOverlapLevel === "high"
|
||||
? "It overlaps heavily with the peak window."
|
||||
: precipOverlapLevel === "medium"
|
||||
? "It overlaps part of the peak window."
|
||||
: "It sits mostly outside the peak window."
|
||||
: precipOverlapLevel === "high"
|
||||
? "与峰值窗口重叠较高。"
|
||||
: precipOverlapLevel === "medium"
|
||||
? "与峰值窗口部分重叠。"
|
||||
: "主要落在峰值窗口之外。";
|
||||
return isEnglish(locale)
|
||||
? `Model precipitation window is ${precipWindowLabel}, peaking near ${Math.round(primaryPrecipWindow.peak)}%. ${overlapText}`
|
||||
: `模型降水窗口在 ${precipWindowLabel},峰值约 ${Math.round(primaryPrecipWindow.peak)}%。${overlapText}`;
|
||||
})();
|
||||
const firstBucket = trendBucketFromDir(first.windDir);
|
||||
const lastBucket = trendBucketFromDir(last.windDir);
|
||||
const weatherGovPeriods = getForecastTextForDate(detail, dateStr);
|
||||
@@ -1981,7 +2289,11 @@ export function computeFrontTrendSignal(
|
||||
}
|
||||
|
||||
if (precipMax >= 50) {
|
||||
parts.push("Precipitation risk is high enough to watch for cloud/rain suppression.");
|
||||
parts.push(
|
||||
primaryPrecipWindow
|
||||
? `Model precipitation risk concentrates in ${precipWindowLabel}.`
|
||||
: "Precipitation risk is high enough to watch for cloud/rain suppression.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
@@ -2023,7 +2335,11 @@ export function computeFrontTrendSignal(
|
||||
}
|
||||
|
||||
if (precipMax >= 50) {
|
||||
parts.push("降水概率已足以关注云雨压温。");
|
||||
parts.push(
|
||||
primaryPrecipWindow
|
||||
? `模型降水窗口主要落在 ${precipWindowLabel}。`
|
||||
: "降水概率已足以关注云雨压温。",
|
||||
);
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
@@ -2175,13 +2491,13 @@ export function computeFrontTrendSignal(
|
||||
const precipNote = (() => {
|
||||
if (precipMax >= 60) {
|
||||
return isEnglish(locale)
|
||||
? "Precipitation risk is high enough that cloud/rain suppression can materially change the peak outcome."
|
||||
: "降水概率已高到足以显著改变峰值兑现结果,需要重点防压温。";
|
||||
? `${precipWindowSummary} Cloud/rain suppression can materially change the peak outcome.`
|
||||
: `${precipWindowSummary} 降水风险已高到足以显著改变峰值兑现结果,需要重点防压温。`;
|
||||
}
|
||||
if (precipMax >= 40) {
|
||||
return isEnglish(locale)
|
||||
? "Precipitation risk is meaningful; watch whether cloud and showers interrupt daytime heating."
|
||||
: "降水概率已有存在感,需要关注云系和阵雨是否打断白天增温。";
|
||||
? `${precipWindowSummary} Watch whether cloud and showers interrupt daytime heating.`
|
||||
: `${precipWindowSummary} 需要关注云系和阵雨是否打断白天增温。`;
|
||||
}
|
||||
return isEnglish(locale)
|
||||
? "Precipitation risk remains limited and is used mainly as a suppression check."
|
||||
@@ -2215,10 +2531,12 @@ export function computeFrontTrendSignal(
|
||||
value: `${bucketLabel(firstBucket, locale)} -> ${bucketLabel(lastBucket, locale)}`,
|
||||
},
|
||||
{
|
||||
label: isEnglish(locale) ? "Precip probability" : "降水概率",
|
||||
label: isEnglish(locale) ? "Precip window" : "降水窗口",
|
||||
note: precipNote,
|
||||
tone: precipMax >= 50 ? "cold" : "",
|
||||
value: `${Math.round(precipMax)}%`,
|
||||
value: primaryPrecipWindow
|
||||
? `${precipWindowLabel} · ${Math.round(precipMax)}%`
|
||||
: `${Math.round(precipMax)}%`,
|
||||
},
|
||||
{
|
||||
label: isEnglish(locale) ? "Forecast cloud-cover delta" : "预测云量增幅",
|
||||
@@ -2253,6 +2571,8 @@ export function computeFrontTrendSignal(
|
||||
metrics,
|
||||
upperAirMetrics,
|
||||
upperAirSummary,
|
||||
precipOverlapLevel,
|
||||
precipWindowLabel,
|
||||
precipMax,
|
||||
score,
|
||||
summary: combinedSummary || backendSummary || summary,
|
||||
@@ -2620,9 +2940,164 @@ export function getHistorySummary(
|
||||
};
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function firstFiniteNumber(values: unknown[]) {
|
||||
for (const value of values) {
|
||||
const numeric = toFiniteNumber(value);
|
||||
if (numeric != null) return numeric;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstNonEmptyString(values: unknown[]) {
|
||||
for (const value of values) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatObservationUpdate(value: unknown, locale: Locale) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const looksDated =
|
||||
/^\d{4}-\d{2}-\d{2}/.test(raw) ||
|
||||
raw.includes("T") ||
|
||||
/[+-]\d{2}:?\d{2}$/.test(raw);
|
||||
if (looksDated) {
|
||||
const parsed = new Date(raw);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return new Intl.DateTimeFormat(isEnglish(locale) ? "en-US" : "zh-CN", {
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
minute: "2-digit",
|
||||
month: "2-digit",
|
||||
})
|
||||
.format(parsed)
|
||||
.replace(",", "");
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeHm(raw) || raw;
|
||||
}
|
||||
|
||||
function getOfficialObservationCandidates(detail: CityDetail) {
|
||||
const officialNearby = Array.isArray(detail.official_nearby)
|
||||
? detail.official_nearby
|
||||
: [];
|
||||
const mgmNearby = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : [];
|
||||
const officialAnchor =
|
||||
officialNearby.find(
|
||||
(station) => station.is_settlement_anchor || station.is_airport_station,
|
||||
) || officialNearby[0];
|
||||
|
||||
return {
|
||||
centerStation: detail.center_station_candidate,
|
||||
mgmNearby,
|
||||
officialAnchor,
|
||||
officialNearby,
|
||||
};
|
||||
}
|
||||
|
||||
function getObservedTemperatureProfile(detail: CityDetail, locale: Locale) {
|
||||
const { mgmNearby } = getOfficialObservationCandidates(detail);
|
||||
const currentSource = String(
|
||||
detail.current?.settlement_source ||
|
||||
detail.current?.settlement_source_label ||
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const isNmcCurrent = currentSource === "nmc" || currentSource.includes("nmc");
|
||||
const isNmcAirport = String(detail.airport_primary?.source_label || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.includes("nmc");
|
||||
const candidates = [
|
||||
{
|
||||
sourceLabel: detail.airport_current?.source_label || "METAR",
|
||||
temp: detail.airport_current?.temp,
|
||||
},
|
||||
{
|
||||
sourceLabel: detail.airport_primary?.source_label || "METAR",
|
||||
temp: isNmcAirport ? null : detail.airport_primary?.temp,
|
||||
},
|
||||
{
|
||||
sourceLabel: detail.current?.settlement_source_label,
|
||||
temp: isNmcCurrent ? null : detail.current?.temp,
|
||||
},
|
||||
{
|
||||
sourceLabel: mgmNearby[0]?.source_label,
|
||||
temp: mgmNearby[0]?.temp,
|
||||
},
|
||||
];
|
||||
const selected = candidates.find((candidate) =>
|
||||
Number.isFinite(Number(candidate.temp)),
|
||||
);
|
||||
|
||||
if (!selected) {
|
||||
return isEnglish(locale) ? "Unavailable" : "未提供";
|
||||
}
|
||||
|
||||
const observedTemp = Number(selected.temp);
|
||||
const sourceLabel = firstNonEmptyString([
|
||||
selected.sourceLabel,
|
||||
getObservationSourceTag(detail),
|
||||
]);
|
||||
const rounded =
|
||||
Math.abs(observedTemp - Math.round(observedTemp)) < 0.05
|
||||
? String(Math.round(observedTemp))
|
||||
: observedTemp.toFixed(1);
|
||||
|
||||
return `${rounded}${detail.temp_symbol || "°C"}${
|
||||
sourceLabel ? ` · ${sourceLabel}` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
function getObservationUpdateProfile(detail: CityDetail, locale: Locale) {
|
||||
const { mgmNearby } = getOfficialObservationCandidates(detail);
|
||||
const mgmFirstRecord = asRecord(mgmNearby[0]);
|
||||
const currentSource = String(
|
||||
detail.current?.settlement_source ||
|
||||
detail.current?.settlement_source_label ||
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const isNmcCurrent = currentSource === "nmc" || currentSource.includes("nmc");
|
||||
const rawValue = firstNonEmptyString([
|
||||
detail.airport_primary?.obs_time,
|
||||
detail.airport_primary?.report_time,
|
||||
detail.airport_current?.obs_time,
|
||||
detail.airport_current?.report_time,
|
||||
isNmcCurrent ? "" : detail.current?.obs_time,
|
||||
isNmcCurrent ? "" : detail.current?.report_time,
|
||||
mgmFirstRecord?.obs_time,
|
||||
mgmFirstRecord?.report_time,
|
||||
mgmFirstRecord?.time,
|
||||
detail.updated_at,
|
||||
]);
|
||||
|
||||
return (
|
||||
formatObservationUpdate(rawValue, locale) ||
|
||||
(isEnglish(locale) ? "Unavailable" : "未提供")
|
||||
);
|
||||
}
|
||||
|
||||
export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN") {
|
||||
const risk = detail.risk || {};
|
||||
const current = detail.current || {};
|
||||
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
||||
const nearbySource = String(detail.nearby_source || "").trim().toLowerCase();
|
||||
const sourceCode = getObservationSourceCode(detail);
|
||||
@@ -2694,12 +3169,13 @@ export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN"
|
||||
? "Not marked"
|
||||
: "未标注",
|
||||
},
|
||||
{
|
||||
label: isEnglish(locale) ? "Observed temp" : "实测温度",
|
||||
value: getObservedTemperatureProfile(detail, locale),
|
||||
},
|
||||
{
|
||||
label: isEnglish(locale) ? "Observation update" : "观测更新",
|
||||
value:
|
||||
current.obs_time ||
|
||||
detail.updated_at ||
|
||||
(isEnglish(locale) ? "Unavailable" : "未提供"),
|
||||
value: getObservationUpdateProfile(detail, locale),
|
||||
},
|
||||
{
|
||||
label: isEnglish(locale) ? "Nearby stations" : "周边站点",
|
||||
|
||||
@@ -10,6 +10,7 @@ const config: Config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
/* Legacy shadcn/ui HSL tokens */
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: "hsl(var(--card))",
|
||||
@@ -21,9 +22,60 @@ const config: Config = {
|
||||
accent: "hsl(var(--accent))",
|
||||
"accent-foreground": "hsl(var(--accent-foreground))",
|
||||
border: "hsl(var(--border))",
|
||||
|
||||
/* PolyWeather Design Tokens — direct CSS var refs */
|
||||
"pw-bg": {
|
||||
base: "var(--color-bg-base)",
|
||||
raised: "var(--color-bg-raised)",
|
||||
overlay: "var(--color-bg-overlay)",
|
||||
card: "var(--color-bg-card)",
|
||||
input: "var(--color-bg-input)",
|
||||
},
|
||||
"pw-text": {
|
||||
primary: "var(--color-text-primary)",
|
||||
secondary: "var(--color-text-secondary)",
|
||||
muted: "var(--color-text-muted)",
|
||||
disabled: "var(--color-text-disabled)",
|
||||
},
|
||||
"pw-accent": {
|
||||
primary: "var(--color-accent-primary)",
|
||||
secondary: "var(--color-accent-secondary)",
|
||||
tertiary: "var(--color-accent-tertiary)",
|
||||
},
|
||||
"pw-signal": {
|
||||
success: "var(--color-signal-success)",
|
||||
warning: "var(--color-signal-warning)",
|
||||
danger: "var(--color-signal-danger)",
|
||||
info: "var(--color-signal-info)",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
data: ["var(--font-data)"],
|
||||
display: ["var(--font-display)"],
|
||||
mono: ["var(--font-mono)"],
|
||||
},
|
||||
spacing: {
|
||||
"pw-1": "var(--space-1)",
|
||||
"pw-2": "var(--space-2)",
|
||||
"pw-3": "var(--space-3)",
|
||||
"pw-4": "var(--space-4)",
|
||||
"pw-5": "var(--space-5)",
|
||||
"pw-6": "var(--space-6)",
|
||||
"pw-8": "var(--space-8)",
|
||||
},
|
||||
borderRadius: {
|
||||
"pw-sm": "var(--radius-sm)",
|
||||
"pw-md": "var(--radius-md)",
|
||||
"pw-lg": "var(--radius-lg)",
|
||||
"pw-xl": "var(--radius-xl)",
|
||||
},
|
||||
boxShadow: {
|
||||
glow: "0 0 0 1px rgba(34, 211, 238, 0.2), 0 8px 32px rgba(8, 47, 73, 0.5)",
|
||||
glow: "0 0 0 1px rgba(0, 229, 255, 0.15), 0 8px 32px rgba(8, 47, 73, 0.5)",
|
||||
"glow-accent": "var(--shadow-glow-accent)",
|
||||
"glow-secondary": "var(--shadow-glow-secondary)",
|
||||
"elevation-1": "var(--shadow-elevation-1)",
|
||||
"elevation-2": "var(--shadow-elevation-2)",
|
||||
"elevation-3": "var(--shadow-elevation-3)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,9 +9,11 @@ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from src.analysis.deb_algorithm import load_history # noqa: E402
|
||||
from src.analysis.settlement_rounding import apply_city_settlement # noqa: E402
|
||||
from scripts.fit_probability_calibration import _default_history_arg # noqa: E402
|
||||
from scripts.fit_probability_calibration import ( # noqa: E402
|
||||
_default_history_arg,
|
||||
_load_history_with_fallback,
|
||||
)
|
||||
|
||||
|
||||
def _sf(value):
|
||||
@@ -126,7 +128,7 @@ def main():
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
history = load_history(args.history_file)
|
||||
history = _load_history_with_fallback(args.history_file)
|
||||
overall = _blank_metrics()
|
||||
by_city = defaultdict(_blank_metrics)
|
||||
by_date = defaultdict(_blank_metrics)
|
||||
|
||||
@@ -18,9 +18,14 @@ from src.analysis.settlement_rounding import apply_city_settlement # noqa: E402
|
||||
from scripts.fit_probability_calibration import ( # noqa: E402
|
||||
_default_history_arg,
|
||||
_extract_samples,
|
||||
_load_history_with_fallback,
|
||||
_load_json_if_exists,
|
||||
_load_legacy_training_samples,
|
||||
_load_snapshot_rows,
|
||||
_load_training_feature_history,
|
||||
_load_truth_history,
|
||||
merge_samples_with_legacy_archive,
|
||||
)
|
||||
from src.analysis.deb_algorithm import load_history # noqa: E402
|
||||
|
||||
|
||||
def _mean(values):
|
||||
@@ -93,12 +98,20 @@ def main():
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
history = load_history(args.history_file)
|
||||
history = _load_history_with_fallback(args.history_file)
|
||||
training_feature_history = _load_training_feature_history()
|
||||
truth_history = _load_truth_history()
|
||||
settlement_history = _load_json_if_exists(args.settlement_history)
|
||||
snapshot_rows = _load_snapshot_rows(None)
|
||||
legacy_training_samples = _load_legacy_training_samples()
|
||||
samples, filled_actual_from_history = _extract_samples(
|
||||
history,
|
||||
training_feature_history=training_feature_history,
|
||||
truth_history=truth_history,
|
||||
settlement_history=settlement_history,
|
||||
snapshot_rows=snapshot_rows,
|
||||
)
|
||||
samples = merge_samples_with_legacy_archive(samples, legacy_training_samples)
|
||||
|
||||
legacy_crps = []
|
||||
emos_crps = []
|
||||
|
||||
@@ -42,6 +42,15 @@ def _load_json_if_exists(path):
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _legacy_training_samples_path():
|
||||
return os.path.join(
|
||||
PROJECT_ROOT,
|
||||
"artifacts",
|
||||
"probability_calibration",
|
||||
"training_samples.json",
|
||||
)
|
||||
|
||||
|
||||
def _legacy_history_path():
|
||||
return os.path.join(PROJECT_ROOT, "data", "daily_records.json")
|
||||
|
||||
@@ -63,10 +72,10 @@ def _load_history_with_fallback(path):
|
||||
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
|
||||
return DailyRecordRepository().load_all()
|
||||
return {}
|
||||
data = load_history(path)
|
||||
data = _load_json_if_exists(path)
|
||||
if data:
|
||||
return data
|
||||
return _load_json_if_exists(path)
|
||||
return load_history(path)
|
||||
|
||||
|
||||
def _load_truth_history():
|
||||
@@ -107,6 +116,14 @@ def _load_snapshot_rows(path):
|
||||
return rows
|
||||
|
||||
|
||||
def _load_legacy_training_samples(path=None):
|
||||
payload = _load_json_if_exists(path or _legacy_training_samples_path())
|
||||
rows = payload.get("samples") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
return [row for row in rows if isinstance(row, dict)]
|
||||
|
||||
|
||||
def _actual_high_for(history, truth_history, settlement_history, city, date_str):
|
||||
city_rows = (history or {}).get(city) or {}
|
||||
record = city_rows.get(date_str) or {}
|
||||
@@ -327,6 +344,40 @@ def _extract_samples(history, training_feature_history=None, truth_history=None,
|
||||
return snapshot_samples + daily_samples, snapshot_filled + daily_filled
|
||||
|
||||
|
||||
def merge_samples_with_legacy_archive(samples, legacy_samples=None):
|
||||
merged = []
|
||||
seen = set()
|
||||
for sample in samples or []:
|
||||
if not isinstance(sample, dict):
|
||||
continue
|
||||
key = (
|
||||
str(sample.get("city") or "").strip().lower(),
|
||||
str(sample.get("date") or "").strip(),
|
||||
str(sample.get("sample_source") or "").strip().lower(),
|
||||
)
|
||||
if not key[0] or not key[1]:
|
||||
continue
|
||||
if key in seen:
|
||||
continue
|
||||
merged.append(sample)
|
||||
seen.add(key)
|
||||
for sample in legacy_samples or []:
|
||||
if not isinstance(sample, dict):
|
||||
continue
|
||||
key = (
|
||||
str(sample.get("city") or "").strip().lower(),
|
||||
str(sample.get("date") or "").strip(),
|
||||
str(sample.get("sample_source") or "").strip().lower(),
|
||||
)
|
||||
if not key[0] or not key[1]:
|
||||
continue
|
||||
if key in seen:
|
||||
continue
|
||||
merged.append(sample)
|
||||
seen.add(key)
|
||||
return merged
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fit PolyWeather probability calibration parameters.")
|
||||
parser.add_argument(
|
||||
@@ -366,6 +417,7 @@ def main():
|
||||
truth_history = _load_truth_history()
|
||||
settlement_history = _load_json_if_exists(args.settlement_history)
|
||||
snapshot_rows = _load_snapshot_rows(args.snapshot_file)
|
||||
legacy_training_samples = _load_legacy_training_samples()
|
||||
samples, filled_actual_from_history = _extract_samples(
|
||||
history,
|
||||
training_feature_history=training_feature_history,
|
||||
@@ -373,6 +425,7 @@ def main():
|
||||
settlement_history=settlement_history,
|
||||
snapshot_rows=snapshot_rows,
|
||||
)
|
||||
samples = merge_samples_with_legacy_archive(samples, legacy_training_samples)
|
||||
calibration = fit_calibration(samples, version=args.version)
|
||||
if not samples:
|
||||
calibration = default_calibration_payload(
|
||||
@@ -382,6 +435,7 @@ def main():
|
||||
calibration.setdefault("metrics", {})
|
||||
calibration["metrics"]["filled_actual_from_history"] = filled_actual_from_history
|
||||
calibration["metrics"]["settlement_history_city_count"] = len(settlement_history)
|
||||
calibration["metrics"]["legacy_archive_samples"] = len(legacy_training_samples)
|
||||
try:
|
||||
calibration["source"] = os.path.relpath(args.output, PROJECT_ROOT)
|
||||
except ValueError:
|
||||
|
||||
@@ -7,7 +7,6 @@ from src.analysis.settlement_rounding import apply_city_settlement
|
||||
from loguru import logger
|
||||
from src.database.runtime_state import (
|
||||
DailyRecordRepository,
|
||||
STATE_STORAGE_DUAL,
|
||||
STATE_STORAGE_SQLITE,
|
||||
TrainingFeatureRecordRepository,
|
||||
TruthRecordRepository,
|
||||
@@ -79,7 +78,7 @@ def load_history(filepath):
|
||||
logger.error(f"Error loading daily records from sqlite, fallback to file: {e}")
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
if mode == STATE_STORAGE_DUAL:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
try:
|
||||
data = _daily_record_repo.load_all()
|
||||
_history_cache = data
|
||||
@@ -113,13 +112,12 @@ def save_history(filepath, data):
|
||||
_history_cache = data
|
||||
mode = get_state_storage_mode()
|
||||
|
||||
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
try:
|
||||
_daily_record_repo.replace_all(data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving daily records to sqlite: {e}")
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
return
|
||||
return
|
||||
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
return
|
||||
@@ -884,22 +882,21 @@ def update_daily_record(
|
||||
except Exception as e:
|
||||
logger.error(f"Error persisting training feature record city={city_name} date={date_str}: {e}")
|
||||
|
||||
# 自动清理:只保留最近 14 天的记录(DEB 只用 7 天,14 天留足余量)
|
||||
cutoff = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d")
|
||||
# 自动清理:训练特征需要更长窗口,保留最近 180 天的记录
|
||||
cutoff = (datetime.now() - timedelta(days=180)).strftime("%Y-%m-%d")
|
||||
for city in list(data.keys()):
|
||||
old_dates = [d for d in data[city] if d < cutoff]
|
||||
for d in old_dates:
|
||||
del data[city][d]
|
||||
|
||||
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
try:
|
||||
_daily_record_repo.upsert_record(city_name, date_str, existing)
|
||||
cutoff = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d")
|
||||
cutoff = (datetime.now() - timedelta(days=180)).strftime("%Y-%m-%d")
|
||||
_daily_record_repo.delete_older_than(cutoff)
|
||||
except Exception as e:
|
||||
logger.error(f"Error upserting daily record to sqlite city={city_name} date={date_str}: {e}")
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
raise
|
||||
raise
|
||||
|
||||
if mode != STATE_STORAGE_SQLITE:
|
||||
save_history(history_file, data)
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.database.runtime_state import (
|
||||
ProbabilitySnapshotRepository,
|
||||
STATE_STORAGE_DUAL,
|
||||
STATE_STORAGE_SQLITE,
|
||||
TrainingFeatureRecordRepository,
|
||||
get_state_storage_mode,
|
||||
@@ -105,7 +104,7 @@ def load_snapshot_rows_for_day(
|
||||
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
path = archive_path or os.path.join(root_dir, "data", "probability_training_snapshots.jsonl")
|
||||
if not os.path.exists(path):
|
||||
if mode == STATE_STORAGE_DUAL:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
return _snapshot_repo.load_rows_by_city_date(city_key, date_key)
|
||||
return []
|
||||
|
||||
@@ -260,7 +259,7 @@ def append_probability_snapshot(
|
||||
return
|
||||
|
||||
mode = get_state_storage_mode()
|
||||
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
_snapshot_repo.append_snapshot(payload)
|
||||
_training_feature_repo.upsert_record(
|
||||
city_key,
|
||||
|
||||
@@ -233,6 +233,69 @@ class SupabaseEntitlementService:
|
||||
logger.warning(f"supabase subscription query error user_id={user_id}: {exc}")
|
||||
return None
|
||||
|
||||
def _query_active_subscription_rows(
|
||||
self,
|
||||
user_id: str,
|
||||
) -> List[Dict[str, object]]:
|
||||
if not user_id:
|
||||
return []
|
||||
if not self.service_role_key:
|
||||
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
|
||||
return []
|
||||
|
||||
now_ts = time.time()
|
||||
with self._sub_cache_lock:
|
||||
cached = self._sub_cache.get(user_id)
|
||||
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
|
||||
rows = cached.get("rows")
|
||||
if isinstance(rows, list):
|
||||
return [row for row in rows if isinstance(row, dict)]
|
||||
row = cached.get("row")
|
||||
if isinstance(row, dict):
|
||||
return [row]
|
||||
return []
|
||||
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
now_iso = now.isoformat()
|
||||
params = {
|
||||
"select": "id,user_id,status,plan_code,starts_at,expires_at,source,created_at,updated_at",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "eq.active",
|
||||
"expires_at": f"gt.{now_iso}",
|
||||
"order": "expires_at.desc",
|
||||
"limit": "100",
|
||||
}
|
||||
response = requests.get(
|
||||
self._subscription_endpoint(),
|
||||
headers=self._request_headers_for_service_role(),
|
||||
params=params,
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"supabase active subscription rows query failed user_id={} status={}",
|
||||
user_id,
|
||||
response.status_code,
|
||||
)
|
||||
rows: List[Dict[str, object]] = []
|
||||
else:
|
||||
data = response.json() if response.content else []
|
||||
rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else []
|
||||
|
||||
current_row = self._pick_latest_current_subscription(rows, now=now)
|
||||
with self._sub_cache_lock:
|
||||
self._sub_cache[user_id] = {
|
||||
"active": bool(current_row),
|
||||
"row": current_row,
|
||||
"rows": rows,
|
||||
"ts": now_ts,
|
||||
}
|
||||
return rows
|
||||
except Exception as exc:
|
||||
logger.warning(f"supabase active subscription rows query error user_id={user_id}: {exc}")
|
||||
return []
|
||||
|
||||
def _query_latest_subscription_any_status(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -453,6 +516,51 @@ class SupabaseEntitlementService:
|
||||
) -> Optional[Dict[str, object]]:
|
||||
return self._query_latest_subscription_any_status(user_id)
|
||||
|
||||
def get_subscription_window(
|
||||
self,
|
||||
user_id: str,
|
||||
respect_requirement: bool = True,
|
||||
) -> Dict[str, object]:
|
||||
if respect_requirement and not self.require_subscription:
|
||||
return {}
|
||||
rows = self._query_active_subscription_rows(user_id)
|
||||
if not rows:
|
||||
return {}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
current = self._pick_latest_current_subscription(rows, now=now)
|
||||
total_expiry: Optional[datetime] = None
|
||||
current_expiry: Optional[datetime] = None
|
||||
if isinstance(current, dict):
|
||||
current_expiry = self._parse_iso_datetime(str(current.get("expires_at") or ""))
|
||||
|
||||
queued_count = 0
|
||||
for row in rows:
|
||||
exp = self._parse_iso_datetime(str(row.get("expires_at") or ""))
|
||||
if exp is not None and (total_expiry is None or exp > total_expiry):
|
||||
total_expiry = exp
|
||||
if current_expiry is not None:
|
||||
starts = self._parse_iso_datetime(str(row.get("starts_at") or ""))
|
||||
if starts is not None and starts >= current_expiry and row is not current:
|
||||
queued_count += 1
|
||||
|
||||
queued_days = 0
|
||||
if total_expiry is not None and current_expiry is not None and total_expiry > current_expiry:
|
||||
queued_days = max(
|
||||
0,
|
||||
int(round((total_expiry - current_expiry).total_seconds() / 86_400)),
|
||||
)
|
||||
|
||||
return {
|
||||
"current": current,
|
||||
"current_expires_at": current.get("expires_at") if isinstance(current, dict) else None,
|
||||
"current_starts_at": current.get("starts_at") if isinstance(current, dict) else None,
|
||||
"total_expires_at": total_expiry.isoformat() if total_expiry else None,
|
||||
"queued_days": queued_days,
|
||||
"queued_count": queued_count,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
def has_active_subscription(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@@ -149,21 +149,20 @@ CITY_REGISTRY = {
|
||||
},
|
||||
"taipei": {
|
||||
"name": "Taipei",
|
||||
"lat": 25.0670,
|
||||
"lon": 121.5525,
|
||||
"lat": 25.0377,
|
||||
"lon": 121.5149,
|
||||
"icao": "RCSS",
|
||||
"settlement_source": "wunderground",
|
||||
"settlement_station_code": "RCSS",
|
||||
"settlement_station_label": "Taipei Songshan Airport Station",
|
||||
"settlement_url": "https://www.wunderground.com/history/daily/tw/taipei/RCSS",
|
||||
"settlement_source": "cwa",
|
||||
"settlement_station_code": "466920",
|
||||
"settlement_station_label": "中央气象署台北站",
|
||||
"tz_offset": 28800,
|
||||
"use_fahrenheit": False,
|
||||
"is_major": True,
|
||||
"risk_level": "low",
|
||||
"risk_emoji": "🟢",
|
||||
"airport_name": "台北松山机场",
|
||||
"distance_km": 4.1,
|
||||
"warning": "市场现按 Wunderground 台北松山机场站整度°C口径结算;以历史页当日最终完成后的最高整度摄氏值为准。",
|
||||
"airport_name": "中央气象署台北站",
|
||||
"distance_km": 0.0,
|
||||
"warning": "结算按交通部中央气象署台北站口径,不应混用松山机场 METAR 作为结算主源。",
|
||||
},
|
||||
"shanghai": {
|
||||
"name": "Shanghai",
|
||||
@@ -229,6 +228,60 @@ CITY_REGISTRY = {
|
||||
"distance_km": 12.0,
|
||||
"warning": "市场现按 Wunderground Halim 机场站整度°C口径结算;热带午后阵雨常在峰值前后快速压制气温。",
|
||||
},
|
||||
"manila": {
|
||||
"name": "Manila",
|
||||
"lat": 14.5086,
|
||||
"lon": 121.0198,
|
||||
"icao": "RPLL",
|
||||
"settlement_source": "wunderground",
|
||||
"settlement_station_code": "RPLL",
|
||||
"settlement_station_label": "Manila/Ninoy Aquino International Airport Station",
|
||||
"settlement_url": "https://www.wunderground.com/history/daily/ph/manila/RPLL",
|
||||
"tz_offset": 28800,
|
||||
"use_fahrenheit": False,
|
||||
"is_major": True,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟡",
|
||||
"airport_name": "尼诺伊·阿基诺国际机场",
|
||||
"distance_km": 10.0,
|
||||
"warning": "市场现按 Wunderground 尼诺伊·阿基诺国际机场站整度°C口径结算;海风、湿热与午后对流会压缩日最高温窗口。",
|
||||
},
|
||||
"karachi": {
|
||||
"name": "Karachi",
|
||||
"lat": 24.9065,
|
||||
"lon": 67.1608,
|
||||
"icao": "OPKC",
|
||||
"settlement_source": "wunderground",
|
||||
"settlement_station_code": "OPKC",
|
||||
"settlement_station_label": "Karachi/Jinnah International Airport Station",
|
||||
"settlement_url": "https://www.wunderground.com/history/daily/pk/karachi/OPKC",
|
||||
"tz_offset": 18000,
|
||||
"use_fahrenheit": False,
|
||||
"is_major": True,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟡",
|
||||
"airport_name": "真纳国际机场",
|
||||
"distance_km": 15.0,
|
||||
"min_plausible_metar_temp_c": 5.0,
|
||||
"warning": "市场现按 Wunderground 真纳国际机场站整度°C口径结算;海风锋、干热输送与尘霾会影响峰值兑现时间。",
|
||||
},
|
||||
"masroor air base": {
|
||||
"name": "Masroor Air Base",
|
||||
"lat": 24.8936,
|
||||
"lon": 66.9388,
|
||||
"icao": "OPMR",
|
||||
"settlement_station_code": "OPMR",
|
||||
"settlement_station_label": "Masroor Air Base METAR",
|
||||
"tz_offset": 18000,
|
||||
"use_fahrenheit": False,
|
||||
"is_major": False,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟡",
|
||||
"airport_name": "Masroor Air Base",
|
||||
"distance_km": 0.0,
|
||||
"min_plausible_metar_temp_c": 5.0,
|
||||
"warning": "军事机场 METAR 锚点;公开报文可能不连续,若 OPMR 缺报则不要自动替换为 OPKC 结算口径。",
|
||||
},
|
||||
"tokyo": {
|
||||
"name": "Tokyo",
|
||||
"lat": 35.5523,
|
||||
@@ -638,6 +691,24 @@ CITY_REGISTRY = {
|
||||
"distance_km": 32.0,
|
||||
"warning": "市场现按 Wunderground 深圳宝安国际机场站整度°C口径结算;以历史页当日最终完成后的最高整度摄氏值为准。",
|
||||
},
|
||||
"guangzhou": {
|
||||
"name": "Guangzhou",
|
||||
"lat": 23.3924,
|
||||
"lon": 113.2988,
|
||||
"icao": "ZGGG",
|
||||
"settlement_source": "wunderground",
|
||||
"settlement_station_code": "ZGGG",
|
||||
"settlement_station_label": "Guangzhou Baiyun International Airport Station",
|
||||
"settlement_url": "https://www.wunderground.com/history/daily/cn/guangzhou/ZGGG",
|
||||
"tz_offset": 28800,
|
||||
"use_fahrenheit": False,
|
||||
"is_major": True,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟡",
|
||||
"airport_name": "广州白云国际机场",
|
||||
"distance_km": 28.0,
|
||||
"warning": "市场现按 Wunderground 广州白云国际机场站整度°C口径结算;珠三角海陆风与午后对流会让峰值兑现时间出现摇摆。",
|
||||
},
|
||||
"beijing": {
|
||||
"name": "Beijing",
|
||||
"lat": 40.0801,
|
||||
@@ -684,6 +755,60 @@ CITY_REGISTRY = {
|
||||
"distance_km": 6.0,
|
||||
"warning": "市场现按 Wunderground Marcos A. Gelabert 机场站整度°C口径结算;热带对流常让峰值在午后被快速截断。",
|
||||
},
|
||||
"lagos": {
|
||||
"name": "Lagos",
|
||||
"lat": 6.5774,
|
||||
"lon": 3.3212,
|
||||
"icao": "DNMM",
|
||||
"settlement_source": "wunderground",
|
||||
"settlement_station_code": "DNMM",
|
||||
"settlement_station_label": "Lagos/Murtala Muhammed International Airport Station",
|
||||
"settlement_url": "https://www.wunderground.com/history/daily/ng/lagos/DNMM",
|
||||
"tz_offset": 3600,
|
||||
"use_fahrenheit": False,
|
||||
"is_major": True,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟡",
|
||||
"airport_name": "穆尔塔拉·穆罕默德国际机场",
|
||||
"distance_km": 12.0,
|
||||
"warning": "市场现按 Wunderground 拉各斯穆尔塔拉·穆罕默德国际机场站整度°C口径结算;海陆风与热带对流会让午后峰值兑现存在波动。",
|
||||
},
|
||||
"cape town": {
|
||||
"name": "Cape Town",
|
||||
"lat": -33.9696,
|
||||
"lon": 18.5972,
|
||||
"icao": "FACT",
|
||||
"settlement_source": "wunderground",
|
||||
"settlement_station_code": "FACT",
|
||||
"settlement_station_label": "Cape Town International Airport Station",
|
||||
"settlement_url": "https://www.wunderground.com/history/daily/za/matroosfontein/FACT",
|
||||
"tz_offset": 7200,
|
||||
"use_fahrenheit": False,
|
||||
"is_major": True,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟡",
|
||||
"airport_name": "开普敦国际机场",
|
||||
"distance_km": 20.0,
|
||||
"warning": "市场现按 Wunderground 开普敦国际机场站整度°C口径结算;海风和平流云会显著影响午后升温节奏。",
|
||||
},
|
||||
"jeddah": {
|
||||
"name": "Jeddah",
|
||||
"lat": 21.6702,
|
||||
"lon": 39.1525,
|
||||
"icao": "OEJN",
|
||||
"settlement_source": "wunderground",
|
||||
"settlement_station_code": "OEJN",
|
||||
"settlement_station_label": "Jeddah/King Abdulaziz International Airport Station",
|
||||
"settlement_url": "https://www.wunderground.com/history/daily/sa/jeddah/OEJN",
|
||||
"tz_offset": 10800,
|
||||
"use_fahrenheit": False,
|
||||
"is_major": True,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟡",
|
||||
"airport_name": "阿卜杜勒阿齐兹国王国际机场",
|
||||
"distance_km": 18.0,
|
||||
"warning": "市场现按 Wunderground 吉达阿卜杜勒阿齐兹国王国际机场站整度°C口径结算;干热背景下海风与沙尘输送会改变峰值兑现时点。",
|
||||
},
|
||||
}
|
||||
|
||||
ALIASES = {
|
||||
@@ -706,9 +831,15 @@ ALIASES = {
|
||||
"sg": "singapore", "tok": "tokyo", "tyo": "tokyo",
|
||||
"kul": "kuala lumpur", "wmkk": "kuala lumpur", "sepang": "kuala lumpur",
|
||||
"jkt": "jakarta", "wihh": "jakarta", "halim": "jakarta",
|
||||
"mnl": "manila", "manila": "manila", "rpll": "manila",
|
||||
"khi": "karachi", "karachi": "karachi", "opkc": "karachi", "jinnah": "karachi",
|
||||
"opmr": "masroor air base", "masroor": "masroor air base", "masroor airbase": "masroor air base",
|
||||
"hel": "helsinki", "efhk": "helsinki", "vantaa": "helsinki",
|
||||
"ams": "amsterdam", "eham": "amsterdam", "schiphol": "amsterdam",
|
||||
"pty": "panama city", "mpmg": "panama city", "panamacity": "panama city",
|
||||
"los": "lagos", "dnmm": "lagos",
|
||||
"cpt": "cape town", "fact": "cape town", "capetown": "cape town", "matroosfontein": "cape town",
|
||||
"jed": "jeddah", "oejn": "jeddah",
|
||||
"tlv": "tel aviv", "telaviv": "tel aviv",
|
||||
"ba": "buenos aires", "wel": "wellington",
|
||||
"luc": "lucknow", "sp": "sao paulo", "mun": "munich",
|
||||
@@ -746,6 +877,13 @@ ALIASES = {
|
||||
"吉隆坡": "kuala lumpur",
|
||||
"雅加达": "jakarta",
|
||||
"雅加達": "jakarta",
|
||||
"马尼拉": "manila",
|
||||
"馬尼拉": "manila",
|
||||
"卡拉奇": "karachi",
|
||||
"马斯鲁尔": "masroor air base",
|
||||
"馬斯魯爾": "masroor air base",
|
||||
"马斯鲁尔空军基地": "masroor air base",
|
||||
"馬斯魯爾空軍基地": "masroor air base",
|
||||
"东京": "tokyo",
|
||||
"東京": "tokyo",
|
||||
"特拉维夫": "tel aviv",
|
||||
@@ -762,16 +900,23 @@ ALIASES = {
|
||||
"赫爾辛基": "helsinki",
|
||||
"阿姆斯特丹": "amsterdam",
|
||||
"巴拿马城": "panama city",
|
||||
"拉各斯": "lagos",
|
||||
"开普敦": "cape town",
|
||||
"開普敦": "cape town",
|
||||
"吉达": "jeddah",
|
||||
"吉達": "jeddah",
|
||||
"马德里": "madrid",
|
||||
"馬德里": "madrid",
|
||||
"ctu": "chengdu", "cd": "chengdu",
|
||||
"ckg": "chongqing", "cq": "chongqing",
|
||||
"szx": "shenzhen", "sz": "shenzhen",
|
||||
"can": "guangzhou", "gz": "guangzhou", "zggg": "guangzhou",
|
||||
"pek": "beijing", "bjs": "beijing", "bj": "beijing",
|
||||
"wuh": "wuhan", "wh": "wuhan",
|
||||
"成都": "chengdu",
|
||||
"重庆": "chongqing",
|
||||
"深圳": "shenzhen",
|
||||
"广州": "guangzhou",
|
||||
"北京": "beijing",
|
||||
"武汉": "wuhan",
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ CHINA_CMA_CITIES = {
|
||||
"beijing",
|
||||
"chengdu",
|
||||
"chongqing",
|
||||
"guangzhou",
|
||||
"shanghai",
|
||||
"shenzhen",
|
||||
"wuhan",
|
||||
@@ -41,6 +42,8 @@ def _provider_code_for_city(city: str) -> str:
|
||||
return "turkey_mgm"
|
||||
if normalized in {"busan", "seoul"}:
|
||||
return "korea_kma"
|
||||
if normalized == "moscow":
|
||||
return "russia_station_web"
|
||||
if settlement_source == "hko":
|
||||
return "hongkong_hko"
|
||||
if settlement_source == "cwa":
|
||||
@@ -235,6 +238,34 @@ def _kma_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _ru_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]:
|
||||
rows = raw.get("ru_official_nearby") or []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
out.append(
|
||||
_normalize_station_row(
|
||||
station_code=row.get("station_code") or row.get("icao") or row.get("istNo"),
|
||||
station_label=row.get("station_label") or row.get("name"),
|
||||
temp=row.get("temp"),
|
||||
lat=row.get("lat"),
|
||||
lon=row.get("lon"),
|
||||
obs_time=row.get("obs_time"),
|
||||
source_code="ru_station_web",
|
||||
source_label="Russia station web",
|
||||
is_official=True,
|
||||
is_airport_station=_bool(row.get("is_airport_station")),
|
||||
is_settlement_anchor=False,
|
||||
extra={
|
||||
"distance_km": _safe_float(row.get("distance_km")),
|
||||
"page_url": row.get("page_url"),
|
||||
},
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _mgm_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]:
|
||||
meta = _city_meta(city)
|
||||
rows = raw.get("mgm_nearby") or []
|
||||
@@ -497,6 +528,28 @@ class KoreaKmaNetworkProvider(CountryNetworkProvider):
|
||||
}
|
||||
|
||||
|
||||
class RussiaStationWebNetworkProvider(CountryNetworkProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("russia_station_web", "Russia station web")
|
||||
|
||||
def official_nearby_current(self, city: str, raw: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
rows = _ru_rows(raw, city)
|
||||
if rows:
|
||||
return rows
|
||||
return _metar_cluster_rows(raw)
|
||||
|
||||
def official_network_status(self, city: str, raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rows = self.official_nearby_current(city, raw)
|
||||
has_ru = bool(_ru_rows(raw, city))
|
||||
return {
|
||||
"provider_code": self.provider_code,
|
||||
"provider_label": self.provider_label,
|
||||
"available": has_ru,
|
||||
"mode": "official_web_crawl" if has_ru else ("fallback_metar_cluster" if rows else "reference_only"),
|
||||
"row_count": len(rows),
|
||||
}
|
||||
|
||||
|
||||
class HongKongHkoNetworkProvider(CountryNetworkProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("hongkong_hko", "HKO")
|
||||
@@ -521,6 +574,8 @@ def get_country_network_provider(city: str) -> CountryNetworkProvider:
|
||||
return TurkeyMgmNetworkProvider()
|
||||
if provider_code == "korea_kma":
|
||||
return KoreaKmaNetworkProvider()
|
||||
if provider_code == "russia_station_web":
|
||||
return RussiaStationWebNetworkProvider()
|
||||
if provider_code == "japan_jma":
|
||||
return JapanJmaNetworkProvider()
|
||||
if provider_code == "china_cma":
|
||||
|
||||
@@ -12,6 +12,45 @@ from src.utils.metrics import record_source_call
|
||||
|
||||
|
||||
class MetarSourceMixin:
|
||||
def _get_min_plausible_metar_temp_c(
|
||||
self, city: str, icao: Optional[str] = None
|
||||
) -> Optional[float]:
|
||||
normalized = str(city or "").strip().lower()
|
||||
city_meta = (getattr(self, "CITY_REGISTRY", {}) or {}).get(normalized) or {}
|
||||
if not city_meta and icao:
|
||||
icao_upper = str(icao or "").strip().upper()
|
||||
for candidate in (getattr(self, "CITY_REGISTRY", {}) or {}).values():
|
||||
if str(candidate.get("icao") or "").strip().upper() == icao_upper:
|
||||
city_meta = candidate
|
||||
break
|
||||
raw = city_meta.get("min_plausible_metar_temp_c")
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if value > -80 else None
|
||||
|
||||
def _is_plausible_metar_temp_c(
|
||||
self, temp_c: object, city: str, icao: Optional[str] = None
|
||||
) -> bool:
|
||||
try:
|
||||
value = float(temp_c)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
minimum = self._get_min_plausible_metar_temp_c(city, icao)
|
||||
if minimum is None:
|
||||
return True
|
||||
if value < minimum:
|
||||
logger.warning(
|
||||
"Discarding implausible METAR temperature city={} icao={} temp_c={} min_c={}",
|
||||
city,
|
||||
icao,
|
||||
value,
|
||||
minimum,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_icao_code(self, city: str) -> Optional[str]:
|
||||
"""根据城市名获取对应的 ICAO 机场代码"""
|
||||
normalized = city.lower().strip()
|
||||
@@ -44,26 +83,52 @@ class MetarSourceMixin:
|
||||
|
||||
try:
|
||||
url = "https://aviationweather.gov/api/data/metar"
|
||||
params = {
|
||||
"ids": icao,
|
||||
"format": "json",
|
||||
"hours": 24,
|
||||
"_t": int(time.time()),
|
||||
}
|
||||
response = self.session.get(url, params=params, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
history_hours = 24
|
||||
|
||||
def _request_metar_records(hours: int, timeout: float) -> list:
|
||||
response = self._http_get(
|
||||
url,
|
||||
params={
|
||||
"ids": icao,
|
||||
"format": "json",
|
||||
"hours": hours,
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return payload if isinstance(payload, list) else []
|
||||
|
||||
try:
|
||||
data = _request_metar_records(
|
||||
history_hours,
|
||||
getattr(self, "metar_timeout_sec", self.timeout),
|
||||
)
|
||||
except httpx.HTTPError as primary_exc:
|
||||
history_hours = 2
|
||||
logger.warning(
|
||||
f"METAR {icao} 24h 请求失败,尝试 latest fallback: {primary_exc}"
|
||||
)
|
||||
try:
|
||||
data = _request_metar_records(
|
||||
history_hours,
|
||||
getattr(self, "metar_latest_timeout_sec", 2.5),
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
raise primary_exc
|
||||
|
||||
data = response.json()
|
||||
if not data:
|
||||
return None
|
||||
|
||||
latest = data[0]
|
||||
temp_c = latest.get("temp")
|
||||
if temp_c is not None and not self._is_plausible_metar_temp_c(temp_c, city, icao):
|
||||
temp_c = None
|
||||
dewp_c = latest.get("dewp")
|
||||
|
||||
def _parse_rawob_time(obs):
|
||||
raw = obs.get("rawOb", "")
|
||||
match = re.search(r"(\d{2})(\d{2})(\d{2})Z", raw)
|
||||
match = re.search(r"\b(\d{2})(\d{2})(\d{2})Z\b", raw)
|
||||
if match:
|
||||
_day, hour, minute = (
|
||||
int(match.group(1)),
|
||||
@@ -112,7 +177,11 @@ class MetarSourceMixin:
|
||||
try:
|
||||
if obs_dt_iter >= utc_midnight:
|
||||
temp_value = obs.get("temp")
|
||||
if temp_value is not None and temp_value > max_so_far_c:
|
||||
if (
|
||||
temp_value is not None
|
||||
and self._is_plausible_metar_temp_c(temp_value, city, icao)
|
||||
and temp_value > max_so_far_c
|
||||
):
|
||||
max_so_far_c = temp_value
|
||||
local_report = obs_dt_iter + timedelta(seconds=utc_offset)
|
||||
max_temp_time = local_report.strftime("%H:%M")
|
||||
@@ -133,7 +202,11 @@ class MetarSourceMixin:
|
||||
for index, obs in enumerate(data):
|
||||
obs_temp = obs.get("temp")
|
||||
obs_dt_iter = _parse_rawob_time(obs)
|
||||
if obs_temp is not None and obs_dt_iter:
|
||||
if (
|
||||
obs_temp is not None
|
||||
and obs_dt_iter
|
||||
and self._is_plausible_metar_temp_c(obs_temp, city, icao)
|
||||
):
|
||||
local_rt = obs_dt_iter + timedelta(seconds=utc_offset)
|
||||
time_str = local_rt.strftime("%H:%M")
|
||||
if obs_dt_iter >= utc_midnight:
|
||||
@@ -199,11 +272,15 @@ class MetarSourceMixin:
|
||||
"today_obs": today_obs,
|
||||
"recent_obs": recent_obs_raw,
|
||||
"unit": unit,
|
||||
"history_hours": history_hours,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} (obs: {obs_time})"
|
||||
)
|
||||
if temp is not None:
|
||||
logger.info(
|
||||
f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} (obs: {obs_time})"
|
||||
)
|
||||
else:
|
||||
logger.info(f"✈️ METAR {icao}: temperature unavailable (obs: {obs_time})")
|
||||
with self._metar_cache_lock:
|
||||
self._metar_cache[cache_key] = {"d": result, "t": now_ts}
|
||||
record_source_call("metar", "current", "success", (time.perf_counter() - started) * 1000.0)
|
||||
@@ -246,9 +323,12 @@ class MetarSourceMixin:
|
||||
"ids": icao,
|
||||
"format": "json",
|
||||
"hours": 24,
|
||||
"_t": int(time.time()),
|
||||
}
|
||||
response = self.session.get(url, params=params, timeout=self.timeout)
|
||||
response = self._http_get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=getattr(self, "metar_timeout_sec", self.timeout),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not data:
|
||||
@@ -295,7 +375,11 @@ class MetarSourceMixin:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
}
|
||||
resp = self.session.get(url, headers=headers, timeout=self.timeout)
|
||||
resp = self.session.get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=getattr(self, "metar_cluster_timeout_sec", self.timeout),
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"METAR cluster fetch HTTP {resp.status_code} for {icaos}")
|
||||
return []
|
||||
|
||||
@@ -208,7 +208,7 @@ class NwsOpenMeteoSourceMixin:
|
||||
response = self._http_get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=self.timeout,
|
||||
timeout=getattr(self, "open_meteo_timeout_sec", self.timeout),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
@@ -369,7 +369,7 @@ class NwsOpenMeteoSourceMixin:
|
||||
response = self._http_get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=self.timeout,
|
||||
timeout=getattr(self, "open_meteo_timeout_sec", self.timeout),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
@@ -526,7 +526,7 @@ class NwsOpenMeteoSourceMixin:
|
||||
response = self._http_get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=self.timeout,
|
||||
timeout=getattr(self, "open_meteo_timeout_sec", self.timeout),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
@@ -7,7 +7,6 @@ import time
|
||||
from loguru import logger
|
||||
from src.database.runtime_state import (
|
||||
OpenMeteoCacheRepository,
|
||||
STATE_STORAGE_DUAL,
|
||||
STATE_STORAGE_SQLITE,
|
||||
get_state_storage_mode,
|
||||
)
|
||||
@@ -77,7 +76,7 @@ class OpenMeteoCacheMixin:
|
||||
self._disk_cache_last_mtime = current_mtime
|
||||
if loaded:
|
||||
logger.info(f"✅ 从磁盘加载 Open-Meteo 缓存 {loaded} 条 ({self._disk_cache_path})")
|
||||
if mode == STATE_STORAGE_DUAL:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
try:
|
||||
_open_meteo_cache_repo.replace_payload(saved, self._disk_cache_max_age_sec)
|
||||
except Exception as exc:
|
||||
@@ -124,7 +123,7 @@ class OpenMeteoCacheMixin:
|
||||
"multi_model": multi_model_snapshot,
|
||||
"saved_at": time.time(),
|
||||
}
|
||||
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
_open_meteo_cache_repo.replace_payload(payload, self._disk_cache_max_age_sec)
|
||||
self._disk_cache_last_mtime = _open_meteo_cache_repo.latest_updated_at()
|
||||
if mode != STATE_STORAGE_SQLITE:
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from src.utils.metrics import record_source_call
|
||||
|
||||
|
||||
RUSSIA_MOSCOW_STATIONS: Dict[str, Dict[str, Any]] = {
|
||||
"27524": {
|
||||
"station_code": "27524",
|
||||
"station_label": "Vnukovo",
|
||||
"lat": 55.5870,
|
||||
"lon": 37.2500,
|
||||
},
|
||||
"27518": {
|
||||
"station_code": "27518",
|
||||
"station_label": "Podmoskovnaya",
|
||||
"lat": 55.7084,
|
||||
"lon": 37.1823,
|
||||
},
|
||||
"27515": {
|
||||
"station_code": "27515",
|
||||
"station_label": "Nemchinovka",
|
||||
"lat": 55.7065,
|
||||
"lon": 37.3719,
|
||||
},
|
||||
"27504": {
|
||||
"station_code": "27504",
|
||||
"station_label": "Moscow (Butovo)",
|
||||
"lat": 55.5780,
|
||||
"lon": 37.5541,
|
||||
},
|
||||
"27416": {
|
||||
"station_code": "27416",
|
||||
"station_label": "Moscow (Strogino)",
|
||||
"lat": 55.7976,
|
||||
"lon": 37.3982,
|
||||
},
|
||||
"27605": {
|
||||
"station_code": "27605",
|
||||
"station_label": "Moscow (Balchug)",
|
||||
"lat": 55.7455,
|
||||
"lon": 37.6300,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class RussiaStationSourceMixin:
|
||||
def _ru_http_get_text(self, url: str) -> str:
|
||||
getter = getattr(self, "_http_get", None)
|
||||
if callable(getter):
|
||||
response = getter(url)
|
||||
else:
|
||||
response = self.session.get(url, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
@staticmethod
|
||||
def _ru_safe_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
if value in (None, "", "-", "—"):
|
||||
return None
|
||||
text = str(value).strip().replace(",", ".")
|
||||
return float(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _ru_distance_km(
|
||||
lat1: Optional[float],
|
||||
lon1: Optional[float],
|
||||
lat2: Optional[float],
|
||||
lon2: Optional[float],
|
||||
) -> Optional[float]:
|
||||
if None in (lat1, lon1, lat2, lon2):
|
||||
return None
|
||||
try:
|
||||
r = 6371.0
|
||||
d_lat = math.radians(float(lat2) - float(lat1))
|
||||
d_lon = math.radians(float(lon2) - float(lon1))
|
||||
a = (
|
||||
math.sin(d_lat / 2) ** 2
|
||||
+ math.cos(math.radians(float(lat1)))
|
||||
* math.cos(math.radians(float(lat2)))
|
||||
* math.sin(d_lon / 2) ** 2
|
||||
)
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
return round(r * c, 2)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _ru_clean_cell(cell_html: str) -> str:
|
||||
text = re.sub(r"<[^>]+>", " ", str(cell_html or ""))
|
||||
text = html.unescape(text)
|
||||
text = text.replace("\xa0", " ")
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text.strip()
|
||||
|
||||
@classmethod
|
||||
def _ru_parse_table_rows(cls, table_html: str) -> List[List[str]]:
|
||||
rows: List[List[str]] = []
|
||||
for row_html in re.findall(r"<tr[^>]*>(.*?)</tr>", table_html, flags=re.S | re.I):
|
||||
cells = re.findall(r"<t[dh][^>]*>(.*?)</t[dh]>", row_html, flags=re.S | re.I)
|
||||
cleaned = [cls._ru_clean_cell(cell) for cell in cells]
|
||||
if cleaned:
|
||||
rows.append(cleaned)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def _ru_build_obs_time(hour_text: str, day_month_text: str) -> Optional[str]:
|
||||
hour_match = re.search(r"(\d{1,2})", str(hour_text or ""))
|
||||
day_match = re.search(r"(\d{1,2})\.(\d{1,2})", str(day_month_text or ""))
|
||||
if not hour_match or not day_match:
|
||||
return None
|
||||
hour = int(hour_match.group(1))
|
||||
day = int(day_match.group(1))
|
||||
month = int(day_match.group(2))
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
year = now_utc.year
|
||||
try:
|
||||
candidate = datetime(year, month, day, hour, 0, tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
if candidate > now_utc and (candidate - now_utc).days > 40:
|
||||
candidate = datetime(year - 1, month, day, hour, 0, tzinfo=timezone.utc)
|
||||
return candidate.isoformat()
|
||||
|
||||
def _ru_parse_station_current_from_weather_html(self, html_text: str) -> Optional[Dict[str, Any]]:
|
||||
tables = re.findall(r"<table[^>]*>(.*?)</table>", html_text, flags=re.S | re.I)
|
||||
if len(tables) < 2:
|
||||
return None
|
||||
time_rows = self._ru_parse_table_rows(tables[0])
|
||||
data_rows = self._ru_parse_table_rows(tables[1])
|
||||
if len(time_rows) < 2 or len(data_rows) < 2:
|
||||
return None
|
||||
|
||||
pair_count = min(len(time_rows), len(data_rows)) - 1
|
||||
for idx in range(pair_count):
|
||||
time_row = time_rows[idx + 1]
|
||||
data_row = data_rows[idx + 1]
|
||||
if len(time_row) < 2 or len(data_row) < 6:
|
||||
continue
|
||||
temp_c = self._ru_safe_float(data_row[5])
|
||||
if temp_c is None:
|
||||
continue
|
||||
obs_time = self._ru_build_obs_time(time_row[0], time_row[1])
|
||||
return {
|
||||
"temp_c": round(temp_c, 1),
|
||||
"obs_time": obs_time,
|
||||
"raw_hour": time_row[0],
|
||||
"raw_day_month": time_row[1],
|
||||
}
|
||||
return None
|
||||
|
||||
def _ru_cached_station_current(
|
||||
self,
|
||||
station_code: str,
|
||||
station_meta: Dict[str, Any],
|
||||
use_fahrenheit: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
cache_key = f"{station_code}:{use_fahrenheit}"
|
||||
now_ts = time.time()
|
||||
with self._ru_station_cache_lock:
|
||||
cached = self._ru_station_cache.get(cache_key)
|
||||
if cached and now_ts - cached["t"] < self.ru_station_cache_ttl_sec:
|
||||
return cached["d"]
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
url = f"https://www.pogodaiklimat.ru/weather.php?id={station_code}"
|
||||
html_text = self._ru_http_get_text(url)
|
||||
parsed = self._ru_parse_station_current_from_weather_html(html_text)
|
||||
if not parsed:
|
||||
record_source_call(
|
||||
"ru_station_web",
|
||||
"current",
|
||||
"empty",
|
||||
(time.perf_counter() - started) * 1000.0,
|
||||
)
|
||||
return None
|
||||
obs_time = parsed.get("obs_time")
|
||||
max_stale_sec = max(
|
||||
0,
|
||||
int(getattr(self, "ru_station_max_stale_sec", 72 * 3600)),
|
||||
)
|
||||
if obs_time and max_stale_sec > 0:
|
||||
try:
|
||||
obs_dt = datetime.fromisoformat(str(obs_time))
|
||||
obs_age_sec = (datetime.now(timezone.utc) - obs_dt).total_seconds()
|
||||
if obs_age_sec > max_stale_sec:
|
||||
logger.info(
|
||||
"Russia station web row is stale station={} obs_time={} age_hours={:.1f}",
|
||||
station_code,
|
||||
obs_time,
|
||||
obs_age_sec / 3600.0,
|
||||
)
|
||||
record_source_call(
|
||||
"ru_station_web",
|
||||
"current",
|
||||
"stale_row",
|
||||
(time.perf_counter() - started) * 1000.0,
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
temp_c = parsed.get("temp_c")
|
||||
if temp_c is None:
|
||||
record_source_call(
|
||||
"ru_station_web",
|
||||
"current",
|
||||
"no_temperature",
|
||||
(time.perf_counter() - started) * 1000.0,
|
||||
)
|
||||
return None
|
||||
temp = round(temp_c * 9 / 5 + 32, 1) if use_fahrenheit else round(temp_c, 1)
|
||||
result = {
|
||||
"station_code": station_code,
|
||||
"station_label": station_meta.get("station_label") or f"RU {station_code}",
|
||||
"name": station_meta.get("station_label") or f"RU {station_code}",
|
||||
"lat": station_meta.get("lat"),
|
||||
"lon": station_meta.get("lon"),
|
||||
"temp": temp,
|
||||
"obs_time": parsed.get("obs_time"),
|
||||
"source": "ru_station_web",
|
||||
"source_label": "Russia station web",
|
||||
"source_code": "ru_station_web",
|
||||
"is_official": True,
|
||||
"is_airport_station": station_code == "27524",
|
||||
"is_settlement_anchor": False,
|
||||
"page_url": f"https://www.pogodaiklimat.ru/weather.php?id={station_code}",
|
||||
}
|
||||
with self._ru_station_cache_lock:
|
||||
self._ru_station_cache[cache_key] = {"d": result, "t": now_ts}
|
||||
record_source_call(
|
||||
"ru_station_web",
|
||||
"current",
|
||||
"success",
|
||||
(time.perf_counter() - started) * 1000.0,
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.warning("Russia station web fetch failed station={} error={}", station_code, exc)
|
||||
with self._ru_station_cache_lock:
|
||||
stale = self._ru_station_cache.get(cache_key)
|
||||
if stale:
|
||||
record_source_call(
|
||||
"ru_station_web",
|
||||
"current",
|
||||
"stale_cache",
|
||||
(time.perf_counter() - started) * 1000.0,
|
||||
)
|
||||
return stale["d"]
|
||||
record_source_call(
|
||||
"ru_station_web",
|
||||
"current",
|
||||
"error",
|
||||
(time.perf_counter() - started) * 1000.0,
|
||||
)
|
||||
return None
|
||||
|
||||
def fetch_russia_moscow_official_nearby(
|
||||
self,
|
||||
city: str,
|
||||
use_fahrenheit: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
started = time.perf_counter()
|
||||
city_key = str(city or "").strip().lower()
|
||||
if city_key != "moscow":
|
||||
record_source_call(
|
||||
"ru_station_web",
|
||||
"nearby",
|
||||
"unsupported_city",
|
||||
(time.perf_counter() - started) * 1000.0,
|
||||
)
|
||||
return []
|
||||
|
||||
city_meta = self.CITY_REGISTRY.get(city_key) or {}
|
||||
anchor_lat = self._ru_safe_float(city_meta.get("lat"))
|
||||
anchor_lon = self._ru_safe_float(city_meta.get("lon"))
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
for station_code, station_meta in RUSSIA_MOSCOW_STATIONS.items():
|
||||
current = self._ru_cached_station_current(
|
||||
station_code,
|
||||
station_meta,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
if not current:
|
||||
continue
|
||||
row = dict(current)
|
||||
row["distance_km"] = self._ru_distance_km(
|
||||
anchor_lat,
|
||||
anchor_lon,
|
||||
self._ru_safe_float(row.get("lat")),
|
||||
self._ru_safe_float(row.get("lon")),
|
||||
)
|
||||
row["icao"] = station_code
|
||||
row["istNo"] = station_code
|
||||
rows.append(row)
|
||||
rows.sort(
|
||||
key=lambda item: (
|
||||
item.get("distance_km") is None,
|
||||
item.get("distance_km") if item.get("distance_km") is not None else 9999,
|
||||
item.get("station_label") or "",
|
||||
)
|
||||
)
|
||||
trimmed = rows[:6]
|
||||
record_source_call(
|
||||
"ru_station_web",
|
||||
"nearby",
|
||||
"success" if trimmed else "empty",
|
||||
(time.perf_counter() - started) * 1000.0,
|
||||
)
|
||||
return trimmed
|
||||
except Exception as exc:
|
||||
logger.warning("Russia station nearby fetch failed city={} error={}", city_key, exc)
|
||||
record_source_call(
|
||||
"ru_station_web",
|
||||
"nearby",
|
||||
"error",
|
||||
(time.perf_counter() - started) * 1000.0,
|
||||
)
|
||||
return []
|
||||
@@ -4,6 +4,7 @@ import csv
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -11,7 +12,6 @@ from loguru import logger
|
||||
|
||||
from src.database.runtime_state import (
|
||||
OfficialIntradayObservationRepository,
|
||||
STATE_STORAGE_DUAL,
|
||||
STATE_STORAGE_SQLITE,
|
||||
get_state_storage_mode,
|
||||
)
|
||||
@@ -182,7 +182,7 @@ class SettlementSourceMixin:
|
||||
date_str = local_dt.strftime("%Y-%m-%d")
|
||||
time_str = local_dt.strftime("%H:%M")
|
||||
mode = get_state_storage_mode()
|
||||
if mode not in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
|
||||
if mode != STATE_STORAGE_SQLITE:
|
||||
return [{"time": time_str, "temp": round(float(current_temp), 1)}]
|
||||
|
||||
lock = self._get_settlement_series_lock()
|
||||
@@ -226,19 +226,31 @@ class SettlementSourceMixin:
|
||||
|
||||
try:
|
||||
base = "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather"
|
||||
temp_csv = self._http_get(f"{base}/latest_1min_temperature.csv", timeout=self.timeout)
|
||||
temp_csv.raise_for_status()
|
||||
maxmin_csv = self._http_get(f"{base}/latest_since_midnight_maxmin.csv", timeout=self.timeout)
|
||||
maxmin_csv.raise_for_status()
|
||||
humidity_csv = self._http_get(f"{base}/latest_1min_humidity.csv", timeout=self.timeout)
|
||||
humidity_csv.raise_for_status()
|
||||
wind_csv = self._http_get(f"{base}/latest_10min_wind.csv", timeout=self.timeout)
|
||||
wind_csv.raise_for_status()
|
||||
csv_urls = {
|
||||
"temp": f"{base}/latest_1min_temperature.csv",
|
||||
"maxmin": f"{base}/latest_since_midnight_maxmin.csv",
|
||||
"humidity": f"{base}/latest_1min_humidity.csv",
|
||||
"wind": f"{base}/latest_10min_wind.csv",
|
||||
}
|
||||
|
||||
temp_rows = self._csv_rows(temp_csv.text)
|
||||
maxmin_rows = self._csv_rows(maxmin_csv.text)
|
||||
humidity_rows = self._csv_rows(humidity_csv.text)
|
||||
wind_rows = self._csv_rows(wind_csv.text)
|
||||
def _fetch_csv(url: str):
|
||||
response = self._http_get(url, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
fetched_csv = {}
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
future_map = {
|
||||
executor.submit(_fetch_csv, url): key
|
||||
for key, url in csv_urls.items()
|
||||
}
|
||||
for future, key in future_map.items():
|
||||
fetched_csv[key] = future.result()
|
||||
|
||||
temp_rows = self._csv_rows(fetched_csv["temp"].text)
|
||||
maxmin_rows = self._csv_rows(fetched_csv["maxmin"].text)
|
||||
humidity_rows = self._csv_rows(fetched_csv["humidity"].text)
|
||||
wind_rows = self._csv_rows(fetched_csv["wind"].text)
|
||||
|
||||
temp_row = self._pick_station_row(temp_rows, candidate_names)
|
||||
maxmin_row = self._pick_station_row(maxmin_rows, candidate_names)
|
||||
@@ -626,6 +638,8 @@ class SettlementSourceMixin:
|
||||
station_name=station_name,
|
||||
station_candidates=station_candidates,
|
||||
)
|
||||
if settlement_source == "cwa":
|
||||
return self.fetch_cwa_taipei_settlement_current()
|
||||
if settlement_source == "noaa":
|
||||
station_code = (
|
||||
str(city_meta.get("settlement_station_code") or "").strip()
|
||||
@@ -643,8 +657,5 @@ class SettlementSourceMixin:
|
||||
except Exception as exc:
|
||||
logger.warning(f"Settlement source dispatch failed city={city}: {exc}")
|
||||
if normalized == "taipei":
|
||||
return self.fetch_noaa_station_settlement_current(
|
||||
station_code="RCTP",
|
||||
station_name="Taiwan Taoyuan International Airport",
|
||||
)
|
||||
return self.fetch_cwa_taipei_settlement_current()
|
||||
return None
|
||||
|
||||
@@ -12,11 +12,12 @@ from src.data_collection.metar_sources import MetarSourceMixin
|
||||
from src.data_collection.mgm_sources import MgmSourceMixin
|
||||
from src.data_collection.kma_station_sources import KmaStationSourceMixin
|
||||
from src.data_collection.jma_amedas_sources import JmaAmedasSourceMixin
|
||||
from src.data_collection.russia_station_sources import RussiaStationSourceMixin
|
||||
from src.data_collection.nmc_sources import NmcSourceMixin
|
||||
from src.data_collection.nws_open_meteo_sources import NwsOpenMeteoSourceMixin
|
||||
|
||||
|
||||
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, KmaStationSourceMixin, JmaAmedasSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin):
|
||||
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, KmaStationSourceMixin, JmaAmedasSourceMixin, RussiaStationSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin):
|
||||
"""
|
||||
Multi-source weather data collector
|
||||
|
||||
@@ -52,6 +53,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
"taipei": ["RCSS", "RCTP"],
|
||||
"chengdu": ["ZUUU", "ZUTF"],
|
||||
"chongqing": ["ZUCK", "ZUPS"],
|
||||
"guangzhou": ["ZGGG", "ZGSZ"],
|
||||
"shenzhen": ["ZGSZ", "ZGGG"],
|
||||
"beijing": ["ZBAA", "ZBAD"],
|
||||
"wuhan": ["ZHHH", "ZHES"],
|
||||
@@ -59,6 +61,9 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
"singapore": ["WSSS", "WSAP", "WMKK"],
|
||||
"kuala lumpur": ["WMKK", "WMSA"],
|
||||
"jakarta": ["WIHH", "WIII"],
|
||||
"manila": ["RPLL"],
|
||||
"karachi": ["OPKC", "OPMR"],
|
||||
"masroor air base": ["OPMR", "OPKC"],
|
||||
"tokyo": ["RJTT", "RJAA", "RJAH", "RJTJ"],
|
||||
"tel aviv": ["LLBG"],
|
||||
"milan": ["LIMC", "LIML", "LIME", "LIPO"],
|
||||
@@ -112,16 +117,38 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
|
||||
self.timeout = 30 # 增加超时以支持高延迟 VPS
|
||||
# Keep external calls short so one degraded source cannot block the whole city pipeline.
|
||||
self.timeout = max(
|
||||
2.0, float(os.getenv("POLYWEATHER_HTTP_TIMEOUT_SEC", "8"))
|
||||
)
|
||||
self.http_retry_count = max(
|
||||
0, int(os.getenv("POLYWEATHER_HTTP_RETRY_COUNT", "1"))
|
||||
0, int(os.getenv("POLYWEATHER_HTTP_RETRY_COUNT", "0"))
|
||||
)
|
||||
self.http_retry_backoff_sec = max(
|
||||
0.0, float(os.getenv("POLYWEATHER_HTTP_RETRY_BACKOFF_SEC", "0.35"))
|
||||
0.0, float(os.getenv("POLYWEATHER_HTTP_RETRY_BACKOFF_SEC", "0.2"))
|
||||
)
|
||||
self.open_meteo_timeout_sec = max(
|
||||
2.0, float(os.getenv("POLYWEATHER_OPEN_METEO_TIMEOUT_SEC", "5"))
|
||||
)
|
||||
self.metar_timeout_sec = max(
|
||||
2.0, float(os.getenv("POLYWEATHER_METAR_TIMEOUT_SEC", "4"))
|
||||
)
|
||||
self.metar_latest_timeout_sec = max(
|
||||
1.0, float(os.getenv("POLYWEATHER_METAR_LATEST_TIMEOUT_SEC", "2.5"))
|
||||
)
|
||||
self.metar_cluster_timeout_sec = max(
|
||||
2.0, float(os.getenv("POLYWEATHER_METAR_CLUSTER_TIMEOUT_SEC", "3.5"))
|
||||
)
|
||||
self.user_agent = str(
|
||||
os.getenv(
|
||||
"POLYWEATHER_USER_AGENT",
|
||||
"PolyWeather/1.0 (+https://polyweather-pro.vercel.app)",
|
||||
)
|
||||
).strip()
|
||||
self.session = httpx.Client(
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": self.user_agent},
|
||||
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
|
||||
)
|
||||
self.open_meteo_cache_ttl_sec = int(
|
||||
@@ -150,7 +177,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
self._open_meteo_rl_lock = threading.Lock()
|
||||
# Open-Meteo burst control: avoid hammering API with many cities at once.
|
||||
self._open_meteo_min_interval_sec: float = float(
|
||||
os.getenv("OPEN_METEO_MIN_CALL_INTERVAL_SEC", "3")
|
||||
os.getenv("OPEN_METEO_MIN_CALL_INTERVAL_SEC", "1")
|
||||
)
|
||||
self._open_meteo_last_call_ts: float = 0.0
|
||||
self._open_meteo_call_lock = threading.Lock()
|
||||
@@ -170,7 +197,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
self._nmc_cache: Dict[str, Dict] = {}
|
||||
self._nmc_cache_lock = threading.Lock()
|
||||
self.jma_cache_ttl_sec = int(
|
||||
os.getenv("JMA_AMEDAS_CACHE_TTL_SEC", "300")
|
||||
os.getenv("JMA_AMEDAS_CACHE_TTL_SEC", "120")
|
||||
)
|
||||
self._jma_cache: Dict[str, Dict] = {}
|
||||
self._jma_cache_lock = threading.Lock()
|
||||
@@ -179,6 +206,14 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
)
|
||||
self._kma_cache: Dict[str, Dict] = {}
|
||||
self._kma_cache_lock = threading.Lock()
|
||||
self.ru_station_cache_ttl_sec = int(
|
||||
os.getenv("RU_STATION_CACHE_TTL_SEC", "300")
|
||||
)
|
||||
self.ru_station_max_stale_sec = int(
|
||||
os.getenv("RU_STATION_MAX_STALE_SEC", str(72 * 3600))
|
||||
)
|
||||
self._ru_station_cache: Dict[str, Dict] = {}
|
||||
self._ru_station_cache_lock = threading.Lock()
|
||||
self.settlement_cache_ttl_sec = int(
|
||||
os.getenv("SETTLEMENT_SOURCE_CACHE_TTL_SEC", "120")
|
||||
)
|
||||
@@ -213,6 +248,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True,
|
||||
proxy=proxy,
|
||||
headers={"User-Agent": self.user_agent},
|
||||
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
|
||||
)
|
||||
logger.info(f"正在使用天气数据代理: {proxy}")
|
||||
@@ -575,6 +611,18 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
"jakarta": "Jakarta",
|
||||
"雅加达": "Jakarta",
|
||||
"雅加達": "Jakarta",
|
||||
"manila": "Manila",
|
||||
"rpll": "Manila",
|
||||
"马尼拉": "Manila",
|
||||
"馬尼拉": "Manila",
|
||||
"karachi": "Karachi",
|
||||
"opkc": "Karachi",
|
||||
"卡拉奇": "Karachi",
|
||||
"masroor": "Masroor Air Base",
|
||||
"masroor air base": "Masroor Air Base",
|
||||
"opmr": "Masroor Air Base",
|
||||
"马斯鲁尔": "Masroor Air Base",
|
||||
"馬斯魯爾": "Masroor Air Base",
|
||||
"helsinki": "Helsinki",
|
||||
"vantaa": "Helsinki",
|
||||
"赫尔辛基": "Helsinki",
|
||||
@@ -679,6 +727,14 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
if key.startswith(prefix):
|
||||
self._metar_cache.pop(key, None)
|
||||
normalized = str(city or "").strip().lower()
|
||||
with self._jma_cache_lock:
|
||||
self._jma_cache.pop(f"{normalized}:{use_fahrenheit}", None)
|
||||
with self._kma_cache_lock:
|
||||
self._kma_cache.pop(f"{normalized}:{use_fahrenheit}", None)
|
||||
with self._nmc_cache_lock:
|
||||
self._nmc_cache.pop(f"{normalized}:{use_fahrenheit}", None)
|
||||
with self._ru_station_cache_lock:
|
||||
self._ru_station_cache.pop(f"{normalized}:{use_fahrenheit}", None)
|
||||
with self._settlement_cache_lock:
|
||||
city_meta = self.CITY_REGISTRY.get(normalized) or {}
|
||||
settlement_source = str(city_meta.get("settlement_source") or "").strip().lower()
|
||||
@@ -696,6 +752,12 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
or normalized
|
||||
)
|
||||
self._settlement_cache.pop(f"noaa:{station_code.lower()}", None)
|
||||
elif settlement_source == "cwa":
|
||||
station_code = (
|
||||
str(city_meta.get("settlement_station_code") or "").strip()
|
||||
or normalized
|
||||
)
|
||||
self._settlement_cache.pop(f"cwa:{station_code.lower()}", None)
|
||||
|
||||
def _uses_fahrenheit(self, city_lower: str) -> bool:
|
||||
return city_lower in self.US_CITIES
|
||||
@@ -719,24 +781,42 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
hko_forecast = self.fetch_hko_forecast()
|
||||
if hko_forecast:
|
||||
results["hko_forecast"] = hko_forecast
|
||||
elif settlement_source == "cwa":
|
||||
cwa_forecast = self.fetch_cwa_taipei_forecast()
|
||||
if cwa_forecast is not None:
|
||||
results["cwa_forecast"] = cwa_forecast
|
||||
|
||||
def _attach_turkish_mgm_data(self, results: Dict, city_lower: str) -> None:
|
||||
if city_lower not in self.TURKISH_PROVINCES:
|
||||
def _attach_turkish_mgm_data(
|
||||
self,
|
||||
results: Dict,
|
||||
city_lower: str,
|
||||
*,
|
||||
include_mgm: bool = True,
|
||||
include_nearby: bool = True,
|
||||
) -> None:
|
||||
if not include_mgm or city_lower not in self.TURKISH_PROVINCES:
|
||||
return
|
||||
istno, province = self.TURKISH_PROVINCES[city_lower]
|
||||
mgm_data = self.fetch_from_mgm(istno)
|
||||
if not mgm_data:
|
||||
return
|
||||
results["mgm"] = mgm_data
|
||||
results["nearby_source"] = "mgm"
|
||||
nearby = self.fetch_mgm_nearby_stations(province, root_ist_no=istno)
|
||||
if nearby:
|
||||
results["mgm_nearby"] = nearby
|
||||
if include_nearby:
|
||||
results["nearby_source"] = "mgm"
|
||||
nearby = self.fetch_mgm_nearby_stations(province, root_ist_no=istno)
|
||||
if nearby:
|
||||
results["mgm_nearby"] = nearby
|
||||
|
||||
def _attach_global_nearby_cluster(
|
||||
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
||||
) -> None:
|
||||
if city_lower not in self.CITY_METAR_CLUSTERS or "mgm_nearby" in results:
|
||||
city_meta = self.CITY_REGISTRY.get(str(city_lower or "").strip().lower()) or {}
|
||||
settlement_source = str(city_meta.get("settlement_source") or "").strip().lower()
|
||||
if (
|
||||
city_lower not in self.CITY_METAR_CLUSTERS
|
||||
or "mgm_nearby" in results
|
||||
or settlement_source in {"hko", "cwa"}
|
||||
):
|
||||
return
|
||||
cluster_icaos = self.CITY_METAR_CLUSTERS[city_lower]
|
||||
cluster_data = self.fetch_metar_nearby_cluster(
|
||||
@@ -798,6 +878,21 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
results["mgm_nearby"] = official_rows
|
||||
results["nearby_source"] = "kma"
|
||||
|
||||
def _attach_russia_official_nearby(
|
||||
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
||||
) -> None:
|
||||
if city_lower != "moscow":
|
||||
return
|
||||
official_rows = self.fetch_russia_moscow_official_nearby(
|
||||
city_lower, use_fahrenheit=use_fahrenheit
|
||||
)
|
||||
if not official_rows:
|
||||
return
|
||||
results["ru_official_nearby"] = official_rows
|
||||
if "mgm_nearby" not in results:
|
||||
results["mgm_nearby"] = official_rows
|
||||
results["nearby_source"] = "ru_station_web"
|
||||
|
||||
def _attach_warsaw_official_nearby(
|
||||
self, results: Dict, use_fahrenheit: bool
|
||||
) -> None:
|
||||
@@ -830,21 +925,26 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
lat: float,
|
||||
lon: float,
|
||||
use_fahrenheit: bool,
|
||||
*,
|
||||
include_ensemble: bool = True,
|
||||
include_multi_model: bool = True,
|
||||
) -> None:
|
||||
if use_fahrenheit:
|
||||
nws_data = self.fetch_nws(lat, lon)
|
||||
if nws_data:
|
||||
results["nws"] = nws_data
|
||||
|
||||
ensemble_data = self.fetch_ensemble(lat, lon, use_fahrenheit=use_fahrenheit)
|
||||
if ensemble_data:
|
||||
results["ensemble"] = ensemble_data
|
||||
if include_ensemble:
|
||||
ensemble_data = self.fetch_ensemble(lat, lon, use_fahrenheit=use_fahrenheit)
|
||||
if ensemble_data:
|
||||
results["ensemble"] = ensemble_data
|
||||
|
||||
multi_model_data = self.fetch_multi_model(
|
||||
lat, lon, city=city, use_fahrenheit=use_fahrenheit
|
||||
)
|
||||
if multi_model_data:
|
||||
results["multi_model"] = multi_model_data
|
||||
if include_multi_model:
|
||||
multi_model_data = self.fetch_multi_model(
|
||||
lat, lon, city=city, use_fahrenheit=use_fahrenheit
|
||||
)
|
||||
if multi_model_data:
|
||||
results["multi_model"] = multi_model_data
|
||||
|
||||
def fetch_all_sources(
|
||||
self,
|
||||
@@ -853,6 +953,11 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
lon: float = None,
|
||||
country: str = None,
|
||||
force_refresh: bool = False,
|
||||
include_taf: bool = True,
|
||||
include_nearby: bool = True,
|
||||
include_ensemble: bool = True,
|
||||
include_multi_model: bool = True,
|
||||
include_mgm: bool = True,
|
||||
) -> Dict:
|
||||
"""
|
||||
Fetch weather data from all available sources
|
||||
@@ -886,22 +991,35 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
)
|
||||
if metar_data:
|
||||
results["metar"] = metar_data
|
||||
if supports_aviationweather and city_lower != "hong kong":
|
||||
if include_taf and supports_aviationweather and city_lower != "hong kong":
|
||||
taf_data = self.fetch_taf(city, utc_offset=utc_offset)
|
||||
if taf_data:
|
||||
results["taf"] = taf_data
|
||||
|
||||
self._attach_turkish_mgm_data(results, city_lower)
|
||||
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
||||
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
||||
self._attach_korea_official_nearby(results, city_lower, use_fahrenheit)
|
||||
if city_lower == "warsaw":
|
||||
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
||||
self._attach_global_nearby_cluster(
|
||||
results, city_lower, use_fahrenheit
|
||||
self._attach_turkish_mgm_data(
|
||||
results,
|
||||
city_lower,
|
||||
include_mgm=include_mgm,
|
||||
include_nearby=include_nearby,
|
||||
)
|
||||
if include_nearby:
|
||||
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
||||
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
||||
self._attach_korea_official_nearby(results, city_lower, use_fahrenheit)
|
||||
self._attach_russia_official_nearby(results, city_lower, use_fahrenheit)
|
||||
if city_lower == "warsaw":
|
||||
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
||||
self._attach_global_nearby_cluster(
|
||||
results, city_lower, use_fahrenheit
|
||||
)
|
||||
self._attach_nws_and_models(
|
||||
results, city, lat, lon, use_fahrenheit
|
||||
results,
|
||||
city,
|
||||
lat,
|
||||
lon,
|
||||
use_fahrenheit,
|
||||
include_ensemble=include_ensemble,
|
||||
include_multi_model=include_multi_model,
|
||||
)
|
||||
else:
|
||||
fallback_utc_offset = int(
|
||||
@@ -915,29 +1033,42 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
)
|
||||
if metar_data:
|
||||
results["metar"] = metar_data
|
||||
if supports_aviationweather and city_lower != "hong kong":
|
||||
if include_taf and supports_aviationweather and city_lower != "hong kong":
|
||||
taf_data = self.fetch_taf(city, utc_offset=fallback_utc_offset)
|
||||
if taf_data:
|
||||
results["taf"] = taf_data
|
||||
|
||||
self._attach_turkish_mgm_data(results, city_lower)
|
||||
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
||||
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
||||
self._attach_korea_official_nearby(results, city_lower, use_fahrenheit)
|
||||
if city_lower == "warsaw":
|
||||
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
||||
self._attach_global_nearby_cluster(
|
||||
results, city_lower, use_fahrenheit
|
||||
self._attach_turkish_mgm_data(
|
||||
results,
|
||||
city_lower,
|
||||
include_mgm=include_mgm,
|
||||
include_nearby=include_nearby,
|
||||
)
|
||||
if include_nearby:
|
||||
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
||||
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
||||
self._attach_korea_official_nearby(results, city_lower, use_fahrenheit)
|
||||
self._attach_russia_official_nearby(results, city_lower, use_fahrenheit)
|
||||
if city_lower == "warsaw":
|
||||
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
||||
self._attach_global_nearby_cluster(
|
||||
results, city_lower, use_fahrenheit
|
||||
)
|
||||
self._attach_nws_and_models(
|
||||
results, city, lat, lon, use_fahrenheit
|
||||
results,
|
||||
city,
|
||||
lat,
|
||||
lon,
|
||||
use_fahrenheit,
|
||||
include_ensemble=include_ensemble,
|
||||
include_multi_model=include_multi_model,
|
||||
)
|
||||
else:
|
||||
if supports_aviationweather:
|
||||
metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit)
|
||||
if metar_data:
|
||||
results["metar"] = metar_data
|
||||
if supports_aviationweather and city_lower != "hong kong":
|
||||
if include_taf and supports_aviationweather and city_lower != "hong kong":
|
||||
taf_data = self.fetch_taf(city)
|
||||
if taf_data:
|
||||
results["taf"] = taf_data
|
||||
|
||||
@@ -182,6 +182,64 @@ class DBManager:
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS city_summary_cache (
|
||||
city TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_at_ts REAL NOT NULL,
|
||||
version TEXT,
|
||||
source_fingerprint TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS city_panel_cache (
|
||||
city TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_at_ts REAL NOT NULL,
|
||||
version TEXT,
|
||||
source_fingerprint TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS city_nearby_cache (
|
||||
city TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_at_ts REAL NOT NULL,
|
||||
version TEXT,
|
||||
source_fingerprint TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS city_market_cache (
|
||||
city TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_at_ts REAL NOT NULL,
|
||||
version TEXT,
|
||||
source_fingerprint TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS city_history_preview_cache (
|
||||
city TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_at_ts REAL NOT NULL,
|
||||
version TEXT,
|
||||
source_fingerprint TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS cache_refresh_locks (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
locked_until_ts REAL NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS payment_audit_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -247,6 +305,151 @@ class DBManager:
|
||||
conn.commit()
|
||||
logger.info(f"Database initialized successfully path={self.db_path}")
|
||||
|
||||
def _cache_table_name(self, kind: str) -> Optional[str]:
|
||||
normalized = str(kind or "").strip().lower()
|
||||
if normalized == "summary":
|
||||
return "city_summary_cache"
|
||||
if normalized == "panel":
|
||||
return "city_panel_cache"
|
||||
if normalized == "nearby":
|
||||
return "city_nearby_cache"
|
||||
if normalized == "market":
|
||||
return "city_market_cache"
|
||||
if normalized == "history_preview":
|
||||
return "city_history_preview_cache"
|
||||
return None
|
||||
|
||||
def get_city_cache(self, kind: str, city: str) -> Optional[Dict[str, Any]]:
|
||||
table = self._cache_table_name(kind)
|
||||
normalized_city = str(city or "").strip().lower()
|
||||
if not table or not normalized_city:
|
||||
return None
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT city, payload_json, updated_at, updated_at_ts, version, source_fingerprint
|
||||
FROM {table}
|
||||
WHERE city = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(normalized_city,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(str(row["payload_json"] or "{}"))
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return {
|
||||
"city": str(row["city"] or normalized_city),
|
||||
"payload": payload,
|
||||
"updated_at": str(row["updated_at"] or ""),
|
||||
"updated_at_ts": float(row["updated_at_ts"] or 0.0),
|
||||
"version": str(row["version"] or ""),
|
||||
"source_fingerprint": str(row["source_fingerprint"] or ""),
|
||||
}
|
||||
|
||||
def set_city_cache(
|
||||
self,
|
||||
kind: str,
|
||||
city: str,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
version: str = "v1",
|
||||
source_fingerprint: Optional[str] = None,
|
||||
) -> None:
|
||||
table = self._cache_table_name(kind)
|
||||
normalized_city = str(city or "").strip().lower()
|
||||
if not table or not normalized_city or not isinstance(payload, dict):
|
||||
return
|
||||
now = datetime.now().isoformat()
|
||||
now_ts = datetime.now().timestamp()
|
||||
with self._get_connection() as conn:
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO {table} (
|
||||
city,
|
||||
payload_json,
|
||||
updated_at,
|
||||
updated_at_ts,
|
||||
version,
|
||||
source_fingerprint
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(city) DO UPDATE SET
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_at_ts = excluded.updated_at_ts,
|
||||
version = excluded.version,
|
||||
source_fingerprint = excluded.source_fingerprint
|
||||
""",
|
||||
(
|
||||
normalized_city,
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
now,
|
||||
now_ts,
|
||||
str(version or "v1"),
|
||||
str(source_fingerprint or ""),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def acquire_cache_refresh_lock(
|
||||
self,
|
||||
cache_key: str,
|
||||
*,
|
||||
ttl_sec: int = 120,
|
||||
owner: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
normalized_key = str(cache_key or "").strip().lower()
|
||||
if not normalized_key:
|
||||
return None
|
||||
lock_owner = str(owner or "").strip() or hashlib.sha1(
|
||||
f"{normalized_key}:{datetime.now().timestamp()}".encode("utf-8")
|
||||
).hexdigest()[:12]
|
||||
now_ts = datetime.now().timestamp()
|
||||
locked_until_ts = now_ts + max(15, int(ttl_sec or 120))
|
||||
updated_at = datetime.now().isoformat()
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO cache_refresh_locks (cache_key, locked_until_ts, owner, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
locked_until_ts = excluded.locked_until_ts,
|
||||
owner = excluded.owner,
|
||||
updated_at = excluded.updated_at
|
||||
WHERE cache_refresh_locks.locked_until_ts < ?
|
||||
""",
|
||||
(
|
||||
normalized_key,
|
||||
locked_until_ts,
|
||||
lock_owner,
|
||||
updated_at,
|
||||
now_ts,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return lock_owner if int(cursor.rowcount or 0) > 0 else None
|
||||
|
||||
def release_cache_refresh_lock(self, cache_key: str, owner: str) -> None:
|
||||
normalized_key = str(cache_key or "").strip().lower()
|
||||
normalized_owner = str(owner or "").strip()
|
||||
if not normalized_key or not normalized_owner:
|
||||
return
|
||||
with self._get_connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM cache_refresh_locks
|
||||
WHERE cache_key = ? AND owner = ?
|
||||
""",
|
||||
(normalized_key, normalized_owner),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_payment_runtime_state(self, state_key: str) -> Optional[Dict[str, Any]]:
|
||||
key = str(state_key or "").strip()
|
||||
if not key:
|
||||
|
||||
@@ -16,9 +16,9 @@ from src.database.db_manager import DBManager
|
||||
STATE_STORAGE_FILE = "file"
|
||||
STATE_STORAGE_DUAL = "dual"
|
||||
STATE_STORAGE_SQLITE = "sqlite"
|
||||
DEFAULT_STATE_STORAGE_MODE = STATE_STORAGE_SQLITE
|
||||
VALID_STATE_STORAGE_MODES = {
|
||||
STATE_STORAGE_FILE,
|
||||
STATE_STORAGE_DUAL,
|
||||
STATE_STORAGE_SQLITE,
|
||||
}
|
||||
|
||||
@@ -26,12 +26,17 @@ _LOGGED_MODES: set[str] = set()
|
||||
|
||||
|
||||
def get_state_storage_mode() -> str:
|
||||
raw = str(os.getenv("POLYWEATHER_STATE_STORAGE_MODE") or STATE_STORAGE_DUAL).strip().lower()
|
||||
raw = str(os.getenv("POLYWEATHER_STATE_STORAGE_MODE") or DEFAULT_STATE_STORAGE_MODE).strip().lower()
|
||||
if raw == STATE_STORAGE_DUAL:
|
||||
logger.warning(
|
||||
f"POLYWEATHER_STATE_STORAGE_MODE={STATE_STORAGE_DUAL!r} is deprecated, normalize to {STATE_STORAGE_SQLITE}"
|
||||
)
|
||||
raw = STATE_STORAGE_SQLITE
|
||||
if raw not in VALID_STATE_STORAGE_MODES:
|
||||
logger.warning(
|
||||
f"invalid POLYWEATHER_STATE_STORAGE_MODE={raw!r}, fallback to {STATE_STORAGE_DUAL}"
|
||||
f"invalid POLYWEATHER_STATE_STORAGE_MODE={raw!r}, fallback to {DEFAULT_STATE_STORAGE_MODE}"
|
||||
)
|
||||
raw = STATE_STORAGE_DUAL
|
||||
raw = DEFAULT_STATE_STORAGE_MODE
|
||||
if raw not in _LOGGED_MODES:
|
||||
logger.info(f"runtime state storage mode={raw}")
|
||||
_LOGGED_MODES.add(raw)
|
||||
|
||||
@@ -289,13 +289,17 @@ def build_training_samples(
|
||||
history_data: Optional[Dict[str, Any]] = None,
|
||||
snapshot_index: Optional[Dict[Tuple[str, str], Dict[str, Any]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
mode = get_state_storage_mode()
|
||||
if isinstance(history_data, dict):
|
||||
runtime_history = history_data
|
||||
elif get_state_storage_mode() == STATE_STORAGE_SQLITE:
|
||||
elif mode == STATE_STORAGE_SQLITE:
|
||||
runtime_history = DailyRecordRepository().load_all()
|
||||
else:
|
||||
runtime_history = load_history(_history_file_path())
|
||||
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
|
||||
if isinstance(history_data, dict):
|
||||
truth_history = runtime_history
|
||||
training_feature_history = {}
|
||||
elif mode == STATE_STORAGE_SQLITE:
|
||||
truth_history = TruthRecordRepository().load_all()
|
||||
training_feature_history = TrainingFeatureRecordRepository().load_all()
|
||||
else:
|
||||
|
||||
@@ -1988,9 +1988,11 @@ class PaymentContractCheckoutService:
|
||||
"payment": repaired.get("payment"),
|
||||
"subscription": repaired.get("subscription"),
|
||||
}
|
||||
if intent.status in {"failed", "cancelled", "expired"}:
|
||||
if intent.status in {"cancelled", "expired"}:
|
||||
raise PaymentCheckoutError(409, f"intent status is {intent.status}")
|
||||
tx_hash_text = str(tx_hash or intent.tx_hash or "").strip().lower()
|
||||
if intent.status == "failed" and not tx_hash_text:
|
||||
raise PaymentCheckoutError(409, "intent status is failed and tx_hash is missing")
|
||||
if not tx_hash_text:
|
||||
raise PaymentCheckoutError(400, "tx_hash required")
|
||||
if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
|
||||
@@ -2028,7 +2030,20 @@ class PaymentContractCheckoutService:
|
||||
)
|
||||
if not tx_to or not tx_from:
|
||||
raise PaymentCheckoutError(409, "tx indexed partially; retry confirm")
|
||||
if tx_to != intent.receiver_address:
|
||||
block_number = int(receipt.get("blockNumber") or 0)
|
||||
latest_block = int(w3.eth.block_number)
|
||||
confirmations = max(0, latest_block - block_number + 1) if block_number else 0
|
||||
if confirmations < self.confirmations:
|
||||
raise PaymentCheckoutError(
|
||||
409, f"confirmations not enough: {confirmations}/{self.confirmations}"
|
||||
)
|
||||
event_match = self._extract_matching_event(receipt, intent)
|
||||
event_payer = _normalize_address(event_match.get("payer")) if event_match else None
|
||||
effective_payer = event_payer or tx_from
|
||||
routed_via_delegate = bool(
|
||||
event_match and tx_to and tx_to != intent.receiver_address
|
||||
)
|
||||
if tx_to != intent.receiver_address and not event_match:
|
||||
self._mark_intent_failed(
|
||||
user_id=user_id,
|
||||
intent=intent,
|
||||
@@ -2045,29 +2060,24 @@ class PaymentContractCheckoutService:
|
||||
f"tx to mismatch: got={tx_to} expected={intent.receiver_address}",
|
||||
)
|
||||
if intent.payment_mode == "strict" and intent.allowed_wallet:
|
||||
if tx_from != intent.allowed_wallet:
|
||||
if effective_payer != intent.allowed_wallet:
|
||||
self._mark_intent_failed(
|
||||
user_id=user_id,
|
||||
intent=intent,
|
||||
tx_hash=tx_hash_text,
|
||||
reason="sender_mismatch",
|
||||
detail=f"tx sender mismatch: got={tx_from} expected={intent.allowed_wallet}",
|
||||
extra={"from_address": tx_from},
|
||||
detail=f"tx sender mismatch: got={effective_payer or tx_from} expected={intent.allowed_wallet}",
|
||||
extra={
|
||||
"from_address": tx_from,
|
||||
"event_payer": event_payer,
|
||||
},
|
||||
)
|
||||
raise PaymentCheckoutError(
|
||||
400,
|
||||
f"tx sender mismatch: got={tx_from} expected={intent.allowed_wallet}",
|
||||
f"tx sender mismatch: got={effective_payer or tx_from} expected={intent.allowed_wallet}",
|
||||
)
|
||||
else:
|
||||
self._require_user_wallet(user_id, tx_from)
|
||||
block_number = int(receipt.get("blockNumber") or 0)
|
||||
latest_block = int(w3.eth.block_number)
|
||||
confirmations = max(0, latest_block - block_number + 1) if block_number else 0
|
||||
if confirmations < self.confirmations:
|
||||
raise PaymentCheckoutError(
|
||||
409, f"confirmations not enough: {confirmations}/{self.confirmations}"
|
||||
)
|
||||
event_match = self._extract_matching_event(receipt, intent)
|
||||
self._require_user_wallet(user_id, effective_payer)
|
||||
if not event_match:
|
||||
self._mark_intent_failed(
|
||||
user_id=user_id,
|
||||
@@ -2091,6 +2101,14 @@ class PaymentContractCheckoutService:
|
||||
redemption_meta["points_after"] = points_result.get("points_after")
|
||||
redemption_meta["consumed_at"] = now_iso
|
||||
confirmed_metadata["points_redemption"] = redemption_meta
|
||||
if routed_via_delegate:
|
||||
confirmed_metadata["tx_envelope"] = {
|
||||
"outer_to": tx_to,
|
||||
"outer_from": tx_from,
|
||||
"event_payer": event_payer,
|
||||
"receiver_expected": intent.receiver_address,
|
||||
"matched_via_event": True,
|
||||
}
|
||||
self._rest(
|
||||
"PATCH",
|
||||
"payment_intents",
|
||||
@@ -2188,7 +2206,7 @@ class PaymentContractCheckoutService:
|
||||
"amount_units,payment_mode,allowed_wallet,order_id_hex,status,expires_at,tx_hash,metadata"
|
||||
),
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "in.(created,submitted,confirmed)",
|
||||
"status": "in.(created,submitted,confirmed,failed)",
|
||||
"order": "updated_at.desc",
|
||||
"limit": "5",
|
||||
},
|
||||
@@ -2205,9 +2223,15 @@ class PaymentContractCheckoutService:
|
||||
status = str(intent.status or "").strip().lower()
|
||||
tx_hash_text = str(intent.tx_hash or "").strip().lower()
|
||||
try:
|
||||
if status == "submitted" and tx_hash_text:
|
||||
if status in {"submitted", "failed"} and tx_hash_text:
|
||||
result = self.confirm_intent_tx(user_id, intent.intent_id, tx_hash_text)
|
||||
return {"ok": True, "action": "confirmed_submitted_intent", **result}
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "confirmed_submitted_intent"
|
||||
if status == "submitted"
|
||||
else "recovered_failed_intent",
|
||||
**result,
|
||||
}
|
||||
if status == "confirmed":
|
||||
repaired = self._ensure_confirm_side_effects(user_id, intent, tx_hash_text)
|
||||
refreshed = self.get_intent(user_id, intent.intent_id)
|
||||
|
||||
@@ -20,15 +20,19 @@ DEFAULT_CITIES = [
|
||||
"shanghai",
|
||||
"beijing",
|
||||
"shenzhen",
|
||||
"guangzhou",
|
||||
"wuhan",
|
||||
"chengdu",
|
||||
"chongqing",
|
||||
"hong kong",
|
||||
"taipei",
|
||||
"singapore",
|
||||
"tokyo",
|
||||
"seoul",
|
||||
"busan",
|
||||
"london",
|
||||
"paris",
|
||||
"new york",
|
||||
"los angeles",
|
||||
"madrid",
|
||||
]
|
||||
|
||||
_RUNTIME_LOCK = threading.Lock()
|
||||
@@ -204,7 +208,7 @@ def get_prewarm_runtime_summary() -> Dict[str, Any]:
|
||||
"interval_sec": interval_sec,
|
||||
"jitter_sec": jitter_sec,
|
||||
"include_detail": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_DETAIL", True),
|
||||
"include_market": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_MARKET", True),
|
||||
"include_market": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_MARKET", False),
|
||||
"force_refresh": _truthy_env("POLYWEATHER_PREWARM_FORCE_REFRESH", False),
|
||||
"thread_alive": bool(_WORKER_THREAD and _WORKER_THREAD.is_alive()) or shared_alive,
|
||||
"heartbeat_age_sec": None if heartbeat_age_sec is None else round(heartbeat_age_sec, 2),
|
||||
@@ -376,7 +380,7 @@ def start_prewarm_worker_thread() -> Optional[threading.Thread]:
|
||||
jitter_sec = int(os.getenv("POLYWEATHER_PREWARM_JITTER_SEC", "20"))
|
||||
force_refresh = str(os.getenv("POLYWEATHER_PREWARM_FORCE_REFRESH") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
include_detail = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_DETAIL", "true")).strip().lower() in {"1", "true", "yes", "on"}
|
||||
include_market = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_MARKET", "true")).strip().lower() in {"1", "true", "yes", "on"}
|
||||
include_market = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_MARKET", "false")).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
thread = threading.Thread(
|
||||
target=run_worker_loop,
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from loguru import logger
|
||||
|
||||
from src.database.runtime_state import (
|
||||
STATE_STORAGE_DUAL,
|
||||
STATE_STORAGE_SQLITE,
|
||||
TelegramAlertStateRepository,
|
||||
get_state_storage_mode,
|
||||
@@ -208,11 +207,6 @@ def _load_state(path: str) -> Dict[str, Any]:
|
||||
except Exception as exc:
|
||||
logger.error(f"failed to load telegram push state from sqlite: {exc}")
|
||||
if not os.path.exists(path):
|
||||
if mode == STATE_STORAGE_DUAL:
|
||||
try:
|
||||
return _telegram_state_repo.load_state()
|
||||
except Exception:
|
||||
return {"last_by_city": {}, "by_signature": {}}
|
||||
return {"last_by_city": {}, "by_signature": {}}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
@@ -228,7 +222,7 @@ def _load_state(path: str) -> Dict[str, Any]:
|
||||
|
||||
def _save_state(path: str, state: Dict[str, Any]) -> None:
|
||||
mode = get_state_storage_mode()
|
||||
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
_telegram_state_repo.save_state(state)
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
return
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
from src.data_collection.country_networks import build_country_network_snapshot
|
||||
from web.analysis_service import _build_city_detail_payload
|
||||
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
|
||||
from web.analysis_service import _build_city_detail_payload, _build_intraday_meteorology
|
||||
from web.core import CITIES
|
||||
|
||||
|
||||
def test_new_south_asia_city_registry_entries_are_wired():
|
||||
assert CITY_REGISTRY["manila"]["settlement_source"] == "wunderground"
|
||||
assert CITY_REGISTRY["manila"]["settlement_station_code"] == "RPLL"
|
||||
assert CITY_REGISTRY["karachi"]["settlement_source"] == "wunderground"
|
||||
assert CITY_REGISTRY["karachi"]["settlement_station_code"] == "OPKC"
|
||||
assert CITY_REGISTRY["masroor air base"]["icao"] == "OPMR"
|
||||
assert ALIASES["rpll"] == "manila"
|
||||
assert ALIASES["opkc"] == "karachi"
|
||||
assert ALIASES["opmr"] == "masroor air base"
|
||||
assert CITIES["manila"]["lat"] == CITY_REGISTRY["manila"]["lat"]
|
||||
assert CITIES["karachi"]["lon"] == CITY_REGISTRY["karachi"]["lon"]
|
||||
assert CITIES["masroor air base"]["settlement_source"] == "metar"
|
||||
|
||||
|
||||
def test_turkey_mgm_provider_returns_official_nearby_rows():
|
||||
@@ -108,6 +124,44 @@ def test_hko_provider_marks_explicit_official_station_as_anchor():
|
||||
assert snapshot["official_nearby"][0]["station_code"] == "LFS"
|
||||
|
||||
|
||||
def test_russia_provider_prefers_official_web_rows_when_available():
|
||||
raw = {
|
||||
"metar": {
|
||||
"observation_time": "2026-04-06T10:00:00.000Z",
|
||||
"current": {"temp": 11.0},
|
||||
},
|
||||
"ru_official_nearby": [
|
||||
{
|
||||
"station_code": "27524",
|
||||
"station_label": "Vnukovo",
|
||||
"lat": 55.5870,
|
||||
"lon": 37.2500,
|
||||
"temp": 12.3,
|
||||
"obs_time": "2026-04-06T09:00:00+00:00",
|
||||
"is_airport_station": True,
|
||||
"page_url": "https://www.pogodaiklimat.ru/weather.php?id=27524",
|
||||
}
|
||||
],
|
||||
"mgm_nearby": [
|
||||
{
|
||||
"name": "Sheremetyevo",
|
||||
"icao": "UUEE",
|
||||
"lat": 55.97,
|
||||
"lon": 37.41,
|
||||
"temp": 12.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
snapshot = build_country_network_snapshot("moscow", raw)
|
||||
|
||||
assert snapshot["provider_code"] == "russia_station_web"
|
||||
assert snapshot["official_network_status"]["available"] is True
|
||||
assert snapshot["official_network_status"]["mode"] == "official_web_crawl"
|
||||
assert snapshot["official_nearby"][0]["source_code"] == "ru_station_web"
|
||||
assert snapshot["official_nearby"][0]["is_official"] is True
|
||||
|
||||
|
||||
def test_city_detail_payload_exposes_airport_and_official_network_layers():
|
||||
payload = _build_city_detail_payload(
|
||||
{
|
||||
@@ -148,3 +202,91 @@ def test_city_detail_payload_exposes_airport_and_official_network_layers():
|
||||
assert payload["official"]["airport_primary"]["source_code"] == "metar"
|
||||
assert payload["official"]["official_nearby"][0]["source_code"] == "mgm"
|
||||
assert payload["settlement_station"]["settlement_station_code"] == "LTAC"
|
||||
|
||||
|
||||
def test_intraday_meteorology_supportive_heating_case():
|
||||
payload = _build_intraday_meteorology(
|
||||
{
|
||||
"local_time": "12:04",
|
||||
"temp_symbol": "°C",
|
||||
"current": {"temp": 38.2, "max_so_far": 38.2},
|
||||
"deb": {"prediction": 40.4},
|
||||
"probabilities": {"distribution": [{"value": 40, "probability": 0.42}]},
|
||||
"peak": {"first_h": 14, "last_h": 15, "status": "before"},
|
||||
"deviation_monitor": {"direction": "hot", "severity": "strong", "current_delta": 1.9},
|
||||
"vertical_profile_signal": {
|
||||
"heating_setup": "supportive",
|
||||
"summary_zh": "混合层偏深,仍支持午后继续升温。",
|
||||
},
|
||||
"taf": {"signal": {"available": True, "suppression_level": "low", "summary_zh": "TAF 暂未提示强云雨压温。"}},
|
||||
}
|
||||
)
|
||||
|
||||
assert "上修空间" in payload["headline"]
|
||||
assert "upside" in payload["headline_en"].lower()
|
||||
assert payload["confidence"] == "high"
|
||||
assert payload["base_case_bucket"] == "40°C"
|
||||
assert payload["next_observation_time"] == "12:30"
|
||||
assert any(item["direction"] == "support" for item in payload["signal_contributions"])
|
||||
assert all(item.get("summary_en") for item in payload["signal_contributions"])
|
||||
|
||||
|
||||
def test_intraday_meteorology_suppressed_cloud_rain_case():
|
||||
payload = _build_intraday_meteorology(
|
||||
{
|
||||
"local_time": "13:10",
|
||||
"temp_symbol": "°C",
|
||||
"current": {"temp": 36.0, "max_so_far": 36.5},
|
||||
"deb": {"prediction": 40.2},
|
||||
"probabilities": {"distribution": [{"value": 40, "probability": 0.35}]},
|
||||
"peak": {"first_h": 14, "last_h": 15, "status": "before"},
|
||||
"deviation_monitor": {"direction": "cold", "severity": "strong", "current_delta": -2.0},
|
||||
"vertical_profile_signal": {"heating_setup": "suppressed", "suppression_risk": "high"},
|
||||
"taf": {"signal": {"available": True, "suppression_level": "high", "disruption_level": "high"}},
|
||||
}
|
||||
)
|
||||
|
||||
assert "压制" in payload["headline"]
|
||||
assert "capping" in payload["headline_en"].lower()
|
||||
assert payload["confidence"] == "high"
|
||||
assert any("云雨" in rule for rule in payload["invalidation_rules"])
|
||||
assert any("cloud" in rule.lower() for rule in payload["invalidation_rules_en"])
|
||||
assert any(item["direction"] == "suppress" for item in payload["signal_contributions"])
|
||||
|
||||
|
||||
def test_intraday_meteorology_handles_sparse_observations():
|
||||
payload = _build_intraday_meteorology(
|
||||
{
|
||||
"local_time": "08:00",
|
||||
"temp_symbol": "°C",
|
||||
"current": {},
|
||||
"probabilities": {"distribution": []},
|
||||
"peak": {},
|
||||
"taf": {},
|
||||
"vertical_profile_signal": {},
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["confidence"] == "low"
|
||||
assert payload["next_observation_time"] == "08:30"
|
||||
assert payload["signal_contributions"][0]["label"] == "数据完整性"
|
||||
assert payload["signal_contributions"][0]["label_en"] == "Data completeness"
|
||||
|
||||
|
||||
def test_intraday_meteorology_past_peak_case():
|
||||
payload = _build_intraday_meteorology(
|
||||
{
|
||||
"local_time": "17:20",
|
||||
"temp_symbol": "°C",
|
||||
"current": {"temp": 33.0, "max_so_far": 39.0},
|
||||
"probabilities": {"distribution": [{"value": 39, "probability": 0.5}]},
|
||||
"peak": {"first_h": 13, "last_h": 15, "status": "past"},
|
||||
"deviation_monitor": {"direction": "normal", "severity": "normal", "current_delta": 0.1},
|
||||
}
|
||||
)
|
||||
|
||||
assert "峰值窗口已过" in payload["headline"]
|
||||
assert "passed" in payload["headline_en"].lower()
|
||||
assert payload["base_case_bucket"] == "39°C"
|
||||
assert any("最终高点" in rule for rule in payload["invalidation_rules"])
|
||||
assert any("final" in rule.lower() for rule in payload["invalidation_rules_en"])
|
||||
|
||||
@@ -134,7 +134,7 @@ def test_payment_runtime_endpoint_returns_shape():
|
||||
assert 'recent_audit_events' in payload
|
||||
|
||||
|
||||
def test_auth_me_auto_reconciles_missing_subscription(monkeypatch):
|
||||
def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch):
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
|
||||
def _bind_identity(request):
|
||||
@@ -147,11 +147,10 @@ def test_auth_me_auto_reconciles_missing_subscription(monkeypatch):
|
||||
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||
|
||||
calls = {"count": 0}
|
||||
reconcile_calls = {"count": 0}
|
||||
|
||||
def _latest_subscription(user_id, respect_requirement=False):
|
||||
calls["count"] += 1
|
||||
if calls["count"] == 1:
|
||||
return None
|
||||
return {
|
||||
"plan_code": "pro_monthly",
|
||||
"starts_at": "2026-03-22T00:00:00+00:00",
|
||||
@@ -164,10 +163,15 @@ def test_auth_me_auto_reconciles_missing_subscription(monkeypatch):
|
||||
_latest_subscription,
|
||||
)
|
||||
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", True)
|
||||
|
||||
def _reconcile_latest_intent(user_id):
|
||||
reconcile_calls["count"] += 1
|
||||
return {"ok": True, "action": "reconciled_confirmed_intent"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes.PAYMENT_CHECKOUT,
|
||||
"reconcile_latest_intent",
|
||||
lambda user_id: {"ok": True, "action": "reconciled_confirmed_intent"},
|
||||
_reconcile_latest_intent,
|
||||
)
|
||||
|
||||
response = client.get("/api/auth/me")
|
||||
@@ -176,6 +180,7 @@ def test_auth_me_auto_reconciles_missing_subscription(monkeypatch):
|
||||
payload = response.json()
|
||||
assert payload["subscription_active"] is True
|
||||
assert payload["subscription_plan_code"] == "pro_monthly"
|
||||
assert reconcile_calls["count"] == 0
|
||||
|
||||
|
||||
def test_ops_memberships_prefers_supabase_auth_email(monkeypatch):
|
||||
|
||||
+863
-23
File diff suppressed because it is too large
Load Diff
+499
-124
@@ -6,12 +6,13 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from loguru import logger
|
||||
|
||||
from src.analysis.deb_algorithm import load_history
|
||||
from src.analysis.probability_snapshot_archive import load_snapshot_rows_for_day
|
||||
from src.database.db_manager import DBManager
|
||||
from src.database.runtime_state import TrainingFeatureRecordRepository, TruthRecordRepository
|
||||
from src.analysis.settlement_rounding import apply_city_settlement
|
||||
from src.data_collection.country_networks import get_country_network_provider
|
||||
@@ -19,7 +20,9 @@ from src.data_collection.city_registry import ALIASES
|
||||
from src.utils.metrics import export_prometheus_metrics
|
||||
from web.analysis_service import (
|
||||
_analyze,
|
||||
_analyze_summary,
|
||||
_build_city_detail_payload,
|
||||
_build_city_market_scan_payload,
|
||||
_build_city_summary_payload,
|
||||
)
|
||||
from web.core import (
|
||||
@@ -53,6 +56,7 @@ from web.core import (
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
_CACHE_DB = DBManager()
|
||||
|
||||
_DEB_RECENT_LOOKBACK = 7
|
||||
_DEB_RECENT_MIN_SAMPLES = 3
|
||||
@@ -74,16 +78,293 @@ DEFAULT_PREWARM_CITIES = [
|
||||
"shanghai",
|
||||
"beijing",
|
||||
"shenzhen",
|
||||
"guangzhou",
|
||||
"wuhan",
|
||||
"chengdu",
|
||||
"chongqing",
|
||||
"hong kong",
|
||||
"taipei",
|
||||
"singapore",
|
||||
"tokyo",
|
||||
"seoul",
|
||||
"busan",
|
||||
"london",
|
||||
"paris",
|
||||
"madrid",
|
||||
]
|
||||
HISTORY_PREVIEW_DAY_LIMIT = 21
|
||||
ASIA_CORE_CITIES = [
|
||||
"hong kong",
|
||||
"taipei",
|
||||
"tokyo",
|
||||
"seoul",
|
||||
"busan",
|
||||
"shanghai",
|
||||
"beijing",
|
||||
"guangzhou",
|
||||
"shenzhen",
|
||||
"chongqing",
|
||||
"chengdu",
|
||||
"singapore",
|
||||
"kuala lumpur",
|
||||
"jakarta",
|
||||
]
|
||||
EUROPE_CORE_CITIES = [
|
||||
"istanbul",
|
||||
"ankara",
|
||||
"moscow",
|
||||
"tel aviv",
|
||||
"london",
|
||||
"paris",
|
||||
"madrid",
|
||||
"milan",
|
||||
"warsaw",
|
||||
"amsterdam",
|
||||
"helsinki",
|
||||
]
|
||||
US_CORE_CITIES = [
|
||||
"new york",
|
||||
"los angeles",
|
||||
"san francisco",
|
||||
"austin",
|
||||
"houston",
|
||||
"chicago",
|
||||
"dallas",
|
||||
"miami",
|
||||
"atlanta",
|
||||
"seattle",
|
||||
]
|
||||
CITY_SUMMARY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC", "180")))
|
||||
CITY_PANEL_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC", "300")))
|
||||
CITY_NEARBY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC", "480")))
|
||||
CITY_MARKET_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC", "900")))
|
||||
CITY_HISTORY_PREVIEW_CACHE_TTL_SEC = max(
|
||||
60,
|
||||
int(os.getenv("POLYWEATHER_CITY_HISTORY_PREVIEW_CACHE_TTL_SEC", "1800")),
|
||||
)
|
||||
CACHE_REFRESH_LOCK_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CACHE_REFRESH_LOCK_TTL_SEC", "120")))
|
||||
|
||||
|
||||
def _city_cache_is_fresh(entry: Optional[dict], ttl_sec: int) -> bool:
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
updated_at_ts = float(entry.get("updated_at_ts") or 0.0)
|
||||
if updated_at_ts <= 0:
|
||||
return False
|
||||
return (time.time() - updated_at_ts) < float(ttl_sec)
|
||||
|
||||
|
||||
def _refresh_city_summary_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
data = _analyze_summary(city, force_refresh=force_refresh)
|
||||
payload = _build_city_summary_payload(data)
|
||||
_CACHE_DB.set_city_cache(
|
||||
"summary",
|
||||
city,
|
||||
payload,
|
||||
version="v1",
|
||||
source_fingerprint=f"{city}:summary",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _refresh_city_panel_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="panel")
|
||||
_CACHE_DB.set_city_cache(
|
||||
"panel",
|
||||
city,
|
||||
payload,
|
||||
version="v1",
|
||||
source_fingerprint=f"{city}:panel",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _refresh_city_nearby_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="nearby")
|
||||
_CACHE_DB.set_city_cache(
|
||||
"nearby",
|
||||
city,
|
||||
payload,
|
||||
version="v1",
|
||||
source_fingerprint=f"{city}:nearby",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _refresh_city_market_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="market")
|
||||
_CACHE_DB.set_city_cache(
|
||||
"market",
|
||||
city,
|
||||
payload,
|
||||
version="v1",
|
||||
source_fingerprint=f"{city}:market",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _build_city_history_payload(city: str, include_records: bool = False) -> dict:
|
||||
source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower()
|
||||
truth_rows = _truth_record_repo.load_city(city)
|
||||
feature_rows = _training_feature_repo.load_city(city)
|
||||
|
||||
if not truth_rows and not feature_rows:
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
history_file = os.path.join(project_root, "data", "daily_records.json")
|
||||
data = load_history(history_file)
|
||||
city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {}
|
||||
else:
|
||||
all_dates = sorted(set(truth_rows.keys()) | set(feature_rows.keys()))
|
||||
city_data = {}
|
||||
for day in all_dates:
|
||||
record: dict[str, object] = {}
|
||||
truth = truth_rows.get(day) or {}
|
||||
features = feature_rows.get(day) or {}
|
||||
if truth.get("actual_high") is not None:
|
||||
record["actual_high"] = truth.get("actual_high")
|
||||
record["settlement_source"] = truth.get("settlement_source")
|
||||
record["settlement_station_code"] = truth.get("settlement_station_code")
|
||||
record["settlement_station_label"] = truth.get("settlement_station_label")
|
||||
record["truth_version"] = truth.get("truth_version")
|
||||
record["updated_by"] = truth.get("updated_by")
|
||||
record["truth_updated_at"] = truth.get("truth_updated_at")
|
||||
if isinstance(features, dict):
|
||||
if features.get("deb_prediction") is not None:
|
||||
record["deb_prediction"] = features.get("deb_prediction")
|
||||
if features.get("mu") is not None:
|
||||
record["mu"] = features.get("mu")
|
||||
if isinstance(features.get("forecasts"), dict):
|
||||
record["forecasts"] = features.get("forecasts")
|
||||
city_data[day] = record
|
||||
|
||||
if not city_data:
|
||||
return {
|
||||
"history": [],
|
||||
"mode": "full" if include_records else "preview",
|
||||
"has_more": False,
|
||||
"full_count": 0,
|
||||
"preview_count": 0,
|
||||
"settlement_source": source,
|
||||
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
|
||||
}
|
||||
|
||||
all_days = sorted(city_data.keys())
|
||||
selected_days = all_days if include_records else all_days[-HISTORY_PREVIEW_DAY_LIMIT:]
|
||||
out = []
|
||||
for day in selected_days:
|
||||
rec = city_data.get(day, {})
|
||||
if not isinstance(rec, dict):
|
||||
rec = {}
|
||||
|
||||
act = rec.get("actual_high")
|
||||
deb = rec.get("deb_prediction")
|
||||
mu = rec.get("mu")
|
||||
snapshots = load_snapshot_rows_for_day(city, day)
|
||||
peak_ref = _build_peak_minus_12h_reference(
|
||||
actual_high=act,
|
||||
snapshots=snapshots,
|
||||
)
|
||||
forecasts_raw = rec.get("forecasts", {}) or {}
|
||||
forecasts = {}
|
||||
if isinstance(forecasts_raw, dict):
|
||||
for model_name, model_value in forecasts_raw.items():
|
||||
if _is_excluded_model_name(str(model_name)):
|
||||
continue
|
||||
fv = _sf(model_value)
|
||||
forecasts[str(model_name)] = fv if fv is not None else None
|
||||
forecasts = _merge_missing_history_forecasts_from_snapshots(
|
||||
forecasts,
|
||||
snapshots,
|
||||
)
|
||||
mgm = forecasts.get("MGM")
|
||||
out.append(
|
||||
{
|
||||
"date": day,
|
||||
"actual": float(act) if act is not None else None,
|
||||
"deb": float(deb) if deb is not None else None,
|
||||
"mu": float(mu) if mu is not None else None,
|
||||
"mgm": float(mgm) if mgm is not None else None,
|
||||
"forecasts": forecasts,
|
||||
"settlement_source": rec.get("settlement_source"),
|
||||
"settlement_station_code": rec.get("settlement_station_code"),
|
||||
"settlement_station_label": rec.get("settlement_station_label"),
|
||||
"truth_version": rec.get("truth_version"),
|
||||
"updated_by": rec.get("updated_by"),
|
||||
"truth_updated_at": rec.get("truth_updated_at"),
|
||||
"actual_peak_time": peak_ref.get("actual_peak_time"),
|
||||
"deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"),
|
||||
"deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"),
|
||||
"deb_at_peak_minus_12h_error": peak_ref.get("deb_at_peak_minus_12h_error"),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"history": out,
|
||||
"mode": "full" if include_records else "preview",
|
||||
"has_more": len(all_days) > len(selected_days),
|
||||
"full_count": len(all_days),
|
||||
"preview_count": len(out),
|
||||
"settlement_source": source,
|
||||
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
|
||||
}
|
||||
|
||||
|
||||
def _refresh_city_history_preview_cache(city: str) -> dict:
|
||||
payload = _build_city_history_payload(city, include_records=False)
|
||||
_CACHE_DB.set_city_cache(
|
||||
"history_preview",
|
||||
city,
|
||||
payload,
|
||||
version="v1",
|
||||
source_fingerprint=f"{city}:history_preview",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _schedule_cache_refresh(
|
||||
background_tasks: BackgroundTasks,
|
||||
*,
|
||||
kind: str,
|
||||
city: str,
|
||||
force_refresh: bool = False,
|
||||
) -> bool:
|
||||
normalized_kind = str(kind or "").strip().lower()
|
||||
normalized_city = str(city or "").strip().lower()
|
||||
if normalized_kind not in {"summary", "panel", "nearby", "market", "history_preview"} or not normalized_city:
|
||||
return False
|
||||
cache_key = f"city:{normalized_kind}:{normalized_city}"
|
||||
owner = _CACHE_DB.acquire_cache_refresh_lock(
|
||||
cache_key,
|
||||
ttl_sec=CACHE_REFRESH_LOCK_TTL_SEC,
|
||||
)
|
||||
if not owner:
|
||||
return False
|
||||
|
||||
def _runner() -> None:
|
||||
try:
|
||||
if normalized_kind == "summary":
|
||||
_refresh_city_summary_cache(normalized_city, force_refresh=force_refresh)
|
||||
elif normalized_kind == "panel":
|
||||
_refresh_city_panel_cache(normalized_city, force_refresh=force_refresh)
|
||||
elif normalized_kind == "nearby":
|
||||
_refresh_city_nearby_cache(normalized_city, force_refresh=force_refresh)
|
||||
elif normalized_kind == "history_preview":
|
||||
_refresh_city_history_preview_cache(normalized_city)
|
||||
else:
|
||||
_refresh_city_market_cache(normalized_city, force_refresh=force_refresh)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"cache refresh failed kind={} city={} force_refresh={}: {}",
|
||||
normalized_kind,
|
||||
normalized_city,
|
||||
force_refresh,
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
_CACHE_DB.release_cache_refresh_lock(cache_key, owner)
|
||||
|
||||
background_tasks.add_task(_runner)
|
||||
return True
|
||||
|
||||
|
||||
def _parse_snapshot_dt(value: object) -> Optional[datetime]:
|
||||
@@ -211,6 +492,33 @@ def _normalize_city_list(raw: Optional[str]) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _select_priority_city_batches(client_timezone: Optional[str]) -> dict[str, object]:
|
||||
tz = str(client_timezone or "").strip()
|
||||
normalized = tz.lower()
|
||||
if normalized.startswith("america/"):
|
||||
primary = list(US_CORE_CITIES)
|
||||
secondary = []
|
||||
region = "america"
|
||||
elif normalized.startswith("europe/"):
|
||||
primary = list(EUROPE_CORE_CITIES)
|
||||
secondary = list(ASIA_CORE_CITIES)
|
||||
region = "europe"
|
||||
elif normalized.startswith("asia/") or normalized.startswith("australia/") or normalized.startswith("pacific/"):
|
||||
primary = list(ASIA_CORE_CITIES)
|
||||
secondary = list(EUROPE_CORE_CITIES)
|
||||
region = "asia"
|
||||
else:
|
||||
primary = list(ASIA_CORE_CITIES)
|
||||
secondary = list(EUROPE_CORE_CITIES)
|
||||
region = "default"
|
||||
return {
|
||||
"region": region,
|
||||
"timezone": tz or None,
|
||||
"primary": primary,
|
||||
"secondary": secondary,
|
||||
}
|
||||
|
||||
|
||||
def _history_file_path() -> str:
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
return os.path.join(project_root, "data", "daily_records.json")
|
||||
@@ -310,7 +618,7 @@ async def system_prewarm(
|
||||
for city in selected:
|
||||
city_started = time.perf_counter()
|
||||
try:
|
||||
data = _analyze(city, force_refresh=force_refresh)
|
||||
_refresh_city_summary_cache(city, force_refresh=force_refresh)
|
||||
entry: dict[str, object] = {
|
||||
"city": city,
|
||||
"summary": True,
|
||||
@@ -318,20 +626,11 @@ async def system_prewarm(
|
||||
}
|
||||
summary_ok += 1
|
||||
if include_detail:
|
||||
_build_city_summary_payload(data)
|
||||
_build_city_detail_payload(
|
||||
data,
|
||||
target_date=str(data.get("local_date") or "").strip() or None,
|
||||
)
|
||||
_refresh_city_panel_cache(city, force_refresh=force_refresh)
|
||||
entry["detail"] = True
|
||||
detail_ok += 1
|
||||
if include_market:
|
||||
_build_city_detail_payload(
|
||||
data,
|
||||
target_date=str(data.get("local_date") or "").strip() or None,
|
||||
)
|
||||
entry["market"] = True
|
||||
market_ok += 1
|
||||
entry["market"] = False
|
||||
warmed.append(entry)
|
||||
except Exception as exc:
|
||||
failed.append(
|
||||
@@ -358,6 +657,7 @@ async def system_prewarm(
|
||||
"warmed": warmed,
|
||||
"failed": failed,
|
||||
"summary_ok": summary_ok,
|
||||
"panel_ok": detail_ok,
|
||||
"detail_ok": detail_ok,
|
||||
"market_ok": market_ok,
|
||||
"failed_count": len(failed),
|
||||
@@ -422,112 +722,139 @@ async def list_cities(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/city/{name}")
|
||||
async def city_detail(request: Request, name: str, force_refresh: bool = False):
|
||||
async def city_detail(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
name: str,
|
||||
force_refresh: bool = False,
|
||||
depth: str = "panel",
|
||||
):
|
||||
_assert_entitlement(request)
|
||||
city = _normalize_city_or_404(name)
|
||||
return await run_in_threadpool(_analyze, city, force_refresh)
|
||||
normalized_depth = str(depth or "panel").strip().lower()
|
||||
if normalized_depth == "full":
|
||||
detail_mode = "full"
|
||||
elif normalized_depth == "market":
|
||||
detail_mode = "market"
|
||||
elif normalized_depth == "nearby":
|
||||
detail_mode = "nearby"
|
||||
else:
|
||||
detail_mode = "panel"
|
||||
if detail_mode == "panel":
|
||||
if force_refresh:
|
||||
return await run_in_threadpool(_refresh_city_panel_cache, city, True)
|
||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "panel", city)
|
||||
if cached_entry:
|
||||
if not _city_cache_is_fresh(cached_entry, CITY_PANEL_CACHE_TTL_SEC):
|
||||
_schedule_cache_refresh(background_tasks, kind="panel", city=city)
|
||||
return cached_entry.get("payload") or {}
|
||||
return await run_in_threadpool(_refresh_city_panel_cache, city, False)
|
||||
if detail_mode == "nearby":
|
||||
if force_refresh:
|
||||
return await run_in_threadpool(_refresh_city_nearby_cache, city, True)
|
||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "nearby", city)
|
||||
if cached_entry:
|
||||
if not _city_cache_is_fresh(cached_entry, CITY_NEARBY_CACHE_TTL_SEC):
|
||||
_schedule_cache_refresh(background_tasks, kind="nearby", city=city)
|
||||
return cached_entry.get("payload") or {}
|
||||
return await run_in_threadpool(_refresh_city_nearby_cache, city, False)
|
||||
if detail_mode == "market":
|
||||
if force_refresh:
|
||||
return await run_in_threadpool(_refresh_city_market_cache, city, True)
|
||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "market", city)
|
||||
if cached_entry:
|
||||
if not _city_cache_is_fresh(cached_entry, CITY_MARKET_CACHE_TTL_SEC):
|
||||
_schedule_cache_refresh(background_tasks, kind="market", city=city)
|
||||
return cached_entry.get("payload") or {}
|
||||
return await run_in_threadpool(_refresh_city_market_cache, city, False)
|
||||
return await run_in_threadpool(_analyze, city, force_refresh, False, detail_mode)
|
||||
|
||||
|
||||
@router.get("/api/history/{name}")
|
||||
async def city_history(request: Request, name: str):
|
||||
async def city_history(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
name: str,
|
||||
include_records: bool = False,
|
||||
):
|
||||
_assert_entitlement(request)
|
||||
city = _normalize_city_or_404(name)
|
||||
if include_records:
|
||||
return await run_in_threadpool(_build_city_history_payload, city, True)
|
||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "history_preview", city)
|
||||
if cached_entry:
|
||||
if not _city_cache_is_fresh(cached_entry, CITY_HISTORY_PREVIEW_CACHE_TTL_SEC):
|
||||
_schedule_cache_refresh(background_tasks, kind="history_preview", city=city)
|
||||
return cached_entry.get("payload") or {}
|
||||
return await run_in_threadpool(_refresh_city_history_preview_cache, city)
|
||||
|
||||
def _build_history_payload():
|
||||
source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower()
|
||||
truth_rows = _truth_record_repo.load_city(city)
|
||||
feature_rows = _training_feature_repo.load_city(city)
|
||||
|
||||
if not truth_rows and not feature_rows:
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
history_file = os.path.join(project_root, "data", "daily_records.json")
|
||||
data = load_history(history_file)
|
||||
city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {}
|
||||
else:
|
||||
all_dates = sorted(set(truth_rows.keys()) | set(feature_rows.keys()))
|
||||
city_data = {}
|
||||
for day in all_dates:
|
||||
record: dict[str, object] = {}
|
||||
truth = truth_rows.get(day) or {}
|
||||
features = feature_rows.get(day) or {}
|
||||
if truth.get("actual_high") is not None:
|
||||
record["actual_high"] = truth.get("actual_high")
|
||||
record["settlement_source"] = truth.get("settlement_source")
|
||||
record["settlement_station_code"] = truth.get("settlement_station_code")
|
||||
record["settlement_station_label"] = truth.get("settlement_station_label")
|
||||
record["truth_version"] = truth.get("truth_version")
|
||||
record["updated_by"] = truth.get("updated_by")
|
||||
record["truth_updated_at"] = truth.get("truth_updated_at")
|
||||
if isinstance(features, dict):
|
||||
if features.get("deb_prediction") is not None:
|
||||
record["deb_prediction"] = features.get("deb_prediction")
|
||||
if features.get("mu") is not None:
|
||||
record["mu"] = features.get("mu")
|
||||
if isinstance(features.get("forecasts"), dict):
|
||||
record["forecasts"] = features.get("forecasts")
|
||||
city_data[day] = record
|
||||
|
||||
if not city_data:
|
||||
return {
|
||||
"history": [],
|
||||
"settlement_source": source,
|
||||
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
|
||||
@router.get("/api/system/cache-status")
|
||||
async def system_cache_status(request: Request, cities: Optional[str] = None):
|
||||
_assert_entitlement(request)
|
||||
selected = _normalize_city_list(cities)
|
||||
if not selected:
|
||||
selected = list(DEFAULT_PREWARM_CITIES)
|
||||
kinds = {
|
||||
"summary": CITY_SUMMARY_CACHE_TTL_SEC,
|
||||
"panel": CITY_PANEL_CACHE_TTL_SEC,
|
||||
"nearby": CITY_NEARBY_CACHE_TTL_SEC,
|
||||
"market": CITY_MARKET_CACHE_TTL_SEC,
|
||||
"history_preview": CITY_HISTORY_PREVIEW_CACHE_TTL_SEC,
|
||||
}
|
||||
items = []
|
||||
for city in selected:
|
||||
row = {"city": city}
|
||||
for kind, ttl_sec in kinds.items():
|
||||
entry = _CACHE_DB.get_city_cache(kind, city)
|
||||
row[kind] = {
|
||||
"exists": bool(entry),
|
||||
"fresh": _city_cache_is_fresh(entry, ttl_sec),
|
||||
"updated_at": entry.get("updated_at") if entry else None,
|
||||
"age_sec": round(max(0.0, time.time() - float(entry.get("updated_at_ts") or 0.0)), 1)
|
||||
if entry
|
||||
else None,
|
||||
"ttl_sec": ttl_sec,
|
||||
}
|
||||
items.append(row)
|
||||
return {"cities": items}
|
||||
|
||||
out = []
|
||||
for day, rec in sorted(city_data.items()):
|
||||
if not isinstance(rec, dict):
|
||||
rec = {}
|
||||
|
||||
act = rec.get("actual_high")
|
||||
deb = rec.get("deb_prediction")
|
||||
mu = rec.get("mu")
|
||||
snapshots = load_snapshot_rows_for_day(city, day)
|
||||
peak_ref = _build_peak_minus_12h_reference(
|
||||
actual_high=act,
|
||||
snapshots=snapshots,
|
||||
)
|
||||
forecasts_raw = rec.get("forecasts", {}) or {}
|
||||
forecasts = {}
|
||||
if isinstance(forecasts_raw, dict):
|
||||
for model_name, model_value in forecasts_raw.items():
|
||||
if _is_excluded_model_name(str(model_name)):
|
||||
continue
|
||||
fv = _sf(model_value)
|
||||
forecasts[str(model_name)] = fv if fv is not None else None
|
||||
forecasts = _merge_missing_history_forecasts_from_snapshots(
|
||||
forecasts,
|
||||
snapshots,
|
||||
)
|
||||
mgm = forecasts.get("MGM")
|
||||
out.append(
|
||||
{
|
||||
"date": day,
|
||||
"actual": float(act) if act is not None else None,
|
||||
"deb": float(deb) if deb is not None else None,
|
||||
"mu": float(mu) if mu is not None else None,
|
||||
"mgm": float(mgm) if mgm is not None else None,
|
||||
"forecasts": forecasts,
|
||||
"settlement_source": rec.get("settlement_source"),
|
||||
"settlement_station_code": rec.get("settlement_station_code"),
|
||||
"settlement_station_label": rec.get("settlement_station_label"),
|
||||
"truth_version": rec.get("truth_version"),
|
||||
"updated_by": rec.get("updated_by"),
|
||||
"truth_updated_at": rec.get("truth_updated_at"),
|
||||
"actual_peak_time": peak_ref.get("actual_peak_time"),
|
||||
"deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"),
|
||||
"deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"),
|
||||
"deb_at_peak_minus_12h_error": peak_ref.get("deb_at_peak_minus_12h_error"),
|
||||
}
|
||||
)
|
||||
@router.post("/api/system/priority-warm")
|
||||
async def system_priority_warm(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
timezone: Optional[str] = None,
|
||||
):
|
||||
_assert_entitlement(request)
|
||||
batches = _select_priority_city_batches(timezone)
|
||||
primary = list(batches.get("primary") or [])
|
||||
secondary = list(batches.get("secondary") or [])
|
||||
|
||||
return {
|
||||
"history": out,
|
||||
"settlement_source": source,
|
||||
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
|
||||
}
|
||||
def _runner() -> None:
|
||||
for city in primary:
|
||||
try:
|
||||
_refresh_city_summary_cache(city, force_refresh=False)
|
||||
_refresh_city_panel_cache(city, force_refresh=False)
|
||||
_refresh_city_nearby_cache(city, force_refresh=False)
|
||||
_refresh_city_market_cache(city, force_refresh=False)
|
||||
except Exception as exc:
|
||||
logger.warning("priority warm primary failed city={} timezone={}: {}", city, timezone, exc)
|
||||
for city in secondary:
|
||||
try:
|
||||
_refresh_city_summary_cache(city, force_refresh=False)
|
||||
_refresh_city_panel_cache(city, force_refresh=False)
|
||||
except Exception as exc:
|
||||
logger.warning("priority warm secondary failed city={} timezone={}: {}", city, timezone, exc)
|
||||
|
||||
return await run_in_threadpool(_build_history_payload)
|
||||
background_tasks.add_task(_runner)
|
||||
return {
|
||||
"ok": True,
|
||||
"region": batches.get("region"),
|
||||
"timezone": batches.get("timezone"),
|
||||
"primary": primary,
|
||||
"secondary": secondary,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/auth/me")
|
||||
@@ -545,6 +872,9 @@ async def auth_me(request: Request):
|
||||
subscription_plan_code = None
|
||||
subscription_starts_at = None
|
||||
subscription_expires_at = None
|
||||
subscription_total_expires_at = None
|
||||
subscription_queued_days = 0
|
||||
subscription_queued_count = 0
|
||||
|
||||
if SUPABASE_ENTITLEMENT.enabled and user_id:
|
||||
try:
|
||||
@@ -557,36 +887,37 @@ async def auth_me(request: Request):
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
if (
|
||||
not latest_subscription
|
||||
and getattr(PAYMENT_CHECKOUT, "enabled", False)
|
||||
):
|
||||
try:
|
||||
PAYMENT_CHECKOUT.reconcile_latest_intent(user_id)
|
||||
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
except Exception:
|
||||
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
|
||||
latest_known_subscription = latest_subscription
|
||||
if not latest_known_subscription:
|
||||
latest_known_subscription = (
|
||||
SUPABASE_ENTITLEMENT.get_latest_subscription_any_status(user_id)
|
||||
)
|
||||
subscription_window = SUPABASE_ENTITLEMENT.get_subscription_window(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
subscription_active = bool(latest_subscription)
|
||||
if isinstance(latest_known_subscription, dict):
|
||||
if isinstance(latest_subscription, dict):
|
||||
subscription_plan_code = latest_subscription.get("plan_code")
|
||||
subscription_starts_at = latest_subscription.get("starts_at")
|
||||
subscription_expires_at = latest_subscription.get("expires_at")
|
||||
elif isinstance(latest_known_subscription, dict):
|
||||
subscription_plan_code = latest_known_subscription.get("plan_code")
|
||||
subscription_starts_at = latest_known_subscription.get("starts_at")
|
||||
subscription_expires_at = latest_known_subscription.get("expires_at")
|
||||
if isinstance(subscription_window, dict):
|
||||
subscription_total_expires_at = subscription_window.get("total_expires_at")
|
||||
subscription_queued_days = int(subscription_window.get("queued_days") or 0)
|
||||
subscription_queued_count = int(subscription_window.get("queued_count") or 0)
|
||||
except Exception:
|
||||
subscription_active = None
|
||||
subscription_plan_code = None
|
||||
subscription_starts_at = None
|
||||
subscription_expires_at = None
|
||||
subscription_total_expires_at = None
|
||||
subscription_queued_days = 0
|
||||
subscription_queued_count = 0
|
||||
|
||||
points = _resolve_auth_points(request)
|
||||
weekly_profile = _resolve_weekly_profile(request)
|
||||
@@ -613,6 +944,9 @@ async def auth_me(request: Request):
|
||||
"subscription_plan_code": subscription_plan_code,
|
||||
"subscription_starts_at": subscription_starts_at,
|
||||
"subscription_expires_at": subscription_expires_at,
|
||||
"subscription_total_expires_at": subscription_total_expires_at,
|
||||
"subscription_queued_days": subscription_queued_days,
|
||||
"subscription_queued_count": subscription_queued_count,
|
||||
}
|
||||
|
||||
|
||||
@@ -1016,10 +1350,21 @@ async def payment_reconcile_latest(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/city/{name}/summary")
|
||||
async def city_summary(request: Request, name: str, force_refresh: bool = False):
|
||||
async def city_summary(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
name: str,
|
||||
force_refresh: bool = False,
|
||||
):
|
||||
city = _normalize_city_or_404(name)
|
||||
data = await run_in_threadpool(_analyze, city, force_refresh, False)
|
||||
return await run_in_threadpool(_build_city_summary_payload, data)
|
||||
if force_refresh:
|
||||
return await run_in_threadpool(_refresh_city_summary_cache, city, True)
|
||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "summary", city)
|
||||
if cached_entry:
|
||||
if not _city_cache_is_fresh(cached_entry, CITY_SUMMARY_CACHE_TTL_SEC):
|
||||
_schedule_cache_refresh(background_tasks, kind="summary", city=city)
|
||||
return cached_entry.get("payload") or {}
|
||||
return await run_in_threadpool(_refresh_city_summary_cache, city, False)
|
||||
|
||||
|
||||
@router.get("/api/city/{name}/detail")
|
||||
@@ -1039,3 +1384,33 @@ async def city_detail_aggregate(
|
||||
market_slug,
|
||||
target_date,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/city/{name}/market-scan")
|
||||
async def city_market_scan(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
name: str,
|
||||
force_refresh: bool = False,
|
||||
market_slug: Optional[str] = None,
|
||||
target_date: Optional[str] = None,
|
||||
):
|
||||
_assert_entitlement(request)
|
||||
city = _normalize_city_or_404(name)
|
||||
if force_refresh:
|
||||
data = await run_in_threadpool(_refresh_city_market_cache, city, True)
|
||||
else:
|
||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "market", city)
|
||||
if cached_entry:
|
||||
if not _city_cache_is_fresh(cached_entry, CITY_MARKET_CACHE_TTL_SEC):
|
||||
_schedule_cache_refresh(background_tasks, kind="market", city=city)
|
||||
data = cached_entry.get("payload") or {}
|
||||
else:
|
||||
data = await run_in_threadpool(_refresh_city_market_cache, city, False)
|
||||
return await run_in_threadpool(
|
||||
_build_city_market_scan_payload,
|
||||
data,
|
||||
market_slug,
|
||||
target_date,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user