mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
SAP calculator entry point + cert→inputs adapter + strict P6.1 identifiers
Lands the production code that the just-committed Elmhurst conformance fixtures (6455d48b) exercise: the SAP10.3 calculator orchestrator (domain.sap.calculator.Sap10Calculator), the RdSAP-driven cert→inputs mapper (domain.sap.rdsap.cert_to_inputs), and the EpcPropertyData strict-type pass that P6.1 starts. calculator.py is the entry point. Two surfaces depending on the caller's shape: - Sap10Calculator().calculate(epc) — full RdSAP mapper + worksheet loop - calculate_sap_from_inputs(inputs) — pure physics over typed inputs P6.1 introduces BuildingPartIdentifier as a strictly-typed replacement for bare-string matching on SapBuildingPart.identifier (motivated by the pain point at worksheet/dimensions.py:74-82). Two boundary factories canonicalise raw inputs: from_api_string for the gov-EPC API, and extension(n) for site-notes / construction id flows. Also catches up two transitive deps that6455d48bimplicitly required but I missed: - ml/rdsap_uvalues.py — party-wall U-value rows that heat_transmission resolves; the U=0.0 branch the 000516 fixture exercises lands here. - ml/tests/_fixtures.py — make_minimal_sap10_epc that every Elmhurst fixture imports. Without this catch-up, checking out6455d48bin isolation would ImportError. Out of scope (will commit separately): ml/transform.py legacy envelope drift; backend/ FastAPI + documents_parser layer; etl/ scratch. 824 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6455d48b9d
commit
a8b443f669
11 changed files with 353 additions and 46 deletions
|
|
@ -1,10 +1,67 @@
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import List, Optional, Union
|
from enum import Enum
|
||||||
|
from typing import Final, List, Optional, Union
|
||||||
|
|
||||||
from datatypes.epc.domain.epc import Epc
|
from datatypes.epc.domain.epc import Epc
|
||||||
|
|
||||||
|
|
||||||
|
_API_EXTENSION = re.compile(r"^Extension\s+(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
class BuildingPartIdentifier(Enum):
|
||||||
|
"""Canonical identifier for a SAP building part.
|
||||||
|
|
||||||
|
Replaces bare-string matching on `SapBuildingPart.identifier`. The
|
||||||
|
enum *values* match the site-notes / database shape ("main",
|
||||||
|
"extension_1" .. "extension_4"); boundary mappers (gov-EPC API,
|
||||||
|
site notes) construct these via the `from_api_string` / `extension`
|
||||||
|
classmethods so consumers can dispatch with `is` instead of fragile
|
||||||
|
string equality.
|
||||||
|
|
||||||
|
RdSAP10 §1.2 caps extensions at 4 per dwelling, so EXTENSION_1..4
|
||||||
|
are enumerated explicitly; anything else falls to OTHER so callers
|
||||||
|
can still iterate safely.
|
||||||
|
|
||||||
|
P6.1 — first slice of the strict-typing P6 work documented in
|
||||||
|
HANDOVER_SYSTEMATIC_REVIEW §2.5.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MAIN = "main"
|
||||||
|
EXTENSION_1 = "extension_1"
|
||||||
|
EXTENSION_2 = "extension_2"
|
||||||
|
EXTENSION_3 = "extension_3"
|
||||||
|
EXTENSION_4 = "extension_4"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_string(
|
||||||
|
cls, api_identifier: Optional[str]
|
||||||
|
) -> "BuildingPartIdentifier":
|
||||||
|
"""Map a gov-EPC API `BuildingPart.identifier` to its canonical
|
||||||
|
member. "Main Dwelling" → MAIN; "Extension N" → EXTENSION_N
|
||||||
|
(for N in 1..4). `None` (permitted by the 21_0_1 schema) and
|
||||||
|
anything unrecognised fall to OTHER.
|
||||||
|
"""
|
||||||
|
if api_identifier == "Main Dwelling":
|
||||||
|
return cls.MAIN
|
||||||
|
if api_identifier is not None:
|
||||||
|
match = _API_EXTENSION.match(api_identifier)
|
||||||
|
if match is not None:
|
||||||
|
return cls.extension(int(match.group(1)))
|
||||||
|
return cls.OTHER
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def extension(cls, n: int) -> "BuildingPartIdentifier":
|
||||||
|
"""Canonical identifier for the Nth extension. RdSAP10 §1.2
|
||||||
|
caps at 4; numbers outside 1..4 fall to OTHER."""
|
||||||
|
try:
|
||||||
|
return cls(f"extension_{n}")
|
||||||
|
except ValueError:
|
||||||
|
return cls.OTHER
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EnergyElement:
|
class EnergyElement:
|
||||||
description: str
|
description: str
|
||||||
|
|
@ -201,6 +258,14 @@ class SapRoomInRoof:
|
||||||
construction_age_band: str
|
construction_age_band: str
|
||||||
|
|
||||||
|
|
||||||
|
# RdSAP10 wall_construction integer encoding. The gov-EPC API doesn't publish
|
||||||
|
# the mapping; established empirically from a 50k 2026-bulk sweep — code 6
|
||||||
|
# co-occurs with `walls[].description = "Basement wall"` in 88% of cases at
|
||||||
|
# a 0.18% false-positive rate, so we treat it as the canonical basement-wall
|
||||||
|
# signal.
|
||||||
|
BASEMENT_WALL_CONSTRUCTION_CODE: Final[int] = 6
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SapAlternativeWall:
|
class SapAlternativeWall:
|
||||||
wall_area: float
|
wall_area: float
|
||||||
|
|
@ -210,11 +275,19 @@ class SapAlternativeWall:
|
||||||
wall_thickness_measured: str
|
wall_thickness_measured: str
|
||||||
wall_insulation_thickness: Optional[str] = None
|
wall_insulation_thickness: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_basement_wall(self) -> bool:
|
||||||
|
"""True iff this alt sub-area is the dwelling's basement wall —
|
||||||
|
identified by RdSAP10 wall_construction code = 6 (see module
|
||||||
|
constant `BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23
|
||||||
|
applies a special U-value lookup to basement walls."""
|
||||||
|
return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SapBuildingPart:
|
class SapBuildingPart:
|
||||||
# General
|
# General
|
||||||
identifier: str # e.g. "main", "roof"
|
identifier: BuildingPartIdentifier
|
||||||
construction_age_band: str
|
construction_age_band: str
|
||||||
|
|
||||||
# Wall
|
# Wall
|
||||||
|
|
@ -261,6 +334,29 @@ class SapBuildingPart:
|
||||||
)
|
)
|
||||||
sap_room_in_roof: Optional[SapRoomInRoof] = None
|
sap_room_in_roof: Optional[SapRoomInRoof] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def main_wall_is_basement(self) -> bool:
|
||||||
|
"""True iff this part's primary wall (not an alt sub-area) is the
|
||||||
|
basement wall — happens when the whole part sits below grade.
|
||||||
|
Empirically 54 of 67k parts in the 2026 sweep; rare but real."""
|
||||||
|
return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_basement(self) -> bool:
|
||||||
|
"""True iff this part carries a basement wall — either as its
|
||||||
|
main wall (`main_wall_is_basement`) or as an alt sub-area
|
||||||
|
(`SapAlternativeWall.is_basement_wall`). When true, RdSAP §5.17 /
|
||||||
|
Table 23 governs both the basement-wall U-value AND the entire
|
||||||
|
ground floor's U-value for this part (per user-confirmed
|
||||||
|
convention: basement-wall presence ⇒ whole floor=0 is basement
|
||||||
|
floor)."""
|
||||||
|
if self.main_wall_is_basement:
|
||||||
|
return True
|
||||||
|
return any(
|
||||||
|
alt is not None and alt.is_basement_wall
|
||||||
|
for alt in (self.sap_alternative_wall_1, self.sap_alternative_wall_2)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WindowsTransmissionDetails:
|
class WindowsTransmissionDetails:
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from datatypes.epc.schema.helpers import from_dict
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import (
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
Addendum,
|
Addendum,
|
||||||
|
BuildingPartIdentifier,
|
||||||
EnergyElement,
|
EnergyElement,
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
InstantaneousWwhrs,
|
InstantaneousWwhrs,
|
||||||
|
|
@ -427,7 +428,7 @@ class EpcPropertyDataMapper:
|
||||||
),
|
),
|
||||||
sap_building_parts=[
|
sap_building_parts=[
|
||||||
SapBuildingPart(
|
SapBuildingPart(
|
||||||
identifier=bp.identifier,
|
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||||
construction_age_band=bp.construction_age_band,
|
construction_age_band=bp.construction_age_band,
|
||||||
wall_construction=bp.wall_construction,
|
wall_construction=bp.wall_construction,
|
||||||
wall_insulation_type=bp.wall_insulation_type,
|
wall_insulation_type=bp.wall_insulation_type,
|
||||||
|
|
@ -568,7 +569,7 @@ class EpcPropertyDataMapper:
|
||||||
),
|
),
|
||||||
sap_building_parts=[
|
sap_building_parts=[
|
||||||
SapBuildingPart(
|
SapBuildingPart(
|
||||||
identifier=bp.identifier,
|
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||||
construction_age_band=bp.construction_age_band,
|
construction_age_band=bp.construction_age_band,
|
||||||
wall_construction=bp.wall_construction,
|
wall_construction=bp.wall_construction,
|
||||||
wall_insulation_type=bp.wall_insulation_type,
|
wall_insulation_type=bp.wall_insulation_type,
|
||||||
|
|
@ -705,7 +706,7 @@ class EpcPropertyDataMapper:
|
||||||
),
|
),
|
||||||
sap_building_parts=[
|
sap_building_parts=[
|
||||||
SapBuildingPart(
|
SapBuildingPart(
|
||||||
identifier=bp.identifier,
|
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||||
construction_age_band=bp.construction_age_band,
|
construction_age_band=bp.construction_age_band,
|
||||||
wall_construction=bp.wall_construction,
|
wall_construction=bp.wall_construction,
|
||||||
wall_insulation_type=bp.wall_insulation_type,
|
wall_insulation_type=bp.wall_insulation_type,
|
||||||
|
|
@ -850,7 +851,7 @@ class EpcPropertyDataMapper:
|
||||||
),
|
),
|
||||||
sap_building_parts=[
|
sap_building_parts=[
|
||||||
SapBuildingPart(
|
SapBuildingPart(
|
||||||
identifier=bp.identifier,
|
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||||
construction_age_band=bp.construction_age_band,
|
construction_age_band=bp.construction_age_band,
|
||||||
wall_construction=bp.wall_construction,
|
wall_construction=bp.wall_construction,
|
||||||
wall_insulation_type=bp.wall_insulation_type,
|
wall_insulation_type=bp.wall_insulation_type,
|
||||||
|
|
@ -1012,7 +1013,7 @@ class EpcPropertyDataMapper:
|
||||||
),
|
),
|
||||||
sap_building_parts=[
|
sap_building_parts=[
|
||||||
SapBuildingPart(
|
SapBuildingPart(
|
||||||
identifier=bp.identifier,
|
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||||
construction_age_band=bp.construction_age_band,
|
construction_age_band=bp.construction_age_band,
|
||||||
wall_construction=bp.wall_construction,
|
wall_construction=bp.wall_construction,
|
||||||
wall_insulation_type=bp.wall_insulation_type,
|
wall_insulation_type=bp.wall_insulation_type,
|
||||||
|
|
@ -1201,7 +1202,7 @@ class EpcPropertyDataMapper:
|
||||||
),
|
),
|
||||||
sap_building_parts=[
|
sap_building_parts=[
|
||||||
SapBuildingPart(
|
SapBuildingPart(
|
||||||
identifier=bp.identifier,
|
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||||
construction_age_band=bp.construction_age_band,
|
construction_age_band=bp.construction_age_band,
|
||||||
wall_construction=bp.wall_construction,
|
wall_construction=bp.wall_construction,
|
||||||
wall_insulation_type=bp.wall_insulation_type,
|
wall_insulation_type=bp.wall_insulation_type,
|
||||||
|
|
@ -1446,7 +1447,7 @@ class EpcPropertyDataMapper:
|
||||||
# SAP building parts
|
# SAP building parts
|
||||||
sap_building_parts=[
|
sap_building_parts=[
|
||||||
SapBuildingPart(
|
SapBuildingPart(
|
||||||
identifier=bp.identifier,
|
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||||
construction_age_band=bp.construction_age_band,
|
construction_age_band=bp.construction_age_band,
|
||||||
wall_construction=bp.wall_construction,
|
wall_construction=bp.wall_construction,
|
||||||
wall_insulation_type=bp.wall_insulation_type,
|
wall_insulation_type=bp.wall_insulation_type,
|
||||||
|
|
@ -1755,7 +1756,7 @@ def _map_main_building_part(
|
||||||
floor = construction.floor
|
floor = construction.floor
|
||||||
roof_location, roof_thickness = _map_roof(roof)
|
roof_location, roof_thickness = _map_roof(roof)
|
||||||
return SapBuildingPart(
|
return SapBuildingPart(
|
||||||
identifier="main",
|
identifier=BuildingPartIdentifier.MAIN,
|
||||||
construction_age_band=_extract_age_band(main.age_range),
|
construction_age_band=_extract_age_band(main.age_range),
|
||||||
wall_construction=main.walls_construction_type,
|
wall_construction=main.walls_construction_type,
|
||||||
wall_insulation_type=main.walls_insulation_type,
|
wall_insulation_type=main.walls_insulation_type,
|
||||||
|
|
@ -1779,7 +1780,7 @@ def _map_extension_building_part(
|
||||||
) -> SapBuildingPart:
|
) -> SapBuildingPart:
|
||||||
roof_location, roof_thickness = _map_roof(roof)
|
roof_location, roof_thickness = _map_roof(roof)
|
||||||
return SapBuildingPart(
|
return SapBuildingPart(
|
||||||
identifier=f"extension_{ext_c.id}",
|
identifier=BuildingPartIdentifier.extension(ext_c.id),
|
||||||
construction_age_band=_extract_age_band(ext_c.age_range),
|
construction_age_band=_extract_age_band(ext_c.age_range),
|
||||||
wall_construction=ext_c.walls_construction_type,
|
wall_construction=ext_c.walls_construction_type,
|
||||||
wall_insulation_type=ext_c.walls_insulation_type,
|
wall_insulation_type=ext_c.walls_insulation_type,
|
||||||
|
|
@ -1902,7 +1903,7 @@ def _map_elmhurst_building_part(survey: ElmhurstSiteNotes) -> SapBuildingPart:
|
||||||
for i, f in enumerate(dims.floors)
|
for i, f in enumerate(dims.floors)
|
||||||
]
|
]
|
||||||
return SapBuildingPart(
|
return SapBuildingPart(
|
||||||
identifier="main",
|
identifier=BuildingPartIdentifier.MAIN,
|
||||||
construction_age_band=_strip_code(survey.construction_age_band),
|
construction_age_band=_strip_code(survey.construction_age_band),
|
||||||
wall_construction=_strip_code(survey.walls.wall_type),
|
wall_construction=_strip_code(survey.walls.wall_type),
|
||||||
wall_insulation_type=_strip_code(survey.walls.insulation),
|
wall_insulation_type=_strip_code(survey.walls.insulation),
|
||||||
|
|
|
||||||
98
datatypes/epc/domain/tests/test_building_part_identifier.py
Normal file
98
datatypes/epc/domain/tests/test_building_part_identifier.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Tests for `BuildingPartIdentifier` — the strictly-typed identifier
|
||||||
|
that replaces bare-string matching on `SapBuildingPart.identifier`.
|
||||||
|
|
||||||
|
Two boundary factories convert raw inputs to canonical members:
|
||||||
|
- `BuildingPartIdentifier.from_api_string` (gov-EPC API)
|
||||||
|
- `BuildingPartIdentifier.extension(n)` (site-notes / construction id)
|
||||||
|
|
||||||
|
P6.1 starts P6 (strict-type EpcPropertyData) from the documented pain
|
||||||
|
point in packages/domain/src/domain/sap/worksheet/dimensions.py:74-82.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
|
||||||
|
|
||||||
|
|
||||||
|
class TestFromApiString:
|
||||||
|
"""The gov-EPC API returns "Main Dwelling" and "Extension N"; the
|
||||||
|
21_0_1 schema also permits `None`. All map to canonical members."""
|
||||||
|
|
||||||
|
def test_main_dwelling_becomes_main(self) -> None:
|
||||||
|
# Arrange / Act
|
||||||
|
identifier = BuildingPartIdentifier.from_api_string("Main Dwelling")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert identifier is BuildingPartIdentifier.MAIN
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"api_string, expected",
|
||||||
|
[
|
||||||
|
("Extension 1", BuildingPartIdentifier.EXTENSION_1),
|
||||||
|
("Extension 2", BuildingPartIdentifier.EXTENSION_2),
|
||||||
|
("Extension 3", BuildingPartIdentifier.EXTENSION_3),
|
||||||
|
("Extension 4", BuildingPartIdentifier.EXTENSION_4),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_extension_n_becomes_extension_n(
|
||||||
|
self, api_string: str, expected: BuildingPartIdentifier
|
||||||
|
) -> None:
|
||||||
|
# Arrange / Act
|
||||||
|
identifier = BuildingPartIdentifier.from_api_string(api_string)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert identifier is expected
|
||||||
|
|
||||||
|
def test_none_becomes_other(self) -> None:
|
||||||
|
# Arrange — the 21_0_1 schema permits `identifier: Optional[str]`.
|
||||||
|
# Act
|
||||||
|
identifier = BuildingPartIdentifier.from_api_string(None)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert identifier is BuildingPartIdentifier.OTHER
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"api_string", ["", "roof", "garage", "Extension", "Main", "Extension 5"]
|
||||||
|
)
|
||||||
|
def test_unrecognised_becomes_other(self, api_string: str) -> None:
|
||||||
|
# Arrange — "Extension 5" is intentionally OTHER per RdSAP10 §1.2
|
||||||
|
# (max 4 extensions); bare "Extension" with no digit likewise.
|
||||||
|
# Act
|
||||||
|
identifier = BuildingPartIdentifier.from_api_string(api_string)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert identifier is BuildingPartIdentifier.OTHER
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtensionFactory:
|
||||||
|
"""`extension(n)` is the site-notes-side constructor — surveyors
|
||||||
|
record extensions by integer id; this maps id→canonical member."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"n, expected",
|
||||||
|
[
|
||||||
|
(1, BuildingPartIdentifier.EXTENSION_1),
|
||||||
|
(2, BuildingPartIdentifier.EXTENSION_2),
|
||||||
|
(3, BuildingPartIdentifier.EXTENSION_3),
|
||||||
|
(4, BuildingPartIdentifier.EXTENSION_4),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_valid_extension_number_returns_member(
|
||||||
|
self, n: int, expected: BuildingPartIdentifier
|
||||||
|
) -> None:
|
||||||
|
# Arrange / Act
|
||||||
|
identifier = BuildingPartIdentifier.extension(n)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert identifier is expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("n", [0, 5, 99, -1])
|
||||||
|
def test_out_of_range_falls_to_other(self, n: int) -> None:
|
||||||
|
# Arrange — RdSAP10 §1.2 caps at 4; out-of-range numbers should
|
||||||
|
# not crash the mapper, they should classify as OTHER.
|
||||||
|
# Act
|
||||||
|
identifier = BuildingPartIdentifier.extension(n)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert identifier is BuildingPartIdentifier.OTHER
|
||||||
|
|
@ -6,6 +6,7 @@ from typing import Any, Dict
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import (
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
InstantaneousWwhrs,
|
InstantaneousWwhrs,
|
||||||
MainHeatingDetail,
|
MainHeatingDetail,
|
||||||
|
|
@ -211,7 +212,7 @@ class TestFromSiteNotesExample1:
|
||||||
assert len(result.sap_building_parts) == 1
|
assert len(result.sap_building_parts) == 1
|
||||||
|
|
||||||
def test_building_part_identifier(self, result: EpcPropertyData) -> None:
|
def test_building_part_identifier(self, result: EpcPropertyData) -> None:
|
||||||
assert result.sap_building_parts[0].identifier == "main"
|
assert result.sap_building_parts[0].identifier is BuildingPartIdentifier.MAIN
|
||||||
|
|
||||||
def test_construction_age_band(self, result: EpcPropertyData) -> None:
|
def test_construction_age_band(self, result: EpcPropertyData) -> None:
|
||||||
# main_building.age_range: "I: 1996 - 2002" → letter "I"
|
# main_building.age_range: "I: 1996 - 2002" → letter "I"
|
||||||
|
|
@ -464,7 +465,7 @@ class TestFromSiteNotesExample1:
|
||||||
# Building parts
|
# Building parts
|
||||||
sap_building_parts=[
|
sap_building_parts=[
|
||||||
SapBuildingPart(
|
SapBuildingPart(
|
||||||
identifier="main",
|
identifier=BuildingPartIdentifier.MAIN,
|
||||||
construction_age_band="I",
|
construction_age_band="I",
|
||||||
wall_construction="Cavity",
|
wall_construction="Cavity",
|
||||||
wall_insulation_type="As built",
|
wall_insulation_type="As built",
|
||||||
|
|
|
||||||
|
|
@ -587,6 +587,59 @@ def u_door(
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Basement U-values (RdSAP §5.17 / Table 23)
|
||||||
|
#
|
||||||
|
# Applied when a building part carries an alt wall with
|
||||||
|
# wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE. Per the user-
|
||||||
|
# confirmed convention, the WHOLE floor=0 of that part is the basement
|
||||||
|
# floor — so `u_basement_floor` overrides the regular floor U-value for
|
||||||
|
# the part's ground floor area, and `u_basement_wall` overrides the
|
||||||
|
# cascade for the basement alt sub-area only.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_BASEMENT_WALL_BY_BAND: Final[dict[str, float]] = {
|
||||||
|
"A": 0.7, "B": 0.7, "C": 0.7, "D": 0.7, "E": 0.7,
|
||||||
|
"F": 0.7,
|
||||||
|
"G": 0.6, "H": 0.6,
|
||||||
|
"I": 0.45,
|
||||||
|
"J": 0.35,
|
||||||
|
"K": 0.3,
|
||||||
|
"L": 0.28,
|
||||||
|
"M": 0.26,
|
||||||
|
}
|
||||||
|
|
||||||
|
_BASEMENT_FLOOR_BY_BAND: Final[dict[str, float]] = {
|
||||||
|
"A": 0.50, "B": 0.50, "C": 0.50, "D": 0.50, "E": 0.50,
|
||||||
|
"F": 0.50,
|
||||||
|
"G": 0.5, "H": 0.5, "I": 0.5,
|
||||||
|
"J": 0.25,
|
||||||
|
"K": 0.22,
|
||||||
|
"L": 0.22,
|
||||||
|
"M": 0.18,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def u_basement_wall(age_band: Optional[str]) -> float:
|
||||||
|
"""Basement-wall U-value (W/m²K), RdSAP10 Table 23. Defaults to the
|
||||||
|
A-E value (0.7) when age band is missing — matches the worst-case
|
||||||
|
cascade behaviour elsewhere in this module."""
|
||||||
|
if age_band is None:
|
||||||
|
return 0.7
|
||||||
|
return _BASEMENT_WALL_BY_BAND.get(age_band.upper(), 0.7)
|
||||||
|
|
||||||
|
|
||||||
|
def u_basement_floor(age_band: Optional[str]) -> float:
|
||||||
|
"""Basement-floor U-value (W/m²K), RdSAP10 Table 23. Applied to the
|
||||||
|
WHOLE floor=0 of a part that has a basement (per user-confirmed
|
||||||
|
convention: basement-wall presence ⇒ entire ground floor is basement
|
||||||
|
floor). Defaults to the A-E value (0.50) when band is missing."""
|
||||||
|
if age_band is None:
|
||||||
|
return 0.50
|
||||||
|
return _BASEMENT_FLOOR_BY_BAND.get(age_band.upper(), 0.50)
|
||||||
|
|
||||||
|
|
||||||
def u_party_wall(party_wall_construction: Optional[int]) -> float:
|
def u_party_wall(party_wall_construction: Optional[int]) -> float:
|
||||||
"""RdSAP10 party-wall U-value in W/m^2K, never null.
|
"""RdSAP10 party-wall U-value in W/m^2K, never null.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from datetime import date
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import (
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
InstantaneousWwhrs,
|
InstantaneousWwhrs,
|
||||||
MainHeatingDetail,
|
MainHeatingDetail,
|
||||||
|
|
@ -123,7 +124,7 @@ def make_floor_dimension(
|
||||||
|
|
||||||
def make_building_part(
|
def make_building_part(
|
||||||
*,
|
*,
|
||||||
identifier: str = "Main Dwelling",
|
identifier: BuildingPartIdentifier = BuildingPartIdentifier.MAIN,
|
||||||
construction_age_band: str = "B",
|
construction_age_band: str = "B",
|
||||||
wall_construction: Union[int, str] = 3,
|
wall_construction: Union[int, str] = 3,
|
||||||
wall_insulation_type: Union[int, str] = 2,
|
wall_insulation_type: Union[int, str] = 2,
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,11 @@ class CalculatorInputs:
|
||||||
|
|
||||||
dimensions: Dimensions
|
dimensions: Dimensions
|
||||||
heat_transmission: HeatTransmission
|
heat_transmission: HeatTransmission
|
||||||
infiltration_ach: float
|
# SAP10.2 (25)m — effective monthly air-change rate (12-tuple Jan..Dec).
|
||||||
|
# Per-month because ventilation HLC varies with wind speed (Table U2)
|
||||||
|
# and MV mode (§2 lines 24a-d). Constant-monthly inputs work too:
|
||||||
|
# pass `(ach,) * 12` to model a single rate across all months.
|
||||||
|
monthly_infiltration_ach: tuple[float, ...]
|
||||||
region: int
|
region: int
|
||||||
windows: tuple[WindowInput, ...]
|
windows: tuple[WindowInput, ...]
|
||||||
control_type: int
|
control_type: int
|
||||||
|
|
@ -278,22 +282,34 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
|
||||||
typed `SapResult`. Cert-shape mapping is the job of `cert_to_inputs`
|
typed `SapResult`. Cert-shape mapping is the job of `cert_to_inputs`
|
||||||
(S-A7b); this entry point is pure physics."""
|
(S-A7b); this entry point is pure physics."""
|
||||||
tfa = inputs.dimensions.total_floor_area_m2
|
tfa = inputs.dimensions.total_floor_area_m2
|
||||||
hlc_v = inputs.infiltration_ach * inputs.dimensions.volume_m3 * _AIR_HEAT_CAPACITY_WH_PER_M3_K
|
volume = inputs.dimensions.volume_m3
|
||||||
hlc = inputs.heat_transmission.total_w_per_k + hlc_v
|
transmission_hlc = inputs.heat_transmission.total_w_per_k
|
||||||
hlp = hlc / tfa if tfa > 0 else 0.0
|
|
||||||
tau_h = _time_constant_h(
|
# SAP10.2 §3 line (38): ventilation HLC = 0.33 × (25)m × volume —
|
||||||
|
# monthly because (25)m varies with Table U2 wind. HLC, HLP, and the
|
||||||
|
# time constant τ all become 12-tuples.
|
||||||
|
monthly_hlc_v = tuple(
|
||||||
|
ach * volume * _AIR_HEAT_CAPACITY_WH_PER_M3_K
|
||||||
|
for ach in inputs.monthly_infiltration_ach
|
||||||
|
)
|
||||||
|
monthly_hlc = tuple(transmission_hlc + hv for hv in monthly_hlc_v)
|
||||||
|
monthly_hlp = tuple(h / tfa if tfa > 0 else 0.0 for h in monthly_hlc)
|
||||||
|
monthly_tau_h = tuple(
|
||||||
|
_time_constant_h(
|
||||||
tmp_kj_per_m2_k=inputs.thermal_mass_parameter_kj_per_m2_k,
|
tmp_kj_per_m2_k=inputs.thermal_mass_parameter_kj_per_m2_k,
|
||||||
tfa_m2=tfa,
|
tfa_m2=tfa,
|
||||||
hlc_w_per_k=hlc,
|
hlc_w_per_k=h,
|
||||||
|
)
|
||||||
|
for h in monthly_hlc
|
||||||
)
|
)
|
||||||
|
|
||||||
monthly = tuple(
|
monthly = tuple(
|
||||||
_solve_month(
|
_solve_month(
|
||||||
inputs=inputs,
|
inputs=inputs,
|
||||||
month=m,
|
month=m,
|
||||||
hlc_w_per_k=hlc,
|
hlc_w_per_k=monthly_hlc[m - 1],
|
||||||
time_constant_h=tau_h,
|
time_constant_h=monthly_tau_h[m - 1],
|
||||||
heat_loss_parameter=hlp,
|
heat_loss_parameter=monthly_hlp[m - 1],
|
||||||
)
|
)
|
||||||
for m in range(1, 13)
|
for m in range(1, 13)
|
||||||
)
|
)
|
||||||
|
|
@ -374,11 +390,13 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
|
||||||
"windows_w_per_k": ht.windows_w_per_k,
|
"windows_w_per_k": ht.windows_w_per_k,
|
||||||
"doors_w_per_k": ht.doors_w_per_k,
|
"doors_w_per_k": ht.doors_w_per_k,
|
||||||
"thermal_bridging_w_per_k": ht.thermal_bridging_w_per_k,
|
"thermal_bridging_w_per_k": ht.thermal_bridging_w_per_k,
|
||||||
"infiltration_ach": inputs.infiltration_ach,
|
# Annual means for the back-compat single-float audit dict; full
|
||||||
"infiltration_w_per_k": hlc_v,
|
# monthly arrays are available via the upstream VentilationResult.
|
||||||
"heat_transfer_coefficient_w_per_k": hlc,
|
"infiltration_ach": sum(inputs.monthly_infiltration_ach) / 12.0,
|
||||||
"heat_loss_parameter_w_per_m2k": hlp,
|
"infiltration_w_per_k": sum(monthly_hlc_v) / 12.0,
|
||||||
"time_constant_h": tau_h,
|
"heat_transfer_coefficient_w_per_k": sum(monthly_hlc) / 12.0,
|
||||||
|
"heat_loss_parameter_w_per_m2k": sum(monthly_hlp) / 12.0,
|
||||||
|
"time_constant_h": sum(monthly_tau_h) / 12.0,
|
||||||
"internal_gains_annual_avg_w": sum(e.internal_gains_w for e in monthly) / 12.0,
|
"internal_gains_annual_avg_w": sum(e.internal_gains_w for e in monthly) / 12.0,
|
||||||
"mean_internal_temp_annual_avg_c": sum(e.internal_temp_c for e in monthly) / 12.0,
|
"mean_internal_temp_annual_avg_c": sum(e.internal_temp_c for e in monthly) / 12.0,
|
||||||
"useful_space_heating_kwh_per_yr": space_heating_kwh,
|
"useful_space_heating_kwh_per_yr": space_heating_kwh,
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,10 @@ from domain.sap.worksheet.heat_transmission import (
|
||||||
heat_transmission_from_cert,
|
heat_transmission_from_cert,
|
||||||
)
|
)
|
||||||
from domain.sap.worksheet.solar_gains import Orientation
|
from domain.sap.worksheet.solar_gains import Orientation
|
||||||
from domain.sap.worksheet.ventilation import infiltration_ach
|
from domain.sap.worksheet.ventilation import (
|
||||||
|
MechanicalVentilationKind,
|
||||||
|
ventilation_from_inputs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# RdSAP 10 Table 27 — fraction of total floor area treated as the
|
# RdSAP 10 Table 27 — fraction of total floor area treated as the
|
||||||
|
|
@ -766,7 +769,35 @@ def cert_to_inputs(
|
||||||
vol = dim.volume_m3 if dim.volume_m3 > 0 else 1.0
|
vol = dim.volume_m3 if dim.volume_m3 > 0 else 1.0
|
||||||
storeys = max(1, dim.storey_count)
|
storeys = max(1, dim.storey_count)
|
||||||
vc = _ventilation_counts(epc.sap_ventilation)
|
vc = _ventilation_counts(epc.sap_ventilation)
|
||||||
infiltration = infiltration_ach(
|
# SAP §2 ventilation: full worksheet (6a)..(25)m via VentilationResult.
|
||||||
|
# The following cert→input ambiguities are intentionally papered over
|
||||||
|
# with spec-default values for now; each TODO is a tracked follow-up
|
||||||
|
# for when the mapping rule becomes available:
|
||||||
|
#
|
||||||
|
# TODO(cert→ventilation 1): `epc.mechanical_ventilation: int` carries
|
||||||
|
# a code (0..N) selecting Natural / MVHR / MV / Extract / PIV-outside
|
||||||
|
# / PIV-loft. The int→enum mapping isn't in the SAP10.2 or RdSAP10
|
||||||
|
# PDFs we have; Elmhurst likely uses the same code list. We pin
|
||||||
|
# NATURAL until we have a documented mapping or a golden cert that
|
||||||
|
# exercises an MV path.
|
||||||
|
# TODO(cert→ventilation 2): `has_suspended_timber_floor` / `_sealed`
|
||||||
|
# should be derived from `floor_construction` + `floor_insulation`
|
||||||
|
# on `SapFloorDimension`; the RdSAP §4.1 rule for "treat as
|
||||||
|
# suspended timber" isn't yet wired in. Defaulted to False.
|
||||||
|
# TODO(cert→ventilation 3): `has_draught_lobby` is not lodged on the
|
||||||
|
# gov-EPC API. RdSAP §4.1 may infer from dwelling form. Defaulted
|
||||||
|
# to False (worst case for line (13) = 0.05).
|
||||||
|
# TODO(cert→ventilation 4): `air_permeability_ap50` / `ap4` from a
|
||||||
|
# pressure test — cert has `pressure_test: int` (code, not a value)
|
||||||
|
# and `air_tightness: {description,...}`. Likely only present on
|
||||||
|
# SAP (new-build) certs, not RdSAP. Defaulted to None (no test).
|
||||||
|
# TODO(cert→ventilation 5): `sheltered_sides` not on the cert — we
|
||||||
|
# hardcode 2 (typical UK terraced/semi-detached). Could be derived
|
||||||
|
# from `dwelling_type` (detached → 0, end-terrace → 2, mid-terrace → 3).
|
||||||
|
# TODO(cert→ventilation 6): `monthly_wind_speed_m_s` defaults to
|
||||||
|
# Table U2 non-regional. Should select the regional row keyed by
|
||||||
|
# `epc.region_code` once regional weather is wired in.
|
||||||
|
ventilation = ventilation_from_inputs(
|
||||||
volume_m3=vol,
|
volume_m3=vol,
|
||||||
storey_count=storeys,
|
storey_count=storeys,
|
||||||
is_timber_or_steel_frame=_is_timber_or_steel_frame(epc.sap_building_parts),
|
is_timber_or_steel_frame=_is_timber_or_steel_frame(epc.sap_building_parts),
|
||||||
|
|
@ -780,10 +811,8 @@ def cert_to_inputs(
|
||||||
passive_vents=vc.passive_vents,
|
passive_vents=vc.passive_vents,
|
||||||
flueless_gas_fires=vc.flueless_gas_fires,
|
flueless_gas_fires=vc.flueless_gas_fires,
|
||||||
window_pct_draught_proofed=float(epc.percent_draughtproofed or 0),
|
window_pct_draught_proofed=float(epc.percent_draughtproofed or 0),
|
||||||
# SAP §2 shelter factor: 2 sheltered sides default per typical
|
|
||||||
# UK terraced/semi-detached layout. The cert doesn't lodge a
|
|
||||||
# sheltered-sides count, so we apply the spec's typical default.
|
|
||||||
sheltered_sides=2,
|
sheltered_sides=2,
|
||||||
|
mv_kind=MechanicalVentilationKind.NATURAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
main = _first_main_heating(epc)
|
main = _first_main_heating(epc)
|
||||||
|
|
@ -847,7 +876,9 @@ def cert_to_inputs(
|
||||||
return CalculatorInputs(
|
return CalculatorInputs(
|
||||||
dimensions=dim,
|
dimensions=dim,
|
||||||
heat_transmission=ht,
|
heat_transmission=ht,
|
||||||
infiltration_ach=infiltration.total_ach,
|
# SAP10.2 line (25)m — 12-month effective air-change rate from the
|
||||||
|
# full §2 worksheet (openings, shelter, wind adjustment, MV mode).
|
||||||
|
monthly_infiltration_ach=ventilation.effective_monthly_ach,
|
||||||
region=_region_index(epc.region_code),
|
region=_region_index(epc.region_code),
|
||||||
windows=_window_inputs(epc.sap_windows),
|
windows=_window_inputs(epc.sap_windows),
|
||||||
control_type=_control_type(main),
|
control_type=_control_type(main),
|
||||||
|
|
|
||||||
|
|
@ -354,8 +354,10 @@ def test_open_chimneys_raise_infiltration_ach() -> None:
|
||||||
inputs_base = cert_to_inputs(base)
|
inputs_base = cert_to_inputs(base)
|
||||||
inputs_chim = cert_to_inputs(with_chimney)
|
inputs_chim = cert_to_inputs(with_chimney)
|
||||||
|
|
||||||
# Assert
|
# Assert — direction check on the annual mean of (25)m.
|
||||||
assert inputs_chim.infiltration_ach > inputs_base.infiltration_ach
|
assert sum(inputs_chim.monthly_infiltration_ach) > sum(
|
||||||
|
inputs_base.monthly_infiltration_ach
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_living_area_fraction_uses_rdsap_table_27_by_habitable_rooms() -> None:
|
def test_living_area_fraction_uses_rdsap_table_27_by_habitable_rooms() -> None:
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ def _baseline_dwelling() -> CalculatorInputs:
|
||||||
windows_w_per_k=25.0,
|
windows_w_per_k=25.0,
|
||||||
doors_w_per_k=5.0,
|
doors_w_per_k=5.0,
|
||||||
thermal_bridging_w_per_k=20.0,
|
thermal_bridging_w_per_k=20.0,
|
||||||
|
fabric_heat_loss_w_per_k=130.0, # 60+20+20+0+25+5
|
||||||
|
total_external_element_area_m2=200.0, # synthetic placeholder
|
||||||
total_w_per_k=150.0,
|
total_w_per_k=150.0,
|
||||||
)
|
)
|
||||||
windows = (
|
windows = (
|
||||||
|
|
@ -81,7 +83,7 @@ def _baseline_dwelling() -> CalculatorInputs:
|
||||||
return CalculatorInputs(
|
return CalculatorInputs(
|
||||||
dimensions=dim,
|
dimensions=dim,
|
||||||
heat_transmission=ht,
|
heat_transmission=ht,
|
||||||
infiltration_ach=0.7,
|
monthly_infiltration_ach=(0.7,) * 12,
|
||||||
region=0,
|
region=0,
|
||||||
windows=windows,
|
windows=windows,
|
||||||
control_type=2,
|
control_type=2,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ def _baseline_inputs() -> CalculatorInputs:
|
||||||
windows_w_per_k=25.0,
|
windows_w_per_k=25.0,
|
||||||
doors_w_per_k=5.0,
|
doors_w_per_k=5.0,
|
||||||
thermal_bridging_w_per_k=20.0,
|
thermal_bridging_w_per_k=20.0,
|
||||||
|
fabric_heat_loss_w_per_k=130.0, # 60+20+20+0+25+5
|
||||||
|
total_external_element_area_m2=200.0, # synthetic placeholder
|
||||||
total_w_per_k=150.0,
|
total_w_per_k=150.0,
|
||||||
)
|
)
|
||||||
windows = (
|
windows = (
|
||||||
|
|
@ -77,7 +79,7 @@ def _baseline_inputs() -> CalculatorInputs:
|
||||||
return CalculatorInputs(
|
return CalculatorInputs(
|
||||||
dimensions=dim,
|
dimensions=dim,
|
||||||
heat_transmission=ht,
|
heat_transmission=ht,
|
||||||
infiltration_ach=0.7,
|
monthly_infiltration_ach=(0.7,) * 12,
|
||||||
region=0,
|
region=0,
|
||||||
windows=windows,
|
windows=windows,
|
||||||
control_type=2,
|
control_type=2,
|
||||||
|
|
@ -170,8 +172,9 @@ def test_calculate_exposes_ventilation_intermediates() -> None:
|
||||||
result = calculate_sap_from_inputs(inputs)
|
result = calculate_sap_from_inputs(inputs)
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert result.intermediate["infiltration_ach"] == inputs.infiltration_ach
|
annual_mean_ach = sum(inputs.monthly_infiltration_ach) / 12.0
|
||||||
expected_hlc_v = inputs.infiltration_ach * inputs.dimensions.volume_m3 * 0.33
|
assert result.intermediate["infiltration_ach"] == pytest.approx(annual_mean_ach, rel=1e-12)
|
||||||
|
expected_hlc_v = annual_mean_ach * inputs.dimensions.volume_m3 * 0.33
|
||||||
assert result.intermediate["infiltration_w_per_k"] == pytest.approx(
|
assert result.intermediate["infiltration_w_per_k"] == pytest.approx(
|
||||||
expected_hlc_v, rel=1e-9
|
expected_hlc_v, rel=1e-9
|
||||||
)
|
)
|
||||||
|
|
@ -189,9 +192,10 @@ def test_calculate_exposes_hlc_hlp_and_annual_averages() -> None:
|
||||||
result = calculate_sap_from_inputs(inputs)
|
result = calculate_sap_from_inputs(inputs)
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
|
annual_mean_ach = sum(inputs.monthly_infiltration_ach) / 12.0
|
||||||
expected_hlc = (
|
expected_hlc = (
|
||||||
inputs.heat_transmission.total_w_per_k
|
inputs.heat_transmission.total_w_per_k
|
||||||
+ inputs.infiltration_ach * inputs.dimensions.volume_m3 * 0.33
|
+ annual_mean_ach * inputs.dimensions.volume_m3 * 0.33
|
||||||
)
|
)
|
||||||
expected_hlp = expected_hlc / inputs.dimensions.total_floor_area_m2
|
expected_hlp = expected_hlc / inputs.dimensions.total_floor_area_m2
|
||||||
assert result.intermediate["heat_transfer_coefficient_w_per_k"] == pytest.approx(
|
assert result.intermediate["heat_transfer_coefficient_w_per_k"] == pytest.approx(
|
||||||
|
|
@ -483,8 +487,8 @@ def test_zero_heat_transmission_collapses_space_heating_to_zero() -> None:
|
||||||
base = _baseline_inputs()
|
base = _baseline_inputs()
|
||||||
no_loss = replace(
|
no_loss = replace(
|
||||||
base,
|
base,
|
||||||
heat_transmission=HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
|
heat_transmission=HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
|
||||||
infiltration_ach=0.0,
|
monthly_infiltration_ach=(0.0,) * 12,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue