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.
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_TYPETYPE 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_VARIF 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_VARIF 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_VARIF 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_VARIF 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.