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

HX-0002 — Heat exchanger active with one-side flow missing

Statusverified — engine e2ff2f8, cxf:fnv1a128:570e5bd05f74bc3a19c7e61c7543a042, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed by
RelatedHX-0001, HX-0003, PMP-0001, PMP-0003
Playbookshydronic-heat-exchanger-faults
SourceEnergyPlus 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 statesA 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 matured
  • yPrimaryFlowMissing — 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_EVAL
  • ySecondaryFlowMissing — Delayed diagnostic direction flag for the secondary side; false never means NO_EVAL

Parameters:

NameDefaultUnitCXF pathDescription
primary_flow_min1.0L/sprimaryLow.tNO_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_min1.0L/ssecondaryLow.tNO_PORTABLE_DEFAULT executable placeholder with the same side-specific commissioning requirement; unequal sides need not share a threshold.
alarm_delay900.0sprimaryHeld.delayTime, secondaryHeld.delayTimeADOPTED_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

HX-0002 block graph

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

  1. Side pump failed, tripped, lost coupling, or never received its final command.
  2. Isolation/control valve closed, failed, or under local/HAND ownership.
  3. Clogged strainer/plate passages, air lock, low pressure, or frozen path.
  4. Failed check valve or hydraulic interaction preventing the intended branch.
  5. Flow meter zero/scaling/freshness failure or common-header misbinding.
  6. 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.

ScenarioDescription
disabled_with_no_flowA disabled exchanger is healthy even though both raw flows are below their floors.
disabled_with_both_flowsCommand 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_onlyCommand off with primary flow established and secondary flow missing completes the Boolean matrix and remains clear.
disabled_with_secondary_flow_onlyCommand off with primary flow missing and secondary flow established completes the Boolean matrix and remains clear.
active_with_both_flowsBoth individual branches deliver flow while exchange is commanded active.
primary_flow_missingOnly the primary lane matures when its branch remains below the configured floor.
secondary_flow_missingOnly the secondary lane matures when its branch remains below the configured floor.
both_sides_missingA valid final exchange command with neither branch flowing matures both diagnostic lanes.
flows_exactly_at_thresholdExactly 1.0 L/s is safe under strict Less on both lanes.
primary_just_below_thresholdPrimary flow at 0.9 L/s is one test increment below the threshold.
secondary_just_below_thresholdSecondary flow at 0.9 L/s is one test increment below the threshold.
primary_violation_ends_one_step_before_delayThe primary candidate ends at 840 s and never matures.
mature_primary_alarm_recoversA mature primary missing-flow alarm clears immediately on restored branch flow.
two_short_primary_dropouts_resetTwo short primary-flow dropouts separated by recovery do not accumulate elapsed time.
missing_side_reversal_resets_timerA 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_delayA primary miss present at initialization still waits for persistence.
upstream_enable_misbound_raw_false_positiveAn 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
        }
      ]
    }
  ]
}