"""
Variant comparison module for district heating project analysis.
Provides automatic variant discovery, KPI dashboards, and comparative
visualization of economic and technical metrics.
:author: Dipl.-Ing. (FH) Jonas Pfeiffer
"""
import json
import logging
import os
import matplotlib.pyplot as plt
import seaborn as sns
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 (
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QScrollArea,
QSplitter,
QTabWidget,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from districtheatingsim.gui.EnergySystemTab.config_naming import filename_to_config_name
from districtheatingsim.gui.MainTab.project_structure import discover_variants
def _discover_variant_configs(variant_path: str) -> list:
"""
Return list of (config_name, filename) for all energy system configs in a variant.
:param variant_path: Absolute path to the variant folder.
:return: List of (config_name, filename) tuples, Standard first.
"""
ergebnisse_dir = os.path.join(variant_path, "Ergebnisse")
configs = []
if not os.path.isdir(ergebnisse_dir):
return configs
files = sorted(os.listdir(ergebnisse_dir))
if "Ergebnisse.json" in files:
configs.append(("Standard", "Ergebnisse.json"))
for f in files:
if f.startswith("Ergebnisse_") and f.endswith(".json"):
configs.append((filename_to_config_name(f), f))
return configs
[docs]
class ProjectExplorer(QWidget):
"""
Project explorer for automatic variant discovery and selection.
Scans project folder for available variants and allows multi-selection
for comparison analysis.
"""
variants_changed = pyqtSignal(list) # Signal emitted when variant selection changes
[docs]
def __init__(self, folder_manager, config_manager, parent=None):
super().__init__(parent)
self.folder_manager = folder_manager
self.config_manager = config_manager
self.selected_variants = []
self.base_path = None
self.initUI()
# Connect to folder_manager signal for project path changes
if hasattr(self.folder_manager, "project_folder_changed"):
self.folder_manager.project_folder_changed.connect(self.set_base_path)
# Initialize base_path if available
if hasattr(self.folder_manager, "project_data_path") and self.folder_manager.project_data_path:
self.set_base_path(self.folder_manager.project_data_path)
else:
self.set_base_path(
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "project_data")
)
[docs]
def initUI(self):
"""Initialize project explorer UI."""
self.layout = QVBoxLayout(self)
# Header
header_label = QLabel("Projekt-Explorer")
header_label.setFont(QFont("Arial", 13, QFont.Weight.Bold))
header_label.setStyleSheet(
"color: #fff; background: #222; padding: 8px 12px; border-radius: 6px; margin-bottom: 4px;"
)
self.layout.addWidget(header_label)
# Refresh button
self.refresh_btn = QPushButton("Projekte aktualisieren")
self.refresh_btn.clicked.connect(self.discover_projects)
self.refresh_btn.setStyleSheet("""
QPushButton {
background-color: #3498db;
color: #fff;
border-radius: 8px;
padding: 8px 16px;
font-weight: bold;
font-size: 12px;
margin-bottom: 8px;
}
QPushButton:hover {
background-color: #217dbb;
}
""")
self.layout.addWidget(self.refresh_btn)
# Project tree
self.project_tree = QTreeWidget()
self.project_tree.setHeaderLabel("Verfügbare Projekte")
self.project_tree.itemChanged.connect(self.on_selection_changed)
self.project_tree.setStyleSheet("""
QTreeWidget {
background: #181818;
color: #eee;
border-radius: 6px;
font-size: 12px;
}
QTreeWidget::item {
padding: 4px 8px;
border-radius: 4px;
}
QTreeWidget::item:selected {
background: #2d3a4a;
color: #fff;
border: 1px solid #3498db;
}
QTreeWidget::item:hover {
background: #232323;
}
QHeaderView::section {
background: #222;
color: #fff;
font-weight: bold;
border-radius: 4px;
padding: 4px 8px;
}
QCheckBox {
spacing: 6px;
font-size: 12px;
}
""")
self.layout.addWidget(self.project_tree)
# Selection info
self.info_label = QLabel("Keine Varianten ausgewählt")
self.info_label.setStyleSheet("color: #aaa; font-style: italic; margin-top: 6px;")
self.layout.addWidget(self.info_label)
[docs]
def set_base_path(self, base_path):
"""
Set base path and refresh project list.
:param base_path: Project data base path
:type base_path: str
"""
self.base_path = base_path
self.discover_projects()
[docs]
def discover_projects(self):
"""
Discover variants and their energy system configs; build two-level tree.
Top level: variant folders (``Variante *``).
Children: each ``Ergebnisse*.json`` config inside that variant.
"""
self.project_tree.clear()
try:
if not self.base_path or not os.path.exists(self.base_path):
self.info_label.setText("Projekt-Datenordner nicht gefunden")
return
parent_dir = os.path.dirname(self.base_path)
variant_names = discover_variants(parent_dir)
variant_count = 0
for variant_name in variant_names:
variant_path = os.path.join(parent_dir, variant_name)
if not self.validate_variant(variant_path):
continue
configs = _discover_variant_configs(variant_path)
if not configs:
continue
# Top-level variant item (no UserRole data — not a selectable leaf)
variant_item = QTreeWidgetItem([variant_name])
variant_item.setCheckState(0, Qt.CheckState.Checked)
self.project_tree.addTopLevelItem(variant_item)
for config_name, config_file in configs:
display = config_name
child = QTreeWidgetItem([display])
child.setCheckState(0, Qt.CheckState.Checked)
child.setData(
0,
Qt.ItemDataRole.UserRole,
{
"variant_path": variant_path,
"variant_name": variant_name,
"config_name": config_name,
"config_file": config_file,
"display_name": f"{variant_name} · {config_name}",
},
)
variant_item.addChild(child)
variant_item.setExpanded(True)
variant_count += 1
if variant_count == 0:
self.info_label.setText("Keine gültigen Varianten gefunden")
else:
self.info_label.setText(f"{variant_count} Varianten gefunden")
self.update_selected_variants()
except Exception as e:
QMessageBox.warning(self, "Fehler", f"Fehler beim Laden der Projekte: {str(e)}")
[docs]
def validate_variant(self, variant_path):
"""
Validate that a variant has at least one energy system result file.
:param variant_path: Path to variant folder
:type variant_path: str
:return: True if the variant is usable for comparison
:rtype: bool
"""
ergebnisse_dir = os.path.join(variant_path, "Ergebnisse")
has_results = os.path.isdir(ergebnisse_dir) and any(
f.startswith("Ergebnisse") and f.endswith(".json") for f in os.listdir(ergebnisse_dir)
)
return has_results
[docs]
def on_selection_changed(self, item, column):
"""
Handle selection changes; propagate variant-level toggles to all child configs.
:param item: Changed tree item
:type item: QTreeWidgetItem
:param column: Column index (unused)
:type column: int
"""
self.project_tree.blockSignals(True)
if item.parent() is None:
# Variant-level: propagate state to all config children
state = item.checkState(0)
for i in range(item.childCount()):
item.child(i).setCheckState(0, state)
self.project_tree.blockSignals(False)
self.update_selected_variants()
[docs]
def update_selected_variants(self):
"""
Collect all checked leaf (config) items and emit variants_changed signal.
"""
self.selected_variants = []
for i in range(self.project_tree.topLevelItemCount()):
variant_item = self.project_tree.topLevelItem(i)
for j in range(variant_item.childCount()):
config_item = variant_item.child(j)
if config_item.checkState(0) == Qt.CheckState.Checked:
self.selected_variants.append(config_item.data(0, Qt.ItemDataRole.UserRole))
count = len(self.selected_variants)
if count == 0:
self.info_label.setText("Keine Konfigurationen ausgewählt")
elif count == 1:
self.info_label.setText("1 Konfiguration ausgewählt")
else:
self.info_label.setText(f"{count} Konfigurationen ausgewählt")
self.variants_changed.emit(self.selected_variants)
[docs]
class ComparisonDashboard(QWidget):
"""
Dashboard widget showing comparison overview and KPIs.
Displays economic, environmental, and technical metrics across
selected variants with interactive charts.
"""
[docs]
def __init__(self, parent=None):
super().__init__(parent)
self.variant_data = []
self.initUI()
[docs]
def initUI(self):
"""Initialize dashboard UI."""
self.layout = QVBoxLayout(self)
# Header
header_label = QLabel("Variantenvergleich - Dashboard")
header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
self.layout.addWidget(header_label)
# Create scroll area for dashboard content
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_widget = QWidget()
self.scroll_layout = QVBoxLayout(scroll_widget)
# KPI Overview section
self.create_kpi_section()
# Charts section
self.create_charts_section()
scroll_area.setWidget(scroll_widget)
self.layout.addWidget(scroll_area)
[docs]
def create_kpi_section(self):
"""Create KPI overview section."""
kpi_group = QGroupBox("Kennzahlen-Übersicht")
kpi_group.setFont(QFont("Arial", 10, QFont.Weight.Bold))
kpi_layout = QGridLayout(kpi_group)
# Placeholder for KPI widgets - will be populated when data loads
self.kpi_widgets = {}
kpi_metrics = [
("Wärmegestehungskosten", "€/MWh"),
("CO2-Emissionen", "t_CO2/MWh"),
("Primärenergiefaktor", "-"),
("Jahreswärmebedarf", "MWh"),
("Trassenlänge", "m"),
("Verteilverluste", "%"),
]
for i, (metric, unit) in enumerate(kpi_metrics):
row, col = i // 3, i % 3
widget = self.create_kpi_widget(metric, unit)
self.kpi_widgets[metric] = widget
kpi_layout.addWidget(widget, row, col)
self.scroll_layout.addWidget(kpi_group)
[docs]
def create_charts_section(self):
"""Create charts section with tabbed organization."""
# Create matplotlib figures with seaborn styling
sns.set_style("whitegrid")
plt.rcParams.update(
{
"font.size": 11,
"axes.titlesize": 14,
"axes.labelsize": 12,
"xtick.labelsize": 10,
"ytick.labelsize": 10,
"legend.fontsize": 10,
}
)
# Create tabbed chart organization for better space utilization
charts_tab_widget = QTabWidget()
charts_tab_widget.setStyleSheet("""
QTabWidget::pane {
border: 1px solid #ddd;
border-radius: 5px;
background-color: white;
}
QTabBar::tab {
background-color: #f0f0f0;
padding: 8px 12px;
margin-right: 2px;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
QTabBar::tab:selected {
background-color: white;
border-bottom: 2px solid #3498db;
}
""")
# Economic Analysis Tab
economic_tab = QWidget()
economic_layout = QGridLayout(economic_tab)
economic_layout.setSpacing(15)
# Cost comparison chart (larger size)
self.cost_figure = Figure(figsize=(8, 5))
self.cost_canvas = FigureCanvas(self.cost_figure)
economic_layout.addWidget(self.cost_canvas, 0, 0)
charts_tab_widget.addTab(economic_tab, "💰 Wirtschaftlich")
# Energy Analysis Tab (new)
energy_tab = QWidget()
energy_layout = QGridLayout(energy_tab)
energy_layout.setSpacing(15)
# Energy mix chart (moved from economic)
self.energy_figure = Figure(figsize=(8, 5))
self.energy_canvas = FigureCanvas(self.energy_figure)
energy_layout.addWidget(self.energy_canvas, 0, 0)
charts_tab_widget.addTab(energy_tab, "⚡ Energie")
# Environmental Analysis Tab
environmental_tab = QWidget()
environmental_layout = QGridLayout(environmental_tab)
environmental_layout.setSpacing(15)
# CO2 emissions chart (larger size)
self.co2_figure = Figure(figsize=(8, 5))
self.co2_canvas = FigureCanvas(self.co2_figure)
environmental_layout.addWidget(self.co2_canvas, 0, 0)
# Primärenergiefaktor chart (larger size)
self.pe_figure = Figure(figsize=(8, 5))
self.pe_canvas = FigureCanvas(self.pe_figure)
environmental_layout.addWidget(self.pe_canvas, 0, 1)
charts_tab_widget.addTab(environmental_tab, "🌱 Umwelt")
# Technical Analysis Tab
technical_tab = QWidget()
technical_layout = QVBoxLayout(technical_tab)
technical_layout.setSpacing(15)
# Network statistics chart (full width, larger size)
self.network_figure = Figure(figsize=(16, 6))
self.network_canvas = FigureCanvas(self.network_figure)
technical_layout.addWidget(self.network_canvas)
charts_tab_widget.addTab(technical_tab, "⚙️ Technik")
self.scroll_layout.addWidget(charts_tab_widget)
[docs]
def update_dashboard(self, variant_data):
"""Update dashboard with new variant data."""
self.variant_data = variant_data
if not variant_data:
self.clear_dashboard()
return
self.update_kpis()
self.update_charts()
[docs]
def update_kpis(self):
"""Update KPI widgets with current data."""
if not self.variant_data:
# Reset all KPIs to default
for widget in self.kpi_widgets.values():
widget.value_label.setText("--")
return
# (widget key, result-dict key, number format, text when unavailable).
# The aggregation/formatting is in format_kpi_range (GUI-free, tested).
kpi_specs = [
("Wärmegestehungskosten", "WGK_Gesamt", ".1f", "--"),
("CO2-Emissionen", "specific_emissions_Gesamt", ".3f", "--"),
("Primärenergiefaktor", "primärenergiefaktor_Gesamt", ".2f", "--"),
("Jahreswärmebedarf", "Jahreswärmebedarf", ".0f", "--"),
("Trassenlänge", "Trassenlänge", ".0f", "n.v."),
("Verteilverluste", "Verteilverluste", ".1f", "n.v."),
]
try:
for widget_key, data_key, fmt, empty in kpi_specs:
text = format_kpi_range(self.variant_data, data_key, fmt, empty=empty)
self.kpi_widgets[widget_key].value_label.setText(text)
except Exception as e:
logging.warning("KPI-Aktualisierung fehlgeschlagen: %s", e)
for widget in self.kpi_widgets.values():
widget.value_label.setText("--")
[docs]
def update_charts(self):
"""Update all comparison charts."""
try:
self.update_cost_chart()
self.update_energy_chart()
self.update_co2_chart()
self.update_pe_chart()
self.update_network_chart()
except Exception as e:
logging.warning("Diagramm-Aktualisierung fehlgeschlagen: %s", e)
[docs]
def update_cost_chart(self):
"""Update cost comparison chart."""
self.cost_figure.clear()
if not self.variant_data:
return
ax = self.cost_figure.add_subplot(111)
# Use shorter, cleaner names for charts
names = [
self.get_clean_variant_name(v.get("name", f"Variante {i + 1}")) for i, v in enumerate(self.variant_data)
]
costs = [v.get("WGK_Gesamt", 0) for v in self.variant_data]
bars = ax.bar(names, costs, color="#3498db", alpha=0.8, edgecolor="#2980b9", linewidth=1)
ax.set_title("Wärmegestehungskosten Vergleich", fontweight="bold", fontsize=12)
ax.set_ylabel("Kosten (€/MWh)", fontweight="bold")
# Improve x-axis labels
if len(names) > 3:
ax.tick_params(axis="x", rotation=45, labelsize=9)
else:
ax.tick_params(axis="x", rotation=0, labelsize=10)
# Add value labels on bars with better positioning
for bar, cost in zip(bars, costs, strict=False):
height = bar.get_height()
ax.text(
bar.get_x() + bar.get_width() / 2.0,
height + height * 0.02,
f"{cost:.1f}",
ha="center",
va="bottom",
fontweight="bold",
fontsize=10,
)
# Improve layout
ax.grid(True, alpha=0.3, axis="y")
ax.set_axisbelow(True)
self.cost_figure.tight_layout()
self.cost_canvas.draw()
[docs]
def get_clean_variant_name(self, full_name):
"""Extract clean variant name from full project path name."""
if " - " in full_name:
return full_name.split(" - ")[-1] # Take the last part after ' - '
return full_name
[docs]
def update_energy_chart(self):
"""Update energy mix comparison chart."""
self.energy_figure.clear()
if not self.variant_data:
return
n_variants = len(self.variant_data)
if n_variants == 0:
return
fig = self.energy_figure
# Adjust subplot layout based on number of variants
if n_variants == 1:
cols = 1
elif n_variants == 2:
cols = 2
else:
cols = min(3, n_variants) # Max 3 columns
rows = (n_variants + cols - 1) // cols # Calculate required rows
for i, variant in enumerate(self.variant_data):
ax = fig.add_subplot(rows, cols, i + 1)
techs = variant.get("techs", [])
anteile = variant.get("Anteile", [])
colors = variant.get("colors", plt.cm.Set3.colors[: len(techs)])
if techs and anteile:
filtered_data = [
(tech, anteil, color)
for tech, anteil, color in zip(techs, anteile, colors, strict=False)
if anteil > 1.0
]
if filtered_data:
techs_filtered, anteile_filtered, colors_filtered = zip(*filtered_data, strict=False)
else:
techs_filtered, anteile_filtered, colors_filtered = techs, anteile, colors
wedges, texts = ax.pie(
anteile_filtered, labels=None, colors=colors_filtered, autopct=None, startangle=90
)
legend_labels = [
f"{tech}: {anteil:.1f}%" for tech, anteil in zip(techs_filtered, anteile_filtered, strict=False)
]
# Always show legend, right for 1-2, below for more
if cols == 1 or (cols == 2 and n_variants <= 2):
ax.legend(
wedges,
legend_labels,
loc="center left",
bbox_to_anchor=(1.1, 0.5),
fontsize=9,
frameon=True,
fancybox=True,
shadow=True,
)
else:
ax.legend(
wedges,
legend_labels,
loc="upper center",
bbox_to_anchor=(0.5, -0.1),
ncol=2,
fontsize=8,
frameon=True,
fancybox=True,
shadow=True,
)
clean_name = self.get_clean_variant_name(variant.get("name", f"Variante {i + 1}"))
ax.set_title(clean_name, fontsize=10, fontweight="bold", pad=10)
fig.suptitle("Energiemix Vergleich", fontsize=12, fontweight="bold", y=0.98)
fig.tight_layout(rect=[0, 0, 1, 0.97])
self.energy_canvas.draw()
[docs]
def update_co2_chart(self):
"""Update CO2 emissions comparison chart."""
self.co2_figure.clear()
if not self.variant_data:
return
ax = self.co2_figure.add_subplot(111)
names = [
self.get_clean_variant_name(v.get("name", f"Variante {i + 1}")) for i, v in enumerate(self.variant_data)
]
emissions = [v.get("specific_emissions_Gesamt", 0) for v in self.variant_data]
# Create CO2 bar chart
bars = ax.bar(names, emissions, color="#e74c3c", alpha=0.8, edgecolor="#c0392b", linewidth=1)
ax.set_title("CO2-Emissionen Vergleich", fontweight="bold", fontsize=12)
ax.set_ylabel("CO2-Emissionen (t/MWh)", fontweight="bold")
ax.set_xlabel("Varianten", fontweight="bold")
# Rotate labels if needed
if len(names) > 3:
ax.tick_params(axis="x", rotation=45)
# Add value labels on bars
for bar, emission in zip(bars, emissions, strict=False):
height = bar.get_height()
ax.text(
bar.get_x() + bar.get_width() / 2.0,
height + height * 0.02,
f"{emission:.3f}",
ha="center",
va="bottom",
fontsize=9,
fontweight="bold",
)
# Improve grid and styling
ax.grid(True, alpha=0.3, axis="y")
ax.set_axisbelow(True)
self.co2_figure.tight_layout()
self.co2_canvas.draw()
[docs]
def update_pe_chart(self):
"""Update Primärenergiefaktor comparison chart."""
self.pe_figure.clear()
if not self.variant_data:
return
ax = self.pe_figure.add_subplot(111)
names = [
self.get_clean_variant_name(v.get("name", f"Variante {i + 1}")) for i, v in enumerate(self.variant_data)
]
pe_factors = [v.get("primärenergiefaktor_Gesamt", 0) for v in self.variant_data]
# Create PE factor bar chart
bars = ax.bar(names, pe_factors, color="#f39c12", alpha=0.8, edgecolor="#e67e22", linewidth=1)
ax.set_title("Primärenergiefaktor Vergleich", fontweight="bold", fontsize=12)
ax.set_ylabel("Primärenergiefaktor (-)", fontweight="bold")
ax.set_xlabel("Varianten", fontweight="bold")
# Rotate labels if needed
if len(names) > 3:
ax.tick_params(axis="x", rotation=45)
# Add value labels on bars
for bar, pe_factor in zip(bars, pe_factors, strict=False):
height = bar.get_height()
ax.text(
bar.get_x() + bar.get_width() / 2.0,
height + height * 0.02,
f"{pe_factor:.2f}",
ha="center",
va="bottom",
fontsize=9,
fontweight="bold",
)
# Improve grid and styling
ax.grid(True, alpha=0.3, axis="y")
ax.set_axisbelow(True)
self.pe_figure.tight_layout()
self.pe_canvas.draw()
[docs]
def update_network_chart(self):
"""Update network statistics chart with enhanced layout for larger space."""
self.network_figure.clear()
if not self.variant_data:
return
# Extract network metrics
names = [
self.get_clean_variant_name(v.get("name", f"Variante {i + 1}")) for i, v in enumerate(self.variant_data)
]
verteilverluste = [v.get("Verteilverluste", 0) for v in self.variant_data]
anzahl_gebaeude = [v.get("Anzahl_Gebäude", 0) for v in self.variant_data]
trassenlaenge = [v.get("Trassenlänge", 0) for v in self.variant_data]
# Check if we have meaningful data
if all(v == 0 for v in verteilverluste + anzahl_gebaeude + trassenlaenge):
# Show placeholder if no real data available
ax = self.network_figure.add_subplot(111)
ax.text(
0.5,
0.5,
"Netzstatistiken\n(Daten werden aus Projekten geladen...)",
ha="center",
va="center",
transform=ax.transAxes,
fontsize=14,
style="italic",
color="#666",
bbox=dict(boxstyle="round,pad=0.5", facecolor="lightgray", alpha=0.5),
)
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.axis("off")
else:
# Create subplots for different network metrics (side by side for better space utilization)
fig = self.network_figure
# Create three subplots horizontally
ax1 = fig.add_subplot(131) # Verteilverluste
ax2 = fig.add_subplot(132) # Anzahl Gebäude
ax3 = fig.add_subplot(133) # Trassenlänge
# Verteilverluste chart
if any(v > 0 for v in verteilverluste):
bars1 = ax1.bar(names, verteilverluste, color="#e74c3c", alpha=0.8, edgecolor="#c0392b")
ax1.set_title("Verteilverluste", fontweight="bold", fontsize=12)
ax1.set_ylabel("Verluste (%)", fontweight="bold")
# Add value labels
for bar, value in zip(bars1, verteilverluste, strict=False):
if value > 0:
height = bar.get_height()
ax1.text(
bar.get_x() + bar.get_width() / 2.0,
height + height * 0.02,
f"{value:.1f}%",
ha="center",
va="bottom",
fontweight="bold",
)
else:
ax1.text(
0.5,
0.5,
"Keine\nDaten",
ha="center",
va="center",
transform=ax1.transAxes,
fontsize=10,
style="italic",
color="#999",
)
ax1.set_title("Verteilverluste", fontweight="bold", fontsize=12)
# Anzahl Gebäude chart
if any(v > 0 for v in anzahl_gebaeude):
bars2 = ax2.bar(names, anzahl_gebaeude, color="#3498db", alpha=0.8, edgecolor="#2980b9")
ax2.set_title("Anzahl Gebäude", fontweight="bold", fontsize=12)
ax2.set_ylabel("Anzahl", fontweight="bold")
# Add value labels
for bar, value in zip(bars2, anzahl_gebaeude, strict=False):
if value > 0:
height = bar.get_height()
ax2.text(
bar.get_x() + bar.get_width() / 2.0,
height + height * 0.02,
f"{int(value)}",
ha="center",
va="bottom",
fontweight="bold",
)
else:
ax2.text(
0.5,
0.5,
"Keine\nDaten",
ha="center",
va="center",
transform=ax2.transAxes,
fontsize=10,
style="italic",
color="#999",
)
ax2.set_title("Anzahl Gebäude", fontweight="bold", fontsize=12)
# Trassenlänge chart
if any(v > 0 for v in trassenlaenge):
bars3 = ax3.bar(names, trassenlaenge, color="#f39c12", alpha=0.8, edgecolor="#e67e22")
ax3.set_title("Trassenlänge", fontweight="bold", fontsize=12)
ax3.set_ylabel("Länge (m)", fontweight="bold")
# Add value labels
for bar, value in zip(bars3, trassenlaenge, strict=False):
if value > 0:
height = bar.get_height()
ax3.text(
bar.get_x() + bar.get_width() / 2.0,
height + height * 0.02,
f"{int(value)}",
ha="center",
va="bottom",
fontweight="bold",
)
else:
ax3.text(
0.5,
0.5,
"Keine\nDaten",
ha="center",
va="center",
transform=ax3.transAxes,
fontsize=10,
style="italic",
color="#999",
)
ax3.set_title("Trassenlänge", fontweight="bold", fontsize=12)
# Improve layout for all subplots
for ax in [ax1, ax2, ax3]:
ax.grid(True, alpha=0.3, axis="y")
ax.set_axisbelow(True)
if len(names) > 2:
ax.tick_params(axis="x", rotation=45)
self.network_figure.tight_layout(pad=2.0)
self.network_canvas.draw()
self.network_canvas.draw()
[docs]
def clear_dashboard(self):
"""Clear all dashboard content."""
# Reset KPI widgets
for widget in self.kpi_widgets.values():
widget.value_label.setText("--")
# Clear charts
for figure in [self.cost_figure, self.energy_figure, self.co2_figure, self.pe_figure, self.network_figure]:
figure.clear()
for canvas in [self.cost_canvas, self.energy_canvas, self.co2_canvas, self.pe_canvas, self.network_canvas]:
canvas.draw()
[docs]
class ComparisonTab(QWidget):
"""
Comparison tab with comprehensive variant analysis.
Integrates project explorer, KPI dashboard, and comparative
visualizations for multi-variant evaluation.
"""
[docs]
def __init__(self, folder_manager, data_manager, config_manager, parent=None):
"""
Initialize comparison tab.
:param folder_manager: Project folder manager
:type folder_manager: ProjectFolderManager
:param data_manager: Application data manager
:type data_manager: DataManager
:param config_manager: Configuration manager
:type config_manager: ProjectConfigManager
:param parent: Parent widget (optional)
:type parent: QWidget
"""
super().__init__(parent)
self.folder_manager = folder_manager
self.data_manager = data_manager
self.config_manager = config_manager
self.variant_data = []
self.initUI()
[docs]
def initUI(self):
"""Initialize modern user interface."""
self.mainLayout = QHBoxLayout(self)
# Create main splitter
main_splitter = QSplitter(Qt.Orientation.Horizontal)
# Left panel: Project Explorer (25% width)
self.project_explorer = ProjectExplorer(self.folder_manager, self.config_manager)
self.project_explorer.variants_changed.connect(self.on_variants_changed)
self.project_explorer.setMaximumWidth(350)
self.project_explorer.setMinimumWidth(250)
main_splitter.addWidget(self.project_explorer)
# Right panel: Comparison content (75% width)
self.comparison_content = self.create_comparison_content()
main_splitter.addWidget(self.comparison_content)
# Set splitter proportions
main_splitter.setSizes([250, 750])
main_splitter.setStretchFactor(0, 0) # Explorer doesn't stretch
main_splitter.setStretchFactor(1, 1) # Content stretches
self.mainLayout.addWidget(main_splitter)
self.project_explorer.update_selected_variants() # Initial update to load any pre-selected variants
[docs]
def create_comparison_content(self):
"""
Create main comparison content area.
:return: Content widget with dashboard tabs
:rtype: QWidget
"""
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
# Create tab widget for different comparison views
self.tab_widget = QTabWidget()
# Dashboard tab
self.dashboard = ComparisonDashboard()
self.tab_widget.addTab(self.dashboard, "📊 Dashboard")
content_layout.addWidget(self.tab_widget)
return content_widget
[docs]
def on_variants_changed(self, selected_variants):
"""
Handle variant selection changes from explorer.
:param selected_variants: List of selected variant info dicts
:type selected_variants: list
"""
if not selected_variants:
self.variant_data = []
self.dashboard.update_dashboard([])
return
# Load data for selected variants
self.load_variant_data(selected_variants)
[docs]
def load_variant_data(self, selected_variants):
"""
Load data for selected (variant, config) combinations.
Network KPIs are cached per variant path since they are shared across configs.
:param selected_variants: List of item data dicts from ProjectExplorer
:type selected_variants: list
"""
self.variant_data = []
network_cache: dict = {}
for item in selected_variants:
try:
variant_path = item["variant_path"]
variant_name = item["variant_name"]
config_name = item["config_name"]
config_file = item["config_file"]
display_name = item["display_name"]
results_path = os.path.join(variant_path, "Ergebnisse", config_file)
with open(results_path, encoding="utf-8") as f:
data = json.load(f)
results = data.get("results", {})
processed_data = self.process_variant_results(results)
processed_data["name"] = display_name
processed_data["variant_name"] = variant_name
processed_data["config_name"] = config_name
processed_data["path"] = variant_path
# Network KPIs: load once per variant, share across configs
if variant_path not in network_cache:
network_cache[variant_path] = self.load_network_data(variant_path)
processed_data.update(network_cache[variant_path])
self.variant_data.append(processed_data)
except Exception as e:
QMessageBox.warning(
self, "Ladefehler", f"Fehler beim Laden von '{item.get('display_name', '?')}':\n{str(e)}"
)
self.dashboard.update_dashboard(self.variant_data)
[docs]
def load_network_data(self, variant_path):
"""
Load network KPI data from variant configuration.
:param variant_path: Path to variant folder
:type variant_path: str
:return: Network KPI data (length, losses, pump energy, building count)
:rtype: dict
"""
network_data = {"Trassenlänge": 0, "Verteilverluste": 0, "Pumpenenergie": 0, "Anzahl_Gebäude": 0}
try:
config_path = os.path.join(variant_path, "Wärmenetz", "Konfiguration Netzinitialisierung.json")
if not os.path.exists(config_path):
return network_data
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
kpi_results = config.get("kpi_results", {})
network_data["Trassenlänge"] = kpi_results.get("Trassenlänge Wärmenetz [m]", 0)
network_data["Verteilverluste"] = kpi_results.get("rel. Verteilverluste [%]", 0)
network_data["Pumpenenergie"] = kpi_results.get("Pumpenstrom [MWh]", 0)
network_data["Anzahl_Gebäude"] = kpi_results.get("Anzahl angeschlossene Gebäude", 0)
except (OSError, ValueError, KeyError, json.JSONDecodeError) as e:
logging.warning("Konnte Netz-KPIs der Variante nicht lesen: %s", e)
return network_data
[docs]
def process_variant_results(self, results):
"""
Process raw variant results for comparison.
:param results: Raw results from JSON file
:type results: dict
:return: Processed results for dashboard display
:rtype: dict
:raises ValueError: If processing fails
"""
try:
# Handle primärenergiefaktor_Gesamt which can be float or list
pe_gesamt = results.get("primärenergiefaktor_Gesamt", 0)
waermemengen = results.get("Wärmemengen", [])
if isinstance(pe_gesamt, (float, int)):
pe_gesamt = [pe_gesamt] * len(waermemengen) if waermemengen else [pe_gesamt]
elif isinstance(pe_gesamt, list):
if len(pe_gesamt) != len(waermemengen) and waermemengen:
pe_gesamt = pe_gesamt * len(waermemengen) if pe_gesamt else [0] * len(waermemengen)
processed_results = {
"techs": results.get("techs", []),
"Wärmemengen": [round(w, 2) for w in waermemengen],
"WGK": [round(w, 2) for w in results.get("WGK", [])],
"Anteile": [round(a * 100, 2) for a in results.get("Anteile", [])],
"colors": results.get("colors", []),
"specific_emissions_L": [round(e, 4) for e in results.get("specific_emissions_L", [])],
"primärenergie_L": [
round(pe / w, 4) if w else 0 for pe, w in zip(pe_gesamt, waermemengen, strict=False)
],
"Jahreswärmebedarf": round(results.get("Jahreswärmebedarf", 0), 1),
"Strommenge": round(results.get("Strommenge", 0), 2),
"Strombedarf": round(results.get("Strombedarf", 0), 2),
"WGK_Gesamt": round(results.get("WGK_Gesamt", 0), 2),
"specific_emissions_Gesamt": round(results.get("specific_emissions_Gesamt", 0), 4),
"primärenergiefaktor_Gesamt": round(results.get("primärenergiefaktor_Gesamt", 0), 4),
}
return processed_results
except Exception as e:
raise ValueError(f"Error processing results: {e}") from e