From 2adff0821006323c406ce31608cc106f75b35cc5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 29 May 2026 18:37:56 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.75:=20Wire=20Appendix=20H=20orche?= =?UTF-8?q?strator=20into=20cascade;=20cert=20000565=20HW=20+272=20?= =?UTF-8?q?=E2=86=92=20=E2=88=9269?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per SAP 10.2 §4 line (64)m: `(64)m = max(0, (62)m + (63a)m + (63b)m + (63c)m + (63d)m)` where (63c)m is the solar HW credit lodged as a negative quantity. The cascade hardcoded (63c)m = 0 since S0380.66 when the Appendix H orchestrator landed without integration, pending the 1.81× over-count resolution (closed in S0380.74). This slice plumbs the orchestrator into `water_heating_from_cert` via a new `solar_water_heating_monthly_kwh_override` parameter, and adds `_solar_hw_monthly_override` in cert_to_inputs.py that drives the orchestrator from RdSAP 10 §10.11 Table 29 defaults + cert-lodged collector geometry on Elmhurst Summary §16.0. RdSAP 10 §10.11 Table 29 row "Solar panel" (p.58, verbatim): "If solar panel present, the parameters for the calculation not provided in the RdSAP data set are: - panel aperture area 3 m² - flat panel, η₀ = 0.80, a₁ = 4.0, a₂ = 0.01 - facing South, pitch 30°, modest overshading - … - pump for solar-heated water is electric (75 kWh/year) - showers are both electric and non-electric" Lodged collector orientation / pitch / overshading on the Summary §16.0 ("Are details known? Yes" branch) override South / 30° / Modest. Aperture, η₀, a₁, a₂, IAM stay at Table 29 defaults — the deeper thermal parameter lodgement (P960 worksheet) isn't yet in the Summary extractor surface. For (H17)m to include storage + primary + combi losses, the cascade runs a `demand_pass` call without solar (gets (62)m) before sizing the solar credit. The final call then uses all overrides. Files: - datatypes/epc/surveys/elmhurst_site_notes.py: Renewables gains `solar_hw_collector_orientation` / `_pitch_deg` / `_overshading` optional fields. - datatypes/epc/domain/epc_property_data.py: same three fields added at the end of the dataclass. - datatypes/epc/domain/mapper.py: from_elmhurst_site_notes propagates the three new fields. - backend/documents_parser/elmhurst_extractor.py: §16.0 section parsing reads "Collector orientation" / "Collector elevation" / "Overshading" rows; `_parse_solar_pitch_deg` strips the degree glyph. - domain/sap10_calculator/worksheet/water_heating.py: new `solar_water_heating_monthly_kwh_override` param on `water_heating_from_cert`; threaded into `output_from_water_ heater_monthly_kwh(solar_monthly_kwh=...)`. - domain/sap10_calculator/rdsap/cert_to_inputs.py: Table 29 constants + `_solar_hw_monthly_override` helper + `_orientation_from_summary_string` mapper. Added the demand_pass intermediate call so (H17)m sees the full (62)m. Negates the orchestrator output at the boundary (spec convention: heat displaced from boiler is negative on line (63c)m). Cert 000565 cascade pin shifts: - hot_water_kwh_per_yr: +271.84 → −68.96 (4× closer) - sap_score_continuous: +0.6334 → +0.7732 (drift downstream of HW) - ecf: −0.0643 → −0.0784 (drift) - total_fuel_cost: −56.08 → −68.36 (drift) - co2: −19.77 → −22.66 (drift) - sap_score (int): 29 EXACT (unchanged) - space_heating / main_heating_fuel / lighting / pumps_fans: unchanged The remaining −69 kWh HW residual is the gap between Table 29 defaults (H12 = 75 L separate tank) and cert 000565's lodged H12 = 53 L + combined cylinder 160 L. Closing this requires extracting solar storage volume + combined-cylinder routing from the cert (P960 worksheet block lodges these explicitly; Summary doesn't). That's the follow-on slice. Test baseline: 547 pass + 9 expected `test_sap_result_pin[000565-*]` fails preserved. Cohort-2 + ASHP cohort + all golden fixtures untouched (no certs other than 000565 lodge `solar_water_heating = True`). Pyright net-zero on touched files (68 errors at baseline = 68 errors post-change). Co-Authored-By: Claude Opus 4.7 --- .../documents_parser/elmhurst_extractor.py | 28 +++ datatypes/epc/domain/epc_property_data.py | 9 + datatypes/epc/domain/mapper.py | 3 + datatypes/epc/surveys/elmhurst_site_notes.py | 9 + .../sap10_calculator/rdsap/cert_to_inputs.py | 163 ++++++++++++++++++ .../worksheet/water_heating.py | 8 +- 6 files changed, 219 insertions(+), 1 deletion(-) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 010beb4f..a0f81318 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -29,6 +29,15 @@ from datatypes.epc.surveys.elmhurst_site_notes import ( ) +def _parse_solar_pitch_deg(raw: Optional[str]) -> Optional[int]: + """Parse the §16.0 "Collector elevation" lodgement (e.g. "30°", "60°", + or a bare integer). Returns None when absent or unparseable.""" + if not raw: + return None + m = re.search(r"(\d+)", raw) + return int(m.group(1)) if m else None + + class ElmhurstSiteNotesExtractor: def __init__(self, pages: List[str]) -> None: self._text = "\n".join(pages) @@ -1279,6 +1288,22 @@ class ElmhurstSiteNotesExtractor: # None so downstream can distinguish "no PV" from "PV via % # roof area path". pv_pct = self._int_val("Proportion of roof area") + # Solar HW collector geometry — Summary §16.0. Only populated + # when the cert lodges "Are details known? Yes" in the solar + # block. Cert 000565 lodges West / 30° / Modest. When absent + # (cert says no, or no solar HW at all) → None and the cascade + # falls back to RdSAP 10 §10.11 Table 29 defaults (South / 30° + # / Modest). + solar_lines = self._section_lines( + "16.0 Solar water heating", + "17.0 Waste Water Heat Recovery System", + ) + solar_orientation = self._local_val( + solar_lines, "Collector orientation", + ) + solar_pitch_raw = self._local_val(solar_lines, "Collector elevation") + solar_pitch = _parse_solar_pitch_deg(solar_pitch_raw) + solar_overshading = self._local_val(solar_lines, "Overshading") return Renewables( solar_water_heating=self._bool_val("Solar Water Heating"), wwhrs_present=self._bool_val("Is WWHRS present in the property?"), @@ -1290,6 +1315,9 @@ class ElmhurstSiteNotesExtractor: hydro_electricity_generated_kwh=hydro, pv_arrays=self._extract_pv_arrays(), pv_percent_roof_area=pv_pct if pv_pct > 0 else None, + solar_hw_collector_orientation=solar_orientation, + solar_hw_collector_pitch_deg=solar_pitch, + solar_hw_overshading=solar_overshading, ) def _extract_pv_arrays(self) -> List[ElmhurstPvArray]: diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 85f1527f..8162c2dc 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -634,3 +634,12 @@ class EpcPropertyData: waste_water_heat_recovery: Optional[str] = None hydro: Optional[bool] = None photovoltaic_array: Optional[bool] = None + # Solar HW collector geometry lodged in Summary §16.0 when + # "Are details known? Yes". Optional — when absent (cert lodges + # no detail, or no solar HW), the Appendix H cascade falls back + # to RdSAP 10 §10.11 Table 29 defaults (South / 30° / Modest). + # Orientation strings: "North"..."NW" (the compass names used in + # the Elmhurst Summary). + solar_hw_collector_orientation: Optional[str] = None + solar_hw_collector_pitch_deg: Optional[int] = None + solar_hw_overshading: Optional[str] = None diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index f6f3abe6..82a6450b 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -359,6 +359,9 @@ class EpcPropertyDataMapper: survey, is_flat=property_type.lower() == "flat", ), solar_water_heating=survey.renewables.solar_water_heating, + solar_hw_collector_orientation=survey.renewables.solar_hw_collector_orientation, + solar_hw_collector_pitch_deg=survey.renewables.solar_hw_collector_pitch_deg, + solar_hw_overshading=survey.renewables.solar_hw_overshading, has_hot_water_cylinder=survey.water_heating.hot_water_cylinder_present, has_fixed_air_conditioning=survey.ventilation.fixed_space_cooling, wet_rooms_count=0, diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index c736ae92..9a9a02ae 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -315,6 +315,15 @@ class Renewables: # = 40". The cascade then synthesizes a single PV array with # kWp = 0.12 × PV area, defaulting to South / 30° / Modest. pv_percent_roof_area: Optional[int] = None + # Solar HW collector lodgement (Summary §16.0). Populated only + # when the cert lodges "Are details known? Yes" — the cert can + # carry orientation / pitch / overshading without the deeper + # thermal parameters (η₀, a₁, a₂) which fall back to RdSAP 10 + # §10.11 Table 29 defaults. Cert 000565 lodges West / 30° / + # Modest in this block. + solar_hw_collector_orientation: Optional[str] = None + solar_hw_collector_pitch_deg: Optional[int] = None + solar_hw_overshading: Optional[str] = None @dataclass diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 535a20f0..de411fa7 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -123,11 +123,15 @@ from domain.sap10_calculator.worksheet.internal_gains import ( ) from domain.sap10_calculator.worksheet.solar_gains import ( ORIENTATION_BY_SAP10_CODE, + Orientation, RoofWindowInput, SolarGainsResult, solar_gains_from_cert, surface_solar_flux_w_per_m2, ) +from domain.sap10_calculator.worksheet.appendix_h_solar import ( + solar_water_heating_input_monthly_kwh, +) from domain.sap10_calculator.worksheet.heat_transmission import ( DwellingExposure, HeatTransmission, @@ -2975,6 +2979,140 @@ def _primary_loss_applies( return main.main_heating_category in {1, 2} +# RdSAP 10 §10.11 Table 29 "Heating and hot water parameters" row +# "Solar panel" (p.58) — the spec defaults to use when the cert +# lodges "Solar collector details known: No". Verbatim: +# +# "If solar panel present, the parameters for the calculation not +# provided in the RdSAP data set are: +# - panel aperture area 3 m² +# - flat panel, η₀ = 0.80, a₁ = 4.0, a₂ = 0.01 +# - facing South, pitch 30°, modest overshading +# - … +# - pump for solar-heated water is electric (75 kWh/year) +# - showers are both electric and non-electric" +# +# Lodged collector orientation / pitch / overshading on the Summary +# §16.0 (when "Are details known? Yes") override the South / 30° / +# Modest defaults. The remaining parameters (aperture, η₀, a₁, a₂) +# always take the Table 29 default unless a separate SAP-style +# detailed lodgement is present (not exposed by the Summary today; +# follow-on slice when the P960 detail extraction lands). +_TABLE_29_APERTURE_M2: Final[float] = 3.0 +_TABLE_29_ETA_0: Final[float] = 0.8 +_TABLE_29_A1: Final[float] = 4.0 +_TABLE_29_A2: Final[float] = 0.01 +_TABLE_29_LOOP_EFF: Final[float] = 0.9 +_TABLE_29_IAM_FLAT_PLATE: Final[float] = 0.94 +_TABLE_29_DEDICATED_SOLAR_STORAGE_L: Final[float] = 75.0 +_TABLE_29_DEFAULT_ORIENTATION: Final[Orientation] = Orientation.S +_TABLE_29_DEFAULT_PITCH_DEG: Final[float] = 30.0 + +# SAP 10.2 Table H2 (p.78) — overshading factor (H8). RdSAP uses the +# string lodgement on Summary §16.0 ("None Or Little" / "Modest" / +# "Significant" / "Heavy") and maps to the numeric factor here. +_TABLE_H2_OVERSHADING_FACTOR: Final[dict[str, float]] = { + "None Or Little": 1.0, + "Modest": 0.8, + "Significant": 0.65, + "Heavy": 0.5, +} + +# SAP 10.2 Appendix U §U3.1 (p.124) Table U1 — monthly average external +# air temperature for region 0 (UK average, Block 1 SAP rating). Used +# by Appendix H (H20)m/(H21)m. The demand-cascade uses postcode-PCDB +# climate instead; this constant is only the SAP-rating fallback. +_APPENDIX_U_REGION_0_EXT_TEMP_C: Final[tuple[float, ...]] = ( + 4.3, 4.9, 6.5, 8.9, 11.7, 14.6, 16.6, 16.4, 14.1, 10.6, 7.1, 4.2, +) + + +def _solar_hw_monthly_override( + *, + epc: EpcPropertyData, + hw_demand_monthly_kwh: tuple[float, ...], +) -> Optional[tuple[float, ...]]: + """SAP 10.2 Appendix H — (63c)m / (H24)m solar HW contribution. + + Returns None when the cert doesn't lodge solar HW; otherwise calls + the Appendix H orchestrator with RdSAP 10 §10.11 Table 29 defaults + for the parameters the Summary doesn't carry (aperture, η₀, a₁, + a₂, loop efficiency, IAM, dedicated solar storage) and the cert- + lodged collector orientation / pitch / overshading. Falls back to + South / 30° / Modest when the Summary doesn't lodge those either. + + Block 1 SAP rating uses region 0 (UK average) per Appendix U §U3.1; + the demand cascade's postcode-climate override is wired in a + follow-on slice. + """ + if not epc.solar_water_heating: + return None + orientation = _orientation_from_summary_string( + epc.solar_hw_collector_orientation + ) or _TABLE_29_DEFAULT_ORIENTATION + pitch_deg = ( + float(epc.solar_hw_collector_pitch_deg) + if epc.solar_hw_collector_pitch_deg is not None + else _TABLE_29_DEFAULT_PITCH_DEG + ) + overshading = _TABLE_H2_OVERSHADING_FACTOR.get( + epc.solar_hw_overshading or "Modest", + _TABLE_H2_OVERSHADING_FACTOR["Modest"], + ) + h24_kwh_positive = solar_water_heating_input_monthly_kwh( + collector_orientation=orientation, + collector_pitch_deg=pitch_deg, + region=0, + aperture_area_m2=_TABLE_29_APERTURE_M2, + zero_loss_efficiency=_TABLE_29_ETA_0, + linear_heat_loss_a1=_TABLE_29_A1, + second_order_heat_loss_a2=_TABLE_29_A2, + loop_efficiency=_TABLE_29_LOOP_EFF, + incidence_angle_modifier=_TABLE_29_IAM_FLAT_PLATE, + overshading_factor=overshading, + dedicated_solar_storage_volume_l=_TABLE_29_DEDICATED_SOLAR_STORAGE_L, + combined_cylinder_total_volume_l=None, + hot_water_demand_monthly_kwh=hw_demand_monthly_kwh, + wwhrs_monthly_kwh=(0.0,) * 12, + cold_water_temperatures_monthly_c=TABLE_J1_TCOLD_FROM_MAINS_C, + external_temperatures_monthly_c=_APPENDIX_U_REGION_0_EXT_TEMP_C, + solar_hot_water_only=True, + ) + # SAP 10.2 §4 line (64)m sign convention: heat displaced from the + # boiler is entered NEGATIVE (so the line sums to delivered HW). + # The Appendix H orchestrator returns positive (H24)m kWh of solar + # contribution; negate at the boundary. + return tuple(-v for v in h24_kwh_positive) + + +# Compass strings as lodged on the Summary §16.0 "Collector orientation" +# row. SAP 10.2 §6 ORIENTATION_BY_SAP10_CODE indexes by integer code; +# this dict maps the surveyor-typed strings. +_SUMMARY_ORIENTATION_BY_STRING: Final[dict[str, Orientation]] = { + "North": Orientation.N, + "North East": Orientation.NE, + "NE": Orientation.NE, + "East": Orientation.E, + "South East": Orientation.SE, + "SE": Orientation.SE, + "South": Orientation.S, + "South West": Orientation.SW, + "SW": Orientation.SW, + "West": Orientation.W, + "North West": Orientation.NW, + "NW": Orientation.NW, +} + + +def _orientation_from_summary_string(raw: Optional[str]) -> Optional[Orientation]: + """Look up a §16.0 / §19.0 compass-string lodgement against + `_SUMMARY_ORIENTATION_BY_STRING`. Returns None when absent. + """ + if raw is None: + return None + return _SUMMARY_ORIENTATION_BY_STRING.get(raw) + + 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 @@ -3039,6 +3177,30 @@ def _water_heating_worksheet_and_gains( # (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) + # SAP 10.2 Appendix H — solar HW contribution (63c)m. Only fires + # when the cert lodges solar HW; orchestrator drives off lodged + # collector geometry + RdSAP 10 §10.11 Table 29 defaults for + # parameters the Summary doesn't carry (aperture, η₀, a₁, a₂, + # IAM, storage). See `_solar_hw_monthly_override` for the spec + # breakdown. The orchestrator's (H17)m = (62)m must include the + # storage / primary / combi losses, so we re-run the cascade + # *without* solar to land (62)m before sizing the solar credit. + demand_pass = 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, + 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, + ) + solar_hw_override = _solar_hw_monthly_override( + epc=epc, + hw_demand_monthly_kwh=demand_pass.total_demand_monthly_kwh, + ) wh_result = water_heating_from_cert( epc=epc, mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc), @@ -3048,6 +3210,7 @@ def _water_heating_worksheet_and_gains( combi_loss_monthly_kwh_override=combi_loss_override, solar_storage_monthly_kwh_override=storage_loss_override, primary_loss_monthly_kwh_override=primary_loss_override, + solar_water_heating_monthly_kwh_override=solar_hw_override, has_electric_shower=has_electric_shower, electric_shower_count=electric_shower_count, ) diff --git a/domain/sap10_calculator/worksheet/water_heating.py b/domain/sap10_calculator/worksheet/water_heating.py index aec76b2d..dec71237 100644 --- a/domain/sap10_calculator/worksheet/water_heating.py +++ b/domain/sap10_calculator/worksheet/water_heating.py @@ -839,6 +839,7 @@ def water_heating_from_cert( 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, + solar_water_heating_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, @@ -936,11 +937,16 @@ def water_heating_from_cert( primary_loss_monthly_kwh=primary_loss, combi_loss_monthly_kwh=combi, ) + solar_hw = ( + solar_water_heating_monthly_kwh_override + if solar_water_heating_monthly_kwh_override is not None + else zero12 + ) output = output_from_water_heater_monthly_kwh( total_demand_monthly_kwh=total_demand, wwhrs_monthly_kwh=zero12, pv_diverter_monthly_kwh=zero12, - solar_monthly_kwh=zero12, + solar_monthly_kwh=solar_hw, fghrs_monthly_kwh=zero12, ) if electric_shower_monthly_kwh_override is not None: