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-0001 — Terminal fan proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:b7e84c2382c4a0204115bd7efa28032d, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedFPB-0002, FPB-0003, FPB-0004
Playbooksproof-of-operation, fan-powered-terminal-faults
SourceLibrary proof-of-operation precedents AHU-0039, TOWER-0004, PMP-0003, HW-0009, and FCU-0006 — final command versus independent status with separate direction timers; ASHRAE Guideline 36-2021 section 5.1.6 — equipment proof semantics only; no portable terminal-fan timer values; LBNL FDD simulated FPU dataset, DOI 10.25984/1881324 — documented PFPU/SFPU topologies and future validation source, not threshold evidence
Operating statesseries or parallel fan-powered terminal states in which the final terminal-fan command and same-fan independent proof are authoritative

Preconditions (host-enforced): Bind fan_cmd after subtype sequence ownership, fan delay, occupancy, low-flow/heating logic, and normal interlocks. Bind independent fan_status for the same fan; command echo is invalid. A series fan may run continuously while occupied, whereas a parallel fan may legitimately remain off in many modes. Exclude smoke/emergency, freeze/condensate protection, exercise, maintenance, local hand operation, and any intentional fan delay not represented in the final command. Inputs must be fresh and aligned; unmet obligations are NO_EVAL, not healthy.

Points: fan_cmd, fan_status

Outputs:

  • yFault — True while either terminal-fan command/proof mismatch has matured through its own timer
  • yFailToStart — Diagnostic direction flag; true after a final start command remains without independent proof for start_proof_time. False never means NO_EVAL
  • yUnexpectedRun — Diagnostic direction flag; true after independent operation continues without a final run command for stop_proof_time. False never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
start_proof_time60.0sstartProof.delayTimeADOPTED_TUNABLE final-command-to-proof allowance; commission above normal fan response, proof pickup, and telemetry latency.
stop_proof_time60.0sstopProof.delayTimeADOPTED_TUNABLE command-off-to-proof-dropout allowance; commission above coast-down, proof dropout, and telemetry latency.

Description

This rule checks whether the fan inside a series or parallel fan-powered terminal did what its final Boolean command requested. Direction identifies the observed mismatch, not its cause or the subtype’s expected operating schedule.

Detection Logic

fail_to_start  = fan_cmd AND NOT fan_status
unexpected_run = NOT fan_cmd AND fan_status
yFailToStart   = fail_to_start sustained for start_proof_time
yUnexpectedRun = unexpected_run sustained for stop_proof_time
yFault         = yFailToStart OR yUnexpectedRun

FPB-0001 block graph

Independent TrueDelay(delayOnInit=true) lanes prevent elapsed time from crossing a mismatch-direction reversal. Agreement clears both lanes immediately.

Possible Diagnoses

  1. Failed motor, ECM, contactor, controller output, breaker, or wiring.
  2. Seized wheel, failed bearing, belt, coupling, or fan relay.
  3. Bad current, airflow, pressure, speed, rotation, or auxiliary-contact proof.
  4. Local hand/thermostat ownership or a subtype-specific interlock omitted from command.
  5. Wrong terminal or upstream AHU fan bound to either canonical point.

Energy Impact

Unexpected operation can waste terminal-fan electricity and alter delivered heating or airflow. Fail-to-start is principally a comfort and availability finding; this Boolean pair cannot price its zone or plant effect.

Emissions Impact

Scope 2 proxy applies only to unexpected operation using measured fan kW, mismatch hours, and the applicable electricity factor.

Deviations

  • Both 60 s timers are adopted. No cited source establishes portable FPB proof windows.
  • Subtype schedule remains host-side. The same graph is valid for a continuously running series fan and an intermittently commanded parallel fan because it judges agreement, not when command should be on.
  • Final command and independent proof are mandatory. Upstream enable creates false positives; command echo creates a blind spot.
  • No whole-rule suppression is encoded. Fail-to-start can remove another rule’s premise while unexpected run can leave that rule physically meaningful.
  • No empirical FPR or TPR is claimed in this slice. The LBNL adapter and exact dataset-to-canonical-point mappings are deferred to PR11.

Test Vectors

25 scenarios, clock step 10 s over 2400 s.

ScenarioDescription
agreement_stoppedCommand and independent proof agree off; every direction remains clear.
agreement_runningCommand and independent proof agree on; every direction remains clear.
fail_to_start_at_initializationA standing commanded-on/unproved mismatch is pinned one tick before and one tick after the 60 s edge; the exact edge is intentionally unasserted.
unexpected_run_at_initializationA standing command-off/proven-on mismatch is pinned one tick before and one tick after the 60 s edge; the exact edge is intentionally unasserted.
normal_delayed_start_and_stopStatus proves one tick inside both configured allowances, so ordinary start and stop latency never alarms.
start_mismatch_ends_one_tick_before_proofA start mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.
start_mismatch_ends_at_proof_boundaryThe start mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted. The exact maturity tick is intentionally left unasserted; clear behavior is pinned on both sides.
stop_mismatch_ends_one_tick_before_proofA stop mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.
stop_mismatch_ends_at_proof_boundaryThe stop mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted. The exact maturity tick is intentionally left unasserted; clear behavior is pinned on both sides.
mature_start_alarm_recoversA fail-to-start persists past its proof time, then clears immediately when independent proof arrives.
mature_stop_alarm_recoversAn unexpected run persists past its proof time, then clears immediately when proof drops.
pending_direction_reversal_starts_fresh_laneCommand and status swap while fail-to-start is pending. The old lane resets and the unexpected-run lane must serve its complete independent timer.
mature_direction_reversal_hands_offA mature fail-to-start reverses directly into unexpected-run. The old flag clears on the reversal tick, yFault has a real gap, and the opposite lane serves a fresh complete stop timer.
two_short_start_mismatches_do_not_accumulateTwo commanded-on/unproved episodes are separated by agreement; elapsed time cannot transfer across the reset.
two_short_stop_mismatches_do_not_accumulateTwo proven-on/uncommanded episodes are separated by agreement; elapsed time cannot transfer across the reset.
command_chatter_never_maturesThe final command chatters faster than either proof window while status is off; no continuous mismatch matures.
status_chatter_never_maturesIndependent proof chatters against a held-on command; every unproved interval clears before the start timer.
unexpected_run_chatter_never_maturesIndependent proof chatters on against a held-off command; every unexpected-run interval clears before the stop timer.
upstream_enable_raw_false_positiveAn upstream enable held true while downstream logic correctly withholds operation produces a raw fail-to-start alarm; this pins why only the final command is a valid binding.
command_echo_raw_blind_spotA status echo follows the command while the device is mechanically stopped. The pair agrees and the graph stays silent, pinning why proof must be independent.
manual_local_run_is_host_excludedA locally commanded device is proven on while the BAS final command is off. The raw unexpected-run alarm is correct; approved service or hand operation is host NO_EVAL.
wrong_speed_stage_is_boolean_blind_spotAn ECM is commanded to high speed but independent Boolean proof reports only that it runs at some speed. Both booleans agree, so a failed requested stage is outside this operation-only rule.
ordinary_vav_without_terminal_fan_is_not_applicableAn ordinary VAV has no honest terminal-fan command/proof pair. Synthetic disagreement alarms raw, demonstrating why the host must not instantiate the FPB family there.
series_terminal_continuous_occupied_fanA series fan-powered terminal may command and prove its fan continuously while occupied; command/proof agreement is healthy.
parallel_terminal_idle_fan_offA parallel terminal may legitimately leave its fan off outside heating or low-primary-flow operation; agreement off is healthy.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 10,
    "horizon_s": 2400
  },
  "scenarios": [
    {
      "name": "agreement_stopped",
      "description": "Command and independent proof agree off; every direction remains clear.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "agreement_running",
      "description": "Command and independent proof agree on; every direction remains clear.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "fail_to_start_at_initialization",
      "description": "A standing commanded-on/unproved mismatch is pinned one tick before and one tick after the 60 s edge; the exact edge is intentionally unasserted.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "unexpected_run_at_initialization",
      "description": "A standing command-off/proven-on mismatch is pinned one tick before and one tick after the 60 s edge; the exact edge is intentionally unasserted.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "normal_delayed_start_and_stop",
      "description": "Status proves one tick inside both configured allowances, so ordinary start and stop latency never alarms.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 350,
            "value": true
          },
          {
            "t": 1250,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "start_mismatch_ends_one_tick_before_proof",
      "description": "A start mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 350,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "start_mismatch_ends_at_proof_boundary",
      "description": "The start mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted. The exact maturity tick is intentionally left unasserted; clear behavior is pinned on both sides.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 360,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 370,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 370,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 370,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "stop_mismatch_ends_one_tick_before_proof",
      "description": "A stop mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 350,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "stop_mismatch_ends_at_proof_boundary",
      "description": "The stop mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted. The exact maturity tick is intentionally left unasserted; clear behavior is pinned on both sides.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 360,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 370,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 370,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 370,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_start_alarm_recovers",
      "description": "A fail-to-start persists past its proof time, then clears immediately when independent proof arrives.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 460,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 370,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 370,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "mature_stop_alarm_recovers",
      "description": "An unexpected run persists past its proof time, then clears immediately when proof drops.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 460,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 370,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 370,
          "to_s": 450,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 460,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "pending_direction_reversal_starts_fresh_lane",
      "description": "Command and status swap while fail-to-start is pending. The old lane resets and the unexpected-run lane must serve its complete independent timer.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 40,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 40,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 110,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 110,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "mature_direction_reversal_hands_off",
      "description": "A mature fail-to-start reverses directly into unexpected-run. The old flag clears on the reversal tick, yFault has a real gap, and the opposite lane serves a fresh complete stop timer.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 400,
            "value": false
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 70,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 400,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 450,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 470,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 70,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 400,
          "to_s": 450,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 470,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "two_short_start_mismatches_do_not_accumulate",
      "description": "Two commanded-on/unproved episodes are separated by agreement; elapsed time cannot transfer across the reset.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ],
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "two_short_stop_mismatches_do_not_accumulate",
      "description": "Two proven-on/uncommanded episodes are separated by agreement; elapsed time cannot transfer across the reset.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "command_chatter_never_matures",
      "description": "The final command chatters faster than either proof window while status is off; no continuous mismatch matures.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 640,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ],
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "status_chatter_never_matures",
      "description": "Independent proof chatters against a held-on command; every unproved interval clears before the start timer.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          },
          {
            "t": 340,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 640,
            "value": true
          },
          {
            "t": 900,
            "value": false
          },
          {
            "t": 940,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "unexpected_run_chatter_never_matures",
      "description": "Independent proof chatters on against a held-off command; every unexpected-run interval clears before the stop timer.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 640,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "upstream_enable_raw_false_positive",
      "description": "An upstream enable held true while downstream logic correctly withholds operation produces a raw fail-to-start alarm; this pins why only the final command is a valid binding.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "command_echo_raw_blind_spot",
      "description": "A status echo follows the command while the device is mechanically stopped. The pair agrees and the graph stays silent, pinning why proof must be independent.",
      "inputs": {
        "fan_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "fan_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "manual_local_run_is_host_excluded",
      "description": "A locally commanded device is proven on while the BAS final command is off. The raw unexpected-run alarm is correct; approved service or hand operation is host NO_EVAL.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "wrong_speed_stage_is_boolean_blind_spot",
      "description": "An ECM is commanded to high speed but independent Boolean proof reports only that it runs at some speed. Both booleans agree, so a failed requested stage is outside this operation-only rule.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "ordinary_vav_without_terminal_fan_is_not_applicable",
      "description": "An ordinary VAV has no honest terminal-fan command/proof pair. Synthetic disagreement alarms raw, demonstrating why the host must not instantiate the FPB family there.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 70,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "series_terminal_continuous_occupied_fan",
      "description": "A series fan-powered terminal may command and prove its fan continuously while occupied; command/proof agreement is healthy.",
      "inputs": {
        "fan_cmd": true,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "parallel_terminal_idle_fan_off",
      "description": "A parallel terminal may legitimately leave its fan off outside heating or low-primary-flow operation; agreement off is healthy.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        }
      ]
    }
  ]
}