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

RTU-0002 — Evaporator coil fouling — reduced temperature split

Statusverified — engine e2ff2f8, cxf:fnv1a128:da9a9817ba72e7a56a69ec6f4e2c7e9b, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEFFICIENCY_LOSS
ConfidenceMEDIUM
EstimationBASELINE_COMPARISON
G36
Clusters
Suppresses
Suppressed byRTU-0003
RelatedRTU-0003, RTU-0007, RTU-0010, RTU-0011
Playbooksrtu-compressor-refrigerant
SourceHVAC FDD Reference v1.0 §11, RTU-0002; Catrini & Piacentino 2023; Ebrahimifakhar et al. 2020
Operating statesmechanical cooling, one or two compressor stages

Preconditions (host-enforced): The compressor must have run at its current stage for min_runtime_for_eval (15 min) before the split is read; the coil is still pulling down after a start or a stage change and reads falsely degraded. sat must pass its integrity gate: RTU-0003 (see suppressed_by) checks it against mat and silences this rule while it is active. Nothing validates rat on its own, so a drifted return-air sensor biases the split with no other symptom. Stage evaluability is signalled in-rule by yStageOk; when it is false the verdict is NO_EVAL, not healthy.

Points: rat, sat, comp_stage

Outputs:

  • yFault — True while the measured temperature split has stayed more than split_degradation_threshold below the baseline for the running stage, for at least alarm_delay
  • yStageOk — Evaluability signal — true when comp_stage is 1 or 2, the stages this rule carries baselines for; false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
split_degradation_threshold0.251ratioHigh.tFractional shortfall of the measured split against the stage baseline that counts as fouling (0.25 = 25% below baseline)
baseline_split_stage_18.0°Cbase1.kExpected rat − sat with one compressor stage running on a clean coil at design airflow
baseline_split_stage_212.0°Cbase2.kExpected rat − sat with two compressor stages running on a clean coil at design airflow
alarm_delay3600.0spersist.delayTimeContinuous degradation required before the alarm asserts (60 min)

Description

A clean evaporator coil at design airflow drops the air passing through it by a predictable amount — roughly 8 °C on one compressor stage, 12 °C on two. When that split shrinks while the same stage runs, the coil is no longer moving the heat it should: dust bridging the fins, a loaded filter, ice, or a charge that has leaked away all produce the same reading, and they cost the same way — the unit runs longer for the same cooling, and where the cause is restricted airflow the fan spends more energy per unit of air delivered. Two temperatures and a stage number is the whole measurement, which is what makes it practical on packaged equipment carrying no refrigerant instrumentation, and also why the rule is blind to cause. Catrini & Piacentino (2023) measured 13.3% capacity loss and up to 47% additional fan power on fouled units.

Detection Logic

actual_split   = rat − sat
expected_split = baseline_split_stage_2  if comp_stage = 2
                 baseline_split_stage_1  otherwise
degradation    = (expected_split − actual_split) / expected_split

yStageOk = comp_stage > 0 AND comp_stage ≤ 2       (false ⇒ host reports NO_EVAL)
yFault   = degradation > split_degradation_threshold AND yStageOk,
           sustained for alarm_delay

Block graph (rule.cxf.jsonld):

RTU-0002 block graph

Both baselines are live on every tick and expected selects one, so the baseline can change under the rule mid-run when the unit stages. Because that denominator is always a selected constant — 8 or 12, never zero, never noisy — the division is safe by construction and needs no divide-by-zero branch. yStageOk answers a different question: which stages does the rule carry baselines for? Outside 1–2 it holds yFault down, and that false means “not evaluated”, not “coil is clean”. Stage 0 is the case that matters in practice — with no compressor running, rat − sat collapses toward zero and reads as near-total degradation. stagePos carries no t node because CDL’s default integer threshold is already the 0 this test wants. The comparison is strict, so a split exactly 25% below baseline is not a fault and 25.1% is. persist requires 60 continuous minutes, long enough to ride out swings in return air and to let the split settle after a stage-up; delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. Evaporator coil fouled — dust and lint bridging the fins, usually downstream of a filter that was never changed; a loaded filter alone gives the same reading and is the cheapest thing on this list to rule out
  2. Low refrigerant charge from a leak, which shrinks the split the same way
  3. Evaporator fan motor or drive degradation cutting airflow — belt slip, a failing motor, or a dirty blower wheel
  4. Iced evaporator coil, itself usually a symptom of low charge or low airflow

Energy Impact

EFFICIENCY_LOSS, MEDIUM confidence, BASELINE_COMPARISON. The degradation fraction the rule already computes is the estimator: waste_kw = (expected_split − actual_split) / expected_split × rtu_kw, treating the capacity shortfall as proportional extra runtime at the unit’s rated draw. Catrini & Piacentino (2023) put the measured effect at 13.3% capacity reduction and as much as 47% additional fan power on airflow-restricted cases; PNNL EEM-23 (advanced RTU controls) is the related retrofit package. Confidence is MEDIUM because the baselines are population values, not this unit’s commissioned performance.

Emissions Impact

Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 300–2,000 kg CO₂e/yr for a commercial packaged unit, scaling with tonnage and cooling hours. Emissions follow the added compressor and fan electricity, so the avoided-emissions basis is the marginal operating emissions rate (MOER) — fouling costs most on hot afternoons, when the grid is dirtiest and the unit runs longest.

Deviations

  • The per-stage baseline function is a two-way Switch, not a lookup. The reference writes an open-ended baseline_split_for_stage(comp_stage); the block set has no integer-keyed table and the reference supplies exactly two baselines. A host with three or more stages instantiates the rule once per stage pair, rebinding base1, base2, kTwo.k and both integer bounds together. Widening stageKnown.t alone is the trap: it removes the NO_EVAL signal while stage 3 still falls through to the stage-1 baseline.
  • Stage evaluability is an output, not just a precondition. The stage-range test is computable from this rule’s own inputs, so per SCHEMA.md it is exposed as yStageOk. A rule that silently returned false at comp_stage = 0 would be reporting a healthy coil on a unit that is not cooling at all.
  • min_runtime_for_eval (15 min) stays a host precondition. It gates on time since the last stage change, which the block graph cannot see, and this library keeps state gating host-side. The 60-minute alarm_delay does not substitute for it: pull-down after a stage change starts the persistence timer rather than being excluded from it, so a coil taking 20 minutes to settle spends a third of the alarm window looking fouled.
  • method: statistical describes the provenance of the baselines, not the graph. At runtime the graph does one subtraction, one division and one comparison. The classification is the reference’s and it is fair — the 8/12 °C baselines are population values from the fouling literature rather than a commissioned measurement of the unit in front of you.
  • Strict > at the degradation threshold, where the reference’s playbook is inclusive (“a 25% or greater reduction in split indicates fouling”). CDL Reals has no GreaterEqual, so the strict form is the expressible one and a split exactly 25% below baseline reads healthy. The disagreement is measure-zero on a real-valued signal; both sides are pinned by vectors.
  • The threshold is carried as a fraction, not a percentage. The reference writes 25%; ratioHigh.t is 0.25, matching the dimensionless quotient the graph computes. A host that set this parameter to 25 would disable the rule, so the card declares its unit as 1.
  • persist.delayOnInit = true (CDL default is false): a coil already degraded when the controller starts waits out the full hour rather than alarming on the first tick.

Notes

Start at the filter: it reproduces the fouled-coil signature exactly and costs minutes to rule out. If a fresh filter does not restore the split, the question is airflow versus refrigerant, and the two separate at the unit — airflow shows in static pressure across the coil, charge shows in superheat and subcooling at the service ports. Neither is visible from the points this rule reads.

The rtu-compressor-refrigerant playbook orders the remediation (filter, coil cleaning, fan motor, ice) and tests resolution at the split returning to within 15% of baseline — tighter than the 25% this rule alarms at, so a coil cleaned back to 20% degraded clears the alarm without being fixed. RTU-0007 (condenser airflow restriction) is the condenser-side counterpart: its stage-and-OAT baseline ships as the host-fitted point cond_split_baseline, and its own resolution-vs-alarm gap mirrors this one.

Test Vectors

13 scenarios, clock step 300 s over 9000 s.

ScenarioDescription
stage1_healthyStage 1 at its 8 °C baseline split (rat 24 → sat 16): zero degradation, evaluable
stage1_fouledStage 1 split collapsed to 5.5 °C (rat 24 → sat 18.5) = 31% below baseline; alarms after alarm_delay
stage2_healthyStage 2 at its 12 °C baseline split (rat 26 → sat 14): zero degradation
stage1_split_7_is_healthyBaseline selection, low side: a 7 °C split at stage 1 is 12.5% below the 8 °C baseline and clears. Against the stage-2 baseline the same split would read 42% degraded, so this pins that the Switch took the stage-1 leg
stage2_split_7_is_fouledBaseline selection, high side: the identical 7 °C split at stage 2 is 42% below the 12 °C baseline and alarms — two compressors’ worth of capacity producing one stage’s worth of cooling
stage0_not_evaluableCompressor off: rat and sat are equal because no coil is running, which reads as 100% degradation against the stage-1 baseline. yStageOk is false, so yFault stays down and the host reports NO_EVAL rather than healthy
stage3_not_evaluableA third stage on a unit this rule has no baseline for: the split (4 °C) looks badly degraded against the stage-1 constant, but comp_stage > 2 is outside the rule’s calibration, so yStageOk is false and no verdict is issued
edge_degradation_equals_thresholdThreshold edge: a 6 °C split at stage 1 is exactly 25% below baseline; the comparison is strict, so no fault
edge_degradation_just_over_thresholdThreshold edge: a 5.9 °C split at stage 1 is 26.25% below baseline and clears the strict comparison, alarming after alarm_delay
transient_low_split_never_alarmsThe split dips to 5.5 °C for 1800 s — a brief capacity loss such as a head-pressure control step or a momentary airflow disturbance — and recovers before alarm_delay elapses; the timer resets
stage_change_restarts_persistenceA 7 °C split that is healthy on stage 1 becomes a fault the moment the unit stages up at t = 1800 s; the full 60 min runs from the stage change, not from the start of the low split
fault_clears_after_coil_cleaningStage-1 fouling alarms at 3600 s; the split returns to 8 °C at t = 5400 s (filter changed, coil washed) and the alarm drops on that tick
compressor_stop_forces_no_evalA matured stage-1 fault is not reported as cleared when the compressor stops: yStageOk goes false at t = 5400 s and yFault drops with it, which is NO_EVAL, not a repair
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 9000
  },
  "scenarios": [
    {
      "name": "stage1_healthy",
      "description": "Stage 1 at its 8 \u00b0C baseline split (rat 24 \u2192 sat 16): zero degradation, evaluable",
      "inputs": {
        "rat": 24.0,
        "sat": 16.0,
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "stage1_fouled",
      "description": "Stage 1 split collapsed to 5.5 \u00b0C (rat 24 \u2192 sat 18.5) = 31% below baseline; alarms after alarm_delay",
      "inputs": {
        "rat": 24.0,
        "sat": 18.5,
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "stage2_healthy",
      "description": "Stage 2 at its 12 \u00b0C baseline split (rat 26 \u2192 sat 14): zero degradation",
      "inputs": {
        "rat": 26.0,
        "sat": 14.0,
        "comp_stage": 2
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "stage1_split_7_is_healthy",
      "description": "Baseline selection, low side: a 7 \u00b0C split at stage 1 is 12.5% below the 8 \u00b0C baseline and clears. Against the stage-2 baseline the same split would read 42% degraded, so this pins that the Switch took the stage-1 leg",
      "inputs": {
        "rat": 24.0,
        "sat": 17.0,
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "stage2_split_7_is_fouled",
      "description": "Baseline selection, high side: the identical 7 \u00b0C split at stage 2 is 42% below the 12 \u00b0C baseline and alarms \u2014 two compressors' worth of capacity producing one stage's worth of cooling",
      "inputs": {
        "rat": 24.0,
        "sat": 17.0,
        "comp_stage": 2
      },
      "expect": [
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "stage0_not_evaluable",
      "description": "Compressor off: rat and sat are equal because no coil is running, which reads as 100% degradation against the stage-1 baseline. yStageOk is false, so yFault stays down and the host reports NO_EVAL rather than healthy",
      "inputs": {
        "rat": 24.0,
        "sat": 24.0,
        "comp_stage": 0
      },
      "expect": [
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "stage3_not_evaluable",
      "description": "A third stage on a unit this rule has no baseline for: the split (4 \u00b0C) looks badly degraded against the stage-1 constant, but comp_stage > 2 is outside the rule's calibration, so yStageOk is false and no verdict is issued",
      "inputs": {
        "rat": 24.0,
        "sat": 20.0,
        "comp_stage": 3
      },
      "expect": [
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_degradation_equals_threshold",
      "description": "Threshold edge: a 6 \u00b0C split at stage 1 is exactly 25% below baseline; the comparison is strict, so no fault",
      "inputs": {
        "rat": 24.0,
        "sat": 18.0,
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_degradation_just_over_threshold",
      "description": "Threshold edge: a 5.9 \u00b0C split at stage 1 is 26.25% below baseline and clears the strict comparison, alarming after alarm_delay",
      "inputs": {
        "rat": 24.0,
        "sat": 18.1,
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_low_split_never_alarms",
      "description": "The split dips to 5.5 \u00b0C for 1800 s \u2014 a brief capacity loss such as a head-pressure control step or a momentary airflow disturbance \u2014 and recovers before alarm_delay elapses; the timer resets",
      "inputs": {
        "rat": 24.0,
        "sat": [
          {
            "t": 0,
            "value": 16.0
          },
          {
            "t": 900,
            "value": 18.5
          },
          {
            "t": 2700,
            "value": 16.0
          }
        ],
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "stage_change_restarts_persistence",
      "description": "A 7 \u00b0C split that is healthy on stage 1 becomes a fault the moment the unit stages up at t = 1800 s; the full 60 min runs from the stage change, not from the start of the low split",
      "inputs": {
        "rat": 24.0,
        "sat": 17.0,
        "comp_stage": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 1800,
            "value": 2
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "fault_clears_after_coil_cleaning",
      "description": "Stage-1 fouling alarms at 3600 s; the split returns to 8 \u00b0C at t = 5400 s (filter changed, coil washed) and the alarm drops on that tick",
      "inputs": {
        "rat": 24.0,
        "sat": [
          {
            "t": 0,
            "value": 18.5
          },
          {
            "t": 5400,
            "value": 16.0
          }
        ],
        "comp_stage": 1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "compressor_stop_forces_no_eval",
      "description": "A matured stage-1 fault is not reported as cleared when the compressor stops: yStageOk goes false at t = 5400 s and yFault drops with it, which is NO_EVAL, not a repair",
      "inputs": {
        "rat": 24.0,
        "sat": 18.5,
        "comp_stage": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 5400,
            "value": 0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yStageOk",
          "from_s": 0,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yStageOk",
          "from_s": 5400,
          "to_s": 9000,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": false
        }
      ]
    }
  ]
}