feat(setDemand): surface specificClass.setDemand(value, unit='%') + slim npm pack

Why:
- pumpingStation level-based control was calling MGC.handleInput(percent)
  directly. handleInput expects canonical m³/s; a 1 % keep-alive arrived
  as 1 m³/s ≈ 3600 m³/h, the dispatcher clamped to dt.flow.max and the
  group ran at 100 %. The unit math already existed inside the set.demand
  command handler — but only that handler could reach it.

What:
- New public method `async setDemand(value, unit='%')` on MachineGroup
  (specificClass.js). Resolves the unit (`%` → interpolate against the
  dynamic-totals envelope, absolute units → convert(value)) and calls
  handleInput with canonical m³/s. Negative value remains the operator
  stop-all signal. Single source of truth for the percent → m³/s rule.
- Refactor handlers.setDemand to parse the payload + apply mode gating
  and then delegate to source.setDemand. Drops the local `convert` import
  (now reached via the source).
- Update commands.basic.test.js mock with a setDemand shim that mirrors
  the real method, so existing handleInput assertions still hold.

Packaging:
- Add .npmignore mirroring .gitignore plus dev-only trees (test/, wiki/,
  CLAUDE.md, …) so the published tarball stays small.
- Extend .gitignore with the standard dev-artifact deny list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-19 21:36:00 +02:00
parent a57e0095a3
commit aeb938c205
5 changed files with 94 additions and 33 deletions

View File

@@ -10,8 +10,6 @@
// Pure functions: no module-level state. The registry already enforces the
// typeof-check ladder; per-topic semantic validation lives here.
const { convert } = require('generalFunctions');
function _logger(source, ctx) {
return ctx?.logger || source?.logger || null;
}
@@ -61,8 +59,8 @@ exports.setDemand = async (source, msg, ctx) => {
// payload = { value, unit:'m3/h' | 'l/s' | 'm3/s' | ... } → absolute flow
// payload < 0 (any unit) → operator stop-all signal
//
// The handler is the only place that resolves units. _runDispatch sees a
// single canonical m³/s number and never branches on scaling.
// Unit resolution + canonical dispatch lives in source.setDemand. The
// handler's job is payload parsing, mode gating, and the "done" reply.
const p = msg?.payload;
let rawValue;
let unit;
@@ -88,33 +86,8 @@ exports.setDemand = async (source, msg, ctx) => {
else if (source?.mode === 'priorityControl') action = 'execSequentialControl';
else action = 'execOptimalCombination';
if (!_gate(source, action, msg)) return;
// Negative is the operator's "stop all" signal regardless of unit.
if (value < 0) {
try {
await source.turnOffAllMachines();
} catch (err) {
log?.error?.(`set.demand: turnOffAllMachines failed: ${err && err.message}`);
}
return;
}
// Resolve to canonical m³/s.
let canonicalDemand;
if (unit === '%') {
const dt = source.calcDynamicTotals();
// Linear interpolation: 0 % → dt.flow.min, 100 % → dt.flow.max. The
// interpolation helper also clamps so 110 % can't run pumps past max.
canonicalDemand = source.interpolation.interpolate_lin_single_point(
value, 0, 100, dt.flow.min, dt.flow.max);
} else {
try {
canonicalDemand = convert(value).from(unit).to('m3/s');
} catch (err) {
log?.error?.(`set.demand: cannot convert ${value} ${unit} → m3/s: ${err && err.message}`);
return;
}
}
try {
await source.handleInput('parent', canonicalDemand);
await source.setDemand(value, unit);
} catch (err) {
log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`);
return;

View File

@@ -359,6 +359,39 @@ class MachineGroup extends BaseDomain {
return this._demandDispatcher.fireAndWait({ source, demand, powerCap, priorityList });
}
// Operator-style entry point: accepts a (value, unit) pair and resolves
// to canonical m³/s before delegating to handleInput. Single source of
// truth for the unit math shared by the set.demand command handler and
// by parent nodes (e.g. pumpingStation level-based control) that hold a
// direct reference to this specificClass and need to push a % demand
// without re-implementing the interpolation. Negative value is the
// stop-all signal regardless of unit.
async setDemand(value, unit = '%') {
const v = Number(value);
if (!Number.isFinite(v)) {
this.logger?.error?.(`setDemand: invalid value '${value}'`);
return undefined;
}
if (v < 0) {
await this.turnOffAllMachines();
return undefined;
}
let canonical;
if (unit === '%') {
const dt = this.calcDynamicTotals();
canonical = this.interpolation.interpolate_lin_single_point(
v, 0, 100, dt.flow.min, dt.flow.max);
} else {
try {
canonical = convert(v).from(unit).to('m3/s');
} catch (err) {
this.logger?.error?.(`setDemand: cannot convert ${v} ${unit} -> m3/s: ${err?.message || err}`);
return undefined;
}
}
return this.handleInput('parent', canonical);
}
async _runDispatch(source, demand, powerCap, priorityList) {
const demandQ = parseFloat(demand);
if (!Number.isFinite(demandQ)) {