mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(modelling): wire glazing generator into the candidate pool
Slice 4 of the glazing generator (ADR-0022): run recommend_glazing in _candidate_recommendations, threading the Property's PlanningRestrictions so a protected dwelling is offered secondary glazing instead of double (mirrors recommend_solid_wall). Price both Measure Types in the offline catalogue (double £600/window, secondary £510 -- the legacy 0.85x scaling) and the contingency table (0.15, the legacy windows_glazing rate); the _GENERATOR_MEASURE_TYPES forcing test enforces both entries exist. run_modelling tests pin the wiring end-to-end on an all-single-glazed dwelling: double when unrestricted, secondary when listed. The first-run integration test seeds a double_glazing Product because its lodged EPC has a single-glazed window. _single_glazed_epc() deep-copies build_epc() (which shares its window objects) so the mutation can't leak into other tests' baselines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
276dd1a500
commit
456a81df0a
5 changed files with 66 additions and 4 deletions
|
|
@ -15,6 +15,8 @@ _CONTINGENCY_RATES: dict[str, float] = {
|
||||||
"mechanical_ventilation": 0.26,
|
"mechanical_ventilation": 0.26,
|
||||||
"external_wall_insulation": 0.26,
|
"external_wall_insulation": 0.26,
|
||||||
"internal_wall_insulation": 0.26,
|
"internal_wall_insulation": 0.26,
|
||||||
|
"double_glazing": 0.15,
|
||||||
|
"secondary_glazing": 0.15,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,7 @@
|
||||||
"solid_floor_insulation": { "unit_cost_per_m2": 45.0 },
|
"solid_floor_insulation": { "unit_cost_per_m2": 45.0 },
|
||||||
"mechanical_ventilation": { "unit_cost_per_m2": 450.0 },
|
"mechanical_ventilation": { "unit_cost_per_m2": 450.0 },
|
||||||
"external_wall_insulation": { "unit_cost_per_m2": 100.0 },
|
"external_wall_insulation": { "unit_cost_per_m2": 100.0 },
|
||||||
"internal_wall_insulation": { "unit_cost_per_m2": 90.0 }
|
"internal_wall_insulation": { "unit_cost_per_m2": 90.0 },
|
||||||
|
"double_glazing": { "unit_cost_per_m2": 600.0 },
|
||||||
|
"secondary_glazing": { "unit_cost_per_m2": 510.0 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ from domain.modelling.scoring.scoring import (
|
||||||
)
|
)
|
||||||
from domain.modelling.generators.wall_recommendation import recommend_cavity_wall
|
from domain.modelling.generators.wall_recommendation import recommend_cavity_wall
|
||||||
from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall
|
from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall
|
||||||
|
from domain.modelling.generators.glazing_recommendation import recommend_glazing
|
||||||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
from domain.sap10_calculator.calculator import SapCalculator
|
from domain.sap10_calculator.calculator import SapCalculator
|
||||||
from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository
|
from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository
|
||||||
|
|
@ -198,13 +199,14 @@ def _candidate_recommendations(
|
||||||
planning_restrictions: PlanningRestrictions,
|
planning_restrictions: PlanningRestrictions,
|
||||||
) -> list[Recommendation]:
|
) -> list[Recommendation]:
|
||||||
"""Run every fabric Recommendation Generator; keep the ones that apply.
|
"""Run every fabric Recommendation Generator; keep the ones that apply.
|
||||||
Solid-wall insulation is additionally gated by the Property's planning
|
Solid-wall insulation and glazing are additionally gated by the Property's
|
||||||
protections (ADR-0019)."""
|
planning protections (ADR-0019 / ADR-0022)."""
|
||||||
found = (
|
found = (
|
||||||
recommend_cavity_wall(effective_epc, products),
|
recommend_cavity_wall(effective_epc, products),
|
||||||
recommend_solid_wall(effective_epc, products, planning_restrictions),
|
recommend_solid_wall(effective_epc, products, planning_restrictions),
|
||||||
recommend_roof_insulation(effective_epc, products),
|
recommend_roof_insulation(effective_epc, products),
|
||||||
recommend_floor_insulation(effective_epc, products),
|
recommend_floor_insulation(effective_epc, products),
|
||||||
|
recommend_glazing(effective_epc, products, planning_restrictions),
|
||||||
)
|
)
|
||||||
return [recommendation for recommendation in found if recommendation is not None]
|
return [recommendation for recommendation in found if recommendation is not None]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -31,6 +32,8 @@ _GENERATOR_MEASURE_TYPES = (
|
||||||
"suspended_floor_insulation",
|
"suspended_floor_insulation",
|
||||||
"solid_floor_insulation",
|
"solid_floor_insulation",
|
||||||
"mechanical_ventilation",
|
"mechanical_ventilation",
|
||||||
|
"double_glazing",
|
||||||
|
"secondary_glazing",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -112,6 +115,49 @@ def test_run_modelling_listed_building_yields_no_wall_insulation() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _single_glazed_epc() -> EpcPropertyData:
|
||||||
|
"""The cavity/floor dwelling with all windows single-glazed — the glazing
|
||||||
|
generator's trigger, sized so the upgrade reaches the optimised package.
|
||||||
|
|
||||||
|
`build_epc()` shares its window objects across calls, so deep-copy before
|
||||||
|
mutating to avoid leaking single-glazing into other tests' baselines."""
|
||||||
|
epc: EpcPropertyData = copy.deepcopy(_build_uninsulated_cavity_and_floor_epc())
|
||||||
|
for window in epc.sap_windows:
|
||||||
|
window.glazing_type = 1 # SAP10.2 Table U2 code 1 = single.
|
||||||
|
return epc
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_modelling_recommends_double_glazing_for_single_glazed_windows() -> None:
|
||||||
|
# Arrange — a single-glazed dwelling; the glazing generator is wired into
|
||||||
|
# the candidate pool.
|
||||||
|
epc: EpcPropertyData = _single_glazed_epc()
|
||||||
|
|
||||||
|
# Act — Modelling only, no database, unrestricted.
|
||||||
|
plan = run_modelling(epc, goal_band="C", print_table=False)
|
||||||
|
|
||||||
|
# Assert — double glazing reaches the optimised package.
|
||||||
|
measure_types = {measure.measure_type for measure in plan.measures}
|
||||||
|
assert "double_glazing" in measure_types
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_modelling_protected_dwelling_yields_secondary_glazing() -> None:
|
||||||
|
# Arrange — the same single-glazed dwelling, listed (blocks external work).
|
||||||
|
epc: EpcPropertyData = _single_glazed_epc()
|
||||||
|
|
||||||
|
# Act — thread a listed-building restriction through to the generator.
|
||||||
|
plan = run_modelling(
|
||||||
|
epc,
|
||||||
|
goal_band="C",
|
||||||
|
print_table=False,
|
||||||
|
planning_restrictions=PlanningRestrictions(is_listed=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert — the picked glazing Measure is secondary, never double.
|
||||||
|
measure_types = {measure.measure_type for measure in plan.measures}
|
||||||
|
assert "secondary_glazing" in measure_types
|
||||||
|
assert "double_glazing" not in measure_types
|
||||||
|
|
||||||
|
|
||||||
def test_sample_catalogue_prices_every_generator_measure_type() -> None:
|
def test_sample_catalogue_prices_every_generator_measure_type() -> None:
|
||||||
# Arrange — the default offline catalogue.
|
# Arrange — the default offline catalogue.
|
||||||
products: ProductJsonRepository = ProductJsonRepository(DEFAULT_CATALOGUE)
|
products: ProductJsonRepository = ProductJsonRepository(DEFAULT_CATALOGUE)
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,9 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
|
||||||
# The sample EPC's solid floor is uninsulated, so the floor generator
|
# The sample EPC's solid floor is uninsulated, so the floor generator
|
||||||
# fires during candidate generation and prices against this Product. The
|
# fires during candidate generation and prices against this Product. The
|
||||||
# ventilation Measure Dependency is built for every not-yet-ventilated
|
# ventilation Measure Dependency is built for every not-yet-ventilated
|
||||||
# dwelling, so its Product must exist too (ADR-0016).
|
# dwelling, so its Product must exist too (ADR-0016). The EPC also lodges
|
||||||
|
# a single-glazed window, so the glazing generator fires and reaches for
|
||||||
|
# the double-glazing Product (ADR-0022).
|
||||||
session.add_all(
|
session.add_all(
|
||||||
[
|
[
|
||||||
MaterialRow(
|
MaterialRow(
|
||||||
|
|
@ -142,6 +144,14 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
|
||||||
is_active=True,
|
is_active=True,
|
||||||
description="Mechanical extract ventilation unit",
|
description="Mechanical extract ventilation unit",
|
||||||
),
|
),
|
||||||
|
MaterialRow(
|
||||||
|
id=3,
|
||||||
|
type="double_glazing",
|
||||||
|
total_cost=600.0,
|
||||||
|
cost_unit="gbp_per_unit",
|
||||||
|
is_active=True,
|
||||||
|
description="Double glazing",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue