AHU-0002 — Mixed air temperature too low
| Status | verified — engine e2ff2f8, cxf:fnv1a128:8591ae042848563cd91744d8168eba08, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 1 |
| Category | COMFORT_ENERGY |
| Confidence | LOW |
| Estimation | QUALITATIVE_ONLY |
| G36 | §5.16.14 FC#2 |
| Clusters | CLU-09 |
| Suppresses | — |
| Suppressed by | AHU-0028 |
| Related | AHU-0003, AHU-0028 |
| Playbooks | sensor-drift |
| Source | HVAC FDD Reference v1.0 §9, AHU-0002; G36 §5.16.14 FC#2; PNNL-25985 (EEM-01, sensor recalibration); Bushby et al. 2001 (NIST/CEC PIER Project 2.3, APAR rules 26/27 — earliest form of this envelope test) |
| Operating states | OS 1-5 |
Preconditions (host-enforced): Supply fan running — MAT means nothing in still air. The host must not evaluate during coil freeze-protection or within a few minutes of an economizer mode transition, when MAT lags the mixture it is supposed to report, and must silence this rule while AHU-0028 is active. When a gate is unmet the verdict is NO_EVAL, not healthy.
Outputs:
yFault— True while MAT has stayed more than mat_tolerance below min(oat, rat) for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
mat_tolerance | 2.0 | °C | gapBig.t | Combined sensor accuracy allowance; MAT may sit this far below the lower envelope bound before it counts as a fault. The 2.0 degC default is the reference ch.9 card’s own eps_MAT (4 degF); G36-2018 Table 5.16.14.5 publishes eps_MAT = 3 degC — retune to 3.0 for strict G36 conformance |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (30 min) |
Description
Mixed air is a blend of outdoor and return air, so it cannot be colder than both streams. When MAT reads more than combined sensor accuracy below the colder of OAT and RAT, the reading is impossible and something is wrong with the measurement or the mixing plenum: a sensor out of calibration, or outdoor air leaking past the mixing box straight onto the MAT sensor.
This is G36 §5.16.14 FC#2, the low half of the mixed-air envelope check. AHU-0003 is its mirror on the high side; both sit in cluster CLU-09 (Sensor Integrity Failure), whose trigger AHU-0028 tests the envelope in both directions at once. Roughly 15% of buildings have at least one AHU with a temperature sensor this far out.
Detection Logic
yFault = mat < min(oat, rat) − mat_tolerance,
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
Min rather than a fixed OAT-below-RAT assumption, because which stream is
colder flips with the season. The threshold comparison is strict, so a MAT
sitting exactly 2.0 °C below the bound is healthy and 2.1 °C below is not.
persist requires 30 minutes of continuous violation — a MAT that dips out and
recovers during a damper stroke never alarms, and any interruption restarts the
timer — while recovery is immediate on the tick MAT climbs back inside the
bound. Nothing above the envelope can trip this rule: a MAT 4 °C above both
sources violates the same physics and reads healthy here, because that case is
AHU-0003’s.
Possible Diagnoses
- MAT sensor out of calibration, reading low
- OAT sensor out of calibration, reading high
- RAT sensor out of calibration, reading high
- Cold air leakage into the mixing plenum — outdoor air short-circuiting to the sensor rather than mixing
Energy Impact
COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. A mis-read temperature costs nothing directly; the cost is downstream. A MAT biased low reads as excess cold in the mixture, so the mixed-air loop closes outdoor air below what the mixture needs — giving up free cooling in mild weather — and can call for preheat that nothing requires. PNNL-25985 EEM-01 (sensor recalibration) puts the recoverable range at 0–5% of site energy across a whole sensor population, sensor-dependent and climate-neutral. Prevalence ~15% of buildings have sensor faults.
Emissions Impact
QUALITATIVE_EMISSIONS, LOW confidence; no direct emissions. Scope is 1|2
because it depends on which subsystem the bad reading distorts: unnecessary
preheat lands in Scope 1 (on-site combustion), a disabled economizer calling
for mechanical cooling lands in Scope 2 (purchased electricity). The same
drifted sensor can do both in different seasons, so no single scope is correct
year-round. Avoided-emissions basis: N/A.
Deviations
- Single combined tolerance instead of G36’s per-sensor error bands. G36’s
precise form is
MAT_AVG + eps_MAT < min[(RAT_AVG − eps_RAT), (OAT_AVG − eps_OAT)]; the HVAC FDD Reference collapses that to oneeps_MATapplied once and this card inherits the simplification. One combined band needs a larger true error before it fires — fewer false positives, less sensitivity to drift in any individual sensor. The G36 form would need three parameters and an offset block on each sensor path ahead of theMin. - Instantaneous samples instead of averaged signals. G36 compares
time-averaged temperatures; this rule compares raw samples and leans on the
30-minute
persistdelay to reject noise. The two are not equivalent — averaging tolerates a signal that crosses the bound repeatedly while its mean stays outside, whereas persistence resets on every tick inside the envelope and can hide an oscillating MAT indefinitely. Steady drift, the fault this rule is for, reads the same either way. - Bound comparison rewritten as gap comparison.
min − mat > tol ⟺ mat < min − tol, algebraically identical, but it keepsmat_tolerancea single positiveset_paramvalue instead of a negative offset in the graph. Same rewrite as AHU-0028. - Strict inequality at the threshold.
GreaterThresholdisu > t, so a sensor sitting precisely at its rated accuracy stays off the alarm list; CDLRealsoffers no greater-or-equal comparison, so this is also the only available spelling. - Suppression is declared, not encoded. AHU-0028 tests the same envelope
with a shorter delay and suppresses this rule while active. The block graph
cannot express that — the engine is status-blind and each rule is an
independent composite — so it lives in
suppressed_byand CLU-09 for the host to enforce. - Operating states and preconditions are frontmatter, not graph. Freeze protection and the minutes after an economizer mode change are periods when MAT legitimately disagrees with the steady-state mixture; all of it is host-enforced, per the library’s stance.
persist.delayOnInit = true(Modelica/CDL default isfalse): a violation already present at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart.- Severity 3 (warning) comes from the reference’s chapter 9 card, its only severity statement for this fault — the §5.8.1 index carries no severity column.
Notes
The rule finds a contradiction between three sensors; it cannot say which one is lying. Direction narrows the list — a low MAT means MAT reads low or one of its bounds reads high — which is why the diagnoses mirror AHU-0003’s rather than copy them. Step 1 of the sensor-drift playbook resolves the ambiguity one sensor at a time; fix is on-site recalibration or replacement ($30–$80), and if all three check out, look at the mixing box for a leakage path onto the sensor. AHU-0028 runs a 15-minute delay against this pair’s 30, so the operator sees the integrity alarm first and this rule confirms which side of the envelope the reading fell on.
Test Vectors
9 scenarios, clock step 300 s over 7200 s.
| Scenario | Description |
|---|---|
normal_mixing | Reference vector: MAT 18 °C with OAT 5 / RAT 22 sits 13 °C above the low bound; nothing to report |
mat_below_envelope | Reference vector: MAT 1 °C is 4 °C below min(oat, rat) = 5 °C, twice the tolerance; alarms at alarm_delay (1800 s) |
within_tolerance | Reference vector: MAT 4 °C is 1 °C below the low bound — inside eps_MAT, so the sensor pair is still plausible |
edge_gap_equals_tolerance | Threshold edge: min(oat, rat) − mat = 2.0 °C exactly; the comparison is strict, so this is healthy |
edge_gap_just_over_tolerance | Threshold edge, other side: a gap of 2.1 °C clears the strict comparison and alarms at alarm_delay |
summer_oat_above_rat | Cooling season with OAT above RAT: RAT 24 °C is now the low bound and MAT 20 °C sits 4 °C under it |
high_excursion_is_not_this_fault | MAT 26 °C is 4 °C above the envelope — a real violation, but the high side belongs to AHU-0003 |
transient_excursion_never_alarms | MAT drops below the bound for 1200 s (< alarm_delay) during a damper stroke and recovers; the timer resets unfired |
fault_clears_on_recovery | Sustained low excursion alarms at 1800 s; recalibration at 3600 s drops the alarm on the same tick |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 7200
},
"scenarios": [
{
"name": "normal_mixing",
"description": "Reference vector: MAT 18 \u00b0C with OAT 5 / RAT 22 sits 13 \u00b0C above the low bound; nothing to report",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": 18.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "mat_below_envelope",
"description": "Reference vector: MAT 1 \u00b0C is 4 \u00b0C below min(oat, rat) = 5 \u00b0C, twice the tolerance; alarms at alarm_delay (1800 s)",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": 1.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 7200,
"equals": true
}
]
},
{
"name": "within_tolerance",
"description": "Reference vector: MAT 4 \u00b0C is 1 \u00b0C below the low bound \u2014 inside eps_MAT, so the sensor pair is still plausible",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": 4.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "edge_gap_equals_tolerance",
"description": "Threshold edge: min(oat, rat) \u2212 mat = 2.0 \u00b0C exactly; the comparison is strict, so this is healthy",
"inputs": {
"oat": 20.0,
"rat": 22.0,
"mat": 18.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "edge_gap_just_over_tolerance",
"description": "Threshold edge, other side: a gap of 2.1 \u00b0C clears the strict comparison and alarms at alarm_delay",
"inputs": {
"oat": 20.0,
"rat": 22.0,
"mat": 17.9
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 7200,
"equals": true
}
]
},
{
"name": "summer_oat_above_rat",
"description": "Cooling season with OAT above RAT: RAT 24 \u00b0C is now the low bound and MAT 20 \u00b0C sits 4 \u00b0C under it",
"inputs": {
"oat": 30.0,
"rat": 24.0,
"mat": 20.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 7200,
"equals": true
}
]
},
{
"name": "high_excursion_is_not_this_fault",
"description": "MAT 26 \u00b0C is 4 \u00b0C above the envelope \u2014 a real violation, but the high side belongs to AHU-0003",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": 26.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "transient_excursion_never_alarms",
"description": "MAT drops below the bound for 1200 s (< alarm_delay) during a damper stroke and recovers; the timer resets unfired",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": [
{
"t": 0,
"value": 18.0
},
{
"t": 900,
"value": 1.0
},
{
"t": 2100,
"value": 18.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "fault_clears_on_recovery",
"description": "Sustained low excursion alarms at 1800 s; recalibration at 3600 s drops the alarm on the same tick",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": [
{
"t": 0,
"value": 1.0
},
{
"t": 3600,
"value": 18.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 3300,
"equals": true
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 7200,
"equals": false
}
]
}
]
}