```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)" ) ```