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:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -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.
|
# Large local artifacts that don't belong in Git.
|
||||||
# wiki/test.gif: screen recordings of the dashboard are kept locally for
|
# 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
|
# reference but exceed 100 MB — use Git LFS or external storage if they
|
||||||
|
|||||||
28
.npmignore
Normal file
28
.npmignore
Normal file
@@ -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
|
||||||
@@ -10,8 +10,6 @@
|
|||||||
// Pure functions: no module-level state. The registry already enforces the
|
// Pure functions: no module-level state. The registry already enforces the
|
||||||
// typeof-check ladder; per-topic semantic validation lives here.
|
// typeof-check ladder; per-topic semantic validation lives here.
|
||||||
|
|
||||||
const { convert } = require('generalFunctions');
|
|
||||||
|
|
||||||
function _logger(source, ctx) {
|
function _logger(source, ctx) {
|
||||||
return ctx?.logger || source?.logger || null;
|
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 = { value, unit:'m3/h' | 'l/s' | 'm3/s' | ... } → absolute flow
|
||||||
// payload < 0 (any unit) → operator stop-all signal
|
// payload < 0 (any unit) → operator stop-all signal
|
||||||
//
|
//
|
||||||
// The handler is the only place that resolves units. _runDispatch sees a
|
// Unit resolution + canonical dispatch lives in source.setDemand. The
|
||||||
// single canonical m³/s number and never branches on scaling.
|
// handler's job is payload parsing, mode gating, and the "done" reply.
|
||||||
const p = msg?.payload;
|
const p = msg?.payload;
|
||||||
let rawValue;
|
let rawValue;
|
||||||
let unit;
|
let unit;
|
||||||
@@ -88,33 +86,8 @@ exports.setDemand = async (source, msg, ctx) => {
|
|||||||
else if (source?.mode === 'priorityControl') action = 'execSequentialControl';
|
else if (source?.mode === 'priorityControl') action = 'execSequentialControl';
|
||||||
else action = 'execOptimalCombination';
|
else action = 'execOptimalCombination';
|
||||||
if (!_gate(source, action, msg)) return;
|
if (!_gate(source, action, msg)) return;
|
||||||
// Negative is the operator's "stop all" signal regardless of unit.
|
|
||||||
if (value < 0) {
|
|
||||||
try {
|
try {
|
||||||
await source.turnOffAllMachines();
|
await source.setDemand(value, unit);
|
||||||
} 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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`);
|
log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -359,6 +359,39 @@ class MachineGroup extends BaseDomain {
|
|||||||
return this._demandDispatcher.fireAndWait({ source, demand, powerCap, priorityList });
|
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) {
|
async _runDispatch(source, demand, powerCap, priorityList) {
|
||||||
const demandQ = parseFloat(demand);
|
const demandQ = parseFloat(demand);
|
||||||
if (!Number.isFinite(demandQ)) {
|
if (!Number.isFinite(demandQ)) {
|
||||||
|
|||||||
@@ -65,9 +65,25 @@ function makeSource({
|
|||||||
if (handleInputResult instanceof Error) throw handleInputResult;
|
if (handleInputResult instanceof Error) throw handleInputResult;
|
||||||
return handleInputResult;
|
return handleInputResult;
|
||||||
},
|
},
|
||||||
// Used by set.demand handler when unit is %: needs dt.flow + interpolation.
|
// Mirror of the real specificClass.setDemand: resolves unit -> canonical
|
||||||
// With min=0, max=100, the linear interpolation is identity so a bare
|
// m³/s and forwards to handleInput. With dt.flow {min:0,max:100} the %
|
||||||
// numeric demand round-trips through handleInput unchanged.
|
// 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,
|
calcDynamicTotals: () => dt,
|
||||||
interpolation: {
|
interpolation: {
|
||||||
interpolate_lin_single_point: (x, ix, iy, ox, oy) => {
|
interpolate_lin_single_point: (x, ix, iy, ox, oy) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user