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-0025 — Heating or cooling coil lockout not active by OAT

Statusverified — engine e2ff2f8, cxf:fnv1a128:5322cb5d6f80a2e512e9d5ec26d5e4bc, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceMEDIUM
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-01
Suppresses
Suppressed by
RelatedAHU-0016
Playbookssimultaneous-hc
SourceHVAC FDD Reference v1.0 §9, AHU-0025; PNNL RetuningOpps A02; PNNL-25985
Operating statesall (fan running)

Preconditions (host-enforced): Supply fan running — a coil lockout only matters while the unit moves air. The OAT reading must be trustworthy: a biased outdoor sensor produces this exact signature with no lockout defect present (diagnosis 4), so hosts should gate on OAT data quality. When either gate is unmet the verdict is NO_EVAL, not healthy.

Points: htg_vlv_cmd, clg_vlv_cmd, oat

Outputs:

  • yFault — True while a coil has been open past valve_open_threshold on the wrong side of its OAT lockout for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
valve_open_threshold5.0%htgOpen.t, clgOpen.tValve command above which a coil counts as active; binds both branches
heating_lockout_temp18.0°CoatWarm.tOAT above which the heating coil should be locked out
cooling_lockout_temp10.0°CoatCold.tOAT below which the cooling coil should be locked out
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

A coil is running on the wrong side of its outdoor-air lockout: the heating valve is open while outdoor air is warm, or the cooling valve is open while outdoor air is cold. Either way the lockout that should have disabled that coil is missing, overridden, or defeated by a valve that will not close. Out-of-season coil operation is pure waste — the load it serves is either nonexistent or better met by outdoor air — and it usually runs for months before anyone notices, because nothing about it is uncomfortable. Present in roughly 15% of buildings; a member fault of CLU-01 (Simultaneous Heating & Cooling), whose trigger is AHU-0016.

Detection Logic

yFault = (htg_vlv_cmd > valve_open_threshold AND oat > heating_lockout_temp)
      OR (clg_vlv_cmd > valve_open_threshold AND oat < cooling_lockout_temp)
      sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0025 block graph

The two branches are independent tests that share one persistence timer: oat fans out to oatWarm and oatCold, each valve gets its own open test at the same threshold, and anyMiss raises either finding. All three comparisons are strict, so a valve parked at exactly 5% or an OAT sitting exactly on 18 °C or 10 °C does not trip the rule. Between the two lockout temperatures — the 10–18 °C band where economizer operation belongs — neither branch can fire regardless of valve position; judging coil operation inside that band is AHU-0016’s job, not this rule’s. persist requires 30 minutes of continuous violation, which rides out mode changes, morning warmup tails, and short manual valve strokes.

Possible Diagnoses

  1. Lockout sequence never programmed in the BAS
  2. Lockout overridden or disabled (often during a comfort complaint, then left)
  3. Valve stuck open — mechanical failure, failed actuator, or a normally-open valve with no signal
  4. Incorrect OAT sensor reading (sun-exposed or wall-heated sensor reads warm; a sensor in an exhaust path reads warm year-round)

Energy Impact

CRITICAL_WASTE, MEDIUM confidence, DIRECT_MEASUREMENT. Waste is computable from the live valve command and the coil’s design capacity: heating branch waste_kw = htg_vlv_cmd/100 × ahu_htg_capacity_kw, cooling branch waste_kw = clg_vlv_cmd/100 × ahu_clg_capacity_kw. Correcting the lockout saves 5–15% of the affected subsystem’s energy while the fault is active (PNNL-25985 EEM-38). Confidence is MEDIUM rather than HIGH because the lockout temperatures are site-specific: a building with a genuine year-round reheat load or a heat-recovery scheme may legitimately hold a coil open outside the default band. Sensitive to both climates — the heating branch bites in shoulder seasons and summer, the cooling branch in winter.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, MEDIUM confidence; typical 500–4,000 kg CO₂e/yr. The two branches land in different inventories: out-of-season heating is usually scope 1 gas at the boiler, out-of-season cooling is scope 2 electricity at the chiller. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • Severity 3 (warning), per the reference’s chapter 9 card — its only severity statement for this fault, since the §5.8.1 index carries no severity column. This chapter’s README previously mistranscribed it as 2, corrected alongside this card.
  • The reference tags this fault for both AHU and RTU. This card is the AHU-family instance; an RTU-family sibling would restate it against staged compressor and gas-valve commands rather than modulating valve positions.
  • All three comparisons are strict (>, >, <); the reference does not specify boundary behavior, so an OAT parked exactly on a lockout setpoint stays out of the alarm.
  • valve_open_threshold is one card parameter bound to two CXF paths (htgOpen.t, clgOpen.t), matching the reference’s single threshold. Hosts must set both together; a site needing per-coil thresholds should retune the paths individually and note the divergence.
  • Preconditions (fan running, OAT trustworthy) are declared in frontmatter for host enforcement rather than encoded in the block graph, as in AHU-0016.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a violation already present at load still waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

Both branches feed one persist timer, so a violation that switches branches without a gap keeps the timer running — which takes an 8 °C OAT swing inside a single tick. If both branches fire on the same day, suspect the sensor before the sequence.

Fix order within CLU-01: clear the trigger (AHU-0016) first, since a valve held open by a fighting control loop also reads as a missing lockout. When the lockout is genuinely absent, add one with hysteresis — typically disable heating above 16 °C and re-enable below 14 °C (playbook simultaneous-hc, step 2.4).

Test Vectors

9 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
normal_heating_in_winterReference vector: heating valve at 40% with OAT 2 °C — below the heating lockout, so heating is legitimate
heating_when_warmReference vector: heating valve at 30% with OAT 25 °C — heating lockout is not active; alarms after alarm_delay (1800 s)
cooling_when_coldReference vector: cooling valve at 40% with OAT 2 °C — cooling lockout is not active; alarms after alarm_delay
normal_cooling_in_summerCooling valve at 60% with OAT 28 °C — above the cooling lockout, so cooling is legitimate
valve_exactly_at_thresholdEdge case: heating valve sits exactly at valve_open_threshold (5%) in 25 °C weather — the strict > means the coil does not count as open
oat_at_heating_lockout_then_aboveEdge case: OAT sits exactly at heating_lockout_temp (18 °C) with the heating valve open — no alarm; at t=1200 OAT rises to 18.1 °C and the alarm follows 1800 s later
oat_at_cooling_lockoutEdge case: OAT sits exactly at cooling_lockout_temp (10 °C) with the cooling valve open — the strict < means no alarm
warm_spell_clears_before_delayTransient: heating runs in 25 °C air, but OAT drops back to 5 °C at t=1200 — the condition clears before alarm_delay completes, so no alarm
valve_cycle_resets_persistenceTransient: cooling valve closes at t=900 and reopens at t=1200 in 2 °C air — persistence restarts from the reopen, 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": "normal_heating_in_winter",
      "description": "Reference vector: heating valve at 40% with OAT 2 \u00b0C \u2014 below the heating lockout, so heating is legitimate",
      "inputs": {
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": 0.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "heating_when_warm",
      "description": "Reference vector: heating valve at 30% with OAT 25 \u00b0C \u2014 heating lockout is not active; alarms after alarm_delay (1800 s)",
      "inputs": {
        "htg_vlv_cmd": 30.0,
        "clg_vlv_cmd": 0.0,
        "oat": 25.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "cooling_when_cold",
      "description": "Reference vector: cooling valve at 40% with OAT 2 \u00b0C \u2014 cooling lockout is not active; alarms after alarm_delay",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 40.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": "normal_cooling_in_summer",
      "description": "Cooling valve at 60% with OAT 28 \u00b0C \u2014 above the cooling lockout, so cooling is legitimate",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 60.0,
        "oat": 28.0
      },
      "expect": [
        {
          "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%) in 25 \u00b0C weather \u2014 the strict `>` means the coil does not count as open",
      "inputs": {
        "htg_vlv_cmd": 5.0,
        "clg_vlv_cmd": 5.0,
        "oat": 25.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_at_heating_lockout_then_above",
      "description": "Edge case: OAT sits exactly at heating_lockout_temp (18 \u00b0C) with the heating valve open \u2014 no alarm; at t=1200 OAT rises to 18.1 \u00b0C and the alarm follows 1800 s later",
      "inputs": {
        "htg_vlv_cmd": 30.0,
        "clg_vlv_cmd": 0.0,
        "oat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 1200,
            "value": 18.1
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_at_cooling_lockout",
      "description": "Edge case: OAT sits exactly at cooling_lockout_temp (10 \u00b0C) with the cooling valve open \u2014 the strict `<` means no alarm",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 45.0,
        "oat": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "warm_spell_clears_before_delay",
      "description": "Transient: heating runs in 25 \u00b0C air, but OAT drops back to 5 \u00b0C at t=1200 \u2014 the condition clears before alarm_delay completes, so no alarm",
      "inputs": {
        "htg_vlv_cmd": 30.0,
        "clg_vlv_cmd": 0.0,
        "oat": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 1200,
            "value": 5.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_cycle_resets_persistence",
      "description": "Transient: cooling valve closes at t=900 and reopens at t=1200 in 2 \u00b0C air \u2014 persistence restarts from the reopen, so the alarm lands at 3000 s, not 1800 s",
      "inputs": {
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 40.0
          },
          {
            "t": 900,
            "value": 0.0
          },
          {
            "t": 1200,
            "value": 40.0
          }
        ],
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2940,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3060,
          "to_s": 3600,
          "equals": true
        }
      ]
    }
  ]
}