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,315 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from typing import TYPE_CHECKING
5
+
6
+ from framcore.aggregators._utils import (
7
+ _aggregate_costs,
8
+ _aggregate_result_volumes,
9
+ _aggregate_weighted_expressions,
10
+ _all_detailed_exprs_in_sum_expr,
11
+ )
12
+ from framcore.aggregators.Aggregator import Aggregator # full import path so inheritance works
13
+ from framcore.attributes import AvgFlowVolume, Cost
14
+ from framcore.components import Component, Solar, Wind
15
+ from framcore.curves import Curve
16
+ from framcore.expressions import Expr, get_level_value
17
+ from framcore.timeindexes import FixedFrequencyTimeIndex, SinglePeriodTimeIndex
18
+ from framcore.timevectors import ConstantTimeVector, TimeVector
19
+
20
+ if TYPE_CHECKING:
21
+ from framcore import Model
22
+
23
+
24
+ class _WindSolarAggregator(Aggregator):
25
+ """
26
+ Aggregate Wind and Solar components into groups based on their power nodes.
27
+
28
+ Aggregation steps (self._aggregate):
29
+
30
+ 1. Group components based on their power nodes (self._group_by_power_node):
31
+ 2. Aggregate grouped components into a single aggregated component for each group (self._aggregate_groups):
32
+ - Max_capacity is calculated as the sum of the maximum capacity levels with weighted profiles.
33
+ - Variable operational costs (voc) are aggregated using weighted averages based on the weighting method (now only max_capacity supported).
34
+ - TODO: Add support for additional weighting methods (e.g. production instead of capacity).
35
+ - Production is aggregated as the sum of production levels with weighted profiles. TODO: Add possibility to skip results aggregation.
36
+ 2a. Make new hydro module and delete original components from model data.
37
+ 3. Add mapping from detailed to aggregated components to self._aggregation_map.
38
+
39
+
40
+ Disaggregation steps (self._disaggregate):
41
+
42
+ 1. Restore original components from self._original_data. NB! Changes to aggregated modules are lost except for results (TODO)
43
+ 2. Distribute production from aggregated components back to the original components:
44
+ - Results are weighted based on the weighting method (now only max_capacity supported).
45
+ 3. Delete aggregated components from the model.
46
+
47
+ See Aggregator for general design notes and rules to follow when using Aggregators.
48
+
49
+ Attributes:
50
+ _data_dim (SinglePeriodTimeIndex | None): Data dimension for eager evaluation.
51
+ _scen_dim (FixedFrequencyTimeIndex | None): Scenario dimension for eager evaluation.
52
+ _grouped_components (dict[str, set[str]]): Mapping of aggregated components to their detailed components. agg to detailed
53
+
54
+ Parent Attributes (see framcore.aggregators.Aggregator):
55
+
56
+ _is_last_call_aggregate (bool | None): Tracks whether the last operation was an aggregation.
57
+ _original_data (dict[str, Component | TimeVector | Curve | Expr] | None): Original detailed data before aggregation.
58
+ _aggregation_map (dict[str, set[str]] | None): Maps aggregated components to their detailed components. detailed to agg
59
+
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ data_dim: SinglePeriodTimeIndex | None = None,
65
+ scen_dim: FixedFrequencyTimeIndex | None = None,
66
+ ) -> None:
67
+ """
68
+ Initialize Aggregator.
69
+
70
+ Args:
71
+ data_dim (SinglePeriodTimeIndex): Data dimension for eager evalutation.
72
+ scen_dim (FixedFrequencyTimeIndex): Scenario dimension for eager evalutation.
73
+
74
+ """
75
+ super().__init__()
76
+ self._data_dim = data_dim
77
+ self._scen_dim = scen_dim
78
+ self._grouped_components: dict[str, set[str]] = defaultdict(set)
79
+
80
+ def _aggregate(self, model: Model) -> None:
81
+ data = model.get_data()
82
+
83
+ # Group components by power node and remove groups of size 1
84
+ self._group_by_power_node(data)
85
+
86
+ # Aggregate the grouped components
87
+ self._aggregate_groups(model)
88
+
89
+ # Remove the original components from the model
90
+ for group_id in self._grouped_components:
91
+ for component_id in self._grouped_components[group_id]:
92
+ del data[component_id]
93
+
94
+ # Add mapping to self._aggregation_map
95
+ self._aggregation_map = {member_id: {group_id} for group_id, member_ids in self._grouped_components.items() for member_id in member_ids}
96
+
97
+ def _group_by_power_node(self, data: dict[str, Component | TimeVector | Curve | Expr]) -> None:
98
+ """Group components by their power node and remove groups with only one member."""
99
+ self._grouped_components.clear()
100
+ for name, obj in data.items():
101
+ if isinstance(obj, self._component_type):
102
+ power_node = obj.get_power_node()
103
+ if power_node is None:
104
+ message = f"Component {name} has no power node defined. Cannot group by power node."
105
+ raise ValueError(message)
106
+ group_id = f"Aggregated{self._component_type.__name__}{power_node}"
107
+ self._grouped_components[group_id].add(name)
108
+
109
+ for group_id in list(self._grouped_components.keys()):
110
+ if len(self._grouped_components[group_id]) == 1:
111
+ del self._grouped_components[group_id]
112
+
113
+ def _aggregate_groups(self, model: Model) -> None:
114
+ """Aggregate each group of components into a single component."""
115
+ for group_id, member_ids in self._grouped_components.items():
116
+ self._aggregate_group(model, group_id, member_ids)
117
+
118
+ def _aggregate_group(self, model: Model, group_id: str, member_ids: list[str]) -> None:
119
+ """Aggregate a group of components into a single component."""
120
+ self.send_info_event(f"{group_id} from {len(member_ids)} components.")
121
+ data = model.get_data()
122
+ members = [data[member_id] for member_id in member_ids]
123
+
124
+ # Weights
125
+ capacity_levels = [member.get_max_capacity().get_level() for member in members]
126
+ capacity_profiles = [member.get_max_capacity().get_profile() for member in members]
127
+ vocs = [member.get_voc() for member in members]
128
+ if any(capacity_profiles) or any(vocs): # only calc capacity weights if needed
129
+ capacity_level_values = [get_level_value(cl, model, "MW", self._data_dim, self._scen_dim, True) for cl in capacity_levels]
130
+ if sum(capacity_level_values) == 0.0:
131
+ message = "All grouped components do not contribute to weights (capacity = 0). Simplified aggregation."
132
+ self.send_warning_event(message)
133
+
134
+ # Production capacity
135
+ capacity_levels = [member.get_max_capacity().get_level() for member in members]
136
+ capacity_level = sum(capacity_levels)
137
+
138
+ capacity_profile = None
139
+ if any(capacity_profiles) and (sum(capacity_level_values) != 0.0):
140
+ one_profile = Expr(src=ConstantTimeVector(1.0, is_zero_one_profile=False), is_profile=True)
141
+ capacity_profiles = [profile if profile else one_profile for profile in capacity_profiles]
142
+ capacity_profile = _aggregate_weighted_expressions(capacity_profiles, capacity_level_values)
143
+
144
+ sum_capacity = AvgFlowVolume(capacity_level, capacity_profile)
145
+
146
+ # Power node
147
+ power_node = members[0].get_power_node()
148
+
149
+ # Production
150
+ productions = [member.get_production() for member in members]
151
+ production = _aggregate_result_volumes(model, productions, "MW", self._data_dim, self._scen_dim, group_id, member_ids)
152
+
153
+ # Variable operational cost
154
+ voc = None
155
+ if any(vocs) and (sum(capacity_level_values) != 0.0):
156
+ voc_level, voc_profile, voc_intercept = _aggregate_costs(model, vocs, outside_weights=capacity_level_values, weight_unit="EUR/MWh")
157
+ voc = Cost(voc_level, voc_profile, voc_intercept)
158
+
159
+ new_wind = Wind(
160
+ power_node=power_node,
161
+ max_capacity=sum_capacity,
162
+ voc=voc,
163
+ production=production,
164
+ )
165
+
166
+ data[group_id] = new_wind
167
+
168
+ def _disaggregate(
169
+ self,
170
+ model: Model,
171
+ original_data: dict[str, Component | TimeVector | Curve | Expr],
172
+ ) -> None:
173
+ new_data = model.get_data()
174
+
175
+ deleted_group_names = self._get_deleted_group_components(new_data)
176
+ agg_components = {key: new_data.pop(key) for key in self._grouped_components if key not in deleted_group_names} # isolate agg modules out of new_data
177
+
178
+ # Reinstate original detailed components that are not fully deleted
179
+ for detailed_key, agg_keys in self._aggregation_map.items():
180
+ if agg_keys and all(key in deleted_group_names for key in agg_keys):
181
+ continue
182
+ new_data[detailed_key] = original_data[detailed_key]
183
+
184
+ # Set production results in detailed modules
185
+ for agg_key, detailed_keys in self._grouped_components.items():
186
+ if agg_key in deleted_group_names:
187
+ continue
188
+
189
+ agg_production_level = agg_components[agg_key].get_production().get_level()
190
+ if agg_production_level is None: # keep original production if agg has no production defined
191
+ continue
192
+ if len(detailed_keys) == 1: # only one detailed module, set production directly
193
+ new_data[detailed_key].get_production().set_level(agg_production_level)
194
+ continue
195
+ detailed_production_levels = [new_data[detailed_key].get_production().get_level() for detailed_key in detailed_keys]
196
+ if any(detailed_production_levels) and not all(
197
+ detailed_production_levels,
198
+ ): # if some but not all detailed components have production defined, skip setting production
199
+ missing = [detailed_key for detailed_key, level in zip(detailed_keys, detailed_production_levels, strict=False) if not level]
200
+ message = f"Some but not all grouped components have production defined. Production not disaggregated for {agg_key}, missing for {missing}."
201
+ self.send_warning_event(message)
202
+ continue
203
+ if _all_detailed_exprs_in_sum_expr(agg_production_level, detailed_production_levels): # if agg production is sum of detailed levels, keep original
204
+ continue
205
+ capacity_levels = [new_data[detailed_key].get_max_capacity().get_level() for detailed_key in detailed_keys]
206
+ capacity_level_values = [get_level_value(cl, model, "MW", self._data_dim, self._scen_dim, True) for cl in capacity_levels]
207
+ capacity_level_value_weights = [cl / sum(capacity_level_values) for cl in capacity_level_values]
208
+ production_weights = {detailed_key: weight for detailed_key, weight in zip(detailed_keys, capacity_level_value_weights, strict=False)}
209
+ for detailed_key in detailed_keys:
210
+ self._set_weighted_production(new_data[detailed_key], agg_components[agg_key], production_weights[detailed_key]) # default
211
+
212
+ def _get_deleted_group_components(self, new_data: dict[str, Component | TimeVector | Curve | Expr]) -> set[str]:
213
+ """Identify which aggregated components have been deleted from the model."""
214
+ deleted_group_names: set[str] = set()
215
+
216
+ for group_name in self._grouped_components:
217
+ if group_name not in new_data:
218
+ deleted_group_names.add(group_name)
219
+ continue
220
+
221
+ return deleted_group_names
222
+
223
+ def _set_weighted_production(self, detailed_component: Component, agg_component: Component, production_weight: float) -> None:
224
+ """Set production level and profile for detailed components based on aggregated component."""
225
+ agg_production_level = agg_component.get_production().get_level()
226
+ agg_production_profile = agg_component.get_production().get_profile()
227
+ production_level = production_weight * agg_production_level
228
+ detailed_component.get_production().set_level(production_level)
229
+ detailed_component.get_production().set_profile(agg_production_profile)
230
+
231
+
232
+ class WindAggregator(_WindSolarAggregator):
233
+ """
234
+ Aggregate Wind components into groups based on their power nodes.
235
+
236
+ Aggregation steps (self._aggregate):
237
+
238
+ 1. Group components based on their power nodes (self._group_by_power_node):
239
+ 2. Aggregate grouped components into a single aggregated component for each group (self._aggregate_groups):
240
+ - Max_capacity is calculated as the sum of the maximum capacity levels with weighted profiles.
241
+ - Variable operation costs (voc) are aggregated using weighted averages based on the weighting method (now ony max_capacity supported).
242
+ - TODO: Add support for additional weighting methods (e.g. production instead of capacity).
243
+ - Production is aggregated as the sum of production levels with weighted profiles.
244
+ 2a. Make new hydro module and delete original components from model data.
245
+ 3. Add mapping from detailed to aggregated components to self._aggregation_map.
246
+
247
+
248
+ Disaggregation steps (self._disaggregate):
249
+
250
+ 1. Restore original components from self._original_data. NB! Changes to aggregated modules are lost except for results.
251
+ 2. Distribute production from aggregated components back to the original components:
252
+ - Results are weighted based on the weighting method (now ony max_capacity supported).
253
+ 3. Delete aggregated components from the model.
254
+
255
+
256
+ See Aggregator for general design notes and rules to follow when using Aggregators.
257
+
258
+ Attributes:
259
+ _data_dim (SinglePeriodTimeIndex | None): Data dimension for eager evaluation.
260
+ _scen_dim (FixedFrequencyTimeIndex | None): Scenario dimension for eager evaluation.
261
+ _grouped_components (dict[str, set[str]]): Mapping of aggregated components to their detailed components. agg to detailed
262
+
263
+
264
+ Parent Attributes (see framcore.aggregators.Aggregator):
265
+
266
+ _is_last_call_aggregate (bool | None): Tracks whether the last operation was an aggregation.
267
+ _original_data (dict[str, Component | TimeVector | Curve | Expr] | None): Original detailed data before aggregation.
268
+ _aggregation_map (dict[str, set[str]] | None): Maps aggregated components to their detailed components. detailed to agg
269
+
270
+ """
271
+
272
+ _component_type = Wind
273
+
274
+
275
+ class SolarAggregator(_WindSolarAggregator):
276
+ """
277
+ Aggregate Solar components into groups based on their power nodes.
278
+
279
+ Aggregation steps (self._aggregate):
280
+
281
+ 1. Group components based on their power nodes (self._group_by_power_node):
282
+ 2. Aggregate grouped components into a single aggregated component for each group (self._aggregate_groups):
283
+ - Max_capacity is calculated as the sum of the maximum capacity levels with weighted profiles.
284
+ - Variable operation costs (voc) are aggregated using weighted averages based on the weighting method (now ony max_capacity supported).
285
+ - TODO: Add support for additional weighting methods (e.g. production instead of capacity).
286
+ - Production is aggregated as the sum of production levels with weighted profiles.
287
+ 2a. Make new hydro module and delete original components from model data.
288
+ 3. Add mapping from detailed to aggregated components to self._aggregation_map.
289
+
290
+
291
+ Disaggregation steps (self._disaggregate):
292
+
293
+ 1. Restore original components from self._original_data. NB! Changes to aggregated modules are lost except for results.
294
+ 2. Distribute production from aggregated components back to the original components:
295
+ - Results are weighted based on the weighting method (now ony max_capacity supported).
296
+ 3. Delete aggregated components from the model.
297
+
298
+
299
+ See Aggregator for general design notes and rules to follow when using Aggregators.
300
+
301
+ Attributes:
302
+ _data_dim (SinglePeriodTimeIndex | None): Data dimension for eager evaluation.
303
+ _scen_dim (FixedFrequencyTimeIndex | None): Scenario dimension for eager evaluation.
304
+ _grouped_components (dict[str, set[str]]): Mapping of aggregated components to their detailed components. agg to detailed
305
+
306
+
307
+ Parent Attributes (see framcore.aggregators.Aggregator):
308
+
309
+ _is_last_call_aggregate (bool | None): Tracks whether the last operation was an aggregation.
310
+ _original_data (dict[str, Component | TimeVector | Curve | Expr] | None): Original detailed data before aggregation.
311
+ _aggregation_map (dict[str, set[str]] | None): Maps aggregated components to their detailed components. detailed to agg
312
+
313
+ """
314
+
315
+ _component_type = Solar
@@ -0,0 +1,13 @@
1
+ # framcore/aggregators/__init__.py
2
+ from framcore.aggregators.Aggregator import Aggregator
3
+ from framcore.aggregators.HydroAggregator import HydroAggregator
4
+ from framcore.aggregators.NodeAggregator import NodeAggregator
5
+ from framcore.aggregators.WindSolarAggregator import WindAggregator, SolarAggregator
6
+
7
+ __all__ = [
8
+ "Aggregator",
9
+ "HydroAggregator",
10
+ "NodeAggregator",
11
+ "SolarAggregator",
12
+ "WindAggregator",
13
+ ]
@@ -0,0 +1,184 @@
1
+ """Utility functions for aggregation and disaggregation of model attributes."""
2
+
3
+ from math import isclose
4
+
5
+ from framcore.attributes import AvgFlowVolume, Cost, LevelProfile
6
+ from framcore.expressions import Expr, get_level_value
7
+ from framcore.Model import Model
8
+ from framcore.timeindexes import FixedFrequencyTimeIndex, SinglePeriodTimeIndex
9
+ from framcore.timevectors import ConstantTimeVector
10
+
11
+
12
+ # Aggregation util functions ---------------------------------------------------------------------
13
+ # Only for results
14
+ def _aggregate_result_volumes(
15
+ model: Model,
16
+ volumes: list[AvgFlowVolume],
17
+ weight_unit: str,
18
+ data_dim: SinglePeriodTimeIndex,
19
+ scen_dim: FixedFrequencyTimeIndex,
20
+ group_id: str,
21
+ grouped_ids: list[str],
22
+ ) -> AvgFlowVolume | None:
23
+ """Aggregate result volumes for grouped components. If some but not all grouped components have volume defined, send warning and return None."""
24
+ sum_volume = None
25
+ if all(volume.get_level() for volume in volumes):
26
+ level, profiles, weights = _get_level_profile_weights_volumes_from_results(model, volumes, weight_unit, data_dim, scen_dim)
27
+ profile = _aggregate_weighted_expressions(profiles, weights)
28
+ sum_volume = AvgFlowVolume(level=level, profile=profile)
29
+ elif any(volume.get_level() for volume in volumes):
30
+ missing = [grouped_id for grouped_id, volume in zip(grouped_ids, volumes, strict=False) if not volume.get_level()]
31
+ message = f"Some but not all grouped components have volume defined. Volume not aggregated for {group_id}, missing volume for {missing}."
32
+ model.send_warning_event(message)
33
+ return sum_volume
34
+
35
+
36
+ def _get_level_profile_weights_volumes_from_results(
37
+ model: Model,
38
+ volumes: list[AvgFlowVolume],
39
+ weight_unit: str,
40
+ data_dim: SinglePeriodTimeIndex,
41
+ scen_dim: FixedFrequencyTimeIndex,
42
+ ) -> tuple[Expr, list[Expr], list[float]]:
43
+ """
44
+ Get aggregated level, and profiles with weights from list of volumes.
45
+
46
+ Two cases:
47
+ 1. All volumes have previously been disaggregated (levels are weight * LevelExpr). Can be aggregated more efficiently.
48
+ 2. Default: sum levels and get weights from level values.
49
+ """
50
+ levels = [volume.get_level() for volume in volumes]
51
+ if all(_is_weight_flow_expr(level) for level in levels):
52
+ return _get_level_profile_weights_from_disagg_levelprofiles(model, volumes, data_dim, scen_dim)
53
+ level = sum(levels)
54
+ profiles = [volume.get_profile() for volume in volumes]
55
+ weights = [get_level_value(level, model, weight_unit, data_dim, scen_dim, False) for level in levels]
56
+ return level, profiles, weights
57
+
58
+
59
+ def _get_level_profile_weights_from_disagg_levelprofiles(
60
+ model: Model,
61
+ objs: list[LevelProfile],
62
+ data_dim: SinglePeriodTimeIndex,
63
+ scen_dim: FixedFrequencyTimeIndex,
64
+ ) -> tuple[Expr, list[Expr], list[float]]:
65
+ """
66
+ Get aggregated level, and profiles with weights from disaggregated LevelProfiles with Level = weight * LevelExpr.
67
+
68
+ Two cases:
69
+ - If all sum weights are 1, return sum of levels and profiles with weights 1.
70
+ - Otherwise, return weighted sum of levels, and profiles with weights from level expressions.
71
+ """
72
+ weights = _get_weights_from_levelprofiles(model, objs, data_dim, scen_dim)
73
+ if all(isclose(weight, 1.0, rel_tol=1e-6) for weight in weights.values()):
74
+ level = sum([obj[0] for obj in weights]) # all weights 1, return sum of objs
75
+ profiles = [obj[1] for obj in weights]
76
+ weights = [1.0 for _ in weights]
77
+ return level, profiles, weights
78
+ level = sum([weight * obj[0] for obj, weight in weights.items()]) # return weighted sum of objs
79
+ profiles = [obj[1] for obj in weights]
80
+ weights = [weight for weight in weights.values()]
81
+ return level, profiles, weights
82
+
83
+
84
+ # Generic
85
+ def _aggregate_weighted_expressions(exprs: list[Expr], weights: list[float]) -> Expr:
86
+ """Calculate weighted average of expressions with sum of weights = 1. If all profiles are identical, return that expr."""
87
+ if any(e is None for e in exprs):
88
+ message = f"Cannot aggregate profiles if some profiles are None: {exprs}."
89
+ raise ValueError(message)
90
+ if all(exprs[0] == e for e in exprs):
91
+ return exprs[0]
92
+ weights_dict = dict()
93
+ for e, w in zip(exprs, weights, strict=True):
94
+ if e not in weights_dict:
95
+ weights_dict[e] = 0.0
96
+ weights_dict[e] += w / sum(weights)
97
+ return sum([w * e for e, w in weights_dict.items()])
98
+
99
+
100
+ def _is_weight_flow_expr(expr: Expr) -> bool:
101
+ """Check if expr is weight * FlowExpr, which indicates it comes from disaggregation."""
102
+ if expr.is_leaf():
103
+ return False
104
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
105
+ if ops != "*" or len(args) != 2 or not args[0].is_leaf(): # noqa E501
106
+ return False
107
+ if not (not args[0].is_level() and not args[0].is_profile()):
108
+ return False
109
+ return args[1].is_flow()
110
+
111
+
112
+ def _get_weights_from_levelprofiles(
113
+ model: Model,
114
+ objs: list[LevelProfile],
115
+ data_dim: SinglePeriodTimeIndex,
116
+ scen_dim: FixedFrequencyTimeIndex,
117
+ ) -> dict[tuple[Expr, Expr], float]:
118
+ """Get sum of weights for each unique (level, profile) pair from disaggregated LevelProfiles with Level = weight * LevelExpr."""
119
+ weights = dict()
120
+ for obj in objs:
121
+ ops, args = obj.get_level().get_operations(expect_ops=True, copy_list=False)
122
+ key = (args[1], obj.get_profile())
123
+ if key not in weights:
124
+ weights[key] = 0.0
125
+ weights[key] += get_level_value(args[0], model, unit=None, data_dim=data_dim, scen_dim=scen_dim, is_max=False)
126
+
127
+ for key in weights: # noqa: PLC0206
128
+ if isclose(weights[key], 1.0, rel_tol=1e-6):
129
+ weights[key] = 1.0
130
+
131
+ if any(weight > 1.0 for weight in weights.values()):
132
+ message = f"Sum of weights are over 1 for some level/profile combinations: {weights}."
133
+ raise ValueError(message)
134
+
135
+ return weights
136
+
137
+
138
+ def _aggregate_costs(
139
+ model: Model,
140
+ costs: list[Cost],
141
+ weights: list[float],
142
+ weight_unit: str,
143
+ data_dim: SinglePeriodTimeIndex,
144
+ scen_dim: FixedFrequencyTimeIndex,
145
+ ) -> tuple[Expr, Expr | None, Expr | None]:
146
+ """Aggregate a list of costs with weights. Aggregated cost has weighted level, profile and intercept."""
147
+ # Initialize default values
148
+ aggregated_level = None
149
+ aggregated_profile = None
150
+ aggregated_intercept = None
151
+
152
+ # Handle levels
153
+ zero_level = Expr(ConstantTimeVector(0.0, is_max_level=False), is_level=True)
154
+ cost_levels = [cost.get_level() if cost.get_level() else zero_level for cost in costs]
155
+ aggregated_level = _aggregate_weighted_expressions(cost_levels, weights)
156
+
157
+ # Handle profiles
158
+ cost_profiles = [cost.get_profile() for cost in costs]
159
+ if any(cost_profiles):
160
+ one_profile = Expr(src=ConstantTimeVector(1.0, is_zero_one_profile=False), is_profile=True)
161
+ cost_profiles = [profile if profile else one_profile for profile in cost_profiles]
162
+ cost_level_values = [get_level_value(level, model, weight_unit, data_dim, scen_dim, False) for level in cost_levels]
163
+ profile_weights = [clv * weight for clv, weight in zip(cost_level_values, weights, strict=True)]
164
+ aggregated_profile = _aggregate_weighted_expressions(cost_profiles, profile_weights)
165
+
166
+ # Handle intercepts
167
+ cost_intercepts = [cost.get_intercept() for cost in costs]
168
+ if any(cost_intercepts):
169
+ one_profile = Expr(src=ConstantTimeVector(1.0, is_zero_one_profile=False), is_profile=True)
170
+ cost_intercepts = [intercept if intercept else one_profile for intercept in cost_intercepts]
171
+ aggregated_intercept = _aggregate_weighted_expressions(cost_intercepts, weights)
172
+
173
+ return aggregated_level, aggregated_profile, aggregated_intercept
174
+
175
+
176
+ # Disaggregation util functions ---------------------------------------------------------------------
177
+ def _all_detailed_exprs_in_sum_expr(expr: Expr, detailed_exprs: list[Expr]) -> bool:
178
+ """Check if expr is sum of detailed exprs. Does not handle the case where len(exprs) == 1."""
179
+ if expr.is_leaf():
180
+ return False
181
+ ops, args = expr.get_operations(expect_ops=True, copy_list=False)
182
+ if ops != "+" or len(args) != len(detailed_exprs):
183
+ return False
184
+ return all(arg in detailed_exprs for arg in args)