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

RTU-0010 — RTU supply-fan proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:9ce62aa58b24bd636ac88bdc9f0b6504, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedRTU-0001, RTU-0002, RTU-0003, RTU-0004, RTU-0005, RTU-0006, RTU-0008, RTU-0009, RTU-0011
Playbooksproof-of-operation
SourceLibrary proof-of-operation precedents AHU-0039, TOWER-0004, PMP-0003, and HW-0009 — final command versus independent status, separate direction timers, and initialization-safe persistence; ASHRAE Guideline 36-2021 section 5.1.6 — equipment is proven when its digital status matches the state set by its digital command; semantic grounding only, not a source for these shipped timer values; Library-authored RTU supply fan timing adaptation; no cited source publishes 60 s start and 120 s stop proof limits as portable values
Operating statesall states in which the final RTU supply-fan command and independent proof for that same fan are authoritative

Preconditions (host-enforced): Bind sf_cmd to the final supply-fan command after smoke, freeze, heat-exchanger, post-heat fan-delay, and safety logic; unit enable is valid only when it demonstrably is that final command. A commanded post-heat run must keep sf_cmd true. Bind sf_status to independent electrical, airflow, speed, rotation, or auxiliary-contact proof for the same fan. Exclude purge, smoke control, ventilation override, service, local hand mode, and any fan-delay state omitted from sf_cmd. The 120 s stop timer must exceed normal mechanical coast and proof dropout, not conceal an upstream command. Inputs must be fresh and aligned; unmet obligations are NO_EVAL, not healthy.

Points: sf_cmd, sf_status

Outputs:

  • yFault — True while either final command/status mismatch has matured through its own proof timer
  • yFailToStart — Diagnostic direction flag; true after a final start command remains without independent run 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 device response, acceleration, proof pickup, and telemetry latency.
stop_proof_time120.0sstopProof.delayTimeADOPTED_TUNABLE command-off-to-proof-dropout allowance. Commission above normal deceleration, coast-down, proof dropout, and telemetry latency; intentional sequence operation must remain represented in the final command.

Description

This rule checks whether the RTU supply fan did what its final Boolean command requested. Commanded on without independent proof is a fail-to-start; proven on without command is unexpected operation. The direction identifies the mismatch, not its cause, and neither diagnostic output is an evaluability gate.

Detection Logic

fail_to_start  = sf_cmd AND NOT sf_status
unexpected_run = NOT sf_cmd AND sf_status

yFailToStart   = fail_to_start sustained for start_proof_time
yUnexpectedRun = unexpected_run sustained for stop_proof_time
yFault         = yFailToStart OR yUnexpectedRun

RTU-0010 block graph

Each direction has its own TrueDelay(delayOnInit=true). Agreement clears both lanes immediately. A direct mismatch reversal clears the old diagnostic and starts the other timer from zero; elapsed time never transfers between lanes.

Possible Diagnoses

  1. Motor, contactor, belt, fan wheel, VFD, overload, or disconnect failure.
  2. Smoke, freeze, high-static, heat-exchanger, or OEM safety interlock.
  3. Failed or misconfigured current, airflow, speed, or auxiliary proof.
  4. Post-heat fan delay or purge omitted from the final command binding.
  5. Unauthorized local/manual operation, welded contactor, or second owner.

Energy Impact

The effect is direction-dependent. Unexpected operation can waste measured electrical energy during the mismatch. Fail-to-start is primarily availability, comfort, and diagnostic-coverage loss; these two booleans cannot price it.

Emissions Impact

Scope 2 is proxy-only for unexpected operation: multiply independently measured device kW by mismatch hours and an appropriate operating emissions factor. Do not claim avoided energy or emissions for fail-to-start without another model.

Deviations

  • Both timers are adopted commissioning values. No cited source establishes universal RTU supply fan proof windows. Configure them independently around the actual sequence, proof device, sampling, and network latency.
  • The command is final and device-scoped. An upstream enable, demand, or fleet request can disagree with status while downstream logic works correctly.
  • Status is independent proof. Command echo makes the graph tautological; proof type determines whether electrical operation, rotation, or delivery was actually demonstrated.
  • No whole-rule suppression is encoded. Fail-to-start can invalidate another rule’s running premise, but unexpected operation may leave that rule physically meaningful; current metadata cannot suppress by direction.
  • delayOnInit=true is explicit on both lanes. Evaluator restart into an existing mismatch must serve the full configured proof time.
  • No empirical FPR or TPR is claimed. Current simulation telemetry cannot provide both an independent final command and field-like proof for this device.
  • The stop allowance is 120 s, twice the 60 s start allowance, to accommodate mechanical coast and proof dropout. A controlled post-heat run must keep the final sf_cmd true; the timer does not legalize an upstream binding. Both values remain site-tuned placeholders, not portable source values.
  • A purge or smoke-control run is raw unexpected operation unless it is included in sf_cmd. The host excludes that state; the graph does not encode an upstream mode gate.

Notes

Fail-to-start contests the running or airflow premise of RTU-0001 through RTU-0006 and can distort RTU-0008/0009 refrigerant evidence. Unexpected run does not invalidate those rules automatically, so the relationship remains informational rather than a whole-rule suppression.

Test Vectors

26 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 clear one tick before 60 s, asserts exactly at 60 s, and remains true after the initialization-safe start timer.
unexpected_run_at_initializationA standing command-off/proven-on mismatch is clear one tick before 120 s, asserts exactly at 120 s, and remains true after the initialization-safe stop timer.
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.
stop_mismatch_ends_one_tick_before_proofA stop mismatch beginning at 300 s clears at 410 s, one 10 s evaluator step before maturity, and never alarms.
stop_mismatch_ends_at_proof_boundaryThe stop mismatch clears exactly 120 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.
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.
occupied_continuous_fan_transitionA continuously commanded and proven fan remains in agreement across an occupancy transition; schedule state is intentionally not an input to this final-command proof rule.
valid_ninety_second_post_heat_runA true final command remains on through the 90 s post-heat run; command and proof drop together at 390 s, so the sequence stays in agreement.
proof_dropout_lingers_ninety_secondsThe final command drops at 300 s and electrical/airflow proof lingers for 90 s during mechanical coast or sensor dropout. The 120 s stop allowance accepts it without treating an active post-heat sequence as uncommanded.
proof_remains_on_beyond_stop_timerFan proof remains on beyond the 120 s coast/dropout allowance after final command-off: unexpected-run asserts exactly at 420 s and clears when proof drops.
purge_raw_behavior_is_host_excludedA purge or smoke-control owner runs the fan while an upstream BAS command stays off. The raw unexpected-run alarm pins the required final-command or host-gate obligation.
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": {
        "sf_cmd": false,
        "sf_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": {
        "sf_cmd": true,
        "sf_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 clear one tick before 60 s, asserts exactly at 60 s, and remains true after the initialization-safe start timer.",
      "inputs": {
        "sf_cmd": true,
        "sf_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 60,
          "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": 60,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "unexpected_run_at_initialization",
      "description": "A standing command-off/proven-on mismatch is clear one tick before 120 s, asserts exactly at 120 s, and remains true after the initialization-safe stop timer.",
      "inputs": {
        "sf_cmd": false,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 120,
          "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": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 1200,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 350,
            "value": true
          },
          {
            "t": 1310,
            "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": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "sf_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.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 360,
            "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": "stop_mismatch_ends_one_tick_before_proof",
      "description": "A stop mismatch beginning at 300 s clears at 410 s, one 10 s evaluator step before maturity, and never alarms.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 410,
            "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 120 s after it begins. Agreement on the maturity tick wins and no alarm is asserted.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 420,
            "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": "mature_start_alarm_recovers",
      "description": "A fail-to-start persists past its proof time, then clears immediately when independent proof arrives.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 460,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 350,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 360,
          "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": 360,
          "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": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 520,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 410,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 420,
          "to_s": 510,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 520,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 410,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 420,
          "to_s": 510,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 520,
          "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": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 40,
            "value": false
          }
        ],
        "sf_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": 150,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 160,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 150,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 160,
          "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": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 400,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 60,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFailToStart",
          "from_s": 400,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 510,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 520,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 400,
          "to_s": 510,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 520,
          "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": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 340,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 940,
            "value": false
          }
        ],
        "sf_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": {
        "sf_cmd": false,
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 400,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 1000,
            "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": {
        "sf_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
          }
        ],
        "sf_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": {
        "sf_cmd": true,
        "sf_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": {
        "sf_cmd": false,
        "sf_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 400,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 700,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 1000,
            "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": {
        "sf_cmd": true,
        "sf_status": false
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 60,
          "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": 60,
          "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": {
        "sf_cmd": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          }
        ],
        "sf_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": {
        "sf_cmd": false,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        }
      ]
    },
    {
      "name": "occupied_continuous_fan_transition",
      "description": "A continuously commanded and proven fan remains in agreement across an occupancy transition; schedule state is intentionally not an input to this final-command proof rule.",
      "inputs": {
        "sf_cmd": true,
        "sf_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": "valid_ninety_second_post_heat_run",
      "description": "A true final command remains on through the 90 s post-heat run; command and proof drop together at 390 s, so the sequence stays in agreement.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 390,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 390,
            "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": "proof_dropout_lingers_ninety_seconds",
      "description": "The final command drops at 300 s and electrical/airflow proof lingers for 90 s during mechanical coast or sensor dropout. The 120 s stop allowance accepts it without treating an active post-heat sequence as uncommanded.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 390,
            "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": "proof_remains_on_beyond_stop_timer",
      "description": "Fan proof remains on beyond the 120 s coast/dropout allowance after final command-off: unexpected-run asserts exactly at 420 s and clears when proof drops.",
      "inputs": {
        "sf_cmd": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 300,
            "value": false
          }
        ],
        "sf_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 410,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 420,
          "to_s": 590,
          "equals": true
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 600,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 410,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 420,
          "to_s": 590,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 600,
          "to_s": 2400,
          "equals": false
        }
      ]
    },
    {
      "name": "purge_raw_behavior_is_host_excluded",
      "description": "A purge or smoke-control owner runs the fan while an upstream BAS command stays off. The raw unexpected-run alarm pins the required final-command or host-gate obligation.",
      "inputs": {
        "sf_cmd": false,
        "sf_status": true
      },
      "expect": [
        {
          "output": "yFailToStart",
          "from_s": 0,
          "to_s": 2400,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 110,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 120,
          "to_s": 2400,
          "equals": true
        }
      ]
    }
  ]
}