mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
handling fixed cost exceeding our budget, creating negative budget
This commit is contained in:
parent
2dda567e2d
commit
90fbc593f9
4 changed files with 181 additions and 16 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from backend.Property import Property
|
from backend.Property import Property
|
||||||
from typing import List, Mapping
|
from typing import List, Mapping, Any
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
from recommendations.FloorRecommendations import FloorRecommendations
|
from recommendations.FloorRecommendations import FloorRecommendations
|
||||||
from recommendations.WallRecommendations import WallRecommendations
|
from recommendations.WallRecommendations import WallRecommendations
|
||||||
|
|
@ -499,23 +499,26 @@ class Recommendations:
|
||||||
return predicted_appliances_cost_reduction, predicted_appliances_kwh_reduction
|
return predicted_appliances_cost_reduction, predicted_appliances_kwh_reduction
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _check_veniltation_out_of_bounds(sap_impact, ventilation_sap_limit):
|
def _check_ventilation_out_of_bounds(sap_impact, ventilation_sap_limit):
|
||||||
return (sap_impact < ventilation_sap_limit) or (sap_impact >= 0)
|
return (sap_impact < ventilation_sap_limit) or (sap_impact >= 0)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _adjust_ventilation_sap(sap_impact, ventilation_sap_limit):
|
def _adjust_ventilation_sap(sap_impact, ventilation_sap_limit):
|
||||||
|
|
||||||
if sap_impact >= 0:
|
if sap_impact >= 0:
|
||||||
return -1
|
return -1
|
||||||
if sap_impact < ventilation_sap_limit:
|
if sap_impact < ventilation_sap_limit:
|
||||||
return ventilation_sap_limit
|
return ventilation_sap_limit
|
||||||
|
|
||||||
|
return sap_impact
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _filter_phase_adjustment(phase_adjustments):
|
def _filter_phase_adjustment(phase_adjustments):
|
||||||
"""
|
"""
|
||||||
Utility function to select the entry from the dictionary, by phase, with the largest
|
Utility function to select the entry from the dictionary, by phase, with the largest
|
||||||
phase adjustment
|
phase adjustment
|
||||||
:param phase_adjustments: List of phase adjustments, in the form
|
:param phase_adjustments: List of phase adjustments, in the form
|
||||||
[{"recommendation_id": str, "phase": int, "adjustment_amount": float}]
|
[{"recommendation_id": str, "phase": int, "sap_adjustment": float}]
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
filtered_adjustments = []
|
filtered_adjustments = []
|
||||||
|
|
@ -583,6 +586,15 @@ class Recommendations:
|
||||||
if len(previous_phase_reps) == 1:
|
if len(previous_phase_reps) == 1:
|
||||||
return previous_phase_reps[0]
|
return previous_phase_reps[0]
|
||||||
|
|
||||||
|
# It's unlikely that this will occur but this fallback will ensure that we don't
|
||||||
|
# run the next step and run a median of nothing, which will return None
|
||||||
|
if not previous_phase_reps:
|
||||||
|
return {
|
||||||
|
"sap": float(property_instance.data["current-energy-efficiency"]),
|
||||||
|
"carbon": float(property_instance.data["co2-emissions-current"]),
|
||||||
|
"heat_demand": float(property_instance.data["energy-consumption-current"]),
|
||||||
|
}
|
||||||
|
|
||||||
# Median fallback (including zero-length case)
|
# Median fallback (including zero-length case)
|
||||||
keys = ("sap", "carbon", "heat_demand")
|
keys = ("sap", "carbon", "heat_demand")
|
||||||
return {
|
return {
|
||||||
|
|
@ -612,9 +624,9 @@ class Recommendations:
|
||||||
@classmethod
|
@classmethod
|
||||||
def _resolve_current_phase_sap(
|
def _resolve_current_phase_sap(
|
||||||
cls,
|
cls,
|
||||||
rec: Mapping[str, any],
|
rec: Mapping[str, Any],
|
||||||
previous_phase_values: Mapping[str, any],
|
previous_phase_values: Mapping[str, Any],
|
||||||
phase_energy_efficiency_metrics: Mapping[str, any],
|
phase_energy_efficiency_metrics: Mapping[str, Any],
|
||||||
adjustments: list[dict],
|
adjustments: list[dict],
|
||||||
) -> float:
|
) -> float:
|
||||||
if rec.get("survey", False):
|
if rec.get("survey", False):
|
||||||
|
|
@ -713,14 +725,14 @@ class Recommendations:
|
||||||
elif rec["type"] == "mechanical_ventilation":
|
elif rec["type"] == "mechanical_ventilation":
|
||||||
# ventilation is capped by having no greater and a -4 impact
|
# ventilation is capped by having no greater and a -4 impact
|
||||||
ventilation_sap_limit = -4
|
ventilation_sap_limit = -4
|
||||||
ventilation_out_of_bounds = cls._check_veniltation_out_of_bounds(
|
ventilation_out_of_bounds = cls._check_ventilation_out_of_bounds(
|
||||||
property_phase_impact["sap"], ventilation_sap_limit
|
property_phase_impact["sap"], ventilation_sap_limit
|
||||||
)
|
)
|
||||||
|
|
||||||
if ventilation_out_of_bounds:
|
if ventilation_out_of_bounds:
|
||||||
previous_modelled_sap = previous_phase_values.get("sap_prediction", 0)
|
previous_modelled_sap = previous_phase_values.get("sap_prediction", 0)
|
||||||
proposed_sap_impact = current_phase_values["sap"] - previous_modelled_sap
|
proposed_sap_impact = current_phase_values["sap"] - previous_modelled_sap
|
||||||
proposal_out_of_bounds = cls._check_veniltation_out_of_bounds(
|
proposal_out_of_bounds = cls._check_ventilation_out_of_bounds(
|
||||||
proposed_sap_impact, ventilation_sap_limit
|
proposed_sap_impact, ventilation_sap_limit
|
||||||
)
|
)
|
||||||
if proposal_out_of_bounds:
|
if proposal_out_of_bounds:
|
||||||
|
|
@ -805,7 +817,7 @@ class Recommendations:
|
||||||
return property_phase_impact, current_phase_values, adjustments
|
return property_phase_impact, current_phase_values, adjustments
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_recommendation_updates(rec: Mapping[str, any]):
|
def _validate_recommendation_updates(rec: Mapping[str, Any]):
|
||||||
"""
|
"""
|
||||||
Utility function to validate that the recommendation updates have been applied correctly
|
Utility function to validate that the recommendation updates have been applied correctly
|
||||||
:param rec: updated recommendation
|
:param rec: updated recommendation
|
||||||
|
|
@ -821,11 +833,11 @@ class Recommendations:
|
||||||
def calculate_recommendation_impact(
|
def calculate_recommendation_impact(
|
||||||
cls,
|
cls,
|
||||||
property_instance: Property,
|
property_instance: Property,
|
||||||
all_predictions: Mapping[str, any],
|
all_predictions: Mapping[str, Any],
|
||||||
recommendations: Mapping[int, List],
|
recommendations: Mapping[int, List],
|
||||||
representative_recommendations: Mapping[int, List],
|
representative_recommendations: Mapping[int, List],
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
) -> (Mapping[int, List], List[Mapping[str, any]]):
|
) -> (Mapping[int, List], List[Mapping[str, Any]]):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Given predictions from the model apis, with method will update the recommendations with the predicted
|
Given predictions from the model apis, with method will update the recommendations with the predicted
|
||||||
|
|
|
||||||
|
|
@ -711,9 +711,12 @@ def optimise_with_scenarios(
|
||||||
if kept:
|
if kept:
|
||||||
remaining_measures.append(kept)
|
remaining_measures.append(kept)
|
||||||
|
|
||||||
|
remaining_budget = budget - fabric_cost if budget is not None else None
|
||||||
|
remaining_budget = 0 if remaining_budget < 0 else remaining_budget
|
||||||
|
|
||||||
picked_extra, extra_cost, extra_gain = run_optimizer(
|
picked_extra, extra_cost, extra_gain = run_optimizer(
|
||||||
remaining_measures,
|
remaining_measures,
|
||||||
budget=budget - fabric_cost if budget is not None else None,
|
budget=remaining_budget,
|
||||||
sub_target_gain=(
|
sub_target_gain=(
|
||||||
target_gain - fabric_gain
|
target_gain - fabric_gain
|
||||||
if target_gain is not None
|
if target_gain is not None
|
||||||
|
|
@ -769,6 +772,12 @@ def optimise_with_scenarios(
|
||||||
|
|
||||||
fixed_cost, fixed_gain = sum_cost_gain(fixed_items)
|
fixed_cost, fixed_gain = sum_cost_gain(fixed_items)
|
||||||
|
|
||||||
|
if budget is not None:
|
||||||
|
# If we have a budget, we cannot exceed it via our fixed cost. If we do,
|
||||||
|
# this is not a viable solution
|
||||||
|
if fixed_cost > budget:
|
||||||
|
continue
|
||||||
|
|
||||||
# Remaining measures (all other groups)
|
# Remaining measures (all other groups)
|
||||||
remaining_measures = [
|
remaining_measures = [
|
||||||
grp for gi, grp in enumerate(optimisation_measures)
|
grp for gi, grp in enumerate(optimisation_measures)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import pytest
|
import pytest
|
||||||
import datetime
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pandas import Timestamp
|
|
||||||
from numpy import nan
|
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from recommendations.Recommendations import Recommendations
|
from recommendations.Recommendations import Recommendations
|
||||||
|
|
@ -372,6 +369,153 @@ def test_filter_phase_adjustment(input_data, expected):
|
||||||
assert Recommendations._filter_phase_adjustment(input_data) == expected
|
assert Recommendations._filter_phase_adjustment(input_data) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sap_impact, limit, expected",
|
||||||
|
[
|
||||||
|
(1.0, -4, True), # positive SAP not allowed
|
||||||
|
(0.0, -4, True), # zero not allowed
|
||||||
|
(-1.0, -4, False), # valid range
|
||||||
|
(-3.9, -4, False), # valid range
|
||||||
|
(-4.0, -4, False), # exact lower bound allowed
|
||||||
|
(-4.1, -4, True), # below lower bound
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_check_ventilation_out_of_bounds(sap_impact, limit, expected):
|
||||||
|
assert Recommendations._check_ventilation_out_of_bounds(
|
||||||
|
sap_impact, limit
|
||||||
|
) is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sap_impact, limit, expected",
|
||||||
|
[
|
||||||
|
(1.2, -4, -1), # positive → capped to -1
|
||||||
|
(0.0, -4, -1), # zero → capped to -1
|
||||||
|
(-5.0, -4, -4), # below limit → clamp
|
||||||
|
(-3.0, -4, -3.0), # already valid → unchanged
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_adjust_ventilation_sap(sap_impact, limit, expected):
|
||||||
|
assert Recommendations._adjust_ventilation_sap(
|
||||||
|
sap_impact, limit
|
||||||
|
) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_previous_phase_values_starting_phase(property_instance):
|
||||||
|
result = Recommendations._get_previous_phase_values(
|
||||||
|
rec_phase=0,
|
||||||
|
starting_phase=0,
|
||||||
|
impact_summary=[],
|
||||||
|
property_instance=property_instance,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"sap": 65.0,
|
||||||
|
"carbon": 2.4,
|
||||||
|
"heat_demand": 284.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_previous_phase_values_single_rep(property_instance):
|
||||||
|
impact_summary = [
|
||||||
|
{
|
||||||
|
"phase": 0,
|
||||||
|
"representative": True,
|
||||||
|
"sap": 66,
|
||||||
|
"carbon": 2.2,
|
||||||
|
"heat_demand": 260,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = Recommendations._get_previous_phase_values(
|
||||||
|
rec_phase=1,
|
||||||
|
starting_phase=0,
|
||||||
|
impact_summary=impact_summary,
|
||||||
|
property_instance=property_instance,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["sap"] == 66
|
||||||
|
assert result["carbon"] == 2.2
|
||||||
|
assert result["heat_demand"] == 260
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_previous_phase_values_median(property_instance):
|
||||||
|
impact_summary = [
|
||||||
|
{"phase": 1, "representative": True, "sap": 70, "carbon": 2.0, "heat_demand": 250},
|
||||||
|
{"phase": 1, "representative": True, "sap": 74, "carbon": 1.6, "heat_demand": 230},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = Recommendations._get_previous_phase_values(
|
||||||
|
rec_phase=2,
|
||||||
|
starting_phase=0,
|
||||||
|
impact_summary=impact_summary,
|
||||||
|
property_instance=property_instance,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["sap"] == np.median([70, 74])
|
||||||
|
assert result["carbon"] == np.median([2.0, 1.6])
|
||||||
|
assert result["heat_demand"] == np.median([250, 230])
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_phase_impact_standard():
|
||||||
|
previous = {"sap": 65, "carbon": 2.4, "heat_demand": 284}
|
||||||
|
current = {"sap": 64, "carbon": 2.6, "heat_demand": 300}
|
||||||
|
|
||||||
|
impact = Recommendations._compute_phase_impact(
|
||||||
|
rec_type="loft_insulation",
|
||||||
|
previous_phase_values=previous,
|
||||||
|
current_phase_values=current,
|
||||||
|
)
|
||||||
|
|
||||||
|
# monotonicity enforced
|
||||||
|
assert impact["sap"] == 0
|
||||||
|
assert impact["carbon"] == 0
|
||||||
|
assert impact["heat_demand"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_phase_impact_mechanical_ventilation():
|
||||||
|
previous = {"sap": 65, "carbon": 2.4, "heat_demand": 284}
|
||||||
|
current = {"sap": 63, "carbon": 2.4, "heat_demand": 284}
|
||||||
|
|
||||||
|
impact = Recommendations._compute_phase_impact(
|
||||||
|
rec_type="mechanical_ventilation",
|
||||||
|
previous_phase_values=previous,
|
||||||
|
current_phase_values=current,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert impact["sap"] == -2
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_current_phase_sap_with_adjustments():
|
||||||
|
rec = {"phase": 3, "survey": False}
|
||||||
|
previous = {"sap": 65}
|
||||||
|
phase_metrics = {"sap_change": 70}
|
||||||
|
adjustments = [
|
||||||
|
{"phase": 1, "sap_adjustment": 1.5},
|
||||||
|
{"phase": 2, "sap_adjustment": 2.0},
|
||||||
|
]
|
||||||
|
|
||||||
|
sap = Recommendations._resolve_current_phase_sap(
|
||||||
|
rec=rec,
|
||||||
|
previous_phase_values=previous,
|
||||||
|
phase_energy_efficiency_metrics=phase_metrics,
|
||||||
|
adjustments=adjustments,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sap == 70 - (1.5 + 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_recommendation_updates_raises():
|
||||||
|
rec = {
|
||||||
|
"sap_points": None,
|
||||||
|
"co2_equivalent_savings": None,
|
||||||
|
"heat_demand": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Recommendations._validate_recommendation_updates(rec)
|
||||||
|
|
||||||
|
|
||||||
def test_calculate_recommendation_impact(property_instance, heat_demand_predictions, carbon_predictions):
|
def test_calculate_recommendation_impact(property_instance, heat_demand_predictions, carbon_predictions):
|
||||||
#######
|
#######
|
||||||
# Case 3
|
# Case 3
|
||||||
|
|
|
||||||
2
tox.ini
2
tox.ini
|
|
@ -9,5 +9,5 @@ deps =
|
||||||
-rbackend/engine/requirements.txt
|
-rbackend/engine/requirements.txt
|
||||||
-rbackend/app/requirements/requirements.txt
|
-rbackend/app/requirements/requirements.txt
|
||||||
-rtest.requirements.txt
|
-rtest.requirements.txt
|
||||||
commands = pytest
|
commands = pytest {posargs}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue