AHU-0030 — Excess outdoor air during heating mode
| Status | verified — engine e2ff2f8, cxf:fnv1a128:a1a61e83113bf6d634eb7702f954f32f, 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-0028, AHU-0021, AHU-0006 |
| Playbooks | economizer-failure |
| Source | HVAC FDD Reference v1.0 §9, AHU-0030; Schein et al. 2006 (APAR); PNNL-27338; Gunay 2023 |
| Operating states | heating (htg_vlv_cmd is tested in-rule; deeper mode gating stays host-side) |
Preconditions (host-enforced): Supply fan running. MAT must pass its integrity gate (AHU-0028, see suppressed_by) — the reference names that rule as this one’s prerequisite, because the outdoor air fraction is a ratio of temperature differences and 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.
Points: oat, rat, mat, htg_vlv_cmd
Outputs:
yFault— True while the heating coil has been open and the outdoor air fraction more than oa_excess_margin above design, for at least alarm_delay, with the temperature difference large enough to evaluateyTempDeltaOk— Evaluability signal — true when |oat − rat| exceeds min_delta; false means NO_EVAL and the host must ignore yFault
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
design_min_oa_fraction | 0.15 | 1 | designConst.k | Design minimum outdoor air fraction the unit should hold while heating (0–1) |
oa_excess_margin | 0.15 | 1 | marginHigh.t | Tolerance above the design minimum fraction before the excess counts as a fault |
min_delta | 6.0 | °C | deltaOk.t | Minimum |
valve_open_threshold | 5.0 | % | htgOn.t | Heating valve command above which the coil counts as heating |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (30 min) |
Description
The heating coil is running and the unit is drawing well over its design minimum outdoor air. This is AHU-0021’s measurement narrowed to the operating state where excess ventilation costs the most: every extra cubic metre arrives at outdoor temperature and has to be lifted to supply temperature by the coil the rule is watching — in a −5 °C hour against 22 °C return air, 27 °C of lift on that share of the airflow. The classic cause is an economizer that never handed back: dampers open for free cooling in a mild afternoon, outdoor air turns cold overnight, and the sequence (or a stuck actuator, or a leaking blade seal) leaves them there while the heating coil compensates. Nothing about it is uncomfortable, so it survives until someone reads the fuel bill. Present in roughly 15% of buildings.
Detection Logic
oaf = (mat − rat) / (oat − rat)
yTempDeltaOk = |oat − rat| > min_delta (false ⇒ host reports NO_EVAL)
yFault = (oaf − design_min_oa_fraction > oa_excess_margin)
AND (htg_vlv_cmd > valve_open_threshold)
AND yTempDeltaOk,
sustained for alarm_delay
Block graph (rule.cxf.jsonld):
The fraction chain is AHU-0021’s, unchanged: matRat and oatRat form the
two differences, oaf divides them, and margin subtracts the design fraction
so marginHigh tests the excess against a single positive threshold. htgOn
adds the heating condition and and1 conjoins it with the excess; and2 then
gates the whole finding on deltaOk, whose output is also the boundary output
yTempDeltaOk. That gate is what makes the unguarded division safe: CDL
Divide follows IEEE-754, so oat = rat yields ±∞ or NaN rather than an error,
and a near-zero denominator turns 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 and2 stops them. All three
comparisons are strict: a valve parked at exactly 5%, a fraction sitting exactly
at design_min_oa_fraction + oa_excess_margin, and a temperature difference of
exactly min_delta all read as no-fault. persist requires 30 continuous
minutes, riding out damper strokes and the mixing transient after a mode change;
delayOnInit = true holds that window across a controller restart.
Possible Diagnoses
- OA damper stuck partially open
- OA damper minimum setpoint configured too high
- Economizer override not releasing after the transition out of free cooling
- Leaking OA damper seals
Energy Impact
EXCESS_CONSUMPTION, HIGH confidence, DIRECT_MEASUREMENT. The waste is
computable from live data: excess_htg_kw = (actual_oaf − design_min_oa_fraction) × airflow × cp × (rat − oat), with the excess fraction
already on the wire as oaf − designConst.k. Correcting it saves 3–15% of
heating energy (PNNL EEM-06, OA damper faults; PNNL-27338), the top of that
range in cold climates where (rat − oat) stays large for months. This is the
defect AHU-0021 finds year-round, priced at its worst hour, which is why the
two share a playbook.
Emissions Impact
Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typical 300–2,500 kg CO₂e/yr for the excess ventilation heating load. Most of it is scope 1 fuel at the boiler or furnace; the scope 2 share is whatever the site’s heating comes from electrically — heat pumps, electric resistance coils, and the extra fan energy of moving the air. Avoided-emissions basis: marginal operating emissions rate (MOER).
Deviations
min_deltais adopted, not transcribed: the reference states the fraction is computed only when|OAT − RAT| > min_deltabut omits the parameter from its tunables table. 6.0 °C matches AHU-0021’soaf_temp_thresholdso the two rules agree on when the shared measurement is meaningful (PNNL-27338 uses 5 °F for the same computation); retune one and retune the other.valve_open_thresholdis likewise absent from the reference’s tunables table. 5% is what chapter 9 uses everywhere else a valve counts as open (AHU-0016, AHU-0025), so “heating” means the same thing chapter-wide.- The reference writes the test as
oaf > (design_min_oa_fraction + oa_excess_margin), which would force the two tunables into one summed threshold. Feeding the design fraction asReals.Sources.Constant.kand comparing the remaining margin againstoa_excess_marginis 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. A falseyFaultunder a falseyTempDeltaOkmeans “unknown”, not “healthy”. - Heating is in-graph because
htg_vlv_cmdis a measured point; everything beyond it — occupancy, unit mode, morning warmup — stays host-side per this library’s design stance, as in AHU-0017 and AHU-0021. - All three 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
This rule and AHU-0021 share the fraction core and differ in three ways, all
from their respective reference cards: the htgOn term is in-graph here where
FC-055 leaves its scope to the host, the excess margin is 0.15 against FC-055’s
0.10, and the energy term uses the signed (rat − oat) because in heating the
sign is known. Deploying both is not redundant — this one alarms earlier in
winter with a sharper cost estimate, FC-055 watches the rest of the year.
Verify AHU-0028 is clear before acting: a MAT sensor reading 3 °C low in −5 °C weather manufactures this fault out of nothing, which is why the reference names it a prerequisite. If the fraction is genuinely high, command the OA damper to minimum and watch MAT. It should climb toward return temperature within minutes; if it does not, the problem is mechanical and the economizer-failure playbook’s on-site steps apply. If it does, the sequence never commanded minimum position and the fix is at a desk.
Test Vectors
7 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
normal_heating_ventilation | Reference vector: OAF 0.148 with the heating valve at 50% (oat −5 °C, rat 22 °C, mat 18 °C) — at design minimum, no excess |
excess_oa_while_heating | Reference vector: OAF 0.444 with the heating valve at 80% (oat −5 °C, rat 22 °C, mat 10 °C); alarms after alarm_delay (1800 s) |
small_delta_not_evaluable | Reference vector: |
valve_exactly_at_threshold | Edge case: heating valve sits exactly at valve_open_threshold (5%) with OAF 0.444 — the strict > means the coil does not count as heating |
margin_exactly_at_threshold | Edge case: OAF 0.30 exactly (oat 2 °C, rat 22 °C, mat 16 °C), so the margin equals oa_excess_margin — the strict > means no fault |
margin_just_over_threshold | Edge case: OAF 0.31 (mat 15.8 °C) clears the strict comparison and alarms after alarm_delay |
valve_closes_before_delay | Transient: OAF stays at 0.444 but the heating valve closes at t=1200 — 1200 s of heating, short of alarm_delay, so no alarm |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "normal_heating_ventilation",
"description": "Reference vector: OAF 0.148 with the heating valve at 50% (oat \u22125 \u00b0C, rat 22 \u00b0C, mat 18 \u00b0C) \u2014 at design minimum, no excess",
"inputs": {
"oat": -5.0,
"rat": 22.0,
"mat": 18.0,
"htg_vlv_cmd": 50.0
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "excess_oa_while_heating",
"description": "Reference vector: OAF 0.444 with the heating valve at 80% (oat \u22125 \u00b0C, rat 22 \u00b0C, mat 10 \u00b0C); alarms after alarm_delay (1800 s)",
"inputs": {
"oat": -5.0,
"rat": 22.0,
"mat": 10.0,
"htg_vlv_cmd": 80.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": "small_delta_not_evaluable",
"description": "Reference vector: |oat \u2212 rat| = 2 \u00b0C (oat 20 \u00b0C, rat 22 \u00b0C, mat 21 \u00b0C, valve 20%) \u2014 the raw fraction reads 0.50, so an ungated rule would alarm; yTempDeltaOk stays false and holds yFault down",
"inputs": {
"oat": 20.0,
"rat": 22.0,
"mat": 21.0,
"htg_vlv_cmd": 20.0
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "valve_exactly_at_threshold",
"description": "Edge case: heating valve sits exactly at valve_open_threshold (5%) with OAF 0.444 \u2014 the strict `>` means the coil does not count as heating",
"inputs": {
"oat": -5.0,
"rat": 22.0,
"mat": 10.0,
"htg_vlv_cmd": 5.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "margin_exactly_at_threshold",
"description": "Edge case: OAF 0.30 exactly (oat 2 \u00b0C, rat 22 \u00b0C, mat 16 \u00b0C), so the margin equals oa_excess_margin \u2014 the strict `>` means no fault",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 16.0,
"htg_vlv_cmd": 50.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "margin_just_over_threshold",
"description": "Edge case: OAF 0.31 (mat 15.8 \u00b0C) clears the strict comparison and alarms after alarm_delay",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 15.8,
"htg_vlv_cmd": 50.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "valve_closes_before_delay",
"description": "Transient: OAF stays at 0.444 but the heating valve closes at t=1200 \u2014 1200 s of heating, short of alarm_delay, so no alarm",
"inputs": {
"oat": -5.0,
"rat": 22.0,
"mat": 10.0,
"htg_vlv_cmd": [
{
"t": 0,
"value": 80.0
},
{
"t": 1200,
"value": 0.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
}
]
}