diff --git a/districtgenerator/classes/KPIs.py b/districtgenerator/classes/KPIs.py index 50b79fb..e66c938 100644 --- a/districtgenerator/classes/KPIs.py +++ b/districtgenerator/classes/KPIs.py @@ -5,16 +5,8 @@ import os import json import math -import reportlab -from reportlab.pdfgen import canvas -from reportlab.lib import colors -from reportlab.graphics.shapes import * -from reportlab.graphics.charts.piecharts import Pie -from reportlab.graphics.charts.legends import Legend -from datetime import datetime -from reportlab.lib.styles import ParagraphStyle -from reportlab.platypus import Paragraph from itertools import zip_longest +from districtgenerator.classes.certificate_generator import CertificateBuilder class KPIs: @@ -26,9 +18,7 @@ def __init__(self, data): ---------- data : Datahandler object Datahandler object which contains all relevant information to compute the key performance indicators (KPIs). - decentral_config : dict - Dict containing the decentral configuration parameters. - + Returns ------- None. @@ -47,11 +37,24 @@ def __init__(self, data): self.co2emissions = None self.W_inj_GCP_year = None self.W_dem_GCP_year = None - self.Gas_year = None + + self.gas_year = None + self.biomass_year = None + self.waste_year = None + self.hydrogen_year = None + self.oil_year = None + self.district_heat_year = None + self.dcf_year = None self.scf_year = None self.annual_fixed_costs_decentral = None self.annual_fixed_costs_central = None + + self.decentral_individual_devices_annualized_cost = None + self.central_individual_devices_annualized_cost = None + self.total_ICE_fuel_liters = None + self.gasoline_costs = None + self.totalarea_residential = None self.totalarea_non_residential = None self.totalheatload = None @@ -216,33 +219,6 @@ def calculateEnergyExchangeGCP(self, data): """ Calculate energy exchange of the district with its environment in [kWh] for each year. """ - - W_inj_GCP = {} - W_dem_GCP = {} - gas = {} - biomass = {} - waste = {} - hydrogen = {} - oil = {} - districtHeat = {} - - for year in self.inputData["simulated_years"]: - # Electricity [kWh] feed into the superordinated grid - W_inj_GCP[year] = np.zeros(len(data.clusters)) - # Electricity [kWh] covered by the superordinated grid - W_dem_GCP[year] = np.zeros(len(data.clusters)) - - # Fuel consumption [kWh] - gas[year] = np.zeros(len(data.clusters)) - biomass[year] = np.zeros(len(data.clusters)) - waste[year] = np.zeros(len(data.clusters)) - hydrogen[year] = np.zeros(len(data.clusters)) - oil[year] = np.zeros(len(data.clusters)) - - # District heat consumption [kWh] - districtHeat[year] = np.zeros(len(data.clusters)) - - # electricity feed into and covered by superordinated grid for one year [kWh] self.W_inj_GCP_year = {} self.W_dem_GCP_year = {} self.gas_year = {} @@ -252,6 +228,11 @@ def calculateEnergyExchangeGCP(self, data): self.oil_year = {} self.districtHeat_year = {} + self.el_dem_buildings = {} + self.el_inj_buildings = {} + self.el_dem_eh = {} + self.el_inj_eh = {} + for year in self.inputData["simulated_years"]: # Variables for the yearly consumption calculation self.W_inj_GCP_year[year] = 0 @@ -263,28 +244,29 @@ def calculateEnergyExchangeGCP(self, data): self.oil_year[year] = 0 self.districtHeat_year[year] = 0 + self.el_dem_buildings[year] = 0 + self.el_inj_buildings[year] = 0 + self.el_dem_eh[year] = 0 + self.el_inj_eh[year] = 0 + # loop over cluster for c in range(len(self.inputData["clusters"])): - W_dem_GCP[year][c] = sum(self.inputData["resultsOptimization"][year][c]["P_dem_gcp"]) \ - * data.time["timeResolution"] / 3600 / 1000 # from Ws to kWh - W_inj_GCP[year][c] = sum(self.inputData["resultsOptimization"][year][c]["P_inj_gcp"]) \ - * data.time["timeResolution"] / 3600 / 1000 - gas[year][c] = sum(self.inputData["resultsOptimization"][year][c]["P_gas_total"]) * data.time["timeResolution"] / 3600 / 1000 - biomass[year][c] = sum(self.inputData["resultsOptimization"][year][c]["P_biomass_total"]) * data.time["timeResolution"] / 3600 / 1000 - waste[year][c] = sum(self.inputData["resultsOptimization"][year][c]["P_waste_total"]) * data.time["timeResolution"] / 3600 / 1000 - hydrogen[year][c] = sum(self.inputData["resultsOptimization"][year][c]["P_hydrogen_total"]) * data.time["timeResolution"] / 3600 / 1000 - oil[year][c] = sum(self.inputData["resultsOptimization"][year][c]["P_oil_total"]) * data.time["timeResolution"] / 3600 / 1000 - districtHeat[year][c] = sum(self.inputData["resultsOptimization"][year][c]["P_district_heat_total"]) * data.time["timeResolution"] / 3600 / 1000 - - - self.W_dem_GCP_year[year] += W_dem_GCP[year][c] * self.inputData["clusterWeights"][self.inputData["clusters"][c]] - self.W_inj_GCP_year[year] += W_inj_GCP[year][c] * self.inputData["clusterWeights"][self.inputData["clusters"][c]] - self.gas_year[year] += gas[year][c] * self.inputData["clusterWeights"][self.inputData["clusters"][c]] - self.biomass_year[year] += biomass[year][c] * self.inputData["clusterWeights"][self.inputData["clusters"][c]] - self.waste_year[year] += waste[year][c] * self.inputData["clusterWeights"][self.inputData["clusters"][c]] - self.hydrogen_year[year] += hydrogen[year][c] * self.inputData["clusterWeights"][self.inputData["clusters"][c]] - self.oil_year[year] += oil[year][c] * self.inputData["clusterWeights"][self.inputData["clusters"][c]] - self.districtHeat_year[year] += districtHeat[year][c] * self.inputData["clusterWeights"][self.inputData["clusters"][c]] + weight = self.inputData["clusterWeights"][self.inputData["clusters"][c]] + opt_res = self.inputData["resultsOptimization"][year][c] + + self.W_dem_GCP_year[year] += opt_res["from_grid_total_el"] * weight + self.W_inj_GCP_year[year] += opt_res["to_grid_total_el"] * weight + self.gas_year[year] += opt_res["from_grid_total_gas"] * weight + self.biomass_year[year] += opt_res["total_biomass_used"] * weight + self.waste_year[year] += opt_res["total_waste_used"] * weight + self.hydrogen_year[year] += opt_res["from_grid_total_hydrogen"] * weight + self.oil_year[year] += opt_res["total_oil_used"] * weight + self.districtHeat_year[year] += opt_res["total_district_heat_used"] * weight + + self.el_dem_buildings[year] += opt_res["from_grid_total_el_buildings"] * weight + self.el_inj_buildings[year] += opt_res["to_grid_total_el_buildings"] * weight + self.el_dem_eh[year] += opt_res["from_grid_total_el_eh"] * weight + self.el_inj_eh[year] += opt_res["to_grid_total_el_eh"] * weight def calculateEnergyExchangeWithinDistrict(self, data): @@ -385,6 +367,7 @@ def calc_annual_cost_total(self, data): capacities[n]["OBOI"] = district[n]["capacities"]["OBOI"] / 1000 capacities[n]["HP"] = district[n]["capacities"]["HP"] / 1000 capacities[n]["EH"] = district[n]["capacities"]["EH"] / 1000 + capacities[n]["CC"] = district[n]["capacities"]["CC"] / 1000 capacities[n]["CHP"] = district[n]["capacities"]["CHP"] / 1000 capacities[n]["FC"] = district[n]["capacities"]["FC"] / 1000 capacities[n]["DH"] = district[n]["capacities"]["DH"]/ decentral_device_data["DH"]["eta_th"] / 1000 # Price is payed for the power of the connection not for the actual thermal power delivered @@ -401,7 +384,7 @@ def calc_annual_cost_total(self, data): self.annual_fixed_costs_decentral = 0 self.annual_fixed_costs_decentral_unsubsidized = 0 - devices = ["BOI", "BBOI", "H2BOI", "OBOI", "HP", "EH", "CHP", "FC", "DH", "PV", "STC", "EV", "BAT", "TES"] + devices = ["BOI", "BBOI", "H2BOI", "OBOI", "HP", "EH", "CC", "CHP", "FC", "DH", "PV", "STC", "EV", "BAT", "TES"] # Iteration over all buildings and then over all devices for n in range(len(district)): @@ -508,6 +491,28 @@ def calc_annual_cost_total(self, data): self.annual_fixed_costs_central = 0 self.annual_fixed_costs_central_unsubsidized = 0 + def calculateDetailedCostsPerYear(self, data): + """Calculate the detailed costs for each simulated year.""" + self.detailed_costs_year = {} + + for year in self.inputData["simulated_years"]: + ecoData = data.all_sim_ecoData[year] + + + # Save all costs in a dictionary for each year + self.detailed_costs_year[year] = { + "eh_fixed": self.annual_fixed_costs_central, + "decentral_fixed": self.annual_fixed_costs_decentral, + "electricity": self.el_dem_buildings[year] * ecoData["price_supply_el"] + self.el_dem_eh[year] * ecoData["price_supply_el_eh"], + "gas": self.gas_year[year] * ecoData["price_supply_gas"], + "oil": self.oil_year[year] * ecoData["price_oil"], + "waste": self.waste_year[year] * ecoData["price_waste"], + "biomass": self.biomass_year[year] * ecoData["price_biomass"], + "district_heat": self.districtHeat_year[year] * ecoData["price_district_heat"], + "hydrogen": self.hydrogen_year[year] * ecoData["price_hydrogen"], + "revenue_feed_in_el": -(self.el_inj_buildings[year] * ecoData["revenue_feed_in_el"] + self.el_inj_eh[year] * ecoData["revenue_feed_in_el_eh"]) + } + def calc_annual_cost_device(self, dev, ecoData, cap, mode="subsidized"): """ Calculation of total investment costs including replacements (based on VDI 2067-1, pages 16-17). @@ -729,6 +734,7 @@ def calc_total_areas_and_demands(self, data): None. """ total_area_residential = 0 + total_area_mixed = 0 total_area_non_residential = 0 total_number_flats = 0 total_number_occ = 0 @@ -753,7 +759,11 @@ def calc_total_areas_and_demands(self, data): total_number_flats += building["user"].nb_flats for flat in building["user"].nb_occ: total_number_occ += flat - + elif "+" in building["buildingFeatures"]["building"]: #TODO: This requires a working mixed building implementation + total_area_mixed += building["buildingFeatures"]["area"] + total_number_flats += building["user"].nb_res_flats + for flat in building["user"].nb_res_occ: + total_number_occ += flat else: total_area_non_residential += building["buildingFeatures"]["area"] total_ICE_fuel_liters += np.sum(building["user"].ice_carprofile) # liters per timestep summed over year @@ -782,6 +792,7 @@ def calc_total_areas_and_demands(self, data): sum_dhw_profile, building["user"].dhw, fillvalue=0)] self.totalarea_residential = total_area_residential + self.total_area_mixed = total_area_mixed self.totalarea_non_residential = total_area_non_residential self.totalnumberflats = total_number_flats self.totalnumberocc = total_number_occ @@ -845,6 +856,10 @@ def calc_total_consumption_and_emissions(self, data): self.total_co2_oil = sum(self.co2emissions[year]["co2_oil"] * year_weights[year] for year in sorted_years) # CO2 emissions from oil consumption self.total_co2_district_heat = sum(self.co2emissions[year]["co2_district_heat"] * year_weights[year] for year in sorted_years) # CO2 emissions from district heat consumption + self.avg_co2_emissions = self.total_co2_all / observation_time + self.total_operation_costs = sum(self.operationCosts[year] * year_weights[year] for year in sorted_years) + self.avg_operationCosts = self.total_operation_costs / observation_time + def calculateGasolineCosts(self, data): """Compute annual gasoline costs (€) for each simulated year.""" self.gasoline_costs = {} @@ -875,6 +890,10 @@ def calculateAllKPIs(self, data): self.calc_total_areas_and_demands(data) self.calculateGasolineCosts(data) self.calc_total_consumption_and_emissions(data) + self.calculateDetailedCostsPerYear(data) + + + def create_certificate(self, data, result_path): """ @@ -886,880 +905,5 @@ def create_certificate(self, data, result_path): - kpis: A list of strings, where each string is a KPI to be written in the document. """ - # preprocessing buildinglist - template_dict = {"Anzahl": 0, - "Gesamtfläche": 0, - "vor 1968": 0, - "1968-1978": 0, - "1979-1983": 0, - "1984-1994": 0, - "1995-2001": 0, - "2002-2009": 0, - "2010-2015": 0, - "ab 2016": 0, - } - SFH = dict(template_dict) - TH = dict(template_dict) - MFH = dict(template_dict) - AB = dict(template_dict) - gebaeudeliste = [] - - for building in data.district: - if building["buildingFeatures"]["building"] == 'SFH': - SFH["Anzahl"] += 1 - SFH["Gesamtfläche"] += building["buildingFeatures"]["area"] - if building["buildingFeatures"]["year"] < 1968: - SFH["vor 1968"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1968 and building["buildingFeatures"]["year"] <= 1978 : - SFH["1968-1978"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1979 and building["buildingFeatures"]["year"] <= 1983 : - SFH["1979-1983"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1984 and building["buildingFeatures"]["year"] <= 1994 : - SFH["1984-1994"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1995 and building["buildingFeatures"]["year"] <= 2001 : - SFH["1995-2001"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 2002 and building["buildingFeatures"]["year"] <= 2009: - SFH["2002-2009"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 2010 and building["buildingFeatures"]["year"] <= 2015: - SFH["2010-2015"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 2016: - SFH["ab 2016"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["building"] == 'TH': - TH["Anzahl"] += 1 - TH["Gesamtfläche"] += building["buildingFeatures"]["area"] - if building["buildingFeatures"]["year"] < 1968: - TH["vor 1968"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1968 and building["buildingFeatures"]["year"] <= 1978 : - TH["1968-1978"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1979 and building["buildingFeatures"]["year"] <= 1983 : - TH["1979-1983"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1984 and building["buildingFeatures"]["year"] <= 1994 : - TH["1984-1994"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1995 and building["buildingFeatures"]["year"] <= 2001 : - TH["1995-2001"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 2002 and building["buildingFeatures"]["year"] <= 2009: - TH["2002-2009"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 2010 and building["buildingFeatures"]["year"] <= 2015: - TH["2010-2015"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 2016: - TH["ab 2016"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["building"] == 'MFH': - MFH["Anzahl"] += 1 - MFH["Gesamtfläche"] += building["buildingFeatures"]["area"] - if building["buildingFeatures"]["year"] < 1968: - MFH["vor 1968"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1968 and building["buildingFeatures"]["year"] <= 1978 : - MFH["1968-1978"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1979 and building["buildingFeatures"]["year"] <= 1983 : - MFH["1979-1983"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1984 and building["buildingFeatures"]["year"] <= 1994 : - MFH["1984-1994"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1995 and building["buildingFeatures"]["year"] <= 2001 : - MFH["1995-2001"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 2002 and building["buildingFeatures"]["year"] <= 2009: - MFH["2002-2009"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 2010 and building["buildingFeatures"]["year"] <= 2015: - MFH["2010-2015"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 2016: - MFH["ab 2016"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["building"] == 'AB': - AB["Anzahl"] += 1 - AB["Gesamtfläche"] += building["buildingFeatures"]["area"] - if building["buildingFeatures"]["year"] < 1968: - AB["vor 1968"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1968 and building["buildingFeatures"]["year"] <= 1978 : - AB["1968-1978"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1979 and building["buildingFeatures"]["year"] <= 1983 : - AB["1979-1983"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1984 and building["buildingFeatures"]["year"] <= 1994 : - AB["1984-1994"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 1995 and building["buildingFeatures"]["year"] <= 2001 : - AB["1995-2001"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 2002 and building["buildingFeatures"]["year"] <= 2009: - AB["2002-2009"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 2010 and building["buildingFeatures"]["year"] <= 2015: - AB["2010-2015"] += building["buildingFeatures"]["area"] - elif building["buildingFeatures"]["year"] >= 2016: - AB["ab 2016"] += building["buildingFeatures"]["area"] - - # enforce fTES=0 when heater is heat_grid - f_TES = 0 if building["buildingFeatures"]["heater"] == "heat_grid" \ - else building["buildingFeatures"]["f_TES"] - - gebaeudeliste.append([building["buildingFeatures"]["building"], - building["buildingFeatures"]["year"], - building["buildingFeatures"]["retrofit"], - building["buildingFeatures"]["construction_type"], - building["buildingFeatures"]["night_setback"], - building["buildingFeatures"]["area"], - building["buildingFeatures"]["heater"], - building["buildingFeatures"]["EV"], - f_TES, - building["buildingFeatures"]["f_BAT"], - building["buildingFeatures"]["f_PV1"], - building["buildingFeatures"]["f_PV2"], - building["buildingFeatures"]["f_STC"], - building["buildingFeatures"]["gamma_PV"], - building["buildingFeatures"]["ev_charging"]]) - - # create dicts to categorize KPIs and building information - # kennwerte: a dictionary with the following keys (in order, formatted as strings), and all values formatted as - # strings with the corresponding units (unless otherwise specified): - # Primärenergiebedarf: primary energy demand of the district - # Endenergiebedarf: end energy demand of the district - # Norm-Heizlast insgesamt: the overall heat demand of the district - # Solltemperatur: set-point temperature of the buildings in the district - # Bedarfe: a TUPLE containing three values (float/int) for demands of electricity, heat, and water heating (in that order) - # Max. Leistungen: a TUPLE containing three values (float/int) for the maximum power of each energy type, in the order above - # - # opt_ergebnisse: a dictionary with the following keys (in order, formatted as strings), and all values formatted - # as strings with the corresponding units: - # CO2-äqui. Emissionen: CO2-equivalent emissions of the district - # Energiekosten: energy cost for the district - # Spitzenlast (el.) gesamt: peak load for the district - # Max. Einspeiseleistung gesamt: maximum feed-in power of the district - # Supply-Cover-Faktor: supply cover factor - # Demand-Cover-Faktor: demand cover factor - # - # struktur: a dictionary with the following keys (in order, formatted as strings) and and all values formatted as - # strings with the corresponding units (unless otherwise specified): - # EFH: a DICTIONARY containing the following keys and values pertaining to single-family homes in the district - # (keys formatted as strings, values formatted as strings including the relevant units): - # Anzahl: the number of buildings of this type in the district - # Gesamtfläche: the total floor space of these buildings (without unit!) - # vor 1968: the total floor space of the buildings of this type built before 1968, in m^2 (without unit in string!) - # 1968-1979: the total floor space of the buildings of this type built between 1968 and 1979, in m^2 (without unit in string!) - # 1979-1983: the total floor space of the buildings of this type built between 1979 and 1983, in m^2 (without unit in string!) - # 1984-1994: the total floor space of the buildings of this type built between 1984 and 1994, in m^2 (without unit in string!) - # 1995-2001: the total floor space of the buildings of this type built between 1995 and 2001, in m^2 (without unit in string!) - # 2002-2009: the total floor space of the buildings of this type built between 2002 and 2009, in m^2 (without unit in string!) - # 2010-2015: the total floor space of the buildings of this type built between 2010 and 2015, in m^2 (without unit in string!) - # ab 2016: the total floor space of the buildings of this type built since 2016, in m^2 (without unit in string!) - # MFH: a DICTIONARY formatted as specified above, with the values pertaining to multiple-family homes. - # Reihenhaus: a DICTIONARY formatted as specified above, with the values pertaining to townhouses. - # Block: a DICTIONARY formatted as specified above, with the values pertaining to block buildings. - # Wohnungen gesamt: number of households in the district - # Bewohner gesamt: number of residents in the district - # Nettowohnfläche gesamt: net living space in the district - # Standort (PLZ): the zip code of the district - # Testreferenzjahr: the reference year and reference weather conditions, formatted as "YYYY / warm" - # Quartiersname: the name of the district - # - # gebaeudeliste: a two-dimensional list, with each index corresponding to a building ID and the list in each index containing the following values: - # building: SFH, MFH, Townhouse, or Block - # year: year of construction - # retrofit: 1 (yes) or 0 (no) - # area: floor space - # heater: type of heating - # PV: - # STC: - # EV: - # BAT: - # f_TES: - # f_BAT: - # f_PV1: - # f_PV2: - # f_STC: - # gamma_PV: - # ev_charging: - kennwerte={ - # TODO: We don't have any primary factors for gas and electricity mix. Should be added? - # "Primärenergiebedarf": "120 kWh/m\u00B2a", - # TODO: Discuss total final energy calculation and if a specific value would be better - "Nutzenergiebedarf": str(round((self.total_electricity_demand - + self.total_heating_demand - + self.total_cooling_demand - + self.total_dhw_demand - + self.total_EV_demand ) / 1000000, 1)) + " MWh/a", - "Norm-Heizlast": str(round(self.totalheatload / 1000)) + " kW", - - "Bedarfe": (round(self.total_electricity_demand / 1000000, 2), - round(self.total_heating_demand / 1000000, 2), - round(self.total_dhw_demand / 1000000, 2), - round(self.total_cooling_demand / 1000000, 2), - round(self.total_EV_demand / 1000000, 2)), - "Max. Leistungen": (round(self.total_electricity_peak / 1000), - round(self.total_heat_peak / 1000), - round(self.total_dhw_peak / 1000), - round(self.total_cooling_peak / 1000)) - - } - opt_ergebnisse={ - "CO2-äqui. Emissionen": str(round(self.co2emissions[0]['total_co2'])) + " t/a", - "Energiekosten": str(round(self.operationCosts[0])) + " \u20AC/a", - "Spritkosten": str(round(self.gasoline_costs[0] or 0)) + " \u20AC/a", - "Dezentrale Fixkosten": str(round(self.annual_fixed_costs_decentral)) + " \u20AC/a", - "Zentrale Fixkosten": str(round(self.annual_fixed_costs_central)) + " \u20AC/a", - "Spitzenlast (el.)": str(round(self.peakDemand[0], 2)) + " kW", - "Max. Einspeiseleistung": str(round(self.peakInjection[0], 2)) + " kW", - "Supply-Cover-Faktor": str(round(self.scf_year[0] * 100, 0)) + " %", - "Demand-Cover-Faktor": str(round(self.dcf_year[0] * 100, 0)) + " %", - - } - struktur={ - "EFH": SFH, - "MFH": MFH, - "Reihenhaus": TH, - "Block": AB, - "Wohneinheiten gesamt": self.totalnumberflats, - "Bewohner gesamt": self.totalnumberocc, - "Nettowohnfläche gesamt": str(self.totalarea_residential) + " m\u00B2", - "Nettofläche GHD gesamt": str(self.totalarea_non_residential) + " m\u00B2", - "Standort (PLZ)": str(data.site["zip"]), - "Testreferenzjahr": str(data.site["TRYYear"])[3:] + " / " + str(data.site["TRYType"]), - "Quartiersname": str(data.scenario_name) - } - - if result_path is None: - src_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - filename = os.path.join(src_path, "results", f"Quartiersenergieausweis_{data.scenario_name}.pdf") - else: - filename = os.path.join(result_path, f"Quartiersenergieausweis_{data.scenario_name}.pdf") - - # initialize certificate - certificate = canvas.Canvas(filename, pagesize=reportlab.lib.pagesizes.A4) - width, height = reportlab.lib.pagesizes.A4 - - # draw line for header and add title - certificate.setStrokeColorRGB(54 / 256, 132 / 256, 39 / 256) - certificate.setLineWidth(4) - certificate.line(72, height - 60, width - 72, height - 60) - certificate.setFont("Helvetica-Bold", 20) - certificate.drawString(72, height - 50, "Quartiersenergieausweis") - - # draw lines for first section and add section title - top1 = 90 # top of the section - bottom1 = 350 # bottom of the section - - certificate.setStrokeColorRGB(54 / 256, 132 / 256, 39 / 256) - certificate.setLineWidth(2) - certificate.setLineCap(2) - certificate.line(72, height - top1, 78, height - top1) - certificate.line(277, height - top1, width - 72, height - top1) - certificate.line(72, height - top1, 72, height - bottom1) - certificate.line(72, height - bottom1, width - 72, height - bottom1) - certificate.line(width - 72, height - bottom1, width - 72, height - top1) - certificate.setFont("Helvetica-Bold", 16) - certificate.drawString(85, height - top1 - 6, "Energetische Kennwerte") - - # do the same for the second section - top2 = 380 - bottom2 = 720 - - certificate.line(72, height - top2, 78, height - top2) - certificate.line(223, height - top2, width - 72, height - top2) - certificate.line(72, height - top2, 72, height - bottom2) - certificate.line(72, height - bottom2, width - 72, height - bottom2) - certificate.line(width - 72, height - bottom2, width - 72, height - top2) - certificate.drawString(85, height - top2 - 6, "Quartiersstruktur") - - # do the same for the final section - top3 = 730 - bottom3 = 770 - - certificate.line(72, height - top3, width - 72, height - top3) - certificate.line(72, height - bottom3, width - 72, height - bottom3) - certificate.line(72, height - top3, 72, height - bottom3) - certificate.line(width - 72, height - top3, width - 72, height - bottom3) - certificate.setFont("Helvetica-Bold", 12) - certificate.drawString(85, height - ((top3 + bottom3) / 2) - 4, "Quartiersname: ") - certificate.setFont("Helvetica", 10) - certificate.drawString(177, height - ((top3 + bottom3) / 2) - 4, struktur["Quartiersname"]) - certificate.drawString(380, height - ((top3 + bottom3) / 2) - 4, - "Erstellt am: " + datetime.now().strftime('%d.%m.%Y %H:%M')) - - # filling in the first section - # in the create_certificate function, the argument would be a dictionary of parameters and their values. this dictionary would be titled quartier_daten - - certificate.setFont("Helvetica", 12) - content = tuple(kennwerte.keys()) - values = tuple(kennwerte.values()) - content = content[0:2] - values = values[0:2] - - i = 0 - for item in content: - certificate.drawString(85, height - top1 - 32 - (18 * i), item + ":") - i = i + 1 - - j = 0 - for value in values: - certificate.drawString(200, height - top1 - 32 - (18 * j), str(value)) - j = j + 1 - - bottom_kennwerte = top1 + 25 + 18 * j - - # create a subsection for optimization results, fill in the values - - certificate.setLineWidth(1) - certificate.setLineCap(2) - certificate.line(72, height - bottom_kennwerte, 325, height - bottom_kennwerte) - certificate.line(325, height - bottom_kennwerte, 325, height - bottom1) - - certificate.setFont("Helvetica-Bold", 14) - certificate.drawString(85, height - bottom_kennwerte - 25, "Optimierter Anlagenbetrieb") - - certificate.setFont("Helvetica", 12) - opt_keys = tuple(opt_ergebnisse.keys()) - opt_values = tuple(opt_ergebnisse.values()) - - i = 0 - for item in opt_keys: - certificate.drawString(85, height - bottom_kennwerte - 50 - (18 * i), item + ":") - i = i + 1 - - j = 0 - for value in opt_values: - certificate.drawString(230, height - bottom_kennwerte - 50 - (18 * j), str(value)) - j = j + 1 - - # create graphics for the energy demands and maximum powers by energy type - d = Drawing(300, 300) - - pc = Pie() - pc.width = 90 - pc.height = 90 - pc.data = kennwerte["Bedarfe"] - pc.labels = None#['Strom: ' + str(pc.data[0]), 'Wärme: ' + str(pc.data[1]), 'TWW: ' + str(pc.data[2]),'Kälte: '+str(pc.data[3])] - #pc.sideLabelsOffset = 0.1 - #pc.sideLabels = 1 - #pc.checkLabelOverlap = 1 - - pc.slices.strokeWidth = 1 - pc.slices.labelRadius = 1.5 - pc.slices[3].labelRadius = 1.2 - pc.slices.fontName = "Helvetica" - pc.slices.strokeColor = colors.white - - pc.slices[0].fillColor = colors.Color(0 / 256, 85 / 256, 31 / 256) - pc.slices[1].fillColor = colors.Color(134 / 256, 169 / 256, 26 / 256) - pc.slices[2].fillColor = colors.Color(54 / 256, 132 / 256, 39 / 256) - pc.slices[3].fillColor = colors.Color(122 / 256, 186 / 256, 214 / 256) - pc.slices[4].fillColor = colors.Color(102 / 256, 51 / 256, 153 / 256) - - d.add(pc) - - legend = Legend() - legend.alignment = 'right' - legend.fontName = "Helvetica" - legend.fontSize = 10 - legend.dx = 7 - legend.dy = 7 - legend.yGap = 0 - legend.deltax = 90 - legend.deltay = 10 - legend.strokeWidth = 0 - legend.strokeColor = colors.white - legend.columnMaximum = 3 - legend.boxAnchor = 'nw' - legend.y = -5 - legend.x = -36 - legend.colorNamePairs = [ - (colors.Color(0 / 256, 85 / 256, 31 / 256), u'Strom: ' + str(pc.data[0])), - (colors.Color(134 / 256, 169 / 256, 26 / 256), u'Wärme: ' + str(pc.data[1])), - (colors.Color(54 / 256, 132 / 256, 39 / 256), u'TWW: ' + str(pc.data[2])), - (colors.Color(122 / 256, 186 / 256, 214 / 256), u'Kälte: ' + str(pc.data[3])), - (colors.Color(102 / 256, 51 / 256, 153 / 256), u'EV: ' + str(pc.data[4])),] - d.add(legend) - - d.drawOn(certificate,(width/2)+80,height-top1-120) - - certificate.setFont("Helvetica-Bold", 14) - certificate.drawString(340, height - top1 - 20, "Energiebedarfe in MWh") - - max_leistungen = kennwerte["Max. Leistungen"] - leist_labels = ("Strom: ", "Wärme: ", "TWW: ","Kälte: ") - - ML_top = 263 - - certificate.drawString(340, height - ML_top, "Maximale Leistungen") - - certificate.setStrokeColorRGB(54 / 256, 132 / 256, 39 / 256) - certificate.setLineWidth(5) - certificate.setLineCap(2) - certificate.setFont("Helvetica", 12) - - scaling_line = 75 / max(max_leistungen) - - for i in range(len(max_leistungen)): - certificate.drawString(340, height - ML_top - 18 - (18 * i), leist_labels[i]) - certificate.line(387, height - ML_top - 16 - (18 * i), 390 + (scaling_line * max_leistungen[i]), - height - ML_top - 16 - (18 * i)) - certificate.drawString(395 + (scaling_line * max_leistungen[i]), height - ML_top - 18 - (18 * i), - str(max_leistungen[i]) + " kW") - - # create table in section 2 - n_rows = 11 - n_columns = 5 - certificate.setStrokeColorRGB(0, 0, 0) - certificate.setLineWidth(1) - certificate.setLineCap(2) - table_top = height - top2 - 20 - table_bottom = table_top - (18 * n_rows) - table_width = width - 180 - - first_column_width = (table_width / 5) * 1.3 - remaining_column_width = (table_width - first_column_width) / 4 - - # Draw horizontal lines - for ii in range(n_rows + 1): - certificate.line(90, table_top - (18 * ii), width - 90, table_top - (18 * ii)) - - # Draw vertical lines with adjusted column widths - x_position = 90 # Starting x position for first column - - # First column - certificate.line(x_position, table_top, x_position, table_bottom) - x_position += first_column_width # Move to next column - - # Remaining columns - for jj in range(1, n_columns + 1): - certificate.line(x_position, table_top, x_position, table_bottom) - x_position += remaining_column_width # Move to the next column - - # Column titles - certificate.setFont("Helvetica-Bold", 11.5) - column_titles = ("Wohngebäudetyp", "EFH", "MFH", "Reihenhaus", "Block") - - # Draw column titles - x_position = 90 # Reset x position - - certificate.drawString( - x_position + (first_column_width - len(column_titles[0]) * 6.8) / 2, table_top - 14, column_titles[0] - ) - x_position += first_column_width # Move to next column - - for i in range(1, 5): - certificate.drawString( - x_position + (remaining_column_width - len(column_titles[i]) * 6.8) / 2, table_top - 14, - column_titles[i] - ) - x_position += remaining_column_width # Move to next column - - # row titles - row_titles = tuple(struktur["EFH"].keys()) - - for i in range(len(row_titles)): - certificate.drawString(94 + (((table_width / 5) - len(row_titles[i]) * 6.8) / 2), - table_top - 14 - 18 - (18 * i), row_titles[i]) - - # fill in table values - certificate.setFont("Helvetica", 11.5) - EFH_values = tuple(struktur["EFH"].values()) - MFH_values = tuple(struktur["MFH"].values()) - RH_values = tuple(struktur["Reihenhaus"].values()) - B_values = tuple(struktur["Block"].values()) - - certificate.drawString((table_width * 2 / 5) + ((table_width / 5) / 2) + 17 - (len(str(EFH_values[0])) * 6.5), - table_top - 14 - 18, str(EFH_values[0])) - certificate.drawString((table_width * 3 / 5) + ((table_width / 5) / 2) + 17 - (len(str(MFH_values[0])) * 6.5), - table_top - 14 - 18, str(MFH_values[0])) - certificate.drawString((table_width * 4 / 5) + ((table_width / 5) / 2) + 17 - (len(str(RH_values[i])) * 6.5), - table_top - 14 - 18, str(RH_values[0])) - certificate.drawString((table_width) + ((table_width / 5) / 2) + 17 - (len(str(B_values[i])) * 6.5), - table_top - 14 - 18, str(B_values[0])) - - for i in range(1, len(EFH_values)): - certificate.drawString((table_width * 2 / 5) + ((table_width / 5) / 2) + 17 - (len(str(EFH_values[i])) * 6.5), - table_top - 14 - 18 - (18 * i), str(EFH_values[i])) - certificate.drawString((table_width * 2 / 5) + ((table_width / 5) / 2) + 22, table_top - 14 - 18 - (18 * i), - "m\u00B2") - for i in range(1, len(MFH_values)): - certificate.drawString((table_width * 3 / 5) + ((table_width / 5) / 2) + 17 - (len(str(MFH_values[i])) * 6.5), - table_top - 14 - 18 - (18 * i), str(MFH_values[i])) - certificate.drawString((table_width * 3 / 5) + ((table_width / 5) / 2) + 22, table_top - 14 - 18 - (18 * i), - "m\u00B2") - for i in range(1, len(RH_values)): - certificate.drawString((table_width * 4 / 5) + ((table_width / 5) / 2) + 17 - (len(str(RH_values[i])) * 6.5), - table_top - 14 - 18 - (18 * i), str(RH_values[i])) - certificate.drawString((table_width * 4 / 5) + ((table_width / 5) / 2) + 22, table_top - 14 - 18 - (18 * i), - "m\u00B2") - for i in range(1, len(B_values)): - certificate.drawString((table_width) + ((table_width / 5) / 2) + 17 - (len(str(B_values[i])) * 6.5), - table_top - 14 - 18 - (18 * i), str(B_values[i])) - certificate.drawString((table_width) + ((table_width / 5) / 2) + 22, table_top - 14 - 18 - (18 * i), - "m\u00B2") - - # fill in the info under the table - certificate.setFont("Helvetica", 12) - struktur_keys = tuple(struktur.keys()) - struktur_keys = struktur_keys[4:-1] - struktur_values = tuple(struktur.values()) - struktur_values = struktur_values[4:-1] - - i = 0 - for item in struktur_keys: - certificate.drawString(185, table_bottom - 20 - (18 * i), item + ":") - i = i + 1 - - j = 0 - for value in struktur_values: - certificate.drawString(350, table_bottom - 20 - (18 * j), str(value)) - j = j + 1 - - # end first page, continue to next page - certificate.showPage() - - # swap page orientation to landscape - certificate.setPageSize((height, width)) - height, width = width, height - - # create table - if len(gebaeudeliste) <= 26: - n_rows = len(gebaeudeliste) + 1 - else: - n_rows = 27 - - n_columns = 16 - certificate.setStrokeColorRGB(0, 0, 0) - certificate.setLineWidth(1) - certificate.setLineCap(2) - table_top = height - 54 - table_bottom = table_top - (18 * n_rows) - table_width = width - 108 - total_pages = (len(gebaeudeliste) // 26) + 1 - - if len(gebaeudeliste) % 26 == 0: - total_pages = total_pages - 1 - - if total_pages == 1: - - for ii in range(n_rows + 1): - certificate.line(54, table_top - (18 * ii), width - 54, table_top - (18 * ii)) - for jj in range(n_columns + 1): - certificate.line(54 + table_width * (jj / n_columns), table_top, 54 + table_width * (jj / n_columns), - table_bottom) - - # column titles - certificate.setFont("Helvetica-Bold", 6) - column_titles = ( - "Gebäude ID", "Gebäudetyp", "Baujahr", "Sanierung", "Sp-Masse", "N-Absenkung", "Wohnfläche", "Heizung", "EV", - "fTES", "fBAT", "fPV1", "fPV2", "fSTC", "gammaPV ", "EV Charging") - for i in range(len(column_titles)): - certificate.drawString(54 + table_width * (i / n_columns) + ( - ((table_width / len(column_titles)) - len(column_titles[i]) * 3.2) / 2), table_top - 11, - column_titles[i]) - - # add table values - certificate.setFont("Helvetica", 6) - for i in range(len(gebaeudeliste)): - certificate.drawString((table_width / n_columns) + (((table_width / n_columns) - (len(str(i))) * 3.2) / 2) + 10, - table_top - 11 - 18 - (18 * i), str(i)) - for j in range(15): - certificate.drawString((table_width * (j + 2) / n_columns) + ( - ((table_width / n_columns) - (len(str(gebaeudeliste[i][j]))) * 3.2) / 2) + 10, - table_top - 11 - 18 - (18 * i), str(gebaeudeliste[i][j])) - - # add border and title - certificate.setStrokeColorRGB(54 / 256, 132 / 256, 39 / 256) - certificate.setLineWidth(2) - certificate.setLineCap(2) - certificate.line(36, height - 36, 78, height - 36) - certificate.line(230, height - 36, width - 36, height - 36) - certificate.line(36, height - 36, 36, 36) - certificate.line(36, 36, width - 36, 36) - certificate.line(width - 36, 36, width - 36, height - 36) - certificate.setFont("Helvetica-Bold", 16) - certificate.drawString(85, height - 36 - 6, "Liste der Gebäude") - - # end page, continue to next page - certificate.showPage() - - else: - num_data_cols = len(gebaeudeliste[0]) - for page in range(1, total_pages + 1, 1): - offset = 26 * (page - 1) - rows_on_page = min(26, len(gebaeudeliste) - offset) - - if page != total_pages or len(gebaeudeliste) % 26 == 0: - for ii in range(n_rows + 1): - certificate.line(54, table_top - (18 * ii), width - 54, table_top - (18 * ii)) - for jj in range(n_columns + 1): - certificate.line(54 + table_width * (jj / n_columns), table_top, - 54 + table_width * (jj / n_columns), table_bottom) - - # add table values - certificate.setFont("Helvetica", 6) - for i in range(rows_on_page): - row_idx = offset + i - certificate.drawString((table_width / 15) + ( - ((table_width / 15) - (len(str(i + (26 * (page - 1))))) * 3.2) / 2) + 10, - table_top - 11 - 18 - (18 * i), str(row_idx)) - for j in range(num_data_cols): - val = gebaeudeliste[row_idx][j] - certificate.drawString((table_width * (j + 2) / 15) + (((table_width / 15) - ( - len(str(gebaeudeliste[i + (26 * (page - 1))][j]))) * 3.2) / 2) + 10, - table_top - 11 - 18 - (18 * i), - str(val)) - - elif page == total_pages: - - n_rows = len(gebaeudeliste) % 26 + 1 - table_bottom = table_top - (18 * n_rows) - - for ii in range(n_rows + 1): - certificate.line(54, table_top - (18 * ii), width - 54, table_top - (18 * ii)) - for jj in range(n_columns + 1): - certificate.line(54 + table_width * (jj / n_columns), table_top, - 54 + table_width * (jj / n_columns), table_bottom) - - # add table values - certificate.setFont("Helvetica", 6) - for i in range(rows_on_page): - row_idx = offset + i - certificate.drawString((table_width / 15) + ( - ((table_width / 15) - (len(str(i + (26 * (page - 1))))) * 3.2) / 2) + 10, - table_top - 11 - 18 - (18 * i), str(row_idx)) - for j in range(num_data_cols): - val = gebaeudeliste[row_idx][j] - certificate.drawString((table_width * (j + 2) / 15) + (((table_width / 15) - ( - len(str(gebaeudeliste[i + (26 * (page - 1))][j]))) * 3.2) / 2) + 10, - table_top - 11 - 18 - (18 * i), - str(val)) - # column titles - certificate.setFont("Helvetica-Bold", 6) - column_titles = ( - "Gebäude ID", "Gebäudetyp", "Baujahr", "Sanierung", "Sp-Masse", "N-Absenkung", - "Wohnfläche", "Heizung", "EV", - "fTES", "fBAT", "fPV1", "fPV2", "fSTC", "gammaPV ", "EV Charging") - for i in range(len(column_titles)): - certificate.drawString(54 + table_width * (i / n_columns) + ( - ((table_width / len(column_titles)) - len(column_titles[i]) * 3.2) / 2), table_top - 11, - column_titles[i]) - - # add border and title - certificate.setStrokeColorRGB(54 / 256, 132 / 256, 39 / 256) - certificate.setLineWidth(2) - certificate.setLineCap(2) - certificate.line(36, height - 36, 78, height - 36) - certificate.line(268, height - 36, width - 36, height - 36) - certificate.line(36, height - 36, 36, 36) - certificate.line(36, 36, width - 36, 36) - certificate.line(width - 36, 36, width - 36, height - 36) - certificate.setFont("Helvetica-Bold", 16) - certificate.drawString(85, height - 36 - 6, - "Liste der Gebäude (" + str(page) + "/" + str(total_pages) + ")") - - certificate.showPage() - - try: - data.centralDevices["capacities"] - - certificate.setPageSize(reportlab.lib.pagesizes.A4) - - width, height = reportlab.lib.pagesizes.A4 - top4 = 40 - bottom4 = 350 - - certificate.setStrokeColorRGB(54 / 256, 132 / 256, 39 / 256) - certificate.setLineWidth(2) - certificate.setLineCap(2) - certificate.line(72, height - top4, 78, height - top4) - certificate.line(180, height - top4, width - 72, height - top4) - certificate.line(72, height - top4, 72, height - bottom4) - certificate.line(72, height - bottom4, width - 72, height - bottom4) - certificate.line(width - 72, height - bottom4, width - 72, height - top4) - certificate.setFont("Helvetica-Bold", 16) - certificate.drawString(85, height - top4 - 6, "Energy Hub") - - # ——— prepare rows: only feasible devices - rows = [["Device", "Capacity"]] - for dev, spec in data.centralDevices["capacities"].items(): - # skip non-dict entries - if not isinstance(spec, dict): - continue - cap = spec.get("cap", None) - if cap is None or cap <= 0: - continue - - # HP special naming - if dev in ["HP","GHP","BHP","H2HP","OHP"]: - if data.central_device_data["AirHP"]["feasible"]: - name = "Air-source Heat Pump" - elif data.central_device_data["GroundHP"]["feasible"]: - name = "Ground-source Heat Pump" - else: - name = "Heat Pump" - unit = "kW" - - # CC special naming - elif dev == "CC": - if data.central_device_data["AirCC"]["feasible"]: - name = "Air-cooled Chiller" - else: - name = "Cooling Chiller" - unit = "kW" - - # for everything else - else: - name_map = { - "PV": "Solar Panels", - "WT": "Wind Turbine", - "WAT": "Water Turbine", - "STC": "Solar Thermal Collector", - "CHP": "Combined Heat & Power", - "BOI": "Boiler", - "GHP": "Gas Heat Pump", - "EB": "Electric Boiler", - "AC": "Absorption Chiller", - "BCHP": "Biogas CHP", - "BBOI": "Biogas Boiler", - "WCHP": "Waste Heat CHP", - "WBOI": "Waste Heat Boiler", - "ELYZ": "Electrolyzer", - "FC": "Fuel Cell", - "H2S": "Hydrogen Storage", - "SAB": "Sabatier Reactor", - "TES": "Heat Storage", - "CTES": "Cold Storage", - "BAT": "Battery", - "GS": "Gas Storage" - } - name = name_map.get(dev, dev) - unit_map = { - "TES": "kWh", - "CTES": "kWh", - "BAT": "kWh", - "GS": "kWh" - } - unit = unit_map.get(dev, "kW") - - rows.append([name, f"{cap:.2f} {unit}"]) - - - n_rows = len(rows) - margin = 100 - table_width = width - 2 * margin - first_col = table_width * 0.4 - second_col = table_width - first_col - row_h = 18 - - table_top = height - top4 - 15 - table_bottom = table_top - n_rows * row_h - - certificate.setStrokeColorRGB(0, 0, 0) - certificate.setLineWidth(1) - - for i in range(n_rows + 1): - y = table_top - i * row_h - certificate.line(margin, y, margin + table_width, y) - - x = margin - certificate.line(x, table_top, x, table_bottom) - x += first_col - certificate.line(x, table_top, x, table_bottom) - x += second_col - certificate.line(x, table_top, x, table_bottom) - - certificate.setFont("Helvetica-Bold", 11.5) - certificate.drawCentredString(margin + first_col / 2, table_top - 3 * row_h / 4, rows[0][0]) - certificate.drawCentredString(margin + first_col + second_col / 2, table_top - 3 * row_h / 4, - rows[0][1]) - - certificate.setFont("Helvetica", 11.5) - for idx, (dev, cap) in enumerate(rows[1:], start=1): - y = table_top - idx * row_h - 3 * row_h / 4 - certificate.drawCentredString(margin + first_col / 2, y, dev) - certificate.drawCentredString(margin + first_col + second_col / 2, y, cap) - - certificate.showPage() - - except KeyError: - pass - - certificate.setPageSize(reportlab.lib.pagesizes.A4) - width, height = reportlab.lib.pagesizes.A4 - - # add border and title - certificate.setStrokeColorRGB(54 / 256, 132 / 256, 39 / 256) - certificate.setLineWidth(2) - certificate.setLineCap(2) - certificate.line(36, height - 36, 78, height - 36) - certificate.line(250, height - 36, width - 36, height - 36) - certificate.line(36, height - 36, 36, 36) - certificate.line(36, 36, width - 36, 36) - certificate.line(width - 36, 36, width - 36, height - 36) - certificate.setFont("Helvetica-Bold", 16) - certificate.drawString(85, height - 36 - 6, "Allgemeine Hinweise") - - # add information - terms = ["Bezeichnungen in der Liste der Gebäude", "Energetische Kennwerte", "Optimierter Anlagenbetrieb"] - details = [ - "Gebäude ID: Gebäudenummer zur Identifizierung
" - "Gebäudetyp: SFH = Einfamilienhaus, MFH = Mehrfamilienhaus, TH = Reihenhaus, AB = Wohnblock, " - "OB = Bürogebäude, SC = Schule, GS = Lebensmittelgeschäft, RE = Restaurant, " - "MFH+GR = Mehrfamilienhaus+Lebensmittelgeschäft, AB+GR = Wohnblock+Lebensmittelgeschäft, " - "MFH+RE = Mehrfamilienhaus+Restaurant, AB+RE = Wohnblock+Restaurant
" - "Baujahr: Baualtersklasse (vor 1969, 1968-1978, 1979-1983, 1984-1994, 1995-2001, 2002-2009, " - "2010-2015, ab 2016)
" - "Sanierung für Wohngebäude: 0 = Bestand, 1 = Sanierung nach EnEV 2016, 2 = Sanierung nach KfW 55
" - "Sanierung für Nichtwohngebäude: 0 = Nichtsaniert, 1 = Teilsaniert (nur Fenster und Wände), " - "2 = Vollsaniert (Decke, Fenster, Dach und Wände)
" - "Sp-Masse: Gebäudespeichermasse: 0 = Leichtbau, 1 = Mittelbau, 2 = Massivbau
" - "N-Absenkung: Nachtabsenkung: 0 = keine Nachtabsenkung, 1 = mit Nachtabsenkung
" - "Wohnfläche: Nettoraumfläche in m²
" - "Heizung: ausgewählter Wärmeerzeuger
" - "EV: Zwischen 0 und 1; Anteil der Elektroautos am Gesamtfahrzeugbestand im Gebäude
" - "fTES: Größe des Pufferspeichers in Liter pro kW Heizleistung der Wärmeerzeugungsanlage
" - "fBAT: Größe des Batteriespeichers in abhängigkeit der Leistung der PV-Anlage in Wh/W_PV
" - "fPV1: Anteil der gesamten Dachfläche, der auf Dachseite 1 mit Photovoltaik belegt ist. Dachseite 1 " - "ist dabei die Seite, für die der Azimutwinkel gammaPV vergegeben wird (Informationen zu Dachflächen " - "sind den Typgebäuden nach Tabula zu entnehmen)
" - "fPV2: Anteil der gesamten Dachfläche, der auf Dachseite 2 mit Photovoltaik belegt ist. Der Azimutwinkel " - 'von Dachseite 2 wird als 180° zu gammaPV gedreht ("gegenüberliegend") berechnet.
' - "fSTC: Anteil der Dachfläche, die mit Solarthermie ausgestattet ist (Informationen zu Dachflächen " - "sind den Typgebäuden nach Tabula zu entnehmen)
" - "gammaPV: Azimut = Himmelsausrichtung von Dachseite 1, Ausrichtung nach Süden entspricht 0°
" - "EV Charging: Ladeverhalten des Elektroautos (bi-direktional: Be- und Entladung, Nutzung als " - "Stromspeicher, on-demand: Beladung nach Bedarf, intelligent: optimierte Beladung)
", - "Die hier angegebenen Werte basieren auf den rechnerischen Bedarfen auf Nutzerebene. " - "Ein Anlagenbetrieb ist hier nicht berücksichtigt.
" - "Nutzenergiebedarf: Über alle Gebäude aufsummierter Nutzenergiebedarf (Haushaltsstrom, Wärme, " - "Trinkwarmwasser, Kälte und EV-Strom)
" - "Norm-Heizlast: Über alle Gebäude aufsummierte Norm-Heizlast nach DIN EN ISO 13790
" - "Energiebedarfe (MWh): Über alle Gebäude aufsummierten Jahresenergiebedarfe auf Basis der " - "generierten Bedarfsprofile (für Wärme, Kälte, Haushaltsstrom, Trinkwarmwasser und Elektroautos)
" - "Maximale Leistungen: Maximale Leistungen in kW im Quartier auf Basis der aufsummierten " - "Bedarfsprofile aller Gebäude (ohne Betriebsoptimierung)


", - "Die hier angegebenen Werte wurden nach einer Betriebsoptimierung unter Berücksichtigung aller " - "definierten Anlagen (Erzeuger wie auch Speicher) im Quartier berechnet.
" - "CO2-äqui. Emissionen: Im Quartier emittierte CO2-Äquivalente in t/a durch den optimierten " - "Betrieb (Gasbedarf und Strombedarf)
" - "Energiekosten: Spezifische Betriebskosten des gesamten Quartiers in €/kWh auf Basis der " - "Betriebsoptimierung
" - "Fixed Costs: total fixed, annualized cost of all installed energy assets, including capital " - "expenditures (CAPEX) and fixed operation & maintenance (O&M) costs
" - "Spitzenlast (el.): Maximaler Strombezug des gesamten Quartiers aus übergeordnetem " - "Stromnetz auf Basis der Betriebsoptimierung
" - "Max. Einspeiseleistung: Maximale Stromeinspeisung des gesamten Quartiers in " - "übergeordnetes Stromnetz auf Basis der Betriebsoptimierung
" - "Supply-Cover-Faktor: Anteil des aus den Gebäuden des Quartiers ins lokale Netz eingespeisten " - "Stroms, der für den Eigenverbrauch innerhalb des Quartiers durch andere Gebäude genutzt wird " - "(Werte zwischen 0 % und 100 %)
" - "Demand-Cover-Faktor: Anteil des residualen Strombedarfs im Quartier, der durch den von den Gebäuden " - "im Quartier erzeugten und ins lokale Netz eingespeisten Stroms gedeckt wird (Werte zwischen 0 % und 100 %)
" - "El-Autonomy-Faktor: Anteil der Betriebszeit, in der der lokale Strombedarf vollständig durch die " - "Stromerzeugung im Quartier gedeckt wird (Werte zwischen 0 % und 100 %)
" - ] - - details_Style = ParagraphStyle('My Para style', - fontName='Helvetica', - fontSize=10, - alignment=0, - leftIndent=10, - firstLineIndent=-20, - spaceafter=6 - ) - - term_height = height - 100 - - for i in range(len(terms)): - p = Paragraph("" + terms[i] + ":
" + details[i], details_Style) - p.wrap(width - 144, term_height) - num_lines = len(p.blPara.lines) - term_height = term_height - num_lines * 10 - p.drawOn(certificate, 72, term_height) - term_height = term_height - 30 - - # save certificate - certificate.save() \ No newline at end of file + certGenerator = CertificateBuilder(data = data, kpis=self, result_path=result_path) + certGenerator.generate_certificate() \ No newline at end of file diff --git a/districtgenerator/classes/certificate_generator.py b/districtgenerator/classes/certificate_generator.py new file mode 100644 index 0000000..2258371 --- /dev/null +++ b/districtgenerator/classes/certificate_generator.py @@ -0,0 +1,3435 @@ +# -*- coding: utf-8 -*- + +""" +The code of this file serves the purpose of generating the PDF district energy certificate from prepared data and KPIs. + +- Purpose: Constructs and renders a PDF report (ReportLab) from `data` and `kpis`. +- ThemeManager: Manages colors, fonts, page margins, and layout parameters. +- ReportComponent / BaseReportFlowable: Global style, translations, and flowable base. +- FrameBox + Flowables: Layout building blocks and individual sections. +- DataExtractor: Extracts and prepares key figures, tables, and district structure for presentation. +- CertificateTemplate / CertificateLayout: Page and frame templates, along with story assembly. +- CertificateBuilder: Orchestrates Theme, DataExtractor, and creates the PDF via `doc.build(story)`. + +Version Date: 22.05.2026 +""" + +import json +from districtgenerator.classes import * +from reportlab.platypus import BaseDocTemplate, PageTemplate, Frame +from reportlab.lib.pagesizes import A4, A3, landscape +from reportlab.lib import colors +from reportlab.lib.styles import StyleSheet1, ParagraphStyle +from reportlab.platypus import Flowable, Table, TableStyle, Paragraph, Spacer +from reportlab.platypus import NextPageTemplate, PageBreak, FrameBreak +from datetime import datetime +import os +from collections import OrderedDict +import pandas as pd +import math + +from reportlab.graphics.shapes import Drawing, Rect, String, Line, Circle, Polygon, Group +from reportlab.graphics.charts.piecharts import Pie +from reportlab.graphics.charts.legends import Legend +from reportlab.platypus import Flowable +from reportlab.lib import colors +from reportlab.graphics.charts.barcharts import VerticalBarChart + + +DEBUG = False # If set to True boxes are drawn around the different components to visualize the layout and available space +debug_line_width = 0.1 # Line width for the debug boxes + +################################################################################ +# Certificate Style Configuration Class +################################################################################ + +class ThemeManager: + """ + This class is responsible for managing the different styles and enforcing a consistent design throughout the certificate. + """ + + FILE_LAYOUT = { + 'A4': { + 'page_margin_x': 72, + 'page_margin_small_x': 36, + 'page_margin_y': 72, + 'page_margin_small_y': 36, + 'section_padding': 10, + 'line_width': { + 'thick': 4, + 'medium': 2, + 'thin': 1 + }, + 'spacing': { + 'large': 10, + 'medium': 10, + 'small': 6 + }, + 'padding': 10 + }, + 'A3': { # Overwrites for A3. If a parameter is not specified here, the A4 value is used. + 'section_padding': 15, + 'spacing': { + 'large': 30, + 'medium': 20, + 'small': 8 + }, + 'padding': 15 + } + } + + def __init__(self, report_config): + self.report_config = report_config # Contains info about sizing, fonts and colors + self.pagesize = self.report_config["pagesize"] + self.colors = self.report_config["colors"] + self.fonts = self.report_config["fonts"] + + available_pagesizes = {"A3", "A4"} + if self.pagesize not in available_pagesizes: + print(f"Warning: Pagesize '{self.pagesize}' not recognized. Available options are: {available_pagesizes}. Defaulting to 'A4'.") + self.pagesize = "A4" + + self.layout = self.FILE_LAYOUT["A4"].copy() + + # overwrite the A4 baseline with pagesize specific values + if self.pagesize != 'A4': + self.layout.update(self.FILE_LAYOUT[self.pagesize]) + + # --- Getters for Styles --- + def get_color(self, color_type:str): + """Returns the colors defined in the report configuration.""" + try: + return self.colors[color_type] + except KeyError: + raise KeyError(f"Color type '{color_type}' not found. Available options: {list(self.colors.keys())}") + + def get_energy_color(self, energy_type:str): + """Returns the color for the specified energy type.""" + try: + return self.colors["energy"][energy_type] + except KeyError: + raise KeyError(f"Energy type '{energy_type}' not found. Available options: {list(self.colors['energy'].keys())}") + + def get_source_color(self, source_type:str): + """Returns the color for the specified energy source type.""" + try: + return self.colors["source"][source_type] + except KeyError: + raise KeyError(f"Source type '{source_type}' not found. Available options: {list(self.colors['source'].keys())}") + + def get_layout_color(self, layout_type: str): + """Returns the color for district layout elements (connected, eh, etc.).""" + try: + return self.colors["layout"][layout_type] + except KeyError: + raise KeyError(f"Layout type '{layout_type}' not found. Available options: {list(self.colors['layout'].keys())}") + + def get_layout_size(self, size_type: str): + """Returns the size for layout elements.""" + try: + return self.report_config["sizes"][size_type] + except KeyError: + raise KeyError(f"Size type '{size_type}' not found. Available options: {list(self.report_config['sizes'].keys())}") + + def get_layout_options(self, option_type: str): + """Returns the boolean value for the specified layout option.""" + try: + return self.report_config["layout_options"][option_type] + except KeyError: + raise KeyError(f"Layout option type '{option_type}' not found. Available options: {list(self.report_config['layout_options'].keys())}") + + def get_font_size(self, font_type:str): + """Returns the font size for the specified font type.""" + try: + return self.fonts["sizes"][font_type] + except KeyError: + raise KeyError(f"Font type '{font_type}' not found. Available options: {list(self.fonts['sizes'].keys())}") + + def get_font(self, bold:bool=False): + """Returns the font name based on whether bold is True or False.""" + return self.fonts["bold"] if bold else self.fonts["regular"] + + def get_line_width(self, line_type:str): + """Returns the line width for the specified line type.""" + try: + return self.layout['line_width'][line_type] + except KeyError: + raise KeyError(f"Line type '{line_type}' not found. Available options: {list(self.layout['line_width'].keys())}") + + def get_spacing(self, spacing_type:str): + """Returns the spacing for the specified spacing type.""" + try: + return self.layout['spacing'][spacing_type] + except KeyError: + raise KeyError(f"Spacing type '{spacing_type}' not found. Available options: {list(self.layout['spacing'].keys())}") + + def get_padding(self): + """Returns the padding for the current pagesize.""" + return self.layout['padding'] + + def get_page_margins(self): + """Returns the page margins for the current pagesize.""" + return { + 'x': self.layout['page_margin_x'], + 'small_x': self.layout['page_margin_small_x'], + 'y': self.layout['page_margin_y'], + 'small_y': self.layout['page_margin_small_y'] + } + + def get_pagesize(self): + """Returns the pagesize as a tuple (width, height) for the current pagesize.""" + PAGESIZE_MAP = { + "A4": A4, + "A3": A3 + } + try: + return PAGESIZE_MAP[self.pagesize] + except KeyError: + raise ValueError(f"Pagesize '{self.pagesize}' not recognized. Available options are: {list(PAGESIZE_MAP.keys())}.") + + + # --- Defined Styles for the Certificate --- + def get_paragraph_styles(self) -> StyleSheet1: + """Returns an object with the defined paragraph styles for the certificate.""" + + styles = StyleSheet1() + styles.add(ParagraphStyle( + name='Normal', + fontName=self.fonts["regular"], + fontSize=self.fonts["sizes"]["body"], + textColor=self.colors["text"], + )) + + styles.add(ParagraphStyle( + name='SectionTitle', + fontName=self.fonts["bold"], + fontSize=self.fonts["sizes"]["section_title"], + textColor=self.colors["text"], + )) + + styles.add(ParagraphStyle( + name='Small', + fontName=self.fonts["regular"], + fontSize=self.fonts["sizes"]["small"], + textColor=self.colors["text_light"] + )) + + return styles + + def get_table_styles(self) -> dict: + """Returns a dictionary of table styles that can be used for different types of tables in the certificate.""" + styles = {} + + styles['standard'] = TableStyle([ + # Overall table style + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('INNERGRID', (0, 0), (-1, -1), 0.25, self.colors["text"]), + ('BOX', (0, 0), (-1, -1), 1, self.colors["text"]), + ('TEXTCOLOR', (0, 0), (-1, -1), self.colors["text"]), + ('FONTSIZE', (0, 0), (-1, -1), self.fonts["sizes"]["table"]), + ('BACKGROUND', (0, 0), (-1, -1), self.colors["background"]), + + # Data rows style + ('FONTNAME', (1, 1), (-1, -1), self.fonts["regular"]), + # header row bold + ('FONTNAME', (0, 0), (-1, 0), self.fonts["bold"]), + # first column bold + ('FONTNAME', (0, 1), (0, -1), self.fonts["bold"]), + ]) + + styles['input_data'] = TableStyle([ + # General table style + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('GRID', (0, 0), (-1, -1), 0.25, self.colors["text"]), + + # Header style + ('BACKGROUND', (0, 0), (-1, 0), self.colors["background"]), + ('TEXTCOLOR', (0, 0), (-1, 0), self.colors["text"]), + ('FONTNAME', (0, 0), (-1, 0), self.fonts["bold"]), + ('FONTSIZE', (0, 0), (-1, 0), self.fonts["sizes"]["dense"]), + + # Body style + ('TEXTCOLOR', (0, 1), (-1, -1), self.colors["text"]), + ('FONTNAME', (0, 1), (-1, -1), self.fonts["regular"]), + ('FONTSIZE', (0, 1), (-1, -1), self.fonts["sizes"]["dense"]), + + # Padding + ('TOPPADDING', (0, 0), (-1, -1), int(self.fonts["sizes"]["dense"] * 0.25)), + ('BOTTOMPADDING', (0, 0), (-1, -1), int(self.fonts["sizes"]["dense"] * 0.25)), + ]) + + styles['listed'] = TableStyle([ + ('FONTNAME', (0, 0), (-1, -1), self.fonts["regular"]), + ('FONTSIZE', (0, 0), (-1, -1), self.fonts["sizes"]["table"]), + ('TEXTCOLOR', (0, 0), (-1, -1), self.colors["text"]), + ('ALIGN', (0, 0), (0, -1), 'LEFT'), + ('ALIGN', (1, 0), (1, -1), 'LEFT'), + ('LEFTPADDING', (0, 0), (-1, -1), 0), + ('TOPPADDING', (0, 0), (-1, -1), 1), + ('BOTTOMPADDING', (0, 0), (-1, -1), 1), + ]) + + styles['layout'] = TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('LEFTPADDING', (0, 0), (-1, -1), 0), + ('RIGHTPADDING', (0, 0), (-1, -1), 0), + ('BOTTOMPADDING', (0, 0), (-1, -1), 0), + ('TOPPADDING', (0, 0), (-1, -1), 0), + ]) + + return styles + +class ReportComponent: + """ + Base class to hold the shared theme state. Avoids passing the theme manager to every single component. The theme can be set once using the apply_style method and is then available to all components as a class variable. + """ + # Shared static variable to hold the theme manager and the translations dictionary + style = None + translations = {} + + @classmethod + def apply_style(cls, theme_manager: ThemeManager, language: str): + """ + Sets the theme once for all components. + """ + cls.style = theme_manager + srcPath = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + translations_path = os.path.join(srcPath, "data", "report_translations.json") + cls.__load_translations(translations_path, language) + + @classmethod + def get_style(cls): + """ + Returns the theme manager to access the styles. Can be used in all components after the theme has been set. + """ + if cls.style is None: + raise Exception("Theme not set. Please call ReportComponent.apply_style(theme_manager) before creating any components.") + return cls.style + + @classmethod + def translate(cls, text_key: str) -> str|dict: + """ + Translates a given key. Returns the key itself if not found. + """ + return cls.translations.get(text_key, text_key) + + @classmethod + def __load_translations(cls, file_path, language): + try: + with open(file_path, 'r', encoding='utf-8') as f: + translation_json = json.load(f) + + except (FileNotFoundError, json.JSONDecodeError) as e: + print(f"Warning: Could not load translations from {file_path}. Error: {e}") + cls.translations = {} + return + + english_translations = translation_json["en"] + specific_translations = translation_json.get(language, {}) + + # Merge the english translations with the specific language translations, where the specific language translations overwrite the english ones + cls.translations = english_translations | specific_translations + +class BaseReportFlowable(Flowable, ReportComponent): + """ + Base class for all Flowables in the certificate. Includes drawing of the debug frame if DEBUG is set to True + """ + + def draw(self): + self.draw_content() + + if DEBUG: + c = self.canv + c.saveState() + c.setStrokeColor(colors.red) + c.setLineWidth(debug_line_width) + c.rect(0, 0, self.width, self.height, stroke=1, fill=0) + c.restoreState() + + def draw_content(self): + """ + This method should be implemented by all child classes to draw the actual content of the Flowable. + The base draw() method will handle the drawing of the debug frame if DEBUG is True. + """ + raise NotImplementedError("Subclasses of BaseReportFlowable must implement the draw_content() method to draw their content.") + +################################################################################ +# Basic Layout is a Framebox around the content +################################################################################ + +class FrameBox(BaseReportFlowable): + def __init__(self, title: str, content_flowable: Flowable = None) -> None: + """ + title: title as a string + content_flowable: Single Flowable within the block + """ + super().__init__() + self.title = title #str + self.style = self.get_style() # Get the ThemeManager instance to access the styles and design parameters + + # Design parameters + self.fontstyle = self.style.get_font(bold=True) + self.fontsize = self.style.get_font_size('section_title') + self.font_color = self.style.get_color('text') + + # Line parameters + self.line_width = self.style.get_line_width('medium') + self.line_color = self.style.get_color('primary_color') + + # Padding -> Distance between content and frame + self.padding = self.style.get_padding() + + self.width = None + self.height = None + + # Content Flowable + self.content_flowable = content_flowable + self.table_full_width = False # If True, table columns are adjusted to full width + + def set_content(self, content_flowable: Flowable, full_width:bool=False): + """Allows placing of the content after initialization.""" + self.content_flowable = content_flowable + self.table_full_width = full_width + + def get_available_height(self, availHeight=None): + """Returns the available height for the content within the frame.""" + if self.height is None: + if availHeight is not None: + return availHeight - self.fontsize - 2*self.padding - self.line_width + else: + raise ValueError("FrameBox height is not set and no availHeight is given. Call wrap() first or give .") + return self.height - self.fontsize - 2*self.padding - self.line_width + + def get_available_width(self, availWidth=None): + """Returns the available width for the content within the frame.""" + if self.width is None: + if availWidth is not None: + return availWidth - 2*self.padding - 2*self.line_width + else: raise ValueError("FrameBox width is not set and no availWidth is given. Call wrap() first or give .") + return self.width - 2*self.padding - 2*self.line_width + + def wrap(self, availWidth, availHeight): + # with is the total available width + self.width = availWidth + + # height of the content area + if self.content_flowable is not None: + + # Set the size of the content + availContentWidth = self.get_available_width(availWidth) + availContentHeight = self.get_available_height(availHeight) + + # dynamic column width for tables + if isinstance(self.content_flowable, Table) and self.table_full_width: + n_cols = len(self.content_flowable._cellvalues[0]) + col_width = (self.width - 2*self.padding) / n_cols + # Set the column widths + self.content_flowable._argW = [col_width] * n_cols + + # content_height is height of the content + content_width, content_height = self.content_flowable.wrap(availContentWidth, availContentHeight) + else: + content_height = 0 + + # Needed Total height = title + content + padding above and below content + bottom line + self.height = self.fontsize + content_height + 2*self.padding + self.line_width + + return self.width, self.height + + def draw_content(self): + """Draw the frame with title and content.""" + c = self.canv # Canvas for frame + c.saveState() + + w = self.width + + y_topframe = int(self.height - 2*self.fontsize/3 + self.line_width/2) # Position of the middle of the top line + y_bottomframe = 0 # no padding below + + # Linestyle of the frame + c.setStrokeColorRGB(*self.line_color) + c.setLineWidth(self.line_width) + c.setLineCap(2) # + + title_text = str(self.title) # Title should be a string + + title_w = c.stringWidth(title_text, self.fontstyle, self.fontsize) + + y_title = int(y_topframe - self.line_width/2 - self.fontsize/3) + gap = 5 # small gap between line and text + + # --- Title centered between lines --- + x_title = int(self.padding + gap) + # --- Lines --- + # Side frame + c.line(0, y_topframe, 0, y_bottomframe) # left line + c.line(0, y_bottomframe, w, y_bottomframe) # bottom line + c.line(w, y_bottomframe, w, y_topframe) # right line + + # left + c.line(0, y_topframe, x_title - gap, y_topframe) + # right + c.line(x_title + title_w + gap, y_topframe, w, y_topframe) + + # --- Title --- + c.setFont(self.fontstyle, self.fontsize) + c.setFillColorRGB(*self.font_color) + c.drawString(x_title, y_title, title_text) + + + + # Placement of content + if self.content_flowable is not None: + y = int(y_topframe - self.line_width/2 - self.fontsize/3 - self.padding) # Position of the top of the content area + x = self.padding + self.line_width + w_f, h_f = self.content_flowable.wrap(w - 2*x, y - y_bottomframe - self.padding - self.line_width) + self.content_flowable.drawOn(c, x, y - h_f) + + + c.restoreState() + + @classmethod + def get_available_space_content(cls, availWidth, availHeight): + """ + Returns the available space for content within the frame box. Where the frame box has availWidth and availHeight for itself. + This can be used without instantiating the class. To be used for layout calculations of the content. + """ + style = cls.get_style() + + # Same calculation as in get_available_width and get_available_height + padding = style.get_padding() + line_width = style.get_line_width('medium') + title_height = style.get_font_size('section_title') + + content_width = availWidth - 2*padding - 2*line_width + content_height = availHeight - 2*padding - title_height - line_width + + return content_width, content_height + +################################################################################ +# Base elements (Flowables) that can be used in the layout +################################################################################ + +class Header(BaseReportFlowable): + """ + This class generates the header section of the certificate. + """ + def __init__(self, title) -> None: + super().__init__() + self.style = self.get_style() + self.title = title + self.width = None + self.height = None + + # Design parameters + self.fontstyle = self.style.get_font(bold=True) + self.fontsize = self.style.get_font_size('title') + self.font_color = self.style.get_color('text') + + # line parameters + self.line_width = self.style.get_line_width('thick') + self.line_color = self.style.get_color('primary_color') + self.line_gap = 5 + + def wrap(self, availWidth, availHeight): + self.width = availWidth + self.height = self.fontsize + self.line_gap + self.line_width + return self.width, self.height + + def draw_content(self): + c = self.canv + c.saveState() + + # Header text + c.setFont(self.fontstyle, self.fontsize) + c.setFillColorRGB(*self.font_color) + + # Text position + y_text = self.height - self.fontsize + c.drawString(0, y_text, self.title) + + # Line position below the text + c.setStrokeColorRGB(*self.line_color) + c.setLineWidth(self.line_width) + c.line(0, 0, self.width, 0) + + c.restoreState() + +class Energiekennwerte(BaseReportFlowable): + """ + Generates the Energiekennwerte section of the certificate. + Arranges a summary table on the left, and a pie chart stacked above + a max loads table on the right. + """ + def __init__(self, kpi_data: dict, availWidth: float): + super().__init__() + self.style = self.get_style() + self.width = availWidth + self.height = 0 + + self.district_key_kpis = kpi_data["district_key_kpis"] + self.district_operation_kpis = kpi_data["district_operation_kpis"] + self.max_loads_data = kpi_data["max_loads_table"] + self.energy_pie_data = kpi_data["pie_chart_energy"] + + + + # Arrange components in a transparent layout table + col_w_left = self.width *0.6 + col_w_right = self.width *0.4 + + self.t_summary = self._build_listed_table(self.district_key_kpis) + self.t_operation = self._build_listed_table(self.district_operation_kpis) + self.pie_chart_flowable = EnergyPieChart(pie_data=self.energy_pie_data, availWidth=col_w_right) + self.max_loads_flowable = MaxLoadsBarChart(max_loads_data=self.max_loads_data, availWidth=col_w_right) + + left_column_content = [Spacer(1, self.style.get_padding()),self.t_summary, Spacer(1, 3*self.style.get_padding()), Title(self.translate("title_optimized_operation_kpis")), Spacer(1, self.style.get_padding()), self.t_operation] + right_column_content = [self.pie_chart_flowable, Spacer(1, self.style.get_padding()), self.max_loads_flowable] + + layout_data = [ + [left_column_content, right_column_content] + ] + + self.layout_table = Table(layout_data, colWidths=[col_w_left, col_w_right]) + self.layout_table.setStyle(self.style.get_table_styles()['layout']) + + def _build_listed_table(self, data: list) -> Table: + """Builds table with listed style.""" + t = Table(data) + t.setStyle(self.style.get_table_styles()['listed']) + + if DEBUG: + t.setStyle(TableStyle([ + ('BOX', (0, 0), (-1, -1), debug_line_width, colors.red) + ])) + + return t + + def wrap(self, availWidth, availHeight): + """Calculates the needed height based on the pre-built layout table.""" + _, self.height = self.layout_table.wrap(availWidth, availHeight) + return self.width, self.height + + def draw_content(self): + """Draws the layout table onto the canvas.""" + self.layout_table.drawOn(self.canv, 0, 0) + +class EnergyPieChart(BaseReportFlowable): + """ + Standalone Flowable that generates a pie chart for annual energy demands, + using the configured colors. + """ + + def __init__(self, pie_data: dict, availWidth: float): + super().__init__() + self.style = self.get_style() + self.pie_data = pie_data + self.availWidth = availWidth + + self.drawing = self._create_drawing() + self.width = self.drawing.width + self.height = self.drawing.height + + def _create_drawing(self) -> Drawing: + """Constructs the Drawing object containing the Pie and Legend.""" + width = self.availWidth + padding = self.style.get_padding() + + labels = [] + values = [] + slice_colors = [] + + legend_font = self.style.get_font(bold=False) + legend_size = self.style.get_font_size('small') + + title_font = self.style.get_font(bold=True) + title_size = self.style.get_font_size('body') + title_color = colors.Color(*self.style.get_color('text')) + + + # Map pie categories to the specific keys in the colors dictionary + color_mapping = { + self.translate("name_el"): self.style.get_energy_color("electricity"), + self.translate("name_heat"): self.style.get_energy_color("heating"), + self.translate("name_dhw"): self.style.get_energy_color("dhw"), + self.translate("name_cool"): self.style.get_energy_color("cooling"), + self.translate("name_ev"): self.style.get_energy_color("ev") + } + + for key, color_rgb in color_mapping.items(): + val = self.pie_data[key] + + labels.append(key) + values.append(float(val)) + slice_colors.append(colors.Color(*color_rgb)) + + # --- Shared Legend Logic --- + legend = Legend() + legend.alignment = 'right' + legend.fontName = legend_font + legend.fontSize = legend_size + legend.dx = 7 + legend.dy = 7 + legend.yGap = 0 + legend.deltax = 90 + legend.deltay = 10 + legend.strokeWidth = 0 + legend.strokeColor = colors.white + legend.columnMaximum = 3 + + legend.colorNamePairs = [(slice_colors[i], f"{labels[i]}: {round(values[i], 1)}") for i in range(len(labels))] + + # Place temporarily at (0,0) to calculate the bounding box + legend.x = 0 + legend.y = 0 + bounds = legend.getBounds() + actual_legend_width = bounds[2] - bounds[0] + + # Place the legend at the proper position based on the actual size + legend.x = (width - actual_legend_width) / 2 + legend.y = -bounds[1] # Ensures the true bottom rests exactly at the lower bound + + # --- Pie Chart Logic --- + pie = Pie() + pie.width = 90 + pie.height = 90 + pie.x = (width - pie.width) / 2 + + # Place pie dynamically above the legend + pie.y = legend.y + bounds[3] + padding + + pie.data = values + pie.labels = None + pie.slices.strokeColor = colors.white + pie.slices.strokeWidth = 1 + pie.slices.labelRadius = 1.5 + pie.slices[3].labelRadius = 1.2 + pie.slices.fontName = self.style.get_font(bold=False) + + for i in range(len(values)): + pie.slices[i].fillColor = slice_colors[i] + + # Calculate dynamic total height based on pie top position and title space + title_y = pie.y + pie.height + self.style.get_spacing('small') + dynamic_height = title_y + title_size + + d = Drawing(width, dynamic_height) + + d.add(pie) + d.add(legend) + + + d.add(String(width / 2.0, title_y, self.translate("title_pie_chart_energy"), + fontName=title_font, + fontSize=title_size, + textAnchor='middle', + fillColor=title_color)) + + # Draw debug boxes for internal components + if DEBUG: + # Pie bounding box + d.add(Rect(pie.x, pie.y, pie.width, pie.height, strokeColor=colors.red, strokeWidth=debug_line_width, fillColor=None)) + + # Legend bounding box using exact final bounds + final_bounds = legend.getBounds() + l_x = final_bounds[0] + l_y = final_bounds[1] + l_w = final_bounds[2] - final_bounds[0] + l_h = final_bounds[3] - final_bounds[1] + d.add(Rect(l_x, l_y, l_w, l_h, strokeColor=colors.red, strokeWidth=debug_line_width, fillColor=None)) + + # Title + d.add(Rect(0, title_y, width, title_size, strokeColor=colors.red, strokeWidth=debug_line_width, fillColor=None)) + + return d + + def wrap(self, availWidth, availHeight): + # The drawing has fixed dimensions, so we just return them directly + return self.width, self.height + + def draw_content(self): + # Draw the internal Drawing onto the Flowable's canvas + self.drawing.drawOn(self.canv, 0, 0) + +class MaxLoadsBarChart(BaseReportFlowable): + """ + Generates a horizontal bar chart representing maximum loads with values aligned to the right. + """ + def __init__(self, max_loads_data: list, availWidth: float): + super().__init__() + self.style = self.get_style() + self.max_loads_data = max_loads_data + self.width = availWidth + self.height = 0 + self.row_height = self.style.get_font_size('body') + 2 + + self.title_font = (self.style.get_font(bold=True), self.style.get_font_size('body')) + self.text_font = (self.style.get_font(bold=False), self.style.get_font_size('body')) + self.number_font = (self.style.get_font(bold=False), self.style.get_font_size('small')) + + + def wrap(self, availWidth, availHeight): + """Calculates needed height dynamically based on rows.""" + self.width = availWidth + self.height = len(self.max_loads_data) * self.row_height + self.style.get_spacing('small') + self.title_font[1] # rows + spacing + title + return self.width, self.height + + def draw_content(self): + """Draws labels, scaled bars, and values onto the canvas.""" + c = self.canv + c.saveState() + + labels = [] + values_text = [] + values_num = [] + + # Parse data + for row in self.max_loads_data: + labels.append(row[0]) + val_str = str(row[1]) + values_text.append(val_str) + # Extract numerical value for scaling + try: + num = float(val_str.split()[0]) + except ValueError: + num = 0.0 + values_num.append(num) + + max_val = max(values_num) if values_num and max(values_num) > 0 else 1 + + # Title + c.setFont(*self.title_font) + c.setFillColorRGB(*self.style.get_color('text')) + c.drawCentredString(self.width / 2.0, self.height - self.title_font[1], self.translate("title_max_loads")) + + # Font settings for body + c.setFont(*self.text_font) + + # Calculate dynamic layout metrics + max_label_width = max([c.stringWidth(lbl, *self.text_font) for lbl in labels]) + max_val_width = max([c.stringWidth(val, *self.number_font) for val in values_text]) + + x_label = 0 + x_bar = max_label_width + self.style.get_padding() + gap_after_bar = self.style.get_spacing('small') + + available_bar_width = self.width - x_bar - max_val_width - gap_after_bar + scale_factor = available_bar_width / max_val + + bar_color = self.style.get_color('secondary_color') + text_color = self.style.get_color('text') + + for i in range(len(values_num)): + y_pos = self.height - self.title_font[1] - self.style.get_spacing('small') - self.text_font[1] - (self.row_height * i) + + # Label + c.setFont(*self.text_font) + c.setFillColorRGB(*text_color) + c.drawString(x_label, y_pos, labels[i]) + + # Bar + bar_w = values_num[i] * scale_factor + c.setFillColorRGB(*bar_color) + c.rect(x_bar, y_pos, bar_w, 6, stroke=0, fill=1) + + # Value + c.setFont(*self.number_font) + c.setFillColorRGB(*text_color) + c.drawString(x_bar + bar_w + gap_after_bar, y_pos, values_text[i]) + + c.restoreState() + +class Title(BaseReportFlowable): + """A simple Flowable to draw a centered bold title.""" + def __init__(self, title_text: str): + super().__init__() + self.style = self.get_style() + self.title_text = title_text + self.font = self.style.get_font(bold=True) + self.font_size = self.style.get_font_size('subsection_title') + self.color = self.style.get_color('text') + self.width = 0 + self.height = self.font_size + 4 + + def wrap(self, availWidth, availHeight): + self.width = availWidth + return self.width, self.height + + def draw_content(self): + c = self.canv + c.setFont(self.font, self.font_size) + c.setFillColorRGB(*self.color) + c.drawString(0, 0, self.title_text) + +class Quartiersstruktur(BaseReportFlowable): + """ + This class generates the Quartiersstruktur section of the certificate providing a summary of the building stock and the neighborhood characteristics. + """ + def __init__(self, summary_table_data: list, general_info: list) -> None: + super().__init__() + self.style = self.get_style() + self.width = None + self.height = None + + # Build the table + self.table = Table(summary_table_data) + self.table.setStyle(self.style.get_table_styles()['standard']) + + self.info_table = Table(general_info) + self.info_table.setStyle(self.style.get_table_styles()['listed']) + + if DEBUG: + self.table.setStyle(TableStyle([ + ('BOX', (0, 0), (-1, -1), debug_line_width, colors.red) + ])) + self.info_table.setStyle(TableStyle([ + ('BOX', (0, 0), (-1, -1), debug_line_width, colors.red) + ])) + + def wrap(self, availWidth, availHeight): + self.width = availWidth + + # Main table width (90% of available space to prevent squeezing) + n_cols = len(self.table._cellvalues[0]) + col_w = (availWidth * 0.9) / n_cols + self.table._argW = [col_w, col_w, col_w, col_w] # All columns same width + self.table_width, self.table_height = self.table.wrap(availWidth, availHeight) + + # Info table width + self.info_table_width, self.info_height = self.info_table.wrap(availWidth, availHeight) + + # Total height: Main Table + Padding + Info Table + self.height = self.table_height + self.style.get_padding() + self.info_height + return self.width, self.height + + def draw_content(self): + c = self.canv + y = self.height + + # Main Table (centered) + x_offset = (self.width - self.table_width) / 2.0 + self.table.drawOn(c, x_offset, y - self.table_height) + + y -= self.table_height + self.style.get_padding() + + # Info Table (centered) + x_offset = (self.width - self.info_table_width) / 2.0 + self.info_table.drawOn(c, x_offset, y - self.info_height) + +class Footer(BaseReportFlowable): + """ + This class generates the Footer section of the certificate. Visiable on the bottom of the first page. + """ + def __init__(self, scenario_name) -> None: + super().__init__() + self.scenario_name = scenario_name + self.style = self.get_style() + + # Design Parameters + self.normal_fontstyle = self.style.get_font(bold=False) + self.normal_fontsize = self.style.get_font_size('small') + self.normal_fontcolor = self.style.get_color('text_light') + + self.highlight_fontstyle = self.style.get_font(bold=True) + self.highlight_fontsize = self.style.get_font_size('highlighted') + self.highlight_fontcolor = self.style.get_color('text') + + # Line Parameters + self.line_width = self.style.get_line_width('medium') + self.line_color = self.style.get_color('primary_color') + + self.padding = self.style.get_padding() + + self.width = None + self.height = None + + def wrap(self, availWidth, availHeight): + self.width = availWidth + self.height = max(self.normal_fontsize, self.highlight_fontsize) + 2*self.padding + 2*self.line_width + return self.width, self.height + + def draw_content(self): + w = self.width + h = self.height + c = self.canv + c.saveState() + + # Draw the lines around the footer + c.setStrokeColorRGB(*self.line_color) + c.setLineWidth(self.line_width) + c.rect(0, 0, w, h, stroke=1, fill=0) + + # Draw the text + available_height = h - 2*self.padding - 2*self.line_width + text_height = max(self.normal_fontsize, self.highlight_fontsize) + y = self.padding + self.line_width + available_height / 2 - text_height/3 + + x1 = self.padding + self.line_width + string1 = self.translate("ui_footer_name") + length1 = c.stringWidth(string1, self.highlight_fontstyle, self.highlight_fontsize) + gap = 3 + string2 = str(self.scenario_name) + string3 = f"{self.translate('ui_footer_created')} {datetime.now().strftime('%d.%m.%Y %H:%M')}" + + c.setFont(self.highlight_fontstyle, self.highlight_fontsize) + c.setFillColorRGB(*self.highlight_fontcolor) + c.drawString(x1, y, string1) + + c.setFont(self.normal_fontstyle, self.normal_fontsize) + c.setFillColorRGB(*self.normal_fontcolor) + c.drawString(x1 + length1 + gap, y, string2) + + c.drawRightString(w - self.padding - self.line_width, y, string3) + c.restoreState() + + @classmethod + def get_required_height(cls,pagesize:tuple=A4): + """ + Returns the required height for the footer. + Can be used without instantiating the class. + """ + style = cls.get_style() + + # Design Parameters (same as for __init__) + normal_fontsize = style.get_font_size('small') + highlight_fontsize = style.get_font_size('highlighted') + + # Line Parameters + line_width = style.get_line_width('medium') + padding = style.get_padding() + + # height calculated same as in wrap() + required_height = max(normal_fontsize, highlight_fontsize) + 2*padding + 2*line_width + + return required_height + +class EnergyHub(BaseReportFlowable): + """ + Flowable that handles the visual layout of the Energy Hub content. + Provides a class method to handle pagination and FrameBox wrapping. + """ + def __init__(self, content_flowable: Flowable): + super().__init__() + self.style = self.get_style() + self.content_flowable = content_flowable + self.width = None + self.height = None + + def wrap(self, availWidth, availHeight): + self.width = availWidth + + if hasattr(self.content_flowable, 'table'): + target_table = self.content_flowable.table + elif isinstance(self.content_flowable, Table): + target_table = self.content_flowable + else: + target_table = None + + if target_table: + n_cols = len(target_table._cellvalues[0]) + target_table._argW = [availWidth / n_cols] * n_cols + + _, self.height = self.content_flowable.wrap(availWidth, availHeight) + return self.width, self.height + + def draw_content(self): + # Delegate drawing to the internal content + self.content_flowable.drawOn(self.canv, 0, 0) + + # here additional specifics can be added + + @classmethod + def create_boxes(cls, data_energyhub: pd.DataFrame, availWidth: float, availHeight: float) -> list: + """Creates a list of FrameBox objects for the Energy Hub data.""" + style = cls.get_style() + boxes = [] + title = cls.translate("title_central_devices") + + # Handle empty data + if data_energyhub is None or data_energyhub.empty: + box = FrameBox(title=title) + p = Paragraph(cls.translate("msg_no_central_devices"), style.get_paragraph_styles()['Normal']) + box.set_content(cls(content_flowable=p)) + boxes.append(box) + return boxes + + # Handle data with pagination + eh_tables = PaginatedDataFrameTable.create_all_tables( + availWidth=availWidth, + availHeight=availHeight, + input_data=data_energyhub, + style_name='standard' + ) + + # Check if it fits on exactly one page + if len(eh_tables) == 1: + box = FrameBox(title=title) + box.set_content(cls(content_flowable=eh_tables[0].table)) + boxes.append(box) + + else: + for i, eh_table in enumerate(eh_tables, start=1): + box = FrameBox(title=f"{title} ({i}/{len(eh_tables)})") + box.set_content(cls(content_flowable=eh_table)) + boxes.append(box) + + return boxes + +class DecentralSystems(BaseReportFlowable): + """ + Flowable that handles the visual layout of the Decentral Systems content. + Provides a class method to handle pagination and FrameBox wrapping. + """ + def __init__(self, content_flowable: Flowable): + super().__init__() + self.style = self.get_style() + self.content_flowable = content_flowable + self.width = None + self.height = None + + def wrap(self, availWidth, availHeight): + self.width = availWidth + + if hasattr(self.content_flowable, 'table'): + target_table = self.content_flowable.table + elif isinstance(self.content_flowable, Table): + target_table = self.content_flowable + else: + target_table = None + + if target_table: + n_cols = len(target_table._cellvalues[0]) + target_table._argW = [availWidth / n_cols] * n_cols + + _, self.height = self.content_flowable.wrap(availWidth, availHeight) + return self.width, self.height + + def draw_content(self): + # Delegate drawing to the internal content + self.content_flowable.drawOn(self.canv, 0, 0) + + @classmethod + def create_boxes(cls, data_decentral: pd.DataFrame, availWidth: float, availHeight: float) -> list: + """Creates a list of FrameBox objects for the Decentral Systems data.""" + style = cls.get_style() + boxes = [] + title = cls.translate("title_decentral_devices") + # Handle empty data + if data_decentral is None or data_decentral.empty: + box = FrameBox(title=title) + p = Paragraph(cls.translate("msg_no_decentral_devices"), style.get_paragraph_styles()['Normal']) + box.set_content(cls(content_flowable=p)) + boxes.append(box) + return boxes + + # Handle data with pagination + dec_tables = PaginatedDataFrameTable.create_all_tables( + availWidth=availWidth, + availHeight=availHeight, + input_data=data_decentral, + style_name='standard' + ) + + # Check if it fits on exactly one page + if len(dec_tables) == 1: + box = FrameBox(title=title) + box.set_content(cls(content_flowable=dec_tables[0].table)) + boxes.append(box) + + else: + for i, dec_table in enumerate(dec_tables, start=1): + box = FrameBox(title=f"{title} ({i}/{len(dec_tables)})") + box.set_content(cls(content_flowable=dec_table)) + boxes.append(box) + + return boxes + +class YearlyStackedBarCharts(BaseReportFlowable): + """ + Generates stacked bar charts for each simulated year, with a shared legend below. + """ + def __init__(self, costs_data: list, co2_data: list, availWidth: float, availHeight: float, observation_time: int = None): + super().__init__() + self.style = self.get_style() + self.costs_data = costs_data + self.co2_data = co2_data + self.availWidth = availWidth + self.availHeight = availHeight + self.observation_time = observation_time + + self.drawing = self._create_drawing() + self.width = self.drawing.width + self.height = self.drawing.height + + def _create_drawing(self) -> Drawing: + # Interpolation points (years) and categories for both charts + years = sorted([item["Year"] for item in self.costs_data]) + years_co2 = sorted([item["Year"] for item in self.co2_data]) + if years != years_co2: + raise ValueError(f"Mismatch in years between costs_data and co2_data ({years} vs {years_co2}). Ensure both datasets cover the same years as Simulations are linked.") + + year_labels = [] + for i in range(len(years)): + start_year = years[i] + + if i < len(years) - 1: + # Das Ende ist das Folgejahr minus 1 + end_year = years[i+1] - 1 + else: + # Der letzte Balken nutzt die observation_time als Obergrenze + end_year = self.observation_time - 1 + + year_labels.append(f"{start_year} - {end_year}") + + # Define categories (excluding 'Year' values) + cost_categories = [k for k in self.costs_data[0].keys() if k != "Year"] + co2_categories = [k for k in self.co2_data[0].keys() if k != "Year"] + + # Combine all categories for the shared legend + all_categories = list(dict.fromkeys(cost_categories + co2_categories)) + + # Format data for ReportLab VerticalBarChart (list of tuples/lists per category across all years) + cost_series = [] + for cat in cost_categories: + series = [next(item[cat] for item in self.costs_data if item["Year"] == y) for y in years] + cost_series.append(tuple(series)) + + co2_series = [] + for cat in co2_categories: + series = [next(item[cat] for item in self.co2_data if item["Year"] == y) for y in years] + co2_series.append(tuple(series)) + + # Define order of charts and titles from Bottom to Top + + charts_config = [ + { + "title": f"{self.translate('title_emissions_graph')} (t/a)", + "series": co2_series, + "categories": co2_categories + }, + { + "title": f"{self.translate('title_cost_graph')} (€/a)", + "series": cost_series, + "categories": cost_categories + } + ] + + # CO2 Emissions in t/a or kg/a + max_co2 = max([max(series) for series in co2_series]) if co2_series else 0 # Unit in t/a + if max_co2 < 5: + co2_series = [tuple(val * 1000 for val in series) for series in co2_series] + charts_config[0]["title"] = f"{self.translate('title_emissions_graph')} (kg/a)" + charts_config[0]["series"] = co2_series + + # Costs in t€/a or €/a + max_costs = max([max(series) for series in cost_series]) if cost_series else 0 # Unit in €/a + if max_costs > 5000: + cost_series = [tuple(val / 1000 for val in series) for series in cost_series] + charts_config[1]["title"] = f"{self.translate('title_cost_graph')} ({self.translate('name_for_thousand')} €/a)" + charts_config[1]["series"] = cost_series + + + # --- Base Layout --- + + # Fonts + title_font = self.style.get_font(bold=True) + title_size = self.style.get_font_size('body') + + axis_font = self.style.get_font(bold=False) + axis_size = self.style.get_font_size('axis_values') + + axis_label_font = self.style.get_font(bold=False) + axis_label_size = self.style.get_font_size('small') + + legend_font = self.style.get_font(bold=False) + legend_size = self.style.get_font_size('small') + + legend_max_cols = 3 + num_legend_items = len(all_categories) + + # Helper function to get color mapping + def get_cat_color(category): + """Returns the color based on the exact dictionary key string.""" + try: + mapping = { + self.translate("name_central_costs"): "eh_fixed", + self.translate("name_decentral_costs"): "decentral_fixed", + self.translate("name_el"): "electricity", + self.translate("name_gas"): "gas", + self.translate("name_oil"): "oil", + self.translate("name_waste"): "waste", + self.translate("name_biomass"): "biomass", + self.translate("name_district_heat"): "district_heat", + self.translate("name_hydrogen"): "hydrogen", + self.translate("name_feed_in_revenue"): "revenue_feed_in_el" + } + + # Check if the exact string exists in mapping + if category in mapping: + color_key = mapping[category] + return self.style.get_source_color(color_key) + + # Fallback if the string is not in the explicit list + print(f"Warning: Category '{category}' not found in color mapping. Using secondary color as fallback. Check if color {mapping[category]} is defined in the config.") + return self.style.get_color("secondary_color") + + except KeyError: + # Fallback if the color key itself is missing in the theme config + print(f"Warning: Category '{category}' not defined in color mapping. Using secondary color as fallback.") + return self.style.get_color("secondary_color") + + # --- Placement Calculations --- + padding = self.style.get_padding() + drawing_width = self.availWidth + x_align = 3 * self.style.get_padding() + chart_width = drawing_width - 2*x_align # Leave padding on the sides + + d = Drawing(drawing_width, self.availHeight) + + # --- Shared Legend --- + legend = Legend() + legend.fontName = legend_font + legend.fontSize = legend_size + legend.dx = 8 + legend.dy = 8 + legend.yGap = 0 + legend.deltay = 12 + legend.strokeWidth = 0 + legend.dxTextSpace = self.style.get_spacing('medium') + legend.variColumn = True + legend.columnMaximum = legend_max_cols + legend.alignment = 'right' + + legend_pairs = [(colors.Color(*get_cat_color(cat)), cat) for cat in all_categories] + legend.colorNamePairs = legend_pairs + + # Center legend + num_columns = math.ceil(num_legend_items / legend.columnMaximum) + actual_legend_width = num_columns * legend.deltax + + # Place temporarily at (0,0) to calculate the bounding box and get the actual width and height of the legend + legend.x = 0 + legend.y = 0 + bounds = legend.getBounds() + actual_legend_width = bounds[2] - bounds[0] + actual_legend_height = bounds[3] - bounds[1] + + # Place the legend at the proper position based on the actual size + legend.x = (drawing_width - actual_legend_width) / 2 + legend.y = padding + actual_legend_height + d.add(legend) + + # Debugging: Draw bounding box around the legend + if DEBUG: + d.add(Rect(legend.x, padding, actual_legend_width, actual_legend_height, strokeColor=colors.red, strokeWidth=debug_line_width, fillColor=None)) + + current_y = legend.y + padding # LOWER LIMIT NO element should extend below this! + + # --- Dynamic Height Calculation -- + num_charts = len(charts_config) + remaining_space = self.availHeight - current_y + remaining_space -= padding * (num_charts - 1) # Account for padding between charts + + max_block_height = 200 + total_chart_height = min(remaining_space / num_charts, max_block_height) + + # Iterate through the charts to draw them according to the defined charts_config + for chart in charts_config: + setoff_chart_start = (axis_label_size) + self.style.get_spacing("small") + (axis_size * 1.2) + title_height = title_size * 1.2 + chart_height = total_chart_height - setoff_chart_start - title_height - padding # Leave space for title and axis labels + + bc = VerticalBarChart() + bc.x = x_align + bc.y = current_y + setoff_chart_start + bc.height = chart_height + bc.width = chart_width + bc.data = chart["series"] + bc.categoryAxis.categoryNames = year_labels + bc.categoryAxis.labels.fontName = axis_font + bc.categoryAxis.labels.fontSize = axis_size + bc.valueAxis.labels.fontName = axis_font + bc.valueAxis.labels.fontSize = axis_size + bc.categoryAxis.style = 'stacked' + + for i, cat in enumerate(chart["categories"]): + bc.bars[i].fillColor = colors.Color(*get_cat_color(cat)) + bc.bars[i].strokeWidth = 0 + + d.add(bc) + + title_y = current_y + chart_height + padding + setoff_chart_start + d.add(String(bc.x + chart_width/2, title_y, chart["title"], fontName=title_font, fontSize=title_size, textAnchor='middle')) + d.add(String(bc.x + chart_width/2, current_y, self.translate("axis_title_simulated_year"), fontName=axis_label_font, fontSize=axis_label_size, textAnchor='middle')) + + total_chart_height = title_y - current_y + title_size + + # For debug purposes: Draw bounding boxes around the charts + if DEBUG: + d.add(Rect(bc.x, current_y, bc.width, total_chart_height, strokeColor=colors.red, strokeWidth=debug_line_width, fillColor=None)) + + current_y += total_chart_height + padding # Next chart starts after the chart height including the title and all text. + + # Shrink the drawing height to the actual used height + d.height = current_y + return d + + def wrap(self, availWidth, availHeight): + return self.width, self.height + + def draw_content(self): + self.drawing.drawOn(self.canv, 0, 0) + +class InputDataTable(BaseReportFlowable): + """ + This class generates the Input Data Table section of the certificate. + """ + def __init__(self, input_data:pd.DataFrame=None) -> None: + super().__init__() + self.style = self.get_style() + + self.width = None + self.height = None + self.actual_height = None + + if input_data is None or input_data.empty: + self.input_data = pd.DataFrame() + table_data = [] + else: + self.input_data = input_data.copy() + self.input_data = self.input_data.fillna("-").astype(str) + + header = self.input_data.columns.tolist() + body = self.input_data.values.tolist() + table_data = [header] + body + + self.table = Table(table_data) + + # Apply the pre-configured TableStyle + self.table_style = self.style.get_table_styles()['input_data'] + self.table.setStyle(self.table_style) + + def wrap(self, availWidth, availHeight): + # Calculates the column widths to fill out the available width equally + if self.input_data is None or self.input_data.empty: + self.width = 0 + self.height = 0 + return self.width, self.height + + num_cols = len(self.input_data.columns) + col_width = availWidth / num_cols + self.table._argW = [col_width] * num_cols + + actual_width, self.actual_height = self.table.wrap(availWidth, availHeight) + self.width = availWidth + self.height = availHeight + return self.width, self.height # Takes the whole available space + + def draw_content(self): + self.table.drawOn(self.canv, 0, self.height - self.actual_height) #Placement at the top-left corner + +class Hinweise(BaseReportFlowable): + """ + This class generates the Allgemeine Hinweise section of the certificate. + """ + def __init__(self, sections_to_include=None) -> None: + super().__init__() + self.style = self.get_style() + self.width = None + self.height = None + self.padding = self.style.get_padding() + + # Sections that should be displayed by this Object (None = all) + self.sections_to_include = sections_to_include + + # This dict contains the text that is displayed in the Hinweise section + self.hinweise_content = self.translate("content_information_page") + + # Styling configuration + self.styles = { + 'section_title': ParagraphStyle( + 'SectionTitle', + fontName=self.style.get_font(bold=True), + fontSize=self.style.get_font_size('highlighted'), + alignment=0, #-> left aligned + leftIndent=0, # no indent + textColor=self.style.get_color('text') + ), + 'item_definition': ParagraphStyle( + 'ItemDefinition', + fontName=self.style.get_font(bold=False), + fontSize=self.style.get_font_size('small'), + alignment=0,# -> left aligned + leftIndent=self.style.get_font_size('small'), # indent for the items + firstLineIndent=-self.style.get_font_size('small'), # hanging indent + textColor=self.style.get_color('text') + ) + } + self.layout = {'distance_after_title': self.styles['section_title'].fontSize * 0.3, # Distance after the section title + 'distance_after_item': self.styles['item_definition'].fontSize * 0, # No distance after an item + 'distance_after_section': self.styles['section_title'].fontSize * 0.5} # Distance after a section + + def get_filtered_content(self): + """Returns the filtered hinweise_content based on sections_to_include.""" + if self.sections_to_include is None: + return self.hinweise_content + filtered_content = {section: content for section, content in self.hinweise_content.items() if section in self.sections_to_include} + return filtered_content + + def calculate_section_height(self, section_title, section_data, content_width): + """Calculates the required height for a section of the hinweise_content. To determine if the remaining space is enough""" + total_height = 0 + + title_paragraph = Paragraph(f"{section_title}:", self.styles['section_title']) + title_paragraph.wrap(content_width, 1000) + total_height += title_paragraph.height + self.layout['distance_after_title'] + + for item_name, item_description in section_data.items(): + item_text = f"{item_name}: {item_description}" + item_paragraph = Paragraph(item_text, self.styles['item_definition']) + item_paragraph.wrap(content_width, 1000) + total_height += item_paragraph.height + self.layout['distance_after_item'] + + total_height += self.layout['distance_after_section'] + return total_height + + def get_sections_that_fit(self, available_height): + """Ermittelt welche der Sektionen alle auf die nächste Seite passen""" + content_width = self.width - 2 * self.padding + content = self.get_filtered_content() + + sections_that_fit = [] + sections_overflow = [] + current_height = self.padding # Start-Padding + + for section_title, section_data in content.items(): + section_height = self.calculate_section_height(section_title, section_data, content_width) + + if current_height + section_height <= available_height - self.padding: + sections_that_fit.append(section_title) + current_height += section_height + else: + sections_overflow.append(section_title) + + return sections_that_fit, sections_overflow + + def wrap(self, availWidth, availHeight): + self.width = availWidth + self.height = availHeight + return self.width, self.height + + def draw_content(self): + """Only draws the sections that fit on the current page.""" + c = self.canv + c.saveState() + + content_width = self.width - 2*self.padding + margin_left = self.padding + + # y- Start position (from top to bottom) + y_current = self.height - self.padding + + content = self.get_filtered_content() + + for section_title, section_data in content.items(): + + # 1. Sektion-Titel zeichnen + title_paragraph = Paragraph(f"{section_title}:", self.styles['section_title']) + title_paragraph.wrap(content_width, y_current) + title_height = title_paragraph.height + + # Titel zeichnen + y_current -= title_height # Title height + title_paragraph.drawOn(c, margin_left, y_current) + y_current -= self.layout['distance_after_title'] + + # 2. Items zeichnen + for item_name, item_description in section_data.items(): + + # Item-Text erstellen + item_text = f"{item_name}: {item_description}" + item_paragraph = Paragraph(item_text, self.styles['item_definition']) + item_paragraph.wrap(content_width, y_current) + item_height = item_paragraph.height + + # Move position downward + y_current -= item_height + + # Draw item + item_paragraph.drawOn(c, margin_left, y_current) + y_current -= self.layout['distance_after_item'] + + + # Distance between sections + y_current -= self.layout['distance_after_section'] + + c.restoreState() + + @classmethod + def create_all_hinweise(cls, availWidth, availHeight): + """ + Creates all Hinweise flowables so that the whole content can be displayed. + The flowables contain all sections that fit on one page. + The overflow sections are placed in the next box. + This continues until all sections are placed in a box. + The list of flowables is then returned. + """ + dummy_instance = cls() + remaining_sections = list(dummy_instance.hinweise_content.keys()) # all sections that need to be placed + + hinweise = [] + + while remaining_sections: + # Create test instance with remaining sections + test_hinweise = cls(sections_to_include=remaining_sections) + test_hinweise.width = availWidth + + # Determine which sections fit into this box + sections_that_fit, sections_overflow = test_hinweise.get_sections_that_fit(availHeight) + + # Ensure at least one section is processed + if not sections_that_fit and remaining_sections: + raise Exception("At least one section of the Hinweise content is too large to fit on one page. Please review the content.") + + # Create Hinweise-Flowable for matching sections + if sections_that_fit: + hinweise_flowable = cls(sections_to_include=sections_that_fit) + hinweise.append(hinweise_flowable) + + # Update remaining sections for next iteration + remaining_sections = sections_overflow + + else: + # Safety break if no more sections available + break + + return hinweise + +class DistrictLayout(BaseReportFlowable): + def __init__(self, data_district_layout, availWidth: float, availHeight: float) -> None: + super().__init__() + self.style = self.get_style() + self.data_district_layout = data_district_layout + + self.width = availWidth + self.height = availHeight + + self.legend_width = self.width * 0.3 + self.legend_height = 0 + self.map_width = self.width - self.legend_width + self.scale = None + + + self.legend_position = "left" + if self.legend_position == "right": + self.map_offset_x = 0 + self.legend_offset_x = self.map_width + else: # left + self.legend_offset_x = 0 + self.map_offset_x = self.legend_width + + self.map = self._create_map() + self.legend = self._create_legend() + + def _create_map(self): + + d = Drawing(self.map_width, self.height) + + # Draw the outer boundary box #! Maybe remove later + border = Rect(0, 0, self.map_width, self.height) + border.strokeColor = colors.Color(1, 1, 1) # 1,1,1 is white (not visible), 0,0,0 would be black. Maybe use a light grey for better visibility of the layout elements? colors.Color(0.8, 0.8, 0.8) + border.strokeWidth = 1 + border.fillColor = None + # border.fillColor = colors.Color(247/255, 232/255, 197/255) + d.add(border) + + + nodes = self.data_district_layout.get('network_nodes', {}) + edges = self.data_district_layout.get('network_edges', {}) + buildings = self.data_district_layout.get('buildings', []) + pipeline_data = self.data_district_layout.get('pipeline_data', {}) + + if not buildings: + font_name = self.style.get_font(bold=False) + font_size = self.style.get_font_size('subsection_title') + text_color = colors.Color(*self.style.colors["text"]) + d.add(String(self.map_width / 2.0, self.height / 2.0, self.translate("msg_no_district_layout"), + fontName=font_name, fontSize=font_size, fillColor=text_color, textAnchor='middle')) + return d + + # All positions of nodes and buildings + all_x = [] + all_y = [] + for n in nodes.values(): + all_x.append(n['pos'][0]) + all_y.append(n['pos'][1]) + for b in buildings: + all_x.append(b['x']) + all_y.append(b['y']) + + min_x, max_x = min(all_x), max(all_x) + min_y, max_y = min(all_y), max(all_y) + range_x = max_x - min_x + range_y = max_y - min_y + + distance_to_border = self.style.get_padding() + 2 * self.style.get_layout_size('label') + + avail_w = self.map_width - 2 * distance_to_border + avail_h = self.height - 2 * distance_to_border + + scale_x = avail_w / range_x if range_x > 0 else float('inf') + scale_y = avail_h / range_y if range_y > 0 else float('inf') + + self.scale = min(scale_x, scale_y) + if self.scale == float('inf'): + self.scale = 1.0 + + def transform(x, y): + """Translate the relative coordinates to the coordinates in the drawing based on the calculated scale and offsets.""" + tx = (self.map_width - (range_x * self.scale)) / 2.0 + (x - min_x) * self.scale + ty = (self.height - (range_y * self.scale)) / 2.0 + (y - min_y) * self.scale + return tx, ty + + # Draw elements, order determines which element is on top of which + network_group = Group() + + self._draw_pipes(network_group, pipeline_data, transform) + self._draw_buildings(network_group, buildings, transform) + self._draw_energy_hub(network_group, nodes, transform) + + d.add(network_group) + return d + + def _draw_pipes(self, group, pipeline_data, transform): + """Draws the pipes between the nodes""" + if not pipeline_data: + return # If no heat_grid is present, skip drawing pipes + + line_color = colors.Color(*self.style.get_layout_color("pipe")) + max_pipe_width = self.style.get_layout_size('pipe') + min_pipe_width = self.style.get_layout_size('pipe')/10 # Minimum line width for visibility, can be adjusted as needed + label_size = self.style.get_layout_size('label') + text_color = colors.Color(*self.style.get_color("text")) + + dn_values = [pipe_info["DN"] for pipe_info in pipeline_data.values()] + + max_dn = max(dn_values) + + for pipe in pipeline_data.values(): + x1, y1 = transform(*pipe["from_pos"]) + x2, y2 = transform(*pipe["to_pos"]) + + dn = pipe["DN"] + + if max_dn > 0: + ratio = dn / float(max_dn) + lw = max_pipe_width * ratio + + if lw < min_pipe_width: + lw = min_pipe_width + else: + lw = max_pipe_width + + pipe_line = Line(x1, y1, x2, y2) + pipe_line.strokeColor = line_color + pipe_line.strokeWidth = lw + group.add(pipe_line) + + if self.style.get_layout_options("show_pipe_labels"): + # Label for the pipe diameter + if x1 > x2 or (x1 == x2 and y1 > y2): + x1, x2, y1, y2 = x2, x1, y2, y1 + + mid_x = (x1 + x2) / 2.0 + mid_y = (y1 + y2) / 2.0 + + # Placement of the label based on the angle of the pipe + pipe_angle = math.atan2(y2 - y1, x2 - x1) + + nx = -math.sin(pipe_angle) + ny = math.cos(pipe_angle) + offset_dist = (lw / 2.0) + 2 + + label_x = mid_x + nx * offset_dist + label_y = mid_y + ny * offset_dist + + if pipe_angle > math.pi / 6.0: + text_anchor = 'end' + elif pipe_angle < -math.pi / 6.0: + text_anchor = 'start' + else: + text_anchor = 'middle' + + dn_label = String( + label_x, label_y, + f"DN-{dn}", + fontName=self.style.get_font(bold=False), + fontSize=label_size, + fillColor=text_color, + textAnchor=text_anchor + ) + group.add(dn_label) + + def _draw_energy_hub(self, group, nodes, transform): + """Draws the energy hub""" + eh_color = colors.Color(*self.style.get_layout_color("eh")) + for node_id, node_data in nodes.items(): + if node_data.get('role') == 'EH': + hx, hy = transform(*node_data['pos']) + r = self.style.get_layout_size('eh') + h_triangle = math.sqrt(3) * r + y_top = hy + (h_triangle * (2.0/3.0)) + y_bottom = hy - (h_triangle * (1.0/3.0)) + + eh_shape = Polygon([ + hx, y_top, + hx - r, y_bottom, + hx + r, y_bottom + ]) + eh_shape.fillColor = eh_color + eh_shape.strokeColor = colors.black + eh_shape.strokeWidth = 0.5 + group.add(eh_shape) + + if self.style.get_layout_options("show_building_labels"): + label = String(hx, hy + r + 4, node_id, fontName=self.style.get_font(bold=True), + fontSize=self.style.get_layout_size('label')*1.5, fillColor=eh_color, textAnchor='middle') + group.add(label) + + def _draw_buildings(self, group, buildings, transform): + """Draws the buildings""" + connected_color = colors.Color(*self.style.get_layout_color("building_connected")) + disconnected_color = colors.Color(*self.style.get_layout_color("building_not_connected")) + text_color = colors.Color(*self.style.colors["text"]) + + for b in buildings: + bx, by = transform(b['x'], b['y']) + b_color = connected_color if b.get('is_connected', False) else disconnected_color + + radius = self.style.get_layout_size('building') + b_circle = Circle(bx, by, r=radius) + b_circle.fillColor = b_color + b_circle.strokeColor = colors.black + b_circle.strokeWidth = 0.5 + group.add(b_circle) + + if self.style.get_layout_options("show_building_labels"): + type_text = f"{b['type']}" + type_label = String(bx, by - radius - self.style.get_layout_size('label'), type_text, fontName=self.style.get_font(bold=True), + fontSize=self.style.get_layout_size('label'), fillColor=text_color, textAnchor='middle') + group.add(type_label) + + id_text = f"({b['id']})" + id_label = String(bx, by - radius - 2* self.style.get_layout_size('label'), id_text, fontName=self.style.get_font(bold=False), + fontSize=self.style.get_layout_size('label'), fillColor=text_color, textAnchor='middle') + group.add(id_label) + + def _create_legend(self): + d = Drawing(self.legend_width, self.height) + + padding = self.style.get_padding() + size_elements = 10 + + # X- Starting points (from left to right) + start_x = padding + sym_x = start_x + size_elements # Center of the symbols + text_x = sym_x + size_elements + self.style.get_spacing('medium') # Start of the text, after symbol and some spacing + + text_color = colors.Color(*self.style.get_color("text")) + legend_font_size = self.style.get_layout_size("legend_text") + y_text_offset = legend_font_size / 3.0 + + distance_entries = self.style.get_spacing('medium') + + # Y- Starting point (from top to bottom) + current_y = self.height - padding + + # Scale: + if self.scale is not None: + max_scale_width = self.legend_width - 2 * padding + + allowed_real_meters = [] + for power in range(0, 4): + allowed_real_meters.extend([1 * 10**power, 2.5 * 10**power, 5 * 10**power]) + + # Find the best fitting scale value that is the closest to but smaller than the maximum width + best_real_meters = 10 + for val in reversed(allowed_real_meters): + if val * self.scale <= max_scale_width: + best_real_meters = val + break + + drawn_length = best_real_meters * self.scale + + scale_y = current_y - size_elements + scale_start_x = start_x + + bar_height = 5 # Height of the scale bar + num_segments = 4 # Number of blocks in the scale (e.g., 4 blocks for 0, 25%, 50%, 75%, 100%) + seg_length = drawn_length / num_segments + + # Draw the scale segments + for i in range(num_segments): + seg_x = scale_start_x + i * seg_length + is_black = (i % 2 == 0) + + seg_rect = Rect(seg_x, scale_y, seg_length, bar_height) + seg_rect.strokeColor = colors.black + seg_rect.strokeWidth = 0.5 + seg_rect.fillColor = colors.black if is_black else colors.white + d.add(seg_rect) + + # Placement of the labels for the scale + label_y = scale_y + bar_height + 2 + label_size = self.style.get_layout_size("label") + + # "0" at the beginning of the scale + d.add(String(scale_start_x, label_y, "0", + fontName=self.style.get_font(bold=False), fontSize=label_size, + fillColor=text_color, textAnchor='middle')) + + # Halfway value in the middle + half_meters = best_real_meters / 2.0 + d.add(String(scale_start_x + drawn_length / 2.0, label_y, f"{half_meters:g}", + fontName=self.style.get_font(bold=False), fontSize=label_size, + fillColor=text_color, textAnchor='middle')) + + # End value with unit ("m") at the end + d.add(String(scale_start_x + drawn_length, label_y, f"{best_real_meters:g} m", + fontName=self.style.get_font(bold=False), fontSize=label_size, + fillColor=text_color, textAnchor='middle')) + + #Start of the legend, below the scale + box_start_y = scale_y - padding + + else: + box_start_y = current_y + + current_y = box_start_y - padding + + # Legend Title + # font_size_title = self.style.get_font_size("subsection_title") + # current_y -= font_size_title + # d.add(String(start_x, current_y, "Legende", fontName=self.style.get_font(bold=True), fontSize=font_size_title, fillColor=text_color)) + # current_y -= distance_entries + + # Energy Hub + current_y -= size_elements # Move to center of the symbol + eh_color = colors.Color(*self.style.get_layout_color("eh")) + h_triangle = math.sqrt(3) * size_elements + + y_top = current_y + (h_triangle * (2.0/3.0)) + y_bottom = current_y - (h_triangle * (1.0/3.0)) + + eh_shape = Polygon([ + sym_x, y_top, # Top + sym_x - size_elements, y_bottom, # Bottom left + sym_x + size_elements, y_bottom # Bottom right + ]) + eh_shape.fillColor = eh_color + eh_shape.strokeColor = colors.black + eh_shape.strokeWidth = 0.5 + d.add(eh_shape) + + + d.add(String(text_x, current_y- y_text_offset, self.translate("legend_eh"), + fontName=self.style.get_font(bold=False), + fontSize=legend_font_size, + fillColor=text_color, + textAnchor = 'start')) + + current_y -= size_elements + distance_entries + + # Pipes + current_y -= size_elements / 2.0 + pipe_color = colors.Color(*self.style.get_layout_color("pipe")) + pipe_width = 2*size_elements/10 + + if self.style.get_layout_options("show_pipe_labels"): + y_text_line1 = current_y + y_text_line2 = current_y - legend_font_size * 1.2 + y_center_of_texts = (y_text_line1 + y_text_line2) / 2.0 + + # Draw line centered between the two text lines + pipe_line = Line(sym_x - size_elements, y_center_of_texts, sym_x + size_elements, y_center_of_texts) + pipe_line.strokeColor = pipe_color + pipe_line.strokeWidth = pipe_width + d.add(pipe_line) + + # Draw DN-X label above the line + d.add(String(sym_x, y_center_of_texts + pipe_width/2 + 2, "DN-X", + fontName=self.style.get_font(bold=False), + fontSize=legend_font_size * 0.8, + fillColor=text_color, textAnchor='middle')) + + # Draw main text + d.add(String(text_x, y_text_line1, self.translate("legend_pipes"), + fontName=self.style.get_font(bold=False), + fontSize=legend_font_size, + fillColor=text_color, + textAnchor='start')) + + # Draw explanation text + explanation_color = colors.Color(*self.style.get_color("text_light")) + d.add(String(text_x, y_text_line2, self.translate("legend_pipe_dn"), + fontName=self.style.get_font(bold=False), + fontSize=legend_font_size * 0.85, + fillColor=explanation_color, + textAnchor='start')) + + current_y = y_text_line2 - distance_entries + + else: + # Draw simple line without labels + pipe_line = Line(sym_x - size_elements, current_y, sym_x + size_elements, current_y) + pipe_line.strokeColor = pipe_color + pipe_line.strokeWidth = pipe_width + d.add(pipe_line) + + # Draw single main text + d.add(String(text_x, current_y - y_text_offset, self.translate("legend_pipes"), + fontName=self.style.get_font(bold=False), + fontSize=legend_font_size, + fillColor=text_color, + textAnchor='start')) + + current_y -= size_elements + distance_entries + + + # Buildings connected to the heat grid + current_y -= size_elements # Move to center of the symbol + conn_color = colors.Color(*self.style.get_layout_color("building_connected")) + b_conn = Circle(sym_x, current_y, r=size_elements) + b_conn.fillColor = conn_color + b_conn.strokeColor = colors.black + b_conn.strokeWidth = 0.5 + d.add(b_conn) + + d.add(String(text_x, current_y - y_text_offset, self.translate("legend_bldg_conn"), + fontName=self.style.get_font(bold=False), + fontSize=legend_font_size, + fillColor=text_color, + textAnchor='start')) + + current_y -= size_elements + distance_entries + + + # Buildings not connected to the heat grid + current_y -= size_elements # Move to center of the symbol + not_conn_color = colors.Color(*self.style.get_layout_color("building_not_connected")) + b_not_conn = Circle(sym_x, current_y, r=size_elements) + b_not_conn.fillColor = not_conn_color + b_not_conn.strokeColor = colors.black + b_not_conn.strokeWidth = 0.5 + d.add(b_not_conn) + + d.add(String(text_x, current_y - y_text_offset, self.translate("legend_bldg_not_conn"), + fontName=self.style.get_font(bold=False), + fontSize=legend_font_size, + fillColor=text_color, + textAnchor='start')) + + current_y -= size_elements + + # Box around the legend entries + current_y -= padding + box_height = box_start_y - current_y + legend_box = Rect(0, current_y, self.legend_width, box_height) + legend_box.strokeColor = colors.Color(0.2, 0.2, 0.2) + legend_box.strokeWidth = 1 + legend_box.fillColor = None + + d.add(legend_box) + + self.legend_height = self.height - current_y + + return d + + def wrap(self, availWidth, availHeight): + return self.width, self.height + + def draw_content(self): + self.map.drawOn(self.canv, self.map_offset_x, 0) + self.legend.drawOn(self.canv, self.legend_offset_x, self.legend_height - self.height) + +################################################################################ +# Layout generation as classes +################################################################################ + +class CertificateLayout(ReportComponent): + """ + This class generates the layout for the certificate. Where which flowable is placed. + """ + + def __init__(self, certificate_builder) -> None: + self.style = self.get_style() + self.story = [] + self.certificate_builder = certificate_builder + self.paragraph_styles = self.style.get_paragraph_styles() + + # Titlepage methods + def create_header(self): + """Creates Header and adds it to the story.""" + title = self.translate("ui_certificate_title") + header = Header(title=title) + self.story.append(header) + self.add_standard_spacer('large') + + def create_energiekennwerte(self, data_energiekennwerte): + """Creates the Energiekennwerte section and adds it to the story.""" + title = self.translate("title_kpis") + box = FrameBox(title=title) + frame_width, frame_height = self.certificate_builder.get_Framesize(id='TitleContentFrame') + avail_w, avail_h = FrameBox.get_available_space_content(frame_width, frame_height) + + energiekennwerte = Energiekennwerte(kpi_data=data_energiekennwerte, availWidth=avail_w) + box.set_content(energiekennwerte) + self.story.append(box) + self.add_standard_spacer() + + def create_quartiersstruktur(self, data_quartiersstruktur): + """Creates the Quartiersstruktur section and adds it to the story.""" + title = self.translate("title_district_structure") + box = FrameBox(title=title) + + quartiersstruktur_flowable = Quartiersstruktur( + summary_table_data=data_quartiersstruktur["summary_table"], + general_info=data_quartiersstruktur["general_info"] + ) + + box.set_content(quartiersstruktur_flowable, full_width=False) + self.story.append(box) + self.add_standard_spacer() + + def create_footer(self, scenario_name): + """Creates Footer and adds it to the story.""" + footer = Footer(scenario_name) + self.story.append(footer) + + + + # Energyhub device capacity page + def create_energyhub_data(self, data_energyhub): + """Creates the Energyhub Data section and adds it to the story.""" + + # 1. Get the available space from the template frame + frame_width, frame_height = self.certificate_builder.get_Framesize(id='EnergyhubDevicesFrame') + eh_width, eh_height = FrameBox.get_available_space_content(frame_width, frame_height) + + # 2. Let the EnergyHub factory create all necessary, perfectly sized boxes + boxes = EnergyHub.create_boxes( + data_energyhub=data_energyhub, + availWidth=eh_width, + availHeight=eh_height + ) + + # 3. Add them to the document story + self.story.extend(boxes) + + def create_decentral_systems(self, data_decentral): + """Creates the Decentral Systems section and adds it to the story.""" + + # 1. Get the available space from the template frame + frame_width, frame_height = self.certificate_builder.get_Framesize(id='EnergyhubDevicesFrame') + + # Calculate how much height is ALREADY consumed by the EnergyHub boxes currently in the story + used_height = 0 + for item in self.story: + # Give it a wide dummy width just to ask the Flowables for their height + _, h = item.wrap(frame_width, frame_height) + used_height += h + + # Available height is the total frame height minus what we've already used + remaining_height = max(0, frame_height - used_height) + + # Get content dimensions + dec_width, dec_height = FrameBox.get_available_space_content(frame_width, remaining_height) + + # 2. Let the DecentralSystems factory create all necessary boxes + boxes = DecentralSystems.create_boxes( + data_decentral=data_decentral, + availWidth=dec_width, + availHeight=dec_height + ) + + # Add spacing before the new section if there are boxes + if boxes: + self.add_standard_spacer() + + # 3. Add them to the document story + self.story.extend(boxes) + + def create_quartiersstruktur_details(self, data_quartiersstruktur): + """Creates the detailed matrix on a landscape page.""" + df_details = data_quartiersstruktur["df_details"] + + if df_details.empty: + return + + frame_width, frame_height = self.certificate_builder.get_Framesize(id='InputDataFrame') + avail_w, avail_h = FrameBox.get_available_space_content(frame_width, frame_height) + + # Use universal pagination logic + tables = PaginatedDataFrameTable.create_all_tables( + availWidth=avail_w, + availHeight=avail_h, + input_data=df_details, + style_name='input_data' + ) + + title = self.translate("title_district_structure_detailed") + for i, tab in enumerate(tables, start=1): + title = title if len(tables) == 1 else f"{title} ({i}/{len(tables)})" + box = FrameBox(title=title) + box.set_content(tab, full_width=True) + self.story.append(box) + + def create_district_layout(self, data_district_layout): + """Plots the district layout""" + frame_width, frame_height = self.certificate_builder.get_Framesize(id='InputDataFrame') + map_width, map_height = FrameBox.get_available_space_content(frame_width, frame_height) + + + district_map = DistrictLayout( + data_district_layout=data_district_layout, + availWidth=map_width, + availHeight=map_height + ) + + name= self.translate("title_district_layout") + box = FrameBox(title=name) + box.set_content(district_map) + self.story.append(box) + + # Input Data page + def create_input_data_table(self, data_input): + """Creates the Input Data Table section and adds it to the story.""" + frame_width, frame_height = self.certificate_builder.get_Framesize(id='InputDataFrame') + input_data_width, input_data_height = FrameBox.get_available_space_content(frame_width, frame_height) + + input_data_tables = PaginatedDataFrameTable.create_all_tables( + availWidth=input_data_width, + availHeight=input_data_height, + input_data=data_input, + style_name='input_data' + ) + + title = self.translate("title_bldg_list") + for i, input_data_table in enumerate(input_data_tables, start=1): + name = f"{title} ({i}/{len(input_data_tables)})" if len(input_data_tables) > 1 else title + box = FrameBox(title=name) + box.set_content(input_data_table) + self.story.append(box) + + # Additional Information page + def create_hinweise(self): + """ + Creates the Hinweise section and adds it to the story. + Uses the Hinweise class method to create all Hinweise boxes + These are then all placed in a FrameBox + and these are added to the story as seperate pages. + """ + frame_width, frame_height = self.certificate_builder.get_Framesize(id='AdditionalInformationFrame') + hinweise_width, hinweise_height = FrameBox.get_available_space_content(frame_width, frame_height) + + hinweise = Hinweise.create_all_hinweise(availWidth=hinweise_width, availHeight=hinweise_height) + pages_hinweise = len(hinweise) + + title = self.translate("title_information") + for i, hinweis in enumerate(hinweise, start=1): + name = f"{title} ({i}/{pages_hinweise})" if pages_hinweise > 1 else title + box = FrameBox(title=name) + box.set_content(hinweis) + self.story.append(box) + + def create_yearly_bar_charts(self, kpi_data): + """Creates the yearly stacked bar charts and adds them to the story.""" + costs_data = kpi_data.get("bar_costs_data", []) + co2_data = kpi_data.get("bar_co2_data", []) + obs_time = kpi_data.get("observation_time", None) + + if not costs_data or not co2_data: + return + + title = self.translate("title_cost_emissions") + box = FrameBox(title=title) + frame_width, frame_height = self.certificate_builder.get_Framesize(id='EnergyhubDevicesFrame') + avail_w, avail_h = FrameBox.get_available_space_content(frame_width, frame_height) + + barcharts_flowable = YearlyStackedBarCharts(costs_data=costs_data, co2_data=co2_data, availWidth=avail_w, availHeight=avail_h, observation_time=obs_time) + box.set_content(barcharts_flowable) + self.story.append(box) + + def add_standard_spacer(self, size:str='medium'): + """Adds a standard spacer to the story.""" + spacing = self.style.get_spacing(size) + spacer = Spacer(1, spacing) + self.story.append(spacer) + + def get_story(self): + """Returns the story for the certificate.""" + return self.story + + def reset_story(self): + """Resets the story.""" + self.story = [] + +class CertificateTemplate(BaseDocTemplate, ReportComponent): + """ + This class handles the pure PDF document structure, frames, and page templates. + """ + + + def __init__(self, filename, page_margins, **kwargs): + self.style = self.get_style() + self.pagesize = self.style.get_pagesize() + + # Initialize BaseDocTemplate with 0 margins (margins handled in frames) + super().__init__(filename, pagesize=self.pagesize, leftMargin=0, rightMargin=0, topMargin=0, bottomMargin=0, **kwargs) + + self.page_margins = page_margins + + self.setup_templates() + + def setup_templates(self): + """ + Sets up the page templates and defines the frames for the different sections of the certificate. + """ + width, height = self.pagesize + landscape_pagesize = landscape(self.pagesize) + + # Title Page + page_id = 'TitlePage' + margins = self.page_margins + margin_x, margin_y = self.page_margins[page_id] + + footer_height = Footer.get_required_height() + footer_spacing = self.style.get_spacing('medium') + + title_content_frame = Frame( + margin_x, + margin_y + footer_height, + width - 2*margin_x, + height - 2*margin_y - footer_height - footer_spacing, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, + id='TitleContentFrame' + ) + + title_footer_frame = Frame( + margin_x, + margin_y, + width - 2*margin_x, + footer_height, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, + id='TitleFooterFrame' + ) + + title_template = PageTemplate( + id=page_id, + frames=[title_content_frame, title_footer_frame], + onPage=self.draw_title_page, + pagesize=self.pagesize + ) + + # Energyhub Devices Page + page_id = 'ContentPage' + margin_x, margin_y = self.page_margins[page_id] + + energyhub_frame = Frame( + margin_x, margin_y, + width - 2*margin_x, height - 2*margin_y, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, + id='EnergyhubDevicesFrame' + ) + energyhub_template = PageTemplate(id=page_id, frames=[energyhub_frame], onPage=self.draw_energyhub_devices_page, pagesize=self.pagesize) + + # Input Data Page (Landscape) + page_id = 'InputDataPage' + margin_x, margin_y = self.page_margins[page_id] + + input_data_frame = Frame( + margin_x, margin_y, + landscape_pagesize[0] - 2*margin_x, landscape_pagesize[1] - 2*margin_y, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, + id='InputDataFrame' + ) + input_data_template = PageTemplate(id=page_id, frames=[input_data_frame], onPage=self.draw_input_data_page, pagesize=landscape_pagesize) + + # Additional Information Page + page_id = 'AdditionalInformationPage' + margin_x, margin_y = self.page_margins[page_id] + + additional_info_frame = Frame( + margin_x, margin_y, + width - 2*margin_x, height - 2*margin_y, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, + id='AdditionalInformationFrame' + ) + additional_info_template = PageTemplate(id=page_id, frames=[additional_info_frame], onPage=self.draw_additional_information_page, pagesize=self.pagesize) + + self.addPageTemplates([title_template, energyhub_template, input_data_template, additional_info_template]) + + # --- Functions to draw the different page templates adding page numbers, adding logos etc. --- + def draw_title_page(self, canvas, doc): + """Draws the title page elements.""" + canvas.saveState() + canvas.restoreState() + + def draw_energyhub_devices_page(self, canvas, doc): + """Draws the energyhub devices page elements.""" + canvas.saveState() + self.add_page_number(canvas, doc) + canvas.restoreState() + + def draw_input_data_page(self, canvas, doc): + """Draws the input data page elements.""" + canvas.saveState() + self.add_page_number(canvas, doc) + canvas.restoreState() + + def draw_additional_information_page(self, canvas, doc): + """Draws the additional information page elements.""" + canvas.saveState() + self.add_page_number(canvas, doc) + canvas.restoreState() + + def add_page_number(self, canvas, doc): + """Adds page number to the canvas.""" + canvas.saveState() + width, height = canvas._pagesize + page_id = doc.pageTemplate.id + + margin_x, margin_y = self.page_margins[page_id] + + canvas.setFont(self.style.get_font(bold=False), self.style.get_font_size('page_number')) + canvas.setFillColorRGB(*self.style.get_color('text_light')) + + canvas.drawRightString(width - margin_x, margin_y - 20, f"{self.translate('ui_page')} {doc.page}") + canvas.restoreState() + + def get_Framesize(self, id:str): + """Returns the size of the frame with the given id.""" + for template in self.pageTemplates: + for frame in template.frames: + if frame.id == id: + return frame._width, frame._height + raise ValueError(f"No frame with id {id} found.") + +class PaginatedDataFrameTable(BaseReportFlowable): + """ + Generic class to generate and paginate tables from pandas DataFrames. + """ + + def __init__(self, input_data: pd.DataFrame, style_name: str) -> None: + super().__init__() + self.style = self.get_style() + self.style_name = style_name + self.table_style = self.style.get_table_styles()[self.style_name] + + self.width = None + self.height = None + self.actual_height = None + + if input_data is None or input_data.empty: + self.input_data = pd.DataFrame() + table_data = [] + else: + self.input_data = input_data.copy() + self.input_data = self.input_data.fillna("-").astype(str) + + header = self.input_data.columns.tolist() + table_data = self._format_table_data(self.input_data, header) + + self.table = Table(table_data) + + # Apply the pre-configured TableStyle + self.table.setStyle(self.table_style) + + def _format_table_data(self, df, header): + """ + Converts all cells to Paragraphs to ensure uniform vertical alignment and subscript rendering. + Dynamically extracts FONTNAME, FONTSIZE, ALIGN, and TEXTCOLOR directly from the TableStyle + for each specific cell coordinate to make this class 100% style-agnostic. + """ + num_cols = len(header) + num_rows = len(df) + 1 # 1 for header row + + # Helper function to check if a cell (c, r) falls within a ReportLab TableStyle command coordinate range + def in_range(c, r, start_coord, end_coord): + sc, sr = start_coord + ec, er = end_coord + # Translate negative coordinates + if sc < 0: sc += num_cols + if ec < 0: ec += num_cols + if sr < 0: sr += num_rows + if er < 0: er += num_rows + + return (sc <= c <= ec) and (sr <= r <= er) + + base_style = self.style.get_paragraph_styles()['Normal'] + memoized_styles = {} # Cache to avoid creating thousands of duplicate ParagraphStyle objects + + def get_cell_paragraph_style(c, r): + necessary_attrs = {'f_name': 'FONTNAME', 'f_size': 'FONTSIZE', 'align': 'ALIGN', 't_color': 'TEXTCOLOR'} + f_name = f_size = align = t_color = None + + # Extract specific styles for this exact cell from the TableStyle commands + for cmd in self.table_style.getCommands(): + op = cmd[0] + start_coord = cmd[1] + end_coord = cmd[2] + + if in_range(c, r, start_coord, end_coord): + if op == 'FONTNAME': + f_name = cmd[3] + elif op == 'FONTSIZE': + f_size = cmd[3] + elif op == 'ALIGN': + val = cmd[3].upper() + if val == 'LEFT': align = 0 + elif val == 'CENTER': align = 1 + elif val == 'RIGHT': align = 2 + elif op == 'TEXTCOLOR': + t_color = cmd[3] + + # Check if any of the style attributes were not set by the TableStyle commands and raise exceptions if so as Style is not fully defined: + for var_name, attr_name in necessary_attrs.items(): + if var_name not in locals(): + raise ValueError(f"TableStyle is missing necessary '{attr_name}' command for cell ({c}, {r}). All of FONTNAME, FONTSIZE, ALIGN, and TEXTCOLOR must be defined for every cell to ensure consistent styling. Please check the TableStyle configuration.") + + + # Create or reuse a corrosponding ParagraphStyle for this unique combination + style_key = (f_name, f_size, align, t_color) + if style_key not in memoized_styles: + memoized_styles[style_key] = ParagraphStyle( + f'DynamicStyle_{id(style_key)}', + parent=base_style, + fontName=f_name, + fontSize=f_size, + alignment=align, + textColor=t_color, + leading=f_size*1.2, + leftIndent=0, + rightIndent=0, + spaceBefore=0, # Padding defined by the TableStyle not here + spaceAfter=0, + splitLongWords=0 # prevent forced hyphenation + ) + return memoized_styles[style_key] + + def prep(text, current_f_size): + t = str(text) + # Replace XML special characters + t = t.replace('&', '&').replace('<', '<').replace('>', '>') + + sub_size = round(current_f_size * 0.70, 1) + sub_rise = round(current_f_size * 0.20, 1) + super_rise = round(current_f_size * 0.40, 1) + + # Restore tags and specify size and rise for subscripts based on the current font size of the cell + custom_sub = f'' + t = t.replace('<sub>', custom_sub).replace('</sub>', '') + + # Restore tags and specify size and rise for superscripts based on the current font size of the cell use sub_size and sub_rise + custom_super = f'' + t = t.replace('<super>', custom_super).replace('</super>', '') + + return f"{t}" + + # Build Header + formatted_header = [] + for c, col in enumerate(header): + style = get_cell_paragraph_style(c, 0) + formatted_header.append(Paragraph(prep(col, style.fontSize), style)) + + # Build Body + formatted_body = [] + for r, row in enumerate(df.values.tolist(), start=1): + formatted_row = [] + for c, cell in enumerate(row): + style = get_cell_paragraph_style(c, r) + formatted_row.append(Paragraph(prep(cell, style.fontSize), style)) + formatted_body.append(formatted_row) + + return [formatted_header] + formatted_body + + def get_rows_that_fit(self, availWidth, availHeight): + """ + Determines which rows of the data fit into the available height. + Returns a tuple of two DataFrames: (fitting_rows, overspill_rows). + """ + if self.input_data is None or self.input_data.empty: + return pd.DataFrame(), pd.DataFrame() + + header = self.input_data.columns.tolist() + num_cols = len(header) + colWidths = [availWidth / num_cols] * num_cols + + num_data_rows_fit = len(self.input_data) + + # Iteratively reduce estimated number of rows until it fits + while num_data_rows_fit > 0: + fitting_rows_df = self.input_data.iloc[:num_data_rows_fit] + + table_data = self._format_table_data(fitting_rows_df, header) + tmp_table = Table(table_data, colWidths=colWidths) + tmp_table.setStyle(self.table_style) + + _, wrapped_height = tmp_table.wrap(availWidth, availHeight) + + if wrapped_height <= availHeight: + break + num_data_rows_fit -= 1 + + fitting_rows = self.input_data.iloc[:num_data_rows_fit] + overspill_rows = self.input_data.iloc[num_data_rows_fit:] + + return fitting_rows, overspill_rows + + def wrap(self, availWidth, availHeight): + if self.input_data is None or self.input_data.empty: + self.width = 0 + self.height = 0 + return self.width, self.height + + num_cols = len(self.input_data.columns) + col_width = availWidth / num_cols + self.table._argW = [col_width] * num_cols + + actual_width, self.actual_height = self.table.wrap(availWidth, availHeight) + self.width = availWidth + self.height = availHeight + return self.width, self.height + + def draw_content(self): + # Placement at the top-left corner + self.table.drawOn(self.canv, 0, self.height - self.actual_height) + + @classmethod + def create_all_tables(cls, availWidth, availHeight, input_data: pd.DataFrame, style_name: str): + """ + Creates all tables so that the whole content can be displayed. + """ + if input_data is None or input_data.empty: + return [] + + flowables = [] + remaining_data = input_data.copy() + + while not remaining_data.empty: + test_input_data = cls(input_data=remaining_data, style_name=style_name) + test_input_data.width = availWidth + + fitting_rows, overspill_rows = test_input_data.get_rows_that_fit(availWidth=availWidth, availHeight=availHeight) + + if fitting_rows.empty and not overspill_rows.empty: + raise Exception(f"A single row does not fit into the available height. Decrease the needed height. Current available height: {str(availHeight)} and current available width: {str(availWidth)}") + elif not fitting_rows.empty: + table_flowable = cls(input_data=fitting_rows, style_name=style_name) + flowables.append(table_flowable) + + remaining_data = overspill_rows + else: + break + + return flowables + +################################################################################ +# Data Infrastructure for the certificate as a class to extract and prepare relevant data for the certificate +################################################################################ + +class DataExtractor(ReportComponent): + """ + This class extracts the data from the input data structure and prepares it for the certificate. + """ + def __init__(self, data, kpis) -> None: + """ + Args: + data: Input data structure (Datahandler-Object from dataHandler.py) + kpis: Key performance indicators (KPI-Object from KPIs.py) + """ + self.data = data + self.kpis = kpis + self.building_stats = {} + self.gebaude_df = None + self.kennwerte = None + self.optimization_results = None + self.district_structure = None + self.energyhub_df = None + self.decentral_df = None + self.district_layout = None + + + + # Building features to be included in the gebaude_df and the keys to extract the data from buildingFeatures + self.mapping_building_list = OrderedDict([ # key: display name value: data key to extract value from buildingFeatures + (self.translate("name_building_type"), "building"), + (self.translate("name_building_year"), "year"), + (self.translate("name_building_retrofit"), "retrofit"), + (self.translate("name_sp_mass"), "construction_type"), + (self.translate("name_night_setback"), "night_setback"), + (self.translate("name_building_area"), "area"), + (self.translate("name_heating_tech"), "heater"), + (self.translate("name_ev_share"), "EV"), + (self.translate("name_f_tes"), "f_TES"), + (self.translate("name_f_bat"), "f_BAT"), + (self.translate("name_f_pv1"), "f_PV1"), + (self.translate("name_f_pv2"), "f_PV2"), + (self.translate("name_f_stc"), "f_STC"), + (self.translate("name_gamma_pv"), "gamma_PV"), + (self.translate("name_ev_charging"), "ev_charging") + ]) + + self._extract_data() + + def _get_year_category(self, year:int) -> str: + """ + Returns the building year category as a string based on the given year. + Args: + year: Building year as an integer + Returns: + Building year category as a string + """ + if year < 1968: return self.translate("name_age_cat_before_1968") + if 1968 <= year <= 1978: return self.translate("name_age_cat_1968_1978") + if 1979 <= year <= 1983: return self.translate("name_age_cat_1979_1983") + if 1984 <= year <= 1994: return self.translate("name_age_cat_1984_1994") + if 1995 <= year <= 2001: return self.translate("name_age_cat_1995_2001") + if 2002 <= year <= 2009: return self.translate("name_age_cat_2002_2009") + if 2010 <= year <= 2015: return self.translate("name_age_cat_2010_2015") + return self.translate("name_age_cat_after_2016") + + def _process_buildings(self): + """ + Processes the building data and populates the building_stats and list_of_buildings attributes. + """ + template_dict = OrderedDict([ + (self.translate("name_number_bldgs"), 0), (self.translate("name_total_area"), 0), (self.translate("name_age_cat_before_1968"), 0), + (self.translate("name_age_cat_1968_1978"), 0), (self.translate("name_age_cat_1979_1983"), 0), (self.translate("name_age_cat_1984_1994"), 0), + (self.translate("name_age_cat_1995_2001"), 0), (self.translate("name_age_cat_2002_2009"), 0), (self.translate("name_age_cat_2010_2015"), 0), + (self.translate("name_age_cat_after_2016"), 0) + ]) + + building_types = ['SFH', 'TH', 'MFH', 'AB', 'OB', 'SC', 'GS', 'RE', "UNI", "HOSPITAL", "CULTURE", "SPORT", "RETAIL", "WORKSHOP", "MIXED"] + + self.building_stats = {b_type: template_dict.copy() for b_type in building_types} + building_data_list = [] + for idx, building in enumerate(self.data.district): + features = building["buildingFeatures"] + b_type = features["building"] + stat_type = "MIXED" if "+" in b_type else b_type + + if stat_type in self.building_stats: + # Update building statistics by this building + self.building_stats[stat_type][self.translate("name_number_bldgs")] += 1 + self.building_stats[stat_type][self.translate("name_total_area")] += features["area"] + year_category = self._get_year_category(features["year"]) + self.building_stats[stat_type][year_category] += features["area"] + + building_dict = {} + building_dict[self.translate("name_building_id")] = features["id"] + + # Add to the dictionary from the mapping + building_dict.update({ + display_name: features.get(data_key) + for display_name, data_key in self.mapping_building_list.items() + }) + + # manual adjustments: + if features.get("heater") == "heat_grid": + building_dict["fTES"] = 0 # if building is connected to heat grid, no local TES even if otherwise specified + + # Add dictionary to the list + building_data_list.append(building_dict) + + self.gebaude_df = pd.DataFrame(building_data_list) + + # TODO: Maybe add here dtype casting e.g. ensure area is integer not float .... + + def _extract_kennwerte(self): + """Extracts the general key performance indicators.""" + years = self.kpis.inputData["simulated_years"] + obs_time = self.data.ecoData["observation_time"] + to_kW = 1000 # Convert W to kW for power values + to_MWh = 1000000 # Convert W to MWh for energy values + + # Prepare Data -> # TODO: Move to KPIs class + avg_autonomy = sum(self.kpis.energy_autonomy_year[y] for y in years) / len(years) + avg_scf = sum(self.kpis.scf_year[y] for y in years) / len(years) + avg_dcf = sum(self.kpis.dcf_year[y] for y in years) / len(years) + + + + # Overall_summary + self.district_key_kpis = [ + [self.translate("kpi_net_energy_demand"), f"{round((self.kpis.total_heating_demand + self.kpis.total_cooling_demand + self.kpis.total_electricity_demand + self.kpis.total_dhw_demand + self.kpis.total_EV_demand) / to_MWh, 1)} MWh/a"], + [self.translate("kpi_standard_heat_load"), f"{round(self.kpis.totalheatload / to_kW, 1)} kW"], + [self.translate("kpi_project_time"), f"{obs_time} {self.translate('name_years')}"] + ] + + self.district_operation_kpis = [ + [self.translate("kpi_avg_co2_emissions"), f"{round(self.kpis.avg_co2_emissions, 2)} t/a"], + [self.translate("kpi_avg_energy_costs"), f"{round(self.kpis.avg_operationCosts, 0)} €/a"], + [self.translate("kpi_sys_costs_central"), f"{round(self.kpis.annual_fixed_costs_central, 0)} €/a"], + [self.translate("kpi_sys_costs_decentral"), f"{round(self.kpis.annual_fixed_costs_decentral, 0)} €/a"], + [self.translate("kpi_peak_load_el"), f"{round(max(self.kpis.peakDemand.values()), 1)} kW"], + [self.translate("kpi_max_feed_in"), f"{round(max(self.kpis.peakInjection.values()), 1)} kW"], + [self.translate("kpi_autonomy_rate"), f"{round(avg_autonomy * 100, 1)} %"], + [self.translate("kpi_supply_cover_ratio"), f"{round(avg_scf * 100, 1)} %"], + [self.translate("kpi_demand_cover_ratio"), f"{round(avg_dcf * 100, 1)} %"], + ] + + # max loads in kW + self.max_loads_table = [ + [f"{self.translate('name_heat')}:", f"{int(round(self.kpis.total_heat_peak / to_kW))} kW"], + [f"{self.translate('name_el')}:", f"{int(round(self.kpis.total_electricity_peak / to_kW))} kW"], + [f"{self.translate('name_dhw')}:", f"{int(round(self.kpis.total_dhw_peak / to_kW))} kW"], + [f"{self.translate('name_cool')}:", f"{int(round(self.kpis.total_cooling_peak / to_kW))} kW"] + ] + + # energy demand in MWh/a + self.pie_chart_energy = { + self.translate("name_el"): round(self.kpis.total_electricity_demand / to_MWh, 2), + self.translate("name_heat"): round(self.kpis.total_heating_demand / to_MWh, 2), + self.translate("name_dhw"): round(self.kpis.total_dhw_demand / to_MWh, 2), + self.translate("name_cool"): round(self.kpis.total_cooling_demand / to_MWh, 2), + self.translate("name_ev"): round(self.kpis.total_EV_demand / to_MWh, 2) + } + + # Bar charts: + self.bar_costs_data = [] + self.bar_co2_data = [] + + for y in years: + # Fetch cost breakdown + costs = self.kpis.detailed_costs_year[y] + self.bar_costs_data.append({ + "Year": y, + self.translate("name_central_costs"): round(costs["eh_fixed"], 0), + self.translate("name_decentral_costs"): round(costs["decentral_fixed"], 0), + self.translate("name_el"): round(costs["electricity"], 0), + self.translate("name_gas"): round(costs["gas"], 0), + self.translate("name_oil"): round(costs["oil"], 0), + self.translate("name_waste"): round(costs["waste"], 0), + self.translate("name_biomass"): round(costs["biomass"], 0), + self.translate("name_district_heat"): round(costs["district_heat"], 0), + self.translate("name_hydrogen"): round(costs["hydrogen"], 0), + self.translate("name_feed_in_revenue"): round(costs["revenue_feed_in_el"], 0) + }) + + # Fetch CO2 breakdown + em = self.kpis.co2emissions[y] + self.bar_co2_data.append({ + "Year": y, + self.translate("name_el"): round(em["co2_dem_grid"], 2), + self.translate("name_gas"): round(em["co2_gas"], 2), + self.translate("name_oil"): round(em["co2_oil"], 2), + self.translate("name_waste"): round(em["co2_waste"], 2), + self.translate("name_biomass"): round(em["co2_biom"], 2), + self.translate("name_district_heat"): round(em["co2_district_heat"], 2), + self.translate("name_hydrogen"): round(em["co2_hydrogen"], 2) + }) + + # Maybe later add also the development of the energy demand over the years as a stacked bar if renovation measures or other changes are implemented in the multi-year simulation. + + self.kennwerte = { + "district_key_kpis": self.district_key_kpis, + "district_operation_kpis": self.district_operation_kpis, + "max_loads_table": self.max_loads_table, + "pie_chart_energy": self.pie_chart_energy, + "bar_costs_data": self.bar_costs_data, + "bar_co2_data": self.bar_co2_data, + "observation_time": obs_time + } + + def _extract_district_structure(self): + """Extracts the structural information of the district and prepares tables.""" + res_types = {'SFH', 'TH', 'MFH', 'AB'} + mixed_types = {'MIXED'} + + first_b_type = list(self.building_stats.keys())[0] + all_keys = list(self.building_stats[first_b_type].keys()) + age_classes = all_keys[2:] # Change to actively exclude Anzahl and Gesamtfläche instead of relying on the order + + agg_stats = { + self.translate("name_res_bldg"): {self.translate("name_number_bldgs"): 0, self.translate("name_total_area"): 0}, + self.translate("name_mixed_bldg"): {self.translate("name_number_bldgs"): 0, self.translate("name_total_area"): 0}, + self.translate("name_com_bldg"): {self.translate("name_number_bldgs"): 0, self.translate("name_total_area"): 0} + } + for cat in agg_stats: + for age in age_classes: + agg_stats[cat][age] = 0 + + details_rows = [] + + # Build the aggregated stats and the details rows at the same time by iterating through the building types only once + for b_type, stats in self.building_stats.items(): + + # Map to main category + if b_type in res_types: + cat = self.translate("name_res_bldg") + elif b_type in mixed_types: + cat = self.translate("name_mixed_bldg") + else: + cat = self.translate("name_com_bldg") + + # Sum up for the compact table + agg_stats[cat][self.translate("name_number_bldgs")] += stats[self.translate("name_number_bldgs")] + agg_stats[cat][self.translate("name_total_area")] += stats[self.translate("name_total_area")] + for age in age_classes: + agg_stats[cat][age] += stats[age] + + # Detailed row for the landscape page - ALWAYS appended + translated_name = self._translate_building_type(b_type) + detail_row = { + self.translate("name_building_type"): translated_name, + self.translate("name_number_bldgs"): stats[self.translate("name_number_bldgs")] if stats[self.translate("name_number_bldgs")] > 0 else "-" + } + for age in age_classes: + detail_row[age] = f"{round(stats[age])} m²" if stats[age] > 0 else "-" + details_rows.append(detail_row) + + # Summary Table displayed on the first page + summary_table_data = [ + ["", self.translate("name_res_bldg"), self.translate("name_mixed_bldg"), self.translate("name_com_bldg")], + [self.translate("name_number_bldgs"), + str(agg_stats[self.translate("name_res_bldg")][self.translate("name_number_bldgs")]) if agg_stats[self.translate("name_res_bldg")][self.translate("name_number_bldgs")] > 0 else "-", + str(agg_stats[self.translate("name_mixed_bldg")][self.translate("name_number_bldgs")]) if agg_stats[self.translate("name_mixed_bldg")][self.translate("name_number_bldgs")] > 0 else "-", + str(agg_stats[self.translate("name_com_bldg")][self.translate("name_number_bldgs")]) if agg_stats[self.translate("name_com_bldg")][self.translate("name_number_bldgs")] > 0 else "-"], + [self.translate("name_total_area"), + f"{round(agg_stats[self.translate("name_res_bldg")][self.translate("name_total_area")])} m²" if agg_stats[self.translate("name_res_bldg")][self.translate("name_total_area")] > 0 else "-", + f"{round(agg_stats[self.translate("name_mixed_bldg")][self.translate("name_total_area")])} m²" if agg_stats[self.translate("name_mixed_bldg")][self.translate("name_total_area")] > 0 else "-", + f"{round(agg_stats[self.translate("name_com_bldg")][self.translate("name_total_area")])} m²" if agg_stats[self.translate("name_com_bldg")][self.translate("name_total_area")] > 0 else "-"] + ] + for age in age_classes: + w_area = agg_stats[self.translate("name_res_bldg")][age] + m_area = agg_stats[self.translate("name_mixed_bldg")][age] + g_area = agg_stats[self.translate("name_com_bldg")][age] + + summary_table_data.append([ + age, + f"{round(w_area)} m²" if w_area > 0 else "-", + f"{round(m_area)} m²" if m_area > 0 else "-", + f"{round(g_area)} m²" if g_area > 0 else "-" + ]) + + # General info to be displayed below the summary table on the first page + general_info = [ + [self.translate("gen_info_nb_dwellings"), str(self.kpis.totalnumberflats)], + [self.translate("gen_info_nb_residents"), str(self.kpis.totalnumberocc)], + [self.translate("gen_info_postal_code_loc"), f"{str(self.data.site['zip'])}"], + # [self.translate("gen_info_district_area"), f"{round(self.data.site['district_area'], 2)} ha"], + [self.translate("gen_info_ref_year"), f"{str(self.data.site['TRYYear'])[3:]} / {self.data.site['TRYType']}"] + ] + + # 4. Pack everything into the final structure + self.district_structure = { + "summary_table": summary_table_data, + "general_info": general_info, + "df_details": pd.DataFrame(details_rows) + } + + def _translate_building_type(self, b_type:str) -> str: + """Translates the building type from the data to the display name.""" + return self.translate(f"bldg_{b_type}") + + def _extract_energyhub_data(self): + """Extracts the energyhub data for central devices.""" + try: + capacities = self.data.centralDevices["capacities"] + central_configs = self.data.central_device_data + + col_device = self.translate("table_device_colname") + col_capacity = self.translate("table_capacity_colname") + col_cost = self.translate("table_cost_sub_colname") + not_selected_text = self.translate("msg_not_selected") + + device_list = [] + + def append_energyhub_row(device_name, capacity, annual_cost): + device_list.append({ + col_device: device_name, + col_capacity: capacity, + col_cost: annual_cost + }) + + all_cost_devices = set(self.kpis.central_individual_devices_annualized_cost.keys()) + + # Iterate through all feasible devices + for dev, config in central_configs.items(): + if not isinstance(config, dict): + continue + if not config.get("feasible", False): + continue + + if dev == "AirHP" or dev == "GroundHP": + opt_key = "HP" + elif dev == "AirCC": + opt_key = "CC" + else: + opt_key = dev + + cap = 0 + annual_cost_sub = "-" + annual_cost_unsub = "-" + cost_unit = "" + + if opt_key in capacities: + spec = capacities[opt_key] + cap = round(spec["cap"], 2) + + if opt_key in self.kpis.central_individual_devices_annualized_cost: + device_cost_info = self.kpis.central_individual_devices_annualized_cost[opt_key] + annual_cost_sub = round(device_cost_info["subsidized_annual_cost"], 2) + annual_cost_unsub = round(device_cost_info["unsubsidized_annual_cost"], 2) + all_cost_devices.discard(opt_key) # Remove this device from the set of devices as it has been processed + cost_unit = " €/a" + + + # Get the device name and unit + name, base_unit = self.get_central_device_name(dev) + + display_cap, display_unit = self._determine_unit(cap=cap*1000, base_unit= base_unit) + + if cap <= 0: + display_cap = not_selected_text + + # Append dict to the device list + append_energyhub_row( + device_name=name, + capacity=f"{display_cap} {display_unit}".strip(), + annual_cost=f"{annual_cost_sub}{cost_unit}" + ) + + # Add all devices that are in the cost breakdown but not in the feasible central device data + for dev in all_cost_devices: + cost = round(self.kpis.central_individual_devices_annualized_cost[dev]['subsidized_annual_cost'], 2) + if cost == 0: + continue # Skip devices that have zero cost + elif cost > 0: + cost_unit = " €/a" + + name, base_unit = self.get_central_device_name(dev) + cap = 0 + display_cap, display_unit = self._determine_unit(cap=cap*1000, base_unit=base_unit) # Convert kW to W for unit determination + + if display_cap <= 0: + display_cap = "-" + + append_energyhub_row( + device_name=name, + capacity=f"{display_cap} {display_unit}".strip(), + annual_cost=f"{cost}{cost_unit}" + ) + + # Create the DataFrame only if devices are present + if device_list: + self.energyhub_df = pd.DataFrame(device_list) + else: + self.energyhub_df = None + + + except (KeyError, AttributeError) as e: + # If no central devices are defined, set energyhub_df to None + if "capacities" not in self.data.centralDevices: # if truly no central devices are defined + self.energyhub_df = None + else: + raise Exception(f"Error extracting energyhub data. Please check the structure of centralDevices and central_device_data in the input data.\n Caused error: {e}") + + def _extract_decentral_data(self): + """Extracts and aggregates decentral device capacities and counts across all buildings, excluding EV.""" + try: + aggregated_data = {} + + # 1. Iterate over all buildings and their decentral devices + for b_index, devices in self.kpis.decentral_individual_devices_annualized_cost.items(): + for dev_name, info in devices.items(): + # Skip Electric Vehicles and virtual measures + if dev_name in ["T_reduction_measures"]: + continue + + # Direct access to enforce crash on missing keys + cap = info["cap"] + cost = info["subsidized_annual_cost"] + + if cap == '' or cap is None: + cap = 0 + + cap_float = float(cap) + cost_float = float(cost) + + # Initialize dictionary structure for new devices + if dev_name not in aggregated_data: + aggregated_data[dev_name] = {"count": 0, "total_cap": 0.0, "total_cost": 0.0} + + # Add to aggregate sum and increment the count + if dev_name == "EV": + ev_caps = self.data.district[int(b_index)]["user"].ev_capacity + if ev_caps is None: + ev_caps = [] + elif isinstance(ev_caps, (int, float)): + ev_caps = [ev_caps] + + ev_count = sum(1 for x in ev_caps if float(x) > 0) + aggregated_data[dev_name]["count"] += ev_count + else: + aggregated_data[dev_name]["count"] += 1 + + aggregated_data[dev_name]["total_cap"] += cap_float + aggregated_data[dev_name]["total_cost"] += cost_float + + device_list = [] + + # 2. Format the aggregated data into a list of dictionaries for the DataFrame + for dev_name, data in aggregated_data.items(): + name, base_unit = self.get_decentral_device_name(dev_name) + + total_cap_adjusted, total_unit_adjusted = self._determine_unit(cap=data['total_cap']*1000, base_unit=base_unit) # Input cap is in kW, convert to W for unit determination + total_power = f"{total_cap_adjusted} {total_unit_adjusted}".strip() + + cost = round(data['total_cost'], 2) + if cost == 0: + ann_cost = f"-" + elif cost >0: + ann_cost = f"{cost} €/a" + else: + raise ValueError(f"Negative cost value encountered for device {dev_name}: {cost}. Please check the input data for inconsistencies.") + + + device_list.append({ + self.translate("table_device_colname"): name, + self.translate("table_count_colname"): data["count"], + self.translate("table_total_cap_colname"): total_power, + self.translate("table_cost_colname"): ann_cost + }) + + # 3. Create the DataFrame + if device_list: + self.decentral_df = pd.DataFrame(device_list) + else: + self.decentral_df = None + + except AttributeError as e: + print(f"Warning: Decentral device data could not be extracted. {e}") + self.decentral_df = None + + def _extract_district_layout(self): + """Extracts district layout information including building positions, network topology, and installed devices.""" + layout_data = { + "buildings": [], + "network_nodes": {}, + "network_edges": {}, + "pipeline_data": {} + } + + # Extract building positions, connectivity status, and devices + for building in self.data.district: + features = building["buildingFeatures"] + + # Safely get position + if "position" in features and isinstance(features["position"], (tuple, list)) and len(features["position"]) >= 2: + pos = features["position"] + + # Check for installed devices (capacity > 0) + installed_devices = [] + if "capacities" in building: + for device_name, cap in building["capacities"].items(): + # Capacities can be either a dict {"cap": X} or a direct number + if isinstance(cap, dict) and "cap" in cap and float(cap["cap"]) > 0: + installed_devices.append(device_name) + elif isinstance(cap, (int, float)) and float(cap) > 0: + installed_devices.append(device_name) + + # If the heater is defined in features and not in capacities, add it + main_heater = features["heater"] + if main_heater not in installed_devices: + installed_devices.append(main_heater) + + layout_data["buildings"].append({ + "id": building["unique_name"], # TODO: After Mixed Building Implementation change to: features["original_bldg_id"], + "name": building["unique_name"], + "x": float(pos[0]), + "y": float(pos[1]), + "type": features["building"], + "is_connected": main_heater == "heat_grid", + "devices": installed_devices + }) + + # Extract network topology if central heating network was generated + if hasattr(self.data, 'pipeline_nodes') and self.data.pipeline_nodes: + layout_data["network_nodes"] = self.data.pipeline_nodes + + if hasattr(self.data, 'pipeline_topology') and self.data.pipeline_topology: + layout_data["network_edges"] = self.data.pipeline_topology + + if hasattr(self.data, 'pipeline') and self.data.pipeline: + layout_data["pipeline_data"] = self.data.pipeline + + self.district_layout = layout_data + + def _extract_data(self): + """Extracts and processes all necessary data for the certificate.""" + self._process_buildings() + self._extract_kennwerte() + self._extract_district_structure() + self._extract_energyhub_data() + self._extract_decentral_data() + self._extract_district_layout() + # Additional data extraction methods can be added here + + def get_central_device_name(self, dev: str) -> tuple[str, str]: + """ + Returns the central device name and unit based on the device key. th for thermal devices and el for electric devices. If not specified, default is "W". + + Args: + dev (str): device key (e.g. "HP", "PV"). + data (object): The data object containing central device data. + + Returns: + tuple[str, str]: A tuple consisting of the full name and the unit. + """ + + + device_unit_map = { # if not specified, default is "W" + "H2S": "Wh", + "TES": "Whth", + "CTES": "Whth", + "BAT": "Whel", + "GS": "Wh", + "PV": "Wel", + "WT": "Wel", + "WAT": "Wel", + "CHP": "Wel", + "BCHP": "Wel", + "WCHP": "Wel", + "ELYZ": "Wel", + "FC": "Wel", + "STC": "Wth", + "HP": "Wth", + "AirHP": "Wth", + "GroundHP": "Wth", + "EB": "Wth", + "BOI": "Wth", + "GHP": "Wth", + "BBOI": "Wth", + "WBOI": "Wth", + "CC": "Wth", + "AirCC": "Wth", + "AC": "Wth", + "Heat_Grid": "Wth" + } + + name = self.translate(f"device_{dev}") + unit = device_unit_map.get(dev, "W") + + return name, unit + + def get_decentral_device_name(self, dev: str) -> tuple[str, str]: + """ + Returns the decentral device name and unit based on the device key. + Args: + dev (str): device key (e.g. "HP", "PV", "OBOI"). + Returns: + tuple[str, str]: A tuple consisting of the full name and the base unit (W or Wh) without any prefixes. + """ + + device_unit_map = { + "BAT": "Whel", + "TES": "Whth", + "EV": "Whel", + "STC": "m²", + "PV": "m²", + "HP": "Wth", + "HP35": "Wth", + "HP55": "Wth", + "EH": "Wth", + "CHP": "Wth", + "BOI": "Wth", + "BBOI": "Wth", + "OBOI": "Wth", + "H2BOI": "Wth", + "FC": "Wth", + "DH": "Wth", + "heat_grid": "Wth", + "CC": "Wth" + } + + # All devices + name = self.translate(f"device_{dev}") + base_unit = device_unit_map.get(dev, "W") + return name, base_unit + + @staticmethod + def _determine_unit(cap: float, base_unit: str) -> tuple[float, str]: + """ + Determines the appropriate unit (kW, MW, kWh, MWh) based on the capacity value and the base unit. Input cap is expected to be in kW or kWh. + + Args: + cap (float): The capacity value for capacity value in W/Wh/m². + base_unit (str): The base unit ("W" or "Wh", "kW", "kWh"). Can deal with "m²" as well for area devices. Does allow suffixes like "Wth". + + Returns: + tuple[float, str]: A tuple containing the adjusted capacity and the appropriate unit. + """ + + if base_unit == "m²": + if cap <= 0: + adjusted_cap = cap + adjusted_unit = "" # No prefix for zero or negative values + elif cap >= 10000: + adjusted_cap = round(cap / 10000, 2) + adjusted_unit = "ha" # Hectar for large areas + else: + adjusted_cap = round(cap, 2) + adjusted_unit = base_unit + elif cap <= 0: + adjusted_cap = cap + adjusted_unit = "" # No prefix for zero or negative values + elif cap >= 1000000000: + adjusted_cap = round(cap / 1000000000, 2) + adjusted_unit = "G" + base_unit # Giga + elif cap >= 1000000: + adjusted_cap = round(cap / 1000000, 2) + adjusted_unit = "M" + base_unit # Mega + elif cap >= 1000: + adjusted_cap = round(cap / 1000, 2) + adjusted_unit = "k" + base_unit # Kilo + else: + adjusted_cap = round(cap, 2) + adjusted_unit = base_unit + + return adjusted_cap, adjusted_unit + + + def get_kennwerte(self): + return self.kennwerte + + def get_optimization_results(self): + return self.optimization_results + + def get_district_structure(self): + return self.district_structure + + def get_building_df(self): + return self.gebaude_df + + def get_energyhub_df(self): + return self.energyhub_df + + def get_decentral_df(self): + return self.decentral_df + + def get_district_layout(self): + return self.district_layout + + def get_scenario_name(self): + return self.data.scenario_name + +################################################################################ +# Certificate Builder +################################################################################ + +class CertificateBuilder(ReportComponent): + """ + This class orchestrates the certificate creation by setting up data, theme, initializing the PDF template, and building the story. + """ + + def __init__(self, data, kpis, result_path) -> None: + # Initialize and apply theme globally and load the translations dict + self.report_config = data.report_config + self.language = data.report_config["language"] + report_theme = ThemeManager(self.report_config) + ReportComponent.apply_style(theme_manager=report_theme, language=self.language) + + self.data_object = DataExtractor(data=data, kpis=kpis) + self.scenario_name = self.data_object.get_scenario_name() + + self.style = self.get_style() + self.result_path = result_path + + if self.result_path is not None: + os.makedirs(self.result_path, exist_ok=True) + self.outputpath = os.path.join(result_path, f"Quartiersenergieausweis_{self.scenario_name}.pdf") + else: + src_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + self.outputpath = os.path.join(src_path, "results", f"Quartiersenergieausweis_{self.scenario_name}.pdf") + + margins = self.style.get_page_margins() + self.page_margins = { + "TitlePage": (margins['x'], margins['y']), + "ContentPage": (margins['x'], margins['y']), + "InputDataPage": (margins['small_x'], margins['small_y']), + "AdditionalInformationPage": (margins['small_x'], margins['small_y']), + } + + self.doc = CertificateTemplate(self.outputpath, self.page_margins) + self.layout = CertificateLayout(certificate_builder=self) + + def get_Framesize(self, id:str): + return self.doc.get_Framesize(id) + + def generate_certificate(self): + """ + Generates the certificate by creating the story of the individual pages. + """ + story = [] + + story.append(NextPageTemplate('TitlePage')) + self.layout.create_header() + self.layout.create_energiekennwerte(data_energiekennwerte=self.data_object.get_kennwerte()) + self.layout.create_quartiersstruktur(data_quartiersstruktur=self.data_object.get_district_structure()) + + story.extend(self.layout.get_story()) + self.layout.reset_story() + story.append(FrameBreak()) + + self.layout.create_footer(str(self.scenario_name)) + story.extend(self.layout.get_story()) + self.layout.reset_story() + + story.append(NextPageTemplate('ContentPage')) + story.append(PageBreak()) + + self.layout.create_yearly_bar_charts(self.data_object.get_kennwerte()) + story.extend(self.layout.get_story()) + self.layout.reset_story() + story.append(PageBreak()) + + self.layout.create_energyhub_data(data_energyhub=self.data_object.get_energyhub_df()) + self.layout.create_decentral_systems(data_decentral=self.data_object.get_decentral_df()) + story.extend(self.layout.get_story()) + self.layout.reset_story() + + story.append(NextPageTemplate('InputDataPage')) + story.append(PageBreak()) + self.layout.create_district_layout(data_district_layout=self.data_object.get_district_layout()) + story.extend(self.layout.get_story()) + self.layout.reset_story() + + story.append(PageBreak()) + self.layout.create_quartiersstruktur_details(data_quartiersstruktur=self.data_object.get_district_structure()) + story.extend(self.layout.get_story()) + self.layout.reset_story() + + story.append(PageBreak()) + self.layout.create_input_data_table(data_input=self.data_object.get_building_df()) + story.extend(self.layout.get_story()) + self.layout.reset_story() + + story.append(NextPageTemplate('AdditionalInformationPage')) + story.append(PageBreak()) + + self.layout.create_hinweise() + story.extend(self.layout.get_story()) + self.layout.reset_story() + + story.append(PageBreak()) + + self.doc.build(story) + print(f"Certificate saved to {self.outputpath}") + +if __name__ == "__main__": + print("All imports successful.") \ No newline at end of file diff --git a/districtgenerator/classes/datahandler.py b/districtgenerator/classes/datahandler.py index e952702..37d3968 100644 --- a/districtgenerator/classes/datahandler.py +++ b/districtgenerator/classes/datahandler.py @@ -109,6 +109,7 @@ def __init__(self, self.heat_grid_data = {} self.pipe_data = {} self.pyomo_config = {} + self.report_config = {} # Additional attributes self.counter = {} self.building_dict = {} # Dictionary to store Residential Building IDs @@ -164,16 +165,10 @@ def load_all_data(self, env_path, scenario_name): ------- None. """ + # --- 1. Load all Configs --- global_config: GlobalConfig = load_global_config(env_file=env_path) - self.scenario_name = scenario_name or global_config.scenario_name.scenario_name or "example_decentral" - - - # %% load scenario file with building information - self.scenario = (pd.read_csv(os.path.join(self.scenario_file_path, f"{self.scenario_name}.csv"), delimiter=";", - converters={"position": parse_position}).set_index("id", drop=False)) - # %% load information about of the site under consideration (used in generateEnvironment) # important for weather conditions for attr, value in global_config.location.__dict__.items(): @@ -218,10 +213,24 @@ def load_all_data(self, env_path, scenario_name): for attr, value in global_config.pyomo.__dict__.items(): self.pyomo_config[attr] = value + # load report configuration data + for attr, value in global_config.report.__dict__.items(): + self.report_config[attr] = value + # load heat grid data (used in heating network design and optimization) for attr, value in global_config.heatgrid.__dict__.items(): self.heat_grid_data[attr] = value + self.scenario_name = scenario_name or global_config.scenario_name.scenario_name or "example_decentral" + + # --- 2. Load scenario data --- + + # %% load scenario file with building information + self.scenario = (pd.read_csv(os.path.join(self.scenario_file_path, f"{self.scenario_name}.csv"), delimiter=";", + converters={"position": parse_position}).set_index("id", drop=False)) + + # --- 3. Load pipe data based on the selected heat grid generation --- + self.pipe_file_path = os.path.join(self.filePath, 'pipe') # select the pipe file based on the generation selection # KMR for 3rd generation; PMR for 4th generation; PE for 5th generation @@ -896,18 +905,18 @@ def generateDistrictComplete(self, calcUserProfiles=True, saveUserProfiles=True, or any( not isinstance(p, tuple) or len(p) != 2 or not all(isinstance(x, (int, float)) for x in p) for p in self.scenario["position"])) + if missing_positions: print("No district geometry found — running simple heating network design.") heating_network_simple.heating_network(self) - self.designCentralDevices(saveGenerationProfiles=True) - self.finalizeClusterProfiles() else: print("Generating and optimizing heating network...") self.generateNetwork(topology_option="node") self.prepareClusteringInputs() self.optimization_heatingnetwork() - self.designCentralDevices(saveGenerationProfiles=True) - self.finalizeClusterProfiles() + + self.designCentralDevices(saveGenerationProfiles=True) + self.finalizeClusterProfiles() else: print("No central heat grid detected — skipping heating network design.") self.centralDevices = {} @@ -1385,13 +1394,25 @@ def optimizationClusters(self): # save results as attribute self.resultsOptimization[year][cluster] = results_temp # Save the results of the optimization for each cluster + # Check which clusters were unsolvable + failed_optimizations = [] + for year, clusters in self.resultsOptimization.items(): + for cluster, result in clusters.items(): + if result is None: + failed_optimizations.append((year, cluster)) + + if failed_optimizations: + error_message = "The following optimization runs failed:\n" + for year, cluster in failed_optimizations: + error_message += f" - Year: {year}, Cluster: {cluster}\n" + + raise Exception(error_message) + end_time = time.time() print(f"\nOptimization of all clusters for all simulated years completed in {end_time - start_time:.2f} seconds.") def calculate_ecoData_per_cluster(self): ecoData = self.ecoData - # Change this to take the interpolation points from ecoData instead of hardcoding them - self.ecoData["interpolation_points"] = [0] simulated_years = self.ecoData["interpolation_points"] observation_time = self.ecoData["observation_time"] diff --git a/districtgenerator/data/.env.CONFIG.EXAMPLE b/districtgenerator/data/.env.CONFIG.EXAMPLE index ea0f1d2..6627372 100644 --- a/districtgenerator/data/.env.CONFIG.EXAMPLE +++ b/districtgenerator/data/.env.CONFIG.EXAMPLE @@ -56,6 +56,9 @@ INTEREST_RATE=0.05 # - OBSERVATION_TIME=20 # in years OPTIMIZATION_FOCUS=0 # Optimization focus. Annual costs vs CO2 emissions. '0' means only cost optimization; '1' means only CO2 optimization. +NUM_INTERPOLATION_POINTS=None +INTERPOLATION_POINTS= 0,5,10,15 + # Prices in €/kWh PRICE_SUPPLY_EL=[0.3460] PRICE_SUPPLY_EL_EH=[0.1590] @@ -159,6 +162,74 @@ SOLVER_OPTIONS__THREADS=4 SOLVER_OPTIONS__NONCONVEX=2 SOLVER_OPTIONS__DUAL_REDUCTIONS=1 +################################################# +### Report Configuration (ReportConfig) ### +################################################# + +# General Layout & Export +PAGESIZE=A4 +LANGUAGE=en + +# Colors - General (# indicates hex color code and not comment!) +COLORS__PRIMARY_COLOR=#368427 +COLORS__SECONDARY_COLOR=#86A91A +COLORS__BACKGROUND=#FFFFFF +COLORS__TEXT=#000000 +COLORS__TEXT_LIGHT=#3C3C3C + +# Colors - Energy Types (# indicates hex color code and not comment!) +COLORS__ENERGY__ELECTRICITY=#00551F +COLORS__ENERGY__HEATING=#86A91A +COLORS__ENERGY__DHW=#368427 +COLORS__ENERGY__COOLING=#7ABAD6 +COLORS__ENERGY__EV=#663399 + +# Colors - Sources & Costs (# indicates hex color code and not comment!) +COLORS__SOURCE__ELECTRICITY=#00551F +COLORS__SOURCE__GAS=#F39C12 +COLORS__SOURCE__OIL=#344EFB +COLORS__SOURCE__WASTE=#8B5A2B +COLORS__SOURCE__BIOMASS=#27AE60 +COLORS__SOURCE__DISTRICT_HEAT=#C0392B +COLORS__SOURCE__HYDROGEN=#2980B9 +COLORS__SOURCE__WASTE_HEAT=#E67E22 +COLORS__SOURCE__EH_FIXED=#2C3E50 +COLORS__SOURCE__DECENTRAL_FIXED=#7F8C8D +COLORS__SOURCE__REVENUE_FEED_IN_EL=#F10F84 + +# Colors - District Layout Graph (# indicates hex color code and not comment!) +COLORS__LAYOUT__BUILDING_CONNECTED=#2ECC71 +COLORS__LAYOUT__BUILDING_NOT_CONNECTED=#95A5A6 +COLORS__LAYOUT__EH=#E74C3C +COLORS__LAYOUT__PIPE=#3498DB + +# Sizes - District Layout Graph +SIZES__BUILDING=7 +SIZES__EH=10 +SIZES__PIPE=5 +SIZES__LABEL=8 +SIZES__LEGEND_TEXT=9 + +# Options - District Layout Graph +LAYOUT_OPTIONS__SHOW_BUILDING_LABELS=True +LAYOUT_OPTIONS__SHOW_PIPE_LABELS=True + +# Fonts - Families +FONTS__REGULAR=Helvetica +FONTS__BOLD=Helvetica-Bold + +# Fonts - Sizes +FONTS__SIZES__TITLE=20 +FONTS__SIZES__SECTION_TITLE=16 +FONTS__SIZES__SUBSECTION_TITLE=14 +FONTS__SIZES__HIGHLIGHTED=12 +FONTS__SIZES__BODY=12 +FONTS__SIZES__SMALL=10 +FONTS__SIZES__TABLE=11.5 +FONTS__SIZES__AXIS_VALUES=8 +FONTS__SIZES__DENSE=7 +FONTS__SIZES__PAGE_NUMBER=9 + ########################################################## ### Central Device Configuration (CentralDeviceConfig) ### ### Prefix "C_" for central only here, not config.py ### diff --git a/districtgenerator/data/report_translations.json b/districtgenerator/data/report_translations.json new file mode 100644 index 0000000..818bbdb --- /dev/null +++ b/districtgenerator/data/report_translations.json @@ -0,0 +1,423 @@ +{ + "en":{ + "ui_certificate_title": "District Energy Certificate", + "ui_page": "Page", + "ui_footer_name": "District Name:", + "ui_footer_created": "Created on:", + "name_for_thousand": "k", + "name_years": "Years", + + "title_kpis": "Energy Performance Indicators", + "title_district_structure": "District Structure", + "title_district_structure_detailed": "Net Floor Area by Building Type and Age Class", + "title_central_devices": "Central Energy Systems", + "title_decentral_devices": "Decentralized Energy Systems", + "title_district_layout": "District Layout", + "title_cost_emissions": "Annual Development (Costs & Emissions)", + "title_bldg_list": "List of Buildings", + "title_information": "General Information", + + "title_pie_chart_energy": "Energy Demand in MWh/a", + "title_optimized_operation_kpis": "Optimized System Operation", + "title_max_loads": "Peak Loads", + "title_cost_graph": "Costs", + "title_emissions_graph": "CO2 Emissions", + "axis_title_simulated_year": "Simulated Years", + + "kpi_net_energy_demand": "Net Energy Demand:", + "kpi_standard_heat_load": "Design Heat Load:", + "kpi_project_time": "Project Duration:", + "kpi_avg_co2_emissions": "Ø CO2 Emissions:", + "kpi_avg_energy_costs": "Ø Energy Costs:", + "kpi_sys_costs_central": "Central System Costs:", + "kpi_sys_costs_decentral": "Decentral System Costs:", + "kpi_peak_load_el": "Peak Load (el.):", + "kpi_max_feed_in": "Max. Feed-in Power:", + "kpi_autonomy_rate": "Autonomy Rate:", + "kpi_supply_cover_ratio": "Supply-Cover Factor:", + "kpi_demand_cover_ratio": "Demand-Cover Factor:", + + "name_heat": "Heating", + "name_cool": "Cooling", + "name_el": "Electricity", + "name_dhw": "DHW", + "name_ev": "EV", + "name_gas": "Gas", + "name_oil": "Oil", + "name_waste": "Waste", + "name_biomass": "Biomass", + "name_district_heat": "District Heating", + "name_hydrogen": "Hydrogen", + "name_waste_heat": "Waste Heat", + + "name_central_costs": "Central System Costs", + "name_decentral_costs": "Decentral System Costs", + "name_feed_in_revenue": "Feed-in Revenues (el.)", + + "name_res_bldg": "Residential", + "name_com_bldg": "Commercial", + "name_mixed_bldg": "Mixed-use", + "name_total_area": "Total Area", + "name_number_bldgs": "Building Count", + + "name_building_id": "Building ID", + "name_building_type": "Building Type", + "name_building_year": "Construction Year", + "name_building_retrofit": "Retrofit Level", + "name_sp_mass": "Sp-Mass", + "name_night_setback": "Night Setback", + "name_building_area": "NFA (m²)", + "name_heating_tech": "Heating", + "name_ev_share": "EV Share", + "name_f_tes": "fTES", + "name_f_bat": "fBAT", + "name_f_pv1": "fPV1", + "name_f_pv2": "fPV2", + "name_f_stc": "fSTC", + "name_gamma_pv": "gammaPV", + "name_ev_charging": "EV Charging Mode", + + "name_age_cat_before_1968": "before 1968", + "name_age_cat_1968_1978": "1968-1978", + "name_age_cat_1979_1983": "1979-1983", + "name_age_cat_1984_1994": "1984-1994", + "name_age_cat_1995_2001": "1995-2001", + "name_age_cat_2002_2009": "2002-2009", + "name_age_cat_2010_2015": "2010-2015", + "name_age_cat_after_2016": "2016 and later", + + "name_seasonal_storage": "Seasonal Storage", + "name_waste_heat_potential": "Waste Heat Potential", + + "gen_info_nb_dwellings": "Residential Units in District", + "gen_info_nb_residents": "District Residents", + "gen_info_postal_code_loc": "Location (Postal Code)", + "gen_info_ref_year": "Test Reference Year", + + "table_device_colname": "Device", + "table_capacity_colname": "Capacity", + "table_total_cap_colname": "Total Capacity", + "table_count_colname": "Count", + "table_cost_sub_colname": "System Costs (sub.)", + "table_cost_colname": "System Costs", + + "msg_no_central_devices": "No central energy systems were designed.", + "msg_no_decentral_devices": "No decentralized energy systems were designed.", + "msg_no_district_layout": "No district layout available.", + "msg_not_selected": "not selected", + + "legend_eh": "Energy Hub", + "legend_pipes": "Heat Grid Pipes", + "legend_pipe_dn": "(DN-X = Nominal Diameter in mm)", + "legend_bldg_conn": "Connected Buildings", + "legend_bldg_not_conn": "Unconnected Buildings", + + "bldg_SFH": "Single Family Home", + "bldg_TH": "Terraced House", + "bldg_MFH": "Multi Family Home", + "bldg_AB": "Apartment Block", + "bldg_OB": "Office Building", + "bldg_SC": "School", + "bldg_GS": "Grocery Store", + "bldg_RE": "Restaurant", + "bldg_UNI": "University Building", + "bldg_HOSPITAL": "Hospital", + "bldg_CULTURE": "Cultural Building", + "bldg_SPORT": "Sports Building", + "bldg_RETAIL": "Retail Building", + "bldg_WORKSHOP": "Workshop", + "bldg_MIXED": "Mixed-use Building", + + "device_PV": "Photovoltaics", + "device_WT": "Wind Turbine", + "device_WAT": "Water Turbine", + "device_STC": "Solar Thermal", + "device_CHP": "Combined Heat and Power", + "device_AirHP": "Air-source Heat Pump", + "device_GroundHP": "Ground-source Heat Pump", + "device_HP": "Heat Pump", + "device_BOI": "Gas Boiler", + "device_GHP": "Gas Heat Pump", + "device_EB": "Electric Boiler", + "device_AC": "Absorption Chiller", + "device_BCHP": "Biomass Combined Heat and Power", + "device_BBOI": "Biomass Boiler", + "device_WCHP": "Waste Combined Heat and Power", + "device_WBOI": "Waste Boiler", + "device_ELYZ": "Electrolyzer", + "device_FC": "Fuel Cell", + "device_H2S": "Hydrogen Storage", + "device_SAB": "Sabatier Reactor", + "device_TES": "Heat Storage", + "device_CTES": "Cold Storage", + "device_BAT": "Battery Storage", + "device_GS": "Gas Storage", + "device_AirCC": "Air-cooled Chiller", + "device_CC": "Compression Chiller", + "device_EH": "Electric Heater", + "device_OBOI": "Oil Boiler", + "device_H2BOI": "Hydrogen Boiler", + "device_DH": "District Heating Connection", + "device_Heat_Grid": "Local Heat Grid", + "device_EV": "Electric Vehicle", + "device_HP35": "Heat Pump (35°C)", + "device_HP55": "Heat Pump (55°C)", + + "content_information_page": { + "Energy Performance Indicators": { + "Net Energy Demand": "Total net energy demand aggregated across all buildings (household electricity, heating, domestic hot water, cooling, and EV electricity).", + "Standard Heat Load": "Total standard heat load across all buildings according to DIN EN ISO 13790.", + "Energy Demand (MWh)": "Total annual energy demand across all buildings based on the generated demand profiles (for heating, cooling, household electricity, domestic hot water (DHW), and electric vehicles (EV)).", + "Peak Loads": "Peak loads in kW in the district based on the aggregated demand profiles of all buildings (without operation optimization)." + }, + "Optimized System Operation": { + "Ø CO2 Emissions": "CO2 equivalents emitted in the district in t/a through optimized operation (gas and electricity demand).", + "Ø Energy Costs": "Specific operation costs of the entire district in €/kWh based on operation optimization.", + "System Costs": "Annualized fixed costs of all installed energy systems. This includes the allocated investment costs (CAPEX) minus subsidies, as well as fixed operation and maintenance costs (O&M).", + "Peak Load (el.)": "Maximum electricity drawn by the entire district from the overarching power grid based on operation optimization.", + "Max. Feed-in Power": "Maximum electricity fed into the overarching power grid by the entire district based on operation optimization.", + "Feed-in Revenues (el.)": "Revenues from feeding locally generated electricity into the overarching power grid based on operation optimization in €/a.", + "Autonomy Rate": "Proportion of the operating time during which the local electricity demand is fully covered by electricity generation within the district (values between 0 % and 100 %).", + "Supply-Cover Factor": "Proportion of the electricity generated by buildings and fed into the local grid that is used for self-consumption within the district by other buildings (values between 0 % and 100 %).", + "Demand-Cover Factor": "Proportion of the residual electricity demand in the district that is covered by the electricity generated and fed into the local grid by buildings in the district (values between 0 % and 100 %).", + "Seasonal Storage": "Available annual withdrawal capacity from seasonal storage in MWh/a. A constant withdrawal capacity over the year is assumed." + }, + "Designations for District Structure and District Layout": { + "Commercial Buildings": "Buildings with commercial, trade, and service usage.", + "Mixed-use Buildings": "Buildings with a mixed usage of residential and commercial.", + "Test Reference Year": "Reference year used for determining demand and generating renewable energy sources based on weather data.", + "Energy Hub": "Central energy generation plant that feeds the district's heat grid.", + "DN (Nominal Diameter)": "Inner diameter of the laid heat grid pipelines in millimeters." + }, + "Designations in the List of Buildings": { + "Building ID": "Building ID for unique identification.", + "Building Type": "SFH = Single Family Home, MFH = Multi Family Home, TH = Terraced House, AB = Apartment Block, OB = Office Building, SC = School, GS = Grocery Store, RE = Restaurant, UNI = University Building, HOSPITAL = Hospital, CULTURE = Cultural Building, SPORT = Sports Building, RETAIL = Retail Building, WORKSHOP = Workshop. A '+' (e.g., MFH+RETAIL) indicates a mixed-use building.", + "Construction Year": "Construction age class (before 1969, 1968-1978, 1979-1983, 1984-1994, 1995-2001, 2002-2009, 2010-2015, from 2016).", + "Retrofit for Residential Buildings": "0 = Existing, 1 = Retrofit according to EnEV 2016, 2 = Retrofit according to KfW 55.", + "Retrofit for Non-Residential Buildings": "0 = Unrenovated, 1 = Partially renovated (only windows and walls), 2 = Fully renovated (ceiling, windows, roof, and walls).", + "Thermal Mass": "Building thermal storage mass: 0 = Lightweight, 1 = Medium-weight, 2 = Heavyweight.", + "Night Setback": "0 = No night setback, 1 = With night setback.", + "NFA": "Net floor area in m².", + "Heating": "Selected heat generator.", + "EV": "Between 0 and 1; Share of electric vehicles in the total vehicle fleet in the building.", + "fTES": "Size of the buffer storage in liters per kW heating capacity of the heat generation system.", + "fBAT": "Size of the battery storage depending on the capacity of the PV system in Wh/W_PV.", + "fPV1": "Share of the total roof area covered with photovoltaics on roof side 1. Roof side 1 is the side for which the azimuth angle gammaPV is assigned (information on roof areas can be found in the type buildings according to Tabula).", + "fPV2": "Share of the total roof area covered with photovoltaics on roof side 2. The azimuth angle of roof side 2 is calculated as 180° rotated to gammaPV (\"opposite\").", + "fSTC": "Share of the roof area equipped with solar thermal energy (information on roof areas can be found in the type buildings according to Tabula).", + "gammaPV": "Azimuth = sky orientation of roof side 1, orientation to the south corresponds to 0°.", + "EV Charging Mode": "Charging behavior of the electric vehicle (bidirectional: charging and discharging, use as power storage; on-demand: charging as needed; intelligent: optimized charging)." + } + } + }, + "de":{ + "ui_certificate_title": "Quartiersenergieausweis", + "ui_page": "Seite", + "ui_footer_name": "Quartiersname:", + "ui_footer_created": "Erstellt am:", + "name_for_thousand": "Tsd.", + "name_years": "Jahre", + + "title_kpis": "Energetische Kennwerte", + "title_district_structure": "Quartiersstruktur", + "title_district_structure_detailed": "Nettoraumfläche nach Gebäudetyp und Altersklasse", + "title_central_devices": "Zentrale Energiesysteme", + "title_decentral_devices": "Dezentrale Energiesysteme", + "title_district_layout": "Quartierslayout", + "title_cost_emissions": "Jährliche Entwicklung (Kosten & Emissionen)", + "title_bldg_list": "Liste der Gebäude", + "title_information": "Allgemeine Hinweise", + "title_pie_chart_energy": "Energiebedarfe in MWh/a", + "title_optimized_operation_kpis": "Optimierter Anlagenbetrieb", + "title_max_loads": "Maximale Leistungen", + "title_cost_graph": "Kosten", + "title_emissions_graph": "CO2-Emissionen", + "axis_title_simulated_year": "simulierte Jahre", + + "msg_no_central_devices": "Es wurden keine zentralen Energiesysteme ausgelegt", + "msg_no_decentral_devices": "Es wurden keine dezentralen Energiesysteme ausgelegt", + "msg_no_district_layout": "Kein Quartierslayout verfügbar", + "msg_not_selected": "nicht ausgewählt", + + "kpi_net_energy_demand": "Nutzenergiebedarf:", + "kpi_standard_heat_load": "Norm-Heizlast:", + "kpi_project_time": "Projektdauer:", + "kpi_avg_co2_emissions": "Ø CO2-Emissionen:", + "kpi_avg_energy_costs": "Ø Energiekosten:", + "kpi_sys_costs_central": "Anlagenkosten Zentral:", + "kpi_sys_costs_decentral": "Anlagenkosten Dezentral:", + "kpi_peak_load_el": "Spitzenlast (el.):", + "kpi_max_feed_in": "Max. Einspeiseleistung:", + "kpi_autonomy_rate": "Autarkiegrad:", + "kpi_supply_cover_ratio": "Supply-Cover Ratio:", + "kpi_demand_cover_ratio": "Demand-Cover Ratio:", + + "name_el": "Strom", + "name_heat": "Wärme", + "name_dhw": "TWW", + "name_cool": "Kälte", + "name_ev": "EV", + "name_gas": "Gas", + "name_oil": "Öl", + "name_waste": "Abfall", + "name_biomass": "Biomasse", + "name_hydrogen": "Wasserstoff", + "name_district_heat": "Fernwärme", + "name_waste_heat": "Abwärme", + "name_waste_heat_potential": "Abwärmepotential", + "name_seasonal_storage": "Saisonaler Speicher", + + "name_central_costs": "Anlagenkosten zentral", + "name_decentral_costs": "Anlagenkosten dezentral", + "name_feed_in_revenue": "Einspeiseerlöse (el.)", + + "table_device_colname": "Anlage", + "table_capacity_colname": "Kapazität", + "table_total_cap_colname": "Gesamtkapazität", + "table_count_colname": "Anzahl", + "table_cost_sub_colname": "Anlagenkosten (subv.)", + "table_cost_unsub_colname": "Anlagenkosten (unsubv.)", + "table_cost_colname": "Anlagenkosten", + + "name_building_id": "Gebäude ID", + "name_building_type": "Gebäudetyp", + "name_building_year": "Baujahr", + "name_building_retrofit": "Sanierungs Zustand", + "name_sp_mass": "Sp-Masse", + "name_night_setback": "Nacht Absenkung", + "name_building_area": "NRF (m²)", + "name_heating_tech": "Heizungsart", + "name_ev_share": "EV-Anteil", + "name_f_tes": "fTES", + "name_f_bat": "fBAT", + "name_f_pv1": "fPV1", + "name_f_pv2": "fPV2", + "name_f_stc": "fSTC", + "name_gamma_pv": "gammaPV", + "name_ev_charging": "EV Ladestrategie", + + "name_res_bldg": "Wohngebäude", + "name_com_bldg": "GHD-Gebäude", + "name_mixed_bldg": "Mischgebäude", + "name_total_area": "Gesamtfläche", + "name_number_bldgs": "Anzahl Gebäude", + + "name_age_cat_before_1968": "vor 1968", + "name_age_cat_1968_1978": "1968-1978", + "name_age_cat_1979_1983": "1979-1983", + "name_age_cat_1984_1994": "1984-1994", + "name_age_cat_1995_2001": "1995-2001", + "name_age_cat_2002_2009": "2002-2009", + "name_age_cat_2010_2015": "2010-2015", + "name_age_cat_after_2016": "ab 2016", + + "bldg_SFH": "Einfamilienhaus", + "bldg_TH": "Reihenhaus", + "bldg_MFH": "Mehrfamilienhaus", + "bldg_AB": "Apartmentblock", + "bldg_OB": "Bürogebäude", + "bldg_SC": "Schulgebäude", + "bldg_GS": "Lebensmittelgeschäft", + "bldg_RE": "Restaurantgebäude", + "bldg_UNI": "Universitätsgebäude", + "bldg_HOSPITAL": "Krankenhausgebäude", + "bldg_CULTURE": "Kulturgebäude", + "bldg_SPORT": "Sportgebäude", + "bldg_RETAIL": "Handelsgebäude", + "bldg_WORKSHOP": "Werkstattgebäude", + "bldg_MIXED": "Mischgebäude", + + "device_PV": "Photovoltaik", + "device_WT": "Windkraftanlage", + "device_WAT": "Wasserkraftanlage", + "device_STC": "Solarthermie", + "device_CHP": "Blockheizkraftwerk", + "device_AirHP": "Luftwärmepumpe", + "device_GroundHP": "Erdwärmepumpe", + "device_HP": "Wärmepumpe", + "device_BOI": "Heizkessel", + "device_GHP": "Gaswärmepumpe", + "device_EB": "Elektrokessel", + "device_AC": "Absorpt.-Kältemaschine", + "device_BCHP": "Biomasse-BHKW", + "device_BBOI": "Biomasse-Heizkessel", + "device_WCHP": "Abfall-BHKW", + "device_WBOI": "Abfall-Heizkessel", + "device_ELYZ": "Elektrolyseur", + "device_FC": "Brennstoffzelle", + "device_H2S": "Wasserstoffspeicher", + "device_SAB": "Sabatier-Reaktor", + "device_TES": "Wärmespeicher", + "device_CTES": "Kältespeicher", + "device_BAT": "Batteriespeicher", + "device_GS": "Gasspeicher", + "device_AirCC": "Luftgekühlte Kältemaschine", + "device_CC": "Kompr.-Kältemaschine", + "device_Heat_Grid": "Nahwärmenetz", + "device_EH": "Heizstab", + "device_OBOI": "Öl-Heizkessel", + "device_H2BOI": "Wasserstoff-Heizkessel", + "device_DH": "Fernwärmeanschluss", + "device_EV": "Elektrofahrzeug", + "device_HP35": "Wärmepumpe (35°C)", + "device_HP55": "Wärmepumpe (55°C)", + + "legend_eh": "Energiezentrale", + "legend_pipes": "Rohre des Wärmenetzes", + "legend_pipe_dn": "(DN-X = Nenndurchmesser in mm)", + "legend_bldg_conn": "angeschlossene Gebäude", + "legend_bldg_not_conn": "nicht angeschlossene Gebäude", + + "gen_info_nb_dwellings": "Wohneinheiten im Quartier", + "gen_info_nb_residents": "Bewohner des Quartiers", + "gen_info_postal_code_loc": "Standort (PLZ)", + "gen_info_ref_year": "Testreferenzjahr", + + "content_information_page": { + "Energetische Kennwerte": { + "Nutzenergiebedarf": "Über alle Gebäude aufsummierter Nutzenergiebedarf (Haushaltsstrom, Wärme, Trinkwarmwasser, Kälte und EV-Strom)", + "Norm-Heizlast": "Über alle Gebäude aufsummierte Norm-Heizlast nach DIN EN ISO 13790", + "Energiebedarfe (MWh)": "Über alle Gebäude aufsummierten Jahresenergiebedarfe auf Basis der generierten Bedarfsprofile (für Wärme, Kälte, Haushaltsstrom, Trinkwarmwasser (TWW) und Elektroautos (EV))", + "Maximale Leistungen": "Maximale Leistungen in kW im Quartier auf Basis der aufsummierten Bedarfsprofile aller Gebäude (ohne Betriebsoptimierung)" + }, + "Optimierter Anlagenbetrieb": { + "Ø CO2-Emissionen": "Im Quartier emittierte CO2-Äquivalente in t/a durch den optimierten Betrieb (Gasbedarf und Strombedarf)", + "Ø Energiekosten": "Spezifische Betriebskosten des gesamten Quartiers in €/kWh auf Basis der Betriebsoptimierung", + "Anlagenkosten": "Annuitätische Fixkosten aller installierten Energieanlagen. Dies beinhaltet die umgelegten Investitionskosten (CAPEX) abzüglich Subventionen sowie feste Betriebs- und Wartungskosten (O&M).", + "Spitzenlast (el.)": "Maximaler Strombezug des gesamten Quartiers aus übergeordnetem Stromnetz auf Basis der Betriebsoptimierung", + "Max. Einspeiseleistung": "Maximale Stromeinspeisung des gesamten Quartiers in übergeordnetes Stromnetz auf Basis der Betriebsoptimierung", + "Einspeiseerlöse (el.)": "Erlöse durch die Einspeisung von lokal erzeugtem Strom in das übergeordnete Stromnetz auf Basis der Betriebsoptimierung in €/a", + "Autarkiegrad": "Anteil der Betriebszeit, in der der lokale Strombedarf vollständig durch die Stromerzeugung im Quartier gedeckt wird (Werte zwischen 0 % und 100 %)", + "Supply-Cover-Faktor": "Anteil des aus den Gebäuden des Quartiers ins lokale Netz eingespeisten Stroms, der für den Eigenverbrauch innerhalb des Quartiers durch andere Gebäude genutzt wird (Werte zwischen 0 % und 100 %)", + "Demand-Cover-Faktor": "Anteil des residualen Strombedarfs im Quartier, der durch den von den Gebäuden im Quartier erzeugten und ins lokale Netz eingespeisten Stroms gedeckt wird (Werte zwischen 0 % und 100 %)", + "Saisonaler Speicher": "Zur Verfügung stehende jährliche Entnahmeleistung aus saisonalen Speichern in MWh/a. Es wird eine konstante Entnahmeleistung über das Jahr angenommen." + }, + "Bezeichnungen für die Quartierstruktur und das Quartierslayout": { + "GHD-Gebäude": "Gewerbe-, Handels- und Dienstleistungsgebäude", + "Mischgebäude": "Gebäude mit einer gemischten Nutzung aus Wohnen und GHD", + "Testreferenzjahr": "Verwendetes Referenzjahr für die Bedarfsermittlung sowie die Erzeugung von Erneuerbaren Energiequellen anhand von Wetterdaten", + "Energiezentrale": "Zentrale Energieerzeugungsanlage, die das Wärmenetz des Quartiers speist", + "DN (Nenndurchmesser)": "Innendurchmesser der verlegten Rohrleitungen des Wärmenetzes in Millimetern" + }, + "Bezeichnungen in der Liste der Gebäude": { + "Gebäude ID": "ID des Gebäudes zur eindeutigen Identifizierung", + "Gebäudetyp": "SFH = Einfamilienhaus, MFH = Mehrfamilienhaus, TH = Reihenhaus, AB = Wohnblock, OB = Bürogebäude, SC = Schule, GS = Lebensmittelgeschäft, RE = Restaurant, UNI = Universitätsgebäude, HOSPITAL = Krankenhaus, CULTURE = Kulturgebäude, SPORT = Sportgebäude, RETAIL = Handelsgebäude, WORKSHOP = Werkstattgebäude. Ein '+' (z. B. MFH+RETAIL) kennzeichnet ein Mischgebäude", + "Baujahr": "Baualtersklasse (vor 1969, 1968-1978, 1979-1983, 1984-1994, 1995-2001, 2002-2009, 2010-2015, ab 2016)", + "Sanierung für Wohngebäude": "0 = Bestand, 1 = Sanierung nach EnEV 2016, 2 = Sanierung nach KfW 55", + "Sanierung für Nichtwohngebäude": "0 = Nichtsaniert, 1 = Teilsaniert (nur Fenster und Wände), 2 = Vollsaniert (Decke, Fenster, Dach und Wände)", + "Sp-Masse": "Gebäudespeichermasse: 0 = Leichtbau, 1 = Mittelbau, 2 = Massivbau", + "Nacht Absenkung": "Nachtabsenkung: 0 = keine Nachtabsenkung, 1 = mit Nachtabsenkung", + "NRF": "Nettoraumfläche in m²", + "Heizungsart": "ausgewählter Wärmeerzeuger", + "EV-Anteil": "Zwischen 0 und 1; Anteil der Elektroautos am Gesamtfahrzeugbestand im Gebäude", + "fTES": "Größe des Pufferspeichers in Liter pro kW Heizleistung der Wärmeerzeugungsanlage", + "fBAT": "Größe des Batteriespeichers in Abhängigkeit der Leistung der PV-Anlage in Wh/W_PV", + "fPV1": "Anteil der gesamten Dachfläche, der auf Dachseite 1 mit Photovoltaik belegt ist. Dachseite 1 ist dabei die Seite, für die der Azimutwinkel gammaPV vergeben wird (Informationen zu Dachflächen sind den Typgebäuden nach Tabula zu entnehmen)", + "fPV2": "Anteil der gesamten Dachfläche, der auf Dachseite 2 mit Photovoltaik belegt ist. Der Azimutwinkel von Dachseite 2 wird als 180° zu gammaPV gedreht (\"gegenüberliegend\") berechnet.", + "fSTC": "Anteil der Dachfläche, die mit Solarthermie ausgestattet ist (Informationen zu Dachflächen sind den Typgebäuden nach Tabula zu entnehmen)", + "gammaPV": "Azimut = Himmelsausrichtung von Dachseite 1, Ausrichtung nach Süden entspricht 0°", + "EV Ladestrategie": "Ladeverhalten des Elektroautos (bi-direktional: Be- und Entladung, Nutzung als Stromspeicher, on-demand: Beladung nach Bedarf, intelligent: optimierte Beladung)" + } + } + } +} \ No newline at end of file diff --git a/districtgenerator/data_handling/config.py b/districtgenerator/data_handling/config.py index 4843dfa..59496f3 100644 --- a/districtgenerator/data_handling/config.py +++ b/districtgenerator/data_handling/config.py @@ -8,8 +8,6 @@ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict from pydantic_settings.sources import PydanticBaseSettingsSource -from dotenv import dotenv_values - ### Helper functions ### def parse_float_list(value: any) -> list[float]: @@ -569,7 +567,7 @@ class EHDOConfig(BaseSettings): enable_supply_limit_gas: bool = False # Enable limit annual gas import, bool. # Other options - peak_dem_met_conv: bool = True # Meet peak demands of unclustered demands, bool. + peak_dem_met_conv: bool = True # Meet peak demands without utilizing fluctuating sources (STC, PV, WT), bool. co2_el_feed_in: float = 0 #! CO₂ emission credit for electricity feed-in kg/kWh (Move to EcoConfig) co2_gas_feed_in: float = 0 #! CO₂ emission credit for gas feed-in kg/kWh (Move to EcoConfig) n_clusters: int = 12 # Number of design days. @@ -584,7 +582,7 @@ class EHDOConfig(BaseSettings): class CalendarConfig(BaseSettings): """ - CalenderConfig class to manage calendar-related parameters for the district generator. + CalendarConfig class to manage calendar-related parameters for the district generator. This class contains parameters related to holidays and initial days for different years. """ consider_heating_period: bool = True # Consider heating period in the clustering (True) or calculate whole year (False) @@ -611,6 +609,182 @@ class ScenarioName(BaseSettings): extra = 'ignore' # Ignores all other variables in the .env.CONFIG file ) +class ReportConfig(BaseSettings): + """ + ReportConfig class to manage the configuration for report generation in the districtgenerator. + Configuration parameters for the generation of the Quartiersenergieausweis (PDF certificate). + Change Layout, as well as colors and fonts to match corporate design. + Colors can be defined as HEX codes or RGB tuples. HEX codes will be automatically converted to RGB tupels. RGB tuples should be in the range 0-255 for each value. + """ + + # Layout + pagesize: str = "A4" # Alternatives: A3, A4; Layout optimized for A4 Format + + # Language + language: str = "en" # Language for the report, selected between: "de" (German) and "en" (English). + + # --- Colors Dictionary --- + colors: dict = {} + colors__primary_color: str | Tuple[float, float, float] = "#368427" # Main color of the Report, Used for Frames and Lines + colors__secondary_color: str | Tuple[float, float, float] = "#86A91A" # Secondary color of the report e.g. used for bars in graphs + colors__background: str | Tuple[float, float, float] = "#FFFFFF" # Color for the background of the report and for background in tables + colors__text: str | Tuple[float, float, float] = "#000000" # Color of the text and titles in report + colors__text_light: str | Tuple[float, float, float] = "#3C3C3C" # Color of the text for additional information that is supposed to be less prominent + + # Colors for energy types in graphs + colors__energy__electricity: str | Tuple[float, float, float] = "#00551F" + colors__energy__heating: str | Tuple[float, float, float] = "#86A91A" + colors__energy__dhw: str | Tuple[float, float, float] = "#368427" + colors__energy__cooling: str | Tuple[float, float, float] = "#7ABAD6" + colors__energy__ev: str | Tuple[float, float, float] = "#663399" + + # Colors for energy sources and cost categories in graphs + colors__source__electricity: str | Tuple[float, float, float] = "#00551F" # Grid electricity + colors__source__gas: str | Tuple[float, float, float] = "#F39C12" # Natural gas + colors__source__oil: str | Tuple[float, float, float] = "#344EFB" # Heating oil + colors__source__waste: str | Tuple[float, float, float] = "#8B5A2B" # Waste + colors__source__biomass: str | Tuple[float, float, float] = "#27AE60" # Biomass + colors__source__district_heat: str | Tuple[float, float, float] = "#C0392B" # District heating + colors__source__hydrogen: str | Tuple[float, float, float] = "#2980B9" # Hydrogen + colors__source__waste_heat: str | Tuple[float, float, float] = "#E67E22" # Waste heat + + # Colors for fixed costs and revenues in financial charts + colors__source__eh_fixed: str | Tuple[float, float, float] = "#2C3E50" # Central energy hub fixed costs + colors__source__decentral_fixed: str | Tuple[float, float, float] = "#7F8C8D" # Decentralized fixed costs + colors__source__revenue_feed_in_el: str | Tuple[float, float, float] = "#F10F84" # Revenue from electricity feed-in + + # Colors for district layout: + colors__layout__building_connected: str | Tuple[float, float, float] = "#2ECC71" # Color for buildings connected to the heatgrid + colors__layout__building_not_connected: str | Tuple[float, float, float] = "#95A5A6" # Color for buildings that are not connected to the heatgrid + colors__layout__eh: str | Tuple[float, float, float] = "#E74C3C" # Color for the energy hub + colors__layout__pipe: str | Tuple[float, float, float] = "#3498DB" # Color for the pipes in the district layout graph + + # Sizes of the elements in the district layout visualization + sizes: dict = {} + sizes__building: float = 7 # Radius of the circles representing buildings + sizes__eh: float = 10 # Radius of the circle representing the energy hub + sizes__pipe: float = 5 # Thickness of the lines representing the pipes in the district layout graph + sizes__label: int = 8 # Font size for labels in the district layout graph + sizes__legend_text: int = 9 # Font size for text in legends in the district layout graph + + # Options to show or hide elements in the district layout visualization + layout_options: dict = {} + layout_options__show_building_labels: bool = True # Whether to show the labels for the buildings and the Energy Hub in the district layout graph, bool + layout_options__show_pipe_labels: bool = True # Whether to show labels for the pipes in the district layout graph, bool + + # --- Fonts Dictionary --- + fonts: dict = {} + fonts__regular: str = 'Helvetica' + fonts__bold: str = 'Helvetica-Bold' + + #Sizes + fonts__sizes__title: int = 20 + fonts__sizes__section_title: int = 16 + fonts__sizes__subsection_title: int = 14 + fonts__sizes__highlighted: int = 12 + fonts__sizes__body: int = 12 + fonts__sizes__small: int = 10 + fonts__sizes__table: float = 11.5 + fonts__sizes__axis_values: int = 8 + fonts__sizes__dense: int = 7 + fonts__sizes__page_number: int = 9 + + def parse_none_string(cls, v): + """Convert string 'None' to Python None""" + if v == "None" or v == "null" or v == "": + return None + return v + + @model_validator(mode='after') + def process_config(self) -> 'ReportConfig': + """Translate HEX to RGB and build all nested dictionaries.""" + + field_names = list(self.__dict__.keys()) + + # 1. Translate Colors first + for field_name in field_names: + if field_name.startswith('colors__'): + val = getattr(self, field_name) + + # Translate color inputs to RGB tuples in the range 0-1 + # Case A: It's a string + if isinstance(val, str): + val = val.strip() + + # HEX Code + if val.startswith('#'): + hex_code = val.lstrip('#') + if len(hex_code) == 6: + rgb_tuple = tuple(int(hex_code[i:i+2], 16) / 255.0 for i in (0, 2, 4)) + setattr(self, field_name, rgb_tuple) + else: + raise ValueError(f"Invalid HEX code '{val}' for field {field_name}") + + # stringified Tuple like "(54, 132, 39)" or "54, 132, 39" + else: + clean_val = val.replace('(', '').replace(')', '').replace('[', '').replace(']', '') + parts = [float(p.strip()) for p in clean_val.split(',')] + if len(parts) == 3: + if any(p < 0 or p > 255 for p in parts): + raise ValueError(f"RGB values must be between 0 and 255 for field {field_name}. Got: {val}") + if any(p > 1.0 for p in parts): + rgb_tuple = tuple(p / 255.0 for p in parts) + else: + rgb_tuple = tuple(parts) + setattr(self, field_name, rgb_tuple) + else: + raise ValueError(f"RGB input must have exactly 3 values. Got: {val}") + + # Case B: It's already a Tuple/List + elif isinstance(val, (tuple, list)): + if len(val) == 3: + if any(p > 1.0 for p in val): + if any(p < 0 or p > 255 for p in val): + raise ValueError(f"RGB values must be between 0 and 255 for field {field_name}. Got: {val}") + rgb_tuple = tuple(float(p) / 255.0 for p in val) + setattr(self, field_name, rgb_tuple) + else: + setattr(self, field_name, tuple(float(p) for p in val)) + else: + raise ValueError(f"RGB tuple must have exactly 3 values. Got: {val}") + + # 2. Build nested dictionaries (Supports both colors and fonts) + for field_name in field_names: + # Skip if we already deleted this attribute in a previous iteration + if not hasattr(self, field_name): + continue + + if isinstance(getattr(self, field_name), dict): + if getattr(self, field_name) == {}: + target_dict = {} + prefix = f"{field_name}__" + + for attr_name in field_names: + if attr_name.startswith(prefix) and hasattr(self, attr_name): + key_path = attr_name[len(prefix):] + keys = key_path.split('__') + + current_dict = target_dict + for i, key in enumerate(keys): + if i == len(keys) - 1: + current_dict[key] = getattr(self, attr_name) + else: + if key not in current_dict: + current_dict[key] = {} + current_dict = current_dict[key] + + setattr(self, field_name, target_dict) + + for attr_name in field_names: + if attr_name.startswith(prefix) and hasattr(self, attr_name): + delattr(self, attr_name) + + return self + + model_config = SettingsConfigDict( + extra='ignore' # Ignores all other variables in the .env.CONFIG file + ) + class DecentralDeviceConfig(BaseSettings): """Configuration for decentralized devices in a district energy system. @@ -1229,6 +1403,8 @@ class GlobalConfig(BaseModel): Configuration parameters for calendar settings, such as holidays and initial days. scenario_name : ScenarioName The name of the scenario being configured, used for identification and output purposes. + report : ReportConfig + Configuration parameters for reporting and output generation, including formats and paths. """ location: 'LocationConfig' @@ -1243,7 +1419,7 @@ class GlobalConfig(BaseModel): central: 'CentralDeviceConfig' calendar: 'CalendarConfig' scenario_name: ScenarioName - + report: 'ReportConfig' class Settings(BaseSettings): """ Settings class to manage global configuration parameters. @@ -1308,5 +1484,6 @@ def load_global_config(env_file: Optional[str] = None) -> GlobalConfig: decentral=DecentralDeviceConfig(_env_file=env_file_path), central=CentralDeviceConfig(_env_file=env_file_path), calendar=CalendarConfig(_env_file=env_file_path), - scenario_name = ScenarioName(_env_file=env_file_path) + scenario_name = ScenarioName(_env_file=env_file_path), + report=ReportConfig(_env_file=env_file_path) ) diff --git a/districtgenerator/functions/opti_central.py b/districtgenerator/functions/opti_central.py index 5881707..792d41c 100644 --- a/districtgenerator/functions/opti_central.py +++ b/districtgenerator/functions/opti_central.py @@ -458,7 +458,7 @@ def build_model(model, data, year, cluster, sim_ecoData): model.power_waste_import = pyo.Var(model.t, within=pyo.NonNegativeReals) model.power_district_heating_import = pyo.Var(model.t, within=pyo.NonNegativeReals) - # total energy amounts taken from grid + # total energy amounts used model.from_grid_total_el = pyo.Var(within=pyo.NonNegativeReals) model.to_grid_total_el = pyo.Var(within=pyo.NonNegativeReals) model.to_grid_total_el_buildings = pyo.Var(within=pyo.NonNegativeReals) @@ -1616,6 +1616,10 @@ def write_solution_file(model, filename): results_dict["total_oil_used"] = pyo.value(model.total_oil_used) results_dict["total_waste_used"] = pyo.value(model.total_waste_used) results_dict["total_district_heat_used"] = pyo.value(model.total_district_heat_used) + results_dict["from_grid_total_el_buildings"] = pyo.value(model.from_grid_total_el_buildings) + results_dict["to_grid_total_el_buildings"] = pyo.value(model.to_grid_total_el_buildings) + results_dict["from_grid_total_el_eh"] = pyo.value(model.from_grid_total_el_eh) + results_dict["to_grid_total_el_eh"] = pyo.value(model.to_grid_total_el_eh) # energy imports and exports per time step in W