RTU-0002 — Evaporator coil fouling — reduced temperature split
| Status | verified — engine e2ff2f8, cxf:fnv1a128:da9a9817ba72e7a56a69ec6f4e2c7e9b, 2026-08-17 |
| Severity | 3 |
| Method | statistical |
| Phase | 2 |
| Category | EFFICIENCY_LOSS |
| Confidence | MEDIUM |
| Estimation | BASELINE_COMPARISON |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | RTU-0003 |
| Related | RTU-0003, RTU-0007, RTU-0010, RTU-0011 |
| Playbooks | rtu-compressor-refrigerant |
| Source | HVAC FDD Reference v1.0 §11, RTU-0002; Catrini & Piacentino 2023; Ebrahimifakhar et al. 2020 |
| Operating states | mechanical 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_delayyStageOk— 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:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
split_degradation_threshold | 0.25 | 1 | ratioHigh.t | Fractional shortfall of the measured split against the stage baseline that counts as fouling (0.25 = 25% below baseline) |
baseline_split_stage_1 | 8.0 | °C | base1.k | Expected rat − sat with one compressor stage running on a clean coil at design airflow |
baseline_split_stage_2 | 12.0 | °C | base2.k | Expected rat − sat with two compressor stages running on a clean coil at design airflow |
alarm_delay | 3600.0 | s | persist.delayTime | Continuous 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):
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
- 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
- Low refrigerant charge from a leak, which shrinks the split the same way
- Evaporator fan motor or drive degradation cutting airflow — belt slip, a failing motor, or a dirty blower wheel
- 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-endedbaseline_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, rebindingbase1,base2,kTwo.kand both integer bounds together. WideningstageKnown.talone 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 atcomp_stage = 0would 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-minutealarm_delaydoes 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: statisticaldescribes 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 noGreaterEqual, 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.tis0.25, matching the dimensionless quotient the graph computes. A host that set this parameter to25would disable the rule, so the card declares its unit as1. persist.delayOnInit = true(CDL default isfalse): 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.
| Scenario | Description |
|---|---|
stage1_healthy | Stage 1 at its 8 °C baseline split (rat 24 → sat 16): zero degradation, evaluable |
stage1_fouled | Stage 1 split collapsed to 5.5 °C (rat 24 → sat 18.5) = 31% below baseline; alarms after alarm_delay |
stage2_healthy | Stage 2 at its 12 °C baseline split (rat 26 → sat 14): zero degradation |
stage1_split_7_is_healthy | Baseline 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_fouled | Baseline 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_evaluable | 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 |
stage3_not_evaluable | A 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_threshold | Threshold edge: a 6 °C split at stage 1 is exactly 25% below baseline; the comparison is strict, so no fault |
edge_degradation_just_over_threshold | Threshold 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_alarms | The 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_persistence | A 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_cleaning | Stage-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_eval | 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 |
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
}
]
}
]
}