fram-core 0.0.0__py3-none-any.whl → 0.1.0__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 (103) hide show
  1. fram_core-0.1.0.dist-info/METADATA +42 -0
  2. fram_core-0.1.0.dist-info/RECORD +100 -0
  3. {fram_core-0.0.0.dist-info → fram_core-0.1.0.dist-info}/WHEEL +1 -2
  4. fram_core-0.1.0.dist-info/licenses/LICENSE.md +8 -0
  5. framcore/Base.py +161 -0
  6. framcore/Model.py +90 -0
  7. framcore/__init__.py +10 -0
  8. framcore/aggregators/Aggregator.py +172 -0
  9. framcore/aggregators/HydroAggregator.py +849 -0
  10. framcore/aggregators/NodeAggregator.py +530 -0
  11. framcore/aggregators/WindSolarAggregator.py +315 -0
  12. framcore/aggregators/__init__.py +13 -0
  13. framcore/aggregators/_utils.py +184 -0
  14. framcore/attributes/Arrow.py +307 -0
  15. framcore/attributes/ElasticDemand.py +90 -0
  16. framcore/attributes/ReservoirCurve.py +23 -0
  17. framcore/attributes/SoftBound.py +16 -0
  18. framcore/attributes/StartUpCost.py +65 -0
  19. framcore/attributes/Storage.py +158 -0
  20. framcore/attributes/TargetBound.py +16 -0
  21. framcore/attributes/__init__.py +63 -0
  22. framcore/attributes/hydro/HydroBypass.py +49 -0
  23. framcore/attributes/hydro/HydroGenerator.py +100 -0
  24. framcore/attributes/hydro/HydroPump.py +178 -0
  25. framcore/attributes/hydro/HydroReservoir.py +27 -0
  26. framcore/attributes/hydro/__init__.py +13 -0
  27. framcore/attributes/level_profile_attributes.py +911 -0
  28. framcore/components/Component.py +136 -0
  29. framcore/components/Demand.py +144 -0
  30. framcore/components/Flow.py +189 -0
  31. framcore/components/HydroModule.py +371 -0
  32. framcore/components/Node.py +99 -0
  33. framcore/components/Thermal.py +208 -0
  34. framcore/components/Transmission.py +198 -0
  35. framcore/components/_PowerPlant.py +81 -0
  36. framcore/components/__init__.py +22 -0
  37. framcore/components/wind_solar.py +82 -0
  38. framcore/curves/Curve.py +44 -0
  39. framcore/curves/LoadedCurve.py +146 -0
  40. framcore/curves/__init__.py +9 -0
  41. framcore/events/__init__.py +21 -0
  42. framcore/events/events.py +51 -0
  43. framcore/expressions/Expr.py +591 -0
  44. framcore/expressions/__init__.py +30 -0
  45. framcore/expressions/_get_constant_from_expr.py +477 -0
  46. framcore/expressions/_utils.py +73 -0
  47. framcore/expressions/queries.py +416 -0
  48. framcore/expressions/units.py +227 -0
  49. framcore/fingerprints/__init__.py +11 -0
  50. framcore/fingerprints/fingerprint.py +292 -0
  51. framcore/juliamodels/JuliaModel.py +171 -0
  52. framcore/juliamodels/__init__.py +7 -0
  53. framcore/loaders/__init__.py +10 -0
  54. framcore/loaders/loaders.py +405 -0
  55. framcore/metadata/Div.py +73 -0
  56. framcore/metadata/ExprMeta.py +56 -0
  57. framcore/metadata/LevelExprMeta.py +32 -0
  58. framcore/metadata/Member.py +55 -0
  59. framcore/metadata/Meta.py +44 -0
  60. framcore/metadata/__init__.py +15 -0
  61. framcore/populators/Populator.py +108 -0
  62. framcore/populators/__init__.py +7 -0
  63. framcore/querydbs/CacheDB.py +50 -0
  64. framcore/querydbs/ModelDB.py +34 -0
  65. framcore/querydbs/QueryDB.py +45 -0
  66. framcore/querydbs/__init__.py +11 -0
  67. framcore/solvers/Solver.py +63 -0
  68. framcore/solvers/SolverConfig.py +272 -0
  69. framcore/solvers/__init__.py +9 -0
  70. framcore/timeindexes/AverageYearRange.py +27 -0
  71. framcore/timeindexes/ConstantTimeIndex.py +22 -0
  72. framcore/timeindexes/DailyIndex.py +33 -0
  73. framcore/timeindexes/FixedFrequencyTimeIndex.py +814 -0
  74. framcore/timeindexes/HourlyIndex.py +33 -0
  75. framcore/timeindexes/IsoCalendarDay.py +33 -0
  76. framcore/timeindexes/ListTimeIndex.py +277 -0
  77. framcore/timeindexes/ModelYear.py +23 -0
  78. framcore/timeindexes/ModelYears.py +27 -0
  79. framcore/timeindexes/OneYearProfileTimeIndex.py +29 -0
  80. framcore/timeindexes/ProfileTimeIndex.py +43 -0
  81. framcore/timeindexes/SinglePeriodTimeIndex.py +37 -0
  82. framcore/timeindexes/TimeIndex.py +103 -0
  83. framcore/timeindexes/WeeklyIndex.py +33 -0
  84. framcore/timeindexes/__init__.py +36 -0
  85. framcore/timeindexes/_time_vector_operations.py +689 -0
  86. framcore/timevectors/ConstantTimeVector.py +131 -0
  87. framcore/timevectors/LinearTransformTimeVector.py +131 -0
  88. framcore/timevectors/ListTimeVector.py +127 -0
  89. framcore/timevectors/LoadedTimeVector.py +97 -0
  90. framcore/timevectors/ReferencePeriod.py +51 -0
  91. framcore/timevectors/TimeVector.py +108 -0
  92. framcore/timevectors/__init__.py +17 -0
  93. framcore/utils/__init__.py +35 -0
  94. framcore/utils/get_regional_volumes.py +387 -0
  95. framcore/utils/get_supported_components.py +60 -0
  96. framcore/utils/global_energy_equivalent.py +63 -0
  97. framcore/utils/isolate_subnodes.py +172 -0
  98. framcore/utils/loaders.py +97 -0
  99. framcore/utils/node_flow_utils.py +236 -0
  100. framcore/utils/storage_subsystems.py +106 -0
  101. fram_core-0.0.0.dist-info/METADATA +0 -5
  102. fram_core-0.0.0.dist-info/RECORD +0 -4
  103. fram_core-0.0.0.dist-info/top_level.txt +0 -1
@@ -0,0 +1,911 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC
4
+ from typing import TYPE_CHECKING
5
+
6
+ import numpy as np
7
+ from numpy.typing import NDArray
8
+
9
+ from framcore import Base
10
+ from framcore.expressions import (
11
+ Expr,
12
+ ensure_expr,
13
+ get_leaf_profiles,
14
+ get_level_value,
15
+ get_profile_exprs_from_leaf_levels,
16
+ get_profile_vector,
17
+ get_timeindexes_from_expr,
18
+ get_units_from_expr,
19
+ )
20
+ from framcore.expressions._get_constant_from_expr import _get_constant_from_expr
21
+ from framcore.querydbs import QueryDB
22
+ from framcore.timeindexes import FixedFrequencyTimeIndex, SinglePeriodTimeIndex, TimeIndex
23
+ from framcore.timevectors import ConstantTimeVector, ReferencePeriod, TimeVector
24
+
25
+ if TYPE_CHECKING:
26
+ from framcore import Model
27
+ from framcore.loaders import Loader
28
+
29
+
30
+ # TODO: Name all abstract classes Abstract[clsname]
31
+ class LevelProfile(Base, ABC):
32
+ """
33
+ Attributes representing timeseries data for Components. Mostly as Level * Profile, where both Level and Profile are Expr (expressions).
34
+
35
+ Level and Profile represent two distinct dimensions of time. This is because we want to simulate future system states with historical weather patterns.
36
+ Therefore, Level represents the system state at a given time (data_dim), while Profile represents the scenario dimension (scen_dim).
37
+ A Level would for example represent the installed capacity of solar plants towards 2030,
38
+ while the Profile would represent the historical variation between 1991-2020.
39
+
40
+ Level and Profile can have two main formats: A maximum Level with a Profile that varies between 0-1,
41
+ and an average Level with a Profile with a mean of 1 (the latter can have a ReferencePeriod).
42
+ The max format is, for example, used for capacities, while the mean format can be used for prices and flows.
43
+ The system needs to be able to convert between the two formats. This is especially important for aggregations
44
+ (for example weighted averages) where all the TimeVectors need to be on the same format for a correct result.
45
+ One simple example of conversion is pairing a max Level of 100 MW with a mean_one Profile [0, 1, 2].
46
+ Asking for this on the max format will return the series 100*[0, 0.5, 1] MW, while on the avg format it will return 50*[0, 1, 2] MW.
47
+
48
+ Queries to LevelProfile need to provide a database, the desired target TimeIndex for both dimensions, the target unit and the desired format.
49
+ At the moment we support these queries for LevelProfile:
50
+ - self.get_data_value(db, scen_dim, data_dim, unit, is_max_level)
51
+ - self.get_scenario_vector(db, scen_dim, data_dim, unit, is_float32)
52
+
53
+ In addition, we have the possibility to shift, scale, and change the intercept of the LevelProfiles.
54
+ Then we get the full representation: Scale * (Level + Level_shift) * Profile + Intercept.
55
+ - Level_shift adds a constant value to Level, has the same Profile as Level.
56
+ - Scale multiplies (Level + Level_shift) by a constant value.
57
+ - Intercept adds a constant value to LevelProfile, ignoring Level and Profile. **This is the only way of supporting a timeseries that crosses zero
58
+ in our system. This functionality is under development and has not been properly tested.**
59
+
60
+ LevelProfiles also have additional properties that describes their behaviour. These can be used for initialization, validation,
61
+ and to simplify queries. The properties are:
62
+ - is_stock: True if attribute is a stock variable. Level Expr should also have is_stock=True. See Expr for details.
63
+ - is_flow: True if attribute is a flow variable. Level Expr should also have is_flow=True. See Expr for details.
64
+ - is_not_negative: True if attribute is not allowed to have negative values. Level Expr should also have only non-negative values.
65
+ - is_max_and_zero_one: Preferred format of Level and Profile. Used for initialization and queries.
66
+ - is_ingoing: True if attribute is ingoing, False if outgoing, None if neither.
67
+ - is_cost: True if attribute is objective function cost coefficient. Else None.
68
+ - is_unitless: True if attribute is known to be unitless. False if known to have a unit that is not None. Else None.
69
+
70
+ """
71
+
72
+ # must be overwritten by subclass when otherwise
73
+ # don't change the defaults
74
+ _IS_ABSTRACT: bool = True
75
+ _IS_STOCK: bool = False
76
+ _IS_FLOW: bool = False
77
+ _IS_NOT_NEGATIVE: bool = True
78
+ _IS_MAX_AND_ZERO_ONE: bool = False
79
+
80
+ # must be set by subclass when applicable
81
+ _IS_INGOING: bool | None = None
82
+ _IS_COST: bool | None = None
83
+ _IS_UNITLESS: bool | None = None
84
+
85
+ def __init__(
86
+ self,
87
+ level: Expr | TimeVector | str | None = None,
88
+ profile: Expr | TimeVector | str | None = None,
89
+ value: float | int | None = None, # To support Price(value=20, unit="EUR/MWh")
90
+ unit: str | None = None,
91
+ level_shift: Expr | None = None,
92
+ intercept: Expr | None = None,
93
+ scale: Expr | None = None,
94
+ ) -> None:
95
+ """
96
+ Initialize LevelProfile.
97
+
98
+ See the LevelProfile class docstring for details. A complete LevelProfile is represented as:
99
+ Scale * (Level + Level_shift) * Profile + Intercept. Normally only Level and Profile are used.
100
+
101
+ Either give level and profile, or value and unit.
102
+
103
+ Args:
104
+ level (Expr | TimeVector | str | None, optional): Level Expr. Defaults to None.
105
+ profile (Expr | TimeVector | str | None, optional): Profile Expr. Defaults to None.
106
+ value (float | int | None, optional): A constant value to initialize Level. Defaults to None.
107
+ unit (str | None, optional): Unit of the constant value to initialize Level. Defaults to None.
108
+ level_shift (Expr | None, optional): Level_shift Expr. Defaults to None.
109
+ intercept (Expr | None, optional): Intercept Expr. Defaults to None.
110
+ scale (Expr | None, optional): Scale Expr. Defaults to None.
111
+
112
+ """
113
+ self._assert_invariants()
114
+
115
+ self._check_type(value, (float, int, type(None)))
116
+ self._check_type(unit, (str, type(None)))
117
+ self._check_type(level, (Expr, TimeVector, str, type(None)))
118
+ self._check_type(profile, (Expr, TimeVector, str, type(None)))
119
+ self._check_type(level_shift, (Expr, type(None)))
120
+ self._check_type(intercept, (Expr, type(None)))
121
+ self._check_type(scale, (Expr, type(None)))
122
+ level = self._ensure_level_expr(level, value, unit)
123
+ profile = self._ensure_profile_expr(profile)
124
+ self._ensure_compatible_level_profile_combo(level, profile)
125
+ self._ensure_compatible_level_profile_combo(level_shift, profile)
126
+ self._level: Expr | None = level
127
+ self._profile: Expr | None = profile
128
+ self._level_shift: Expr | None = level_shift
129
+ self._intercept: Expr | None = intercept
130
+ self._scale: Expr | None = scale
131
+ # TODO: Validate that profiles are equal in level and level_shift.
132
+ # TODO: Validate that level_shift, scale and intercept only consist of Exprs with ConstantTimeVectors
133
+ # TODO: Validate that level_shift, level_scale and intercept have correct Expr properties
134
+
135
+ def _assert_invariants(self) -> None:
136
+ abstract = self._IS_ABSTRACT
137
+ max_level_profile = self._IS_MAX_AND_ZERO_ONE
138
+ stock = self._IS_STOCK
139
+ flow = self._IS_FLOW
140
+ unitless = self._IS_UNITLESS
141
+ ingoing = self._IS_INGOING
142
+ cost = self._IS_COST
143
+ not_negative = self._IS_NOT_NEGATIVE
144
+
145
+ assert not abstract, "Abstract types should only be used for type hints and checks."
146
+ assert isinstance(max_level_profile, bool)
147
+ assert isinstance(stock, bool)
148
+ assert isinstance(flow, bool)
149
+ assert isinstance(not_negative, bool)
150
+ assert isinstance(ingoing, bool | type(None))
151
+ assert isinstance(unitless, bool | type(None))
152
+ assert isinstance(cost, bool | type(None))
153
+ assert not (flow and stock)
154
+ if flow or stock:
155
+ assert not unitless, "flow and stock must have unit that is not None."
156
+ assert not_negative, "flow and stock cannot have negative values."
157
+ if ingoing is True:
158
+ assert cost is None, "cost must be None when ingoing is True."
159
+ if cost is True:
160
+ assert ingoing is None, "ingoing must be None when cost is True."
161
+
162
+ parent = super()
163
+ if isinstance(parent, LevelProfile) and not parent._IS_ABSTRACT: # noqa: SLF001
164
+ self._assert_same_behaviour(parent)
165
+
166
+ def add_loaders(self, loaders: set[Loader]) -> None:
167
+ """Add all loaders stored in expressions to loaders."""
168
+ from framcore.utils import add_loaders_if
169
+
170
+ add_loaders_if(loaders, self.get_level())
171
+ add_loaders_if(loaders, self.get_profile())
172
+
173
+ def clear(self) -> None:
174
+ """
175
+ Set all internal fields to None.
176
+
177
+ You may want to use this to get exogenous flow to use capacities instead of volume.
178
+ """
179
+ self._level = None
180
+ self._profile = None
181
+ self._level_shift = None
182
+ self._intercept = None
183
+ self._scale = None
184
+
185
+ def is_stock(self) -> bool:
186
+ """
187
+ Return True if attribute is a stock variable.
188
+
189
+ Return False if attribute is not a stock variable.
190
+ """
191
+ return self._IS_STOCK
192
+
193
+ def is_flow(self) -> bool:
194
+ """
195
+ Return True if attribute is a flow variable.
196
+
197
+ Return False if attribute is not a flow variable.
198
+ """
199
+ return self._IS_FLOW
200
+
201
+ def is_not_negative(self) -> bool:
202
+ """
203
+ Return True if attribute is not allowed to have negative values.
204
+
205
+ Return False if attribute can have both positive and negative values.
206
+ """
207
+ return self._IS_NOT_NEGATIVE
208
+
209
+ def is_max_and_zero_one(self) -> bool:
210
+ """
211
+ When True level should be max (not average) and corresponding profile should be zero_one (not mean_one).
212
+
213
+ When False level should be average (not max) and corresponding profile should be mean_one (not zero_one).
214
+ """
215
+ return self._IS_MAX_AND_ZERO_ONE
216
+
217
+ def is_ingoing(self) -> bool | None:
218
+ """
219
+ Return True if attribute is ingoing.
220
+
221
+ Return True if attribute is outgoing.
222
+
223
+ Return None if not applicable.
224
+ """
225
+ return self._IS_INGOING
226
+
227
+ def is_cost(self) -> bool | None:
228
+ """
229
+ Return True if attribute is objective function cost coefficient.
230
+
231
+ Return False if attribute is objective function revenue coefficient.
232
+
233
+ Return None if not applicable.
234
+ """
235
+ return self._IS_COST
236
+
237
+ def is_unitless(self) -> bool | None:
238
+ """
239
+ Return True if attribute is known to be unitless.
240
+
241
+ Return False if attribute is known to have a unit that is not None.
242
+
243
+ Return None if not applicable.
244
+ """
245
+ return self._IS_UNITLESS
246
+
247
+ def has_level(self) -> bool:
248
+ """Return True if get_level will return value not None."""
249
+ return (self._level is not None) or (self._level_shift is not None)
250
+
251
+ def has_profile(self) -> bool:
252
+ """Return True if get_profile will return value not None."""
253
+ return self._profile is not None
254
+
255
+ def has_intercept(self) -> bool:
256
+ """Return True if get_intercept will return value not None."""
257
+ return self._intercept is not None
258
+
259
+ def copy_from(self, other: LevelProfile) -> None:
260
+ """Copy fields from other."""
261
+ self._check_type(other, LevelProfile)
262
+ self._assert_same_behaviour(other)
263
+ self._level = other._level
264
+ self._profile = other._profile
265
+ self._level_shift = other._level_shift
266
+ self._intercept = other._intercept
267
+ self._scale = other._scale
268
+
269
+ def get_level(self) -> Expr | None:
270
+ """Get level part of (level * profile + intercept)."""
271
+ level = self._level
272
+
273
+ if level is None:
274
+ return None
275
+
276
+ if level.is_leaf():
277
+ level = Expr(
278
+ src=level.get_src(),
279
+ operations=level.get_operations(expect_ops=False, copy_list=True),
280
+ is_stock=level.is_stock(),
281
+ is_flow=level.is_flow(),
282
+ is_level=True,
283
+ is_profile=False,
284
+ profile=self._profile,
285
+ )
286
+
287
+ if self._level_shift is not None:
288
+ level += self._level_shift
289
+
290
+ if self._scale is not None:
291
+ level *= self._scale
292
+
293
+ return level
294
+
295
+ def set_level(self, level: Expr | TimeVector | str | None) -> None:
296
+ """Set level part of (scale * (level + level_shift) * profile + intercept)."""
297
+ self._check_type(level, (Expr, TimeVector, str, type(None)))
298
+ level = self._ensure_level_expr(level)
299
+ self._ensure_compatible_level_profile_combo(level, self._profile)
300
+ self._level = level
301
+
302
+ def get_profile(self) -> Expr | None:
303
+ """Get profile part of (level * profile + intercept)."""
304
+ return self._profile
305
+
306
+ def set_profile(self, profile: Expr | TimeVector | str | None) -> None:
307
+ """Set profile part of (scale * (level + level_shift) * profile + intercept)."""
308
+ self._check_type(profile, (Expr, TimeVector, str, type(None)))
309
+ profile = self._ensure_profile_expr(profile)
310
+ self._ensure_compatible_level_profile_combo(self._level, profile)
311
+ self._profile = profile
312
+
313
+ def get_intercept(self) -> Expr | None:
314
+ """Get intercept part of (level * profile + intercept)."""
315
+ intercept = self._intercept
316
+ if self._scale is not None:
317
+ intercept *= self._scale
318
+ return intercept
319
+
320
+ def set_intercept(self, value: Expr | None) -> None:
321
+ """Set intercept part of (level * profile + intercept)."""
322
+ self._check_type(value, (Expr, type(None)))
323
+ if value is not None:
324
+ self._check_level_expr(value)
325
+ self._intercept = value
326
+
327
+ def get_level_unit_set(
328
+ self,
329
+ db: QueryDB | Model,
330
+ ) -> set[TimeIndex]:
331
+ """
332
+ Return set with all units behind level expression.
333
+
334
+ Useful for discovering valid unit input to get_level_value.
335
+ """
336
+ if not self.has_level():
337
+ return set()
338
+ return get_units_from_expr(db, self.get_level())
339
+
340
+ def get_profile_timeindex_set(
341
+ self,
342
+ db: QueryDB | Model,
343
+ ) -> set[TimeIndex]:
344
+ """
345
+ Return set with all TimeIndex behind profile expression.
346
+
347
+ Can be used to run optimized queries, i.e. not asking for
348
+ finer time resolutions than necessary.
349
+ """
350
+ if not self.has_profile():
351
+ return set()
352
+ return get_timeindexes_from_expr(db, self.get_profile())
353
+
354
+ def get_scenario_vector(
355
+ self,
356
+ db: QueryDB | Model,
357
+ scenario_horizon: FixedFrequencyTimeIndex,
358
+ level_period: SinglePeriodTimeIndex,
359
+ unit: str | None,
360
+ is_float32: bool = True,
361
+ ) -> NDArray:
362
+ """
363
+ Evaluate LevelProfile over the periods in scenario dimension, and at the level period of the data dimension.
364
+
365
+ Underlying profiles are evalutated over the scenario dimension,
366
+ and levels are evalutated to scalars over level_period in the data dimension.
367
+
368
+ Args:
369
+ db (QueryDB | Model): The database or model instance used to fetch the required data.
370
+ scenario_horizon (FixedFrequencyTimeIndex): TimeIndex of the scenario dimension to evaluate profiles.
371
+ level_period (SinglePeriodTimeIndex): TimeIndex of the data dimension to evaluate levels.
372
+ unit (str | None): The unit to convert the resulting values into (e.g., MW, GWh). If None,
373
+ the expression should be unitless.
374
+ is_float32 (bool, optional): Whether to return the vector as a NumPy array with `float32`
375
+ precision. Defaults to True.
376
+
377
+ """
378
+ return self._get_scenario_vector(db, scenario_horizon, level_period, unit, is_float32)
379
+
380
+ def get_data_value(
381
+ self,
382
+ db: QueryDB | Model,
383
+ scenario_horizon: FixedFrequencyTimeIndex,
384
+ level_period: SinglePeriodTimeIndex,
385
+ unit: str | None,
386
+ is_max_level: bool | None = None,
387
+ ) -> float:
388
+ """
389
+ Evaluate LevelProfile to a scalar at the level period of the data dimension, and as an average over the scenario horizon.
390
+
391
+ Args:
392
+ db (QueryDB | Model): The database or model instance used to fetch the required data.
393
+ scenario_horizon (FixedFrequencyTimeIndex): TimeIndex of the scenario dimension to evaluate profiles.
394
+ level_period (SinglePeriodTimeIndex): TimeIndex of the data dimension to evaluate levels.
395
+ unit (str | None): The unit to convert the resulting values into (e.g., MW, GWh). If None,
396
+ the expression should be unitless.
397
+ is_max_level (bool | None, optional): Whether to evaluate the expression as a maximum level (with a zero_one profile)
398
+ or as an average level (with a mean_one profile). If None, the default format of the attribute is used.
399
+
400
+ """
401
+ return self._get_data_value(db, scenario_horizon, level_period, unit, is_max_level)
402
+
403
+ def shift_intercept(self, value: float, unit: str | None) -> None:
404
+ """Modify the intercept part of (level * profile + intercept) of an attribute by adding a constant value."""
405
+ expr = ensure_expr(
406
+ ConstantTimeVector(self._ensure_float(value), unit=unit, is_max_level=False),
407
+ is_level=True,
408
+ is_profile=False,
409
+ is_stock=self._IS_STOCK,
410
+ is_flow=self._IS_FLOW,
411
+ profile=None,
412
+ )
413
+ if self._intercept is None:
414
+ self._intercept = expr
415
+ else:
416
+ self._intercept += expr
417
+
418
+ def shift_level(
419
+ self,
420
+ value: float | int,
421
+ unit: str | None = None,
422
+ reference_period: ReferencePeriod | None = None,
423
+ is_max_level: bool | None = None,
424
+ use_profile: bool = True, # TODO: Remove. Should always use profile. If has profile validate that it is equal to the profile of Level.
425
+ ) -> None:
426
+ """Modify the level_shift part of (scale * (level + level_shift) * profile + intercept) of an attribute by adding a constant value."""
427
+ # TODO: Not allowed to shift if there is intercept?
428
+ self._check_type(value, (float, int))
429
+ self._check_type(unit, (str, type(None)))
430
+ self._check_type(reference_period, (ReferencePeriod, type(None)))
431
+ self._check_type(is_max_level, (bool, type(None)))
432
+ self._check_type(use_profile, bool)
433
+
434
+ if is_max_level is None:
435
+ is_max_level = self._IS_MAX_AND_ZERO_ONE
436
+
437
+ expr = ensure_expr(
438
+ ConstantTimeVector(
439
+ self._ensure_float(value),
440
+ unit=unit,
441
+ is_max_level=is_max_level,
442
+ reference_period=reference_period,
443
+ ),
444
+ is_level=True,
445
+ is_profile=False,
446
+ is_stock=self._IS_STOCK,
447
+ is_flow=self._IS_FLOW,
448
+ profile=self._profile if use_profile else None,
449
+ )
450
+ if self._level_shift is None:
451
+ self._level_shift = expr
452
+ else:
453
+ self._level_shift += expr
454
+
455
+ def scale(self, value: float | int) -> None:
456
+ """Modify the scale part of (scale * (level + level_shift) * profile + intercept) of an attribute by multiplying with a constant value."""
457
+ # TODO: Not allowed to scale if there is intercept?
458
+ expr = ensure_expr(
459
+ ConstantTimeVector(self._ensure_float(value), unit=None, is_max_level=False),
460
+ is_level=True,
461
+ is_profile=False,
462
+ profile=None,
463
+ )
464
+ if self._scale is None:
465
+ self._scale = expr
466
+ else:
467
+ self._scale *= expr
468
+
469
+ def _ensure_level_expr(
470
+ self,
471
+ level: Expr | str | TimeVector | None,
472
+ value: float | int | None = None,
473
+ unit: str | None = None,
474
+ reference_period: ReferencePeriod | None = None,
475
+ ) -> Expr | None:
476
+ if value is not None:
477
+ level = ConstantTimeVector(
478
+ scalar=float(value),
479
+ unit=unit,
480
+ is_max_level=self._IS_MAX_AND_ZERO_ONE,
481
+ is_zero_one_profile=None,
482
+ reference_period=reference_period,
483
+ )
484
+ if level is None:
485
+ return None
486
+
487
+ if isinstance(level, Expr):
488
+ self._check_level_expr(level)
489
+ return level
490
+
491
+ return Expr(
492
+ src=level,
493
+ is_flow=self._IS_FLOW,
494
+ is_stock=self._IS_STOCK,
495
+ is_level=True,
496
+ is_profile=False,
497
+ profile=None,
498
+ )
499
+
500
+ def _ensure_compatible_level_profile_combo(self, level: Expr | None, profile: Expr | None) -> None:
501
+ """Check that all profiles in leaf levels (in level) also exist in profile."""
502
+ if level is None or profile is None:
503
+ return
504
+
505
+ leaf_level_profiles = get_profile_exprs_from_leaf_levels(level)
506
+ leaf_profile_profiles = get_leaf_profiles(profile)
507
+
508
+ for p in leaf_level_profiles:
509
+ if p not in leaf_profile_profiles:
510
+ message = (
511
+ f"Incompatible level/profile combination because all profiles in leaf levels (in level) does not exist in profile. "
512
+ f"Profile expression {p} found in level {level} but not in profile."
513
+ )
514
+ raise ValueError(message)
515
+
516
+ def _check_level_expr(self, expr: Expr) -> None:
517
+ msg = f"{self} requires {expr} to be "
518
+ if expr.is_stock() != self._IS_STOCK:
519
+ raise ValueError(msg + f"is_stock={self._IS_STOCK}")
520
+ if expr.is_flow() != self._IS_FLOW:
521
+ raise ValueError(msg + f"is_flow={self._IS_STOCK}")
522
+ if expr.is_level() is False:
523
+ raise ValueError(msg + "is_level=True")
524
+ if expr.is_profile() is True:
525
+ raise ValueError(msg + "is_profile=False")
526
+
527
+ def _check_profile_expr(self, expr: Expr) -> None:
528
+ msg = f"{self} requires {expr} to be "
529
+ if expr.is_stock() is True:
530
+ raise ValueError(msg + "is_stock=False")
531
+ if expr.is_flow() is True:
532
+ raise ValueError(msg + "is_flow=False")
533
+ if expr.is_level() is True:
534
+ raise ValueError(msg + "is_level=False")
535
+ if expr.is_profile() is False:
536
+ raise ValueError(msg + "is_profile=True")
537
+
538
+ def _ensure_profile_expr(
539
+ self,
540
+ value: Expr | str | TimeVector | None,
541
+ ) -> Expr | None:
542
+ if value is None:
543
+ return None
544
+
545
+ if isinstance(value, Expr):
546
+ self._check_profile_expr(value)
547
+ return value
548
+
549
+ return Expr(
550
+ src=value,
551
+ is_flow=False,
552
+ is_stock=False,
553
+ is_level=False,
554
+ is_profile=True,
555
+ profile=None,
556
+ )
557
+
558
+ def _get_data_value(
559
+ self,
560
+ db: QueryDB,
561
+ scenario_horizon: FixedFrequencyTimeIndex,
562
+ level_period: SinglePeriodTimeIndex,
563
+ unit: str | None,
564
+ is_max_level: bool | None,
565
+ ) -> float:
566
+ # NB! don't type check db, as this is done in get_level_value and get_profile_vector
567
+ self._check_type(scenario_horizon, FixedFrequencyTimeIndex)
568
+ self._check_type(level_period, SinglePeriodTimeIndex)
569
+ self._check_type(unit, (str, type(None)))
570
+ self._check_type(is_max_level, (bool, type(None)))
571
+
572
+ level_expr = self.get_level()
573
+
574
+ if is_max_level is None:
575
+ is_max_level = self._IS_MAX_AND_ZERO_ONE
576
+
577
+ self._check_type(level_expr, (Expr, type(None)))
578
+ if not isinstance(level_expr, Expr):
579
+ raise ValueError("Attribute level Expr is None. Have you called Solver.solve yet?")
580
+
581
+ level_value = get_level_value(
582
+ expr=level_expr,
583
+ db=db,
584
+ scen_dim=scenario_horizon,
585
+ data_dim=level_period,
586
+ unit=unit,
587
+ is_max=is_max_level,
588
+ )
589
+
590
+ intercept = None
591
+ if self._intercept is not None:
592
+ intercept = _get_constant_from_expr(
593
+ self._intercept,
594
+ db,
595
+ unit=unit,
596
+ data_dim=level_period,
597
+ scen_dim=scenario_horizon,
598
+ is_max=is_max_level,
599
+ )
600
+
601
+ if intercept is None:
602
+ return level_value
603
+
604
+ return level_value + intercept
605
+
606
+ def _get_scenario_vector(
607
+ self,
608
+ db: QueryDB | Model,
609
+ scenario_horizon: FixedFrequencyTimeIndex,
610
+ level_period: SinglePeriodTimeIndex,
611
+ unit: str | None,
612
+ is_float32: bool = True,
613
+ ) -> NDArray:
614
+ """Return vector with values along the given scenario horizon using level over level_period."""
615
+ # NB! don't type check db, as this is done in get_level_value and get_profile_vector
616
+ self._check_type(scenario_horizon, FixedFrequencyTimeIndex)
617
+ self._check_type(level_period, SinglePeriodTimeIndex)
618
+ self._check_type(unit, (str, type(None)))
619
+ self._check_type(is_float32, bool)
620
+
621
+ level_expr = self.get_level()
622
+
623
+ self._check_type(level_expr, (Expr, type(None)))
624
+ if not isinstance(level_expr, Expr):
625
+ raise ValueError("Attribute level Expr is None. Have you called Solver.solve yet?")
626
+
627
+ level_value = get_level_value(
628
+ expr=level_expr,
629
+ db=db,
630
+ scen_dim=scenario_horizon,
631
+ data_dim=level_period,
632
+ unit=unit,
633
+ is_max=self._IS_MAX_AND_ZERO_ONE,
634
+ )
635
+
636
+ profile_expr = self.get_profile()
637
+
638
+ if profile_expr is None:
639
+ profile_vector = np.ones(
640
+ scenario_horizon.get_num_periods(),
641
+ dtype=np.float32 if is_float32 else np.float64,
642
+ )
643
+ else:
644
+ profile_vector = get_profile_vector(
645
+ expr=profile_expr,
646
+ db=db,
647
+ scen_dim=scenario_horizon,
648
+ data_dim=level_period,
649
+ is_zero_one=self._IS_MAX_AND_ZERO_ONE,
650
+ is_float32=is_float32,
651
+ )
652
+
653
+ intercept = None
654
+ if self._intercept is not None:
655
+ intercept = _get_constant_from_expr(
656
+ self._intercept,
657
+ db,
658
+ unit=unit,
659
+ data_dim=level_period,
660
+ scen_dim=scenario_horizon,
661
+ is_max=self._IS_MAX_AND_ZERO_ONE,
662
+ )
663
+
664
+ if intercept is None:
665
+ return level_value * profile_vector
666
+
667
+ return level_value * profile_vector + intercept
668
+
669
+ def _has_same_behaviour(self, other: LevelProfile) -> bool:
670
+ return all(
671
+ (
672
+ self._IS_FLOW == other._IS_FLOW,
673
+ self._IS_STOCK == other._IS_STOCK,
674
+ self._IS_NOT_NEGATIVE == other._IS_NOT_NEGATIVE,
675
+ self._IS_MAX_AND_ZERO_ONE == other._IS_MAX_AND_ZERO_ONE,
676
+ self._IS_INGOING == other._IS_INGOING,
677
+ self._IS_COST == other._IS_COST,
678
+ self._IS_UNITLESS == other._IS_UNITLESS,
679
+ ),
680
+ )
681
+
682
+ def _assert_same_behaviour(self, other: LevelProfile) -> None:
683
+ if not self._has_same_behaviour(other):
684
+ message = f"Not same behaviour for {self} and {other}"
685
+ raise ValueError(message)
686
+
687
+ def __eq__(self, other) -> bool: # noqa: ANN001
688
+ """Return True if other is equal to self."""
689
+ if not isinstance(other, LevelProfile):
690
+ return False
691
+ if not self._has_same_behaviour(other):
692
+ return False
693
+ return all(
694
+ (
695
+ self._level == other._level,
696
+ self._profile == other._profile,
697
+ self._level_shift == other._level_shift,
698
+ self._intercept == other._intercept,
699
+ self._scale == other._scale,
700
+ ),
701
+ )
702
+
703
+ def __hash__(self) -> int:
704
+ """Compute hash of self."""
705
+ return hash(
706
+ (
707
+ type(self).__name__,
708
+ self._level,
709
+ self._profile,
710
+ self._level_shift,
711
+ self._intercept,
712
+ self._scale,
713
+ ),
714
+ )
715
+
716
+
717
+ # Abstract subclasses intended type hints and checks
718
+
719
+
720
+ class FlowVolume(LevelProfile):
721
+ """
722
+ Abstract class representing a flow volume attribute, indicating that the attribute is a flow variable.
723
+
724
+ Subclass of LevelProfile. See LevelProfile for details.
725
+ """
726
+
727
+ _IS_FLOW = True
728
+
729
+
730
+ class Coefficient(LevelProfile):
731
+ """
732
+ Abstract class representing a coefficient attribute, used as a base class for various coefficient types.
733
+
734
+ Subclass of LevelProfile. See LevelProfile for details.
735
+ """
736
+
737
+ pass
738
+
739
+
740
+ class ArrowCoefficient(Coefficient):
741
+ """
742
+ Abstract class representing an arrow coefficient attribute, used for efficiency, loss, and conversion coefficients.
743
+
744
+ Subclass of Coefficient < LevelProfile. See LevelProfile for details.
745
+ """
746
+
747
+ pass
748
+
749
+
750
+ class ShadowPrice(Coefficient):
751
+ """
752
+ Abstract class representing a shadow price attribute, indicating that the attribute has a unit and might be negative.
753
+
754
+ Subclass of Coefficient < LevelProfile. See LevelProfile for details.
755
+ """
756
+
757
+ _IS_UNITLESS = False
758
+ _IS_NOT_NEGATIVE = False
759
+
760
+
761
+ class ObjectiveCoefficient(Coefficient):
762
+ """
763
+ Abstract class representing an objective coefficient attribute, indicating cost or revenue coefficients in the objective function.
764
+
765
+ Subclass of Coefficient < LevelProfile. See LevelProfile for details.
766
+ """
767
+
768
+ _IS_UNITLESS = False
769
+ _IS_NOT_NEGATIVE = False
770
+
771
+
772
+ # Concrete subclasses intended for final use
773
+
774
+
775
+ class Price(ShadowPrice):
776
+ """
777
+ Concrete class representing a price attribute, indicating the price of a commodity at a specific node.
778
+
779
+ Subclass of ShadowPrice < Coefficient < LevelProfile. See LevelProfile for details.
780
+ """
781
+
782
+ _IS_ABSTRACT = False
783
+
784
+
785
+ class WaterValue(ShadowPrice):
786
+ """
787
+ Concrete class representing a water value attribute, indicating the value of water in the system.
788
+
789
+ Subclass of ShadowPrice < Coefficient < LevelProfile. See LevelProfile for details.
790
+ """
791
+
792
+ _IS_ABSTRACT = False
793
+
794
+
795
+ class Cost(ObjectiveCoefficient):
796
+ """
797
+ Concrete class representing a cost attribute, indicating cost coefficients in the objective function.
798
+
799
+ Subclass of ObjectiveCoefficient < Coefficient < LevelProfile. See LevelProfile for details.
800
+ """
801
+
802
+ _IS_ABSTRACT = False
803
+ _IS_COST = True
804
+
805
+
806
+ class ReservePrice(ObjectiveCoefficient):
807
+ """
808
+ Concrete class representing a reserve price attribute, indicating revenue coefficients in the objective function.
809
+
810
+ Subclass of ObjectiveCoefficient < Coefficient < LevelProfile. See LevelProfile for details.
811
+ """
812
+
813
+ _IS_ABSTRACT = False
814
+ _IS_COST = False
815
+
816
+
817
+ class Elasticity(Coefficient): # TODO: How do this work?
818
+ """
819
+ Concrete class representing an elasticity coefficient attribute, indicating a unitless coefficient.
820
+
821
+ Subclass of Coefficient < LevelProfile. See LevelProfile for details.
822
+ """
823
+
824
+ _IS_ABSTRACT = False
825
+ _IS_UNITLESS = True
826
+
827
+
828
+ class Proportion(Coefficient):
829
+ """
830
+ Concrete class representing a proportion coefficient attribute, indicating a unitless coefficient between 0 and 1.
831
+
832
+ Subclass of Coefficient < LevelProfile. See LevelProfile for details.
833
+ """
834
+
835
+ _IS_ABSTRACT = False
836
+ _IS_UNITLESS = True
837
+
838
+
839
+ class Hours(Coefficient): # TODO: How do this work?
840
+ """
841
+ Concrete class representing an hours coefficient attribute, indicating a time-related coefficient.
842
+
843
+ Subclass of Coefficient < LevelProfile. See LevelProfile for details.
844
+ """
845
+
846
+ _IS_ABSTRACT = False
847
+
848
+
849
+ class Efficiency(ArrowCoefficient):
850
+ """
851
+ Concrete class representing an efficiency coefficient attribute, indicating a unitless coefficient.
852
+
853
+ Subclass of ArrowCoefficient < Coefficient < LevelProfile. See LevelProfile for details.
854
+ """
855
+
856
+ _IS_ABSTRACT = False
857
+ _IS_UNITLESS = True
858
+
859
+
860
+ class Loss(ArrowCoefficient): # TODO: Make a loss for storage that is percentage per time
861
+ """
862
+ Concrete class representing a loss coefficient attribute, indicating a unitless coefficient.
863
+
864
+ Subclass of ArrowCoefficient < Coefficient < LevelProfile. See LevelProfile for details.
865
+ """
866
+
867
+ _IS_ABSTRACT = False
868
+ _IS_UNITLESS = True
869
+
870
+
871
+ class Conversion(ArrowCoefficient):
872
+ """
873
+ Concrete class representing a conversion coefficient attribute, used for conversion factors in the model.
874
+
875
+ Subclass of ArrowCoefficient < Coefficient < LevelProfile. See LevelProfile for details.
876
+ """
877
+
878
+ _IS_ABSTRACT = False
879
+
880
+
881
+ class AvgFlowVolume(FlowVolume):
882
+ """
883
+ Concrete class representing an average flow volume attribute, indicating a flow variable with average values.
884
+
885
+ Subclass of FlowVolume < LevelProfile. See LevelProfile for details.
886
+ """
887
+
888
+ _IS_ABSTRACT = False
889
+
890
+
891
+ class MaxFlowVolume(FlowVolume):
892
+ """
893
+ Concrete class representing a maximum flow volume attribute, indicating a flow variable with maximum values.
894
+
895
+ Subclass of FlowVolume < LevelProfile. See LevelProfile for details.
896
+ """
897
+
898
+ _IS_ABSTRACT = False
899
+ _IS_MAX_AND_ZERO_ONE = True
900
+
901
+
902
+ class StockVolume(LevelProfile):
903
+ """
904
+ Concrete class representing a stock volume attribute, indicating a stock variable with maximum values.
905
+
906
+ Subclass of LevelProfile. See LevelProfile for details.
907
+ """
908
+
909
+ _IS_ABSTRACT = False
910
+ _IS_STOCK = True
911
+ _IS_MAX_AND_ZERO_ONE = True