levelBased ramp + engagement: - Ramp foot is now max(startLevel, holdLevel) — was max(startLevel, inflowLevel). inflowLevel is basin geometry, not a control setpoint; the implicit hold zone it created was causing pumps to "start at inflowLevel" instead of startLevel. - New optional `holdLevel` config (defaults to startLevel = no hold band). When raised, pumps engage at startLevel and hold at 0 % = MGC flow.min across [startLevel, holdLevel], then ramp 0..100 % to maxLevel. - Engagement decided in run() (not in `_applyMachineGroupLevelControl`): rising-edge hysteresis arming gates a clean turnOff early-return. Once armed, the helper always forwards setDemand(pct, '%') — 0 % legitimately means "engaged at min flow", no more soft-turnOff at the boundary. - Disengagement paths (minLevel hard-stop, stopLevel falling-edge, pre-arming idle) now all clear the shifted-ramp hysteresis state too. - Threshold validator drops the startLevel ≤ inflowLevel rule; adds startLevel ≤ holdLevel < maxLevel (only checked when holdLevel is explicitly set, so default-null doesn't false-flag). MGC unit math: - Replace direct group.handleInput(percent) with group.setDemand(pct, '%') in _applyMachineGroupLevelControl. The percent → m³/s resolution now lives in MGC.setDemand (committed separately in the MGC submodule). FlowAggregator variant picking: - New _pickFlowSum() helper mirrors selectBestNetFlow's variant precedence (measured first, then predicted) and resolves each side independently. Realistic mixed case — real measured upstream sensor + predicted pump outflow — now feeds the predicted-volume integrator. Was reading only `flow.predicted.*` so a real upstream sensor (which writes `flow.measured.*`) never moved the level. Editor: - New `holdLevel` and `deadZoneKeepAlivePercent` defaults + side-panel input rows in the levelbased mode preview. - Add the missing `ps-mode-line-holdLevel` SVG marker (was declared in the side-panel coupling but the SVG element didn't exist, so the dashed line never rendered). - Relax stopLevel marker gate so it renders for any non-negative typed value — start/stop ordering is the ribbon's job, not the marker's (was hiding the line whenever startLevel was momentarily smaller). - Add holdLevel to the marker loop in mode-preview so changes track. - Add stopLevel + holdLevel + maxLevel to all three bindRedraw lists (basin-diagram, mode-preview, bounds.apply) so the SVG, validation ribbon, and HTML5 min/max attrs update on every edit. - Initialise stopLevel + holdLevel + deadZoneKeepAlivePercent inputs in oneditprepare so reopening the editor shows the saved values. - nodeClass passes holdLevel + deadZoneKeepAlivePercent into the domain config. Tests: - New test/basic/_probe_upstream_emit.test.js: confirms the parent surfaces flow.measured.upstream.* on Port 0 after a measurement child write — pins the previously-invisible measured variant flow. - flowAggregator.basic.test.js: two new regression cases — measured inflow when predicted side is empty, and the measured-in / predicted-out mixed case. - control-levelBased.basic.test.js: new cases for the holdLevel hold band, the [stopLevel, startLevel] keep-alive, the engagement gate, and the "0 % at startLevel = setDemand" contract. - specificClass.test.js: zone tests adjusted to the new ramp foot. Shifted-ramp tests pin holdLevel = 3 explicitly so their legacy arithmetic (ramp foot at inflowLevel) stays self-consistent. - shifted-ramp-end-to-end.test.js: same holdLevel pin for the same reason. Packaging: - Add .gitignore + .npmignore so the published tarball drops the wiki/, simulations/, test/, tools/, .claude/ etc. The pack went from 1.5 MB (72 files) to ~57 KB (30 files). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
86 lines
3.3 KiB
JavaScript
86 lines
3.3 KiB
JavaScript
// Throwaway probe — exercises the exact path:
|
|
// measurement child writes flow.measured.upstream → pumpingStation parent
|
|
// subscribes → getOutput() (≡ what Port 0 emits).
|
|
// Run with: node --test test/basic/_probe_upstream_emit.test.js
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const PumpingStation = require('../../src/specificClass');
|
|
const { MeasurementContainer, configManager } = require('generalFunctions');
|
|
const EventEmitter = require('node:events');
|
|
|
|
// Minimal PumpingStation config — matches the editor defaults shape.
|
|
function makePsConfig() {
|
|
const ui = {
|
|
name: 'PS', basinVolume: 50, basinHeight: 5,
|
|
inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
|
|
minHeightBasedOn: 'outlet',
|
|
controlMode: 'levelbased',
|
|
minLevel: 1, startLevel: 2, maxLevel: 4,
|
|
levelCurveType: 'linear',
|
|
processOutputFormat: 'process', dbaseOutputFormat: 'influxdb',
|
|
};
|
|
const cm = new configManager();
|
|
// Use the same buildConfig pipeline the runtime uses.
|
|
return cm.buildConfig('pumpingStation', ui, 'ps-probe', {
|
|
basin: {
|
|
volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
|
|
},
|
|
hydraulics: { minHeightBasedOn: 'outlet' },
|
|
control: {
|
|
mode: 'levelbased',
|
|
allowedModes: new Set(['levelbased']),
|
|
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear' },
|
|
},
|
|
safety: {},
|
|
});
|
|
}
|
|
|
|
// Fake measurement child that looks exactly like the real one to the router:
|
|
// - softwareType 'measurement'
|
|
// - config.asset.type = 'flow'
|
|
// - config.functionality.positionVsParent = 'upstream'
|
|
// - .measurements is a real MeasurementContainer with a real emitter
|
|
function makeMeasurementChild(id = 'meas-probe') {
|
|
const measurements = new MeasurementContainer({
|
|
autoConvert: true,
|
|
preferredUnits: { flow: 'm3/s' },
|
|
});
|
|
// Real container ships an emitter; sanity check.
|
|
assert.ok(measurements.emitter instanceof EventEmitter || typeof measurements.emitter?.on === 'function');
|
|
return {
|
|
id,
|
|
source: {
|
|
config: {
|
|
general: { id, name: id },
|
|
functionality: { softwareType: 'measurement', positionVsParent: 'upstream' },
|
|
asset: { type: 'flow' },
|
|
},
|
|
measurements,
|
|
},
|
|
};
|
|
}
|
|
|
|
test('PROBE: measurement child writes flow.measured.upstream — parent surfaces it on getOutput()', () => {
|
|
const ps = new PumpingStation(makePsConfig());
|
|
const child = makeMeasurementChild();
|
|
|
|
// Register the child the same way the runtime does.
|
|
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
|
|
|
|
// Drive a value through the child's MeasurementContainer the way Channel
|
|
// does — type/variant/position chain then .value().
|
|
child.source.measurements
|
|
.type('flow').variant('measured').position('upstream')
|
|
.value(12, Date.now(), 'm3/h'); // 12 m³/h ≈ 0.00333 m³/s
|
|
|
|
const out = ps.getOutput();
|
|
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
|
|
console.log('flow.measured.upstream.* keys in Port 0 payload:', upstreamKeys);
|
|
for (const k of upstreamKeys) console.log(` ${k} = ${out[k]}`);
|
|
|
|
// The contract: the parent should surface the upstream measurement.
|
|
assert.ok(upstreamKeys.length > 0, 'parent must surface flow.measured.upstream.* on Port 0');
|
|
});
|