Files
digitaltwin/server/analytics/trend.js
T
2026-08-24 15:35:32 +05:30

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`;
}