Slice 25b: 000487 §4 closure (7/8) — has_electric_shower routes Nbath

Closes §4 LINE_43 + LINE_44/45/46/61/62/64 for 000487 (7 of 8 fails).
LINE_65 still fails — needs Appendix J step 8 (electric-shower kWh
derivation from cert) to land before LINE_65 heat gains close.

Spec citation: SAP10.2 Appendix J (p.81) step 2a: `Nbath = 0.13N + 0.19
if shower also present; = 0.35N + 0.50 if no shower present`. The
"shower also present" branch fires when ANY shower is lodged — mixer OR
electric — per the implicit reading that step 1a's Noutlets includes
electric showers in the count.

Changes:
- SapHeating gains `electric_shower_count` + `mixer_shower_count`.
- `water_heating_from_cert` gains `has_electric_shower: bool = False`;
  combined with mixer-flow-rate presence to drive `has_shower`.
- `_mixer_shower_flow_rates_from_cert` honors `mixer_shower_count`
  (default 1 vented when unlodged — preserves legacy behaviour).
- `_has_electric_shower_from_cert` new helper.
- `water_heating_section_from_cert` plumbs `has_electric_shower`
  through bootstrap + final call (and the internal cert_to_inputs path).
- 000487 fixture: `electric_shower_count=1, mixer_shower_count=0`.

§4 per-fixture:
  fixture | LINE_42 | LINE_43 | LINE_44-46 | LINE_61-65
  000474  |   ✓     |   ✓     |    ✓       |   ✓ (9/9)
  000477  |   ✓     |   ✓     |    ✓       |   ✗ LINE_61/62/64/65 (slice 25c)
  000480  |   ✓     |   ✓     |    ✓       |   ✓ (9/9)
  000487  |   ✓     |   ✓     |    ✓       |   ✓ except LINE_65 (8/9)
  000490  |   ✓     |   ✓     |    ✓       |   ✓ (9/9)
  000516  |   ✓     |   ✓     |    ✓       |   ✓ (9/9)

Scoreboard:
  section_cascade_pins: 279 → 286 PASS (+7)
  e2e SapResult:         32 →  32 PASS (unchanged — LINE_65 cascade still
    open, blocks downstream §5 LINE_72/73 + §6 LINE_84 + §7 + downstream)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-23 22:44:40 +00:00
parent 015144361a
commit ca56fdee5b
6 changed files with 63 additions and 17 deletions

View file

@ -141,6 +141,18 @@ class SapHeating:
# SAP10 hot-water demand inputs from sap_heating.
number_baths: Optional[int] = None
number_baths_wwhrs: Optional[int] = None
# Per SAP10.2 Appendix J (p.81) step 1a: Noutlets includes electric
# showers in the count for Nshower; step 2a routes Nbath through the
# "shower also present" branch (0.13N + 0.19) when ANY shower is
# lodged — including electric. Modelled separately from mixer outlets
# because electric showers don't draw warm water from the system.
electric_shower_count: Optional[int] = None
# PCDF mixer-shower lodgement (count of outlets that DO draw warm
# water from the main HW system). When set, overrides the heuristic
# default of 1 vented outlet @ 7 L/min used by `_mixer_shower_flow_
# rates_from_cert`. Most certs lodge only count; the standard
# vented-system flow rate from Table J4 (7 L/min) is the default.
mixer_shower_count: Optional[int] = None
@dataclass

View file

@ -133,14 +133,15 @@ 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-25a: section_cascade_pins 279 PASS / 33 FAIL, e2e SapResult
32 PASS / 40 FAIL. §3 + §5 + §6 + §7 (mostly) pinned. §3 NOW FULLY
CLOSES for all 6 fixtures (24/24) — slice 25a closed 000487 by lodging
detailed RR surfaces + adding gable_wall_external + Ext1 alt U
override + RdSAP §3.8 roof-area-as-max rule + half-up rounding.
Remaining failures: §4 monthly on 000477+487 (slice 25b), §5 LINE_72/73
+ §6 LINE_84 on 000477/487 (cascade from §4), §7 LINE_92/93 marginal
on 000474/477/480/490 (precision artefact), §7 on 000487 (cascade).)
(Post-slice-25b: section_cascade_pins 286 PASS / 26 FAIL, e2e SapResult
32 PASS / 40 FAIL. §3 fully closes for all 6 fixtures (24/24). §4 closes
8 of 9 for 000487 — only LINE_65 (heat gains from WH) still fails
because the §4 cascade doesn't yet derive (64a) electric-shower kWh
from the cert (Appendix J step 8). Remaining cascade failures: §4 on
000477 (combi loss precision, slice 25c) + §4 LINE_65 on 000487
(electric shower derivation), §5/§6 LINE_72/73/84 on 000477+487
(cascade from §4), §7 LINE_92/93 marginal on 000474/477/480/490
(precision artefact), §7 on 000487 (cascade from §4 LINE_65).)
### B.2 SapResult pin matrix (post-slice-22/23)
@ -201,6 +202,7 @@ fixture | section §4 pin status
### B.5 Recent slices (in reverse order — newest first)
```
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
Slice 26c: §7 mean internal temp cascade pin (60 cases, 44 PASS) — LINE_85..94
Slice 26b: §6 solar gains cascade pin (12 cases, 10 PASS) + SapRoofWindow solar attrs + plumb to §6 cascade

View file

@ -100,6 +100,8 @@ def make_sap_heating(
secondary_fuel_type: Optional[int] = None,
secondary_heating_type: Optional[int] = None,
number_baths: Optional[int] = None,
electric_shower_count: Optional[int] = None,
mixer_shower_count: Optional[int] = None,
) -> SapHeating:
"""Build a SapHeating with SAP10 API defaults."""
return SapHeating(
@ -115,6 +117,8 @@ def make_sap_heating(
secondary_fuel_type=secondary_fuel_type,
secondary_heating_type=secondary_heating_type,
number_baths=number_baths,
electric_shower_count=electric_shower_count,
mixer_shower_count=mixer_shower_count,
)

View file

@ -718,12 +718,14 @@ def water_heating_section_from_cert(
if main is not None and main.main_heating_index_number is not None
else None
)
has_electric_shower = _has_electric_shower_from_cert(epc)
bootstrap = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
has_bath=_has_bath_from_cert(epc),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
has_electric_shower=has_electric_shower,
)
combi_loss_override = pcdb_combi_loss_override(
pcdb_main,
@ -737,6 +739,7 @@ def water_heating_section_from_cert(
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
combi_loss_monthly_kwh_override=combi_loss_override,
has_electric_shower=has_electric_shower,
)
@ -950,16 +953,30 @@ def _mixer_shower_flow_rates_from_cert(
) -> tuple[float, ...]:
"""Pull mixer-shower flow rates from the cert.
The cert lodges flow rate per shower outlet (Elmhurst worksheets
show "Vented hot water system, 7.00"). The domain model doesn't
surface that field yet; until it does, every cert defaults to the
SAP10.2 Table J4 "Vented hot water system" row at 7 L/min the
existing-dwelling minimum and what both validation fixtures
(000474 + 000490) actually lodge. Combi-pumped showers (11 L/min)
or electric showers (handled via (64a)m, not here) will need
proper shower-outlet-type plumbing in a later slice.
When `sap_heating.mixer_shower_count` is lodged, use that count
of vented mixers @ Table J4's 7 L/min row. When None, default to a
single vented outlet the modal RdSAP lodging. Combi-pumped
showers (11 L/min) need a richer cert surface in a future slice.
"""
return (_SHOWER_FLOW_VENTED_L_PER_MIN,)
count = (
epc.sap_heating.mixer_shower_count
if epc.sap_heating is not None
else None
)
if count is None:
count = 1
return tuple(_SHOWER_FLOW_VENTED_L_PER_MIN for _ in range(max(0, count)))
def _has_electric_shower_from_cert(epc: EpcPropertyData) -> bool:
"""True iff cert lodges ≥ 1 instantaneous electric shower.
Electric showers don't draw warm water from the main HW system but
count in `Noutlets` for SAP10.2 Appendix J (p.81) step 1a and route
Nbath through the "shower also present" branch in step 2a (0.13N +
0.19 instead of 0.35N + 0.50). Defaults False when unlodged."""
n = epc.sap_heating.electric_shower_count if epc.sap_heating is not None else None
return (n or 0) >= 1
def _has_bath_from_cert(epc: EpcPropertyData) -> bool:
@ -1052,12 +1069,14 @@ def _water_heating_worksheet_and_gains(
zero_monthly = (0.0,) * 12
if epc.total_floor_area_m2 is None:
return None, zero_monthly
has_electric_shower = _has_electric_shower_from_cert(epc)
bootstrap = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
has_bath=_has_bath_from_cert(epc),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
has_electric_shower=has_electric_shower,
)
combi_loss_override = pcdb_combi_loss_override(
pcdb_record,
@ -1071,6 +1090,7 @@ def _water_heating_worksheet_and_gains(
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
combi_loss_monthly_kwh_override=combi_loss_override,
has_electric_shower=has_electric_shower,
)
return wh_result, wh_result.heat_gains_monthly_kwh

View file

@ -172,6 +172,12 @@ def build_epc() -> EpcPropertyData:
# radiant heaters" → SAP code 691. Table 11 fraction 0.10.
secondary_heating_type=691,
number_baths=1, # 000487 line 123: Total number of baths = 1
# 000487 cert: 1 instantaneous electric shower, no mixer
# outlet (worksheet text shows only Doors/Windows openings,
# no shower outlet under Heat Loss). Drives Appendix J step 2a
# to use Nbath = 0.13N + 0.19 ("shower also present").
electric_shower_count=1,
mixer_shower_count=0,
),
)

View file

@ -595,6 +595,7 @@ def water_heating_from_cert(
low_water_use: bool,
combi_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None,
electric_shower_monthly_kwh_override: Optional[tuple[float, ...]] = None,
has_electric_shower: bool = False,
) -> 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.
@ -635,6 +636,7 @@ def water_heating_from_cert(
has_shower = (
len(mixer_shower_flow_rates_l_per_min) > 0
or electric_shower_monthly_kwh_override is not None
or has_electric_shower
)
baths = hot_water_baths_monthly_l_per_day(
n_occupants=n,