vpp-ai-platform/skills-py/tests/test_potential_dispatch.py

97 lines
4.3 KiB
Python
Raw Permalink 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
"""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