"""
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 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
"""
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 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()