177 lines
12 KiB
TypeScript
177 lines
12 KiB
TypeScript
|
|
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
|
||
|
|
})
|
||
|
|
})
|