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
+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