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-0002 — Mixed air temperature too low

Statusverified — engine e2ff2f8, cxf:fnv1a128:8591ae042848563cd91744d8168eba08, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36§5.16.14 FC#2
ClustersCLU-09
Suppresses
Suppressed byAHU-0028
RelatedAHU-0003, AHU-0028
Playbookssensor-drift
SourceHVAC 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 statesOS 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.

Points: oat, rat, mat

Outputs:

  • yFault — True while MAT has stayed more than mat_tolerance below min(oat, rat) for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
mat_tolerance2.0°CgapBig.tCombined 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_delay1800.0spersist.delayTimeContinuous 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):

AHU-0002 block graph

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

  1. MAT sensor out of calibration, reading low
  2. OAT sensor out of calibration, reading high
  3. RAT sensor out of calibration, reading high
  4. 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 one eps_MAT applied 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 the Min.
  • Instantaneous samples instead of averaged signals. G36 compares time-averaged temperatures; this rule compares raw samples and leans on the 30-minute persist delay 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 keeps mat_tolerance a single positive set_param value instead of a negative offset in the graph. Same rewrite as AHU-0028.
  • Strict inequality at the threshold. GreaterThreshold is u > t, so a sensor sitting precisely at its rated accuracy stays off the alarm list; CDL Reals offers 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_by and 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 is false): 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.

ScenarioDescription
normal_mixingReference vector: MAT 18 °C with OAT 5 / RAT 22 sits 13 °C above the low bound; nothing to report
mat_below_envelopeReference vector: MAT 1 °C is 4 °C below min(oat, rat) = 5 °C, twice the tolerance; alarms at alarm_delay (1800 s)
within_toleranceReference vector: MAT 4 °C is 1 °C below the low bound — inside eps_MAT, so the sensor pair is still plausible
edge_gap_equals_toleranceThreshold edge: min(oat, rat) − mat = 2.0 °C exactly; the comparison is strict, so this is healthy
edge_gap_just_over_toleranceThreshold edge, other side: a gap of 2.1 °C clears the strict comparison and alarms at alarm_delay
summer_oat_above_ratCooling 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_faultMAT 26 °C is 4 °C above the envelope — a real violation, but the high side belongs to AHU-0003
transient_excursion_never_alarmsMAT drops below the bound for 1200 s (< alarm_delay) during a damper stroke and recovers; the timer resets unfired
fault_clears_on_recoverySustained 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
        }
      ]
    }
  ]
}