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.
- package/README.md +18 -0
- package/package.json +30 -0
- package/plugin/index.js +149 -0
- package/plugin/lib/influx-client.js +171 -0
- package/plugin/lib/power-engine.js +359 -0
- package/plugin/lib/publisher.js +241 -0
- package/plugin/lib/schema.js +168 -0
- package/plugin/lib/tankage-engine.js +242 -0
- package/plugin/lib/usage-coordinator.js +81 -0
|
@@ -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;
|