97 lines
4.3 KiB
Python
97 lines
4.3 KiB
Python
|
|
"""Potential assessment + dispatch optimization skills (M4)."""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from decimal import Decimal
|
|||
|
|
|
|||
|
|
import numpy as np
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
|
|||
|
|
from vpp_contracts.dispatch_optimization_request import DispatchOptimizationRequest
|
|||
|
|
from vpp_contracts.potential_assessment_request import PotentialAssessmentRequest
|
|||
|
|
from vpp_skills.app import app
|
|||
|
|
from vpp_skills.dispatch_opt import optimize_dispatch
|
|||
|
|
from vpp_skills.potential import assess_potential
|
|||
|
|
|
|||
|
|
from .conftest import REF_A, curve_of
|
|||
|
|
|
|||
|
|
DATE = "2026-03-15"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _profile(rid: str, certified: str, reliability: str = "0.9", confidence: str = "0.9") -> dict:
|
|||
|
|
return {
|
|||
|
|
"resource_id": rid, "name": rid, "type": "STORAGE", "rated_power_mw": "30.0", "certified_adjustable_mw": certified,
|
|||
|
|
"confidence": confidence, "reliability_score": reliability,
|
|||
|
|
"constraints": {"min_duration_min": 60, "recovery_rate_mw_per_min": "0.5"}, "evidence_refs": [], "updated_at": "2026-03-14T06:00:00Z",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _potential(resources: list[dict]) -> PotentialAssessmentRequest:
|
|||
|
|
return PotentialAssessmentRequest.model_validate({"market_date": DATE, "resources": resources, "features_snapshot_ref": REF_A})
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_potential_derates_by_fulfillment_and_sums():
|
|||
|
|
req = _potential(
|
|||
|
|
[
|
|||
|
|
{"profile": _profile("res-a", "20.0"), "fulfillment": [{"market_date": "2026-03-13", "planned_mwh": "100.0", "delivered_mwh": "80.0"}] * 3},
|
|||
|
|
{"profile": _profile("res-b", "10.0"), "fulfillment": []},
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
res = assess_potential(req)
|
|||
|
|
a, b = res.assessments
|
|||
|
|
assert a.fulfillment_rate == "0.8000" and a.evidence_days == 3
|
|||
|
|
assert a.adjustable_mw.values[0].root == "16.000" # 20 × 0.8
|
|||
|
|
assert b.fulfillment_rate is None and b.evidence_days == 0
|
|||
|
|
assert b.adjustable_mw.values[0].root == "10.000" # no history → certified, lower confidence
|
|||
|
|
assert Decimal(b.confidence) < Decimal(a.confidence)
|
|||
|
|
assert res.total_adjustable_mw.values[0].root == "26.000"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_potential_never_exceeds_certified():
|
|||
|
|
req = _potential([{"profile": _profile("res-a", "20.0"), "fulfillment": [{"market_date": "2026-03-13", "planned_mwh": "10.0", "delivered_mwh": "15.0"}]}])
|
|||
|
|
res = assess_potential(req)
|
|||
|
|
assert res.assessments[0].fulfillment_rate == "1.0000"
|
|||
|
|
assert res.assessments[0].adjustable_mw.values[0].root == "20.000"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _dispatch(target: np.ndarray, units: list[tuple[str, np.ndarray, str]]) -> DispatchOptimizationRequest:
|
|||
|
|
q = lambda arr: [f"{float(v):.3f}" for v in arr] # noqa: E731
|
|||
|
|
return DispatchOptimizationRequest.model_validate(
|
|||
|
|
{"market_date": DATE, "target_mw": curve_of(q(target), DATE), "units": [{"unit_id": u, "available_mw": curve_of(q(a), DATE), "cost_weight": w} for u, a, w in units]}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_dispatch_fills_cheapest_unit_first_and_balances():
|
|||
|
|
target = np.full(96, 48.0)
|
|||
|
|
res = optimize_dispatch(_dispatch(target, [("u1", np.full(96, 30.0), "1.0"), ("u2", np.full(96, 30.0), "1.2")]))
|
|||
|
|
assert res.solver.status.value == "OPTIMAL"
|
|||
|
|
by = {a.unit_id: [float(v.root) for v in a.target_mw.values] for a in res.allocations}
|
|||
|
|
assert by["u1"][0] == 30.0 and by["u2"][0] == 18.0
|
|||
|
|
assert res.shortfall_mwh == "0.000"
|
|||
|
|
for t in range(96):
|
|||
|
|
assert abs(by["u1"][t] + by["u2"][t] - 48.0) < 1e-6
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_dispatch_reports_shortfall_instead_of_hiding_it():
|
|||
|
|
target = np.full(96, 100.0)
|
|||
|
|
res = optimize_dispatch(_dispatch(target, [("u1", np.full(96, 30.0), "1.0")]))
|
|||
|
|
assert res.solver.status.value == "OPTIMAL"
|
|||
|
|
assert Decimal(res.shortfall_mwh) == Decimal("70") * 96 * Decimal("0.25")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_dispatch_rejects_bad_input():
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
with pytest.raises(ValueError, match="duplicate"):
|
|||
|
|
optimize_dispatch(_dispatch(np.full(96, 1.0), [("u1", np.full(96, 1.0), "1"), ("u1", np.full(96, 1.0), "1")]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_http_routes():
|
|||
|
|
client = TestClient(app)
|
|||
|
|
ids = {s["id"] for s in client.get("/v1/skills").json()}
|
|||
|
|
assert {"potential-assessment", "dispatch-optimization"} <= ids
|
|||
|
|
r = client.post("/v1/optimize/dispatch", json=_dispatch(np.full(96, 5.0), [("u1", np.full(96, 10.0), "1")]).model_dump(mode="json"))
|
|||
|
|
assert r.status_code == 200, r.text
|
|||
|
|
r = client.post("/v1/assess/potential", json=_potential([{"profile": _profile("r", "1.0"), "fulfillment": []}]).model_dump(mode="json"))
|
|||
|
|
assert r.status_code == 200, r.text
|