Add WeatherNext2 worker and prevent empty scan cache
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
# Secrets
|
||||
.env
|
||||
secrets/
|
||||
*.service-account.json
|
||||
gcp-sa.json
|
||||
|
||||
# Scratch / temp scripts
|
||||
scratch/
|
||||
|
||||
@@ -380,6 +380,9 @@ compose_up_retry "cache warmer" -d --no-deps polyweather_warmer
|
||||
echo "Updating training settlement worker..."
|
||||
compose_up_retry "training settlement" -d --no-deps polyweather_training_settlement
|
||||
|
||||
echo "Updating WeatherNext2 worker..."
|
||||
compose_up_retry "WeatherNext2 worker" -d --no-deps polyweather_weathernext2_worker
|
||||
|
||||
echo "Updating frontend..."
|
||||
compose_up_retry "frontend" -d --no-deps polyweather_frontend
|
||||
|
||||
|
||||
@@ -139,6 +139,9 @@ services:
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
||||
SUPABASE_URL: ${SUPABASE_URL}
|
||||
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
|
||||
WEATHERNEXT2_CITY_HIGHS_PATH: ${WEATHERNEXT2_CITY_HIGHS_PATH:-/app/data/weathernext2_city_highs.json}
|
||||
WEATHERNEXT2_ENABLED: ${WEATHERNEXT2_ENABLED:-1}
|
||||
WEATHERNEXT2_MODEL_DIR: ${WEATHERNEXT2_MODEL_DIR:-/app/data/models/weathernext2_calibrator}
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
@@ -296,6 +299,56 @@ services:
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
polyweather_weathernext2_worker:
|
||||
command: python -m web.weathernext2_worker
|
||||
container_name: polyweather_weathernext2_worker
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
polyweather_web:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
cpus: ${POLYWEATHER_WEATHERNEXT2_CPUS:-1.50}
|
||||
env_file: *id001
|
||||
environment:
|
||||
GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/app/secrets/gcp-sa.json}
|
||||
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: weathernext2_worker
|
||||
WEATHERNEXT2_BACKEND: ${WEATHERNEXT2_BACKEND:-gcs_zarr}
|
||||
WEATHERNEXT2_CITY_HIGHS_PATH: ${WEATHERNEXT2_CITY_HIGHS_PATH:-/app/data/weathernext2_city_highs.json}
|
||||
WEATHERNEXT2_DATA_ROOT: ${WEATHERNEXT2_DATA_ROOT:-/app/data/weathernext2}
|
||||
WEATHERNEXT2_ENABLED: ${WEATHERNEXT2_ENABLED:-1}
|
||||
WEATHERNEXT2_GCS_ZARR_URI: ${WEATHERNEXT2_GCS_ZARR_URI:-gs://weathernext/weathernext_2_0_0/zarr}
|
||||
WEATHERNEXT2_MODEL_DIR: ${WEATHERNEXT2_MODEL_DIR:-/app/data/models/weathernext2_calibrator}
|
||||
WEATHERNEXT2_WORKER_INITIAL_DELAY_SEC: ${WEATHERNEXT2_WORKER_INITIAL_DELAY_SEC:-90}
|
||||
WEATHERNEXT2_WORKER_INTERVAL_SEC: ${WEATHERNEXT2_WORKER_INTERVAL_SEC:-21600}
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
|
||||
c.execute('SELECT 1'); c.close()
|
||||
timeout: 10s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
mem_limit: ${POLYWEATHER_WEATHERNEXT2_MEM_LIMIT:-2g}
|
||||
memswap_limit: ${POLYWEATHER_WEATHERNEXT2_MEMSWAP_LIMIT:-3g}
|
||||
pids_limit: 512
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
- ./secrets:/app/secrets:ro
|
||||
x-polyweather-base:
|
||||
env_file: *id001
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# WeatherNext 2 接入说明
|
||||
|
||||
PolyWeather 已新增 WeatherNext 2 的内部接入层,当前定位是“概率底座”和“DEB 参考模型”,不是公开 API。
|
||||
|
||||
## 已落地
|
||||
|
||||
- `src.data_collection.weathernext2_sources`
|
||||
- 将 64 成员最高温预报聚合成 Polymarket 口径概率桶。
|
||||
- 摄氏城市按单温度选项,例如 `33°C`。
|
||||
- 华氏城市按两度市场选项,例如 `94-95°F`。
|
||||
- 支持从 hourly 成员序列按城市当地日期切出今日最高温。
|
||||
- `WeatherDataCollector.fetch_weathernext2_probability`
|
||||
- 默认关闭,不影响现有生产采集。
|
||||
- `WEATHERNEXT2_ENABLED=1` 后启用。
|
||||
- 优先读取 worker 生成的 `/app/data/weathernext2_city_highs.json`,再降级读取 fixture。
|
||||
- 若存在 LightGBM 校准模型,会对 raw 成员分布做 quantile residual 校准后再生成市场桶概率。
|
||||
- `web.weathernext2_worker`
|
||||
- 独立低频 worker,不在 API/Telegram 请求中训练或访问 GCS。
|
||||
- 从 GCS Zarr 对城市经纬度最近格点取 2m temperature hourly ensemble。
|
||||
- 按城市当地日期切今日最高温,写入离线 JSON 产物。
|
||||
- 使用 `training_feature_records_store` + `truth_records_store` 训练 LightGBM q10/q50/q90 residual calibrator。
|
||||
- `trend_engine`
|
||||
- 若 `weather_data["weathernext2"]` 存在,`distribution_full` 使用 WeatherNext 2 概率桶,`probability_engine=weathernext2`。
|
||||
- WeatherNext 2 ensemble median/mean 会作为 `WeatherNext 2` 进入 `current_forecasts`,供 DEB 融合参考。
|
||||
|
||||
## 环境变量
|
||||
|
||||
```bash
|
||||
WEATHERNEXT2_ENABLED=1
|
||||
WEATHERNEXT2_BACKEND=gcs_zarr
|
||||
WEATHERNEXT2_DATA_ROOT=/app/data/weathernext2
|
||||
WEATHERNEXT2_CITY_HIGHS_PATH=/app/data/weathernext2_city_highs.json
|
||||
WEATHERNEXT2_MODEL_DIR=/app/data/models/weathernext2_calibrator
|
||||
WEATHERNEXT2_WORKER_INTERVAL_SEC=21600
|
||||
WEATHERNEXT2_CACHE_TTL_SEC=21600
|
||||
WEATHERNEXT2_GCS_ZARR_URI=gs://weathernext/weathernext_2_0_0/zarr
|
||||
WEATHERNEXT2_MEAN_GCS_ZARR_URI=gs://weathernext/weathernext_2_0_0_mean/zarr
|
||||
GOOGLE_APPLICATION_CREDENTIALS=/app/secrets/gcp-sa.json
|
||||
```
|
||||
|
||||
Google 官方说明中 WeatherNext 2 GCS Zarr 路径为 `gs://weathernext/weathernext_2_0_0/zarr`,访问前需要 WeatherNext Data Request 权限。缺 GCP 凭据或无访问权限时,worker 会失败退出本轮,不覆盖旧产物;Web/API 仍按现有数据源运行。
|
||||
|
||||
## Artifact / Fixture 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": "weathernext2",
|
||||
"backend": "gcs_zarr",
|
||||
"generated_at": "2026-07-01T10:00:00+00:00",
|
||||
"cities": {
|
||||
"houston": {
|
||||
"target_date": "2026-06-29",
|
||||
"source_run": "2026-06-29T00:00:00Z",
|
||||
"member_highs": {
|
||||
"member_00": 94.1,
|
||||
"member_01": 94.8,
|
||||
"member_02": 95.2,
|
||||
"member_03": 96.7
|
||||
},
|
||||
"summary": {
|
||||
"members": 4,
|
||||
"median": 95.0
|
||||
},
|
||||
"buckets": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
也可以提供 hourly 成员序列:
|
||||
|
||||
```json
|
||||
{
|
||||
"shanghai": {
|
||||
"target_date": "2026-06-29",
|
||||
"timezone_offset_seconds": 28800,
|
||||
"utc_times": ["2026-06-28T16:00:00Z", "2026-06-29T06:00:00Z"],
|
||||
"member_hourly": {
|
||||
"member_00": [31.2, 33.0],
|
||||
"member_01": [30.8, 32.6]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 后续真实拉取
|
||||
|
||||
Google 官方 WeatherNext 2 数据源包括 GCS Zarr、BigQuery、Earth Engine 和 Vertex AI。当前第一版已经按独立离线 job 落地:
|
||||
|
||||
- 每 6 小时拉取最近一次 WeatherNext 2 run。
|
||||
- 对 50 个城市插值到机场/结算点。
|
||||
- 切城市当地日期今日最高温。
|
||||
- 写入 `/app/data/weathernext2_city_highs.json` 或数据库表。
|
||||
- Web/API 只读本地结果,避免实时请求 Google 数据集影响响应时间。
|
||||
|
||||
后续可以评估 BigQuery 后端作为替代路径,但不应让 API 请求直接依赖外部 WeatherNext 数据集。
|
||||
@@ -30,6 +30,7 @@ export function runTests() {
|
||||
model_cluster_sources: {
|
||||
ECMWF: 28.8,
|
||||
GFS: 29.4,
|
||||
"WeatherNext 2": 29.8,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -144,8 +145,9 @@ export function runTests() {
|
||||
assert(
|
||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("AROME HD") &&
|
||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("HRRR") &&
|
||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("NAM"),
|
||||
"model summary must expose the fixed model columns including optional short-range models",
|
||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("NAM") &&
|
||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("WeatherNext 2"),
|
||||
"model summary must expose the fixed model columns including optional short-range and WeatherNext 2 models",
|
||||
);
|
||||
assert(summaryRows.length === 5, "model summary should keep one row per city");
|
||||
assert(summaryRows[0].cityName === "Beijing", "model summary should sort by resolved region then city name");
|
||||
@@ -179,6 +181,7 @@ export function runTests() {
|
||||
"model summary should aggregate Fahrenheit probabilities into two-degree market option labels",
|
||||
);
|
||||
assert(parisRow.marketMatches.length === 3, "model summary should keep every Polymarket tradable bucket");
|
||||
assert(beijingRow?.models["WeatherNext 2"] === 29.8, "model summary should preserve WeatherNext 2 model representative");
|
||||
assert(
|
||||
parisRow.marketMatches[0].label === "32°C" &&
|
||||
parisRow.marketMatches[0].modelProbability === null &&
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
readScanCache,
|
||||
writeScanCache,
|
||||
} from "@/components/dashboard/scan-terminal/use-scan-terminal-query";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const originalLocalStorage = globalThis.localStorage;
|
||||
const storage = new Map<string, string>();
|
||||
const memoryStorage = {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||
} as Storage;
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: memoryStorage,
|
||||
});
|
||||
|
||||
try {
|
||||
writeScanCache(
|
||||
{
|
||||
generated_at: "2026-07-02T12:00:00Z",
|
||||
rows: [{ city: "ankara", deb_prediction: 20.3 } as any],
|
||||
status: "ready",
|
||||
} as any,
|
||||
"all",
|
||||
"model-summary",
|
||||
);
|
||||
assert(
|
||||
readScanCache("all", "model-summary", { allowStale: true })?.rows?.length === 1,
|
||||
"non-empty scan cache should remain readable",
|
||||
);
|
||||
|
||||
writeScanCache(
|
||||
{
|
||||
generated_at: "2026-07-02T12:05:00Z",
|
||||
rows: [],
|
||||
status: "ready",
|
||||
} as any,
|
||||
"all",
|
||||
"model-summary",
|
||||
);
|
||||
assert(
|
||||
readScanCache("all", "model-summary", { allowStale: true }) === null,
|
||||
"empty scan cache responses must be ignored so model summary can revalidate rows",
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: originalLocalStorage,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ function scanCacheKey(tradingRegion: string, cacheScope: string): string {
|
||||
return `${SCAN_CACHE_PREFIX}:${cacheScope || "terminal"}:${tradingRegion || "all"}`;
|
||||
}
|
||||
|
||||
function readScanCache(
|
||||
export function readScanCache(
|
||||
tradingRegion: string,
|
||||
cacheScope: string,
|
||||
options?: { allowStale?: boolean },
|
||||
@@ -47,15 +47,27 @@ function readScanCache(
|
||||
const cached = JSON.parse(raw);
|
||||
const age = Date.now() - Number(cached.ts || 0);
|
||||
const maxAge = options?.allowStale ? MAX_STALE_SCAN_CACHE_MS : SCAN_CACHE_TTL_MS;
|
||||
if (cached.ts && age >= 0 && age < maxAge && cached.data?.rows) {
|
||||
if (
|
||||
cached.ts &&
|
||||
age >= 0 &&
|
||||
age < maxAge &&
|
||||
Array.isArray(cached.data?.rows) &&
|
||||
cached.data.rows.length > 0
|
||||
) {
|
||||
return cached.data;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeScanCache(data: ScanTerminalResponse, tradingRegion: string, cacheScope: string) {
|
||||
try { localStorage.setItem(scanCacheKey(tradingRegion, cacheScope), JSON.stringify({ ts: Date.now(), data })); } catch { /* ignore */ }
|
||||
export function writeScanCache(data: ScanTerminalResponse, tradingRegion: string, cacheScope: string) {
|
||||
try {
|
||||
if (!Array.isArray(data.rows) || data.rows.length <= 0) {
|
||||
localStorage.removeItem(scanCacheKey(tradingRegion, cacheScope));
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(scanCacheKey(tradingRegion, cacheScope), JSON.stringify({ ts: Date.now(), data }));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function normalizeCityKey(city: string | null | undefined) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const MODEL_SUMMARY_MODEL_COLUMNS = [
|
||||
{ key: "AROME HD", label: "AROME HD" },
|
||||
{ key: "HRRR", label: "HRRR" },
|
||||
{ key: "NAM", label: "NAM" },
|
||||
{ key: "WeatherNext 2", label: "WeatherNext 2" },
|
||||
] as const;
|
||||
|
||||
export type ModelSummaryColumnKey = (typeof MODEL_SUMMARY_MODEL_COLUMNS)[number]["key"];
|
||||
|
||||
+145
-8
@@ -4,6 +4,7 @@ aiohappyeyeballs==2.6.2
|
||||
# via aiohttp
|
||||
aiohttp==3.14.1
|
||||
# via
|
||||
# gcsfs
|
||||
# pytelegrambotapi
|
||||
# web3
|
||||
aiosignal==1.4.0
|
||||
@@ -28,6 +29,8 @@ certifi==2026.5.20
|
||||
# httpx
|
||||
# netcdf4
|
||||
# requests
|
||||
cffi==2.0.0 ; platform_python_implementation != 'PyPy'
|
||||
# via cryptography
|
||||
cftime==1.6.5
|
||||
# via netcdf4
|
||||
charset-normalizer==3.4.7
|
||||
@@ -40,8 +43,14 @@ colorama==0.4.6 ; sys_platform == 'win32'
|
||||
# via
|
||||
# click
|
||||
# loguru
|
||||
cryptography==49.0.0
|
||||
# via google-auth
|
||||
cytoolz==1.1.0 ; implementation_name == 'cpython'
|
||||
# via eth-utils
|
||||
decorator==5.3.1
|
||||
# via gcsfs
|
||||
donfig==0.8.1.post1
|
||||
# via zarr
|
||||
eth-abi==5.2.0
|
||||
# via
|
||||
# eth-account
|
||||
@@ -81,6 +90,58 @@ frozenlist==1.8.0
|
||||
# via
|
||||
# aiohttp
|
||||
# aiosignal
|
||||
fsspec==2026.6.0
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# gcsfs
|
||||
gcsfs==2026.6.0
|
||||
# via -r requirements.txt
|
||||
google-api-core==2.31.0
|
||||
# via
|
||||
# google-cloud-core
|
||||
# google-cloud-storage
|
||||
# google-cloud-storage-control
|
||||
google-auth==2.55.1
|
||||
# via
|
||||
# gcsfs
|
||||
# google-api-core
|
||||
# google-auth-oauthlib
|
||||
# google-cloud-core
|
||||
# google-cloud-storage
|
||||
# google-cloud-storage-control
|
||||
google-auth-oauthlib==1.4.0
|
||||
# via gcsfs
|
||||
google-cloud-core==2.6.0
|
||||
# via google-cloud-storage
|
||||
google-cloud-storage==3.12.0
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# gcsfs
|
||||
google-cloud-storage-control==1.12.0
|
||||
# via gcsfs
|
||||
google-crc32c==1.8.0
|
||||
# via
|
||||
# google-cloud-storage
|
||||
# google-resumable-media
|
||||
# zarr
|
||||
google-resumable-media==2.10.0
|
||||
# via google-cloud-storage
|
||||
googleapis-common-protos==1.75.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpc-google-iam-v1==0.14.4
|
||||
# via google-cloud-storage-control
|
||||
grpcio==1.81.1
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-storage-control
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpcio-status==1.81.1
|
||||
# via google-api-core
|
||||
h11==0.16.0
|
||||
# via
|
||||
# httpcore
|
||||
@@ -100,33 +161,72 @@ idna==3.18
|
||||
# httpx
|
||||
# requests
|
||||
# yarl
|
||||
joblib==1.5.3
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# scikit-learn
|
||||
lightgbm==4.6.0
|
||||
# via -r requirements.txt
|
||||
loguru==0.7.3
|
||||
# via -r requirements.txt
|
||||
multidict==6.7.1
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
narwhals==2.23.0
|
||||
# via scikit-learn
|
||||
netcdf4==1.7.4
|
||||
# via -r requirements.txt
|
||||
numcodecs==0.16.5
|
||||
# via zarr
|
||||
numpy==2.4.6
|
||||
|
||||
torch
|
||||
typing_extensions==4.15.0
|
||||
# via torch
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# cftime
|
||||
# lightgbm
|
||||
# netcdf4
|
||||
# numcodecs
|
||||
# pandas
|
||||
# scikit-learn
|
||||
# scipy
|
||||
# xarray
|
||||
# zarr
|
||||
oauthlib==3.3.1
|
||||
# via requests-oauthlib
|
||||
packaging==26.2
|
||||
# via
|
||||
# xarray
|
||||
# zarr
|
||||
pandas==3.0.3
|
||||
# via xarray
|
||||
parsimonious==0.10.0
|
||||
# via eth-abi
|
||||
propcache==0.5.2
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
proto-plus==1.28.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-storage-control
|
||||
protobuf==7.35.1
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-storage-control
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
# proto-plus
|
||||
pyaes==1.6.1
|
||||
# via telethon
|
||||
pyasn1==0.6.3
|
||||
# via rsa
|
||||
# via
|
||||
# pyasn1-modules
|
||||
# rsa
|
||||
pyasn1-modules==0.4.2
|
||||
# via google-auth
|
||||
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
|
||||
# via cffi
|
||||
pycryptodome==3.23.0
|
||||
# via
|
||||
# eth-hash
|
||||
@@ -141,6 +241,8 @@ pydantic-core==2.46.4
|
||||
# via pydantic
|
||||
pytelegrambotapi==4.34.0
|
||||
# via -r requirements.txt
|
||||
python-dateutil==2.9.0.post0
|
||||
# via pandas
|
||||
python-dotenv==1.2.2
|
||||
# via -r requirements.txt
|
||||
pyunormalize==17.0.0
|
||||
@@ -148,7 +250,9 @@ pyunormalize==17.0.0
|
||||
pywin32==312 ; sys_platform == 'win32'
|
||||
# via web3
|
||||
pyyaml==6.0.3
|
||||
# via -r requirements.txt
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# donfig
|
||||
redis==6.4.0
|
||||
# via -r requirements.txt
|
||||
regex==2026.5.9
|
||||
@@ -156,18 +260,40 @@ regex==2026.5.9
|
||||
requests==2.34.2
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# gcsfs
|
||||
# google-api-core
|
||||
# google-cloud-storage
|
||||
# pytelegrambotapi
|
||||
# requests-oauthlib
|
||||
# web3
|
||||
requests-oauthlib==2.0.0
|
||||
# via google-auth-oauthlib
|
||||
rlp==4.1.0
|
||||
# via
|
||||
# eth-account
|
||||
# eth-rlp
|
||||
rsa==4.9.1
|
||||
# via telethon
|
||||
scikit-learn==1.9.0
|
||||
# via -r requirements.txt
|
||||
scipy==1.17.1 ; python_full_version < '3.12'
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# lightgbm
|
||||
# scikit-learn
|
||||
scipy==1.18.0 ; python_full_version >= '3.12'
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# lightgbm
|
||||
# scikit-learn
|
||||
six==1.17.0
|
||||
# via python-dateutil
|
||||
starlette==1.3.1
|
||||
# via fastapi
|
||||
telethon==1.43.2
|
||||
# via -r requirements.txt
|
||||
threadpoolctl==3.6.0
|
||||
# via scikit-learn
|
||||
toolz==1.1.0 ; implementation_name == 'cpython' or implementation_name == 'pypy'
|
||||
# via
|
||||
# cytoolz
|
||||
@@ -181,17 +307,22 @@ typing-extensions==4.15.0
|
||||
# anyio
|
||||
# eth-typing
|
||||
# fastapi
|
||||
# grpcio
|
||||
# numcodecs
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# starlette
|
||||
# typing-inspection
|
||||
# web3
|
||||
# zarr
|
||||
typing-inspection==0.4.2
|
||||
# via
|
||||
# fastapi
|
||||
# pydantic
|
||||
tzdata==2026.2 ; sys_platform == 'win32'
|
||||
# via -r requirements.txt
|
||||
tzdata==2026.2 ; sys_platform == 'emscripten' or sys_platform == 'win32'
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# pandas
|
||||
urllib3==2.7.0
|
||||
# via
|
||||
# requests
|
||||
@@ -206,5 +337,11 @@ websockets==15.0.1
|
||||
# web3
|
||||
win32-setctime==1.2.0 ; sys_platform == 'win32'
|
||||
# via loguru
|
||||
xarray==2026.4.0
|
||||
# via -r requirements.txt
|
||||
yarl==1.24.2
|
||||
# via aiohttp
|
||||
zarr==3.1.6 ; python_full_version < '3.12'
|
||||
# via -r requirements.txt
|
||||
zarr==3.2.1 ; python_full_version >= '3.12'
|
||||
# via -r requirements.txt
|
||||
|
||||
@@ -7,6 +7,15 @@ python-dotenv>=1.1,<2
|
||||
PyYAML>=6.0,<7
|
||||
netCDF4>=1.7,<2
|
||||
numpy>=2.2,<3
|
||||
scipy>=1.10,<2
|
||||
scikit-learn>=1.4,<2
|
||||
lightgbm>=4.6,<5
|
||||
joblib>=1.3,<2
|
||||
xarray>=2024.7,<2027
|
||||
zarr>=2.18,<4
|
||||
fsspec>=2024.10,<2027
|
||||
gcsfs>=2024.10,<2027
|
||||
google-cloud-storage>=2.18,<4
|
||||
web3>=7.13,<8
|
||||
fastapi>=0.115,<1
|
||||
uvicorn>=0.34,<1
|
||||
|
||||
@@ -110,6 +110,7 @@ def _deb_model_priority(model_name: str) -> int:
|
||||
"hko": 45,
|
||||
"lgbm": 50,
|
||||
"openmeteo": 15,
|
||||
"weathernext2": 30,
|
||||
}.get(normalized, 10)
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,49 @@ def _sf(v):
|
||||
return None
|
||||
|
||||
|
||||
def _weathernext2_probability_payload(
|
||||
weather_data: Dict[str, Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
source = weather_data.get("weathernext2")
|
||||
if not isinstance(source, dict):
|
||||
return None
|
||||
raw_buckets = source.get("buckets")
|
||||
if not isinstance(raw_buckets, list) or not raw_buckets:
|
||||
return None
|
||||
|
||||
buckets = []
|
||||
for bucket in raw_buckets:
|
||||
if not isinstance(bucket, dict):
|
||||
continue
|
||||
probability = _sf(bucket.get("probability"))
|
||||
if probability is None or probability <= 0:
|
||||
continue
|
||||
copied = dict(bucket)
|
||||
copied["probability"] = round(probability, 3)
|
||||
buckets.append(copied)
|
||||
if not buckets:
|
||||
return None
|
||||
|
||||
summary = source.get("summary") if isinstance(source.get("summary"), dict) else {}
|
||||
mu = _sf(summary.get("median"))
|
||||
if mu is None:
|
||||
mu = _sf(summary.get("mean"))
|
||||
if mu is None:
|
||||
top_bucket = max(buckets, key=lambda item: _sf(item.get("probability")) or 0)
|
||||
mu = _sf(top_bucket.get("value"))
|
||||
|
||||
return {
|
||||
"engine": "weathernext2",
|
||||
"mu": mu,
|
||||
"probabilities": sorted(
|
||||
buckets,
|
||||
key=lambda item: _sf(item.get("probability")) or 0,
|
||||
reverse=True,
|
||||
)[:4],
|
||||
"probabilities_all": buckets,
|
||||
}
|
||||
|
||||
|
||||
def _median(values: List[float]) -> Optional[float]:
|
||||
if not values:
|
||||
return None
|
||||
@@ -548,6 +591,18 @@ def analyze_weather_trend(
|
||||
for m_name, m_val in mm_forecasts.items():
|
||||
if m_val is not None and not _is_excluded_model_name(m_name):
|
||||
current_forecasts[m_name] = _sf(m_val)
|
||||
weathernext2 = weather_data.get("weathernext2")
|
||||
if isinstance(weathernext2, dict):
|
||||
weathernext2_summary = (
|
||||
weathernext2.get("summary")
|
||||
if isinstance(weathernext2.get("summary"), dict)
|
||||
else {}
|
||||
)
|
||||
weathernext2_median = _sf(weathernext2_summary.get("median"))
|
||||
if weathernext2_median is None:
|
||||
weathernext2_median = _sf(weathernext2_summary.get("mean"))
|
||||
if weathernext2_median is not None:
|
||||
current_forecasts["WeatherNext 2"] = weathernext2_median
|
||||
forecast_highs = [h for h in current_forecasts.values() if h is not None]
|
||||
forecast_high = max(forecast_highs) if forecast_highs else None
|
||||
forecast_median = (
|
||||
@@ -865,9 +920,12 @@ def analyze_weather_trend(
|
||||
# === Probability Engine ===
|
||||
probabilities: List[Dict[str, Any]] = []
|
||||
probabilities_all: List[Dict[str, Any]] = []
|
||||
probability_engine = "legacy"
|
||||
forecast_miss_deg = 0.0
|
||||
weathernext2_probs = _weathernext2_probability_payload(weather_data)
|
||||
|
||||
if is_dead_market:
|
||||
probability_engine = "dead_market"
|
||||
settled_wu = apply_city_settlement(city_name, max_so_far) if max_so_far is not None else 0
|
||||
dead_msg = (
|
||||
f"🎲 <b>结算预测</b>:已锁定 {settled_wu}{temp_symbol} "
|
||||
@@ -881,6 +939,24 @@ def analyze_weather_trend(
|
||||
{"value": settled_wu, "range": f"[{settled_wu-0.5}~{settled_wu+0.5})", "probability": 1.0}
|
||||
]
|
||||
probabilities_all = probabilities
|
||||
elif weathernext2_probs:
|
||||
if max_so_far is not None and forecast_median is not None:
|
||||
forecast_miss_deg = round(forecast_median - max_so_far, 1)
|
||||
probability_engine = "weathernext2"
|
||||
mu = weathernext2_probs.get("mu") or mu
|
||||
probabilities = weathernext2_probs.get("probabilities", [])
|
||||
probabilities_all = weathernext2_probs.get("probabilities_all", probabilities)
|
||||
prob_parts = []
|
||||
for bucket in probabilities[:4]:
|
||||
label = str(bucket.get("label") or bucket.get("range") or bucket.get("value") or "").strip()
|
||||
probability = _sf(bucket.get("probability"))
|
||||
if label and probability is not None:
|
||||
prob_parts.append(f"{label} {probability * 100:.0f}%")
|
||||
if prob_parts:
|
||||
mu_label = f"μ={mu:.1f}" if mu is not None else "μ=--"
|
||||
prob_str = " | ".join(prob_parts)
|
||||
insights.append(f"🎲 <b>WeatherNext 2 概率</b> ({mu_label}):{prob_str}")
|
||||
ai_features.append(f"🎲 WeatherNext 2 概率分布:{prob_str}")
|
||||
elif (ens_p10 is not None and ens_p90 is not None) or fallback_sigma:
|
||||
# Forecast miss magnitude
|
||||
if max_so_far is not None and forecast_median is not None:
|
||||
@@ -1117,7 +1193,7 @@ def analyze_weather_trend(
|
||||
"mu": mu,
|
||||
"probabilities": probabilities,
|
||||
"probabilities_all": probabilities_all or probabilities,
|
||||
"probability_engine": "legacy",
|
||||
"probability_engine": probability_engine,
|
||||
"trend_info": {
|
||||
"direction": trend_direction if 'trend_direction' in dir() else "unknown",
|
||||
"recent": recent_list,
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
from src.data_collection.weathernext2_sources import build_weathernext2_city_probability
|
||||
|
||||
|
||||
QUANTILES = {"q10": 0.10, "q50": 0.50, "q90": 0.90}
|
||||
FEATURE_NAMES = [
|
||||
"city_code",
|
||||
"wn2_mean",
|
||||
"wn2_median",
|
||||
"wn2_p10",
|
||||
"wn2_p25",
|
||||
"wn2_p75",
|
||||
"wn2_p90",
|
||||
"wn2_spread",
|
||||
"deb_prediction_c",
|
||||
"model_median_c",
|
||||
"model_spread",
|
||||
"current_max_so_far_c",
|
||||
"local_hour",
|
||||
"month",
|
||||
"day_of_year",
|
||||
"observation_progress",
|
||||
]
|
||||
|
||||
|
||||
def _sf(value: Any) -> Optional[float]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if math.isfinite(parsed) else None
|
||||
|
||||
|
||||
def _date_parts(value: Any) -> tuple[float, float]:
|
||||
text = str(value or "").strip()
|
||||
try:
|
||||
parsed = time.strptime(text[:10], "%Y-%m-%d")
|
||||
return float(parsed.tm_mon), float(parsed.tm_yday)
|
||||
except Exception:
|
||||
return 0.0, 0.0
|
||||
|
||||
|
||||
def _summary_from_record(record: Dict[str, Any]) -> Dict[str, Any]:
|
||||
wn2 = record.get("weathernext2") if isinstance(record.get("weathernext2"), dict) else {}
|
||||
summary = wn2.get("summary") if isinstance(wn2.get("summary"), dict) else {}
|
||||
return summary if isinstance(summary, dict) else {}
|
||||
|
||||
|
||||
def _city_key(value: Any) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def _build_city_index(records: Iterable[Dict[str, Any]]) -> Dict[str, int]:
|
||||
cities = sorted({_city_key(record.get("city")) for record in records if _city_key(record.get("city"))})
|
||||
return {city: idx for idx, city in enumerate(cities)}
|
||||
|
||||
|
||||
def _feature_row(record: Dict[str, Any], city_index: Dict[str, int]) -> Optional[list[float]]:
|
||||
summary = _summary_from_record(record)
|
||||
median = _sf(summary.get("median"))
|
||||
if median is None:
|
||||
return None
|
||||
target_date = record.get("target_date") or record.get("date")
|
||||
month, day_of_year = _date_parts(target_date)
|
||||
city = _city_key(record.get("city"))
|
||||
current = _sf(record.get("current_max_so_far_c"))
|
||||
progress = _sf(record.get("observation_progress"))
|
||||
if progress is None:
|
||||
local_hour = _sf(record.get("local_hour"))
|
||||
progress = min(max((local_hour or 0.0) / 24.0, 0.0), 1.0)
|
||||
|
||||
def fallback(name: str, default: float) -> float:
|
||||
parsed = _sf(summary.get(name))
|
||||
return default if parsed is None else parsed
|
||||
|
||||
return [
|
||||
float(city_index.get(city, -1)),
|
||||
fallback("mean", median),
|
||||
median,
|
||||
fallback("p10", median),
|
||||
fallback("p25", median),
|
||||
fallback("p75", median),
|
||||
fallback("p90", median),
|
||||
fallback("spread", 0.0),
|
||||
_sf(record.get("deb_prediction_c")) or median,
|
||||
_sf(record.get("model_median_c")) or median,
|
||||
_sf(record.get("model_spread")) or 0.0,
|
||||
current if current is not None else median,
|
||||
_sf(record.get("local_hour")) or 0.0,
|
||||
month,
|
||||
day_of_year,
|
||||
progress,
|
||||
]
|
||||
|
||||
|
||||
def _training_xy(
|
||||
records: Iterable[Dict[str, Any]],
|
||||
city_index: Dict[str, int],
|
||||
) -> tuple[list[list[float]], list[float], list[Dict[str, Any]]]:
|
||||
features: list[list[float]] = []
|
||||
residuals: list[float] = []
|
||||
kept: list[Dict[str, Any]] = []
|
||||
for record in records:
|
||||
summary = _summary_from_record(record)
|
||||
median = _sf(summary.get("median"))
|
||||
actual = _sf(record.get("actual_high_c", record.get("actual_high")))
|
||||
row = _feature_row(record, city_index)
|
||||
if median is None or actual is None or row is None:
|
||||
continue
|
||||
features.append(row)
|
||||
residuals.append(actual - median)
|
||||
kept.append(record)
|
||||
return features, residuals, kept
|
||||
|
||||
|
||||
def train_lightgbm_quantile_calibrator(
|
||||
records: Iterable[Dict[str, Any]],
|
||||
*,
|
||||
model_dir: os.PathLike[str] | str,
|
||||
min_global_samples: int = 150,
|
||||
min_city_samples: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
rows = [record for record in records if isinstance(record, dict)]
|
||||
city_index = _build_city_index(rows)
|
||||
features, residuals, kept = _training_xy(rows, city_index)
|
||||
if len(features) < int(min_global_samples):
|
||||
return {
|
||||
"trained": False,
|
||||
"reason": "insufficient_global_samples",
|
||||
"samples": len(features),
|
||||
}
|
||||
|
||||
city_counts: Dict[str, int] = {}
|
||||
for record in kept:
|
||||
city_counts[_city_key(record.get("city"))] = city_counts.get(_city_key(record.get("city")), 0) + 1
|
||||
if not any(count >= int(min_city_samples) for count in city_counts.values()):
|
||||
return {
|
||||
"trained": False,
|
||||
"reason": "insufficient_city_samples",
|
||||
"samples": len(features),
|
||||
}
|
||||
|
||||
try:
|
||||
import joblib # type: ignore
|
||||
from lightgbm import LGBMRegressor # type: ignore
|
||||
except Exception as exc:
|
||||
return {
|
||||
"trained": False,
|
||||
"reason": "missing_lightgbm",
|
||||
"samples": len(features),
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
target_dir = Path(model_dir)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
models = {}
|
||||
for key, alpha in QUANTILES.items():
|
||||
model = LGBMRegressor(
|
||||
objective="quantile",
|
||||
alpha=alpha,
|
||||
n_estimators=45,
|
||||
learning_rate=0.08,
|
||||
num_leaves=15,
|
||||
min_child_samples=5,
|
||||
random_state=42,
|
||||
n_jobs=2,
|
||||
verbosity=-1,
|
||||
)
|
||||
model.fit(features, residuals)
|
||||
models[key] = model
|
||||
joblib.dump(model, target_dir / f"{key}.pkl")
|
||||
|
||||
metadata = {
|
||||
"model_version": f"weathernext2_lightgbm_quantile_{int(time.time())}",
|
||||
"engine": "lightgbm_quantile",
|
||||
"samples": len(features),
|
||||
"feature_names": FEATURE_NAMES,
|
||||
"city_index": city_index,
|
||||
"city_counts": city_counts,
|
||||
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
(target_dir / "metadata.json").write_text(
|
||||
json.dumps(metadata, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
ordered = True
|
||||
for feature in features[: min(len(features), 50)]:
|
||||
preds = sorted(float(models[key].predict([feature])[0]) for key in ("q10", "q50", "q90"))
|
||||
if preds[0] > preds[1] or preds[1] > preds[2]:
|
||||
ordered = False
|
||||
break
|
||||
return {
|
||||
"trained": True,
|
||||
"samples": len(features),
|
||||
"model_dir": str(target_dir),
|
||||
"model_version": metadata["model_version"],
|
||||
"validation": {"ordered_quantiles": ordered},
|
||||
}
|
||||
|
||||
|
||||
def _load_model_bundle(model_dir: os.PathLike[str] | str) -> Optional[Dict[str, Any]]:
|
||||
target_dir = Path(model_dir)
|
||||
metadata_path = target_dir / "metadata.json"
|
||||
if not metadata_path.is_file():
|
||||
return None
|
||||
try:
|
||||
import joblib # type: ignore
|
||||
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
return {
|
||||
"metadata": metadata,
|
||||
"models": {
|
||||
key: joblib.load(target_dir / f"{key}.pkl")
|
||||
for key in QUANTILES
|
||||
},
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _predict_residual_quantiles(record: Dict[str, Any], bundle: Dict[str, Any]) -> Optional[Dict[str, float]]:
|
||||
metadata = bundle.get("metadata") or {}
|
||||
city_index = metadata.get("city_index") if isinstance(metadata.get("city_index"), dict) else {}
|
||||
feature = _feature_row(record, city_index)
|
||||
if feature is None:
|
||||
return None
|
||||
raw = {
|
||||
key: float(model.predict([feature])[0])
|
||||
for key, model in (bundle.get("models") or {}).items()
|
||||
}
|
||||
values = sorted([raw.get("q10", 0.0), raw.get("q50", 0.0), raw.get("q90", 0.0)])
|
||||
return {"q10": values[0], "q50": values[1], "q90": values[2]}
|
||||
|
||||
|
||||
def _calibrate_members(
|
||||
member_highs: Dict[str, Any],
|
||||
raw_summary: Dict[str, Any],
|
||||
residuals: Dict[str, float],
|
||||
) -> Dict[str, float]:
|
||||
raw_median = _sf(raw_summary.get("median"))
|
||||
raw_p10 = _sf(raw_summary.get("p10"))
|
||||
raw_p90 = _sf(raw_summary.get("p90"))
|
||||
if raw_median is None:
|
||||
return {}
|
||||
target_p10 = (raw_p10 if raw_p10 is not None else raw_median) + residuals["q10"]
|
||||
target_median = raw_median + residuals["q50"]
|
||||
target_p90 = (raw_p90 if raw_p90 is not None else raw_median) + residuals["q90"]
|
||||
|
||||
calibrated = {}
|
||||
for member_id, value in (member_highs or {}).items():
|
||||
parsed = _sf(value)
|
||||
if parsed is None:
|
||||
continue
|
||||
if parsed <= raw_median:
|
||||
raw_span = max(raw_median - (raw_p10 if raw_p10 is not None else raw_median), 0.1)
|
||||
target_span = max(target_median - target_p10, 0.1)
|
||||
adjusted = target_median - (raw_median - parsed) / raw_span * target_span
|
||||
else:
|
||||
raw_span = max((raw_p90 if raw_p90 is not None else raw_median) - raw_median, 0.1)
|
||||
target_span = max(target_p90 - target_median, 0.1)
|
||||
adjusted = target_median + (parsed - raw_median) / raw_span * target_span
|
||||
calibrated[str(member_id)] = round(adjusted, 1)
|
||||
return calibrated
|
||||
|
||||
|
||||
def apply_quantile_calibration_to_payload(
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
model_dir: os.PathLike[str] | str,
|
||||
) -> Dict[str, Any]:
|
||||
bundle = _load_model_bundle(model_dir)
|
||||
if not bundle:
|
||||
return dict(payload)
|
||||
raw_summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
|
||||
member_highs = payload.get("member_highs") if isinstance(payload.get("member_highs"), dict) else {}
|
||||
if not raw_summary or not member_highs:
|
||||
return dict(payload)
|
||||
|
||||
record = {
|
||||
"city": payload.get("city"),
|
||||
"target_date": payload.get("target_date"),
|
||||
"weathernext2": {"summary": raw_summary},
|
||||
}
|
||||
residuals = _predict_residual_quantiles(record, bundle)
|
||||
if residuals is None:
|
||||
return dict(payload)
|
||||
|
||||
calibrated_members = _calibrate_members(member_highs, raw_summary, residuals)
|
||||
if not calibrated_members:
|
||||
return dict(payload)
|
||||
|
||||
calibrated = build_weathernext2_city_probability(
|
||||
city=str(payload.get("city") or ""),
|
||||
member_highs=calibrated_members,
|
||||
temp_symbol=str(payload.get("temp_symbol") or "°C"),
|
||||
target_date=payload.get("target_date"),
|
||||
source_run=payload.get("source_run"),
|
||||
generated_at=payload.get("generated_at"),
|
||||
)
|
||||
metadata = bundle.get("metadata") or {}
|
||||
calibrated["calibration"] = {
|
||||
"engine": "lightgbm_quantile",
|
||||
"model_version": metadata.get("model_version"),
|
||||
"samples": metadata.get("samples"),
|
||||
"residual_quantiles": {key: round(value, 3) for key, value in residuals.items()},
|
||||
"raw_summary": raw_summary,
|
||||
"calibrated_summary": calibrated.get("summary"),
|
||||
}
|
||||
calibrated["raw_weathernext2"] = {
|
||||
"summary": raw_summary,
|
||||
"buckets": payload.get("buckets") or [],
|
||||
"top_bucket": payload.get("top_bucket"),
|
||||
}
|
||||
return calibrated
|
||||
@@ -18,6 +18,7 @@ from src.data_collection.metar_sources import MetarSourceMixin
|
||||
from src.data_collection.mgm_sources import MgmSourceMixin
|
||||
from src.data_collection.jma_amedas_sources import JmaAmedasSourceMixin
|
||||
from src.data_collection.nws_open_meteo_sources import NwsOpenMeteoSourceMixin
|
||||
from src.data_collection.weathernext2_sources import WeatherNext2SourceMixin
|
||||
from src.data_collection.amos_station_sources import AmosStationSourceMixin
|
||||
from src.data_collection.amsc_awos_sources import AmscAwosSourceMixin
|
||||
from src.data_collection.fmi_sources import FmiSourceMixin
|
||||
@@ -35,7 +36,7 @@ from src.data_collection.forecast_source_bundle import fetch_open_meteo_forecast
|
||||
from src.database.db_manager import DBManager
|
||||
|
||||
|
||||
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, AmscAwosSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin, CowinSourceMixin, MadisSourceMixin, SingaporeMssSourceMixin, ImsSourceMixin, NcmSourceMixin, AerowebSourceMixin, WundergroundHistoricalMixin):
|
||||
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, NwsOpenMeteoSourceMixin, WeatherNext2SourceMixin, AmosStationSourceMixin, AmscAwosSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin, CowinSourceMixin, MadisSourceMixin, SingaporeMssSourceMixin, ImsSourceMixin, NcmSourceMixin, AerowebSourceMixin, WundergroundHistoricalMixin):
|
||||
"""
|
||||
Multi-source weather data collector
|
||||
|
||||
@@ -189,9 +190,11 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
self._open_meteo_cache: Dict[str, Dict] = {}
|
||||
self._ensemble_cache: Dict[str, Dict] = {}
|
||||
self._multi_model_cache: Dict[str, Dict] = {}
|
||||
self._weathernext2_cache: Dict[str, Dict] = {}
|
||||
self._open_meteo_cache_lock = threading.Lock()
|
||||
self._ensemble_cache_lock = threading.Lock()
|
||||
self._multi_model_cache_lock = threading.Lock()
|
||||
self._weathernext2_cache_lock = threading.Lock()
|
||||
# Open-Meteo 共享 429 冷却计时器:触发限流后所有 OM 端点暂停请求
|
||||
self._open_meteo_rate_limit_until: float = 0.0
|
||||
self._open_meteo_rl_cooldown: int = int(
|
||||
@@ -1740,6 +1743,26 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
if multi_model_data:
|
||||
results["multi_model"] = multi_model_data
|
||||
|
||||
def _attach_weathernext2_model(
|
||||
self,
|
||||
results: Dict,
|
||||
city: str,
|
||||
lat: float,
|
||||
lon: float,
|
||||
use_fahrenheit: bool,
|
||||
*,
|
||||
timezone_offset_seconds: Optional[int] = None,
|
||||
) -> None:
|
||||
payload = self.fetch_weathernext2_probability(
|
||||
city,
|
||||
lat,
|
||||
lon,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
timezone_offset_seconds=timezone_offset_seconds,
|
||||
)
|
||||
if payload:
|
||||
results["weathernext2"] = payload
|
||||
|
||||
def fetch_all_sources(
|
||||
self,
|
||||
city: str,
|
||||
@@ -1794,6 +1817,14 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
results["open-meteo"] = open_meteo
|
||||
# 获取时区偏移以过滤 METAR
|
||||
utc_offset = open_meteo.get("utc_offset", 0)
|
||||
self._attach_weathernext2_model(
|
||||
results,
|
||||
city_lower,
|
||||
lat,
|
||||
lon,
|
||||
use_fahrenheit,
|
||||
timezone_offset_seconds=utc_offset,
|
||||
)
|
||||
if supports_aviationweather:
|
||||
metar_data = self.fetch_metar(
|
||||
city, use_fahrenheit=use_fahrenheit, utc_offset=utc_offset
|
||||
@@ -1844,6 +1875,14 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
fallback_utc_offset = int(
|
||||
self.CITY_REGISTRY.get(city_lower, {}).get("tz_offset", 0)
|
||||
)
|
||||
self._attach_weathernext2_model(
|
||||
results,
|
||||
city_lower,
|
||||
lat,
|
||||
lon,
|
||||
use_fahrenheit,
|
||||
timezone_offset_seconds=fallback_utc_offset,
|
||||
)
|
||||
if supports_aviationweather:
|
||||
metar_data = self.fetch_metar(
|
||||
city,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Mapping, Optional, Sequence
|
||||
|
||||
|
||||
TEMPERATURE_VARIABLE_CANDIDATES = (
|
||||
"temperature_2m",
|
||||
"2m_temperature",
|
||||
"t2m",
|
||||
"air_temperature_2m",
|
||||
"temperature",
|
||||
)
|
||||
|
||||
|
||||
def _mapping_keys(dataset: Any) -> list[str]:
|
||||
if isinstance(dataset, Mapping):
|
||||
return [str(key) for key in dataset.keys()]
|
||||
data_vars = getattr(dataset, "data_vars", None)
|
||||
if data_vars is not None:
|
||||
try:
|
||||
return [str(key) for key in data_vars.keys()]
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def select_temperature_variable(dataset: Any, preferred: Optional[str] = None) -> str:
|
||||
if preferred:
|
||||
keys = set(_mapping_keys(dataset))
|
||||
if preferred in keys:
|
||||
return preferred
|
||||
raise KeyError(f"WeatherNext2 temperature variable not found: {preferred}")
|
||||
|
||||
keys = _mapping_keys(dataset)
|
||||
lowered = {key.lower(): key for key in keys}
|
||||
for candidate in TEMPERATURE_VARIABLE_CANDIDATES:
|
||||
if candidate.lower() in lowered:
|
||||
return lowered[candidate.lower()]
|
||||
|
||||
for key in keys:
|
||||
normalized = key.lower().replace("-", "_")
|
||||
if "temperature" in normalized and ("2m" in normalized or "two_meter" in normalized):
|
||||
return key
|
||||
raise KeyError("WeatherNext2 2m temperature variable was not found")
|
||||
|
||||
|
||||
def normalize_temperature_value(
|
||||
value: Any,
|
||||
units: str = "",
|
||||
*,
|
||||
use_fahrenheit: bool = False,
|
||||
) -> Optional[float]:
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(parsed):
|
||||
return None
|
||||
|
||||
unit = str(units or "").strip().lower()
|
||||
if unit in {"k", "kelvin"} or parsed > 150:
|
||||
celsius = parsed - 273.15
|
||||
elif unit in {"f", "fahrenheit", "degf", "degree_fahrenheit"}:
|
||||
celsius = (parsed - 32.0) * 5.0 / 9.0
|
||||
else:
|
||||
celsius = parsed
|
||||
|
||||
if use_fahrenheit:
|
||||
return round(celsius * 9.0 / 5.0 + 32.0, 1)
|
||||
return round(celsius, 1)
|
||||
|
||||
|
||||
def _get_dataset_value(dataset: Any, key: str) -> Any:
|
||||
if isinstance(dataset, Mapping):
|
||||
return dataset[key]
|
||||
return dataset[key]
|
||||
|
||||
|
||||
def _as_list(values: Any) -> list[Any]:
|
||||
if hasattr(values, "values"):
|
||||
try:
|
||||
values = values.values
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(values, "tolist"):
|
||||
try:
|
||||
return values.tolist()
|
||||
except Exception:
|
||||
pass
|
||||
return list(values or [])
|
||||
|
||||
|
||||
def _nearest_index(values: Sequence[Any], target: float) -> int:
|
||||
numeric_values = [float(value) for value in values]
|
||||
return min(range(len(numeric_values)), key=lambda idx: abs(numeric_values[idx] - target))
|
||||
|
||||
|
||||
def _variable_units(dataset: Any, temp_var: str) -> str:
|
||||
if isinstance(dataset, Mapping):
|
||||
units = dataset.get("units")
|
||||
if isinstance(units, Mapping):
|
||||
return str(units.get(temp_var) or "")
|
||||
return ""
|
||||
variable = dataset[temp_var]
|
||||
attrs = getattr(variable, "attrs", {}) or {}
|
||||
return str(attrs.get("units") or attrs.get("unit") or "")
|
||||
|
||||
|
||||
def _variable_values(dataset: Any, temp_var: str) -> Any:
|
||||
variable = _get_dataset_value(dataset, temp_var)
|
||||
if hasattr(variable, "values"):
|
||||
return variable.values
|
||||
return variable
|
||||
|
||||
|
||||
def _index_nested(values: Any, indexes: Sequence[int]) -> Any:
|
||||
current = values
|
||||
for idx in indexes:
|
||||
current = current[idx]
|
||||
return current
|
||||
|
||||
|
||||
def _dataset_dimension_values(dataset: Any, candidates: Sequence[str]) -> tuple[str, list[Any]]:
|
||||
keys = _mapping_keys(dataset)
|
||||
lowered = {key.lower(): key for key in keys}
|
||||
for candidate in candidates:
|
||||
key = lowered.get(candidate.lower())
|
||||
if key is not None:
|
||||
return key, _as_list(_get_dataset_value(dataset, key))
|
||||
|
||||
coords = getattr(dataset, "coords", None)
|
||||
if coords is not None:
|
||||
for candidate in candidates:
|
||||
if candidate in coords:
|
||||
return candidate, _as_list(coords[candidate])
|
||||
raise KeyError(f"WeatherNext2 dimension not found: {candidates}")
|
||||
|
||||
|
||||
def _parse_time(value: Any) -> str:
|
||||
if isinstance(value, datetime):
|
||||
dt = value
|
||||
else:
|
||||
text = str(value or "").strip()
|
||||
if text.endswith("Z"):
|
||||
text = f"{text[:-1]}+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return str(value)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def extract_member_hourly_from_grid_dataset(
|
||||
dataset: Any,
|
||||
*,
|
||||
lat: float,
|
||||
lon: float,
|
||||
temp_var: Optional[str] = None,
|
||||
use_fahrenheit: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract all member hourly temperatures for the nearest grid point."""
|
||||
selected_var = select_temperature_variable(dataset, temp_var or os.getenv("WEATHERNEXT2_TEMP_VAR") or None)
|
||||
lat_name, lat_values = _dataset_dimension_values(dataset, ("lat", "latitude"))
|
||||
lon_name, lon_values = _dataset_dimension_values(dataset, ("lon", "longitude"))
|
||||
member_name, member_values = _dataset_dimension_values(dataset, ("member", "realization", "ensemble_member"))
|
||||
time_name, time_values = _dataset_dimension_values(dataset, ("time", "valid_time"))
|
||||
|
||||
lat_idx = _nearest_index(lat_values, lat)
|
||||
lon_idx = _nearest_index(lon_values, lon)
|
||||
units = _variable_units(dataset, selected_var)
|
||||
|
||||
variable = _get_dataset_value(dataset, selected_var)
|
||||
dims = list(getattr(variable, "dims", []) or [member_name, time_name, lat_name, lon_name])
|
||||
values = _variable_values(dataset, selected_var)
|
||||
dim_indexes = {
|
||||
member_name: None,
|
||||
time_name: None,
|
||||
lat_name: lat_idx,
|
||||
lon_name: lon_idx,
|
||||
}
|
||||
|
||||
member_hourly: Dict[str, list[Optional[float]]] = {}
|
||||
for member_idx, member in enumerate(member_values):
|
||||
hourly = []
|
||||
for time_idx, _time_value in enumerate(time_values):
|
||||
dim_indexes[member_name] = member_idx
|
||||
dim_indexes[time_name] = time_idx
|
||||
indexes = [int(dim_indexes[dim]) for dim in dims]
|
||||
hourly.append(
|
||||
normalize_temperature_value(
|
||||
_index_nested(values, indexes),
|
||||
units,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
)
|
||||
try:
|
||||
member_label = f"member_{int(member):02d}"
|
||||
except Exception:
|
||||
member_label = f"member_{member_idx:02d}"
|
||||
member_hourly[member_label] = hourly
|
||||
|
||||
return {
|
||||
"temp_var": selected_var,
|
||||
"units": units,
|
||||
"lat_dim": lat_name,
|
||||
"lon_dim": lon_name,
|
||||
"member_dim": member_name,
|
||||
"time_dim": time_name,
|
||||
"nearest_lat": float(lat_values[lat_idx]),
|
||||
"nearest_lon": float(lon_values[lon_idx]),
|
||||
"utc_times": [_parse_time(value) for value in time_values],
|
||||
"member_hourly": member_hourly,
|
||||
}
|
||||
|
||||
|
||||
def open_weathernext2_zarr_dataset(uri: str):
|
||||
"""Open a WeatherNext 2 Zarr dataset lazily with optional runtime dependencies."""
|
||||
try:
|
||||
import xarray as xr # type: ignore
|
||||
except Exception as exc: # pragma: no cover - depends on deployment extras
|
||||
raise RuntimeError("xarray is required for WeatherNext2 GCS/Zarr access") from exc
|
||||
|
||||
storage_options = {}
|
||||
credentials = str(os.getenv("GOOGLE_APPLICATION_CREDENTIALS", "") or "").strip()
|
||||
if credentials:
|
||||
storage_options["token"] = "google_default"
|
||||
try:
|
||||
return xr.open_zarr(
|
||||
uri,
|
||||
storage_options=storage_options or None,
|
||||
consolidated=True,
|
||||
)
|
||||
except Exception:
|
||||
return xr.open_zarr(
|
||||
uri,
|
||||
storage_options=storage_options or None,
|
||||
consolidated=False,
|
||||
)
|
||||
@@ -0,0 +1,581 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Union
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
WEATHERNEXT2_SOURCE = "weathernext2"
|
||||
WEATHERNEXT2_PROVIDER = "google_deepmind"
|
||||
WEATHERNEXT2_GCS_ZARR_URI = "gs://weathernext/weathernext_2_0_0/zarr"
|
||||
WEATHERNEXT2_MEAN_GCS_ZARR_URI = "gs://weathernext/weathernext_2_0_0_mean/zarr"
|
||||
|
||||
Number = Union[int, float]
|
||||
|
||||
|
||||
def _numeric(value: Any) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if math.isfinite(parsed) else None
|
||||
|
||||
|
||||
def _round1(value: Number) -> float:
|
||||
return round(float(value), 1)
|
||||
|
||||
|
||||
def _round3(value: Number) -> float:
|
||||
return round(float(value), 3)
|
||||
|
||||
|
||||
def _settle_integer(value: Number) -> int:
|
||||
parsed = float(value)
|
||||
if parsed >= 0:
|
||||
return int(math.floor(parsed + 0.5))
|
||||
return int(math.ceil(parsed - 0.5))
|
||||
|
||||
|
||||
def _is_fahrenheit(temp_symbol: str) -> bool:
|
||||
return "F" in str(temp_symbol or "").upper()
|
||||
|
||||
|
||||
def _unit(temp_symbol: str) -> str:
|
||||
return "°F" if _is_fahrenheit(temp_symbol) else "°C"
|
||||
|
||||
|
||||
def market_bucket_for_temperature(value: Number, temp_symbol: str = "°C") -> Dict[str, Any]:
|
||||
"""Return the tradable market option bucket for a single member temperature."""
|
||||
unit = _unit(temp_symbol)
|
||||
settled = _settle_integer(value)
|
||||
if _is_fahrenheit(unit):
|
||||
lower_value = settled if settled % 2 == 0 else settled - 1
|
||||
upper_value = lower_value + 1
|
||||
return {
|
||||
"key": f"{lower_value}-{upper_value}{unit}",
|
||||
"label": f"{lower_value}-{upper_value}{unit}",
|
||||
"lower": float(lower_value) - 0.5,
|
||||
"upper": float(upper_value) + 0.5,
|
||||
"sort_value": float(lower_value),
|
||||
}
|
||||
return {
|
||||
"key": f"{settled}{unit}",
|
||||
"label": f"{settled}{unit}",
|
||||
"lower": float(settled) - 0.5,
|
||||
"upper": float(settled) + 0.5,
|
||||
"sort_value": float(settled),
|
||||
}
|
||||
|
||||
|
||||
def _member_items(member_highs: Union[Mapping[str, Any], Sequence[Any]]) -> Iterable[tuple[str, float]]:
|
||||
if isinstance(member_highs, Mapping):
|
||||
iterable = member_highs.items()
|
||||
else:
|
||||
iterable = ((f"member_{idx:02d}", value) for idx, value in enumerate(member_highs))
|
||||
for member_id, value in iterable:
|
||||
parsed = _numeric(value)
|
||||
if parsed is not None:
|
||||
yield str(member_id), parsed
|
||||
|
||||
|
||||
def summarize_member_highs(member_highs: Union[Mapping[str, Any], Sequence[Any]]) -> Dict[str, Any]:
|
||||
values = sorted(value for _member_id, value in _member_items(member_highs))
|
||||
if not values:
|
||||
return {
|
||||
"members": 0,
|
||||
"mean": None,
|
||||
"median": None,
|
||||
"p10": None,
|
||||
"p25": None,
|
||||
"p75": None,
|
||||
"p90": None,
|
||||
"min": None,
|
||||
"max": None,
|
||||
"spread": None,
|
||||
}
|
||||
|
||||
def percentile(q: float) -> float:
|
||||
if len(values) == 1:
|
||||
return values[0]
|
||||
position = (len(values) - 1) * q
|
||||
lower_idx = int(math.floor(position))
|
||||
upper_idx = int(math.ceil(position))
|
||||
if lower_idx == upper_idx:
|
||||
return values[lower_idx]
|
||||
weight = position - lower_idx
|
||||
return values[lower_idx] * (1 - weight) + values[upper_idx] * weight
|
||||
|
||||
return {
|
||||
"members": len(values),
|
||||
"mean": _round1(sum(values) / len(values)),
|
||||
"median": _round1(percentile(0.5)),
|
||||
"p10": _round1(percentile(0.1)),
|
||||
"p25": _round1(percentile(0.25)),
|
||||
"p75": _round1(percentile(0.75)),
|
||||
"p90": _round1(percentile(0.9)),
|
||||
"min": _round1(values[0]),
|
||||
"max": _round1(values[-1]),
|
||||
"spread": _round1(values[-1] - values[0]),
|
||||
}
|
||||
|
||||
|
||||
def build_market_bucket_probabilities(
|
||||
member_highs: Union[Mapping[str, Any], Sequence[Any]],
|
||||
temp_symbol: str = "°C",
|
||||
) -> list[Dict[str, Any]]:
|
||||
grouped: Dict[str, Dict[str, Any]] = {}
|
||||
total_members = 0
|
||||
|
||||
for _member_id, value in _member_items(member_highs):
|
||||
total_members += 1
|
||||
option = market_bucket_for_temperature(value, temp_symbol)
|
||||
bucket = grouped.setdefault(
|
||||
option["key"],
|
||||
{
|
||||
"key": option["key"],
|
||||
"label": option["label"],
|
||||
"lower": option["lower"],
|
||||
"upper": option["upper"],
|
||||
"sort_value": option["sort_value"],
|
||||
"weighted_sum": 0.0,
|
||||
"member_count": 0,
|
||||
},
|
||||
)
|
||||
bucket["weighted_sum"] += value
|
||||
bucket["member_count"] += 1
|
||||
|
||||
if total_members <= 0:
|
||||
return []
|
||||
|
||||
buckets = []
|
||||
for bucket in grouped.values():
|
||||
member_count = int(bucket["member_count"])
|
||||
buckets.append(
|
||||
{
|
||||
"key": bucket["key"],
|
||||
"label": bucket["label"],
|
||||
"lower": bucket["lower"],
|
||||
"upper": bucket["upper"],
|
||||
"value": _round1(bucket["weighted_sum"] / member_count),
|
||||
"probability": _round3(member_count / total_members),
|
||||
"member_count": member_count,
|
||||
"total_members": total_members,
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(buckets, key=lambda item: (float(item["lower"]), float(item["upper"])))
|
||||
|
||||
|
||||
def _parse_utc_time(value: Any) -> Optional[datetime]:
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
else:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith("Z"):
|
||||
text = f"{text[:-1]}+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _parse_date(value: Any) -> Optional[date]:
|
||||
if isinstance(value, date) and not isinstance(value, datetime):
|
||||
return value
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(text[:10]).date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def build_city_local_daily_highs_from_hourly(
|
||||
member_hourly: Mapping[str, Sequence[Any]],
|
||||
utc_times: Sequence[Any],
|
||||
timezone_offset_seconds: int,
|
||||
target_local_date: Any,
|
||||
) -> Dict[str, float]:
|
||||
"""Reduce hourly ensemble member temperatures to local-date daily highs."""
|
||||
target_date = _parse_date(target_local_date)
|
||||
if target_date is None:
|
||||
return {}
|
||||
|
||||
parsed_times = [_parse_utc_time(value) for value in utc_times]
|
||||
highs: Dict[str, float] = {}
|
||||
offset = timedelta(seconds=int(timezone_offset_seconds or 0))
|
||||
|
||||
for member_id, values in member_hourly.items():
|
||||
member_values = []
|
||||
for idx, utc_time in enumerate(parsed_times):
|
||||
if utc_time is None or idx >= len(values):
|
||||
continue
|
||||
local_date = (utc_time + offset).date()
|
||||
if local_date != target_date:
|
||||
continue
|
||||
parsed = _numeric(values[idx])
|
||||
if parsed is not None:
|
||||
member_values.append(parsed)
|
||||
if member_values:
|
||||
highs[str(member_id)] = _round1(max(member_values))
|
||||
|
||||
return highs
|
||||
|
||||
|
||||
def build_weathernext2_city_probability(
|
||||
*,
|
||||
city: str,
|
||||
member_highs: Union[Mapping[str, Any], Sequence[Any]],
|
||||
temp_symbol: str = "°C",
|
||||
target_date: Optional[str] = None,
|
||||
source_run: Optional[str] = None,
|
||||
generated_at: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a WeatherNext 2 probability payload aligned with tradable market options."""
|
||||
buckets = build_market_bucket_probabilities(member_highs, temp_symbol=temp_symbol)
|
||||
summary = summarize_member_highs(member_highs)
|
||||
normalized_member_highs = {
|
||||
member_id: _round1(value)
|
||||
for member_id, value in _member_items(member_highs)
|
||||
}
|
||||
top_bucket = (
|
||||
max(buckets, key=lambda item: (float(item["probability"]), float(item["lower"])))
|
||||
if buckets
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"source": WEATHERNEXT2_SOURCE,
|
||||
"provider": WEATHERNEXT2_PROVIDER,
|
||||
"city": str(city or "").strip(),
|
||||
"target_date": target_date,
|
||||
"source_run": source_run,
|
||||
"generated_at": generated_at or datetime.now(timezone.utc).isoformat(),
|
||||
"temp_symbol": _unit(temp_symbol),
|
||||
"members": int(summary["members"] or 0),
|
||||
"member_highs": normalized_member_highs,
|
||||
"summary": summary,
|
||||
"buckets": buckets,
|
||||
"top_bucket": top_bucket,
|
||||
"bucket_policy": "celsius_single_fahrenheit_two_degree_market_options",
|
||||
"gcs_zarr_uri": os.getenv("WEATHERNEXT2_GCS_ZARR_URI", WEATHERNEXT2_GCS_ZARR_URI),
|
||||
"mean_gcs_zarr_uri": os.getenv("WEATHERNEXT2_MEAN_GCS_ZARR_URI", WEATHERNEXT2_MEAN_GCS_ZARR_URI),
|
||||
}
|
||||
|
||||
|
||||
def _env_enabled(name: str, default: str = "0") -> bool:
|
||||
return str(os.getenv(name, default) or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
def _city_key(city: str) -> str:
|
||||
return str(city or "").strip().lower()
|
||||
|
||||
|
||||
def _load_fixture_payload(path: str, city: str) -> Optional[Dict[str, Any]]:
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
payload = json.load(fh)
|
||||
except Exception as exc:
|
||||
logger.warning("WeatherNext2 fixture load failed path={}: {}", path, exc)
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
key = _city_key(city)
|
||||
if isinstance(payload.get(key), dict):
|
||||
return dict(payload[key])
|
||||
for candidate, value in payload.items():
|
||||
if _city_key(candidate) == key and isinstance(value, dict):
|
||||
return dict(value)
|
||||
if "member_highs" in payload or "member_hourly" in payload:
|
||||
return dict(payload)
|
||||
return None
|
||||
|
||||
|
||||
def _weathernext2_data_root() -> str:
|
||||
return str(os.getenv("WEATHERNEXT2_DATA_ROOT", "/app/data/weathernext2") or "").strip()
|
||||
|
||||
|
||||
def _weathernext2_artifact_path() -> str:
|
||||
configured = str(os.getenv("WEATHERNEXT2_CITY_HIGHS_PATH", "") or "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
return os.path.join(_weathernext2_data_root(), "weathernext2_city_highs.json")
|
||||
|
||||
|
||||
def _weathernext2_model_dir() -> str:
|
||||
return str(
|
||||
os.getenv(
|
||||
"WEATHERNEXT2_MODEL_DIR",
|
||||
"/app/data/models/weathernext2_calibrator",
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _load_artifact_payload(path: str, city: str) -> Optional[Dict[str, Any]]:
|
||||
if not path or not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
payload = json.load(fh)
|
||||
except Exception as exc:
|
||||
logger.warning("WeatherNext2 artifact load failed path={}: {}", path, exc)
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
key = _city_key(city)
|
||||
city_payloads: Any = payload.get("cities")
|
||||
if isinstance(city_payloads, dict):
|
||||
if isinstance(city_payloads.get(key), dict):
|
||||
return dict(city_payloads[key])
|
||||
for candidate, value in city_payloads.items():
|
||||
if _city_key(candidate) == key and isinstance(value, dict):
|
||||
return dict(value)
|
||||
if isinstance(city_payloads, list):
|
||||
for value in city_payloads:
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
if _city_key(value.get("city")) == key:
|
||||
return dict(value)
|
||||
|
||||
if isinstance(payload.get(key), dict):
|
||||
return dict(payload[key])
|
||||
for candidate, value in payload.items():
|
||||
if _city_key(candidate) == key and isinstance(value, dict):
|
||||
return dict(value)
|
||||
return None
|
||||
|
||||
|
||||
class WeatherNext2SourceMixin:
|
||||
"""Optional WeatherNext 2 probability source.
|
||||
|
||||
The live Google datasets require project access and optional heavy client
|
||||
dependencies. This mixin keeps production safe by supporting a fixture or
|
||||
prepared payload first, while reserving backend configuration for the real
|
||||
GCS/BigQuery fetcher.
|
||||
"""
|
||||
|
||||
def _weathernext2_enabled(self) -> bool:
|
||||
return _env_enabled("WEATHERNEXT2_ENABLED")
|
||||
|
||||
def _weathernext2_cache_key(
|
||||
self,
|
||||
city: str,
|
||||
lat: float,
|
||||
lon: float,
|
||||
use_fahrenheit: bool,
|
||||
target_date: Optional[str],
|
||||
) -> str:
|
||||
return (
|
||||
f"{_city_key(city)}:{round(float(lat), 4)}:{round(float(lon), 4)}:"
|
||||
f"{'f' if use_fahrenheit else 'c'}:{target_date or ''}"
|
||||
)
|
||||
|
||||
def _weathernext2_cache_state(self) -> tuple[Dict[str, Dict[str, Any]], threading.Lock, int]:
|
||||
if not hasattr(self, "_weathernext2_cache"):
|
||||
self._weathernext2_cache = {}
|
||||
if not hasattr(self, "_weathernext2_cache_lock"):
|
||||
self._weathernext2_cache_lock = threading.Lock()
|
||||
ttl_sec = int(os.getenv("WEATHERNEXT2_CACHE_TTL_SEC", "21600"))
|
||||
return self._weathernext2_cache, self._weathernext2_cache_lock, ttl_sec
|
||||
|
||||
def _weathernext2_target_date(
|
||||
self,
|
||||
timezone_offset_seconds: Optional[int],
|
||||
target_date: Optional[str],
|
||||
) -> str:
|
||||
if target_date:
|
||||
return str(target_date)
|
||||
offset = timedelta(seconds=int(timezone_offset_seconds or 0))
|
||||
return (datetime.now(timezone.utc) + offset).date().isoformat()
|
||||
|
||||
def _weathernext2_from_fixture(
|
||||
self,
|
||||
*,
|
||||
city: str,
|
||||
use_fahrenheit: bool,
|
||||
target_date: str,
|
||||
timezone_offset_seconds: Optional[int],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
fixture_path = str(os.getenv("WEATHERNEXT2_FIXTURE_PATH", "") or "").strip()
|
||||
fixture = _load_fixture_payload(fixture_path, city)
|
||||
if not fixture:
|
||||
return None
|
||||
|
||||
temp_symbol = "°F" if use_fahrenheit else "°C"
|
||||
fixture_target_date = str(fixture.get("target_date") or target_date)
|
||||
member_highs = fixture.get("member_highs")
|
||||
if member_highs is None and isinstance(fixture.get("member_hourly"), dict):
|
||||
member_highs = build_city_local_daily_highs_from_hourly(
|
||||
fixture["member_hourly"],
|
||||
fixture.get("utc_times") or fixture.get("times") or [],
|
||||
int(timezone_offset_seconds or fixture.get("timezone_offset_seconds") or 0),
|
||||
fixture_target_date,
|
||||
)
|
||||
if member_highs is None:
|
||||
return None
|
||||
|
||||
return build_weathernext2_city_probability(
|
||||
city=city,
|
||||
member_highs=member_highs,
|
||||
temp_symbol=temp_symbol,
|
||||
target_date=fixture_target_date,
|
||||
source_run=fixture.get("source_run"),
|
||||
generated_at=fixture.get("generated_at"),
|
||||
)
|
||||
|
||||
def _weathernext2_normalize_prepared_payload(
|
||||
self,
|
||||
*,
|
||||
payload: Mapping[str, Any],
|
||||
city: str,
|
||||
use_fahrenheit: bool,
|
||||
target_date: str,
|
||||
timezone_offset_seconds: Optional[int],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
temp_symbol = "°F" if use_fahrenheit else "°C"
|
||||
payload_target_date = str(payload.get("target_date") or target_date)
|
||||
member_highs = payload.get("member_highs")
|
||||
if member_highs is None and isinstance(payload.get("member_hourly"), dict):
|
||||
member_highs = build_city_local_daily_highs_from_hourly(
|
||||
payload["member_hourly"],
|
||||
payload.get("utc_times") or payload.get("times") or [],
|
||||
int(timezone_offset_seconds or payload.get("timezone_offset_seconds") or 0),
|
||||
payload_target_date,
|
||||
)
|
||||
if member_highs is None and isinstance(payload.get("buckets"), list):
|
||||
normalized = dict(payload)
|
||||
normalized.setdefault("source", WEATHERNEXT2_SOURCE)
|
||||
normalized.setdefault("provider", WEATHERNEXT2_PROVIDER)
|
||||
normalized.setdefault("city", city)
|
||||
normalized.setdefault("target_date", payload_target_date)
|
||||
normalized.setdefault("temp_symbol", temp_symbol)
|
||||
normalized.setdefault(
|
||||
"gcs_zarr_uri",
|
||||
os.getenv("WEATHERNEXT2_GCS_ZARR_URI", WEATHERNEXT2_GCS_ZARR_URI),
|
||||
)
|
||||
return normalized
|
||||
if member_highs is None:
|
||||
return None
|
||||
return build_weathernext2_city_probability(
|
||||
city=city,
|
||||
member_highs=member_highs,
|
||||
temp_symbol=temp_symbol,
|
||||
target_date=payload_target_date,
|
||||
source_run=payload.get("source_run"),
|
||||
generated_at=payload.get("generated_at"),
|
||||
)
|
||||
|
||||
def _weathernext2_from_artifact(
|
||||
self,
|
||||
*,
|
||||
city: str,
|
||||
use_fahrenheit: bool,
|
||||
target_date: str,
|
||||
timezone_offset_seconds: Optional[int],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
artifact = _load_artifact_payload(_weathernext2_artifact_path(), city)
|
||||
if not artifact:
|
||||
return None
|
||||
payload = self._weathernext2_normalize_prepared_payload(
|
||||
payload=artifact,
|
||||
city=city,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
target_date=target_date,
|
||||
timezone_offset_seconds=timezone_offset_seconds,
|
||||
)
|
||||
if payload is None:
|
||||
return None
|
||||
try:
|
||||
from src.analysis.weathernext2_calibration import apply_quantile_calibration_to_payload
|
||||
|
||||
return apply_quantile_calibration_to_payload(
|
||||
payload,
|
||||
model_dir=_weathernext2_model_dir(),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("WeatherNext2 calibration apply failed city={}: {}", city, exc)
|
||||
return payload
|
||||
|
||||
def fetch_weathernext2_probability(
|
||||
self,
|
||||
city: str,
|
||||
lat: float,
|
||||
lon: float,
|
||||
*,
|
||||
use_fahrenheit: bool = False,
|
||||
target_date: Optional[str] = None,
|
||||
timezone_offset_seconds: Optional[int] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self._weathernext2_enabled():
|
||||
return None
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
|
||||
resolved_target_date = self._weathernext2_target_date(
|
||||
timezone_offset_seconds,
|
||||
target_date,
|
||||
)
|
||||
cache_key = self._weathernext2_cache_key(
|
||||
city,
|
||||
lat,
|
||||
lon,
|
||||
use_fahrenheit,
|
||||
resolved_target_date,
|
||||
)
|
||||
cache, lock, ttl_sec = self._weathernext2_cache_state()
|
||||
now_ts = time.time()
|
||||
with lock:
|
||||
cached = cache.get(cache_key)
|
||||
cached_data = cached.get("data") if isinstance(cached, dict) else None
|
||||
if isinstance(cached_data, dict) and now_ts - float(cached.get("t", 0)) < ttl_sec:
|
||||
return dict(cached_data)
|
||||
|
||||
payload = self._weathernext2_from_artifact(
|
||||
city=city,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
target_date=resolved_target_date,
|
||||
timezone_offset_seconds=timezone_offset_seconds,
|
||||
)
|
||||
if payload is None:
|
||||
payload = self._weathernext2_from_fixture(
|
||||
city=city,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
target_date=resolved_target_date,
|
||||
timezone_offset_seconds=timezone_offset_seconds,
|
||||
)
|
||||
if payload is None:
|
||||
backend = str(os.getenv("WEATHERNEXT2_BACKEND", "") or "").strip().lower()
|
||||
if backend:
|
||||
logger.warning(
|
||||
"WeatherNext2 backend={} configured but live fetcher is not installed; use fixture/prepared payload first",
|
||||
backend,
|
||||
)
|
||||
return None
|
||||
|
||||
with lock:
|
||||
cache[cache_key] = {"t": now_ts, "data": dict(payload)}
|
||||
return payload
|
||||
@@ -100,6 +100,13 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
|
||||
training_settlement_block = compose.split(
|
||||
" polyweather_training_settlement:",
|
||||
1,
|
||||
)[1].split(
|
||||
"\n polyweather_weathernext2_worker:",
|
||||
1,
|
||||
)[0]
|
||||
weathernext2_block = compose.split(
|
||||
" polyweather_weathernext2_worker:",
|
||||
1,
|
||||
)[1].split(
|
||||
"\nx-polyweather-base:",
|
||||
1,
|
||||
@@ -110,6 +117,7 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
|
||||
assert "POLYWEATHER_SERVICE_ROLE: collector" in collector_block
|
||||
assert "POLYWEATHER_SERVICE_ROLE: warmer" in warmer_block
|
||||
assert "POLYWEATHER_SERVICE_ROLE: training_settlement" in training_settlement_block
|
||||
assert "POLYWEATHER_SERVICE_ROLE: weathernext2_worker" in weathernext2_block
|
||||
assert "redis-server --appendonly yes --maxmemory ${POLYWEATHER_REDIS_MAXMEMORY:-512mb} --maxmemory-policy noeviction" in compose
|
||||
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in bot_block
|
||||
assert "POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}" in web_block
|
||||
@@ -126,7 +134,9 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
|
||||
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'" in collector_block
|
||||
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in warmer_block
|
||||
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in training_settlement_block
|
||||
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in weathernext2_block
|
||||
assert "command: python -m web.training_settlement_worker" in training_settlement_block
|
||||
assert "command: python -m web.weathernext2_worker" in weathernext2_block
|
||||
assert (
|
||||
"POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC: "
|
||||
"${POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC:-21600}"
|
||||
@@ -142,6 +152,25 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
|
||||
assert "POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS:-3000}" in web_block
|
||||
assert "POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-8000}" in web_block
|
||||
assert "UVICORN_WORKERS: ${UVICORN_WORKERS:-2}" in web_block
|
||||
assert "WEATHERNEXT2_ENABLED: ${WEATHERNEXT2_ENABLED:-1}" in web_block
|
||||
assert "WEATHERNEXT2_ENABLED: ${WEATHERNEXT2_ENABLED:-1}" in weathernext2_block
|
||||
assert "WEATHERNEXT2_BACKEND: ${WEATHERNEXT2_BACKEND:-gcs_zarr}" in weathernext2_block
|
||||
assert (
|
||||
"WEATHERNEXT2_GCS_ZARR_URI: "
|
||||
"${WEATHERNEXT2_GCS_ZARR_URI:-gs://weathernext/weathernext_2_0_0/zarr}"
|
||||
in weathernext2_block
|
||||
)
|
||||
assert (
|
||||
"WEATHERNEXT2_MODEL_DIR: "
|
||||
"${WEATHERNEXT2_MODEL_DIR:-/app/data/models/weathernext2_calibrator}"
|
||||
in weathernext2_block
|
||||
)
|
||||
assert (
|
||||
"GOOGLE_APPLICATION_CREDENTIALS: "
|
||||
"${GOOGLE_APPLICATION_CREDENTIALS:-/app/secrets/gcp-sa.json}"
|
||||
in weathernext2_block
|
||||
)
|
||||
assert "./secrets:/app/secrets:ro" in weathernext2_block
|
||||
assert "POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''" in bot_block
|
||||
assert "POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''" in web_block
|
||||
assert (
|
||||
@@ -312,6 +341,7 @@ def test_deploy_script_retries_compose_recreate_races():
|
||||
assert 'compose_up_retry "observation collector" -d --no-deps polyweather_collector' in script
|
||||
assert 'compose_up_retry "cache warmer" -d --no-deps polyweather_warmer' in script
|
||||
assert 'compose_up_retry "training settlement" -d --no-deps polyweather_training_settlement' in script
|
||||
assert 'compose_up_retry "WeatherNext2 worker" -d --no-deps polyweather_weathernext2_worker' in script
|
||||
assert 'compose_up_retry "frontend" -d --no-deps polyweather_frontend' in script
|
||||
|
||||
|
||||
|
||||
@@ -241,6 +241,64 @@ class TestMuCalculation:
|
||||
assert sd["peak_hours"] == ["15:00", "16:00"]
|
||||
assert sd["peak_status"] == "before"
|
||||
|
||||
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
|
||||
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
|
||||
@patch("src.analysis.trend_engine.update_daily_record")
|
||||
def test_weathernext2_probability_replaces_legacy_distribution(
|
||||
self, _udr, _deb_acc, _dw
|
||||
):
|
||||
data = _make_weather_data(
|
||||
cur_temp=30.0,
|
||||
max_so_far=31.0,
|
||||
om_today_high=33.0,
|
||||
ens_median=32.0,
|
||||
local_time="2026-03-04 11:00",
|
||||
)
|
||||
data["weathernext2"] = {
|
||||
"summary": {"median": 33.1},
|
||||
"buckets": [
|
||||
{
|
||||
"label": "32°C",
|
||||
"value": 32.1,
|
||||
"lower": 31.5,
|
||||
"upper": 32.5,
|
||||
"probability": 0.41,
|
||||
},
|
||||
{
|
||||
"label": "33°C",
|
||||
"value": 33.0,
|
||||
"lower": 32.5,
|
||||
"upper": 33.5,
|
||||
"probability": 0.56,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
_, ai_context, sd = analyze_weather_trend(data, "°C", "test_city")
|
||||
|
||||
assert sd["probability_engine"] == "weathernext2"
|
||||
assert sd["probabilities_all"] == data["weathernext2"]["buckets"]
|
||||
assert sd["mu"] == 33.1
|
||||
assert "WeatherNext 2 概率分布" in ai_context
|
||||
|
||||
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
|
||||
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
|
||||
@patch("src.analysis.trend_engine.update_daily_record")
|
||||
def test_weathernext2_median_enters_current_forecasts(
|
||||
self, _udr, _deb_acc, _dw
|
||||
):
|
||||
data = _make_weather_data(local_time="2026-03-04 11:00")
|
||||
data["weathernext2"] = {
|
||||
"summary": {"median": 33.1},
|
||||
"buckets": [
|
||||
{"label": "33°C", "value": 33.0, "probability": 0.56},
|
||||
],
|
||||
}
|
||||
|
||||
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
|
||||
|
||||
assert sd["current_forecasts"]["WeatherNext 2"] == 33.1
|
||||
|
||||
|
||||
class TestDebEnsembleSignal:
|
||||
@patch(
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.analysis.weathernext2_calibration import (
|
||||
apply_quantile_calibration_to_payload,
|
||||
train_lightgbm_quantile_calibrator,
|
||||
)
|
||||
from src.data_collection.weathernext2_sources import build_weathernext2_city_probability
|
||||
|
||||
|
||||
def _synthetic_training_rows(count: int = 170):
|
||||
rows = []
|
||||
for idx in range(count):
|
||||
city = "houston" if idx % 2 == 0 else "shanghai"
|
||||
median = 30.0 + (idx % 7) * 0.2
|
||||
residual = 1.0 if city == "houston" else -0.5
|
||||
rows.append(
|
||||
{
|
||||
"city": city,
|
||||
"target_date": f"2026-05-{idx % 28 + 1:02d}",
|
||||
"actual_high_c": median + residual,
|
||||
"weathernext2": {
|
||||
"summary": {
|
||||
"mean": median,
|
||||
"median": median,
|
||||
"p10": median - 1.0,
|
||||
"p25": median - 0.5,
|
||||
"p75": median + 0.5,
|
||||
"p90": median + 1.0,
|
||||
"spread": 2.0,
|
||||
}
|
||||
},
|
||||
"deb_prediction_c": median + 0.2,
|
||||
"model_median_c": median + 0.1,
|
||||
"model_spread": 1.4,
|
||||
"current_max_so_far_c": median - 2.0,
|
||||
"local_hour": 12,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def test_lightgbm_quantile_calibrator_trains_and_saves_ordered_quantiles(tmp_path):
|
||||
result = train_lightgbm_quantile_calibrator(
|
||||
_synthetic_training_rows(),
|
||||
model_dir=tmp_path,
|
||||
min_global_samples=150,
|
||||
min_city_samples=5,
|
||||
)
|
||||
|
||||
assert result["trained"] is True
|
||||
assert result["samples"] == 170
|
||||
assert (tmp_path / "metadata.json").is_file()
|
||||
assert (tmp_path / "q10.pkl").is_file()
|
||||
assert (tmp_path / "q50.pkl").is_file()
|
||||
assert (tmp_path / "q90.pkl").is_file()
|
||||
assert result["validation"]["ordered_quantiles"] is True
|
||||
|
||||
|
||||
def test_lightgbm_quantile_calibrator_skips_when_samples_are_insufficient(tmp_path):
|
||||
result = train_lightgbm_quantile_calibrator(
|
||||
_synthetic_training_rows(20),
|
||||
model_dir=tmp_path,
|
||||
min_global_samples=150,
|
||||
min_city_samples=5,
|
||||
)
|
||||
|
||||
assert result["trained"] is False
|
||||
assert result["reason"] == "insufficient_global_samples"
|
||||
|
||||
|
||||
def test_calibrated_distribution_rebuilds_market_buckets_from_shifted_members(tmp_path):
|
||||
train_lightgbm_quantile_calibrator(
|
||||
_synthetic_training_rows(),
|
||||
model_dir=tmp_path,
|
||||
min_global_samples=150,
|
||||
min_city_samples=5,
|
||||
)
|
||||
raw = build_weathernext2_city_probability(
|
||||
city="houston",
|
||||
member_highs=[30.0, 30.2, 30.4, 30.6],
|
||||
temp_symbol="°C",
|
||||
target_date="2026-06-29",
|
||||
)
|
||||
|
||||
calibrated = apply_quantile_calibration_to_payload(raw, model_dir=tmp_path)
|
||||
|
||||
assert calibrated["calibration"]["engine"] == "lightgbm_quantile"
|
||||
assert calibrated["calibration"]["samples"] == 170
|
||||
assert calibrated["calibration"]["raw_summary"]["median"] == raw["summary"]["median"]
|
||||
assert calibrated["calibration"]["calibrated_summary"]["median"] > raw["summary"]["median"]
|
||||
assert calibrated["buckets"]
|
||||
assert calibrated["top_bucket"]["label"].endswith("°C")
|
||||
@@ -0,0 +1,259 @@
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
|
||||
from src.data_collection.weather_sources import WeatherDataCollector
|
||||
from src.data_collection.weathernext2_fetcher import (
|
||||
extract_member_hourly_from_grid_dataset,
|
||||
normalize_temperature_value,
|
||||
open_weathernext2_zarr_dataset,
|
||||
select_temperature_variable,
|
||||
)
|
||||
from src.data_collection.weathernext2_sources import (
|
||||
build_city_local_daily_highs_from_hourly,
|
||||
build_weathernext2_city_probability,
|
||||
market_bucket_for_temperature,
|
||||
summarize_member_highs,
|
||||
)
|
||||
|
||||
|
||||
def test_market_bucket_for_temperature_uses_single_celsius_options():
|
||||
bucket = market_bucket_for_temperature(32.6, "°C")
|
||||
|
||||
assert bucket["label"] == "33°C"
|
||||
assert bucket["lower"] == 32.5
|
||||
assert bucket["upper"] == 33.5
|
||||
|
||||
|
||||
def test_market_bucket_for_temperature_groups_fahrenheit_by_two_degree_market_option():
|
||||
assert market_bucket_for_temperature(94.2, "°F")["label"] == "94-95°F"
|
||||
assert market_bucket_for_temperature(95.4, "°F")["label"] == "94-95°F"
|
||||
assert market_bucket_for_temperature(96.1, "°F")["label"] == "96-97°F"
|
||||
|
||||
|
||||
def test_weathernext2_probability_aggregates_member_highs_to_market_buckets():
|
||||
probability = build_weathernext2_city_probability(
|
||||
city="Houston",
|
||||
member_highs={
|
||||
"member_00": 94.1,
|
||||
"member_01": 94.8,
|
||||
"member_02": 95.2,
|
||||
"member_03": 96.7,
|
||||
},
|
||||
temp_symbol="°F",
|
||||
target_date="2026-06-29",
|
||||
source_run="2026-06-29T00:00:00Z",
|
||||
)
|
||||
|
||||
assert probability["source"] == "weathernext2"
|
||||
assert probability["members"] == 4
|
||||
assert probability["top_bucket"]["label"] == "94-95°F"
|
||||
assert probability["top_bucket"]["probability"] == 0.75
|
||||
assert probability["buckets"] == [
|
||||
{
|
||||
"key": "94-95°F",
|
||||
"label": "94-95°F",
|
||||
"lower": 93.5,
|
||||
"upper": 95.5,
|
||||
"value": 94.7,
|
||||
"probability": 0.75,
|
||||
"member_count": 3,
|
||||
"total_members": 4,
|
||||
},
|
||||
{
|
||||
"key": "96-97°F",
|
||||
"label": "96-97°F",
|
||||
"lower": 95.5,
|
||||
"upper": 97.5,
|
||||
"value": 96.7,
|
||||
"probability": 0.25,
|
||||
"member_count": 1,
|
||||
"total_members": 4,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_weathernext2_probability_keeps_celsius_market_option_labels():
|
||||
probability = build_weathernext2_city_probability(
|
||||
city="Shanghai",
|
||||
member_highs=[31.6, 32.2, 32.8, 33.1],
|
||||
temp_symbol="°C",
|
||||
target_date="2026-06-29",
|
||||
)
|
||||
|
||||
labels = [bucket["label"] for bucket in probability["buckets"]]
|
||||
|
||||
assert labels == ["32°C", "33°C"]
|
||||
assert probability["top_bucket"]["label"] == "33°C"
|
||||
assert probability["top_bucket"]["probability"] == 0.5
|
||||
|
||||
|
||||
def test_city_local_daily_highs_from_hourly_uses_target_local_date():
|
||||
highs = build_city_local_daily_highs_from_hourly(
|
||||
member_hourly={
|
||||
"member_00": [21.0, 26.0, 28.0, 24.0],
|
||||
"member_01": [20.0, 25.0, None, 27.0],
|
||||
},
|
||||
utc_times=[
|
||||
datetime(2026, 6, 28, 15, tzinfo=timezone.utc), # local 2026-06-28 23:00
|
||||
datetime(2026, 6, 28, 16, tzinfo=timezone.utc), # local 2026-06-29 00:00
|
||||
datetime(2026, 6, 29, 6, tzinfo=timezone.utc), # local 2026-06-29 14:00
|
||||
datetime(2026, 6, 29, 16, tzinfo=timezone.utc), # local 2026-06-30 00:00
|
||||
],
|
||||
timezone_offset_seconds=8 * 3600,
|
||||
target_local_date="2026-06-29",
|
||||
)
|
||||
|
||||
assert highs == {"member_00": 28.0, "member_01": 25.0}
|
||||
|
||||
|
||||
def test_summarize_member_highs_reports_ensemble_shape():
|
||||
summary = summarize_member_highs([30, 32, 34, 36, 38])
|
||||
|
||||
assert summary == {
|
||||
"members": 5,
|
||||
"mean": 34.0,
|
||||
"median": 34.0,
|
||||
"p10": 30.8,
|
||||
"p25": 32.0,
|
||||
"p75": 36.0,
|
||||
"p90": 37.2,
|
||||
"min": 30.0,
|
||||
"max": 38.0,
|
||||
"spread": 8.0,
|
||||
}
|
||||
|
||||
|
||||
def test_collector_reads_weathernext2_fixture_when_enabled(monkeypatch, tmp_path):
|
||||
fixture_path = tmp_path / "weathernext2.json"
|
||||
fixture_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"houston": {
|
||||
"target_date": "2026-06-29",
|
||||
"source_run": "2026-06-29T00:00:00Z",
|
||||
"member_highs": [94.1, 94.8, 95.2, 96.7],
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("WEATHERNEXT2_ENABLED", "1")
|
||||
monkeypatch.setenv("WEATHERNEXT2_FIXTURE_PATH", str(fixture_path))
|
||||
|
||||
collector = WeatherDataCollector({})
|
||||
|
||||
payload = collector.fetch_weathernext2_probability(
|
||||
"Houston",
|
||||
lat=29.7604,
|
||||
lon=-95.3698,
|
||||
use_fahrenheit=True,
|
||||
target_date="2026-06-29",
|
||||
timezone_offset_seconds=-5 * 3600,
|
||||
)
|
||||
|
||||
assert payload["source"] == "weathernext2"
|
||||
assert payload["top_bucket"]["label"] == "94-95°F"
|
||||
assert payload["buckets"][0]["probability"] == 0.75
|
||||
|
||||
|
||||
def test_collector_reads_weathernext2_worker_artifact_before_fixture(monkeypatch, tmp_path):
|
||||
artifact_path = tmp_path / "weathernext2_city_highs.json"
|
||||
fixture_path = tmp_path / "fixture.json"
|
||||
artifact_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"cities": {
|
||||
"houston": {
|
||||
"target_date": "2026-06-29",
|
||||
"source_run": "2026-06-29T00:00:00Z",
|
||||
"member_highs": [94.1, 94.8, 95.2, 96.7],
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
fixture_path.write_text(
|
||||
json.dumps({"houston": {"member_highs": [80.0, 80.2]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("WEATHERNEXT2_ENABLED", "1")
|
||||
monkeypatch.setenv("WEATHERNEXT2_CITY_HIGHS_PATH", str(artifact_path))
|
||||
monkeypatch.setenv("WEATHERNEXT2_FIXTURE_PATH", str(fixture_path))
|
||||
monkeypatch.setenv("WEATHERNEXT2_MODEL_DIR", str(tmp_path / "missing-model"))
|
||||
|
||||
collector = WeatherDataCollector({})
|
||||
|
||||
payload = collector.fetch_weathernext2_probability(
|
||||
"Houston",
|
||||
lat=29.7604,
|
||||
lon=-95.3698,
|
||||
use_fahrenheit=True,
|
||||
target_date="2026-06-29",
|
||||
timezone_offset_seconds=-5 * 3600,
|
||||
)
|
||||
|
||||
assert payload["source"] == "weathernext2"
|
||||
assert payload["top_bucket"]["label"] == "94-95°F"
|
||||
assert payload["buckets"][0]["probability"] == 0.75
|
||||
|
||||
|
||||
def test_weathernext2_grid_extractor_selects_temp_var_nearest_point_and_converts_kelvin():
|
||||
dataset = {
|
||||
"lat": [10.0, 20.0],
|
||||
"lon": [30.0, 40.0],
|
||||
"member": [0, 1],
|
||||
"time": [
|
||||
"2026-06-29T00:00:00Z",
|
||||
"2026-06-29T06:00:00Z",
|
||||
"2026-06-29T12:00:00Z",
|
||||
],
|
||||
"temperature_2m": [
|
||||
[
|
||||
[[290.0, 291.0], [292.0, 293.0]],
|
||||
[[294.0, 295.0], [296.0, 297.0]],
|
||||
[[298.0, 299.0], [300.0, 301.0]],
|
||||
],
|
||||
[
|
||||
[[289.0, 290.0], [291.0, 292.0]],
|
||||
[[293.0, 294.0], [295.0, 296.0]],
|
||||
[[297.0, 298.0], [299.0, 300.0]],
|
||||
],
|
||||
],
|
||||
"units": {"temperature_2m": "K"},
|
||||
}
|
||||
|
||||
assert select_temperature_variable(dataset) == "temperature_2m"
|
||||
assert normalize_temperature_value(300.15, "K") == 27.0
|
||||
|
||||
extracted = extract_member_hourly_from_grid_dataset(
|
||||
dataset,
|
||||
lat=18.0,
|
||||
lon=38.0,
|
||||
)
|
||||
|
||||
assert extracted["temp_var"] == "temperature_2m"
|
||||
assert extracted["nearest_lat"] == 20.0
|
||||
assert extracted["nearest_lon"] == 40.0
|
||||
assert extracted["member_hourly"]["member_00"] == [19.9, 23.9, 27.9]
|
||||
assert extracted["member_hourly"]["member_01"] == [18.9, 22.9, 26.9]
|
||||
|
||||
|
||||
def test_weathernext2_zarr_uses_google_default_credentials(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class FakeXarray:
|
||||
@staticmethod
|
||||
def open_zarr(uri, **kwargs):
|
||||
calls.append((uri, kwargs))
|
||||
return {"ok": True}
|
||||
|
||||
import src.data_collection.weathernext2_fetcher as fetcher
|
||||
|
||||
monkeypatch.setattr(fetcher, "xr", FakeXarray, raising=False)
|
||||
monkeypatch.setitem(__import__("sys").modules, "xarray", FakeXarray)
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/app/secrets/gcp-sa.json")
|
||||
|
||||
assert open_weathernext2_zarr_dataset("gs://weathernext/example") == {"ok": True}
|
||||
assert calls[0][1]["storage_options"]["token"] == "google_default"
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
from src.database.runtime_state import (
|
||||
RuntimeStateDB,
|
||||
TrainingFeatureRecordRepository,
|
||||
TruthRecordRepository,
|
||||
)
|
||||
from web.weathernext2_worker_service import run_weathernext2_cycle
|
||||
|
||||
|
||||
def _seed_training_rows(feature_repo, truth_repo, count: int = 170):
|
||||
for idx in range(count):
|
||||
city = "houston" if idx % 2 == 0 else "shanghai"
|
||||
target_date = (date(2026, 1, 1) + timedelta(days=idx)).isoformat()
|
||||
median = 30.0 + (idx % 7) * 0.2
|
||||
residual = 1.0 if city == "houston" else -0.5
|
||||
feature_repo.upsert_record(
|
||||
city,
|
||||
target_date,
|
||||
{
|
||||
"weathernext2": {
|
||||
"summary": {
|
||||
"mean": median,
|
||||
"median": median,
|
||||
"p10": median - 1.0,
|
||||
"p25": median - 0.5,
|
||||
"p75": median + 0.5,
|
||||
"p90": median + 1.0,
|
||||
"spread": 2.0,
|
||||
}
|
||||
},
|
||||
"deb_prediction": median + 0.2,
|
||||
"model_median_c": median + 0.1,
|
||||
"model_spread": 1.4,
|
||||
"current_max_so_far_c": median - 2.0,
|
||||
"local_hour": 12,
|
||||
},
|
||||
)
|
||||
truth_repo.upsert_truth(
|
||||
city=city,
|
||||
target_date=target_date,
|
||||
actual_high=median + residual,
|
||||
settlement_source="metar",
|
||||
settlement_station_code="TEST",
|
||||
settlement_station_label="TEST",
|
||||
truth_version="test",
|
||||
updated_by="test",
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
|
||||
def test_weathernext2_worker_writes_city_artifact_and_trains_calibrator(tmp_path):
|
||||
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
|
||||
feature_repo = TrainingFeatureRecordRepository(db)
|
||||
truth_repo = TruthRecordRepository(db)
|
||||
_seed_training_rows(feature_repo, truth_repo)
|
||||
|
||||
def fake_fetcher(city, meta, target_date):
|
||||
return {
|
||||
"city": city,
|
||||
"target_date": target_date,
|
||||
"source_run": "2026-06-29T00:00:00Z",
|
||||
"member_highs": [30.0, 30.2, 30.4, 30.6],
|
||||
}
|
||||
|
||||
output_path = tmp_path / "weathernext2_city_highs.json"
|
||||
model_dir = tmp_path / "model"
|
||||
result = run_weathernext2_cycle(
|
||||
city_registry={
|
||||
"houston": {"name": "Houston", "lat": 29.7, "lon": -95.3, "use_fahrenheit": False, "tz_offset": -5 * 3600},
|
||||
},
|
||||
output_path=str(output_path),
|
||||
model_dir=str(model_dir),
|
||||
fetcher=fake_fetcher,
|
||||
feature_repo=feature_repo,
|
||||
truth_repo=truth_repo,
|
||||
min_global_samples=150,
|
||||
min_city_samples=5,
|
||||
)
|
||||
|
||||
assert result["status"] == "written"
|
||||
assert result["city_count"] == 1
|
||||
assert result["calibration"]["trained"] is True
|
||||
assert (model_dir / "metadata.json").is_file()
|
||||
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
assert payload["cities"]["houston"]["member_highs"] == [30.0, 30.2, 30.4, 30.6]
|
||||
assert payload["calibration_training"]["samples"] == 170
|
||||
|
||||
|
||||
def test_weathernext2_worker_does_not_overwrite_artifact_when_no_city_payloads(tmp_path):
|
||||
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
|
||||
output_path = tmp_path / "weathernext2_city_highs.json"
|
||||
output_path.write_text('{"cities":{"old":{"member_highs":[1]}}}', encoding="utf-8")
|
||||
|
||||
result = run_weathernext2_cycle(
|
||||
city_registry={"houston": {"name": "Houston", "lat": 29.7, "lon": -95.3}},
|
||||
output_path=str(output_path),
|
||||
model_dir=str(tmp_path / "model"),
|
||||
fetcher=lambda _city, _meta, _target_date: None,
|
||||
feature_repo=TrainingFeatureRecordRepository(db),
|
||||
truth_repo=TruthRecordRepository(db),
|
||||
)
|
||||
|
||||
assert result["status"] == "no_city_payloads"
|
||||
assert json.loads(output_path.read_text(encoding="utf-8"))["cities"]["old"]["member_highs"] == [1]
|
||||
@@ -78,6 +78,34 @@ def _city_local_time(city: str, utc_offset_seconds: Optional[int] = None) -> str
|
||||
return _city_local_now(city, utc_offset_seconds).strftime("%H:%M")
|
||||
|
||||
|
||||
def _model_sources_with_weathernext2(
|
||||
base_models: Any,
|
||||
data: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
models = (
|
||||
{
|
||||
str(k): v
|
||||
for k, v in (base_models or {}).items()
|
||||
if v is not None
|
||||
}
|
||||
if isinstance(base_models, dict)
|
||||
else {}
|
||||
)
|
||||
weathernext2 = data.get("weathernext2")
|
||||
if isinstance(weathernext2, dict):
|
||||
summary = (
|
||||
weathernext2.get("summary")
|
||||
if isinstance(weathernext2.get("summary"), dict)
|
||||
else {}
|
||||
)
|
||||
representative = _safe_float(summary.get("median"))
|
||||
if representative is None:
|
||||
representative = _safe_float(summary.get("mean"))
|
||||
if representative is not None:
|
||||
models["WeatherNext 2"] = round(float(representative), 1)
|
||||
return models
|
||||
|
||||
|
||||
def _panel_cache_stale_reason(city: str, cached_entry: Dict[str, Any], payload: Dict[str, Any]) -> Optional[str]:
|
||||
updated_at_ts = _safe_float(cached_entry.get("updated_at_ts"))
|
||||
if updated_at_ts is None or time.time() - updated_at_ts > SCAN_PANEL_CACHE_MAX_AGE_SEC:
|
||||
@@ -611,7 +639,12 @@ def _build_terminal_row(
|
||||
"distribution_full": scan.get("distribution_full") or scan.get("distribution_preview") or row.get("distribution_preview") or [],
|
||||
"probability_engine": scan.get("probability_engine") or (data.get("probabilities") or {}).get("engine"),
|
||||
"probability_calibration_mode": scan.get("probability_calibration_mode") or (data.get("probabilities") or {}).get("calibration_mode"),
|
||||
"model_cluster_sources": daily_entry.get("models") if isinstance(daily_entry.get("models"), dict) else data.get("multi_model", {}).get("forecasts"),
|
||||
"model_cluster_sources": _model_sources_with_weathernext2(
|
||||
daily_entry.get("models")
|
||||
if isinstance(daily_entry.get("models"), dict)
|
||||
else data.get("multi_model", {}).get("forecasts"),
|
||||
data,
|
||||
),
|
||||
"window_phase": row.get("window_phase") or scan.get("window_phase"),
|
||||
"window_score": row.get("window_score") if row.get("window_score") is not None else scan.get("window_score"),
|
||||
"signal_status": scan.get("signal_status"),
|
||||
@@ -744,13 +777,11 @@ def _build_quick_row(
|
||||
"network_provider": data.get("official_network_source") or official_status.get("provider_code"),
|
||||
"network_provider_label": official_status.get("provider_label"),
|
||||
"deb_prediction": deb.get("prediction"),
|
||||
"model_cluster_sources": (
|
||||
"model_cluster_sources": _model_sources_with_weathernext2(
|
||||
daily_entry.get("models")
|
||||
if isinstance(daily_entry.get("models"), dict)
|
||||
else {
|
||||
str(k): v for k, v in multi.get("forecasts", {}).items()
|
||||
if v is not None
|
||||
}
|
||||
else multi.get("forecasts", {}),
|
||||
data,
|
||||
),
|
||||
"distribution_preview": distribution[:6] if distribution else [],
|
||||
"distribution_full": probs.get("distribution_all") or distribution,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Standalone WeatherNext 2 offline worker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from types import FrameType
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from web.weathernext2_worker_service import run_weathernext2_cycle
|
||||
|
||||
|
||||
_STOP_EVENT = threading.Event()
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _handle_stop_signal(signum: int, _frame: Optional[FrameType]) -> None:
|
||||
logger.info("WeatherNext2 worker stopping signal={}", signum)
|
||||
_STOP_EVENT.set()
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch WeatherNext 2 city highs and train LightGBM calibration."
|
||||
)
|
||||
parser.add_argument("--once", action="store_true", help="Run one cycle and exit.")
|
||||
parser.add_argument(
|
||||
"--interval-sec",
|
||||
type=int,
|
||||
default=_env_int("WEATHERNEXT2_WORKER_INTERVAL_SEC", 21600),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--initial-delay-sec",
|
||||
type=int,
|
||||
default=_env_int("WEATHERNEXT2_WORKER_INITIAL_DELAY_SEC", 90),
|
||||
)
|
||||
parser.add_argument("--cities", nargs="*", default=None)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _run_once(*, cities: Optional[list[str]]) -> dict:
|
||||
result = run_weathernext2_cycle(cities=cities)
|
||||
logger.info("WeatherNext2 worker result={}", json.dumps(result, ensure_ascii=False))
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
signal.signal(signal.SIGINT, _handle_stop_signal)
|
||||
signal.signal(signal.SIGTERM, _handle_stop_signal)
|
||||
args = _parse_args()
|
||||
|
||||
if args.once:
|
||||
result = _run_once(cities=args.cities)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return
|
||||
|
||||
interval_sec = max(1800, int(args.interval_sec or 21600))
|
||||
initial_delay_sec = max(0, int(args.initial_delay_sec or 0))
|
||||
logger.info("WeatherNext2 worker started interval={}s", interval_sec)
|
||||
if initial_delay_sec and _STOP_EVENT.wait(initial_delay_sec):
|
||||
return
|
||||
|
||||
while not _STOP_EVENT.is_set():
|
||||
started = time.time()
|
||||
try:
|
||||
_run_once(cities=args.cities)
|
||||
except Exception as exc:
|
||||
logger.exception("WeatherNext2 cycle failed: {}", exc)
|
||||
elapsed = time.time() - started
|
||||
wait_for = max(5.0, interval_sec - elapsed)
|
||||
if _STOP_EVENT.wait(wait_for):
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Offline WeatherNext 2 fetch and calibration worker service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Sequence
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from src.analysis.weathernext2_calibration import train_lightgbm_quantile_calibrator
|
||||
from src.data_collection.city_registry import CITY_REGISTRY
|
||||
from src.data_collection.weathernext2_fetcher import (
|
||||
extract_member_hourly_from_grid_dataset,
|
||||
open_weathernext2_zarr_dataset,
|
||||
)
|
||||
from src.data_collection.weathernext2_sources import (
|
||||
WEATHERNEXT2_GCS_ZARR_URI,
|
||||
build_city_local_daily_highs_from_hourly,
|
||||
build_weathernext2_city_probability,
|
||||
)
|
||||
from src.database.runtime_state import (
|
||||
TrainingFeatureRecordRepository,
|
||||
TruthRecordRepository,
|
||||
)
|
||||
|
||||
|
||||
CityFetcher = Callable[[str, Mapping[str, Any], str], Optional[Mapping[str, Any]]]
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_city(city: str) -> str:
|
||||
return str(city or "").strip().lower().replace("-", " ")
|
||||
|
||||
|
||||
def _selected_city_names(
|
||||
city_registry: Mapping[str, Mapping[str, Any]],
|
||||
cities: Optional[Iterable[str]],
|
||||
) -> Sequence[str]:
|
||||
selected = {_normalize_city(city) for city in (cities or []) if _normalize_city(city)}
|
||||
names = []
|
||||
for city in sorted(city_registry.keys()):
|
||||
normalized = _normalize_city(city)
|
||||
if selected and normalized not in selected:
|
||||
continue
|
||||
names.append(normalized)
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def _city_target_date(city_meta: Mapping[str, Any]) -> str:
|
||||
offset_seconds = int(city_meta.get("tz_offset") or 0)
|
||||
now_utc = datetime.now(timezone.utc).timestamp()
|
||||
return datetime.fromtimestamp(now_utc + offset_seconds, tz=timezone.utc).date().isoformat()
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed == parsed else None
|
||||
|
||||
|
||||
def _dataset_source_run(dataset: Any) -> Optional[str]:
|
||||
attrs = getattr(dataset, "attrs", None)
|
||||
if isinstance(attrs, Mapping):
|
||||
for key in ("source_run", "forecast_reference_time", "init_time", "run_time"):
|
||||
value = attrs.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
for key in ("init_time", "forecast_reference_time", "time"):
|
||||
try:
|
||||
values = dataset[key].values
|
||||
except Exception:
|
||||
try:
|
||||
values = dataset[key]
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
first = values[-1]
|
||||
except Exception:
|
||||
first = values
|
||||
if first is not None:
|
||||
return str(first)
|
||||
return None
|
||||
|
||||
|
||||
def _default_city_fetcher(dataset: Any, temp_var: Optional[str]) -> CityFetcher:
|
||||
source_run = _dataset_source_run(dataset)
|
||||
|
||||
def fetch(city: str, meta: Mapping[str, Any], target_date: str) -> Optional[Mapping[str, Any]]:
|
||||
lat = _safe_float(meta.get("lat"))
|
||||
lon = _safe_float(meta.get("lon"))
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
use_fahrenheit = bool(meta.get("use_fahrenheit") or meta.get("f"))
|
||||
extracted = extract_member_hourly_from_grid_dataset(
|
||||
dataset,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
temp_var=temp_var,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
member_highs = build_city_local_daily_highs_from_hourly(
|
||||
extracted["member_hourly"],
|
||||
extracted["utc_times"],
|
||||
int(meta.get("tz_offset") or 0),
|
||||
target_date,
|
||||
)
|
||||
if not member_highs:
|
||||
return None
|
||||
payload = build_weathernext2_city_probability(
|
||||
city=city,
|
||||
member_highs=member_highs,
|
||||
temp_symbol="°F" if use_fahrenheit else "°C",
|
||||
target_date=target_date,
|
||||
source_run=source_run,
|
||||
)
|
||||
payload["fetch_metadata"] = {
|
||||
"backend": "gcs_zarr",
|
||||
"temp_var": extracted.get("temp_var"),
|
||||
"units": extracted.get("units"),
|
||||
"nearest_lat": extracted.get("nearest_lat"),
|
||||
"nearest_lon": extracted.get("nearest_lon"),
|
||||
"requested_lat": lat,
|
||||
"requested_lon": lon,
|
||||
}
|
||||
return payload
|
||||
|
||||
return fetch
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
|
||||
tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(str(tmp_path), str(path))
|
||||
|
||||
|
||||
def _flatten_training_records_for_weathernext2(
|
||||
*,
|
||||
feature_repo: Optional[TrainingFeatureRecordRepository] = None,
|
||||
truth_repo: Optional[TruthRecordRepository] = None,
|
||||
) -> list[Dict[str, Any]]:
|
||||
features = (feature_repo or TrainingFeatureRecordRepository()).load_all()
|
||||
truth = (truth_repo or TruthRecordRepository()).load_all()
|
||||
rows: list[Dict[str, Any]] = []
|
||||
for city, by_date in features.items():
|
||||
city_truth = truth.get(city, {})
|
||||
for target_date, payload in by_date.items():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
actual = payload.get("actual_high")
|
||||
if actual is None and isinstance(city_truth.get(target_date), dict):
|
||||
actual = city_truth[target_date].get("actual_high")
|
||||
if actual is None:
|
||||
continue
|
||||
row = dict(payload)
|
||||
row["city"] = city
|
||||
row["target_date"] = target_date
|
||||
row["actual_high_c"] = actual
|
||||
if "deb_prediction_c" not in row and row.get("deb_prediction") is not None:
|
||||
row["deb_prediction_c"] = row.get("deb_prediction")
|
||||
if "weathernext2" not in row and isinstance(row.get("weather_data"), dict):
|
||||
wn2 = row["weather_data"].get("weathernext2")
|
||||
if isinstance(wn2, dict):
|
||||
row["weathernext2"] = wn2
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def run_weathernext2_cycle(
|
||||
*,
|
||||
city_registry: Optional[Mapping[str, Mapping[str, Any]]] = None,
|
||||
cities: Optional[Iterable[str]] = None,
|
||||
output_path: Optional[str] = None,
|
||||
model_dir: Optional[str] = None,
|
||||
fetcher: Optional[CityFetcher] = None,
|
||||
dataset: Any = None,
|
||||
feature_repo: Optional[TrainingFeatureRecordRepository] = None,
|
||||
truth_repo: Optional[TruthRecordRepository] = None,
|
||||
min_global_samples: Optional[int] = None,
|
||||
min_city_samples: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
registry = city_registry or CITY_REGISTRY
|
||||
data_root = Path(os.getenv("WEATHERNEXT2_DATA_ROOT", "/app/data/weathernext2"))
|
||||
out_path = Path(output_path or os.getenv("WEATHERNEXT2_CITY_HIGHS_PATH", "") or data_root / "weathernext2_city_highs.json")
|
||||
calibrator_dir = Path(model_dir or os.getenv("WEATHERNEXT2_MODEL_DIR", "/app/data/models/weathernext2_calibrator"))
|
||||
selected = _selected_city_names(registry, cities)
|
||||
backend = str(os.getenv("WEATHERNEXT2_BACKEND", "gcs_zarr") or "gcs_zarr").strip().lower()
|
||||
|
||||
generated_at = datetime.now(timezone.utc).isoformat()
|
||||
opened_dataset = dataset
|
||||
if fetcher is None:
|
||||
if opened_dataset is None:
|
||||
if backend != "gcs_zarr":
|
||||
return {"ok": False, "status": "skipped", "reason": "unsupported_backend", "backend": backend}
|
||||
uri = os.getenv("WEATHERNEXT2_GCS_ZARR_URI", WEATHERNEXT2_GCS_ZARR_URI)
|
||||
opened_dataset = open_weathernext2_zarr_dataset(uri)
|
||||
fetcher = _default_city_fetcher(
|
||||
opened_dataset,
|
||||
str(os.getenv("WEATHERNEXT2_TEMP_VAR", "") or "").strip() or None,
|
||||
)
|
||||
|
||||
city_payloads: Dict[str, Dict[str, Any]] = {}
|
||||
failed = 0
|
||||
items = []
|
||||
for city in selected:
|
||||
meta = registry.get(city) or {}
|
||||
target_date = _city_target_date(meta)
|
||||
try:
|
||||
payload = fetcher(city, meta, target_date)
|
||||
if not isinstance(payload, Mapping):
|
||||
items.append({"city": city, "ok": True, "status": "empty", "target_date": target_date})
|
||||
continue
|
||||
city_payloads[city] = dict(payload)
|
||||
items.append({"city": city, "ok": True, "status": "written", "target_date": target_date})
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
logger.warning("WeatherNext2 city fetch failed city={}: {}", city, exc)
|
||||
items.append({"city": city, "ok": False, "status": "failed", "target_date": target_date, "error": str(exc)})
|
||||
|
||||
training_rows = _flatten_training_records_for_weathernext2(
|
||||
feature_repo=feature_repo,
|
||||
truth_repo=truth_repo,
|
||||
)
|
||||
calibration = train_lightgbm_quantile_calibrator(
|
||||
training_rows,
|
||||
model_dir=calibrator_dir,
|
||||
min_global_samples=min_global_samples
|
||||
if min_global_samples is not None
|
||||
else _env_int("WEATHERNEXT2_MIN_GLOBAL_SAMPLES", 150),
|
||||
min_city_samples=min_city_samples
|
||||
if min_city_samples is not None
|
||||
else _env_int("WEATHERNEXT2_MIN_CITY_SAMPLES", 5),
|
||||
)
|
||||
|
||||
if not city_payloads:
|
||||
return {
|
||||
"ok": failed == 0,
|
||||
"status": "no_city_payloads",
|
||||
"backend": backend,
|
||||
"generated_at": generated_at,
|
||||
"city_count": 0,
|
||||
"failed": failed,
|
||||
"items": items,
|
||||
"calibration": calibration,
|
||||
"output_path": str(out_path),
|
||||
}
|
||||
|
||||
artifact = {
|
||||
"schema_version": 1,
|
||||
"source": "weathernext2",
|
||||
"backend": backend,
|
||||
"gcs_zarr_uri": os.getenv("WEATHERNEXT2_GCS_ZARR_URI", WEATHERNEXT2_GCS_ZARR_URI),
|
||||
"generated_at": generated_at,
|
||||
"city_count": len(city_payloads),
|
||||
"cities": city_payloads,
|
||||
"fetch_metadata": {
|
||||
"selected_city_count": len(selected),
|
||||
"failed": failed,
|
||||
},
|
||||
"calibration_training": calibration,
|
||||
}
|
||||
_atomic_write_json(out_path, artifact)
|
||||
|
||||
return {
|
||||
"ok": failed == 0,
|
||||
"status": "written",
|
||||
"backend": backend,
|
||||
"generated_at": generated_at,
|
||||
"city_count": len(city_payloads),
|
||||
"failed": failed,
|
||||
"items": items,
|
||||
"calibration": calibration,
|
||||
"output_path": str(out_path),
|
||||
}
|
||||
Reference in New Issue
Block a user