ERV-0004 — Recovery device proof-of-operation failure
| Status | verified — engine e2ff2f8, cxf:fnv1a128:0c006939620ca8d334e3d521c711d2cc, 2026-08-20 |
| Severity | 2 |
| Method | rule |
| Phase | 2 |
| Category | PROTECTIVE |
| Confidence | HIGH |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | ERV-0001, ERV-0003, ERV-0005 |
| Playbooks | proof-of-operation, erv-effectiveness |
| Source | Library-authored active-recovery application of the command/status proof family established by AHU-0039, PMP-0003, and HW-0009; Buildings.Controls.OBC.CDL.Logical.Proof (Hu & Wetter, March 2023), available at engine pin e2ff2f8; the library uses the proven two-direction vocabulary but composes independent timers because the packaged block cannot express this card’s required behavior; points/erv.points.json erv_recovery_cmd and erv_recovery_status — final-command and independent-physical-proof contracts for wheels, runaround pumps, and other active recovery devices; Greenheck ERV controller IOM 484118, pp.5 and 11 — the controller I/O identifies a wheel-rotation alarm and the menu documents automatic wheel jog; supports available independent proof and the final-command/jog binding caveat, not the 120 s defaults |
| Operating states | all operating states of an active recovery device; both mismatch directions remain meaningful whenever the final command and independent proof are available |
Preconditions (host-enforced): Applicable only to an active recovery component with a final run command and independent physical proof: a rotary wheel drive, runaround-loop pump, or equivalent. Passive fixed-plate cores are excluded. Both points must belong to the same component, be fresh, and arrive faster than the configured proof windows. Bind erv_recovery_cmd after frost, bypass, smoke, safety, automatic wheel-jog/exercise, and local sequence logic; an upstream ERV enable is not the same command. If jog is not represented in the final command, exclude its intervals host-side. erv_recovery_status must prove rotation/work (wheel speed/rotation switch, pump current/flow, or equivalent), not echo the command relay. Configure start_proof_time above the slowest legitimate acceleration and delivery delay and stop_proof_time above wheel coast-down. Exclude maintenance and local/hand testing at the host.
Points: erv_recovery_cmd, erv_recovery_status
Outputs:
yFault— True while either independently timed command/status mismatch direction is activeyFailToStart— Diagnostic flag — command true and independent status false continuously for start_proof_timeyUnexpectedRun— Diagnostic flag — status true and command false continuously for stop_proof_time
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
start_proof_time | 120.0 | s | startHeld.delayTime | ADOPTED_TUNABLE: maximum allowed time from final run command to independent proof (2 min). Set above wheel/pump acceleration plus worst-case point-delivery latency; this is not a manufacturer-universal limit. |
stop_proof_time | 120.0 | s | stopHeld.delayTime | ADOPTED_TUNABLE: maximum allowed time for proof to remain after command-off (2 min). Set above legitimate wheel coast-down or runaround-pump proof decay and point-delivery latency. |
Description
An active recovery device has to do more than receive an enable. This rule compares the final command reaching the wheel drive or runaround pump with an independent indication that the component actually operates. It detects both a device that fails to start and one that continues running after its command is removed. Passive plate cores have no command/status pair and are outside scope.
Detection Logic
yFailToStart = (erv_recovery_cmd AND NOT erv_recovery_status)
sustained for start_proof_time
yUnexpectedRun = (erv_recovery_status AND NOT erv_recovery_cmd)
sustained for stop_proof_time
yFault = yFailToStart OR yUnexpectedRun
Block graph (rule.cxf.jsonld):
Each direction owns its delay, and both delays use delayOnInit = true. A
direction flip clears the old flag immediately and starts the other timer from
zero; the two diagnostic outputs cannot overlap because their command terms are
opposites. Agreement clears without an off-delay.
Possible Diagnoses
yFailToStart:
- Broken wheel belt/coupling, stalled motor, tripped overload, or failed drive
- Runaround pump locked out, isolated, air-bound, or mechanically failed
- Status switch/threshold failed even though the device operates
- Final command point not reaching the starter or drive
yUnexpectedRun:
- HOA/local switch left in HAND or drive left in local mode
- Software override, welded contactor, or a second controller still commanding
- Status point stuck true or sourced from the wrong component
Energy Impact
PROTECTIVE, HIGH confidence, PROXY_ESTIMATION. A fail-to-start can remove most of the intended heat recovery while downstream coils silently make up the load; an unexpected run adds motor/pump power and can oppose bypass or frost intent. Duration is known, but power and lost recovered heat are host-side quantities.
Emissions Impact
Scope 1 + 2, PROXY_EMISSIONS. Device power is usually scope 2. Lost recovery shifts load to electric cooling and either electric or fuel heating, so the emissions scope follows the downstream plant and operating season.
Deviations
- Library-authored for active recovery only. The HVAC FDD Reference has no ERV proof card; this is the established AHU/pump/boiler topology applied only where a real command and independent proof exist.
- Both proof times are 120 s ADOPTED_TUNABLE defaults. No cited source gives one portable wheel/runaround value; separate parameters preserve legitimate acceleration and coast-down differences.
- Automatic jog belongs in the final command. Greenheck documents wheel jog
as normal control behavior; binding an upstream enable or excluding jog from
the command would turn intentional operation into
yUnexpectedRun. - Composed logic replaces
CDL.Logical.Proof. At engine pine2ff2f8, that block has one timing contract and unsuitable initialization/chatter behavior;Not+And+ twoTrueDelays preserves mutual exclusivity and full startup delays exactly like AHU-0039. - No evaluability output. Evaluability is freshness, independence, and applicability of the two bindings, all host knowledge that cannot be derived from their boolean values.
- No suppression. A proof failure can cause ERV-0001’s real effectiveness loss, and a wheel can fail while airflow stays balanced; operators benefit from both findings.
- No ERV cluster. The shared order belongs in
erv-effectivenessandproof-of-operation; a three-rule delivery batch is not a causal taxonomy. - No empirical validation claim. Command and independent wheel/pump proof are not available in the current simulation mapping; synthetic vectors cover all truth-table and timing behavior.
Notes
Command echo is the dangerous binding: it agrees perfectly with the command even when a belt is broken or a pump is seized. If the two points share the same controller object or relay source, omit the rule until independent proof exists.
Test Vectors
12 scenarios, clock step 30 s over 1800 s.
| Scenario | Description |
|---|---|
agreement_in_both_states_is_silent | Command and independent status agree through one on/off cycle, covering both healthy truth-table rows. |
fail_to_start_asserts_after_start_proof_time | The command rises at t=300 and proof never follows. Assertions leave the exact 120 s timer edge unbound and prove the start branch one step later. |
unexpected_run_asserts_after_stop_proof_time | Status rises against an off command at t=300. Assertions leave the timer edge unbound and prove unexpected run one step later. |
healthy_start_proves_just_before_the_window | Independent proof follows 90 s after command, one sample before the 120 s window, so no alarm appears. |
healthy_stop_coasts_down_just_before_the_window | A wheel status remains true for 90 s after command-off, then drops before stop_proof_time. |
command_chatter_restarts_the_start_timer | A one-sample command drop resets the start clock. The true assertion moves to one step after the restarted timer edge. |
status_chatter_never_matures | Status alternates every 90 s against a held-on command; no fail-to-start interval survives the 120 s window. |
direction_flip_never_asserts_both | A mature fail-to-start becomes unexpected run at t=600. The first flag clears immediately and the second serves its own window. |
restart_into_a_mismatch_serves_the_full_window | The model initializes with status on and command off. delayOnInit keeps the first 120 s quiet. |
proof_recovery_clears_on_the_same_tick | A mature fail-to-start clears immediately when independent proof arrives; the delay does not impose an off-delay. |
unexpected_run_recovery_clears_on_the_same_tick | A mature unexpected-run mismatch clears immediately when independent proof drops; the stop lane has no off-delay. |
start_proof_arrives_two_steps_after_the_boundary | Proof arrives at t=180. Assertions skip the exact t=120 timer edge, prove the mismatch at t=150, then prove immediate recovery. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 30,
"horizon_s": 1800
},
"scenarios": [
{
"name": "agreement_in_both_states_is_silent",
"description": "Command and independent status agree through one on/off cycle, covering both healthy truth-table rows.",
"inputs": {
"erv_recovery_cmd": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
},
{
"t": 900,
"value": false
}
],
"erv_recovery_status": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
},
{
"t": 900,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "fail_to_start_asserts_after_start_proof_time",
"description": "The command rises at t=300 and proof never follows. Assertions leave the exact 120 s timer edge unbound and prove the start branch one step later.",
"inputs": {
"erv_recovery_cmd": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
}
],
"erv_recovery_status": false
},
"expect": [
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 390,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 450,
"to_s": 1800,
"equals": true
},
{
"output": "yFault",
"from_s": 450,
"to_s": 1800,
"equals": true
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "unexpected_run_asserts_after_stop_proof_time",
"description": "Status rises against an off command at t=300. Assertions leave the timer edge unbound and prove unexpected run one step later.",
"inputs": {
"erv_recovery_cmd": false,
"erv_recovery_status": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
}
]
},
"expect": [
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 390,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 450,
"to_s": 1800,
"equals": true
},
{
"output": "yFault",
"from_s": 450,
"to_s": 1800,
"equals": true
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "healthy_start_proves_just_before_the_window",
"description": "Independent proof follows 90 s after command, one sample before the 120 s window, so no alarm appears.",
"inputs": {
"erv_recovery_cmd": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
}
],
"erv_recovery_status": [
{
"t": 0,
"value": false
},
{
"t": 390,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "healthy_stop_coasts_down_just_before_the_window",
"description": "A wheel status remains true for 90 s after command-off, then drops before stop_proof_time.",
"inputs": {
"erv_recovery_cmd": [
{
"t": 0,
"value": true
},
{
"t": 300,
"value": false
}
],
"erv_recovery_status": [
{
"t": 0,
"value": true
},
{
"t": 390,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "command_chatter_restarts_the_start_timer",
"description": "A one-sample command drop resets the start clock. The true assertion moves to one step after the restarted timer edge.",
"inputs": {
"erv_recovery_cmd": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
},
{
"t": 390,
"value": false
},
{
"t": 480,
"value": true
}
],
"erv_recovery_status": false
},
"expect": [
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 570,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 630,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "status_chatter_never_matures",
"description": "Status alternates every 90 s against a held-on command; no fail-to-start interval survives the 120 s window.",
"inputs": {
"erv_recovery_cmd": true,
"erv_recovery_status": [
{
"t": 0,
"value": false
},
{
"t": 90,
"value": true
},
{
"t": 180,
"value": false
},
{
"t": 270,
"value": true
},
{
"t": 360,
"value": false
},
{
"t": 450,
"value": true
},
{
"t": 540,
"value": false
},
{
"t": 630,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "direction_flip_never_asserts_both",
"description": "A mature fail-to-start becomes unexpected run at t=600. The first flag clears immediately and the second serves its own window.",
"inputs": {
"erv_recovery_cmd": [
{
"t": 0,
"value": true
},
{
"t": 600,
"value": false
}
],
"erv_recovery_status": [
{
"t": 0,
"value": false
},
{
"t": 600,
"value": true
}
]
},
"expect": [
{
"output": "yFailToStart",
"from_s": 150,
"to_s": 570,
"equals": true
},
{
"output": "yFailToStart",
"from_s": 600,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 690,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 750,
"to_s": 1800,
"equals": true
},
{
"output": "yFault",
"from_s": 150,
"to_s": 570,
"equals": true
},
{
"output": "yFault",
"from_s": 600,
"to_s": 690,
"equals": false
},
{
"output": "yFault",
"from_s": 750,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "restart_into_a_mismatch_serves_the_full_window",
"description": "The model initializes with status on and command off. delayOnInit keeps the first 120 s quiet.",
"inputs": {
"erv_recovery_cmd": false,
"erv_recovery_status": true
},
"expect": [
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 90,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 150,
"to_s": 1800,
"equals": true
}
]
},
{
"name": "proof_recovery_clears_on_the_same_tick",
"description": "A mature fail-to-start clears immediately when independent proof arrives; the delay does not impose an off-delay.",
"inputs": {
"erv_recovery_cmd": true,
"erv_recovery_status": [
{
"t": 0,
"value": false
},
{
"t": 600,
"value": true
}
]
},
"expect": [
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 90,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 150,
"to_s": 570,
"equals": true
},
{
"output": "yFailToStart",
"from_s": 600,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 600,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "unexpected_run_recovery_clears_on_the_same_tick",
"description": "A mature unexpected-run mismatch clears immediately when independent proof drops; the stop lane has no off-delay.",
"inputs": {
"erv_recovery_cmd": false,
"erv_recovery_status": [
{
"t": 0,
"value": true
},
{
"t": 600,
"value": false
}
]
},
"expect": [
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 90,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 150,
"to_s": 570,
"equals": true
},
{
"output": "yUnexpectedRun",
"from_s": 600,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 600,
"to_s": 1800,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
},
{
"name": "start_proof_arrives_two_steps_after_the_boundary",
"description": "Proof arrives at t=180. Assertions skip the exact t=120 timer edge, prove the mismatch at t=150, then prove immediate recovery.",
"inputs": {
"erv_recovery_cmd": true,
"erv_recovery_status": [
{
"t": 0,
"value": false
},
{
"t": 180,
"value": true
}
]
},
"expect": [
{
"output": "yFailToStart",
"from_s": 0,
"to_s": 90,
"equals": false
},
{
"output": "yFailToStart",
"from_s": 150,
"to_s": 150,
"equals": true
},
{
"output": "yFailToStart",
"from_s": 180,
"to_s": 1800,
"equals": false
},
{
"output": "yFault",
"from_s": 150,
"to_s": 150,
"equals": true
},
{
"output": "yFault",
"from_s": 180,
"to_s": 1800,
"equals": false
},
{
"output": "yUnexpectedRun",
"from_s": 0,
"to_s": 1800,
"equals": false
}
]
}
]
}