- Update all submodule URLs from gitea.centraal.wbd-rd.nl to gitea.wbd-rd.nl - Add settler as proper submodule in .gitmodules - Add agent skills, function anchors, decisions, and improvements - Add Docker configuration and scripts - Add manuals and third_party docs - Update .gitignore with secrets and build artifacts - Remove stale .tgz build artifact Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
456 lines
12 KiB
JavaScript
456 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Patch demo-flow.json:
|
|
* Phase A: Add 4 NH4 measurement nodes + ui-group + ui-chart
|
|
* Phase B: Add influent composer function node + wire merge collector
|
|
* Phase C: Fix biomass init on reactor
|
|
* Phase D: Add RAS pump, flow sensor, 2 injects, filter function + wiring
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const flowPath = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
|
const flow = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
|
|
|
// Helper: find node by id
|
|
const findNode = (id) => flow.find(n => n.id === id);
|
|
|
|
// ============================================================
|
|
// PHASE A: Add 4 NH4 measurement nodes + ui-group + ui-chart
|
|
// ============================================================
|
|
|
|
const nh4Measurements = [
|
|
{
|
|
id: 'demo_meas_nh4_in',
|
|
name: 'NH4-IN (Ammonium Inlet)',
|
|
uuid: 'nh4-in-001',
|
|
assetTagNumber: 'NH4-IN',
|
|
distance: 0,
|
|
distanceDescription: 'reactor inlet',
|
|
y: 280
|
|
},
|
|
{
|
|
id: 'demo_meas_nh4_a',
|
|
name: 'NH4-A (Early Aeration)',
|
|
uuid: 'nh4-a-001',
|
|
assetTagNumber: 'NH4-A',
|
|
distance: 10,
|
|
distanceDescription: 'early aeration zone',
|
|
y: 320
|
|
},
|
|
{
|
|
id: 'demo_meas_nh4_b',
|
|
name: 'NH4-B (Mid-Reactor)',
|
|
uuid: 'nh4-b-001',
|
|
assetTagNumber: 'NH4-B',
|
|
distance: 25,
|
|
distanceDescription: 'mid-reactor',
|
|
y: 360
|
|
},
|
|
{
|
|
id: 'demo_meas_nh4_c',
|
|
name: 'NH4-C (Near Outlet)',
|
|
uuid: 'nh4-c-001',
|
|
assetTagNumber: 'NH4-C',
|
|
distance: 45,
|
|
distanceDescription: 'near outlet',
|
|
y: 400
|
|
}
|
|
];
|
|
|
|
for (const m of nh4Measurements) {
|
|
flow.push({
|
|
id: m.id,
|
|
type: 'measurement',
|
|
z: 'demo_tab_treatment',
|
|
name: m.name,
|
|
scaling: true,
|
|
i_min: 0,
|
|
i_max: 50,
|
|
i_offset: 0,
|
|
o_min: 0,
|
|
o_max: 50,
|
|
smooth_method: 'mean',
|
|
count: 3,
|
|
simulator: true,
|
|
uuid: m.uuid,
|
|
supplier: 'Hach',
|
|
category: 'sensor',
|
|
assetType: 'ammonium',
|
|
model: 'Amtax-sc',
|
|
unit: 'mg/L',
|
|
assetTagNumber: m.assetTagNumber,
|
|
enableLog: false,
|
|
logLevel: 'error',
|
|
positionVsParent: 'atEquipment',
|
|
x: 400,
|
|
y: m.y,
|
|
wires: [
|
|
['demo_link_meas_dash', 'demo_link_process_out_treatment'],
|
|
['demo_link_influx_out_treatment'],
|
|
['demo_reactor']
|
|
],
|
|
positionIcon: '⊥',
|
|
hasDistance: true,
|
|
distance: m.distance,
|
|
distanceUnit: 'm',
|
|
distanceDescription: m.distanceDescription
|
|
});
|
|
}
|
|
|
|
// NH4 profile ui-group
|
|
flow.push({
|
|
id: 'demo_ui_grp_nh4_profile',
|
|
type: 'ui-group',
|
|
name: 'NH4 Profile Along Reactor',
|
|
page: 'demo_ui_page_treatment',
|
|
width: '6',
|
|
height: '1',
|
|
order: 6,
|
|
showTitle: true,
|
|
className: ''
|
|
});
|
|
|
|
// NH4 profile chart
|
|
flow.push({
|
|
id: 'demo_chart_nh4_profile',
|
|
type: 'ui-chart',
|
|
z: 'demo_tab_dashboard',
|
|
group: 'demo_ui_grp_nh4_profile',
|
|
name: 'NH4 Profile',
|
|
label: 'NH4 Along Reactor (mg/L)',
|
|
order: 1,
|
|
width: '6',
|
|
height: '5',
|
|
chartType: 'line',
|
|
category: 'topic',
|
|
categoryType: 'msg',
|
|
xAxisType: 'time',
|
|
yAxisLabel: 'mg/L',
|
|
removeOlder: '10',
|
|
removeOlderUnit: '60',
|
|
action: 'append',
|
|
pointShape: 'false',
|
|
pointRadius: 0,
|
|
interpolation: 'linear',
|
|
x: 510,
|
|
y: 1060,
|
|
wires: [],
|
|
showLegend: true,
|
|
xAxisProperty: '',
|
|
xAxisPropertyType: 'timestamp',
|
|
yAxisProperty: 'payload',
|
|
yAxisPropertyType: 'msg',
|
|
colors: [
|
|
'#0094ce',
|
|
'#FF7F0E',
|
|
'#2CA02C',
|
|
'#D62728',
|
|
'#A347E1',
|
|
'#D62728',
|
|
'#FF9896',
|
|
'#9467BD',
|
|
'#C5B0D5'
|
|
],
|
|
textColor: ['#aaaaaa'],
|
|
textColorDefault: false,
|
|
gridColor: ['#333333'],
|
|
gridColorDefault: false,
|
|
className: ''
|
|
});
|
|
|
|
// Link out + link in for NH4 profile chart
|
|
flow.push({
|
|
id: 'demo_link_nh4_profile_dash',
|
|
type: 'link out',
|
|
z: 'demo_tab_treatment',
|
|
name: '→ NH4 Profile Dashboard',
|
|
mode: 'link',
|
|
links: ['demo_link_nh4_profile_dash_in'],
|
|
x: 620,
|
|
y: 340
|
|
});
|
|
|
|
flow.push({
|
|
id: 'demo_link_nh4_profile_dash_in',
|
|
type: 'link in',
|
|
z: 'demo_tab_dashboard',
|
|
name: '← NH4 Profile',
|
|
links: ['demo_link_nh4_profile_dash'],
|
|
x: 75,
|
|
y: 1060,
|
|
wires: [['demo_fn_nh4_profile_parse']]
|
|
});
|
|
|
|
// Parse function for NH4 profile chart
|
|
flow.push({
|
|
id: 'demo_fn_nh4_profile_parse',
|
|
type: 'function',
|
|
z: 'demo_tab_dashboard',
|
|
name: 'Parse NH4 Profile',
|
|
func: `const p = msg.payload || {};
|
|
const topic = msg.topic || '';
|
|
const now = Date.now();
|
|
const val = Number(p.mAbs);
|
|
if (!Number.isFinite(val)) return null;
|
|
|
|
let label = topic;
|
|
if (topic.includes('NH4-IN')) label = 'NH4-IN (0m)';
|
|
else if (topic.includes('NH4-A')) label = 'NH4-A (10m)';
|
|
else if (topic.includes('NH4-B')) label = 'NH4-B (25m)';
|
|
else if (topic.includes('NH4-001')) label = 'NH4-001 (35m)';
|
|
else if (topic.includes('NH4-C')) label = 'NH4-C (45m)';
|
|
|
|
return { topic: label, payload: Math.round(val * 100) / 100, timestamp: now };`,
|
|
outputs: 1,
|
|
x: 280,
|
|
y: 1060,
|
|
wires: [['demo_chart_nh4_profile']]
|
|
});
|
|
|
|
// Wire existing NH4-001 and new NH4 measurements to the profile link out
|
|
const existingNh4 = findNode('demo_meas_nh4');
|
|
if (existingNh4) {
|
|
if (!existingNh4.wires[0].includes('demo_link_nh4_profile_dash')) {
|
|
existingNh4.wires[0].push('demo_link_nh4_profile_dash');
|
|
}
|
|
}
|
|
for (const m of nh4Measurements) {
|
|
const node = findNode(m.id);
|
|
if (node && !node.wires[0].includes('demo_link_nh4_profile_dash')) {
|
|
node.wires[0].push('demo_link_nh4_profile_dash');
|
|
}
|
|
}
|
|
|
|
console.log('Phase A: Added 4 NH4 measurements + ui-group + chart + wiring');
|
|
|
|
// ============================================================
|
|
// PHASE B: Add influent composer + wire merge collector
|
|
// ============================================================
|
|
|
|
flow.push({
|
|
id: 'demo_fn_influent_compose',
|
|
type: 'function',
|
|
z: 'demo_tab_treatment',
|
|
name: 'Influent Composer',
|
|
func: `// Convert merge collector output to Fluent messages for reactor
|
|
// ASM3: [S_O, S_I, S_S, S_NH, S_N2, S_NO, S_HCO, X_I, X_S, X_H, X_STO, X_A, X_TS]
|
|
const p = msg.payload || {};
|
|
const MUNICIPAL = [0.5, 30, 200, 40, 0, 0, 5, 25, 150, 30, 0, 0, 200];
|
|
const INDUSTRIAL = [0.5, 40, 300, 25, 0, 0, 4, 30, 100, 20, 0, 0, 150];
|
|
const RESIDENTIAL = [0.5, 25, 180, 45, 0, 0, 5, 20, 130, 25, 0, 0, 175];
|
|
|
|
const Fw = (p.west?.netFlow || 0) * 24; // m3/h -> m3/d
|
|
const Fn = (p.north?.netFlow || 0) * 24;
|
|
const Fs = (p.south?.netFlow || 0) * 24;
|
|
|
|
const msgs = [];
|
|
if (Fw > 0) msgs.push({ topic: 'Fluent', payload: { inlet: 0, F: Fw, C: MUNICIPAL }});
|
|
if (Fn > 0) msgs.push({ topic: 'Fluent', payload: { inlet: 1, F: Fn, C: INDUSTRIAL }});
|
|
if (Fs > 0) msgs.push({ topic: 'Fluent', payload: { inlet: 2, F: Fs, C: RESIDENTIAL }});
|
|
return [msgs];`,
|
|
outputs: 1,
|
|
x: 480,
|
|
y: 1040,
|
|
wires: [['demo_reactor']]
|
|
});
|
|
|
|
// Wire merge collector → influent composer (add to existing wires)
|
|
const mergeCollect = findNode('demo_fn_merge_collect');
|
|
if (mergeCollect) {
|
|
if (!mergeCollect.wires[0].includes('demo_fn_influent_compose')) {
|
|
mergeCollect.wires[0].push('demo_fn_influent_compose');
|
|
}
|
|
console.log('Phase B: Wired merge collector → influent composer → reactor');
|
|
} else {
|
|
console.error('Phase B: ERROR — demo_fn_merge_collect not found!');
|
|
}
|
|
|
|
// ============================================================
|
|
// PHASE C: Fix biomass initialization
|
|
// ============================================================
|
|
|
|
const reactor = findNode('demo_reactor');
|
|
if (reactor) {
|
|
reactor.X_A_init = 300;
|
|
reactor.X_H_init = 1500;
|
|
reactor.X_TS_init = 2500;
|
|
reactor.S_HCO_init = 8;
|
|
console.log('Phase C: Updated reactor biomass init values');
|
|
} else {
|
|
console.error('Phase C: ERROR — demo_reactor not found!');
|
|
}
|
|
|
|
// ============================================================
|
|
// PHASE D: Return Activated Sludge
|
|
// ============================================================
|
|
|
|
// D1: RAS pump
|
|
flow.push({
|
|
id: 'demo_pump_ras',
|
|
type: 'rotatingMachine',
|
|
z: 'demo_tab_treatment',
|
|
name: 'RAS Pump',
|
|
speed: '1',
|
|
startup: '5',
|
|
warmup: '3',
|
|
shutdown: '4',
|
|
cooldown: '2',
|
|
movementMode: 'dynspeed',
|
|
machineCurve: '',
|
|
uuid: 'pump-ras-001',
|
|
supplier: 'hidrostal',
|
|
category: 'machine',
|
|
assetType: 'pump-centrifugal',
|
|
model: 'hidrostal-RAS',
|
|
unit: 'm3/h',
|
|
enableLog: true,
|
|
logLevel: 'info',
|
|
positionVsParent: 'downstream',
|
|
positionIcon: '←',
|
|
hasDistance: false,
|
|
distance: 0,
|
|
distanceUnit: 'm',
|
|
distanceDescription: '',
|
|
x: 1000,
|
|
y: 380,
|
|
wires: [
|
|
['demo_link_process_out_treatment'],
|
|
['demo_link_influx_out_treatment'],
|
|
['demo_settler']
|
|
],
|
|
curveFlowUnit: 'l/s',
|
|
curvePressureUnit: 'mbar',
|
|
curvePowerUnit: 'kW'
|
|
});
|
|
|
|
// D2: RAS flow sensor
|
|
flow.push({
|
|
id: 'demo_meas_ft_ras',
|
|
type: 'measurement',
|
|
z: 'demo_tab_treatment',
|
|
name: 'FT-RAS (RAS Flow)',
|
|
scaling: true,
|
|
i_min: 20,
|
|
i_max: 80,
|
|
i_offset: 0,
|
|
o_min: 20,
|
|
o_max: 80,
|
|
smooth_method: 'mean',
|
|
count: 3,
|
|
simulator: true,
|
|
uuid: 'ft-ras-001',
|
|
supplier: 'Endress+Hauser',
|
|
category: 'sensor',
|
|
assetType: 'flow',
|
|
model: 'Promag-W400',
|
|
unit: 'm3/h',
|
|
assetTagNumber: 'FT-RAS',
|
|
enableLog: false,
|
|
logLevel: 'error',
|
|
positionVsParent: 'atEquipment',
|
|
positionIcon: '⊥',
|
|
hasDistance: false,
|
|
distance: 0,
|
|
distanceUnit: 'm',
|
|
distanceDescription: '',
|
|
x: 1200,
|
|
y: 380,
|
|
wires: [
|
|
['demo_link_process_out_treatment'],
|
|
['demo_link_influx_out_treatment'],
|
|
['demo_pump_ras']
|
|
]
|
|
});
|
|
|
|
// D3: Inject to set pump mode
|
|
flow.push({
|
|
id: 'demo_inj_ras_mode',
|
|
type: 'inject',
|
|
z: 'demo_tab_treatment',
|
|
name: 'RAS → virtualControl',
|
|
props: [
|
|
{ p: 'topic', vt: 'str' },
|
|
{ p: 'payload', vt: 'str' }
|
|
],
|
|
topic: 'setMode',
|
|
payload: 'virtualControl',
|
|
payloadType: 'str',
|
|
once: true,
|
|
onceDelay: '3',
|
|
x: 1000,
|
|
y: 440,
|
|
wires: [['demo_pump_ras']],
|
|
repeatType: 'none',
|
|
crontab: '',
|
|
repeat: ''
|
|
});
|
|
|
|
// D3: Inject to set pump speed
|
|
flow.push({
|
|
id: 'demo_inj_ras_speed',
|
|
type: 'inject',
|
|
z: 'demo_tab_treatment',
|
|
name: 'RAS speed → 50%',
|
|
props: [
|
|
{ p: 'topic', vt: 'str' },
|
|
{ p: 'payload', vt: 'json' }
|
|
],
|
|
topic: 'execMovement',
|
|
payload: '{"source":"auto","action":"setpoint","setpoint":50}',
|
|
payloadType: 'json',
|
|
once: true,
|
|
onceDelay: '4',
|
|
x: 1000,
|
|
y: 480,
|
|
wires: [['demo_pump_ras']],
|
|
repeatType: 'none',
|
|
crontab: '',
|
|
repeat: ''
|
|
});
|
|
|
|
// D4: RAS filter function
|
|
flow.push({
|
|
id: 'demo_fn_ras_filter',
|
|
type: 'function',
|
|
z: 'demo_tab_treatment',
|
|
name: 'RAS Filter',
|
|
func: `// Only pass RAS (inlet 2) from settler to reactor as inlet 3
|
|
if (msg.topic === 'Fluent' && msg.payload && msg.payload.inlet === 2) {
|
|
msg.payload.inlet = 3; // reactor inlet 3 = RAS
|
|
return msg;
|
|
}
|
|
return null;`,
|
|
outputs: 1,
|
|
x: 1000,
|
|
y: 320,
|
|
wires: [['demo_reactor']]
|
|
});
|
|
|
|
// D5: Wire settler Port 0 → RAS filter
|
|
const settler = findNode('demo_settler');
|
|
if (settler) {
|
|
if (!settler.wires[0].includes('demo_fn_ras_filter')) {
|
|
settler.wires[0].push('demo_fn_ras_filter');
|
|
}
|
|
console.log('Phase D: Wired settler → RAS filter → reactor');
|
|
} else {
|
|
console.error('Phase D: ERROR — demo_settler not found!');
|
|
}
|
|
|
|
// D5: Update reactor n_inlets: 3 → 4
|
|
if (reactor) {
|
|
reactor.n_inlets = 4;
|
|
console.log('Phase D: Updated reactor n_inlets to 4');
|
|
}
|
|
|
|
console.log('Phase D: Added RAS pump, flow sensor, 2 injects, filter function');
|
|
|
|
// ============================================================
|
|
// WRITE OUTPUT
|
|
// ============================================================
|
|
|
|
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n', 'utf8');
|
|
console.log(`\nDone. Wrote ${flow.length} nodes to ${flowPath}`);
|