signalk-usage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,359 @@
1
+ function PowerEngine(app, influxClient, options) {
2
+ this.app = app;
3
+ this.influxClient = influxClient;
4
+ this.options = options;
5
+
6
+ this.cache = new Map();
7
+ this.cacheEnabled = options.reporting?.cacheResults !== false;
8
+ }
9
+
10
+ PowerEngine.prototype.calculateAll = async function() {
11
+ this.app.debug('PowerEngine: Calculating usage for all power items');
12
+
13
+ const powerItems = (this.options.power || [])
14
+ .filter(item => item.enabled !== false);
15
+
16
+ const promises = powerItems.map(item => this.calculateForItem(item));
17
+ await Promise.all(promises);
18
+ };
19
+
20
+ PowerEngine.prototype.calculateForItem = async function(item) {
21
+ const { path } = item;
22
+
23
+ this.app.debug(`PowerEngine: Calculating usage for ${path}`);
24
+
25
+ // Get periods from item config (fallback to defaults if not specified)
26
+ const periods = item.periods || [
27
+ { range: '1h', aggregation: '1m' },
28
+ { range: '24h', aggregation: '15m' },
29
+ { range: '7d', aggregation: '1h' }
30
+ ];
31
+
32
+ const itemData = {
33
+ path: path,
34
+ name: item.name || path,
35
+ unit: 'watts',
36
+ directionality: item.directionality,
37
+ capacity: item.capacity,
38
+ periods: {}
39
+ };
40
+
41
+ for (const period of periods) {
42
+ try {
43
+ const usage = await this.calculateUsageForPeriod(item, period);
44
+ itemData.periods[period.range] = usage;
45
+ } catch (err) {
46
+ this.app.debug(`PowerEngine: Error calculating ${period.range} usage for ${path}: ${err.message}`);
47
+ itemData.periods[period.range] = { error: err.message };
48
+ }
49
+ }
50
+
51
+ if (this.cacheEnabled) {
52
+ this.cache.set(path, {
53
+ data: itemData,
54
+ timestamp: Date.now()
55
+ });
56
+ }
57
+
58
+ return itemData;
59
+ };
60
+
61
+ PowerEngine.prototype.parseAggregationWindow = function(aggregation) {
62
+ // Parse aggregation string like "30m", "1h", "1d" to minutes
63
+ const match = aggregation.match(/^(\d+)([smhd])$/);
64
+ if (!match) return 60; // Default to 60 minutes if can't parse
65
+
66
+ const value = parseInt(match[1]);
67
+ const unit = match[2];
68
+
69
+ switch (unit) {
70
+ case 's': return value / 60;
71
+ case 'm': return value;
72
+ case 'h': return value * 60;
73
+ case 'd': return value * 24 * 60;
74
+ default: return 60;
75
+ }
76
+ };
77
+
78
+ PowerEngine.prototype.parseTimeRange = function(range) {
79
+ // Parse range string like "1h", "7d", "365d" to hours
80
+ const match = range.match(/^(\d+)([smhd])$/);
81
+ if (!match) return 1; // Default to 1 hour if can't parse
82
+
83
+ const value = parseInt(match[1]);
84
+ const unit = match[2];
85
+
86
+ switch (unit) {
87
+ case 's': return value / 3600;
88
+ case 'm': return value / 60;
89
+ case 'h': return value;
90
+ case 'd': return value * 24;
91
+ default: return 1;
92
+ }
93
+ };
94
+
95
+ PowerEngine.prototype.calculateUsageForPeriod = async function(item, period) {
96
+ const { path, directionality } = item;
97
+ const { range, aggregation } = period;
98
+ const rangeParam = `-${range}`;
99
+
100
+ // Determine directionality (explicit or auto-detect)
101
+ let effectiveDirectionality = directionality;
102
+ if (!effectiveDirectionality) {
103
+ effectiveDirectionality = this.autoDetectDirectionalityType(path);
104
+ }
105
+
106
+ this.app.debug(`PowerEngine: Calculating ${range} for ${path} (aggregation: ${aggregation || 'auto'}, directionality: ${effectiveDirectionality})`);
107
+
108
+ const { first, last } = await this.influxClient.getFirstAndLast(path, rangeParam);
109
+
110
+ if (!first || !last) {
111
+ return {
112
+ insufficientData: true,
113
+ reason: 'No data available for this period'
114
+ };
115
+ }
116
+
117
+ const delta = last.value - first.value;
118
+ const timeDiffHours = (last.timestamp - first.timestamp) / (1000 * 60 * 60);
119
+
120
+ const usage = {
121
+ period: range,
122
+ startTime: first.timestamp,
123
+ endTime: last.timestamp,
124
+ startValue: first.value,
125
+ endValue: last.value,
126
+ delta: delta
127
+ };
128
+
129
+ // Energy calculation using integration with aggregated data
130
+ try {
131
+ this.app.debug(`PowerEngine: Fetching aggregated data for ${path}`);
132
+ const dataPoints = await this.influxClient.queryPath(path, rangeParam, aggregation);
133
+
134
+ if (!dataPoints || dataPoints.length < 2) {
135
+ this.app.debug(`PowerEngine: Insufficient data points for ${path}: ${dataPoints ? dataPoints.length : 0}`);
136
+ return {
137
+ insufficientData: true,
138
+ reason: 'Insufficient data points'
139
+ };
140
+ }
141
+
142
+ // Check coverage based on unique days (or hours for sub-day periods)
143
+ const rangeHours = this.parseTimeRange(range);
144
+
145
+ let uniquePeriods, expectedPeriods, periodType;
146
+
147
+ if (rangeHours >= 24) {
148
+ // For periods >= 1 day, check daily coverage
149
+ const uniqueDays = new Set(dataPoints.map(p => {
150
+ const date = new Date(p.timestamp);
151
+ return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
152
+ }));
153
+ uniquePeriods = uniqueDays.size;
154
+ expectedPeriods = Math.ceil(rangeHours / 24);
155
+ periodType = 'days';
156
+ } else {
157
+ // For periods < 1 day, check hourly coverage
158
+ const uniqueHours = new Set(dataPoints.map(p => {
159
+ const date = new Date(p.timestamp);
160
+ return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}-${String(date.getUTCHours()).padStart(2, '0')}`;
161
+ }));
162
+ uniquePeriods = uniqueHours.size;
163
+ expectedPeriods = Math.ceil(rangeHours);
164
+ periodType = 'hours';
165
+ }
166
+
167
+ this.app.debug(`PowerEngine: Coverage for ${path} (${range}): ${uniquePeriods}/${expectedPeriods} ${periodType}`);
168
+
169
+ // Check if we have data for every day (or hour)
170
+ if (uniquePeriods < expectedPeriods) {
171
+ this.app.debug(`PowerEngine: Insufficient coverage for ${path} (${range}) - missing ${expectedPeriods - uniquePeriods} ${periodType}`);
172
+ return {
173
+ insufficientData: true,
174
+ reason: `Insufficient coverage (${uniquePeriods}/${expectedPeriods} ${periodType})`
175
+ };
176
+ }
177
+
178
+ this.app.debug(`PowerEngine: Integrating ${dataPoints.length} aggregated data points for ${path} (${range})`);
179
+
180
+ // Parse aggregation window to detect gaps
181
+ const expectedIntervalMinutes = this.parseAggregationWindow(aggregation);
182
+ const gapThresholdMs = expectedIntervalMinutes * 2 * 60 * 1000; // 2x expected interval
183
+
184
+ this.app.debug(` Gap threshold: ${(gapThresholdMs / 1000 / 60).toFixed(1)} minutes (2x ${expectedIntervalMinutes}m aggregation)`);
185
+
186
+ let totalEnergyWh = 0;
187
+ let positiveEnergyWh = 0;
188
+ let negativeEnergyWh = 0;
189
+ let gapsDetected = 0;
190
+ let gapTimeHours = 0;
191
+ let skippedNoise = 0;
192
+
193
+ // Integrate using trapezoidal rule - KEEP zeros, only skip noise
194
+ for (let i = 1; i < dataPoints.length; i++) {
195
+ const p1 = dataPoints[i - 1];
196
+ const p2 = dataPoints[i];
197
+
198
+ const timeDiffMs = p2.timestamp - p1.timestamp;
199
+ const timeDiffHours = timeDiffMs / (1000 * 60 * 60);
200
+ const avgPower = (p1.value + p2.value) / 2;
201
+
202
+ // ONLY skip negative noise for producer/consumer (not zeros!)
203
+ let shouldSkip = false;
204
+
205
+ if (effectiveDirectionality === 'producer' && avgPower < 0) {
206
+ shouldSkip = true;
207
+ skippedNoise++;
208
+ } else if (effectiveDirectionality === 'consumer' && avgPower < 0) {
209
+ shouldSkip = true;
210
+ skippedNoise++;
211
+ }
212
+
213
+ if (shouldSkip) {
214
+ continue; // Skip this noise window
215
+ }
216
+
217
+ // Check if this is a data gap (missing windows)
218
+ if (timeDiffMs > gapThresholdMs) {
219
+ // GAP DETECTED - use last known value for the gap
220
+ gapsDetected++;
221
+ gapTimeHours += timeDiffHours;
222
+
223
+ // Energy during gap = last known power * gap duration
224
+ const gapEnergy = p1.value * timeDiffHours;
225
+
226
+ totalEnergyWh += gapEnergy;
227
+
228
+ if (gapEnergy > 0) {
229
+ positiveEnergyWh += gapEnergy;
230
+ } else {
231
+ negativeEnergyWh += Math.abs(gapEnergy);
232
+ }
233
+ } else {
234
+ // Normal window - use trapezoidal rule (INCLUDING zeros)
235
+ const energy = avgPower * timeDiffHours;
236
+
237
+ totalEnergyWh += energy;
238
+
239
+ if (energy > 0) {
240
+ positiveEnergyWh += energy;
241
+ } else {
242
+ negativeEnergyWh += Math.abs(energy);
243
+ }
244
+ }
245
+ }
246
+
247
+ if (skippedNoise > 0) {
248
+ this.app.debug(` Skipped ${skippedNoise} noise windows (negative values)`);
249
+ }
250
+
251
+ if (gapsDetected > 0) {
252
+ this.app.debug(` Detected ${gapsDetected} data gaps totaling ${gapTimeHours.toFixed(1)} hours - filled with last known values`);
253
+ }
254
+
255
+ // Apply directionality logic
256
+ const result = this.applyDirectionality(path, effectiveDirectionality, positiveEnergyWh, negativeEnergyWh);
257
+
258
+ this.app.debug(`PowerEngine: Energy for ${path} (${range}):`);
259
+ this.app.debug(` Consumed: ${result.consumedWh.toFixed(2)} Wh`);
260
+ this.app.debug(` Generated: ${result.generatedWh.toFixed(2)} Wh`);
261
+ this.app.debug(` Directionality: ${result.appliedDirectionality}`);
262
+
263
+ usage.energy = {
264
+ consumedWh: result.consumedWh,
265
+ generatedWh: result.generatedWh
266
+ };
267
+ } catch (err) {
268
+ this.app.debug(`PowerEngine: Error calculating energy for ${path}: ${err.message}`);
269
+ this.app.debug(`Stack trace: ${err.stack}`);
270
+ return {
271
+ insufficientData: true,
272
+ reason: `Error: ${err.message}`
273
+ };
274
+ }
275
+
276
+ return usage;
277
+ };
278
+
279
+ PowerEngine.prototype.autoDetectDirectionalityType = function(path) {
280
+ const pathLower = path.toLowerCase();
281
+
282
+ if (pathLower.includes('solar') || pathLower.includes('panel') || pathLower.includes('alternator')) {
283
+ return 'producer';
284
+ } else if (pathLower.includes('acin') || pathLower.includes('shore')) {
285
+ return 'consumer';
286
+ } else if (pathLower.includes('battery') && !pathLower.includes('acout')) {
287
+ return 'bidirectional-reversed';
288
+ } else {
289
+ return 'bidirectional-normal';
290
+ }
291
+ };
292
+
293
+ PowerEngine.prototype.applyDirectionality = function(path, directionality, positiveEnergyWh, negativeEnergyWh) {
294
+ let consumedWh, generatedWh, appliedDirectionality;
295
+
296
+ switch (directionality) {
297
+ case 'producer':
298
+ consumedWh = 0;
299
+ generatedWh = positiveEnergyWh;
300
+ appliedDirectionality = 'producer (explicit)';
301
+ if (negativeEnergyWh > 0.1) {
302
+ this.app.debug(` Note: Filtered out ${negativeEnergyWh.toFixed(2)} Wh negative energy (noise)`);
303
+ }
304
+ break;
305
+
306
+ case 'consumer':
307
+ consumedWh = positiveEnergyWh;
308
+ generatedWh = 0;
309
+ appliedDirectionality = 'consumer (explicit)';
310
+ if (negativeEnergyWh > 0.1) {
311
+ this.app.debug(` Note: Filtered out ${negativeEnergyWh.toFixed(2)} Wh negative energy (noise)`);
312
+ }
313
+ break;
314
+
315
+ case 'bidirectional-normal':
316
+ consumedWh = negativeEnergyWh;
317
+ generatedWh = positiveEnergyWh;
318
+ appliedDirectionality = 'bidirectional-normal (explicit)';
319
+ break;
320
+
321
+ case 'bidirectional-reversed':
322
+ consumedWh = positiveEnergyWh;
323
+ generatedWh = negativeEnergyWh;
324
+ appliedDirectionality = 'bidirectional-reversed (explicit)';
325
+ break;
326
+
327
+ default:
328
+ this.app.debug(` WARNING: Unknown directionality '${directionality}' for ${path}, using auto-detection`);
329
+ const detectedType = this.autoDetectDirectionalityType(path);
330
+ return this.applyDirectionality(path, detectedType, positiveEnergyWh, negativeEnergyWh);
331
+ }
332
+
333
+ return {
334
+ consumedWh: consumedWh,
335
+ generatedWh: generatedWh,
336
+ appliedDirectionality: appliedDirectionality
337
+ };
338
+ };
339
+
340
+ PowerEngine.prototype.getUsageData = function() {
341
+ const data = {};
342
+
343
+ this.cache.forEach((cached, path) => {
344
+ data[path] = cached.data;
345
+ });
346
+
347
+ return data;
348
+ };
349
+
350
+ PowerEngine.prototype.getUsageForPath = function(path) {
351
+ const cached = this.cache.get(path);
352
+ return cached ? cached.data : null;
353
+ };
354
+
355
+ PowerEngine.prototype.stop = function() {
356
+ this.cache.clear();
357
+ };
358
+
359
+ module.exports = PowerEngine;
@@ -0,0 +1,241 @@
1
+ function Publisher(app, usageCoordinator, options) {
2
+ this.app = app;
3
+ this.usageCoordinator = usageCoordinator;
4
+ this.options = options;
5
+ this.publishTimer = null;
6
+ }
7
+
8
+ Publisher.prototype.start = function() {
9
+ const interval = (this.options.reporting?.updateInterval || 20) * 1000;
10
+
11
+ this.app.debug(`Starting publisher (interval: ${interval}ms)`);
12
+
13
+ // Publish immediately (will skip if not ready)
14
+ this.publish();
15
+
16
+ const self = this;
17
+ this.publishTimer = setInterval(() => {
18
+ self.publish();
19
+ }, interval);
20
+ };
21
+
22
+ Publisher.prototype.round = function(value) {
23
+ // Round to 1 decimal place
24
+ return Math.round(value * 10) / 10;
25
+ };
26
+
27
+ Publisher.prototype.publish = function() {
28
+ const usageData = this.usageCoordinator.getUsageData();
29
+
30
+ // Don't publish if data isn't ready yet
31
+ if (!usageData.ready) {
32
+ this.app.debug('Publisher: Data not ready yet, skipping publish');
33
+ return;
34
+ }
35
+
36
+ const deltas = [];
37
+ const meta = [];
38
+
39
+ // Items are already separated by engine type
40
+ const items = usageData.items || {};
41
+
42
+ // Separate by checking item unit (power items have 'watts', tankage has m3/ratio)
43
+ const tankageItems = {};
44
+ const powerItems = {};
45
+
46
+ Object.entries(items).forEach(([path, item]) => {
47
+ if (item.unit === 'watts') {
48
+ powerItems[path] = item;
49
+ } else {
50
+ tankageItems[path] = item;
51
+ }
52
+ });
53
+
54
+ this.app.debug(`Publishing ${Object.keys(tankageItems).length} tankage items, ${Object.keys(powerItems).length} power items`);
55
+
56
+ this.publishTankageItems(tankageItems, deltas, meta);
57
+ this.publishPowerItems(powerItems, deltas, meta);
58
+
59
+ if (deltas.length > 0) {
60
+ const delta = {
61
+ context: 'vessels.' + this.app.selfId,
62
+ updates: [{
63
+ timestamp: new Date().toISOString(),
64
+ values: deltas,
65
+ meta: meta
66
+ }]
67
+ };
68
+
69
+ this.app.handleMessage('signalk-usage', delta);
70
+
71
+ this.app.debug(`Published ${deltas.length} values to SignalK`);
72
+ } else {
73
+ this.app.debug('No deltas to publish');
74
+ }
75
+ };
76
+
77
+ Publisher.prototype.publishTankageItems = function(items, deltas, meta) {
78
+ Object.values(items).forEach(item => {
79
+ this.app.debug(`Publishing tankage item: ${item.path}`);
80
+ const basePath = this.getBasePath(item);
81
+
82
+ // Each item has its own periods - publish all of them
83
+ Object.entries(item.periods || {}).forEach(([period, periodData]) => {
84
+ const unit = item.unit || 'm3';
85
+
86
+ // Check if this period has insufficient data
87
+ if (periodData.insufficientData) {
88
+ this.app.debug(` ${period}: Insufficient data - publishing null (${periodData.reason})`);
89
+
90
+ // Publish null values for this period
91
+ deltas.push({ path: `${basePath}.consumed.${period}`, value: null });
92
+ meta.push({ path: `${basePath}.consumed.${period}`, value: { units: unit } });
93
+
94
+ deltas.push({ path: `${basePath}.added.${period}`, value: null });
95
+ meta.push({ path: `${basePath}.added.${period}`, value: { units: unit } });
96
+
97
+ deltas.push({ path: `${basePath}.consumptionRate.${period}`, value: null });
98
+ meta.push({ path: `${basePath}.consumptionRate.${period}`, value: { units: `${unit}/h` } });
99
+
100
+ return;
101
+ }
102
+
103
+ // Publish normal values (rounded to 1 decimal)
104
+ deltas.push({
105
+ path: `${basePath}.consumed.${period}`,
106
+ value: this.round(periodData.consumed || 0)
107
+ });
108
+ meta.push({
109
+ path: `${basePath}.consumed.${period}`,
110
+ value: { units: unit }
111
+ });
112
+
113
+ deltas.push({
114
+ path: `${basePath}.added.${period}`,
115
+ value: this.round(periodData.added || 0)
116
+ });
117
+ meta.push({
118
+ path: `${basePath}.added.${period}`,
119
+ value: { units: unit }
120
+ });
121
+
122
+ deltas.push({
123
+ path: `${basePath}.consumptionRate.${period}`,
124
+ value: this.round(periodData.consumptionRate || 0)
125
+ });
126
+ meta.push({
127
+ path: `${basePath}.consumptionRate.${period}`,
128
+ value: { units: `${unit}/h` }
129
+ });
130
+ });
131
+ });
132
+ };
133
+
134
+ Publisher.prototype.isBattery = function(item) {
135
+ const pathLower = item.path.toLowerCase();
136
+ return pathLower.includes('battery') || pathLower.includes('batt');
137
+ };
138
+
139
+ Publisher.prototype.publishPowerItems = function(items, deltas, meta) {
140
+ Object.values(items).forEach(item => {
141
+ this.app.debug(`Publishing power item: ${item.path}`);
142
+ const basePath = this.getBasePath(item);
143
+ const isBattery = this.isBattery(item);
144
+
145
+ // Each item has its own periods - publish all of them
146
+ Object.entries(item.periods || {}).forEach(([period, periodData]) => {
147
+ const directionality = item.directionality;
148
+
149
+ // Battery gets special naming: charged/discharged
150
+ const consumedLabel = isBattery ? 'dischargedWh' : 'consumedWh';
151
+ const generatedLabel = isBattery ? 'chargedWh' : 'generatedWh';
152
+
153
+ // Check if this period has insufficient data
154
+ if (periodData.insufficientData) {
155
+ this.app.debug(` ${period}: Insufficient data - publishing null (${periodData.reason})`);
156
+
157
+ // Publish null values based on directionality
158
+ if (directionality === 'producer') {
159
+ deltas.push({ path: `${basePath}.${generatedLabel}.${period}`, value: null });
160
+ meta.push({ path: `${basePath}.${generatedLabel}.${period}`, value: { units: 'Wh' } });
161
+ } else if (directionality === 'consumer') {
162
+ deltas.push({ path: `${basePath}.${consumedLabel}.${period}`, value: null });
163
+ meta.push({ path: `${basePath}.${consumedLabel}.${period}`, value: { units: 'Wh' } });
164
+ } else {
165
+ // Bidirectional - publish both as null
166
+ deltas.push({ path: `${basePath}.${consumedLabel}.${period}`, value: null });
167
+ meta.push({ path: `${basePath}.${consumedLabel}.${period}`, value: { units: 'Wh' } });
168
+
169
+ deltas.push({ path: `${basePath}.${generatedLabel}.${period}`, value: null });
170
+ meta.push({ path: `${basePath}.${generatedLabel}.${period}`, value: { units: 'Wh' } });
171
+ }
172
+
173
+ return;
174
+ }
175
+
176
+ // Skip if no energy data
177
+ if (!periodData.energy) return;
178
+
179
+ // Publish normal values (rounded to 1 decimal)
180
+ if (directionality === 'producer') {
181
+ // Only publish generated
182
+ deltas.push({
183
+ path: `${basePath}.${generatedLabel}.${period}`,
184
+ value: this.round(periodData.energy.generatedWh || 0)
185
+ });
186
+ meta.push({
187
+ path: `${basePath}.${generatedLabel}.${period}`,
188
+ value: { units: 'Wh' }
189
+ });
190
+ } else if (directionality === 'consumer') {
191
+ // Only publish consumed
192
+ deltas.push({
193
+ path: `${basePath}.${consumedLabel}.${period}`,
194
+ value: this.round(periodData.energy.consumedWh || 0)
195
+ });
196
+ meta.push({
197
+ path: `${basePath}.${consumedLabel}.${period}`,
198
+ value: { units: 'Wh' }
199
+ });
200
+ } else {
201
+ // Bidirectional or auto-detected - publish both
202
+ deltas.push({
203
+ path: `${basePath}.${consumedLabel}.${period}`,
204
+ value: this.round(periodData.energy.consumedWh || 0)
205
+ });
206
+ meta.push({
207
+ path: `${basePath}.${consumedLabel}.${period}`,
208
+ value: { units: 'Wh' }
209
+ });
210
+
211
+ deltas.push({
212
+ path: `${basePath}.${generatedLabel}.${period}`,
213
+ value: this.round(periodData.energy.generatedWh || 0)
214
+ });
215
+ meta.push({
216
+ path: `${basePath}.${generatedLabel}.${period}`,
217
+ value: { units: 'Wh' }
218
+ });
219
+ }
220
+ });
221
+ });
222
+ };
223
+
224
+ Publisher.prototype.getBasePath = function(item) {
225
+ // If user provided a custom name, use it in the path
226
+ // Otherwise use the full SignalK path
227
+ if (item.name && item.name !== item.path) {
228
+ return 'usage.' + item.name;
229
+ }
230
+ return 'usage.' + item.path;
231
+ };
232
+
233
+ Publisher.prototype.stop = function() {
234
+ if (this.publishTimer) {
235
+ clearInterval(this.publishTimer);
236
+ this.publishTimer = null;
237
+ this.app.debug('Publisher stopped');
238
+ }
239
+ };
240
+
241
+ module.exports = Publisher;