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-0002 — Primary airflow tracking failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:5c3e3827711a6dd02246c4398a19d26c, 2026-08-20
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceHIGH
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedFPB-0001, FPB-0003, FPB-0004, FPB-0005, VAV-0004, AHU-0001, AHU-0024, AHU-0031
Playbooksfan-powered-terminal-faults
SourceLibrary 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 statesenabled 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_duration
  • ySetpointOk — Evaluability flag — true only when primary_airflow_sp is strictly above minimum_airflow_sp. FALSE MEANS NO_EVAL, regardless of yFault
  • yFlowLow — Diagnostic direction flag; true immediately when an evaluable flow is more than the allowed fraction below target. False never means NO_EVAL
  • yFlowHigh — Diagnostic direction flag; true immediately when an evaluable flow is more than the allowed fraction above target. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
minimum_airflow_sp50.0L/ssetpointOk.tNO_PORTABLE_DEFAULT and adoption-blocking floor below which relative tracking is not evaluated; equality is NO_EVAL.
max_tracking_error_fraction0.21allowedError.kADOPTED_TUNABLE maximum absolute residual as a fraction of the positive active setpoint; both comparisons are strict.
sustained_duration600.0spersist.delayTimeADOPTED_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

FPB-0002 block graph

The positive setpoint floor defines the valid domain. Cross-multiplied residuals avoid Divide entirely, so zero setpoint cannot evaluate an unsafe denominator.

Possible Diagnoses

  1. Stuck, disconnected, or miscalibrated primary-air damper/actuator.
  2. Insufficient or excessive upstream duct static pressure or failed AHU reset.
  3. Blocked inlet, damaged flow ring, wrong K-factor, or sensor bias.
  4. Stale, misbound, or incorrect active setpoint/units.
  5. 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.
  • ySetpointOk is 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.

ScenarioDescription
tracking_normallyMeasured primary airflow equals its active target.
sustained_low_flowA 20.1% deficit drives only the low diagnostic and matures after the 600 s persistence margin.
sustained_high_flowA 20.1% excess drives only the high diagnostic and matures independently.
low_error_exactly_at_thresholdAt exactly 80 L/s against 100 L/s, residual equals allowance and the strict comparison is clear.
high_error_exactly_at_thresholdAt exactly 120 L/s against 100 L/s, residual equals allowance and the strict comparison is clear.
setpoint_below_minimumA 49.9 L/s target is below the evaluability floor; all diagnostic and fault outputs stay false.
setpoint_exactly_at_minimumThe strict floor leaves exactly 50 L/s non-evaluable.
setpoint_just_above_minimumOne representable increment above the floor is numerically evaluable when flow tracks it.
zero_setpoint_zero_flowThe zero/zero case cannot divide because this graph has no Divide block; the floor reports NO_EVAL.
violation_ends_one_step_before_persistenceA deficit clears at 540 s, one evaluator step before 600 s, and never matures.
mature_low_fault_recoversA mature low-flow alarm clears immediately when measured flow returns to target.
two_short_episodes_resetTwo four-minute deficits separated by compliant tracking cannot accumulate age.
sampled_direction_handoff_preserves_ageA 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_transientAn 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_positiveWith 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
        }
      ]
    }
  ]
}