HP-0003 — Reversing valve fault
| Status | verified — engine e2ff2f8, cxf:fnv1a128:cca57c9ed8973afd8c50972d6e595dda, 2026-08-17 |
| Severity | 2 |
| Method | rule |
| Phase | 2 |
| Category | CRITICAL_WASTE |
| Confidence | MEDIUM |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | HP-0001, HP-0002, HP-0006, HP-0007 |
| Playbooks | heat-pump-faults |
| Source | HVAC FDD Reference v1.0 §11, HP-0003; Barandier & Mendes 2024 |
| Operating states | heating and cooling, compressor running (host-gated) |
Preconditions (host-enforced): The compressor must be running. A heat pump idling on its indoor fan drifts its discharge toward room temperature, which reads as too warm for cooling and too cold for heating at the same time, so an unrunning unit must not be evaluated. The host must map its own mode enum onto heating_mode_code and cooling_mode_code and must report NO_EVAL — not healthy — for every other mode it can command (off, auto, emergency heat, dehumidify): the graph is structurally silent on codes it does not carry. Defrost is the sharp case: during a defrost cycle the unit deliberately runs the reversing valve in cooling while mode_command still reads HEATING, so the host should gate this rule on defrost_status. The rule survives a normal defrost only because alarm_delay outlasts it — see Deviations. sat must be trustworthy; nothing in this rule cross-checks it, and a discharge sensor reading 15 °C low fabricates a heating-mode fault on a healthy unit.
Points: mode_command, sat
Outputs:
yFault— True while the discharge temperature has contradicted a settled mode command continuously for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
heating_mode_code | 1 | 1 | kHeat.k | Value of mode_command meaning HEATING |
cooling_mode_code | 2 | 1 | kCool.k | Value of mode_command meaning COOLING |
sat_cooling_max | 18.0 | °C | satHigh.t | Discharge temperature above which the unit is not cooling, judged after the mode has settled. ADOPTED — the reference states no default (see Deviations) |
sat_heating_min | 28.0 | °C | satLow.t | Discharge temperature below which the unit is not heating, judged after the mode has settled. ADOPTED — the reference states no default (see Deviations) |
mode_switch_settle_time | 600.0 | s | heatSettled.delayTime, coolSettled.delayTime | How long a mode command must stand before the discharge temperature is judged against it (10 min); binds both selector timers |
alarm_delay | 900.0 | s | persist.delayTime | Continuous contradiction required before the alarm asserts (15 min) |
Description
A heat pump is one refrigeration circuit run in either direction, and the
reversing valve chooses the direction. When the valve does not shift, the unit
keeps doing what it was doing last: cooling a building that asked for heat, or
heating one that asked to be cooled. Nothing looks broken — the compressor runs,
the fans run, the controller reports the mode it commanded — and the zone
thermostat responds by asking for more of what it is already not getting. That
feedback loop is why this is severity 2. The unit is not merely inefficient, it
is adding load in the wrong direction, so whatever else serves the space pays to
undo the work; the reference’s estimator hp_capacity_kw × (1 + 1/COP) is
exactly that accounting.
Detection Logic
in_heating = (mode_command = heating_mode_code) held for mode_switch_settle_time
in_cooling = (mode_command = cooling_mode_code) held for mode_switch_settle_time
heat_bad = in_heating AND sat < sat_heating_min
cool_bad = in_cooling AND sat > sat_cooling_max
yFault = (heat_bad OR cool_bad) sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
kHeat and kCool decode mode_command through two Integers.Equal
selectors, and each selector passes through its own TrueDelay before it is
allowed to judge anything. That settle delay is the whole reason the rule can be
this simple: for ten minutes after a changeover the duct is still full of air
from the previous mode, and a unit that switched correctly looks exactly like a
unit that did not.
The two temperature tests are absolute limits rather than a comparison against
return air, because rat is not among the points the reference gives this rule.
satHigh and satLow are strict, so a discharge sitting exactly at 18.0 °C in
cooling or 28.0 °C in heating reads healthy. Between the limits is a deliberate
dead band: lukewarm air — 22 °C in either mode — satisfies neither test, so a
partially shifting valve, or a unit so short of charge it barely moves heat
either way, lives there and is HP-0001’s to find.
anyBad ORs the two branches so one output covers both directions, and
persist requires 15 continuous minutes. The chain matters more than either
delay alone: from a cold start in a bad mode the alarm lands at 1500 s, so
nothing shorter than 25 minutes of continuous wrong-direction operation raises
this fault — which is what keeps normal defrost cycles out of it, barely (see
Deviations).
Possible Diagnoses
- Reversing valve stuck or failed — usually mechanically seized; replacement runs $500–$2,000 plus refrigerant recovery
- Reversing valve solenoid failure — the cheap end ($100–$300), and the one the playbook’s click test isolates in a minute
- Wiring issue between the controller and the solenoid: the valve is fine and never got the signal
- Refrigerant charge too low for the valve to shift — the valve needs a pressure differential to move, so check charge before condemning the valve
- Sequencing error upstream of the valve — the unit faithfully executing a mode
the building did not want, usually a stuck
mode_commandin the BAS priority array
Energy Impact
CRITICAL_WASTE, MEDIUM confidence, PROXY_ESTIMATION. The reference gives 20–50%
of mode energy and the estimator waste_kw = hp_capacity_kw × (1 + 1/COP),
counting both the misdirected thermal output and the electricity that produced
it. PROXY because the rule sees a command and a temperature, not capacity or
power — hp_capacity_kw and COP come from the nameplate or from HP-0001’s
fitted baseline. MEDIUM because the fault is unambiguous once detected but its
cost depends on how long it ran and what else was compensating. Both climates: a
heat pump has two ways to be in the wrong mode and this rule watches both.
Emissions Impact
Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 1,000–6,000 kg CO₂e/yr while a unit runs in the wrong mode — the largest single-fault range in the heat pump chapter. An all-electric heat pump puts the whole impact in scope 2, and the avoided-emissions basis is the marginal operating emissions rate (MOER): a valve stuck in heating on a hot afternoon draws its worst power at the hour the grid is dirtiest.
Deviations
sat_cooling_maxandsat_heating_minare adopted, not transcribed. The reference’s equation names both, but its tunables table lists onlymode_switch_settle_timeandAlarmDelay. The playbook states the test relative to return air, which this card’s point list does not carry, so fixed limits stand in: 18.0 °C is above any plausible cooling discharge (10–14 °C) and below occupied space temperature, 28.0 °C is below any plausible heating discharge (30–45 °C) and above room temperature. Both are judgment calls and both are wrong for low-lift and inverter-driven units at part load, which produce discharge temperatures near room temperature — retune or accept silence. The dead band between the limits is the honest cost of not havingrat.- The reference’s “
mode_commandchanged AND aftermode_switch_settle_time” becomes a dwell test, not an edge test. The block graph has no “time since last change” quantity, so the condition is expressed as the selector having been continuously true for the settle time. The two agree where it matters — any change flips at least one selector and restarts its timer — and the dwell form is stronger at boot, wheredelayOnInit = truemakes a unit already sitting in a mode wait out the settle rather than be judged on its first tick. - A normal defrost cycle is ridden out by
alarm_delay, and only just. During defrost the unit reverses into cooling whilemode_commandstill reads HEATING, soheatSettledis already mature andsatLowgoes true as soon as the discharge cools; only the 15-minutepersiststands between a defrost and a false alarm, and HP-0002 putsmax_defrost_durationat exactly 15 minutes. Hosts should gate ondefrost_statusor raisealarm_delayabove their unit’s longest legitimate defrost. A defrost long enough to trip this rule is itself a fault, and HP-0002 will be reporting it. ratis not consumed, because the reference’s required points for this card aremode_commandand SAT only and the HP dictionary carries noratentry to bind. Recorded as the obvious upgrade if it gains one:sat > ratin cooling /sat < ratin heating needs no adopted constants and has no dead band.- The 1/2 mode encoding is this library’s convention, matching the point
dictionary’s
mode_commandentry, and both codes are parameters (kHeat.k,kCool.k) so a host with its own enum rebinds constants rather than editing the graph. Precedent: AHU-0029’sexpected_mode. - An unmapped
mode_commandleaves the rule structurally silent. Both selectors go false and no discharge temperature can raise a fault, however wrong it is. That silence is NO_EVAL, not a health claim, and the host must treat it as such — the same stance as AHU-0029, which faces the identical gap. - Strict comparisons at both limits. CDL Reals has no
GreaterEqualorLessEqual, so a discharge sitting exactly on a limit is not a fault. The disagreement is measure-zero on a real-valued signal, and both sides of both limits are pinned. delayOnInit = trueon all three timers (CDL default isfalse), the library’s standing choice: a unit already in the wrong mode when the controller restarted waits out the settle and the alarm delay instead of alarming on the first tick.- Operating states (compressor running) and the defrost exclusion live in frontmatter for host enforcement rather than in the block graph, per the library’s design stance.
- Frontmatter
clustersis empty: the reference lists no cluster for this fault and this card does not invent one. The relationship to HP-0001 and HP-0002 is carried byrelatedand the shared playbook. - The reference publishes no test vectors for this card; every scenario in
vectors.jsonis authored from the equation.
Notes
The heat-pump-faults playbook orders the service. Step 1.3 is the manual version of this rule — command a mode change, wait ten minutes, compare the discharge against return air. Step 2.3 is the diagnosis: listen for the solenoid click first, because no click is a $100–$300 solenoid rather than a $500–$2,000 valve body; then the wiring; then the charge. Expect company — a valve failing to shift cleanly usually degrades COP first, so HP-0001 may be reporting on the same unit, and a unit short enough of charge to stall the valve is short enough to lose its COP. Fix the charge before replacing anything.
Test Vectors
13 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
cooling_mode_supply_air_cold | Healthy cooling: the unit is commanded to COOLING (mode_command = 2) and the discharge reads 10 °C, well under sat_cooling_max. The reversing valve is where it was told to be. |
heating_mode_supply_air_warm | Healthy heating: mode_command = 1 with the discharge at 38 °C, well above sat_heating_min. |
cooling_mode_valve_did_not_shift | The unit is commanded to COOLING and the discharge sits at 24 °C — warmer than the space it is supposed to be cooling. The mode selector settles at t=600 s and the alarm lands one alarm_delay later, at t=1500 s. |
heating_mode_valve_did_not_shift | The mirror case: commanded to HEATING with the discharge at 14 °C, so the unit is air-conditioning a building that asked for heat. |
cooling_sat_exactly_at_max | Boundary: COOLING with the discharge at exactly sat_cooling_max (18.0 °C). CDL Reals has no GreaterEqual, so the comparison is strict and a discharge sitting precisely on the limit is not a fault. |
cooling_sat_just_above_max | Boundary from the other side: 18.1 °C in COOLING clears the strict comparison and alarms on the same schedule as the stuck-valve case. |
heating_sat_exactly_at_min | Boundary on the heating branch: HEATING with the discharge at exactly sat_heating_min (28.0 °C). The LessThreshold is strict, so the limit itself reads healthy. |
heating_sat_just_below_min | Boundary from the other side: 27.9 °C in HEATING trips the comparison and alarms after the settle and persistence delays. |
mode_chatter_never_settles | The unit flips between HEATING and COOLING every 540 s while the discharge holds 24 °C — a temperature that is too warm for cooling AND too cool for heating, so an unsettled rule would alarm continuously. Neither selector ever holds long enough to satisfy mode_switch_settle_time, so nothing is ever judged. |
settled_after_mode_switch | Settle edge from the long side: the unit runs HEATING until t=600 s (never settling, so the heating branch is never judged) and then switches to COOLING with the discharge holding 24 °C. The cooling selector settles 600 s after the switch and the alarm follows one alarm_delay after that. |
settle_window_masks_pulldown | The reason the settle delay exists: the unit switches from HEATING to COOLING at t=300 s with 30 °C air still in the duct, and the coil pulls it down to 12 °C by t=840 s. The cooling selector does not settle until t=900 s, by which point the discharge is where cooling wants it, so the changeover transient raises nothing. |
unmapped_mode_code_is_silent | mode_command = 3 (a host code this rule does not encode — off, auto, or defrost) with the discharge at 24 °C, which would fault in either mapped mode. Both selectors are false, so the rule is structurally silent: that silence is NO_EVAL, not a health claim, and the host must treat it that way. |
alarm_clears_when_valve_shifts | Recovery: the stuck-in-heating alarm asserts, then the technician frees the reversing valve at t=2400 s and the discharge drops to 12 °C. TrueDelay passes falling edges immediately, so yFault clears on that tick without waiting out another delay. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "cooling_mode_supply_air_cold",
"description": "Healthy cooling: the unit is commanded to COOLING (mode_command = 2) and the discharge reads 10 \u00b0C, well under sat_cooling_max. The reversing valve is where it was told to be.",
"inputs": {
"mode_command": 2,
"sat": 10.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "heating_mode_supply_air_warm",
"description": "Healthy heating: mode_command = 1 with the discharge at 38 \u00b0C, well above sat_heating_min.",
"inputs": {
"mode_command": 1,
"sat": 38.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "cooling_mode_valve_did_not_shift",
"description": "The unit is commanded to COOLING and the discharge sits at 24 \u00b0C \u2014 warmer than the space it is supposed to be cooling. The mode selector settles at t=600 s and the alarm lands one alarm_delay later, at t=1500 s.",
"inputs": {
"mode_command": 2,
"sat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1440,
"equals": false
},
{
"output": "yFault",
"from_s": 1560,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "heating_mode_valve_did_not_shift",
"description": "The mirror case: commanded to HEATING with the discharge at 14 \u00b0C, so the unit is air-conditioning a building that asked for heat.",
"inputs": {
"mode_command": 1,
"sat": 14.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1440,
"equals": false
},
{
"output": "yFault",
"from_s": 1560,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "cooling_sat_exactly_at_max",
"description": "Boundary: COOLING with the discharge at exactly sat_cooling_max (18.0 \u00b0C). CDL Reals has no GreaterEqual, so the comparison is strict and a discharge sitting precisely on the limit is not a fault.",
"inputs": {
"mode_command": 2,
"sat": 18.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "cooling_sat_just_above_max",
"description": "Boundary from the other side: 18.1 \u00b0C in COOLING clears the strict comparison and alarms on the same schedule as the stuck-valve case.",
"inputs": {
"mode_command": 2,
"sat": 18.1
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1440,
"equals": false
},
{
"output": "yFault",
"from_s": 1560,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "heating_sat_exactly_at_min",
"description": "Boundary on the heating branch: HEATING with the discharge at exactly sat_heating_min (28.0 \u00b0C). The LessThreshold is strict, so the limit itself reads healthy.",
"inputs": {
"mode_command": 1,
"sat": 28.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "heating_sat_just_below_min",
"description": "Boundary from the other side: 27.9 \u00b0C in HEATING trips the comparison and alarms after the settle and persistence delays.",
"inputs": {
"mode_command": 1,
"sat": 27.9
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1440,
"equals": false
},
{
"output": "yFault",
"from_s": 1560,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "mode_chatter_never_settles",
"description": "The unit flips between HEATING and COOLING every 540 s while the discharge holds 24 \u00b0C \u2014 a temperature that is too warm for cooling AND too cool for heating, so an unsettled rule would alarm continuously. Neither selector ever holds long enough to satisfy mode_switch_settle_time, so nothing is ever judged.",
"inputs": {
"mode_command": [
{
"t": 0,
"value": 2
},
{
"t": 540,
"value": 1
},
{
"t": 1080,
"value": 2
},
{
"t": 1620,
"value": 1
},
{
"t": 2160,
"value": 2
},
{
"t": 2700,
"value": 1
},
{
"t": 3240,
"value": 2
}
],
"sat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "settled_after_mode_switch",
"description": "Settle edge from the long side: the unit runs HEATING until t=600 s (never settling, so the heating branch is never judged) and then switches to COOLING with the discharge holding 24 \u00b0C. The cooling selector settles 600 s after the switch and the alarm follows one alarm_delay after that.",
"inputs": {
"mode_command": [
{
"t": 0,
"value": 1
},
{
"t": 600,
"value": 2
}
],
"sat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2040,
"equals": false
},
{
"output": "yFault",
"from_s": 2160,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "settle_window_masks_pulldown",
"description": "The reason the settle delay exists: the unit switches from HEATING to COOLING at t=300 s with 30 \u00b0C air still in the duct, and the coil pulls it down to 12 \u00b0C by t=840 s. The cooling selector does not settle until t=900 s, by which point the discharge is where cooling wants it, so the changeover transient raises nothing.",
"inputs": {
"mode_command": [
{
"t": 0,
"value": 1
},
{
"t": 300,
"value": 2
}
],
"sat": [
{
"t": 0,
"value": 30.0
},
{
"t": 840,
"value": 12.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "unmapped_mode_code_is_silent",
"description": "mode_command = 3 (a host code this rule does not encode \u2014 off, auto, or defrost) with the discharge at 24 \u00b0C, which would fault in either mapped mode. Both selectors are false, so the rule is structurally silent: that silence is NO_EVAL, not a health claim, and the host must treat it that way.",
"inputs": {
"mode_command": 3,
"sat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "alarm_clears_when_valve_shifts",
"description": "Recovery: the stuck-in-heating alarm asserts, then the technician frees the reversing valve at t=2400 s and the discharge drops to 12 \u00b0C. TrueDelay passes falling edges immediately, so yFault clears on that tick without waiting out another delay.",
"inputs": {
"mode_command": 2,
"sat": [
{
"t": 0,
"value": 24.0
},
{
"t": 2400,
"value": 12.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1440,
"equals": false
},
{
"output": "yFault",
"from_s": 1560,
"to_s": 2340,
"equals": true
},
{
"output": "yFault",
"from_s": 2460,
"to_s": 3600,
"equals": false
}
]
}
]
}