#!/usr/bin/env node
'use strict';

/**
 * build-examples.js — regenerate the three example flows for pumpingStation.
 *
 * Source of truth for the Tier 1/2/3 example flows under examples/.
 * Follows EVOLV/.claude/rules/node-red-flow-layout.md:
 *   - Lane positions L0..L7 = [120, 360, 600, 840, 1080, 1320, 1560, 1800]
 *   - S88 colours per Node-RED group (Process Cell = #0c99d9, Unit = #50a8d9,
 *     Equipment Module = #86bbdd, Control Module = #a9daee, neutral = #dddddd)
 *   - Cross-tab wiring via named link out/link in channels (cmd:* / evt:* / setup:*)
 *   - ui-chart objects carry every mandatory key (interpolation, yAxisProperty,
 *     xAxisPropertyType, action, removeOlder*, colors, etc.) — omitting any
 *     causes FlowFuse to render the chart blank with no error.
 *
 * Only canonical pumpingStation topic names are used (per CONTRACT.md):
 *   set.mode, set.inflow, set.demand, cmd.calibrate.volume, cmd.calibrate.level.
 *
 * Run from repo root or any cwd:
 *   node nodes/pumpingStation/tools/build-examples.js
 */

const fs = require('fs');
const path = require('path');

const OUT_DIR = path.join(__dirname, '..', 'examples');

/* ------------------------------------------------------------------ */
/* Layout constants                                                    */
/* ------------------------------------------------------------------ */

const LANE_X = [120, 360, 600, 840, 1080, 1320, 1560, 1800];
const S88 = {
  AR: '#0f52a5',
  PC: '#0c99d9',
  UN: '#50a8d9',
  EM: '#86bbdd',
  CM: '#a9daee',
  neutral: '#dddddd',
};

const CHART_COLORS = [
  '#0095FF', '#FF0000', '#FF7F0E', '#2CA02C', '#A347E1',
  '#D62728', '#FF9896', '#9467BD', '#C5B0D5',
];

/* ------------------------------------------------------------------ */
/* Helpers                                                             */
/* ------------------------------------------------------------------ */

function tab(id, label, info) {
  return { id, type: 'tab', label, disabled: false, info: info || '' };
}

function comment(id, z, name, x, y) {
  return { id, type: 'comment', z, name, info: '', x, y, wires: [] };
}

function linkOut(id, z, name, x, y, links) {
  return { id, type: 'link out', z, name, mode: 'link', links: links || [], x, y, wires: [] };
}

function linkIn(id, z, name, x, y, links, downstream) {
  return { id, type: 'link in', z, name, links: links || [], x, y, wires: [downstream || []] };
}

function inject(id, z, name, topic, payload, payloadType, x, y, wires, opts) {
  const o = opts || {};
  return {
    id, type: 'inject', z, name,
    props: [
      { p: 'topic', vt: 'str' },
      { p: 'payload', v: String(payload), vt: payloadType },
    ],
    topic,
    repeat: o.repeat || '',
    crontab: '',
    once: !!o.once,
    onceDelay: o.onceDelay || '',
    x, y,
    wires: [wires || []],
  };
}

function fn(id, z, name, code, x, y, wires, outputs) {
  return {
    id, type: 'function', z, name,
    func: code,
    outputs: outputs || 1,
    noerr: 0,
    initialize: '',
    finalize: '',
    libs: [],
    x, y,
    wires: wires || [[]],
  };
}

function debugNode(id, z, name, x, y, complete, targetType, active) {
  return {
    id, type: 'debug', z, name,
    active: active !== false,
    tosidebar: true,
    console: false,
    tostatus: false,
    complete: complete || 'payload',
    targetType: targetType || 'msg',
    x, y, wires: [],
  };
}

function group(id, z, name, color, nodes, bbox) {
  return {
    id, type: 'group', z, name,
    style: { label: true, stroke: '#000000', fill: color, 'fill-opacity': '0.10' },
    nodes,
    x: bbox.x, y: bbox.y, w: bbox.w, h: bbox.h,
  };
}

function bboxOf(nodeList, ids, pad) {
  const p = pad == null ? 20 : pad;
  const ns = nodeList.filter((n) => ids.includes(n.id));
  const xs = ns.map((n) => n.x || 0);
  const ys = ns.map((n) => n.y || 0);
  const minX = Math.min(...xs) - p;
  const minY = Math.min(...ys) - p - 20;
  const w = Math.max(...xs) - Math.min(...xs) + 200 + 2 * p;
  const h = Math.max(...ys) - Math.min(...ys) + 60 + 2 * p;
  return { x: minX, y: minY, w, h };
}

/* Build a fully-specified pumpingStation node. Every config field is set
 * explicitly per rule §9 (no schema-default reliance for operational
 * parameters). 50 m³ basin, 3.5 m height, inflow at 3 m, outflow at 0.2 m,
 * overflow at 3.2 m. Level thresholds chosen so levelbased control activates
 * mid-tank and saturates near overflow.
 */
function pumpingStationNode(id, z, name, x, y, wires) {
  return {
    id, type: 'pumpingStation', z, name,
    simulator: false,
    basinVolume: 50,
    basinHeight: 3.5,
    inflowLevel: 3.0,
    outflowLevel: 0.2,
    overflowLevel: 3.2,
    defaultFluid: 'wastewater',
    inletPipeDiameter: 0.3,
    outletPipeDiameter: 0.3,
    pipelineLength: 80,
    maxDischargeHead: 24,
    staticHead: 12,
    maxInflowRate: 200,
    temperatureReferenceDegC: 15,
    timeleftToFullOrEmptyThresholdSeconds: 0,
    enableDryRunProtection: true,
    enableOverfillProtection: true,
    dryRunThresholdPercent: 2,
    overfillThresholdPercent: 98,
    minHeightBasedOn: 'outlet',
    processOutputFormat: 'process',
    dbaseOutputFormat: 'influxdb',
    refHeight: 'NAP',
    basinBottomRef: 1,
    uuid: 'example-ps-001',
    supplier: 'WBD-RD',
    category: 'station',
    assetType: 'pumpingstation',
    model: 'demo-50m3',
    unit: 'm3/h',
    enableLog: true,
    logLevel: 'info',
    positionVsParent: 'atEquipment',
    positionIcon: '',
    hasDistance: false,
    distance: '',
    distanceUnit: 'm',
    distanceDescription: '',
    controlMode: 'levelbased',
    startLevel: 1.2,
    minLevel: 0.4,
    maxLevel: 2.8,
    flowSetpoint: null,
    flowDeadband: null,
    x, y,
    wires: wires || [[], [], []],
  };
}

function measurementLevelNode(id, z, name, x, y, wires) {
  return {
    id, type: 'measurement', z, name,
    mode: 'analog',
    channels: '[]',
    scaling: false,
    i_min: 0, i_max: 0, i_offset: 0,
    o_min: 0, o_max: 1,
    simulator: true,
    smooth_method: 'mean',
    count: 5,
    processOutputFormat: 'process',
    dbaseOutputFormat: 'influxdb',
    uuid: 'example-level-001',
    supplier: 'vega',
    category: 'sensor',
    assetType: 'level',
    model: 'VEGAPULS-31',
    unit: 'm',
    assetTagNumber: 'LT-001',
    enableLog: false,
    logLevel: 'error',
    positionVsParent: 'atEquipment',
    positionIcon: '',
    hasDistance: false,
    distance: 0,
    distanceUnit: 'm',
    distanceDescription: '',
    x, y,
    wires: wires || [[], [], []],
  };
}

function machineGroupControlNode(id, z, name, x, y, wires) {
  return {
    id, type: 'machineGroupControl', z, name,
    enableLog: true,
    logLevel: 'info',
    positionVsParent: 'atEquipment',
    positionIcon: '',
    hasDistance: false,
    distance: '',
    distanceUnit: 'm',
    x, y,
    wires: wires || [[], [], []],
  };
}

function rotatingMachineNode(id, z, name, uuid, x, y, wires) {
  return {
    id, type: 'rotatingMachine', z, name,
    speed: '1',
    startup: '2', warmup: '1', shutdown: '2', cooldown: '1',
    movementMode: 'staticspeed',
    machineCurve: '',
    uuid,
    supplier: 'hidrostal',
    category: 'pump',
    assetType: 'pump-centrifugal',
    model: 'hidrostal-H05K-S03R',
    unit: 'm3/h',
    curvePressureUnit: 'mbar',
    curveFlowUnit: 'm3/h',
    curvePowerUnit: 'kW',
    curveControlUnit: '%',
    enableLog: false,
    logLevel: 'error',
    positionVsParent: 'atEquipment',
    positionIcon: '',
    hasDistance: false,
    distance: '',
    distanceUnit: 'm',
    distanceDescription: '',
    x, y,
    wires: wires || [[], [], []],
  };
}

/* FlowFuse ui-chart with every required key (per layout rule §4). */
function uiChart(id, z, group, name, label, order, yAxisLabel, x, y, color) {
  return {
    id, type: 'ui-chart', z, group, name, label,
    order, width: 12, height: 6,
    chartType: 'line',
    category: 'topic',
    categoryType: 'msg',
    xAxisLabel: 'time',
    xAxisType: 'time',
    xAxisProperty: '',
    xAxisPropertyType: 'timestamp',
    xAxisFormat: '',
    xAxisFormatType: 'auto',
    yAxisLabel,
    yAxisProperty: 'payload',
    yAxisPropertyType: 'msg',
    xmin: '', xmax: '', ymin: '', ymax: '',
    bins: 10,
    action: 'append',
    stackSeries: false,
    pointShape: 'circle',
    pointRadius: 4,
    interpolation: 'linear',
    showLegend: true,
    className: '',
    removeOlder: '15',
    removeOlderUnit: '60',
    removeOlderPoints: '200',
    colors: color ? [color, ...CHART_COLORS.slice(1)] : CHART_COLORS,
    textColor: ['#666666'],
    textColorDefault: true,
    gridColor: ['#e5e5e5'],
    gridColorDefault: true,
    x, y, wires: [],
  };
}

function uiText(id, z, group, name, label, order, x, y, format) {
  return {
    id, type: 'ui-text', z, group, name, label,
    order, width: 4, height: 1,
    format: format || '{{msg.payload}}',
    layout: 'row-spread',
    x, y, wires: [],
  };
}

function uiSlider(id, z, group, name, label, order, x, y, topic, min, max, step) {
  return {
    id, type: 'ui-slider', z, group, name, label,
    order, width: 6, height: 1,
    passthru: true,
    outs: 'end',
    topic,
    topicType: 'str',
    min, max, step,
    icon: '',
    thumbLabel: 'always',
    showValue: true,
    className: '',
    x, y, wires: [[]],
  };
}

function uiDropdown(id, z, group, name, label, order, x, y, topic, options, wires) {
  return {
    id, type: 'ui-dropdown', z, group, name, label,
    order, width: 6, height: 1,
    passthru: true,
    multiple: false,
    options: options.map((o) => ({ label: o, value: o, type: 'str' })),
    payload: '',
    topic,
    topicType: 'str',
    x, y,
    wires: [wires || []],
  };
}

function uiBase(id) {
  return {
    id, type: 'ui-base',
    name: 'EVOLV Demo',
    path: '/dashboard',
    appIcon: '',
    includeClientData: true,
    acceptsClientConfig: ['ui-notification', 'ui-control'],
    showPathInSidebar: false,
    headerContent: 'page',
    navigationStyle: 'default',
    titleBarStyle: 'default',
  };
}

function uiTheme(id) {
  return {
    id, type: 'ui-theme',
    name: 'EVOLV Theme',
    colors: {
      surface: '#ffffff', primary: '#0c99d9', bgPage: '#eeeeee',
      groupBg: '#ffffff', groupOutline: '#cccccc',
    },
    sizes: {
      density: 'default', pagePadding: '14px', groupGap: '14px',
      groupBorderRadius: '6px', widgetGap: '12px',
    },
  };
}

function uiPage(id, base, theme, name, path, order) {
  return {
    id, type: 'ui-page', name, ui: base, path,
    icon: 'water',
    layout: 'grid', theme,
    breakpoints: [{ name: 'Default', px: '0', cols: '12' }],
    order, className: '',
  };
}

function uiGroup(id, page, name, width, height, order) {
  return {
    id, type: 'ui-group', name, page, width, height, order,
    showTitle: true, className: '',
  };
}

/* ------------------------------------------------------------------ */
/* Tier 1 — 01-Basic.json                                              */
/* ------------------------------------------------------------------ */

function buildBasic() {
  const Z = 'ps_basic_tab';
  const nodes = [];

  nodes.push(tab(Z, 'PumpingStation - Basic',
    'Tier 1: single pumpingStation node driven by inject nodes only. ' +
    'Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand.'));

  nodes.push(comment('ps_basic_title', Z,
    'PumpingStation - Basic\n' +
    '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
    'A 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\n' +
    'overflow at 3.2 m). controlMode = levelbased, manual demand allowed\n' +
    'only when set.mode = manual.\n\n' +
    'HOW TO USE:\n' +
    '  1. Deploy the flow.\n' +
    '  2. Click "set.mode = manual" so set.demand is honoured.\n' +
    '  3. Click "set.inflow = 60 m3/h" to push wastewater into the basin.\n' +
    '  4. Watch the basin fill on Port 0 (level, volume, percControl rise).\n' +
    '  5. Click "calibrate volume 25 m3" to jump straight to half-full.\n\n' +
    'Aliases (changemode, q_in, Qd, …) still work but log a deprecation\n' +
    'warning - fresh flows use the canonical names.', 600, 40));

  // Lane 0: link-in placeholders (none for Tier 1 - all inputs are local).
  // Lane 2..3: inject nodes (we keep them in lane 1 for proximity).
  const injectMode    = inject('ps_basic_inj_mode',    Z, 'set.mode = manual',    'set.mode',    'manual', 'str', 200, 160, ['ps_basic_node']);
  const injectModeLvl = inject('ps_basic_inj_mode_lvl',Z, 'set.mode = levelbased','set.mode',    'levelbased', 'str', 220, 200, ['ps_basic_node']);
  const injectInflow  = inject('ps_basic_inj_inflow',  Z, 'set.inflow = 60 m3/h', 'set.inflow',  '60',     'num', 200, 260, ['ps_basic_node']);
  const injectDemand  = inject('ps_basic_inj_demand',  Z, 'set.demand = 40 %',    'set.demand',  '40',     'num', 200, 300, ['ps_basic_node']);
  const injectCalVol  = inject('ps_basic_inj_calvol',  Z, 'calibrate volume 25 m3','cmd.calibrate.volume','25','num', 220, 360, ['ps_basic_node']);
  const injectCalLvl  = inject('ps_basic_inj_callvl',  Z, 'calibrate level 1.5 m','cmd.calibrate.level','1.5','num', 220, 400, ['ps_basic_node']);
  nodes.push(injectMode, injectModeLvl, injectInflow, injectDemand, injectCalVol, injectCalLvl);

  // Lane 5 (PC): the pumpingStation itself.
  const ps = pumpingStationNode('ps_basic_node', Z, 'Pumping Station', LANE_X[5], 300,
    [['ps_basic_format'], ['ps_basic_dbg_influx'], ['ps_basic_dbg_parent']]);
  nodes.push(ps);

  // Lane 6: format/merge function for Port 0.
  const formatFn = fn('ps_basic_format', Z, 'Merge deltas + format',
    "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
    "const cache = context.get('c') || {};\n" +
    "Object.assign(cache, p);\n" +
    "context.set('c', cache);\n" +
    "function pick(prefix) {\n" +
    "  for (const k of Object.keys(cache)) if (k === prefix || k.indexOf(prefix + '.') === 0) {\n" +
    "    const v = Number(cache[k]); if (Number.isFinite(v)) return v;\n" +
    "  } return null;\n" +
    "}\n" +
    "const vol  = pick('volume.predicted.atequipment');\n" +
    "const lvl  = pick('level.predicted.atequipment');\n" +
    "const flIn = pick('flow.predicted.in');\n" +
    "msg.payload = {\n" +
    "  state: cache.state || 'unknown',\n" +
    "  controlMode: cache.controlMode || cache.mode || 'n/a',\n" +
    "  direction: cache.direction || 'n/a',\n" +
    "  percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a',\n" +
    "  volume: vol != null ? vol.toFixed(2) + ' m3' : 'n/a',\n" +
    "  volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1) + ' %' : 'n/a',\n" +
    "  level: lvl != null ? lvl.toFixed(3) + ' m' : 'n/a',\n" +
    "  inflow: flIn != null ? (flIn * 3600).toFixed(1) + ' m3/h' : 'n/a',\n" +
    "  timeToFull: cache.timeToFull != null ? Number(cache.timeToFull).toFixed(0) + ' s' : 'n/a',\n" +
    "  timeToEmpty: cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a'\n" +
    "};\nreturn msg;",
    LANE_X[6], 280, [['ps_basic_dbg_process']]);
  nodes.push(formatFn);

  // Lane 7: debug taps.
  nodes.push(debugNode('ps_basic_dbg_process', Z, 'Port 0: Process', LANE_X[7], 240, 'payload', 'msg', true));
  nodes.push(debugNode('ps_basic_dbg_influx',  Z, 'Port 1: InfluxDB', LANE_X[7], 320, 'true', 'full', false));
  nodes.push(debugNode('ps_basic_dbg_parent',  Z, 'Port 2: Parent reg', LANE_X[7], 380, 'true', 'full', true));

  // Wrap the station + its formatter in a Process Cell group box.
  const psGroupIds = ['ps_basic_node', 'ps_basic_format'];
  nodes.push(group('grp_ps_basic', Z, 'Pumping Station (PC)', S88.PC, psGroupIds,
    bboxOf(nodes, psGroupIds, 30)));

  return nodes;
}

/* ------------------------------------------------------------------ */
/* Tier 2 — 02-Integration.json                                        */
/* ------------------------------------------------------------------ */

function buildIntegration() {
  const TAB_PROC = 'ps_int_proc';
  const TAB_SETUP = 'ps_int_setup';
  const nodes = [];

  nodes.push(tab(TAB_PROC,  'Process Plant',
    'Tier 2: pumpingStation + measurement child + machineGroupControl parent with two rotatingMachine pumps. ' +
    'Demonstrates Phase-2 parent/child handshakes and the canonical set.mode/set.inflow/set.demand topics.'));
  nodes.push(tab(TAB_SETUP, 'Setup',
    'Deploy-time once-true injects that initialise control modes on the EVOLV nodes.'));

  /* ---------- Process Plant tab ---------------------------------- */

  nodes.push(comment('ps_int_title', TAB_PROC,
    'PumpingStation - Integration\n' +
    '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
    'L0 link-ins | L2 level sensor (CM) | L3 pumps (EM) | L4 MGC (UN) | L5 station (PC).\n' +
    'Pumps register with MGC via Port 2; MGC and the level sensor register with the station via Port 2.\n' +
    'Cross-tab channels: setup:* drive once-true initialisation from the Setup tab.', 600, 40));

  /* Link-ins on L0 receive from the Setup tab. */
  const linInMode  = linkIn('lin_setup_mode',  TAB_PROC, 'setup:to-ps-mode',   LANE_X[0], 500, [], ['ps_int_station']);
  const linInInflow = linkIn('lin_setup_inflow', TAB_PROC, 'setup:to-ps-inflow', LANE_X[0], 560, [], ['ps_int_station']);
  const linInMgcMode = linkIn('lin_setup_mgcmode', TAB_PROC, 'setup:to-mgc-mode', LANE_X[0], 360, [], ['ps_int_mgc']);
  nodes.push(linInMode, linInInflow, linInMgcMode);

  /* L2: level measurement (Control Module). */
  const levelMeas = measurementLevelNode('meas_level', TAB_PROC, 'Basin level sensor',
    LANE_X[2], 700, [['ps_int_dbg_level'], [], ['ps_int_station']]);
  nodes.push(levelMeas);
  // Simulator measurement injector for the level sensor (push a varying level so PS sees something).
  const levelInj = inject('ps_int_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num', LANE_X[0], 700, ['meas_level']);
  nodes.push(levelInj);

  /* L3: two rotatingMachine pumps (Equipment Module). */
  const pumpA = rotatingMachineNode('pump_a', TAB_PROC, 'Pump A', 'example-pump-a',
    LANE_X[3], 320, [['ps_int_dbg_pa'], [], ['ps_int_mgc']]);
  const pumpB = rotatingMachineNode('pump_b', TAB_PROC, 'Pump B', 'example-pump-b',
    LANE_X[3], 400, [['ps_int_dbg_pb'], [], ['ps_int_mgc']]);
  nodes.push(pumpA, pumpB);

  /* L4: MGC (Unit). */
  const mgc = machineGroupControlNode('ps_int_mgc', TAB_PROC, 'Pump Group',
    LANE_X[4], 360, [['ps_int_dbg_mgc'], [], ['ps_int_station']]);
  nodes.push(mgc);

  /* L5: pumpingStation (Process Cell). */
  const station = pumpingStationNode('ps_int_station', TAB_PROC, 'Pumping Station',
    LANE_X[5], 520, [['ps_int_format'], ['ps_int_dbg_influx'], []]);
  nodes.push(station);

  /* L6: formatter for the station's Port 0. */
  const formatFn = fn('ps_int_format', TAB_PROC, 'Merge deltas + format',
    "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
    "const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" +
    "function pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\n" +
    "const vol=pick('volume.predicted.atequipment'), lvl=pick('level.predicted.atequipment'), flIn=pick('flow.predicted.in'), flOut=pick('flow.predicted.out');\n" +
    "msg.payload = {\n" +
    "  state: cache.state || 'unknown',\n" +
    "  controlMode: cache.controlMode || cache.mode || 'n/a',\n" +
    "  direction: cache.direction || 'n/a',\n" +
    "  percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1)+' %' : 'n/a',\n" +
    "  volume: vol != null ? vol.toFixed(2)+' m3' : 'n/a',\n" +
    "  volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1)+' %' : 'n/a',\n" +
    "  level: lvl != null ? lvl.toFixed(3)+' m' : 'n/a',\n" +
    "  inflow: flIn != null ? (flIn*3600).toFixed(1)+' m3/h' : 'n/a',\n" +
    "  outflow: flOut != null ? (flOut*3600).toFixed(1)+' m3/h' : 'n/a',\n" +
    "  childCount: cache.childCount != null ? cache.childCount : 'n/a'\n" +
    "};\nreturn msg;",
    LANE_X[6], 520, [['ps_int_dbg_process']]);
  nodes.push(formatFn);

  /* L7: debug taps for the various ports. */
  nodes.push(debugNode('ps_int_dbg_process', TAB_PROC, 'PS Port 0: Process',   LANE_X[7], 480, 'payload', 'msg', true));
  nodes.push(debugNode('ps_int_dbg_influx',  TAB_PROC, 'PS Port 1: InfluxDB',  LANE_X[7], 540, 'true', 'full', false));
  nodes.push(debugNode('ps_int_dbg_mgc',     TAB_PROC, 'MGC Port 0',           LANE_X[7], 360, 'payload', 'msg', true));
  nodes.push(debugNode('ps_int_dbg_pa',      TAB_PROC, 'Pump A Port 0',        LANE_X[7], 320, 'payload', 'msg', false));
  nodes.push(debugNode('ps_int_dbg_pb',      TAB_PROC, 'Pump B Port 0',        LANE_X[7], 400, 'payload', 'msg', false));
  nodes.push(debugNode('ps_int_dbg_level',   TAB_PROC, 'Level Port 0',         LANE_X[7], 700, 'payload', 'msg', false));

  /* Group boxes. */
  const pumpAIds = ['pump_a', 'ps_int_dbg_pa'];
  const pumpBIds = ['pump_b', 'ps_int_dbg_pb'];
  const mgcIds   = ['ps_int_mgc', 'ps_int_dbg_mgc', 'lin_setup_mgcmode'];
  const stationIds = ['ps_int_station', 'ps_int_format', 'ps_int_dbg_process', 'ps_int_dbg_influx', 'lin_setup_mode', 'lin_setup_inflow'];
  const levelIds = ['meas_level', 'ps_int_inj_level', 'ps_int_dbg_level'];
  nodes.push(group('grp_pumpa', TAB_PROC,   'Pump A (EM)',          S88.EM, pumpAIds,   bboxOf(nodes, pumpAIds, 25)));
  nodes.push(group('grp_pumpb', TAB_PROC,   'Pump B (EM)',          S88.EM, pumpBIds,   bboxOf(nodes, pumpBIds, 25)));
  nodes.push(group('grp_mgc',   TAB_PROC,   'Pump Group MGC (UN)',  S88.UN, mgcIds,     bboxOf(nodes, mgcIds, 25)));
  nodes.push(group('grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, stationIds, bboxOf(nodes, stationIds, 25)));
  nodes.push(group('grp_level', TAB_PROC,   'Level Sensor (CM)',    S88.CM, levelIds,   bboxOf(nodes, levelIds, 25)));

  /* ---------- Setup tab ----------------------------------------- */

  nodes.push(comment('setup_title', TAB_SETUP,
    'Deploy-time setup\n' +
    '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
    'Fires once after each deploy: pushes the canonical set.mode / set.inflow /\n' +
    'set.demand topics across cross-tab channels into the Process Plant tab.',
    LANE_X[2], 40));

  const setMode   = inject('setup_inj_mode',     TAB_SETUP, 'set.mode = levelbased',    'set.mode',   'levelbased', 'str', LANE_X[0], 160, ['lout_setup_mode'],   { once: true, onceDelay: '0.5' });
  const setMgc    = inject('setup_inj_mgcmode',  TAB_SETUP, 'MGC set.mode = auto',      'set.mode',   'auto',       'str', LANE_X[0], 220, ['lout_setup_mgcmode'],{ once: true, onceDelay: '0.5' });
  const setInflow = inject('setup_inj_inflow',   TAB_SETUP, 'seed inflow 60 m3/h',      'set.inflow', '60',         'num', LANE_X[0], 280, ['lout_setup_inflow'], { once: true, onceDelay: '1.0' });
  nodes.push(setMode, setMgc, setInflow);

  const loutMode    = linkOut('lout_setup_mode',    TAB_SETUP, 'setup:to-ps-mode',    LANE_X[7], 160, ['lin_setup_mode']);
  const loutMgcMode = linkOut('lout_setup_mgcmode', TAB_SETUP, 'setup:to-mgc-mode',   LANE_X[7], 220, ['lin_setup_mgcmode']);
  const loutInflow  = linkOut('lout_setup_inflow',  TAB_SETUP, 'setup:to-ps-inflow',  LANE_X[7], 280, ['lin_setup_inflow']);
  nodes.push(loutMode, loutMgcMode, loutInflow);

  // Setup tab group.
  const setupIds = ['setup_inj_mode', 'setup_inj_mgcmode', 'setup_inj_inflow',
                    'lout_setup_mode', 'lout_setup_mgcmode', 'lout_setup_inflow'];
  nodes.push(group('grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25)));

  return nodes;
}

/* ------------------------------------------------------------------ */
/* Tier 3 — 03-Dashboard.json                                          */
/* ------------------------------------------------------------------ */

function buildDashboard() {
  const TAB_PROC = 'ps_dash_proc';
  const TAB_UI = 'ps_dash_ui';
  const TAB_SETUP = 'ps_dash_setup';
  const nodes = [];

  nodes.push(tab(TAB_PROC,  'Process Plant',
    'Tier 3: full station with measurement + MGC + 2 pumps, formatted for live dashboard.'));
  nodes.push(tab(TAB_UI,    'Dashboard UI',
    'FlowFuse dashboard 2.0: 3 charts (flow / level / volumePercent), text widgets and 2 sliders.'));
  nodes.push(tab(TAB_SETUP, 'Setup',
    'Once-true injects: initial mode + initial inflow seed.'));

  /* ---------- FlowFuse dashboard scaffolding -------------------- */

  nodes.push(uiBase('ps_dash_base'));
  nodes.push(uiTheme('ps_dash_theme'));
  nodes.push(uiPage('ps_dash_page', 'ps_dash_base', 'ps_dash_theme', 'PumpingStation Demo', '/pumping-station', 1));
  nodes.push(uiGroup('ps_dash_grp_ctrl',   'ps_dash_page', 'Controls',         6, 1, 1));
  nodes.push(uiGroup('ps_dash_grp_status', 'ps_dash_page', 'Operator Status',  6, 1, 2));
  nodes.push(uiGroup('ps_dash_grp_trend',  'ps_dash_page', 'Live Trends',     12, 1, 3));

  /* ---------- Process Plant tab --------------------------------- */

  nodes.push(comment('ps_dash_proc_title', TAB_PROC,
    'Process Plant\n━━━━━━━━━━━━━━━━━\nFull station with parent (MGC) and 2 pump children.\n' +
    'Events go to Dashboard UI through evt:ps; commands come back through cmd:ps-mode and cmd:ps-demand.',
    600, 40));

  /* L0 link-ins: setup + dashboard commands. */
  const linModeProc   = linkIn('lin_proc_mode',   TAB_PROC, 'cmd:ps-mode',    LANE_X[0], 480, [], ['ps_dash_station']);
  const linDemandProc = linkIn('lin_proc_demand', TAB_PROC, 'cmd:ps-demand',  LANE_X[0], 540, [], ['ps_dash_station']);
  const linSetupMode  = linkIn('lin_proc_setupmode', TAB_PROC, 'setup:to-ps-mode',  LANE_X[0], 420, [], ['ps_dash_station']);
  const linSetupInflow= linkIn('lin_proc_setupinflow', TAB_PROC, 'setup:to-ps-inflow',LANE_X[0], 600, [], ['ps_dash_station']);
  nodes.push(linModeProc, linDemandProc, linSetupMode, linSetupInflow);

  /* L2 level sensor with simulator. */
  const levelMeas = measurementLevelNode('ps_dash_meas_level', TAB_PROC, 'Basin level sensor',
    LANE_X[2], 700, [[], [], ['ps_dash_station']]);
  nodes.push(levelMeas);
  nodes.push(inject('ps_dash_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num',
    LANE_X[0], 700, ['ps_dash_meas_level']));

  /* L3 pumps. */
  const pumpA = rotatingMachineNode('ps_dash_pump_a', TAB_PROC, 'Pump A', 'example-pump-a',
    LANE_X[3], 320, [[], [], ['ps_dash_mgc']]);
  const pumpB = rotatingMachineNode('ps_dash_pump_b', TAB_PROC, 'Pump B', 'example-pump-b',
    LANE_X[3], 400, [[], [], ['ps_dash_mgc']]);
  nodes.push(pumpA, pumpB);

  /* L4 MGC. */
  const mgc = machineGroupControlNode('ps_dash_mgc', TAB_PROC, 'Pump Group',
    LANE_X[4], 360, [[], [], ['ps_dash_station']]);
  nodes.push(mgc);

  /* L5 pumpingStation. */
  const station = pumpingStationNode('ps_dash_station', TAB_PROC, 'Pumping Station',
    LANE_X[5], 520, [['ps_dash_trend_split'], [], []]);
  nodes.push(station);

  /* L6 trend-split fn: one output per chart + one output for the status text widgets.
   * Outputs:
   *   0 -> chart_flow      ({topic: 'Inflow', payload: m3/h}, {topic: 'Outflow', payload: m3/h})
   *   1 -> chart_level     ({topic: 'Level', payload: m})
   *   2 -> chart_volpct    ({topic: 'Volume%', payload: %})
   *   3 -> text_status     (compact state string)
   *   4 -> text_perc       (percControl)
   *   5 -> text_direction  (direction)
   *   6 -> text_timetoempty(timeToEmpty)
   */
  const trendCode =
    "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
    "const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" +
    "function pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\n" +
    "const flowIn  = pick('flow.predicted.in');\n" +
    "const flowOut = pick('flow.predicted.out');\n" +
    "const level   = pick('level.predicted.atequipment');\n" +
    "const volPct  = Number(cache.volumePercent);\n" +
    "const ts = Date.now();\n" +
    "const flowMsgs = [];\n" +
    "if (flowIn  != null) flowMsgs.push({ topic: 'Inflow',  payload: flowIn  * 3600, timestamp: ts });\n" +
    "if (flowOut != null) flowMsgs.push({ topic: 'Outflow', payload: flowOut * 3600, timestamp: ts });\n" +
    "const flowOut1 = flowMsgs.length ? flowMsgs : null;\n" +
    "const levelOut = level != null ? { topic: 'Level', payload: level, timestamp: ts } : null;\n" +
    "const volOut   = Number.isFinite(volPct) ? { topic: 'Volume%', payload: volPct, timestamp: ts } : null;\n" +
    "const stateStr = `state=${cache.state||'?'} | mode=${cache.controlMode||cache.mode||'?'}`;\n" +
    "const percStr  = cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a';\n" +
    "const dirStr   = cache.direction || 'n/a';\n" +
    "const tEmpty   = cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a';\n" +
    "return [\n" +
    "  flowOut1,\n" +
    "  levelOut,\n" +
    "  volOut,\n" +
    "  { payload: stateStr },\n" +
    "  { payload: percStr },\n" +
    "  { payload: dirStr },\n" +
    "  { payload: tEmpty }\n" +
    "];";
  const trendSplit = fn('ps_dash_trend_split', TAB_PROC, 'Trend split + status', trendCode,
    LANE_X[6], 520,
    [
      ['lout_evt_flow'],
      ['lout_evt_level'],
      ['lout_evt_volpct'],
      ['lout_evt_state'],
      ['lout_evt_perc'],
      ['lout_evt_dir'],
      ['lout_evt_tempty'],
    ], 7);
  nodes.push(trendSplit);

  /* L7 link-outs into the Dashboard UI tab. */
  const loutFlow   = linkOut('lout_evt_flow',   TAB_PROC, 'evt:flow',   LANE_X[7], 420, ['lin_ui_flow']);
  const loutLevel  = linkOut('lout_evt_level',  TAB_PROC, 'evt:level',  LANE_X[7], 460, ['lin_ui_level']);
  const loutVolPct = linkOut('lout_evt_volpct', TAB_PROC, 'evt:volpct', LANE_X[7], 500, ['lin_ui_volpct']);
  const loutState  = linkOut('lout_evt_state',  TAB_PROC, 'evt:state',  LANE_X[7], 540, ['lin_ui_state']);
  const loutPerc   = linkOut('lout_evt_perc',   TAB_PROC, 'evt:perc',   LANE_X[7], 580, ['lin_ui_perc']);
  const loutDir    = linkOut('lout_evt_dir',    TAB_PROC, 'evt:dir',    LANE_X[7], 620, ['lin_ui_dir']);
  const loutTempty = linkOut('lout_evt_tempty', TAB_PROC, 'evt:tempty', LANE_X[7], 660, ['lin_ui_tempty']);
  nodes.push(loutFlow, loutLevel, loutVolPct, loutState, loutPerc, loutDir, loutTempty);

  /* Process tab groups. */
  const procStationIds = ['ps_dash_station', 'ps_dash_trend_split',
                          'lin_proc_mode', 'lin_proc_demand', 'lin_proc_setupmode', 'lin_proc_setupinflow',
                          'lout_evt_flow', 'lout_evt_level', 'lout_evt_volpct', 'lout_evt_state', 'lout_evt_perc', 'lout_evt_dir', 'lout_evt_tempty'];
  const procPumpAIds   = ['ps_dash_pump_a'];
  const procPumpBIds   = ['ps_dash_pump_b'];
  const procMgcIds     = ['ps_dash_mgc'];
  const procLevelIds   = ['ps_dash_meas_level', 'ps_dash_inj_level'];
  nodes.push(group('ps_dash_grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, procStationIds, bboxOf(nodes, procStationIds, 25)));
  nodes.push(group('ps_dash_grp_pa',      TAB_PROC, 'Pump A (EM)',          S88.EM, procPumpAIds,   bboxOf(nodes, procPumpAIds, 25)));
  nodes.push(group('ps_dash_grp_pb',      TAB_PROC, 'Pump B (EM)',          S88.EM, procPumpBIds,   bboxOf(nodes, procPumpBIds, 25)));
  nodes.push(group('ps_dash_grp_mgc',     TAB_PROC, 'Pump Group MGC (UN)',  S88.UN, procMgcIds,     bboxOf(nodes, procMgcIds, 25)));
  nodes.push(group('ps_dash_grp_level',   TAB_PROC, 'Level Sensor (CM)',    S88.CM, procLevelIds,   bboxOf(nodes, procLevelIds, 25)));

  /* ---------- Dashboard UI tab ---------------------------------- */

  nodes.push(comment('ps_dash_ui_title', TAB_UI,
    'Dashboard UI\n━━━━━━━━━━━━━━━\nLink-ins on L0 receive evt:* from Process Plant.\n' +
    'Sliders on L2 emit cmd:* back to Process Plant.\n' +
    'Charts use the trend-split pattern: one chart per metric, series labelled by msg.topic.',
    600, 40));

  /* L0 link-ins from the process side. */
  nodes.push(linkIn('lin_ui_flow',   TAB_UI, 'evt:flow',   LANE_X[0], 220, [], ['ui_chart_flow']));
  nodes.push(linkIn('lin_ui_level',  TAB_UI, 'evt:level',  LANE_X[0], 320, [], ['ui_chart_level']));
  nodes.push(linkIn('lin_ui_volpct', TAB_UI, 'evt:volpct', LANE_X[0], 420, [], ['ui_chart_volpct']));
  nodes.push(linkIn('lin_ui_state',  TAB_UI, 'evt:state',  LANE_X[0], 520, [], ['ui_text_state']));
  nodes.push(linkIn('lin_ui_perc',   TAB_UI, 'evt:perc',   LANE_X[0], 560, [], ['ui_text_perc']));
  nodes.push(linkIn('lin_ui_dir',    TAB_UI, 'evt:dir',    LANE_X[0], 600, [], ['ui_text_dir']));
  nodes.push(linkIn('lin_ui_tempty', TAB_UI, 'evt:tempty', LANE_X[0], 640, [], ['ui_text_tempty']));

  /* L4 charts and text widgets. */
  nodes.push(uiChart('ui_chart_flow',   TAB_UI, 'ps_dash_grp_trend', 'Flow trend',   'Flow (m³/h)',  1, 'm³/h', LANE_X[4], 220));
  nodes.push(uiChart('ui_chart_level',  TAB_UI, 'ps_dash_grp_trend', 'Level trend',  'Level (m)',    2, 'm',    LANE_X[4], 320));
  nodes.push(uiChart('ui_chart_volpct', TAB_UI, 'ps_dash_grp_trend', 'Volume %',     'Volume (%)',   3, '%',    LANE_X[4], 420));
  nodes.push(uiText( 'ui_text_state',   TAB_UI, 'ps_dash_grp_status','State',        'Station state',1, LANE_X[4], 520));
  nodes.push(uiText( 'ui_text_perc',    TAB_UI, 'ps_dash_grp_status','percControl',  'Control %',    2, LANE_X[4], 560));
  nodes.push(uiText( 'ui_text_dir',     TAB_UI, 'ps_dash_grp_status','direction',    'Direction',    3, LANE_X[4], 600));
  nodes.push(uiText( 'ui_text_tempty',  TAB_UI, 'ps_dash_grp_status','timeToEmpty',  'Time to empty',4, LANE_X[4], 640));

  /* L2 controls: dropdown for mode + slider for demand. */
  const modeDropdown = uiDropdown('ui_dd_mode', TAB_UI, 'ps_dash_grp_ctrl',
    'Mode', 'Control mode', 1, LANE_X[2], 160, 'set.mode',
    ['manual', 'levelbased', 'flowbased', 'none'], ['ui_wrap_mode']);
  const demandSlider = uiSlider('ui_sl_demand', TAB_UI, 'ps_dash_grp_ctrl',
    'Demand', 'Manual demand (m³/h)', 2, LANE_X[2], 220, 'set.demand', 0, 200, 5);
  nodes.push(modeDropdown, demandSlider);
  // Slider wires need explicit wiring (uiSlider helper leaves wires empty so we set them post-creation).
  demandSlider.wires = [['ui_wrap_demand']];

  /* L4 wrappers: enforce the canonical topic on the outgoing msg. */
  const wrapMode = fn('ui_wrap_mode', TAB_UI, 'topic=set.mode',
    "msg.topic = 'set.mode';\nmsg.payload = String(msg.payload || 'manual');\nreturn msg;",
    LANE_X[4], 160, [['lout_cmd_mode']]);
  const wrapDemand = fn('ui_wrap_demand', TAB_UI, 'topic=set.demand',
    "msg.topic = 'set.demand';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
    LANE_X[4], 220, [['lout_cmd_demand']]);
  nodes.push(wrapMode, wrapDemand);

  /* L7 link-outs to the process plant. */
  nodes.push(linkOut('lout_cmd_mode',   TAB_UI, 'cmd:ps-mode',   LANE_X[7], 160, ['lin_proc_mode']));
  nodes.push(linkOut('lout_cmd_demand', TAB_UI, 'cmd:ps-demand', LANE_X[7], 220, ['lin_proc_demand']));

  /* UI tab groups (mirror the dashboard groups). */
  const uiCtrlIds   = ['ui_dd_mode', 'ui_sl_demand', 'ui_wrap_mode', 'ui_wrap_demand',
                       'lout_cmd_mode', 'lout_cmd_demand'];
  const uiStatusIds = ['ui_text_state', 'ui_text_perc', 'ui_text_dir', 'ui_text_tempty',
                       'lin_ui_state', 'lin_ui_perc', 'lin_ui_dir', 'lin_ui_tempty'];
  const uiTrendIds  = ['ui_chart_flow', 'ui_chart_level', 'ui_chart_volpct',
                       'lin_ui_flow', 'lin_ui_level', 'lin_ui_volpct'];
  nodes.push(group('grp_ui_ctrl',   TAB_UI, 'Controls (PC)',         S88.PC, uiCtrlIds,   bboxOf(nodes, uiCtrlIds, 25)));
  nodes.push(group('grp_ui_status', TAB_UI, 'Operator status (PC)',  S88.PC, uiStatusIds, bboxOf(nodes, uiStatusIds, 25)));
  nodes.push(group('grp_ui_trend',  TAB_UI, 'Live trends (PC)',      S88.PC, uiTrendIds,  bboxOf(nodes, uiTrendIds, 25)));

  /* ---------- Setup tab ----------------------------------------- */

  nodes.push(comment('ps_dash_setup_title', TAB_SETUP, 'Deploy-time setup\n━━━━━━━━━━━━━━━━━━━\n' +
    'Initialises set.mode = levelbased and seeds an inflow at deploy time.',
    LANE_X[2], 40));

  nodes.push(inject('ps_dash_setup_mode',   TAB_SETUP, 'set.mode = levelbased', 'set.mode',   'levelbased', 'str',
    LANE_X[0], 160, ['ps_dash_lout_setup_mode'],   { once: true, onceDelay: '0.5' }));
  nodes.push(inject('ps_dash_setup_inflow', TAB_SETUP, 'seed inflow 60 m3/h',   'set.inflow', '60',         'num',
    LANE_X[0], 220, ['ps_dash_lout_setup_inflow'], { once: true, onceDelay: '1.0' }));

  nodes.push(linkOut('ps_dash_lout_setup_mode',   TAB_SETUP, 'setup:to-ps-mode',   LANE_X[7], 160, ['lin_proc_setupmode']));
  nodes.push(linkOut('ps_dash_lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 220, ['lin_proc_setupinflow']));

  const setupIds = ['ps_dash_setup_mode', 'ps_dash_setup_inflow',
                    'ps_dash_lout_setup_mode', 'ps_dash_lout_setup_inflow'];
  nodes.push(group('ps_dash_grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25)));

  return nodes;
}

/* ------------------------------------------------------------------ */
/* README                                                              */
/* ------------------------------------------------------------------ */

const README = `# pumpingStation - Example Flows

Three Node-RED flows demonstrating the Phase-2 pumpingStation node on the
canonical topic API (\`set.mode\`, \`set.inflow\`, \`set.demand\`,
\`cmd.calibrate.volume\`, \`cmd.calibrate.level\`). Legacy aliases
(\`changemode\`, \`q_in\`, \`Qd\`, \`calibratePredictedVolume\`,
\`calibratePredictedLevel\`, \`registerChild\`) still work but log a
one-time deprecation warning; these fresh flows use the canonical names only.

## Files

| File | Tier | Tabs | Purpose |
|---|---|---|---|
| \`01-Basic.json\` | 1 | Process Plant | Single pumpingStation driven by inject nodes - no parent, no dashboard. |
| \`02-Integration.json\` | 2 | Process Plant + Setup | Adds a \`measurement\` level child and a \`machineGroupControl\` parent with two \`rotatingMachine\` pumps. Demonstrates the Phase-2 parent/child handshake. |
| \`03-Dashboard.json\` | 3 | Process Plant + Dashboard UI + Setup | Tier 2 plumbing plus a FlowFuse Dashboard 2.0 page with 3 charts (flow / level / volume %), text widgets, and 2 controls (mode dropdown + demand slider). |

## Prerequisites

- Node-RED with the EVOLV package installed (so the \`pumpingStation\`,
  \`measurement\`, \`machineGroupControl\`, and \`rotatingMachine\` node
  types are registered).
- For \`03-Dashboard.json\`: \`@flowfuse/node-red-dashboard\` (Dashboard 2.0).

## How to load

\`\`\`bash
# Drop a file into a running Node-RED instance using its Admin API.
curl -X POST -H 'Content-Type: application/json' \\
  --data @nodes/pumpingStation/examples/01-Basic.json \\
  http://localhost:1880/flows
\`\`\`

Or in the editor: **Menu -> Import -> select file -> Import**. The flows
import into their own tabs and can be deployed immediately.

## 01-Basic - what to try

1. Deploy.
2. Inject \`set.mode = manual\`.
3. Inject \`set.inflow = 60 m3/h\` - the basin starts filling. Watch the
   formatted Port 0 payload in the debug sidebar.
4. Inject \`set.demand = 40 %\` - in manual mode this would feed any
   registered children; here there are no pump children so it is logged
   and shown on Port 0.
5. Inject \`cmd.calibrate.volume = 25 m3\` to jump the predicted-volume
   integrator to half-full.

## 02-Integration - what to try

1. Deploy. The Setup tab fires \`set.mode = levelbased\` to the station
   and \`set.mode = auto\` to the MGC.
2. The two pumps register with the MGC via Port 2; the MGC and the level
   sensor register with the station via Port 2. Watch the registration
   debug taps to confirm.
3. The level inject pushes a 1.6 m measurement so the station sees a
   non-zero starting level. Setup also seeds \`set.inflow = 60 m3/h\`.
4. The station's \`controlMode = levelbased\` then drives the MGC, which
   dispatches to Pump A / Pump B.

## 03-Dashboard - what to try

1. Deploy.
2. Open the dashboard at \`http://localhost:1880/dashboard/page/pumping-station\`.
3. Use the **Control mode** dropdown to switch between \`manual\`,
   \`levelbased\`, \`flowbased\`, \`none\`.
4. In manual mode, drag the **Manual demand** slider - the demand cascades
   to the MGC and on to the pumps.
5. The three charts (flow, level, volume %) plot live data; the four text
   widgets show state, percControl, direction, and time-to-empty.

## Layout conventions

These flows follow the EVOLV layout rule set in
\`.claude/rules/node-red-flow-layout.md\`:

- Tabs split by **concern**: Process Plant (EVOLV nodes) / Dashboard UI
  (\`ui-*\` widgets) / Setup (once-true injects).
- Cross-tab wiring via **named link out / link in channels**:
  \`setup:to-ps-mode\`, \`setup:to-ps-inflow\`, \`setup:to-mgc-mode\`,
  \`cmd:ps-mode\`, \`cmd:ps-demand\`, \`evt:flow\`, \`evt:level\`,
  \`evt:volpct\`, \`evt:state\`, \`evt:perc\`, \`evt:dir\`, \`evt:tempty\`.
- **Lane positions** L0-L7 = \`[120, 360, 600, 840, 1080, 1320, 1560, 1800]\`,
  driven by each node's S88 level (Process Cell on L5, Unit on L4,
  Equipment on L3, Control Module on L2).
- **Group boxes** wrap each parent + its direct children, coloured by the
  parent's S88 level.

## Regenerating

These flows are generated from \`tools/build-examples.js\`. Edit the
generator, never the JSON, then:

\`\`\`bash
node nodes/pumpingStation/tools/build-examples.js
\`\`\`

The script writes \`01-Basic.json\`, \`02-Integration.json\`, and
\`03-Dashboard.json\` into this directory.
`;

/* ------------------------------------------------------------------ */
/* Main                                                                */
/* ------------------------------------------------------------------ */

function writeFlow(filename, builder) {
  const flow = builder();
  const dest = path.join(OUT_DIR, filename);
  fs.writeFileSync(dest, JSON.stringify(flow, null, 2) + '\n', 'utf8');
  console.log(`wrote ${dest}  (${flow.length} nodes)`);
}

function main() {
  if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });
  writeFlow('01-Basic.json', buildBasic);
  writeFlow('02-Integration.json', buildIntegration);
  writeFlow('03-Dashboard.json', buildDashboard);
  fs.writeFileSync(path.join(OUT_DIR, 'README.md'), README, 'utf8');
  console.log(`wrote ${path.join(OUT_DIR, 'README.md')}`);
}

main();
