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,591 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import copy
4
+ from typing import TYPE_CHECKING
5
+
6
+ from framcore import Base
7
+ from framcore.curves import Curve, LoadedCurve
8
+ from framcore.fingerprints import Fingerprint, FingerprintRef
9
+ from framcore.timevectors import ConstantTimeVector, TimeVector
10
+
11
+ if TYPE_CHECKING:
12
+ from framcore.loaders import Loader
13
+
14
+
15
+ # TODO: Add Expr.add_many to support faster aggregation expressions.
16
+ class Expr(Base):
17
+ """
18
+ Mathematical expression with TimeVectors and Curves to represent Levels and Profiles in LevelProfiles.
19
+
20
+ The simplest Expr is a single TimeVector, while a more complicated expression could be a weighted average of several TimeVectors or Expressions.
21
+ Expr can also have string references to Expr, TimeVector or Curve in a database (often Model).
22
+
23
+ Expr are classified as Stock, Flow or None of them. See https://en.wikipedia.org/wiki/Stock_and_flow. In FRAM we only support Flow data as a rate of change.
24
+ So, for example, a production timeseries has to be in MW, and not in MWh. Converting between the two versions of Flow would add another
25
+ level of complexity both in Expr and in TimeVector operations.
26
+
27
+ Expr are also classified as Level, Profile or none of them. This classification, together with Stock or Flow,
28
+ is used to check if the built Expr are legal operations.
29
+ - Expr that are Level can contain its connected Profile Expr. This is used in the queries to evaluate Levels according to their ReferencePeriod, and
30
+ convert between Level formats (max level or average level, see LevelProfile for more details).
31
+
32
+ Calculations using Expr are evaluated lazily, reducing unnecessary numerical operations during data manipulation.
33
+ Computations involving values and units occur only when the Expr is queried.
34
+
35
+ We only support calculations using +, -, *, and / in Expr, and we have no plans to change this.
36
+ Expanding beyond these would turn Expr into a complex programming language rather than keeping it as a simple
37
+ and efficient system for common time-series calculations. More advanced operations are still possible through eager evaluation, so this is not a limitation.
38
+ It simply distributes responsibilities across system components in a way that is practical from a maintenance perspective.
39
+
40
+ We use SymPy to support unit conversions. Already computed unit conversion factors are cached to minimize redundant calculations.
41
+
42
+ At the moment we support these queries for Expr (see Aggregators for more about how they are used):
43
+ - get_level_value(expr, db, unit, data_dim, scen_dim, is_max)
44
+ - Supports all expressions. Will evaluate level Exprs at data_dim (with reference period of scen_dim),
45
+ and profile Exprs as an average over scen_dim (both as constants).
46
+ - Has optimized fastpath methods for sums, products and aggregations. The rest uses a fallback method with SymPy.
47
+ - get_profile_vector(expr, db, data_dim, scen_dim, is_zero_one, is_float32)
48
+ - Supports expr = sum(weight[i] * profile[i]) where weight[i] is a unitless constant Expr with value >= 0, and profile[i] is a unitless profile Expr.
49
+
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ src: str | Curve | TimeVector | None = None,
55
+ is_stock: bool = False,
56
+ is_flow: bool = False,
57
+ is_profile: bool = False,
58
+ is_level: bool = False,
59
+ profile: Expr | None = None,
60
+ operations: tuple[str, list[Expr]] | None = None,
61
+ ) -> None:
62
+ """
63
+ Create new (immutable) Expression.
64
+
65
+ Args:
66
+ src (str | Curve | TimeVector | None, optional): Source of the values to be used in the Expression. Either a Curve or TimeVector object,
67
+ or a reference to one of them. Defaults to None.
68
+ is_stock (bool, optional): Flag to signify if the Expr represents a stock type variable. Defaults to False.
69
+ is_flow (bool, optional): Flag to signify if the Expr represents a flow type variable. Defaults to False.
70
+ is_profile (bool, optional): Flag to signify if the Expr represents a profile. Defaults to False.
71
+ is_level (bool, optional): Flag to signify if the Expr represents a level. Defaults to False.
72
+ profile (Expr | None, optional): Expr that are Level can contain its connected Profile Expr. This is used in the queries to evaluate
73
+ Levels according to their ReferencePeriod, and convert between Level formats (max level or average level, see LevelProfile for more details).
74
+ operations (tuple[str, list[Expr]] | None, optional): Operations to apply to the expression. Defaults to None.
75
+
76
+ """
77
+ if is_level and is_profile:
78
+ message = "Expr cannot be both level and a profile. Set either is_level or is_profile True or both False."
79
+ raise ValueError(message)
80
+
81
+ if is_flow and is_stock:
82
+ message = "Expr cannot be both flow and stock. Set either is_flow or is_stock True or both False."
83
+ raise ValueError(message)
84
+
85
+ if is_profile and (is_flow or is_stock):
86
+ message = "Expr cannot be both a profile and a flow/stock. Profiles must be coefficients."
87
+ raise ValueError(message)
88
+
89
+ self._src: str | Curve | TimeVector | None = src
90
+ self._is_stock = is_stock
91
+ self._is_flow = is_flow
92
+ self._is_profile = is_profile
93
+ self._is_level = is_level
94
+ self._profile = profile
95
+
96
+ # have to come after setting fields
97
+ # because fields are used to create
98
+ # error messages e.g. in __repr__
99
+
100
+ self._check_type(src, (str, Curve, TimeVector, type(None)))
101
+ self._check_type(is_stock, (bool, type(None)))
102
+ self._check_type(is_flow, (bool, type(None)))
103
+ self._check_type(is_level, (bool, type(None)))
104
+ self._check_type(is_profile, (bool, type(None)))
105
+ self._check_type(profile, (Expr, type(None)))
106
+
107
+ self._check_operations(operations)
108
+ if operations is None:
109
+ operations = "", []
110
+ self._operations: tuple[str, list[Expr]] = operations
111
+
112
+ def _check_operations(self, operations: tuple[str, list[Expr]] | None, expect_ops: bool = False) -> None:
113
+ if operations is None:
114
+ return
115
+ self._check_type(operations, tuple)
116
+ if len(operations) != 2: # noqa: PLR2004
117
+ message = f"Expected len(operations) == 2. Got: {operations}"
118
+ raise ValueError(message)
119
+ ops, args = operations
120
+ self._check_type(ops, str)
121
+ self._check_type(args, list)
122
+ if ops == "":
123
+ if expect_ops:
124
+ message = f"Expected ops, but got {operations}"
125
+ raise ValueError(message)
126
+ if len(args) > 0:
127
+ message = f"Expected ops to have length. Got {operations}"
128
+ raise ValueError(message)
129
+ return
130
+ if len(ops) != len(args) - 1:
131
+ message = f"Expected len(ops) == len(args) - 1. Got {operations}"
132
+ raise ValueError(message)
133
+ for op in ops:
134
+ if op not in "+-/*":
135
+ message = f"Expected all op in ops in +-*/. Got {operations}"
136
+ raise ValueError(message)
137
+ for ex in args:
138
+ self._check_type(ex, Expr)
139
+
140
+ def get_fingerprint(self) -> Fingerprint:
141
+ """Return fingerprint."""
142
+ fingerprint = Fingerprint(self)
143
+ fingerprint.add("is_stock", self._is_stock)
144
+ fingerprint.add("is_flow", self._is_flow)
145
+ fingerprint.add("is_profile", self._is_profile)
146
+ fingerprint.add("is_level", self._is_level)
147
+ fingerprint.add("profile", self._profile)
148
+ if self._src:
149
+ fingerprint.add("src", self._src.get_fingerprint() if isinstance(self._src, TimeVector) else FingerprintRef(self._src))
150
+ fingerprint.add("operations", self._operations)
151
+ return fingerprint
152
+
153
+ def is_leaf(self) -> bool:
154
+ """Return True if self is not an operation expression."""
155
+ return self._src is not None
156
+
157
+ def get_src(self) -> str | Curve | TimeVector | None:
158
+ """Return str, Curve or TimeVector (either reference to Curve/TimeVector or Curve/TimeVector itself) or None if self is an operation expression."""
159
+ return self._src
160
+
161
+ def get_operations(self, expect_ops: bool, copy_list: bool) -> tuple[str, list[Expr]]:
162
+ """Return ops, args. Users of this (low level) API must supply expect_ops and copy_list args."""
163
+ self._check_type(copy_list, bool)
164
+ self._verify_operations(expect_ops)
165
+ if copy_list:
166
+ ops, args = self._operations
167
+ return ops, copy(args)
168
+ return self._operations
169
+
170
+ def _verify_operations(self, expect_ops: bool = False) -> None:
171
+ self._check_operations(self._operations, expect_ops)
172
+ ops = self._operations[0]
173
+
174
+ if not ops:
175
+ return
176
+
177
+ has_add = "+" in ops
178
+ has_sub = "-" in ops
179
+ has_mul = "*" in ops
180
+ has_div = "/" in ops
181
+
182
+ if (has_add or has_sub) and (has_mul or has_div):
183
+ message = f"Found +- in same operation level as */ in operations {self._operations} "
184
+ raise ValueError(message)
185
+
186
+ if has_div:
187
+ seen_div = False
188
+ for op in ops:
189
+ if op == "/":
190
+ seen_div = True
191
+ continue
192
+ if seen_div and op != "/":
193
+ message = f"Found +-* after / in operations {self._operations}"
194
+ raise ValueError(message)
195
+
196
+ def is_flow(self) -> bool:
197
+ """Return True if flow. Cannot be stock and flow."""
198
+ return self._is_flow
199
+
200
+ def is_stock(self) -> bool:
201
+ """Return True if stock. Cannot be stock and flow."""
202
+ return self._is_stock
203
+
204
+ def is_level(self) -> bool:
205
+ """Return True if level. Cannot be level and profile."""
206
+ return self._is_level
207
+
208
+ def is_profile(self) -> bool:
209
+ """Return True if profile. Cannot be level and profile."""
210
+ return self._is_profile
211
+
212
+ def get_profile(self) -> Expr | None:
213
+ """Return Expr representing profile. Implies self.is_level() is True."""
214
+ return self._profile
215
+
216
+ def set_profile(self, profile: Expr | None) -> None:
217
+ """Set the profile of the Expr. Implies self.is_level() is True."""
218
+ if not self.is_level():
219
+ raise ValueError("Cannot set profile on Expr that is not a level.")
220
+ self._profile = profile
221
+
222
+ def _analyze_op(self, op: str, other: Expr) -> tuple[bool, bool, bool, bool, Expr | None]:
223
+ flow = (True, False)
224
+ stock = (False, True)
225
+ level = (True, False)
226
+ profile = (False, True)
227
+ none = (False, False)
228
+
229
+ supported_cases = {
230
+ # all op supported for none
231
+ ("+", none, none, none, none): (none, none, None),
232
+ ("-", none, none, none, none): (none, none, None),
233
+ ("*", none, none, none, none): (none, none, None),
234
+ ("/", none, none, none, none): (none, none, None),
235
+ # + flow level
236
+ ("+", flow, level, flow, level): (flow, level, None),
237
+ # * flow level
238
+ ("*", flow, level, none, none): (flow, level, self.get_profile()),
239
+ ("*", none, none, flow, level): (flow, level, other.get_profile()),
240
+ # / flow level
241
+ ("/", flow, level, none, none): (flow, level, self.get_profile()),
242
+ ("/", flow, level, flow, level): (none, none, None),
243
+ # + stock level
244
+ ("+", stock, level, stock, level): (stock, level, None),
245
+ # * stock level
246
+ ("*", stock, level, none, level): (stock, level, None),
247
+ ("*", none, level, stock, level): (stock, level, None),
248
+ ("*", stock, level, none, none): (stock, level, self.get_profile()),
249
+ ("*", none, none, stock, level): (stock, level, other.get_profile()),
250
+ # / stock level
251
+ ("/", stock, level, none, level): (stock, level, None),
252
+ ("/", stock, level, none, none): (stock, level, self.get_profile()),
253
+ ("/", stock, level, stock, level): (none, none, None),
254
+ # level * level ok if one is flow (i.e. price * volume) or none (co2_eff / eff)
255
+ ("*", flow, level, none, level): (flow, level, None),
256
+ ("*", none, level, flow, level): (flow, level, None),
257
+ ("/", flow, level, none, level): (flow, level, None),
258
+ ("/", none, level, none, level): (none, level, None),
259
+ ("*", none, level, none, level): (none, level, None),
260
+ # profile
261
+ ("+", none, profile, none, profile): (none, profile, None),
262
+ ("-", none, profile, none, profile): (none, profile, None),
263
+ ("/", none, profile, none, none): (none, profile, None),
264
+ ("*", none, profile, none, none): (none, profile, None),
265
+ ("*", none, none, none, profile): (none, profile, None),
266
+ ("/", none, none, none, profile): (none, profile, None),
267
+ # level
268
+ ("+", none, level, none, level): (none, level, None),
269
+ ("-", none, level, none, level): (none, level, None),
270
+ ("/", none, level, none, none): (none, level, self.get_profile()),
271
+ ("*", none, level, none, none): (none, level, self.get_profile()),
272
+ ("*", none, none, none, level): (none, level, other.get_profile()),
273
+ ("/", none, none, none, level): (none, level, other.get_profile()),
274
+ }
275
+
276
+ case = (
277
+ op,
278
+ (self.is_flow(), self.is_stock()),
279
+ (self.is_level(), self.is_profile()),
280
+ (other.is_flow(), other.is_stock()),
281
+ (other.is_level(), other.is_profile()),
282
+ )
283
+
284
+ if case not in supported_cases:
285
+ printable_case = {
286
+ "op": case[0],
287
+ "self_is_flow": case[1][0],
288
+ "self_is_stock": case[1][1],
289
+ "self_is_level": case[2][0],
290
+ "self_is_profile": case[2][1],
291
+ "other_is_flow": case[3][0],
292
+ "other_is_stock": case[3][1],
293
+ "other_is_level": case[4][0],
294
+ "other_is_profile": case[4][1],
295
+ }
296
+ message = f"Unsupported case:\n{printable_case}\nexpression:\n{self} {op} {other}."
297
+ raise ValueError(message)
298
+
299
+ ((is_flow, is_stock), (is_level, is_profile), profile) = supported_cases[case]
300
+
301
+ return is_stock, is_flow, is_level, is_profile, profile
302
+
303
+ @staticmethod
304
+ def _is_number(src: str) -> bool:
305
+ try:
306
+ float(src)
307
+ return True
308
+ except ValueError:
309
+ return False
310
+
311
+ def _create_op_expr( # noqa: C901
312
+ self,
313
+ op: str,
314
+ other: Expr | int | float,
315
+ is_rhs: bool,
316
+ ) -> Expr:
317
+ if isinstance(other, Expr):
318
+ is_stock, is_flow, is_level, is_profile, profile = self._analyze_op(op, other)
319
+
320
+ x, y = (other, self) if is_rhs else (self, other)
321
+
322
+ xisconst = isinstance(x.get_src(), ConstantTimeVector)
323
+ yisconst = isinstance(y.get_src(), ConstantTimeVector)
324
+ if xisconst and yisconst:
325
+ xtv = x.get_src()
326
+ ytv = y.get_src()
327
+ is_combinable_tv = (
328
+ xtv.get_unit() == ytv.get_unit()
329
+ and xtv.is_max_level() == ytv.is_max_level()
330
+ and xtv.is_zero_one_profile() == ytv.is_zero_one_profile()
331
+ and xtv.get_reference_period() == ytv.get_reference_period()
332
+ )
333
+ if is_combinable_tv:
334
+ is_combinable_expr = (
335
+ x.is_level() == y.is_level()
336
+ and x.is_profile() == y.is_profile()
337
+ and x.is_flow() == y.is_flow()
338
+ and x.is_stock() == y.is_stock()
339
+ and x.get_profile() == y.get_profile()
340
+ )
341
+ if is_combinable_expr:
342
+ xscalar = xtv.get_vector(is_float32=True)[0]
343
+ yscalar = ytv.get_vector(is_float32=True)[0]
344
+ if op == "+":
345
+ scalar = xscalar + yscalar
346
+ elif op == "-":
347
+ scalar = xscalar - yscalar
348
+ elif op == "*":
349
+ scalar = xscalar * yscalar
350
+ elif op == "/":
351
+ scalar = xscalar / yscalar
352
+ return Expr(
353
+ src=ConstantTimeVector(
354
+ scalar=scalar,
355
+ unit=xtv.get_unit(),
356
+ is_max_level=xtv.is_max_level(),
357
+ is_zero_one_profile=xtv.is_zero_one_profile(),
358
+ reference_period=xtv.get_reference_period(),
359
+ ),
360
+ is_stock=x.is_stock(),
361
+ is_flow=x.is_flow(),
362
+ is_profile=x.is_profile(),
363
+ is_level=x.is_level(),
364
+ profile=x.get_profile(),
365
+ operations=None,
366
+ )
367
+
368
+ ops, args = x.get_operations(expect_ops=False, copy_list=True)
369
+
370
+ if not ops:
371
+ ops = op
372
+ args = [x, y]
373
+ else:
374
+ last_op = ops[-1]
375
+ if last_op == op or (op in "+-" and last_op in "+-") or (last_op == "*" and op == "/"):
376
+ ops = f"{ops}{op}"
377
+ args.append(y)
378
+ else:
379
+ ops = op
380
+ args = [x, y]
381
+
382
+ return Expr(
383
+ src=None,
384
+ is_flow=is_flow,
385
+ is_stock=is_stock,
386
+ is_level=is_level,
387
+ is_profile=is_profile,
388
+ profile=profile,
389
+ operations=(ops, args),
390
+ )
391
+
392
+ if self._is_number(other):
393
+ if op in "*/":
394
+ other_expr = Expr(src=ConstantTimeVector(float(other), is_max_level=False))
395
+ return self._create_op_expr(op=op, other=other_expr, is_rhs=is_rhs)
396
+
397
+ if op in "+" and other == 0: # Comes from sum(expr_list). See sum() noqa
398
+ return self # TODO: Also accept 0 - Expr and -Expr?
399
+
400
+ message = f"Only support multiplication and division with numbers, got {op} and {other}."
401
+ raise ValueError(message)
402
+
403
+ message = f"Only support Expr, int, float. Got unsupported type {type(other).__name__}."
404
+ raise TypeError(message)
405
+
406
+ def __add__(self, other: object) -> Expr: # noqa: D105
407
+ return self._create_op_expr("+", other, is_rhs=False)
408
+
409
+ def __sub__(self, other: object) -> Expr: # noqa: D105
410
+ return self._create_op_expr("-", other, is_rhs=False)
411
+
412
+ def __mul__(self, other: object) -> Expr: # noqa: D105
413
+ return self._create_op_expr("*", other, is_rhs=False)
414
+
415
+ def __truediv__(self, other: object) -> Expr: # noqa: D105
416
+ return self._create_op_expr("/", other, is_rhs=False)
417
+
418
+ def __radd__(self, other: object) -> Expr: # noqa: D105
419
+ return self._create_op_expr("+", other, is_rhs=True)
420
+
421
+ def __rsub__(self, other: object) -> Expr: # noqa: D105
422
+ return self._create_op_expr("-", other, is_rhs=True)
423
+
424
+ def __rmul__(self, other: object) -> Expr: # noqa: D105
425
+ return self._create_op_expr("*", other, is_rhs=True)
426
+
427
+ def __rtruediv__(self, other: object) -> Expr: # noqa: D105
428
+ return self._create_op_expr("/", other, is_rhs=True)
429
+
430
+ def __repr__(self) -> str:
431
+ """Represent Expr as str."""
432
+ if self._src is not None:
433
+ return f"Expr({self._src})"
434
+ ops, args = self.get_operations(expect_ops=True, copy_list=False)
435
+ out = f"{args[0]}"
436
+ for op, arg in zip(ops, args[1:], strict=True):
437
+ out = f"{out} {op} {arg}"
438
+ return f"Expr({out})"
439
+
440
+ def __eq__(self, other) -> bool: # noqa: ANN001
441
+ """Check if self and other are equal."""
442
+ if not isinstance(other, type(self)):
443
+ return False
444
+ return (
445
+ self._is_flow == other._is_flow
446
+ and self._is_level == other._is_level
447
+ and self._src == other._src
448
+ and self._is_stock == other._is_stock
449
+ and self._is_profile == other._is_profile
450
+ and self._profile == other._profile
451
+ and self._operations[0] == other._operations[0]
452
+ and len(self._operations[1]) == len(other._operations[1])
453
+ and all([self._operations[1][i] == other._operations[1][i] for i in range(len(self._operations[1]))]) # noqa: SLF001
454
+ )
455
+
456
+ def __hash__(self) -> int:
457
+ """Compute hash value.."""
458
+ return hash(
459
+ (
460
+ self._is_flow,
461
+ self._is_stock,
462
+ self._is_level,
463
+ self._is_profile,
464
+ self._src,
465
+ self._profile,
466
+ self._operations[0],
467
+ tuple(self._operations[1]),
468
+ ),
469
+ )
470
+
471
+ def add_loaders(self, loaders: set[Loader]) -> None:
472
+ """Add all loaders stored in TimeVector or Curve within Expr to loaders."""
473
+ if self.is_leaf():
474
+ src = self.get_src()
475
+ if isinstance(src, TimeVector | LoadedCurve):
476
+ loader = src.get_loader()
477
+ if loader is not None:
478
+ loaders.add(loader)
479
+ return
480
+ __, args = self.get_operations(expect_ops=True, copy_list=False)
481
+ for arg in args:
482
+ arg.add_loaders(loaders)
483
+
484
+
485
+ # Proposed new way of creating Expr in classes.
486
+ def ensure_expr(
487
+ value: Expr | str | Curve | TimeVector | None, # technically anything that can be converted to float. Typehint for this?
488
+ is_flow: bool = False,
489
+ is_stock: bool = False,
490
+ is_level: bool = False,
491
+ is_profile: bool = False,
492
+ profile: Expr | None = None,
493
+ ) -> Expr | None:
494
+ """
495
+ Ensure that the value is an expression of the expected type or create one if possible.
496
+
497
+ Args:
498
+ value (Expr | str | None): The value to check.
499
+ is_flow (str): If the Expr is a flow. Cannot be True if is_stock is True.
500
+ is_stock (str): If the Expr is a stock. Cannot be True if is_flow is True.
501
+ is_level (bool): Wether the Expr represents a level. Cannot be True if is_profile is True.
502
+ is_profile (bool): Wether the Expr represents a profile. Cannot be True if is_level is True.
503
+ profile (Expr | None): If the Expr is a level, this should be its profile.
504
+
505
+ Returns:
506
+ value (Expr | str): The value as an expression of the expected type or None.
507
+
508
+ """
509
+ if not isinstance(value, (str, Expr, Curve, TimeVector)) and value is not None:
510
+ msg = f"Expected value to be of type Expr, str, Curve, TimeVector or None. Got {type(value).__name__}."
511
+ raise TypeError(msg)
512
+
513
+ if value is None:
514
+ return None
515
+
516
+ if isinstance(value, Expr):
517
+ # Check wether given Expr matches expected flow, stock, profile and level status.
518
+ if value.is_flow() != is_flow or value.is_stock() != is_stock or value.is_level() != is_level or value.is_profile() != is_profile:
519
+ message = (
520
+ "Given Expr has a mismatch between expected and actual flow/stock or level/profile status:\nExpected: "
521
+ f"is_flow - {is_flow}, is_stock - {is_stock}, is_level - {is_level}, is_profile - {is_profile}\n"
522
+ f"Actual: is_flow - {value.is_flow()}, is_stock - {value.is_stock()}, "
523
+ f"is_level - {value.is_level()}, is_profile - {value.is_profile()}"
524
+ )
525
+ raise ValueError(message)
526
+ return value
527
+
528
+ return Expr(
529
+ src=value,
530
+ is_flow=is_flow,
531
+ is_stock=is_stock,
532
+ is_level=is_level,
533
+ is_profile=is_profile,
534
+ profile=profile,
535
+ )
536
+
537
+
538
+ def get_profile_exprs_from_leaf_levels(expr: Expr) -> list[Expr]:
539
+ """
540
+ Get all profile expressions from leaf-level Expr objects that are marked as levels.
541
+
542
+ Args:
543
+ expr (Expr): The starting Expr object.
544
+
545
+ Returns:
546
+ list[Expr]: A list of profile expressions from leaf-level Expr objects.
547
+
548
+ """
549
+ profile_exprs = []
550
+
551
+ def _traverse(expr: Expr) -> None:
552
+ if expr.is_leaf():
553
+ if expr.is_level() and expr.get_profile() is not None:
554
+ profile_exprs.append(expr.get_profile())
555
+ return
556
+
557
+ # Recursively traverse the arguments of the expression
558
+ _, args = expr.get_operations(expect_ops=False, copy_list=False)
559
+ for arg in args:
560
+ _traverse(arg)
561
+
562
+ _traverse(expr)
563
+ return profile_exprs
564
+
565
+
566
+ def get_leaf_profiles(expr: Expr) -> list[Expr]:
567
+ """
568
+ Get all leaf profile expressions from an Expr object.
569
+
570
+ Args:
571
+ expr (Expr): The starting Expr object.
572
+
573
+ Returns:
574
+ list[Expr]: A list of leaf profile expressions.
575
+
576
+ """
577
+ leaf_profiles = []
578
+
579
+ def _traverse(expr: Expr) -> None:
580
+ if expr.is_leaf():
581
+ if expr.is_profile():
582
+ leaf_profiles.append(expr)
583
+ return
584
+
585
+ # Recursively traverse the arguments of the expression
586
+ _, args = expr.get_operations(expect_ops=False, copy_list=False)
587
+ for arg in args:
588
+ _traverse(arg)
589
+
590
+ _traverse(expr)
591
+ return leaf_profiles
@@ -0,0 +1,30 @@
1
+ # framcore/expressions/__init__.py
2
+
3
+ from framcore.expressions.Expr import Expr, ensure_expr, get_leaf_profiles, get_profile_exprs_from_leaf_levels
4
+
5
+ from framcore.expressions.units import (
6
+ get_unit_conversion_factor,
7
+ is_convertable,
8
+ validate_unit_conversion_fastpaths,
9
+ )
10
+
11
+ from framcore.expressions.queries import (
12
+ get_level_value,
13
+ get_profile_vector,
14
+ get_units_from_expr,
15
+ get_timeindexes_from_expr,
16
+ )
17
+
18
+ __all__ = [
19
+ "Expr",
20
+ "ensure_expr",
21
+ "get_leaf_profiles",
22
+ "get_level_value",
23
+ "get_profile_exprs_from_leaf_levels",
24
+ "get_profile_vector",
25
+ "get_timeindexes_from_expr",
26
+ "get_unit_conversion_factor",
27
+ "get_units_from_expr",
28
+ "is_convertable",
29
+ "validate_unit_conversion_fastpaths",
30
+ ]