From 1ae376ac8e500f4f7b1af974f4489ab8da949a53 Mon Sep 17 00:00:00 2001 From: Giovanni Date: Sun, 19 Jul 2026 15:17:27 +0000 Subject: [PATCH] Motore centrale della simulazione fotovoltaica --- pvsim/simulator.py | 1141 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1141 insertions(+) create mode 100644 pvsim/simulator.py diff --git a/pvsim/simulator.py b/pvsim/simulator.py new file mode 100644 index 0000000..73fce6a --- /dev/null +++ b/pvsim/simulator.py @@ -0,0 +1,1141 @@ +```python +""" +simulator.py + +Motore centrale della simulazione fotovoltaica. + +Collega: + + SunModel + | + v + WeatherModel + | + v + FaultManager + | + v + PVPlant + | + +-- Inverters + | | + | +-- CombinerBoxes + | | + | +-- PVPanels + | + v + SimulationResult + +Il simulatore esegue la simulazione timestep per timestep. + +Per ogni timestamp vengono calcolati: + + - posizione del sole; + - condizioni meteorologiche; + - irraggiamento; + - temperatura; + - fault attivi; + - produzione dei pannelli; + - aggregazione Combiner Box; + - aggregazione Inverter; + - aggregazione Plant. + +Il modulo è progettato per simulazioni: + + - giornaliere; + - mensili; + - stagionali; + - annuali; + - multi-annuali. + +Il timestep può essere configurato liberamente. + +Esempio: + + 5 minuti + 15 minuti + 1 ora + +Nota: + +Il simulatore mantiene due livelli di output: + + 1. summary: + dati aggregati a livello impianto. + + 2. hierarchy: + dati dettagliati di pannelli, + combiner e inverter. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +import pandas as pd + +from .plant import PVPlant +from .sun import SunModel +from .weather import WeatherModel +from .faults import FaultManager + + +# ====================================================================== +# CONFIGURAZIONE SIMULAZIONE +# ====================================================================== + + +@dataclass +class SimulationConfig: + """ + Configurazione temporale della simulazione. + + Parameters + ---------- + start: + Timestamp iniziale. + + end: + Timestamp finale. + + timestep_minutes: + Durata del timestep in minuti. + + years_from_start: + Anno relativo utilizzato per la degradazione. + + store_panel_data: + Salva i dati dei singoli pannelli. + + store_combiner_data: + Salva i dati delle Combiner Box. + + store_inverter_data: + Salva i dati degli inverter. + + store_plant_data: + Salva i dati aggregati dell'impianto. + + generate_random_faults: + Abilita la generazione automatica dei fault. + """ + + start: datetime + + end: datetime + + timestep_minutes: int = 5 + + years_from_start: float = 0.0 + + store_panel_data: bool = True + + store_combiner_data: bool = True + + store_inverter_data: bool = True + + store_plant_data: bool = True + + generate_random_faults: bool = False + + 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 > 0" + ) + + +# ====================================================================== +# RISULTATO DELLA SIMULAZIONE +# ====================================================================== + + +@dataclass +class SimulationResult: + """ + Contenitore dei risultati della simulazione. + + I dati sono organizzati in quattro livelli: + + plant + inverter + combiner + panel + + Ogni elemento contiene una lista di record + successivamente convertibili in DataFrame. + """ + + plant: List[Dict[str, Any]] = field( + default_factory=list + ) + + inverter: List[Dict[str, Any]] = field( + default_factory=list + ) + + combiner: List[Dict[str, Any]] = field( + default_factory=list + ) + + panel: List[Dict[str, Any]] = field( + default_factory=list + ) + + faults: List[Dict[str, Any]] = field( + default_factory=list + ) + + # ------------------------------------------------------------------ + # Conversione DataFrame + # ------------------------------------------------------------------ + + def plant_dataframe( + self + ) -> pd.DataFrame: + + return pd.DataFrame( + self.plant + ) + + # ------------------------------------------------------------------ + + def inverter_dataframe( + self + ) -> pd.DataFrame: + + return pd.DataFrame( + self.inverter + ) + + # ------------------------------------------------------------------ + + def combiner_dataframe( + self + ) -> pd.DataFrame: + + return pd.DataFrame( + self.combiner + ) + + # ------------------------------------------------------------------ + + def panel_dataframe( + self + ) -> pd.DataFrame: + + return pd.DataFrame( + self.panel + ) + + # ------------------------------------------------------------------ + + def faults_dataframe( + self + ) -> pd.DataFrame: + + return pd.DataFrame( + self.faults + ) + + # ------------------------------------------------------------------ + # Export CSV + # ------------------------------------------------------------------ + + def export_csv( + self, + output_dir: str + ) -> None: + """ + Esporta tutti i livelli in file CSV. + """ + + import os + + os.makedirs( + output_dir, + exist_ok=True + ) + + if self.plant: + + self.plant_dataframe().to_csv( + + os.path.join( + + output_dir, + + "plant.csv" + ), + + index=False + ) + + if self.inverter: + + self.inverter_dataframe().to_csv( + + os.path.join( + + output_dir, + + "inverter.csv" + ), + + index=False + ) + + if self.combiner: + + self.combiner_dataframe().to_csv( + + os.path.join( + + output_dir, + + "combiner.csv" + ), + + index=False + ) + + if self.panel: + + self.panel_dataframe().to_csv( + + os.path.join( + + output_dir, + + "panel.csv" + ), + + index=False + ) + + if self.faults: + + self.faults_dataframe().to_csv( + + os.path.join( + + output_dir, + + "faults.csv" + ), + + index=False + ) + + +# ====================================================================== +# SIMULATORE +# ====================================================================== + + +@dataclass +class PVSimulator: + """ + Motore principale della simulazione. + + Parameters + ---------- + plant: + Modello gerarchico dell'impianto. + + sun: + Modello della posizione solare. + + weather: + Modello meteorologico. + + fault_manager: + Gestore dei fault. + """ + + plant: PVPlant + + sun: SunModel + + weather: WeatherModel + + fault_manager: FaultManager + + # ------------------------------------------------------------------ + # Preparazione ID + # ------------------------------------------------------------------ + + def _get_component_ids( + self + ) -> Dict[str, List[str]]: + """ + Estrae tutti gli ID della gerarchia. + """ + + 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 + } + + # ------------------------------------------------------------------ + # Applicazione fault + # ------------------------------------------------------------------ + + def _get_fault_factor( + self, + component_id: str, + timestamp: datetime + ) -> float: + """ + Restituisce il fattore di produzione dovuto ai fault. + """ + + return ( + + self.fault_manager + .get_component_factor( + + component_id, + + timestamp + ) + ) + + # ------------------------------------------------------------------ + # Dati fault + # ------------------------------------------------------------------ + + def _collect_fault_records( + self, + timestamp: datetime + ) -> List[Dict[str, Any]]: + """ + Converte i fault attivi in record serializzabili. + """ + + records = [] + + active_faults = ( + + self.fault_manager + .get_active_faults( + timestamp + ) + ) + + for fault in active_faults: + + records.append({ + + "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 + + # ------------------------------------------------------------------ + # Simulazione singolo timestep + # ------------------------------------------------------------------ + + def simulate_timestep( + self, + timestamp: datetime, + config: SimulationConfig + ) -> Dict[str, Any]: + """ + Esegue un singolo timestep. + + Il metodo restituisce i dati gerarchici del timestep. + """ + + # -------------------------------------------------------------- + # Condizioni meteorologiche + # -------------------------------------------------------------- + + weather = ( + + self.weather.get_conditions( + + timestamp= + + timestamp + ) + ) + + irradiance = ( + + weather[ + "poa_global_Wm2" + ] + ) + + ambient_temperature = ( + + weather[ + "ambient_temperature_C" + ] + ) + + # -------------------------------------------------------------- + # Fault automatici + # -------------------------------------------------------------- + + if config.generate_random_faults: + + 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" + ] + ) + + # -------------------------------------------------------------- + # Aggiornamento Plant + # -------------------------------------------------------------- + + plant_data = ( + + self.plant.update( + + timestamp= + + timestamp, + + irradiance= + + irradiance, + + ambient_temperature= + + ambient_temperature, + + years_from_start= + + config.years_from_start, + + timestep_minutes= + + config.timestep_minutes + ) + ) + + # -------------------------------------------------------------- + # Applicazione fault Plant + # -------------------------------------------------------------- + + plant_factor = ( + + self._get_fault_factor( + + self.plant.plant_id, + + timestamp + ) + ) + + plant_data[ + "fault_factor" + ] = plant_factor + + plant_data[ + "effective_ac_power_W" + ] = ( + + plant_data[ + "ac_power_W" + ] + + * plant_factor + ) + + # -------------------------------------------------------------- + # Output + # -------------------------------------------------------------- + + return { + + "timestamp": + timestamp, + + "weather": + weather, + + "plant": + plant_data, + + "faults": + self._collect_fault_records( + + timestamp + ) + } + + # ------------------------------------------------------------------ + # Simulazione completa + # ------------------------------------------------------------------ + + def run( + self, + config: SimulationConfig + ) -> SimulationResult: + """ + Esegue la simulazione completa. + + Returns + ------- + SimulationResult + Risultati a tutti i livelli. + """ + + result = ( + SimulationResult() + ) + + timestamp = ( + config.start + ) + + # -------------------------------------------------------------- + # Loop temporale + # -------------------------------------------------------------- + + while timestamp < config.end: + + timestep = ( + + self.simulate_timestep( + + timestamp, + + config + ) + ) + + weather = ( + + timestep[ + "weather" + ] + ) + + plant_data = ( + + timestep[ + "plant" + ] + ) + + # ---------------------------------------------------------- + # Plant level + # ---------------------------------------------------------- + + if config.store_plant_data: + + plant_record = { + + **plant_data, + + "ambient_temperature_C": + + weather[ + "ambient_temperature_C" + ], + + "ghi_Wm2": + + weather[ + "ghi_Wm2" + ], + + "dni_Wm2": + + weather[ + "dni_Wm2" + ], + + "dhi_Wm2": + + weather[ + "dhi_Wm2" + ], + + "poa_global_Wm2": + + weather[ + "poa_global_Wm2" + ], + + "cloud_factor": + + weather[ + "cloud_factor" + ], + + "rain_factor": + + weather[ + "rain_factor" + ] + } + + result.plant.append( + + plant_record + ) + + # ---------------------------------------------------------- + # Inverter level + # ---------------------------------------------------------- + + if config.store_inverter_data: + + for inverter in ( + self.plant.inverters + ): + + inverter_factor = ( + + self._get_fault_factor( + + inverter.inverter_id, + + timestamp + ) + ) + + inverter_record = { + + "timestamp": + timestamp, + + "inverter_id": + inverter.inverter_id, + + "nominal_power_kW": + inverter.nominal_power_kW, + + "fault_factor": + inverter_factor, + + "enabled": + inverter.enabled + } + + result.inverter.append( + + inverter_record + ) + + # -------------------------------------------------- + # Combiner level + # -------------------------------------------------- + + if config.store_combiner_data: + + for combiner in ( + inverter.combiners + ): + + combiner_factor = ( + + self._get_fault_factor( + + combiner.combiner_id, + + timestamp + ) + ) + + combiner_record = { + + "timestamp": + timestamp, + + "inverter_id": + inverter.inverter_id, + + "combiner_id": + combiner.combiner_id, + + "fault_factor": + combiner_factor, + + "panel_count": + len( + combiner.panels + ) + } + + result.combiner.append( + + combiner_record + ) + + # ------------------------------------------ + # Panel level + # ------------------------------------------ + + if config.store_panel_data: + + for panel in ( + combiner.panels + ): + + panel_factor = ( + + self._get_fault_factor( + + panel.panel_id, + + timestamp + ) + ) + + panel_record = { + + "timestamp": + timestamp, + + "inverter_id": + inverter.inverter_id, + + "combiner_id": + combiner.combiner_id, + + "panel_id": + panel.panel_id, + + "fault_factor": + panel_factor, + + "enabled": + panel.enabled, + + "nominal_power_W": + panel.nominal_power + } + + result.panel.append( + + panel_record + ) + + # ---------------------------------------------------------- + # Fault level + # ---------------------------------------------------------- + + result.faults.extend( + + timestep[ + "faults" + ] + ) + + # ---------------------------------------------------------- + # Incremento temporale + # ---------------------------------------------------------- + + timestamp += timedelta( + + minutes= + config.timestep_minutes + ) + + return result + + # ------------------------------------------------------------------ + # Simulazione rapida + # ------------------------------------------------------------------ + + def run_single_day( + self, + date: datetime, + timestep_minutes: int = 5 + ) -> SimulationResult: + """ + Esegue una simulazione di una singola 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 + ) + + return self.run( + config + ) + + # ------------------------------------------------------------------ + # Simulazione annuale + # ------------------------------------------------------------------ + + def run_year( + self, + year: int, + timestep_minutes: int = 15 + ) -> SimulationResult: + """ + Esegue una simulazione annuale. + + Nota: + per una simulazione annuale a 5 minuti + il numero di record a livello pannello + può diventare molto elevato. + + Per questo motivo il default è 15 minuti. + """ + + 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, + + years_from_start= + 0.0 + ) + + return self.run( + config + ) + + # ------------------------------------------------------------------ + # Riepilogo + # ------------------------------------------------------------------ + + def summary( + self, + result: SimulationResult + ) -> Dict[str, Any]: + """ + Calcola un riepilogo della simulazione. + """ + + summary = { + + "plant_id": + self.plant.plant_id, + + "inverters": + self.plant.get_inverter_count(), + + "combiners": + self.plant.get_combiner_count(), + + "panels": + self.plant.get_panel_count(), + + "plant_records": + len( + result.plant + ), + + "inverter_records": + len( + result.inverter + ), + + "combiner_records": + len( + result.combiner + ), + + "panel_records": + len( + result.panel + ), + + "fault_records": + len( + result.faults + ), + + "total_ac_energy_Wh": + self.plant.total_ac_energy_Wh, + + "total_dc_energy_Wh": + self.plant.total_dc_energy_Wh + } + + return summary + + # ------------------------------------------------------------------ + # Rappresentazione + # ------------------------------------------------------------------ + + def __repr__( + self + ) -> str: + """ + Rappresentazione del simulatore. + """ + + 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()})" + ) +```