2270 lines
44 KiB
Python
2270 lines
44 KiB
Python
```python
|
|
"""
|
|
pvsim/simulator.py
|
|
|
|
Motore centrale della simulazione fotovoltaica.
|
|
|
|
Il simulatore coordina:
|
|
|
|
SunModel
|
|
|
|
|
v
|
|
WeatherModel
|
|
|
|
|
v
|
|
FaultManager
|
|
|
|
|
v
|
|
PVPlant
|
|
|
|
|
+-- Inverter
|
|
| |
|
|
| +-- CombinerBox
|
|
| |
|
|
| +-- PVPanel
|
|
|
|
|
v
|
|
SimulationStep
|
|
|
|
|
+-- WeatherData
|
|
+-- PanelData[]
|
|
+-- CombinerData[]
|
|
+-- InverterData[]
|
|
+-- PlantData
|
|
+-- FaultData[]
|
|
|
|
Il simulatore non utilizza più dizionari generici
|
|
per rappresentare la telemetria.
|
|
|
|
Tutti i dati prodotti vengono convertiti nei modelli
|
|
definiti in pvsim.models.
|
|
|
|
Output gerarchico:
|
|
|
|
Plant
|
|
|
|
|
+-- Inverter
|
|
| |
|
|
| +-- Combiner
|
|
| |
|
|
| +-- Panel
|
|
|
|
|
+-- Faults
|
|
|
|
|
+-- Weather
|
|
|
|
Il risultato finale è contenuto in SimulationResult.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Dict, List
|
|
|
|
import pandas as pd
|
|
|
|
from .plant import PVPlant
|
|
from .sun import SunModel
|
|
from .weather import WeatherModel
|
|
from .faults import (
|
|
FaultManager,
|
|
ComponentLevel,
|
|
)
|
|
|
|
from .models import (
|
|
WeatherData,
|
|
PanelData,
|
|
CombinerData,
|
|
InverterData,
|
|
PlantData,
|
|
FaultData,
|
|
SimulationStep,
|
|
create_panel_data,
|
|
)
|
|
|
|
|
|
# ======================================================================
|
|
# CONFIGURAZIONE
|
|
# ======================================================================
|
|
|
|
|
|
@dataclass
|
|
class SimulationConfig:
|
|
"""
|
|
Configurazione temporale della simulazione.
|
|
|
|
Parameters
|
|
----------
|
|
start:
|
|
Timestamp iniziale.
|
|
|
|
end:
|
|
Timestamp finale.
|
|
|
|
timestep_minutes:
|
|
Durata del timestep in minuti.
|
|
|
|
years_from_start:
|
|
Anni trascorsi dall'installazione dell'impianto.
|
|
|
|
generate_random_faults:
|
|
Abilita la generazione automatica dei fault.
|
|
|
|
store_panel_data:
|
|
Memorizza i dati dei pannelli.
|
|
|
|
store_combiner_data:
|
|
Memorizza i dati dei combiner.
|
|
|
|
store_inverter_data:
|
|
Memorizza i dati degli inverter.
|
|
|
|
store_plant_data:
|
|
Memorizza i dati dell'impianto.
|
|
|
|
store_weather_data:
|
|
Memorizza i dati meteorologici.
|
|
|
|
store_fault_data:
|
|
Memorizza i dati dei fault.
|
|
"""
|
|
|
|
start: datetime
|
|
|
|
end: datetime
|
|
|
|
timestep_minutes: int = 5
|
|
|
|
years_from_start: float = 0.0
|
|
|
|
generate_random_faults: bool = False
|
|
|
|
store_panel_data: bool = True
|
|
|
|
store_combiner_data: bool = True
|
|
|
|
store_inverter_data: bool = True
|
|
|
|
store_plant_data: bool = True
|
|
|
|
store_weather_data: bool = True
|
|
|
|
store_fault_data: bool = True
|
|
|
|
def __post_init__(
|
|
self,
|
|
) -> None:
|
|
|
|
if self.end <= self.start:
|
|
|
|
raise ValueError(
|
|
"end deve essere maggiore di start"
|
|
)
|
|
|
|
if self.timestep_minutes <= 0:
|
|
|
|
raise ValueError(
|
|
"timestep_minutes deve essere maggiore di zero"
|
|
)
|
|
|
|
|
|
# ======================================================================
|
|
# RISULTATI
|
|
# ======================================================================
|
|
|
|
|
|
@dataclass
|
|
class SimulationResult:
|
|
"""
|
|
Risultato completo della simulazione.
|
|
|
|
La struttura principale è:
|
|
|
|
steps
|
|
|
|
|
+-- SimulationStep
|
|
|
|
|
+-- WeatherData
|
|
+-- PanelData[]
|
|
+-- CombinerData[]
|
|
+-- InverterData[]
|
|
+-- PlantData
|
|
+-- FaultData[]
|
|
|
|
Per comodità vengono inoltre mantenuti metodi
|
|
per ottenere direttamente DataFrame tabellari.
|
|
"""
|
|
|
|
steps: List[
|
|
SimulationStep
|
|
] = field(
|
|
default_factory=list
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Proprietà
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def panel_records(
|
|
self,
|
|
) -> List[PanelData]:
|
|
|
|
records = []
|
|
|
|
for step in self.steps:
|
|
|
|
records.extend(
|
|
step.panels
|
|
)
|
|
|
|
return records
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def combiner_records(
|
|
self,
|
|
) -> List[CombinerData]:
|
|
|
|
records = []
|
|
|
|
for step in self.steps:
|
|
|
|
records.extend(
|
|
step.combiners
|
|
)
|
|
|
|
return records
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def inverter_records(
|
|
self,
|
|
) -> List[InverterData]:
|
|
|
|
records = []
|
|
|
|
for step in self.steps:
|
|
|
|
records.extend(
|
|
step.inverters
|
|
)
|
|
|
|
return records
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def plant_records(
|
|
self,
|
|
) -> List[PlantData]:
|
|
|
|
records = []
|
|
|
|
for step in self.steps:
|
|
|
|
if step.plant is not None:
|
|
|
|
records.append(
|
|
step.plant
|
|
)
|
|
|
|
return records
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def weather_records(
|
|
self,
|
|
) -> List[WeatherData]:
|
|
|
|
records = []
|
|
|
|
for step in self.steps:
|
|
|
|
if step.weather is not None:
|
|
|
|
records.append(
|
|
step.weather
|
|
)
|
|
|
|
return records
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def fault_records(
|
|
self,
|
|
) -> List[FaultData]:
|
|
|
|
records = []
|
|
|
|
for step in self.steps:
|
|
|
|
records.extend(
|
|
step.faults
|
|
)
|
|
|
|
return records
|
|
|
|
# ==================================================================
|
|
# DATAFRAME
|
|
# ==================================================================
|
|
|
|
def _records_to_dataframe(
|
|
self,
|
|
records: List[Any],
|
|
) -> pd.DataFrame:
|
|
"""
|
|
Converte una lista di modelli in DataFrame.
|
|
"""
|
|
|
|
if not records:
|
|
|
|
return pd.DataFrame()
|
|
|
|
return pd.DataFrame(
|
|
|
|
[
|
|
record.to_dict()
|
|
|
|
for record
|
|
in records
|
|
]
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def panel_dataframe(
|
|
self,
|
|
) -> pd.DataFrame:
|
|
|
|
return self._records_to_dataframe(
|
|
|
|
self.panel_records
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def combiner_dataframe(
|
|
self,
|
|
) -> pd.DataFrame:
|
|
|
|
return self._records_to_dataframe(
|
|
|
|
self.combiner_records
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def inverter_dataframe(
|
|
self,
|
|
) -> pd.DataFrame:
|
|
|
|
return self._records_to_dataframe(
|
|
|
|
self.inverter_records
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def plant_dataframe(
|
|
self,
|
|
) -> pd.DataFrame:
|
|
|
|
return self._records_to_dataframe(
|
|
|
|
self.plant_records
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def weather_dataframe(
|
|
self,
|
|
) -> pd.DataFrame:
|
|
|
|
return self._records_to_dataframe(
|
|
|
|
self.weather_records
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def faults_dataframe(
|
|
self,
|
|
) -> pd.DataFrame:
|
|
|
|
return self._records_to_dataframe(
|
|
|
|
self.fault_records
|
|
)
|
|
|
|
# ==================================================================
|
|
# EXPORT CSV
|
|
# ==================================================================
|
|
|
|
def export_csv(
|
|
self,
|
|
output_dir: str,
|
|
) -> None:
|
|
"""
|
|
Esporta i dati della simulazione in CSV.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
output_path = Path(
|
|
output_dir
|
|
)
|
|
|
|
output_path.mkdir(
|
|
|
|
parents=True,
|
|
|
|
exist_ok=True
|
|
)
|
|
|
|
datasets = {
|
|
|
|
"weather":
|
|
self.weather_dataframe(),
|
|
|
|
"panel":
|
|
self.panel_dataframe(),
|
|
|
|
"combiner":
|
|
self.combiner_dataframe(),
|
|
|
|
"inverter":
|
|
self.inverter_dataframe(),
|
|
|
|
"plant":
|
|
self.plant_dataframe(),
|
|
|
|
"faults":
|
|
self.faults_dataframe(),
|
|
}
|
|
|
|
for name, dataframe in datasets.items():
|
|
|
|
if dataframe.empty:
|
|
|
|
continue
|
|
|
|
dataframe.to_csv(
|
|
|
|
output_path
|
|
/ f"{name}.csv",
|
|
|
|
index=False
|
|
)
|
|
|
|
# ==================================================================
|
|
# EXPORT PARQUET
|
|
# ==================================================================
|
|
|
|
def export_parquet(
|
|
self,
|
|
output_dir: str,
|
|
) -> None:
|
|
"""
|
|
Esporta i dati in formato Parquet.
|
|
|
|
Richiede pyarrow o fastparquet.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
output_path = Path(
|
|
output_dir
|
|
)
|
|
|
|
output_path.mkdir(
|
|
|
|
parents=True,
|
|
|
|
exist_ok=True
|
|
)
|
|
|
|
datasets = {
|
|
|
|
"weather":
|
|
self.weather_dataframe(),
|
|
|
|
"panel":
|
|
self.panel_dataframe(),
|
|
|
|
"combiner":
|
|
self.combiner_dataframe(),
|
|
|
|
"inverter":
|
|
self.inverter_dataframe(),
|
|
|
|
"plant":
|
|
self.plant_dataframe(),
|
|
|
|
"faults":
|
|
self.faults_dataframe(),
|
|
}
|
|
|
|
for name, dataframe in datasets.items():
|
|
|
|
if dataframe.empty:
|
|
|
|
continue
|
|
|
|
dataframe.to_parquet(
|
|
|
|
output_path
|
|
/ f"{name}.parquet",
|
|
|
|
index=False
|
|
)
|
|
|
|
# ==================================================================
|
|
# SUMMARY
|
|
# ==================================================================
|
|
|
|
def summary(
|
|
self,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Restituisce un riepilogo del risultato.
|
|
"""
|
|
|
|
return {
|
|
|
|
"timesteps":
|
|
len(
|
|
self.steps
|
|
),
|
|
|
|
"weather_records":
|
|
len(
|
|
self.weather_records
|
|
),
|
|
|
|
"panel_records":
|
|
len(
|
|
self.panel_records
|
|
),
|
|
|
|
"combiner_records":
|
|
len(
|
|
self.combiner_records
|
|
),
|
|
|
|
"inverter_records":
|
|
len(
|
|
self.inverter_records
|
|
),
|
|
|
|
"plant_records":
|
|
len(
|
|
self.plant_records
|
|
),
|
|
|
|
"fault_records":
|
|
len(
|
|
self.fault_records
|
|
),
|
|
}
|
|
|
|
|
|
# ======================================================================
|
|
# SIMULATORE
|
|
# ======================================================================
|
|
|
|
|
|
@dataclass
|
|
class PVSimulator:
|
|
"""
|
|
Motore principale della simulazione.
|
|
|
|
Parameters
|
|
----------
|
|
plant:
|
|
Modello gerarchico dell'impianto.
|
|
|
|
sun:
|
|
Modello solare.
|
|
|
|
weather:
|
|
Modello meteorologico.
|
|
|
|
fault_manager:
|
|
Gestore dei fault.
|
|
"""
|
|
|
|
plant: PVPlant
|
|
|
|
sun: SunModel
|
|
|
|
weather: WeatherModel
|
|
|
|
fault_manager: FaultManager
|
|
|
|
# ==================================================================
|
|
# ID COMPONENTI
|
|
# ==================================================================
|
|
|
|
def _get_component_ids(
|
|
self,
|
|
) -> Dict[str, List[str]]:
|
|
"""
|
|
Estrae gli ID di tutti i componenti.
|
|
|
|
Gerarchia:
|
|
|
|
inverter
|
|
|
|
|
+-- combiner
|
|
|
|
|
+-- panel
|
|
"""
|
|
|
|
panel_ids = []
|
|
|
|
combiner_ids = []
|
|
|
|
inverter_ids = []
|
|
|
|
for inverter in self.plant.inverters:
|
|
|
|
inverter_ids.append(
|
|
|
|
inverter.inverter_id
|
|
)
|
|
|
|
for combiner in inverter.combiners:
|
|
|
|
combiner_ids.append(
|
|
|
|
combiner.combiner_id
|
|
)
|
|
|
|
for panel in combiner.panels:
|
|
|
|
panel_ids.append(
|
|
|
|
panel.panel_id
|
|
)
|
|
|
|
return {
|
|
|
|
"panel":
|
|
panel_ids,
|
|
|
|
"combiner":
|
|
combiner_ids,
|
|
|
|
"inverter":
|
|
inverter_ids,
|
|
}
|
|
|
|
# ==================================================================
|
|
# WEATHER MODEL
|
|
# ==================================================================
|
|
|
|
def _build_weather_data(
|
|
self,
|
|
timestamp: datetime,
|
|
) -> WeatherData:
|
|
"""
|
|
Converte l'output del WeatherModel
|
|
nel modello WeatherData.
|
|
"""
|
|
|
|
conditions = (
|
|
|
|
self.weather.get_conditions(
|
|
|
|
timestamp=
|
|
|
|
timestamp
|
|
)
|
|
)
|
|
|
|
return WeatherData(
|
|
|
|
timestamp=
|
|
timestamp,
|
|
|
|
ambient_temperature_C=
|
|
|
|
conditions[
|
|
"ambient_temperature_C"
|
|
],
|
|
|
|
solar_elevation_deg=
|
|
|
|
conditions[
|
|
"solar_elevation_deg"
|
|
],
|
|
|
|
solar_azimuth_deg=
|
|
|
|
conditions[
|
|
"solar_azimuth_deg"
|
|
],
|
|
|
|
solar_zenith_deg=
|
|
|
|
conditions[
|
|
"solar_zenith_deg"
|
|
],
|
|
|
|
angle_of_incidence_deg=
|
|
|
|
conditions[
|
|
"angle_of_incidence_deg"
|
|
],
|
|
|
|
ghi_Wm2=
|
|
|
|
conditions[
|
|
"ghi_Wm2"
|
|
],
|
|
|
|
dni_Wm2=
|
|
|
|
conditions[
|
|
"dni_Wm2"
|
|
],
|
|
|
|
dhi_Wm2=
|
|
|
|
conditions[
|
|
"dhi_Wm2"
|
|
],
|
|
|
|
poa_global_Wm2=
|
|
|
|
conditions[
|
|
"poa_global_Wm2"
|
|
],
|
|
|
|
poa_direct_Wm2=
|
|
|
|
conditions[
|
|
"poa_direct_Wm2"
|
|
],
|
|
|
|
poa_diffuse_Wm2=
|
|
|
|
conditions[
|
|
"poa_diffuse_Wm2"
|
|
],
|
|
|
|
cloud_factor=
|
|
|
|
conditions[
|
|
"cloud_factor"
|
|
],
|
|
|
|
rain_factor=
|
|
|
|
conditions[
|
|
"rain_factor"
|
|
],
|
|
|
|
rain_active=
|
|
|
|
conditions[
|
|
"rain_active"
|
|
],
|
|
|
|
daylight=
|
|
|
|
conditions[
|
|
"daylight"
|
|
],
|
|
)
|
|
|
|
# ==================================================================
|
|
# FAULT DATA
|
|
# ==================================================================
|
|
|
|
def _build_fault_data(
|
|
self,
|
|
timestamp: datetime,
|
|
) -> List[FaultData]:
|
|
"""
|
|
Converte i fault attivi in FaultData.
|
|
"""
|
|
|
|
records = []
|
|
|
|
active_faults = (
|
|
|
|
self.fault_manager
|
|
.get_active_faults(
|
|
|
|
timestamp
|
|
)
|
|
)
|
|
|
|
for fault in active_faults:
|
|
|
|
records.append(
|
|
|
|
FaultData(
|
|
|
|
timestamp=
|
|
timestamp,
|
|
|
|
fault_id=
|
|
fault.fault_id,
|
|
|
|
fault_type=
|
|
fault.fault_type.value,
|
|
|
|
component_level=
|
|
fault.component_level.value,
|
|
|
|
component_id=
|
|
fault.component_id,
|
|
|
|
severity=
|
|
fault.severity,
|
|
|
|
reduction_factor=
|
|
fault.reduction_factor(),
|
|
|
|
description=
|
|
fault.description,
|
|
)
|
|
)
|
|
|
|
return records
|
|
|
|
# ==================================================================
|
|
# FAULT AUTOMATICI
|
|
# ==================================================================
|
|
|
|
def _generate_random_faults(
|
|
self,
|
|
timestamp: datetime,
|
|
) -> None:
|
|
"""
|
|
Genera eventuali fault casuali.
|
|
"""
|
|
|
|
ids = (
|
|
|
|
self._get_component_ids()
|
|
)
|
|
|
|
self.fault_manager.simulate_random_faults(
|
|
|
|
timestamp=
|
|
|
|
timestamp,
|
|
|
|
panel_ids=
|
|
|
|
ids[
|
|
"panel"
|
|
],
|
|
|
|
combiner_ids=
|
|
|
|
ids[
|
|
"combiner"
|
|
],
|
|
|
|
inverter_ids=
|
|
|
|
ids[
|
|
"inverter"
|
|
],
|
|
)
|
|
|
|
# ==================================================================
|
|
# PANEL DATA
|
|
# ==================================================================
|
|
|
|
def _build_panel_data(
|
|
self,
|
|
timestamp: datetime,
|
|
weather: WeatherData,
|
|
) -> List[PanelData]:
|
|
"""
|
|
Genera la telemetria di tutti i pannelli.
|
|
|
|
La produzione del pannello viene calcolata
|
|
dal modello PVPanel.
|
|
|
|
Il fault factor viene applicato dopo
|
|
il calcolo della produzione fisica.
|
|
"""
|
|
|
|
records = []
|
|
|
|
for inverter in self.plant.inverters:
|
|
|
|
for combiner in inverter.combiners:
|
|
|
|
for panel in combiner.panels:
|
|
|
|
# --------------------------------------------------
|
|
# Fault
|
|
# --------------------------------------------------
|
|
|
|
fault_factor = (
|
|
|
|
self.fault_manager
|
|
.get_component_factor(
|
|
|
|
panel.panel_id,
|
|
|
|
timestamp
|
|
)
|
|
)
|
|
|
|
# --------------------------------------------------
|
|
# Produzione fisica
|
|
# --------------------------------------------------
|
|
|
|
try:
|
|
|
|
panel_result = (
|
|
|
|
panel.simulate(
|
|
|
|
irradiance=
|
|
|
|
weather
|
|
.poa_global_Wm2,
|
|
|
|
ambient_temperature=
|
|
|
|
weather
|
|
.ambient_temperature_C,
|
|
|
|
timestamp=
|
|
|
|
timestamp
|
|
)
|
|
)
|
|
|
|
dc_power = (
|
|
|
|
panel_result[
|
|
"dc_power_W"
|
|
]
|
|
)
|
|
|
|
panel_temperature = (
|
|
|
|
panel_result.get(
|
|
|
|
"panel_temperature_C",
|
|
|
|
weather
|
|
.ambient_temperature_C
|
|
)
|
|
)
|
|
|
|
except (
|
|
AttributeError,
|
|
TypeError,
|
|
KeyError,
|
|
):
|
|
|
|
# --------------------------------------------------
|
|
# Fallback compatibilità
|
|
# --------------------------------------------------
|
|
|
|
dc_power = (
|
|
|
|
panel.nominal_power
|
|
|
|
* (
|
|
|
|
weather
|
|
.poa_global_Wm2
|
|
|
|
/ 1000.0
|
|
)
|
|
)
|
|
|
|
panel_temperature = (
|
|
|
|
weather
|
|
.ambient_temperature_C
|
|
)
|
|
|
|
# --------------------------------------------------
|
|
# Fault
|
|
# --------------------------------------------------
|
|
|
|
effective_power = (
|
|
|
|
dc_power
|
|
|
|
* fault_factor
|
|
)
|
|
|
|
# --------------------------------------------------
|
|
# Model
|
|
# --------------------------------------------------
|
|
|
|
record = create_panel_data(
|
|
|
|
timestamp=
|
|
|
|
timestamp,
|
|
|
|
panel_id=
|
|
|
|
panel.panel_id,
|
|
|
|
combiner_id=
|
|
|
|
combiner.combiner_id,
|
|
|
|
inverter_id=
|
|
|
|
inverter.inverter_id,
|
|
|
|
dc_power_W=
|
|
|
|
effective_power,
|
|
|
|
nominal_power_W=
|
|
|
|
panel.nominal_power,
|
|
|
|
irradiance_Wm2=
|
|
|
|
weather
|
|
.poa_global_Wm2,
|
|
|
|
panel_temperature_C=
|
|
|
|
panel_temperature,
|
|
|
|
fault_factor=
|
|
|
|
fault_factor,
|
|
|
|
enabled=
|
|
|
|
panel.enabled,
|
|
)
|
|
|
|
records.append(
|
|
record
|
|
)
|
|
|
|
return records
|
|
|
|
# ==================================================================
|
|
# COMBINER DATA
|
|
# ==================================================================
|
|
|
|
def _build_combiner_data(
|
|
self,
|
|
timestamp: datetime,
|
|
panel_records: List[PanelData],
|
|
) -> List[CombinerData]:
|
|
"""
|
|
Aggrega i dati dei pannelli a livello Combiner Box.
|
|
"""
|
|
|
|
records = []
|
|
|
|
for inverter in self.plant.inverters:
|
|
|
|
for combiner in inverter.combiners:
|
|
|
|
panels = [
|
|
|
|
panel
|
|
|
|
for panel
|
|
in panel_records
|
|
|
|
if (
|
|
|
|
panel.combiner_id
|
|
|
|
== combiner.combiner_id
|
|
)
|
|
]
|
|
|
|
if not panels:
|
|
|
|
continue
|
|
|
|
dc_power = sum(
|
|
|
|
panel.dc_power_W
|
|
|
|
for panel in panels
|
|
)
|
|
|
|
nominal_power = sum(
|
|
|
|
panel.nominal_power_W
|
|
|
|
for panel in panels
|
|
)
|
|
|
|
active_panels = sum(
|
|
|
|
1
|
|
|
|
for panel
|
|
in panels
|
|
|
|
if panel.enabled
|
|
and panel.dc_power_W > 0
|
|
)
|
|
|
|
fault_factor = (
|
|
|
|
self.fault_manager
|
|
.get_component_factor(
|
|
|
|
combiner.combiner_id,
|
|
|
|
timestamp
|
|
)
|
|
)
|
|
|
|
# ------------------------------------------------------
|
|
# Applica fault Combiner
|
|
# ------------------------------------------------------
|
|
|
|
effective_power = (
|
|
|
|
dc_power
|
|
|
|
* fault_factor
|
|
)
|
|
|
|
# ------------------------------------------------------
|
|
# Corrente e tensione
|
|
# ------------------------------------------------------
|
|
|
|
dc_voltage = 0.0
|
|
|
|
dc_current = 0.0
|
|
|
|
if effective_power > 0:
|
|
|
|
dc_voltage = 400.0
|
|
|
|
dc_current = (
|
|
|
|
effective_power
|
|
|
|
/ dc_voltage
|
|
)
|
|
|
|
# ------------------------------------------------------
|
|
# Stato
|
|
# ------------------------------------------------------
|
|
|
|
fault_active = (
|
|
|
|
fault_factor < 1.0
|
|
)
|
|
|
|
if fault_active:
|
|
|
|
status = "fault"
|
|
|
|
elif active_panels == 0:
|
|
|
|
status = "idle"
|
|
|
|
else:
|
|
|
|
status = "normal"
|
|
|
|
availability = (
|
|
|
|
active_panels
|
|
|
|
/ len(panels)
|
|
)
|
|
|
|
record = CombinerData(
|
|
|
|
timestamp=
|
|
|
|
timestamp,
|
|
|
|
combiner_id=
|
|
|
|
combiner.combiner_id,
|
|
|
|
inverter_id=
|
|
|
|
inverter.inverter_id,
|
|
|
|
dc_power_W=
|
|
|
|
effective_power,
|
|
|
|
dc_voltage_V=
|
|
|
|
dc_voltage,
|
|
|
|
dc_current_A=
|
|
|
|
dc_current,
|
|
|
|
nominal_power_W=
|
|
|
|
nominal_power,
|
|
|
|
panel_count=
|
|
|
|
len(panels),
|
|
|
|
active_panel_count=
|
|
|
|
active_panels,
|
|
|
|
fault_factor=
|
|
|
|
fault_factor,
|
|
|
|
fault_active=
|
|
|
|
fault_active,
|
|
|
|
availability=
|
|
|
|
availability,
|
|
|
|
status=
|
|
|
|
status,
|
|
)
|
|
|
|
records.append(
|
|
record
|
|
)
|
|
|
|
return records
|
|
|
|
# ==================================================================
|
|
# INVERTER DATA
|
|
# ==================================================================
|
|
|
|
def _build_inverter_data(
|
|
self,
|
|
timestamp: datetime,
|
|
combiner_records: List[CombinerData],
|
|
weather: WeatherData,
|
|
) -> List[InverterData]:
|
|
"""
|
|
Aggrega i dati delle Combiner Box
|
|
a livello inverter.
|
|
"""
|
|
|
|
records = []
|
|
|
|
for inverter in self.plant.inverters:
|
|
|
|
combiners = [
|
|
|
|
combiner
|
|
|
|
for combiner
|
|
in combiner_records
|
|
|
|
if (
|
|
|
|
combiner.inverter_id
|
|
|
|
== inverter.inverter_id
|
|
)
|
|
]
|
|
|
|
if not combiners:
|
|
|
|
continue
|
|
|
|
dc_power = sum(
|
|
|
|
combiner.dc_power_W
|
|
|
|
for combiner
|
|
in combiners
|
|
)
|
|
|
|
nominal_power = sum(
|
|
|
|
combiner.nominal_power_W
|
|
|
|
for combiner
|
|
in combiners
|
|
)
|
|
|
|
active_combiners = sum(
|
|
|
|
1
|
|
|
|
for combiner
|
|
in combiners
|
|
|
|
if combiner.status
|
|
!= "fault"
|
|
)
|
|
|
|
# ----------------------------------------------------------
|
|
# Fault inverter
|
|
# ----------------------------------------------------------
|
|
|
|
fault_factor = (
|
|
|
|
self.fault_manager
|
|
.get_component_factor(
|
|
|
|
inverter.inverter_id,
|
|
|
|
timestamp
|
|
)
|
|
)
|
|
|
|
dc_power *= fault_factor
|
|
|
|
# ----------------------------------------------------------
|
|
# Efficienza
|
|
# ----------------------------------------------------------
|
|
|
|
inverter_efficiency = 0.97
|
|
|
|
ac_power = (
|
|
|
|
dc_power
|
|
|
|
* inverter_efficiency
|
|
)
|
|
|
|
# ----------------------------------------------------------
|
|
# Clipping
|
|
# ----------------------------------------------------------
|
|
|
|
inverter_nominal = (
|
|
|
|
inverter.nominal_power_kW
|
|
|
|
* 1000.0
|
|
)
|
|
|
|
clipping_loss = 0.0
|
|
|
|
if ac_power > inverter_nominal:
|
|
|
|
clipping_loss = (
|
|
|
|
ac_power
|
|
|
|
- inverter_nominal
|
|
)
|
|
|
|
ac_power = (
|
|
inverter_nominal
|
|
)
|
|
|
|
# ----------------------------------------------------------
|
|
# Fault inverter
|
|
# ----------------------------------------------------------
|
|
|
|
fault_active = (
|
|
|
|
fault_factor < 1.0
|
|
)
|
|
|
|
if fault_active:
|
|
|
|
status = "fault"
|
|
|
|
elif ac_power <= 0:
|
|
|
|
status = "idle"
|
|
|
|
else:
|
|
|
|
status = "normal"
|
|
|
|
# ----------------------------------------------------------
|
|
# Elettrico
|
|
# ----------------------------------------------------------
|
|
|
|
dc_voltage = 800.0
|
|
|
|
if dc_voltage > 0:
|
|
|
|
dc_current = (
|
|
|
|
dc_power
|
|
|
|
/ dc_voltage
|
|
)
|
|
|
|
else:
|
|
|
|
dc_current = 0.0
|
|
|
|
ac_voltage = 400.0
|
|
|
|
if ac_voltage > 0:
|
|
|
|
ac_current = (
|
|
|
|
ac_power
|
|
|
|
/ ac_voltage
|
|
)
|
|
|
|
else:
|
|
|
|
ac_current = 0.0
|
|
|
|
# ----------------------------------------------------------
|
|
# Temperatura inverter
|
|
# ----------------------------------------------------------
|
|
|
|
inverter_temperature = (
|
|
|
|
weather.ambient_temperature_C
|
|
|
|
+ (
|
|
|
|
ac_power
|
|
|
|
/ max(
|
|
|
|
inverter_nominal,
|
|
|
|
1.0
|
|
)
|
|
)
|
|
|
|
* 20.0
|
|
)
|
|
|
|
availability = (
|
|
|
|
active_combiners
|
|
|
|
/ len(combiners)
|
|
)
|
|
|
|
record = InverterData(
|
|
|
|
timestamp=
|
|
|
|
timestamp,
|
|
|
|
inverter_id=
|
|
|
|
inverter.inverter_id,
|
|
|
|
dc_power_W=
|
|
|
|
dc_power,
|
|
|
|
ac_power_W=
|
|
|
|
ac_power,
|
|
|
|
dc_voltage_V=
|
|
|
|
dc_voltage,
|
|
|
|
dc_current_A=
|
|
|
|
dc_current,
|
|
|
|
ac_voltage_V=
|
|
|
|
ac_voltage,
|
|
|
|
ac_current_A=
|
|
|
|
ac_current,
|
|
|
|
efficiency=
|
|
|
|
inverter_efficiency,
|
|
|
|
nominal_power_W=
|
|
|
|
inverter_nominal,
|
|
|
|
clipping_loss_W=
|
|
|
|
clipping_loss,
|
|
|
|
temperature_C=
|
|
|
|
inverter_temperature,
|
|
|
|
fault_factor=
|
|
|
|
fault_factor,
|
|
|
|
combiner_count=
|
|
|
|
len(combiners),
|
|
|
|
active_combiner_count=
|
|
|
|
active_combiners,
|
|
|
|
fault_active=
|
|
|
|
fault_active,
|
|
|
|
availability=
|
|
|
|
availability,
|
|
|
|
status=
|
|
|
|
status,
|
|
)
|
|
|
|
records.append(
|
|
record
|
|
)
|
|
|
|
return records
|
|
|
|
# ==================================================================
|
|
# PLANT DATA
|
|
# ==================================================================
|
|
|
|
def _build_plant_data(
|
|
self,
|
|
timestamp: datetime,
|
|
panel_records: List[PanelData],
|
|
combiner_records: List[CombinerData],
|
|
inverter_records: List[InverterData],
|
|
config: SimulationConfig,
|
|
) -> PlantData:
|
|
"""
|
|
Aggrega tutti i dati a livello di impianto.
|
|
"""
|
|
|
|
dc_power = sum(
|
|
|
|
inverter.dc_power_W
|
|
|
|
for inverter
|
|
in inverter_records
|
|
)
|
|
|
|
ac_power = sum(
|
|
|
|
inverter.ac_power_W
|
|
|
|
for inverter
|
|
in inverter_records
|
|
)
|
|
|
|
nominal_power = sum(
|
|
|
|
inverter.nominal_power_W
|
|
|
|
for inverter
|
|
in inverter_records
|
|
)
|
|
|
|
# --------------------------------------------------------------
|
|
# Energia del timestep
|
|
# --------------------------------------------------------------
|
|
|
|
timestep_hours = (
|
|
|
|
config.timestep_minutes
|
|
|
|
/ 60.0
|
|
)
|
|
|
|
energy_Wh = (
|
|
|
|
ac_power
|
|
|
|
* timestep_hours
|
|
)
|
|
|
|
# --------------------------------------------------------------
|
|
# Conteggi
|
|
# --------------------------------------------------------------
|
|
|
|
inverter_count = (
|
|
|
|
len(
|
|
inverter_records
|
|
)
|
|
)
|
|
|
|
active_inverters = sum(
|
|
|
|
1
|
|
|
|
for inverter
|
|
in inverter_records
|
|
|
|
if inverter.status
|
|
!= "fault"
|
|
)
|
|
|
|
combiner_count = (
|
|
|
|
len(
|
|
combiner_records
|
|
)
|
|
)
|
|
|
|
active_combiners = sum(
|
|
|
|
1
|
|
|
|
for combiner
|
|
in combiner_records
|
|
|
|
if combiner.status
|
|
!= "fault"
|
|
)
|
|
|
|
panel_count = (
|
|
|
|
len(
|
|
panel_records
|
|
)
|
|
)
|
|
|
|
active_panels = sum(
|
|
|
|
1
|
|
|
|
for panel
|
|
in panel_records
|
|
|
|
if panel.enabled
|
|
)
|
|
|
|
# --------------------------------------------------------------
|
|
# Efficienza
|
|
# --------------------------------------------------------------
|
|
|
|
if dc_power > 0:
|
|
|
|
efficiency = (
|
|
|
|
ac_power
|
|
|
|
/ dc_power
|
|
)
|
|
|
|
else:
|
|
|
|
efficiency = 0.0
|
|
|
|
# --------------------------------------------------------------
|
|
# Performance Ratio
|
|
# --------------------------------------------------------------
|
|
|
|
if nominal_power > 0:
|
|
|
|
performance_ratio = (
|
|
|
|
ac_power
|
|
|
|
/ nominal_power
|
|
)
|
|
|
|
else:
|
|
|
|
performance_ratio = 0.0
|
|
|
|
# --------------------------------------------------------------
|
|
# Availability
|
|
# --------------------------------------------------------------
|
|
|
|
if panel_count > 0:
|
|
|
|
availability = (
|
|
|
|
active_panels
|
|
|
|
/ panel_count
|
|
)
|
|
|
|
else:
|
|
|
|
availability = 0.0
|
|
|
|
# --------------------------------------------------------------
|
|
# Fault factor
|
|
# --------------------------------------------------------------
|
|
|
|
if panel_count > 0:
|
|
|
|
fault_factor = (
|
|
|
|
sum(
|
|
|
|
panel.fault_factor
|
|
|
|
for panel
|
|
in panel_records
|
|
)
|
|
|
|
/ panel_count
|
|
)
|
|
|
|
else:
|
|
|
|
fault_factor = 1.0
|
|
|
|
fault_active = (
|
|
|
|
fault_factor < 1.0
|
|
)
|
|
|
|
if fault_active:
|
|
|
|
status = "fault"
|
|
|
|
elif ac_power <= 0:
|
|
|
|
status = "idle"
|
|
|
|
else:
|
|
|
|
status = "normal"
|
|
|
|
return PlantData(
|
|
|
|
timestamp=
|
|
|
|
timestamp,
|
|
|
|
plant_id=
|
|
|
|
self.plant.plant_id,
|
|
|
|
dc_power_W=
|
|
|
|
dc_power,
|
|
|
|
ac_power_W=
|
|
|
|
ac_power,
|
|
|
|
effective_ac_power_W=
|
|
|
|
ac_power,
|
|
|
|
nominal_power_W=
|
|
|
|
nominal_power,
|
|
|
|
energy_Wh=
|
|
|
|
energy_Wh,
|
|
|
|
cumulative_energy_Wh=
|
|
|
|
getattr(
|
|
|
|
self.plant,
|
|
|
|
"total_ac_energy_Wh",
|
|
|
|
0.0
|
|
),
|
|
|
|
inverter_count=
|
|
|
|
inverter_count,
|
|
|
|
active_inverter_count=
|
|
|
|
active_inverters,
|
|
|
|
combiner_count=
|
|
|
|
combiner_count,
|
|
|
|
active_combiner_count=
|
|
|
|
active_combiners,
|
|
|
|
panel_count=
|
|
|
|
panel_count,
|
|
|
|
active_panel_count=
|
|
|
|
active_panels,
|
|
|
|
efficiency=
|
|
|
|
efficiency,
|
|
|
|
performance_ratio=
|
|
|
|
performance_ratio,
|
|
|
|
availability=
|
|
|
|
availability,
|
|
|
|
fault_factor=
|
|
|
|
fault_factor,
|
|
|
|
fault_active=
|
|
|
|
fault_active,
|
|
|
|
status=
|
|
|
|
status,
|
|
)
|
|
|
|
# ==================================================================
|
|
# SINGOLO TIMESTEP
|
|
# ==================================================================
|
|
|
|
def simulate_timestep(
|
|
self,
|
|
timestamp: datetime,
|
|
config: SimulationConfig,
|
|
) -> SimulationStep:
|
|
"""
|
|
Esegue un singolo timestep e restituisce
|
|
un SimulationStep completamente tipizzato.
|
|
"""
|
|
|
|
# --------------------------------------------------------------
|
|
# Weather
|
|
# --------------------------------------------------------------
|
|
|
|
weather = (
|
|
|
|
self._build_weather_data(
|
|
|
|
timestamp
|
|
)
|
|
)
|
|
|
|
# --------------------------------------------------------------
|
|
# Random faults
|
|
# --------------------------------------------------------------
|
|
|
|
if config.generate_random_faults:
|
|
|
|
self._generate_random_faults(
|
|
|
|
timestamp
|
|
)
|
|
|
|
# --------------------------------------------------------------
|
|
# Panel
|
|
# --------------------------------------------------------------
|
|
|
|
panel_records = (
|
|
|
|
self._build_panel_data(
|
|
|
|
timestamp,
|
|
|
|
weather
|
|
)
|
|
|
|
if config.store_panel_data
|
|
|
|
else []
|
|
)
|
|
|
|
# --------------------------------------------------------------
|
|
# Combiner
|
|
# --------------------------------------------------------------
|
|
|
|
combiner_records = (
|
|
|
|
self._build_combiner_data(
|
|
|
|
timestamp,
|
|
|
|
panel_records
|
|
)
|
|
|
|
if config.store_combiner_data
|
|
|
|
else []
|
|
)
|
|
|
|
# --------------------------------------------------------------
|
|
# Inverter
|
|
# --------------------------------------------------------------
|
|
|
|
inverter_records = (
|
|
|
|
self._build_inverter_data(
|
|
|
|
timestamp,
|
|
|
|
combiner_records,
|
|
|
|
weather
|
|
)
|
|
|
|
if config.store_inverter_data
|
|
|
|
else []
|
|
)
|
|
|
|
# --------------------------------------------------------------
|
|
# Plant
|
|
# --------------------------------------------------------------
|
|
|
|
plant_record = (
|
|
|
|
self._build_plant_data(
|
|
|
|
timestamp,
|
|
|
|
panel_records,
|
|
|
|
combiner_records,
|
|
|
|
inverter_records,
|
|
|
|
config
|
|
)
|
|
|
|
if config.store_plant_data
|
|
|
|
else None
|
|
)
|
|
|
|
# --------------------------------------------------------------
|
|
# Faults
|
|
# --------------------------------------------------------------
|
|
|
|
fault_records = (
|
|
|
|
self._build_fault_data(
|
|
|
|
timestamp
|
|
)
|
|
|
|
if config.store_fault_data
|
|
|
|
else []
|
|
)
|
|
|
|
# --------------------------------------------------------------
|
|
# SimulationStep
|
|
# --------------------------------------------------------------
|
|
|
|
return SimulationStep(
|
|
|
|
timestamp=
|
|
|
|
timestamp,
|
|
|
|
weather=(
|
|
|
|
weather
|
|
|
|
if config.store_weather_data
|
|
|
|
else None
|
|
),
|
|
|
|
panels=
|
|
|
|
panel_records,
|
|
|
|
combiners=
|
|
|
|
combiner_records,
|
|
|
|
inverters=
|
|
|
|
inverter_records,
|
|
|
|
plant=
|
|
|
|
plant_record,
|
|
|
|
faults=
|
|
|
|
fault_records,
|
|
)
|
|
|
|
# ==================================================================
|
|
# RUN
|
|
# ==================================================================
|
|
|
|
def run(
|
|
self,
|
|
config: SimulationConfig,
|
|
) -> SimulationResult:
|
|
"""
|
|
Esegue la simulazione completa.
|
|
"""
|
|
|
|
result = (
|
|
|
|
SimulationResult()
|
|
)
|
|
|
|
timestamp = (
|
|
|
|
config.start
|
|
)
|
|
|
|
while timestamp < config.end:
|
|
|
|
# ----------------------------------------------------------
|
|
# Simula timestep
|
|
# ----------------------------------------------------------
|
|
|
|
step = (
|
|
|
|
self.simulate_timestep(
|
|
|
|
timestamp,
|
|
|
|
config
|
|
)
|
|
)
|
|
|
|
result.steps.append(
|
|
|
|
step
|
|
)
|
|
|
|
# ----------------------------------------------------------
|
|
# Incremento temporale
|
|
# ----------------------------------------------------------
|
|
|
|
timestamp += timedelta(
|
|
|
|
minutes=
|
|
|
|
config.timestep_minutes
|
|
)
|
|
|
|
return result
|
|
|
|
# ==================================================================
|
|
# RUN SINGLE DAY
|
|
# ==================================================================
|
|
|
|
def run_single_day(
|
|
self,
|
|
date: datetime,
|
|
timestep_minutes: int = 5,
|
|
generate_random_faults: bool = False,
|
|
) -> SimulationResult:
|
|
"""
|
|
Esegue la simulazione di una giornata.
|
|
"""
|
|
|
|
start = datetime(
|
|
|
|
year=
|
|
|
|
date.year,
|
|
|
|
month=
|
|
|
|
date.month,
|
|
|
|
day=
|
|
|
|
date.day
|
|
)
|
|
|
|
end = (
|
|
|
|
start
|
|
|
|
+ timedelta(
|
|
|
|
days=1
|
|
)
|
|
)
|
|
|
|
config = SimulationConfig(
|
|
|
|
start=
|
|
|
|
start,
|
|
|
|
end=
|
|
|
|
end,
|
|
|
|
timestep_minutes=
|
|
|
|
timestep_minutes,
|
|
|
|
generate_random_faults=
|
|
|
|
generate_random_faults,
|
|
)
|
|
|
|
return self.run(
|
|
|
|
config
|
|
)
|
|
|
|
# ==================================================================
|
|
# RUN YEAR
|
|
# ==================================================================
|
|
|
|
def run_year(
|
|
self,
|
|
year: int,
|
|
timestep_minutes: int = 15,
|
|
generate_random_faults: bool = False,
|
|
) -> SimulationResult:
|
|
"""
|
|
Esegue una simulazione annuale.
|
|
"""
|
|
|
|
start = datetime(
|
|
|
|
year=
|
|
|
|
year,
|
|
|
|
month=1,
|
|
|
|
day=1
|
|
)
|
|
|
|
end = datetime(
|
|
|
|
year=
|
|
|
|
year + 1,
|
|
|
|
month=1,
|
|
|
|
day=1
|
|
)
|
|
|
|
config = SimulationConfig(
|
|
|
|
start=
|
|
|
|
start,
|
|
|
|
end=
|
|
|
|
end,
|
|
|
|
timestep_minutes=
|
|
|
|
timestep_minutes,
|
|
|
|
generate_random_faults=
|
|
|
|
generate_random_faults,
|
|
)
|
|
|
|
return self.run(
|
|
|
|
config
|
|
)
|
|
|
|
# ==================================================================
|
|
# SUMMARY
|
|
# ==================================================================
|
|
|
|
def summary(
|
|
self,
|
|
result: SimulationResult,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Restituisce un riepilogo della simulazione.
|
|
"""
|
|
|
|
return {
|
|
|
|
"plant_id":
|
|
|
|
self.plant.plant_id,
|
|
|
|
"inverters":
|
|
|
|
self.plant.get_inverter_count(),
|
|
|
|
"combiners":
|
|
|
|
self.plant.get_combiner_count(),
|
|
|
|
"panels":
|
|
|
|
self.plant.get_panel_count(),
|
|
|
|
**result.summary(),
|
|
}
|
|
|
|
# ==================================================================
|
|
# REPR
|
|
# ==================================================================
|
|
|
|
def __repr__(
|
|
self,
|
|
) -> str:
|
|
|
|
return (
|
|
|
|
f"PVSimulator("
|
|
|
|
f"plant="
|
|
|
|
f"{self.plant.plant_id}, "
|
|
|
|
f"inverters="
|
|
|
|
f"{self.plant.get_inverter_count()}, "
|
|
|
|
f"combiners="
|
|
|
|
f"{self.plant.get_combiner_count()}, "
|
|
|
|
f"panels="
|
|
|
|
f"{self.plant.get_panel_count()})"
|
|
)
|
|
|
|
|
|
# ======================================================================
|
|
# EXPORT
|
|
# ======================================================================
|
|
|
|
|
|
__all__ = [
|
|
|
|
"SimulationConfig",
|
|
|
|
"SimulationResult",
|
|
|
|
"PVSimulator",
|
|
]
|