Source code for districtheatingsim.gui.ComparisonTab.comparison_tab

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


[docs] def format_kpi_range(variant_data: list[dict], key: str, fmt: str, *, empty: str = "--") -> str: """ Summarize one KPI across the compared variants for the dashboard. Filters out missing / zero values, then returns a single formatted number, a ``"min - max"`` range when several variants differ, or ``empty`` when no variant provides the value. GUI-free domain/formatting logic (extracted from the view so it is unit-testable — BACKLOG B2). :param variant_data: One result dict per variant. :param key: The metric key to read from each variant dict. :param fmt: A ``format()`` spec applied to each number (e.g. ``".1f"``). :param empty: Text to show when no variant provides the metric. :return: The formatted value / range / ``empty`` string. :rtype: str """ values = [v.get(key, 0) for v in variant_data if v.get(key, 0) not in (None, 0)] if not values: return empty if len(values) == 1: return format(values[0], fmt) return f"{format(min(values), fmt)} - {format(max(values), fmt)}"
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_kpi_widget(self, title, unit): """Create individual KPI widget.""" widget = QFrame() widget.setFrameStyle(QFrame.Shape.StyledPanel) widget.setMinimumHeight(80) layout = QVBoxLayout(widget) title_label = QLabel(title) title_label.setFont(QFont("Arial", 9, QFont.Weight.Bold)) title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(title_label) value_label = QLabel("--") value_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) value_label.setAlignment(Qt.AlignmentFlag.AlignCenter) value_label.setStyleSheet("color: #2E8B57;") layout.addWidget(value_label) unit_label = QLabel(unit) unit_label.setFont(QFont("Arial", 8)) unit_label.setAlignment(Qt.AlignmentFlag.AlignCenter) unit_label.setStyleSheet("color: #666;") layout.addWidget(unit_label) widget.value_label = value_label # Store reference for updates return widget
[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