RTU-0004 — Economizer not modulating properly
| Status | verified — engine e2ff2f8, cxf:fnv1a128:ed12779429be89a3c61d6668681918c6, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | CRITICAL_WASTE |
| Confidence | HIGH |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | CLU-03 |
| Suppresses | — |
| Suppressed by | RTU-0003 |
| Related | AHU-0017, RTU-0003, RTU-0005, RTU-0010, RTU-0011 |
| Playbooks | economizer-failure |
| Source | HVAC FDD Reference v1.0 §11, RTU-0004; PNNL-23790 AFDD1/AFDD3; California Title 24 economizer FDD; PNNL EEM-06, EEM-23; Cowan 2004 (54% of RTUs) |
| Operating states | cooling call active |
Preconditions (host-enforced): comp_status gates both branches inside the graph, so the rule is already silent with no mechanical cooling running; a false yFault under a stopped compressor is no verdict, not a healthy economizer. Everything above that stays host-side: mode transitions, morning warm-up, and any period when the damper is under manual override or a commissioning test. The OAT sensor must be trustworthy — a sensor reading high produces branch 1’s signature with the economizer control working exactly as designed (diagnosis 4), which is why RTU-0003 (PNNL’s AFDD0 sensor-consistency check) suppresses this rule while it is active. Damper position is taken from the command, not a feedback signal: a unit whose actuator ignores the command reports the command’s story here and is caught by RTU-0003 or by the playbook’s step 3. When any gate is unmet the verdict is NO_EVAL, not healthy.
Points: oat, oa_dmpr_cmd, comp_status
Outputs:
yFault— True while either economizer fault condition — damper at minimum while free cooling is available, or damper open while the economizer should be locked out — has held continuously with the compressor running for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
econ_lockout_temp | 21.0 | °C | oatLow.t | Outdoor temperature below which the economizer is expected to be modulating open; branch 1 arms below it |
econ_relock_temp | 22.0 | °C | oatHigh.t | Outdoor temperature above which the economizer is expected to be at minimum position; branch 2 arms above it. Equals econ_lockout_temp + the reference’s lockout_deadband (1 °C) — a host moving the lockout must move this parameter with it |
min_oa_margin | 25.0 | % | dmprLow.t, dmprHigh.t | Damper command that separates “parked at ventilation minimum” from “modulating for free cooling”; binds both branches |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (30 min) |
Description
A packaged unit’s economizer has two jobs and this rule watches both of them fail. Branch 1 catches the damper parked at ventilation minimum on a mild day with mechanical cooling running — free cooling standing right there, unused; branch 2 catches 30 °C outdoor air pouring through a damper that should have closed, with the compressor absorbing the difference. The two failures come from different places: stuck-at-minimum is usually mechanical (a popped rod end, a dead actuator, economizing switched off), stuck-open is more often a spring-return actuator that lost its return or a high limit that never locks out. Cowan’s 2004 survey found 54% of RTU economizers carrying at least one fault, and both failures are invisible from a monthly bill.
Detection Logic
fault_1 = oat < econ_lockout_temp AND comp_status AND oa_dmpr_cmd < min_oa_margin
fault_2 = oat > econ_relock_temp AND comp_status AND oa_dmpr_cmd > min_oa_margin
yFault = (fault_1 OR fault_2), sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
comp_status is in the graph rather than in the frontmatter because it is not a
gate on data quality — it is part of the fault definition. Neither branch
describes waste without a compressor running: a damper at minimum on a cool
morning with the unit coasting is a unit that does not need cooling, not a
broken economizer. Between econ_lockout_temp and econ_relock_temp the rule
is deliberately silent — neither temperature test is true in that band, so a
unit changing over at 21.4 °C produces no verdict while its mixed-air loop
settles. That silence is what the reference’s lockout_deadband buys. All four
comparisons are strict, so a damper resting exactly on min_oa_margin trips
neither branch and outdoor air resting exactly on either setpoint arms neither.
persist requires 30 minutes of continuous violation, which rides out a damper
stroke, a changeover, and the minimum-position dwell an economizer holds while
its own loop settles; delayOnInit = true holds that window across a restart.
Possible Diagnoses
- OA damper stuck at minimum — disconnected linkage, failed actuator, bound blades (branch 1, and the most likely one by a wide margin)
- OA damper stuck open — spring return failed, actuator jammed off its seat (branch 2)
- Economizer controller disabled or misconfigured in the unit controller
- OAT sensor reading erroneously high, which locks out changeover while the control sequence works correctly (branch 1’s most common false positive, and the reason RTU-0003 suppresses this rule)
- Economizer high-limit setpoint set too low for the climate zone, so the unit locks out during weather it should be economizing in
Energy Impact
CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. The waste is compressor
work the economizer position made unnecessary, and compressor status is one of
this rule’s own inputs: waste_kw = comp_status × rtu_cooling_kw — under branch
1 the mechanical cooling free cooling would have displaced, under branch 2 the
load the open damper added. The reference’s 5–20% of cooling energy is
consistent with PNNL EEM-06 (OA damper and controls) and EEM-23 (RTU advanced
controls, 3–11% of unit electricity). HIGH confidence: the condition is read
directly from a temperature and two commands with no model in between. Strongly
cooling-dominant, and worth the most in shoulder seasons.
Emissions Impact
Scope 2, DIRECT_EMISSIONS, HIGH confidence; typically 800–5,000 kg CO₂e/yr for a single packaged unit. The wasted energy is compressor electricity, so the whole impact lands in purchased power. Free-cooling hours cluster in mild daytime and overnight weather, so use the marginal operating emissions rate (MOER), not an average grid factor, or the estimate misses by the width of the grid’s daily swing.
Deviations
min_oa_margin’s default is adopted, not transcribed. The reference states both branches in terms of it but omits it from the tunables table. This card adopts 25.0%, chapter 9’s value for a damper parked at minimum (AHU-0017’secon_damper_threshold), so the phrase means the same thing across both economizer rules; the reference’s own vectors (10% versus 75–80%) are decidable at any margin between those. AHU-0030 precedent.- The reference’s
lockout_deadbandis folded into a second absolute threshold. Branch 2 is writtenoat > econ_lockout_temp + lockout_deadband, but a card parameter binds a single CXF path and the block set cannot add two parameters in-graph, so branch 2 compares againstecon_relock_temp= 22.0 °C = 21.0 + 1.0. Moving the lockout means retuning both; settingecon_relock_tempbelowecon_lockout_tempoverlaps the temperature tests into a rule that fires at every damper position but the margin itself. Combined-parameter precedent AHU-0005. min_oa_marginis one card parameter bound to two CXF paths (dmprLow.t,dmprHigh.t), matching the reference’s single margin. Hosts must set both together: split them and a band of damper positions is either tested by neither branch or read as parked at minimum and as open at once. AHU-0025 precedent.- All four comparisons are strict (
<,>,<,>). The reference does not specify boundary behavior and CDL’s Reals family has noGreaterEqual, so the inclusive reading is not expressible. The deviation is measure-zero and it errs toward silence. - The changeover band is a blind spot, by construction. Between 21 and 22 °C neither branch can fire whatever the damper is doing, so an economizer that fails while the weather sits in that 1 °C band reports nothing until the weather moves. Reporting inside the band would alarm on every normal changeover, which is what the deadband exists to prevent.
comp_statusis in-graph; everything else about mode is not. The reference lists “cooling call active” as the operating state andcomp_statusas a term of both equations. The term is implemented; broader gating (unit mode, occupancy, manual override, RTU-0003’s sensor check) stays host-side, as in AHU-0017.- Damper command, not damper feedback. The RTU dictionary carries no damper position feedback point, so an actuator reporting 80% while the blades sit closed is invisible here. That failure belongs to the mixed-air checks (RTU-0003) and to step 3 of the playbook — which is why the two rules are linked by suppression rather than by a shared input.
- Severity 3 (warning), phase 2, method
rule, and the tunable defaults are the reference’s chapter 11 card; its §5.8.3 index corroborates and carries no severity column.g36: null— PNNL/Title 24-derived, not a G36 §5.16.14 clause. 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 restart.
Notes
This is AHU-0017’s fault seen through packaged-unit points — an RTU has a compressor contactor and a fixed dry-bulb high limit where the AHU has a modulating valve and a differential changeover — so both sit in CLU-03 with AHU-0017 as trigger and this card as member.
Retune econ_lockout_temp for the climate zone before trusting the default:
21 °C is near ASHRAE 90.1’s 70 °F fixed high limit for zones 4A–5A, while zones
1A–3A allow 75 °F (23.9 °C) and zones 5B–8 use 65 °F (18.3 °C). A limit set for
the wrong zone produces branch 1’s signature with nothing mechanically wrong
(diagnosis 5, a remote fix). Verify order within CLU-03 is RTU-0003 first,
then this rule, then RTU-0005, whose excess outdoor air is often branch 2 seen
from the airflow side; the economizer-failure
playbook carries the climate-zone table and puts the odds of a sibling unit on
the same roof having the same problem at 30–50%.
Test Vectors
10 scenarios, clock step 300 s over 7200 s.
| Scenario | Description |
|---|---|
economizing_when_favorable | Reference vector: 15 °C outdoor air, damper at 80%, compressor running. Outdoor air is well below the lockout, so branch 1 is armed, but the damper is nowhere near minimum — the economizer is doing its job and the compressor is trimming what free cooling cannot cover. Clear on both branches |
damper_at_minimum_when_favorable | Reference vector: 15 °C outdoor air, damper at 10%, compressor running — the classic stuck-at-minimum economizer. Branch 1 (oat < econ_lockout_temp AND comp_status AND oa_dmpr_cmd < min_oa_margin) is true from t=0, and yFault asserts one alarm_delay later at t=1800 |
locked_out_damper_at_minimum | Reference vector: 30 °C outdoor air, damper at 10%, compressor running. Outdoor air is above the relock temperature, so bringing it in would add load; the damper sitting at its ventilation minimum is exactly right. Clear on both branches |
locked_out_damper_open | Reference vector: 30 °C outdoor air, damper at 75%, compressor running — the economizer failed open, or never locked out, and the compressor is cooling 75% outdoor air on a hot day. Branch 2 (oat > econ_relock_temp AND comp_status AND oa_dmpr_cmd > min_oa_margin) is true from t=0, and yFault asserts at t=1800 |
changeover_band_is_silent | Deadband pin. At 21.5 °C the outdoor air is above econ_lockout_temp and below econ_relock_temp, so neither branch’s temperature test is true and the rule has no opinion about the damper. The damper is parked at minimum for the first hour and wide open for the second — a damper position that would trip branch 1 below 21 °C and branch 2 above 22 °C, and trips neither here |
damper_at_margin_is_silent | Damper boundary pin, both sides. The damper sits exactly on min_oa_margin (25%), which satisfies neither < 25 nor > 25. Outdoor air runs at 15 °C for the first hour (branch 1 armed) and 30 °C for the second (branch 2 armed), and both stay clear |
lockout_setpoints_are_silent | Temperature boundary pin, both sides. First hour: oat exactly 21.0 with the damper at minimum — oat < 21 is false, so branch 1 does not fire on a unit sitting precisely on its lockout. Second hour: oat exactly 22.0 with the damper at 80% — oat > 22 is false, so branch 2 does not fire on a unit sitting precisely on its relock point |
compressor_off_no_verdict | Cooling-call gate. Favorable outdoor air and a damper at minimum, but no compressor running: there is no mechanical cooling being wasted, so there is nothing to report. comp_status gates both branches in-graph, and a false yFault here means the rule has no verdict rather than that the economizer is healthy |
transient_shorter_than_alarm_delay | Transient. The damper drops to minimum at t=600 and returns to 80% at t=1800 — 20 minutes at minimum with the compressor running, the shape of a mixed-air loop resetting or an operator overriding the damper briefly. Branch 1 holds for 1200 s against a 1800 s alarm_delay, so nothing asserts |
damper_recovers_after_alarm | Recovery. The stuck-at-minimum case asserts at t=1800; at t=3600 the linkage is reconnected and the damper strokes to 60%. Branch 1 goes false on that tick and yFault follows immediately — TrueDelay has no off-delay, so a fixed economizer clears the alarm at the next evaluation rather than one alarm_delay later |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 7200
},
"scenarios": [
{
"name": "economizing_when_favorable",
"description": "Reference vector: 15 \u00b0C outdoor air, damper at 80%, compressor running. Outdoor air is well below the lockout, so branch 1 is armed, but the damper is nowhere near minimum \u2014 the economizer is doing its job and the compressor is trimming what free cooling cannot cover. Clear on both branches",
"inputs": {
"oat": 15.0,
"oa_dmpr_cmd": 80.0,
"comp_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "damper_at_minimum_when_favorable",
"description": "Reference vector: 15 \u00b0C outdoor air, damper at 10%, compressor running \u2014 the classic stuck-at-minimum economizer. Branch 1 (oat < econ_lockout_temp AND comp_status AND oa_dmpr_cmd < min_oa_margin) is true from t=0, and yFault asserts one alarm_delay later at t=1800",
"inputs": {
"oat": 15.0,
"oa_dmpr_cmd": 10.0,
"comp_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 1980,
"to_s": 7200,
"equals": true
}
]
},
{
"name": "locked_out_damper_at_minimum",
"description": "Reference vector: 30 \u00b0C outdoor air, damper at 10%, compressor running. Outdoor air is above the relock temperature, so bringing it in would add load; the damper sitting at its ventilation minimum is exactly right. Clear on both branches",
"inputs": {
"oat": 30.0,
"oa_dmpr_cmd": 10.0,
"comp_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "locked_out_damper_open",
"description": "Reference vector: 30 \u00b0C outdoor air, damper at 75%, compressor running \u2014 the economizer failed open, or never locked out, and the compressor is cooling 75% outdoor air on a hot day. Branch 2 (oat > econ_relock_temp AND comp_status AND oa_dmpr_cmd > min_oa_margin) is true from t=0, and yFault asserts at t=1800",
"inputs": {
"oat": 30.0,
"oa_dmpr_cmd": 75.0,
"comp_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 1980,
"to_s": 7200,
"equals": true
}
]
},
{
"name": "changeover_band_is_silent",
"description": "Deadband pin. At 21.5 \u00b0C the outdoor air is above econ_lockout_temp and below econ_relock_temp, so neither branch's temperature test is true and the rule has no opinion about the damper. The damper is parked at minimum for the first hour and wide open for the second \u2014 a damper position that would trip branch 1 below 21 \u00b0C and branch 2 above 22 \u00b0C, and trips neither here",
"inputs": {
"oat": 21.5,
"oa_dmpr_cmd": [
{
"t": 0,
"value": 10.0
},
{
"t": 3600,
"value": 80.0
}
],
"comp_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "damper_at_margin_is_silent",
"description": "Damper boundary pin, both sides. The damper sits exactly on min_oa_margin (25%), which satisfies neither `< 25` nor `> 25`. Outdoor air runs at 15 \u00b0C for the first hour (branch 1 armed) and 30 \u00b0C for the second (branch 2 armed), and both stay clear",
"inputs": {
"oat": [
{
"t": 0,
"value": 15.0
},
{
"t": 3600,
"value": 30.0
}
],
"oa_dmpr_cmd": 25.0,
"comp_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "lockout_setpoints_are_silent",
"description": "Temperature boundary pin, both sides. First hour: oat exactly 21.0 with the damper at minimum \u2014 `oat < 21` is false, so branch 1 does not fire on a unit sitting precisely on its lockout. Second hour: oat exactly 22.0 with the damper at 80% \u2014 `oat > 22` is false, so branch 2 does not fire on a unit sitting precisely on its relock point",
"inputs": {
"oat": [
{
"t": 0,
"value": 21.0
},
{
"t": 3600,
"value": 22.0
}
],
"oa_dmpr_cmd": [
{
"t": 0,
"value": 10.0
},
{
"t": 3600,
"value": 80.0
}
],
"comp_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "compressor_off_no_verdict",
"description": "Cooling-call gate. Favorable outdoor air and a damper at minimum, but no compressor running: there is no mechanical cooling being wasted, so there is nothing to report. comp_status gates both branches in-graph, and a false yFault here means the rule has no verdict rather than that the economizer is healthy",
"inputs": {
"oat": 15.0,
"oa_dmpr_cmd": 10.0,
"comp_status": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "transient_shorter_than_alarm_delay",
"description": "Transient. The damper drops to minimum at t=600 and returns to 80% at t=1800 \u2014 20 minutes at minimum with the compressor running, the shape of a mixed-air loop resetting or an operator overriding the damper briefly. Branch 1 holds for 1200 s against a 1800 s alarm_delay, so nothing asserts",
"inputs": {
"oat": 15.0,
"oa_dmpr_cmd": [
{
"t": 0,
"value": 80.0
},
{
"t": 600,
"value": 10.0
},
{
"t": 1800,
"value": 80.0
}
],
"comp_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "damper_recovers_after_alarm",
"description": "Recovery. The stuck-at-minimum case asserts at t=1800; at t=3600 the linkage is reconnected and the damper strokes to 60%. Branch 1 goes false on that tick and yFault follows immediately \u2014 TrueDelay has no off-delay, so a fixed economizer clears the alarm at the next evaluation rather than one alarm_delay later",
"inputs": {
"oat": 15.0,
"oa_dmpr_cmd": [
{
"t": 0,
"value": 10.0
},
{
"t": 3600,
"value": 60.0
}
],
"comp_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 1980,
"to_s": 3300,
"equals": true
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 7200,
"equals": false
}
]
}
]
}