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,416 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import TYPE_CHECKING
5
+
6
+ import numpy as np
7
+ from numpy.typing import NDArray
8
+
9
+ from framcore import check_type
10
+ from framcore.curves import Curve
11
+ from framcore.expressions import Expr
12
+ from framcore.expressions._get_constant_from_expr import _get_constant_from_expr
13
+ from framcore.expressions._utils import _load_model_and_create_model_db
14
+ from framcore.expressions.units import get_unit_conversion_factor
15
+ from framcore.querydbs import QueryDB
16
+ from framcore.timeindexes import FixedFrequencyTimeIndex, SinglePeriodTimeIndex, TimeIndex
17
+ from framcore.timevectors import TimeVector
18
+
19
+ if TYPE_CHECKING:
20
+ from framcore import Model
21
+
22
+
23
+ def get_level_value(
24
+ expr: Expr,
25
+ db: QueryDB | Model,
26
+ unit: str | None,
27
+ data_dim: SinglePeriodTimeIndex,
28
+ scen_dim: FixedFrequencyTimeIndex,
29
+ is_max: bool,
30
+ ) -> float:
31
+ """
32
+ Evaluate Expr representing a (possibly aggregated) level.
33
+
34
+ The follwing will be automatically handled for you:
35
+ - fetching from different data objecs (from db)
36
+ - conversion to requested unit
37
+ - query at requested TimeIndex for data and scenario dimension, and with requested reference period
38
+ - conversion to requested level type (is_max or is_avg)
39
+
40
+ Supports all expressions. Will evaluate level Exprs at data_dim (with reference period of scen_dim),
41
+ and profile Exprs as an average over scen_dim (both as constants). Has optimized fastpath methods for sums, products and aggregations.
42
+ The rest uses a fallback method with SymPy.
43
+
44
+ """
45
+ check_type(expr, Expr) # check expr here since _get_level_value is not recursively called.
46
+ check_type(unit, (str, type(None)))
47
+ check_type(data_dim, SinglePeriodTimeIndex)
48
+ check_type(scen_dim, FixedFrequencyTimeIndex)
49
+ check_type(is_max, bool)
50
+ db = _load_model_and_create_model_db(db)
51
+
52
+ return _get_level_value(expr, db, unit, data_dim, scen_dim, is_max)
53
+
54
+
55
+ def get_profile_vector(
56
+ expr: Expr,
57
+ db: QueryDB | Model,
58
+ data_dim: SinglePeriodTimeIndex,
59
+ scen_dim: FixedFrequencyTimeIndex,
60
+ is_zero_one: bool,
61
+ is_float32: bool = True,
62
+ ) -> NDArray:
63
+ """
64
+ Evaluate expr representing a (possibly aggregated) profile.
65
+
66
+ expr = sum(weight[i] * profile[i]) where
67
+
68
+ weight[i] >= 0 and is unitless, and will be evaluated as a constant
69
+ profile[i] is a unitless profile expr
70
+
71
+ profile[i] is either "zero_one" or "mean_one" type of profile
72
+
73
+ "zero_one" and "mean_one" profile type must be converted to the
74
+ same standard to be added correctly.
75
+
76
+ The query parameters data_dim and scen_dim are used to evaluate the values
77
+ requested TimeIndex for data and scenario dimension, and with requested reference period
78
+
79
+ weight[i] will be evaluated level Exprs at data_dim (with reference period of scen_dim),
80
+ and profile Exprs as an average over scen_dim (both as constants)
81
+
82
+ profile[i] will be evaluated as profile vectors over scen_dim
83
+
84
+ The query parameter is_zero_one tells which profile type the output
85
+ vector should be converted to.
86
+ """
87
+ # Argument expr checked in _get_profile_vector since it can be recursively called.
88
+ check_type(data_dim, SinglePeriodTimeIndex)
89
+ check_type(scen_dim, FixedFrequencyTimeIndex)
90
+ check_type(is_zero_one, bool)
91
+ check_type(is_float32, bool)
92
+ db = _load_model_and_create_model_db(db)
93
+
94
+ return _get_profile_vector(expr, db, data_dim, scen_dim, is_zero_one, is_float32)
95
+
96
+
97
+ def get_units_from_expr(db: QueryDB | Model, expr: Expr) -> set[str]:
98
+ """Find all units behind an expression. Useful for queries involving conversion factors."""
99
+ db = _load_model_and_create_model_db(db)
100
+
101
+ units: set[str] = set()
102
+
103
+ _recursively_update_units(units, db, expr)
104
+
105
+ return units
106
+
107
+
108
+ def get_timeindexes_from_expr(db: QueryDB | Model, expr: Expr) -> set[TimeIndex]:
109
+ """
110
+ Find all timeindexes behind an expression.
111
+
112
+ Useful for optimized queries (not asking for more data than necessary).
113
+ """
114
+ db = _load_model_and_create_model_db(db)
115
+
116
+ timeindexes: set[TimeIndex] = set()
117
+
118
+ _recursively_update_timeindexes(timeindexes, db, expr)
119
+
120
+ return timeindexes
121
+
122
+
123
+ def _get_level_value(
124
+ expr: Expr,
125
+ db: QueryDB,
126
+ unit: str | None,
127
+ data_dim: SinglePeriodTimeIndex,
128
+ scen_dim: FixedFrequencyTimeIndex,
129
+ is_max: bool,
130
+ ) -> float:
131
+ cache_key = ("_get_constant_from_expr", expr, unit, data_dim, scen_dim, is_max)
132
+ if db.has_key(cache_key):
133
+ return db.get(cache_key)
134
+ t0 = time.perf_counter()
135
+ output_value = _get_constant_from_expr(expr, db, unit, data_dim, scen_dim, is_max)
136
+ t1 = time.perf_counter()
137
+ db.put(cache_key, output_value, elapsed_seconds=t1 - t0)
138
+
139
+ return output_value
140
+
141
+
142
+ def _get_profile_vector(
143
+ expr: Expr,
144
+ db: QueryDB,
145
+ data_dim: SinglePeriodTimeIndex,
146
+ scen_dim: FixedFrequencyTimeIndex,
147
+ is_zero_one: bool,
148
+ is_float32: bool = True,
149
+ ) -> NDArray:
150
+ check_type(expr, Expr)
151
+
152
+ if expr.is_leaf():
153
+ return _get_profile_vector_from_leaf_expr(expr, db, data_dim, scen_dim, is_zero_one, is_float32)
154
+
155
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
156
+ tmp = np.zeros(scen_dim.get_num_periods(), dtype=np.float32 if is_float32 else np.float64)
157
+
158
+ if "+" in ops:
159
+ out = _get_profile_vector(args[0], db, data_dim, scen_dim, is_zero_one, is_float32)
160
+ for op, arg in zip(ops, args[1:], strict=True):
161
+ assert op == "+", f"{ops} {args}"
162
+ tmp = _get_profile_vector(arg, db, data_dim, scen_dim, is_zero_one, is_float32)
163
+ np.add(out, tmp, out=out)
164
+ return out
165
+
166
+ if not all(op == "*" for op in ops):
167
+ message = f"Expected w1*w2*..*wn*profile. Got operations {ops} for expr {expr}"
168
+ raise ValueError(message)
169
+
170
+ profiles = [arg for arg in args if arg.is_profile()]
171
+ weights = [arg for arg in args if not arg.is_profile()]
172
+
173
+ if len(profiles) != 1:
174
+ message = f"Got {len(profiles)} profiles in expr {expr}"
175
+ raise ValueError(message)
176
+
177
+ total_weight = 0.0
178
+ is_max = False # use avg-values to calculate weights
179
+
180
+ for weight_expr in weights:
181
+ total_weight += _get_constant_from_expr(weight_expr, db, None, data_dim, scen_dim, is_max)
182
+
183
+ out = _get_profile_vector(profiles[0], db, data_dim, scen_dim, is_zero_one, is_float32)
184
+ np.multiply(out, total_weight, out=out)
185
+ return out
186
+
187
+
188
+ def _get_profile_vector_from_leaf_expr(
189
+ expr: Expr,
190
+ db: QueryDB,
191
+ data_dim: SinglePeriodTimeIndex,
192
+ scen_dim: FixedFrequencyTimeIndex,
193
+ is_zero_one: bool,
194
+ is_float32: bool,
195
+ ) -> NDArray:
196
+ src = expr.get_src()
197
+
198
+ if isinstance(src, str):
199
+ obj = db.get(src)
200
+ else:
201
+ obj = src
202
+
203
+ if isinstance(obj, Expr):
204
+ if not obj.is_profile():
205
+ msg = f"Expected {obj} to be is_profile=True." # User may be getting this from setting wrong metadata in time vector files?
206
+ raise ValueError(msg)
207
+ return _get_profile_vector(obj, db, data_dim, scen_dim, is_zero_one, is_float32)
208
+
209
+ assert isinstance(obj, (TimeVector, Curve))
210
+
211
+ cache_key = ("_get_profile_vector_from_timevector", obj, data_dim, scen_dim, is_zero_one, is_float32)
212
+ if db.has_key(cache_key):
213
+ vector: NDArray = db.get(cache_key)
214
+ return vector.copy()
215
+ t0 = time.perf_counter()
216
+ vector = _get_profile_vector_from_timevector(obj, scen_dim, is_zero_one, is_float32)
217
+ t1 = time.perf_counter()
218
+ db.put(cache_key, vector, elapsed_seconds=t1 - t0)
219
+ return vector
220
+
221
+
222
+ def _get_profile_vector_from_timevector(
223
+ timevector: TimeVector,
224
+ scen_dim: FixedFrequencyTimeIndex,
225
+ target_is_zero_one: bool,
226
+ is_float32: bool,
227
+ ) -> NDArray:
228
+ out = np.zeros(scen_dim.get_num_periods(), dtype=np.float32 if is_float32 else np.float64)
229
+
230
+ tv_is_zero_one = timevector.is_zero_one_profile() # OPPGAVE endrer TimeVector-API
231
+ assert isinstance(tv_is_zero_one, bool)
232
+ assert isinstance(timevector.get_unit(), type(None))
233
+ values = timevector.get_vector(is_float32)
234
+ timeindex = timevector.get_timeindex()
235
+ timeindex.write_into_fixed_frequency(out, scen_dim, values)
236
+
237
+ # CASE HANDLED:
238
+ # Both profiles are mean one within their respective reference periods.
239
+ # We convert timevector ref period to target reference period by
240
+ # making sure the mean value of 'out' is 1 within the target reference period.
241
+ if not target_is_zero_one and not tv_is_zero_one:
242
+ target_ref_period = scen_dim.get_reference_period()
243
+ tv_ref_period = timevector.get_reference_period()
244
+ if target_ref_period != tv_ref_period:
245
+ tv_target_ref_period_mean = timeindex.get_period_average(
246
+ vector=values,
247
+ start_time=scen_dim.get_start_time(),
248
+ duration=scen_dim.get_period_duration() * scen_dim.get_num_periods(),
249
+ is_52_week_years=scen_dim.is_52_week_years(),
250
+ )
251
+
252
+ if tv_target_ref_period_mean == 0.0:
253
+ message = f"TimeVector {timevector} has invalid mean value of 0.0 for target reference period {target_ref_period}."
254
+ raise ValueError(message)
255
+
256
+ np.multiply(out, 1 / tv_target_ref_period_mean, out=out)
257
+
258
+ # TODO: Bør heller "ikke stole på"
259
+ if target_is_zero_one != tv_is_zero_one:
260
+ if target_is_zero_one:
261
+ np.multiply(out, 1 / out.max(), out=out) # convert to zero one profile standard
262
+ elif not np.all(out == 0):
263
+ np.multiply(out, 1 / out.mean(), out=out) # convert to mean one profile standard")
264
+
265
+ # TODO: handle different ref periods if is_avg
266
+ return out
267
+
268
+
269
+ def _recursively_update_units(units: set[str], db: QueryDB, expr: Expr) -> None:
270
+ if expr.is_leaf():
271
+ src = expr.get_src()
272
+ obj = src if isinstance(src, TimeVector) else db.get(key=src)
273
+
274
+ if isinstance(obj, Expr):
275
+ _recursively_update_units(units, db, obj)
276
+ elif isinstance(obj, Curve):
277
+ message = "Not yet implemented for Curve objects."
278
+ raise NotImplementedError(message)
279
+ elif isinstance(obj, TimeVector):
280
+ unit = obj.get_unit()
281
+ if unit is not None:
282
+ units.add(unit)
283
+ else:
284
+ message = f"Got unexpected object {obj}."
285
+ raise RuntimeError(message)
286
+ __, args = expr.get_operations(expect_ops=False, copy_list=False)
287
+ for arg in args:
288
+ _recursively_update_units(units, db, arg)
289
+
290
+
291
+ def _recursively_update_timeindexes(timeindexes: set[TimeIndex], db: QueryDB, expr: Expr) -> None:
292
+ if expr.is_leaf():
293
+ src = expr.get_src()
294
+ obj = src if isinstance(src, TimeVector) else db.get(key=src)
295
+ if isinstance(obj, Expr):
296
+ _recursively_update_timeindexes(timeindexes, db, obj)
297
+ elif isinstance(obj, Curve):
298
+ pass
299
+ elif isinstance(obj, TimeVector):
300
+ timeindex = obj.get_timeindex()
301
+ if timeindex is not None:
302
+ timeindexes.add(timeindex)
303
+ else:
304
+ message = f"Got unexpected object {obj}."
305
+ raise RuntimeError(message)
306
+ __, args = expr.get_operations(expect_ops=False, copy_list=False)
307
+ for arg in args:
308
+ _recursively_update_timeindexes(timeindexes, db, arg)
309
+
310
+
311
+ def _get_level_value_from_timevector( # noqa: C901
312
+ timevector: TimeVector,
313
+ db: QueryDB,
314
+ target_unit: str | None,
315
+ data_dim: SinglePeriodTimeIndex,
316
+ scen_dim: FixedFrequencyTimeIndex,
317
+ target_is_max: bool,
318
+ profile_expr: Expr | None,
319
+ ) -> float:
320
+ tv_is_max = timevector.is_max_level() # OPPGAVE endrer TimeVector-API
321
+
322
+ is_float32 = True
323
+
324
+ values = timevector.get_vector(is_float32) # OPPGAVE endrer TimeVector-API
325
+
326
+ # if DEFENSIVE_MODE: # global i data-mng-modul
327
+ # assert isinstance(values, np.ndarray)
328
+ # assert len(values.shape) == 1
329
+
330
+ timeindex = timevector.get_timeindex()
331
+ from_unit = timevector.get_unit()
332
+
333
+ starttime = data_dim.get_start_time() # OPPGAVE endrer ConstantTimeIndex-API?
334
+ timedelta = data_dim.get_period_duration() # OPPGAVE endrer ConstantTimeIndex-API?
335
+ scalar = timeindex.get_period_average(values, starttime, timedelta, data_dim.is_52_week_years())
336
+
337
+ if from_unit is not None and target_unit is not None:
338
+ scalar *= get_unit_conversion_factor(from_unit, target_unit)
339
+ elif from_unit is None and target_unit is None:
340
+ pass
341
+ else:
342
+ message = "Mismatch between 'target_unit' 'from_unit'. One is None while the other is not." # more descriptive?
343
+ raise ValueError(message)
344
+
345
+ if not target_is_max:
346
+ if not tv_is_max: # from avg to avg
347
+ avg_level = scalar
348
+ tv_ref_period = timevector.get_reference_period() # Vi lar ReferencePeriod være tidsindex som en lang periode
349
+ if tv_ref_period is None:
350
+ assert profile_expr is None, f"Timevector {timevector} has no reference period, profile_expr must therefore be None."
351
+ return avg_level # avg level fra timevector uten ref periode
352
+ target_ref_period = scen_dim.get_reference_period()
353
+ if target_ref_period is None:
354
+ message = f"No reference period for scen_dim {scen_dim}"
355
+ raise ValueError(message)
356
+ if tv_ref_period != target_ref_period:
357
+ # if DEFENSIVE_MODE:
358
+ # tv_ref_period_mean = get_profile_vector(profile_expr, db, data_dim, tv_ref_period, is_float32, is_zero_one=False)
359
+ # assert tv_ref_period_mean.size == 1
360
+ # assert tv_ref_period_mean[0] == 1
361
+ assert profile_expr, f"Profile Expr is None for TimeVector {timevector} when it should exist"
362
+ tv_target_ref_period_mean = get_profile_vector(
363
+ profile_expr,
364
+ db,
365
+ data_dim,
366
+ scen_dim.copy_as_reference_period(target_ref_period),
367
+ is_zero_one=False,
368
+ is_float32=is_float32,
369
+ )
370
+ assert tv_target_ref_period_mean.size == 1
371
+ avg_level = tv_target_ref_period_mean[0] * avg_level
372
+ return avg_level
373
+ # timevector fra max til avg
374
+ max_level = scalar
375
+ assert timevector.get_reference_period() is None
376
+
377
+ zero_one_profile_vector_mean = 1
378
+ if profile_expr is not None: # only try to get profile if the level is actually associated with one.
379
+ zero_one_profile_vector_mean = get_profile_vector(
380
+ profile_expr,
381
+ db,
382
+ data_dim,
383
+ scen_dim,
384
+ is_zero_one=True,
385
+ is_float32=is_float32,
386
+ ).mean()
387
+ return zero_one_profile_vector_mean * max_level
388
+
389
+ assert target_is_max # vi skal ha max level
390
+
391
+ if not tv_is_max:
392
+ avg_level = scalar
393
+ tv_ref_period = timevector.get_reference_period() # Vi lar ReferencePeriod være tidsindex som en lang periode
394
+ if tv_ref_period is None:
395
+ assert profile_expr is None, f"Timevector {timevector} has no reference period, profile_expr must therefore be None."
396
+ return avg_level
397
+ target_ref_period = scen_dim.get_reference_period()
398
+ if target_ref_period is None:
399
+ message = f"No reference period for scen_dim {scen_dim}"
400
+ raise ValueError(message)
401
+ if tv_ref_period != target_ref_period:
402
+ tv_target_ref_period_mean = get_profile_vector(
403
+ profile_expr,
404
+ db,
405
+ data_dim,
406
+ scen_dim.copy_as_reference_period(target_ref_period),
407
+ is_zero_one=False,
408
+ is_float32=is_float32,
409
+ )
410
+ assert tv_target_ref_period_mean.size == 1
411
+ avg_level = tv_target_ref_period_mean[0] * avg_level
412
+ # avg_level med korrekt ref periode
413
+ mean_one_profile_vector = get_profile_vector(profile_expr, db, data_dim, scen_dim, is_zero_one=False, is_float32=is_float32)
414
+ return mean_one_profile_vector.max() * avg_level
415
+
416
+ return scalar # all good
@@ -0,0 +1,227 @@
1
+ """
2
+ Define units used in the system, their handling and conversion rules.
3
+
4
+ We use SymPy to support unit conversions. Already computed unit conversion factors are cached to minimize redundant calculations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import re
11
+
12
+ import sympy
13
+ from sympy import Expr as SymPyExpr
14
+ from sympy.core.power import Pow
15
+ from sympy.core.symbol import Symbol
16
+ from sympy.physics.units import Quantity, giga, gram, hour, kilo, mega, meter, second, tera, tonne, watt, year
17
+ from sympy.physics.units.prefixes import Prefix
18
+
19
+ EUR = Quantity("EUR", abbrev="€")
20
+
21
+ _SUPPORTED_UNITS = {
22
+ "second": second,
23
+ "s": second,
24
+ "hour": hour,
25
+ "h": hour,
26
+ "year": year,
27
+ "y": year,
28
+ "watt": watt,
29
+ "g": gram,
30
+ "gram": gram,
31
+ "kg": kilo * gram,
32
+ "t": tonne,
33
+ "tonne": tonne,
34
+ "meter": meter,
35
+ "m": meter,
36
+ "m3": meter**3,
37
+ "Mm3": mega * meter**3,
38
+ "m3/s": meter**3 / second,
39
+ "kilo": kilo,
40
+ "mega": mega,
41
+ "giga": giga,
42
+ "tera": tera,
43
+ "kWh": kilo * watt * hour,
44
+ "MWh": mega * watt * hour,
45
+ "GWh": giga * watt * hour,
46
+ "TWh": tera * watt * hour,
47
+ "kW": kilo * watt,
48
+ "MW": mega * watt,
49
+ "GW": giga * watt,
50
+ "TW": tera * watt,
51
+ "EUR": EUR,
52
+ "€": EUR,
53
+ }
54
+
55
+ _FASTPATH_CONVERSION_FACTORS = {
56
+ ("MW", "GW"): 0.001,
57
+ ("MWh", "TWh"): 1e-6,
58
+ ("m3", "Mm3"): 1e-6,
59
+ ("kWh/m3", "GWh/Mm3"): 1.0,
60
+ ("EUR/MWh", "EUR/GWh"): 1000.0,
61
+ ("GWh/year", "MW"): 0.11407955544967756,
62
+ ("Mm3/year", "m3/s"): 0.03168876540268821,
63
+ ("t/MWh", "t/GWh"): 1000.0,
64
+ }
65
+
66
+ _FASTPATH_INCOMPATIBLE_CONVERSIONS = {
67
+ ("MW", "m3/s"),
68
+ ("m3/s", "MW"),
69
+ ("GWh", "Mm3"),
70
+ ("Mm3", "GWh"),
71
+ }
72
+
73
+ _DEBUG = False
74
+
75
+ _COLLECT_FASTPATH_DATA = False
76
+ _OBSERVED_UNIT_CONVERSIONS = set()
77
+
78
+
79
+ def get_unit_conversion_factor(from_unit: str | None, to_unit: str | None) -> float: # noqa C901
80
+ """Get the conversion factor from one unit to another."""
81
+ if from_unit == to_unit:
82
+ return 1.0
83
+
84
+ if from_unit is None or to_unit is None:
85
+ return _get_unit_conversion_factor_with_none(from_unit, to_unit)
86
+
87
+ fastpath = _fastpath_get_unit_conversion_factor(from_unit, to_unit)
88
+
89
+ if _DEBUG is False and fastpath is not None:
90
+ return fastpath
91
+
92
+ if fastpath is None:
93
+ has_multiplier = False
94
+ with contextlib.suppress(Exception):
95
+ ix = from_unit.index("*")
96
+ multiplier = float(from_unit[:ix])
97
+ base_from_unit = from_unit[ix + 1 :].strip()
98
+ has_multiplier = True
99
+
100
+ if has_multiplier:
101
+ fastpath = _fastpath_get_unit_conversion_factor(base_from_unit, to_unit)
102
+ fastpath = fastpath if fastpath is None else fastpath * multiplier
103
+ if _DEBUG is False and fastpath is not None:
104
+ return fastpath
105
+
106
+ if _COLLECT_FASTPATH_DATA and fastpath is None:
107
+ if has_multiplier:
108
+ _OBSERVED_UNIT_CONVERSIONS.add((base_from_unit, to_unit))
109
+ else:
110
+ _OBSERVED_UNIT_CONVERSIONS.add((from_unit, to_unit))
111
+
112
+ fallback = _fallback_get_unit_conversion_factor(from_unit, to_unit)
113
+
114
+ if _DEBUG and fastpath is not None and fallback != fastpath:
115
+ message = f"Different results!\nfrom_unit {from_unit} to_unit {to_unit}\nfastpath {fastpath} fallback {fallback}"
116
+ raise RuntimeError(message)
117
+
118
+ if _unit_has_no_floats(from_unit) and _unit_has_no_floats(to_unit):
119
+ _FASTPATH_CONVERSION_FACTORS[(from_unit, to_unit)] = fallback
120
+
121
+ return fallback
122
+
123
+
124
+ def _get_unit_conversion_factor_with_none(from_unit: str | None, to_unit: str | None) -> float:
125
+ if from_unit:
126
+ try:
127
+ return get_unit_conversion_factor(from_unit, "1")
128
+ except Exception:
129
+ pass
130
+ if to_unit:
131
+ try:
132
+ return get_unit_conversion_factor("1", to_unit)
133
+ except Exception:
134
+ pass
135
+ message = f"Incompatible units: from_unit {from_unit} to_unit {to_unit}"
136
+ raise ValueError(message)
137
+
138
+
139
+ def _unit_has_no_floats(unit: str) -> bool:
140
+ if not unit:
141
+ return True
142
+ floats_in_str = re.findall(r"[-+]?(?:\d*\.*\d+)", unit)
143
+ if not floats_in_str:
144
+ return True
145
+ floats_in_str: list[float] = [float(x) for x in floats_in_str]
146
+ return all(x.is_integer() for x in floats_in_str)
147
+
148
+
149
+ def validate_unit_conversion_fastpaths() -> bool:
150
+ """Run-Time validation of fastpaths."""
151
+ errors = []
152
+ for (from_unit, to_unit), result in _FASTPATH_CONVERSION_FACTORS.items():
153
+ sympy_result = None
154
+ with contextlib.suppress(Exception):
155
+ sympy_result = _fallback_get_unit_conversion_factor(from_unit, to_unit)
156
+ if result != sympy_result:
157
+ message = f"'{from_unit}' to '{to_unit}' failed. Fastpath: {result}, SymPy: {sympy_result}"
158
+ errors.append(message)
159
+ for from_unit, to_unit in _FASTPATH_INCOMPATIBLE_CONVERSIONS:
160
+ with contextlib.suppress(Exception):
161
+ sympy_result = _fallback_get_unit_conversion_factor(from_unit, to_unit)
162
+ message = f"'{from_unit}' to '{to_unit}'. Fastpath claim incompatible units, but SymPy fallback returned {sympy_result}"
163
+ errors.append(message)
164
+ if errors:
165
+ message = "\n".join(errors)
166
+ raise RuntimeError(message)
167
+
168
+
169
+ def _fastpath_get_unit_conversion_factor(from_unit: str, to_unit: str) -> float | None:
170
+ """Try to look up the result."""
171
+ key = (from_unit, to_unit)
172
+ if key in _FASTPATH_CONVERSION_FACTORS:
173
+ return _FASTPATH_CONVERSION_FACTORS[key]
174
+ if key in _FASTPATH_INCOMPATIBLE_CONVERSIONS:
175
+ message = f"Cannot convert from '{from_unit}' to '{to_unit}'"
176
+ raise ValueError(message)
177
+ return None
178
+
179
+
180
+ def _fallback_get_unit_conversion_factor(from_unit: str, to_unit: str) -> float | str:
181
+ """Calculate conversion factor using sympy."""
182
+ from_unit_sym = _unit_str_to_sym(from_unit)
183
+ to_unit_sym = _unit_str_to_sym(to_unit)
184
+
185
+ conversion_expr = from_unit_sym / to_unit_sym
186
+
187
+ value = _get_scalar_from_expr(conversion_expr)
188
+
189
+ if not isinstance(value, float):
190
+ s = f"Incompatible units in expression: {conversion_expr}\nSimplified: {value}"
191
+ message = f"Cannot convert from '{from_unit}' to '{to_unit}':\n{s}"
192
+ raise ValueError(message)
193
+
194
+ return value
195
+
196
+
197
+ def _unit_str_to_sym(unit: str) -> SymPyExpr:
198
+ """Convert str unit to valid sympy representation or error."""
199
+ unit = unit.strip()
200
+ x = sympy.sympify(unit, locals=_SUPPORTED_UNITS)
201
+ unsupported_args = [arg for arg in x.args if not (isinstance(arg, Prefix | Quantity | Pow | Symbol) or arg.is_number)]
202
+ if unsupported_args:
203
+ message = f"Unit string '{unit}' not valid. Unsupported args: {unsupported_args}"
204
+ raise ValueError(message)
205
+ return x
206
+
207
+
208
+ def _get_scalar_from_expr(expr_sym: SymPyExpr) -> float | str:
209
+ """Get scalar value from a sympy expression."""
210
+ simplified_expr = expr_sym.simplify()
211
+ if not simplified_expr.is_number:
212
+ for prefix in _SUPPORTED_UNITS.values():
213
+ if isinstance(prefix, Prefix):
214
+ expr_sym = expr_sym.subs(prefix, prefix.scale_factor)
215
+ simplified_expr = expr_sym.simplify()
216
+ try:
217
+ return float(simplified_expr)
218
+ except Exception:
219
+ return str(simplified_expr)
220
+
221
+
222
+ def is_convertable(unit_from: str, unit_to: str) -> bool:
223
+ """Return True if from_unit can be converted to to_unit else False."""
224
+ with contextlib.suppress(Exception):
225
+ get_unit_conversion_factor(unit_from, unit_to)
226
+ return True
227
+ return False
@@ -0,0 +1,11 @@
1
+ # framcore/fingerprints/__init__.py
2
+
3
+ from framcore.fingerprints.fingerprint import Fingerprint, FingerprintDiff, FingerprintDiffType, FingerprintRef
4
+
5
+ __all__ = [
6
+ "Fingerprint",
7
+ "FingerprintDiff",
8
+ "FingerprintDiffType",
9
+ "FingerprintRef",
10
+ ]
11
+