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

89 lines
3.8 KiB
Python
Raw Normal View History

M4: resource agent, envelopes live, review loop, insight cards - packages/domain: AwardNotice, DISPATCH_PLAN proposal payload, ExecutionReport, MeteringRecord, potential-assessment and dispatch-optimization contracts, ReviewFinding (+ typed writebacks), SemanticMemoryEntry, EnvelopeChangeRequest, InsightCard — exported with fixtures on both sides. - skills-py: potential-assessment (certified × rolling fulfilment, evidence days) and dispatch-optimization (per-interval LP on HiGHS, shortfall reported) skills + routes + tests. - packages/services: dispatch rules in the policy pack (over-allocation, award anchor, lineage integrity for allocations); PowerBalanceSimulator; SimulationGateway (permit-only, idempotent, seeded execute → ExecutionReports); envelope deviation-streak suspension + apply(); ReviewService (attribution, reliability EWMA writeback, semantic memory, envelope recommendations as change requests); dispatch assembler; skill client methods. - packages/runtime: resource agent; award-decomposition, review and envelope-review workflows; lifecycle selects simulator/gateway by proposal type; trigger hooks for awards, execution reports, metering; decide() resumes either lifecycle or envelope-review runs; insight cards API. - Tests: docs/07 D-1 16:00 and D+1 end to end; reliability score 0.9 → 0.880 and the next assessment de-rates capacity; envelope suspension on a seeded 3-day streak; WIDEN request applied only by a human. 184 TS + 80 Python. - docs/open-questions: B10 (reliability/potential parameters). README and CLAUDE.md status → M4 done, M5 next. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA
2026-09-02 19:31:12 -04:00
"""Aggregation/dispatch optimization skill v1 (docs/05 §2.2 聚合/调度优化).
Decompose a province-level target curve to aggregation units (docs/04 §1:
never below unit granularity). Per interval, an LP:
minimise Σ_u w_u · x_{u,t} + M · s_t
s.t. Σ_u x_{u,t} + s_t = target_t (balance, s_t = shortfall)
0 ≤ x_{u,t} ≤ available_{u,t}
Intervals are independent in v1 (no ramp/energy coupling); the whole day is
one sparse LP solved by HiGHS. Shortfall is reported, never hidden — a
non-zero shortfall is what the power-balance simulation escalates on.
"""
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, kron
from vpp_contracts.dispatch_optimization_request import DispatchOptimizationRequest
from vpp_contracts.dispatch_optimization_result import DispatchOptimizationResult
from . import SKILL_VERSIONS
from .numeric import INTERVALS, SCALE_MW, SCALE_MWH, curve, quantize, to_array
SKILL = "dispatch-optimization"
SHORTFALL_PENALTY = 1e6
def optimize_dispatch(req: DispatchOptimizationRequest) -> DispatchOptimizationResult:
if req.target_mw.date != req.market_date or any(u.available_mw.date != req.market_date for u in req.units):
raise ValueError("all curves must be for market_date")
ids = [u.unit_id for u in req.units]
if len(set(ids)) != len(ids):
raise ValueError("duplicate unit_id")
target = to_array(v.root for v in req.target_mw.values)
avail = np.stack([to_array(v.root for v in u.available_mw.values) for u in req.units]) # (U, T)
weights = np.array([float(Decimal(u.cost_weight)) for u in req.units])
if np.any(target < 0) or np.any(avail < 0) or np.any(weights < 0):
raise ValueError("target, availability and cost weights must be non-negative")
n_u, n_t = avail.shape
# Variables: x (U·T, unit-major) then s (T).
c = np.concatenate([np.repeat(weights, n_t), np.full(n_t, SHORTFALL_PENALTY)])
ub = np.concatenate([avail.ravel(), target])
bounds = Bounds(np.zeros(n_u * n_t + n_t), ub)
# Balance: for each t, Σ_u x_{u,t} + s_t = target_t
a_x = kron(csr_matrix(np.ones((1, n_u))), identity(n_t, format="csr"))
a = hstack([a_x, identity(n_t, format="csr")])
constraints = [LinearConstraint(a, target, target)]
t0 = time.perf_counter()
res = milp(c, constraints=constraints, bounds=bounds)
wall_ms = int(round((time.perf_counter() - t0) * 1000))
status = {0: "OPTIMAL", 2: "INFEASIBLE", 3: "INFEASIBLE"}.get(int(res.status), "ERROR")
if res.x is None:
x = np.zeros((n_u, n_t))
shortfall = target.copy()
else:
x = np.clip(res.x[: n_u * n_t].reshape(n_u, n_t), 0.0, None)
shortfall = np.clip(target - x.sum(axis=0), 0.0, None)
allocations = [{"unit_id": uid, "target_mw": curve(x[i], req.market_date, SCALE_MW)} for i, uid in enumerate(ids)]
# Recompute shortfall from the *quantized* allocations so the headline is consistent with the curves.
alloc_sum = np.zeros(n_t)
for a_ in allocations:
alloc_sum += to_array(a_["target_mw"]["values"])
short_mwh = float(np.clip(target - alloc_sum, 0.0, None).sum() * 0.25)
if short_mwh < 1e-3:
short_mwh = 0.0
return DispatchOptimizationResult.model_validate(
{
"market_date": req.market_date,
"total_target_mw": curve([v.root for v in req.target_mw.values], req.market_date, SCALE_MW),
"allocations": allocations,
"shortfall_mwh": quantize(short_mwh, SCALE_MWH),
"solver": {"name": "highs", "version": f"scipy-{scipy.__version__}", "status": status, "wall_time_ms": wall_ms},
"skill_version": SKILL_VERSIONS[SKILL],
}
)