"""Envelope heat-loss (W/K) summed across all building parts. Computes Sigma(U * A) + y * A_exposed over the main dwelling and every extension on a cert. U-values come from the cascade-defaulting helpers in `rdsap_uvalues`; geometry is read off `sap_building_parts` + the cert's pre-aggregated window area and door count. Used by `transform.py` to populate the `envelope_heat_loss_w_per_k` feature in v16.x. See ADR-0008 for the physics-as-feature rationale. """ from __future__ import annotations from typing import Any, Optional from datatypes.epc.domain.epc_property_data import SapBuildingPart from domain.sap10_ml.rdsap_uvalues import ( Country, WALL_CAVITY, WALL_UNKNOWN, thermal_bridging_y, u_door, u_floor, u_party_wall, u_roof, u_wall, u_window, ) # SAP10 wall_insulation_type code 4 ("None") marks no insulation declared. _WALL_INSULATION_NONE: int = 4 # Standard SAP10 external door area (m^2) when door dimensions aren't given. _DEFAULT_DOOR_AREA_M2: float = 1.85 def _int_or_none(value: Any) -> Optional[int]: return value if isinstance(value, int) else None def _parse_thickness_mm(value: Any) -> Optional[int]: if value is None: return None if isinstance(value, int): return value if not isinstance(value, str): return None s = value.strip() if s.upper() == "NI": return 0 digits = "" for c in s: if c.isdigit(): digits += c else: break return int(digits) if digits else None def _part_geometry(part: SapBuildingPart) -> dict[str, float]: """Sum floor area / heat-loss perimeter / party-wall length / room heights across the floor dimensions in a building part.""" if not part.sap_floor_dimensions: return { "ground_floor_area_m2": 0.0, "ground_perimeter_m": 0.0, "top_floor_area_m2": 0.0, "total_perimeter_m": 0.0, "party_wall_length_m": 0.0, "avg_room_height_m": 2.5, "storey_count": 1.0, } fds = list(part.sap_floor_dimensions) # Ground floor = floor == 0 if present, else the first entry. ground = next((fd for fd in fds if fd.floor == 0), fds[0]) # Top floor = floor with the largest non-None index, else the last entry. indexed = [(fd.floor if fd.floor is not None else 0, fd) for fd in fds] top = max(indexed, key=lambda kv: kv[0])[1] total_area = sum(fd.total_floor_area_m2 or 0.0 for fd in fds) total_perimeter = sum(fd.heat_loss_perimeter_m or 0.0 for fd in fds) party_length = sum(fd.party_wall_length_m or 0.0 for fd in fds) weighted_height = sum( (fd.total_floor_area_m2 or 0.0) * (fd.room_height_m or 2.5) for fd in fds ) avg_height = (weighted_height / total_area) if total_area > 0 else 2.5 return { "ground_floor_area_m2": ground.total_floor_area_m2 or 0.0, "ground_perimeter_m": ground.heat_loss_perimeter_m or 0.0, "top_floor_area_m2": top.total_floor_area_m2 or 0.0, "total_perimeter_m": total_perimeter, "party_wall_length_m": party_length, "avg_room_height_m": avg_height, "storey_count": float(len(fds)), } def _part_heat_loss_w_per_k( part: SapBuildingPart, country: Country, window_area_m2: float, door_area_m2: float, window_u_value: float, door_u_value: float, roof_description: Optional[str] = None, wall_description: Optional[str] = None, ) -> float: """Heat loss coefficient (W/K) for a single building part: walls + roof + floor + party walls + windows + doors + thermal bridging. The aggregate-level caller (`envelope_heat_loss_w_per_k`) apportions windows and doors to whichever part it considers primary (currently the first part); other parts pass 0 for the window/door area. """ geom = _part_geometry(part) age_band = part.construction_age_band wall_construction = _int_or_none(part.wall_construction) wall_ins_type = _int_or_none(part.wall_insulation_type) wall_ins_thickness = _parse_thickness_mm(part.wall_insulation_thickness) wall_ins_present = wall_ins_type is not None and wall_ins_type != _WALL_INSULATION_NONE party_construction = _int_or_none(part.party_wall_construction) roof_thickness = _parse_thickness_mm(getattr(part, "roof_insulation_thickness", None)) floor_ins_thickness = _parse_thickness_mm(getattr(part, "floor_insulation_thickness", None)) # Floor — pick the ground-floor's floor_dimension for the BS EN ISO 13370 # area/perimeter inputs. ground_fd = next( (fd for fd in part.sap_floor_dimensions if fd.floor == 0), part.sap_floor_dimensions[0] if part.sap_floor_dimensions else None, ) floor_area = ground_fd.total_floor_area_m2 if ground_fd is not None else None floor_perimeter = ground_fd.heat_loss_perimeter_m if ground_fd is not None else None floor_construction = ( _int_or_none(ground_fd.floor_construction) if ground_fd is not None else None ) uw = u_wall( country=country, age_band=age_band, construction=wall_construction if wall_construction != WALL_UNKNOWN else None, insulation_thickness_mm=wall_ins_thickness, insulation_present=wall_ins_present, description=wall_description, ) ur = u_roof( country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=roof_description, ) uf = u_floor( country=country, age_band=age_band, construction=floor_construction, insulation_thickness_mm=floor_ins_thickness, area_m2=floor_area, perimeter_m=floor_perimeter, wall_thickness_mm=part.wall_thickness_mm, ) upw = u_party_wall(party_wall_construction=party_construction) y = thermal_bridging_y(age_band=age_band) # Areas. storey_count = geom["storey_count"] storey_height = geom["avg_room_height_m"] # SAP10.2 wall area: gross exposed perimeter * storey height * storey count # minus openings. Heat-loss perimeter (heat_loss_perimeter_m on each floor # dimension) already excludes party walls. gross_wall_area = geom["ground_perimeter_m"] * storey_height * storey_count net_wall_area = max(0.0, gross_wall_area - window_area_m2 - door_area_m2) party_area = geom["party_wall_length_m"] * storey_height * storey_count roof_area = geom["top_floor_area_m2"] floor_area_total = geom["ground_floor_area_m2"] conduction = ( uw * net_wall_area + upw * party_area + ur * roof_area + uf * floor_area_total + window_u_value * window_area_m2 + door_u_value * door_area_m2 ) bridging_area = net_wall_area + party_area + roof_area + floor_area_total + window_area_m2 + door_area_m2 return conduction + y * bridging_area def envelope_heat_loss_w_per_k( sap_building_parts: list[SapBuildingPart], *, country_code: Optional[str], window_total_area_m2: float, window_avg_u_value: Optional[float], door_count: int, insulated_door_count: int, insulated_door_u_value: Optional[float], age_band_for_door: Optional[str] = None, roof_description: Optional[str] = None, wall_description: Optional[str] = None, ) -> float: """Total envelope heat-loss coefficient (W/K) summed over all building parts. Windows and doors are apportioned entirely to the first part (the main dwelling) per RdSAP10 convention -- the cert's window list is not split across extensions. All U-values cascade through `rdsap_uvalues` defaults, so the return is never null. `roof_description` carries the worst-case surveyor description across the top-level `roofs[i]` list (e.g. "Pitched, no insulation"). When the cert flags a roof as uninsulated, u_roof returns Table 16 0mm/12mm values instead of the optimistic Table 18 age-band default -- catastrophic heritage roofs need that correction. """ if not sap_building_parts: return 0.0 country = Country.from_code(country_code) door_area = max(0, door_count) * _DEFAULT_DOOR_AREA_M2 if window_avg_u_value is None or window_avg_u_value <= 0: window_u = u_window(installed_year=None, glazing_type=None, frame_type=None) else: window_u = window_avg_u_value # Door U: blend insulated/uninsulated by share. door_uninsulated = u_door( country=country, age_band=age_band_for_door or sap_building_parts[0].construction_age_band, insulated=False, insulated_u_value=None, ) door_insulated = ( insulated_door_u_value if insulated_door_u_value is not None else u_door(country=country, age_band="M", insulated=True, insulated_u_value=None) ) insulated_share = (insulated_door_count or 0) / door_count if door_count > 0 else 0.0 door_u = (1.0 - insulated_share) * door_uninsulated + insulated_share * door_insulated total = 0.0 for i, part in enumerate(sap_building_parts): # Windows and doors only on the first (main) part. w_area = window_total_area_m2 if i == 0 else 0.0 d_area = door_area if i == 0 else 0.0 total += _part_heat_loss_w_per_k( part=part, country=country, window_area_m2=w_area, door_area_m2=d_area, window_u_value=window_u, door_u_value=door_u, roof_description=roof_description, wall_description=wall_description, ) return total