Slice S0380.79: (57)m solar storage adjustment + separately-timed-DHW cylinder default

Closes cert 000565 sap_score regression — 28 (Δ−1) → **29 ✓ EXACT**.
Continuous SAP 28.4735 → 28.5652 (Δ −0.035 → +0.057 vs worksheet
28.5087). Two coupled fixes that together close the (56)/(57)m
storage-loss gap per SAP 10.2 §4 + Table 2b.

## 1. (57)m solar storage adjustment — SAP 10.2 §4 line 7693 (p.137)

    If the vessel contains dedicated solar storage or dedicated
    WWHRS storage,
          (57)m = (56)m × [(47) - Vs] ÷ (47), else (57)m = (56)m
    where Vs is Vww from Appendix G3 or (H12) from Appendix H.

    Total heat required for water heating calculated for each month
          (62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m

(62)m sums (57)m — the solar-adjusted storage loss — not raw
(56)m. The cascade's `_cylinder_storage_loss_override` was
passing (56)m straight through as `solar_storage_monthly_kwh_
override`, over-counting (62)m by Vs/V each month whenever solar
HW shares the cylinder. For cert 000565: V = 160 L, Vs = (H12) =
53 L per the combined-cylinder ⅓-volume convention (S0380.76);
(V − Vs)/V = 0.6688 (matches worksheet 50.7018/75.8157 = 0.6688).

Fix: when `epc.solar_water_heating` is True, return (57)m =
(56)m × (V − Vs)/V from `_cylinder_storage_loss_override`. The
combined-cylinder Vs derivation reuses the
`_COMBINED_CYLINDER_SOLAR_PREHEAT_FRACTION` constant established
by S0380.76 for the Appendix H orchestrator path.

## 2. separately_timed_dhw defaults True when a cylinder is lodged

SAP 10.2 Table 2b note b) (PDF p.159):

    Multiply Temperature Factor by 0.9 if there is separate time
    control of domestic hot water (boiler systems, warm air
    systems and heat pump systems)

RdSAP 10 Specification §3 default table "Hot water separately
timed" (PDF p.57):

    No programmer, pre-1998 boiler: - No
    Programmer, pre-1998 boiler: - Yes
    Post-1998 boiler: - Yes

When a hot-water cylinder is lodged, DHW is timed by its own
programmer / boost cycle regardless of which heat generator
(boiler, HP, or combi-acting-as-boiler) feeds it. Combi-only
dwellings (no cylinder) skip the multiplier — DHW is
instantaneous and shares the boiler's space-heating cycle.

The earlier `_separately_timed_dhw` heuristic gated only on
`main_heating_category == 4` (heat pumps), returning False for
boiler-family + cylinder combos. Cert 000565 (gas combi via
WHC 914 + 160 L cylinder + cyl-stat absent) fell through to TF
= 0.60 × 1.3 × 1.0 = 0.78; the worksheet uses 0.60 × 1.3 × 0.9
= 0.702. The 10% TF over-count drove +98 kWh/yr into (56)m before
compounding with the missing (57)m solar adjustment.

Fix: `_separately_timed_dhw(epc, main)` returns True when a
cylinder is lodged, in addition to the existing HP branch. Signature
gains `epc` so the helper can inspect `has_hot_water_cylinder`;
both call sites in `_primary_loss_override` and
`_cylinder_storage_loss_override` updated.

## Cert 000565 movements at HEAD (post-S0380.78 → post-this slice)

| Field                | Pre-slice  | Post-slice |  Worksheet | Pre-Δ   | Post-Δ  |
|----------------------|-----------:|-----------:|-----------:|--------:|--------:|
| **sap_score**        |       **28** |       **29** |       **29** |    −1   |  **✓ 0**  |
| sap_score_continuous |    28.4735 |    28.5652 |    28.5087 | −0.035  |  +0.057 |
| ecf                  |     5.3904 |     5.3810 |     5.3866 | +0.004  |  −0.006 |
| total_fuel_cost_gbp  |    4683.39 |    4675.23 |    4680.26 |  +3.13  |   −5.03 |
| co2_kg               |    6480.57 |    6388.80 |    6447.63 |  +33    |   −58.8 |
| hot_water_kwh        |    4014.64 |    3517.37 |    3755.03 | +259.6  |  −237.7 |
| space_heating_kwh    |   58792.99 |   58936.06 |   59008.35 | −215.4  |   −72.3 |
| main_heating_fuel    |   34584.11 |   34668.27 |   34710.79 | −126.7  |   −42.5 |

HW pin overshot −238 (down from +260) — within ~6% of the
worksheet, vs the +37% over-count three slices ago. Continuous
SAP residual flipped from Δ −0.035 to Δ +0.057, restoring integer
sap_score = 29 EXACT. The cumulative cert 000565 closure across
S0380.77/78/79:
  hot_water_kwh:   +1399  →  +260  →  −238   (84% closed)
  sap_score_cont:  +0.60  → −0.035 →  +0.057 (90% closed)
  ecf:             −0.06  → +0.004 →  −0.006 (90% closed)

## Cross-cohort impact — cert 0390 golden pin update

Golden cert `0390-2954-3640-2196-4175` (Firebird oil combi PCDF
9005 + 160 L cylinder + cyl-stat=Y, no solar) was previously
flagged at SAP residual −7 with the comment "traces to fabric
heat-loss / oil-fuel cost cascade rather than the §4 HW path".
That diagnosis was wrong: cert 0390's §4 HW cascade WAS
applying TF=0.60 instead of TF=0.54 for the (56)m storage loss,
contributing ~£20/yr cost over-count.

Per [[feedback-spec-floor-skepticism]] + [[feedback-golden-
residuals-near-zero]], the +1 SAP closure (53→54, residual
−7→−6) is the spec-correct outcome of applying RdSAP §3 default
"Programmer, pre-1998 boiler → Yes". Pin updated; revised notes
record the slice S0380.79 attribution.

## Tests

- `test_cylinder_storage_loss_applies_57m_solar_adjustment_per_sap_4_line_7693`
  (test_cert_to_inputs.py) — pins `solar_storage_monthly_kwh[0]` to
  worksheet (57)Jan = 50.7018 at abs=1e-4 and the 12-month sum to
  596.9725 at abs=1e-3, for cert-000565-shape inputs (gas combi +
  cylinder + solar HW + cyl-stat absent).
- Updated golden pin for cert 0390 per the cross-cohort impact note.

Test baseline: 548 → 550 pass + 9 expected `test_sap_result_pin
[000565-*]` fails (sap_score now PASSING; one fewer expected fail
than mid-slice). Pyright net-zero on touched files (46 baseline =
46 after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-29 21:46:12 +00:00
parent 509ef4fbbf
commit f9551355bb
3 changed files with 146 additions and 23 deletions

View file

@ -2749,19 +2749,28 @@ _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = {
_CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1
def _separately_timed_dhw(main: Optional[MainHeatingDetail]) -> bool:
"""RdSAP §3 default table (PDF p.57): "Hot water separately timed —
Post-1998 boiler: Yes". Heat pumps (cat 4) and heat networks (cat 3,
6) always have programmer-driven DHW timing, so default to True for
those mains. For boiler-family mains (cat 1, 2) the cohort closes
via the heuristic that age band K, L, M (post-2007) True; older
bands keep the spec's no-programmer default of False.
def _separately_timed_dhw(
epc: EpcPropertyData, main: Optional[MainHeatingDetail],
) -> bool:
"""SAP 10.2 Table 2b note b) (PDF p.159): "Multiply Temperature
Factor by 0.9 if there is separate time control of domestic hot
water (boiler systems, warm air systems and heat pump systems)".
RdSAP §3 default: when a hot-water cylinder is lodged, DHW timing
is separate from space heat the cylinder is heated on its own
programmer / overnight boost regardless of which heat generator
(boiler, HP, or combi-acting-as-boiler) feeds it.
Combi-only dwellings (no cylinder) skip the multiplier DHW is
instantaneous and shares the boiler's space-heating cycle, so
there's no separate timer. Heat pumps (cat 4) keep their existing
always-True default for the HP-without-cylinder edge case the
earlier cohort calibration was sized around.
"""
if main is None:
return False
if main.main_heating_category == 4:
return True
return False
return bool(epc.has_hot_water_cylinder)
# RdSAP §3 default table (PDF p.56) — "Insulation of primary pipework":
@ -3295,7 +3304,7 @@ def _primary_loss_override(
primary_age
),
has_cylinder_thermostat=epc.sap_heating.cylinder_thermostat == "Y",
separately_timed_dhw=_separately_timed_dhw(main),
separately_timed_dhw=_separately_timed_dhw(epc, main),
)
@ -3303,12 +3312,23 @@ def _cylinder_storage_loss_override(
epc: EpcPropertyData,
main: Optional[MainHeatingDetail],
) -> Optional[tuple[float, ...]]:
"""Resolve (56)m for `water_heating_from_cert` from the cert's lodged
"""Resolve (57)m for `water_heating_from_cert` from the cert's lodged
cylinder fields. Returns None when no cylinder is lodged so the
cascade keeps its existing zero-storage-loss default for combi /
instantaneous systems. Per SAP 10.2 §4 line 7693 the (57)m solar
adjustment equals (56)m when no dedicated solar storage volume is
present (cohort certs have none).
instantaneous systems.
SAP 10.2 §4 line 7693 (PDF p.137):
If the vessel contains dedicated solar storage or dedicated
WWHRS storage,
(57)m = (56)m × [(47) - Vs] ÷ (47), else (57)m = (56)m
where Vs is Vww from Appendix G3 or (H12) from Appendix H.
`water_heating_from_cert` feeds the override straight into (62)m
via `solar_storage_monthly_kwh`, so the helper returns the (57)m
series (solar-adjusted when applicable), not raw (56)m. Vs derives
from the same combined-cylinder -volume convention used by
`_solar_hw_monthly_override` per S0380.76.
"""
if not epc.has_hot_water_cylinder:
return None
@ -3324,13 +3344,20 @@ def _cylinder_storage_loss_override(
thickness_mm = sh.cylinder_insulation_thickness_mm
if thickness_mm is None:
return None
return cylinder_storage_loss_monthly_kwh(
storage_56m = cylinder_storage_loss_monthly_kwh(
volume_l=volume_l,
insulation_type="factory_insulated",
thickness_mm=float(thickness_mm),
has_cylinder_thermostat=sh.cylinder_thermostat == "Y",
separately_timed_dhw=_separately_timed_dhw(main),
separately_timed_dhw=_separately_timed_dhw(epc, main),
)
# (57)m solar adjustment when solar HW + dedicated solar storage
# share the cylinder. Vs follows the combined-cylinder convention.
if not epc.solar_water_heating:
return storage_56m
vs_l = round(volume_l * _COMBINED_CYLINDER_SOLAR_PREHEAT_FRACTION)
factor = (volume_l - vs_l) / volume_l
return tuple(s * factor for s in storage_56m)
def _apply_water_efficiency(

View file

@ -1802,6 +1802,100 @@ def test_cert_with_hot_water_cylinder_computes_primary_loss_59m_from_sap_table_3
)
def test_cylinder_storage_loss_applies_57m_solar_adjustment_per_sap_4_line_7693() -> None:
"""SAP 10.2 §4 line 7693 (PDF p.137):
If the vessel contains dedicated solar storage or dedicated
WWHRS storage,
(57)m = (56)m × [(47) - Vs] ÷ (47), else (57)m = (56)m
where Vs is Vww from Appendix G3 or (H12) from Appendix H (as
applicable).
Total heat required for water heating calculated for each month
(62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m
(62)m sums (57)m the solar-adjusted storage loss not (56)m. When
solar HW is present the cascade was passing (56)m unchanged as
`solar_storage_monthly_kwh_override`, over-counting (62)m by
(56)m × Vs / V each month.
SAP 10.2 Table 2b note b) (PDF p.159): "Multiply Temperature Factor
by 0.9 if there is separate time control of domestic hot water
(boiler systems, warm air systems and heat pump systems)". RdSAP §3
default: when a hot-water cylinder is present, DHW timing is
separate from space heating (the cylinder is heated on its own
timer / boost). The cohort heuristic that gated separately-timed
on `main_heating_category == 4` missed cert 000565's gas-combi-
plus-cylinder topology (cat=2 + WHC 914 + cylinder), driving TF up
from 0.702 (worksheet) to 0.78 (cascade) a further ~98 kWh/yr
over-count on top of the missing (57)m solar adjustment.
Cert 000565 worksheet lines (Block 1):
(56)m Jan = 75.8157, (56) sum 892.66
(57)m Jan = 50.7018, (57) sum 596.97
With V = 160 L, Vs = (H12) = 53 L per the combined-cylinder
convention (S0380.76), (V Vs) / V = 0.6688 matching the
worksheet ratio (50.7018 / 75.8157).
"""
# Arrange — cert 000565 shape: ASHP Main 1 + gas combi Main 2 +
# WHC 914 + 160 L cylinder + cylinder thermostat absent + solar HW
# lodged. Per RdSAP §3 default, the lodged cylinder makes DHW
# separately-timed regardless of which main is the heat generator.
hp_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2206,
main_heating_category=None,
sap_main_heating_code=224,
)
combi_main = _gas_boiler_detail(sap_main_heating_code=102)
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
has_hot_water_cylinder=True,
sap_building_parts=[make_building_part(construction_age_band="D")],
sap_heating=make_sap_heating(
main_heating_details=[hp_main, combi_main],
water_heating_code=914,
cylinder_size=3, # 160 L
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=25,
cylinder_thermostat="N",
),
solar_water_heating=True,
)
# Act
wh_result, _ = _water_heating_worksheet_and_gains(
epc=epc,
water_efficiency_pct=0.88,
is_instantaneous=False,
primary_age="D",
pcdb_record=None,
)
# Assert — solar_storage_monthly_kwh is the (57)m solar-adjusted
# series the cascade feeds into (62)m, not raw (56)m. Pin Jan and
# the annual sum at abs=1e-4 vs cert 000565 worksheet.
assert wh_result is not None
expected_57_jan = 50.7018
expected_57_sum = 596.9725
got_57_jan = wh_result.solar_storage_monthly_kwh[0]
got_57_sum = sum(wh_result.solar_storage_monthly_kwh)
assert abs(got_57_jan - expected_57_jan) < 1e-4, (
f"(57)Jan: got {got_57_jan!r}, want {expected_57_jan!r} per "
f"SAP 10.2 §4 line 7693 ((57)m = (56)m × (V - Vs)/V) + Table 2b"
)
assert abs(got_57_sum - expected_57_sum) < 1e-3, (
f"(57) sum: got {got_57_sum!r}, want {expected_57_sum!r} per "
f"SAP 10.2 §4 line 7693 + Table 2b"
)
def test_whc_914_dhw_routes_primary_loss_gate_to_second_main_heating_per_sap_table_3() -> None:
"""SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) primary-loss eligibility
is determined by the heat generator that feeds the hot water storage

View file

@ -124,19 +124,21 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="0390-2954-3640-2196-4175",
actual_sap=60,
expected_sap_resid=-7,
expected_pe_resid_kwh_per_m2=-26.0093,
expected_co2_resid_tonnes_per_yr=-2.5211,
expected_sap_resid=-6,
expected_pe_resid_kwh_per_m2=-26.3749,
expected_co2_resid_tonnes_per_yr=-2.5544,
notes=(
"Detached, TFA 360, age F, Firebird oil combi PCDF 9005 "
"(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + "
"keep_hot_facility=None — Slice S0380.20 strict-raise blocked "
"this cert; Slice S0380.21 dispatches it to Table 3a row 1 "
"(`600 × fu × n/365`) per SAP 10.2 spec p.160. Residuals "
"re-pinned post-slice; SAP 53 vs lodged 60 (-7) traces to "
"the larger fabric heat-loss / oil-fuel cost cascade rather "
"than the §4 HW path (oil tariff + age-F masonry on a 360 "
"m² detached typically lands -5..-10 SAP)."
"(`600 × fu × n/365`) per SAP 10.2 spec p.160. Slice S0380.79 "
"(_separately_timed_dhw=True when cylinder lodged per "
"SAP 10.2 Table 2b note b) + RdSAP §3 default) closed a "
"10% storage-loss over-count via TF 0.60 → 0.54, lifting SAP "
"53 → 54 (resid -7 → -6). Remaining -6 traces to fabric heat-"
"loss / oil-fuel cost cascade (oil tariff + age-F masonry on "
"a 360 m² detached typically lands -5..-10 SAP)."
),
),
_GoldenExpectation(