tools: add physics-sanity + Docker MCP scaffolding + tools/README
- 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>
This commit is contained in:
80
tools/physics-sanity/README.md
Normal file
80
tools/physics-sanity/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# @evolv/physics-sanity
|
||||
|
||||
Cross-node physical-balance helpers. Import from any node's test files
|
||||
to assert that scenario states close mass, hydraulic, hydraulic-power,
|
||||
oxygen-transfer, or energy balances within a stated tolerance.
|
||||
|
||||
## Why
|
||||
|
||||
Per-node unit tests verify shape and behaviour. They don't catch
|
||||
physically impossible plant states that arise from cross-node coupling
|
||||
— e.g. a pumpingStation reporting outflow > inflow + accumulation, or a
|
||||
diffuser reporting OTR inconsistent with its KLa × ΔC × V.
|
||||
|
||||
These helpers don't replace per-node tests. They sit on top of an
|
||||
integration scenario and assert the closing balance.
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
const sanity = require('../../../tools/physics-sanity');
|
||||
|
||||
test('three-pump station closes the hydraulic balance', () => {
|
||||
// … drive the scenario, take a snapshot …
|
||||
const r = sanity.assertHydraulicBalance({
|
||||
headerSuctionPa: ps.suctionPressurePa,
|
||||
headerDischargePa: ps.dischargePressurePa,
|
||||
pumpHeadPa: sumOfPumpHeads,
|
||||
frictionPa: pipeFrictionEstimate,
|
||||
});
|
||||
assert.equal(r.ok, true, sanity.reportToString(r));
|
||||
});
|
||||
```
|
||||
|
||||
## Helpers exported
|
||||
|
||||
| Function | Asserts |
|
||||
|---|---|
|
||||
| `assertMassBalance({ inflowKgPerS, outflowKgPerS, accumulationKgPerS })` | `in - out - accumulation ≈ 0` |
|
||||
| `assertHydraulicBalance({ headerSuctionPa, headerDischargePa, pumpHeadPa, frictionPa, staticHeadPa })` | `ΔP_headers ≈ pumpHead - friction - static` |
|
||||
| `assertHydraulicPower({ flowM3PerS, headPa, shaftPowerW, efficiency })` | `shaft ≈ Q·H / η` |
|
||||
| `assertOxygenTransfer({ klaPerS, csMgPerL, cMgPerL, otrKgPerS, volumeM3 })` | `OTR ≈ KLa · (Cs - C) · V` |
|
||||
| `assertEnergyBalance({ heatInW, workInW, heatOutW, workOutW, accumulationW })` | `Q_in + W_in ≈ Q_out + W_out + ΔE` |
|
||||
|
||||
Each returns `{ ok, label, ...residuals }`. `reportToString(r)` formats
|
||||
for human-readable failure messages.
|
||||
|
||||
## CLI demo
|
||||
|
||||
```bash
|
||||
node tools/physics-sanity/bin/physics-sanity.js
|
||||
```
|
||||
|
||||
Runs four sanity-check scenarios against the helpers (smoke-test for
|
||||
the library itself).
|
||||
|
||||
## Tolerance defaults
|
||||
|
||||
| Domain | Absolute | Relative |
|
||||
|---|---|---|
|
||||
| mass | 1e-6 kg/s | 0.1 % |
|
||||
| hydraulic ΔP | 50 Pa (0.5 mbar) | 0.1 % |
|
||||
| hydraulic power | 1 W | 0.5 % |
|
||||
| OTR | 1e-4 kg/s | 0.5 % |
|
||||
| energy | 1 W | 0.1 % |
|
||||
|
||||
Override per call with `absTol` / `relTol`.
|
||||
|
||||
## Where to use this
|
||||
|
||||
Out-of-the-box destinations:
|
||||
|
||||
| Scenario | Where to add | Calls |
|
||||
|---|---|---|
|
||||
| pumpingStation hydraulic closure | `nodes/pumpingStation/test/integration/` | `assertHydraulicBalance`, `assertHydraulicPower` |
|
||||
| reactor → settler mass balance | `nodes/reactor/test/integration/` | `assertMassBalance` |
|
||||
| diffuser OTR vs reactor uptake | `nodes/diffuser/test/integration/` | `assertOxygenTransfer` |
|
||||
| machineGroupControl efficiency sanity | `nodes/machineGroupControl/test/integration/` | `assertHydraulicPower` |
|
||||
|
||||
A future tool can scan integration tests and report which scenarios do
|
||||
or don't have a closing-balance assertion.
|
||||
18
tools/physics-sanity/bin/physics-sanity.js
Normal file
18
tools/physics-sanity/bin/physics-sanity.js
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const lib = require('../index.js');
|
||||
|
||||
function runDemo() {
|
||||
const checks = [
|
||||
lib.assertMassBalance({ inflowKgPerS: 12.5, outflowKgPerS: 12.5, accumulationKgPerS: 0, label: 'reactor-passthrough' }),
|
||||
lib.assertHydraulicBalance({ headerSuctionPa: 0, headerDischargePa: 110000, pumpHeadPa: 110000, label: 'station-A-headers' }),
|
||||
lib.assertHydraulicPower({ flowM3PerS: 0.030, headPa: 110000, shaftPowerW: 5000, efficiency: 0.66, label: 'pump-A' }),
|
||||
lib.assertOxygenTransfer({ klaPerS: 0.002, csMgPerL: 9.0, cMgPerL: 2.0, otrKgPerS: 2.8e-7, volumeM3: 20, label: 'diffuser-A' }),
|
||||
];
|
||||
for (const c of checks) process.stdout.write(lib.reportToString(c) + '\n');
|
||||
const failed = checks.filter((c) => !c.ok).length;
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
if (require.main === module) runDemo();
|
||||
111
tools/physics-sanity/index.js
Normal file
111
tools/physics-sanity/index.js
Normal file
@@ -0,0 +1,111 @@
|
||||
'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,
|
||||
};
|
||||
14
tools/physics-sanity/package.json
Normal file
14
tools/physics-sanity/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@evolv/physics-sanity",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Cross-node physical-balance helpers (mass, hydraulic, energy). Import from test files; closure tolerance asserted at known plant states.",
|
||||
"main": "index.js",
|
||||
"bin": {
|
||||
"evolv-physics-sanity": "bin/physics-sanity.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"license": "UNLICENSED"
|
||||
}
|
||||
57
tools/physics-sanity/test/balance.test.js
Normal file
57
tools/physics-sanity/test/balance.test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const lib = require('../index.js');
|
||||
|
||||
test('mass balance closes for steady-state pass-through', () => {
|
||||
const r = lib.assertMassBalance({ inflowKgPerS: 10, outflowKgPerS: 10 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.residualKgPerS, 0);
|
||||
});
|
||||
|
||||
test('mass balance reports residual when leaking', () => {
|
||||
const r = lib.assertMassBalance({ inflowKgPerS: 10, outflowKgPerS: 9.5 });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(Math.round(r.residualKgPerS * 1000), 500);
|
||||
});
|
||||
|
||||
test('hydraulic balance: ΔP = pumpHead - friction - static', () => {
|
||||
const r = lib.assertHydraulicBalance({
|
||||
headerSuctionPa: 0,
|
||||
headerDischargePa: 90000,
|
||||
pumpHeadPa: 100000,
|
||||
frictionPa: 8000,
|
||||
staticHeadPa: 2000,
|
||||
});
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test('hydraulic power Q·H / η — within 0.5% relative tolerance', () => {
|
||||
const Q = 0.03;
|
||||
const H = 100000;
|
||||
const eta = 0.65;
|
||||
const shaft = (Q * H) / eta;
|
||||
const r = lib.assertHydraulicPower({ flowM3PerS: Q, headPa: H, shaftPowerW: shaft, efficiency: eta });
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test('hydraulic power flags eta=0', () => {
|
||||
const r = lib.assertHydraulicPower({ flowM3PerS: 0.03, headPa: 100000, shaftPowerW: 5000, efficiency: 0 });
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
|
||||
test('OTR check uses standard KLa formula', () => {
|
||||
const kla = 0.002;
|
||||
const cs = 9.0;
|
||||
const c = 2.0;
|
||||
const V = 20;
|
||||
const otr = kla * (cs - c) * V / 1e6;
|
||||
const r = lib.assertOxygenTransfer({ klaPerS: kla, csMgPerL: cs, cMgPerL: c, volumeM3: V, otrKgPerS: otr });
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test('energy balance: heat-in + work-in = heat-out + work-out + accumulation', () => {
|
||||
const r = lib.assertEnergyBalance({ heatInW: 1000, workInW: 200, heatOutW: 700, workOutW: 400, accumulationW: 100 });
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
Reference in New Issue
Block a user