mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
299 lines
12 KiB
JavaScript
299 lines
12 KiB
JavaScript
// Fleet Report Builder
|
|
// 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 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 minuteGroups = new Map();
|
|
minutes.forEach((minute) => {
|
|
const roverId = String(minute.roverId);
|
|
if (!minuteGroups.has(roverId)) minuteGroups.set(roverId, []);
|
|
minuteGroups.get(roverId).push(minute);
|
|
});
|
|
|
|
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;
|
|
const base = {
|
|
roverId,
|
|
name: rosterById.get(roverId)?.name || roverId,
|
|
color: rosterById.get(roverId)?.color || null,
|
|
online: Boolean(rosterById.get(roverId)),
|
|
minutes: rows,
|
|
sampleCount: sum(rows, 'sampleCount'),
|
|
coverageMs: sum(rows, 'coverageMs'),
|
|
gapCount: sum(rows, 'gapCount'),
|
|
distanceMm,
|
|
movingMs,
|
|
averageSpeedMmPerSecond: movingMs ? distanceMm / (movingMs / 1000) : null,
|
|
maximumSpeedMmPerSecond: maximum(rows, 'maximumSpeedMmPerSecond'),
|
|
chargedMah: sum(rows, 'chargedMah'),
|
|
dischargedMah: sum(rows, 'dischargedMah'),
|
|
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 buildAttention(roverRows, now) {
|
|
const attention = [];
|
|
roverRows.forEach((rover) => {
|
|
if (!rover.sampleCount) {
|
|
attention.push({
|
|
key: `telemetry:${rover.roverId}`,
|
|
roverId: rover.roverId,
|
|
severity: 'notice',
|
|
title: rover.online ? 'Battery metrics unavailable in this range' : 'Rover was not observed in this range',
|
|
});
|
|
}
|
|
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',
|
|
title: 'Battery metrics are stale',
|
|
});
|
|
}
|
|
});
|
|
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 = false, eventLimit = 500 }) {
|
|
const visibleRoster = Array.from(roverManager.rovers?.values?.() || []).map((record) => ({
|
|
id: record.id,
|
|
name: record.name || record.id,
|
|
color: record.color || record.meta?.color || null,
|
|
}));
|
|
const requestedIds = Array.isArray(roverIds) ? roverIds.map(String) : null;
|
|
const roster = requestedIds
|
|
? visibleRoster.filter((rover) => requestedIds.includes(String(rover.id)))
|
|
: visibleRoster;
|
|
const effectiveIds = requestedIds || roster.map((rover) => String(rover.id));
|
|
const minutes = storage.listMinutes({ since, until, roverIds: effectiveIds });
|
|
const batterySessions = storage.listBatterySessions({ since, until, roverIds: effectiveIds, limit: 2000 });
|
|
const batteryRegistry = storage.listBatteries(effectiveIds);
|
|
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,
|
|
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,
|
|
attention,
|
|
batteryRegistry,
|
|
dailyReportHistory: storage.listDailyReports(365),
|
|
// 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(),
|
|
},
|
|
};
|
|
}
|
|
|
|
return { build };
|
|
}
|
|
|
|
module.exports = {
|
|
MINIMUM_EFFICIENCY_DISTANCE_MM,
|
|
createReportBuilder,
|
|
};
|