vpp-ai-platform/skills-py/vpp_skills/forecast.py
Thomas Bayes 8796faca63 M2: skill contracts, Python skill service, L2 eval harness with baseline
- packages/domain: ForecastRequest, BidOptimizationRequest/Result,
  ReportRequest, SkillReport (+ golden and invalid fixtures, exported to
  contracts/ and regenerated as pydantic models).
- skills-py/vpp_skills: FastAPI service with versioned registry; load/PV/
  price forecasts (same-day-type EWM point forecast, conformal residual
  quantiles — coverage test as acceptance gate); bid-optimization MILP on
  HiGHS (binary block participation, hard ledger energy bounds, exact
  Decimal fit of the rounded curve inside the bounds, revenue distribution
  over quantile paths); report generator whose every figure is a
  {tool_call_id, path} reference, with a verifier. 48 tests incl. hypothesis
  property test that bids respect ledger constraints.
- packages/services: LedgerService.dayAheadBounds (the P7 cascade band
  handed to the optimizer); Decimal resolved once for CJS/ESM interop.
- packages/evals: L2 metrics (MAPE, nRMSE, coverage, direction accuracy,
  naive/hindsight revenue baselines), HTTP skill client, rolling-origin
  harness that pushes each bid through the real ledger, CLI with
  --check/--write-baseline; committed baseline on the SYNTHETIC dataset
  (no historical Hubei data yet — baselines measure the harness, not KPI).
- CI: evals job boots the skill service and fails on baseline digest drift.
- docs/open-questions: A6 (flexibility marginal cost = offer floor); A4/B6
  wired as placeholders. README/CLAUDE.md status → M2 done, M3 next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA
2026-09-02 06:29:08 -04:00

126 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Forecast skills v1: load / PV / price with quantile intervals (docs/05 §2.1).
Baseline method, deliberately simple and fully deterministic:
* point forecast (P50): exponentially-weighted mean of recent history days of
the same day-type (weekday vs weekend), per 15-min interval;
* interval (P10/P90): empirical quantiles of *backtest residuals* — the same
method is re-run on each history day using only the days before it, and the
pooled (actual − predicted) residuals give the band. This is split-conformal
calibration in its simplest form, so coverage on held-out days tracks the
nominal 80% instead of being an optimistic guess (docs/12 L2: 区间校准单独考核).
Better models (statsforecast, gradient boosting) plug in behind the same
contract; the eval harness (packages/evals) is what decides whether they win.
Exogenous inputs are accepted by the contract but unused by v1 — recorded here
so nobody mistakes v1 for weather-aware.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from typing import Callable
import numpy as np
from vpp_contracts.forecast_bundle import ForecastBundle
from vpp_contracts.forecast_request import ForecastRequest
from . import SKILL_VERSIONS
from .numeric import INTERVALS, SCALE_MW, SCALE_PRICE, curve, to_array
MODEL_NAME = {"LOAD": "load-forecast", "PV": "pv-forecast", "PRICE": "price-forecast"}
UNIT_FOR_KIND = {"LOAD": "mw", "PV": "mw", "PRICE": "yuan_per_mwh"}
LOOKBACK_DAYS = 14
HALF_LIFE_DAYS = 3.0
MIN_BACKTEST_HISTORY = 3
NOMINAL_LOWER, NOMINAL_UPPER = 0.10, 0.90
def _is_weekend(d: str) -> bool:
return date.fromisoformat(d).weekday() >= 5
def _point_forecast(days: list[tuple[str, np.ndarray]], target: str) -> np.ndarray:
"""EWM over the most recent LOOKBACK_DAYS same-day-type days (fallback: all days)."""
same = [(d, v) for d, v in days if _is_weekend(d) == _is_weekend(target)]
pool = (same if len(same) >= 2 else days)[-LOOKBACK_DAYS:]
ages = np.array([(date.fromisoformat(target) - date.fromisoformat(d)).days for d, _ in pool], float)
w = np.power(0.5, ages / HALF_LIFE_DAYS)
stack = np.stack([v for _, v in pool])
return (w[:, None] * stack).sum(axis=0) / w.sum()
def _residual_quantiles(days: list[tuple[str, np.ndarray]], relative: bool) -> tuple[float, float]:
"""Pooled backtest residuals. Relative (actual/pred − 1) for quantities whose
spread scales with level (load, PV — a night-time zero must not shrink the
noon band); additive for prices, which can sit near or below zero."""
pairs: list[tuple[np.ndarray, np.ndarray]] = []
for i in range(MIN_BACKTEST_HISTORY, len(days)):
d, actual = days[i]
pairs.append((actual, _point_forecast(days[:i], d)))
if not pairs:
# Too little history to backtest: fall back to day-to-day differences.
pairs = [(days[i][1], days[i - 1][1]) for i in range(1, len(days))]
actual = np.concatenate([a for a, _ in pairs])
pred = np.concatenate([p for _, p in pairs])
if relative:
floor = 0.05 * max(float(pred.max()), 1e-9)
mask = pred > floor
pooled = actual[mask] / pred[mask] - 1.0 if mask.any() else np.zeros(1)
else:
pooled = actual - pred
lo, hi = np.quantile(pooled, [NOMINAL_LOWER, NOMINAL_UPPER])
return float(lo), float(hi)
def forecast(
req: ForecastRequest,
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> ForecastBundle:
kind = str(req.kind.value)
if req.unit.value != UNIT_FOR_KIND[kind]:
raise ValueError(f"{kind} forecast unit must be {UNIT_FOR_KIND[kind]}, got {req.unit.value}")
days = [(c.date, to_array(v.root for v in c.values)) for c in req.history]
dates = [d for d, _ in days]
if dates != sorted(dates) or len(set(dates)) != len(dates):
raise ValueError("history must be ascending by date with no duplicates")
if dates[-1] >= req.market_date:
raise ValueError("history must be strictly before market_date")
p50 = _point_forecast(days, req.market_date)
relative = kind in ("LOAD", "PV")
lo, hi = _residual_quantiles(days, relative)
if relative:
p10, p90 = p50 * (1.0 + lo), p50 * (1.0 + hi)
else:
p10, p90 = p50 + lo, p50 + hi
if kind in ("LOAD", "PV"):
p10, p50, p90 = (np.clip(x, 0.0, None) for x in (p10, p50, p90))
if kind == "PV":
# Intervals that were zero on every history day (night) stay exactly zero.
night = np.all(np.stack([v for _, v in days]) == 0.0, axis=0)
for x in (p10, p50, p90):
x[night] = 0.0
scale = SCALE_PRICE if kind == "PRICE" else SCALE_MW
assert p50.shape == (INTERVALS,)
bundle = {
"id": f"fc-{kind.lower()}-{req.market_date}",
"kind": kind,
"market_date": req.market_date,
"unit": req.unit.value,
"quantiles": {
"p10": curve(p10, req.market_date, scale),
"p50": curve(p50, req.market_date, scale),
"p90": curve(p90, req.market_date, scale),
},
"model": {"name": MODEL_NAME[kind], "version": SKILL_VERSIONS[MODEL_NAME[kind]]},
"features_snapshot_ref": req.features_snapshot_ref,
"generated_at": clock().isoformat().replace("+00:00", "Z"),
}
return ForecastBundle.model_validate(bundle)