mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
slopreporting
This commit is contained in:
@@ -68,6 +68,12 @@ function makeMinute(roverId, now) {
|
||||
gapCount: 0,
|
||||
chargedMah: 0,
|
||||
dischargedMah: 0,
|
||||
chargedWh: 0,
|
||||
dischargedWh: 0,
|
||||
movingDischargedWh: 0,
|
||||
stationaryDischargedWh: 0,
|
||||
movingMs: 0,
|
||||
maximumSpeedMmPerSecond: null,
|
||||
minVoltageMv: null,
|
||||
maxVoltageMv: null,
|
||||
voltageTotal: 0,
|
||||
@@ -107,6 +113,12 @@ function persistedMinute(minute) {
|
||||
gapCount: minute.gapCount,
|
||||
chargedMah: minute.chargedMah,
|
||||
dischargedMah: minute.dischargedMah,
|
||||
chargedWh: minute.chargedWh,
|
||||
dischargedWh: minute.dischargedWh,
|
||||
movingDischargedWh: minute.movingDischargedWh,
|
||||
stationaryDischargedWh: minute.stationaryDischargedWh,
|
||||
movingMs: Math.round(minute.movingMs),
|
||||
maximumSpeedMmPerSecond: minute.maximumSpeedMmPerSecond,
|
||||
minVoltageMv: minute.minVoltageMv,
|
||||
maxVoltageMv: minute.maxVoltageMv,
|
||||
avgVoltageMv: minute.voltageCount ? minute.voltageTotal / minute.voltageCount : null,
|
||||
@@ -240,7 +252,7 @@ function createCollector({ storage, logger, maximumIntegrationGapMs, minimumCapa
|
||||
}
|
||||
}
|
||||
|
||||
function updateMinute(minute, sensors, elapsedMs, chargedMah, dischargedMah, gap) {
|
||||
function updateMinute(minute, sensors, elapsedMs, chargedMah, dischargedMah, chargedWh, dischargedWh, gap) {
|
||||
const voltage = finite(sensors?.voltageMv);
|
||||
const current = finite(sensors?.currentMa);
|
||||
const temperature = finite(sensors?.batteryTemperatureC);
|
||||
@@ -251,6 +263,23 @@ function createCollector({ storage, logger, maximumIntegrationGapMs, minimumCapa
|
||||
minute.gapCount += gap ? 1 : 0;
|
||||
minute.chargedMah += chargedMah;
|
||||
minute.dischargedMah += dischargedMah;
|
||||
minute.chargedWh += chargedWh;
|
||||
minute.dischargedWh += dischargedWh;
|
||||
/*
|
||||
Movement classification deliberately consumes the center speed produced
|
||||
by odometerService. That service already owns encoder rollover, physical
|
||||
conversion, and impossible-jump rejection; duplicating those rules here
|
||||
would allow reporting and the rover's actual odometer to disagree.
|
||||
*/
|
||||
const speed = finite(sensors?.wheelSpeedsMmPerSecond?.center);
|
||||
const moving = speed != null && Math.abs(speed) >= 1;
|
||||
if (moving) {
|
||||
minute.movingMs += elapsedMs;
|
||||
minute.movingDischargedWh += dischargedWh;
|
||||
} else {
|
||||
minute.stationaryDischargedWh += dischargedWh;
|
||||
}
|
||||
minute.maximumSpeedMmPerSecond = maximum(minute.maximumSpeedMmPerSecond, speed == null ? null : Math.abs(speed));
|
||||
minute.minVoltageMv = minimum(minute.minVoltageMv, voltage);
|
||||
minute.maxVoltageMv = maximum(minute.maxVoltageMv, voltage);
|
||||
if (voltage != null) { minute.voltageTotal += voltage; minute.voltageCount += 1; }
|
||||
@@ -371,6 +400,16 @@ function createCollector({ storage, logger, maximumIntegrationGapMs, minimumCapa
|
||||
const deltaMah = currentMa * validElapsedMs / 3600000;
|
||||
const chargedMah = Math.max(0, deltaMah);
|
||||
const dischargedMah = Math.max(0, -deltaMah);
|
||||
const voltageMv = finite(sensors.voltageMv);
|
||||
/*
|
||||
Millivolts multiplied by milliamps are microwatts. Dividing their
|
||||
millisecond product by 3.6e12 therefore yields watt-hours. Integrating
|
||||
voltage and current together here is required: multiplying independent
|
||||
daily averages later would produce incorrect energy whenever load varies.
|
||||
*/
|
||||
const deltaWh = voltageMv == null ? 0 : voltageMv * currentMa * validElapsedMs / 3.6e12;
|
||||
const chargedWh = Math.max(0, deltaWh);
|
||||
const dischargedWh = Math.max(0, -deltaWh);
|
||||
|
||||
const bucketTs = Math.floor(now / MINUTE_MS) * MINUTE_MS;
|
||||
if (state.minute.bucketTs !== bucketTs) {
|
||||
@@ -378,7 +417,16 @@ function createCollector({ storage, logger, maximumIntegrationGapMs, minimumCapa
|
||||
diagnostics.minuteWrites += 1;
|
||||
state.minute = makeMinute(state.roverId, now);
|
||||
}
|
||||
updateMinute(state.minute, sensors, validElapsedMs, chargedMah, dischargedMah, gap);
|
||||
updateMinute(
|
||||
state.minute,
|
||||
sensors,
|
||||
validElapsedMs,
|
||||
chargedMah,
|
||||
dischargedMah,
|
||||
chargedWh,
|
||||
dischargedWh,
|
||||
gap,
|
||||
);
|
||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||
const nextSafety = {
|
||||
bump: Boolean(bumps.bumpLeft || bumps.bumpRight),
|
||||
|
||||
@@ -58,6 +58,51 @@ test('integrates signed battery current while excluding long telemetry gaps', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('integrates watt-hours and classifies energy with existing odometer speed', () => {
|
||||
const { collector } = makeHarness();
|
||||
const originalNow = Date.now;
|
||||
let now = 1_500_000;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
collector.collectSensor({
|
||||
roverId: 'alpha',
|
||||
sensors: sensors({ wheelSpeedsMmPerSecond: { left: 200, right: 200, center: 200 } }),
|
||||
});
|
||||
now += 1000;
|
||||
collector.collectSensor({
|
||||
roverId: 'alpha',
|
||||
sensors: sensors({ wheelSpeedsMmPerSecond: { left: 200, right: 200, center: 200 } }),
|
||||
});
|
||||
now += 1000;
|
||||
collector.collectSensor({
|
||||
roverId: 'alpha',
|
||||
sensors: sensors({ wheelSpeedsMmPerSecond: { left: 0, right: 0, center: 0 } }),
|
||||
});
|
||||
const live = collector.getLiveState()[0].minute;
|
||||
/*
|
||||
A 14.5 V, 3.6 A discharge is 52.2 W. Two one-second intervals therefore
|
||||
consume 52.2 / 1800 Wh; the first is moving and the second stationary.
|
||||
This verifies that the collector uses odometer speed rather than deriving
|
||||
movement from commands.
|
||||
*/
|
||||
assert.ok(Math.abs(live.dischargedWh - (52.2 / 1800)) < 1e-12);
|
||||
assert.ok(Math.abs(live.movingDischargedWh - (52.2 / 3600)) < 1e-12);
|
||||
assert.ok(Math.abs(live.stationaryDischargedWh - (52.2 / 3600)) < 1e-12);
|
||||
assert.equal(live.movingMs, 1000);
|
||||
assert.equal(live.maximumSpeedMmPerSecond, 200);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('uses cumulative odometer distance without recalculating encoder movement', () => {
|
||||
const { collector } = makeHarness();
|
||||
collector.collectOdometer({ roverId: 'alpha', odometer: { totalMm: 1000, updatedAt: 4_000_000 } });
|
||||
collector.collectOdometer({ roverId: 'alpha', odometer: { totalMm: 1250, updatedAt: 4_001_000 } });
|
||||
const live = collector.getLiveState()[0].minute;
|
||||
assert.equal(live.distanceMm, 250);
|
||||
});
|
||||
|
||||
test('aggregates drive commands into minute counters instead of event noise', () => {
|
||||
const { collector, writes } = makeHarness();
|
||||
const originalNow = Date.now;
|
||||
|
||||
@@ -83,8 +83,10 @@ if (!isFeatureEnabled('fleetReports')) {
|
||||
since: Number(since) || end - 24 * 60 * 60 * 1000,
|
||||
until: end,
|
||||
roverIds: Array.isArray(roverIds) ? roverIds : undefined,
|
||||
includeEvents: true,
|
||||
eventLimit: 1000,
|
||||
// Daily Discord output is intentionally metric-only. Avoiding the event
|
||||
// query here also prevents irrelevant event volume from bloating the
|
||||
// durable daily snapshot that supports delivery idempotency.
|
||||
includeEvents: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,184 +1,235 @@
|
||||
// Fleet Report Builder
|
||||
// Purpose: Produces dense read models from stored evidence without leaking presentation logic into collection.
|
||||
// Scope: Owns totals, rover comparisons, attention findings, and exact supporting datasets for UI/Discord consumers.
|
||||
// Purpose: Produces fleet-wide battery-health and energy-efficiency read models from passive evidence.
|
||||
// Scope: Keeps estimation, confidence, and comparison policy out of collection, transport, Discord, and UI code.
|
||||
|
||||
const MINIMUM_EFFICIENCY_DISTANCE_MM = 25 * 1000;
|
||||
|
||||
function sum(rows, key) {
|
||||
return rows.reduce((total, row) => total + (Number(row?.[key]) || 0), 0);
|
||||
}
|
||||
|
||||
function groupByRover(minutes, roster = []) {
|
||||
function median(values) {
|
||||
const usable = values.map(Number).filter(Number.isFinite).sort((a, b) => a - b);
|
||||
if (!usable.length) return null;
|
||||
const middle = Math.floor(usable.length / 2);
|
||||
return usable.length % 2 ? usable[middle] : (usable[middle - 1] + usable[middle]) / 2;
|
||||
}
|
||||
|
||||
function weightedAverage(rows, valueKey, weightKey = 'sampleCount') {
|
||||
const weighted = rows.reduce((result, row) => {
|
||||
const value = Number(row[valueKey]);
|
||||
const weight = Number(row[weightKey]);
|
||||
if (!Number.isFinite(value) || !Number.isFinite(weight) || weight <= 0) return result;
|
||||
result.total += value * weight;
|
||||
result.weight += weight;
|
||||
return result;
|
||||
}, { total: 0, weight: 0 });
|
||||
return weighted.weight ? weighted.total / weighted.weight : null;
|
||||
}
|
||||
|
||||
function minimum(rows, key) {
|
||||
const values = rows.map((row) => Number(row[key])).filter(Number.isFinite);
|
||||
return values.length ? Math.min(...values) : null;
|
||||
}
|
||||
|
||||
function maximum(rows, key) {
|
||||
const values = rows.map((row) => Number(row[key])).filter(Number.isFinite);
|
||||
return values.length ? Math.max(...values) : null;
|
||||
}
|
||||
|
||||
function confidenceForObservationCount(count, averageDepthPercent) {
|
||||
/*
|
||||
Confidence is intentionally continuous evidence summarized into a label,
|
||||
not a pass/fail cycle judgment. Multiple partial observations can become
|
||||
strong evidence, while shallow observations remain visible and useful.
|
||||
*/
|
||||
if (count >= 5 && averageDepthPercent >= 25) return 'high';
|
||||
if (count >= 2 && averageDepthPercent >= 10) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function buildBatteryHealth({ rover, sessions, registryEntry }) {
|
||||
const referenceMah = Number(registryEntry?.ratedCapacityMah) || Number(rover.reportedCapacityMah) || null;
|
||||
const batteryKey = registryEntry?.batteryKey
|
||||
|| sessions[0]?.batteryKey
|
||||
|| `unregistered:${rover.roverId}`;
|
||||
const sameBattery = sessions.filter((session) => session.batteryKey === batteryKey);
|
||||
const observations = sameBattery.flatMap((session) => {
|
||||
if (session.kind !== 'discharging' || !referenceMah) return [];
|
||||
const chargeDropMah = Number(session.startChargeMah) - Number(session.endChargeMah);
|
||||
const dischargedMah = Number(session.dischargedMah);
|
||||
if (!Number.isFinite(chargeDropMah) || chargeDropMah < 100 || !Number.isFinite(dischargedMah) || dischargedMah <= 0) {
|
||||
return [];
|
||||
}
|
||||
const depthPercent = chargeDropMah / referenceMah * 100;
|
||||
/*
|
||||
Packet 25 provides the changing charge position while signed current
|
||||
supplies an independent coulomb count. Extrapolating each partial slice
|
||||
produces a capacity observation without requiring a full-to-empty run.
|
||||
Depth is retained so callers can see exactly how much evidence supports
|
||||
the estimate.
|
||||
*/
|
||||
return [{
|
||||
startedAt: session.startedAt,
|
||||
endedAt: session.endedAt,
|
||||
depthPercent,
|
||||
estimatedUsableMah: dischargedMah / (chargeDropMah / referenceMah),
|
||||
gapCount: Number(session.gapCount) || 0,
|
||||
}];
|
||||
});
|
||||
const cleanObservations = observations.filter((observation) => observation.gapCount <= 1);
|
||||
const measuredUsableMah = median(cleanObservations.map((observation) => observation.estimatedUsableMah));
|
||||
const observedChargeHighMah = maximum(rover.minutes, 'maxChargeMah');
|
||||
const observedChargeLowMah = minimum(rover.minutes, 'minChargeMah');
|
||||
const observedUsableFloorMah = observedChargeHighMah != null && observedChargeLowMah != null
|
||||
? Math.max(0, observedChargeHighMah - observedChargeLowMah)
|
||||
: null;
|
||||
const averageDepthPercent = cleanObservations.length
|
||||
? sum(cleanObservations, 'depthPercent') / cleanObservations.length
|
||||
: 0;
|
||||
const baselineMah = Number(registryEntry?.healthyBaselineMah) || referenceMah;
|
||||
const capacityRetentionPercent = measuredUsableMah && baselineMah
|
||||
? measuredUsableMah / baselineMah * 100
|
||||
: null;
|
||||
const nominalVoltageMv = rover.averageVoltageMv;
|
||||
|
||||
return {
|
||||
batteryKey,
|
||||
referenceMah,
|
||||
baselineMah,
|
||||
measuredUsableMah,
|
||||
measuredUsableWh: measuredUsableMah && nominalVoltageMv
|
||||
? measuredUsableMah * nominalVoltageMv / 1e6
|
||||
: null,
|
||||
capacityRetentionPercent,
|
||||
observedUsableFloorMah,
|
||||
observedChargeHighMah,
|
||||
observedChargeLowMah,
|
||||
observationCount: cleanObservations.length,
|
||||
averageObservationDepthPercent: averageDepthPercent,
|
||||
confidence: confidenceForObservationCount(cleanObservations.length, averageDepthPercent),
|
||||
confidenceReason: cleanObservations.length
|
||||
? `${cleanObservations.length} partial current/charge observations averaging ${averageDepthPercent.toFixed(1)}% depth`
|
||||
: 'collecting partial discharge evidence',
|
||||
dischargedThroughputMah: sum(sameBattery, 'dischargedMah'),
|
||||
latestObservationAt: cleanObservations.reduce(
|
||||
(latest, observation) => Math.max(latest, Number(observation.endedAt) || Number(observation.startedAt) || 0),
|
||||
0,
|
||||
) || null,
|
||||
observations: cleanObservations,
|
||||
};
|
||||
}
|
||||
|
||||
function groupByRover({ minutes, sessions, roster, batteryRegistry }) {
|
||||
const rosterById = new Map(roster.map((rover) => [String(rover.id), rover]));
|
||||
const grouped = new Map();
|
||||
const minuteGroups = new Map();
|
||||
minutes.forEach((minute) => {
|
||||
const roverId = String(minute.roverId);
|
||||
if (!grouped.has(roverId)) grouped.set(roverId, []);
|
||||
grouped.get(roverId).push(minute);
|
||||
if (!minuteGroups.has(roverId)) minuteGroups.set(roverId, []);
|
||||
minuteGroups.get(roverId).push(minute);
|
||||
});
|
||||
return Array.from(new Set([...rosterById.keys(), ...grouped.keys()])).map((roverId) => {
|
||||
const rows = grouped.get(roverId) || [];
|
||||
const samples = sum(rows, 'sampleCount');
|
||||
const voltageWeighted = rows.reduce(
|
||||
(total, row) => total + (Number(row.avgVoltageMv) || 0) * (Number(row.sampleCount) || 0),
|
||||
0,
|
||||
);
|
||||
const temperatureWeighted = rows.reduce(
|
||||
(total, row) => total + (Number(row.avgTemperatureC) || 0) * (Number(row.sampleCount) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return Array.from(new Set([...rosterById.keys(), ...minuteGroups.keys()])).map((roverId) => {
|
||||
const rows = minuteGroups.get(roverId) || [];
|
||||
const distanceMm = sum(rows, 'distanceMm');
|
||||
const dischargedWh = sum(rows, 'dischargedWh');
|
||||
const movingDischargedWh = sum(rows, 'movingDischargedWh');
|
||||
const movingMs = sum(rows, 'movingMs');
|
||||
const latest = rows[rows.length - 1] || null;
|
||||
return {
|
||||
const base = {
|
||||
roverId,
|
||||
name: rosterById.get(roverId)?.name || roverId,
|
||||
color: rosterById.get(roverId)?.color || null,
|
||||
online: Boolean(rosterById.get(roverId)),
|
||||
sampleCount: samples,
|
||||
minutes: rows,
|
||||
sampleCount: sum(rows, 'sampleCount'),
|
||||
coverageMs: sum(rows, 'coverageMs'),
|
||||
gapCount: sum(rows, 'gapCount'),
|
||||
commandCount: sum(rows, 'commandCount'),
|
||||
driveCommandCount: sum(rows, 'driveCommandCount'),
|
||||
rejectedCommandCount: sum(rows, 'rejectedCommandCount'),
|
||||
distanceMm: sum(rows, 'distanceMm'),
|
||||
bumpCount: sum(rows, 'bumpCount'),
|
||||
cliffCount: sum(rows, 'cliffCount'),
|
||||
wheelDropCount: sum(rows, 'wheelDropCount'),
|
||||
virtualWallCount: sum(rows, 'virtualWallCount'),
|
||||
overcurrentEpisodeCount: sum(rows, 'overcurrentEpisodeCount'),
|
||||
distanceMm,
|
||||
movingMs,
|
||||
averageSpeedMmPerSecond: movingMs ? distanceMm / (movingMs / 1000) : null,
|
||||
maximumSpeedMmPerSecond: maximum(rows, 'maximumSpeedMmPerSecond'),
|
||||
chargedMah: sum(rows, 'chargedMah'),
|
||||
dischargedMah: sum(rows, 'dischargedMah'),
|
||||
averageVoltageMv: samples ? voltageWeighted / samples : null,
|
||||
minimumVoltageMv: rows.reduce((value, row) => row.minVoltageMv == null ? value : Math.min(value ?? Infinity, row.minVoltageMv), null),
|
||||
maximumVoltageMv: rows.reduce((value, row) => row.maxVoltageMv == null ? value : Math.max(value ?? -Infinity, row.maxVoltageMv), null),
|
||||
averageTemperatureC: samples ? temperatureWeighted / samples : null,
|
||||
minimumTemperatureC: rows.reduce((value, row) => row.minTemperatureC == null ? value : Math.min(value ?? Infinity, row.minTemperatureC), null),
|
||||
maximumTemperatureC: rows.reduce((value, row) => row.maxTemperatureC == null ? value : Math.max(value ?? -Infinity, row.maxTemperatureC), null),
|
||||
chargedWh: sum(rows, 'chargedWh'),
|
||||
dischargedWh,
|
||||
movingDischargedWh,
|
||||
stationaryDischargedWh: sum(rows, 'stationaryDischargedWh'),
|
||||
overallWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
|
||||
? dischargedWh / (distanceMm / 1e6)
|
||||
: null,
|
||||
movingWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
|
||||
? movingDischargedWh / (distanceMm / 1e6)
|
||||
: null,
|
||||
efficiencyDistanceRequiredMm: Math.max(0, MINIMUM_EFFICIENCY_DISTANCE_MM - distanceMm),
|
||||
averageVoltageMv: weightedAverage(rows, 'avgVoltageMv'),
|
||||
averageCurrentMa: weightedAverage(rows, 'avgCurrentMa'),
|
||||
minimumVoltageMv: minimum(rows, 'minVoltageMv'),
|
||||
maximumVoltageMv: maximum(rows, 'maxVoltageMv'),
|
||||
averageTemperatureC: weightedAverage(rows, 'avgTemperatureC'),
|
||||
minimumTemperatureC: minimum(rows, 'minTemperatureC'),
|
||||
maximumTemperatureC: maximum(rows, 'maxTemperatureC'),
|
||||
latestChargeMah: latest?.lastChargeMah ?? null,
|
||||
reportedCapacityMah: latest?.reportedCapacityMah ?? null,
|
||||
lastSampleAt: latest ? latest.bucketTs + 60000 : null,
|
||||
};
|
||||
const registryEntry = batteryRegistry.find((battery) =>
|
||||
String(battery.roverId) === roverId && battery.retiredAt == null,
|
||||
);
|
||||
base.batteryHealth = buildBatteryHealth({
|
||||
rover: base,
|
||||
sessions: sessions.filter((session) => String(session.roverId) === roverId),
|
||||
registryEntry,
|
||||
});
|
||||
delete base.minutes;
|
||||
return base;
|
||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function buildFindings({ roverRows, events, now }) {
|
||||
const findings = [];
|
||||
function buildAttention(roverRows, now) {
|
||||
const attention = [];
|
||||
roverRows.forEach((rover) => {
|
||||
if (!rover.sampleCount) {
|
||||
findings.push({
|
||||
key: `no-telemetry:${rover.roverId}`,
|
||||
roverId: rover.roverId,
|
||||
severity: rover.online ? 'warning' : 'notice',
|
||||
confidence: 'high',
|
||||
status: 'ongoing',
|
||||
title: rover.online ? 'No battery telemetry in selected range' : 'Rover offline or absent',
|
||||
evidence: { sampleCount: 0 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (rover.maximumTemperatureC != null && rover.maximumTemperatureC >= 45) {
|
||||
findings.push({
|
||||
key: `battery-temperature:${rover.roverId}`,
|
||||
roverId: rover.roverId,
|
||||
severity: rover.maximumTemperatureC >= 50 ? 'critical' : 'warning',
|
||||
confidence: 'high',
|
||||
status: 'observed',
|
||||
title: 'High battery temperature observed',
|
||||
evidence: { maximumTemperatureC: rover.maximumTemperatureC, sampleCount: rover.sampleCount },
|
||||
});
|
||||
}
|
||||
if (rover.gapCount > 0) {
|
||||
findings.push({
|
||||
key: `telemetry-gaps:${rover.roverId}`,
|
||||
attention.push({
|
||||
key: `telemetry:${rover.roverId}`,
|
||||
roverId: rover.roverId,
|
||||
severity: 'notice',
|
||||
confidence: 'high',
|
||||
status: 'observed',
|
||||
title: 'Battery integration contains telemetry gaps',
|
||||
evidence: { gapCount: rover.gapCount, coverageMs: rover.coverageMs },
|
||||
title: rover.online ? 'Battery metrics unavailable in this range' : 'Rover was not observed in this range',
|
||||
});
|
||||
}
|
||||
if (rover.lastSampleAt && now - rover.lastSampleAt > 5 * 60 * 1000 && rover.online) {
|
||||
findings.push({
|
||||
key: `stale-telemetry:${rover.roverId}`,
|
||||
if (rover.maximumTemperatureC >= 45) {
|
||||
attention.push({
|
||||
key: `temperature:${rover.roverId}`,
|
||||
roverId: rover.roverId,
|
||||
severity: rover.maximumTemperatureC >= 50 ? 'critical' : 'warning',
|
||||
title: `Battery reached ${rover.maximumTemperatureC} °C`,
|
||||
});
|
||||
}
|
||||
if (rover.batteryHealth.capacityRetentionPercent != null
|
||||
&& rover.batteryHealth.confidence !== 'low'
|
||||
&& rover.batteryHealth.capacityRetentionPercent < 80) {
|
||||
attention.push({
|
||||
key: `capacity:${rover.roverId}`,
|
||||
roverId: rover.roverId,
|
||||
severity: rover.batteryHealth.capacityRetentionPercent < 65 ? 'critical' : 'warning',
|
||||
title: `Estimated usable capacity is ${rover.batteryHealth.capacityRetentionPercent.toFixed(1)}% of baseline`,
|
||||
});
|
||||
}
|
||||
if (rover.online && rover.lastSampleAt && now - rover.lastSampleAt > 5 * 60 * 1000) {
|
||||
attention.push({
|
||||
key: `stale:${rover.roverId}`,
|
||||
roverId: rover.roverId,
|
||||
severity: 'warning',
|
||||
confidence: 'high',
|
||||
status: 'ongoing',
|
||||
title: 'Telemetry is stale while rover is online',
|
||||
evidence: { lastSampleAt: rover.lastSampleAt },
|
||||
title: 'Battery metrics are stale',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const criticalEvents = events.filter((event) => event.severity === 'critical');
|
||||
if (criticalEvents.length) {
|
||||
findings.push({
|
||||
key: 'critical-events',
|
||||
roverId: null,
|
||||
severity: 'critical',
|
||||
confidence: 'high',
|
||||
status: 'observed',
|
||||
title: `${criticalEvents.length} critical event${criticalEvents.length === 1 ? '' : 's'} in selected range`,
|
||||
evidence: { eventIds: criticalEvents.slice(0, 25).map((event) => event.id) },
|
||||
});
|
||||
}
|
||||
const rank = { critical: 0, warning: 1, notice: 2, informational: 3 };
|
||||
return findings.sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9));
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
}
|
||||
|
||||
function buildBatteryHealth({ sessions, roverRows, batteryRegistry }) {
|
||||
return roverRows.map((rover) => {
|
||||
const roverSessions = sessions.filter((session) => String(session.roverId) === String(rover.roverId));
|
||||
const qualified = roverSessions
|
||||
.filter((session) => session.kind === 'discharging' && session.details?.capacityTestQualified)
|
||||
.sort((a, b) => a.startedAt - b.startedAt);
|
||||
// The first three qualified tests establish the learned healthy baseline.
|
||||
// A median resists one unusually light/heavy run while remaining auditable
|
||||
// in the session table. Battery replacement identity will start a separate
|
||||
// key, so only sessions for the current key should contribute once one is
|
||||
// registered.
|
||||
const activeRegistryEntry = batteryRegistry.find((battery) =>
|
||||
String(battery.roverId) === String(rover.roverId) && battery.retiredAt == null,
|
||||
);
|
||||
const currentKey = activeRegistryEntry?.batteryKey || qualified[qualified.length - 1]?.batteryKey || roverSessions[0]?.batteryKey || `unregistered:${rover.roverId}`;
|
||||
const sameBattery = qualified.filter((session) => session.batteryKey === currentKey);
|
||||
const baselineTests = sameBattery.slice(0, 3);
|
||||
const baselineMah = median(baselineTests.map((session) => Number(session.dischargedMah)));
|
||||
const latest = sameBattery[sameBattery.length - 1] || null;
|
||||
const measuredUsableMah = latest ? Number(latest.dischargedMah) : null;
|
||||
const capacityRetentionPercent = baselineMah && measuredUsableMah != null
|
||||
? measuredUsableMah / baselineMah * 100
|
||||
: null;
|
||||
const throughputMah = roverSessions.reduce((total, session) => total + (Number(session.dischargedMah) || 0), 0);
|
||||
const cycleReferenceMah = baselineMah || Number(rover.reportedCapacityMah) || null;
|
||||
return {
|
||||
roverId: rover.roverId,
|
||||
batteryKey: currentKey,
|
||||
qualifiedTestCount: sameBattery.length,
|
||||
baselineTestCount: baselineTests.length,
|
||||
baselineMah,
|
||||
measuredUsableMah,
|
||||
capacityRetentionPercent,
|
||||
equivalentFullCycles: cycleReferenceMah ? throughputMah / cycleReferenceMah : null,
|
||||
dischargedThroughputMah: throughputMah,
|
||||
latestQualifiedTestAt: latest?.endedAt || null,
|
||||
confidence: sameBattery.length >= 3 ? 'high' : sameBattery.length >= 1 ? 'medium' : 'low',
|
||||
confidenceReason: sameBattery.length >= 3
|
||||
? 'at least three qualified full-to-low tests'
|
||||
: sameBattery.length >= 1
|
||||
? 'fewer than three qualified tests'
|
||||
: 'no qualified full-to-low capacity test',
|
||||
};
|
||||
});
|
||||
const rank = { critical: 0, warning: 1, notice: 2 };
|
||||
return attention.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
||||
}
|
||||
|
||||
function createReportBuilder({ storage, collector, roverManager }) {
|
||||
function build({ since, until, roverIds, includeEvents = true, eventLimit = 500 }) {
|
||||
function build({ since, until, roverIds, includeEvents = false, eventLimit = 500 }) {
|
||||
const visibleRoster = Array.from(roverManager.rovers?.values?.() || []).map((record) => ({
|
||||
id: record.id,
|
||||
name: record.name || record.id,
|
||||
@@ -190,48 +241,47 @@ function createReportBuilder({ storage, collector, roverManager }) {
|
||||
: visibleRoster;
|
||||
const effectiveIds = requestedIds || roster.map((rover) => String(rover.id));
|
||||
const minutes = storage.listMinutes({ since, until, roverIds: effectiveIds });
|
||||
const events = includeEvents
|
||||
? storage.listEvents({ since, until, roverIds: effectiveIds, limit: eventLimit })
|
||||
: [];
|
||||
const batterySessions = storage.listBatterySessions({ since, until, roverIds: effectiveIds, limit: 500 });
|
||||
const roverRows = groupByRover(minutes, roster);
|
||||
const batterySessions = storage.listBatterySessions({ since, until, roverIds: effectiveIds, limit: 2000 });
|
||||
const batteryRegistry = storage.listBatteries(effectiveIds);
|
||||
const batteryHealth = buildBatteryHealth({ sessions: batterySessions, roverRows, batteryRegistry });
|
||||
const findings = buildFindings({ roverRows, events, now: Date.now() });
|
||||
const roverRows = groupByRover({ minutes, sessions: batterySessions, roster, batteryRegistry });
|
||||
const attention = buildAttention(roverRows, Date.now());
|
||||
const distanceMm = sum(roverRows, 'distanceMm');
|
||||
const dischargedWh = sum(roverRows, 'dischargedWh');
|
||||
const movingDischargedWh = sum(roverRows, 'movingDischargedWh');
|
||||
|
||||
return {
|
||||
generatedAt: Date.now(),
|
||||
range: { since, until },
|
||||
methodology: {
|
||||
minimumEfficiencyDistanceMm: MINIMUM_EFFICIENCY_DISTANCE_MM,
|
||||
historicalWhAvailable: false,
|
||||
},
|
||||
totals: {
|
||||
roverCount: roverRows.length,
|
||||
onlineRoverCount: roverRows.filter((rover) => rover.online).length,
|
||||
sampleCount: sum(minutes, 'sampleCount'),
|
||||
coverageMs: sum(minutes, 'coverageMs'),
|
||||
telemetryGapCount: sum(minutes, 'gapCount'),
|
||||
commandCount: sum(minutes, 'commandCount'),
|
||||
driveCommandCount: sum(minutes, 'driveCommandCount'),
|
||||
rejectedCommandCount: sum(minutes, 'rejectedCommandCount'),
|
||||
distanceMm: sum(minutes, 'distanceMm'),
|
||||
bumpCount: sum(minutes, 'bumpCount'),
|
||||
cliffCount: sum(minutes, 'cliffCount'),
|
||||
wheelDropCount: sum(minutes, 'wheelDropCount'),
|
||||
virtualWallCount: sum(minutes, 'virtualWallCount'),
|
||||
overcurrentEpisodeCount: sum(minutes, 'overcurrentEpisodeCount'),
|
||||
chargedMah: sum(minutes, 'chargedMah'),
|
||||
dischargedMah: sum(minutes, 'dischargedMah'),
|
||||
eventCountReturned: events.length,
|
||||
batterySessionCount: batterySessions.length,
|
||||
criticalFindingCount: findings.filter((finding) => finding.severity === 'critical').length,
|
||||
warningFindingCount: findings.filter((finding) => finding.severity === 'warning').length,
|
||||
distanceMm,
|
||||
movingMs: sum(roverRows, 'movingMs'),
|
||||
chargedWh: sum(roverRows, 'chargedWh'),
|
||||
dischargedWh,
|
||||
movingDischargedWh,
|
||||
stationaryDischargedWh: sum(roverRows, 'stationaryDischargedWh'),
|
||||
overallWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
|
||||
? dischargedWh / (distanceMm / 1e6)
|
||||
: null,
|
||||
movingWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
|
||||
? movingDischargedWh / (distanceMm / 1e6)
|
||||
: null,
|
||||
attentionCount: attention.filter((item) => item.severity !== 'notice').length,
|
||||
},
|
||||
rovers: roverRows,
|
||||
findings,
|
||||
minutes,
|
||||
batterySessions,
|
||||
batteryHealth,
|
||||
attention,
|
||||
batteryRegistry,
|
||||
dailyReportHistory: storage.listDailyReports(365),
|
||||
events,
|
||||
live: collector.getLiveState(),
|
||||
// Events remain available only for explicit advanced/debug consumers.
|
||||
// Neither the normal UI nor Discord requests them.
|
||||
events: includeEvents
|
||||
? storage.listEvents({ since, until, roverIds: effectiveIds, limit: eventLimit })
|
||||
: [],
|
||||
diagnostics: {
|
||||
collector: collector.getDiagnostics(),
|
||||
storage: storage.getDiagnostics(),
|
||||
@@ -243,5 +293,6 @@ function createReportBuilder({ storage, collector, roverManager }) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MINIMUM_EFFICIENCY_DISTANCE_MM,
|
||||
createReportBuilder,
|
||||
};
|
||||
|
||||
@@ -44,18 +44,11 @@ function registerSocketGateway({ roverManager, reportBuilder, storage, collector
|
||||
report.totals.eventCountReturned = report.events.length;
|
||||
}
|
||||
if (payload.compact === true) {
|
||||
// The Activities card needs current totals and findings, not the
|
||||
// underlying time series. Removing bulky evidence here keeps the
|
||||
// always-visible surface cheap while `/reports` retains full depth.
|
||||
report.minutes = [];
|
||||
// The Activities card now consumes the same all-rovers metric rows
|
||||
// as fullscreen. The builder no longer attaches minute/session
|
||||
// evidence by default, so compacting only removes archival metadata.
|
||||
report.events = [];
|
||||
report.batterySessions = [];
|
||||
report.dailyReportHistory = [];
|
||||
report.live = report.live.map((entry) => ({
|
||||
roverId: entry.roverId,
|
||||
lastAt: entry.lastAt,
|
||||
sessionKind: entry.sessionKind,
|
||||
}));
|
||||
}
|
||||
cb({ ok: true, report });
|
||||
} catch (err) {
|
||||
|
||||
@@ -63,6 +63,12 @@ function createStorage({ logger }) {
|
||||
gap_count INTEGER NOT NULL,
|
||||
charged_mah REAL NOT NULL,
|
||||
discharged_mah REAL NOT NULL,
|
||||
charged_wh REAL NOT NULL DEFAULT 0,
|
||||
discharged_wh REAL NOT NULL DEFAULT 0,
|
||||
moving_discharged_wh REAL NOT NULL DEFAULT 0,
|
||||
stationary_discharged_wh REAL NOT NULL DEFAULT 0,
|
||||
moving_ms INTEGER NOT NULL DEFAULT 0,
|
||||
maximum_speed_mm_per_second REAL,
|
||||
min_voltage_mv INTEGER,
|
||||
max_voltage_mv INTEGER,
|
||||
avg_voltage_mv REAL,
|
||||
@@ -152,6 +158,12 @@ function createStorage({ logger }) {
|
||||
['wheel_drop_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['virtual_wall_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['overcurrent_episode_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['charged_wh', 'REAL NOT NULL DEFAULT 0'],
|
||||
['discharged_wh', 'REAL NOT NULL DEFAULT 0'],
|
||||
['moving_discharged_wh', 'REAL NOT NULL DEFAULT 0'],
|
||||
['stationary_discharged_wh', 'REAL NOT NULL DEFAULT 0'],
|
||||
['moving_ms', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['maximum_speed_mm_per_second', 'REAL'],
|
||||
].forEach(([name, definition]) => {
|
||||
if (!minuteColumns.has(name)) db.exec(`ALTER TABLE fleet_minute_samples ADD COLUMN ${name} ${definition}`);
|
||||
});
|
||||
@@ -165,6 +177,8 @@ function createStorage({ logger }) {
|
||||
INSERT INTO fleet_minute_samples (
|
||||
rover_id, bucket_ts, sample_count, coverage_ms, gap_count,
|
||||
charged_mah, discharged_mah, min_voltage_mv, max_voltage_mv, avg_voltage_mv,
|
||||
charged_wh, discharged_wh, moving_discharged_wh,
|
||||
stationary_discharged_wh, moving_ms, maximum_speed_mm_per_second,
|
||||
min_current_ma, max_current_ma, avg_current_ma, min_temperature_c,
|
||||
max_temperature_c, avg_temperature_c, min_charge_mah, max_charge_mah,
|
||||
last_charge_mah, reported_capacity_mah, docked_samples, charging_samples,
|
||||
@@ -174,6 +188,8 @@ function createStorage({ logger }) {
|
||||
) VALUES (
|
||||
@roverId, @bucketTs, @sampleCount, @coverageMs, @gapCount,
|
||||
@chargedMah, @dischargedMah, @minVoltageMv, @maxVoltageMv, @avgVoltageMv,
|
||||
@chargedWh, @dischargedWh, @movingDischargedWh,
|
||||
@stationaryDischargedWh, @movingMs, @maximumSpeedMmPerSecond,
|
||||
@minCurrentMa, @maxCurrentMa, @avgCurrentMa, @minTemperatureC,
|
||||
@maxTemperatureC, @avgTemperatureC, @minChargeMah, @maxChargeMah,
|
||||
@lastChargeMah, @reportedCapacityMah, @dockedSamples, @chargingSamples,
|
||||
@@ -187,6 +203,12 @@ function createStorage({ logger }) {
|
||||
gap_count = excluded.gap_count,
|
||||
charged_mah = excluded.charged_mah,
|
||||
discharged_mah = excluded.discharged_mah,
|
||||
charged_wh = excluded.charged_wh,
|
||||
discharged_wh = excluded.discharged_wh,
|
||||
moving_discharged_wh = excluded.moving_discharged_wh,
|
||||
stationary_discharged_wh = excluded.stationary_discharged_wh,
|
||||
moving_ms = excluded.moving_ms,
|
||||
maximum_speed_mm_per_second = excluded.maximum_speed_mm_per_second,
|
||||
min_voltage_mv = excluded.min_voltage_mv,
|
||||
max_voltage_mv = excluded.max_voltage_mv,
|
||||
avg_voltage_mv = excluded.avg_voltage_mv,
|
||||
@@ -318,6 +340,11 @@ function createStorage({ logger }) {
|
||||
SELECT rover_id AS roverId, bucket_ts AS bucketTs, sample_count AS sampleCount,
|
||||
coverage_ms AS coverageMs, gap_count AS gapCount, charged_mah AS chargedMah,
|
||||
discharged_mah AS dischargedMah, min_voltage_mv AS minVoltageMv,
|
||||
charged_wh AS chargedWh, discharged_wh AS dischargedWh,
|
||||
moving_discharged_wh AS movingDischargedWh,
|
||||
stationary_discharged_wh AS stationaryDischargedWh,
|
||||
moving_ms AS movingMs,
|
||||
maximum_speed_mm_per_second AS maximumSpeedMmPerSecond,
|
||||
max_voltage_mv AS maxVoltageMv, avg_voltage_mv AS avgVoltageMv,
|
||||
min_current_ma AS minCurrentMa, max_current_ma AS maxCurrentMa,
|
||||
avg_current_ma AS avgCurrentMa, min_temperature_c AS minTemperatureC,
|
||||
|
||||
Reference in New Issue
Block a user