import { mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { BidOptimizationRequest, BidOptimizationResult, Envelope, ForecastBundle, ForecastKind, ForecastRequest, ReportRequest, SkillReport } from '@vpp/domain' import { 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 { throw new Error('not used') } } 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) } }