Compare commits
2 Commits
b59d8e60f7
...
f41e319b30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f41e319b30 | ||
|
|
551ee6d70e |
@@ -14,11 +14,16 @@
|
||||
// (stopping / coolingdown / unknown) are skipped.
|
||||
// 3. Rendezvous time t* = max(eta_i over ALL non-noop moves). The
|
||||
// slowest move (typically a startup ladder + ramp) sets the deadline.
|
||||
// 4. Every command is delayed by (t* − eta_j) so it FINISHES at t*.
|
||||
// Exception: a startup's `execsequence` command must fire NOW so the
|
||||
// ladder can begin — its own duration is what defines eta and thus
|
||||
// t* — but the startup's queued flowmovement (held in the pump's
|
||||
// delayedMove) lands at t* by construction.
|
||||
// 4. Every command — including a startup's `execsequence` — is delayed by
|
||||
// (t* − eta_j) so its move FINISHES at t*. A startup is delayed as a
|
||||
// whole: its ladder begins at (t* − eta) and completes at (t* − rampS),
|
||||
// then the queued flowmovement (held in the pump's delayedMove) ramps to
|
||||
// finish at t*. The slowest mover (t* − eta == 0) fires immediately.
|
||||
// Delaying the ladder — rather than firing it at tick 0 — is what keeps a
|
||||
// faster-than-slowest startup from reaching `operational` early and
|
||||
// sitting at its MINIMUM flow before t* (calcFlow at min position is not
|
||||
// zero), which otherwise leaks ~min-flow into the group total ahead of
|
||||
// the rendezvous (the staging bump).
|
||||
//
|
||||
// Net effect: ALL pumps reach their per-pump flow target at the same
|
||||
// wall-clock instant t*. Sum-of-flows is monotonic during the transition
|
||||
@@ -177,38 +182,31 @@ function plan(profiles, combination, currentPressure, options = {}) {
|
||||
const isUnchanged = q.direction === 'unchanged';
|
||||
|
||||
if (q.action === 'startup') {
|
||||
// execsequence MUST begin NOW — the ladder duration is
|
||||
// baked into eta and can't be compressed.
|
||||
// Just-in-time start. Delay the ENTIRE startup — ladder AND ramp —
|
||||
// by (t* − eta), so the warmup ladder finishes (and the ramp
|
||||
// begins) at (t* − rampS) and the flow lands exactly at t*.
|
||||
//
|
||||
// The ladder duration can't be compressed, but it CAN be delayed.
|
||||
// Firing the execsequence at tick 0 (the old behaviour) made a
|
||||
// faster-than-slowest startup reach `operational` early and sit at
|
||||
// its minimum flow from warmup-end until its delayed ramp — leaking
|
||||
// ~min-flow into the group total before t* (the staging bump). For
|
||||
// the slowest pump (eta == t*) fireAtTickNDelayed is 0, so it still
|
||||
// fires immediately. The flowmovement fires on the same tick; the
|
||||
// pump holds it in delayedMove through the ladder, then ramps over
|
||||
// rampS to finish at t*.
|
||||
commands.push({
|
||||
machineId: q.machineId,
|
||||
action: 'execsequence',
|
||||
sequence: 'startup',
|
||||
fireAtTickN: 0,
|
||||
fireAtTickN: fireAtTickNDelayed,
|
||||
eta: q.eta,
|
||||
});
|
||||
// flowmovement timing.
|
||||
//
|
||||
// Default behaviour: queue it at tick 0; the pump's
|
||||
// delayedMove holds it until warmup completes, after which
|
||||
// the pump ramps at its own velocity. That ramp finishes at
|
||||
// ladderS + rampS = eta. For a single pump (eta == tStar)
|
||||
// this naturally lands at tStar — no extra delay needed.
|
||||
//
|
||||
// Mixed-speed multi-startup: if this pump is FASTER than
|
||||
// the slowest one, its natural landing (at its own eta)
|
||||
// is EARLIER than tStar. Delay the flowmovement so the
|
||||
// ramp starts at (tStar − rampS), making the ramp finish
|
||||
// at tStar regardless of per-pump speed.
|
||||
const naturalRampStartS = q.ladderS;
|
||||
const rendezvousRampStartS = tStar - q.rampS;
|
||||
const flowMoveFireAtS = rendezvousRampStartS > naturalRampStartS
|
||||
? rendezvousRampStartS
|
||||
: 0;
|
||||
commands.push({
|
||||
machineId: q.machineId,
|
||||
action: 'flowmovement',
|
||||
flow: q.targetFlow,
|
||||
fireAtTickN: Math.max(0, Math.round(flowMoveFireAtS / tickS)),
|
||||
fireAtTickN: fireAtTickNDelayed,
|
||||
eta: q.eta,
|
||||
});
|
||||
} else if (q.action === 'flowmove') {
|
||||
|
||||
@@ -112,6 +112,33 @@ Documented in `CONTRACT.md`; tested indirectly via `group-bep-cascade.integratio
|
||||
|
||||
---
|
||||
|
||||
## Example flow fan-out — `examples/02-Dashboard.json :: fn_status_split` (outputs: 18)
|
||||
|
||||
Delta-caches Port 0 then fans one msg per dashboard widget. Charts return the
|
||||
whole msg as `null` (drop the output) when their source is missing — never
|
||||
`{ payload: null }`. All ports covered by `test/integration/dashboard-fanout.integration.test.js`.
|
||||
|
||||
| # | Target widget | Topic / payload | Populated | Degraded (missing source) |
|
||||
|---|---|---|---|---|
|
||||
| 0 | ui_txt_mode | string | ✔ State C | ✔ State A → mode string |
|
||||
| 1 | ui_txt_flow | `'… m³/h'` | ✔ | ✔ State A → `—` |
|
||||
| 2 | ui_txt_power | `'… kW'` | ✔ | ✔ → `—` |
|
||||
| 3 | ui_txt_capacity | `'min – max m³/h'` | ✔ State B | ✔ → `—` |
|
||||
| 4 | ui_txt_machines | `'nAct / nTot'` | ✔ | ✔ → `—` |
|
||||
| 5 | ui_txt_bep (rel%) | `'… %'` | ✔ | ✔ null/undefined → `—` |
|
||||
| 6 | ui_txt_eta | `'… %'` | ✔ | ✔ → `—` |
|
||||
| 7 | ui_txt_eta_peak | `'… %'` | ✔ | ✔ → `—` |
|
||||
| 8 | ui_txt_bep_abs | `'…'` (η pts, 3dp) | ✔ | ✔ → `—` |
|
||||
| 9 | ui_txt_ncog | `'… %'` (sum/nAct) | ✔ | ✔ nAct=0/missing → `—` |
|
||||
| 10 | ui_chart_flow | `{topic:'Flow', payload:number}` | ✔ | ✔ → null (drop) |
|
||||
| 11 | ui_chart_flow (capacity) | `{topic:'Capacity', …}` | ✔ | ✔ → null |
|
||||
| 12 | ui_chart_power | `{topic:'Power', …}` | ✔ | ✔ → null |
|
||||
| 13 | ui_chart_bep | `{topic:'BEP rel %', ×100}` | ✔ | ✔ → null |
|
||||
| 14 | ui_chart_eta | `{topic:'η (%)', ×100}` | ✔ | ✔ → null |
|
||||
| 15 | ui_tpl_raw | `[{key,value}]` rows | ✔ | ✔ |
|
||||
| 16 | ui_chart_qh (passthrough) | raw `msg.payload` | ✔ | ✔ |
|
||||
| 17 | ui_chart_mgc_pctcap | `{topic:'% of capacity', payload:flow/capMax×100}` | ✔ State C | ✔ State A → null (drop) |
|
||||
|
||||
## Coverage gaps (open items)
|
||||
|
||||
These are known holes flagged during the 2026-05-14 governance review; not yet
|
||||
|
||||
@@ -242,34 +242,29 @@ test('plan: mixed-speed multi-startup — fast pumps wait so all land at tStar t
|
||||
// tStar = max(eta_A, eta_B, eta_C) = 130 s.
|
||||
assert.ok(Math.abs(out.tStarS - 130) < 0.01, `tStar should be 130; got ${out.tStarS}`);
|
||||
|
||||
// execsequence fires at 0 for ALL idle pumps (the ladder must start now).
|
||||
// Just-in-time: the WHOLE startup (ladder + ramp) is delayed by (tStar −
|
||||
// eta), so both execsequence and flowmovement fire at the same delayed
|
||||
// tick. eta_A = 30 + 33.33 ≈ 63.33, eta_B = 40, eta_C = 130.
|
||||
// A: round(130 − 63.33) = 67
|
||||
// B: round(130 − 40) = 90
|
||||
// C: round(130 − 130) = 0 (slowest — defines tStar, fires now)
|
||||
const delays = { A: Math.round(130 - (30 + 100 / 3)), B: 90, C: 0 };
|
||||
for (const id of ['A', 'B', 'C']) {
|
||||
const exec = out.commands.find((c) => c.machineId === id && c.action === 'execsequence');
|
||||
const flow = out.commands.find((c) => c.machineId === id && c.action === 'flowmovement');
|
||||
assert.ok(exec, `${id} execsequence present`);
|
||||
assert.equal(exec.fireAtTickN, 0, `${id} execsequence fires immediately`);
|
||||
assert.ok(flow, `${id} flowmovement present`);
|
||||
assert.equal(exec.fireAtTickN, delays[id], `${id} ladder delayed to land at tStar`);
|
||||
assert.equal(flow.fireAtTickN, delays[id], `${id} flowmovement fires with the ladder`);
|
||||
}
|
||||
|
||||
// flowmovement gating — each pump's ramp must FINISH at tStar=130.
|
||||
const flowA = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement');
|
||||
const flowB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
|
||||
const flowC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement');
|
||||
|
||||
// A (medium): rampStart = 130 − 33.33 ≈ 96.67 → fireAtTickN = 97.
|
||||
assert.equal(flowA.fireAtTickN, Math.round(130 - 100 / 3));
|
||||
// B (fast): rampStart = 130 − 10 = 120 → fireAtTickN = 120.
|
||||
assert.equal(flowB.fireAtTickN, 120);
|
||||
// C (slow, defines tStar): rendezvousRampStart = 130 − 100 = 30 == ladderS,
|
||||
// so no extra delay needed — fall back to fireAtTickN=0 and let
|
||||
// the pump's delayedMove fire it naturally at warmup-end.
|
||||
assert.equal(flowC.fireAtTickN, 0);
|
||||
|
||||
// Sanity: with these schedules, all three pumps' ramps end at the
|
||||
// same wall-clock instant (within rounding).
|
||||
// A: 97 + 100/3 ≈ 130.33
|
||||
// B: 120 + 10 = 130
|
||||
// C: 30 (delayedMove) + 100 = 130
|
||||
// Max spread ≈ 0.33 s — far better than the per-eta spread of
|
||||
// 130 − 40 = 90 s the planner would produce without this gating.
|
||||
// Sanity: with the ladder delayed, each pump reaches `operational` only at
|
||||
// (delay + ladderS) and its ramp ends at the same wall-clock instant ≈ 130.
|
||||
// A: 67 + 30 (op) + 33.33 ≈ 130.33
|
||||
// B: 90 + 30 (op) + 10 = 130
|
||||
// C: 0 + 30 (op) + 100 = 130
|
||||
// No pump sits at `operational` (and minimum flow) before its ramp — that
|
||||
// early min-flow was the staging bump this just-in-time start removes.
|
||||
});
|
||||
|
||||
test('plan: zero-velocity machine is demoted (infinite eta) but does not crash', () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ function runFn(msgs) {
|
||||
return msgs.map(msg => fn_body(msg, context));
|
||||
}
|
||||
|
||||
// Indices into the 17-output return array. Kept here as the manifest contract
|
||||
// Indices into the 18-output return array. Kept here as the manifest contract
|
||||
// for this function — every test below references these names, never raw ints.
|
||||
const PORT = {
|
||||
text_mode: 0, text_flow: 1, text_power: 2, text_capacity: 3,
|
||||
@@ -31,6 +31,7 @@ const PORT = {
|
||||
chart_flow: 10, chart_capacity: 11, chart_power: 12, chart_bep_rel: 13,
|
||||
chart_eta: 14,
|
||||
raw_rows: 15, raw_passthrough: 16,
|
||||
chart_pctcap: 17,
|
||||
};
|
||||
|
||||
const initialMsg = {
|
||||
@@ -64,9 +65,9 @@ const postDemandMsg = {
|
||||
},
|
||||
};
|
||||
|
||||
test('manifest: function has exactly 17 outputs and wires array matches', () => {
|
||||
assert.equal(fn.outputs, 17);
|
||||
assert.equal(fn.wires.length, 17);
|
||||
test('manifest: function has exactly 18 outputs and wires array matches', () => {
|
||||
assert.equal(fn.outputs, 18);
|
||||
assert.equal(fn.wires.length, 18);
|
||||
});
|
||||
|
||||
test('State A (deploy-time): no AT_EQUIPMENT keys → flow/power text show em-dash', () => {
|
||||
@@ -113,6 +114,16 @@ test('State C (post-demand): every text/chart output has real value', () => {
|
||||
assert.equal(out[PORT.chart_flow].payload, 200);
|
||||
assert.equal(out[PORT.chart_power].payload, 11.4);
|
||||
assert.equal(out[PORT.chart_eta].payload, 62);
|
||||
// % of capacity = flow / flowCapacityMax × 100 = 200 / 450 × 100 ≈ 44.44.
|
||||
assert.equal(out[PORT.chart_pctcap].topic, '% of capacity');
|
||||
assert.ok(Math.abs(out[PORT.chart_pctcap].payload - (200 / 450) * 100) < 1e-6);
|
||||
});
|
||||
|
||||
test('% of capacity chart: drops msg when flow or capacity missing (no payload:null)', () => {
|
||||
// State A: no flow + flowCapacityMax=0 → pctCap undefined → chart() returns
|
||||
// null so the function node skips the output, never { payload: null }.
|
||||
const [out] = runFn([initialMsg]);
|
||||
assert.equal(out[PORT.chart_pctcap], null, 'chart_pctcap must drop msg when source missing');
|
||||
});
|
||||
|
||||
test('NCog formatter: SUM is normalized by machineCountActive before display', () => {
|
||||
|
||||
Reference in New Issue
Block a user