HX-0002 — Heat exchanger active with one-side flow missing
| Status | verified — engine e2ff2f8, cxf:fnv1a128:570e5bd05f74bc3a19c7e61c7543a042, 2026-08-20 |
| Severity | 2 |
| Method | rule |
| Phase | 2 |
| Category | PROTECTIVE |
| Confidence | MEDIUM |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | HX-0001, HX-0003, PMP-0001, PMP-0003 |
| Playbooks | hydronic-heat-exchanger-faults |
| Source | EnergyPlus 25.1 Input/Output Reference, HeatExchanger:FluidToFluid — generic four-port model and control modes request both connection flows when exchange operates: https://bigladdersoftware.com/epx/docs/25-1/input-output-reference/group-condenser-equipment.html#heatexchangerfluidtofluid; EnergyPlus 25.1 official PlantLoopChainHeating.idf and PlantLoopChainCooling.idf test models — separate supply/demand-side mass flows and operation status |
| Operating states | A controlled liquid-to-liquid HX in a final automatic state that presently expects both individual branches to flow |
Preconditions (host-enforced): exchange_cmd must be the final downstream both-flow expectation after temperature feasibility, pump/valve ownership, anti-cycle, local/HAND, freeze, pressure, minimum-flow, and other normal sequence logic. Availability, an upstream plant enable, or a supervisory status that permits zero flow is invalid. Both meters must be individual branches on the same HX, fresh, nonnegative in the declared inlet-to-outlet direction, and correctly converted to L/s; common-header, fleet, or duplicated flow is invalid. Configure each threshold above meter zero/noise but below the minimum legitimate established flow, and configure alarm_delay above the slowest permitted start/transport latency. Passive/uncontrolled exchangers and sequences that intentionally flow one side only while armed are NO_EVAL. Exclude maintenance, flushing, fill/purge, drain-down, exercise, and sensor invalidity.
Points: exchange_cmd, primary_flow, secondary_flow
Outputs:
yFault— True while either side-specific missing-flow diagnostic has maturedyPrimaryFlowMissing— Delayed diagnostic direction flag; true when final exchange command is active and primary flow remains below its floor for alarm_delay. False never means NO_EVALySecondaryFlowMissing— Delayed diagnostic direction flag for the secondary side; false never means NO_EVAL
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
primary_flow_min | 1.0 | L/s | primaryLow.t | NO_PORTABLE_DEFAULT executable placeholder. Commission above the primary meter’s zero/noise/resolution and below minimum legitimate established branch flow before enabling the rule. |
secondary_flow_min | 1.0 | L/s | secondaryLow.t | NO_PORTABLE_DEFAULT executable placeholder with the same side-specific commissioning requirement; unequal sides need not share a threshold. |
alarm_delay | 900.0 | s | primaryHeld.delayTime, secondaryHeld.delayTime | ADOPTED_TUNABLE 15-minute proof window. Set above the slowest valid final-command-to-flow latency and point delivery time. One value is applied to both independent timers. |
Description
A four-port liquid heat exchanger cannot transfer useful heat when one required branch has no flow. This rule compares a final both-flow exchange command with individual primary and secondary branch meters, delays each missing-side signature independently, and tells the operator which side failed.
The rule does not prove a pump fault. A closed isolation valve, clogged strainer, air lock, pressure problem, meter failure, or correct local sequence can create the same observation. Final command semantics and branch scope are therefore adoption requirements.
Detection Logic
primary_candidate = exchange_cmd AND primary_flow < primary_flow_min
secondary_candidate = exchange_cmd AND secondary_flow < secondary_flow_min
yPrimaryFlowMissing = primary_candidate continuously for alarm_delay
ySecondaryFlowMissing = secondary_candidate continuously for alarm_delay
yFault = yPrimaryFlowMissing OR ySecondaryFlowMissing
Each side owns a TrueDelay with delayOnInit = true. If the missing side
reverses, the old lane clears and the new lane starts from zero; elapsed time is
not inherited through an OR. Both flags may mature if both flows are missing.
The strict LessThreshold makes exactly the configured floor safe.
Possible Diagnoses
- Side pump failed, tripped, lost coupling, or never received its final command.
- Isolation/control valve closed, failed, or under local/HAND ownership.
- Clogged strainer/plate passages, air lock, low pressure, or frozen path.
- Failed check valve or hydraulic interaction preventing the intended branch.
- Flow meter zero/scaling/freshness failure or common-header misbinding.
- Upstream enable bound instead of the final both-flow expectation.
Energy Impact
PROTECTIVE with DIRECT_MEASUREMENT and MEDIUM confidence. Pumps and plant may consume energy while the exchanger delivers little or no useful transfer, but this Boolean/flow signature cannot quantify the loss safely. In low-temperature or protective service, delivery/freeze/equipment consequences can outweigh energy cost.
Emissions Impact
Scope 1+2, QUALITATIVE_ONLY. Quantify only after measuring the active plant and pump energy plus any replacement heat source used during the incomplete path.
Deviations
- Thresholds have no portable default. The numeric 1 L/s values exist for executable vectors only. Meter size, design flow, glycol, and minimum stable control flow are installation-specific and must replace them.
- One delay is intentionally duplicated onto two blocks. This preserves a
direction reversal reset that a single delay after
(primary OR secondary)cannot provide. - The final command is stricter than ordinary enable. EnergyPlus’s operation status/control behavior is physical precedent, not a claim that every BAS exposes the needed state. Without it the rule is not deployable.
- No raw low-flow outputs. The two exported direction flags are delayed findings, not mathematical evaluability flags. False never means NO_EVAL.
- No suppression or cluster. Pump proof/delivery rules can help diagnose a side, but no one causal trigger or repair clears all HX findings reliably and global rule-ID suppression would cross equipment instances.
Test Vectors
17 scenarios, clock step 60 s over 3000 s.
| Scenario | Description |
|---|---|
disabled_with_no_flow | A disabled exchanger is healthy even though both raw flows are below their floors. |
disabled_with_both_flows | Command off with both branch flows established completes the Boolean matrix and remains clear; passive circulation is outside this rule’s commanded-active premise. |
disabled_with_primary_flow_only | Command off with primary flow established and secondary flow missing completes the Boolean matrix and remains clear. |
disabled_with_secondary_flow_only | Command off with primary flow missing and secondary flow established completes the Boolean matrix and remains clear. |
active_with_both_flows | Both individual branches deliver flow while exchange is commanded active. |
primary_flow_missing | Only the primary lane matures when its branch remains below the configured floor. |
secondary_flow_missing | Only the secondary lane matures when its branch remains below the configured floor. |
both_sides_missing | A valid final exchange command with neither branch flowing matures both diagnostic lanes. |
flows_exactly_at_threshold | Exactly 1.0 L/s is safe under strict Less on both lanes. |
primary_just_below_threshold | Primary flow at 0.9 L/s is one test increment below the threshold. |
secondary_just_below_threshold | Secondary flow at 0.9 L/s is one test increment below the threshold. |
primary_violation_ends_one_step_before_delay | The primary candidate ends at 840 s and never matures. |
mature_primary_alarm_recovers | A mature primary missing-flow alarm clears immediately on restored branch flow. |
two_short_primary_dropouts_reset | Two short primary-flow dropouts separated by recovery do not accumulate elapsed time. |
missing_side_reversal_resets_timer | A primary miss recovers as a secondary miss begins; independent timers create a clear gap rather than inheriting the primary elapsed time. |
initial_primary_miss_obeys_delay | A primary miss present at initialization still waits for persistence. |
upstream_enable_misbound_raw_false_positive | An upstream enable held true while a healthy controller intentionally requests no flow will alarm; this pins why exchange_cmd must be the final both-flow expectation. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3000
},
"scenarios": [
{
"name": "disabled_with_no_flow",
"description": "A disabled exchanger is healthy even though both raw flows are below their floors.",
"inputs": {
"exchange_cmd": false,
"primary_flow": 0.0,
"secondary_flow": 0.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3000,
"equals": false
}
]
},
{
"name": "disabled_with_both_flows",
"description": "Command off with both branch flows established completes the Boolean matrix and remains clear; passive circulation is outside this rule's commanded-active premise.",
"inputs": {
"exchange_cmd": false,
"primary_flow": 10.0,
"secondary_flow": 10.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3000,
"equals": false
}
]
},
{
"name": "disabled_with_primary_flow_only",
"description": "Command off with primary flow established and secondary flow missing completes the Boolean matrix and remains clear.",
"inputs": {
"exchange_cmd": false,
"primary_flow": 10.0,
"secondary_flow": 0.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3000,
"equals": false
}
]
},
{
"name": "disabled_with_secondary_flow_only",
"description": "Command off with primary flow missing and secondary flow established completes the Boolean matrix and remains clear.",
"inputs": {
"exchange_cmd": false,
"primary_flow": 0.0,
"secondary_flow": 10.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3000,
"equals": false
}
]
},
{
"name": "active_with_both_flows",
"description": "Both individual branches deliver flow while exchange is commanded active.",
"inputs": {
"exchange_cmd": true,
"primary_flow": 10.0,
"secondary_flow": 9.5
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3000,
"equals": false
}
]
},
{
"name": "primary_flow_missing",
"description": "Only the primary lane matures when its branch remains below the configured floor.",
"inputs": {
"exchange_cmd": true,
"primary_flow": 0.0,
"secondary_flow": 10.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yPrimaryFlowMissing",
"from_s": 960,
"to_s": 3000,
"equals": true
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3000,
"equals": true
}
]
},
{
"name": "secondary_flow_missing",
"description": "Only the secondary lane matures when its branch remains below the configured floor.",
"inputs": {
"exchange_cmd": true,
"primary_flow": 10.0,
"secondary_flow": 0.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 960,
"to_s": 3000,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3000,
"equals": true
}
]
},
{
"name": "both_sides_missing",
"description": "A valid final exchange command with neither branch flowing matures both diagnostic lanes.",
"inputs": {
"exchange_cmd": true,
"primary_flow": 0.0,
"secondary_flow": 0.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yPrimaryFlowMissing",
"from_s": 960,
"to_s": 3000,
"equals": true
},
{
"output": "ySecondaryFlowMissing",
"from_s": 960,
"to_s": 3000,
"equals": true
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3000,
"equals": true
}
]
},
{
"name": "flows_exactly_at_threshold",
"description": "Exactly 1.0 L/s is safe under strict Less on both lanes.",
"inputs": {
"exchange_cmd": true,
"primary_flow": 1.0,
"secondary_flow": 1.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3000,
"equals": false
}
]
},
{
"name": "primary_just_below_threshold",
"description": "Primary flow at 0.9 L/s is one test increment below the threshold.",
"inputs": {
"exchange_cmd": true,
"primary_flow": 0.9,
"secondary_flow": 10.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yPrimaryFlowMissing",
"from_s": 960,
"to_s": 3000,
"equals": true
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3000,
"equals": true
}
]
},
{
"name": "secondary_just_below_threshold",
"description": "Secondary flow at 0.9 L/s is one test increment below the threshold.",
"inputs": {
"exchange_cmd": true,
"primary_flow": 10.0,
"secondary_flow": 0.9
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 960,
"to_s": 3000,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3000,
"equals": true
}
]
},
{
"name": "primary_violation_ends_one_step_before_delay",
"description": "The primary candidate ends at 840 s and never matures.",
"inputs": {
"exchange_cmd": true,
"primary_flow": [
{
"t": 0,
"value": 0.0
},
{
"t": 840,
"value": 10.0
}
],
"secondary_flow": 10.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3000,
"equals": false
}
]
},
{
"name": "mature_primary_alarm_recovers",
"description": "A mature primary missing-flow alarm clears immediately on restored branch flow.",
"inputs": {
"exchange_cmd": true,
"primary_flow": [
{
"t": 0,
"value": 0.0
},
{
"t": 1200,
"value": 10.0
}
],
"secondary_flow": 10.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yPrimaryFlowMissing",
"from_s": 960,
"to_s": 1140,
"equals": true
},
{
"output": "yPrimaryFlowMissing",
"from_s": 1200,
"to_s": 3000,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 1140,
"equals": true
},
{
"output": "yFault",
"from_s": 1200,
"to_s": 3000,
"equals": false
}
]
},
{
"name": "two_short_primary_dropouts_reset",
"description": "Two short primary-flow dropouts separated by recovery do not accumulate elapsed time.",
"inputs": {
"exchange_cmd": true,
"primary_flow": [
{
"t": 0,
"value": 0.0
},
{
"t": 600,
"value": 10.0
},
{
"t": 900,
"value": 0.0
},
{
"t": 1500,
"value": 10.0
}
],
"secondary_flow": 10.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3000,
"equals": false
}
]
},
{
"name": "missing_side_reversal_resets_timer",
"description": "A primary miss recovers as a secondary miss begins; independent timers create a clear gap rather than inheriting the primary elapsed time.",
"inputs": {
"exchange_cmd": true,
"primary_flow": [
{
"t": 0,
"value": 0.0
},
{
"t": 1200,
"value": 10.0
}
],
"secondary_flow": [
{
"t": 0,
"value": 10.0
},
{
"t": 1200,
"value": 0.0
}
]
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yPrimaryFlowMissing",
"from_s": 960,
"to_s": 1140,
"equals": true
},
{
"output": "yPrimaryFlowMissing",
"from_s": 1200,
"to_s": 3000,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 2040,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 2160,
"to_s": 3000,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 1140,
"equals": true
},
{
"output": "yFault",
"from_s": 1200,
"to_s": 2040,
"equals": false
},
{
"output": "yFault",
"from_s": 2160,
"to_s": 3000,
"equals": true
}
]
},
{
"name": "initial_primary_miss_obeys_delay",
"description": "A primary miss present at initialization still waits for persistence.",
"inputs": {
"exchange_cmd": true,
"primary_flow": 0.0,
"secondary_flow": 10.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yPrimaryFlowMissing",
"from_s": 960,
"to_s": 3000,
"equals": true
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 3000,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3000,
"equals": true
}
]
},
{
"name": "upstream_enable_misbound_raw_false_positive",
"description": "An upstream enable held true while a healthy controller intentionally requests no flow will alarm; this pins why exchange_cmd must be the final both-flow expectation.",
"inputs": {
"exchange_cmd": true,
"primary_flow": 0.0,
"secondary_flow": 0.0
},
"expect": [
{
"output": "yPrimaryFlowMissing",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yPrimaryFlowMissing",
"from_s": 960,
"to_s": 3000,
"equals": true
},
{
"output": "ySecondaryFlowMissing",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "ySecondaryFlowMissing",
"from_s": 960,
"to_s": 3000,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3000,
"equals": true
}
]
}
]
}