P2 wave 1: extract concerns from pumpingStation specificClass
Splits pumpingStation/src/ into focused concern modules. specificClass.js
will be slimmed to an orchestrator in P2.9 (integration); for now both
the inlined logic AND the new modules coexist so tests stay green
throughout.
src/basin/ BasinGeometry + thresholdValidator (pure)
src/measurement/ flowAggregator + measurementRouter + calibration
src/control/ levelBased + flowBased(stub) + manual + index dispatcher
src/safety/ safetyController split into dryRun + overfill rules
src/commands/ registry array + handlers (canonical names from start)
src/editor.js 260 lines of SVG basin-diagram redraw, was inline in .html
examples/standalone-demo.js was if(require.main===module) at bottom of specificClass.js
CONTRACT.md canonical inputs + outputs + emitted events
Modified:
src/specificClass.js removed the 170-line standalone demo block
pumpingStation.html oneditprepare/oneditsave delegate to editor.{init,save}
pumpingStation.js added admin endpoint serving src/editor.js
102 basic tests pass (60 new + 42 existing).
specificClass.js itself is unchanged in behaviour — integration is P2.9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
src/control/flowBased.js
Normal file
11
src/control/flowBased.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// Placeholder — flow-based control mode is not yet implemented.
|
||||
// The dispatcher routes here when config.control.mode === 'flowbased',
|
||||
// at which point a real implementation should land in this file.
|
||||
async function run(ctx) {
|
||||
ctx?.logger?.debug?.('flow-based mode not yet implemented');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'flowbased',
|
||||
run,
|
||||
};
|
||||
20
src/control/index.js
Normal file
20
src/control/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const levelBased = require('./levelBased');
|
||||
const flowBased = require('./flowBased');
|
||||
const manual = require('./manual');
|
||||
|
||||
const strategies = {
|
||||
[levelBased.name]: levelBased,
|
||||
[flowBased.name]: flowBased,
|
||||
[manual.name]: manual,
|
||||
};
|
||||
|
||||
function dispatch(mode, ctx, controlState) {
|
||||
const s = strategies[mode];
|
||||
if (!s) {
|
||||
ctx.logger?.warn?.(`Unsupported control mode: ${mode}`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return s.run(ctx, controlState);
|
||||
}
|
||||
|
||||
module.exports = { strategies, dispatch, manual };
|
||||
92
src/control/levelBased.js
Normal file
92
src/control/levelBased.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const { interpolation } = require('generalFunctions');
|
||||
|
||||
const _interp = new interpolation();
|
||||
|
||||
// Maps [startLevel..maxLevel] → [0..100]. Outside the range,
|
||||
// interpolate_lin_single_point clamps to o_min / o_max.
|
||||
function _scaleLevelToFlowPercent(level, levelbased, logger) {
|
||||
const { startLevel, maxLevel } = levelbased;
|
||||
logger?.debug?.(`Scaling startLevel=${startLevel} maxLevel=${maxLevel}`);
|
||||
return _interp.interpolate_lin_single_point(level, startLevel, maxLevel, 0, 100);
|
||||
}
|
||||
|
||||
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
|
||||
if (!machineGroups || Object.keys(machineGroups).length === 0) return;
|
||||
await Promise.all(
|
||||
Object.values(machineGroups).map((group) =>
|
||||
group.handleInput('parent', percentControl).catch((err) => {
|
||||
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err.message}`);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function _applyMachineLevelControl(machines, percentControl, logger) {
|
||||
const filtered = Object.values(machines).filter((machine) => {
|
||||
const pos = machine?.config?.functionality?.positionVsParent;
|
||||
return (pos === 'downstream' || pos === 'atequipment');
|
||||
});
|
||||
if (!filtered.length) return;
|
||||
|
||||
const perMachine = percentControl / filtered.length;
|
||||
for (const machine of filtered) {
|
||||
try {
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
await machine.handleInput('parent', 'execMovement', perMachine);
|
||||
} catch (err) {
|
||||
logger?.error?.(`Failed to start machine "${machine.config?.general?.name}": ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _pickVariant(measurements, type, variants, position, unit) {
|
||||
for (const variant of variants) {
|
||||
const val = measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
|
||||
if (!Number.isFinite(val)) continue;
|
||||
return val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function run(ctx, controlState) {
|
||||
const { measurements, config, logger, machineGroups, levelVariants } = ctx;
|
||||
const { startLevel, minLevel } = config.control.levelbased;
|
||||
const levelUnit = measurements.getUnit('level');
|
||||
|
||||
const variants = levelVariants || ['measured', 'predicted'];
|
||||
const level = _pickVariant(measurements, 'level', variants, 'atequipment', levelUnit);
|
||||
if (level == null) {
|
||||
logger?.warn?.('No valid level found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Three-zone level control:
|
||||
// level < minLevel → STOP (unconditional MGC shutdown)
|
||||
// minLevel ≤ level < startLevel → DEAD ZONE (no-op)
|
||||
// level ≥ startLevel → RUN (linear ramp → MGC)
|
||||
if (level < minLevel) {
|
||||
controlState.percControl = 0;
|
||||
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||
return;
|
||||
}
|
||||
|
||||
if (level < startLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawPercControl = _scaleLevelToFlowPercent(level, config.control.levelbased, logger);
|
||||
const percControl = Math.max(0, rawPercControl);
|
||||
controlState.percControl = percControl;
|
||||
logger?.debug?.(`Level-based control: level=${level} percControl=${percControl}`);
|
||||
|
||||
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'levelbased',
|
||||
run,
|
||||
// Exposed for future reuse / tests; not part of the strategy contract.
|
||||
_scaleLevelToFlowPercent,
|
||||
_applyMachineGroupLevelControl,
|
||||
_applyMachineLevelControl,
|
||||
};
|
||||
36
src/control/manual.js
Normal file
36
src/control/manual.js
Normal file
@@ -0,0 +1,36 @@
|
||||
async function run() {
|
||||
// No-op: manual mode is event-driven via set.demand → forwardDemand,
|
||||
// not tick-driven.
|
||||
}
|
||||
|
||||
async function forwardDemand(ctx, demand) {
|
||||
const { machineGroups, machines, logger } = ctx;
|
||||
logger?.info?.(`Manual demand forwarded: ${demand}`);
|
||||
|
||||
if (machineGroups && Object.keys(machineGroups).length > 0) {
|
||||
await Promise.all(
|
||||
Object.values(machineGroups).map((group) =>
|
||||
group.handleInput('parent', demand).catch((err) => {
|
||||
logger?.error?.(`Failed to forward demand to group: ${err.message}`);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (machines && Object.keys(machines).length > 0) {
|
||||
const perMachine = demand / Object.keys(machines).length;
|
||||
for (const machine of Object.values(machines)) {
|
||||
try {
|
||||
await machine.handleInput('parent', 'execMovement', perMachine);
|
||||
} catch (err) {
|
||||
logger?.error?.(`Failed to forward demand to machine: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'manual',
|
||||
run,
|
||||
forwardDemand,
|
||||
};
|
||||
Reference in New Issue
Block a user