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-0017 — Economizer not operational when favorable

Statusverified — engine e2ff2f8, cxf:fnv1a128:6bd93b0a587698d63e6263031a029829, 2026-08-17
Severity3
Methodrule
Phase1
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-03
Suppresses
Suppressed by
RelatedAHU-0009, AHU-0011, AHU-0034
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §9, AHU-0017; PNNL-27338 §3; PNNL EEM-06; Cowan 2004 (54% of RTUs)
Operating statesOS 4 (mechanical cooling)

Preconditions (host-enforced): Supply fan running. The outdoor/return comparison must be evaluable: |oat - rat| >= TMIN (PNNL-27338 uses 5 °F for its outdoor-air-fraction work), since two sensors reading within their combined error of each other cannot establish which air is cooler. Hosts also gate on OAT sensor quality — a sensor reading high produces this fault’s signature with the economizer control working correctly (diagnosis 4). When either gate is unmet the verdict is NO_EVAL, not healthy.

Points: oat, rat, clg_vlv_cmd, oa_dmpr_cmd

Outputs:

  • yFault — True while outdoor conditions have favored economizing, mechanical cooling has run, and the OA damper has stayed below econ_damper_threshold, all continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
econ_type_is_ddbTrueboolisDDB.kEconomizer changeover type — true = differential dry-bulb (compare oat to rat), false = fixed high-limit dry-bulb (compare oat to econ_hl_temp)
econ_hl_temp21.0°ChlConst.kFixed high-limit changeover temperature, used only when econ_type_is_ddb is false
temp_deadband1.0°CddbFav.t, hlFav.tMargin the favorable comparison must clear before economizing counts as worthwhile; binds both changeover branches
cooling_enabled_threshold10.0%clgOn.tCooling valve command above which mechanical cooling counts as active
econ_damper_threshold25.0%dmprLow.tOA damper command below which the damper counts as parked at minimum position
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

Outdoor air is cool enough to do the cooling for free, but the outdoor-air damper sits at or near its minimum position while the cooling coil runs. Every kilowatt the compressor or chiller spends in that state buys cooling the economizer was standing by to provide at fan power alone. Common and cheap to fix: Cowan’s 2004 field survey found 54% of RTU economizers carrying at least one fault, most often a disconnected damper linkage. Trigger rule for CLU-03 (Economizer Failure) — fix it first, since a damper pinned at minimum also fails the mixed-air and OA-fraction tests in AHU-0009 and AHU-0011.

Detection Logic

econ_favorable = (rat - oat)          > temp_deadband   when econ_type_is_ddb
               = (econ_hl_temp - oat) > temp_deadband   otherwise

yFault = econ_favorable
     AND clg_vlv_cmd > cooling_enabled_threshold
     AND oa_dmpr_cmd < econ_damper_threshold
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0017 block graph

Both changeover branches compute on every tick and favSel (Logical.Switch, y = u2 ? u1 : u3) picks one: isDDB true (the default) selects the differential branch (rat - oat), false the fixed high-limit branch (econ_hl_temp - oat). Thresholding the difference rather than the raw temperatures is what lets one temp_deadband serve both branches. All three comparisons are strict, so a damper parked at exactly 25%, a cooling valve at exactly 10%, or a gap of exactly 1.0 °C does not trip the rule. persist requires 30 minutes of continuous violation — long enough to ride out damper strokes and changeover transitions — and any interruption restarts the timer; delayOnInit = true makes a violation already present at engine start wait out the full window.

Possible Diagnoses

  1. Economizer control sequence disabled or misconfigured in the BAS
  2. OA damper stuck at minimum position (linkage disconnected, blades bound)
  3. Damper actuator failure — no power, no air, or a burnt-out motor
  4. OAT sensor error, reading higher than actual, which locks out changeover
  5. Economizer lockout active when it should not be (seasonal or manual)

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. The waste is the mechanical cooling that free cooling would have displaced, readable straight off the valve command: waste_kw = clg_vlv_cmd/100 × ahu_clg_capacity_kw. Correcting economizer operation saves 5–20% of cooling energy (PNNL-27338 §3; PNNL EEM-06, OA damper faults and controls). Cooling-dominant, and worth most in mild shoulder-season weather — exactly when the damper should be modulating.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence; typical 1,500–8,000 kg CO₂e/yr. The displaced energy is electric compressor or chiller work, so the entire impact lands in purchased electricity. Free-cooling hours cluster in mild daytime and overnight weather, when the marginal generator differs sharply from the annual average — use the marginal operating emissions rate (MOER), not an average grid factor.

Deviations

  • The reference’s econ_type enum (DDB | HL_DB, default DDB) is carried here as the boolean econ_type_is_ddb driving a Logical.Switch. Two values do not earn an enum, and a boolean is retunable through set_param on a deployed rule; enthalpy changeover needs its own rule, not a third value.
  • The evaluability gate |oat - rat| >= TMIN and the OS-4 (mechanical cooling) operating-state restriction are declared as preconditions for host enforcement rather than encoded in the block graph — gating and data quality stay out of the rule, which computes the fault condition given valid data.
  • All three comparisons are strict (>, >, <); the reference does not specify boundary behavior, so the library’s strict convention applies.
  • temp_deadband is one card parameter bound to two CXF paths (ddbFav.t, hlFav.t), matching the reference’s single deadband. Hosts must set both paths together, or flipping econ_type_is_ddb silently changes the deadband.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

With default parameters the shipped vectors exercise only the DDB branch — vectors/v1 stages inputs, not parameters, so hlConst, hlGap, and hlFav are structurally verified but never reach yFault through u3. A host that sets econ_type_is_ddb = false should commission that path itself.

econ_hl_temp defaults to 21 °C, near ASHRAE 90.1’s 70 °F high limit for climate zones 4A–5A. Zones 1A–3A allow 75 °F (23.9 °C) and zones 5B–8 use 65 °F (18.3 °C) — retune per the playbook rather than accepting the default in a climate it does not fit.

Test Vectors

9 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
favorable_damper_openReference vector: OAT 10 °C against RAT 22 °C with cooling at 40% — economizer is favorable and the damper is at 80%, so free cooling is being used
favorable_damper_at_minReference vector: same favorable conditions and cooling call, but the damper sits at 15% — mechanical cooling with the economizer shut; alarms after alarm_delay (1800 s)
not_favorableReference vector: OAT 30 °C is hotter than the 22 °C return air — economizing would add load, so a closed damper is correct
no_cooling_callReference vector: favorable outdoor air but the cooling valve is closed — no mechanical cooling to displace, so the minimum-position damper is correct
damper_exactly_at_thresholdEdge case: damper parked exactly at econ_damper_threshold (25%) under favorable conditions with cooling — the strict < keeps it out of the alarm
cooling_exactly_at_thresholdEdge case: cooling valve exactly at cooling_enabled_threshold (10%) with the damper at minimum — the strict > means the coil does not count as cooling
deadband_edge_then_favorableEdge case: rat − oat sits exactly on temp_deadband (1.0 °C) — no alarm; at t=1200 the return air rises to 22.5 °C, opening the gap to 1.5 °C, and the alarm follows 1800 s later
damper_opens_before_delayTransient: the economizer is shut against favorable air with cooling running, but the damper drives to 70% at t=1200 — the condition clears before alarm_delay completes, so no alarm
damper_cycle_resets_persistenceTransient: the damper opens at t=900 and falls back to minimum at t=1200 under an unbroken favorable-plus-cooling condition — persistence restarts from the reclose, so the alarm lands at 3000 s, not 1800 s
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "favorable_damper_open",
      "description": "Reference vector: OAT 10 \u00b0C against RAT 22 \u00b0C with cooling at 40% \u2014 economizer is favorable and the damper is at 80%, so free cooling is being used",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 80.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "favorable_damper_at_min",
      "description": "Reference vector: same favorable conditions and cooling call, but the damper sits at 15% \u2014 mechanical cooling with the economizer shut; alarms after alarm_delay (1800 s)",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "not_favorable",
      "description": "Reference vector: OAT 30 \u00b0C is hotter than the 22 \u00b0C return air \u2014 economizing would add load, so a closed damper is correct",
      "inputs": {
        "oat": 30.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "no_cooling_call",
      "description": "Reference vector: favorable outdoor air but the cooling valve is closed \u2014 no mechanical cooling to displace, so the minimum-position damper is correct",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 0.0,
        "oa_dmpr_cmd": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_exactly_at_threshold",
      "description": "Edge case: damper parked exactly at econ_damper_threshold (25%) under favorable conditions with cooling \u2014 the strict `<` keeps it out of the alarm",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 25.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "cooling_exactly_at_threshold",
      "description": "Edge case: cooling valve exactly at cooling_enabled_threshold (10%) with the damper at minimum \u2014 the strict `>` means the coil does not count as cooling",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 10.0,
        "oa_dmpr_cmd": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "deadband_edge_then_favorable",
      "description": "Edge case: rat \u2212 oat sits exactly on temp_deadband (1.0 \u00b0C) \u2014 no alarm; at t=1200 the return air rises to 22.5 \u00b0C, opening the gap to 1.5 \u00b0C, and the alarm follows 1800 s later",
      "inputs": {
        "oat": 21.0,
        "rat": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 1200,
            "value": 22.5
          }
        ],
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": 15.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "damper_opens_before_delay",
      "description": "Transient: the economizer is shut against favorable air with cooling running, but the damper drives to 70% at t=1200 \u2014 the condition clears before alarm_delay completes, so no alarm",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 1200,
            "value": 70.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "damper_cycle_resets_persistence",
      "description": "Transient: the damper opens at t=900 and falls back to minimum at t=1200 under an unbroken favorable-plus-cooling condition \u2014 persistence restarts from the reclose, so the alarm lands at 3000 s, not 1800 s",
      "inputs": {
        "oat": 10.0,
        "rat": 22.0,
        "clg_vlv_cmd": 40.0,
        "oa_dmpr_cmd": [
          {
            "t": 0,
            "value": 15.0
          },
          {
            "t": 900,
            "value": 70.0
          },
          {
            "t": 1200,
            "value": 15.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}