Source code for districtheatingsim.gui.MainTab.main_data_manager

"""
Data management layer for DistrictHeatingSim application.

This module implements the Model layer of the MVP pattern, providing three core managers:
- ProjectConfigManager: Configuration and user preferences
- DataManager: Central data storage for analysis results
- ProjectFolderManager: Project folder structure and navigation

:author: Dipl.-Ing. (FH) Jonas Pfeiffer
"""

import os
import json
from typing import Dict, List, Optional, Any

from PyQt6.QtCore import QObject, pyqtSignal

from districtheatingsim.utilities.utilities import get_resource_path
        
[docs] class ProjectConfigManager: """ Manages application configuration and resource paths. Handles JSON-based configuration storage including user preferences, recent projects, and resource path resolution for both development and PyInstaller builds. :param config_path: Path to config.json (optional, uses default if None) :type config_path: str :param file_paths_path: Path to file_paths.json (optional, uses default if None) :type file_paths_path: str .. note:: Configuration files are stored with UTF-8 encoding for international character support. """
[docs] def __init__(self, config_path: Optional[str] = None, file_paths_path: Optional[str] = None): """ Initialize configuration manager with automatic data loading. :param config_path: Custom path to configuration file :type config_path: str :param file_paths_path: Custom path to file paths configuration :type file_paths_path: str """ self.config_path = config_path or self.get_default_config_path() self.file_paths_path = file_paths_path or self.get_default_file_paths_path() self.config_data = self.load_config() self.file_paths_data = self.load_file_paths()
[docs] def get_default_config_path(self) -> str: """ Get the default path to recent_projects.json. :return: Absolute path to the default configuration file :rtype: str """ return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'recent_projects.json')
[docs] def get_default_file_paths_path(self) -> str: """ Get the default path to file_paths.json. :return: Absolute path to the default file paths configuration file :rtype: str """ return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file_paths.json')
[docs] def load_config(self) -> Dict[str, Any]: """ Load application configuration from JSON file with UTF-8 encoding. :return: Configuration dictionary (empty if file doesn't exist) :rtype: dict """ if os.path.exists(self.config_path): try: with open(self.config_path, 'r', encoding='utf-8') as file: return json.load(file) except (json.JSONDecodeError, UnicodeDecodeError) as e: print(f"Error loading configuration: {e}") return {} return {}
[docs] def load_file_paths(self) -> Dict[str, str]: """ Load file path mappings from JSON configuration with UTF-8 encoding. :return: File paths dictionary mapping resource IDs to relative paths (empty if file doesn't exist) :rtype: dict """ if os.path.exists(self.file_paths_path): try: with open(self.file_paths_path, 'r', encoding='utf-8') as file: return json.load(file) except (json.JSONDecodeError, UnicodeDecodeError) as e: print(f"Error loading file paths: {e}") return {} return {}
[docs] def save_config(self, config: Dict[str, Any]) -> None: """ Save configuration data to JSON file with UTF-8 encoding. :param config: Configuration data to save :type config: dict :raises Exception: If file cannot be written """ try: with open(self.config_path, 'w', encoding='utf-8') as file: json.dump(config, file, indent=4, ensure_ascii=False) except Exception as e: print(f"Error saving configuration: {e}") raise
[docs] def save_file_paths(self, file_paths: Dict[str, str]) -> None: """ Save file paths configuration to JSON file with UTF-8 encoding. :param file_paths: File paths data to save :type file_paths: dict :raises Exception: If file cannot be written """ try: with open(self.file_paths_path, 'w', encoding='utf-8') as file: json.dump(file_paths, file, indent=4, ensure_ascii=False) except Exception as e: print(f"Error saving file paths: {e}") raise
[docs] def get_last_project(self) -> str: """ Retrieve the path of the most recently opened project. :return: Path to last opened project (empty string if none) :rtype: str """ return self.config_data.get('last_project', '')
[docs] def set_last_project(self, path: str) -> None: """ Set the most recently opened project and update recent projects list. Automatically manages recent projects history (max 5 entries) with duplicate prevention and configuration persistence. :param path: Path to the project directory :type path: str """ self.config_data['last_project'] = path # Initialize recent projects list if not exists if 'recent_projects' not in self.config_data: self.config_data['recent_projects'] = [] # Add to recent projects with duplicate prevention if path not in self.config_data['recent_projects']: self.config_data['recent_projects'].insert(0, path) # Maintain maximum of 5 recent projects self.config_data['recent_projects'] = self.config_data['recent_projects'][:5] else: # Move existing entry to top self.config_data['recent_projects'].remove(path) self.config_data['recent_projects'].insert(0, path) # Persist changes immediately self.save_config(self.config_data)
[docs] def get_recent_projects(self) -> List[str]: """ Retrieve the list of recently opened projects. :return: List of project paths (max 5 entries, most recent first) :rtype: list of str """ return self.config_data.get('recent_projects', [])
[docs] def get_relative_path(self, key: str) -> str: """ Get relative path from file paths configuration. :param key: Configuration key for desired resource path :type key: str :return: Relative path string :rtype: str :raises KeyError: If key not found in file paths configuration """ relative_path = self.file_paths_data.get(key, "") if not relative_path: raise KeyError(f"Key '{key}' not found in file paths configuration.") return relative_path
[docs] def get_resource_path(self, key: str) -> str: """ Get absolute path to resource with PyInstaller compatibility. :param key: Configuration key for desired resource :type key: str :return: Absolute path to resource :rtype: str """ relative_path = self.get_relative_path(key) absolute_path = get_resource_path(relative_path) return absolute_path
[docs] class DataManager: """ Central data storage for district heating simulation. Manages map visualization data, weather data (TRY) filenames, and heat pump performance (COP) filenames for use across application components. """
[docs] def __init__(self): """ Initialize the data manager with empty data structures. """ self.map_data = [] self.try_filename = None self.cop_filename = None
[docs] def add_data(self, data: Any) -> None: """ Add data to the map data collection. :param data: Data to be added to the map data collection :type data: any """ self.map_data.append(data)
[docs] def get_map_data(self) -> List[Any]: """ Get the complete map data collection. :return: List of all map data entries :rtype: list """ return self.map_data
[docs] def set_try_filename(self, filename: str) -> None: """ Set the Test Reference Year (TRY) weather data filename. :param filename: Name of the TRY weather data file :type filename: str """ self.try_filename = filename
[docs] def get_try_filename(self) -> Optional[str]: """ Get the currently selected TRY weather data filename. :return: TRY weather data filename or None if not set :rtype: str or None """ return self.try_filename
[docs] def set_cop_filename(self, filename: str) -> None: """ Set the Coefficient of Performance (COP) data filename for heat pumps. :param filename: Name of the COP data file :type filename: str """ self.cop_filename = filename
[docs] def get_cop_filename(self) -> Optional[str]: """ Get the currently selected COP data filename. :return: COP data filename or None if not set :rtype: str or None """ return self.cop_filename
[docs] class ProjectFolderManager(QObject): """ Manages project folder structure and variant navigation. Handles project folder hierarchy, variant switching, and emits signals when project/variant folders change for UI synchronization. :param config_manager: Configuration manager instance (creates new if None) :type config_manager: ProjectConfigManager :signal project_folder_changed: Emitted when project/variant folder changes (str) """ project_folder_changed = pyqtSignal(str) def __init__(self, config_manager: Optional[ProjectConfigManager] = None): """ Initialize the project folder manager. :param config_manager: Configuration manager instance (creates new if None) :type config_manager: ProjectConfigManager """
[docs] def __init__(self, config_manager: Optional[ProjectConfigManager] = None): super(ProjectFolderManager, self).__init__() self.config_manager = config_manager or ProjectConfigManager() # Do not set project_folder or variant_folder until a project is selected or loaded self.project_folder = None self.variant_folder = None
# Do not emit initial folder change signal; will be emitted after project selection
[docs] def emit_project_and_variant_folder(self) -> None: """ Emit signal for current project and variant folder state. Creates default "Variante 1" if no variant folder exists. """ if self.project_folder and self.variant_folder and os.path.exists(self.variant_folder): print(f"Initial variant folder set to: {self.variant_folder}") self.project_folder_changed.emit(self.variant_folder) elif self.project_folder: # Create default variant if none exists print("No variant folder found, setting default variant") self.variant_folder = os.path.join(self.project_folder, "Variante 1") self.project_folder_changed.emit(self.variant_folder)
[docs] def set_project_folder(self, path: str) -> None: """ Set the project folder and update configuration. Validates variant folder and emits signals to notify connected components. Creates default "Variante 1" if no valid variant exists. :param path: Path to the project directory :type path: str """ self.project_folder = path self.config_manager.set_last_project(self.project_folder) # Validate and set variant folder if not self.variant_folder or not os.path.exists(self.variant_folder): self.variant_folder = os.path.join(self.project_folder, "Variante 1") self.emit_project_and_variant_folder()
[docs] def set_variant_folder(self, variant_name: str) -> None: """ Set the current variant folder and emit change signal. :param variant_name: Name of the variant folder (e.g., "Variante 1") :type variant_name: str """ if self.project_folder: self.variant_folder = os.path.join(self.project_folder, variant_name) self.project_folder_changed.emit(self.variant_folder) self.config_manager.set_last_project(self.project_folder)
[docs] def get_variant_folder(self) -> str: """ Get the current variant folder path. :return: Current variant folder path (or project folder if no variant) :rtype: str """ return self.variant_folder if self.variant_folder else self.project_folder
[docs] def load_last_project(self) -> None: """ Load the most recently opened project from configuration. Validates project existence before loading. Emits default state if project invalid or missing. """ last_project = self.config_manager.get_last_project() if last_project and os.path.exists(last_project): self.set_project_folder(last_project) else: self.emit_project_and_variant_folder()