diff --git a/pvsim/sun.py b/pvsim/sun.py new file mode 100644 index 0000000..0f04d6a --- /dev/null +++ b/pvsim/sun.py @@ -0,0 +1,679 @@ +```python +""" +sun.py + +Modello della posizione del sole per il simulatore fotovoltaico. + +Utilizza pvlib per calcolare: + +- elevazione solare; +- azimuth solare; +- zenith solare; +- sunrise; +- sunset; +- durata del giorno; +- angolo di incidenza sui pannelli; +- fattore geometrico di incidenza. + +Il modulo non simula ancora nuvole, pioggia o irraggiamento. +Queste funzionalità saranno gestite successivamente da weather.py. + +Flusso: + + Timestamp + | + v + SunModel + | + +-- Solar Position + | + +-- Sunrise / Sunset + | + +-- Daylight + | + +-- Panel Geometry + | + v + Weather Model + | + v + PV Panel +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Dict, Optional + +import numpy as np +import pandas as pd +import pvlib + + +@dataclass +class SunModel: + """ + Modello della posizione solare. + + Parameters + ---------- + latitude: + Latitudine del sito [gradi]. + + longitude: + Longitudine del sito [gradi]. + + timezone: + Timezone del sito, ad esempio "Europe/Rome". + + altitude: + Altitudine del sito sul livello del mare [m]. + """ + + latitude: float + + longitude: float + + timezone: str = "UTC" + + altitude: float = 0.0 + + # ------------------------------------------------------------------ + # Creazione localizzazione + # ------------------------------------------------------------------ + + def get_location( + self + ) -> pvlib.location.Location: + """ + Restituisce un oggetto pvlib Location. + """ + + return pvlib.location.Location( + + latitude=self.latitude, + + longitude=self.longitude, + + tz=self.timezone, + + altitude=self.altitude + ) + + # ------------------------------------------------------------------ + # Preparazione timestamp + # ------------------------------------------------------------------ + + def _prepare_timestamp( + self, + timestamp: datetime + ) -> pd.DatetimeIndex: + """ + Converte un datetime in DatetimeIndex timezone-aware. + + Se il datetime non contiene timezone viene interpretato + nella timezone configurata per l'impianto. + """ + + ts = pd.Timestamp( + timestamp + ) + + if ts.tzinfo is None: + + ts = ts.tz_localize( + self.timezone + ) + + else: + + ts = ts.tz_convert( + self.timezone + ) + + return pd.DatetimeIndex( + [ts] + ) + + # ------------------------------------------------------------------ + # Posizione del sole + # ------------------------------------------------------------------ + + def solar_position( + self, + timestamp: datetime + ) -> Dict[str, float]: + """ + Calcola la posizione del sole per un determinato istante. + + Returns + ------- + Dict + Dizionario contenente: + + - apparent_zenith + - zenith + - apparent_elevation + - elevation + - azimuth + - equation_of_time + """ + + times = ( + self._prepare_timestamp( + timestamp + ) + ) + + location = ( + self.get_location() + ) + + position = ( + location.get_solarposition( + times + ) + ) + + row = position.iloc[0] + + return { + + "apparent_zenith_deg": + float( + row[ + "apparent_zenith" + ] + ), + + "zenith_deg": + float( + row[ + "zenith" + ] + ), + + "apparent_elevation_deg": + float( + row[ + "apparent_elevation" + ] + ), + + "elevation_deg": + float( + row[ + "elevation" + ] + ), + + "azimuth_deg": + float( + row[ + "azimuth" + ] + ), + + "equation_of_time_minutes": + float( + row[ + "equation_of_time" + ] + ) + } + + # ------------------------------------------------------------------ + # Posizione solare per serie temporale + # ------------------------------------------------------------------ + + def solar_position_series( + self, + timestamps: pd.DatetimeIndex + ) -> pd.DataFrame: + """ + Calcola la posizione solare per una serie temporale completa. + + Utile per simulazioni annuali dove è inefficiente calcolare + la posizione del sole timestamp per timestamp. + """ + + if not isinstance( + timestamps, + pd.DatetimeIndex + ): + + timestamps = pd.DatetimeIndex( + timestamps + ) + + if timestamps.tz is None: + + timestamps = timestamps.tz_localize( + self.timezone + ) + + else: + + timestamps = timestamps.tz_convert( + self.timezone + ) + + location = ( + self.get_location() + ) + + return location.get_solarposition( + timestamps + ) + + # ------------------------------------------------------------------ + # Giorno / notte + # ------------------------------------------------------------------ + + def is_daylight( + self, + timestamp: datetime + ) -> bool: + """ + Determina se il sole è sopra l'orizzonte. + + Viene utilizzata un'elevazione solare di 0 gradi + come soglia geometrica. + """ + + position = ( + self.solar_position( + timestamp + ) + ) + + return ( + position[ + "apparent_elevation_deg" + ] + > 0.0 + ) + + # ------------------------------------------------------------------ + + def is_sun_above_horizon( + self, + timestamp: datetime + ) -> bool: + """ + Alias esplicito per verificare se il sole è sopra l'orizzonte. + """ + + return self.is_daylight( + timestamp + ) + + # ------------------------------------------------------------------ + # Alba e tramonto + # ------------------------------------------------------------------ + + def sunrise_sunset( + self, + date: datetime + ) -> Dict[str, Optional[datetime]]: + """ + Calcola alba e tramonto per una determinata data. + + Returns + ------- + Dict + Contiene: + + sunrise + sunset + """ + + location = ( + self.get_location() + ) + + day = pd.Timestamp( + date + ) + + if day.tzinfo is None: + + day = day.tz_localize( + self.timezone + ) + + else: + + day = day.tz_convert( + self.timezone + ) + + times = pd.date_range( + + start=day.normalize(), + + end=( + day.normalize() + + pd.Timedelta( + days=1 + ) + ), + + freq="1min", + + inclusive="left" + ) + + solar_events = ( + pvlib.solarposition.sun_rise_set_transit_spa( + + times, + + latitude= + self.latitude, + + longitude= + self.longitude, + + altitude= + self.altitude + ) + ) + + # Cerchiamo l'evento relativo al giorno richiesto + sunrise = solar_events[ + "sunrise" + ].iloc[0] + + sunset = solar_events[ + "sunset" + ].iloc[0] + + return { + + "sunrise": + sunrise.to_pydatetime() + if pd.notna(sunrise) + else None, + + "sunset": + sunset.to_pydatetime() + if pd.notna(sunset) + else None + } + + # ------------------------------------------------------------------ + # Durata del giorno + # ------------------------------------------------------------------ + + def daylight_duration_hours( + self, + date: datetime + ) -> float: + """ + Calcola la durata del giorno in ore. + """ + + events = ( + self.sunrise_sunset( + date + ) + ) + + sunrise = events[ + "sunrise" + ] + + sunset = events[ + "sunset" + ] + + if ( + sunrise is None + or sunset is None + ): + + return 0.0 + + duration = ( + sunset - sunrise + ) + + return ( + duration.total_seconds() + / 3600.0 + ) + + # ------------------------------------------------------------------ + # Angolo di incidenza + # ------------------------------------------------------------------ + + def angle_of_incidence( + self, + timestamp: datetime, + surface_tilt_deg: float, + surface_azimuth_deg: float + ) -> float: + """ + Calcola l'angolo di incidenza della radiazione solare + sulla superficie del pannello. + + Parameters + ---------- + surface_tilt_deg: + Inclinazione del pannello rispetto all'orizzontale. + + surface_azimuth_deg: + Azimuth della superficie. + + Convenzione pvlib: + 180° = Sud + 90° = Est + 270° = Ovest + 0° = Nord + + Returns + ------- + float + Angolo di incidenza [gradi]. + """ + + position = ( + self.solar_position( + timestamp + ) + ) + + aoi = ( + pvlib.irradiance.aoi( + + surface_tilt= + surface_tilt_deg, + + surface_azimuth= + surface_azimuth_deg, + + solar_zenith= + position[ + "apparent_zenith_deg" + ], + + solar_azimuth= + position[ + "azimuth_deg" + ] + ) + ) + + return float( + aoi + ) + + # ------------------------------------------------------------------ + # Fattore geometrico + # ------------------------------------------------------------------ + + def incidence_factor( + self, + timestamp: datetime, + surface_tilt_deg: float, + surface_azimuth_deg: float + ) -> float: + """ + Calcola un fattore geometrico semplificato + basato sull'angolo di incidenza. + + 1.0 = incidenza ideale + 0.0 = sole dietro il pannello / nessuna produzione + + Il fattore è calcolato come cos(AOI). + """ + + aoi = ( + self.angle_of_incidence( + + timestamp= + + timestamp, + + surface_tilt_deg= + + surface_tilt_deg, + + surface_azimuth_deg= + + surface_azimuth_deg + ) + ) + + if aoi >= 90.0: + + return 0.0 + + factor = np.cos( + np.radians( + aoi + ) + ) + + return float( + max( + factor, + 0.0 + ) + ) + + # ------------------------------------------------------------------ + # Output completo + # ------------------------------------------------------------------ + + def get_conditions( + self, + timestamp: datetime, + surface_tilt_deg: float = 30.0, + surface_azimuth_deg: float = 180.0 + ) -> Dict[str, float]: + """ + Restituisce tutte le principali informazioni solari + per un pannello o una superficie fotovoltaica. + + Returns + ------- + Dict + Condizioni solari complete. + """ + + position = ( + self.solar_position( + timestamp + ) + ) + + daylight = ( + position[ + "apparent_elevation_deg" + ] + > 0.0 + ) + + if daylight: + + aoi = ( + self.angle_of_incidence( + + timestamp, + + surface_tilt_deg, + + surface_azimuth_deg + ) + ) + + incidence = ( + self.incidence_factor( + + timestamp, + + surface_tilt_deg, + + surface_azimuth_deg + ) + ) + + else: + + aoi = 90.0 + + incidence = 0.0 + + return { + + "timestamp": + timestamp, + + "solar_elevation_deg": + position[ + "apparent_elevation_deg" + ], + + "solar_azimuth_deg": + position[ + "azimuth_deg" + ], + + "solar_zenith_deg": + position[ + "apparent_zenith_deg" + ], + + "angle_of_incidence_deg": + aoi, + + "incidence_factor": + incidence, + + "daylight": + daylight + } + + # ------------------------------------------------------------------ + # Rappresentazione + # ------------------------------------------------------------------ + + def __repr__( + self + ) -> str: + """ + Rappresentazione leggibile del modello solare. + """ + + return ( + + f"SunModel(" + f"latitude={self.latitude}, " + f"longitude={self.longitude}, " + f"timezone='{self.timezone}', " + f"altitude={self.altitude}m)" + ) +```