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

VAV-0004 — VAV airflow tracking error

Statusverified — engine e2ff2f8, cxf:fnv1a128:14bf3a6e9f1e4f032ceae6e45dd294f9, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedAHU-0020, VAV-0001, VAV-0005, FPB-0002
Playbooksstuck-actuator
SourceHVAC FDD Reference v1.0 §10, VAV-0004; Schein et al. 2006 (VPACC); ORNL Im et al. 2025; Gunay et al. 2020
Operating statesall (fan running)

Preconditions (host-enforced): AHU fan running — a box cannot track a setpoint with no branch pressure behind it, and every zone on a stopped fan would report this fault. Airflow sensor and active setpoint both available and fresh; a stale setpoint held at its last value while the measurement moves reads as a tracking error that is really a communication fault. The evaluability gate is signalled in-rule by ySetpointOk: when it is false the verdict is NO_EVAL, not healthy. Boxes commissioned with the airflow sensor disabled (pressure-independent boxes converted to pressure-dependent control) must be excluded host-side — there is no setpoint to track.

Points: zone_airflow, zone_airflow_sp

Outputs:

  • yFault — True while the fractional airflow tracking error has stayed above tracking_error_threshold, at an evaluable setpoint, for tracking_duration plus alarm_delay
  • ySetpointOk — Evaluability signal — true when zone_airflow_sp exceeds min_evaluable_setpoint; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
tracking_error_threshold0.31ratioHigh.tFractional deviation of measured airflow from setpoint above which tracking counts as failed (0.30 = 30% of setpoint, in either direction)
min_evaluable_setpoint50.0L/sspOk.tAirflow setpoint below which the fraction is not meaningful — at low flow the box’s differential-pressure sensor is near its noise floor and a large percentage error is a small absolute one
tracking_duration900.0strack.delayTimeContinuous violation required before the tracking error counts as sustained rather than a damper stroke in progress (15 min)
alarm_delay300.0spersist.delayTimeFurther persistence required after tracking_duration before the alarm asserts (5 min)

Description

The box is not delivering the air it is being asked for. A VAV terminal is a flow controller: the zone loop computes an airflow setpoint and the damper loop strokes the blade until measured flow matches it, so a persistent gap means that inner loop has lost its authority — a stuck damper, an actuator off its shaft, a fouled flow sensor, or not enough static pressure at the branch. The test is a fraction rather than an absolute flow so one threshold covers a 60 L/s office and a 900 L/s conference room; that costs something at the bottom of the range, where a box holding minimum measures a few pascals of velocity pressure and a 30% error is a handful of litres per second inside the sensor’s noise, which is why the rule carries an explicit evaluability test. Comfort is the first casualty and energy the second, hence severity 3.

Detection Logic

ratio       = |zone_airflow − zone_airflow_sp| / zone_airflow_sp
ySetpointOk = zone_airflow_sp > min_evaluable_setpoint     (false ⇒ host reports NO_EVAL)
yFault      = (ratio > tracking_error_threshold) AND ySetpointOk
              held continuously for tracking_duration, then a further alarm_delay

Block graph (rule.cxf.jsonld):

VAV-0004 block graph

Taking the absolute value before the division makes the test symmetric: 100 L/s and 300 L/s against a 200 L/s setpoint are both 50% out and both report, since a damper stuck open is as much a failure of the flow loop as one stuck shut. gate carries the evaluability branch and is also what makes the unguarded division safe — CDL Divide follows IEEE-754, so a zero setpoint yields ±∞ or NaN and a near-zero denominator amplifies noise into a fraction of any magnitude, but a denominator small enough to misbehave is below min_evaluable_setpoint by construction. False yFault under false ySetpointOk means unknown, not healthy, and the host must treat it that way. track and persist are two delays in series, so 20 minutes of continuous violation are required; any momentary return to setpoint drops both timers and discards the accumulated time, so the alarm describes one continuous excursion or nothing. Both comparisons are strict, matching the reference. Both delayOnInit flags are true, holding the full 1200 s across a restart.

Possible Diagnoses

  1. VAV damper stuck — mechanical failure of the blade, shaft, or linkage
  2. VAV damper actuator disconnected, so the loop commands into thin air while the blade sits wherever it was left
  3. Airflow sensor fouled or failed — a lint-blocked or water-logged differential-pressure pickup reads low and the loop opens the damper against a measurement that will not move
  4. Duct obstruction downstream of the box — a closed fire damper, a collapsed flex duct, or a balancing damper someone shut during a complaint call
  5. Insufficient system static pressure at this branch, in which case the box is working correctly and the fault is upstream

Energy Impact

COMFORT_ENERGY, MEDIUM confidence, PROXY_ESTIMATION. The reference puts the loss at 2–5% of zone energy and names comfort as the primary impact. The waste term is computable only in the over-delivery direction — waste_kw ≈ (zone_airflow − zone_airflow_sp) × cp × |sat − zone_temp|, assembled host-side since neither temperature is a rule input; under-delivery costs unmet load and complaints instead. MEDIUM confidence: the mechanism is well established (Schein et al. 2006’s VPACC rules are the origin of this test, with ORNL’s Im et al. 2025 dataset as ground truth for stuck boxes) but the per-zone figure depends on the direction of failure and on what the reheat coil does about it. Climate-neutral; no PNNL measure maps to this fault. The multiplier is the point — what matters is the fraction of a building’s boxes doing this.

Emissions Impact

Scope 1 or 2, PROXY_EMISSIONS, MEDIUM confidence; typically 50–500 kg CO₂e/yr per zone. Which scope applies follows what the excess air is conditioned by: an over-delivering box on a hot-water reheat coil drives on-site combustion (scope 1), while the fan moving the air and the chiller cooling it are purchased electricity (scope 2). Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • Evaluability is an output, not just a precondition. The reference labels its low-setpoint vector NO_EVAL rather than NO_FAULT, and zone_airflow_sp > min_evaluable_setpoint is computable from this rule’s own inputs, so SCHEMA.md requires exposing it as ySetpointOk.
  • Two delays in series rather than one. The reference separates tracking_duration (15 min) from AlarmDelay (5 min), so both stay independently tunable even though a single 1200 s delay behaves identically at the defaults (AHU-0027’s arrangement). A site that wants a shorter duration test changes one parameter.
  • The tracking-duration condition alone is not a fault. If the error clears after track has matured but before persist has served its 5 minutes, both timers discard their accumulated time and a host reading yFault never learns the box was 15 minutes into the condition. Intended: the alarm describes one continuous excursion.
  • The division is unguarded, and the gate is what makes that safe. AHU-0021’s arrangement verbatim — NaN cannot raise a comparison, ±∞ and noise-inflated finite ratios can, and gate holds yFault down over exactly the interval the host is told to disregard.
  • Both comparisons are strict, which is the reference’s own notation on both sides rather than a substitution forced by the block set, so no measure-zero deviation arises. The boundary is exact rather than approximately exact: 140 L/s against 200 L/s evaluates to precisely the same double as the 0.30 threshold parameter.
  • playbooks: [stuck-actuator] is library-assigned, not transcribed. The reference’s card carries no playbook row, but diagnoses 1 and 2 are literally a stuck damper and a disconnected actuator and the playbook’s procedures apply unmodified. Its Applies to row lists AHU-0020, AHU-0014 and AHU-0015; the addition is recorded here because playbooks/ is single-writer.
  • related adds VAV-0005. Hunting and tracking failure are the two ways a box’s flow loop misbehaves, and a damper oscillating hard enough will also fail an averaged tracking test.
  • Vector tick is the library’s usual 300 s. This rule holds no windowed statistics — the only state is the two delay timers — so nothing couples the verdict to the host’s tick interval, unlike VAV-0005. Both delays are exact multiples of 300 s.
  • track.delayOnInit and persist.delayOnInit are both true (Modelica/CDL default is false), the library’s standing choice against alarming on the first tick after a controller restart.
  • Severity 3 (warning), phase 2, method rule, and the four tunable defaults are the reference’s chapter 10 card; its §5.8.2 index carries no severity column. g36: null — Schein’s VPACC work predates G36 and no §5.16 clause covers terminal-unit flow tracking.
  • Operating states are declared, not gated: the reference marks the fault applicable in every state with the fan running, and the graph has nothing to exclude.

Notes

This rule says the flow loop is failing, not which part. The cheapest split of diagnoses 1–3 is to plot zone_dmpr_pos alongside the two flow points for a day: a damper that never moves while the error persists is actuator or linkage (stuck-actuator step 3), a damper that strokes its full range while the measurement barely responds is a sensor, a plugged pickup, or an obstruction — check the sensor first, since a fouled pickup and a collapsed flex duct look identical from the BAS and one is a five-minute fix. Diagnosis 5 is not about this box at all: a dozen boxes short of air on the same riser is a system problem and replacing twelve actuators will not fix it.

min_evaluable_setpoint deserves a moment at commissioning. At the 50 L/s default a box whose minimum setpoint is 40 L/s is never evaluated while it sits at minimum, which is most of the year for an interior zone. That is deliberate — the measurement is not trustworthy down there — but it means a damper stuck at minimum stays invisible until the zone calls for cooling and the setpoint rises past 50 L/s.

Test Vectors

11 scenarios, clock step 300 s over 7200 s.

ScenarioDescription
tracking_normallyReference vector 1: 180 L/s measured against a 200 L/s setpoint. The 10% error is ordinary damper trim — a VAV box modulating against a moving branch pressure never sits exactly on setpoint. ratio holds at 0.10, well under the 0.30 threshold, so neither delay ever starts and the setpoint is evaluable throughout.
sustained_tracking_errorReference vector 2: 100 L/s delivered against a 200 L/s setpoint — a 50% shortfall, the signature of a damper stuck near minimum while the loop calls for more. ratio is 0.50 from the first tick, so track satisfies tracking_duration at t=900 s and persist adds alarm_delay for an assertion at t=1200 s, matching the reference’s 20-minute sustained-error vector exactly.
setpoint_below_minimumReference vector 3, the NO_EVAL case: 20 L/s against a 30 L/s setpoint is a 33% error on paper, but a 30 L/s setpoint is below min_evaluable_setpoint — at that flow the box’s differential-pressure sensor is reading a few pascals and the fraction is sensor noise, not tracking. ySetpointOk stays false for the whole run and yFault is held down with it; false yFault here means unknown, not healthy.
error_exactly_at_thresholdThreshold edge, low side: 140 L/s against 200 L/s is 60/200 = 0.30 exactly in IEEE-754, the same double the threshold parameter carries. ratioHigh is a strict comparison, so an error sitting exactly on tracking_error_threshold reads clear and no timer starts.
error_just_over_thresholdThreshold edge, high side: 139 L/s against 200 L/s is 0.305, one L/s past the previous scenario. The pair brackets tracking_error_threshold from both sides. The alarm follows the same 900 + 300 s chain and asserts at t=1200 s.
setpoint_exactly_at_minimumEvaluability edge, low side: a 50.0 L/s setpoint with 200 L/s measured is a 300% error, and the rule still refuses to judge it. spOk is strict, so a setpoint sitting exactly on min_evaluable_setpoint is not evaluable; ySetpointOk stays false and the gate holds yFault down for the whole run.
setpoint_just_above_minimumEvaluability edge, high side: a 51 L/s setpoint with the box delivering nothing — a disconnected actuator on a small zone. One L/s of setpoint separates this from the previous scenario: ySetpointOk is true, the 100% error clears the ratio test, and yFault asserts at t=1200 s.
over_delivery300 L/s against a 200 L/s setpoint — a damper stuck open, or branch pressure high enough that the box cannot throttle down to setpoint. The absolute value makes the test symmetric, so a 50% overshoot alarms on the same schedule as the 50% shortfall in sustained_tracking_error. This is the case the card’s over-delivery runtime estimate prices.
error_clears_between_delaysTransient across the two-timer boundary: a 50% error runs from t=0 and clears at t=1000 s, after track has satisfied tracking_duration (t=900 s) but before persist has served its 300 s alarm_delay. The gate drops at t=1200 s, both timers discard their accumulated time, and nothing is reported. The duration condition alone is not a fault.
brief_excursionTransient shorter than tracking_duration: the box loses flow for 700 s — a branch-pressure dip while a neighbouring zone opens up — and recovers. The gate is true for three ticks, short of the 900 s track needs, so no timer survives and no alarm is raised.
recovery_clearsRecovery: the same 50% shortfall asserts at t=1200 s, then flow is restored at t=3000 s — a technician reseats the actuator coupling. TrueDelay has no off-delay, so the gate falling drops yFault on the same tick the error clears.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "tracking_normally",
      "description": "Reference vector 1: 180 L/s measured against a 200 L/s setpoint. The 10% error is ordinary damper trim \u2014 a VAV box modulating against a moving branch pressure never sits exactly on setpoint. ratio holds at 0.10, well under the 0.30 threshold, so neither delay ever starts and the setpoint is evaluable throughout.",
      "inputs": {
        "zone_airflow": 180.0,
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "sustained_tracking_error",
      "description": "Reference vector 2: 100 L/s delivered against a 200 L/s setpoint \u2014 a 50% shortfall, the signature of a damper stuck near minimum while the loop calls for more. ratio is 0.50 from the first tick, so track satisfies tracking_duration at t=900 s and persist adds alarm_delay for an assertion at t=1200 s, matching the reference's 20-minute sustained-error vector exactly.",
      "inputs": {
        "zone_airflow": 100.0,
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "setpoint_below_minimum",
      "description": "Reference vector 3, the NO_EVAL case: 20 L/s against a 30 L/s setpoint is a 33% error on paper, but a 30 L/s setpoint is below min_evaluable_setpoint \u2014 at that flow the box's differential-pressure sensor is reading a few pascals and the fraction is sensor noise, not tracking. ySetpointOk stays false for the whole run and yFault is held down with it; false yFault here means unknown, not healthy.",
      "inputs": {
        "zone_airflow": 20.0,
        "zone_airflow_sp": 30.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "error_exactly_at_threshold",
      "description": "Threshold edge, low side: 140 L/s against 200 L/s is 60/200 = 0.30 exactly in IEEE-754, the same double the threshold parameter carries. ratioHigh is a strict comparison, so an error sitting exactly on tracking_error_threshold reads clear and no timer starts.",
      "inputs": {
        "zone_airflow": 140.0,
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "error_just_over_threshold",
      "description": "Threshold edge, high side: 139 L/s against 200 L/s is 0.305, one L/s past the previous scenario. The pair brackets tracking_error_threshold from both sides. The alarm follows the same 900 + 300 s chain and asserts at t=1200 s.",
      "inputs": {
        "zone_airflow": 139.0,
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "setpoint_exactly_at_minimum",
      "description": "Evaluability edge, low side: a 50.0 L/s setpoint with 200 L/s measured is a 300% error, and the rule still refuses to judge it. spOk is strict, so a setpoint sitting exactly on min_evaluable_setpoint is not evaluable; ySetpointOk stays false and the gate holds yFault down for the whole run.",
      "inputs": {
        "zone_airflow": 200.0,
        "zone_airflow_sp": 50.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "setpoint_just_above_minimum",
      "description": "Evaluability edge, high side: a 51 L/s setpoint with the box delivering nothing \u2014 a disconnected actuator on a small zone. One L/s of setpoint separates this from the previous scenario: ySetpointOk is true, the 100% error clears the ratio test, and yFault asserts at t=1200 s.",
      "inputs": {
        "zone_airflow": 0.0,
        "zone_airflow_sp": 51.0
      },
      "expect": [
        {
          "output": "ySetpointOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "over_delivery",
      "description": "300 L/s against a 200 L/s setpoint \u2014 a damper stuck open, or branch pressure high enough that the box cannot throttle down to setpoint. The absolute value makes the test symmetric, so a 50% overshoot alarms on the same schedule as the 50% shortfall in sustained_tracking_error. This is the case the card's over-delivery runtime estimate prices.",
      "inputs": {
        "zone_airflow": 300.0,
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "error_clears_between_delays",
      "description": "Transient across the two-timer boundary: a 50% error runs from t=0 and clears at t=1000 s, after track has satisfied tracking_duration (t=900 s) but before persist has served its 300 s alarm_delay. The gate drops at t=1200 s, both timers discard their accumulated time, and nothing is reported. The duration condition alone is not a fault.",
      "inputs": {
        "zone_airflow": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 1000,
            "value": 200.0
          }
        ],
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "brief_excursion",
      "description": "Transient shorter than tracking_duration: the box loses flow for 700 s \u2014 a branch-pressure dip while a neighbouring zone opens up \u2014 and recovers. The gate is true for three ticks, short of the 900 s track needs, so no timer survives and no alarm is raised.",
      "inputs": {
        "zone_airflow": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 700,
            "value": 200.0
          }
        ],
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "recovery_clears",
      "description": "Recovery: the same 50% shortfall asserts at t=1200 s, then flow is restored at t=3000 s \u2014 a technician reseats the actuator coupling. TrueDelay has no off-delay, so the gate falling drops yFault on the same tick the error clears.",
      "inputs": {
        "zone_airflow": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 3000,
            "value": 200.0
          }
        ],
        "zone_airflow_sp": 200.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 7200,
          "equals": false
        }
      ]
    }
  ]
}