Add WeatherNext2 worker and prevent empty scan cache

This commit is contained in:
2569718930@qq.com
2026-07-02 20:16:24 +08:00
parent 5bad2ec398
commit 459909539c
24 changed files with 2620 additions and 22 deletions
+3
View File
@@ -1,5 +1,8 @@
# Secrets
.env
secrets/
*.service-account.json
gcp-sa.json
# Scratch / temp scripts
scratch/
+3
View File
@@ -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
+53
View File
@@ -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}
+97
View File
@@ -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) {
+1
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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
+1
View File
@@ -110,6 +110,7 @@ def _deb_model_priority(model_name: str) -> int:
"hko": 45,
"lgbm": 50,
"openmeteo": 15,
"weathernext2": 30,
}.get(normalized, 10)
+77 -1
View File
@@ -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,
+325
View File
@@ -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
+40 -1
View File
@@ -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,
+243
View File
@@ -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,
)
+581
View File
@@ -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
+30
View File
@@ -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
+58
View File
@@ -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(
+92
View File
@@ -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")
+259
View File
@@ -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"
+108
View File
@@ -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]
+37 -6
View File
@@ -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,
+91
View File
@@ -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()
+288
View File
@@ -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),
}