vpp-ai-platform/skills-py/vpp_skills/bid_milp.py

189 lines
8.3 KiB
Python
Raw Normal View History

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
"""Day-ahead bid optimization MILP (docs/05 §2.2, docs/07 D-1 08:00).
Decision: for each of 96 intervals, an offer quantity q_t (MWh) and an offer
price. Model (v1):
maximise Σ_t (w_t − c − DEVIATION_PENALTY) · q_t
s.t. q_t ≤ k · cap_t · 0.25 · u_t (sellable share of certified capacity)
q_t ≥ min_block · u_t (market block size, u_t ∈ {0,1})
E_min ≤ Σ_t q_t ≤ E_max (position ledger cascade, P7)
with w_t = (1−λ)·P50_t + λ·P10_t the risk-weighted price (λ = risk_aversion)
and c the marginal cost of delivering flexibility. In a uniform-price market a
seller is paid the clearing price, so the *offer price* is the cost floor c
(price-taker when c = 0) and the forecast's job is allocation: which intervals
get the bounded daily energy. Risk aversion tilts allocation away from
intervals whose price band is wide (low P10). The revenue distribution
evaluates the bid against each forecast quantile path with the clearing rule
"cleared where offer ≤ price".
Solver: HiGHS via scipy.optimize.milp — open source, deterministic, adequate
for 96 binaries. Pyomo/commercial solvers (docs/08) are a swap behind the
same contract.
"""
from __future__ import annotations
import time
from decimal import Decimal
import numpy as np
import scipy
from scipy.optimize import Bounds, LinearConstraint, milp
from scipy.sparse import csr_matrix, hstack, identity
from vpp_contracts.bid_optimization_request import BidOptimizationRequest
from vpp_contracts.bid_optimization_result import BidOptimizationResult
from . import SKILL_VERSIONS
from .numeric import INTERVALS, SCALE_MONEY, SCALE_MWH, SCALE_PRICE, curve, dsum, quantize, to_array
SKILL = "bid-optimization-milp"
# OPEN-QUESTION A4: Hubei deviation-assessment rule (band + penalty price
# mechanism) is undecided. Until it is, the objective carries a zero penalty
# per offered MWh so the term exists and can be wired to config, but it does
# not shape the bid. See docs/open-questions.md → market.deviation.
DEVIATION_PENALTY_YUAN_PER_MWH = Decimal("0")
STATUS_BY_SCIPY = {0: "OPTIMAL", 1: "TIME_LIMIT", 2: "INFEASIBLE", 3: "INFEASIBLE", 4: "ERROR"}
TOL = 1e-6
def _revenue(offer: list[str], qty: list[str], path: list[str]) -> Decimal:
total = Decimal(0)
for o, q, p in zip(offer, qty, path, strict=True):
if Decimal(o) <= Decimal(p):
total += Decimal(p) * Decimal(q)
return total
def _fit_to_bounds(qty: np.ndarray, cap_mwh: np.ndarray, min_block: float, bounds) -> list[str]:
"""Quantize to SCALE_MWH without leaving [E_min, E_max].
Rounding 96 values independently can move the sum by up to 96·½ulp, enough
for the ledger to reject a bid the solver found feasible. Floor each value
(sum can only drop), then top up the deficit — if any — on intervals that
still have capacity headroom, all in exact decimal arithmetic.
"""
ulp = Decimal(1).scaleb(-SCALE_MWH)
vals = [(Decimal(repr(float(v))).quantize(ulp, rounding="ROUND_FLOOR")) for v in qty.tolist()]
vals = [abs(v) if v == 0 else v for v in vals]
caps = [Decimal(repr(float(c))).quantize(ulp, rounding="ROUND_FLOOR") for c in cap_mwh.tolist()]
e_min = Decimal(bounds.daily_energy_min_mwh)
total = sum(vals, Decimal(0))
if total < e_min and total > 0:
deficit = e_min - total
for i in sorted(range(len(vals)), key=lambda j: caps[j] - vals[j], reverse=True):
if vals[i] == 0 or deficit <= 0:
continue # never open a new interval below the block size
room = caps[i] - vals[i]
add = min(room, deficit).quantize(ulp, rounding="ROUND_CEILING")
add = min(add, room)
vals[i] += add
deficit -= add
# If the deficit could not be placed the solver bound was tight to
# machine precision; the ledger check will judge the residual.
return [format(v, "f") for v in vals]
def optimize_bid(req: BidOptimizationRequest) -> BidOptimizationResult:
if req.price_forecast.kind.value != "PRICE":
raise ValueError("price_forecast.kind must be PRICE")
if req.price_forecast.market_date != req.market_date or req.adjustable_capacity_mw.date != req.market_date:
raise ValueError("price forecast and capacity curve must be for market_date")
lam = float(Decimal(req.risk.risk_aversion))
k = float(Decimal(req.risk.commitment_buffer_k))
min_block = float(Decimal(req.risk.min_block_mwh))
e_min = float(Decimal(req.position_bounds.daily_energy_min_mwh))
e_max = float(Decimal(req.position_bounds.daily_energy_max_mwh))
if not 0.0 <= lam <= 1.0:
raise ValueError("risk_aversion must be in [0, 1]")
if not 0.0 < k <= 1.0:
raise ValueError("commitment_buffer_k must be in (0, 1]")
cost = float(Decimal(req.risk.marginal_cost_yuan_per_mwh))
if cost < 0:
raise ValueError("marginal_cost_yuan_per_mwh must be non-negative")
if min_block < 0 or e_min < 0 or e_max < e_min:
raise ValueError("invalid block size or position bounds")
q = req.price_forecast.quantiles
p10 = to_array(v.root for v in q.p10.values)
p50 = to_array(v.root for v in q.p50.values)
cap_mwh = k * to_array(v.root for v in req.adjustable_capacity_mw.values) * 0.25
if np.any(cap_mwh < 0):
raise ValueError("adjustable capacity must be non-negative")
w = (1.0 - lam) * p50 + lam * p10
penalty = float(DEVIATION_PENALTY_YUAN_PER_MWH)
n = INTERVALS
# Variables: x = [q_0..q_95, u_0..u_95]; minimise -(w - cost - penalty)·q
c = np.concatenate([-(w - cost - penalty), np.zeros(n)])
integrality = np.concatenate([np.zeros(n), np.ones(n)])
bounds = Bounds(np.zeros(2 * n), np.concatenate([cap_mwh, np.ones(n)]))
eye = identity(n, format="csr")
a_cap = hstack([eye, -csr_matrix(np.diag(cap_mwh))]) # q - cap·u ≤ 0
a_blk = hstack([eye, -min_block * eye]) # q - min_block·u ≥ 0
a_sum = csr_matrix(np.concatenate([np.ones(n), np.zeros(n)])[None, :])
constraints = [
LinearConstraint(a_cap, -np.inf, 0.0),
LinearConstraint(a_blk, 0.0, np.inf),
LinearConstraint(a_sum, e_min, e_max),
]
t0 = time.perf_counter()
res = milp(c, constraints=constraints, integrality=integrality, bounds=bounds)
wall_ms = int(round((time.perf_counter() - t0) * 1000))
status = STATUS_BY_SCIPY.get(int(res.status), "ERROR")
binding: list[str] = []
if res.x is None:
qty = np.zeros(n)
if e_min > cap_mwh.sum() + TOL:
binding.append("daily_energy_min exceeds sellable energy")
objective = None
else:
qty = np.clip(res.x[:n], 0.0, None)
qty[qty < TOL] = 0.0
total = qty.sum()
if abs(total - e_max) < 1e-4:
binding.append("daily_energy_max")
if abs(total - e_min) < 1e-4:
binding.append("daily_energy_min")
if np.any((cap_mwh > 0) & (np.abs(qty - cap_mwh) < 1e-6)):
binding.append("interval_capacity")
objective = quantize(-float(res.fun), SCALE_MONEY)
quantities = curve(_fit_to_bounds(qty, cap_mwh, min_block, req.position_bounds), req.market_date, SCALE_MWH)
offers = curve(np.full(n, cost), req.market_date, SCALE_PRICE)
qv, ov = quantities["values"], offers["values"]
p10s = [v.root for v in q.p10.values]
p50s = [v.root for v in q.p50.values]
p90s = [v.root for v in q.p90.values]
result = {
"market_date": req.market_date,
"prices_yuan_per_mwh": offers,
"quantities_mwh": quantities,
"daily_energy_mwh": quantize(dsum(qv), SCALE_MWH),
"expected_revenue_yuan": quantize(_revenue(ov, qv, p50s), SCALE_MONEY),
"revenue_distribution_yuan": {
"p10": quantize(_revenue(ov, qv, p10s), SCALE_MONEY),
"p50": quantize(_revenue(ov, qv, p50s), SCALE_MONEY),
"p90": quantize(_revenue(ov, qv, p90s), SCALE_MONEY),
},
"position_bounds": req.position_bounds.model_dump(),
"solver": {
"name": "highs",
"version": f"scipy-{scipy.__version__}",
"status": status,
"objective_value": objective,
"wall_time_ms": wall_ms,
},
"binding_constraints": binding,
"skill_version": SKILL_VERSIONS[SKILL],
}
return BidOptimizationResult.model_validate(result)