slopreporting

This commit is contained in:
legop3
2026-07-25 15:54:38 -04:00
parent 3a26b4871a
commit 8642fbac3c
21 changed files with 707 additions and 555 deletions
@@ -1 +0,0 @@
.uplot,.uplot *,.uplot *:before,.uplot *:after{box-sizing:border-box}.uplot{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";line-height:1.5;width:-moz-min-content;width:min-content}.u-title{text-align:center;font-size:18px;font-weight:700}.u-wrap{position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none}.u-over,.u-under{position:absolute}.u-under{overflow:hidden}.uplot canvas{display:block;position:relative;width:100%;height:100%}.u-axis{position:absolute}.u-legend{font-size:14px;margin:auto;text-align:center}.u-inline{display:block}.u-inline *{display:inline-block}.u-inline tr{margin-right:16px}.u-legend th{font-weight:600}.u-legend th>*{vertical-align:middle;display:inline-block}.u-legend .u-marker{width:1em;height:1em;margin-right:4px;background-clip:padding-box!important}.u-inline.u-live th:after{content:":";vertical-align:middle}.u-inline:not(.u-live) .u-value{display:none}.u-series>*{padding:4px}.u-series th{cursor:pointer}.u-legend .u-off>*{opacity:.3}.u-select{background:#00000012;position:absolute;pointer-events:none}.u-cursor-x,.u-cursor-y{position:absolute;left:0;top:0;pointer-events:none;will-change:transform}.u-hz .u-cursor-x,.u-vt .u-cursor-y{height:100%;border-right:1px dashed #607D8B}.u-hz .u-cursor-y,.u-vt .u-cursor-x{width:100%;border-bottom:1px dashed #607D8B}.u-cursor-pt{position:absolute;top:0;left:0;border-radius:50%;border:0 solid;pointer-events:none;will-change:transform;background-clip:padding-box!important}.u-axis.u-off,.u-select.u-off,.u-cursor-x.u-off,.u-cursor-y.u-off,.u-cursor-pt.u-off{display:none}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DynS6sFA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-L6xBNcuq.css">
<script type="module" crossorigin src="/assets/index-sHjOwTuH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BvKlDyS5.css">
</head>
<body>
<div id="root"></div>
@@ -2,7 +2,7 @@
// Purpose: Schedules and delivers completed-day fleet summaries to the existing admin alert channel.
// Scope: Discord owns timing/formatting/delivery; the fleet service owns evidence, analysis, and durable delivery state.
const { DateTime } = require('luxon');
const { AttachmentBuilder, EmbedBuilder } = require('discord.js');
const { EmbedBuilder } = require('discord.js');
function parseSendTime(value) {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value || '').trim());
@@ -54,24 +54,34 @@ function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetRepo
function buildEmbed(reportDate, report) {
const totals = report.totals;
const attention = report.findings.slice(0, 12).map((finding) =>
`${finding.roverId ? `${finding.roverId}: ` : ''}${finding.title} (${finding.severity}, ${finding.confidence} confidence)`,
).join('\n') || 'No report findings.';
const roverLines = report.rovers.map((rover) =>
`${rover.name}: ${formatNumber(rover.dischargedMah)} mAh used, ${formatNumber(rover.chargedMah)} mAh charged, ${formatNumber(rover.sampleCount, 0)} samples, ${formatNumber(rover.gapCount, 0)} gaps`,
const attention = report.attention
.filter((item) => item.severity !== 'notice')
.slice(0, 12)
.map((item) => `${item.roverId}: ${item.title}`)
.join('\n') || 'No material battery or efficiency changes need attention.';
const roverLines = report.rovers.map((rover) => {
const health = rover.batteryHealth || {};
const efficiency = rover.overallWhPerKm == null
? `efficiency pending (${formatNumber(rover.distanceMm / 1000, 0)} m)`
: `${formatNumber(rover.overallWhPerKm)} Wh/km`;
const capacity = health.measuredUsableMah == null
? `health collecting (${health.confidence || 'low'} confidence)`
: `${formatNumber(health.measuredUsableMah / 1000, 2)} Ah usable · ${formatNumber(health.capacityRetentionPercent)}% retained · ${health.confidence} confidence`;
return `${rover.name}: ${formatNumber(rover.distanceMm / 1e6, 2)} km · ${formatNumber(rover.dischargedWh, 2)} Wh · ${efficiency}\n Battery: ${capacity}`;
}
).join('\n') || 'No public rover telemetry.';
return new EmbedBuilder()
.setTitle(`Daily fleet report — ${reportDate}`)
.setColor(totals.criticalFindingCount ? 0xe53935 : totals.warningFindingCount ? 0xf0b651 : 0x4caf50)
.setColor(totals.attentionCount ? 0xf0b651 : 0x4caf50)
.addFields(
{
name: 'Fleet totals',
value: `${totals.onlineRoverCount}/${totals.roverCount} online · ${formatNumber(totals.sampleCount, 0)} samples · ${formatNumber(totals.dischargedMah)} mAh used · ${formatNumber(totals.chargedMah)} mAh charged · ${formatNumber(totals.telemetryGapCount, 0)} gaps`,
name: 'Fleet energy',
value: `${formatNumber(totals.distanceMm / 1e6, 2)} km · ${formatNumber(totals.dischargedWh, 2)} Wh · ${totals.overallWhPerKm == null ? 'efficiency pending' : `${formatNumber(totals.overallWhPerKm)} Wh/km`} · ${formatNumber(totals.stationaryDischargedWh, 2)} stationary Wh`,
},
{ name: 'Needs attention', value: attention.slice(0, 1024) },
{ name: 'Rovers', value: roverLines.slice(0, 1024) },
)
.setFooter({ text: 'Detailed read-only evidence is available on the server reports page.' });
.setFooter({ text: 'The server reports page contains the complete all-rovers metric table.' });
}
async function deliverPreviousDay() {
@@ -79,31 +89,23 @@ function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetRepo
const range = completedDayRange();
const existing = fleetReportService.storage.getDailyReport(range.reportDate);
if (existing?.discordDeliveredAt) return;
const report = existing?.report || fleetReportService.getDailyReport({
const report = fleetReportService.getDailyReport({
since: range.since,
until: range.until,
roverIds: publicRoverIds(),
});
if (!report) return;
// Lockdown-only records and raw event payloads do not belong in a shared
// Discord attachment. The interactive server UI applies per-socket access
// and remains the place for complete event evidence.
const attachmentReport = {
...report,
events: report.events.filter((event) => event.visibility !== 'lockdown').map(({ payload, ...event }) => ({
...event,
payload,
})),
};
fleetReportService.storage.saveDailyReport(range.reportDate, attachmentReport);
const attachment = new AttachmentBuilder(
Buffer.from(JSON.stringify(attachmentReport, null, 2)),
{ name: `fleet-report-${range.reportDate}.json` },
);
/*
Daily storage retains the exact metric report used for delivery, but the
Discord message intentionally has no raw JSON attachment. Admins need
actionable fleet comparisons here; the complete read-only evidence stays
on the reports page without turning routine events into notification noise.
*/
fleetReportService.storage.saveDailyReport(range.reportDate, report);
const sent = await sendToChannel(
channelId,
`Daily fleet report for ${range.reportDate}`,
{ embeds: [buildEmbed(range.reportDate, report)], files: [attachment] },
{ embeds: [buildEmbed(range.reportDate, report)] },
{ parse: [] },
);
if (sent) {
@@ -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,