diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 3e453c5d..2fc35875 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -68,8 +68,14 @@ from domain.sap10_ml.sap_efficiencies import ( water_heating_efficiency as _legacy_water_heating_efficiency, ) from domain.sap10_calculator.calculator import CalculatorInputs -from domain.sap10_calculator.tables.pcdb import gas_oil_boiler_record -from domain.sap10_calculator.tables.pcdb.parser import GasOilBoilerRecord +from domain.sap10_calculator.tables.pcdb import ( + gas_oil_boiler_record, + heat_pump_record, +) +from domain.sap10_calculator.tables.pcdb.parser import ( + GasOilBoilerRecord, + HeatPumpRecord, +) from domain.sap10_calculator.tables.pcdb.postcode_weather import ( PostcodeClimate, postcode_climate, @@ -143,11 +149,14 @@ from domain.sap10_calculator.worksheet.ventilation import ( ventilation_from_inputs, ) from domain.sap10_calculator.worksheet.water_heating import ( + PIPEWORK_INSULATED_FULLY, + PIPEWORK_INSULATED_UNINSULATED, TABLE_J1_TCOLD_FROM_MAINS_C, WaterHeatingResult, combi_loss_monthly_kwh_table_3b_row_1_instantaneous, combi_loss_monthly_kwh_table_3c_two_profile_instantaneous, cylinder_storage_loss_monthly_kwh, + primary_loss_monthly_kwh, water_efficiency_monthly_via_equation_d1, water_heating_from_cert, ) @@ -1882,6 +1891,62 @@ def _separately_timed_dhw(main: Optional[MainHeatingDetail]) -> bool: return False +# RdSAP §3 default table (PDF p.56) — "Insulation of primary pipework": +# age bands A-J → none (p=0.0); age bands K, L, M → full (p=1.0). The +# default applies when the cert does not lodge an explicit insulation +# fraction — which is the modal case for the Open EPC API (no field). +_PIPEWORK_FULL_INSULATION_AGE_BANDS: Final[frozenset[str]] = frozenset( + {"K", "L", "M"} +) + + +def _pipework_insulation_fraction_table_3(primary_age: Optional[str]) -> float: + """RdSAP §3 default for primary pipework insulation by age band. + Bands K, L, M (post-2007) → 1.0 fully insulated; A-J → 0.0 + uninsulated. Unknown age band defaults to 0.0 (the conservative + older-stock assumption matching cert 0380's worksheet 'Uninsulated + primary pipework' lodgement). + """ + if primary_age in _PIPEWORK_FULL_INSULATION_AGE_BANDS: + return PIPEWORK_INSULATED_FULLY + return PIPEWORK_INSULATED_UNINSULATED + + +def _primary_loss_applies( + main: Optional[MainHeatingDetail], + cylinder_present: bool, + hp_record: Optional[HeatPumpRecord], +) -> bool: + """SAP 10.2 Table 3 (PDF p.159) zero-loss configurations — primary + loss only fires when a cylinder is present AND the lodgement falls + outside the zero list. The cohort path: heat-pump main heating with + a separate (not integral) vessel per the PCDB Table 362 record. + + Combi boilers, CPSUs, thermal stores within 1.5 m insulated pipe, + direct-acting electric boilers, electric immersion heaters, and + HPs with `hw_vessel_mode = 1` (integral) all skip the loss. For + cohort coverage we model two paths: + - HP with PCDB record: gate on `hp_record.hw_vessel_mode != 1` + - Boiler (cat 1, 2) with cylinder: primary loss applies (the + cascade's pre-slice-102d behaviour was zero, masking ~516 + kWh/yr on certs with cylinders). + """ + if not cylinder_present: + return False + if main is None: + return False + if main.main_heating_category == 4: + if hp_record is None: + # No PCDB record → assume separate-vessel (conservative; the + # zero-loss "integral vessel" branch requires explicit PCDB + # confirmation per spec). + return True + # Spec p.159: zero for "Heat pump from PCDB with hot water vessel + # integral to package". Vessel mode 1 = integral. + return hp_record.hw_vessel_mode != 1 + return main.main_heating_category in {1, 2} + + 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 §4 line 7702. Returns True only when the main heating system is in the @@ -1942,6 +2007,10 @@ def _water_heating_worksheet_and_gains( # combi systems, so the override is only built when the cert explicitly # lodges a cylinder. storage_loss_override = _cylinder_storage_loss_override(epc, main) + # SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) — primary circuit loss + # (59)m. Only fires for indirect cylinders; HPs with integral + # vessels and combi boilers are in the spec's zero list. + primary_loss_override = _primary_loss_override(epc, main, primary_age) wh_result = water_heating_from_cert( epc=epc, mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc), @@ -1950,12 +2019,41 @@ def _water_heating_worksheet_and_gains( low_water_use=False, combi_loss_monthly_kwh_override=combi_loss_override, solar_storage_monthly_kwh_override=storage_loss_override, + primary_loss_monthly_kwh_override=primary_loss_override, has_electric_shower=has_electric_shower, electric_shower_count=electric_shower_count, ) return wh_result, wh_result.heat_gains_monthly_kwh +def _primary_loss_override( + epc: EpcPropertyData, + main: Optional[MainHeatingDetail], + primary_age: Optional[str], +) -> Optional[tuple[float, ...]]: + """Resolve (59)m for `water_heating_from_cert` from the cert + PCDB + Table 362 record (for HP mains). Returns None when primary loss does + not apply (combi boiler, integral-vessel HP, no cylinder, etc.) so + the cascade keeps its zero default. Pipework insulation fraction p + comes from RdSAP §3 age-band default (no API field); circulation + hours h come from Table 3 keyed on cylinder thermostat + separately- + timed-DHW lodgement. + """ + cylinder_present = bool(epc.has_hot_water_cylinder) + hp_record: Optional[HeatPumpRecord] = None + if main is not None and main.main_heating_index_number is not None: + hp_record = heat_pump_record(main.main_heating_index_number) + if not _primary_loss_applies(main, cylinder_present, hp_record): + return None + return primary_loss_monthly_kwh( + pipework_insulation_fraction=_pipework_insulation_fraction_table_3( + primary_age + ), + has_cylinder_thermostat=epc.sap_heating.cylinder_thermostat == "Y", + separately_timed_dhw=_separately_timed_dhw(main), + ) + + def _cylinder_storage_loss_override( epc: EpcPropertyData, main: Optional[MainHeatingDetail], 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 daf8842b..619cd93a 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -1086,6 +1086,82 @@ def test_cert_with_hot_water_cylinder_computes_storage_loss_56m_from_sap_tables_ ) +def test_cert_with_hot_water_cylinder_computes_primary_loss_59m_from_sap_table_3() -> None: + """SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) define the primary + circuit loss for an indirect cylinder: + (59)m = n_m × 14 × [{0.0091 × p + 0.0245 × (1 − p)} × h + 0.0263] + where + n_m = days in month, + p = fraction of primary pipework insulated (0.0 uninsulated, + 0.1 first 1m, 0.3 all accessible, 1.0 fully insulated), + h = hours per day of circulation (5 winter / 3 summer if + cylinder thermostat present; 3 / 3 if DHW separately + timed; 11 / 3 if no cylinder thermostat). + + RdSAP §3 default table (PDF p.56) supplies pipework insulation by + age band: bands A-J → none (p=0.0); bands K, L, M → full (p=1.0). + + Cert 0380 (band D = 1950-1966 → p=0.0; cylinder thermostat lodged + + separately-timed DHW → h=3 winter and summer) yields + (59)Jan = 31 × 14 × (0.0245 × 3 + 0.0263) + = 31 × 14 × 0.0998 + = 43.3132 kWh/month + matching the cert 0380 dr87 worksheet pin to 4 d.p. + + Spec PDF p.159 lists configurations for which the primary loss is + zero ("Combi boiler", "Electric immersion heater", "Heat pump from + PCDB with hot water vessel integral to package", etc.). Cert 0380 + uses a heat pump with separate-and-specified vessel + (`hw_vessel_mode = 2` in PCDB Table 362), so the loss applies. + """ + # Arrange — synthetic ASHP cert mirroring cert 0380: cat=4, PCDB + # 104568 (Mitsubishi 5 kW Ecodan, separate-specified vessel), + # cylinder lodged with thermostat, separately-timed DHW, age band D. + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2206, + main_heating_category=4, + sap_main_heating_code=None, + main_heating_index_number=104568, + ) + 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(construction_age_band="D")], + sap_heating=make_sap_heating( + main_heating_details=[hp_main], + water_heating_code=901, + cylinder_size=3, + cylinder_insulation_type=1, + 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 — (59)m Jan matches worksheet at 1e-4. + assert wh_result is not None + expected_jan_kwh = 43.3132 + got_jan_kwh = wh_result.primary_loss_monthly_kwh[0] + assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4, ( + f"(59)Jan: got {got_jan_kwh!r}, want {expected_jan_kwh!r} per " + f"SAP 10.2 §4 line 7700 + Table 3" + ) + + 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 a77de00c..a4bee41d 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -120,16 +120,20 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0390-2954-3640-2196-4175", actual_sap=60, - expected_sap_resid=-6, - expected_pe_resid_kwh_per_m2=-27.5026, - expected_co2_resid_tonnes_per_yr=-2.6570, + expected_sap_resid=-7, + expected_pe_resid_kwh_per_m2=-26.0093, + expected_co2_resid_tonnes_per_yr=-2.5211, notes=( "Large detached, TFA 360, age F, oil PCDB-listed. Cert lodges " "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." + "tightening PE -28.68 → -27.50 and CO2 -2.76 → -2.66. Slice 102d " + "then added SAP 10.2 Table 3 primary circuit loss (~516 kWh/yr " + "uninsulated, age band F → A-J default p=0.0), tightening PE " + "-27.50 → -26.01, CO2 -2.66 → -2.52, and shifting SAP residual " + "-6 → -7 (cost of the higher HW fuel)." ), ), _GoldenExpectation( diff --git a/domain/sap10_calculator/worksheet/water_heating.py b/domain/sap10_calculator/worksheet/water_heating.py index 85692ee6..d183cd2c 100644 --- a/domain/sap10_calculator/worksheet/water_heating.py +++ b/domain/sap10_calculator/worksheet/water_heating.py @@ -59,6 +59,7 @@ class WaterHeatingResult: energy_content_monthly_kwh: tuple[float, ...] distribution_loss_monthly_kwh: tuple[float, ...] solar_storage_monthly_kwh: tuple[float, ...] # (57)m — Tables 2/2a/2b + primary_loss_monthly_kwh: tuple[float, ...] # (59)m — Table 3 combi_loss_monthly_kwh: tuple[float, ...] total_demand_monthly_kwh: tuple[float, ...] output_monthly_kwh: tuple[float, ...] @@ -487,6 +488,76 @@ def cylinder_temperature_factor_table_2b( return factor +# SAP 10.2 Table 3 (PDF p.159) — primary circuit loss for boilers and +# heat pumps connected to a hot water cylinder via insulated or +# uninsulated primary pipework. The spec lists the zero-loss +# configurations explicitly (combi boilers, integral-vessel heat pumps, +# CPSUs, thermal stores within 1.5 m insulated pipe, etc.); callers +# must gate this helper on those exemptions. +PIPEWORK_INSULATED_UNINSULATED: Final[float] = 0.0 +PIPEWORK_INSULATED_FIRST_METRE: Final[float] = 0.1 +PIPEWORK_INSULATED_ALL_ACCESSIBLE: Final[float] = 0.3 +PIPEWORK_INSULATED_FULLY: Final[float] = 1.0 + +# Per Table 3 hours-per-day table: 5 winter / 3 summer if cylinder +# thermostat present and water heating not separately timed; 3 / 3 if +# cylinder thermostat present AND separately timed; 11 / 3 if no +# cylinder thermostat. "Use summer value for June, July, August and +# September and winter value for other months." +_SUMMER_MONTH_INDICES: Final[tuple[int, ...]] = (5, 6, 7, 8) # Jun..Sep + + +def primary_circuit_hours_per_day_table_3( + *, + has_cylinder_thermostat: bool, + separately_timed_dhw: bool, +) -> tuple[float, float]: + """SAP 10.2 Table 3 (PDF p.159) — hours of primary circulation per + day, returned as `(winter_hours, summer_hours)`: + no thermostat → (11, 3) + thermostat, not separately timed → ( 5, 3) + thermostat, separately timed → ( 3, 3) + """ + if not has_cylinder_thermostat: + return (11.0, 3.0) + if separately_timed_dhw: + return (3.0, 3.0) + return (5.0, 3.0) + + +def primary_loss_monthly_kwh( + *, + pipework_insulation_fraction: float, + has_cylinder_thermostat: bool, + separately_timed_dhw: bool, +) -> tuple[float, ...]: + """SAP 10.2 §4 line (59)m via Table 3 (PDF p.159): + (59)m = n_m × 14 × [{0.0091 × p + 0.0245 × (1 − p)} × h + 0.0263] + where p is the fraction of primary pipework insulated and h is the + hours of primary circulation per day (winter / summer split per + `primary_circuit_hours_per_day_table_3`). + + Returns 12 monthly values in calendar order Jan..Dec. Callers must + gate this helper on the spec's zero-loss configurations + (combi boilers, integral-vessel HPs, CPSUs, thermal stores ≤ 1.5 m + insulated pipe, etc.) — the formula assumes the configuration + incurs the loss. + """ + p = pipework_insulation_fraction + pipework_term = 0.0091 * p + 0.0245 * (1.0 - p) + winter_h, summer_h = primary_circuit_hours_per_day_table_3( + has_cylinder_thermostat=has_cylinder_thermostat, + separately_timed_dhw=separately_timed_dhw, + ) + return tuple( + n * 14.0 * ( + pipework_term * (summer_h if m in _SUMMER_MONTH_INDICES else winter_h) + + 0.0263 + ) + for m, n in enumerate(_DAYS_IN_MONTH) + ) + + def cylinder_storage_loss_monthly_kwh( *, volume_l: float, @@ -728,6 +799,7 @@ def water_heating_from_cert( low_water_use: bool, combi_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None, solar_storage_monthly_kwh_override: Optional[tuple[float, ...]] = None, + primary_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, @@ -813,11 +885,16 @@ def water_heating_from_cert( if solar_storage_monthly_kwh_override is not None else zero12 ) + primary_loss = ( + primary_loss_monthly_kwh_override + if primary_loss_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=solar_storage, - primary_loss_monthly_kwh=zero12, + primary_loss_monthly_kwh=primary_loss, combi_loss_monthly_kwh=combi, ) output = output_from_water_heater_monthly_kwh( @@ -845,7 +922,7 @@ def water_heating_from_cert( energy_content_monthly_kwh=energy_content, distribution_loss_monthly_kwh=distribution, solar_storage_monthly_kwh=solar_storage, - primary_loss_monthly_kwh=zero12, + primary_loss_monthly_kwh=primary_loss, combi_loss_monthly_kwh=combi, electric_shower_monthly_kwh=electric_shower, ) @@ -856,6 +933,7 @@ def water_heating_from_cert( energy_content_monthly_kwh=energy_content, distribution_loss_monthly_kwh=distribution, solar_storage_monthly_kwh=solar_storage, + primary_loss_monthly_kwh=primary_loss, combi_loss_monthly_kwh=combi, total_demand_monthly_kwh=total_demand, output_monthly_kwh=output,