Model/domain/sap10_ml/envelope.py
Khalim Conn-Kowlessar 68401c517a refactor: lift-and-shift packages/domain/src/domain/ml → domain/sap10_ml
Sibling migration to the sap10_calculator move — `domain.ml` now lives
at the root-level layout (`domain/sap10_ml/`) matching the pattern
already used by `domain.addresses`, `domain.tasks`, `domain.postcode`,
and `domain.sap10_calculator`.

Changes:

- `git mv packages/domain/src/domain/ml → domain/sap10_ml` (19 files;
  history preserved).
- Subpackage rename: `domain.ml` → `domain.sap10_ml`. 32 references
  rewritten across .py and .md files: 11 internal + 21 external
  (datatypes/epc/domain/mapper.py, 14 files in domain/sap10_calculator,
  2 backend tests, 2 ADRs, 1 README, 1 design doc).
- Path-string updates: `pytest.ini` testpath
  `packages/domain/src/domain/ml/tests` → `domain/sap10_ml/tests` so
  ML tests stay in the default auto-discovered sweep. `CONTEXT.md`
  also updated.

`packages/domain/src/domain/` is now empty — the workspace `domain/`
tree has been fully migrated. Together with the `domain/__init__.py`
deletions from the sap10_calculator commit (29ac35cc), `domain` is
now a single root-level namespace package with subpackages
{addresses, sap10_calculator, sap10_ml, tasks} + the standalone
`postcode.py` module.

Verified:

- Focused sweep (backend mapper-chain + sap10_calculator worksheet
  e2e + golden fixtures): 99 passed / 19 failed — identical baseline.
- Wider sweep (all sap10_calculator + sap10_ml): 1654 passed / 20
  failed (same pre-existing failures).
- domain/sap10_ml/tests: 210/210 PASSED at new path.
- Pyright net-zero: heat_transmission.py 13, cert_to_inputs.py 35,
  mapper.py 33, rdsap_uvalues.py 1 (all unchanged from baseline).

Note: `packages/domain/pyproject.toml` still declares
`packages = ["src/domain"]` for the hatchling wheel — that target
directory is now empty and the wheel build is effectively a no-op.
Retiring the workspace package or repointing the wheel is a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:01:35 +00:00

253 lines
9.4 KiB
Python

"""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