680 lines
14 KiB
Python
680 lines
14 KiB
Python
```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)"
|
|
)
|
|
```
|