3 Commits

Author SHA1 Message Date
znetsixe
693517cc8f fix: conditional abort recovery — don't auto-transition on routine aborts
The unconditional transition to 'operational' after every movement abort
caused a bounce loop when MGC called abortActiveMovements on each demand
tick: abort→operational→new-flowmovement→abort→operational→... endlessly.
Pumps never reached their setpoint.

Fix: abortCurrentMovement now takes an options.returnToOperational flag
(default false). Routine MGC aborts leave the pump in accelerating/
decelerating — the pump continues its residual movement and reaches
operational naturally. Shutdown/emergency-stop paths pass
returnToOperational:true so the FSM unblocks for the stopping transition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:01:41 +02:00
znetsixe
086e5fe751 fix: remove bogus machineCurve default that poisoned prediction splines
The schema default for machineCurve.nq had a dummy pressure slice at
key "1" with x=[1..5] y=[10..50]. configUtils.updateConfig deep-merges
defaults into the real config, so this fake slice survived alongside the
real pressure slices (70000, 80000, ..., 390000 Pa). The predict class
then included it in its pressure-dimension spline, pulling all
interpolated y-values toward the dummy data at low pressures and
producing NEGATIVE flow predictions (e.g. -243 m³/h) where the real
curve is strictly positive.

Fix: default to empty objects {nq: {}, np: {}} so the deep merge adds
nothing. The validateMachineCurve function already returns the whole
default if the real curve is missing or invalid, so the empty default
doesn't break the no-curve-data path — it just stops poisoning the
real curve data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:27:59 +02:00
znetsixe
29b78a3f9b fix(childRegistrationUtils): alias rotatingmachine/machinegroupcontrol so production parents see them
The MGC and pumpingStation registerChild handlers dispatch on
softwareType === 'machine' / 'machinegroup' / 'pumpingstation' /
'measurement'. But buildConfig sets functionality.softwareType to the
lowercased node name, so in production rotatingMachine reports
'rotatingmachine' and machineGroupControl reports 'machinegroupcontrol'.
Result: the MGC <-> rotatingMachine and pumpingStation <-> MGC wiring
silently never hit the right branch in production, even though every
unit test passes (tests pass an already-aliased softwareType manually).

Fix: tiny SOFTWARE_TYPE_ALIASES map at the central registerChild
dispatcher in childRegistrationUtils. Real production names get
translated to the dispatch keys parents already check for, while tests
that pass already-aliased keys are unaffected (their values aren't in
the alias map and pass through unchanged).

  rotatingmachine        -> machine
  machinegroupcontrol    -> machinegroup

Verified end-to-end on Dockerized Node-RED: MGC now reports
'3 machine(s) connected' when wired to 3 rotatingMachine ports;
pumpingStation registers MGC as a machinegroup child and listens to
its predicted-flow stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:53:21 +02:00
3 changed files with 45 additions and 47 deletions

View File

@@ -282,43 +282,9 @@
}, },
"machineCurve": { "machineCurve": {
"default": { "default": {
"nq": { "nq": {},
"1": { "np": {}
"x": [ },
1,
2,
3,
4,
5
],
"y": [
10,
20,
30,
40,
50
]
}
},
"np": {
"1": {
"x": [
1,
2,
3,
4,
5
],
"y": [
10,
20,
30,
40,
50
]
}
}
},
"rules": { "rules": {
"type": "machineCurve", "type": "machineCurve",
"description": "All machine curves must have a 'nq' and 'np' curve. nq stands for the flow curve, np stands for the power curve. Together they form the efficiency curve." "description": "All machine curves must have a 'nq' and 'np' curve. nq stands for the flow curve, np stands for the power curve. Together they form the efficiency curve."

View File

@@ -1,3 +1,17 @@
// Map a child's raw softwareType (the lowercased node name from
// buildConfig) to the "role" key that parent registerChild() handlers
// dispatch on. Without this, MGC/pumpingStation register-handlers (which
// branch on 'machine' / 'machinegroup' / 'pumpingstation' / 'measurement')
// silently miss every real production child because rotatingMachine
// reports softwareType='rotatingmachine' and machineGroupControl reports
// 'machinegroupcontrol'. Existing tests that pass already-aliased keys
// ('machine', 'machinegroup') stay green because those aren't in the
// alias map and pass through unchanged.
const SOFTWARE_TYPE_ALIASES = {
rotatingmachine: 'machine',
machinegroupcontrol: 'machinegroup',
};
class ChildRegistrationUtils { class ChildRegistrationUtils {
constructor(mainClass) { constructor(mainClass) {
this.mainClass = mainClass; this.mainClass = mainClass;
@@ -15,7 +29,8 @@ class ChildRegistrationUtils {
return false; return false;
} }
const softwareType = (child.config.functionality.softwareType || '').toLowerCase(); const rawSoftwareType = (child.config.functionality.softwareType || '').toLowerCase();
const softwareType = SOFTWARE_TYPE_ALIASES[rawSoftwareType] || rawSoftwareType;
const name = child.config.general.name || child.config.general.id || 'unknown'; const name = child.config.general.name || child.config.general.id || 'unknown';
const id = child.config.general.id || name; const id = child.config.general.id || name;

View File

@@ -85,17 +85,24 @@ class state{
this.emitter.emit("movementComplete", { position: targetPosition }); this.emitter.emit("movementComplete", { position: targetPosition });
await this.transitionToState("operational"); await this.transitionToState("operational");
} catch (error) { } catch (error) {
// Abort path: return to 'operational' so a subsequent shutdown/emergency // Abort path: only return to 'operational' when explicitly requested
// sequence can proceed. Without this, the FSM remains stuck in // (shutdown/emergency-stop needs it to unblock the FSM). Routine MGC
// accelerating/decelerating and blocks stopping/idle transitions. // demand-update aborts must NOT auto-transition — doing so causes a
// bounce loop where every tick aborts → operational → new move →
// abort → operational → ... and the pump never reaches its setpoint.
const msg = typeof error === 'string' ? error : error?.message; const msg = typeof error === 'string' ? error : error?.message;
if (msg === 'Transition aborted' || msg === 'Movement aborted') { if (msg === 'Transition aborted' || msg === 'Movement aborted') {
this.logger.debug(`Movement aborted; returning to 'operational' to unblock further transitions.`); if (this._returnToOperationalOnAbort) {
try { this.logger.debug(`Movement aborted; returning to 'operational' (requested by caller).`);
await this.transitionToState("operational"); try {
} catch (e) { await this.transitionToState("operational");
this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`); } catch (e) {
this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`);
}
} else {
this.logger.debug(`Movement aborted; staying in current state (routine abort).`);
} }
this._returnToOperationalOnAbort = false;
this.emitter.emit("movementAborted", { position: targetPosition }); this.emitter.emit("movementAborted", { position: targetPosition });
} else { } else {
this.logger.error(error); this.logger.error(error);
@@ -105,9 +112,19 @@ class state{
// -------- State Transition Methods -------- // // -------- State Transition Methods -------- //
abortCurrentMovement(reason = "group override") { /**
* @param {string} reason - human-readable abort reason
* @param {object} [options]
* @param {boolean} [options.returnToOperational=false] - when true the FSM
* transitions back to 'operational' after the abort so a subsequent
* shutdown/emergency-stop sequence can proceed. Set to false (default)
* for routine demand updates where the caller will send a new movement
* immediately — auto-transitioning would cause a bounce loop.
*/
abortCurrentMovement(reason = "group override", options = {}) {
if (this.abortController && !this.abortController.signal.aborted) { if (this.abortController && !this.abortController.signal.aborted) {
this.logger.warn(`Aborting movement: ${reason}`); this.logger.warn(`Aborting movement: ${reason}`);
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
this.abortController.abort(); this.abortController.abort();
} }
} }