Refactor of valveGroupControl to use the platform infrastructure (BaseDomain, BaseNodeAdapter, ChildRouter, commandRegistry, statusBadge). Extracts concerns into focused modules per .claude/refactor/MODULE_SPLIT.md generic template. Tests stay green; CONTRACT.md generated; legacy aliases preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
127 lines
4.9 KiB
JavaScript
127 lines
4.9 KiB
JavaScript
'use strict';
|
|
|
|
// Per-valve flow distribution. Splits the group's total flow across
|
|
// available valves proportional to Kv, then asks each valve back what
|
|
// flow it actually accepted and re-balances the residual. Also surfaces
|
|
// max delta-P across the group for downstream readers.
|
|
|
|
const DEFAULT_RECONCILIATION = Object.freeze({ maxPasses: 2, residualTolerance: 0.001 });
|
|
|
|
function isValveAvailable(valve) {
|
|
const currentState = valve?.state?.getCurrentState?.();
|
|
const mode = valve?.currentMode;
|
|
const kv = Number(valve?.kv);
|
|
return (
|
|
currentState !== 'off'
|
|
&& currentState !== 'maintenance'
|
|
&& mode !== 'maintenance'
|
|
&& Number.isFinite(kv)
|
|
&& kv > 0
|
|
);
|
|
}
|
|
|
|
function listAvailableValves(valves) {
|
|
return Object.entries(valves)
|
|
.filter(([, valve]) => isValveAvailable(valve))
|
|
.map(([id, valve]) => ({ id, valve }));
|
|
}
|
|
|
|
function _readAcceptedFlow(valve, flowUnit) {
|
|
const accepted = Number(
|
|
valve?.measurements
|
|
?.type('flow')
|
|
?.variant('predicted')
|
|
?.position('downstream')
|
|
?.getCurrentValue(flowUnit)
|
|
);
|
|
return Number.isFinite(accepted) ? accepted : null;
|
|
}
|
|
|
|
function solveFlowDistribution(totalFlow, availableEntries, reconciliation, flowUnit) {
|
|
const totalKv = availableEntries.reduce((sum, { valve }) => sum + Number(valve.kv), 0);
|
|
if (!Number.isFinite(totalKv) || totalKv <= 0) {
|
|
return { flowsById: {}, residual: Number(totalFlow) || 0, passes: 0 };
|
|
}
|
|
|
|
const targetById = Object.fromEntries(availableEntries.map(({ id }) => [id, 0]));
|
|
let residual = Number(totalFlow);
|
|
let passes = 0;
|
|
const maxPasses = Math.max(1, Number(reconciliation?.maxPasses) || DEFAULT_RECONCILIATION.maxPasses);
|
|
const tolerance = Math.max(0, Number(reconciliation?.residualTolerance) || DEFAULT_RECONCILIATION.residualTolerance);
|
|
|
|
while (passes < maxPasses && Number.isFinite(residual) && Math.abs(residual) > tolerance) {
|
|
availableEntries.forEach(({ id, valve }) => {
|
|
const share = (Number(valve.kv) / totalKv) * residual;
|
|
targetById[id] = Number(targetById[id]) + share;
|
|
valve.updateFlow('predicted', targetById[id], 'downstream', flowUnit);
|
|
});
|
|
|
|
let acceptedTotal = 0;
|
|
availableEntries.forEach(({ id, valve }) => {
|
|
const accepted = _readAcceptedFlow(valve, flowUnit);
|
|
if (Number.isFinite(accepted)) {
|
|
targetById[id] = accepted;
|
|
acceptedTotal += accepted;
|
|
return;
|
|
}
|
|
acceptedTotal += Number(targetById[id]) || 0;
|
|
});
|
|
|
|
residual = Number(totalFlow) - acceptedTotal;
|
|
passes += 1;
|
|
}
|
|
|
|
return { flowsById: targetById, residual: Number.isFinite(residual) ? residual : 0, passes };
|
|
}
|
|
|
|
function distributeFlow(vgc) {
|
|
const flowUnit = vgc.unitPolicy.output('flow');
|
|
const totalFlowMeasured = vgc._read('flow', 'measured', 'atEquipment', flowUnit);
|
|
const totalFlowPredicted = vgc._read('flow', 'predicted', 'atEquipment', flowUnit);
|
|
const totalFlow = Number.isFinite(totalFlowMeasured) ? totalFlowMeasured : totalFlowPredicted;
|
|
if (!Number.isFinite(totalFlow)) return;
|
|
|
|
const availableEntries = listAvailableValves(vgc.valves);
|
|
const availableIds = new Set(availableEntries.map((entry) => entry.id));
|
|
const totalKv = availableEntries.reduce((sum, { valve }) => sum + Number(valve.kv), 0);
|
|
|
|
if (!availableEntries.length || !Number.isFinite(totalKv) || totalKv <= 0) {
|
|
vgc.logger.warn('No available valves with valid Kv, setting assigned flow to 0.');
|
|
for (const valve of Object.values(vgc.valves)) {
|
|
valve.updateFlow('predicted', 0, 'downstream', flowUnit);
|
|
}
|
|
vgc._write('flow', 'predicted', 'atEquipment', 0, flowUnit);
|
|
vgc.lastFlowSolve = { passes: 0, residual: Number(totalFlow) || 0, targetTotal: Number(totalFlow) || 0, assignedTotal: 0 };
|
|
return;
|
|
}
|
|
|
|
const solve = solveFlowDistribution(totalFlow, availableEntries, vgc.flowReconciliation, flowUnit);
|
|
let assignedTotal = 0;
|
|
for (const [id, valve] of Object.entries(vgc.valves)) {
|
|
const flow = availableIds.has(id) ? (solve.flowsById[id] || 0) : 0;
|
|
valve.updateFlow('predicted', flow, 'downstream', flowUnit);
|
|
assignedTotal += flow;
|
|
}
|
|
|
|
vgc._write('flow', 'predicted', 'atEquipment', assignedTotal, flowUnit);
|
|
vgc.lastFlowSolve = { passes: solve.passes, residual: solve.residual, targetTotal: totalFlow, assignedTotal };
|
|
calcMaxDeltaP(vgc);
|
|
}
|
|
|
|
function calcMaxDeltaP(vgc) {
|
|
const pUnit = vgc.unitPolicy.output('pressure');
|
|
let maxDeltaP = 0;
|
|
for (const [id, valve] of Object.entries(vgc.valves)) {
|
|
const deltaP = Number(
|
|
valve.measurements.type('pressure').variant('predicted').position('delta').getCurrentValue(pUnit)
|
|
);
|
|
if (!Number.isFinite(deltaP)) continue;
|
|
vgc.logger.debug(`Delta P for valve ${id}: ${deltaP}`);
|
|
if (deltaP > maxDeltaP) maxDeltaP = deltaP;
|
|
}
|
|
vgc.maxDeltaP = maxDeltaP;
|
|
vgc._write('pressure', 'predicted', 'deltaMax', maxDeltaP, pUnit);
|
|
}
|
|
|
|
module.exports = { distributeFlow, calcMaxDeltaP, listAvailableValves, isValveAvailable, DEFAULT_RECONCILIATION };
|