Advanced
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.