Source code for districtheatingsim.gui.EnergySystemTab._05_cost_tab

"""
Cost Tab Module
===============

:author: Dipl.-Ing. (FH) Jonas Pfeiffer

Displaying and managing cost-related data for heat generation project components.
"""

import pandas as pd
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
    QHBoxLayout,
    QHeaderView,
    QInputDialog,
    QLabel,
    QLineEdit,
    QMenu,
    QPushButton,
    QScrollArea,
    QSizePolicy,
    QTableWidget,
    QTableWidgetItem,
    QVBoxLayout,
    QWidget,
)

from districtheatingsim.gui.EnergySystemTab._02_energy_system_dialogs import KostenBerechnungDialog
from districtheatingsim.gui.EnergySystemTab._10_utilities import CollapsibleHeader
from districtheatingsim.heat_generators.annuity import infrastructure_annuity


[docs] class CostTab(QWidget): """ Tab for displaying and managing cost-related data for heat generation project components. :signal data_added: Signal emitted when new data is added. """ data_added = pyqtSignal(object) # Signal that transfers data as an object
[docs] def __init__(self, folder_manager, config_manager, parent=None): """ Initializes the CostTab instance. :param folder_manager: Reference to the folder manager instance :type folder_manager: object :param config_manager: Reference to the config manager instance :type config_manager: object :param parent: Reference to the parent widget :type parent: QWidget or None """ super().__init__(parent) self.folder_manager = folder_manager self.config_manager = config_manager self.parent = parent self.results = {} self.tech_objects = [] self.individual_costs = [] self.summe_tech_kosten = 0 # Initialize the variable # Connect to the data manager signal self.folder_manager.project_folder_changed.connect(self.updateDefaultPath) self.updateDefaultPath(self.folder_manager.variant_folder) self.data = self.initData() # Initialize the DataFrame self.initUI()
[docs] def updateDefaultPath(self, new_base_path): """ Updates the default path for the project. :param new_base_path: The new base path for the project :type new_base_path: str """ self.base_path = new_base_path
[docs] def initData(self): """ Initializes the data as a pandas DataFrame with an index name and calculates the Annuität. :return: DataFrame with infrastructure costs and calculated annuity values :rtype: pd.DataFrame """ # Create the initial DataFrame data = pd.DataFrame( { "Kosten": [2000000, 100000, 20000, 40000, 15000, 500000], "T_N": [40, 20, 20, 40, 15, 20], "F_inst": [1, 1, 1, 1, 1, 0], "F_w_insp": [0, 1, 1, 0, 1, 0], "Bedienaufwand": [5, 2, 2, 0, 5, 0], }, index=[ "Wärmenetz", "Hausanschlussstationen", "Druckhaltung", "Hydraulik", "Elektroinstallation", "Planungskosten", ], ) data.index.name = "Komponente" # Calculate Annuität for each row data["Annuität"] = data.apply( lambda row: self.calc_annuität( row["Kosten"], row["T_N"], row["F_inst"], row["F_w_insp"], row["Bedienaufwand"] ), axis=1, ) # Create the summary row as a DataFrame total_row = pd.DataFrame( { "Kosten": [data["Kosten"].sum()], "T_N": [""], # No meaningful value for T_N in the summary row "F_inst": [""], # No meaningful value for F_inst in the summary row "F_w_insp": [""], # No meaningful value for F_w_insp in the summary row "Bedienaufwand": [""], # No meaningful value for Bedienaufwand in the summary row "Annuität": [data["Annuität"].sum()], }, index=["Summe Infrastruktur"], ) # Concatenate the summary row with the original data data = pd.concat([data, total_row]) return data
[docs] def initUI(self): """ Initializes the user interface components for the CostTab. """ # Create the main scroll area with full expansion self.createMainScrollArea() # Infrastructure Costs Section self.setupInfrastructureCostsTable() # Technology Costs Section self.tech_widget = QWidget() tech_layout = QVBoxLayout(self.tech_widget) self.setupTechDataTable() tech_layout.addWidget(self.techDataTable) self.tech_section = CollapsibleHeader("Kosten Erzeuger", self.tech_widget) # Cost Composition Section self.cost_composition_widget = QWidget() cost_composition_layout = QVBoxLayout(self.cost_composition_widget) self.setupCostCompositionChart() cost_composition_layout.addWidget(self.bar_chart_canvas) # Add bar chart cost_composition_layout.addWidget(self.pie_chart_canvas) # Add pie chart self.cost_composition_section = CollapsibleHeader("Kostenzusammensetzung", self.cost_composition_widget) # Add all sections to the main layout with stretch factors self.mainLayout.addWidget(self.infrastructure_section, 2) self.mainLayout.addWidget(self.tech_section, 2) self.mainLayout.addWidget(self.cost_composition_section, 2) # Initialize and style total cost label as QLabel self.totalCostLabel = QLabel("Gesamtkosten: 0 €") self.totalCostLabel.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px;") self.mainLayout.addWidget(self.totalCostLabel, alignment=Qt.AlignmentFlag.AlignLeft) # Set the main layout to use all available space in the scroll area self.mainScrollArea.setWidget(self.mainWidget) self.mainLayout.setContentsMargins(10, 10, 10, 10) # Adjust margins if needed # Set the layout for the entire CostTab widget self.setLayout(self.createMainLayout())
[docs] def createMainScrollArea(self): """ Creates the main scroll area for the tab and sets it to take full width and height. """ self.mainScrollArea = QScrollArea(self) self.mainScrollArea.setWidgetResizable(True) self.mainWidget = QWidget() self.mainLayout = QVBoxLayout(self.mainWidget) self.mainScrollArea.setWidget(self.mainWidget) self.mainScrollArea.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
[docs] def createMainLayout(self): """ Creates the main layout for the tab with adjusted spacing and margins for better alignment. :return: The main layout for the tab :rtype: QVBoxLayout """ layout = QVBoxLayout(self) layout.setContentsMargins(10, 10, 10, 10) # Set uniform margins for the tab layout.setSpacing(10) # Adjust spacing between sections layout.addWidget(self.mainScrollArea) return layout
[docs] def addLabel(self, text): """ Adds a label to the main layout. :param text: The text for the label :type text: str """ label = QLabel(text) self.mainLayout.addWidget(label)
### Infrastructure Tables ###
[docs] def setupInfrastructureCostsTable(self): """ Sets up the infrastructure costs table. """ self.infrastructure_widget = QWidget() self.infrastructure_section = CollapsibleHeader("Kosten Wärmenetzinfrastruktur", self.infrastructure_widget) self.infrastructure_layout = QVBoxLayout(self.infrastructure_widget) self.infrastructureCostsTable = QTableWidget() self.infrastructureCostsTable.setColumnCount(len(self.data.columns)) self.infrastructureCostsTable.setRowCount(len(self.data)) self.infrastructureCostsTable.setHorizontalHeaderLabels(self.data.columns.tolist()) self.infrastructureCostsTable.setVerticalHeaderLabels(self.data.index.tolist()) self.infrastructureCostsTable.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.infrastructureCostsTable.setAlternatingRowColors(True) self.updateInfrastructureTable() self.infrastructureCostsTable.itemChanged.connect(self.updateDataFromTable) self.infrastructureCostsTable.verticalHeader().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.infrastructureCostsTable.verticalHeader().customContextMenuRequested.connect(self.openHeaderContextMenu) self.infrastructure_layout.addWidget(self.infrastructureCostsTable) # Add buttons for row management buttonLayout = QHBoxLayout() self.addButton = QPushButton("Zeile hinzufügen") self.removeButton = QPushButton("Zeile entfernen") self.berechneWärmenetzKostenButton = QPushButton("Kosten Wärmenetz aus geoJSON berechnen", self) self.berechneHausanschlussKostenButton = QPushButton("Kosten Hausanschlusstationen aus geoJSON berechnen", self) self.addButton.clicked.connect(self.addRow) self.removeButton.clicked.connect(self.removeRow) self.berechneWärmenetzKostenButton.clicked.connect(self.berechneWaermenetzKosten) self.berechneHausanschlussKostenButton.clicked.connect(self.berechneHausanschlussKosten) buttonLayout.addWidget(self.addButton) buttonLayout.addWidget(self.removeButton) buttonLayout.addWidget(self.berechneWärmenetzKostenButton) buttonLayout.addWidget(self.berechneHausanschlussKostenButton) self.infrastructure_layout.addLayout(buttonLayout)
[docs] def updateInfrastructureTable(self): """ Updates the infrastructure costs table with data from the DataFrame. Ensures proper formatting and correct handling of indices. """ # Update the table dimensions self.infrastructureCostsTable.setRowCount(len(self.data)) self.infrastructureCostsTable.setColumnCount(len(self.data.columns)) self.infrastructureCostsTable.setHorizontalHeaderLabels(self.data.columns.tolist()) self.infrastructureCostsTable.setVerticalHeaderLabels(self.data.index.tolist()) # Update vertical headers # Iterate over the DataFrame rows self.infrastructureCostsTable.blockSignals(True) for row_idx, (_index, row) in enumerate(self.data.iterrows()): for col_idx, (col_name, value) in enumerate(row.items()): # Apply formatting based on column type if col_name == "Kosten" or col_name == "Annuität": formatted_value = self.format_cost(value) if value != "" else "" elif col_name == "T_N": formatted_value = f"{value} a" if value != "" else "" # Append 'a' for years elif col_name in ["F_inst", "F_w_insp"]: formatted_value = f"{value} %" if value != "" else "" # Convert to percentage and add '%' elif col_name == "Bedienaufwand": formatted_value = f"{value} h" if value != "" else "" # Append 'h' for hours else: formatted_value = str(value) # Ensure the value is set correctly in the table self.infrastructureCostsTable.setItem(row_idx, col_idx, QTableWidgetItem(formatted_value)) self.infrastructureCostsTable.blockSignals(False) # Adjust table size and column widths self.infrastructureCostsTable.resizeColumnsToContents() self.infrastructureCostsTable.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.adjustTableSize(self.infrastructureCostsTable)
[docs] def format_cost(self, value): """ Formats the cost value in European style with spaces as thousand separators. :param value: The cost value :type value: float :return: Formatted cost string :rtype: str """ return f"{value:,.0f} €".replace(",", " ")
[docs] def updateDataFromTable(self, item): """ Updates the DataFrame with values from the QTableWidget and recalculates Annuität. :param item: The changed table item :type item: QTableWidgetItem """ row = item.row() col = item.column() value = item.text() try: # Remove unit suffixes before conversion value = value.replace("€", "").replace("a", "").replace("%", "").replace("h", "").replace(" ", "").strip() # Attempt to convert the stripped value to a float or int if "." in value or "e" in value.lower(): self.data.iloc[row, col] = float(value) else: self.data.iloc[row, col] = int(value) except ValueError: self.data.iloc[row, col] = value # Handle non-numeric values gracefully # Recalculate Annuität for the updated row self.data.at[self.data.index[row], "Annuität"] = self.calc_annuität( float(self.data.at[self.data.index[row], "Kosten"]), int(self.data.at[self.data.index[row], "T_N"]), float(self.data.at[self.data.index[row], "F_inst"]), float(self.data.at[self.data.index[row], "F_w_insp"]), float(self.data.at[self.data.index[row], "Bedienaufwand"]), ) # Update the Annuität column in the table annuity_value = self.data.at[self.data.index[row], "Annuität"] self.infrastructureCostsTable.blockSignals(True) self.infrastructureCostsTable.setItem( row, self.data.columns.get_loc("Annuität"), QTableWidgetItem(self.format_cost(annuity_value)) ) self.infrastructureCostsTable.blockSignals(False) # Recalculate the summary row self.updateSummaryRow() # Refresh the table to include the updated summary row self.updateInfrastructureTable()
[docs] def addRow(self): """ Adds a new row to the table and DataFrame, with default values and calculated Annuität. The new row is added above the summary row. """ # Remove the summary row temporarily if "Summe Infrastruktur" in self.data.index: self.data = self.data.drop("Summe Infrastruktur") # Create a new row with default values new_row_name = f"Neues Objekt {len(self.data) + 1}" default_values = { "Kosten": 0, "T_N": 0, "F_inst": 0, "F_w_insp": 0, "Bedienaufwand": 0, "Annuität": self.calc_annuität(0, 0, 0, 0, 0), } new_row = pd.DataFrame(default_values, index=[new_row_name]) # Insert the new row into the DataFrame self.data = pd.concat([self.data, new_row]) # Recalculate the summary row and ensure it is the last row self.updateSummaryRow() # Refresh the table self.updateInfrastructureTable()
[docs] def removeRow(self): """ Removes the selected row from the table and DataFrame. """ current_row = self.infrastructureCostsTable.currentRow() if current_row != -1: # Remove the summary row temporarily if "Summe Infrastruktur" in self.data.index: self.data = self.data.drop("Summe Infrastruktur") # Remove the selected row self.data = self.data.drop(self.data.index[current_row]) # Recalculate the summary row self.updateSummaryRow() # Refresh the table self.updateInfrastructureTable()
[docs] def openHeaderContextMenu(self, position): """ Opens the context menu for the vertical header. :param position: The position where the context menu should be opened :type position: QPoint """ menu = QMenu() renameAction = menu.addAction("Umbenennen") action = menu.exec(self.infrastructureCostsTable.verticalHeader().mapToGlobal(position)) if action == renameAction: row = self.infrastructureCostsTable.verticalHeader().logicalIndexAt(position) if row != -1: self.renameHeader(row)
[docs] def renameHeader(self, row): """ Renames the header item at the specified row. :param row: The row index of the header item to rename :type row: int """ newName, okPressed = QInputDialog.getText(self, "Name ändern", "Neuer Name:", QLineEdit.EchoMode.Normal, "") if okPressed and newName: old_name = self.data.index[row] self.data.rename(index={old_name: newName}, inplace=True) self.infrastructureCostsTable.setVerticalHeaderLabels(self.data.index.tolist())
[docs] def calc_annuität(self, A0, TN, f_Inst, f_W_Insp, Bedienaufwand): """ Calculates the annuity for a given set of parameters. :param A0: Initial investment cost :type A0: float :param TN: Lifetime of the investment :type TN: int :param f_Inst: Installation factor :type f_Inst: float :param f_W_Insp: Maintenance and inspection factor :type f_W_Insp: float :param Bedienaufwand: Operating effort :type Bedienaufwand: float :return: The calculated annuity :rtype: float """ return infrastructure_annuity(A0, TN, f_Inst, f_W_Insp, Bedienaufwand, self.parent.economic_parameters)
[docs] def updateSummaryRow(self): """ Recalculates the summary row and appends it to the DataFrame. """ # Remove existing summary row if it exists if "Summe Infrastruktur" in self.data.index: self.data = self.data.drop("Summe Infrastruktur") # Recalculate the summary row total_row = pd.DataFrame( { "Kosten": [self.data["Kosten"].sum()], "T_N": [""], # No meaningful value for T_N in the summary row "F_inst": [""], # No meaningful value for F_inst in the summary row "F_w_insp": [""], # No meaningful value for F_w_insp in the summary row "Bedienaufwand": [""], # No meaningful value for Bedienaufwand in the summary row "Annuität": [self.data["Annuität"].sum()], }, index=["Summe Infrastruktur"], ) # Append the summary row to the DataFrame self.data = pd.concat([self.data, total_row])
[docs] def updateTableValue(self, row, column, value): """ Updates the value in the specified table cell. :param row: The row index :type row: int :param column: The column index :type column: int :param value: The value to set :type value: Any """ if ( 0 <= row < self.infrastructureCostsTable.rowCount() and 0 <= column < self.infrastructureCostsTable.columnCount() ): self.infrastructureCostsTable.setItem(row, column, QTableWidgetItem(self.format_cost(value))) else: pass
[docs] def berechneWaermenetzKosten(self): """ Opens the dialog to calculate the cost of the heating network and updates the table. """ dialog = KostenBerechnungDialog( self, label="spez. Kosten Wärmenetz pro m_Trasse (inkl. Tiefbau) in €/m", value="1000", type="flow line" ) dialog.setWindowTitle("Kosten Wärmenetz berechnen") if dialog.exec(): cost_net = dialog.total_cost self.updateTableValue(row=0, column=0, value=cost_net)
[docs] def berechneHausanschlussKosten(self): """ Opens the dialog to calculate the cost of house connection stations and updates the table. """ dialog = KostenBerechnungDialog( self, label="spez. Kosten Hausanschlussstationen pro kW max. Wärmebedarf in €/kW", value="250", type="HAST" ) dialog.setWindowTitle("Kosten Hausanschlussstationen berechnen") if dialog.exec(): cost_net = dialog.total_cost self.updateTableValue(row=1, column=0, value=cost_net)
### Setup of Calculation Result Tables ###
[docs] def setupTechDataTable(self): self.techDataTable = QTableWidget() self.techDataTable.setColumnCount(4) self.techDataTable.setHorizontalHeaderLabels(["Name", "Dimensionen", "Kosten", "Gesamtkosten"]) self.techDataTable.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.techDataTable.setAlternatingRowColors(True)
[docs] def updateTechDataTable(self, tech_objects): """ Updates the technology data table with the given technology objects. :param tech_objects: List of technology objects :type tech_objects: list """ self.individual_costs = [] # Reset individual costs self.techDataTable.setRowCount(len(tech_objects)) self.summe_tech_kosten = 0 for i, tech in enumerate(tech_objects): name, dimensions, costs, full_costs = tech.extract_tech_data() self.techDataTable.setItem(i, 0, QTableWidgetItem(name)) self.techDataTable.setItem(i, 1, QTableWidgetItem(dimensions)) self.techDataTable.setItem(i, 2, QTableWidgetItem(costs)) self.techDataTable.setItem(i, 3, QTableWidgetItem(self.format_cost(float(full_costs)))) self.summe_tech_kosten += float(full_costs) self.individual_costs.append((name, float(full_costs))) self.techDataTable.resizeColumnsToContents() self.adjustTableSize(self.techDataTable) self.addSummaryTechCosts()
[docs] def addSummaryTechCosts(self): """ Adds a summary row for the technology costs. """ summen_row_index = self.techDataTable.rowCount() self.techDataTable.insertRow(summen_row_index) boldFont = QFont() boldFont.setBold(True) summen_beschreibung_item = QTableWidgetItem("Summe Erzeugerkosten") summen_beschreibung_item.setFont(boldFont) self.techDataTable.setItem(summen_row_index, 0, summen_beschreibung_item) formatted_cost = self.format_cost(self.summe_tech_kosten) summen_kosten_item = QTableWidgetItem(formatted_cost) summen_kosten_item.setFont(boldFont) self.techDataTable.setItem(summen_row_index, 3, summen_kosten_item) self.techDataTable.resizeColumnsToContents() self.adjustTableSize(self.techDataTable)
[docs] def updateSumLabel(self): """ Updates the label displaying the total costs using the summary row from the DataFrame. """ # Extract the total costs from the summary row if "Summe Infrastruktur" in self.data.index: total_cost = self.data.at["Summe Infrastruktur", "Kosten"] else: total_cost = 0 # Fallback if the summary row is missing # Format the total cost formatted_total_cost = self.format_cost(total_cost) # Update the label self.totalCostLabel.setText(f"Gesamtkosten: {formatted_total_cost}")
### Setup of Cost Composition Chart ###
[docs] def setupCostCompositionChart(self): """ Sets up two separate figures for the bar chart and pie chart. """ # Create bar chart figure and canvas self.bar_chart_figure, self.bar_chart_canvas = self.addFigure() self.bar_chart_canvas.setMinimumHeight(400) # Create pie chart figure and canvas self.pie_chart_figure, self.pie_chart_canvas = self.addFigure() self.pie_chart_canvas.setMinimumHeight(400)
[docs] def addFigure(self): """ Creates and returns a new figure and its canvas. :return: The figure and canvas :rtype: tuple """ figure = Figure(figsize=(8, 6)) canvas = FigureCanvas(figure) return figure, canvas
[docs] def plotCostComposition(self): """ Plots the cost composition with two separate figures: a bar chart and a pie chart. Clears the diagrams before replotting. """ # Combine costs from self.data (excluding the summary row) and individual costs data_costs = [ (index, self.data.at[index, "Kosten"]) for index in self.data.index if index != "Summe Infrastruktur" # Exclude the summary row ] combined_costs = data_costs + self.individual_costs # Data for the charts labels = [cost[0] for cost in combined_costs] sizes = [cost[1] for cost in combined_costs] ### Bar Chart self.bar_chart_figure.clf() # Clear the figure before plotting ax1 = self.bar_chart_figure.add_subplot(111) # Full plot for bar chart bar_colors = ax1.barh(labels, sizes, height=0.5) # Bar chart with default colors ax1.set_title("Kostenzusammensetzung (Absolut in €)") ax1.set_xlabel("Kosten (€)") ax1.set_ylabel("Komponenten") # Display exact cost values next to each bar for i, (size, _label) in enumerate(zip(sizes, labels, strict=False)): formatted_size = self.format_cost(size) ax1.text(size, i, formatted_size, va="center") ### Pie Chart self.pie_chart_figure.clf() # Clear the figure before plotting ax2 = self.pie_chart_figure.add_subplot(111) # Full plot for pie chart # Calculate percentages for legend total = sum(sizes) percent_labels = [f"{label} ({size / total * 100:.1f}%)" for label, size in zip(labels, sizes, strict=False)] wedges, _ = ax2.pie( sizes, labels=None, # No labels on the pie itself autopct=None, # No percentage labels on the pie startangle=140, explode=[0.1 if label == "Wärmenetz" else 0 for label in labels], labeldistance=1.1, pctdistance=0.85, ) # Extract colors from pie chart for consistency pie_colors = [wedge.get_facecolor() for wedge in wedges] ax2.set_title("Kostenzusammensetzung (Relativ in %)") ax2.legend(wedges, percent_labels, loc="best", bbox_to_anchor=(1, 0.5)) # Apply consistent colors to the bar chart for bar, color in zip(bar_colors, pie_colors, strict=False): bar.set_color(color) # Draw both charts self.bar_chart_canvas.draw() self.pie_chart_canvas.draw()
[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 totalCostLabel(self): """ Returns the total cost label. :return: The total cost label :rtype: QLabel """ # Create the total cost label self.totalCostLabel = QLabel()