2 Commits

Author SHA1 Message Date
znetsixe
e47de87adb feat(commands): unit shorthand + collapse duplicated value/unit parsing; wiki sync
- 5 descriptors -> unit: shorthand (cmd.calibrate.volume/level, set.inflow/
  outflow/demand).
- setInflow/setOutflow: drop the hand-rolled scalar-vs-object parsing — the
  registry now normalises every shape to a number in the descriptor unit; the
  handlers become guarded one-liners (matching setDemand).
- Regenerate wiki topic-contract + command-envelope note (msg.origin).

143/143 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:41:32 +02:00
znetsixe
fc6491dc23 change(ps): emit output flow in m³/s
Set UnitPolicy output flow/netFlowRate to m³/s (was m³/h).

NOTE: this reverts the output-unit half of e041877 ("keep canonical
flow in m³/s, emit output in m³/h"). Committed at user direction during
dev-lzm → development integration; flagged for review against the
documented PS units decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:37:14 +02:00
4 changed files with 37 additions and 46 deletions

View File

@@ -48,42 +48,29 @@ exports.calibrateLevel = (source, msg, ctx) => {
source.calibratePredictedLevel(v); source.calibratePredictedLevel(v);
}; };
exports.setInflow = (source, msg) => { // The registry has already normalised any accepted shape (number, numeric
// Payload is either a number (legacy q_in shape) or // string, or { value, unit } object) to a number in the descriptor unit
// { value, unit, timestamp } (richer object form). // (m3/h) and tagged msg.unit. Handlers just read the normalised scalar.
const p = msg.payload; exports.setInflow = (source, msg, ctx) => {
let value; const log = _logger(source, ctx);
let unit; const value = Number(msg.payload);
let timestamp; if (!Number.isFinite(value)) {
if (p !== null && typeof p === 'object') { log?.warn?.(`set.inflow: non-numeric payload '${JSON.stringify(msg.payload)}'`);
value = Number(p.value); return;
unit = p.unit;
timestamp = p.timestamp || Date.now();
} else {
value = Number(p);
unit = msg?.unit;
timestamp = msg?.timestamp || Date.now();
} }
source.setManualInflow(value, timestamp, unit); source.setManualInflow(value, msg.timestamp, msg.unit);
}; };
exports.setOutflow = (source, msg) => { exports.setOutflow = (source, msg, ctx) => {
// Manual q_out — basin-docs dashboard injects a drain rate without // Manual q_out — basin-docs dashboard injects a drain rate without wiring a
// wiring a real pump. Same payload shape as q_in. // real pump. Same normalised shape as set.inflow.
const p = msg.payload; const log = _logger(source, ctx);
let value; const value = Number(msg.payload);
let unit; if (!Number.isFinite(value)) {
let timestamp; log?.warn?.(`set.outflow: non-numeric payload '${JSON.stringify(msg.payload)}'`);
if (p !== null && typeof p === 'object') { return;
value = Number(p.value);
unit = p.unit;
timestamp = p.timestamp || Date.now();
} else {
value = Number(p);
unit = msg?.unit;
timestamp = msg?.timestamp || Date.now();
} }
source.setManualOutflow(value, timestamp, unit); source.setManualOutflow(value, msg.timestamp, msg.unit);
}; };
exports.setDemand = (source, msg, ctx) => { exports.setDemand = (source, msg, ctx) => {

View File

@@ -26,9 +26,10 @@ module.exports = [
{ {
topic: 'cmd.calibrate.volume', topic: 'cmd.calibrate.volume',
aliases: ['calibratePredictedVolume'], aliases: ['calibratePredictedVolume'],
// any: payload may be a number or numeric string. // any: payload may be a number, numeric string, or { value, unit } object —
// the registry normalises all of them to a number in `unit` before the handler.
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volume', default: 'm3' }, unit: 'm3',
description: 'Calibrate the predicted-volume integrator to a known basin volume.', description: 'Calibrate the predicted-volume integrator to a known basin volume.',
handler: handlers.calibrateVolume, handler: handlers.calibrateVolume,
}, },
@@ -36,16 +37,15 @@ module.exports = [
topic: 'cmd.calibrate.level', topic: 'cmd.calibrate.level',
aliases: ['calibratePredictedLevel'], aliases: ['calibratePredictedLevel'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'length', default: 'm' }, unit: 'm',
description: 'Calibrate the predicted-volume integrator to a known basin level.', description: 'Calibrate the predicted-volume integrator to a known basin level.',
handler: handlers.calibrateLevel, handler: handlers.calibrateLevel,
}, },
{ {
topic: 'set.inflow', topic: 'set.inflow',
aliases: ['q_in'], aliases: ['q_in'],
// any: number, numeric string, or { value, unit, timestamp } object.
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' }, unit: 'm3/h',
description: 'Push a measured inflow value into the basin balance.', description: 'Push a measured inflow value into the basin balance.',
handler: handlers.setInflow, handler: handlers.setInflow,
}, },
@@ -53,7 +53,7 @@ module.exports = [
topic: 'set.outflow', topic: 'set.outflow',
aliases: ['q_out'], aliases: ['q_out'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' }, unit: 'm3/h',
description: 'Push a measured outflow value into the basin balance.', description: 'Push a measured outflow value into the basin balance.',
handler: handlers.setOutflow, handler: handlers.setOutflow,
}, },
@@ -61,7 +61,7 @@ module.exports = [
topic: 'set.demand', topic: 'set.demand',
aliases: ['Qd'], aliases: ['Qd'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' }, unit: 'm3/h',
description: 'Operator outflow demand setpoint for the station.', description: 'Operator outflow demand setpoint for the station.',
handler: handlers.setDemand, handler: handlers.setDemand,
}, },

View File

@@ -32,7 +32,7 @@ class PumpingStation extends BaseDomain {
static unitPolicy = UnitPolicy.declare({ static unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' }, canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
output: { output: {
flow: 'm3/h', netFlowRate: 'm3/h', level: 'm', volume: 'm3', flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3',
overflowVolume: 'm3', underflowVolume: 'm3', overflowVolume: 'm3', underflowVolume: 'm3',
}, },
requireUnitForTypes: [], requireUnitForTypes: [],

View File

@@ -11,7 +11,11 @@
## Topic contract ## Topic contract
The **Unit** column reflects each descriptor's `units: { measure, default }` declaration. The default unit is what the commandRegistry coerces incoming `msg.unit` values to before the handler runs. The **Unit** column reflects each descriptor's declared unit (via the `unit: 'm3/h'` shorthand or the legacy `units: { measure, default }`; the measure is derived from the unit). The default unit is what the commandRegistry coerces incoming values to before the handler runs.
**Command envelope (all EVOLV nodes).** Every command shares one envelope on top of `msg.topic`:
- **Value + unit** — send `msg.payload` as a number (with optional sibling `msg.unit`) **or** as `{ value, unit }`. The registry always converts the value to the descriptor's unit before the handler; numeric strings are converted too. A missing unit assumes the descriptor default.
- **`msg.origin`** — the control authority that issued the command: `parent` (automation/parent controller, the default), `GUI` (SCADA/HMI operator), or `fysical` (physical buttons). On nodes with a control mode, the mode's `allowedSources` decides which origins are accepted; releasing control is done by changing the mode.
<!-- BEGIN AUTOGEN: topic-contract --> <!-- BEGIN AUTOGEN: topic-contract -->
@@ -19,11 +23,11 @@ The **Unit** column reflects each descriptor's `units: { measure, default }` dec
|---|---|---|---|---| |---|---|---|---|---|
| `set.mode` | `changemode` | `string` | — | Switch the station between auto / manual control modes. | | `set.mode` | `changemode` | `string` | — | Switch the station between auto / manual control modes. |
| `child.register` | `registerChild` | `string` | — | Register a child node (machine group, measurement, …) with this station. | | `child.register` | `registerChild` | `string` | — | Register a child node (machine group, measurement, …) with this station. |
| `cmd.calibrate.volume` | `calibratePredictedVolume` | `any` | `volume` (default `m3`) | Calibrate the predicted-volume integrator to a known basin volume. | | `cmd.calibrate.volume` | `calibratePredictedVolume` | any | `volume` (default `m3`) | Calibrate the predicted-volume integrator to a known basin volume. |
| `cmd.calibrate.level` | `calibratePredictedLevel` | `any` | `length` (default `m`) | Calibrate the predicted-volume integrator to a known basin level. | | `cmd.calibrate.level` | `calibratePredictedLevel` | any | `length` (default `m`) | Calibrate the predicted-volume integrator to a known basin level. |
| `set.inflow` | `q_in` | `any` | `volumeFlowRate` (default `m3/h`) | Push a measured inflow value into the basin balance. | | `set.inflow` | `q_in` | any | `volumeFlowRate` (default `m3/h`) | Push a measured inflow value into the basin balance. |
| `set.outflow` | `q_out` | `any` | `volumeFlowRate` (default `m3/h`) | Push a measured outflow value into the basin balance. | | `set.outflow` | `q_out` | any | `volumeFlowRate` (default `m3/h`) | Push a measured outflow value into the basin balance. |
| `set.demand` | `Qd` | `any` | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. | | `set.demand` | `Qd` | any | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. |
<!-- END AUTOGEN: topic-contract --> <!-- END AUTOGEN: topic-contract -->