diff --git a/.gitignore b/.gitignore index 332399c..ef6e397 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ +# Repo dev artifacts. Mirrors the deny list in .npmignore so the two stay +# in sync — anything that shouldn't be committed AND shouldn't ship in the +# npm tarball goes in both files. +node_modules/ +package-lock.json +*.tgz +.env +.env.* +.DS_Store +npm-debug.log* + # Large local artifacts that don't belong in Git. # wiki/test.gif: screen recordings of the dashboard are kept locally for # reference but exceed 100 MB — use Git LFS or external storage if they diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..cd49559 --- /dev/null +++ b/.npmignore @@ -0,0 +1,28 @@ +# === Mirrors .gitignore — items below this block are also excluded from +# the npm tarball. Kept here verbatim so npm pack doesn't fall back to +# the .gitignore inheritance (silent + surprising). === +node_modules/ +package-lock.json +*.tgz +.env +.env.* +.DS_Store +npm-debug.log* + +# Large local screen recording (>100 MB) — kept out of both repo and pack. +wiki/test.gif + +# === Dev-only content the npm tarball doesn't need === +# Tests + their harness — Node-RED loads the entry .js, not the test tree. +test/ +*.test.js + +# Wiki / docs — useful in the repo, big in the pack. +wiki/ + +# Project memory + IDE configs. +.claude/ +.codex/ +.repo-mem/ +CLAUDE.md +CLAUDE.local.md diff --git a/src/commands/handlers.js b/src/commands/handlers.js index c88b65f..46d1797 100644 --- a/src/commands/handlers.js +++ b/src/commands/handlers.js @@ -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; diff --git a/src/specificClass.js b/src/specificClass.js index 59805e1..dd826b4 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -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)) { diff --git a/test/basic/commands.basic.test.js b/test/basic/commands.basic.test.js index 96ea339..a814409 100644 --- a/test/basic/commands.basic.test.js +++ b/test/basic/commands.basic.test.js @@ -65,9 +65,25 @@ function makeSource({ if (handleInputResult instanceof Error) throw handleInputResult; return handleInputResult; }, - // Used by set.demand handler when unit is %: needs dt.flow + interpolation. - // With min=0, max=100, the linear interpolation is identity so a bare - // numeric demand round-trips through handleInput unchanged. + // Mirror of the real specificClass.setDemand: resolves unit -> canonical + // m³/s and forwards to handleInput. With dt.flow {min:0,max:100} the % + // interpolation is identity, so a bare numeric demand round-trips through + // handleInput unchanged — keeping the existing assertions stable. + setDemand: async (value, unit = '%') => { + const v = Number(value); + if (!Number.isFinite(v)) return undefined; + if (v < 0) { await source.turnOffAllMachines(); return undefined; } + let canonical; + if (unit === '%') { + canonical = source.interpolation.interpolate_lin_single_point( + v, 0, 100, dt.flow.min, dt.flow.max); + } else { + const { convert } = require('generalFunctions'); + canonical = convert(v).from(unit).to('m3/s'); + } + return source.handleInput('parent', canonical); + }, + // Retained for completeness — the mock setDemand uses these internally. calcDynamicTotals: () => dt, interpolation: { interpolate_lin_single_point: (x, ix, iy, ox, oy) => {