wrps-demo-kit/03-plc/src/21_fb_duty_selector.st
Clio Liu 13a05d0135 feat(plc): ST sources, generators and the Modbus contract
The PLC program from the old repo's 04-plc/, flattened into one folder and
checked against the running system.

Verified during the move:
  - build.py regenerates register-map.csv byte-identically (69 points)
  - polled the live PLC: the SIMULATION build is what is deployed and
    running, %MW21=2 wet weather, values moving, run hours accumulating
  - addresses, %MW HR1024 segmentation and %QW17/%QW7 signedness all
    match the map

Corrections against the old repo:
  - 10_globals.st header cited WRPS-CTL-002 (the FDS); it means CTL-003
  - build.py wrote the map to its parent directory; now beside itself
  - deploy/README.md was a single-file folder; now DEPLOY.md
  - dropped the empty editor-devices/remote/
  - README no longer claims the simulation build is uncompiled - it is
    the one running

Two open items are now stated plainly rather than buried:
  - none of the 20 acceptance tests in CTL-003 have ever been run
  - the OpenPLC Editor lived only on the retired dev-ubuntu host, so
    there is currently NO route to deploy a new program (DEPLOY.md 0)

Documents the setpoint distinction: IO_MUX seeds %MW defaults once at
first scan, operators retune them live, and that tuning exists only in
the container volume - a restart reverts it.
2026-09-02 15:48:49 +10:00

128 lines
3.5 KiB
Smalltalk

(* =====================================================================
21_fb_duty_selector.st - FB_DUTY_SELECT, section 4.2
Decides WHICH units run, never HOW MANY.
Ranking, best first:
1. unavailable units excluded entirely
2. not-due-for-service ranks above due-for-service
3. within a group, lower run hours first
4. ties break by ascending pump number (deterministic for demos)
The two rules that outrank the ranking:
- never stop a running unit to start a better-ranked one
- service-due is a preference, never a veto
===================================================================== *)
FUNCTION_BLOCK FB_DUTY_SELECT
VAR_INPUT
Available : ARRAY[1..3] OF BOOL;
RunHours : ARRAY[1..3] OF REAL;
ServiceDue : ARRAY[1..3] OF BOOL;
RunningNow : ARRAY[1..3] OF BOOL;
PumpsRequired : INT;
END_VAR
VAR_OUTPUT
RunRequest : ARRAY[1..3] OF BOOL;
DutyPump : INT; (* 0 = none, 1-3 *)
END_VAR
VAR CONSTANT
(* Service-due penalty. Larger than any credible run-hours value,
so a due unit always ranks below every not-due unit while still
remaining in the list - rule 2 is a preference, not a veto. *)
SERVICE_PENALTY : REAL := 1000000.0;
END_VAR
VAR
Rank : ARRAY[1..3] OF INT; (* pump numbers, best first *)
Used : ARRAY[1..3] OF BOOL;
Sel : ARRAY[1..3] OF BOOL;
i : INT;
k : INT;
best : INT;
bestKey : REAL;
key : REAL;
nRanked : INT;
slots : INT;
cnt : INT;
END_VAR
(* --- 1. Reset working state --------------------------------------- *)
FOR i := 1 TO 3 DO
Used[i] := FALSE;
Sel[i] := FALSE;
Rank[i] := 0;
END_FOR;
nRanked := 0;
(* --- 2. Build the ranked list by repeated selection.
Scanning i ascending with a strict "<" test means an equal key
never displaces an earlier pump, which is rule 4. ------------ *)
FOR k := 1 TO 3 DO
best := 0;
bestKey := 0.0;
FOR i := 1 TO 3 DO
IF Available[i] AND NOT Used[i] THEN
key := RunHours[i];
IF ServiceDue[i] THEN
key := key + SERVICE_PENALTY;
END_IF;
IF (best = 0) OR (key < bestKey) THEN
best := i;
bestKey := key;
END_IF;
END_IF;
END_FOR;
IF best > 0 THEN
nRanked := nRanked + 1;
Rank[nRanked] := best;
Used[best] := TRUE;
END_IF;
END_FOR;
(* --- 3. Clamp the demand ------------------------------------------ *)
slots := PumpsRequired;
IF slots < 0 THEN
slots := 0;
END_IF;
IF slots > 3 THEN
slots := 3;
END_IF;
(* --- 4. Pass A: units already running keep their slots.
Walking in rank order means that if the demand has dropped it
is the worst-ranked running unit that loses its slot. -------- *)
cnt := 0;
FOR k := 1 TO nRanked DO
i := Rank[k];
IF RunningNow[i] AND (cnt < slots) THEN
Sel[i] := TRUE;
cnt := cnt + 1;
END_IF;
END_FOR;
(* --- 5. Pass B: fill the slots that remain, by rank --------------- *)
FOR k := 1 TO nRanked DO
i := Rank[k];
IF (NOT Sel[i]) AND (cnt < slots) THEN
Sel[i] := TRUE;
cnt := cnt + 1;
END_IF;
END_FOR;
(* --- 6. Publish. DutyPump is the best-ranked selected unit. ------- *)
DutyPump := 0;
FOR k := 1 TO nRanked DO
i := Rank[k];
IF Sel[i] AND (DutyPump = 0) THEN
DutyPump := i;
END_IF;
END_FOR;
FOR i := 1 TO 3 DO
RunRequest[i] := Sel[i];
END_FOR;
END_FUNCTION_BLOCK