RTU-0010 — RTU supply-fan proof-of-operation failure
| Status | verified — engine e2ff2f8, cxf:fnv1a128:9ce62aa58b24bd636ac88bdc9f0b6504, 2026-08-20 |
| Severity | 2 |
| Method | rule |
| Phase | 2 |
| Category | PROTECTIVE |
| Confidence | HIGH |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | RTU-0001, RTU-0002, RTU-0003, RTU-0004, RTU-0005, RTU-0006, RTU-0008, RTU-0009, RTU-0011 |
| Playbooks | proof-of-operation |
| Source | Library 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 states | all 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.
Outputs:
yFault— True while either final command/status mismatch has matured through its own proof timeryFailToStart— Diagnostic direction flag; true after a final start command remains without independent run proof for start_proof_time. False never means NO_EVALyUnexpectedRun— Diagnostic direction flag; true after independent operation continues without a final run command for stop_proof_time. False never means NO_EVAL
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
start_proof_time | 60.0 | s | startProof.delayTime | ADOPTED_TUNABLE final-command-to-proof allowance. Commission above normal device response, acceleration, proof pickup, and telemetry latency. |
stop_proof_time | 120.0 | s | stopProof.delayTime | ADOPTED_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
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
- Motor, contactor, belt, fan wheel, VFD, overload, or disconnect failure.
- Smoke, freeze, high-static, heat-exchanger, or OEM safety interlock.
- Failed or misconfigured current, airflow, speed, or auxiliary proof.
- Post-heat fan delay or purge omitted from the final command binding.
- 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=trueis 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.
| Scenario | Description |
|---|---|
agreement_stopped | Command and independent proof agree off; every direction remains clear. |
agreement_running | Command and independent proof agree on; every direction remains clear. |
fail_to_start_at_initialization | 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. |
unexpected_run_at_initialization | 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. |
normal_delayed_start_and_stop | Status proves one tick inside both configured allowances, so ordinary start and stop latency never alarms. |
start_mismatch_ends_one_tick_before_proof | A 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_boundary | The 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_proof | A 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_boundary | The stop mismatch clears exactly 120 s after it begins. Agreement on the maturity tick wins and no alarm is asserted. |
mature_start_alarm_recovers | A fail-to-start persists past its proof time, then clears immediately when independent proof arrives. |
mature_stop_alarm_recovers | An unexpected run persists past its proof time, then clears immediately when proof drops. |
pending_direction_reversal_starts_fresh_lane | 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. |
mature_direction_reversal_hands_off | 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. |
two_short_start_mismatches_do_not_accumulate | Two commanded-on/unproved episodes are separated by agreement; elapsed time cannot transfer across the reset. |
two_short_stop_mismatches_do_not_accumulate | Two proven-on/uncommanded episodes are separated by agreement; elapsed time cannot transfer across the reset. |
command_chatter_never_matures | The final command chatters faster than either proof window while status is off; no continuous mismatch matures. |
status_chatter_never_matures | Independent proof chatters against a held-on command; every unproved interval clears before the start timer. |
unexpected_run_chatter_never_matures | Independent proof chatters on against a held-off command; every unexpected-run interval clears before the stop timer. |
upstream_enable_raw_false_positive | 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. |
command_echo_raw_blind_spot | 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. |
manual_local_run_is_host_excluded | 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. |
occupied_continuous_fan_transition | 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. |
valid_ninety_second_post_heat_run | 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. |
proof_dropout_lingers_ninety_seconds | 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. |
proof_remains_on_beyond_stop_timer | 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. |
purge_raw_behavior_is_host_excluded | 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. |
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
}
]
}
]
}