diff --git a/backend/app/plan/schemas.py b/backend/app/plan/schemas.py index 36d847eb..5b59b699 100644 --- a/backend/app/plan/schemas.py +++ b/backend/app/plan/schemas.py @@ -11,13 +11,23 @@ TYPICAL_MEASURE_TYPES = [ WALL_INSULATION_MEASURES = ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"] ROOF_INSULATION_MEASURES = ["loft_insulation", "flat_roof_insulation", "room_roof_insulation"] -SPECIFIC_MEASURES = WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES + [ - "suspended_floor_insulation", "solid_floor_insulation", - "boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump", - "secondary_heating", "solar_pv", "double_glazing", "secondary_glazing", - "ventilation", "low_energy_lighting", "fireplace", "hot_water_tank_insulation", - "cylinder_thermostat" +# Both all and roof insulaiton measures are eligible for ECO4. These are the remaining fabric and heating measures +# This is based on th measures we have recommendations for +ECO4_ELIGIBILE_FABRIC_MEASURES = [ + "suspended_floor_insulation", "solid_floor_insulation", "double_glazing", "secondary_glazing" ] +ECO4_ELIGIBLE_HEATING_MEASURES = [ + "boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump", "solar_pv" +] + +SPECIFIC_MEASURES = ( + WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES + ECO4_ELIGIBILE_FABRIC_MEASURES + + ECO4_ELIGIBLE_HEATING_MEASURES + [ + "secondary_heating" "ventilation", "low_energy_lighting", "fireplace", + "hot_water_tank_insulation", + "cylinder_thermostat" + ] +) INSULATION_MEASURES = [ "internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation", diff --git a/recommendations/tests/test_optimisers.py b/recommendations/tests/test_optimisers.py index 252ad675..b6e9f5b6 100644 --- a/recommendations/tests/test_optimisers.py +++ b/recommendations/tests/test_optimisers.py @@ -5,6 +5,9 @@ from numpy import nan import datetime from copy import deepcopy +from app.plan.schemas import ( + WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, ECO4_ELIGIBILE_FABRIC_MEASURES, ECO4_ELIGIBLE_HEATING_MEASURES +) from recommendations.optimiser.CostOptimiser import CostOptimiser from recommendations.optimiser.GainOptimiser import GainOptimiser from backend.Funding import Funding @@ -472,7 +475,7 @@ def _find_measure(input_measures, measure_type): return False -def _make_generic_eco4_funding_paths(p, input_measures, funding_paths, remaining_insulation_type): +def _make_solar_heating_funding_paths(p, input_measures, funding_paths, remaining_insulation_type): # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Solar PV with existing eligible heating system # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -624,7 +627,7 @@ def make_funding_paths(p, input_measures, tenure): ): input_gbis_measures_innovation.append([measure]) - funding_paths = _make_generic_eco4_funding_paths( + funding_paths = _make_solar_heating_funding_paths( p, input_measures_innovation, funding_paths, remaining_insulation_type ) @@ -653,7 +656,7 @@ def make_funding_paths(p, input_measures, tenure): ewi_or_iwi[0]["reference"] = "+".join(reference_measures) + ":eco4" funding_paths.append(ewi_or_iwi) - funding_paths = _make_generic_eco4_funding_paths( + funding_paths = _make_solar_heating_funding_paths( p, input_measures, funding_paths, remaining_insulation_type ) @@ -758,6 +761,24 @@ def violates_min_insulation(fixed): return is_heating and not has_insul +# Treat "type" like "external_wall_insulation+mechanical_ventilation" → "external_wall_insulation" +def _base_type(s: str) -> str: + return s.split("+", 1)[0] + + +def _filter_measures_by_types(input_measures, allowed_types): + """ + Keep only groups that have ≥1 allowed option; inside each group keep only allowed options. + """ + allowed_set = set(allowed_types) + filtered = [] + for group in input_measures: + kept_opts = [opt for opt in group if _base_type(opt["type"]) in allowed_set] + if kept_opts: + filtered.append(kept_opts) + return filtered + + def optimise_with_funding_paths(input_measures, budget=None, target_gain=None, social=False): """ run_optimizer(sub_measures, budget, target_gain) -> (picked_options, sub_cost, sub_gain) @@ -765,8 +786,40 @@ def optimise_with_funding_paths(input_measures, budget=None, target_gain=None, s funding_paths = make_funding_paths(p, input_measures, body.housing_type) + # We now produce a fabric only path for ECO4 + # We add in generic insulation funding paths (where there is no fixed measure) + # Heating controls are only eligible if installed as part of a heating upgrade and so we do not include them + # here + allowed_types = WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES + ECO4_ELIGIBILE_FABRIC_MEASURES + funding_paths = [{'AND': [], 'reference': 'fabric-only:eco4'}] + funding_paths + solutions = [] for path_spec in funding_paths: + + if path_spec["reference"] == "fabric-only:eco4": + sub_measures = _filter_measures_by_types(input_measures, allowed_types) + if not sub_measures: + continue + + picked, sub_cost, sub_gain = run_optimizer( + sub_measures, + budget=budget, # no fixed items; budget unchanged + sub_target_gain=target_gain + ) + + if picked is None: + continue + + solutions.append( + { + "fixed_ids": [], + "items": picked, + "total_cost": sub_cost, + "total_gain": sub_gain, + "path": path_spec, + } + ) + # 1) expand fixed selections for this path fixed_selections = expand_funding_path(input_measures, path_spec) if path_spec else [[]] if not fixed_selections: @@ -1031,8 +1084,9 @@ def run_optimizer(input_measures, budget=None, sub_target_gain=None, allow_slack else: if sub_target_gain is None: raise ValueError("Either budget or target_gain must be provided.") - opt = CostOptimiser(input_measures, min_gain=sub_target_gain) + opt = CostOptimiser(sub_measures, min_gain=sub_target_gain) opt.setup() opt.solve() - return opt.solution, opt.solution_cost, opt.solution_gain + cost = sum([x["cost"] for x in opt.solution]) + return opt.solution, cost, opt.solution_gain