Files
BikeControl/ui/src/lib/history.ts
T
dtourolleandClaude Opus 5 7c17ca6158 Core ride logic, FTMS client, FIT encoder and probe CLI
Adds backing state for Resistance and Erg control modes, which had no
value to hold and so could never satisfy FR-4.3/FR-4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:34:27 +02:00

87 lines
2.7 KiB
TypeScript

/**
* Bounded, self-decimating chart history (NFR-3).
*
* A two-hour ride at 4 Hz is 28 800 samples per series. Keeping them all is
* both a memory leak and a rendering cost that grows through the ride — exactly
* what NFR-3 forbids. Instead the buffer has a hard capacity: when it fills, it
* averages adjacent pairs in place, halving the point count and doubling the
* time each point represents. Later samples are then averaged in groups of that
* same stride before being stored.
*
* The result is constant memory and a constant point count for any ride
* length, with resolution degrading gracefully: full 250 ms detail for the
* first ~15 minutes, 2-second buckets by two hours. Typed arrays throughout so
* uPlot can consume them without a copy.
*/
const CAPACITY = 3600;
export class History {
readonly capacity: number;
readonly seriesCount: number;
/** Seconds since ride start. */
readonly x: Float64Array;
readonly y: Float64Array[];
/** Number of populated points. */
length = 0;
/** Raw samples currently folded into one stored point. */
stride = 1;
private pendingX = 0;
private pendingY: Float64Array;
private pendingN = 0;
constructor(seriesCount: number, capacity = CAPACITY) {
this.capacity = capacity;
this.seriesCount = seriesCount;
this.x = new Float64Array(capacity);
this.y = Array.from({ length: seriesCount }, () => new Float64Array(capacity));
this.pendingY = new Float64Array(seriesCount);
}
push(x: number, values: number[]): void {
this.pendingX += x;
for (let s = 0; s < this.seriesCount; s++) this.pendingY[s] += values[s] ?? 0;
this.pendingN++;
if (this.pendingN < this.stride) return;
if (this.length >= this.capacity) this.compact();
const i = this.length++;
this.x[i] = this.pendingX / this.pendingN;
for (let s = 0; s < this.seriesCount; s++) this.y[s][i] = this.pendingY[s] / this.pendingN;
this.pendingX = 0;
this.pendingY.fill(0);
this.pendingN = 0;
}
/** Halve the resolution in place. O(capacity), amortised to O(1) per sample. */
private compact(): void {
const half = this.length >> 1;
for (let i = 0; i < half; i++) {
const a = i * 2;
const b = a + 1;
this.x[i] = (this.x[a] + this.x[b]) / 2;
for (let s = 0; s < this.seriesCount; s++) {
this.y[s][i] = (this.y[s][a] + this.y[s][b]) / 2;
}
}
this.length = half;
this.stride *= 2;
}
clear(): void {
this.length = 0;
this.stride = 1;
this.pendingX = 0;
this.pendingY.fill(0);
this.pendingN = 0;
}
/** Views sized to the populated region, ready for `uPlot.setData`. */
view(): Float64Array[] {
return [this.x.subarray(0, this.length), ...this.y.map((a) => a.subarray(0, this.length))];
}
}