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.

Files changed (41) hide show
  1. openenergyid/__init__.py +1 -1
  2. openenergyid/abstractsim/__init__.py +5 -0
  3. openenergyid/abstractsim/abstract.py +102 -0
  4. openenergyid/baseload/__init__.py +1 -1
  5. openenergyid/capacity/__init__.py +1 -1
  6. openenergyid/capacity/main.py +1 -0
  7. openenergyid/capacity/models.py +2 -0
  8. openenergyid/const.py +11 -0
  9. openenergyid/dyntar/main.py +10 -9
  10. openenergyid/dyntar/models.py +3 -2
  11. openenergyid/elia/__init__.py +4 -0
  12. openenergyid/elia/api.py +91 -0
  13. openenergyid/elia/const.py +18 -0
  14. openenergyid/energysharing/data_formatting.py +9 -1
  15. openenergyid/energysharing/main.py +13 -2
  16. openenergyid/energysharing/models.py +3 -2
  17. openenergyid/models.py +10 -4
  18. openenergyid/mvlr/__init__.py +1 -1
  19. openenergyid/mvlr/main.py +1 -1
  20. openenergyid/mvlr/models.py +2 -3
  21. openenergyid/pvsim/__init__.py +8 -0
  22. openenergyid/pvsim/abstract.py +60 -0
  23. openenergyid/pvsim/elia/__init__.py +3 -0
  24. openenergyid/pvsim/elia/main.py +89 -0
  25. openenergyid/pvsim/main.py +49 -0
  26. openenergyid/pvsim/pvlib/__init__.py +11 -0
  27. openenergyid/pvsim/pvlib/main.py +115 -0
  28. openenergyid/pvsim/pvlib/models.py +235 -0
  29. openenergyid/pvsim/pvlib/quickscan.py +99 -0
  30. openenergyid/pvsim/pvlib/weather.py +91 -0
  31. openenergyid/sim/__init__.py +5 -0
  32. openenergyid/sim/main.py +67 -0
  33. openenergyid/simeval/__init__.py +6 -0
  34. openenergyid/simeval/main.py +148 -0
  35. openenergyid/simeval/models.py +162 -0
  36. {openenergyid-0.1.29.dist-info → openenergyid-0.1.30.dist-info}/METADATA +3 -1
  37. openenergyid-0.1.30.dist-info/RECORD +50 -0
  38. openenergyid-0.1.29.dist-info/RECORD +0 -30
  39. {openenergyid-0.1.29.dist-info → openenergyid-0.1.30.dist-info}/WHEEL +0 -0
  40. {openenergyid-0.1.29.dist-info → openenergyid-0.1.30.dist-info}/licenses/LICENSE +0 -0
  41. {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,11 @@
1
+ """PVLib-based simulator implementation."""
2
+
3
+ from .main import PVLibSimulationInput, PVLibSimulator
4
+ from .models import ModelChainModel, to_pv
5
+
6
+ __all__ = [
7
+ "PVLibSimulator",
8
+ "PVLibSimulationInput",
9
+ "ModelChainModel",
10
+ "to_pv",
11
+ ]
@@ -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
@@ -0,0 +1,5 @@
1
+ """Main Simulation Package that can handle every simulation."""
2
+
3
+ from .main import ExAnteData, FullSimulationInput, run_simulation
4
+
5
+ __all__ = ["FullSimulationInput", "run_simulation", "ExAnteData"]
@@ -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
@@ -0,0 +1,6 @@
1
+ """Module containing basic evaluation functions for energy systems."""
2
+
3
+ from .main import compare_results, evaluate
4
+ from .models import EvaluationInput, EvaluationOutput
5
+
6
+ __all__ = ["EvaluationInput", "EvaluationOutput", "evaluate", "compare_results"]