AHU-0017 — Economizer not operational when favorable
| Status | verified — engine e2ff2f8, cxf:fnv1a128:6bd93b0a587698d63e6263031a029829, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 1 |
| Category | CRITICAL_WASTE |
| Confidence | HIGH |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | CLU-03 |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0009, AHU-0011, AHU-0034 |
| Playbooks | economizer-failure |
| Source | HVAC FDD Reference v1.0 §9, AHU-0017; PNNL-27338 §3; PNNL EEM-06; Cowan 2004 (54% of RTUs) |
| Operating states | OS 4 (mechanical cooling) |
Preconditions (host-enforced): Supply fan running. The outdoor/return comparison must be evaluable: |oat - rat| >= TMIN (PNNL-27338 uses 5 °F for its outdoor-air-fraction work), since two sensors reading within their combined error of each other cannot establish which air is cooler. Hosts also gate on OAT sensor quality — a sensor reading high produces this fault’s signature with the economizer control working correctly (diagnosis 4). When either gate is unmet the verdict is NO_EVAL, not healthy.
Points: oat, rat, clg_vlv_cmd, oa_dmpr_cmd
Outputs:
yFault— True while outdoor conditions have favored economizing, mechanical cooling has run, and the OA damper has stayed below econ_damper_threshold, all continuously for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
econ_type_is_ddb | True | bool | isDDB.k | Economizer changeover type — true = differential dry-bulb (compare oat to rat), false = fixed high-limit dry-bulb (compare oat to econ_hl_temp) |
econ_hl_temp | 21.0 | °C | hlConst.k | Fixed high-limit changeover temperature, used only when econ_type_is_ddb is false |
temp_deadband | 1.0 | °C | ddbFav.t, hlFav.t | Margin the favorable comparison must clear before economizing counts as worthwhile; binds both changeover branches |
cooling_enabled_threshold | 10.0 | % | clgOn.t | Cooling valve command above which mechanical cooling counts as active |
econ_damper_threshold | 25.0 | % | dmprLow.t | OA damper command below which the damper counts as parked at minimum position |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (30 min) |
Description
Outdoor air is cool enough to do the cooling for free, but the outdoor-air damper sits at or near its minimum position while the cooling coil runs. Every kilowatt the compressor or chiller spends in that state buys cooling the economizer was standing by to provide at fan power alone. Common and cheap to fix: Cowan’s 2004 field survey found 54% of RTU economizers carrying at least one fault, most often a disconnected damper linkage. Trigger rule for CLU-03 (Economizer Failure) — fix it first, since a damper pinned at minimum also fails the mixed-air and OA-fraction tests in AHU-0009 and AHU-0011.
Detection Logic
econ_favorable = (rat - oat) > temp_deadband when econ_type_is_ddb
= (econ_hl_temp - oat) > temp_deadband otherwise
yFault = econ_favorable
AND clg_vlv_cmd > cooling_enabled_threshold
AND oa_dmpr_cmd < econ_damper_threshold
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
Both changeover branches compute on every tick and favSel (Logical.Switch,
y = u2 ? u1 : u3) picks one: isDDB true (the default) selects the
differential branch (rat - oat), false the fixed high-limit branch
(econ_hl_temp - oat). Thresholding the difference rather than the raw
temperatures is what lets one temp_deadband serve both branches. All three
comparisons are strict, so a damper parked at exactly 25%, a cooling valve at
exactly 10%, or a gap of exactly 1.0 °C does not trip the rule. persist
requires 30 minutes of continuous violation — long enough to ride out damper
strokes and changeover transitions — and any interruption restarts the timer;
delayOnInit = true makes a violation already present at engine start wait out
the full window.
Possible Diagnoses
- Economizer control sequence disabled or misconfigured in the BAS
- OA damper stuck at minimum position (linkage disconnected, blades bound)
- Damper actuator failure — no power, no air, or a burnt-out motor
- OAT sensor error, reading higher than actual, which locks out changeover
- Economizer lockout active when it should not be (seasonal or manual)
Energy Impact
CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. The waste is the
mechanical cooling that free cooling would have displaced, readable straight
off the valve command: waste_kw = clg_vlv_cmd/100 × ahu_clg_capacity_kw.
Correcting economizer operation saves 5–20% of cooling energy (PNNL-27338 §3;
PNNL EEM-06, OA damper faults and controls). Cooling-dominant, and worth most
in mild shoulder-season weather — exactly when the damper should be modulating.
Emissions Impact
Scope 2, DIRECT_EMISSIONS, HIGH confidence; typical 1,500–8,000 kg CO₂e/yr. The displaced energy is electric compressor or chiller work, so the entire impact lands in purchased electricity. Free-cooling hours cluster in mild daytime and overnight weather, when the marginal generator differs sharply from the annual average — use the marginal operating emissions rate (MOER), not an average grid factor.
Deviations
- The reference’s
econ_typeenum (DDB|HL_DB, defaultDDB) is carried here as the booleanecon_type_is_ddbdriving aLogical.Switch. Two values do not earn an enum, and a boolean is retunable throughset_paramon a deployed rule; enthalpy changeover needs its own rule, not a third value. - The evaluability gate
|oat - rat| >= TMINand the OS-4 (mechanical cooling) operating-state restriction are declared as preconditions for host enforcement rather than encoded in the block graph — gating and data quality stay out of the rule, which computes the fault condition given valid data. - All three comparisons are strict (
>,>,<); the reference does not specify boundary behavior, so the library’s strict convention applies. temp_deadbandis one card parameter bound to two CXF paths (ddbFav.t,hlFav.t), matching the reference’s single deadband. Hosts must set both paths together, or flippingecon_type_is_ddbsilently changes the deadband.persist.delayOnInit = true(Modelica/CDL default isfalse), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.
Notes
With default parameters the shipped vectors exercise only the DDB branch —
vectors/v1 stages inputs, not parameters, so hlConst, hlGap, and hlFav
are structurally verified but never reach yFault through u3. A host that
sets econ_type_is_ddb = false should commission that path itself.
econ_hl_temp defaults to 21 °C, near ASHRAE 90.1’s 70 °F high limit for
climate zones 4A–5A. Zones 1A–3A allow 75 °F (23.9 °C) and zones 5B–8 use
65 °F (18.3 °C) — retune per the playbook rather than accepting the default in
a climate it does not fit.
Test Vectors
9 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
favorable_damper_open | Reference vector: OAT 10 °C against RAT 22 °C with cooling at 40% — economizer is favorable and the damper is at 80%, so free cooling is being used |
favorable_damper_at_min | Reference vector: same favorable conditions and cooling call, but the damper sits at 15% — mechanical cooling with the economizer shut; alarms after alarm_delay (1800 s) |
not_favorable | Reference vector: OAT 30 °C is hotter than the 22 °C return air — economizing would add load, so a closed damper is correct |
no_cooling_call | Reference vector: favorable outdoor air but the cooling valve is closed — no mechanical cooling to displace, so the minimum-position damper is correct |
damper_exactly_at_threshold | Edge case: damper parked exactly at econ_damper_threshold (25%) under favorable conditions with cooling — the strict < keeps it out of the alarm |
cooling_exactly_at_threshold | Edge case: cooling valve exactly at cooling_enabled_threshold (10%) with the damper at minimum — the strict > means the coil does not count as cooling |
deadband_edge_then_favorable | Edge case: rat − oat sits exactly on temp_deadband (1.0 °C) — no alarm; at t=1200 the return air rises to 22.5 °C, opening the gap to 1.5 °C, and the alarm follows 1800 s later |
damper_opens_before_delay | Transient: the economizer is shut against favorable air with cooling running, but the damper drives to 70% at t=1200 — the condition clears before alarm_delay completes, so no alarm |
damper_cycle_resets_persistence | Transient: the damper opens at t=900 and falls back to minimum at t=1200 under an unbroken favorable-plus-cooling condition — persistence restarts from the reclose, so the alarm lands at 3000 s, not 1800 s |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "favorable_damper_open",
"description": "Reference vector: OAT 10 \u00b0C against RAT 22 \u00b0C with cooling at 40% \u2014 economizer is favorable and the damper is at 80%, so free cooling is being used",
"inputs": {
"oat": 10.0,
"rat": 22.0,
"clg_vlv_cmd": 40.0,
"oa_dmpr_cmd": 80.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "favorable_damper_at_min",
"description": "Reference vector: same favorable conditions and cooling call, but the damper sits at 15% \u2014 mechanical cooling with the economizer shut; alarms after alarm_delay (1800 s)",
"inputs": {
"oat": 10.0,
"rat": 22.0,
"clg_vlv_cmd": 40.0,
"oa_dmpr_cmd": 15.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "not_favorable",
"description": "Reference vector: OAT 30 \u00b0C is hotter than the 22 \u00b0C return air \u2014 economizing would add load, so a closed damper is correct",
"inputs": {
"oat": 30.0,
"rat": 22.0,
"clg_vlv_cmd": 40.0,
"oa_dmpr_cmd": 15.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "no_cooling_call",
"description": "Reference vector: favorable outdoor air but the cooling valve is closed \u2014 no mechanical cooling to displace, so the minimum-position damper is correct",
"inputs": {
"oat": 10.0,
"rat": 22.0,
"clg_vlv_cmd": 0.0,
"oa_dmpr_cmd": 15.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "damper_exactly_at_threshold",
"description": "Edge case: damper parked exactly at econ_damper_threshold (25%) under favorable conditions with cooling \u2014 the strict `<` keeps it out of the alarm",
"inputs": {
"oat": 10.0,
"rat": 22.0,
"clg_vlv_cmd": 40.0,
"oa_dmpr_cmd": 25.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "cooling_exactly_at_threshold",
"description": "Edge case: cooling valve exactly at cooling_enabled_threshold (10%) with the damper at minimum \u2014 the strict `>` means the coil does not count as cooling",
"inputs": {
"oat": 10.0,
"rat": 22.0,
"clg_vlv_cmd": 10.0,
"oa_dmpr_cmd": 15.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "deadband_edge_then_favorable",
"description": "Edge case: rat \u2212 oat sits exactly on temp_deadband (1.0 \u00b0C) \u2014 no alarm; at t=1200 the return air rises to 22.5 \u00b0C, opening the gap to 1.5 \u00b0C, and the alarm follows 1800 s later",
"inputs": {
"oat": 21.0,
"rat": [
{
"t": 0,
"value": 22.0
},
{
"t": 1200,
"value": 22.5
}
],
"clg_vlv_cmd": 40.0,
"oa_dmpr_cmd": 15.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2940,
"equals": false
},
{
"output": "yFault",
"from_s": 3060,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "damper_opens_before_delay",
"description": "Transient: the economizer is shut against favorable air with cooling running, but the damper drives to 70% at t=1200 \u2014 the condition clears before alarm_delay completes, so no alarm",
"inputs": {
"oat": 10.0,
"rat": 22.0,
"clg_vlv_cmd": 40.0,
"oa_dmpr_cmd": [
{
"t": 0,
"value": 15.0
},
{
"t": 1200,
"value": 70.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "damper_cycle_resets_persistence",
"description": "Transient: the damper opens at t=900 and falls back to minimum at t=1200 under an unbroken favorable-plus-cooling condition \u2014 persistence restarts from the reclose, so the alarm lands at 3000 s, not 1800 s",
"inputs": {
"oat": 10.0,
"rat": 22.0,
"clg_vlv_cmd": 40.0,
"oa_dmpr_cmd": [
{
"t": 0,
"value": 15.0
},
{
"t": 900,
"value": 70.0
},
{
"t": 1200,
"value": 15.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2940,
"equals": false
},
{
"output": "yFault",
"from_s": 3060,
"to_s": 3600,
"equals": true
}
]
}
]
}