From 67f701273e3594e93c4e309ea2cd04b3c8b63daf Mon Sep 17 00:00:00 2001 From: Philipp Nimphius Date: Wed, 20 May 2026 15:48:34 +0200 Subject: [PATCH 1/2] Fix #113: Prevent runtime warnings and NaNs in KPI cover factor calculations Replaced direct division with `np.divide` to safely handle scenarios where the denominator for demand or supply cover factors is zero. This prevents `RuntimeWarning` and `NaN` values, ensuring defined outputs (1 for demand cover factor, 0 for supply cover factor) in these edge cases. --- districtgenerator/classes/KPIs.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/districtgenerator/classes/KPIs.py b/districtgenerator/classes/KPIs.py index 50b79fb..ce2a9bc 100644 --- a/districtgenerator/classes/KPIs.py +++ b/districtgenerator/classes/KPIs.py @@ -340,8 +340,19 @@ def calculateCoverFactors(self, data): nenner_sup[c, t] += b min[c, t] = np.min([a, b]) - self.demandCoverFactor[year][c] = np.sum(min[c, :]) / np.sum(nenner_dem[c, :]) - self.supplyCoverFactor[year][c] = np.sum(min[c, :]) / np.sum(nenner_sup[c, :]) + sum_min = np.sum(min[c, :]) + sum_dem = np.sum(nenner_dem[c, :]) + sum_sup = np.sum(nenner_sup[c, :]) + + + self.demandCoverFactor[year][c] = np.divide( + sum_min, sum_dem, + out=np.ones_like(sum_min), where=(sum_dem != 0) + ) + self.supplyCoverFactor[year][c] = np.divide( + sum_min, sum_sup, + out=np.zeros_like(sum_min), where=(sum_sup != 0) + ) # Calculate weighted average over all years From 44eb5ec45d4691fe8495dbfb8e28beb549ed02c5 Mon Sep 17 00:00:00 2001 From: Philipp Nimphius Date: Wed, 20 May 2026 17:36:59 +0200 Subject: [PATCH 2/2] Improve annual SCF and DCF calculation and integrate energy hub The calculation of annual demand and supply cover factors (dcf_year, scf_year) has been reworked. Instead of averaging cluster-level cover factors, the new approach aggregates weighted shared energy, total demand, and total supply across all clusters for a given year before calculating the overall annual factors. Additionally, residual load and injection from the energy hub is now included in the SCF and DCF calculations. --- districtgenerator/classes/KPIs.py | 53 ++++++++++++++------- districtgenerator/functions/opti_central.py | 7 +++ 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/districtgenerator/classes/KPIs.py b/districtgenerator/classes/KPIs.py index ce2a9bc..2bb4b38 100644 --- a/districtgenerator/classes/KPIs.py +++ b/districtgenerator/classes/KPIs.py @@ -313,6 +313,13 @@ def calculateCoverFactors(self, data): self.supplyCoverFactor = {} self.demandCoverFactor = {} + self.dcf_year = {} + self.scf_year = {} + + sum_ClusterWeights = sum(self.inputData["clusterWeights"][self.inputData["clusters"][c]] + for c in range(len(self.inputData["clusters"]))) + + for year in self.inputData["simulated_years"]: self.supplyCoverFactor[year] = np.zeros(len(self.inputData["clusters"])) self.demandCoverFactor[year] = np.zeros(len(self.inputData["clusters"])) @@ -322,7 +329,12 @@ def calculateCoverFactors(self, data): nenner_sup = np.zeros([len(self.inputData["clusters"]), len(data.district[0]["user"].elec_cluster[0])], dtype=float) nenner_dem = np.zeros([len(self.inputData["clusters"]), len(data.district[0]["user"].elec_cluster[0])], dtype=float) + total_weighted_shared = 0.0 + total_weighted_demand = 0.0 + total_weighted_supply = 0.0 + for c in range(len(self.inputData["clusters"])): + cluster_weight = self.inputData["clusterWeights"][self.inputData["clusters"][c]] for t in range(len(data.district[0]["user"].elec_cluster[0])): a = 0 b = 0 @@ -331,13 +343,18 @@ def calculateCoverFactors(self, data): idx = data.building_dict[int(bldg_id)] a += self.inputData["resultsOptimization"][year][c][idx]["res_load"][t] b += self.inputData["resultsOptimization"][year][c][idx]["res_inj"][t] + + # Energy Hub + a += self.inputData["resultsOptimization"][year][c]["eh_res_load"][t] + b += self.inputData["resultsOptimization"][year][c]["eh_res_inj"][t] + # At the same time step t, either res_load or res_inj should be 0. # However, a and b could both be greater than 0 at the same time step t, # since they represent the sums of all the buildings. # If both a and b are greater than 0, it means electricity is being transported from one building to another. # sum of all timesteps - nenner_dem[c, t] += a - nenner_sup[c, t] += b + nenner_dem[c, t] = a + nenner_sup[c, t] = b min[c, t] = np.min([a, b]) sum_min = np.sum(min[c, :]) @@ -354,21 +371,25 @@ def calculateCoverFactors(self, data): out=np.zeros_like(sum_min), where=(sum_sup != 0) ) - # Calculate weighted average over all years - - self.dcf_year = {} - self.scf_year = {} - - sum_ClusterWeights = sum(self.inputData["clusterWeights"][self.inputData["clusters"][c]] - for c in range(len(self.inputData["clusters"]))) + # Weighted Energy Exchange within the neighborhood accumulated across all clusters for each year + weight_norm = cluster_weight / sum_ClusterWeights + total_weighted_shared += sum_min * weight_norm + total_weighted_demand += sum_dem * weight_norm + total_weighted_supply += sum_sup * weight_norm + + + # Calculate the weighted average of the cover factors across clusters for each year + self.dcf_year[year] = ( + total_weighted_shared / total_weighted_demand + if total_weighted_demand != 0 else 1.0 + ) + + self.scf_year[year] = ( + total_weighted_shared / total_weighted_supply + if total_weighted_supply != 0 else 0.0 + ) - for year in self.inputData["simulated_years"]: - self.dcf_year[year] = 0 - self.scf_year[year] = 0 - for c in range(len(self.inputData["clusters"])): - weight = self.inputData["clusterWeights"][self.inputData["clusters"][c]] / sum_ClusterWeights - self.dcf_year[year] += self.demandCoverFactor[year][c] * weight - self.scf_year[year] += self.supplyCoverFactor[year][c] * weight + return None def calc_annual_cost_total(self, data): diff --git a/districtgenerator/functions/opti_central.py b/districtgenerator/functions/opti_central.py index 5881707..4b80945 100644 --- a/districtgenerator/functions/opti_central.py +++ b/districtgenerator/functions/opti_central.py @@ -1692,6 +1692,13 @@ def helper_func_extract_eh_results(model, results_dict, variable_type, device_se device_set=EH_ECS_STORAGE, time_steps=time_steps) helper_func_extract_eh_results(model=model, results_dict=results_dict, variable_type="eh_soc", device_set=EH_ECS_STORAGE, time_steps=time_steps) + + # Residual Load of the Energyhub calculated through model.eh_power_to_grid and model.eh_power_from_grid + results_dict["eh_res_load"] = [] + results_dict["eh_res_inj"] = [] + for t in time_steps: + results_dict["eh_res_load"].append(round(pyo.value(model.eh_power_from_grid[t]), 0)) + results_dict["eh_res_inj"].append(round(pyo.value(model.eh_power_to_grid[t]), 0)) ################################################################################ # Building results