|
| 1 | +import type { FullProfile, SimConfig, CO2Timeline, Scenario } from "../types"; |
| 2 | +import { simulateStepwise, buildResult } from "./simulation"; |
| 3 | + |
| 4 | +export interface SweepPoint { |
| 5 | + thetaPause: number; |
| 6 | + thetaResume: number; |
| 7 | + actualOverheadPct: number; |
| 8 | + co2SavingsPct: number; |
| 9 | + score: number; |
| 10 | + numPauses: number; |
| 11 | + totalEmissionsKgco2: number; |
| 12 | + baselineEmissionsKgco2: number; |
| 13 | + withinBudget: boolean; |
| 14 | + stopReason: string; |
| 15 | + completed: boolean; |
| 16 | + iteration: number; |
| 17 | +} |
| 18 | + |
| 19 | +export interface SweepOptions { |
| 20 | + thetaPauseMin: number; |
| 21 | + thetaPauseMax: number; |
| 22 | + thetaPauseStep: number; |
| 23 | + hysteresisMode: "ratio" | "offset"; |
| 24 | + hysteresisValue: number; |
| 25 | + overheadBudgetPct: number; |
| 26 | +} |
| 27 | + |
| 28 | +export interface AdaptiveOptions { |
| 29 | + thetaPauseMax: number; |
| 30 | + overheadBudgetPct: number; |
| 31 | + resolution: number; |
| 32 | + maxIterations: number; |
| 33 | + minStep: number; |
| 34 | + shrinkFactor: number; |
| 35 | +} |
| 36 | + |
| 37 | +export interface AdaptiveResult { |
| 38 | + points: SweepPoint[]; |
| 39 | + best: SweepPoint | null; |
| 40 | + iterations: number; |
| 41 | +} |
| 42 | + |
| 43 | +export function runSweep( |
| 44 | + profile: FullProfile, |
| 45 | + timeline: CO2Timeline, |
| 46 | + scenario: Scenario, |
| 47 | + options: SweepOptions, |
| 48 | + startTimeIdx = 0, |
| 49 | +): SweepPoint[] { |
| 50 | + const startTime = scenario.startTimes[startTimeIdx] || "01-01"; |
| 51 | + |
| 52 | + const shared = { |
| 53 | + scenarioDescription: scenario.description, |
| 54 | + region: scenario.region, |
| 55 | + historicalYears: scenario.historicalYears, |
| 56 | + startTime, |
| 57 | + overheadBudgetPct: options.overheadBudgetPct, |
| 58 | + }; |
| 59 | + |
| 60 | + const baselineLast = runBaseline(profile, shared, timeline); |
| 61 | + |
| 62 | + const points: SweepPoint[] = []; |
| 63 | + for (let tp = options.thetaPauseMin; tp <= options.thetaPauseMax; tp = round(tp + options.thetaPauseStep)) { |
| 64 | + const tr = options.hysteresisMode === "ratio" |
| 65 | + ? round(tp * options.hysteresisValue) |
| 66 | + : Math.max(0, round(tp - options.hysteresisValue)); |
| 67 | + |
| 68 | + const pt = evaluate(profile, { ...shared, thetaPause: tp, thetaResume: tr }, timeline, baselineLast, 0); |
| 69 | + if (pt) points.push(pt); |
| 70 | + } |
| 71 | + |
| 72 | + return points; |
| 73 | +} |
| 74 | + |
| 75 | +export function adaptiveSweep( |
| 76 | + profile: FullProfile, |
| 77 | + timeline: CO2Timeline, |
| 78 | + scenario: Scenario, |
| 79 | + options: AdaptiveOptions, |
| 80 | + startTimeIdx = 0, |
| 81 | + onIteration?: (iteration: number, points: SweepPoint[], best: SweepPoint | null) => void, |
| 82 | +): AdaptiveResult { |
| 83 | + const startTime = scenario.startTimes[startTimeIdx] || "01-01"; |
| 84 | + |
| 85 | + const shared = { |
| 86 | + scenarioDescription: scenario.description, |
| 87 | + region: scenario.region, |
| 88 | + historicalYears: scenario.historicalYears, |
| 89 | + startTime, |
| 90 | + overheadBudgetPct: options.overheadBudgetPct, |
| 91 | + }; |
| 92 | + |
| 93 | + const baselineLast = runBaseline(profile, shared, timeline); |
| 94 | + |
| 95 | + const allPoints: SweepPoint[] = []; |
| 96 | + let iterPoints: SweepPoint[] = []; |
| 97 | + let best: SweepPoint | null = null; |
| 98 | + |
| 99 | + let tpMin = 10; |
| 100 | + let tpMax = options.thetaPauseMax; |
| 101 | + let trMin = 0; |
| 102 | + let trMax = options.thetaPauseMax; |
| 103 | + |
| 104 | + for (let iter = 0; iter < options.maxIterations; iter++) { |
| 105 | + const stepTp = Math.max((tpMax - tpMin) / (options.resolution - 1), 1); |
| 106 | + const stepTr = Math.max((trMax - trMin) / (options.resolution - 1), 1); |
| 107 | + |
| 108 | + if (stepTp < options.minStep && stepTr < options.minStep) break; |
| 109 | + |
| 110 | + iterPoints = []; |
| 111 | + |
| 112 | + for (let tpi = 0; tpi < options.resolution; tpi++) { |
| 113 | + const tp = round(tpMin + tpi * stepTp); |
| 114 | + if (tp > options.thetaPauseMax) break; |
| 115 | + |
| 116 | + const trCount = Math.max(2, Math.round(((tp - trMin) / (trMax - trMin)) * options.resolution)); |
| 117 | + for (let tri = 0; tri < trCount; tri++) { |
| 118 | + const tr = round(trMin + tri * (tp - trMin) / Math.max(trCount - 1, 1)); |
| 119 | + if (tr > tp) break; |
| 120 | + |
| 121 | + const pt = evaluate(profile, { ...shared, thetaPause: tp, thetaResume: tr }, timeline, baselineLast, iter); |
| 122 | + if (pt) iterPoints.push(pt); |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + allPoints.push(...iterPoints); |
| 127 | + |
| 128 | + const iterBest = findBest(iterPoints, options.overheadBudgetPct); |
| 129 | + if (iterBest && (!best || iterBest.score > best.score)) { |
| 130 | + best = iterBest; |
| 131 | + } |
| 132 | + |
| 133 | + if (onIteration) onIteration(iter, iterPoints, best); |
| 134 | + |
| 135 | + if (iter === options.maxIterations - 1) break; |
| 136 | + |
| 137 | + if (!best) { |
| 138 | + tpMin = Math.max(10, tpMin * 0.8); |
| 139 | + tpMax = Math.min(options.thetaPauseMax, tpMax * 0.9); |
| 140 | + trMin = 0; |
| 141 | + trMax = tpMax; |
| 142 | + continue; |
| 143 | + } |
| 144 | + |
| 145 | + const span = tpMax - tpMin; |
| 146 | + const newMin = Math.max(10, best.thetaPause - span * options.shrinkFactor / 2); |
| 147 | + const newMax = Math.min(options.thetaPauseMax, best.thetaPause + span * options.shrinkFactor / 2); |
| 148 | + |
| 149 | + tpMin = round(newMin); |
| 150 | + tpMax = round(newMax); |
| 151 | + trMin = round(Math.max(0, best.thetaResume - span * options.shrinkFactor / 2)); |
| 152 | + trMax = round(Math.min(tpMax, best.thetaResume + span * options.shrinkFactor / 2)); |
| 153 | + trMin = Math.max(0, trMin); |
| 154 | + trMax = Math.max(trMin + options.minStep, trMax); |
| 155 | + } |
| 156 | + |
| 157 | + return { points: allPoints, best, iterations: Math.min(options.maxIterations, allPoints.length > 0 ? allPoints[allPoints.length - 1].iteration + 1 : 0) }; |
| 158 | +} |
| 159 | + |
| 160 | +function runBaseline(profile: FullProfile, shared: { scenarioDescription: string; region: string; historicalYears: number[]; startTime: string; overheadBudgetPct: number }, timeline: CO2Timeline) { |
| 161 | + const baselineProgress: import("../types").SimProgress[] = []; |
| 162 | + for (const p of simulateStepwise(profile, { ...shared, thetaPause: Infinity, thetaResume: 0 }, timeline)) { |
| 163 | + baselineProgress.push(p); |
| 164 | + } |
| 165 | + return baselineProgress[baselineProgress.length - 1]; |
| 166 | +} |
| 167 | + |
| 168 | +function evaluate( |
| 169 | + profile: FullProfile, |
| 170 | + config: SimConfig, |
| 171 | + timeline: CO2Timeline, |
| 172 | + baselineLast: import("../types").SimProgress, |
| 173 | + iteration: number, |
| 174 | +): SweepPoint | null { |
| 175 | + let lastProgress: import("../types").SimProgress | null = null; |
| 176 | + for (const p of simulateStepwise(profile, config, timeline)) { |
| 177 | + lastProgress = p; |
| 178 | + } |
| 179 | + if (!lastProgress) return null; |
| 180 | + |
| 181 | + const meta = buildResult(profile, config, lastProgress, baselineLast); |
| 182 | + const actualOverheadPct = meta.actualOverheadPct; |
| 183 | + const co2SavingsPct = meta.baselineEmissionsKgco2 > 0 |
| 184 | + ? (meta.baselineEmissionsKgco2 - meta.totalEmissionsKgco2) / meta.baselineEmissionsKgco2 * 100 |
| 185 | + : 0; |
| 186 | + |
| 187 | + return { |
| 188 | + thetaPause: config.thetaPause === Infinity ? 9999 : config.thetaPause, |
| 189 | + thetaResume: config.thetaResume, |
| 190 | + actualOverheadPct, |
| 191 | + co2SavingsPct, |
| 192 | + score: co2SavingsPct / Math.max(actualOverheadPct, 0.001), |
| 193 | + numPauses: meta.numPauses, |
| 194 | + totalEmissionsKgco2: meta.totalEmissionsKgco2, |
| 195 | + baselineEmissionsKgco2: meta.baselineEmissionsKgco2, |
| 196 | + withinBudget: meta.withinOverheadBudget, |
| 197 | + stopReason: meta.stopReason, |
| 198 | + completed: meta.completed, |
| 199 | + iteration, |
| 200 | + }; |
| 201 | +} |
| 202 | + |
| 203 | +function findBest(points: SweepPoint[], budget: number): SweepPoint | null { |
| 204 | + const valid = points.filter(r => r.withinBudget && r.co2SavingsPct > 0); |
| 205 | + if (valid.length === 0) return null; |
| 206 | + return valid.reduce((a, b) => (a.score > b.score ? a : b)); |
| 207 | +} |
| 208 | + |
| 209 | +function round(n: number): number { |
| 210 | + return Math.round(n * 100) / 100; |
| 211 | +} |
0 commit comments