AHU-0021 — Excess outdoor air during occupied hours
| Status | verified — engine e2ff2f8, cxf:fnv1a128:a9c58cb4c0e46fe131faed48a0c8efc0, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | EXCESS_CONSUMPTION |
| Confidence | HIGH |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | AHU-0028 |
| Related | AHU-0006, AHU-0017, AHU-0030 |
| Playbooks | economizer-failure |
| Source | HVAC FDD Reference v1.0 §9, AHU-0021; PNNL-27338 §3; PNNL EEM-17 (demand control ventilation) |
| Operating states | occupied, non-economizer operation (host-gated); reference OS 1, OS 4 |
Preconditions (host-enforced): Supply fan running. The host must not evaluate during economizer operation — bringing in more than the design minimum is the point of economizing, and this rule cannot tell that apart from a stuck damper. MAT must pass its integrity gate (AHU-0028, see suppressed_by): the fraction is a ratio of temperature differences, so a biased mixed-air reading moves it directly. The temperature-difference gate is signalled in-rule by yTempDeltaOk; when it is false the verdict is NO_EVAL, not healthy. Additionally suspend evaluation (NO_EVAL) while demand-controlled ventilation or a ventilation-demand override holds outdoor-air flow above the minimum-OA state — at VAV turndown a constant ventilation flow becomes a large OA fraction and this rule fires on healthy operation (fleet-validated FP mechanism; see the validation block).
Outputs:
yFault— True while the outdoor air fraction has stayed more than oaf_threshold above desired_oaf for at least alarm_delay, with the temperature difference large enough to evaluateyTempDeltaOk— Evaluability signal — true when |oat − rat| exceeds oaf_temp_threshold; false means NO_EVAL and the host must ignore yFault
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
desired_oaf | 0.15 | 1 | designConst.k | Design outdoor air fraction the unit should hold at minimum ventilation (0–1) |
oaf_threshold | 0.1 | 1 | marginHigh.t | Tolerance above the design fraction before the excess counts as a fault |
oaf_temp_threshold | 6.0 | °C | deltaOk.t | Minimum |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (30 min) |
Description
The unit is pulling in more outdoor air than its design minimum ventilation requires, and it is not economizing — every extra cubic metre has to be heated or cooled to supply temperature for no ventilation benefit. Unlike a failed economizer, this fault is invisible from the zone: the space stays comfortable, the coils simply work harder to keep it that way, through every occupied hour. The outdoor air fraction is inferred from the mixing-box energy balance rather than measured, which makes the diagnostic cheap — three temperatures, no airflow station — and conditional, since the inference only holds when outdoor and return air differ enough to locate the fraction; hence the explicit evaluability output. AHU-0030 is the same measurement narrowed to heating operation, where the excess is most expensive. Present in roughly 15% of buildings.
Detection Logic
oaf = (mat − rat) / (oat − rat)
yTempDeltaOk = |oat − rat| > oaf_temp_threshold (false ⇒ host reports NO_EVAL)
yFault = (oaf − desired_oaf > oaf_threshold) AND yTempDeltaOk,
sustained for alarm_delay
Block graph (rule.cxf.jsonld):
matRat and oatRat form the two differences, oaf divides them, and
margin subtracts the design fraction so that marginHigh tests the excess
against a single positive threshold. oatRat fans out a second time into
absDelta and deltaOk, whose output is both the boundary output
yTempDeltaOk and the second input of gate — so yFault is held down over
exactly the interval the host is told to disregard it. That matters because the
division is unguarded: CDL Divide follows IEEE-754, so oat = rat yields ±∞
or NaN rather than an error, and a near-zero denominator amplifies ordinary
sensor noise into a fraction of any magnitude. NaN compares false everywhere,
but ±∞ and a noise-inflated finite fraction can both raise marginHigh, and
gate is what stops them. Both comparisons are strict: a fraction sitting
exactly at desired_oaf + oaf_threshold is not a fault, and a temperature
difference of exactly oaf_temp_threshold is not evaluable. The fraction is
signed consistently across the year — summer both differences positive, winter
both negative — so no seasonal branch is needed. persist requires 30
continuous minutes, riding out damper strokes and the mixing transient after a
mode change; delayOnInit = true holds that window across a restart.
Possible Diagnoses
- OA damper minimum position set too high
- OA damper not closing to minimum — stuck, or the sequence never commands it back down after a purge or economizer period
- Damper actuator issue: failed actuator, slipped linkage, or a position feedback that disagrees with the blade
- Exhaust fan creating negative building pressure that pulls outdoor air in past the minimum position
Energy Impact
EXCESS_CONSUMPTION, HIGH confidence, DIRECT_MEASUREMENT. The waste is
computable from live data: excess_oa_kw = (actual_oaf − desired_oaf) × airflow × cp × |oat − rat|, with the excess fraction already on the wire as
oaf − designConst.k. Correcting minimum ventilation saves 2–10% of AHU
thermal energy (PNNL-27338), the upper half of that range in heating-dominant
climates. PNNL EEM-17 (demand control ventilation) is the related retrofit and
this rule is its screening test: a unit already over its design fraction with
the dampers at minimum will not benefit from CO₂ control until the mechanical
problem is fixed.
Emissions Impact
Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typical 500–4,000 kg CO₂e/yr for the excess ventilation thermal load. The split follows the season: excess outdoor air in winter usually burns scope 1 fuel at the heating coil, in summer it draws scope 2 electricity at the chiller. Avoided-emissions basis: marginal operating emissions rate (MOER).
Deviations
- The reference’s
AND NOT econ_favorableterm is not in the block graph. Economizer operation is an operating state, not a measurement, and this library keeps operating-state gating host-side (precedent: AHU-0017’s OS-4 restriction), so the term lives inoperating_statesandpreconditionsinstead. A host that evaluates this rule during economizing will get a fault, and it will be the host’s bug. - The reference writes the test as
oaf > (desired_oaf + oaf_threshold), which would force the two tunables into one summed threshold. Feedingdesired_oafasReals.Sources.Constant.kand comparing the remaining margin againstoaf_thresholdis algebraically identical and keeps both retunable alone. - Evaluability is an output, not just a precondition: the
|oat − rat|test is computable from this rule’s own inputs, so SCHEMA.md requires exposing it asyTempDeltaOk(PNNL-27338 uses 5 °F for the same computation; the reference’s 6 °C default is adopted). A falseyFaultunder a falseyTempDeltaOkmeans “unknown”, not “healthy”. - Both comparisons are strict (
>); the reference does not specify boundary behavior, so the library’s strict convention applies. persist.delayOnInit = true(Modelica/CDL default isfalse), the library’s standing choice: an excess already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.
Notes
Check the minimum position setpoint before sending anyone to the roof — the most common cause is a minimum dialled up during a ventilation complaint or a commissioning shortcut, and it is a $0 desk fix. The economizer-failure playbook’s damper and linkage steps come after that.
The rule is deliberately blind to why the fraction is high. A damper stuck at 40% and a building held under negative pressure by an oversized exhaust fan produce the same number, and the second is invisible from the AHU’s own points: if commanding the damper closed does not move the fraction, measure building pressure before replacing the actuator.
Test Vectors
8 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
normal_ventilation | Reference vector: OAF 0.18 against a 0.25 alarm point (oat 2 °C, rat 22 °C, mat 18.4 °C) — design minimum plus normal spread |
excess_outdoor_air | Reference vector: OAF 0.40 (oat 2 °C, rat 22 °C, mat 14.0 °C) — 0.25 above design; alarms after alarm_delay (1800 s) |
margin_exactly_at_threshold | Edge case: OAF 0.25 exactly (mat 17.0 °C), so the margin equals oaf_threshold — the strict > means no fault |
margin_just_over_threshold | Edge case: OAF 0.26 (mat 16.8 °C) clears the strict comparison and alarms after alarm_delay |
small_delta_not_evaluable | NO_EVAL: |
delta_exactly_at_threshold | Edge case: |
transient_excess_clears_before_delay | Transient: OAF jumps to 0.40 at t=600 and falls back to 0.18 at t=1800 — 1200 s of excess, short of alarm_delay, so no alarm |
summer_excess_oat_above_rat | Sign case: outdoor air warmer than return (oat 35 °C, rat 24 °C, mat 28.4 °C) — the fraction still reads 0.40 and the rule alarms after alarm_delay |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "normal_ventilation",
"description": "Reference vector: OAF 0.18 against a 0.25 alarm point (oat 2 \u00b0C, rat 22 \u00b0C, mat 18.4 \u00b0C) \u2014 design minimum plus normal spread",
"inputs": {
"mat": 18.4,
"rat": 22.0,
"oat": 2.0
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "excess_outdoor_air",
"description": "Reference vector: OAF 0.40 (oat 2 \u00b0C, rat 22 \u00b0C, mat 14.0 \u00b0C) \u2014 0.25 above design; alarms after alarm_delay (1800 s)",
"inputs": {
"mat": 14.0,
"rat": 22.0,
"oat": 2.0
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "margin_exactly_at_threshold",
"description": "Edge case: OAF 0.25 exactly (mat 17.0 \u00b0C), so the margin equals oaf_threshold \u2014 the strict `>` means no fault",
"inputs": {
"mat": 17.0,
"rat": 22.0,
"oat": 2.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "margin_just_over_threshold",
"description": "Edge case: OAF 0.26 (mat 16.8 \u00b0C) clears the strict comparison and alarms after alarm_delay",
"inputs": {
"mat": 16.8,
"rat": 22.0,
"oat": 2.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "small_delta_not_evaluable",
"description": "NO_EVAL: |oat \u2212 rat| = 2 \u00b0C (oat 20 \u00b0C, rat 22 \u00b0C) with mat 14 \u00b0C, so the raw fraction reads 4.0 \u2014 an ungated rule would alarm; yTempDeltaOk stays false and holds yFault down",
"inputs": {
"mat": 14.0,
"rat": 22.0,
"oat": 20.0
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "delta_exactly_at_threshold",
"description": "Edge case: |oat \u2212 rat| = 6.0 \u00b0C exactly (oat 16 \u00b0C, rat 22 \u00b0C) with OAF 0.50 \u2014 the strict `>` leaves the rule not evaluable",
"inputs": {
"mat": 19.0,
"rat": 22.0,
"oat": 16.0
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "transient_excess_clears_before_delay",
"description": "Transient: OAF jumps to 0.40 at t=600 and falls back to 0.18 at t=1800 \u2014 1200 s of excess, short of alarm_delay, so no alarm",
"inputs": {
"mat": [
{
"t": 0,
"value": 18.4
},
{
"t": 600,
"value": 14.0
},
{
"t": 1800,
"value": 18.4
}
],
"rat": 22.0,
"oat": 2.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "summer_excess_oat_above_rat",
"description": "Sign case: outdoor air warmer than return (oat 35 \u00b0C, rat 24 \u00b0C, mat 28.4 \u00b0C) \u2014 the fraction still reads 0.40 and the rule alarms after alarm_delay",
"inputs": {
"mat": 28.4,
"rat": 24.0,
"oat": 35.0
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
}
]
}
]
}