Skip to content

Commit 5d62209

Browse files
committed
refactor: deduplicate round2; fix expandBounds expansion direction; cache baseline in optimizer; use structuredClone for stripProxies
test: add optimizer unit tests
1 parent 2745387 commit 5d62209

9 files changed

Lines changed: 380 additions & 58 deletions

File tree

src/domain/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from "./policy";
44
export * from "./simulation";
55
export * from "./result";
66
export * from "./optimize";
7+
export * from "./utils";

src/domain/optimize.test.ts

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
generateGrid,
4+
generateDateSamples,
5+
refineBounds,
6+
expandBounds,
7+
findBest,
8+
initialBounds,
9+
dateToDay,
10+
dayToDate,
11+
} from "./optimize";
12+
import type { Bounds, SweepPoint } from "./types";
13+
14+
function bounds(overrides?: Partial<Bounds>): Bounds {
15+
return {
16+
tpMin: 10,
17+
tpMax: 500,
18+
trMin: 0,
19+
trMax: 500,
20+
dayMin: 0,
21+
dayMax: 364,
22+
...overrides,
23+
};
24+
}
25+
26+
function sweepPoint(overrides?: Partial<SweepPoint>): SweepPoint {
27+
return {
28+
thetaPause: 200,
29+
thetaResume: 150,
30+
startTime: "01-01",
31+
actualOverheadPct: 10,
32+
co2SavingsPct: 15,
33+
score: 0.8,
34+
numPauses: 10,
35+
totalEmissionsKgco2: 5000,
36+
baselineEmissionsKgco2: 6000,
37+
withinBudget: true,
38+
stopReason: "completed",
39+
completed: true,
40+
iteration: 0,
41+
...overrides,
42+
};
43+
}
44+
45+
// ---------------------------------------------------------------------------
46+
// generateGrid
47+
// ---------------------------------------------------------------------------
48+
49+
describe("generateGrid", () => {
50+
it("returns points within bounds", () => {
51+
const pts = generateGrid(bounds(), 5);
52+
for (const p of pts) {
53+
expect(p.thetaPause).toBeGreaterThanOrEqual(10);
54+
expect(p.thetaPause).toBeLessThanOrEqual(500);
55+
expect(p.thetaResume).toBeGreaterThanOrEqual(0);
56+
expect(p.thetaResume).toBeLessThanOrEqual(p.thetaPause);
57+
}
58+
});
59+
60+
it("generates expected number of theta_pause values", () => {
61+
const pts = generateGrid(bounds(), 5);
62+
const tpValues = new Set(pts.map((p) => p.thetaPause));
63+
expect(tpValues.size).toBe(5);
64+
});
65+
66+
it("produces non-empty grid with minimum resolution", () => {
67+
const pts = generateGrid(bounds(), 2);
68+
expect(pts.length).toBeGreaterThan(0);
69+
});
70+
71+
it("always includes tpMax as the last thetaPause", () => {
72+
const pts = generateGrid(bounds(), 10);
73+
const tpValues = [...new Set(pts.map((p) => p.thetaPause))];
74+
expect(tpValues[tpValues.length - 1]).toBe(500);
75+
});
76+
77+
it("thetaResume never exceeds thetaPause (hysteresis constraint)", () => {
78+
const pts = generateGrid(bounds(), 10);
79+
for (const p of pts) {
80+
expect(p.thetaResume).toBeLessThanOrEqual(p.thetaPause);
81+
}
82+
});
83+
84+
it("handles narrow bounds (tpMin close to tpMax)", () => {
85+
const pts = generateGrid({ tpMin: 100, tpMax: 110, trMin: 0, trMax: 110, dayMin: 0, dayMax: 364 }, 5);
86+
expect(pts.length).toBeGreaterThan(0);
87+
for (const p of pts) {
88+
expect(p.thetaPause).toBeGreaterThanOrEqual(100);
89+
expect(p.thetaPause).toBeLessThanOrEqual(110);
90+
}
91+
});
92+
});
93+
94+
// ---------------------------------------------------------------------------
95+
// generateDateSamples
96+
// ---------------------------------------------------------------------------
97+
98+
describe("generateDateSamples", () => {
99+
it("generates correct number of samples", () => {
100+
const days = generateDateSamples(bounds(), 7);
101+
expect(days.length).toBe(7);
102+
});
103+
104+
it("returns single point when min >= max", () => {
105+
const days = generateDateSamples({ ...bounds(), dayMin: 100, dayMax: 100 }, 5);
106+
expect(days).toEqual([100]);
107+
});
108+
109+
it("covers the full range", () => {
110+
const days = generateDateSamples(bounds({ dayMin: 0, dayMax: 364 }), 5);
111+
expect(days[0]).toBe(0);
112+
expect(days[days.length - 1]).toBe(364);
113+
});
114+
});
115+
116+
// ---------------------------------------------------------------------------
117+
// expandBounds
118+
// ---------------------------------------------------------------------------
119+
120+
describe("expandBounds", () => {
121+
it("expands tpMin and tpMax outward", () => {
122+
const b = bounds({ tpMin: 100, tpMax: 300 });
123+
const expanded = expandBounds(b, 500);
124+
expect(expanded.tpMin).toBeLessThan(b.tpMin);
125+
expect(expanded.tpMax).toBeGreaterThan(b.tpMax);
126+
});
127+
128+
it("never expands tpMin below 10", () => {
129+
const b = bounds({ tpMin: 10, tpMax: 50 });
130+
const expanded = expandBounds(b, 500);
131+
expect(expanded.tpMin).toBe(10);
132+
});
133+
134+
it("caps tpMax at maxThetaPause", () => {
135+
const b = bounds({ tpMin: 100, tpMax: 200 });
136+
const expanded = expandBounds(b, 250);
137+
expect(expanded.tpMax).toBeLessThanOrEqual(250);
138+
});
139+
140+
it("resets day range to full year", () => {
141+
const b = bounds({ dayMin: 100, dayMax: 200 });
142+
const expanded = expandBounds(b, 500);
143+
expect(expanded.dayMin).toBe(0);
144+
expect(expanded.dayMax).toBe(364);
145+
});
146+
147+
it("resets trMin to 0", () => {
148+
const b = bounds({ trMin: 50, trMax: 400 });
149+
const expanded = expandBounds(b, 500);
150+
expect(expanded.trMin).toBe(0);
151+
});
152+
153+
it("produces tpMax >= tpMin", () => {
154+
const b = bounds({ tpMin: 490, tpMax: 500 });
155+
const expanded = expandBounds(b, 500);
156+
expect(expanded.tpMax).toBeGreaterThanOrEqual(expanded.tpMin);
157+
});
158+
});
159+
160+
// ---------------------------------------------------------------------------
161+
// refineBounds
162+
// ---------------------------------------------------------------------------
163+
164+
describe("refineBounds", () => {
165+
it("centers bounds around best point", () => {
166+
const b = bounds({ tpMin: 0, tpMax: 500 });
167+
const refined = refineBounds({ thetaPause: 250, thetaResume: 200, startDay: 180 }, b, 0.5, 3);
168+
expect(refined.tpMin).toBeGreaterThan(0);
169+
expect(refined.tpMax).toBeLessThan(500);
170+
expect(refined.trMin).toBeGreaterThanOrEqual(0);
171+
expect(refined.trMax).toBeLessThanOrEqual(refined.tpMax);
172+
});
173+
174+
it("ensures tpMin >= 10", () => {
175+
const b = bounds({ tpMin: 0, tpMax: 50 });
176+
const refined = refineBounds({ thetaPause: 15, thetaResume: 10, startDay: 0 }, b, 0.5, 3);
177+
expect(refined.tpMin).toBeGreaterThanOrEqual(10);
178+
});
179+
180+
it("ensures trMax >= trMin + minStep", () => {
181+
const b = bounds({ tpMin: 0, tpMax: 500, trMin: 0, trMax: 500 });
182+
const refined = refineBounds({ thetaPause: 250, thetaResume: 250, startDay: 180 }, b, 0.5, 3);
183+
expect(refined.trMax - refined.trMin).toBeGreaterThanOrEqual(3);
184+
});
185+
186+
it("ensures dayMax > dayMin", () => {
187+
const b = bounds({ dayMin: 0, dayMax: 364 });
188+
const refined = refineBounds({ thetaPause: 250, thetaResume: 200, startDay: 200 }, b, 0.1, 3);
189+
expect(refined.dayMax).toBeGreaterThan(refined.dayMin);
190+
});
191+
192+
it("tpMin <= tpMax", () => {
193+
const b = bounds({ tpMin: 100, tpMax: 500 });
194+
const refined = refineBounds({ thetaPause: 300, thetaResume: 200, startDay: 180 }, b, 0.5, 3);
195+
expect(refined.tpMin).toBeLessThanOrEqual(refined.tpMax);
196+
});
197+
});
198+
199+
// ---------------------------------------------------------------------------
200+
// findBest
201+
// ---------------------------------------------------------------------------
202+
203+
describe("findBest", () => {
204+
it("returns null when no points within budget", () => {
205+
const pts = [
206+
sweepPoint({ withinBudget: false, co2SavingsPct: 10, score: 0.5 }),
207+
sweepPoint({ withinBudget: false, co2SavingsPct: 20, score: 0.8 }),
208+
];
209+
expect(findBest(pts, 200)).toBeNull();
210+
});
211+
212+
it("returns null when no points have positive savings", () => {
213+
const pts = [
214+
sweepPoint({ withinBudget: true, co2SavingsPct: 0, score: 0.5 }),
215+
sweepPoint({ withinBudget: true, co2SavingsPct: -5, score: 0.3 }),
216+
];
217+
expect(findBest(pts, 200)).toBeNull();
218+
});
219+
220+
it("returns the point with highest score among budget+savings valid", () => {
221+
const pts = [
222+
sweepPoint({ withinBudget: true, co2SavingsPct: 10, score: 0.5 }),
223+
sweepPoint({ withinBudget: true, co2SavingsPct: 20, score: 0.9 }),
224+
sweepPoint({ withinBudget: true, co2SavingsPct: 15, score: 0.7 }),
225+
];
226+
const best = findBest(pts, 200);
227+
expect(best).not.toBeNull();
228+
expect(best!.score).toBe(0.9);
229+
expect(best!.co2SavingsPct).toBe(20);
230+
});
231+
232+
it("ignores out-of-budget points even if they have high score", () => {
233+
const pts = [
234+
sweepPoint({ withinBudget: false, co2SavingsPct: 50, score: 0.99 }),
235+
sweepPoint({ withinBudget: true, co2SavingsPct: 5, score: 0.4 }),
236+
];
237+
const best = findBest(pts, 200);
238+
expect(best).not.toBeNull();
239+
expect(best!.score).toBe(0.4);
240+
});
241+
242+
it("returns null for empty array", () => {
243+
expect(findBest([], 200)).toBeNull();
244+
});
245+
});
246+
247+
// ---------------------------------------------------------------------------
248+
// dateToDay / dayToDate round-trip
249+
// ---------------------------------------------------------------------------
250+
251+
describe("dateToDay / dayToDate", () => {
252+
it("round-trips: 01-01", () => {
253+
expect(dayToDate(dateToDay("01-01"))).toBe("01-01");
254+
});
255+
256+
it("round-trips: 06-15", () => {
257+
expect(dayToDate(dateToDay("06-15"))).toBe("06-15");
258+
});
259+
260+
it("round-trips: 12-31", () => {
261+
expect(dayToDate(dateToDay("12-31"))).toBe("12-31");
262+
});
263+
264+
it("dateToDay 01-01 is 0", () => {
265+
expect(dateToDay("01-01")).toBe(0);
266+
});
267+
268+
it("dateToDay 12-31 is 364", () => {
269+
expect(dateToDay("12-31")).toBe(364);
270+
});
271+
});
272+
273+
// ---------------------------------------------------------------------------
274+
// initialBounds
275+
// ---------------------------------------------------------------------------
276+
277+
describe("initialBounds", () => {
278+
it("uses the given thetaPauseMax as tpMax and trMax", () => {
279+
const b = initialBounds(500);
280+
expect(b.tpMin).toBe(10);
281+
expect(b.tpMax).toBe(500);
282+
expect(b.trMin).toBe(0);
283+
expect(b.trMax).toBe(500);
284+
});
285+
286+
it("covers full year", () => {
287+
const b = initialBounds(500);
288+
expect(b.dayMin).toBe(0);
289+
expect(b.dayMax).toBe(364);
290+
});
291+
});
292+
293+
// ---------------------------------------------------------------------------
294+
// Interaction: expandBounds followed by generateGrid
295+
// ---------------------------------------------------------------------------
296+
297+
describe("expandBounds + generateGrid integration", () => {
298+
it("grid produced from expanded bounds is valid", () => {
299+
const b = bounds({ tpMin: 100, tpMax: 200 });
300+
const expanded = expandBounds(b, 500);
301+
const grid = generateGrid(expanded, 5);
302+
expect(grid.length).toBeGreaterThan(0);
303+
for (const p of grid) {
304+
expect(p.thetaPause).toBeGreaterThanOrEqual(expanded.tpMin);
305+
expect(p.thetaPause).toBeLessThanOrEqual(expanded.tpMax);
306+
expect(p.thetaResume).toBeGreaterThanOrEqual(0);
307+
expect(p.thetaResume).toBeLessThanOrEqual(p.thetaPause);
308+
}
309+
});
310+
});

0 commit comments

Comments
 (0)