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-0021 — Excess outdoor air during occupied hours

Statusverified — engine e2ff2f8, cxf:fnv1a128:a9c58cb4c0e46fe131faed48a0c8efc0, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed byAHU-0028
RelatedAHU-0006, AHU-0017, AHU-0030
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §9, AHU-0021; PNNL-27338 §3; PNNL EEM-17 (demand control ventilation)
Operating statesoccupied, non-economizer operation (host-gated); reference OS 1, OS 4

Preconditions (host-enforced): Supply fan running. The host must not evaluate during economizer operation — bringing in more than the design minimum is the point of economizing, and this rule cannot tell that apart from a stuck damper. MAT must pass its integrity gate (AHU-0028, see suppressed_by): the fraction is a ratio of temperature differences, so a biased mixed-air reading moves it directly. The temperature-difference gate is signalled in-rule by yTempDeltaOk; when it is false the verdict is NO_EVAL, not healthy. Additionally suspend evaluation (NO_EVAL) while demand-controlled ventilation or a ventilation-demand override holds outdoor-air flow above the minimum-OA state — at VAV turndown a constant ventilation flow becomes a large OA fraction and this rule fires on healthy operation (fleet-validated FP mechanism; see the validation block).

Points: mat, rat, oat

Outputs:

  • yFault — True while the outdoor air fraction has stayed more than oaf_threshold above desired_oaf for at least alarm_delay, with the temperature difference large enough to evaluate
  • yTempDeltaOk — Evaluability signal — true when |oat − rat| exceeds oaf_temp_threshold; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
desired_oaf0.151designConst.kDesign outdoor air fraction the unit should hold at minimum ventilation (0–1)
oaf_threshold0.11marginHigh.tTolerance above the design fraction before the excess counts as a fault
oaf_temp_threshold6.0°CdeltaOk.tMinimum
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

The unit is pulling in more outdoor air than its design minimum ventilation requires, and it is not economizing — every extra cubic metre has to be heated or cooled to supply temperature for no ventilation benefit. Unlike a failed economizer, this fault is invisible from the zone: the space stays comfortable, the coils simply work harder to keep it that way, through every occupied hour. The outdoor air fraction is inferred from the mixing-box energy balance rather than measured, which makes the diagnostic cheap — three temperatures, no airflow station — and conditional, since the inference only holds when outdoor and return air differ enough to locate the fraction; hence the explicit evaluability output. AHU-0030 is the same measurement narrowed to heating operation, where the excess is most expensive. Present in roughly 15% of buildings.

Detection Logic

oaf          = (mat − rat) / (oat − rat)
yTempDeltaOk = |oat − rat| > oaf_temp_threshold     (false ⇒ host reports NO_EVAL)
yFault       = (oaf − desired_oaf > oaf_threshold) AND yTempDeltaOk,
               sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0021 block graph

matRat and oatRat form the two differences, oaf divides them, and margin subtracts the design fraction so that marginHigh tests the excess against a single positive threshold. oatRat fans out a second time into absDelta and deltaOk, whose output is both the boundary output yTempDeltaOk and the second input of gate — so yFault is held down over exactly the interval the host is told to disregard it. That matters because the division is unguarded: CDL Divide follows IEEE-754, so oat = rat yields ±∞ or NaN rather than an error, and a near-zero denominator amplifies ordinary sensor noise into a fraction of any magnitude. NaN compares false everywhere, but ±∞ and a noise-inflated finite fraction can both raise marginHigh, and gate is what stops them. Both comparisons are strict: a fraction sitting exactly at desired_oaf + oaf_threshold is not a fault, and a temperature difference of exactly oaf_temp_threshold is not evaluable. The fraction is signed consistently across the year — summer both differences positive, winter both negative — so no seasonal branch is needed. persist requires 30 continuous minutes, riding out damper strokes and the mixing transient after a mode change; delayOnInit = true holds that window across a restart.

Possible Diagnoses

  1. OA damper minimum position set too high
  2. OA damper not closing to minimum — stuck, or the sequence never commands it back down after a purge or economizer period
  3. Damper actuator issue: failed actuator, slipped linkage, or a position feedback that disagrees with the blade
  4. Exhaust fan creating negative building pressure that pulls outdoor air in past the minimum position

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, DIRECT_MEASUREMENT. The waste is computable from live data: excess_oa_kw = (actual_oaf − desired_oaf) × airflow × cp × |oat − rat|, with the excess fraction already on the wire as oaf − designConst.k. Correcting minimum ventilation saves 2–10% of AHU thermal energy (PNNL-27338), the upper half of that range in heating-dominant climates. PNNL EEM-17 (demand control ventilation) is the related retrofit and this rule is its screening test: a unit already over its design fraction with the dampers at minimum will not benefit from CO₂ control until the mechanical problem is fixed.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typical 500–4,000 kg CO₂e/yr for the excess ventilation thermal load. The split follows the season: excess outdoor air in winter usually burns scope 1 fuel at the heating coil, in summer it draws scope 2 electricity at the chiller. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • The reference’s AND NOT econ_favorable term is not in the block graph. Economizer operation is an operating state, not a measurement, and this library keeps operating-state gating host-side (precedent: AHU-0017’s OS-4 restriction), so the term lives in operating_states and preconditions instead. A host that evaluates this rule during economizing will get a fault, and it will be the host’s bug.
  • The reference writes the test as oaf > (desired_oaf + oaf_threshold), which would force the two tunables into one summed threshold. Feeding desired_oaf as Reals.Sources.Constant.k and comparing the remaining margin against oaf_threshold is algebraically identical and keeps both retunable alone.
  • Evaluability is an output, not just a precondition: the |oat − rat| test is computable from this rule’s own inputs, so SCHEMA.md requires exposing it as yTempDeltaOk (PNNL-27338 uses 5 °F for the same computation; the reference’s 6 °C default is adopted). A false yFault under a false yTempDeltaOk means “unknown”, not “healthy”.
  • Both comparisons are strict (>); the reference does not specify boundary behavior, so the library’s strict convention applies.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: an excess already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

Check the minimum position setpoint before sending anyone to the roof — the most common cause is a minimum dialled up during a ventilation complaint or a commissioning shortcut, and it is a $0 desk fix. The economizer-failure playbook’s damper and linkage steps come after that.

The rule is deliberately blind to why the fraction is high. A damper stuck at 40% and a building held under negative pressure by an oversized exhaust fan produce the same number, and the second is invisible from the AHU’s own points: if commanding the damper closed does not move the fraction, measure building pressure before replacing the actuator.

Test Vectors

8 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
normal_ventilationReference vector: OAF 0.18 against a 0.25 alarm point (oat 2 °C, rat 22 °C, mat 18.4 °C) — design minimum plus normal spread
excess_outdoor_airReference vector: OAF 0.40 (oat 2 °C, rat 22 °C, mat 14.0 °C) — 0.25 above design; alarms after alarm_delay (1800 s)
margin_exactly_at_thresholdEdge case: OAF 0.25 exactly (mat 17.0 °C), so the margin equals oaf_threshold — the strict > means no fault
margin_just_over_thresholdEdge case: OAF 0.26 (mat 16.8 °C) clears the strict comparison and alarms after alarm_delay
small_delta_not_evaluableNO_EVAL:
delta_exactly_at_thresholdEdge case:
transient_excess_clears_before_delayTransient: OAF jumps to 0.40 at t=600 and falls back to 0.18 at t=1800 — 1200 s of excess, short of alarm_delay, so no alarm
summer_excess_oat_above_ratSign case: outdoor air warmer than return (oat 35 °C, rat 24 °C, mat 28.4 °C) — the fraction still reads 0.40 and the rule alarms after alarm_delay
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "normal_ventilation",
      "description": "Reference vector: OAF 0.18 against a 0.25 alarm point (oat 2 \u00b0C, rat 22 \u00b0C, mat 18.4 \u00b0C) \u2014 design minimum plus normal spread",
      "inputs": {
        "mat": 18.4,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "excess_outdoor_air",
      "description": "Reference vector: OAF 0.40 (oat 2 \u00b0C, rat 22 \u00b0C, mat 14.0 \u00b0C) \u2014 0.25 above design; alarms after alarm_delay (1800 s)",
      "inputs": {
        "mat": 14.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "margin_exactly_at_threshold",
      "description": "Edge case: OAF 0.25 exactly (mat 17.0 \u00b0C), so the margin equals oaf_threshold \u2014 the strict `>` means no fault",
      "inputs": {
        "mat": 17.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "margin_just_over_threshold",
      "description": "Edge case: OAF 0.26 (mat 16.8 \u00b0C) clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "mat": 16.8,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "small_delta_not_evaluable",
      "description": "NO_EVAL: |oat \u2212 rat| = 2 \u00b0C (oat 20 \u00b0C, rat 22 \u00b0C) with mat 14 \u00b0C, so the raw fraction reads 4.0 \u2014 an ungated rule would alarm; yTempDeltaOk stays false and holds yFault down",
      "inputs": {
        "mat": 14.0,
        "rat": 22.0,
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_exactly_at_threshold",
      "description": "Edge case: |oat \u2212 rat| = 6.0 \u00b0C exactly (oat 16 \u00b0C, rat 22 \u00b0C) with OAF 0.50 \u2014 the strict `>` leaves the rule not evaluable",
      "inputs": {
        "mat": 19.0,
        "rat": 22.0,
        "oat": 16.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_excess_clears_before_delay",
      "description": "Transient: OAF jumps to 0.40 at t=600 and falls back to 0.18 at t=1800 \u2014 1200 s of excess, short of alarm_delay, so no alarm",
      "inputs": {
        "mat": [
          {
            "t": 0,
            "value": 18.4
          },
          {
            "t": 600,
            "value": 14.0
          },
          {
            "t": 1800,
            "value": 18.4
          }
        ],
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "summer_excess_oat_above_rat",
      "description": "Sign case: outdoor air warmer than return (oat 35 \u00b0C, rat 24 \u00b0C, mat 28.4 \u00b0C) \u2014 the fraction still reads 0.40 and the rule alarms after alarm_delay",
      "inputs": {
        "mat": 28.4,
        "rat": 24.0,
        "oat": 35.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}