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
|
## Current phase
|
||||||
|
|
||||||
Docs complete; M1–M3 implemented: schemas + contracts pipeline; deterministic
|
Docs complete; M1–M4 implemented: schemas + contracts pipeline; deterministic
|
||||||
services (ledger, stores, policy engine, envelope, authority/permits, gateway,
|
services (ledger, stores, policy engine incl. dispatch rules, envelope with
|
||||||
events, lineage assembler, simulation, Case Desk); Python skill service; L2 eval
|
deviation-streak suspension, authority/permits, file-export + simulation
|
||||||
harness with committed baseline (synthetic data); Mastra runtime with the
|
gateways, events, lineage assemblers, revenue + power-balance simulation,
|
||||||
proposal-lifecycle safety chain (durable suspend/resume), situation and bid
|
review service, Case Desk); Python skill service (forecasts, bid MILP, report,
|
||||||
workflows, trigger service, LLM port with LLM-down mode, Case Desk API. Storage
|
potential assessment, dispatch optimization); L2 eval harness with committed
|
||||||
is file-backed reference semantics — Postgres/Timescale adapters later. Next is
|
baseline (synthetic data); Mastra runtime with the proposal-lifecycle safety
|
||||||
M4. Build order is ROADMAP.md (M1→M5). A skill change that moves an L2 metric
|
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`).
|
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
|
Workflows are per-runtime factories (`createProposalLifecycle(ctx)` etc.) — never
|
||||||
module singletons, or a second Mastra instance steals their context.
|
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
|
execution permit) governs everything before any external effect. **The LLM never
|
||||||
computes numbers and never touches the second-level control loop.**
|
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:
|
follows [ROADMAP.md](ROADMAP.md). Present today:
|
||||||
|
|
||||||
- `packages/domain` — zod schemas for every business and safety-chain object, exported to
|
- `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/evals` — L2 eval harness with a committed baseline (on a **synthetic** dataset).
|
||||||
- `packages/runtime` — Mastra runtime: `proposal-lifecycle` (the safety chain, suspend/resume
|
- `packages/runtime` — Mastra runtime: `proposal-lifecycle` (the safety chain, suspend/resume
|
||||||
for human approval, durable across restarts), `day-ahead-situation`, `day-ahead-bid`;
|
for human approval, durable across restarts), `day-ahead-situation`, `day-ahead-bid`;
|
||||||
trigger service (scheduled / event / manual via router agent); provider-abstracted LLM
|
`award-decomposition` (resource agent: potential assessment → dispatch optimization →
|
||||||
port with an LLM-down mode; Case Desk HTTP API. Bid release is a file export for manual
|
DISPATCH_PLAN through the same chain to a simulation gateway), `review` (D+1
|
||||||
upload (degraded channel by design).
|
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`,
|
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.
|
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
|
## 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"
|
"payload"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"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",
|
"$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` |
|
| B7 | 极端日协议触发的气象条件 | `abnormal_day.triggers` |
|
||||||
| B8 | 各级熔断(L0–L4)授权岗位与恢复条件 | `breaker.authority` |
|
| B8 | 各级熔断(L0–L4)授权岗位与恢复条件 | `breaker.authority` |
|
||||||
| B9 | 演习样本制度:频率与考核挂钩方式 | — (制度非配置) |
|
| B9 | 演习样本制度:频率与考核挂钩方式 | — (制度非配置) |
|
||||||
|
| B10 | 可靠性评分与可调潜力辨识口径:评分更新权重(EWMA α)、证据窗口与饱和天数、包络放宽所需连续达标天数与放宽步长 | `review.reliability_alpha` / `potential.evidence_window` / `envelope.widen_*` |
|
||||||
|
|
||||||
## C. KPI 口径(owner: 运营 + 验收双方 · blocks M5 dashboard)
|
## C. KPI 口径(owner: 运营 + 验收双方 · blocks M5 dashboard)
|
||||||
|
|
||||||
|
|||||||
@ -57,6 +57,31 @@ const valid: Record<string, Record<string, unknown>> = {
|
|||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
created_at: T1,
|
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: {
|
approval: {
|
||||||
approve: {
|
approve: {
|
||||||
@ -343,6 +368,162 @@ const valid: Record<string, Record<string, unknown>> = {
|
|||||||
exported_at: T1,
|
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: {
|
situation_report: {
|
||||||
'normal-day': {
|
'normal-day': {
|
||||||
id: 'sit-001',
|
id: 'sit-001',
|
||||||
@ -442,6 +623,14 @@ const invalid: Record<string, Record<string, unknown>> = {
|
|||||||
// a workflow id that is not a registered template
|
// a workflow id that is not a registered template
|
||||||
'unknown-template': { workflow_id: 'transfer-funds', params: { market_date: DATE }, confidence: 'HIGH' },
|
'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: {
|
position_update: {
|
||||||
// missing optimistic-concurrency field
|
// missing optimistic-concurrency field
|
||||||
'missing-version': (() => {
|
'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,
|
StaleDenial,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
} from './chain.js'
|
} 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 './common.js'
|
||||||
export * from './proposal.js'
|
export * from './proposal.js'
|
||||||
@ -38,6 +49,8 @@ export * from './case.js'
|
|||||||
export * from './event.js'
|
export * from './event.js'
|
||||||
export * from './skill.js'
|
export * from './skill.js'
|
||||||
export * from './chain.js'
|
export * from './chain.js'
|
||||||
|
export * from './dispatch.js'
|
||||||
|
export * from './review.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry driving the contracts pipeline: keys become schema/fixture/module
|
* 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,
|
bid_proposal_draft: BidProposalDraft,
|
||||||
router_decision: RouterDecision,
|
router_decision: RouterDecision,
|
||||||
bid_export_file: BidExportFile,
|
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>
|
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({
|
const ProposalBase = z.object({
|
||||||
id: Id,
|
id: Id,
|
||||||
timescale: Timescale,
|
timescale: Timescale,
|
||||||
@ -70,10 +86,11 @@ const ProposalBase = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M1 carries the BID variant only; INVITATION / CONTROL_PLAN / DISPATCH_PLAN
|
* BID (M1) and DISPATCH_PLAN (M4). INVITATION / CONTROL_PLAN payloads are
|
||||||
* payloads arrive with their milestones (ROADMAP M4 / phase 2).
|
* phase 2 (interaction and load-control agents).
|
||||||
*/
|
*/
|
||||||
export const Proposal = z.discriminatedUnion('type', [
|
export const Proposal = z.discriminatedUnion('type', [
|
||||||
ProposalBase.extend({ type: z.literal('BID'), payload: BidPayload }),
|
ProposalBase.extend({ type: z.literal('BID'), payload: BidPayload }),
|
||||||
|
ProposalBase.extend({ type: z.literal('DISPATCH_PLAN'), payload: DispatchPlanPayload }),
|
||||||
])
|
])
|
||||||
export type Proposal = z.infer<typeof Proposal>
|
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> {
|
async report(_req: ReportRequest): Promise<SkillReport> {
|
||||||
throw new Error('not used')
|
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 }
|
const cfg = { ...DEFAULT_CONFIG, window: 7, holdoutFrom: 10, holdoutDays: 5 }
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { InsightCard } from '@vpp/domain'
|
||||||
|
import { insightCards } from './cards.js'
|
||||||
import { LlmUnavailable } from './llm.js'
|
import { LlmUnavailable } from './llm.js'
|
||||||
import type { Runtime } from './runtime.js'
|
import type { Runtime } from './runtime.js'
|
||||||
import type { TriggerService } from './trigger.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)
|
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 })
|
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') {
|
if (req.method === 'POST' && url.pathname === '/tasks') {
|
||||||
const body = TaskBody.parse(await readBody(req))
|
const body = TaskBody.parse(await readBody(req))
|
||||||
return json(res, 200, await triggers.manual(body.message))
|
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,
|
EnvelopeService,
|
||||||
EventBus,
|
EventBus,
|
||||||
FileExportGateway,
|
FileExportGateway,
|
||||||
|
GatewayPort,
|
||||||
LedgerService,
|
LedgerService,
|
||||||
LineageRecorder,
|
LineageRecorder,
|
||||||
PolicyEngine,
|
PolicyEngine,
|
||||||
|
Repository,
|
||||||
ResourceRegistry,
|
ResourceRegistry,
|
||||||
|
ReviewService,
|
||||||
|
SimulationGateway,
|
||||||
SimulationPort,
|
SimulationPort,
|
||||||
SkillClient,
|
SkillClient,
|
||||||
SnapshotStore,
|
SnapshotStore,
|
||||||
TimeSeriesStore,
|
TimeSeriesStore,
|
||||||
} from '@vpp/services'
|
} from '@vpp/services'
|
||||||
|
import type { AwardNotice, EnvelopeChangeRequest, ExecutionReport, MeteringRecord, Proposal, ReviewFinding } from '@vpp/domain'
|
||||||
import type { LlmPort } from './llm.js'
|
import type { LlmPort } from './llm.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -38,6 +43,12 @@ export interface RuntimeConfig {
|
|||||||
extremeDayPriceRatio: string
|
extremeDayPriceRatio: string
|
||||||
/** Bid deadline as HH:MM Asia/Shanghai on D-1 (OPEN-QUESTION A1); used for case deadlines. */
|
/** Bid deadline as HH:MM Asia/Shanghai on D-1 (OPEN-QUESTION A1); used for case deadlines. */
|
||||||
bidDeadlineLocal: string
|
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 {
|
export interface RuntimeContext {
|
||||||
@ -53,7 +64,16 @@ export interface RuntimeContext {
|
|||||||
envelopes: EnvelopeService
|
envelopes: EnvelopeService
|
||||||
authority: AuthorityService
|
authority: AuthorityService
|
||||||
gateway: FileExportGateway
|
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
|
caseDesk: CaseDeskService
|
||||||
skills: SkillClient
|
skills: SkillClient
|
||||||
lineage: LineageRecorder
|
lineage: LineageRecorder
|
||||||
|
|||||||
@ -7,3 +7,7 @@ export * from './api.js'
|
|||||||
export * from './workflows/proposal-lifecycle.js'
|
export * from './workflows/proposal-lifecycle.js'
|
||||||
export * from './workflows/day-ahead-situation.js'
|
export * from './workflows/day-ahead-situation.js'
|
||||||
export * from './workflows/day-ahead-bid.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[] = [
|
export const AGENT_SPECS: AgentSpec[] = [
|
||||||
{
|
{
|
||||||
id: 'router-agent',
|
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), ' +
|
'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.',
|
'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',
|
id: 'trading-agent',
|
||||||
name: 'Trading',
|
name: 'Trading',
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { join } from 'node:path'
|
|||||||
import { Mastra } from '@mastra/core'
|
import { Mastra } from '@mastra/core'
|
||||||
import type { MastraModelConfig } from '@mastra/core/llm'
|
import type { MastraModelConfig } from '@mastra/core/llm'
|
||||||
import { LibSQLStore } from '@mastra/libsql'
|
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 {
|
import {
|
||||||
AuthorityService,
|
AuthorityService,
|
||||||
CaseDeskService,
|
CaseDeskService,
|
||||||
@ -18,14 +18,20 @@ import {
|
|||||||
MemoryTimeSeriesStore,
|
MemoryTimeSeriesStore,
|
||||||
PendingApprovalSchema,
|
PendingApprovalSchema,
|
||||||
PolicyEngine,
|
PolicyEngine,
|
||||||
|
PowerBalanceSimulator,
|
||||||
RevenueScenarioSimulator,
|
RevenueScenarioSimulator,
|
||||||
|
ReviewService,
|
||||||
|
SimulationGateway,
|
||||||
hubeiSpotBiddingPack,
|
hubeiSpotBiddingPack,
|
||||||
} from '@vpp/services'
|
} from '@vpp/services'
|
||||||
import type { SkillClient } from '@vpp/services'
|
import type { SkillClient } from '@vpp/services'
|
||||||
import type { RuntimeConfig, RuntimeContext } from './context.js'
|
import type { RuntimeConfig, RuntimeContext } from './context.js'
|
||||||
import { AGENT_SPECS, MastraLlm, NullLlm } from './llm.js'
|
import { AGENT_SPECS, MastraLlm, NullLlm } from './llm.js'
|
||||||
import type { LlmPort } 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 { 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 { createDayAheadSituation } from './workflows/day-ahead-situation.js'
|
||||||
import { createProposalLifecycle } from './workflows/proposal-lifecycle.js'
|
import { createProposalLifecycle } from './workflows/proposal-lifecycle.js'
|
||||||
|
|
||||||
@ -55,6 +61,10 @@ export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
|
|||||||
forecastWindowDays: 28,
|
forecastWindowDays: 28,
|
||||||
extremeDayPriceRatio: '2.0', // OPEN-QUESTION B7
|
extremeDayPriceRatio: '2.0', // OPEN-QUESTION B7
|
||||||
bidDeadlineLocal: '09:00', // OPEN-QUESTION A1
|
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 {
|
export interface Runtime {
|
||||||
@ -89,6 +99,24 @@ export async function createRuntime(opts: RuntimeOptions): Promise<Runtime> {
|
|||||||
clock,
|
clock,
|
||||||
})
|
})
|
||||||
const gateway = new FileExportGateway(authority, dir('exports'), new FsRepository(dir('receipts.json'), ExecutionReceipt, (r) => r.idempotency_key, 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 llm: LlmPort
|
||||||
let agents: Record<string, never> | ReturnType<MastraLlm['mastraAgents']> = {}
|
let agents: Record<string, never> | ReturnType<MastraLlm['mastraAgents']> = {}
|
||||||
@ -106,14 +134,22 @@ export async function createRuntime(opts: RuntimeOptions): Promise<Runtime> {
|
|||||||
newId,
|
newId,
|
||||||
snapshots,
|
snapshots,
|
||||||
timeseries: opts.timeseries ?? new MemoryTimeSeriesStore(clock),
|
timeseries: opts.timeseries ?? new MemoryTimeSeriesStore(clock),
|
||||||
resources: new FsRepository(dir('resources.json'), ResourceProfile, (r) => r.resource_id, clock),
|
resources,
|
||||||
ledger,
|
ledger,
|
||||||
events,
|
events,
|
||||||
policy,
|
policy,
|
||||||
envelopes,
|
envelopes,
|
||||||
authority,
|
authority,
|
||||||
gateway,
|
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,
|
caseDesk,
|
||||||
skills: typeof opts.skills === 'string' ? new HttpSkillClient(opts.skills) : opts.skills,
|
skills: typeof opts.skills === 'string' ? new HttpSkillClient(opts.skills) : opts.skills,
|
||||||
lineage: new LineageRecorder(),
|
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;
|
// Workflows are built per runtime so their steps close over *this* context;
|
||||||
// module-level singletons would bind to whichever Mastra registered last.
|
// module-level singletons would bind to whichever Mastra registered last.
|
||||||
const proposalLifecycle = createProposalLifecycle(ctx)
|
const proposalLifecycle = createProposalLifecycle(ctx)
|
||||||
|
const envelopeReview = createEnvelopeReview(ctx)
|
||||||
const mastra = new Mastra({
|
const mastra = new Mastra({
|
||||||
storage,
|
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,
|
agents,
|
||||||
})
|
})
|
||||||
return { mastra, ctx, close: () => storage.close() }
|
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 type { EventEnvelope } from '@vpp/domain'
|
||||||
|
import { ENVELOPE_REVIEW_STEP_ID } from './workflows/envelope-review.js'
|
||||||
import { LlmUnavailable } from './llm.js'
|
import { LlmUnavailable } from './llm.js'
|
||||||
import type { Runtime } from './runtime.js'
|
import type { Runtime } from './runtime.js'
|
||||||
import { ENVELOPE_GATE_STEP_ID } from './workflows/proposal-lifecycle.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 ------------------------------------------------------------
|
// ---- manual ------------------------------------------------------------
|
||||||
|
|
||||||
async route(message: string): Promise<RouterDecision> {
|
async route(message: string): Promise<RouterDecision> {
|
||||||
@ -126,6 +159,12 @@ export class TriggerService {
|
|||||||
// ---- approval = resume -------------------------------------------------
|
// ---- approval = resume -------------------------------------------------
|
||||||
|
|
||||||
async decide(runId: string, decision: ResumeDecision) {
|
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 wf = this.rt.mastra.getWorkflow('proposalLifecycle')
|
||||||
const run = await wf.createRun({ runId })
|
const run = await wf.createRun({ runId })
|
||||||
return run.resume({ step: ENVELOPE_GATE_STEP_ID, resumeData: decision })
|
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 }) => {
|
execute: async ({ inputData }) => {
|
||||||
if (inputData.done) return inputData
|
if (inputData.done) return inputData
|
||||||
const p = transition(ctx, inputData.case_id, inputData.proposal, 'SIMULATION')
|
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(),
|
now: ctx.clock(),
|
||||||
priceForecast: priceForecastFromLineage(ctx, p),
|
priceForecast: priceForecastFromLineage(ctx, p),
|
||||||
marginalCostYuanPerMwh: ctx.config.risk.marginal_cost_yuan_per_mwh,
|
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
|
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')
|
if (!permit) throw new Error('release reached without a permit — invariant I4 violated in workflow wiring')
|
||||||
try {
|
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 })
|
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 })
|
ctx.events.append({ event_type: 'ExecutionReceipt', payload: receipt, correlation_id: inputData.case_id, causation_id: p.id })
|
||||||
ctx.ledger.append({
|
if (p.type === 'BID') {
|
||||||
id: ctx.newId('pos'),
|
ctx.ledger.append({
|
||||||
timescale: 'DAY_AHEAD',
|
id: ctx.newId('pos'),
|
||||||
period: p.type === 'BID' ? p.payload.market_date : now.slice(0, 10),
|
timescale: 'DAY_AHEAD',
|
||||||
kind: 'BID_SUBMITTED',
|
period: p.payload.market_date,
|
||||||
energy_mwh: p.type === 'BID' ? p.payload.quantities_mwh.values.reduce((s, v) => (Number(s) + Number(v)).toFixed(3), '0') : '0',
|
kind: 'BID_SUBMITTED',
|
||||||
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'),
|
||||||
source_ref: p.digest,
|
curve: p.payload.quantities_mwh,
|
||||||
expected_version: ctx.ledger.read().version,
|
source_ref: p.digest,
|
||||||
})
|
expected_version: ctx.ledger.read().version,
|
||||||
|
})
|
||||||
|
}
|
||||||
ctx.caseDesk.close(inputData.case_id, receipt.receipt_id, true)
|
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: [] }
|
return { outcome: 'RELEASED' as const, proposal: p, permit_id: permit.id, receipt_id: receipt.receipt_id, reasons: [] }
|
||||||
} catch (e) {
|
} 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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
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 { MemoryTimeSeriesStore } from '@vpp/services'
|
||||||
import type { SkillClient } from '@vpp/services'
|
import type { SkillClient } from '@vpp/services'
|
||||||
import { createRuntime } from '../src/runtime.js'
|
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',
|
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) {
|
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
|
required_level: string
|
||||||
reasons: string[]
|
reasons: string[]
|
||||||
since: string
|
since: string
|
||||||
|
/** Which workflow's run to resume: 'proposalLifecycle' (default) or 'envelopeReview'. */
|
||||||
|
workflow?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CaseView {
|
export interface CaseView {
|
||||||
@ -177,4 +179,5 @@ export const PendingApprovalSchema = z.object({
|
|||||||
required_level: z.string().min(1),
|
required_level: z.string().min(1),
|
||||||
reasons: z.array(z.string()),
|
reasons: z.array(z.string()),
|
||||||
since: z.string().min(1),
|
since: z.string().min(1),
|
||||||
|
workflow: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -27,6 +27,48 @@ export class EnvelopeService {
|
|||||||
this.repo.put(envelope)
|
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 {
|
match(proposal: Proposal, ctx: EnvelopeContext): EnvelopeMatch {
|
||||||
const fallback = ctx.fallbackLevel ?? 'L2'
|
const fallback = ctx.fallbackLevel ?? 'L2'
|
||||||
const candidates = this.repo
|
const candidates = this.repo
|
||||||
@ -70,6 +112,7 @@ export class EnvelopeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private checkBounds(proposal: Proposal, env: Envelope, ctx: EnvelopeContext): string[] {
|
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']
|
if (proposal.type !== 'BID') return ['unsupported proposal type for envelope v1']
|
||||||
const reasons: string[] = []
|
const reasons: string[] = []
|
||||||
const maxEnergy = env.bounds['max_energy_mwh']
|
const maxEnergy = env.bounds['max_energy_mwh']
|
||||||
@ -104,4 +147,27 @@ export class EnvelopeService {
|
|||||||
}
|
}
|
||||||
return reasons
|
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 { mkdirSync, writeFileSync, existsSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
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 { BidExportFile as BidExportFileSchema, ExecutionReceipt as ReceiptSchema } from '@vpp/domain'
|
||||||
import type { AuthorityService } from './authority.js'
|
import type { AuthorityService } from './authority.js'
|
||||||
import { MemoryRepository } from './relational.js'
|
import { MemoryRepository } from './relational.js'
|
||||||
@ -73,3 +73,63 @@ export class FileExportGateway implements GatewayPort {
|
|||||||
return receipt
|
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 './simulation.js'
|
||||||
export * from './casedesk.js'
|
export * from './casedesk.js'
|
||||||
export * from './skillclient.js'
|
export * from './skillclient.js'
|
||||||
|
export * from './review.js'
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||||
import type { BidPayload, BidProposalDraft, LineageRef, Proposal, ToolCallRef } from '@vpp/domain'
|
import type { BidPayload, BidProposalDraft, DispatchPlanPayload, DispatchProposalDraft, LineageRef, Proposal, ToolCallRef } from '@vpp/domain'
|
||||||
import { BidPayload as BidPayloadSchema, Curve96, DecimalString } from '@vpp/domain'
|
import { BidPayload as BidPayloadSchema, Curve96, DecimalString, DispatchPlanPayload as DispatchPlanPayloadSchema } from '@vpp/domain'
|
||||||
import { proposalDigest } from './digest.js'
|
import { proposalDigest } from './digest.js'
|
||||||
import type { SnapshotStore } from './snapshot.js'
|
import type { SnapshotStore } from './snapshot.js'
|
||||||
|
|
||||||
@ -118,3 +118,38 @@ export function assembleBidProposal(
|
|||||||
}
|
}
|
||||||
return { ...base, digest: proposalDigest(base) }
|
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 type { LedgerView, Proposal, ValidationResult } from '@vpp/domain'
|
||||||
import { digestMatches } from './digest.js'
|
import { digestMatches } from './digest.js'
|
||||||
|
import { canonicalJson } from './snapshot.js'
|
||||||
import type { LedgerService } from './ledger.js'
|
import type { LedgerService } from './ledger.js'
|
||||||
import type { SnapshotStore } from './snapshot.js'
|
import type { SnapshotStore } from './snapshot.js'
|
||||||
import { Decimal } from './decimal.js'
|
import { Decimal } from './decimal.js'
|
||||||
@ -89,12 +90,15 @@ export interface HubeiBidPackConfig {
|
|||||||
priceCapYuanPerMwh: string
|
priceCapYuanPerMwh: string
|
||||||
/** Agents permitted to originate each proposal type (I2: operate ≠ approve ≠ administer). */
|
/** Agents permitted to originate each proposal type (I2: operate ≠ approve ≠ administer). */
|
||||||
originators: Record<string, string[]>
|
originators: Record<string, string[]>
|
||||||
|
dispatchBalanceToleranceMw: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HUBEI_BID_PACK_PLACEHOLDER: HubeiBidPackConfig = {
|
export const HUBEI_BID_PACK_PLACEHOLDER: HubeiBidPackConfig = {
|
||||||
priceFloorYuanPerMwh: '0', // OPEN-QUESTION D5
|
priceFloorYuanPerMwh: '0', // OPEN-QUESTION D5
|
||||||
priceCapYuanPerMwh: '1500', // 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 })
|
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)',
|
description: 'every payload number is found verbatim in a referenced tool output (P2, machine-checkable)',
|
||||||
source_refs: ['docs/03 §1', 'docs/01 P2'],
|
source_refs: ['docs/03 §1', 'docs/01 P2'],
|
||||||
check: (p, ctx) => {
|
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.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>[] = []
|
const outputs: Record<string, unknown>[] = []
|
||||||
for (const tc of p.lineage.tool_calls) {
|
for (const tc of p.lineage.tool_calls) {
|
||||||
const out = ctx.snapshots.get(tc.outputs_ref)
|
const out = ctx.snapshots.get(tc.outputs_ref)
|
||||||
@ -196,6 +208,33 @@ export function hubeiSpotBiddingPack(version: string, cfg: HubeiBidPackConfig):
|
|||||||
return out
|
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',
|
id: 'originator-permission',
|
||||||
description: 'originating agent is allowed to originate this proposal type (I2)',
|
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 {
|
import {
|
||||||
BidOptimizationRequest,
|
BidOptimizationRequest,
|
||||||
BidOptimizationResult,
|
BidOptimizationResult,
|
||||||
|
DispatchOptimizationRequest,
|
||||||
|
DispatchOptimizationResult,
|
||||||
ForecastBundle,
|
ForecastBundle,
|
||||||
ForecastRequest,
|
ForecastRequest,
|
||||||
|
PotentialAssessmentRequest,
|
||||||
|
PotentialAssessmentResult,
|
||||||
ReportRequest,
|
ReportRequest,
|
||||||
SkillReport,
|
SkillReport,
|
||||||
} from '@vpp/domain'
|
} from '@vpp/domain'
|
||||||
@ -19,6 +23,8 @@ export interface SkillClient {
|
|||||||
forecast(kind: ForecastKind, req: ForecastRequest): Promise<ForecastBundle>
|
forecast(kind: ForecastKind, req: ForecastRequest): Promise<ForecastBundle>
|
||||||
optimizeBid(req: BidOptimizationRequest): Promise<BidOptimizationResult>
|
optimizeBid(req: BidOptimizationRequest): Promise<BidOptimizationResult>
|
||||||
report(req: ReportRequest): Promise<SkillReport>
|
report(req: ReportRequest): Promise<SkillReport>
|
||||||
|
assessPotential(req: PotentialAssessmentRequest): Promise<PotentialAssessmentResult>
|
||||||
|
optimizeDispatch(req: DispatchOptimizationRequest): Promise<DispatchOptimizationResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SkillHttpError extends Error {
|
export class SkillHttpError extends Error {
|
||||||
@ -72,4 +78,12 @@ export class HttpSkillClient implements SkillClient {
|
|||||||
report(req: ReportRequest) {
|
report(req: ReportRequest) {
|
||||||
return this.post('/v1/report', ReportRequest.parse(req), (x) => SkillReport.parse(x))
|
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 { join } from 'node:path'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { BidProposalDraft, Proposal as ProposalSchema } from '@vpp/domain'
|
import { BidProposalDraft, Proposal as ProposalSchema } from '@vpp/domain'
|
||||||
|
import type { Proposal } from '@vpp/domain'
|
||||||
import { AuthorityService, assertApproverIndependent, isStaleDenial } from '../src/authority.js'
|
import { AuthorityService, assertApproverIndependent, isStaleDenial } from '../src/authority.js'
|
||||||
import { CaseDeskService } from '../src/casedesk.js'
|
import { CaseDeskService } from '../src/casedesk.js'
|
||||||
import { proposalDigest } from '../src/digest.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', () => {
|
it('digest-valid: a tampered payload no longer matches its digest', () => {
|
||||||
const w = world()
|
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())
|
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')
|
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)', () => {
|
it('lineage-integrity: a number not present in any referenced tool output is rejected (P2)', () => {
|
||||||
const w = world()
|
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
|
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())
|
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')
|
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 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' })
|
forecastTool({ kind: 'PRICE' })
|
||||||
const { call } = milp({ market_date: DATE })
|
const { call } = milp({ market_date: DATE })
|
||||||
return assembleBidProposal(
|
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
|
payload: Payload
|
||||||
|
|
||||||
|
|
||||||
class Proposal(RootModel[Proposal1]):
|
class Originator1(BaseModel):
|
||||||
root: Proposal1 = Field(..., title='Proposal')
|
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",
|
"price-forecast": "1.0.0",
|
||||||
"bid-optimization-milp": "1.0.0",
|
"bid-optimization-milp": "1.0.0",
|
||||||
"report-generator": "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_request import BidOptimizationRequest
|
||||||
from vpp_contracts.bid_optimization_result import BidOptimizationResult
|
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_bundle import ForecastBundle
|
||||||
from vpp_contracts.forecast_request import ForecastRequest
|
from vpp_contracts.forecast_request import ForecastRequest
|
||||||
from vpp_contracts.report_request import ReportRequest
|
from vpp_contracts.report_request import ReportRequest
|
||||||
@ -20,6 +24,8 @@ from vpp_contracts.skill_report import SkillReport
|
|||||||
|
|
||||||
from . import SKILL_VERSIONS
|
from . import SKILL_VERSIONS
|
||||||
from .bid_milp import optimize_bid
|
from .bid_milp import optimize_bid
|
||||||
|
from .dispatch_opt import optimize_dispatch
|
||||||
|
from .potential import assess_potential
|
||||||
from .forecast import forecast
|
from .forecast import forecast
|
||||||
from .report import generate_report
|
from .report import generate_report
|
||||||
|
|
||||||
@ -31,6 +37,8 @@ ROUTES = {
|
|||||||
"price-forecast": "/v1/forecast/price",
|
"price-forecast": "/v1/forecast/price",
|
||||||
"bid-optimization-milp": "/v1/optimize/bid",
|
"bid-optimization-milp": "/v1/optimize/bid",
|
||||||
"report-generator": "/v1/report",
|
"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)
|
@app.post(ROUTES["report-generator"], response_model=SkillReport)
|
||||||
def report(req: ReportRequest) -> SkillReport:
|
def report(req: ReportRequest) -> SkillReport:
|
||||||
return generate_report(req)
|
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