"""
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 os
import json
import traceback
import pandas as pd
import numpy as np
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.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QSplitter,
QScrollArea, QGroupBox, QGridLayout, QFrame, QLabel,
QPushButton, QTreeWidget, QTreeWidgetItem, QMessageBox
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
[docs]
def debug_print(msg):
"""
Print debug message with ProjectExplorer prefix.
:param msg: Debug message
:type msg: str
"""
print(f"[ProjectExplorer DEBUG] {msg}")
[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
debug_print(f"set_base_path called with: {base_path}")
self.discover_projects()
[docs]
def discover_projects(self):
"""
Discover and populate project variants.
Scans parent directory for variant folders and validates them.
"""
self.project_tree.clear()
try:
debug_print(f"discover_projects: base_path={self.base_path}")
if not self.base_path or not os.path.exists(self.base_path):
debug_print("Projekt-Datenordner nicht gefunden!")
self.info_label.setText("Projekt-Datenordner nicht gefunden")
return
# Use parent directory of base_path to find all variants
parent_dir = os.path.dirname(self.base_path)
debug_print(f"Using parent_dir for variants: {parent_dir}")
variants = [d for d in os.listdir(parent_dir) if os.path.isdir(os.path.join(parent_dir, d)) and d.startswith("Variante")]
debug_print(f"Found variant folders: {variants}")
# Add all valid variants as top-level items
variant_count = 0
for variant_name in variants:
variant_path = os.path.join(parent_dir, variant_name)
if self.validate_variant(variant_path):
debug_print(f"Valid variant: {variant_name} at {variant_path}")
variant_item = QTreeWidgetItem([variant_name])
variant_item.setCheckState(0, Qt.CheckState.Checked)
variant_item.setData(0, Qt.ItemDataRole.UserRole, variant_path)
self.project_tree.addTopLevelItem(variant_item)
variant_count += 1
else:
debug_print(f"Skipped (invalid): {variant_name} at {variant_path}")
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:
debug_print(f"Exception in discover_projects: {e}\n{traceback.format_exc()}")
QMessageBox.warning(self, "Fehler", f"Fehler beim Laden der Projekte: {str(e)}")
# No longer needed, replaced by set_base_path
[docs]
def validate_variant(self, variant_path):
"""
Validate if variant contains required data files.
:param variant_path: Path to variant folder
:type variant_path: str
:return: True if all required files exist
:rtype: bool
"""
required_files = [
os.path.join("Ergebnisse", "Ergebnisse.json"),
os.path.join("Lastgang", "Lastgang.csv"),
os.path.join("Wärmenetz", "Konfiguration Netzinitialisierung.json")
]
valid = all(os.path.exists(os.path.join(variant_path, file)) for file in required_files)
debug_print(f"validate_variant: {variant_path} valid={valid}")
return valid
[docs]
def on_selection_changed(self, item, column):
"""
Handle variant selection changes in tree widget.
:param item: Changed tree item
:type item: QTreeWidgetItem
:param column: Column index
:type column: int
"""
if item.data(0, Qt.ItemDataRole.UserRole): # Only for variant items
self.update_selected_variants()
[docs]
def update_selected_variants(self):
"""
Update list of selected variants and emit signal.
Collects all checked variants and emits variants_changed signal.
"""
self.selected_variants = []
for i in range(self.project_tree.topLevelItemCount()):
variant_item = self.project_tree.topLevelItem(i)
if variant_item.checkState(0) == Qt.CheckState.Checked:
variant_path = variant_item.data(0, Qt.ItemDataRole.UserRole)
variant_name = variant_item.text(0)
self.selected_variants.append({
'name': variant_name,
'path': variant_path
})
# Update info label
count = len(self.selected_variants)
if count == 0:
self.info_label.setText("Keine Varianten ausgewählt")
elif count == 1:
self.info_label.setText("1 Variante ausgewählt")
else:
self.info_label.setText(f"{count} Varianten ausgewählt")
# Emit signal
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
# Calculate averages or ranges for KPIs
try:
# Extract metrics from variant data
wgk_values = [v.get('WGK_Gesamt', 0) for v in self.variant_data if v.get('WGK_Gesamt', 0) not in (None, 0)]
co2_values = [v.get('specific_emissions_Gesamt', 0) for v in self.variant_data if v.get('specific_emissions_Gesamt', 0) not in (None, 0)]
pe_values = [v.get('primärenergiefaktor_Gesamt', 0) for v in self.variant_data if v.get('primärenergiefaktor_Gesamt', 0) not in (None, 0)]
heat_values = [v.get('Jahreswärmebedarf', 0) for v in self.variant_data if v.get('Jahreswärmebedarf', 0) not in (None, 0)]
# New: Extract network metrics
trassenlänge_values = [v.get('Trassenlänge', 0) for v in self.variant_data if v.get('Trassenlänge', 0) not in (None, 0)]
verluste_values = [v.get('Verteilverluste', 0) for v in self.variant_data if v.get('Verteilverluste', 0) not in (None, 0)]
# Update KPI widgets with proper formatting
# Wärmegestehungskosten
if wgk_values:
if len(wgk_values) == 1:
self.kpi_widgets['Wärmegestehungskosten'].value_label.setText(f"{wgk_values[0]:.1f}")
else:
min_val, max_val = min(wgk_values), max(wgk_values)
self.kpi_widgets['Wärmegestehungskosten'].value_label.setText(f"{min_val:.1f} - {max_val:.1f}")
else:
self.kpi_widgets['Wärmegestehungskosten'].value_label.setText("--")
# CO2-Emissionen
if co2_values:
if len(co2_values) == 1:
self.kpi_widgets['CO2-Emissionen'].value_label.setText(f"{co2_values[0]:.3f}")
else:
min_val, max_val = min(co2_values), max(co2_values)
self.kpi_widgets['CO2-Emissionen'].value_label.setText(f"{min_val:.3f} - {max_val:.3f}")
else:
self.kpi_widgets['CO2-Emissionen'].value_label.setText("--")
# Primärenergiefaktor
if pe_values:
if len(pe_values) == 1:
self.kpi_widgets['Primärenergiefaktor'].value_label.setText(f"{pe_values[0]:.2f}")
else:
min_val, max_val = min(pe_values), max(pe_values)
self.kpi_widgets['Primärenergiefaktor'].value_label.setText(f"{min_val:.2f} - {max_val:.2f}")
else:
self.kpi_widgets['Primärenergiefaktor'].value_label.setText("--")
# Jahreswärmebedarf
if heat_values:
if len(heat_values) == 1:
self.kpi_widgets['Jahreswärmebedarf'].value_label.setText(f"{heat_values[0]:.0f}")
else:
min_val, max_val = min(heat_values), max(heat_values)
self.kpi_widgets['Jahreswärmebedarf'].value_label.setText(f"{min_val:.0f} - {max_val:.0f}")
else:
self.kpi_widgets['Jahreswärmebedarf'].value_label.setText("--")
# Trassenlänge (new)
if trassenlänge_values:
if len(trassenlänge_values) == 1:
self.kpi_widgets['Trassenlänge'].value_label.setText(f"{trassenlänge_values[0]:.0f}")
else:
min_val, max_val = min(trassenlänge_values), max(trassenlänge_values)
self.kpi_widgets['Trassenlänge'].value_label.setText(f"{min_val:.0f} - {max_val:.0f}")
else:
self.kpi_widgets['Trassenlänge'].value_label.setText("n.v.") # "nicht verfügbar"
# Verteilverluste (new)
if verluste_values:
if len(verluste_values) == 1:
self.kpi_widgets['Verteilverluste'].value_label.setText(f"{verluste_values[0]:.1f}")
else:
min_val, max_val = min(verluste_values), max(verluste_values)
self.kpi_widgets['Verteilverluste'].value_label.setText(f"{min_val:.1f} - {max_val:.1f}")
else:
self.kpi_widgets['Verteilverluste'].value_label.setText("n.v.")
except Exception as e:
print(f"Error updating KPIs: {e}")
# Reset to default on error
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:
print(f"Error updating charts: {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):
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2., 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) if anteil > 1.0]
if filtered_data:
techs_filtered, anteile_filtered, colors_filtered = zip(*filtered_data)
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)]
# 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):
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2., 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):
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2., 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):
if value > 0:
height = bar.get_height()
ax1.text(bar.get_x() + bar.get_width()/2., 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):
if value > 0:
height = bar.get_height()
ax2.text(bar.get_x() + bar.get_width()/2., 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):
if value > 0:
height = bar.get_height()
ax3.text(bar.get_x() + bar.get_width()/2., 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 variants.
:param selected_variants: List of selected variant info dicts
:type selected_variants: list
"""
self.variant_data = []
for variant_info in selected_variants:
try:
variant_path = variant_info['path']
variant_name = variant_info['name']
# Load results.json
results_path = os.path.join(variant_path, "Ergebnisse", "Ergebnisse.json")
with open(results_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Extract results section
results = data.get('results', {})
# Process data for comparison
processed_data = self.process_variant_results(results)
processed_data['name'] = variant_name
processed_data['path'] = variant_path
# Load additional network data
network_data = self.load_network_data(variant_path)
processed_data.update(network_data)
self.variant_data.append(processed_data)
except Exception as e:
QMessageBox.warning(self, "Ladenfehler",
f"Fehler beim Laden von {variant_name}:\n{str(e)}")
# Update dashboard
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):
print(f"Config not found for {variant_path}")
return network_data
with open(config_path, 'r', 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 Exception as e:
print(f"Error loading KPIs for {variant_path}: {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)],
"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}")