mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
slopreporting
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# Simple spectator bot
|
||||
|
||||
A spectator bot connects to the rover server with Socket.IO. It can receive the current session, read chat, and send messages that are visually tagged as bot messages.
|
||||
|
||||
## Install
|
||||
|
||||
Create a small Node.js project and install the Socket.IO client:
|
||||
|
||||
```bash
|
||||
npm install socket.io-client
|
||||
```
|
||||
|
||||
## Example bot
|
||||
|
||||
Create `bot.js`:
|
||||
|
||||
```js
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
// Replace this with the public URL of the MultiRoombaRover server.
|
||||
const socket = io('https://your-rover-server.example', {
|
||||
// Match the transports supported by the server while retaining polling as a
|
||||
// fallback for networks or proxies that do not allow WebSocket connections.
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
// Socket.IO acknowledgements use callbacks. This small wrapper turns them into
|
||||
// promises so setup failures and rejected chat messages are easy to handle.
|
||||
function emitWithAck(event, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.emit(event, payload, (response = {}) => {
|
||||
if (response.error) {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('connect', async () => {
|
||||
console.log('Connected:', socket.id);
|
||||
|
||||
try {
|
||||
// Set the name that will appear beside this connection and its messages.
|
||||
await emitWithAck('nickname:set', {
|
||||
nickname: 'My spectator bot',
|
||||
});
|
||||
|
||||
// Ask the server to make this passive connection a spectator. Performing
|
||||
// this after every connection also restores the role after a reconnect.
|
||||
await emitWithAck('session:setRole', {
|
||||
role: 'spectator',
|
||||
});
|
||||
|
||||
console.log('Connected as a spectator');
|
||||
} catch (error) {
|
||||
console.error('Spectator setup failed:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Each session:sync event is a complete current session snapshot. Replace any
|
||||
// previously stored session with this object instead of merging snapshots.
|
||||
socket.on('session:sync', (session) => {
|
||||
console.log('Session:', session);
|
||||
});
|
||||
|
||||
// chat:init contains the recent chat history available when the bot connects.
|
||||
socket.on('chat:init', (messages) => {
|
||||
console.log('Recent chat:', messages);
|
||||
});
|
||||
|
||||
// chat:message fires whenever a new message is broadcast, including messages
|
||||
// sent by this bot itself.
|
||||
socket.on('chat:message', (message) => {
|
||||
console.log(`${message.nickname || 'Unknown'}: ${message.text}`);
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('Disconnected:', reason);
|
||||
});
|
||||
|
||||
// Setting bot to true adds the normal bot tag to the displayed chat message.
|
||||
// It does not grant the connection any additional permissions.
|
||||
function sendBotMessage(text) {
|
||||
return emitWithAck('chat:send', {
|
||||
text,
|
||||
bot: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Send one example message after the connection has had time to finish setup.
|
||||
// A real bot would call sendBotMessage from its own message-handling logic.
|
||||
setTimeout(() => {
|
||||
sendBotMessage('Hello from my spectator bot!').catch((error) => {
|
||||
console.error('Message failed:', error.message);
|
||||
});
|
||||
}, 5000);
|
||||
```
|
||||
|
||||
Run it with:
|
||||
|
||||
```bash
|
||||
node bot.js
|
||||
```
|
||||
|
||||
## Events used
|
||||
|
||||
- `nickname:set` sets the bot's visible nickname.
|
||||
- `session:setRole` changes the connection to a spectator.
|
||||
- `session:sync` provides the latest complete session state.
|
||||
- `chat:init` provides recent chat history after connecting.
|
||||
- `chat:message` provides new chat messages.
|
||||
- `chat:send` sends a chat message. Include `bot: true` to give it the bot tag.
|
||||
|
||||
The server can reject spectator access or a chat message. Always check the acknowledgement callback, as the example does, so those errors are not silently ignored.
|
||||
@@ -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
+1
-1
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
1. assign rovers based on battery percentage, give people highest one
|
||||
2. add admin ui for VIP and private requests instead of only through discord
|
||||
3. add flag in roverd for video aspect ratio
|
||||
2. bandwidth savings option for videoplayer invisible disconnecting
|
||||
3. setting to disable replay popups in spectator settings menu
|
||||
4. add admin ui for VIP and private requests instead of only through discord
|
||||
5. add flag in roverd for video aspect ratio
|
||||
1. maybe dont? whats the point anyway? why do we exist at all? is there purpose to life?
|
||||
1. just removing the black bars, doesnt do anything practical for the driver page
|
||||
2. would only actually help for keeping spectate page compact
|
||||
@@ -9,7 +11,7 @@
|
||||
3. default is 4:3
|
||||
4. all it does is tell the web UI to make the rover video 16:9 or 4:3 shaped
|
||||
1. web UI should default to 4:3 if that rover doesnt yet have that config yet
|
||||
4. fix this:
|
||||
6. fix this:
|
||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Fleet Reports Card
|
||||
// Purpose: Shows a dense current fleet summary at the bottom of the Activities tab.
|
||||
// Scope: Self-gates from session.features and links to the dedicated read-only fullscreen report.
|
||||
// Purpose: Shows every rover's key battery and efficiency metrics at the bottom of Activities.
|
||||
// Scope: Self-gates through session.features and links to the all-rovers fullscreen report.
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
@@ -8,34 +8,19 @@ import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import useFleetReport from '../../hooks/useFleetReport.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function formatMah(value) {
|
||||
return Number.isFinite(Number(value)) ? `${Math.round(Number(value))} mAh` : '--';
|
||||
function value(number, digits = 1) {
|
||||
return Number.isFinite(Number(number))
|
||||
? Number(number).toLocaleString(undefined, { maximumFractionDigits: digits })
|
||||
: '--';
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
const minutes = Math.round((Number(ms) || 0) / 60000);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
function formatDistance(mm) {
|
||||
const value = Number(mm) || 0;
|
||||
return value >= 1000000 ? `${(value / 1000000).toFixed(2)} km` : `${(value / 1000).toFixed(1)} m`;
|
||||
}
|
||||
|
||||
function Metric({ label, value }) {
|
||||
return (
|
||||
<div className="surface flex min-w-0 items-center justify-between gap-1 px-1 py-0.5 text-sm">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<span className="truncate text-right font-medium text-slate-100">{value}</span>
|
||||
</div>
|
||||
);
|
||||
function distance(millimeters) {
|
||||
return Number(millimeters) >= 1e6
|
||||
? `${value(Number(millimeters) / 1e6, 2)} km`
|
||||
: `${value(Number(millimeters) / 1000, 1)} m`;
|
||||
}
|
||||
|
||||
function EnabledFleetReportsCard() {
|
||||
// State gives each request a stable range endpoint while allowing the user
|
||||
// to advance the 24-hour window explicitly without calling time APIs during
|
||||
// render.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const { report, loading, error } = useFleetReport({
|
||||
since: now - 24 * 60 * 60 * 1000,
|
||||
@@ -43,60 +28,30 @@ function EnabledFleetReportsCard() {
|
||||
compact: true,
|
||||
includeEvents: false,
|
||||
});
|
||||
|
||||
const actions = (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button type="button" className="button-dark px-1 py-0.25 text-[0.75rem]" onClick={() => setNow(Date.now())}>Refresh</button>
|
||||
<Link className="button-dark px-1 py-0.25 text-[0.75rem]" to="/reports">Open full report</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<CardFrame title="Fleet report" meta="Last 24 hours" actions={actions} bodyClassName="space-y-0.5 p-0.5 text-sm">
|
||||
{loading && !report ? <p className="text-slate-400">Loading fleet report…</p> : null}
|
||||
<CardFrame
|
||||
title="Fleet battery and efficiency"
|
||||
meta="Last 24 hours"
|
||||
actions={<div className="flex gap-0.5"><button type="button" className="button-dark px-1 py-0.25 text-[0.75rem]" onClick={() => setNow(Date.now())}>Refresh</button><Link className="button-dark px-1 py-0.25 text-[0.75rem]" to="/reports">Open full report</Link></div>}
|
||||
bodyClassName="space-y-0.5 p-0.5 text-sm"
|
||||
>
|
||||
{loading && !report ? <p className="text-slate-400">Loading fleet metrics…</p> : null}
|
||||
{error ? <p className="text-red-300">{error}</p> : null}
|
||||
{report ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-4">
|
||||
<Metric label="Online" value={`${report.totals.onlineRoverCount}/${report.totals.roverCount}`} />
|
||||
<Metric label="Coverage" value={formatDuration(report.totals.coverageMs)} />
|
||||
<Metric label="Discharged" value={formatMah(report.totals.dischargedMah)} />
|
||||
<Metric label="Charged" value={formatMah(report.totals.chargedMah)} />
|
||||
<Metric label="Sensor samples" value={report.totals.sampleCount.toLocaleString()} />
|
||||
<Metric label="Telemetry gaps" value={report.totals.telemetryGapCount.toLocaleString()} />
|
||||
<Metric label="Distance" value={formatDistance(report.totals.distanceMm)} />
|
||||
<Metric label="Overcurrent episodes" value={report.totals.overcurrentEpisodeCount.toLocaleString()} />
|
||||
<Metric label="Warnings" value={report.totals.warningFindingCount.toLocaleString()} />
|
||||
<Metric label="Critical" value={report.totals.criticalFindingCount.toLocaleString()} />
|
||||
</div>
|
||||
{report.findings.length ? (
|
||||
<div className="surface space-y-0.5 px-1 py-0.5">
|
||||
<p className="text-xs font-semibold text-slate-200">Needs attention</p>
|
||||
{report.findings.slice(0, 5).map((finding) => (
|
||||
<div key={finding.key} className="flex items-start justify-between gap-1 text-xs">
|
||||
<span className="text-slate-200">{finding.roverId ? `${finding.roverId}: ` : ''}{finding.title}</span>
|
||||
<span className={finding.severity === 'critical' ? 'text-red-300' : finding.severity === 'warning' ? 'text-amber-300' : 'text-slate-400'}>
|
||||
{finding.severity}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="surface px-1 py-0.5 text-xs text-emerald-300">No report findings in this range.</p>}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>State</th><th>Samples</th><th>Used</th><th>Temp max</th><th>Gaps</th></tr></thead>
|
||||
<tbody>
|
||||
{report.rovers.map((rover) => (
|
||||
<tr key={rover.roverId} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td>{rover.name}</td><td>{rover.online ? 'online' : 'offline'}</td>
|
||||
<td>{rover.sampleCount.toLocaleString()}</td><td>{formatMah(rover.dischargedMah)}</td>
|
||||
<td>{rover.maximumTemperatureC == null ? '--' : `${rover.maximumTemperatureC}°C`}</td><td>{rover.gapCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full whitespace-nowrap text-left text-xs">
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>State</th><th>Health</th><th>Confidence</th><th>Wh/km</th><th>Distance</th><th>Used</th><th>Temperature</th></tr></thead>
|
||||
<tbody>{report.rovers.map((rover) => (
|
||||
<tr key={rover.roverId} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td>{rover.name}</td><td>{rover.online ? 'online' : 'offline'}</td>
|
||||
<td>{rover.batteryHealth.capacityRetentionPercent == null ? '--' : `${value(rover.batteryHealth.capacityRetentionPercent)}%`}</td>
|
||||
<td>{rover.batteryHealth.confidence}</td><td>{value(rover.overallWhPerKm)}</td>
|
||||
<td>{distance(rover.distanceMm)}</td><td>{value(rover.dischargedWh, 2)} Wh</td>
|
||||
<td>{value(rover.maximumTemperatureC)} °C</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
);
|
||||
@@ -104,9 +59,10 @@ function EnabledFleetReportsCard() {
|
||||
|
||||
export default function FleetReportsCard() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'fleetReports'));
|
||||
// The outer component owns the optional feature gate, while the enabled
|
||||
// child owns data hooks. This avoids opening report socket requests at all
|
||||
// when the server has disabled the feature and still lets layout stacks stay
|
||||
// completely unaware of feature branching.
|
||||
/*
|
||||
Keeping the hook inside the enabled child ensures a disabled optional
|
||||
feature creates no socket traffic and leaves the Activities layout unaware
|
||||
of reporting internals.
|
||||
*/
|
||||
return enabled ? <EnabledFleetReportsCard /> : null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Fullscreen Fleet Reports Application
|
||||
// Purpose: Presents deep read-only operational history using the same global cards, surfaces, theme, and spacing as the driver page.
|
||||
// Scope: Owns report-range controls, dense evidence tables, chart selection, CSV export, and route-level feature handling.
|
||||
// Fullscreen Fleet Energy Report
|
||||
// Purpose: Shows every rover together in one dense, read-only battery-health and efficiency workspace.
|
||||
// Scope: Uses existing CardFrame, surface, button, page-theme, and spacing globals; intentionally contains no charts.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Papa from 'papaparse';
|
||||
@@ -13,7 +13,6 @@ import useFleetReport from '../hooks/useFleetReport.js';
|
||||
import { isFeatureEnabled } from '../lib/features.js';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass, themeGapClass } from '../themes/index.js';
|
||||
import FleetTimeSeriesChart from './FleetTimeSeriesChart.jsx';
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: '24 hours', ms: 24 * 60 * 60 * 1000 },
|
||||
@@ -23,39 +22,43 @@ const RANGE_OPTIONS = [
|
||||
{ label: '1 year', ms: 365 * 24 * 60 * 60 * 1000 },
|
||||
];
|
||||
|
||||
const METRICS = [
|
||||
['dischargedMah', 'Discharged mAh'],
|
||||
['chargedMah', 'Charged mAh'],
|
||||
['avgVoltageMv', 'Average voltage'],
|
||||
['avgCurrentMa', 'Average current'],
|
||||
['avgTemperatureC', 'Average temperature'],
|
||||
];
|
||||
|
||||
function number(value, digits = 2) {
|
||||
return value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value))
|
||||
return value !== null && value !== undefined && Number.isFinite(Number(value))
|
||||
? Number(value).toLocaleString(undefined, { maximumFractionDigits: digits })
|
||||
: '--';
|
||||
}
|
||||
|
||||
function timestamp(value) {
|
||||
return value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value))
|
||||
? new Date(Number(value)).toLocaleString()
|
||||
: '--';
|
||||
return Number.isFinite(Number(value)) ? new Date(Number(value)).toLocaleString() : '--';
|
||||
}
|
||||
|
||||
function duration(value) {
|
||||
const minutes = Math.round((Number(value) || 0) / 60000);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||||
return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`;
|
||||
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
function distance(value) {
|
||||
const mm = Number(value) || 0;
|
||||
return mm >= 1000000 ? `${number(mm / 1000000)} km` : `${number(mm / 1000)} m`;
|
||||
const millimeters = Number(value) || 0;
|
||||
return millimeters >= 1e6 ? `${number(millimeters / 1e6)} km` : `${number(millimeters / 1000, 1)} m`;
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail = null }) {
|
||||
function exportCsv(rows) {
|
||||
// Papa Parse owns quoting and escaping so new metric columns cannot silently
|
||||
// corrupt exports when rover names or confidence explanations contain commas.
|
||||
const csv = Papa.unparse(rows.map((rover) => ({
|
||||
...rover,
|
||||
batteryHealth: JSON.stringify(rover.batteryHealth),
|
||||
})));
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = 'fleet-battery-efficiency.csv';
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail }) {
|
||||
return (
|
||||
<div className="surface min-w-0 px-1 py-0.5">
|
||||
<div className="text-[0.68rem] text-slate-400">{label}</div>
|
||||
@@ -65,23 +68,13 @@ function Metric({ label, value, detail = null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function exportCsv(name, rows) {
|
||||
// Papa Parse owns CSV quoting and nested-value escaping. JSON-stringifying
|
||||
// object cells preserves evidence without maintaining a fragile custom CSV
|
||||
// serializer for arbitrary structured event payloads.
|
||||
const normalized = rows.map((row) => Object.fromEntries(
|
||||
Object.entries(row).map(([key, value]) => [key, value && typeof value === 'object' ? JSON.stringify(value) : value]),
|
||||
));
|
||||
const csv = Papa.unparse(normalized);
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = name;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
function SortHeading({ field, sort, onSort, children }) {
|
||||
// Sorting is an explicit table interaction rather than hidden column state;
|
||||
// the arrow tells users which fleet comparison currently controls row order.
|
||||
return <th><button type="button" className="whitespace-nowrap text-left" onClick={() => onSort(field)}>{children}{sort.key === field ? (sort.descending ? ' ↓' : ' ↑') : ''}</button></th>;
|
||||
}
|
||||
|
||||
function BatteryRegistryPanel({ report, onSaved }) {
|
||||
function BatteryRegistry({ report, refresh }) {
|
||||
const socket = useSocket();
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const canEdit = role === 'admin' || role === 'lockdown';
|
||||
@@ -94,48 +87,43 @@ function BatteryRegistryPanel({ report, onSaved }) {
|
||||
});
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
const submit = (event) => {
|
||||
function submit(event) {
|
||||
event.preventDefault();
|
||||
setStatus('Saving…');
|
||||
socket.emit('fleetReports:replaceBattery', {
|
||||
roverId: draft.roverId,
|
||||
chemistry: draft.chemistry,
|
||||
...draft,
|
||||
ratedCapacityMah: Number(draft.ratedCapacityMah),
|
||||
installedAt: new Date(`${draft.installedDate}T12:00:00`).getTime(),
|
||||
notes: draft.notes,
|
||||
}, (response = {}) => {
|
||||
if (response.error) {
|
||||
setStatus(response.error);
|
||||
return;
|
||||
}
|
||||
setStatus(`Registered ${response.battery?.batteryKey || 'battery'}`);
|
||||
onSaved();
|
||||
setStatus(response.error || `Registered ${response.battery?.batteryKey || 'battery'}`);
|
||||
if (!response.error) refresh();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<CardFrame title="Physical battery registry" meta={`${report.batteryRegistry.length} historical records`} bodyClassName="space-y-0.5 p-0.5">
|
||||
<CardFrame title="Battery identity and baselines" meta={`${report.batteryRegistry.length} records`} bodyClassName="space-y-0.5 p-0.5">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full whitespace-nowrap text-left text-xs">
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>Battery identity</th><th>Chemistry</th><th>Rated capacity mAh</th><th>Installed</th><th>Retired</th><th>Learned baseline mAh</th><th>Notes</th></tr></thead>
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>Battery</th><th>Chemistry</th><th>Rated mAh</th><th>Installed</th><th>Retired</th><th>Learned baseline mAh</th><th>Notes</th></tr></thead>
|
||||
<tbody>{report.batteryRegistry.map((battery) => (
|
||||
<tr key={battery.batteryKey} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td>{battery.roverId}</td><td>{battery.batteryKey}</td><td>{battery.chemistry || '--'}</td><td>{number(battery.ratedCapacityMah, 0)}</td>
|
||||
<td>{timestamp(battery.installedAt)}</td><td>{timestamp(battery.retiredAt)}</td><td>{number(battery.healthyBaselineMah)}</td><td>{battery.notes || '--'}</td>
|
||||
<td>{battery.roverId}</td><td>{battery.batteryKey}</td><td>{battery.chemistry || '--'}</td>
|
||||
<td>{number(battery.ratedCapacityMah, 0)}</td><td>{timestamp(battery.installedAt)}</td>
|
||||
<td>{timestamp(battery.retiredAt)}</td><td>{number(battery.healthyBaselineMah, 0)}</td><td>{battery.notes || '--'}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{canEdit ? (
|
||||
<form className="surface grid gap-0.5 p-1 text-xs md:grid-cols-2 xl:grid-cols-6" onSubmit={submit}>
|
||||
<label className="space-y-0.5"><span className="text-slate-400">Rover</span><select className="w-full bg-neutral-800 p-0.5" value={draft.roverId} onChange={(event) => setDraft((current) => ({ ...current, roverId: event.target.value }))}>{report.rovers.map((rover) => <option key={rover.roverId} value={rover.roverId}>{rover.name}</option>)}</select></label>
|
||||
<label className="space-y-0.5"><span className="text-slate-400">Chemistry</span><select className="w-full bg-neutral-800 p-0.5" value={draft.chemistry} onChange={(event) => setDraft((current) => ({ ...current, chemistry: event.target.value }))}><option value="unknown">Unknown</option><option value="NiMH">NiMH</option><option value="Li-ion">Li-ion</option></select></label>
|
||||
<label className="space-y-0.5"><span className="text-slate-400">Rated capacity mAh</span><input required min="1" max="65535" type="number" className="w-full bg-neutral-800 p-0.5" value={draft.ratedCapacityMah} onChange={(event) => setDraft((current) => ({ ...current, ratedCapacityMah: event.target.value }))} /></label>
|
||||
<label className="space-y-0.5"><span className="text-slate-400">Installation date</span><input required type="date" className="w-full bg-neutral-800 p-0.5" value={draft.installedDate} onChange={(event) => setDraft((current) => ({ ...current, installedDate: event.target.value }))} /></label>
|
||||
<label className="space-y-0.5"><span className="text-slate-400">Notes</span><input className="w-full bg-neutral-800 p-0.5" value={draft.notes} onChange={(event) => setDraft((current) => ({ ...current, notes: event.target.value }))} /></label>
|
||||
<div className="flex items-end gap-0.5"><button type="submit" className="button-dark px-1 py-0.5">Install/replace battery</button><span className="text-slate-400">{status}</span></div>
|
||||
<label><span className="text-slate-400">Rover</span><select className="w-full bg-neutral-800 p-0.5" value={draft.roverId} onChange={(event) => setDraft((value) => ({ ...value, roverId: event.target.value }))}>{report.rovers.map((rover) => <option key={rover.roverId} value={rover.roverId}>{rover.name}</option>)}</select></label>
|
||||
<label><span className="text-slate-400">Chemistry</span><select className="w-full bg-neutral-800 p-0.5" value={draft.chemistry} onChange={(event) => setDraft((value) => ({ ...value, chemistry: event.target.value }))}><option value="unknown">Unknown</option><option value="NiMH">NiMH</option><option value="Li-ion">Li-ion</option></select></label>
|
||||
<label><span className="text-slate-400">Rated capacity mAh</span><input required min="1" max="65535" type="number" className="w-full bg-neutral-800 p-0.5" value={draft.ratedCapacityMah} onChange={(event) => setDraft((value) => ({ ...value, ratedCapacityMah: event.target.value }))} /></label>
|
||||
<label><span className="text-slate-400">Installed</span><input required type="date" className="w-full bg-neutral-800 p-0.5" value={draft.installedDate} onChange={(event) => setDraft((value) => ({ ...value, installedDate: event.target.value }))} /></label>
|
||||
<label><span className="text-slate-400">Notes</span><input className="w-full bg-neutral-800 p-0.5" value={draft.notes} onChange={(event) => setDraft((value) => ({ ...value, notes: event.target.value }))} /></label>
|
||||
<div className="flex items-end gap-0.5"><button type="submit" className="button-dark px-1 py-0.5">Install or replace</button><span className="text-slate-400">{status}</span></div>
|
||||
</form>
|
||||
) : <p className="surface px-1 py-0.5 text-xs text-slate-400">Battery history is read-only. Admin access is required to register a physical replacement.</p>}
|
||||
) : null}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -143,200 +131,125 @@ function BatteryRegistryPanel({ report, onSaved }) {
|
||||
function FullReportContent() {
|
||||
const [rangeMs, setRangeMs] = useState(RANGE_OPTIONS[1].ms);
|
||||
const [rangeEnd, setRangeEnd] = useState(() => Date.now());
|
||||
const [metric, setMetric] = useState('dischargedMah');
|
||||
const [selectedRovers, setSelectedRovers] = useState([]);
|
||||
const since = rangeEnd - rangeMs;
|
||||
const [query, setQuery] = useState('');
|
||||
const [sort, setSort] = useState({ key: 'name', descending: false });
|
||||
const { report, loading, error, refresh } = useFleetReport({
|
||||
since,
|
||||
since: rangeEnd - rangeMs,
|
||||
until: rangeEnd,
|
||||
compact: false,
|
||||
includeEvents: true,
|
||||
roverIds: selectedRovers.length ? selectedRovers : null,
|
||||
includeEvents: false,
|
||||
});
|
||||
|
||||
const chartRoverIds = useMemo(() => report?.rovers.map((rover) => rover.roverId) || [], [report]);
|
||||
const toggleRover = (roverId) => {
|
||||
setSelectedRovers((current) => current.includes(roverId)
|
||||
? current.filter((id) => id !== roverId)
|
||||
: [...current, roverId]);
|
||||
};
|
||||
const rows = useMemo(() => {
|
||||
const filtered = (report?.rovers || []).filter((rover) =>
|
||||
`${rover.name} ${rover.roverId}`.toLowerCase().includes(query.trim().toLowerCase()),
|
||||
);
|
||||
return [...filtered].sort((left, right) => {
|
||||
const leftValue = sort.key.startsWith('batteryHealth.')
|
||||
? left.batteryHealth?.[sort.key.slice('batteryHealth.'.length)]
|
||||
: left[sort.key];
|
||||
const rightValue = sort.key.startsWith('batteryHealth.')
|
||||
? right.batteryHealth?.[sort.key.slice('batteryHealth.'.length)]
|
||||
: right[sort.key];
|
||||
const result = typeof leftValue === 'string'
|
||||
? leftValue.localeCompare(String(rightValue ?? ''))
|
||||
: (Number(leftValue) || 0) - (Number(rightValue) || 0);
|
||||
return sort.descending ? -result : result;
|
||||
});
|
||||
}, [query, report, sort]);
|
||||
|
||||
function sortBy(key) {
|
||||
setSort((current) => ({ key, descending: current.key === key ? !current.descending : false }));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardFrame
|
||||
title="Fleet reports"
|
||||
meta={report ? `Generated ${timestamp(report.generatedAt)}` : 'Read-only operational history'}
|
||||
actions={<Link className="button-dark px-1 py-0.25 text-[0.75rem]" to="/">Back to rover page</Link>}
|
||||
bodyClassName="space-y-0.5 p-0.5 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
type="button"
|
||||
className={`button-dark px-1 py-0.5 text-xs ${rangeMs === option.ms ? 'text-cyan-200' : ''}`}
|
||||
onClick={() => { setRangeMs(option.ms); setRangeEnd(Date.now()); }}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="button-dark px-1 py-0.5 text-xs" onClick={() => setRangeEnd(Date.now())}>Refresh</button>
|
||||
<span className="text-xs text-slate-400">{timestamp(since)} through {timestamp(rangeEnd)}</span>
|
||||
<CardFrame title="Fleet battery health and efficiency" meta={report ? timestamp(report.generatedAt) : 'Loading'} bodyClassName="space-y-0.5 p-0.5">
|
||||
<div className="surface flex flex-wrap items-end gap-0.5 p-0.5 text-xs">
|
||||
<label><span className="mr-0.5 text-slate-400">Range</span><select className="bg-neutral-800 p-0.5" value={rangeMs} onChange={(event) => setRangeMs(Number(event.target.value))}>{RANGE_OPTIONS.map((option) => <option key={option.ms} value={option.ms}>{option.label}</option>)}</select></label>
|
||||
<label><span className="mr-0.5 text-slate-400">Find rover</span><input className="bg-neutral-800 p-0.5" value={query} onChange={(event) => setQuery(event.target.value)} /></label>
|
||||
<button type="button" className="button-dark px-1 py-0.5" onClick={() => { setRangeEnd(Date.now()); refresh(); }}>Refresh</button>
|
||||
<Link className="button-dark px-1 py-0.5" to="/">Back</Link>
|
||||
</div>
|
||||
{report?.rovers.length ? (
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{report.rovers.map((rover) => (
|
||||
<button
|
||||
key={rover.roverId}
|
||||
type="button"
|
||||
className={`button-dark px-1 py-0.5 text-xs ${selectedRovers.includes(rover.roverId) ? 'text-cyan-200' : ''}`}
|
||||
onClick={() => toggleRover(rover.roverId)}
|
||||
>
|
||||
{selectedRovers.includes(rover.roverId) ? '✓ ' : ''}{rover.name}
|
||||
</button>
|
||||
))}
|
||||
{selectedRovers.length ? <button type="button" className="button-dark px-1 py-0.5 text-xs" onClick={() => setSelectedRovers([])}>Clear rover filter</button> : null}
|
||||
{loading && !report ? <p className="text-slate-400">Loading fleet metrics…</p> : null}
|
||||
{error ? <p className="text-red-300">{error}</p> : null}
|
||||
{report ? (
|
||||
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-4 xl:grid-cols-8">
|
||||
<Metric label="Rovers online" value={`${report.totals.onlineRoverCount}/${report.totals.roverCount}`} />
|
||||
<Metric label="Distance" value={distance(report.totals.distanceMm)} />
|
||||
<Metric label="Energy used" value={`${number(report.totals.dischargedWh)} Wh`} />
|
||||
<Metric label="Fleet Wh/km" value={report.totals.overallWhPerKm == null ? '--' : number(report.totals.overallWhPerKm)} />
|
||||
<Metric label="Moving Wh/km" value={report.totals.movingWhPerKm == null ? '--' : number(report.totals.movingWhPerKm)} />
|
||||
<Metric label="Moving energy" value={`${number(report.totals.movingDischargedWh)} Wh`} />
|
||||
<Metric label="Stationary energy" value={`${number(report.totals.stationaryDischargedWh)} Wh`} />
|
||||
<Metric label="Attention" value={number(report.totals.attentionCount, 0)} />
|
||||
</div>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
|
||||
{loading && !report ? <CardFrame title="Loading" bodyClassName="p-1 text-sm text-slate-400">Loading detailed fleet evidence…</CardFrame> : null}
|
||||
{error ? <CardFrame title="Report unavailable" bodyClassName="p-1 text-sm text-red-300">{error}</CardFrame> : null}
|
||||
|
||||
{report ? (
|
||||
<>
|
||||
<CardFrame title="Exact totals" meta={`${report.totals.sampleCount.toLocaleString()} sensor samples`} bodyClassName="grid grid-cols-2 gap-0.5 p-0.5 md:grid-cols-4 xl:grid-cols-6">
|
||||
<Metric label="Rovers" value={`${report.totals.onlineRoverCount}/${report.totals.roverCount} online`} />
|
||||
<Metric label="Telemetry coverage" value={duration(report.totals.coverageMs)} />
|
||||
<Metric label="Telemetry gaps" value={number(report.totals.telemetryGapCount, 0)} />
|
||||
<Metric label="Commands" value={number(report.totals.commandCount, 0)} detail={`${number(report.totals.driveCommandCount, 0)} drive/motor`} />
|
||||
<Metric label="Rejected commands" value={number(report.totals.rejectedCommandCount, 0)} />
|
||||
<Metric label="Distance" value={distance(report.totals.distanceMm)} />
|
||||
<Metric label="Overcurrent episodes" value={number(report.totals.overcurrentEpisodeCount, 0)} />
|
||||
<Metric label="Bumps" value={number(report.totals.bumpCount, 0)} />
|
||||
<Metric label="Cliff episodes" value={number(report.totals.cliffCount, 0)} />
|
||||
<Metric label="Wheel drops" value={number(report.totals.wheelDropCount, 0)} />
|
||||
<Metric label="Virtual walls" value={number(report.totals.virtualWallCount, 0)} />
|
||||
<Metric label="Charged" value={`${number(report.totals.chargedMah)} mAh`} />
|
||||
<Metric label="Discharged" value={`${number(report.totals.dischargedMah)} mAh`} />
|
||||
<Metric label="Battery sessions" value={number(report.totals.batterySessionCount, 0)} />
|
||||
<Metric label="Warnings" value={number(report.totals.warningFindingCount, 0)} />
|
||||
<Metric label="Critical" value={number(report.totals.criticalFindingCount, 0)} />
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame title="Needs attention" meta={report.findings.length} bodyClassName="space-y-0.5 p-0.5 text-xs">
|
||||
{report.findings.length ? report.findings.map((finding) => (
|
||||
<details key={finding.key} className="surface px-1 py-0.5">
|
||||
<summary className="cursor-pointer text-slate-100">
|
||||
<span className={finding.severity === 'critical' ? 'text-red-300' : finding.severity === 'warning' ? 'text-amber-300' : 'text-slate-400'}>{finding.severity}</span>
|
||||
{' · '}{finding.roverId ? `${finding.roverId} · ` : ''}{finding.title} · confidence {finding.confidence}
|
||||
</summary>
|
||||
<pre className="mt-0.5 overflow-x-auto whitespace-pre-wrap text-[0.68rem] text-slate-300">{JSON.stringify(finding.evidence, null, 2)}</pre>
|
||||
</details>
|
||||
)) : <p className="surface px-1 py-0.5 text-emerald-300">No findings in the selected range.</p>}
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame
|
||||
title="Time series"
|
||||
meta={`${report.minutes.length.toLocaleString()} minute rows`}
|
||||
actions={(
|
||||
<select className="surface px-1 py-0.25 text-xs text-slate-100" value={metric} onChange={(event) => setMetric(event.target.value)}>
|
||||
{METRICS.map(([key, label]) => <option key={key} value={key}>{label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
bodyClassName="p-0.5"
|
||||
>
|
||||
<FleetTimeSeriesChart minutes={report.minutes} roverIds={chartRoverIds} metric={metric} />
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame
|
||||
title="Rover comparison"
|
||||
meta={report.rovers.length}
|
||||
actions={<button type="button" className="button-dark px-1 py-0.25 text-xs" onClick={() => exportCsv('fleet-rovers.csv', report.rovers)}>Export CSV</button>}
|
||||
title="All rovers"
|
||||
meta={`${rows.length} visible`}
|
||||
actions={<button type="button" className="button-dark px-1 py-0.25 text-xs" onClick={() => exportCsv(rows)}>Export CSV</button>}
|
||||
bodyClassName="overflow-x-auto p-0.5"
|
||||
>
|
||||
<table className="w-full whitespace-nowrap text-left text-xs">
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>Online</th><th>Samples</th><th>Coverage</th><th>Gaps</th><th>Distance</th><th>Commands</th><th>Drive/motor</th><th>Rejected</th><th>Overcurrents</th><th>Bumps</th><th>Cliffs</th><th>Wheel drops</th><th>Virtual walls</th><th>Charged mAh</th><th>Discharged mAh</th><th>Voltage avg/min/max mV</th><th>Temperature avg/min/max °C</th><th>Charge now mAh</th><th>Reported capacity mAh</th><th>Last sample</th></tr></thead>
|
||||
<tbody>{report.rovers.map((rover) => (
|
||||
<tr key={rover.roverId} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td>{rover.name}</td><td>{rover.online ? 'yes' : 'no'}</td><td>{number(rover.sampleCount, 0)}</td><td>{duration(rover.coverageMs)}</td><td>{number(rover.gapCount, 0)}</td><td>{distance(rover.distanceMm)}</td><td>{number(rover.commandCount, 0)}</td><td>{number(rover.driveCommandCount, 0)}</td><td>{number(rover.rejectedCommandCount, 0)}</td><td>{number(rover.overcurrentEpisodeCount, 0)}</td><td>{number(rover.bumpCount, 0)}</td><td>{number(rover.cliffCount, 0)}</td><td>{number(rover.wheelDropCount, 0)}</td><td>{number(rover.virtualWallCount, 0)}</td>
|
||||
<td>{number(rover.chargedMah)}</td><td>{number(rover.dischargedMah)}</td>
|
||||
<td>{number(rover.averageVoltageMv, 0)} / {number(rover.minimumVoltageMv, 0)} / {number(rover.maximumVoltageMv, 0)}</td>
|
||||
<td>{number(rover.averageTemperatureC)} / {number(rover.minimumTemperatureC)} / {number(rover.maximumTemperatureC)}</td>
|
||||
<td>{number(rover.latestChargeMah, 0)}</td><td>{number(rover.reportedCapacityMah, 0)}</td><td>{timestamp(rover.lastSampleAt)}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
<thead className="sticky top-0 bg-neutral-900 text-slate-400"><tr>
|
||||
<SortHeading field="name" sort={sort} onSort={sortBy}>Rover</SortHeading><SortHeading field="online" sort={sort} onSort={sortBy}>State</SortHeading>
|
||||
<SortHeading field="batteryHealth.capacityRetentionPercent" sort={sort} onSort={sortBy}>Health</SortHeading><SortHeading field="batteryHealth.measuredUsableMah" sort={sort} onSort={sortBy}>Usable Ah</SortHeading>
|
||||
<SortHeading field="batteryHealth.measuredUsableWh" sort={sort} onSort={sortBy}>Usable Wh</SortHeading><SortHeading field="batteryHealth.confidence" sort={sort} onSort={sortBy}>Confidence</SortHeading>
|
||||
<SortHeading field="overallWhPerKm" sort={sort} onSort={sortBy}>Wh/km</SortHeading><SortHeading field="movingWhPerKm" sort={sort} onSort={sortBy}>Moving Wh/km</SortHeading>
|
||||
<SortHeading field="distanceMm" sort={sort} onSort={sortBy}>Distance</SortHeading><SortHeading field="dischargedWh" sort={sort} onSort={sortBy}>Used Wh</SortHeading>
|
||||
<SortHeading field="movingDischargedWh" sort={sort} onSort={sortBy}>Moving Wh</SortHeading><SortHeading field="stationaryDischargedWh" sort={sort} onSort={sortBy}>Stationary Wh</SortHeading>
|
||||
<SortHeading field="movingMs" sort={sort} onSort={sortBy}>Moving time</SortHeading><SortHeading field="averageSpeedMmPerSecond" sort={sort} onSort={sortBy}>Average speed</SortHeading>
|
||||
<SortHeading field="maximumSpeedMmPerSecond" sort={sort} onSort={sortBy}>Maximum speed</SortHeading><SortHeading field="latestChargeMah" sort={sort} onSort={sortBy}>Charge</SortHeading>
|
||||
<SortHeading field="averageVoltageMv" sort={sort} onSort={sortBy}>Voltage</SortHeading><SortHeading field="averageCurrentMa" sort={sort} onSort={sortBy}>Current</SortHeading>
|
||||
<SortHeading field="maximumTemperatureC" sort={sort} onSort={sortBy}>Temperature</SortHeading><SortHeading field="lastSampleAt" sort={sort} onSort={sortBy}>Last data</SortHeading>
|
||||
</tr></thead>
|
||||
<tbody>{rows.map((rover) => {
|
||||
const health = rover.batteryHealth;
|
||||
return (
|
||||
<tr key={rover.roverId} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td className="sticky left-0 bg-neutral-900 font-semibold">{rover.name}</td><td>{rover.online ? 'online' : 'offline'}</td>
|
||||
<td>{health.capacityRetentionPercent == null ? '--' : `${number(health.capacityRetentionPercent, 1)}%`}</td>
|
||||
<td>{health.measuredUsableMah == null ? '--' : number(health.measuredUsableMah / 1000, 3)}</td>
|
||||
<td>{number(health.measuredUsableWh)}</td><td title={health.confidenceReason}>{health.confidence}</td>
|
||||
<td>{rover.overallWhPerKm == null ? `need ${distance(rover.efficiencyDistanceRequiredMm)}` : number(rover.overallWhPerKm)}</td>
|
||||
<td>{number(rover.movingWhPerKm)}</td><td>{distance(rover.distanceMm)}</td><td>{number(rover.dischargedWh)}</td>
|
||||
<td>{number(rover.movingDischargedWh)}</td><td>{number(rover.stationaryDischargedWh)}</td><td>{duration(rover.movingMs)}</td>
|
||||
<td>{rover.averageSpeedMmPerSecond == null ? '--' : `${number(rover.averageSpeedMmPerSecond)} mm/s`}</td>
|
||||
<td>{rover.maximumSpeedMmPerSecond == null ? '--' : `${number(rover.maximumSpeedMmPerSecond)} mm/s`}</td>
|
||||
<td>{number(rover.latestChargeMah, 0)} mAh</td><td>{number(rover.averageVoltageMv, 0)} mV</td>
|
||||
<td>{number(rover.averageCurrentMa, 0)} mA</td><td>{number(rover.maximumTemperatureC, 1)} °C</td><td>{timestamp(rover.lastSampleAt)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}</tbody>
|
||||
</table>
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame
|
||||
title="Battery sessions"
|
||||
meta={report.batterySessions.length}
|
||||
actions={<button type="button" className="button-dark px-1 py-0.25 text-xs" onClick={() => exportCsv('fleet-battery-sessions.csv', report.batterySessions)}>Export CSV</button>}
|
||||
bodyClassName="overflow-x-auto p-0.5"
|
||||
>
|
||||
<CardFrame title="Battery-health evidence" meta="All rovers" bodyClassName="overflow-x-auto p-0.5">
|
||||
<table className="w-full whitespace-nowrap text-left text-xs">
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>Kind</th><th>Started</th><th>Ended</th><th>Duration</th><th>Start/end charge</th><th>Charged mAh</th><th>Discharged mAh</th><th>Voltage min/max</th><th>Temperature min/max</th><th>Samples</th><th>Gaps</th><th>Confidence</th><th>Qualification</th></tr></thead>
|
||||
<tbody>{report.batterySessions.map((session) => (
|
||||
<tr key={session.id} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td>{session.roverId}</td><td>{session.kind}</td><td>{timestamp(session.startedAt)}</td><td>{timestamp(session.endedAt)}</td><td>{duration((session.endedAt || report.generatedAt) - session.startedAt)}</td>
|
||||
<td>{number(session.startChargeMah, 0)} / {number(session.endChargeMah, 0)} mAh</td><td>{number(session.chargedMah)}</td><td>{number(session.dischargedMah)}</td>
|
||||
<td>{number(session.minVoltageMv, 0)} / {number(session.maxVoltageMv, 0)} mV</td><td>{number(session.minTemperatureC)} / {number(session.maxTemperatureC)} °C</td>
|
||||
<td>{number(session.sampleCount, 0)}</td><td>{number(session.gapCount, 0)}</td><td>{session.confidence}</td><td>{session.qualificationReason}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>Battery</th><th>Reference mAh</th><th>Estimated usable mAh</th><th>Observed range floor</th><th>Retention</th><th>Observations</th><th>Average depth</th><th>Throughput</th><th>Latest evidence</th><th>Confidence basis</th></tr></thead>
|
||||
<tbody>{rows.map((rover) => {
|
||||
const health = rover.batteryHealth;
|
||||
return <tr key={rover.roverId} className="border-t border-neutral-700/70 text-slate-200"><td>{rover.name}</td><td>{health.batteryKey}</td><td>{number(health.referenceMah, 0)}</td><td>{number(health.measuredUsableMah, 0)}</td><td>{number(health.observedUsableFloorMah, 0)}</td><td>{health.capacityRetentionPercent == null ? '--' : `${number(health.capacityRetentionPercent, 1)}%`}</td><td>{number(health.observationCount, 0)}</td><td>{number(health.averageObservationDepthPercent, 1)}%</td><td>{number(health.dischargedThroughputMah, 0)} mAh</td><td>{timestamp(health.latestObservationAt)}</td><td>{health.confidenceReason}</td></tr>;
|
||||
})}</tbody>
|
||||
</table>
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame title="Battery health calculations" meta={report.batteryHealth.length} bodyClassName="overflow-x-auto p-0.5">
|
||||
<table className="w-full whitespace-nowrap text-left text-xs">
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>Battery identity</th><th>Qualified tests</th><th>Baseline tests</th><th>Learned baseline mAh</th><th>Latest usable mAh</th><th>Capacity retained</th><th>Discharged throughput mAh</th><th>Equivalent full cycles</th><th>Latest qualified test</th><th>Confidence</th><th>Confidence reason</th></tr></thead>
|
||||
<tbody>{report.batteryHealth.map((health) => (
|
||||
<tr key={health.roverId} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td>{health.roverId}</td><td>{health.batteryKey}</td><td>{number(health.qualifiedTestCount, 0)}</td><td>{number(health.baselineTestCount, 0)}</td>
|
||||
<td>{number(health.baselineMah)}</td><td>{number(health.measuredUsableMah)}</td><td>{health.capacityRetentionPercent == null ? '--' : `${number(health.capacityRetentionPercent)}%`}</td>
|
||||
<td>{number(health.dischargedThroughputMah)}</td><td>{number(health.equivalentFullCycles, 3)}</td><td>{timestamp(health.latestQualifiedTestAt)}</td><td>{health.confidence}</td><td>{health.confidenceReason}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardFrame>
|
||||
{report.attention.length ? (
|
||||
<CardFrame title="Attention" meta={report.attention.length} bodyClassName="p-0.5 text-xs">
|
||||
<table className="w-full text-left"><thead className="text-slate-400"><tr><th>Rover</th><th>Severity</th><th>Reason</th></tr></thead><tbody>{report.attention.map((item) => <tr key={item.key} className="border-t border-neutral-700/70"><td>{item.roverId}</td><td>{item.severity}</td><td>{item.title}</td></tr>)}</tbody></table>
|
||||
</CardFrame>
|
||||
) : null}
|
||||
|
||||
<BatteryRegistryPanel report={report} onSaved={refresh} />
|
||||
|
||||
<CardFrame
|
||||
title="Structured event history"
|
||||
meta={`${report.events.length} returned`}
|
||||
actions={<button type="button" className="button-dark px-1 py-0.25 text-xs" onClick={() => exportCsv('fleet-events.csv', report.events)}>Export CSV</button>}
|
||||
bodyClassName="space-y-0.5 p-0.5"
|
||||
>
|
||||
{report.events.map((event) => (
|
||||
<details key={event.id} className="surface px-1 py-0.5 text-xs">
|
||||
<summary className="cursor-pointer text-slate-200">{timestamp(event.ts)} · {event.severity} · {event.source} · {event.type}{event.roverId ? ` · ${event.roverId}` : ''}</summary>
|
||||
<pre className="mt-0.5 overflow-x-auto whitespace-pre-wrap text-[0.68rem] text-slate-300">{JSON.stringify(event.payload, null, 2)}</pre>
|
||||
</details>
|
||||
))}
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame title="Daily report and Discord delivery history" meta={report.dailyReportHistory.length} bodyClassName="overflow-x-auto p-0.5">
|
||||
<table className="w-full whitespace-nowrap text-left text-xs">
|
||||
<thead className="text-slate-400"><tr><th>Report day</th><th>Generated</th><th>Stored bytes</th><th>Discord delivered</th><th>Delivery error</th></tr></thead>
|
||||
<tbody>{report.dailyReportHistory.map((daily) => (
|
||||
<tr key={daily.reportDate} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td>{daily.reportDate}</td><td>{timestamp(daily.generatedAt)}</td><td>{number(daily.reportBytes, 0)}</td><td>{timestamp(daily.discordDeliveredAt)}</td><td>{daily.discordError || '--'}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame title="Collector and storage diagnostics" bodyClassName="grid gap-0.5 p-0.5 md:grid-cols-2">
|
||||
<pre className="surface overflow-x-auto whitespace-pre-wrap p-1 text-[0.68rem] text-slate-300">{JSON.stringify(report.diagnostics.collector, null, 2)}</pre>
|
||||
<pre className="surface overflow-x-auto whitespace-pre-wrap p-1 text-[0.68rem] text-slate-300">{JSON.stringify(report.diagnostics.storage, null, 2)}</pre>
|
||||
</CardFrame>
|
||||
<BatteryRegistry report={report} refresh={refresh} />
|
||||
|
||||
<CardFrame title="Methodology" bodyClassName="space-y-0.5 p-1 text-xs text-slate-300">
|
||||
<p>Battery throughput integrates OI packet 23 signed battery current: delta mAh = current mA × elapsed milliseconds / 3,600,000. Positive current is charged throughput; negative current is discharged throughput.</p>
|
||||
<p>Intervals exceeding the configured maximum gap are excluded rather than interpreted as zero. Packet 25 charge movement is retained as independent evidence. Packet 26 is displayed as the rover-reported fixed reference and is not treated as measured battery health.</p>
|
||||
<p>A high-confidence capacity test requires a qualified full endpoint, continuous telemetry, configured minimum discharge depth, and a low endpoint. Partial or interrupted sessions remain visible with lower confidence and an explicit qualification reason.</p>
|
||||
<p>Energy is integrated per sensor interval from OI voltage and signed current. Existing odometer distance and speed are consumed directly; reporting does not calculate a competing speed value.</p>
|
||||
<p>Capacity combines partial discharge current with packet 25 charge movement. Every usable observation contributes; deeper observations increase confidence without labeling cycles good or bad. Packet 26 is only a fixed reference.</p>
|
||||
<p>Wh/km appears after 25 m of selected-range odometer travel. Existing historical rows predate exact power integration, so their Wh fields remain zero rather than being backfilled with false precision.</p>
|
||||
</CardFrame>
|
||||
</>
|
||||
) : null}
|
||||
@@ -348,9 +261,8 @@ export default function FleetReportsApp() {
|
||||
useUserIdentitySync({ identitySurface: 'passive' });
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'fleetReports'));
|
||||
const { value: pageSettings } = useSettingsNamespace('page', { backgroundTheme: DEFAULT_PAGE_THEME_KEY });
|
||||
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
|
||||
return (
|
||||
<div className={`${pageBackgroundClass} min-h-screen text-slate-100`}>
|
||||
<div className={`${getPageThemeClass(pageSettings?.backgroundTheme)} min-h-screen text-slate-100`}>
|
||||
<SocketConnectionPill />
|
||||
<main className={`mx-auto flex min-h-screen w-full max-w-[120rem] flex-col ${themeGapClass} p-1`}>
|
||||
{enabled ? <FullReportContent /> : (
|
||||
|
||||
Reference in New Issue
Block a user