TOWER-0005 — Condenser water overcooling with fan energy
| Status | verified — engine e2ff2f8, cxf:fnv1a128:b4c4b0066b62393e887a37208afffd2e, 2026-08-20 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | EXCESS_CONSUMPTION |
| Confidence | MEDIUM |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | TOWER-0001, TOWER-0002, TOWER-0004, CHW-0005 |
| Playbooks | cooling-tower-performance |
| Source | NREL, Refrigeration Playbook: Natural Refrigerants (2021), cooling-tower control discussion near p.45 — variable-speed fans seek leaving-water setpoint and turn off when leaving water is below setpoint; mechanism only; EnergyPlus Engineering Reference, Cooling Towers and Evaporative Fluid Coolers — fan-off free convection and fan modulation/cycling are normal ways to meet tower outlet setpoint; simulation semantics only; Library-authored thresholds and persistence; no cited source publishes 1 K, 30%, or 600 s as portable fault limits |
| Operating states | Tower cell enabled for normal mechanical heat rejection with an active setpoint, proven fan operation, and normal automatic isolation/bypass control |
Preconditions (host-enforced): Bind tower_leaving_temp to the cold water leaving this tower/cell and tower_leaving_temp_sp to the active target for that same stream; the latter is commonly named condenser-water supply or entering-chiller setpoint. Parallel cells may share a common setpoint only when that target truly applies to each cell and per-cell outlet sensing exists. Fan status and speed must describe the same cell: status is independent run proof, while speed is preferably feedback. A verified command proxy is allowed only with a documented limitation and must not be used where local control, current limiting, or drive overrides can separate command from delivered airflow. Configure minimum_fan_speed from the same-cell feedback/power relationship and effective drive minimum before adoption; the shipped 30% is NO_PORTABLE_DEFAULT. Points must be fresh and time-aligned. Exclude waterside economizer/free cooling, thermal-storage charging, emergency heat rejection, mandated low-condenser-water modes, startup/shutdown transients, drain-down, manual/local operation, and abnormal isolation or bypass states. Sensor calibration and topology must be valid; otherwise report NO_EVAL.
Points: tower_leaving_temp, tower_leaving_temp_sp, tower_fan_status, tower_fan_speed
Outputs:
yFault— True after material overcooling and proven loaded fan operation persist continuously for sustained_durationyOvercooled— True while active tower-leaving setpoint exceeds measured leaving temperature by strictly more than overcooling_allowanceyFanLoaded— True while this fan is independently proven on and speed is strictly above minimum_fan_speed
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
overcooling_allowance | 1.0 | K | overcooled.t | Allowed tower-leaving temperature undershoot. ADOPTED_TUNABLE: set outside combined sensor, setpoint-distribution, and control-loop uncertainty. |
minimum_fan_speed | 30.0 | % | speedHigh.t | Speed above which fan use is considered materially loaded. NO_PORTABLE_DEFAULT: 30% is an adoption-blocking placeholder; configure from measured same-cell fan feedback/power and the drive’s effective minimum. The strict graph comparison leaves exactly 30% clear. |
sustained_duration | 600.0 | s | persist.delayTime | Continuous overcooling with loaded fan required before alarm. ADOPTED_TUNABLE: longer than ordinary setpoint and cell-stage response. |
Description
A healthy variable-speed tower reduces or stops fan work when leaving water is already colder than its active target. This rule finds the narrower waste case: the water is materially below setpoint while the same cell is proven running above a meaningful fan speed. Cold water with the fan off remains explicitly silent; natural convection is normal operation, not a fault.
Detection Logic
temperature_error = tower_leaving_temp_sp - tower_leaving_temp
overcooled = temperature_error > overcooling_allowance
fan_loaded = tower_fan_status AND tower_fan_speed > minimum_fan_speed
candidate = overcooled AND fan_loaded
yOvercooled = overcooled
yFanLoaded = fan_loaded
yFault = candidate sustained for sustained_duration
The algebra avoids a second subtract block: setpoint minus measured leaving
temperature is positive when the tower overcools. Both comparisons are strict,
so exactly 1 K of undershoot or exactly 30% fan speed remains clear.
TrueDelay(delayOnInit=true) resets immediately if temperature recovers, fan
proof drops, or speed unloads.
Possible Diagnoses
- Leaving-water setpoint is reset upward but the tower sequence still follows a stale, local, or differently scaled setpoint
- Fan minimum speed, VFD floor, or cell-stage deadband is too high
- Fan is in hand/local, speed command is overridden, or drive feedback is wrong
- Isolation or bypass valve is stuck/mis-sequenced, making the sensed stream unrepresentative of the controlled common header
- Leaving-water sensor reads low or active setpoint is mapped to the wrong node
- Cell lead/lag logic keeps too many fans loaded after the heat-rejection load falls
- A legitimate low-water strategy was not excluded by the host
Energy Impact
EXCESS_CONSUMPTION with MEDIUM confidence. The direct waste is tower-fan electricity during the persisted condition. Integrating measured same-cell fan power gives a conservative upper bound, not guaranteed savings: the corrected sequence may still require some fan work. Chiller energy can move in either direction with condenser-water temperature and must not be added without a site-specific optimum-lift model.
Emissions Impact
Scope 2 from avoidable fan electricity, using the site’s marginal operating emissions rate. No portable savings or emissions fraction is claimed.
Deviations
- No source establishes the three numeric defaults. The 1 K allowance and
10-minute persistence are adopted commissioning starts. The 30% loaded floor
is
NO_PORTABLE_DEFAULT: configure it from same-cell feedback/power before adoption because a speed percentage is not a universal meaningful-energy line. - The 1 K allowance must exceed measurement uncertainty. Common-header setpoints compared with individual-cell outlet temperatures can create a false undershoot on parallel towers; topology and setpoint distribution are preconditions, not hidden graph assumptions.
- Speed feedback is preferred. A verified command proxy is weaker because a drive in local, limited, or overridden operation can deliver something else. The host must label that binding and avoid near-threshold conclusions.
- This rule stays outside CLU-10. It is a control/energy finding, not the condenser-side degradation syndrome triggered by TOWER-0001.
- No suppression is encoded. TOWER-0004 failure-to-start can invalidate the fan premise, while its unexpected-run direction can cause this exact fault; whole-rule suppression would erase a useful diagnosis.
- Simulation validates healthy FPR only, not causal TPR. The recorded Denver July campaign uses native per-object outlet temperature, fan power, and airflow ratio at 60 s. Airflow ratio is an effective-airflow proxy rather than mechanical VFD feedback, and the parallel towers share one loop target.
Notes
Read both diagnostic outputs before dispatch. yOvercooled=true with
yFanLoaded=false is often normal free convection. yFanLoaded=true without
overcooling says the fan may simply be doing useful work.
Test Vectors
20 scenarios, clock step 60 s over 2400 s.
| Scenario | Description |
|---|---|
on_setpoint_fan_loaded | A loaded fan at the active leaving-water setpoint is not overcooling. |
naturally_cold_fan_off | Cold water with the fan proven off exposes temperature context but never raises the energy finding. |
cold_fan_below_minimum | A running fan below the strict diagnostic speed floor is not treated as materially loaded. |
cold_loaded_at_initialization | A standing violation serves the complete initialization-safe sustained duration. |
temperature_exact_boundary_clear | Exactly 1 K below setpoint is clear because the comparator is strict. |
temperature_just_beyond_boundary | One test increment beyond the adopted temperature allowance is overcooled. |
speed_exact_boundary_clear | Exactly 30 percent is clear because loaded-fan comparison is strict. |
speed_just_beyond_boundary | One test increment above the speed floor is materially loaded. |
violation_ends_one_tick_before_persistence | A 540-second candidate clears one evaluator tick before the 600-second delay and never alarms. |
mature_alarm_recovers | An already-mature overcooling alarm clears immediately when leaving water returns to setpoint. |
fan_unload_resets_timer | Dropping below the loaded-fan floor resets persistence even while water remains cold. |
setpoint_change_resets_candidate | A temporary active-setpoint increase creates a sub-duration candidate that clears when the setpoint returns. |
status_false_blocks_high_speed | A high speed value without independent run proof cannot satisfy the loaded-fan gate. |
two_short_episodes_do_not_accumulate | Separated overcooling episodes each need a complete sustained duration. |
threshold_chatter_never_matures | Temperature chatter across the strict allowance repeatedly resets persistence. |
startup_violation_uses_full_delay | A candidate beginning after healthy initialization receives the same complete delay. |
both_thresholds_just_beyond | Temperature and speed one test increment beyond their strict boundaries mature the full finding. |
speed_threshold_chatter_never_matures | Speed chatter across the strict loaded threshold repeatedly resets persistence. |
setpoint_changes_but_candidate_remains_true | A setpoint change that leaves error above the allowance does not reset the Boolean candidate’s timer. |
intentional_low_water_mode_is_host_excluded | Intentional free-cooling or mandated low-condenser-water operation raises the raw graph; the host must report NO_EVAL for that mode. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 2400
},
"scenarios": [
{
"name": "on_setpoint_fan_loaded",
"description": "A loaded fan at the active leaving-water setpoint is not overcooling.",
"inputs": {
"tower_leaving_temp": 25.0,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 50.0
},
"expect": [
{
"output": "yOvercooled",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFanLoaded",
"from_s": 0,
"to_s": 2400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "naturally_cold_fan_off",
"description": "Cold water with the fan proven off exposes temperature context but never raises the energy finding.",
"inputs": {
"tower_leaving_temp": 23.0,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": false,
"tower_fan_speed": 0.0
},
"expect": [
{
"output": "yOvercooled",
"from_s": 0,
"to_s": 2400,
"equals": true
},
{
"output": "yFanLoaded",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "cold_fan_below_minimum",
"description": "A running fan below the strict diagnostic speed floor is not treated as materially loaded.",
"inputs": {
"tower_leaving_temp": 23.0,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 20.0
},
"expect": [
{
"output": "yOvercooled",
"from_s": 0,
"to_s": 2400,
"equals": true
},
{
"output": "yFanLoaded",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "cold_loaded_at_initialization",
"description": "A standing violation serves the complete initialization-safe sustained duration.",
"inputs": {
"tower_leaving_temp": 23.0,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 50.0
},
"expect": [
{
"output": "yOvercooled",
"from_s": 0,
"to_s": 2400,
"equals": true
},
{
"output": "yFanLoaded",
"from_s": 0,
"to_s": 2400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 540,
"equals": false
},
{
"output": "yFault",
"from_s": 660,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "temperature_exact_boundary_clear",
"description": "Exactly 1 K below setpoint is clear because the comparator is strict.",
"inputs": {
"tower_leaving_temp": 24.0,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 50.0
},
"expect": [
{
"output": "yOvercooled",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "temperature_just_beyond_boundary",
"description": "One test increment beyond the adopted temperature allowance is overcooled.",
"inputs": {
"tower_leaving_temp": 23.99,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 50.0
},
"expect": [
{
"output": "yOvercooled",
"from_s": 0,
"to_s": 2400,
"equals": true
},
{
"output": "yFault",
"from_s": 660,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "speed_exact_boundary_clear",
"description": "Exactly 30 percent is clear because loaded-fan comparison is strict.",
"inputs": {
"tower_leaving_temp": 23.0,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 30.0
},
"expect": [
{
"output": "yFanLoaded",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "speed_just_beyond_boundary",
"description": "One test increment above the speed floor is materially loaded.",
"inputs": {
"tower_leaving_temp": 23.0,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 30.01
},
"expect": [
{
"output": "yFanLoaded",
"from_s": 0,
"to_s": 2400,
"equals": true
},
{
"output": "yFault",
"from_s": 660,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "violation_ends_one_tick_before_persistence",
"description": "A 540-second candidate clears one evaluator tick before the 600-second delay and never alarms.",
"inputs": {
"tower_leaving_temp": [
{
"t": 0,
"value": 23.0
},
{
"t": 540,
"value": 25.0
}
],
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 50.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "mature_alarm_recovers",
"description": "An already-mature overcooling alarm clears immediately when leaving water returns to setpoint.",
"inputs": {
"tower_leaving_temp": [
{
"t": 0,
"value": 23.0
},
{
"t": 900,
"value": 25.0
}
],
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 50.0
},
"expect": [
{
"output": "yFault",
"from_s": 660,
"to_s": 840,
"equals": true
},
{
"output": "yFault",
"from_s": 900,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "fan_unload_resets_timer",
"description": "Dropping below the loaded-fan floor resets persistence even while water remains cold.",
"inputs": {
"tower_leaving_temp": 23.0,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": [
{
"t": 0,
"value": 50.0
},
{
"t": 300,
"value": 20.0
},
{
"t": 600,
"value": 50.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1140,
"equals": false
},
{
"output": "yFault",
"from_s": 1260,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "setpoint_change_resets_candidate",
"description": "A temporary active-setpoint increase creates a sub-duration candidate that clears when the setpoint returns.",
"inputs": {
"tower_leaving_temp": 25.0,
"tower_leaving_temp_sp": [
{
"t": 0,
"value": 25.0
},
{
"t": 300,
"value": 27.0
},
{
"t": 840,
"value": 25.0
}
],
"tower_fan_status": true,
"tower_fan_speed": 50.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "status_false_blocks_high_speed",
"description": "A high speed value without independent run proof cannot satisfy the loaded-fan gate.",
"inputs": {
"tower_leaving_temp": 23.0,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": false,
"tower_fan_speed": 100.0
},
"expect": [
{
"output": "yFanLoaded",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "two_short_episodes_do_not_accumulate",
"description": "Separated overcooling episodes each need a complete sustained duration.",
"inputs": {
"tower_leaving_temp": [
{
"t": 0,
"value": 23.0
},
{
"t": 300,
"value": 25.0
},
{
"t": 600,
"value": 23.0
},
{
"t": 900,
"value": 25.0
}
],
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 50.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "threshold_chatter_never_matures",
"description": "Temperature chatter across the strict allowance repeatedly resets persistence.",
"inputs": {
"tower_leaving_temp": [
{
"t": 0,
"value": 23.99
},
{
"t": 120,
"value": 24.01
},
{
"t": 240,
"value": 23.99
},
{
"t": 360,
"value": 24.01
},
{
"t": 480,
"value": 23.99
},
{
"t": 600,
"value": 24.01
}
],
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 50.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "startup_violation_uses_full_delay",
"description": "A candidate beginning after healthy initialization receives the same complete delay.",
"inputs": {
"tower_leaving_temp": [
{
"t": 0,
"value": 25.0
},
{
"t": 300,
"value": 23.0
}
],
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 50.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "both_thresholds_just_beyond",
"description": "Temperature and speed one test increment beyond their strict boundaries mature the full finding.",
"inputs": {
"tower_leaving_temp": 23.99,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 30.01
},
"expect": [
{
"output": "yOvercooled",
"from_s": 0,
"to_s": 2400,
"equals": true
},
{
"output": "yFanLoaded",
"from_s": 0,
"to_s": 2400,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 540,
"equals": false
},
{
"output": "yFault",
"from_s": 660,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "speed_threshold_chatter_never_matures",
"description": "Speed chatter across the strict loaded threshold repeatedly resets persistence.",
"inputs": {
"tower_leaving_temp": 23.0,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": [
{
"t": 0,
"value": 30.01
},
{
"t": 120,
"value": 30.0
},
{
"t": 240,
"value": 30.01
},
{
"t": 360,
"value": 30.0
},
{
"t": 480,
"value": 30.01
},
{
"t": 600,
"value": 30.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
}
]
},
{
"name": "setpoint_changes_but_candidate_remains_true",
"description": "A setpoint change that leaves error above the allowance does not reset the Boolean candidate's timer.",
"inputs": {
"tower_leaving_temp": 23.0,
"tower_leaving_temp_sp": [
{
"t": 0,
"value": 25.0
},
{
"t": 300,
"value": 26.0
}
],
"tower_fan_status": true,
"tower_fan_speed": 50.0
},
"expect": [
{
"output": "yOvercooled",
"from_s": 0,
"to_s": 2400,
"equals": true
},
{
"output": "yFault",
"from_s": 660,
"to_s": 2400,
"equals": true
}
]
},
{
"name": "intentional_low_water_mode_is_host_excluded",
"description": "Intentional free-cooling or mandated low-condenser-water operation raises the raw graph; the host must report NO_EVAL for that mode.",
"inputs": {
"tower_leaving_temp": 23.0,
"tower_leaving_temp_sp": 25.0,
"tower_fan_status": true,
"tower_fan_speed": 50.0
},
"expect": [
{
"output": "yFault",
"from_s": 660,
"to_s": 2400,
"equals": true
}
]
}
]
}