Slice 25d: 000487 §4 LINE_65 closure — derive LINE_64A from cert (App J step 8)

Closes the final §4 cascade fail. SAP10.2 Appendix J step 8 (p.82)
specifies the electric-shower kWh formula:

  N_ES = N_shower / N_outlets             (eq J16)
  EES,j,m = N_ES × f_beh × P_ES,j × 0.1 × n_m   (eq J17)
  EES,m = Σ EES,j,m                       (eq J18)

where P_ES,j defaults to Table J4 (p.83) row "Instantaneous electric
shower" = 9.3 kW for assessments of existing dwellings, and 0.1 = the
6-minute shower duration in hours.

For 000487 (N=2.492, has_bath, 1 electric shower, 0 mixer outlets):
  N_shower = 0.45 × 2.492 + 0.65 = 1.7714
  N_outlets = 1 (just the electric)
  N_ES = 1.7714 / 1 = 1.7714
  Jan: 1.7714 × 1.035 × 9.3 × 0.1 × 31 = 52.86 kWh ≈ PDF LINE_64A[1] = 52.8566 ✓

LINE_65 (heat gains from water heating) was undercounting by 25% of
the missing LINE_64A (the recovery factor for instantaneous electric
showers per the heat-gains formula); deriving LINE_64A from cert
closes it.

Changes:
- water_heating.py: new `electric_shower_monthly_kwh` function +
  `electric_shower_count` parameter to `water_heating_from_cert`.
  When count > 0 and no override, derives LINE_64A from N_outlets +
  Table J4 default P_ES.
- cert_to_inputs.py: `_electric_shower_count_from_cert` helper +
  plumb through both the §4 section helper and internal cascade.

Per-fixture cluster status (was/now):
  §3   24/24 → 24/24  ✓ all 6 fixtures
  §4   53/54 → 54/54  ✓ all 6 fixtures
  §5   52/54 → 54/54  ✓ all 6 fixtures
  §6   11/12 → 12/12  ✓ all 6 fixtures
  §7   45/60 → 52/60  (000487 cascade closed; LINE_92/93 marginal on
                       000474/477/480/490 remains)

Scoreboard:
  section_cascade_pins: 293 → 304 PASS (+11; 97.4% closure)
  e2e SapResult:         32 →  33 PASS (+1, water_heating closure cascades)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-23 23:08:32 +00:00
parent 8520a52ee9
commit 147da90a5a
3 changed files with 79 additions and 11 deletions

View file

@ -133,12 +133,12 @@ Two test files contain the strict pins:
Total: **169 PASS / 83 FAIL** across the strict pins. 4 of 6 fixtures fully
close §1+§2+§4. 000487 is the worst (RR fixture defect propagates everywhere).
(Post-slice-25c: section_cascade_pins 293 PASS / 19 FAIL, e2e SapResult
32 PASS / 40 FAIL. §3 closes 24/24. §4 closes 53/54 (only 000487
LINE_65 remains — Appendix J step 8 electric-shower kWh derivation,
slice 25d). §5 52/54, §6 11/12 — only 000487 cascade. 000477's §4
combi-loss cluster was closed by fixing the SAP10.2 Table 3c (p.162)
M+L lower bound: my DVF used `V < 100.0` where spec says `V < 100.2`.)
(Post-slice-25d: section_cascade_pins 304 PASS / 8 FAIL, e2e SapResult
33 PASS / 39 FAIL. §3 + §4 + §5 + §6 ALL fully close for all 6
fixtures. §7 closes 52/60 — only LINE_92/93 marginal ~0.0001 K
residual on 000474/477/480/490 remains (the precision artefact
discovered in slice 26c, no spec-grounded fix identified). Cascade
total closure: 312 tests, 304 PASS = 97.4%.)
### B.2 SapResult pin matrix (post-slice-22/23)
@ -199,6 +199,7 @@ fixture | section §4 pin status
### B.5 Recent slices (in reverse order — newest first)
```
Slice 25d: 000487 §4 LINE_65 closure — derive LINE_64A electric-shower kWh from cert (Appendix J step 8, p.82)
Slice 25c: 000477 §4/§5/§6 closure — SAP10.2 Table 3c (p.162) M+L lower bound 100.0 → 100.2
Slice 25b: 000487 §4 closure (7/8) — has_electric_shower + mixer/electric counts on SapHeating, Appendix J step 2a fix
Slice 25a: 000487 §3 closure — detailed RR + gable_wall_external + Ext1 alt U=1.9 + §3.8 max-floor roof + half-up rounding

View file

@ -719,6 +719,7 @@ def water_heating_section_from_cert(
else None
)
has_electric_shower = _has_electric_shower_from_cert(epc)
electric_shower_count = _electric_shower_count_from_cert(epc)
bootstrap = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
@ -726,6 +727,7 @@ def water_heating_section_from_cert(
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)
combi_loss_override = pcdb_combi_loss_override(
pcdb_main,
@ -740,6 +742,7 @@ def water_heating_section_from_cert(
low_water_use=False,
combi_loss_monthly_kwh_override=combi_loss_override,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)
@ -979,6 +982,14 @@ def _has_electric_shower_from_cert(epc: EpcPropertyData) -> bool:
return (n or 0) >= 1
def _electric_shower_count_from_cert(epc: EpcPropertyData) -> int:
"""Cert-lodged count of instantaneous electric showers. Drives the
LINE_64A energy derivation in `water_heating_from_cert` per SAP10.2
Appendix J (p.82) step 8."""
n = epc.sap_heating.electric_shower_count if epc.sap_heating is not None else None
return max(0, n or 0)
def _has_bath_from_cert(epc: EpcPropertyData) -> bool:
"""True iff cert lodges ≥ 1 bath. `number_baths is None` is treated as
bath present (modal UK lodging bathless dwellings are rare and
@ -1070,6 +1081,7 @@ def _water_heating_worksheet_and_gains(
if epc.total_floor_area_m2 is None:
return None, zero_monthly
has_electric_shower = _has_electric_shower_from_cert(epc)
electric_shower_count = _electric_shower_count_from_cert(epc)
bootstrap = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
@ -1077,6 +1089,7 @@ def _water_heating_worksheet_and_gains(
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)
combi_loss_override = pcdb_combi_loss_override(
pcdb_record,
@ -1091,6 +1104,7 @@ def _water_heating_worksheet_and_gains(
low_water_use=False,
combi_loss_monthly_kwh_override=combi_loss_override,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)
return wh_result, wh_result.heat_gains_monthly_kwh

View file

@ -586,6 +586,49 @@ def hot_water_other_uses_monthly_l_per_day(
return tuple(annual_average * f for f in _TABLE_J2_MONTHLY_FACTORS)
_ELECTRIC_SHOWER_DEFAULT_KW: Final[float] = 9.3
_ELECTRIC_SHOWER_DURATION_HOURS: Final[float] = 0.1
def electric_shower_monthly_kwh(
*,
n_occupants: float,
has_bath: bool,
n_outlets: int,
n_electric_showers: int,
rated_power_kw: float = _ELECTRIC_SHOWER_DEFAULT_KW,
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (64a)m via Appendix J (p.82) step 8.
Per outlet for each month:
N_ES = N_shower / N_outlets (eq J16)
E_ES,j,m = N_ES × f_beh × P_ES,j × 0.1 × n_m (eq J17)
Summed across electric-shower outlets j (eq J18).
N_shower from step 1c (same branch as `hot_water_mixer_showers_monthly_
l_per_day`); N_outlets is the cert-lodged total of mixer + electric
outlets. P_ES,j defaults to Table J4 row "Instantaneous electric
shower" = 9.3 kW for assessments of existing dwellings.
Returns 12-tuple of zeros when there are no electric showers."""
if n_electric_showers <= 0 or n_outlets <= 0:
return tuple(0.0 for _ in range(12))
if has_bath:
n_shower = 0.45 * n_occupants + 0.65
else:
n_shower = 0.58 * n_occupants + 0.83
n_es_per_outlet = n_shower / n_outlets
return tuple(
n_electric_showers
* n_es_per_outlet
* fbeh
* rated_power_kw
* _ELECTRIC_SHOWER_DURATION_HOURS
* n_m
for fbeh, n_m in zip(TABLE_J5_BEHAVIOURAL_FACTOR, _DAYS_IN_MONTH)
)
def water_heating_from_cert(
*,
epc: EpcPropertyData,
@ -596,6 +639,7 @@ def water_heating_from_cert(
combi_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None,
electric_shower_monthly_kwh_override: Optional[tuple[float, ...]] = None,
has_electric_shower: bool = False,
electric_shower_count: int = 0,
) -> WaterHeatingResult:
"""SAP 10.2 §4 orchestrator — chain every line ref from (42) through
(65) for a combi-gas dwelling with optional PCDB-backed combi loss.
@ -687,11 +731,20 @@ def water_heating_from_cert(
solar_monthly_kwh=zero12,
fghrs_monthly_kwh=zero12,
)
electric_shower = (
electric_shower_monthly_kwh_override
if electric_shower_monthly_kwh_override is not None
else zero12
)
if electric_shower_monthly_kwh_override is not None:
electric_shower = electric_shower_monthly_kwh_override
elif electric_shower_count > 0:
# Appendix J step 8 — N_outlets counts mixer + electric outlets
# together (eq J16).
n_outlets_total = len(mixer_shower_flow_rates_l_per_min) + electric_shower_count
electric_shower = electric_shower_monthly_kwh(
n_occupants=n,
has_bath=has_bath,
n_outlets=n_outlets_total,
n_electric_showers=electric_shower_count,
)
else:
electric_shower = zero12
gains = heat_gains_from_water_heating_monthly_kwh(
energy_content_monthly_kwh=energy_content,
distribution_loss_monthly_kwh=distribution,