AHU-0020 — Stuck or failed actuator
| Status | verified — engine e2ff2f8, cxf:fnv1a128:42b5629649240985c2d9776995d8b4de, 2026-08-17 |
| Severity | 2 |
| Method | rule |
| Phase | 1 |
| Category | CRITICAL_WASTE |
| Confidence | MEDIUM |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0014, AHU-0015 |
| Playbooks | stuck-actuator |
| Source | HVAC FDD Reference v1.0 §9, AHU-0020; PNNL retuning; Bie et al. 2025 |
| Operating states | all |
Preconditions (host-enforced): Both the command and the position feedback must be available and bound to the same physical device — a feedback point wired to a different actuator than the command produces a permanent false alarm. When the feedback point is absent, stale, or its device pairing is unverified, the verdict is NO_EVAL, not healthy.
Points: actuator_cmd, actuator_pos
Outputs:
yFault— True while command and position feedback have differed by more than position_error_threshold continuously for stuck_duration plus alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
position_error_threshold | 10.0 | % | errBig.t | Command-vs-position delta above which the actuator counts as not tracking |
stuck_duration | 1800.0 | s | stuck.delayTime | How long the delta must persist before the actuator is judged stuck (30 min) |
alarm_delay | 300.0 | s | persist.delayTime | Additional debounce held after stuck_duration before the alarm asserts (5 min) |
Description
An actuator’s command and its measured position disagree by more than the
tracking allowance and stay disagreed for half an hour. Whatever the sequence
told the device to do, it is not doing: the linkage has come off, the motor has
failed, the stem is seized in scale or debris, or the control signal never
reaches the actuator at all. This is the chapter’s one template rule —
actuator_cmd and actuator_pos are a command/feedback pair the host binds per
actuated device, so a typical AHU carries three instances (outdoor air damper,
heating valve, cooling valve). Severity 2 because a stuck actuator defeats
whatever sequence commands it: when this fires alongside AHU-0016, AHU-0017,
or AHU-0025 on the same subsystem, those rules are reporting the symptom and
this one is naming the cause.
Detection Logic
yFault = |actuator_cmd − actuator_pos| > position_error_threshold
sustained continuously for stuck_duration,
then held for a further alarm_delay
Block graph (rule.cxf.jsonld):
err takes the signed difference and absErr strips the sign, so the test is
direction-blind: an actuator that will not open, one that will not close, and a
reverse-wired feedback reporting 80% against a 20% command all trip the same
threshold. errBig compares strictly, so a delta sitting exactly on 10% reads
healthy — the threshold is a tracking allowance, and an actuator at the edge of
it is within spec. The two timers chain rather than merge: stuck requires 30
minutes of continuous mistracking, longer than any real stroke (full 0–100%
damper travel takes 90–150 s), and persist adds the reference’s 5-minute
debounce on top, putting worst-case time to alarm at 2100 s. Either timer resets
the moment feedback comes back inside the allowance, so recovery is immediate.
delayOnInit = true holds the full window across a controller restart.
Possible Diagnoses
- Actuator mechanical failure — motor, gear train, or spring return
- Actuator linkage disconnected (the most common finding, and the cheapest fix)
- Incorrect wiring — command and feedback bound to different devices
- Valve or damper seized by corrosion or debris
- Control signal not reaching the actuator (broken wire, blown fuse, failed pneumatic transducer)
Energy Impact
CRITICAL_WASTE, MEDIUM confidence, PROXY_ESTIMATION. The waste depends on which
actuator is stuck and where it stopped: a heating valve stuck at 40% burns fuel
year-round; stuck closed it costs nothing in energy and shows up as a comfort
complaint. Estimating the loss means falling back on the affected subsystem’s
formula — AHU-0014/AHU-0015 for a coil valve, AHU-0021/AHU-0026 for an OA damper —
with the stuck position read from actuator_pos rather than the command, which
is why this card is PROXY_ESTIMATION. Repairing a stuck actuator returns 5–20%
of the affected subsystem’s energy (PNNL retuning measures EEM-03 for leaking
coil valves, EEM-06 for OA damper faults). Climate sensitivity follows the
device.
Emissions Impact
PROXY_EMISSIONS, MEDIUM confidence; typical 200–2,000 kg CO₂e/yr, the wide band
reflecting the same device dependence as the energy estimate. Scope is recorded
as 1|2 because it follows the affected subsystem rather than the fault: a
stuck heating valve wastes on-site combustion (scope 1), a stuck cooling valve
or a damper feeding the chiller outdoor air wastes purchased electricity
(scope 2), and a stuck OA damper in a gas-heated building can do both across a
year. Hosts should attribute against the subsystem the bound device serves, not
against this rule. Avoided-emissions basis: MOER.
Deviations
- NO_EVAL is a host precondition, not an output. The reference’s data-absence
case (command 50%, feedback missing) is not representable in a status-blind
block graph — nothing here distinguishes “no feedback” from “feedback reads
0”, and an unbound point held at 0 against a 50% command alarms in 35 minutes.
The host must confirm both points are present, fresh, and bound to the same
device before interpreting
yFault. actuator_cmdandactuator_posare the dictionary’s first template entries, bound per device by the host rather than forked into per-device rule variants that would triple the card count. Their entries carry no Brick or 223P class because the class differs per instance (Damper_Position_Commandon one,Valve_Position_Commandon another); the cost is that the CXF document alone does not say which device it watches — the host binding does.stuck_durationandalarm_delaystay separate timers rather than one 2100 sTrueDelay, because the reference tunes them separately and they answer different questions: when an actuator counts as stuck, versus how much alarm-noise suppression the site wants on top.errBigusesGreaterThreshold(u > t), so a 10.0% delta is healthy and 10.1% is not; the reference writes> threshold.absErrimplements the reference’s|actuator_cmd − actuator_pos|literally, so over-travel and reversed feedback wiring alarm on the same schedule as a jam — diagnosis 3 depends on it.- The reference tags this fault for AHU, RTU, VAV, and FCU. This is the AHU-family instance; the other families reuse the block graph unchanged, since the template points carry no equipment-specific semantics.
delayOnInit = trueon both timers (Modelica/CDL default isfalse), the library’s standing choice: an actuator already mistracking at load waits out the full 35 minutes rather than alarming after a controller restart.
Notes
Deploy the three AHU instances together — the diagnosis often depends on which one fired. Sites without position feedback on an actuator simply do not instantiate the rule there; there is no degraded mode.
Remote fixes are limited to releasing overrides and checking for demand-limiting
that clamps the command range (playbook stuck-actuator, step 2). Everything
else is on-site: $0–$50 to reconnect a linkage, $200–$800 for an actuator,
$500–$2,000 for a seized valve body. After the repair, stroke the device
0 → 100 → 0 and confirm feedback tracks within 5%.
Test Vectors
9 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
tracking_normally | Reference vector: command 50%, feedback 48% — a 2% tracking error, inside the allowance |
briefly_stuck_then_recovers | Reference vector: command 80% against feedback 20% for 10 min, then the actuator catches up — the 60% error never reaches stuck_duration |
stuck_sustained | Reference vector: command 80% against feedback 20% held past 45 min — the alarm lands at stuck_duration + alarm_delay = 2100 s, well before the reference’s 2700 s observation point |
error_exactly_at_threshold | Edge case: command 50%, feedback 40% — a delta of exactly position_error_threshold (10%); the strict > keeps it healthy |
error_just_over_threshold | Edge case: command 50%, feedback 39.5% — a 10.5% delta clears the threshold and alarms at 2100 s |
reverse_error_is_symmetric | Feedback above command (20% commanded, 80% reported) — absErr makes the test direction-blind, so an over-travelled or reverse-wired actuator alarms on the same 2100 s schedule |
normal_stroke_lag | Transient: the command steps 20% → 80% at t=600 and the actuator takes 300 s to arrive — a 60% error for the length of the stroke, far short of stuck_duration |
error_clears_during_debounce | Transient: the error survives stuck_duration (timer satisfied at 1800 s) but the actuator frees itself at 1920 s, inside the 300 s debounce — no alarm ever asserts |
error_returns_restarts_stuck_timer | Transient: the actuator tracks briefly at t=600 and jams again at t=900 — the stuck timer restarts from the second jam, pushing the alarm to 900 + 2100 = 3000 s |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "tracking_normally",
"description": "Reference vector: command 50%, feedback 48% \u2014 a 2% tracking error, inside the allowance",
"inputs": {
"actuator_cmd": 50.0,
"actuator_pos": 48.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "briefly_stuck_then_recovers",
"description": "Reference vector: command 80% against feedback 20% for 10 min, then the actuator catches up \u2014 the 60% error never reaches stuck_duration",
"inputs": {
"actuator_cmd": 80.0,
"actuator_pos": [
{
"t": 0,
"value": 20.0
},
{
"t": 600,
"value": 80.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "stuck_sustained",
"description": "Reference vector: command 80% against feedback 20% held past 45 min \u2014 the alarm lands at stuck_duration + alarm_delay = 2100 s, well before the reference's 2700 s observation point",
"inputs": {
"actuator_cmd": 80.0,
"actuator_pos": 20.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2040,
"equals": false
},
{
"output": "yFault",
"from_s": 2160,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "error_exactly_at_threshold",
"description": "Edge case: command 50%, feedback 40% \u2014 a delta of exactly position_error_threshold (10%); the strict `>` keeps it healthy",
"inputs": {
"actuator_cmd": 50.0,
"actuator_pos": 40.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "error_just_over_threshold",
"description": "Edge case: command 50%, feedback 39.5% \u2014 a 10.5% delta clears the threshold and alarms at 2100 s",
"inputs": {
"actuator_cmd": 50.0,
"actuator_pos": 39.5
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2040,
"equals": false
},
{
"output": "yFault",
"from_s": 2160,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "reverse_error_is_symmetric",
"description": "Feedback above command (20% commanded, 80% reported) \u2014 `absErr` makes the test direction-blind, so an over-travelled or reverse-wired actuator alarms on the same 2100 s schedule",
"inputs": {
"actuator_cmd": 20.0,
"actuator_pos": 80.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2040,
"equals": false
},
{
"output": "yFault",
"from_s": 2160,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "normal_stroke_lag",
"description": "Transient: the command steps 20% \u2192 80% at t=600 and the actuator takes 300 s to arrive \u2014 a 60% error for the length of the stroke, far short of stuck_duration",
"inputs": {
"actuator_cmd": [
{
"t": 0,
"value": 20.0
},
{
"t": 600,
"value": 80.0
}
],
"actuator_pos": [
{
"t": 0,
"value": 20.0
},
{
"t": 900,
"value": 80.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "error_clears_during_debounce",
"description": "Transient: the error survives stuck_duration (timer satisfied at 1800 s) but the actuator frees itself at 1920 s, inside the 300 s debounce \u2014 no alarm ever asserts",
"inputs": {
"actuator_cmd": 80.0,
"actuator_pos": [
{
"t": 0,
"value": 20.0
},
{
"t": 1920,
"value": 80.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "error_returns_restarts_stuck_timer",
"description": "Transient: the actuator tracks briefly at t=600 and jams again at t=900 \u2014 the stuck timer restarts from the second jam, pushing the alarm to 900 + 2100 = 3000 s",
"inputs": {
"actuator_cmd": 80.0,
"actuator_pos": [
{
"t": 0,
"value": 20.0
},
{
"t": 600,
"value": 80.0
},
{
"t": 900,
"value": 20.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2940,
"equals": false
},
{
"output": "yFault",
"from_s": 3060,
"to_s": 3600,
"equals": true
}
]
}
]
}