Merge pull request #745 from Hestia-Homes/bug/incorrect-secondary-heating

Handling cases where ventilation sap is zero
This commit is contained in:
Jun-te Kim 2026-02-24 10:55:13 +00:00 committed by GitHub
commit 90af1b156e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 231 additions and 14 deletions

View file

@ -490,7 +490,7 @@ class Property:
for rec_id in rec_ids:
sim_epc = self.simulation_epcs[rec_id].copy()
rec_impact = [x for x in impact_summary if x["recommendation_id"] == rec_id][0]
# We update all of the features that should have an impact on the kwh model
# We update all features that should have an impact on the kwh model
sim_epc.update(
{

View file

@ -499,8 +499,16 @@ class Recommendations:
return predicted_appliances_cost_reduction, predicted_appliances_kwh_reduction
@staticmethod
def _check_ventilation_out_of_bounds(sap_impact, ventilation_sap_limit):
return (sap_impact < ventilation_sap_limit) or (sap_impact >= 0)
def _check_ventilation_out_of_bounds(sap_impact: float, ventilation_sap_limit: float) -> bool:
"""
Checks if the SAP impact of a ventilation recommendation is out of bounds, which would indicate that the
recommendation is not appropriate.
:param sap_impact: The SAP impact of the ventilation recommendation, which is typically negative or zero
:param ventilation_sap_limit: The SAP limit for ventilation recommendations, which is typically a negative
number. E.g. -4
:return:
"""
return (sap_impact < ventilation_sap_limit) or (sap_impact > 0)
@staticmethod
def _adjust_ventilation_sap(sap_impact, ventilation_sap_limit):
@ -691,7 +699,8 @@ class Recommendations:
previous_phase_values: dict,
current_phase_values: dict,
adjustments: list,
property_instance,
property_instance: Property,
model_predicted_sap: float,
):
# For the moment, we cap the number of SAP points that can be achieved by LEDs at 2
if rec["type"] == "low_energy_lighting":
@ -785,7 +794,6 @@ class Recommendations:
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
elif rec["type"] == "loft_insulation":
# When we have a loft insulation recommendation, where there is an extension and the existing
# amount of loft insulation is already good, we limit the SAP points
@ -831,6 +839,27 @@ class Recommendations:
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
elif rec["measure_type"] in ["roomstat_programmer_trvs", "time_temperature_zone_control"]:
# We trim the SAP point recommendations based on the minimum of the predicted and the survey SAP
# points
predicted_difference = model_predicted_sap - previous_phase_values["sap_prediction"]
proposed_impact = property_phase_impact["sap"]
numerically_the_same = np.isclose(proposed_impact, predicted_difference)
if predicted_difference > 0 and (predicted_difference < proposed_impact) and not numerically_the_same:
# We constrain the impact based on what the model predicts.
# We update the proposed impact to be the predicted difference
adjustments.append(
{
"recommendation_id": rec["recommendation_id"],
"phase": rec["phase"],
# If we've made an adjustment, it will be negative
"sap_adjustment": property_phase_impact["sap"] - predicted_difference,
}
)
property_phase_impact["sap"] = predicted_difference
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
return property_phase_impact, current_phase_values, adjustments
@ -963,7 +992,8 @@ class Recommendations:
previous_phase_values=previous_phase_values,
current_phase_values=current_phase_values,
adjustments=adjustments,
property_instance=property_instance
property_instance=property_instance,
model_predicted_sap=phase_energy_efficiency_metrics["sap_change"],
)
# Insert this information into the recommendation.

View file

@ -18,6 +18,9 @@ class SecondaryHeating:
def recommend(self, phase: int):
# Reset
self.recommendation = []
if self.property.epc_record.secondheat_description in ["None", None]:
# No secondary heating system, so no recommendation to remove it
return
if self.property.data['number-habitable-rooms'] > self.property.data['number-heated-rooms']:
n_rooms = self.property.data['number-habitable-rooms'] - self.property.data['number-heated-rooms']

View file

@ -79,14 +79,14 @@ def prepare_input_measures(
# if recs[0]["type"] == "solar_pv":
# recs = [r for r in recs if ~r["has_battery"]]
# Only include measures with non-negative cost savings
# Only include measures with non-negative cost savings - we allow for a minor negative impact
if eco_measures:
recs_to_append = [
rec for rec in recs if (rec["energy_cost_savings"] >= 0) or (rec["measure_type"] in eco_measures)
rec for rec in recs if (rec["energy_cost_savings"] >= -10) or (rec["measure_type"] in eco_measures)
]
else:
recs_to_append = [
rec for rec in recs if (rec["energy_cost_savings"] >= 0)
rec for rec in recs if (rec["energy_cost_savings"] >= -10)
]
if not recs_to_append:
continue

View file

@ -373,7 +373,7 @@ def test_filter_phase_adjustment(input_data, expected):
"sap_impact, limit, expected",
[
(1.0, -4, True), # positive SAP not allowed
(0.0, -4, True), # zero not allowed
(0.0, -4, False), # zero is allowed
(-1.0, -4, False), # valid range
(-3.9, -4, False), # valid range
(-4.0, -4, False), # exact lower bound allowed
@ -1476,7 +1476,9 @@ def test_lighting_and_loft_adjustment_combined(property_instance, heat_demand_pr
assert adjustments2 == [
{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)},
{'recommendation_id': '4_phase=2', 'phase': 2, 'sap_adjustment': np.float64(4.0)}
{'recommendation_id': '4_phase=2', 'phase': 2, 'sap_adjustment': np.float64(4.0)},
{'recommendation_id': '5_phase=3', 'phase': 3, 'sap_adjustment': np.float64(1.0)},
{'recommendation_id': '6_phase=3', 'phase': 3, 'sap_adjustment': np.float64(1.0000000000000027)}
]
@ -1499,7 +1501,8 @@ def test_mechanical_ventilation_sap_floor(property_instance):
previous_phase_values=previous_phase_values,
current_phase_values=current_phase_values,
adjustments=adjustments,
property_instance=property_instance
property_instance=property_instance,
model_predicted_sap=0
)
)
@ -1538,7 +1541,8 @@ def test_mechanical_ventilation_no_floor_adjustment(property_instance):
previous_phase_values=previous_phase_values,
current_phase_values=current_phase_values,
adjustments=adjustments,
property_instance=property_instance
property_instance=property_instance,
model_predicted_sap=0
)
)
@ -1570,7 +1574,8 @@ def test_mechanical_ventilation_exactly_one_no_adjustment(property_instance):
previous_phase_values=previous_phase_values,
current_phase_values=current_phase_values,
adjustments=adjustments,
property_instance=property_instance
property_instance=property_instance,
model_predicted_sap=0
)
)
@ -1578,3 +1583,182 @@ def test_mechanical_ventilation_exactly_one_no_adjustment(property_instance):
assert updated_adjustments == []
assert updated_current["sap"] == 1.0
assert updated_impact["sap"] == -1.0
def test_mechanical_ventilation_sap_zero_no_adjustment(property_instance):
# Test when SAP = 0
rec = {
"type": "mechanical_ventilation",
"recommendation_id": "mv_test",
"phase": 1,
}
previous_phase_values = {'phase': 0, 'representative': True, 'recommendation_id': '0_phase=0',
'measure_type': 'flat_roof_insulation', 'sap': 68.0, 'carbon': np.float64(0.5),
'heat_demand': np.float64(300.1), 'sap_prediction': np.float64(71.7)}
current_phase_values = {'sap': 68.0, 'carbon': np.float64(0.5), 'heat_demand': np.float64(307.0)}
property_phase_impact = {'sap': 0, 'carbon': 0, 'heat_demand': np.float64(-6.899999999999977)}
adjustments = []
updated_impact, updated_current, updated_adjustments = (
Recommendations._apply_measure_specific_rules(
rec=rec,
property_phase_impact=property_phase_impact,
previous_phase_values=previous_phase_values,
current_phase_values=current_phase_values,
adjustments=adjustments,
property_instance=property_instance,
model_predicted_sap=0
)
)
# SAP is already at 0 → no adjustment expected
assert updated_adjustments == []
assert updated_current["sap"] == 68.0
assert updated_impact["sap"] == 0
def test_mv_valid_negative_no_adjustment(property_instance):
rec = {"type": "mechanical_ventilation", "recommendation_id": "mv", "phase": 1}
previous = {"sap": 70.0}
current = {"sap": 67.0}
impact = {"sap": -3.0, "carbon": 0, "heat_demand": 0}
adjustments = []
updated_impact, updated_current, updated_adjustments = (
Recommendations._apply_measure_specific_rules(
rec, impact, previous, current, adjustments, property_instance, 0
)
)
assert updated_adjustments == []
assert updated_current["sap"] == 67.0
assert updated_impact["sap"] == -3.0
def test_mv_zero_impact_allowed(property_instance):
rec = {"type": "mechanical_ventilation", "recommendation_id": "mv", "phase": 1}
previous = {"sap": 68.0, "sap_prediction": 71.7}
current = {"sap": 68.0}
impact = {"sap": 0.0, "carbon": 0, "heat_demand": 0}
adjustments = []
updated_impact, updated_current, updated_adjustments = (
Recommendations._apply_measure_specific_rules(
rec, impact, previous, current, adjustments, property_instance, 0
)
)
assert updated_adjustments == []
assert updated_current["sap"] == 68.0
assert updated_impact["sap"] == 0.0
def test_mv_positive_impact_corrected(property_instance):
rec = {"type": "mechanical_ventilation", "recommendation_id": "mv", "phase": 1}
previous = {"sap": 60.0}
current = {"sap": 61.0}
impact = {"sap": 1.0, "carbon": 0, "heat_demand": 0}
adjustments = []
updated_impact, updated_current, updated_adjustments = (
Recommendations._apply_measure_specific_rules(
rec, impact, previous, current, adjustments, property_instance, 0
)
)
assert len(updated_adjustments) == 1
assert updated_current["sap"] == previous["sap"] + updated_impact["sap"]
assert updated_impact["sap"] <= 0
def test_mv_below_lower_bound_corrected(property_instance):
rec = {"type": "mechanical_ventilation", "recommendation_id": "mv", "phase": 1}
previous = {"sap": 70.0}
current = {"sap": 64.0}
impact = {"sap": -6.0, "carbon": 0, "heat_demand": 0}
adjustments = []
updated_impact, updated_current, updated_adjustments = (
Recommendations._apply_measure_specific_rules(
rec, impact, previous, current, adjustments, property_instance, 0
)
)
assert len(updated_adjustments) == 1
assert updated_impact["sap"] >= -4
def test_mv_floor_triggered(property_instance):
rec = {"type": "mechanical_ventilation", "recommendation_id": "mv", "phase": 1}
previous = {"sap": 2.0}
current = {"sap": 0.5}
impact = {"sap": -1.5, "carbon": 0, "heat_demand": 0}
adjustments = []
updated_impact, updated_current, updated_adjustments = (
Recommendations._apply_measure_specific_rules(
rec, impact, previous, current, adjustments, property_instance, 0
)
)
assert updated_current["sap"] == 1.0
assert updated_adjustments[0]["sap_adjustment"] > 0
def test_mv_exactly_one_no_floor(property_instance):
rec = {"type": "mechanical_ventilation", "recommendation_id": "mv", "phase": 1}
previous = {"sap": 2.0}
current = {"sap": 1.0}
impact = {"sap": -1.0, "carbon": 0, "heat_demand": 0}
adjustments = []
updated_impact, updated_current, updated_adjustments = (
Recommendations._apply_measure_specific_rules(
rec, impact, previous, current, adjustments, property_instance, 0
)
)
assert updated_adjustments == []
assert updated_current["sap"] == 1.0
def test_lighting_no_cap(property_instance):
rec = {"type": "low_energy_lighting", "recommendation_id": "led", "phase": 1,
"co2_equivalent_savings": 0}
previous = {"sap": 60.0, "carbon": 2.0}
current = {"sap": 61.0, "carbon": 2.0}
impact = {"sap": 1.0, "carbon": 0, "heat_demand": 0}
adjustments = []
updated_impact, updated_current, updated_adjustments = (
Recommendations._apply_measure_specific_rules(
rec, impact, previous, current, adjustments, property_instance, 0
)
)
assert updated_adjustments == []
def test_filter_phase_adjustments():
example_adjustments = [
{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)},
{'recommendation_id': '4_phase=2', 'phase': 2, 'sap_adjustment': np.float64(4.0)},
{'recommendation_id': '5_phase=3', 'phase': 3, 'sap_adjustment': np.float64(1.0)},
{'recommendation_id': '6_phase=3', 'phase': 3, 'sap_adjustment': np.float64(1.0000000000000027)}
]
res = Recommendations._filter_phase_adjustment(example_adjustments)
assert res == [
{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)},
{'recommendation_id': '4_phase=2', 'phase': 2, 'sap_adjustment': np.float64(4.0)},
{'recommendation_id': '6_phase=3', 'phase': 3, 'sap_adjustment': np.float64(1.0000000000000027)}
]