slice S-A7b: RdSAP cert→inputs mapper + Sap10Calculator.calculate(epc)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 09:34:41 +00:00
parent 684e2945ae
commit a243055de7
5 changed files with 605 additions and 1 deletions

View file

@ -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))

View file

@ -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),
)

View file

@ -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