2024-12-15 14:40:03 +01:00
|
|
|
"""PV forecast module for PV power predictions."""
|
|
|
|
|
2025-07-19 08:55:16 +02:00
|
|
|
from typing import Any, List, Optional, Self, Union
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
from pydantic import Field, computed_field, field_validator, model_validator
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
2025-03-27 21:53:01 +01:00
|
|
|
from akkudoktoreos.prediction.prediction import get_prediction
|
|
|
|
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
2025-01-12 05:19:37 +01:00
|
|
|
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
|
2025-07-19 08:55:16 +02:00
|
|
|
from akkudoktoreos.prediction.pvforecastvrm import PVforecastVrmCommonSettings
|
2025-01-19 18:12:50 +01:00
|
|
|
from akkudoktoreos.utils.docs import get_model_structure_from_examples
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-03-27 21:53:01 +01:00
|
|
|
prediction_eos = get_prediction()
|
|
|
|
|
|
|
|
# Valid PV forecast providers
|
|
|
|
pvforecast_providers = [
|
|
|
|
provider.provider_id()
|
|
|
|
for provider in prediction_eos.providers
|
|
|
|
if isinstance(provider, PVForecastProvider)
|
|
|
|
]
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
class PVForecastPlaneSetting(SettingsBaseModel):
|
|
|
|
"""PV Forecast Plane Configuration."""
|
2025-01-15 00:54:45 +01:00
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
# latitude: Optional[float] = Field(default=None, description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)")
|
|
|
|
surface_tilt: Optional[float] = Field(
|
2025-04-05 13:08:12 +02:00
|
|
|
default=30.0,
|
fix: azimuth setting of pvforecastakkudoktor provider (#567)
EOS now enforces the general azimuth definition as e.g. defined in ISO 19111:
north=0, east=90, south=180, west=270. This is the convention that is and was
in the EOS documentation.
As the PV forecast of akkudoktor.net follows a different convention
(north=+-180, east=-90, south=0, west=90) the values from EOS are now converted
before the request is sent to akkudoktor.net.
BREAKING CHANGE: Azimuth configurations that followed the PVForecastAkkudoktor convention
(north=+-180, east=-90, south=0, west=90) must be converted to the general azimuth definition:
north=0, east=90, south=180, west=270.
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-05-28 20:42:43 +02:00
|
|
|
ge=0.0,
|
|
|
|
le=90.0,
|
2025-01-15 00:54:45 +01:00
|
|
|
description="Tilt angle from horizontal plane. Ignored for two-axis tracking.",
|
2025-01-19 18:12:50 +01:00
|
|
|
examples=[10.0, 20.0],
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
surface_azimuth: Optional[float] = Field(
|
2025-04-05 13:08:12 +02:00
|
|
|
default=180.0,
|
fix: azimuth setting of pvforecastakkudoktor provider (#567)
EOS now enforces the general azimuth definition as e.g. defined in ISO 19111:
north=0, east=90, south=180, west=270. This is the convention that is and was
in the EOS documentation.
As the PV forecast of akkudoktor.net follows a different convention
(north=+-180, east=-90, south=0, west=90) the values from EOS are now converted
before the request is sent to akkudoktor.net.
BREAKING CHANGE: Azimuth configurations that followed the PVForecastAkkudoktor convention
(north=+-180, east=-90, south=0, west=90) must be converted to the general azimuth definition:
north=0, east=90, south=180, west=270.
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-05-28 20:42:43 +02:00
|
|
|
ge=0.0,
|
|
|
|
le=360.0,
|
2024-12-15 14:40:03 +01:00
|
|
|
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
|
fix: azimuth setting of pvforecastakkudoktor provider (#567)
EOS now enforces the general azimuth definition as e.g. defined in ISO 19111:
north=0, east=90, south=180, west=270. This is the convention that is and was
in the EOS documentation.
As the PV forecast of akkudoktor.net follows a different convention
(north=+-180, east=-90, south=0, west=90) the values from EOS are now converted
before the request is sent to akkudoktor.net.
BREAKING CHANGE: Azimuth configurations that followed the PVForecastAkkudoktor convention
(north=+-180, east=-90, south=0, west=90) must be converted to the general azimuth definition:
north=0, east=90, south=180, west=270.
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-05-28 20:42:43 +02:00
|
|
|
examples=[180.0, 90.0],
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
userhorizon: Optional[List[float]] = Field(
|
2024-12-15 14:40:03 +01:00
|
|
|
default=None,
|
|
|
|
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
|
2025-01-19 18:12:50 +01:00
|
|
|
examples=[[10.0, 20.0, 30.0], [5.0, 15.0, 25.0]],
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
peakpower: Optional[float] = Field(
|
|
|
|
default=None, description="Nominal power of PV system in kW.", examples=[5.0, 3.5]
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
pvtechchoice: Optional[str] = Field(
|
2024-12-15 14:40:03 +01:00
|
|
|
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
|
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
mountingplace: Optional[str] = Field(
|
2024-12-15 14:40:03 +01:00
|
|
|
default="free",
|
|
|
|
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
|
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
loss: Optional[float] = Field(default=14.0, description="Sum of PV system losses in percent")
|
|
|
|
trackingtype: Optional[int] = Field(
|
2025-01-02 12:40:12 +01:00
|
|
|
default=None,
|
2025-01-19 18:12:50 +01:00
|
|
|
ge=0,
|
|
|
|
le=5,
|
2024-12-15 14:40:03 +01:00
|
|
|
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
|
2025-01-15 00:54:45 +01:00
|
|
|
examples=[0, 1, 2, 3, 4, 5],
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
optimal_surface_tilt: Optional[bool] = Field(
|
2024-12-15 14:40:03 +01:00
|
|
|
default=False,
|
|
|
|
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
|
2025-01-15 00:54:45 +01:00
|
|
|
examples=[False],
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
optimalangles: Optional[bool] = Field(
|
2024-12-15 14:40:03 +01:00
|
|
|
default=False,
|
|
|
|
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
|
2025-01-15 00:54:45 +01:00
|
|
|
examples=[False],
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
albedo: Optional[float] = Field(
|
2024-12-15 14:40:03 +01:00
|
|
|
default=None,
|
|
|
|
description="Proportion of the light hitting the ground that it reflects back.",
|
2025-01-15 00:54:45 +01:00
|
|
|
examples=[None],
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
module_model: Optional[str] = Field(
|
2025-01-15 00:54:45 +01:00
|
|
|
default=None, description="Model of the PV modules of this plane.", examples=[None]
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
inverter_model: Optional[str] = Field(
|
2025-01-15 00:54:45 +01:00
|
|
|
default=None, description="Model of the inverter of this plane.", examples=[None]
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
inverter_paco: Optional[int] = Field(
|
2025-04-05 13:08:12 +02:00
|
|
|
default=None, description="AC power rating of the inverter [W].", examples=[6000, 4000]
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
modules_per_string: Optional[int] = Field(
|
2025-01-15 00:54:45 +01:00
|
|
|
default=None,
|
|
|
|
description="Number of the PV modules of the strings of this plane.",
|
|
|
|
examples=[20],
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
strings_per_inverter: Optional[int] = Field(
|
2025-01-15 00:54:45 +01:00
|
|
|
default=None,
|
|
|
|
description="Number of the strings of the inverter of this plane.",
|
|
|
|
examples=[2],
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
|
|
|
|
@model_validator(mode="after")
|
|
|
|
def validate_list_length(self) -> Self:
|
|
|
|
# Check if either attribute is set and add to active planes
|
|
|
|
if self.trackingtype == 2:
|
|
|
|
# Tilt angle from horizontal plane is ignored for two-axis tracking.
|
|
|
|
if self.surface_azimuth is None:
|
|
|
|
raise ValueError("If trackingtype is set, azimuth must be set as well.")
|
|
|
|
elif self.surface_tilt is None or self.surface_azimuth is None:
|
|
|
|
raise ValueError("surface_tilt and surface_azimuth must be set.")
|
|
|
|
return self
|
|
|
|
|
|
|
|
@field_validator("mountingplace")
|
|
|
|
def validate_mountingplace(cls, mountingplace: Optional[str]) -> Optional[str]:
|
|
|
|
if mountingplace is not None and mountingplace not in ["free", "building"]:
|
|
|
|
raise ValueError(f"Invalid mountingplace: {mountingplace}")
|
|
|
|
return mountingplace
|
|
|
|
|
|
|
|
@field_validator("pvtechchoice")
|
|
|
|
def validate_pvtechchoice(cls, pvtechchoice: Optional[str]) -> Optional[str]:
|
|
|
|
if pvtechchoice is not None and pvtechchoice not in ["crystSi", "CIS", "CdTe", "Unknown"]:
|
|
|
|
raise ValueError(f"Invalid pvtechchoice: {pvtechchoice}")
|
|
|
|
return pvtechchoice
|
|
|
|
|
|
|
|
|
|
|
|
class PVForecastCommonSettings(SettingsBaseModel):
|
|
|
|
"""PV Forecast Configuration."""
|
|
|
|
|
|
|
|
# General plane parameters
|
|
|
|
# https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/iotools/pvgis.html
|
|
|
|
# Inverter Parameters
|
|
|
|
# https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/inverter.html
|
|
|
|
|
|
|
|
provider: Optional[str] = Field(
|
2025-01-15 00:54:45 +01:00
|
|
|
default=None,
|
2025-01-19 18:12:50 +01:00
|
|
|
description="PVForecast provider id of provider to be used.",
|
|
|
|
examples=["PVForecastAkkudoktor"],
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
|
2025-07-19 08:55:16 +02:00
|
|
|
provider_settings: Optional[
|
|
|
|
Union[PVForecastImportCommonSettings, PVforecastVrmCommonSettings]
|
|
|
|
] = Field(default=None, description="Provider settings", examples=[None])
|
2025-04-05 13:08:12 +02:00
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
planes: Optional[list[PVForecastPlaneSetting]] = Field(
|
2025-01-15 00:54:45 +01:00
|
|
|
default=None,
|
2025-01-19 18:12:50 +01:00
|
|
|
description="Plane configuration.",
|
|
|
|
examples=[get_model_structure_from_examples(PVForecastPlaneSetting, True)],
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
|
|
|
|
2025-04-05 13:08:12 +02:00
|
|
|
max_planes: Optional[int] = Field(
|
|
|
|
default=0,
|
|
|
|
ge=0,
|
|
|
|
description="Maximum number of planes that can be set",
|
|
|
|
)
|
2025-01-19 18:12:50 +01:00
|
|
|
|
2025-03-27 21:53:01 +01:00
|
|
|
# Validators
|
|
|
|
@field_validator("provider", mode="after")
|
|
|
|
@classmethod
|
|
|
|
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
|
|
|
|
if value is None or value in pvforecast_providers:
|
|
|
|
return value
|
|
|
|
raise ValueError(
|
|
|
|
f"Provider '{value}' is not a valid PV forecast provider: {pvforecast_providers}."
|
|
|
|
)
|
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
## Computed fields
|
2024-12-15 14:40:03 +01:00
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
|
|
@property
|
2025-01-19 18:12:50 +01:00
|
|
|
def planes_peakpower(self) -> List[float]:
|
2024-12-15 14:40:03 +01:00
|
|
|
"""Compute a list of the peak power per active planes."""
|
|
|
|
planes_peakpower = []
|
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
if self.planes:
|
|
|
|
for plane in self.planes:
|
|
|
|
peakpower = plane.peakpower
|
|
|
|
if peakpower is None:
|
|
|
|
# TODO calculate peak power from modules/strings
|
|
|
|
planes_peakpower.append(float(5000))
|
|
|
|
else:
|
|
|
|
planes_peakpower.append(float(peakpower))
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
return planes_peakpower
|
|
|
|
|
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
|
|
@property
|
2025-01-19 18:12:50 +01:00
|
|
|
def planes_azimuth(self) -> List[float]:
|
2024-12-15 14:40:03 +01:00
|
|
|
"""Compute a list of the azimuths per active planes."""
|
|
|
|
planes_azimuth = []
|
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
if self.planes:
|
|
|
|
for plane in self.planes:
|
|
|
|
azimuth = plane.surface_azimuth
|
|
|
|
if azimuth is None:
|
|
|
|
# TODO Use default
|
|
|
|
planes_azimuth.append(float(180))
|
|
|
|
else:
|
|
|
|
planes_azimuth.append(float(azimuth))
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
return planes_azimuth
|
|
|
|
|
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
|
|
@property
|
2025-01-19 18:12:50 +01:00
|
|
|
def planes_tilt(self) -> List[float]:
|
2024-12-15 14:40:03 +01:00
|
|
|
"""Compute a list of the tilts per active planes."""
|
|
|
|
planes_tilt = []
|
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
if self.planes:
|
|
|
|
for plane in self.planes:
|
|
|
|
tilt = plane.surface_tilt
|
|
|
|
if tilt is None:
|
|
|
|
# TODO Use default
|
|
|
|
planes_tilt.append(float(30))
|
|
|
|
else:
|
|
|
|
planes_tilt.append(float(tilt))
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
return planes_tilt
|
|
|
|
|
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
|
|
@property
|
2025-01-19 18:12:50 +01:00
|
|
|
def planes_userhorizon(self) -> Any:
|
2024-12-15 14:40:03 +01:00
|
|
|
"""Compute a list of the user horizon per active planes."""
|
|
|
|
planes_userhorizon = []
|
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
if self.planes:
|
|
|
|
for plane in self.planes:
|
|
|
|
userhorizon = plane.userhorizon
|
|
|
|
if userhorizon is None:
|
|
|
|
# TODO Use default
|
|
|
|
planes_userhorizon.append([float(0), float(0)])
|
|
|
|
else:
|
|
|
|
planes_userhorizon.append(userhorizon)
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
return planes_userhorizon
|
|
|
|
|
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
|
|
@property
|
2025-01-19 18:12:50 +01:00
|
|
|
def planes_inverter_paco(self) -> Any:
|
2024-12-15 14:40:03 +01:00
|
|
|
"""Compute a list of the maximum power rating of the inverter per active planes."""
|
|
|
|
planes_inverter_paco = []
|
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
if self.planes:
|
|
|
|
for plane in self.planes:
|
|
|
|
inverter_paco = plane.inverter_paco
|
|
|
|
if inverter_paco is None:
|
|
|
|
# TODO Use default - no clipping
|
|
|
|
planes_inverter_paco.append(25000.0)
|
|
|
|
else:
|
|
|
|
planes_inverter_paco.append(float(inverter_paco))
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
return planes_inverter_paco
|