"""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