openenergyid 0.1.31__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.
Files changed (50) hide show
  1. openenergyid/__init__.py +8 -0
  2. openenergyid/abstractsim/__init__.py +5 -0
  3. openenergyid/abstractsim/abstract.py +102 -0
  4. openenergyid/baseload/__init__.py +15 -0
  5. openenergyid/baseload/analysis.py +190 -0
  6. openenergyid/baseload/exceptions.py +9 -0
  7. openenergyid/baseload/models.py +32 -0
  8. openenergyid/capacity/__init__.py +6 -0
  9. openenergyid/capacity/main.py +103 -0
  10. openenergyid/capacity/models.py +32 -0
  11. openenergyid/const.py +29 -0
  12. openenergyid/dyntar/__init__.py +20 -0
  13. openenergyid/dyntar/const.py +31 -0
  14. openenergyid/dyntar/main.py +313 -0
  15. openenergyid/dyntar/models.py +101 -0
  16. openenergyid/elia/__init__.py +4 -0
  17. openenergyid/elia/api.py +91 -0
  18. openenergyid/elia/const.py +18 -0
  19. openenergyid/energysharing/__init__.py +12 -0
  20. openenergyid/energysharing/const.py +8 -0
  21. openenergyid/energysharing/data_formatting.py +77 -0
  22. openenergyid/energysharing/main.py +122 -0
  23. openenergyid/energysharing/models.py +80 -0
  24. openenergyid/enums.py +16 -0
  25. openenergyid/models.py +174 -0
  26. openenergyid/mvlr/__init__.py +19 -0
  27. openenergyid/mvlr/helpers.py +30 -0
  28. openenergyid/mvlr/main.py +34 -0
  29. openenergyid/mvlr/models.py +227 -0
  30. openenergyid/mvlr/mvlr.py +450 -0
  31. openenergyid/pvsim/__init__.py +8 -0
  32. openenergyid/pvsim/abstract.py +60 -0
  33. openenergyid/pvsim/elia/__init__.py +3 -0
  34. openenergyid/pvsim/elia/main.py +89 -0
  35. openenergyid/pvsim/main.py +49 -0
  36. openenergyid/pvsim/pvlib/__init__.py +11 -0
  37. openenergyid/pvsim/pvlib/main.py +115 -0
  38. openenergyid/pvsim/pvlib/models.py +235 -0
  39. openenergyid/pvsim/pvlib/quickscan.py +99 -0
  40. openenergyid/pvsim/pvlib/weather.py +91 -0
  41. openenergyid/sim/__init__.py +5 -0
  42. openenergyid/sim/main.py +67 -0
  43. openenergyid/simeval/__init__.py +6 -0
  44. openenergyid/simeval/main.py +148 -0
  45. openenergyid/simeval/models.py +162 -0
  46. openenergyid-0.1.31.dist-info/METADATA +32 -0
  47. openenergyid-0.1.31.dist-info/RECORD +50 -0
  48. openenergyid-0.1.31.dist-info/WHEEL +5 -0
  49. openenergyid-0.1.31.dist-info/licenses/LICENSE +21 -0
  50. openenergyid-0.1.31.dist-info/top_level.txt +1 -0
@@ -0,0 +1,60 @@
1
+ """
2
+ This module contains the abstract base class for PVSimulator.
3
+ """
4
+
5
+ import datetime as dt
6
+ from abc import ABC
7
+ from typing import cast
8
+
9
+ import pandas as pd
10
+ from pydantic import Field
11
+
12
+ from openenergyid.models import TimeSeries
13
+
14
+ from ..abstractsim import SimulationInputAbstract, Simulator
15
+ from ..const import ELECTRICITY_PRODUCED
16
+
17
+
18
+ class PVSimulationInputAbstract(SimulationInputAbstract):
19
+ """
20
+ Input parameters for the PV simulation.
21
+ """
22
+
23
+ start: dt.date
24
+ end: dt.date
25
+ result_resolution: str = Field(
26
+ "15min",
27
+ description="Resolution of the simulation results",
28
+ examples=["15min", "1h", "D", "MS"],
29
+ )
30
+
31
+
32
+ class PVSimulator(Simulator, ABC):
33
+ """
34
+ An abstract base class for PV simulators.
35
+ """
36
+
37
+ def __init__(self, result_resolution: str = "15min", **kwargs) -> None:
38
+ self._simulation_results: pd.Series | None = None
39
+ self.result_resolution = result_resolution
40
+
41
+ @property
42
+ def simulation_results(self) -> pd.Series:
43
+ """The results of the simulation."""
44
+ if self._simulation_results is None:
45
+ results = self.simulate()
46
+ self._simulation_results = cast(pd.Series, results)
47
+ return self._simulation_results
48
+
49
+ def result_to_timeseries(self):
50
+ """
51
+ Convert the simulation results to a TimeSeries object.
52
+ """
53
+ result = self.simulation_results.resample(self.result_resolution).sum()
54
+ return TimeSeries.from_pandas(result)
55
+
56
+ def result_as_frame(self) -> pd.DataFrame:
57
+ """
58
+ Convert the simulation results to a DataFrame.
59
+ """
60
+ return self.simulation_results.rename(ELECTRICITY_PRODUCED).to_frame()
@@ -0,0 +1,3 @@
1
+ from .main import EliaPVSimulationInput, EliaPVSimulator
2
+
3
+ __all__ = ["EliaPVSimulationInput", "EliaPVSimulator"]
@@ -0,0 +1,89 @@
1
+ """
2
+ This module contains the LoadFactorPVSimulator class which
3
+ simulates the power output of a PV system based on load factors.
4
+ """
5
+
6
+ import datetime as dt
7
+ from typing import Literal
8
+
9
+ import aiohttp
10
+ import pandas as pd
11
+ from pydantic import Field
12
+
13
+ from openenergyid import elia
14
+ from openenergyid.pvsim.abstract import PVSimulationInputAbstract, PVSimulator
15
+
16
+
17
+ class EliaPVSimulationInput(PVSimulationInputAbstract):
18
+ """Input parameters for the Elia PV simulation."""
19
+
20
+ type: Literal["eliapvsimulation"] = Field("eliapvsimulation", frozen=True) # tag
21
+ region: elia.Region
22
+ panel_power: float = Field(..., gt=0, description="Installed panel power in W")
23
+ inverter_power: float = Field(..., gt=0, description="Installed inverter power in W")
24
+
25
+
26
+ class EliaPVSimulator(PVSimulator):
27
+ """
28
+ A PV simulator that simulates the power output of a PV system based on load factors.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ start: dt.date,
34
+ end: dt.date,
35
+ panel_power: float,
36
+ inverter_power: float,
37
+ region: elia.Region,
38
+ load_factors: pd.Series | None = None,
39
+ **kwargs,
40
+ ):
41
+ self.start = start
42
+ self.end = end
43
+ self.panel_power = panel_power / 1000 # convert to kW
44
+ self.inverter_power = inverter_power / 1000 # convert to kW
45
+ self.region = region
46
+ self.load_factors = load_factors if load_factors is not None else pd.Series(dtype=float)
47
+
48
+ super().__init__(**kwargs)
49
+
50
+ def simulate(self, **kwargs) -> pd.Series:
51
+ """Run the simulation."""
52
+ result = self.load_factors * self.panel_power * 0.01
53
+ result.clip(upper=self.inverter_power, inplace=True)
54
+ result = result * 0.25 # To Energy
55
+ result.name = None
56
+ return result
57
+
58
+ @staticmethod
59
+ async def download_load_factors(
60
+ start: dt.date,
61
+ end: dt.date,
62
+ region: elia.Region,
63
+ session: aiohttp.ClientSession,
64
+ ) -> pd.Series:
65
+ """Download load factors from the API."""
66
+ pv_load_factor_json = await elia.get_dataset(
67
+ dataset="ods032",
68
+ start=start,
69
+ end=end,
70
+ region=region,
71
+ select={"loadfactor"},
72
+ session=session,
73
+ )
74
+ pv_load_factors = elia.parse_response(
75
+ data=pv_load_factor_json, index="datetime", columns=["loadfactor"]
76
+ )["loadfactor"]
77
+ pv_load_factors = pv_load_factors.truncate(
78
+ after=pd.Timestamp(end, tz="Europe/Brussels") - pd.Timedelta(minutes=15)
79
+ )
80
+ return pv_load_factors
81
+
82
+ async def load_resources(self, session: aiohttp.ClientSession) -> None:
83
+ """Load resources required for the simulation."""
84
+ self.load_factors = await self.download_load_factors(
85
+ start=self.start,
86
+ end=self.end,
87
+ region=self.region,
88
+ session=session,
89
+ )
@@ -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"