PLC HVAC & Building Automation: Complete Structured Text Programming Guide

Build a complete HVAC building automation system — with AHU damper control, VAV zone logic, chiller plant sequencing, occupancy-based scheduling, and energy optimization in Structured Text.

HVAC Building Automation Overview

Modern buildings rely on PLCs and DDC (Direct Digital Control) systems to manage heating, ventilation, and air conditioning. A typical Building Automation System (BAS) controls:

                    ┌─── AHU-1 (Air Handling Unit)
                    │      ├── Supply Fan
                    │      ├── Return Fan
                    │      ├── Mixed Air Dampers
                    │      ├── Heating Coil
                    │      ├── Cooling Coil
                    │      └── Filter DP Monitor
Chiller Plant ─────┤
  & Boiler Plant   ├─── VAV Boxes (per zone)
                    │      ├── Zone 1: Office North
                    │      ├── Zone 2: Office South
                    │      ├── Zone 3: Conference Rooms
                    │      └── Zone 4: Lobby
                    │
                    └─── Exhaust / Ventilation
                           ├── Restroom Exhaust
                           ├── Kitchen Hood
                           └── Garage Ventilation

HVAC Data Structures

Zone and AHU Types

TYPE ZoneData :
STRUCT
    Name           : STRING(20);
    ActualTemp     : REAL;       // °C measured
    TempSetpoint   : REAL;       // °C desired
    Humidity       : REAL;       // %RH
    CO2_Level      : REAL;       // ppm
    Occupied       : BOOL;       // Occupancy sensor
    DamperPosition : REAL;       // 0-100% VAV damper
    ReheatValve    : REAL;       // 0-100% reheat coil
    AirflowCFM     : REAL;       // Cubic feet per minute
    MinCFM         : REAL;       // Ventilation minimum
    MaxCFM         : REAL;       // Cooling maximum
    TempError      : REAL;       // Setpoint - Actual
    Comfortable    : BOOL;       // Within deadband
END_STRUCT;
END_TYPE

TYPE AHU_Data : STRUCT SupplyTemp : REAL; // °C supply air ReturnTemp : REAL; // °C return air OutdoorTemp : REAL; // °C outside air MixedAirTemp : REAL; // °C after dampers SupplyTempSP : REAL; // °C setpoint SupplyFanSpeed : REAL; // 0-100% VFD ReturnFanSpeed : REAL; // 0-100% VFD OA_DamperPos : REAL; // 0-100% outside air RA_DamperPos : REAL; // 0-100% return air CoolValvePos : REAL; // 0-100% chilled water HeatValvePos : REAL; // 0-100% hot water FilterDP : REAL; // Pa differential pressure FilterAlarm : BOOL; FreezeAlarm : BOOL; SystemEnabled : BOOL; END_STRUCT; END_TYPE

AHU Control — Mixed Air & Economizer

The economizer uses free outdoor air for cooling when conditions allow, reducing chiller energy:

Economizer Logic

FUNCTION_BLOCK FB_AHU_Economizer
VAR_INPUT
    Enable         : BOOL;
    OutdoorTemp    : REAL;      // °C
    ReturnTemp     : REAL;      // °C
    OutdoorHumidity: REAL;      // %RH
    MixedAirTemp   : REAL;      // °C (sensor after mixing)
    SupplyTempSP   : REAL;      // °C desired supply air
END_VAR
VAR_OUTPUT
    OA_DamperCmd   : REAL;      // 0-100%
    RA_DamperCmd   : REAL;      // 0-100%
    EconomizerActive: BOOL;
    MechCoolNeeded : BOOL;      // TRUE = need chilled water
END_VAR
VAR
    Error          : REAL;
    Integral       : REAL := 0.0;
    PrevError      : REAL := 0.0;
    PID_Out        : REAL;
    Kp             : REAL := 5.0;
    Ki             : REAL := 0.3;
    MinOA_Pct      : REAL := 15.0;  // Minimum outside air %
    EconHighLimit  : REAL := 21.0;  // °C — disable economizer above this
    EconEnthalpyOK : BOOL;
END_VAR

IF NOT Enable THEN OA_DamperCmd := 0.0; RA_DamperCmd := 100.0; EconomizerActive := FALSE; MechCoolNeeded := FALSE; RETURN; END_IF;

// ── Economizer eligibility ── // Use free cooling when outdoor air is cooler than return air // and below the high-limit lockout temperature EconEnthalpyOK := (OutdoorTemp < ReturnTemp) AND (OutdoorTemp < EconHighLimit) AND (OutdoorHumidity < 80.0);

IF EconEnthalpyOK THEN EconomizerActive := TRUE; // PID controls OA damper to achieve supply temp setpoint Error := SupplyTempSP - MixedAirTemp; Integral := Integral + (Error * Ki); IF Integral > 50.0 THEN Integral := 50.0; END_IF; IF Integral < -50.0 THEN Integral := -50.0; END_IF; PID_Out := (Error * Kp) + Integral; OA_DamperCmd := 50.0 + PID_Out; // Center at 50% IF OA_DamperCmd < MinOA_Pct THEN OA_DamperCmd := MinOA_Pct; END_IF; IF OA_DamperCmd > 100.0 THEN OA_DamperCmd := 100.0; END_IF; // If damper is fully open and still too warm, need mechanical cooling MechCoolNeeded := (OA_DamperCmd >= 100.0) AND (MixedAirTemp > SupplyTempSP + 1.0); ELSE EconomizerActive := FALSE; OA_DamperCmd := MinOA_Pct; // Minimum ventilation only MechCoolNeeded := TRUE; // Must use chiller END_IF;

// Return air damper is inverse of outside air RA_DamperCmd := 100.0 - OA_DamperCmd; PrevError := Error; END_FUNCTION_BLOCK

Supply Air Temperature Control

Heating/Cooling Sequence

FUNCTION_BLOCK FB_AHU_TempControl
VAR_INPUT
    Enable         : BOOL;
    SupplyTemp     : REAL;       // °C actual
    SupplyTempSP   : REAL;       // °C setpoint
    EconCoolOK     : BOOL;       // Economizer handling cooling
END_VAR
VAR_OUTPUT
    HeatValveCmd   : REAL;       // 0-100% hot water valve
    CoolValveCmd   : REAL;       // 0-100% chilled water valve
    Heating        : BOOL;
    Cooling        : BOOL;
END_VAR
VAR
    Error          : REAL;
    Deadband       : REAL := 1.0;  // °C — no action zone
    HeatPID_I      : REAL := 0.0;
    CoolPID_I      : REAL := 0.0;
    Kp_Heat        : REAL := 8.0;
    Ki_Heat        : REAL := 0.5;
    Kp_Cool        : REAL := 6.0;
    Ki_Cool        : REAL := 0.4;
END_VAR

IF NOT Enable THEN HeatValveCmd := 0.0; CoolValveCmd := 0.0; RETURN; END_IF;

Error := SupplyTempSP - SupplyTemp;

// ── Heating mode (supply too cold) ── IF Error > Deadband THEN Heating := TRUE; Cooling := FALSE; CoolValveCmd := 0.0; CoolPID_I := 0.0; HeatPID_I := HeatPID_I + (Error * Ki_Heat); IF HeatPID_I > 60.0 THEN HeatPID_I := 60.0; END_IF; IF HeatPID_I < 0.0 THEN HeatPID_I := 0.0; END_IF; HeatValveCmd := (Error * Kp_Heat) + HeatPID_I; IF HeatValveCmd > 100.0 THEN HeatValveCmd := 100.0; END_IF; IF HeatValveCmd < 0.0 THEN HeatValveCmd := 0.0; END_IF;

// ── Cooling mode (supply too warm) ── ELSIF Error < -Deadband AND NOT EconCoolOK THEN Cooling := TRUE; Heating := FALSE; HeatValveCmd := 0.0; HeatPID_I := 0.0; CoolPID_I := CoolPID_I + (ABS(Error) * Ki_Cool); IF CoolPID_I > 60.0 THEN CoolPID_I := 60.0; END_IF; IF CoolPID_I < 0.0 THEN CoolPID_I := 0.0; END_IF; CoolValveCmd := (ABS(Error) * Kp_Cool) + CoolPID_I; IF CoolValveCmd > 100.0 THEN CoolValveCmd := 100.0; END_IF; IF CoolValveCmd < 0.0 THEN CoolValveCmd := 0.0; END_IF;

// ── Deadband (no action) ── ELSE Heating := FALSE; Cooling := FALSE; HeatValveCmd := 0.0; CoolValveCmd := 0.0; HeatPID_I := 0.0; CoolPID_I := 0.0; END_IF; END_FUNCTION_BLOCK

VAV Zone Control

Variable Air Volume boxes regulate airflow to each zone. Each VAV has a damper and optional reheat coil:

FUNCTION_BLOCK FB_VAV_ZoneControl
VAR_INPUT
    ZoneTemp       : REAL;       // °C actual
    ZoneTempSP     : REAL;       // °C setpoint
    CO2_Level      : REAL;       // ppm
    Occupied       : BOOL;       // Occupancy sensor
    MinCFM         : REAL;       // Minimum airflow
    MaxCFM         : REAL;       // Maximum airflow
END_VAR
VAR_OUTPUT
    DamperCmd      : REAL;       // 0-100%
    ReheatCmd      : REAL;       // 0-100%
    AirflowSP      : REAL;      // CFM setpoint
    NeedsCooling   : BOOL;
    NeedsHeating   : BOOL;
END_VAR
VAR
    Error          : REAL;
    Deadband       : REAL := 0.5;   // °C
    UnoccSetpoint  : REAL := 26.0;  // °C — unoccupied cooling SP
    UnoccHeatSP    : REAL := 18.0;  // °C — unoccupied heating SP
    CO2_Setpoint   : REAL := 800.0; // ppm target
    ActiveSP       : REAL;
    CO2_Ventilation: REAL;
END_VAR

// ── Determine active setpoint ── IF Occupied THEN ActiveSP := ZoneTempSP; ELSE // Setback during unoccupied hours IF ZoneTemp > UnoccSetpoint THEN ActiveSP := UnoccSetpoint; ELSIF ZoneTemp < UnoccHeatSP THEN ActiveSP := UnoccHeatSP; ELSE // Within unoccupied range — no action DamperCmd := 0.0; ReheatCmd := 0.0; AirflowSP := 0.0; NeedsCooling := FALSE; NeedsHeating := FALSE; RETURN; END_IF; END_IF;

Error := ActiveSP - ZoneTemp;

// ── Cooling: increase airflow ── IF Error < -Deadband THEN NeedsCooling := TRUE; NeedsHeating := FALSE; ReheatCmd := 0.0; // Scale damper proportionally to error AirflowSP := MinCFM + (ABS(Error) / 5.0) * (MaxCFM - MinCFM); IF AirflowSP > MaxCFM THEN AirflowSP := MaxCFM; END_IF; DamperCmd := (AirflowSP / MaxCFM) * 100.0;

// ── Heating: minimum air + reheat ── ELSIF Error > Deadband THEN NeedsCooling := FALSE; NeedsHeating := TRUE; AirflowSP := MinCFM; DamperCmd := (MinCFM / MaxCFM) * 100.0; ReheatCmd := (Error / 5.0) * 100.0; IF ReheatCmd > 100.0 THEN ReheatCmd := 100.0; END_IF;

// ── Deadband ── ELSE NeedsCooling := FALSE; NeedsHeating := FALSE; AirflowSP := MinCFM; DamperCmd := (MinCFM / MaxCFM) * 100.0; ReheatCmd := 0.0; END_IF;

// ── CO2-based demand ventilation override ── IF Occupied AND CO2_Level > CO2_Setpoint THEN CO2_Ventilation := MinCFM + ((CO2_Level - CO2_Setpoint) / 400.0) * (MaxCFM - MinCFM); IF CO2_Ventilation > AirflowSP THEN AirflowSP := CO2_Ventilation; DamperCmd := (AirflowSP / MaxCFM) * 100.0; END_IF; END_IF; END_FUNCTION_BLOCK

Chiller Plant Sequencing

Lead/Lag Chiller Staging

FUNCTION_BLOCK FB_ChillerSequencer
VAR_INPUT
    Enable          : BOOL;
    CoolingLoad     : REAL;      // 0-100% building cooling demand
    CHWST           : REAL;      // °C Chilled Water Supply Temp
    CHWST_SP        : REAL;      // °C setpoint (typically 6-7°C)
    Chiller1_Avail  : BOOL;
    Chiller2_Avail  : BOOL;
    Chiller3_Avail  : BOOL;
END_VAR
VAR_OUTPUT
    Chiller1_Run    : BOOL;
    Chiller2_Run    : BOOL;
    Chiller3_Run    : BOOL;
    ActiveChillers  : INT;
    CHWP1_Run       : BOOL;      // Chilled water pump
    CHWP2_Run       : BOOL;
    CHWP3_Run       : BOOL;
END_VAR
VAR
    StageUpThreshold : REAL := 85.0;   // % load to add chiller
    StageDownThreshold: REAL := 30.0;  // % load to remove chiller
    StageUpTimer    : TON;
    StageDownTimer  : TON;
    StageUpDelay    : TIME := T#10m;   // Wait 10 min before staging
    StageDownDelay  : TIME := T#15m;
    MinRunTime      : TON;
    MinRunDuration  : TIME := T#20m;   // Minimum run before stopping
END_VAR

IF NOT Enable THEN Chiller1_Run := FALSE; Chiller2_Run := FALSE; Chiller3_Run := FALSE; ActiveChillers := 0; RETURN; END_IF;

// ── Stage up logic ── StageUpTimer(IN := CoolingLoad > StageUpThreshold, PT := StageUpDelay); IF StageUpTimer.Q AND ActiveChillers < 3 THEN IF NOT Chiller1_Run AND Chiller1_Avail THEN Chiller1_Run := TRUE; ELSIF NOT Chiller2_Run AND Chiller2_Avail THEN Chiller2_Run := TRUE; ELSIF NOT Chiller3_Run AND Chiller3_Avail THEN Chiller3_Run := TRUE; END_IF; StageUpTimer(IN := FALSE, PT := StageUpDelay); END_IF;

// ── Stage down logic ── StageDownTimer(IN := CoolingLoad < StageDownThreshold, PT := StageDownDelay); IF StageDownTimer.Q AND ActiveChillers > 1 THEN IF Chiller3_Run THEN Chiller3_Run := FALSE; ELSIF Chiller2_Run THEN Chiller2_Run := FALSE; END_IF; StageDownTimer(IN := FALSE, PT := StageDownDelay); END_IF;

// ── Always run at least one chiller when enabled ── IF NOT Chiller1_Run AND NOT Chiller2_Run AND NOT Chiller3_Run THEN IF Chiller1_Avail THEN Chiller1_Run := TRUE; ELSIF Chiller2_Avail THEN Chiller2_Run := TRUE; ELSIF Chiller3_Avail THEN Chiller3_Run := TRUE; END_IF; END_IF;

// Count active and match pumps ActiveChillers := 0; IF Chiller1_Run THEN ActiveChillers := ActiveChillers + 1; END_IF; IF Chiller2_Run THEN ActiveChillers := ActiveChillers + 1; END_IF; IF Chiller3_Run THEN ActiveChillers := ActiveChillers + 1; END_IF;

// One pump per chiller CHWP1_Run := Chiller1_Run; CHWP2_Run := Chiller2_Run; CHWP3_Run := Chiller3_Run; END_FUNCTION_BLOCK

Occupancy Scheduling

FUNCTION_BLOCK FB_OccupancyScheduler
VAR_INPUT
    CurrentHour    : INT;        // 0-23
    CurrentDay     : INT;        // 1=Mon, 7=Sun
    OccupancySensor: BOOL;       // PIR or CO2-based
    OverrideOn     : BOOL;       // Manual override button
    OverrideDuration: TIME := T#2h;
END_VAR
VAR_OUTPUT
    IsOccupied     : BOOL;
    TempSetpoint   : REAL;       // Active setpoint
    FanMode        : INT;        // 0=Off, 1=Low, 2=Auto
END_VAR
VAR
    ScheduleOccupied : BOOL;
    OverrideTimer    : TON;
    OverrideActive   : BOOL := FALSE;
    
    OccStartHour   : INT := 7;   // 7:00 AM
    OccEndHour     : INT := 19;  // 7:00 PM
    OccTempSP      : REAL := 22.0;
    UnoccTempSP    : REAL := 28.0;  // Cooling setback
    UnoccHeatSP    : REAL := 16.0;  // Heating setback
END_VAR

// ── Schedule-based occupancy (Mon-Fri, 7AM-7PM) ── ScheduleOccupied := (CurrentDay >= 1 AND CurrentDay <= 5) AND (CurrentHour >= OccStartHour AND CurrentHour < OccEndHour);

// ── Override timer ── IF OverrideOn AND NOT OverrideActive THEN OverrideActive := TRUE; END_IF; OverrideTimer(IN := OverrideActive, PT := OverrideDuration); IF OverrideTimer.Q THEN OverrideActive := FALSE; OverrideTimer(IN := FALSE, PT := OverrideDuration); END_IF;

// ── Final occupancy decision ── IsOccupied := ScheduleOccupied OR OccupancySensor OR OverrideActive;

// ── Setpoints and fan mode ── IF IsOccupied THEN TempSetpoint := OccTempSP; FanMode := 2; // Auto ELSE TempSetpoint := UnoccTempSP; FanMode := 0; // Off (or minimum ventilation) END_IF; END_FUNCTION_BLOCK

Freeze Protection

Critical safety logic to prevent coil freeze-up in cold climates:

FUNCTION_BLOCK FB_FreezeProtection
VAR_INPUT
    MixedAirTemp   : REAL;       // °C after mixing dampers
    SupplyTemp     : REAL;       // °C after coils
    OutdoorTemp    : REAL;       // °C
    CoilExitTemp   : REAL;       // °C leaving water temp
END_VAR
VAR_OUTPUT
    FreezeAlarm    : BOOL;
    CloseOA_Damper : BOOL;
    OpenHeatValve  : BOOL;
    ShutdownAHU    : BOOL;
END_VAR
VAR
    FreezeThreshold : REAL := 3.0;   // °C — warning
    CriticalThreshold: REAL := 1.0;  // °C — emergency
    FreezeTimer    : TON;
END_VAR

// ── Level 1: Warning ── FreezeAlarm := (MixedAirTemp < FreezeThreshold) OR (CoilExitTemp < FreezeThreshold);

// ── Level 2: Protective action ── IF MixedAirTemp < FreezeThreshold OR CoilExitTemp < FreezeThreshold THEN CloseOA_Damper := TRUE; // Close outside air OpenHeatValve := TRUE; // Full heat to prevent freeze END_IF;

// ── Level 3: Emergency shutdown ── FreezeTimer(IN := MixedAirTemp < CriticalThreshold, PT := T#30s); IF FreezeTimer.Q THEN ShutdownAHU := TRUE; // Stop fans, close all dampers CloseOA_Damper := TRUE; OpenHeatValve := TRUE; // Keep heat on to thaw END_IF;

// ── Reset when safe ── IF MixedAirTemp > (FreezeThreshold + 3.0) AND CoilExitTemp > (FreezeThreshold + 3.0) THEN CloseOA_Damper := FALSE; OpenHeatValve := FALSE; FreezeTimer(IN := FALSE, PT := T#0s); END_IF; END_FUNCTION_BLOCK

Complete AHU Main Program

PROGRAM AHU_Control
VAR
    Economizer     : FB_AHU_Economizer;
    TempControl    : FB_AHU_TempControl;
    FreezeGuard    : FB_FreezeProtection;
    Scheduler      : FB_OccupancyScheduler;
    ChillerPlant   : FB_ChillerSequencer;
    
    Zones          : ARRAY[1..4] OF FB_VAV_ZoneControl;
    ZoneData       : ARRAY[1..4] OF ZoneData;
    AHU            : AHU_Data;
    
    // Calculated
    TotalCoolingDemand : REAL;
    ZonesCooling   : INT := 0;
END_VAR

// ── Scheduling ── Scheduler(CurrentHour := 14, CurrentDay := 3);

// ── Economizer ── Economizer( Enable := AHU.SystemEnabled, OutdoorTemp := AHU.OutdoorTemp, ReturnTemp := AHU.ReturnTemp, MixedAirTemp := AHU.MixedAirTemp, SupplyTempSP := AHU.SupplyTempSP );

AHU.OA_DamperPos := Economizer.OA_DamperCmd; AHU.RA_DamperPos := Economizer.RA_DamperCmd;

// ── Supply air temp control ── TempControl( Enable := AHU.SystemEnabled, SupplyTemp := AHU.SupplyTemp, SupplyTempSP := AHU.SupplyTempSP, EconCoolOK := Economizer.EconomizerActive AND NOT Economizer.MechCoolNeeded );

AHU.HeatValvePos := TempControl.HeatValveCmd; AHU.CoolValvePos := TempControl.CoolValveCmd;

// ── Freeze protection ── FreezeGuard( MixedAirTemp := AHU.MixedAirTemp, SupplyTemp := AHU.SupplyTemp, OutdoorTemp := AHU.OutdoorTemp, CoilExitTemp := AHU.SupplyTemp );

IF FreezeGuard.ShutdownAHU THEN AHU.SystemEnabled := FALSE; END_IF;

// ── VAV zones ── ZonesCooling := 0; FOR i := 1 TO 4 DO Zonesi; ZoneData[i].DamperPosition := Zones[i].DamperCmd; ZoneData[i].ReheatValve := Zones[i].ReheatCmd; IF Zones[i].NeedsCooling THEN ZonesCooling := ZonesCooling + 1; END_IF; END_FOR;

// ── Chiller staging from zone demand ── TotalCoolingDemand := INT_TO_REAL(ZonesCooling) / 4.0 * 100.0; ChillerPlant( Enable := AHU.SystemEnabled, CoolingLoad := TotalCoolingDemand, CHWST := 7.0, CHWST_SP := 6.5 ); END_PROGRAM

Energy Optimization Tips

1. Supply Air Temperature Reset

Raise supply air temp when cooling demand is low — saves chiller energy:

// Reset SAT from 12°C (full cooling) to 16°C (light cooling)
IF TotalCoolingDemand < 30.0 THEN
    AHU.SupplyTempSP := 16.0;
ELSIF TotalCoolingDemand < 60.0 THEN
    AHU.SupplyTempSP := 14.0;
ELSE
    AHU.SupplyTempSP := 12.0;
END_IF;

2. Demand-Controlled Ventilation (DCV)

Use CO2 sensors to reduce outside air when spaces are lightly occupied — saves heating/cooling energy on ventilation air.

3. Optimal Start

Pre-condition the building before occupancy based on outdoor temperature — start earlier in extreme weather, later in mild conditions.

Summary

HVAC building automation in Structured Text combines PID control for temperature regulation, economizer logic for free cooling, VAV zone control for per-room comfort, chiller sequencing for plant efficiency, and occupancy scheduling for energy savings. The modular FB approach — one block per subsystem — lets you scale from a single AHU to a campus of buildings. Critical safety features like freeze protection must always override comfort logic. When done well, a BAS reduces energy consumption 20-40% while maintaining occupant comfort.

Tuning: Temperature and Pressure Loops Want Opposite Gains

The commonest tuning mistake is copying gains between loops that share only a data type. Kp is percent output per engineering unit of error, so a °C loop and a pascal-based static loop are two orders of magnitude apart: duct static setpoints commonly sit near 250–375 Pa and errors arrive in tens of pascals, while a discharge-air loop sees errors of a couple of degrees. Take the gain that suits the chilled water valve, drop it into the static loop, and the first gust slams the VFD to 100%.

A fan answers in seconds; a coil carries a minute or two of lag plus a 60–90 s valve stroke. Pressure loops want low gain and integral time in tens of seconds; since fan pressure rises roughly with the square of speed, the process gain at 85% speed is well above the gain at 40%, so tune where the gain is highest — at the top of the range — and the loop stays stable everywhere below it. Temperature loops want the reverse: real gain, integral in minutes, derivative at zero.

The trap that surfaces a year later is scan-rate dependence: Integral := Integral + Error * Ki has no time in it, so it is per-scan. Move that POU from a 100 ms task to a 1 s task and the integral action in every loop in the building runs ten times slower.

FUNCTION_BLOCK FB_HVAC_PI
VAR_INPUT
    Enable  : BOOL;
    PV      : REAL;           // measured value
    SP      : REAL;           // setpoint, same engineering units as PV
    Kp      : REAL;           // % output per engineering unit of error
    Ti      : REAL;           // integral time in SECONDS (0.0 = P-only)
    dt      : REAL;           // ACTUAL loop period in seconds
    OutMin  : REAL := 0.0;
    OutMax  : REAL := 100.0;
    Reverse : BOOL := TRUE;   // TRUE = output falls as PV rises
                              //   (heating valve, supply fan on static pressure)
    Track   : REAL := 0.0;    // output held while disabled
END_VAR
VAR_OUTPUT
    Out       : REAL;
    Saturated : BOOL;
END_VAR
VAR
    Err   : REAL;
    ITerm : REAL := 0.0;
    Raw   : REAL;
END_VAR

IF Reverse THEN Err := SP - PV; ELSE Err := PV - SP; // cooling valve: opens as PV climbs above SP END_IF;

IF NOT Enable OR dt <= 0.0 THEN Out := Track; // Preload the integral to Track MINUS the proportional term, so the first // enabled scan resolves to exactly Track. Setting ITerm := Track instead // leaves a step of Kp*Err at the moment of re-enable. ITerm := Track - (Kp * Err); Saturated := FALSE; RETURN; END_IF;

IF Ti > 0.0 THEN ITerm := ITerm + (Kp Err dt / Ti); END_IF;

Raw := (Kp * Err) + ITerm;

// Integrator clamping by back-calculation: the integral is forced to the exact // value that puts the output on its limit, so the loop leaves that limit on the // first scan the error reverses instead of unwinding for minutes. IF Raw > OutMax THEN Out := OutMax; ITerm := OutMax - (Kp * Err); Saturated := TRUE; ELSIF Raw < OutMin THEN Out := OutMin; ITerm := OutMin - (Kp * Err); Saturated := TRUE; ELSE Out := Raw; Saturated := FALSE; END_IF; END_FUNCTION_BLOCK

// One block, two loops that could not be tuned more differently. Starting
// points only. Call both from a 1-5 s task: sampling ten times a second
// against a two-minute process just integrates sensor noise.
DAT_Loop( Enable := AHU.SystemEnabled, PV := AHU.SupplyTemp, SP := AHU.SupplyTempSP,
          Kp := 12.0, Ti := 180.0, dt := 1.0, Reverse := FALSE );   // %/°C,  3 min
SP_Loop(  Enable := AHU.SystemEnabled, PV := DuctStatic_Pa, SP := 300.0,
          Kp := 0.12, Ti := 45.0,  dt := 1.0, Reverse := TRUE  );   // %/Pa, 45 s

Economizer Changeover: The Failure Is Mechanical

Near changeover, outdoor and return readings are by definition close, so 1 °C of sensor drift flips the decision every few minutes and the unit ping-pongs all afternoon. Add a deadband and five minutes minimum in state. Enthalpy changeover reads better on paper and worse in the field: humidity sensors drift and nobody recalibrates them, so a fixed dry-bulb high limit — commonly 18–24 °C by climate zone in the ASHRAE 90.1 tables — fails safer.

A stuck damper hides: supply temperature control simply opens the chilled water valve further. Prove it — with OA at 100% and a real outdoor-to-return spread, mixed air temperature must migrate toward outdoor air; if it stays near return, the linkage is broken or the actuator has lost power. The two streams also do not mix within a few feet of the dampers, so use an averaging mixed-air element: a single-point sensor reads whichever stream it sits in, and nuisance-trips freeze protection on mild days.

Staging Without Short-Cycling

Minimum on time, minimum off time, and minimum time between starts are three separate constraints; the start-to-start limit is the one that bounds winding heating from inrush current. Stage on accumulated deficit, not instantaneous load: a percentage crosses a threshold on any transient, an integrator only on a sustained shortfall.

IF StageChangeAllowed THEN
    DegMin := DegMin + ((CHWST - CHWST_SP) * dt / 60.0);   // °C·min of deficit
    IF DegMin < 0.0 THEN DegMin := 0.0; END_IF;            // never bank credit
    IF (DegMin > 8.0) AND NextUnit_ReadyToStart AND LastStage_RunProof THEN
        StageUp := TRUE;
        DegMin  := 0.0;      // clear the accumulator on any stage change --
    END_IF;                  // the stage-down branch must do the same
ELSE
    DegMin := 0.0;
END_IF;

Most chillers and compressors run their own anti-recycle timer and silently ignore a start command while it times. A sequencer watching only its command bit concludes stage 2 failed and stages up onto machine 3, so gate each stage on the previous one's run proof and ready-to-start status.

VAV Boxes Are Cascade Loops

A pressure-independent box is two loops: zone temperature resets an airflow setpoint, a faster inner loop holds it. Keep the inner five times faster or they fight and the box hunts. Flow comes from velocity pressure, which varies with velocity squared, so near minimum flow the signal is down in the transducer's noise — hold damper position instead of chasing it. Controllers turn that pressure into flow through a box-specific K-factor — CFM = K × √ΔP — so reuse one on a different box size and every airflow reading and every balancing figure is wrong.

BACnet: The Priority Array Eats Schedules

A write to a BACnet commandable object claims one of 16 priority slots, not a value: 1–2 are life safety, 6 is reserved for minimum on/off, 8 is conventionally manual operator. Override a fan at priority 8, never write NULL to release it, and months later the schedule is silently outranked. Flag any point whose commanded value and Present_Value disagree beyond the actuator's stroke time. Relinquish_Default is where the object lands once every slot has been relinquished, so a factory zero means the fan ends up off the moment the last command is released. And Max_Master ships at 127 on nearly every MS/TP device, leaving the token polling addresses nobody installed.

Schedules, Setback, and the Morning Ramp

BACnet numbers Monday as 1 through Sunday as 7 while many C-derived RTC libraries return Sunday as 0; mix them and the weekday schedule lands on the wrong day. Morning warm-up must lock the economizer out and hold outside air closed: ventilation is not required before occupancy, and pulling in cold outdoor air during recovery is pure loss. Stagger warm-up too: thirty air handlers leaving setback at once is a demand peak you pay for all month.