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