|
|
|
|
@@ -3,6 +3,51 @@ const EventEmitter = require('events');
|
|
|
|
|
const convertModule = require('../convert/index');
|
|
|
|
|
const { POSITIONS } = require('../constants/positions');
|
|
|
|
|
|
|
|
|
|
/* ============================================================================
|
|
|
|
|
* MeasurementContainer — measurement storage with chainable type/variant/
|
|
|
|
|
* position/child addressing.
|
|
|
|
|
*
|
|
|
|
|
* INTERNAL STORAGE SHAPE
|
|
|
|
|
* measurements[type][variant][position][childId] = Measurement instance
|
|
|
|
|
*
|
|
|
|
|
* The childId layer is ALWAYS present, even when the caller doesn't specify
|
|
|
|
|
* one. _getOrCreateMeasurement defaults childId to 'default' when no
|
|
|
|
|
* .child(...) is in the chain. So writing
|
|
|
|
|
*
|
|
|
|
|
* mc.type('level').variant('measured').position('atequipment')
|
|
|
|
|
* .value(2.5, ts, 'm');
|
|
|
|
|
*
|
|
|
|
|
* stores the value at measurements.level.measured.atequipment.default.
|
|
|
|
|
*
|
|
|
|
|
* READING — the chainable getters resolve the default child transparently,
|
|
|
|
|
* so consumers usually don't see it:
|
|
|
|
|
*
|
|
|
|
|
* mc.type('level').variant('measured').position('atequipment')
|
|
|
|
|
* .getCurrentValue('m'); // returns 2.5
|
|
|
|
|
*
|
|
|
|
|
* FLATTENED OUTPUT — getFlattenedOutput() emits ONE key per child, including
|
|
|
|
|
* the implicit 'default' bucket:
|
|
|
|
|
*
|
|
|
|
|
* {
|
|
|
|
|
* 'level.measured.atequipment.default': 2.5, // implicit child
|
|
|
|
|
* 'flow.predicted.in.manual-qin': 0.05, // explicit .child('manual-qin')
|
|
|
|
|
* 'flow.predicted.in.from-pump-A': 0.03,
|
|
|
|
|
* …
|
|
|
|
|
* }
|
|
|
|
|
*
|
|
|
|
|
* ⚠ DASHBOARDS / DOWNSTREAM PARSERS MUST INCLUDE THE CHILD KEY
|
|
|
|
|
* The flat key format is `${type}.${variant}.${position}.${childId}`.
|
|
|
|
|
* When you have not used .child(), the childId is the literal string
|
|
|
|
|
* 'default'. Use 'level.measured.atequipment.default', NOT
|
|
|
|
|
* 'level.measured.atequipment'. This trips up new consumers — see the
|
|
|
|
|
* pumpingStation basic-dashboard parser for an example that gets it right.
|
|
|
|
|
*
|
|
|
|
|
* AGGREGATION — sum() folds all children of a position into one number:
|
|
|
|
|
*
|
|
|
|
|
* mc.sum('flow', 'predicted', ['in'], 'm3/s');
|
|
|
|
|
* // = manual-qin + from-pump-A + … + (default if any)
|
|
|
|
|
* ============================================================================
|
|
|
|
|
*/
|
|
|
|
|
class MeasurementContainer {
|
|
|
|
|
constructor(options = {},logger) {
|
|
|
|
|
this.logger = logger || null;
|
|
|
|
|
@@ -535,18 +580,43 @@ class MeasurementContainer {
|
|
|
|
|
.reduce((acc, v) => acc + v, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Flatten the entire container to a key→value map, suitable for
|
|
|
|
|
* dashboards / InfluxDB / debug dumps.
|
|
|
|
|
*
|
|
|
|
|
* KEY FORMAT — child-bucketed series (the common case):
|
|
|
|
|
* `${type}.${variant}.${position}.${childId}`
|
|
|
|
|
*
|
|
|
|
|
* Even measurements written without an explicit `.child(...)` end up
|
|
|
|
|
* here under `childId === 'default'` (see _getOrCreateMeasurement).
|
|
|
|
|
* Examples:
|
|
|
|
|
* level.measured.atequipment.default // implicit child
|
|
|
|
|
* flow.predicted.in.manual-qin // explicit child
|
|
|
|
|
* flow.predicted.in.from-pump-A // explicit child
|
|
|
|
|
*
|
|
|
|
|
* Consumers (Node-RED dashboards, parsers) MUST include the trailing
|
|
|
|
|
* `.default` when reading default-bucket measurements. Stripping it
|
|
|
|
|
* silently misses the value. This is the #1 footgun for new code that
|
|
|
|
|
* uses MeasurementContainer.
|
|
|
|
|
*
|
|
|
|
|
* The "Legacy single series" branch below catches a pre-v2 storage
|
|
|
|
|
* shape where a position held a Measurement directly (no child layer);
|
|
|
|
|
* new code never produces that shape but old serialized state may.
|
|
|
|
|
*/
|
|
|
|
|
getFlattenedOutput(options = {}) {
|
|
|
|
|
const requestedUnits = options.requestedUnits || (options.usePreferredUnits ? this.preferredUnits : null);
|
|
|
|
|
const out = {};
|
|
|
|
|
Object.entries(this.measurements).forEach(([type, variants]) => {
|
|
|
|
|
Object.entries(variants).forEach(([variant, positions]) => {
|
|
|
|
|
Object.entries(positions).forEach(([position, entry]) => {
|
|
|
|
|
// Legacy single series
|
|
|
|
|
// Legacy single series (no childId layer)
|
|
|
|
|
if (entry?.getCurrentValue) {
|
|
|
|
|
out[`${type}.${variant}.${position}`] = this._resolveOutputValue(type, entry, requestedUnits);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Child-bucketed series
|
|
|
|
|
// Child-bucketed series — ALWAYS the case for new writes,
|
|
|
|
|
// including the implicit 'default' bucket when no .child() is
|
|
|
|
|
// used. The flat key carries the childId.
|
|
|
|
|
if (entry && typeof entry === 'object') {
|
|
|
|
|
Object.entries(entry).forEach(([childId, m]) => {
|
|
|
|
|
if (m?.getCurrentValue) {
|
|
|
|
|
|