101 lines
3.2 KiB
JavaScript
101 lines
3.2 KiB
JavaScript
/**
|
|
* 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`;
|
|
}
|