Compare commits

4 Commits

Author SHA1 Message Date
znetsixe
11d196f363 fix: pass returnToOperational:true for shutdown/estop abort path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:01:49 +02:00
znetsixe
510a4233e6 fix: remove trace instrumentation + update tests for corrected curve bounds
The bogus machineCurve default at pressure "1" (fixed in generalFunctions
086e5fe) made fValues.min=1, which let sub-curve differentials pass
unclamped. With the fix, fValues.min=70000 (the real curve minimum) and
low differentials get clamped. Three tests that accidentally depended on
the bogus min=1 behavior are updated:

- coolprop test: expects fDimension clamped to curve minimum when
  differential < curve range
- pressure-initialization test: uses pressures whose differential falls
  WITHIN the curve range (900 mbar = 90000 Pa > 70000 Pa minimum)
- sequences test: tests upper-bound constraint with setpoint > max,
  then confirms a valid setpoint is applied as-is (was incorrectly
  asserting any setpoint would be clamped to max)

Trace instrumentation from debugging session removed.

91/91 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:28:13 +02:00
znetsixe
26e253d030 fix: clamp flow/power predictions to 0 when controller position ≤ 0
At ctrl=0% with high backpressure, the curve prediction extrapolates to
large negative values (backflow through a stopped pump). This produced
confusing chart readings (-200+ m³/h for an idle pump) and polluted
downstream consumers like MGC efficiency calculations.

Fix: in both calcFlow and calcPower, if the controller position x ≤ 0
the prediction is clamped to 0 regardless of what the spline returns.
For x > 0, predictions are also clamped to ≥ 0 (negative flow/power
from a running pump is physically implausible for a centrifugal machine).

91/91 tests still green — no existing test asserted on negative
flow/power values at ctrl=0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:07:02 +02:00
znetsixe
c464b66b27 docs: add CLAUDE.md with S88 classification and superproject rule reference
References the flow-layout rule set in the EVOLV superproject
(.claude/rules/node-red-flow-layout.md) so Claude Code sessions working
in this repo know the S88 level, colour, and placement lane for this node.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:47:21 +02:00
5 changed files with 54 additions and 16 deletions

23
CLAUDE.md Normal file
View File

@@ -0,0 +1,23 @@
# rotatingMachine — Claude Code context
Individual pump / compressor / blower control.
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
## S88 classification
| Level | Colour | Placement lane |
|---|---|---|
| **Equipment Module** | `#86bbdd` | L3 |
## Flow layout rules
When wiring this node into a multi-node demo or production flow, follow the
placement rule set in the **EVOLV superproject**:
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
Key points for this node:
- Place on lane **L3** (x-position per the lane table in the rule).
- Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `#86bbdd` (Equipment Module).

View File

@@ -883,7 +883,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
if (interruptible.has(sequenceName) && if (interruptible.has(sequenceName) &&
(currentState === "accelerating" || currentState === "decelerating")) { (currentState === "accelerating" || currentState === "decelerating")) {
this.logger.warn(`Sequence '${sequenceName}' requested during '${currentState}'. Aborting active movement.`); this.logger.warn(`Sequence '${sequenceName}' requested during '${currentState}'. Aborting active movement.`);
this.state.abortCurrentMovement(`${sequenceName} sequence requested`); this.state.abortCurrentMovement(`${sequenceName} sequence requested`, { returnToOperational: true });
await this._waitForOperational(2000); await this._waitForOperational(2000);
} }
@@ -967,10 +967,11 @@ _callMeasurementHandler(measurementType, value, position, context) {
return 0; return 0;
} }
const cFlow = this.predictFlow.y(x); const rawFlow = this.predictFlow.y(x);
// Clamp: at position ≤ 0 the pump isn't rotating — physical flow is 0.
const cFlow = (x <= 0) ? 0 : Math.max(0, rawFlow);
this.measurements.type("flow").variant("predicted").position("downstream").value(cFlow,Date.now(),this.unitPolicy.canonical.flow); this.measurements.type("flow").variant("predicted").position("downstream").value(cFlow,Date.now(),this.unitPolicy.canonical.flow);
this.measurements.type("flow").variant("predicted").position("atEquipment").value(cFlow,Date.now(),this.unitPolicy.canonical.flow); this.measurements.type("flow").variant("predicted").position("atEquipment").value(cFlow,Date.now(),this.unitPolicy.canonical.flow);
//this.logger.debug(`Calculated flow: ${cFlow} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
return cFlow; return cFlow;
} }
@@ -991,10 +992,9 @@ _callMeasurementHandler(measurementType, value, position, context) {
return 0; return 0;
} }
//this.predictPower.currentX = x; Decrepated const rawPower = this.predictPower.y(x);
const cPower = this.predictPower.y(x); const cPower = (x <= 0) ? 0 : Math.max(0, rawPower);
this.measurements.type("power").variant("predicted").position('atEquipment').value(cPower, Date.now(), this.unitPolicy.canonical.power); this.measurements.type("power").variant("predicted").position('atEquipment').value(cPower, Date.now(), this.unitPolicy.canonical.power);
//this.logger.debug(`Calculated power: ${cPower} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
return cPower; return cPower;
} }
// If no curve data is available, log a warning and return 0 // If no curve data is available, log a warning and return 0

View File

@@ -48,7 +48,12 @@ test('predictions use initialized medium pressure and not the minimum-pressure f
assert.equal(pressureStatus.initialized, true); assert.equal(pressureStatus.initialized, true);
assert.equal(pressureStatus.hasDifferential, true); assert.equal(pressureStatus.hasDifferential, true);
const expectedDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa canonical const rawDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa = 40000
assert.equal(Math.round(machine.predictFlow.fDimension), expectedDiff); // fDimension is clamped to [fValues.min, fValues.max]. The H05K curve's
// minimum pressure slice is 70000 Pa (700 mbar). A 40000 Pa differential
// is below the curve minimum, so it gets clamped to 70000.
const curveMinPressure = 70000;
const expected = Math.max(rawDiff, curveMinPressure);
assert.equal(Math.round(machine.predictFlow.fDimension), expected);
assert.ok(machine.predictFlow.fDimension > 0); assert.ok(machine.predictFlow.fDimension > 0);
}); });

View File

@@ -14,7 +14,10 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(status.source, null); assert.equal(status.source, null);
const noPressureValue = machine.getMeasuredPressure(); const noPressureValue = machine.getMeasuredPressure();
assert.equal(noPressureValue, 0); assert.equal(noPressureValue, 0);
assert.ok(machine.predictFlow.fDimension <= 1); // With no pressure injected, fDimension is clamped to the curve minimum
// (70000 Pa for H05K). Previously a schema default at pressure "1" made
// fValues.min=1 — that was a data-poisoning bug, now fixed.
assert.ok(machine.predictFlow.fDimension >= 70000);
// upstream only // upstream only
machine = createMachine(); machine = createMachine();
@@ -44,9 +47,11 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(Math.round(downstreamValue), downstreamOnly * 100); assert.equal(Math.round(downstreamValue), downstreamOnly * 100);
assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly * 100); assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly * 100);
// downstream and upstream // downstream and upstream — pick values whose differential (Pa) is above
// the curve's minimum pressure slice (70000 Pa = 700 mbar for H05K).
// 200 mbar upstream + 1100 mbar downstream → diff = 900 mbar = 90000 Pa.
machine = createMachine(); machine = createMachine();
const upstream = 700; const upstream = 200;
const downstream = 1100; const downstream = 1100;
machine.measurements.type('pressure').variant('measured').position('upstream').value(upstream, Date.now(), 'mbar'); machine.measurements.type('pressure').variant('measured').position('upstream').value(upstream, Date.now(), 'mbar');
machine.measurements.type('pressure').variant('measured').position('downstream').value(downstream, Date.now(), 'mbar'); machine.measurements.type('pressure').variant('measured').position('downstream').value(downstream, Date.now(), 'mbar');

View File

@@ -14,11 +14,16 @@ test('execSequence startup reaches operational with zero transition times', asyn
test('execMovement constrains controller position to safe bounds in operational state', async () => { test('execMovement constrains controller position to safe bounds in operational state', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } })); const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
const { max } = machine._resolveSetpointBounds(); const { min, max } = machine._resolveSetpointBounds();
// Test upper constraint: setpoint above max gets clamped to max
await machine.handleInput('parent', 'execMovement', max + 50);
let pos = machine.state.getCurrentPosition();
assert.equal(pos, max, `setpoint above max should be clamped to ${max}`);
// Test that a valid setpoint within bounds is applied as-is
await machine.handleInput('parent', 'execMovement', 10); await machine.handleInput('parent', 'execMovement', 10);
pos = machine.state.getCurrentPosition();
const pos = machine.state.getCurrentPosition(); assert.equal(pos, 10, 'setpoint within bounds should be applied as-is');
assert.ok(pos <= max); assert.ok(pos >= min && pos <= max);
assert.equal(pos, max);
}); });