Compare commits
324 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27d4fc7c2b | |||
| fef37f6b0f | |||
| 1fa5645c0d | |||
| 24ef8ee8be | |||
| 799abd71b0 | |||
| 4316f42a68 | |||
| d9faff1bc3 | |||
| f108c9f7df | |||
| 951eb2f261 | |||
| 28ee268b35 | |||
| 6c4f9f8203 | |||
| 6f80d31852 | |||
| 7b1f34db27 | |||
| 0af5a84449 | |||
| f0904dbcc5 | |||
| 1d937728ee | |||
| 13f0713a98 | |||
| b16bfae991 | |||
| cdccd4a21a | |||
| c3da29c09c | |||
| 0bb3b573e1 | |||
| 22621f5d9c | |||
| eab6ec7cff | |||
| 420aa32a39 | |||
| 1b5b38e99d | |||
| 3f13257127 | |||
| fbd1e4ab16 | |||
| 7fe16bd584 | |||
| 08d7308486 | |||
| 9be12ad1d7 | |||
| 26674cf2b7 | |||
| 0f5c658ba8 | |||
| 2e44b40b86 | |||
| 5117a08057 | |||
| 960a06672e | |||
| cc08839b51 | |||
| bdc300bdd2 | |||
| e4a5c4c8d5 | |||
| f4335833b4 | |||
| e2c43351bd | |||
| 92a1a7de12 | |||
| b93a917f88 | |||
| a5b5711863 | |||
| 343c5c9c2f | |||
| 15a16f59fe | |||
| 7f9e774548 | |||
| 3e265d2764 | |||
| e32eff6e31 | |||
| 3f82fd2855 | |||
| bbdba2540c | |||
| 781c247952 | |||
| 37cd8b8166 | |||
| 90791b6070 | |||
| 95c0c452f1 | |||
| 603546f2f9 | |||
| f64d3aec83 | |||
| 93f4a95e80 | |||
| 59fb1266b9 | |||
| f8143f462f | |||
| da0b4198ac | |||
| ee40045b70 | |||
| 65688dffd9 | |||
| fdcc57339b | |||
| 9dc21a3088 | |||
| 62cf8f2c4d | |||
| c7c4f6c674 | |||
| 149452dcc0 | |||
| 9e9e5a56ba | |||
| 4f017d501b | |||
| 5fa69556eb | |||
| 384fd7d1e4 | |||
| b1b8d3439c | |||
| c9719cf577 | |||
| 07b2770e9d | |||
| 342ed77283 | |||
| 400916b023 | |||
| 3bbd8774a4 | |||
| a8462e188b | |||
| 6626b5472c | |||
| 79832dbc2b | |||
| c29b560401 | |||
| 8c8e242753 | |||
| 2508e4164f | |||
| e4db7d5dea | |||
| 2ee30ce551 | |||
| 3899012387 | |||
| 83824a772e | |||
| bbda6da39a | |||
| 4e399d5961 | |||
| 538de5cdd8 | |||
| 16404ccf71 | |||
| ee114fb5bf | |||
| 6c7b6de8f9 | |||
| c64fa87e05 | |||
| 0cc4f4f0d3 | |||
| 4a2db4727b | |||
| a5c667473e | |||
| cdfc785402 | |||
| 5b0506a663 | |||
| efb5e426b7 | |||
| d0e1bea941 | |||
| 346836f561 | |||
| 2b5d2cd801 | |||
| 6139960a9e | |||
| cbb704577a | |||
| 0c8ad54780 | |||
| 98bcfe2a21 | |||
| e32cfd8015 | |||
| 7237278f5a | |||
| 15a452dd49 | |||
| 2007ded86d | |||
| 1ab7db065c | |||
| 436afe121a | |||
| 88b355e9de | |||
| b146c637b6 | |||
| 7caae4dceb | |||
| 28db3ed428 | |||
| 306d701a2d | |||
| 54c93db483 | |||
| 961451aeff | |||
| a584da83f7 | |||
| 443540c27e | |||
| ab5c5cfb23 | |||
| 7de31ec429 | |||
| 7589fd74bf | |||
| fd210ac730 | |||
| 2b0e6a6f67 | |||
| b1ce479909 | |||
| 6d20d51bb9 | |||
| 4e0ddfa771 | |||
| b866ebf6e3 | |||
| afffbb852b | |||
| 69ea686750 | |||
| b7a72bd9ff | |||
| 77f2b6ac07 | |||
| 1dfdd18421 | |||
| 67e691ed95 | |||
| 1461d02488 | |||
| 54acfc05aa | |||
| bbc5dbb8d6 | |||
| 29f6542a72 | |||
| 05184cb2ad | |||
| b365155622 | |||
| fc72b3539d | |||
| 5796d380c5 | |||
| 58a09fe6fe | |||
| 4ac690228e | |||
| 90822c81d4 | |||
| 5e83a5cca3 | |||
| 578e50b2da | |||
| 5ac5caf3ef | |||
| 7adec4e17c | |||
| 354d55f752 | |||
| 6feb4ff489 | |||
| fcaa9d5c7f | |||
| b1102400f6 | |||
| 5e31bc9ea3 | |||
| ff7d77ef06 | |||
| ce22752ccc | |||
| 8a091fd7cc | |||
| 06b1e1b9fe | |||
| 21a42af01c | |||
| ace9c71743 | |||
| 7cdd6102c6 | |||
| f8b17d16ea | |||
| e2c51f5125 | |||
| 60785471e2 | |||
| d2b27b1020 | |||
| 7171192ea6 | |||
| e8a39000b9 | |||
| 4519af0c6c | |||
| 3cb5d2e8fc | |||
| 2ba680954c | |||
| 33385c3dda | |||
| 3c950956e3 | |||
| 2eb46853bf | |||
| c795d90993 | |||
| 7fe89e097b | |||
| bddcd434da | |||
| 6aa3a7dda8 | |||
| dd720582d5 | |||
| adf2924a1c | |||
| e662ef7d3b | |||
| 0425c237b1 | |||
| 13cd176449 | |||
| 49d883e3a2 | |||
| 75c2baf1d1 | |||
| b11836a378 | |||
| f7c5a2f443 | |||
| 290dd2b3cf | |||
| ac03fb74be | |||
| 4413b31396 | |||
| a19925fe7a | |||
| 8959faac91 | |||
| e7e52c0da7 | |||
| 345c56c4f4 | |||
| e4b8d71653 | |||
| ddf909f690 | |||
| 98d36a9174 | |||
| a0b8a3595c | |||
| 7225b8bcc7 | |||
| 43749fff7c | |||
| 6b76290cff | |||
| 48619830a5 | |||
| 74e35b990b | |||
| f6771247a6 | |||
| 3196552c78 | |||
| 03dcb4329b | |||
| 1c84893bed | |||
| 25ab512371 | |||
| 2a19a23e4d | |||
| 6e3bc1ce75 | |||
| 5e88e1480a | |||
| e4cc5b4263 | |||
| dbf10253a8 | |||
| da9b0b36f7 | |||
| f049a69ee3 | |||
| b33a741385 | |||
| 69f24ddc08 | |||
| f0fcb1e379 | |||
| 5c209ceb75 | |||
| 15a2fb91d1 | |||
| f2547e11b6 | |||
| fef3097c36 | |||
| 5057892255 | |||
| 233112cf0a | |||
| 60f83d0905 | |||
| 1ae9b55509 | |||
| ebaa57b5d1 | |||
| 9ac0a13937 | |||
| 508c322c8b | |||
| 5a6d9eeda4 | |||
| 7345b70958 | |||
| 5d2ed68cdf | |||
| 7510e0a1e0 | |||
| 217414408c | |||
| 93f7a852a8 | |||
| 90c998f06d | |||
| 54544e40b9 | |||
| 609fc8914a | |||
| 6a229a592b | |||
| 1ebd07ca31 | |||
| 81604bfe8a | |||
| e7a5347907 | |||
| fd7ec30e58 | |||
| 9bff3b0683 | |||
| 314cad82e6 | |||
| 1c57a6f56e | |||
| 530080a75d | |||
| 6d1f77cac1 | |||
| 2397a7569b | |||
| c1fb52551d | |||
| d1f4a8b236 | |||
| 4bd531bbc9 | |||
| 64f8b37014 | |||
| 02e35a3a5f | |||
| be61c39795 | |||
| af2561d019 | |||
| 4bd97cc5a5 | |||
| db2f22509b | |||
| f27970f157 | |||
| 3d9d4dcd64 | |||
| 786765e48b | |||
| cd0a5d519f | |||
| edf2a37976 | |||
| 7bde49bd5e | |||
| 80acade251 | |||
| ac5c3a2c76 | |||
| 08217deefb | |||
| ac9e537070 | |||
| e780830f76 | |||
| aa98623cab | |||
| f00360ed56 | |||
| e798a4f33a | |||
| 50a2202930 | |||
| c3958c29c7 | |||
| 663d6be506 | |||
| 2de083d53a | |||
| 04fda60fb4 | |||
| c031d9caeb | |||
| 9ba0890ca9 | |||
| fb630b47cb | |||
| 5b7a257087 | |||
| 534eef4197 | |||
| 25f11912dd | |||
| e201856700 | |||
| 74232907a9 | |||
| 024df2dc5b | |||
| 9531cc4b64 | |||
| 990182e089 | |||
| 120f4b1261 | |||
| f2e0940282 | |||
| 3c3ffa1248 | |||
| 18a525af88 | |||
| c0d914eb88 | |||
| 0aaa09370b | |||
| aa3546e2a9 | |||
| 003aff98d6 | |||
| 1e541433e5 | |||
| 0f51780566 | |||
| a5948a35d4 | |||
| 5c3de0dbe0 | |||
| 31ee147734 | |||
| 54c4a26162 | |||
| f7b649bb0a | |||
| d5bbc43b52 | |||
| e62a36e0bc | |||
| bd839d0cbe | |||
| e3bd90d186 | |||
| 9cc9ab93c4 | |||
| 0a869459c4 | |||
| 987aec2fa6 | |||
| f4fea03f35 | |||
| c582015163 | |||
| 27fe6ea494 | |||
| 0877efe0c3 | |||
| 20d3e940e0 | |||
| c156809231 | |||
| be651cd1d7 | |||
| 22082b9a83 | |||
| 0c976ac846 | |||
| 47ff575588 | |||
| b4d38590e1 | |||
| f065b67605 |
@@ -0,0 +1,31 @@
|
||||
.git
|
||||
.github
|
||||
.vscode
|
||||
.agent
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.secrets.example
|
||||
|
||||
venv
|
||||
.venv
|
||||
.uv-cache
|
||||
.uv-python
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
__pycache__
|
||||
|
||||
.npm-cache
|
||||
frontend/node_modules
|
||||
|
||||
artifacts
|
||||
notebooks
|
||||
|
||||
bot.log
|
||||
*.log
|
||||
|
||||
extension.zip
|
||||
tmp_*.js
|
||||
tmp_*.html
|
||||
@@ -1,36 +1,147 @@
|
||||
# Telegram Bot
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
TELEGRAM_CHAT_ID=your_chat_id_here
|
||||
# PolyWeather backend/bot minimal reproducible config
|
||||
# Full configuration guide:
|
||||
# docs/CONFIGURATION_ZH.md
|
||||
# Sensitive-only template:
|
||||
# .env.secrets.example
|
||||
|
||||
########################################
|
||||
# 1) Runtime paths and base behavior
|
||||
########################################
|
||||
ENV=production
|
||||
LOG_LEVEL=INFO
|
||||
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
|
||||
# Optional: host user/group mapping for Docker on Linux.
|
||||
# Windows / macOS can usually keep the defaults.
|
||||
UID=1000
|
||||
GID=1000
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
POLYWEATHER_PROMETHEUS_PORT=9090
|
||||
POLYWEATHER_ALERTMANAGER_PORT=9093
|
||||
POLYWEATHER_ALERT_RELAY_PORT=9099
|
||||
POLYWEATHER_GRAFANA_PORT=3001
|
||||
POLYWEATHER_GRAFANA_ADMIN_USER=admin
|
||||
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather
|
||||
|
||||
########################################
|
||||
# 2) Telegram bot minimal
|
||||
########################################
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
TELEGRAM_CHAT_IDS=
|
||||
TELEGRAM_QUERY_TOPIC_CHAT_ID=
|
||||
TELEGRAM_QUERY_TOPIC_ID=
|
||||
TELEGRAM_QUERY_TOPIC_MAP=
|
||||
POLYWEATHER_BOT_GROUP_INVITE_URL=
|
||||
|
||||
########################################
|
||||
# 3) Weather + cache
|
||||
########################################
|
||||
OPEN_METEO_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=7200
|
||||
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
|
||||
METAR_CACHE_TTL_SEC=600
|
||||
METEOBLUE_CACHE_TTL_SEC=7200
|
||||
POLYWEATHER_LGBM_ENABLED=false
|
||||
POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
|
||||
POLYWEATHER_LGBM_SCHEMA_PATH=/app/artifacts/models/lgbm_daily_high_schema.json
|
||||
POLYWEATHER_LGBM_MIN_HISTORY_POINTS=3
|
||||
|
||||
########################################
|
||||
# 4) Auth / entitlement
|
||||
########################################
|
||||
POLYWEATHER_AUTH_ENABLED=false
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false
|
||||
POLYWEATHER_REQUIRE_ENTITLEMENT=false
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
SUPABASE_AUTH_CACHE_TTL_SEC=30
|
||||
SUPABASE_SUB_CACHE_TTL_SEC=60
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
########################################
|
||||
# 5) Alerts / operations
|
||||
########################################
|
||||
TELEGRAM_ALERT_PUSH_ENABLED=true
|
||||
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
|
||||
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
|
||||
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
||||
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
||||
# Mispricing radar: skip push when YES buy price is above this cap (10c = 0.10)
|
||||
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
|
||||
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
|
||||
TELEGRAM_ALERT_MISPRICING_ONLY=true
|
||||
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
|
||||
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||
|
||||
# AI
|
||||
GROQ_API_KEY=your_groq_api_key_here
|
||||
########################################
|
||||
# 6) Frontend-facing shared values
|
||||
########################################
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
|
||||
# Open-Meteo (forecast data changes ~hourly, no need to refresh more often)
|
||||
OPEN_METEO_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=7200
|
||||
########################################
|
||||
# 7) Optional modules
|
||||
########################################
|
||||
|
||||
# Proxy Setting (optional)
|
||||
HTTPS_PROXY=http://127.0.0.1:7890
|
||||
HTTP_PROXY=http://127.0.0.1:7890
|
||||
# Optional Groq commentary rewrite for intraday structure cards
|
||||
POLYWEATHER_GROQ_COMMENTARY_ENABLED=false
|
||||
GROQ_API_KEY=
|
||||
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
|
||||
# Other Settings
|
||||
LOG_LEVEL=INFO
|
||||
ENV=production
|
||||
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
|
||||
# Backend entitlement guard (for /api/cities, /api/city/*, /api/history/*)
|
||||
POLYWEATHER_REQUIRE_ENTITLEMENT=false
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
# Weekly reward / leaderboard
|
||||
POLYWEATHER_WEEKLY_REWARD_ENABLED=true
|
||||
POLYWEATHER_WEEKLY_REWARD_TIMEZONE=Asia/Shanghai
|
||||
POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY=1
|
||||
POLYWEATHER_WEEKLY_REWARD_SETTLE_HOUR=0
|
||||
POLYWEATHER_WEEKLY_REWARD_SETTLE_MINUTE=5
|
||||
POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC=300
|
||||
POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC=10
|
||||
POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED=true
|
||||
|
||||
# Polymarket P0 Read-Only Market Layer
|
||||
# Group message points
|
||||
POLYWEATHER_BOT_MESSAGE_POINTS=4
|
||||
POLYWEATHER_BOT_MESSAGE_DAILY_CAP=40
|
||||
POLYWEATHER_BOT_MESSAGE_MIN_LENGTH=3
|
||||
POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC=30
|
||||
POLYWEATHER_BOT_CITY_QUERY_COST=1
|
||||
POLYWEATHER_BOT_DEB_QUERY_COST=1
|
||||
|
||||
# Payments
|
||||
POLYWEATHER_PAYMENT_ENABLED=false
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
|
||||
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
|
||||
POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10
|
||||
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
|
||||
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
|
||||
POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
|
||||
# Polymarket market scan
|
||||
POLYMARKET_MARKET_SCAN_ENABLED=true
|
||||
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
|
||||
POLYMARKET_CLOB_URL=https://clob.polymarket.com
|
||||
@@ -43,10 +154,10 @@ POLYMARKET_DISCOVERY_LIMIT=200
|
||||
POLYMARKET_SIGNAL_MIN_LIQUIDITY=500
|
||||
POLYMARKET_SIGNAL_EDGE_PCT=2
|
||||
|
||||
# Polygon Wallet Watcher (Single Chain P0)
|
||||
# Polygon watcher
|
||||
POLYGON_WALLET_WATCH_ENABLED=false
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
POLYGON_WALLET_WATCH_ADDRESSES=0x0000000000000000000000000000000000000000
|
||||
POLYGON_WALLET_WATCH_ADDRESSES=
|
||||
POLYGON_WALLET_WATCH_INTERVAL_SEC=8
|
||||
POLYGON_WALLET_WATCH_CONFIRMATIONS=2
|
||||
POLYGON_WALLET_WATCH_MAX_BLOCKS_PER_CYCLE=30
|
||||
@@ -56,12 +167,16 @@ POLYGON_WALLET_WATCH_TX_BASE=https://polygonscan.com/tx
|
||||
POLYGON_WALLET_WATCH_ADDR_BASE=https://polygonscan.com/address
|
||||
POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
|
||||
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
|
||||
# Optional custom Polymarket contracts, format: LABEL:0x...,LABEL2:0x...
|
||||
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
|
||||
|
||||
# Polymarket Wallet Activity Watcher (all markets, not weather-only)
|
||||
# Polymarket wallet activity (retired; replaced by market monitor digests + critical alerts)
|
||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
POLYMARKET_WALLET_ACTIVITY_USERS=0x0000000000000000000000000000000000000000
|
||||
POLYMARKET_WALLET_ACTIVITY_USERS=
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_IDS=
|
||||
POLYMARKET_WALLET_ACTIVITY_TOPIC_CHAT_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_TOPIC_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_USER_ALIASES=
|
||||
POLYMARKET_WALLET_ACTIVITY_DATA_API_URL=https://data-api.polymarket.com
|
||||
POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC=20
|
||||
POLYMARKET_WALLET_ACTIVITY_TIMEOUT_SEC=10
|
||||
@@ -74,7 +189,16 @@ POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC=20
|
||||
POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG=5
|
||||
POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED=false
|
||||
POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT=false
|
||||
POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW=true
|
||||
POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC=30
|
||||
POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC=120
|
||||
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01
|
||||
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX=0.99
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_POSITION_VALUE_USD=0
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_VALUE_EXEMPT_USERS=
|
||||
|
||||
########################################
|
||||
# 8) Optional proxies
|
||||
########################################
|
||||
HTTPS_PROXY=
|
||||
HTTP_PROXY=
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# PolyWeather secrets-only template
|
||||
# Copy the required lines into your real `.env` / platform secret manager.
|
||||
# Never commit actual values.
|
||||
|
||||
########################################
|
||||
# Telegram
|
||||
########################################
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
|
||||
########################################
|
||||
# Supabase
|
||||
########################################
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
########################################
|
||||
# Entitlement / dashboard
|
||||
########################################
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
########################################
|
||||
# Meteoblue / third-party APIs
|
||||
########################################
|
||||
METEOBLUE_API_KEY=
|
||||
|
||||
########################################
|
||||
# Wallet / payments
|
||||
########################################
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
|
||||
########################################
|
||||
# Optional exchange / market secrets
|
||||
########################################
|
||||
POLYMARKET_API_KEY=
|
||||
POLYMARKET_SECRET_KEY=
|
||||
POLYMARKET_PASSPHRASE=
|
||||
POLYMARKET_WALLET_ADDRESS=
|
||||
@@ -0,0 +1,61 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
python-quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
- name: Ruff
|
||||
run: python -m ruff check .
|
||||
|
||||
- name: Pytest
|
||||
run: python -m pytest
|
||||
|
||||
frontend-quality:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t polyweather-ci .
|
||||
@@ -2,6 +2,9 @@
|
||||
.env
|
||||
|
||||
# Data and Logs
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
data/*.db.*
|
||||
data/*.json
|
||||
data/logs/
|
||||
data/historical/
|
||||
@@ -30,3 +33,12 @@ Thumbs.db
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
frontend/.vercel/
|
||||
frontend/*.tsbuildinfo
|
||||
|
||||
.npm-cache/
|
||||
.env.local
|
||||
.vercel/
|
||||
|
||||
# Browser extension build artifacts
|
||||
/extension.zip
|
||||
/extension-*.zip
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"css.validate": false,
|
||||
"scss.validate": false,
|
||||
"less.validate": false
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## 1.5.1 - 2026-03-23
|
||||
|
||||
- `/ops` 页面增加管理员守卫,前后端双层限制管理员访问
|
||||
- `/ops` 支持会员列表、支付异常单、用户查询、周榜和手动补分
|
||||
- `/ops` 支付异常单支持按原因筛选、标记已处理,并补充支付异常审计视图
|
||||
- 会员列表支持按 `user_id` 去重,并优先回补 Supabase Auth 邮箱/注册时间
|
||||
- 新增按邮箱补跑订阅恢复脚本 `scripts/reconcile_subscription_by_email.py`
|
||||
- 支付确认失败(如 `receiver_mismatch`)现在会明确落 `failed`,并写入 SQLite 审计事件
|
||||
- 支付前强制重新拉取 `/api/payments/config`,并校验最新地址、允许域名和当前支付上下文
|
||||
- 浏览器钱包选择补齐 EIP-6963 发现、稳定去重和绑定后账户状态即时刷新
|
||||
- 城市详情页新增 `官方参考 / Official Sources` 区块,覆盖主要城市的官方机构/机场/METAR 链接
|
||||
- “今日日内分析”结构解读改为后端同源动态短评,并统一网页与 Bot 解释口径
|
||||
- 台北主结算源切换到 `NOAA RCTP`,按最终质控后的最高整度摄氏值展示和说明
|
||||
- 浏览器插件同步台北 `NOAA RCTP` 结算参考标签和说明
|
||||
- `/ops` 手机端收口为卡片化视图,保留桌面表格
|
||||
- 账户中心补充本周积分显示,`weekly_points` 与周排行同屏展示
|
||||
- Dashboard 历史对账补充“峰值前 12 小时 DEB 参考(近似)”卡片
|
||||
- 历史图不再错误混入 `settlement_history` 实测,历史样本仅按可比较样本统计
|
||||
- 新增 `scripts/backfill_recent_daily_actuals_from_metar.py`,支持为缺失 `daily_records` 的 METAR 城市补最近 14 天 `actual_high`
|
||||
- 历史接口对新接入的 METAR 城市增加自动 bootstrap,避免新增城市历史页整块空白
|
||||
- 香港历史/日内展示继续坚持 `HKO` 官方口径,不再 fallback 到 `VHHH METAR` 连续线
|
||||
- 香港 HKO 当天官方点位不再落单独 JSON,统一写入 runtime state
|
||||
- 今日日内结构信号按城市本地时间与峰值窗口分析,不再只看固定下午时段
|
||||
- 新增高空结构信号:冲高环境、压温风险、午后扰动、冲高效率,并提供中英文说明
|
||||
- 新增交易动作卡:结合高空结构、市场拥挤度与 `edge_percent` 输出 `偏暖侧 / 偏谨慎 / 先观察`
|
||||
- 非香港机场城市新增 `TAF` 接入,支持 `FM / TEMPO / BECMG / PROB30/40` 时间片解析
|
||||
- 温度走势图新增 `TAF 时段 / TAF Timing` 标记,并在 tooltip 中显示对应时段摘要
|
||||
- `TAF` 信号与 `market_signal / edge_percent` 联动进入交易动作,提示更贴近交易语境
|
||||
- `TAF` 展示词已改成普通用户可读版本:`基础时段 / 明确切换 / 临时波动 / 逐步转变`
|
||||
- 日内结构总摘要补充“TAF 未新增压温不等于继续升温”的解释,避免误读
|
||||
- 浏览器插件多日预报改为 `DEB` 优先,基础判断卡补充方向、置信度与原因,并统一引流到主站首页
|
||||
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.5.0 - 2026-03-21
|
||||
|
||||
- 运行态状态与缓存支持 SQLite 渐进迁移,新增 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`
|
||||
- 新增 `/healthz`、`/api/system/status`、`/metrics`
|
||||
- 新增支付运行态接口 `/api/payments/runtime`
|
||||
- 支付侧新增 SQLite 审计事件、事件重放脚本与多 RPC 容灾支持
|
||||
- 新增支付静态审计脚本与 V2 合约升级草案
|
||||
- 统一周积分显示口径,`/top` 中“我的状态”改为累计发言/本周排名/本周积分
|
||||
- 文档同步更新为 2026-03-20 当前状态
|
||||
|
||||
## 1.4.0 - 2026-03-14
|
||||
|
||||
- 统一收费阶段产品口径,发布 PolyWeather Pro `v1.4.0`
|
||||
- 前端交付覆盖账户、支付、权限展示与缓存策略
|
||||
- 支付链路支持 intent -> submit -> confirm 与自动补单
|
||||
- 文档统一切换到单一版本源管理
|
||||
@@ -6,6 +6,8 @@ WORKDIR /app
|
||||
# 设置环境变量
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_ROOT_USER_ACTION=ignore \
|
||||
TZ=UTC
|
||||
|
||||
# 安装系统依赖 (如果有必要的包可以取消注释)
|
||||
@@ -15,7 +17,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安装 Python 依赖
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir --prefer-binary -r requirements.txt
|
||||
|
||||
# 复制项目代码
|
||||
COPY . .
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# 前端交付与重构报告(v1.5.1)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
## 1. 报告目的
|
||||
|
||||
说明当前线上前端(`frontend/`)在收费阶段的实际交付状态。
|
||||
|
||||
## 2. 当前前端架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B["Browser"] --> N["Next.js App Router (Vercel)"]
|
||||
N --> RH["Route Handlers /api/*"]
|
||||
RH --> F["FastAPI (VPS)"]
|
||||
|
||||
N --> STORE["Dashboard Store"]
|
||||
STORE --> MAP["MapCanvas"]
|
||||
STORE --> SIDEBAR["CitySidebar"]
|
||||
STORE --> PANEL["DetailPanel + Modal"]
|
||||
STORE --> ACCOUNT["Account Center + Pro Overlay"]
|
||||
```
|
||||
|
||||
## 3. 已落地能力
|
||||
|
||||
### 3.1 信息架构与交互
|
||||
|
||||
- 风险分组侧栏折叠(持久化)。
|
||||
- 选中城市状态持久化。
|
||||
- 今日分析、历史对账、未来日期分析联动。
|
||||
|
||||
### 3.2 收费相关
|
||||
|
||||
- 账户中心(登录态、积分、订阅状态、钱包管理)。
|
||||
- Pro 解锁浮层(套餐、积分抵扣、FAQ、社群入口)。
|
||||
- 钱包绑定:浏览器扩展钱包 + WalletConnect 扫码。
|
||||
- 支付流程:create intent -> submit -> confirm。
|
||||
- `confirm pending` 时自动轮询 intent 状态,确认后自动刷新订阅态。
|
||||
|
||||
### 3.3 缓存与性能
|
||||
|
||||
- BFF `ETag/304`:`cities` / `summary` / `history`。
|
||||
- `summary?force_refresh=true` => `no-store`。
|
||||
- `sessionStorage` + in-flight 去重。
|
||||
- `localStorage`:选中城市、侧栏折叠状态。
|
||||
|
||||
### 3.4 可访问性与稳定性
|
||||
|
||||
- 详情面板 `inert + blur` 焦点冲突修复。
|
||||
- 关键支付错误文案标准化(用户取消、gas 不足、pending)。
|
||||
|
||||
## 4. 当前明确未做
|
||||
|
||||
- 离线能力(Service Worker / IndexedDB)
|
||||
- 前端级财务报表与退款后台(后端/运营侧)
|
||||
|
||||
## 5. 验收建议
|
||||
|
||||
### 5.1 前端构建
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 5.2 缓存验收
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
```
|
||||
|
||||
### 5.3 支付验收
|
||||
|
||||
- 绑定钱包
|
||||
- 创建 intent
|
||||
- 发交易
|
||||
- 验证 `intent` 状态从 `submitted -> confirmed`
|
||||
- 校验账户页订阅状态更新
|
||||
|
||||
## 6. 结论
|
||||
|
||||
前端已具备收费阶段的核心能力(账户、支付、权限展示、状态回收),可支持持续商业迭代。
|
||||
@@ -1,21 +1,661 @@
|
||||
MIT License
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (c) 2026 Yuanzhen Yang (yangyuan-zhen)
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -3,108 +3,89 @@
|
||||
Production weather-intelligence stack for temperature settlement markets.
|
||||
|
||||
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
中文说明: [README_ZH.md](README_ZH.md)
|
||||
|
||||
## What This Project Does
|
||||
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday signals, TAF, settlement sources, history, and extension).
|
||||
|
||||
- Aggregates weather observations and forecasts for monitored cities.
|
||||
- Blends multi-model forecasts with DEB (Dynamic Error Balancing).
|
||||
- Computes settlement-oriented probability buckets (mu-centered distribution).
|
||||
- Maps model view to Polymarket read-only market data for mispricing/risk scan.
|
||||
- Delivers the same core logic to web dashboard and Telegram bot.
|
||||
## Product Screenshots
|
||||
|
||||
## Overview Diagram
|
||||
### Global Dashboard
|
||||
|
||||

|
||||
|
||||
### City Analysis (Ankara)
|
||||
|
||||

|
||||
|
||||
## Product Status (2026-03-24)
|
||||
|
||||
- Subscription live: `Pro Monthly 5 USDC`.
|
||||
- Points redemption live: `500 points = 1 USDC`, max `3 USDC` off.
|
||||
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e).
|
||||
- Auto-reconciliation live: event listener + periodic confirm loop.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
|
||||
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
|
||||
- Runtime state, cache, and core offline training/backfill flows now use SQLite as the primary path; legacy JSON/JSONL files remain only for migration, export, and explicit fallback input.
|
||||
- EMOS/CRPS pipeline is integrated in `shadow` mode with rollout gating.
|
||||
- Intraday structural signal is now peak-window aware and bilingual (`zh-CN` / `en-US`).
|
||||
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
|
||||
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
||||
- 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.
|
||||
|
||||
## License & Commercial Boundary
|
||||
|
||||
This repository is licensed under **GNU AGPL-3.0 only** from `2026-03-30` onward.
|
||||
|
||||
- Public in repo: weather aggregation, core analysis, dashboard, bot baseline, and standard payment flow.
|
||||
- Not included in this repository: private production data, internal operating thresholds, commercial risk rules, pricing strategy details, and growth tooling.
|
||||
- Trademark, brand, domain, production databases, and hosted-service operations are **not** granted by the code license.
|
||||
|
||||
See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- Aggregates observations and forecasts for 30 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.
|
||||
- Reuses one analysis core across web dashboard and Telegram bot.
|
||||
- Adds payment audit trails, replay tooling, and incident visibility in ops.
|
||||
- Adds peak-window-oriented intraday structure cards for surface + upper-air analysis.
|
||||
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
|
||||
|
||||
## Reference Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["PolyWeather Pro"]
|
||||
flowchart LR
|
||||
U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Vercel)"]
|
||||
U --> BOT["Telegram Bot (VPS)"]
|
||||
FE --> API["FastAPI /web/app.py"]
|
||||
BOT --> API
|
||||
|
||||
subgraph DL["Data Layer"]
|
||||
DL1["METAR (Aviation Weather / METAR)"]
|
||||
DL2["MGM (Turkey MGM)"]
|
||||
DL3["Station 17130 (Ankara Center 17130)"]
|
||||
DL4["Open-Meteo"]
|
||||
DL5["weather.gov (US cities)"]
|
||||
DL6["Polymarket (P0 Read-only)"]
|
||||
end
|
||||
API --> WX["Weather Collector"]
|
||||
WX --> METAR["Aviation Weather (METAR)"]
|
||||
WX --> TAF["Aviation Weather (TAF)"]
|
||||
WX --> MGM["MGM (Turkey station network)"]
|
||||
WX --> OM["Open-Meteo"]
|
||||
WX --> HKO["HKO / NOAA / Official settlement sources"]
|
||||
|
||||
subgraph AL["Analysis Layer"]
|
||||
AL1["DEB (Dynamic Error Balancing)"]
|
||||
AL2["Probability Engine (mu + buckets)"]
|
||||
AL3["Trend Engine"]
|
||||
AL4["Risk Profiles"]
|
||||
AL5["Mispricing Radar"]
|
||||
end
|
||||
|
||||
subgraph DEL["Delivery Layer"]
|
||||
DEL1["FastAPI"]
|
||||
DEL2["Next.js Dashboard"]
|
||||
DEL3["Telegram Bot"]
|
||||
DEL4["Alert Push"]
|
||||
end
|
||||
|
||||
subgraph OL["Ops Layer"]
|
||||
OL1["Docker Compose (VPS backend + bot)"]
|
||||
OL2["Vercel (frontend)"]
|
||||
OL3["Cache + force_refresh"]
|
||||
OL4["Speed Insights"]
|
||||
end
|
||||
|
||||
A --> DL
|
||||
A --> AL
|
||||
A --> DEL
|
||||
A --> OL
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket Read-only Layer"]
|
||||
```
|
||||
|
||||
## Architecture
|
||||
## Monitored Cities (30)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User[Web / Telegram User] --> FE[Next.js Frontend on Vercel]
|
||||
User --> Bot[Telegram Bot on VPS]
|
||||
FE --> API[FastAPI Service]
|
||||
Bot --> API
|
||||
|
||||
API --> WX[Weather Data Collector]
|
||||
WX --> METAR[METAR / Aviation Weather]
|
||||
WX --> MGM[MGM API / nearby stations]
|
||||
WX --> OM[Open-Meteo]
|
||||
WX --> NWS[weather.gov]
|
||||
|
||||
API --> DEB[DEB + Trend + Probability Engines]
|
||||
API --> PM[Polymarket Read-only Layer]
|
||||
PM --> Gamma[Gamma API]
|
||||
PM --> CLOB[CLOB / py-clob-client]
|
||||
```
|
||||
|
||||
## Current Source Policy
|
||||
|
||||
| Domain | Source Policy |
|
||||
| :------------------ | :--------------------------------------------------- |
|
||||
| Primary observation | Aviation Weather / METAR |
|
||||
| Ankara enhancement | MGM + nearby stations, lead station fixed to `17130` |
|
||||
| Forecast baseline | Open-Meteo |
|
||||
| US official context | weather.gov |
|
||||
| Market layer | Polymarket P0 read-only discovery + quotes |
|
||||
| Removed source | Meteoblue (fully removed from code and docs) |
|
||||
|
||||
## Recent Changes (2026-03-11)
|
||||
|
||||
- Removed all Meteoblue API integration and references.
|
||||
- Fixed market top-bucket rendering path by deduplicating repeated temperature buckets.
|
||||
- Added frontend fallback guard when market top buckets collapse to low-quality duplicates.
|
||||
- Fixed detail panel accessibility issue (`aria-hidden` focus conflict) using `inert` + active-element blur.
|
||||
- Added Vercel Speed Insights integration in `frontend/app/layout.tsx`.
|
||||
|
||||
## Repositories and Runtime Paths
|
||||
|
||||
- Frontend: `frontend/` (Next.js App Router)
|
||||
- Backend API: `web/app.py` and `src/`
|
||||
- Telegram runtime: `bot_listener.py` + `src/analysis/*`
|
||||
- Docs: `docs/`
|
||||
- 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
|
||||
- South Asia: Lucknow
|
||||
- China extension: Chengdu, Chongqing, Shenzhen, Beijing, Wuhan
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Backend + Bot (VPS / Docker)
|
||||
### Backend + Bot (Docker)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
@@ -118,32 +99,96 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Frontend production build check
|
||||
## Recent Highlights
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
- Taipei settlement is aligned to `Wunderground RCSS` with whole-degree Celsius resolution logic.
|
||||
- Shenzhen settlement is aligned to `Wunderground ZGSZ`.
|
||||
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
|
||||
- Intraday analysis now separates:
|
||||
- `Surface Structure`
|
||||
- `Upper-Air Structure`
|
||||
- `Trade cue`
|
||||
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
|
||||
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
|
||||
|
||||
## Runtime Data (Recommended on VPS)
|
||||
|
||||
Use external runtime storage to avoid SQLite/git conflicts:
|
||||
|
||||
```env
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
```
|
||||
|
||||
## Command Surface (Telegram)
|
||||
## Ops Verification
|
||||
|
||||
| Command | Purpose |
|
||||
| :------------- | :---------------------------- |
|
||||
| `/city <name>` | City real-time analysis |
|
||||
| `/deb <name>` | DEB historical reconciliation |
|
||||
| `/top` | User leaderboard |
|
||||
| `/help` | Help and command usage |
|
||||
### Health / system status / metrics
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
### Frontend cache headers
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
```
|
||||
|
||||
### Payment auto-reconciliation logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
|
||||
```
|
||||
|
||||
### Payment runtime
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/api/payments/runtime
|
||||
```
|
||||
|
||||
### Wallet activity logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
||||
```
|
||||
|
||||
## Telegram Commands
|
||||
|
||||
| Command | Purpose |
|
||||
| :-- | :-- |
|
||||
| `/city <name>` | City real-time analysis |
|
||||
| `/deb <name>` | DEB historical reconciliation |
|
||||
| `/top` | User leaderboard |
|
||||
| `/id` | Show current chat ID |
|
||||
| `/diag` | Startup diagnostics |
|
||||
| `/help` | Help and usage |
|
||||
|
||||
## Documentation Index
|
||||
|
||||
- Chinese API guide: `docs/API_ZH.md`
|
||||
- Commercial roadmap: `docs/COMMERCIALIZATION.md`
|
||||
- Tech debt (EN): `docs/TECH_DEBT.md`
|
||||
- Tech debt (ZH): `docs/TECH_DEBT_ZH.md`
|
||||
- Chinese overview: `README_ZH.md`
|
||||
- Chinese overview: [README_ZH.md](README_ZH.md)
|
||||
- Chinese API guide: [docs/API_ZH.md](docs/API_ZH.md)
|
||||
- TAF signal guide (ZH): [docs/TAF_SIGNAL_ZH.md](docs/TAF_SIGNAL_ZH.md)
|
||||
- Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
|
||||
- AGPL-3.0 policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
|
||||
- Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- Configuration & secrets (ZH): [docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
||||
- LightGBM daily-high model (ZH): [docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md)
|
||||
- Frontend deployment (ZH): [docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- Tech debt (EN): [docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
||||
- Tech debt (ZH): [docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- Payment verification: [docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||
- Payment audit: [docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
- Payment V2 upgrade: [docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- Ops admin guide: [docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- Deep research report: [docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- Frontend report: [FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- Release process: [RELEASE.md](RELEASE.md)
|
||||
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Status
|
||||
## Version
|
||||
|
||||
- Version: `v1.3`
|
||||
- Last Updated: `2026-03-11`
|
||||
- Runtime: Stable (web + bot + market read-only layer in production)
|
||||
- Version: `v1.5.1`
|
||||
- Last Updated: `2026-03-24`
|
||||
|
||||
@@ -4,106 +4,80 @@
|
||||
|
||||
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
|
||||
## 这个项目在做什么
|
||||
## 产品截图
|
||||
|
||||
- 聚合监控城市的实测与预报数据。
|
||||
- 用 DEB(Dynamic Error Balancing)做动态融合预测。
|
||||
- 计算结算导向的温度概率分布(`μ` + 温度桶)。
|
||||
- 将模型概率与 Polymarket 只读市场数据对齐,输出错价/风险信号。
|
||||
- Web 仪表盘与 Telegram 机器人共用同一套核心逻辑。
|
||||
### 全球看板
|
||||
|
||||
## 概览图
|
||||

|
||||
|
||||
### 城市分析(Ankara)
|
||||
|
||||

|
||||
|
||||
## 当前产品状态(2026-03-21)
|
||||
|
||||
- 已上线订阅制:`Pro 月付 5 USDC`。
|
||||
- 已上线积分抵扣:`500 积分 = 1 USDC`,最多抵扣 `3 USDC`。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
|
||||
- 已上线自动补单:事件监听 + 周期确认双链路。
|
||||
- 已上线支付运行态与审计接口:`/api/payments/runtime`。
|
||||
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
|
||||
- 已上线轻量可观测性:`/healthz`、`/api/system/status`、`/metrics`。
|
||||
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
|
||||
- 运行态状态、缓存与核心离线训练/回填链路已完成 SQLite 主路径收口;legacy JSON/JSONL 仅保留给迁移、导出与显式回退输入。
|
||||
- 已接入 EMOS/CRPS 校准链路,但当前仍保持 `emos_shadow`。
|
||||
|
||||
## 许可证与商用边界(重要)
|
||||
|
||||
本仓库自 `2026-03-30` 起采用 **GNU AGPL-3.0-only**。
|
||||
|
||||
- 仓库公开部分:天气聚合、基础分析、前端看板、Bot 基础能力、标准支付流程。
|
||||
- 不包含在仓库中的部分:生产私有数据、商业风控规则、运营阈值、收费策略细节、内部对账与增长工具。
|
||||
- 商标、品牌、域名、生产数据库与托管服务运营能力,不因代码许可证一并授权。
|
||||
|
||||
详细见:[AGPL-3.0 与商用边界](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
## 核心能力
|
||||
|
||||
- 聚合 30 个监控城市的实测与预报数据。
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 输出结算导向概率分布(`mu` + 温度桶)。
|
||||
- 将模型观点映射到 Polymarket 行情,做错价扫描。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
||||
|
||||
## 参考架构
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["PolyWeather Pro"]
|
||||
flowchart LR
|
||||
U["用户(Web / Telegram)"] --> FE["Next.js 前端(Vercel)"]
|
||||
U --> BOT["Telegram Bot(VPS)"]
|
||||
FE --> API["FastAPI /web/app.py"]
|
||||
BOT --> API
|
||||
|
||||
subgraph DL["数据层"]
|
||||
DL1["METAR (Aviation Weather / METAR)"]
|
||||
DL2["MGM (土耳其 MGM)"]
|
||||
DL3["安卡拉主站 (17130 Center)"]
|
||||
DL4["Open-Meteo"]
|
||||
DL5["weather.gov (美国城市)"]
|
||||
DL6["Polymarket (P0 只读)"]
|
||||
end
|
||||
API --> WX["Weather Collector"]
|
||||
WX --> METAR["Aviation Weather(METAR)"]
|
||||
WX --> MGM["MGM(土耳其站网)"]
|
||||
WX --> OM["Open-Meteo"]
|
||||
|
||||
subgraph AL["分析层"]
|
||||
AL1["DEB (动态误差平衡)"]
|
||||
AL2["概率引擎 (mu + 桶分布)"]
|
||||
AL3["趋势引擎"]
|
||||
AL4["城市风险档案"]
|
||||
AL5["错价雷达"]
|
||||
end
|
||||
|
||||
subgraph DEL["交付层"]
|
||||
DEL1["FastAPI"]
|
||||
DEL2["Next.js 仪表盘"]
|
||||
DEL3["Telegram Bot"]
|
||||
DEL4["预警推送"]
|
||||
end
|
||||
|
||||
subgraph OL["运维层"]
|
||||
OL1["Docker Compose (VPS)"]
|
||||
OL2["Vercel (前端)"]
|
||||
OL3["缓存 + force_refresh"]
|
||||
OL4["Speed Insights"]
|
||||
end
|
||||
|
||||
A --> DL
|
||||
A --> AL
|
||||
A --> DEL
|
||||
A --> OL
|
||||
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
|
||||
ANA --> PAY["支付状态(Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket 只读层"]
|
||||
API --> OBS["healthz / system status / metrics"]
|
||||
ANA --> STATE["SQLite runtime state<br/>legacy files only for migration/export fallback"]
|
||||
```
|
||||
|
||||
## 系统架构
|
||||
## 监控城市(30)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User[Web / Telegram 用户] --> FE[Vercel Next.js 前端]
|
||||
User --> Bot[VPS Telegram Bot]
|
||||
FE --> API[FastAPI 服务]
|
||||
Bot --> API
|
||||
|
||||
API --> WX[Weather Collector]
|
||||
WX --> METAR[METAR / Aviation Weather]
|
||||
WX --> MGM[MGM API / 周边站]
|
||||
WX --> OM[Open-Meteo]
|
||||
WX --> NWS[weather.gov]
|
||||
|
||||
API --> DEB[DEB + 趋势 + 概率引擎]
|
||||
API --> PM[Polymarket 只读层]
|
||||
PM --> Gamma[Gamma API]
|
||||
PM --> CLOB[CLOB / py-clob-client]
|
||||
```
|
||||
|
||||
## 当前数据源口径
|
||||
|
||||
| 领域 | 当前口径 |
|
||||
| :------------- | :-------------------------------- |
|
||||
| 主观测源 | Aviation Weather / METAR |
|
||||
| Ankara 增强 | MGM + 周边站,领先站固定 `17130` |
|
||||
| 预报基线 | Open-Meteo |
|
||||
| 美国官方语义层 | weather.gov |
|
||||
| 市场层 | Polymarket P0 只读发现 + 报价 |
|
||||
| 已移除 | Meteoblue(代码与文档已全部移除) |
|
||||
|
||||
## 最近更新(2026-03-11)
|
||||
|
||||
- 完整移除 Meteoblue API 及全部引用。
|
||||
- 修复市场“最热温度桶”重复温度刷屏问题(后端按温度去重 + 前端兜底去重)。
|
||||
- 修复详情面板可访问性告警(`aria-hidden` 焦点冲突),改为 `inert + blur`。
|
||||
- 集成 Vercel Speed Insights(`frontend/app/layout.tsx`)。
|
||||
|
||||
## 目录说明
|
||||
|
||||
- 前端:`frontend/`(Next.js App Router)
|
||||
- 后端:`web/app.py` 与 `src/`
|
||||
- 机器人:`bot_listener.py` + `src/analysis/*`
|
||||
- 文档:`docs/`
|
||||
- 欧洲/中东: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
|
||||
- 南亚:Lucknow
|
||||
- 中国扩展:Chengdu、Chongqing、Shenzhen、Beijing、Wuhan
|
||||
|
||||
## 快速启动
|
||||
|
||||
### 后端 + 机器人(VPS / Docker)
|
||||
### 后端 + Bot(Docker)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
@@ -117,32 +91,110 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 前端构建校验
|
||||
## 运行数据目录(VPS 推荐)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
|
||||
|
||||
```env
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
```
|
||||
|
||||
## Telegram 命令
|
||||
## 运维验收
|
||||
|
||||
| 命令 | 用途 |
|
||||
| :------------- | :----------- |
|
||||
### 健康与系统状态
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
### 前端缓存头
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
```
|
||||
|
||||
### 支付自动补单日志
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
|
||||
```
|
||||
|
||||
### 外部监控栈
|
||||
|
||||
```bash
|
||||
docker compose --profile monitoring up -d polyweather_prometheus polyweather_alertmanager polyweather_alert_relay polyweather_grafana
|
||||
```
|
||||
|
||||
- Prometheus:`http://127.0.0.1:${POLYWEATHER_PROMETHEUS_PORT:-9090}`
|
||||
- Alertmanager:`http://127.0.0.1:${POLYWEATHER_ALERTMANAGER_PORT:-9093}`
|
||||
- Grafana:`http://127.0.0.1:${POLYWEATHER_GRAFANA_PORT:-3001}`
|
||||
|
||||
手动巡检:
|
||||
|
||||
```bash
|
||||
python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
### 支付运行态
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/api/payments/runtime
|
||||
```
|
||||
|
||||
### 运营后台
|
||||
|
||||
- 前端入口:`https://polyweather-pro.vercel.app/ops`
|
||||
- 后端需配置:
|
||||
|
||||
```env
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
```
|
||||
|
||||
### 钱包异动监听日志
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
||||
```
|
||||
|
||||
## Telegram 指令
|
||||
|
||||
| 指令 | 用途 |
|
||||
| :-- | :-- |
|
||||
| `/city <name>` | 城市实时分析 |
|
||||
| `/deb <name>` | DEB 历史对账 |
|
||||
| `/top` | 用户排行榜 |
|
||||
| `/help` | 帮助说明 |
|
||||
| `/deb <name>` | DEB 历史对账 |
|
||||
| `/top` | 用户积分排行 |
|
||||
| `/id` | 查看聊天 Chat ID |
|
||||
| `/diag` | Bot 启动诊断 |
|
||||
| `/help` | 帮助与用法 |
|
||||
|
||||
## 文档索引
|
||||
|
||||
- API 文档(中文):`docs/API_ZH.md`
|
||||
- 商业化路线:`docs/COMMERCIALIZATION.md`
|
||||
- 技术债(英文):`docs/TECH_DEBT.md`
|
||||
- 技术债(中文):`docs/TECH_DEBT_ZH.md`
|
||||
- 英文总览:`README.md`
|
||||
- 英文总览:[README.md](README.md)
|
||||
- API 文档(中文):[docs/API_ZH.md](docs/API_ZH.md)
|
||||
- 商业化说明:[docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
|
||||
- AGPL-3.0 边界:[docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
|
||||
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
||||
- 前端部署(Vercel):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- EMOS 训练报告:[docs/EMOS_TRAINING_REPORT_ZH.md](docs/EMOS_TRAINING_REPORT_ZH.md)
|
||||
- 概率快照归档:[docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md)
|
||||
- 技术债(中文镜像):[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- 技术债(主文档):[docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
||||
- 支付合约验证:[docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||
- 支付审计说明:[docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 当前状态
|
||||
## 当前版本
|
||||
|
||||
- 版本:`v1.3`
|
||||
- 最后更新:`2026-03-11`
|
||||
- 状态:稳定运行(Web + Bot + 市场只读层)
|
||||
- 版本:`v1.5.1`
|
||||
- 文档最后更新:`2026-03-21`
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# 版本发布流程
|
||||
|
||||
本项目采用语义化版本号:`MAJOR.MINOR.PATCH`
|
||||
|
||||
当前单一版本源为根目录 [VERSION](E:/web/PolyWeather/VERSION) 文件,所有对外文档与前端版本号都从这里同步。
|
||||
|
||||
## 版本规则
|
||||
|
||||
- `PATCH`:修复缺陷、文档修正、兼容性不变的小改动
|
||||
- `MINOR`:新增能力、接口扩展、向后兼容的功能迭代
|
||||
- `MAJOR`:不兼容变更、核心架构升级、公开接口重大调整
|
||||
|
||||
示例:
|
||||
|
||||
- `1.4.0 -> 1.4.1`:告警逻辑修正、缓存修正、文档修正
|
||||
- `1.4.0 -> 1.5.0`:新增支付能力、新增页面、新增 API
|
||||
- `1.4.0 -> 2.0.0`:接口重构或数据结构不兼容
|
||||
|
||||
## 日常升版步骤
|
||||
|
||||
### 1. 升版本号
|
||||
|
||||
```bash
|
||||
python scripts/bump_version.py patch
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
```bash
|
||||
python scripts/bump_version.py minor
|
||||
python scripts/bump_version.py major
|
||||
python scripts/bump_version.py 1.5.0
|
||||
```
|
||||
|
||||
### 2. 检查同步结果
|
||||
|
||||
```bash
|
||||
python scripts/sync_version.py
|
||||
git diff
|
||||
```
|
||||
|
||||
### 3. 补充 Changelog
|
||||
|
||||
在 [CHANGELOG.md](E:/web/PolyWeather/CHANGELOG.md) 对应版本下补齐:
|
||||
|
||||
- 新增能力
|
||||
- 修复项
|
||||
- 兼容性说明
|
||||
|
||||
### 4. 验证
|
||||
|
||||
建议至少执行:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
如涉及后端核心逻辑,补充执行:
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
### 5. 提交与打标签
|
||||
|
||||
工作区干净后再打标签:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "release: v1.4.1"
|
||||
git tag v1.4.1
|
||||
```
|
||||
|
||||
### 6. 推送
|
||||
|
||||
```bash
|
||||
git push
|
||||
git push origin v1.4.1
|
||||
```
|
||||
|
||||
## 当前约束
|
||||
|
||||
- 不直接手改多份文档版本号
|
||||
- 不在工作区脏状态下打 release tag
|
||||
- `README.md`、前端 `package.json`、文档标题版本都通过脚本同步
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"model_type": "LightGBMRegressor",
|
||||
"target": "actual_high",
|
||||
"horizon": "D0",
|
||||
"feature_names": [
|
||||
"actual_high_lag_1",
|
||||
"actual_high_lag_2",
|
||||
"actual_high_lag_3",
|
||||
"actual_high_lag_7",
|
||||
"actual_high_mean_7",
|
||||
"actual_high_mean_14",
|
||||
"actual_high_trend_3",
|
||||
"open_meteo",
|
||||
"ecmwf",
|
||||
"gfs",
|
||||
"gem",
|
||||
"jma",
|
||||
"icon",
|
||||
"mgm",
|
||||
"nws",
|
||||
"deb_prediction",
|
||||
"model_median",
|
||||
"model_spread",
|
||||
"current_temp",
|
||||
"max_so_far",
|
||||
"humidity",
|
||||
"wind_speed_kt",
|
||||
"visibility_mi",
|
||||
"local_hour",
|
||||
"month",
|
||||
"weekday",
|
||||
"peak_status_code"
|
||||
],
|
||||
"base_model_columns": [
|
||||
"open_meteo",
|
||||
"ecmwf",
|
||||
"gfs",
|
||||
"gem",
|
||||
"jma",
|
||||
"icon",
|
||||
"mgm",
|
||||
"nws"
|
||||
],
|
||||
"model_path": "artifacts\\models\\lgbm_daily_high.txt",
|
||||
"sample_count": 54,
|
||||
"train_count": 42,
|
||||
"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
|
||||
},
|
||||
"full_sample": {
|
||||
"sample_count": 54,
|
||||
"lgbm_mae": 0.691,
|
||||
"deb_mae": 6.287,
|
||||
"best_single_mae": 5.431,
|
||||
"median_mae": 6.265
|
||||
}
|
||||
},
|
||||
"generated_at": "2026-04-02T16:27:44.816882Z",
|
||||
"trained_at": "2026-04-02T16:27:44.816882Z"
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"version": "emos-20260402162744",
|
||||
"trained_at": "2026-04-02T16:27:44.114836+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
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"sigma_constraints": {
|
||||
"min_ratio": 0.85,
|
||||
"max_ratio": 1.35,
|
||||
"absolute_min": 0.25,
|
||||
"absolute_max": 3.0
|
||||
},
|
||||
"selection_guardrails": {
|
||||
"max_mae_increase": 0.02,
|
||||
"max_bucket_hit_drop": 0.01,
|
||||
"max_bucket_brier_increase": 0.05
|
||||
},
|
||||
"blending": {
|
||||
"alpha_mu": 0.0,
|
||||
"alpha_sigma": 0.05
|
||||
},
|
||||
"cities": {
|
||||
"ankara": {
|
||||
"samples": 3,
|
||||
"mu_bias": 1.271844,
|
||||
"sigma_scale": 1.477644,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 4,
|
||||
"mu_bias": 1.32008,
|
||||
"sigma_scale": 1.04023,
|
||||
"confidence": 0.5
|
||||
},
|
||||
"milan": {
|
||||
"samples": 3,
|
||||
"mu_bias": -3.935178,
|
||||
"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,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 3,
|
||||
"mu_bias": -0.625333,
|
||||
"sigma_scale": 1.25968,
|
||||
"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,
|
||||
"filled_actual_from_history": 0,
|
||||
"settlement_history_city_count": 30
|
||||
},
|
||||
"source": "artifacts\\probability_calibration\\default.json"
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
{
|
||||
"summary": {
|
||||
"sample_count": 54,
|
||||
"filled_actual_from_history": 2,
|
||||
"legacy": {
|
||||
"mean_crps": 4.300621,
|
||||
"mean_mae": 4.502963,
|
||||
"bucket_hit_rate": 0.537037
|
||||
},
|
||||
"emos": {
|
||||
"mean_crps": 4.213889,
|
||||
"mean_mae": 4.502963,
|
||||
"bucket_hit_rate": 0.537037
|
||||
},
|
||||
"delta": {
|
||||
"crps": -0.086732,
|
||||
"mae": 0.0,
|
||||
"bucket_hit_rate": 0.0
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"atlanta": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 30.449382,
|
||||
"emos_mean_crps": 30.578432,
|
||||
"legacy_mean_mae": 32.015,
|
||||
"emos_mean_mae": 32.015,
|
||||
"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,
|
||||
"legacy_mean_mae": 10.27,
|
||||
"emos_mean_mae": 10.27,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"chicago": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 1.250268,
|
||||
"emos_mean_crps": 0.701085,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"dallas": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 2.173363,
|
||||
"emos_mean_crps": 0.701085,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.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
|
||||
},
|
||||
"london": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 3.885033,
|
||||
"emos_mean_crps": 3.866915,
|
||||
"legacy_mean_mae": 4.135,
|
||||
"emos_mean_mae": 4.135,
|
||||
"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,
|
||||
"legacy_mean_mae": 3.205,
|
||||
"emos_mean_mae": 3.205,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"madrid": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 6.27726,
|
||||
"emos_mean_crps": 5.967277,
|
||||
"legacy_mean_mae": 7.33,
|
||||
"emos_mean_mae": 7.33,
|
||||
"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,
|
||||
"legacy_mean_mae": 30.175,
|
||||
"emos_mean_mae": 30.175,
|
||||
"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,
|
||||
"legacy_mean_mae": 4.06,
|
||||
"emos_mean_mae": 4.06,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"munich": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 3.145192,
|
||||
"emos_mean_crps": 3.011312,
|
||||
"legacy_mean_mae": 3.64,
|
||||
"emos_mean_mae": 3.64,
|
||||
"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,
|
||||
"legacy_mean_mae": 4.94,
|
||||
"emos_mean_mae": 4.94,
|
||||
"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,
|
||||
"legacy_mean_mae": 4.265,
|
||||
"emos_mean_mae": 4.265,
|
||||
"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,
|
||||
"legacy_mean_mae": 6.57,
|
||||
"emos_mean_mae": 6.57,
|
||||
"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,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.0,
|
||||
"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,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.299116,
|
||||
"emos_mean_crps": 0.394855,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"emos_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"shenzhen": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.798351,
|
||||
"emos_mean_crps": 0.762787,
|
||||
"legacy_mean_mae": 0.9,
|
||||
"emos_mean_mae": 0.9,
|
||||
"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,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"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
|
||||
},
|
||||
"tel aviv": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.446758,
|
||||
"emos_mean_crps": 0.578006,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.3,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"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
|
||||
},
|
||||
"toronto": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 5.497916,
|
||||
"emos_mean_crps": 5.240552,
|
||||
"legacy_mean_mae": 6.33,
|
||||
"emos_mean_mae": 6.33,
|
||||
"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,
|
||||
"legacy_mean_mae": 2.056667,
|
||||
"emos_mean_mae": 2.056667,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"emos_bucket_hit_rate": 0.333333
|
||||
},
|
||||
"wellington": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.364919,
|
||||
"emos_mean_crps": 0.475875,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"evaluation_report_path": "E:\\web\\PolyWeather\\artifacts\\probability_calibration\\evaluation_report.json",
|
||||
"shadow_report_path": "E:\\web\\PolyWeather\\artifacts\\probability_calibration\\shadow_report.json",
|
||||
"evaluation_report_exists": true,
|
||||
"shadow_report_exists": true,
|
||||
"decision": {
|
||||
"decision": "hold",
|
||||
"ready_for_primary": false,
|
||||
"summary": "当前指标不足以切换 emos_primary,应继续保持 shadow。",
|
||||
"thresholds": {
|
||||
"evaluation_min_samples": 80,
|
||||
"shadow_min_samples": 50,
|
||||
"max_delta_mae": 0.05,
|
||||
"min_delta_crps": -0.02,
|
||||
"min_delta_bucket_hit_rate": 0.0,
|
||||
"max_delta_bucket_brier_promote": 0.02,
|
||||
"max_delta_bucket_brier_observe": 0.15
|
||||
},
|
||||
"evaluation": {
|
||||
"sample_count": 54,
|
||||
"delta_crps": -0.086732,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0
|
||||
},
|
||||
"shadow": {
|
||||
"sample_count": 48,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.041666,
|
||||
"delta_bucket_brier": 0.123252
|
||||
},
|
||||
"blocking_reasons": [
|
||||
"离线评估样本不足:54 < 80",
|
||||
"shadow 样本不足:48 < 50",
|
||||
"shadow bucket brier 退化超限:delta=0.123252"
|
||||
],
|
||||
"worst_shadow_regressions": [
|
||||
{
|
||||
"city": "dallas",
|
||||
"samples": 1,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.792585
|
||||
},
|
||||
{
|
||||
"city": "chicago",
|
||||
"samples": 1,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.791878
|
||||
},
|
||||
{
|
||||
"city": "seattle",
|
||||
"samples": 1,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.61609
|
||||
},
|
||||
{
|
||||
"city": "wellington",
|
||||
"samples": 2,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.509203
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"samples": 2,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.439879
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
{
|
||||
"generated_at": "2026-04-02T16:23:24.376528Z",
|
||||
"summary": {
|
||||
"samples": 48,
|
||||
"legacy_mean_mae": 3.04125,
|
||||
"shadow_mean_mae": 3.04125,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"shadow_bucket_hit_rate": 0.5,
|
||||
"legacy_bucket_brier": 0.68666,
|
||||
"shadow_bucket_brier": 0.814079,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.127419
|
||||
},
|
||||
"by_city": {
|
||||
"ankara": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"shadow_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.494847,
|
||||
"shadow_bucket_brier": 0.654648,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.159801
|
||||
},
|
||||
"atlanta": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 17.06,
|
||||
"shadow_mean_mae": 17.06,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.029097,
|
||||
"shadow_bucket_brier": 1.064101,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.035004
|
||||
},
|
||||
"buenos aires": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 10.27,
|
||||
"shadow_mean_mae": 10.27,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.117726,
|
||||
"shadow_bucket_brier": 1.116732,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.000994
|
||||
},
|
||||
"chicago": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"shadow_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.0,
|
||||
"shadow_bucket_brier": 0.791878,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.791878
|
||||
},
|
||||
"dallas": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"shadow_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.0,
|
||||
"shadow_bucket_brier": 0.792585,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.792585
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 3,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"shadow_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"shadow_bucket_hit_rate": 0.666667,
|
||||
"legacy_bucket_brier": 0.882203,
|
||||
"shadow_bucket_brier": 0.437187,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.333334,
|
||||
"delta_bucket_brier": -0.445016
|
||||
},
|
||||
"london": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 4.135,
|
||||
"shadow_mean_mae": 4.135,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 0.929652,
|
||||
"shadow_bucket_brier": 1.039551,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.109899
|
||||
},
|
||||
"lucknow": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 3.205,
|
||||
"shadow_mean_mae": 3.205,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.673607,
|
||||
"shadow_bucket_brier": 0.96272,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.710887
|
||||
},
|
||||
"madrid": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 7.33,
|
||||
"shadow_mean_mae": 7.33,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.148213,
|
||||
"shadow_bucket_brier": 1.133888,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.014325
|
||||
},
|
||||
"miami": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 11.04,
|
||||
"shadow_mean_mae": 11.04,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.182605,
|
||||
"shadow_bucket_brier": 1.067257,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.115348
|
||||
},
|
||||
"milan": {
|
||||
"samples": 3,
|
||||
"legacy_mean_mae": 4.06,
|
||||
"shadow_mean_mae": 4.06,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"shadow_bucket_hit_rate": 0.666667,
|
||||
"legacy_bucket_brier": 0.587548,
|
||||
"shadow_bucket_brier": 0.78616,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.198612
|
||||
},
|
||||
"munich": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 3.64,
|
||||
"shadow_mean_mae": 3.64,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 0.918378,
|
||||
"shadow_bucket_brier": 0.945234,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.026856
|
||||
},
|
||||
"new york": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 4.94,
|
||||
"shadow_mean_mae": 4.94,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.111711,
|
||||
"shadow_bucket_brier": 1.099556,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.012155
|
||||
},
|
||||
"paris": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 4.265,
|
||||
"shadow_mean_mae": 4.265,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"shadow_bucket_hit_rate": 0.5,
|
||||
"legacy_bucket_brier": 0.90186,
|
||||
"shadow_bucket_brier": 0.9967,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.09484
|
||||
},
|
||||
"sao paulo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 6.57,
|
||||
"shadow_mean_mae": 6.57,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.263988,
|
||||
"shadow_bucket_brier": 1.159352,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.104636
|
||||
},
|
||||
"seattle": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"shadow_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.0,
|
||||
"shadow_bucket_brier": 0.61609,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.61609
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.258977,
|
||||
"shadow_bucket_brier": 0.548485,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.289508
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.1156,
|
||||
"shadow_bucket_brier": 0.501323,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.385723
|
||||
},
|
||||
"singapore": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.306527,
|
||||
"shadow_bucket_brier": 0.553647,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.24712
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 3,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"shadow_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 0.333333,
|
||||
"legacy_bucket_brier": 0.195261,
|
||||
"shadow_bucket_brier": 0.684998,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": -0.666667,
|
||||
"delta_bucket_brier": 0.489737
|
||||
},
|
||||
"tel aviv": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"shadow_mean_mae": 0.3,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.257895,
|
||||
"shadow_bucket_brier": 0.697774,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.439879
|
||||
},
|
||||
"tokyo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.25,
|
||||
"shadow_mean_mae": 0.25,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.284759,
|
||||
"shadow_bucket_brier": 0.69701,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.5,
|
||||
"delta_bucket_brier": 0.412251
|
||||
},
|
||||
"toronto": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 6.33,
|
||||
"shadow_mean_mae": 6.33,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.256736,
|
||||
"shadow_bucket_brier": 1.221422,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.035314
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 3,
|
||||
"legacy_mean_mae": 2.056667,
|
||||
"shadow_mean_mae": 2.056667,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"shadow_bucket_hit_rate": 0.333333,
|
||||
"legacy_bucket_brier": 0.86425,
|
||||
"shadow_bucket_brier": 0.750985,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.113265
|
||||
},
|
||||
"wellington": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.095481,
|
||||
"shadow_bucket_brier": 0.604684,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.509203
|
||||
}
|
||||
},
|
||||
"by_date": {
|
||||
"2026-03-17": {
|
||||
"samples": 4,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 0.5,
|
||||
"legacy_bucket_brier": 0.101543,
|
||||
"shadow_bucket_brier": 0.6636,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": -0.5,
|
||||
"delta_bucket_brier": 0.562057
|
||||
},
|
||||
"2026-03-18": {
|
||||
"samples": 25,
|
||||
"legacy_mean_mae": 2.5476,
|
||||
"shadow_mean_mae": 2.5476,
|
||||
"legacy_bucket_hit_rate": 0.52,
|
||||
"shadow_bucket_hit_rate": 0.56,
|
||||
"legacy_bucket_brier": 0.657516,
|
||||
"shadow_bucket_brier": 0.74574,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.04,
|
||||
"delta_bucket_brier": 0.088224
|
||||
},
|
||||
"2026-03-19": {
|
||||
"samples": 19,
|
||||
"legacy_mean_mae": 4.299474,
|
||||
"shadow_mean_mae": 4.299474,
|
||||
"legacy_bucket_hit_rate": 0.368421,
|
||||
"shadow_bucket_hit_rate": 0.421053,
|
||||
"legacy_bucket_brier": 0.848191,
|
||||
"shadow_bucket_brier": 0.935678,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.052632,
|
||||
"delta_bucket_brier": 0.087487
|
||||
}
|
||||
},
|
||||
"recent_observations": [
|
||||
{
|
||||
"city": "wellington",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 18.0,
|
||||
"actual_bucket": 18,
|
||||
"legacy_mu": 18.3,
|
||||
"shadow_mu": 18.3,
|
||||
"legacy_top_bucket": 18,
|
||||
"shadow_top_bucket": 18,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 7.0,
|
||||
"actual_bucket": 7,
|
||||
"legacy_mu": 12.03,
|
||||
"shadow_mu": 12.03,
|
||||
"legacy_top_bucket": 12,
|
||||
"shadow_top_bucket": 12,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "toronto",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": -2.0,
|
||||
"actual_bucket": -2,
|
||||
"legacy_mu": 5.67,
|
||||
"shadow_mu": 5.67,
|
||||
"legacy_top_bucket": 6,
|
||||
"shadow_top_bucket": 6,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "tokyo",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 16.0,
|
||||
"actual_bucket": 16,
|
||||
"legacy_mu": 16.5,
|
||||
"shadow_mu": 16.5,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 16,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 21.0,
|
||||
"actual_bucket": 21,
|
||||
"legacy_mu": 21.3,
|
||||
"shadow_mu": 21.3,
|
||||
"legacy_top_bucket": 21,
|
||||
"shadow_top_bucket": 21,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 22.0,
|
||||
"actual_bucket": 22,
|
||||
"legacy_mu": 21.7,
|
||||
"shadow_mu": 21.7,
|
||||
"legacy_top_bucket": 22,
|
||||
"shadow_top_bucket": 21,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "singapore",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 32.0,
|
||||
"actual_bucket": 32,
|
||||
"legacy_mu": 32.3,
|
||||
"shadow_mu": 32.3,
|
||||
"legacy_top_bucket": 32,
|
||||
"shadow_top_bucket": 32,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 12.0,
|
||||
"actual_bucket": 12,
|
||||
"legacy_mu": 12.3,
|
||||
"shadow_mu": 12.3,
|
||||
"legacy_top_bucket": 12,
|
||||
"shadow_top_bucket": 12,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "seoul",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 10.0,
|
||||
"actual_bucket": 10,
|
||||
"legacy_mu": 10.3,
|
||||
"shadow_mu": 10.3,
|
||||
"legacy_top_bucket": 10,
|
||||
"shadow_top_bucket": 10,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "sao paulo",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 21.0,
|
||||
"actual_bucket": 21,
|
||||
"legacy_mu": 26.5,
|
||||
"shadow_mu": 26.5,
|
||||
"legacy_top_bucket": 26,
|
||||
"shadow_top_bucket": 26,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"actual_bucket": 8,
|
||||
"legacy_mu": 16.06,
|
||||
"shadow_mu": 16.06,
|
||||
"legacy_top_bucket": 16,
|
||||
"shadow_top_bucket": 16,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "munich",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 5.0,
|
||||
"actual_bucket": 5,
|
||||
"legacy_mu": 11.62,
|
||||
"shadow_mu": 11.62,
|
||||
"legacy_top_bucket": 12,
|
||||
"shadow_top_bucket": 12,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 5.0,
|
||||
"actual_bucket": 5,
|
||||
"legacy_mu": 16.58,
|
||||
"shadow_mu": 16.58,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 17,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "madrid",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"actual_bucket": 8,
|
||||
"legacy_mu": 18.23,
|
||||
"shadow_mu": 18.23,
|
||||
"legacy_top_bucket": 18,
|
||||
"shadow_top_bucket": 18,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "lucknow",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 30.0,
|
||||
"actual_bucket": 30,
|
||||
"legacy_mu": 35.3,
|
||||
"shadow_mu": 35.3,
|
||||
"legacy_top_bucket": 35,
|
||||
"shadow_top_bucket": 35,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "london",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"actual_bucket": 8,
|
||||
"legacy_mu": 15.71,
|
||||
"shadow_mu": 15.71,
|
||||
"legacy_top_bucket": 16,
|
||||
"shadow_top_bucket": 16,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 27.3,
|
||||
"actual_bucket": 27,
|
||||
"legacy_mu": 27.6,
|
||||
"shadow_mu": 27.6,
|
||||
"legacy_top_bucket": 28,
|
||||
"shadow_top_bucket": 27,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "buenos aires",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 16.0,
|
||||
"actual_bucket": 16,
|
||||
"legacy_mu": 27.39,
|
||||
"shadow_mu": 27.39,
|
||||
"legacy_top_bucket": 27,
|
||||
"shadow_top_bucket": 27,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 11.0,
|
||||
"actual_bucket": 11,
|
||||
"legacy_mu": 11.0,
|
||||
"shadow_mu": 11.0,
|
||||
"legacy_top_bucket": 11,
|
||||
"shadow_top_bucket": 11,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "wellington",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 21.0,
|
||||
"actual_bucket": 21,
|
||||
"legacy_mu": 21.0,
|
||||
"shadow_mu": 21.0,
|
||||
"legacy_top_bucket": 21,
|
||||
"shadow_top_bucket": 21,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 13.0,
|
||||
"actual_bucket": 13,
|
||||
"legacy_mu": 13.84,
|
||||
"shadow_mu": 13.84,
|
||||
"legacy_top_bucket": 14,
|
||||
"shadow_top_bucket": 14,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "toronto",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": -6.0,
|
||||
"actual_bucket": -6,
|
||||
"legacy_mu": -1.01,
|
||||
"shadow_mu": -1.01,
|
||||
"legacy_top_bucket": -1,
|
||||
"shadow_top_bucket": -1,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "tokyo",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 17.0,
|
||||
"actual_bucket": 17,
|
||||
"legacy_mu": 17.0,
|
||||
"shadow_mu": 17.0,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 17,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 30.0,
|
||||
"actual_bucket": 30,
|
||||
"legacy_mu": 30.3,
|
||||
"shadow_mu": 30.3,
|
||||
"legacy_top_bucket": 30,
|
||||
"shadow_top_bucket": 30,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 29.0,
|
||||
"actual_bucket": 29,
|
||||
"legacy_mu": 29.0,
|
||||
"shadow_mu": 29.0,
|
||||
"legacy_top_bucket": 29,
|
||||
"shadow_top_bucket": 29,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "singapore",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 32.0,
|
||||
"actual_bucket": 32,
|
||||
"legacy_mu": 32.0,
|
||||
"shadow_mu": 32.0,
|
||||
"legacy_top_bucket": 32,
|
||||
"shadow_top_bucket": 32,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 13.0,
|
||||
"actual_bucket": 13,
|
||||
"legacy_mu": 13.0,
|
||||
"shadow_mu": 13.0,
|
||||
"legacy_top_bucket": 13,
|
||||
"shadow_top_bucket": 13,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "seoul",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 8.0,
|
||||
"actual_bucket": 8,
|
||||
"legacy_mu": 8.0,
|
||||
"shadow_mu": 8.0,
|
||||
"legacy_top_bucket": 8,
|
||||
"shadow_top_bucket": 8,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "seattle",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 55.9,
|
||||
"actual_bucket": 56,
|
||||
"legacy_mu": 55.9,
|
||||
"shadow_mu": 55.9,
|
||||
"legacy_top_bucket": 56,
|
||||
"shadow_top_bucket": 56,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "sao paulo",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 22.0,
|
||||
"actual_bucket": 22,
|
||||
"legacy_mu": 29.64,
|
||||
"shadow_mu": 29.64,
|
||||
"legacy_top_bucket": 30,
|
||||
"shadow_top_bucket": 30,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"actual_bucket": 14,
|
||||
"legacy_mu": 14.47,
|
||||
"shadow_mu": 14.47,
|
||||
"legacy_top_bucket": 14,
|
||||
"shadow_top_bucket": 14,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "new york",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 32.0,
|
||||
"actual_bucket": 32,
|
||||
"legacy_mu": 36.94,
|
||||
"shadow_mu": 36.94,
|
||||
"legacy_top_bucket": 37,
|
||||
"shadow_top_bucket": 37,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "munich",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 10.0,
|
||||
"actual_bucket": 10,
|
||||
"legacy_mu": 10.66,
|
||||
"shadow_mu": 10.66,
|
||||
"legacy_top_bucket": 11,
|
||||
"shadow_top_bucket": 11,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"actual_bucket": 14,
|
||||
"legacy_mu": 14.3,
|
||||
"shadow_mu": 14.3,
|
||||
"legacy_top_bucket": 14,
|
||||
"shadow_top_bucket": 14,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "miami",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 59.0,
|
||||
"actual_bucket": 59,
|
||||
"legacy_mu": 70.04,
|
||||
"shadow_mu": 70.04,
|
||||
"legacy_top_bucket": 70,
|
||||
"shadow_top_bucket": 70,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "madrid",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"actual_bucket": 14,
|
||||
"legacy_mu": 18.43,
|
||||
"shadow_mu": 18.43,
|
||||
"legacy_top_bucket": 18,
|
||||
"shadow_top_bucket": 18,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "lucknow",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 34.0,
|
||||
"actual_bucket": 34,
|
||||
"legacy_mu": 35.11,
|
||||
"shadow_mu": 35.11,
|
||||
"legacy_top_bucket": 35,
|
||||
"shadow_top_bucket": 35,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "london",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 16.0,
|
||||
"actual_bucket": 16,
|
||||
"legacy_mu": 16.56,
|
||||
"shadow_mu": 16.56,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 17,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 27.8,
|
||||
"actual_bucket": 27,
|
||||
"legacy_mu": 27.8,
|
||||
"shadow_mu": 27.8,
|
||||
"legacy_top_bucket": 28,
|
||||
"shadow_top_bucket": 27,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "dallas",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 73.9,
|
||||
"actual_bucket": 74,
|
||||
"legacy_mu": 73.9,
|
||||
"shadow_mu": 73.9,
|
||||
"legacy_top_bucket": 74,
|
||||
"shadow_top_bucket": 74,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "chicago",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 46.0,
|
||||
"actual_bucket": 46,
|
||||
"legacy_mu": 46.0,
|
||||
"shadow_mu": 46.0,
|
||||
"legacy_top_bucket": 46,
|
||||
"shadow_top_bucket": 46,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "buenos aires",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 17.0,
|
||||
"actual_bucket": 17,
|
||||
"legacy_mu": 26.15,
|
||||
"shadow_mu": 26.15,
|
||||
"legacy_top_bucket": 26,
|
||||
"shadow_top_bucket": 26,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "atlanta",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 37.0,
|
||||
"actual_bucket": 37,
|
||||
"legacy_mu": 54.06,
|
||||
"shadow_mu": 54.06,
|
||||
"legacy_top_bucket": 54,
|
||||
"shadow_top_bucket": 54,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 15.0,
|
||||
"actual_bucket": 15,
|
||||
"legacy_mu": 15.2,
|
||||
"shadow_mu": 15.2,
|
||||
"legacy_top_bucket": 15,
|
||||
"shadow_top_bucket": 15,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 11.0,
|
||||
"actual_bucket": 11,
|
||||
"legacy_mu": 11.3,
|
||||
"shadow_mu": 11.3,
|
||||
"legacy_top_bucket": 11,
|
||||
"shadow_top_bucket": 11,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 26.7,
|
||||
"actual_bucket": 27,
|
||||
"legacy_mu": 26.7,
|
||||
"shadow_mu": 26.7,
|
||||
"legacy_top_bucket": 27,
|
||||
"shadow_top_bucket": 26,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 17.0,
|
||||
"actual_bucket": 17,
|
||||
"legacy_mu": 17.3,
|
||||
"shadow_mu": 17.3,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 17,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 24.0,
|
||||
"actual_bucket": 24,
|
||||
"legacy_mu": 24.0,
|
||||
"shadow_mu": 24.0,
|
||||
"legacy_top_bucket": 24,
|
||||
"shadow_top_bucket": 23,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
{
|
||||
"sample_count": 54,
|
||||
"snapshot_sample_count": 1,
|
||||
"daily_record_sample_count": 53,
|
||||
"filled_actual_from_history": 2,
|
||||
"samples": [
|
||||
{
|
||||
"city": "shenzhen",
|
||||
"date": "2026-03-25",
|
||||
"timestamp": "2026-03-25T08:57:11.783182+00:00",
|
||||
"actual_high": 28.0,
|
||||
"raw_mu": 26.7,
|
||||
"raw_sigma": 0.18016764322916676,
|
||||
"deb_prediction": 28.1,
|
||||
"ens_median": 31.4,
|
||||
"ensemble_spread": 0.5078125000000002,
|
||||
"max_so_far_gap": 1.4000000000000021,
|
||||
"peak_flag": 1.0,
|
||||
"sample_source": "snapshot",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 15.0,
|
||||
"raw_mu": 15.2,
|
||||
"raw_sigma": 1.2000000000000002,
|
||||
"deb_prediction": 15.4,
|
||||
"ens_median": 15.8,
|
||||
"ensemble_spread": 1.2000000000000002,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 11.0,
|
||||
"raw_mu": 11.0,
|
||||
"raw_sigma": 2.05,
|
||||
"deb_prediction": 9.8,
|
||||
"ens_median": 10.1,
|
||||
"ensemble_spread": 2.05,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-29",
|
||||
"actual_high": 10.0,
|
||||
"raw_mu": 10.0,
|
||||
"raw_sigma": 0.9000000000000004,
|
||||
"deb_prediction": 9.7,
|
||||
"ens_median": 10.0,
|
||||
"ensemble_spread": 0.9000000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "atlanta",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 37.0,
|
||||
"raw_mu": 54.06,
|
||||
"raw_sigma": 4.0,
|
||||
"deb_prediction": 52.5,
|
||||
"ens_median": 52.2,
|
||||
"ensemble_spread": 4.0,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "atlanta",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 18.6,
|
||||
"raw_mu": 65.57,
|
||||
"raw_sigma": 1.5500000000000007,
|
||||
"deb_prediction": 65.7,
|
||||
"ens_median": 65.9,
|
||||
"ensemble_spread": 1.5500000000000007,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "buenos aires",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 17.0,
|
||||
"raw_mu": 26.15,
|
||||
"raw_sigma": 2.0,
|
||||
"deb_prediction": 26.4,
|
||||
"ens_median": 26.0,
|
||||
"ensemble_spread": 2.0,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "buenos aires",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 16.0,
|
||||
"raw_mu": 27.39,
|
||||
"raw_sigma": 2.0999999999999996,
|
||||
"deb_prediction": 26.8,
|
||||
"ens_median": 27.9,
|
||||
"ensemble_spread": 2.0999999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "chicago",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 46.0,
|
||||
"raw_mu": 46.0,
|
||||
"raw_sigma": 5.350000000000001,
|
||||
"deb_prediction": 42.7,
|
||||
"ens_median": 44.2,
|
||||
"ensemble_spread": 5.350000000000001,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "dallas",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 73.9,
|
||||
"raw_mu": 73.9,
|
||||
"raw_sigma": 9.299999999999997,
|
||||
"deb_prediction": 75.2,
|
||||
"ens_median": 74.4,
|
||||
"ensemble_spread": 9.299999999999997,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 24.0,
|
||||
"raw_mu": 24.0,
|
||||
"raw_sigma": 1.9000000000000004,
|
||||
"deb_prediction": 23.1,
|
||||
"ens_median": 23.1,
|
||||
"ensemble_spread": 1.9000000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 27.8,
|
||||
"raw_mu": 27.8,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 24.5,
|
||||
"ens_median": 24.9,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 27.3,
|
||||
"raw_mu": 27.6,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 24.9,
|
||||
"ens_median": 25.0,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-23",
|
||||
"actual_high": 27.4,
|
||||
"raw_mu": 27.4,
|
||||
"raw_sigma": 1.6999999999999993,
|
||||
"deb_prediction": 25.2,
|
||||
"ens_median": 25.1,
|
||||
"ensemble_spread": 1.6999999999999993,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "london",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 16.0,
|
||||
"raw_mu": 16.56,
|
||||
"raw_sigma": 1.299999999999999,
|
||||
"deb_prediction": 16.5,
|
||||
"ens_median": 16.8,
|
||||
"ensemble_spread": 1.299999999999999,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "london",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"raw_mu": 15.71,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 15.9,
|
||||
"ens_median": 15.8,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "lucknow",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 34.0,
|
||||
"raw_mu": 35.11,
|
||||
"raw_sigma": 1.3000000000000007,
|
||||
"deb_prediction": 34.3,
|
||||
"ens_median": 34.6,
|
||||
"ensemble_spread": 1.3000000000000007,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "lucknow",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 30.0,
|
||||
"raw_mu": 35.3,
|
||||
"raw_sigma": 1.75,
|
||||
"deb_prediction": 34.5,
|
||||
"ens_median": 34.7,
|
||||
"ensemble_spread": 1.75,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "madrid",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"raw_mu": 18.43,
|
||||
"raw_sigma": 1.8499999999999996,
|
||||
"deb_prediction": 18.3,
|
||||
"ens_median": 18.7,
|
||||
"ensemble_spread": 1.8499999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "madrid",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"raw_mu": 18.23,
|
||||
"raw_sigma": 1.8999999999999995,
|
||||
"deb_prediction": 18.4,
|
||||
"ens_median": 18.8,
|
||||
"ensemble_spread": 1.8999999999999995,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "miami",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 59.0,
|
||||
"raw_mu": 70.04,
|
||||
"raw_sigma": 2.8999999999999986,
|
||||
"deb_prediction": 69.1,
|
||||
"ens_median": 70.4,
|
||||
"ensemble_spread": 2.8999999999999986,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "miami",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 24.6,
|
||||
"raw_mu": 73.91,
|
||||
"raw_sigma": 2.5500000000000043,
|
||||
"deb_prediction": 74.2,
|
||||
"ens_median": 74.0,
|
||||
"ensemble_spread": 2.5500000000000043,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 17.0,
|
||||
"raw_mu": 17.3,
|
||||
"raw_sigma": 9.1,
|
||||
"deb_prediction": 12.5,
|
||||
"ens_median": 15.1,
|
||||
"ensemble_spread": 9.1,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"raw_mu": 14.3,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 13.7,
|
||||
"ens_median": 13.8,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 5.0,
|
||||
"raw_mu": 16.58,
|
||||
"raw_sigma": 1.25,
|
||||
"deb_prediction": 16.9,
|
||||
"ens_median": 17.0,
|
||||
"ensemble_spread": 1.25,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "munich",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 10.0,
|
||||
"raw_mu": 10.66,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 10.6,
|
||||
"ens_median": 10.6,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "munich",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 5.0,
|
||||
"raw_mu": 11.62,
|
||||
"raw_sigma": 1.3000000000000007,
|
||||
"deb_prediction": 11.5,
|
||||
"ens_median": 11.8,
|
||||
"ensemble_spread": 1.3000000000000007,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "new york",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 32.0,
|
||||
"raw_mu": 36.94,
|
||||
"raw_sigma": 2.25,
|
||||
"deb_prediction": 36.8,
|
||||
"ens_median": 37.0,
|
||||
"ensemble_spread": 2.25,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"raw_mu": 14.47,
|
||||
"raw_sigma": 0.9000000000000004,
|
||||
"deb_prediction": 14.2,
|
||||
"ens_median": 14.8,
|
||||
"ensemble_spread": 0.9000000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"raw_mu": 16.06,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 16.2,
|
||||
"ens_median": 16.3,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "sao paulo",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 22.0,
|
||||
"raw_mu": 29.64,
|
||||
"raw_sigma": 2.450000000000001,
|
||||
"deb_prediction": 30.0,
|
||||
"ens_median": 29.4,
|
||||
"ensemble_spread": 2.450000000000001,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "sao paulo",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 21.0,
|
||||
"raw_mu": 26.5,
|
||||
"raw_sigma": 1.200000000000001,
|
||||
"deb_prediction": 25.5,
|
||||
"ens_median": 26.5,
|
||||
"ensemble_spread": 1.200000000000001,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "seattle",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 55.9,
|
||||
"raw_mu": 55.9,
|
||||
"raw_sigma": 1.3500000000000014,
|
||||
"deb_prediction": 55.8,
|
||||
"ens_median": 55.9,
|
||||
"ensemble_spread": 1.3500000000000014,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "seoul",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 8.0,
|
||||
"raw_mu": 8.0,
|
||||
"raw_sigma": 0.7999999999999998,
|
||||
"deb_prediction": 7.4,
|
||||
"ens_median": 7.4,
|
||||
"ensemble_spread": 0.7999999999999998,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "seoul",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 10.0,
|
||||
"raw_mu": 10.3,
|
||||
"raw_sigma": 1.7999999999999998,
|
||||
"deb_prediction": 7.6,
|
||||
"ens_median": 6.3,
|
||||
"ensemble_spread": 1.7999999999999998,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 13.0,
|
||||
"raw_mu": 13.0,
|
||||
"raw_sigma": 1.1500000000000004,
|
||||
"deb_prediction": 13.2,
|
||||
"ens_median": 13.0,
|
||||
"ensemble_spread": 1.1500000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 12.0,
|
||||
"raw_mu": 12.3,
|
||||
"raw_sigma": 0.7999999999999998,
|
||||
"deb_prediction": 10.7,
|
||||
"ens_median": 11.2,
|
||||
"ensemble_spread": 0.7999999999999998,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-29",
|
||||
"actual_high": 18.0,
|
||||
"raw_mu": 18.0,
|
||||
"raw_sigma": 1.6999999999999993,
|
||||
"deb_prediction": 18.4,
|
||||
"ens_median": 17.6,
|
||||
"ensemble_spread": 1.6999999999999993,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "singapore",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 32.0,
|
||||
"raw_mu": 32.0,
|
||||
"raw_sigma": 0.9500000000000011,
|
||||
"deb_prediction": 30.2,
|
||||
"ens_median": 30.1,
|
||||
"ensemble_spread": 0.9500000000000011,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "singapore",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 32.0,
|
||||
"raw_mu": 32.3,
|
||||
"raw_sigma": 1.3499999999999996,
|
||||
"deb_prediction": 31.5,
|
||||
"ens_median": 32.1,
|
||||
"ensemble_spread": 1.3499999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 26.7,
|
||||
"raw_mu": 26.7,
|
||||
"raw_sigma": 2.25,
|
||||
"deb_prediction": 24.9,
|
||||
"ens_median": 25.4,
|
||||
"ensemble_spread": 2.25,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 29.0,
|
||||
"raw_mu": 29.0,
|
||||
"raw_sigma": 1.0500000000000007,
|
||||
"deb_prediction": 27.2,
|
||||
"ens_median": 27.5,
|
||||
"ensemble_spread": 1.0500000000000007,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 22.0,
|
||||
"raw_mu": 21.7,
|
||||
"raw_sigma": 1.1500000000000004,
|
||||
"deb_prediction": 21.5,
|
||||
"ens_median": 21.3,
|
||||
"ensemble_spread": 1.1500000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 30.0,
|
||||
"raw_mu": 30.3,
|
||||
"raw_sigma": 2.1500000000000004,
|
||||
"deb_prediction": 29.0,
|
||||
"ens_median": 28.9,
|
||||
"ensemble_spread": 2.1500000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 21.0,
|
||||
"raw_mu": 21.3,
|
||||
"raw_sigma": 1.5,
|
||||
"deb_prediction": 20.7,
|
||||
"ens_median": 21.1,
|
||||
"ensemble_spread": 1.5,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "tokyo",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 17.0,
|
||||
"raw_mu": 17.0,
|
||||
"raw_sigma": 1.5499999999999998,
|
||||
"deb_prediction": 15.4,
|
||||
"ens_median": 15.8,
|
||||
"ensemble_spread": 1.5499999999999998,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "tokyo",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 16.0,
|
||||
"raw_mu": 16.5,
|
||||
"raw_sigma": 2.0999999999999996,
|
||||
"deb_prediction": 17.8,
|
||||
"ens_median": 18.5,
|
||||
"ensemble_spread": 2.0999999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "toronto",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": -6.0,
|
||||
"raw_mu": -1.01,
|
||||
"raw_sigma": 0.8,
|
||||
"deb_prediction": -0.6,
|
||||
"ens_median": -1.1,
|
||||
"ensemble_spread": 0.8,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "toronto",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": -2.0,
|
||||
"raw_mu": 5.67,
|
||||
"raw_sigma": 2.1500000000000004,
|
||||
"deb_prediction": 6.4,
|
||||
"ens_median": 6.3,
|
||||
"ensemble_spread": 2.1500000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 11.0,
|
||||
"raw_mu": 11.3,
|
||||
"raw_sigma": 0.6499999999999995,
|
||||
"deb_prediction": 10.4,
|
||||
"ens_median": 10.5,
|
||||
"ensemble_spread": 0.6499999999999995,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 13.0,
|
||||
"raw_mu": 13.84,
|
||||
"raw_sigma": 1.4000000000000004,
|
||||
"deb_prediction": 13.6,
|
||||
"ens_median": 14.2,
|
||||
"ensemble_spread": 1.4000000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 7.0,
|
||||
"raw_mu": 12.03,
|
||||
"raw_sigma": 1.5999999999999996,
|
||||
"deb_prediction": 11.8,
|
||||
"ens_median": 12.3,
|
||||
"ensemble_spread": 1.5999999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "wellington",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 21.0,
|
||||
"raw_mu": 21.0,
|
||||
"raw_sigma": 0.9500000000000011,
|
||||
"deb_prediction": 19.2,
|
||||
"ens_median": 19.1,
|
||||
"ensemble_spread": 0.9500000000000011,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "wellington",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 18.0,
|
||||
"raw_mu": 18.3,
|
||||
"raw_sigma": 2.0999999999999996,
|
||||
"deb_prediction": 17.9,
|
||||
"ens_median": 17.1,
|
||||
"ensemble_spread": 2.0999999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,416 +1,13 @@
|
||||
import sys
|
||||
import os
|
||||
import telebot # type: ignore
|
||||
from loguru import logger # type: ignore
|
||||
import sys
|
||||
|
||||
# 确保项目根目录在 sys.path 中
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from src.utils.config_loader import load_config # type: ignore # noqa: E402
|
||||
from src.utils.telegram_push import start_trade_alert_push_loop # type: ignore # noqa: E402
|
||||
from src.onchain.polygon_wallet_watcher import start_polygon_wallet_watch_loop # type: ignore # noqa: E402
|
||||
from src.onchain.polymarket_wallet_activity_watcher import start_polymarket_wallet_activity_loop # type: ignore # noqa: E402
|
||||
from src.data_collection.weather_sources import WeatherDataCollector # type: ignore # noqa: E402
|
||||
from src.database.db_manager import DBManager
|
||||
from src.analysis.city_query_service import (
|
||||
resolve_city_name,
|
||||
build_city_query_report,
|
||||
)
|
||||
|
||||
MESSAGE_POINTS = 4
|
||||
MESSAGE_DAILY_CAP = 50
|
||||
MESSAGE_MIN_LENGTH = 2
|
||||
MESSAGE_COOLDOWN_SEC = 30
|
||||
CITY_QUERY_COST = 1
|
||||
DEB_QUERY_COST = 1
|
||||
|
||||
|
||||
def start_bot():
|
||||
config = load_config()
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
if not token:
|
||||
logger.error("未找到 TELEGRAM_BOT_TOKEN 环境变量")
|
||||
return
|
||||
|
||||
bot = telebot.TeleBot(token)
|
||||
db = DBManager()
|
||||
weather = WeatherDataCollector(config)
|
||||
start_trade_alert_push_loop(bot, config)
|
||||
start_polygon_wallet_watch_loop(bot)
|
||||
start_polymarket_wallet_activity_loop(bot)
|
||||
|
||||
def _display_name(user) -> str:
|
||||
return user.username or user.first_name or f"User_{user.id}"
|
||||
|
||||
def _ensure_query_points(message, cost: int, label: str) -> bool:
|
||||
user = message.from_user
|
||||
db.upsert_user(user.id, _display_name(user))
|
||||
result = db.spend_points(user.id, cost)
|
||||
if result.get("ok"):
|
||||
return True
|
||||
|
||||
balance = int(result.get("balance") or 0)
|
||||
required = int(result.get("required") or cost)
|
||||
missing = max(0, required - balance)
|
||||
bot.reply_to(
|
||||
message,
|
||||
(
|
||||
f"❌ 积分不足,无法执行 <b>{label}</b>\n"
|
||||
f"当前积分: <code>{balance}</code>\n"
|
||||
f"需要积分: <code>{required}</code>\n"
|
||||
f"还差积分: <code>{missing}</code>\n\n"
|
||||
f"积分规则:每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return False
|
||||
|
||||
@bot.message_handler(commands=["start", "help"])
|
||||
def send_welcome(message):
|
||||
welcome_text = (
|
||||
"🚀 <b>PolyWeather 天气查询机器人</b>\n\n"
|
||||
"可用指令:\n"
|
||||
f"/city [城市名] - 查询城市天气预测与实测 (消耗 {CITY_QUERY_COST} 积分)\n"
|
||||
f"/deb [城市名] - 查看 DEB 融合预测准确率 (消耗 {DEB_QUERY_COST} 积分)\n"
|
||||
"/top - 查看积分排行榜\n"
|
||||
"/id - 获取当前聊天的 Chat ID\n\n"
|
||||
"示例: <code>/city 伦敦</code>\n"
|
||||
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
|
||||
)
|
||||
bot.reply_to(message, welcome_text, parse_mode="HTML")
|
||||
|
||||
@bot.message_handler(commands=["id"])
|
||||
def get_chat_id(message):
|
||||
bot.reply_to(
|
||||
message,
|
||||
f"🎯 当前聊天的 Chat ID 是: <code>{message.chat.id}</code>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
@bot.message_handler(commands=["top"])
|
||||
def show_points(message):
|
||||
"""显示当前用户的积分及排行榜"""
|
||||
user = message.from_user
|
||||
db.upsert_user(user.id, _display_name(user))
|
||||
user_info = db.get_user(user.id)
|
||||
|
||||
leaderboard = db.get_leaderboard(limit=5)
|
||||
rank_text = "🏆 <b>PolyWeather 活跃度排行榜</b>\n"
|
||||
rank_text += "────────────────────\n"
|
||||
for i, entry in enumerate(leaderboard):
|
||||
medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
|
||||
rank_text += f"{medal} {entry['username'][:12]}: <b>{entry['points']}</b> 点\n"
|
||||
|
||||
if user_info:
|
||||
rank_text += "────────────────────\n"
|
||||
rank_text += (
|
||||
f"👤 <b>我的状态:</b>\n"
|
||||
f"┣ 积分: <code>{user_info['points']}</code>\n"
|
||||
f"┣ 发言: <code>{user_info['message_count']}</code> 次\n"
|
||||
f"┣ 今日发言积分: <code>{user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}</code>\n"
|
||||
f"┗ /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
|
||||
)
|
||||
|
||||
bot.send_message(message.chat.id, rank_text, parse_mode="HTML")
|
||||
@bot.message_handler(commands=["deb"])
|
||||
def deb_accuracy(message):
|
||||
"""查询 DEB 融合预测的近 7 天准确率。"""
|
||||
try:
|
||||
parts = message.text.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
bot.reply_to(
|
||||
message,
|
||||
"❌ 用法: <code>/deb ankara</code>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
import os as _os
|
||||
|
||||
from src.analysis.deb_algorithm import (
|
||||
load_history,
|
||||
_is_excluded_model_name,
|
||||
reconcile_recent_actual_highs,
|
||||
)
|
||||
from src.data_collection.city_registry import ALIASES
|
||||
|
||||
city_input = parts[1].strip().lower()
|
||||
city_name = ALIASES.get(city_input, city_input)
|
||||
|
||||
project_root = _os.path.dirname(_os.path.abspath(__file__))
|
||||
history_file = _os.path.join(project_root, "data", "daily_records.json")
|
||||
data = load_history(history_file)
|
||||
|
||||
if city_name not in data or not data[city_name]:
|
||||
bot.reply_to(
|
||||
message,
|
||||
f"❌ 暂无 {city_name} 的历史数据。",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
if not _ensure_query_points(message, DEB_QUERY_COST, "/deb"):
|
||||
return
|
||||
|
||||
reconcile_info = reconcile_recent_actual_highs(city_name, lookback_days=7)
|
||||
# Reload in case reconciliation updated the file
|
||||
data = load_history(history_file)
|
||||
city_data = data[city_name]
|
||||
today = _dt.now().date()
|
||||
today_str = today.strftime("%Y-%m-%d")
|
||||
cutoff_date = today - _td(days=6)
|
||||
|
||||
recent_items = []
|
||||
for date_str, record in city_data.items():
|
||||
try:
|
||||
row_date = _dt.strptime(date_str, "%Y-%m-%d").date()
|
||||
except Exception:
|
||||
continue
|
||||
if row_date >= cutoff_date:
|
||||
recent_items.append((date_str, record, row_date))
|
||||
|
||||
recent_items.sort(key=lambda item: item[0])
|
||||
|
||||
lines = [
|
||||
f"📊 <b>DEB 准确率报告 - {city_name.title()}</b>",
|
||||
"",
|
||||
"📅 <b>近日记录:</b>",
|
||||
]
|
||||
if (
|
||||
isinstance(reconcile_info, dict)
|
||||
and reconcile_info.get("ok")
|
||||
and int(reconcile_info.get("updated") or 0) > 0
|
||||
):
|
||||
lines.extend(
|
||||
[
|
||||
f"🔁 已用 METAR 历史回填修正 {int(reconcile_info.get('updated'))} 天实测最高温",
|
||||
"",
|
||||
]
|
||||
)
|
||||
total_days = 0
|
||||
hits = 0
|
||||
deb_errors = []
|
||||
signed_errors = []
|
||||
model_errors = {}
|
||||
|
||||
for date_str, record, _row_date in recent_items:
|
||||
actual = record.get("actual_high")
|
||||
deb_pred = record.get("deb_prediction")
|
||||
forecasts = record.get("forecasts", {}) or {}
|
||||
|
||||
if actual is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
actual = float(actual)
|
||||
if deb_pred is not None:
|
||||
deb_pred = float(deb_pred)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if deb_pred is None and forecasts:
|
||||
valid_preds = [
|
||||
float(v)
|
||||
for k, v in forecasts.items()
|
||||
if v is not None and not _is_excluded_model_name(k)
|
||||
]
|
||||
if valid_preds:
|
||||
deb_pred = round(sum(valid_preds) / len(valid_preds), 1)
|
||||
|
||||
actual_wu = round(actual)
|
||||
|
||||
if date_str == today_str:
|
||||
lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual:.1f})")
|
||||
elif deb_pred is not None:
|
||||
total_days += 1
|
||||
deb_wu = round(deb_pred)
|
||||
hit = deb_wu == actual_wu
|
||||
if hit:
|
||||
hits += 1
|
||||
err = deb_pred - actual
|
||||
deb_errors.append(abs(err))
|
||||
signed_errors.append(err)
|
||||
|
||||
if hit:
|
||||
result_icon = "✅"
|
||||
err_text = f"偏差{abs(err):.1f}°"
|
||||
elif err < 0:
|
||||
result_icon = "❌"
|
||||
err_text = f"低估{abs(err):.1f}°"
|
||||
else:
|
||||
result_icon = "❌"
|
||||
err_text = f"高估{abs(err):.1f}°"
|
||||
|
||||
retro = "≈" if "deb_prediction" not in record else ""
|
||||
lines.append(
|
||||
f" {date_str}: DEB {retro}{deb_pred:.1f}→{deb_wu} vs 实测 {actual:.1f}→{actual_wu} "
|
||||
f"{result_icon} {err_text}"
|
||||
)
|
||||
|
||||
if date_str != today_str and actual is not None:
|
||||
for model, pred in forecasts.items():
|
||||
if _is_excluded_model_name(model):
|
||||
continue
|
||||
if pred is None:
|
||||
continue
|
||||
try:
|
||||
model_errors.setdefault(model, []).append(abs(float(pred) - actual))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if total_days > 0:
|
||||
hit_rate = hits / total_days * 100
|
||||
deb_mae = sum(deb_errors) / len(deb_errors)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"🏁 <b>DEB 总战绩:</b>WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
|
||||
)
|
||||
|
||||
if model_errors:
|
||||
lines.append("")
|
||||
lines.append("📈 <b>模型 MAE 对比:</b>")
|
||||
model_maes = {m: sum(e) / len(e) for m, e in model_errors.items() if e}
|
||||
sorted_models = sorted(model_maes.items(), key=lambda item: item[1])
|
||||
for model, mae in sorted_models:
|
||||
tag = " ⭐" if mae <= deb_mae else ""
|
||||
lines.append(f" {model}: {mae:.1f}°{tag}")
|
||||
lines.append(f" <b>DEB融合: {deb_mae:.1f}°</b>")
|
||||
|
||||
mean_bias = sum(signed_errors) / len(signed_errors)
|
||||
underest = sum(1 for e in signed_errors if e < -0.3)
|
||||
overest = sum(1 for e in signed_errors if e > 0.3)
|
||||
accurate = total_days - underest - overest
|
||||
|
||||
lines.append("")
|
||||
lines.append("🔍 <b>偏差分析:</b>")
|
||||
if abs(mean_bias) > 0.3:
|
||||
bias_label = "系统性低估" if mean_bias < 0 else "系统性高估"
|
||||
lines.append(f" ⚠️ {bias_label}:平均偏差 {mean_bias:+.1f}°")
|
||||
else:
|
||||
lines.append(f" ✅ 整体无明显系统偏差:平均偏差 {mean_bias:+.1f}°")
|
||||
lines.append(f" (低估 {underest} 次 | 高估 {overest} 次 | 准确 {accurate} 次)")
|
||||
|
||||
lines.append("")
|
||||
lines.append("💡 <b>建议:</b>")
|
||||
if underest > overest and abs(mean_bias) > 0.5:
|
||||
lines.append(
|
||||
f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 "
|
||||
f"{abs(mean_bias):.0f}-{abs(mean_bias) + 0.5:.0f}°。交易时建议适当看高。"
|
||||
)
|
||||
elif overest > underest and abs(mean_bias) > 0.5:
|
||||
lines.append(
|
||||
f" 该城市模型集体高估趋势明显({mean_bias:+.1f}°),实际最高温可能低于 DEB 融合值。交易时注意追高风险。"
|
||||
)
|
||||
elif deb_mae > 1.5:
|
||||
lines.append(f" 近期模型波动较大(MAE {deb_mae:.1f}°),建议降低对单一日预测的信任度。")
|
||||
elif hit_rate >= 60:
|
||||
lines.append(" DEB 近期表现稳定,可继续作为主要参考。")
|
||||
else:
|
||||
lines.append(" 近期准确率一般,建议结合主站实测与周边站点共同判断。")
|
||||
|
||||
lines.append("")
|
||||
lines.append("📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。")
|
||||
lines.append("📅 统计窗口:近7天滚动样本。")
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("🔔 近 7 天尚无完整的 DEB 预测记录。")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"💸 本次消耗 <code>{DEB_QUERY_COST}</code> 积分。")
|
||||
bot.reply_to(message, "\n".join(lines), parse_mode="HTML")
|
||||
except Exception as e:
|
||||
bot.reply_to(message, f"❌ 查询失败: {e}")
|
||||
|
||||
@bot.message_handler(commands=["city"])
|
||||
def get_city_info(message):
|
||||
"""查询指定城市的天气详情"""
|
||||
try:
|
||||
parts = message.text.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
bot.reply_to(
|
||||
message,
|
||||
"❌ 请输入城市名称\n\n用法: <code>/city chicago</code>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
city_input = parts[1].strip().lower()
|
||||
city_name, supported_cities = resolve_city_name(city_input)
|
||||
if not city_name:
|
||||
city_list = ", ".join(supported_cities)
|
||||
bot.reply_to(
|
||||
message,
|
||||
f"❌ 未找到城市: <b>{city_input}</b>\n\n支持的城市: {city_list}",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
if not _ensure_query_points(message, CITY_QUERY_COST, "/city"):
|
||||
return
|
||||
|
||||
bot.send_message(
|
||||
message.chat.id, f"🔍 正在查询 {city_name.title()} 的天气数据..."
|
||||
)
|
||||
|
||||
coords = weather.get_coordinates(city_name)
|
||||
if not coords:
|
||||
bot.reply_to(message, f"❌ 未找到城市坐标: {city_name}")
|
||||
return
|
||||
|
||||
weather_data = weather.fetch_all_sources(
|
||||
city_name,
|
||||
lat=coords["lat"],
|
||||
lon=coords["lon"],
|
||||
force_refresh=True,
|
||||
)
|
||||
city_report = build_city_query_report(
|
||||
city_name=city_name,
|
||||
weather_data=weather_data,
|
||||
city_query_cost=CITY_QUERY_COST,
|
||||
)
|
||||
bot.send_message(message.chat.id, city_report, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
logger.error(f"查询失败: {e}\n{traceback.format_exc()}")
|
||||
bot.reply_to(message, f"❌ 查询失败: {e}")
|
||||
|
||||
@bot.message_handler(func=lambda message: True, content_types=['text'])
|
||||
def track_activity(message):
|
||||
"""全量监听消息,用于记录群内发言积分(非指令消息)"""
|
||||
if message.text.startswith('/'):
|
||||
return
|
||||
if message.chat.type not in ("group", "supergroup"):
|
||||
return
|
||||
|
||||
user = message.from_user
|
||||
username = _display_name(user)
|
||||
db.upsert_user(user.id, username)
|
||||
|
||||
result = db.add_message_activity(
|
||||
user.id,
|
||||
text=message.text,
|
||||
points_to_add=MESSAGE_POINTS,
|
||||
cooldown_sec=MESSAGE_COOLDOWN_SEC,
|
||||
daily_cap=MESSAGE_DAILY_CAP,
|
||||
min_text_length=MESSAGE_MIN_LENGTH,
|
||||
)
|
||||
if result.get("awarded"):
|
||||
logger.info(
|
||||
f"message points awarded user={user.id} points=+{MESSAGE_POINTS} "
|
||||
f"daily_points={result.get('daily_points')}/{MESSAGE_DAILY_CAP}"
|
||||
)
|
||||
|
||||
logger.info("🤖 Bot 启动中...")
|
||||
bot.infinity_polling()
|
||||
from src.bot.orchestrator import start_bot # noqa: E402
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start_bot()
|
||||
|
||||
|
||||
|
||||
@@ -1,71 +1,97 @@
|
||||
# Weather API Configuration
|
||||
weather:
|
||||
timeout: 30
|
||||
|
||||
# Target Cities
|
||||
cities:
|
||||
- id: "london"
|
||||
city: "London"
|
||||
country: "UK"
|
||||
latitude: 51.5074
|
||||
longitude: -0.1278
|
||||
- id: "paris"
|
||||
city: "Paris"
|
||||
country: "France"
|
||||
latitude: 48.8566
|
||||
longitude: 2.3522
|
||||
- id: "ankara"
|
||||
city: "Ankara"
|
||||
country: "Turkey"
|
||||
latitude: 39.9334
|
||||
longitude: 32.8597
|
||||
- id: "new_york"
|
||||
city: "New York"
|
||||
country: "USA"
|
||||
latitude: 40.7128
|
||||
longitude: -74.0060
|
||||
- id: "chicago"
|
||||
city: "Chicago"
|
||||
country: "USA"
|
||||
latitude: 41.8781
|
||||
longitude: -87.6298
|
||||
- id: "lucknow"
|
||||
city: "Lucknow"
|
||||
country: "India"
|
||||
latitude: 26.7606
|
||||
longitude: 80.8893
|
||||
- id: "sao paulo"
|
||||
city: "São Paulo"
|
||||
country: "Brazil"
|
||||
latitude: -23.4356
|
||||
longitude: -46.4731
|
||||
- id: "munich"
|
||||
city: "Munich"
|
||||
country: "Germany"
|
||||
latitude: 48.3538
|
||||
longitude: 11.7861
|
||||
- id: "hong_kong"
|
||||
city: "Hong Kong"
|
||||
country: "China"
|
||||
latitude: 22.3080
|
||||
longitude: 113.9185
|
||||
- id: "shanghai"
|
||||
city: "Shanghai"
|
||||
country: "China"
|
||||
latitude: 31.1434
|
||||
longitude: 121.8052
|
||||
- id: "singapore"
|
||||
city: "Singapore"
|
||||
country: "Singapore"
|
||||
latitude: 1.3644
|
||||
longitude: 103.9915
|
||||
- id: "tokyo"
|
||||
city: "Tokyo"
|
||||
country: "Japan"
|
||||
latitude: 35.5523
|
||||
longitude: 139.7798
|
||||
# Logging
|
||||
- id: london
|
||||
city: London
|
||||
country: UK
|
||||
latitude: 51.5074
|
||||
longitude: -0.1278
|
||||
- id: paris
|
||||
city: Paris
|
||||
country: France
|
||||
latitude: 48.8566
|
||||
longitude: 2.3522
|
||||
- id: ankara
|
||||
city: Ankara
|
||||
country: Turkey
|
||||
latitude: 39.9334
|
||||
longitude: 32.8597
|
||||
- id: new_york
|
||||
city: New York
|
||||
country: USA
|
||||
latitude: 40.7128
|
||||
longitude: -74.006
|
||||
- id: chicago
|
||||
city: Chicago
|
||||
country: USA
|
||||
latitude: 41.8781
|
||||
longitude: -87.6298
|
||||
- id: lucknow
|
||||
city: Lucknow
|
||||
country: India
|
||||
latitude: 26.7606
|
||||
longitude: 80.8893
|
||||
- id: sao paulo
|
||||
city: São Paulo
|
||||
country: Brazil
|
||||
latitude: -23.4356
|
||||
longitude: -46.4731
|
||||
- id: munich
|
||||
city: Munich
|
||||
country: Germany
|
||||
latitude: 48.3538
|
||||
longitude: 11.7861
|
||||
- id: hong_kong
|
||||
city: Hong Kong
|
||||
country: China
|
||||
latitude: 22.3019
|
||||
longitude: 114.1742
|
||||
- id: shanghai
|
||||
city: Shanghai
|
||||
country: China
|
||||
latitude: 31.1434
|
||||
longitude: 121.8052
|
||||
- id: singapore
|
||||
city: Singapore
|
||||
country: Singapore
|
||||
latitude: 1.3644
|
||||
longitude: 103.9915
|
||||
- id: tokyo
|
||||
city: Tokyo
|
||||
country: Japan
|
||||
latitude: 35.5523
|
||||
longitude: 139.7798
|
||||
- id: tel_aviv
|
||||
city: Tel Aviv
|
||||
country: Israel
|
||||
latitude: 32.0114
|
||||
longitude: 34.8867
|
||||
- id: chengdu
|
||||
city: Chengdu
|
||||
country: China
|
||||
latitude: 30.5785
|
||||
longitude: 103.9471
|
||||
- id: chongqing
|
||||
city: Chongqing
|
||||
country: China
|
||||
latitude: 29.7196
|
||||
longitude: 106.6416
|
||||
- id: shenzhen
|
||||
city: Shenzhen
|
||||
country: China
|
||||
latitude: 22.6393
|
||||
longitude: 113.8107
|
||||
- id: beijing
|
||||
city: Beijing
|
||||
country: China
|
||||
latitude: 40.0801
|
||||
longitude: 116.5846
|
||||
- id: wuhan
|
||||
city: Wuhan
|
||||
country: China
|
||||
latitude: 30.7838
|
||||
longitude: 114.2081
|
||||
logging:
|
||||
level: "INFO"
|
||||
rotation: "10 MB"
|
||||
retention: "10 days"
|
||||
level: INFO
|
||||
rotation: 10 MB
|
||||
retention: 10 days
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
pragma solidity ^0.8.24;
|
||||
|
||||
interface IERC20 {
|
||||
function transferFrom(address from, address to, uint256 value) external returns (bool);
|
||||
}
|
||||
|
||||
contract PolyWeatherCheckout {
|
||||
address public owner;
|
||||
address public treasury;
|
||||
mapping(address => bool) public allowedToken;
|
||||
mapping(bytes32 => bool) public paidOrder;
|
||||
|
||||
event OrderPaid(
|
||||
bytes32 indexed orderId,
|
||||
address indexed payer,
|
||||
uint256 indexed planId,
|
||||
address token,
|
||||
uint256 amount
|
||||
);
|
||||
|
||||
modifier onlyOwner() {
|
||||
require(msg.sender == owner, "ONLY_OWNER");
|
||||
_;
|
||||
}
|
||||
|
||||
constructor(address _token, address _treasury) {
|
||||
require(_token != address(0) && _treasury != address(0), "ZERO_ADDR");
|
||||
owner = msg.sender;
|
||||
treasury = _treasury;
|
||||
allowedToken[_token] = true;
|
||||
}
|
||||
|
||||
function setTreasury(address _treasury) external onlyOwner {
|
||||
require(_treasury != address(0), "ZERO_ADDR");
|
||||
treasury = _treasury;
|
||||
}
|
||||
|
||||
function setTokenAllowed(address token, bool allowed) external onlyOwner {
|
||||
require(token != address(0), "ZERO_ADDR");
|
||||
allowedToken[token] = allowed;
|
||||
}
|
||||
|
||||
function pay(bytes32 orderId, uint256 planId, uint256 amount, address token) external {
|
||||
require(allowedToken[token], "TOKEN_NOT_ALLOWED");
|
||||
require(amount > 0, "AMOUNT_ZERO");
|
||||
require(!paidOrder[orderId], "ORDER_PAID");
|
||||
|
||||
paidOrder[orderId] = true;
|
||||
require(IERC20(token).transferFrom(msg.sender, treasury, amount), "TRANSFER_FAILED");
|
||||
|
||||
emit OrderPaid(orderId, msg.sender, planId, token, amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
pragma solidity ^0.8.24;
|
||||
|
||||
interface IERC20 {
|
||||
function transferFrom(address from, address to, uint256 value) external returns (bool);
|
||||
function transfer(address to, uint256 value) external returns (bool);
|
||||
}
|
||||
|
||||
library Address {
|
||||
function functionCall(address target, bytes memory data, string memory errorMessage) internal returns (bytes memory) {
|
||||
(bool success, bytes memory returndata) = target.call(data);
|
||||
require(success, errorMessage);
|
||||
return returndata;
|
||||
}
|
||||
}
|
||||
|
||||
library SafeERC20 {
|
||||
using Address for address;
|
||||
|
||||
function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {
|
||||
bytes memory returndata = address(token).functionCall(
|
||||
abi.encodeWithSelector(token.transferFrom.selector, from, to, value),
|
||||
"SAFE_TRANSFER_FROM_FAILED"
|
||||
);
|
||||
if (returndata.length > 0) {
|
||||
require(abi.decode(returndata, (bool)), "SAFE_TRANSFER_FROM_FALSE");
|
||||
}
|
||||
}
|
||||
|
||||
function safeTransfer(IERC20 token, address to, uint256 value) internal {
|
||||
bytes memory returndata = address(token).functionCall(
|
||||
abi.encodeWithSelector(token.transfer.selector, to, value),
|
||||
"SAFE_TRANSFER_FAILED"
|
||||
);
|
||||
if (returndata.length > 0) {
|
||||
require(abi.decode(returndata, (bool)), "SAFE_TRANSFER_FALSE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract contract Ownable {
|
||||
address public owner;
|
||||
|
||||
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
|
||||
|
||||
modifier onlyOwner() {
|
||||
require(msg.sender == owner, "ONLY_OWNER");
|
||||
_;
|
||||
}
|
||||
|
||||
constructor(address initialOwner) {
|
||||
require(initialOwner != address(0), "ZERO_OWNER");
|
||||
owner = initialOwner;
|
||||
emit OwnershipTransferred(address(0), initialOwner);
|
||||
}
|
||||
|
||||
function transferOwnership(address newOwner) external onlyOwner {
|
||||
require(newOwner != address(0), "ZERO_OWNER");
|
||||
emit OwnershipTransferred(owner, newOwner);
|
||||
owner = newOwner;
|
||||
}
|
||||
}
|
||||
|
||||
abstract contract Pausable {
|
||||
bool public paused;
|
||||
|
||||
event Paused(address indexed account);
|
||||
event Unpaused(address indexed account);
|
||||
|
||||
modifier whenNotPaused() {
|
||||
require(!paused, "PAUSED");
|
||||
_;
|
||||
}
|
||||
|
||||
function _pause() internal {
|
||||
require(!paused, "PAUSED");
|
||||
paused = true;
|
||||
emit Paused(msg.sender);
|
||||
}
|
||||
|
||||
function _unpause() internal {
|
||||
require(paused, "NOT_PAUSED");
|
||||
paused = false;
|
||||
emit Unpaused(msg.sender);
|
||||
}
|
||||
}
|
||||
|
||||
abstract contract ReentrancyGuard {
|
||||
uint256 private _status = 1;
|
||||
|
||||
modifier nonReentrant() {
|
||||
require(_status == 1, "REENTRANT");
|
||||
_status = 2;
|
||||
_;
|
||||
_status = 1;
|
||||
}
|
||||
}
|
||||
|
||||
contract PolyWeatherCheckoutV2 is Ownable, Pausable, ReentrancyGuard {
|
||||
using SafeERC20 for IERC20;
|
||||
|
||||
struct PlanConfig {
|
||||
uint256 amount;
|
||||
bool active;
|
||||
}
|
||||
|
||||
bytes32 public constant AUTHORIZED_PAYMENT_TYPEHASH =
|
||||
keccak256(
|
||||
"AuthorizedPayment(bytes32 orderId,address payer,uint256 planId,address token,uint256 amount,uint256 nonce,uint256 deadline)"
|
||||
);
|
||||
|
||||
bytes32 public immutable DOMAIN_SEPARATOR;
|
||||
|
||||
address public treasury;
|
||||
address public signer;
|
||||
mapping(address => bool) public allowedToken;
|
||||
mapping(bytes32 => bool) public paidOrder;
|
||||
mapping(uint256 => mapping(address => PlanConfig)) public planConfig;
|
||||
mapping(address => uint256) public payerNonce;
|
||||
|
||||
event OrderPaid(
|
||||
bytes32 indexed orderId,
|
||||
address indexed payer,
|
||||
uint256 indexed planId,
|
||||
address token,
|
||||
uint256 amount
|
||||
);
|
||||
event TreasuryUpdated(address indexed treasury);
|
||||
event SignerUpdated(address indexed signer);
|
||||
event TokenAllowedUpdated(address indexed token, bool allowed);
|
||||
event PlanConfigured(uint256 indexed planId, address indexed token, uint256 amount, bool active);
|
||||
|
||||
constructor(address initialOwner, address initialTreasury, address initialSigner)
|
||||
Ownable(initialOwner)
|
||||
{
|
||||
require(initialTreasury != address(0), "ZERO_TREASURY");
|
||||
treasury = initialTreasury;
|
||||
signer = initialSigner;
|
||||
|
||||
uint256 chainId;
|
||||
assembly {
|
||||
chainId := chainid()
|
||||
}
|
||||
DOMAIN_SEPARATOR = keccak256(
|
||||
abi.encode(
|
||||
keccak256(
|
||||
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
|
||||
),
|
||||
keccak256(bytes("PolyWeatherCheckoutV2")),
|
||||
keccak256(bytes("1")),
|
||||
chainId,
|
||||
address(this)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function setTreasury(address newTreasury) external onlyOwner {
|
||||
require(newTreasury != address(0), "ZERO_ADDR");
|
||||
treasury = newTreasury;
|
||||
emit TreasuryUpdated(newTreasury);
|
||||
}
|
||||
|
||||
function setSigner(address newSigner) external onlyOwner {
|
||||
signer = newSigner;
|
||||
emit SignerUpdated(newSigner);
|
||||
}
|
||||
|
||||
function setTokenAllowed(address token, bool allowed) external onlyOwner {
|
||||
require(token != address(0), "ZERO_ADDR");
|
||||
allowedToken[token] = allowed;
|
||||
emit TokenAllowedUpdated(token, allowed);
|
||||
}
|
||||
|
||||
function setPlan(uint256 planId, address token, uint256 amount, bool active) external onlyOwner {
|
||||
require(planId > 0, "PLAN_ZERO");
|
||||
require(token != address(0), "ZERO_ADDR");
|
||||
require(amount > 0 || !active, "AMOUNT_ZERO");
|
||||
planConfig[planId][token] = PlanConfig({amount: amount, active: active});
|
||||
emit PlanConfigured(planId, token, amount, active);
|
||||
}
|
||||
|
||||
function pause() external onlyOwner {
|
||||
_pause();
|
||||
}
|
||||
|
||||
function unpause() external onlyOwner {
|
||||
_unpause();
|
||||
}
|
||||
|
||||
function payPlan(bytes32 orderId, uint256 planId, address token)
|
||||
external
|
||||
whenNotPaused
|
||||
nonReentrant
|
||||
{
|
||||
require(allowedToken[token], "TOKEN_NOT_ALLOWED");
|
||||
PlanConfig memory config = planConfig[planId][token];
|
||||
require(config.active, "PLAN_NOT_ACTIVE");
|
||||
require(config.amount > 0, "PLAN_AMOUNT_ZERO");
|
||||
_collect(orderId, msg.sender, planId, token, config.amount);
|
||||
}
|
||||
|
||||
function payAuthorized(
|
||||
bytes32 orderId,
|
||||
uint256 planId,
|
||||
address token,
|
||||
uint256 amount,
|
||||
uint256 deadline,
|
||||
bytes calldata signature
|
||||
) external whenNotPaused nonReentrant {
|
||||
require(allowedToken[token], "TOKEN_NOT_ALLOWED");
|
||||
require(amount > 0, "AMOUNT_ZERO");
|
||||
require(deadline >= block.timestamp, "AUTH_EXPIRED");
|
||||
require(signer != address(0), "SIGNER_NOT_SET");
|
||||
|
||||
uint256 nonce = payerNonce[msg.sender];
|
||||
bytes32 structHash = keccak256(
|
||||
abi.encode(
|
||||
AUTHORIZED_PAYMENT_TYPEHASH,
|
||||
orderId,
|
||||
msg.sender,
|
||||
planId,
|
||||
token,
|
||||
amount,
|
||||
nonce,
|
||||
deadline
|
||||
)
|
||||
);
|
||||
bytes32 digest = keccak256(
|
||||
abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)
|
||||
);
|
||||
require(_recover(digest, signature) == signer, "BAD_SIGNATURE");
|
||||
payerNonce[msg.sender] = nonce + 1;
|
||||
|
||||
_collect(orderId, msg.sender, planId, token, amount);
|
||||
}
|
||||
|
||||
function rescueToken(address token, address to, uint256 amount) external onlyOwner nonReentrant {
|
||||
require(token != address(0) && to != address(0), "ZERO_ADDR");
|
||||
IERC20(token).safeTransfer(to, amount);
|
||||
}
|
||||
|
||||
function _collect(bytes32 orderId, address payer, uint256 planId, address token, uint256 amount) internal {
|
||||
require(!paidOrder[orderId], "ORDER_PAID");
|
||||
paidOrder[orderId] = true;
|
||||
IERC20(token).safeTransferFrom(payer, treasury, amount);
|
||||
emit OrderPaid(orderId, payer, planId, token, amount);
|
||||
}
|
||||
|
||||
function _recover(bytes32 digest, bytes calldata signature) internal pure returns (address) {
|
||||
require(signature.length == 65, "BAD_SIG_LEN");
|
||||
bytes32 r;
|
||||
bytes32 s;
|
||||
uint8 v;
|
||||
assembly {
|
||||
r := calldataload(signature.offset)
|
||||
s := calldataload(add(signature.offset, 32))
|
||||
v := byte(0, calldataload(add(signature.offset, 64)))
|
||||
}
|
||||
if (v < 27) {
|
||||
v += 27;
|
||||
}
|
||||
require(v == 27 || v == 28, "BAD_SIG_V");
|
||||
address recovered = ecrecover(digest, v, r, s);
|
||||
require(recovered != address(0), "BAD_SIG");
|
||||
return recovered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
{"city": "ankara", "timestamp": "2026-03-20T12:00:00+03:00", "date": "2026-03-20", "temp_symbol": "°C", "raw_mu": 15.2, "raw_sigma": 1.2, "deb_prediction": 15.4, "ensemble": {"p10": 14.8, "median": 15.8, "p90": 17.9}, "multi_model": {"ECMWF": 15.8, "GFS": 14.1, "ICON": 15.9}, "max_so_far": 15.0, "peak_status": "before", "prob_snapshot": [{"v": 15, "p": 0.552}, {"v": 16, "p": 0.377}], "shadow_prob_snapshot": [{"v": 15, "p": 0.324}, {"v": 16, "p": 0.238}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320130245", "calibration_source": "artifacts/probability_calibration/default.json", "calibrated_mu": 15.1, "calibrated_sigma": 1.25}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "hong kong", "timestamp": "2026-03-23T21:10:00+08:00", "date": "2026-03-23", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.6806250000000001, "deb_prediction": 25.2, "ensemble": {"p10": 26.3, "median": 26.4, "p90": 26.6}, "multi_model": {"Open-Meteo": 24.8, "HKO(港天文)": 27.0, "ECMWF": 25.4, "GFS": 25.1, "ICON": 24.8, "GEM": 25.3, "JMA": 23.6}, "max_so_far": 27.4, "peak_status": "past", "prob_snapshot": [{"v": 27, "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": "shenzhen", "timestamp": "2026-03-25T08:43:15.528748+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 29.0, "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": "shenzhen", "timestamp": "2026-03-25T08:57:11.783182+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": 26.7, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 26.7, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [{"v": 27, "p": 1.0}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 26.7, "calibrated_sigma": 0.24322631835937514}
|
||||
{"city": "shenzhen", "timestamp": "2026-03-25T09:32:32+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.16637912326388898, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 28.9, "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": "shenzhen", "timestamp": "2026-03-25T10:02:35+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.15911458333333342, "deb_prediction": 28.2, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.5, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.5, "GEM": 30.7, "JMA": 26.2}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "shanghai", "timestamp": "2026-03-29T15:00:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.23736458333333335, "deb_prediction": 18.4, "ensemble": {"p10": 16.1, "median": 16.5, "p90": 16.9}, "multi_model": {"Open-Meteo": 17.0, "ECMWF": 19.2, "GFS": 19.1, "ICON": 17.0, "GEM": 17.6, "JMA": 15.8}, "max_so_far": 18.0, "observation": {"current_temp": 14.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": 3.11, "local_hour": 23.25}, "peak_status": "past", "prob_snapshot": [{"v": 18, "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-03-29T15:01:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.25176666666666664, "deb_prediction": 9.7, "ensemble": {"p10": 9.2, "median": 9.5, "p90": 10.2}, "multi_model": {"Open-Meteo": 9.2, "ECMWF": 9.5, "GFS": 10.1, "ICON": 9.2, "GEM": 11.0, "JMA": 10.0}, "max_so_far": 10.0, "observation": {"current_temp": 7.0, "humidity": null, "wind_speed_kt": 12.0, "visibility_mi": null, "local_hour": 18.25}, "peak_status": "past", "prob_snapshot": [{"v": 10, "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": "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}
|
||||
@@ -1,24 +1,112 @@
|
||||
x-polyweather-base: &polyweather-base
|
||||
build: .
|
||||
image: polyweather-app:latest
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
services:
|
||||
polyweather:
|
||||
build: .
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_bot
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data # 挂载数据目录,确保历史数据持久化
|
||||
# Persist runtime data outside git workspace.
|
||||
# Host path defaults to /var/lib/polyweather and can be overridden in .env.
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
# Keep /app/data compatibility for existing cache/state defaults.
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
- ./bot.log:/app/bot.log # 挂载日志文件
|
||||
# UID/GID are mainly useful on Linux hosts to avoid root-owned output files.
|
||||
# Windows / macOS can usually keep the fallback values.
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_web:
|
||||
build: .
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_web
|
||||
restart: unless-stopped
|
||||
command: python web/app.py
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
# Web service shares the same runtime data directory as bot/state tasks.
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# UID/GID are mainly useful on Linux hosts to avoid root-owned output files.
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_prewarm:
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_prewarm
|
||||
restart: unless-stopped
|
||||
profiles: ["workers"]
|
||||
command: python scripts/prewarm_dashboard_worker.py --include-detail --include-market
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_prometheus:
|
||||
image: prom/prometheus:v3.4.1
|
||||
container_name: polyweather_prometheus
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
depends_on:
|
||||
- polyweather_web
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=15d"
|
||||
- "--web.enable-lifecycle"
|
||||
volumes:
|
||||
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/prometheus:/prometheus
|
||||
ports:
|
||||
- "${POLYWEATHER_PROMETHEUS_PORT:-9090}:9090"
|
||||
|
||||
polyweather_alertmanager:
|
||||
image: prom/alertmanager:v0.28.1
|
||||
container_name: polyweather_alertmanager
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
depends_on:
|
||||
- polyweather_alert_relay
|
||||
command:
|
||||
- "--config.file=/etc/alertmanager/alertmanager.yml"
|
||||
- "--storage.path=/alertmanager"
|
||||
volumes:
|
||||
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/alertmanager:/alertmanager
|
||||
ports:
|
||||
- "${POLYWEATHER_ALERTMANAGER_PORT:-9093}:9093"
|
||||
|
||||
polyweather_alert_relay:
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_alert_relay
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
command: python scripts/alertmanager_telegram_relay.py
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
ports:
|
||||
- "${POLYWEATHER_ALERT_RELAY_PORT:-9099}:9099"
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_grafana:
|
||||
image: grafana/grafana-oss:12.0.2
|
||||
container_name: polyweather_grafana
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
depends_on:
|
||||
- polyweather_prometheus
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${POLYWEATHER_GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${POLYWEATHER_GRAFANA_ADMIN_PASSWORD:-polyweather}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
volumes:
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/grafana:/var/lib/grafana
|
||||
ports:
|
||||
- "${POLYWEATHER_GRAFANA_PORT:-3001}:3000"
|
||||
|
||||
@@ -1,254 +1,242 @@
|
||||
# PolyWeather API 文档(v1.2)
|
||||
# PolyWeather API 文档(v1.5.1)
|
||||
|
||||
本文档描述当前后端真实可用接口(`web/app.py`)。
|
||||
前端一般通过 Next.js BFF 路由代理访问这些接口。
|
||||
最后更新:`2026-03-24`
|
||||
|
||||
---
|
||||
本文档描述当前对外可用 API 口径(`web/app.py` + `web/routes.py` + `frontend/app/api/*`)。
|
||||
|
||||
## 1. 基础信息
|
||||
|
||||
- 本地地址:`http://127.0.0.1:8000`
|
||||
- 生产地址:`http://<vps-ip>:8000` 或你绑定的 HTTPS 域名
|
||||
- 后端直连:`http://127.0.0.1:8000`
|
||||
- 前端 BFF:`https://polyweather-pro.vercel.app/api/*`
|
||||
- 返回格式:`application/json`
|
||||
- 缓存策略:
|
||||
- 后端分析缓存:默认 5 分钟(Ankara 特殊口径 60 秒)
|
||||
- 前端详情缓存:5 分钟 + revision 检查
|
||||
- 手动刷新:`force_refresh=true` 强制绕过缓存
|
||||
|
||||
---
|
||||
|
||||
## 2. API 思维导图
|
||||
## 2. 请求链路
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["PolyWeather API"]
|
||||
|
||||
subgraph E["接口分组"]
|
||||
E1["GET /api/cities"]
|
||||
E2["GET /api/city/{name}"]
|
||||
E3["GET /api/city/{name}/summary"]
|
||||
E4["GET /api/city/{name}/detail"]
|
||||
E5["GET /api/history/{name}"]
|
||||
end
|
||||
|
||||
subgraph O["关键对象"]
|
||||
O1["current"]
|
||||
O2["forecast"]
|
||||
O3["probabilities (mu + distribution)"]
|
||||
O4["multi_model / multi_model_daily"]
|
||||
O5["market_scan (P0 只读)"]
|
||||
end
|
||||
|
||||
A --> E
|
||||
A --> O
|
||||
flowchart LR
|
||||
FE["Browser / Dashboard"] --> BFF["Next.js Route Handlers (/api/*)"]
|
||||
BFF --> API["FastAPI (/web/app.py + /web/routes.py)"]
|
||||
API --> WX["Weather Collector"]
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
API --> PAY["Payment Intent + Event + Confirm Loops"]
|
||||
API --> OBS["healthz / system status / metrics"]
|
||||
```
|
||||
|
||||
---
|
||||
## 3. 天气分析接口
|
||||
|
||||
## 3. 接口总览
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/api/cities` | GET | 监控城市列表 |
|
||||
| `/api/city/{name}` | GET | 城市主分析 |
|
||||
| `/api/city/{name}/summary` | GET | 轻量摘要 |
|
||||
| `/api/city/{name}/detail` | GET | 聚合详情(含 market_scan) |
|
||||
| `/api/history/{name}` | GET | 历史对账 |
|
||||
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :------------------------- | :--- | :------------------------------------ |
|
||||
| `/api/cities` | GET | 城市清单与地图基础信息 |
|
||||
| `/api/city/{name}` | GET | 城市主分析数据(侧栏/今日分析主来源) |
|
||||
| `/api/city/{name}/summary` | GET | 轻量摘要(首屏预热/低开销更新) |
|
||||
| `/api/city/{name}/detail` | GET | 聚合详情 + Polymarket P0 只读市场层 |
|
||||
| `/api/history/{name}` | GET | 历史对账数据 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键接口详解
|
||||
|
||||
### 4.1 `GET /api/cities`
|
||||
|
||||
返回监控城市列表(地图 Marker 与侧边栏基础数据)。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"cities": [
|
||||
{
|
||||
"name": "ankara",
|
||||
"display_name": "Ankara",
|
||||
"lat": 39.9334,
|
||||
"lon": 32.8597,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟠",
|
||||
"airport": "Esenboğa",
|
||||
"icao": "LTAC",
|
||||
"temp_unit": "celsius",
|
||||
"is_major": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `GET /api/city/{name}`
|
||||
|
||||
主数据接口,前端详情面板和今日分析最常用。
|
||||
### `GET /api/city/{name}/detail`
|
||||
|
||||
可选参数:
|
||||
|
||||
- `force_refresh=true|false`
|
||||
- `market_slug=<slug>`
|
||||
- `target_date=YYYY-MM-DD`
|
||||
|
||||
核心字段:
|
||||
重点字段:
|
||||
|
||||
- `name`, `display_name`, `local_date`, `local_time`, `temp_symbol`
|
||||
- `risk`
|
||||
- `current`
|
||||
- `forecast`
|
||||
- `mgm`, `mgm_nearby`
|
||||
- `multi_model`, `multi_model_daily`
|
||||
- `deb`
|
||||
- `ensemble`
|
||||
- `probabilities`(`mu` + `distribution`)
|
||||
- `trend`, `peak`
|
||||
- `hourly`, `hourly_next_48h`
|
||||
- `source_forecasts`(当前只保留 `weather_gov`)
|
||||
- `market_scan`
|
||||
- `updated_at`
|
||||
- `market_scan.available`
|
||||
- `market_scan.signal_label`
|
||||
- `market_scan.edge_percent`
|
||||
- `market_scan.anchor_model / anchor_high / anchor_settlement`
|
||||
- `market_scan.yes_buy / no_buy`
|
||||
- `market_scan.primary_market.tradable`
|
||||
- `peak.first_h / peak.last_h / peak.status`
|
||||
- `vertical_profile_signal.heating_setup / suppression_risk / trigger_risk / mixing_strength`
|
||||
- `taf.signal.peak_window / suppression_level / disruption_level / markers`
|
||||
|
||||
说明:
|
||||
### `detail` 新增结构信号说明
|
||||
|
||||
- `current.raw_metar` 是原始 METAR 报文。
|
||||
- Ankara 专项增强使用 MGM 站网,领先站固定 `17130`。
|
||||
- Meteoblue 已彻底移除,不再出现在接口字段中。
|
||||
`/api/city/{name}/detail` 现在会返回一组更偏交易场景的结构字段:
|
||||
|
||||
### 4.3 `GET /api/city/{name}/summary`
|
||||
#### 1. `peak`
|
||||
|
||||
轻量温度摘要,用于地图首屏预热和低成本刷新。
|
||||
- `first_h`:预计峰值窗口起始小时
|
||||
- `last_h`:预计峰值窗口结束小时
|
||||
- `status`:`before_peak | near_peak | after_peak`
|
||||
|
||||
典型字段:
|
||||
这组字段用于让日内结构信号围绕真实峰值窗口分析,而不是固定只看下午。
|
||||
|
||||
- `name`, `display_name`, `icao`
|
||||
- `local_time`, `temp_symbol`
|
||||
- `current.temp`, `current.obs_time`
|
||||
- `deb.prediction`
|
||||
- `risk.level`, `risk.warning`
|
||||
- `updated_at`
|
||||
#### 2. `vertical_profile_signal`
|
||||
|
||||
### 4.4 `GET /api/city/{name}/detail`
|
||||
重点字段:
|
||||
|
||||
聚合视图接口,包含天气分析和市场只读层。
|
||||
- `source`
|
||||
- `window`
|
||||
- `cape_max`
|
||||
- `cin_min`
|
||||
- `lifted_index_min`
|
||||
- `boundary_layer_height_max`
|
||||
- `shear_10m_180m_max`
|
||||
- `suppression_risk`
|
||||
- `trigger_risk`
|
||||
- `mixing_strength`
|
||||
- `shear_risk`
|
||||
- `heating_setup`
|
||||
- `heating_score`
|
||||
- `summary_zh`
|
||||
- `summary_en`
|
||||
|
||||
可选参数:
|
||||
这组字段对应前端“高空结构信号 / Upper-Air Structure”卡片。
|
||||
|
||||
- `force_refresh=true|false`
|
||||
- `market_slug=<slug>`(调试/定向市场匹配)
|
||||
#### 3. `taf.signal`
|
||||
|
||||
关键结构:
|
||||
仅对**非香港机场城市**启用。当前已支持解析:
|
||||
|
||||
- `overview`
|
||||
- `official`
|
||||
- `timeseries`
|
||||
- `models`
|
||||
- `probabilities`
|
||||
- `market_scan`
|
||||
- `risk`
|
||||
- `ai_analysis`
|
||||
- `FM`
|
||||
- `TEMPO`
|
||||
- `BECMG`
|
||||
- `PROB30`
|
||||
- `PROB40`
|
||||
|
||||
`market_scan`(P0 只读)重点字段:
|
||||
重点字段:
|
||||
|
||||
- `primary_market`, `selected_condition_id`, `selected_slug`
|
||||
- `yes_token`, `no_token`
|
||||
- `yes_buy`, `yes_sell`, `no_buy`, `no_sell`
|
||||
- `market_price`, `model_probability`, `edge_percent`
|
||||
- `temperature_bucket`
|
||||
- `top_buckets`(前端展示前会再去重)
|
||||
- `signal_label`(`BUY YES` / `BUY NO` / `MONITOR`)
|
||||
- `websocket.asset_ids`, `websocket.condition_ids`(订阅标识,不涉及下单)
|
||||
- `peak_window`
|
||||
- `segments`
|
||||
- `markers`
|
||||
- `suppression_level`
|
||||
- `disruption_level`
|
||||
- `wind_shift`
|
||||
- `summary_zh`
|
||||
- `summary_en`
|
||||
|
||||
注意:
|
||||
`markers` 会被前端温度走势图拿来做 `TAF 时段 / TAF Timing` 标记。
|
||||
|
||||
- 后端已做温度桶去重与方向优先(优先与主市场同方向的 `or higher`/`or lower` 桶)。
|
||||
- 前端还有二次去重兜底,避免重复温度桶刷屏。
|
||||
## 4. 鉴权与账户接口
|
||||
|
||||
### 4.5 `GET /api/history/{name}`
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/api/auth/me` | GET | 当前登录态、积分、订阅状态 |
|
||||
|
||||
历史对账数据来源。
|
||||
`/api/auth/me` 关键字段:
|
||||
|
||||
示例:
|
||||
- `authenticated`
|
||||
- `user_id`, `email`
|
||||
- `points`, `weekly_points`, `weekly_rank`
|
||||
- `subscription_active`, `subscription_plan_code`, `subscription_expires_at`
|
||||
|
||||
```json
|
||||
{
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-03-07",
|
||||
"actual": 7.0,
|
||||
"deb": 6.5,
|
||||
"mu": 7.2,
|
||||
"mgm": 8.0
|
||||
}
|
||||
]
|
||||
}
|
||||
## 5. 支付接口
|
||||
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/api/payments/config` | GET | 支付配置、代币列表、套餐、积分抵扣规则 |
|
||||
| `/api/payments/runtime` | GET | 支付运行态、RPC 状态、event loop 状态、最近审计事件 |
|
||||
| `/api/payments/wallets` | GET | 当前用户已绑定钱包 |
|
||||
| `/api/payments/wallets/challenge` | POST | 获取绑定签名 challenge |
|
||||
| `/api/payments/wallets/verify` | POST | 提交签名并绑定钱包 |
|
||||
| `/api/payments/intents` | POST | 创建支付意图(intent) |
|
||||
| `/api/payments/intents/{intent_id}` | GET | 查询 intent 最新状态 |
|
||||
| `/api/payments/intents/{intent_id}/submit` | POST | 提交交易哈希 |
|
||||
| `/api/payments/intents/{intent_id}/confirm` | POST | 手动触发确认 |
|
||||
| `/api/payments/reconcile-latest` | POST | 对当前登录用户最近一笔 intent 做恢复性确认 |
|
||||
|
||||
### 支付状态建议
|
||||
|
||||
前端流程建议:
|
||||
|
||||
1. `POST /intents`
|
||||
2. 钱包发链上交易
|
||||
3. `POST /submit`
|
||||
4. `POST /confirm`
|
||||
5. 若 pending,轮询 `GET /intents/{id}` 直到 `confirmed`
|
||||
|
||||
## 6. 运维与观测接口
|
||||
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/healthz` | GET | 基础健康检查 |
|
||||
| `/api/system/status` | GET | 系统状态、功能开关、rollout 状态、轻量指标摘要 |
|
||||
| `/metrics` | GET | Prometheus 风格指标导出 |
|
||||
|
||||
`/api/system/status` 当前会包含:
|
||||
|
||||
- `features.state_storage_mode`
|
||||
- `probability.decision`
|
||||
- `probability.ready_for_primary`
|
||||
- `metrics`
|
||||
|
||||
`/metrics` 当前会导出:
|
||||
|
||||
- `polyweather_http_requests_total`
|
||||
- `polyweather_http_request_duration_ms_*`
|
||||
- `polyweather_source_requests_total`
|
||||
- `polyweather_source_request_duration_ms_*`
|
||||
|
||||
## 7. Ops 管理接口
|
||||
|
||||
这些接口主要给 `/ops` 管理后台使用,默认要求:
|
||||
|
||||
- 已登录
|
||||
- 当前邮箱位于 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/api/ops/users` | GET | 按 Telegram ID / 用户名 / 邮箱查询用户 |
|
||||
| `/api/ops/leaderboard/weekly` | GET | 本周积分榜 |
|
||||
| `/api/ops/memberships` | GET | 当前有效会员(已按用户去重,保留最晚到期) |
|
||||
| `/api/ops/users/grant-points` | POST | 手动补分 |
|
||||
| `/api/ops/payments/incidents` | GET | 支付异常单(仅 `payment_intent_failed`) |
|
||||
| `/api/ops/payments/incidents/{event_id}/resolve` | POST | 标记支付异常单已处理 |
|
||||
|
||||
`/api/ops/payments/incidents` 当前支持:
|
||||
|
||||
- `reason=<receiver_mismatch|sender_mismatch|event_mismatch|tx_reverted>`
|
||||
- 默认不返回已标记处理的记录
|
||||
- 重点用于排查“已付款未开通”“打到旧收款地址”等事故
|
||||
## 8. 缓存策略(当前)
|
||||
|
||||
- `cities` / `summary` / `history`:BFF 支持 `ETag + 304`
|
||||
- `summary?force_refresh=true`:`Cache-Control: no-store`
|
||||
- 详情接口与支付接口:`no-store`
|
||||
- `METAR` / `TAF` / settlement current 由后端各自维护短 TTL 缓存
|
||||
|
||||
## 9. 调试示例
|
||||
|
||||
### 查询未来日期 market_scan
|
||||
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/api/city/ankara/detail?force_refresh=true&target_date=2026-03-12"
|
||||
```
|
||||
|
||||
---
|
||||
### 校验支付配置
|
||||
|
||||
## 5. 请求链路(以 `/api/city/{name}` 为例)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as Frontend
|
||||
participant API as FastAPI
|
||||
participant WX as Weather Collector
|
||||
participant PM as Polymarket RO Layer
|
||||
|
||||
FE->>API: GET /api/city/{name}?force_refresh=...
|
||||
API->>WX: fetch_all_sources(city)
|
||||
WX-->>API: METAR / MGM / Open-Meteo / weather.gov / Multi-model
|
||||
API->>API: DEB + trend + probability
|
||||
API->>PM: build_market_scan(...)
|
||||
PM-->>API: market_scan (read-only)
|
||||
API-->>FE: merged city payload
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/api/payments/config | python3 -m json.tool
|
||||
```
|
||||
|
||||
---
|
||||
### 查看支付运行态
|
||||
|
||||
## 6. 数据口径
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/api/payments/runtime | python3 -m json.tool
|
||||
```
|
||||
|
||||
### 6.1 主观测
|
||||
### 查看支付异常单
|
||||
|
||||
- Aviation Weather / METAR 是全局主观测源。
|
||||
- Ankara:结算主站仍是 `LTAC`,领先信号强化使用 MGM(`17130`)。
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/api/ops/payments/incidents?reason=receiver_mismatch" | python3 -m json.tool
|
||||
```
|
||||
|
||||
### 6.2 预测源
|
||||
### 查看系统状态
|
||||
|
||||
- Open-Meteo
|
||||
- weather.gov(美国城市)
|
||||
- 多模型:ECMWF / GFS / ICON / GEM / JMA
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/api/system/status | python3 -m json.tool
|
||||
```
|
||||
|
||||
### 6.3 概率口径
|
||||
### 观察支付自动补单
|
||||
|
||||
- `mu`:动态分布中心,不是固定结算值。
|
||||
- `distribution`:按温度桶输出概率分布,面向结算决策而非通用天气展示。
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
|
||||
```
|
||||
|
||||
---
|
||||
## 10. AGPL 与公开口径说明
|
||||
|
||||
## 7. 常见问题
|
||||
本仓库代码自 `2026-03-30` 起采用 `AGPL-3.0-only`。对外公开文档仅覆盖通用 API 契约;生产商业策略参数、私有运营阈值与托管服务能力不在公开文档披露。
|
||||
|
||||
### 7.1 接口 500
|
||||
|
||||
- 先检查容器是否启动:`docker compose ps`
|
||||
- 查看日志:`docker compose logs -f polyweather_web`
|
||||
|
||||
### 7.2 METAR 看起来“延迟”
|
||||
|
||||
优先核对:
|
||||
|
||||
- `current.obs_time`
|
||||
- `current.report_time`
|
||||
- `current.receipt_time`
|
||||
|
||||
通常是上游发布节奏,不一定是本地轮询问题。
|
||||
|
||||
### 7.3 前端仍显示旧内容
|
||||
|
||||
- 确认 Vercel 已部署最新构建
|
||||
- 浏览器强刷(`Ctrl+F5`)
|
||||
- 检查是否命中前端 5 分钟 TTL
|
||||
|
||||
---
|
||||
|
||||
最后更新:`2026-03-11`
|
||||
详见:[AGPL-3.0 与商用边界](OPEN_CORE_POLICY.md)
|
||||
|
||||
@@ -1,120 +1,66 @@
|
||||
# Commercialization Roadmap
|
||||
# 商业化说明(Production)
|
||||
|
||||
Target: make PolyWeather a sustainable paid weather-intelligence product.
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
---
|
||||
## 1. 定位
|
||||
|
||||
## 1. Product Positioning
|
||||
PolyWeather 是面向温度结算场景的气象决策层,不是通用天气应用。
|
||||
|
||||
PolyWeather is not a generic weather app.
|
||||
It is a decision-support layer for temperature-settlement markets:
|
||||
核心价值:
|
||||
|
||||
- observation-first (METAR/MGM),
|
||||
- settlement-aware probability modeling (DEB + mu/buckets),
|
||||
- market mapping (Polymarket read-only) for actionable edge detection.
|
||||
- 观测优先(METAR/MGM)
|
||||
- 结算导向(DEB + 概率桶)
|
||||
- 市场映射(行情对照 + 错价雷达)
|
||||
|
||||
---
|
||||
## 2. 当前收费能力状态
|
||||
|
||||
## 2. Business Overview Diagram
|
||||
| 能力 | 状态 | 备注 |
|
||||
| :-- | :-- | :-- |
|
||||
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
|
||||
| 订阅套餐(Pro 月付) | 已上线 | `5 USDC / 30天` |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
|
||||
| 合约支付 | 已上线 | Polygon,USDC + USDC.e |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
|
||||
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
|
||||
| 私有频道推送 | 已上线 | 可拆分业务频道 |
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["PolyWeather Monetization"]
|
||||
## 3. 权限模型(当前)
|
||||
|
||||
subgraph P["Product"]
|
||||
P1["Telegram Signal Channel"]
|
||||
P2["Web Dashboard"]
|
||||
P3["VIP Bundle"]
|
||||
end
|
||||
- 游客:可查看基础看板与简版信息。
|
||||
- 登录用户:账户中心、钱包绑定、积分同步。
|
||||
- Pro 用户:
|
||||
- 今日日内深度分析(含高温时段)
|
||||
- 历史对账 + 未来日期分析
|
||||
- 全平台智能气象推送
|
||||
|
||||
subgraph R["Pricing"]
|
||||
R1["Entry 1 USD"]
|
||||
R2["Dashboard 5 USD"]
|
||||
R3["Bundle 5.5 USD"]
|
||||
end
|
||||
## 4. 收费与积分规则(默认)
|
||||
|
||||
subgraph AC["Access Control"]
|
||||
AC1["Manual activation (P1)"]
|
||||
AC2["Wallet/USDC detection (P2)"]
|
||||
AC3["Entitlement middleware"]
|
||||
end
|
||||
- 套餐:`pro_monthly`(5 USDC / 30 天)
|
||||
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
|
||||
- 实付下限:2 USDC(当积分满额时)
|
||||
|
||||
subgraph G["Growth"]
|
||||
G1["Accuracy reports"]
|
||||
G2["Retention analytics"]
|
||||
G3["User preference center"]
|
||||
end
|
||||
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
|
||||
|
||||
A --> P
|
||||
A --> R
|
||||
A --> AC
|
||||
A --> G
|
||||
```
|
||||
## 5. 许可证与商用边界
|
||||
|
||||
---
|
||||
当前仓库代码采用 `AGPL-3.0-only`:
|
||||
|
||||
## 3. Packaging and Pricing
|
||||
- 公开:基础能力与通用支付流程源码。
|
||||
- 不随代码许可证授权:商业风控、营销策略、关键运营参数、内部审计策略、品牌与托管服务资产。
|
||||
|
||||
| Tier | Price | Value |
|
||||
| :--------------- | :----------- | :---------------------------------------- |
|
||||
| Telegram Channel | $1 / month | Low-noise proactive signal feed |
|
||||
| Web Dashboard | $5 / month | Full multi-model context + reconciliation |
|
||||
| VIP Bundle | $5.5 / month | Dashboard + signal stream |
|
||||
详见:[AGPL-3.0 与商用边界](OPEN_CORE_POLICY.md)
|
||||
|
||||
Payment direction:
|
||||
## 6. 上线检查清单(收费前)
|
||||
|
||||
- Currency: Polygon USDC
|
||||
- Phasing: manual activation first, then automated entitlement sync
|
||||
1. 支付链路:创建 intent、提交 tx、确认入账、订阅开通全链路可回放。
|
||||
2. 权限链路:前端/后端/Bot 对 Pro 权限判定一致。
|
||||
3. 审计能力:支付日志、订阅变更、异常重试可追溯。
|
||||
4. 通知策略:支付成功私发、群内通知降噪。
|
||||
5. 安全边界:敏感配置不进仓库。
|
||||
|
||||
---
|
||||
## 7. 后续路线
|
||||
|
||||
## 4. Execution Phases
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
P1[Phase 1 Manual Beta] --> P2[Phase 2 Payment Automation]
|
||||
P2 --> P3[Phase 3 Growth and B2B]
|
||||
```
|
||||
|
||||
### Phase 1: Manual Beta
|
||||
|
||||
- Keep paid channel small, optimize signal quality first.
|
||||
- Manual payment confirmation + manual entitlement grant.
|
||||
- Invite-gated dashboard while access control hardens.
|
||||
|
||||
### Phase 2: Payment Automation
|
||||
|
||||
- Detect wallet payment events (USDC).
|
||||
- Auto-issue/refresh entitlement.
|
||||
- Enforce route-level and API-level access guards.
|
||||
|
||||
### Phase 3: Growth and Expansion
|
||||
|
||||
- Self-serve billing and subscription panel.
|
||||
- Operator analytics and feature usage telemetry.
|
||||
- Optional B2B API package for quant teams.
|
||||
|
||||
---
|
||||
|
||||
## 5. Technical Dependencies for Revenue
|
||||
|
||||
| Dependency | Why it matters |
|
||||
| :------------------- | :--------------------------------------------------------------- |
|
||||
| Entitlement guard | Prevents unpaid dashboard/API access |
|
||||
| Subscriber store | Persistent paid user state |
|
||||
| Audit trail | Explains why each alert fired |
|
||||
| Observability | Detects degradation before churn |
|
||||
| Frontend performance | Impacts conversion and retention (Speed Insights now integrated) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Immediate Commercial Priorities
|
||||
|
||||
1. Finish robust entitlement middleware in frontend and backend.
|
||||
2. Persist subscriber/payment state in managed DB.
|
||||
3. Publish transparent monthly accuracy and signal-quality reports.
|
||||
4. Add support playbooks for false-alert and stale-data incidents.
|
||||
|
||||
---
|
||||
|
||||
Last Updated: `2026-03-11`
|
||||
- 支持更多链和稳定币。
|
||||
- 引入退款与工单后台。
|
||||
- 建立周/月留存与付费转化看板。
|
||||
- 打通渠道分销与邀请码返利系统。
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
# 配置与密钥管理(中文)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
PolyWeather 的环境变量很多,但不是所有变量都属于同一层级。
|
||||
|
||||
当前推荐做法是把配置拆成三类:
|
||||
|
||||
1. 可复现基础配置
|
||||
放在:[.env.example](/E:/web/PolyWeather/.env.example)
|
||||
|
||||
2. 敏感密钥模板
|
||||
放在:[.env.secrets.example](/E:/web/PolyWeather/.env.secrets.example)
|
||||
|
||||
3. 平台侧真实密钥
|
||||
放在:
|
||||
- VPS / Docker `.env`
|
||||
- Vercel Environment Variables
|
||||
- GitHub Secrets(如需要)
|
||||
|
||||
## 2. 为什么要拆
|
||||
|
||||
如果把所有变量都平铺在一个 `.env` 里,会有三个问题:
|
||||
|
||||
1. 新环境很难知道“最小启动到底需要哪些变量”
|
||||
2. 敏感密钥和普通开关混在一起,容易误泄露
|
||||
3. 调优参数太多时,团队很难区分“必须填”和“保持默认即可”
|
||||
|
||||
所以正确做法不是“减少变量数量”,而是:
|
||||
|
||||
- 保留变量能力
|
||||
- 按职责分层
|
||||
- 给出最小启动路径
|
||||
|
||||
## 3. 文件职责
|
||||
|
||||
### 3.1 根 `.env.example`
|
||||
|
||||
文件:
|
||||
|
||||
- [.env.example](/E:/web/PolyWeather/.env.example)
|
||||
|
||||
用途:
|
||||
|
||||
- 后端 / Bot / Docker 的可复现配置模板
|
||||
- 只放变量名、默认值、开关与非敏感示例
|
||||
|
||||
### 3.2 根 `.env.secrets.example`
|
||||
|
||||
文件:
|
||||
|
||||
- [.env.secrets.example](/E:/web/PolyWeather/.env.secrets.example)
|
||||
|
||||
用途:
|
||||
|
||||
- 只列敏感项
|
||||
- 帮助运维明确哪些值必须从密钥系统注入
|
||||
|
||||
### 3.3 前端 `.env.example`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/.env.example](/E:/web/PolyWeather/frontend/.env.example)
|
||||
|
||||
用途:
|
||||
|
||||
- 前端本地开发与 Vercel 环境变量模板
|
||||
|
||||
## 4. 配置分级
|
||||
|
||||
### 4.1 L1:最小启动必需项
|
||||
|
||||
这是“服务能跑起来”的最小集合。
|
||||
|
||||
后端 / Bot:
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `TELEGRAM_CHAT_ID`
|
||||
- `POLYWEATHER_RUNTIME_DATA_DIR`
|
||||
- `POLYWEATHER_DB_PATH`
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE`
|
||||
|
||||
前端:
|
||||
|
||||
- `POLYWEATHER_API_BASE_URL`
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`(如果启用 `/ops` 页面级管理员守卫)
|
||||
|
||||
如果启用登录:
|
||||
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- `SUPABASE_URL`
|
||||
- `SUPABASE_ANON_KEY`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
|
||||
### 4.2 L2:功能开关
|
||||
|
||||
这些变量一般不敏感,但会决定功能是否启用。
|
||||
|
||||
例如:
|
||||
|
||||
- `POLYWEATHER_AUTH_ENABLED`
|
||||
- `POLYWEATHER_AUTH_REQUIRED`
|
||||
- `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION`
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE`
|
||||
- `POLYWEATHER_PAYMENT_ENABLED`
|
||||
- `POLYMARKET_MARKET_SCAN_ENABLED`
|
||||
- `POLYGON_WALLET_WATCH_ENABLED`
|
||||
- `TELEGRAM_ALERT_PUSH_ENABLED`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`(已退役,建议保持 `false`)
|
||||
|
||||
### 4.3 L3:运行调优项
|
||||
|
||||
这些一般不需要在第一天就改。
|
||||
|
||||
例如:
|
||||
|
||||
- 各类 `*_TTL_SEC`
|
||||
- 各类 `*_TIMEOUT_SEC`
|
||||
- 各类 `*_COOLDOWN_SEC`
|
||||
- 各类 `*_INTERVAL_SEC`
|
||||
- `TELEGRAM_ALERT_MIN_TRIGGER_COUNT`
|
||||
- `TELEGRAM_ALERT_MIN_SEVERITY`
|
||||
- `TELEGRAM_ALERT_MISPRICING_ONLY`
|
||||
- `TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- `TAF_CACHE_TTL_SEC`
|
||||
|
||||
策略:
|
||||
|
||||
- 先用默认值
|
||||
- 出现性能或运维问题时再调
|
||||
|
||||
### 4.4 L4:敏感项
|
||||
|
||||
这些变量不应写进公开文档截图,也不应提交到仓库。
|
||||
|
||||
例如:
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
||||
- `METEOBLUE_API_KEY`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `POLYMARKET_SECRET_KEY`
|
||||
|
||||
## 5. 推荐部署矩阵
|
||||
|
||||
### 5.1 VPS / Docker(后端 + Bot)
|
||||
|
||||
建议放这些:
|
||||
|
||||
- 根 `.env` 的后端项
|
||||
- 所有 secrets
|
||||
- Bot / 支付 / watcher 配置
|
||||
|
||||
### 5.2 Vercel(前端)
|
||||
|
||||
建议只放前端真正需要的变量:
|
||||
|
||||
- `POLYWEATHER_API_BASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- `POLYWEATHER_AUTH_ENABLED`
|
||||
- `POLYWEATHER_AUTH_REQUIRED`
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL`
|
||||
|
||||
说明:
|
||||
|
||||
- `/ops` 现在是前后端双层限制:
|
||||
- 前端页面入口读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 后端写接口同样读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 因此,Vercel 和 VPS / Docker 两侧都应配置相同的管理员邮箱白名单。
|
||||
|
||||
不要把后端专用密钥全搬进 Vercel。
|
||||
|
||||
### 5.3 GitHub Actions
|
||||
|
||||
当前 CI 不需要大规模 secrets。
|
||||
|
||||
如果未来要做自动部署,再考虑:
|
||||
|
||||
- `VERCEL_TOKEN`
|
||||
- `VERCEL_ORG_ID`
|
||||
- `VERCEL_PROJECT_ID`
|
||||
|
||||
## 6. 最小部署示例
|
||||
|
||||
### 6.1 前端最小变量
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://your-backend.example.com
|
||||
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
|
||||
```
|
||||
|
||||
### 6.2 后端最小变量
|
||||
|
||||
```env
|
||||
TELEGRAM_BOT_TOKEN=...
|
||||
TELEGRAM_CHAT_ID=...
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
UID=1000
|
||||
GID=1000
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
TAF_CACHE_TTL_SEC=900
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_ANON_KEY=...
|
||||
SUPABASE_SERVICE_ROLE_KEY=...
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=...
|
||||
TELEGRAM_ALERT_PUSH_ENABLED=true
|
||||
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
|
||||
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
|
||||
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
||||
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
||||
TELEGRAM_ALERT_MISPRICING_ONLY=true
|
||||
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||
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
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `UID` / `GID` 主要给 Linux Docker 主机用,避免容器把运行文件写成 root 所有。
|
||||
- Windows / macOS 一般可以直接保留默认值。
|
||||
- `POLYWEATHER_RUNTIME_DATA_DIR` 建议放在仓库外,例如 `/var/lib/polyweather`。
|
||||
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather` 和 `/app/data`,兼容现有缓存与 SQLite 路径。
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`。
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`。
|
||||
- 机器人市场监控当前以 `关注清单` 为主,按固定间隔主动推送。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。
|
||||
|
||||
### 6.3 机器人市场监控建议配置
|
||||
|
||||
这套配置用于替代旧的钱包异动监听,围绕市场本身做两类推送:
|
||||
|
||||
- `关键提醒`:实时错价/触发条件满足时发送
|
||||
- `关注清单`:按亚洲时区定时推送当日重点市场摘要
|
||||
|
||||
推荐值:
|
||||
|
||||
```env
|
||||
TELEGRAM_ALERT_PUSH_ENABLED=true
|
||||
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
|
||||
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
|
||||
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
||||
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
||||
TELEGRAM_ALERT_MISPRICING_ONLY=true
|
||||
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||
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
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮机会清单。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED=false` 表示停用旧的钱包异动监听,统一收敛到市场监控。
|
||||
|
||||
## 7. 当前建议的运维规则
|
||||
|
||||
### 7.1 仓库中允许存在
|
||||
|
||||
- `.env.example`
|
||||
- `.env.secrets.example`
|
||||
- `frontend/.env.example`
|
||||
|
||||
### 7.2 仓库中不应提交
|
||||
|
||||
- `.env`
|
||||
- `.env.local`
|
||||
- 任何带真实 token / key 的配置文件
|
||||
|
||||
### 7.3 截图与共享规则
|
||||
|
||||
以下值一旦出现在截图或聊天里,建议视为泄露并轮换:
|
||||
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- 第三方私有 API Key
|
||||
|
||||
## 8. 如何收口配置复杂度
|
||||
|
||||
如果你觉得变量仍然太多,正确的做法不是一刀删掉,而是:
|
||||
|
||||
1. 把“功能开关”和“调优参数”分开看
|
||||
2. 保持 `.env.example` 中:
|
||||
- 最小启动项
|
||||
- 常用功能开关
|
||||
- 默认调优值
|
||||
3. 让不常改的高阶参数继续留默认
|
||||
|
||||
也就是说:
|
||||
|
||||
- 使用者只需要先关心 10-20 个关键变量
|
||||
- 其余变量保持默认即可
|
||||
|
||||
## 9. 当前已经完成的配置治理
|
||||
|
||||
1. 根 `.env.example` 收口
|
||||
2. `.env.secrets.example` 新增
|
||||
3. 前端 `.env.example` 收口
|
||||
4. 运行时配置校验脚本新增
|
||||
5. `/ops` 管理员白名单与前后端职责边界已明确
|
||||
5. 支付运行态与多 RPC 配置支持
|
||||
6. 运行态 SQLite 迁移配置支持
|
||||
|
||||
## 10. 配置校验命令
|
||||
|
||||
在不启动服务的情况下,你可以直接检查配置:
|
||||
|
||||
```bash
|
||||
python scripts/validate_runtime_env.py --component web
|
||||
python scripts/validate_runtime_env.py --component bot
|
||||
```
|
||||
@@ -0,0 +1,653 @@
|
||||
# EMOS + LGBM 系统说明(中文)
|
||||
|
||||
本文档用于完整说明 PolyWeather 当前的两条统计/机器学习链路:
|
||||
|
||||
- `EMOS`:概率后处理与校准链路
|
||||
- `LGBM`:日最高温点预测辅助模型
|
||||
|
||||
重点不只是“模型怎么训练”,还包括:
|
||||
|
||||
- 这些模型依赖什么历史数据
|
||||
- 真值和训练特征现在如何长期保存
|
||||
- 为什么过去样本一直不够
|
||||
- 当前线上到底运行在哪个模式
|
||||
- 现在能做什么,不能做什么
|
||||
|
||||
本文档基于仓库当前实现与最近一轮重建结果,适合作为:
|
||||
|
||||
- 项目内部模型说明
|
||||
- 运维与数据治理说明
|
||||
- 未来继续扩展 EMOS/LGBM 的基线文档
|
||||
|
||||
---
|
||||
|
||||
## 1. 总览
|
||||
|
||||
PolyWeather 当前不是“用一个模型替代所有东西”,而是多层结构:
|
||||
|
||||
1. 多源天气采集层
|
||||
2. `DEB` 业务主预测层
|
||||
3. `LGBM` 轻量点预测辅助层
|
||||
4. `EMOS` 概率校准层
|
||||
5. 市场概率/桶命中评估层
|
||||
|
||||
可以简化理解为:
|
||||
|
||||
```text
|
||||
天气源 / 观测 / 历史真值
|
||||
↓
|
||||
DEB 主预测
|
||||
↓
|
||||
LGBM 辅助点预测
|
||||
↓
|
||||
EMOS 对概率分布做后处理
|
||||
↓
|
||||
市场概率 / shadow / rollout 门禁
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `DEB` 仍然是当前业务主路径
|
||||
- `LGBM` 是辅助预测源,不是主路径
|
||||
- `EMOS` 是概率后处理,不是基础天气模型
|
||||
|
||||
---
|
||||
|
||||
## 2. 两条链路各自负责什么
|
||||
|
||||
### 2.1 EMOS 负责什么
|
||||
|
||||
`EMOS` 的全称通常指 Ensemble Model Output Statistics。
|
||||
|
||||
在本项目里,它的角色不是重新预测温度,而是:
|
||||
|
||||
- 把已有的预测结果做概率后处理
|
||||
- 让输出分布更“可校准”
|
||||
- 让桶概率和市场评估更稳定
|
||||
|
||||
EMOS 关注的是:
|
||||
|
||||
- `raw_mu`
|
||||
- `raw_sigma`
|
||||
- `deb_prediction`
|
||||
- `ens_median`
|
||||
- `ensemble_spread`
|
||||
- `max_so_far_gap`
|
||||
- `peak_flag`
|
||||
- 最终真实 `actual_high`
|
||||
|
||||
它最终输出的是一套“经过校准的概率分布”,而不是单一温度值。
|
||||
|
||||
所以 EMOS 的核心衡量指标不是单纯 MAE,而更看重:
|
||||
|
||||
- `CRPS`
|
||||
- `bucket_hit_rate`
|
||||
- `bucket_brier`
|
||||
|
||||
### 2.2 LGBM 负责什么
|
||||
|
||||
`LGBM` 是一个轻量级的回归模型,用来预测:
|
||||
|
||||
- `actual_high`(日最高温)
|
||||
|
||||
它吃的是:
|
||||
|
||||
- 历史真值 lag 特征
|
||||
- 多模型 forecast
|
||||
- `deb_prediction`
|
||||
- 当前观测特征
|
||||
- 时间特征
|
||||
|
||||
它输出的是:
|
||||
|
||||
- 一个点预测 `actual_high`
|
||||
|
||||
然后这个点预测可以作为:
|
||||
|
||||
- 额外 forecast 源
|
||||
- 供 DEB / 运营 / 研究参考
|
||||
|
||||
所以它和 EMOS 的区别非常重要:
|
||||
|
||||
- `LGBM`:做点预测
|
||||
- `EMOS`:做概率校准
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前代码结构
|
||||
|
||||
### 3.1 EMOS 相关
|
||||
|
||||
核心文件:
|
||||
|
||||
- [probability_calibration.py](/E:/web/PolyWeather/src/analysis/probability_calibration.py)
|
||||
- [probability_rollout.py](/E:/web/PolyWeather/src/analysis/probability_rollout.py)
|
||||
- [fit_probability_calibration.py](/E:/web/PolyWeather/scripts/fit_probability_calibration.py)
|
||||
- [evaluate_probability_calibration.py](/E:/web/PolyWeather/scripts/evaluate_probability_calibration.py)
|
||||
- [build_probability_shadow_report.py](/E:/web/PolyWeather/scripts/build_probability_shadow_report.py)
|
||||
- [judge_probability_rollout.py](/E:/web/PolyWeather/scripts/judge_probability_rollout.py)
|
||||
|
||||
核心产物:
|
||||
|
||||
- [default.json](/E:/web/PolyWeather/artifacts/probability_calibration/default.json)
|
||||
- [evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
|
||||
- [shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
|
||||
- [rollout_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/rollout_report.json)
|
||||
- [training_samples.json](/E:/web/PolyWeather/artifacts/probability_calibration/training_samples.json)
|
||||
|
||||
### 3.2 LGBM 相关
|
||||
|
||||
核心文件:
|
||||
|
||||
- [lgbm_daily_high.py](/E:/web/PolyWeather/src/models/lgbm_daily_high.py)
|
||||
- [lgbm_features.py](/E:/web/PolyWeather/src/models/lgbm_features.py)
|
||||
- [train_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/train_lgbm_daily_high.py)
|
||||
- [report_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/report_lgbm_daily_high.py)
|
||||
|
||||
核心产物:
|
||||
|
||||
- [lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
|
||||
- [lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
|
||||
|
||||
---
|
||||
|
||||
## 4. 为什么之前样本总是上不去
|
||||
|
||||
这件事是理解当前状态的关键。
|
||||
|
||||
过去项目里有一个结构性问题:
|
||||
|
||||
- `daily_records_store` 同时承担了
|
||||
- 运行态缓存
|
||||
- 历史训练数据来源
|
||||
|
||||
但运行态层会把 `daily_records` 硬裁成最近 14 天。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 对线上运行来说没问题
|
||||
- 对训练来说,历史监督样本会不断被删掉
|
||||
|
||||
结果就是:
|
||||
|
||||
- 城市越来越多
|
||||
- 训练历史反而越来越稀
|
||||
- `LGBM` 很容易只有二十几条样本
|
||||
- `EMOS` 也只能靠有限 snapshot/daily_record 拼起来
|
||||
|
||||
这不是“模型太差”,而是“数据主存设计不对”。
|
||||
|
||||
---
|
||||
|
||||
## 5. 这次历史真值治理做了什么
|
||||
|
||||
现在已经把“运行态缓存”和“长期训练主存”拆开了。
|
||||
|
||||
### 5.1 `daily_records_store`
|
||||
|
||||
继续保留,但只作为:
|
||||
|
||||
- 最近 14 天运行态缓存
|
||||
|
||||
它不再承担长期训练历史职责。
|
||||
|
||||
### 5.2 `truth_records_store`
|
||||
|
||||
新增永久真值表,作为长期训练真值主存。
|
||||
|
||||
当前核心字段包括:
|
||||
|
||||
- `city`
|
||||
- `target_date`
|
||||
- `actual_high`
|
||||
- `settlement_source`
|
||||
- `settlement_station_code`
|
||||
- `settlement_station_label`
|
||||
- `truth_version`
|
||||
- `updated_by`
|
||||
- `updated_at`
|
||||
- `source_payload_json`
|
||||
- `is_final`
|
||||
|
||||
这张表的意义是:
|
||||
|
||||
- 长期保存监督真值
|
||||
- 不再被 14 天缓存裁剪
|
||||
- 真值来源变得可追溯
|
||||
|
||||
### 5.3 `truth_revisions_store`
|
||||
|
||||
新增真值修订审计表。
|
||||
|
||||
它记录:
|
||||
|
||||
- 老值是什么
|
||||
- 新值是什么
|
||||
- 来源怎么变了
|
||||
- 谁改的
|
||||
- 为什么改
|
||||
- 什么时候改
|
||||
|
||||
所以现在回填不会再是“静默覆盖”。
|
||||
|
||||
### 5.4 `training_feature_records_store`
|
||||
|
||||
新增长期训练特征表。
|
||||
|
||||
它长期留存:
|
||||
|
||||
- forecasts
|
||||
- deb_prediction
|
||||
- mu
|
||||
- probability_features
|
||||
- prob_snapshot
|
||||
- shadow_prob_snapshot
|
||||
- calibration 摘要
|
||||
|
||||
它的作用是:
|
||||
|
||||
- 从现在开始,不再继续丢失历史训练特征
|
||||
- 让未来 EMOS/LGBM 样本自然累积
|
||||
|
||||
---
|
||||
|
||||
## 6. 训练数据现在怎么来
|
||||
|
||||
### 6.1 EMOS 训练样本
|
||||
|
||||
EMOS 训练不只是需要真值,还要有“当时那一刻的预测快照”。
|
||||
|
||||
所以一条 EMOS 样本,本质上需要两部分:
|
||||
|
||||
1. 历史预测特征
|
||||
2. 对应日期最终真值
|
||||
|
||||
当前导出的 EMOS 样本里,核心字段包括:
|
||||
|
||||
- `city`
|
||||
- `date`
|
||||
- `actual_high`
|
||||
- `raw_mu`
|
||||
- `raw_sigma`
|
||||
- `deb_prediction`
|
||||
- `ens_median`
|
||||
- `ensemble_spread`
|
||||
- `max_so_far_gap`
|
||||
- `peak_flag`
|
||||
- `sample_source`
|
||||
- `settlement_source`
|
||||
- `settlement_station_code`
|
||||
- `truth_version`
|
||||
- `truth_updated_by`
|
||||
- `truth_updated_at`
|
||||
|
||||
也就是说,EMOS 训练样本现在已经带了真值 provenance。
|
||||
|
||||
### 6.2 LGBM 训练样本
|
||||
|
||||
LGBM 训练样本会优先从:
|
||||
|
||||
1. 永久真值表取监督目标
|
||||
2. 长期训练特征表取历史特征
|
||||
3. 再回退到必要的运行态/快照补充
|
||||
|
||||
当前 LGBM 样本会用到:
|
||||
|
||||
- 历史 `actual_high` lag
|
||||
- 历史均值/趋势
|
||||
- 多模型 forecast
|
||||
- `deb_prediction`
|
||||
- 当前观测
|
||||
- 时间特征
|
||||
|
||||
---
|
||||
|
||||
## 7. Wunderground 历史回填为什么重要
|
||||
|
||||
这次治理里一个重点是:
|
||||
|
||||
- `Taipei`
|
||||
- `Shenzhen`
|
||||
|
||||
这两个城市已经切到了市场指定的 `Wunderground` 结算口径。
|
||||
|
||||
之前的问题是:
|
||||
|
||||
- 城市注册表已经写成 `wunderground`
|
||||
- 但历史回填链路还没有真正支持按指定历史日期抓 WU 历史页
|
||||
|
||||
所以过去它们的 `actual_high` 可能:
|
||||
|
||||
- 没有被正确回填
|
||||
- 或者被错误来源污染
|
||||
|
||||
现在已经补了正式历史回填函数:
|
||||
|
||||
- [wunderground_sources.py](/E:/web/PolyWeather/src/data_collection/wunderground_sources.py)
|
||||
|
||||
它会:
|
||||
|
||||
1. 按 `city + target_date` 拼出对应历史页
|
||||
2. 解析该日观测序列
|
||||
3. 取当日最高温
|
||||
4. 按市场规则做整度结算
|
||||
5. 写入永久真值表
|
||||
6. 记录来源与审计信息
|
||||
|
||||
这一步对 `Taipei/Shenzhen` 尤其关键,因为它们不是 NOAA/HKO 口径。
|
||||
|
||||
---
|
||||
|
||||
## 8. 当前线上/离线运行模式
|
||||
|
||||
### 8.1 概率引擎模式
|
||||
|
||||
当前项目仍然应该保持:
|
||||
|
||||
- `emos_shadow`
|
||||
|
||||
而不是:
|
||||
|
||||
- `emos_primary`
|
||||
|
||||
原因不是工程没接好,而是门禁还没过。
|
||||
|
||||
### 8.2 LGBM 角色
|
||||
|
||||
当前 `LGBM` 仍然只能算:
|
||||
|
||||
- 辅助预测源
|
||||
- 研究/观测链路
|
||||
|
||||
不适合替代 `DEB` 主路径。
|
||||
|
||||
---
|
||||
|
||||
## 9. 当前最新状态
|
||||
|
||||
以下状态来自最近一轮恢复、回填和重训产物。
|
||||
|
||||
### 9.1 永久真值
|
||||
|
||||
当前永久真值表已恢复到长期历史:
|
||||
|
||||
- `truth_records_store`
|
||||
- 最早:`2023-01-01`
|
||||
- 最晚:`2026-04-02`
|
||||
- 行数:约 `35138`
|
||||
- 城市数:`30`
|
||||
|
||||
运行态缓存仍然只有近 14 天:
|
||||
|
||||
- `daily_records_store`
|
||||
- 仍然是近两周范围
|
||||
|
||||
这说明:
|
||||
|
||||
- 长期真值主存已经从运行态缓存里分离出来了
|
||||
|
||||
### 9.2 真值修订
|
||||
|
||||
当前已有 revision 审计记录:
|
||||
|
||||
- `truth_revisions_store`
|
||||
- 行数:`2`
|
||||
|
||||
这说明审计链路已经在工作。
|
||||
|
||||
### 9.3 Wunderground 回填
|
||||
|
||||
`Taipei` 与 `Shenzhen` 已按 WU 历史页完成回填。
|
||||
|
||||
当前这两城已经补到:
|
||||
|
||||
- `2026-04-02`
|
||||
|
||||
### 9.4 长期训练特征
|
||||
|
||||
当前 `training_feature_records_store` 已经接通,但历史上真正留存下来的特征仍然很少。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 从现在开始不会继续丢
|
||||
- 但过去没留下的那部分特征,不会凭空恢复
|
||||
|
||||
这也是为什么:
|
||||
|
||||
- 真值恢复了
|
||||
- `EMOS` 样本量却没有同步大幅增长
|
||||
|
||||
---
|
||||
|
||||
## 10. 当前 EMOS 结果怎么理解
|
||||
|
||||
最近一轮离线评估大致是:
|
||||
|
||||
- `sample_count = 54`
|
||||
- `delta_crps ≈ -0.0867`
|
||||
- `delta_mae = 0`
|
||||
- `delta_bucket_hit_rate = 0`
|
||||
|
||||
这说明:
|
||||
|
||||
- 从 `CRPS` 看,EMOS 有改善
|
||||
- 但从 `MAE` 和 `top bucket hit` 看,没有明显进步
|
||||
|
||||
shadow 报告里更关键的问题是:
|
||||
|
||||
- `shadow sample_count = 48`
|
||||
- `delta_bucket_brier` 仍然明显偏坏
|
||||
|
||||
所以 rollout 结论仍然是:
|
||||
|
||||
- `hold`
|
||||
|
||||
这不是“EMOS 无效”,而是:
|
||||
|
||||
- 它还没有稳定到能切主路径
|
||||
|
||||
### 10.1 当前阻塞点
|
||||
|
||||
主要阻塞仍然是:
|
||||
|
||||
- 样本数不够
|
||||
- shadow bucket brier 退化
|
||||
|
||||
也就是说,当前 EMOS 状态可以总结成:
|
||||
|
||||
- 工程链路完整
|
||||
- 数据治理大幅改善
|
||||
- 发布门禁仍未通过
|
||||
|
||||
---
|
||||
|
||||
## 11. 当前 LGBM 结果怎么理解
|
||||
|
||||
最近一轮 LGBM 训练后,样本数已经从以前更少的状态提升到:
|
||||
|
||||
- `sample_count = 54`
|
||||
- `train_count = 42`
|
||||
- `validation_count = 12`
|
||||
|
||||
验证集指标大致为:
|
||||
|
||||
- `lgbm_mae = 1.349`
|
||||
- `deb_mae = 0.875`
|
||||
|
||||
这说明:
|
||||
|
||||
- LGBM 比以前样本更充足了
|
||||
- 但在验证集上仍然不如 DEB
|
||||
|
||||
所以当前它的定位仍然应该是:
|
||||
|
||||
- 辅助参考
|
||||
- 不替代 DEB
|
||||
|
||||
---
|
||||
|
||||
## 12. 为什么现在 EMOS 没有像 LGBM 那样明显涨样本
|
||||
|
||||
这点很容易误解。
|
||||
|
||||
答案不是“恢复失败”,而是两条链路对数据要求不一样。
|
||||
|
||||
### 12.1 LGBM
|
||||
|
||||
LGBM 更依赖:
|
||||
|
||||
- 长期真值
|
||||
- 基础 forecast 特征
|
||||
|
||||
这部分通过:
|
||||
|
||||
- `truth_records_store`
|
||||
- `training_feature_records_store`
|
||||
|
||||
已经改善很多。
|
||||
|
||||
### 12.2 EMOS
|
||||
|
||||
EMOS 更依赖:
|
||||
|
||||
- 某一时刻的概率快照/分布特征
|
||||
|
||||
如果过去那些 snapshot 没有长期保存下来,那么即使今天把真值补齐了:
|
||||
|
||||
- 也无法凭空重建完整 EMOS 样本
|
||||
|
||||
所以当前现实是:
|
||||
|
||||
- 真值问题已经大幅改善
|
||||
- 未来特征不会再继续丢
|
||||
- 但过去缺失的 EMOS 快照历史仍然限制样本增长
|
||||
|
||||
---
|
||||
|
||||
## 13. 当前最重要的工程判断
|
||||
|
||||
### 13.1 已经完成的
|
||||
|
||||
这些现在可以认为已经完成:
|
||||
|
||||
- 真值主存从运行态缓存里拆出
|
||||
- 真值 provenance 落库
|
||||
- revision 审计表落地
|
||||
- Wunderground 历史回填接通
|
||||
- `Taipei/Shenzhen` 真值口径修正
|
||||
- 长期训练特征表接通
|
||||
- `/ops` 已能可视化 truth / feature / EMOS / LGBM 覆盖情况
|
||||
|
||||
### 13.2 还没完成的
|
||||
|
||||
这些仍然是后续重点:
|
||||
|
||||
- EMOS 样本继续自然积累
|
||||
- shadow bucket brier 稳定下来
|
||||
- LGBM 验证效果超过 DEB
|
||||
- 让更多城市开始持续积累训练特征
|
||||
|
||||
---
|
||||
|
||||
## 14. 运维怎么看当前状态
|
||||
|
||||
现在最直接的入口是:
|
||||
|
||||
- `/ops`
|
||||
|
||||
这页已经能看到:
|
||||
|
||||
- 历史真值主表统计
|
||||
- 真值来源分布
|
||||
- 真值修订数量
|
||||
- 长期训练特征统计
|
||||
- `Taipei/Shenzhen` 的 WU 回填状态
|
||||
- 城市覆盖缺口
|
||||
- 模型城市覆盖
|
||||
- 城市覆盖矩阵
|
||||
|
||||
因此,运维现在可以快速回答:
|
||||
|
||||
- 哪些城市真值已经长期化
|
||||
- 哪些城市还没有特征积累
|
||||
- 哪些城市已经能支撑 EMOS/LGBM
|
||||
- 哪些城市目前仍然只能主要依赖 DEB
|
||||
|
||||
---
|
||||
|
||||
## 15. 推荐工作流
|
||||
|
||||
### 15.1 日常
|
||||
|
||||
1. 查看 `/ops`
|
||||
2. 看 `truth / feature / EMOS / LGBM` 覆盖有没有继续增长
|
||||
3. 看 `Taipei/Shenzhen` 的 WU 行数是否继续更新
|
||||
4. 看 rollout 仍然是 `hold` 还是有改善
|
||||
|
||||
### 15.2 周期性重训
|
||||
|
||||
建议周期性执行:
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/export_probability_training_dataset.py
|
||||
./venv/Scripts/python.exe scripts/fit_probability_calibration.py
|
||||
./venv/Scripts/python.exe scripts/evaluate_probability_calibration.py
|
||||
./venv/Scripts/python.exe scripts/build_probability_shadow_report.py
|
||||
./venv/Scripts/python.exe scripts/judge_probability_rollout.py
|
||||
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
### 15.3 真值恢复/补数
|
||||
|
||||
当有新的历史真值补数或回填需要时:
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/restore_training_truth_history.py
|
||||
./venv/Scripts/python.exe scripts/restore_training_feature_history.py
|
||||
./venv/Scripts/python.exe scripts/backfill_recent_daily_actuals_from_metar.py --cities taipei shenzhen --lookback-days 14
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 脚本名里虽然还保留 `from_metar`
|
||||
- 但当前实现已经会按 `settlement_source` 自动分发
|
||||
- `wunderground` 会走 WU 历史回填分支
|
||||
|
||||
---
|
||||
|
||||
## 16. 当前最务实的结论
|
||||
|
||||
如果只用一句话概括当前状态:
|
||||
|
||||
**EMOS 和 LGBM 的工程基础已经补齐,但数据积累还在恢复期;当前最正确的策略仍然是继续以 `DEB` 为主路径,让长期真值和训练特征继续沉淀,再观察 EMOS/LGBM 是否自然变强。**
|
||||
|
||||
更具体一点:
|
||||
|
||||
- `EMOS`
|
||||
- 已接好
|
||||
- 可训练
|
||||
- 可评估
|
||||
- 可 shadow
|
||||
- 但暂时不能切主路径
|
||||
|
||||
- `LGBM`
|
||||
- 已接好
|
||||
- 样本比以前更多
|
||||
- 但验证集还不如 DEB
|
||||
- 目前只能做辅助参考
|
||||
|
||||
- 数据层
|
||||
- 这次治理的真正价值,是防止未来继续丢历史
|
||||
- 这对两条模型链路都比继续“微调参数”更关键
|
||||
|
||||
---
|
||||
|
||||
## 17. 相关文档
|
||||
|
||||
若需要看更细分的历史说明,可继续参考:
|
||||
|
||||
- [EMOS_TRAINING_REPORT_ZH.md](/E:/web/PolyWeather/docs/EMOS_TRAINING_REPORT_ZH.md)
|
||||
- [LGBM_DAILY_HIGH_ZH.md](/E:/web/PolyWeather/docs/LGBM_DAILY_HIGH_ZH.md)
|
||||
- [PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](/E:/web/PolyWeather/docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md)
|
||||
- [deep-research-report.md](/E:/web/PolyWeather/docs/deep-research-report.md)
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
# EMOS 训练报告(2026-03-20)
|
||||
|
||||
## 1. 报告目的
|
||||
|
||||
本文档用于记录当前 PolyWeather 概率校准引擎(EMOS)的训练结果、离线评估结果、线上 shadow 观测结果,以及是否具备切换为主路径的条件。
|
||||
|
||||
当前结论先写在前面:
|
||||
|
||||
- `EMOS` 已完成接入、训练、离线评估、shadow 落盘与滚动报表。
|
||||
- 当前默认运行模式应继续保持 `emos_shadow`。
|
||||
- 现阶段 **不建议切换到 `emos_primary`**。
|
||||
|
||||
## 2. 本次训练版本
|
||||
|
||||
- 校准版本:`emos-20260320130245`
|
||||
- 训练时间:`2026-03-20T13:02:45.903772+00:00`
|
||||
- 参数文件:[default.json](/E:/web/PolyWeather/artifacts/probability_calibration/default.json)
|
||||
- 离线评估报告:[evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
|
||||
- 线上 shadow 报表:[shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
|
||||
|
||||
## 3. 训练数据概况
|
||||
|
||||
### 3.1 数据来源
|
||||
|
||||
当前训练主要使用两类数据:
|
||||
|
||||
1. 项目历史日记录
|
||||
文件:[daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
|
||||
2. 历史天气 CSV 构建出的结算标签
|
||||
文件:[settlement_history.json](/E:/web/PolyWeather/artifacts/probability_calibration/settlement_history.json)
|
||||
|
||||
### 3.2 样本规模
|
||||
|
||||
- 总训练样本数:`105`
|
||||
- 通过历史天气 CSV 补回的缺失 `actual_high`:`2`
|
||||
- 历史结算标签覆盖城市数:`30`
|
||||
|
||||
说明:
|
||||
|
||||
- 当前样本已覆盖 30 个城市,但有效监督样本量仍偏小。
|
||||
- 部分城市样本数只有 `2-7` 条,城市级参数容易波动。
|
||||
|
||||
## 4. 模型结构
|
||||
|
||||
### 4.1 当前实现
|
||||
|
||||
EMOS 属于统计后处理层,不是数值天气模型本身。当前结构位于:
|
||||
|
||||
- [probability_calibration.py](/E:/web/PolyWeather/src/analysis/probability_calibration.py)
|
||||
|
||||
当前目标是对原有概率引擎输出进行校准:
|
||||
|
||||
- 输入:`raw_mu`、`raw_sigma`、`DEB`、`ensemble median/spread`、`peak_status` 等特征
|
||||
- 输出:校准后的 `mu / sigma / distribution`
|
||||
|
||||
### 4.2 当前运行模式
|
||||
|
||||
支持三种模式:
|
||||
|
||||
- `legacy`
|
||||
- `emos_shadow`
|
||||
- `emos_primary`
|
||||
|
||||
当前建议默认模式:
|
||||
|
||||
- `emos_shadow`
|
||||
|
||||
即:
|
||||
|
||||
- 对外仍展示 legacy 结果
|
||||
- 后台并行计算 EMOS 结果
|
||||
- 用于持续评估,不直接影响用户
|
||||
|
||||
## 5. 本次训练参数摘要
|
||||
|
||||
### 5.1 全局约束
|
||||
|
||||
本次训练已加入两类约束:
|
||||
|
||||
1. `sigma_constraints`
|
||||
- `min_ratio = 0.85`
|
||||
- `max_ratio = 1.35`
|
||||
- `absolute_min = 0.25`
|
||||
- `absolute_max = 3.0`
|
||||
|
||||
2. `selection_guardrails`
|
||||
- `max_mae_increase = 0.02`
|
||||
- `max_bucket_hit_drop = 0.01`
|
||||
- `max_bucket_brier_increase = 0.05`
|
||||
|
||||
这两类约束的目的不是追求“更激进的拟合”,而是防止 EMOS 为了降低 CRPS 而把分布摊得过平,导致业务上更关键的顶桶命中和概率质量变差。
|
||||
|
||||
### 5.2 当前选中的 blending
|
||||
|
||||
本次训练产物中最终选择:
|
||||
|
||||
- `alpha_mu = 0.0`
|
||||
- `alpha_sigma = 0.0`
|
||||
|
||||
含义是:
|
||||
|
||||
- 训练器在护栏约束下,没有找到足够安全的候选方案可以替代 legacy 主路径
|
||||
- 因此当前正式选中的可用结果,本质上仍然锚定在 legacy
|
||||
|
||||
这是一种正确的保护行为,不是失败。说明门禁已经起作用,避免了坏校准进入主路径。
|
||||
|
||||
## 6. 离线评估结果
|
||||
|
||||
评估报告来源:
|
||||
|
||||
- [evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
|
||||
|
||||
### 6.1 总体结果
|
||||
|
||||
Legacy:
|
||||
|
||||
- `mean_crps = 2.793938`
|
||||
- `mean_mae = 2.721143`
|
||||
- `bucket_hit_rate = 0.695238`
|
||||
|
||||
EMOS(强制 primary 评估):
|
||||
|
||||
- `mean_crps = 2.650216`
|
||||
- `mean_mae = 2.722829`
|
||||
- `bucket_hit_rate = 0.666667`
|
||||
|
||||
Delta:
|
||||
|
||||
- `CRPS = -0.143722`
|
||||
- `MAE = +0.001686`
|
||||
- `bucket_hit_rate = -0.028571`
|
||||
|
||||
### 6.2 解读
|
||||
|
||||
这组结果说明:
|
||||
|
||||
1. `CRPS` 有改善
|
||||
说明从“分布整体平滑度”角度看,EMOS 有一定价值。
|
||||
|
||||
2. `MAE` 基本持平但略差
|
||||
不是大问题,但也不能算改善。
|
||||
|
||||
3. `bucket_hit_rate` 明显下降
|
||||
这是当前最大阻塞项。对 PolyWeather 这种结算桶业务来说,顶桶命中率比单纯 CRPS 更关键。
|
||||
|
||||
因此,离线结论是:
|
||||
|
||||
- `EMOS` 有研究价值
|
||||
- 但 **离线强切 primary 仍然不合格**
|
||||
|
||||
## 7. 线上 Shadow 观测结果
|
||||
|
||||
线上 shadow 报表来源:
|
||||
|
||||
- [shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
|
||||
|
||||
### 7.1 总体结果
|
||||
|
||||
- `samples = 103`
|
||||
- `legacy_mean_mae = 1.839223`
|
||||
- `shadow_mean_mae = 1.851931`
|
||||
- `delta_mae = +0.012708`
|
||||
|
||||
- `legacy_bucket_hit_rate = 0.669903`
|
||||
- `shadow_bucket_hit_rate = 0.679612`
|
||||
- `delta_bucket_hit_rate = +0.009709`
|
||||
|
||||
- `legacy_bucket_brier = 0.462814`
|
||||
- `shadow_bucket_brier = 0.756649`
|
||||
- `delta_bucket_brier = +0.293835`
|
||||
|
||||
### 7.2 解读
|
||||
|
||||
线上 shadow 结果和离线强制 primary 结果不完全相同,这是正常的。原因是:
|
||||
|
||||
- `shadow_report` 反映的是历史记录中实际落盘的 shadow 输出
|
||||
- `evaluation_report` 反映的是离线脚本在强制 `emos_primary` 下重新计算的效果
|
||||
|
||||
当前线上 shadow 的含义是:
|
||||
|
||||
1. 顶桶命中率略有提升
|
||||
`+0.97%`
|
||||
|
||||
2. 但 `MAE` 轻微变差
|
||||
虽然幅度不大,但没有形成明确优势
|
||||
|
||||
3. `bucket_brier` 明显更差
|
||||
说明 shadow 分布仍然偏“摊平”,概率质量不足
|
||||
|
||||
这是当前最重要的信号:
|
||||
|
||||
- EMOS 在“顶桶命中”上偶尔能赢
|
||||
- 但在“概率质量”上还不够好
|
||||
|
||||
## 8. 城市级观察
|
||||
|
||||
从当前城市级结果看,EMOS 并不是“全城市统一改善”,而是明显分化:
|
||||
|
||||
### 8.1 相对改善较明显的城市
|
||||
|
||||
- `London`
|
||||
- `Hong Kong`
|
||||
- `Tokyo`
|
||||
- `New York`
|
||||
|
||||
这些城市在部分指标上看到一定改善,说明当前校准特征在这些城市上更有效。
|
||||
|
||||
### 8.2 风险较高的城市
|
||||
|
||||
- `Atlanta`
|
||||
- `Miami`
|
||||
- `Chicago`
|
||||
- `Dallas`
|
||||
- `Seattle`
|
||||
|
||||
这些城市常见现象是:
|
||||
|
||||
- 顶桶命中没有显著提高
|
||||
- 或 `bucket_brier` 明显恶化
|
||||
- 或者 `MAE` 出现不必要抬升
|
||||
|
||||
这说明当前 EMOS 还没有形成稳定的全局校准能力,城市间异质性很强。
|
||||
|
||||
## 9. 当前判断
|
||||
|
||||
### 9.1 能不能上线为主路径
|
||||
|
||||
当前答案:
|
||||
|
||||
- **不能**
|
||||
|
||||
原因:
|
||||
|
||||
1. 离线强制 primary 时,`bucket_hit_rate` 下降
|
||||
2. 线上 shadow 时,`bucket_brier` 明显变差
|
||||
3. 样本量依然偏小,城市样本不均衡
|
||||
4. 城市级表现分化明显
|
||||
|
||||
### 9.2 当前应该怎么运行
|
||||
|
||||
当前最合理的运行方式:
|
||||
|
||||
1. 保持 `emos_shadow`
|
||||
2. 继续落盘 `shadow_prob_snapshot`
|
||||
3. 继续维护滚动报表
|
||||
4. 不修改机器人和网页的正式对外概率展示
|
||||
|
||||
## 10. 已完成的工程能力
|
||||
|
||||
目前已经具备以下能力:
|
||||
|
||||
1. 可离线训练
|
||||
脚本:[fit_probability_calibration.py](/E:/web/PolyWeather/scripts/fit_probability_calibration.py)
|
||||
|
||||
2. 可离线评估
|
||||
脚本:[evaluate_probability_calibration.py](/E:/web/PolyWeather/scripts/evaluate_probability_calibration.py)
|
||||
|
||||
3. 可导出训练样本
|
||||
脚本:[export_probability_training_dataset.py](/E:/web/PolyWeather/scripts/export_probability_training_dataset.py)
|
||||
|
||||
4. 可历史回填 shadow 结果
|
||||
脚本:[backfill_probability_shadow_history.py](/E:/web/PolyWeather/scripts/backfill_probability_shadow_history.py)
|
||||
|
||||
5. 可生成滚动 shadow 报表
|
||||
脚本:[build_probability_shadow_report.py](/E:/web/PolyWeather/scripts/build_probability_shadow_report.py)
|
||||
|
||||
6. CI 已接入
|
||||
包含 `ruff / pytest / frontend build / docker build workflow`
|
||||
|
||||
## 11. 下一步建议
|
||||
|
||||
### 11.1 必做
|
||||
|
||||
1. 扩大监督样本量
|
||||
重点不是继续堆原始天气 CSV,而是补更多带 forecast snapshot 的历史样本。
|
||||
|
||||
2. 继续按版本沉淀训练报告
|
||||
每次重训后都更新本报告或新增版本报告,避免只看单次结果。
|
||||
|
||||
3. 保持 `shadow` 连续观测
|
||||
至少持续一段时间观察滚动指标是否稳定。
|
||||
|
||||
### 11.2 再做
|
||||
|
||||
1. 细分城市组建模
|
||||
比如按气候区、结算规则、温度单位分组,而不是完全全局一套参数。
|
||||
|
||||
2. 优化训练目标
|
||||
目前已经把 `bucket_brier` 纳入目标,但仍需进一步靠近 PolyWeather 的业务目标。
|
||||
|
||||
3. 补更严格的切换门槛
|
||||
只有在同时满足以下条件时,才考虑切 `emos_primary`:
|
||||
- `CRPS` 下降
|
||||
- `MAE` 不上升
|
||||
- `bucket_hit_rate` 不下降
|
||||
- `bucket_brier` 不上升
|
||||
|
||||
## 12. 结论
|
||||
|
||||
当前 EMOS 状态可以概括为:
|
||||
|
||||
- 工程上:已经完整接入,具备训练、评估、shadow 观测能力
|
||||
- 模型上:有一定价值,但还不稳定
|
||||
- 产品上:适合继续做 shadow,不适合切主路径
|
||||
|
||||
最终结论:
|
||||
|
||||
- **继续使用 `emos_shadow`**
|
||||
- **暂不切 `emos_primary`**
|
||||
- **继续积累样本并按版本跟踪训练结果**
|
||||
@@ -0,0 +1,211 @@
|
||||
# 前端部署配置(Vercel)
|
||||
|
||||
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
|
||||
|
||||
## 一、部署目标
|
||||
|
||||
推荐方案:
|
||||
|
||||
1. GitHub Actions 负责 `CI`
|
||||
2. Vercel 负责前端 `CD`
|
||||
3. FastAPI 后端单独部署在 VPS / Docker 主机
|
||||
|
||||
前端本身不直接访问天气源,而是通过 Next Route Handlers 转发到后端:
|
||||
|
||||
1. 浏览器 -> Vercel 上的 Next.js 前端
|
||||
2. Next `/api/*` -> `POLYWEATHER_API_BASE_URL`
|
||||
3. FastAPI 后端 -> 分析 / 支付 / 鉴权服务
|
||||
|
||||
## 二、Vercel 项目设置
|
||||
|
||||
在 Vercel 导入 GitHub 仓库后,使用下面的设置:
|
||||
|
||||
- Framework Preset: `Next.js`
|
||||
- Root Directory: `frontend`
|
||||
- Build Command: `npm run build`
|
||||
- Install Command: `npm install`
|
||||
|
||||
如果仓库已经连接过 Vercel,通常只需要确认 `Root Directory` 仍然是 `frontend`。
|
||||
|
||||
## 三、最小必填环境变量
|
||||
|
||||
只部署天气看板和基础登录时,先填下面 4 项:
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://<your-fastapi-host>
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<your-project>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-key>
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
```
|
||||
|
||||
建议显式补:
|
||||
|
||||
```env
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `POLYWEATHER_API_BASE_URL`:前端所有 `/api/*` Route Handler 转发时依赖它,没填会直接返回 500。
|
||||
- `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`:Supabase 客户端依赖它们。
|
||||
- `POLYWEATHER_AUTH_ENABLED`:关闭时,前端不会启用登录能力。
|
||||
- `POLYWEATHER_AUTH_REQUIRED`:控制 middleware 是否强制登录。
|
||||
|
||||
## 四、按功能启用的可选环境变量
|
||||
|
||||
### 1. 分享式看板
|
||||
|
||||
```env
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
```
|
||||
|
||||
设置后,可通过 `/?access_token=<token>` 打开带令牌的看板入口。
|
||||
|
||||
### 2. 前后端 entitlement 校验
|
||||
|
||||
```env
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
```
|
||||
|
||||
仅当后端开启 entitlement / 订阅校验时需要。
|
||||
|
||||
### 3. 钱包支付
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
```
|
||||
|
||||
如果不启用钱包支付,可以留空。
|
||||
|
||||
### 4. `/ops` 管理员页面守卫
|
||||
|
||||
```env
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `/ops` 现在不是只有后端接口限制,前端页面入口也会读取管理员邮箱白名单。
|
||||
- 因此前端部署到 Vercel 时,也应配置 `POLYWEATHER_OPS_ADMIN_EMAILS`。
|
||||
|
||||
### 5. Telegram 入口
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
```
|
||||
|
||||
只影响按钮跳转,不影响核心页面加载。
|
||||
|
||||
## 五、支付配置与旧部署治理
|
||||
|
||||
支付区现在有一层额外防护:
|
||||
|
||||
1. 用户点击支付前,前端会重新请求 `/api/payments/config`
|
||||
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
|
||||
3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付
|
||||
|
||||
这层防护的目的,是降低以下事故概率:
|
||||
|
||||
- 用户使用长期未刷新的旧标签页
|
||||
- 命中旧 deployment URL
|
||||
- 页面本地状态残留旧收款地址
|
||||
|
||||
如果你变更过支付收款地址,建议同步执行:
|
||||
|
||||
1. 在 Vercel 对当前 production 做一次 redeploy
|
||||
2. 删除明显过期、可能还带旧支付配置的旧 deployment
|
||||
3. 在 `Settings -> Security -> Deployment Retention Policy` 中收紧旧部署保留周期
|
||||
|
||||
## 六、推荐的三套配置口径
|
||||
|
||||
### 1. 公开游客模式
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://api.example.com
|
||||
POLYWEATHER_AUTH_ENABLED=false
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
```
|
||||
|
||||
适合公开演示站。
|
||||
|
||||
### 2. 正常登录模式
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://api.example.com
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
```
|
||||
|
||||
适合正式前端站点。
|
||||
|
||||
### 3. 登录 + entitlement 联动
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://api.example.com
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<shared-token>
|
||||
```
|
||||
|
||||
适合前后端都启用了会员/订阅保护的生产环境。
|
||||
|
||||
## 七、不要放进 Vercel 的变量
|
||||
|
||||
这些属于后端私密配置,不应该放到前端项目:
|
||||
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN` 以外的后端 secret
|
||||
- 支付签名私钥 / 交易私钥 / 任何 bot 凭据
|
||||
|
||||
特别注意:
|
||||
|
||||
- `NEXT_PUBLIC_*` 会暴露给浏览器
|
||||
- 只有明确允许前端公开使用的值,才应加 `NEXT_PUBLIC_`
|
||||
|
||||
## 八、上线前检查
|
||||
|
||||
Vercel 部署前至少确认:
|
||||
|
||||
1. `POLYWEATHER_API_BASE_URL` 指向可访问的后端生产地址
|
||||
2. `frontend/.env.example` 和 Vercel Project Settings 中的实际值一致
|
||||
3. GitHub Actions 中 `frontend-quality` 已通过
|
||||
4. 如果启用鉴权,Supabase redirect URL 已包含前端域名
|
||||
5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约
|
||||
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在 Vercel 与后端同时配置
|
||||
|
||||
## 九、常见问题
|
||||
|
||||
### 1. 页面打开后 API 全部 500
|
||||
|
||||
先检查:
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL
|
||||
```
|
||||
|
||||
这是最常见原因。
|
||||
|
||||
### 2. Vercel 构建通过,但登录失败
|
||||
|
||||
先检查:
|
||||
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- Supabase 项目里的站点 URL / redirect URL
|
||||
|
||||
### 3. 钱包入口显示未配置
|
||||
|
||||
先检查:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||
```
|
||||
|
||||
这是钱包连接的必需项。
|
||||
@@ -0,0 +1,310 @@
|
||||
# LightGBM 日最高温模型(中文)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
这套 `LightGBM` 模型是给 PolyWeather 增加一个轻量级的统计学习预测源。
|
||||
|
||||
它的定位不是替代:
|
||||
|
||||
- `DEB`
|
||||
- `EMOS`
|
||||
- `ECMWF / GFS / GEM / JMA / ICON / Open-Meteo / MGM / NWS`
|
||||
|
||||
而是作为一个新的点预测源:
|
||||
|
||||
`现有模型 + 观测特征 -> LGBM -> 并入 current_forecasts -> DEB -> EMOS`
|
||||
|
||||
第一版只做:
|
||||
|
||||
- `D0` 当日最高温预测
|
||||
|
||||
不做:
|
||||
|
||||
- `D1-D3`
|
||||
- 小时级曲线
|
||||
- 概率分布
|
||||
- 独立结算源
|
||||
|
||||
## 2. 适用场景
|
||||
|
||||
这条链路是为低资源 VPS 准备的。
|
||||
|
||||
当前项目线上环境只有 `2GB RAM` 时,不适合引入 `TimesFM` 这类大模型,但适合用 `LightGBM` 做轻量推理。
|
||||
|
||||
当前方案是:
|
||||
|
||||
1. 训练离线完成
|
||||
2. 训练产物直接提交到仓库
|
||||
3. VPS 线上只加载模型文件并推理
|
||||
4. VPS 不训练,不起额外服务
|
||||
|
||||
## 3. 文件结构
|
||||
|
||||
核心文件如下:
|
||||
|
||||
- 运行时推理:
|
||||
- [src/models/lgbm_daily_high.py](/E:/web/PolyWeather/src/models/lgbm_daily_high.py)
|
||||
- 特征构建:
|
||||
- [src/models/lgbm_features.py](/E:/web/PolyWeather/src/models/lgbm_features.py)
|
||||
- 训练脚本:
|
||||
- [scripts/train_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/train_lgbm_daily_high.py)
|
||||
- 训练报告脚本:
|
||||
- [scripts/report_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/report_lgbm_daily_high.py)
|
||||
- 模型文件:
|
||||
- [artifacts/models/lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
|
||||
- 模型 schema / 指标:
|
||||
- [artifacts/models/lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
|
||||
|
||||
接入链路位置:
|
||||
|
||||
- Web API 聚合:
|
||||
- [web/analysis_service.py](/E:/web/PolyWeather/web/analysis_service.py)
|
||||
- 共享趋势引擎:
|
||||
- [src/analysis/trend_engine.py](/E:/web/PolyWeather/src/analysis/trend_engine.py)
|
||||
|
||||
## 4. 特征说明
|
||||
|
||||
第一版特征固定为以下几组。
|
||||
|
||||
### 4.1 历史日高温特征
|
||||
|
||||
- `actual_high_lag_1`
|
||||
- `actual_high_lag_2`
|
||||
- `actual_high_lag_3`
|
||||
- `actual_high_lag_7`
|
||||
- `actual_high_mean_7`
|
||||
- `actual_high_mean_14`
|
||||
- `actual_high_trend_3`
|
||||
|
||||
### 4.2 当天模型特征
|
||||
|
||||
- `Open-Meteo`
|
||||
- `ECMWF`
|
||||
- `GFS`
|
||||
- `GEM`
|
||||
- `JMA`
|
||||
- `ICON`
|
||||
- `MGM`
|
||||
- `NWS`
|
||||
- `deb_prediction`
|
||||
- `model_median`
|
||||
- `model_spread`
|
||||
|
||||
### 4.3 当前观测特征
|
||||
|
||||
- `current_temp`
|
||||
- `max_so_far`
|
||||
- `humidity`
|
||||
- `wind_speed_kt`
|
||||
- `visibility_mi`
|
||||
|
||||
### 4.4 时间与状态特征
|
||||
|
||||
- `local_hour`
|
||||
- `month`
|
||||
- `weekday`
|
||||
- `peak_status_code`
|
||||
|
||||
其中:
|
||||
|
||||
- `before = 0`
|
||||
- `in_window = 1`
|
||||
- `past = 2`
|
||||
|
||||
## 5. 训练数据来源
|
||||
|
||||
训练数据主要来自两份运行时历史文件:
|
||||
|
||||
- [data/daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
- [data/probability_training_snapshots.jsonl](/E:/web/PolyWeather/data/probability_training_snapshots.jsonl)
|
||||
|
||||
作用分工:
|
||||
|
||||
- `daily_records.json`
|
||||
- 提供 `actual_high`
|
||||
- 提供当天各模型 forecast
|
||||
- 提供历史 `deb_prediction`
|
||||
|
||||
- `probability_training_snapshots.jsonl`
|
||||
- 提供 `max_so_far`
|
||||
- 提供 `peak_status`
|
||||
- 提供观测特征快照
|
||||
|
||||
为后续重训,概率快照归档现在还会额外写入:
|
||||
|
||||
- `current_temp`
|
||||
- `humidity`
|
||||
- `wind_speed_kt`
|
||||
- `visibility_mi`
|
||||
- `local_hour`
|
||||
|
||||
对应代码:
|
||||
|
||||
- [src/analysis/probability_snapshot_archive.py](/E:/web/PolyWeather/src/analysis/probability_snapshot_archive.py)
|
||||
|
||||
## 6. 训练流程
|
||||
|
||||
训练脚本:
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
训练流程如下:
|
||||
|
||||
1. 从历史文件构造监督样本
|
||||
2. 目标值固定为 `actual_high`
|
||||
3. 按日期做简单的时间顺序切分
|
||||
4. 最后约 20% 做验证集
|
||||
5. 先训练并评估验证集
|
||||
6. 再用全量样本训练最终模型
|
||||
7. 输出模型文件和 schema 文件
|
||||
|
||||
输出产物:
|
||||
|
||||
- [artifacts/models/lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
|
||||
- [artifacts/models/lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
|
||||
|
||||
## 7. 如何看训练结果
|
||||
|
||||
查看训练报告:
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/report_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
这个脚本会读取 schema,并打印:
|
||||
|
||||
- `Sample Count`
|
||||
- `Train Count`
|
||||
- `Valid Count`
|
||||
- `LGBM MAE`
|
||||
- `DEB MAE`
|
||||
- `Best Single MAE`
|
||||
- `Median MAE`
|
||||
- `Winner`
|
||||
|
||||
当前这版训练结果是:
|
||||
|
||||
- `sample_count = 29`
|
||||
- `validation_count = 12`
|
||||
- `validation.lgbm_mae = 2.975`
|
||||
- `validation.deb_mae = 2.267`
|
||||
- `validation.best_single_mae = 1.167`
|
||||
|
||||
这说明:
|
||||
|
||||
- 当前 `LGBM` 链路已经可用
|
||||
- 但现阶段验证集表现还没有超过 `DEB`
|
||||
- 所以默认配置仍建议保持关闭
|
||||
|
||||
## 8. 线上运行逻辑
|
||||
|
||||
运行时推理逻辑不是“直接替代 DEB”,而是:
|
||||
|
||||
1. 先收集现有模型 forecast
|
||||
2. 先算一版基线 `DEB`
|
||||
3. 把这版 `DEB` 当作 `LGBM` 的一个输入特征
|
||||
4. 输出 `LGBM` 点预测
|
||||
5. 把 `LGBM` 注入 `current_forecasts`
|
||||
6. 重新计算最终 `DEB`
|
||||
|
||||
这样做的原因是:
|
||||
|
||||
- `LGBM` 需要吃到 `deb_prediction` 特征
|
||||
- 但最终 `DEB` 又要把 `LGBM` 当成一个新的输入模型
|
||||
|
||||
## 9. 环境变量
|
||||
|
||||
示例配置见:
|
||||
|
||||
- [.env.example](/E:/web/PolyWeather/.env.example)
|
||||
|
||||
相关变量:
|
||||
|
||||
```env
|
||||
POLYWEATHER_LGBM_ENABLED=false
|
||||
POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
|
||||
POLYWEATHER_LGBM_SCHEMA_PATH=/app/artifacts/models/lgbm_daily_high_schema.json
|
||||
POLYWEATHER_LGBM_MIN_HISTORY_POINTS=3
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `POLYWEATHER_LGBM_ENABLED`
|
||||
- 是否启用运行时推理
|
||||
- `POLYWEATHER_LGBM_MODEL_PATH`
|
||||
- 模型文件路径
|
||||
- `POLYWEATHER_LGBM_SCHEMA_PATH`
|
||||
- schema 文件路径
|
||||
- `POLYWEATHER_LGBM_MIN_HISTORY_POINTS`
|
||||
- 某城市最低历史样本门槛
|
||||
|
||||
默认是 `3`,原因不是最理想,而是当前整体样本仍然偏少。
|
||||
|
||||
如果门槛设太高,很多城市现在根本不会触发 `LGBM`。
|
||||
|
||||
## 10. VPS 部署建议
|
||||
|
||||
如果你的 VPS 只有 `2GB RAM`:
|
||||
|
||||
- 可以跑这套 `LightGBM`
|
||||
- 不要在 VPS 上训练
|
||||
- 不要起额外模型服务
|
||||
|
||||
推荐方式:
|
||||
|
||||
1. 在本地或开发环境训练
|
||||
2. 提交模型产物
|
||||
3. VPS 拉代码
|
||||
4. 开启 `POLYWEATHER_LGBM_ENABLED=true`
|
||||
5. 重启主服务
|
||||
|
||||
不推荐:
|
||||
|
||||
- 在 VPS 上跑训练脚本
|
||||
- 把 `LightGBM` 当成长任务服务单独部署
|
||||
- 同时引入大模型推理
|
||||
|
||||
## 11. 当前结论
|
||||
|
||||
这条链路已经完成了:
|
||||
|
||||
- 离线训练
|
||||
- 模型产物固化
|
||||
- 运行时懒加载
|
||||
- Web / 共享分析链路注入
|
||||
- 前端模型类型兼容
|
||||
|
||||
但当前样本量仍偏少,所以建议运营策略是:
|
||||
|
||||
1. 先继续积累历史 `actual_high`
|
||||
2. 继续积累概率快照观测字段
|
||||
3. 定期重训
|
||||
4. 只有当验证集 `MAE` 持续接近或优于 `DEB` 时,再考虑默认线上开启
|
||||
|
||||
## 12. 常用命令
|
||||
|
||||
### 训练
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
### 查看训练报告
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/report_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
### 本地测试
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe -m pytest tests/test_lgbm_features.py tests/test_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
### 编译检查
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe -m compileall src web scripts tests
|
||||
```
|
||||
@@ -0,0 +1,123 @@
|
||||
# 外部监控与告警说明
|
||||
|
||||
最后更新:`2026-04-01`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在现有轻量可观测性基础上,把 PolyWeather 补成最小可用的外部监控链路:
|
||||
|
||||
- Prometheus 抓取 `/metrics`
|
||||
- Alertmanager 根据规则聚合告警
|
||||
- Relay 把告警推到运营频道
|
||||
- Grafana 展示趋势面板
|
||||
- 巡检脚本补健康检查
|
||||
|
||||
## 2. 组件
|
||||
|
||||
本仓库现在内置 4 个监控组件:
|
||||
|
||||
- `polyweather_prometheus`
|
||||
- `polyweather_alertmanager`
|
||||
- `polyweather_alert_relay`
|
||||
- `polyweather_grafana`
|
||||
|
||||
对应配置目录:
|
||||
|
||||
- [monitoring/prometheus/prometheus.yml](../monitoring/prometheus/prometheus.yml)
|
||||
- [monitoring/prometheus/alerts.yml](../monitoring/prometheus/alerts.yml)
|
||||
- [monitoring/alertmanager/alertmanager.yml](../monitoring/alertmanager/alertmanager.yml)
|
||||
- [monitoring/grafana/dashboards/polyweather-overview.json](../monitoring/grafana/dashboards/polyweather-overview.json)
|
||||
|
||||
## 3. 启动
|
||||
|
||||
```bash
|
||||
docker compose --profile monitoring up -d polyweather_prometheus polyweather_alertmanager polyweather_alert_relay polyweather_grafana
|
||||
```
|
||||
|
||||
默认端口:
|
||||
|
||||
- Prometheus: `9090`
|
||||
- Alertmanager: `9093`
|
||||
- Grafana: `3001`
|
||||
- Alert relay: `9099`
|
||||
|
||||
## 4. 环境变量
|
||||
|
||||
在 [.env.example](../.env.example) 里新增了这些配置:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PROMETHEUS_PORT=9090
|
||||
POLYWEATHER_ALERTMANAGER_PORT=9093
|
||||
POLYWEATHER_ALERT_RELAY_PORT=9099
|
||||
POLYWEATHER_GRAFANA_PORT=3001
|
||||
POLYWEATHER_GRAFANA_ADMIN_USER=admin
|
||||
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather
|
||||
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `POLYWEATHER_MONITORING_ALERT_CHAT_IDS` 为空时,relay 会自动回退到:
|
||||
- `TELEGRAM_CHAT_IDS`
|
||||
- `TELEGRAM_CHAT_ID`
|
||||
- 告警发送仍复用现有 `TELEGRAM_BOT_TOKEN`
|
||||
|
||||
## 5. 当前告警规则
|
||||
|
||||
当前默认规则:
|
||||
|
||||
- `PolyWeatherWebDown`
|
||||
- `PolyWeatherHttp5xxBurst`
|
||||
- `PolyWeatherHighSourceErrorRate`
|
||||
- `PolyWeatherOpenMeteoCooldownLoop`
|
||||
- `PolyWeatherSlowHttpAverage`
|
||||
|
||||
规则文件:
|
||||
|
||||
- [monitoring/prometheus/alerts.yml](../monitoring/prometheus/alerts.yml)
|
||||
|
||||
## 6. 当前 Grafana 面板
|
||||
|
||||
预置了一个最小仪表板:
|
||||
|
||||
- `PolyWeather Overview`
|
||||
|
||||
包含这些图:
|
||||
|
||||
- HTTP Requests by Status
|
||||
- HTTP Latency
|
||||
- Source Requests by Outcome
|
||||
- Source Error Rate (15m)
|
||||
|
||||
## 7. 巡检脚本
|
||||
|
||||
手动巡检:
|
||||
|
||||
```bash
|
||||
python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
这个脚本会检查:
|
||||
|
||||
- `/healthz`
|
||||
- `/api/system/status`
|
||||
- `/metrics`
|
||||
|
||||
任何一项失败都会非零退出,适合挂到 crontab 或 systemd timer。
|
||||
|
||||
## 8. 备注
|
||||
|
||||
这套监控现在已经具备:
|
||||
|
||||
- 外部抓取
|
||||
- 告警规则
|
||||
- Telegram 推送
|
||||
- 趋势面板
|
||||
- 巡检脚本
|
||||
|
||||
但它仍是“最小可用版”,还没有覆盖:
|
||||
|
||||
- 节点级 CPU / 内存 / 磁盘
|
||||
- 数据库体积趋势
|
||||
- 更细粒度支付指标
|
||||
- 按城市/来源拆分的业务 SLA
|
||||
@@ -0,0 +1,45 @@
|
||||
# AGPL-3.0 与商用边界
|
||||
|
||||
最后更新:`2026-03-30`
|
||||
|
||||
## 1. 当前许可证
|
||||
|
||||
- 本仓库代码自 `2026-03-30` 起采用 **GNU Affero General Public License v3.0 only**(`AGPL-3.0-only`)。
|
||||
- 该许可证适用于仓库中未另行声明许可证的源代码与文档。
|
||||
- 如果你修改本项目并通过网络向用户提供服务,需按 AGPL 第 13 条向用户提供对应源码。
|
||||
|
||||
## 2. 旧版本说明
|
||||
|
||||
- 在本次切换前已经发布的 MIT 版本,仍按其原始许可证生效。
|
||||
- 本次变更不会追溯撤销既往已发布版本的 MIT 授权。
|
||||
|
||||
## 3. 仓库公开范围
|
||||
|
||||
- 天气数据采集与标准化(METAR / Open-Meteo / MGM / 官方结算源接口层)。
|
||||
- DEB、基础趋势分析、概率桶、历史对账、前端看板与 Bot 基础能力。
|
||||
- 标准支付流程、链上收款合约与公开 API/BFF 结构。
|
||||
|
||||
## 4. 不在仓库许可证授权范围内的资产
|
||||
|
||||
- 商标、品牌名、域名、Logo、商店素材与市场宣传文案。
|
||||
- 生产数据库、用户资料、钱包映射、订阅审计日志、内部报表。
|
||||
- 私有运营脚本、增长工具、内部风控参数、收费策略细节与内部阈值。
|
||||
- 托管服务本身、SLA、客服、运维值守与内部告警路由。
|
||||
|
||||
## 5. 配置与数据安全红线
|
||||
|
||||
- 不提交:`.env`、私钥、API key、机器人 token、第三方 service role key。
|
||||
- 不提交:生产数据库、运行态快照、支付流水快照、用户身份信息。
|
||||
- 不提交:仅用于线上商业判断的私有规则库与内部操作手册。
|
||||
|
||||
## 6. 对部署者的要求
|
||||
|
||||
- 若你提供公开网络服务,应在产品界面中提供清晰可访问的源码入口。
|
||||
- 若你修改了本项目再对外提供网络服务,应公开与你实际运行版本对应的源码。
|
||||
- 若你使用了仓库外的私有数据、商标或运营资产,这些额外资产不因 AGPL 自动获得授权。
|
||||
|
||||
## 7. 法务与运营建议
|
||||
|
||||
- 代码许可证与商标/品牌授权应继续分离管理。
|
||||
- 官网应补充服务条款、隐私政策与付费权益说明。
|
||||
- 若后续接受外部贡献,再次调整许可证前应先确认贡献者版权归属与再许可条件。
|
||||
@@ -0,0 +1,109 @@
|
||||
# Ops 运营后台说明
|
||||
|
||||
最后更新:`2026-04-01`
|
||||
|
||||
## 1. 入口
|
||||
|
||||
前端入口:
|
||||
|
||||
- `https://polyweather-pro.vercel.app/ops`
|
||||
|
||||
## 2. 权限
|
||||
|
||||
`/ops` 的写接口由后端白名单控制:
|
||||
|
||||
```env
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
```
|
||||
|
||||
可配置多个邮箱,逗号分隔。
|
||||
|
||||
## 3. 当前能力
|
||||
|
||||
### 只读能力
|
||||
|
||||
- 系统健康
|
||||
- SQLite / rollout / metrics 摘要
|
||||
- 支付运行态
|
||||
- 当前会员
|
||||
- 周榜
|
||||
- 支付异常单
|
||||
- 漏斗转化面板
|
||||
|
||||
### 写能力
|
||||
|
||||
- 手动补分
|
||||
- 标记支付异常单“已处理”
|
||||
|
||||
## 4. 当前会员
|
||||
|
||||
会员列表来自:
|
||||
|
||||
1. `subscriptions` 中的有效订阅
|
||||
2. 本地 `users` / `supabase_bindings`
|
||||
3. 若本地缺邮箱或注册时间,再回补 Supabase Auth 用户信息
|
||||
|
||||
去重规则:
|
||||
|
||||
- 同一个 `user_id` 只保留最晚到期那条
|
||||
|
||||
## 5. 支付异常单
|
||||
|
||||
当前异常单来源:
|
||||
|
||||
- `payment_audit_events`
|
||||
- 仅筛 `payment_intent_failed`
|
||||
|
||||
当前支持的典型失败原因:
|
||||
|
||||
- `receiver_mismatch`
|
||||
- `sender_mismatch`
|
||||
- `event_mismatch`
|
||||
- `tx_reverted`
|
||||
|
||||
默认只显示未处理项。
|
||||
|
||||
## 6. 典型处理流程
|
||||
|
||||
### 6.1 钱已到账但没开订阅
|
||||
|
||||
先看 `/ops` 的支付异常单:
|
||||
|
||||
- 如果是 `receiver_mismatch`
|
||||
- 优先判定为支付打到了旧收款地址
|
||||
- 不是缓存问题
|
||||
|
||||
然后执行:
|
||||
|
||||
1. 查 `payment_intents`
|
||||
2. 查 `payment_transactions`
|
||||
3. 查 `subscriptions`
|
||||
4. 跑恢复脚本:
|
||||
|
||||
```bash
|
||||
python scripts/reconcile_subscription_by_email.py --email <user_email>
|
||||
```
|
||||
|
||||
如果仍然失败,再人工补订阅。
|
||||
|
||||
### 6.2 已人工处理
|
||||
|
||||
在 `/ops` 里直接点:
|
||||
|
||||
- `标记已处理`
|
||||
|
||||
这不会删除审计事件,只会给原事件写:
|
||||
|
||||
- `resolved_at`
|
||||
- `resolved_by`
|
||||
|
||||
## 7. 备注
|
||||
|
||||
`/ops` 是运营后台最小版,不是完整 Admin 平台。当前目标是:
|
||||
|
||||
- 让会员、积分、支付事故、系统状态可查
|
||||
- 让常见人工操作不必再直接写 SQL
|
||||
|
||||
外部监控与告警栈说明见:
|
||||
|
||||
- [MONITORING_ZH.md](./MONITORING_ZH.md)
|
||||
@@ -0,0 +1,324 @@
|
||||
# 概率训练样本归档说明(中文)
|
||||
|
||||
## 1. 目的
|
||||
|
||||
这份文档说明两件事:
|
||||
|
||||
1. 为什么 `EMOS` 训练不能只依赖历史实测天气
|
||||
2. 未来如何持续沉淀“历史预测记录”,让概率引擎越训越稳
|
||||
|
||||
一句话结论:
|
||||
|
||||
- 历史实测天气只能补 `actual_high`
|
||||
- 真正决定 `EMOS` 训练质量的是“当时那一刻的预测快照”
|
||||
|
||||
## 2. 什么是“历史预测记录”
|
||||
|
||||
对 PolyWeather 来说,一条可训练的历史预测记录,至少应该包含这些字段:
|
||||
|
||||
- `city`
|
||||
- `timestamp`
|
||||
- `date`
|
||||
- `raw_mu`
|
||||
- `raw_sigma`
|
||||
- `deb_prediction`
|
||||
- `ensemble p10 / p50 / p90`
|
||||
- `multi-model forecasts`
|
||||
- `max_so_far`
|
||||
- `peak_status`
|
||||
- `prob_snapshot`
|
||||
- 当天最终 `actual_high`
|
||||
- 当天最终 `settlement bucket`
|
||||
|
||||
这类记录的核心价值是:
|
||||
|
||||
- 还原“当时系统实际看到什么”
|
||||
- 再对照“后来真实发生了什么”
|
||||
|
||||
只有这两者成对,`EMOS` 才能学习偏差。
|
||||
|
||||
## 3. 为什么不能只用历史天气实测
|
||||
|
||||
历史天气 CSV 只能告诉你:
|
||||
|
||||
- 当天最高温是多少
|
||||
- 某小时温度是多少
|
||||
|
||||
但它不能告诉你:
|
||||
|
||||
- 当天早上 09:00 时,系统的 `mu` 是多少
|
||||
- 当时的 `ensemble spread` 是多少
|
||||
- 当时 `DEB` 怎么看
|
||||
- 当时的 top bucket 是什么
|
||||
|
||||
所以:
|
||||
|
||||
- 历史实测天气是标签
|
||||
- 历史预测记录才是训练输入
|
||||
|
||||
缺少后者,EMOS 只能学到很有限的东西。
|
||||
|
||||
## 4. 当前项目里已经有的基础
|
||||
|
||||
### 4.1 已有历史日记录
|
||||
|
||||
文件:
|
||||
|
||||
- [daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
|
||||
当前已经保存了一部分训练相关字段,例如:
|
||||
|
||||
- `forecasts`
|
||||
- `actual_high`
|
||||
- `deb_prediction`
|
||||
- `mu`
|
||||
- `prob_snapshot`
|
||||
- `shadow_prob_snapshot`
|
||||
- `probability_calibration`
|
||||
- `probability_features`
|
||||
|
||||
这已经是“历史预测记录”的雏形。
|
||||
|
||||
### 4.2 已有历史天气 CSV
|
||||
|
||||
目录:
|
||||
|
||||
- [data/historical](/E:/web/PolyWeather/data/historical)
|
||||
|
||||
它们可以帮助补:
|
||||
|
||||
- `actual_high`
|
||||
- `settlement history`
|
||||
|
||||
但不能替代预测快照归档。
|
||||
|
||||
## 5. 未来应该怎么存历史预测记录
|
||||
|
||||
推荐做法是:
|
||||
|
||||
### 5.1 固定时点归档
|
||||
|
||||
每天为每个重点城市固定存几次快照,例如:
|
||||
|
||||
- 当地 `09:00`
|
||||
- 当地 `12:00`
|
||||
- 当地 `15:00`
|
||||
|
||||
这样能确保每个交易日都有稳定可比样本。
|
||||
|
||||
### 5.2 关键变化时补充归档
|
||||
|
||||
除了固定时点,还应该在以下情况额外存一次:
|
||||
|
||||
- `max_so_far` 创新高
|
||||
- `mu` 变化超过阈值
|
||||
- `top bucket` 发生变化
|
||||
- `shadow top bucket` 发生变化
|
||||
|
||||
这样能捕捉真正有训练价值的转折点。
|
||||
|
||||
### 5.3 建议的存储格式
|
||||
|
||||
建议新增一个文件,例如:
|
||||
|
||||
- `data/probability_training_snapshots.jsonl`
|
||||
|
||||
每一行保存一条 JSON 记录。
|
||||
|
||||
优点:
|
||||
|
||||
- 追加写入简单
|
||||
- 后续导出训练集方便
|
||||
- 不容易因为单个大 JSON 文件损坏而全盘受影响
|
||||
|
||||
## 6. 一条建议的快照结构
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"city": "ankara",
|
||||
"timestamp": "2026-03-20T12:00:00+03:00",
|
||||
"date": "2026-03-20",
|
||||
"raw_mu": 15.2,
|
||||
"raw_sigma": 1.2,
|
||||
"deb_prediction": 15.4,
|
||||
"ensemble": {
|
||||
"p10": 14.8,
|
||||
"median": 15.8,
|
||||
"p90": 17.9
|
||||
},
|
||||
"multi_model": {
|
||||
"ECMWF": 15.8,
|
||||
"GFS": 14.1,
|
||||
"ICON": 15.9,
|
||||
"GEM": 16.5,
|
||||
"JMA": 14.5
|
||||
},
|
||||
"max_so_far": 15.0,
|
||||
"peak_status": "before",
|
||||
"prob_snapshot": [
|
||||
{"v": 15, "p": 0.552},
|
||||
{"v": 16, "p": 0.377}
|
||||
],
|
||||
"shadow_prob_snapshot": [
|
||||
{"v": 15, "p": 0.324},
|
||||
{"v": 16, "p": 0.238}
|
||||
],
|
||||
"probability_engine": "legacy",
|
||||
"probability_mode": "emos_shadow",
|
||||
"calibration_version": "emos-20260320130245"
|
||||
}
|
||||
```
|
||||
|
||||
当天结束后,再由后处理脚本回填:
|
||||
|
||||
- `actual_high`
|
||||
- `settlement_bucket`
|
||||
|
||||
## 7. 现阶段你可以执行的命令
|
||||
|
||||
### 7.1 回填历史天气 CSV
|
||||
|
||||
```bash
|
||||
python scripts/backfill_historical_weather.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 补全 30 城市历史天气时序 CSV
|
||||
|
||||
### 7.2 从历史 CSV 构建日级结算标签
|
||||
|
||||
```bash
|
||||
python scripts/build_settlement_history_from_csv.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [settlement_history.json](/E:/web/PolyWeather/artifacts/probability_calibration/settlement_history.json)
|
||||
|
||||
### 7.3 导出当前训练样本
|
||||
|
||||
```bash
|
||||
python scripts/export_probability_training_dataset.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [training_samples.json](/E:/web/PolyWeather/artifacts/probability_calibration/training_samples.json)
|
||||
|
||||
### 7.4 重训 EMOS
|
||||
|
||||
```bash
|
||||
python scripts/fit_probability_calibration.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成新的 [default.json](/E:/web/PolyWeather/artifacts/probability_calibration/default.json)
|
||||
|
||||
### 7.5 离线评估训练效果
|
||||
|
||||
```bash
|
||||
python scripts/evaluate_probability_calibration.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
|
||||
|
||||
### 7.6 回填 shadow 结果到历史记录
|
||||
|
||||
```bash
|
||||
python scripts/backfill_probability_shadow_history.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 把 `shadow_prob_snapshot` 和 `probability_calibration` 回填到 [daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
|
||||
### 7.7 生成线上 shadow 滚动报表
|
||||
|
||||
```bash
|
||||
python scripts/build_probability_shadow_report.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
|
||||
|
||||
## 8. 推荐的一整套重训流程
|
||||
|
||||
如果过了十天、半个月,想重新训练一次,建议按这个顺序执行:
|
||||
|
||||
```bash
|
||||
python scripts/build_settlement_history_from_csv.py
|
||||
python scripts/export_probability_training_dataset.py
|
||||
python scripts/fit_probability_calibration.py
|
||||
python scripts/evaluate_probability_calibration.py
|
||||
python scripts/backfill_probability_shadow_history.py
|
||||
python scripts/build_probability_shadow_report.py
|
||||
```
|
||||
|
||||
如果历史天气 CSV 还没补全,再先执行:
|
||||
|
||||
```bash
|
||||
python scripts/backfill_historical_weather.py
|
||||
```
|
||||
|
||||
## 9. 怎么判断这次训练有没有进步
|
||||
|
||||
重训后,不要只看一个指标。
|
||||
|
||||
至少看这 4 个:
|
||||
|
||||
1. `CRPS`
|
||||
- 越低越好
|
||||
|
||||
2. `MAE`
|
||||
- 越低越好
|
||||
- 至少不要明显变差
|
||||
|
||||
3. `Bucket Hit Rate`
|
||||
- 越高越好
|
||||
- 这是业务上非常关键的指标
|
||||
|
||||
4. `Bucket Brier`
|
||||
- 越低越好
|
||||
- 反映概率分布质量
|
||||
|
||||
只有同时满足下面条件,才可以说训练效果真的进步:
|
||||
|
||||
- `CRPS` 下降
|
||||
- `MAE` 不上升
|
||||
- `Bucket Hit Rate` 不下降
|
||||
- `Bucket Brier` 不上升
|
||||
|
||||
## 10. 当前最重要的现实判断
|
||||
|
||||
过去的“完整历史预测记录”通常没法完全补出来,除非:
|
||||
|
||||
1. 你之前就存过
|
||||
2. 你接入了支持 forecast archive 的商业数据源
|
||||
|
||||
所以现实里最重要的不是“把过去全补齐”,而是:
|
||||
|
||||
- 从现在开始系统化归档
|
||||
- 每天稳定沉淀可训练样本
|
||||
- 定期离线重训
|
||||
|
||||
## 11. 推荐的下一步
|
||||
|
||||
最值得做的改造是:
|
||||
|
||||
1. 新增 `probability_training_snapshots.jsonl`
|
||||
2. 每次分析时自动追加一条快照
|
||||
3. 当天结束后自动回填 `actual_high`
|
||||
4. 每 1-2 周重新训练一次
|
||||
|
||||
## 12. 总结
|
||||
|
||||
如果只记住一句话,就记这个:
|
||||
|
||||
**EMOS 要想越训越好,关键不是多下载一点历史天气,而是持续保存“当时系统看到的预测快照”。**
|
||||
@@ -0,0 +1,122 @@
|
||||
# Supabase + 登录 + 支付接入说明(v1.5.1)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 前端支持 Google 一键登录 + 邮箱注册/登录。
|
||||
- 后端支持 Supabase JWT 鉴权。
|
||||
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。
|
||||
|
||||
## 2. Supabase 控制台配置
|
||||
|
||||
1. `Auth -> Providers` 打开 `Google` 与 `Email`。
|
||||
2. Google Cloud OAuth 回调配置:
|
||||
- `https://<project-ref>.supabase.co/auth/v1/callback`
|
||||
3. `Auth -> URL Configuration` 添加:
|
||||
- 站点 URL(生产域名)
|
||||
- 回调 URL(例如 `https://polyweather-pro.vercel.app/auth/callback`)
|
||||
|
||||
## 3. 数据库脚本
|
||||
|
||||
在 Supabase SQL Editor 执行:
|
||||
|
||||
- `scripts/supabase/schema.sql`
|
||||
|
||||
会创建支付与订阅相关表:
|
||||
|
||||
- `subscriptions`
|
||||
- `payments`
|
||||
- `entitlement_events`
|
||||
- `user_wallets`
|
||||
- `wallet_link_challenges`
|
||||
- `payment_intents`
|
||||
- `payment_transactions`
|
||||
|
||||
## 4. 环境变量
|
||||
|
||||
### 4.1 前端(Vercel / frontend/.env.local)
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
POLYWEATHER_API_BASE_URL=http://<backend-host>:8000
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
# WalletConnect(支持手机钱包扫码)
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
|
||||
# Overlay 跳转
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||
```
|
||||
|
||||
### 4.2 后端 / Bot(.env)
|
||||
|
||||
```env
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false
|
||||
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
|
||||
POLYWEATHER_PAYMENT_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract>
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
|
||||
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
|
||||
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
|
||||
|
||||
# 支持双币种(示例)
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver>","is_default":true},{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver>"}]
|
||||
|
||||
# 套餐(当前只保留月付)
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}}
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
|
||||
|
||||
# 积分抵扣
|
||||
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
|
||||
|
||||
# 支付自动补单
|
||||
POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
|
||||
```
|
||||
|
||||
## 5. 钱包异动频道拆分(推荐)
|
||||
|
||||
如果要把“钱包异动监控”发到独立频道:
|
||||
|
||||
```env
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=-1003821482461
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 设置了 `POLYMARKET_WALLET_ACTIVITY_CHAT_ID(S)` 后,钱包异动推送优先发该频道。
|
||||
- 未设置时,回退到全局 `TELEGRAM_CHAT_IDS/TELEGRAM_CHAT_ID`。
|
||||
|
||||
## 6. 验证步骤
|
||||
|
||||
1. 登录后请求 `/api/auth/me`,确认 `authenticated=true`。
|
||||
2. 请求 `/api/payments/config`,确认 `enabled=true`、`configured=true`。
|
||||
3. 钱包绑定:
|
||||
- `POST /api/payments/wallets/challenge`
|
||||
- `POST /api/payments/wallets/verify`
|
||||
4. 支付流程:
|
||||
- `POST /api/payments/intents`
|
||||
- 发链上交易
|
||||
- `POST /api/payments/intents/{id}/submit`
|
||||
- `POST /api/payments/intents/{id}/confirm`
|
||||
5. 若前端显示 pending,轮询:
|
||||
- `GET /api/payments/intents/{id}`
|
||||
6. 确认订阅:`/api/auth/me` 返回 `subscription_active=true`。
|
||||
@@ -0,0 +1,286 @@
|
||||
# PolyWeather TAF 信号说明(TAF_SIGNAL_ZH)
|
||||
|
||||
本文档说明 PolyWeather 当前如何把 `TAF`(机场终端预报)接入“今日日内分析”,以及这些信号在交易判断里到底代表什么。
|
||||
|
||||
本文档只描述**当前实现**,不夸大、不脑补。
|
||||
|
||||
---
|
||||
|
||||
## 一、TAF 在项目中的定位
|
||||
|
||||
在 PolyWeather 里,`TAF` 不是主温度模型,也不是结算源。
|
||||
|
||||
它的定位是:
|
||||
|
||||
1. **机场侧确认层**
|
||||
- 用来补充说明机场在峰值窗口附近会不会出现云、雨、雷暴或风向切换。
|
||||
|
||||
2. **压温 / 扰动风险提示层**
|
||||
- 用来判断“机场最高温是否可能因为天气扰动被压低”。
|
||||
|
||||
3. **走势图时间轴联动层**
|
||||
- 用来在温度走势图上标出 `FM / TEMPO / BECMG / PROB30/40` 对应的时段。
|
||||
|
||||
它**不负责**:
|
||||
|
||||
1. 直接提供多模型最高温数值
|
||||
2. 替代 `DEB`
|
||||
3. 替代机场实况 `METAR`
|
||||
4. 替代官方结算源
|
||||
|
||||
---
|
||||
|
||||
## 二、后端当前怎么解析 TAF
|
||||
|
||||
后端入口在:
|
||||
|
||||
- [web/analysis_service.py](/E:/web/PolyWeather/web/analysis_service.py)
|
||||
|
||||
核心函数:
|
||||
|
||||
- `_build_taf_signal(...)`
|
||||
|
||||
当前会解析这些时间片:
|
||||
|
||||
1. `BASE`
|
||||
2. `FM`
|
||||
3. `TEMPO`
|
||||
4. `BECMG`
|
||||
5. `PROB30`
|
||||
6. `PROB40`
|
||||
7. `PROB30 TEMPO`
|
||||
8. `PROB40 TEMPO`
|
||||
|
||||
并且只聚焦于:
|
||||
|
||||
- **峰值窗口前后**
|
||||
|
||||
当前窗口定义是:
|
||||
|
||||
- `peak.first_h - 2h`
|
||||
- 到
|
||||
- `peak.last_h + 1h`
|
||||
|
||||
也就是说,TAF 不是整段报文全量平铺,而是会优先关注**和今天高温兑现最相关的时段**。
|
||||
|
||||
---
|
||||
|
||||
## 三、当前后端真正产出的核心字段
|
||||
|
||||
### 1. `suppression_level`
|
||||
|
||||
表示机场端的**压温风险等级**。
|
||||
|
||||
当前逻辑来自:
|
||||
|
||||
1. 降水 / 雷暴关键词
|
||||
- `TSRA`
|
||||
- `TS`
|
||||
- `VCTS`
|
||||
- `SHRA`
|
||||
- `SHSN`
|
||||
- `SHGS`
|
||||
- `RA`
|
||||
- `DZ`
|
||||
- `SN`
|
||||
|
||||
2. 低云底
|
||||
- 只对 `BKN / OVC` 生效
|
||||
- 如果最低云底 `<= 4000 ft`
|
||||
- 会把原本 `low` 的压温风险至少抬到 `medium`
|
||||
|
||||
注意:
|
||||
|
||||
- 不是所有 `FEW / SCT / BKN / OVC` 都会直接把风险打到 `high`
|
||||
- 当前实现里,**低云主要是把风险从 `low` 抬到 `medium`**
|
||||
- 真正更容易触发 `high` 的,还是阵雨 / 雷暴类关键词
|
||||
|
||||
### 2. `disruption_level`
|
||||
|
||||
表示峰值窗口附近的**扰动程度**。
|
||||
|
||||
当前逻辑:
|
||||
|
||||
1. 这些时间片会至少把扰动抬到 `medium`
|
||||
- `TEMPO`
|
||||
- `BECMG`
|
||||
- `PROB30`
|
||||
- `PROB40`
|
||||
- `PROB30 TEMPO`
|
||||
- `PROB40 TEMPO`
|
||||
|
||||
2. 这些情况会把扰动抬到 `high`
|
||||
- `PROB30 TEMPO`
|
||||
- `PROB40 TEMPO`
|
||||
- 或者该时段本身就出现强降水/雷暴类关键词
|
||||
|
||||
注意:
|
||||
|
||||
- **`TEMPO` 本身不等于 `high`**
|
||||
- **`PROB40` 也不等于“确定发生”**
|
||||
|
||||
---
|
||||
|
||||
## 四、前端怎么展示
|
||||
|
||||
前端主要在:
|
||||
|
||||
- [frontend/components/dashboard/FutureForecastModal.tsx](/E:/web/PolyWeather/frontend/components/dashboard/FutureForecastModal.tsx)
|
||||
- [frontend/lib/dashboard-utils.ts](/E:/web/PolyWeather/frontend/lib/dashboard-utils.ts)
|
||||
|
||||
当前会通过三种方式展示 TAF:
|
||||
|
||||
### 1. 图表时间轴标记
|
||||
|
||||
在日内温度走势图上,当前会显示:
|
||||
|
||||
- `TAF 时段 / TAF Timing`
|
||||
|
||||
tooltip 会显示该时段摘要,例如:
|
||||
|
||||
- `基础时段 13:00-19:00 以稳定为主`
|
||||
- `明确切换 15:00-21:00 以稳定为主`
|
||||
- `临时波动 14:00-17:00 有云雨扰动`
|
||||
|
||||
### 2. 今日日内结构信号里的 `机场预报`
|
||||
|
||||
会显示类似:
|
||||
|
||||
- `防压温`
|
||||
- `看云雨`
|
||||
- `暂稳`
|
||||
|
||||
### 3. 顶部摘要与交易动作
|
||||
|
||||
系统会把:
|
||||
|
||||
1. 近地面结构
|
||||
2. 高空结构
|
||||
3. `TAF`
|
||||
4. `market_signal / edge_percent / bucket crowding`
|
||||
|
||||
合并成更贴近交易的提示,例如:
|
||||
|
||||
- `偏暖侧`
|
||||
- `偏谨慎`
|
||||
- `先观察`
|
||||
|
||||
---
|
||||
|
||||
## 五、TAF 关键词当前在项目里的真实含义
|
||||
|
||||
| TAF 关键词 | 当前展示词 | 当前项目含义 |
|
||||
| :-- | :-- | :-- |
|
||||
| `BASE` | 基础时段 | 在第一个显式变化组出现前的默认背景天气段 |
|
||||
| `FM` | 明确切换 | 从某个明确时刻开始,机场预报进入一套新的天气阶段 |
|
||||
| `TEMPO` | 临时波动 | 一段短时、非整段主导的扰动窗口 |
|
||||
| `BECMG` | 逐步转变 | 天气在该窗口内逐步过渡,不是立刻硬切 |
|
||||
| `PROB30/40` | 30% / 40% 风险窗 | 有概率触发的扰动窗口,不等于确定发生 |
|
||||
|
||||
注意:
|
||||
|
||||
- `FM` 不是由 `valid_match` 触发,它是按 `FMddhhmm` 独立解析出来的明确切换段
|
||||
- `PROB40` 不是“确定性信号”,仍然只是概率窗口
|
||||
|
||||
---
|
||||
|
||||
## 六、怎么理解“机场端压温风险偏高”
|
||||
|
||||
这句话的意思不是:
|
||||
|
||||
- 城区一定更冷
|
||||
- 一定会结算更低
|
||||
|
||||
真正意思是:
|
||||
|
||||
**在机场这个结算相关站点上,峰值窗口附近更容易因为云、阵雨或雷暴,导致最终最高温冲不上去。**
|
||||
|
||||
对于很多按机场报文或机场相关站点结算的市场,这一点很关键。
|
||||
|
||||
一句话:
|
||||
|
||||
- `TAF` 提示压温高
|
||||
- 不代表一定下雨
|
||||
- 但代表“机场高温可能被压低”的概率更值得防
|
||||
|
||||
---
|
||||
|
||||
## 七、怎么和图表一起看
|
||||
|
||||
### 情况 A:模型还偏热,但 TAF 给出压温高
|
||||
|
||||
这表示:
|
||||
|
||||
1. 数值模型仍给出较高高温
|
||||
2. 但机场端预报提示云雨/雷暴会打断峰值兑现
|
||||
|
||||
这种情况下,更适合理解成:
|
||||
|
||||
- **机场侧高温兑现有风险**
|
||||
- 追更高温区间要谨慎
|
||||
|
||||
### 情况 B:TAF 没有新增压温,但总判断仍偏降温
|
||||
|
||||
这表示:
|
||||
|
||||
1. `TAF` 没有提供新的云雨压温利空
|
||||
2. 但近地面窗口本身已经在走弱
|
||||
|
||||
比如:
|
||||
|
||||
- 温度走弱
|
||||
- 风场切换
|
||||
- 气压回升
|
||||
- 露点回落
|
||||
|
||||
所以:
|
||||
|
||||
- `TAF 无压温`
|
||||
- **不等于**
|
||||
- `一定继续升温`
|
||||
|
||||
当前系统已经会在摘要里把这层关系解释清楚。
|
||||
|
||||
---
|
||||
|
||||
## 八、当前实现边界
|
||||
|
||||
这套 `TAF` 逻辑当前是**交易导向的轻量解码**,不是完整航空专业解码器。
|
||||
|
||||
当前做得到:
|
||||
|
||||
1. 识别主要时间片
|
||||
2. 找出峰值窗口附近的扰动
|
||||
3. 识别云雨压温和风向切换
|
||||
4. 联动图表时间轴
|
||||
5. 联动交易动作
|
||||
|
||||
当前还没有做:
|
||||
|
||||
1. 对全部 TAF 语法做完整航空级严格解释
|
||||
2. 对每个时段都做完整的逐字段人工预报学解释
|
||||
3. 把 TAF 当成温度主预测模型
|
||||
|
||||
---
|
||||
|
||||
## 九、产品口径总结
|
||||
|
||||
最重要的一句:
|
||||
|
||||
**在 PolyWeather 里,TAF 是“机场侧扰动确认层”,不是主模型,也不是结算源。**
|
||||
|
||||
它最值钱的地方是:
|
||||
|
||||
- 帮你判断峰值窗口附近,机场高温会不会被云雨、雷暴、低云或风向切换打断。
|
||||
|
||||
对交易来说,更适合把它理解成:
|
||||
|
||||
- “机场这边有没有额外的压温风险”
|
||||
|
||||
而不是:
|
||||
|
||||
- “TAF 直接告诉我今天结算温度是多少”
|
||||
|
||||
---
|
||||
|
||||
_PolyWeather 文档中心_
|
||||
@@ -1,92 +1,81 @@
|
||||
# Technical Debt Backlog
|
||||
# 技术债与工程待办(v1.5.1)
|
||||
|
||||
Purpose: keep engineering debt explicit while shipping production features.
|
||||
最后更新:`2026-03-31`
|
||||
|
||||
---
|
||||
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
|
||||
|
||||
## 1. Debt Landscape
|
||||
## 1. 债务快照
|
||||
|
||||
当前估计:**95% 稳定 / 5% 技术债**。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Tech Debt"]
|
||||
A["技术债"]
|
||||
|
||||
subgraph AR["Architecture"]
|
||||
AR1["Monolithic bot entry"]
|
||||
AR2["Shared runtime coupling"]
|
||||
subgraph P["支付与订阅"]
|
||||
P1["合约 V2 升级(SafeERC20 / Pausable)"]
|
||||
P2["退款与工单流程"]
|
||||
P3["多 RPC 与链上对账面板"]
|
||||
end
|
||||
|
||||
subgraph PI["Product Infra"]
|
||||
PI1["Entitlement hardening"]
|
||||
PI2["Subscription persistence"]
|
||||
subgraph E["权限与运营"]
|
||||
E1["前后端/Bot 权限矩阵回归"]
|
||||
E2["积分来源明细与补分审计"]
|
||||
end
|
||||
|
||||
subgraph Q["Quality"]
|
||||
Q1["Replay harness"]
|
||||
Q2["Broader regression tests"]
|
||||
subgraph O["可观测性"]
|
||||
O1["外部监控抓取与告警阈值"]
|
||||
O2["业务监控看板"]
|
||||
end
|
||||
|
||||
subgraph O["Observability"]
|
||||
O1["Alert evidence trace"]
|
||||
O2["SLO dashboards"]
|
||||
subgraph S["状态与概率"]
|
||||
S1["EMOS shadow -> primary 门禁稳定化"]
|
||||
end
|
||||
|
||||
A --> AR
|
||||
A --> PI
|
||||
A --> Q
|
||||
A --> P
|
||||
A --> E
|
||||
A --> O
|
||||
A --> S
|
||||
```
|
||||
|
||||
Current system health estimate: **84% stable / 16% debt**.
|
||||
## 2. 近期已关闭
|
||||
|
||||
---
|
||||
- 支付主链路已上线(intent -> submit -> confirm)。
|
||||
- 支付自动补单已上线(Event Loop + Confirm Loop)。
|
||||
- 支付事件重放脚本已补齐。
|
||||
- 支付运行态 API 与 SQLite 审计事件已补齐。
|
||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||
- 账户中心与 Pro 权限展示链路打通。
|
||||
- 钱包异动支持独立频道路由。
|
||||
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
|
||||
- 轻量可观测性已上线(`/healthz`、`/api/system/status`、`/metrics`)。
|
||||
- EMOS/CRPS 校准链路已上线 shadow 模式。
|
||||
|
||||
## 2. Recently Closed (2026-03-11)
|
||||
## 3. 高优先级技术债
|
||||
|
||||
- Meteoblue API path fully removed from backend, frontend, config and docs.
|
||||
- Market top-bucket duplicate temperature issue fixed (backend dedupe + frontend guard).
|
||||
- Detail panel a11y conflict fixed (`aria-hidden` focus conflict resolved with `inert` + blur).
|
||||
- Vercel Speed Insights integrated for frontend performance telemetry.
|
||||
|
||||
---
|
||||
|
||||
## 3. High Priority Debt
|
||||
|
||||
| Item | Impact | Suggested Work |
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| Monolithic bot entry (`bot_listener.py`) | Hard to test and safely refactor | Split orchestration, IO and analysis modules |
|
||||
| Entitlement enforcement consistency | Revenue leakage risk | Align frontend middleware and backend enforcement |
|
||||
| Subscriber persistence model | Manual operations do not scale | Move to managed PostgreSQL/Supabase state |
|
||||
| Alert explainability | Operator trust and debugging cost | Standardize evidence payload per alert |
|
||||
| EMOS 上线门禁 | 当前 `hold`,不能切 primary | 继续积累样本,重点压 `bucket_brier` |
|
||||
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
|
||||
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
|
||||
|
||||
---
|
||||
## 4. 中优先级技术债
|
||||
|
||||
## 4. Medium Priority Debt
|
||||
|
||||
| Item | Impact | Suggested Work |
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| Replay simulation harness | Hard to reproduce edge cases | Build deterministic replay over stored records |
|
||||
| Chart/UI regression coverage | Visual regressions can slip | Add snapshot + interaction test coverage |
|
||||
| Config centralization | Threshold changes are error-prone | Consolidate runtime knobs into structured config |
|
||||
| Naming cleanup | Legacy terms reduce clarity | Refactor naming in market/weather boundary layer |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/奖励/手动补分) |
|
||||
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
|
||||
---
|
||||
## 5. 低优先级技术债
|
||||
|
||||
## 5. Low Priority Debt
|
||||
|
||||
| Item | Impact | Suggested Work |
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| Cold-start behavior | First request latency variance | Add warming strategy for top city routes |
|
||||
| Storage abstraction | Local file assumptions remain | Continue moving state to remote services |
|
||||
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
|
||||
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
|
||||
|
||||
---
|
||||
## 6. 下阶段里程碑
|
||||
|
||||
## 6. Next Milestones
|
||||
|
||||
1. Entitlement parity: one policy across frontend and backend.
|
||||
2. Subscriber DB integration and migration scripts.
|
||||
3. Alert evidence schema + tooling for quick operator audit.
|
||||
4. Replay runner for weather/market mixed regression scenarios.
|
||||
|
||||
---
|
||||
|
||||
Last Updated: `2026-03-11`
|
||||
1. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件。
|
||||
2. 补外部监控抓取与告警阈值。
|
||||
3. 评估并推进支付合约 V2 升级。
|
||||
|
||||
@@ -1,92 +1,81 @@
|
||||
# 技术债与工程待办
|
||||
# 技术债与工程待办(v1.5.1)
|
||||
|
||||
目标:在持续交付的同时,把关键技术债显式化、可追踪化。
|
||||
最后更新:`2026-03-31`
|
||||
|
||||
---
|
||||
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
|
||||
|
||||
## 1. 技术债全景
|
||||
## 1. 债务快照
|
||||
|
||||
当前估计:**95% 稳定 / 5% 技术债**。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["技术债"]
|
||||
|
||||
subgraph AR["架构层"]
|
||||
AR1["机器人入口过于集中"]
|
||||
AR2["共享运行时耦合"]
|
||||
subgraph P["支付与订阅"]
|
||||
P1["合约 V2 升级(SafeERC20 / Pausable)"]
|
||||
P2["退款与工单流程"]
|
||||
P3["多 RPC 与链上对账面板"]
|
||||
end
|
||||
|
||||
subgraph PI["产品基础设施"]
|
||||
PI1["订阅权限一致性"]
|
||||
PI2["付费用户持久化"]
|
||||
end
|
||||
|
||||
subgraph Q["质量保障"]
|
||||
Q1["回放测试能力"]
|
||||
Q2["UI 回归覆盖不足"]
|
||||
subgraph E["权限与运营"]
|
||||
E1["前后端/Bot 权限矩阵回归"]
|
||||
E2["积分来源明细与补分审计"]
|
||||
end
|
||||
|
||||
subgraph O["可观测性"]
|
||||
O1["告警证据链"]
|
||||
O2["SLO 看板"]
|
||||
O1["外部监控抓取与告警阈值"]
|
||||
O2["业务监控看板"]
|
||||
end
|
||||
|
||||
A --> AR
|
||||
A --> PI
|
||||
A --> Q
|
||||
subgraph S["状态与概率"]
|
||||
S1["EMOS shadow -> primary 门禁稳定化"]
|
||||
end
|
||||
|
||||
A --> P
|
||||
A --> E
|
||||
A --> O
|
||||
A --> S
|
||||
```
|
||||
|
||||
当前系统健康度估计:**84% 稳定 / 16% 技术债**。
|
||||
## 2. 近期已关闭
|
||||
|
||||
---
|
||||
|
||||
## 2. 最近已关闭项(2026-03-11)
|
||||
|
||||
- Meteoblue API 全链路移除(后端/前端/配置/文档)。
|
||||
- 市场温度桶重复刷屏问题修复(后端去重 + 前端兜底)。
|
||||
- 详情面板可访问性告警修复(`aria-hidden` 焦点冲突改为 `inert + blur`)。
|
||||
- 前端已接入 Vercel Speed Insights。
|
||||
|
||||
---
|
||||
- 支付主链路已上线(intent -> submit -> confirm)。
|
||||
- 支付自动补单已上线(Event Loop + Confirm Loop)。
|
||||
- 支付事件重放脚本已补齐。
|
||||
- 支付运行态 API 与 SQLite 审计事件已补齐。
|
||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||
- 账户中心与 Pro 权限展示链路打通。
|
||||
- 钱包异动支持独立频道路由。
|
||||
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
|
||||
- 轻量可观测性已上线(`/healthz`、`/api/system/status`、`/metrics`)。
|
||||
- EMOS/CRPS 校准链路已上线 shadow 模式。
|
||||
|
||||
## 3. 高优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 机器人入口单体化(`bot_listener.py`) | 测试和重构风险高 | 拆分为编排层、IO 层、分析层 |
|
||||
| 订阅权限策略不完全统一 | 可能造成付费泄露 | 前后端统一权限校验策略 |
|
||||
| 付费用户状态持久化不足 | 人工运营不可扩展 | 迁移到托管 DB(PostgreSQL/Supabase) |
|
||||
| 告警可解释性不足 | 运维排障成本高 | 统一告警证据字段(Evidence Schema) |
|
||||
|
||||
---
|
||||
| EMOS 上线门禁 | 当前 `hold`,不能切 primary | 继续积累样本,重点压 `bucket_brier` |
|
||||
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
|
||||
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
|
||||
|
||||
## 4. 中优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 回放仿真能力不足 | 边缘场景难复现 | 基于历史记录构建可重复 Replay |
|
||||
| 图表/UI 回归覆盖不足 | 视觉回归风险 | 增加快照与交互自动化测试 |
|
||||
| 阈值配置分散 | 改动成本高且易错 | 统一收口到结构化配置 |
|
||||
| 命名历史包袱 | 认知成本高 | 系统化命名治理 |
|
||||
|
||||
---
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/奖励/手动补分) |
|
||||
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
|
||||
## 5. 低优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 冷启动波动 | 首次请求延迟不稳定 | 热点城市路由预热 |
|
||||
| 本地文件状态依赖 | 云端弹性场景受限 | 持续迁移到远程存储 |
|
||||
|
||||
---
|
||||
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
|
||||
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
|
||||
|
||||
## 6. 下阶段里程碑
|
||||
|
||||
1. 完成前后端订阅权限一致化。
|
||||
2. 上线付费用户持久化与迁移脚本。
|
||||
3. 建立告警证据标准并接入运维排障流。
|
||||
4. 落地天气+市场混合回放回归测试。
|
||||
|
||||
---
|
||||
|
||||
最后更新:`2026-03-11`
|
||||
1. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件。
|
||||
2. 补外部监控抓取与告警阈值。
|
||||
3. 评估并推进支付合约 V2 升级。
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# PolyWeather 深度评估与改进提案报告
|
||||
|
||||
## 执行摘要
|
||||
|
||||
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
|
||||
从工程实现看,截至 `2026-04-03`,项目已经完成一轮更明确的工程化收口:多源天气采集仍保持现有业务能力,同时已完成采集层与 Web API 大文件拆分、CI 质量门禁、配置分级(`.env.example` / `.env.secrets.example` / 中文部署文档)、EMOS/CRPS 校准链路、运行态状态与缓存迁移到 SQLite 主路径,以及最小外部监控链路(`/healthz`、`/api/system/status`、`/metrics` + Prometheus + Alertmanager + Grafana + Telegram relay)。除此之外,项目还补上了**历史真值治理**:`daily_records` 继续只保留近 14 天运行态缓存,但新增了永久真值表、真值 revision 审计表和长期训练特征表,并开始把监督真值与训练特征从“短期缓存”正式拆到“长期可追溯存储”。
|
||||
这意味着报告里最初最突出的“工程地基缺失”问题,已经有一部分被关闭:`src/data_collection/weather_sources.py` 与 `web/app.py` 不再是原来的超大单文件;GitHub Actions 已覆盖 Python、前端和 Docker build;配置与密钥治理已成体系;运行态状态不再只能依赖 JSON/JSONL 文件;EMOS 也不再只是概念,而是进入了可训练、可评估、可 shadow、可门禁判断的阶段;更重要的是,监督真值与训练特征不再只能附着在 14 天运行态缓存上。
|
||||
但项目仍处在“从可用走向稳态”的中段,而不是终局。当前真正的高优先级问题已进一步收敛:**EMOS 仍未达到生产切换标准**,当前门禁结论明确为 `hold`,阻塞原因是 shadow bucket brier 明显退化,同时历史长期特征仍处在“刚开始积累”的阶段。SQLite 迁移方面,运行态主读切换和核心离线训练/回填链路已经完成验收:在移除 `data/*.json` / `data/*.jsonl` 后,训练、评估、shadow report 与关键 backfill 脚本仍可仅依赖运行时数据库正常执行;当前保留的 legacy 文件路径主要用于迁移、导出、校验和显式回退输入。历史真值治理方面,新增的永久真值表、revision 审计表与长期训练特征表已经落地,`Taipei` / `Shenzhen` 的 `Wunderground` 历史回填也已接通,因此当前缺口已从“历史真值是否会继续丢失”转为“历史特征是否能持续增长并支撑 EMOS/LGBM 评估”。可观测性方面,最小外部监控链路已经补齐:Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram 告警 relay 与巡检脚本均已落地;当前剩余缺口已从“有没有外部监控”转为“监控覆盖深度是否足够”,例如节点级资源、数据库体积趋势、支付细粒度指标、按城市/来源拆分的业务 SLA。支付链路方面,链下审计与容灾已明显增强:事件重放、SQLite 审计事件、RPC 多节点容灾、合约静态检查、`/ops` 支付异常单都已补齐;当前剩余风险主要集中在**链上合约本身仍是最小实现**,尚未升级到 SafeERC20、Pausable、链上套餐绑定等更强防护版本。
|
||||
因此,当前阶段最正确的策略已经不是继续做“大范围基础重构”,而是围绕**EMOS 上线门禁稳定化、长期训练特征持续积累、监控覆盖深挖、支付合约防护升级**这四条线持续收口。短中期内更高 ROI 的方向依然不是引入新的大模型,而是把现有“采集→后处理→市场映射→支付/订阅”的链路做成**状态一致、指标可见、发布可控、回退明确**的生产平台。
|
||||
## 项目概览
|
||||
|
||||
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
|
||||
项目主功能可归纳为四层:
|
||||
**天气层(数据源/采集)**:聚合 39 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用官方结算源(香港 HKO、台北 RCSS/Wunderground、深圳 ZGSZ/Wunderground)等。
|
||||
**分析层(DEB/趋势/概率/结算口径)**:
|
||||
DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WU(Weather Underground 口径)四舍五入的结算命中评估。
|
||||
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
|
||||
**市场层(Polymarket 行情对照)**:只读模式从 Gamma API 发现市场、从 CLOB(`py-clob-client` 或 REST 回退)读取价格/盘口并计算 edge(模型概率 − 市场概率)生成信号标签。
|
||||
**商业化与支付**:订阅(`Pro Monthly 5 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
|
||||
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
|
||||
(插图:项目 README 中包含产品截图,可用于快速理解信息架构与 UI 形态)
|
||||

|
||||
|
||||
## 架构与代码库分析
|
||||
|
||||
### 代码库模块地图
|
||||
|
||||
从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述):
|
||||
| 层级 | 目录/文件 | 角色定位 | 关键说明 |
|
||||
| ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 运行时组件 | `frontend/` | Next.js 前端(Vercel) | 前端重构报告提到 App Router、Route Handlers(BFF)、缓存策略、支付与账户中心等。 |
|
||||
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层。 |
|
||||
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py` 调 `start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
|
||||
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 + 市场读取 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py`、`settlement_sources.py`、`metar_sources.py`、`mgm_sources.py`、`nws_open_meteo_sources.py`。 |
|
||||
| Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py`、`trend_engine.py`、`settlement_rounding.py`。 |
|
||||
| Python 域模块 | `src/analysis/probability_calibration.py` + `src/analysis/probability_rollout.py` | 概率校准与上线门禁 | 已支持 `legacy / emos_shadow / emos_primary`,并可产出 rollout 判断。 |
|
||||
| Python 域模块 | `src/payments/*` + `contracts/*` | 支付合约 + 事件监听/补单 | Solidity 合约 + Python 侧事件扫描/确认循环 + SQLite 审计事件 + RPC 多节点容灾 + 合约静态检查。 |
|
||||
| Python 域模块 | `src/auth/*`、`docs/SUPABASE_SETUP_ZH.md`、`scripts/supabase/schema.sql` | Supabase 鉴权/订阅/积分 | 使用 `/auth/v1/user` 校验 JWT、`/rest/v1/subscriptions` 查订阅(服务端角色 key 必须保密)。 |
|
||||
| Python 域模块 | `src/database/runtime_state.py` | 运行态状态、永久真值与训练特征仓储 | 已接入 `daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 持久缓存,并新增永久真值表、真值修订审计表、长期训练特征表。 |
|
||||
| 工程与运维 | `docker-compose.yml`、`Dockerfile`、`.github/workflows/ci.yml`、`scripts/*` | 部署/验证脚本 | 现已具备 CI 门禁、迁移脚本、状态校验脚本、配置校验脚本与 rollout 报告脚本。 |
|
||||
|
||||
### 参考架构与关键工作流
|
||||
|
||||
项目 README 给出了一版 mermaid 参考架构图(Web/Telegram→FastAPI→采集→分析→支付/市场层)。 在此基础上,结合 `StartupCoordinator` 的 loop 启动与支付监听逻辑,可补充一个更“运行时视角”的架构图:
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Clients
|
||||
WEB[Next.js Frontend<br/>Vercel]
|
||||
TG[Telegram Bot<br/>TeleBot + Handlers]
|
||||
end
|
||||
|
||||
subgraph API
|
||||
FAST[FastAPI<br/>web/app.py]
|
||||
end
|
||||
|
||||
subgraph Data
|
||||
WX[WeatherDataCollector]
|
||||
CITY[CITY_REGISTRY]
|
||||
HIST[(SQLite runtime state<br/>daily_records / cache / snapshots)]
|
||||
TRUTH[(SQLite truth tables<br/>truth_records / revisions / features)]
|
||||
JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
end
|
||||
|
||||
subgraph ExternalAPIs
|
||||
OM[Open-Meteo Forecast/Ensemble/Multi-model]
|
||||
AW[AviationWeather Data API<br/>METAR]
|
||||
MGM[MGM Turkey]
|
||||
NWS[api.weather.gov]
|
||||
HKO[data.weather.gov.hk]
|
||||
CWA[opendata.cwa.gov.tw]
|
||||
PM_G[Polymarket Gamma API]
|
||||
PM_C[Polymarket CLOB API]
|
||||
SB[Supabase Auth/REST]
|
||||
RPC[Polygon RPC]
|
||||
end
|
||||
|
||||
subgraph Payments
|
||||
SOL[PolyWeatherCheckout.sol]
|
||||
EVT[event_loop<br/>scan logs]
|
||||
CF[confirm_loop<br/>confirm intents]
|
||||
end
|
||||
|
||||
WEB --> FAST
|
||||
TG --> FAST
|
||||
TG -->|StartupCoordinator<br/>starts loops| EVT
|
||||
TG --> CF
|
||||
|
||||
FAST --> WX
|
||||
WX --> OM
|
||||
WX --> AW
|
||||
WX --> MGM
|
||||
WX --> NWS
|
||||
WX --> HKO
|
||||
WX --> CWA
|
||||
|
||||
FAST --> PM_G
|
||||
FAST --> PM_C
|
||||
|
||||
FAST --> SB
|
||||
EVT --> RPC
|
||||
CF --> RPC
|
||||
RPC --> SOL
|
||||
|
||||
WX --> HIST
|
||||
WX --> TRUTH
|
||||
WX --> JSON
|
||||
FAST --> HIST
|
||||
FAST --> TRUTH
|
||||
WX --> CITY
|
||||
```
|
||||
|
||||
### 依赖与运行环境
|
||||
|
||||
**Python 依赖**:`requirements.txt` 包含 `requests`、`loguru`、`pyTelegramBotAPI`、`python-dotenv`、`numpy`、`web3`、`fastapi`、`uvicorn` 等,符合“采集+bot+api+链上交互”的需求。
|
||||
**容器环境**:`Dockerfile` 基于 `python:3.11-slim`,默认启动 bot;`docker-compose.yml` 通过不同 command 分别启动 bot 与 web(`python bot_listener.py` / `python web/app.py`),并挂载运行态数据目录。
|
||||
**前端依赖**:前端 README 描述 Next.js、Leaflet、Chart.js、Supabase Auth、WalletConnect 等;`frontend/package.json` 是前端依赖来源。
|
||||
### 数据预处理、模型与“训练/推理”管线
|
||||
|
||||
本项目的“模型”主要是统计融合与规则/启发式引擎,而非深度网络训练:
|
||||
**天气数据预处理**:`WeatherDataCollector` 内部做了大量“输入清洗+缓存+退避”的工程处理:
|
||||
包含 Open-Meteo 三类缓存(forecast/ensemble/multi_model)、429 冷却期、最小调用间隔、磁盘持久化缓存文件(重启后避免冷启动打爆 API)、以及 METAR/结算源缓存。
|
||||
**DEB(Dynamic Error Balancing)**:以最近 N 天各模型的 MAE 计算倒数权重并做加权融合;同时将 `forecasts / actual_high / deb_prediction / mu / prob_snapshot` 写入 `data/daily_records.json`,并提供命中率/MAE/Brier 等统计口径。
|
||||
**概率引擎**:`trend_engine.py` 以集合预报的 p10/p90 推 σ(并考虑历史 MAE floor、风向/云量/压强的 shock_score、以及峰值窗口 time-decay),再用正态近似把连续分布映射为 WU 整数“温度桶概率”。
|
||||
**推理流水线(在线)**:
|
||||
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。
|
||||
**检查点(checkpoints)**:传统 ML checkpoint 不适用;但项目现已形成两类“业务状态 checkpoint”:
|
||||
(a)SQLite 运行态存储(当前线上与核心离线链路主路径);(b)SQLite 永久真值/训练特征表(当前监督真值与训练样本长期主存);(c)legacy JSON/JSONL 文件(主要保留给迁移回滚、导出比对与显式回退输入)。当前设计仍支持 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`,但对线上部署与离线训练/回填而言,推荐目标状态都已经是 `sqlite`。
|
||||
### 测试、CI/CD 与运维验证
|
||||
|
||||
**测试**:仓库存在 `tests/test_trend_engine.py`,覆盖 μ 计算、死盘判定、预报崩盘提示、趋势方向等核心逻辑(通过 patch 隔离外部依赖)。
|
||||
**CI/CD**:已补齐 GitHub Actions 工作流,至少覆盖 Python lint/test、前端 build、Docker build 三条门禁;当前缺口不再是“有没有 CI”,而是“是否已在 GitHub 分支保护中强制执行”。
|
||||
**运维验收**:除 `scripts/validate_frontend_cache.sh` 外,现已新增配置校验、运行态迁移/核验、EMOS rollout 判断等脚本,并提供 `/healthz`、`/api/system/status`、`/metrics` 作为基础观测入口。
|
||||
**部署/更新**:Compose 用于启动服务;另有 `update.sh` 通过 `pkill` + `nohup` 重启 bot 与 web。
|
||||
## 优势与薄弱点
|
||||
|
||||
### 优势
|
||||
|
||||
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。
|
||||
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。
|
||||
**面向外部 API 的工程防护意识较强**:Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
|
||||
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
|
||||
### 薄弱点与风险
|
||||
|
||||
**核心文件过大问题已明显缓解,但边界仍需继续稳定**:`WeatherDataCollector` 与 `web/app.py` 的超大文件问题已完成第一阶段拆分;当前风险已从“文件过大”转为“跨模块兼容与边界稳定性”,例如旧调用路径、兼容导出、跨层 helper 仍需持续清理。
|
||||
**可复现性已从“缺模板”进入“模板与生产对齐”的阶段**:`.env.example`、`.env.secrets.example`、中文配置文档、前端部署文档、运行时配置校验器都已存在;当前风险主要在于线上历史 `.env` 与新模板并存、旧变量命名残留、以及密钥轮换与分层是否真正落实。
|
||||
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
|
||||
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**:`daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。
|
||||
**历史真值治理已从设计缺陷修复到可追溯运行**:`daily_records` 继续作为近 14 天运行态缓存,但已经不再承担长期监督真值职责;项目新增了永久真值表、真值 revision 审计表和长期训练特征表,并为 `Taipei` / `Shenzhen` 补上了 `Wunderground` 历史回填链路。当前风险已不再是“监督真值会不会继续被 14 天裁剪吞掉”,而是“历史长期特征能否持续积累到足够支撑 EMOS/LGBM 重新评估”。
|
||||
**第三方服务合规与稳定性风险**:
|
||||
项目强依赖外部 API(Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。
|
||||
**可观测性最小闭环已完成,但监控深度仍待加强**:项目现在已有 `/healthz`、`/api/system/status`、`/metrics`,并已补齐 Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram relay 与巡检脚本。与此同时,`/ops` 已经逐步演进为后台管理台而不只是状态页:除支付、会员、用户与 EMOS 门禁外,还新增了训练数据治理卡片、城市覆盖矩阵,以及 `/ops/truth-history` 这种可直接查询 `actual_high / settlement_source / station_code / truth_version / updated_by / updated_at` 的真值表浏览页。当前缺口不再是“有没有外部监控”,而是节点级资源、数据库体积趋势、更细粒度支付指标、按城市/来源拆分的业务 SLA,以及是否需要进一步补 `truth revision` 明细页、趋势图和运营日报。
|
||||
**EMOS 已完成工程接入,但未完成生产发布**:EMOS/CRPS 校准、shadow 观测、rollout report、上线门禁都已实现;当前真实门禁结果为 `hold`,阻塞原因是 shadow bucket brier 明显退化。因此概率引擎标准化并非未做,而是“工程完成、发布未通过”。
|
||||
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部 AI 预报模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
|
||||
## 对标分析
|
||||
|
||||
为满足“至少 3 个相似开源项目或近期论文”对标,本报告选择三类代表:
|
||||
1)**AI 气象预报模型**(GraphCast / FourCastNet / Pangu-Weather):用于评估“若 PolyWeather 未来扩展到更强预测能力”的技术与许可边界;
|
||||
2)**概率后处理方法**(EMOS):作为 PolyWeather 概率引擎的更标准化替代/对照;
|
||||
3)**预测市场 API 客户端生态**(Polymarket/py-clob-client、aiopolymarket):用于评估市场层的工程选型。
|
||||
### 关键对比表
|
||||
|
||||
| 项目/论文 | 解决的问题 | 输出形态 | 性能/效果(公开描述) | 易用性与依赖 | 许可证要点 |
|
||||
| --------------------------------------------------------- | ---------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| **PolyWeather**(本仓库) | 温度结算市场气象情报:多源→概率桶→错价扫描→订阅/支付 | 生产级应用(Web+Bot+API+支付) | 以工程能力为主;内置 DEB、概率桶、死盘判定、市场扫描;当前覆盖 39 城市,并已补齐真值治理与后台运维视图。 | 主要依赖外部 API;Docker Compose 一键启动。 | 仓库 `AGPL-3.0-only`;品牌、生产私有数据与运营规则不随代码许可证授权。 |
|
||||
| **GraphCast**(google-deepmind/graphcast) | 10 天全球中期预报(ML 替代/增强 NWP) | 模型代码+权重+notebooks | 论文与介绍提到在大量指标上优于主流确定性系统;仓库提供预训练权重与示例数据入口,并提示 ERA5/HRES 数据条款需另行遵守。 | 完整训练需 ERA5 等;更适合科研/平台级推理,不是产品级 BFF。 | 代码 Apache-2.0;权重 CC BY-NC-SA 4.0(商业限制)。 |
|
||||
| **FourCastNet**(NVlabs/FourCastNet) | 高分辨率 data-driven 全球预报(AFNO/ViT) | 模型训练/推理代码+数据/权重链接 | README 描述:0.25° 分辨率、周尺度推理非常快,并可做大规模集合;适合平台型预报。 | 训练/数据依赖大(ERA5 子集 TB 级);工程集成成本高。 | BSD 3-Clause(代码)。 |
|
||||
| **Pangu-Weather**(198808xc/Pangu-Weather + Nature 论文) | 3D Transformer 架构的中期全球预报 | ONNX 推理代码+预训练模型 | Nature 论文称在 reanalysis 上对比 IFS 有更强确定性预报表现,并强调速度优势;仓库提供 ONNX 推理与 lite 版训练说明。 | 模型文件大(多份 ~GB 级),训练资源需求高;更适合科研推理或内部平台。 | 权重 BY-NC-SA 4.0、明确禁止商业用途。 |
|
||||
| **EMOS**(Gneiting & Raftery 等) | 集合预报校准:纠偏与解决 underdispersion | 统计后处理方法 | 提出用回归形式输出概率分布(常见为高斯),并以 CRPS 等指标拟合,属于成熟的气象概率校准路线。 | 易落地:对 PolyWeather 而言只需“历史库+拟合器”。 | 方法论(论文);可自行实现,无额外许可约束(注意论文版权)。 |
|
||||
| **Polymarket/py-clob-client** | Polymarket CLOB 读写 SDK | Python SDK | 官方 SDK,支持 read-only 与交易接口;协议与端点在官方文档中给出。 | 易用,适合增强 PolyWeather 市场层。 | MIT。 |
|
||||
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | 强调类型安全(Pydantic)、自动分页、重试与 backoff,适合高并发与健壮性诉求。 | 适合替换/补强当前同步 requests 与自定义缓存。 | 以仓库许可为准(此处建议上线前核验)。 |
|
||||
|
||||
**对标结论**:PolyWeather 与这类“全球 AI 预报模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
|
||||
## 优先级改进建议
|
||||
|
||||
下表按截至 `2026-04-03` 的真实状态重排优先级。已完成项不再继续列为“待做”,只保留当前仍需推进的事项。
|
||||
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 高 | **稳定 EMOS shadow 并收紧上线门禁** | 1–2 周 | 让概率引擎升级具备明确发布条件,避免拍脑袋切换 | 当前 shadow bucket brier 退化明显,存在误上线风险 | 1) 持续积累 snapshot 样本 → 2) 定期重训与生成 `evaluation_report` / `shadow_report` / `rollout_report` → 3) 重点压 `bucket_brier` 退化 → 4) 只有门禁从 `hold` 进入 `observe/promote` 后才考虑上线 |
|
||||
| 高 | **持续积累长期训练特征,验证 SQLite 真值治理后的样本增长** | 1–2 周 | 让 EMOS/LGBM 的重训真正建立在长期可信样本上,而不是继续被短期特征缺口卡住 | 当前真值已长期化,但历史长期特征仍偏少,EMOS/LGBM 样本增长会滞后 | 1) 持续写入 `training_feature_records_store` → 2) 每日检查 `/ops` 训练数据与 `/ops/truth-history` → 3) 定期对 `Taipei` / `Shenzhen` 的 Wunderground 回填做抽查 → 4) 观察样本是否自然增长后再重训 |
|
||||
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 |
|
||||
| 中 | **市场层升级为 async + 类型安全**:引入 `aiopolymarket` 或在现有层加重试/backoff/连接池 | 4–7 天 | 行情层更稳,减少短时网络抖动;更易扩展更多市场/分页 | 依赖升级带来的行为差异 | 1) 把 requests.Session 替换为 aiohttp/httpx → 2) 在 Gamma/CLOB 调用侧实现指数退避 → 3) 引入 typed models,减少解析失败 |
|
||||
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 |
|
||||
| 中 | **将 CI 与分支保护/发布流程真正绑定** | 1–3 天 | 让现有 CI 从“存在”变成“强制门禁” | 历史分支/热修流程可能受影响 | 1) GitHub `main` 开启 required checks → 2) 把 release/tag 流程绑定 CI → 3) 明确热修例外流程 |
|
||||
| 低 | **引入外部 AI 预报模型作为附加信号**(GraphCast/FourCastNet/Pangu-Weather 等) | 2–6 周(取决于范围) | 可能提升极端/中期预测能力与差异化 | **商业许可限制**(多为 CC BY-NC-SA/禁止商业)与算力成本 | 1) 先做合规评审(权重许可/数据条款)→ 2) 仅在研究/非商业环境评估 → 3) 若要商用,优先选择可商用权重或自研/购买授权 |
|
||||
|
||||
### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
|
||||
|
||||
1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档:
|
||||
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级;(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、市场扫描 miss、前端缓存异常等典型故障处理。
|
||||
2)**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐:
|
||||
(a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
|
||||
3)**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
|
||||
## 建议实验与基准
|
||||
|
||||
PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报所有变量。建议建立两类基准:**气象预测基准(结算导向)**与**市场信号基准(交易导向)**。
|
||||
### 气象预测与概率校准基准
|
||||
|
||||
**数据集**(建议从现有生产数据演进)
|
||||
1)`truth_records_store + training_feature_records_store` 的长期样本:当前长期评测主源应优先来自永久真值表与长期训练特征表;legacy 的 `daily_records.json` 与 `settlement_history.json` 更适合作为迁移恢复与对照来源,而不是长期主输入。
|
||||
2)观测“真值”统一口径:对 METAR 城市用 AviationWeather Data API;对香港按 HKO、对台北/深圳按 `Wunderground RCSS/ZGSZ` 等结算源作为真值,和项目当前逻辑一致。
|
||||
**指标**
|
||||
1)确定性误差:MAE、RMSE(按城市、按季节、按风险等级分组);
|
||||
2)结算命中率:`WU_round(pred) == WU_round(actual)`(项目已有统计口径);
|
||||
3)概率质量:Brier Score(对离散温度桶),以及建议补充 CRPS(连续变量概率评分,EMOS 体系常用)。
|
||||
4)校准曲线:预测概率分箱的可靠性图(reliability diagram)与 Sharpness(分布集中度)。
|
||||
**基线**
|
||||
|
||||
- Baseline A:Open-Meteo 当日最高温(或 forecast median)作为点预测;
|
||||
- Baseline B:等权平均(DEB 在历史少时也会回退此策略);
|
||||
- Baseline C:当前 DEB;
|
||||
- Baseline D:EMOS(以 ensemble 均值/方差为输入,拟合 μ 与 σ,优化 CRPS)。
|
||||
**预期结果(定性)**
|
||||
|
||||
- 若历史样本足够,DEB 应在“系统性偏差明显”的城市提升 MAE;
|
||||
- EMOS 类方法通常能在概率校准(可靠性与 CRPS)上更稳定,尤其当 ensemble 信息可用(项目已接入 Open-Meteo ensemble/p10/p90)。
|
||||
**算力**:以上评测全部可在 CPU 上完成;数据量按“39 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
|
||||
### 错价信号与市场有效性基准
|
||||
|
||||
**数据集**
|
||||
|
||||
- 保存每次扫描输出:`date/city/bucket/model_prob/market_price/liquidity/edge`,并加上未来 `settled_bucket` 作为标签;Polymarket 市场发现与报价来自 Gamma/CLOB(官方文档说明三套 API:Gamma/Data/CLOB)。
|
||||
**指标**
|
||||
|
||||
- Signal 覆盖率:能否找到正确 market / bucket;
|
||||
- Edge 稳健性:不同流动性分位的 edge 分布;
|
||||
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益(即使项目当前只读,也可以离线评估“若执行”会怎样)。
|
||||
**基线**
|
||||
|
||||
- 简单策略:仅用市场中间价(不做模型)作为概率;
|
||||
- 当前策略:模型概率 vs 市场概率 edge 阈值;
|
||||
- 改进策略:引入“流动性/盘口深度/波动”作为信号置信度(aiopolymarket/py-clob-client 提供更完整的盘口读取能力)。
|
||||
**算力**:CPU 即可;关键在于数据采样与回放。
|
||||
## 路线图与风险缓解
|
||||
|
||||
下面给出一个**12 周**(约 3 个月)的建议路线图,按“可稳定交付的工程里程碑”组织;人力以“1 名后端/数据工程 + 1 名前端(可兼职)+ 0.5 名链上工程(按需)”估算。
|
||||
| 时间窗 | 里程碑 | 交付物 | 资源/备注 |
|
||||
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| 第 1–2 周 | 工程地基:CI + 规范 + 配置可复现 | GitHub Actions;ruff/eslint;pytest 可一键跑;`.env.example`;敏感项分级说明(尤其 Supabase service role key 不可暴露)。 | 后端为主;前端补 eslint/typecheck |
|
||||
| 第 3–5 周 | 核心模块解耦:采集 Provider 化 + API 分层 | provider 接口与实现;`web/app.py` 拆分路由与服务;核心 schema(Pydantic) | 风险:行为漂移;用回放测试压住 |
|
||||
| 第 6–8 周 | 状态/缓存统一 + 可观测性 | `daily_records/open_meteo_cache` 主读 SQLite;离线脚本也切到 SQLite 优先;永久真值表 / revision / 长期训练特征表落地;指标(请求量/429/延迟/命中率);Prometheus/Alertmanager/Grafana 最小链路与告警阈值 | 可先用 SQLite/Redis,后续再上 Postgres |
|
||||
| 第 9–10 周 | 评测体系上线 | 离线评测脚本(MAE/RMSE/WU-hit/Brier/CRPS);日报/周报自动生成 | 直接基于项目现有字段扩展 |
|
||||
| 第 11–12 周 | 概率引擎升级(可选)+ 市场层健壮性增强 | EMOS/CRPS 拟合的 shadow 输出;Gamma/CLOB 客户端增强(async、重试、分页) | 以“小步可回滚”为原则,避免一次性替换 |
|
||||
|
||||
### 主要风险与缓解策略
|
||||
|
||||
**外部 API 速率限制/格式变更**:AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用)。
|
||||
**密钥泄露与权限滥用**:Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
|
||||
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。
|
||||
**引入外部 AI 预报模型的商业合规风险**:GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
|
||||
**代码公开与生产私有资产边界导致的“公开仓库与生产行为不一致”**:README 明确品牌、商标、生产私有数据与运营阈值不在代码许可证授权范围内。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
|
||||
## 参考链接
|
||||
|
||||
- PolyWeather 仓库(本次评估对象):https://github.com/yangyuan-zhen/PolyWeather
|
||||
- Polymarket API 文档(Gamma/Data/CLOB):https://docs.polymarket.com/api-reference
|
||||
- AviationWeather Data API(METAR 等):https://aviationweather.gov/data/api/
|
||||
- Open-Meteo Docs(Forecast):https://open-meteo.com/en/docs
|
||||
- Open-Meteo Docs(Ensemble):https://open-meteo.com/en/docs/ensemble-api
|
||||
- Supabase REST API:https://supabase.com/docs/guides/api
|
||||
- Supabase API keys(service_role 风险):https://supabase.com/docs/guides/api/api-keys
|
||||
- GraphCast(代码 Apache-2.0;权重 CC BY-NC-SA):https://github.com/google-deepmind/graphcast
|
||||
- FourCastNet(BSD-3):https://github.com/NVlabs/FourCastNet
|
||||
- Pangu-Weather(权重 BY-NC-SA,禁商用):https://github.com/198808xc/Pangu-Weather
|
||||
- Polymarket 官方 Python CLOB SDK(MIT):https://github.com/Polymarket/py-clob-client
|
||||
- aiopolymarket(async、类型安全):https://github.com/the-odds-company/aiopolymarket
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 507 KiB After Width: | Height: | Size: 947 KiB |
@@ -0,0 +1,244 @@
|
||||
# PolyWeather 支付审计与防护说明
|
||||
|
||||
最后更新:`2026-03-21`
|
||||
|
||||
## 1. 当前已落地的防护
|
||||
|
||||
### 链下运行态
|
||||
|
||||
- 支付事件扫描与确认循环已把运行态写入 SQLite:
|
||||
- `payment_runtime_state`
|
||||
- `payment_audit_events`
|
||||
- 关键循环现在会记录:
|
||||
- `event_loop_started`
|
||||
- `event_loop_cycle`
|
||||
- `event_loop_error`
|
||||
- `confirm_loop_started`
|
||||
- `confirm_loop_cycle`
|
||||
- `confirm_loop_error`
|
||||
|
||||
### 事件确认边界
|
||||
|
||||
- 后端只认链上 `OrderPaid` 事件。
|
||||
- 前端提交 intent 不会直接视为支付完成。
|
||||
- `confirm_loop` 会再次按链上交易与确认数校验 intent。
|
||||
- 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted`。
|
||||
|
||||
当前已显式识别的失败原因包括:
|
||||
|
||||
- `receiver_mismatch`
|
||||
- `sender_mismatch`
|
||||
- `event_mismatch`
|
||||
- `tx_reverted`
|
||||
|
||||
### RPC 多节点容灾
|
||||
|
||||
- 支持 `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- 格式示例:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
|
||||
```
|
||||
|
||||
- 启动时按顺序探活。
|
||||
- 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。
|
||||
|
||||
### 事件重放
|
||||
|
||||
- 已提供脚本:
|
||||
- [replay_payment_events.py](/E:/web/PolyWeather/scripts/replay_payment_events.py)
|
||||
|
||||
用途:
|
||||
- 审计某个区块范围内的 `OrderPaid`
|
||||
- 事后补查漏单
|
||||
- 排查 RPC 抖动导致的监听遗漏
|
||||
|
||||
命令示例:
|
||||
|
||||
```bash
|
||||
python scripts/replay_payment_events.py --from-block 10000000 --to-block 10001000
|
||||
```
|
||||
|
||||
### 运行态检查
|
||||
|
||||
- 已提供接口:
|
||||
- `GET /api/payments/runtime`
|
||||
|
||||
可查看:
|
||||
- checkout 配置摘要
|
||||
- 当前活跃 RPC
|
||||
- 候选 RPC 列表
|
||||
- event loop 最新状态
|
||||
- 最近审计事件
|
||||
|
||||
### Ops 事故单
|
||||
|
||||
现在 `/ops` 已提供单独的支付异常单列表,默认展示:
|
||||
|
||||
- `payment_intent_failed`
|
||||
|
||||
支持:
|
||||
|
||||
- 按 `reason` 过滤
|
||||
- 标记已处理
|
||||
|
||||
这让下面这类事故不再需要翻日志定位:
|
||||
|
||||
- 已付款但未开通
|
||||
- 打到旧收款地址
|
||||
- 交易事件不匹配
|
||||
|
||||
## 2. 当前合约的授权边界
|
||||
|
||||
合约源码:
|
||||
- [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol)
|
||||
|
||||
当前边界:
|
||||
|
||||
1. `owner`
|
||||
- 可执行:
|
||||
- `setTreasury`
|
||||
- `setTokenAllowed`
|
||||
|
||||
2. 普通用户
|
||||
- 只能调用:
|
||||
- `pay(orderId, planId, amount, token)`
|
||||
|
||||
3. 代币边界
|
||||
- 只有 `allowedToken[token] == true` 的 token 可支付
|
||||
|
||||
4. 订单边界
|
||||
- 同一个 `orderId` 只能成功支付一次
|
||||
|
||||
## 3. 重入与重复支付判断
|
||||
|
||||
当前合约的 `pay` 逻辑顺序是:
|
||||
|
||||
1. 检查 token allowlist
|
||||
2. 检查 `amount > 0`
|
||||
3. 检查 `paidOrder[orderId] == false`
|
||||
4. 先写入 `paidOrder[orderId] = true`
|
||||
5. 再执行 `transferFrom`
|
||||
6. 发出 `OrderPaid`
|
||||
|
||||
这意味着:
|
||||
|
||||
- 同一 `orderId` 的重复支付会被拦住
|
||||
- 典型“转账外部调用后再回调重复执行同订单”的路径会被 `paidOrder` 状态挡住
|
||||
|
||||
但要注意:
|
||||
|
||||
- 当前合约没有 `Pausable`
|
||||
- 当前合约没有 `SafeERC20`
|
||||
- 当前合约没有在链上校验 `planId -> amount`
|
||||
|
||||
所以它属于:
|
||||
- **最小可用支付合约**
|
||||
- 不是“全功能强防护合约”
|
||||
|
||||
## 4. 当前静态审计结论
|
||||
|
||||
已提供脚本:
|
||||
- [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py)
|
||||
|
||||
命令:
|
||||
|
||||
```bash
|
||||
python scripts/check_payment_contract_security.py
|
||||
```
|
||||
|
||||
输出会检查这些项目:
|
||||
|
||||
- 是否有 `onlyOwner`
|
||||
- `setTreasury` / `setTokenAllowed` 是否受 owner 保护
|
||||
- constructor / setter 是否检查零地址
|
||||
- 是否校验 allowlist
|
||||
- 是否校验 `amount > 0`
|
||||
- 是否校验重复订单
|
||||
- 是否在 `transferFrom` 前写入 `paidOrder`
|
||||
- 是否有 pause 开关
|
||||
- 是否使用 SafeERC20
|
||||
- 是否在链上绑定套餐价格
|
||||
|
||||
## 5. 当前主要剩余风险
|
||||
|
||||
1. 单地址 owner
|
||||
- 建议把 `owner` 迁移到多签钱包
|
||||
|
||||
2. 无暂停开关
|
||||
- 发现紧急问题时,无法直接暂停 `pay`
|
||||
|
||||
3. 金额校验主要在链下
|
||||
- 当前 `planId / amount / token` 绑定主要靠后端 intent 和确认逻辑
|
||||
|
||||
4. ERC20 兼容性假设
|
||||
- 当前使用 `IERC20.transferFrom`
|
||||
- 升级版合约更建议改为 OpenZeppelin `SafeERC20`
|
||||
|
||||
## 6. 推荐操作
|
||||
|
||||
### 每次支付配置变更后
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
python scripts/check_payment_contract_security.py
|
||||
python scripts/replay_payment_events.py --from-block <from> --to-block <to>
|
||||
```
|
||||
|
||||
### 线上巡检
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/api/payments/runtime
|
||||
```
|
||||
|
||||
重点看:
|
||||
|
||||
- `rpc.active_rpc_url`
|
||||
- `rpc.configured_rpc_count`
|
||||
- `event_loop_state.last_scanned_block`
|
||||
- `recent_audit_events`
|
||||
|
||||
如果你在 `/ops` 或脚本里看到:
|
||||
|
||||
- `receiver_mismatch`
|
||||
|
||||
其含义通常不是“缓存没刷新”,而是:
|
||||
|
||||
- 用户这笔交易的 `to` 地址不是当前生产收款合约
|
||||
- 常见原因是旧页面、旧 deployment、旧钱包会话,或历史收款地址仍被命中
|
||||
|
||||
此时应优先做:
|
||||
|
||||
1. 确认链上真实 `to` 地址
|
||||
2. 确认当前 `/api/payments/config` 返回的 `receiver_contract`
|
||||
3. 如确已收款,再走人工恢复或补开订阅
|
||||
|
||||
### 按邮箱恢复最近支付
|
||||
|
||||
已提供脚本:
|
||||
|
||||
- [reconcile_subscription_by_email.py](/E:/web/PolyWeather/scripts/reconcile_subscription_by_email.py)
|
||||
|
||||
命令:
|
||||
|
||||
```bash
|
||||
docker compose exec polyweather_web python scripts/reconcile_subscription_by_email.py --email user@example.com
|
||||
```
|
||||
|
||||
适用场景:
|
||||
|
||||
- 用户声称已付费但未开通
|
||||
- 需要快速确认最近一笔 intent 是否能自动恢复
|
||||
|
||||
## 7. 下一版合约建议
|
||||
|
||||
如果后续升级合约,优先级建议:
|
||||
|
||||
1. `Ownable` -> 多签 owner
|
||||
2. `SafeERC20`
|
||||
3. `Pausable`
|
||||
4. 链上 plan/amount/token 绑定
|
||||
5. 必要时增加 rescue/sweep 能力
|
||||
@@ -0,0 +1,197 @@
|
||||
# PolyWeather 支付合约升级方案(V2)
|
||||
|
||||
最后更新:`2026-03-20`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本次 V2 方案对应三个明确目标:
|
||||
|
||||
1. 把 `owner` 迁到多签地址
|
||||
2. 升级到 `SafeERC20 + Pausable + ReentrancyGuard`
|
||||
3. 把“链上 plan 绑定”和“EIP-712 授权支付”都纳入设计,而不是只在链下校验
|
||||
|
||||
合约草案:
|
||||
- [PolyWeatherCheckoutV2.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckoutV2.sol)
|
||||
|
||||
构造参数编码脚本:
|
||||
- [encode_checkout_v2_constructor.py](/E:/web/PolyWeather/scripts/encode_checkout_v2_constructor.py)
|
||||
|
||||
## 2. V2 新增能力
|
||||
|
||||
### 多签 owner
|
||||
|
||||
V2 constructor 不再默认 `msg.sender` 作为唯一 owner,而是显式传入:
|
||||
|
||||
- `initialOwner`
|
||||
- `initialTreasury`
|
||||
- `initialSigner`
|
||||
|
||||
这意味着:
|
||||
- 部署后可直接把多签地址设为 `owner`
|
||||
- 不需要先单签部署再补 transfer
|
||||
|
||||
### SafeERC20
|
||||
|
||||
V2 内置最小 `SafeERC20` 封装:
|
||||
|
||||
- `safeTransferFrom`
|
||||
- `safeTransfer`
|
||||
|
||||
相比直接依赖 `IERC20.transferFrom -> bool`:
|
||||
- 对非标准 ERC20 的兼容性更稳
|
||||
- 出错边界更明确
|
||||
|
||||
### Pausable
|
||||
|
||||
V2 增加:
|
||||
|
||||
- `pause()`
|
||||
- `unpause()`
|
||||
|
||||
支付入口:
|
||||
|
||||
- `payPlan(...)`
|
||||
- `payAuthorized(...)`
|
||||
|
||||
都受 `whenNotPaused` 保护。
|
||||
|
||||
一旦发现:
|
||||
- treasury 配置错误
|
||||
- token allowlist 配置错误
|
||||
- 签名器异常
|
||||
- 链上风控问题
|
||||
|
||||
可以直接暂停支付入口。
|
||||
|
||||
### ReentrancyGuard
|
||||
|
||||
V2 增加 `nonReentrant`,保护:
|
||||
|
||||
- `payPlan`
|
||||
- `payAuthorized`
|
||||
- `rescueToken`
|
||||
|
||||
虽然当前订单去重已经能挡住典型重复支付路径,但 `ReentrancyGuard` 仍然是更稳的防线。
|
||||
|
||||
### 链上套餐绑定
|
||||
|
||||
V2 新增:
|
||||
|
||||
- `setPlan(planId, token, amount, active)`
|
||||
- `planConfig[planId][token]`
|
||||
|
||||
正式支付入口 `payPlan` 会:
|
||||
|
||||
1. 校验 token 已 allowed
|
||||
2. 校验 `planId + token` 的 plan 已 active
|
||||
3. 从链上读取 amount
|
||||
4. 按链上配置收款
|
||||
|
||||
这意味着:
|
||||
- `planId / amount / token` 绑定不再完全依赖链下
|
||||
|
||||
### EIP-712 授权支付
|
||||
|
||||
V2 同时保留第二条入口:
|
||||
|
||||
- `payAuthorized(...)`
|
||||
|
||||
它适合:
|
||||
- 临时折扣
|
||||
- 特殊活动价
|
||||
- 不想每次都上链改 `setPlan`
|
||||
|
||||
校验字段包括:
|
||||
|
||||
- `orderId`
|
||||
- `payer`
|
||||
- `planId`
|
||||
- `token`
|
||||
- `amount`
|
||||
- `nonce`
|
||||
- `deadline`
|
||||
|
||||
签名人地址由:
|
||||
|
||||
- `signer`
|
||||
|
||||
统一控制。
|
||||
|
||||
## 3. 两条支付路径怎么选
|
||||
|
||||
### 路线 A:链上套餐绑定优先
|
||||
|
||||
优点:
|
||||
- 最直观
|
||||
- 合约级约束最强
|
||||
- 更容易审计
|
||||
|
||||
缺点:
|
||||
- 套餐改价需要 owner 交易
|
||||
|
||||
适合:
|
||||
- 月付/季付/年付这类稳定商品
|
||||
|
||||
### 路线 B:EIP-712 授权优先
|
||||
|
||||
优点:
|
||||
- 活动价灵活
|
||||
- 不必每次改链上 plan
|
||||
|
||||
缺点:
|
||||
- 需要管理 signer 密钥
|
||||
- 风险从 owner 单点,部分转移到 signer 运维
|
||||
|
||||
适合:
|
||||
- 促销
|
||||
- 临时折扣
|
||||
- 白名单价格
|
||||
|
||||
### 当前建议
|
||||
|
||||
生产建议不是二选一,而是:
|
||||
|
||||
1. **稳定套餐** 走 `payPlan`
|
||||
2. **特殊场景** 走 `payAuthorized`
|
||||
|
||||
这样:
|
||||
- 主流程更稳
|
||||
- 特殊价仍保留灵活性
|
||||
|
||||
## 4. 推荐迁移步骤
|
||||
|
||||
1. 先部署 V2 到测试环境
|
||||
2. `owner` 直接用多签地址
|
||||
3. 配置 `treasury`
|
||||
4. 配置 `allowedToken`
|
||||
5. 配置 `planId/token/amount`
|
||||
6. 仅在需要活动价时再配置 `signer`
|
||||
7. 用事件重放脚本和运行态接口验证
|
||||
8. 再切生产前端/后端配置到新 `receiver_contract`
|
||||
|
||||
## 5. 构造参数编码
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
python scripts/encode_checkout_v2_constructor.py \
|
||||
--owner 0xYourMultiSig \
|
||||
--treasury 0xYourTreasury \
|
||||
--signer 0xYourBackendSigner
|
||||
```
|
||||
|
||||
## 6. 当前判断
|
||||
|
||||
V2 已经把这三件事做成了明确方案:
|
||||
|
||||
1. 多签 owner
|
||||
2. SafeERC20 + Pausable + ReentrancyGuard
|
||||
3. 链上 plan 绑定 + EIP-712 授权
|
||||
|
||||
它现在是**升级草案**,不是现网已部署合约。
|
||||
|
||||
如果要真正上线,下一步就是:
|
||||
|
||||
1. 做一次测试网或本地链验证
|
||||
2. 更新 PolygonScan 验证文档
|
||||
3. 修改后端 `receiver_contract` 配置
|
||||
@@ -0,0 +1,76 @@
|
||||
# PolyWeatherCheckout PolygonScan 验证(v1.5.1)
|
||||
|
||||
最后更新:`2026-03-20`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
对生产收款合约完成源码验证,降低钱包风控误报并提升用户信任。
|
||||
|
||||
当前说明:
|
||||
|
||||
- **现网合约仍为 V1**:`contracts/PolyWeatherCheckout.sol`
|
||||
- **V2 只是升级草案**:`contracts/PolyWeatherCheckoutV2.sol`
|
||||
- 当前 PolygonScan 验证流程默认针对 V1
|
||||
|
||||
## 2. 当前部署参数(示例)
|
||||
|
||||
- 链:Polygon Mainnet(`chainId=137`)
|
||||
- 合约:`PolyWeatherCheckout`
|
||||
- 编译器:`v0.8.24+commit.e11b9ed9`
|
||||
- 优化器:`Enabled`,`runs=200`
|
||||
|
||||
> 实际地址以线上配置为准:`POLYWEATHER_PAYMENT_RECEIVER_CONTRACT`。
|
||||
|
||||
## 3. 构造参数编码
|
||||
|
||||
使用仓库脚本生成构造参数:
|
||||
|
||||
```bash
|
||||
python scripts/encode_checkout_constructor.py \
|
||||
--token 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 \
|
||||
--treasury 0xe581D578EF101c80e3F32263e97E6eA28A0B170e
|
||||
```
|
||||
|
||||
将输出填入 PolygonScan 的 `Constructor Arguments ABI-encoded`。
|
||||
|
||||
## 4. PolygonScan 操作步骤
|
||||
|
||||
1. 打开合约页 -> `Contract` -> `Verify and Publish`。
|
||||
2. 选择 `Solidity (Single file)`。
|
||||
3. 粘贴 `contracts/PolyWeatherCheckout.sol` 源码。
|
||||
4. 填写编译器/优化器参数。
|
||||
5. 粘贴构造参数并提交。
|
||||
|
||||
## 5. 验证后检查
|
||||
|
||||
- `Read Contract`:可见 `owner / treasury / allowedToken / paidOrder`
|
||||
- `Write Contract`:可见 `pay / setTreasury / setTokenAllowed`
|
||||
- 标签显示 `Contract Source Code Verified`
|
||||
|
||||
## 6. 双币种开启(USDC + USDC.e)
|
||||
|
||||
验证后可通过 `setTokenAllowed` 开启两种代币:
|
||||
|
||||
- USDC.e: `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`
|
||||
- Native USDC: `0x3c499c542cef5e3811e1192ce70d8cc03d5c3359`
|
||||
|
||||
## 7. V2 说明(尚未部署)
|
||||
|
||||
如果后续升级到 V2,请改用:
|
||||
|
||||
```bash
|
||||
python scripts/encode_checkout_v2_constructor.py \
|
||||
--owner 0xYourMultiSig \
|
||||
--treasury 0xYourTreasury \
|
||||
--signer 0xYourBackendSigner
|
||||
```
|
||||
|
||||
V2 相关文档:
|
||||
|
||||
- [PAYMENT_UPGRADE_V2_ZH.md](/E:/web/PolyWeather/docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- [PAYMENT_AUDIT_ZH.md](/E:/web/PolyWeather/docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
|
||||
## 8. 说明
|
||||
|
||||
- 源码验证能显著降低“欺诈/不可信”误报,但钱包风险缓存更新存在延迟。
|
||||
- 生产商用环境可使用私有升级版合约;公开仓库保留标准实现与验证流程。
|
||||
@@ -0,0 +1,55 @@
|
||||
# PolyWeather Side Panel
|
||||
|
||||
`PolyWeather Side Panel` 是一个面向天气交易场景的 Chrome / Edge 浏览器侧边栏工具。
|
||||
|
||||
## 功能
|
||||
|
||||
1. 自动识别当前 Polymarket 页面中的城市,也支持手动切换。
|
||||
2. 展示城市档案:结算站点、站点距离、观测更新时间、周边站点数量。
|
||||
3. 展示今日日内走势(简版):`DEB` 走势与官方观测(`METAR / HKO / CWA / NOAA`)对照,可悬停查看时间与温度。
|
||||
4. 展示多日最高温预报(简版),当前以 `DEB` 优先。
|
||||
5. 支持一键刷新,强制拉取最新温度数据。
|
||||
6. 支持本地缓存,提升打开速度;刷新时自动更新缓存。
|
||||
7. 支持一键跳转到完整网站分析页面。
|
||||
|
||||
## 数据说明
|
||||
|
||||
- 香港使用 `HKO`(香港天文台)结算源。
|
||||
- 其他城市按配置使用 `METAR / NOAA / 官方数据源`。
|
||||
- 城市展示名以主站返回值为准,例如 `aurora` 市场在插件中会显示为 `Denver`。
|
||||
|
||||
## 权限说明
|
||||
|
||||
- `tabs`:用于识别当前活动标签页 URL 并自动匹配城市。
|
||||
- `storage`:用于保存插件配置与本地缓存,仅存储在本地浏览器。
|
||||
- `sidePanel`:用于在浏览器侧边栏展示界面。
|
||||
|
||||
## 隐私说明
|
||||
|
||||
本扩展不要求用户登录,不收集个人身份信息,不上传浏览历史,仅在必要时请求天气接口数据以完成展示功能。
|
||||
|
||||
## 本地安装(开发者模式)
|
||||
|
||||
1. 打开 Chrome/Edge 扩展页面:
|
||||
- Chrome:`chrome://extensions`
|
||||
- Edge:`edge://extensions`
|
||||
2. 打开“开发者模式”。
|
||||
3. 选择“加载已解压的扩展程序”。
|
||||
4. 选择目录:`extension/`。
|
||||
5. 点击扩展图标,侧边栏会打开。
|
||||
|
||||
## 设置
|
||||
|
||||
首次建议打开扩展“选项页”并确认:
|
||||
|
||||
- `网站基础地址`:你的前端域名(例如 `https://polyweather-pro.vercel.app`)
|
||||
- `API 基础地址`:你的后端 API 域名(若同域也可填前端域名)
|
||||
- `Bearer Token`:后端开启鉴权时填写
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前版本仍是轻量产品,重点是“监控 + 基础判断 + 导流回站”,未接入支付链路。
|
||||
- 若你的 API 做了严格鉴权,请先在设置页填写 token 再使用。
|
||||
- 插件走势图与主站保持一致:`Wunderground` 结算城市不再单独绘制结算参考线,统一显示机场 `METAR` / 官方观测点位。
|
||||
- 点击“打开网站查看更多”会回到主站继续查看完整分析。
|
||||
- 插件不会承载完整分析;完整结构判断、历史对账和更多信号仍以主站为准。
|
||||
@@ -0,0 +1,6 @@
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.sidePanel
|
||||
.setPanelBehavior({ openPanelOnActionClick: true })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 826 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 87 KiB |
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "PolyWeather Side Panel",
|
||||
"description": "Weather side panel for Polymarket.",
|
||||
"version": "0.1.9",
|
||||
"icons": {
|
||||
"16": "icon-16.png",
|
||||
"32": "icon-32.png",
|
||||
"48": "icon-48.png",
|
||||
"128": "icon-128.png"
|
||||
},
|
||||
"permissions": ["sidePanel", "storage", "tabs"],
|
||||
"host_permissions": ["https://*/*", "http://*/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_icon": {
|
||||
"16": "icon-16.png",
|
||||
"32": "icon-32.png",
|
||||
"48": "icon-48.png"
|
||||
},
|
||||
"default_title": "Open PolyWeather"
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidepanel.html"
|
||||
},
|
||||
"options_page": "options.html"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0b1225;
|
||||
color: #e5eefb;
|
||||
font-family: "Inter", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 840px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 18px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
color: #9fb0c9;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
background: #121c38;
|
||||
color: #f1f5ff;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid rgba(34, 211, 238, 0.4);
|
||||
background: rgba(34, 211, 238, 0.12);
|
||||
color: #ccf7ff;
|
||||
border-radius: 9px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
background: rgba(99, 102, 241, 0.14);
|
||||
color: #dbe4ff;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
min-height: 56px;
|
||||
color: #9fb0c9;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PolyWeather Extension Settings</title>
|
||||
<link rel="stylesheet" href="./options.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<h1 id="settingsTitle">PolyWeather 侧边栏设置</h1>
|
||||
|
||||
<label class="field">
|
||||
<span id="siteBaseLabel">Site Base URL</span>
|
||||
<input id="siteBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span id="apiBaseLabel">API Base URL</span>
|
||||
<input id="apiBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span id="tokenLabel">Bearer Token(可选)</span>
|
||||
<textarea
|
||||
id="tokenInput"
|
||||
rows="3"
|
||||
placeholder="公开模式留空即可;仅当后端返回 401 时再填写。"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button id="saveBtn">保存</button>
|
||||
<button id="testBtn" class="ghost">测试 /api/cities</button>
|
||||
</div>
|
||||
|
||||
<pre id="resultBox" class="result"></pre>
|
||||
</main>
|
||||
|
||||
<script src="./options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
const DEFAULT_CONFIG = {
|
||||
apiBase: "https://polyweather-pro.vercel.app",
|
||||
siteBase: "https://polyweather-pro.vercel.app",
|
||||
authToken: "",
|
||||
selectedCity: ""
|
||||
};
|
||||
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
|
||||
? "zh"
|
||||
: "en";
|
||||
const I18N = {
|
||||
zh: {
|
||||
settingsTitle: "PolyWeather 侧边栏设置",
|
||||
tokenLabel: "Bearer Token(可选)",
|
||||
tokenPlaceholder: "公开模式留空即可;仅当后端返回 401 时再填写。",
|
||||
save: "保存",
|
||||
test: "测试 /api/cities",
|
||||
saved: "已保存。公开模式下 Token 可留空。",
|
||||
connectOk: "连接成功,返回城市数",
|
||||
tokenOptional: "Token 可留空",
|
||||
testFailed: "测试失败",
|
||||
backendAuthHint: "说明后端仍要求鉴权;若你要公开插件,请先放开 /api/cities 与 /api/city/*/detail。"
|
||||
},
|
||||
en: {
|
||||
settingsTitle: "PolyWeather Side Panel Settings",
|
||||
tokenLabel: "Bearer Token (Optional)",
|
||||
tokenPlaceholder: "Leave empty in public mode; only fill it if the backend returns 401.",
|
||||
save: "Save",
|
||||
test: "Test /api/cities",
|
||||
saved: "Saved. Token can be empty in public mode.",
|
||||
connectOk: "Connected successfully, city count",
|
||||
tokenOptional: "Token can be empty",
|
||||
testFailed: "Test failed",
|
||||
backendAuthHint: "The backend still requires auth. If the extension should be public, allow /api/cities and /api/city/*/detail."
|
||||
}
|
||||
};
|
||||
|
||||
function t(key) {
|
||||
return I18N[locale][key] || I18N.zh[key] || key;
|
||||
}
|
||||
|
||||
const apiBaseInput = document.getElementById("apiBaseInput");
|
||||
const siteBaseInput = document.getElementById("siteBaseInput");
|
||||
const tokenInput = document.getElementById("tokenInput");
|
||||
const resultBox = document.getElementById("resultBox");
|
||||
const settingsTitle = document.getElementById("settingsTitle");
|
||||
const siteBaseLabel = document.getElementById("siteBaseLabel");
|
||||
const apiBaseLabel = document.getElementById("apiBaseLabel");
|
||||
const tokenLabel = document.getElementById("tokenLabel");
|
||||
const saveBtn = document.getElementById("saveBtn");
|
||||
const testBtn = document.getElementById("testBtn");
|
||||
|
||||
function normalizeBase(url) {
|
||||
return String(url || "").trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function writeResult(text) {
|
||||
resultBox.textContent = text;
|
||||
}
|
||||
|
||||
function getStorage() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.sync.get(DEFAULT_CONFIG, (items) => resolve(items));
|
||||
});
|
||||
}
|
||||
|
||||
function setStorage(values) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.sync.set(values, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadForm() {
|
||||
const cfg = await getStorage();
|
||||
apiBaseInput.value = cfg.apiBase || DEFAULT_CONFIG.apiBase;
|
||||
siteBaseInput.value = cfg.siteBase || cfg.apiBase || DEFAULT_CONFIG.siteBase;
|
||||
tokenInput.value = cfg.authToken || "";
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
const next = {
|
||||
apiBase: normalizeBase(apiBaseInput.value),
|
||||
siteBase: normalizeBase(siteBaseInput.value || apiBaseInput.value),
|
||||
authToken: String(tokenInput.value || "").trim()
|
||||
};
|
||||
await setStorage(next);
|
||||
writeResult(t("saved"));
|
||||
}
|
||||
|
||||
async function testApi() {
|
||||
const apiBase = normalizeBase(apiBaseInput.value);
|
||||
const authToken = String(tokenInput.value || "").trim();
|
||||
try {
|
||||
const headers = { Accept: "application/json" };
|
||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/api/cities`, {
|
||||
headers,
|
||||
cache: "no-store"
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch (_e) {
|
||||
data = text;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${typeof data === "string" ? data : JSON.stringify(data)}`);
|
||||
}
|
||||
const count = Array.isArray(data)
|
||||
? data.length
|
||||
: Array.isArray(data?.cities)
|
||||
? data.cities.length
|
||||
: 0;
|
||||
writeResult(`${t("connectOk")}: ${count} (${t("tokenOptional")})`);
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err || "");
|
||||
if (msg.includes("HTTP 401")) {
|
||||
writeResult(`${t("testFailed")}: ${msg}\n${t("backendAuthHint")}`);
|
||||
return;
|
||||
}
|
||||
writeResult(`${t("testFailed")}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStaticTranslations() {
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
||||
if (settingsTitle) settingsTitle.textContent = t("settingsTitle");
|
||||
if (tokenLabel) tokenLabel.textContent = t("tokenLabel");
|
||||
if (tokenInput) tokenInput.placeholder = t("tokenPlaceholder");
|
||||
if (saveBtn) saveBtn.textContent = t("save");
|
||||
if (testBtn) testBtn.textContent = t("test");
|
||||
if (siteBaseLabel) siteBaseLabel.textContent = "Site Base URL";
|
||||
if (apiBaseLabel) apiBaseLabel.textContent = "API Base URL";
|
||||
}
|
||||
|
||||
document.getElementById("saveBtn").addEventListener("click", () => {
|
||||
void saveForm();
|
||||
});
|
||||
document.getElementById("testBtn").addEventListener("click", () => {
|
||||
void testApi();
|
||||
});
|
||||
|
||||
applyStaticTranslations();
|
||||
void loadForm();
|
||||
@@ -0,0 +1,410 @@
|
||||
:root {
|
||||
--bg: #070d1f;
|
||||
--panel: #0d152b;
|
||||
--card: rgba(255, 255, 255, 0.03);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--text: #e5eefb;
|
||||
--muted: #8ba0be;
|
||||
--cyan: #22d3ee;
|
||||
--blue: #3b82f6;
|
||||
--green: #34d399;
|
||||
--amber: #f59e0b;
|
||||
--red: #f87171;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: radial-gradient(circle at top, #0c1735, var(--bg) 45%);
|
||||
color: var(--text);
|
||||
font-family: "Inter", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(7, 13, 31, 0.7);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: 3px solid rgba(34, 211, 238, 0.28);
|
||||
border-top-color: #22d3ee;
|
||||
animation: panel-loading-spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #b7dcff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.freshness-hint {
|
||||
margin: -2px 0 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.28);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
color: #fcd34d;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.freshness-hint.stale {
|
||||
border-color: rgba(248, 113, 113, 0.38);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.risk-badge {
|
||||
padding: 5px 9px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.risk-badge.low {
|
||||
color: #86efac;
|
||||
border-color: rgba(52, 211, 153, 0.5);
|
||||
background: rgba(52, 211, 153, 0.14);
|
||||
}
|
||||
|
||||
.risk-badge.medium {
|
||||
color: #fcd34d;
|
||||
border-color: rgba(245, 158, 11, 0.45);
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
}
|
||||
|
||||
.risk-badge.high {
|
||||
color: #fca5a5;
|
||||
border-color: rgba(248, 113, 113, 0.5);
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
}
|
||||
|
||||
.city-picker-wrap {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.city-picker-wrap label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#citySelect {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0f1b35;
|
||||
color: var(--text);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0f1b35;
|
||||
color: #9cecff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.refresh-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.refresh-btn.spinning svg {
|
||||
animation: refresh-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes refresh-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panel-loading-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.5);
|
||||
color: #9cecff;
|
||||
background: rgba(34, 211, 238, 0.07);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--card);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.mini-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.mini-card strong {
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#trendCanvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.decision-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.decision-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.decision-direction {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
color: #f8fbff;
|
||||
}
|
||||
|
||||
.decision-window {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.decision-confidence {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.decision-confidence.high {
|
||||
color: #86efac;
|
||||
border-color: rgba(52, 211, 153, 0.45);
|
||||
background: rgba(52, 211, 153, 0.12);
|
||||
}
|
||||
|
||||
.decision-confidence.medium {
|
||||
color: #fcd34d;
|
||||
border-color: rgba(245, 158, 11, 0.45);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.decision-confidence.low,
|
||||
.decision-confidence.neutral {
|
||||
color: #cbd5e1;
|
||||
border-color: rgba(148, 163, 184, 0.28);
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.decision-summary {
|
||||
color: #dbeafe;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.decision-reasons {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.decision-reasons li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forecast-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.forecast-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 8px;
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.forecast-card.today {
|
||||
border-color: rgba(34, 211, 238, 0.62);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
|
||||
.f-date {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.f-temp {
|
||||
margin-top: 7px;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.btn-open-full {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #d9ebff;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.open-full-hint {
|
||||
margin-bottom: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid rgba(248, 113, 113, 0.45);
|
||||
border-radius: 12px;
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
color: #fecaca;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-tooltip {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
max-width: 180px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.45);
|
||||
background: rgba(9, 17, 36, 0.92);
|
||||
color: #dff7ff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PolyWeather Panel</title>
|
||||
<link rel="stylesheet" href="./sidepanel.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="panel">
|
||||
<div id="loadingOverlay" class="loading-overlay hidden" aria-live="polite">
|
||||
<div class="loading-spinner" aria-hidden="true"></div>
|
||||
<div id="loadingText" class="loading-text">正在加载温度数据...</div>
|
||||
</div>
|
||||
<header class="topbar">
|
||||
<div id="riskBadge" class="risk-badge medium">中风险</div>
|
||||
<div class="city-picker-wrap">
|
||||
<label id="cityLabel" for="citySelect">城市</label>
|
||||
<select id="citySelect"></select>
|
||||
</div>
|
||||
<button id="refreshBtn" class="refresh-btn" title="刷新数据" aria-label="刷新数据">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div id="freshnessHint" class="freshness-hint hidden"></div>
|
||||
|
||||
<section class="section">
|
||||
<h3 id="profileTitle">城市档案</h3>
|
||||
<div class="grid2">
|
||||
<article class="mini-card">
|
||||
<span class="mini-label" id="settlementLabel">结算站点</span>
|
||||
<strong id="settlementValue">--</strong>
|
||||
</article>
|
||||
<article class="mini-card">
|
||||
<span id="distanceLabel" class="mini-label">站点距离</span>
|
||||
<strong id="distanceValue">--</strong>
|
||||
</article>
|
||||
<article class="mini-card">
|
||||
<span id="obsTimeLabel" class="mini-label">观测更新</span>
|
||||
<strong id="obsTimeValue">--</strong>
|
||||
</article>
|
||||
<article class="mini-card">
|
||||
<span id="nearbyLabel" class="mini-label">周边站点</span>
|
||||
<strong id="nearbyValue">--</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3 id="trendTitle">今日日内走势(简版)</h3>
|
||||
<div class="chart-wrap">
|
||||
<canvas id="trendCanvas" width="560" height="220"></canvas>
|
||||
<div id="chartTooltip" class="chart-tooltip hidden"></div>
|
||||
</div>
|
||||
<div id="chartLegend" class="legend-text">--</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3 id="decisionTitle">方向判断</h3>
|
||||
<div class="decision-card">
|
||||
<div class="decision-top">
|
||||
<div>
|
||||
<div id="decisionDirection" class="decision-direction">--</div>
|
||||
<div id="decisionWindow" class="decision-window">--</div>
|
||||
</div>
|
||||
<div id="decisionConfidence" class="decision-confidence neutral">--</div>
|
||||
</div>
|
||||
<div id="decisionSummary" class="decision-summary">--</div>
|
||||
<ul id="decisionReasons" class="decision-reasons"></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3 id="forecastTitle">多日预报</h3>
|
||||
<div id="forecastRow" class="forecast-row"></div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div id="openFullHint" class="open-full-hint"></div>
|
||||
<button id="openFullBtn" class="btn btn-open-full">打开完整网站分析</button>
|
||||
</section>
|
||||
|
||||
<section id="errorBox" class="error hidden"></section>
|
||||
</main>
|
||||
|
||||
<script src="./sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,35 @@
|
||||
# PolyWeather 前端最小配置(本地 / Vercel)
|
||||
# 只部署天气看板时,先填下面 4 项即可。
|
||||
|
||||
# 必填:后端 FastAPI 基础地址
|
||||
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
|
||||
# Optional dashboard guard (Next.js middleware)
|
||||
# If set, open dashboard with: /?access_token=<token>
|
||||
|
||||
# 必填:Supabase 前端公钥(鉴权开启时必须)
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
# false: 关闭登录能力,访客模式
|
||||
POLYWEATHER_AUTH_ENABLED=false
|
||||
|
||||
# 常用:是否强制登录
|
||||
# true: middleware 强制登录后才能访问主页面
|
||||
# false: 登录可选,访客可浏览
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
|
||||
# 可选:分享式看板访问令牌
|
||||
# 设置后,可通过 /?access_token=<token> 打开受保护看板
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
# Shared secret forwarded by Next API routes to backend
|
||||
|
||||
# 可选:前端 API Route 转发到后端时附带的共享令牌
|
||||
# 仅当后端启用了 entitlement / 订阅校验时需要
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
# 可选:钱包支付 / Telegram 入口
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||
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
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
# PolyWeather Frontend
|
||||
# PolyWeather 前端
|
||||
|
||||
This directory is the only web frontend in production.
|
||||
PolyWeather Pro 的生产前端工程。
|
||||
|
||||
Production URL:
|
||||
- https://polyweather-pro.vercel.app/
|
||||
线上地址:
|
||||
- [https://polyweather-pro.vercel.app/](https://polyweather-pro.vercel.app/)
|
||||
|
||||
## Stack
|
||||
## 技术栈
|
||||
|
||||
- Next.js App Router
|
||||
- React (component-driven dashboard)
|
||||
- Tailwind CSS
|
||||
- Leaflet (map runtime)
|
||||
- Chart.js (charts with manual lifecycle wrapper)
|
||||
- Typed store + typed data client
|
||||
- React + Tailwind
|
||||
- Leaflet + Chart.js
|
||||
- Supabase Auth
|
||||
- WalletConnect + 浏览器 EVM 钱包
|
||||
|
||||
## Production Model
|
||||
## 运行模型
|
||||
|
||||
- Vercel serves the web UI and BFF route handlers
|
||||
- FastAPI on VPS serves weather APIs only
|
||||
- The old FastAPI static website has been removed
|
||||
- The production page shell is React-driven (`components/dashboard/*`), with no runtime dependency on `public/static/app.js`
|
||||
1. 浏览器 -> Next 应用(`frontend`)
|
||||
2. Next Route Handlers(`/api/*`)-> FastAPI 后端
|
||||
3. FastAPI -> 分析服务 / 支付服务
|
||||
|
||||
Current request flow:
|
||||
- Browser -> Vercel frontend
|
||||
- React store/client -> Next route handlers
|
||||
- Next route handlers -> FastAPI API
|
||||
## 当前前端能力
|
||||
|
||||
## Local Development
|
||||
- 主站 Dashboard 支持地图、城市详情、今日日内分析、历史准确率对账和账户中心
|
||||
- `/docs` 已提供公开双语产品文档中心,解释日内结构信号、TAF、结算来源和历史对账
|
||||
- 今日日内分析支持:
|
||||
- 峰值窗口感知的近地面结构信号
|
||||
- 高空结构信号
|
||||
- 交易动作卡
|
||||
- 非香港机场城市的 `TAF` 时段提示与走势图联动
|
||||
- 历史对账支持:
|
||||
- `DEB / 最佳单模型 / 实测最高温` 对比
|
||||
- 峰值前 12 小时 `DEB` 参考(近似)
|
||||
- `/ops` 已支持桌面表格 + 手机端卡片化视图
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
@@ -35,44 +42,139 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Default local URL:
|
||||
- http://localhost:3000
|
||||
## Vercel 最小部署配置
|
||||
|
||||
## Required Environment Variable
|
||||
只跑看板和基础鉴权时,先填这 4 项:
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://<your-fastapi-host>
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<your-supabase-project>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-key>
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `http://38.54.27.70:8000`
|
||||
- `https://api.example.com`
|
||||
建议显式补:
|
||||
|
||||
## Route Handlers
|
||||
```env
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
```
|
||||
|
||||
如果你只是开放游客浏览,可改成:
|
||||
|
||||
```env
|
||||
POLYWEATHER_AUTH_ENABLED=false
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
```
|
||||
|
||||
## 可选环境变量
|
||||
|
||||
仅在对应功能启用时填写:
|
||||
|
||||
```env
|
||||
# 看板分享令牌
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
# 前端 API 转发到后端时使用的共享令牌
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
# 钱包支付
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||
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
|
||||
```
|
||||
|
||||
更完整的 Vercel 配置说明见:
|
||||
- [docs/FRONTEND_DEPLOYMENT_ZH.md](/E:/web/PolyWeather/docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
|
||||
## 路由处理器
|
||||
|
||||
天气:
|
||||
|
||||
Thin BFF routes currently exposed by Next:
|
||||
- `GET /api/cities`
|
||||
- `GET /api/city/[name]`
|
||||
- `GET /api/city/[name]/summary`
|
||||
- `GET /api/city/[name]/detail`
|
||||
- `GET /api/history/[name]`
|
||||
|
||||
Current frontend behavior:
|
||||
- `/` keeps the world overview layout and initial city temperatures preload
|
||||
- Marker click: focus map + open right city card + render nearby stations
|
||||
- Right-card "今日日内分析": opens modal and freezes map motion
|
||||
- Blank-map click: closes right card only, without resetting camera
|
||||
鉴权:
|
||||
|
||||
## Vercel Deployment
|
||||
- `GET /api/auth/me`
|
||||
|
||||
1. Import the repo into Vercel
|
||||
2. Set Root Directory to `frontend`
|
||||
3. Set `POLYWEATHER_API_BASE_URL`
|
||||
4. Deploy
|
||||
支付:
|
||||
|
||||
## Notes
|
||||
- `GET /api/payments/config`
|
||||
- `GET /api/payments/wallets`
|
||||
- `POST /api/payments/wallets/challenge`
|
||||
- `POST /api/payments/wallets/verify`
|
||||
- `POST /api/payments/intents`
|
||||
- `GET /api/payments/intents/[intentId]`
|
||||
- `POST /api/payments/intents/[intentId]/submit`
|
||||
- `POST /api/payments/intents/[intentId]/confirm`
|
||||
|
||||
- Backend CORS must allow `https://polyweather-pro.vercel.app`
|
||||
- City detail cache TTL is 5 minutes with revision probe; manual refresh bypasses cache
|
||||
- UI layout and sizing remain aligned with the legacy visual contract after React migration
|
||||
Ops:
|
||||
|
||||
Last updated: 2026-03-09
|
||||
- `GET /ops`
|
||||
- `GET /api/ops/users`
|
||||
- `GET /api/ops/leaderboard/weekly`
|
||||
- `GET /api/ops/memberships`
|
||||
- `GET /api/ops/payments/incidents`
|
||||
- `POST /api/ops/users/grant-points`
|
||||
- `POST /api/ops/payments/incidents/[eventId]/resolve`
|
||||
|
||||
## Ops 管理后台
|
||||
|
||||
当前前端已内置轻量管理页:
|
||||
|
||||
- [https://polyweather-pro.vercel.app/ops](https://polyweather-pro.vercel.app/ops)
|
||||
|
||||
页面当前支持:
|
||||
|
||||
- 系统状态
|
||||
- SQLite / rollout / 支付运行态
|
||||
- 用户查询
|
||||
- 当前会员
|
||||
- 本周积分榜
|
||||
- 手动补分
|
||||
- 支付异常单筛选与标记已处理
|
||||
- 手机端卡片化视图
|
||||
|
||||
注意:
|
||||
|
||||
- `/ops` 现在是前后端双层管理员限制
|
||||
- Vercel 前端和后端都应配置相同的 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 前端登录邮箱本身不会自动获得管理员权限
|
||||
|
||||
## 支付安全补充
|
||||
|
||||
为降低“旧页面/旧配置导致打到旧收款地址”的风险,支付区现在会:
|
||||
|
||||
1. 点击支付前重新请求 `/api/payments/config`
|
||||
2. 若 `receiver_contract` 已更新,先切到最新地址
|
||||
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
|
||||
4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent
|
||||
5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆
|
||||
|
||||
这意味着:
|
||||
|
||||
- 旧标签页风险已明显降低
|
||||
- 但支付地址变更后,仍建议在 Vercel 上 redeploy 当前 production,并清理明显过期 deployment
|
||||
|
||||
## 缓存行为
|
||||
|
||||
- `cities` / `summary` / `history`:`ETag + Cache-Control`
|
||||
- `summary?force_refresh=true`:`no-store`
|
||||
- 支付相关路由:`no-store`
|
||||
|
||||
## AGPL 与商用边界说明
|
||||
|
||||
此前端代码随仓库一起采用 `AGPL-3.0-only`。
|
||||
生产私有运营流程、商业策略调优、敏感生产参数、品牌与托管服务能力不在代码许可证授权范围内。
|
||||
|
||||
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
|
||||
|
||||
最后更新:`2026-03-24`
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
import { AccountEntry } from "@/components/account/AccountEntry";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather | Account Center",
|
||||
description: "PolyWeather account center for identity and entitlement status.",
|
||||
};
|
||||
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AccountEntry />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 542 KiB |
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ANALYTICS_ENABLED =
|
||||
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!ANALYTICS_ENABLED) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/analytics/events`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 260) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to track analytics event", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/auth/me`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: false,
|
||||
subscription_active: false,
|
||||
points: 0,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch auth profile", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
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`, {
|
||||
headers: buildBackendRequestHeaders(),
|
||||
cache: "no-store",
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"public, max-age=0, s-maxage=300, stale-while-revalidate=1800",
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch cities", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,10 +11,11 @@ export async function GET(
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
@@ -30,23 +34,27 @@ export async function GET(
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(url, {
|
||||
headers: buildBackendRequestHeaders(),
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch city detail aggregate", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,10 +11,11 @@ export async function GET(
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
@@ -19,23 +23,29 @@ export async function GET(
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
headers: buildBackendRequestHeaders(),
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch city detail", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,34 +12,63 @@ export async function GET(
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const bypassCache = forceRefresh === "true";
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const fetchOptions =
|
||||
bypassCache
|
||||
? {
|
||||
headers: auth.headers,
|
||||
cache: "no-store" as const,
|
||||
}
|
||||
: {
|
||||
headers: auth.headers,
|
||||
next: { revalidate: 20 },
|
||||
};
|
||||
const res = await fetch(url, {
|
||||
headers: buildBackendRequestHeaders(),
|
||||
cache: "no-store",
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
if (bypassCache) {
|
||||
const response = NextResponse.json(data, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"public, max-age=0, s-maxage=20, stale-while-revalidate=60",
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch city summary", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/healthz`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch healthz", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
const url = `${API_BASE}/api/history/${encodeURIComponent(name)}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const fetchOptions = {
|
||||
headers: auth.headers,
|
||||
next: { revalidate: 60 },
|
||||
} as const;
|
||||
const res = await fetch(url, {
|
||||
headers: buildBackendRequestHeaders(),
|
||||
cache: "no-store",
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"public, max-age=0, s-maxage=60, stale-while-revalidate=300",
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch history", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
|
||||
const days = req.nextUrl.searchParams.get("days");
|
||||
if (days) {
|
||||
url.searchParams.set("days", days);
|
||||
}
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch analytics funnel", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch weekly leaderboard", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const url = new URL(`${API_BASE}/api/ops/memberships`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch memberships", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ eventId: string }>;
|
||||
};
|
||||
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const { eventId } = await context.params;
|
||||
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to resolve payment incident", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch payment incidents", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const url = new URL(`${API_BASE}/api/ops/truth-history`);
|
||||
for (const key of ["city", "date_from", "date_to", "limit"]) {
|
||||
const value = req.nextUrl.searchParams.get(key);
|
||||
if (value) url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch truth history", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...auth.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to grant points", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const url = new URL(`${API_BASE}/api/ops/users`);
|
||||
const q = req.nextUrl.searchParams.get("q");
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (q) url.searchParams.set("q", q);
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch ops users", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/config`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch payment config", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/confirm`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to confirm payment tx", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch payment intent", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/submit`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to submit payment tx", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { isPaymentHostAllowed } from "@/lib/payment-host";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const requestHost =
|
||||
req.headers.get("x-forwarded-host") ||
|
||||
req.headers.get("host") ||
|
||||
req.nextUrl.hostname;
|
||||
if (!isPaymentHostAllowed(requestHost)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Payments are disabled on this host. Please return to the main production site and retry.",
|
||||
host: requestHost,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/intents`, {
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create payment intent", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
res.headers.get("content-type") || "application/json; charset=utf-8",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to reconcile latest payment", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/runtime`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch payment runtime", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: `Backend returned ${res.status}`,
|
||||
detail: raw.slice(0, 350),
|
||||
proxy_debug: {
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has(
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
},
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create wallet challenge", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch wallets", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = (await req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
||||
method: "DELETE",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) {
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: `Backend returned ${res.status}`,
|
||||
detail: raw.slice(0, 350),
|
||||
proxy_debug: {
|
||||
incoming_has_authorization: Boolean(
|
||||
String(req.headers.get("authorization") || "").trim(),
|
||||
),
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has(
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
},
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
let data: unknown = { ok: true };
|
||||
if (raw) {
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
data = { ok: true, raw };
|
||||
}
|
||||
}
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to unbind wallet", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: `Backend returned ${res.status}`,
|
||||
detail: raw.slice(0, 350),
|
||||
proxy_debug: {
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has(
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
},
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to verify wallet binding", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/system/status`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch system status", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getVitalsSummary,
|
||||
normalizeMetricName,
|
||||
recordVitalsSample,
|
||||
} from "@/lib/vitals-store";
|
||||
|
||||
const WEB_VITALS_ENABLED =
|
||||
process.env.NEXT_PUBLIC_POLYWEATHER_WEB_VITALS === "true";
|
||||
|
||||
type VitalsPayload = {
|
||||
id?: string;
|
||||
metric?: string;
|
||||
navigationType?: string;
|
||||
pathname?: string;
|
||||
rating?: string;
|
||||
value?: number;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!WEB_VITALS_ENABLED) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = (await request.json()) as VitalsPayload;
|
||||
const metric = normalizeMetricName(payload.metric);
|
||||
if (!metric) {
|
||||
return NextResponse.json({ ok: false, error: "metric is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const pathname = String(payload.pathname || "/");
|
||||
const rating = String(payload.rating || "unknown");
|
||||
const value = Number(payload.value);
|
||||
const navigationType = String(payload.navigationType || "unknown");
|
||||
const id = String(payload.id || "");
|
||||
const ts = Date.now();
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
return NextResponse.json({ ok: false, error: "value must be finite" }, { status: 400 });
|
||||
}
|
||||
|
||||
recordVitalsSample({
|
||||
id,
|
||||
metric,
|
||||
navigationType,
|
||||
pathname,
|
||||
rating,
|
||||
timestamp: ts,
|
||||
value,
|
||||
});
|
||||
|
||||
// Keep this lightweight: log for now, can be wired to a persistent sink later.
|
||||
console.info(
|
||||
`[vitals] metric=${metric} path=${pathname} value=${Number.isFinite(value) ? value : "NaN"} rating=${rating} nav=${navigationType} id=${id}`,
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.warn("[vitals] failed to parse payload", error);
|
||||
return NextResponse.json({ ok: false }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!WEB_VITALS_ENABLED) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
disabled: true,
|
||||
generatedAt: Date.now(),
|
||||
sampleCount: 0,
|
||||
routes: {},
|
||||
});
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const targetRoute = String(searchParams.get("route") || "").trim();
|
||||
const summary = getVitalsSummary();
|
||||
|
||||
if (!targetRoute) {
|
||||
return NextResponse.json({ ok: true, ...summary });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
generatedAt: summary.generatedAt,
|
||||
route: targetRoute,
|
||||
metrics: summary.routes[targetRoute] || {},
|
||||
});
|
||||
}
|
||||
|
After Width: | Height: | Size: 77 KiB |
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
||||
|
||||
function normalizeNextPath(input: string | null) {
|
||||
const fallback = "/";
|
||||
const raw = String(input || "").trim();
|
||||
if (!raw) return fallback;
|
||||
if (!raw.startsWith("/")) return fallback;
|
||||
if (raw.startsWith("//")) return fallback;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
|
||||
const redirectUrl = request.nextUrl.clone();
|
||||
redirectUrl.pathname = nextPath;
|
||||
redirectUrl.search = "";
|
||||
|
||||
if (!hasSupabaseServerEnv()) {
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
const response = NextResponse.redirect(redirectUrl);
|
||||
const supabase = createSupabaseRouteClient(request, response);
|
||||
const code = request.nextUrl.searchParams.get("code");
|
||||
if (code) {
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { LoginClient } from "@/components/auth/LoginClient";
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<{ next?: string }>;
|
||||
};
|
||||
|
||||
function normalizeNextPath(input: string | undefined) {
|
||||
const fallback = "/";
|
||||
const raw = String(input || "").trim();
|
||||
if (!raw) return fallback;
|
||||
if (!raw.startsWith("/")) return fallback;
|
||||
if (raw.startsWith("//")) return fallback;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: PageProps) {
|
||||
const params = (await searchParams) || {};
|
||||
const nextPath = normalizeNextPath(params.next);
|
||||
return (
|
||||
<I18nProvider>
|
||||
<LoginClient nextPath={nextPath} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||