HP-0001 — COP degradation vs baseline
| Status | verified — engine e2ff2f8, cxf:fnv1a128:4e34c94f444594a4a9ccdce25c4eebef, 2026-08-17 |
| Severity | 3 |
| Method | statistical |
| Phase | 2 |
| Category | EFFICIENCY_LOSS |
| Confidence | MEDIUM |
| Estimation | BASELINE_COMPARISON |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | HP-0002, HP-0003, HP-0004, HP-0005, RTU-0002, HP-0007, HP-0008 |
| Playbooks | heat-pump-faults |
| Source | HVAC FDD Reference v1.0 §11, HP-0001; Barandier 2023; Barandier & Mendes 2024 |
| Operating states | heating or cooling, evaluated separately — one rule instance per mode, each carrying that mode’s fitted line |
Preconditions (host-enforced): The compressor must have run for min_runtime_for_eval (15 min) at its current capacity before the quotient means anything; a unit still pulling down after a start, or coming out of a defrost cycle, reads degraded on physics rather than on fault. The host owns the baseline: it runs the learning_period_days (14 d) regression of COP against oat for THIS mode, confirms R² > 0.6, and writes the result into cop_baseline_slope and cop_baseline_intercept with set_param. Until it has done so the rule is comparing against the shipped placeholders and means nothing (see Deviations). oat must also lie inside the range the line was fitted over — the graph extrapolates the line forever and says nothing about where the fit stops being physical. thermal_power is almost always a host-computed virtual point; its provenance is part of the R² precondition, not separate from it. Compressor evaluability is signalled in-rule by yPowerOk; when it is false the verdict is NO_EVAL, not healthy.
Points: thermal_power, elec_power, oat
Outputs:
yFault— True while the measured COP has stayed below cop_ratio_threshold × the fitted baseline for the current outdoor temperature, continuously for at least alarm_delayyPowerOk— Evaluability signal — true when elec_power is above elec_power_min, the floor below which the COP quotient is meaningless; false means NO_EVAL and the host must ignore yFault
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
cop_baseline_slope | 0.08 | 1/°C | oatSlope.k | Slope of the host-fitted COP-vs-oat regression. PER-UNIT, PER-MODE SITE CONFIGURATION — the reference supplies a learned model, not a number, and the shipped 0.08 is a placeholder for a generic air-source heat pump in heating. Inherently signed; a cooling-mode fit is negative. |
cop_baseline_intercept | 2.7 | 1 | expected.p | Intercept of the same regression — the expected COP at oat = 0 °C. PER-UNIT, PER-MODE SITE CONFIGURATION on the same terms as the slope; the pair is only meaningful together. |
cop_ratio_threshold | 0.85 | 1 | allowed.k | Fraction of the baseline COP the unit must stay above. 0.85 is the reference’s 15% degradation threshold written as a ratio: fault when measured < 0.85 × expected. |
elec_power_min | 0.5 | kW | pwrOk.t | Compressor draw below which the COP quotient is not evaluated. Guards the division — at zero draw the quotient is NaN, and at a standby trickle it reads as total degradation. Retune to the smallest real compressor draw the unit produces at minimum capacity. |
alarm_delay | 3600.0 | s | persist.delayTime | Continuous degradation required before the alarm asserts (60 min) |
Description
A heat pump has no COP it is supposed to hold — the same machine returns four
units of heat per unit of electricity on a mild afternoon and two on a cold
night, and neither number is a fault. What it has, per unit and per mode, is a
line: COP against outdoor air temperature, fitted by the host over two weeks of
normal operation. This rule evaluates that line, alarming when measured COP
stays below 85% of what the line predicts at today’s oat. Everything
statistical happens before the first tick. Barandier (2023) found refrigerant
undercharge the most frequent heat pump fault, and it is what this rule sees
best: COP falls across the whole operating range while no single reading looks
wrong on its own.
Detection Logic
measured_cop = thermal_power / elec_power
expected_cop = cop_baseline_slope × oat + cop_baseline_intercept
allowed_cop = cop_ratio_threshold × expected_cop
yPowerOk = elec_power > elec_power_min (false ⇒ host reports NO_EVAL)
yFault = measured_cop < allowed_cop AND yPowerOk,
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
oatSlope and expected are the fitted line; allowed scales it by the
tolerance, so the test is against a second line parallel to the first rather
than against a number.
cop is the rule’s only division and its denominator goes to zero every time
the compressor stops; pwrOk guards it. With both meters at zero the quotient
is NaN; with a standby trickle and no heat output it is a clean, believable
0.0 — the more dangerous case, since nothing downstream would flag it. pwrOk
drives both the boundary output yPowerOk and gate, so a unit below the floor
holds yFault down and the host reads the silence as NO_EVAL, not healthy.
The comparison is strict, so a unit exactly on the allowed line reads healthy.
persist requires 60 continuous minutes of shortfall — long enough to ride out
a defrost cycle, a capacity step, or a load transient — and delayOnInit = true
holds that window across a controller restart.
Possible Diagnoses
- Refrigerant undercharge — the most common heat pump fault (Barandier 2023); check subcooling and superheat at the service ports first
- Refrigerant overcharge, which degrades COP the same way and is less common
- Condenser or evaporator coil fouling — the pathology RTU-0002 detects from the air side; rule it out with a filter change before opening anything up
- Compressor degradation — worn valves or bearings raise amp draw against nameplate for the same delivered capacity
- Non-condensable gases in the refrigerant circuit, usually from a service procedure that skipped or shortened the evacuation
Energy Impact
EFFICIENCY_LOSS, MEDIUM confidence, BASELINE_COMPARISON.
waste_kw = elec_power × (1 − measured_cop / expected_cop) — the share of the
compressor’s draw that buys nothing. Range 5–25% of compressor energy
(Barandier 2023), spanning a small charge loss at one end and a badly fouled
coil or failing compressor at the other. MEDIUM for a structural reason: the
baseline is the unit’s own recent behavior, so the rule measures degradation
since the learning period — a heat pump commissioned undercharged learns an
undercharged baseline and reads healthy forever.
Emissions Impact
Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 300–2,500 kg CO₂e/yr for a commercial packaged heat pump. All of it is compressor electricity, so the avoided-emissions basis is the marginal operating emissions rate (MOER). Degradation costs most at the extremes of the outdoor temperature range, when the unit runs longest and the grid is dirtiest, so a charge correction is worth more than the annual average kWh figure suggests.
Deviations
cop_baseline_slopeandcop_baseline_interceptship as placeholders, not site values. The reference specifies a regression the host fits (14 days, R² > 0.6); the fitting lives in the host, the fitted line in the graph as twoset_paramtargets. The shipped 0.08 /°C and 2.7 describe a generic air-source heat pump in heating so the card is runnable as delivered — a wrong pair fails silently: fit high and every hour alarms, fit low and nothing ever does.- A negative
cop_baseline_slopeis the documented exception to the library’s no-negative-parameters rule. A regression slope is inherently signed — positive for a heating fit, negative for a cooling fit — and one instance must accept either without being rewired; pushing the sign into the topology would mean two graphs for one fault. - The degradation test is a ratio, not the reference’s fraction. The
reference writes
(expected_cop − measured_cop) / expected_cop > 0.15; this computesmeasured_cop < 0.85 × expected_cop, the same predicate for any positiveexpected_copand without a second division by a fitted line. Note the units:cop_ratio_thresholdis the fraction retained (0.85), not the percentage lost — writing15into it makes the rule alarm permanently. elec_power_minandyPowerOkare adopted, not transcribed. The reference names no power floor; its evaluability gate ismin_runtime_for_eval, which the graph cannot see, and per SCHEMA.md a test computable from the rule’s own inputs belongs in the graph as a boundary output. The 0.5 kW default suits a small commercial packaged unit — raise it for a large compressor, lower it for a variable-speed unit that genuinely modulates below it.min_runtime_for_eval(15 min) andlearning_period_days(14 d) stay host preconditions. Both gate on things outside the graph’s view: time since a capacity transition, and an offline fitting run. The 60-minutealarm_delaycovers post-start pull-down in steady operation but does not substitute — a unit that takes 20 minutes to settle spends a third of the window degraded.- The fitted line is extrapolated without limit. Nothing in the graph knows
the temperature range the regression covered; far enough out a cooling-mode
line goes negative,
allowedwith it, every positive measured COP clears and the rule goes quiet. No block expresses a domain guard, so it is a frontmatter precondition; a host can clampoatwithReals.Limiterupstream. - Mode separation is instance-level. The reference evaluates heating and cooling separately and this rule has no mode input: one line, one alarm. A reversible unit runs two instances with two fitted pairs, and the host enables whichever matches the mode currently commanded.
- Strict
<at the allowance, where the playbook reads inclusively. The heat pump playbook’s step 1.a calls a 15% or greater drop below the baseline curve degradation, but CDL Reals has noLessEqual, so the strict form is the expressible one and a unit sitting exactly on the allowed line reads healthy. Both sides of the boundary are pinned; the disagreement is measure-zero. method: statisticaldescribes the baseline’s provenance, not the runtime. The graph performs one division, one multiply-add, one scale and one comparison. The coefficients come from a regression and the reference’s classification is recorded rather than relabelled; RTU-0002 carries the same note.persist.delayOnInit = true(CDL default isfalse), the library’s standing choice: a unit already below its line at controller start waits out the full hour rather than alarming on the first tick.- Operating states and preconditions are declared in frontmatter for host
enforcement rather than encoded in the block graph. Severity 3 and
method: statisticalare the reference’s chapter 11 card; its §5.8.4 index carries no severity column. - The reference publishes no test vectors, so every scenario in
vectors.jsonis authored from the equation and replayed against the pinned engine rev.
Notes
Read yPowerOk before yFault. A repair and a compressor stop are
indistinguishable in yFault alone — both drop the alarm — so a host that
treats the falling edge as a fix will close this fault every time the unit
finishes a cycle. The
heat-pump-faults playbook orders the
on-site work by prevalence and calls the fault resolved when COP returns to
within 10% of the baseline, tighter than the 15% this rule alarms at. Do not
re-fit the baseline while the fault is active: fitting from a degraded unit’s
own history bakes the fault in as the new normal.
Test Vectors
13 scenarios, clock step 300 s over 9000 s.
| Scenario | Description |
|---|---|
on_the_baseline_line | oat 10 °C puts the fitted baseline at 0.08 × 10 + 2.7 = 3.5. The unit delivers 7.0 kW thermal for 2.0 kW electrical, so measured COP is exactly 3.5 — no degradation at all, and the compressor is well above the evaluability floor. |
degraded_at_mild_oat | Same 10 °C day, same 2.0 kW draw, but only 5.6 kW of heat delivered: COP 2.8 against a 3.5 baseline is a 20% shortfall, past the 15% the reference allows. Both terms hold from t=0, so delayOnInit puts the alarm one alarm_delay in. |
same_cop_is_healthy_on_a_cold_day | Baseline slope, low side: the identical COP 2.8 at oat −5 °C sits against a 2.3 baseline and is 22% ABOVE it. A heat pump is supposed to do worse when it is cold, and the fitted line is what says how much worse. Pairs with degraded_at_mild_oat — wire the slope backwards and one of the two flips. |
same_cop_is_a_fault_on_a_warm_day | Baseline slope, high side: COP 3.5 was exactly on the line at 10 °C and is a fault at 20 °C, where the baseline has risen to 4.3 and the allowance to 3.655. Same two power readings, opposite verdicts, because the outdoor temperature moved. |
cold_day_genuinely_degraded | A real cold-weather fault: at oat −5 °C the baseline is 2.3 and the allowance 1.955, and the unit manages COP 1.8 (3.6 kW out of 2.0 kW in) — 22% below the line. Degradation is judged against the cold-weather expectation, not against a nameplate rating. |
cop_exactly_at_the_allowance | Threshold edge: 5.95 kW over 2.0 kW is COP 2.975, exactly cop_ratio_threshold × expected (0.85 × 3.5). The comparison is a strict <, so a unit sitting precisely on the allowance reads healthy. Both operands are binary-exact here, so this is a true boundary pin and not a near miss. |
cop_just_below_the_allowance | Threshold edge from the other side: 5.93 kW gives COP 2.965, one hundredth under the allowance, and the alarm lands after alarm_delay. |
elec_power_exactly_at_the_floor | Evaluability edge: the compressor draws exactly elec_power_min (0.5 kW) and delivers nothing, which reads as COP 0 — total degradation. The floor test is a strict >, so yPowerOk is false and yFault stays down: the host must report NO_EVAL, not a healthy heat pump. |
elec_power_just_above_the_floor | Evaluability edge from the other side: 0.51 kW clears the floor, so the same COP 0 is now a verdict the rule is willing to issue and the alarm matures. The two scenarios differ by 10 W of compressor draw and disagree completely — which is the point of publishing yPowerOk rather than burying the test. |
compressor_off_divide_by_zero | The case the floor exists for: the unit is off, both meters read 0.0, and the quotient is 0/0 = NaN. yPowerOk is false and yFault never asserts. Without the floor this rule would spend every idle hour reporting a catastrophically degraded heat pump. |
transient_dip_never_alarms | Thermal output drops to 5.6 kW for 1800 s — a defrost cycle, or a load step the capacity control has not caught up with — and recovers 1800 s short of alarm_delay. The timer resets on the recovery and nothing is reported. |
alarm_clears_after_charge_restored | Recovery: a 20% shortfall alarms at 3600 s, the technician tops up the refrigerant charge at t = 5400 s (playbook step 2.1.1), and thermal output returns to 7.0 kW. yFault drops on that same tick — TrueDelay only delays the rising edge. |
compressor_stop_forces_no_eval | A matured fault is not reported as repaired when the compressor stops: at t = 5400 s both meters fall to standby, yPowerOk goes false and yFault drops with it. The distinction between this scenario and alarm_clears_after_charge_restored is invisible in yFault alone and is exactly what yPowerOk carries. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 9000
},
"scenarios": [
{
"name": "on_the_baseline_line",
"description": "oat 10 \u00b0C puts the fitted baseline at 0.08 \u00d7 10 + 2.7 = 3.5. The unit delivers 7.0 kW thermal for 2.0 kW electrical, so measured COP is exactly 3.5 \u2014 no degradation at all, and the compressor is well above the evaluability floor.",
"inputs": {
"oat": 10.0,
"thermal_power": 7.0,
"elec_power": 2.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
},
{
"output": "yPowerOk",
"from_s": 0,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "degraded_at_mild_oat",
"description": "Same 10 \u00b0C day, same 2.0 kW draw, but only 5.6 kW of heat delivered: COP 2.8 against a 3.5 baseline is a 20% shortfall, past the 15% the reference allows. Both terms hold from t=0, so delayOnInit puts the alarm one alarm_delay in.",
"inputs": {
"oat": 10.0,
"thermal_power": 5.6,
"elec_power": 2.0
},
"expect": [
{
"output": "yPowerOk",
"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": "same_cop_is_healthy_on_a_cold_day",
"description": "Baseline slope, low side: the identical COP 2.8 at oat \u22125 \u00b0C sits against a 2.3 baseline and is 22% ABOVE it. A heat pump is supposed to do worse when it is cold, and the fitted line is what says how much worse. Pairs with degraded_at_mild_oat \u2014 wire the slope backwards and one of the two flips.",
"inputs": {
"oat": -5.0,
"thermal_power": 5.6,
"elec_power": 2.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "same_cop_is_a_fault_on_a_warm_day",
"description": "Baseline slope, high side: COP 3.5 was exactly on the line at 10 \u00b0C and is a fault at 20 \u00b0C, where the baseline has risen to 4.3 and the allowance to 3.655. Same two power readings, opposite verdicts, because the outdoor temperature moved.",
"inputs": {
"oat": 20.0,
"thermal_power": 7.0,
"elec_power": 2.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "cold_day_genuinely_degraded",
"description": "A real cold-weather fault: at oat \u22125 \u00b0C the baseline is 2.3 and the allowance 1.955, and the unit manages COP 1.8 (3.6 kW out of 2.0 kW in) \u2014 22% below the line. Degradation is judged against the cold-weather expectation, not against a nameplate rating.",
"inputs": {
"oat": -5.0,
"thermal_power": 3.6,
"elec_power": 2.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "cop_exactly_at_the_allowance",
"description": "Threshold edge: 5.95 kW over 2.0 kW is COP 2.975, exactly cop_ratio_threshold \u00d7 expected (0.85 \u00d7 3.5). The comparison is a strict `<`, so a unit sitting precisely on the allowance reads healthy. Both operands are binary-exact here, so this is a true boundary pin and not a near miss.",
"inputs": {
"oat": 10.0,
"thermal_power": 5.95,
"elec_power": 2.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "cop_just_below_the_allowance",
"description": "Threshold edge from the other side: 5.93 kW gives COP 2.965, one hundredth under the allowance, and the alarm lands after alarm_delay.",
"inputs": {
"oat": 10.0,
"thermal_power": 5.93,
"elec_power": 2.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "elec_power_exactly_at_the_floor",
"description": "Evaluability edge: the compressor draws exactly elec_power_min (0.5 kW) and delivers nothing, which reads as COP 0 \u2014 total degradation. The floor test is a strict `>`, so yPowerOk is false and yFault stays down: the host must report NO_EVAL, not a healthy heat pump.",
"inputs": {
"oat": 10.0,
"thermal_power": 0.0,
"elec_power": 0.5
},
"expect": [
{
"output": "yPowerOk",
"from_s": 0,
"to_s": 9000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "elec_power_just_above_the_floor",
"description": "Evaluability edge from the other side: 0.51 kW clears the floor, so the same COP 0 is now a verdict the rule is willing to issue and the alarm matures. The two scenarios differ by 10 W of compressor draw and disagree completely \u2014 which is the point of publishing yPowerOk rather than burying the test.",
"inputs": {
"oat": 10.0,
"thermal_power": 0.0,
"elec_power": 0.51
},
"expect": [
{
"output": "yPowerOk",
"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": "compressor_off_divide_by_zero",
"description": "The case the floor exists for: the unit is off, both meters read 0.0, and the quotient is 0/0 = NaN. yPowerOk is false and yFault never asserts. Without the floor this rule would spend every idle hour reporting a catastrophically degraded heat pump.",
"inputs": {
"oat": 10.0,
"thermal_power": 0.0,
"elec_power": 0.0
},
"expect": [
{
"output": "yPowerOk",
"from_s": 0,
"to_s": 9000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "transient_dip_never_alarms",
"description": "Thermal output drops to 5.6 kW for 1800 s \u2014 a defrost cycle, or a load step the capacity control has not caught up with \u2014 and recovers 1800 s short of alarm_delay. The timer resets on the recovery and nothing is reported.",
"inputs": {
"oat": 10.0,
"elec_power": 2.0,
"thermal_power": [
{
"t": 0,
"value": 7.0
},
{
"t": 900,
"value": 5.6
},
{
"t": 2700,
"value": 7.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "alarm_clears_after_charge_restored",
"description": "Recovery: a 20% shortfall alarms at 3600 s, the technician tops up the refrigerant charge at t = 5400 s (playbook step 2.1.1), and thermal output returns to 7.0 kW. yFault drops on that same tick \u2014 TrueDelay only delays the rising edge.",
"inputs": {
"oat": 10.0,
"elec_power": 2.0,
"thermal_power": [
{
"t": 0,
"value": 5.6
},
{
"t": 5400,
"value": 7.0
}
]
},
"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 fault is not reported as repaired when the compressor stops: at t = 5400 s both meters fall to standby, yPowerOk goes false and yFault drops with it. The distinction between this scenario and alarm_clears_after_charge_restored is invisible in yFault alone and is exactly what yPowerOk carries.",
"inputs": {
"oat": 10.0,
"thermal_power": [
{
"t": 0,
"value": 5.6
},
{
"t": 5400,
"value": 0.0
}
],
"elec_power": [
{
"t": 0,
"value": 2.0
},
{
"t": 5400,
"value": 0.2
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 5100,
"equals": true
},
{
"output": "yPowerOk",
"from_s": 0,
"to_s": 5100,
"equals": true
},
{
"output": "yPowerOk",
"from_s": 5400,
"to_s": 9000,
"equals": false
},
{
"output": "yFault",
"from_s": 5700,
"to_s": 9000,
"equals": false
}
]
}
]
}