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-0020 — Stuck or failed actuator

Statusverified — engine e2ff2f8, cxf:fnv1a128:42b5629649240985c2d9776995d8b4de, 2026-08-17
Severity2
Methodrule
Phase1
CategoryCRITICAL_WASTE
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0014, AHU-0015
Playbooksstuck-actuator
SourceHVAC FDD Reference v1.0 §9, AHU-0020; PNNL retuning; Bie et al. 2025
Operating statesall

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:

NameDefaultUnitCXF pathDescription
position_error_threshold10.0%errBig.tCommand-vs-position delta above which the actuator counts as not tracking
stuck_duration1800.0sstuck.delayTimeHow long the delta must persist before the actuator is judged stuck (30 min)
alarm_delay300.0spersist.delayTimeAdditional 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):

AHU-0020 block graph

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

  1. Actuator mechanical failure — motor, gear train, or spring return
  2. Actuator linkage disconnected (the most common finding, and the cheapest fix)
  3. Incorrect wiring — command and feedback bound to different devices
  4. Valve or damper seized by corrosion or debris
  5. 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_cmd and actuator_pos are 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_Command on one, Valve_Position_Command on another); the cost is that the CXF document alone does not say which device it watches — the host binding does.
  • stuck_duration and alarm_delay stay separate timers rather than one 2100 s TrueDelay, 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.
  • errBig uses GreaterThreshold (u > t), so a 10.0% delta is healthy and 10.1% is not; the reference writes > threshold.
  • absErr implements 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 = true on both timers (Modelica/CDL default is false), 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.

ScenarioDescription
tracking_normallyReference vector: command 50%, feedback 48% — a 2% tracking error, inside the allowance
briefly_stuck_then_recoversReference vector: command 80% against feedback 20% for 10 min, then the actuator catches up — the 60% error never reaches stuck_duration
stuck_sustainedReference 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_thresholdEdge case: command 50%, feedback 40% — a delta of exactly position_error_threshold (10%); the strict > keeps it healthy
error_just_over_thresholdEdge case: command 50%, feedback 39.5% — a 10.5% delta clears the threshold and alarms at 2100 s
reverse_error_is_symmetricFeedback 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_lagTransient: 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_debounceTransient: 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_timerTransient: 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
        }
      ]
    }
  ]
}