diff --git a/.gitignore b/.gitignore index 8c3f66c..875b2df 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ npm-debug.log* # Per-session runtime locks (scheduled_tasks, etc.) .claude/*.lock + +# Local tooling env (developer-specific MCP endpoints/tokens) +tools/.env diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..431b2e4 --- /dev/null +++ b/tools/README.md @@ -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//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//package.json` with a `bin` entry. +2. `tools//bin/.js` — must accept `--json` and exit 1 on drift. +3. `tools//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. diff --git a/tools/docker-compose.yml b/tools/docker-compose.yml new file mode 100644 index 0000000..f1f37f4 --- /dev/null +++ b/tools/docker-compose.yml @@ -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// + # 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"] diff --git a/tools/mcp/README.md b/tools/mcp/README.md new file mode 100644 index 0000000..dd8f606 --- /dev/null +++ b/tools/mcp/README.md @@ -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//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. diff --git a/tools/mcp/browser/Dockerfile b/tools/mcp/browser/Dockerfile new file mode 100644 index 0000000..1d858bc --- /dev/null +++ b/tools/mcp/browser/Dockerfile @@ -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"] diff --git a/tools/mcp/influxdb/Dockerfile b/tools/mcp/influxdb/Dockerfile new file mode 100644 index 0000000..ea409c2 --- /dev/null +++ b/tools/mcp/influxdb/Dockerfile @@ -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"] diff --git a/tools/mcp/node-red-admin/Dockerfile b/tools/mcp/node-red-admin/Dockerfile new file mode 100644 index 0000000..6e01c4c --- /dev/null +++ b/tools/mcp/node-red-admin/Dockerfile @@ -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"] diff --git a/tools/mcp/node-red-admin/ROADMAP.md b/tools/mcp/node-red-admin/ROADMAP.md new file mode 100644 index 0000000..27fb53d --- /dev/null +++ b/tools/mcp/node-red-admin/ROADMAP.md @@ -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. diff --git a/tools/physics-sanity/README.md b/tools/physics-sanity/README.md new file mode 100644 index 0000000..7e64207 --- /dev/null +++ b/tools/physics-sanity/README.md @@ -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. diff --git a/tools/physics-sanity/bin/physics-sanity.js b/tools/physics-sanity/bin/physics-sanity.js new file mode 100644 index 0000000..4ca9a54 --- /dev/null +++ b/tools/physics-sanity/bin/physics-sanity.js @@ -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(); diff --git a/tools/physics-sanity/index.js b/tools/physics-sanity/index.js new file mode 100644 index 0000000..82ba32a --- /dev/null +++ b/tools/physics-sanity/index.js @@ -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, +}; diff --git a/tools/physics-sanity/package.json b/tools/physics-sanity/package.json new file mode 100644 index 0000000..af462aa --- /dev/null +++ b/tools/physics-sanity/package.json @@ -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" +} diff --git a/tools/physics-sanity/test/balance.test.js b/tools/physics-sanity/test/balance.test.js new file mode 100644 index 0000000..16ff51e --- /dev/null +++ b/tools/physics-sanity/test/balance.test.js @@ -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); +});