diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 9127ebf0..3e453c5d 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -147,6 +147,7 @@ from domain.sap10_calculator.worksheet.water_heating import ( WaterHeatingResult, combi_loss_monthly_kwh_table_3b_row_1_instantaneous, combi_loss_monthly_kwh_table_3c_two_profile_instantaneous, + cylinder_storage_loss_monthly_kwh, water_efficiency_monthly_via_equation_d1, water_heating_from_cert, ) @@ -1849,6 +1850,37 @@ _TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES: Final[frozenset[int]] = frozenset( {1, 2, 3, 6} ) +# RdSAP 10 §10.5 Table 28: lodged "Cylinder size" descriptors → SAP +# calculation litres. The Open EPC API encodes the descriptor as an +# integer per the cohort below (ground-truthed against worksheet (47) +# line refs in /sap worksheets/Additional data with api//dr87-*.pdf): +# code 1 → no cylinder (gated via `has_hot_water_cylinder`) +# code 3 → Medium (160 litres) (certs 0350, 0380, 2225, 2636, +# 3800, 9285) +# code 4 → Large (210 litres) (cert 9418) +# Codes 2 / 5 / 6 (Normal / Inaccessible / Exact) not yet observed. +_CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = {3: 160.0, 4: 210.0} + +# RdSAP 10 §10.5 code 7-11: cylinder insulation type. Empirical mapping +# from the ASHP cohort (all 7 certs lodge code 1, worksheet shows +# "Foam" → factory-applied per SAP 10.2 Table 2 Note 2). +_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. + """ + if main is None: + return False + if main.main_heating_category == 4: + return True + return False + def _table_3a_combi_loss_default_applies(main: Optional[MainHeatingDetail]) -> bool: """Gate for the Table 3a keep-hot 600 kWh/yr fall-through per SAP 10.2 @@ -1896,14 +1928,20 @@ def _water_heating_worksheet_and_gains( energy_content_monthly_kwh=bootstrap.energy_content_monthly_kwh, daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly, ) + main = _first_main_heating(epc) # SAP 10.2 §4 line 7702: non-combi main heating → (61)m = 0. Without # this gate the cascade falls through to `combi_loss_monthly_kwh_table_ # 3a_keep_hot_time_clock()` (600 kWh/yr) on every cert lacking a PCDB # Table 105 boiler record — including all heat pump certs. if combi_loss_override is None and not _table_3a_combi_loss_default_applies( - _first_main_heating(epc) + main ): combi_loss_override = zero_monthly + # SAP 10.2 §4 lines 7670-7693 + Tables 2/2a/2b — cylinder storage loss + # (56)m. Spec p.135 instructs entering 0 in (47) for instantaneous / + # combi systems, so the override is only built when the cert explicitly + # lodges a cylinder. + storage_loss_override = _cylinder_storage_loss_override(epc, main) wh_result = water_heating_from_cert( epc=epc, mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc), @@ -1911,12 +1949,47 @@ 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, + solar_storage_monthly_kwh_override=storage_loss_override, has_electric_shower=has_electric_shower, electric_shower_count=electric_shower_count, ) return wh_result, wh_result.heat_gains_monthly_kwh +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 + 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). + """ + if not epc.has_hot_water_cylinder: + return None + sh = epc.sap_heating + size_code = _int_or_none(sh.cylinder_size) + if size_code is None: + return None + volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code) + if volume_l is None: + return None + if sh.cylinder_insulation_type != _CYLINDER_INSULATION_TYPE_FACTORY: + return None + thickness_mm = sh.cylinder_insulation_thickness_mm + if thickness_mm is None: + return None + return 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), + ) + + def _apply_water_efficiency( *, wh_output_monthly_kwh: tuple[float, ...], diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 009ed92b..daf8842b 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -1016,6 +1016,76 @@ def test_pcdb_combi_loss_override_returns_none_for_untested_or_storage_combis() ) +def test_cert_with_hot_water_cylinder_computes_storage_loss_56m_from_sap_tables_2_2a_2b() -> None: + """SAP 10.2 §4 line 7690 worksheet defines + (56)m = (55) × n_m where (55) = (47) × (51) × (52) × (53) + i.e. storage loss = volume × Table 2 loss factor × Table 2a volume + factor × Table 2b temperature factor, scaled by days in month. + + Cert 0380 worksheet (dr87-0001-000899.pdf) pins for the Mitsubishi + ASHP + 160 L factory-insulated 50 mm cylinder with thermostat and + separately-timed DHW: + (51) L = 0.0152 kWh/litre/day (Table 2, factory, 50 mm) + (52) VF = 0.9086 (Table 2a, V=160) + (53) TF = 0.5400 (Table 2b, indirect × 0.9 timing) + (55) combined = 1.1920 (V × L × VF × TF) + (56)m Jan = 36.9530 kWh/month ((55) × 31) + + Pre-fix, `_water_heating_worksheet_and_gains` passes a zero12 tuple + as `solar_storage_monthly_kwh` to `water_heating_from_cert`, so the + (62)m total demand is missing ~432 kWh/yr of cylinder storage loss + that the spec explicitly accounts for. + """ + # Arrange — synthetic semi-detached, ASHP main, 160 L factory- + # insulated cylinder (cylinder_size=3 = Medium per RdSAP §10.5 Table + # 28; cylinder_insulation_type=1 = factory-applied; thickness 50 mm; + # thermostat lodged; separately-timed DHW lodged via WHS code 901). + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2206, + main_heating_category=4, # heat pump + sap_main_heating_code=None, + ) + 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()], + sap_heating=make_sap_heating( + main_heating_details=[hp_main], + water_heating_code=901, + cylinder_size=3, # Medium → 160 L per RdSAP §10.5 Table 28 + cylinder_insulation_type=1, # factory-applied + cylinder_insulation_thickness_mm=50, + cylinder_thermostat="Y", + ), + ) + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=1.7, + is_instantaneous=False, + primary_age="D", + pcdb_record=None, + ) + + # Assert — (56)m Jan matches worksheet at 1e-4. Solar storage on the + # WaterHeatingResult carries the (57)m tuple — for cert 0380 there + # is no dedicated solar storage so (57)m = (56)m per spec line 7693. + assert wh_result is not None + expected_jan_kwh = 36.9530 + got_jan_kwh = wh_result.solar_storage_monthly_kwh[0] + assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4, ( + f"(56)Jan: got {got_jan_kwh!r}, want {expected_jan_kwh!r} per " + f"SAP 10.2 §4 line 7690 + Tables 2/2a/2b" + ) + + def test_air_source_heat_pump_main_heating_zeroes_table_3a_combi_loss_per_sap_4_line_7702() -> None: """SAP 10.2 §4 line 7702 worksheet defines (61)m as 'Combi loss for each month from Table 3a, 3b or 3c (enter "0" if not a combi diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index cbcd4e27..a77de00c 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -121,14 +121,15 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0390-2954-3640-2196-4175", actual_sap=60, expected_sap_resid=-6, - expected_pe_resid_kwh_per_m2=-28.6783, - expected_co2_resid_tonnes_per_yr=-2.7640, + expected_pe_resid_kwh_per_m2=-27.5026, + expected_co2_resid_tonnes_per_yr=-2.6570, notes=( "Large detached, TFA 360, age F, oil PCDB-listed. Cert lodges " - "has_draught_lobby=true. Slice 97 added glazing_type=2 — " - "windows now drop to spec U=2.0, widening PE -26.46 → -28.68 " - "and CO2 -2.56 → -2.76 (the cert's lodged U for this glazing " - "type appears to be higher than the spec's table-24 default)." + "has_draught_lobby=true and a 160 L factory-insulated cylinder. " + "Slice 97 added glazing_type=2 — windows now drop to spec U=2.0, " + "widening PE → -28.68 and CO2 → -2.76. Slice 102b then applied " + "SAP 10.2 Tables 2/2a/2b cylinder storage loss (~432 kWh/yr), " + "tightening PE -28.68 → -27.50 and CO2 -2.76 → -2.66." ), ), _GoldenExpectation( diff --git a/domain/sap10_calculator/worksheet/water_heating.py b/domain/sap10_calculator/worksheet/water_heating.py index 836748e4..85692ee6 100644 --- a/domain/sap10_calculator/worksheet/water_heating.py +++ b/domain/sap10_calculator/worksheet/water_heating.py @@ -58,6 +58,7 @@ class WaterHeatingResult: daily_hot_water_l_per_day_monthly: tuple[float, ...] energy_content_monthly_kwh: tuple[float, ...] distribution_loss_monthly_kwh: tuple[float, ...] + solar_storage_monthly_kwh: tuple[float, ...] # (57)m — Tables 2/2a/2b combi_loss_monthly_kwh: tuple[float, ...] total_demand_monthly_kwh: tuple[float, ...] output_monthly_kwh: tuple[float, ...] @@ -429,6 +430,93 @@ def combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() -> tuple[float, ...]: return tuple(600.0 * n / _DAYS_IN_YEAR for n in _DAYS_IN_MONTH) +# SAP 10.2 Table 2 (PDF p.158) hot water storage loss factor L kWh/litre/day. +# Note 1 gives the smooth formulae the cascade uses (rather than the discrete +# thickness rows) so any positive thickness resolves deterministically. +_CYLINDER_INSULATION_FACTORY = "factory_insulated" +_CYLINDER_INSULATION_LOOSE_JACKET = "loose_jacket" + + +def cylinder_storage_loss_factor_table_2( + *, + insulation_type: Literal["factory_insulated", "loose_jacket"], + thickness_mm: float, +) -> float: + """SAP 10.2 Table 2 (PDF p.158) — hot water storage loss factor L + in kWh/litre/day. Note 1 supplies the smooth formula: + Cylinder, factory insulated: L = 0.005 + 0.55 / (t + 4.0) + Cylinder, loose jacket: L = 0.005 + 1.76 / (t + 12.8) + where t is the insulation thickness in mm. Note 2 applies the + factory-insulated row to "all cases other than an electric CPSU + where the insulation is applied in the course of manufacture + irrespective of the insulation material used" — so foam, mineral + wool, polyurethane and similar factory-applied insulations all + resolve via the factory branch. + """ + if insulation_type == _CYLINDER_INSULATION_FACTORY: + return 0.005 + 0.55 / (thickness_mm + 4.0) + return 0.005 + 1.76 / (thickness_mm + 12.8) + + +def cylinder_volume_factor_table_2a(volume_l: float) -> float: + """SAP 10.2 Table 2a (PDF p.158) — volume factor VF using Note 2's + closed form `VF = (120 / Vc)^(1/3)`. The closed form matches the + tabulated rows to 4 d.p. (V=160 → VF=0.9086 in the worksheet vs the + table's 0.908 — Elmhurst computes via formula). + """ + return (120.0 / volume_l) ** (1.0 / 3.0) + + +def cylinder_temperature_factor_table_2b( + *, + has_cylinder_thermostat: bool, + separately_timed_dhw: bool, +) -> float: + """SAP 10.2 Table 2b (PDF p.159) — temperature factor for a + "Cylinder, indirect" or "Cylinder, electric immersion" lodgement + (both base 0.60 in the "loss from Table 2" column). Multipliers per + Notes a) / b): + × 1.3 if cylinder thermostat is absent + × 0.9 if domestic hot water is separately timed + """ + factor = 0.60 + if not has_cylinder_thermostat: + factor *= 1.3 + if separately_timed_dhw: + factor *= 0.9 + return factor + + +def cylinder_storage_loss_monthly_kwh( + *, + volume_l: float, + insulation_type: Literal["factory_insulated", "loose_jacket"], + thickness_mm: float, + has_cylinder_thermostat: bool, + separately_timed_dhw: bool, +) -> tuple[float, ...]: + """SAP 10.2 §4 line (56)m water storage loss per spec (PDF p.136): + (54) = V × L × VF × TF (Table 2 absence-of-declared-loss branch) + (55) = (54) (no manufacturer's declared loss) + (56)m = (55) × n_m (n_m = days in month) + + Returns 12 monthly values in calendar order Jan..Dec. The cert's + "(57)m = (56)m" identity (spec line 7693) applies when no dedicated + solar storage is present in the vessel — callers handling solar + storage must adjust further per `(57)m = (56)m × [(47) - Vs] / (47)`. + """ + L = cylinder_storage_loss_factor_table_2( + insulation_type=insulation_type, thickness_mm=thickness_mm, + ) + VF = cylinder_volume_factor_table_2a(volume_l) + TF = cylinder_temperature_factor_table_2b( + has_cylinder_thermostat=has_cylinder_thermostat, + separately_timed_dhw=separately_timed_dhw, + ) + combined_55 = volume_l * L * VF * TF + return tuple(combined_55 * n for n in _DAYS_IN_MONTH) + + def total_water_heating_demand_monthly_kwh( *, energy_content_monthly_kwh: tuple[float, ...], @@ -639,6 +727,7 @@ def water_heating_from_cert( cold_water_temps_c: tuple[float, ...], low_water_use: bool, combi_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None, + solar_storage_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, @@ -719,10 +808,15 @@ def water_heating_from_cert( else combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() ) zero12 = (0.0,) * 12 + solar_storage = ( + solar_storage_monthly_kwh_override + if solar_storage_monthly_kwh_override is not None + else zero12 + ) total_demand = total_water_heating_demand_monthly_kwh( energy_content_monthly_kwh=energy_content, distribution_loss_monthly_kwh=distribution, - solar_storage_monthly_kwh=zero12, + solar_storage_monthly_kwh=solar_storage, primary_loss_monthly_kwh=zero12, combi_loss_monthly_kwh=combi, ) @@ -750,7 +844,7 @@ def water_heating_from_cert( gains = heat_gains_from_water_heating_monthly_kwh( energy_content_monthly_kwh=energy_content, distribution_loss_monthly_kwh=distribution, - solar_storage_monthly_kwh=zero12, + solar_storage_monthly_kwh=solar_storage, primary_loss_monthly_kwh=zero12, combi_loss_monthly_kwh=combi, electric_shower_monthly_kwh=electric_shower, @@ -761,6 +855,7 @@ def water_heating_from_cert( daily_hot_water_l_per_day_monthly=daily_total, energy_content_monthly_kwh=energy_content, distribution_loss_monthly_kwh=distribution, + solar_storage_monthly_kwh=solar_storage, combi_loss_monthly_kwh=combi, total_demand_monthly_kwh=total_demand, output_monthly_kwh=output, diff --git a/domain/sap10_ml/tests/_fixtures.py b/domain/sap10_ml/tests/_fixtures.py index a9352f92..7a8da2a0 100644 --- a/domain/sap10_ml/tests/_fixtures.py +++ b/domain/sap10_ml/tests/_fixtures.py @@ -96,7 +96,9 @@ def make_sap_heating( water_heating_code: Optional[int] = 901, water_heating_fuel: Optional[int] = 26, cylinder_size: Optional[Union[int, str]] = None, + cylinder_insulation_type: Optional[int] = None, cylinder_insulation_thickness_mm: Optional[int] = None, + cylinder_thermostat: Optional[str] = None, secondary_fuel_type: Optional[int] = None, secondary_heating_type: Optional[int] = None, number_baths: Optional[int] = None, @@ -113,7 +115,9 @@ def make_sap_heating( water_heating_code=water_heating_code, water_heating_fuel=water_heating_fuel, cylinder_size=cylinder_size, + cylinder_insulation_type=cylinder_insulation_type, cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm, + cylinder_thermostat=cylinder_thermostat, secondary_fuel_type=secondary_fuel_type, secondary_heating_type=secondary_heating_type, number_baths=number_baths,