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
This commit is contained in:
parent
8a9554d5c4
commit
23fdf49ff2
19
CLAUDE.md
19
CLAUDE.md
@ -6,14 +6,17 @@ docs win — or write an ADR changing the doc first.
|
||||
|
||||
## Current phase
|
||||
|
||||
Docs complete; M1–M3 implemented: schemas + contracts pipeline; deterministic
|
||||
services (ledger, stores, policy engine, envelope, authority/permits, gateway,
|
||||
events, lineage assembler, simulation, Case Desk); Python skill service; L2 eval
|
||||
harness with committed baseline (synthetic data); Mastra runtime with the
|
||||
proposal-lifecycle safety chain (durable suspend/resume), situation and bid
|
||||
workflows, trigger service, LLM port with LLM-down mode, Case Desk API. Storage
|
||||
is file-backed reference semantics — Postgres/Timescale adapters later. Next is
|
||||
M4. Build order is ROADMAP.md (M1→M5). A skill change that moves an L2 metric
|
||||
Docs complete; M1–M4 implemented: schemas + contracts pipeline; deterministic
|
||||
services (ledger, stores, policy engine incl. dispatch rules, envelope with
|
||||
deviation-streak suspension, authority/permits, file-export + simulation
|
||||
gateways, events, lineage assemblers, revenue + power-balance simulation,
|
||||
review service, Case Desk); Python skill service (forecasts, bid MILP, report,
|
||||
potential assessment, dispatch optimization); L2 eval harness with committed
|
||||
baseline (synthetic data); Mastra runtime with the proposal-lifecycle safety
|
||||
chain (durable suspend/resume), situation / bid / award-decomposition / review /
|
||||
envelope-review workflows, trigger service, LLM port with LLM-down mode, Case
|
||||
Desk API with insight cards. Storage is file-backed reference semantics —
|
||||
Postgres/Timescale adapters later. Next is M5 (shadow run). Build order is ROADMAP.md (M1→M5). A skill change that moves an L2 metric
|
||||
must update the baseline in the same change (`npm run eval -w @vpp/evals -- --write-baseline`).
|
||||
Workflows are per-runtime factories (`createProposalLifecycle(ctx)` etc.) — never
|
||||
module singletons, or a second Mastra instance steals their context.
|
||||
|
||||
18
README.md
18
README.md
@ -6,7 +6,7 @@ a deterministic safety chain (rule check → simulation → envelope/human appro
|
||||
execution permit) governs everything before any external effect. **The LLM never
|
||||
computes numbers and never touches the second-level control loop.**
|
||||
|
||||
**Status: M1–M3 implemented.** Design docs 00–13 are complete; implementation
|
||||
**Status: M1–M4 implemented.** Design docs 00–13 are complete; implementation
|
||||
follows [ROADMAP.md](ROADMAP.md). Present today:
|
||||
|
||||
- `packages/domain` — zod schemas for every business and safety-chain object, exported to
|
||||
@ -20,14 +20,20 @@ follows [ROADMAP.md](ROADMAP.md). Present today:
|
||||
- `packages/evals` — L2 eval harness with a committed baseline (on a **synthetic** dataset).
|
||||
- `packages/runtime` — Mastra runtime: `proposal-lifecycle` (the safety chain, suspend/resume
|
||||
for human approval, durable across restarts), `day-ahead-situation`, `day-ahead-bid`;
|
||||
trigger service (scheduled / event / manual via router agent); provider-abstracted LLM
|
||||
port with an LLM-down mode; Case Desk HTTP API. Bid release is a file export for manual
|
||||
upload (degraded channel by design).
|
||||
`award-decomposition` (resource agent: potential assessment → dispatch optimization →
|
||||
DISPATCH_PLAN through the same chain to a simulation gateway), `review` (D+1
|
||||
plan-vs-actual attribution → ReviewFinding → semantic memory + reliability writebacks +
|
||||
envelope change requests), `envelope-review` (the only path that changes an envelope,
|
||||
human-approved); envelope deviation-streak suspension; trigger service (scheduled / event /
|
||||
manual via router agent); provider-abstracted LLM port with an LLM-down mode; Case Desk
|
||||
HTTP API incl. AI insight cards. Bid release is a file export for manual upload (degraded
|
||||
channel by design).
|
||||
|
||||
All eight docs/01 invariants have automated tests (`packages/services/test/chain.test.ts`,
|
||||
`packages/runtime/test/lifecycle.test.ts`). Storage is file-backed reference semantics;
|
||||
`packages/runtime/test/lifecycle.test.ts`); the docs/07 D-1 16:00 and D+1 sections run in
|
||||
`packages/runtime/test/m4.test.ts`. Storage is file-backed reference semantics;
|
||||
Postgres/Timescale adapters and the programmatic trading-platform channel are later work.
|
||||
M4 (resource agent, envelopes live, review loop) is next.
|
||||
M5 (shadow run) is next.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "rf-2026-03-15-response",
|
||||
"market_date": "2026-03-15",
|
||||
"kind": "RESPONSE",
|
||||
"summary": "Unit agg-unit-wuhan-01 delivered 95.0% of plan; reliability score adjusted.",
|
||||
"attribution": {
|
||||
"trading_mwh": "48.000",
|
||||
"control_mwh": "0.000",
|
||||
"response_mwh": "57.600",
|
||||
"forecast_load_mape": "0.041",
|
||||
"forecast_price_mape": "0.017",
|
||||
"net_deviation_mwh": "57.600"
|
||||
},
|
||||
"writebacks": [
|
||||
{
|
||||
"target": "LEDGER",
|
||||
"note": "nope"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
],
|
||||
"lineage_refs": [
|
||||
{
|
||||
"tool_call_id": "tc-review-001",
|
||||
"path": "attribution"
|
||||
}
|
||||
],
|
||||
"generated_at": "2026-03-16T03:00:00Z"
|
||||
}
|
||||
210
contracts/fixtures/award_notice/da-award.json
Normal file
210
contracts/fixtures/award_notice/da-award.json
Normal file
@ -0,0 +1,210 @@
|
||||
{
|
||||
"id": "award-2026-03-15",
|
||||
"market_date": "2026-03-15",
|
||||
"bid_proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"awarded_mwh": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0",
|
||||
"12.0"
|
||||
]
|
||||
},
|
||||
"clearing_price_yuan_per_mwh": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30",
|
||||
"418.30"
|
||||
]
|
||||
},
|
||||
"received_at": "2026-03-14T08:00:00Z"
|
||||
}
|
||||
319
contracts/fixtures/dispatch_optimization_request/basic.json
Normal file
319
contracts/fixtures/dispatch_optimization_request/basic.json
Normal file
@ -0,0 +1,319 @@
|
||||
{
|
||||
"market_date": "2026-03-15",
|
||||
"target_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0"
|
||||
]
|
||||
},
|
||||
"units": [
|
||||
{
|
||||
"unit_id": "agg-unit-wuhan-01",
|
||||
"available_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0"
|
||||
]
|
||||
},
|
||||
"cost_weight": "1.0"
|
||||
},
|
||||
{
|
||||
"unit_id": "agg-unit-wuhan-02",
|
||||
"available_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0",
|
||||
"30.0"
|
||||
]
|
||||
},
|
||||
"cost_weight": "1.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
325
contracts/fixtures/dispatch_optimization_result/basic.json
Normal file
325
contracts/fixtures/dispatch_optimization_result/basic.json
Normal file
@ -0,0 +1,325 @@
|
||||
{
|
||||
"market_date": "2026-03-15",
|
||||
"total_target_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0"
|
||||
]
|
||||
},
|
||||
"allocations": [
|
||||
{
|
||||
"unit_id": "agg-unit-wuhan-01",
|
||||
"target_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000",
|
||||
"30.000"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"unit_id": "agg-unit-wuhan-02",
|
||||
"target_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000",
|
||||
"18.000"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shortfall_mwh": "0.000",
|
||||
"solver": {
|
||||
"name": "highs",
|
||||
"version": "scipy-1.17.1",
|
||||
"status": "OPTIMAL",
|
||||
"wall_time_ms": 4
|
||||
},
|
||||
"skill_version": "1.0.0"
|
||||
}
|
||||
12
contracts/fixtures/dispatch_proposal_draft/basic.json
Normal file
12
contracts/fixtures/dispatch_proposal_draft/basic.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"market_date": "2026-03-15",
|
||||
"total_target_ref": {
|
||||
"tool_call_id": "tc-002",
|
||||
"path": "total_target_mw"
|
||||
},
|
||||
"allocations_ref": {
|
||||
"tool_call_id": "tc-002",
|
||||
"path": "allocations"
|
||||
},
|
||||
"rationale": "Storage covers the evening block; industrial load takes the midday share."
|
||||
}
|
||||
12
contracts/fixtures/envelope_change_request/widen.json
Normal file
12
contracts/fixtures/envelope_change_request/widen.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "ecr-001",
|
||||
"envelope_id": "env-bid-001",
|
||||
"action": "WIDEN",
|
||||
"bounds": {
|
||||
"price_deviation_pct": "6.0",
|
||||
"max_energy_mwh": "1500.0"
|
||||
},
|
||||
"basis": "ReviewFinding rf-2026-03-15-response: 26/26 within-envelope days under threshold",
|
||||
"source_finding_id": "rf-2026-03-15-response",
|
||||
"requested_at": "2026-03-16T03:00:00Z"
|
||||
}
|
||||
213
contracts/fixtures/execution_report/unit-01.json
Normal file
213
contracts/fixtures/execution_report/unit-01.json
Normal file
@ -0,0 +1,213 @@
|
||||
{
|
||||
"id": "exec-2026-03-15-agg-unit-wuhan-01",
|
||||
"market_date": "2026-03-15",
|
||||
"unit_id": "agg-unit-wuhan-01",
|
||||
"dispatch_proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"planned_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0"
|
||||
]
|
||||
},
|
||||
"actual_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6"
|
||||
]
|
||||
},
|
||||
"deviation_mwh": "57.600",
|
||||
"fulfillment_ratio": "0.950",
|
||||
"reported_at": "2026-03-16T01:00:00Z"
|
||||
}
|
||||
24
contracts/fixtures/insight_card/trading-desk.json
Normal file
24
contracts/fixtures/insight_card/trading-desk.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "card-001",
|
||||
"page": "TRADING_DESK",
|
||||
"market_date": "2026-03-15",
|
||||
"title": "Day-ahead situation",
|
||||
"headline": "Risk LOW; P50 peak price 425.50 yuan/MWh",
|
||||
"metrics": [
|
||||
{
|
||||
"name": "peak_price_p50",
|
||||
"value": "425.50",
|
||||
"unit": "yuan_per_mwh",
|
||||
"ref": {
|
||||
"tool_call_id": "tc-001",
|
||||
"path": "quantiles.p50.values.76"
|
||||
}
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
"kind": "SITUATION_REPORT",
|
||||
"id": "sit-001",
|
||||
"ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
},
|
||||
"generated_at": "2026-03-14T06:00:00Z"
|
||||
}
|
||||
209
contracts/fixtures/metering_record/da-metering.json
Normal file
209
contracts/fixtures/metering_record/da-metering.json
Normal file
@ -0,0 +1,209 @@
|
||||
{
|
||||
"id": "meter-2026-03-15",
|
||||
"market_date": "2026-03-15",
|
||||
"metered_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6",
|
||||
"45.6"
|
||||
]
|
||||
},
|
||||
"actual_price_yuan_per_mwh": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00",
|
||||
"421.00"
|
||||
]
|
||||
},
|
||||
"received_at": "2026-03-16T02:00:00Z"
|
||||
}
|
||||
37
contracts/fixtures/potential_assessment_request/basic.json
Normal file
37
contracts/fixtures/potential_assessment_request/basic.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"market_date": "2026-03-15",
|
||||
"resources": [
|
||||
{
|
||||
"profile": {
|
||||
"resource_id": "res-storage-01",
|
||||
"name": "Wuhan industrial park storage #1",
|
||||
"type": "STORAGE",
|
||||
"rated_power_mw": "10.0",
|
||||
"certified_adjustable_mw": "8.0",
|
||||
"confidence": "0.92",
|
||||
"reliability_score": "0.88",
|
||||
"constraints": {
|
||||
"min_duration_min": 60,
|
||||
"recovery_rate_mw_per_min": "0.5"
|
||||
},
|
||||
"evidence_refs": [
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
],
|
||||
"updated_at": "2026-03-14T06:00:00Z"
|
||||
},
|
||||
"fulfillment": [
|
||||
{
|
||||
"market_date": "2026-03-13",
|
||||
"planned_mwh": "40.0",
|
||||
"delivered_mwh": "38.0"
|
||||
},
|
||||
{
|
||||
"market_date": "2026-03-14",
|
||||
"planned_mwh": "40.0",
|
||||
"delivered_mwh": "39.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"features_snapshot_ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
216
contracts/fixtures/potential_assessment_result/basic.json
Normal file
216
contracts/fixtures/potential_assessment_result/basic.json
Normal file
@ -0,0 +1,216 @@
|
||||
{
|
||||
"market_date": "2026-03-15",
|
||||
"assessments": [
|
||||
{
|
||||
"resource_id": "res-storage-01",
|
||||
"adjustable_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700"
|
||||
]
|
||||
},
|
||||
"confidence": "0.90",
|
||||
"fulfillment_rate": "0.9625",
|
||||
"evidence_days": 2
|
||||
}
|
||||
],
|
||||
"total_adjustable_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700",
|
||||
"7.700"
|
||||
]
|
||||
},
|
||||
"skill_version": "1.0.0"
|
||||
}
|
||||
244
contracts/fixtures/proposal/dispatch-plan.json
Normal file
244
contracts/fixtures/proposal/dispatch-plan.json
Normal file
@ -0,0 +1,244 @@
|
||||
{
|
||||
"id": "prop-002",
|
||||
"type": "DISPATCH_PLAN",
|
||||
"timescale": "DAY_AHEAD",
|
||||
"originator": {
|
||||
"agent": "resource-agent",
|
||||
"trigger": "EVENT",
|
||||
"task_id": "task-002"
|
||||
},
|
||||
"payload": {
|
||||
"kind": "DISPATCH_PLAN",
|
||||
"market_date": "2026-03-15",
|
||||
"award_ref": "award-2026-03-15",
|
||||
"total_target_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0"
|
||||
]
|
||||
},
|
||||
"allocations": [
|
||||
{
|
||||
"unit_id": "agg-unit-wuhan-01",
|
||||
"target_mw": {
|
||||
"interval_minutes": 15,
|
||||
"date": "2026-03-15",
|
||||
"values": [
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0",
|
||||
"48.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"lineage": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"tool_call_id": "tc-002",
|
||||
"tool": "dispatch-optimization",
|
||||
"version": "1.0.0",
|
||||
"inputs_ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"outputs_ref": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
}
|
||||
],
|
||||
"data_refs": [
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
],
|
||||
"ledger_version": 43,
|
||||
"policy_pack_version": "2026.03"
|
||||
},
|
||||
"envelope_ref": null,
|
||||
"digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"status": "DRAFT",
|
||||
"created_at": "2026-03-14T08:30:00Z"
|
||||
}
|
||||
48
contracts/fixtures/review_finding/d-plus-1.json
Normal file
48
contracts/fixtures/review_finding/d-plus-1.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"id": "rf-2026-03-15-response",
|
||||
"market_date": "2026-03-15",
|
||||
"kind": "RESPONSE",
|
||||
"summary": "Unit agg-unit-wuhan-01 delivered 95.0% of plan; reliability score adjusted.",
|
||||
"attribution": {
|
||||
"trading_mwh": "48.000",
|
||||
"control_mwh": "0.000",
|
||||
"response_mwh": "57.600",
|
||||
"forecast_load_mape": "0.041",
|
||||
"forecast_price_mape": "0.017",
|
||||
"net_deviation_mwh": "57.600"
|
||||
},
|
||||
"writebacks": [
|
||||
{
|
||||
"target": "SEMANTIC_MEMORY",
|
||||
"topic": "response:agg-unit-wuhan-01",
|
||||
"lesson": "Evening block under-delivery on 2026-03-15."
|
||||
},
|
||||
{
|
||||
"target": "RESOURCE_PROFILE",
|
||||
"resource_id": "res-storage-01",
|
||||
"previous_reliability_score": "0.88",
|
||||
"new_reliability_score": "0.894",
|
||||
"fulfillment_ratio": "0.950"
|
||||
},
|
||||
{
|
||||
"target": "ENVELOPE_RECOMMENDATION",
|
||||
"envelope_id": "env-bid-001",
|
||||
"action": "WIDEN",
|
||||
"bound": "price_deviation_pct",
|
||||
"current_value": "5.0",
|
||||
"recommended_value": "6.0",
|
||||
"basis": "26/26 within-envelope days under threshold"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
],
|
||||
"lineage_refs": [
|
||||
{
|
||||
"tool_call_id": "tc-review-001",
|
||||
"path": "attribution"
|
||||
}
|
||||
],
|
||||
"generated_at": "2026-03-16T03:00:00Z"
|
||||
}
|
||||
10
contracts/fixtures/semantic_memory_entry/basic.json
Normal file
10
contracts/fixtures/semantic_memory_entry/basic.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "mem-001",
|
||||
"topic": "forecast:load:hot-day",
|
||||
"lesson": "Hot-day HVAC load systematically under-forecast; feature-engineering candidate.",
|
||||
"source_finding_id": "rf-2026-03-15-response",
|
||||
"evidence_refs": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
],
|
||||
"created_at": "2026-03-16T03:00:00Z"
|
||||
}
|
||||
90
contracts/schema/award_notice.json
Normal file
90
contracts/schema/award_notice.json
Normal file
@ -0,0 +1,90 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"bid_proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"awarded_mwh": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"clearing_price_yuan_per_mwh": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"received_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"market_date",
|
||||
"bid_proposal_digest",
|
||||
"awarded_mwh",
|
||||
"clearing_price_yuan_per_mwh",
|
||||
"received_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/award_notice.json",
|
||||
"title": "AwardNotice"
|
||||
}
|
||||
97
contracts/schema/dispatch_optimization_request.json
Normal file
97
contracts/schema/dispatch_optimization_request.json
Normal file
@ -0,0 +1,97 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"target_mw": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"units": {
|
||||
"minItems": 1,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"unit_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"available_mw": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"cost_weight": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"unit_id",
|
||||
"available_mw",
|
||||
"cost_weight"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"market_date",
|
||||
"target_mw",
|
||||
"units"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/dispatch_optimization_request.json",
|
||||
"title": "DispatchOptimizationRequest"
|
||||
}
|
||||
136
contracts/schema/dispatch_optimization_result.json
Normal file
136
contracts/schema/dispatch_optimization_result.json
Normal file
@ -0,0 +1,136 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"total_target_mw": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"allocations": {
|
||||
"minItems": 1,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"unit_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"target_mw": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"unit_id",
|
||||
"target_mw"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"shortfall_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"solver": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"OPTIMAL",
|
||||
"INFEASIBLE",
|
||||
"ERROR"
|
||||
]
|
||||
},
|
||||
"wall_time_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"version",
|
||||
"status",
|
||||
"wall_time_ms"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"skill_version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"market_date",
|
||||
"total_target_mw",
|
||||
"allocations",
|
||||
"shortfall_mwh",
|
||||
"solver",
|
||||
"skill_version"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/dispatch_optimization_result.json",
|
||||
"title": "DispatchOptimizationResult"
|
||||
}
|
||||
59
contracts/schema/dispatch_proposal_draft.json
Normal file
59
contracts/schema/dispatch_proposal_draft.json
Normal file
@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"total_target_ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"allocations_ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rationale": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"market_date",
|
||||
"total_target_ref",
|
||||
"allocations_ref",
|
||||
"rationale"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/dispatch_proposal_draft.json",
|
||||
"title": "DispatchProposalDraft"
|
||||
}
|
||||
65
contracts/schema/envelope_change_request.json
Normal file
65
contracts/schema/envelope_change_request.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"envelope_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"WIDEN",
|
||||
"NARROW",
|
||||
"SUSPEND",
|
||||
"REACTIVATE"
|
||||
]
|
||||
},
|
||||
"bounds": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
},
|
||||
"basis": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"source_finding_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requested_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"envelope_id",
|
||||
"action",
|
||||
"bounds",
|
||||
"basis",
|
||||
"source_finding_id",
|
||||
"requested_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/envelope_change_request.json",
|
||||
"title": "EnvelopeChangeRequest"
|
||||
}
|
||||
105
contracts/schema/execution_report.json
Normal file
105
contracts/schema/execution_report.json
Normal file
@ -0,0 +1,105 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"unit_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"dispatch_proposal_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"planned_mw": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"actual_mw": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"deviation_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"fulfillment_ratio": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"reported_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"market_date",
|
||||
"unit_id",
|
||||
"dispatch_proposal_digest",
|
||||
"planned_mw",
|
||||
"actual_mw",
|
||||
"deviation_mwh",
|
||||
"fulfillment_ratio",
|
||||
"reported_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/execution_report.json",
|
||||
"title": "ExecutionReport"
|
||||
}
|
||||
135
contracts/schema/insight_card.json
Normal file
135
contracts/schema/insight_card.json
Normal file
@ -0,0 +1,135 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"page": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"TRADING_DESK",
|
||||
"RESOURCE_POOL",
|
||||
"OPERATIONS_DASHBOARD"
|
||||
]
|
||||
},
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"headline": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"metrics": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"value",
|
||||
"unit",
|
||||
"ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"SITUATION_REPORT",
|
||||
"REVIEW_FINDING",
|
||||
"DECISION_CASE",
|
||||
"POTENTIAL_ASSESSMENT"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"id",
|
||||
"ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"generated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"page",
|
||||
"market_date",
|
||||
"title",
|
||||
"headline",
|
||||
"metrics",
|
||||
"source",
|
||||
"generated_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/insight_card.json",
|
||||
"title": "InsightCard"
|
||||
}
|
||||
85
contracts/schema/metering_record.json
Normal file
85
contracts/schema/metering_record.json
Normal file
@ -0,0 +1,85 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"metered_mw": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"actual_price_yuan_per_mwh": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"received_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"market_date",
|
||||
"metered_mw",
|
||||
"actual_price_yuan_per_mwh",
|
||||
"received_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/metering_record.json",
|
||||
"title": "MeteringRecord"
|
||||
}
|
||||
161
contracts/schema/potential_assessment_request.json
Normal file
161
contracts/schema/potential_assessment_request.json
Normal file
@ -0,0 +1,161 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"resources": {
|
||||
"minItems": 1,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resource_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"STORAGE",
|
||||
"PV",
|
||||
"EV_CHARGER",
|
||||
"HVAC",
|
||||
"INDUSTRIAL_LOAD",
|
||||
"MICROGRID",
|
||||
"BASE_STATION"
|
||||
]
|
||||
},
|
||||
"rated_power_mw": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"certified_adjustable_mw": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"reliability_score": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"constraints": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_duration_min": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"recovery_rate_mw_per_min": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"min_duration_min",
|
||||
"recovery_rate_mw_per_min"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"evidence_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
}
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resource_id",
|
||||
"name",
|
||||
"type",
|
||||
"rated_power_mw",
|
||||
"certified_adjustable_mw",
|
||||
"confidence",
|
||||
"reliability_score",
|
||||
"constraints",
|
||||
"evidence_refs",
|
||||
"updated_at"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"fulfillment": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"planned_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"delivered_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"market_date",
|
||||
"planned_mwh",
|
||||
"delivered_mwh"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"profile",
|
||||
"fulfillment"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"features_snapshot_ref": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"market_date",
|
||||
"resources",
|
||||
"features_snapshot_ref"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/potential_assessment_request.json",
|
||||
"title": "PotentialAssessmentRequest"
|
||||
}
|
||||
119
contracts/schema/potential_assessment_result.json
Normal file
119
contracts/schema/potential_assessment_result.json
Normal file
@ -0,0 +1,119 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"assessments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resource_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"adjustable_mw": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"fulfillment_rate": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence_days": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resource_id",
|
||||
"adjustable_mw",
|
||||
"confidence",
|
||||
"fulfillment_rate",
|
||||
"evidence_days"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"total_adjustable_mw": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"skill_version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"market_date",
|
||||
"assessments",
|
||||
"total_adjustable_mw",
|
||||
"skill_version"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/potential_assessment_result.json",
|
||||
"title": "PotentialAssessmentResult"
|
||||
}
|
||||
@ -248,6 +248,272 @@
|
||||
"payload"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"timescale": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ANNUAL",
|
||||
"MONTHLY",
|
||||
"DAY_AHEAD",
|
||||
"INTRADAY",
|
||||
"REALTIME"
|
||||
]
|
||||
},
|
||||
"originator": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"trigger": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"MANUAL",
|
||||
"EVENT",
|
||||
"SCHEDULED"
|
||||
]
|
||||
},
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent",
|
||||
"trigger",
|
||||
"task_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"lineage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_calls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"tool": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"inputs_ref": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"outputs_ref": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"tool",
|
||||
"version",
|
||||
"inputs_ref",
|
||||
"outputs_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"data_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
}
|
||||
},
|
||||
"ledger_version": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"policy_pack_version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_calls",
|
||||
"data_refs",
|
||||
"ledger_version",
|
||||
"policy_pack_version"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"envelope_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"DRAFT",
|
||||
"RULE_CHECK",
|
||||
"SIMULATION",
|
||||
"ENVELOPE_CHECK",
|
||||
"PENDING_HUMAN",
|
||||
"AUTO_APPROVED",
|
||||
"APPROVED",
|
||||
"FRESH_CHECK",
|
||||
"STALE",
|
||||
"AUTHORIZED",
|
||||
"RELEASED",
|
||||
"EXECUTING",
|
||||
"COMPLETED",
|
||||
"ROLLED_BACK",
|
||||
"REJECTED"
|
||||
]
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "DISPATCH_PLAN"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"const": "DISPATCH_PLAN"
|
||||
},
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"award_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"total_target_mw": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"allocations": {
|
||||
"minItems": 1,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"unit_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"target_mw": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_minutes": {
|
||||
"type": "number",
|
||||
"const": 15
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"values": {
|
||||
"minItems": 96,
|
||||
"maxItems": 96,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"interval_minutes",
|
||||
"date",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"unit_id",
|
||||
"target_mw"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"market_date",
|
||||
"award_ref",
|
||||
"total_target_mw",
|
||||
"allocations"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"timescale",
|
||||
"originator",
|
||||
"lineage",
|
||||
"envelope_ref",
|
||||
"digest",
|
||||
"status",
|
||||
"created_at",
|
||||
"type",
|
||||
"payload"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"$id": "https://vpp-ai-platform/contracts/proposal.json",
|
||||
|
||||
237
contracts/schema/review_finding.json
Normal file
237
contracts/schema/review_finding.json
Normal file
@ -0,0 +1,237 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"market_date": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"FORECAST",
|
||||
"RESPONSE",
|
||||
"TRADING",
|
||||
"CONTROL"
|
||||
]
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"attribution": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"trading_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"control_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"response_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"forecast_load_mape": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"forecast_price_mape": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"net_deviation_mwh": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"trading_mwh",
|
||||
"control_mwh",
|
||||
"response_mwh",
|
||||
"forecast_load_mape",
|
||||
"forecast_price_mape",
|
||||
"net_deviation_mwh"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"writebacks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"const": "SEMANTIC_MEMORY"
|
||||
},
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"lesson": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"target",
|
||||
"topic",
|
||||
"lesson"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"const": "RESOURCE_PROFILE"
|
||||
},
|
||||
"resource_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"previous_reliability_score": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"new_reliability_score": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"fulfillment_ratio": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"target",
|
||||
"resource_id",
|
||||
"previous_reliability_score",
|
||||
"new_reliability_score",
|
||||
"fulfillment_ratio"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"const": "ENVELOPE_RECOMMENDATION"
|
||||
},
|
||||
"envelope_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"WIDEN",
|
||||
"NARROW",
|
||||
"SUSPEND",
|
||||
"KEEP"
|
||||
]
|
||||
},
|
||||
"bound": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"current_value": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"recommended_value": {
|
||||
"type": "string",
|
||||
"pattern": "^-?\\d+(\\.\\d+)?$"
|
||||
},
|
||||
"basis": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"target",
|
||||
"envelope_id",
|
||||
"action",
|
||||
"bound",
|
||||
"current_value",
|
||||
"recommended_value",
|
||||
"basis"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"evidence_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
}
|
||||
},
|
||||
"lineage_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_call_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tool_call_id",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"generated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"market_date",
|
||||
"kind",
|
||||
"summary",
|
||||
"attribution",
|
||||
"writebacks",
|
||||
"evidence_refs",
|
||||
"lineage_refs",
|
||||
"generated_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/review_finding.json",
|
||||
"title": "ReviewFinding"
|
||||
}
|
||||
45
contracts/schema/semantic_memory_entry.json
Normal file
45
contracts/schema/semantic_memory_entry.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"lesson": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"source_finding_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"evidence_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
}
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"topic",
|
||||
"lesson",
|
||||
"source_finding_id",
|
||||
"evidence_refs",
|
||||
"created_at"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$id": "https://vpp-ai-platform/contracts/semantic_memory_entry.json",
|
||||
"title": "SemanticMemoryEntry"
|
||||
}
|
||||
@ -29,6 +29,7 @@ values for items on this list** — wire named config with placeholder + an
|
||||
| B7 | 极端日协议触发的气象条件 | `abnormal_day.triggers` |
|
||||
| B8 | 各级熔断(L0–L4)授权岗位与恢复条件 | `breaker.authority` |
|
||||
| B9 | 演习样本制度:频率与考核挂钩方式 | — (制度非配置) |
|
||||
| B10 | 可靠性评分与可调潜力辨识口径:评分更新权重(EWMA α)、证据窗口与饱和天数、包络放宽所需连续达标天数与放宽步长 | `review.reliability_alpha` / `potential.evidence_window` / `envelope.widen_*` |
|
||||
|
||||
## C. KPI 口径(owner: 运营 + 验收双方 · blocks M5 dashboard)
|
||||
|
||||
|
||||
@ -57,6 +57,31 @@ const valid: Record<string, Record<string, unknown>> = {
|
||||
status: 'DRAFT',
|
||||
created_at: T1,
|
||||
},
|
||||
'dispatch-plan': {
|
||||
id: 'prop-002',
|
||||
type: 'DISPATCH_PLAN',
|
||||
timescale: 'DAY_AHEAD',
|
||||
originator: { agent: 'resource-agent', trigger: 'EVENT', task_id: 'task-002' },
|
||||
payload: {
|
||||
kind: 'DISPATCH_PLAN',
|
||||
market_date: DATE,
|
||||
award_ref: 'award-2026-03-15',
|
||||
total_target_mw: curve('48.0'),
|
||||
allocations: [{ unit_id: 'agg-unit-wuhan-01', target_mw: curve('48.0') }],
|
||||
},
|
||||
lineage: {
|
||||
tool_calls: [
|
||||
{ tool_call_id: 'tc-002', tool: 'dispatch-optimization', version: '1.0.0', inputs_ref: REF_A, outputs_ref: REF_C },
|
||||
],
|
||||
data_refs: [REF_C],
|
||||
ledger_version: 43,
|
||||
policy_pack_version: '2026.03',
|
||||
},
|
||||
envelope_ref: null,
|
||||
digest: REF_C,
|
||||
status: 'DRAFT',
|
||||
created_at: T1,
|
||||
},
|
||||
},
|
||||
approval: {
|
||||
approve: {
|
||||
@ -343,6 +368,162 @@ const valid: Record<string, Record<string, unknown>> = {
|
||||
exported_at: T1,
|
||||
},
|
||||
},
|
||||
award_notice: {
|
||||
'da-award': {
|
||||
id: 'award-2026-03-15',
|
||||
market_date: DATE,
|
||||
bid_proposal_digest: REF_B,
|
||||
awarded_mwh: curve('12.0'),
|
||||
clearing_price_yuan_per_mwh: curve('418.30'),
|
||||
received_at: '2026-03-14T08:00:00Z',
|
||||
},
|
||||
},
|
||||
dispatch_proposal_draft: {
|
||||
basic: {
|
||||
market_date: DATE,
|
||||
total_target_ref: { tool_call_id: 'tc-002', path: 'total_target_mw' },
|
||||
allocations_ref: { tool_call_id: 'tc-002', path: 'allocations' },
|
||||
rationale: 'Storage covers the evening block; industrial load takes the midday share.',
|
||||
},
|
||||
},
|
||||
execution_report: {
|
||||
'unit-01': {
|
||||
id: 'exec-2026-03-15-agg-unit-wuhan-01',
|
||||
market_date: DATE,
|
||||
unit_id: 'agg-unit-wuhan-01',
|
||||
dispatch_proposal_digest: REF_B,
|
||||
planned_mw: curve('48.0'),
|
||||
actual_mw: curve('45.6'),
|
||||
deviation_mwh: '57.600',
|
||||
fulfillment_ratio: '0.950',
|
||||
reported_at: '2026-03-16T01:00:00Z',
|
||||
},
|
||||
},
|
||||
metering_record: {
|
||||
'da-metering': {
|
||||
id: 'meter-2026-03-15',
|
||||
market_date: DATE,
|
||||
metered_mw: curve('45.6'),
|
||||
actual_price_yuan_per_mwh: curve('421.00'),
|
||||
received_at: '2026-03-16T02:00:00Z',
|
||||
},
|
||||
},
|
||||
potential_assessment_request: {
|
||||
basic: {
|
||||
market_date: DATE,
|
||||
resources: [
|
||||
{
|
||||
profile: {
|
||||
resource_id: 'res-storage-01',
|
||||
name: 'Wuhan industrial park storage #1',
|
||||
type: 'STORAGE',
|
||||
rated_power_mw: '10.0',
|
||||
certified_adjustable_mw: '8.0',
|
||||
confidence: '0.92',
|
||||
reliability_score: '0.88',
|
||||
constraints: { min_duration_min: 60, recovery_rate_mw_per_min: '0.5' },
|
||||
evidence_refs: [REF_C],
|
||||
updated_at: T0,
|
||||
},
|
||||
fulfillment: [
|
||||
{ market_date: '2026-03-13', planned_mwh: '40.0', delivered_mwh: '38.0' },
|
||||
{ market_date: '2026-03-14', planned_mwh: '40.0', delivered_mwh: '39.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
features_snapshot_ref: REF_A,
|
||||
},
|
||||
},
|
||||
potential_assessment_result: {
|
||||
basic: {
|
||||
market_date: DATE,
|
||||
assessments: [
|
||||
{ resource_id: 'res-storage-01', adjustable_mw: curve('7.700'), confidence: '0.90', fulfillment_rate: '0.9625', evidence_days: 2 },
|
||||
],
|
||||
total_adjustable_mw: curve('7.700'),
|
||||
skill_version: '1.0.0',
|
||||
},
|
||||
},
|
||||
dispatch_optimization_request: {
|
||||
basic: {
|
||||
market_date: DATE,
|
||||
target_mw: curve('48.0'),
|
||||
units: [
|
||||
{ unit_id: 'agg-unit-wuhan-01', available_mw: curve('30.0'), cost_weight: '1.0' },
|
||||
{ unit_id: 'agg-unit-wuhan-02', available_mw: curve('30.0'), cost_weight: '1.2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
dispatch_optimization_result: {
|
||||
basic: {
|
||||
market_date: DATE,
|
||||
total_target_mw: curve('48.0'),
|
||||
allocations: [
|
||||
{ unit_id: 'agg-unit-wuhan-01', target_mw: curve('30.000') },
|
||||
{ unit_id: 'agg-unit-wuhan-02', target_mw: curve('18.000') },
|
||||
],
|
||||
shortfall_mwh: '0.000',
|
||||
solver: { name: 'highs', version: 'scipy-1.17.1', status: 'OPTIMAL', wall_time_ms: 4 },
|
||||
skill_version: '1.0.0',
|
||||
},
|
||||
},
|
||||
review_finding: {
|
||||
'd-plus-1': {
|
||||
id: 'rf-2026-03-15-response',
|
||||
market_date: DATE,
|
||||
kind: 'RESPONSE',
|
||||
summary: 'Unit agg-unit-wuhan-01 delivered 95.0% of plan; reliability score adjusted.',
|
||||
attribution: {
|
||||
trading_mwh: '48.000',
|
||||
control_mwh: '0.000',
|
||||
response_mwh: '57.600',
|
||||
forecast_load_mape: '0.041',
|
||||
forecast_price_mape: '0.017',
|
||||
net_deviation_mwh: '57.600',
|
||||
},
|
||||
writebacks: [
|
||||
{ target: 'SEMANTIC_MEMORY', topic: 'response:agg-unit-wuhan-01', lesson: 'Evening block under-delivery on 2026-03-15.' },
|
||||
{ target: 'RESOURCE_PROFILE', resource_id: 'res-storage-01', previous_reliability_score: '0.88', new_reliability_score: '0.894', fulfillment_ratio: '0.950' },
|
||||
{ target: 'ENVELOPE_RECOMMENDATION', envelope_id: 'env-bid-001', action: 'WIDEN', bound: 'price_deviation_pct', current_value: '5.0', recommended_value: '6.0', basis: '26/26 within-envelope days under threshold' },
|
||||
],
|
||||
evidence_refs: [REF_A, REF_C],
|
||||
lineage_refs: [{ tool_call_id: 'tc-review-001', path: 'attribution' }],
|
||||
generated_at: '2026-03-16T03:00:00Z',
|
||||
},
|
||||
},
|
||||
semantic_memory_entry: {
|
||||
basic: {
|
||||
id: 'mem-001',
|
||||
topic: 'forecast:load:hot-day',
|
||||
lesson: 'Hot-day HVAC load systematically under-forecast; feature-engineering candidate.',
|
||||
source_finding_id: 'rf-2026-03-15-response',
|
||||
evidence_refs: [REF_A],
|
||||
created_at: '2026-03-16T03:00:00Z',
|
||||
},
|
||||
},
|
||||
envelope_change_request: {
|
||||
widen: {
|
||||
id: 'ecr-001',
|
||||
envelope_id: 'env-bid-001',
|
||||
action: 'WIDEN',
|
||||
bounds: { price_deviation_pct: '6.0', max_energy_mwh: '1500.0' },
|
||||
basis: 'ReviewFinding rf-2026-03-15-response: 26/26 within-envelope days under threshold',
|
||||
source_finding_id: 'rf-2026-03-15-response',
|
||||
requested_at: '2026-03-16T03:00:00Z',
|
||||
},
|
||||
},
|
||||
insight_card: {
|
||||
'trading-desk': {
|
||||
id: 'card-001',
|
||||
page: 'TRADING_DESK',
|
||||
market_date: DATE,
|
||||
title: 'Day-ahead situation',
|
||||
headline: 'Risk LOW; P50 peak price 425.50 yuan/MWh',
|
||||
metrics: [{ name: 'peak_price_p50', value: '425.50', unit: 'yuan_per_mwh', ref: { tool_call_id: 'tc-001', path: 'quantiles.p50.values.76' } }],
|
||||
source: { kind: 'SITUATION_REPORT', id: 'sit-001', ref: REF_A },
|
||||
generated_at: T0,
|
||||
},
|
||||
},
|
||||
situation_report: {
|
||||
'normal-day': {
|
||||
id: 'sit-001',
|
||||
@ -442,6 +623,14 @@ const invalid: Record<string, Record<string, unknown>> = {
|
||||
// a workflow id that is not a registered template
|
||||
'unknown-template': { workflow_id: 'transfer-funds', params: { market_date: DATE }, confidence: 'HIGH' },
|
||||
},
|
||||
review_finding: {
|
||||
// a writeback with an unknown target — findings may only write to the three sanctioned stores
|
||||
'unknown-writeback': (() => {
|
||||
const o = structuredClone(valid['review_finding']!['d-plus-1']) as any
|
||||
o.writebacks = [{ target: 'LEDGER', note: 'nope' }]
|
||||
return o
|
||||
})(),
|
||||
},
|
||||
position_update: {
|
||||
// missing optimistic-concurrency field
|
||||
'missing-version': (() => {
|
||||
|
||||
119
packages/domain/src/dispatch.ts
Normal file
119
packages/domain/src/dispatch.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { z } from 'zod'
|
||||
import { Curve96, DecimalString, Id, IsoUtc, MarketDate, SnapshotRef } from './common.js'
|
||||
import { LineageRef } from './chain.js'
|
||||
import { UnitAllocation } from './proposal.js'
|
||||
import { ResourceProfile } from './resource.js'
|
||||
import { SkillVersion } from './skill.js'
|
||||
|
||||
/**
|
||||
* D-1 16:00 award handling and the resource-dispatch agent's objects
|
||||
* (docs/07 D-1 16:00, docs/04 §1–2, docs/05 §2.2).
|
||||
*/
|
||||
|
||||
/** Market clearing result for one day (出清 / 中标) — written by the trading-platform adapter. */
|
||||
export const AwardNotice = z.object({
|
||||
id: Id,
|
||||
market_date: MarketDate,
|
||||
/** Digest of the bid proposal this award answers. */
|
||||
bid_proposal_digest: SnapshotRef,
|
||||
awarded_mwh: Curve96,
|
||||
clearing_price_yuan_per_mwh: Curve96,
|
||||
received_at: IsoUtc,
|
||||
})
|
||||
export type AwardNotice = z.infer<typeof AwardNotice>
|
||||
|
||||
/** What the resource agent's LLM step may emit: references and prose only (P2). */
|
||||
export const DispatchProposalDraft = z.object({
|
||||
market_date: MarketDate,
|
||||
total_target_ref: LineageRef,
|
||||
allocations_ref: LineageRef,
|
||||
rationale: z.string().min(1),
|
||||
})
|
||||
export type DispatchProposalDraft = z.infer<typeof DispatchProposalDraft>
|
||||
|
||||
/** Execution outcome per unit (docs/04 §2 ExecutionReport → review). */
|
||||
export const ExecutionReport = z.object({
|
||||
id: Id,
|
||||
market_date: MarketDate,
|
||||
unit_id: Id,
|
||||
dispatch_proposal_digest: SnapshotRef,
|
||||
planned_mw: Curve96,
|
||||
actual_mw: Curve96,
|
||||
/** |actual − planned| summed over intervals, MWh. */
|
||||
deviation_mwh: DecimalString,
|
||||
/** Share of planned energy delivered, 0–1 as decimal string. */
|
||||
fulfillment_ratio: DecimalString,
|
||||
reported_at: IsoUtc,
|
||||
})
|
||||
export type ExecutionReport = z.infer<typeof ExecutionReport>
|
||||
|
||||
/** Metering facts for the day (docs/06 Settlement precursor). */
|
||||
export const MeteringRecord = z.object({
|
||||
id: Id,
|
||||
market_date: MarketDate,
|
||||
metered_mw: Curve96,
|
||||
actual_price_yuan_per_mwh: Curve96,
|
||||
received_at: IsoUtc,
|
||||
})
|
||||
export type MeteringRecord = z.infer<typeof MeteringRecord>
|
||||
|
||||
// ---- skills (docs/05 §2.2) --------------------------------------------------
|
||||
|
||||
/** History of what a resource was asked for vs delivered — the evidence a potential assessment must cite. */
|
||||
export const FulfillmentRecord = z.object({
|
||||
market_date: MarketDate,
|
||||
planned_mwh: DecimalString,
|
||||
delivered_mwh: DecimalString,
|
||||
})
|
||||
|
||||
export const PotentialAssessmentRequest = z.object({
|
||||
market_date: MarketDate,
|
||||
resources: z.array(z.object({ profile: ResourceProfile, fulfillment: z.array(FulfillmentRecord) })).min(1),
|
||||
features_snapshot_ref: SnapshotRef,
|
||||
})
|
||||
export type PotentialAssessmentRequest = z.infer<typeof PotentialAssessmentRequest>
|
||||
|
||||
export const PotentialAssessmentResult = z.object({
|
||||
market_date: MarketDate,
|
||||
assessments: z.array(
|
||||
z.object({
|
||||
resource_id: Id,
|
||||
adjustable_mw: Curve96,
|
||||
/** 0–1 */
|
||||
confidence: DecimalString,
|
||||
/** Rolling fulfillment rate used for de-rating; null when no history. */
|
||||
fulfillment_rate: DecimalString.nullable(),
|
||||
evidence_days: z.int().nonnegative(),
|
||||
}),
|
||||
),
|
||||
total_adjustable_mw: Curve96,
|
||||
skill_version: SkillVersion,
|
||||
})
|
||||
export type PotentialAssessmentResult = z.infer<typeof PotentialAssessmentResult>
|
||||
|
||||
export const DispatchOptimizationRequest = z.object({
|
||||
market_date: MarketDate,
|
||||
target_mw: Curve96,
|
||||
units: z
|
||||
.array(
|
||||
z.object({
|
||||
unit_id: Id,
|
||||
available_mw: Curve96,
|
||||
/** Relative cost/wear weight; lower is dispatched first. */
|
||||
cost_weight: DecimalString,
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
})
|
||||
export type DispatchOptimizationRequest = z.infer<typeof DispatchOptimizationRequest>
|
||||
|
||||
export const DispatchOptimizationResult = z.object({
|
||||
market_date: MarketDate,
|
||||
total_target_mw: Curve96,
|
||||
allocations: z.array(UnitAllocation).min(1),
|
||||
/** Σ over intervals of max(0, target − Σ allocations), MWh. Non-zero means capacity was short. */
|
||||
shortfall_mwh: DecimalString,
|
||||
solver: z.object({ name: z.string().min(1), version: z.string().min(1), status: z.enum(['OPTIMAL', 'INFEASIBLE', 'ERROR']), wall_time_ms: z.int().nonnegative() }),
|
||||
skill_version: SkillVersion,
|
||||
})
|
||||
export type DispatchOptimizationResult = z.infer<typeof DispatchOptimizationResult>
|
||||
@ -25,6 +25,17 @@ import {
|
||||
StaleDenial,
|
||||
ValidationResult,
|
||||
} from './chain.js'
|
||||
import {
|
||||
AwardNotice,
|
||||
DispatchOptimizationRequest,
|
||||
DispatchOptimizationResult,
|
||||
DispatchProposalDraft,
|
||||
ExecutionReport,
|
||||
MeteringRecord,
|
||||
PotentialAssessmentRequest,
|
||||
PotentialAssessmentResult,
|
||||
} from './dispatch.js'
|
||||
import { EnvelopeChangeRequest, InsightCard, ReviewFinding, SemanticMemoryEntry } from './review.js'
|
||||
|
||||
export * from './common.js'
|
||||
export * from './proposal.js'
|
||||
@ -38,6 +49,8 @@ export * from './case.js'
|
||||
export * from './event.js'
|
||||
export * from './skill.js'
|
||||
export * from './chain.js'
|
||||
export * from './dispatch.js'
|
||||
export * from './review.js'
|
||||
|
||||
/**
|
||||
* Registry driving the contracts pipeline: keys become schema/fixture/module
|
||||
@ -69,4 +82,16 @@ export const schemaRegistry: Record<string, z.ZodType> = {
|
||||
bid_proposal_draft: BidProposalDraft,
|
||||
router_decision: RouterDecision,
|
||||
bid_export_file: BidExportFile,
|
||||
award_notice: AwardNotice,
|
||||
dispatch_proposal_draft: DispatchProposalDraft,
|
||||
execution_report: ExecutionReport,
|
||||
metering_record: MeteringRecord,
|
||||
potential_assessment_request: PotentialAssessmentRequest,
|
||||
potential_assessment_result: PotentialAssessmentResult,
|
||||
dispatch_optimization_request: DispatchOptimizationRequest,
|
||||
dispatch_optimization_result: DispatchOptimizationResult,
|
||||
review_finding: ReviewFinding,
|
||||
semantic_memory_entry: SemanticMemoryEntry,
|
||||
envelope_change_request: EnvelopeChangeRequest,
|
||||
insight_card: InsightCard,
|
||||
}
|
||||
|
||||
@ -57,6 +57,22 @@ export const BidPayload = z.object({
|
||||
})
|
||||
export type BidPayload = z.infer<typeof BidPayload>
|
||||
|
||||
/** Dispatch plan: awarded energy decomposed to aggregation units (docs/04 §1: province → unit granularity only). */
|
||||
export const UnitAllocation = z.object({
|
||||
unit_id: Id,
|
||||
target_mw: Curve96,
|
||||
})
|
||||
export type UnitAllocation = z.infer<typeof UnitAllocation>
|
||||
|
||||
export const DispatchPlanPayload = z.object({
|
||||
kind: z.literal('DISPATCH_PLAN'),
|
||||
market_date: MarketDate,
|
||||
award_ref: Id,
|
||||
total_target_mw: Curve96,
|
||||
allocations: z.array(UnitAllocation).min(1),
|
||||
})
|
||||
export type DispatchPlanPayload = z.infer<typeof DispatchPlanPayload>
|
||||
|
||||
const ProposalBase = z.object({
|
||||
id: Id,
|
||||
timescale: Timescale,
|
||||
@ -70,10 +86,11 @@ const ProposalBase = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* M1 carries the BID variant only; INVITATION / CONTROL_PLAN / DISPATCH_PLAN
|
||||
* payloads arrive with their milestones (ROADMAP M4 / phase 2).
|
||||
* BID (M1) and DISPATCH_PLAN (M4). INVITATION / CONTROL_PLAN payloads are
|
||||
* phase 2 (interaction and load-control agents).
|
||||
*/
|
||||
export const Proposal = z.discriminatedUnion('type', [
|
||||
ProposalBase.extend({ type: z.literal('BID'), payload: BidPayload }),
|
||||
ProposalBase.extend({ type: z.literal('DISPATCH_PLAN'), payload: DispatchPlanPayload }),
|
||||
])
|
||||
export type Proposal = z.infer<typeof Proposal>
|
||||
|
||||
100
packages/domain/src/review.ts
Normal file
100
packages/domain/src/review.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { z } from 'zod'
|
||||
import { DecimalString, Id, IsoUtc, MarketDate, SnapshotRef } from './common.js'
|
||||
import { LineageRef } from './chain.js'
|
||||
|
||||
/**
|
||||
* Review loop objects (docs/02 §4, docs/07 D+1): the structured lesson and
|
||||
* the three writebacks it drives — semantic memory, resource reliability,
|
||||
* envelope recommendations. Every number in a finding is a lineage reference
|
||||
* or a value computed by the deterministic review service, never LLM text.
|
||||
*/
|
||||
export const DeviationKind = z.enum(['FORECAST', 'RESPONSE', 'TRADING', 'CONTROL'])
|
||||
|
||||
export const Attribution = z.object({
|
||||
/** Bid vs award — market did not clear what we offered. */
|
||||
trading_mwh: DecimalString,
|
||||
/** Award vs dispatch plan — decomposition could not cover the award. */
|
||||
control_mwh: DecimalString,
|
||||
/** Dispatch plan vs execution — units under/over-delivered. */
|
||||
response_mwh: DecimalString,
|
||||
/** Forecast P50 vs metered, price MAPE. */
|
||||
forecast_load_mape: DecimalString.nullable(),
|
||||
forecast_price_mape: DecimalString.nullable(),
|
||||
/** Metered vs planned, the settlement-relevant gap. */
|
||||
net_deviation_mwh: DecimalString,
|
||||
})
|
||||
|
||||
export const Writeback = z.discriminatedUnion('target', [
|
||||
z.object({ target: z.literal('SEMANTIC_MEMORY'), topic: z.string().min(1), lesson: z.string().min(1) }),
|
||||
z.object({
|
||||
target: z.literal('RESOURCE_PROFILE'),
|
||||
resource_id: Id,
|
||||
previous_reliability_score: DecimalString,
|
||||
new_reliability_score: DecimalString,
|
||||
fulfillment_ratio: DecimalString,
|
||||
}),
|
||||
z.object({
|
||||
target: z.literal('ENVELOPE_RECOMMENDATION'),
|
||||
envelope_id: Id,
|
||||
action: z.enum(['WIDEN', 'NARROW', 'SUSPEND', 'KEEP']),
|
||||
bound: z.string().min(1),
|
||||
current_value: DecimalString,
|
||||
recommended_value: DecimalString,
|
||||
basis: z.string().min(1),
|
||||
}),
|
||||
])
|
||||
export type Writeback = z.infer<typeof Writeback>
|
||||
|
||||
export const ReviewFinding = z.object({
|
||||
id: Id,
|
||||
market_date: MarketDate,
|
||||
kind: DeviationKind,
|
||||
summary: z.string().min(1),
|
||||
attribution: Attribution,
|
||||
writebacks: z.array(Writeback),
|
||||
evidence_refs: z.array(SnapshotRef),
|
||||
/** Tool calls the numbers came from. */
|
||||
lineage_refs: z.array(LineageRef),
|
||||
generated_at: IsoUtc,
|
||||
})
|
||||
export type ReviewFinding = z.infer<typeof ReviewFinding>
|
||||
|
||||
/** Semantic memory (docs/02 §3): versioned lessons, injected by templates, never auto-recalled into decisions. */
|
||||
export const SemanticMemoryEntry = z.object({
|
||||
id: Id,
|
||||
topic: z.string().min(1),
|
||||
lesson: z.string().min(1),
|
||||
source_finding_id: Id,
|
||||
evidence_refs: z.array(SnapshotRef),
|
||||
created_at: IsoUtc,
|
||||
})
|
||||
export type SemanticMemoryEntry = z.infer<typeof SemanticMemoryEntry>
|
||||
|
||||
/** Request to change an envelope; approved by a human through the envelope-review workflow. */
|
||||
export const EnvelopeChangeRequest = z.object({
|
||||
id: Id,
|
||||
envelope_id: Id,
|
||||
action: z.enum(['WIDEN', 'NARROW', 'SUSPEND', 'REACTIVATE']),
|
||||
bounds: z.record(z.string(), DecimalString),
|
||||
basis: z.string().min(1),
|
||||
source_finding_id: Id.nullable(),
|
||||
requested_at: IsoUtc,
|
||||
})
|
||||
export type EnvelopeChangeRequest = z.infer<typeof EnvelopeChangeRequest>
|
||||
|
||||
/**
|
||||
* AI insight card (docs/02 §5, docs/06 §4): read-only projection embedded
|
||||
* in an existing business page. Metrics are lineage references; the card
|
||||
* expands to tool outputs → data sources.
|
||||
*/
|
||||
export const InsightCard = z.object({
|
||||
id: Id,
|
||||
page: z.enum(['TRADING_DESK', 'RESOURCE_POOL', 'OPERATIONS_DASHBOARD']),
|
||||
market_date: MarketDate,
|
||||
title: z.string().min(1),
|
||||
headline: z.string().min(1),
|
||||
metrics: z.array(z.object({ name: z.string().min(1), value: DecimalString, unit: z.string().min(1), ref: LineageRef.nullable() })),
|
||||
source: z.object({ kind: z.enum(['SITUATION_REPORT', 'REVIEW_FINDING', 'DECISION_CASE', 'POTENTIAL_ASSESSMENT']), id: Id, ref: SnapshotRef.nullable() }),
|
||||
generated_at: IsoUtc,
|
||||
})
|
||||
export type InsightCard = z.infer<typeof InsightCard>
|
||||
@ -68,6 +68,12 @@ class StubClient implements SkillClient {
|
||||
async report(_req: ReportRequest): Promise<SkillReport> {
|
||||
throw new Error('not used')
|
||||
}
|
||||
async assessPotential(): Promise<never> {
|
||||
throw new Error('not used')
|
||||
}
|
||||
async optimizeDispatch(): Promise<never> {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = { ...DEFAULT_CONFIG, window: 7, holdoutFrom: 10, holdoutDays: 5 }
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { z } from 'zod'
|
||||
import { InsightCard } from '@vpp/domain'
|
||||
import { insightCards } from './cards.js'
|
||||
import { LlmUnavailable } from './llm.js'
|
||||
import type { Runtime } from './runtime.js'
|
||||
import type { TriggerService } from './trigger.js'
|
||||
@ -54,6 +56,17 @@ export function createApi(rt: Runtime, triggers: TriggerService): Server {
|
||||
const result = await triggers.decide(parts[1], decision)
|
||||
return json(res, 200, { run_id: parts[1], status: result.status, outcome: result.status === 'success' ? result.result.outcome : null })
|
||||
}
|
||||
if (req.method === 'GET' && parts[0] === 'cards' && parts[1]) return json(res, 200, insightCards(rt.ctx, InsightCard.shape.page.parse(parts[1])))
|
||||
if (req.method === 'GET' && url.pathname === '/memory') return json(res, 200, rt.ctx.review.memory.list().map((r) => r.value))
|
||||
if (req.method === 'GET' && url.pathname === '/envelopes') return json(res, 200, rt.ctx.envelopes.repo.list().map((r) => r.value))
|
||||
if (req.method === 'GET' && url.pathname === '/findings') return json(res, 200, rt.ctx.findings.list().map((r) => r.value))
|
||||
if (req.method === 'POST' && url.pathname === '/awards') return json(res, 200, await triggers.onAward((await readBody(req)) as never))
|
||||
if (req.method === 'POST' && url.pathname === '/execution-reports') {
|
||||
const body = (await readBody(req)) as unknown[]
|
||||
triggers.onExecutionReports(body as never)
|
||||
return json(res, 200, { accepted: body.length })
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/metering') return json(res, 200, await triggers.onMetering((await readBody(req)) as never))
|
||||
if (req.method === 'POST' && url.pathname === '/tasks') {
|
||||
const body = TaskBody.parse(await readBody(req))
|
||||
return json(res, 200, await triggers.manual(body.message))
|
||||
|
||||
86
packages/runtime/src/cards.ts
Normal file
86
packages/runtime/src/cards.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import type { InsightCard, ReviewFinding, SituationReport, ToolCallRef } from '@vpp/domain'
|
||||
import type { RuntimeContext } from './context.js'
|
||||
|
||||
/**
|
||||
* AI insight cards (docs/02 §5, docs/06 §4): read-only projections over the
|
||||
* event log, findings and cases for embedding in existing business pages.
|
||||
* Every metric that is a tool output carries a lineage ref; review-computed
|
||||
* values carry null refs and name their finding as source instead.
|
||||
*/
|
||||
export function insightCards(ctx: RuntimeContext, page: InsightCard['page']): InsightCard[] {
|
||||
const now = ctx.clock()
|
||||
const cards: InsightCard[] = []
|
||||
const situations = ctx.events.list({ event_type: 'SituationPublished' })
|
||||
const latestSit = situations[situations.length - 1]?.payload as { report: SituationReport; report_ref: string; forecast_tool_calls: ToolCallRef[] } | undefined
|
||||
const findings = ctx.findings.list().map((r) => r.value)
|
||||
const latestFinding: ReviewFinding | undefined = findings[findings.length - 1]
|
||||
|
||||
if (page === 'TRADING_DESK' && latestSit) {
|
||||
const priceCall = latestSit.forecast_tool_calls.find((t) => t.tool === 'price-forecast')
|
||||
const price = priceCall ? (ctx.snapshots.get(priceCall.outputs_ref) as { quantiles: { p50: { values: string[] } } } | undefined) : undefined
|
||||
const metrics: InsightCard['metrics'] = []
|
||||
if (price && priceCall) {
|
||||
const values = price.quantiles.p50.values
|
||||
const idx = values.reduce((best, v, i) => (Number(v) > Number(values[best]) ? i : best), 0)
|
||||
metrics.push({ name: 'peak_price_p50', value: values[idx]!, unit: 'yuan_per_mwh', ref: { tool_call_id: priceCall.tool_call_id, path: `quantiles.p50.values.${idx}` } })
|
||||
}
|
||||
cards.push({
|
||||
id: `card-sit-${latestSit.report.id}`,
|
||||
page,
|
||||
market_date: latestSit.report.market_date,
|
||||
title: 'Day-ahead situation',
|
||||
headline: `Risk ${latestSit.report.risk_level}: ${latestSit.report.findings[0]?.summary ?? ''}`,
|
||||
metrics,
|
||||
source: { kind: 'SITUATION_REPORT', id: latestSit.report.id, ref: latestSit.report_ref },
|
||||
generated_at: now,
|
||||
})
|
||||
}
|
||||
if ((page === 'TRADING_DESK' || page === 'OPERATIONS_DASHBOARD') && latestFinding) {
|
||||
cards.push({
|
||||
id: `card-rf-${latestFinding.id}`,
|
||||
page,
|
||||
market_date: latestFinding.market_date,
|
||||
title: 'D+1 review',
|
||||
headline: latestFinding.summary,
|
||||
metrics: [
|
||||
{ name: 'net_deviation_mwh', value: latestFinding.attribution.net_deviation_mwh, unit: 'mwh', ref: null },
|
||||
{ name: 'response_mwh', value: latestFinding.attribution.response_mwh, unit: 'mwh', ref: null },
|
||||
...(latestFinding.attribution.forecast_price_mape ? [{ name: 'forecast_price_mape', value: latestFinding.attribution.forecast_price_mape, unit: '1', ref: null }] : []),
|
||||
],
|
||||
source: { kind: 'REVIEW_FINDING', id: latestFinding.id, ref: null },
|
||||
generated_at: now,
|
||||
})
|
||||
}
|
||||
if (page === 'RESOURCE_POOL') {
|
||||
for (const r of ctx.resources.list()) {
|
||||
const wb = latestFinding?.writebacks.find((w) => w.target === 'RESOURCE_PROFILE' && w.resource_id === r.key)
|
||||
cards.push({
|
||||
id: `card-res-${r.key}`,
|
||||
page,
|
||||
market_date: latestFinding?.market_date ?? now.slice(0, 10),
|
||||
title: r.value.name,
|
||||
headline: wb && wb.target === 'RESOURCE_PROFILE' ? `Reliability ${wb.previous_reliability_score} → ${wb.new_reliability_score} (fulfilled ${wb.fulfillment_ratio})` : `Reliability ${r.value.reliability_score}`,
|
||||
metrics: [
|
||||
{ name: 'reliability_score', value: r.value.reliability_score, unit: '1', ref: null },
|
||||
{ name: 'certified_adjustable_mw', value: r.value.certified_adjustable_mw, unit: 'mw', ref: null },
|
||||
],
|
||||
source: latestFinding ? { kind: 'REVIEW_FINDING', id: latestFinding.id, ref: null } : { kind: 'DECISION_CASE', id: 'none', ref: null },
|
||||
generated_at: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (page === 'OPERATIONS_DASHBOARD') {
|
||||
const open = ctx.caseDesk.cases.list().map((c) => c.value).filter((c) => !c.status.startsWith('CLOSED'))
|
||||
cards.push({
|
||||
id: 'card-ops-cases',
|
||||
page,
|
||||
market_date: now.slice(0, 10),
|
||||
title: 'Open cases',
|
||||
headline: `${open.length} open, ${ctx.caseDesk.inbox().filter((p) => p.run_id !== '').length} awaiting approval`,
|
||||
metrics: [{ name: 'open_cases', value: String(open.length), unit: '1', ref: null }],
|
||||
source: { kind: 'DECISION_CASE', id: open[0]?.id ?? 'none', ref: null },
|
||||
generated_at: now,
|
||||
})
|
||||
}
|
||||
return cards
|
||||
}
|
||||
@ -4,15 +4,20 @@ import type {
|
||||
EnvelopeService,
|
||||
EventBus,
|
||||
FileExportGateway,
|
||||
GatewayPort,
|
||||
LedgerService,
|
||||
LineageRecorder,
|
||||
PolicyEngine,
|
||||
Repository,
|
||||
ResourceRegistry,
|
||||
ReviewService,
|
||||
SimulationGateway,
|
||||
SimulationPort,
|
||||
SkillClient,
|
||||
SnapshotStore,
|
||||
TimeSeriesStore,
|
||||
} from '@vpp/services'
|
||||
import type { AwardNotice, EnvelopeChangeRequest, ExecutionReport, MeteringRecord, Proposal, ReviewFinding } from '@vpp/domain'
|
||||
import type { LlmPort } from './llm.js'
|
||||
|
||||
/**
|
||||
@ -38,6 +43,12 @@ export interface RuntimeConfig {
|
||||
extremeDayPriceRatio: string
|
||||
/** Bid deadline as HH:MM Asia/Shanghai on D-1 (OPEN-QUESTION A1); used for case deadlines. */
|
||||
bidDeadlineLocal: string
|
||||
/** Aggregation units → resource ids ('*' = all). OPEN-QUESTION D4 (topology). */
|
||||
units: Record<string, string[]>
|
||||
/** Review writeback parameters — OPEN-QUESTION B10 (reliability EWMA), B4/B9 (compliant days), B1 (widen step). */
|
||||
reliabilityAlpha: string
|
||||
widenAfterCompliantDays: number
|
||||
widenStepPct: string
|
||||
}
|
||||
|
||||
export interface RuntimeContext {
|
||||
@ -53,7 +64,16 @@ export interface RuntimeContext {
|
||||
envelopes: EnvelopeService
|
||||
authority: AuthorityService
|
||||
gateway: FileExportGateway
|
||||
simulation: SimulationPort
|
||||
/** Simulation and gateway are chosen by proposal type (ADR-0005: type-specific behaviour is data, not workflow structure). */
|
||||
simulationFor: (type: Proposal['type']) => SimulationPort
|
||||
gatewayFor: (type: Proposal['type']) => GatewayPort
|
||||
simulationGateway: SimulationGateway
|
||||
review: ReviewService
|
||||
awards: Repository<AwardNotice>
|
||||
executionReports: Repository<ExecutionReport>
|
||||
metering: Repository<MeteringRecord>
|
||||
envelopeRequests: Repository<EnvelopeChangeRequest>
|
||||
findings: Repository<ReviewFinding>
|
||||
caseDesk: CaseDeskService
|
||||
skills: SkillClient
|
||||
lineage: LineageRecorder
|
||||
|
||||
@ -7,3 +7,7 @@ export * from './api.js'
|
||||
export * from './workflows/proposal-lifecycle.js'
|
||||
export * from './workflows/day-ahead-situation.js'
|
||||
export * from './workflows/day-ahead-bid.js'
|
||||
export * from './cards.js'
|
||||
export * from './workflows/award-decomposition.js'
|
||||
export * from './workflows/review.js'
|
||||
export * from './workflows/envelope-review.js'
|
||||
|
||||
@ -83,7 +83,7 @@ export class ScriptedLlm implements LlmPort {
|
||||
}
|
||||
}
|
||||
|
||||
/** The five agents (docs/02 §2). M3 wires three; interaction/load-control are phase 2. */
|
||||
/** The five agents (docs/02 §2). M3–M4 wire router, analysis, trading, resource; interaction/load-control are phase 2. */
|
||||
export const AGENT_SPECS: AgentSpec[] = [
|
||||
{
|
||||
id: 'router-agent',
|
||||
@ -99,6 +99,13 @@ export const AGENT_SPECS: AgentSpec[] = [
|
||||
'You are the situational-awareness analyst for a virtual power plant. Given forecast bundles (already computed by tools), ' +
|
||||
'write short findings. Never state a number that is not in the provided tool outputs; cite snapshot refs.',
|
||||
},
|
||||
{
|
||||
id: 'resource-agent',
|
||||
name: 'Resource dispatch',
|
||||
instructions:
|
||||
'You decompose awarded energy to aggregation units. The dispatch-optimization tool has already computed the allocation; ' +
|
||||
'you reference its output by {tool_call_id, path} and explain unit choices and any capacity shortfall for the approver.',
|
||||
},
|
||||
{
|
||||
id: 'trading-agent',
|
||||
name: 'Trading',
|
||||
|
||||
@ -2,7 +2,7 @@ import { join } from 'node:path'
|
||||
import { Mastra } from '@mastra/core'
|
||||
import type { MastraModelConfig } from '@mastra/core/llm'
|
||||
import { LibSQLStore } from '@mastra/libsql'
|
||||
import { Approval, DecisionCase, Envelope, ExecutionPermit, ExecutionReceipt, Proposal, ResourceProfile } from '@vpp/domain'
|
||||
import { Approval, AwardNotice, DecisionCase, Envelope, EnvelopeChangeRequest, ExecutionPermit, ExecutionReceipt, ExecutionReport, MeteringRecord, Proposal, ResourceProfile, ReviewFinding, SemanticMemoryEntry } from '@vpp/domain'
|
||||
import {
|
||||
AuthorityService,
|
||||
CaseDeskService,
|
||||
@ -18,14 +18,20 @@ import {
|
||||
MemoryTimeSeriesStore,
|
||||
PendingApprovalSchema,
|
||||
PolicyEngine,
|
||||
PowerBalanceSimulator,
|
||||
RevenueScenarioSimulator,
|
||||
ReviewService,
|
||||
SimulationGateway,
|
||||
hubeiSpotBiddingPack,
|
||||
} from '@vpp/services'
|
||||
import type { SkillClient } from '@vpp/services'
|
||||
import type { RuntimeConfig, RuntimeContext } from './context.js'
|
||||
import { AGENT_SPECS, MastraLlm, NullLlm } from './llm.js'
|
||||
import type { LlmPort } from './llm.js'
|
||||
import { createAwardDecomposition } from './workflows/award-decomposition.js'
|
||||
import { createDayAheadBid } from './workflows/day-ahead-bid.js'
|
||||
import { createEnvelopeReview } from './workflows/envelope-review.js'
|
||||
import { createReview } from './workflows/review.js'
|
||||
import { createDayAheadSituation } from './workflows/day-ahead-situation.js'
|
||||
import { createProposalLifecycle } from './workflows/proposal-lifecycle.js'
|
||||
|
||||
@ -55,6 +61,10 @@ export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
|
||||
forecastWindowDays: 28,
|
||||
extremeDayPriceRatio: '2.0', // OPEN-QUESTION B7
|
||||
bidDeadlineLocal: '09:00', // OPEN-QUESTION A1
|
||||
units: { 'agg-unit-01': ['*'] }, // OPEN-QUESTION D4
|
||||
reliabilityAlpha: '0.2', // OPEN-QUESTION B10
|
||||
widenAfterCompliantDays: 20, // OPEN-QUESTION B4/B9
|
||||
widenStepPct: '20', // OPEN-QUESTION B1
|
||||
}
|
||||
|
||||
export interface Runtime {
|
||||
@ -89,6 +99,24 @@ export async function createRuntime(opts: RuntimeOptions): Promise<Runtime> {
|
||||
clock,
|
||||
})
|
||||
const gateway = new FileExportGateway(authority, dir('exports'), new FsRepository(dir('receipts.json'), ExecutionReceipt, (r) => r.idempotency_key, clock))
|
||||
const simulationGateway = new SimulationGateway(authority, new FsRepository(dir('sim-receipts.json'), ExecutionReceipt, (r) => r.idempotency_key, clock))
|
||||
const resources = new FsRepository(dir('resources.json'), ResourceProfile, (r) => r.resource_id, clock)
|
||||
const revenueSim = new RevenueScenarioSimulator()
|
||||
const executionReports = new FsRepository(dir('execution-reports.json'), ExecutionReport, (r) => r.id, clock)
|
||||
const powerSim = new PowerBalanceSimulator((marketDate) => {
|
||||
// Unit limits = the potential assessment recorded for that day, if any; else no per-unit limit.
|
||||
const limits: Record<string, string[]> = {}
|
||||
for (const evt of events.list({ event_type: 'PotentialAssessed' })) {
|
||||
const p = evt.payload as { market_date: string; units: Record<string, string[]> }
|
||||
if (p.market_date === marketDate) Object.assign(limits, p.units)
|
||||
}
|
||||
return limits
|
||||
})
|
||||
const review = new ReviewService(resources, envelopes, new FsRepository(dir('memory.json'), SemanticMemoryEntry, (m) => m.id, clock), {
|
||||
reliabilityAlpha: config.reliabilityAlpha,
|
||||
widenAfterCompliantDays: config.widenAfterCompliantDays,
|
||||
widenStepPct: config.widenStepPct,
|
||||
})
|
||||
|
||||
let llm: LlmPort
|
||||
let agents: Record<string, never> | ReturnType<MastraLlm['mastraAgents']> = {}
|
||||
@ -106,14 +134,22 @@ export async function createRuntime(opts: RuntimeOptions): Promise<Runtime> {
|
||||
newId,
|
||||
snapshots,
|
||||
timeseries: opts.timeseries ?? new MemoryTimeSeriesStore(clock),
|
||||
resources: new FsRepository(dir('resources.json'), ResourceProfile, (r) => r.resource_id, clock),
|
||||
resources,
|
||||
ledger,
|
||||
events,
|
||||
policy,
|
||||
envelopes,
|
||||
authority,
|
||||
gateway,
|
||||
simulation: new RevenueScenarioSimulator(),
|
||||
simulationFor: (type) => (type === 'DISPATCH_PLAN' ? powerSim : revenueSim),
|
||||
gatewayFor: (type) => (type === 'DISPATCH_PLAN' ? simulationGateway : gateway),
|
||||
simulationGateway,
|
||||
review,
|
||||
awards: new FsRepository(dir('awards.json'), AwardNotice, (a) => a.id, clock),
|
||||
executionReports,
|
||||
metering: new FsRepository(dir('metering.json'), MeteringRecord, (m) => m.id, clock),
|
||||
envelopeRequests: new FsRepository(dir('envelope-requests.json'), EnvelopeChangeRequest, (r) => r.id, clock),
|
||||
findings: new FsRepository(dir('findings.json'), ReviewFinding, (f) => f.id, clock),
|
||||
caseDesk,
|
||||
skills: typeof opts.skills === 'string' ? new HttpSkillClient(opts.skills) : opts.skills,
|
||||
lineage: new LineageRecorder(),
|
||||
@ -124,9 +160,17 @@ export async function createRuntime(opts: RuntimeOptions): Promise<Runtime> {
|
||||
// Workflows are built per runtime so their steps close over *this* context;
|
||||
// module-level singletons would bind to whichever Mastra registered last.
|
||||
const proposalLifecycle = createProposalLifecycle(ctx)
|
||||
const envelopeReview = createEnvelopeReview(ctx)
|
||||
const mastra = new Mastra({
|
||||
storage,
|
||||
workflows: { proposalLifecycle, dayAheadSituation: createDayAheadSituation(ctx), dayAheadBid: createDayAheadBid(ctx, proposalLifecycle) },
|
||||
workflows: {
|
||||
proposalLifecycle,
|
||||
envelopeReview,
|
||||
dayAheadSituation: createDayAheadSituation(ctx),
|
||||
dayAheadBid: createDayAheadBid(ctx, proposalLifecycle),
|
||||
awardDecomposition: createAwardDecomposition(ctx, proposalLifecycle),
|
||||
review: createReview(ctx, envelopeReview),
|
||||
},
|
||||
agents,
|
||||
})
|
||||
return { mastra, ctx, close: () => storage.close() }
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { RouterDecision } from '@vpp/domain'
|
||||
import { AwardNotice, ExecutionReport, MeteringRecord, RouterDecision } from '@vpp/domain'
|
||||
import type { EventEnvelope } from '@vpp/domain'
|
||||
import { ENVELOPE_REVIEW_STEP_ID } from './workflows/envelope-review.js'
|
||||
import { LlmUnavailable } from './llm.js'
|
||||
import type { Runtime } from './runtime.js'
|
||||
import { ENVELOPE_GATE_STEP_ID } from './workflows/proposal-lifecycle.js'
|
||||
@ -94,6 +95,38 @@ export class TriggerService {
|
||||
)
|
||||
}
|
||||
|
||||
/** Trading-platform adapter stand-in: an award arrives → ledger + decomposition (docs/07 D-1 16:00). */
|
||||
async onAward(award: AwardNotice) {
|
||||
const a = AwardNotice.parse(award)
|
||||
this.rt.ctx.events.append({ event_type: 'AwardReceived', payload: a, correlation_id: a.id })
|
||||
const wf = this.rt.mastra.getWorkflow('awardDecomposition')
|
||||
const run = await wf.createRun()
|
||||
const result = await run.start({ inputData: { award: a } })
|
||||
if (result.status === 'failed') throw result.error
|
||||
return { run_id: run.runId, result: result.status === 'success' ? result.result : null, status: result.status }
|
||||
}
|
||||
|
||||
/** Execution engine / edge feedback stand-in: execution reports are evidence for review and potential assessment. */
|
||||
onExecutionReports(reports: ExecutionReport[]) {
|
||||
for (const r of reports) {
|
||||
const parsed = ExecutionReport.parse(r)
|
||||
this.rt.ctx.executionReports.put(parsed)
|
||||
this.rt.ctx.events.append({ event_type: 'ExecutionReport', payload: parsed, correlation_id: parsed.dispatch_proposal_digest })
|
||||
}
|
||||
}
|
||||
|
||||
/** Metering adapter stand-in: D+1 metering arrives → review workflow (docs/07 D+1). */
|
||||
async onMetering(record: MeteringRecord) {
|
||||
const m = MeteringRecord.parse(record)
|
||||
this.rt.ctx.metering.put(m)
|
||||
this.rt.ctx.events.append({ event_type: 'MeteringArrived', payload: m, correlation_id: m.id })
|
||||
const wf = this.rt.mastra.getWorkflow('review')
|
||||
const run = await wf.createRun()
|
||||
const result = await run.start({ inputData: { market_date: m.market_date } })
|
||||
if (result.status === 'failed') throw result.error
|
||||
return { run_id: run.runId, result: result.status === 'success' ? result.result : null }
|
||||
}
|
||||
|
||||
// ---- manual ------------------------------------------------------------
|
||||
|
||||
async route(message: string): Promise<RouterDecision> {
|
||||
@ -126,6 +159,12 @@ export class TriggerService {
|
||||
// ---- approval = resume -------------------------------------------------
|
||||
|
||||
async decide(runId: string, decision: ResumeDecision) {
|
||||
const pending = this.rt.ctx.caseDesk.pending.list().find((p) => p.value.run_id === runId)?.value
|
||||
if (pending?.workflow === 'envelopeReview') {
|
||||
const run = await this.rt.mastra.getWorkflow('envelopeReview').createRun({ runId })
|
||||
const res = await run.resume({ step: ENVELOPE_REVIEW_STEP_ID, resumeData: decision })
|
||||
return res.status === 'success' ? { status: 'success' as const, result: { outcome: res.result.applied ? ('RELEASED' as const) : ('REJECTED' as const), ...res.result } } : res
|
||||
}
|
||||
const wf = this.rt.mastra.getWorkflow('proposalLifecycle')
|
||||
const run = await wf.createRun({ runId })
|
||||
return run.resume({ step: ENVELOPE_GATE_STEP_ID, resumeData: decision })
|
||||
|
||||
200
packages/runtime/src/workflows/award-decomposition.ts
Normal file
200
packages/runtime/src/workflows/award-decomposition.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { createStep, createWorkflow } from '@mastra/core/workflows'
|
||||
import { z } from 'zod'
|
||||
import type { DispatchProposalDraft, Proposal, ToolCallRef } from '@vpp/domain'
|
||||
import { AwardNotice, DispatchProposalDraft as DispatchProposalDraftSchema, Proposal as ProposalSchema, ToolCallRef as ToolCallRefSchema } from '@vpp/domain'
|
||||
import { Decimal, assembleDispatchProposal } from '@vpp/services'
|
||||
import type { RuntimeContext } from '../context.js'
|
||||
import { LlmUnavailable } from '../llm.js'
|
||||
import { registerSkill } from '../skills.js'
|
||||
import { LifecycleOutput } from './proposal-lifecycle.js'
|
||||
import type { createProposalLifecycle } from './proposal-lifecycle.js'
|
||||
|
||||
/**
|
||||
* D-1 16:00 出清结果处理 (docs/07): award → ledger (DAY_AHEAD/AWARD) →
|
||||
* resource agent: potential assessment (cites execution feedback) →
|
||||
* dispatch optimization → draft of references → DISPATCH_PLAN proposal →
|
||||
* lifecycle (power-balance simulation, envelope, permit, simulation gateway).
|
||||
*/
|
||||
export const AwardInput = z.object({ award: AwardNotice })
|
||||
|
||||
export const AwardOutput = z.object({
|
||||
case_id: z.string(),
|
||||
proposal_id: z.string(),
|
||||
proposal: ProposalSchema,
|
||||
lifecycle_run_id: z.string(),
|
||||
lifecycle: LifecycleOutput.nullable(),
|
||||
lifecycle_status: z.enum(['success', 'suspended', 'failed']),
|
||||
llm_used: z.boolean(),
|
||||
shortfall_mwh: z.string(),
|
||||
})
|
||||
|
||||
const Context = z.object({
|
||||
market_date: z.string(),
|
||||
award_id: z.string(),
|
||||
case_id: z.string(),
|
||||
tool_calls: z.array(ToolCallRefSchema),
|
||||
data_refs: z.array(z.string()),
|
||||
dispatch_call: ToolCallRefSchema,
|
||||
dispatch_output: z.unknown(),
|
||||
ledger_version: z.int(),
|
||||
policy_pack_version: z.string(),
|
||||
})
|
||||
|
||||
/** Units → resources mapping. '*' means every registered resource. OPEN-QUESTION D4 (aggregation topology). */
|
||||
export function unitResources(ctx: RuntimeContext): Record<string, string[]> {
|
||||
const all = ctx.resources.list().map((r) => r.key)
|
||||
return Object.fromEntries(Object.entries(ctx.config.units).map(([u, rs]) => [u, rs.includes('*') ? all : rs]))
|
||||
}
|
||||
|
||||
export function createAwardDecomposition(ctx: RuntimeContext, lifecycle: ReturnType<typeof createProposalLifecycle>) {
|
||||
const ingestAward = createStep({
|
||||
id: 'ingest-award',
|
||||
inputSchema: AwardInput,
|
||||
outputSchema: Context,
|
||||
execute: async ({ inputData }) => {
|
||||
const { award } = inputData
|
||||
ctx.awards.put(award)
|
||||
const energy = award.awarded_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0))
|
||||
ctx.ledger.append({
|
||||
id: ctx.newId('pos'),
|
||||
timescale: 'DAY_AHEAD',
|
||||
period: award.market_date,
|
||||
kind: 'AWARD',
|
||||
energy_mwh: energy.toFixed(3),
|
||||
curve: award.awarded_mwh,
|
||||
source_ref: award.id,
|
||||
expected_version: ctx.ledger.read().version,
|
||||
})
|
||||
ctx.events.append({ event_type: 'AwardRecorded', payload: { award_id: award.id, market_date: award.market_date, energy_mwh: energy.toFixed(3), ledger_version: ctx.ledger.read().version }, correlation_id: award.id })
|
||||
const caseId = ctx.caseDesk.open({
|
||||
id: ctx.newId('case'),
|
||||
kind: 'DAY_AHEAD_BID',
|
||||
objective: `Decompose award ${award.id} for ${award.market_date}`,
|
||||
owner: 'user-ops-lead', // OPEN-QUESTION B3
|
||||
deadline: `${award.market_date}T00:00:00Z`,
|
||||
}).id
|
||||
|
||||
return ctx.lineage.run(async () => {
|
||||
const awardRef = ctx.snapshots.put(award)
|
||||
ctx.lineage.addDataRef(awardRef)
|
||||
const units = unitResources(ctx)
|
||||
// Potential assessment cites recent execution feedback per resource (docs/02 §2.2 画像时效性).
|
||||
const reports = ctx.executionReports.list().map((r) => r.value)
|
||||
const resources = ctx.resources.list().map((r) => ({
|
||||
profile: r.value,
|
||||
fulfillment: reports
|
||||
.filter((rep) => (units[rep.unit_id] ?? []).includes(r.key))
|
||||
.map((rep) => ({
|
||||
market_date: rep.market_date,
|
||||
planned_mwh: rep.planned_mw.values.reduce((s, v) => s + Number(v), 0).toFixed(3).replace(/^(\d+\.\d{3})$/, '$1'),
|
||||
delivered_mwh: rep.actual_mw.values.reduce((s, v) => s + Number(v), 0).toFixed(3),
|
||||
})),
|
||||
}))
|
||||
if (resources.length === 0) throw new Error('no resources registered — nothing to dispatch')
|
||||
const potential = registerSkill(
|
||||
{ lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') },
|
||||
{ id: 'potential-assessment', version: '1.0.0', invoke: (req: Parameters<typeof ctx.skills.assessPotential>[0]) => ctx.skills.assessPotential(req) },
|
||||
)
|
||||
const { output: assessed, ref: potRef } = await potential.call({ market_date: award.market_date, resources, features_snapshot_ref: awardRef })
|
||||
|
||||
const unitInputs = Object.entries(units).map(([unit_id, rids]) => ({
|
||||
unit_id,
|
||||
available_mw: {
|
||||
interval_minutes: 15 as const,
|
||||
date: award.market_date,
|
||||
values: Array.from({ length: 96 }, (_, t) =>
|
||||
assessed.assessments.filter((a) => rids.includes(a.resource_id)).reduce((s, a) => s.add(new Decimal(a.adjustable_mw.values[t]!)), new Decimal(0)).toFixed(3),
|
||||
),
|
||||
},
|
||||
cost_weight: '1.0', // OPEN-QUESTION B2: per-unit cost/wear weights
|
||||
}))
|
||||
ctx.events.append({ event_type: 'PotentialAssessed', payload: { market_date: award.market_date, tool_call_id: potRef.tool_call_id, units: Object.fromEntries(unitInputs.map((u) => [u.unit_id, u.available_mw.values])) }, correlation_id: award.id })
|
||||
const target = { interval_minutes: 15 as const, date: award.market_date, values: award.awarded_mwh.values.map((v) => new Decimal(v).div('0.25').toFixed(3)) }
|
||||
const dispatch = registerSkill(
|
||||
{ lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') },
|
||||
{ id: 'dispatch-optimization', version: '1.0.0', invoke: (req: Parameters<typeof ctx.skills.optimizeDispatch>[0]) => ctx.skills.optimizeDispatch(req) },
|
||||
)
|
||||
const { output, ref } = await dispatch.call({ market_date: award.market_date, target_mw: target, units: unitInputs })
|
||||
if (output.solver.status !== 'OPTIMAL') throw new Error(`dispatch optimization ${output.solver.status}`)
|
||||
const lin = ctx.lineage.current()
|
||||
ctx.caseDesk.addEvidence(caseId, lin.data_refs)
|
||||
return {
|
||||
market_date: award.market_date,
|
||||
award_id: award.id,
|
||||
case_id: caseId,
|
||||
tool_calls: [potRef, ref],
|
||||
data_refs: lin.data_refs,
|
||||
dispatch_call: ref,
|
||||
dispatch_output: output,
|
||||
ledger_version: ctx.ledger.read().version,
|
||||
policy_pack_version: ctx.policy.current(ctx.config.policyPackId).version,
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const resourceStrategy = createStep({
|
||||
id: 'resource-strategy',
|
||||
inputSchema: Context,
|
||||
outputSchema: Context.extend({ draft: DispatchProposalDraftSchema, llm_used: z.boolean() }),
|
||||
execute: async ({ inputData }) => {
|
||||
const tc = inputData.dispatch_call.tool_call_id
|
||||
const out = inputData.dispatch_output as { shortfall_mwh: string; allocations: Array<{ unit_id: string }> }
|
||||
const template: DispatchProposalDraft = {
|
||||
market_date: inputData.market_date,
|
||||
total_target_ref: { tool_call_id: tc, path: 'total_target_mw' },
|
||||
allocations_ref: { tool_call_id: tc, path: 'allocations' },
|
||||
rationale: `Dispatch ${tc}: ${out.allocations.length} unit(s), shortfall ${out.shortfall_mwh} MWh (template — LLM unavailable)`,
|
||||
}
|
||||
try {
|
||||
const prompt = `Award ${inputData.award_id} for ${inputData.market_date}. Dispatch tool call ${tc} output has fields total_target_mw and allocations (units: ${out.allocations.map((a) => a.unit_id).join(', ')}; shortfall_mwh = ${out.shortfall_mwh}). Return a DispatchProposalDraft referencing those two fields plus a short rationale. No numbers beyond those quoted.`
|
||||
const draft = await ctx.llm.structured('resource-agent', prompt, DispatchProposalDraftSchema)
|
||||
return { ...inputData, draft, llm_used: true }
|
||||
} catch (e) {
|
||||
if (!(e instanceof LlmUnavailable)) throw e
|
||||
return { ...inputData, draft: template, llm_used: false }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const assembleAndSubmit = createStep({
|
||||
id: 'assemble-and-submit',
|
||||
inputSchema: resourceStrategy.outputSchema,
|
||||
outputSchema: AwardOutput,
|
||||
execute: async ({ inputData }) => {
|
||||
const proposal: Proposal = assembleDispatchProposal(inputData.draft, {
|
||||
id: ctx.newId('prop'),
|
||||
award_ref: inputData.award_id,
|
||||
originator: { agent: 'resource-agent', trigger: 'EVENT', task_id: inputData.case_id },
|
||||
lineage: { tool_calls: inputData.tool_calls as ToolCallRef[], data_refs: inputData.data_refs },
|
||||
ledger_version: inputData.ledger_version,
|
||||
policy_pack_version: inputData.policy_pack_version,
|
||||
snapshots: ctx.snapshots,
|
||||
now: ctx.clock(),
|
||||
})
|
||||
ctx.caseDesk.recordProposal(inputData.case_id, proposal)
|
||||
const run = await lifecycle.createRun()
|
||||
const result = await run.start({ inputData: { proposal, case_id: inputData.case_id } })
|
||||
if (result.status === 'suspended') {
|
||||
const pending = ctx.caseDesk.pending.get(proposal.id)
|
||||
if (pending) ctx.caseDesk.pending.put({ ...pending.value, run_id: run.runId }, pending.version)
|
||||
}
|
||||
return {
|
||||
case_id: inputData.case_id,
|
||||
proposal_id: proposal.id,
|
||||
proposal,
|
||||
lifecycle_run_id: run.runId,
|
||||
lifecycle: result.status === 'success' ? result.result : null,
|
||||
lifecycle_status: (result.status === 'success' ? 'success' : result.status === 'suspended' ? 'suspended' : 'failed') as 'success' | 'suspended' | 'failed',
|
||||
llm_used: inputData.llm_used,
|
||||
shortfall_mwh: (inputData.dispatch_output as { shortfall_mwh: string }).shortfall_mwh,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return createWorkflow({ id: 'award-decomposition', inputSchema: AwardInput, outputSchema: AwardOutput })
|
||||
.then(ingestAward)
|
||||
.then(resourceStrategy)
|
||||
.then(assembleAndSubmit)
|
||||
.commit()
|
||||
}
|
||||
57
packages/runtime/src/workflows/envelope-review.ts
Normal file
57
packages/runtime/src/workflows/envelope-review.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { createStep, createWorkflow } from '@mastra/core/workflows'
|
||||
import { z } from 'zod'
|
||||
import { Envelope as EnvelopeSchema, EnvelopeChangeRequest as EnvelopeChangeRequestSchema } from '@vpp/domain'
|
||||
import type { RuntimeContext } from '../context.js'
|
||||
import { ResumeDecision } from './proposal-lifecycle.js'
|
||||
|
||||
/**
|
||||
* Envelope re-approval workflow (docs/03 §3, docs/07 D+1): the *only* path
|
||||
* that changes an envelope's bounds or status. It always suspends for a
|
||||
* human — widening autonomy is a governance act, never automatic. Shadow
|
||||
* data may recommend; a person approves (docs/12 §3: 包络放宽仅凭 L4 数据审批).
|
||||
*/
|
||||
export const EnvelopeReviewInput = z.object({ request: EnvelopeChangeRequestSchema, case_id: z.string().min(1) })
|
||||
|
||||
export const EnvelopeReviewOutput = z.object({
|
||||
applied: z.boolean(),
|
||||
request: EnvelopeChangeRequestSchema,
|
||||
envelope: EnvelopeSchema.nullable(),
|
||||
decided_by: z.string().nullable(),
|
||||
reason: z.string(),
|
||||
})
|
||||
|
||||
export const ENVELOPE_REVIEW_STEP_ID = 'envelope-review-gate'
|
||||
|
||||
export function createEnvelopeReview(ctx: RuntimeContext) {
|
||||
const gate = createStep({
|
||||
id: ENVELOPE_REVIEW_STEP_ID,
|
||||
inputSchema: EnvelopeReviewInput,
|
||||
outputSchema: EnvelopeReviewOutput,
|
||||
suspendSchema: z.object({ reason: z.string(), request_id: z.string(), required_level: z.string() }),
|
||||
resumeSchema: ResumeDecision,
|
||||
execute: async ({ inputData, resumeData, suspend }) => {
|
||||
const { request, case_id } = inputData
|
||||
const now = ctx.clock()
|
||||
if (!resumeData) {
|
||||
return await suspend({ reason: `envelope ${request.envelope_id}: ${request.action} (${request.basis})`, request_id: request.id, required_level: 'L2' })
|
||||
}
|
||||
// I1/I2: envelope approval needs an approver role; the review workflow and every agent are excluded.
|
||||
if (!ctx.config.approverRoles.includes(resumeData.approver.role) || resumeData.approver.id.endsWith('-agent')) {
|
||||
ctx.events.append({ event_type: 'ApprovalRefused', payload: { request_id: request.id, approver: resumeData.approver, reason: 'not an approver role (I1/I2)' }, correlation_id: case_id })
|
||||
return await suspend({ reason: `approval refused for ${resumeData.approver.id}`, request_id: request.id, required_level: 'L2' })
|
||||
}
|
||||
ctx.caseDesk.clearPending(request.id)
|
||||
if (resumeData.decision === 'reject') {
|
||||
ctx.events.append({ event_type: 'EnvelopeChangeRejected', payload: { request_id: request.id, by: resumeData.approver.id, comment: resumeData.comment ?? null }, correlation_id: case_id })
|
||||
ctx.caseDesk.close(case_id, null, false)
|
||||
return { applied: false, request, envelope: ctx.envelopes.get(request.envelope_id) ?? null, decided_by: resumeData.approver.id, reason: resumeData.comment ?? 'rejected' }
|
||||
}
|
||||
const envelope = ctx.envelopes.apply(request, [resumeData.approver.id])
|
||||
ctx.events.append({ event_type: 'EnvelopeChanged', payload: { request_id: request.id, envelope_id: envelope.id, action: request.action, bounds: envelope.bounds, status: envelope.status, by: resumeData.approver.id, at: now }, correlation_id: case_id })
|
||||
ctx.caseDesk.close(case_id, request.id, true)
|
||||
return { applied: true, request, envelope, decided_by: resumeData.approver.id, reason: resumeData.comment ?? 'approved' }
|
||||
},
|
||||
})
|
||||
|
||||
return createWorkflow({ id: 'envelope-review', inputSchema: EnvelopeReviewInput, outputSchema: EnvelopeReviewOutput }).then(gate).commit()
|
||||
}
|
||||
@ -99,7 +99,7 @@ export function createProposalLifecycle(ctx: RuntimeContext) {
|
||||
execute: async ({ inputData }) => {
|
||||
if (inputData.done) return inputData
|
||||
const p = transition(ctx, inputData.case_id, inputData.proposal, 'SIMULATION')
|
||||
const result = ctx.simulation.simulate(p, {
|
||||
const result = ctx.simulationFor(p.type).simulate(p, {
|
||||
now: ctx.clock(),
|
||||
priceForecast: priceForecastFromLineage(ctx, p),
|
||||
marginalCostYuanPerMwh: ctx.config.risk.marginal_cost_yuan_per_mwh,
|
||||
@ -205,19 +205,21 @@ export function createProposalLifecycle(ctx: RuntimeContext) {
|
||||
const permit = permitId ? ctx.authority.permits.get(permitId)?.value : undefined
|
||||
if (!permit) throw new Error('release reached without a permit — invariant I4 violated in workflow wiring')
|
||||
try {
|
||||
const receipt = ctx.gateway.dispatch(inputData.proposal, permit, now)
|
||||
const receipt = ctx.gatewayFor(inputData.proposal.type).dispatch(inputData.proposal, permit, now)
|
||||
const p = transition(ctx, inputData.case_id, inputData.proposal, 'RELEASED', { receipt_id: receipt.receipt_id, artifact_ref: receipt.artifact_ref })
|
||||
ctx.events.append({ event_type: 'ExecutionReceipt', payload: receipt, correlation_id: inputData.case_id, causation_id: p.id })
|
||||
if (p.type === 'BID') {
|
||||
ctx.ledger.append({
|
||||
id: ctx.newId('pos'),
|
||||
timescale: 'DAY_AHEAD',
|
||||
period: p.type === 'BID' ? p.payload.market_date : now.slice(0, 10),
|
||||
period: p.payload.market_date,
|
||||
kind: 'BID_SUBMITTED',
|
||||
energy_mwh: p.type === 'BID' ? p.payload.quantities_mwh.values.reduce((s, v) => (Number(s) + Number(v)).toFixed(3), '0') : '0',
|
||||
curve: p.type === 'BID' ? p.payload.quantities_mwh : null,
|
||||
energy_mwh: p.payload.quantities_mwh.values.reduce((s, v) => (Number(s) + Number(v)).toFixed(3), '0'),
|
||||
curve: p.payload.quantities_mwh,
|
||||
source_ref: p.digest,
|
||||
expected_version: ctx.ledger.read().version,
|
||||
})
|
||||
}
|
||||
ctx.caseDesk.close(inputData.case_id, receipt.receipt_id, true)
|
||||
return { outcome: 'RELEASED' as const, proposal: p, permit_id: permit.id, receipt_id: receipt.receipt_id, reasons: [] }
|
||||
} catch (e) {
|
||||
|
||||
162
packages/runtime/src/workflows/review.ts
Normal file
162
packages/runtime/src/workflows/review.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { createStep, createWorkflow } from '@mastra/core/workflows'
|
||||
import { z } from 'zod'
|
||||
import type { EnvelopeChangeRequest, ForecastBundle, Proposal, ReviewFinding } from '@vpp/domain'
|
||||
import { EnvelopeChangeRequest as EnvelopeChangeRequestSchema, ForecastBundle as ForecastBundleSchema, MarketDate, ReviewFinding as ReviewFindingSchema, SkillReport } from '@vpp/domain'
|
||||
import { Decimal, contentRef, canonicalJson } from '@vpp/services'
|
||||
import type { RuntimeContext } from '../context.js'
|
||||
import { LlmUnavailable } from '../llm.js'
|
||||
import { registerSkill } from '../skills.js'
|
||||
import { unitResources } from './award-decomposition.js'
|
||||
import type { createEnvelopeReview } from './envelope-review.js'
|
||||
|
||||
/**
|
||||
* D+1 偏差复盘 (docs/07 D+1, docs/02 §4, ADR-0007): analysis-agent-led
|
||||
* template, not a sixth agent. Ledger plan-vs-actual per timescale →
|
||||
* deterministic attribution → ReviewFinding → writebacks (semantic memory,
|
||||
* reliability scores) applied directly; envelope recommendations become
|
||||
* change requests that a human approves through envelope-review. The
|
||||
* report-generator skill produces the numeric report; the LLM only narrates.
|
||||
*/
|
||||
export const ReviewInput = z.object({ market_date: MarketDate })
|
||||
|
||||
export const ReviewOutput = z.object({
|
||||
market_date: MarketDate,
|
||||
findings: z.array(ReviewFindingSchema),
|
||||
memory_entries: z.int(),
|
||||
updated_profiles: z.array(z.object({ resource_id: z.string(), reliability_score: z.string() })),
|
||||
envelope_suspended: z.array(z.string()),
|
||||
envelope_requests: z.array(EnvelopeChangeRequestSchema),
|
||||
envelope_review_run_ids: z.array(z.string()),
|
||||
report_id: z.string().nullable(),
|
||||
narrative: z.string(),
|
||||
llm_used: z.boolean(),
|
||||
})
|
||||
|
||||
const Narrative = z.object({ narrative: z.string().min(1) })
|
||||
|
||||
function forecastFromLineage(ctx: RuntimeContext, p: Proposal | null, tool: string): ForecastBundle | null {
|
||||
if (!p) return null
|
||||
for (const tc of p.lineage.tool_calls) {
|
||||
if (tc.tool !== tool) continue
|
||||
const parsed = ForecastBundleSchema.safeParse(ctx.snapshots.get(tc.outputs_ref))
|
||||
if (parsed.success) return parsed.data
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createReview(ctx: RuntimeContext, envelopeReview: ReturnType<typeof createEnvelopeReview>) {
|
||||
const attribute = createStep({
|
||||
id: 'attribute',
|
||||
inputSchema: ReviewInput,
|
||||
outputSchema: ReviewOutput.omit({ report_id: true, narrative: true, llm_used: true }),
|
||||
execute: async ({ inputData }) => {
|
||||
const date = inputData.market_date
|
||||
const now = ctx.clock()
|
||||
const released = ctx.caseDesk.proposals.list().map((r) => r.value).filter((p) => p.payload.market_date === date && p.status === 'RELEASED')
|
||||
const bid = released.find((p) => p.type === 'BID') ?? null
|
||||
const dispatch = released.find((p) => p.type === 'DISPATCH_PLAN') ?? null
|
||||
const reports = ctx.executionReports.list().map((r) => r.value).filter((r) => r.market_date === date)
|
||||
const metering = ctx.metering.list().map((r) => r.value).find((m) => m.market_date === date) ?? null
|
||||
|
||||
// Envelope usage for the day: which envelopes auto-approved, and how far execution deviated from plan.
|
||||
const planned = reports.reduce((s, r) => s.add(r.planned_mw.values.reduce((a, v) => a.add(new Decimal(v)), new Decimal(0))), new Decimal(0))
|
||||
const deviation = reports.reduce((s, r) => s.add(new Decimal(r.deviation_mwh)), new Decimal(0)).div('0.25')
|
||||
const deviationPct = planned.gt(0) ? deviation.div(planned).mul(100).toFixed(2) : '0'
|
||||
const envelopeUsage = released.filter((p) => p.envelope_ref).map((p) => ({ envelope_id: p.envelope_ref!, deviation_pct: deviationPct }))
|
||||
|
||||
const findings = ctx.review.review({
|
||||
market_date: date,
|
||||
ledger: ctx.ledger.read(),
|
||||
bidProposal: bid,
|
||||
dispatchProposal: dispatch,
|
||||
executionReports: reports,
|
||||
metering,
|
||||
actualLoad: ctx.timeseries.latest('load:aggregate', date)?.curve ?? null,
|
||||
forecasts: { load: forecastFromLineage(ctx, bid, 'load-forecast'), price: forecastFromLineage(ctx, bid, 'price-forecast') },
|
||||
unitResources: unitResources(ctx),
|
||||
envelopeUsage,
|
||||
now,
|
||||
idGen: (p) => ctx.newId(p),
|
||||
})
|
||||
|
||||
const suspended: string[] = []
|
||||
for (const u of envelopeUsage) {
|
||||
const r = ctx.envelopes.recordExecution(u.envelope_id, u.deviation_pct, now)
|
||||
ctx.events.append({ event_type: 'EnvelopeExecutionRecorded', payload: { envelope_id: u.envelope_id, deviation_pct: u.deviation_pct, streak: r.streak }, correlation_id: `review-${date}` })
|
||||
if (r.suspended) {
|
||||
suspended.push(u.envelope_id)
|
||||
ctx.events.append({ event_type: 'EnvelopeSuspended', payload: { envelope_id: u.envelope_id, streak: r.streak }, correlation_id: `review-${date}` })
|
||||
}
|
||||
}
|
||||
|
||||
let memoryCount = 0
|
||||
const profiles: Array<{ resource_id: string; reliability_score: string }> = []
|
||||
const requests: EnvelopeChangeRequest[] = []
|
||||
const runIds: string[] = []
|
||||
for (const f of findings) {
|
||||
ctx.findings.put(f)
|
||||
const ref = ctx.snapshots.put(f)
|
||||
ctx.events.append({ event_type: 'ReviewFinding', payload: { finding_id: f.id, kind: f.kind, ref, writebacks: f.writebacks.length }, correlation_id: `review-${date}`, causation_id: f.id })
|
||||
const applied = ctx.review.apply(f, now)
|
||||
memoryCount += applied.memory.length
|
||||
for (const p of applied.profiles) {
|
||||
profiles.push({ resource_id: p.resource_id, reliability_score: p.reliability_score })
|
||||
ctx.events.append({ event_type: 'ResourceProfileUpdated', payload: { resource_id: p.resource_id, reliability_score: p.reliability_score, finding_id: f.id }, correlation_id: `review-${date}`, causation_id: f.id })
|
||||
}
|
||||
for (const req of applied.envelopeRequests) {
|
||||
ctx.envelopeRequests.put(req)
|
||||
requests.push(req)
|
||||
const caseId = ctx.caseDesk.open({ id: ctx.newId('case'), kind: 'REVIEW', objective: `Re-approve envelope ${req.envelope_id}: ${req.action} ${Object.keys(req.bounds).join(',')}`, owner: 'user-ops-lead', deadline: now }).id
|
||||
const run = await envelopeReview.createRun()
|
||||
const res = await run.start({ inputData: { request: req, case_id: caseId } })
|
||||
if (res.status === 'suspended') {
|
||||
ctx.caseDesk.requestApproval({ proposal_id: req.id, proposal_digest: contentRef(canonicalJson(req)), case_id: caseId, run_id: run.runId, required_level: 'L2', reasons: [req.basis], since: now, workflow: 'envelopeReview' })
|
||||
}
|
||||
runIds.push(run.runId)
|
||||
ctx.events.append({ event_type: 'EnvelopeChangeRequested', payload: { request_id: req.id, envelope_id: req.envelope_id, action: req.action, run_id: run.runId }, correlation_id: `review-${date}`, causation_id: f.id })
|
||||
}
|
||||
}
|
||||
return { market_date: date, findings, memory_entries: memoryCount, updated_profiles: profiles, envelope_suspended: suspended, envelope_requests: requests, envelope_review_run_ids: runIds }
|
||||
},
|
||||
})
|
||||
|
||||
const narrate = createStep({
|
||||
id: 'narrate',
|
||||
inputSchema: attribute.outputSchema,
|
||||
outputSchema: ReviewOutput,
|
||||
execute: async ({ inputData }) => {
|
||||
let reportId: string | null = null
|
||||
try {
|
||||
const report = registerSkill(
|
||||
{ lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') },
|
||||
{ id: 'report-generator', version: '1.0.0', invoke: (req: Parameters<typeof ctx.skills.report>[0]) => ctx.skills.report(req) },
|
||||
)
|
||||
const { output } = await ctx.lineage.run(() =>
|
||||
report.call({
|
||||
kind: 'BID_BACKTEST',
|
||||
market_date: inputData.market_date,
|
||||
sources: inputData.findings.map((f: ReviewFinding) => ({ tool_call_id: f.id, tool: 'review-attribution', version: '1.0.0', output: { attribution: f.attribution } })),
|
||||
}),
|
||||
)
|
||||
reportId = SkillReport.parse(output).id
|
||||
} catch (e) {
|
||||
ctx.events.append({ event_type: 'ReviewReportFailed', payload: { error: (e as Error).message }, correlation_id: `review-${inputData.market_date}` })
|
||||
}
|
||||
const f = inputData.findings[0]
|
||||
const fallback = f ? `${f.kind}: ${f.summary}. Writebacks: ${f.writebacks.map((w) => w.target).join(', ') || 'none'} (template — LLM unavailable)` : 'no findings'
|
||||
let narrative = fallback
|
||||
let llmUsed = false
|
||||
try {
|
||||
const out = await ctx.llm.structured('analysis-agent', `Narrate this review for operators without adding numbers: ${JSON.stringify(inputData.findings.map((x: ReviewFinding) => ({ kind: x.kind, summary: x.summary, attribution: x.attribution })))}`, Narrative)
|
||||
narrative = out.narrative
|
||||
llmUsed = true
|
||||
} catch (e) {
|
||||
if (!(e instanceof LlmUnavailable)) throw e
|
||||
}
|
||||
ctx.events.append({ event_type: 'ReviewCompleted', payload: { market_date: inputData.market_date, findings: inputData.findings.map((x: ReviewFinding) => x.id), report_id: reportId }, correlation_id: `review-${inputData.market_date}` })
|
||||
return { ...inputData, report_id: reportId, narrative, llm_used: llmUsed }
|
||||
},
|
||||
})
|
||||
|
||||
return createWorkflow({ id: 'review', inputSchema: ReviewInput, outputSchema: ReviewOutput }).then(attribute).then(narrate).commit()
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { BidOptimizationRequest, BidOptimizationResult, Envelope, ForecastBundle, ForecastKind, ForecastRequest, ReportRequest, SkillReport } from '@vpp/domain'
|
||||
import type { BidOptimizationRequest, BidOptimizationResult, DispatchOptimizationRequest, DispatchOptimizationResult, Envelope, ForecastBundle, ForecastKind, ForecastRequest, PotentialAssessmentRequest, PotentialAssessmentResult, ReportRequest, SkillReport } from '@vpp/domain'
|
||||
import { MemoryTimeSeriesStore } from '@vpp/services'
|
||||
import type { SkillClient } from '@vpp/services'
|
||||
import { createRuntime } from '../src/runtime.js'
|
||||
@ -56,7 +56,38 @@ export class StubSkills implements SkillClient {
|
||||
solver: { name: 'stub', version: '0', status: 'OPTIMAL', objective_value: revenue, wall_time_ms: 1 }, binding_constraints: ['daily_energy_min'], skill_version: '0.0.1',
|
||||
}
|
||||
}
|
||||
async report(_r: ReportRequest): Promise<SkillReport> { throw new Error('not used') }
|
||||
async report(r: ReportRequest): Promise<SkillReport> {
|
||||
this.calls.push('report')
|
||||
return { id: `rep-${r.kind.toLowerCase()}-${r.market_date}`, kind: r.kind, market_date: r.market_date, sections: [], skill_version: '0.0.1', generated_at: '2026-03-16T03:00:00Z' }
|
||||
}
|
||||
async assessPotential(req: PotentialAssessmentRequest): Promise<PotentialAssessmentResult> {
|
||||
this.calls.push('potential')
|
||||
const curveOf = (v: string) => ({ interval_minutes: 15 as const, date: req.market_date, values: Array(96).fill(v) as string[] })
|
||||
const assessments = req.resources.map((r) => {
|
||||
const planned = r.fulfillment.reduce((s, f) => s + Number(f.planned_mwh), 0)
|
||||
const delivered = r.fulfillment.reduce((s, f) => s + Number(f.delivered_mwh), 0)
|
||||
const rate = planned > 0 ? Math.min(1, delivered / planned) : null
|
||||
const mw = (Number(r.profile.certified_adjustable_mw) * (rate ?? 1)).toFixed(3)
|
||||
return { resource_id: r.profile.resource_id, adjustable_mw: curveOf(mw), confidence: '0.900', fulfillment_rate: rate === null ? null : rate.toFixed(4), evidence_days: r.fulfillment.length }
|
||||
})
|
||||
const total = assessments.reduce((s, a) => s + Number(a.adjustable_mw.values[0]), 0).toFixed(3)
|
||||
return { market_date: req.market_date, assessments, total_adjustable_mw: curveOf(total), skill_version: '0.0.1' }
|
||||
}
|
||||
async optimizeDispatch(req: DispatchOptimizationRequest): Promise<DispatchOptimizationResult> {
|
||||
this.calls.push('dispatch')
|
||||
// Greedy fill in unit order, per interval.
|
||||
const remaining = req.target_mw.values.map(Number)
|
||||
const allocations = req.units.map((u) => {
|
||||
const values = u.available_mw.values.map((cap, t) => {
|
||||
const x = Math.min(Number(cap), remaining[t]!)
|
||||
remaining[t]! -= x
|
||||
return x.toFixed(3)
|
||||
})
|
||||
return { unit_id: u.unit_id, target_mw: { interval_minutes: 15 as const, date: req.market_date, values } }
|
||||
})
|
||||
const shortfall = (remaining.reduce((s, v) => s + Math.max(0, v), 0) * 0.25).toFixed(3)
|
||||
return { market_date: req.market_date, total_target_mw: req.target_mw, allocations, shortfall_mwh: shortfall, solver: { name: 'stub', version: '0', status: 'OPTIMAL', wall_time_ms: 0 }, skill_version: '0.0.1' }
|
||||
}
|
||||
}
|
||||
|
||||
export function seededTimeseries(clock: () => string) {
|
||||
|
||||
159
packages/runtime/test/m4.test.ts
Normal file
159
packages/runtime/test/m4.test.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AwardNotice, Envelope } from '@vpp/domain'
|
||||
import { insightCards } from '../src/cards.js'
|
||||
import { TriggerService } from '../src/trigger.js'
|
||||
import { MARKET_DATE, envelope, harness } from './helpers.js'
|
||||
|
||||
const APPROVER = { id: 'user-ops-lead', role: 'ops-lead' }
|
||||
const flat = (v: string) => ({ interval_minutes: 15 as const, date: MARKET_DATE, values: Array(96).fill(v) as string[] })
|
||||
|
||||
const dispatchEnvelope = (bounds: Record<string, string>): Envelope =>
|
||||
envelope(bounds, { id: 'env-dispatch-001', scope: { proposal_type: 'DISPATCH_PLAN', timescales: ['DAY_AHEAD'], resource_set: 'pool-hubei-01' } })
|
||||
|
||||
/** Runs D-1 08:00 (bid) and returns the bid's daily energy so the award can answer it. */
|
||||
async function bidDay(h: Awaited<ReturnType<typeof harness>>) {
|
||||
h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
|
||||
const run = await h.rt.mastra.getWorkflow('dayAheadBid').createRun()
|
||||
const res = await run.start({ inputData: { market_date: MARKET_DATE } })
|
||||
if (res.status !== 'success') throw new Error(res.status)
|
||||
return res.result
|
||||
}
|
||||
|
||||
const awardFor = (digest: string, mwhPerInterval: string): AwardNotice => ({
|
||||
id: `award-${MARKET_DATE}`,
|
||||
market_date: MARKET_DATE,
|
||||
bid_proposal_digest: digest,
|
||||
awarded_mwh: flat(mwhPerInterval),
|
||||
clearing_price_yuan_per_mwh: flat('418.30'),
|
||||
received_at: '2026-03-14T08:00:00Z',
|
||||
})
|
||||
|
||||
describe('docs/07 D-1 16:00: award → resource agent → dispatch plan through the safety chain', () => {
|
||||
it('records the award in the ledger and releases a dispatch plan to the simulation gateway', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const bid = await bidDay(h)
|
||||
h.rt.ctx.envelopes.register(dispatchEnvelope({ max_total_mw: '30.0' }))
|
||||
const t = new TriggerService(h.rt)
|
||||
const out = await t.onAward(awardFor(bid.proposal.digest, '3.0')) // 12 MW target, resource offers 24 MW
|
||||
expect(out.status).toBe('success')
|
||||
const r = out.result!
|
||||
expect(r.lifecycle?.outcome).toBe('RELEASED')
|
||||
expect(r.proposal.type).toBe('DISPATCH_PLAN')
|
||||
expect(r.shortfall_mwh).toBe('0.000')
|
||||
expect(h.rt.ctx.ledger.read().entries.map((e) => e.kind)).toEqual(['CONTRACT', 'BID_SUBMITTED', 'AWARD'])
|
||||
expect(h.skills.calls.filter((c) => c === 'potential' || c === 'dispatch')).toEqual(['potential', 'dispatch'])
|
||||
expect(h.rt.ctx.simulationGateway.receipts.list()).toHaveLength(1)
|
||||
// P2 for dispatch numbers: allocations are byte-identical to the tool output.
|
||||
const lin = h.rt.ctx.caseDesk.expandLineage(r.proposal_id)
|
||||
expect(lin.tool_calls.map((c) => c.tool)).toEqual(['potential-assessment', 'dispatch-optimization'])
|
||||
expect((lin.tool_calls[1]!.outputs as { allocations: unknown }).allocations).toEqual(r.proposal.type === 'DISPATCH_PLAN' ? r.proposal.payload.allocations : null)
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('a shortfall (award beyond assessed capacity) alerts in power-balance simulation and goes to a human', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const bid = await bidDay(h)
|
||||
h.rt.ctx.envelopes.register(dispatchEnvelope({ max_total_mw: '100.0' }))
|
||||
const out = await new TriggerService(h.rt).onAward(awardFor(bid.proposal.digest, '10.0')) // 40 MW target > 24 MW
|
||||
expect(out.status).toBe('success')
|
||||
expect(out.result!.lifecycle_status).toBe('suspended')
|
||||
expect(Number(out.result!.shortfall_mwh)).toBeGreaterThan(0)
|
||||
expect(h.rt.ctx.caseDesk.inbox()[0]!.reasons[0]).toMatch(/short/)
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('docs/07 D+1: execution feedback → review → writebacks', () => {
|
||||
async function throughExecution(h: Awaited<ReturnType<typeof harness>>, fulfilment: number) {
|
||||
const bid = await bidDay(h)
|
||||
h.rt.ctx.envelopes.register(dispatchEnvelope({ max_total_mw: '30.0' }))
|
||||
const t = new TriggerService(h.rt)
|
||||
const out = await t.onAward(awardFor(bid.proposal.digest, '3.0'))
|
||||
const digest = out.result!.proposal.digest
|
||||
t.onExecutionReports(h.rt.ctx.simulationGateway.execute(digest, { 'agg-unit-01': fulfilment }, '2026-03-16T01:00:00Z'))
|
||||
const review = await t.onMetering({ id: `meter-${MARKET_DATE}`, market_date: MARKET_DATE, metered_mw: flat((12 * fulfilment).toFixed(3)), actual_price_yuan_per_mwh: flat('421.00'), received_at: '2026-03-16T02:00:00Z' })
|
||||
return { t, review: review.result!, bid }
|
||||
}
|
||||
|
||||
it('a ReviewFinding measurably updates the resource reliability score and writes semantic memory', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
expect(h.rt.ctx.resources.get('res-storage-01')!.value.reliability_score).toBe('0.9')
|
||||
const { review } = await throughExecution(h, 0.8)
|
||||
expect(review.findings).toHaveLength(1)
|
||||
const f = review.findings[0]!
|
||||
expect(f.kind).toBe('RESPONSE')
|
||||
expect(review.updated_profiles).toEqual([{ resource_id: 'res-storage-01', reliability_score: '0.880' }])
|
||||
expect(h.rt.ctx.resources.get('res-storage-01')!.value.reliability_score).toBe('0.880')
|
||||
expect(review.memory_entries).toBeGreaterThanOrEqual(1)
|
||||
expect(h.rt.ctx.review.memory.list().some((m) => m.value.topic === 'response:agg-unit-01')).toBe(true)
|
||||
expect(review.report_id).toMatch(/^rep-bid_backtest/)
|
||||
expect(review.llm_used).toBe(false)
|
||||
expect(h.rt.ctx.events.list({ event_type: 'ResourceProfileUpdated' })).toHaveLength(1)
|
||||
// The next potential assessment cites this execution feedback (画像时效性).
|
||||
const t = new TriggerService(h.rt)
|
||||
h.rt.ctx.envelopes.register(dispatchEnvelope({ max_total_mw: '30.0' }))
|
||||
const again = await t.onAward({ ...awardFor(h.rt.ctx.caseDesk.proposals.list()[0]!.value.digest, '2.0'), id: 'award-2', market_date: '2026-03-16' })
|
||||
void again
|
||||
const pot = h.rt.ctx.events.list({ event_type: 'PotentialAssessed' }).at(-1)!.payload as { units: Record<string, string[]> }
|
||||
expect(pot.units['agg-unit-01']![0]).toBe('19.200') // 24 MW × 0.8 fulfilment
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('envelope suspension triggers on a seeded deviation streak (N=3, threshold 10%)', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const { t } = await throughExecution(h, 0.8) // 20% deviation → streak 1
|
||||
const digest = h.rt.ctx.caseDesk.proposals.list().find((p) => p.value.type === 'DISPATCH_PLAN')!.value.digest
|
||||
for (let i = 0; i < 2; i++) {
|
||||
t.onExecutionReports(h.rt.ctx.simulationGateway.execute(digest, { 'agg-unit-01': 0.8 }, '2026-03-16T01:00:00Z'))
|
||||
await t.onMetering({ id: `meter-${i}`, market_date: MARKET_DATE, metered_mw: flat('9.600'), actual_price_yuan_per_mwh: flat('421.00'), received_at: '2026-03-16T02:00:00Z' })
|
||||
}
|
||||
expect(h.rt.ctx.envelopes.get('env-bid-001')!.status).toBe('SUSPENDED')
|
||||
expect(h.rt.ctx.envelopes.get('env-dispatch-001')!.status).toBe('SUSPENDED')
|
||||
expect(h.rt.ctx.events.list({ event_type: 'EnvelopeSuspended' })).toHaveLength(2)
|
||||
// Autonomy has returned to humans: the next bid waits for approval.
|
||||
const run = await h.rt.mastra.getWorkflow('dayAheadBid').createRun()
|
||||
const res = await run.start({ inputData: { market_date: '2026-03-16' } })
|
||||
expect(res.status === 'success' && res.result.lifecycle_status).toBe('suspended')
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('a compliant streak produces a WIDEN request that only a human can apply (envelope-review)', async () => {
|
||||
const h = await harness({ llm: null, config: { widenAfterCompliantDays: 2 } })
|
||||
const { t, review } = await throughExecution(h, 1.0)
|
||||
expect(review.envelope_requests).toHaveLength(0)
|
||||
const digest = h.rt.ctx.caseDesk.proposals.list().find((p) => p.value.type === 'DISPATCH_PLAN')!.value.digest
|
||||
t.onExecutionReports(h.rt.ctx.simulationGateway.execute(digest, {}, '2026-03-16T01:00:00Z'))
|
||||
const second = (await t.onMetering({ id: 'meter-2', market_date: MARKET_DATE, metered_mw: flat('12.000'), actual_price_yuan_per_mwh: flat('421.00'), received_at: '2026-03-16T02:00:00Z' })).result!
|
||||
expect(second.envelope_requests.map((r: { envelope_id: string }) => r.envelope_id).sort()).toEqual(['env-bid-001', 'env-dispatch-001'])
|
||||
expect(second.envelope_review_run_ids).toHaveLength(2)
|
||||
const inbox = h.rt.ctx.caseDesk.inbox().filter((p) => p.run_id !== '')
|
||||
expect(inbox.every((p) => p.workflow === 'envelopeReview')).toBe(true)
|
||||
expect(h.rt.ctx.envelopes.get('env-bid-001')!.bounds['price_deviation_pct']).toBeUndefined() // bid envelope had only max_energy
|
||||
|
||||
const bidReq = second.envelope_requests.find((r: { envelope_id: string }) => r.envelope_id === 'env-dispatch-001')!
|
||||
const runId = inbox.find((p) => p.proposal_id === bidReq.id)!.run_id
|
||||
const refused = await t.decide(runId, { decision: 'approve', approver: { id: 'resource-agent', role: 'ops-lead' } })
|
||||
expect(refused.status).toBe('suspended')
|
||||
const ok = await t.decide(runId, { decision: 'approve', approver: APPROVER, comment: 'shadow data supports widening' })
|
||||
expect(ok.status).toBe('success')
|
||||
expect(h.rt.ctx.envelopes.get('env-dispatch-001')!.bounds['max_total_mw']).toBe('36.00')
|
||||
expect(h.rt.ctx.envelopes.get('env-dispatch-001')!.approval.approved_by).toEqual(['user-ops-lead'])
|
||||
expect(h.rt.ctx.events.list({ event_type: 'EnvelopeChanged' })).toHaveLength(1)
|
||||
await h.rt.close()
|
||||
})
|
||||
|
||||
it('insight cards project situation, review and resource state with lineage refs', async () => {
|
||||
const h = await harness({ llm: null })
|
||||
const sit = await h.rt.mastra.getWorkflow('dayAheadSituation').createRun()
|
||||
await sit.start({ inputData: { market_date: MARKET_DATE } })
|
||||
await throughExecution(h, 0.8)
|
||||
const trading = insightCards(h.rt.ctx, 'TRADING_DESK')
|
||||
expect(trading.map((c) => c.title)).toEqual(['Day-ahead situation', 'D+1 review'])
|
||||
expect(trading[0]!.metrics[0]!.ref?.path).toMatch(/^quantiles\.p50\.values\.\d+$/)
|
||||
const pool = insightCards(h.rt.ctx, 'RESOURCE_POOL')
|
||||
expect(pool[0]!.headline).toMatch(/Reliability 0\.900 → 0\.880/)
|
||||
const ops = insightCards(h.rt.ctx, 'OPERATIONS_DASHBOARD')
|
||||
expect(ops.find((c) => c.id === 'card-ops-cases')).toBeDefined()
|
||||
await h.rt.close()
|
||||
})
|
||||
})
|
||||
@ -21,6 +21,8 @@ export interface PendingApproval {
|
||||
required_level: string
|
||||
reasons: string[]
|
||||
since: string
|
||||
/** Which workflow's run to resume: 'proposalLifecycle' (default) or 'envelopeReview'. */
|
||||
workflow?: string | undefined
|
||||
}
|
||||
|
||||
export interface CaseView {
|
||||
@ -177,4 +179,5 @@ export const PendingApprovalSchema = z.object({
|
||||
required_level: z.string().min(1),
|
||||
reasons: z.array(z.string()),
|
||||
since: z.string().min(1),
|
||||
workflow: z.string().optional(),
|
||||
})
|
||||
|
||||
@ -27,6 +27,48 @@ export class EnvelopeService {
|
||||
this.repo.put(envelope)
|
||||
}
|
||||
|
||||
get(id: string): Envelope | undefined {
|
||||
return this.repo.get(id)?.value
|
||||
}
|
||||
|
||||
private readonly streaks = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Escalation rule (docs/03 §3): N consecutive within-envelope executions
|
||||
* whose deviation exceeds the threshold suspend the envelope — autonomy
|
||||
* returns to humans until a re-approval. N and threshold live on the
|
||||
* envelope (OPEN-QUESTION B4 values).
|
||||
*/
|
||||
recordExecution(envelopeId: string, deviationPct: string, now: string): { streak: number; suspended: boolean } {
|
||||
const row = this.repo.get(envelopeId)
|
||||
if (!row) throw new Error(`envelope ${envelopeId} not found`)
|
||||
const env = row.value
|
||||
const exceeded = new Decimal(deviationPct).gt(env.escalation.deviation_threshold_pct)
|
||||
const streak = exceeded ? (this.streaks.get(envelopeId) ?? 0) + 1 : 0
|
||||
this.streaks.set(envelopeId, streak)
|
||||
if (exceeded && streak >= env.escalation.max_consecutive_deviations && env.status === 'ACTIVE') {
|
||||
this.repo.put({ ...env, status: 'SUSPENDED' }, row.version)
|
||||
void now
|
||||
return { streak, suspended: true }
|
||||
}
|
||||
return { streak, suspended: false }
|
||||
}
|
||||
|
||||
/** Applied only by the envelope-review workflow after a human approval. */
|
||||
apply(change: { envelope_id: string; action: 'WIDEN' | 'NARROW' | 'SUSPEND' | 'REACTIVATE'; bounds: Record<string, string> }, approvedBy: string[]): Envelope {
|
||||
const row = this.repo.get(change.envelope_id)
|
||||
if (!row) throw new Error(`envelope ${change.envelope_id} not found`)
|
||||
const env = row.value
|
||||
const next: Envelope = {
|
||||
...env,
|
||||
bounds: change.action === 'SUSPEND' ? env.bounds : { ...env.bounds, ...change.bounds },
|
||||
status: change.action === 'SUSPEND' ? 'SUSPENDED' : 'ACTIVE',
|
||||
approval: { ...env.approval, approved_by: approvedBy },
|
||||
}
|
||||
this.streaks.set(env.id, 0)
|
||||
return this.repo.put(next, row.version).value
|
||||
}
|
||||
|
||||
match(proposal: Proposal, ctx: EnvelopeContext): EnvelopeMatch {
|
||||
const fallback = ctx.fallbackLevel ?? 'L2'
|
||||
const candidates = this.repo
|
||||
@ -70,6 +112,7 @@ export class EnvelopeService {
|
||||
}
|
||||
|
||||
private checkBounds(proposal: Proposal, env: Envelope, ctx: EnvelopeContext): string[] {
|
||||
if (proposal.type === 'DISPATCH_PLAN') return this.checkDispatchBounds(proposal, env)
|
||||
if (proposal.type !== 'BID') return ['unsupported proposal type for envelope v1']
|
||||
const reasons: string[] = []
|
||||
const maxEnergy = env.bounds['max_energy_mwh']
|
||||
@ -104,4 +147,27 @@ export class EnvelopeService {
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
/** CONTROL/DISPATCH bounds (docs/03 §3): max_total_mw, max_unit_mw. Values OPEN-QUESTION B2. */
|
||||
private checkDispatchBounds(proposal: Proposal & { type: 'DISPATCH_PLAN' }, env: Envelope): string[] {
|
||||
const reasons: string[] = []
|
||||
const maxTotal = env.bounds['max_total_mw']
|
||||
const maxUnit = env.bounds['max_unit_mw']
|
||||
for (let t = 0; t < 96; t++) {
|
||||
let total = new Decimal(0)
|
||||
for (const a of proposal.payload.allocations) {
|
||||
const v = new Decimal(a.target_mw.values[t]!)
|
||||
total = total.add(v)
|
||||
if (maxUnit !== undefined && v.gt(maxUnit)) {
|
||||
reasons.push(`interval ${t}: unit ${a.unit_id} target ${v.toString()} MW > max_unit_mw ${maxUnit}`)
|
||||
return reasons
|
||||
}
|
||||
}
|
||||
if (maxTotal !== undefined && total.gt(maxTotal)) {
|
||||
reasons.push(`interval ${t}: total ${total.toString()} MW > max_total_mw ${maxTotal}`)
|
||||
return reasons
|
||||
}
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { mkdirSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { BidExportFile, ExecutionPermit, ExecutionReceipt, Proposal } from '@vpp/domain'
|
||||
import type { BidExportFile, ExecutionPermit, ExecutionReceipt, ExecutionReport, Proposal } from '@vpp/domain'
|
||||
import { BidExportFile as BidExportFileSchema, ExecutionReceipt as ReceiptSchema } from '@vpp/domain'
|
||||
import type { AuthorityService } from './authority.js'
|
||||
import { MemoryRepository } from './relational.js'
|
||||
@ -73,3 +73,63 @@ export class FileExportGateway implements GatewayPort {
|
||||
return receipt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulation gateway (docs/08 shadow mode, ROADMAP M5): accepts released
|
||||
* DISPATCH_PLAN proposals the same way the execution engine will — permit
|
||||
* only, idempotent — and can later "execute" them with seeded deviations to
|
||||
* produce ExecutionReports for the review loop.
|
||||
*/
|
||||
export class SimulationGateway implements GatewayPort {
|
||||
readonly orders = new Map<string, { proposal: Proposal; permit: ExecutionPermit }>()
|
||||
|
||||
constructor(
|
||||
private readonly authority: AuthorityService,
|
||||
readonly receipts: Repository<ExecutionReceipt> = new MemoryRepository(ReceiptSchema, (r) => r.idempotency_key),
|
||||
) {}
|
||||
|
||||
dispatch(proposal: Proposal, permit: ExecutionPermit, now: string): ExecutionReceipt {
|
||||
const key = `${proposal.digest}:${permit.id}`
|
||||
const existing = this.receipts.get(key)
|
||||
if (existing) return existing.value
|
||||
const reasons = this.authority.validate(permit, proposal, now)
|
||||
if (reasons.length > 0) throw new GatewayRejected(reasons)
|
||||
if (proposal.type !== 'DISPATCH_PLAN') throw new GatewayRejected([`simulation gateway accepts DISPATCH_PLAN only, got ${proposal.type}`])
|
||||
this.orders.set(proposal.digest, { proposal, permit })
|
||||
const receipt: ExecutionReceipt = {
|
||||
receipt_id: `rcpt-${proposal.id}-${permit.id}`,
|
||||
proposal_digest: proposal.digest,
|
||||
permit_id: permit.id,
|
||||
channel: 'SIMULATION_GATEWAY',
|
||||
idempotency_key: key,
|
||||
artifact_ref: `sim://dispatch/${proposal.digest}`,
|
||||
accepted_at: now,
|
||||
}
|
||||
this.receipts.put(receipt)
|
||||
return receipt
|
||||
}
|
||||
|
||||
/** Seeded execution: each unit delivers `fulfillment[unit]` (default 1.0) of its plan. */
|
||||
execute(proposalDigest: string, fulfillment: Record<string, number>, now: string): ExecutionReport[] {
|
||||
const order = this.orders.get(proposalDigest)
|
||||
if (!order || order.proposal.type !== 'DISPATCH_PLAN') throw new Error(`no dispatch order for ${proposalDigest.slice(0, 8)}…`)
|
||||
const p = order.proposal
|
||||
return p.payload.allocations.map((a) => {
|
||||
const f = fulfillment[a.unit_id] ?? 1
|
||||
const actual = a.target_mw.values.map((v) => (Number(v) * f).toFixed(3))
|
||||
const planned = a.target_mw.values.map(Number).reduce((s, v) => s + v, 0) * 0.25
|
||||
const dev = a.target_mw.values.reduce((s, v, i) => s + Math.abs(Number(v) - Number(actual[i])), 0) * 0.25
|
||||
return {
|
||||
id: `exec-${p.payload.market_date}-${a.unit_id}`,
|
||||
market_date: p.payload.market_date,
|
||||
unit_id: a.unit_id,
|
||||
dispatch_proposal_digest: p.digest,
|
||||
planned_mw: a.target_mw,
|
||||
actual_mw: { ...a.target_mw, values: actual },
|
||||
deviation_mwh: dev.toFixed(3),
|
||||
fulfillment_ratio: planned === 0 ? '1.000' : Math.min(1, (planned - dev) / planned).toFixed(3),
|
||||
reported_at: now,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,3 +15,4 @@ export * from './lineage.js'
|
||||
export * from './simulation.js'
|
||||
export * from './casedesk.js'
|
||||
export * from './skillclient.js'
|
||||
export * from './review.js'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import type { BidPayload, BidProposalDraft, LineageRef, Proposal, ToolCallRef } from '@vpp/domain'
|
||||
import { BidPayload as BidPayloadSchema, Curve96, DecimalString } from '@vpp/domain'
|
||||
import type { BidPayload, BidProposalDraft, DispatchPlanPayload, DispatchProposalDraft, LineageRef, Proposal, ToolCallRef } from '@vpp/domain'
|
||||
import { BidPayload as BidPayloadSchema, Curve96, DecimalString, DispatchPlanPayload as DispatchPlanPayloadSchema } from '@vpp/domain'
|
||||
import { proposalDigest } from './digest.js'
|
||||
import type { SnapshotStore } from './snapshot.js'
|
||||
|
||||
@ -118,3 +118,38 @@ export function assembleBidProposal(
|
||||
}
|
||||
return { ...base, digest: proposalDigest(base) }
|
||||
}
|
||||
|
||||
/** DISPATCH_PLAN assembler: same discipline, resource-agent originator, EVENT trigger. */
|
||||
export function assembleDispatchProposal(
|
||||
draft: DispatchProposalDraft,
|
||||
ctx: {
|
||||
id: string
|
||||
award_ref: string
|
||||
originator: Proposal['originator']
|
||||
lineage: { tool_calls: ToolCallRef[]; data_refs: string[] }
|
||||
ledger_version: number
|
||||
policy_pack_version: string
|
||||
snapshots: SnapshotStore
|
||||
now: string
|
||||
},
|
||||
): Proposal {
|
||||
const payload: DispatchPlanPayload = DispatchPlanPayloadSchema.parse({
|
||||
kind: 'DISPATCH_PLAN',
|
||||
market_date: draft.market_date,
|
||||
award_ref: ctx.award_ref,
|
||||
total_target_mw: Curve96.parse(resolveRef(draft.total_target_ref, ctx.lineage.tool_calls, ctx.snapshots)),
|
||||
allocations: resolveRef(draft.allocations_ref, ctx.lineage.tool_calls, ctx.snapshots),
|
||||
})
|
||||
const base = {
|
||||
id: ctx.id,
|
||||
type: 'DISPATCH_PLAN' as const,
|
||||
timescale: 'DAY_AHEAD' as const,
|
||||
originator: ctx.originator,
|
||||
payload,
|
||||
lineage: { tool_calls: ctx.lineage.tool_calls, data_refs: ctx.lineage.data_refs, ledger_version: ctx.ledger_version, policy_pack_version: ctx.policy_pack_version },
|
||||
envelope_ref: null,
|
||||
status: 'DRAFT' as const,
|
||||
created_at: ctx.now,
|
||||
}
|
||||
return { ...base, digest: proposalDigest(base) }
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { LedgerView, Proposal, ValidationResult } from '@vpp/domain'
|
||||
import { digestMatches } from './digest.js'
|
||||
import { canonicalJson } from './snapshot.js'
|
||||
import type { LedgerService } from './ledger.js'
|
||||
import type { SnapshotStore } from './snapshot.js'
|
||||
import { Decimal } from './decimal.js'
|
||||
@ -89,12 +90,15 @@ export interface HubeiBidPackConfig {
|
||||
priceCapYuanPerMwh: string
|
||||
/** Agents permitted to originate each proposal type (I2: operate ≠ approve ≠ administer). */
|
||||
originators: Record<string, string[]>
|
||||
dispatchBalanceToleranceMw: string
|
||||
}
|
||||
|
||||
export const HUBEI_BID_PACK_PLACEHOLDER: HubeiBidPackConfig = {
|
||||
priceFloorYuanPerMwh: '0', // OPEN-QUESTION D5
|
||||
priceCapYuanPerMwh: '1500', // OPEN-QUESTION D5
|
||||
originators: { BID: ['trading-agent', 'operator'] },
|
||||
originators: { BID: ['trading-agent', 'operator'], DISPATCH_PLAN: ['resource-agent', 'operator'] },
|
||||
/** Allowed |Σ allocations − target| per interval, MW. OPEN-QUESTION B2. */
|
||||
dispatchBalanceToleranceMw: '0.001',
|
||||
}
|
||||
|
||||
const reject = (rule_id: string, message: string): Violation => ({ rule_id, severity: 'REJECT', message })
|
||||
@ -178,8 +182,16 @@ export function hubeiSpotBiddingPack(version: string, cfg: HubeiBidPackConfig):
|
||||
description: 'every payload number is found verbatim in a referenced tool output (P2, machine-checkable)',
|
||||
source_refs: ['docs/03 §1', 'docs/01 P2'],
|
||||
check: (p, ctx) => {
|
||||
if (p.type !== 'BID') return []
|
||||
if (p.lineage.tool_calls.length === 0) return [reject('lineage-integrity', 'no tool calls in lineage')]
|
||||
if (p.type === 'DISPATCH_PLAN') {
|
||||
const outputs = p.lineage.tool_calls.map((tc) => ctx.snapshots.get(tc.outputs_ref)).filter((o): o is Record<string, unknown> => !!o && typeof o === 'object')
|
||||
const eq = (a: unknown, b: unknown) => canonicalJson(a) === canonicalJson(b)
|
||||
const out: Violation[] = []
|
||||
if (!outputs.some((o) => eq((o['total_target_mw'] as { values?: unknown } | undefined)?.values, p.payload.total_target_mw.values))) out.push(reject('lineage-integrity', 'total_target_mw not traceable to a tool output'))
|
||||
if (!outputs.some((o) => eq(o['allocations'], p.payload.allocations))) out.push(reject('lineage-integrity', 'allocations not traceable to a tool output'))
|
||||
return out
|
||||
}
|
||||
if (p.type !== 'BID') return []
|
||||
const outputs: Record<string, unknown>[] = []
|
||||
for (const tc of p.lineage.tool_calls) {
|
||||
const out = ctx.snapshots.get(tc.outputs_ref)
|
||||
@ -196,6 +208,33 @@ export function hubeiSpotBiddingPack(version: string, cfg: HubeiBidPackConfig):
|
||||
return out
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dispatch-balance',
|
||||
description: 'allocations never exceed the awarded target in any interval; a shortfall is a simulation alert, not a rule breach (docs/04 §2)',
|
||||
source_refs: ['docs/04 §2', 'B2'],
|
||||
check: (p) => {
|
||||
if (p.type !== 'DISPATCH_PLAN') return []
|
||||
const tol = new Decimal(cfg.dispatchBalanceToleranceMw)
|
||||
for (let t = 0; t < 96; t++) {
|
||||
const total = p.payload.allocations.reduce((s, a) => s.add(new Decimal(a.target_mw.values[t]!)), new Decimal(0))
|
||||
const target = new Decimal(p.payload.total_target_mw.values[t]!)
|
||||
if (total.sub(target).gt(tol)) return [reject('dispatch-balance', `interval ${t}: allocations ${total.toString()} MW exceed target ${target.toString()} MW`)]
|
||||
}
|
||||
return []
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dispatch-award-anchor',
|
||||
description: 'a dispatch plan cites an AWARD position in the current ledger for its market date (P7)',
|
||||
source_refs: ['docs/04 §4'],
|
||||
check: (p, ctx) => {
|
||||
if (p.type !== 'DISPATCH_PLAN') return []
|
||||
const award = ctx.ledger.entries.find((e) => e.kind === 'AWARD' && e.timescale === 'DAY_AHEAD' && e.period === p.payload.market_date)
|
||||
if (!award) return [reject('dispatch-award-anchor', `no AWARD position for ${p.payload.market_date} in ledger v${ctx.ledger.version}`)]
|
||||
if (p.lineage.ledger_version !== ctx.ledger.version) return [reject('dispatch-award-anchor', `lineage ledger_version ${p.lineage.ledger_version} ≠ current ${ctx.ledger.version}`)]
|
||||
return []
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'originator-permission',
|
||||
description: 'originating agent is allowed to originate this proposal type (I2)',
|
||||
|
||||
201
packages/services/src/review.ts
Normal file
201
packages/services/src/review.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import type {
|
||||
Curve96,
|
||||
Envelope,
|
||||
EnvelopeChangeRequest,
|
||||
ExecutionReport,
|
||||
ForecastBundle,
|
||||
LedgerView,
|
||||
MeteringRecord,
|
||||
Proposal,
|
||||
ResourceProfile,
|
||||
ReviewFinding,
|
||||
SemanticMemoryEntry,
|
||||
Writeback,
|
||||
} from '@vpp/domain'
|
||||
import { SemanticMemoryEntry as SemanticMemoryEntrySchema } from '@vpp/domain'
|
||||
import { Decimal } from './decimal.js'
|
||||
import type { EnvelopeService } from './envelope.js'
|
||||
import { MemoryRepository } from './relational.js'
|
||||
import type { Repository, ResourceRegistry } from './relational.js'
|
||||
|
||||
/**
|
||||
* Review loop (docs/02 §4, docs/07 D+1): deterministic plan-vs-actual per
|
||||
* timescale from the ledger and execution/metering facts, attribution into
|
||||
* trading / control / response / forecast components, and the three
|
||||
* writebacks. Everything here is arithmetic on recorded objects — the
|
||||
* analysis agent may narrate a finding but never produces one.
|
||||
*/
|
||||
export interface ReviewInputs {
|
||||
market_date: string
|
||||
ledger: LedgerView
|
||||
bidProposal: Proposal | null
|
||||
dispatchProposal: Proposal | null
|
||||
executionReports: ExecutionReport[]
|
||||
metering: MeteringRecord | null
|
||||
/** Actual aggregate load telemetry for the day (time-series store) — what the load forecast is judged against. */
|
||||
actualLoad: Curve96 | null
|
||||
forecasts: { load: ForecastBundle | null; price: ForecastBundle | null }
|
||||
/** Unit → resources it aggregates (for reliability writebacks). */
|
||||
unitResources: Record<string, string[]>
|
||||
/** Envelopes used on this day and the day's deviation vs threshold. */
|
||||
envelopeUsage: Array<{ envelope_id: string; deviation_pct: string }>
|
||||
now: string
|
||||
idGen: (prefix: string) => string
|
||||
}
|
||||
|
||||
export interface ReviewConfig {
|
||||
/** EWMA weight of the new observation in the reliability score. OPEN-QUESTION B10. */
|
||||
reliabilityAlpha: string
|
||||
/** Consecutive compliant days before recommending a wider envelope. OPEN-QUESTION B4/B9. */
|
||||
widenAfterCompliantDays: number
|
||||
/** Relative widening step for a WIDEN recommendation. OPEN-QUESTION B1. */
|
||||
widenStepPct: string
|
||||
}
|
||||
|
||||
export const DEFAULT_REVIEW_CONFIG: ReviewConfig = {
|
||||
reliabilityAlpha: '0.2',
|
||||
widenAfterCompliantDays: 20,
|
||||
widenStepPct: '20',
|
||||
}
|
||||
|
||||
const D = (v: string | number) => new Decimal(v)
|
||||
const energy = (values: string[]) => values.reduce((s, v) => s.add(D(v)), D(0)).mul('0.25')
|
||||
const mape = (actual: string[], pred: string[]): string | null => {
|
||||
let n = 0
|
||||
let sum = D(0)
|
||||
actual.forEach((a, i) => {
|
||||
if (D(a).eq(0)) return
|
||||
sum = sum.add(D(pred[i]!).sub(D(a)).abs().div(D(a)))
|
||||
n++
|
||||
})
|
||||
return n === 0 ? null : sum.div(n).toFixed(4)
|
||||
}
|
||||
|
||||
export class ReviewService {
|
||||
private readonly compliantDays = new Map<string, number>()
|
||||
|
||||
constructor(
|
||||
private readonly resources: ResourceRegistry,
|
||||
private readonly envelopes: EnvelopeService,
|
||||
readonly memory: Repository<SemanticMemoryEntry> = new MemoryRepository(SemanticMemoryEntrySchema, (m) => m.id),
|
||||
private readonly cfg: ReviewConfig = DEFAULT_REVIEW_CONFIG,
|
||||
) {}
|
||||
|
||||
/** Plan-vs-actual per timescale → attribution → finding with writebacks (not yet applied). */
|
||||
review(input: ReviewInputs): ReviewFinding[] {
|
||||
const findings: ReviewFinding[] = []
|
||||
const bid = input.bidProposal?.type === 'BID' ? input.bidProposal.payload : null
|
||||
const award = input.ledger.entries.find((e) => e.kind === 'AWARD' && e.period === input.market_date)
|
||||
const dispatch = input.dispatchProposal?.type === 'DISPATCH_PLAN' ? input.dispatchProposal.payload : null
|
||||
|
||||
const bidMwh = bid ? bid.quantities_mwh.values.reduce((s, v) => s.add(D(v)), D(0)) : D(0)
|
||||
const awardMwh = award ? D(award.energy_mwh) : D(0)
|
||||
const planMwh = dispatch ? energy(dispatch.total_target_mw.values) : D(0)
|
||||
const deliveredMwh = input.executionReports.reduce((s, r) => s.add(energy(r.actual_mw.values)), D(0))
|
||||
const meteredMwh = input.metering ? energy(input.metering.metered_mw.values) : deliveredMwh
|
||||
|
||||
const attribution = {
|
||||
trading_mwh: bidMwh.sub(awardMwh).abs().toFixed(3),
|
||||
control_mwh: awardMwh.sub(planMwh).abs().toFixed(3),
|
||||
response_mwh: input.executionReports.reduce((s, r) => s.add(D(r.deviation_mwh)), D(0)).toFixed(3),
|
||||
forecast_load_mape: input.forecasts.load && input.actualLoad ? mape(input.actualLoad.values, input.forecasts.load.quantiles.p50.values) : null,
|
||||
forecast_price_mape: input.forecasts.price && input.metering ? mape(input.metering.actual_price_yuan_per_mwh.values, input.forecasts.price.quantiles.p50.values) : null,
|
||||
net_deviation_mwh: meteredMwh.sub(planMwh.gt(0) ? planMwh : awardMwh).toFixed(3),
|
||||
}
|
||||
const evidence = [
|
||||
...(input.bidProposal ? [input.bidProposal.digest] : []),
|
||||
...(input.dispatchProposal ? [input.dispatchProposal.digest] : []),
|
||||
]
|
||||
|
||||
// ---- RESPONSE: per-unit fulfillment → resource reliability writebacks
|
||||
const writebacks: Writeback[] = []
|
||||
for (const r of input.executionReports) {
|
||||
for (const rid of input.unitResources[r.unit_id] ?? []) {
|
||||
const row = this.resources.get(rid)
|
||||
if (!row) continue
|
||||
const prev = D(row.value.reliability_score)
|
||||
const alpha = D(this.cfg.reliabilityAlpha)
|
||||
const next = prev.mul(D(1).sub(alpha)).add(D(r.fulfillment_ratio).mul(alpha))
|
||||
writebacks.push({ target: 'RESOURCE_PROFILE', resource_id: rid, previous_reliability_score: prev.toFixed(3), new_reliability_score: next.toFixed(3), fulfillment_ratio: r.fulfillment_ratio })
|
||||
}
|
||||
if (D(r.fulfillment_ratio).lt('0.9')) {
|
||||
writebacks.push({ target: 'SEMANTIC_MEMORY', topic: `response:${r.unit_id}`, lesson: `${r.unit_id} delivered ${D(r.fulfillment_ratio).mul(100).toFixed(1)}% of plan on ${input.market_date} (deviation ${r.deviation_mwh} MWh)` })
|
||||
}
|
||||
}
|
||||
// ---- ENVELOPE recommendations from compliance streaks
|
||||
for (const u of input.envelopeUsage) {
|
||||
const env = this.envelopes.get(u.envelope_id)
|
||||
if (!env) continue
|
||||
const compliant = D(u.deviation_pct).lte(env.escalation.deviation_threshold_pct)
|
||||
const days = compliant ? (this.compliantDays.get(env.id) ?? 0) + 1 : 0
|
||||
this.compliantDays.set(env.id, days)
|
||||
writebacks.push(this.envelopeRecommendation(env, days, compliant))
|
||||
}
|
||||
// ---- FORECAST lesson
|
||||
if (attribution.forecast_load_mape !== null && D(attribution.forecast_load_mape).gt('0.08')) {
|
||||
writebacks.push({ target: 'SEMANTIC_MEMORY', topic: 'forecast:load', lesson: `Load MAPE ${D(attribution.forecast_load_mape).mul(100).toFixed(1)}% on ${input.market_date} exceeds the 8% KPI; feature-engineering candidate` })
|
||||
}
|
||||
|
||||
const kind = D(attribution.response_mwh).gt(0) ? 'RESPONSE' : D(attribution.trading_mwh).gt(0) ? 'TRADING' : D(attribution.control_mwh).gt(0) ? 'CONTROL' : 'FORECAST'
|
||||
findings.push({
|
||||
id: input.idGen('rf'),
|
||||
market_date: input.market_date,
|
||||
kind,
|
||||
summary: `bid ${bidMwh.toFixed(3)} / award ${awardMwh.toFixed(3)} / plan ${planMwh.toFixed(3)} / delivered ${deliveredMwh.toFixed(3)} / metered ${meteredMwh.toFixed(3)} MWh; ${writebacks.length} writebacks`,
|
||||
attribution,
|
||||
writebacks,
|
||||
evidence_refs: evidence,
|
||||
lineage_refs: [],
|
||||
generated_at: input.now,
|
||||
})
|
||||
return findings
|
||||
}
|
||||
|
||||
private envelopeRecommendation(env: Envelope, compliantDays: number, compliant: boolean): Writeback {
|
||||
const bound = env.scope.proposal_type === 'BID' ? 'price_deviation_pct' : 'max_total_mw'
|
||||
const current = env.bounds[bound] ?? '0'
|
||||
if (!compliant) {
|
||||
return { target: 'ENVELOPE_RECOMMENDATION', envelope_id: env.id, action: env.status === 'SUSPENDED' ? 'SUSPEND' : 'KEEP', bound, current_value: current, recommended_value: current, basis: `deviation above threshold on this day (streak reset)` }
|
||||
}
|
||||
if (compliantDays >= this.cfg.widenAfterCompliantDays) {
|
||||
const recommended = D(current).mul(D(1).add(D(this.cfg.widenStepPct).div(100))).toFixed(2)
|
||||
return { target: 'ENVELOPE_RECOMMENDATION', envelope_id: env.id, action: 'WIDEN', bound, current_value: current, recommended_value: recommended, basis: `${compliantDays}/${compliantDays} within-envelope days under threshold` }
|
||||
}
|
||||
return { target: 'ENVELOPE_RECOMMENDATION', envelope_id: env.id, action: 'KEEP', bound, current_value: current, recommended_value: current, basis: `${compliantDays} compliant days (widen at ${this.cfg.widenAfterCompliantDays})` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the writebacks a finding carries. Semantic memory and reliability
|
||||
* scores are written directly (they are review's own stores, docs/02 §4).
|
||||
* Envelope changes are NOT applied here — they become EnvelopeChangeRequests
|
||||
* for a human (docs/07 D+1: 触发包络重审工作流,人工批准生效).
|
||||
*/
|
||||
apply(finding: ReviewFinding, now: string): { memory: SemanticMemoryEntry[]; profiles: ResourceProfile[]; envelopeRequests: EnvelopeChangeRequest[] } {
|
||||
const memory: SemanticMemoryEntry[] = []
|
||||
const profiles: ResourceProfile[] = []
|
||||
const envelopeRequests: EnvelopeChangeRequest[] = []
|
||||
for (const w of finding.writebacks) {
|
||||
if (w.target === 'SEMANTIC_MEMORY') {
|
||||
const entry: SemanticMemoryEntry = { id: `mem-${finding.id}-${memory.length + 1}`, topic: w.topic, lesson: w.lesson, source_finding_id: finding.id, evidence_refs: finding.evidence_refs, created_at: now }
|
||||
this.memory.put(entry)
|
||||
memory.push(entry)
|
||||
} else if (w.target === 'RESOURCE_PROFILE') {
|
||||
const row = this.resources.get(w.resource_id)
|
||||
if (!row) continue
|
||||
const updated = this.resources.put({ ...row.value, reliability_score: w.new_reliability_score, updated_at: now }, row.version).value
|
||||
profiles.push(updated)
|
||||
} else if (w.target === 'ENVELOPE_RECOMMENDATION' && (w.action === 'WIDEN' || w.action === 'NARROW')) {
|
||||
envelopeRequests.push({
|
||||
id: `ecr-${finding.id}-${w.envelope_id}`,
|
||||
envelope_id: w.envelope_id,
|
||||
action: w.action,
|
||||
bounds: { [w.bound]: w.recommended_value },
|
||||
basis: `ReviewFinding ${finding.id}: ${w.basis}`,
|
||||
source_finding_id: finding.id,
|
||||
requested_at: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
return { memory, profiles, envelopeRequests }
|
||||
}
|
||||
}
|
||||
@ -103,3 +103,38 @@ export class RevenueScenarioSimulator implements SimulationPort {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Power-balance check for dispatch plans (docs/03 §2 控制类走功率平衡): the
|
||||
* decomposition must cover the target in every interval and no unit may be
|
||||
* asked for more than its available capacity. Any breach is an alert.
|
||||
*/
|
||||
export class PowerBalanceSimulator implements SimulationPort {
|
||||
static readonly NAME = 'power-balance'
|
||||
static readonly VERSION = '1.0.0'
|
||||
|
||||
constructor(private readonly unitLimits: (marketDate: string) => Record<string, string[]>) {}
|
||||
|
||||
simulate(proposal: Proposal, ctx: SimulationContext): SimulationResult {
|
||||
const base = { proposal_digest: proposal.digest, kind: 'POWER_BALANCE' as const, simulator: { name: PowerBalanceSimulator.NAME, version: PowerBalanceSimulator.VERSION, seed: 0 }, simulated_at: ctx.now }
|
||||
if (proposal.type !== 'DISPATCH_PLAN') return { ...base, alert: true, alerts: ['power-balance simulation only supports DISPATCH_PLAN'], metrics: {}, scenario_count: 0 }
|
||||
const limits = this.unitLimits(proposal.payload.market_date)
|
||||
const alerts: string[] = []
|
||||
let shortfall = 0
|
||||
let overload = 0
|
||||
for (let t = 0; t < 96; t++) {
|
||||
let total = 0
|
||||
for (const a of proposal.payload.allocations) {
|
||||
const v = Number(a.target_mw.values[t])
|
||||
total += v
|
||||
const lim = limits[a.unit_id]?.[t]
|
||||
if (lim !== undefined && v > Number(lim) + 1e-9) overload += (v - Number(lim)) * 0.25
|
||||
}
|
||||
const gap = Number(proposal.payload.total_target_mw.values[t]) - total
|
||||
if (gap > 1e-6) shortfall += gap * 0.25
|
||||
}
|
||||
if (shortfall > 0) alerts.push(`decomposition short by ${shortfall.toFixed(3)} MWh over the day`)
|
||||
if (overload > 0) alerts.push(`units asked beyond available capacity: ${overload.toFixed(3)} MWh`)
|
||||
return { ...base, alert: alerts.length > 0, alerts, metrics: { shortfall_mwh: shortfall.toFixed(3), overload_mwh: overload.toFixed(3) }, scenario_count: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import {
|
||||
BidOptimizationRequest,
|
||||
BidOptimizationResult,
|
||||
DispatchOptimizationRequest,
|
||||
DispatchOptimizationResult,
|
||||
ForecastBundle,
|
||||
ForecastRequest,
|
||||
PotentialAssessmentRequest,
|
||||
PotentialAssessmentResult,
|
||||
ReportRequest,
|
||||
SkillReport,
|
||||
} from '@vpp/domain'
|
||||
@ -19,6 +23,8 @@ export interface SkillClient {
|
||||
forecast(kind: ForecastKind, req: ForecastRequest): Promise<ForecastBundle>
|
||||
optimizeBid(req: BidOptimizationRequest): Promise<BidOptimizationResult>
|
||||
report(req: ReportRequest): Promise<SkillReport>
|
||||
assessPotential(req: PotentialAssessmentRequest): Promise<PotentialAssessmentResult>
|
||||
optimizeDispatch(req: DispatchOptimizationRequest): Promise<DispatchOptimizationResult>
|
||||
}
|
||||
|
||||
export class SkillHttpError extends Error {
|
||||
@ -72,4 +78,12 @@ export class HttpSkillClient implements SkillClient {
|
||||
report(req: ReportRequest) {
|
||||
return this.post('/v1/report', ReportRequest.parse(req), (x) => SkillReport.parse(x))
|
||||
}
|
||||
|
||||
assessPotential(req: PotentialAssessmentRequest) {
|
||||
return this.post('/v1/assess/potential', PotentialAssessmentRequest.parse(req), (x) => PotentialAssessmentResult.parse(x))
|
||||
}
|
||||
|
||||
optimizeDispatch(req: DispatchOptimizationRequest) {
|
||||
return this.post('/v1/optimize/dispatch', DispatchOptimizationRequest.parse(req), (x) => DispatchOptimizationResult.parse(x))
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BidProposalDraft, Proposal as ProposalSchema } from '@vpp/domain'
|
||||
import type { Proposal } from '@vpp/domain'
|
||||
import { AuthorityService, assertApproverIndependent, isStaleDenial } from '../src/authority.js'
|
||||
import { CaseDeskService } from '../src/casedesk.js'
|
||||
import { proposalDigest } from '../src/digest.js'
|
||||
@ -31,7 +32,7 @@ describe('policy engine: hubei-spot-bidding pack (each rule has a pass and a fai
|
||||
|
||||
it('digest-valid: a tampered payload no longer matches its digest', () => {
|
||||
const w = world()
|
||||
const tampered = { ...w.proposal, payload: { ...w.proposal.payload, expected_revenue_yuan: '999.00' } }
|
||||
const tampered = { ...w.proposal, payload: { ...w.proposal.payload, expected_revenue_yuan: '999.00' } } as Proposal
|
||||
const r = w.engine.check(tampered, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.violations.map((v) => v.rule_id)).toContain('digest-valid')
|
||||
})
|
||||
@ -56,7 +57,7 @@ describe('policy engine: hubei-spot-bidding pack (each rule has a pass and a fai
|
||||
|
||||
it('lineage-integrity: a number not present in any referenced tool output is rejected (P2)', () => {
|
||||
const w = world()
|
||||
const forged = { ...w.proposal, payload: { ...w.proposal.payload, quantities_mwh: curve('12.6') } }
|
||||
const forged = { ...w.proposal, payload: { ...w.proposal.payload, quantities_mwh: curve('12.6') } } as Proposal
|
||||
forged.digest = proposalDigest(forged) // even a re-digested forgery fails: the number has no provenance
|
||||
const r = w.engine.check(forged, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
|
||||
expect(r.violations.map((v) => v.rule_id)).toContain('lineage-integrity')
|
||||
|
||||
@ -49,7 +49,7 @@ export function world(opts: { qty?: string; price?: string; agent?: string } = {
|
||||
}))
|
||||
const forecastTool = withLineage(lineage, snapshots, { tool: 'price-forecast', version: '1.0.0', idGen: () => `tc-${++n}` }, (_i: unknown) => priceForecast())
|
||||
|
||||
const proposal = lineage.run(() => {
|
||||
const proposal: Proposal = lineage.run(() => {
|
||||
forecastTool({ kind: 'PRICE' })
|
||||
const { call } = milp({ market_date: DATE })
|
||||
return assembleBidProposal(
|
||||
|
||||
176
packages/services/test/m4.test.ts
Normal file
176
packages/services/test/m4.test.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ExecutionReport, Proposal, ReviewFinding } from '@vpp/domain'
|
||||
import { isStaleDenial } from '../src/authority.js'
|
||||
import { EnvelopeService } from '../src/envelope.js'
|
||||
import { GatewayRejected, SimulationGateway } from '../src/gateway.js'
|
||||
import { LineageRecorder, assembleDispatchProposal, withLineage } from '../src/lineage.js'
|
||||
import { createResourceRegistry } from '../src/relational.js'
|
||||
import { ReviewService } from '../src/review.js'
|
||||
import { PowerBalanceSimulator } from '../src/simulation.js'
|
||||
import { DATE, NOW, PACK_VERSION, approvalFor, curve, envelope, world } from './helpers.js'
|
||||
|
||||
/** Dispatch world: an AWARD in the ledger, a recorded dispatch-optimization output, a DISPATCH_PLAN proposal from refs. */
|
||||
function dispatchWorld(opts: { total?: string; alloc?: string[] } = {}) {
|
||||
const w = world()
|
||||
w.ledger.append({ id: 'award', timescale: 'DAY_AHEAD', period: DATE, kind: 'AWARD', energy_mwh: '1152.000', curve: curve('12.0'), source_ref: 'award-1', expected_version: 1 })
|
||||
const lineage = new LineageRecorder()
|
||||
let n = 0
|
||||
const total = opts.total ?? '48.000'
|
||||
const allocs = opts.alloc ?? ['30.000', '18.000']
|
||||
const dispatch = withLineage(lineage, w.snapshots, { tool: 'dispatch-optimization', version: '1.0.0', idGen: () => `tcd-${++n}` }, (_i: unknown) => ({
|
||||
market_date: DATE,
|
||||
total_target_mw: curve(total),
|
||||
allocations: allocs.map((v, i) => ({ unit_id: `u${i + 1}`, target_mw: curve(v) })),
|
||||
shortfall_mwh: '0.000',
|
||||
}))
|
||||
const proposal: Proposal = lineage.run(() => {
|
||||
const { call } = dispatch({})
|
||||
return assembleDispatchProposal(
|
||||
{ market_date: DATE, total_target_ref: { tool_call_id: call.tool_call_id, path: 'total_target_mw' }, allocations_ref: { tool_call_id: call.tool_call_id, path: 'allocations' }, rationale: 'test' },
|
||||
{ id: 'prop-d1', award_ref: 'award-1', originator: { agent: 'resource-agent', trigger: 'EVENT', task_id: 'case-d' }, lineage: lineage.current(), ledger_version: w.ledger.read().version, policy_pack_version: PACK_VERSION, snapshots: w.snapshots, now: NOW },
|
||||
)
|
||||
})
|
||||
return { ...w, proposal }
|
||||
}
|
||||
|
||||
describe('dispatch plans in the policy pack', () => {
|
||||
it('a balanced, anchored, traceable plan passes', () => {
|
||||
const d = dispatchWorld()
|
||||
const r = d.engine.check(d.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, d.ruleCtx())
|
||||
expect(r.ok, JSON.stringify(r.violations)).toBe(true)
|
||||
expect(r.rules_evaluated).toContain('dispatch-balance')
|
||||
})
|
||||
|
||||
it('dispatch-balance rejects over-allocation (shortfall is left to the simulation alert)', () => {
|
||||
const short = dispatchWorld({ alloc: ['30.000', '17.000'] })
|
||||
expect(short.engine.check(short.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, short.ruleCtx()).ok).toBe(true)
|
||||
const d = dispatchWorld({ alloc: ['30.000', '19.000'] })
|
||||
const r = d.engine.check(d.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, d.ruleCtx())
|
||||
expect(r.violations.map((v) => v.rule_id)).toContain('dispatch-balance')
|
||||
})
|
||||
|
||||
it('dispatch-award-anchor rejects a plan without an AWARD in the ledger', () => {
|
||||
const w = world()
|
||||
const d = dispatchWorld()
|
||||
const r = w.engine.check({ ...d.proposal, lineage: { ...d.proposal.lineage, ledger_version: 1 } } as Proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, { ...w.ruleCtx(), snapshots: d.snapshots })
|
||||
expect(r.violations.some((v) => v.rule_id === 'dispatch-award-anchor')).toBe(true)
|
||||
})
|
||||
|
||||
it('originator-permission: the trading agent may not originate a dispatch plan', () => {
|
||||
const d = dispatchWorld()
|
||||
const r = d.engine.check({ ...d.proposal, originator: { ...d.proposal.originator, agent: 'trading-agent' } } as Proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, d.ruleCtx())
|
||||
expect(r.violations.map((v) => v.rule_id)).toContain('originator-permission')
|
||||
})
|
||||
})
|
||||
|
||||
describe('power-balance simulation', () => {
|
||||
it('passes a covered plan and alerts on shortfall or overload', () => {
|
||||
const d = dispatchWorld()
|
||||
const sim = new PowerBalanceSimulator(() => ({ u1: Array(96).fill('30.000'), u2: Array(96).fill('30.000') }))
|
||||
const ok = sim.simulate(d.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '0' })
|
||||
expect(ok.alert).toBe(false)
|
||||
const short = dispatchWorld({ alloc: ['30.000', '10.000'] })
|
||||
expect(sim.simulate(short.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '0' }).alerts[0]).toMatch(/short/)
|
||||
const over = new PowerBalanceSimulator(() => ({ u1: Array(96).fill('20.000') }))
|
||||
expect(over.simulate(d.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '0' }).alerts[0]).toMatch(/beyond available/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('envelope: dispatch bounds and deviation-streak suspension', () => {
|
||||
it('max_total_mw / max_unit_mw bound dispatch plans', () => {
|
||||
const d = dispatchWorld()
|
||||
const svc = new EnvelopeService()
|
||||
svc.register(envelope({ max_total_mw: '50.0', max_unit_mw: '35.0' }, { id: 'env-dispatch', scope: { proposal_type: 'DISPATCH_PLAN', timescales: ['DAY_AHEAD'], resource_set: 'pool' } }))
|
||||
expect(svc.match(d.proposal, { now: NOW, priceBaseline: null }).within).toBe(true)
|
||||
const tight = new EnvelopeService()
|
||||
tight.register(envelope({ max_unit_mw: '25.0' }, { id: 'env-dispatch', scope: { proposal_type: 'DISPATCH_PLAN', timescales: ['DAY_AHEAD'], resource_set: 'pool' } }))
|
||||
expect(tight.match(d.proposal, { now: NOW, priceBaseline: null }).reasons[0]).toMatch(/max_unit_mw/)
|
||||
})
|
||||
|
||||
it('N consecutive over-threshold executions suspend the envelope; a compliant day resets the streak', () => {
|
||||
const svc = new EnvelopeService()
|
||||
svc.register(envelope({ max_energy_mwh: '1500.0' })) // N=3, threshold 10%
|
||||
expect(svc.recordExecution('env-bid-001', '12.0', NOW)).toEqual({ streak: 1, suspended: false })
|
||||
expect(svc.recordExecution('env-bid-001', '5.0', NOW)).toEqual({ streak: 0, suspended: false })
|
||||
svc.recordExecution('env-bid-001', '15.0', NOW)
|
||||
svc.recordExecution('env-bid-001', '15.0', NOW)
|
||||
expect(svc.recordExecution('env-bid-001', '15.0', NOW)).toEqual({ streak: 3, suspended: true })
|
||||
expect(svc.get('env-bid-001')?.status).toBe('SUSPENDED')
|
||||
const w = world()
|
||||
expect(svc.match(w.proposal, { now: NOW, priceBaseline: null }).within).toBe(false)
|
||||
})
|
||||
|
||||
it('apply() is the only way bounds change, and it records who approved', () => {
|
||||
const svc = new EnvelopeService()
|
||||
svc.register(envelope({ price_deviation_pct: '5.0' }, { status: 'SUSPENDED' }))
|
||||
const e = svc.apply({ envelope_id: 'env-bid-001', action: 'WIDEN', bounds: { price_deviation_pct: '6.0' } }, ['user-ops-lead', 'user-risk-officer'])
|
||||
expect(e.status).toBe('ACTIVE')
|
||||
expect(e.bounds['price_deviation_pct']).toBe('6.0')
|
||||
expect(e.approval.approved_by).toEqual(['user-ops-lead', 'user-risk-officer'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('simulation gateway (dispatch release channel)', () => {
|
||||
it('accepts only permitted dispatch plans, idempotently, and can execute them with seeded fulfilment', () => {
|
||||
const d = dispatchWorld()
|
||||
const gw = new SimulationGateway(d.authority)
|
||||
const permit = d.authority.authorize(d.proposal, { approvals: [approvalFor(d.proposal, 2, { scope: { effect_type: 'DISPATCH_PLAN', limits: {} } })], envelopeMatch: null }, { now: NOW, ledger: d.ledger, currentPolicyPackVersion: PACK_VERSION, offlineResources: [], permitTtlMs: 3_600_000 })
|
||||
if (isStaleDenial(permit)) throw new Error(permit.reasons.join())
|
||||
const r1 = gw.dispatch(d.proposal, permit, NOW)
|
||||
expect(gw.dispatch(d.proposal, permit, NOW)).toEqual(r1)
|
||||
expect(r1.channel).toBe('SIMULATION_GATEWAY')
|
||||
const w = world()
|
||||
expect(() => gw.dispatch(w.proposal, permit, NOW)).toThrow(GatewayRejected)
|
||||
const reports = gw.execute(d.proposal.digest, { u1: 0.8 }, '2026-03-16T01:00:00Z')
|
||||
expect(reports.map((r) => r.fulfillment_ratio)).toEqual(['0.800', '1.000'])
|
||||
expect(reports[0]!.deviation_mwh).toBe((30 * 0.2 * 96 * 0.25).toFixed(3))
|
||||
})
|
||||
})
|
||||
|
||||
describe('review service: attribution and writebacks', () => {
|
||||
const report = (unit: string, planned: string, actual: string): ExecutionReport => ({
|
||||
id: `exec-${unit}`, market_date: DATE, unit_id: unit, dispatch_proposal_digest: 'c'.repeat(64), planned_mw: curve(planned), actual_mw: curve(actual),
|
||||
deviation_mwh: ((Number(planned) - Number(actual)) * 96 * 0.25).toFixed(3), fulfillment_ratio: (Number(actual) / Number(planned)).toFixed(3), reported_at: '2026-03-16T01:00:00Z',
|
||||
})
|
||||
|
||||
function setup(cfg = { reliabilityAlpha: '0.2', widenAfterCompliantDays: 2, widenStepPct: '20' }) {
|
||||
const resources = createResourceRegistry(() => NOW)
|
||||
resources.put({ resource_id: 'res-a', name: 'a', type: 'STORAGE', rated_power_mw: '30.0', certified_adjustable_mw: '30.0', confidence: '0.9', reliability_score: '0.900', constraints: { min_duration_min: 60, recovery_rate_mw_per_min: '0.5' }, evidence_refs: [], updated_at: NOW })
|
||||
const envelopes = new EnvelopeService()
|
||||
envelopes.register(envelope({ price_deviation_pct: '5.0' }))
|
||||
const svc = new ReviewService(resources, envelopes, undefined, cfg)
|
||||
const d = dispatchWorld()
|
||||
const input = (reports: ExecutionReport[], deviationPct: string) => ({
|
||||
market_date: DATE, ledger: d.ledger.read(), bidProposal: d.proposal.type === 'BID' ? d.proposal : world().proposal, dispatchProposal: d.proposal, executionReports: reports, metering: null, actualLoad: null,
|
||||
forecasts: { load: null, price: null }, unitResources: { u1: ['res-a'], u2: [] }, envelopeUsage: [{ envelope_id: 'env-bid-001', deviation_pct: deviationPct }], now: '2026-03-16T03:00:00Z', idGen: (p: string) => `${p}-1`,
|
||||
})
|
||||
return { resources, envelopes, svc, input }
|
||||
}
|
||||
|
||||
it('attributes deviation per timescale and writes back a measurably updated reliability score', () => {
|
||||
const { resources, svc, input } = setup()
|
||||
const [finding] = svc.review(input([report('u1', '30.000', '24.000')], '20.0')) as [ReviewFinding]
|
||||
expect(finding.kind).toBe('RESPONSE')
|
||||
expect(finding.attribution.trading_mwh).toBe('48.000') // bid 1200 vs award 1152
|
||||
expect(finding.attribution.response_mwh).toBe('144.000')
|
||||
const wb = finding.writebacks.find((w) => w.target === 'RESOURCE_PROFILE')
|
||||
expect(wb).toMatchObject({ resource_id: 'res-a', previous_reliability_score: '0.900', new_reliability_score: '0.880', fulfillment_ratio: '0.800' })
|
||||
expect(finding.writebacks.find((w) => w.target === 'SEMANTIC_MEMORY')?.target).toBe('SEMANTIC_MEMORY')
|
||||
const applied = svc.apply(finding, '2026-03-16T03:00:00Z')
|
||||
expect(resources.get('res-a')?.value.reliability_score).toBe('0.880')
|
||||
expect(applied.memory).toHaveLength(1)
|
||||
expect(svc.memory.list()).toHaveLength(1)
|
||||
expect(applied.envelopeRequests).toHaveLength(0) // deviation above threshold → no widening
|
||||
})
|
||||
|
||||
it('recommends widening after the configured run of compliant days, as a request (not applied)', () => {
|
||||
const { svc, input, envelopes } = setup()
|
||||
svc.review(input([report('u1', '30.000', '29.000')], '3.0'))
|
||||
const [f2] = svc.review(input([report('u1', '30.000', '29.000')], '3.0')) as [ReviewFinding]
|
||||
const rec = f2.writebacks.find((w) => w.target === 'ENVELOPE_RECOMMENDATION')
|
||||
expect(rec).toMatchObject({ action: 'WIDEN', bound: 'price_deviation_pct', current_value: '5.0', recommended_value: '6.00' })
|
||||
const applied = svc.apply(f2, '2026-03-16T03:00:00Z')
|
||||
expect(applied.envelopeRequests[0]).toMatchObject({ envelope_id: 'env-bid-001', action: 'WIDEN', bounds: { price_deviation_pct: '6.00' } })
|
||||
expect(envelopes.get('env-bid-001')?.bounds['price_deviation_pct']).toBe('5.0') // untouched until a human approves
|
||||
})
|
||||
})
|
||||
96
skills-py/tests/test_potential_dispatch.py
Normal file
96
skills-py/tests/test_potential_dispatch.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""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
|
||||
42
skills-py/vpp_contracts/award_notice.py
Normal file
42
skills-py/vpp_contracts/award_notice.py
Normal file
@ -0,0 +1,42 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: award_notice.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr
|
||||
|
||||
|
||||
class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]):
|
||||
root: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
|
||||
|
||||
class AwardedMwh(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class ClearingPriceYuanPerMwh(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class AwardNotice(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
id: constr(min_length=1)
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
bid_proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
|
||||
awarded_mwh: AwardedMwh
|
||||
clearing_price_yuan_per_mwh: ClearingPriceYuanPerMwh
|
||||
received_at: AwareDatetime
|
||||
48
skills-py/vpp_contracts/dispatch_optimization_request.py
Normal file
48
skills-py/vpp_contracts/dispatch_optimization_request.py
Normal file
@ -0,0 +1,48 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: dispatch_optimization_request.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel, constr
|
||||
|
||||
|
||||
class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]):
|
||||
root: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
|
||||
|
||||
class TargetMw(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class AvailableMw(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class Unit(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
unit_id: constr(min_length=1)
|
||||
available_mw: AvailableMw
|
||||
cost_weight: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
|
||||
|
||||
class DispatchOptimizationRequest(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
target_mw: TargetMw
|
||||
units: list[Unit] = Field(..., min_length=1)
|
||||
67
skills-py/vpp_contracts/dispatch_optimization_result.py
Normal file
67
skills-py/vpp_contracts/dispatch_optimization_result.py
Normal file
@ -0,0 +1,67 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: dispatch_optimization_result.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel, conint, constr
|
||||
|
||||
|
||||
class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]):
|
||||
root: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
|
||||
|
||||
class TotalTargetMw(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class TargetMw(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class Allocation(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
unit_id: constr(min_length=1)
|
||||
target_mw: TargetMw
|
||||
|
||||
|
||||
class Status(StrEnum):
|
||||
OPTIMAL = 'OPTIMAL'
|
||||
INFEASIBLE = 'INFEASIBLE'
|
||||
ERROR = 'ERROR'
|
||||
|
||||
|
||||
class Solver(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
name: constr(min_length=1)
|
||||
version: constr(min_length=1)
|
||||
status: Status
|
||||
wall_time_ms: conint(ge=0, le=9007199254740991)
|
||||
|
||||
|
||||
class DispatchOptimizationResult(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
total_target_mw: TotalTargetMw
|
||||
allocations: list[Allocation] = Field(..., min_length=1)
|
||||
shortfall_mwh: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
solver: Solver
|
||||
skill_version: constr(pattern=r'^\d+\.\d+\.\d+$')
|
||||
32
skills-py/vpp_contracts/dispatch_proposal_draft.py
Normal file
32
skills-py/vpp_contracts/dispatch_proposal_draft.py
Normal file
@ -0,0 +1,32 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: dispatch_proposal_draft.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, constr
|
||||
|
||||
|
||||
class TotalTargetRef(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
tool_call_id: constr(min_length=1)
|
||||
path: constr(min_length=1)
|
||||
|
||||
|
||||
class AllocationsRef(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
tool_call_id: constr(min_length=1)
|
||||
path: constr(min_length=1)
|
||||
|
||||
|
||||
class DispatchProposalDraft(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
total_target_ref: TotalTargetRef
|
||||
allocations_ref: AllocationsRef
|
||||
rationale: constr(min_length=1)
|
||||
28
skills-py/vpp_contracts/envelope_change_request.py
Normal file
28
skills-py/vpp_contracts/envelope_change_request.py
Normal file
@ -0,0 +1,28 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: envelope_change_request.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
|
||||
|
||||
|
||||
class Action(StrEnum):
|
||||
WIDEN = 'WIDEN'
|
||||
NARROW = 'NARROW'
|
||||
SUSPEND = 'SUSPEND'
|
||||
REACTIVATE = 'REACTIVATE'
|
||||
|
||||
|
||||
class EnvelopeChangeRequest(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
id: constr(min_length=1)
|
||||
envelope_id: constr(min_length=1)
|
||||
action: Action
|
||||
bounds: dict[str, constr(pattern=r'^-?\d+(\.\d+)?$')]
|
||||
basis: constr(min_length=1)
|
||||
source_finding_id: constr(min_length=1) | None
|
||||
requested_at: AwareDatetime
|
||||
45
skills-py/vpp_contracts/execution_report.py
Normal file
45
skills-py/vpp_contracts/execution_report.py
Normal file
@ -0,0 +1,45 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: execution_report.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr
|
||||
|
||||
|
||||
class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]):
|
||||
root: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
|
||||
|
||||
class PlannedMw(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class ActualMw(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class ExecutionReport(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
id: constr(min_length=1)
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
unit_id: constr(min_length=1)
|
||||
dispatch_proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
|
||||
planned_mw: PlannedMw
|
||||
actual_mw: ActualMw
|
||||
deviation_mwh: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
fulfillment_ratio: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
reported_at: AwareDatetime
|
||||
62
skills-py/vpp_contracts/insight_card.py
Normal file
62
skills-py/vpp_contracts/insight_card.py
Normal file
@ -0,0 +1,62 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: insight_card.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
|
||||
|
||||
|
||||
class Page(StrEnum):
|
||||
TRADING_DESK = 'TRADING_DESK'
|
||||
RESOURCE_POOL = 'RESOURCE_POOL'
|
||||
OPERATIONS_DASHBOARD = 'OPERATIONS_DASHBOARD'
|
||||
|
||||
|
||||
class Ref(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
tool_call_id: constr(min_length=1)
|
||||
path: constr(min_length=1)
|
||||
|
||||
|
||||
class Metric(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
name: constr(min_length=1)
|
||||
value: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
unit: constr(min_length=1)
|
||||
ref: Ref | None
|
||||
|
||||
|
||||
class Kind(StrEnum):
|
||||
SITUATION_REPORT = 'SITUATION_REPORT'
|
||||
REVIEW_FINDING = 'REVIEW_FINDING'
|
||||
DECISION_CASE = 'DECISION_CASE'
|
||||
POTENTIAL_ASSESSMENT = 'POTENTIAL_ASSESSMENT'
|
||||
|
||||
|
||||
class Source(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
kind: Kind
|
||||
id: constr(min_length=1)
|
||||
ref: constr(pattern=r'^[0-9a-f]{64}$') | None
|
||||
|
||||
|
||||
class InsightCard(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
id: constr(min_length=1)
|
||||
page: Page
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
title: constr(min_length=1)
|
||||
headline: constr(min_length=1)
|
||||
metrics: list[Metric]
|
||||
source: Source
|
||||
generated_at: AwareDatetime
|
||||
41
skills-py/vpp_contracts/metering_record.py
Normal file
41
skills-py/vpp_contracts/metering_record.py
Normal file
@ -0,0 +1,41 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: metering_record.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr
|
||||
|
||||
|
||||
class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]):
|
||||
root: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
|
||||
|
||||
class MeteredMw(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class ActualPriceYuanPerMwh(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class MeteringRecord(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
id: constr(min_length=1)
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
metered_mw: MeteredMw
|
||||
actual_price_yuan_per_mwh: ActualPriceYuanPerMwh
|
||||
received_at: AwareDatetime
|
||||
68
skills-py/vpp_contracts/potential_assessment_request.py
Normal file
68
skills-py/vpp_contracts/potential_assessment_request.py
Normal file
@ -0,0 +1,68 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: potential_assessment_request.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, conint, constr
|
||||
|
||||
|
||||
class Type(StrEnum):
|
||||
STORAGE = 'STORAGE'
|
||||
PV = 'PV'
|
||||
EV_CHARGER = 'EV_CHARGER'
|
||||
HVAC = 'HVAC'
|
||||
INDUSTRIAL_LOAD = 'INDUSTRIAL_LOAD'
|
||||
MICROGRID = 'MICROGRID'
|
||||
BASE_STATION = 'BASE_STATION'
|
||||
|
||||
|
||||
class Constraints(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
min_duration_min: conint(le=9007199254740991, gt=0) | None
|
||||
recovery_rate_mw_per_min: constr(pattern=r'^-?\d+(\.\d+)?$') | None
|
||||
|
||||
|
||||
class Profile(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
resource_id: constr(min_length=1)
|
||||
name: constr(min_length=1)
|
||||
type: Type
|
||||
rated_power_mw: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
certified_adjustable_mw: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
confidence: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
reliability_score: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
constraints: Constraints
|
||||
evidence_refs: list[constr(pattern=r'^[0-9a-f]{64}$')]
|
||||
updated_at: AwareDatetime
|
||||
|
||||
|
||||
class FulfillmentItem(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
planned_mwh: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
delivered_mwh: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
|
||||
|
||||
class Resource(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
profile: Profile
|
||||
fulfillment: list[FulfillmentItem]
|
||||
|
||||
|
||||
class PotentialAssessmentRequest(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
resources: list[Resource] = Field(..., min_length=1)
|
||||
features_snapshot_ref: constr(pattern=r'^[0-9a-f]{64}$')
|
||||
51
skills-py/vpp_contracts/potential_assessment_result.py
Normal file
51
skills-py/vpp_contracts/potential_assessment_result.py
Normal file
@ -0,0 +1,51 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: potential_assessment_result.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel, conint, constr
|
||||
|
||||
|
||||
class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]):
|
||||
root: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
|
||||
|
||||
class AdjustableMw(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class Assessment(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
resource_id: constr(min_length=1)
|
||||
adjustable_mw: AdjustableMw
|
||||
confidence: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
fulfillment_rate: constr(pattern=r'^-?\d+(\.\d+)?$') | None
|
||||
evidence_days: conint(ge=0, le=9007199254740991)
|
||||
|
||||
|
||||
class TotalAdjustableMw(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class PotentialAssessmentResult(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
assessments: list[Assessment]
|
||||
total_adjustable_mw: TotalAdjustableMw
|
||||
skill_version: constr(pattern=r'^\d+\.\d+\.\d+$')
|
||||
@ -128,5 +128,77 @@ class Proposal1(BaseModel):
|
||||
payload: Payload
|
||||
|
||||
|
||||
class Proposal(RootModel[Proposal1]):
|
||||
root: Proposal1 = Field(..., title='Proposal')
|
||||
class Originator1(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
agent: constr(min_length=1)
|
||||
trigger: Trigger
|
||||
task_id: constr(min_length=1)
|
||||
|
||||
|
||||
class Lineage1(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
tool_calls: list[ToolCall]
|
||||
data_refs: list[constr(pattern=r'^[0-9a-f]{64}$')]
|
||||
ledger_version: conint(ge=0, le=9007199254740991)
|
||||
policy_pack_version: constr(min_length=1)
|
||||
|
||||
|
||||
class TotalTargetMw(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class TargetMw(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
interval_minutes: Literal[15]
|
||||
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
values: list[Value] = Field(..., max_length=96, min_length=96)
|
||||
|
||||
|
||||
class Allocation(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
unit_id: constr(min_length=1)
|
||||
target_mw: TargetMw
|
||||
|
||||
|
||||
class Payload1(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
kind: Literal['DISPATCH_PLAN']
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
award_ref: constr(min_length=1)
|
||||
total_target_mw: TotalTargetMw
|
||||
allocations: list[Allocation] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class Proposal2(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
id: constr(min_length=1)
|
||||
timescale: Timescale
|
||||
originator: Originator1
|
||||
lineage: Lineage1
|
||||
envelope_ref: constr(min_length=1) | None
|
||||
digest: constr(pattern=r'^[0-9a-f]{64}$')
|
||||
status: Status
|
||||
created_at: AwareDatetime
|
||||
type: Literal['DISPATCH_PLAN']
|
||||
payload: Payload1
|
||||
|
||||
|
||||
class Proposal(RootModel[Proposal1 | Proposal2]):
|
||||
root: Proposal1 | Proposal2 = Field(..., title='Proposal')
|
||||
|
||||
91
skills-py/vpp_contracts/review_finding.py
Normal file
91
skills-py/vpp_contracts/review_finding.py
Normal file
@ -0,0 +1,91 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: review_finding.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
|
||||
|
||||
|
||||
class Kind(StrEnum):
|
||||
FORECAST = 'FORECAST'
|
||||
RESPONSE = 'RESPONSE'
|
||||
TRADING = 'TRADING'
|
||||
CONTROL = 'CONTROL'
|
||||
|
||||
|
||||
class Attribution(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
trading_mwh: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
control_mwh: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
response_mwh: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
forecast_load_mape: constr(pattern=r'^-?\d+(\.\d+)?$') | None
|
||||
forecast_price_mape: constr(pattern=r'^-?\d+(\.\d+)?$') | None
|
||||
net_deviation_mwh: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
|
||||
|
||||
class Writebacks(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
target: Literal['SEMANTIC_MEMORY']
|
||||
topic: constr(min_length=1)
|
||||
lesson: constr(min_length=1)
|
||||
|
||||
|
||||
class Writebacks1(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
target: Literal['RESOURCE_PROFILE']
|
||||
resource_id: constr(min_length=1)
|
||||
previous_reliability_score: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
new_reliability_score: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
fulfillment_ratio: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
|
||||
|
||||
class Action(StrEnum):
|
||||
WIDEN = 'WIDEN'
|
||||
NARROW = 'NARROW'
|
||||
SUSPEND = 'SUSPEND'
|
||||
KEEP = 'KEEP'
|
||||
|
||||
|
||||
class Writebacks2(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
target: Literal['ENVELOPE_RECOMMENDATION']
|
||||
envelope_id: constr(min_length=1)
|
||||
action: Action
|
||||
bound: constr(min_length=1)
|
||||
current_value: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
recommended_value: constr(pattern=r'^-?\d+(\.\d+)?$')
|
||||
basis: constr(min_length=1)
|
||||
|
||||
|
||||
class LineageRef(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
tool_call_id: constr(min_length=1)
|
||||
path: constr(min_length=1)
|
||||
|
||||
|
||||
class ReviewFinding(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
id: constr(min_length=1)
|
||||
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
|
||||
kind: Kind
|
||||
summary: constr(min_length=1)
|
||||
attribution: Attribution
|
||||
writebacks: list[Writebacks | Writebacks1 | Writebacks2]
|
||||
evidence_refs: list[constr(pattern=r'^[0-9a-f]{64}$')]
|
||||
lineage_refs: list[LineageRef]
|
||||
generated_at: AwareDatetime
|
||||
18
skills-py/vpp_contracts/semantic_memory_entry.py
Normal file
18
skills-py/vpp_contracts/semantic_memory_entry.py
Normal file
@ -0,0 +1,18 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: semantic_memory_entry.json
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
|
||||
|
||||
|
||||
class SemanticMemoryEntry(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
)
|
||||
id: constr(min_length=1)
|
||||
topic: constr(min_length=1)
|
||||
lesson: constr(min_length=1)
|
||||
source_finding_id: constr(min_length=1)
|
||||
evidence_refs: list[constr(pattern=r'^[0-9a-f]{64}$')]
|
||||
created_at: AwareDatetime
|
||||
@ -12,4 +12,6 @@ SKILL_VERSIONS: dict[str, str] = {
|
||||
"price-forecast": "1.0.0",
|
||||
"bid-optimization-milp": "1.0.0",
|
||||
"report-generator": "1.0.0",
|
||||
"potential-assessment": "1.0.0",
|
||||
"dispatch-optimization": "1.0.0",
|
||||
}
|
||||
|
||||
@ -13,6 +13,10 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from vpp_contracts.bid_optimization_request import BidOptimizationRequest
|
||||
from vpp_contracts.bid_optimization_result import BidOptimizationResult
|
||||
from vpp_contracts.dispatch_optimization_request import DispatchOptimizationRequest
|
||||
from vpp_contracts.dispatch_optimization_result import DispatchOptimizationResult
|
||||
from vpp_contracts.potential_assessment_request import PotentialAssessmentRequest
|
||||
from vpp_contracts.potential_assessment_result import PotentialAssessmentResult
|
||||
from vpp_contracts.forecast_bundle import ForecastBundle
|
||||
from vpp_contracts.forecast_request import ForecastRequest
|
||||
from vpp_contracts.report_request import ReportRequest
|
||||
@ -20,6 +24,8 @@ from vpp_contracts.skill_report import SkillReport
|
||||
|
||||
from . import SKILL_VERSIONS
|
||||
from .bid_milp import optimize_bid
|
||||
from .dispatch_opt import optimize_dispatch
|
||||
from .potential import assess_potential
|
||||
from .forecast import forecast
|
||||
from .report import generate_report
|
||||
|
||||
@ -31,6 +37,8 @@ ROUTES = {
|
||||
"price-forecast": "/v1/forecast/price",
|
||||
"bid-optimization-milp": "/v1/optimize/bid",
|
||||
"report-generator": "/v1/report",
|
||||
"potential-assessment": "/v1/assess/potential",
|
||||
"dispatch-optimization": "/v1/optimize/dispatch",
|
||||
}
|
||||
|
||||
|
||||
@ -79,3 +87,13 @@ def optimize(req: BidOptimizationRequest) -> BidOptimizationResult:
|
||||
@app.post(ROUTES["report-generator"], response_model=SkillReport)
|
||||
def report(req: ReportRequest) -> SkillReport:
|
||||
return generate_report(req)
|
||||
|
||||
|
||||
@app.post(ROUTES["potential-assessment"], response_model=PotentialAssessmentResult)
|
||||
def potential(req: PotentialAssessmentRequest) -> PotentialAssessmentResult:
|
||||
return assess_potential(req)
|
||||
|
||||
|
||||
@app.post(ROUTES["dispatch-optimization"], response_model=DispatchOptimizationResult)
|
||||
def dispatch(req: DispatchOptimizationRequest) -> DispatchOptimizationResult:
|
||||
return optimize_dispatch(req)
|
||||
|
||||
88
skills-py/vpp_skills/dispatch_opt.py
Normal file
88
skills-py/vpp_skills/dispatch_opt.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""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],
|
||||
}
|
||||
)
|
||||
73
skills-py/vpp_skills/potential.py
Normal file
73
skills-py/vpp_skills/potential.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""Adjustable-potential assessment skill v1 (docs/05 §2.2 可调潜力辨识).
|
||||
|
||||
For each resource: adjustable capacity = certified capacity × rolling
|
||||
fulfillment rate (delivered / planned over the evidence window), with
|
||||
confidence from the profile's own confidence, the reliability score and the
|
||||
amount of evidence. A resource with no execution history keeps its certified
|
||||
figure at the profile's confidence — the "画像时效性" risk (docs/13 §0) is
|
||||
made visible through `evidence_days` rather than hidden.
|
||||
|
||||
The de-rating exponent and evidence saturation are placeholders: OPEN-QUESTION
|
||||
B10 (可靠性/潜力辨识口径). The target accuracy (≥ 90%) is judged by the eval
|
||||
harness on real execution feedback (docs/12 L2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vpp_contracts.potential_assessment_request import PotentialAssessmentRequest
|
||||
from vpp_contracts.potential_assessment_result import PotentialAssessmentResult
|
||||
|
||||
from . import SKILL_VERSIONS
|
||||
from .numeric import INTERVALS, SCALE_MW, curve, quantize
|
||||
|
||||
SKILL = "potential-assessment"
|
||||
|
||||
EVIDENCE_WINDOW_DAYS = 14 # OPEN-QUESTION B10
|
||||
EVIDENCE_SATURATION_DAYS = 7 # OPEN-QUESTION B10: confidence saturates after this many days
|
||||
|
||||
|
||||
def assess_potential(req: PotentialAssessmentRequest) -> PotentialAssessmentResult:
|
||||
assessments = []
|
||||
total = np.zeros(INTERVALS)
|
||||
for r in req.resources:
|
||||
profile = r.profile
|
||||
certified = float(Decimal(profile.certified_adjustable_mw))
|
||||
recent = sorted(r.fulfillment, key=lambda f: f.market_date)[-EVIDENCE_WINDOW_DAYS:]
|
||||
planned = sum(Decimal(f.planned_mwh) for f in recent)
|
||||
delivered = sum(Decimal(f.delivered_mwh) for f in recent)
|
||||
if planned > 0:
|
||||
rate = min(Decimal(1), delivered / planned)
|
||||
evidence_days = len(recent)
|
||||
else:
|
||||
rate = None
|
||||
evidence_days = 0
|
||||
|
||||
derate = float(rate) if rate is not None else 1.0
|
||||
adjustable = np.full(INTERVALS, certified * derate)
|
||||
evidence_factor = min(1.0, evidence_days / EVIDENCE_SATURATION_DAYS)
|
||||
base_conf = float(Decimal(profile.confidence))
|
||||
reliability = float(Decimal(profile.reliability_score))
|
||||
confidence = base_conf * (0.5 + 0.5 * evidence_factor) * (0.5 + 0.5 * reliability)
|
||||
|
||||
total += adjustable
|
||||
assessments.append(
|
||||
{
|
||||
"resource_id": profile.resource_id,
|
||||
"adjustable_mw": curve(adjustable, req.market_date, SCALE_MW),
|
||||
"confidence": quantize(confidence, 3),
|
||||
"fulfillment_rate": quantize(rate, 4) if rate is not None else None,
|
||||
"evidence_days": evidence_days,
|
||||
}
|
||||
)
|
||||
return PotentialAssessmentResult.model_validate(
|
||||
{
|
||||
"market_date": req.market_date,
|
||||
"assessments": assessments,
|
||||
"total_adjustable_mw": curve(total, req.market_date, SCALE_MW),
|
||||
"skill_version": SKILL_VERSIONS[SKILL],
|
||||
}
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user