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-0005 — Primary airflow sensor disagreement

Statusverified — engine e2ff2f8, cxf:fnv1a128:dea3bb3d4caf1dc61fdbb108eaaa20e3, 2026-08-20
Severity3
Methodmeta
Phase3
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedFPB-0002, FPB-0004
Playbooksfan-powered-terminal-faults, sensor-drift
SourceLBNL FDD simulated FPU dataset, DOI 10.25984/1881324 — primary-airflow sensor-bias fault class; future empirical replay source, not threshold evidence; Library sensor-health precedents SYS-0005 and SYS-0006 — independent-reference comparison, directional residuals, and explicit adjudication uncertainty; Library-authored thresholds and persistence; no cited source publishes 50 L/s, 15%, or 900 s as portable FPB limits
Operating statessteady FPB primary-air operation with sufficient reference flow and an independent, ready same-stream reference

Preconditions (host-enforced): primary_airflow_reference must be independent of primary_airflow, known-good/in-domain, documented, fresh, and time-aligned. Valid sources include a certified redundant sensor, calibrated independent damper/pressure model, or validated upstream branch balance; a calculation that consumes primary_airflow is circular and invalid. Both points must use L/s and the same primary inlet stream/location. Exclude startup, setpoint/damper/static-pressure transitions the reference cannot follow, AHU shutdown, and insufficient steady flow. minimum_reference_airflow is adoption-blocking. yReferenceOk covers numerical positivity only. Because adjudicates.verdict is ambiguous, yFault establishes disagreement but does not by itself authorize automatic invalidation of primary_airflow; deployment must separately certify the reference before directional adjudication.

Points: primary_airflow, primary_airflow_reference

Outputs:

  • yFault — True after the primary airflow measurement and independent reference remain more than the allowed fraction apart for sustained_duration; the verdict does not prove which value is wrong
  • yReferenceOk — Evaluability flag — true only when primary_airflow_reference is strictly above minimum_reference_airflow. FALSE MEANS NO_EVAL
  • yPositiveBias — Diagnostic direction flag; true when the measurement is above the valid reference by more than the allowed fraction. False never means NO_EVAL
  • yNegativeBias — Diagnostic direction flag; true when the measurement is below the valid reference by more than the allowed fraction. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
minimum_reference_airflow50.0L/sreferenceOk.tNO_PORTABLE_DEFAULT and adoption-blocking reference-flow floor; equality is NO_EVAL.
max_disagreement_fraction0.151allowance.kADOPTED_TUNABLE allowed absolute residual as a fraction of positive reference flow; both comparisons are strict.
sustained_duration900.0spersist.delayTimeADOPTED_TUNABLE continuous disagreement duration; commission above measurement/reference lag and normal control transitions.

Description

This rule gives the canonical primary-airflow sensor an independent second opinion. Sustained disagreement is a sensor-integrity finding, but an ordinary host-derived reference can be wrong too, so the verdict remains ambiguous.

Detection Logic

reference_ok = primary_airflow_reference > minimum_reference_airflow
allowance    = primary_airflow_reference * max_disagreement_fraction
positive     = (primary_airflow - primary_airflow_reference) > allowance
negative     = (primary_airflow_reference - primary_airflow) > allowance
yPositiveBias = reference_ok AND positive
yNegativeBias = reference_ok AND negative
yReferenceOk  = reference_ok
yFault = reference_ok AND (positive OR negative), sustained for sustained_duration

FPB-0005 block graph

No Divide exists. One timer follows the direction OR, so a sampled sign reversal without reconvergence preserves age; any in-band sample resets it.

Possible Diagnoses

  1. Primary airflow sensor bias, scaling/K-factor, tubing, or pickup fault.
  2. Reference sensor/model bias, stale version, or out-of-domain extrapolation.
  3. Circular reference, different stream/location, time misalignment, or unit mismatch.
  4. Real transient the reference cannot reproduce.

Energy Impact

The disagreement itself uses no energy. Impact is whatever wrong airflow control or downstream diagnosis the erroneous member causes.

Emissions Impact

Scope 1/2 qualitative through downstream heating, cooling, and fan decisions.

Deviations

  • Ambiguous means no automatic victim. The graph’s input roles differ, but the reference contract is not inherently trusted enough to invalidate primary_airflow without deployment certification.
  • No Divide block is used. Positive-reference cross-multiplication is safe at zero and negative raw references.
  • All defaults require commissioning. The floor is adoption-blocking; 15% and 900 s are adopted.
  • FPB-0002 is not automatically suppressed. A biased measurement can explain tracking error, but an ambiguous reference cannot silently erase it.
  • No empirical FPR/TPR is claimed. LBNL replay is deferred to PR11.

Test Vectors

17 scenarios, clock step 60 s over 2100 s.

ScenarioDescription
exact_agreementMeasurement and independent reference agree.
sustained_positive_biasA 15.1% high measurement drives only the positive direction and matures outside the delay edge.
sustained_negative_biasA 15.1% low measurement drives only the negative direction.
positive_exact_thresholdExactly +15% is clear under strict Greater.
negative_exact_thresholdExactly -15% is clear under the mirrored strict comparison.
reference_below_minimum49.9 L/s is below the reference floor and all bias outputs are gated false.
reference_exactly_at_minimumExactly 50 L/s is NO_EVAL.
reference_just_above_minimum50.1 L/s is evaluable when measurement agrees.
zero_referenceZero reference cannot trigger Divide because the graph contains none; it is NO_EVAL.
negative_reference_raw_caseA negative reference remains numerically safe and non-evaluable.
violation_ends_one_step_before_persistenceDisagreement clears at 840 s, one step before persistence, and never matures.
mature_disagreement_recoversA mature bias alarm clears immediately when the measurement reconverges.
two_short_disagreements_resetSeparated short disagreements do not accumulate timer age.
direction_handoff_preserves_ageA direct positive-to-negative handoff stays continuously outside the band, so the shared timer preserves age while diagnostics swap.
time_alignment_step_transientReference moves two samples before measurement; the temporary disagreement is rejected by persistence, but alignment remains a host obligation.
circular_reference_blind_spotA reference calculated directly from the accused point agrees perfectly and hides any bias; the clear result pins why circular evidence is invalid.
unit_mismatch_raw_false_positiveNumerically mismatched units look like a permanent positive bias and alarm raw; equal units are host-enforced.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 2100
  },
  "scenarios": [
    {
      "name": "exact_agreement",
      "description": "Measurement and independent reference agree.",
      "inputs": {
        "primary_airflow": 100.0,
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "sustained_positive_bias",
      "description": "A 15.1% high measurement drives only the positive direction and matures outside the delay edge.",
      "inputs": {
        "primary_airflow": 115.1,
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2100,
          "equals": true
        }
      ]
    },
    {
      "name": "sustained_negative_bias",
      "description": "A 15.1% low measurement drives only the negative direction.",
      "inputs": {
        "primary_airflow": 84.9,
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "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": "positive_exact_threshold",
      "description": "Exactly +15% is clear under strict Greater.",
      "inputs": {
        "primary_airflow": 115.0,
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "negative_exact_threshold",
      "description": "Exactly -15% is clear under the mirrored strict comparison.",
      "inputs": {
        "primary_airflow": 85.0,
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "reference_below_minimum",
      "description": "49.9 L/s is below the reference floor and all bias outputs are gated false.",
      "inputs": {
        "primary_airflow": 100.0,
        "primary_airflow_reference": 49.9
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "reference_exactly_at_minimum",
      "description": "Exactly 50 L/s is NO_EVAL.",
      "inputs": {
        "primary_airflow": 100.0,
        "primary_airflow_reference": 50.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "reference_just_above_minimum",
      "description": "50.1 L/s is evaluable when measurement agrees.",
      "inputs": {
        "primary_airflow": 50.1,
        "primary_airflow_reference": 50.1
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "zero_reference",
      "description": "Zero reference cannot trigger Divide because the graph contains none; it is NO_EVAL.",
      "inputs": {
        "primary_airflow": 100.0,
        "primary_airflow_reference": 0.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "negative_reference_raw_case",
      "description": "A negative reference remains numerically safe and non-evaluable.",
      "inputs": {
        "primary_airflow": 100.0,
        "primary_airflow_reference": -10.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "violation_ends_one_step_before_persistence",
      "description": "Disagreement clears at 840 s, one step before persistence, and never matures.",
      "inputs": {
        "primary_airflow": [
          {
            "t": 0,
            "value": 120.0
          },
          {
            "t": 840,
            "value": 100.0
          }
        ],
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 780,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 840,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_disagreement_recovers",
      "description": "A mature bias alarm clears immediately when the measurement reconverges.",
      "inputs": {
        "primary_airflow": [
          {
            "t": 0,
            "value": 120.0
          },
          {
            "t": 1200,
            "value": 100.0
          }
        ],
        "primary_airflow_reference": 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": "yPositiveBias",
          "from_s": 0,
          "to_s": 1140,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 1200,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_disagreements_reset",
      "description": "Separated short disagreements do not accumulate timer age.",
      "inputs": {
        "primary_airflow": [
          {
            "t": 0,
            "value": 120.0
          },
          {
            "t": 360,
            "value": 100.0
          },
          {
            "t": 600,
            "value": 120.0
          },
          {
            "t": 960,
            "value": 100.0
          }
        ],
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "direction_handoff_preserves_age",
      "description": "A direct positive-to-negative handoff stays continuously outside the band, so the shared timer preserves age while diagnostics swap.",
      "inputs": {
        "primary_airflow": [
          {
            "t": 0,
            "value": 120.0
          },
          {
            "t": 480,
            "value": 80.0
          }
        ],
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 420,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 480,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 420,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 480,
          "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": "time_alignment_step_transient",
      "description": "Reference moves two samples before measurement; the temporary disagreement is rejected by persistence, but alignment remains a host obligation.",
      "inputs": {
        "primary_airflow_reference": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 300,
            "value": 140.0
          }
        ],
        "primary_airflow": [
          {
            "t": 0,
            "value": 100.0
          },
          {
            "t": 420,
            "value": 140.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "circular_reference_blind_spot",
      "description": "A reference calculated directly from the accused point agrees perfectly and hides any bias; the clear result pins why circular evidence is invalid.",
      "inputs": {
        "primary_airflow": 130.0,
        "primary_airflow_reference": 130.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        }
      ]
    },
    {
      "name": "unit_mismatch_raw_false_positive",
      "description": "Numerically mismatched units look like a permanent positive bias and alarm raw; equal units are host-enforced.",
      "inputs": {
        "primary_airflow": 211.9,
        "primary_airflow_reference": 100.0
      },
      "expect": [
        {
          "output": "yReferenceOk",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yPositiveBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": true
        },
        {
          "output": "yNegativeBias",
          "from_s": 0,
          "to_s": 2100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2100,
          "equals": true
        }
      ]
    }
  ]
}