RTU-0006 — Insufficient ventilation air
| Status | verified — engine e2ff2f8, cxf:fnv1a128:2d1a469cb6689db185713b59c4047434, 2026-08-17 |
| Severity | 2 |
| Method | rule |
| Phase | 2 |
| Category | COMFORT_ENERGY |
| Confidence | MEDIUM |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | AHU-0028 |
| Related | RTU-0005, AHU-0006, AHU-0026, RTU-0010 |
| Playbooks | economizer-failure |
| Source | HVAC FDD Reference v1.0 §11, RTU-0006; PNNL-23790 (RTU AFDD6); ASHRAE Standard 62.1; PNNL EEM-06 (OA damper faults) |
| Operating states | occupied with the supply fan running — both conjuncts are in the graph (occ_schedule, sf_status), because the reference writes them into the fault equation and both are canonical RTU points |
Preconditions (host-enforced): Occupancy schedule data available and current; the host evaluates the schedule (time zone, calendar, holidays) into the boolean occ_schedule point, and a stale or unknown schedule makes the verdict NO_EVAL rather than healthy. 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, and this rule’s deficit branch is exactly where a low MAT lands. 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, sf_status, occ_schedule
Outputs:
yFault— True while the outdoor air fraction has stayed more than oa_deficit_margin below design_min_oa_fraction, occupied and with the supply fan running, 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 owes its occupants (0–1) |
oa_deficit_margin | 0.05 | 1 | deficitBig.t | Tolerance below the design minimum before the shortfall 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 not delivering the outdoor air its occupants are owed: the building is occupied, the fan is running, and the mixing-box energy balance puts the outdoor share of the supply below the design minimum by more than the allowance. Unlike every other fault on this quotient the finding is a health one — ASHRAE 62.1 sets the minimum for a reason, and a unit that misses it accumulates CO₂, humidity and whatever else the space generates — which is why the reference rates it severity 2 against its excess-air twin’s 3. Nothing about under-ventilation announces itself: the space holds temperature better than it should, and the energy signature runs the wrong way, so a bill review will never find it. The fraction is inferred from three temperatures rather than measured, which makes the diagnostic cheap and 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 = (design_min_oa_fraction − oaf > oa_deficit_margin)
AND occ_schedule AND sf_status AND yTempDeltaOk,
sustained for alarm_delay
Block graph (rule.cxf.jsonld):
The fraction core is RTU-0005’s, unchanged; deficit subtracts the other way
round so the test is a positive gap against a positive threshold — the same
identity read from the other side, since oaf < design − margin exactly when
design − oaf > margin. Occupancy and fan status are conjoined in-graph and
participate in the persistence: the 30-minute clock starts when the last
conjunct becomes true, so a deficit that predates occupancy is timed from the
start of the occupied period, not from the start of the deficit. 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 sensor noise into a
fraction of any magnitude; NaN compares false everywhere, and −∞ or a
noise-inflated finite fraction can raise deficitBig 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. Both comparisons are strict: a fraction sitting exactly at
design_min_oa_fraction − oa_deficit_margin is not a fault, and a difference of
exactly min_delta is not evaluable. persist requires 30 continuous minutes,
riding out a damper stroke and a purge cycle; recovery is immediate, and
delayOnInit = true holds the window across a restart.
Possible Diagnoses
- OA damper stuck closed or nearly closed
- OA damper minimum position set too low
- OA intake blocked — debris, snow, or ice
- Exhaust fan creating negative building pressure
Energy Impact
COMFORT_ENERGY, MEDIUM confidence, QUALITATIVE_ONLY, mapped to PNNL EEM-06 (OA damper faults). The reference publishes no savings range or runtime formula because there is nothing to compute: under-ventilation is not waste. A unit conditioning 5% outdoor air instead of 15% spends less on that air than it should, and correcting the damper raises the heating and cooling load rather than lowering it.
A host that accumulates energy savings across the fault library must exclude
this rule explicitly. AHU-0006 carries the same warning for its low branch;
this rule is that branch made reachable, so it matters more here. The number
worth carrying runs the other way — the ventilation the occupants did not get —
and the rule has the fraction for it (deficit.y) but not the airflow to turn
it into cubic metres.
Emissions Impact
Scope 1 or 2 depending on how the unit heats, QUALITATIVE_EMISSIONS, MEDIUM confidence. The reference’s figure is 50–300 kg CO₂e/yr with IAQ primary and emissions secondary; the sign is negative, in that fixing the fault raises emissions slightly by restoring the ventilation load the unit was supposed to carry. No avoided-emissions basis applies, and none is claimed.
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, the value RTU-0005, AHU-0021 and AHU-0030 use, 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 deficit is computed as a positive gap. The reference writes
oa_fraction < (design_min_oa_fraction − oa_deficit_margin), which implemented literally folds the two tunables into one threshold and stops a host retuning either alone. Subtracting the fraction from aReals.Sources.Constantkeeps both as independentset_parampaths, keeps every parameter non-negative, and is algebraically identical. - Occupancy and fan status are in the graph, unlike RTU-0005’s economizer
term.
occ_scheduleandsf_statusare canonical RTU points with measured or host-published values, so the reference’sin_occupied_schedule AND sf_status = ONtranscribes directly rather than needing a mode enumeration the point dictionary does not carry (precedents: AHU-0018, AHU-0030). What stays host-side is the schedule’s provenance — time zone, calendar, holidays. - 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 on a health fault that distinction is the whole point. - Both comparisons are strict (
>). The reference does not specify boundary behavior and CDLRealsoffers noGreaterEqual. One caveat on the deficit edge: the nominal alarm point — a fraction of exactly 0.10 against a 0.15 design and a 0.05 margin — is not representable in binary, and the computed gap lands two ulps below the double nearest 0.05, so it reads healthy. Decimal arithmetic gives the same verdict through the strict>, so the rounding hides nothing; a host binding coarsely quantized temperatures should still not read anything into a fraction sitting on the threshold. - The reference publishes no worked vectors for this fault, so every scenario in
vectors.jsonis authored from the equation. persist.delayOnInit = true(Modelica/CDL default isfalse), the library’s standing choice: a deficit already present at load waits out the full 30 minutes instead of alarming on the first tick after a restart.
Notes
This rule catches what AHU-0006 cannot. FC#6 tests the same quotient
symmetrically against a 0.30 tolerance, so with %OAmin at 0.15 its low-side
alarm point is a fraction below −0.15 — which no physical mixing box can
produce, so a damper welded shut reads healthy there. Here the alarm point is a
fraction below 0.10 and the same shut damper alarms. The two are complementary:
FC#6 polices deviation from a G36 minimum-OA state in either direction with a
band sized to suppress false alarms, and this is the dedicated under-ventilation
alarm. A site that wants a real ventilation-deficit alarm deploys this one.
A negative inferred fraction reads as a large deficit and alarms. With honest
sensors that is a genuine finding — a shut damper plus heat picked up before the
sensor. With a lying mat it is not a ventilation finding at all, which is what
the suppressed_by: [AHU-0028] contract exists to silence: AHU-0028’s graph
consumes nothing but mat, oat and rat, so the host instantiates it against
this RTU’s own three points and deploys the pair together. When both are active
the sensor is the story and the fraction is noise.
Field-verify before dispatching a damper repair: a CO₂ reading in the space, or a smoke pencil at the intake, measures the thing that matters and costs an afternoon. If the fraction is genuinely low, command the damper open and watch mixed air move toward outdoor — no movement means actuator, linkage, or a blocked intake (economizer-failure playbook), movement means the sequence never commanded minimum position and the fix is at a desk. Check the intake screen first in a climate that gets snow.
Test Vectors
13 scenarios, clock step 300 s over 5400 s.
| Scenario | Description |
|---|---|
healthy_minimum_ventilation | OAF 0.15 exactly on design (oat 2 °C, rat 22 °C, mat 19 °C), occupied with the fan running — the deficit is zero and the rule is silent |
damper_shut_no_outdoor_air | The failure this rule exists for: OA damper shut, so mat tracks rat (22 °C) and the inferred fraction is 0 against a 0.15 design. Deficit 0.15 > 0.05 and the rule alarms after alarm_delay. AHU-0006 runs the same quotient with a symmetric 0.30 band, whose low side sits below a fraction of −0.15, and reads this same unit as healthy |
unoccupied_deficit_stays_clear | Same shut damper, unoccupied: the reference scopes under-ventilation to occupied hours because there is nobody to under-ventilate, and the occ_schedule conjunct holds yFault down |
fan_off_deficit_stays_clear | Same shut damper, occupied, fan off: with no airflow the mixing box delivers nothing to infer a fraction from, and sf_status holds yFault down |
negative_fraction_alarms | mat 23 °C sits above both rat (22 °C) and oat (2 °C), so the inferred fraction is −0.05 and the deficit reads 0.20. The rule alarms: with honest sensors that is a shut damper plus duct conduction, and with a lying mat it is the reading AHU-0028 suppression exists to silence |
deficit_exactly_at_threshold | Edge case: OAF 0.10 exactly (oat 2 °C, rat 22 °C, mat 20 °C), so the deficit equals oa_deficit_margin — the strict > means no fault |
deficit_just_over_threshold | Edge case, other side: OAF 0.09 (mat 20.2 °C) puts the deficit at 0.06 and the rule alarms after alarm_delay |
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: |
transient_deficit_clears_before_delay | Transient: the damper drops to shut at t=600 and returns to minimum at t=1500 — 900 s of deficit, short of alarm_delay, so a damper stroke or a purge cycle never becomes an alarm |
occupancy_start_restarts_persistence | The deficit is present from t=0 but the building is unoccupied until t=1200, so the 30-minute clock starts at occupancy rather than at the deficit — the alarm lands at 3000 s, not 1800 s |
recovery_clears_alarm | Recovery: the shut damper alarms after alarm_delay, then reopens to design minimum at t=3000 (OAF 0.15) — 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": "healthy_minimum_ventilation",
"description": "OAF 0.15 exactly on design (oat 2 \u00b0C, rat 22 \u00b0C, mat 19 \u00b0C), occupied with the fan running \u2014 the deficit is zero and the rule is silent",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 19.0,
"sf_status": true,
"occ_schedule": true
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 5400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "damper_shut_no_outdoor_air",
"description": "The failure this rule exists for: OA damper shut, so mat tracks rat (22 \u00b0C) and the inferred fraction is 0 against a 0.15 design. Deficit 0.15 > 0.05 and the rule alarms after alarm_delay. AHU-0006 runs the same quotient with a symmetric 0.30 band, whose low side sits below a fraction of \u22120.15, and reads this same unit as healthy",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 22.0,
"sf_status": true,
"occ_schedule": true
},
"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": "unoccupied_deficit_stays_clear",
"description": "Same shut damper, unoccupied: the reference scopes under-ventilation to occupied hours because there is nobody to under-ventilate, and the occ_schedule conjunct holds yFault down",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 22.0,
"sf_status": true,
"occ_schedule": false
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 5400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "fan_off_deficit_stays_clear",
"description": "Same shut damper, occupied, fan off: with no airflow the mixing box delivers nothing to infer a fraction from, and sf_status holds yFault down",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 22.0,
"sf_status": false,
"occ_schedule": true
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 5400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "negative_fraction_alarms",
"description": "mat 23 \u00b0C sits above both rat (22 \u00b0C) and oat (2 \u00b0C), so the inferred fraction is \u22120.05 and the deficit reads 0.20. The rule alarms: with honest sensors that is a shut damper plus duct conduction, and with a lying mat it is the reading AHU-0028 suppression exists to silence",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 23.0,
"sf_status": true,
"occ_schedule": true
},
"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": "deficit_exactly_at_threshold",
"description": "Edge case: OAF 0.10 exactly (oat 2 \u00b0C, rat 22 \u00b0C, mat 20 \u00b0C), so the deficit equals oa_deficit_margin \u2014 the strict `>` means no fault",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 20.0,
"sf_status": true,
"occ_schedule": true
},
"expect": [
{
"output": "yTempDeltaOk",
"from_s": 0,
"to_s": 5400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "deficit_just_over_threshold",
"description": "Edge case, other side: OAF 0.09 (mat 20.2 \u00b0C) puts the deficit at 0.06 and the rule alarms after alarm_delay",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 20.2,
"sf_status": true,
"occ_schedule": true
},
"expect": [
{
"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.9 \u00b0C, so the fraction reads 0.05 and deficitBig is true \u2014 an ungated rule would alarm on a mixture the two sources cannot locate; yTempDeltaOk stays false and holds yFault down",
"inputs": {
"oat": 20.0,
"rat": 22.0,
"mat": 21.9,
"sf_status": true,
"occ_schedule": true
},
"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 the damper shut (mat 22 \u00b0C) \u2014 the strict `>` leaves the rule not evaluable, so a full ventilation deficit still reports NO_EVAL rather than healthy",
"inputs": {
"oat": 16.0,
"rat": 22.0,
"mat": 22.0,
"sf_status": true,
"occ_schedule": true
},
"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 the same shut damper \u2014 evaluable, and the deficit alarms after alarm_delay",
"inputs": {
"oat": 15.5,
"rat": 22.0,
"mat": 22.0,
"sf_status": true,
"occ_schedule": true
},
"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": "transient_deficit_clears_before_delay",
"description": "Transient: the damper drops to shut at t=600 and returns to minimum at t=1500 \u2014 900 s of deficit, short of alarm_delay, so a damper stroke or a purge cycle never becomes an alarm",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": [
{
"t": 0,
"value": 19.0
},
{
"t": 600,
"value": 22.0
},
{
"t": 1500,
"value": 19.0
}
],
"sf_status": true,
"occ_schedule": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "occupancy_start_restarts_persistence",
"description": "The deficit is present from t=0 but the building is unoccupied until t=1200, so the 30-minute clock starts at occupancy rather than at the deficit \u2014 the alarm lands at 3000 s, not 1800 s",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": 22.0,
"sf_status": true,
"occ_schedule": [
{
"t": 0,
"value": false
},
{
"t": 1200,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2700,
"equals": false
},
{
"output": "yFault",
"from_s": 3300,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "recovery_clears_alarm",
"description": "Recovery: the shut damper alarms after alarm_delay, then reopens to design minimum at t=3000 (OAF 0.15) \u2014 yFault drops on that tick, with no hold-off",
"inputs": {
"oat": 2.0,
"rat": 22.0,
"mat": [
{
"t": 0,
"value": 22.0
},
{
"t": 3000,
"value": 19.0
}
],
"sf_status": true,
"occ_schedule": true
},
"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
}
]
}
]
}