vpp-ai-platform/skills-py/tests/test_potential_dispatch.py
Thomas Bayes 23fdf49ff2
Some checks are pending
ci / typescript (push) Waiting to run
ci / python (push) Waiting to run
ci / evals (push) Blocked by required conditions
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

97 lines
4.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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