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,