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.

What a building automation system controls

Buildings use PLCs and DDC (direct digital control) controllers to run heating, ventilation and air conditioning. A typical building automation system (BAS) controls an air handling unit (AHU), the VAV boxes that feed each zone, exhaust and ventilation fans, and the chiller and boiler plant behind them:

                    ┌─── 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

Each zone and the AHU get their own structure, with engineering units in the comments: °C for temperatures, %RH, ppm CO2, CFM for airflow and Pa for filter differential pressure. Damper, valve and fan commands are all 0-100%.

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 and economizer

When conditions allow, the economizer cools with free outdoor air and saves chiller energy.

Economizer logic

Here the economizer is allowed when outdoor air is cooler than return air, below the 21 °C high limit and under 80 %RH. A PI loop then moves the outside air damper (centred at 50%, never below the 15% ventilation minimum) to hold mixed air at the supply setpoint. The return damper always takes the complement of the outside air damper. If the outside air damper is fully open and mixed air is still more than 1 °C above setpoint, the block asks for mechanical cooling:

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 and cooling sequence

Heating and cooling never run together. The block heats when supply air is more than the 1 °C deadband below setpoint, cools when it is more than 1 °C above and the economizer cannot carry the load, and closes both valves inside the deadband. Each mode resets the other's integral when it takes over:

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 (VAV) boxes regulate airflow to each zone. Each box has a damper and, optionally, a reheat coil. When the zone is unoccupied the block sets back to 26 °C cooling and 18 °C heating and does nothing in between. When it is occupied, CO2 above 800 ppm can push the airflow above what temperature alone asks for:

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

The sequencer adds a chiller after the load has stayed above 85% for 10 minutes and removes one after it has stayed below 30% for 15 minutes. It always keeps at least one running, and each chiller has its own chilled water pump:

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

The schedule is occupied Monday to Friday, 7:00 to 19:00. An occupancy sensor or a two-hour manual override also counts as occupied:

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

In cold climates this logic stops the coils freezing up, and it must always override the comfort logic. It works in three levels. Below 3 °C at the mixed air or coil exit it raises an alarm, closes the outside air damper and opens the heating valve fully. If mixed air stays below 1 °C for 30 seconds, it shuts the AHU down and keeps the heat on to thaw the coil. The damper and valve release only when both readings are back above 6 °C:

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

The main program calls each block once per scan and wires their outputs together. Freeze protection can disable the whole AHU, and chiller staging follows the share of zones that are calling for cooling:

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
    Zones[i](
        ZoneTemp   := ZoneData[i].ActualTemp,
        ZoneTempSP := Scheduler.TempSetpoint,
        CO2_Level  := ZoneData[i].CO2_Level,
        Occupied   := Scheduler.IsOccupied,
        MinCFM     := ZoneData[i].MinCFM,
        MaxCFM     := ZoneData[i].MaxCFM
    );
    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

Saving energy

1. Supply air temperature reset

Raise the supply air temperature when cooling demand is low. The chiller then has less work to do:

// 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 cut outside air when spaces are lightly occupied. You then spend less heating and cooling energy on ventilation air.

3. Optimal start

Pre-condition the building before occupancy based on outdoor temperature. Start earlier in extreme weather and later in mild conditions.

How the blocks fit together

With one function block per subsystem (economizer, supply air temperature, VAV zones, chiller staging, scheduling) the same code scales from a single AHU to a campus of buildings. Freeze protection must always override comfort logic. When done well, a BAS reduces energy consumption 20-40% while keeping occupants comfortable.

The simple loops above are enough to show the sequences. The sections below cover what goes wrong when you commission them on a real building.

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 an integral time in tens of seconds. Because 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. The block below fixes that by taking the integral time in seconds and the actual loop period dt, and it clamps the integrator so the output leaves a limit as soon as the error reverses:

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 a five-minute minimum time in each state. Enthalpy changeover reads better on paper and worse in the field, because humidity sensors drift and nobody recalibrates them. 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 itself, because supply temperature control simply opens the chilled water valve further. To prove the damper, command OA to 100% when there is a real spread between outdoor and return temperature. Mixed air temperature must then 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, while an integrator crosses it only on a sustained shortfall. This snippet accumulates degree-minutes of chilled water deficit and allows a stage-up once the total passes 8 °C·min:

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, and a faster inner loop holds it. Keep the inner loop five times faster or the two 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 there instead of chasing it. Controllers turn that pressure into flow through a box-specific K-factor (CFM = K × √ΔP). 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 does not simply set a value. It claims one of 16 priority slots: 1–2 are life safety, 6 is reserved for minimum on/off, and 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 the two 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 the warm-up as well: thirty air handlers leaving setback at once make a demand peak you pay for all month.