Compare commits

...

1 Commits

Author SHA1 Message Date
Rene De Ren
4b6250cc42 pumpingStation schema: shiftArmPercent + MeasurementContainer .default doc
- control.levelbased.shiftArmPercent (default 95): output % threshold that
  arms the shift on the way up. Once armed, the up-curve % at the
  filling→draining transition becomes the held value, kept until level
  drops to shiftLevel; from there it ramps to 0 % at startLevel.
- shiftLevel description updated — it is no longer the arming trigger,
  it's the level at which the held output begins ramping down.
- MeasurementContainer.js: prominent doc block on the class plus a
  JSDoc on getFlattenedOutput documenting the `${type}.${variant}.
  ${position}.${childId}` flatten format and the implicit 'default'
  childId convention. This was the #1 footgun for new dashboard
  consumers — the comments now make the rule impossible to miss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:15 +02:00
2 changed files with 82 additions and 3 deletions

View File

@@ -546,7 +546,16 @@
"rules": {
"type": "number",
"min": 0,
"description": "Level (m) that arms the shifted ramp when crossed going up. Should be ≤ maxLevel. Ignored when enableShiftedRamp is false."
"description": "Level (m) at which the held output starts ramping down during draining. Must be > startLevel and ≤ maxLevel. Ignored when enableShiftedRamp is false."
}
},
"shiftArmPercent": {
"default": 95,
"rules": {
"type": "number",
"min": 0,
"max": 100,
"description": "Output % threshold that arms the shift on the way up. Once armed, the output value at the moment direction flips to draining becomes the held value, and stays held until level drops to shiftLevel. Disarms when level reaches startLevel."
}
}
},

View File

@@ -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) {