Collapse full-SAP roof-window openings onto sap_roof_windows 🟩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-15 13:46:32 +00:00
parent 36929accf7
commit dde98fb684
4 changed files with 433 additions and 181 deletions

View file

@ -51,9 +51,12 @@ from datatypes.epc.schema.sap_schema_17_1 import (
# full-SAP opening-type codes: 1/2/3 = door, 4 = window, 5 = roof window.
_SAP_OPENING_TYPE_WINDOW: Final[int] = 4
_SAP_OPENING_TYPE_ROOF_WINDOW: Final[int] = 5
_SAP_OPENING_TYPE_DOORS: Final[frozenset[int]] = frozenset({1, 2, 3})
# SAP-typical glazing solar transmittance when an opening-type omits it.
_SAP_DEFAULT_SOLAR_TRANSMITTANCE: Final[float] = 0.63
# SAP-typical window frame factor when an opening-type omits it.
_SAP_DEFAULT_FRAME_FACTOR: Final[float] = 0.70
from datatypes.epc.schema.rdsap_schema_18_0 import (
RdSapSchema18_0,
EnergyElement as EnergyElement_18_0,
@ -676,8 +679,10 @@ class EpcPropertyDataMapper:
walls=EpcPropertyDataMapper._map_energy_elements(schema.walls),
floors=EpcPropertyDataMapper._map_energy_elements(schema.floors),
main_heating=[],
# D2: vertical-window openings (opening-type 4) → sap_windows.
# D2: vertical-window openings (opening-type 4) → sap_windows;
# roof-window openings (opening-type 5) → sap_roof_windows.
sap_windows=EpcPropertyDataMapper._sap_17_1_windows(schema),
sap_roof_windows=EpcPropertyDataMapper._sap_17_1_roof_windows(schema),
sap_building_parts=[],
sap_heating=SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
@ -739,6 +744,46 @@ class EpcPropertyDataMapper:
)
return windows
@staticmethod
def _sap_17_1_roof_windows(schema: SapSchema17_1) -> List[SapRoofWindow]:
"""D2: collapse roof-window openings (opening-type 5) onto the engine's
`SapRoofWindow` model pitched roof glazing driving the §6 solar /
§5 daylight cascades, distinct from vertical `sap_windows`. Area is the
measured width × height; the joined opening-type carries U, frame factor
and solar transmittance (g)."""
types: Dict[Union[str, int], SapOpeningType_SAP_17_1] = {
ot.name: ot for ot in schema.sap_opening_types
}
roof_windows: List[SapRoofWindow] = []
for bp in schema.sap_building_parts:
for op in bp.sap_openings:
ot = types.get(op.type)
if ot is None or ot.type != _SAP_OPENING_TYPE_ROOF_WINDOW:
continue
roof_windows.append(
SapRoofWindow(
area_m2=float(op.width) * float(op.height),
u_value_raw=ot.u_value,
orientation=op.orientation if op.orientation is not None else 0,
# default 45° pitch when unlodged — matches the
# API roof-window path's inclination convention.
pitch_deg=float(op.pitch) if op.pitch is not None else 45.0,
g_perpendicular=(
ot.solar_transmittance
if ot.solar_transmittance is not None
else _SAP_DEFAULT_SOLAR_TRANSMITTANCE
),
frame_factor=(
ot.frame_factor
if ot.frame_factor is not None
else _SAP_DEFAULT_FRAME_FACTOR
),
glazing_type=ot.glazing_type,
window_location=op.location if op.location is not None else "",
)
)
return roof_windows
@staticmethod
def from_rdsap_schema_17_1(schema: RdSapSchema17_1) -> EpcPropertyData:
es = schema.sap_energy_source

View file

@ -165,3 +165,30 @@ class TestFromSapSchema17_1Doors:
result = self._map("sap_17_1_flat.json")
assert result.door_count == 2
assert result.insulated_door_u_value == pytest.approx(1.6114713, abs=1e-6)
class TestFromSapSchema17_1RoofWindows:
"""Slice 4c (D2): roof-window openings (opening-type 5) collapse onto
sap_roof_windows (pitched roof glazing for the §6 solar / §5 daylight
cascades), distinct from vertical sap_windows."""
def _map(self, fixture: str) -> EpcPropertyData:
schema = from_dict(SapSchema17_1, load(fixture))
return EpcPropertyDataMapper.from_sap_schema_17_1(schema)
def test_house_maps_both_roof_windows(self) -> None:
result = self._map("sap_17_1_house.json")
assert result.sap_roof_windows is not None
assert len(result.sap_roof_windows) == 2
def test_roof_window_area_and_u(self) -> None:
result = self._map("sap_17_1_house.json")
assert result.sap_roof_windows is not None
rw = result.sap_roof_windows[0]
assert rw.area_m2 == pytest.approx(0.99 * 0.73, abs=1e-6)
assert rw.u_value_raw == 1.2
def test_flat_has_no_roof_windows(self) -> None:
# A ground-floor flat lodges no roof windows.
result = self._map("sap_17_1.json")
assert not result.sap_roof_windows

View file

@ -42,6 +42,8 @@ class SapOpening:
height: Union[int, float]
location: Optional[str] = None
orientation: Optional[int] = None
# roof-window openings (opening-type 5) lodge a roof pitch.
pitch: Optional[Union[int, float]] = None
@dataclass

View file

@ -1,24 +1,24 @@
{
"uprn": 10090592989,
"uprn": 12191803,
"roofs": [
{
"description": "Average thermal transmittance 0.14 W/m\u00b2K",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
"description": "Average thermal transmittance 0.17 W/m\u00b2K",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"walls": [
{
"description": "Average thermal transmittance 0.27 W/m\u00b2K",
"description": "Average thermal transmittance 0.15 W/m\u00b2K",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
}
],
"floors": [
{
"description": "Average thermal transmittance 0.22 W/m\u00b2K",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
"description": "Average thermal transmittance 0.12 W/m\u00b2K",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
}
],
"status": "entered",
@ -33,24 +33,25 @@
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
"postcode": "DN11 8FB",
"postcode": "W3 0BH",
"data_type": 2,
"hot_water": {
"description": "From main system",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"post_town": "DONCASTER",
"built_form": 2,
"created_at": "2019-07-29 10:22:30",
"living_area": 14.54,
"orientation": 4,
"region_code": 3,
"post_town": "LONDON",
"built_form": 4,
"created_at": "2019-01-22 09:38:33",
"living_area": 15.19,
"orientation": 2,
"region_code": 17,
"report_type": 3,
"sap_heating": {
"thermal_store": 1,
"water_fuel_type": 1,
"water_heating_code": 901,
"hot_water_store_size": 210,
"main_heating_details": [
{
"main_fuel_type": 1,
@ -65,15 +66,21 @@
"main_heating_flue_type": 2,
"central_heating_pump_age": 2,
"main_heating_data_source": 1,
"main_heating_index_number": 17045,
"main_heating_index_number": 18214,
"has_separate_delayed_start": "true",
"load_or_weather_compensation": 0,
"is_central_heating_pump_in_heated_space": "true"
}
],
"has_hot_water_cylinder": "false",
"has_hot_water_cylinder": "true",
"has_cylinder_thermostat": "true",
"hot_water_store_heat_loss": 1.42,
"has_fixed_air_conditioning": "false",
"secondary_heating_category": 1
"secondary_heating_category": 1,
"is_cylinder_in_heated_space": "true",
"primary_pipework_insulation": 4,
"is_hot_water_separately_timed": "true",
"hot_water_store_heat_loss_source": 2
},
"sap_version": 9.92,
"schema_type": "SAP-Schema-17.1",
@ -87,68 +94,82 @@
}
],
"air_tightness": {
"description": "Air permeability 7.5 m\u00b3/h.m\u00b2 (assessed average)",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
"description": "Air permeability 4.5 m\u00b3/h.m\u00b2 (as tested)",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"dwelling_type": "Semi-detached house",
"dwelling_type": "Mid-terrace house",
"language_code": 1,
"property_type": 0,
"address_line_1": "29, Avalon Gardens",
"address_line_2": "Harworth",
"assessment_date": "2019-07-29",
"address_line_1": "10, Grieg Road",
"assessment_date": "2019-01-22",
"assessment_type": "SAP",
"completion_date": "2019-07-29",
"inspection_date": "2019-07-29",
"completion_date": "2019-01-22",
"inspection_date": "2019-01-22",
"sap_ventilation": {
"psv_count": 0,
"pressure_test": 5,
"air_permeability": 5.47,
"pressure_test": 1,
"wet_rooms_count": 3,
"air_permeability": 4.53,
"open_flues_count": 0,
"ventilation_type": 1,
"extract_fans_count": 4,
"ventilation_type": 8,
"extract_fans_count": 0,
"open_fireplaces_count": 0,
"sheltered_sides_count": 2,
"flueless_gas_fires_count": 0
"flueless_gas_fires_count": 0,
"mechanical_vent_duct_type": 2,
"mechanical_vent_duct_insulation": 2,
"mechanical_ventilation_data_source": 1,
"mechanical_vent_system_index_number": 500250,
"is_mechanical_vent_approved_installer_scheme": "true"
},
"design_water_use": 1,
"sap_data_version": 9.9,
"total_floor_area": 82,
"sap_data_version": 9.92,
"total_floor_area": 114,
"transaction_type": 6,
"conservatory_type": 1,
"registration_date": "2019-07-29",
"registration_date": "2019-01-22",
"sap_energy_source": {
"pv_arrays": [
{
"pitch": 3,
"peak_power": 1.62,
"orientation": 6,
"overshading": 1,
"pv_connection": 2
}
],
"electricity_tariff": 1,
"wind_turbines_count": 0,
"wind_turbine_terrain_type": 1,
"fixed_lighting_outlets_count": 11,
"low_energy_fixed_lighting_outlets_count": 11,
"wind_turbine_terrain_type": 2,
"fixed_lighting_outlets_count": 10,
"low_energy_fixed_lighting_outlets_count": 10,
"low_energy_fixed_lighting_outlets_percentage": 100
},
"sap_opening_types": [
{
"name": "Door",
"name": "Solid Door",
"type": 1,
"u_value": 1.1,
"u_value": 1.2,
"data_source": 2,
"glazing_type": 1
},
{
"name": "Windows",
"name": "Window",
"type": 4,
"u_value": 1.35,
"u_value": 1.2,
"data_source": 2,
"frame_factor": 0.7,
"glazing_type": 7,
"glazing_type": 6,
"solar_transmittance": 0.63
},
{
"name": "Rooflights",
"name": "Roof Window",
"type": 5,
"u_value": 1.35,
"u_value": 1.2,
"data_source": 2,
"frame_factor": 0.7,
"glazing_type": 7,
"glazing_type": 6,
"solar_transmittance": 0.63
}
],
@ -157,175 +178,341 @@
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"lzc_energy_sources": [
11
],
"sap_building_parts": [
{
"sap_roofs": [
{
"name": "Roof 1",
"u_value": 0.14,
"u_value": 0.12,
"roof_type": 2,
"description": "Cold Roof",
"kappa_value": 0,
"total_roof_area": 45.87
"description": "Pitched (insulated joists)",
"total_roof_area": 15.48
},
{
"name": "Roof 2",
"u_value": 0.11,
"roof_type": 2,
"description": "Flat Roof",
"total_roof_area": 2.2
},
{
"name": "Roof 3",
"u_value": 0.19,
"roof_type": 2,
"description": "Pitched (insulated rafter)",
"total_roof_area": 45.58
},
{
"name": "Roof 4",
"u_value": 0.24,
"roof_type": 2,
"description": "Lower Pitched Roof",
"total_roof_area": 1.77
},
{
"name": "Party roof 1",
"u_value": 0,
"roof_type": 4,
"description": "Ground Ceiling",
"total_roof_area": 43.28
},
{
"name": "Party roof 2",
"u_value": 0,
"roof_type": 4,
"description": "First Ceiling",
"total_roof_area": 43.28
}
],
"sap_walls": [
{
"name": "External Wall 1",
"u_value": 0.26,
"u_value": 0.15,
"wall_type": 2,
"description": "External Wall",
"kappa_value": 0,
"total_wall_area": 91.31,
"is_curtain_walling": "false"
},
{
"name": "External Wall 2",
"u_value": 0.4,
"wall_type": 3,
"description": "Garage Wall",
"kappa_value": 0,
"total_wall_area": 17.02,
"total_wall_area": 87.85,
"is_curtain_walling": "false"
},
{
"name": "Party Wall 0",
"u_value": 0,
"wall_type": 4,
"kappa_value": 45,
"total_wall_area": 29.56
},
{
"name": "Internal Wall 0",
"u_value": 0,
"wall_type": 5,
"kappa_value": 9,
"total_wall_area": 55.0346
},
{
"name": "Internal Wall 0",
"u_value": 0,
"wall_type": 5,
"kappa_value": 9,
"total_wall_area": 97.359
"total_wall_area": 105.44
}
],
"identifier": "Main Dwelling",
"overshading": 2,
"sap_openings": [
{
"name": "Front Door",
"type": "Door",
"width": 1.95,
"height": 1,
"name": "Bike Store",
"type": "Solid Door",
"width": 0.91,
"height": 2.11,
"location": "External Wall 1",
"orientation": 0
},
{
"name": "Front Windows",
"type": "Windows",
"width": 5.33,
"height": 1,
"name": "Front Door",
"type": "Solid Door",
"width": 1.02,
"height": 2.11,
"location": "External Wall 1",
"orientation": 4
"orientation": 0
},
{
"name": "RH Windows",
"type": "Windows",
"width": 0.66,
"height": 1,
"name": "Kitchen",
"type": "Window",
"width": 1.47,
"height": 1.36,
"location": "External Wall 1",
"orientation": 2
},
{
"name": "Rear Windows",
"type": "Windows",
"width": 7.92,
"height": 1,
"name": "Kitchen Door",
"type": "Window",
"width": 1.02,
"height": 2.19,
"location": "External Wall 1",
"orientation": 6
},
{
"name": "Living Room",
"type": "Window",
"width": 1.36,
"height": 1.14,
"location": "External Wall 1",
"orientation": 8
},
{
"name": "Living Room",
"type": "Window",
"width": 0.69,
"height": 2.19,
"location": "External Wall 1",
"orientation": 8
},
{
"name": "Living Room",
"type": "Window",
"width": 2.04,
"height": 2.19,
"location": "External Wall 1",
"orientation": 6
},
{
"name": "Bed 3",
"type": "Window",
"width": 1.47,
"height": 1.14,
"location": "External Wall 1",
"orientation": 2
},
{
"name": "Bed 3",
"type": "Window",
"width": 0.69,
"height": 2.19,
"location": "External Wall 1",
"orientation": 2
},
{
"name": "Bathroom",
"type": "Window",
"width": 0.69,
"height": 1.14,
"location": "External Wall 1",
"orientation": 6
},
{
"name": "Bed 2",
"type": "Window",
"width": 0.69,
"height": 1.14,
"location": "External Wall 1",
"orientation": 8
},
{
"name": "Bed 1",
"type": "Window",
"width": 0.69,
"height": 1.89,
"location": "External Wall 1",
"orientation": 6
},
{
"name": "Bed 1 Roof",
"type": "Roof Window",
"pitch": 45,
"width": 0.99,
"height": 0.73,
"location": "Roof 3",
"orientation": 2
},
{
"name": "Landing",
"type": "Roof Window",
"pitch": 40,
"width": 0.99,
"height": 0.73,
"location": "Roof 3",
"orientation": 6
}
],
"construction_year": 2015,
"construction_year": 2019,
"sap_thermal_bridges": {
"thermal_bridges": [
{
"length": 11.19,
"psi_value": 0.395,
"psi_value_source": 3,
"thermal_bridge_type": "E1"
"length": 12.74,
"psi_value": 0.3,
"psi_value_source": 2,
"thermal_bridge_type": "E2"
},
{
"length": 8.46,
"psi_value": 0.028,
"psi_value_source": 3,
"length": 10.81,
"psi_value": 0.04,
"psi_value_source": 2,
"thermal_bridge_type": "E3"
},
{
"length": 27,
"psi_value": 0.035,
"psi_value_source": 3,
"length": 41.58,
"psi_value": 0.05,
"psi_value_source": 2,
"thermal_bridge_type": "E4"
},
{
"length": 25.51,
"psi_value": 0.061,
"psi_value_source": 3,
"length": 17.75,
"psi_value": 0.16,
"psi_value_source": 2,
"thermal_bridge_type": "E5"
},
{
"length": 22.28,
"length": 2.7,
"psi_value": 0.32,
"psi_value_source": 4,
"thermal_bridge_type": "E20"
},
{
"length": 3.6,
"psi_value": 0.32,
"psi_value_source": 4,
"thermal_bridge_type": "E21"
},
{
"length": 10.1,
"psi_value": 0.07,
"psi_value_source": 2,
"thermal_bridge_type": "E6"
},
{
"length": 12.86,
"psi_value": 0.098,
"psi_value_source": 3,
"length": 2.8,
"psi_value": 0.06,
"psi_value_source": 2,
"thermal_bridge_type": "E10"
},
{
"length": 10.76,
"psi_value": 0.069,
"psi_value_source": 3,
"thermal_bridge_type": "E12"
"length": 5.6,
"psi_value": 0.24,
"psi_value_source": 4,
"thermal_bridge_type": "E24"
},
{
"length": 14.4,
"psi_value": 0.062,
"length": 9.09,
"psi_value": 0.04,
"psi_value_source": 2,
"thermal_bridge_type": "E11"
},
{
"length": 4.37,
"psi_value": 0.28,
"psi_value_source": 3,
"thermal_bridge_type": "E15"
},
{
"length": 10.5,
"psi_value": 0.09,
"psi_value_source": 2,
"thermal_bridge_type": "E16"
},
{
"length": 4.65,
"psi_value": -0.078,
"psi_value_source": 3,
"length": 10.5,
"psi_value": -0.09,
"psi_value_source": 2,
"thermal_bridge_type": "E17"
},
{
"length": 9.75,
"psi_value": 0.076,
"psi_value_source": 3,
"length": 22,
"psi_value": 0.06,
"psi_value_source": 2,
"thermal_bridge_type": "E18"
},
{
"length": 4.46,
"psi_value": 0.043,
"psi_value_source": 3,
"length": 16.06,
"psi_value": 0.16,
"psi_value_source": 4,
"thermal_bridge_type": "P1"
},
{
"length": 4.46,
"length": 23.01,
"psi_value": 0,
"psi_value_source": 4,
"thermal_bridge_type": "P2"
},
{
"length": 7.52,
"psi_value": 0.055,
"length": 16.23,
"psi_value": 0,
"psi_value_source": 4,
"thermal_bridge_type": "P3"
},
{
"length": 0.9,
"psi_value": 0.16,
"psi_value_source": 4,
"thermal_bridge_type": "P7"
},
{
"length": 5.62,
"psi_value": 0.12,
"psi_value_source": 3,
"thermal_bridge_type": "P4"
},
{
"length": 20.68,
"psi_value": 0.08,
"psi_value_source": 3,
"thermal_bridge_type": "P5"
},
{
"length": 1.98,
"psi_value": 0.08,
"psi_value_source": 4,
"thermal_bridge_type": "R1"
},
{
"length": 1.98,
"psi_value": 0.06,
"psi_value_source": 4,
"thermal_bridge_type": "R2"
},
{
"length": 2.92,
"psi_value": 0.08,
"psi_value_source": 4,
"thermal_bridge_type": "R3"
},
{
"length": 11,
"psi_value": 0.06,
"psi_value_source": 4,
"thermal_bridge_type": "R6"
},
{
"length": 11,
"psi_value": 0.06,
"psi_value_source": 4,
"thermal_bridge_type": "R8"
}
],
"thermal_bridge_code": 5
@ -334,37 +521,43 @@
"sap_floor_dimensions": [
{
"storey": 0,
"u_value": 0.22,
"u_value": 0.12,
"floor_type": 2,
"description": "Ground Floor",
"kappa_value": 0,
"storey_height": 2.33,
"heat_loss_area": 37.78,
"total_floor_area": 37.78,
"kappa_value_from_below": 9
"storey_height": 2.5,
"heat_loss_area": 47.26,
"total_floor_area": 47.26
},
{
"storey": 1,
"u_value": 0.21,
"u_value": 0.12,
"floor_type": 3,
"description": "Floor Above Garage",
"kappa_value": 18,
"storey_height": 2.55,
"heat_loss_area": 8.08,
"total_floor_area": 43.99
"description": "Upper Floor",
"storey_height": 3,
"heat_loss_area": 2.43,
"total_floor_area": 43.28
},
{
"storey": 2,
"u_value": 0,
"floor_type": 3,
"storey_height": 2.5,
"heat_loss_area": 0,
"total_floor_area": 23.39
}
]
],
"thermal_mass_parameter": 250
}
],
"heating_cost_current": {
"value": 241,
"value": 225,
"currency": "GBP"
},
"co2_emissions_current": 1.4,
"co2_emissions_current": 0.7,
"energy_rating_average": 60,
"energy_rating_current": 83,
"energy_rating_current": 91,
"lighting_cost_current": {
"value": 64,
"value": 76,
"currency": "GBP"
},
"main_heating_controls": [
@ -374,20 +567,20 @@
"environmental_efficiency_rating": 5
}
],
"has_hot_water_cylinder": "false",
"has_hot_water_cylinder": "true",
"heating_cost_potential": {
"value": 241,
"value": 226,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 79,
"value": 101,
"currency": "GBP"
},
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": {
"value": 29,
"value": 44,
"currency": "GBP"
},
"indicative_cost": "\u00a34,000 - \u00a36,000",
@ -396,53 +589,38 @@
"improvement_number": 19
},
"improvement_category": 5,
"energy_performance_rating": 84,
"environmental_impact_rating": 87
},
{
"sequence": 2,
"typical_saving": {
"value": 294,
"currency": "GBP"
},
"indicative_cost": "\u00a33,500 - \u00a35,500",
"improvement_type": "U",
"improvement_details": {
"improvement_number": 34
},
"improvement_category": 5,
"energy_performance_rating": 94,
"environmental_impact_rating": 96
"energy_performance_rating": 92,
"environmental_impact_rating": 94
}
],
"co2_emissions_potential": 0.4,
"energy_rating_potential": 94,
"energy_rating_potential": 92,
"lighting_cost_potential": {
"value": 64,
"value": 76,
"currency": "GBP"
},
"schema_version_original": "LIG-17.0",
"hot_water_cost_potential": {
"value": 50,
"value": 56,
"currency": "GBP"
},
"is_in_smoke_control_area": "unknown",
"renewable_heat_incentive": {
"rhi_new_dwelling": {
"space_heating": 3202,
"water_heating": 1776
"space_heating": 1882,
"water_heating": 2152
}
},
"seller_commission_report": "Y",
"energy_consumption_current": 100,
"energy_consumption_current": 35,
"has_fixed_air_conditioning": "false",
"multiple_glazed_percentage": 100,
"calculation_software_version": "4.04r04",
"energy_consumption_potential": 24,
"environmental_impact_current": 85,
"calculation_software_version": "4.08r12",
"energy_consumption_potential": 22,
"environmental_impact_current": 92,
"current_energy_efficiency_band": "B",
"environmental_impact_potential": 96,
"environmental_impact_potential": 94,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "A",
"co2_emissions_current_per_floor_area": 17
"co2_emissions_current_per_floor_area": 6
}