FPB-0001 — Terminal fan proof-of-operation failure
| Status | verified — engine e2ff2f8, cxf:fnv1a128:b7e84c2382c4a0204115bd7efa28032d, 2026-08-20 |
| Severity | 2 |
| Method | rule |
| Phase | 2 |
| Category | PROTECTIVE |
| Confidence | HIGH |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | FPB-0002, FPB-0003, FPB-0004 |
| Playbooks | proof-of-operation, fan-powered-terminal-faults |
| Source | Library proof-of-operation precedents AHU-0039, TOWER-0004, PMP-0003, HW-0009, and FCU-0006 — final command versus independent status with separate direction timers; ASHRAE Guideline 36-2021 section 5.1.6 — equipment proof semantics only; no portable terminal-fan timer values; LBNL FDD simulated FPU dataset, DOI 10.25984/1881324 — documented PFPU/SFPU topologies and future validation source, not threshold evidence |
| Operating states | series or parallel fan-powered terminal states in which the final terminal-fan command and same-fan independent proof are authoritative |
Preconditions (host-enforced): Bind fan_cmd after subtype sequence ownership, fan delay, occupancy, low-flow/heating logic, and normal interlocks. Bind independent fan_status for the same fan; command echo is invalid. A series fan may run continuously while occupied, whereas a parallel fan may legitimately remain off in many modes. Exclude smoke/emergency, freeze/condensate protection, exercise, maintenance, local hand operation, and any intentional fan delay not represented in the final command. Inputs must be fresh and aligned; unmet obligations are NO_EVAL, not healthy.
Points: fan_cmd, fan_status
Outputs:
yFault— True while either terminal-fan command/proof mismatch has matured through its own timeryFailToStart— Diagnostic direction flag; true after a final start command remains without independent 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 fan response, proof pickup, and telemetry latency. |
stop_proof_time | 60.0 | s | stopProof.delayTime | ADOPTED_TUNABLE command-off-to-proof-dropout allowance; commission above coast-down, proof dropout, and telemetry latency. |
Description
This rule checks whether the fan inside a series or parallel fan-powered terminal did what its final Boolean command requested. Direction identifies the observed mismatch, not its cause or the subtype’s expected operating schedule.
Detection Logic
fail_to_start = fan_cmd AND NOT fan_status
unexpected_run = NOT fan_cmd AND fan_status
yFailToStart = fail_to_start sustained for start_proof_time
yUnexpectedRun = unexpected_run sustained for stop_proof_time
yFault = yFailToStart OR yUnexpectedRun
Independent TrueDelay(delayOnInit=true) lanes prevent elapsed time from
crossing a mismatch-direction reversal. Agreement clears both lanes immediately.
Possible Diagnoses
- Failed motor, ECM, contactor, controller output, breaker, or wiring.
- Seized wheel, failed bearing, belt, coupling, or fan relay.
- Bad current, airflow, pressure, speed, rotation, or auxiliary-contact proof.
- Local hand/thermostat ownership or a subtype-specific interlock omitted from command.
- Wrong terminal or upstream AHU fan bound to either canonical point.
Energy Impact
Unexpected operation can waste terminal-fan electricity and alter delivered heating or airflow. Fail-to-start is principally a comfort and availability finding; this Boolean pair cannot price its zone or plant effect.
Emissions Impact
Scope 2 proxy applies only to unexpected operation using measured fan kW, mismatch hours, and the applicable electricity factor.
Deviations
- Both 60 s timers are adopted. No cited source establishes portable FPB proof windows.
- Subtype schedule remains host-side. The same graph is valid for a continuously running series fan and an intermittently commanded parallel fan because it judges agreement, not when command should be on.
- Final command and independent proof are mandatory. Upstream enable creates false positives; command echo creates a blind spot.
- No whole-rule suppression is encoded. Fail-to-start can remove another rule’s premise while unexpected run can leave that rule physically meaningful.
- No empirical FPR or TPR is claimed in this slice. The LBNL adapter and exact dataset-to-canonical-point mappings are deferred to PR11.
Test Vectors
25 scenarios, clock step 10 s over 2400 s.
| 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 pinned one tick before and one tick after the 60 s edge; the exact edge is intentionally unasserted. |
unexpected_run_at_initialization | A standing command-off/proven-on mismatch is pinned one tick before and one tick after the 60 s edge; the exact edge is intentionally unasserted. |
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. The exact maturity tick is intentionally left unasserted; clear behavior is pinned on both sides. |
stop_mismatch_ends_one_tick_before_proof | A 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_boundary | The stop mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted. The exact maturity tick is intentionally left unasserted; clear behavior is pinned on both sides. |
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. |
wrong_speed_stage_is_boolean_blind_spot | 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. |
ordinary_vav_without_terminal_fan_is_not_applicable | An ordinary VAV has no honest terminal-fan command/proof pair. Synthetic disagreement alarms raw, demonstrating why the host must not instantiate the FPB family there. |
series_terminal_continuous_occupied_fan | A series fan-powered terminal may command and prove its fan continuously while occupied; command/proof agreement is healthy. |
parallel_terminal_idle_fan_off | A parallel terminal may legitimately leave its fan off outside heating or low-primary-flow operation; agreement off is healthy. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 10,
"horizon_s": 2400
},
"scenarios": [
{
"name": "agreement_stopped",
"description": "Command and independent proof agree off; every direction remains clear.",
"inputs": {
"fan_cmd": false,
"fan_status": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "agreement_running",
"description": "Command and independent proof agree on; every direction remains clear.",
"inputs": {
"fan_cmd": true,
"fan_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "fail_to_start_at_initialization",
"description": "A standing commanded-on/unproved mismatch is pinned one tick before and one tick after the 60 s edge; the exact edge is intentionally unasserted.",
"inputs": {
"fan_cmd": true,
"fan_status": false
},
"expect": [
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 50,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 70,
"to_s": 2400,
"equals": true
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 50,
"equals": false
},
{
"output": "yFault",
"from_s": 70,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "unexpected_run_at_initialization",
"description": "A standing command-off/proven-on mismatch is pinned one tick before and one tick after the 60 s edge; the exact edge is intentionally unasserted.",
"inputs": {
"fan_cmd": false,
"fan_status": true
},
"expect": [
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 50,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 70,
"to_s": 2400,
"equals": true
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 50,
"equals": false
},
{
"output": "yFault",
"from_s": 70,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "normal_delayed_start_and_stop",
"description": "Status proves one tick inside both configured allowances, so ordinary start and stop latency never alarms.",
"inputs": {
"fan_cmd": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
},
{
"t": 1200,
"value": false
}
],
"fan_status": [
{
"t": 0,
"value": false
},
{
"t": 350,
"value": true
},
{
"t": 1250,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "start_mismatch_ends_one_tick_before_proof",
"description": "A start mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.",
"inputs": {
"fan_cmd": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
}
],
"fan_status": [
{
"t": 0,
"value": false
},
{
"t": 350,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "start_mismatch_ends_at_proof_boundary",
"description": "The start mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted. The exact maturity tick is intentionally left unasserted; clear behavior is pinned on both sides.",
"inputs": {
"fan_cmd": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
}
],
"fan_status": [
{
"t": 0,
"value": false
},
{
"t": 360,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 350,
"equals": false
},
{
"output": "yFault",
"from_s": 370,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 350,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 370,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 350,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 370,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "stop_mismatch_ends_one_tick_before_proof",
"description": "A stop mismatch beginning at 300 s clears at 350 s, one 10 s evaluator step before maturity, and never alarms.",
"inputs": {
"fan_cmd": [
{
"t": 0,
"value": true
},
{
"t": 300,
"value": false
}
],
"fan_status": [
{
"t": 0,
"value": true
},
{
"t": 350,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "stop_mismatch_ends_at_proof_boundary",
"description": "The stop mismatch clears exactly 60 s after it begins. Agreement on the maturity tick wins and no alarm is asserted. The exact maturity tick is intentionally left unasserted; clear behavior is pinned on both sides.",
"inputs": {
"fan_cmd": [
{
"t": 0,
"value": true
},
{
"t": 300,
"value": false
}
],
"fan_status": [
{
"t": 0,
"value": true
},
{
"t": 360,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 350,
"equals": false
},
{
"output": "yFault",
"from_s": 370,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 350,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 370,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 350,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 370,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "mature_start_alarm_recovers",
"description": "A fail-to-start persists past its proof time, then clears immediately when independent proof arrives.",
"inputs": {
"fan_cmd": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
}
],
"fan_status": [
{
"t": 0,
"value": false
},
{
"t": 460,
"value": true
}
]
},
"expect": [
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 350,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 370,
"to_s": 450,
"equals": true
},
{
"output": "yFailToStart",
"from_s": 460,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 350,
"equals": false
},
{
"output": "yFault",
"from_s": 370,
"to_s": 450,
"equals": true
},
{
"output": "yFault",
"from_s": 460,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "mature_stop_alarm_recovers",
"description": "An unexpected run persists past its proof time, then clears immediately when proof drops.",
"inputs": {
"fan_cmd": [
{
"t": 0,
"value": true
},
{
"t": 300,
"value": false
}
],
"fan_status": [
{
"t": 0,
"value": true
},
{
"t": 460,
"value": false
}
]
},
"expect": [
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 350,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 370,
"to_s": 450,
"equals": true
},
{
"output": "yUnexpectedRun",
"from_s": 460,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 350,
"equals": false
},
{
"output": "yFault",
"from_s": 370,
"to_s": 450,
"equals": true
},
{
"output": "yFault",
"from_s": 460,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "pending_direction_reversal_starts_fresh_lane",
"description": "Command and status swap while fail-to-start is pending. The old lane resets and the unexpected-run lane must serve its complete independent timer.",
"inputs": {
"fan_cmd": [
{
"t": 0,
"value": true
},
{
"t": 40,
"value": false
}
],
"fan_status": [
{
"t": 0,
"value": false
},
{
"t": 40,
"value": true
}
]
},
"expect": [
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 90,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 110,
"to_s": 2400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 90,
"equals": false
},
{
"output": "yFault",
"from_s": 110,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "mature_direction_reversal_hands_off",
"description": "A mature fail-to-start reverses directly into unexpected-run. The old flag clears on the reversal tick, yFault has a real gap, and the opposite lane serves a fresh complete stop timer.",
"inputs": {
"fan_cmd": [
{
"t": 0,
"value": true
},
{
"t": 400,
"value": false
}
],
"fan_status": [
{
"t": 0,
"value": false
},
{
"t": 400,
"value": true
}
]
},
"expect": [
{
"output": "yFailToStart",
"from_s": 70,
"to_s": 390,
"equals": true
},
{
"output": "yFailToStart",
"from_s": 400,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 450,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 470,
"to_s": 2400,
"equals": true
},
{
"output": "yFault",
"from_s": 70,
"to_s": 390,
"equals": true
},
{
"output": "yFault",
"from_s": 400,
"to_s": 450,
"equals": false
},
{
"output": "yFault",
"from_s": 470,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "two_short_start_mismatches_do_not_accumulate",
"description": "Two commanded-on/unproved episodes are separated by agreement; elapsed time cannot transfer across the reset.",
"inputs": {
"fan_cmd": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
},
{
"t": 340,
"value": false
},
{
"t": 900,
"value": true
},
{
"t": 940,
"value": false
}
],
"fan_status": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "two_short_stop_mismatches_do_not_accumulate",
"description": "Two proven-on/uncommanded episodes are separated by agreement; elapsed time cannot transfer across the reset.",
"inputs": {
"fan_cmd": false,
"fan_status": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
},
{
"t": 340,
"value": false
},
{
"t": 900,
"value": true
},
{
"t": 940,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "command_chatter_never_matures",
"description": "The final command chatters faster than either proof window while status is off; no continuous mismatch matures.",
"inputs": {
"fan_cmd": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
},
{
"t": 340,
"value": false
},
{
"t": 600,
"value": true
},
{
"t": 640,
"value": false
},
{
"t": 900,
"value": true
},
{
"t": 940,
"value": false
}
],
"fan_status": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "status_chatter_never_matures",
"description": "Independent proof chatters against a held-on command; every unproved interval clears before the start timer.",
"inputs": {
"fan_cmd": true,
"fan_status": [
{
"t": 0,
"value": true
},
{
"t": 300,
"value": false
},
{
"t": 340,
"value": true
},
{
"t": 600,
"value": false
},
{
"t": 640,
"value": true
},
{
"t": 900,
"value": false
},
{
"t": 940,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "unexpected_run_chatter_never_matures",
"description": "Independent proof chatters on against a held-off command; every unexpected-run interval clears before the stop timer.",
"inputs": {
"fan_cmd": false,
"fan_status": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
},
{
"t": 340,
"value": false
},
{
"t": 600,
"value": true
},
{
"t": 640,
"value": false
},
{
"t": 900,
"value": true
},
{
"t": 940,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "upstream_enable_raw_false_positive",
"description": "An upstream enable held true while downstream logic correctly withholds operation produces a raw fail-to-start alarm; this pins why only the final command is a valid binding.",
"inputs": {
"fan_cmd": true,
"fan_status": false
},
"expect": [
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 50,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 70,
"to_s": 2400,
"equals": true
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 50,
"equals": false
},
{
"output": "yFault",
"from_s": 70,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "command_echo_raw_blind_spot",
"description": "A status echo follows the command while the device is mechanically stopped. The pair agrees and the graph stays silent, pinning why proof must be independent.",
"inputs": {
"fan_cmd": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
}
],
"fan_status": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "manual_local_run_is_host_excluded",
"description": "A locally commanded device is proven on while the BAS final command is off. The raw unexpected-run alarm is correct; approved service or hand operation is host NO_EVAL.",
"inputs": {
"fan_cmd": false,
"fan_status": true
},
"expect": [
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 50,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 70,
"to_s": 2400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 50,
"equals": false
},
{
"output": "yFault",
"from_s": 70,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "wrong_speed_stage_is_boolean_blind_spot",
"description": "An ECM is commanded to high speed but independent Boolean proof reports only that it runs at some speed. Both booleans agree, so a failed requested stage is outside this operation-only rule.",
"inputs": {
"fan_cmd": true,
"fan_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "ordinary_vav_without_terminal_fan_is_not_applicable",
"description": "An ordinary VAV has no honest terminal-fan command/proof pair. Synthetic disagreement alarms raw, demonstrating why the host must not instantiate the FPB family there.",
"inputs": {
"fan_cmd": true,
"fan_status": false
},
"expect": [
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 50,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 70,
"to_s": 2400,
"equals": true
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 50,
"equals": false
},
{
"output": "yFault",
"from_s": 70,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "series_terminal_continuous_occupied_fan",
"description": "A series fan-powered terminal may command and prove its fan continuously while occupied; command/proof agreement is healthy.",
"inputs": {
"fan_cmd": true,
"fan_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "parallel_terminal_idle_fan_off",
"description": "A parallel terminal may legitimately leave its fan off outside heating or low-primary-flow operation; agreement off is healthy.",
"inputs": {
"fan_cmd": false,
"fan_status": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
}
]
}