HW-0012 — Excess boiler stages at low plant load
| Status | verified — engine e2ff2f8, cxf:fnv1a128:5f5748540da32f7ac07fe2d05e6c46cb, 2026-08-20 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | EXCESS_CONSUMPTION |
| Confidence | MEDIUM |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | HW-0001, HW-0002, HW-0011 |
| Playbooks | hot-water-plant-faults |
| Source | LBNL Simulated Boiler Plant dataset inventory, PDF pp.4-8 — two identical parallel boilers, staged heat-load control, status channels, and useful secondary-loop power; it does not publish the shipped generic thresholds or an over-staging fault; PNNL Heating Plant Equipment Modeling Inputs — boiler type, sizing, efficiency, and stage-up part-load ratios are plant-specific inputs, supporting an adoption-blocking staging map rather than a portable threshold; Library precedent RTU-0001/TOWER-0003 — verified native integer stage/count comparison and initialization-safe continuous persistence |
| Operating states | normal automatic boiler staging after fleet, availability, load basis, minimum-time, and stage-transition state have settled |
Preconditions (host-enforced): boiler_stage_count must count proven firing staging units in one configured eligible fleet, not enabled or available units. A unit may be a whole boiler or a comparable modular burner section, but unlike units must not be mixed in one scalar count. hw_plant_load_fraction must be useful plant heating load divided by one commissioned eligible-fleet capacity basis, remain in 0..1, and keep that denominator stable through the evaluation window. If fleet membership or capacity basis changes, restart evaluation or report NO_EVAL. The threshold and allowed count require site commissioning against sizes, turndown, minimum flow, venting, emissions, redundancy, and minimum on/off policy; the shipped numbers are adoption-blocking placeholders. Exclude warm-up, freeze protection, emergency redundancy, exercise, maintenance, rotation, recent starts/stops, stage overlap, and intentional safety/application limits. OAT alone is not a valid load proxy. Freshness, alignment, finite values, and derivation provenance remain host obligations.
Points: boiler_stage_count, hw_plant_load_fraction
Outputs:
yFault— True after a valid low-load plant operates above the commissioned allowed firing-stage count continuously for sustained_durationyLoadOk— Evaluability output; true only for an inclusive 0..1 load fraction. False means NO_EVAL and every other output is uninterpretableyLowLoad— Raw diagnostic; true when load fraction is strictly below low_load_fraction, including for invalid negative values, so consult yLoadOk firstyExcessStages— Raw diagnostic; true when firing stage count is strictly above max_stages_at_low_load
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
low_load_fraction | 0.35 | 1 | lowLoad.t | NO_PORTABLE_DEFAULT: executable adoption-blocking placeholder. Replace from the commissioned staging map, boiler sizes, turndown, minimum-flow, and emissions constraints before deployment. |
max_stages_at_low_load | 1 | 1 | excessStages.t | NO_PORTABLE_DEFAULT: executable adoption-blocking placeholder. Replace with the allowed comparable firing-unit count for the configured fleet and load region. |
sustained_duration | 900.0 | s | persist.delayTime | ADOPTED_TUNABLE continuous over-staging proof. Set beyond ordinary stage overlap, rotation, and minimum-on/off transitions. |
Description
This rule identifies a configured boiler fleet keeping more firing units on than its commissioned staging map permits at low useful load. The point is not that two boilers are universally wrong: unequal machines, modular burners, minimum-flow requirements, redundancy, emissions, and minimum run time can make two units correct. Those facts define the adoption contract and are why both shipped decision thresholds deliberately block portable deployment.
Detection Logic
negative_load = hw_plant_load_fraction < 0
above_one = hw_plant_load_fraction > 1
yLoadOk = NOT negative_load AND NOT above_one
yLowLoad = hw_plant_load_fraction < low_load_fraction
yExcessStages = boiler_stage_count > max_stages_at_low_load
yFault = TrueDelay(yLoadOk AND yLowLoad AND yExcessStages,
sustained_duration)
Block graph (rule.cxf.jsonld):
The load-validity interval includes exactly 0 and 1. Exact load 0.35 and exact
stage count 1 are clear because both decision comparisons are strict. The graph
uses CDL.Integers.GreaterThreshold directly. delayOnInit=true requires the
full interval on evaluator startup and any false conjunct resets the timer.
Read yLoadOk first. A negative load makes raw yLowLoad=true, but it gates
yFault off and means NO_EVAL; non-finite/freshness checks remain host-side.
Possible Diagnoses
- Stage-down threshold, timer, or minimum-run logic set too conservatively.
- Lead/lag sequence leaving a second boiler latched after load falls.
- Enabled/available units mistakenly counted as proven firing units.
- Load numerator, commissioned capacity, or fleet membership derived wrong.
- Boiler sizes/turndown make the adopted scalar stage rule invalid.
- Redundancy, exercise, freeze, emissions, or minimum-flow mode not excluded.
Energy Impact
At the same useful load, excess firing machines can add jacket and standby loss, operate each burner below its efficient modulation region, and add purge or light-off cycles. Magnitude depends on equipment and sequence. Without measured fuel and a commissioned alternative staging model, the result remains qualitative and no generic savings percentage is claimed.
Emissions Impact
Any scope-1 impact follows the site-specific fuel penalty of the actual staging sequence. This rule has neither a fuel measurement nor a counterfactual staging model, so it does not assign an emissions quantity.
Deviations
- The brief classed 0.35 and one stage as adopted tunables. They are reclassified
NO_PORTABLE_DEFAULT: source material does not establish them, and boiler sizing, turndown, topology, and policy change the correct values materially. - The load denominator is one stable commissioned eligible-fleet capacity, not ambiguously “current” capacity. Availability changes require a new explicit configuration and evaluation restart.
- Range validity is implemented in-graph as
yLoadOk; finite values, source quality, and derivation provenance cannot be proven by inverted comparisons. - The LBNL plant has useful healthy staging channels but no injected over-stage fault. No local dataset was available and EnergyPlus’s target loop has only one real boiler, so this card records no simulation validation claim.
- No Boiler Control Instability cluster is created. Short cycling, hunting, and over-staging can co-occur, but no one trigger reliably occurs first or shares one repair that clears all members.
Notes
Investigate HW-0001 for resulting starts, HW-0011 for unstable modulation and temperature, and HW-0002 for measured efficiency degradation. Never reduce stages until manufacturer turndown, minimum flow, venting, emissions, safety, redundancy, and minimum-time requirements have been checked.
Test Vectors
22 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
zero_stages_at_low_load | A stopped plant at low load exposes only the low-load diagnostic and is not over-staged. |
one_stage_at_low_load | One firing boiler is the shipped allowance below the low-load threshold. |
two_stages_at_low_load_initialization | Two firing boilers at low load assert both diagnostics immediately: yFault is clear one tick before 900 s, asserts exactly at 900 s, and remains true after the full delay. |
multiple_stages_at_high_load | Multiple boilers are appropriate above the configured low-load region. |
load_exact_threshold_clear | Exactly 0.35 is not low load under the strict comparator. |
load_just_below_threshold | One thousandth below the load threshold activates the low-load branch. |
stage_count_exact_threshold_clear | Exactly one firing stage is not excess under the integer comparison. |
both_just_beyond_boundaries | Two stages and 0.349 load are the nearest representable faulting values on both branches. |
load_zero_endpoint_is_valid | Zero is an inclusive valid load endpoint and can support an over-staging finding. |
load_one_endpoint_is_valid | One is an inclusive valid load endpoint and is outside the low-load region. |
stage_overlap_ends_one_tick_before_persistence | A second stage drops out one evaluator step before 900 seconds and never alarms. |
mature_alarm_clears_when_stage_drops | A mature over-staging alarm clears immediately when the extra boiler stops firing. |
mature_alarm_clears_when_load_rises | A real load increase clears the low-load premise even while two boilers continue firing. |
two_short_overlaps_do_not_accumulate | Separated transient stage overlaps each receive a fresh timer. |
load_rise_resets_timer | A temporary load rise resets persistence before the original low-load condition returns. |
stage_drop_resets_timer | A one-stage interval resets persistence before the second stage returns. |
load_threshold_chatter_resets_delay | Load alternating around 0.35 never stays low for the full delay. |
stage_count_chatter_resets_delay | A second stage repeatedly entering and leaving below the proof duration never matures. |
negative_load_is_not_evaluable | A negative derived load is numerically low, but the in-graph range gate blocks the alarm and exposes NO_EVAL. |
load_above_one_is_not_evaluable | A derived load above one is invalid; yLoadOk prevents a false healthy interpretation even though low-load is false. |
enabled_count_misbinding_raw_false_positive | Counting enabled or available boilers instead of firing machines can create this raw alarm; the derivation contract must reject that binding. |
redundancy_warmup_is_host_excluded | Two firing boilers during an emergency-redundancy or warm-up interval are raw over-staging but explicitly outside normal automatic sequencing. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "zero_stages_at_low_load",
"description": "A stopped plant at low load exposes only the low-load diagnostic and is not over-staged.",
"inputs": {
"boiler_stage_count": 0,
"hw_plant_load_fraction": 0.1
},
"expect": [
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "one_stage_at_low_load",
"description": "One firing boiler is the shipped allowance below the low-load threshold.",
"inputs": {
"boiler_stage_count": 1,
"hw_plant_load_fraction": 0.2
},
"expect": [
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "two_stages_at_low_load_initialization",
"description": "Two firing boilers at low load assert both diagnostics immediately: yFault is clear one tick before 900 s, asserts exactly at 900 s, and remains true after the full delay.",
"inputs": {
"boiler_stage_count": 2,
"hw_plant_load_fraction": 0.2
},
"expect": [
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 900,
"to_s": 900,
"equals": true
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "multiple_stages_at_high_load",
"description": "Multiple boilers are appropriate above the configured low-load region.",
"inputs": {
"boiler_stage_count": 3,
"hw_plant_load_fraction": 0.8
},
"expect": [
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "load_exact_threshold_clear",
"description": "Exactly 0.35 is not low load under the strict comparator.",
"inputs": {
"boiler_stage_count": 2,
"hw_plant_load_fraction": 0.35
},
"expect": [
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "load_just_below_threshold",
"description": "One thousandth below the load threshold activates the low-load branch.",
"inputs": {
"boiler_stage_count": 1,
"hw_plant_load_fraction": 0.349
},
"expect": [
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "stage_count_exact_threshold_clear",
"description": "Exactly one firing stage is not excess under the integer comparison.",
"inputs": {
"boiler_stage_count": 1,
"hw_plant_load_fraction": 0.1
},
"expect": [
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "both_just_beyond_boundaries",
"description": "Two stages and 0.349 load are the nearest representable faulting values on both branches.",
"inputs": {
"boiler_stage_count": 2,
"hw_plant_load_fraction": 0.349
},
"expect": [
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "load_zero_endpoint_is_valid",
"description": "Zero is an inclusive valid load endpoint and can support an over-staging finding.",
"inputs": {
"boiler_stage_count": 2,
"hw_plant_load_fraction": 0.0
},
"expect": [
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "load_one_endpoint_is_valid",
"description": "One is an inclusive valid load endpoint and is outside the low-load region.",
"inputs": {
"boiler_stage_count": 2,
"hw_plant_load_fraction": 1.0
},
"expect": [
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "stage_overlap_ends_one_tick_before_persistence",
"description": "A second stage drops out one evaluator step before 900 seconds and never alarms.",
"inputs": {
"boiler_stage_count": [
{
"t": 0,
"value": 2
},
{
"t": 840,
"value": 1
}
],
"hw_plant_load_fraction": 0.2
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "mature_alarm_clears_when_stage_drops",
"description": "A mature over-staging alarm clears immediately when the extra boiler stops firing.",
"inputs": {
"boiler_stage_count": [
{
"t": 0,
"value": 2
},
{
"t": 1800,
"value": 1
}
],
"hw_plant_load_fraction": 0.2
},
"expect": [
{
"output": "yFault",
"from_s": 960,
"to_s": 1740,
"equals": true
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": false
},
{
"output": "yExcessStages",
"from_s": 1860,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "mature_alarm_clears_when_load_rises",
"description": "A real load increase clears the low-load premise even while two boilers continue firing.",
"inputs": {
"boiler_stage_count": 2,
"hw_plant_load_fraction": [
{
"t": 0,
"value": 0.2
},
{
"t": 1800,
"value": 0.5
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 960,
"to_s": 1740,
"equals": true
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": false
},
{
"output": "yLowLoad",
"from_s": 1860,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "two_short_overlaps_do_not_accumulate",
"description": "Separated transient stage overlaps each receive a fresh timer.",
"inputs": {
"boiler_stage_count": [
{
"t": 0,
"value": 2
},
{
"t": 600,
"value": 1
},
{
"t": 900,
"value": 2
},
{
"t": 1500,
"value": 1
}
],
"hw_plant_load_fraction": 0.2
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "load_rise_resets_timer",
"description": "A temporary load rise resets persistence before the original low-load condition returns.",
"inputs": {
"boiler_stage_count": 2,
"hw_plant_load_fraction": [
{
"t": 0,
"value": 0.2
},
{
"t": 600,
"value": 0.5
},
{
"t": 900,
"value": 0.2
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "stage_drop_resets_timer",
"description": "A one-stage interval resets persistence before the second stage returns.",
"inputs": {
"boiler_stage_count": [
{
"t": 0,
"value": 2
},
{
"t": 600,
"value": 1
},
{
"t": 900,
"value": 2
}
],
"hw_plant_load_fraction": 0.2
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "load_threshold_chatter_resets_delay",
"description": "Load alternating around 0.35 never stays low for the full delay.",
"inputs": {
"boiler_stage_count": 2,
"hw_plant_load_fraction": [
{
"t": 0,
"value": 0.349
},
{
"t": 180,
"value": 0.351
},
{
"t": 360,
"value": 0.349
},
{
"t": 540,
"value": 0.351
},
{
"t": 720,
"value": 0.349
},
{
"t": 900,
"value": 0.351
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "stage_count_chatter_resets_delay",
"description": "A second stage repeatedly entering and leaving below the proof duration never matures.",
"inputs": {
"boiler_stage_count": [
{
"t": 0,
"value": 2
},
{
"t": 180,
"value": 1
},
{
"t": 360,
"value": 2
},
{
"t": 540,
"value": 1
},
{
"t": 720,
"value": 2
},
{
"t": 900,
"value": 1
}
],
"hw_plant_load_fraction": 0.2
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "negative_load_is_not_evaluable",
"description": "A negative derived load is numerically low, but the in-graph range gate blocks the alarm and exposes NO_EVAL.",
"inputs": {
"boiler_stage_count": 2,
"hw_plant_load_fraction": -0.1
},
"expect": [
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "load_above_one_is_not_evaluable",
"description": "A derived load above one is invalid; yLoadOk prevents a false healthy interpretation even though low-load is false.",
"inputs": {
"boiler_stage_count": 2,
"hw_plant_load_fraction": 1.1
},
"expect": [
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "enabled_count_misbinding_raw_false_positive",
"description": "Counting enabled or available boilers instead of firing machines can create this raw alarm; the derivation contract must reject that binding.",
"inputs": {
"boiler_stage_count": 2,
"hw_plant_load_fraction": 0.1
},
"expect": [
{
"output": "yLowLoad",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yExcessStages",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "redundancy_warmup_is_host_excluded",
"description": "Two firing boilers during an emergency-redundancy or warm-up interval are raw over-staging but explicitly outside normal automatic sequencing.",
"inputs": {
"boiler_stage_count": 2,
"hw_plant_load_fraction": 0.2
},
"expect": [
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
},
{
"output": "yLoadOk",
"from_s": 0,
"to_s": 3600,
"equals": true
}
]
}
]
}