From 2a4d67e39659311445a8257a46138a8d7405e494 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 23:21:08 +0000 Subject: [PATCH] =?UTF-8?q?feat(conservatory):=20=C2=A76.1=20solar=20gains?= =?UTF-8?q?=20+=20TFA-occupancy=20(demand-side)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the §6.1 conservatory demand cascade per RdSAP 10 §6.1 + Table 25. Solar gains (§6, solar_gains.py) — Table 25 note (PDF p.51): "The orientation of windows in a conservatory is not recorded, thus solar gains are calculated using the default solar flux (East/West orientation, with 20° pitch for roof windows)." The glazed wall bills onto the (76) East line (vertical, average-overshading Z); the glazed roof onto the (82) roof-window line (20° pitch, Z=1.0), both at Table 25 g=0.76, FF=0.70. TFA-occupancy (mapper) — §6.1: the conservatory floor area is added to the dwelling total floor area. TFA drives occupancy → §5 internal gains + §4 hot-water demand, so the non-separated conservatory's floor area now enters `EpcPropertyData.total_floor_area_m2` (the worksheet's (4) = 95.38 carries it). Separated conservatories (§6.2) stay excluded. Pinned against the case-44 P960 demand cascade at abs=1e-4: (73) internal gains 625.1759, (83) solar gains 495.8655, (95) useful gains 1079.6510, (99) space heating per m² 89.8073 — the full §6.1 chain reproduces EXACTLY. The whole-dwelling SAP (72.9517) / CO2 (3241.8656) are not pinned: the case-44 Summary omits the House-Coal secondary heater (SAP 633) the P960 descriptor carries (cf. case 43), so the cascade computes no secondary — the entire residual (+349.77 kg CO2). A Summary-input defect, independent of §6.1; every conservatory-affected line ref is exact. Worksheet harness stays 47/47 0-raised; corpus unchanged (API path; mirror is the next slice). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 12 +++++ .../sap10_calculator/worksheet/solar_gains.py | 46 +++++++++++++++++++ .../_elmhurst_worksheet_001431_case44.py | 25 ++++++++++ .../worksheet/test_section_cascade_pins.py | 44 ++++++++++++++++++ 4 files changed, 127 insertions(+) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 514803ec..c2b5c2cc 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -416,6 +416,18 @@ class EpcPropertyDataMapper: ext.room_in_roof.floor_area_m2 for ext in survey.extensions if ext.room_in_roof is not None + ) + # RdSAP 10 §6.1 (PDF p.49) — a non-separated conservatory's + # floor area is added to the dwelling total floor area. TFA + # drives occupancy → §5 internal gains + §4 hot-water demand, + # so it must include the conservatory (the worksheet's (4) = + # 95.38 carries it). Separated conservatories (§6.2) are + # disregarded. + + ( + survey.conservatory.floor_area_m2 + if survey.conservatory is not None + and not survey.conservatory.thermally_separated + else 0.0 ), 2, ), diff --git a/domain/sap10_calculator/worksheet/solar_gains.py b/domain/sap10_calculator/worksheet/solar_gains.py index 8d3d12a4..04de4982 100644 --- a/domain/sap10_calculator/worksheet/solar_gains.py +++ b/domain/sap10_calculator/worksheet/solar_gains.py @@ -36,6 +36,10 @@ from math import cos, radians, sin from typing import Final from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow +from domain.sap10_calculator.worksheet.conservatory import ( + CONSERVATORY_ROOF_PITCH_DEG, + conservatory_geometry, +) from domain.sap10_calculator.tables.pcdb.postcode_weather import PostcodeClimate from domain.sap10_calculator.climate.appendix_u import ( horizontal_solar_irradiance_w_per_m2, @@ -435,6 +439,48 @@ def solar_gains_from_cert( o: _sum_tuples(*per_orientation[o]) for o in Orientation } + # RdSAP 10 §6.1 (PDF p.49) + Table 25 note (p.51): "The orientation of + # windows in a conservatory is not recorded, thus solar gains are + # calculated using the default solar flux (East/West orientation, with + # 20° pitch for roof windows)." Average overshading per §7 (Table 6d). + # The glazed wall bills onto the (76) East line (vertical, Z=z_vertical); + # the glazed roof onto the (82) roof-window line (20° pitch, Z=1.0). + cons = conservatory_geometry(epc) + if cons is not None: + cons_wall_monthly = tuple( + window_solar_gain_w( + area_m2=cons.glazed_wall_area_m2, + surface_flux_w_per_m2=surface_solar_flux_w_per_m2( + orientation=Orientation.E, pitch_deg=90.0, + region=region, month=m, + ), + g_perpendicular=cons.g_value, + frame_factor=cons.frame_factor, + overshading_factor=z_vertical, + ) + for m in _MONTHS + ) + cons_roof_monthly = tuple( + window_solar_gain_w( + area_m2=cons.glazed_roof_area_m2, + surface_flux_w_per_m2=surface_solar_flux_w_per_m2( + orientation=Orientation.E, + pitch_deg=CONSERVATORY_ROOF_PITCH_DEG, + region=region, month=m, + ), + g_perpendicular=cons.g_value, + frame_factor=cons.frame_factor, + overshading_factor=_HORIZONTAL_Z, + ) + for m in _MONTHS + ) + per_orientation_summed[Orientation.E] = _sum_tuples( + per_orientation_summed[Orientation.E], cons_wall_monthly, + ) + roof_windows_monthly = _sum_tuples( + roof_windows_monthly, cons_roof_monthly, + ) + total = _sum_tuples( *per_orientation_summed.values(), roof_windows_monthly, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py index 39d7da49..49318a42 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py @@ -75,6 +75,31 @@ LINE_31_EXTERNAL_AREA_M2: Final[float] = 294.2900 LINE_33_FABRIC_W_PER_K: Final[float] = 207.3274 LINE_36_THERMAL_BRIDGING_W_PER_K: Final[float] = 23.5432 +# Demand-side line refs (Jan column, UK-average rating block). These +# integrate the WHOLE §6.1 conservatory chain end-to-end: +# - (73) internal gains — the conservatory floor area enters TFA (4), +# which drives occupancy → §5 appliance/cooking/metabolic gains; +# - (83) solar gains — the glazed wall (E/W flux, 90° pitch) + glazed +# roof (E/W flux, 20° pitch) at Table 25 g=0.76, FF=0.70; +# - (95) useful gains = (84) total gains × the §7 utilisation factor — +# matches only when fabric (33), ventilation (38) AND gains (84) all +# agree, so it is the single tightest end-to-end conservatory pin; +# - (99) space heating per m² = (98c)/(4) — the integrated demand. +LINE_73_INTERNAL_GAINS_JAN_W: Final[float] = 625.1759 +LINE_83_SOLAR_GAINS_JAN_W: Final[float] = 495.8655 +LINE_95_USEFUL_GAINS_JAN_W: Final[float] = 1079.6510 +LINE_99_SPACE_HEATING_PER_M2_KWH: Final[float] = 89.8073 + +# NB — the full SAP value (72.9517) + (272) CO2 (3241.8656) are NOT pinned +# here. The case-44 Summary PDF omits the House-Coal secondary heater +# (SAP 633, 60% eff) that the P960 worksheet's descriptor block carries +# (the same secondary as case 43); routed through the extractor the +# Summary therefore yields NO secondary system, and the residual SAP/CO2 +# gap is exactly that missing secondary (main+secondary CO2 1927.31 + +# 563.92 = 2491.23 vs cascade 2141.46 → +349.77 ≈ the 350 kg deficit). +# This is a Summary-input defect, independent of §6.1 — every +# conservatory-affected line ref above reproduces the P960 EXACTLY. + def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: """Convert a Summary PDF into the per-page text format the diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index 19b9d7dc..6547a585 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -427,6 +427,50 @@ def test_case44_non_separated_conservatory_fabric_matches_pdf() -> None: ) +def test_case44_conservatory_demand_side_matches_pdf() -> None: + """End-to-end §6.1 conservatory demand pin for simulated case 44. + Beyond the §3 fabric, the conservatory ripples through the demand + cascade: its floor area enters TFA (4) → occupancy → §5 internal + gains (73); its glazing contributes §6 solar gains (83) at the + default E/W flux (Table 25 g=0.76, FF=0.70, 20° roof pitch); fabric + + ventilation + gains combine into the §7 useful gains (95) and the + space-heating demand (99). Every line ref reproduces the P960 to 1e-4. + + The full SAP/CO2 is NOT asserted: the case-44 Summary omits the + House-Coal secondary heater the P960 carries (see the provider's NB) — + a Summary-input defect downstream of, and independent of, §6.1.""" + # Arrange + epc = _w001431_case44.build_epc() + + # Act + ig = internal_gains_section_from_cert(epc) + sg = solar_gains_section_from_cert(epc) + sh = space_heating_section_from_cert(epc) + assert ig is not None # TFA present ⇒ §5 helper returns a result + + # Assert — §5/§6/§7 demand line refs, each at abs=1e-4. + _pin( + ig.total_internal_gains_monthly_w[0], + _w001431_case44.LINE_73_INTERNAL_GAINS_JAN_W, + "§5 (73) case44", + ) + _pin( + sg.total_solar_gains_monthly_w[0], + _w001431_case44.LINE_83_SOLAR_GAINS_JAN_W, + "§6 (83) case44", + ) + _pin( + sh.useful_gains_monthly_w[0], + _w001431_case44.LINE_95_USEFUL_GAINS_JAN_W, + "§7 (95) case44", + ) + _pin( + sh.space_heating_per_m2_kwh, + _w001431_case44.LINE_99_SPACE_HEATING_PER_M2_KWH, + "§7 (99) case44", + ) + + def test_case44_blower_door_pressure_test_matches_pdf() -> None: """Simulated case 44 lodges a Blower Door air-pressure test (§12.2 "Pressure Test Result (AP50) 4.50"). SAP 10.2 §2 (17)-(18):