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