mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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.
369 lines
14 KiB
Python
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,
|
|
),
|
|
)
|