mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
730 lines
29 KiB
Python
730 lines
29 KiB
Python
"""End-to-end through-repos integration for First Run (ADR-0012, #1138).
|
|
|
|
Real PostgresUnitOfWork over an ephemeral DB: Ingestion writes the EPC, Baseline
|
|
reads it back *through the repo* (not in memory), and a re-run replaces rather
|
|
than duplicates. Stub Modelling. The source clients are faked (no IO)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from sqlalchemy import Engine
|
|
from sqlmodel import Session, col, select
|
|
|
|
from datatypes.epc.domain.epc import Epc
|
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
|
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
|
from domain.property_baseline.rebaseliner import StubRebaseliner
|
|
from domain.sap10_calculator.calculator import Sap10Calculator
|
|
from domain.modelling.portfolio_goal import PortfolioGoal
|
|
from infrastructure.postgres.modelling import ScenarioModel
|
|
from domain.geospatial.coordinates import Coordinates
|
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
|
from domain.geospatial.spatial_reference import SpatialReference
|
|
from tests.domain.modelling._elmhurst_recommendation import (
|
|
parse_recommendation_summary,
|
|
)
|
|
from infrastructure.postgres.property_baseline_performance_table import (
|
|
PropertyBaselinePerformanceModel,
|
|
)
|
|
from infrastructure.postgres.epc_property_table import EpcPropertyModel
|
|
from infrastructure.postgres.modelling import PlanModel, RecommendationModel
|
|
from infrastructure.postgres.product_table import MaterialRow
|
|
from infrastructure.postgres.property_table import PropertyRow
|
|
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
|
|
build_epc as _build_uninsulated_cavity_and_floor_epc,
|
|
)
|
|
from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator
|
|
from orchestration.ara_first_run_pipeline import AraFirstRunPipeline
|
|
from orchestration.ingestion_orchestrator import IngestionOrchestrator
|
|
from orchestration.modelling_orchestrator import ModellingOrchestrator
|
|
from repositories.epc.epc_postgres_repository import EpcPostgresRepository
|
|
from repositories.property_baseline.property_baseline_postgres_repository import (
|
|
PropertyBaselinePostgresRepository,
|
|
)
|
|
from repositories.fuel_rates.fuel_rates_static_file_repository import (
|
|
FuelRatesStaticFileRepository,
|
|
)
|
|
from repositories.geospatial.geospatial_repository import GeospatialRepository
|
|
from repositories.postgres_unit_of_work import PostgresUnitOfWork
|
|
|
|
_JSON_SAMPLES = Path(__file__).resolve().parents[2] / "backend/epc_api/json_samples"
|
|
|
|
|
|
@dataclass
|
|
class _FakeCommand:
|
|
portfolio_id: int
|
|
property_ids: list[int]
|
|
scenario_ids: list[int]
|
|
|
|
|
|
class _FetcherReturning:
|
|
def __init__(self, epc: EpcPropertyData) -> None:
|
|
self._epc = epc
|
|
|
|
def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]:
|
|
return self._epc
|
|
|
|
|
|
class _NoCoordinates(GeospatialRepository):
|
|
def coordinates_for(self, uprn: int) -> Optional[Coordinates]:
|
|
return None # skip the solar leg — not under test here
|
|
|
|
|
|
class _UnusedSolarFetcher:
|
|
def get_building_insights(
|
|
self, longitude: float, latitude: float
|
|
) -> dict[str, Any]: # pragma: no cover
|
|
return {}
|
|
|
|
|
|
def _lodged_epc() -> EpcPropertyData:
|
|
# A real, persistable EPC (so it round-trips through the EPC repo), with the
|
|
# recorded-performance fields the sample leaves blank filled in so Baseline
|
|
# can read its Lodged Performance.
|
|
raw: dict[str, Any] = json.loads(
|
|
(_JSON_SAMPLES / "RdSAP-Schema-21.0.0" / "epc.json").read_text()
|
|
)
|
|
epc = EpcPropertyDataMapper.from_api_response(raw)
|
|
return dataclasses.replace(
|
|
epc,
|
|
energy_rating_current=72,
|
|
current_energy_efficiency_band=Epc.C,
|
|
co2_emissions_current=1.8,
|
|
energy_consumption_current=180,
|
|
)
|
|
|
|
|
|
def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
|
|
db_engine: Engine,
|
|
) -> None:
|
|
# Arrange — a property row to ingest against, and the EPC its fetcher returns.
|
|
with Session(db_engine) as session:
|
|
session.add(
|
|
PropertyRow(
|
|
id=10,
|
|
portfolio_id=1,
|
|
postcode="A0 0AA",
|
|
address="1 Some Street",
|
|
uprn=12345,
|
|
)
|
|
)
|
|
# Modelling now runs for real: it reads scenario 7 (the command's
|
|
# scenario_ids) through the repo, so the row must exist.
|
|
session.add(
|
|
ScenarioModel(
|
|
id=7, goal=PortfolioGoal.INCREASING_EPC, goal_value="C", is_default=True
|
|
)
|
|
)
|
|
# The sample EPC's solid floor is uninsulated, so the floor generator
|
|
# fires during candidate generation and prices against this Product. The
|
|
# ventilation Measure Dependency is built for every not-yet-ventilated
|
|
# 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(
|
|
[
|
|
MaterialRow(
|
|
id=5,
|
|
type="air_source_heat_pump",
|
|
total_cost=12000.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Air source heat pump",
|
|
),
|
|
MaterialRow(
|
|
id=1,
|
|
type="solid_floor_insulation",
|
|
total_cost=25.0,
|
|
cost_unit="gbp_per_m2",
|
|
is_active=True,
|
|
description="Solid floor insulation",
|
|
),
|
|
MaterialRow(
|
|
id=2,
|
|
type="mechanical_ventilation",
|
|
total_cost=450.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
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",
|
|
),
|
|
MaterialRow(
|
|
id=4,
|
|
type="low_energy_lighting_installation",
|
|
total_cost=8.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="LED bulb",
|
|
),
|
|
MaterialRow(
|
|
id=6,
|
|
type="boiler_upgrade",
|
|
total_cost=3000.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Gas condensing boiler",
|
|
),
|
|
MaterialRow(
|
|
id=7,
|
|
type="roomstat_programmer_trvs",
|
|
total_cost=500.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Heating controls + cylinder tune-up",
|
|
),
|
|
MaterialRow(
|
|
id=8,
|
|
type="time_temperature_zone_control",
|
|
total_cost=900.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Zoned heating controls + cylinder tune-up",
|
|
),
|
|
MaterialRow(
|
|
id=9,
|
|
type="secondary_heating_removal",
|
|
total_cost=250.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Secondary heating removal",
|
|
),
|
|
]
|
|
)
|
|
session.commit()
|
|
|
|
def unit_of_work() -> PostgresUnitOfWork:
|
|
return PostgresUnitOfWork(lambda: Session(db_engine))
|
|
|
|
pipeline = AraFirstRunPipeline(
|
|
ingestion=IngestionOrchestrator(
|
|
unit_of_work=unit_of_work,
|
|
epc_fetcher=_FetcherReturning(_lodged_epc()),
|
|
geospatial_repo=_NoCoordinates(),
|
|
solar_fetcher=_UnusedSolarFetcher(),
|
|
),
|
|
baseline=PropertyBaselineOrchestrator(
|
|
unit_of_work=unit_of_work,
|
|
rebaseliner=StubRebaseliner(),
|
|
fuel_rates=FuelRatesStaticFileRepository(),
|
|
),
|
|
modelling=ModellingOrchestrator(
|
|
unit_of_work=unit_of_work,
|
|
calculator=Sap10Calculator(),
|
|
fuel_rates=FuelRatesStaticFileRepository(),
|
|
),
|
|
)
|
|
command = _FakeCommand(portfolio_id=1, property_ids=[10], scenario_ids=[7])
|
|
|
|
# Act — First Run, then a re-run over the same batch.
|
|
pipeline.run(command)
|
|
pipeline.run(command)
|
|
|
|
# Assert — Baseline read the EPC Ingestion persisted (through the repo, only
|
|
# property_ids crossed the stage boundary), and the re-run replaced rather
|
|
# than duplicated either row.
|
|
with Session(db_engine) as session:
|
|
baseline = PropertyBaselinePostgresRepository(session).get_for_property(10)
|
|
epc_rows = session.exec(
|
|
select(EpcPropertyModel).where(EpcPropertyModel.property_id == 10)
|
|
).all()
|
|
baseline_rows = session.exec(
|
|
select(PropertyBaselinePerformanceModel).where(
|
|
PropertyBaselinePerformanceModel.property_id == 10
|
|
)
|
|
).all()
|
|
|
|
assert baseline is not None
|
|
assert baseline.lodged.sap_score == 72
|
|
assert baseline.space_heating_kwh == 13120.0
|
|
assert len(epc_rows) == 1
|
|
assert len(baseline_rows) == 1
|
|
|
|
|
|
def test_modelling_optimises_and_persists_a_multi_measure_plan(
|
|
db_engine: Engine,
|
|
) -> None:
|
|
# Arrange — an EPC with an uninsulated cavity wall AND an uninsulated
|
|
# suspended floor (loft already at 300mm), so the wall + floor Generators
|
|
# both fire and the Optimiser selects from two groups. We drive the
|
|
# Modelling stage directly off a repo-seeded EPC rather than the full
|
|
# pipeline: this calculator fixture has no lodged recorded-performance
|
|
# fields, so the Baseline stage (not under test here) can't run on it.
|
|
# SAP-numeric correctness is pinned in test_elmhurst_cascade_pins; here we
|
|
# prove the multi-measure Plan is optimised, priced, attributed and
|
|
# persisted. The property is band D (~57.4) and tops out at ~61, so the
|
|
# goal-C target is unreachable — this exercises the least-cost-to-target
|
|
# objective's **max-gain fallback** (ADR-0016 amendment): best effort, all
|
|
# measures, below target.
|
|
with Session(db_engine) as session:
|
|
session.add(
|
|
PropertyRow(
|
|
id=30,
|
|
portfolio_id=1,
|
|
postcode="A0 0AA",
|
|
address="3 Some Street",
|
|
uprn=33333,
|
|
)
|
|
)
|
|
session.add(
|
|
ScenarioModel(
|
|
id=7, goal=PortfolioGoal.INCREASING_EPC, goal_value="C", is_default=True
|
|
)
|
|
)
|
|
session.add_all(
|
|
[
|
|
MaterialRow(
|
|
id=5,
|
|
type="air_source_heat_pump",
|
|
total_cost=12000.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Air source heat pump",
|
|
),
|
|
MaterialRow(
|
|
id=1,
|
|
type="cavity_wall_insulation",
|
|
total_cost=18.5,
|
|
cost_unit="gbp_per_m2",
|
|
is_active=True,
|
|
description="Cavity wall insulation",
|
|
),
|
|
MaterialRow(
|
|
id=2,
|
|
type="suspended_floor_insulation",
|
|
total_cost=25.0,
|
|
cost_unit="gbp_per_m2",
|
|
is_active=True,
|
|
description="Suspended floor insulation",
|
|
),
|
|
MaterialRow(
|
|
id=3,
|
|
type="mechanical_ventilation",
|
|
total_cost=450.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Mechanical extract ventilation unit",
|
|
),
|
|
MaterialRow(
|
|
id=4,
|
|
type="low_energy_lighting_installation",
|
|
total_cost=8.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="LED bulb",
|
|
),
|
|
MaterialRow(
|
|
id=9,
|
|
type="secondary_heating_removal",
|
|
total_cost=250.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Secondary heating removal",
|
|
),
|
|
]
|
|
)
|
|
session.commit()
|
|
EpcPostgresRepository(session).save(
|
|
_build_uninsulated_cavity_and_floor_epc(),
|
|
property_id=30,
|
|
portfolio_id=1,
|
|
)
|
|
session.commit()
|
|
|
|
def unit_of_work() -> PostgresUnitOfWork:
|
|
return PostgresUnitOfWork(lambda: Session(db_engine))
|
|
|
|
# Act
|
|
ModellingOrchestrator(
|
|
unit_of_work=unit_of_work,
|
|
calculator=Sap10Calculator(),
|
|
fuel_rates=FuelRatesStaticFileRepository(),
|
|
).run(property_ids=[30], scenario_ids=[7], portfolio_id=1)
|
|
|
|
# Assert — one Plan with three Plan Measures: the wall + floor the Optimiser
|
|
# chose, plus the ventilation Measure Dependency the wall forces in
|
|
# (ADR-0016). Each is priced and attributed, linked by plan_id.
|
|
with Session(db_engine) as session:
|
|
plan = session.exec(
|
|
select(PlanModel).where(col(PlanModel.property_id) == 30)
|
|
).first()
|
|
assert plan is not None
|
|
rec_rows = session.exec(
|
|
select(RecommendationModel).where(
|
|
col(RecommendationModel.plan_id) == plan.id
|
|
)
|
|
).all()
|
|
|
|
assert plan.scenario_id == 7
|
|
assert plan.portfolio_id == 1
|
|
assert plan.is_default is True
|
|
assert plan.post_sap_points is not None
|
|
assert plan.post_epc_rating is not None
|
|
assert plan.cost_of_works is not None
|
|
assert plan.cost_of_works > 0.0
|
|
# Plan-level energy/bill figures derived from the post-package bill vs the
|
|
# baseline bill at the run's Fuel Rates (ADR-0014 amendment). The package
|
|
# improves the property, so it consumes less energy and costs less to run.
|
|
assert plan.post_energy_bill is not None and plan.post_energy_bill > 0.0
|
|
assert plan.post_energy_consumption is not None
|
|
assert plan.post_energy_consumption > 0.0
|
|
assert plan.energy_bill_savings is not None and plan.energy_bill_savings > 0.0
|
|
assert plan.energy_consumption_savings is not None
|
|
assert plan.energy_consumption_savings > 0.0
|
|
|
|
by_type = {rec.type: rec for rec in rec_rows}
|
|
assert set(by_type) == {
|
|
"cavity_wall_insulation",
|
|
"suspended_floor_insulation",
|
|
"mechanical_ventilation",
|
|
# The sample EPC lodges 8 low-energy-unknown bulbs, so the LED upgrade is
|
|
# a cheap positive-SAP candidate the Optimiser also keeps (ADR-0023).
|
|
"low_energy_lighting",
|
|
# The efficient representative heat pump (Vaillant aroTHERM plus 5 kW,
|
|
# ADR-0025) now raises SAP even on this gas dwelling, so the Optimiser
|
|
# also keeps the ASHP bundle in the least-cost-to-band package (ADR-0024).
|
|
"air_source_heat_pump",
|
|
# The sample lodges an electric secondary (SAP 691), so removal is offered
|
|
# (ADR-0028); the Optimiser keeps it in its all-beneficial-measures package
|
|
# — its SAP gain is 0 once the ASHP (category 4) ignores the secondary, but
|
|
# the heater is still physically removed at its own cost.
|
|
"secondary_heating_removal",
|
|
}
|
|
# Each persisted measure carries the catalogue id of the Product it installs
|
|
# (the MaterialRow ids seeded above), replacing the retired
|
|
# recommendation_materials BOM with a single material_id on the row.
|
|
assert by_type["cavity_wall_insulation"].material_id == 1
|
|
assert by_type["suspended_floor_insulation"].material_id == 2
|
|
assert by_type["mechanical_ventilation"].material_id == 3
|
|
assert by_type["low_energy_lighting"].material_id == 4
|
|
assert by_type["secondary_heating_removal"].material_id == 9
|
|
for rec in rec_rows:
|
|
assert rec.default is True
|
|
assert rec.already_installed is False
|
|
assert rec.sap_points is not None
|
|
assert rec.estimated_cost is not None
|
|
# The forced ventilation costs two £450 units and is priced even though it
|
|
# was never a free choice in the pool.
|
|
vent_cost: float | None = by_type["mechanical_ventilation"].estimated_cost
|
|
assert vent_cost is not None
|
|
assert abs(vent_cost - 900.0) <= 1e-6
|
|
# The insulation measures earn positive SAP; ventilation's contribution is
|
|
# not positive (it only ever costs SAP — ADR-0016).
|
|
wall_sap: float | None = by_type["cavity_wall_insulation"].sap_points
|
|
vent_sap: float | None = by_type["mechanical_ventilation"].sap_points
|
|
assert wall_sap is not None and vent_sap is not None
|
|
assert wall_sap > 0.0
|
|
assert vent_sap <= 0.0
|
|
# Per-measure bill savings (telescoping cascade, ADR-0014 amendment): each
|
|
# measure carries its delivered-kWh and £ saving, and they telescope exactly
|
|
# to the Plan's headline savings. Ventilation increases energy, so its
|
|
# savings are negative — and the telescoping still holds.
|
|
for rec in rec_rows:
|
|
assert rec.kwh_savings is not None
|
|
assert rec.energy_cost_savings is not None
|
|
vent_kwh: float | None = by_type["mechanical_ventilation"].kwh_savings
|
|
assert vent_kwh is not None and vent_kwh < 0.0
|
|
kwh_total: float = sum(rec.kwh_savings or 0.0 for rec in rec_rows)
|
|
cost_total: float = sum(rec.energy_cost_savings or 0.0 for rec in rec_rows)
|
|
assert plan.energy_consumption_savings is not None
|
|
assert plan.energy_bill_savings is not None
|
|
assert abs(kwh_total - plan.energy_consumption_savings) <= 1e-6
|
|
assert abs(cost_total - plan.energy_bill_savings) <= 1e-6
|
|
|
|
|
|
def test_modelling_recommends_nothing_when_already_at_the_target_band(
|
|
db_engine: Engine,
|
|
) -> None:
|
|
# Arrange — the same band-D property (~57.4), but a goal of band D, which it
|
|
# already meets. Least-cost-to-target recommends the cheapest package that
|
|
# *reaches* the target — and the target is already reached, so the cheapest
|
|
# package is the empty one. (The old max-gain objective would have
|
|
# recommended wall + floor + ventilation here, improving within the band the
|
|
# property is already in — exactly the over-recommendation this objective
|
|
# removes.) ADR-0016 amendment.
|
|
with Session(db_engine) as session:
|
|
session.add(
|
|
PropertyRow(
|
|
id=31,
|
|
portfolio_id=1,
|
|
postcode="A0 0AA",
|
|
address="4 Some Street",
|
|
uprn=44444,
|
|
)
|
|
)
|
|
session.add(
|
|
ScenarioModel(
|
|
id=8, goal=PortfolioGoal.INCREASING_EPC, goal_value="D", is_default=True
|
|
)
|
|
)
|
|
# The fabric Generators + the ventilation dependency builder still run
|
|
# during candidate generation, so their Products must exist even though
|
|
# nothing is ultimately selected.
|
|
session.add_all(
|
|
[
|
|
MaterialRow(
|
|
id=14,
|
|
type="air_source_heat_pump",
|
|
total_cost=12000.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Air source heat pump",
|
|
),
|
|
MaterialRow(
|
|
id=10,
|
|
type="cavity_wall_insulation",
|
|
total_cost=18.5,
|
|
cost_unit="gbp_per_m2",
|
|
is_active=True,
|
|
description="Cavity wall insulation",
|
|
),
|
|
MaterialRow(
|
|
id=11,
|
|
type="suspended_floor_insulation",
|
|
total_cost=25.0,
|
|
cost_unit="gbp_per_m2",
|
|
is_active=True,
|
|
description="Suspended floor insulation",
|
|
),
|
|
MaterialRow(
|
|
id=12,
|
|
type="mechanical_ventilation",
|
|
total_cost=450.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Mechanical extract ventilation unit",
|
|
),
|
|
MaterialRow(
|
|
id=13,
|
|
type="low_energy_lighting_installation",
|
|
total_cost=8.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="LED bulb",
|
|
),
|
|
MaterialRow(
|
|
id=9,
|
|
type="secondary_heating_removal",
|
|
total_cost=250.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Secondary heating removal",
|
|
),
|
|
]
|
|
)
|
|
session.commit()
|
|
EpcPostgresRepository(session).save(
|
|
_build_uninsulated_cavity_and_floor_epc(),
|
|
property_id=31,
|
|
portfolio_id=1,
|
|
)
|
|
session.commit()
|
|
|
|
def unit_of_work() -> PostgresUnitOfWork:
|
|
return PostgresUnitOfWork(lambda: Session(db_engine))
|
|
|
|
# Act
|
|
ModellingOrchestrator(
|
|
unit_of_work=unit_of_work,
|
|
calculator=Sap10Calculator(),
|
|
fuel_rates=FuelRatesStaticFileRepository(),
|
|
).run(property_ids=[31], scenario_ids=[8], portfolio_id=1)
|
|
|
|
# Assert — a Plan is persisted with no measures and zero cost; the
|
|
# post-retrofit figure is the unchanged baseline (still band D).
|
|
with Session(db_engine) as session:
|
|
plan = session.exec(
|
|
select(PlanModel).where(col(PlanModel.property_id) == 31)
|
|
).first()
|
|
assert plan is not None
|
|
rec_rows = session.exec(
|
|
select(RecommendationModel).where(
|
|
col(RecommendationModel.plan_id) == plan.id
|
|
)
|
|
).all()
|
|
|
|
assert rec_rows == []
|
|
assert plan.cost_of_works == 0.0
|
|
assert plan.post_epc_rating is Epc.D
|
|
# No measures → post bill equals the baseline bill → zero savings, but the
|
|
# post-retrofit bill/consumption are still the (non-zero) current figures.
|
|
assert plan.post_energy_bill is not None and plan.post_energy_bill > 0.0
|
|
assert plan.post_energy_consumption is not None
|
|
assert plan.post_energy_consumption > 0.0
|
|
assert plan.energy_bill_savings == 0.0
|
|
assert plan.energy_consumption_savings == 0.0
|
|
|
|
|
|
class _NoEpcFetcher:
|
|
"""An EPC fetcher that returns nothing — the EPC is seeded directly so this
|
|
e2e drives only the spatial-reference half of Ingestion."""
|
|
|
|
def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]:
|
|
return None
|
|
|
|
|
|
class _SpatialByUprn(GeospatialRepository):
|
|
"""Resolves a per-UPRN spatial reference (coordinates nulled — the Solar leg
|
|
is not under test)."""
|
|
|
|
def __init__(self, by_uprn: dict[int, SpatialReference]) -> None:
|
|
self._by_uprn = by_uprn
|
|
|
|
def coordinates_for(self, uprn: int) -> Optional[Coordinates]:
|
|
return None
|
|
|
|
def spatial_for(self, uprn: int) -> Optional[SpatialReference]:
|
|
return self._by_uprn.get(uprn)
|
|
|
|
|
|
def test_listed_uprn_ingested_blocks_solid_wall_insulation_in_modelling(
|
|
db_engine: Engine,
|
|
) -> None:
|
|
# Arrange — two solid-brick uninsulated dwellings: one in a listed building,
|
|
# one unrestricted. Ingestion caches each UPRN's planning protections; the
|
|
# EPC is seeded directly (the solid-wall mechanics are pinned elsewhere).
|
|
listed_reference = SpatialReference(
|
|
coordinates=None, restrictions=PlanningRestrictions(is_listed=True)
|
|
)
|
|
unrestricted_reference = SpatialReference(
|
|
coordinates=None, restrictions=PlanningRestrictions()
|
|
)
|
|
solid_brick_epc = parse_recommendation_summary("solid_brick_ewi_001431_before.pdf")
|
|
with Session(db_engine) as session:
|
|
session.add_all(
|
|
[
|
|
PropertyRow(
|
|
id=40,
|
|
portfolio_id=1,
|
|
postcode="A0 0AA",
|
|
address="Listed House",
|
|
uprn=44444,
|
|
),
|
|
PropertyRow(
|
|
id=41,
|
|
portfolio_id=1,
|
|
postcode="A0 0AA",
|
|
address="Unrestricted House",
|
|
uprn=55555,
|
|
),
|
|
ScenarioModel(
|
|
id=7,
|
|
goal=PortfolioGoal.INCREASING_EPC,
|
|
goal_value="C",
|
|
is_default=True,
|
|
),
|
|
]
|
|
)
|
|
# The solid-brick EPC fires the floor + solid-wall Generators and the
|
|
# ventilation dependency, so every Product they reach for must exist.
|
|
session.add_all(
|
|
[
|
|
MaterialRow(
|
|
id=5,
|
|
type="air_source_heat_pump",
|
|
total_cost=12000.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Air source heat pump",
|
|
),
|
|
MaterialRow(
|
|
id=1,
|
|
type="external_wall_insulation",
|
|
total_cost=100.0,
|
|
cost_unit="gbp_per_m2",
|
|
is_active=True,
|
|
description="External wall insulation",
|
|
),
|
|
MaterialRow(
|
|
id=2,
|
|
type="internal_wall_insulation",
|
|
total_cost=90.0,
|
|
cost_unit="gbp_per_m2",
|
|
is_active=True,
|
|
description="Internal wall insulation",
|
|
),
|
|
MaterialRow(
|
|
id=3,
|
|
type="solid_floor_insulation",
|
|
total_cost=25.0,
|
|
cost_unit="gbp_per_m2",
|
|
is_active=True,
|
|
description="Solid floor insulation",
|
|
),
|
|
MaterialRow(
|
|
id=4,
|
|
type="mechanical_ventilation",
|
|
total_cost=450.0,
|
|
cost_unit="gbp_per_unit",
|
|
is_active=True,
|
|
description="Mechanical extract ventilation unit",
|
|
),
|
|
]
|
|
)
|
|
session.commit()
|
|
epc_repo = EpcPostgresRepository(session)
|
|
epc_repo.save(solid_brick_epc, property_id=40, portfolio_id=1)
|
|
epc_repo.save(solid_brick_epc, property_id=41, portfolio_id=1)
|
|
session.commit()
|
|
|
|
def unit_of_work() -> PostgresUnitOfWork:
|
|
return PostgresUnitOfWork(lambda: Session(db_engine))
|
|
|
|
geospatial_repo = _SpatialByUprn(
|
|
{44444: listed_reference, 55555: unrestricted_reference}
|
|
)
|
|
|
|
# Act — Ingestion caches the protections per UPRN, then Modelling reads them
|
|
# back off the Property (through the repo) and gates the solid-wall measures.
|
|
IngestionOrchestrator(
|
|
unit_of_work=unit_of_work,
|
|
epc_fetcher=_NoEpcFetcher(),
|
|
geospatial_repo=geospatial_repo,
|
|
solar_fetcher=_UnusedSolarFetcher(),
|
|
).run([40, 41])
|
|
ModellingOrchestrator(
|
|
unit_of_work=unit_of_work,
|
|
calculator=Sap10Calculator(),
|
|
fuel_rates=FuelRatesStaticFileRepository(),
|
|
).run(property_ids=[40, 41], scenario_ids=[7], portfolio_id=1)
|
|
|
|
# Assert — a listed building blocks the fabric-protected measures: both
|
|
# solid-wall Options AND the ASHP bundle (all gated on `blocks_internal`,
|
|
# ADR-0024). So the listed dwelling gets neither, while the unrestricted one
|
|
# gets the ASHP bundle (which the efficient Vaillant now makes the Optimiser
|
|
# select — ADR-0025, so walls are no longer needed to reach the band). The
|
|
# only difference between them is the planning status Ingestion cached,
|
|
# proving the gate end to end (ADR-0019/0020/0024).
|
|
_PROTECTED_TYPES = {
|
|
"external_wall_insulation",
|
|
"internal_wall_insulation",
|
|
"air_source_heat_pump",
|
|
}
|
|
with Session(db_engine) as session:
|
|
listed_types = _plan_measure_types(session, property_id=40)
|
|
unrestricted_types = _plan_measure_types(session, property_id=41)
|
|
|
|
assert _PROTECTED_TYPES.isdisjoint(listed_types)
|
|
assert "air_source_heat_pump" in unrestricted_types
|
|
|
|
|
|
def _plan_measure_types(session: Session, *, property_id: int) -> set[str]:
|
|
plan = session.exec(
|
|
select(PlanModel).where(col(PlanModel.property_id) == property_id)
|
|
).first()
|
|
assert plan is not None
|
|
rec_rows = session.exec(
|
|
select(RecommendationModel).where(col(RecommendationModel.plan_id) == plan.id)
|
|
).all()
|
|
return {rec.type for rec in rec_rows}
|