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

FCU-0006 — FCU fan proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:8c4af7be56126b213921f77982c3f3e7, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedFCU-0001, FCU-0002, FCU-0003, FCU-0004, FCU-0005
Playbooksproof-of-operation, fcu-faults
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 FCU fan timing adaptation; no cited source publishes 60 s start and 60 s stop proof limits as portable values
Operating statesall states in which this FCU has an actively controlled fan and its final individual fan command and independent run proof are authoritative

Preconditions (host-enforced): The FCU must have an actively commanded fan; passive/convection units are not applicable. Bind fan_cmd to the final fan output after occupancy, mode, condensate, freeze, and local interlock logic, not to unit enable or thermostat demand. Bind fan_status to independent motor-current, airflow, speed, rotation, or auxiliary-contact proof for the same fan, never to a command echo. Exclude fan coast-down, exercise, maintenance, condensate-alarm shutdown, freeze protection, and local hand operation unless represented in the final command. For ECM and multi-speed fans, Boolean normalization proves only operation and must not hide a failed requested speed stage. Inputs must be fresh and aligned; unmet obligations are NO_EVAL, not healthy.

Points: fan_cmd, fan_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_time60.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 FCU 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  = 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

FCU-0006 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. Failed motor, ECM, controller, breaker, contactor, or output wiring.
  2. Seized fan wheel, failed belt, coupling, or bearing.
  3. Condensate, freeze, or local safety interlock omitted from command.
  4. Bad current, airflow, speed, rotation, or auxiliary-contact proof.
  5. Local thermostat, hand switch, or independent controller owns the fan.

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 FCU 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.
  • Passive fan coils are explicitly not applicable. A host must not synthesize a command/status pair for a unit that moves air only by convection.
  • The Boolean pair proves fan operation, not the requested ECM or multi-speed stage. A wrong-stage failure needs speed or stage feedback and is outside this graph.

Notes

Read the direction before interpreting FCU-0002 through FCU-0005: fail-to-start removes their airflow premise, while unexpected operation can leave their temperature signatures physically meaningful.

Test Vectors

23 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 60 s, asserts exactly at 60 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 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.
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.
passive_unit_is_not_applicableA passive fan coil has no honest final fan command or proof. Synthetic disagreement would alarm raw, demonstrating why the host must not instantiate this rule on convection-only units.
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 clear one tick before 60 s, asserts exactly at 60 s, and remains true after the initialization-safe start timer.",
      "inputs": {
        "fan_cmd": true,
        "fan_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 60 s, asserts exactly at 60 s, and remains true after the initialization-safe stop timer.",
      "inputs": {
        "fan_cmd": false,
        "fan_status": true
      },
      "expect": [
        {
          "output": "yUnexpectedRun",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yUnexpectedRun",
          "from_s": 60,
          "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": 60,
          "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.",
      "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": 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 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.",
      "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": 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": {
        "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": 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": {
        "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": 360,
          "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": 360,
          "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": 100,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 90,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 100,
          "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": 60,
          "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": 460,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 60,
          "to_s": 390,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 400,
          "to_s": 450,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 460,
          "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": 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": {
        "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": 60,
          "to_s": 2400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 50,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 60,
          "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": "passive_unit_is_not_applicable",
      "description": "A passive fan coil has no honest final fan command or proof. Synthetic disagreement would alarm raw, demonstrating why the host must not instantiate this rule on convection-only units.",
      "inputs": {
        "fan_cmd": true,
        "fan_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
        }
      ]
    }
  ]
}