Model/domain/sap10_ml/tests/_fixtures.py
Khalim Conn-Kowlessar 76fdab42de Slice 102b: cylinder storage loss via SAP 10.2 Tables 2/2a/2b
SAP 10.2 §4 line 7690 (full spec PDF p.136) defines the cylinder storage
loss cascade for any cert with a hot water cylinder lodged:
  (54) = V × L × VF × TF           (Table 2 absence-of-declared-loss branch)
  (55) = (54)                       (no manufacturer's declared loss)
  (56)m = (55) × n_m                (per spec, n_m = days in month)
where
  L  = Table 2 (PDF p.158) Note 1 formula for the lodged insulation type
       (factory-insulated cylinders: 0.005 + 0.55/(t+4.0); loose jacket:
       0.005 + 1.76/(t+12.8))
  VF = Table 2a (PDF p.158) Note 2 closed form (120/V)^(1/3)
  TF = Table 2b (PDF p.159) base 0.60 for indirect / electric-immersion
       cylinders, × 1.3 if no thermostat, × 0.9 if DHW separately timed

Prior, `water_heating_from_cert` hard-coded `solar_storage_monthly_kwh
= zero12` and `_water_heating_worksheet_and_gains` had no path to
populate it. The new `cylinder_storage_loss_monthly_kwh` helper in
`worksheet/water_heating.py` exposes Tables 2 / 2a / 2b as small typed
functions plus a composite; the cert-side orchestrator in
`rdsap/cert_to_inputs.py::_cylinder_storage_loss_override` resolves
the lodged cylinder fields and injects the override.

Code → litres mapping ground-truthed against worksheet (47) line refs
in /sap worksheets/Additional data with api/<cert>/dr87-*.pdf for the
7-cert ASHP cohort: code 3 → 160 L (Medium, 6 certs) and code 4 →
210 L (Large, cert 9418). Codes 2 / 5 / 6 (Normal / Inaccessible /
Exact) absent from the cohort and not yet mapped.

Cylinder insulation type code → "factory_insulated" mapping
(_CYLINDER_INSULATION_TYPE_FACTORY = 1) ground-truthed against all 7
ASHP cohort worksheets ("Foam" lodgement → SAP 10.2 Table 2 Note 2
"factory-insulated cylinder where the insulation is applied in the
course of manufacture irrespective of the insulation material used").

RdSAP §3 default table (PDF p.57) — "Hot water separately timed:
Post-1998 boiler: Yes" — applied to heat-pump main heating systems
(cat 4) per the cohort worksheet evidence.

Cert 0380 (Mitsubishi ASHP, 160 L factory 50 mm, thermostat + separately
timed) lands the spec formula at worksheet (56) Jan = 36.9530 kWh/month
(test pinned at 1e-4); HW kWh/yr 242.21 → 431.38, recovering ~189 kWh/yr
of cylinder loss the cascade was previously dropping.

Cohort regression: cert 0390-2954 (oil boiler + 160 L cylinder) tightens
PE residual -28.6783 → -27.5026 kWh/m² and CO2 residual -2.7640 →
-2.6570 t/yr — both move closer to the lodged values (improvement).
Re-pinned with a slice-102b note.

Closed boiler chain tests (001479, 0330, 9501) unaffected: those certs
lodge has_hot_water_cylinder=false so the override stays None and the
existing zero-storage-loss default fires.
2026-05-27 11:42:01 +00:00

369 lines
14 KiB
Python

"""Test fixtures for EpcMlTransform tests.
`make_minimal_sap10_epc()` constructs a valid EpcPropertyData with the smallest
sensible defaults for required fields; target values are passed by kwarg so each
test parametrises only the fields it cares about.
`make_window()` builds a SapWindow with sensible SAP10 defaults; pass the fields
relevant to the test (orientation / dimensions / glazing / draught proofing).
"""
from datetime import date
from typing import Optional, Union
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
InstantaneousWwhrs,
MainHeatingDetail,
PhotovoltaicArray,
PhotovoltaicSupply,
PhotovoltaicSupplyNoneOrNoDetails,
PvBatteries,
PvBattery,
RenewableHeatIncentive,
SapBuildingPart,
SapEnergySource,
SapFloorDimension,
SapHeating,
SapRoofWindow,
SapRoomInRoof,
SapVentilation,
SapWindow,
WindTurbineDetails,
WindowTransmissionDetails,
)
def make_pv_array(
*,
peak_power: float = 2.0,
pitch: int = 2,
orientation: int = 5,
overshading: int = 1,
) -> PhotovoltaicArray:
"""Build a PhotovoltaicArray with SAP10 defaults (2 kW, S-facing)."""
return PhotovoltaicArray(
peak_power=peak_power,
pitch=pitch,
orientation=orientation,
overshading=overshading,
)
def make_main_heating_detail(
*,
main_fuel_type: Union[int, str] = 26, # mains gas (not community)
heat_emitter_type: Union[int, str] = 1,
main_heating_control: Union[int, str] = 2106,
emitter_temperature: Union[int, str] = 1,
main_heating_category: Optional[int] = 2,
has_fghrs: bool = False,
fan_flue_present: Optional[bool] = True,
boiler_flue_type: Optional[int] = 2,
central_heating_pump_age: Optional[int] = 0,
main_heating_number: Optional[int] = 1,
main_heating_index_number: Optional[int] = None,
main_heating_data_source: Optional[int] = None,
sap_main_heating_code: Optional[int] = None,
) -> MainHeatingDetail:
"""Build a MainHeatingDetail with SAP10 API defaults (mains gas boiler).
Pass `main_heating_index_number` to point at a PCDB record (typical
cert convention is `main_heating_data_source=1` + a PCDB pointer for
PCDB-listed systems; `main_heating_data_source=2` + `sap_main_heating_
code` for Table 4b-lodged systems)."""
return MainHeatingDetail(
has_fghrs=has_fghrs,
main_fuel_type=main_fuel_type,
heat_emitter_type=heat_emitter_type,
emitter_temperature=emitter_temperature,
main_heating_control=main_heating_control,
fan_flue_present=fan_flue_present,
boiler_flue_type=boiler_flue_type,
central_heating_pump_age=central_heating_pump_age,
main_heating_number=main_heating_number,
main_heating_category=main_heating_category,
main_heating_index_number=main_heating_index_number,
main_heating_data_source=main_heating_data_source,
sap_main_heating_code=sap_main_heating_code,
)
def make_sap_heating(
*,
main_heating_details: Optional[list[MainHeatingDetail]] = None,
has_fixed_air_conditioning: bool = False,
water_heating_code: Optional[int] = 901,
water_heating_fuel: Optional[int] = 26,
cylinder_size: Optional[Union[int, str]] = None,
cylinder_insulation_type: Optional[int] = None,
cylinder_insulation_thickness_mm: Optional[int] = None,
cylinder_thermostat: Optional[str] = None,
secondary_fuel_type: Optional[int] = None,
secondary_heating_type: Optional[int] = None,
number_baths: Optional[int] = None,
electric_shower_count: Optional[int] = None,
mixer_shower_count: Optional[int] = None,
) -> SapHeating:
"""Build a SapHeating with SAP10 API defaults."""
return SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=main_heating_details
if main_heating_details is not None
else [make_main_heating_detail()],
has_fixed_air_conditioning=has_fixed_air_conditioning,
water_heating_code=water_heating_code,
water_heating_fuel=water_heating_fuel,
cylinder_size=cylinder_size,
cylinder_insulation_type=cylinder_insulation_type,
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm,
cylinder_thermostat=cylinder_thermostat,
secondary_fuel_type=secondary_fuel_type,
secondary_heating_type=secondary_heating_type,
number_baths=number_baths,
electric_shower_count=electric_shower_count,
mixer_shower_count=mixer_shower_count,
)
def make_floor_dimension(
*,
total_floor_area_m2: float = 50.0,
room_height_m: float = 2.5,
party_wall_length_m: float = 5.0,
heat_loss_perimeter_m: float = 20.0,
floor: Optional[int] = 0,
) -> SapFloorDimension:
"""Build a SapFloorDimension with sensible defaults."""
return SapFloorDimension(
room_height_m=room_height_m,
total_floor_area_m2=total_floor_area_m2,
party_wall_length_m=party_wall_length_m,
heat_loss_perimeter_m=heat_loss_perimeter_m,
floor=floor,
)
def make_building_part(
*,
identifier: BuildingPartIdentifier = BuildingPartIdentifier.MAIN,
construction_age_band: str = "B",
wall_construction: Union[int, str] = 3,
wall_insulation_type: Union[int, str] = 2,
wall_thickness_measured: bool = True,
party_wall_construction: Union[int, str] = 1,
roof_construction: Optional[int] = 4,
floor_dimensions: Optional[list[SapFloorDimension]] = None,
sap_room_in_roof: Optional[SapRoomInRoof] = None,
) -> SapBuildingPart:
"""Build a SapBuildingPart with sensible SAP10 defaults."""
return SapBuildingPart(
identifier=identifier,
construction_age_band=construction_age_band,
wall_construction=wall_construction,
wall_insulation_type=wall_insulation_type,
wall_thickness_measured=wall_thickness_measured,
party_wall_construction=party_wall_construction,
roof_construction=roof_construction,
sap_floor_dimensions=floor_dimensions
if floor_dimensions is not None
else [make_floor_dimension()],
sap_room_in_roof=sap_room_in_roof,
)
def make_window(
*,
orientation: Union[int, str] = 5, # SAP10: 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW
width: float = 1.0,
height: float = 1.0,
draught_proofed: bool = True,
glazing_type: Union[int, str] = 2, # "double glazing 2002-2022"
glazing_gap: Union[int, str] = "16+",
window_type: Union[int, str] = 1,
window_location: Union[int, str] = 0,
window_wall_type: Union[int, str] = 1,
permanent_shutters_present: Union[bool, str] = False,
frame_material: Optional[str] = "PVC",
frame_factor: Optional[float] = 0.7, # SAP10.2 Table 6c PVC default;
# mirrors the Elmhurst mapper's
# surfaced value from Summary §11.
window_transmission_details: Optional[WindowTransmissionDetails] = None,
solar_transmittance: Optional[float] = None,
u_value: float = 2.8,
) -> SapWindow:
"""Build a SapWindow with SAP10 defaults; override the fields the test cares about.
`solar_transmittance` is a shortcut that builds a
`WindowTransmissionDetails(u_value, data_source=1, solar_transmittance)`
when `window_transmission_details` isn't supplied directly — keeps
Elmhurst §6 fixture literals tight.
"""
if window_transmission_details is None and solar_transmittance is not None:
window_transmission_details = WindowTransmissionDetails(
u_value=u_value, data_source=1, solar_transmittance=solar_transmittance,
)
return SapWindow(
frame_material=frame_material,
glazing_gap=glazing_gap,
orientation=orientation,
window_type=window_type,
glazing_type=glazing_type,
window_width=width,
window_height=height,
draught_proofed=draught_proofed,
window_location=window_location,
window_wall_type=window_wall_type,
permanent_shutters_present=permanent_shutters_present,
frame_factor=frame_factor,
window_transmission_details=window_transmission_details,
)
def make_minimal_sap10_epc(
*,
energy_rating_current: Optional[int] = None,
co2_emissions_current: Optional[float] = None,
energy_consumption_current: Optional[int] = None,
space_heating_kwh: float = 0.0,
water_heating_kwh: float = 0.0,
total_floor_area_m2: float = 70.0,
door_count: int = 0,
habitable_rooms_count: int = 0,
heated_rooms_count: int = 0,
wet_rooms_count: int = 0,
extensions_count: int = 0,
open_chimneys_count: int = 0,
insulated_door_count: int = 0,
cfl_fixed_lighting_bulbs_count: int = 0,
led_fixed_lighting_bulbs_count: int = 0,
incandescent_fixed_lighting_bulbs_count: int = 0,
low_energy_fixed_lighting_bulbs_count: Optional[int] = None,
solar_water_heating: bool = False,
has_hot_water_cylinder: bool = False,
has_fixed_air_conditioning: bool = False,
percent_draughtproofed: Optional[int] = None,
energy_rating_average: Optional[int] = None,
environmental_impact_current: Optional[int] = None,
dwelling_type: str = "Mid-terrace house",
tenure: str = "1",
transaction_type: str = "1",
property_type: Optional[str] = None,
built_form: Optional[str] = None,
region_code: Optional[str] = None,
country_code: Optional[str] = None,
sap_windows: Optional[list[SapWindow]] = None,
sap_roof_windows: Optional[list[SapRoofWindow]] = None,
sap_building_parts: Optional[list[SapBuildingPart]] = None,
sap_heating: Optional[SapHeating] = None,
photovoltaic_arrays: Optional[list[PhotovoltaicArray]] = None,
photovoltaic_supply_percent_roof_area: Optional[int] = None,
mains_gas: bool = True,
electricity_smart_meter_present: bool = False,
gas_smart_meter_present: bool = False,
is_dwelling_export_capable: bool = False,
pv_battery_count: int = 0,
pv_battery_capacity_per_unit_kwh: Optional[float] = None,
wind_turbines_count: int = 0,
mechanical_ventilation: Optional[int] = None,
mechanical_vent_duct_type: Optional[int] = None,
blocked_chimneys_count: Optional[int] = None,
pressure_test: Optional[int] = None,
sap_ventilation: Optional[SapVentilation] = None,
postcode: str = "A1 1AA",
) -> EpcPropertyData:
"""Construct a minimal valid SAP10 EpcPropertyData with parametrisable targets."""
return EpcPropertyData(
dwelling_type=dwelling_type,
inspection_date=date(2025, 6, 1),
tenure=tenure,
transaction_type=transaction_type,
address_line_1="1 Test Street",
postcode=postcode,
post_town="Testtown",
roofs=[],
walls=[],
floors=[],
main_heating=[],
door_count=door_count,
sap_heating=sap_heating if sap_heating is not None else SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=[],
has_fixed_air_conditioning=False,
),
sap_windows=list(sap_windows) if sap_windows is not None else [],
sap_roof_windows=(
list(sap_roof_windows) if sap_roof_windows is not None else None
),
sap_energy_source=SapEnergySource(
mains_gas=mains_gas,
meter_type="Single",
pv_battery_count=pv_battery_count,
wind_turbines_count=wind_turbines_count,
gas_smart_meter_present=gas_smart_meter_present,
is_dwelling_export_capable=is_dwelling_export_capable,
wind_turbines_terrain_type="Suburban",
electricity_smart_meter_present=electricity_smart_meter_present,
photovoltaic_arrays=list(photovoltaic_arrays)
if photovoltaic_arrays is not None
else None,
photovoltaic_supply=(
PhotovoltaicSupply(
none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails(
percent_roof_area=photovoltaic_supply_percent_roof_area
)
)
if photovoltaic_supply_percent_roof_area is not None
else None
),
pv_batteries=(
PvBatteries(
pv_battery=PvBattery(
battery_capacity=pv_battery_capacity_per_unit_kwh
)
)
if pv_battery_capacity_per_unit_kwh is not None
else None
),
wind_turbine_details=(
WindTurbineDetails(hub_height=5.0, rotor_diameter=2.0)
if wind_turbines_count > 0
else None
),
),
sap_building_parts=list(sap_building_parts) if sap_building_parts is not None else [],
solar_water_heating=solar_water_heating,
has_hot_water_cylinder=has_hot_water_cylinder,
has_fixed_air_conditioning=has_fixed_air_conditioning,
wet_rooms_count=wet_rooms_count,
extensions_count=extensions_count,
heated_rooms_count=heated_rooms_count,
open_chimneys_count=open_chimneys_count,
habitable_rooms_count=habitable_rooms_count,
insulated_door_count=insulated_door_count,
cfl_fixed_lighting_bulbs_count=cfl_fixed_lighting_bulbs_count,
led_fixed_lighting_bulbs_count=led_fixed_lighting_bulbs_count,
incandescent_fixed_lighting_bulbs_count=incandescent_fixed_lighting_bulbs_count,
low_energy_fixed_lighting_bulbs_count=low_energy_fixed_lighting_bulbs_count,
total_floor_area_m2=total_floor_area_m2,
sap_version=10.2,
energy_rating_current=energy_rating_current,
co2_emissions_current=co2_emissions_current,
energy_consumption_current=energy_consumption_current,
percent_draughtproofed=percent_draughtproofed,
energy_rating_average=energy_rating_average,
environmental_impact_current=environmental_impact_current,
property_type=property_type,
built_form=built_form,
region_code=region_code,
country_code=country_code,
mechanical_ventilation=mechanical_ventilation,
mechanical_vent_duct_type=mechanical_vent_duct_type,
blocked_chimneys_count=blocked_chimneys_count,
pressure_test=pressure_test,
sap_ventilation=sap_ventilation,
renewable_heat_incentive=RenewableHeatIncentive(
space_heating_kwh=space_heating_kwh,
water_heating_kwh=water_heating_kwh,
),
)