AHU-0003 — Mixed air temperature too high
| Status | verified — engine e2ff2f8, cxf:fnv1a128:4c3231f994e9323f54b769c76ab050e6, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 1 |
| Category | COMFORT_ENERGY |
| Confidence | LOW |
| Estimation | QUALITATIVE_ONLY |
| G36 | §5.16.14 FC#3 |
| Clusters | CLU-09 |
| Suppresses | — |
| Suppressed by | AHU-0028 |
| Related | AHU-0002, AHU-0028 |
| Playbooks | sensor-drift |
| Source | HVAC FDD Reference v1.0 §9, AHU-0003; G36 §5.16.14 FC#3; 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 above max(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 above the upper 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 warmer than both streams. When MAT reads more than combined sensor accuracy above the warmer of OAT and RAT, the reading is impossible and something is wrong with the measurement or the sensor’s surroundings: a sensor out of calibration, or one mounted where sunlight or a warm surface reaches it instead of the mixed stream.
This is G36 §5.16.14 FC#3, the high half of the mixed-air envelope check. AHU-0002 is its mirror on the low 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 > max(oat, rat) + mat_tolerance,
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
Max rather than a fixed RAT-above-OAT assumption, because which stream is
warmer flips with the season. The threshold comparison is strict, so a MAT
sitting exactly 2.0 °C above the bound is healthy and 2.1 °C above is not.
persist requires 30 minutes of continuous violation — a MAT that spikes and
recovers during a damper stroke never alarms, and any interruption restarts the
timer — while recovery is immediate on the tick MAT drops back inside the
bound. Nothing below the envelope can trip this rule: a MAT 4 °C under both
sources violates the same physics and reads healthy here, because that case is
AHU-0002’s.
Possible Diagnoses
- MAT sensor out of calibration, reading high
- OAT sensor out of calibration, reading low
- RAT sensor out of calibration, reading low
- Solar gain at the MAT sensor location — the sensor sees radiant heat or a sun-struck duct wall rather than the mixed stream
Energy Impact
COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. A mis-read temperature costs nothing directly; the cost is downstream. A MAT biased high reads as excess heat in the mixture, so the mixed-air loop pulls in more outdoor air than the mixture needs and the coil downstream pays for the difference — preheat in winter, mechanical cooling in summer. 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: excess outdoor
air paid for at the preheat coil lands in Scope 1 (on-site combustion), the
same excess paid for at the cooling coil 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 > max[(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 theMax. - 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.
mat − max > tol ⟺ mat > max + tol, algebraically identical, but it keepsmat_tolerancea single positiveset_paramvalue. Matches AHU-0002 and AHU-0028 spelling for spelling. - 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 high MAT means MAT reads high or one of its bounds reads low — and solar gain earns a place on this side only: a sun-struck sensor reads high, never low. 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 relocating a MAT probe out of a sunlit spot fixes what recalibration cannot. 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 4 °C below the high bound; nothing to report |
mat_above_envelope | Reference vector: MAT 28 °C is 6 °C above max(oat, rat) = 22 °C; alarms at alarm_delay (1800 s) |
within_tolerance | Mirror of the FC#2 within-tolerance vector: MAT 23 °C is 1 °C above the high bound — inside eps_MAT |
edge_gap_equals_tolerance | Threshold edge: mat − max(oat, rat) = 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: OAT 30 °C is now the high bound and MAT 34 °C sits 4 °C over it |
low_excursion_is_not_this_fault | MAT 1 °C is 4 °C below the envelope — a real violation, but the low side belongs to AHU-0002 |
transient_excursion_never_alarms | MAT rises above the bound for 1200 s (< alarm_delay) during a damper stroke and recovers; the timer resets unfired |
fault_clears_on_recovery | Sustained high 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 4 \u00b0C below the high 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_above_envelope",
"description": "Reference vector: MAT 28 \u00b0C is 6 \u00b0C above max(oat, rat) = 22 \u00b0C; alarms at alarm_delay (1800 s)",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": 28.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": "Mirror of the FC#2 within-tolerance vector: MAT 23 \u00b0C is 1 \u00b0C above the high bound \u2014 inside eps_MAT",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "edge_gap_equals_tolerance",
"description": "Threshold edge: mat \u2212 max(oat, rat) = 2.0 \u00b0C exactly; the comparison is strict, so this is healthy",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": 24.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": 5.0,
"rat": 22.0,
"mat": 24.1
},
"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: OAT 30 \u00b0C is now the high bound and MAT 34 \u00b0C sits 4 \u00b0C over it",
"inputs": {
"oat": 30.0,
"rat": 24.0,
"mat": 34.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 7200,
"equals": true
}
]
},
{
"name": "low_excursion_is_not_this_fault",
"description": "MAT 1 \u00b0C is 4 \u00b0C below the envelope \u2014 a real violation, but the low side belongs to AHU-0002",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": 1.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "transient_excursion_never_alarms",
"description": "MAT rises above 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": 28.0
},
{
"t": 2100,
"value": 18.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "fault_clears_on_recovery",
"description": "Sustained high 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": 28.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
}
]
}
]
}