HW-0010 — Hot-water supply temperature tracking failure
| Status | verified — engine e2ff2f8, cxf:fnv1a128:d680c1142ee5617cde72f0dbcc028046, 2026-08-20 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | COMFORT_ENERGY |
| Confidence | MEDIUM |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | HW-0002, HW-0004, HW-0007, HW-0008, HW-0009, HW-0011, FPB-0006 |
| Playbooks | hot-water-plant-faults |
| Source | NIST, Automatically Detecting Faulty Regulation in HVAC Controls (2013), pp. 412 and 416-419 — regulated-variable allowance bands, transient exclusions, and field-tuned alarm parameters; LBNL Simulated Boiler Plant dataset inventory, PDF pp.4-8 — hot-water loop supply temperature/setpoint/status channels and separate sensor-bias, fouling, and poor-PI fault cases; the dataset is a future replay target, not a completed validation claim; Library precedent CHW-0007 — verified strict mirrored hydronic tracking-error topology and continuous 900 s persistence |
| Operating states | normal automatic hot-water operation after startup, with at least one boiler proven firing and distribution circulation established |
Preconditions (host-enforced): hws_temp and hws_temp_sp must describe the same controlled outlet or common header and final active target. A common header plus an OR of firing statuses is valid only when all points belong to the same configured plant; it is not an individual-boiler outlet comparison. Exclude warm-up and setback recovery, setpoint/reset ramps, stage or pump changes, minimum-flow transitions, tuning tests, and intentional demand, high-limit, fuel, flame-safeguard, freeze, emissions, or other capacity limits until the plant has settled. boiler_status must represent firing rather than enable, and hw_pump_status must establish distribution circulation. Temperature, status, and setpoint signals must be fresh, aligned, calibrated, and in the declared units. When any obligation is unmet the verdict is NO_EVAL, not healthy.
Points: hws_temp, hws_temp_sp, boiler_status, hw_pump_status
Outputs:
yFault— True after the active plant remains more than tracking_error above or below its final HWS target continuously for sustained_durationyTooCold— Immediate direction flag; true while the active plant is more than tracking_error below setpointyTooHot— Immediate direction flag; true while the active plant is more than tracking_error above setpoint
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
tracking_error | 2.0 | K | tooHot.t, tooCold.t | ADOPTED_TUNABLE symmetric settled tracking allowance. Commission above combined measurement error, setpoint resolution, and the controller’s normal deadband; exact equality is clear. |
sustained_duration | 900.0 | s | persist.delayTime | LIBRARY_PRECEDENT from CHW-0007’s identical hydronic tracking form. Confirm it exceeds ordinary plant response after every excluded transition. |
Description
This rule reports a firing, circulating hot-water plant that cannot hold the same target its supply-temperature point is meant to control. The direction is diagnostic, not causal: cold water may reflect capacity, flow, fouling, staging, or an intentional limit the host failed to exclude; hot water may reflect overshoot, aggressive staging, a bad target, sensor bias, or the wrong header.
Detection Logic
error = hws_temp - hws_temp_sp
too_hot = error > tracking_error
too_cold = -error > tracking_error
plant_active = boiler_status AND hw_pump_status
yTooHot = plant_active AND too_hot
yTooCold = plant_active AND too_cold
yFault = TrueDelay(yTooHot OR yTooCold, sustained_duration)
Block graph (rule.cxf.jsonld):
Both comparisons are strict, so exactly +/-2 K is clear. The single delay is
after their OR: a directly sampled hot-to-cold jump without an in-band tick
preserves persistence, while an in-band, boiler-off, or pump-off tick resets it.
delayOnInit=true requires the complete interval after evaluator startup.
Possible Diagnoses
- Boiler capacity, fuel input, heat exchanger, or minimum-flow limitation.
- Poor temperature-loop tuning, excessive integral action, or plant delay.
- Stage command, firing proof, distribution pump, or control-valve problem.
- Active setpoint not reaching the local boiler or mixing controller.
- Temperature sensor bias, poor placement, stale delivery, or wrong header.
- A real demand, reset, safety, emissions, or high-limit condition omitted from the host gate.
Energy Impact
The finding is qualitative. Sustained over-temperature can increase pipe loss and keep a condensing plant above its efficient return-temperature region; under-temperature can increase terminal/pump effort or shift load to other heat. The graph has no fuel or delivered-load model, so it does not invent savings.
Emissions Impact
Any scope-1 effect follows the change in boiler fuel use and cannot be inferred from error direction alone. Quantification requires measured fuel or a validated load-and-efficiency model; this rule reports no generic emissions reduction.
Deviations
- This is a library-authored application of NIST regulation concepts and the CHW-0007 graph, not a source-transcribed boiler rule.
- The shipped 2 K allowance is adopted and tunable; neither NIST nor LBNL publishes it as a portable boiler threshold.
- The LBNL dataset is cited for point/fault coverage only. No LBNL replay was available locally for this slice, so no LBNL-derived TPR or FPR claim is recorded; the frontmatter separately records limited healthy EnergyPlus FPR evidence.
- Confidence is MEDIUM rather than the brief’s proposed HIGH because common- header topology, sensor bias, and intentional plant limits remain material confounders even with the stated host gates.
- Direction flags include the active-plant gate. When the plant stops, they and
yFaultclear immediately; the host must report NO_EVAL outside the stated operating state.
Notes
Read HW-0009 when command and proof disagree, HW-0008 when the target itself is not resetting, and HW-0002/HW-0004 when temperature tracking coexists with efficiency or low-delta-T evidence. These rules may co-occur and do not suppress one another.
Test Vectors
19 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
inactive_plant_with_error | A stopped plant is outside the rule’s operating premise even with a large temperature error. |
active_on_setpoint | A firing boiler and running pump holding the active target are healthy. |
too_cold_at_initialization | A standing cold-side violation exposes direction immediately: yFault is clear one tick before 900 s, asserts exactly at 900 s, and remains true after the initialization-safe delay. |
too_hot_at_initialization | The mirror hot-side violation uses the same continuous persistence. |
hot_error_exact_boundary_clear | Exactly +2 K is clear under the strict comparator. |
hot_error_just_beyond_boundary | One hundredth kelvin above the hot boundary follows the full delay. |
cold_error_exact_boundary_clear | Exactly -2 K is clear under the mirrored strict comparator. |
cold_error_just_beyond_boundary | One hundredth kelvin past the cold boundary follows the full delay. |
boiler_only_is_inactive | Boiler firing without established distribution-pump proof gates off both directions. |
pump_only_is_inactive | Pump circulation without a firing heat source is not tracking evidence for this rule. |
violation_ends_one_tick_before_persistence | The cold error returns to the band one evaluator step before 900 s and never matures. |
mature_alarm_recovers | A mature hot-side alarm and its diagnostic clear immediately after temperature recovers. |
two_short_episodes_do_not_accumulate | Two cold episodes separated by an in-band interval each start a fresh timer. |
pump_proof_drop_resets_timer | Loss and restoration of pump proof gives the continuing temperature error a fresh 900-second clock. |
boiler_proof_drop_resets_timer | A firing-status interruption independently resets persistence. |
direct_direction_handoff_preserves_timer | A sampled hot-to-cold handoff never re-enters the band, so the shared out-of-band timer continues while diagnostics swap. |
setpoint_step_settles_before_delay | A reset step creates raw cold error, but temperature catches the new target before persistence. |
temperature_threshold_chatter_resets_delay | Alternating just inside and outside the hot boundary never accumulates continuous persistence. |
common_header_binding_is_host_excluded | The graph alarms on a coherent raw tuple even if this header is not the firing boiler’s controlled target; topology proof is a mandatory host gate. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "inactive_plant_with_error",
"description": "A stopped plant is outside the rule's operating premise even with a large temperature error.",
"inputs": {
"hws_temp": 50.0,
"hws_temp_sp": 60.0,
"boiler_status": false,
"hw_pump_status": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yTooCold",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yTooHot",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "active_on_setpoint",
"description": "A firing boiler and running pump holding the active target are healthy.",
"inputs": {
"hws_temp": 60.0,
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yTooCold",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yTooHot",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "too_cold_at_initialization",
"description": "A standing cold-side violation exposes direction immediately: yFault is clear one tick before 900 s, asserts exactly at 900 s, and remains true after the initialization-safe delay.",
"inputs": {
"hws_temp": 57.0,
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yTooCold",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yTooHot",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"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
}
]
},
{
"name": "too_hot_at_initialization",
"description": "The mirror hot-side violation uses the same continuous persistence.",
"inputs": {
"hws_temp": 63.0,
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yTooHot",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yTooCold",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "hot_error_exact_boundary_clear",
"description": "Exactly +2 K is clear under the strict comparator.",
"inputs": {
"hws_temp": 62.0,
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yTooCold",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yTooHot",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "hot_error_just_beyond_boundary",
"description": "One hundredth kelvin above the hot boundary follows the full delay.",
"inputs": {
"hws_temp": 62.01,
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yTooHot",
"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
}
]
},
{
"name": "cold_error_exact_boundary_clear",
"description": "Exactly -2 K is clear under the mirrored strict comparator.",
"inputs": {
"hws_temp": 58.0,
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yTooCold",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yTooHot",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "cold_error_just_beyond_boundary",
"description": "One hundredth kelvin past the cold boundary follows the full delay.",
"inputs": {
"hws_temp": 57.99,
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yTooCold",
"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
}
]
},
{
"name": "boiler_only_is_inactive",
"description": "Boiler firing without established distribution-pump proof gates off both directions.",
"inputs": {
"hws_temp": 50.0,
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yTooCold",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yTooHot",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "pump_only_is_inactive",
"description": "Pump circulation without a firing heat source is not tracking evidence for this rule.",
"inputs": {
"hws_temp": 50.0,
"hws_temp_sp": 60.0,
"boiler_status": false,
"hw_pump_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yTooCold",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yTooHot",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "violation_ends_one_tick_before_persistence",
"description": "The cold error returns to the band one evaluator step before 900 s and never matures.",
"inputs": {
"hws_temp": [
{
"t": 0,
"value": 57.0
},
{
"t": 840,
"value": 60.0
}
],
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "mature_alarm_recovers",
"description": "A mature hot-side alarm and its diagnostic clear immediately after temperature recovers.",
"inputs": {
"hws_temp": [
{
"t": 0,
"value": 63.0
},
{
"t": 1800,
"value": 60.0
}
],
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 960,
"to_s": 1740,
"equals": true
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": false
},
{
"output": "yTooHot",
"from_s": 1860,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "two_short_episodes_do_not_accumulate",
"description": "Two cold episodes separated by an in-band interval each start a fresh timer.",
"inputs": {
"hws_temp": [
{
"t": 0,
"value": 57.0
},
{
"t": 600,
"value": 60.0
},
{
"t": 900,
"value": 57.0
},
{
"t": 1500,
"value": 60.0
}
],
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "pump_proof_drop_resets_timer",
"description": "Loss and restoration of pump proof gives the continuing temperature error a fresh 900-second clock.",
"inputs": {
"hws_temp": 57.0,
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": [
{
"t": 0,
"value": true
},
{
"t": 600,
"value": false
},
{
"t": 900,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "boiler_proof_drop_resets_timer",
"description": "A firing-status interruption independently resets persistence.",
"inputs": {
"hws_temp": 63.0,
"hws_temp_sp": 60.0,
"boiler_status": [
{
"t": 0,
"value": true
},
{
"t": 600,
"value": false
},
{
"t": 900,
"value": true
}
],
"hw_pump_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "direct_direction_handoff_preserves_timer",
"description": "A sampled hot-to-cold handoff never re-enters the band, so the shared out-of-band timer continues while diagnostics swap.",
"inputs": {
"hws_temp": [
{
"t": 0,
"value": 63.0
},
{
"t": 600,
"value": 57.0
}
],
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yTooHot",
"from_s": 0,
"to_s": 540,
"equals": true
},
{
"output": "yTooCold",
"from_s": 660,
"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
}
]
},
{
"name": "setpoint_step_settles_before_delay",
"description": "A reset step creates raw cold error, but temperature catches the new target before persistence.",
"inputs": {
"hws_temp": [
{
"t": 0,
"value": 60.0
},
{
"t": 600,
"value": 64.0
}
],
"hws_temp_sp": [
{
"t": 0,
"value": 60.0
},
{
"t": 300,
"value": 64.0
}
],
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "temperature_threshold_chatter_resets_delay",
"description": "Alternating just inside and outside the hot boundary never accumulates continuous persistence.",
"inputs": {
"hws_temp": [
{
"t": 0,
"value": 62.01
},
{
"t": 180,
"value": 61.99
},
{
"t": 360,
"value": 62.01
},
{
"t": 540,
"value": 61.99
},
{
"t": 720,
"value": 62.01
},
{
"t": 900,
"value": 61.99
}
],
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "common_header_binding_is_host_excluded",
"description": "The graph alarms on a coherent raw tuple even if this header is not the firing boiler's controlled target; topology proof is a mandatory host gate.",
"inputs": {
"hws_temp": 55.0,
"hws_temp_sp": 60.0,
"boiler_status": true,
"hw_pump_status": true
},
"expect": [
{
"output": "yTooCold",
"from_s": 0,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
}
]
}
]
}