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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,3 +11,6 @@ npm-debug.log*
|
|||||||
|
|
||||||
# Per-session runtime locks (scheduled_tasks, etc.)
|
# Per-session runtime locks (scheduled_tasks, etc.)
|
||||||
.claude/*.lock
|
.claude/*.lock
|
||||||
|
|
||||||
|
# Local tooling env (developer-specific MCP endpoints/tokens)
|
||||||
|
tools/.env
|
||||||
|
|||||||
57
tools/README.md
Normal file
57
tools/README.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# EVOLV tooling (`tools/`)
|
||||||
|
|
||||||
|
Repo-local tools and MCP services for EVOLV development. All Node.js
|
||||||
|
native (no Python toolchain). Each tool encodes a rule that we've
|
||||||
|
previously discovered through a bug; skipping them re-opens those bugs.
|
||||||
|
|
||||||
|
See `CLAUDE.md` § "Tooling (Docker-first, local now, central later)"
|
||||||
|
for the operating doctrine.
|
||||||
|
|
||||||
|
## Tools (CLI)
|
||||||
|
|
||||||
|
| Tool | Path | What it does | Run |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `contract-verify` | `tools/contract-verify/` | Diffs `nodes/<n>/CONTRACT.md` topic table vs `src/commands/index.js` | `node tools/contract-verify/bin/contract-verify.js` |
|
||||||
|
| `flow-lint` | `tools/flow-lint/` | Lints `examples/*.flow.json` against the flow-layout rule | `node tools/flow-lint/bin/flow-lint.js` |
|
||||||
|
| `wiki-gen` | `tools/wiki-gen/` | Regenerates AUTOGEN topic-contract blocks in per-node wikis | `node tools/wiki-gen/bin/wiki-gen.js` |
|
||||||
|
| `output-manifest-verify` | `tools/output-manifest-verify/` | Enforces the output-coverage manifest rule | `node tools/output-manifest-verify/bin/output-manifest-verify.js` |
|
||||||
|
| `physics-sanity` | `tools/physics-sanity/` | Library of cross-node balance helpers; import from tests | `require('../../tools/physics-sanity')` |
|
||||||
|
|
||||||
|
CI-friendly: every tool accepts `--json` (JSON output) and exits non-zero
|
||||||
|
on findings.
|
||||||
|
|
||||||
|
## MCP services (Docker)
|
||||||
|
|
||||||
|
See `tools/mcp/README.md`. Three services scaffolded:
|
||||||
|
|
||||||
|
| Service | Purpose | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `mcp-node-red-admin` | Wraps Node-RED admin HTTP API | TODO (scaffold) |
|
||||||
|
| `mcp-influxdb` | Telemetry query + assertion | TODO (scaffold) |
|
||||||
|
| `mcp-browser` | Headless Playwright against the dashboard | TODO (scaffold) |
|
||||||
|
|
||||||
|
Start (once impls land):
|
||||||
|
```bash
|
||||||
|
cd tools && docker compose --profile mcp up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI integration
|
||||||
|
|
||||||
|
Recommended order on every PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/contract-verify/bin/contract-verify.js # 1. CONTRACT vs registry
|
||||||
|
node tools/flow-lint/bin/flow-lint.js # 2. Flow JSON shapes
|
||||||
|
node tools/wiki-gen/bin/wiki-gen.js --check # 3. Wiki AUTOGEN blocks
|
||||||
|
node tools/output-manifest-verify/bin/output-manifest-verify.js # 4. Manifest coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
Each is fast (<1 s on the whole repo).
|
||||||
|
|
||||||
|
## Adding a new tool
|
||||||
|
|
||||||
|
1. `tools/<name>/package.json` with a `bin` entry.
|
||||||
|
2. `tools/<name>/bin/<name>.js` — must accept `--json` and exit 1 on drift.
|
||||||
|
3. `tools/<name>/README.md` — one-page docs.
|
||||||
|
4. Add a row to this README + a row to `CLAUDE.md` § Tooling.
|
||||||
|
5. Wire into the CI snippet above.
|
||||||
46
tools/docker-compose.yml
Normal file
46
tools/docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: evolv-tools
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Local MCP services for the Claude Code tooling stack.
|
||||||
|
# Migration note (2026-05): these are deliberately local. When the
|
||||||
|
# central MCP server comes online (target Q3 2026), each service moves
|
||||||
|
# to the shared infra; this compose file becomes the spec for what to
|
||||||
|
# provision there. The build context + Dockerfile in tools/mcp/<name>/
|
||||||
|
# stays in this repo as the canonical definition.
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
mcp-node-red-admin:
|
||||||
|
build:
|
||||||
|
context: ./mcp/node-red-admin
|
||||||
|
container_name: evolv-mcp-node-red-admin
|
||||||
|
environment:
|
||||||
|
NODE_RED_HOST: ${NODE_RED_HOST:-http://host.docker.internal:1880}
|
||||||
|
NODE_RED_TOKEN: ${NODE_RED_TOKEN:-}
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
profiles: ["mcp"]
|
||||||
|
|
||||||
|
mcp-influxdb:
|
||||||
|
build:
|
||||||
|
context: ./mcp/influxdb
|
||||||
|
container_name: evolv-mcp-influxdb
|
||||||
|
environment:
|
||||||
|
INFLUX_URL: ${INFLUX_URL:-http://host.docker.internal:8086}
|
||||||
|
INFLUX_TOKEN: ${INFLUX_TOKEN:-}
|
||||||
|
INFLUX_ORG: ${INFLUX_ORG:-wbd}
|
||||||
|
INFLUX_BUCKET: ${INFLUX_BUCKET:-telemetry}
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
profiles: ["mcp"]
|
||||||
|
|
||||||
|
mcp-browser:
|
||||||
|
build:
|
||||||
|
context: ./mcp/browser
|
||||||
|
container_name: evolv-mcp-browser
|
||||||
|
environment:
|
||||||
|
DASHBOARD_URL: ${DASHBOARD_URL:-http://host.docker.internal:1880/dashboard}
|
||||||
|
HEADLESS: ${HEADLESS:-true}
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
profiles: ["mcp"]
|
||||||
97
tools/mcp/README.md
Normal file
97
tools/mcp/README.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# EVOLV MCP services — Docker stack
|
||||||
|
|
||||||
|
Three MCP services Claude Code uses to close real loops during EVOLV work:
|
||||||
|
|
||||||
|
| Service | What it does | Tools exposed |
|
||||||
|
|---|---|---|
|
||||||
|
| `mcp-node-red-admin` | Wraps the Node-RED HTTP admin API | `getFlows`, `postFlow`, `getFlow`, `inject`, `listNodes`, `restartFlow` |
|
||||||
|
| `mcp-influxdb` | Queries the telemetry bucket | `query`, `assertSeriesExists`, `assertRecentWrite`, `listMeasurements` |
|
||||||
|
| `mcp-browser` | Headless Playwright against the FlowFuse dashboard | `loadDashboard`, `screenshot`, `consoleLogs`, `waitForChart`, `getChartData` |
|
||||||
|
|
||||||
|
## Why these
|
||||||
|
|
||||||
|
Each closes a verification loop Claude currently cannot close:
|
||||||
|
|
||||||
|
- **Node-RED admin** — today Claude pushes flows via raw `curl`; the
|
||||||
|
MCP lets Claude deploy + fire injects + read live state in one
|
||||||
|
conversational turn.
|
||||||
|
- **InfluxDB** — today Claude cannot verify "did the telemetry land?"
|
||||||
|
beyond reading source. The MCP closes the loop after a deploy.
|
||||||
|
- **Browser** — today Claude cannot see the rendered dashboard. The
|
||||||
|
MCP catches the failure mode behind the η-null crash + the
|
||||||
|
blank-ui-chart bug + the editor pile-up bug at the only layer where
|
||||||
|
they're visible.
|
||||||
|
|
||||||
|
## Migration plan
|
||||||
|
|
||||||
|
These run locally **now** (we're in the middle of an infra migration).
|
||||||
|
Once the central MCP server is provisioned (target Q3 2026), each
|
||||||
|
service moves to shared infra by lifting the entry from this
|
||||||
|
`docker-compose.yml` plus the matching `tools/mcp/<name>/Dockerfile`
|
||||||
|
and pointing every developer's Claude Code at the central endpoint
|
||||||
|
instead of `localhost`. **The compose file stays here as the canonical
|
||||||
|
definition.**
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# build the three images (one-off, ~3 min)
|
||||||
|
cd tools
|
||||||
|
docker compose --profile mcp build
|
||||||
|
|
||||||
|
# start them
|
||||||
|
docker compose --profile mcp up -d
|
||||||
|
|
||||||
|
# wire Claude Code to them — add to your user-level .mcp.json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"evolv-node-red-admin": { "type": "stdio", "command": "docker", "args": ["exec", "-i", "evolv-mcp-node-red-admin", "node", "server.mjs"] },
|
||||||
|
"evolv-influxdb": { "type": "stdio", "command": "docker", "args": ["exec", "-i", "evolv-mcp-influxdb", "node", "server.mjs"] },
|
||||||
|
"evolv-browser": { "type": "stdio", "command": "docker", "args": ["exec", "-i", "evolv-mcp-browser", "node", "server.mjs"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The repo-level `.mcp.json` is deliberately **not** committed (each
|
||||||
|
developer has different host endpoints / tokens). Use a user-level
|
||||||
|
config or `~/.claude.json`.
|
||||||
|
|
||||||
|
## Required environment
|
||||||
|
|
||||||
|
`tools/.env` (gitignored) with:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
NODE_RED_HOST=http://host.docker.internal:1880
|
||||||
|
NODE_RED_TOKEN=… # optional, only if Node-RED has admin auth on
|
||||||
|
INFLUX_URL=http://host.docker.internal:8086
|
||||||
|
INFLUX_TOKEN=…
|
||||||
|
INFLUX_ORG=wbd
|
||||||
|
INFLUX_BUCKET=telemetry
|
||||||
|
DASHBOARD_URL=http://host.docker.internal:1880/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Service | Dockerfile | Server impl | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `mcp-node-red-admin` | placeholder | **TODO** — see `mcp/node-red-admin/ROADMAP.md` | not runnable yet |
|
||||||
|
| `mcp-influxdb` | placeholder | **TODO** | not runnable yet |
|
||||||
|
| `mcp-browser` | placeholder | **TODO** — wrap `@playwright/test` | not runnable yet |
|
||||||
|
|
||||||
|
The compose file is the **target shape**. The Dockerfile + server
|
||||||
|
implementation per service is a follow-up (each is ~200–400 LOC of MCP
|
||||||
|
protocol + the wrapped client). When a service lands, flip its row
|
||||||
|
above to `runnable` and remove the placeholder.
|
||||||
|
|
||||||
|
## When to use these — required reading
|
||||||
|
|
||||||
|
`CLAUDE.md` § "Tooling (Docker-first, local now, central later)" lists
|
||||||
|
the operating doctrine: **always prefer these tools over ad-hoc
|
||||||
|
curl/grep/manual checks**. Each tool exists because of a specific bug
|
||||||
|
class we've already paid for. Skipping them re-opens those bugs.
|
||||||
|
|
||||||
|
## Future: OPC-UA / PLC MCP
|
||||||
|
|
||||||
|
Out of scope for this round; will be revisited later. When added it
|
||||||
|
follows the same pattern: `tools/mcp/opcua/` with its own Dockerfile
|
||||||
|
and a row in this README.
|
||||||
5
tools/mcp/browser/Dockerfile
Normal file
5
tools/mcp/browser/Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# placeholder — see ../README.md status table
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
RUN echo 'console.error("mcp-browser not yet implemented"); process.exit(1);' > server.mjs
|
||||||
|
CMD ["node", "server.mjs"]
|
||||||
5
tools/mcp/influxdb/Dockerfile
Normal file
5
tools/mcp/influxdb/Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# placeholder — see ../README.md status table
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
RUN echo 'console.error("mcp-influxdb not yet implemented"); process.exit(1);' > server.mjs
|
||||||
|
CMD ["node", "server.mjs"]
|
||||||
5
tools/mcp/node-red-admin/Dockerfile
Normal file
5
tools/mcp/node-red-admin/Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# placeholder — see ROADMAP.md
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
RUN echo 'console.error("mcp-node-red-admin not yet implemented — see ROADMAP.md"); process.exit(1);' > server.mjs
|
||||||
|
CMD ["node", "server.mjs"]
|
||||||
50
tools/mcp/node-red-admin/ROADMAP.md
Normal file
50
tools/mcp/node-red-admin/ROADMAP.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# mcp-node-red-admin — implementation roadmap
|
||||||
|
|
||||||
|
Status: placeholder. Compose entry exists, server impl is TODO.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
An MCP stdio server that wraps the Node-RED admin HTTP API, exposing the
|
||||||
|
following tools to Claude Code:
|
||||||
|
|
||||||
|
| Tool | Wraps | Use case |
|
||||||
|
|---|---|---|
|
||||||
|
| `node_red_get_flows` | `GET /flows` | Read deployed flow JSON |
|
||||||
|
| `node_red_post_flow` | `POST /flow/:id` (single tab) | Deploy one tab without nuking others |
|
||||||
|
| `node_red_replace_flows` | `POST /flows` (bulk) | Replace the entire flow set |
|
||||||
|
| `node_red_inject` | `POST /inject/:nodeId` | Fire an inject node by id |
|
||||||
|
| `node_red_list_nodes` | `GET /nodes` | Discover registered node types |
|
||||||
|
| `node_red_restart_flow` | `DELETE` + redeploy | Force a restart of one tab |
|
||||||
|
|
||||||
|
## Sketch
|
||||||
|
|
||||||
|
```js
|
||||||
|
// tools/mcp/node-red-admin/server.mjs
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
|
||||||
|
const NODE_RED_HOST = process.env.NODE_RED_HOST;
|
||||||
|
const TOKEN = process.env.NODE_RED_TOKEN;
|
||||||
|
|
||||||
|
const server = new Server({ name: 'evolv-node-red-admin', version: '0.1.0' }, { capabilities: { tools: {} } });
|
||||||
|
server.setRequestHandler(/* listTools */);
|
||||||
|
server.setRequestHandler(/* callTool — fetch NODE_RED_HOST + tool's endpoint */);
|
||||||
|
await server.connect(new StdioServerTransport());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dockerfile sketch
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json server.mjs ./
|
||||||
|
RUN npm install
|
||||||
|
CMD ["node", "server.mjs"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to build it
|
||||||
|
|
||||||
|
After we've shipped enough EVOLV flow work that "deploy + fire inject +
|
||||||
|
read state in one turn" becomes the dominant inner loop. Today the
|
||||||
|
`curl` pattern still works for one-off deploys; the MCP earns its
|
||||||
|
keep when the loop runs 5+ times per session.
|
||||||
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