mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(modelling): surface the full candidate measure menu with per-measure cost
The run only showed the measures the Optimiser selected, so a candidate it passed over (e.g. an ASHP it found too costly for the target band) and that measure's cost were invisible. Add `harness.console.candidate_recommendations` — every Generator Option with its per-Option cost, before optimisation — and have run_modelling_e2e print the full menu per property (flagging the selected Options), write a "cost per measure" section into the markdown, and emit a per-Option modelling_e2e_candidates.csv. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5c19737fc5
commit
31ced27162
3 changed files with 144 additions and 3 deletions
|
|
@ -25,14 +25,19 @@ from domain.geospatial.coordinates import Coordinates
|
|||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.plan import Plan
|
||||
from domain.modelling.recommendation import Recommendation
|
||||
from domain.modelling.scenario import Scenario
|
||||
from domain.modelling.solar_potential import SolarPotential
|
||||
from domain.property.property import Property, PropertyIdentity
|
||||
from domain.property_baseline.rebaseliner import StubRebaseliner
|
||||
from domain.sap10_calculator.calculator import Sap10Calculator
|
||||
from harness.plan_table import format_plan_table
|
||||
from orchestration.ara_first_run_pipeline import AraFirstRunPipeline
|
||||
from orchestration.ingestion_orchestrator import IngestionOrchestrator
|
||||
from orchestration.modelling_orchestrator import ModellingOrchestrator
|
||||
from orchestration.modelling_orchestrator import (
|
||||
ModellingOrchestrator,
|
||||
_candidate_recommendations, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator
|
||||
from repositories.fuel_rates.fuel_rates_static_file_repository import (
|
||||
FuelRatesStaticFileRepository,
|
||||
|
|
@ -247,3 +252,31 @@ def run_modelling(
|
|||
if print_table:
|
||||
print("\n" + format_plan_table(plan))
|
||||
return plan
|
||||
|
||||
|
||||
def candidate_recommendations(
|
||||
epc: EpcPropertyData,
|
||||
*,
|
||||
catalogue_path: Path = DEFAULT_CATALOGUE,
|
||||
planning_restrictions: PlanningRestrictions = PlanningRestrictions(),
|
||||
solar_insights: Optional[dict[str, Any]] = None,
|
||||
considered_measures: Optional[frozenset[MeasureType]] = None,
|
||||
products: Optional[ProductRepository] = None,
|
||||
) -> list[Recommendation]:
|
||||
"""Every candidate Recommendation the Generators produce for ``epc`` — the
|
||||
full menu of Measure Options with their per-Option cost, *before* the
|
||||
Optimiser selects a Plan. Use this to inspect measures (and their cost) that
|
||||
a Plan does not end up selecting, e.g. an ASHP the Optimiser passed over for
|
||||
a cheaper route to the target band. Inputs mirror `run_modelling`."""
|
||||
solar_potential: Optional[SolarPotential] = (
|
||||
SolarPotential.from_building_insights(solar_insights)
|
||||
if solar_insights is not None and "solarPotential" in solar_insights
|
||||
else None
|
||||
)
|
||||
return _candidate_recommendations(
|
||||
epc,
|
||||
products or ProductJsonRepository(catalogue_path),
|
||||
planning_restrictions,
|
||||
solar_potential,
|
||||
considered_measures,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,8 +61,9 @@ from domain.geospatial.planning_restrictions import PlanningRestrictions # noqa
|
|||
from domain.geospatial.spatial_reference import SpatialReference # noqa: E402
|
||||
from domain.modelling.measure_type import MeasureType # noqa: E402
|
||||
from domain.modelling.plan import Plan, PlanMeasure # noqa: E402
|
||||
from domain.modelling.recommendation import Recommendation # noqa: E402
|
||||
from domain.modelling.scenario import Scenario # noqa: E402
|
||||
from harness.console import run_modelling # noqa: E402
|
||||
from harness.console import candidate_recommendations, run_modelling # noqa: E402
|
||||
from harness.plan_table import format_plan_table # noqa: E402
|
||||
from infrastructure.epc_client.epc_client_service import EpcClientService # noqa: E402
|
||||
from infrastructure.solar.google_solar_api_client import ( # noqa: E402
|
||||
|
|
@ -86,6 +87,7 @@ from sqlmodel import Session # noqa: E402
|
|||
_ENV_PATH = _REPO_ROOT / "backend" / ".env"
|
||||
_MARKDOWN_PATH = Path("modelling_e2e.md")
|
||||
_CSV_PATH = Path("modelling_e2e.csv")
|
||||
_CANDIDATES_CSV_PATH = Path("modelling_e2e_candidates.csv")
|
||||
|
||||
|
||||
def _load_env(path: Path) -> None:
|
||||
|
|
@ -236,6 +238,53 @@ def _measure_summary(measure: PlanMeasure) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _candidate_lines(
|
||||
recommendations: list[Recommendation], selected: set[MeasureType]
|
||||
) -> list[str]:
|
||||
"""Render every candidate Option (the full menu the Generators produced,
|
||||
not just the Plan the Optimiser selected) with its per-Option cost, flagging
|
||||
the Options that made it into the Plan — so measures the Optimiser passed
|
||||
over (e.g. an ASHP it found too costly for the target band) are visible."""
|
||||
lines: list[str] = []
|
||||
for recommendation in recommendations:
|
||||
for option in recommendation.options:
|
||||
cost = option.cost
|
||||
cost_note = (
|
||||
f"£{cost.total:,.0f} (+{cost.contingency_rate * 100:.0f}% cont.)"
|
||||
if cost is not None
|
||||
else "no cost"
|
||||
)
|
||||
flag = " ✓ SELECTED" if option.measure_type in selected else ""
|
||||
lines.append(
|
||||
f" [{recommendation.surface}] {option.measure_type} · "
|
||||
f"{cost_note}{flag} — {option.description}"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def _candidate_csv_rows(
|
||||
property_id: int,
|
||||
uprn: Optional[int],
|
||||
recommendations: list[Recommendation],
|
||||
selected: set[MeasureType],
|
||||
) -> list[str]:
|
||||
"""One CSV row per candidate Option: the full measure menu with cost,
|
||||
contingency, and whether the Optimiser selected it."""
|
||||
rows: list[str] = []
|
||||
for recommendation in recommendations:
|
||||
for option in recommendation.options:
|
||||
cost = option.cost
|
||||
total = f"{cost.total:.2f}" if cost is not None else ""
|
||||
contingency = f"{cost.contingency_rate:.4f}" if cost is not None else ""
|
||||
chosen = "yes" if option.measure_type in selected else "no"
|
||||
description = option.description.replace(",", ";")
|
||||
rows.append(
|
||||
f"{property_id},{uprn or ''},{recommendation.surface},"
|
||||
f"{option.measure_type},{total},{contingency},{chosen},{description}"
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _persist(
|
||||
engine: Engine,
|
||||
*,
|
||||
|
|
@ -358,6 +407,10 @@ def main() -> None:
|
|||
csv_rows: list[str] = [
|
||||
"property_id,uprn,baseline_sap,post_sap,measures,measure_types,cost_of_works"
|
||||
]
|
||||
candidate_csv_rows: list[str] = [
|
||||
"property_id,uprn,surface,measure_type,cost_total,contingency_rate,"
|
||||
"selected,description"
|
||||
]
|
||||
|
||||
for property_id in args.property_ids:
|
||||
uprn = uprns.get(property_id)
|
||||
|
|
@ -384,6 +437,15 @@ def main() -> None:
|
|||
scenario=scenario,
|
||||
print_table=False,
|
||||
)
|
||||
# The full candidate menu (every Generator Option + its cost), so
|
||||
# measures the Optimiser did not select are still visible.
|
||||
candidates: list[Recommendation] = candidate_recommendations(
|
||||
epc,
|
||||
planning_restrictions=restrictions,
|
||||
solar_insights=solar_insights,
|
||||
considered_measures=considered,
|
||||
products=products,
|
||||
)
|
||||
if args.persist:
|
||||
assert scenario is not None # guaranteed by the --persist guard
|
||||
_persist(
|
||||
|
|
@ -412,7 +474,9 @@ def main() -> None:
|
|||
continue
|
||||
|
||||
measure_types = [m.measure_type for m in plan.measures]
|
||||
selected: set[MeasureType] = {m.measure_type for m in plan.measures}
|
||||
context = _context_summary(spatial, solar_insights)
|
||||
candidate_lines = _candidate_lines(candidates, selected)
|
||||
header = (
|
||||
f"=== Property {property_id} (uprn {uprn}) === "
|
||||
f"SAP {plan.baseline.sap_continuous:.1f} -> {plan.post_sap_continuous:.1f} "
|
||||
|
|
@ -420,6 +484,9 @@ def main() -> None:
|
|||
)
|
||||
print(header)
|
||||
print(format_plan_table(plan))
|
||||
print(f" candidate measures considered ({len(candidate_lines)} option(s)):")
|
||||
for candidate_line in candidate_lines:
|
||||
print(candidate_line)
|
||||
print()
|
||||
|
||||
md_lines.append(f"## Property {property_id} (uprn {uprn})\n")
|
||||
|
|
@ -428,19 +495,30 @@ def main() -> None:
|
|||
f"· {len(plan.measures)} measure(s) · cost £{plan.cost_of_works:,.0f} "
|
||||
f"· {context}\n"
|
||||
)
|
||||
md_lines.append("**Selected Plan**\n")
|
||||
md_lines.extend(_measure_summary(m) for m in plan.measures)
|
||||
md_lines.append("")
|
||||
md_lines.append("**All candidate measures (cost per measure)**\n")
|
||||
md_lines.extend(candidate_lines)
|
||||
md_lines.append("")
|
||||
csv_rows.append(
|
||||
f"{property_id},{uprn},{plan.baseline.sap_continuous:.2f},"
|
||||
f"{plan.post_sap_continuous:.2f},{len(plan.measures)},"
|
||||
f"{'|'.join(measure_types)},{plan.cost_of_works:.0f}"
|
||||
)
|
||||
candidate_csv_rows.extend(
|
||||
_candidate_csv_rows(property_id, uprn, candidates, selected)
|
||||
)
|
||||
|
||||
catalogue_session.close()
|
||||
_MARKDOWN_PATH.write_text("\n".join(md_lines) + "\n", encoding="utf-8")
|
||||
_CSV_PATH.write_text("\n".join(csv_rows) + "\n", encoding="utf-8")
|
||||
_CANDIDATES_CSV_PATH.write_text(
|
||||
"\n".join(candidate_csv_rows) + "\n", encoding="utf-8"
|
||||
)
|
||||
print(f"wrote {_MARKDOWN_PATH.resolve()}")
|
||||
print(f"wrote {_CSV_PATH.resolve()}")
|
||||
print(f"wrote {_CANDIDATES_CSV_PATH.resolve()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ from domain.geospatial.planning_restrictions import PlanningRestrictions
|
|||
from domain.modelling.contingencies import contingency_rate
|
||||
from domain.modelling.generators.heating_recommendation import recommend_heating
|
||||
from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall
|
||||
from harness.console import DEFAULT_CATALOGUE, run_modelling, run_one
|
||||
from harness.console import (
|
||||
DEFAULT_CATALOGUE,
|
||||
candidate_recommendations,
|
||||
run_modelling,
|
||||
run_one,
|
||||
)
|
||||
from repositories.product.product_json_repository import ProductJsonRepository
|
||||
from tests.domain.modelling._elmhurst_recommendation import (
|
||||
parse_recommendation_summary,
|
||||
|
|
@ -224,6 +229,31 @@ def test_run_modelling_recommends_hhr_storage_for_an_electric_dwelling() -> None
|
|||
assert "air_source_heat_pump" in {m.measure_type for m in plan.measures}
|
||||
|
||||
|
||||
def test_candidate_recommendations_surface_unselected_options_with_cost() -> None:
|
||||
# Arrange — an electric dwelling whose heating Recommendation offers both an
|
||||
# ASHP and an HHR-storage bundle; the Optimiser selects only one of them.
|
||||
epc: EpcPropertyData = _electric_storage_lit_epc()
|
||||
|
||||
# Act — the full candidate menu (every Generator Option, pre-optimisation)
|
||||
# alongside the optimised Plan.
|
||||
candidates = candidate_recommendations(epc)
|
||||
plan = run_modelling(epc, goal_band="C", print_table=False)
|
||||
|
||||
# Assert — the menu carries every offered Option (so a measure the Plan did
|
||||
# not select, like the passed-over HHR bundle, is still inspectable), and
|
||||
# every Option carries a cost so "cost per measure" is always available.
|
||||
offered = {
|
||||
option.measure_type for rec in candidates for option in rec.options
|
||||
}
|
||||
selected = {measure.measure_type for measure in plan.measures}
|
||||
assert "high_heat_retention_storage_heaters" in offered
|
||||
assert "air_source_heat_pump" in offered
|
||||
assert offered - selected # at least one offered measure was not selected
|
||||
assert all(
|
||||
option.cost is not None for rec in candidates for option in rec.options
|
||||
)
|
||||
|
||||
|
||||
def test_sample_catalogue_prices_every_generator_measure_type() -> None:
|
||||
# Arrange — the default offline catalogue.
|
||||
products: ProductJsonRepository = ProductJsonRepository(DEFAULT_CATALOGUE)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue