mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(modelling): detect single-glazing code 15 + glazing before/after pins
With the mapper now in main, cert 001431 parses: it lodges four single-glazed
windows — codes 1 ("Single") and 15 ("single glazing, known data", a single
pane with manufacturer U/g). The generator only detected code 1, so it missed
two panes. Detect {1, 15}; set the secondary target to code 11 ("Secondary
glazing - Normal emissivity", what the cert re-lodges; score-neutral vs 7 but
exact).
A deterministic green pin proves the overlay reproduces the after's 14 windows
exactly. The full-SAP before->after pins are xfail(strict) tripwires: the
overlay nails the windows, but the measure also re-lodges percent_draughtproofed
84->100 (sealed units draught-proof the replaced openings) plus a ~0.4 SAP
fabric residual the overlay doesn't model yet — a glazing-measure coupling to
close later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2c36a8e1d6
commit
07dbaa5361
3 changed files with 110 additions and 12 deletions
|
|
@ -27,10 +27,12 @@ from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||||
from domain.modelling.simulation import EpcSimulation, WindowOverlay
|
from domain.modelling.simulation import EpcSimulation, WindowOverlay
|
||||||
from repositories.product.product_repository import ProductRepository
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
# SAP10.2 Table U2 code 1 = single glazing — the only windows this generator
|
# Single-glazing codes — the only windows this generator upgrades. Code 1 is
|
||||||
# upgrades (the Elmhurst mapper sends "Single"/"Single glazing" → 1 and the API
|
# bare "Single"/"Single glazing"; code 15 is "single glazing, known data" (a
|
||||||
# passes int 1, so the trigger is path-consistent).
|
# single pane with manufacturer U/g lodged — same g_L=0.90 as code 1, per
|
||||||
_SINGLE_GLAZED: Final[int] = 1
|
# RdSAP-21). Both are single-glazed and must be detected, or a cert that lodges
|
||||||
|
# manufacturer data on its single panes (e.g. 001431 windows 12-13) is missed.
|
||||||
|
_SINGLE_GLAZED_CODES: Final[frozenset[int]] = frozenset({1, 15})
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -57,13 +59,14 @@ _DOUBLE: Final[_GlazingTarget] = _GlazingTarget(
|
||||||
solar_transmittance=0.72,
|
solar_transmittance=0.72,
|
||||||
)
|
)
|
||||||
# Protected (conservation/listed/heritage): fit an internal secondary pane
|
# Protected (conservation/listed/heritage): fit an internal secondary pane
|
||||||
# (gt=7 "Secondary glazing"; U→2.90, g unchanged at 0.85 — the existing outer
|
# (gt=11 "Secondary glazing - Normal emissivity", what cert 001431 re-lodges;
|
||||||
# single pane still drives solar gain). The external units can't be replaced on
|
# U→2.90, g unchanged at 0.85 — the existing outer single pane still drives
|
||||||
# a protected/over-looked building, so this is the planning-picked Measure.
|
# solar gain). The external units can't be replaced on a protected/over-looked
|
||||||
|
# building, so this is the planning-picked Measure.
|
||||||
_SECONDARY: Final[_GlazingTarget] = _GlazingTarget(
|
_SECONDARY: Final[_GlazingTarget] = _GlazingTarget(
|
||||||
measure_type="secondary_glazing",
|
measure_type="secondary_glazing",
|
||||||
description="Fit secondary glazing to the single-glazed windows",
|
description="Fit secondary glazing to the single-glazed windows",
|
||||||
glazing_type=7,
|
glazing_type=11,
|
||||||
u_value=2.90,
|
u_value=2.90,
|
||||||
solar_transmittance=0.85,
|
solar_transmittance=0.85,
|
||||||
)
|
)
|
||||||
|
|
@ -81,7 +84,7 @@ def recommend_glazing(
|
||||||
single_indices = tuple(
|
single_indices = tuple(
|
||||||
index
|
index
|
||||||
for index, window in enumerate(epc.sap_windows)
|
for index, window in enumerate(epc.sap_windows)
|
||||||
if window.glazing_type == _SINGLE_GLAZED
|
if window.glazing_type in _SINGLE_GLAZED_CODES
|
||||||
)
|
)
|
||||||
if not single_indices:
|
if not single_indices:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ from __future__ import annotations
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import (
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
BuildingPartIdentifier,
|
BuildingPartIdentifier,
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
|
|
@ -34,6 +36,8 @@ from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
from domain.modelling.generators.solid_wall_recommendation import (
|
from domain.modelling.generators.solid_wall_recommendation import (
|
||||||
recommend_solid_wall,
|
recommend_solid_wall,
|
||||||
)
|
)
|
||||||
|
from domain.modelling.generators.glazing_recommendation import recommend_glazing
|
||||||
|
from domain.modelling.scoring.overlay_applicator import apply_simulations
|
||||||
from domain.modelling.recommendation import MeasureOption
|
from domain.modelling.recommendation import MeasureOption
|
||||||
from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
|
from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
|
||||||
from repositories.product.product_repository import ProductRepository
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
@ -442,3 +446,94 @@ def test_suspended_floor_overlay_reproduces_the_relodged_after() -> None:
|
||||||
_assert_overlay_reproduces_after(
|
_assert_overlay_reproduces_after(
|
||||||
before, after, recommendation.options[0].overlay
|
before, after, recommendation.options[0].overlay
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_glazing_overlay_reproduces_the_relodged_after_windows() -> None:
|
||||||
|
# The full-SAP pin below is xfail (draught-proofing coupling), but the
|
||||||
|
# overlay's actual job — turning every single-glazed window into the
|
||||||
|
# relodged spec — is deterministic and must hold exactly: it proves the
|
||||||
|
# generator detects BOTH single-glazing codes (1 and 15) on the real cert.
|
||||||
|
# Arrange
|
||||||
|
before: EpcPropertyData = parse_recommendation_summary(
|
||||||
|
"double_glazing_001431_before.pdf"
|
||||||
|
)
|
||||||
|
after: EpcPropertyData = parse_recommendation_summary(
|
||||||
|
"double_glazing_001431_after.pdf"
|
||||||
|
)
|
||||||
|
recommendation: Recommendation | None = recommend_glazing(before, _AnyProduct())
|
||||||
|
assert recommendation is not None
|
||||||
|
|
||||||
|
# Act — apply the overlay to the parsed before.
|
||||||
|
applied: EpcPropertyData = apply_simulations(
|
||||||
|
before, [recommendation.options[0].overlay]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert — every window's glazing_type + lodged U/g matches the after.
|
||||||
|
def _window_spec(epc: EpcPropertyData) -> list[tuple[object, object, object]]:
|
||||||
|
specs: list[tuple[object, object, object]] = []
|
||||||
|
for window in epc.sap_windows:
|
||||||
|
details = window.window_transmission_details
|
||||||
|
specs.append(
|
||||||
|
(
|
||||||
|
window.glazing_type,
|
||||||
|
details.u_value if details is not None else None,
|
||||||
|
details.solar_transmittance if details is not None else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return specs
|
||||||
|
|
||||||
|
assert _window_spec(applied) == _window_spec(after)
|
||||||
|
|
||||||
|
|
||||||
|
_GLAZING_DRAUGHT_COUPLING_REASON: Final[str] = (
|
||||||
|
"Blocked on the glazing measure's draught-proofing coupling. The window "
|
||||||
|
"U/g overlay reproduces the after's 14 windows EXACTLY (all four single-"
|
||||||
|
"glazed panes — codes 1 and 15 — become the relodged double/secondary "
|
||||||
|
"spec). The residual ~0.7 SAP is a secondary effect the overlay does not "
|
||||||
|
"model: replacing the single-glazed (lodged draught_proofed=No) windows "
|
||||||
|
"with sealed units re-lodges percent_draughtproofed 84->100 (~0.3 SAP) and "
|
||||||
|
"lowers fabric heat loss by ~+150 kWh space heating (~0.4 SAP) not yet "
|
||||||
|
"isolated. Flips green once the glazing overlay propagates draught-proofing "
|
||||||
|
"(and the residual fabric coupling is modelled)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON)
|
||||||
|
def test_double_glazing_overlay_reproduces_the_relodged_after() -> None:
|
||||||
|
# Arrange — cert 001431 lodges four single-glazed windows (codes 1 and 15,
|
||||||
|
# "single glazing, known data"); the after re-lodges every one as double
|
||||||
|
# (gt=5, U=1.40, g=0.72).
|
||||||
|
before: EpcPropertyData = parse_recommendation_summary(
|
||||||
|
"double_glazing_001431_before.pdf"
|
||||||
|
)
|
||||||
|
after: EpcPropertyData = parse_recommendation_summary(
|
||||||
|
"double_glazing_001431_after.pdf"
|
||||||
|
)
|
||||||
|
recommendation: Recommendation | None = recommend_glazing(before, _AnyProduct())
|
||||||
|
assert recommendation is not None
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
_assert_overlay_reproduces_after(
|
||||||
|
before, after, recommendation.options[0].overlay
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON)
|
||||||
|
def test_secondary_glazing_overlay_reproduces_the_relodged_after() -> None:
|
||||||
|
# Arrange — a planning protection forces secondary glazing; the after
|
||||||
|
# re-lodges every single-glazed window as secondary (gt=11, U=2.90, g=0.85).
|
||||||
|
before: EpcPropertyData = parse_recommendation_summary(
|
||||||
|
"secondary_glazing_001431_before.pdf"
|
||||||
|
)
|
||||||
|
after: EpcPropertyData = parse_recommendation_summary(
|
||||||
|
"secondary_glazing_001431_after.pdf"
|
||||||
|
)
|
||||||
|
recommendation: Recommendation | None = recommend_glazing(
|
||||||
|
before, _AnyProduct(), PlanningRestrictions(in_conservation_area=True)
|
||||||
|
)
|
||||||
|
assert recommendation is not None
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
_assert_overlay_reproduces_after(
|
||||||
|
before, after, recommendation.options[0].overlay
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -116,11 +116,11 @@ def test_planning_protection_picks_secondary_glazing_over_double() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert — one secondary-glazing Option; each single window upgraded to the
|
# Assert — one secondary-glazing Option; each single window upgraded to the
|
||||||
# pinned secondary target (gt=7, U=2.90, g unchanged at 0.85).
|
# pinned secondary target (gt=11, U=2.90, g unchanged at 0.85).
|
||||||
assert recommendation is not None
|
assert recommendation is not None
|
||||||
option = recommendation.options[0]
|
option = recommendation.options[0]
|
||||||
assert option.measure_type == "secondary_glazing"
|
assert option.measure_type == "secondary_glazing"
|
||||||
assert dict(option.overlay.windows) == {
|
assert dict(option.overlay.windows) == {
|
||||||
0: WindowOverlay(glazing_type=7, u_value=2.90, solar_transmittance=0.85),
|
0: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85),
|
||||||
2: WindowOverlay(glazing_type=7, u_value=2.90, solar_transmittance=0.85),
|
2: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue