Skip to content

Commit a6ee002

Browse files
committed
fix(forecast): accuracy = 100 − WAPE, and days without daylight sample coverage (logger outage) are neither scored nor used to learn the sky model
- mean per-day % error let one partial day (2.1 kWh actual vs 25 forecast) zero a month - evaluatedForecastRows joins 06:00–17:59 BKK sample counts; < 115 of 144 → actual=null, incomplete=true - feeds both /api/forecast/accuracy and the learned sky factors
1 parent 37cdfea commit a6ee002

3 files changed

Lines changed: 32 additions & 8 deletions

File tree

‎src/lib/__tests__/skylearn.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,15 @@ describe("learnSky — site sky factors shrunk toward the regional prior", () =>
2525
});
2626

2727
describe("accuracySummary", () => {
28-
it("score = 100 − mean absolute % error; bias signed", () => {
28+
it("score = 100 − WAPE (Σ|err| ÷ Σactual); bias signed", () => {
2929
const a = accuracySummary([{ day: "a", predicted: 45, actual: 50 }, { day: "b", predicted: 55, actual: 50 }]);
3030
expect(a.n).toBe(2); expect(a.score).toBe(90); expect(a.bias).toBeCloseTo(0); expect(a.mae).toBeCloseTo(5);
3131
});
32+
it("one near-empty day can't zero a month of good days (WAPE, not mean APE)", () => {
33+
const good = Array.from({ length: 10 }, (_, i) => ({ day: `d${i}`, predicted: 30, actual: 30 }));
34+
const a = accuracySummary([...good, { day: "x", predicted: 25, actual: 2.1 }]);
35+
expect(a.score).toBeGreaterThan(90); // mean APE would have scored 0
36+
});
3237
it("ignores days with no real production; empty → nulls", () => {
3338
expect(accuracySummary([{ day: "a", predicted: 10, actual: 0 }]).score).toBeNull();
3439
});

‎src/lib/skylearn.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ export interface Accuracy { n: number; score: number | null; bias: number | null
4242
export function accuracySummary(rows: AccRow[]): Accuracy {
4343
const r = rows.filter((x) => Number.isFinite(x.predicted) && Number.isFinite(x.actual) && x.actual > 0.5);
4444
if (!r.length) return { n: 0, score: null, bias: null, mae: null };
45-
let ape = 0, err = 0, abs = 0;
46-
for (const x of r) { const e = x.predicted - x.actual; ape += Math.abs(e) / x.actual; err += e; abs += Math.abs(e); }
47-
return { n: r.length, score: Math.round(Math.max(0, 1 - ape / r.length) * 100), bias: err / r.length, mae: abs / r.length };
45+
// Score is 100 − WAPE (Σ|error| ÷ Σactual): errors weighted by the energy at stake. A plain mean
46+
// of per-day % errors lets one near-empty day (2 kWh actual vs 25 forecast = 1,100 %) wipe out a
47+
// month of good days; WAPE is the standard production-forecast metric and has no such cliff.
48+
let err = 0, abs = 0, act = 0;
49+
for (const x of r) { const e = x.predicted - x.actual; err += e; abs += Math.abs(e); act += x.actual; }
50+
return { n: r.length, score: Math.round(Math.max(0, 1 - abs / act) * 100), bias: err / r.length, mae: abs / r.length };
4851
}

‎src/worker/index.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -703,13 +703,29 @@ app.get("/api/export", async (c) => {
703703
// One place both the app (/api/totals) and the cron's day-ahead log read from, so
704704
// what gets evaluated is exactly what was shown. Memoised per isolate 30 min.
705705
let memModel: { at: number; m: { capKw: number; calibDays: number; sky: Record<number, number>; skyDays: number } } | null = null;
706+
// 06:00–17:59 = 144 five-minute samples; require 80 % of them
707+
const DAYLIGHT_COVER_MIN = 115;
706708
async function evaluatedForecastRows(env: Env, days: number) {
707709
const today = bkkDayOf(Date.now());
708710
const cutoff = bkkDayOf(Date.now() - days * 86400000);
711+
// A day only counts as "actual" when our own 5-min samples cover most of its daylight: a
712+
// logger outage leaves Deye's day total partial (e.g. 2.1 kWh for a ~30 kWh day), and judging
713+
// the forecast — or teaching the sky model — on that would be wrong. Such days come back with
714+
// actual = null and incomplete = true.
715+
const since = Math.floor(Date.parse(cutoff + "T00:00:00+07:00") / 1000);
709716
const { results } = await env.DB.prepare(
710-
`SELECT f.day, f.predicted, f.cond, f.psh, f.cap_kw, d.gen AS actual FROM forecast_log f LEFT JOIN daily d ON d.day = f.day
711-
WHERE f.day >= ? AND f.day < ? ORDER BY f.day`).bind(cutoff, today).all();
712-
return (results || []) as { day: string; predicted: number; cond: number; psh: number; cap_kw: number; actual: number | null }[];
717+
`SELECT f.day, f.predicted, f.cond, f.psh, f.cap_kw, d.gen AS actual, COALESCE(c.n, 0) AS cover
718+
FROM forecast_log f
719+
LEFT JOIN daily d ON d.day = f.day
720+
LEFT JOIN (SELECT date(ts + 25200, 'unixepoch') AS day, COUNT(*) AS n FROM samples
721+
WHERE ts >= ? AND CAST(strftime('%H', ts + 25200, 'unixepoch') AS INTEGER) BETWEEN 6 AND 17
722+
GROUP BY 1) c ON c.day = f.day
723+
WHERE f.day >= ? AND f.day < ? ORDER BY f.day`).bind(since, cutoff, today).all();
724+
return ((results || []) as any[]).map((r) => {
725+
const complete = Number(r.cover) >= DAYLIGHT_COVER_MIN;
726+
return { day: String(r.day), predicted: Number(r.predicted), cond: Number(r.cond), psh: Number(r.psh), cap_kw: Number(r.cap_kw),
727+
actual: complete && r.actual != null ? Number(r.actual) : null, incomplete: !complete && r.actual != null };
728+
});
713729
}
714730
async function forecastModel(env: Env) {
715731
if (memModel && Date.now() - memModel.at < 30 * 60 * 1000) return memModel.m;
@@ -802,7 +818,7 @@ app.get("/api/forecast/accuracy", async (c) => {
802818
const days = Math.min(365, Math.max(7, Number(c.req.query("days")) || 30));
803819
const rows = await evaluatedForecastRows(c.env, days);
804820
const s = accuracySummary(rows.filter((r) => r.actual != null).map((r) => ({ day: r.day, predicted: Number(r.predicted), actual: Number(r.actual) })));
805-
return c.json({ days, ...s, rows: rows.map((r) => ({ day: r.day, predicted: Number(r.predicted), actual: r.actual == null ? null : Number(r.actual), cond: Number(r.cond) })) });
821+
return c.json({ days, ...s, rows: rows.map((r) => ({ day: r.day, predicted: Number(r.predicted), actual: r.actual == null ? null : Number(r.actual), cond: Number(r.cond), incomplete: r.incomplete })) });
806822
});
807823

808824
app.get("/api/weather", async (c) => c.json(await getWeather(c.env)));

0 commit comments

Comments
 (0)