"""
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.figure import Figure
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QScrollArea, QTableWidget, QTableWidgetItem, QHeaderView, QSizePolicy, QHBoxLayout, QPushButton, QLineEdit, QInputDialog, QMenu)
from PyQt6.QtCore import pyqtSignal, Qt
from PyQt6.QtGui import QFont
from districtheatingsim.heat_generators.annuity import annuity
from districtheatingsim.gui.EnergySystemTab._10_utilities import CollapsibleHeader
from districtheatingsim.gui.EnergySystemTab._02_energy_system_dialogs import KostenBerechnungDialog
[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 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 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
"""
if TN == 0: # Avoid division by zero
return 0.0
q = float(self.parent.economic_parameters["capital_interest_rate"])
r = float(self.parent.economic_parameters["inflation_rate"])
t = int(self.parent.economic_parameters["time_period"])
stundensatz = self.parent.economic_parameters["hourly_rate"]
return annuity(A0, TN, f_Inst, f_W_Insp, Bedienaufwand, interest_rate_factor=q, inflation_rate_factor=r, consideration_time_period_years=t, hourly_rate=stundensatz)
[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:
print("Fehler: Ungültiger Zeilen- oder Spaltenindex.")
[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 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)):
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)]
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):
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()