|
| 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