From a243055de79da12ce157319098d993ec3dd8d106 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 18 May 2026 09:34:41 +0000 Subject: [PATCH] =?UTF-8?q?slice=20S-A7b:=20RdSAP=20cert=E2=86=92inputs=20?= =?UTF-8?q?mapper=20+=20Sap10Calculator.calculate(epc)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds domain.sap.rdsap.cert_to_inputs.cert_to_inputs(epc) which produces a typed CalculatorInputs from an EpcPropertyData, and a thin Sap10Calculator.calculate(epc) entry point that wraps the mapper + the S-A7a orchestrator. Defaults follow RdSAP 10 (Table 27 for living-area fraction, Table 5 for ventilation, Table 12 for fuel cost + CO2 factor) and SAP 10.3 Tables 4a/4b for heating efficiency via the existing domain.ml.sap_efficiencies cascade. Deferred to Session B: conservatory modes, room-in-roof, secondary heating split (Table 11), multi-fuel weighted cost, thermal-mass parameter from construction type, control-temp adjustment from main_heating_control code. Co-Authored-By: Claude Opus 4.7 --- packages/domain/src/domain/sap/calculator.py | 23 +- .../domain/src/domain/sap/rdsap/__init__.py | 0 .../src/domain/sap/rdsap/cert_to_inputs.py | 368 ++++++++++++++++++ .../src/domain/sap/rdsap/tests/__init__.py | 0 .../sap/rdsap/tests/test_cert_to_inputs.py | 215 ++++++++++ 5 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 packages/domain/src/domain/sap/rdsap/__init__.py create mode 100644 packages/domain/src/domain/sap/rdsap/cert_to_inputs.py create mode 100644 packages/domain/src/domain/sap/rdsap/tests/__init__.py create mode 100644 packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index 881c8931..9de3186a 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -33,9 +33,12 @@ Reference: SAP 10.3 specification (13-01-2026) §§5-13 (pages 23-43), Table from __future__ import annotations from dataclasses import dataclass -from typing import Final +from typing import Final, TYPE_CHECKING from domain.sap.climate.appendix_u import external_temperature_c + +if TYPE_CHECKING: + from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.sap.worksheet.dimensions import Dimensions from domain.sap.worksheet.heat_transmission import HeatTransmission from domain.sap.worksheet.internal_gains import internal_gains_w @@ -272,3 +275,21 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: lighting_kwh_per_yr=inputs.lighting_kwh_per_yr, monthly=monthly, ) + + +class Sap10Calculator: + """Deterministic SAP 10.3 calculator entry point. Maps an + `EpcPropertyData` to typed `CalculatorInputs` via the RdSAP-driven + `cert_to_inputs` mapper and runs the 12-month worksheet loop. + + Separating mapping (cert-shape rules, RdSAP defaults) from the + physics orchestration (`calculate_sap_from_inputs`) lets either side + be tested without dragging in the other — and lets product code that + already has a populated `CalculatorInputs` (e.g. a future + MeasureApplicator that emits modified inputs) skip the mapper. + """ + + def calculate(self, epc: "EpcPropertyData") -> SapResult: + from domain.sap.rdsap.cert_to_inputs import cert_to_inputs + + return calculate_sap_from_inputs(cert_to_inputs(epc)) diff --git a/packages/domain/src/domain/sap/rdsap/__init__.py b/packages/domain/src/domain/sap/rdsap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py new file mode 100644 index 00000000..10a177e4 --- /dev/null +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -0,0 +1,368 @@ +"""RdSAP 10 cert → SAP 10.3 CalculatorInputs mapping. + +Reads `EpcPropertyData` (the gov EPC API / site-notes domain model) and +produces the typed `CalculatorInputs` the synthetic-input orchestrator +consumes. The boundary between this module and `calculator.py` is the +cleanest one in the deterministic calculator: cert-shape assumptions and +RdSAP defaulting rules stay here; physics stays in `calculator.py` + +`worksheet/*`. + +Defaulting rules per RdSAP 10 (10-06-2025): + + - Dimensions: §3 (port lives in `worksheet/dimensions.py`) + - Heat transmission: §5 (port in `worksheet/heat_transmission.py`) + - Infiltration: §4 Table 5 (port in `worksheet/ventilation.py`) + - Living-area fraction: Table 27 by `habitable_rooms_count` + - Heating efficiency: SAP 10.3 Tables 4a/4b (existing + `domain.ml.sap_efficiencies.seasonal_efficiency` cascade) + - Hot-water demand: Appendix J (existing `domain.ml.demand`) + - Lighting demand: Appendix L simplified (`domain.ml.demand`) + - Fuel unit cost: Table 12 (existing `domain.ml.sap_efficiencies`, + pence/kWh → £/kWh conversion happens here) + - CO2 factors: Table 12 + +Edge cases deliberately deferred to Session B: +- conservatory modes (`has_conservatory`) +- room-in-roof contributions to wall/roof area +- secondary heating split (Table 11) +- multi-fuel weighted unit cost (currently main-fuel only) +- thermal mass parameter from construction type (defaults to medium 250) +- control_temperature_adjustment from main_heating_control code 2101/2103/2106 + (defaults to 0) + +Reference: RdSAP 10 specification (10-06-2025); SAP 10.3 specification +(13-01-2026) Tables 4a/4b/4e/12. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, Optional + +from datatypes.epc.domain.epc_property_data import ( + EpcPropertyData, + MainHeatingDetail, + SapBuildingPart, + SapVentilation, + SapWindow, +) + +from domain.ml.demand import predicted_hot_water_kwh, predicted_lighting_kwh +from domain.ml.sap_efficiencies import ( + fuel_unit_price_p_per_kwh, + seasonal_efficiency, + water_heating_efficiency, +) +from domain.sap.calculator import CalculatorInputs, WindowInput +from domain.sap.worksheet.dimensions import dimensions_from_cert +from domain.sap.worksheet.heat_transmission import heat_transmission_from_cert +from domain.sap.worksheet.solar_gains import Orientation +from domain.sap.worksheet.ventilation import infiltration_ach + + +# RdSAP 10 Table 27 — fraction of total floor area treated as the +# "living area" for the §7 mean-internal-temperature blend. +_LIVING_AREA_FRACTION_BY_ROOMS: Final[dict[int, float]] = { + 1: 0.75, 2: 0.50, 3: 0.30, 4: 0.25, 5: 0.21, 6: 0.18, + 7: 0.16, 8: 0.14, +} +_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 12 CO2 emission factors (kg CO2 / kWh delivered). +# Keys are SAP 10.2 Table 32 fuel codes (the existing fuel-price keys); +# anything not listed cascades to mains-gas baseline. +_CO2_BY_TABLE32_CODE: Final[dict[int, float]] = { + 1: 0.210, # mains gas + 2: 0.241, # bulk LPG + 3: 0.241, # bottled LPG + 4: 0.298, # heating oil + 10: 0.351, 11: 0.351, 12: 0.351, 15: 0.351, 20: 0.043, 21: 0.043, + 22: 0.043, 23: 0.043, # solid: house/anthracite high; wood ~0.043 + 30: 0.136, 31: 0.136, 32: 0.136, 33: 0.136, 34: 0.136, 35: 0.136, + 38: 0.136, 39: 0.136, 40: 0.136, 60: 0.136, 36: 0.136, # electricity + 41: 0.136, 42: 0.043, 43: 0.043, 44: 0.043, 45: 0.043, 46: 0.043, + 48: 0.043, 50: 0.0, # heat networks + 51: 0.210, 52: 0.241, 53: 0.298, 54: 0.351, 55: 0.298, 56: 0.298, + 57: 0.298, 58: 0.298, +} +_CO2_DEFAULT_KG_PER_KWH: Final[float] = 0.210 + + +# Gov EPC API main_fuel_type → Table 32. Lifted from +# `sap_efficiencies._API_TO_TABLE32` (private there). Kept inline here so +# the cert→inputs mapper stays self-contained; future consolidation in +# Session B can move both to a single Table 32 module. +_API_TO_TABLE32: Final[dict[int, int]] = { + 0: 30, 1: 1, 2: 2, 3: 3, 4: 4, 5: 15, 6: 20, 7: 23, 8: 21, 9: 10, + 10: 30, 11: 42, 12: 43, 13: 44, 14: 11, 15: 12, 16: 22, 17: 9, + 18: 75, 19: 76, 20: 51, 21: 52, 22: 53, 23: 55, 24: 54, 25: 41, + 26: 1, 27: 2, 28: 4, 29: 30, +} + + +_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 + + +def _region_index(region_code: Optional[str]) -> int: + """Coerce EpcPropertyData.region_code (str) to the integer Appendix U + region index. Out-of-range or unparseable → 0 (UK average).""" + if region_code is None: + return 0 + try: + idx = int(region_code) + except (TypeError, ValueError): + return 0 + if 0 <= idx <= 21: + return idx + return 0 + + +def _is_timber_or_steel_frame(parts: list[SapBuildingPart]) -> bool: + """RdSAP 10 §5: wall_construction codes 5 (timber frame) and 6 (system + build steel frame) get the lower 0.25 structural ACH; everything else + is treated as 0.35 masonry.""" + if not parts: + return False + wc = parts[0].wall_construction + return isinstance(wc, int) and wc in (5, 6) + + +def _living_area_fraction(habitable_rooms_count: Optional[int]) -> float: + """RdSAP 10 Table 27 by `habitable_rooms_count`. Defaults to the + bottom of the table for ≥8 rooms; falls back to the SAP convention + 0.21 when count missing or zero.""" + if not habitable_rooms_count or habitable_rooms_count <= 0: + return _LIVING_AREA_FRACTION_DEFAULT + if habitable_rooms_count in _LIVING_AREA_FRACTION_BY_ROOMS: + return _LIVING_AREA_FRACTION_BY_ROOMS[habitable_rooms_count] + return _LIVING_AREA_FRACTION_MIN + + +def _window_inputs(windows: list[SapWindow]) -> tuple[WindowInput, ...]: + """Map each cert window with a known SAP octant to a `WindowInput`. + + Defaults for the optical / shading factors are SAP 10.3 Table 6b/6c/6d + typicals (low-e double-glazing, PVC frame, average overshading) — the + cert rarely carries these directly. 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) + g = ( + float(w.window_transmission_details.solar_transmittance) + if w.window_transmission_details is not None + else 0.63 + ) + ff = float(w.frame_factor) if w.frame_factor is not None else 0.7 + out.append( + WindowInput( + area_m2=area, + orientation=_ORIENTATION_BY_CODE[orientation_code], + pitch_deg=90.0, + g_perpendicular=g, + frame_factor=ff, + 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: + return 0.0, None + total_area = 0.0 + weighted_u_area = 0.0 + measured_area = 0.0 + for w in windows: + a = float(w.window_width) * float(w.window_height) + total_area += a + if w.window_transmission_details is not None: + weighted_u_area += w.window_transmission_details.u_value * a + measured_area += a + avg_u = weighted_u_area / measured_area if measured_area > 0 else None + return total_area, avg_u + + +def _first_main_heating(epc: EpcPropertyData) -> Optional[MainHeatingDetail]: + """First entry of `sap_heating.main_heating_details` if any. Multi- + heating split (Table 11) is Session B; the first heating system + drives Session-A inputs.""" + details = epc.sap_heating.main_heating_details if epc.sap_heating else [] + return details[0] if details else None + + +def _control_type(main: Optional[MainHeatingDetail]) -> int: + """SAP 10.3 §7.1 / Table 9 control type 1/2/3. Defaults to 2 + (programmer + room thermostat or better) — the modal RdSAP case.""" + _ = main # cert-side heating-control code map is Session B work + return 2 + + +def _responsiveness(main: Optional[MainHeatingDetail]) -> float: + """SAP 10.3 Table 9b responsiveness R ∈ [0, 1]. Radiators ≈ 1.0; + underfloor ≈ 0.25. Defaults to radiators.""" + if main is None: + return 1.0 + emitter = main.heat_emitter_type + if isinstance(emitter, int) and emitter == 2: + return 0.25 + return 1.0 + + +def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]: + if main is None: + return None + fuel = main.main_fuel_type + return fuel if isinstance(fuel, int) else None + + +def _fuel_cost_gbp_per_kwh(main: Optional[MainHeatingDetail]) -> float: + """Convert Table 32 p/kWh → £/kWh. Unknown fuel falls back to mains + gas via `fuel_unit_price_p_per_kwh`.""" + return fuel_unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP + + +def _co2_factor_kg_per_kwh(main: Optional[MainHeatingDetail]) -> float: + """SAP 10.3 Table 12 CO2 emission factor by Table 32 fuel code.""" + code = _main_fuel_code(main) + if code is None: + return _CO2_DEFAULT_KG_PER_KWH + if code in _CO2_BY_TABLE32_CODE: + return _CO2_BY_TABLE32_CODE[code] + table32_code = _API_TO_TABLE32.get(code) + if table32_code is not None and table32_code in _CO2_BY_TABLE32_CODE: + return _CO2_BY_TABLE32_CODE[table32_code] + return _CO2_DEFAULT_KG_PER_KWH + + +def _int_or_none(value: object) -> Optional[int]: + return value if isinstance(value, int) else None + + +@dataclass(frozen=True) +class _VentilationCounts: + open_flues: int = 0 + closed_fire_chimneys: int = 0 + solid_fuel_boiler_chimneys: int = 0 + other_heater_chimneys: int = 0 + intermittent_fans: int = 0 + passive_vents: int = 0 + flueless_gas_fires: int = 0 + + +def _ventilation_counts(vent: Optional[SapVentilation]) -> _VentilationCounts: + if vent is None: + return _VentilationCounts() + return _VentilationCounts( + open_flues=vent.open_flues_count or 0, + closed_fire_chimneys=vent.closed_flues_count or 0, + solid_fuel_boiler_chimneys=vent.boiler_flues_count or 0, + other_heater_chimneys=vent.other_flues_count or 0, + intermittent_fans=vent.extract_fans_count or 0, + passive_vents=vent.passive_vents_count or 0, + flueless_gas_fires=vent.flueless_gas_fires_count or 0, + ) + + +def cert_to_inputs(epc: EpcPropertyData) -> CalculatorInputs: + """Build a typed `CalculatorInputs` aggregate from an `EpcPropertyData`.""" + dim = dimensions_from_cert(epc) + window_total_area, window_avg_u = _window_total_area_and_avg_u(epc.sap_windows) + ht = heat_transmission_from_cert( + epc, + window_total_area_m2=window_total_area, + window_avg_u_value=window_avg_u, + door_count=epc.door_count, + insulated_door_count=epc.insulated_door_count, + insulated_door_u_value=epc.insulated_door_u_value, + ) + + vol = dim.volume_m3 if dim.volume_m3 > 0 else 1.0 + storeys = max(1, dim.storey_count) + vc = _ventilation_counts(epc.sap_ventilation) + infiltration = infiltration_ach( + volume_m3=vol, + storey_count=storeys, + is_timber_or_steel_frame=_is_timber_or_steel_frame(epc.sap_building_parts), + open_chimneys=epc.open_chimneys_count or 0, + blocked_chimneys=epc.blocked_chimneys_count or 0, + open_flues=vc.open_flues, + closed_fire_chimneys=vc.closed_fire_chimneys, + solid_fuel_boiler_chimneys=vc.solid_fuel_boiler_chimneys, + other_heater_chimneys=vc.other_heater_chimneys, + intermittent_fans=vc.intermittent_fans, + passive_vents=vc.passive_vents, + flueless_gas_fires=vc.flueless_gas_fires, + window_pct_draught_proofed=float(epc.percent_draughtproofed or 0), + ) + + main = _first_main_heating(epc) + main_code = main.sap_main_heating_code if main is not None else None + main_category = main.main_heating_category if main is not None else None + main_fuel = _main_fuel_code(main) + + eff = seasonal_efficiency(main_code, main_category, main_fuel) + water_eff = water_heating_efficiency(epc.sap_heating.water_heating_code, main_code) + primary_age = ( + epc.sap_building_parts[0].construction_age_band if epc.sap_building_parts else None + ) + hw_kwh = predicted_hot_water_kwh( + total_floor_area_m2=epc.total_floor_area_m2, + seasonal_efficiency_water=water_eff, + cylinder_size=_int_or_none(epc.sap_heating.cylinder_size), + cylinder_insulation_thickness_mm=epc.sap_heating.cylinder_insulation_thickness_mm, + cylinder_insulation_type=_int_or_none(epc.sap_heating.cylinder_insulation_type), + age_band=primary_age, + has_wwhrs=False, + has_solar_water_heating=epc.solar_water_heating, + ) + lighting_kwh = predicted_lighting_kwh( + total_floor_area_m2=epc.total_floor_area_m2, + cfl_count=epc.cfl_fixed_lighting_bulbs_count, + led_count=epc.led_fixed_lighting_bulbs_count, + incandescent_count=epc.incandescent_fixed_lighting_bulbs_count, + ) + + return CalculatorInputs( + dimensions=dim, + heat_transmission=ht, + infiltration_ach=infiltration.total_ach, + 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), + control_temperature_adjustment_c=0.0, + thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + main_heating_efficiency=eff, + hot_water_kwh_per_yr=hw_kwh, + pumps_fans_kwh_per_yr=_DEFAULT_PUMPS_FANS_KWH_PER_YR, + lighting_kwh_per_yr=lighting_kwh, + fuel_unit_cost_gbp_per_kwh=_fuel_cost_gbp_per_kwh(main), + co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main), + ) diff --git a/packages/domain/src/domain/sap/rdsap/tests/__init__.py b/packages/domain/src/domain/sap/rdsap/tests/__init__.py new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..3e24ef27 --- /dev/null +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -0,0 +1,215 @@ +"""Tests for RdSAP 10 cert → CalculatorInputs mapper. + +End of S-A7b: an `EpcPropertyData` produces a typed `CalculatorInputs` +that the synthetic-input orchestrator can consume. Mapping rules follow +RdSAP 10 (June 2025), with SAP 10.3 referenced for Tables 4/12 and the +control / responsiveness / CO2 factor lookups. + +Tests use the shared `make_minimal_sap10_epc` fixture so synthetic data +matches the shape the live mapper sees in production. + +Reference: RdSAP 10 specification (10-06-2025) §§3-11 + Table 27 (living +area fraction); SAP 10.3 specification (13-01-2026) Tables 4a/4e/12. +""" + +from __future__ import annotations + +from typing import Final + +from datatypes.epc.domain.epc_property_data import MainHeatingDetail + +from domain.ml.tests._fixtures import ( + make_building_part, + make_floor_dimension, + make_minimal_sap10_epc, + make_sap_heating, + make_window, +) +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: + """A SAP10 gas-combi main heating detail keyed to a specific Table 4b + code so the seasonal-efficiency cascade resolves deterministically.""" + return MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, # mains gas (not community) + heat_emitter_type=1, # radiators + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + sap_main_heating_code=sap_main_heating_code, + ) + + +_TYPICAL_TFA_M2: Final[float] = 90.0 + + +def _typical_semi_detached_epc(): + """A 90 m² 2-storey semi-detached, B-band, gas-boiler — the modal + cert in the RdSAP corpus. Used as the baseline for direction checks.""" + return make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + door_count=2, + insulated_door_count=1, + region_code="1", + country_code="ENG", + open_chimneys_count=0, + sap_building_parts=[ + make_building_part( + floor_dimensions=[ + make_floor_dimension(total_floor_area_m2=45.0, floor=0), + make_floor_dimension(total_floor_area_m2=45.0, floor=1), + ], + ), + ], + sap_windows=[ + make_window(orientation=5, width=2.0, height=1.2), # S + make_window(orientation=1, width=2.0, height=1.2), # N + ], + sap_heating=make_sap_heating( + main_heating_details=[ + _gas_boiler_detail(sap_main_heating_code=102), # gas combi, 84% eff + ], + ), + ) + + +def test_minimal_cert_round_trips_through_calculator_and_returns_sap_result() -> None: + # Arrange — a complete-enough RdSAP cert (envelope + heating + windows + # + region) that the mapper can populate every CalculatorInputs field. + epc = _typical_semi_detached_epc() + + # Act + result = Sap10Calculator().calculate(epc) + + # Assert — tracer: end-to-end mapper + orchestrator yields a valid + # SapResult with a plausible SAP score for an ENG mid-band dwelling. + assert isinstance(result, SapResult) + assert len(result.monthly) == 12 + assert 1 <= result.sap_score <= 100 + assert result.space_heating_kwh_per_yr > 0 + assert result.total_fuel_cost_gbp > 0 + + +def test_region_code_string_is_translated_to_appendix_u_region_index() -> None: + # Arrange — `region_code` on EpcPropertyData is a string ("1"="Thames", + # "20"="Shetland"); the mapper must coerce it to the integer index + # Appendix U expects, falling back to 0 (UK average) when missing. + base = _typical_semi_detached_epc() + thames = base + no_region = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + region_code=None, + sap_building_parts=base.sap_building_parts, + sap_heating=base.sap_heating, + ) + + # Act + inputs_thames = cert_to_inputs(thames) + inputs_default = cert_to_inputs(no_region) + + # Assert + assert inputs_thames.region == 1 + 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 + # chimneys must report a higher infiltration ACH than one with 0. + base = _typical_semi_detached_epc() + with_chimney = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + region_code="1", + open_chimneys_count=2, + sap_building_parts=base.sap_building_parts, + sap_windows=base.sap_windows, + sap_heating=base.sap_heating, + ) + + # Act + inputs_base = cert_to_inputs(base) + inputs_chim = cert_to_inputs(with_chimney) + + # Assert + assert inputs_chim.infiltration_ach > inputs_base.infiltration_ach + + +def test_living_area_fraction_uses_rdsap_table_27_by_habitable_rooms() -> None: + # Arrange — RdSAP 10 Table 27 lookup by `habitable_rooms_count`: + # fewer rooms → larger living-area share. 1 → 0.75, 2 → 0.50, + # 3 → 0.30, 4 → 0.25, ≥5 → smaller still. + one_room = _typical_semi_detached_epc() + one_room.habitable_rooms_count = 1 + four_rooms = _typical_semi_detached_epc() + four_rooms.habitable_rooms_count = 4 + + # Act + inputs_one = cert_to_inputs(one_room) + inputs_four = cert_to_inputs(four_rooms) + + # Assert + assert inputs_one.living_area_fraction == 0.75 + assert inputs_four.living_area_fraction == 0.25 + + +def test_main_heating_efficiency_reads_sap_main_heating_code() -> None: + # Arrange — Direction check: a gas combi (Table 4b code 102, 84% eff) + # vs a non-condensing gas boiler (code 105, 70% eff) must show through + # on `main_heating_efficiency`. Reads `seasonal_efficiency` from the + # existing SAP-efficiency module. + high_eff = _typical_semi_detached_epc() + low_eff = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + region_code="1", + sap_building_parts=high_eff.sap_building_parts, + sap_windows=high_eff.sap_windows, + sap_heating=make_sap_heating( + main_heating_details=[ + _gas_boiler_detail(sap_main_heating_code=105), + ], + ), + ) + + # Act + inputs_hi = cert_to_inputs(high_eff) + inputs_lo = cert_to_inputs(low_eff) + + # Assert + assert inputs_hi.main_heating_efficiency == 0.84 + assert inputs_lo.main_heating_efficiency == 0.70 + + +def test_mains_gas_fuel_cost_in_gbp_per_kwh() -> None: + # Arrange — Table 12 mains-gas unit price is 3.48 p/kWh; mapper must + # report this as £0.0348/kWh (decimal-pound, not pence). + epc = _typical_semi_detached_epc() + + # Act + inputs = cert_to_inputs(epc) + + # Assert + assert inputs.fuel_unit_cost_gbp_per_kwh == 0.0348