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-0030 — Excess outdoor air during heating mode

Statusverified — engine e2ff2f8, cxf:fnv1a128:a1a61e83113bf6d634eb7702f954f32f, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed byAHU-0028
RelatedAHU-0028, AHU-0021, AHU-0006
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §9, AHU-0030; Schein et al. 2006 (APAR); PNNL-27338; Gunay 2023
Operating statesheating (htg_vlv_cmd is tested in-rule; deeper mode gating stays host-side)

Preconditions (host-enforced): Supply fan running. MAT must pass its integrity gate (AHU-0028, see suppressed_by) — the reference names that rule as this one’s prerequisite, because the outdoor air fraction is a ratio of temperature differences and 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.

Points: oat, rat, mat, htg_vlv_cmd

Outputs:

  • yFault — True while the heating coil has been open and the outdoor air fraction more than oa_excess_margin above design, for at least alarm_delay, with the temperature difference large enough to evaluate
  • yTempDeltaOk — Evaluability signal — true when |oat − rat| exceeds min_delta; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
design_min_oa_fraction0.151designConst.kDesign minimum outdoor air fraction the unit should hold while heating (0–1)
oa_excess_margin0.151marginHigh.tTolerance above the design minimum fraction before the excess counts as a fault
min_delta6.0°CdeltaOk.tMinimum
valve_open_threshold5.0%htgOn.tHeating valve command above which the coil counts as heating
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

The heating coil is running and the unit is drawing well over its design minimum outdoor air. This is AHU-0021’s measurement narrowed to the operating state where excess ventilation costs the most: every extra cubic metre arrives at outdoor temperature and has to be lifted to supply temperature by the coil the rule is watching — in a −5 °C hour against 22 °C return air, 27 °C of lift on that share of the airflow. The classic cause is an economizer that never handed back: dampers open for free cooling in a mild afternoon, outdoor air turns cold overnight, and the sequence (or a stuck actuator, or a leaking blade seal) leaves them there while the heating coil compensates. Nothing about it is uncomfortable, so it survives until someone reads the fuel bill. Present in roughly 15% of buildings.

Detection Logic

oaf          = (mat − rat) / (oat − rat)
yTempDeltaOk = |oat − rat| > min_delta                (false ⇒ host reports NO_EVAL)
yFault       = (oaf − design_min_oa_fraction > oa_excess_margin)
               AND (htg_vlv_cmd > valve_open_threshold)
               AND yTempDeltaOk,
               sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0030 block graph

The fraction chain is AHU-0021’s, unchanged: matRat and oatRat form the two differences, oaf divides them, and margin subtracts the design fraction so marginHigh tests the excess against a single positive threshold. htgOn adds the heating condition and and1 conjoins it with the excess; and2 then gates the whole finding on deltaOk, whose output is also the boundary output yTempDeltaOk. That gate is what makes the unguarded division safe: CDL Divide follows IEEE-754, so oat = rat yields ±∞ or NaN rather than an error, and a near-zero denominator turns 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 and2 stops them. All three comparisons are strict: a valve parked at exactly 5%, a fraction sitting exactly at design_min_oa_fraction + oa_excess_margin, and a temperature difference of exactly min_delta all read as no-fault. persist requires 30 continuous minutes, riding out damper strokes and the mixing transient after a mode change; delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. OA damper stuck partially open
  2. OA damper minimum setpoint configured too high
  3. Economizer override not releasing after the transition out of free cooling
  4. Leaking OA damper seals

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, DIRECT_MEASUREMENT. The waste is computable from live data: excess_htg_kw = (actual_oaf − design_min_oa_fraction) × airflow × cp × (rat − oat), with the excess fraction already on the wire as oaf − designConst.k. Correcting it saves 3–15% of heating energy (PNNL EEM-06, OA damper faults; PNNL-27338), the top of that range in cold climates where (rat − oat) stays large for months. This is the defect AHU-0021 finds year-round, priced at its worst hour, which is why the two share a playbook.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typical 300–2,500 kg CO₂e/yr for the excess ventilation heating load. Most of it is scope 1 fuel at the boiler or furnace; the scope 2 share is whatever the site’s heating comes from electrically — heat pumps, electric resistance coils, and the extra fan energy of moving the air. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • min_delta is adopted, not transcribed: the reference states the fraction is computed only when |OAT − RAT| > min_delta but omits the parameter from its tunables table. 6.0 °C matches AHU-0021’s oaf_temp_threshold so the two rules agree on when the shared measurement is meaningful (PNNL-27338 uses 5 °F for the same computation); retune one and retune the other.
  • valve_open_threshold is likewise absent from the reference’s tunables table. 5% is what chapter 9 uses everywhere else a valve counts as open (AHU-0016, AHU-0025), so “heating” means the same thing chapter-wide.
  • The reference writes the test as oaf > (design_min_oa_fraction + oa_excess_margin), which would force the two tunables into one summed threshold. Feeding the design fraction as Reals.Sources.Constant.k and comparing the remaining margin against oa_excess_margin 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. A false yFault under a false yTempDeltaOk means “unknown”, not “healthy”.
  • Heating is in-graph because htg_vlv_cmd is a measured point; everything beyond it — occupancy, unit mode, morning warmup — stays host-side per this library’s design stance, as in AHU-0017 and AHU-0021.
  • All three 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

This rule and AHU-0021 share the fraction core and differ in three ways, all from their respective reference cards: the htgOn term is in-graph here where FC-055 leaves its scope to the host, the excess margin is 0.15 against FC-055’s 0.10, and the energy term uses the signed (rat − oat) because in heating the sign is known. Deploying both is not redundant — this one alarms earlier in winter with a sharper cost estimate, FC-055 watches the rest of the year.

Verify AHU-0028 is clear before acting: a MAT sensor reading 3 °C low in −5 °C weather manufactures this fault out of nothing, which is why the reference names it a prerequisite. If the fraction is genuinely high, command the OA damper to minimum and watch MAT. It should climb toward return temperature within minutes; if it does not, the problem is mechanical and the economizer-failure playbook’s on-site steps apply. If it does, the sequence never commanded minimum position and the fix is at a desk.

Test Vectors

7 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
normal_heating_ventilationReference vector: OAF 0.148 with the heating valve at 50% (oat −5 °C, rat 22 °C, mat 18 °C) — at design minimum, no excess
excess_oa_while_heatingReference vector: OAF 0.444 with the heating valve at 80% (oat −5 °C, rat 22 °C, mat 10 °C); alarms after alarm_delay (1800 s)
small_delta_not_evaluableReference vector:
valve_exactly_at_thresholdEdge case: heating valve sits exactly at valve_open_threshold (5%) with OAF 0.444 — the strict > means the coil does not count as heating
margin_exactly_at_thresholdEdge case: OAF 0.30 exactly (oat 2 °C, rat 22 °C, mat 16 °C), so the margin equals oa_excess_margin — the strict > means no fault
margin_just_over_thresholdEdge case: OAF 0.31 (mat 15.8 °C) clears the strict comparison and alarms after alarm_delay
valve_closes_before_delayTransient: OAF stays at 0.444 but the heating valve closes at t=1200 — 1200 s of heating, short of alarm_delay, so no alarm
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "normal_heating_ventilation",
      "description": "Reference vector: OAF 0.148 with the heating valve at 50% (oat \u22125 \u00b0C, rat 22 \u00b0C, mat 18 \u00b0C) \u2014 at design minimum, no excess",
      "inputs": {
        "oat": -5.0,
        "rat": 22.0,
        "mat": 18.0,
        "htg_vlv_cmd": 50.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "excess_oa_while_heating",
      "description": "Reference vector: OAF 0.444 with the heating valve at 80% (oat \u22125 \u00b0C, rat 22 \u00b0C, mat 10 \u00b0C); alarms after alarm_delay (1800 s)",
      "inputs": {
        "oat": -5.0,
        "rat": 22.0,
        "mat": 10.0,
        "htg_vlv_cmd": 80.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": "small_delta_not_evaluable",
      "description": "Reference vector: |oat \u2212 rat| = 2 \u00b0C (oat 20 \u00b0C, rat 22 \u00b0C, mat 21 \u00b0C, valve 20%) \u2014 the raw fraction reads 0.50, so an ungated rule would alarm; yTempDeltaOk stays false and holds yFault down",
      "inputs": {
        "oat": 20.0,
        "rat": 22.0,
        "mat": 21.0,
        "htg_vlv_cmd": 20.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_exactly_at_threshold",
      "description": "Edge case: heating valve sits exactly at valve_open_threshold (5%) with OAF 0.444 \u2014 the strict `>` means the coil does not count as heating",
      "inputs": {
        "oat": -5.0,
        "rat": 22.0,
        "mat": 10.0,
        "htg_vlv_cmd": 5.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "margin_exactly_at_threshold",
      "description": "Edge case: OAF 0.30 exactly (oat 2 \u00b0C, rat 22 \u00b0C, mat 16 \u00b0C), so the margin equals oa_excess_margin \u2014 the strict `>` means no fault",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 16.0,
        "htg_vlv_cmd": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "margin_just_over_threshold",
      "description": "Edge case: OAF 0.31 (mat 15.8 \u00b0C) clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 15.8,
        "htg_vlv_cmd": 50.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_closes_before_delay",
      "description": "Transient: OAF stays at 0.444 but the heating valve closes at t=1200 \u2014 1200 s of heating, short of alarm_delay, so no alarm",
      "inputs": {
        "oat": -5.0,
        "rat": 22.0,
        "mat": 10.0,
        "htg_vlv_cmd": [
          {
            "t": 0,
            "value": 80.0
          },
          {
            "t": 1200,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}