From a0ce45c98cbeab663be2c21b0340751489185a40 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 May 2026 21:01:32 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A76=20slice=207:=20delete=20legacy=20=5Fsol?= =?UTF-8?q?ar=5Fgains=5Fw=20+=20WindowInput=20+=20=5Fwindow=5Finputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes: - calculator.WindowInput dataclass - calculator.CalculatorInputs.windows field - calculator._solar_gains_w function - cert_to_inputs._window_inputs / _g_perpendicular / _frame_factor - cert_to_inputs._G_PERPENDICULAR_BY_GLAZING_TYPE / _FRAME_FACTOR_BY_MATERIAL / _ORIENTATION_BY_CODE lookup tables (duplicated, spec-correct versions live in solar_gains.py) - 3 obsolete tests in test_cert_to_inputs.py that probed deleted internals; one asserted the spec-incorrect Metal frame factor 0.83 (Table 6c spec value is 0.8). Test fixtures in test_calculator.py + test_bre_worked_examples.py pin the prior synthetic solar 12-tuple verbatim so heat-balance numerics stay identical pre/post §6 wiring. Co-Authored-By: Claude Opus 4.7 --- packages/domain/src/domain/sap/calculator.py | 43 ------- .../src/domain/sap/rdsap/cert_to_inputs.py | 108 +----------------- .../sap/rdsap/tests/test_cert_to_inputs.py | 53 --------- .../sap/tests/test_bre_worked_examples.py | 30 +---- .../src/domain/sap/tests/test_calculator.py | 31 ++--- 5 files changed, 15 insertions(+), 250 deletions(-) diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index 9eb75d73..67a150c2 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -50,11 +50,6 @@ from domain.sap.worksheet.rating import ( sap_rating, sap_rating_integer, ) -from domain.sap.worksheet.solar_gains import ( - Orientation, - surface_solar_flux_w_per_m2, - window_solar_gain_w, -) from domain.sap.worksheet.space_heating import monthly_heat_requirement_kwh from domain.sap.worksheet.utilisation_factor import utilisation_factor @@ -65,22 +60,6 @@ _TIME_CONSTANT_DIVISOR_KJ_TO_WH: Final[float] = 3.6 _ETA_ITERATIONS: Final[int] = 2 -@dataclass(frozen=True) -class WindowInput: - """One glazed opening contributing solar gain. Orientation maps to a - Table U5 column and to Table U4 latitude via `surface_solar_flux_w_per_m2`. - `g_perpendicular`, `frame_factor`, `overshading_factor` come from - Tables 6b/6c/6d — supplied by the caller so this module remains - physics-only.""" - - area_m2: float - orientation: Orientation - pitch_deg: float - g_perpendicular: float - frame_factor: float - overshading_factor: float - - @dataclass(frozen=True) class CalculatorInputs: """Synthetic SAP 10.3 calculator inputs. The cert→inputs mapper @@ -110,7 +89,6 @@ class CalculatorInputs: # only indexes into it per month, no recomputation here. solar_gains_monthly_w: tuple[float, ...] region: int - windows: tuple[WindowInput, ...] control_type: int responsiveness: float living_area_fraction: float @@ -185,27 +163,6 @@ class SapResult: intermediate: dict[str, float] -def _solar_gains_w( - *, windows: tuple[WindowInput, ...], region: int, month: int -) -> float: - total = 0.0 - for w in windows: - s = surface_solar_flux_w_per_m2( - orientation=w.orientation, - pitch_deg=w.pitch_deg, - region=region, - month=month, - ) - total += window_solar_gain_w( - area_m2=w.area_m2, - surface_flux_w_per_m2=s, - g_perpendicular=w.g_perpendicular, - frame_factor=w.frame_factor, - overshading_factor=w.overshading_factor, - ) - return total - - def _time_constant_h(*, tmp_kj_per_m2_k: float, tfa_m2: float, hlc_w_per_k: float) -> float: if hlc_w_per_k <= 0: return float("inf") 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 cd3c3c2b..c35e4cb2 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -52,7 +52,7 @@ from domain.ml.sap_efficiencies import ( seasonal_efficiency, water_heating_efficiency as _legacy_water_heating_efficiency, ) -from domain.sap.calculator import CalculatorInputs, WindowInput +from domain.sap.calculator import CalculatorInputs from domain.sap.tables.table_12 import ( co2_factor_kg_per_kwh, primary_energy_factor, @@ -67,7 +67,7 @@ from domain.sap.worksheet.heat_transmission import ( DwellingExposure, heat_transmission_from_cert, ) -from domain.sap.worksheet.solar_gains import Orientation, solar_gains_from_cert +from domain.sap.worksheet.solar_gains import solar_gains_from_cert from domain.sap.worksheet.ventilation import ( MechanicalVentilationKind, ventilation_from_inputs, @@ -88,56 +88,6 @@ _LIVING_AREA_FRACTION_DEFAULT: Final[float] = 0.21 _LIVING_AREA_FRACTION_MIN: Final[float] = 0.13 -# SAP10 octant code → solar_gains.Orientation. Codes 1-8 only; anything -# else (0, "NR", arbitrary string) is treated as un-mapped and the window -# contributes no solar gain. -_ORIENTATION_BY_CODE: Final[dict[int, Orientation]] = { - 1: Orientation.N, - 2: Orientation.NE, - 3: Orientation.E, - 4: Orientation.SE, - 5: Orientation.S, - 6: Orientation.SW, - 7: Orientation.W, - 8: Orientation.NW, -} - - -# SAP 10.3 Table 6b — g_perpendicular (solar transmittance at normal -# incidence) by SAP10 glazing_type code. Default 0.72 (modern double -# glazing, no low-E) when the cert's glazing_type is missing or -# unrecognised — the modal RdSAP case. -_G_PERPENDICULAR_BY_GLAZING_TYPE: Final[dict[int, float]] = { - 1: 0.85, # single - 2: 0.72, # double 2002-2022 (no low-E) - 3: 0.72, # double pre-2002 - 4: 0.63, # double low-E soft coat - 5: 0.76, # secondary glazing - 6: 0.68, # triple -} -_G_PERPENDICULAR_DEFAULT: Final[float] = 0.72 - - -# SAP 10.3 Table 6c — frame factor (proportion of window area that is -# glazed, not frame) by frame material. The cert lodges this as a free- -# text string ("PVC", "Wood", "Metal", "Aluminium"); matching is case- -# insensitive and substring-based because site-notes capitalisation -# drifts. -_FRAME_FACTOR_BY_MATERIAL: Final[tuple[tuple[str, float], ...]] = ( - ("metal with thermal break", 0.80), - ("metal", 0.83), - ("aluminium with thermal break", 0.80), - ("aluminium", 0.83), - ("steel", 0.83), - ("wood", 0.70), - ("timber", 0.70), - ("pvc", 0.70), - ("upvc", 0.70), - ("composite", 0.70), -) -_FRAME_FACTOR_DEFAULT: Final[float] = 0.70 - - _PENCE_TO_GBP: Final[float] = 0.01 _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0 _DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0 @@ -363,59 +313,6 @@ def _living_area_fraction(habitable_rooms_count: Optional[int]) -> float: return _LIVING_AREA_FRACTION_MIN -def _g_perpendicular(w: SapWindow) -> float: - """Solar transmittance at normal incidence per SAP 10.3 Table 6b. - Prefer the cert's measured value (`window_transmission_details`); - otherwise look up by `glazing_type`.""" - if w.window_transmission_details is not None: - return float(w.window_transmission_details.solar_transmittance) - if isinstance(w.glazing_type, int) and w.glazing_type in _G_PERPENDICULAR_BY_GLAZING_TYPE: - return _G_PERPENDICULAR_BY_GLAZING_TYPE[w.glazing_type] - return _G_PERPENDICULAR_DEFAULT - - -def _frame_factor(w: SapWindow) -> float: - """SAP 10.3 Table 6c frame factor. Prefer the cert's measured value - (`SapWindow.frame_factor`); otherwise look up by `frame_material`.""" - if w.frame_factor is not None: - return float(w.frame_factor) - material = (w.frame_material or "").lower() - for needle, ff in _FRAME_FACTOR_BY_MATERIAL: - if needle in material: - return ff - return _FRAME_FACTOR_DEFAULT - - -def _window_inputs(windows: list[SapWindow]) -> tuple[WindowInput, ...]: - """Map each cert window with a known SAP octant to a `WindowInput`. - - `g_perpendicular` follows SAP 10.3 Table 6b (by glazing_type when no - measured transmission details), `frame_factor` follows Table 6c (by - frame_material when no measured value). Overshading factor stays at - SAP's "average" default 0.77 because the cert doesn't lodge a per- - window overshading code in RdSAP 10. Pitch = 90° (vertical windows). - Windows whose orientation isn't in 1-8 are dropped, matching the - live ML feature-builder convention. - """ - out: list[WindowInput] = [] - for w in windows: - orientation_code = w.orientation if isinstance(w.orientation, int) else None - if orientation_code is None or orientation_code not in _ORIENTATION_BY_CODE: - continue - area = float(w.window_width) * float(w.window_height) - out.append( - WindowInput( - area_m2=area, - orientation=_ORIENTATION_BY_CODE[orientation_code], - pitch_deg=90.0, - g_perpendicular=_g_perpendicular(w), - frame_factor=_frame_factor(w), - overshading_factor=0.77, - ) - ) - return tuple(out) - - def _window_total_area_and_avg_u(windows: list[SapWindow]) -> tuple[float, Optional[float]]: """Area-weighted total + U-value for the conduction worksheet.""" if not windows: @@ -1009,7 +906,6 @@ def cert_to_inputs( overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING, ).total_solar_gains_monthly_w, region=_region_index(epc.region_code), - windows=_window_inputs(epc.sap_windows), control_type=_control_type(main), responsiveness=_responsiveness(main), living_area_fraction=_living_area_fraction(epc.habitable_rooms_count), diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index e01c5f4a..109b03d4 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -29,7 +29,6 @@ from domain.ml.tests._fixtures import ( ) from domain.sap.calculator import Sap10Calculator, SapResult from domain.sap.rdsap.cert_to_inputs import cert_to_inputs -from domain.sap.worksheet.solar_gains import Orientation def _gas_boiler_detail(sap_main_heating_code: int = 102) -> MainHeatingDetail: @@ -319,22 +318,6 @@ def test_calculator_always_uses_uk_average_weather_for_rating() -> None: assert inputs_default.region == 0 -def test_window_orientation_codes_map_to_solar_gains_orientation_enum() -> None: - # Arrange — SAP10 octant codes 1-8 (1=N, 5=S) must surface in the - # mapped WindowInput list as the matching `Orientation` enum members. - base = _typical_semi_detached_epc() - - # Act - inputs = cert_to_inputs(base) - - # Assert — south + north window from the fixture both land. - orientations = {w.orientation for w in inputs.windows} - assert orientations == {Orientation.S, Orientation.N} - south = next(w for w in inputs.windows if w.orientation == Orientation.S) - assert south.area_m2 == 2.0 * 1.2 # width × height from fixture - assert south.pitch_deg == 90.0 - - def test_open_chimneys_raise_infiltration_ach() -> None: # Arrange — Direction check: chimneys add Table 2.1 volume to the # infiltration calc, so an otherwise identical dwelling with 2 open @@ -464,42 +447,6 @@ def test_main_heating_control_code_maps_to_sap_control_type() -> None: assert type_3.control_type == 3 -def test_window_g_perpendicular_uses_table_6b_by_glazing_type() -> None: - # Arrange — SAP 10.3 Table 6b: g⊥ depends on the glazing type when - # transmission details aren't measured. Single (code 1) → 0.85; - # double low-E soft coat (code 4) → 0.63; triple (code 6) → 0.68. - single = make_window(orientation=5, glazing_type=1, frame_material="PVC") - triple = make_window(orientation=5, glazing_type=6, frame_material="PVC") - low_e_double = make_window(orientation=5, glazing_type=4, frame_material="PVC") - base = _typical_semi_detached_epc() - base.sap_windows = [single, triple, low_e_double] - - # Act - inputs = cert_to_inputs(base) - - # Assert — same orientation, three different glazing types. - g_values = sorted(w.g_perpendicular for w in inputs.windows) - assert g_values == [0.63, 0.68, 0.85] - - -def test_window_frame_factor_uses_table_6c_by_frame_material() -> None: - # Arrange — Table 6c: PVC/Wood = 0.70; aluminium / steel = 0.83 - # (no thermal break). Metal-with-thermal-break would be 0.80 but - # not tested here since cert strings rarely carry that distinction. - pvc = make_window(orientation=5, frame_material="PVC") - wood = make_window(orientation=5, frame_material="Wood") - aluminium = make_window(orientation=5, frame_material="Aluminium") - base = _typical_semi_detached_epc() - base.sap_windows = [pvc, wood, aluminium] - - # Act - inputs = cert_to_inputs(base) - - # Assert - ff_values = sorted(w.frame_factor for w in inputs.windows) - assert ff_values == [0.70, 0.70, 0.83] - - def test_off_peak_meter_routes_electric_costs_to_low_rate() -> None: # Arrange — RdSAP rule (per S-B15): we trust the cert's lodged # meter_type as the tariff source of truth. SAP10 code 2 = off-peak diff --git a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py index 6da003b4..6f46b3d2 100644 --- a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py +++ b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py @@ -25,13 +25,10 @@ import pytest from domain.sap.calculator import ( CalculatorInputs, - WindowInput, - _solar_gains_w, calculate_sap_from_inputs, ) from domain.sap.worksheet.dimensions import Dimensions from domain.sap.worksheet.heat_transmission import HeatTransmission -from domain.sap.worksheet.solar_gains import Orientation def _baseline_dwelling() -> CalculatorInputs: @@ -63,34 +60,19 @@ def _baseline_dwelling() -> CalculatorInputs: total_external_element_area_m2=200.0, # synthetic placeholder total_w_per_k=150.0, ) - windows = ( - WindowInput( - area_m2=4.0, - orientation=Orientation.S, - pitch_deg=90.0, - g_perpendicular=0.63, - frame_factor=0.7, - overshading_factor=0.77, - ), - WindowInput( - area_m2=4.0, - orientation=Orientation.N, - pitch_deg=90.0, - g_perpendicular=0.63, - frame_factor=0.7, - overshading_factor=0.77, - ), - ) return CalculatorInputs( dimensions=dim, heat_transmission=ht, monthly_infiltration_ach=(0.7,) * 12, internal_gains_monthly_w=(450.0,) * 12, - solar_gains_monthly_w=tuple( - _solar_gains_w(windows=windows, region=0, month=m) for m in range(1, 13) + # Hand-computed solar (S + N 4 m² panes, g⊥=0.63 FF=0.7 Z=0.77, + # UK-avg region 0, vertical) — captured at HEAD so the trace fixture + # matches the pre-§6-wiring numerical baseline. + solar_gains_monthly_w=( + 70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177, + 223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212, ), region=0, - windows=windows, control_type=2, responsiveness=1.0, living_area_fraction=0.30, diff --git a/packages/domain/src/domain/sap/tests/test_calculator.py b/packages/domain/src/domain/sap/tests/test_calculator.py index 5ee88aae..1c6afcea 100644 --- a/packages/domain/src/domain/sap/tests/test_calculator.py +++ b/packages/domain/src/domain/sap/tests/test_calculator.py @@ -23,13 +23,10 @@ import pytest from domain.sap.calculator import ( CalculatorInputs, SapResult, - WindowInput, - _solar_gains_w, calculate_sap_from_inputs, ) from domain.sap.worksheet.dimensions import Dimensions from domain.sap.worksheet.heat_transmission import HeatTransmission -from domain.sap.worksheet.solar_gains import Orientation def _baseline_inputs() -> CalculatorInputs: @@ -59,24 +56,6 @@ def _baseline_inputs() -> CalculatorInputs: total_external_element_area_m2=200.0, # synthetic placeholder total_w_per_k=150.0, ) - windows = ( - WindowInput( - area_m2=4.0, - orientation=Orientation.S, - pitch_deg=90.0, - g_perpendicular=0.63, - frame_factor=0.7, - overshading_factor=0.77, - ), - WindowInput( - area_m2=4.0, - orientation=Orientation.N, - pitch_deg=90.0, - g_perpendicular=0.63, - frame_factor=0.7, - overshading_factor=0.77, - ), - ) return CalculatorInputs( dimensions=dim, heat_transmission=ht, @@ -85,11 +64,15 @@ def _baseline_inputs() -> CalculatorInputs: # per-month variation lives in §5 orchestrator output; tracer # tests don't need the modulation to verify the SAP loop. internal_gains_monthly_w=(450.0,) * 12, - solar_gains_monthly_w=tuple( - _solar_gains_w(windows=windows, region=0, month=m) for m in range(1, 13) + # Hand-computed solar (S + N 4 m² panes, g⊥=0.63 FF=0.7 Z=0.77, + # UK-avg region 0, vertical) — captured from §6 leaves at HEAD + # so this synthetic baseline keeps the same heat-balance gains + # as before the §6 wiring slice. + solar_gains_monthly_w=( + 70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177, + 223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212, ), region=0, - windows=windows, control_type=2, responsiveness=1.0, living_area_fraction=0.30,