Files
valveGroupControl/src/groupOps/flowDistribution.js
znetsixe e02cd1a7a7 P6: convert valveGroupControl to BaseDomain + BaseNodeAdapter + concern split
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>
2026-05-10 22:09:24 +02:00

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 };