Adds to scalar setters whose payloads are
plain numbers OR {value, unit}. Skipped where payload is compound or
mode-dependent (control-%, {F, C: [...]}, etc.) — documented inline.
Every command gains a description field for wikiGen consumption.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
256 lines
10 KiB
JavaScript
256 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
// ValveGroupControl — S88 Unit orchestrator coordinating valve children.
|
|
// Concern modules under src/{groupOps,sources,io,commands} carry the
|
|
// real work; this file stitches them together: registration, valve event
|
|
// routing, source fluid-contract aggregation, mode/sequence dispatch.
|
|
|
|
const { BaseDomain, UnitPolicy, state } = require('generalFunctions');
|
|
const flowDist = require('./groupOps/flowDistribution');
|
|
const sources = require('./sources/fluidContract');
|
|
const io = require('./io/output');
|
|
|
|
// Source softwareTypes after BaseDomain canonicalisation
|
|
// (rotatingmachine→machine, machinegroupcontrol→machinegroup).
|
|
const SOURCE_SOFTWARE_TYPES = ['machine', 'machinegroup', 'pumpingstation', 'valvegroupcontrol'];
|
|
|
|
class ValveGroupControl extends BaseDomain {
|
|
static name = 'valveGroupControl';
|
|
|
|
static unitPolicy = UnitPolicy.declare({
|
|
canonical: { flow: 'm3/s', pressure: 'Pa' },
|
|
output: { flow: 'm3/h', pressure: 'mbar' },
|
|
requireUnitForTypes: ['pressure', 'flow'],
|
|
});
|
|
|
|
configure() {
|
|
this.config = this.configUtils.updateConfig(this.config, {
|
|
general: { unit: this.unitPolicy.output('flow') },
|
|
});
|
|
|
|
this.valves = {};
|
|
this._valveListeners = new Map();
|
|
this.sources = {};
|
|
this._sourceListeners = new Map();
|
|
this.fluidContract = {
|
|
status: 'unknown', serviceType: null, upstreamServiceTypes: [],
|
|
sourceCount: 0, message: 'No upstream fluid sources registered.',
|
|
};
|
|
this.flowReconciliation = { ...flowDist.DEFAULT_RECONCILIATION };
|
|
this.lastFlowSolve = { passes: 0, residual: 0, targetTotal: 0, assignedTotal: 0 };
|
|
this.maxDeltaP = 0;
|
|
this.currentMode = this.config.mode.current;
|
|
|
|
this.state = new state({}, this.logger);
|
|
this.state.stateManager.currentState = 'operational';
|
|
|
|
this.router.onRegister('valve', (child) => this._registerValve(child));
|
|
for (const swType of SOURCE_SOFTWARE_TYPES) {
|
|
this.router.onRegister(swType, (child, canonicalKey) => this._registerSource(child, canonicalKey));
|
|
}
|
|
}
|
|
|
|
_isValveLike(child) {
|
|
return Boolean(
|
|
child
|
|
&& typeof child.updateFlow === 'function'
|
|
&& child.state && typeof child.state.getCurrentState === 'function'
|
|
&& child.measurements
|
|
);
|
|
}
|
|
|
|
_registerValve(child) {
|
|
if (!this._isValveLike(child)) {
|
|
this.logger.warn('registerChild skipped: child is not valve-like');
|
|
return false;
|
|
}
|
|
const id = child.config?.general?.id || child.config?.general?.name || `valve-${Object.keys(this.valves).length + 1}`;
|
|
if (this.valves[id]) {
|
|
this.logger.debug(`registerChild skipped: valve ${id} already registered`);
|
|
return true;
|
|
}
|
|
const positionVsParent = child.positionVsParent
|
|
|| child.config?.functionality?.positionVsParent
|
|
|| 'atEquipment';
|
|
child.positionVsParent = positionVsParent;
|
|
this.valves[id] = child;
|
|
this._bindValveEvents(id, child);
|
|
this.calcValveFlows();
|
|
this.calcMaxDeltaP();
|
|
sources.refreshFluidContract(this);
|
|
this.logger.info(`Valve '${id}' registered at ${positionVsParent}.`);
|
|
return true;
|
|
}
|
|
|
|
_registerSource(child, softwareType) {
|
|
const positionVsParent = child.positionVsParent
|
|
|| child.config?.functionality?.positionVsParent
|
|
|| 'atEquipment';
|
|
return sources.registerSource(this, child, positionVsParent, softwareType);
|
|
}
|
|
|
|
_bindValveEvents(valveId, valve) {
|
|
const handlers = {
|
|
onPositionChange: () => { this.logger.debug(`Valve ${valveId} position changed, recalculating flows.`); this.calcValveFlows(); },
|
|
onDeltaPChange: () => { this.logger.debug(`Valve ${valveId} deltaP changed, recalculating max deltaP.`); this.calcMaxDeltaP(); },
|
|
};
|
|
if (valve.state?.emitter?.on) valve.state.emitter.on('positionChange', handlers.onPositionChange);
|
|
if (valve.emitter?.on) valve.emitter.on('deltaPChange', handlers.onDeltaPChange);
|
|
this._valveListeners.set(valveId, { valve, handlers });
|
|
}
|
|
|
|
_unbindValveEvents(valveId) {
|
|
const entry = this._valveListeners.get(valveId);
|
|
if (!entry) return;
|
|
const { valve, handlers } = entry;
|
|
if (handlers.onPositionChange && valve.state?.emitter?.off) valve.state.emitter.off('positionChange', handlers.onPositionChange);
|
|
if (handlers.onDeltaPChange && valve.emitter?.off) valve.emitter.off('deltaPChange', handlers.onDeltaPChange);
|
|
this._valveListeners.delete(valveId);
|
|
}
|
|
|
|
registerOnChildEvents() {}
|
|
|
|
destroy() {
|
|
for (const id of this._valveListeners.keys()) this._unbindValveEvents(id);
|
|
for (const id of this._sourceListeners.keys()) sources.unbindSource(this, id);
|
|
}
|
|
|
|
// ── measurement read/write helpers used by concern modules ─────────
|
|
_outputUnitForType(type) {
|
|
switch (String(type || '').toLowerCase()) {
|
|
case 'flow': return this.unitPolicy.output('flow');
|
|
case 'pressure': return this.unitPolicy.output('pressure');
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
_read(type, variant, position, unit = null) {
|
|
const u = unit || this._outputUnitForType(type);
|
|
return this.measurements.type(type).variant(variant).position(position).getCurrentValue(u || undefined);
|
|
}
|
|
|
|
_write(type, variant, position, value, unit = null, timestamp = Date.now()) {
|
|
const v = Number(value);
|
|
if (!Number.isFinite(v)) return;
|
|
this.measurements.type(type).variant(variant).position(position).value(v, timestamp, unit || undefined);
|
|
}
|
|
|
|
// ── public surface used by adapter, tests, commands, valves ────────
|
|
getAvailableValves() { return flowDist.listAvailableValves(this.valves); }
|
|
calcValveFlows() { flowDist.distributeFlow(this); this.notifyOutputChanged(); }
|
|
calcMaxDeltaP() { flowDist.calcMaxDeltaP(this); }
|
|
getFluidContract() { return sources.getFluidContract(this); }
|
|
|
|
isValidSourceForMode(source, mode) {
|
|
const allowedSourcesSet = this.config.mode.allowedSources[mode] || [];
|
|
return allowedSourcesSet.has(source);
|
|
}
|
|
|
|
setMode(newMode) {
|
|
const availableModes = Array.isArray(this.defaultConfig?.mode?.current?.rules?.values)
|
|
? this.defaultConfig.mode.current.rules.values.map((m) => m.value)
|
|
: Object.keys(this.config?.mode?.allowedSources || {});
|
|
if (!availableModes.includes(newMode)) {
|
|
this.logger.warn(`Invalid mode '${newMode}'. Allowed modes are: ${availableModes.join(', ')}`);
|
|
return;
|
|
}
|
|
this.currentMode = newMode;
|
|
this.logger.info(`Mode successfully changed to '${newMode}'.`);
|
|
this.notifyOutputChanged();
|
|
}
|
|
|
|
async executeSequence(sequenceName) {
|
|
const sequence = this.config.sequences[sequenceName];
|
|
if (!sequence || sequence.size === 0) {
|
|
this.logger.warn(`Sequence '${sequenceName}' not defined.`);
|
|
return;
|
|
}
|
|
this.logger.info(` --------- Executing sequence: ${sequenceName} -------------`);
|
|
for (const stateName of sequence) {
|
|
try { await this.state.transitionToState(stateName); }
|
|
catch (error) { this.logger.error(`Error during sequence '${sequenceName}': ${error}`); break; }
|
|
}
|
|
}
|
|
|
|
updateFlow(variant, value, position, unit = this.unitPolicy.output('flow')) {
|
|
if (value === null || value === undefined) {
|
|
this.logger.warn(`Received null or undefined value for flow update. Variant: ${variant}, Position: ${position}`);
|
|
return;
|
|
}
|
|
if (variant !== 'measured' && variant !== 'predicted') {
|
|
this.logger.warn(`Unrecognized variant '${variant}' for flow update.`);
|
|
return;
|
|
}
|
|
this.logger.debug(`Updating ${variant} flow for position ${position} with value ${value}`);
|
|
this._write('flow', variant, position, value, unit);
|
|
this.calcValveFlows();
|
|
}
|
|
|
|
updateMeasurement(variant, subType, value, position, unit) {
|
|
this.logger.debug(`---------------------- updating ${subType} ------------------ `);
|
|
if (subType === 'flow') {
|
|
this.updateFlow(variant, value, position, unit || this.unitPolicy.output('flow'));
|
|
return;
|
|
}
|
|
this.logger.error(`Type '${subType}' not recognized for measured update.`);
|
|
}
|
|
|
|
async handleInput(source, action, parameter) {
|
|
if (!this.isValidSourceForMode(source, this.currentMode)) {
|
|
const warningTxt = `Source '${source}' is not valid for mode '${this.currentMode}'.`;
|
|
this.logger.warn(warningTxt);
|
|
return { status: false, feedback: warningTxt };
|
|
}
|
|
this.logger.info(`Handling input from source '${source}' with action '${action}' in mode '${this.currentMode}'.`);
|
|
try {
|
|
const flowUnit = this.unitPolicy.output('flow');
|
|
switch (action) {
|
|
case 'execSequence':
|
|
await this.executeSequence(parameter);
|
|
break;
|
|
case 'totalFlowChange':
|
|
if (parameter && typeof parameter === 'object' && Object.prototype.hasOwnProperty.call(parameter, 'value')) {
|
|
await this.updateFlow(parameter.variant || 'measured', parameter.value, parameter.position || 'atEquipment', parameter.unit || flowUnit);
|
|
} else if (parameter && typeof parameter === 'object' && Object.prototype.hasOwnProperty.call(parameter, 'q')) {
|
|
await this.updateFlow('measured', Number(parameter.q), 'atEquipment', parameter.unit || flowUnit);
|
|
} else {
|
|
await this.updateFlow('measured', Number(parameter), 'atEquipment', flowUnit);
|
|
}
|
|
break;
|
|
case 'emergencyStop':
|
|
case 'emergencystop':
|
|
this.logger.warn(`Emergency stop activated by '${source}'.`);
|
|
await this.executeSequence('emergencystop');
|
|
break;
|
|
case 'statusCheck':
|
|
this.logger.info(`Status Check: Mode = '${this.currentMode}', Source = '${source}'.`);
|
|
break;
|
|
default:
|
|
this.logger.warn(`Action '${action}' is not implemented.`);
|
|
break;
|
|
}
|
|
this.logger.debug(`Action '${action}' successfully executed`);
|
|
return { status: true, feedback: `Action '${action}' successfully executed.` };
|
|
} catch (error) {
|
|
this.logger.error(`Error handling input: ${error}`);
|
|
return { status: false, feedback: `Error handling input: ${error.message || error}` };
|
|
}
|
|
}
|
|
|
|
setReconcileIntervalSeconds(sec) {
|
|
const ms = Math.max(100, Math.round(Number(sec) * 1000));
|
|
this.emitter.emit('reconcileIntervalChange', ms);
|
|
this.logger.info(`Flow reconciliation interval updated to ${sec}s (${ms}ms).`);
|
|
}
|
|
|
|
// Periodic reconciliation — adapter fires this each tickInterval. Keeps
|
|
// per-valve assigned flow in sync if a child's accepted value drifts
|
|
// between event-driven recalcs.
|
|
tick() { this.calcValveFlows(); }
|
|
|
|
getOutput() { return io.getOutput(this); }
|
|
getStatusBadge() { return io.getStatusBadge(this); }
|
|
}
|
|
|
|
module.exports = ValveGroupControl;
|