AHU-0028 — Mixing box damper fault — MAT outside the OAT/RAT envelope
| Status | verified — engine e2ff2f8, cxf:fnv1a128:e2e36af9468f68ae62924385965827fb, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | COMFORT_ENERGY |
| Confidence | LOW |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | CLU-09 |
| Suppresses | AHU-0002, AHU-0003, AHU-0005, AHU-0006, AHU-0008, AHU-0010, AHU-0012, AHU-0014, AHU-0015, AHU-0021, AHU-0030, RTU-0005, RTU-0006 |
| Suppressed by | — |
| Related | AHU-0002, AHU-0003 |
| Playbooks | sensor-drift |
| Source | HVAC FDD Reference v1.0 §9, AHU-0028; Schein et al. 2006 (APAR Rule 1); Bushby et al. 2001 (NIST/CEC PIER Project 2.3, APAR rules 26/27 — earliest form of this envelope test); Torabi 2022; Gunay 2023 |
| Operating states | all (supply fan running) |
Preconditions (host-enforced): Supply fan running and the dampers commanded to a mixing state. The host must not evaluate during coil freeze-protection or within 2 min of an economizer mode transition, when MAT lags the mixture it is supposed to report. When the fan is off or damper state is unknown, the verdict is NO_EVAL, not healthy.
Outputs:
yFault— True while MAT has stayed outside the min(oat, rat)–max(oat, rat) envelope by more than sensor_tolerance for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
sensor_tolerance | 2.0 | °C | belowMin.t, aboveMax.t | Combined sensor accuracy allowance applied to both envelope bounds; MAT may sit this far outside min/max before it counts as a fault |
alarm_delay | 900.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (15 min) |
Description
Mixed air is a blend of outdoor and return air, so its temperature must lie between the two source temperatures. When MAT falls outside the OAT–RAT envelope by more than combined sensor accuracy, the physics has been violated and one of three things is wrong: a temperature sensor is out of calibration, the MAT sensor is reading a stratified or sun-struck slice of the plenum rather than the mixture, or the mixing box itself is short-circuiting. This is APAR Rule 1 (Schein et al. 2006), the sanity check every mixed-air diagnostic is built on — the trigger of cluster CLU-09 (Sensor Integrity Failure) and the library’s first use of the suppression mechanism, since the MAT-consuming rules are running on data known to be wrong while it is active. Roughly 15% of buildings have at least one AHU with a mixed-air sensor this far out.
Detection Logic
T_min = min(oat, rat) − sensor_tolerance
T_max = max(oat, rat) + sensor_tolerance
yFault = (mat < T_min OR mat > T_max), sustained for alarm_delay
Block graph (rule.cxf.jsonld):
The graph tests the same condition in gap form: lowGap computes
min(oat, rat) − mat and highGap computes mat − max(oat, rat), and each
gap is compared against sensor_tolerance by a strict GreaterThreshold — a
gap of exactly 2.0 °C is not a fault, 2.1 °C is. outOfRange ORs the two sides
and persist requires the excursion to hold for the full alarm_delay, so a
MAT that swings out and back during a damper stroke never alarms. Recovery is
immediate; delayOnInit = true holds the window across a controller restart.
Which bound is which flips with the season — in cooling weather OAT is the upper
bound and RAT the lower — so Min and Max are used rather than a fixed
OAT-below-RAT assumption. During 100% outdoor-air or 100% return-air operation
the mixture legitimately pins to one bound, which is why sensor_tolerance is a
combined sensor-accuracy allowance and not a mixing-quality threshold.
Possible Diagnoses
- MAT sensor out of calibration or failed
- OAT sensor out of calibration
- RAT sensor out of calibration
- MAT sensor in a poor location — plenum stratification or solar gain on the sensor rather than the mixed stream
- Mixing box short-circuiting: OA bypassing the mix and striking the sensor directly
Energy Impact
COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. A mis-read MAT costs nothing by itself; its cost is indirect and can be large, since a biased mixed-air reading drives the economizer to the wrong damper position and can hold a coil open against outdoor air that would have done the job for free. PNNL EEM-01 (sensor recalibration) puts the recoverable range at 0–5% of site energy across an entire sensor population, sensor-dependent and climate-neutral. This rule’s value is the accuracy it restores to the rules it gates, not savings of its own.
Emissions Impact
QUALITATIVE_EMISSIONS, LOW confidence; no direct emissions — a sensor reading
neither burns fuel nor draws power. Scope is recorded as 1|2 because it
follows the subsystem the bad reading distorts: unnecessary preheat lands in
scope 1, disabled economizing or extra mechanical cooling in scope 2, and the
same drifted sensor can do both in different seasons. Avoided-emissions basis:
N/A.
Deviations
- The reference’s bound comparison is rewritten as a gap comparison. Compared
directly against
T_min/T_max, one card parameter would need two signed values, so a host retuningsensor_tolerancewould have to negate one. Subtracting and comparing the gap against a positive threshold is algebraically identical (min − mat > tol ⟺ mat < min − tol) and leaves one number to retune, with both paths listed underparams.sensor_tolerance.cxf. GreaterThresholdisu > t, so a gap of exactlysensor_tolerancereads healthy, matching the reference’s strictmat < T_min/mat > T_max.- Suppression is declared, not encoded. A status-blind engine of independent
composites cannot silence sibling rules, so the relationship lives in
suppressesand CLU-09 for the host to enforce. The graph is equipment-agnostic: the two RTU entries insuppressesare gated by this rule instantiated on the RTU’s own mat/oat/rat points. - Freeze-protect operation and the two-minute window after an economizer mode change — periods when MAT legitimately disagrees with the steady-state mixture — are host-enforced preconditions rather than in-rule gates.
delayOnInit = trueonpersist: a controller restart mid-excursion still waits out the full 15 min before alarming (startup conservatism per AHU-0016).
Notes
The suppression contract matters more than the fault itself. While yFault is
true the host must silence all thirteen rules in suppresses and report every
MAT-derived verdict as NO_EVAL rather than healthy — silence is not a clean bill
of health. Both halves are necessary: a rule that fires on garbage sensor data
erodes operator trust, and a rule that reports “no fault” from the same garbage
hides real problems. Hence Step 1.3 of the
sensor-drift playbook — when several
faults fire on one AHU at once, suspect a shared sensor first. The fix is
on-site: recalibrate against a NIST-traceable reference, or replace ($30–$80).
Test Vectors
10 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
normal_mixing | Reference vector: MAT 18 °C sits inside the 5–22 °C envelope |
mat_below_both | Reference vector: MAT 1 °C is 4 °C below min(oat, rat); alarms after alarm_delay (900 s) |
mat_above_both | Reference vector: MAT 26 °C is 4 °C above max(oat, rat); alarms after alarm_delay |
within_tolerance | Reference vector: MAT 19 °C is 1 °C below min(oat, rat) — inside sensor_tolerance |
edge_gap_equals_tolerance | Threshold edge: min − mat = 2.0 °C exactly; the comparison is strict, so no fault |
edge_gap_just_over_tolerance | Threshold edge: min − mat = 2.1 °C clears the strict comparison and alarms after alarm_delay |
summer_oat_above_rat | Cooling season, OAT above RAT: MAT 35 °C exceeds max(oat, rat) by 3 °C |
full_outdoor_air_pins_to_bound | Economizer at 100% OA: MAT tracks OAT and lands 0.5 °C below it — tolerance absorbs it |
transient_excursion_never_alarms | MAT dips out of range for 600 s (< alarm_delay) and recovers; persistence timer resets |
fault_clears_on_recovery | Sustained excursion alarms at 900 s; the alarm drops on the tick MAT returns inside the envelope |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "normal_mixing",
"description": "Reference vector: MAT 18 \u00b0C sits inside the 5\u201322 \u00b0C envelope",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": 18.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "mat_below_both",
"description": "Reference vector: MAT 1 \u00b0C is 4 \u00b0C below min(oat, rat); alarms after alarm_delay (900 s)",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": 1.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "mat_above_both",
"description": "Reference vector: MAT 26 \u00b0C is 4 \u00b0C above max(oat, rat); alarms after alarm_delay",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": 26.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "within_tolerance",
"description": "Reference vector: MAT 19 \u00b0C is 1 \u00b0C below min(oat, rat) \u2014 inside sensor_tolerance",
"inputs": {
"oat": 20.0,
"rat": 22.0,
"mat": 19.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "edge_gap_equals_tolerance",
"description": "Threshold edge: min \u2212 mat = 2.0 \u00b0C exactly; the comparison is strict, so no fault",
"inputs": {
"oat": 20.0,
"rat": 22.0,
"mat": 18.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "edge_gap_just_over_tolerance",
"description": "Threshold edge: min \u2212 mat = 2.1 \u00b0C clears the strict comparison and alarms after alarm_delay",
"inputs": {
"oat": 20.0,
"rat": 22.0,
"mat": 17.9
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "summer_oat_above_rat",
"description": "Cooling season, OAT above RAT: MAT 35 \u00b0C exceeds max(oat, rat) by 3 \u00b0C",
"inputs": {
"oat": 32.0,
"rat": 24.0,
"mat": 35.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "full_outdoor_air_pins_to_bound",
"description": "Economizer at 100% OA: MAT tracks OAT and lands 0.5 \u00b0C below it \u2014 tolerance absorbs it",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": 4.5
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "transient_excursion_never_alarms",
"description": "MAT dips out of range for 600 s (< alarm_delay) and recovers; persistence timer resets",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": [
{
"t": 0,
"value": 18.0
},
{
"t": 600,
"value": 1.0
},
{
"t": 1200,
"value": 18.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "fault_clears_on_recovery",
"description": "Sustained excursion alarms at 900 s; the alarm drops on the tick MAT returns inside the envelope",
"inputs": {
"oat": 5.0,
"rat": 22.0,
"mat": [
{
"t": 0,
"value": 1.0
},
{
"t": 1800,
"value": 18.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 1740,
"equals": true
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": false
}
]
}
]
}