feat(modelling): floor insulation-type overlay field + cascade pins (#1159)

Completes #1159 end-to-end with solid and suspended-floor before/after
cascade pins on cert 001431, both closing at delta 0.000000.

Adds floor_insulation_type_str to BuildingPartOverlay (the generic
field-fold applicator picks it up with no change) and has
recommend_floor_insulation set it to "Retro-fitted". Insulating an
as-built floor re-lodges its insulation as retro-fitted; the calculator
keys on this for a suspended timber floor's sealed/unsealed
determination (cert_to_inputs.py: "retro" + no U-value supplied →
sealed). Without it the suspended-floor cascade left a +1.40 SAP gap
(the floor stayed "unsealed", wrong U-value); with it the cascade
closes exactly. Solid floors are unaffected by the seal logic and stay
at delta 0; both Elmhurst after-certs lodge "Retro-fitted", so setting
it uniformly is faithful.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 09:41:54 +00:00
parent 44d62c0c9b
commit a0b6a952c3
7 changed files with 48 additions and 1 deletions

View file

@ -21,6 +21,12 @@ from repositories.product.product_repository import ProductRepository
# Recommended ground-floor insulation depth (mm).
_RECOMMENDED_FLOOR_THICKNESS_MM = 100
# Insulating an as-built floor re-lodges its insulation as retro-fitted. The
# calculator keys on this for a suspended timber floor's sealed/unsealed
# determination (cert_to_inputs: "retro" + no U-value → sealed), so the
# overlay must set it or the suspended-floor cascade leaves a ~1.4 SAP gap
# (see test_elmhurst_cascade_pins).
_RETROFITTED_INSULATION = "Retro-fitted"
def _is_uninsulated(thickness: Optional[Union[str, int]]) -> bool:
@ -75,7 +81,8 @@ def recommend_floor_insulation(
overlay=EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
floor_insulation_thickness=_RECOMMENDED_FLOOR_THICKNESS_MM
floor_insulation_thickness=_RECOMMENDED_FLOOR_THICKNESS_MM,
floor_insulation_type_str=_RETROFITTED_INSULATION,
)
}
),

View file

@ -24,6 +24,7 @@ class BuildingPartOverlay:
wall_insulation_type: Optional[int] = None
roof_insulation_thickness: Optional[int] = None
floor_insulation_thickness: Optional[int] = None
floor_insulation_type_str: Optional[str] = None
def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]:

View file

@ -20,6 +20,7 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.package_scorer import PackageScorer, Score
from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation
from domain.modelling.floor_recommendation import recommend_floor_insulation
from domain.modelling.roof_recommendation import recommend_loft_insulation
from domain.modelling.simulation import EpcSimulation
from domain.modelling.wall_recommendation import recommend_cavity_wall
@ -99,3 +100,41 @@ def test_loft_overlay_reproduces_the_relodged_after() -> None:
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_solid_floor_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"solid_floor_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"solid_floor_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_floor_insulation(
before, _AnyProduct()
)
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_suspended_floor_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"suspended_floor_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"suspended_floor_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_floor_insulation(
before, _AnyProduct()
)
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)