test(modelling): pin least-cost-to-target end-to-end through the orchestrator

The orchestrator already threads budget/target_sap/dependencies into
optimise_package, so no orchestrator change was needed. Add an integration test
proving the new objective end-to-end on the real calculator: a band-D property
(~57.4) with a goal of band D — already met — yields a Plan with NO measures and
zero cost (the old max-gain objective would have recommended wall+floor+vent,
improving within the band it is already in). Clarified that the existing
multi-measure test now exercises the max-gain fallback (goal C unreachable from
D, tops out ~61). Narrowed Optional sap_points/estimated_cost through locals to
keep pyright strict-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 16:20:45 +00:00
parent af501fce0e
commit 641c1bd7f6

View file

@ -198,7 +198,10 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan(
# 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.
# 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(
@ -293,8 +296,103 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan(
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.
assert abs(by_type["mechanical_ventilation"].estimated_cost - 900.0) <= 1e-6
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).
assert by_type["cavity_wall_insulation"].sap_points > 0.0
assert by_type["mechanical_ventilation"].sap_points <= 0.0
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
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(
ScenarioRow(
id=8, goal="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=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",
),
]
)
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()
).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(PlanRow).where(col(PlanRow.property_id) == 31)
).first()
assert plan is not None
rec_rows = session.exec(
select(RecommendationRow).where(
col(RecommendationRow.plan_id) == plan.id
)
).all()
assert rec_rows == []
assert plan.cost_of_works == 0.0
assert plan.post_epc_rating is Epc.D