TOWER-0004 — Tower fan proof-of-operation failure
| Status | verified — engine e2ff2f8, cxf:fnv1a128:8cea8bc88b1dfcc84aa0e09731947555, 2026-08-20 |
| Severity | 2 |
| Method | rule |
| Phase | 2 |
| Category | PROTECTIVE |
| Confidence | HIGH |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | TOWER-0001, TOWER-0003, TOWER-0005 |
| Playbooks | cooling-tower-performance |
| Source | Library proof-of-operation precedents CHW-0008, PMP-0003, and HW-0009 — independent command/status mismatch lanes with initialization-safe direction-specific timers; EnergyPlus Engineering Reference, Cooling Towers and Evaporative Fluid Coolers — variable-speed tower fan operation follows leaving-water control and free-convection logic; control semantics only, no portable proof timer; Library-authored tower timing adaptation; no cited source publishes 120 s start and stop proof limits as portable values |
| Operating states | all states in which the final individual fan/cell command and an independent proof of that same fan’s operation are authoritative |
Preconditions (host-enforced): Bind tower_fan_cmd after cell staging, free-convection logic, minimum on/off timing, vibration lockout, and ordinary safeties; a plant tower-enable request is not a per-fan final command. Bind tower_fan_status to independent electrical, airflow, rotation, or auxiliary-contact proof for the same fan, never to an echoed command or an OR of the tower fleet. Evaluate one rule instance per independently commanded fan/cell. Normalize multi-speed stages to Boolean only when any-commanded-stage and any-proven-stage preserve real transitions. Inputs must be fresh and time-aligned, and both timers must exceed normal delivery latency and the real mechanical sequence. Exclude maintenance, local/manual operation, exercise tests, approved coast-down, vibration or OEM safety actions, and any state in which another controller owns the fan; otherwise report NO_EVAL.
Points: tower_fan_cmd, tower_fan_status
Outputs:
yFault— True while either independent command/status mismatch has matured through its own proof timeryFailToStart— True after a final per-fan start command remains unproved for start_proof_timeyUnexpectedRun— True after independent fan operation continues without a final per-fan command for stop_proof_time
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
start_proof_time | 120.0 | s | startProof.delayTime | Allowed fan start and proof sequence. ADOPTED_TUNABLE: exceed normal VFD/starter acceleration, proof pickup, and telemetry latency. |
stop_proof_time | 120.0 | s | stopProof.delayTime | Allowed fan ramp-down, coast-down, and proof dropout. ADOPTED_TUNABLE and independent of the start window. |
Description
This rule checks whether one cooling-tower fan did what its final command requested. A commanded fan without independent proof can mean a failed drive, starter, belt, motor, interlock, or proof point. A proven fan after its command has gone away can mean local control, a stuck output or contactor, a second controller, or a command bound upstream of the true owner.
Detection Logic
fail_to_start = tower_fan_cmd AND NOT tower_fan_status
unexpected_run = NOT tower_fan_cmd AND tower_fan_status
yFailToStart = fail_to_start sustained for start_proof_time
yUnexpectedRun = unexpected_run sustained for stop_proof_time
yFault = yFailToStart OR yUnexpectedRun
Each mismatch has a separate TrueDelay(delayOnInit=true). A direct direction
reversal therefore clears the old diagnostic immediately and starts the other
timer from zero. Time never survives a healthy sample or transfers between
directions.
Possible Diagnoses
yFailToStart:
- VFD/starter trip, disconnect, overload, failed motor, belt, gearbox, or fan
- Vibration, freeze, low-water, fire, or OEM safety interlock is active
- Final cell-stage command was mapped to the wrong fan
- Proof switch, airflow/rotation sensor, auxiliary contact, or integration is bad
- Command point is upstream of free-convection, anti-cycle, or safety logic
yUnexpectedRun:
- Local/manual mode, service override, or a second tower controller
- Welded contactor, stuck output, or VFD run command held internally
- Normal deceleration/coast-down exceeds the configured stop window
- Fleet status was incorrectly bound to an individual fan instance
Energy Impact
PROTECTIVE, direction-dependent. Failure to start threatens heat rejection and can raise chiller lift, but its energy effect cannot be calculated from these two Boolean points. Unexpected operation can waste the same fan’s measured kW; use that only as an upper bound because some rejected heat may still be useful.
Emissions Impact
Scope 2, proxy-only for unexpected fan operation. Do not claim avoided energy or emissions for a failed start without a separate condenser-plant model.
Deviations
- Both 120 s timers are adopted commissioning values. No cited source establishes a universal tower-fan proof window. Configure them independently around actual acceleration, deceleration, proof pickup, and network latency.
- The command is downstream of normal tower logic. A plant enable or upstream leaving-water request is intentionally rejected because free convection, cell staging, minimum timers, and safeties can all keep an individual fan off correctly.
- No rule-wide suppression targets TOWER-0001. Failure to start can make approach-high non-evaluable, but unexpected operation can leave approach physically meaningful. Current metadata cannot suppress only one direction.
delayOnInit=trueis explicit on both lanes. A runtime restart does not turn an existing command/status disagreement into an immediate alarm.- EnergyPlus validation is not claimed. Fan power or airflow ratio can establish status, but the current fixture has no independent final BAS fan command. Deriving command from status would make proof tautological.
Notes
Dispatch from the direction output, not yFault alone. Confirm authority and
point identity remotely before sending a technician: a correct fan paired with
the wrong cell’s command produces a perfectly repeatable false diagnosis.
Test Vectors
19 scenarios, clock step 60 s over 1800 s.
| Scenario | Description |
|---|---|
agreement_stopped | Final command and independent proof both false are healthy agreement. |
agreement_running | Final command and independent proof both true are healthy agreement. |
fail_to_start_at_initialization | A standing start mismatch serves the full initialization-safe proof time. |
unexpected_run_at_initialization | A standing stop mismatch serves its own complete initialization-safe proof time. |
normal_delayed_start_and_stop | Independent proof follows the final command inside both adopted windows. |
start_mismatch_ends_one_tick_before_proof | A start mismatch clearing at 60 seconds cannot mature the 120-second timer. |
stop_mismatch_ends_one_tick_before_proof | A stop mismatch clearing at 60 seconds cannot mature the independent timer. |
mature_start_alarm_recovers | An already-mature failure-to-start clears immediately when independent run proof arrives. |
mature_stop_alarm_recovers | An already-mature unexpected-run alarm clears when the fan stops. |
direction_reversal_restarts_timer | A direct mismatch reversal clears the old lane and serves the new lane’s complete timer. |
command_chatter_never_matures | Alternating command ownership resets both direction-specific candidates. |
status_chatter_never_matures | Status chatter around a steady start command cannot accumulate across healthy samples. |
two_short_start_mismatches_do_not_accumulate | Separated start-proof episodes each need their own complete duration. |
pending_direction_reversal_starts_fresh_lane | A start mismatch reverses before maturity; the start lane never asserts and the stop lane receives a fresh timer. |
unexpected_run_chatter_never_matures | Stop-lane status chatter repeatedly restores healthy agreement before its proof timer matures. |
two_short_stop_mismatches_do_not_accumulate | Separated unexpected-run episodes each require their own complete duration. |
upstream_enable_raw_false_positive | A plant enable misbound as this fan’s final command raises the raw start alarm for an unselected cell; the host must report NO_EVAL. |
command_echo_raw_blind_spot | An echoed command masquerading as status keeps the raw graph clear even when physical truth is unknown; independent proof is mandatory. |
manual_local_run_is_host_excluded | A locally owned fan produces a raw unexpected-run alarm, but the host excludes the ownership window. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 1800
},
"scenarios": [
{
"name": "agreement_stopped",
"description": "Final command and independent proof both false are healthy agreement.",
"inputs": {
"tower_fan_cmd": false,
"tower_fan_status": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "agreement_running",
"description": "Final command and independent proof both true are healthy agreement.",
"inputs": {
"tower_fan_cmd": true,
"tower_fan_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "fail_to_start_at_initialization",
"description": "A standing start mismatch serves the full initialization-safe proof time.",
"inputs": {
"tower_fan_cmd": true,
"tower_fan_status": false
},
"expect": [
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 60,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 180,
"to_s": 1800,
"equals": true
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 60,
"equals": false
},
{
"output": "yFault",
"from_s": 180,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "unexpected_run_at_initialization",
"description": "A standing stop mismatch serves its own complete initialization-safe proof time.",
"inputs": {
"tower_fan_cmd": false,
"tower_fan_status": true
},
"expect": [
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 60,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 180,
"to_s": 1800,
"equals": true
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 60,
"equals": false
},
{
"output": "yFault",
"from_s": 180,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "normal_delayed_start_and_stop",
"description": "Independent proof follows the final command inside both adopted windows.",
"inputs": {
"tower_fan_cmd": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
},
{
"t": 900,
"value": false
}
],
"tower_fan_status": [
{
"t": 0,
"value": false
},
{
"t": 360,
"value": true
},
{
"t": 960,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "start_mismatch_ends_one_tick_before_proof",
"description": "A start mismatch clearing at 60 seconds cannot mature the 120-second timer.",
"inputs": {
"tower_fan_cmd": true,
"tower_fan_status": [
{
"t": 0,
"value": false
},
{
"t": 60,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "stop_mismatch_ends_one_tick_before_proof",
"description": "A stop mismatch clearing at 60 seconds cannot mature the independent timer.",
"inputs": {
"tower_fan_cmd": false,
"tower_fan_status": [
{
"t": 0,
"value": true
},
{
"t": 60,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "mature_start_alarm_recovers",
"description": "An already-mature failure-to-start clears immediately when independent run proof arrives.",
"inputs": {
"tower_fan_cmd": true,
"tower_fan_status": [
{
"t": 0,
"value": false
},
{
"t": 600,
"value": true
}
]
},
"expect": [
{
"output": "yFailToStart",
"from_s": 180,
"to_s": 540,
"equals": true
},
{
"output": "yFault",
"from_s": 180,
"to_s": 540,
"equals": true
},
{
"output": "yFault",
"from_s": 600,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "mature_stop_alarm_recovers",
"description": "An already-mature unexpected-run alarm clears when the fan stops.",
"inputs": {
"tower_fan_cmd": false,
"tower_fan_status": [
{
"t": 0,
"value": true
},
{
"t": 600,
"value": false
}
]
},
"expect": [
{
"output": "yUnexpectedRun",
"from_s": 180,
"to_s": 540,
"equals": true
},
{
"output": "yFault",
"from_s": 180,
"to_s": 540,
"equals": true
},
{
"output": "yFault",
"from_s": 600,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "direction_reversal_restarts_timer",
"description": "A direct mismatch reversal clears the old lane and serves the new lane's complete timer.",
"inputs": {
"tower_fan_cmd": [
{
"t": 0,
"value": true
},
{
"t": 600,
"value": false
}
],
"tower_fan_status": [
{
"t": 0,
"value": false
},
{
"t": 600,
"value": true
}
]
},
"expect": [
{
"output": "yFailToStart",
"from_s": 180,
"to_s": 540,
"equals": true
},
{
"output": "yFailToStart",
"from_s": 600,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 600,
"to_s": 660,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 780,
"to_s": 1800,
"equals": true
},
{
"output": "yFault",
"from_s": 780,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "command_chatter_never_matures",
"description": "Alternating command ownership resets both direction-specific candidates.",
"inputs": {
"tower_fan_cmd": [
{
"t": 0,
"value": true
},
{
"t": 60,
"value": false
},
{
"t": 120,
"value": true
},
{
"t": 180,
"value": false
},
{
"t": 240,
"value": true
},
{
"t": 300,
"value": false
}
],
"tower_fan_status": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "status_chatter_never_matures",
"description": "Status chatter around a steady start command cannot accumulate across healthy samples.",
"inputs": {
"tower_fan_cmd": true,
"tower_fan_status": [
{
"t": 0,
"value": false
},
{
"t": 60,
"value": true
},
{
"t": 120,
"value": false
},
{
"t": 180,
"value": true
},
{
"t": 240,
"value": false
},
{
"t": 300,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "two_short_start_mismatches_do_not_accumulate",
"description": "Separated start-proof episodes each need their own complete duration.",
"inputs": {
"tower_fan_cmd": true,
"tower_fan_status": [
{
"t": 0,
"value": false
},
{
"t": 60,
"value": true
},
{
"t": 300,
"value": false
},
{
"t": 360,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "pending_direction_reversal_starts_fresh_lane",
"description": "A start mismatch reverses before maturity; the start lane never asserts and the stop lane receives a fresh timer.",
"inputs": {
"tower_fan_cmd": [
{
"t": 0,
"value": true
},
{
"t": 60,
"value": false
}
],
"tower_fan_status": [
{
"t": 0,
"value": false
},
{
"t": 60,
"value": true
}
]
},
"expect": [
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 120,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 240,
"to_s": 1800,
"equals": true
},
{
"output": "yFault",
"from_s": 240,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "unexpected_run_chatter_never_matures",
"description": "Stop-lane status chatter repeatedly restores healthy agreement before its proof timer matures.",
"inputs": {
"tower_fan_cmd": false,
"tower_fan_status": [
{
"t": 0,
"value": true
},
{
"t": 60,
"value": false
},
{
"t": 180,
"value": true
},
{
"t": 240,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "two_short_stop_mismatches_do_not_accumulate",
"description": "Separated unexpected-run episodes each require their own complete duration.",
"inputs": {
"tower_fan_cmd": false,
"tower_fan_status": [
{
"t": 0,
"value": true
},
{
"t": 60,
"value": false
},
{
"t": 300,
"value": true
},
{
"t": 360,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "upstream_enable_raw_false_positive",
"description": "A plant enable misbound as this fan's final command raises the raw start alarm for an unselected cell; the host must report NO_EVAL.",
"inputs": {
"tower_fan_cmd": true,
"tower_fan_status": false
},
"expect": [
{
"output": "yFailToStart",
"from_s": 180,
"to_s": 1800,
"equals": true
},
{
"output": "yFault",
"from_s": 180,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "command_echo_raw_blind_spot",
"description": "An echoed command masquerading as status keeps the raw graph clear even when physical truth is unknown; independent proof is mandatory.",
"inputs": {
"tower_fan_cmd": true,
"tower_fan_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "manual_local_run_is_host_excluded",
"description": "A locally owned fan produces a raw unexpected-run alarm, but the host excludes the ownership window.",
"inputs": {
"tower_fan_cmd": false,
"tower_fan_status": true
},
"expect": [
{
"output": "yUnexpectedRun",
"from_s": 180,
"to_s": 1800,
"equals": true
},
{
"output": "yFault",
"from_s": 180,
"to_s": 1800,
"equals": true
}
]
}
]
}