mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
684e2945ae
commit
a243055de7
5 changed files with 605 additions and 1 deletions
|
|
@ -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))
|
||||
|
|
|
|||
0
packages/domain/src/domain/sap/rdsap/__init__.py
Normal file
0
packages/domain/src/domain/sap/rdsap/__init__.py
Normal file
368
packages/domain/src/domain/sap/rdsap/cert_to_inputs.py
Normal file
368
packages/domain/src/domain/sap/rdsap/cert_to_inputs.py
Normal 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),
|
||||
)
|
||||
0
packages/domain/src/domain/sap/rdsap/tests/__init__.py
Normal file
0
packages/domain/src/domain/sap/rdsap/tests/__init__.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue