/** * Trend extrapolation. * * Ordinary least squares over a decimated window of recent samples. Chosen over * anything fancier for one reason: when the customer asks "how does it predict * that?", the answer is one sentence - a straight-line fit over the last N * minutes, projected to the alarm threshold, reported only when the fit is good * enough to mean something. * * This is deliberately NOT presented as AI. It is a regression. */ export class TrendTracker { /** * windowSec - how much history to fit over, in simulated seconds * sampleEverySec- decimation interval, keeps the fit cheap and less noise-bound * minR2 - below this the fit is reported as unreliable, no projection */ constructor({ windowSec = 1200, sampleEverySec = 10, minR2 = 0.55 } = {}) { this.windowSec = windowSec; this.sampleEverySec = sampleEverySec; this.minR2 = minR2; this.samples = []; this.lastSampleT = -Infinity; } reset() { this.samples = []; this.lastSampleT = -Infinity; } update(t, value) { if (!Number.isFinite(value)) return; if (t - this.lastSampleT < this.sampleEverySec) return; this.lastSampleT = t; this.samples.push({ t, v: value }); const cutoff = t - this.windowSec; while (this.samples.length && this.samples[0].t < cutoff) this.samples.shift(); } /** Least-squares fit. slope is in units per simulated second. */ fit() { const n = this.samples.length; if (n < 6) return null; let st = 0, sv = 0; for (const s of this.samples) { st += s.t; sv += s.v; } const mt = st / n, mv = sv / n; let stt = 0, stv = 0, svv = 0; for (const s of this.samples) { const dt = s.t - mt, dv = s.v - mv; stt += dt * dt; stv += dt * dv; svv += dv * dv; } if (stt === 0) return null; const slope = stv / stt; const r2 = svv === 0 ? 0 : (stv * stv) / (stt * svv); return { slope, intercept: mv - slope * mt, mean: mv, n, r2, spanSec: this.samples[n - 1].t - this.samples[0].t, latest: this.samples[n - 1].v, }; } /** * Simulated seconds until the fitted line reaches `threshold`. * * Returns null when there is no usable projection: too little data, a fit too * poor to trust, movement in the wrong direction, or the threshold already * crossed. Returning null is the honest answer and the UI shows nothing. */ timeToThreshold(threshold) { const f = this.fit(); if (!f) return null; if (f.r2 < this.minR2) return null; const current = f.latest; const rising = threshold > current; if (rising && f.slope <= 1e-9) return null; if (!rising && f.slope >= -1e-9) return null; const secs = (threshold - current) / f.slope; if (!Number.isFinite(secs) || secs <= 0) return null; return { seconds: secs, fit: f }; } } /** Human-readable duration for a span of simulated seconds. */ export function formatDuration(sec) { if (!Number.isFinite(sec)) return 'unknown'; if (sec < 90) return `${Math.round(sec)} s`; if (sec < 5400) return `${(sec / 60).toFixed(1)} min`; if (sec < 172800) return `${(sec / 3600).toFixed(1)} h`; return `${(sec / 86400).toFixed(1)} d`; }