openenergyid 0.1.29__py3-none-any.whl → 0.1.30__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of openenergyid might be problematic. Click here for more details.
- openenergyid/__init__.py +1 -1
- openenergyid/abstractsim/__init__.py +5 -0
- openenergyid/abstractsim/abstract.py +102 -0
- openenergyid/baseload/__init__.py +1 -1
- openenergyid/capacity/__init__.py +1 -1
- openenergyid/capacity/main.py +1 -0
- openenergyid/capacity/models.py +2 -0
- openenergyid/const.py +11 -0
- openenergyid/dyntar/main.py +10 -9
- openenergyid/dyntar/models.py +3 -2
- openenergyid/elia/__init__.py +4 -0
- openenergyid/elia/api.py +91 -0
- openenergyid/elia/const.py +18 -0
- openenergyid/energysharing/data_formatting.py +9 -1
- openenergyid/energysharing/main.py +13 -2
- openenergyid/energysharing/models.py +3 -2
- openenergyid/models.py +10 -4
- openenergyid/mvlr/__init__.py +1 -1
- openenergyid/mvlr/main.py +1 -1
- openenergyid/mvlr/models.py +2 -3
- openenergyid/pvsim/__init__.py +8 -0
- openenergyid/pvsim/abstract.py +60 -0
- openenergyid/pvsim/elia/__init__.py +3 -0
- openenergyid/pvsim/elia/main.py +89 -0
- openenergyid/pvsim/main.py +49 -0
- openenergyid/pvsim/pvlib/__init__.py +11 -0
- openenergyid/pvsim/pvlib/main.py +115 -0
- openenergyid/pvsim/pvlib/models.py +235 -0
- openenergyid/pvsim/pvlib/quickscan.py +99 -0
- openenergyid/pvsim/pvlib/weather.py +91 -0
- openenergyid/sim/__init__.py +5 -0
- openenergyid/sim/main.py +67 -0
- openenergyid/simeval/__init__.py +6 -0
- openenergyid/simeval/main.py +148 -0
- openenergyid/simeval/models.py +162 -0
- {openenergyid-0.1.29.dist-info → openenergyid-0.1.30.dist-info}/METADATA +3 -1
- openenergyid-0.1.30.dist-info/RECORD +50 -0
- openenergyid-0.1.29.dist-info/RECORD +0 -30
- {openenergyid-0.1.29.dist-info → openenergyid-0.1.30.dist-info}/WHEEL +0 -0
- {openenergyid-0.1.29.dist-info → openenergyid-0.1.30.dist-info}/licenses/LICENSE +0 -0
- {openenergyid-0.1.29.dist-info → openenergyid-0.1.30.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""PV Simulation module."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Union
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
|
|
8
|
+
from ..abstractsim import SimulationSummary
|
|
9
|
+
from ..const import ELECTRICITY_DELIVERED, ELECTRICITY_EXPORTED, ELECTRICITY_PRODUCED
|
|
10
|
+
from .elia import EliaPVSimulationInput, EliaPVSimulator
|
|
11
|
+
from .pvlib import PVLibSimulationInput, PVLibSimulator
|
|
12
|
+
|
|
13
|
+
PVSimulationInput = Annotated[
|
|
14
|
+
Union[PVLibSimulationInput, EliaPVSimulationInput], Field(discriminator="type")
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PVSimulationSummary(SimulationSummary):
|
|
19
|
+
"""Summary of a PV simulation including ex-ante, simulation results, ex-post, and comparisons."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_simulator(input_: PVSimulationInput) -> PVLibSimulator | EliaPVSimulator:
|
|
23
|
+
"""Get an instance of the simulator based on the input data."""
|
|
24
|
+
if isinstance(input_, PVLibSimulationInput):
|
|
25
|
+
return PVLibSimulator.from_pydantic(input_)
|
|
26
|
+
if isinstance(input_, EliaPVSimulationInput):
|
|
27
|
+
return EliaPVSimulator.from_pydantic(input_)
|
|
28
|
+
raise ValueError(f"Unknown simulator type: {input_.type}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def apply_simulation(input_data: pd.DataFrame, simulation_results: pd.Series) -> pd.DataFrame:
|
|
32
|
+
"""Apply simulation results to input data."""
|
|
33
|
+
df = input_data.copy()
|
|
34
|
+
|
|
35
|
+
if ELECTRICITY_PRODUCED not in df.columns:
|
|
36
|
+
df[ELECTRICITY_PRODUCED] = 0.0
|
|
37
|
+
df[ELECTRICITY_PRODUCED] = df[ELECTRICITY_PRODUCED] + simulation_results
|
|
38
|
+
|
|
39
|
+
new_delivered = (df[ELECTRICITY_DELIVERED] - simulation_results).clip(lower=0.0)
|
|
40
|
+
self_consumed = df[ELECTRICITY_DELIVERED] - new_delivered
|
|
41
|
+
df[ELECTRICITY_DELIVERED] = new_delivered
|
|
42
|
+
|
|
43
|
+
exported = simulation_results - self_consumed
|
|
44
|
+
|
|
45
|
+
if ELECTRICITY_EXPORTED not in df.columns:
|
|
46
|
+
df[ELECTRICITY_EXPORTED] = 0.0
|
|
47
|
+
df[ELECTRICITY_EXPORTED] = df[ELECTRICITY_EXPORTED] + exported
|
|
48
|
+
|
|
49
|
+
return df
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PVLib-based simulator implementation.
|
|
3
|
+
|
|
4
|
+
Defines a PVSimulator subclass that uses PVLib's ModelChain and weather data
|
|
5
|
+
to simulate PV system performance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime as dt
|
|
9
|
+
from typing import Annotated, Literal, Union
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import pvlib
|
|
13
|
+
from aiohttp import ClientSession
|
|
14
|
+
from pvlib.modelchain import ModelChain
|
|
15
|
+
from pydantic import Field
|
|
16
|
+
|
|
17
|
+
from openenergyid.pvsim.abstract import PVSimulationInputAbstract, PVSimulator
|
|
18
|
+
|
|
19
|
+
from .models import ModelChainModel, to_pv
|
|
20
|
+
from .quickscan import (
|
|
21
|
+
QuickScanModelChainModel, # pyright: ignore[reportAttributeAccessIssue]
|
|
22
|
+
)
|
|
23
|
+
from .weather import get_weather
|
|
24
|
+
|
|
25
|
+
ModelChainUnion = Annotated[
|
|
26
|
+
Union[ModelChainModel, QuickScanModelChainModel], Field(discriminator="type")
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PVLibSimulationInput(PVSimulationInputAbstract):
|
|
31
|
+
"""
|
|
32
|
+
Input parameters for the PVLibSimulator.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
type: Literal["pvlibsimulation"] = Field("pvlibsimulation", frozen=True) # tag
|
|
36
|
+
modelchain: ModelChainUnion
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PVLibSimulator(PVSimulator):
|
|
40
|
+
"""
|
|
41
|
+
Simulator for PV systems using PVLib's ModelChain and weather data.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
start: dt.date,
|
|
47
|
+
end: dt.date,
|
|
48
|
+
modelchain: pvlib.modelchain.ModelChain,
|
|
49
|
+
weather: pd.DataFrame | None = None,
|
|
50
|
+
**kwargs,
|
|
51
|
+
):
|
|
52
|
+
"""
|
|
53
|
+
Initialize the simulator with a ModelChain and weather DataFrame.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
modelchain: An instance of pvlib.modelchain.ModelChain.
|
|
57
|
+
weather: Weather data as a pandas DataFrame.
|
|
58
|
+
"""
|
|
59
|
+
super().__init__(**kwargs)
|
|
60
|
+
|
|
61
|
+
self.start = start
|
|
62
|
+
self.end = end
|
|
63
|
+
self.modelchain = modelchain
|
|
64
|
+
self.weather = weather
|
|
65
|
+
|
|
66
|
+
def simulate(self, **kwargs) -> pd.Series:
|
|
67
|
+
"""
|
|
68
|
+
Run the simulation and return the resulting AC energy series.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
pd.Series: Simulated AC energy in kWh for each timestep.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If the AC power result is None.
|
|
75
|
+
"""
|
|
76
|
+
# Run model
|
|
77
|
+
self.modelchain.run_model(self.weather)
|
|
78
|
+
|
|
79
|
+
results = self.modelchain.results
|
|
80
|
+
ac = results.ac
|
|
81
|
+
|
|
82
|
+
if ac is None:
|
|
83
|
+
raise ValueError("AC power is None")
|
|
84
|
+
|
|
85
|
+
# Convert W to kWh
|
|
86
|
+
energy = ac * 0.25 / 1000
|
|
87
|
+
|
|
88
|
+
energy.name = None
|
|
89
|
+
|
|
90
|
+
return energy
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_pydantic(cls, input_: PVLibSimulationInput) -> "PVLibSimulator":
|
|
94
|
+
"""
|
|
95
|
+
Create a PVLibSimulator instance from a PVLibSimulationInput model.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
input_: A PVLibSimulationInput instance.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
PVLibSimulator: An initialized simulator.
|
|
102
|
+
"""
|
|
103
|
+
mc: ModelChain = to_pv(input_.modelchain)
|
|
104
|
+
|
|
105
|
+
return cls(modelchain=mc, **input_.model_dump(exclude={"modelchain"}))
|
|
106
|
+
|
|
107
|
+
async def load_resources(self, session: ClientSession | None = None) -> None:
|
|
108
|
+
weather = get_weather(
|
|
109
|
+
latitude=self.modelchain.location.latitude,
|
|
110
|
+
longitude=self.modelchain.location.longitude,
|
|
111
|
+
start=self.start,
|
|
112
|
+
end=self.end,
|
|
113
|
+
tz=self.modelchain.location.tz,
|
|
114
|
+
)
|
|
115
|
+
self.weather = weather
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dynamic Pydantic models for PVLib classes.
|
|
3
|
+
|
|
4
|
+
This module provides utilities to generate Pydantic models from PVLib classes,
|
|
5
|
+
enabling serialization, validation, and conversion between Pydantic and PVLib objects.
|
|
6
|
+
It also provides helper functions for recursive conversion and type overlays for
|
|
7
|
+
special PVLib constructs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from inspect import Parameter, isclass, signature
|
|
12
|
+
from typing import Annotated, Any, Literal, Union
|
|
13
|
+
|
|
14
|
+
from pvlib.location import Location
|
|
15
|
+
from pvlib.modelchain import ModelChain
|
|
16
|
+
from pvlib.pvsystem import Array, FixedMount, PVSystem, SingleAxisTrackerMount
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, create_model
|
|
18
|
+
from typing_inspect import get_args, get_origin
|
|
19
|
+
|
|
20
|
+
# Registry of PVLib classes for which models can be generated
|
|
21
|
+
REGISTRY: dict[str, type] = {
|
|
22
|
+
"Location": Location,
|
|
23
|
+
"FixedMount": FixedMount,
|
|
24
|
+
"SingleAxisTrackerMount": SingleAxisTrackerMount,
|
|
25
|
+
"Array": Array,
|
|
26
|
+
"PVSystem": PVSystem,
|
|
27
|
+
"ModelChain": ModelChain,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Cache for generated Pydantic models
|
|
31
|
+
MODEL_CACHE: dict[type, type[BaseModel]] = {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_annotation(ann: Any) -> Any:
|
|
35
|
+
"""
|
|
36
|
+
Normalize type annotations for use in Pydantic models.
|
|
37
|
+
|
|
38
|
+
Converts unknown or third-party types (e.g., numpy, pandas) to Any.
|
|
39
|
+
"""
|
|
40
|
+
if ann in (Parameter.empty, None):
|
|
41
|
+
return Any
|
|
42
|
+
try:
|
|
43
|
+
mod = getattr(ann, "__module__", "")
|
|
44
|
+
if mod.startswith(("numpy", "pandas")):
|
|
45
|
+
return Any
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
return ann
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Overlay for type hints that need to be replaced in generated models
|
|
52
|
+
TYPE_OVERLAY: dict[type, dict[str, Any]] = {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def pyd_model_from_type(py_type: type) -> type[BaseModel]:
|
|
56
|
+
"""
|
|
57
|
+
Recursively create or retrieve a Pydantic model for a given PVLib class.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
py_type: The PVLib class type.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
A dynamically created Pydantic model class.
|
|
64
|
+
"""
|
|
65
|
+
if py_type in MODEL_CACHE:
|
|
66
|
+
return MODEL_CACHE[py_type]
|
|
67
|
+
|
|
68
|
+
# Use the __init__ signature to determine fields
|
|
69
|
+
sig = signature(py_type.__init__)
|
|
70
|
+
fields = {}
|
|
71
|
+
|
|
72
|
+
for pname, param in sig.parameters.items():
|
|
73
|
+
if pname == "self":
|
|
74
|
+
continue
|
|
75
|
+
default = Field(...) if param.default is Parameter.empty else Field(param.default)
|
|
76
|
+
|
|
77
|
+
# Use overlay type if available, otherwise normalized annotation
|
|
78
|
+
ann = TYPE_OVERLAY.get(py_type, {}).get(pname, normalize_annotation(param.annotation))
|
|
79
|
+
|
|
80
|
+
origin = get_origin(ann)
|
|
81
|
+
|
|
82
|
+
# Recursively generate models for PVLib class fields
|
|
83
|
+
if isclass(ann) and ann in REGISTRY.values():
|
|
84
|
+
ann = pyd_model_from_type(ann)
|
|
85
|
+
|
|
86
|
+
# Handle lists/unions of PVLib classes
|
|
87
|
+
elif origin in (list, list):
|
|
88
|
+
(t,) = get_args(ann) or (Any,)
|
|
89
|
+
if isclass(t) and t in REGISTRY.values():
|
|
90
|
+
t = pyd_model_from_type(t) # type: ignore
|
|
91
|
+
ann = list[t]
|
|
92
|
+
elif origin in (Union,):
|
|
93
|
+
args = []
|
|
94
|
+
for t in get_args(ann):
|
|
95
|
+
if isclass(t) and t in REGISTRY.values():
|
|
96
|
+
t = pyd_model_from_type(t)
|
|
97
|
+
args.append(t)
|
|
98
|
+
ann = Union[tuple(args)] # type: ignore
|
|
99
|
+
|
|
100
|
+
fields[pname] = (ann, default)
|
|
101
|
+
|
|
102
|
+
# Create the Pydantic model dynamically
|
|
103
|
+
model = create_model(
|
|
104
|
+
py_type.__name__ + "Model",
|
|
105
|
+
__base__=BaseModel,
|
|
106
|
+
__config__=ConfigDict(extra="allow"),
|
|
107
|
+
__doc__=py_type.__doc__,
|
|
108
|
+
**fields,
|
|
109
|
+
)
|
|
110
|
+
# Attach a back-reference to the PVLib class
|
|
111
|
+
setattr(model, "_pvlib_type", py_type)
|
|
112
|
+
MODEL_CACHE[py_type] = model
|
|
113
|
+
return model
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _filter_kwargs(pv_type: type, kwargs: dict) -> dict:
|
|
117
|
+
"""
|
|
118
|
+
Remove keys from kwargs that are not accepted by the PVLib class constructor.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
pv_type: The PVLib class type.
|
|
122
|
+
kwargs: Dictionary of keyword arguments.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Filtered dictionary with only accepted keys.
|
|
126
|
+
"""
|
|
127
|
+
sig = signature(pv_type.__init__)
|
|
128
|
+
params = sig.parameters
|
|
129
|
+
if any(p.kind is Parameter.VAR_KEYWORD for p in params.values()):
|
|
130
|
+
return kwargs # accepts **kwargs, keep all
|
|
131
|
+
allowed = {n for n in params if n != "self"}
|
|
132
|
+
return {k: v for k, v in kwargs.items() if k in allowed}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def to_pv(obj: Any) -> Any:
|
|
136
|
+
"""
|
|
137
|
+
Recursively convert a Pydantic model (or nested structure) to a PVLib object.
|
|
138
|
+
|
|
139
|
+
Handles BaseModel instances, mappings, sequences, and primitives.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
obj: The object to convert.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The corresponding PVLib object or primitive.
|
|
146
|
+
"""
|
|
147
|
+
# 1) Pydantic model → recurse on attributes (NOT model_dump)
|
|
148
|
+
if isinstance(obj, BaseModel):
|
|
149
|
+
pv_type = getattr(obj.__class__, "_pvlib_type", None)
|
|
150
|
+
|
|
151
|
+
# Collect declared fields
|
|
152
|
+
kwargs = {name: to_pv(getattr(obj, name)) for name in obj.__class__.model_fields}
|
|
153
|
+
|
|
154
|
+
# Include extras if extra="allow"
|
|
155
|
+
extra = getattr(obj, "__pydantic_extra__", None)
|
|
156
|
+
if isinstance(extra, dict):
|
|
157
|
+
for k, v in extra.items():
|
|
158
|
+
kwargs[k] = to_pv(v)
|
|
159
|
+
|
|
160
|
+
if pv_type is None:
|
|
161
|
+
# Not a pvlib-backed model: return plain nested dict
|
|
162
|
+
return kwargs
|
|
163
|
+
|
|
164
|
+
# Special case: ModelChain(system, location, **kwargs)
|
|
165
|
+
if pv_type is ModelChain:
|
|
166
|
+
system = kwargs.pop("system")
|
|
167
|
+
location = kwargs.pop("location")
|
|
168
|
+
return ModelChain(system, location, **_filter_kwargs(pv_type, kwargs))
|
|
169
|
+
|
|
170
|
+
# General case: keyword construction
|
|
171
|
+
return pv_type(**_filter_kwargs(pv_type, kwargs))
|
|
172
|
+
|
|
173
|
+
# 2) Containers
|
|
174
|
+
if isinstance(obj, Mapping):
|
|
175
|
+
return {k: to_pv(v) for k, v in obj.items()}
|
|
176
|
+
if isinstance(obj, Sequence) and not isinstance(obj, (str, bytes, bytearray)):
|
|
177
|
+
return type(obj)(to_pv(v) for v in obj) # type: ignore
|
|
178
|
+
|
|
179
|
+
# 3) Primitives
|
|
180
|
+
return obj
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --- Model definitions for PVLib classes ---
|
|
184
|
+
|
|
185
|
+
# Location model
|
|
186
|
+
LocationModel = pyd_model_from_type(Location)
|
|
187
|
+
|
|
188
|
+
# FixedMount model with discriminator field
|
|
189
|
+
FixedMountModel = create_model(
|
|
190
|
+
"FixedMountModel",
|
|
191
|
+
kind=(Literal["fixed"], Field("fixed", frozen=True)), # tag
|
|
192
|
+
__base__=pyd_model_from_type(FixedMount),
|
|
193
|
+
__config__=ConfigDict(extra="allow"),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# SingleAxisTrackerMount model with discriminator field
|
|
197
|
+
TrackerMountModel = create_model(
|
|
198
|
+
"SingleAxisTrackerMountModel",
|
|
199
|
+
kind=(Literal["tracker"], Field("tracker", frozen=True)),
|
|
200
|
+
__base__=pyd_model_from_type(SingleAxisTrackerMount),
|
|
201
|
+
__config__=ConfigDict(extra="allow"),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Union type for mount models, with discriminator
|
|
205
|
+
MountUnion = Annotated[Union[FixedMountModel, TrackerMountModel], Field(discriminator="kind")]
|
|
206
|
+
|
|
207
|
+
# Overlay Array.mount type with MountUnion
|
|
208
|
+
TYPE_OVERLAY.update({Array: {"mount": MountUnion}})
|
|
209
|
+
|
|
210
|
+
# Array model
|
|
211
|
+
ArrayModel = pyd_model_from_type(Array)
|
|
212
|
+
|
|
213
|
+
# Overlay PVSystem.arrays type to allow list, single, or None
|
|
214
|
+
TYPE_OVERLAY.update({PVSystem: {"arrays": list[ArrayModel] | ArrayModel | None}})
|
|
215
|
+
|
|
216
|
+
# PVSystem model
|
|
217
|
+
PVSystemModel = pyd_model_from_type(PVSystem)
|
|
218
|
+
|
|
219
|
+
# Overlay ModelChain system/location types
|
|
220
|
+
TYPE_OVERLAY.update(
|
|
221
|
+
{
|
|
222
|
+
ModelChain: {
|
|
223
|
+
"system": PVSystemModel,
|
|
224
|
+
"location": LocationModel,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# ModelChain model
|
|
230
|
+
ModelChainModel = create_model(
|
|
231
|
+
"ModelChainModel",
|
|
232
|
+
type=(Literal["modelchain"], Field("modelchain", frozen=True)), # tag
|
|
233
|
+
__base__=pyd_model_from_type(ModelChain),
|
|
234
|
+
__config__=ConfigDict(extra="allow"),
|
|
235
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quick PV system and model chain models for rapid PV simulation setup.
|
|
3
|
+
|
|
4
|
+
Provides convenience Pydantic models for specifying PV system parameters
|
|
5
|
+
with simplified fields for module and inverter sizing, and for configuring
|
|
6
|
+
a PVLib ModelChain for quick simulations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import ConfigDict, Field, model_validator
|
|
12
|
+
|
|
13
|
+
from .models import ModelChainModel, PVSystemModel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class QuickPVSystemModel(PVSystemModel):
|
|
17
|
+
"""
|
|
18
|
+
Model for quickly specifying a PV system with simplified sizing fields.
|
|
19
|
+
|
|
20
|
+
Allows specifying module and inverter power directly, and automatically
|
|
21
|
+
derives detailed parameters for use with PVLib.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
p_module: float = Field(default=420, gt=0, description="Module max DC power in W")
|
|
25
|
+
p_inverter: float = Field(
|
|
26
|
+
gt=0,
|
|
27
|
+
description="Inverter max power in W",
|
|
28
|
+
)
|
|
29
|
+
inverter_efficiency: float = Field(
|
|
30
|
+
default=0.96, gt=0, le=1, description="PVWatts efficiency used to derive PDC0 from PAC."
|
|
31
|
+
)
|
|
32
|
+
module_parameters: dict[str, float] = Field(default={"pdc0": 420, "gamma_pdc": -0.003})
|
|
33
|
+
module_type: str = Field(default="glass_polymer", description="Type of PV module")
|
|
34
|
+
racking_model: str = Field(default="open_rack", description="Type of racking model")
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="allow")
|
|
37
|
+
|
|
38
|
+
@model_validator(mode="after")
|
|
39
|
+
def _apply_p_inverter_to_pdc0(self):
|
|
40
|
+
"""
|
|
41
|
+
If p_inverter is provided, set inverter_parameters['pdc0'] accordingly.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: If both p_inverter and inverter_parameters['pdc0'] are provided.
|
|
45
|
+
"""
|
|
46
|
+
p_inverter = self.p_inverter
|
|
47
|
+
inv_params: dict[str, Any] = self.inverter_parameters or {} # type: ignore
|
|
48
|
+
|
|
49
|
+
# choose policy: reject or overwrite if user also sent pdc0
|
|
50
|
+
if "pdc0" in inv_params:
|
|
51
|
+
if inv_params["pdc0"] != p_inverter / self.inverter_efficiency:
|
|
52
|
+
raise ValueError("Provide either 'pac' or 'inverter_parameters.pdc0', not both.")
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
inv_params = dict(inv_params) # avoid mutating shared defaults
|
|
56
|
+
inv_params["pdc0"] = p_inverter / self.inverter_efficiency
|
|
57
|
+
object.__setattr__(self, "inverter_parameters", inv_params)
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
@model_validator(mode="after")
|
|
61
|
+
def _p_module_to_module_parameters(self):
|
|
62
|
+
"""
|
|
63
|
+
Set module_parameters['pdc0'] to the value of p_module.
|
|
64
|
+
"""
|
|
65
|
+
p_module = self.p_module
|
|
66
|
+
mod_params: dict[str, float] = self.module_parameters
|
|
67
|
+
mod_params = dict(mod_params) # avoid mutating shared defaults
|
|
68
|
+
mod_params["pdc0"] = p_module
|
|
69
|
+
object.__setattr__(self, "module_parameters", mod_params)
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="after")
|
|
73
|
+
def _push_settings_to_arrays(self):
|
|
74
|
+
"""
|
|
75
|
+
If settings are specified for the entire system, but not per array,
|
|
76
|
+
and there are arrays defined, push the settings to each array.
|
|
77
|
+
"""
|
|
78
|
+
if hasattr(self, "arrays") and self.arrays is not None: # type: ignore
|
|
79
|
+
for array in self.arrays: # type: ignore
|
|
80
|
+
if not array.module_parameters:
|
|
81
|
+
object.__setattr__(array, "module_parameters", self.module_parameters)
|
|
82
|
+
if not array.module_type:
|
|
83
|
+
object.__setattr__(array, "module_type", self.module_type)
|
|
84
|
+
if not array.mount.racking_model:
|
|
85
|
+
object.__setattr__(array.mount, "racking_model", self.racking_model)
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class QuickScanModelChainModel(ModelChainModel):
|
|
90
|
+
"""
|
|
91
|
+
ModelChain model for quick PV simulation setup using QuickPVSystemModel.
|
|
92
|
+
|
|
93
|
+
Sets default AOI and DC models for PVWatts.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
type: Literal["quickscan"] = Field("quickscan", frozen=True) # tag
|
|
97
|
+
system: QuickPVSystemModel
|
|
98
|
+
aoi_model: str = "physical"
|
|
99
|
+
dc_model: str = "pvwatts"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Weather data utilities for PV simulation.
|
|
3
|
+
|
|
4
|
+
Provides functions to retrieve and process weather data for PV simulations,
|
|
5
|
+
including timezone-aware handling and leap year adjustments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime as dt
|
|
9
|
+
import typing
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import pvlib
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_utc_offset_on_1_jan(timezone: str) -> int:
|
|
16
|
+
"""
|
|
17
|
+
Get the UTC offset in hours for the given timezone on January 1st.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
timezone: The timezone string (e.g., "Europe/Amsterdam").
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The UTC offset in hours as an integer.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ValueError: If the UTC offset cannot be determined.
|
|
27
|
+
"""
|
|
28
|
+
jan_first = pd.Timestamp("2020-01-01 00:00:00", tz=timezone)
|
|
29
|
+
utc_offset = jan_first.utcoffset()
|
|
30
|
+
if utc_offset is None:
|
|
31
|
+
raise ValueError(f"Could not determine UTC offset for timezone {timezone}")
|
|
32
|
+
return int(utc_offset.total_seconds() / 3600)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_weather(
|
|
36
|
+
latitude: float, longitude: float, start: dt.date, end: dt.date, tz: str
|
|
37
|
+
) -> pd.DataFrame:
|
|
38
|
+
"""
|
|
39
|
+
Retrieve and process weather data for the specified location and date range.
|
|
40
|
+
|
|
41
|
+
Downloads a "normal year" TMY dataset from PVGIS, aligns it to the requested
|
|
42
|
+
date range and timezone, and handles leap year adjustments.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
latitude: Latitude of the location.
|
|
46
|
+
longitude: Longitude of the location.
|
|
47
|
+
start: Start date (inclusive).
|
|
48
|
+
end: End date (exclusive).
|
|
49
|
+
tz: Timezone string.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
A pandas DataFrame indexed by timestamp with weather data.
|
|
53
|
+
"""
|
|
54
|
+
# Get a "normal year" from pvgis, it will be indexed in 1990
|
|
55
|
+
utc_offset = get_utc_offset_on_1_jan(tz)
|
|
56
|
+
weather = pvlib.iotools.get_pvgis_tmy(
|
|
57
|
+
latitude=latitude, longitude=longitude, roll_utc_offset=utc_offset
|
|
58
|
+
)[0]
|
|
59
|
+
weather = typing.cast(pd.DataFrame, weather)
|
|
60
|
+
weather = weather.tz_convert(tz)
|
|
61
|
+
weather.index.name = None
|
|
62
|
+
|
|
63
|
+
# Check if 29 februari is included in the weather data
|
|
64
|
+
weather_index = typing.cast(pd.DatetimeIndex, weather.index)
|
|
65
|
+
leap_included = "02-29" in weather_index.strftime("%m-%d").unique()
|
|
66
|
+
|
|
67
|
+
# Construct our desired index
|
|
68
|
+
new_index = pd.date_range(start, end, freq="15min", tz=tz)
|
|
69
|
+
temp_df = pd.DataFrame(index=new_index)
|
|
70
|
+
# Add a key that doesn't contain year
|
|
71
|
+
temp_df["tkey"] = new_index.tz_convert("UTC").strftime("%m-%dT%H:%M:%S%z")
|
|
72
|
+
temp_df["timestamp"] = new_index
|
|
73
|
+
if not leap_included:
|
|
74
|
+
# Replace all tkey's starting with "02-29" with "02-28"
|
|
75
|
+
temp_df.loc[temp_df.tkey.str.startswith("02-29"), "tkey"] = temp_df.loc[
|
|
76
|
+
temp_df.tkey.str.startswith("02-29"), "tkey"
|
|
77
|
+
].str.replace("02-29", "02-28")
|
|
78
|
+
|
|
79
|
+
# Add the key to the weather frame and join
|
|
80
|
+
weather["tkey"] = weather_index.tz_convert("UTC").strftime("%m-%dT%H:%M:%S%z")
|
|
81
|
+
df = (
|
|
82
|
+
pd.merge(temp_df, weather, on="tkey", how="left")
|
|
83
|
+
.set_index("timestamp")
|
|
84
|
+
.drop(columns=["tkey"])
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Interpolate and drop last value
|
|
88
|
+
df = df.interpolate(method="time")
|
|
89
|
+
df = df.iloc[:-1]
|
|
90
|
+
|
|
91
|
+
return df
|
openenergyid/sim/main.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Generic Simulation Analysis Module."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Union
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from ..abstractsim import SimulationSummary, Simulator
|
|
9
|
+
from ..pvsim import PVSimulationInput, apply_simulation
|
|
10
|
+
from ..pvsim import get_simulator as get_pv_simulator
|
|
11
|
+
from ..simeval import EvaluationInput, compare_results, evaluate
|
|
12
|
+
from ..simeval.models import Frequency
|
|
13
|
+
|
|
14
|
+
# Here we define all types of simulations
|
|
15
|
+
SimulationInput = Annotated[Union[PVSimulationInput], Field(discriminator="type")]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_simulator(input_: SimulationInput) -> Simulator:
|
|
19
|
+
"""Get an instance of the simulator based on the input data."""
|
|
20
|
+
# Only PV simulators for now
|
|
21
|
+
return get_pv_simulator(input_)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExAnteData(EvaluationInput):
|
|
25
|
+
"""Ex-ante data for simulation analysis."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FullSimulationInput(BaseModel):
|
|
29
|
+
"""Full input for running a simulation analysis."""
|
|
30
|
+
|
|
31
|
+
ex_ante_data: ExAnteData
|
|
32
|
+
simulation_parameters: SimulationInput
|
|
33
|
+
timezone: str = "Europe/Brussels"
|
|
34
|
+
return_frequencies: list[Frequency] | None = Field(
|
|
35
|
+
default=None,
|
|
36
|
+
examples=["MS", "W-MON"],
|
|
37
|
+
description="Optional list of frequencies that should be included in the analysis. Be default, only `total` is included, but you can add more here. Uses the Pandas freqstr.",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def run_simulation(
|
|
42
|
+
input_: FullSimulationInput, session: aiohttp.ClientSession
|
|
43
|
+
) -> SimulationSummary:
|
|
44
|
+
"""Run the full simulation analysis workflow."""
|
|
45
|
+
df = input_.ex_ante_data.to_pandas(timezone=input_.timezone)
|
|
46
|
+
|
|
47
|
+
ex_ante_eval = evaluate(df, return_frequencies=input_.return_frequencies)
|
|
48
|
+
|
|
49
|
+
simulator: Simulator = get_simulator(input_.simulation_parameters)
|
|
50
|
+
await simulator.load_resources(session=session)
|
|
51
|
+
|
|
52
|
+
sim_eval = evaluate(simulator.result_as_frame(), return_frequencies=input_.return_frequencies)
|
|
53
|
+
|
|
54
|
+
df_post = apply_simulation(df, simulator.simulation_results)
|
|
55
|
+
|
|
56
|
+
post_eval = evaluate(df_post, return_frequencies=input_.return_frequencies)
|
|
57
|
+
|
|
58
|
+
comparison = compare_results(ex_ante_eval, post_eval)
|
|
59
|
+
|
|
60
|
+
summary = SimulationSummary.from_simulation(
|
|
61
|
+
ex_ante=ex_ante_eval,
|
|
62
|
+
simulation_result=sim_eval,
|
|
63
|
+
ex_post=post_eval,
|
|
64
|
+
comparison=comparison,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return summary
|