import { mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { BidOptimizationRequest, BidOptimizationResult, DispatchOptimizationRequest, DispatchOptimizationResult, Envelope, ForecastBundle, ForecastKind, ForecastRequest, PotentialAssessmentRequest, PotentialAssessmentResult, ReportRequest, SkillReport } from '@vpp/domain' import { MemoryTimeSeriesStore } from '@vpp/services' import type { SkillClient } from '@vpp/services' import { createRuntime } from '../src/runtime.js' import type { RuntimeOptions } from '../src/runtime.js' import type { LlmPort } from '../src/llm.js' const datasetPath = fileURLToPath(new URL('../../evals/datasets/synthetic-hubei-v0.json', import.meta.url)) interface Day { date: string; load_mw: string[]; pv_mw: string[]; price_yuan_per_mwh: string[]; adjustable_capacity_mw: string[] } export const MARKET_DATE = '2026-03-15' export const NOW = '2026-03-14T00:00:00Z' // D-1 08:00 Asia/Shanghai /** * Deterministic stand-in for the Python skill service: forecast = last * history day with a ±spread band; bid = flat energy at the position's * lower bound offered at `offer` — enough to steer the lifecycle down every * branch (envelope in/out, simulation alert) from tests. */ export class StubSkills implements SkillClient { spread = 0.1 offer = '0.00' energyFraction = 0 // 0 → E_min, 1 → E_max calls: string[] = [] async skills() { return ['load-forecast', 'pv-forecast', 'price-forecast', 'bid-optimization-milp'].map((id) => ({ id, version: '0.0.1', endpoint: '/stub' })) } async forecast(kind: ForecastKind, req: ForecastRequest): Promise { this.calls.push(`forecast:${kind}`) const last = req.history[req.history.length - 1]! const scaled = (f: number) => ({ interval_minutes: 15 as const, date: req.market_date, values: last.values.map((v) => (Number(v) * f).toFixed(kind === 'PRICE' ? 2 : 3)) }) return { id: `fc-${kind.toLowerCase()}-${req.market_date}`, kind, market_date: req.market_date, unit: req.unit, quantiles: { p10: scaled(1 - this.spread), p50: scaled(1), p90: scaled(1 + this.spread) }, model: { name: `${kind.toLowerCase()}-forecast`, version: '0.0.1' }, features_snapshot_ref: req.features_snapshot_ref, generated_at: '2026-03-14T06:00:00Z', } } async optimizeBid(req: BidOptimizationRequest): Promise { this.calls.push('milp') const lo = Number(req.position_bounds.daily_energy_min_mwh) const hi = Number(req.position_bounds.daily_energy_max_mwh) const per = ((lo + (hi - lo) * this.energyFraction) / 96).toFixed(3) const q = { interval_minutes: 15 as const, date: req.market_date, values: Array(96).fill(per) as string[] } const energy = (Number(per) * 96).toFixed(3) const p50 = req.price_forecast.quantiles.p50.values const revenue = p50.reduce((s, p) => s + (Number(this.offer) <= Number(p) ? Number(p) * Number(per) : 0), 0).toFixed(2) return { market_date: req.market_date, prices_yuan_per_mwh: { ...q, values: Array(96).fill(this.offer) }, quantities_mwh: q, daily_energy_mwh: energy, expected_revenue_yuan: revenue, revenue_distribution_yuan: { p10: revenue, p50: revenue, p90: revenue }, position_bounds: req.position_bounds, 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 { 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 { 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 { 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) { const ts = new MemoryTimeSeriesStore(clock) const days = (JSON.parse(readFileSync(datasetPath, 'utf8')) as { days: Day[] }).days for (const d of days) { ts.write('load:aggregate', { interval_minutes: 15, date: d.date, values: d.load_mw }, 'synthetic') ts.write('pv:aggregate', { interval_minutes: 15, date: d.date, values: d.pv_mw }, 'synthetic') ts.write('price:da', { interval_minutes: 15, date: d.date, values: d.price_yuan_per_mwh }, 'synthetic') } return ts } export const envelope = (bounds: Record, over: Partial = {}): Envelope => ({ id: 'env-bid-001', scope: { proposal_type: 'BID', timescales: ['DAY_AHEAD'], resource_set: 'pool-hubei-01' }, bounds, validity: { from: '2026-03-01T00:00:00Z', to: '2026-03-31T23:59:59Z' }, approval: { level: 'L1', approved_by: ['user-ops-lead'] }, escalation: { max_consecutive_deviations: 3, deviation_threshold_pct: '10.0' }, status: 'ACTIVE', ...over, }) export interface Harness { rt: Awaited>; skills: StubSkills; dataDir: string; setNow: (iso: string) => void } /** Fresh runtime on a temp dir, seeded like docs/07 D-1: history, one storage resource, a monthly contract. */ export async function harness(opts: { llm?: LlmPort | null; dataDir?: string; seed?: boolean; config?: RuntimeOptions['config'] } = {}): Promise { let now = NOW const clock = () => now const skills = new StubSkills() const dataDir = opts.dataDir ?? mkdtempSync(join(tmpdir(), 'vpp-rt-')) const rt = await createRuntime({ dataDir, llm: opts.llm ?? null, skills, clock, timeseries: seededTimeseries(clock), ...(opts.config ? { config: opts.config } : {}) }) if (opts.seed !== false) { rt.ctx.resources.put({ resource_id: 'res-storage-01', name: 'Wuhan storage #1', type: 'STORAGE', rated_power_mw: '30.0', certified_adjustable_mw: '24.0', confidence: '0.9', reliability_score: '0.9', constraints: { min_duration_min: 60, recovery_rate_mw_per_min: '0.5' }, evidence_refs: [], updated_at: NOW, }) // 24 MW × 0.9 × 0.25 h × 96 = 518.4 MWh sellable/day; contract 60% of it → daily share 311.04, band ±5% rt.ctx.ledger.append({ id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT', energy_mwh: (311.04 * 31).toFixed(3), curve: null, source_ref: 'contract-2026-03-001', expected_version: 0 }) } return { rt, skills, dataDir, setNow: (iso) => (now = iso) } }