""" faults.py Motore di simulazione dei guasti e delle anomalie per il simulatore di campo fotovoltaico. Il modulo permette di simulare eventi a diversi livelli: PVPanel | +-- degradation +-- soiling +-- partial_shading +-- intermittent_fault +-- permanent_fault | v CombinerBox | +-- string_loss +-- fuse_fault +-- communication_fault +-- complete_failure | v Inverter | +-- derating +-- overheating +-- shutdown +-- clipping +-- communication_fault Ogni fault produce un fattore di riduzione della produzione. Esempio: normal power = 1000 W soiling factor = 0.90 shading factor = 0.80 effective power = 1000 * 0.90 * 0.80 = 720 W Il modulo non modifica direttamente i componenti PVPanel, CombinerBox o Inverter. Fornisce invece un livello indipendente di simulazione dei fault che può essere interrogato durante ogni timestep. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime from enum import Enum from typing import Dict, List, Optional import random # ====================================================================== # ENUMERAZIONE DEI TIPI DI FAULT # ====================================================================== class FaultType(str, Enum): """ Tipologie di guasto disponibili. """ # -------------------------------------------------------------- # Pannello # -------------------------------------------------------------- PANEL_DEGRADATION = ( "panel_degradation" ) SOILING = ( "soiling" ) PARTIAL_SHADING = ( "partial_shading" ) PANEL_INTERMITTENT = ( "panel_intermittent" ) PANEL_FAILURE = ( "panel_failure" ) # -------------------------------------------------------------- # Combiner # -------------------------------------------------------------- COMBINER_STRING_LOSS = ( "combiner_string_loss" ) COMBINER_FUSE_FAULT = ( "combiner_fuse_fault" ) COMBINER_COMMUNICATION = ( "combiner_communication" ) COMBINER_FAILURE = ( "combiner_failure" ) # -------------------------------------------------------------- # Inverter # -------------------------------------------------------------- INVERTER_DERATING = ( "inverter_derating" ) INVERTER_OVERHEATING = ( "inverter_overheating" ) INVERTER_SHUTDOWN = ( "inverter_shutdown" ) INVERTER_CLIPPING = ( "inverter_clipping" ) INVERTER_COMMUNICATION = ( "inverter_communication" ) # ====================================================================== # LIVELLO DEL COMPONENTE # ====================================================================== class ComponentLevel(str, Enum): """ Livello gerarchico del componente affetto. """ PANEL = "panel" COMBINER = "combiner" INVERTER = "inverter" # ====================================================================== # EVENTO DI FAULT # ====================================================================== @dataclass class FaultEvent: """ Rappresenta un singolo evento di guasto. Parameters ---------- fault_id: Identificativo univoco del fault. fault_type: Tipo di guasto. component_level: Livello gerarchico del componente. component_id: ID del componente interessato. start_time: Inizio del fault. end_time: Fine del fault. None = fault permanente. severity: Severità del fault da 0.0 a 1.0. enabled: Indica se il fault è attivo. description: Descrizione opzionale. """ fault_id: str fault_type: FaultType component_level: ComponentLevel component_id: str start_time: datetime end_time: Optional[datetime] = None severity: float = 1.0 enabled: bool = True description: str = "" # -------------------------------------------------------------- # Validazione # -------------------------------------------------------------- def __post_init__( self ) -> None: self.severity = min( max( self.severity, 0.0 ), 1.0 ) # -------------------------------------------------------------- # Stato # -------------------------------------------------------------- def is_active( self, timestamp: datetime ) -> bool: """ Verifica se il fault è attivo al timestamp indicato. """ if not self.enabled: return False if timestamp < self.start_time: return False if ( self.end_time is not None and timestamp >= self.end_time ): return False return True # -------------------------------------------------------------- # Fattore di riduzione # -------------------------------------------------------------- def reduction_factor( self ) -> float: """ Restituisce il fattore di produzione associato alla severità del fault. severity = 0.0 -> nessuna perdita severity = 1.0 -> perdita completa Returns ------- float Fattore compreso tra 0.0 e 1.0. """ return 1.0 - self.severity # ====================================================================== # CONFIGURAZIONE FAULT # ====================================================================== @dataclass class FaultConfig: """ Configurazione per la generazione automatica dei fault. """ enabled: bool = True panel_fault_probability: float = 0.0001 combiner_fault_probability: float = 0.00002 inverter_fault_probability: float = 0.00001 intermittent_probability: float = 0.30 default_fault_duration_hours: float = 4.0 random_seed: Optional[int] = None # ====================================================================== # FAULT MANAGER # ====================================================================== @dataclass class FaultManager: """ Gestore centrale dei fault dell'impianto. Mantiene tutti gli eventi attivi e storici. Il FaultManager può: - aggiungere fault manualmente; - rimuovere fault; - verificare i fault attivi; - calcolare il fattore di produzione; - generare fault casuali; - fornire informazioni diagnostiche. """ config: FaultConfig = field( default_factory=FaultConfig ) faults: List[FaultEvent] = field( default_factory=list ) _fault_counter: int = 0 # ------------------------------------------------------------------ # Inizializzazione # ------------------------------------------------------------------ def __post_init__( self ) -> None: if ( self.config.random_seed is not None ): random.seed( self.config.random_seed ) # ------------------------------------------------------------------ # Generazione ID # ------------------------------------------------------------------ def _generate_fault_id( self ) -> str: self._fault_counter += 1 return ( f"FAULT_" f"{self._fault_counter:06d}" ) # ------------------------------------------------------------------ # Aggiunta fault # ------------------------------------------------------------------ def add_fault( self, fault_type: FaultType, component_level: ComponentLevel, component_id: str, start_time: datetime, severity: float = 1.0, duration_hours: Optional[float] = None, description: str = "" ) -> FaultEvent: """ Crea e registra un nuovo fault. """ end_time = None if duration_hours is not None: from datetime import timedelta end_time = ( start_time + timedelta( hours= duration_hours ) ) fault = FaultEvent( fault_id= self._generate_fault_id(), fault_type= fault_type, component_level= component_level, component_id= component_id, start_time= start_time, end_time= end_time, severity= severity, description= description ) self.faults.append( fault ) return fault # ------------------------------------------------------------------ # Rimozione fault # ------------------------------------------------------------------ def remove_fault( self, fault_id: str ) -> bool: """ Rimuove un fault tramite ID. """ for fault in self.faults: if ( fault.fault_id == fault_id ): self.faults.remove( fault ) return True return False # ------------------------------------------------------------------ # Disabilitazione fault # ------------------------------------------------------------------ def disable_fault( self, fault_id: str ) -> bool: """ Disabilita un fault senza eliminarlo dallo storico. """ for fault in self.faults: if ( fault.fault_id == fault_id ): fault.enabled = False return True return False # ------------------------------------------------------------------ # Fault attivi # ------------------------------------------------------------------ def get_active_faults( self, timestamp: datetime ) -> List[FaultEvent]: """ Restituisce tutti i fault attivi. """ return [ fault for fault in self.faults if fault.is_active( timestamp ) ] # ------------------------------------------------------------------ # Fault di un componente # ------------------------------------------------------------------ def get_component_faults( self, component_id: str, timestamp: datetime ) -> List[FaultEvent]: """ Restituisce i fault attivi associati a uno specifico componente. """ return [ fault for fault in self.faults if ( fault.component_id == component_id and fault.is_active( timestamp ) ) ] # ------------------------------------------------------------------ # Fattore componente # ------------------------------------------------------------------ def get_component_factor( self, component_id: str, timestamp: datetime ) -> float: """ Calcola il fattore di produzione complessivo di un componente. Se sono presenti più fault contemporaneamente, i fattori vengono moltiplicati. Esempio: Soiling = 0.90 Shading = 0.80 Factor = 0.90 * 0.80 = 0.72 """ active_faults = ( self.get_component_faults( component_id, timestamp ) ) factor = 1.0 for fault in active_faults: factor *= ( fault.reduction_factor() ) return min( max( factor, 0.0 ), 1.0 ) # ------------------------------------------------------------------ # Stato componente # ------------------------------------------------------------------ def get_component_status( self, component_id: str, timestamp: datetime ) -> Dict[str, object]: """ Restituisce lo stato diagnostico di un componente. """ faults = ( self.get_component_faults( component_id, timestamp ) ) factor = ( self.get_component_factor( component_id, timestamp ) ) return { "component_id": component_id, "healthy": len( faults ) == 0, "fault_count": len( faults ), "production_factor": factor, "faults": faults } # ------------------------------------------------------------------ # Generazione fault casuale # ------------------------------------------------------------------ def generate_random_fault( self, component_id: str, component_level: ComponentLevel, timestamp: datetime ) -> Optional[FaultEvent]: """ Genera casualmente un fault. Il metodo è pensato per essere chiamato durante ogni timestep della simulazione. La probabilità viene scelta in base al livello gerarchico. """ if not self.config.enabled: return None if ( component_level == ComponentLevel.PANEL ): probability = ( self.config .panel_fault_probability ) elif ( component_level == ComponentLevel.COMBINER ): probability = ( self.config .combiner_fault_probability ) else: probability = ( self.config .inverter_fault_probability ) # Nessun fault if ( random.random() >= probability ): return None # -------------------------------------------------------------- # Selezione tipologia # -------------------------------------------------------------- if ( component_level == ComponentLevel.PANEL ): fault_type = random.choice([ FaultType.SOILING, FaultType.PARTIAL_SHADING, FaultType.PANEL_INTERMITTENT, FaultType.PANEL_FAILURE ]) elif ( component_level == ComponentLevel.COMBINER ): fault_type = random.choice([ FaultType.COMBINER_STRING_LOSS, FaultType.COMBINER_FUSE_FAULT, FaultType.COMBINER_COMMUNICATION, FaultType.COMBINER_FAILURE ]) else: fault_type = random.choice([ FaultType.INVERTER_DERATING, FaultType.INVERTER_OVERHEATING, FaultType.INVERTER_SHUTDOWN, FaultType.INVERTER_CLIPPING, FaultType.INVERTER_COMMUNICATION ]) # -------------------------------------------------------------- # Intermittent fault # -------------------------------------------------------------- is_intermittent = ( random.random() < self.config .intermittent_probability ) if is_intermittent: duration = ( random.uniform( 0.5, self.config .default_fault_duration_hours ) ) else: duration = None # -------------------------------------------------------------- # Severità # -------------------------------------------------------------- if ( fault_type in [ FaultType.PANEL_FAILURE, FaultType.COMBINER_FAILURE, FaultType.INVERTER_SHUTDOWN ] ): severity = 1.0 elif ( fault_type == FaultType.SOILING ): severity = random.uniform( 0.05, 0.30 ) elif ( fault_type == FaultType.PARTIAL_SHADING ): severity = random.uniform( 0.10, 0.70 ) elif ( fault_type == FaultType.INVERTER_DERATING ): severity = random.uniform( 0.10, 0.50 ) else: severity = random.uniform( 0.10, 0.40 ) return self.add_fault( fault_type= fault_type, component_level= component_level, component_id= component_id, start_time= timestamp, severity= severity, duration_hours= duration, description= "Automatically generated fault" ) # ------------------------------------------------------------------ # Simulazione automatica # ------------------------------------------------------------------ def simulate_random_faults( self, timestamp: datetime, panel_ids: List[str], combiner_ids: List[str], inverter_ids: List[str] ) -> List[FaultEvent]: """ Esegue la generazione casuale dei fault per tutti i componenti dell'impianto. Returns ------- List[FaultEvent] Fault generati durante il timestep. """ generated = [] for panel_id in panel_ids: fault = ( self.generate_random_fault( component_id= panel_id, component_level= ComponentLevel.PANEL, timestamp= timestamp ) ) if fault is not None: generated.append( fault ) for combiner_id in combiner_ids: fault = ( self.generate_random_fault( component_id= combiner_id, component_level= ComponentLevel.COMBINER, timestamp= timestamp ) ) if fault is not None: generated.append( fault ) for inverter_id in inverter_ids: fault = ( self.generate_random_fault( component_id= inverter_id, component_level= ComponentLevel.INVERTER, timestamp= timestamp ) ) if fault is not None: generated.append( fault ) return generated # ------------------------------------------------------------------ # Diagnostica # ------------------------------------------------------------------ def diagnostics( self, timestamp: datetime ) -> Dict[str, object]: """ Restituisce una panoramica diagnostica dei fault attivi. """ active = ( self.get_active_faults( timestamp ) ) by_level = { "panel": 0, "combiner": 0, "inverter": 0 } by_type = {} for fault in active: by_level[ fault.component_level.value ] += 1 fault_name = ( fault.fault_type.value ) by_type[ fault_name ] = ( by_type.get( fault_name, 0 ) + 1 ) return { "timestamp": timestamp, "active_faults": len( active ), "by_level": by_level, "by_type": by_type, "faults": active } # ------------------------------------------------------------------ # Pulizia fault scaduti # ------------------------------------------------------------------ def cleanup_expired_faults( self, timestamp: datetime ) -> int: """ Rimuove i fault terminati. Returns ------- int Numero di fault rimossi. """ before = len( self.faults ) self.faults = [ fault for fault in self.faults if ( fault.end_time is None or fault.end_time > timestamp ) ] return ( before - len( self.faults ) ) # ------------------------------------------------------------------ # Rappresentazione # ------------------------------------------------------------------ def __repr__( self ) -> str: """ Rappresentazione leggibile. """ active = sum( 1 for fault in self.faults if fault.enabled ) return ( f"FaultManager(" f"total_faults=" f"{len(self.faults)}, " f"active=" f"{active})" ) ```