mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 14:50:00 -07:00
feat: Add meter grouping by device ID with consumption trends
Transform flat scrolling meter list into grouped view showing one card per unique meter with: - Consumption history tracking and delta from previous reading - Trend sparkline visualization (color-coded for normal/elevated/spike) - Consumption rate calculation (units/hour over 30-min window) - Cards update in place instead of creating duplicates - Alert sound only plays for new meters Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
278
static/js/components/meter-aggregator.js
Normal file
278
static/js/components/meter-aggregator.js
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Meter Aggregator Component
|
||||
* Client-side aggregation for rtlamr meter readings
|
||||
* Groups readings by meter ID and tracks consumption history
|
||||
*/
|
||||
|
||||
const MeterAggregator = (function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
maxHistoryAge: 60 * 60 * 1000, // 60 minutes
|
||||
maxHistoryLength: 50, // Max readings to keep per meter
|
||||
rateWindowMs: 30 * 60 * 1000 // 30 minutes for rate calculation
|
||||
};
|
||||
|
||||
// Storage for aggregated meters
|
||||
// Map<meterId, MeterData>
|
||||
const meters = new Map();
|
||||
|
||||
/**
|
||||
* MeterData structure:
|
||||
* {
|
||||
* id: string,
|
||||
* type: string,
|
||||
* utility: string,
|
||||
* manufacturer: string,
|
||||
* firstSeen: number (timestamp),
|
||||
* lastSeen: number (timestamp),
|
||||
* readingCount: number,
|
||||
* latestReading: object (full reading data),
|
||||
* history: Array<{timestamp, consumption, raw}>,
|
||||
* delta: number | null (change from previous reading),
|
||||
* rate: number | null (units per hour)
|
||||
* }
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ingest a new meter reading
|
||||
* @param {Object} data - The raw meter reading data
|
||||
* @returns {Object} - { meter: MeterData, isNew: boolean }
|
||||
*/
|
||||
function ingest(data) {
|
||||
const msgData = data.Message || {};
|
||||
const meterId = String(msgData.ID || data.id || 'Unknown');
|
||||
const timestamp = Date.now();
|
||||
const consumption = msgData.Consumption !== undefined ? msgData.Consumption : data.consumption;
|
||||
|
||||
// Get meter type info if available
|
||||
const meterInfo = typeof getMeterTypeInfo === 'function'
|
||||
? getMeterTypeInfo(msgData.EndpointType, data.Type)
|
||||
: { utility: 'Unknown', manufacturer: 'Unknown' };
|
||||
|
||||
const existing = meters.get(meterId);
|
||||
const isNew = !existing;
|
||||
|
||||
if (isNew) {
|
||||
// Create new meter entry
|
||||
const meter = {
|
||||
id: meterId,
|
||||
type: data.Type || 'Unknown',
|
||||
utility: meterInfo.utility,
|
||||
manufacturer: meterInfo.manufacturer,
|
||||
firstSeen: timestamp,
|
||||
lastSeen: timestamp,
|
||||
readingCount: 1,
|
||||
latestReading: data,
|
||||
history: [{
|
||||
timestamp: timestamp,
|
||||
consumption: consumption,
|
||||
raw: data
|
||||
}],
|
||||
delta: null,
|
||||
rate: null
|
||||
};
|
||||
meters.set(meterId, meter);
|
||||
return { meter, isNew: true };
|
||||
}
|
||||
|
||||
// Update existing meter
|
||||
const previousConsumption = existing.history.length > 0
|
||||
? existing.history[existing.history.length - 1].consumption
|
||||
: null;
|
||||
|
||||
// Add to history
|
||||
existing.history.push({
|
||||
timestamp: timestamp,
|
||||
consumption: consumption,
|
||||
raw: data
|
||||
});
|
||||
|
||||
// Prune old history
|
||||
pruneHistory(existing);
|
||||
|
||||
// Calculate delta (change from previous reading)
|
||||
if (previousConsumption !== null && consumption !== undefined && consumption !== null) {
|
||||
existing.delta = consumption - previousConsumption;
|
||||
} else {
|
||||
existing.delta = null;
|
||||
}
|
||||
|
||||
// Calculate rate (units per hour)
|
||||
existing.rate = calculateRate(existing);
|
||||
|
||||
// Update meter data
|
||||
existing.lastSeen = timestamp;
|
||||
existing.readingCount++;
|
||||
existing.latestReading = data;
|
||||
existing.type = data.Type || existing.type;
|
||||
if (meterInfo.utility !== 'Unknown') existing.utility = meterInfo.utility;
|
||||
if (meterInfo.manufacturer !== 'Unknown') existing.manufacturer = meterInfo.manufacturer;
|
||||
|
||||
return { meter: existing, isNew: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune history older than maxHistoryAge and beyond maxHistoryLength
|
||||
*/
|
||||
function pruneHistory(meter) {
|
||||
const cutoff = Date.now() - CONFIG.maxHistoryAge;
|
||||
|
||||
// Remove old entries
|
||||
meter.history = meter.history.filter(h => h.timestamp >= cutoff);
|
||||
|
||||
// Limit length
|
||||
if (meter.history.length > CONFIG.maxHistoryLength) {
|
||||
meter.history = meter.history.slice(-CONFIG.maxHistoryLength);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate consumption rate over the rate window
|
||||
* @returns {number|null} Units per hour, or null if insufficient data
|
||||
*/
|
||||
function calculateRate(meter) {
|
||||
if (meter.history.length < 2) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const windowStart = now - CONFIG.rateWindowMs;
|
||||
|
||||
// Find readings within the rate window
|
||||
const recentHistory = meter.history.filter(h => h.timestamp >= windowStart);
|
||||
if (recentHistory.length < 2) return null;
|
||||
|
||||
const oldest = recentHistory[0];
|
||||
const newest = recentHistory[recentHistory.length - 1];
|
||||
|
||||
// Need both to have valid consumption values
|
||||
if (oldest.consumption === undefined || oldest.consumption === null ||
|
||||
newest.consumption === undefined || newest.consumption === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const consumptionDiff = newest.consumption - oldest.consumption;
|
||||
const timeDiffHours = (newest.timestamp - oldest.timestamp) / (1000 * 60 * 60);
|
||||
|
||||
if (timeDiffHours <= 0) return null;
|
||||
|
||||
return consumptionDiff / timeDiffHours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get consumption deltas for sparkline display
|
||||
* @returns {Array<{timestamp, delta}>}
|
||||
*/
|
||||
function getConsumptionDeltas(meter) {
|
||||
const deltas = [];
|
||||
for (let i = 1; i < meter.history.length; i++) {
|
||||
const prev = meter.history[i - 1];
|
||||
const curr = meter.history[i];
|
||||
if (prev.consumption !== undefined && prev.consumption !== null &&
|
||||
curr.consumption !== undefined && curr.consumption !== null) {
|
||||
deltas.push({
|
||||
timestamp: curr.timestamp,
|
||||
delta: curr.consumption - prev.consumption
|
||||
});
|
||||
}
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a meter by ID
|
||||
* @param {string} id
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function getMeter(id) {
|
||||
return meters.get(String(id)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all meters
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
function getAllMeters() {
|
||||
return Array.from(meters.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meter count
|
||||
* @returns {number}
|
||||
*/
|
||||
function getCount() {
|
||||
return meters.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all aggregated data
|
||||
*/
|
||||
function clear() {
|
||||
meters.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time since last reading for a meter
|
||||
* @param {Object} meter
|
||||
* @returns {string}
|
||||
*/
|
||||
function getTimeSinceLastReading(meter) {
|
||||
const diff = Date.now() - meter.lastSeen;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return 'Just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format rate for display
|
||||
* @param {number|null} rate
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatRate(rate) {
|
||||
if (rate === null || rate === undefined || isNaN(rate)) {
|
||||
return '--';
|
||||
}
|
||||
// Format based on magnitude
|
||||
const absRate = Math.abs(rate);
|
||||
if (absRate >= 100) {
|
||||
return rate.toFixed(0) + '/hr';
|
||||
} else if (absRate >= 1) {
|
||||
return rate.toFixed(1) + '/hr';
|
||||
} else {
|
||||
return rate.toFixed(2) + '/hr';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format delta for display
|
||||
* @param {number|null} delta
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDelta(delta) {
|
||||
if (delta === null || delta === undefined || isNaN(delta)) {
|
||||
return '--';
|
||||
}
|
||||
const sign = delta >= 0 ? '+' : '';
|
||||
return sign + delta.toLocaleString();
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
ingest,
|
||||
getMeter,
|
||||
getAllMeters,
|
||||
getCount,
|
||||
clear,
|
||||
getConsumptionDeltas,
|
||||
getTimeSinceLastReading,
|
||||
formatRate,
|
||||
formatDelta,
|
||||
CONFIG
|
||||
};
|
||||
})();
|
||||
|
||||
// Make globally available
|
||||
window.MeterAggregator = MeterAggregator;
|
||||
Reference in New Issue
Block a user