diff --git a/pvsim/faults.py b/pvsim/faults.py new file mode 100644 index 0000000..01c4a0d --- /dev/null +++ b/pvsim/faults.py @@ -0,0 +1,1135 @@ +```python +""" +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})" + ) +```