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;
  END_VAR
  VAR
    SERVICE_PENALTY : REAL := 1000000.0;
    Rank : ARRAY [1..3] OF INT;
    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

FUNCTION_BLOCK FB_HEADROOM
  VAR_INPUT
    Level : REAL;
    Inflow : REAL;
    TotalDischarge : REAL;
  END_VAR
  VAR_OUTPUT
    InflowFilt : REAL;
    NetInflow : REAL;
    VolToSpill : REAL;
    VolToLSHH : REAL;
    TimeToSpill : INT;
    TimeToLSHH : INT;
  END_VAR
  VAR
    SCAN_S : REAL := 0.1;
    TAU_S : REAL := 30.0;
    AREA_M2 : REAL := 120.0;
    SPILL_M : REAL := 6.000;
    LSHH_M : REAL := 5.500;
    MIN_NET : REAL := 0.5;
    NO_TIME : INT := 32767;
    MAX_TIME : REAL := 32767.0;
    Primed : BOOL := FALSE;
    t : REAL;
  END_VAR

  (* --- Inflow filter.  First-order lag, 30 s.

     Primed on the first scan rather than ramping from zero: without
     this the reference figure in test 14 would take ~2 minutes to
     settle and would not reproduce exactly on demand. ---------------- *)
  IF NOT Primed THEN
      InflowFilt := Inflow;
      Primed     := TRUE;
  ELSE
      InflowFilt := InflowFilt + (Inflow - InflowFilt) * SCAN_S / TAU_S;
  END_IF;

  NetInflow  := InflowFilt - TotalDischarge;

  VolToSpill := (SPILL_M - Level) * AREA_M2;
  VolToLSHH  := (LSHH_M  - Level) * AREA_M2;
  IF VolToSpill < 0.0 THEN
      VolToSpill := 0.0;
  END_IF;
  IF VolToLSHH < 0.0 THEN
      VolToLSHH := 0.0;
  END_IF;

  (* --- Time to spill weir ------------------------------------------- *)
  IF NetInflow <= MIN_NET THEN
      TimeToSpill := NO_TIME;
  ELSE
      t := VolToSpill * 1000.0 / NetInflow;
      IF t >= MAX_TIME THEN
          TimeToSpill := NO_TIME;
      ELSIF t < 0.0 THEN
          TimeToSpill := 0;
      ELSE
          TimeToSpill := REAL_TO_INT(t);
      END_IF;
  END_IF;

  (* --- Time to LSHH -------------------------------------------------- *)
  IF NetInflow <= MIN_NET THEN
      TimeToLSHH := NO_TIME;
  ELSE
      t := VolToLSHH * 1000.0 / NetInflow;
      IF t >= MAX_TIME THEN
          TimeToLSHH := NO_TIME;
      ELSIF t < 0.0 THEN
          TimeToLSHH := 0;
      ELSE
          TimeToLSHH := REAL_TO_INT(t);
      END_IF;
  END_IF;
END_FUNCTION_BLOCK

FUNCTION_BLOCK FB_LEVEL_CTRL
  VAR_INPUT
    Level : REAL;
    Setpoint : REAL;
    Enable : BOOL;
    MinSpeed : REAL;
    MaxSpeed : REAL;
  END_VAR
  VAR_OUTPUT
    Speed : REAL;
  END_VAR
  VAR
    SCAN_S : REAL := 0.1;
    KP : REAL := 12.0;
    TI : REAL := 120.0;
    Integ : REAL := 38.0;
    Err : REAL;
    Raw : REAL;
    Integrate : BOOL;
  END_VAR

  IF NOT Enable THEN
      (* Hold at minimum and reset the integrator, so that a restart does
         not inherit stale integral action. *)
      Integ := MinSpeed;
      Speed := MinSpeed;
  ELSE
      Err := Level - Setpoint;
      Raw := KP * Err + Integ;

      (* Anti-windup: freeze the integrator whenever the output is
         clamped, except when the error would drive it back into range. *)
      Integrate := FALSE;
      IF (Raw > MinSpeed) AND (Raw < MaxSpeed) THEN
          Integrate := TRUE;
      ELSIF (Raw >= MaxSpeed) AND (Err < 0.0) THEN
          Integrate := TRUE;
      ELSIF (Raw <= MinSpeed) AND (Err > 0.0) THEN
          Integrate := TRUE;
      END_IF;

      IF Integrate THEN
          Integ := Integ + (KP / TI) * Err * SCAN_S;
      END_IF;

      Raw := KP * Err + Integ;

      IF Raw > MaxSpeed THEN
          Speed := MaxSpeed;
      ELSIF Raw < MinSpeed THEN
          Speed := MinSpeed;
      ELSE
          Speed := Raw;
      END_IF;
  END_IF;
END_FUNCTION_BLOCK

FUNCTION_BLOCK FB_PUMP
  VAR_INPUT
    RunRequest : BOOL;
    SpeedRef : REAL;
    ThermalOK : BOOL;
    SealLeak : BOOL;
    Vibration : REAL;
    DischPressure : REAL;
    ResetTrip : BOOL;
    Lockout : BOOL;
    MinOffBypass : BOOL;
    ServiceInterval : REAL;
    ResetHours : BOOL;
  END_VAR
  VAR_OUTPUT
    RunCmd : BOOL;
    Running : BOOL;
    Available : BOOL;
    Tripped : BOOL;
    State : INT;
    RunHours : REAL;
    ServiceDue : BOOL;
    VibAlarm : BOOL;
    SealAlarm : BOOL;
  END_VAR
  VAR
    SCAN_S : REAL := 0.1;
    VIB_ALARM : REAL := 7.1;
    VIB_TRIP : REAL := 11.0;
    NOFLOW_KPA : REAL := 150.0;
    MinRunTmr : TON;
    MinOffTmr : TON;
    NoFlowTmr : TON;
    HasRun : BOOL;
    StartOK : BOOL;
  END_VAR

  (* --- 1. Trip reset.  Runs first so that a reset issued while the
         initiating condition is still present re-trips immediately
         rather than latching clear. --------------------------------- *)
  IF ResetTrip THEN
      Tripped := FALSE;
  END_IF;

  (* --- 2. Trip conditions.  All latch; they clear only on reset. ---- *)
  IF NOT ThermalOK THEN
      Tripped := TRUE;                            (* TE-31x, section 5   *)
  END_IF;

  IF Vibration > VIB_TRIP THEN
      Tripped := TRUE;                            (* VE-31x > 11.0 mm/s  *)
  END_IF;

  (* No-flow: 20 s after RunCmd goes true, low discharge pressure trips
     the unit.  Monitored continuously once the window has elapsed, not
     sampled once, so a loss of flow while running is also caught. *)
  NoFlowTmr(IN := RunCmd, PT := T#20s);
  IF NoFlowTmr.Q AND (DischPressure < NOFLOW_KPA) THEN
      Tripped := TRUE;
  END_IF;

  (* --- 3. Alarms that do not affect availability, section 4.1 ------- *)
  VibAlarm  := Vibration > VIB_ALARM;
  SealAlarm := SealLeak;

  (* --- 4. Availability.  A seal leak is deliberately absent here:
         it raises an alarm only, per WRPS-PRO-001 5.5. --------------- *)
  Available := ThermalOK AND NOT Tripped AND NOT Lockout;

  (* --- 5. Minimum run / minimum off timers.
         MinOff is gated on HasRun so that a cold start is not blocked
         for 5 minutes after a runtime restart. ---------------------- *)
  MinRunTmr(IN := RunCmd, PT := T#5m);
  MinOffTmr(IN := (NOT RunCmd) AND HasRun, PT := T#5m);

  StartOK := (NOT HasRun) OR MinOffTmr.Q OR MinOffBypass;

  (* --- 6. Run command ---------------------------------------------- *)
  IF Tripped OR Lockout OR NOT ThermalOK THEN
      RunCmd := FALSE;
  ELSIF RunCmd THEN
      (* running: honour minimum run before accepting a stop *)
      IF (NOT RunRequest) AND MinRunTmr.Q THEN
          RunCmd := FALSE;
      END_IF;
  ELSE
      IF RunRequest AND StartOK THEN
          RunCmd := TRUE;
          HasRun := TRUE;
      END_IF;
  END_IF;

  Running := RunCmd;

  (* --- 7. Run hours.  Scan-time increments while running only. ------ *)
  IF ResetHours THEN
      RunHours := 0.0;
  END_IF;
  IF Running THEN
      RunHours := RunHours + SCAN_S / 3600.0;
  END_IF;
  ServiceDue := RunHours >= ServiceInterval;

  (* --- 8. Published state, section 3.2 ------------------------------ *)
  IF Tripped THEN
      State := 6;                                 (* Tripped             *)
  ELSIF Lockout THEN
      State := 7;                                 (* Maintenance lockout *)
  ELSIF NOT ThermalOK THEN
      State := 0;                                 (* Unavailable         *)
  ELSIF Running AND NOT RunRequest THEN
      State := 4;                                 (* Min-run inhibit     *)
  ELSIF RunCmd AND NOT NoFlowTmr.Q THEN
      State := 2;                                 (* Start delay         *)
  ELSIF Running THEN
      State := 3;                                 (* Running             *)
  ELSIF HasRun AND NOT MinOffTmr.Q THEN
      State := 5;                                 (* Min-off inhibit     *)
  ELSE
      State := 1;                                 (* Available, stopped  *)
  END_IF;
END_FUNCTION_BLOCK

PROGRAM CONTROL
  VAR_EXTERNAL
    g_LevelRaw_mm : INT;
    g_Level_mm : INT;
    g_Level_m : REAL;
    g_Inflow_Lps : REAL;
    g_Disch_Lps : REAL;
    g_PumpP_kPa : ARRAY [1..3] OF REAL;
    g_Vib_mms : ARRAY [1..3] OF REAL;
    g_LSHH : BOOL;
    g_LSLL_Wet : BOOL;
    g_SpillDetected : BOOL;
    g_ThermalOK : ARRAY [1..3] OF BOOL;
    g_SealLeak : ARRAY [1..3] OF BOOL;
    g_MainsOK : BOOL;
    g_cmd_Mode : INT;
    g_cmd_Word : INT;
    g_cmd_Param : INT;
    g_sp_Level : INT;
    g_sp_StartDuty : INT;
    g_sp_StartP2 : INT;
    g_sp_StartP3 : INT;
    g_sp_StopAll : INT;
    g_sp_HighAlarm : INT;
    g_sp_MinSpeed : INT;
    g_sp_ServiceHrs : INT;
    g_o_RunCmd : ARRAY [1..3] OF BOOL;
    g_o_Running : ARRAY [1..3] OF BOOL;
    g_o_Available : ARRAY [1..3] OF BOOL;
    g_o_Tripped : ARRAY [1..3] OF BOOL;
    g_o_InAuto : BOOL;
    g_o_HighLevel : BOOL;
    g_o_SpillActive : BOOL;
    g_o_Level_mm : INT;
    g_o_Inflow_x10 : INT;
    g_o_Disch_x10 : INT;
    g_o_PumpsRun : INT;
    g_o_Speed_x10 : INT;
    g_o_TimeToSpill : INT;
    g_o_TimeToLSHH : INT;
    g_o_NetAccum : INT;
    g_o_RunHours : ARRAY [1..3] OF INT;
    g_o_VolToSpill : INT;
    g_o_StationState : INT;
    g_o_PumpState : ARRAY [1..3] OF INT;
    g_o_DutyPump : INT;
    g_o_AlarmWord : INT;
    g_o_CmdAck : INT;
  END_VAR
  VAR
    SPILL_MM : INT := 6000;
    LEVEL_MAX_MM : INT := 7000;
    HARD_MIN_HZ : REAL := 38.0;
    HARD_MAX_HZ : REAL := 50.0;
    Pump1 : FB_PUMP;
    Pump2 : FB_PUMP;
    Pump3 : FB_PUMP;
    Duty : FB_DUTY_SELECT;
    LvlCtl : FB_LEVEL_CTRL;
    Head : FB_HEADROOM;
    v_Mode : INT := 1;
    v_SpLevel : INT := 4200;
    v_StartDuty : INT := 4000;
    v_StartP2 : INT := 4500;
    v_StartP3 : INT := 5000;
    v_StopAll : INT := 1000;
    v_HighAlarm : INT := 5200;
    v_MinSpeed : INT := 380;
    v_ServiceHrs : INT := 4000;
    SpRejected : BOOL;
    SpOK : BOOL;
    CmdBusy : BOOL;
    ResetTrip : ARRAY [1..3] OF BOOL;
    ResetHours : ARRAY [1..3] OF BOOL;
    Lockout : ARRAY [1..3] OF BOOL;
    AckAlarms : BOOL;
    p : INT;
    PumpsRequired : INT;
    PumpsAllowed : INT;
    StaggerTmr : TON;
    StaggerArm : BOOL;
    DryRun : BOOL;
    DryLockout : BOOL;
    LevelRangeFault : BOOL;
    LevelFrozen : BOOL;
    LevelFault : BOOL;
    LevelRef : INT;
    LevelMoved : BOOL;
    FrozenTmr : TON;
    AnyRunning : BOOL;
    Avail : ARRAY [1..3] OF BOOL;
    Hours : ARRAY [1..3] OF REAL;
    SvcDue : ARRAY [1..3] OF BOOL;
    RunNow : ARRAY [1..3] OF BOOL;
    Req : ARRAY [1..3] OF BOOL;
    Speed : REAL;
    MinSpeedHz : REAL;
    SpLevel_m : REAL;
    HighLevel : BOOL;
    PumpsRun : INT;
    Alarm : DINT;
    i : INT;
    r : REAL;
    Primed : BOOL := FALSE;
  END_VAR

  (* =====================================================================
     Step 1 - read and clamp setpoints, section 2.3

     Every setpoint is validated as a set, not individually: the start
     levels only make sense in order.  A rejected write holds the last
     good value and raises bit 15 rather than acting on it.
     ===================================================================== *)

  (* Mode *)
  IF (g_cmd_Mode = 1) OR (g_cmd_Mode = 2) THEN
      v_Mode := g_cmd_Mode;
  ELSE
      SpRejected := TRUE;
  END_IF;

  (* Level setpoints.  A start level at or above the spill weir must
     never be accepted, section 2.3. *)
  SpOK := TRUE;
  IF (g_sp_StopAll < 0) OR (g_sp_StopAll >= g_sp_StartDuty) THEN
      SpOK := FALSE;
  END_IF;
  IF (g_sp_StartDuty >= g_sp_StartP2) OR (g_sp_StartDuty >= SPILL_MM) THEN
      SpOK := FALSE;
  END_IF;
  IF (g_sp_StartP2 >= g_sp_StartP3) OR (g_sp_StartP2 >= SPILL_MM) THEN
      SpOK := FALSE;
  END_IF;
  IF (g_sp_StartP3 >= SPILL_MM) THEN
      SpOK := FALSE;
  END_IF;
  IF (g_sp_Level <= g_sp_StopAll) OR (g_sp_Level >= SPILL_MM) THEN
      SpOK := FALSE;
  END_IF;
  IF (g_sp_HighAlarm <= 0) OR (g_sp_HighAlarm > SPILL_MM) THEN
      SpOK := FALSE;
  END_IF;

  IF SpOK THEN
      v_SpLevel    := g_sp_Level;
      v_StartDuty  := g_sp_StartDuty;
      v_StartP2    := g_sp_StartP2;
      v_StartP3    := g_sp_StartP3;
      v_StopAll    := g_sp_StopAll;
      v_HighAlarm  := g_sp_HighAlarm;
  ELSE
      SpRejected := TRUE;
  END_IF;

  (* Minimum drive speed, Hz x 10, bounded by the hard physical limits *)
  IF (g_sp_MinSpeed >= 380) AND (g_sp_MinSpeed <= 500) THEN
      v_MinSpeed := g_sp_MinSpeed;
  ELSE
      SpRejected := TRUE;
  END_IF;

  (* Service interval *)
  IF g_sp_ServiceHrs > 0 THEN
      v_ServiceHrs := g_sp_ServiceHrs;
  ELSE
      SpRejected := TRUE;
  END_IF;

  MinSpeedHz := INT_TO_REAL(v_MinSpeed) / 10.0;
  IF MinSpeedHz < HARD_MIN_HZ THEN
      MinSpeedHz := HARD_MIN_HZ;
  END_IF;
  SpLevel_m := INT_TO_REAL(v_SpLevel) / 1000.0;


  (* =====================================================================
     Step 2 - command word and acknowledge, section 3.3

     Executes on the rising edge of a non-zero %MW1, echoes the value to
     %QW20, then takes no further action until %MW1 returns to 0.
     ===================================================================== *)

  (* one-shot pulses, consumed by the FB_PUMP calls later this scan *)
  FOR i := 1 TO 3 DO
      ResetTrip[i]  := FALSE;
      ResetHours[i] := FALSE;
  END_FOR;
  AckAlarms := FALSE;

  IF (g_cmd_Word <> 0) AND NOT CmdBusy THEN
      CmdBusy := TRUE;
      p       := g_cmd_Param;

      CASE g_cmd_Word OF
          1:  (* reset all trips *)
              FOR i := 1 TO 3 DO
                  ResetTrip[i] := TRUE;
              END_FOR;
              (* the dry run lockout is manual-reset and only clears once
                 the level has actually recovered, section 5 *)
              IF g_Level_mm > v_StopAll THEN
                  DryLockout := FALSE;
              END_IF;

          2:  (* reset trip on pump in %MW2 *)
              IF (p >= 1) AND (p <= 3) THEN
                  ResetTrip[p] := TRUE;
              END_IF;

          3:  (* lock out pump in %MW2 *)
              IF (p >= 1) AND (p <= 3) THEN
                  Lockout[p] := TRUE;
              END_IF;

          4:  (* release lockout on pump in %MW2 *)
              IF (p >= 1) AND (p <= 3) THEN
                  Lockout[p] := FALSE;
              END_IF;

          5:  (* reset run hours on pump in %MW2 - service done *)
              IF (p >= 1) AND (p <= 3) THEN
                  ResetHours[p] := TRUE;
              END_IF;

          6:  (* acknowledge alarms *)
              AckAlarms  := TRUE;
              SpRejected := FALSE;
      END_CASE;

      g_o_CmdAck := g_cmd_Word;

  ELSIF g_cmd_Word = 0 THEN
      CmdBusy    := FALSE;
      g_o_CmdAck := 0;
  END_IF;


  (* =====================================================================
     Level signal integrity, section 5

     A frozen transmitter reading a plausible value is the failure that
     actually causes spills, and a range check alone cannot see it.
     ===================================================================== *)

  LevelRangeFault := (g_LevelRaw_mm < 0) OR (g_LevelRaw_mm > LEVEL_MAX_MM);

  IF NOT Primed THEN
      LevelRef := g_LevelRaw_mm;
      Primed   := TRUE;
  END_IF;

  IF ABS(g_LevelRaw_mm - LevelRef) > 1 THEN
      LevelRef   := g_LevelRaw_mm;
      LevelMoved := TRUE;
  ELSE
      LevelMoved := FALSE;
  END_IF;

  AnyRunning := Pump1.Running OR Pump2.Running OR Pump3.Running;
  FrozenTmr(IN := AnyRunning AND NOT LevelMoved, PT := T#10m);
  LevelFrozen := FrozenTmr.Q;

  LevelFault := LevelRangeFault OR LevelFrozen;


  (* =====================================================================
     Step 3 - determine PumpsRequired from level

     The band between StopAll and StartDuty holds the previous value.
     That hysteresis is the whole point; it is never recomputed from
     scratch.
     ===================================================================== *)

  IF NOT LevelFault THEN
      IF g_Level_mm >= v_StartP3 THEN
          PumpsRequired := 3;
      ELSIF g_Level_mm >= v_StartP2 THEN
          PumpsRequired := 2;
      ELSIF g_Level_mm >= v_StartDuty THEN
          PumpsRequired := 1;
      ELSIF g_Level_mm <= v_StopAll THEN
          PumpsRequired := 0;
      END_IF;
      (* otherwise: hold *)
  ELSE
      (* Fall back to discrete level control, section 5.  LSHH and LSLL
         are independent instruments and remain trustworthy. *)
      IF g_LSHH THEN
          PumpsRequired := 3;
      END_IF;
      (* otherwise: hold, and let the LSLL override below stop the
         station if the well is actually dry *)
  END_IF;


  (* =====================================================================
     Step 4 - LSHH override.  Start all available, bypass min-off.
     ===================================================================== *)
  IF g_LSHH THEN
      PumpsRequired := 3;
  END_IF;


  (* =====================================================================
     Step 5 - LSLL override.  Fail-safe: the instrument reads TRUE when
     wet, so a broken wire reads dry and stops the station.
     ===================================================================== *)
  DryRun := NOT g_LSLL_Wet;
  IF DryRun THEN
      PumpsRequired := 0;
      DryLockout    := TRUE;      (* latched, manual reset via command 1 *)
  END_IF;
  IF DryLockout THEN
      PumpsRequired := 0;
  END_IF;


  (* =====================================================================
     Step 6 - station mode off
     ===================================================================== *)
  IF v_Mode = 2 THEN
      PumpsRequired := 0;
  END_IF;


  (* =====================================================================
     Step 11 (applied here, before selection) - stagger starts

     Held second and third starts by 30 s each, to limit inrush and the
     hydraulic transient.  Applied before FB_DUTY_SELECT because it
     limits how many units may start, which is an input to selection,
     not a correction applied afterwards.  Stops are never staggered.

     LSHH bypasses the stagger as well as the min-off timers, so that
     the emergency response is immediate.
     ===================================================================== *)
  IF g_LSHH THEN
      PumpsAllowed := PumpsRequired;
      StaggerArm   := FALSE;
  ELSE
      StaggerTmr(IN := StaggerArm, PT := T#30s);
      IF PumpsAllowed < PumpsRequired THEN
          IF PumpsAllowed = 0 THEN
              PumpsAllowed := 1;              (* first unit starts at once *)
              StaggerArm   := FALSE;
          ELSIF StaggerTmr.Q THEN
              PumpsAllowed := PumpsAllowed + 1;
              StaggerArm   := FALSE;
          ELSE
              StaggerArm := TRUE;
          END_IF;
      ELSE
          IF PumpsAllowed > PumpsRequired THEN
              PumpsAllowed := PumpsRequired;
          END_IF;
          StaggerArm := FALSE;
      END_IF;
  END_IF;


  (* =====================================================================
     Step 7 - duty selection
     ===================================================================== *)
  Avail[1]  := Pump1.Available;   Avail[2]  := Pump2.Available;   Avail[3]  := Pump3.Available;
  Hours[1]  := Pump1.RunHours;    Hours[2]  := Pump2.RunHours;    Hours[3]  := Pump3.RunHours;
  SvcDue[1] := Pump1.ServiceDue;  SvcDue[2] := Pump2.ServiceDue;  SvcDue[3] := Pump3.ServiceDue;
  RunNow[1] := Pump1.Running;     RunNow[2] := Pump2.Running;     RunNow[3] := Pump3.Running;

  Duty(Available     := Avail,
       RunHours      := Hours,
       ServiceDue    := SvcDue,
       RunningNow    := RunNow,
       PumpsRequired := PumpsAllowed);

  Req[1] := Duty.RunRequest[1];
  Req[2] := Duty.RunRequest[2];
  Req[3] := Duty.RunRequest[3];


  (* =====================================================================
     Step 8 - level control.  On LSHH force 50.0 Hz.
     ===================================================================== *)
  LvlCtl(Level    := g_Level_m,
         Setpoint := SpLevel_m,
         Enable   := (PumpsAllowed > 0),
         MinSpeed := MinSpeedHz,
         MaxSpeed := HARD_MAX_HZ);

  Speed := LvlCtl.Speed;
  IF g_LSHH THEN
      Speed := HARD_MAX_HZ;
  END_IF;


  (* =====================================================================
     Step 9 - the pumps
     ===================================================================== *)
  Pump1(RunRequest      := Req[1],
        SpeedRef        := Speed,
        ThermalOK       := g_ThermalOK[1],
        SealLeak        := g_SealLeak[1],
        Vibration       := g_Vib_mms[1],
        DischPressure   := g_PumpP_kPa[1],
        ResetTrip       := ResetTrip[1],
        Lockout         := Lockout[1],
        MinOffBypass    := g_LSHH,
        ServiceInterval := INT_TO_REAL(v_ServiceHrs),
        ResetHours      := ResetHours[1]);

  Pump2(RunRequest      := Req[2],
        SpeedRef        := Speed,
        ThermalOK       := g_ThermalOK[2],
        SealLeak        := g_SealLeak[2],
        Vibration       := g_Vib_mms[2],
        DischPressure   := g_PumpP_kPa[2],
        ResetTrip       := ResetTrip[2],
        Lockout         := Lockout[2],
        MinOffBypass    := g_LSHH,
        ServiceInterval := INT_TO_REAL(v_ServiceHrs),
        ResetHours      := ResetHours[2]);

  Pump3(RunRequest      := Req[3],
        SpeedRef        := Speed,
        ThermalOK       := g_ThermalOK[3],
        SealLeak        := g_SealLeak[3],
        Vibration       := g_Vib_mms[3],
        DischPressure   := g_PumpP_kPa[3],
        ResetTrip       := ResetTrip[3],
        Lockout         := Lockout[3],
        MinOffBypass    := g_LSHH,
        ServiceInterval := INT_TO_REAL(v_ServiceHrs),
        ResetHours      := ResetHours[3]);


  (* =====================================================================
     Step 10 - headroom
     ===================================================================== *)
  Head(Level          := g_Level_m,
       Inflow         := g_Inflow_Lps,
       TotalDischarge := g_Disch_Lps);


  (* =====================================================================
     Step 12 - publish
     ===================================================================== *)

  PumpsRun := 0;
  IF Pump1.Running THEN PumpsRun := PumpsRun + 1; END_IF;
  IF Pump2.Running THEN PumpsRun := PumpsRun + 1; END_IF;
  IF Pump3.Running THEN PumpsRun := PumpsRun + 1; END_IF;

  HighLevel := g_Level_mm >= v_HighAlarm;

  g_o_RunCmd[1]    := Pump1.RunCmd;
  g_o_RunCmd[2]    := Pump2.RunCmd;
  g_o_RunCmd[3]    := Pump3.RunCmd;
  g_o_Running[1]   := Pump1.Running;
  g_o_Running[2]   := Pump2.Running;
  g_o_Running[3]   := Pump3.Running;
  g_o_Available[1] := Pump1.Available;
  g_o_Available[2] := Pump2.Available;
  g_o_Available[3] := Pump3.Available;
  g_o_Tripped[1]   := Pump1.Tripped;
  g_o_Tripped[2]   := Pump2.Tripped;
  g_o_Tripped[3]   := Pump3.Tripped;

  g_o_PumpState[1] := Pump1.State;
  g_o_PumpState[2] := Pump2.State;
  g_o_PumpState[3] := Pump3.State;

  g_o_InAuto      := (v_Mode = 1);
  g_o_HighLevel   := HighLevel;
  g_o_SpillActive := g_SpillDetected;

  g_o_Level_mm    := g_Level_mm;
  g_o_PumpsRun    := PumpsRun;
  g_o_DutyPump    := Duty.DutyPump;

  (* scaled analogues, clamped into 16-bit signed range *)
  r := g_Inflow_Lps * 10.0;
  IF r > 32767.0 THEN r := 32767.0; ELSIF r < -32768.0 THEN r := -32768.0; END_IF;
  g_o_Inflow_x10 := REAL_TO_INT(r);

  r := g_Disch_Lps * 10.0;
  IF r > 32767.0 THEN r := 32767.0; ELSIF r < -32768.0 THEN r := -32768.0; END_IF;
  g_o_Disch_x10 := REAL_TO_INT(r);

  r := Speed * 10.0;
  IF r > 32767.0 THEN r := 32767.0; ELSIF r < 0.0 THEN r := 0.0; END_IF;
  g_o_Speed_x10 := REAL_TO_INT(r);

  r := Head.NetInflow * 10.0;
  IF r > 32767.0 THEN r := 32767.0; ELSIF r < -32768.0 THEN r := -32768.0; END_IF;
  g_o_NetAccum := REAL_TO_INT(r);

  r := Head.VolToSpill;
  IF r > 32767.0 THEN r := 32767.0; ELSIF r < 0.0 THEN r := 0.0; END_IF;
  g_o_VolToSpill := REAL_TO_INT(r);

  g_o_TimeToSpill := Head.TimeToSpill;
  g_o_TimeToLSHH  := Head.TimeToLSHH;

  r := Pump1.RunHours;
  IF r > 32767.0 THEN r := 32767.0; END_IF;
  g_o_RunHours[1] := REAL_TO_INT(r);
  r := Pump2.RunHours;
  IF r > 32767.0 THEN r := 32767.0; END_IF;
  g_o_RunHours[2] := REAL_TO_INT(r);
  r := Pump3.RunHours;
  IF r > 32767.0 THEN r := 32767.0; END_IF;
  g_o_RunHours[3] := REAL_TO_INT(r);

  (* --- station state, section 3.1 ---------------------------------- *)
  IF v_Mode = 2 THEN
      g_o_StationState := 0;                      (* Off                 *)
  ELSIF g_LSHH THEN
      g_o_StationState := 4;                      (* Emergency           *)
  ELSIF DryLockout THEN
      g_o_StationState := 5;                      (* Dry run lockout     *)
  ELSIF LevelFault THEN
      g_o_StationState := 6;                      (* Fault               *)
  ELSIF HighLevel THEN
      g_o_StationState := 3;                      (* High level          *)
  ELSIF PumpsRun > 0 THEN
      g_o_StationState := 2;                      (* Pumping             *)
  ELSE
      g_o_StationState := 1;                      (* Idle                *)
  END_IF;

  (* --- alarm bitmask, section 6.

     Accumulated in a DINT because bit 15 does not fit a signed INT.
     Values at or above 32768 are folded into the negative half of the
     16-bit word; CI Server must read %QW17 as UNSIGNED. ------------- *)
  Alarm := 0;
  IF HighLevel                            THEN Alarm := Alarm + 1;     END_IF;  (* bit0  *)
  IF g_LSHH                               THEN Alarm := Alarm + 2;     END_IF;  (* bit1  *)
  IF DryRun OR DryLockout                 THEN Alarm := Alarm + 4;     END_IF;  (* bit2  *)
  IF g_SpillDetected                      THEN Alarm := Alarm + 8;     END_IF;  (* bit3  *)
  IF Pump1.Tripped                        THEN Alarm := Alarm + 16;    END_IF;  (* bit4  *)
  IF Pump2.Tripped                        THEN Alarm := Alarm + 32;    END_IF;  (* bit5  *)
  IF Pump3.Tripped                        THEN Alarm := Alarm + 64;    END_IF;  (* bit6  *)
  IF Pump1.SealAlarm                      THEN Alarm := Alarm + 128;   END_IF;  (* bit7  *)
  IF Pump2.SealAlarm                      THEN Alarm := Alarm + 256;   END_IF;  (* bit8  *)
  IF Pump3.SealAlarm                      THEN Alarm := Alarm + 512;   END_IF;  (* bit9  *)
  IF Pump1.VibAlarm                       THEN Alarm := Alarm + 1024;  END_IF;  (* bit10 *)
  IF Pump2.VibAlarm                       THEN Alarm := Alarm + 2048;  END_IF;  (* bit11 *)
  IF Pump3.VibAlarm                       THEN Alarm := Alarm + 4096;  END_IF;  (* bit12 *)
  IF LevelFault                           THEN Alarm := Alarm + 8192;  END_IF;  (* bit13 *)
  IF NOT g_MainsOK                        THEN Alarm := Alarm + 16384; END_IF;  (* bit14 *)
  IF SpRejected                           THEN Alarm := Alarm + 32768; END_IF;  (* bit15 *)

  IF Alarm >= 32768 THEN
      g_o_AlarmWord := DINT_TO_INT(Alarm - 65536);
  ELSE
      g_o_AlarmWord := DINT_TO_INT(Alarm);
  END_IF;
END_PROGRAM

PROGRAM SIMULATION
  VAR_EXTERNAL
    g_simcmd_Inflow : INT;
    g_simcmd_Mode : INT;
    g_simcmd_Reset : INT;
    g_simcmd_TimeScale : INT;
    g_o_RunCmd : ARRAY [1..3] OF BOOL;
    g_o_Speed_x10 : INT;
    g_SimActive : BOOL;
    g_sim_ClearReset : BOOL;
    g_sim_Level_mm : INT;
    g_sim_Inflow_x10 : INT;
    g_sim_Disch_x10 : INT;
    g_sim_ManifoldP : INT;
    g_sim_PumpP : ARRAY [1..3] OF INT;
    g_sim_Vib_x10 : ARRAY [1..3] OF INT;
    g_sim_LSHH : BOOL;
    g_sim_LSLL_Wet : BOOL;
    g_sim_Spill : BOOL;
    g_sim_ThermalOK : ARRAY [1..3] OF BOOL;
    g_sim_SealLeak : ARRAY [1..3] OF BOOL;
    g_sim_MainsOK : BOOL;
  END_VAR
  VAR
    SCAN_S : REAL := 0.1;
    AREA_M2 : REAL := 120.0;
    SPILL_M : REAL := 6.0;
    LSHH_M : REAL := 5.5;
    LSLL_M : REAL := 0.30;
    START_DLY_S : REAL := 3.0;
    HZ_LO : REAL := 38.0;
    HZ_HI : REAL := 50.0;
    Q_LO : REAL := 65.0;
    Q_HI : REAL := 120.0;
    P_IDLE : REAL := 80.0;
    P_BASE : REAL := 220.0;
    P_PER_LPS : REAL := 1.4;
    VIB_IDLE : REAL := 0.2;
    VIB_BASE : REAL := 1.5;
    VIB_PER_LPS : REAL := 0.02;
    Init : BOOL := FALSE;
    Volume_m3 : REAL;
    Level_m : REAL;
    Inflow_Lps : REAL;
    SumFlow_Lps : REAL;
    SimClock_s : REAL;
    StartDly_s : ARRAY [1..3] OF REAL;
    Delivering : ARRAY [1..3] OF BOOL;
    Flow_Lps : ARRAY [1..3] OF REAL;
    Press_kPa : ARRAY [1..3] OF REAL;
    Vib_mms : ARRAY [1..3] OF REAL;
    TimeScale : REAL;
    dt_s : REAL;
    Speed_Hz : REAL;
    UnitQ_Lps : REAL;
    Derate : REAL;
    nDelivering : INT;
    i : INT;
    Rnd : DINT := 12345;
    Noise : REAL;
  END_VAR

  (* ---------------------------------------------------------------------
     Reset / first scan.

     %MW22 = 1 restores the initial conditions of the selected mode
     (section 8.2).  The acknowledgement is a global; IO_MUX clears %MW22,
     because this program may not touch located variables.
     --------------------------------------------------------------------- *)
  g_sim_ClearReset := FALSE;

  IF (NOT Init) OR (g_simcmd_Reset = 1) THEN
      Init := TRUE;
      g_sim_ClearReset := TRUE;

      SimClock_s := 0.0;

      IF g_simcmd_Mode = 3 THEN
          Level_m := 4.000;           (* section 7.3 reference condition *)
      ELSE
          Level_m := 3.500;           (* below start duty, station idle *)
      END_IF;

      Volume_m3 := Level_m * AREA_M2;

      FOR i := 1 TO 3 DO
          StartDly_s[i] := 0.0;
          Delivering[i] := FALSE;
          Flow_Lps[i]   := 0.0;
          Press_kPa[i]  := P_IDLE;
          Vib_mms[i]    := VIB_IDLE;
      END_FOR;
  END_IF;


  (* ---------------------------------------------------------------------
     Time base.  %MW23 = 1..120, anything outside that is treated as 1.
     --------------------------------------------------------------------- *)
  IF (g_simcmd_TimeScale >= 1) AND (g_simcmd_TimeScale <= 120) THEN
      TimeScale := INT_TO_REAL(g_simcmd_TimeScale);
  ELSE
      TimeScale := 1.0;
  END_IF;

  dt_s := SCAN_S * TimeScale;
  SimClock_s := SimClock_s + dt_s;


  (* ---------------------------------------------------------------------
     Inflow generator, section 8.2.  %MW21 selects the mode.
     --------------------------------------------------------------------- *)
  CASE g_simcmd_Mode OF
      1:  (* diurnal dry weather: 40-110 L/s over a 24 h simulated period *)
          Inflow_Lps := 75.0 + 35.0 * SIN(6.283185 * SimClock_s / 86400.0);

      2:  (* wet weather: ramp 20 min to 300, hold 40 min, decay over 90 min *)
          IF SimClock_s < 1200.0 THEN
              Inflow_Lps := 75.0 + (300.0 - 75.0) * SimClock_s / 1200.0;
          ELSIF SimClock_s < 3600.0 THEN
              Inflow_Lps := 300.0;
          ELSIF SimClock_s < 9000.0 THEN
              Inflow_Lps := 300.0 - (300.0 - 75.0) * (SimClock_s - 3600.0) / 5400.0;
          ELSE
              Inflow_Lps := 75.0;
          END_IF;

      3:  (* demo reference, section 7.3: held at exactly 165 L/s *)
          Inflow_Lps := 165.0;

      ELSE  (* 0 and anything unrecognised: manual, %MW20 in L/s x 10 *)
          Inflow_Lps := INT_TO_REAL(g_simcmd_Inflow) / 10.0;
  END_CASE;

  IF Inflow_Lps < 0.0 THEN
      Inflow_Lps := 0.0;
  END_IF;


  (* ---------------------------------------------------------------------
     Per-pump flow model, section 8.2.

     Speed comes from CONTROL's published common drive speed, which is
     this scan's value because SIMULATION runs after CONTROL.
     --------------------------------------------------------------------- *)
  Speed_Hz := INT_TO_REAL(g_o_Speed_x10) / 10.0;

  IF Speed_Hz < HZ_LO THEN
      UnitQ_Lps := 0.0;               (* static lift cutoff *)
  ELSE
      IF Speed_Hz > HZ_HI THEN
          Speed_Hz := HZ_HI;
      END_IF;
      UnitQ_Lps := Q_LO + (Speed_Hz - HZ_LO) * (Q_HI - Q_LO) / (HZ_HI - HZ_LO);
  END_IF;

  (* Start delay on REAL time - see the header comment. *)
  nDelivering := 0;
  FOR i := 1 TO 3 DO
      IF g_o_RunCmd[i] THEN
          IF StartDly_s[i] < START_DLY_S THEN
              StartDly_s[i] := StartDly_s[i] + SCAN_S;
          END_IF;
          Delivering[i] := (StartDly_s[i] >= START_DLY_S) AND (UnitQ_Lps > 0.0);
      ELSE
          StartDly_s[i] := 0.0;
          Delivering[i] := FALSE;
      END_IF;

      IF Delivering[i] THEN
          nDelivering := nDelivering + 1;
      END_IF;
  END_FOR;

  (* Parallel derating: 3 units give ~360 L/s, not a naive 3 x 120. *)
  CASE nDelivering OF
      1:  Derate := 1.00;
      2:  Derate := 0.94;
      3:  Derate := 0.88;
      ELSE Derate := 1.00;
  END_CASE;

  SumFlow_Lps := 0.0;
  FOR i := 1 TO 3 DO
      IF Delivering[i] THEN
          Flow_Lps[i]  := UnitQ_Lps * Derate;
          Press_kPa[i] := P_BASE + P_PER_LPS * Flow_Lps[i];
          Vib_mms[i]   := VIB_BASE + VIB_PER_LPS * Flow_Lps[i];
          SumFlow_Lps  := SumFlow_Lps + Flow_Lps[i];
      ELSE
          Flow_Lps[i]  := 0.0;
          Press_kPa[i] := P_IDLE;
          IF g_o_RunCmd[i] THEN
              Vib_mms[i] := VIB_BASE;     (* spinning up, no flow yet *)
          ELSE
              Vib_mms[i] := VIB_IDLE;
          END_IF;
      END_IF;
  END_FOR;


  (* ---------------------------------------------------------------------
     Wet well integration, section 8.2.

     On reaching the weir the level holds at 6.00 m and the excess is
     discarded - the station is seen to spill rather than running the
     level off scale.
     --------------------------------------------------------------------- *)
  Volume_m3 := Volume_m3 + (Inflow_Lps - SumFlow_Lps) * dt_s / 1000.0;

  IF Volume_m3 < 0.0 THEN
      Volume_m3 := 0.0;
  END_IF;

  IF Volume_m3 > SPILL_M * AREA_M2 THEN
      Volume_m3 := SPILL_M * AREA_M2;
  END_IF;

  Level_m := Volume_m3 / AREA_M2;


  (* ---------------------------------------------------------------------
     Publish, section 8.3: small noise so the trends are not perfectly
     smooth and FB_HEADROOM's inflow filter has something to filter.
     LCG kept small enough that the DINT multiply cannot overflow.
     --------------------------------------------------------------------- *)
  Rnd := (Rnd * 75 + 74) MOD 65537;
  Noise := (DINT_TO_REAL(Rnd) / 65536.0 - 0.5) * 0.01;        (* +/-0.5% *)
  g_sim_Level_mm := REAL_TO_INT(Level_m * 1000.0 * (1.0 + Noise));

  Rnd := (Rnd * 75 + 74) MOD 65537;
  Noise := (DINT_TO_REAL(Rnd) / 65536.0 - 0.5) * 0.04;        (* +/-2%   *)
  g_sim_Inflow_x10 := REAL_TO_INT(Inflow_Lps * 10.0 * (1.0 + Noise));

  Rnd := (Rnd * 75 + 74) MOD 65537;
  Noise := (DINT_TO_REAL(Rnd) / 65536.0 - 0.5) * 0.04;
  g_sim_Disch_x10 := REAL_TO_INT(SumFlow_Lps * 10.0 * (1.0 + Noise));

  FOR i := 1 TO 3 DO
      g_sim_PumpP[i]   := REAL_TO_INT(Press_kPa[i]);
      g_sim_Vib_x10[i] := REAL_TO_INT(Vib_mms[i] * 10.0);
  END_FOR;

  (* Manifold: the highest delivering unit's pressure, idle if none. *)
  g_sim_ManifoldP := REAL_TO_INT(P_IDLE);
  IF nDelivering > 0 THEN
      g_sim_ManifoldP := REAL_TO_INT(P_BASE + P_PER_LPS * UnitQ_Lps * Derate);
  END_IF;

  (* --- level switches.  Sense conventions are section 2.1's. --------- *)
  g_sim_LSHH     := Level_m >= LSHH_M;
  g_sim_LSLL_Wet := Level_m > LSLL_M;         (* FALSE = dry *)
  g_sim_Spill    := Level_m >= (SPILL_M - 0.001);

  (* --- plant health.  Healthy unless a fault is injected; injection is
         not modelled yet, so these are constant. ---------------------- *)
  FOR i := 1 TO 3 DO
      g_sim_ThermalOK[i] := TRUE;
      g_sim_SealLeak[i]  := FALSE;
  END_FOR;
  g_sim_MainsOK := TRUE;

  (* Tell IO_MUX to take its process image from here, not from %IW/%IX. *)
  g_SimActive := TRUE;
END_PROGRAM

PROGRAM IO_MUX
  VAR_EXTERNAL
    IW_LIT101 : INT;
    IW_FIT201 : INT;
    IW_FIT301 : INT;
    IW_PIT302 : INT;
    IW_PIT311 : INT;
    IW_PIT321 : INT;
    IW_PIT331 : INT;
    IW_VE314 : INT;
    IW_VE324 : INT;
    IW_VE334 : INT;
    IX_LSHH102 : BOOL;
    IX_LSLL103 : BOOL;
    IX_LSH104 : BOOL;
    IX_TE312 : BOOL;
    IX_TE322 : BOOL;
    IX_TE332 : BOOL;
    IX_MSE313 : BOOL;
    IX_MSE323 : BOOL;
    IX_MSE333 : BOOL;
    IX_XA502 : BOOL;
    QX_RunCmd1 : BOOL;
    QX_RunCmd2 : BOOL;
    QX_RunCmd3 : BOOL;
    QX_Running1 : BOOL;
    QX_Running2 : BOOL;
    QX_Running3 : BOOL;
    QX_Avail1 : BOOL;
    QX_Avail2 : BOOL;
    QX_Avail3 : BOOL;
    QX_InAuto : BOOL;
    QX_HighLevel : BOOL;
    QX_SpillActive : BOOL;
    QX_Tripped1 : BOOL;
    QX_Tripped2 : BOOL;
    QX_Tripped3 : BOOL;
    QW_Level : INT;
    QW_Inflow : INT;
    QW_Discharge : INT;
    QW_PumpsRunning : INT;
    QW_Speed : INT;
    QW_TimeToSpill : INT;
    QW_TimeToLSHH : INT;
    QW_NetAccum : INT;
    QW_RunHours1 : INT;
    QW_RunHours2 : INT;
    QW_RunHours3 : INT;
    QW_VolToSpill : INT;
    QW_StationState : INT;
    QW_PumpState1 : INT;
    QW_PumpState2 : INT;
    QW_PumpState3 : INT;
    QW_DutyPump : INT;
    QW_AlarmWord : INT;
    QW_CmdAck : INT;
    MW_Mode : INT;
    MW_CmdWord : INT;
    MW_CmdParam : INT;
    MW_SpLevel : INT;
    MW_StartDuty : INT;
    MW_StartP2 : INT;
    MW_StartP3 : INT;
    MW_StopAll : INT;
    MW_HighAlarm : INT;
    MW_MinSpeed : INT;
    MW_ServiceHrs : INT;
    MW_SimInflow : INT;
    MW_SimMode : INT;
    MW_SimReset : INT;
    MW_SimTimeScale : INT;
    g_LevelRaw_mm : INT;
    g_Level_mm : INT;
    g_Level_m : REAL;
    g_Inflow_Lps : REAL;
    g_Disch_Lps : REAL;
    g_ManifoldP_kPa : REAL;
    g_PumpP_kPa : ARRAY [1..3] OF REAL;
    g_Vib_mms : ARRAY [1..3] OF REAL;
    g_LSHH : BOOL;
    g_LSLL_Wet : BOOL;
    g_SpillDetected : BOOL;
    g_ThermalOK : ARRAY [1..3] OF BOOL;
    g_SealLeak : ARRAY [1..3] OF BOOL;
    g_MainsOK : BOOL;
    g_cmd_Mode : INT;
    g_cmd_Word : INT;
    g_cmd_Param : INT;
    g_sp_Level : INT;
    g_sp_StartDuty : INT;
    g_sp_StartP2 : INT;
    g_sp_StartP3 : INT;
    g_sp_StopAll : INT;
    g_sp_HighAlarm : INT;
    g_sp_MinSpeed : INT;
    g_sp_ServiceHrs : INT;
    g_o_RunCmd : ARRAY [1..3] OF BOOL;
    g_o_Running : ARRAY [1..3] OF BOOL;
    g_o_Available : ARRAY [1..3] OF BOOL;
    g_o_Tripped : ARRAY [1..3] OF BOOL;
    g_o_InAuto : BOOL;
    g_o_HighLevel : BOOL;
    g_o_SpillActive : BOOL;
    g_o_Level_mm : INT;
    g_o_Inflow_x10 : INT;
    g_o_Disch_x10 : INT;
    g_o_PumpsRun : INT;
    g_o_Speed_x10 : INT;
    g_o_TimeToSpill : INT;
    g_o_TimeToLSHH : INT;
    g_o_NetAccum : INT;
    g_o_RunHours : ARRAY [1..3] OF INT;
    g_o_VolToSpill : INT;
    g_o_StationState : INT;
    g_o_PumpState : ARRAY [1..3] OF INT;
    g_o_DutyPump : INT;
    g_o_AlarmWord : INT;
    g_o_CmdAck : INT;
    DEF_MODE : INT;
    DEF_SP_LEVEL : INT;
    DEF_START_DUTY : INT;
    DEF_START_P2 : INT;
    DEF_START_P3 : INT;
    DEF_STOP_ALL : INT;
    DEF_HIGH_ALARM : INT;
    DEF_MIN_SPEED : INT;
    DEF_SERVICE_HRS : INT;
    g_SimActive : BOOL;
    g_sim_ClearReset : BOOL;
    g_simcmd_Inflow : INT;
    g_simcmd_Mode : INT;
    g_simcmd_Reset : INT;
    g_simcmd_TimeScale : INT;
    g_sim_Level_mm : INT;
    g_sim_Inflow_x10 : INT;
    g_sim_Disch_x10 : INT;
    g_sim_ManifoldP : INT;
    g_sim_PumpP : ARRAY [1..3] OF INT;
    g_sim_Vib_x10 : ARRAY [1..3] OF INT;
    g_sim_LSHH : BOOL;
    g_sim_LSLL_Wet : BOOL;
    g_sim_Spill : BOOL;
    g_sim_ThermalOK : ARRAY [1..3] OF BOOL;
    g_sim_SealLeak : ARRAY [1..3] OF BOOL;
    g_sim_MainsOK : BOOL;
  END_VAR
  VAR
    Seeded : BOOL := FALSE;
  END_VAR

  (* ---------------------------------------------------------------------
     Seed the %MW setpoint defaults once, at first scan.

     Section 9 forbids relying on retained variables, so after a runtime
     restart every %MW reads 0.  Writing the defaults once here means
     CI Server sees real values rather than zeros, and CONTROL's
     validation does not reject an all-zero image on every scan.

     This is a one-shot write, not a per-scan overwrite: everything
     CI Server writes afterwards survives, per section 2.
     --------------------------------------------------------------------- *)
  IF NOT Seeded THEN
      Seeded        := TRUE;
      MW_Mode       := DEF_MODE;
      MW_CmdWord    := 0;
      MW_CmdParam   := 0;
      MW_SpLevel    := DEF_SP_LEVEL;
      MW_StartDuty  := DEF_START_DUTY;
      MW_StartP2    := DEF_START_P2;
      MW_StartP3    := DEF_START_P3;
      MW_StopAll    := DEF_STOP_ALL;
      MW_HighAlarm  := DEF_HIGH_ALARM;
      MW_MinSpeed   := DEF_MIN_SPEED;
      MW_ServiceHrs := DEF_SERVICE_HRS;

      (* Simulation defaults, section 8.2: manual inflow at the dry
         weather average, time scale 1.  Unused in the field build. *)
      MW_SimInflow    := 750;
      MW_SimMode      := 0;
      MW_SimReset     := 0;
      MW_SimTimeScale := 1;
  END_IF;


  (* ---------------------------------------------------------------------
     Publish the previous scan's results to %QW / %QX
     --------------------------------------------------------------------- *)
  QX_RunCmd1     := g_o_RunCmd[1];
  QX_RunCmd2     := g_o_RunCmd[2];
  QX_RunCmd3     := g_o_RunCmd[3];
  QX_Running1    := g_o_Running[1];
  QX_Running2    := g_o_Running[2];
  QX_Running3    := g_o_Running[3];
  QX_Avail1      := g_o_Available[1];
  QX_Avail2      := g_o_Available[2];
  QX_Avail3      := g_o_Available[3];
  QX_InAuto      := g_o_InAuto;
  QX_HighLevel   := g_o_HighLevel;
  QX_SpillActive := g_o_SpillActive;
  QX_Tripped1    := g_o_Tripped[1];
  QX_Tripped2    := g_o_Tripped[2];
  QX_Tripped3    := g_o_Tripped[3];

  QW_Level        := g_o_Level_mm;
  QW_Inflow       := g_o_Inflow_x10;
  QW_Discharge    := g_o_Disch_x10;
  QW_PumpsRunning := g_o_PumpsRun;
  QW_Speed        := g_o_Speed_x10;
  QW_TimeToSpill  := g_o_TimeToSpill;
  QW_TimeToLSHH   := g_o_TimeToLSHH;
  QW_NetAccum     := g_o_NetAccum;
  QW_RunHours1    := g_o_RunHours[1];
  QW_RunHours2    := g_o_RunHours[2];
  QW_RunHours3    := g_o_RunHours[3];
  QW_VolToSpill   := g_o_VolToSpill;
  QW_StationState := g_o_StationState;
  QW_PumpState1   := g_o_PumpState[1];
  QW_PumpState2   := g_o_PumpState[2];
  QW_PumpState3   := g_o_PumpState[3];
  QW_DutyPump     := g_o_DutyPump;
  QW_AlarmWord    := g_o_AlarmWord;
  QW_CmdAck       := g_o_CmdAck;


  (* ---------------------------------------------------------------------
     Commands and setpoints in
     --------------------------------------------------------------------- *)
  g_cmd_Mode      := MW_Mode;
  g_cmd_Word      := MW_CmdWord;
  g_cmd_Param     := MW_CmdParam;
  g_sp_Level      := MW_SpLevel;
  g_sp_StartDuty  := MW_StartDuty;
  g_sp_StartP2    := MW_StartP2;
  g_sp_StartP3    := MW_StartP3;
  g_sp_StopAll    := MW_StopAll;
  g_sp_HighAlarm  := MW_HighAlarm;
  g_sp_MinSpeed   := MW_MinSpeed;
  g_sp_ServiceHrs := MW_ServiceHrs;


  (* ---------------------------------------------------------------------
     Simulation control out, and the reset acknowledgement.

     SIMULATION may not touch located variables, so clearing %MW22 after
     a reset happens here.
     --------------------------------------------------------------------- *)
  g_simcmd_Inflow    := MW_SimInflow;
  g_simcmd_Mode      := MW_SimMode;
  g_simcmd_Reset     := MW_SimReset;
  g_simcmd_TimeScale := MW_SimTimeScale;

  IF g_sim_ClearReset THEN
      MW_SimReset := 0;
  END_IF;


  (* ---------------------------------------------------------------------
     THE MUX, section 8.1.

     Field build:      g_SimActive is FALSE (SIMULATION is not compiled
                       in and nothing ever sets it), so the located
                       inputs are used.
     Simulation build: SIMULATION sets it TRUE every scan and the
                       process image comes from g_sim_* instead.

     CONTROL sees identical globals either way and cannot tell which
     source it is running on - that is the point of section 8.1.
     --------------------------------------------------------------------- *)
  IF g_SimActive THEN

      g_LevelRaw_mm   := g_sim_Level_mm;
      g_Level_mm      := g_sim_Level_mm;
      g_Level_m       := INT_TO_REAL(g_sim_Level_mm) / 1000.0;
      g_Inflow_Lps    := INT_TO_REAL(g_sim_Inflow_x10) / 10.0;
      g_Disch_Lps     := INT_TO_REAL(g_sim_Disch_x10) / 10.0;
      g_ManifoldP_kPa := INT_TO_REAL(g_sim_ManifoldP);

      g_PumpP_kPa[1]  := INT_TO_REAL(g_sim_PumpP[1]);
      g_PumpP_kPa[2]  := INT_TO_REAL(g_sim_PumpP[2]);
      g_PumpP_kPa[3]  := INT_TO_REAL(g_sim_PumpP[3]);

      g_Vib_mms[1]    := INT_TO_REAL(g_sim_Vib_x10[1]) / 10.0;
      g_Vib_mms[2]    := INT_TO_REAL(g_sim_Vib_x10[2]) / 10.0;
      g_Vib_mms[3]    := INT_TO_REAL(g_sim_Vib_x10[3]) / 10.0;

      g_LSHH          := g_sim_LSHH;
      g_LSLL_Wet      := g_sim_LSLL_Wet;
      g_SpillDetected := g_sim_Spill;

      g_ThermalOK[1]  := g_sim_ThermalOK[1];
      g_ThermalOK[2]  := g_sim_ThermalOK[2];
      g_ThermalOK[3]  := g_sim_ThermalOK[3];

      g_SealLeak[1]   := g_sim_SealLeak[1];
      g_SealLeak[2]   := g_sim_SealLeak[2];
      g_SealLeak[3]   := g_sim_SealLeak[3];

      g_MainsOK       := g_sim_MainsOK;

  ELSE

  (* ---------------------------------------------------------------------
     FIELD SOURCE - analogue inputs, scaled to engineering units
     --------------------------------------------------------------------- *)
  g_LevelRaw_mm   := IW_LIT101;
  g_Level_mm      := IW_LIT101;
  g_Level_m       := INT_TO_REAL(IW_LIT101) / 1000.0;
  g_Inflow_Lps    := INT_TO_REAL(IW_FIT201) / 10.0;
  g_Disch_Lps     := INT_TO_REAL(IW_FIT301) / 10.0;
  g_ManifoldP_kPa := INT_TO_REAL(IW_PIT302);

  g_PumpP_kPa[1]  := INT_TO_REAL(IW_PIT311);
  g_PumpP_kPa[2]  := INT_TO_REAL(IW_PIT321);
  g_PumpP_kPa[3]  := INT_TO_REAL(IW_PIT331);

  g_Vib_mms[1]    := INT_TO_REAL(IW_VE314) / 10.0;
  g_Vib_mms[2]    := INT_TO_REAL(IW_VE324) / 10.0;
  g_Vib_mms[3]    := INT_TO_REAL(IW_VE334) / 10.0;

  (* --- discrete inputs.  Sense conventions are section 2.1's, and are
         applied here so that CONTROL never has to know them. --------- *)
  g_LSHH          := IX_LSHH102;      (* TRUE = wet                      *)
  g_LSLL_Wet      := IX_LSLL103;      (* TRUE = wet; FALSE = dry, so a
                                         broken wire stops the station   *)
  g_SpillDetected := IX_LSH104;

  g_ThermalOK[1]  := IX_TE312;        (* TRUE = healthy                  *)
  g_ThermalOK[2]  := IX_TE322;
  g_ThermalOK[3]  := IX_TE332;

  g_SealLeak[1]   := IX_MSE313;       (* TRUE = leak                     *)
  g_SealLeak[2]   := IX_MSE323;
  g_SealLeak[3]   := IX_MSE333;

  g_MainsOK       := IX_XA502;        (* TRUE = healthy                  *)

  END_IF;
END_PROGRAM


CONFIGURATION Config0
  VAR_GLOBAL
    CFG_AREA_M2 : REAL := 120.0;
    CFG_SPILL_M : REAL := 6.000;
    CFG_LSHH_M : REAL := 5.500;
    CFG_MIN_HZ : REAL := 38.0;
    CFG_MAX_HZ : REAL := 50.0;
    DEF_MODE : INT := 1;
    DEF_SP_LEVEL : INT := 4200;
    DEF_START_DUTY : INT := 4000;
    DEF_START_P2 : INT := 4500;
    DEF_START_P3 : INT := 5000;
    DEF_STOP_ALL : INT := 1000;
    DEF_HIGH_ALARM : INT := 5200;
    DEF_MIN_SPEED : INT := 380;
    DEF_SERVICE_HRS : INT := 4000;
    CFG_NO_TIME : INT := 32767;
    IW_LIT101 AT %IW0 : INT;
    IW_FIT201 AT %IW1 : INT;
    IW_FIT301 AT %IW2 : INT;
    IW_PIT302 AT %IW3 : INT;
    IW_PIT311 AT %IW4 : INT;
    IW_PIT321 AT %IW5 : INT;
    IW_PIT331 AT %IW6 : INT;
    IW_VE314 AT %IW7 : INT;
    IW_VE324 AT %IW8 : INT;
    IW_VE334 AT %IW9 : INT;
    IX_LSHH102 AT %IX0.0 : BOOL;
    IX_LSLL103 AT %IX0.1 : BOOL;
    IX_LSH104 AT %IX0.2 : BOOL;
    IX_TE312 AT %IX0.3 : BOOL;
    IX_TE322 AT %IX0.4 : BOOL;
    IX_TE332 AT %IX0.5 : BOOL;
    IX_MSE313 AT %IX0.6 : BOOL;
    IX_MSE323 AT %IX0.7 : BOOL;
    IX_MSE333 AT %IX1.0 : BOOL;
    IX_XA502 AT %IX1.1 : BOOL;
    QX_RunCmd1 AT %QX0.0 : BOOL;
    QX_RunCmd2 AT %QX0.1 : BOOL;
    QX_RunCmd3 AT %QX0.2 : BOOL;
    QX_Running1 AT %QX0.3 : BOOL;
    QX_Running2 AT %QX0.4 : BOOL;
    QX_Running3 AT %QX0.5 : BOOL;
    QX_Avail1 AT %QX0.6 : BOOL;
    QX_Avail2 AT %QX0.7 : BOOL;
    QX_Avail3 AT %QX1.0 : BOOL;
    QX_InAuto AT %QX1.1 : BOOL;
    QX_HighLevel AT %QX1.2 : BOOL;
    QX_SpillActive AT %QX1.3 : BOOL;
    QX_Tripped1 AT %QX1.4 : BOOL;
    QX_Tripped2 AT %QX1.5 : BOOL;
    QX_Tripped3 AT %QX1.6 : BOOL;
    QW_Level AT %QW0 : INT;
    QW_Inflow AT %QW1 : INT;
    QW_Discharge AT %QW2 : INT;
    QW_PumpsRunning AT %QW3 : INT;
    QW_Speed AT %QW4 : INT;
    QW_TimeToSpill AT %QW5 : INT;
    QW_TimeToLSHH AT %QW6 : INT;
    QW_NetAccum AT %QW7 : INT;
    QW_RunHours1 AT %QW8 : INT;
    QW_RunHours2 AT %QW9 : INT;
    QW_RunHours3 AT %QW10 : INT;
    QW_VolToSpill AT %QW11 : INT;
    QW_StationState AT %QW12 : INT;
    QW_PumpState1 AT %QW13 : INT;
    QW_PumpState2 AT %QW14 : INT;
    QW_PumpState3 AT %QW15 : INT;
    QW_DutyPump AT %QW16 : INT;
    QW_AlarmWord AT %QW17 : INT;
    QW_CmdAck AT %QW20 : INT;
    MW_Mode AT %MW0 : INT;
    MW_CmdWord AT %MW1 : INT;
    MW_CmdParam AT %MW2 : INT;
    MW_SpLevel AT %MW3 : INT;
    MW_StartDuty AT %MW4 : INT;
    MW_StartP2 AT %MW5 : INT;
    MW_StartP3 AT %MW6 : INT;
    MW_StopAll AT %MW7 : INT;
    MW_HighAlarm AT %MW8 : INT;
    MW_MinSpeed AT %MW9 : INT;
    MW_ServiceHrs AT %MW10 : INT;
    MW_SimInflow AT %MW20 : INT;
    MW_SimMode AT %MW21 : INT;
    MW_SimReset AT %MW22 : INT;
    MW_SimTimeScale AT %MW23 : INT;
    g_LevelRaw_mm : INT;
    g_Level_mm : INT;
    g_Level_m : REAL;
    g_Inflow_Lps : REAL;
    g_Disch_Lps : REAL;
    g_ManifoldP_kPa : REAL;
    g_PumpP_kPa : ARRAY [1..3] OF REAL;
    g_Vib_mms : ARRAY [1..3] OF REAL;
    g_LSHH : BOOL;
    g_LSLL_Wet : BOOL;
    g_SpillDetected : BOOL;
    g_ThermalOK : ARRAY [1..3] OF BOOL;
    g_SealLeak : ARRAY [1..3] OF BOOL;
    g_MainsOK : BOOL;
    g_cmd_Mode : INT;
    g_cmd_Word : INT;
    g_cmd_Param : INT;
    g_sp_Level : INT;
    g_sp_StartDuty : INT;
    g_sp_StartP2 : INT;
    g_sp_StartP3 : INT;
    g_sp_StopAll : INT;
    g_sp_HighAlarm : INT;
    g_sp_MinSpeed : INT;
    g_sp_ServiceHrs : INT;
    g_o_RunCmd : ARRAY [1..3] OF BOOL;
    g_o_Running : ARRAY [1..3] OF BOOL;
    g_o_Available : ARRAY [1..3] OF BOOL;
    g_o_Tripped : ARRAY [1..3] OF BOOL;
    g_o_InAuto : BOOL;
    g_o_HighLevel : BOOL;
    g_o_SpillActive : BOOL;
    g_o_Level_mm : INT;
    g_o_Inflow_x10 : INT;
    g_o_Disch_x10 : INT;
    g_o_PumpsRun : INT;
    g_o_Speed_x10 : INT;
    g_o_TimeToSpill : INT;
    g_o_TimeToLSHH : INT;
    g_o_NetAccum : INT;
    g_o_RunHours : ARRAY [1..3] OF INT;
    g_o_VolToSpill : INT;
    g_o_StationState : INT;
    g_o_PumpState : ARRAY [1..3] OF INT;
    g_o_DutyPump : INT;
    g_o_AlarmWord : INT;
    g_o_CmdAck : INT;
    g_SimActive : BOOL;
    g_sim_ClearReset : BOOL;
    g_simcmd_Inflow : INT;
    g_simcmd_Mode : INT;
    g_simcmd_Reset : INT;
    g_simcmd_TimeScale : INT;
    g_sim_Level_mm : INT;
    g_sim_Inflow_x10 : INT;
    g_sim_Disch_x10 : INT;
    g_sim_ManifoldP : INT;
    g_sim_PumpP : ARRAY [1..3] OF INT;
    g_sim_Vib_x10 : ARRAY [1..3] OF INT;
    g_sim_LSHH : BOOL;
    g_sim_LSLL_Wet : BOOL;
    g_sim_Spill : BOOL;
    g_sim_ThermalOK : ARRAY [1..3] OF BOOL;
    g_sim_SealLeak : ARRAY [1..3] OF BOOL;
    g_sim_MainsOK : BOOL;
  END_VAR

  RESOURCE Res0 ON PLC
    TASK plc_task(INTERVAL := T#100ms,PRIORITY := 0);
    PROGRAM inst_sim WITH plc_task : SIMULATION;
    PROGRAM inst_mux WITH plc_task : IO_MUX;
    PROGRAM inst_ctl WITH plc_task : CONTROL;
  END_RESOURCE
END_CONFIGURATION
