"""
Generator Schematic Module
===========================
:author: Dipl.-Ing. (FH) Jonas Pfeiffer
Custom QGraphicsScene and QGraphicsView for generator schematic editor with custom items and connections.
"""
from PyQt6.QtWidgets import (QGraphicsScene, QGraphicsPathItem, QGraphicsLineItem, QGraphicsItem, QGraphicsView, QGraphicsRectItem, QGraphicsTextItem)
from PyQt6.QtCore import Qt, QPointF, QRectF, QLineF, pyqtSignal
from PyQt6.QtGui import QPen, QColor, QPainterPath, QFont, QPainter
[docs]
class CustomGraphicsView(QGraphicsView):
[docs]
def __init__(self, scene):
super().__init__(scene)
self.setRenderHint(QPainter.RenderHint.Antialiasing) # Use QPainter.RenderHint.Antialiasing
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) # Activate scroll drag mode
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) # Anchor zoom to mouse position
# Automatically fit the scene when initializing
self.fit_to_scene()
[docs]
def fit_to_scene(self):
"""
Fits the entire scene into the view, considering the current window size.
"""
# Use fitInView to scale the scene so that it fits entirely within the view
self.fitInView(self.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
[docs]
def resizeEvent(self, event):
"""
Ensures the scene fits into the view whenever the window is resized.
:param event: The resize event
:type event: QResizeEvent
"""
super().resizeEvent(event)
self.fit_to_scene() # Fit scene after resizing
[docs]
def wheelEvent(self, event):
"""
Handles zooming with mouse wheel.
:param event: The wheel event
:type event: QWheelEvent
"""
zoom_factor = 1.1 # Zoom factor for each wheel step
if event.angleDelta().y() > 0: # Zoom in
self.scale(zoom_factor, zoom_factor)
else: # Zoom out
self.scale(1 / zoom_factor, 1 / zoom_factor)
[docs]
def mousePressEvent(self, event):
"""
Activates panning on middle mouse button press.
:param event: The mouse press event
:type event: QMouseEvent
"""
if event.button() == Qt.MouseButton.MiddleButton:
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) # Allow panning with middle mouse button
super().mousePressEvent(event)
[docs]
def mouseReleaseEvent(self, event):
"""
Deactivates panning when middle mouse button is released.
:param event: The mouse release event
:type event: QMouseEvent
"""
if event.button() == Qt.MouseButton.MiddleButton:
self.setDragMode(QGraphicsView.DragMode.NoDrag) # Stop panning
super().mouseReleaseEvent(event)
[docs]
class CustomGraphicsScene(QGraphicsScene):
# Signal to emit mouse position updates
mouse_position_changed = pyqtSignal(float, float)
[docs]
def __init__(self, x, y, width, height, parent=None):
super().__init__(x, y, width, height, parent)
self.setSceneRect(x, y, width, height)
[docs]
def mouseMoveEvent(self, event):
"""
Handles mouse move events in the scene.
:param event: The mouse move event
:type event: QMouseEvent
"""
mouse_position = event.scenePos() # Get mouse position relative to the scene
x = mouse_position.x()
y = mouse_position.y()
self.mouse_position_changed.emit(x, y) # Emit signal with the new coordinates
super().mouseMoveEvent(event)
[docs]
class SchematicScene(CustomGraphicsScene):
GRID_SIZE = 1 # Define grid size for snapping
GENERATOR_SPACING = 100
GENERATOR_SPACING_STORAGE = 100 # Spacing between generator and storage
LINE_Y_OFFSET_GENERATOR = 100
GENERATOR_X_START = 20
LINE_THICKNESS = 3
FLOW_LINE_COLOR = Qt.GlobalColor.red
RETURN_LINE_COLOR = Qt.GlobalColor.blue
TEXT_FONT = QFont("Arial", 12, QFont.Weight.Bold)
# Define the objects that can be added to the scene
# Color codes: yellow for Solar, blue for CHP, purple for Storage, green for Consumer
# Sizes: Solar (circle), CHP (rectangle), Storage (cylinder), Consumer (rectangle)
OBJECTS = {
'Solar': {
'color': QColor('yellow'),
'geometry': QRectF(-20, -20, 40, 40), # Circle dimensions for Solar
'shape': 'circle', # Define the shape type
'counter': 0 # Counter for Solar
},
'CHP': {
'color': QColor('blue'),
'geometry': QRectF(-30, -15, 60, 30), # Rectangle dimensions for CHP
'shape': 'rect', # Shape as rectangle
'counter': 0 # Counter for CHP
},
'Wood-CHP': {
'color': QColor('brown'),
'geometry': QRectF(-30, -20, 60, 40), # Larger square to differentiate from other CHP
'shape': 'rect', # Shape as rectangle
'counter': 0 # Counter for Wood-CHP
},
'Biomass Boiler': {
'color': QColor('darkgreen'),
'geometry': QRectF(-25, -20, 50, 40), # Taller rectangle for biomass boiler
'shape': 'rect', # Shape as rectangle
'counter': 0 # Counter for Biomass Boiler
},
'Gas Boiler': {
'color': QColor('gray'),
'geometry': QRectF(-25, -25, 50, 50), # Narrower and shorter rectangle for gas boiler
'shape': 'rect', # Shape as rectangle
'counter': 0 # Counter for Gas Boiler
},
'Power-to-Heat': {
'color': QColor('orange'),
'geometry': QRectF(-25, -25, 50, 50), # Square to represent power-to-heat
'shape': 'rect', # Shape as rectangle
'counter': 0 # Counter for Power-to-Heat
},
'Geothermal Heat Pump': {
'color': QColor('blueviolet'),
'geometry': QRectF(-25, -25, 50, 50), # Square to represent geothermal heat pump
'shape': 'rect', # Shape as rectangle
'counter': 0 # Counter for Geothermal Heat Pump
},
'River Heat Pump': {
'color': QColor('deepskyblue'),
'geometry': QRectF(-25, -25, 50, 50), # Larger square for river heat pump
'shape': 'rect', # Shape as rectangle
'counter': 0 # Counter for River Heat Pump
},
'Waste Heat Pump': {
'color': QColor('orange'),
'geometry': QRectF(-25, -25, 50, 50), # Rectangle for waste heat pump
'shape': 'rect', # Shape as rectangle
'counter': 0 # Counter for Waste Heat Pump
},
'Aqva Heat Pump': {
'color': QColor('cyan'),
'geometry': QRectF(-25, -25, 50, 50), # Square for Aqva heat pump
'shape': 'rect', # Shape as rectangle
'counter': 0 # Counter for Aqva Heat Pump
},
'Storage': {
'color': QColor('purple'),
'geometry': QRectF(-20, -40, 40, 80), # Cylindrical shape for storage
'shape': 'rect', # Use rectangle to represent a cylinder
'counter': 0 # Counter for Storage
},
'Consumer': {
'color': QColor('green'),
'geometry': QRectF(-30, -15, 60, 30), # Rectangle dimensions for Consumer
'shape': 'rect', # Shape as rectangle
'counter': 0 # Counter for Consumer
},
'Seasonal Thermal Storage': {
'color': QColor('darkorange'),
'geometry': QRectF(-30, -20, 60, 40), # Rectangle dimensions for seasonal storage
'shape': 'trapezoid', # Shape as trapezoid to symbolize an earth basin
'counter': 0 # Counter for seasonal storage
},
}
[docs]
def __init__(self, width, height, parent=None):
super().__init__(0, 0, width, height, parent)
self.setSceneRect(0, 0, width, height)
self.setBackgroundBrush(QColor(240, 240, 240)) # Light gray background
self.generators = []
self.storage_units = []
self.pipes = []
self.consumer = None
# Separate counters for each generator type
self.solar_counter = 0
self.chp_counter = 0
self.storage_counter = 0
self.generator_y = height / 2
self.selected_item = None # Track selected item
# Add the consumer first but don't draw the parallel lines yet
self.add_consumer_net('Consumer', connect_to_lines=False)
# Parallel lines are initialized without extending them (no generators yet)
self.vorlauf_line = None
self.ruecklauf_line = None
#self.consumer.create_parallel_lines()
# Verbinde das selectionChanged-Signal mit einer Methode zum Aktualisieren
self.selectionChanged.connect(self.update_selected_item)
[docs]
def update_scene_size(self):
"""
Updates the scene size dynamically based on the positions of all items.
"""
if not self.items():
return
# Berechne die Bounding Box für alle Objekte in der Szene
bounding_rect = self.itemsBoundingRect()
# Erweiterung der Bounding Box, falls nötig (z.B. Puffer für die Ränder)
margin = 25 # Margin in Pixeln, um etwas Platz um die Objekte zu lassen
bounding_rect.adjust(-margin, -margin, margin, margin)
# Setze die neue Szenegröße basierend auf der Bounding Box
self.setSceneRect(bounding_rect)
[docs]
def update_selected_item(self):
"""
Updates the selected object when the selection in the scene changes.
"""
# Hole alle ausgewählten Objekte (es könnte theoretisch mehr als eines sein)
selected_items = self.selectedItems()
if selected_items:
# Falls es ein ausgewähltes Objekt gibt, speichere das erste davon
self.selected_item = selected_items[0] # Speichere das ausgewählte Objekt
else:
# Falls kein Objekt ausgewählt ist, setze selected_item auf None
self.selected_item = None
[docs]
def snap_to_grid(self, position):
"""
Snaps the given position to the nearest grid point.
:param position: The position to snap
:type position: QPointF
:return: The snapped position
:rtype: QPointF
"""
x = round(position.x() / self.GRID_SIZE) * self.GRID_SIZE
y = round(position.y() / self.GRID_SIZE) * self.GRID_SIZE
return QPointF(x, y)
[docs]
def create_parallel_lines(self):
"""
Creates or updates the parallel Vorlauf (red) and Rücklauf (blue) lines and adds labels.
"""
# Entferne bestehende Leitungen und Labels
if hasattr(self, 'vorlauf_line') and self.vorlauf_line:
self.removeItem(self.vorlauf_line)
if hasattr(self, 'ruecklauf_line') and self.ruecklauf_line:
self.removeItem(self.ruecklauf_line)
# Entferne bestehende Labels
for item in self.items():
if isinstance(item, QGraphicsTextItem) and item.toPlainText() in ["Vorlauf", "Rücklauf"]:
self.removeItem(item)
# Überprüfe, ob Erzeuger oder Speicher vorhanden sind
generators_and_storage = [item for item in self.items() if isinstance(item, ComponentItem) and item != self.consumer]
if not generators_and_storage:
# Keine Erzeuger oder Speicher vorhanden, also keine Leitungen zeichnen
return
# Finde die Position des letzten Erzeugers oder Speichers
last_item_x = max(item.pos().x() for item in generators_and_storage)
# Zeichne die Leitungen vom Consumer bis zum letzten Erzeuger oder Speicher
start_x = self.consumer.pos().x()
end_x = last_item_x
# Zeichne Vorlauf (rote Linie)
self.vorlauf_line = QGraphicsLineItem(start_x, self.generator_y - self.LINE_Y_OFFSET_GENERATOR, end_x, self.generator_y - self.LINE_Y_OFFSET_GENERATOR)
self.vorlauf_line.setPen(QPen(self.FLOW_LINE_COLOR, self.LINE_THICKNESS))
self.addItem(self.vorlauf_line)
# Zeichne Rücklauf (blaue Linie)
self.ruecklauf_line = QGraphicsLineItem(start_x, self.generator_y + self.LINE_Y_OFFSET_GENERATOR, end_x, self.generator_y + self.LINE_Y_OFFSET_GENERATOR)
self.ruecklauf_line.setPen(QPen(self.RETURN_LINE_COLOR, self.LINE_THICKNESS))
self.addItem(self.ruecklauf_line)
# Füge Labels hinzu, die zwischen Consumer und letztem Erzeuger/Speicher zentriert sind
self.update_parallel_labels(start_x, end_x)
# Nach dem Erstellen der Leitungen aktualisiere die Pipes, die mit den Leitungen verbunden sind
self.update_pipes_connected_to_lines()
# Consumer mit den Leitungen verbinden, falls das noch nicht geschehen ist
self.connect_items_to_lines(self.consumer)
[docs]
def update_parallel_labels(self, start_x, end_x):
"""
Updates the labels for the Vorlauf and Rücklauf lines dynamically.
:param start_x: The start x-coordinate
:type start_x: float
:param end_x: The end x-coordinate
:type end_x: float
"""
# Berechne die Mitte zwischen Start und Ende
scene_width = end_x - start_x
center_x = start_x + scene_width / 2
# Definiere gut lesbare Textfarbe (dunkelgrau statt rot/blau)
label_text_color = QColor(60, 60, 60) # Dunkelgrau für bessere Lesbarkeit
# Füge Label für Vorlauf (oberhalb der roten Linie) hinzu
vorlauf_label = self.addText("Vorlauf", self.TEXT_FONT)
vorlauf_label.setDefaultTextColor(label_text_color)
vorlauf_label.setPos(center_x - vorlauf_label.boundingRect().width() / 2, self.generator_y - self.LINE_Y_OFFSET_GENERATOR - 30)
# Füge Label für Rücklauf (unterhalb der blauen Linie) hinzu
ruecklauf_label = self.addText("Rücklauf", self.TEXT_FONT)
ruecklauf_label.setDefaultTextColor(label_text_color)
ruecklauf_label.setPos(center_x - ruecklauf_label.boundingRect().width() / 2, self.generator_y + self.LINE_Y_OFFSET_GENERATOR + 10)
[docs]
def update_pipes_connected_to_lines(self):
"""
Updates pipes that are connected to the parallel lines.
"""
for pipe in self.pipes:
if not isinstance(pipe.point1, ConnectionPoint) or not isinstance(pipe.point2, ConnectionPoint):
# Pipe ist mit einer Linie verbunden
pipe.update_path()
[docs]
def add_generator(self, item_type, item_name, connect_to_lines=True):
"""
Adds a generator at a fixed position and optionally connects it to the parallel lines.
:param item_type: Type of the generator (e.g., 'CHP', 'Solar')
:type item_type: str
:param item_name: Unique name for the generator (e.g., 'BHKW_1')
:type item_name: str
:param connect_to_lines: Whether to connect to the parallel lines
:type connect_to_lines: bool
:return: The created generator component
:rtype: ComponentItem
"""
# Define the generator position based on the current x-position
position = QPointF(self.GENERATOR_X_START, self.generator_y)
position = self.snap_to_grid(position)
item_color = self.OBJECTS[item_type]['color'] # Color of the generator
item_geometry = self.OBJECTS[item_type]['geometry'] # Geometry of the generator
self.OBJECTS[item_type]['counter'] += 1 # Increment the counter for the generator type
item_counter = self.OBJECTS[item_type]['counter'] # Get the current count for the generator
# Create and add the generator
generator = ComponentItem(position, item_type, item_name, item_color, item_geometry, self.FLOW_LINE_COLOR, self.RETURN_LINE_COLOR)
generator.create_connection_points() # Create connection points
self.addItem(generator)
self.update_label(generator, item_name)
# Aktualisiere die parallelen Leitungen
self.create_parallel_lines()
if connect_to_lines:
self.connect_items_to_lines(generator)
self.GENERATOR_X_START += self.GENERATOR_SPACING # Shift position for the next generator
# Update the scene size after adding the generator
self.update_scene_size()
# Update all label positions to avoid collisions after adding components
self.update_all_label_positions()
return generator
[docs]
def add_storage(self, position, item_type='Storage', item_name='Speicher'):
"""
Helper function to create and add a storage unit with custom geometry.
:param position: The position for the storage
:type position: QPointF
:param item_type: Type of storage
:type item_type: str
:param item_name: Name of the storage
:type item_name: str
:return: The created storage component
:rtype: ComponentItem
"""
position = self.snap_to_grid(position)
item_color = self.OBJECTS[item_type]['color'] # Color of the storage
item_geometry = self.OBJECTS[item_type]['geometry'] # Geometry of the storage
self.OBJECTS[item_type]['counter'] += 1 # Increment the counter for the storage
item_counter = self.OBJECTS[item_type]['counter'] # Get the current count for the storage
storage = ComponentItem(position, item_type, item_name, item_color, item_geometry, self.FLOW_LINE_COLOR, self.RETURN_LINE_COLOR)
storage.create_connection_points()
self.addItem(storage)
label_text = f'{item_name} {item_counter}' # Add the counter to the label
self.update_label(storage, label_text)
self.GENERATOR_X_START += self.GENERATOR_SPACING
# Update the scene size after adding the generator
self.update_scene_size()
return storage
[docs]
def add_generator_with_storage(self, item_name, name):
"""
Adds a generator and a storage unit, connecting them and the storage to the consumer.
:param item_name: Type of the generator
:type item_name: str
:param name: Unique name for the generator
:type name: str
:return: The created generator component
:rtype: ComponentItem
"""
# Add the generator but don't connect it to the lines
generator = self.add_generator(item_name, name, connect_to_lines=False)
# Add the storage to the right of the generator
storage_position = QPointF(generator.pos().x() + self.GENERATOR_SPACING_STORAGE, generator.pos().y()) # Fixed distance of 100 units to the right
storage = self.add_storage(storage_position)
# Connect generator to storage and storage to consumer
self.connect_generator_to_storage(generator, storage)
# Aktualisiere die parallelen Leitungen
self.create_parallel_lines()
# Connect the storage to the parallel lines
self.connect_items_to_lines(storage, is_storage=True)
# Update the scene size after adding the generator
self.update_scene_size()
# Update all label positions to avoid collisions after adding components
self.update_all_label_positions()
return generator
[docs]
def add_consumer_net(self, item_type, item_name="Wärmenetz", connect_to_lines=False):
"""
Adds the consumer (network).
:param item_type: Type of consumer
:type item_type: str
:param item_name: Name of the consumer
:type item_name: str
:param connect_to_lines: Whether to connect to lines
:type connect_to_lines: bool
"""
if self.consumer is None:
position = QPointF(self.GENERATOR_X_START, self.generator_y) # Place consumer at Start_x
position = self.snap_to_grid(position) # Snap the position to the grid
item_color = self.OBJECTS[item_type]['color'] # Color of the consumer
item_geometry = self.OBJECTS[item_type]['geometry'] # Geometry of the consumer
self.consumer = ComponentItem(position, item_type, item_name, item_color, item_geometry, self.FLOW_LINE_COLOR, self.RETURN_LINE_COLOR)
self.consumer.create_connection_points() # Create connection points
self.addItem(self.consumer)
self.update_label(self.consumer, item_name) # Update the label for the consumer
# Deaktiviere Bewegung und Auswahl für den Consumer
self.consumer.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
self.consumer.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
# Aktualisiere die Position für den nächsten Generator (platziere ihn rechts)
self.GENERATOR_X_START += self.GENERATOR_SPACING # Verschiebe um eine feste Distanz
# Update the scene size after adding the generator
self.update_scene_size()
[docs]
def add_seasonal_storage(self, item_type='Saisonaler Wärmespeicher', item_name='Speicher', connect_to_lines=True):
"""
Adds a seasonal storage unit at a fixed position and optionally connects it to the parallel lines.
:param item_type: Type of seasonal storage
:type item_type: str
:param item_name: Name of the storage
:type item_name: str
:param connect_to_lines: Whether to connect to the parallel lines
:type connect_to_lines: bool
:return: The created storage component
:rtype: ComponentItem
"""
# Define the storage position based on the current x-position
position = QPointF(self.GENERATOR_X_START, self.generator_y)
position = self.snap_to_grid(position)
item_color = self.OBJECTS[item_type]['color'] # Color of the storage
item_geometry = self.OBJECTS[item_type]['geometry'] # Geometry of the storage
self.OBJECTS[item_type]['counter'] += 1 # Increment the counter for the storage type
item_counter = self.OBJECTS[item_type]['counter'] # Get the current count for the storage
# Create and add the storage
storage = ComponentItem(position, item_type, item_name, item_color, item_geometry, self.FLOW_LINE_COLOR, self.RETURN_LINE_COLOR)
storage.create_connection_points() # Create connection points
self.addItem(storage)
self.update_label(storage, f"{item_name} {item_counter}")
# Update the parallel lines
self.create_parallel_lines()
if connect_to_lines:
self.connect_items_to_lines(storage)
self.GENERATOR_X_START += self.GENERATOR_SPACING # Shift position for the next component
# Update the scene size after adding the storage
self.update_scene_size()
return storage
[docs]
def check_label_collision(self, new_label_rect):
"""
Checks if a new label would collide with existing component labels.
:param new_label_rect: Rectangle of the new label
:type new_label_rect: QRectF
:return: True if collision detected, False otherwise
:rtype: bool
"""
for item in self.items():
if isinstance(item, ComponentItem) and item.label and item.label.isVisible():
existing_rect = item.label.sceneBoundingRect()
if new_label_rect.intersects(existing_rect):
return True
return False
[docs]
def find_optimal_label_position(self, item, label):
"""
Finds optimal position for label to avoid collisions.
:param item: The component item
:type item: ComponentItem
:param label: The label to position
:type label: QGraphicsTextItem
:return: The optimal position
:rtype: QPointF
"""
padding = 12
base_y_below = item.pos().y() + item.boundingRect().height() + padding
base_y_above = item.pos().y() - item.boundingRect().height() - padding - label.boundingRect().height()
# Try different Y offsets to find non-colliding position
for y_offset in range(0, 60, 15): # Try offsets up to 60px in 15px steps
# Try below first (preferred for non-storage items)
if item.item_type != 'Storage':
test_y = base_y_below + y_offset
test_x = item.pos().x() - label.boundingRect().width() / 2
test_rect = QRectF(test_x, test_y, label.boundingRect().width(), label.boundingRect().height())
if not self.check_label_collision(test_rect):
return QPointF(test_x, test_y)
# Try above as fallback
test_y = base_y_above - y_offset
test_rect = QRectF(test_x, test_y, label.boundingRect().width(), label.boundingRect().height())
if not self.check_label_collision(test_rect):
return QPointF(test_x, test_y)
else:
# For storage items, try above first
test_y = base_y_above - y_offset
test_x = item.pos().x() - label.boundingRect().width() / 2
test_rect = QRectF(test_x, test_y, label.boundingRect().width(), label.boundingRect().height())
if not self.check_label_collision(test_rect):
return QPointF(test_x, test_y)
# Try below as fallback
test_y = base_y_below + y_offset
test_rect = QRectF(test_x, test_y, label.boundingRect().width(), label.boundingRect().height())
if not self.check_label_collision(test_rect):
return QPointF(test_x, test_y)
# If no collision-free position found, return default position
if item.item_type == 'Storage':
return QPointF(item.pos().x() - label.boundingRect().width() / 2, base_y_above)
else:
return QPointF(item.pos().x() - label.boundingRect().width() / 2, base_y_below)
[docs]
def update_all_label_positions(self):
"""
Updates positions of all component labels to avoid collisions.
"""
components = [item for item in self.items() if isinstance(item, ComponentItem) and item.label]
# Sort components by X position to process from left to right
components.sort(key=lambda c: c.pos().x())
for component in components:
if component.label:
optimal_position = self.find_optimal_label_position(component, component.label)
component.label.setPos(optimal_position)
# Update background rect if it exists
if hasattr(component, 'background_rect') and component.background_rect:
padding = 12
label_rect = component.label.boundingRect()
scene_label_pos = component.label.scenePos()
background_rect_x = scene_label_pos.x() - padding / 2
background_rect_y = scene_label_pos.y() - padding / 2
background_rect_width = label_rect.width() + padding
background_rect_height = label_rect.height() + padding
component.background_rect.setRect(background_rect_x, background_rect_y, background_rect_width, background_rect_height)
[docs]
def update_label(self, item, new_text):
"""
Updates the label of a given item with new text.
:param item: The component item
:type item: ComponentItem
:param new_text: The new label text
:type new_text: str
"""
if item.label:
# Update the text of the label
item.label.setPlainText(new_text)
else:
# If no label exists, create a new one
label = self.addText(new_text, self.TEXT_FONT)
# Setze eine gut lesbare dunkle Textfarbe
label.setDefaultTextColor(QColor(50, 50, 50)) # Dunkelgrau für bessere Lesbarkeit
item.label = label # Link the label to the item
# Ensure the label is always on top
item.label.setZValue(10) # Make sure label is displayed above everything else
# Find optimal position to avoid collisions
optimal_position = self.find_optimal_label_position(item, item.label)
item.label.setPos(optimal_position) # Set the optimized label position
# Add padding for background calculation
padding = 12
# Get the updated bounding rect of the label
label_rect = item.label.boundingRect()
# Calculate the absolute scene position for the background rect
scene_label_pos = item.label.scenePos()
background_rect_x = scene_label_pos.x() - padding / 2
background_rect_y = scene_label_pos.y() - padding / 2
background_rect_width = label_rect.width() + padding
background_rect_height = label_rect.height() + padding
# Optionally: Set background color for the label (pseudo background using rect)
if hasattr(item, 'background_rect') and item.background_rect: # Check if background_rect exists
item.background_rect.setRect(background_rect_x, background_rect_y, background_rect_width, background_rect_height)
else:
# Erstelle ein kontrastreiches Rechteck um das Label
background_rect = QGraphicsRectItem(background_rect_x, background_rect_y, background_rect_width, background_rect_height)
# Verwende hellgrauen Hintergrund mit dunklem Rand für bessere Lesbarkeit
background_color = QColor(240, 240, 240, 220) # Hellgrau mit hoher Deckkraft
border_color = QColor(100, 100, 100) # Dunkler Rahmen für besseren Kontrast
background_rect.setBrush(background_color) # Setze die kontrastreichere Farbe
background_rect.setPen(QPen(border_color, 1)) # Dünner dunkler Rahmen
background_rect.setZValue(9) # Leicht unterhalb des Labels
self.addItem(background_rect) # Add the background to the scene
item.background_rect = background_rect # Link it to the item
[docs]
def connect_generator_to_storage(self, generator, storage):
"""
Connects two items (generator, storage, or consumer) using their connection points.
:param generator: The generator component
:type generator: ComponentItem
:param storage: The storage component
:type storage: ComponentItem
"""
if generator.connection_points and storage.connection_points:
# Erzeuger oder Verbraucher: Verwende rechte Verbindung für Vorlauf und linke für Rücklauf
point1_supply = generator.connection_points[0] # 0: Obere Verbindung für Vorlauf
point1_return = generator.connection_points[1] # 1: Untere Verbindung für Rücklauf
# Speicher: Obere Verbindung für Vorlauf und untere für Rücklauf
point2_supply = storage.connection_points[0] # Obere linke Verbindung für Vorlauf zum Speicher
point2_return = storage.connection_points[2] # Untere linke Verbindung für Rücklauf zum Speicher
# Red supply pipe (Vorlauf)
supply_pipe = Pipe(point1_supply, point2_supply, self.FLOW_LINE_COLOR, self.LINE_THICKNESS)
self.addItem(supply_pipe)
self.pipes.append(supply_pipe)
# Blue return pipe (Rücklauf)
return_pipe = Pipe(point1_return, point2_return, self.RETURN_LINE_COLOR, self.LINE_THICKNESS)
self.addItem(return_pipe)
self.pipes.append(return_pipe)
else:
print("Error: One or both items have no connection points.")
[docs]
def connect_items_to_lines(self, component, is_storage=False):
"""
Connects a component to the parallel Vorlauf (red) and Rücklauf (blue) lines.
:param component: The component to connect
:type component: ComponentItem
:param is_storage: Whether the component is a storage unit
:type is_storage: bool
"""
if not hasattr(self, 'vorlauf_line') or not self.vorlauf_line:
return # Leitungen existieren nicht, können keine Verbindung herstellen
# Vorlauf connection (red line) to top of component
if not is_storage:
# Connect the top (supply) of the generator to the Vorlauf (red line)
point_supply = component.connection_points[0] # Top connection point
line_point_vorlauf = QPointF(point_supply.scenePos().x(), self.vorlauf_line.line().y1()) # Connect vertically to Vorlauf line
supply_pipe = Pipe(point_supply, line_point_vorlauf, self.FLOW_LINE_COLOR, self.LINE_THICKNESS)
self.addItem(supply_pipe)
self.pipes.append(supply_pipe)
# Rücklauf connection (blue line) to bottom of component
# Storage items should always connect to Rücklauf line even if is_storage is True
point_return = component.connection_points[1] # Bottom connection point
line_point_ruecklauf = QPointF(point_return.scenePos().x(), self.ruecklauf_line.line().y1()) # Connect vertically to Rücklauf line
return_pipe = Pipe(point_return, line_point_ruecklauf, self.RETURN_LINE_COLOR, self.LINE_THICKNESS)
self.addItem(return_pipe)
self.pipes.append(return_pipe)
if is_storage:
# For storage, connect both the top (Vorlauf) and bottom (Rücklauf) to the parallel lines
point_storage_supply = component.connection_points[1] # Additional top supply point for storage
line_point_vorlauf_storage = QPointF(point_storage_supply.scenePos().x(), self.vorlauf_line.line().y1()) # Connect vertically to Vorlauf line
storage_supply_pipe = Pipe(point_storage_supply, line_point_vorlauf_storage, self.FLOW_LINE_COLOR, self.LINE_THICKNESS)
self.addItem(storage_supply_pipe)
self.pipes.append(storage_supply_pipe)
# For storage, connect both the top (Vorlauf) and bottom (Rücklauf) to the parallel lines
point_storage_supply = component.connection_points[3] # Additional top supply point for storage
line_point_vorlauf_storage = QPointF(point_storage_supply.scenePos().x(), self.ruecklauf_line.line().y1()) # Connect vertically to Vorlauf line
storage_supply_pipe = Pipe(point_storage_supply, line_point_vorlauf_storage, self.RETURN_LINE_COLOR, self.LINE_THICKNESS)
self.addItem(storage_supply_pipe)
self.pipes.append(storage_supply_pipe)
[docs]
def add_component(self, item_name, name, storage=False):
"""
Adds a component (generator or storage) to the scene.
:param item_name: Type of the component (e.g., 'CHP', 'Solar')
:type item_name: str
:param name: Unique name for the component (e.g., 'BHKW_1')
:type name: str
:param storage: If True, add a storage with the component
:type storage: bool
"""
if storage:
return self.add_generator_with_storage(item_name, name)
else:
if item_name == 'Consumer':
return self.add_consumer_net(name)
if item_name == 'Saisonaler Wärmespeicher':
return self.add_seasonal_storage(item_name, name)
else:
return self.add_generator(item_name, name)
[docs]
def delete_selected(self):
"""
Deletes the selected component, ensuring that connected generators and storage are deleted together.
"""
if self.selected_item and isinstance(self.selected_item, ComponentItem) and self.selected_item != self.consumer:
# Erstelle eine Liste mit Generatoren und zugehörigen Speichern
generators_with_storage = []
generators_without_storage = []
# Durchlaufe alle Items in der Szene und sortiere sie in die entsprechenden Listen
for item in self.items():
if isinstance(item, ComponentItem) and item.item_type != 'Storage' and item != self.consumer:
linked_storage = self.find_linked_storage(item)
if linked_storage:
generators_with_storage.append((item, linked_storage))
else:
generators_without_storage.append(item)
# Prüfe, ob der ausgewählte Item ein Speicher ist und lösche auch den zugehörigen Generator
if self.selected_item.item_type == 'Storage':
# Finde den Generator, der mit dem Speicher verbunden ist
linked_generator = self.find_linked_generator(self.selected_item)
if linked_generator:
generators_with_storage = [(gen, storage) for gen, storage in generators_with_storage if gen != linked_generator and storage != self.selected_item]
else:
generators_with_storage = [(gen, storage) for gen, storage in generators_with_storage if storage != self.selected_item]
else:
# Der ausgewählte Item ist ein Generator, lösche auch den zugehörigen Speicher
linked_storage = self.find_linked_storage(self.selected_item)
if linked_storage:
generators_with_storage = [(gen, storage) for gen, storage in generators_with_storage if gen != self.selected_item and storage != linked_storage]
else:
generators_without_storage = [item for item in generators_without_storage if item != self.selected_item]
# Lösche alles außer dem Consumer
self.delete_all()
# Füge die Generatoren und Speicher in der ursprünglichen Reihenfolge wieder hinzu
for generator, storage in sorted(generators_with_storage, key=lambda i: i[0].pos().x()):
print(generator)
self.add_generator_with_storage(generator.item_type, generator.item_name)
for generator in sorted(generators_without_storage, key=lambda i: i.pos().x()):
self.add_generator(generator.item_type, generator.item_name)
# Setze das ausgewählte Item zurück
self.selected_item = None
self.update_scene_size()
[docs]
def delete_all(self):
"""
Deletes all components, pipes, and resets all counters except for the consumer and its connections.
"""
# Collect all items except for the consumer and the pipes connected to the consumer
items_to_delete = [
item for item in self.items()
if isinstance(item, (ComponentItem, Pipe))
and (item != self.consumer and not self.is_connected_to_consumer(item))
]
# Delete all selected items
for item in items_to_delete:
if isinstance(item, ComponentItem):
if item.label:
self.removeItem(item.label) # Delete the label
if hasattr(item, 'background_rect') and item.background_rect:
self.removeItem(item.background_rect) # Delete the background rect
self.removeItem(item)
# Reset the counters for all object types
for key in self.OBJECTS.keys():
self.OBJECTS[key]['counter'] = 0 # Reset counters
# Keep the consumer and its connections intact, and reset the x position for generators
if self.consumer:
self.GENERATOR_X_START = self.consumer.pos().x() + self.GENERATOR_SPACING # Start next to the consumer
# Da keine Erzeuger mehr vorhanden sind, entferne die parallelen Leitungen
self.create_parallel_lines()
self.selected_item = None # Reset the selected item
self.update_scene_size()
[docs]
def is_connected_to_consumer(self, item):
"""
Helper method to check if a pipe is connected to the consumer.
:param item: The item to check
:type item: Pipe
:return: True if connected to consumer, False otherwise
:rtype: bool
"""
if isinstance(item, Pipe):
point1_connected = isinstance(item.point1, ConnectionPoint) and item.point1.parent == self.consumer
point2_connected = isinstance(item.point2, ConnectionPoint) and item.point2.parent == self.consumer
return point1_connected or point2_connected
return False
[docs]
def find_linked_generator(self, storage):
"""
Finds the generator linked to the given storage unit.
:param storage: The storage component
:type storage: ComponentItem
:return: The linked generator or None
:rtype: ComponentItem or None
"""
for pipe in self.items():
if isinstance(pipe, Pipe):
# Prüfen, ob pipe.point1 ein ConnectionPoint ist und mit dem Speicher verbunden ist
if isinstance(pipe.point1, ConnectionPoint) and pipe.point1.parent == storage:
if isinstance(pipe.point2, ConnectionPoint) and isinstance(pipe.point2.parent, ComponentItem):
return pipe.point2.parent # Rückgabe des Generators
# Prüfen, ob pipe.point2 ein ConnectionPoint ist und mit dem Speicher verbunden ist
if isinstance(pipe.point2, ConnectionPoint) and pipe.point2.parent == storage:
if isinstance(pipe.point1, ConnectionPoint) and isinstance(pipe.point1.parent, ComponentItem):
return pipe.point1.parent # Rückgabe des Generators
return None
[docs]
def find_linked_storage(self, generator):
"""
Finds the storage unit linked to the given generator.
:param generator: The generator component
:type generator: ComponentItem
:return: The linked storage or None
:rtype: ComponentItem or None
"""
for pipe in self.items():
if isinstance(pipe, Pipe):
# Prüfen, ob pipe.point1 ein ConnectionPoint ist und mit dem Generator verbunden ist
if isinstance(pipe.point1, ConnectionPoint) and pipe.point1.parent == generator:
if isinstance(pipe.point2, ConnectionPoint) and pipe.point2.parent.item_type == 'Storage':
return pipe.point2.parent # Rückgabe des Speichers
# Prüfen, ob pipe.point2 ein ConnectionPoint ist und mit dem Generator verbunden ist
if isinstance(pipe.point2, ConnectionPoint) and pipe.point2.parent == generator:
if isinstance(pipe.point1, ConnectionPoint) and pipe.point1.parent.item_type == 'Storage':
return pipe.point1.parent # Rückgabe des Speichers
return None
[docs]
class ComponentItem(QGraphicsItem):
[docs]
def __init__(self, position, item_type, item_name, color, geometry, flow_line_color=Qt.GlobalColor.red, return_line_color=Qt.GlobalColor.blue):
"""
Creates a general visual representation of a component.
:param position: The position of the component
:type position: QPointF
:param item_type: Type of component
:type item_type: str
:param item_name: Name of the component
:type item_name: str
:param color: Color of the component
:type color: QColor
:param geometry: Geometry of the component
:type geometry: QRectF
:param flow_line_color: Color for flow line
:type flow_line_color: Qt.GlobalColor
:param return_line_color: Color for return line
:type return_line_color: Qt.GlobalColor
"""
super().__init__()
self.item_name = item_name
self.color = color
self.item_type = item_type
self.geometry = geometry # The geometry is now passed in from the scene
self.flow_line_color = flow_line_color
self.return_line_color = return_line_color
self.shape = SchematicScene.OBJECTS[item_type]['shape'] # Get the shape type from OBJECTS
self.setPos(position)
# Set the flags for interaction
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
# Placeholder for connection points (ports)
self.connection_points = []
self.label = None # Placeholder for label reference
[docs]
def boundingRect(self):
"""Return the predefined geometry passed in during creation"""
return self.geometry
[docs]
def paint(self, painter, option, widget=None):
"""Draw the item with the specified shape and color."""
painter.setBrush(self.color)
# Überprüfe, ob das Objekt ausgewählt ist
if self.isSelected():
# Wenn das Objekt ausgewählt ist, zeichne den Rahmen dicker und in einer auffälligen Farbe
painter.setPen(QPen(Qt.GlobalColor.red, 3)) # Roter, dicker Rahmen für ausgewähltes Objekt
else:
# Normaler schwarzer Rahmen für nicht ausgewählte Objekte
painter.setPen(QPen(Qt.GlobalColor.black, 1)) # Dünner schwarzer Rahmen
# Drawing based on the shape type
if self.shape == 'circle':
painter.drawEllipse(self.boundingRect())
elif self.shape == 'rect':
painter.drawRect(self.boundingRect())
elif self.shape == 'ellipse':
painter.drawEllipse(self.boundingRect()) # You can add more custom shapes here
else:
painter.drawRect(self.boundingRect()) # Fallback: draw rectangle by default
[docs]
def create_connection_points(self):
"""Create connection points (ports) for the item based on the shape."""
# Add connection points based on the item type (generator, storage, consumer)
if self.item_type == 'Storage':
# Storage has 4 connection points: top-left, top-right, bottom-left, bottom-right
self.connection_points.append(self.create_connection_point(0, 0.1, 'left', self.flow_line_color)) # Top-left
self.connection_points.append(self.create_connection_point(1, 0.1, 'right', self.flow_line_color)) # Top-right
self.connection_points.append(self.create_connection_point(0, 0.9, 'left', self.return_line_color)) # Bottom-left
self.connection_points.append(self.create_connection_point(1, 0.9, 'right', self.return_line_color)) # Bottom-right
else:
# Generators and consumers have 2 connection points: top (supply) and bottom (return)
self.connection_points.append(self.create_connection_point(0.5, 0, 'up', self.flow_line_color)) # Top (middle)
self.connection_points.append(self.create_connection_point(0.5, 1, 'down', self.return_line_color)) # Bottom (middle)
[docs]
def create_connection_point(self, x_offset, y_offset, direction, color):
"""Helper method to create a connection point at a relative position based on the bounding rectangle."""
point = ConnectionPoint(self, x_offset, y_offset, direction, color)
if self.scene(): # Only add if scene exists
self.scene().addItem(point)
return point
[docs]
def itemChange(self, change, value):
"""Update connected pipes, label, and background when the component moves."""
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
# Call snap_to_grid on the scene to adjust the movement to the grid
scene = self.scene()
if isinstance(scene, SchematicScene):
value = scene.snap_to_grid(value) # Snap to grid when moved
if self.scene(): # Check if the item is in a scene
# Aktualisiere alle Pipes, die mit dem Item verbunden sind
for pipe in self.scene().items():
if isinstance(pipe, Pipe):
pipe.update_path() # Update the path of all pipes
# Aktualisiere die Position des Labels mit intelligenter Kollisionsvermeidung
if self.label:
# Temporär die neue Position setzen für die Kollisionsprüfung
old_pos = self.pos()
self.setPos(value) # Temporär neue Position setzen
# Finde optimale Label-Position
optimal_position = self.scene().find_optimal_label_position(self, self.label)
self.label.setPos(optimal_position)
# Position zurücksetzen (wird von PyQt automatisch auf 'value' gesetzt)
self.setPos(old_pos)
padding = 12
# Aktualisiere die Position und Größe der Hintergrundbox (background_rect)
if hasattr(self, 'background_rect') and self.background_rect:
label_rect = self.label.boundingRect()
# Berechne die Szene-Position des Labels
scene_label_pos = self.label.scenePos()
background_rect_x = scene_label_pos.x() - padding / 2
background_rect_y = scene_label_pos.y() - padding / 2
background_rect_width = label_rect.width() + padding
background_rect_height = label_rect.height() + padding
# Setze die neue Position und Größe des Hintergrundrechtecks
self.background_rect.setRect(background_rect_x, background_rect_y, background_rect_width, background_rect_height)
# Aktualisiere die Position der Verbindungs-Punkte
for point in self.connection_points:
point.update_position()
return super().itemChange(change, value)
[docs]
class ConnectionPoint(QGraphicsLineItem):
[docs]
def __init__(self, parent, x_offset, y_offset, direction, color):
"""Create a connection point as a short line extending from the parent item."""
self.parent = parent
self.x_offset = x_offset
self.y_offset = y_offset
self.direction = direction # Direction of the line extension (up, down, left, right)
self.color = color
# Create the line as a QGraphicsLineItem
super().__init__()
self.setParentItem(parent) # Attach to the parent item (Generator, Storage, etc.)
# Set the pen for the line (its color and thickness)
self.setPen(QPen(self.color, 3))
# Position the connection point and create its extension line
self.update_position()
[docs]
def update_position(self):
"""Update the position of the connection point relative to the parent item and its extension line"""
bounding_rect = self.parentItem().boundingRect()
# Calculate the new position relative to the parent's local coordinate system
local_x = bounding_rect.x() + bounding_rect.width() * self.x_offset
local_y = bounding_rect.y() + bounding_rect.height() * self.y_offset
# Set the start of the line to the position of this connection point
line_start = QPointF(local_x, local_y)
# Determine the direction of the line and set the end point
if self.direction == 'up':
line_end = QPointF(line_start.x(), line_start.y() - 10)
elif self.direction == 'down':
line_end = QPointF(line_start.x(), line_start.y() + 10)
elif self.direction == 'left':
line_end = QPointF(line_start.x() - 10, line_start.y())
elif self.direction == 'right':
line_end = QPointF(line_start.x() + 10, line_start.y())
# Set the line's geometry based on the calculated start and end points
self.setLine(QLineF(line_start, line_end))
[docs]
def get_end_point(self):
"""Return the end point of the connection line (for pipe connections)."""
return self.parentItem().mapToScene(self.line().p2())
[docs]
class Pipe(QGraphicsPathItem):
[docs]
def __init__(self, point1, point2, color, line_thickness):
"""Create a flexible pipe (supply/return) between component and parallel lines"""
super().__init__()
self.point1 = point1
self.point2 = point2
self.color = color # Color for the pipe (red for supply, blue for return)
self.line_thickness = line_thickness # Thickness of the pipe
# Set the pen for the pipe, making it thicker and colored
self.setPen(QPen(self.color, self.line_thickness)) # Set line color and thickness
# Now it's safe to call update_path since the pipe is in the scene
self.update_path()
[docs]
def update_path(self):
"""Update the pipe path to connect components to parallel lines with vertical and horizontal segments only."""
if isinstance(self.point1, ConnectionPoint):
start_pos = self.point1.get_end_point() # Start position is the connection point of the component
else:
start_pos = self.point1
if isinstance(self.point2, ConnectionPoint):
end_pos = self.point2.get_end_point() # End position is the connection point of the component
else:
end_pos = self.point2 # End position is the point on the parallel line (Vorlauf or Rücklauf)
# Create a path from start to end with right-angled turns (horizontal and vertical segments only)
self.path = QPainterPath(start_pos)
# Check for collisions in the vertical and horizontal segments
intermediate_point = QPointF(start_pos.x(), end_pos.y())
# Adjust path to avoid collisions
if self.check_collision(intermediate_point):
# If a collision is detected in the vertical segment, adjust the path
adjusted_point = QPointF(start_pos.x() + 20, start_pos.y()) # Move right or left to avoid collision
self.path.lineTo(adjusted_point)
intermediate_point = QPointF(adjusted_point.x(), end_pos.y()) # Recalculate intermediate point
# Add vertical movement to the level of the parallel line
self.path.lineTo(intermediate_point)
# Add horizontal movement along the parallel line
self.path.lineTo(end_pos)
# Apply the updated path to the Pipe
self.setPath(self.path)
[docs]
def check_collision(self, point):
"""Check if the given point collides with any item (generator, consumer, storage)"""
# Only check for collisions if the pipe is part of a scene
if self.scene() is None:
return False
# Loop through the items in the scene and check for collisions
for item in self.scene().items():
if isinstance(item, ComponentItem):
if item.contains(item.mapFromScene(point)):
return True
return False