RTU-0005 — Excess outdoor air intake
| Status | verified — engine e2ff2f8, cxf:fnv1a128:403a34cdcdb48f2f6c7f4827a80b4ab9, 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-0021, AHU-0030, RTU-0006, RTU-0004, RTU-0010 |
| Playbooks | economizer-failure |
| Source | HVAC FDD Reference v1.0 §11, RTU-0005; PNNL-23790 (RTU AFDD4/AFDD5); PNNL EEM-17 (demand control ventilation); PNNL EEM-23 (RTU advanced controls) |
| Operating states | occupied operation with the economizer locked out (host-gated); the reference’s economizer_should_be_inactive(oat, mode) term lives in preconditions, not in the graph |
Preconditions (host-enforced): Supply fan running. The host must not evaluate while the economizer is legitimately open — drawing more than the design minimum is the point of economizing, and these three temperatures cannot tell that apart from a damper that never closed. 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.
Outputs:
yFault— True while the outdoor air fraction has stayed more than oa_excess_margin above design_min_oa_fraction 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 when it is not economizing (0–1) |
oa_excess_margin | 0.15 | 1 | marginHigh.t | Tolerance above the design minimum before the excess counts as a fault |
min_delta | 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 drawing well over its design minimum outdoor air at an hour when it has no business economizing. Every extra cubic metre arrives at outdoor temperature and has to be dragged to supply temperature by the gas heat or the compressors, and none of it buys ventilation the code did not already have. Nothing about it is uncomfortable — the space stays on setpoint, the unit simply runs harder — so the defect survives until someone reads the fuel bill. Packaged units make it common: economizer, minimum-position setting and dampers all live in one weather-exposed cabinet, and Cowan’s 2004 survey found at least one economizer fault on 54% of units. The outdoor air fraction is inferred from the mixing-box energy balance rather than measured, which is what makes the diagnostic cheap — three temperatures, no airflow station — and also what makes it conditional, hence the explicit evaluability output.
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 yTempDeltaOk,
sustained for alarm_delay
Block graph (rule.cxf.jsonld):
The fraction core is AHU-0021’s, unchanged, bound to the RTU point dictionary.
gate is what makes the unguarded division safe: CDL Divide follows IEEE-754,
so oat = rat yields ±∞ or NaN and a near-zero denominator amplifies ordinary
sensor noise into a fraction of any magnitude. NaN compares false everywhere, and
±∞ or a noise-inflated finite fraction can raise marginHigh but cannot pass
gate, because a denominator small enough to misbehave is by construction one
below min_delta. Garbage arithmetic can only make the rule report itself
unevaluable. yTempDeltaOk leaves the block as well as feeding the gate, so a
host that reads it learns the difference between “not faulted” and “cannot
tell”. Both comparisons are strict: a fraction sitting exactly at
design_min_oa_fraction + oa_excess_margin is not a fault, and a temperature
difference of exactly min_delta is not evaluable. The quotient is signed
consistently on both sides of the year — winter makes both differences negative,
summer both positive — so no seasonal branch is needed. persist requires 30
continuous minutes, riding out a damper stroke and the mixing transient after a
stage change; recovery is immediate, and delayOnInit = true holds the window
across a restart.
Possible Diagnoses
- OA damper minimum position set too high
- OA damper not closing to the commanded minimum
- Damper blade seals deteriorated
- Economizer lockout not engaging
Energy Impact
EXCESS_CONSUMPTION, HIGH confidence, DIRECT_MEASUREMENT. The waste is computable
from live data: excess_oa_kw = (actual_oaf − design_min_oa_fraction) × airflow × cp × |oat − rat|, with the excess fraction already on the wire as
oaf − designConst.k. Correcting minimum ventilation is worth 2–10% of the
unit’s thermal energy, the upper half of that range in heating-dominant climates
where the outdoor-to-return difference is largest for months at a time. The
reference maps the fault to PNNL EEM-17 (demand control ventilation) and EEM-23
(RTU advanced controls); this rule screens both, since a unit that cannot hold
its design fraction will not benefit from either until the mechanical problem is
fixed.
Emissions Impact
Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typically 300–2,500 kg CO₂e/yr for the excess ventilation load. The split follows the season and the unit: winter excess burns scope 1 gas at the furnace section, summer excess draws scope 2 electricity at the compressors, and on an all-electric packaged unit the whole exchange collapses to scope 2. Avoided-emissions basis: marginal operating emissions rate (MOER).
Deviations
min_deltadefault adopted, not transcribed. The reference states the fraction is computed only when|OAT − RAT| > min_deltabut omits the parameter from its tunables table. This card adopts 6.0 °C, matching AHU-0021, AHU-0030 and RTU-0006 so every rule running this quotient agrees on when it is meaningful (PNNL-27338 uses 5 °F for the same computation). A site that retunes one should retune all of them.- The reference’s
economizer_should_be_inactive(oat, mode)term is not in the block graph. Economizer state is an operating state, not a measurement, and this library keeps state gating host-side (precedent: AHU-0021, whose reference card carries the same term). A host that evaluates this rule while the unit is economizing gets a sustained fault, and it is the host’s bug;modeis not a canonical RTU point in any case. - Design fraction as a constant, excess as a threshold. The reference writes
oa_fraction > (design_min_oa_fraction + oa_excess_margin), which implemented literally would sum the two tunables into one threshold and stop a host retuning either alone. Feeding the design fraction in asReals.Sources.Constant.kkeeps both as independentset_parampaths with no sign flips. Algebraically identical. - Evaluability is an output, not just a precondition. The
min_deltatest is computable from this rule’s own inputs, so SCHEMA.md requires exposing it asyTempDeltaOk. FalseyFaultunder falseyTempDeltaOkmeans “unknown”, not “healthy”, and the host must treat it that way. - Both comparisons are strict (
>). The reference does not specify boundary behavior and CDLRealsoffers noGreaterEqual, so the choice is made rather than inherited; the disagreement with an inclusive reading has measure zero on a real temperature signal and errs toward silence. - The reference publishes no worked vectors for this fault, so every scenario in
vectors.jsonis authored from the equation, following AHU-0021’s suite shape. 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 restart.
Notes
suppressed_by: [AHU-0028] is transcribed from the reference and points
across equipment families on purpose: AHU-0028’s graph consumes nothing but
mat, oat and rat, so the host instantiates it against this RTU’s own three
points. Deploy the pair together — a MAT sensor reading 4.5 °C low in −5 °C
weather turns a compliant 0.15 fraction into 0.32 and manufactures this fault
out of nothing, and on a milder day with a 10 °C spread under 2 °C of bias does
the same.
RTU-0006 is this rule’s mirror on the same three temperatures: this one alarms more than 0.15 above design, that one more than 0.05 below. The margins are deliberately asymmetric — excess air costs money, deficient air costs air quality — and neither can fire while the other does. If the fraction is genuinely high, command the OA damper to minimum and watch mixed air: it should climb toward return temperature within minutes, and if it does not the problem is mechanical (the economizer-failure playbook’s on-site steps). Check the minimum position setpoint first — the most common cause is a number dialled up during a ventilation complaint, and that is a $0 fix.
Test Vectors
10 scenarios, clock step 300 s over 5400 s.
| Scenario | Description |
|---|---|
normal_minimum_ventilation | OAF 0.18 against a 0.30 alarm point (oat 2 °C, rat 22 °C, mat 18.4 °C) — design minimum plus the spread a real damper holds |
excess_outdoor_air | OAF 0.40 (oat 2 °C, rat 22 °C, mat 14.0 °C) — 0.25 above design, so 0.10 past the margin; alarms after alarm_delay (1800 s) |
summer_excess_oat_above_rat | Sign case: outdoor air warmer than return (oat 35 °C, rat 24 °C, mat 28.4 °C) — both differences flip sign, the quotient still reads 0.40, and the rule alarms exactly as it does in winter |
small_delta_not_evaluable | NO_EVAL with the gate doing work: |
delta_exactly_at_threshold | Edge case: |
delta_just_over_threshold | Edge case, other side: |
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, other side: OAF 0.31 (mat 15.8 °C) clears the strict comparison by one hundredth and alarms after alarm_delay |
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 the damper stroke never becomes an alarm |
recovery_clears_alarm | Recovery: OAF 0.40 alarms after alarm_delay, then the damper returns to minimum at t=3000 (OAF 0.18) — yFault drops on that tick, with no hold-off |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 5400
},
"scenarios": [
{
"name": "normal_minimum_ventilation",
"description": "OAF 0.18 against a 0.30 alarm point (oat 2 \u00b0C, rat 22 \u00b0C, mat 18.4 \u00b0C) \u2014 design minimum plus the spread a real damper holds",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 18.4
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 5400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "excess_outdoor_air",
"description": "OAF 0.40 (oat 2 \u00b0C, rat 22 \u00b0C, mat 14.0 \u00b0C) \u2014 0.25 above design, so 0.10 past the margin; alarms after alarm_delay (1800 s)",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 14.0
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 5400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"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 both differences flip sign, the quotient still reads 0.40, and the rule alarms exactly as it does in winter",
"inputs": {
"oat": 35.0,
"rat": 24.0,
"mat": 28.4
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 5400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "small_delta_not_evaluable",
"description": "NO_EVAL with the gate doing work: |oat \u2212 rat| = 2 \u00b0C (oat 20 \u00b0C, rat 22 \u00b0C) and mat 21 \u00b0C, so the raw fraction reads 0.50 and marginHigh is true \u2014 an ungated rule would alarm; yTempDeltaOk stays false and holds yFault down",
"inputs": {
"oat": 20.0,
"rat": 22.0,
"mat": 21.0
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 5400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"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 (mat 19 \u00b0C) \u2014 the strict `>` leaves the rule not evaluable, so a fraction well past the alarm point still reports NO_EVAL",
"inputs": {
"oat": 16.0,
"rat": 22.0,
"mat": 19.0
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 5400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "delta_just_over_threshold",
"description": "Edge case, other side: |oat \u2212 rat| = 6.5 \u00b0C (oat 15.5 \u00b0C, rat 22 \u00b0C) with OAF 0.40 (mat 19.4 \u00b0C) \u2014 evaluable, and the excess alarms after alarm_delay",
"inputs": {
"oat": 15.5,
"rat": 22.0,
"mat": 19.4
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 5400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"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
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 5400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "margin_just_over_threshold",
"description": "Edge case, other side: OAF 0.31 (mat 15.8 \u00b0C) clears the strict comparison by one hundredth and alarms after alarm_delay",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 15.8
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"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 the damper stroke never becomes an alarm",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": [
{
"t": 0,
"value": 18.4
},
{
"t": 600,
"value": 14.0
},
{
"t": 1800,
"value": 18.4
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "recovery_clears_alarm",
"description": "Recovery: OAF 0.40 alarms after alarm_delay, then the damper returns to minimum at t=3000 (OAF 0.18) \u2014 yFault drops on that tick, with no hold-off",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": [
{
"t": 0,
"value": 14.0
},
{
"t": 3000,
"value": 18.4
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 2700,
"equals": true
},
{
"output": "yFault",
"from_s": 3000,
"to_s": 5400,
"equals": false
}
]
}
]
}