mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
feat(modelling): floor Recommendation Generator + ground-floor-area geometry
recommend_floor_insulation(epc, products) detects an uninsulated ground floor (SapBuildingPart.floor_insulation_thickness blank/zero) and its construction from floor_construction_type — 'Suspended timber' -> suspended_floor_insulation, 'Solid' -> solid_floor_insulation — emitting the matching single Option (a floor is one construction, like a cavity wall) with the overlay (floor_insulation_thickness = 100 mm) and a priced Cost (ground-floor area x the Product's fully-loaded unit cost + contingency). - building_geometry.ground_floor_area(epc, identifier): the lowest floor's (floor == 0) area. Pinned 14.85 m^2 on 000490 MAIN. - BuildingPartOverlay gains floor_insulation_thickness (generic Applicator writes it unchanged). suspended (0.20) / solid (0.26) floor contingencies. Progress on #1159 (generator + geometry); end-to-end + Elmhurst pin pending the orchestrator (#1157) and parser. Four behaviour tests (suspended / solid / none / cost) + geometry pin. pyright strict clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3c87be8e1e
commit
4c10405071
6 changed files with 215 additions and 1 deletions
|
|
@ -45,3 +45,18 @@ def roof_area(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> float
|
|||
return round(
|
||||
max(fd.total_floor_area_m2 for fd in part.sap_floor_dimensions), 2
|
||||
)
|
||||
|
||||
|
||||
def ground_floor_area(
|
||||
epc: EpcPropertyData, identifier: BuildingPartIdentifier
|
||||
) -> float:
|
||||
"""Ground-floor area of one building part, in m^2 — the area of its lowest
|
||||
floor (``floor == 0``), the surface a ground-floor insulation measure
|
||||
treats."""
|
||||
part = next(
|
||||
candidate
|
||||
for candidate in epc.sap_building_parts
|
||||
if candidate.identifier is identifier
|
||||
)
|
||||
ground = next(fd for fd in part.sap_floor_dimensions if fd.floor == 0)
|
||||
return round(ground.total_floor_area_m2, 2)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ extended as each measure type lands.
|
|||
_CONTINGENCY_RATES: dict[str, float] = {
|
||||
"cavity_wall_insulation": 0.10,
|
||||
"loft_insulation": 0.10,
|
||||
"suspended_floor_insulation": 0.20,
|
||||
"solid_floor_insulation": 0.26,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
84
domain/modelling/floor_recommendation.py
Normal file
84
domain/modelling/floor_recommendation.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""The floor Recommendation Generator.
|
||||
|
||||
Detects an uninsulated ground floor and its construction (suspended timber vs
|
||||
solid) and emits a Recommendation whose single Measure Option carries the
|
||||
matching insulation Simulation Overlay and a priced Cost. A floor is one
|
||||
construction, so — like a cavity wall — there is one Option, chosen by
|
||||
detection. No scoring, no persistence (ADR-0016).
|
||||
"""
|
||||
|
||||
from typing import Optional, Union
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
SapBuildingPart,
|
||||
)
|
||||
from domain.building_geometry import ground_floor_area
|
||||
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||
from repositories.product.product_repository import ProductRepository
|
||||
|
||||
# Recommended ground-floor insulation depth (mm).
|
||||
_RECOMMENDED_FLOOR_THICKNESS_MM = 100
|
||||
|
||||
|
||||
def _is_uninsulated(thickness: Optional[Union[str, int]]) -> bool:
|
||||
"""A lodged floor-insulation thickness of nothing / blank / zero is an
|
||||
uninsulated floor; any positive thickness is already insulated."""
|
||||
if thickness is None:
|
||||
return True
|
||||
if isinstance(thickness, int):
|
||||
return thickness == 0
|
||||
return thickness.strip() in ("", "0")
|
||||
|
||||
|
||||
def _floor_measure_type(construction_type: Optional[str]) -> Optional[str]:
|
||||
"""Map the lodged floor construction to the insulation Measure Type, or
|
||||
None when the construction is not a treatable suspended/solid floor."""
|
||||
text = (construction_type or "").lower()
|
||||
if "suspended" in text:
|
||||
return "suspended_floor_insulation"
|
||||
if "solid" in text:
|
||||
return "solid_floor_insulation"
|
||||
return None
|
||||
|
||||
|
||||
def recommend_floor_insulation(
|
||||
epc: EpcPropertyData, products: ProductRepository
|
||||
) -> Optional[Recommendation]:
|
||||
"""Return a ground-floor insulation Recommendation for the main part's
|
||||
uninsulated ground floor, else None."""
|
||||
main: SapBuildingPart = next(
|
||||
part
|
||||
for part in epc.sap_building_parts
|
||||
if part.identifier is BuildingPartIdentifier.MAIN
|
||||
)
|
||||
|
||||
if not _is_uninsulated(main.floor_insulation_thickness):
|
||||
return None
|
||||
|
||||
measure_type = _floor_measure_type(main.floor_construction_type)
|
||||
if measure_type is None:
|
||||
return None
|
||||
|
||||
product = products.get(measure_type)
|
||||
area: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN)
|
||||
cost = Cost(
|
||||
total=area * product.unit_cost_per_m2,
|
||||
contingency_rate=product.contingency_rate,
|
||||
)
|
||||
|
||||
option = MeasureOption(
|
||||
measure_type=measure_type,
|
||||
description="Ground-floor insulation",
|
||||
overlay=EpcSimulation(
|
||||
building_parts={
|
||||
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
|
||||
floor_insulation_thickness=_RECOMMENDED_FLOOR_THICKNESS_MM
|
||||
)
|
||||
}
|
||||
),
|
||||
cost=cost,
|
||||
)
|
||||
return Recommendation(surface="Ground floor", options=(option,))
|
||||
|
|
@ -23,6 +23,7 @@ class BuildingPartOverlay:
|
|||
|
||||
wall_insulation_type: Optional[int] = None
|
||||
roof_insulation_thickness: Optional[int] = None
|
||||
floor_insulation_thickness: Optional[int] = None
|
||||
|
||||
|
||||
def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]:
|
||||
|
|
|
|||
97
tests/domain/modelling/test_floor_recommendation.py
Normal file
97
tests/domain/modelling/test_floor_recommendation.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Behaviour of the floor Recommendation Generator: detecting an uninsulated
|
||||
ground floor and its construction (suspended vs solid), emitting the matching
|
||||
single insulation Option with overlay + priced Cost. A floor is one
|
||||
construction, so this is a single-Option Recommendation (like cavity walls).
|
||||
"""
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
SapBuildingPart,
|
||||
)
|
||||
from domain.modelling.overlay_applicator import apply_simulations
|
||||
from domain.modelling.product import Product
|
||||
from domain.modelling.recommendation import Recommendation
|
||||
from domain.modelling.floor_recommendation import recommend_floor_insulation
|
||||
from repositories.product.product_repository import ProductRepository
|
||||
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
|
||||
build_epc,
|
||||
)
|
||||
|
||||
|
||||
class _StubProducts(ProductRepository):
|
||||
def get(self, measure_type: str) -> Product:
|
||||
return Product(
|
||||
measure_type=measure_type, unit_cost_per_m2=25.0, contingency_rate=0.20
|
||||
)
|
||||
|
||||
|
||||
def _main(epc: EpcPropertyData) -> SapBuildingPart:
|
||||
return next(
|
||||
p for p in epc.sap_building_parts if p.identifier is BuildingPartIdentifier.MAIN
|
||||
)
|
||||
|
||||
|
||||
def test_uninsulated_suspended_floor_yields_suspended_insulation() -> None:
|
||||
# Arrange — 000490 MAIN: "Suspended timber", "As built" (uninsulated)
|
||||
baseline: EpcPropertyData = build_epc()
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_floor_insulation(
|
||||
baseline, _StubProducts()
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert recommendation is not None
|
||||
assert recommendation.surface == "Ground floor"
|
||||
assert len(recommendation.options) == 1
|
||||
option = recommendation.options[0]
|
||||
assert option.measure_type == "suspended_floor_insulation"
|
||||
simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay])
|
||||
assert _main(simulated).floor_insulation_thickness == 100
|
||||
|
||||
|
||||
def test_uninsulated_solid_floor_yields_solid_insulation() -> None:
|
||||
# Arrange
|
||||
baseline: EpcPropertyData = build_epc()
|
||||
_main(baseline).floor_construction_type = "Solid"
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_floor_insulation(
|
||||
baseline, _StubProducts()
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert recommendation is not None
|
||||
assert recommendation.options[0].measure_type == "solid_floor_insulation"
|
||||
|
||||
|
||||
def test_already_insulated_floor_yields_no_recommendation() -> None:
|
||||
# Arrange
|
||||
baseline: EpcPropertyData = build_epc()
|
||||
_main(baseline).floor_insulation_thickness = "100"
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_floor_insulation(
|
||||
baseline, _StubProducts()
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert recommendation is None
|
||||
|
||||
|
||||
def test_floor_option_carries_cost_from_ground_floor_area_and_product() -> None:
|
||||
# Arrange — 000490 MAIN ground floor area 14.85 m^2
|
||||
baseline: EpcPropertyData = build_epc()
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_floor_insulation(
|
||||
baseline, _StubProducts()
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert recommendation is not None
|
||||
cost = recommendation.options[0].cost
|
||||
assert cost is not None
|
||||
assert abs(cost.total - 14.85 * 25.0) <= 0.01
|
||||
assert abs(cost.contingency_rate - 0.20) <= 1e-9
|
||||
|
|
@ -2,7 +2,11 @@
|
|||
reusable outside the SAP calculator (e.g. for Modelling cost quantities)."""
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
|
||||
from domain.building_geometry import gross_heat_loss_wall_area, roof_area
|
||||
from domain.building_geometry import (
|
||||
gross_heat_loss_wall_area,
|
||||
ground_floor_area,
|
||||
roof_area,
|
||||
)
|
||||
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
|
||||
build_epc,
|
||||
)
|
||||
|
|
@ -33,3 +37,14 @@ def test_roof_area_is_the_parts_greatest_floor_area() -> None:
|
|||
|
||||
# Assert
|
||||
assert abs(area - 14.85) <= 0.01
|
||||
|
||||
|
||||
def test_ground_floor_area_is_the_lowest_floors_area() -> None:
|
||||
# Arrange — 000490 MAIN floor 0 total area is 14.85 m^2
|
||||
epc = build_epc()
|
||||
|
||||
# Act
|
||||
area: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN)
|
||||
|
||||
# Assert
|
||||
assert abs(area - 14.85) <= 0.01
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue