fram-core 0.1.0a2__py3-none-any.whl → 0.1.1__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 (78) hide show
  1. {fram_core-0.1.0a2.dist-info → fram_core-0.1.1.dist-info}/METADATA +4 -4
  2. fram_core-0.1.1.dist-info/RECORD +100 -0
  3. {fram_core-0.1.0a2.dist-info → fram_core-0.1.1.dist-info}/WHEEL +1 -1
  4. framcore/Base.py +22 -3
  5. framcore/Model.py +26 -9
  6. framcore/__init__.py +2 -1
  7. framcore/aggregators/Aggregator.py +30 -11
  8. framcore/aggregators/HydroAggregator.py +35 -23
  9. framcore/aggregators/NodeAggregator.py +65 -30
  10. framcore/aggregators/WindSolarAggregator.py +22 -30
  11. framcore/attributes/Arrow.py +6 -4
  12. framcore/attributes/ElasticDemand.py +13 -13
  13. framcore/attributes/ReservoirCurve.py +3 -17
  14. framcore/attributes/SoftBound.py +2 -5
  15. framcore/attributes/StartUpCost.py +14 -3
  16. framcore/attributes/Storage.py +17 -5
  17. framcore/attributes/TargetBound.py +2 -4
  18. framcore/attributes/__init__.py +2 -4
  19. framcore/attributes/hydro/HydroBypass.py +9 -2
  20. framcore/attributes/hydro/HydroGenerator.py +24 -7
  21. framcore/attributes/hydro/HydroPump.py +32 -10
  22. framcore/attributes/hydro/HydroReservoir.py +4 -4
  23. framcore/attributes/level_profile_attributes.py +250 -53
  24. framcore/components/Component.py +27 -3
  25. framcore/components/Demand.py +18 -4
  26. framcore/components/Flow.py +26 -4
  27. framcore/components/HydroModule.py +45 -4
  28. framcore/components/Node.py +32 -9
  29. framcore/components/Thermal.py +12 -8
  30. framcore/components/Transmission.py +17 -2
  31. framcore/components/wind_solar.py +25 -10
  32. framcore/curves/LoadedCurve.py +0 -9
  33. framcore/expressions/Expr.py +137 -36
  34. framcore/expressions/__init__.py +3 -1
  35. framcore/expressions/_get_constant_from_expr.py +14 -20
  36. framcore/expressions/queries.py +121 -84
  37. framcore/expressions/units.py +30 -3
  38. framcore/fingerprints/fingerprint.py +0 -1
  39. framcore/juliamodels/JuliaModel.py +13 -3
  40. framcore/loaders/loaders.py +0 -2
  41. framcore/metadata/ExprMeta.py +13 -7
  42. framcore/metadata/LevelExprMeta.py +16 -1
  43. framcore/metadata/Member.py +7 -7
  44. framcore/metadata/__init__.py +1 -1
  45. framcore/querydbs/CacheDB.py +1 -1
  46. framcore/solvers/Solver.py +21 -6
  47. framcore/solvers/SolverConfig.py +4 -4
  48. framcore/timeindexes/AverageYearRange.py +9 -2
  49. framcore/timeindexes/ConstantTimeIndex.py +7 -2
  50. framcore/timeindexes/DailyIndex.py +14 -2
  51. framcore/timeindexes/FixedFrequencyTimeIndex.py +105 -53
  52. framcore/timeindexes/HourlyIndex.py +14 -2
  53. framcore/timeindexes/IsoCalendarDay.py +5 -3
  54. framcore/timeindexes/ListTimeIndex.py +103 -23
  55. framcore/timeindexes/ModelYear.py +8 -2
  56. framcore/timeindexes/ModelYears.py +11 -2
  57. framcore/timeindexes/OneYearProfileTimeIndex.py +10 -2
  58. framcore/timeindexes/ProfileTimeIndex.py +14 -3
  59. framcore/timeindexes/SinglePeriodTimeIndex.py +1 -1
  60. framcore/timeindexes/TimeIndex.py +16 -3
  61. framcore/timeindexes/WeeklyIndex.py +14 -2
  62. framcore/{expressions → timeindexes}/_time_vector_operations.py +76 -2
  63. framcore/timevectors/ConstantTimeVector.py +12 -16
  64. framcore/timevectors/LinearTransformTimeVector.py +20 -3
  65. framcore/timevectors/ListTimeVector.py +18 -14
  66. framcore/timevectors/LoadedTimeVector.py +1 -8
  67. framcore/timevectors/ReferencePeriod.py +13 -3
  68. framcore/timevectors/TimeVector.py +26 -12
  69. framcore/utils/__init__.py +0 -1
  70. framcore/utils/get_regional_volumes.py +21 -3
  71. framcore/utils/get_supported_components.py +1 -1
  72. framcore/utils/global_energy_equivalent.py +22 -5
  73. framcore/utils/isolate_subnodes.py +12 -3
  74. framcore/utils/loaders.py +7 -7
  75. framcore/utils/node_flow_utils.py +4 -4
  76. framcore/utils/storage_subsystems.py +3 -4
  77. fram_core-0.1.0a2.dist-info/RECORD +0 -100
  78. {fram_core-0.1.0a2.dist-info → fram_core-0.1.1.dist-info}/licenses/LICENSE.md +0 -0
@@ -164,7 +164,7 @@ def _update_constants_with_units(
164
164
  if isinstance(obj, TimeVector):
165
165
  obj: TimeVector
166
166
 
167
- # added to support any_expr * ConstantTimeVector
167
+ # added to support any_expr * ConstantTimeVector. We added this to support LevelProfile.scale()
168
168
  times_constant_case = (not is_profile) and isinstance(obj, ConstantTimeVector)
169
169
 
170
170
  if is_level or times_constant_case:
@@ -215,16 +215,6 @@ def _is_fastpath_product(expr: Expr) -> bool:
215
215
  return all(arg.is_leaf() or _is_fastpath_sum(arg) for arg in args)
216
216
 
217
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
218
  def _is_fastpath_sum_of_products(expr: Expr) -> bool:
229
219
  """E.g. x1 * (x2 + x3) + x4 * x5 where x is leaf."""
230
220
  if expr.is_leaf():
@@ -344,19 +334,23 @@ def _fastpath_aggregation( # noqa: C901, PLR0911
344
334
  return num_value / dem_value
345
335
  return get_unit_conversion_factor(combined_unit, target_unit) * (num_value / dem_value)
346
336
 
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)}"
337
+ new_constants_with_units = dict()
351
338
 
352
- combined_dem_unit = ""
339
+ combined_num = ""
353
340
  for unit, value in num.items():
354
- op = "+" if value > 0 else "-"
355
- combined_dem_unit = f"{combined_dem_unit} {op} {abs(value)}"
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}"
356
350
 
357
- combined_unit = f"({combined_num_unit})/({combined_dem_unit})"
351
+ combined = f"({combined_num})/({combined_dem})"
358
352
 
359
- return get_unit_conversion_factor(combined_unit, target_unit)
353
+ return _sympy_fallback(new_constants_with_units, combined, target_unit)
360
354
 
361
355
 
362
356
  def _get_fastpath_sum_dict(
@@ -1,18 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import time
4
+ from datetime import timedelta
4
5
  from typing import TYPE_CHECKING
5
6
 
6
7
  import numpy as np
7
8
  from numpy.typing import NDArray
8
9
 
10
+ from framcore import check_type
9
11
  from framcore.curves import Curve
10
12
  from framcore.expressions import Expr
11
13
  from framcore.expressions._get_constant_from_expr import _get_constant_from_expr
12
14
  from framcore.expressions._utils import _load_model_and_create_model_db
13
15
  from framcore.expressions.units import get_unit_conversion_factor
14
16
  from framcore.querydbs import QueryDB
15
- from framcore.timeindexes import FixedFrequencyTimeIndex, SinglePeriodTimeIndex, TimeIndex
17
+ from framcore.timeindexes import FixedFrequencyTimeIndex, ProfileTimeIndex, SinglePeriodTimeIndex, TimeIndex, WeeklyIndex
16
18
  from framcore.timevectors import TimeVector
17
19
 
18
20
  if TYPE_CHECKING:
@@ -28,34 +30,26 @@ def get_level_value(
28
30
  is_max: bool,
29
31
  ) -> float:
30
32
  """
31
- Evaluate expr representing a (possibly aggregated) level.
33
+ Evaluate Expr representing a (possibly aggregated) level.
32
34
 
33
- The follwing will be automatically handeled for you:
34
- - conversion to requested unit
35
- - conversion to requested reference period
36
- - conversion to requested level type
37
- - fetching from different data objecs
35
+ The follwing will be automatically handled for you:
36
+ - fetching from different data objecs (from db)
37
+ - conversion to requested unit
38
+ - query at requested TimeIndex for data and scenario dimension, and with requested reference period
39
+ - conversion to requested level type (is_max or is_avg)
38
40
 
39
- expr = sum(scale[i] * level[i]) where
41
+ Supports all expressions. Will evaluate level Exprs at data_dim (with reference period of scen_dim),
42
+ and profile Exprs as an average over scen_dim (both as constants). Has optimized fastpath methods for sums, products and aggregations.
43
+ The rest uses a fallback method with SymPy.
40
44
 
41
- scale[i] >= 0 and is unitless
42
- level[i] is a unitful level expr
43
-
44
- level[i] is either "avg" or "max" type of level
45
-
46
- if "avg", it has a reference period (startyear, startyear + numyears) and a corresponding
47
- "mean_one" profile, for which mean(values) = 1 for values covering the reference period
48
-
49
- "avg" and "max" level type must be converted to the same standard to be added correctly.
50
-
51
- Two "avg" types with different reference period must be converted to same reference period
52
- to be added correctly.
53
-
54
- The query parameter scen_dim tells which reference period to convert to.
55
-
56
- The query parameter is_max tells which level type to convert to.
57
45
  """
46
+ check_type(expr, Expr) # check expr here since _get_level_value is not recursively called.
47
+ check_type(unit, (str, type(None)))
48
+ check_type(data_dim, SinglePeriodTimeIndex)
49
+ check_type(scen_dim, FixedFrequencyTimeIndex)
50
+ check_type(is_max, bool)
58
51
  db = _load_model_and_create_model_db(db)
52
+
59
53
  return _get_level_value(expr, db, unit, data_dim, scen_dim, is_max)
60
54
 
61
55
 
@@ -72,7 +66,7 @@ def get_profile_vector(
72
66
 
73
67
  expr = sum(weight[i] * profile[i]) where
74
68
 
75
- weight[i] >= 0 and is unitless
69
+ weight[i] >= 0 and is unitless, and will be evaluated as a constant
76
70
  profile[i] is a unitless profile expr
77
71
 
78
72
  profile[i] is either "zero_one" or "mean_one" type of profile
@@ -80,15 +74,24 @@ def get_profile_vector(
80
74
  "zero_one" and "mean_one" profile type must be converted to the
81
75
  same standard to be added correctly.
82
76
 
83
- The query parameter data_dim is used to compute weight[i].
77
+ The query parameters data_dim and scen_dim are used to evaluate the values
78
+ requested TimeIndex for data and scenario dimension, and with requested reference period
84
79
 
85
- The query parameter scen_dim specifies the out vector dimension.
80
+ weight[i] will be evaluated level Exprs at data_dim (with reference period of scen_dim),
81
+ and profile Exprs as an average over scen_dim (both as constants)
82
+
83
+ profile[i] will be evaluated as profile vectors over scen_dim
86
84
 
87
85
  The query parameter is_zero_one tells which profile type the output
88
86
  vector should be converted to.
89
87
  """
88
+ # Argument expr checked in _get_profile_vector since it can be recursively called.
89
+ check_type(data_dim, SinglePeriodTimeIndex)
90
+ check_type(scen_dim, FixedFrequencyTimeIndex)
91
+ check_type(is_zero_one, bool)
92
+ check_type(is_float32, bool)
90
93
  db = _load_model_and_create_model_db(db)
91
- # TODO: Implement checks cleaner
94
+
92
95
  return _get_profile_vector(expr, db, data_dim, scen_dim, is_zero_one, is_float32)
93
96
 
94
97
 
@@ -126,9 +129,6 @@ def _get_level_value(
126
129
  scen_dim: FixedFrequencyTimeIndex,
127
130
  is_max: bool,
128
131
  ) -> float:
129
- # TODO: Implement checks cleaner
130
- assert isinstance(expr, Expr), f"{expr}"
131
-
132
132
  cache_key = ("_get_constant_from_expr", expr, unit, data_dim, scen_dim, is_max)
133
133
  if db.has_key(cache_key):
134
134
  return db.get(cache_key)
@@ -148,34 +148,12 @@ def _get_profile_vector(
148
148
  is_zero_one: bool,
149
149
  is_float32: bool = True,
150
150
  ) -> NDArray:
151
- assert isinstance(expr, Expr), f"{expr}"
151
+ check_type(expr, Expr)
152
152
 
153
153
  if expr.is_leaf():
154
- src = expr.get_src()
155
-
156
- if isinstance(src, str):
157
- obj = db.get(src)
158
- else:
159
- assert isinstance(src, TimeVector)
160
- obj = src
161
-
162
- if isinstance(obj, Expr):
163
- assert obj.is_profile(), f"{obj}"
164
- return _get_profile_vector(obj, db, data_dim, scen_dim, is_zero_one, is_float32)
165
-
166
- assert isinstance(obj, TimeVector)
167
- cache_key = ("_get_profile_vector_from_timevector", obj, data_dim, scen_dim, is_zero_one, is_float32)
168
- if db.has_key(cache_key):
169
- vector: NDArray = db.get(cache_key)
170
- return vector.copy()
171
- t0 = time.perf_counter()
172
- vector = _get_profile_vector_from_timevector(obj, scen_dim, is_zero_one, is_float32)
173
- t1 = time.perf_counter()
174
- db.put(cache_key, vector, elapsed_seconds=t1 - t0)
175
- return vector
154
+ return _get_profile_vector_from_leaf_expr(expr, db, data_dim, scen_dim, is_zero_one, is_float32)
176
155
 
177
156
  ops, args = expr.get_operations(expect_ops=True, copy_list=False)
178
-
179
157
  tmp = np.zeros(scen_dim.get_num_periods(), dtype=np.float32 if is_float32 else np.float64)
180
158
 
181
159
  if "+" in ops:
@@ -192,18 +170,56 @@ def _get_profile_vector(
192
170
 
193
171
  profiles = [arg for arg in args if arg.is_profile()]
194
172
  weights = [arg for arg in args if not arg.is_profile()]
173
+
195
174
  if len(profiles) != 1:
196
175
  message = f"Got {len(profiles)} profiles in expr {expr}"
197
176
  raise ValueError(message)
177
+
198
178
  total_weight = 0.0
199
179
  is_max = False # use avg-values to calculate weights
180
+
200
181
  for weight_expr in weights:
201
182
  total_weight += _get_constant_from_expr(weight_expr, db, None, data_dim, scen_dim, is_max)
183
+
202
184
  out = _get_profile_vector(profiles[0], db, data_dim, scen_dim, is_zero_one, is_float32)
203
185
  np.multiply(out, total_weight, out=out)
204
186
  return out
205
187
 
206
188
 
189
+ def _get_profile_vector_from_leaf_expr(
190
+ expr: Expr,
191
+ db: QueryDB,
192
+ data_dim: SinglePeriodTimeIndex,
193
+ scen_dim: FixedFrequencyTimeIndex,
194
+ is_zero_one: bool,
195
+ is_float32: bool,
196
+ ) -> NDArray:
197
+ src = expr.get_src()
198
+
199
+ if isinstance(src, str):
200
+ obj = db.get(src)
201
+ else:
202
+ obj = src
203
+
204
+ if isinstance(obj, Expr):
205
+ if not obj.is_profile():
206
+ msg = f"Expected {obj} to be is_profile=True." # User may be getting this from setting wrong metadata in time vector files?
207
+ raise ValueError(msg)
208
+ return _get_profile_vector(obj, db, data_dim, scen_dim, is_zero_one, is_float32)
209
+
210
+ assert isinstance(obj, (TimeVector, Curve))
211
+
212
+ cache_key = ("_get_profile_vector_from_timevector", obj, data_dim, scen_dim, is_zero_one, is_float32)
213
+ if db.has_key(cache_key):
214
+ vector: NDArray = db.get(cache_key)
215
+ return vector.copy()
216
+ t0 = time.perf_counter()
217
+ vector = _get_profile_vector_from_timevector(obj, scen_dim, is_zero_one, is_float32)
218
+ t1 = time.perf_counter()
219
+ db.put(cache_key, vector, elapsed_seconds=t1 - t0)
220
+ return vector
221
+
222
+
207
223
  def _get_profile_vector_from_timevector(
208
224
  timevector: TimeVector,
209
225
  scen_dim: FixedFrequencyTimeIndex,
@@ -254,7 +270,6 @@ def _get_profile_vector_from_timevector(
254
270
  def _recursively_update_units(units: set[str], db: QueryDB, expr: Expr) -> None:
255
271
  if expr.is_leaf():
256
272
  src = expr.get_src()
257
-
258
273
  obj = src if isinstance(src, TimeVector) else db.get(key=src)
259
274
 
260
275
  if isinstance(obj, Expr):
@@ -274,27 +289,6 @@ def _recursively_update_units(units: set[str], db: QueryDB, expr: Expr) -> None:
274
289
  _recursively_update_units(units, db, arg)
275
290
 
276
291
 
277
- def _recursively_update_units(units: set[str], db: QueryDB, expr: Expr) -> None:
278
- if expr.is_leaf():
279
- src = expr.get_src()
280
- obj = src if isinstance(src, TimeVector) else db.get(key=src)
281
- if isinstance(obj, Expr):
282
- _recursively_update_units(units, db, obj)
283
- elif isinstance(obj, Curve):
284
- message = "Not yet implemented for Curve objects."
285
- raise NotImplementedError(message)
286
- elif isinstance(obj, TimeVector):
287
- unit = obj.get_unit()
288
- if unit is not None:
289
- units.add(unit)
290
- else:
291
- message = f"Got unexpected object {obj}."
292
- raise RuntimeError(message)
293
- __, args = expr.get_operations(expect_ops=False, copy_list=False)
294
- for arg in args:
295
- _recursively_update_units(units, db, arg)
296
-
297
-
298
292
  def _recursively_update_timeindexes(timeindexes: set[TimeIndex], db: QueryDB, expr: Expr) -> None:
299
293
  if expr.is_leaf():
300
294
  src = expr.get_src()
@@ -315,7 +309,7 @@ def _recursively_update_timeindexes(timeindexes: set[TimeIndex], db: QueryDB, ex
315
309
  _recursively_update_timeindexes(timeindexes, db, arg)
316
310
 
317
311
 
318
- def _get_level_value_from_timevector( # noqa: C901
312
+ def _get_level_value_from_timevector( # noqa: C901, PLR0915
319
313
  timevector: TimeVector,
320
314
  db: QueryDB,
321
315
  target_unit: str | None,
@@ -338,8 +332,8 @@ def _get_level_value_from_timevector( # noqa: C901
338
332
  from_unit = timevector.get_unit()
339
333
 
340
334
  starttime = data_dim.get_start_time() # OPPGAVE endrer ConstantTimeIndex-API?
341
- timedelta = data_dim.get_period_duration() # OPPGAVE endrer ConstantTimeIndex-API?
342
- scalar = timeindex.get_period_average(values, starttime, timedelta, data_dim.is_52_week_years())
335
+ timedelta_ = data_dim.get_period_duration() # OPPGAVE endrer ConstantTimeIndex-API?
336
+ scalar = timeindex.get_period_average(values, starttime, timedelta_, data_dim.is_52_week_years())
343
337
 
344
338
  if from_unit is not None and target_unit is not None:
345
339
  scalar *= get_unit_conversion_factor(from_unit, target_unit)
@@ -366,16 +360,59 @@ def _get_level_value_from_timevector( # noqa: C901
366
360
  # assert tv_ref_period_mean.size == 1
367
361
  # assert tv_ref_period_mean[0] == 1
368
362
  assert profile_expr, f"Profile Expr is None for TimeVector {timevector} when it should exist"
369
- tv_target_ref_period_mean = get_profile_vector(
363
+
364
+ start_year = min(tv_ref_period.get_start_year(), target_ref_period.get_start_year())
365
+ end_year_1 = tv_ref_period.get_start_year() + tv_ref_period.get_num_years() - 1
366
+ end_year_2 = target_ref_period.get_start_year() + target_ref_period.get_num_years() - 1
367
+ end_year = max(end_year_1, end_year_2)
368
+ num_years = end_year - start_year + 1
369
+
370
+ pti = ProfileTimeIndex(
371
+ start_year=start_year,
372
+ num_years=num_years,
373
+ period_duration=timedelta(weeks=1),
374
+ is_52_week_years=scen_dim.is_52_week_years(),
375
+ )
376
+
377
+ profile_vector = get_profile_vector(
370
378
  profile_expr,
371
379
  db,
372
380
  data_dim,
373
- scen_dim.copy_as_reference_period(target_ref_period),
381
+ pti,
374
382
  is_zero_one=False,
375
383
  is_float32=is_float32,
376
384
  )
377
- assert tv_target_ref_period_mean.size == 1
378
- avg_level = tv_target_ref_period_mean[0] * avg_level
385
+
386
+ assert np.isclose(profile_vector.mean(), 1.0)
387
+
388
+ ref_period_index = WeeklyIndex(
389
+ start_year=tv_ref_period.get_start_year(),
390
+ num_years=tv_ref_period.get_num_years(),
391
+ is_52_week_years=scen_dim.is_52_week_years(),
392
+ )
393
+
394
+ avg_ref_period = pti.get_period_average(
395
+ vector=profile_vector,
396
+ start_time=ref_period_index.get_start_time(),
397
+ duration=ref_period_index.total_duration(),
398
+ is_52_week_years=ref_period_index.is_52_week_years(),
399
+ )
400
+
401
+ target_ref_period_index = WeeklyIndex(
402
+ start_year=target_ref_period.get_start_year(),
403
+ num_years=target_ref_period.get_num_years(),
404
+ is_52_week_years=scen_dim.is_52_week_years(),
405
+ )
406
+
407
+ avg_target_ref_period = pti.get_period_average(
408
+ vector=profile_vector,
409
+ start_time=target_ref_period_index.get_start_time(),
410
+ duration=target_ref_period_index.total_duration(),
411
+ is_52_week_years=target_ref_period_index.is_52_week_years(),
412
+ )
413
+
414
+ scale = avg_target_ref_period / avg_ref_period
415
+ avg_level = scale * avg_level
379
416
  return avg_level
380
417
  # timevector fra max til avg
381
418
  max_level = scalar
@@ -1,3 +1,9 @@
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
+
1
7
  from __future__ import annotations
2
8
 
3
9
  import contextlib
@@ -20,6 +26,7 @@ _SUPPORTED_UNITS = {
20
26
  "year": year,
21
27
  "y": year,
22
28
  "watt": watt,
29
+ "joule": watt * second,
23
30
  "g": gram,
24
31
  "gram": gram,
25
32
  "kg": kilo * gram,
@@ -28,6 +35,7 @@ _SUPPORTED_UNITS = {
28
35
  "meter": meter,
29
36
  "m": meter,
30
37
  "m3": meter**3,
38
+ "l": meter**3 / 1000,
31
39
  "Mm3": mega * meter**3,
32
40
  "m3/s": meter**3 / second,
33
41
  "kilo": kilo,
@@ -38,6 +46,10 @@ _SUPPORTED_UNITS = {
38
46
  "MWh": mega * watt * hour,
39
47
  "GWh": giga * watt * hour,
40
48
  "TWh": tera * watt * hour,
49
+ "J": watt * second,
50
+ "kJ": kilo * watt * second,
51
+ "MJ": mega * watt * second,
52
+ "GJ": giga * watt * second,
41
53
  "kW": kilo * watt,
42
54
  "MW": mega * watt,
43
55
  "GW": giga * watt,
@@ -55,6 +67,7 @@ _FASTPATH_CONVERSION_FACTORS = {
55
67
  ("GWh/year", "MW"): 0.11407955544967756,
56
68
  ("Mm3/year", "m3/s"): 0.03168876540268821,
57
69
  ("t/MWh", "t/GWh"): 1000.0,
70
+ ("MWh", "GJ"): 3.6,
58
71
  }
59
72
 
60
73
  _FASTPATH_INCOMPATIBLE_CONVERSIONS = {
@@ -70,14 +83,13 @@ _COLLECT_FASTPATH_DATA = False
70
83
  _OBSERVED_UNIT_CONVERSIONS = set()
71
84
 
72
85
 
73
- def get_unit_conversion_factor(from_unit: str | None, to_unit: str | None) -> float:
86
+ def get_unit_conversion_factor(from_unit: str | None, to_unit: str | None) -> float: # noqa C901
74
87
  """Get the conversion factor from one unit to another."""
75
88
  if from_unit == to_unit:
76
89
  return 1.0
77
90
 
78
91
  if from_unit is None or to_unit is None:
79
- message = f"Incompatible units: from_unit {from_unit} to_unit {to_unit}"
80
- raise ValueError(message)
92
+ return _get_unit_conversion_factor_with_none(from_unit, to_unit)
81
93
 
82
94
  fastpath = _fastpath_get_unit_conversion_factor(from_unit, to_unit)
83
95
 
@@ -116,6 +128,21 @@ def get_unit_conversion_factor(from_unit: str | None, to_unit: str | None) -> fl
116
128
  return fallback
117
129
 
118
130
 
131
+ def _get_unit_conversion_factor_with_none(from_unit: str | None, to_unit: str | None) -> float:
132
+ if from_unit:
133
+ try:
134
+ return get_unit_conversion_factor(from_unit, "1")
135
+ except Exception:
136
+ pass
137
+ if to_unit:
138
+ try:
139
+ return get_unit_conversion_factor("1", to_unit)
140
+ except Exception:
141
+ pass
142
+ message = f"Incompatible units: from_unit {from_unit} to_unit {to_unit}"
143
+ raise ValueError(message)
144
+
145
+
119
146
  def _unit_has_no_floats(unit: str) -> bool:
120
147
  if not unit:
121
148
  return True
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import hashlib
4
- import json
5
4
  import pickle
6
5
  from enum import Enum
7
6
 
@@ -1,4 +1,4 @@
1
- """Manage Julia environment and usage of juliacall."""
1
+ """Manage Julia environment and usage of juliacall for Solvers implemented in the Julia language."""
2
2
 
3
3
  import importlib
4
4
  import os
@@ -43,14 +43,16 @@ class JuliaModel(Base):
43
43
  julia_path: Path | str | None = None,
44
44
  dependencies: list[str | tuple[str, str | None]] | None = None,
45
45
  skip_install_dependencies: bool = False,
46
+ force_julia_install: bool = True,
46
47
  ) -> None:
47
48
  """
48
49
  Initialize management of Julia model, environment and dependencies.
49
50
 
50
51
  The three parameters env_path, depot_path and julia_path sets environment variables for locations of your Julia
51
52
  environment, packages and language.
52
- - If user has not specified locations, the default is to use the current python/conda environment.
53
- - If a system installation of Python is used, the default is set to the current user location.
53
+
54
+ - If user has not specified locations, the default is to use the current python/conda environment.
55
+ - If a system installation of Python is used, the default is set to the current user location.
54
56
 
55
57
  Args:
56
58
  env_path (Path | str | None, optional): Path to location of Julia environment. If it doesnt exist it will be
@@ -61,6 +63,8 @@ class JuliaModel(Base):
61
63
  doesnt exist. Defaults to None.
62
64
  dependencies (list[str] | None, optional): List of dependencies of the model. The strings in the list can be
63
65
  either urls or Julia package names.. Defaults to None.
66
+ skip_install_dependencies (bool, optional): Skip installation of dependencies. Defaults to False.
67
+ force_julia_install (bool): Force new Julia install.
64
68
 
65
69
  """
66
70
  self._check_type(env_path, (Path, str, type(None)))
@@ -74,6 +78,7 @@ class JuliaModel(Base):
74
78
  self._julia_path = julia_path
75
79
  self._dependencies = dependencies if dependencies else []
76
80
  self._skip_install_dependencies = skip_install_dependencies
81
+ self._force_julia_install = force_julia_install
77
82
 
78
83
  self._jlpkg = None
79
84
  self._initialize_julia()
@@ -96,6 +101,11 @@ class JuliaModel(Base):
96
101
  if self._julia_path: # If Julia path is not set, let JuliaCall handle defaults.
97
102
  os.environ["PYTHON_JULIAPKG_EXE"] = str(self._julia_path)
98
103
 
104
+ if self._force_julia_install:
105
+ path = os.environ.get("PATH", "")
106
+ cleaned = os.pathsep.join(p for p in path.split(os.pathsep) if "julia" not in p.lower())
107
+ os.environ["PATH"] = cleaned
108
+
99
109
  juliacall = importlib.import_module("juliacall")
100
110
  JuliaModel._jl = juliacall.Main
101
111
  self._jlpkg = juliacall.Pkg
@@ -47,8 +47,6 @@ class Loader(Base, ABC):
47
47
  """Clear cached data from the loader."""
48
48
  pass
49
49
 
50
- # TODO: Is this correct? Also figure out how sharing Loaders should be when copying model given filepaths and copied
51
- # database
52
50
  def __deepcopy__(self, memo: dict) -> Loader:
53
51
  """
54
52
  Overwrite deepcopy.
@@ -2,8 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from framcore.expressions import Expr
4
4
  from framcore.fingerprints import Fingerprint
5
- from framcore.metadata import Div
6
- from framcore.metadata.Meta import Meta # NB! full import path needed for inheritance to work
5
+ from framcore.metadata import Div, Meta
7
6
 
8
7
 
9
8
  class ExprMeta(Meta):
@@ -20,6 +19,8 @@ class ExprMeta(Meta):
20
19
 
21
20
  def __repr__(self) -> str:
22
21
  """Overwrite __repr__ for better string representation."""
22
+ if not hasattr(self, "_value"):
23
+ return f"{type(self).__name__}(uninitialized)"
23
24
  return f"{type(self).__name__}(expr={self._value})"
24
25
 
25
26
  def __eq__(self, other: object) -> bool:
@@ -28,7 +29,11 @@ class ExprMeta(Meta):
28
29
  return False
29
30
  return self._value == other._value
30
31
 
31
- def get_value(self) -> float:
32
+ def __hash__(self) -> int:
33
+ """Compute the hash of the ExprMeta."""
34
+ return hash(self._value)
35
+
36
+ def get_value(self) -> Expr:
32
37
  """Return expr."""
33
38
  return self._value
34
39
 
@@ -40,10 +45,11 @@ class ExprMeta(Meta):
40
45
  def combine(self, other: Meta) -> Expr | Div:
41
46
  """Sum Expr."""
42
47
  if isinstance(other, ExprMeta):
43
- return Expr(self._value + other.get_value())
44
- d = Div(self)
45
- d.set_value(other)
46
- return d
48
+ return self._value + other.get_value()
49
+
50
+ div = Div(self)
51
+ div.set_value(other)
52
+ return div
47
53
 
48
54
  def get_fingerprint(self) -> Fingerprint:
49
55
  """Get the fingerprint of the ScalarMeta."""
@@ -13,5 +13,20 @@ class LevelExprMeta(ExprMeta):
13
13
  """
14
14
 
15
15
  def __init__(self, value: Expr | TimeVector) -> None:
16
- """Create new LevelExprMeta with float value."""
16
+ """
17
+ Create new LevelExprMeta with Expr value.
18
+
19
+ Args:
20
+ value (Expr | TimeVector): Accepts Expr with is_level=True or TimeVector with is_max_level=True/False.
21
+
22
+ Raises:
23
+ TypeError: If value is not Expr or TimeVector.
24
+ ValueError: If value is non-level Expr or TimeVector.
25
+
26
+ """
27
+ self._check_type(value, (Expr, TimeVector))
28
+
29
+ if isinstance(value, TimeVector) and value.is_max_level() is None:
30
+ raise ValueError("Parameter 'value' (TimeVector) must be a level (is_max_level must be True or False).")
31
+
17
32
  self._value = ensure_expr(value, is_level=True)
@@ -7,17 +7,17 @@ from framcore.metadata.Meta import Meta # NB! full import path needed for inher
7
7
 
8
8
  class Member(Meta):
9
9
  """
10
- Member represent membership to a catergory with a str. Subclass of Meta.
10
+ Member represent membership to a catergory or group using a str. Subclass of Meta.
11
11
 
12
12
  Should not have missing values.
13
13
 
14
14
  When used, all components must have a membership.
15
15
  """
16
16
 
17
- def __init__(self, value: str | float | int) -> None: # TODO: only str
17
+ def __init__(self, value: str) -> None:
18
18
  """Create new member with str value."""
19
- self._value = value # set before checking, otherwise __repr__ can fail because self._value is not set.
20
- self._check_type(value, (str, float, int))
19
+ self._value = value
20
+ self._check_type(value, str)
21
21
 
22
22
  def __repr__(self) -> str:
23
23
  """Overwrite __repr__ for better string representation."""
@@ -33,13 +33,13 @@ class Member(Meta):
33
33
  """Overwrite __hash__ since its added to sets."""
34
34
  return hash(self.__repr__())
35
35
 
36
- def get_value(self) -> str | float | int:
36
+ def get_value(self) -> str:
37
37
  """Return str value."""
38
38
  return self._value
39
39
 
40
- def set_value(self, value: str | float | int) -> None:
40
+ def set_value(self, value: str) -> None:
41
41
  """Set str value. TypeError if not str."""
42
- self._check_type(value, (str, float, int))
42
+ self._check_type(value, str)
43
43
  self._value = value
44
44
 
45
45
  def combine(self, other: Meta) -> Member | Div:
@@ -1,10 +1,10 @@
1
1
  # framcore/metadata/__init__.py
2
2
 
3
+ from framcore.metadata.Meta import Meta
3
4
  from framcore.metadata.Div import Div
4
5
  from framcore.metadata.ExprMeta import ExprMeta
5
6
  from framcore.metadata.LevelExprMeta import LevelExprMeta
6
7
  from framcore.metadata.Member import Member
7
- from framcore.metadata.Meta import Meta
8
8
 
9
9
  __all__ = [
10
10
  "Div",
@@ -21,7 +21,7 @@ class CacheDB(QueryDB):
21
21
  def set_min_elapsed_seconds(self, value: float) -> None:
22
22
  """Values that takes below this threshold to compute, does not get cached."""
23
23
  self._check_type(value, float)
24
- self._check_float(lower_bound=0.0)
24
+ self._check_float(value=value, lower_bound=0.0, upper_bound=None)
25
25
  self._min_elapsed_seconds = value
26
26
 
27
27
  def get_min_elapsed_seconds(self) -> float: