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,477 @@
1
+ """
2
+ Implementation of _get_constant_from_expr.
3
+
4
+ The first implementation used the _sympy_fallback function in all cases.
5
+ This turned out to be very slow for large expressions. Therefore,
6
+ we collected data on common expressions that turn up in aggregation,
7
+ and added fast paths for these cases.
8
+
9
+ Since this results in more code than the original,
10
+ we put this function in its own file.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from time import time
16
+ from typing import TYPE_CHECKING
17
+
18
+ import sympy
19
+
20
+ from framcore.curves import Curve
21
+ from framcore.events import send_warning_event
22
+ from framcore.expressions import Expr
23
+ from framcore.expressions._utils import _ensure_real_expr, _load_model_and_create_model_db, _lookup_expr_from_constants_with_units
24
+ from framcore.expressions.units import _get_scalar_from_expr, _unit_str_to_sym, get_unit_conversion_factor
25
+ from framcore.querydbs import QueryDB
26
+ from framcore.timeindexes import FixedFrequencyTimeIndex, SinglePeriodTimeIndex
27
+ from framcore.timevectors import ConstantTimeVector, TimeVector
28
+
29
+ if TYPE_CHECKING:
30
+ from framcore import Model
31
+
32
+ _DEBUG = False
33
+ _DEBUG_ROUND_DECIMALS = 5
34
+ _WARN_IF_FALLBACK = True
35
+ _WARN_MAX_ELAPSED_SECONDS = 0.1
36
+
37
+ _NUM_LEAF = 0
38
+ _NUM_FALLBACK = 0
39
+ _NUM_FASTPATH_PRODUCT = 0
40
+ _NUM_FASTPATH_AGGREGATION = 0
41
+ _NUM_FASTPATH_SUM = 0
42
+
43
+
44
+ def _get_case_counts() -> dict[str, int]:
45
+ """
46
+ Return dict of counts for different cases of _get_constant_from_expr.
47
+
48
+ Useful for fastpath development.
49
+ """
50
+ return {
51
+ "fastpath_leaf": _NUM_LEAF,
52
+ "fallback": _NUM_FALLBACK,
53
+ "fastpath_sum": _NUM_FASTPATH_SUM,
54
+ "fastpath_product": _NUM_FASTPATH_PRODUCT,
55
+ "fastpath_aggregation": _NUM_FASTPATH_AGGREGATION,
56
+ }
57
+
58
+
59
+ def _get_constant_from_expr(
60
+ expr: Expr,
61
+ db: QueryDB | Model,
62
+ unit: str | None,
63
+ data_dim: SinglePeriodTimeIndex,
64
+ scen_dim: FixedFrequencyTimeIndex,
65
+ is_max: bool,
66
+ ) -> float:
67
+ if not isinstance(expr, Expr):
68
+ message = f"Expected Expr, got {expr}"
69
+ raise ValueError(message)
70
+
71
+ db = _load_model_and_create_model_db(db)
72
+
73
+ real_expr = _ensure_real_expr(expr, db)
74
+
75
+ constants_with_units = dict()
76
+
77
+ expr_str = _update_constants_with_units(
78
+ constants_with_units,
79
+ real_expr,
80
+ db,
81
+ data_dim,
82
+ scen_dim,
83
+ is_max,
84
+ )
85
+
86
+ # counts for debug and optimization
87
+ global _NUM_LEAF # noqa: PLW0603
88
+ global _NUM_FALLBACK # noqa: PLW0603
89
+ global _NUM_FASTPATH_PRODUCT # noqa: PLW0603
90
+ global _NUM_FASTPATH_AGGREGATION # noqa: PLW0603
91
+ global _NUM_FASTPATH_SUM # noqa: PLW0603
92
+
93
+ fastpath = None
94
+
95
+ if real_expr.is_leaf():
96
+ _NUM_LEAF += 1
97
+ fastpath = _fastpath_leaf(constants_with_units, real_expr, unit)
98
+
99
+ elif _is_fastpath_sum(expr):
100
+ _NUM_FASTPATH_SUM += 1
101
+ fastpath = _fastpath_sum(constants_with_units, real_expr, unit)
102
+
103
+ elif _is_fastpath_product(real_expr):
104
+ _NUM_FASTPATH_PRODUCT += 1
105
+ fastpath = _fastpath_product(constants_with_units, real_expr, unit)
106
+
107
+ elif _is_fastpath_aggregation(real_expr):
108
+ _NUM_FASTPATH_AGGREGATION += 1
109
+ fastpath = _fastpath_aggregation(constants_with_units, real_expr, unit)
110
+
111
+ if fastpath is not None and _DEBUG is not True:
112
+ return fastpath
113
+
114
+ _NUM_FALLBACK += 1
115
+ t = time()
116
+ fallback = _sympy_fallback(constants_with_units, expr_str, unit)
117
+ elapsed_seconds_fallback = time() - t
118
+
119
+ if _DEBUG and fastpath is not None and round(fastpath, _DEBUG_ROUND_DECIMALS) != round(fallback, _DEBUG_ROUND_DECIMALS):
120
+ message = f"Different results!\nExpr {real_expr}\nwith symbolic representation {expr_str}\nfastpath {fastpath} and fallback {fallback}"
121
+ raise RuntimeError(message)
122
+
123
+ if _DEBUG is False and _WARN_IF_FALLBACK is True and elapsed_seconds_fallback > _WARN_MAX_ELAPSED_SECONDS:
124
+ message = f"fallback used {elapsed_seconds_fallback} seconds for (symbolic) expr: {expr_str}"
125
+ send_warning_event(sender=_get_constant_from_expr, message=message)
126
+
127
+ return fallback
128
+
129
+
130
+ def _update_constants_with_units(
131
+ constants_with_units: dict[str, tuple],
132
+ real_expr: Expr,
133
+ db: QueryDB,
134
+ data_dim: SinglePeriodTimeIndex,
135
+ scen_dim: FixedFrequencyTimeIndex,
136
+ is_max: bool,
137
+ ) -> str:
138
+ """Extract symbol, constant value and unit info from all leaf expressions of real_expr."""
139
+ # To avoid circular import TODO: improve?
140
+ from framcore.expressions.queries import _get_level_value_from_timevector
141
+
142
+ if real_expr.is_leaf():
143
+ is_level = real_expr.is_level()
144
+ is_profile = real_expr.is_profile()
145
+
146
+ src = real_expr.get_src()
147
+
148
+ if isinstance(src, str) and db.has_key(src):
149
+ obj = db.get(src)
150
+ assert not isinstance(obj, Expr), f"{obj}"
151
+ assert isinstance(obj, TimeVector | Curve), f"{obj}"
152
+
153
+ elif isinstance(src, ConstantTimeVector):
154
+ obj: ConstantTimeVector = src
155
+ src = obj.get_expr_str()
156
+ else:
157
+ message = f"Unexpected value for src: {src}\nin expr {real_expr}"
158
+ raise ValueError(message)
159
+
160
+ if src in constants_with_units:
161
+ sym, value, unit = constants_with_units[src]
162
+ return sym
163
+
164
+ if isinstance(obj, TimeVector):
165
+ obj: TimeVector
166
+
167
+ # added to support any_expr * ConstantTimeVector
168
+ times_constant_case = (not is_profile) and isinstance(obj, ConstantTimeVector)
169
+
170
+ if is_level or times_constant_case:
171
+ unit = obj.get_unit()
172
+ profile_expr = real_expr.get_profile()
173
+ value = _get_level_value_from_timevector(obj, db, unit, data_dim, scen_dim, is_max, profile_expr)
174
+ sym = f"x{len(constants_with_units)}"
175
+ constants_with_units[src] = (sym, float(value), unit)
176
+ return sym
177
+
178
+ if not is_profile:
179
+ message = f"Unsupported case where expr is not level and not profile:\nexpr: {real_expr}\nobj: {obj}"
180
+ raise ValueError(message)
181
+
182
+ assert is_profile
183
+
184
+ raise NotImplementedError("Profile TimeVector not implemented yet")
185
+ raise NotImplementedError("Curve not implemented yet")
186
+
187
+ ops, args = real_expr.get_operations(expect_ops=True, copy_list=False)
188
+
189
+ x = _update_constants_with_units(constants_with_units, args[0], db, data_dim, scen_dim, is_max)
190
+ out = f"{x}"
191
+ for op, arg in zip(ops, args[1:], strict=True):
192
+ x = _update_constants_with_units(constants_with_units, arg, db, data_dim, scen_dim, is_max)
193
+ out = f"{out} {op} {x}"
194
+
195
+ return f"({out})"
196
+
197
+
198
+ def _is_fastpath_sum(expr: Expr) -> bool:
199
+ """E.g. x0 + x1 + x2 + .. where x is leaf."""
200
+ if expr.is_leaf():
201
+ return True
202
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
203
+ if ops[0] not in "+-":
204
+ return False
205
+ return all(arg.is_leaf() for arg in args)
206
+
207
+
208
+ def _is_fastpath_product(expr: Expr) -> bool:
209
+ """E.g. x1 * (x2 + x3), or x1 * x2 * x3 where x is leaf."""
210
+ if expr.is_leaf():
211
+ return True
212
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
213
+ if not all(op == "*" for op in ops):
214
+ return False
215
+ return all(arg.is_leaf() or _is_fastpath_sum(arg) for arg in args)
216
+
217
+
218
+ def _is_fastpath_sum_of_products(expr: Expr) -> bool:
219
+ """E.g. x1 * (x2 + x3) + x4 * x5 where x is leaf."""
220
+ if expr.is_leaf():
221
+ return True
222
+ if _is_fastpath_product(expr):
223
+ return True
224
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
225
+ if ops[0] not in "+-":
226
+ return False
227
+ return all(_is_fastpath_product(arg) for arg in args)
228
+
229
+
230
+ def _is_fastpath_aggregation(expr: Expr) -> bool:
231
+ """E.g. ((x1 * (x2 + x3) + x4 * x5) / (x6 + x7)) where x is leaf."""
232
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
233
+ if ops != "/":
234
+ return False
235
+ try:
236
+ numerator, denominator = args
237
+ except Exception:
238
+ return False
239
+ if not _is_fastpath_sum_of_products(numerator):
240
+ return False
241
+ return _is_fastpath_sum(denominator)
242
+
243
+
244
+ def _fastpath_leaf(
245
+ constants_with_units: dict[str, tuple],
246
+ expr: Expr,
247
+ target_unit: str | None,
248
+ ) -> float:
249
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, expr)
250
+ if unit == target_unit:
251
+ return value
252
+ return get_unit_conversion_factor(unit, target_unit) * value
253
+
254
+
255
+ def _fastpath_sum(
256
+ constants_with_units: dict[str, tuple],
257
+ expr: Expr,
258
+ target_unit: str | None,
259
+ ) -> float:
260
+ d = _get_fastpath_sum_dict(constants_with_units, expr)
261
+
262
+ out = 0.0
263
+ for unit, value in d.items():
264
+ if value == 0.0:
265
+ continue
266
+ if unit == target_unit:
267
+ out += value
268
+ else:
269
+ out += value * get_unit_conversion_factor(unit, target_unit)
270
+
271
+ return out
272
+
273
+
274
+ def _fastpath_product(
275
+ constants_with_units: dict[str, tuple],
276
+ expr: Expr,
277
+ target_unit: str | None,
278
+ ) -> float:
279
+ d = _get_fastpath_product_dict(constants_with_units, expr)
280
+
281
+ out = 1.0
282
+ from_unit = None
283
+ for unit, value in d.items():
284
+ if value == 0.0:
285
+ return 0.0
286
+ out *= value
287
+ if unit is None:
288
+ continue
289
+ from_unit = unit if from_unit is None else f"{from_unit} * {unit}"
290
+
291
+ if not from_unit:
292
+ return out
293
+
294
+ return out * get_unit_conversion_factor(from_unit, target_unit)
295
+
296
+
297
+ def _fastpath_aggregation( # noqa: C901, PLR0911
298
+ constants_with_units: dict[str, tuple],
299
+ expr: Expr,
300
+ target_unit: str | None,
301
+ ) -> float:
302
+ __, args = expr.get_operations(expect_ops=True, copy_list=False)
303
+ numerator, denominator = args
304
+
305
+ num = _get_fastpath_aggregation_numerator_dict(constants_with_units, numerator)
306
+ dem = _get_fastpath_aggregation_denominator_dict(constants_with_units, denominator)
307
+
308
+ if len(dem) == len(num) == 1:
309
+ num_unit, num_value = next(iter(num.items()))
310
+ dem_unit, dem_value = next(iter(dem.items()))
311
+
312
+ not_num_unit = num_unit is None
313
+ not_dem_unit = dem_unit is None
314
+ has_num_unit = num_unit is not None
315
+ has_dem_unit = dem_unit is not None
316
+
317
+ if not_num_unit and not_dem_unit:
318
+ if target_unit is None:
319
+ return num_value / dem_value
320
+ message = f"Could not convert to {target_unit} with numerator {numerator} and denominator {denominator} for expr {expr}"
321
+ raise ValueError(message)
322
+
323
+ if not_dem_unit and has_num_unit:
324
+ if target_unit == num_unit:
325
+ return num_value / dem_value
326
+ return get_unit_conversion_factor(num_unit, target_unit) * (num_value / dem_value)
327
+ if has_dem_unit and not_num_unit:
328
+ inverse_dem_unit = f"1/({dem_unit})"
329
+ if target_unit == inverse_dem_unit:
330
+ return num_value / dem_value
331
+ return get_unit_conversion_factor(inverse_dem_unit, target_unit) * (num_value / dem_value)
332
+ combined_unit = f"{num_unit}/({dem_unit})"
333
+ if target_unit == combined_unit:
334
+ return num_value / dem_value
335
+ return get_unit_conversion_factor(combined_unit, target_unit) * (num_value / dem_value)
336
+
337
+ new_constants_with_units = dict()
338
+
339
+ combined_num = ""
340
+ for unit, value in num.items():
341
+ sym = f"x{len(new_constants_with_units)}"
342
+ new_constants_with_units[sym] = (sym, value, unit)
343
+ combined_num = f"{combined_num} + {sym}"
344
+
345
+ combined_dem = ""
346
+ for unit, value in dem.items():
347
+ sym = f"x{len(new_constants_with_units)}"
348
+ new_constants_with_units[sym] = (sym, value, unit)
349
+ combined_dem = f"{combined_dem} + {sym}"
350
+
351
+ combined = f"({combined_num})/({combined_dem})"
352
+
353
+ return _sympy_fallback(new_constants_with_units, combined, target_unit)
354
+
355
+
356
+ def _get_fastpath_sum_dict(
357
+ constants_with_units: dict[str, tuple],
358
+ expr: Expr,
359
+ ) -> dict[str | None, float]:
360
+ d = dict()
361
+ if expr.is_leaf():
362
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, expr)
363
+ return {unit: value}
364
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
365
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, args[0])
366
+ d[unit] = value
367
+ for op, arg in zip(ops, args[1:], strict=True):
368
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, arg)
369
+ contribution = value if op == "+" else -value
370
+ if unit not in d:
371
+ d[unit] = contribution
372
+ else:
373
+ d[unit] += contribution
374
+ return d
375
+
376
+
377
+ def _get_fastpath_product_dict(
378
+ constants_with_units: dict[str, tuple],
379
+ expr: Expr,
380
+ ) -> dict[str | None, float]:
381
+ d = dict()
382
+ if expr.is_leaf():
383
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, expr)
384
+ return {unit: value}
385
+ __, args = expr.get_operations(expect_ops=True, copy_list=False)
386
+ for arg in args:
387
+ if _is_fastpath_sum(arg):
388
+ values = _get_fastpath_sum_dict(constants_with_units, arg)
389
+ else:
390
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, arg)
391
+ values = {unit: value}
392
+ for unit, value in values.items():
393
+ if unit not in d:
394
+ d[unit] = value
395
+ else:
396
+ d[unit] *= value
397
+ return d
398
+
399
+
400
+ def _get_fastpath_product_unit(d: dict[str | None, float]) -> tuple[float, str | None]:
401
+ combined_value = 1.0
402
+ combined_unit = []
403
+ for unit, value in d.items():
404
+ combined_value *= value
405
+ if unit is not None:
406
+ combined_unit.append(unit)
407
+ if not combined_unit:
408
+ return combined_value, None
409
+ return combined_value, "*".join(sorted(f"({s})" for s in combined_unit))
410
+
411
+
412
+ def _get_fastpath_aggregation_numerator_dict(
413
+ constants_with_units: dict[str, tuple],
414
+ expr: Expr,
415
+ ) -> dict[str | None, float]:
416
+ if expr.is_leaf():
417
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, expr)
418
+ return {unit: value}
419
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
420
+ out = dict()
421
+ value, unit = _get_fastpath_product_unit(_get_fastpath_product_dict(constants_with_units, args[0]))
422
+ out[unit] = value
423
+ for op, arg in zip(ops, args[1:], strict=True):
424
+ value, unit = _get_fastpath_product_unit(_get_fastpath_product_dict(constants_with_units, arg))
425
+ contribution = value if op == "+" else -value
426
+ if unit not in out:
427
+ out[unit] = contribution
428
+ else:
429
+ out[unit] += contribution
430
+ return out
431
+
432
+
433
+ def _get_fastpath_aggregation_denominator_dict(
434
+ constants_with_units: dict[str, tuple],
435
+ expr: Expr,
436
+ ) -> dict[str | None, float]:
437
+ if expr.is_leaf():
438
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, expr)
439
+ return {unit: value}
440
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
441
+ d = dict()
442
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, args[0])
443
+ d[unit] = value
444
+ for op, arg in zip(ops, args[1:], strict=True):
445
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, arg)
446
+ if value == 0.0:
447
+ continue
448
+ contribution = value if op == "+" else -value
449
+ if unit not in d:
450
+ d[unit] = contribution
451
+ else:
452
+ d[unit] += contribution
453
+ return d
454
+
455
+
456
+ def _sympy_fallback(constants_with_units: dict[str, tuple], expr_str: str, target_unit: str | None) -> float:
457
+ """Convert expr to sympy expr, substitue in constants with units, and let sympy evaluate."""
458
+ expr_sym = sympy.sympify(expr_str)
459
+ for src, (sym, value, unit) in constants_with_units.items():
460
+ sympy_sym = sympy.Symbol(sym)
461
+
462
+ if unit is not None:
463
+ unit_sym = _unit_str_to_sym(unit)
464
+ expr_sym = expr_sym.subs(sympy_sym, value * unit_sym)
465
+ else:
466
+ expr_sym = expr_sym.subs(sympy_sym, value)
467
+
468
+ if target_unit:
469
+ unit_sym = _unit_str_to_sym(target_unit)
470
+ expr_sym = expr_sym / unit_sym
471
+
472
+ value = _get_scalar_from_expr(expr_sym)
473
+ if isinstance(value, str):
474
+ message = f"Cannot convert expression '{expr_str}' with target_unit {target_unit} to scalar value. {value}."
475
+ raise ValueError(message)
476
+
477
+ return value
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import TYPE_CHECKING
5
+
6
+ from framcore.curves import Curve
7
+ from framcore.expressions import Expr
8
+ from framcore.querydbs import QueryDB
9
+ from framcore.timevectors import ConstantTimeVector, TimeVector
10
+
11
+ if TYPE_CHECKING:
12
+ from framcore import Model
13
+
14
+
15
+ def _load_model_and_create_model_db(db: QueryDB | Model) -> QueryDB:
16
+ from framcore import Model
17
+
18
+ if isinstance(db, Model):
19
+ from framcore.querydbs import ModelDB
20
+
21
+ db = ModelDB(db)
22
+
23
+ if not isinstance(db, QueryDB):
24
+ message = f"Expected db to be Model or QueryDB, got {db} of type {type(db).__name__}"
25
+ raise ValueError(message)
26
+ return db
27
+
28
+
29
+ def _lookup_expr_from_constants_with_units(
30
+ constants_with_units: dict[str, tuple],
31
+ expr: Expr,
32
+ ) -> tuple[str, float, str | None]:
33
+ src = expr.get_src()
34
+ if isinstance(src, ConstantTimeVector):
35
+ src = src.get_expr_str()
36
+ sym, value, unit = constants_with_units[src]
37
+ return sym, value, unit
38
+
39
+
40
+ def _is_real_expr(expr: Expr, db: QueryDB) -> bool:
41
+ if expr.is_leaf():
42
+ src = expr.get_src()
43
+ if isinstance(src, TimeVector | Curve):
44
+ return True
45
+ obj = db.get(src)
46
+ return not isinstance(obj, Expr)
47
+ __, args = expr.get_operations(expect_ops=True, copy_list=False)
48
+ return all(_is_real_expr(ex, db) for ex in args)
49
+
50
+
51
+ def _ensure_real_expr(expr: Expr, db: QueryDB) -> Expr:
52
+ if _is_real_expr(expr, db):
53
+ return expr
54
+ expr = copy.deepcopy(expr)
55
+ _extend_expr(expr, db)
56
+ return expr
57
+
58
+
59
+ def _extend_expr(expr: Expr, db: QueryDB) -> None:
60
+ if expr.is_leaf():
61
+ src = expr.get_src()
62
+ if isinstance(src, TimeVector | Curve):
63
+ return
64
+ obj = db.get(src)
65
+ if isinstance(obj, Expr):
66
+ for name, value in obj.__dict__.items():
67
+ setattr(expr, name, value)
68
+ _extend_expr(expr, db)
69
+ assert isinstance(obj, TimeVector | Curve), f"Got {obj}"
70
+ return
71
+ __, args = expr.get_operations(expect_ops=True, copy_list=False)
72
+ for ex in args:
73
+ _extend_expr(ex, db)