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

ERV-0004 — Recovery device proof-of-operation failure

Statusverified — engine e2ff2f8, cxf:fnv1a128:0c006939620ca8d334e3d521c711d2cc, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedERV-0001, ERV-0003, ERV-0005
Playbooksproof-of-operation, erv-effectiveness
SourceLibrary-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 statesall 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 active
  • yFailToStart — Diagnostic flag — command true and independent status false continuously for start_proof_time
  • yUnexpectedRun — Diagnostic flag — status true and command false continuously for stop_proof_time

Parameters:

NameDefaultUnitCXF pathDescription
start_proof_time120.0sstartHeld.delayTimeADOPTED_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_time120.0sstopHeld.delayTimeADOPTED_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):

ERV-0004 block graph

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:

  1. Broken wheel belt/coupling, stalled motor, tripped overload, or failed drive
  2. Runaround pump locked out, isolated, air-bound, or mechanically failed
  3. Status switch/threshold failed even though the device operates
  4. Final command point not reaching the starter or drive

yUnexpectedRun:

  1. HOA/local switch left in HAND or drive left in local mode
  2. Software override, welded contactor, or a second controller still commanding
  3. 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 pin e2ff2f8, that block has one timing contract and unsuitable initialization/chatter behavior; Not + And + two TrueDelays 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-effectiveness and proof-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.

ScenarioDescription
agreement_in_both_states_is_silentCommand and independent status agree through one on/off cycle, covering both healthy truth-table rows.
fail_to_start_asserts_after_start_proof_timeThe 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_timeStatus 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_windowIndependent proof follows 90 s after command, one sample before the 120 s window, so no alarm appears.
healthy_stop_coasts_down_just_before_the_windowA wheel status remains true for 90 s after command-off, then drops before stop_proof_time.
command_chatter_restarts_the_start_timerA one-sample command drop resets the start clock. The true assertion moves to one step after the restarted timer edge.
status_chatter_never_maturesStatus alternates every 90 s against a held-on command; no fail-to-start interval survives the 120 s window.
direction_flip_never_asserts_bothA 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_windowThe model initializes with status on and command off. delayOnInit keeps the first 120 s quiet.
proof_recovery_clears_on_the_same_tickA 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_tickA mature unexpected-run mismatch clears immediately when independent proof drops; the stop lane has no off-delay.
start_proof_arrives_two_steps_after_the_boundaryProof 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
        }
      ]
    }
  ]
}