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

TOWER-0002 — Tower range collapse

Statusverified — engine e2ff2f8, cxf:fnv1a128:e7ae89b2fdf66c58a01579412c64b6f5, 2026-08-18
Severity3
Methodrule
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceLOW
EstimationPROXY_ESTIMATION
G36
ClustersCLU-10
Suppresses
Suppressed by
RelatedTOWER-0001, TOWER-0003, TOWER-0005, CHW-0004
Playbookscooling-tower-performance
SourceLibrary extension: the HVAC FDD Reference v1.0 has no cooling-tower chapter — the TOWER family is library-authored; cxf-library simulation study, tools/simharness/README.md ‘Tower groundwork’ — 4-climate healthy-operation envelope; range p50 2.2-3.2 °C across all six runs. THE ONLY quantitative grounding for this card’s band, and it measures healthy operation, not the fault side; DOE/PNNL O&M Best Practices Guide Release 3.0 §9.5 and PNNL-13890 §7.5 — cooling-tower poor-performance causes (scale, clogged nozzles, poor airflow, poor pump performance); SILENT on any range magnitude; BEE Best Practice Manual: HVAC Chillers (2006) — condenser-approach mechanism and design bands; SILENT on any range fault magnitude; Sibling precedent: CHW-0004 and HW-0004 (delta-T graph shape, evaluability output), HP-0001 (commissioning-placeholder contract)
Operating statesCondenser loop rejecting heat — a chiller loaded, condenser water circulating, and the tower enabled with its fan above min_fan_speed_for_eval. yFanOk covers the fan half of that state; the chiller-loaded half is the host’s to enforce, and the rule is wrong without it (see Deviations).

Preconditions (host-enforced): tower_entering_temp and tower_leaving_temp must describe the same tower cell (or the same common header) at the same moment, and the loop-side binding must follow points/tower.points.json: LEAVING is the cold basin outlet that feeds the chiller condenser, ENTERING is the warm chiller-leaving water. Bound the other way round the rule reports a permanent fault on a healthy tower (pinned by loop_side_semantics_inverted). Condenser water flow is not a point of this rule and cannot be: range = heat rejected / (flow x cp), so a flow increase collapses range with no tower degradation whatever — a second condenser pump staged on, a VFD forced to 100%, a balancing valve opened. A finding therefore names the pair {flow, heat rejected}, never the tower alone, and the cooling-tower playbook’s step 1.3 checks flow first. The loop must actually be rejecting heat: a tower circulating with the chiller off equalises entering and leaving and alarms permanently, so gate host-side on chiller or condenser-pump status. Bind tower_fan_speed from VFD FEEDBACK where the point exists — where only the command is available, a fan tripped, in hand, or locked out reads a healthy speed while moving no air, and the host must add tower_fan_status to the gate. On a multi-cell tower bind per cell where the cells are sensed individually; common-header temperatures mix a starved cell with a working one and dilute the range of both. Both temperatures must be in °C (the rule converts nothing), and range_low_band must be commissioned from this tower’s own full-load range before any verdict means anything — the shipped 1.0 K is a placeholder (see Deviations). Evaluability is signalled in-rule by yFanOk; when it is false the verdict is NO_EVAL, not a healthy tower.

Points: tower_entering_temp, tower_leaving_temp, tower_fan_speed

Outputs:

  • yFault — True while the tower range has stayed below range_low_band with the fan above min_fan_speed_for_eval, continuously for at least alarm_delay
  • yFanOk — Evaluability signal — true when tower_fan_speed is above min_fan_speed_for_eval, the speed below which so little air is moving that range says nothing about the condenser loop. False means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
range_low_band1.0°CrangeLow.tRange below which the condenser loop is faulted. COMMISSIONING-SET PLACEHOLDER — no published fault-side range band exists for cooling towers (three sources silent; see Deviations). 1.0 K sits well below the 2.2-3.2 K healthy p50 the 4-climate simulation study measured across every climate it ran. Commission it from this tower’s own full-load range.
min_fan_speed_for_eval30.0%fanOk.tTower fan speed below which range is not evaluated. ADOPTED — no source supplies a floor; 30% sits above the 20-25% minimum common in tower VFD sequences, so the gate excludes a tower idling at its drive floor rather than one working. Retune to this drive’s minimum plus a margin.
alarm_delay3600.0spersist.delayTimeContinuous range collapse at fan load required before the alarm asserts (60 min). ADOPTED from CHW-0004 — no tower source specifies a persistence, and condenser-loop thermal mass plus chiller staging make anything shorter noise.

Description

Range is what the condenser loop takes out of the water: how far the tower drops it between the chiller’s discharge and the basin. It is the one tower quantity with a stable healthy band — a 4-climate simulation of a large office plant put the median between 2.2 and 3.2 K from Miami in July to Tucson in January, while approach over the same runs spread from 1.6 to 13.3 K. A range collapsed to a fraction of that says the loop is moving far more water than the heat it carries needs, or that the water and the air are not meeting. Neither finding is about tower capability: range = heat rejected / (flow × cp), and flow is not a point this rule can see. What the rule reports is that the pair has come apart; the playbook checks flow first.

Detection Logic

range  = tower_entering_temp − tower_leaving_temp   (warm chiller-leaving minus cold basin outlet)

yFanOk = tower_fan_speed > min_fan_speed_for_eval   (false ⇒ host reports NO_EVAL)
yFault = range < range_low_band AND yFanOk,
         sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

TOWER-0002 block graph

The operand order is the trap. The tower dictionary grounds tower_leaving_temp as entering condenser water — the cold basin outlet on its way to the chiller — so the warm side is tower_entering_temp and it goes on u1. Subtract the other way and every healthy tower reports a permanent fault while looking like a rule that works.

rangeLow is strict, so a tower sitting exactly on the band reads healthy, and the boundary is bit-exact: 30.0 − 29.0 is precisely 1.0. fanOk is the evaluability story — a fan barely turning rejects little heat, and the range underneath it is small for a reason that has nothing to do with a fault. Fan speed is the only load-shaped signal in the tower dictionary; Deviations records what that substitution costs. persist requires 60 continuous minutes and carries delayOnInit = true: a collapsed range is a loop condition, not an event.

Possible Diagnoses

Library-authored — no source lists range-collapse causes, so this is the mass-balance read of range = heat rejected / (flow × cp):

  1. Condenser water flow above design — a second pump staged on, a VFD forced to full, a balancing valve opened after a service call. The commonest cause and the one this rule cannot separate from any other; check it first
  2. Tower bypass valve open or leaking, the winter freeze-protection valve left in hand being the classic — water reaches the basin without crossing the fill
  3. Water short-circuiting inside the tower: cracked distribution basin, lifted hot-water deck covers, or collapsed and missing fill letting water fall past the air stream
  4. Flow through an idle cell on a multi-cell tower — the idle cell returns water near its entering temperature and the common header mixes the range away
  5. A chiller unloaded further than the fan gate excludes: less heat to reject at unchanged flow. The gate is a fan-speed proxy, not a load measurement
  6. Water-side sensor error — a 0.5 K offset is half the shipped band. An entering sensor reading low, or a leaving sensor reading high, both bias toward the alarm
  7. Sensors bound to different cells, or across a bypass. This alarms hardest of all and has nothing to do with the tower

Energy Impact

EXCESS_CONSUMPTION, LOW confidence, PROXY_ESTIMATION. Where the collapse is flow-driven — the usual case — the waste is condenser pumping: excess_cond_pump_kw ≈ cond_pump_kw × (design_range − actual_range) / design_range, CHW-0004’s estimator on the condenser loop. Where it is a bypass or a short-circuit the pumping term understates the cost, because the water returning to the chiller is warmer than the tower could have made it and the chiller pays for the lift (~2-4% of chiller power per °C, BEE 2006). Confidence is LOW for a reason no tuning fixes: the trip band has no literature behind it, and the rule cannot tell a flow increase from a tower defect.

Emissions Impact

Scope 2, PROXY_EMISSIONS, LOW confidence. Both terms are purchased electricity — condenser pump kWh and chiller kWh — so the avoided-emissions basis is the marginal operating emissions rate. The lift term peaks on hot afternoons when the grid is dirtiest, so a bypass found in July is worth more than its annual kWh figure suggests. No published emissions range exists for this fault; the estimate is the host’s own pump and chiller factors.

Deviations

  • The band is a commissioning-set placeholder, and its only quantitative grounding is simulation. The HVAC FDD Reference has no cooling-tower chapter, and all three sources deep-read for this family (PNNL-13890 §7.5, DOE/PNNL O&M Best Practices 3.0 §9.5, BEE 2006) are silent on any range fault magnitude — they name causes of poor tower performance and attach no number to one. range_low_band = 1.0 K is placed under the 2.2-3.2 K healthy p50 measured by this library’s own 4-climate study (tools/simharness/README.md, “Tower groundwork”), which describes healthy operation, not the fault side. CTI/ASHRAE fault-side corroboration is pending. Until a site commissions the band from its own full-load range this card is runnable but not calibrated — HP-0001’s contract, and the reason confidence: LOW.
  • One absolute band, not a design × fraction pair. CHW-0004 and HW-0004 assemble their trip lines from a design delta-T times a fraction because PNNL-27338 supplies both halves. No tower source supplies a fraction, and the simulation gives an absolute healthy band rather than a ratio to design, so a two-parameter form would dress a placeholder up as a derivation.
  • Range is not a tower-capability measurement, and the flow confound cannot be fixed in-rule. range = Q / (flow × cp): a flow increase collapses it with the tower untouched. No condenser-flow point exists in the tower dictionary and adding one would not help — it would turn the rule into a heat-balance calculation whose answer is still ambiguous without design flow. The confound lives in preconditions, in diagnosis 1, and in the playbook’s step 1.3. Tower capability degradation is TOWER-0001’s approach test, not this one.
  • The fan-speed floor is entirely adopted. No tower source gates a range or approach reading on anything. Shipping an ungated range test would alarm through every mild night and every unloaded chiller hour, which is the same failure HW-0004 records for PNNL-27338 §4.6. 30% is chosen against typical tower VFD minimum speeds (20-25%), not against a citation.
  • tower_fan_speed gates the rule, tower_fan_status does not. The dictionary carries both. Status answers “is the fan running”; speed answers “is the tower working”, which is the question a range reading needs, and a threshold on a real is what SCHEMA.md asks an evaluability output to be. The cost is a fan that is tripped or in hand while its speed command still reads 60% — a NO_EVAL the rule will miss, carried in preconditions.
  • There is no chiller-on conjunct, and that is a real blind spot. A condenser loop circulating with no heat input equalises, range goes to zero, and the rule alarms at full confidence on a plant with no tower defect. Adding a chiller status would import a plant-level condition into a tower rule and silence it through the off-cycles of a normally staging plant; HW-0004 rejected the same edge for the same reasons. The honest placement is operating_states plus a host gate.
  • Nothing guards against a negative range. Sensors bound the wrong way round, or a reverse-flow path, give a negative difference that is below any positive band and alarms permanently (pinned by loop_side_semantics_inverted). A comparison against zero could suppress it, but reverse flow through a bypass is itself a real hydraulic fault, so the guard would hide a plant problem to hide a binding one. Commissioning check: watch the sign once, before trusting the rule.
  • Strict < at the band and strict > at the floor. CDL Reals has no LessEqual or GreaterEqual. A tower at exactly 1.0 K range reads healthy and a fan at exactly 30.0% reads NO_EVAL; both disagreements are measure-zero and both err toward silence.
  • yFanOk is an evaluability flag, not a sub-condition flag. False means NO_EVAL and the host must not read the yFault = false underneath it as a healthy tower. Same stance as CHW-0004’s yLoadOk and HP-0001’s yPowerOk.
  • alarm_delay = 3600 s is adopted from CHW-0004, not sourced. No tower source specifies a persistence at all. An hour matches the chilled-water sibling and rides out a chiller stage change, a pump changeover, and a cell rotation. Persistence is not averaging: a range alternating either side of the band every 20 minutes never alarms (intermittent_collapse_never_alarms), even though a loop spending half its day collapsed is a genuine finding.
  • persist.delayOnInit = true (CDL default false), the library’s standing choice: a tower already below the band at controller restart waits out the full hour rather than alarming on the first tick.
  • Severity 3 and method: rule are library judgements. No reference index exists for the TOWER family to carry them, and the fault is a waste finding with no comfort or protection consequence — a collapsed range costs pump and chiller energy and nothing else fails.
  • clusters: [CLU-10]. clusters/clusters.json has no condenser-side cluster; CLU-06 is chilled water by name and membership. A tower syndrome (this rule, TOWER-0001 and CHW-0005 all describing one condenser loop giving away energy) is a reasonable future cluster and the cluster owner’s edit.
  • suppresses and suppressed_by are both empty. TOWER-0001 is the closest candidate, but approach and range answer different questions — a tower can fail both, either, or neither — and both findings stay separately actionable. Suppression edges must be declared on both cards in any case.
  • No published test vectors exist for this rule. Nothing in the literature specifies a range-collapse case, so every scenario in vectors.json is authored from the equation and replayed against the pinned engine rev.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Read yFanOk before yFault. A tower coasting at 20% fan on a mild morning holds it false for hours, and every yFault = false underneath means “not evaluated” rather than “range is fine”.

Check condenser flow before anyone climbs the tower. Trend range against the number of condenser pumps running and against pump speed: a range that steps down when a second pump starts is diagnosis 1 and needs no tower work at all. A range that is low across every flow state points at the bypass valve, then at the fill and the distribution basin. Where TOWER-0001 fires as well, the approach finding is the tower’s and this one is still probably the loop’s.

Test Vectors

13 scenarios, clock step 60 s over 7200 s.

ScenarioDescription
healthy_rangeEntering 32.0 °C, leaving 29.0 °C — a 3.0 K range, inside the 2.2-3.2 K healthy p50 band the 4-climate simulation study found, with the fan modulating at 60%. Evaluated and silent.
range_collapsedEntering 30.0 °C, leaving 29.2 °C — a 0.8 K range against the 1.0 K band, with the fan at 60%. The alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick.
fan_below_eval_floorThe same collapsed 0.8 K range with the fan idling at 20%. A tower whose fan is barely turning rejects little heat and its range says nothing about the condenser loop, which is what yFanOk = false tells the host: NO_EVAL, not a healthy tower.
range_exactly_at_the_bandBoundary, bit-exact: 30.0 − 29.0 is exactly 1.0 in IEEE-754 and range_low_band is exactly 1.0. Reals.LessThreshold is strict, so exactly-on-the-band reads healthy.
range_just_below_the_bandBoundary from below: 30.0 − 29.01 = 0.99 K clears the strict comparison by 10 mK and alarms on the normal schedule.
range_just_above_the_bandBoundary from above: 30.0 − 28.99 = 1.01 K never alarms, however marginal the loop is. Ten milli-kelvin is an order of magnitude inside any condenser-water sensor’s error, which is the point of the placeholder warning on the band.
fan_speed_exactly_at_the_floorBoundary on the other conjunct: the fan sits at exactly min_fan_speed_for_eval (30%) with the range collapsed. Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL and the rule stays silent.
fan_speed_just_above_the_floor30.1% with the same collapsed range: evaluable, and the alarm lands at exactly alarm_delay. One tenth of a percent of fan speed is the whole difference between this scenario and the previous one.
range_collapses_mid_runA tower holding a healthy 3.0 K range loses it at t = 1800 s — a second condenser pump starting, or a bypass valve opening. The alarm lands at exactly 5400 s: the mid-run rising edge carries the same T + delayTime arithmetic as the init case.
range_recovers_after_alarmRecovery: the alarm asserts at 3600 s and the range comes back at t = 5400 s when the extra pump stops. TrueDelay passes the falling edge with no delay, so yFault drops on that tick.
fan_slows_after_alarmThe evaluability release: an alarming tower drops to 15% fan at t = 5400 s with the collapsed range unchanged. yFault and yFanOk fall on the same tick, and only the pair tells the host that the tower unloaded rather than that the range recovered.
intermittent_collapse_never_alarmsRange alternating between 0.8 K and 3.0 K every 1200 s — a condenser pump staging on and off, or a chiller cycling. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging: a loop spending half its day at 0.8 K range is a real finding this rule cannot make.
loop_side_semantics_invertedBlind spot, pinned: the two water sensors are bound the wrong way round (entering 29.0 °C, leaving 32.0 °C), so range reads −3.0 K. Nothing in the graph knows a negative range is impossible, and the rule reports the strongest possible collapse on a tower that may be running perfectly. The tower dictionary’s loop-side note is the check: leaving is the COLD side.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 7200
  },
  "scenarios": [
    {
      "name": "healthy_range",
      "description": "Entering 32.0 \u00b0C, leaving 29.0 \u00b0C \u2014 a 3.0 K range, inside the 2.2-3.2 K healthy p50 band the 4-climate simulation study found, with the fan modulating at 60%. Evaluated and silent.",
      "inputs": {
        "tower_entering_temp": 32.0,
        "tower_leaving_temp": 29.0,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "range_collapsed",
      "description": "Entering 30.0 \u00b0C, leaving 29.2 \u00b0C \u2014 a 0.8 K range against the 1.0 K band, with the fan at 60%. The alarm lands at exactly alarm_delay because delayOnInit holds the condition from the first tick.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.2,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "fan_below_eval_floor",
      "description": "The same collapsed 0.8 K range with the fan idling at 20%. A tower whose fan is barely turning rejects little heat and its range says nothing about the condenser loop, which is what yFanOk = false tells the host: NO_EVAL, not a healthy tower.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.2,
        "tower_fan_speed": 20.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "range_exactly_at_the_band",
      "description": "Boundary, bit-exact: 30.0 \u2212 29.0 is exactly 1.0 in IEEE-754 and range_low_band is exactly 1.0. Reals.LessThreshold is strict, so exactly-on-the-band reads healthy.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.0,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "range_just_below_the_band",
      "description": "Boundary from below: 30.0 \u2212 29.01 = 0.99 K clears the strict comparison by 10 mK and alarms on the normal schedule.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.01,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "range_just_above_the_band",
      "description": "Boundary from above: 30.0 \u2212 28.99 = 1.01 K never alarms, however marginal the loop is. Ten milli-kelvin is an order of magnitude inside any condenser-water sensor's error, which is the point of the placeholder warning on the band.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 28.99,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "fan_speed_exactly_at_the_floor",
      "description": "Boundary on the other conjunct: the fan sits at exactly min_fan_speed_for_eval (30%) with the range collapsed. Reals.GreaterThreshold is strict, so exactly-at-the-floor is NO_EVAL and the rule stays silent.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.2,
        "tower_fan_speed": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "fan_speed_just_above_the_floor",
      "description": "30.1% with the same collapsed range: evaluable, and the alarm lands at exactly alarm_delay. One tenth of a percent of fan speed is the whole difference between this scenario and the previous one.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.2,
        "tower_fan_speed": 30.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "range_collapses_mid_run",
      "description": "A tower holding a healthy 3.0 K range loses it at t = 1800 s \u2014 a second condenser pump starting, or a bypass valve opening. The alarm lands at exactly 5400 s: the mid-run rising edge carries the same T + delayTime arithmetic as the init case.",
      "inputs": {
        "tower_entering_temp": 32.0,
        "tower_leaving_temp": [
          {
            "t": 0,
            "value": 29.0
          },
          {
            "t": 1800,
            "value": 31.2
          }
        ],
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "range_recovers_after_alarm",
      "description": "Recovery: the alarm asserts at 3600 s and the range comes back at t = 5400 s when the extra pump stops. TrueDelay passes the falling edge with no delay, so yFault drops on that tick.",
      "inputs": {
        "tower_entering_temp": 32.0,
        "tower_leaving_temp": [
          {
            "t": 0,
            "value": 31.2
          },
          {
            "t": 5400,
            "value": 29.0
          }
        ],
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "fan_slows_after_alarm",
      "description": "The evaluability release: an alarming tower drops to 15% fan at t = 5400 s with the collapsed range unchanged. yFault and yFanOk fall on the same tick, and only the pair tells the host that the tower unloaded rather than that the range recovered.",
      "inputs": {
        "tower_entering_temp": 30.0,
        "tower_leaving_temp": 29.2,
        "tower_fan_speed": [
          {
            "t": 0,
            "value": 60.0
          },
          {
            "t": 5400,
            "value": 15.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 5340,
          "equals": true
        },
        {
          "output": "yFanOk",
          "from_s": 5400,
          "to_s": 7200,
          "equals": false
        }
      ]
    },
    {
      "name": "intermittent_collapse_never_alarms",
      "description": "Range alternating between 0.8 K and 3.0 K every 1200 s \u2014 a condenser pump staging on and off, or a chiller cycling. No single episode reaches alarm_delay, so nothing fires. Persistence is not averaging: a loop spending half its day at 0.8 K range is a real finding this rule cannot make.",
      "inputs": {
        "tower_entering_temp": 32.0,
        "tower_leaving_temp": [
          {
            "t": 0,
            "value": 31.2
          },
          {
            "t": 1200,
            "value": 29.0
          },
          {
            "t": 2400,
            "value": 31.2
          },
          {
            "t": 3600,
            "value": 29.0
          },
          {
            "t": 4800,
            "value": 31.2
          },
          {
            "t": 6000,
            "value": 29.0
          }
        ],
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7200,
          "equals": false
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    },
    {
      "name": "loop_side_semantics_inverted",
      "description": "Blind spot, pinned: the two water sensors are bound the wrong way round (entering 29.0 \u00b0C, leaving 32.0 \u00b0C), so range reads \u22123.0 K. Nothing in the graph knows a negative range is impossible, and the rule reports the strongest possible collapse on a tower that may be running perfectly. The tower dictionary's loop-side note is the check: leaving is the COLD side.",
      "inputs": {
        "tower_entering_temp": 29.0,
        "tower_leaving_temp": 32.0,
        "tower_fan_speed": 60.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 7200,
          "equals": true
        },
        {
          "output": "yFanOk",
          "from_s": 0,
          "to_s": 7200,
          "equals": true
        }
      ]
    }
  ]
}