Compare commits
1 Commits
f8f71a4f1c
...
af02d36b07
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af02d36b07 |
@@ -141,6 +141,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"useRendezvous": {
|
||||
"default": true,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "If true, every dispatch is routed through the rendezvous planner regardless of control strategy: per-pump moves are delayed so all pumps reach their setpoint at the same wall-clock instant t* = max(eta_i). If false, all flowmovement commands fire immediately and each pump ramps at its own speed (legacy behaviour)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mode": {
|
||||
"current": {
|
||||
"default": "optimalControl",
|
||||
@@ -201,7 +210,7 @@
|
||||
"rules": {
|
||||
"type": "object",
|
||||
"schema": {
|
||||
"optimalcontrol": {
|
||||
"optimalControl": {
|
||||
"default": ["parent", "GUI", "physical", "API"],
|
||||
"rules": {
|
||||
"type": "set",
|
||||
@@ -209,13 +218,21 @@
|
||||
"description": "Command sources allowed in optimalControl mode."
|
||||
}
|
||||
},
|
||||
"prioritycontrol": {
|
||||
"priorityControl": {
|
||||
"default": ["parent", "GUI", "physical", "API"],
|
||||
"rules": {
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
"description": "Command sources allowed in priorityControl mode."
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"default": ["parent", "GUI"],
|
||||
"rules": {
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
"description": "Command sources allowed in maintenance mode. Status/inspection only — physical/HMI and API writes are dropped."
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Specifies the valid command sources recognized by the machine group controller for each mode."
|
||||
|
||||
@@ -23,6 +23,13 @@ class state{
|
||||
|
||||
this.delayedMove = null;
|
||||
this.mode = this.config.mode.current;
|
||||
// Monotonic counter incremented on every EXTERNAL abort (i.e. one
|
||||
// initiated outside the in-flight sequence — typically MGC reacting
|
||||
// to a new demand). executeSequence captures the value at entry and
|
||||
// breaks its for-loop if the counter advances mid-sequence, so a
|
||||
// shutdown that was already past its ramp-down step doesn't barge
|
||||
// through stopping → coolingdown when a re-engage arrives.
|
||||
this.sequenceAbortToken = 0;
|
||||
|
||||
// Log initialization
|
||||
this.logger.info("State class initialized.");
|
||||
@@ -151,6 +158,14 @@ class state{
|
||||
if (this.abortController && !this.abortController.signal.aborted) {
|
||||
this.logger.warn(`Aborting movement: ${reason}`);
|
||||
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
|
||||
// Only external aborts (returnToOperational=false) advance the
|
||||
// sequence-abort token. Sequence-internal aborts (e.g. shutdown's
|
||||
// own setpoint(0) being pre-empted by a fresher shutdown/estop)
|
||||
// come from inside executeSequence and must not terminate their
|
||||
// own loop.
|
||||
if (!options.returnToOperational) {
|
||||
this.sequenceAbortToken += 1;
|
||||
}
|
||||
this.abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
class stateManager {
|
||||
constructor(config, logger) {
|
||||
this.currentState = config.state.current;
|
||||
// Wall-clock entry timestamp into currentState. Used by
|
||||
// getRemainingTransitionS() so callers (e.g. MGC movement planner)
|
||||
// can compute exact remaining time for timed states without
|
||||
// approximating from the full configured duration.
|
||||
this.stateEnteredAt = Date.now();
|
||||
this.availableStates = config.state.available;
|
||||
this.descriptions = config.state.descriptions;
|
||||
this.logger = logger;
|
||||
@@ -64,6 +69,17 @@ class stateManager {
|
||||
return this.currentState;
|
||||
}
|
||||
|
||||
// Seconds remaining in the current timed state (warmingup, coolingdown,
|
||||
// starting, stopping, …). Returns 0 for untimed states or once the
|
||||
// configured duration has elapsed. The MGC movement planner uses this to
|
||||
// compute exact rendezvous time for protected (non-interruptible) states.
|
||||
getRemainingTransitionS() {
|
||||
const d = this.transitionTimes?.[this.currentState] || 0;
|
||||
if (d <= 0) return 0;
|
||||
const elapsed = (Date.now() - this.stateEnteredAt) / 1000;
|
||||
return Math.max(0, d - elapsed);
|
||||
}
|
||||
|
||||
transitionTo(newState,signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal && signal.aborted) {
|
||||
@@ -89,6 +105,7 @@ class stateManager {
|
||||
if (transitionDuration > 0) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.currentState = newState;
|
||||
this.stateEnteredAt = Date.now();
|
||||
resolve(`Transition from ${this.currentState} to ${newState} completed in ${transitionDuration}s.`);
|
||||
}, transitionDuration * 1000);
|
||||
if (signal) {
|
||||
@@ -99,6 +116,7 @@ class stateManager {
|
||||
}
|
||||
} else {
|
||||
this.currentState = newState;
|
||||
this.stateEnteredAt = Date.now();
|
||||
resolve(`Immediate transition to ${this.currentState} completed.`);
|
||||
}
|
||||
});
|
||||
|
||||
77
test/basic/stateManagerRemaining.basic.test.js
Normal file
77
test/basic/stateManagerRemaining.basic.test.js
Normal file
@@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const StateManager = require('../../src/state/stateManager');
|
||||
|
||||
// Minimal config that satisfies the stateManager constructor's expectations.
|
||||
// Real configs come from configs/<node>.json; we hand-roll one here so the
|
||||
// test doesn't drag the whole node-config plumbing in for a 30-line getter.
|
||||
function makeConfig(initial = 'idle', times = { idle: 0, warmingup: 5 }) {
|
||||
return {
|
||||
state: {
|
||||
current: initial,
|
||||
available: ['idle', 'warmingup', 'operational'],
|
||||
descriptions: { idle: 'off', warmingup: 'warming', operational: 'running' },
|
||||
allowedTransitions: {
|
||||
idle: new Set(['warmingup']),
|
||||
warmingup: new Set(['operational']),
|
||||
operational: new Set(['idle']),
|
||||
},
|
||||
activeStates: new Set(['operational']),
|
||||
},
|
||||
time: times,
|
||||
};
|
||||
}
|
||||
|
||||
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
||||
|
||||
test('getRemainingTransitionS returns 0 for untimed initial state', () => {
|
||||
const sm = new StateManager(makeConfig('idle'), noopLogger);
|
||||
assert.equal(sm.getRemainingTransitionS(), 0);
|
||||
});
|
||||
|
||||
test('getRemainingTransitionS returns ≈full duration just after entering a timed state', async () => {
|
||||
const sm = new StateManager(makeConfig('idle', { idle: 0, warmingup: 5 }), noopLogger);
|
||||
// Force-enter "warmingup" via the constructor's state machinery: simulate
|
||||
// by manually setting fields the way transitionTo would.
|
||||
sm.currentState = 'warmingup';
|
||||
sm.stateEnteredAt = Date.now();
|
||||
const remaining = sm.getRemainingTransitionS();
|
||||
assert.ok(remaining > 4.9 && remaining <= 5.0, `expected ~5s, got ${remaining}`);
|
||||
});
|
||||
|
||||
test('getRemainingTransitionS decays with elapsed time', async () => {
|
||||
const sm = new StateManager(makeConfig('idle', { idle: 0, warmingup: 5 }), noopLogger);
|
||||
sm.currentState = 'warmingup';
|
||||
sm.stateEnteredAt = Date.now() - 2000; // pretend we entered 2s ago
|
||||
const remaining = sm.getRemainingTransitionS();
|
||||
assert.ok(remaining > 2.9 && remaining <= 3.0, `expected ~3s, got ${remaining}`);
|
||||
});
|
||||
|
||||
test('getRemainingTransitionS clamps to 0 once duration has elapsed', () => {
|
||||
const sm = new StateManager(makeConfig('idle', { idle: 0, warmingup: 5 }), noopLogger);
|
||||
sm.currentState = 'warmingup';
|
||||
sm.stateEnteredAt = Date.now() - 60_000; // a minute ago, way past 5s
|
||||
assert.equal(sm.getRemainingTransitionS(), 0);
|
||||
});
|
||||
|
||||
test('transitionTo refreshes stateEnteredAt on the immediate branch', async () => {
|
||||
const sm = new StateManager(makeConfig('idle', { idle: 0 }), noopLogger);
|
||||
const before = sm.stateEnteredAt;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await sm.transitionTo('warmingup');
|
||||
assert.ok(sm.stateEnteredAt > before, 'stateEnteredAt should advance on transition');
|
||||
});
|
||||
|
||||
test('transitionTo refreshes stateEnteredAt on the timed branch', async () => {
|
||||
// Tiny duration so the test stays fast.
|
||||
const sm = new StateManager(makeConfig('idle', { idle: 0.05, warmingup: 0 }), noopLogger);
|
||||
const before = sm.stateEnteredAt;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await sm.transitionTo('warmingup');
|
||||
assert.ok(sm.stateEnteredAt > before, 'stateEnteredAt should advance after timed transition');
|
||||
// And remaining should now be 0 (we're in warmingup, but warmingup duration is 0).
|
||||
assert.equal(sm.getRemainingTransitionS(), 0);
|
||||
});
|
||||
Reference in New Issue
Block a user