From f53c6a23e1bf8521cc0d2438bfb02b9e2ba81991 Mon Sep 17 00:00:00 2001 From: Giovanni Date: Sun, 19 Jul 2026 20:11:58 +0000 Subject: [PATCH] Nuova versione di simulator.py --- pvsim/simulator.py | 2496 ++++++++++++++++++++++++++++++++------------ 1 file changed, 1812 insertions(+), 684 deletions(-) diff --git a/pvsim/simulator.py b/pvsim/simulator.py index 73fce6a..2fd1762 100644 --- a/pvsim/simulator.py +++ b/pvsim/simulator.py @@ -1,10 +1,10 @@ ```python """ -simulator.py +pvsim/simulator.py Motore centrale della simulazione fotovoltaica. -Collega: +Il simulatore coordina: SunModel | @@ -17,73 +17,75 @@ Collega: v PVPlant | - +-- Inverters - | | - | +-- CombinerBoxes - | | - | +-- PVPanels + +-- Inverter + | | + | +-- CombinerBox + | | + | +-- PVPanel | v - SimulationResult + SimulationStep + | + +-- WeatherData + +-- PanelData[] + +-- CombinerData[] + +-- InverterData[] + +-- PlantData + +-- FaultData[] -Il simulatore esegue la simulazione timestep per timestep. +Il simulatore non utilizza più dizionari generici +per rappresentare la telemetria. -Per ogni timestamp vengono calcolati: +Tutti i dati prodotti vengono convertiti nei modelli +definiti in pvsim.models. - - posizione del sole; - - condizioni meteorologiche; - - irraggiamento; - - temperatura; - - fault attivi; - - produzione dei pannelli; - - aggregazione Combiner Box; - - aggregazione Inverter; - - aggregazione Plant. +Output gerarchico: -Il modulo è progettato per simulazioni: + Plant + | + +-- Inverter + | | + | +-- Combiner + | | + | +-- Panel + | + +-- Faults + | + +-- Weather - - 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. +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, Optional +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 +from .faults import ( + FaultManager, + ComponentLevel, +) + +from .models import ( + WeatherData, + PanelData, + CombinerData, + InverterData, + PlantData, + FaultData, + SimulationStep, + create_panel_data, +) # ====================================================================== -# CONFIGURAZIONE SIMULAZIONE +# CONFIGURAZIONE # ====================================================================== @@ -104,22 +106,28 @@ class SimulationConfig: 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. + 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 @@ -130,6 +138,8 @@ class SimulationConfig: years_from_start: float = 0.0 + generate_random_faults: bool = False + store_panel_data: bool = True store_combiner_data: bool = True @@ -138,10 +148,12 @@ class SimulationConfig: store_plant_data: bool = True - generate_random_faults: bool = False + store_weather_data: bool = True + + store_fault_data: bool = True def __post_init__( - self + self, ) -> None: if self.end <= self.start: @@ -153,191 +165,413 @@ class SimulationConfig: if self.timestep_minutes <= 0: raise ValueError( - "timestep_minutes deve essere > 0" + "timestep_minutes deve essere maggiore di zero" ) # ====================================================================== -# RISULTATO DELLA SIMULAZIONE +# RISULTATI # ====================================================================== @dataclass class SimulationResult: """ - Contenitore dei risultati della simulazione. + Risultato completo della simulazione. - I dati sono organizzati in quattro livelli: + La struttura principale è: - plant - inverter - combiner - panel + steps + | + +-- SimulationStep + | + +-- WeatherData + +-- PanelData[] + +-- CombinerData[] + +-- InverterData[] + +-- PlantData + +-- FaultData[] - Ogni elemento contiene una lista di record - successivamente convertibili in DataFrame. + Per comodità vengono inoltre mantenuti metodi + per ottenere direttamente DataFrame tabellari. """ - 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( + steps: List[ + SimulationStep + ] = field( default_factory=list ) # ------------------------------------------------------------------ - # Conversione DataFrame + # Proprietà # ------------------------------------------------------------------ - def plant_dataframe( - self + @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( - self.plant - ) - # ------------------------------------------------------------------ + [ + record.to_dict() - def inverter_dataframe( - self - ) -> pd.DataFrame: - - return pd.DataFrame( - self.inverter - ) - - # ------------------------------------------------------------------ - - def combiner_dataframe( - self - ) -> pd.DataFrame: - - return pd.DataFrame( - self.combiner + for record + in records + ] ) # ------------------------------------------------------------------ def panel_dataframe( - self + self, ) -> pd.DataFrame: - return pd.DataFrame( - self.panel + 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 + self, ) -> pd.DataFrame: - return pd.DataFrame( - self.faults + return self._records_to_dataframe( + + self.fault_records ) - # ------------------------------------------------------------------ - # Export CSV - # ------------------------------------------------------------------ + # ================================================================== + # EXPORT CSV + # ================================================================== def export_csv( self, - output_dir: str + output_dir: str, ) -> None: """ - Esporta tutti i livelli in file CSV. + Esporta i dati della simulazione in CSV. """ - import os + from pathlib import Path + + output_path = Path( + output_dir + ) + + output_path.mkdir( + + parents=True, - os.makedirs( - output_dir, exist_ok=True ) - if self.plant: + datasets = { - self.plant_dataframe().to_csv( + "weather": + self.weather_dataframe(), - os.path.join( + "panel": + self.panel_dataframe(), - output_dir, + "combiner": + self.combiner_dataframe(), - "plant.csv" - ), + "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 ) - if self.inverter: + # ================================================================== + # EXPORT PARQUET + # ================================================================== - self.inverter_dataframe().to_csv( + def export_parquet( + self, + output_dir: str, + ) -> None: + """ + Esporta i dati in formato Parquet. - os.path.join( + Richiede pyarrow o fastparquet. + """ - output_dir, + from pathlib import Path - "inverter.csv" - ), + 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 ) - if self.combiner: + # ================================================================== + # SUMMARY + # ================================================================== - self.combiner_dataframe().to_csv( + def summary( + self, + ) -> Dict[str, Any]: + """ + Restituisce un riepilogo del risultato. + """ - os.path.join( + return { - output_dir, - - "combiner.csv" + "timesteps": + len( + self.steps ), - index=False - ) - - if self.panel: - - self.panel_dataframe().to_csv( - - os.path.join( - - output_dir, - - "panel.csv" + "weather_records": + len( + self.weather_records ), - index=False - ) - - if self.faults: - - self.faults_dataframe().to_csv( - - os.path.join( - - output_dir, - - "faults.csv" + "panel_records": + len( + self.panel_records ), - index=False - ) + "combiner_records": + len( + self.combiner_records + ), + + "inverter_records": + len( + self.inverter_records + ), + + "plant_records": + len( + self.plant_records + ), + + "fault_records": + len( + self.fault_records + ), + } # ====================================================================== @@ -356,7 +590,7 @@ class PVSimulator: Modello gerarchico dell'impianto. sun: - Modello della posizione solare. + Modello solare. weather: Modello meteorologico. @@ -373,15 +607,23 @@ class PVSimulator: fault_manager: FaultManager - # ------------------------------------------------------------------ - # Preparazione ID - # ------------------------------------------------------------------ + # ================================================================== + # ID COMPONENTI + # ================================================================== def _get_component_ids( - self + self, ) -> Dict[str, List[str]]: """ - Estrae tutti gli ID della gerarchia. + Estrae gli ID di tutti i componenti. + + Gerarchia: + + inverter + | + +-- combiner + | + +-- panel """ panel_ids = [] @@ -397,18 +639,14 @@ class PVSimulator: inverter.inverter_id ) - for combiner in ( - inverter.combiners - ): + for combiner in inverter.combiners: combiner_ids.append( combiner.combiner_id ) - for panel in ( - combiner.panels - ): + for panel in combiner.panels: panel_ids.append( @@ -424,106 +662,23 @@ class PVSimulator: combiner_ids, "inverter": - inverter_ids + inverter_ids, } - # ------------------------------------------------------------------ - # Applicazione fault - # ------------------------------------------------------------------ + # ================================================================== + # WEATHER MODEL + # ================================================================== - 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( + def _build_weather_data( self, timestamp: datetime, - config: SimulationConfig - ) -> Dict[str, Any]: + ) -> WeatherData: """ - Esegue un singolo timestep. - - Il metodo restituisce i dati gerarchici del timestep. + Converte l'output del WeatherModel + nel modello WeatherData. """ - # -------------------------------------------------------------- - # Condizioni meteorologiche - # -------------------------------------------------------------- - - weather = ( + conditions = ( self.weather.get_conditions( @@ -533,168 +688,1359 @@ class PVSimulator: ) ) - irradiance = ( + return WeatherData( - weather[ - "poa_global_Wm2" - ] + 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" + ], ) - ambient_temperature = ( + # ================================================================== + # FAULT DATA + # ================================================================== - weather[ - "ambient_temperature_C" - ] - ) + def _build_fault_data( + self, + timestamp: datetime, + ) -> List[FaultData]: + """ + Converte i fault attivi in FaultData. + """ - # -------------------------------------------------------------- - # Fault automatici - # -------------------------------------------------------------- + records = [] - if config.generate_random_faults: + active_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, + self.fault_manager + .get_active_faults( timestamp ) ) - plant_data[ - "fault_factor" - ] = plant_factor + for fault in active_faults: - plant_data[ - "effective_ac_power_W" - ] = ( + records.append( - plant_data[ - "ac_power_W" - ] + FaultData( - * plant_factor + 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() ) - # -------------------------------------------------------------- - # Output - # -------------------------------------------------------------- + self.fault_manager.simulate_random_faults( - return { + timestamp= - "timestamp": timestamp, - "weather": - weather, + panel_ids= - "plant": - plant_data, + ids[ + "panel" + ], - "faults": - self._collect_fault_records( + 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 ) - } + ) - # ------------------------------------------------------------------ - # Simulazione completa - # ------------------------------------------------------------------ + 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 + 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 = ( + # ---------------------------------------------------------- + # Simula timestep + # ---------------------------------------------------------- + + step = ( self.simulate_timestep( @@ -704,224 +2050,9 @@ class PVSimulator: ) ) - weather = ( + result.steps.append( - 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" - ] + step ) # ---------------------------------------------------------- @@ -931,33 +2062,38 @@ class PVSimulator: timestamp += timedelta( minutes= + config.timestep_minutes ) return result - # ------------------------------------------------------------------ - # Simulazione rapida - # ------------------------------------------------------------------ + # ================================================================== + # RUN SINGLE DAY + # ================================================================== def run_single_day( self, date: datetime, - timestep_minutes: int = 5 + timestep_minutes: int = 5, + generate_random_faults: bool = False, ) -> SimulationResult: """ - Esegue una simulazione di una singola giornata. + Esegue la simulazione di una giornata. """ start = datetime( year= + date.year, month= + date.month, day= + date.day ) @@ -966,6 +2102,7 @@ class PVSimulator: start + timedelta( + days=1 ) ) @@ -973,37 +2110,39 @@ class PVSimulator: config = SimulationConfig( start= + start, end= + end, timestep_minutes= - timestep_minutes + + timestep_minutes, + + generate_random_faults= + + generate_random_faults, ) return self.run( + config ) - # ------------------------------------------------------------------ - # Simulazione annuale - # ------------------------------------------------------------------ + # ================================================================== + # RUN YEAR + # ================================================================== def run_year( self, year: int, - timestep_minutes: int = 15 + timestep_minutes: int = 15, + generate_random_faults: bool = False, ) -> 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( @@ -1012,13 +2151,9 @@ class PVSimulator: year, - month= + month=1, - 1, - - day= - - 1 + day=1 ) end = datetime( @@ -1027,115 +2162,108 @@ class PVSimulator: year + 1, - month= + month=1, - 1, - - day= - - 1 + day=1 ) config = SimulationConfig( start= + start, end= + end, timestep_minutes= + timestep_minutes, - years_from_start= - 0.0 + generate_random_faults= + + generate_random_faults, ) return self.run( + config ) - # ------------------------------------------------------------------ - # Riepilogo - # ------------------------------------------------------------------ + # ================================================================== + # SUMMARY + # ================================================================== def summary( self, - result: SimulationResult + result: SimulationResult, ) -> Dict[str, Any]: """ - Calcola un riepilogo della simulazione. + Restituisce un riepilogo della simulazione. """ - summary = { + 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(), - "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 + **result.summary(), } - return summary - - # ------------------------------------------------------------------ - # Rappresentazione - # ------------------------------------------------------------------ + # ================================================================== + # REPR + # ================================================================== def __repr__( - self + 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()})" ) -``` + + +# ====================================================================== +# EXPORT +# ====================================================================== + + +__all__ = [ + + "SimulationConfig", + + "SimulationResult", + + "PVSimulator", +]