diff --git a/pvsim/plant.py b/pvsim/plant.py new file mode 100644 index 0000000..538be08 --- /dev/null +++ b/pvsim/plant.py @@ -0,0 +1,891 @@ +```python +""" +plant.py + +Modello gerarchico completo dell'impianto fotovoltaico. + +Gerarchia: + + PVPlant + | + +-- Inverter + | | + | +-- CombinerBox + | | + | +-- PVPanel + | +-- PVPanel + | +-- ... + | + +-- Inverter + | + +-- CombinerBox + | + +-- PVPanel + +Il modulo costruisce automaticamente l'intero impianto +a partire dal file di configurazione plant.json. + +Responsabilità: + +- creazione della gerarchia; +- gestione degli inverter; +- gestione delle Combiner Box; +- creazione automatica dei pannelli; +- aggiornamento dell'intero impianto; +- aggregazione della potenza; +- aggregazione dell'energia; +- calcolo della disponibilità; +- diagnostica della struttura. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List, Any +import json +from pathlib import Path + +from .panel import PVPanel +from .combiner import CombinerBox +from .inverter import Inverter + + +@dataclass +class PVPlant: + """ + Rappresenta l'intero impianto fotovoltaico. + """ + + plant_id: str + + inverters: List[Inverter] = field( + default_factory=list + ) + + total_energy_Wh: float = 0.0 + + total_dc_energy_Wh: float = 0.0 + + total_ac_energy_Wh: float = 0.0 + + # Numero di timestep elaborati + timestep_count: int = 0 + + # ------------------------------------------------------------------ + # Costruzione da configurazione + # ------------------------------------------------------------------ + + @classmethod + def from_config( + cls, + config: Dict[str, Any] + ) -> "PVPlant": + """ + Crea un impianto completo leggendo la configurazione. + + La configurazione deve avere la struttura: + + layout + | + +-- inverters + | + +-- combiners + | + +-- strings + +-- panels_per_string + + Returns + ------- + PVPlant + Impianto completamente costruito. + """ + + plant_name = ( + config[ + "plant" + ][ + "name" + ] + ) + + plant = cls( + plant_id=plant_name + ) + + module_config = ( + config[ + "module" + ] + ) + + layout_config = ( + config[ + "layout" + ] + ) + + # -------------------------------------------------------------- + # Creazione inverter + # -------------------------------------------------------------- + + for inverter_config in ( + layout_config[ + "inverters" + ] + ): + + inverter = ( + cls._create_inverter( + inverter_config, + module_config + ) + ) + + plant.inverters.append( + inverter + ) + + return plant + + # ------------------------------------------------------------------ + + @classmethod + def from_json( + cls, + config_path: str | Path + ) -> "PVPlant": + """ + Carica la configurazione da un file JSON + e costruisce l'impianto. + """ + + config_path = Path( + config_path + ) + + if not config_path.exists(): + + raise FileNotFoundError( + f"Configurazione non trovata: " + f"{config_path}" + ) + + with open( + config_path, + "r", + encoding="utf-8" + ) as file: + + config = json.load( + file + ) + + return cls.from_config( + config + ) + + # ------------------------------------------------------------------ + # Creazione inverter + # ------------------------------------------------------------------ + + @staticmethod + def _create_inverter( + inverter_config: Dict[str, Any], + module_config: Dict[str, Any] + ) -> Inverter: + """ + Crea un inverter e tutte le relative Combiner Box. + """ + + inverter = Inverter( + + inverter_id= + inverter_config[ + "id" + ], + + nominal_power_kW= + inverter_config[ + "nominal_power_kW" + ], + + nominal_efficiency= + inverter_config[ + "efficiency" + ][ + "nominal" + ], + + night_consumption_W= + inverter_config[ + "efficiency" + ][ + "night_consumption_W" + ] + ) + + # -------------------------------------------------------------- + # Creazione Combiner Box + # -------------------------------------------------------------- + + for combiner_config in ( + inverter_config[ + "combiners" + ] + ): + + combiner = ( + PVPlant._create_combiner( + combiner_config, + module_config + ) + ) + + inverter.add_combiner( + combiner + ) + + return inverter + + # ------------------------------------------------------------------ + # Creazione Combiner Box + # ------------------------------------------------------------------ + + @staticmethod + def _create_combiner( + combiner_config: Dict[str, Any], + module_config: Dict[str, Any] + ) -> CombinerBox: + """ + Crea una Combiner Box e tutti i relativi pannelli. + + La configurazione definisce: + + strings = numero di stringhe + + panels_per_string = + pannelli per ogni stringa + + In questa versione la struttura delle stringhe + viene rappresentata logicamente creando tutti i pannelli + nella Combiner Box. + + L'ID del pannello contiene: + + Combiner ID + String ID + Panel ID + """ + + combiner = CombinerBox( + + combiner_id= + combiner_config[ + "id" + ] + ) + + strings = ( + combiner_config[ + "strings" + ] + ) + + panels_per_string = ( + combiner_config[ + "panels_per_string" + ] + ) + + # -------------------------------------------------------------- + # Parametri pannello + # -------------------------------------------------------------- + + nominal_power = ( + module_config[ + "nominal_power_W" + ] + ) + + electrical = ( + module_config[ + "electrical" + ] + ) + + physical = ( + module_config[ + "physical" + ] + ) + + degradation = ( + module_config[ + "degradation" + ] + ) + + # -------------------------------------------------------------- + # Creazione pannelli + # -------------------------------------------------------------- + + for string_number in range( + 1, + strings + 1 + ): + + for panel_number in range( + 1, + panels_per_string + 1 + ): + + panel_id = ( + f"{combiner_config['id']}" + f"_STR_{string_number:02d}" + f"_PANEL_{panel_number:02d}" + ) + + panel = PVPanel( + + panel_id= + panel_id, + + nominal_power= + nominal_power, + + area= + physical[ + "area_m2" + ], + + vmp= + electrical[ + "vmp_V" + ], + + imp= + electrical[ + "imp_A" + ], + + voc= + electrical[ + "voc_V" + ], + + isc= + electrical[ + "isc_A" + ], + + temp_coeff= + physical[ + "temperature_coefficient_power" + ], + + nominal_temp= + physical[ + "nominal_temperature_C" + ], + + annual_degradation= + degradation[ + "annual_rate" + ] + ) + + combiner.add_panel( + panel + ) + + return combiner + + # ------------------------------------------------------------------ + # Gestione inverter + # ------------------------------------------------------------------ + + def add_inverter( + self, + inverter: Inverter + ) -> None: + """ + Aggiunge un inverter all'impianto. + """ + + self.inverters.append( + inverter + ) + + # ------------------------------------------------------------------ + + def get_inverter_count( + self + ) -> int: + """ + Restituisce il numero di inverter. + """ + + return len( + self.inverters + ) + + # ------------------------------------------------------------------ + + def get_active_inverter_count( + self + ) -> int: + """ + Restituisce il numero di inverter attivi. + """ + + return sum( + + 1 + + for inverter + in self.inverters + + if inverter.enabled + ) + + # ------------------------------------------------------------------ + # Conteggi gerarchici + # ------------------------------------------------------------------ + + def get_combiner_count( + self + ) -> int: + """ + Restituisce il numero totale di Combiner Box. + """ + + return sum( + + len( + inverter.combiners + ) + + for inverter + in self.inverters + ) + + # ------------------------------------------------------------------ + + def get_panel_count( + self + ) -> int: + """ + Restituisce il numero totale di pannelli. + """ + + return sum( + + len( + combiner.panels + ) + + for inverter + in self.inverters + + for combiner + in inverter.combiners + ) + + # ------------------------------------------------------------------ + + def get_active_panel_count( + self + ) -> int: + """ + Restituisce il numero di pannelli attivi. + """ + + return sum( + + 1 + + for inverter + in self.inverters + + for combiner + in inverter.combiners + + for panel + in combiner.panels + + if panel.enabled + ) + + # ------------------------------------------------------------------ + # Aggiornamento impianto + # ------------------------------------------------------------------ + + def update( + self, + timestamp: datetime, + irradiance: float, + ambient_temperature: float, + years_from_start: float, + timestep_minutes: float + ) -> Dict[str, Any]: + """ + Esegue un timestep sull'intero impianto. + + Flusso: + + Plant + | + +-- Inverter + | + +-- Combiner + | + +-- Panel + + Returns + ------- + Dict + Dati aggregati dell'impianto. + """ + + inverter_data = [] + + # -------------------------------------------------------------- + # Aggiornamento inverter + # -------------------------------------------------------------- + + for inverter in self.inverters: + + data = inverter.update( + + timestamp= + timestamp, + + irradiance= + irradiance, + + ambient_temperature= + ambient_temperature, + + years_from_start= + years_from_start, + + timestep_minutes= + timestep_minutes + ) + + inverter_data.append( + data + ) + + # -------------------------------------------------------------- + # Aggregazione potenza + # -------------------------------------------------------------- + + total_dc_power = sum( + + data[ + "dc_power_W" + ] + + for data + in inverter_data + ) + + total_ac_power = sum( + + data[ + "ac_power_W" + ] + + for data + in inverter_data + ) + + # -------------------------------------------------------------- + # Aggregazione energia + # -------------------------------------------------------------- + + dc_energy = ( + + total_dc_power + + * timestep_minutes + + / 60.0 + ) + + ac_energy = ( + + total_ac_power + + * timestep_minutes + + / 60.0 + ) + + self.total_dc_energy_Wh += ( + dc_energy + ) + + self.total_ac_energy_Wh += ( + ac_energy + ) + + self.total_energy_Wh += ( + ac_energy + ) + + self.timestep_count += 1 + + # -------------------------------------------------------------- + # Disponibilità + # -------------------------------------------------------------- + + total_inverters = ( + self.get_inverter_count() + ) + + active_inverters = ( + self.get_active_inverter_count() + ) + + if total_inverters > 0: + + availability = ( + + active_inverters + + / total_inverters + ) + + else: + + availability = 0.0 + + # -------------------------------------------------------------- + # Output + # -------------------------------------------------------------- + + return { + + "timestamp": + timestamp, + + "plant_id": + self.plant_id, + + "dc_power_W": + total_dc_power, + + "ac_power_W": + total_ac_power, + + "dc_energy_Wh": + dc_energy, + + "ac_energy_Wh": + ac_energy, + + "total_energy_Wh": + self.total_energy_Wh, + + "dc_total_energy_Wh": + self.total_dc_energy_Wh, + + "ac_total_energy_Wh": + self.total_ac_energy_Wh, + + "inverters": + total_inverters, + + "active_inverters": + active_inverters, + + "combiners": + self.get_combiner_count(), + + "panels": + self.get_panel_count(), + + "active_panels": + self.get_active_panel_count(), + + "availability": + availability, + + "enabled": + True + } + + # ------------------------------------------------------------------ + # Struttura impianto + # ------------------------------------------------------------------ + + def describe( + self + ) -> str: + """ + Restituisce una descrizione testuale + della gerarchia dell'impianto. + """ + + lines = [] + + lines.append( + f"PV Plant: " + f"{self.plant_id}" + ) + + lines.append( + f" Inverters: " + f"{self.get_inverter_count()}" + ) + + lines.append( + f" Combiners: " + f"{self.get_combiner_count()}" + ) + + lines.append( + f" Panels: " + f"{self.get_panel_count()}" + ) + + lines.append("") + + for inverter in self.inverters: + + lines.append( + + f" ├── " + f"{inverter.inverter_id} " + f"({inverter.nominal_power_kW} kW)" + ) + + for combiner in ( + inverter.combiners + ): + + lines.append( + + f" │ ├── " + f"{combiner.combiner_id} " + f"({len(combiner.panels)} panels)" + ) + + # Raggruppamento logico delle stringhe + panel_count = ( + len( + combiner.panels + ) + ) + + lines.append( + + f" │ │ └── " + f"{panel_count} panels" + ) + + return "\n".join( + lines + ) + + # ------------------------------------------------------------------ + # Ricerca componenti + # ------------------------------------------------------------------ + + def find_inverter( + self, + inverter_id: str + ) -> Inverter | None: + """ + Cerca un inverter tramite ID. + """ + + for inverter in self.inverters: + + if ( + inverter.inverter_id + == inverter_id + ): + + return inverter + + return None + + # ------------------------------------------------------------------ + + def find_combiner( + self, + combiner_id: str + ) -> CombinerBox | None: + """ + Cerca una Combiner Box tramite ID. + """ + + for inverter in self.inverters: + + for combiner in ( + inverter.combiners + ): + + if ( + combiner.combiner_id + == combiner_id + ): + + return combiner + + return None + + # ------------------------------------------------------------------ + + def find_panel( + self, + panel_id: str + ) -> PVPanel | None: + """ + Cerca un pannello tramite ID. + """ + + for inverter in self.inverters: + + for combiner in ( + inverter.combiners + ): + + for panel in ( + combiner.panels + ): + + if ( + panel.panel_id + == panel_id + ): + + return panel + + return None + + # ------------------------------------------------------------------ + + def __repr__( + self + ) -> str: + """ + Rappresentazione leggibile dell'impianto. + """ + + return ( + + f"PVPlant(" + f"id={self.plant_id}, " + f"inverters=" + f"{self.get_inverter_count()}, " + f"combiners=" + f"{self.get_combiner_count()}, " + f"panels=" + f"{self.get_panel_count()})" + ) +```