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

RTU-0005 — Excess outdoor air intake

Statusverified — engine e2ff2f8, cxf:fnv1a128:403a34cdcdb48f2f6c7f4827a80b4ab9, 2026-08-17
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed byAHU-0028
RelatedAHU-0021, AHU-0030, RTU-0006, RTU-0004, RTU-0010
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §11, RTU-0005; PNNL-23790 (RTU AFDD4/AFDD5); PNNL EEM-17 (demand control ventilation); PNNL EEM-23 (RTU advanced controls)
Operating statesoccupied operation with the economizer locked out (host-gated); the reference’s economizer_should_be_inactive(oat, mode) term lives in preconditions, not in the graph

Preconditions (host-enforced): Supply fan running. The host must not evaluate while the economizer is legitimately open — drawing more than the design minimum is the point of economizing, and these three temperatures cannot tell that apart from a damper that never closed. 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.

Points: oat, rat, mat

Outputs:

  • yFault — True while the outdoor air fraction has stayed more than oa_excess_margin above design_min_oa_fraction 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 when it is not economizing (0–1)
oa_excess_margin0.151marginHigh.tTolerance above the design minimum before the excess counts as a fault
min_delta6.0°CdeltaOk.tMinimum
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

The unit is drawing well over its design minimum outdoor air at an hour when it has no business economizing. Every extra cubic metre arrives at outdoor temperature and has to be dragged to supply temperature by the gas heat or the compressors, and none of it buys ventilation the code did not already have. Nothing about it is uncomfortable — the space stays on setpoint, the unit simply runs harder — so the defect survives until someone reads the fuel bill. Packaged units make it common: economizer, minimum-position setting and dampers all live in one weather-exposed cabinet, and Cowan’s 2004 survey found at least one economizer fault on 54% of units. The outdoor air fraction is inferred from the mixing-box energy balance rather than measured, which is what makes the diagnostic cheap — three temperatures, no airflow station — and also what makes it conditional, hence the explicit evaluability output.

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 yTempDeltaOk,
               sustained for alarm_delay

Block graph (rule.cxf.jsonld):

RTU-0005 block graph

The fraction core is AHU-0021’s, unchanged, bound to the RTU point dictionary. gate is what makes the unguarded division safe: CDL Divide follows IEEE-754, so oat = rat yields ±∞ or NaN and a near-zero denominator amplifies ordinary sensor noise into a fraction of any magnitude. NaN compares false everywhere, and ±∞ or a noise-inflated finite fraction can raise marginHigh but cannot pass gate, because a denominator small enough to misbehave is by construction one below min_delta. Garbage arithmetic can only make the rule report itself unevaluable. yTempDeltaOk leaves the block as well as feeding the gate, so a host that reads it learns the difference between “not faulted” and “cannot tell”. Both comparisons are strict: a fraction sitting exactly at design_min_oa_fraction + oa_excess_margin is not a fault, and a temperature difference of exactly min_delta is not evaluable. The quotient is signed consistently on both sides of the year — winter makes both differences negative, summer both positive — so no seasonal branch is needed. persist requires 30 continuous minutes, riding out a damper stroke and the mixing transient after a stage change; recovery is immediate, and delayOnInit = true holds the window across a restart.

Possible Diagnoses

  1. OA damper minimum position set too high
  2. OA damper not closing to the commanded minimum
  3. Damper blade seals deteriorated
  4. Economizer lockout not engaging

Energy Impact

EXCESS_CONSUMPTION, HIGH confidence, DIRECT_MEASUREMENT. The waste is computable from live data: excess_oa_kw = (actual_oaf − design_min_oa_fraction) × airflow × cp × |oat − rat|, with the excess fraction already on the wire as oaf − designConst.k. Correcting minimum ventilation is worth 2–10% of the unit’s thermal energy, the upper half of that range in heating-dominant climates where the outdoor-to-return difference is largest for months at a time. The reference maps the fault to PNNL EEM-17 (demand control ventilation) and EEM-23 (RTU advanced controls); this rule screens both, since a unit that cannot hold its design fraction will not benefit from either until the mechanical problem is fixed.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typically 300–2,500 kg CO₂e/yr for the excess ventilation load. The split follows the season and the unit: winter excess burns scope 1 gas at the furnace section, summer excess draws scope 2 electricity at the compressors, and on an all-electric packaged unit the whole exchange collapses to scope 2. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • min_delta default adopted, not transcribed. The reference states the fraction is computed only when |OAT − RAT| > min_delta but omits the parameter from its tunables table. This card adopts 6.0 °C, matching AHU-0021, AHU-0030 and RTU-0006 so every rule running this quotient agrees on when it is meaningful (PNNL-27338 uses 5 °F for the same computation). A site that retunes one should retune all of them.
  • The reference’s economizer_should_be_inactive(oat, mode) term is not in the block graph. Economizer state is an operating state, not a measurement, and this library keeps state gating host-side (precedent: AHU-0021, whose reference card carries the same term). A host that evaluates this rule while the unit is economizing gets a sustained fault, and it is the host’s bug; mode is not a canonical RTU point in any case.
  • Design fraction as a constant, excess as a threshold. The reference writes oa_fraction > (design_min_oa_fraction + oa_excess_margin), which implemented literally would sum the two tunables into one threshold and stop a host retuning either alone. Feeding the design fraction in as Reals.Sources.Constant.k keeps both as independent set_param paths with no sign flips. Algebraically identical.
  • Evaluability is an output, not just a precondition. The min_delta test is computable from this rule’s own inputs, so SCHEMA.md requires exposing it as yTempDeltaOk. False yFault under false yTempDeltaOk means “unknown”, not “healthy”, and the host must treat it that way.
  • Both comparisons are strict (>). The reference does not specify boundary behavior and CDL Reals offers no GreaterEqual, so the choice is made rather than inherited; the disagreement with an inclusive reading has measure zero on a real temperature signal and errs toward silence.
  • The reference publishes no worked vectors for this fault, so every scenario in vectors.json is authored from the equation, following AHU-0021’s suite shape.
  • 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 restart.

Notes

suppressed_by: [AHU-0028] is transcribed from the reference and points across equipment families on purpose: AHU-0028’s graph consumes nothing but mat, oat and rat, so the host instantiates it against this RTU’s own three points. Deploy the pair together — a MAT sensor reading 4.5 °C low in −5 °C weather turns a compliant 0.15 fraction into 0.32 and manufactures this fault out of nothing, and on a milder day with a 10 °C spread under 2 °C of bias does the same.

RTU-0006 is this rule’s mirror on the same three temperatures: this one alarms more than 0.15 above design, that one more than 0.05 below. The margins are deliberately asymmetric — excess air costs money, deficient air costs air quality — and neither can fire while the other does. If the fraction is genuinely high, command the OA damper to minimum and watch mixed air: it should climb toward return temperature within minutes, and if it does not the problem is mechanical (the economizer-failure playbook’s on-site steps). Check the minimum position setpoint first — the most common cause is a number dialled up during a ventilation complaint, and that is a $0 fix.

Test Vectors

10 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
normal_minimum_ventilationOAF 0.18 against a 0.30 alarm point (oat 2 °C, rat 22 °C, mat 18.4 °C) — design minimum plus the spread a real damper holds
excess_outdoor_airOAF 0.40 (oat 2 °C, rat 22 °C, mat 14.0 °C) — 0.25 above design, so 0.10 past the margin; alarms after alarm_delay (1800 s)
summer_excess_oat_above_ratSign case: outdoor air warmer than return (oat 35 °C, rat 24 °C, mat 28.4 °C) — both differences flip sign, the quotient still reads 0.40, and the rule alarms exactly as it does in winter
small_delta_not_evaluableNO_EVAL with the gate doing work:
delta_exactly_at_thresholdEdge case:
delta_just_over_thresholdEdge case, other side:
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, other side: OAF 0.31 (mat 15.8 °C) clears the strict comparison by one hundredth and alarms after alarm_delay
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 the damper stroke never becomes an alarm
recovery_clears_alarmRecovery: OAF 0.40 alarms after alarm_delay, then the damper returns to minimum at t=3000 (OAF 0.18) — yFault drops on that tick, with no hold-off
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "normal_minimum_ventilation",
      "description": "OAF 0.18 against a 0.30 alarm point (oat 2 \u00b0C, rat 22 \u00b0C, mat 18.4 \u00b0C) \u2014 design minimum plus the spread a real damper holds",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 18.4
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "excess_outdoor_air",
      "description": "OAF 0.40 (oat 2 \u00b0C, rat 22 \u00b0C, mat 14.0 \u00b0C) \u2014 0.25 above design, so 0.10 past the margin; alarms after alarm_delay (1800 s)",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 14.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "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 both differences flip sign, the quotient still reads 0.40, and the rule alarms exactly as it does in winter",
      "inputs": {
        "oat": 35.0,
        "rat": 24.0,
        "mat": 28.4
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "small_delta_not_evaluable",
      "description": "NO_EVAL with the gate doing work: |oat \u2212 rat| = 2 \u00b0C (oat 20 \u00b0C, rat 22 \u00b0C) and mat 21 \u00b0C, so the raw fraction reads 0.50 and marginHigh is true \u2014 an ungated rule would alarm; yTempDeltaOk stays false and holds yFault down",
      "inputs": {
        "oat": 20.0,
        "rat": 22.0,
        "mat": 21.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "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 (mat 19 \u00b0C) \u2014 the strict `>` leaves the rule not evaluable, so a fraction well past the alarm point still reports NO_EVAL",
      "inputs": {
        "oat": 16.0,
        "rat": 22.0,
        "mat": 19.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_just_over_threshold",
      "description": "Edge case, other side: |oat \u2212 rat| = 6.5 \u00b0C (oat 15.5 \u00b0C, rat 22 \u00b0C) with OAF 0.40 (mat 19.4 \u00b0C) \u2014 evaluable, and the excess alarms after alarm_delay",
      "inputs": {
        "oat": 15.5,
        "rat": 22.0,
        "mat": 19.4
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "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
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "margin_just_over_threshold",
      "description": "Edge case, other side: OAF 0.31 (mat 15.8 \u00b0C) clears the strict comparison by one hundredth and alarms after alarm_delay",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": 15.8
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "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 the damper stroke never becomes an alarm",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 18.4
          },
          {
            "t": 600,
            "value": 14.0
          },
          {
            "t": 1800,
            "value": 18.4
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "recovery_clears_alarm",
      "description": "Recovery: OAF 0.40 alarms after alarm_delay, then the damper returns to minimum at t=3000 (OAF 0.18) \u2014 yFault drops on that tick, with no hold-off",
      "inputs": {
        "oat": 2.0,
        "rat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 14.0
          },
          {
            "t": 3000,
            "value": 18.4
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3000,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}