- 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
189 lines
8.3 KiB
Python
189 lines
8.3 KiB
Python
"""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)
|