FPB-0002 — Primary airflow tracking failure
| Status | verified — engine e2ff2f8, cxf:fnv1a128:5c3e3827711a6dd02246c4398a19d26c, 2026-08-20 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | COMFORT_ENERGY |
| Confidence | HIGH |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | FPB-0001, FPB-0003, FPB-0004, FPB-0005, VAV-0004, AHU-0001, AHU-0024, AHU-0031 |
| Playbooks | fan-powered-terminal-faults |
| Source | Library executable precedent VAV-0004 — active-airflow-setpoint tracking semantics and two-sided diagnostic direction; Buildings.Controls.OBC.ASHRAE.G36 terminal-unit sequences — primary airflow measurement and active setpoint are distinct terminal control points; mechanism only; LBNL FDD simulated FPU dataset, DOI 10.25984/1881324 — PFPU/SFPU topology and future validation source, not threshold evidence |
| Operating states | enabled series or parallel FPB in a settled occupied/ventilation/control state where active primary airflow tracking is expected |
Preconditions (host-enforced): primary_airflow must be the AHU-fed primary inlet stream, excluding induced/plenum fan flow; primary_airflow_sp must be the settled final active target, not a design minimum/maximum. The upstream AHU fan and sufficient static pressure must be available. Validate airflow units, calibration and K-factor, controller pressure-independence, and data freshness. Exclude AHU shutdown, startup, setpoint ramps, balancing, overrides, and maintenance. minimum_airflow_sp is adoption-blocking and must be commissioned. ySetpointOk covers only the numerical setpoint floor; any other unmet host obligation is NO_EVAL, not healthy.
Points: primary_airflow, primary_airflow_sp
Outputs:
yFault— True after primary airflow remains more than the allowed fraction above or below its active setpoint for sustained_durationySetpointOk— Evaluability flag — true only when primary_airflow_sp is strictly above minimum_airflow_sp. FALSE MEANS NO_EVAL, regardless of yFaultyFlowLow— Diagnostic direction flag; true immediately when an evaluable flow is more than the allowed fraction below target. False never means NO_EVALyFlowHigh— Diagnostic direction flag; true immediately when an evaluable flow is more than the allowed fraction above target. False never means NO_EVAL
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
minimum_airflow_sp | 50.0 | L/s | setpointOk.t | NO_PORTABLE_DEFAULT and adoption-blocking floor below which relative tracking is not evaluated; equality is NO_EVAL. |
max_tracking_error_fraction | 0.2 | 1 | allowedError.k | ADOPTED_TUNABLE maximum absolute residual as a fraction of the positive active setpoint; both comparisons are strict. |
sustained_duration | 600.0 | s | persist.delayTime | ADOPTED_TUNABLE continuous out-of-band duration; commission above box response and active-setpoint settling. |
Description
This rule detects primary air delivered materially above or below the active FPB target. It deliberately measures only the AHU-fed inlet stream; neither a series fan’s total discharge nor a parallel fan’s induced branch is equivalent.
Detection Logic
setpoint_ok = primary_airflow_sp > minimum_airflow_sp
allowed = primary_airflow_sp * max_tracking_error_fraction
flow_high = (primary_airflow - primary_airflow_sp) > allowed
flow_low = (primary_airflow_sp - primary_airflow) > allowed
yFlowHigh = setpoint_ok AND flow_high
yFlowLow = setpoint_ok AND flow_low
yFault = setpoint_ok AND (flow_high OR flow_low),
sustained for sustained_duration
The positive setpoint floor defines the valid domain. Cross-multiplied
residuals avoid Divide entirely, so zero setpoint cannot evaluate an unsafe denominator.
Possible Diagnoses
- Stuck, disconnected, or miscalibrated primary-air damper/actuator.
- Insufficient or excessive upstream duct static pressure or failed AHU reset.
- Blocked inlet, damaged flow ring, wrong K-factor, or sensor bias.
- Stale, misbound, or incorrect active setpoint/units.
- Pressure-dependent controller or subtype total-flow point bound as primary flow.
Energy Impact
High primary flow can raise AHU fan, cooling, and reheat energy; low flow can miss ventilation and comfort targets. The signature alone does not identify which effect is avoidable, so the card remains qualitative.
Emissions Impact
Scope 1/2 qualitative by the serving heating/cooling system. Quantify only after isolating the cause and measuring the affected fan or thermal input.
Deviations
- The graph does not divide. The brief’s fractional-error equation is algebraically equivalent in the enforced positive-setpoint domain; cross-multiplication makes zero-denominator safety structural.
- 50 L/s is not portable. It is an adoption-blocking placeholder because meaningful minimum flow scales with box size and ventilation design.
- 20% and 600 s are adopted. Sources support the mechanism, not universal thresholds.
- Direction handoff preserves persistence. One timer follows the low/high OR; a continuously out-of-band sampled reversal remains one tracking fault.
ySetpointOkis not the whole host gate. AHU availability, settled target, calibration, and controller behavior remain external obligations.- No empirical FPR or TPR is claimed. The LBNL replay adapter is deferred to PR11.
Test Vectors
15 scenarios, clock step 60 s over 1800 s.
| Scenario | Description |
|---|---|
tracking_normally | Measured primary airflow equals its active target. |
sustained_low_flow | A 20.1% deficit drives only the low diagnostic and matures after the 600 s persistence margin. |
sustained_high_flow | A 20.1% excess drives only the high diagnostic and matures independently. |
low_error_exactly_at_threshold | At exactly 80 L/s against 100 L/s, residual equals allowance and the strict comparison is clear. |
high_error_exactly_at_threshold | At exactly 120 L/s against 100 L/s, residual equals allowance and the strict comparison is clear. |
setpoint_below_minimum | A 49.9 L/s target is below the evaluability floor; all diagnostic and fault outputs stay false. |
setpoint_exactly_at_minimum | The strict floor leaves exactly 50 L/s non-evaluable. |
setpoint_just_above_minimum | One representable increment above the floor is numerically evaluable when flow tracks it. |
zero_setpoint_zero_flow | The zero/zero case cannot divide because this graph has no Divide block; the floor reports NO_EVAL. |
violation_ends_one_step_before_persistence | A deficit clears at 540 s, one evaluator step before 600 s, and never matures. |
mature_low_fault_recovers | A mature low-flow alarm clears immediately when measured flow returns to target. |
two_short_episodes_reset | Two four-minute deficits separated by compliant tracking cannot accumulate age. |
sampled_direction_handoff_preserves_age | A direct sampled low-to-high handoff stays continuously outside the band, so the shared persistence timer keeps its age while direction flags swap. |
active_setpoint_ramp_raw_transient | An unexcluded setpoint step creates a short raw deficit; the host must wait for the active target and controller to settle. |
upstream_ahu_off_raw_false_positive | With an active target left behind while the AHU is off, the raw graph alarms; the host must report NO_EVAL for unavailable upstream fan/static pressure. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 1800
},
"scenarios": [
{
"name": "tracking_normally",
"description": "Measured primary airflow equals its active target.",
"inputs": {
"primary_airflow": 100.0,
"primary_airflow_sp": 100.0
},
"expect": [
{
"output": "ySetpointOk",
"from_s": 0,
"to_s": 1800,
"equals": true
},
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "sustained_low_flow",
"description": "A 20.1% deficit drives only the low diagnostic and matures after the 600 s persistence margin.",
"inputs": {
"primary_airflow": 79.9,
"primary_airflow_sp": 100.0
},
"expect": [
{
"output": "ySetpointOk",
"from_s": 0,
"to_s": 1800,
"equals": true
},
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 1800,
"equals": true
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 540,
"equals": false
},
{
"output": "yFault",
"from_s": 660,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "sustained_high_flow",
"description": "A 20.1% excess drives only the high diagnostic and matures independently.",
"inputs": {
"primary_airflow": 120.1,
"primary_airflow_sp": 100.0
},
"expect": [
{
"output": "ySetpointOk",
"from_s": 0,
"to_s": 1800,
"equals": true
},
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 1800,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 540,
"equals": false
},
{
"output": "yFault",
"from_s": 660,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "low_error_exactly_at_threshold",
"description": "At exactly 80 L/s against 100 L/s, residual equals allowance and the strict comparison is clear.",
"inputs": {
"primary_airflow": 80.0,
"primary_airflow_sp": 100.0
},
"expect": [
{
"output": "ySetpointOk",
"from_s": 0,
"to_s": 1800,
"equals": true
},
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "high_error_exactly_at_threshold",
"description": "At exactly 120 L/s against 100 L/s, residual equals allowance and the strict comparison is clear.",
"inputs": {
"primary_airflow": 120.0,
"primary_airflow_sp": 100.0
},
"expect": [
{
"output": "ySetpointOk",
"from_s": 0,
"to_s": 1800,
"equals": true
},
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "setpoint_below_minimum",
"description": "A 49.9 L/s target is below the evaluability floor; all diagnostic and fault outputs stay false.",
"inputs": {
"primary_airflow": 0.0,
"primary_airflow_sp": 49.9
},
"expect": [
{
"output": "ySetpointOk",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "setpoint_exactly_at_minimum",
"description": "The strict floor leaves exactly 50 L/s non-evaluable.",
"inputs": {
"primary_airflow": 0.0,
"primary_airflow_sp": 50.0
},
"expect": [
{
"output": "ySetpointOk",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "setpoint_just_above_minimum",
"description": "One representable increment above the floor is numerically evaluable when flow tracks it.",
"inputs": {
"primary_airflow": 50.1,
"primary_airflow_sp": 50.1
},
"expect": [
{
"output": "ySetpointOk",
"from_s": 0,
"to_s": 1800,
"equals": true
},
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "zero_setpoint_zero_flow",
"description": "The zero/zero case cannot divide because this graph has no Divide block; the floor reports NO_EVAL.",
"inputs": {
"primary_airflow": 0.0,
"primary_airflow_sp": 0.0
},
"expect": [
{
"output": "ySetpointOk",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "violation_ends_one_step_before_persistence",
"description": "A deficit clears at 540 s, one evaluator step before 600 s, and never matures.",
"inputs": {
"primary_airflow": [
{
"t": 0,
"value": 70.0
},
{
"t": 540,
"value": 100.0
}
],
"primary_airflow_sp": 100.0
},
"expect": [
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 480,
"equals": true
},
{
"output": "yFlowLow",
"from_s": 540,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "mature_low_fault_recovers",
"description": "A mature low-flow alarm clears immediately when measured flow returns to target.",
"inputs": {
"primary_airflow": [
{
"t": 0,
"value": 70.0
},
{
"t": 900,
"value": 100.0
}
],
"primary_airflow_sp": 100.0
},
"expect": [
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 840,
"equals": true
},
{
"output": "yFlowLow",
"from_s": 900,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 540,
"equals": false
},
{
"output": "yFault",
"from_s": 660,
"to_s": 840,
"equals": true
},
{
"output": "yFault",
"from_s": 900,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "two_short_episodes_reset",
"description": "Two four-minute deficits separated by compliant tracking cannot accumulate age.",
"inputs": {
"primary_airflow": [
{
"t": 0,
"value": 70.0
},
{
"t": 240,
"value": 100.0
},
{
"t": 480,
"value": 70.0
},
{
"t": 720,
"value": 100.0
}
],
"primary_airflow_sp": 100.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "sampled_direction_handoff_preserves_age",
"description": "A direct sampled low-to-high handoff stays continuously outside the band, so the shared persistence timer keeps its age while direction flags swap.",
"inputs": {
"primary_airflow": [
{
"t": 0,
"value": 70.0
},
{
"t": 300,
"value": 130.0
}
],
"primary_airflow_sp": 100.0
},
"expect": [
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 240,
"equals": true
},
{
"output": "yFlowLow",
"from_s": 300,
"to_s": 1800,
"equals": false
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 240,
"equals": false
},
{
"output": "yFlowHigh",
"from_s": 300,
"to_s": 1800,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 540,
"equals": false
},
{
"output": "yFault",
"from_s": 660,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "active_setpoint_ramp_raw_transient",
"description": "An unexcluded setpoint step creates a short raw deficit; the host must wait for the active target and controller to settle.",
"inputs": {
"primary_airflow": 60.0,
"primary_airflow_sp": [
{
"t": 0,
"value": 60.0
},
{
"t": 300,
"value": 100.0
},
{
"t": 780,
"value": 60.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "upstream_ahu_off_raw_false_positive",
"description": "With an active target left behind while the AHU is off, the raw graph alarms; the host must report NO_EVAL for unavailable upstream fan/static pressure.",
"inputs": {
"primary_airflow": 0.0,
"primary_airflow_sp": 100.0
},
"expect": [
{
"output": "ySetpointOk",
"from_s": 0,
"to_s": 1800,
"equals": true
},
{
"output": "yFlowLow",
"from_s": 0,
"to_s": 1800,
"equals": true
},
{
"output": "yFlowHigh",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 540,
"equals": false
},
{
"output": "yFault",
"from_s": 660,
"to_s": 1800,
"equals": true
}
]
}
]
}