FPB-0004 — Terminal fan airflow degradation
| Status | verified — engine e2ff2f8, cxf:fnv1a128:6329b30093556d09175f8d877b734a2a, 2026-08-20 |
| Severity | 3 |
| Method | statistical |
| Phase | 3 |
| Category | EFFICIENCY_LOSS |
| Confidence | MEDIUM |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | FPB-0001, FPB-0002, FPB-0005, FPB-0006 |
| Playbooks | fan-powered-terminal-faults |
| Source | LBNL FDD simulated FPU dataset, DOI 10.25984/1881324 — PFPU/SFPU topology and restricted-fan-flow fault class; future empirical replay source, not threshold evidence; Library host-baseline precedent PMP-0006 — positive-baseline cross-multiplied residual, numerical validity output, and explicit model-readiness obligations; Library-authored thresholds and persistence; no cited source publishes 50 L/s, 20%, or 900 s as portable FPB limits |
| Operating states | stable automatic PFPU or SFPU fan operation with proven fan status and a ready same-path expected-airflow model |
Preconditions (host-enforced): fan_airflow_expected is host-fitted on a known-good period, frozen/versioned, fresh, and in-domain for the current fan command/speed, pressure, subtype, damper/topology, and operating state. Actual and expected must represent the same fan path: PFPU fan/plenum branch; SFPU series-fan path. fan_status must prove the same fan. Exclude startup, speed/mode/damper transitions, smoke operation, maintenance, local control, and invalid airflow sensing. minimum_expected_airflow is adoption-blocking. yBaselineOk proves only numerical positivity; other unmet obligations are NO_EVAL, not healthy.
Points: fan_status, fan_airflow, fan_airflow_expected
Outputs:
yFault— True after proven fan operation and a valid expected baseline remain more than the allowed airflow fraction above actual delivery for sustained_durationyBaselineOk— Evaluability flag — true only when fan_airflow_expected is strictly above minimum_expected_airflow. FALSE MEANS NO_EVALyAirflowLow— Diagnostic sub-condition flag; true when a numerically valid expected baseline exceeds actual fan-path airflow by more than the allowed fraction. False never means NO_EVAL
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
minimum_expected_airflow | 50.0 | L/s | baselineOk.t | NO_PORTABLE_DEFAULT and adoption-blocking expected-airflow floor; equality is NO_EVAL. |
max_airflow_drop_fraction | 0.2 | 1 | allowance.k | ADOPTED_TUNABLE maximum actual-flow deficit as a fraction of positive expected flow; strict comparison. |
sustained_duration | 900.0 | s | persist.delayTime | ADOPTED_TUNABLE continuous low-delivery duration; commission against fan and baseline response. |
Description
This rule detects fan-path airflow materially below a known-good, current-condition baseline while the terminal fan is proven on. It reports delivery degradation, not a specific dirty filter, fan, belt, damper, voltage, or sensor cause.
Detection Logic
baseline_ok = fan_airflow_expected > minimum_expected_airflow
residual = fan_airflow_expected - fan_airflow
allowance = fan_airflow_expected * max_airflow_drop_fraction
airflow_low = baseline_ok AND residual > allowance
yBaselineOk = baseline_ok
yAirflowLow = airflow_low
yFault = fan_status AND airflow_low, sustained for sustained_duration
Cross-multiplication makes the relative test safe without a Divide; all
comparisons are strict and persistence uses delayOnInit=true.
Possible Diagnoses
- Restricted intake/discharge, dirty filter, or obstructed fan-path damper.
- Fouled/damaged wheel, slipping belt/coupling, low speed, voltage, or torque limit.
- Different fan configuration, pressure, or subtype state than the baseline.
- Actual/expected path mismatch, bad sensor, stale model, or degraded training data.
Energy Impact
Low delivered airflow can extend fan/reheat/primary-air operation or miss load. The residual is a delivery proxy, not measured power; no kWh is claimed.
Emissions Impact
Scope 2 qualitative through fan and compensating HVAC electricity.
Deviations
- No Divide block is used. Cross-multiplication is equivalent in the positive expected-flow domain and structurally safe at zero.
- The expected point is site-fitted, not a universal fan curve. Readiness, training, features, path scope, and domain checks remain host obligations.
- All defaults require commissioning. 50 L/s is adoption-blocking; 20% and 900 s are adopted tunables.
- Proof remains related rather than a suppressor. FPB-0001 fail-to-start invalidates the premise, but unexpected run can still leave this degradation signature meaningful.
- No empirical FPR/TPR is claimed. LBNL mapping/replay is deferred to PR11.
Test Vectors
15 scenarios, clock step 60 s over 2100 s.
| Scenario | Description |
|---|---|
expected_airflow_delivered | Fan-path airflow equals the valid expected baseline. |
baseline_zero_is_no_eval | A zero expected baseline is numerically invalid without any Divide evaluation. |
baseline_below_minimum | 49.9 L/s lies below the strict baseline floor. |
baseline_exactly_at_minimum | Exactly 50 L/s is NO_EVAL under the strict comparison. |
baseline_just_above_minimum | 50.1 L/s is evaluable when actual airflow matches it. |
drop_exactly_at_threshold | An actual 80 against expected 100 makes residual equal the 20 allowance; strict Greater remains clear. |
drop_just_beyond_threshold | 79.9 against 100 drives the low-flow diagnostic and matures outside the 900 s edge margin. |
actual_above_expected | More airflow than expected is outside this one-sided degradation rule. |
fan_off_low_actual | The baseline-conditioned diagnostic remains visible, but a stopped fan cannot produce the degradation verdict. |
violation_ends_one_step_before_persistence | Low airflow clears at 840 s, one evaluator step before the 900 s delay, and never alarms. |
mature_fault_recovers | A mature airflow degradation alarm clears immediately when actual delivery recovers. |
two_short_drops_reset | Two six-minute deficits separated by recovery do not accumulate age. |
expected_baseline_change_resets_candidate | An in-domain expected-baseline change removes the residual and resets persistence. |
pfpu_total_discharge_bad_binding | A mixed total-discharge point can match the expected branch flow while the fan branch is restricted; the clear raw result pins the path-scope blind spot. |
sfpu_duplicate_primary_sensor_bad_binding | Using the same primary sensor for actual and expected makes a tautological clear result; topology/model independence is host-enforced. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 2100
},
"scenarios": [
{
"name": "expected_airflow_delivered",
"description": "Fan-path airflow equals the valid expected baseline.",
"inputs": {
"fan_status": true,
"fan_airflow": 100.0,
"fan_airflow_expected": 100.0
},
"expect": [
{
"output": "yBaselineOk",
"from_s": 0,
"to_s": 2100,
"equals": true
},
{
"output": "yAirflowLow",
"from_s": 0,
"to_s": 2100,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "baseline_zero_is_no_eval",
"description": "A zero expected baseline is numerically invalid without any Divide evaluation.",
"inputs": {
"fan_status": true,
"fan_airflow": 0.0,
"fan_airflow_expected": 0.0
},
"expect": [
{
"output": "yBaselineOk",
"from_s": 0,
"to_s": 2100,
"equals": false
},
{
"output": "yAirflowLow",
"from_s": 0,
"to_s": 2100,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "baseline_below_minimum",
"description": "49.9 L/s lies below the strict baseline floor.",
"inputs": {
"fan_status": true,
"fan_airflow": 0.0,
"fan_airflow_expected": 49.9
},
"expect": [
{
"output": "yBaselineOk",
"from_s": 0,
"to_s": 2100,
"equals": false
},
{
"output": "yAirflowLow",
"from_s": 0,
"to_s": 2100,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "baseline_exactly_at_minimum",
"description": "Exactly 50 L/s is NO_EVAL under the strict comparison.",
"inputs": {
"fan_status": true,
"fan_airflow": 0.0,
"fan_airflow_expected": 50.0
},
"expect": [
{
"output": "yBaselineOk",
"from_s": 0,
"to_s": 2100,
"equals": false
},
{
"output": "yAirflowLow",
"from_s": 0,
"to_s": 2100,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "baseline_just_above_minimum",
"description": "50.1 L/s is evaluable when actual airflow matches it.",
"inputs": {
"fan_status": true,
"fan_airflow": 50.1,
"fan_airflow_expected": 50.1
},
"expect": [
{
"output": "yBaselineOk",
"from_s": 0,
"to_s": 2100,
"equals": true
},
{
"output": "yAirflowLow",
"from_s": 0,
"to_s": 2100,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "drop_exactly_at_threshold",
"description": "An actual 80 against expected 100 makes residual equal the 20 allowance; strict Greater remains clear.",
"inputs": {
"fan_status": true,
"fan_airflow": 80.0,
"fan_airflow_expected": 100.0
},
"expect": [
{
"output": "yBaselineOk",
"from_s": 0,
"to_s": 2100,
"equals": true
},
{
"output": "yAirflowLow",
"from_s": 0,
"to_s": 2100,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "drop_just_beyond_threshold",
"description": "79.9 against 100 drives the low-flow diagnostic and matures outside the 900 s edge margin.",
"inputs": {
"fan_status": true,
"fan_airflow": 79.9,
"fan_airflow_expected": 100.0
},
"expect": [
{
"output": "yBaselineOk",
"from_s": 0,
"to_s": 2100,
"equals": true
},
{
"output": "yAirflowLow",
"from_s": 0,
"to_s": 2100,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 2100,
"equals": true
}
]
},
{
"name": "actual_above_expected",
"description": "More airflow than expected is outside this one-sided degradation rule.",
"inputs": {
"fan_status": true,
"fan_airflow": 120.0,
"fan_airflow_expected": 100.0
},
"expect": [
{
"output": "yBaselineOk",
"from_s": 0,
"to_s": 2100,
"equals": true
},
{
"output": "yAirflowLow",
"from_s": 0,
"to_s": 2100,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "fan_off_low_actual",
"description": "The baseline-conditioned diagnostic remains visible, but a stopped fan cannot produce the degradation verdict.",
"inputs": {
"fan_status": false,
"fan_airflow": 0.0,
"fan_airflow_expected": 100.0
},
"expect": [
{
"output": "yBaselineOk",
"from_s": 0,
"to_s": 2100,
"equals": true
},
{
"output": "yAirflowLow",
"from_s": 0,
"to_s": 2100,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "violation_ends_one_step_before_persistence",
"description": "Low airflow clears at 840 s, one evaluator step before the 900 s delay, and never alarms.",
"inputs": {
"fan_status": true,
"fan_airflow": [
{
"t": 0,
"value": 70.0
},
{
"t": 840,
"value": 100.0
}
],
"fan_airflow_expected": 100.0
},
"expect": [
{
"output": "yAirflowLow",
"from_s": 0,
"to_s": 780,
"equals": true
},
{
"output": "yAirflowLow",
"from_s": 840,
"to_s": 2100,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "mature_fault_recovers",
"description": "A mature airflow degradation alarm clears immediately when actual delivery recovers.",
"inputs": {
"fan_status": true,
"fan_airflow": [
{
"t": 0,
"value": 70.0
},
{
"t": 1200,
"value": 100.0
}
],
"fan_airflow_expected": 100.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 1140,
"equals": true
},
{
"output": "yFault",
"from_s": 1200,
"to_s": 2100,
"equals": false
},
{
"output": "yAirflowLow",
"from_s": 0,
"to_s": 1140,
"equals": true
},
{
"output": "yAirflowLow",
"from_s": 1200,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "two_short_drops_reset",
"description": "Two six-minute deficits separated by recovery do not accumulate age.",
"inputs": {
"fan_status": true,
"fan_airflow": [
{
"t": 0,
"value": 70.0
},
{
"t": 360,
"value": 100.0
},
{
"t": 600,
"value": 70.0
},
{
"t": 960,
"value": 100.0
}
],
"fan_airflow_expected": 100.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "expected_baseline_change_resets_candidate",
"description": "An in-domain expected-baseline change removes the residual and resets persistence.",
"inputs": {
"fan_status": true,
"fan_airflow": 79.9,
"fan_airflow_expected": [
{
"t": 0,
"value": 100.0
},
{
"t": 600,
"value": 75.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
},
{
"output": "yAirflowLow",
"from_s": 0,
"to_s": 540,
"equals": true
},
{
"output": "yAirflowLow",
"from_s": 600,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "pfpu_total_discharge_bad_binding",
"description": "A mixed total-discharge point can match the expected branch flow while the fan branch is restricted; the clear raw result pins the path-scope blind spot.",
"inputs": {
"fan_status": true,
"fan_airflow": 100.0,
"fan_airflow_expected": 100.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
}
]
},
{
"name": "sfpu_duplicate_primary_sensor_bad_binding",
"description": "Using the same primary sensor for actual and expected makes a tautological clear result; topology/model independence is host-enforced.",
"inputs": {
"fan_status": true,
"fan_airflow": 90.0,
"fan_airflow_expected": 90.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2100,
"equals": false
}
]
}
]
}