AHU-0025 — Heating or cooling coil lockout not active by OAT
| Status | verified — engine e2ff2f8, cxf:fnv1a128:5322cb5d6f80a2e512e9d5ec26d5e4bc, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | CRITICAL_WASTE |
| Confidence | MEDIUM |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | CLU-01 |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0016 |
| Playbooks | simultaneous-hc |
| Source | HVAC FDD Reference v1.0 §9, AHU-0025; PNNL RetuningOpps A02; PNNL-25985 |
| Operating states | all (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:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
valve_open_threshold | 5.0 | % | htgOpen.t, clgOpen.t | Valve command above which a coil counts as active; binds both branches |
heating_lockout_temp | 18.0 | °C | oatWarm.t | OAT above which the heating coil should be locked out |
cooling_lockout_temp | 10.0 | °C | oatCold.t | OAT below which the cooling coil should be locked out |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous 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):
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
- Lockout sequence never programmed in the BAS
- Lockout overridden or disabled (often during a comfort complaint, then left)
- Valve stuck open — mechanical failure, failed actuator, or a normally-open valve with no signal
- 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_thresholdis 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 isfalse), 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.
| Scenario | Description |
|---|---|
normal_heating_in_winter | Reference vector: heating valve at 40% with OAT 2 °C — below the heating lockout, so heating is legitimate |
heating_when_warm | Reference vector: heating valve at 30% with OAT 25 °C — heating lockout is not active; alarms after alarm_delay (1800 s) |
cooling_when_cold | Reference vector: cooling valve at 40% with OAT 2 °C — cooling lockout is not active; alarms after alarm_delay |
normal_cooling_in_summer | Cooling valve at 60% with OAT 28 °C — above the cooling lockout, so cooling is legitimate |
valve_exactly_at_threshold | Edge 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_above | Edge 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_lockout | Edge case: OAT sits exactly at cooling_lockout_temp (10 °C) with the cooling valve open — the strict < means no alarm |
warm_spell_clears_before_delay | Transient: 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_persistence | Transient: 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
}
]
}
]
}