From 147da90a5afc03956b227d677de6c670a3025685 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 23 May 2026 23:08:32 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2025d:=20000487=20=C2=A74=20LINE=5F65=20c?= =?UTF-8?q?losure=20=E2=80=94=20derive=20LINE=5F64A=20from=20cert=20(App?= =?UTF-8?q?=20J=20step=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/sap-spec/HANDOVER_NEXT.md | 13 ++-- .../src/domain/sap/rdsap/cert_to_inputs.py | 14 +++++ .../src/domain/sap/worksheet/water_heating.py | 63 +++++++++++++++++-- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/docs/sap-spec/HANDOVER_NEXT.md b/docs/sap-spec/HANDOVER_NEXT.md index ba6eb72c..1a10dd3a 100644 --- a/docs/sap-spec/HANDOVER_NEXT.md +++ b/docs/sap-spec/HANDOVER_NEXT.md @@ -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 diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 2545c976..678d6b8c 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/water_heating.py b/packages/domain/src/domain/sap/worksheet/water_heating.py index dac1b18f..16cc6748 100644 --- a/packages/domain/src/domain/sap/worksheet/water_heating.py +++ b/packages/domain/src/domain/sap/worksheet/water_heating.py @@ -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,