- tools/physics-sanity/ — JS library of cross-node balance helpers
(mass / hydraulic / hydraulic-power / oxygen-transfer / energy) with
7 unit tests + a CLI demo. Designed for `require()` from per-node
integration tests where shape-based unit tests miss physically-
impossible plant states.
- tools/docker-compose.yml + tools/mcp/{node-red-admin,influxdb,browser}
scaffolding — placeholder Dockerfiles + a ROADMAP.md for the Node-RED
admin MCP. Compose file is the target shape for the Q3-2026 migration
to the central MCP server; the per-service Dockerfile stays in this
repo as the canonical definition either way. Implementations are TODO.
- tools/README.md — top-level tooling index; documents the CI order for
running every tool on a PR.
- .gitignore: ignore tools/.env (developer-specific MCP endpoints).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
112 lines
3.7 KiB
JavaScript
112 lines
3.7 KiB
JavaScript
'use strict';
|
|
|
|
const PA_PER_MBAR = 100;
|
|
const SECONDS_PER_HOUR = 3600;
|
|
|
|
function withinTolerance(observed, expected, absTol, relTol) {
|
|
if (!Number.isFinite(observed) || !Number.isFinite(expected)) return false;
|
|
const absErr = Math.abs(observed - expected);
|
|
if (absErr <= absTol) return true;
|
|
if (Math.abs(expected) > 0) return absErr / Math.abs(expected) <= relTol;
|
|
return false;
|
|
}
|
|
|
|
function assertMassBalance({ inflowKgPerS, outflowKgPerS, accumulationKgPerS = 0, label = 'mass', absTol = 1e-6, relTol = 1e-3 } = {}) {
|
|
const expected = inflowKgPerS - accumulationKgPerS;
|
|
const ok = withinTolerance(outflowKgPerS, expected, absTol, relTol);
|
|
return {
|
|
ok,
|
|
label,
|
|
inflowKgPerS,
|
|
outflowKgPerS,
|
|
accumulationKgPerS,
|
|
residualKgPerS: inflowKgPerS - outflowKgPerS - accumulationKgPerS,
|
|
relErr: expected === 0 ? null : (outflowKgPerS - expected) / expected,
|
|
};
|
|
}
|
|
|
|
function assertHydraulicBalance({ headerSuctionPa, headerDischargePa, pumpHeadPa, frictionPa = 0, staticHeadPa = 0, label = 'hydraulic', absTol = 50, relTol = 1e-3 } = {}) {
|
|
const lhs = headerDischargePa - headerSuctionPa;
|
|
const rhs = pumpHeadPa - frictionPa - staticHeadPa;
|
|
const ok = withinTolerance(lhs, rhs, absTol, relTol);
|
|
return {
|
|
ok,
|
|
label,
|
|
lhsPa: lhs,
|
|
rhsPa: rhs,
|
|
residualPa: lhs - rhs,
|
|
residualMbar: (lhs - rhs) / PA_PER_MBAR,
|
|
};
|
|
}
|
|
|
|
function assertHydraulicPower({ flowM3PerS, headPa, shaftPowerW, efficiency, label = 'hydraulic-power', absTol = 1, relTol = 5e-3 } = {}) {
|
|
if (!Number.isFinite(efficiency) || efficiency <= 0 || efficiency > 1.0) {
|
|
return { ok: false, label, msg: `efficiency=${efficiency} outside (0,1]` };
|
|
}
|
|
const expectedShaftPowerW = (flowM3PerS * headPa) / efficiency;
|
|
const ok = withinTolerance(shaftPowerW, expectedShaftPowerW, absTol, relTol);
|
|
return {
|
|
ok,
|
|
label,
|
|
flowM3PerS,
|
|
headPa,
|
|
efficiency,
|
|
expectedShaftPowerW,
|
|
observedShaftPowerW: shaftPowerW,
|
|
residualW: shaftPowerW - expectedShaftPowerW,
|
|
};
|
|
}
|
|
|
|
function assertEnergyBalance({ heatInW = 0, workInW = 0, heatOutW = 0, workOutW = 0, accumulationW = 0, label = 'energy', absTol = 1, relTol = 1e-3 } = {}) {
|
|
const inputs = heatInW + workInW;
|
|
const outputs = heatOutW + workOutW + accumulationW;
|
|
const ok = withinTolerance(inputs, outputs, absTol, relTol);
|
|
return {
|
|
ok,
|
|
label,
|
|
inputsW: inputs,
|
|
outputsW: outputs,
|
|
residualW: inputs - outputs,
|
|
};
|
|
}
|
|
|
|
function assertOxygenTransfer({ klaPerS, csMgPerL, cMgPerL, otrKgPerS, volumeM3, label = 'OTR', absTol = 1e-4, relTol = 5e-3 } = {}) {
|
|
if (!Number.isFinite(klaPerS) || klaPerS < 0) return { ok: false, label, msg: `KLa=${klaPerS} invalid` };
|
|
if (!Number.isFinite(volumeM3) || volumeM3 <= 0) return { ok: false, label, msg: `volume=${volumeM3} invalid` };
|
|
const driveMgPerL = csMgPerL - cMgPerL;
|
|
const expectedKgPerS = klaPerS * driveMgPerL * volumeM3 * 1e-3 / SECONDS_PER_HOUR * SECONDS_PER_HOUR / 1000;
|
|
const expectedKgPerS_corrected = klaPerS * driveMgPerL * volumeM3 / 1e6;
|
|
const ok = withinTolerance(otrKgPerS, expectedKgPerS_corrected, absTol, relTol);
|
|
return {
|
|
ok,
|
|
label,
|
|
klaPerS,
|
|
csMgPerL,
|
|
cMgPerL,
|
|
driveMgPerL,
|
|
volumeM3,
|
|
expectedKgPerS: expectedKgPerS_corrected,
|
|
observedKgPerS: otrKgPerS,
|
|
residualKgPerS: otrKgPerS - expectedKgPerS_corrected,
|
|
};
|
|
}
|
|
|
|
function reportToString(r) {
|
|
if (r.ok) return `OK ${r.label}`;
|
|
const fields = Object.entries(r)
|
|
.filter(([k]) => !['ok', 'label'].includes(k))
|
|
.map(([k, v]) => `${k}=${typeof v === 'number' ? v.toExponential(3) : v}`)
|
|
.join(' ');
|
|
return `FAIL ${r.label} ${fields}`;
|
|
}
|
|
|
|
module.exports = {
|
|
assertMassBalance,
|
|
assertHydraulicBalance,
|
|
assertHydraulicPower,
|
|
assertEnergyBalance,
|
|
assertOxygenTransfer,
|
|
reportToString,
|
|
PA_PER_MBAR,
|
|
};
|