Initial commit
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user