Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

AHU-0028 — Mixing box damper fault — MAT outside the OAT/RAT envelope

Statusverified — engine e2ff2f8, cxf:fnv1a128:e2e36af9468f68ae62924385965827fb, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
ClustersCLU-09
SuppressesAHU-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
RelatedAHU-0002, AHU-0003
Playbookssensor-drift
SourceHVAC 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 statesall (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.

Points: oat, rat, mat

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:

NameDefaultUnitCXF pathDescription
sensor_tolerance2.0°CbelowMin.t, aboveMax.tCombined sensor accuracy allowance applied to both envelope bounds; MAT may sit this far outside min/max before it counts as a fault
alarm_delay900.0spersist.delayTimeContinuous 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):

AHU-0028 block graph

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

  1. MAT sensor out of calibration or failed
  2. OAT sensor out of calibration
  3. RAT sensor out of calibration
  4. MAT sensor in a poor location — plenum stratification or solar gain on the sensor rather than the mixed stream
  5. 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 retuning sensor_tolerance would 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 under params.sensor_tolerance.cxf.
  • GreaterThreshold is u > t, so a gap of exactly sensor_tolerance reads healthy, matching the reference’s strict mat < 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 suppresses and CLU-09 for the host to enforce. The graph is equipment-agnostic: the two RTU entries in suppresses are 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 = true on persist: 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.

ScenarioDescription
normal_mixingReference vector: MAT 18 °C sits inside the 5–22 °C envelope
mat_below_bothReference vector: MAT 1 °C is 4 °C below min(oat, rat); alarms after alarm_delay (900 s)
mat_above_bothReference vector: MAT 26 °C is 4 °C above max(oat, rat); alarms after alarm_delay
within_toleranceReference vector: MAT 19 °C is 1 °C below min(oat, rat) — inside sensor_tolerance
edge_gap_equals_toleranceThreshold edge: min − mat = 2.0 °C exactly; the comparison is strict, so no fault
edge_gap_just_over_toleranceThreshold edge: min − mat = 2.1 °C clears the strict comparison and alarms after alarm_delay
summer_oat_above_ratCooling season, OAT above RAT: MAT 35 °C exceeds max(oat, rat) by 3 °C
full_outdoor_air_pins_to_boundEconomizer at 100% OA: MAT tracks OAT and lands 0.5 °C below it — tolerance absorbs it
transient_excursion_never_alarmsMAT dips out of range for 600 s (< alarm_delay) and recovers; persistence timer resets
fault_clears_on_recoverySustained 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
        }
      ]
    }
  ]
}