FPB-0005 — Primary airflow sensor disagreement
| Status | verified — engine e2ff2f8, cxf:fnv1a128:dea3bb3d4caf1dc61fdbb108eaaa20e3, 2026-08-20 |
| Severity | 3 |
| Method | meta |
| Phase | 3 |
| Category | PROTECTIVE |
| Confidence | MEDIUM |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | FPB-0002, FPB-0004 |
| Playbooks | fan-powered-terminal-faults, sensor-drift |
| Source | LBNL 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 states | steady 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 wrongyReferenceOk— Evaluability flag — true only when primary_airflow_reference is strictly above minimum_reference_airflow. FALSE MEANS NO_EVALyPositiveBias— Diagnostic direction flag; true when the measurement is above the valid reference by more than the allowed fraction. False never means NO_EVALyNegativeBias— Diagnostic direction flag; true when the measurement is below the valid reference by more than the allowed fraction. False never means NO_EVAL
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
minimum_reference_airflow | 50.0 | L/s | referenceOk.t | NO_PORTABLE_DEFAULT and adoption-blocking reference-flow floor; equality is NO_EVAL. |
max_disagreement_fraction | 0.15 | 1 | allowance.k | ADOPTED_TUNABLE allowed absolute residual as a fraction of positive reference flow; both comparisons are strict. |
sustained_duration | 900.0 | s | persist.delayTime | ADOPTED_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
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
- Primary airflow sensor bias, scaling/K-factor, tubing, or pickup fault.
- Reference sensor/model bias, stale version, or out-of-domain extrapolation.
- Circular reference, different stream/location, time misalignment, or unit mismatch.
- 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.
| Scenario | Description |
|---|---|
exact_agreement | Measurement and independent reference agree. |
sustained_positive_bias | A 15.1% high measurement drives only the positive direction and matures outside the delay edge. |
sustained_negative_bias | A 15.1% low measurement drives only the negative direction. |
positive_exact_threshold | Exactly +15% is clear under strict Greater. |
negative_exact_threshold | Exactly -15% is clear under the mirrored strict comparison. |
reference_below_minimum | 49.9 L/s is below the reference floor and all bias outputs are gated false. |
reference_exactly_at_minimum | Exactly 50 L/s is NO_EVAL. |
reference_just_above_minimum | 50.1 L/s is evaluable when measurement agrees. |
zero_reference | Zero reference cannot trigger Divide because the graph contains none; it is NO_EVAL. |
negative_reference_raw_case | A negative reference remains numerically safe and non-evaluable. |
violation_ends_one_step_before_persistence | Disagreement clears at 840 s, one step before persistence, and never matures. |
mature_disagreement_recovers | A mature bias alarm clears immediately when the measurement reconverges. |
two_short_disagreements_reset | Separated short disagreements do not accumulate timer age. |
direction_handoff_preserves_age | A direct positive-to-negative handoff stays continuously outside the band, so the shared timer preserves age while diagnostics swap. |
time_alignment_step_transient | Reference moves two samples before measurement; the temporary disagreement is rejected by persistence, but alignment remains a host obligation. |
circular_reference_blind_spot | A 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_positive | Numerically 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
}
]
}
]
}