diff --git a/pvsim/combiner.py b/pvsim/combiner.py new file mode 100644 index 0000000..eee6711 --- /dev/null +++ b/pvsim/combiner.py @@ -0,0 +1,482 @@ +```python +""" +combiner.py + +Modello di una Combiner Box fotovoltaica. + +La Combiner Box raccoglie la produzione di più pannelli fotovoltaici +e fornisce una misura aggregata verso l'inverter. + +Gerarchia: + + PV Plant + | + +-- Inverter + | + +-- Combiner Box + | + +-- Panel + +-- Panel + +-- ... + +Responsabilità principali: + +- gestione dei pannelli collegati; +- aggregazione della potenza DC; +- aggregazione della corrente; +- calcolo della tensione equivalente; +- conteggio dei pannelli attivi; +- simulazione di guasto della combiner; +- simulazione delle perdite elettriche; +- simulazione della temperatura interna; +- accumulo dell'energia prodotta. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List +import random + +from .panel import PVPanel + + +@dataclass +class CombinerBox: + """ + Rappresenta una Combiner Box. + + Parameters + ---------- + combiner_id: + Identificativo univoco della combiner. + + panels: + Lista dei pannelli collegati alla combiner. + + nominal_voltage: + Tensione nominale del bus DC [V]. + + cable_loss_factor: + Fattore di perdita dovuto ai cavi e alle connessioni. + Esempio: 0.99 significa una perdita dell'1%. + + sensor_noise: + Rumore relativo delle misure. + + enabled: + Stato operativo della combiner. + + internal_temperature: + Temperatura interna simulata della combiner [°C]. + """ + + combiner_id: str + + panels: List[PVPanel] = field(default_factory=list) + + nominal_voltage: float = 800.0 + + cable_loss_factor: float = 0.99 + + sensor_noise: float = 0.005 + + enabled: bool = True + + internal_temperature: float = 25.0 + + total_energy_Wh: float = 0.0 + + # ------------------------------------------------------------------ + # Gestione pannelli + # ------------------------------------------------------------------ + + def add_panel(self, panel: PVPanel) -> None: + """ + Aggiunge un pannello alla Combiner Box. + """ + + self.panels.append(panel) + + # ------------------------------------------------------------------ + + def remove_panel(self, panel_id: str) -> None: + """ + Rimuove un pannello dalla Combiner Box utilizzando il suo ID. + """ + + self.panels = [ + panel + for panel in self.panels + if panel.panel_id != panel_id + ] + + # ------------------------------------------------------------------ + + def get_panel_count(self) -> int: + """ + Restituisce il numero totale di pannelli collegati. + """ + + return len(self.panels) + + # ------------------------------------------------------------------ + + def get_active_panel_count(self) -> int: + """ + Restituisce il numero di pannelli attualmente attivi. + """ + + return sum( + 1 + for panel in self.panels + if panel.enabled + ) + + # ------------------------------------------------------------------ + # Calcolo grandezze elettriche + # ------------------------------------------------------------------ + + def calculate_voltage( + self, + panel_data: List[Dict] + ) -> float: + """ + Calcola la tensione equivalente della Combiner Box. + + Per una simulazione semplificata viene utilizzata la tensione + media dei pannelli attivi. + + In una futura versione sarà possibile modellare esplicitamente + stringhe serie/parallelo. + """ + + active_voltages = [ + data["voltage_V"] + for data in panel_data + if data["voltage_V"] > 0 + ] + + if not active_voltages: + return 0.0 + + average_voltage = ( + sum(active_voltages) + / len(active_voltages) + ) + + # Rumore del sensore + noise = random.gauss( + 1.0, + self.sensor_noise + ) + + return average_voltage * noise + + # ------------------------------------------------------------------ + + def calculate_current( + self, + panel_data: List[Dict] + ) -> float: + """ + Calcola la corrente totale della Combiner Box. + + La corrente dei pannelli viene sommata. + """ + + total_current = sum( + data["current_A"] + for data in panel_data + ) + + return total_current + + # ------------------------------------------------------------------ + + def calculate_power( + self, + panel_data: List[Dict] + ) -> float: + """ + Calcola la potenza DC aggregata. + + La potenza dei pannelli viene sommata e successivamente + vengono applicate le perdite dei cavi e delle connessioni. + """ + + raw_power = sum( + data["power_W"] + for data in panel_data + ) + + power_after_losses = ( + raw_power + * self.cable_loss_factor + ) + + if power_after_losses <= 0: + return 0.0 + + # Rumore della misura + noise = random.gauss( + 1.0, + self.sensor_noise + ) + + return max( + 0.0, + power_after_losses * noise + ) + + # ------------------------------------------------------------------ + + def calculate_temperature( + self, + panel_data: List[Dict], + ambient_temperature: float + ) -> float: + """ + Stima la temperatura interna della Combiner Box. + + La temperatura aumenta in funzione della potenza dissipata + e della temperatura ambiente. + """ + + power = sum( + data["power_W"] + for data in panel_data + ) + + # Riscaldamento semplificato dell'elettronica + thermal_effect = ( + power / 10000.0 + ) + + temperature = ( + ambient_temperature + + thermal_effect + ) + + return temperature + + # ------------------------------------------------------------------ + # Ciclo principale + # ------------------------------------------------------------------ + + def update( + self, + timestamp: datetime, + irradiance: float, + ambient_temperature: float, + years_from_start: float, + timestep_minutes: float + ) -> Dict: + """ + Aggiorna lo stato della Combiner Box. + + Per ogni pannello: + + 1. viene chiamato panel.update(); + 2. i dati vengono raccolti; + 3. le grandezze vengono aggregate; + 4. viene restituito un dizionario con le misure. + + Returns + ------- + Dict + Dati aggregati della Combiner Box. + """ + + # -------------------------------------------------------------- + # Combiner offline + # -------------------------------------------------------------- + + if not self.enabled: + + return { + "timestamp": timestamp, + + "combiner_id": + self.combiner_id, + + "power_W": 0.0, + + "current_A": 0.0, + + "voltage_V": 0.0, + + "energy_Wh": 0.0, + + "total_energy_Wh": + self.total_energy_Wh, + + "active_panels": 0, + + "total_panels": + self.get_panel_count(), + + "internal_temperature_C": + ambient_temperature, + + "enabled": False + } + + # -------------------------------------------------------------- + # Aggiornamento pannelli + # -------------------------------------------------------------- + + panel_data = [] + + for panel in self.panels: + + data = panel.update( + timestamp=timestamp, + irradiance=irradiance, + ambient_temperature=ambient_temperature, + years_from_start=years_from_start, + timestep_minutes=timestep_minutes + ) + + panel_data.append(data) + + # -------------------------------------------------------------- + # Aggregazione + # -------------------------------------------------------------- + + power = self.calculate_power( + panel_data + ) + + voltage = self.calculate_voltage( + panel_data + ) + + current = self.calculate_current( + panel_data + ) + + # -------------------------------------------------------------- + # Temperatura + # -------------------------------------------------------------- + + internal_temperature = ( + self.calculate_temperature( + panel_data, + ambient_temperature + ) + ) + + self.internal_temperature = ( + internal_temperature + ) + + # -------------------------------------------------------------- + # Energia + # -------------------------------------------------------------- + + energy = ( + power + * timestep_minutes + / 60.0 + ) + + self.total_energy_Wh += energy + + # -------------------------------------------------------------- + # Output + # -------------------------------------------------------------- + + return { + + "timestamp": + timestamp, + + "combiner_id": + self.combiner_id, + + "power_W": + power, + + "current_A": + current, + + "voltage_V": + voltage, + + "energy_Wh": + energy, + + "total_energy_Wh": + self.total_energy_Wh, + + "active_panels": + self.get_active_panel_count(), + + "total_panels": + self.get_panel_count(), + + "internal_temperature_C": + internal_temperature, + + "enabled": + self.enabled + } + + # ------------------------------------------------------------------ + # Gestione guasti + # ------------------------------------------------------------------ + + def fail(self) -> None: + """ + Mette la Combiner Box offline. + + Tutti i pannelli collegati continueranno a mantenere il + proprio stato interno, ma la Combiner non trasferirà + potenza verso l'inverter. + """ + + self.enabled = False + + # ------------------------------------------------------------------ + + def repair(self) -> None: + """ + Riporta la Combiner Box in servizio. + """ + + self.enabled = True + + # ------------------------------------------------------------------ + + def active_power(self) -> float: + """ + Restituisce una stima della potenza attiva corrente. + + Questo metodo è principalmente utile per diagnostica. + """ + + if not self.enabled: + return 0.0 + + return sum( + panel.nominal_power + for panel in self.panels + if panel.enabled + ) * self.cable_loss_factor + + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + """ + Rappresentazione leggibile della Combiner Box. + """ + + return ( + f"CombinerBox(" + f"id={self.combiner_id}, " + f"panels={len(self.panels)}, " + f"enabled={self.enabled})" + ) +```