"""
Results Tab Module
==================
:author: Dipl.-Ing. (FH) Jonas Pfeiffer
Displaying results of energy system calculations with diagrams and tables, including stack plots, pie charts, and result tables.
"""
import sys
import numpy as np
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtWidgets import (
QApplication,
QCheckBox,
QHBoxLayout,
QHeaderView,
QLabel,
QScrollArea,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from districtheatingsim.gui.EnergySystemTab._10_utilities import CheckableComboBox, CollapsibleHeader
# Month labels and starts for a full year (8760 h)
_MONTH_NAMES = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
_MONTH_STARTS = [0, 744, 1416, 2160, 2880, 3624, 4344, 5088, 5832, 6552, 7296, 8016]
def _apply_month_xticks(ax, n_steps: int):
"""Replace integer hour labels with abbreviated month names when n_steps ≥ 8000."""
if n_steps >= 8000:
valid = [(s, m) for s, m in zip(_MONTH_STARTS, _MONTH_NAMES, strict=False) if s < n_steps]
ax.set_xticks([s for s, _ in valid])
ax.set_xticklabels([m for _, m in valid], fontsize=9)
else:
step = max(1, n_steps // 10)
ax.set_xticks(np.arange(0, n_steps + step, step))
def _plot_storage_panels(
fig,
hours,
net_flow,
soc,
T_top,
T_middle,
T_bottom,
Q_loss,
title: str,
n_steps: int,
min_fill: float | None = None,
max_fill: float | None = None,
gen_profile: np.ndarray | None = None,
load_profile: np.ndarray | None = None,
):
"""
Draw the 4-panel storage overview into *fig*.
Panel 1 – load vs generator output + net buffer flow (optional, when gen/load given)
Panel 2 – SOC [%] with optional min/max fill lines
Panel 3 – T_top / T_middle / T_bottom
Panel 4 – Heat loss [kW]
"""
fig.clear()
ax1, ax2, ax3, ax4 = fig.subplots(4, 1, sharex=True)
# Panel 1 – net storage flow (+ optional load/generator overlay)
if gen_profile is not None and load_profile is not None:
ax1.fill_between(hours, load_profile, alpha=0.2, color="gray", label="Wärmebedarf (kW)")
ax1.plot(hours, gen_profile, color="orange", linewidth=0.7, label="Erzeuger (kW)")
ax1.fill_between(hours, net_flow, where=(net_flow > 0), color="steelblue", alpha=0.7, label="Beladung (kW)")
ax1.fill_between(hours, net_flow, where=(net_flow < 0), color="tomato", alpha=0.7, label="Entladung (kW)")
ax1.axhline(0, color="black", linewidth=0.5)
ax1.set_ylabel("Speicherfluss (kW)")
ax1.legend(fontsize=7, loc="upper right")
ax1.grid(True, alpha=0.3)
# Panel 2 – SOC
ax2.fill_between(hours, soc, alpha=0.35, color="steelblue")
ax2.plot(hours, soc, color="steelblue", linewidth=0.8, label="SOC (%)")
if min_fill is not None:
ax2.axhline(
min_fill * 100, color="red", linewidth=0.9, linestyle="--", label=f"min_fill {min_fill * 100:.0f} %"
)
if max_fill is not None:
ax2.axhline(
max_fill * 100, color="green", linewidth=0.9, linestyle="--", label=f"max_fill {max_fill * 100:.0f} %"
)
ax2.set_ylabel("SOC (%)")
ax2.set_ylim(0, 100)
ax2.legend(fontsize=7, loc="upper right")
ax2.grid(True, alpha=0.3)
# Panel 3 – temperatures
ax3.plot(hours, T_top, color="red", linewidth=0.8, label="T oben (°C)")
ax3.plot(hours, T_middle, color="orange", linewidth=0.8, label="T mitte (°C)")
ax3.plot(hours, T_bottom, color="royalblue", linewidth=0.8, label="T unten (°C)")
ax3.set_ylabel("Temperatur (°C)")
ax3.legend(fontsize=7, loc="upper right")
ax3.grid(True, alpha=0.3)
# Panel 4 – heat loss
ax4.fill_between(hours, Q_loss, alpha=0.5, color="orange")
ax4.plot(hours, Q_loss, color="darkorange", linewidth=0.7, label="Wärmeverluste (kW)")
ax4.set_ylabel("Verluste (kW)")
ax4.legend(fontsize=7, loc="upper right")
ax4.grid(True, alpha=0.3)
_apply_month_xticks(ax4, n_steps)
fig.suptitle(title, fontsize=10)
fig.tight_layout()
[docs]
class ResultsTab(QWidget):
"""
A QWidget subclass representing the ResultsTab.
Attributes:
data_added (pyqtSignal): A signal that emits data as an object.
data_manager (DataManager): An instance of the DataManager class for managing data.
parent (QWidget): The parent widget.
results (dict): A dictionary to store results.
selected_variables (list): A list of selected variables for plotting.
"""
data_added = pyqtSignal(object) # Signal, das Daten als Objekt überträgt
[docs]
def __init__(self, data_manager, parent=None):
"""
Initializes the ResultsTab.
:param data_manager: The data manager
:type data_manager: DataManager
:param parent: The parent widget
:type parent: QWidget or None
"""
super().__init__(parent)
self.data_manager = data_manager
self.parent = parent
self.results = {}
self.selected_variables = []
self.energy_system = None
# Dynamic buffer storage section widgets (rebuilt on each updateResults call)
self._buffer_section_widgets: list[QWidget] = []
self.data_manager.project_folder_changed.connect(self.updateDefaultPath)
self.updateDefaultPath(self.data_manager.variant_folder)
self.initUI()
[docs]
def updateDefaultPath(self, new_base_path):
"""
Updates the default base path.
:param new_base_path: The new base path
:type new_base_path: str
"""
self.base_path = new_base_path
[docs]
def initUI(self):
"""
Initializes the UI components of the ResultsTab.
"""
self.mainLayout = QVBoxLayout(self)
self.scrollArea = QScrollArea()
self.scrollArea.setWidgetResizable(True)
self.scrollWidget = QWidget()
self.scrollLayout = QVBoxLayout(self.scrollWidget)
self.setupDiagrams()
self.setupCollapsibleResultsSections()
self.scrollArea.setWidget(self.scrollWidget)
self.mainLayout.addWidget(self.scrollArea)
self.setLayout(self.mainLayout)
[docs]
def setupDiagrams(self):
"""
Sets up the collapsible diagrams for the ResultsTab.
"""
# Layout for variable selection (ComboBox and Checkbox)
self.variableSelectionLayout = QHBoxLayout()
self.variableComboBox = CheckableComboBox()
self.variableComboBox.view().pressed.connect(self.updateSelectedVariables)
self.secondYAxisCheckBox = QCheckBox("Second y-Axis")
self.secondYAxisCheckBox.stateChanged.connect(self.updateSelectedVariables)
self.variableSelectionLayout.addWidget(self.variableComboBox)
self.variableSelectionLayout.addWidget(self.secondYAxisCheckBox)
# First Diagram (Stackplot and Line Plot)
self.stackPlotFigure = Figure(figsize=(8, 6))
self.stackPlotCanvas = FigureCanvas(self.stackPlotFigure)
self.stackPlotCanvas.setMinimumSize(500, 500)
self.toolbar1 = NavigationToolbar(self.stackPlotCanvas, self)
self.diagram1_widget = QWidget()
diagram1_layout = QVBoxLayout(self.diagram1_widget)
diagram1_layout.addLayout(self.variableSelectionLayout)
diagram1_layout.addWidget(self.stackPlotCanvas)
diagram1_layout.addWidget(self.toolbar1)
self.diagram1_section = CollapsibleHeader("Jahresganglinie Diagramm", self.diagram1_widget)
self.scrollLayout.addWidget(self.diagram1_section)
# Second Diagram (Pie Chart)
self.pieChartFigure = Figure(figsize=(6, 6))
self.pieChartCanvas = FigureCanvas(self.pieChartFigure)
self.pieChartCanvas.setMinimumSize(500, 500)
self.pieCharttoolbar = NavigationToolbar(self.pieChartCanvas, self)
self.diagram2_widget = QWidget()
diagram2_layout = QVBoxLayout(self.diagram2_widget)
diagram2_layout.addWidget(self.pieChartCanvas)
diagram2_layout.addWidget(self.pieCharttoolbar)
self.diagram2_section = CollapsibleHeader("Anteile Wärmeerzeugung Diagramm", self.diagram2_widget)
self.scrollLayout.addWidget(self.diagram2_section)
# Third Diagram – network storage (shown only when a ThermalStorageAdapter is present)
self.storageFigure = Figure(figsize=(10, 8))
self.storageCanvas = FigureCanvas(self.storageFigure)
self.storageCanvas.setMinimumSize(500, 600)
self.storageToolbar = NavigationToolbar(self.storageCanvas, self)
self.diagram3_widget = QWidget()
diagram3_layout = QVBoxLayout(self.diagram3_widget)
diagram3_layout.addWidget(self.storageCanvas)
diagram3_layout.addWidget(self.storageToolbar)
self.diagram3_section = CollapsibleHeader("Thermischer Netzspeicher – Betrieb", self.diagram3_widget)
self.diagram3_section.setVisible(False)
self.scrollLayout.addWidget(self.diagram3_section)
# Placeholder widget that holds all dynamic buffer-storage sections.
# It sits between the network storage section and the results tables so
# that newly discovered buffer storages are always inserted in the right place.
self._buffer_container = QWidget()
self._buffer_container_layout = QVBoxLayout(self._buffer_container)
self._buffer_container_layout.setContentsMargins(0, 0, 0, 0)
self._buffer_container_layout.setSpacing(4)
self.scrollLayout.addWidget(self._buffer_container)
[docs]
def setupCollapsibleResultsSections(self):
"""
Sets up the collapsible sections for displaying results tables.
"""
# First Table (Results Table)
self.setupResultsTable()
self.table1_widget = QWidget()
table1_layout = QVBoxLayout(self.table1_widget)
table1_layout.addWidget(self.resultsTable)
self.table1_section = CollapsibleHeader("Ergebnisse Erzeugung", self.table1_widget)
self.scrollLayout.addWidget(self.table1_section)
# Second Table (Additional Results Table)
self.setupAdditionalResultsTable()
self.table2_widget = QWidget()
table2_layout = QVBoxLayout(self.table2_widget)
table2_layout.addWidget(self.additionalResultsTable)
self.table2_section = CollapsibleHeader("Ergebnisse Wirtschaftlichkeit", self.table2_widget)
self.scrollLayout.addWidget(self.table2_section)
[docs]
def addLabel(self, text):
"""
Adds a label to the layout.
:param text: The text for the label
:type text: str
"""
label = QLabel(text)
self.scrollLayout.addWidget(label)
[docs]
def setupResultsTable(self):
"""
Sets up the results table with additional columns for operational hours and starts.
"""
self.resultsTable = QTableWidget()
self.resultsTable.setColumnCount(9)
self.resultsTable.setHorizontalHeaderLabels(
[
"Technologie",
"Wärmemenge (MWh)",
"Anzahl Betriebsstunden",
"Anzahl Starts",
"Betriebsstunden/Start",
"Kosten (€/MWh)",
"Anteil (%)",
"CO2-eq (t_CO2/MWh_th)",
"Primärenergiefaktor",
]
)
self.resultsTable.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
[docs]
def setupAdditionalResultsTable(self):
"""
Sets up the additional results table.
"""
self.additionalResultsTable = QTableWidget()
self.additionalResultsTable.setColumnCount(3)
self.additionalResultsTable.setHorizontalHeaderLabels(["Ergebnis", "Wert", "Einheit"])
self.additionalResultsTable.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
[docs]
def adjustTableSize(self, table):
"""
Adjusts the size of the table to fit its contents.
:param table: The table to adjust
:type table: QTableWidget
"""
header_height = table.horizontalHeader().height()
rows_height = sum([table.rowHeight(i) for i in range(table.rowCount())])
table.setFixedHeight(header_height + rows_height)
[docs]
def updateResults(self, energy_system):
"""
Updates the results in the ResultsTab.
:param energy_system: The energy system instance containing results
:type energy_system: EnergySystem
"""
self.energy_system = energy_system
self.showResultsInTable()
self.showAdditionalResultsTable()
self.plotResults()
self.updatePieChart()
self.plotStorage()
self.plotBufferStorages()
[docs]
def showResultsInTable(self):
"""
Displays the results in the results table, including calculated operational metrics.
Resets the table rows before populating to avoid leftover rows from previous calculations.
"""
results = self.energy_system.results
self.resultsTable.setRowCount(0)
self.resultsTable.setRowCount(len(results["techs"]))
for i, (tech, wärmemenge, wgk, anteil, spec_emission, primary_energy, wärmeleistung) in enumerate(
zip(
results["techs"],
results["Wärmemengen"],
results["WGK"],
results["Anteile"],
results["specific_emissions_L"],
results["primärenergie_L"],
results["Wärmeleistung_L"],
strict=False,
)
):
if not isinstance(wärmeleistung, (list, np.ndarray)):
wärmeleistung = [wärmeleistung]
wärmeleistung = np.array(wärmeleistung)
betriebsstunden = np.count_nonzero(wärmeleistung)
starts = np.sum((wärmeleistung[:-1] == 0) & (wärmeleistung[1:] > 0))
betriebsstunden_pro_start = betriebsstunden / starts if starts > 0 else 0
self.resultsTable.setItem(i, 0, QTableWidgetItem(tech))
self.resultsTable.setItem(i, 1, QTableWidgetItem(f"{np.sum(wärmemenge):.2f}"))
self.resultsTable.setItem(i, 2, QTableWidgetItem(f"{betriebsstunden}"))
self.resultsTable.setItem(i, 3, QTableWidgetItem(f"{starts}"))
self.resultsTable.setItem(i, 4, QTableWidgetItem(f"{betriebsstunden_pro_start:.2f}"))
self.resultsTable.setItem(i, 5, QTableWidgetItem(f"{wgk:.2f}"))
self.resultsTable.setItem(i, 6, QTableWidgetItem(f"{anteil * 100:.2f}"))
self.resultsTable.setItem(i, 7, QTableWidgetItem(f"{spec_emission:.4f}"))
self.resultsTable.setItem(i, 8, QTableWidgetItem(f"{primary_energy / np.sum(wärmemenge):.4f}"))
self.resultsTable.resizeColumnsToContents()
self.adjustTableSize(self.resultsTable)
[docs]
def showAdditionalResultsTable(self):
"""
Displays the additional results in the additional results table.
"""
self.waerme_ges_kW, self.strom_wp_kW = (
np.sum(self.energy_system.results["waerme_ges_kW"]),
np.sum(self.energy_system.results["strom_wp_kW"]),
)
if "Summe Infrastruktur" in self.parent.costTab.data.index:
self.WGK_Infra = (
self.parent.costTab.data.at["Summe Infrastruktur", "Annuität"]
/ self.energy_system.results["Jahreswärmebedarf"]
)
if self.energy_system.economic_parameters["subsidy_eligibility"] == "Ja":
self.WGK_Infra = (
self.parent.costTab.data.at["Summe Infrastruktur", "Annuität"] * 0.6
) / self.energy_system.results["Jahreswärmebedarf"]
else:
self.WGK_Infra = 0
self.wgk_heat_pump_electricity = (
(self.strom_wp_kW / 1000) * self.parent.economic_parameters["electricity_price"]
) / ((self.strom_wp_kW + self.waerme_ges_kW) / 1000)
self.WGK_Gesamt = self.energy_system.results["WGK_Gesamt"] + self.WGK_Infra + self.wgk_heat_pump_electricity
data = [
("Jahreswärmebedarf", round(self.energy_system.results["Jahreswärmebedarf"], 1), "MWh"),
("Stromerzeugung", round(self.energy_system.results["Strommenge"], 2), "MWh"),
("Strombedarf", round(self.energy_system.results["Strombedarf"], 2), "MWh"),
("Wärmegestehungskosten Erzeugeranlagen", round(self.energy_system.results["WGK_Gesamt"], 2), "€/MWh"),
("Wärmegestehungskosten Netzinfrastruktur", round(self.WGK_Infra, 2), "€/MWh"),
("Wärmegestehungskosten dezentrale Wärmepumpen", round(self.wgk_heat_pump_electricity, 2), "€/MWh"),
("Wärmegestehungskosten Gesamt", round(self.WGK_Gesamt, 2), "€/MWh"),
(
"spez. CO2-Emissionen Wärme",
round(self.energy_system.results["specific_emissions_Gesamt"], 4),
"t_CO2/MWh_th",
),
(
"CO2-Emissionen Wärme",
round(
self.energy_system.results["specific_emissions_Gesamt"]
* self.energy_system.results["Jahreswärmebedarf"],
2,
),
"t_CO2",
),
("Primärenergiefaktor", round(self.energy_system.results["primärenergiefaktor_Gesamt"], 4), "-"),
]
self.additionalResultsTable.setRowCount(len(data))
for i, (description, value, unit) in enumerate(data):
self.additionalResultsTable.setItem(i, 0, QTableWidgetItem(description))
self.additionalResultsTable.setItem(i, 1, QTableWidgetItem(str(value)))
self.additionalResultsTable.setItem(i, 2, QTableWidgetItem(unit))
self.additionalResultsTable.resizeColumnsToContents()
self.adjustTableSize(self.additionalResultsTable)
[docs]
def plotResults(self):
"""
Plots the results in the diagrams.
"""
extracted_data, initial_vars = self.energy_system.getInitialPlotData()
model = self.variableComboBox.model()
combo_items = [model.item(i).text() for i in range(model.rowCount())]
if set(extracted_data.keys()) != set(combo_items):
self.variableComboBox.clear()
self.variableComboBox.addItems(extracted_data.keys())
self.variableComboBox.addItem("Last_L")
for var in initial_vars:
self.variableComboBox.setItemChecked(var, True)
self.selected_variables = self.variableComboBox.checkedItems()
self.stackPlotFigure.clear()
self.energy_system.plot_stack_plot(
figure=self.stackPlotFigure,
selected_vars=self.selected_variables,
second_y_axis=self.secondYAxisCheckBox.isChecked(),
)
self.stackPlotCanvas.draw()
[docs]
def updateSelectedVariables(self):
"""
Updates the selected variables and re-plots the diagram.
"""
self.selected_variables = self.variableComboBox.checkedItems()
self.stackPlotFigure.clear()
self.energy_system.plot_stack_plot(
figure=self.stackPlotFigure,
selected_vars=self.selected_variables,
second_y_axis=self.secondYAxisCheckBox.isChecked(),
)
self.stackPlotCanvas.draw()
[docs]
def plotStorage(self):
"""
Draws the 4-panel network storage overview plot.
Hidden when no ThermalStorageAdapter is attached to the energy system.
"""
storage = getattr(self.energy_system, "storage", None)
if storage is None:
self.diagram3_section.setVisible(False)
return
self.diagram3_section.setVisible(True)
n_steps = len(storage._soc)
hours = np.arange(n_steps)
net = storage._Q_net_storage_flow
_plot_storage_panels(
fig=self.storageFigure,
hours=hours,
net_flow=net,
soc=storage._soc * 100,
T_top=storage._T_supply,
T_middle=storage._T_middle,
T_bottom=storage._T_return,
Q_loss=storage.Q_loss,
title=f"Thermischer Netzspeicher – {storage.name}",
n_steps=n_steps,
)
self.storageCanvas.draw()
[docs]
def plotBufferStorages(self):
"""
Dynamically create/update one 4-panel collapsible section per generator
that has an active buffer storage (CHP, BiomassBoiler with speicher_aktiv=True).
Old sections are destroyed and rebuilt on each call.
"""
# Remove all previously created buffer sections from the container
for w in self._buffer_section_widgets:
self._buffer_container_layout.removeWidget(w)
w.setParent(None)
w.deleteLater()
self._buffer_section_widgets.clear()
techs_with_buffer = [
tech for tech in self.energy_system.technologies if getattr(tech, "buffer", None) is not None
]
for tech in techs_with_buffer:
buf = tech.buffer
# Guard: history must cover the full simulation (may be empty on first run)
if not buf.soc_history:
continue
n_steps = len(buf.soc_history)
hours = np.arange(n_steps)
soc = np.array(buf.soc_history) * 100.0
T_top = np.array(buf.T_top_history)
T_mid = np.array(buf.T_middle_history)
T_bot = np.array(buf.T_bottom_history)
Q_loss = np.array(buf.Q_loss_history)
Q_net = np.array(buf.Q_net_history) # + = charge, − = discharge
# Generator output profile (from results) + load profile
gen_profile = None
load_profile = None
results = self.energy_system.results
if tech.name in results.get("techs", []):
idx = list(results["techs"]).index(tech.name)
gen_arr = results["Wärmeleistung_L"][idx]
if len(gen_arr) == n_steps:
gen_profile = gen_arr
load_arr = self.energy_system.load_profile
if len(load_arr) == n_steps:
load_profile = load_arr
# Build the figure
fig = Figure(figsize=(10, 8))
canvas = FigureCanvas(fig)
canvas.setMinimumSize(500, 600)
toolbar = NavigationToolbar(canvas, self)
_plot_storage_panels(
fig=fig,
hours=hours,
net_flow=Q_net,
soc=soc,
T_top=T_top,
T_middle=T_mid,
T_bottom=T_bot,
Q_loss=Q_loss,
title=f"Anlagenspezifischer Pufferspeicher – {tech.name} "
f"(V = {buf.volume:.0f} m³, Kapazität ≈ {buf.get_capacity_kwh():.0f} kWh)",
n_steps=n_steps,
min_fill=getattr(tech, "min_fill", None),
max_fill=getattr(tech, "max_fill", None),
gen_profile=gen_profile,
load_profile=load_profile,
)
canvas.draw()
# Wrap in a collapsible section
inner = QWidget()
inner_layout = QVBoxLayout(inner)
inner_layout.addWidget(canvas)
inner_layout.addWidget(toolbar)
section = CollapsibleHeader(f"Anlagenspezifischer Pufferspeicher – {tech.name}", inner)
self._buffer_container_layout.addWidget(section)
self._buffer_section_widgets.append(section)
[docs]
def updatePieChart(self):
"""
Updates the pie chart with results from the EnergySystem.
"""
self.pieChartFigure.clear()
self.energy_system.plot_pie_chart(self.pieChartFigure)
self.pieChartCanvas.draw()
if __name__ == "__main__":
app = QApplication(sys.argv)
data_manager = None
main = ResultsTab(data_manager)
main.show()
sys.exit(app.exec())