fram-core 0.0.0__py3-none-any.whl → 0.1.0a1__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.0a1.dist-info/METADATA +41 -0
  2. fram_core-0.1.0a1.dist-info/RECORD +100 -0
  3. {fram_core-0.0.0.dist-info → fram_core-0.1.0a1.dist-info}/WHEEL +1 -2
  4. fram_core-0.1.0a1.dist-info/licenses/LICENSE.md +8 -0
  5. framcore/Base.py +142 -0
  6. framcore/Model.py +73 -0
  7. framcore/__init__.py +9 -0
  8. framcore/aggregators/Aggregator.py +153 -0
  9. framcore/aggregators/HydroAggregator.py +837 -0
  10. framcore/aggregators/NodeAggregator.py +495 -0
  11. framcore/aggregators/WindSolarAggregator.py +323 -0
  12. framcore/aggregators/__init__.py +13 -0
  13. framcore/aggregators/_utils.py +184 -0
  14. framcore/attributes/Arrow.py +305 -0
  15. framcore/attributes/ElasticDemand.py +90 -0
  16. framcore/attributes/ReservoirCurve.py +37 -0
  17. framcore/attributes/SoftBound.py +19 -0
  18. framcore/attributes/StartUpCost.py +54 -0
  19. framcore/attributes/Storage.py +146 -0
  20. framcore/attributes/TargetBound.py +18 -0
  21. framcore/attributes/__init__.py +65 -0
  22. framcore/attributes/hydro/HydroBypass.py +42 -0
  23. framcore/attributes/hydro/HydroGenerator.py +83 -0
  24. framcore/attributes/hydro/HydroPump.py +156 -0
  25. framcore/attributes/hydro/HydroReservoir.py +27 -0
  26. framcore/attributes/hydro/__init__.py +13 -0
  27. framcore/attributes/level_profile_attributes.py +714 -0
  28. framcore/components/Component.py +112 -0
  29. framcore/components/Demand.py +130 -0
  30. framcore/components/Flow.py +167 -0
  31. framcore/components/HydroModule.py +330 -0
  32. framcore/components/Node.py +76 -0
  33. framcore/components/Thermal.py +204 -0
  34. framcore/components/Transmission.py +183 -0
  35. framcore/components/_PowerPlant.py +81 -0
  36. framcore/components/__init__.py +22 -0
  37. framcore/components/wind_solar.py +67 -0
  38. framcore/curves/Curve.py +44 -0
  39. framcore/curves/LoadedCurve.py +155 -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 +490 -0
  44. framcore/expressions/__init__.py +28 -0
  45. framcore/expressions/_get_constant_from_expr.py +483 -0
  46. framcore/expressions/_time_vector_operations.py +615 -0
  47. framcore/expressions/_utils.py +73 -0
  48. framcore/expressions/queries.py +423 -0
  49. framcore/expressions/units.py +207 -0
  50. framcore/fingerprints/__init__.py +11 -0
  51. framcore/fingerprints/fingerprint.py +293 -0
  52. framcore/juliamodels/JuliaModel.py +161 -0
  53. framcore/juliamodels/__init__.py +7 -0
  54. framcore/loaders/__init__.py +10 -0
  55. framcore/loaders/loaders.py +407 -0
  56. framcore/metadata/Div.py +73 -0
  57. framcore/metadata/ExprMeta.py +50 -0
  58. framcore/metadata/LevelExprMeta.py +17 -0
  59. framcore/metadata/Member.py +55 -0
  60. framcore/metadata/Meta.py +44 -0
  61. framcore/metadata/__init__.py +15 -0
  62. framcore/populators/Populator.py +108 -0
  63. framcore/populators/__init__.py +7 -0
  64. framcore/querydbs/CacheDB.py +50 -0
  65. framcore/querydbs/ModelDB.py +34 -0
  66. framcore/querydbs/QueryDB.py +45 -0
  67. framcore/querydbs/__init__.py +11 -0
  68. framcore/solvers/Solver.py +48 -0
  69. framcore/solvers/SolverConfig.py +272 -0
  70. framcore/solvers/__init__.py +9 -0
  71. framcore/timeindexes/AverageYearRange.py +20 -0
  72. framcore/timeindexes/ConstantTimeIndex.py +17 -0
  73. framcore/timeindexes/DailyIndex.py +21 -0
  74. framcore/timeindexes/FixedFrequencyTimeIndex.py +762 -0
  75. framcore/timeindexes/HourlyIndex.py +21 -0
  76. framcore/timeindexes/IsoCalendarDay.py +31 -0
  77. framcore/timeindexes/ListTimeIndex.py +197 -0
  78. framcore/timeindexes/ModelYear.py +17 -0
  79. framcore/timeindexes/ModelYears.py +18 -0
  80. framcore/timeindexes/OneYearProfileTimeIndex.py +21 -0
  81. framcore/timeindexes/ProfileTimeIndex.py +32 -0
  82. framcore/timeindexes/SinglePeriodTimeIndex.py +37 -0
  83. framcore/timeindexes/TimeIndex.py +90 -0
  84. framcore/timeindexes/WeeklyIndex.py +21 -0
  85. framcore/timeindexes/__init__.py +36 -0
  86. framcore/timevectors/ConstantTimeVector.py +135 -0
  87. framcore/timevectors/LinearTransformTimeVector.py +114 -0
  88. framcore/timevectors/ListTimeVector.py +123 -0
  89. framcore/timevectors/LoadedTimeVector.py +104 -0
  90. framcore/timevectors/ReferencePeriod.py +41 -0
  91. framcore/timevectors/TimeVector.py +94 -0
  92. framcore/timevectors/__init__.py +17 -0
  93. framcore/utils/__init__.py +36 -0
  94. framcore/utils/get_regional_volumes.py +369 -0
  95. framcore/utils/get_supported_components.py +60 -0
  96. framcore/utils/global_energy_equivalent.py +46 -0
  97. framcore/utils/isolate_subnodes.py +163 -0
  98. framcore/utils/loaders.py +97 -0
  99. framcore/utils/node_flow_utils.py +236 -0
  100. framcore/utils/storage_subsystems.py +107 -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,483 @@
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(expr: Expr) -> bool:
219
+ """x1 + x2 + .. + xn where x is leaf and n >= 1."""
220
+ if expr.is_leaf():
221
+ return True
222
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
223
+ if ops[0] not in "+-":
224
+ return False
225
+ return all(arg.is_leaf() for op, arg in zip(ops, args[1:], strict=True))
226
+
227
+
228
+ def _is_fastpath_sum_of_products(expr: Expr) -> bool:
229
+ """E.g. x1 * (x2 + x3) + x4 * x5 where x is leaf."""
230
+ if expr.is_leaf():
231
+ return True
232
+ if _is_fastpath_product(expr):
233
+ return True
234
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
235
+ if ops[0] not in "+-":
236
+ return False
237
+ return all(_is_fastpath_product(arg) for arg in args)
238
+
239
+
240
+ def _is_fastpath_aggregation(expr: Expr) -> bool:
241
+ """E.g. ((x1 * (x2 + x3) + x4 * x5) / (x6 + x7)) where x is leaf."""
242
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
243
+ if ops != "/":
244
+ return False
245
+ try:
246
+ numerator, denominator = args
247
+ except Exception:
248
+ return False
249
+ if not _is_fastpath_sum_of_products(numerator):
250
+ return False
251
+ return _is_fastpath_sum(denominator)
252
+
253
+
254
+ def _fastpath_leaf(
255
+ constants_with_units: dict[str, tuple],
256
+ expr: Expr,
257
+ target_unit: str | None,
258
+ ) -> float:
259
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, expr)
260
+ if unit == target_unit:
261
+ return value
262
+ return get_unit_conversion_factor(unit, target_unit) * value
263
+
264
+
265
+ def _fastpath_sum(
266
+ constants_with_units: dict[str, tuple],
267
+ expr: Expr,
268
+ target_unit: str | None,
269
+ ) -> float:
270
+ d = _get_fastpath_sum_dict(constants_with_units, expr)
271
+
272
+ out = 0.0
273
+ for unit, value in d.items():
274
+ if value == 0.0:
275
+ continue
276
+ if unit == target_unit:
277
+ out += value
278
+ else:
279
+ out += value * get_unit_conversion_factor(unit, target_unit)
280
+
281
+ return out
282
+
283
+
284
+ def _fastpath_product(
285
+ constants_with_units: dict[str, tuple],
286
+ expr: Expr,
287
+ target_unit: str | None,
288
+ ) -> float:
289
+ d = _get_fastpath_product_dict(constants_with_units, expr)
290
+
291
+ out = 1.0
292
+ from_unit = None
293
+ for unit, value in d.items():
294
+ if value == 0.0:
295
+ return 0.0
296
+ out *= value
297
+ if unit is None:
298
+ continue
299
+ from_unit = unit if from_unit is None else f"{from_unit} * {unit}"
300
+
301
+ if not from_unit:
302
+ return out
303
+
304
+ return out * get_unit_conversion_factor(from_unit, target_unit)
305
+
306
+
307
+ def _fastpath_aggregation( # noqa: C901, PLR0911
308
+ constants_with_units: dict[str, tuple],
309
+ expr: Expr,
310
+ target_unit: str | None,
311
+ ) -> float:
312
+ __, args = expr.get_operations(expect_ops=True, copy_list=False)
313
+ numerator, denominator = args
314
+
315
+ num = _get_fastpath_aggregation_numerator_dict(constants_with_units, numerator)
316
+ dem = _get_fastpath_aggregation_denominator_dict(constants_with_units, denominator)
317
+
318
+ if len(dem) == len(num) == 1:
319
+ num_unit, num_value = next(iter(num.items()))
320
+ dem_unit, dem_value = next(iter(dem.items()))
321
+
322
+ not_num_unit = num_unit is None
323
+ not_dem_unit = dem_unit is None
324
+ has_num_unit = num_unit is not None
325
+ has_dem_unit = dem_unit is not None
326
+
327
+ if not_num_unit and not_dem_unit:
328
+ if target_unit is None:
329
+ return num_value / dem_value
330
+ message = f"Could not convert to {target_unit} with numerator {numerator} and denominator {denominator} for expr {expr}"
331
+ raise ValueError(message)
332
+
333
+ if not_dem_unit and has_num_unit:
334
+ if target_unit == num_unit:
335
+ return num_value / dem_value
336
+ return get_unit_conversion_factor(num_unit, target_unit) * (num_value / dem_value)
337
+ if has_dem_unit and not_num_unit:
338
+ inverse_dem_unit = f"1/({dem_unit})"
339
+ if target_unit == inverse_dem_unit:
340
+ return num_value / dem_value
341
+ return get_unit_conversion_factor(inverse_dem_unit, target_unit) * (num_value / dem_value)
342
+ combined_unit = f"{num_unit}/({dem_unit})"
343
+ if target_unit == combined_unit:
344
+ return num_value / dem_value
345
+ return get_unit_conversion_factor(combined_unit, target_unit) * (num_value / dem_value)
346
+
347
+ combined_num_unit = ""
348
+ for unit, value in num.items():
349
+ op = "+" if value > 0 else "-"
350
+ combined_num_unit = f"{combined_num_unit} {op} {abs(value)}"
351
+
352
+ combined_dem_unit = ""
353
+ for unit, value in num.items():
354
+ op = "+" if value > 0 else "-"
355
+ combined_dem_unit = f"{combined_dem_unit} {op} {abs(value)}"
356
+
357
+ combined_unit = f"({combined_num_unit})/({combined_dem_unit})"
358
+
359
+ return get_unit_conversion_factor(combined_unit, target_unit)
360
+
361
+
362
+ def _get_fastpath_sum_dict(
363
+ constants_with_units: dict[str, tuple],
364
+ expr: Expr,
365
+ ) -> dict[str | None, float]:
366
+ d = dict()
367
+ if expr.is_leaf():
368
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, expr)
369
+ return {unit: value}
370
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
371
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, args[0])
372
+ d[unit] = value
373
+ for op, arg in zip(ops, args[1:], strict=True):
374
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, arg)
375
+ contribution = value if op == "+" else -value
376
+ if unit not in d:
377
+ d[unit] = contribution
378
+ else:
379
+ d[unit] += contribution
380
+ return d
381
+
382
+
383
+ def _get_fastpath_product_dict(
384
+ constants_with_units: dict[str, tuple],
385
+ expr: Expr,
386
+ ) -> dict[str | None, float]:
387
+ d = dict()
388
+ if expr.is_leaf():
389
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, expr)
390
+ return {unit: value}
391
+ __, args = expr.get_operations(expect_ops=True, copy_list=False)
392
+ for arg in args:
393
+ if _is_fastpath_sum(arg):
394
+ values = _get_fastpath_sum_dict(constants_with_units, arg)
395
+ else:
396
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, arg)
397
+ values = {unit: value}
398
+ for unit, value in values.items():
399
+ if unit not in d:
400
+ d[unit] = value
401
+ else:
402
+ d[unit] *= value
403
+ return d
404
+
405
+
406
+ def _get_fastpath_product_unit(d: dict[str | None, float]) -> tuple[float, str | None]:
407
+ combined_value = 1.0
408
+ combined_unit = []
409
+ for unit, value in d.items():
410
+ combined_value *= value
411
+ if unit is not None:
412
+ combined_unit.append(unit)
413
+ if not combined_unit:
414
+ return combined_value, None
415
+ return combined_value, "*".join(sorted(f"({s})" for s in combined_unit))
416
+
417
+
418
+ def _get_fastpath_aggregation_numerator_dict(
419
+ constants_with_units: dict[str, tuple],
420
+ expr: Expr,
421
+ ) -> dict[str | None, float]:
422
+ if expr.is_leaf():
423
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, expr)
424
+ return {unit: value}
425
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
426
+ out = dict()
427
+ value, unit = _get_fastpath_product_unit(_get_fastpath_product_dict(constants_with_units, args[0]))
428
+ out[unit] = value
429
+ for op, arg in zip(ops, args[1:], strict=True):
430
+ value, unit = _get_fastpath_product_unit(_get_fastpath_product_dict(constants_with_units, arg))
431
+ contribution = value if op == "+" else -value
432
+ if unit not in out:
433
+ out[unit] = contribution
434
+ else:
435
+ out[unit] += contribution
436
+ return out
437
+
438
+
439
+ def _get_fastpath_aggregation_denominator_dict(
440
+ constants_with_units: dict[str, tuple],
441
+ expr: Expr,
442
+ ) -> dict[str | None, float]:
443
+ if expr.is_leaf():
444
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, expr)
445
+ return {unit: value}
446
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
447
+ d = dict()
448
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, args[0])
449
+ d[unit] = value
450
+ for op, arg in zip(ops, args[1:], strict=True):
451
+ __, value, unit = _lookup_expr_from_constants_with_units(constants_with_units, arg)
452
+ if value == 0.0:
453
+ continue
454
+ contribution = value if op == "+" else -value
455
+ if unit not in d:
456
+ d[unit] = contribution
457
+ else:
458
+ d[unit] += contribution
459
+ return d
460
+
461
+
462
+ def _sympy_fallback(constants_with_units: dict[str, tuple], expr_str: str, target_unit: str | None) -> float:
463
+ """Convert expr to sympy expr, substitue in constants with units, and let sympy evaluate."""
464
+ expr_sym = sympy.sympify(expr_str)
465
+ for src, (sym, value, unit) in constants_with_units.items():
466
+ sympy_sym = sympy.Symbol(sym)
467
+
468
+ if unit is not None:
469
+ unit_sym = _unit_str_to_sym(unit)
470
+ expr_sym = expr_sym.subs(sympy_sym, value * unit_sym)
471
+ else:
472
+ expr_sym = expr_sym.subs(sympy_sym, value)
473
+
474
+ if target_unit:
475
+ unit_sym = _unit_str_to_sym(target_unit)
476
+ expr_sym = expr_sym / unit_sym
477
+
478
+ value = _get_scalar_from_expr(expr_sym)
479
+ if isinstance(value, str):
480
+ message = f"Cannot convert expression '{expr_str}' with target_unit {target_unit} to scalar value. {value}."
481
+ raise ValueError(message)
482
+
483
+ return value