```python """ pvsim/simulator.py Motore centrale della simulazione fotovoltaica. Il simulatore coordina: SunModel | v WeatherModel | v FaultManager | v PVPlant | +-- Inverter | | | +-- CombinerBox | | | +-- PVPanel | v SimulationStep | +-- WeatherData +-- PanelData[] +-- CombinerData[] +-- InverterData[] +-- PlantData +-- FaultData[] Il simulatore non utilizza più dizionari generici per rappresentare la telemetria. Tutti i dati prodotti vengono convertiti nei modelli definiti in pvsim.models. Output gerarchico: Plant | +-- Inverter | | | +-- Combiner | | | +-- Panel | +-- Faults | +-- Weather Il risultato finale è contenuto in SimulationResult. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any, Dict, List import pandas as pd from .plant import PVPlant from .sun import SunModel from .weather import WeatherModel from .faults import ( FaultManager, ComponentLevel, ) from .models import ( WeatherData, PanelData, CombinerData, InverterData, PlantData, FaultData, SimulationStep, create_panel_data, ) # ====================================================================== # CONFIGURAZIONE # ====================================================================== @dataclass class SimulationConfig: """ Configurazione temporale della simulazione. Parameters ---------- start: Timestamp iniziale. end: Timestamp finale. timestep_minutes: Durata del timestep in minuti. years_from_start: Anni trascorsi dall'installazione dell'impianto. generate_random_faults: Abilita la generazione automatica dei fault. store_panel_data: Memorizza i dati dei pannelli. store_combiner_data: Memorizza i dati dei combiner. store_inverter_data: Memorizza i dati degli inverter. store_plant_data: Memorizza i dati dell'impianto. store_weather_data: Memorizza i dati meteorologici. store_fault_data: Memorizza i dati dei fault. """ start: datetime end: datetime timestep_minutes: int = 5 years_from_start: float = 0.0 generate_random_faults: bool = False store_panel_data: bool = True store_combiner_data: bool = True store_inverter_data: bool = True store_plant_data: bool = True store_weather_data: bool = True store_fault_data: bool = True def __post_init__( self, ) -> None: if self.end <= self.start: raise ValueError( "end deve essere maggiore di start" ) if self.timestep_minutes <= 0: raise ValueError( "timestep_minutes deve essere maggiore di zero" ) # ====================================================================== # RISULTATI # ====================================================================== @dataclass class SimulationResult: """ Risultato completo della simulazione. La struttura principale è: steps | +-- SimulationStep | +-- WeatherData +-- PanelData[] +-- CombinerData[] +-- InverterData[] +-- PlantData +-- FaultData[] Per comodità vengono inoltre mantenuti metodi per ottenere direttamente DataFrame tabellari. """ steps: List[ SimulationStep ] = field( default_factory=list ) # ------------------------------------------------------------------ # Proprietà # ------------------------------------------------------------------ @property def panel_records( self, ) -> List[PanelData]: records = [] for step in self.steps: records.extend( step.panels ) return records # ------------------------------------------------------------------ @property def combiner_records( self, ) -> List[CombinerData]: records = [] for step in self.steps: records.extend( step.combiners ) return records # ------------------------------------------------------------------ @property def inverter_records( self, ) -> List[InverterData]: records = [] for step in self.steps: records.extend( step.inverters ) return records # ------------------------------------------------------------------ @property def plant_records( self, ) -> List[PlantData]: records = [] for step in self.steps: if step.plant is not None: records.append( step.plant ) return records # ------------------------------------------------------------------ @property def weather_records( self, ) -> List[WeatherData]: records = [] for step in self.steps: if step.weather is not None: records.append( step.weather ) return records # ------------------------------------------------------------------ @property def fault_records( self, ) -> List[FaultData]: records = [] for step in self.steps: records.extend( step.faults ) return records # ================================================================== # DATAFRAME # ================================================================== def _records_to_dataframe( self, records: List[Any], ) -> pd.DataFrame: """ Converte una lista di modelli in DataFrame. """ if not records: return pd.DataFrame() return pd.DataFrame( [ record.to_dict() for record in records ] ) # ------------------------------------------------------------------ def panel_dataframe( self, ) -> pd.DataFrame: return self._records_to_dataframe( self.panel_records ) # ------------------------------------------------------------------ def combiner_dataframe( self, ) -> pd.DataFrame: return self._records_to_dataframe( self.combiner_records ) # ------------------------------------------------------------------ def inverter_dataframe( self, ) -> pd.DataFrame: return self._records_to_dataframe( self.inverter_records ) # ------------------------------------------------------------------ def plant_dataframe( self, ) -> pd.DataFrame: return self._records_to_dataframe( self.plant_records ) # ------------------------------------------------------------------ def weather_dataframe( self, ) -> pd.DataFrame: return self._records_to_dataframe( self.weather_records ) # ------------------------------------------------------------------ def faults_dataframe( self, ) -> pd.DataFrame: return self._records_to_dataframe( self.fault_records ) # ================================================================== # EXPORT CSV # ================================================================== def export_csv( self, output_dir: str, ) -> None: """ Esporta i dati della simulazione in CSV. """ from pathlib import Path output_path = Path( output_dir ) output_path.mkdir( parents=True, exist_ok=True ) datasets = { "weather": self.weather_dataframe(), "panel": self.panel_dataframe(), "combiner": self.combiner_dataframe(), "inverter": self.inverter_dataframe(), "plant": self.plant_dataframe(), "faults": self.faults_dataframe(), } for name, dataframe in datasets.items(): if dataframe.empty: continue dataframe.to_csv( output_path / f"{name}.csv", index=False ) # ================================================================== # EXPORT PARQUET # ================================================================== def export_parquet( self, output_dir: str, ) -> None: """ Esporta i dati in formato Parquet. Richiede pyarrow o fastparquet. """ from pathlib import Path output_path = Path( output_dir ) output_path.mkdir( parents=True, exist_ok=True ) datasets = { "weather": self.weather_dataframe(), "panel": self.panel_dataframe(), "combiner": self.combiner_dataframe(), "inverter": self.inverter_dataframe(), "plant": self.plant_dataframe(), "faults": self.faults_dataframe(), } for name, dataframe in datasets.items(): if dataframe.empty: continue dataframe.to_parquet( output_path / f"{name}.parquet", index=False ) # ================================================================== # SUMMARY # ================================================================== def summary( self, ) -> Dict[str, Any]: """ Restituisce un riepilogo del risultato. """ return { "timesteps": len( self.steps ), "weather_records": len( self.weather_records ), "panel_records": len( self.panel_records ), "combiner_records": len( self.combiner_records ), "inverter_records": len( self.inverter_records ), "plant_records": len( self.plant_records ), "fault_records": len( self.fault_records ), } # ====================================================================== # SIMULATORE # ====================================================================== @dataclass class PVSimulator: """ Motore principale della simulazione. Parameters ---------- plant: Modello gerarchico dell'impianto. sun: Modello solare. weather: Modello meteorologico. fault_manager: Gestore dei fault. """ plant: PVPlant sun: SunModel weather: WeatherModel fault_manager: FaultManager # ================================================================== # ID COMPONENTI # ================================================================== def _get_component_ids( self, ) -> Dict[str, List[str]]: """ Estrae gli ID di tutti i componenti. Gerarchia: inverter | +-- combiner | +-- panel """ panel_ids = [] combiner_ids = [] inverter_ids = [] for inverter in self.plant.inverters: inverter_ids.append( inverter.inverter_id ) for combiner in inverter.combiners: combiner_ids.append( combiner.combiner_id ) for panel in combiner.panels: panel_ids.append( panel.panel_id ) return { "panel": panel_ids, "combiner": combiner_ids, "inverter": inverter_ids, } # ================================================================== # WEATHER MODEL # ================================================================== def _build_weather_data( self, timestamp: datetime, ) -> WeatherData: """ Converte l'output del WeatherModel nel modello WeatherData. """ conditions = ( self.weather.get_conditions( timestamp= timestamp ) ) return WeatherData( timestamp= timestamp, ambient_temperature_C= conditions[ "ambient_temperature_C" ], solar_elevation_deg= conditions[ "solar_elevation_deg" ], solar_azimuth_deg= conditions[ "solar_azimuth_deg" ], solar_zenith_deg= conditions[ "solar_zenith_deg" ], angle_of_incidence_deg= conditions[ "angle_of_incidence_deg" ], ghi_Wm2= conditions[ "ghi_Wm2" ], dni_Wm2= conditions[ "dni_Wm2" ], dhi_Wm2= conditions[ "dhi_Wm2" ], poa_global_Wm2= conditions[ "poa_global_Wm2" ], poa_direct_Wm2= conditions[ "poa_direct_Wm2" ], poa_diffuse_Wm2= conditions[ "poa_diffuse_Wm2" ], cloud_factor= conditions[ "cloud_factor" ], rain_factor= conditions[ "rain_factor" ], rain_active= conditions[ "rain_active" ], daylight= conditions[ "daylight" ], ) # ================================================================== # FAULT DATA # ================================================================== def _build_fault_data( self, timestamp: datetime, ) -> List[FaultData]: """ Converte i fault attivi in FaultData. """ records = [] active_faults = ( self.fault_manager .get_active_faults( timestamp ) ) for fault in active_faults: records.append( FaultData( timestamp= timestamp, fault_id= fault.fault_id, fault_type= fault.fault_type.value, component_level= fault.component_level.value, component_id= fault.component_id, severity= fault.severity, reduction_factor= fault.reduction_factor(), description= fault.description, ) ) return records # ================================================================== # FAULT AUTOMATICI # ================================================================== def _generate_random_faults( self, timestamp: datetime, ) -> None: """ Genera eventuali fault casuali. """ ids = ( self._get_component_ids() ) self.fault_manager.simulate_random_faults( timestamp= timestamp, panel_ids= ids[ "panel" ], combiner_ids= ids[ "combiner" ], inverter_ids= ids[ "inverter" ], ) # ================================================================== # PANEL DATA # ================================================================== def _build_panel_data( self, timestamp: datetime, weather: WeatherData, ) -> List[PanelData]: """ Genera la telemetria di tutti i pannelli. La produzione del pannello viene calcolata dal modello PVPanel. Il fault factor viene applicato dopo il calcolo della produzione fisica. """ records = [] for inverter in self.plant.inverters: for combiner in inverter.combiners: for panel in combiner.panels: # -------------------------------------------------- # Fault # -------------------------------------------------- fault_factor = ( self.fault_manager .get_component_factor( panel.panel_id, timestamp ) ) # -------------------------------------------------- # Produzione fisica # -------------------------------------------------- try: panel_result = ( panel.simulate( irradiance= weather .poa_global_Wm2, ambient_temperature= weather .ambient_temperature_C, timestamp= timestamp ) ) dc_power = ( panel_result[ "dc_power_W" ] ) panel_temperature = ( panel_result.get( "panel_temperature_C", weather .ambient_temperature_C ) ) except ( AttributeError, TypeError, KeyError, ): # -------------------------------------------------- # Fallback compatibilità # -------------------------------------------------- dc_power = ( panel.nominal_power * ( weather .poa_global_Wm2 / 1000.0 ) ) panel_temperature = ( weather .ambient_temperature_C ) # -------------------------------------------------- # Fault # -------------------------------------------------- effective_power = ( dc_power * fault_factor ) # -------------------------------------------------- # Model # -------------------------------------------------- record = create_panel_data( timestamp= timestamp, panel_id= panel.panel_id, combiner_id= combiner.combiner_id, inverter_id= inverter.inverter_id, dc_power_W= effective_power, nominal_power_W= panel.nominal_power, irradiance_Wm2= weather .poa_global_Wm2, panel_temperature_C= panel_temperature, fault_factor= fault_factor, enabled= panel.enabled, ) records.append( record ) return records # ================================================================== # COMBINER DATA # ================================================================== def _build_combiner_data( self, timestamp: datetime, panel_records: List[PanelData], ) -> List[CombinerData]: """ Aggrega i dati dei pannelli a livello Combiner Box. """ records = [] for inverter in self.plant.inverters: for combiner in inverter.combiners: panels = [ panel for panel in panel_records if ( panel.combiner_id == combiner.combiner_id ) ] if not panels: continue dc_power = sum( panel.dc_power_W for panel in panels ) nominal_power = sum( panel.nominal_power_W for panel in panels ) active_panels = sum( 1 for panel in panels if panel.enabled and panel.dc_power_W > 0 ) fault_factor = ( self.fault_manager .get_component_factor( combiner.combiner_id, timestamp ) ) # ------------------------------------------------------ # Applica fault Combiner # ------------------------------------------------------ effective_power = ( dc_power * fault_factor ) # ------------------------------------------------------ # Corrente e tensione # ------------------------------------------------------ dc_voltage = 0.0 dc_current = 0.0 if effective_power > 0: dc_voltage = 400.0 dc_current = ( effective_power / dc_voltage ) # ------------------------------------------------------ # Stato # ------------------------------------------------------ fault_active = ( fault_factor < 1.0 ) if fault_active: status = "fault" elif active_panels == 0: status = "idle" else: status = "normal" availability = ( active_panels / len(panels) ) record = CombinerData( timestamp= timestamp, combiner_id= combiner.combiner_id, inverter_id= inverter.inverter_id, dc_power_W= effective_power, dc_voltage_V= dc_voltage, dc_current_A= dc_current, nominal_power_W= nominal_power, panel_count= len(panels), active_panel_count= active_panels, fault_factor= fault_factor, fault_active= fault_active, availability= availability, status= status, ) records.append( record ) return records # ================================================================== # INVERTER DATA # ================================================================== def _build_inverter_data( self, timestamp: datetime, combiner_records: List[CombinerData], weather: WeatherData, ) -> List[InverterData]: """ Aggrega i dati delle Combiner Box a livello inverter. """ records = [] for inverter in self.plant.inverters: combiners = [ combiner for combiner in combiner_records if ( combiner.inverter_id == inverter.inverter_id ) ] if not combiners: continue dc_power = sum( combiner.dc_power_W for combiner in combiners ) nominal_power = sum( combiner.nominal_power_W for combiner in combiners ) active_combiners = sum( 1 for combiner in combiners if combiner.status != "fault" ) # ---------------------------------------------------------- # Fault inverter # ---------------------------------------------------------- fault_factor = ( self.fault_manager .get_component_factor( inverter.inverter_id, timestamp ) ) dc_power *= fault_factor # ---------------------------------------------------------- # Efficienza # ---------------------------------------------------------- inverter_efficiency = 0.97 ac_power = ( dc_power * inverter_efficiency ) # ---------------------------------------------------------- # Clipping # ---------------------------------------------------------- inverter_nominal = ( inverter.nominal_power_kW * 1000.0 ) clipping_loss = 0.0 if ac_power > inverter_nominal: clipping_loss = ( ac_power - inverter_nominal ) ac_power = ( inverter_nominal ) # ---------------------------------------------------------- # Fault inverter # ---------------------------------------------------------- fault_active = ( fault_factor < 1.0 ) if fault_active: status = "fault" elif ac_power <= 0: status = "idle" else: status = "normal" # ---------------------------------------------------------- # Elettrico # ---------------------------------------------------------- dc_voltage = 800.0 if dc_voltage > 0: dc_current = ( dc_power / dc_voltage ) else: dc_current = 0.0 ac_voltage = 400.0 if ac_voltage > 0: ac_current = ( ac_power / ac_voltage ) else: ac_current = 0.0 # ---------------------------------------------------------- # Temperatura inverter # ---------------------------------------------------------- inverter_temperature = ( weather.ambient_temperature_C + ( ac_power / max( inverter_nominal, 1.0 ) ) * 20.0 ) availability = ( active_combiners / len(combiners) ) record = InverterData( timestamp= timestamp, inverter_id= inverter.inverter_id, dc_power_W= dc_power, ac_power_W= ac_power, dc_voltage_V= dc_voltage, dc_current_A= dc_current, ac_voltage_V= ac_voltage, ac_current_A= ac_current, efficiency= inverter_efficiency, nominal_power_W= inverter_nominal, clipping_loss_W= clipping_loss, temperature_C= inverter_temperature, fault_factor= fault_factor, combiner_count= len(combiners), active_combiner_count= active_combiners, fault_active= fault_active, availability= availability, status= status, ) records.append( record ) return records # ================================================================== # PLANT DATA # ================================================================== def _build_plant_data( self, timestamp: datetime, panel_records: List[PanelData], combiner_records: List[CombinerData], inverter_records: List[InverterData], config: SimulationConfig, ) -> PlantData: """ Aggrega tutti i dati a livello di impianto. """ dc_power = sum( inverter.dc_power_W for inverter in inverter_records ) ac_power = sum( inverter.ac_power_W for inverter in inverter_records ) nominal_power = sum( inverter.nominal_power_W for inverter in inverter_records ) # -------------------------------------------------------------- # Energia del timestep # -------------------------------------------------------------- timestep_hours = ( config.timestep_minutes / 60.0 ) energy_Wh = ( ac_power * timestep_hours ) # -------------------------------------------------------------- # Conteggi # -------------------------------------------------------------- inverter_count = ( len( inverter_records ) ) active_inverters = sum( 1 for inverter in inverter_records if inverter.status != "fault" ) combiner_count = ( len( combiner_records ) ) active_combiners = sum( 1 for combiner in combiner_records if combiner.status != "fault" ) panel_count = ( len( panel_records ) ) active_panels = sum( 1 for panel in panel_records if panel.enabled ) # -------------------------------------------------------------- # Efficienza # -------------------------------------------------------------- if dc_power > 0: efficiency = ( ac_power / dc_power ) else: efficiency = 0.0 # -------------------------------------------------------------- # Performance Ratio # -------------------------------------------------------------- if nominal_power > 0: performance_ratio = ( ac_power / nominal_power ) else: performance_ratio = 0.0 # -------------------------------------------------------------- # Availability # -------------------------------------------------------------- if panel_count > 0: availability = ( active_panels / panel_count ) else: availability = 0.0 # -------------------------------------------------------------- # Fault factor # -------------------------------------------------------------- if panel_count > 0: fault_factor = ( sum( panel.fault_factor for panel in panel_records ) / panel_count ) else: fault_factor = 1.0 fault_active = ( fault_factor < 1.0 ) if fault_active: status = "fault" elif ac_power <= 0: status = "idle" else: status = "normal" return PlantData( timestamp= timestamp, plant_id= self.plant.plant_id, dc_power_W= dc_power, ac_power_W= ac_power, effective_ac_power_W= ac_power, nominal_power_W= nominal_power, energy_Wh= energy_Wh, cumulative_energy_Wh= getattr( self.plant, "total_ac_energy_Wh", 0.0 ), inverter_count= inverter_count, active_inverter_count= active_inverters, combiner_count= combiner_count, active_combiner_count= active_combiners, panel_count= panel_count, active_panel_count= active_panels, efficiency= efficiency, performance_ratio= performance_ratio, availability= availability, fault_factor= fault_factor, fault_active= fault_active, status= status, ) # ================================================================== # SINGOLO TIMESTEP # ================================================================== def simulate_timestep( self, timestamp: datetime, config: SimulationConfig, ) -> SimulationStep: """ Esegue un singolo timestep e restituisce un SimulationStep completamente tipizzato. """ # -------------------------------------------------------------- # Weather # -------------------------------------------------------------- weather = ( self._build_weather_data( timestamp ) ) # -------------------------------------------------------------- # Random faults # -------------------------------------------------------------- if config.generate_random_faults: self._generate_random_faults( timestamp ) # -------------------------------------------------------------- # Panel # -------------------------------------------------------------- panel_records = ( self._build_panel_data( timestamp, weather ) if config.store_panel_data else [] ) # -------------------------------------------------------------- # Combiner # -------------------------------------------------------------- combiner_records = ( self._build_combiner_data( timestamp, panel_records ) if config.store_combiner_data else [] ) # -------------------------------------------------------------- # Inverter # -------------------------------------------------------------- inverter_records = ( self._build_inverter_data( timestamp, combiner_records, weather ) if config.store_inverter_data else [] ) # -------------------------------------------------------------- # Plant # -------------------------------------------------------------- plant_record = ( self._build_plant_data( timestamp, panel_records, combiner_records, inverter_records, config ) if config.store_plant_data else None ) # -------------------------------------------------------------- # Faults # -------------------------------------------------------------- fault_records = ( self._build_fault_data( timestamp ) if config.store_fault_data else [] ) # -------------------------------------------------------------- # SimulationStep # -------------------------------------------------------------- return SimulationStep( timestamp= timestamp, weather=( weather if config.store_weather_data else None ), panels= panel_records, combiners= combiner_records, inverters= inverter_records, plant= plant_record, faults= fault_records, ) # ================================================================== # RUN # ================================================================== def run( self, config: SimulationConfig, ) -> SimulationResult: """ Esegue la simulazione completa. """ result = ( SimulationResult() ) timestamp = ( config.start ) while timestamp < config.end: # ---------------------------------------------------------- # Simula timestep # ---------------------------------------------------------- step = ( self.simulate_timestep( timestamp, config ) ) result.steps.append( step ) # ---------------------------------------------------------- # Incremento temporale # ---------------------------------------------------------- timestamp += timedelta( minutes= config.timestep_minutes ) return result # ================================================================== # RUN SINGLE DAY # ================================================================== def run_single_day( self, date: datetime, timestep_minutes: int = 5, generate_random_faults: bool = False, ) -> SimulationResult: """ Esegue la simulazione di una giornata. """ start = datetime( year= date.year, month= date.month, day= date.day ) end = ( start + timedelta( days=1 ) ) config = SimulationConfig( start= start, end= end, timestep_minutes= timestep_minutes, generate_random_faults= generate_random_faults, ) return self.run( config ) # ================================================================== # RUN YEAR # ================================================================== def run_year( self, year: int, timestep_minutes: int = 15, generate_random_faults: bool = False, ) -> SimulationResult: """ Esegue una simulazione annuale. """ start = datetime( year= year, month=1, day=1 ) end = datetime( year= year + 1, month=1, day=1 ) config = SimulationConfig( start= start, end= end, timestep_minutes= timestep_minutes, generate_random_faults= generate_random_faults, ) return self.run( config ) # ================================================================== # SUMMARY # ================================================================== def summary( self, result: SimulationResult, ) -> Dict[str, Any]: """ Restituisce un riepilogo della simulazione. """ return { "plant_id": self.plant.plant_id, "inverters": self.plant.get_inverter_count(), "combiners": self.plant.get_combiner_count(), "panels": self.plant.get_panel_count(), **result.summary(), } # ================================================================== # REPR # ================================================================== def __repr__( self, ) -> str: return ( f"PVSimulator(" f"plant=" f"{self.plant.plant_id}, " f"inverters=" f"{self.plant.get_inverter_count()}, " f"combiners=" f"{self.plant.get_combiner_count()}, " f"panels=" f"{self.plant.get_panel_count()})" ) # ====================================================================== # EXPORT # ====================================================================== __all__ = [ "SimulationConfig", "SimulationResult", "PVSimulator", ]