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

FPB-0004 — Terminal fan airflow degradation

Statusverified — engine e2ff2f8, cxf:fnv1a128:6329b30093556d09175f8d877b734a2a, 2026-08-20
Severity3
Methodstatistical
Phase3
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedFPB-0001, FPB-0002, FPB-0005, FPB-0006
Playbooksfan-powered-terminal-faults
SourceLBNL 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 statesstable 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_duration
  • yBaselineOk — Evaluability flag — true only when fan_airflow_expected is strictly above minimum_expected_airflow. FALSE MEANS NO_EVAL
  • yAirflowLow — 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:

NameDefaultUnitCXF pathDescription
minimum_expected_airflow50.0L/sbaselineOk.tNO_PORTABLE_DEFAULT and adoption-blocking expected-airflow floor; equality is NO_EVAL.
max_airflow_drop_fraction0.21allowance.kADOPTED_TUNABLE maximum actual-flow deficit as a fraction of positive expected flow; strict comparison.
sustained_duration900.0spersist.delayTimeADOPTED_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

FPB-0004 block graph

Cross-multiplication makes the relative test safe without a Divide; all comparisons are strict and persistence uses delayOnInit=true.

Possible Diagnoses

  1. Restricted intake/discharge, dirty filter, or obstructed fan-path damper.
  2. Fouled/damaged wheel, slipping belt/coupling, low speed, voltage, or torque limit.
  3. Different fan configuration, pressure, or subtype state than the baseline.
  4. 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.

ScenarioDescription
expected_airflow_deliveredFan-path airflow equals the valid expected baseline.
baseline_zero_is_no_evalA zero expected baseline is numerically invalid without any Divide evaluation.
baseline_below_minimum49.9 L/s lies below the strict baseline floor.
baseline_exactly_at_minimumExactly 50 L/s is NO_EVAL under the strict comparison.
baseline_just_above_minimum50.1 L/s is evaluable when actual airflow matches it.
drop_exactly_at_thresholdAn actual 80 against expected 100 makes residual equal the 20 allowance; strict Greater remains clear.
drop_just_beyond_threshold79.9 against 100 drives the low-flow diagnostic and matures outside the 900 s edge margin.
actual_above_expectedMore airflow than expected is outside this one-sided degradation rule.
fan_off_low_actualThe baseline-conditioned diagnostic remains visible, but a stopped fan cannot produce the degradation verdict.
violation_ends_one_step_before_persistenceLow airflow clears at 840 s, one evaluator step before the 900 s delay, and never alarms.
mature_fault_recoversA mature airflow degradation alarm clears immediately when actual delivery recovers.
two_short_drops_resetTwo six-minute deficits separated by recovery do not accumulate age.
expected_baseline_change_resets_candidateAn in-domain expected-baseline change removes the residual and resets persistence.
pfpu_total_discharge_bad_bindingA 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_bindingUsing 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
        }
      ]
    }
  ]
}