HW-0007 — HW supply temperature too high at low load
| Status | verified — engine e2ff2f8, cxf:fnv1a128:47d67bb2538aa6a4e4d0fcfb95b2726e, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | EXCESS_CONSUMPTION |
| Confidence | MEDIUM |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | HW-0008, HW-0004, HW-0005, HW-0006, HW-0003, HW-0010 |
| Playbooks | hot-water-plant-faults |
| Source | PNNL-27338 §4.4.2 (pp. 4.12-4.13), high HW supply temperature: pump VFD speed below 35% together with supply water above 130 °F over the averaging window; PNNL-27338 §4.5.2 (pp. 4.16-4.17) — the missing HWS reset this fault is usually the water-side symptom of; HW-0008 is its detector; PNNL-27338 §1.2, §2.1 — the data_window / no_required_data / max_dx_time gating this library expresses as preconditions and evaluability outputs; PNNL-27338 (Katipamula et al. 2018) — adapted via an internal paraphrased deep-read digest, not distributed (HW candidate #6); Library-authored extension: faults/hw/README.md index (name, severity, method); the HVAC FDD Reference v1.0 ch.14 specifies only HW-0001..052 and does not contain this rule; Sibling-rule precedent: CHW-0004 (evaluability floor + hour of persistence), HW-0003 (threshold + TrueDelay on a hot water plant), VFD-0001 (min_cmd_for_eval / yCmdOk) |
| Operating states | Heating plant enabled with the distribution loop circulating above min_pump_speed_for_eval — the rule’s own yLoopOk is that state |
Preconditions (host-enforced): hws_temp and hw_pump_vfd_speed must describe the same loop. On a primary/secondary or injection-mixed plant that is the precondition most often violated: the boiler’s own leaving-water temperature is high by design and says nothing about what the building gets, so bind hws_temp on the system side of the decoupler or mixing valve, and bind the speed from the distribution pump that serves it rather than a boiler primary circulator. On a lead/lag distribution pair, bind the lead pump’s speed or a host-computed plant speed; two pumps at 30% each are not one pump at 30%, and the graph cannot tell them apart. The loop must be variable-speed: a constant-volume distribution pump has no speed signal worth reading and this rule does not apply to it. Before first deployment, check high_hws_temp_threshold against the plant’s design supply temperature and the low end of whatever reset schedule exists — 54.4 °C is PNNL’s number for a typical hydronic system and it is below the design water temperature of an old cast-iron radiator plant, which will read faulted at every mild hour until someone decides whether the emitters can actually accept cooler water. Sites with combined heating/DHW plants must exclude the rule or gate it host-side, exactly as HW-0003 requires: a boiler holding 60 °C for service water is not a heating fault. Evaluability is signalled in-rule by yLoopOk: when it is false the verdict is NO_EVAL, not a plant making appropriate water.
Points: hw_pump_vfd_speed, hws_temp
Outputs:
yFault— True while the HW distribution pump has stayed below low_load_speed_threshold with supply water above high_hws_temp_threshold and the loop circulating, continuously for alarm_delayyLoopOk— Evaluability signal — true when hw_pump_vfd_speed is above min_pump_speed_for_eval, the speed below which the loop is not moving enough water for its supply temperature to describe how the plant is serving the building. False means NO_EVAL and the host must ignore yFault
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
low_load_speed_threshold | 35.0 | % | lowSpeed.t | Pump speed below which the loop counts as lightly loaded (PNNL-27338 §4.4.2). Pump speed is the load proxy: on a DP-controlled loop the drive slows as coil valves close, so a low speed means the building is drawing little of what the plant is making |
high_hws_temp_threshold | 54.4 | °C | hotSupply.t | Supply water temperature above which the plant is making full-temperature water (PNNL-27338 §4.4.2’s 130 °F). PER-PLANT SITE CONFIGURATION — check it against the design supply temperature and the bottom of the reset schedule before trusting a verdict (see Deviations) |
min_pump_speed_for_eval | 10.0 | % | circulating.t | Pump speed at or below which the loop is not judged to be circulating and nothing is evaluated. ADOPTED — PNNL specifies no plant-running gate, and without one a stopped pump reads as the lightest possible load (see Deviations) |
alarm_delay | 3600.0 | s | persist.delayTime | Continuous low-load-with-hot-water required before the alarm asserts (60 min). ADOPTED — PNNL publishes no alarm persistence for this measure, only a 15-60 min averaging window; 60 min is the top of that range and the library’s standing plant-rule persistence (HW-0003, CHW-0004) |
Description
A hot water plant that cannot lower its supply temperature spends the heating season paying design-day prices for mild-day heat. The boiler makes water hot enough for the coldest hour of the year and on a 10 °C afternoon the building takes almost none of it; what is left is standing loss plus a boiler at the least efficient end of its curve — on a condensing boiler, never condensing at all, because the return comes back as hot as the supply went out.
The rule reads that off two signals: pump speed says how much of the plant’s output the building is drawing, because a DP-controlled drive slows as the coil valves close, and supply temperature says what the plant is making anyway. Light draw with full-temperature water is nearly always the water-side symptom of a reset that is missing, disabled or bottomed out too high, which is why HW-0008 sits next to this card. Library-authored: ch.14 has no such rule, and the logic and both published thresholds come from PNNL-27338 §4.4.2.
Detection Logic
yLoopOk = hw_pump_vfd_speed > min_pump_speed_for_eval (false ⇒ host reports NO_EVAL)
yFault = yLoopOk
AND hw_pump_vfd_speed < low_load_speed_threshold
AND hws_temp > high_hws_temp_threshold,
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
lowSpeed and hotSupply are PNNL’s two conditions, both strict and both
reading their thresholds as parameters. lightLoad and overTemp chain
Logical.And rather than using a MultiAnd, which no card in this library
does.
circulating is the third comparison and the one PNNL does not specify. The
low-speed side of a low-speed test is exactly where a stopped pump lives: a
plant on overnight setback reads 0% with 65 °C water standing in the boiler and
satisfies both published conditions perfectly. The same comparison is exposed as
yLoopOk, so a host can tell “the loop is circulating and the water is
appropriate” from “nobody is pumping and the rule has no opinion” — two readings
that both come out as yFault = false.
persist requires 60 continuous minutes and carries delayOnInit = true.
Persistence is continuous, not accumulated: a pickup that takes the pumps to 40%
for ten minutes discards the elapsed time rather than pausing it. The alarm
falls on the tick the condition ends, with no release delay, and TrueDelay
asserts at exactly T + delayTime, so the realized test is “held for strictly
more than alarm_delay” at tick resolution.
Possible Diagnoses
Authored for this card — PNNL-27338 publishes detection thresholds and no diagnosis list, and the reference has no HW-0007 card to transcribe.
- No HW supply temperature reset programmed — the common case and a remote fix. HW-0008 detects the same failure directly, at the setpoint
- Reset programmed but its low end is too high. A schedule bottoming out at 65 °C looks correct in the sequence and behaves nearly as badly in the mild hours; this is the diagnosis that survives a casual review of the controls
- Reset disabled or overridden during a cold snap and never released
- Boiler minimum-temperature protection setting the floor. Where that limit is what stops the reset going lower the finding is real but the fix is a blending valve, a buffer arrangement or a condensing retrofit
- Boiler-local control ignoring the BAS — the aquastat runs its own high limit
and the reset never reaches the fire. Cross-check
hws_tempagainsthws_temp_sp; this is the one case where HW-0008 stays quiet while this rule fires - Emitters that genuinely need the temperature. An old cast-iron system sized for 82 °C water may be operating as designed — a plant-design finding, and the reason the threshold is site configuration rather than law
Energy Impact
EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION. The hot water playbook puts HW supply-temperature reset and DP reset together at 1–3% of site energy from PNNL-27338’s measure set, with no published split and no estimator that converts a flagged hour to kilowatts. The mechanism is definite: standing loss scales with how much hotter the water is than its surroundings, and the boiler-side term is small on a non-condensing machine and large on a condensing one. Confidence is MEDIUM because of the threshold rather than the measurement — the rule does not know the plant’s design supply temperature, and 54.4 °C at low load is a finding on modern coils and Tuesday on a 1950s radiator system. Heating-dominant, weighted to the mild hours where most heating hours live.
Emissions Impact
Scope 1, PROXY_EMISSIONS, MEDIUM confidence. The waste is fuel, not electricity:
the pump is doing useful work at whatever speed the loop needs, and what is
thrown away is the extra fuel burned to hold water hotter than the building
asked for, plus what leaks out of the pipe. A decarbonised grid does not touch
it, and the condensing case is worth separating in a report — a plant holding
its return above the dew point pays a combustion penalty on every therm. No
factor is applied here; the host multiplies whatever fuel figure it can bound
(see runtime_estimation) by a static combustion factor, as HW-0003 does.
Deviations
- This rule is a library extension, not a transcription. Ch.14 specifies
HW-0001, 051 and 052 and publishes no card, tunables table or vectors for
anything resembling this one. The detection logic and both published
thresholds are paraphrased from PNNL-27338 §4.4.2 (Katipamula et al. 2018);
name,severity: 3andmethod: rulearefaults/hw/README.md’s. - 130 °F ships as 54.4 °C, 0.044 K below the exact conversion. Shipping 54.444… would put a fifteen-digit literal in the graph to express a number the source stated to three significant figures. The shipped value is very slightly more sensitive than PNNL’s, by an amount an order of magnitude finer than any hot water sensor a building owns, and the parameter is per-plant configuration in any case.
min_pump_speed_for_evalis ADOPTED, and it is the largest departure from the source. PNNL’s algorithm is two conditions with no plant-running gate, which read literally is unsound on the low side: a stopped pump reads 0%, the lightest possible load, while boiler water stays hot for hours — every overnight setback would report the fault. PNNL gates every algorithm on data sufficiency (§1.2/§2.1), the same class of concern SCHEMA.md routes to preconditions and evaluability outputs, but nothing in §4.4.2 excludes a stopped pump. Closed here as a conjunct that is also exposed asyLoopOk; precedent is CHW-0004’syLoadOkand VFD-0001’syCmdOk.- The floor ships at 10%, well below the fault threshold, on purpose. Its
only job is to exclude a pump that is stopped or barely turning; the band
between it and
low_load_speed_thresholdis where the fault lives, and a pump at 15% is genuinely circulating and genuinely lightly loaded. VFD-0001 sets its analogous floor at 20% because it asks a harder question of the signal. Note the direction before retuning: raising this number narrows the fault band, and raising it above 35% deletes the rule. alarm_delayis ADOPTED at 60 minutes. PNNL publishes no fault persistence for this measure, only a 15–60 minute rolling average. Sixty is the top of that range and the library’s standing plant-rule persistence (HW-0003, CHW-0004), and it rides out the transient that would otherwise dominate: morning warm-up, when the loop is hot and the pumps have not ramped because the coil valves are still opening.- Continuous persistence replaces PNNL’s window average. The reference
averages both signals across the window; this rule requires the conjunction on
every tick for the full hour. For a steady condition — what a missing reset
produces — the two agree; they differ on intermittency, and this one is
stricter, so a loop alternating 20 minutes hot-and-loafing with 20 minutes busy
never alarms though its window average might trip. CHW-0004 made the same
trade.
Reals.MovingAverageis not the alternative: its fixed 64-checkpoint ring needs a tick of at least window/63 before it silently drops samples. - Pump speed alone is the load proxy; no OAT conjunct. PNNL’s neighbouring high-DP measure (§4.2.2, this library’s HW-0005) crosses pump speed with mild outdoor air and §4.4.2 deliberately does not — the pump speed is the load measurement, and a lightly loaded building on a cold, internally-driven afternoon is as valid a finding as one in April. An OAT gate would narrow the rule to the swing seasons and make two hot water cards answer nearly the same question. HW-0003 needs warm weather because it asks whether the plant should run at all; this one needs light load because it asks what temperature the running plant should make.
- Strict comparisons on all three thresholds. CDL
Realshas noLessEqualorGreaterEqualin any case, so a pump at exactly 35.0%, water at exactly 54.4 °C and a pump at exactly 10.0% all fall on the no-fault, no-eval side. Equality is measure-zero in continuous data and perfectly reachable in a BAS that scales drive feedback to whole percent, so each boundary carries an on-the-line vector plus one a tenth of a unit either side. Both the vector and the parameter spell 54.4, so the temperature boundary is decided by the strictness rather than by rounding. persist.delayOnInit = true(CDL defaultfalse), the library’s standing choice: a plant already loafing with hot water at controller restart waits out the full hour rather than alarming on the first tick.- No suppression contract with HW-0008, deliberately. The two rules see one
failure from opposite ends, and the tempting move is to suppress this one. The
findings are separable in both directions — a reset that moves correctly but
bottoms out at 65 °C fires this rule and not HW-0008, and a flat setpoint
parked at 50 °C fires HW-0008 and never trips 54.4 °C here — and suppression
is a two-card contract. They are
relatedand belong in one visit. relatedspans the family and is asserted here first.faults/hw/README.md’s Relationships section documents HW-0001/HW-0002/HW-0003 only and is another writer’s. One entry needs its sign stated: HW-0004 (low loop delta-T) can point the opposite way, since hotter supply water at a given return temperature raises delta-T, so a plant showing both has two causes rather than one.clusters: []. CLU-02 (“Missing Reset Strategy”) is the syndrome this fault belongs to and already carries the air-side consequence rule AHU-0031, this card’s structural analog one system over. But the cluster is AHU-scoped today, membership isclusters/clusters.json’s to declare, and the natural HW-side trigger is HW-0008 rather than this rule.playbooks: [hot-water-plant-faults]. Its Applies-To row names only HW-0001/HW-0002/HW-0003 — the index owner’s line to extend — but Step 1 already carries this fault’s remedy in full, including the OAT-based reset schedule, because it was written from the same PNNL measure set.missing-resetis the near miss: its procedure is SAT and DSP plots throughout, and the card that should claim it on the hot water side is HW-0008.- Blind spots, in the order they will bite. A constant-volume loop has no
speed signal and the rule does not apply — bind nothing rather than binding a
run status scaled to 100. A loop whose DP setpoint never resets holds its pumps
fast at low load, which suppresses this rule silently; that miss is
HW-0005/HW-0006’s finding. The rule sees water, not setpoints, so it cannot
separate “the reset never ran” from “the boiler ignored it” — diagnosis 5,
which needs
hws_temp_sp. And nothing guards against a supply sensor reading high: a 3 K offset moves this decision by more than half the distance between a well-reset plant and a flagged one. - No published test vectors. PNNL-27338 publishes none for its hot water
measures and the reference has no card for this fault, so every scenario in
vectors.jsonis authored from the equation and replayed against the pinned engine rev.
Notes
Read yLoopOk before yFault. A plant on setback, a summer weekend, or a loop
whose pumps have stopped all hold it false, and every yFault = false underneath
means “not evaluated” rather than “the water is at the right temperature”.
Trend hws_temp against hws_temp_sp and outdoor air for a week before
dispatching anyone. A supply temperature tracking a flat setpoint is diagnosis 1
or 2 and the fix is written from a desk; a setpoint that resets while the water
does not follow is diagnosis 5, at the boiler’s own controller; a floor no
schedule explains is diagnosis 4, a plant-design conversation. Only then ask
whether the emitters can accept cooler water — diagnosis 6, the one case where
the right outcome is to retune high_hws_temp_threshold and close the finding.
Where both fire, HW-0008 is the one to fix: it names the cause and this rule
measures the consequence.
Test Vectors
19 scenarios, clock step 300 s over 9000 s.
| Scenario | Description |
|---|---|
heating_season_high_load | A January afternoon: the loop is drawing everything the plant can make, pumps at 70% and supply water at 71 degC. Full-temperature water at full flow is the plant doing its job. |
mild_day_supply_temperature_reset_down | The healthy version of the fault’s own weather: pumps loafing at 22% because the building barely needs heat, and a working reset has taken the supply water down to 49 degC to match. Low load is not the finding; low load with hot water is. |
low_load_with_full_temperature_water | The fault as PNNL-27338 4.4.2 states it: pumps at 20% and supply water at 60 degC, held for the full alarm_delay. The boiler is making design-day water for a loop that is barely drawing, and the alarm asserts at t = 3600 s. |
pump_speed_exactly_at_the_low_load_threshold | Speed boundary, on the line: 35.0% with 60 degC water. Reals.LessThreshold is strict, so 35.0 is not below 35.0 and the loop does not count as lightly loaded. |
pump_speed_one_tenth_below_the_low_load_threshold | Speed boundary, from below: 34.9% is lightly loaded, so the same 60 degC supply water alarms at 3600 s. |
pump_speed_one_tenth_above_the_low_load_threshold | Speed boundary, from above: 35.1% and nothing is reported. With the on-the-line case, this pins the threshold from all three sides. |
hws_temp_exactly_at_the_high_supply_threshold | Temperature boundary, on the line: supply water at exactly 54.4 degC with the pumps at 20%. Reals.GreaterThreshold is strict, so the plant reads clear. Both the vector and the parameter spell 54.4, so the two are the same double and the comparison is decided by strictness rather than by rounding. |
hws_temp_one_tenth_above_the_high_supply_threshold | Temperature boundary, from above: a tenth of a degree warmer at 54.5 degC, same light load, and the alarm arrives at 3600 s. |
hws_temp_one_tenth_below_the_high_supply_threshold | Temperature boundary, from below: 54.3 degC reads clear, which with the two vectors above pins this threshold from all three sides. |
pump_speed_exactly_at_the_evaluability_floor | Evaluability boundary, on the line: 10.0% is not above 10.0%, so the loop is not judged to be circulating. yLoopOk is false for the whole run and the 60 degC supply water is never assessed. |
pump_speed_one_tenth_below_the_evaluability_floor | Evaluability boundary, from below: 9.9% with hot water. Same verdict and same reason — a drive at its lowest command is not evidence that the loop is moving water past the coils. |
pump_speed_one_tenth_above_the_evaluability_floor | Evaluability boundary, from above: 10.1% is circulating, so the same hot water is now a finding and alarms at 3600 s. The floor is pinned from all three sides. |
pump_off_with_hot_standing_water | The false positive the evaluability floor exists to prevent: an overnight setback with the pumps stopped and 65 degC water still standing in the boiler. Speed reads 0%, which is below 35% and would otherwise satisfy the reference’s two-condition test for as long as the water stays hot. yLoopOk is false, so the host reads NO_EVAL rather than a fault. |
supply_temperature_climbs_mid_run | A reset schedule losing its way: the loop sits at 20% all morning with 50 degC water, and at t = 1800 s the supply temperature climbs to 60 degC. The clock starts at the crossing, not at the start of the run, so the alarm lands at 5400 s. |
pump_speed_rises_and_restarts_the_clock | Persistence is continuous, not accumulated. A morning pickup takes the pumps to 40% for 600 s at t = 1800 s and the elapsed time is discarded rather than paused: the alarm lands at 6000 s, one full alarm_delay after the loop settles back to 20%. |
condition_ends_on_the_maturity_tick | Delay edge from below: the condition holds from t = 0 and the supply temperature drops to 50 degC at exactly 3600 s, the tick the timer matures on. The input is false when the timer comes due, so a full hour of overheated water is never reported. |
condition_ends_one_tick_later | Delay edge from above: the same run held 300 s longer asserts at exactly 3600 s and clears at 3900 s. With the vector above, both sides of the delay edge are pinned. |
supply_temperature_resets_down_after_the_alarm | Recovery, and the operator’s confirmation that a reset schedule took: the supply setpoint comes down and the water follows to 48 degC at t = 5400 s. yFault falls on that tick with no release delay, and yLoopOk stays true throughout — the loop was circulating the whole time, so the clearing verdict is a real one. |
pump_stops_after_the_alarm | The other way yFault falls, and the reason yLoopOk is a boundary output: the water stays at 60 degC but the pumps stop at t = 5400 s. yFault clears exactly as it does when the temperature resets down, and only yLoopOk says which of the two happened — here, nothing was fixed and the loop simply stopped being evaluable. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 9000
},
"scenarios": [
{
"name": "heating_season_high_load",
"description": "A January afternoon: the loop is drawing everything the plant can make, pumps at 70% and supply water at 71 degC. Full-temperature water at full flow is the plant doing its job.",
"inputs": {
"hw_pump_vfd_speed": 70.0,
"hws_temp": 71.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
},
{
"output": "yLoopOk",
"from_s": 0,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "mild_day_supply_temperature_reset_down",
"description": "The healthy version of the fault's own weather: pumps loafing at 22% because the building barely needs heat, and a working reset has taken the supply water down to 49 degC to match. Low load is not the finding; low load with hot water is.",
"inputs": {
"hw_pump_vfd_speed": 22.0,
"hws_temp": 49.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
},
{
"output": "yLoopOk",
"from_s": 0,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "low_load_with_full_temperature_water",
"description": "The fault as PNNL-27338 4.4.2 states it: pumps at 20% and supply water at 60 degC, held for the full alarm_delay. The boiler is making design-day water for a loop that is barely drawing, and the alarm asserts at t = 3600 s.",
"inputs": {
"hw_pump_vfd_speed": 20.0,
"hws_temp": 60.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 9000,
"equals": true
},
{
"output": "yLoopOk",
"from_s": 0,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "pump_speed_exactly_at_the_low_load_threshold",
"description": "Speed boundary, on the line: 35.0% with 60 degC water. `Reals.LessThreshold` is strict, so 35.0 is not below 35.0 and the loop does not count as lightly loaded.",
"inputs": {
"hw_pump_vfd_speed": 35.0,
"hws_temp": 60.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
},
{
"output": "yLoopOk",
"from_s": 0,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "pump_speed_one_tenth_below_the_low_load_threshold",
"description": "Speed boundary, from below: 34.9% is lightly loaded, so the same 60 degC supply water alarms at 3600 s.",
"inputs": {
"hw_pump_vfd_speed": 34.9,
"hws_temp": 60.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "pump_speed_one_tenth_above_the_low_load_threshold",
"description": "Speed boundary, from above: 35.1% and nothing is reported. With the on-the-line case, this pins the threshold from all three sides.",
"inputs": {
"hw_pump_vfd_speed": 35.1,
"hws_temp": 60.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "hws_temp_exactly_at_the_high_supply_threshold",
"description": "Temperature boundary, on the line: supply water at exactly 54.4 degC with the pumps at 20%. `Reals.GreaterThreshold` is strict, so the plant reads clear. Both the vector and the parameter spell 54.4, so the two are the same double and the comparison is decided by strictness rather than by rounding.",
"inputs": {
"hw_pump_vfd_speed": 20.0,
"hws_temp": 54.4
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "hws_temp_one_tenth_above_the_high_supply_threshold",
"description": "Temperature boundary, from above: a tenth of a degree warmer at 54.5 degC, same light load, and the alarm arrives at 3600 s.",
"inputs": {
"hw_pump_vfd_speed": 20.0,
"hws_temp": 54.5
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "hws_temp_one_tenth_below_the_high_supply_threshold",
"description": "Temperature boundary, from below: 54.3 degC reads clear, which with the two vectors above pins this threshold from all three sides.",
"inputs": {
"hw_pump_vfd_speed": 20.0,
"hws_temp": 54.3
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "pump_speed_exactly_at_the_evaluability_floor",
"description": "Evaluability boundary, on the line: 10.0% is not above 10.0%, so the loop is not judged to be circulating. yLoopOk is false for the whole run and the 60 degC supply water is never assessed.",
"inputs": {
"hw_pump_vfd_speed": 10.0,
"hws_temp": 60.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
},
{
"output": "yLoopOk",
"from_s": 0,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "pump_speed_one_tenth_below_the_evaluability_floor",
"description": "Evaluability boundary, from below: 9.9% with hot water. Same verdict and same reason \u2014 a drive at its lowest command is not evidence that the loop is moving water past the coils.",
"inputs": {
"hw_pump_vfd_speed": 9.9,
"hws_temp": 60.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
},
{
"output": "yLoopOk",
"from_s": 0,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "pump_speed_one_tenth_above_the_evaluability_floor",
"description": "Evaluability boundary, from above: 10.1% is circulating, so the same hot water is now a finding and alarms at 3600 s. The floor is pinned from all three sides.",
"inputs": {
"hw_pump_vfd_speed": 10.1,
"hws_temp": 60.0
},
"expect": [
{
"output": "yLoopOk",
"from_s": 0,
"to_s": 9000,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "pump_off_with_hot_standing_water",
"description": "The false positive the evaluability floor exists to prevent: an overnight setback with the pumps stopped and 65 degC water still standing in the boiler. Speed reads 0%, which is below 35% and would otherwise satisfy the reference's two-condition test for as long as the water stays hot. yLoopOk is false, so the host reads NO_EVAL rather than a fault.",
"inputs": {
"hw_pump_vfd_speed": 0.0,
"hws_temp": 65.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
},
{
"output": "yLoopOk",
"from_s": 0,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "supply_temperature_climbs_mid_run",
"description": "A reset schedule losing its way: the loop sits at 20% all morning with 50 degC water, and at t = 1800 s the supply temperature climbs to 60 degC. The clock starts at the crossing, not at the start of the run, so the alarm lands at 5400 s.",
"inputs": {
"hw_pump_vfd_speed": 20.0,
"hws_temp": [
{
"t": 0,
"value": 50.0
},
{
"t": 1800,
"value": 60.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5100,
"equals": false
},
{
"output": "yFault",
"from_s": 5700,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "pump_speed_rises_and_restarts_the_clock",
"description": "Persistence is continuous, not accumulated. A morning pickup takes the pumps to 40% for 600 s at t = 1800 s and the elapsed time is discarded rather than paused: the alarm lands at 6000 s, one full alarm_delay after the loop settles back to 20%.",
"inputs": {
"hws_temp": 60.0,
"hw_pump_vfd_speed": [
{
"t": 0,
"value": 20.0
},
{
"t": 1800,
"value": 40.0
},
{
"t": 2400,
"value": 20.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5700,
"equals": false
},
{
"output": "yFault",
"from_s": 6300,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "condition_ends_on_the_maturity_tick",
"description": "Delay edge from below: the condition holds from t = 0 and the supply temperature drops to 50 degC at exactly 3600 s, the tick the timer matures on. The input is false when the timer comes due, so a full hour of overheated water is never reported.",
"inputs": {
"hw_pump_vfd_speed": 20.0,
"hws_temp": [
{
"t": 0,
"value": 60.0
},
{
"t": 3600,
"value": 50.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "condition_ends_one_tick_later",
"description": "Delay edge from above: the same run held 300 s longer asserts at exactly 3600 s and clears at 3900 s. With the vector above, both sides of the delay edge are pinned.",
"inputs": {
"hw_pump_vfd_speed": 20.0,
"hws_temp": [
{
"t": 0,
"value": 60.0
},
{
"t": 3900,
"value": 50.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yFault",
"from_s": 3600,
"to_s": 3600,
"equals": true
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 9000,
"equals": false
}
]
},
{
"name": "supply_temperature_resets_down_after_the_alarm",
"description": "Recovery, and the operator's confirmation that a reset schedule took: the supply setpoint comes down and the water follows to 48 degC at t = 5400 s. yFault falls on that tick with no release delay, and yLoopOk stays true throughout \u2014 the loop was circulating the whole time, so the clearing verdict is a real one.",
"inputs": {
"hw_pump_vfd_speed": 20.0,
"hws_temp": [
{
"t": 0,
"value": 60.0
},
{
"t": 5400,
"value": 48.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 5100,
"equals": true
},
{
"output": "yFault",
"from_s": 5400,
"to_s": 9000,
"equals": false
},
{
"output": "yLoopOk",
"from_s": 0,
"to_s": 9000,
"equals": true
}
]
},
{
"name": "pump_stops_after_the_alarm",
"description": "The other way yFault falls, and the reason yLoopOk is a boundary output: the water stays at 60 degC but the pumps stop at t = 5400 s. yFault clears exactly as it does when the temperature resets down, and only yLoopOk says which of the two happened \u2014 here, nothing was fixed and the loop simply stopped being evaluable.",
"inputs": {
"hws_temp": 60.0,
"hw_pump_vfd_speed": [
{
"t": 0,
"value": 20.0
},
{
"t": 5400,
"value": 0.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 5100,
"equals": true
},
{
"output": "yFault",
"from_s": 5400,
"to_s": 9000,
"equals": false
},
{
"output": "yLoopOk",
"from_s": 0,
"to_s": 5100,
"equals": true
},
{
"output": "yLoopOk",
"from_s": 5400,
"to_s": 9000,
"equals": false
}
]
}
]
}