fram-core 0.0.0__tar.gz → 0.1.0a1__tar.gz
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.
- fram_core-0.1.0a1/LICENSE.md +8 -0
- fram_core-0.1.0a1/PKG-INFO +41 -0
- fram_core-0.1.0a1/README.md +19 -0
- fram_core-0.1.0a1/framcore/Base.py +142 -0
- fram_core-0.1.0a1/framcore/Model.py +73 -0
- fram_core-0.1.0a1/framcore/__init__.py +9 -0
- fram_core-0.1.0a1/framcore/aggregators/Aggregator.py +153 -0
- fram_core-0.1.0a1/framcore/aggregators/HydroAggregator.py +837 -0
- fram_core-0.1.0a1/framcore/aggregators/NodeAggregator.py +495 -0
- fram_core-0.1.0a1/framcore/aggregators/WindSolarAggregator.py +323 -0
- fram_core-0.1.0a1/framcore/aggregators/__init__.py +13 -0
- fram_core-0.1.0a1/framcore/aggregators/_utils.py +184 -0
- fram_core-0.1.0a1/framcore/attributes/Arrow.py +305 -0
- fram_core-0.1.0a1/framcore/attributes/ElasticDemand.py +90 -0
- fram_core-0.1.0a1/framcore/attributes/ReservoirCurve.py +37 -0
- fram_core-0.1.0a1/framcore/attributes/SoftBound.py +19 -0
- fram_core-0.1.0a1/framcore/attributes/StartUpCost.py +54 -0
- fram_core-0.1.0a1/framcore/attributes/Storage.py +146 -0
- fram_core-0.1.0a1/framcore/attributes/TargetBound.py +18 -0
- fram_core-0.1.0a1/framcore/attributes/__init__.py +65 -0
- fram_core-0.1.0a1/framcore/attributes/hydro/HydroBypass.py +42 -0
- fram_core-0.1.0a1/framcore/attributes/hydro/HydroGenerator.py +83 -0
- fram_core-0.1.0a1/framcore/attributes/hydro/HydroPump.py +156 -0
- fram_core-0.1.0a1/framcore/attributes/hydro/HydroReservoir.py +27 -0
- fram_core-0.1.0a1/framcore/attributes/hydro/__init__.py +13 -0
- fram_core-0.1.0a1/framcore/attributes/level_profile_attributes.py +714 -0
- fram_core-0.1.0a1/framcore/components/Component.py +112 -0
- fram_core-0.1.0a1/framcore/components/Demand.py +130 -0
- fram_core-0.1.0a1/framcore/components/Flow.py +167 -0
- fram_core-0.1.0a1/framcore/components/HydroModule.py +330 -0
- fram_core-0.1.0a1/framcore/components/Node.py +76 -0
- fram_core-0.1.0a1/framcore/components/Thermal.py +204 -0
- fram_core-0.1.0a1/framcore/components/Transmission.py +183 -0
- fram_core-0.1.0a1/framcore/components/_PowerPlant.py +81 -0
- fram_core-0.1.0a1/framcore/components/__init__.py +22 -0
- fram_core-0.1.0a1/framcore/components/wind_solar.py +67 -0
- fram_core-0.1.0a1/framcore/curves/Curve.py +44 -0
- fram_core-0.1.0a1/framcore/curves/LoadedCurve.py +155 -0
- fram_core-0.1.0a1/framcore/curves/__init__.py +9 -0
- fram_core-0.1.0a1/framcore/events/__init__.py +21 -0
- fram_core-0.1.0a1/framcore/events/events.py +51 -0
- fram_core-0.1.0a1/framcore/expressions/Expr.py +490 -0
- fram_core-0.1.0a1/framcore/expressions/__init__.py +28 -0
- fram_core-0.1.0a1/framcore/expressions/_get_constant_from_expr.py +483 -0
- fram_core-0.1.0a1/framcore/expressions/_time_vector_operations.py +615 -0
- fram_core-0.1.0a1/framcore/expressions/_utils.py +73 -0
- fram_core-0.1.0a1/framcore/expressions/queries.py +423 -0
- fram_core-0.1.0a1/framcore/expressions/units.py +207 -0
- fram_core-0.1.0a1/framcore/fingerprints/__init__.py +11 -0
- fram_core-0.1.0a1/framcore/fingerprints/fingerprint.py +293 -0
- fram_core-0.1.0a1/framcore/juliamodels/JuliaModel.py +161 -0
- fram_core-0.1.0a1/framcore/juliamodels/__init__.py +7 -0
- fram_core-0.1.0a1/framcore/loaders/__init__.py +10 -0
- fram_core-0.1.0a1/framcore/loaders/loaders.py +407 -0
- fram_core-0.1.0a1/framcore/metadata/Div.py +73 -0
- fram_core-0.1.0a1/framcore/metadata/ExprMeta.py +50 -0
- fram_core-0.1.0a1/framcore/metadata/LevelExprMeta.py +17 -0
- fram_core-0.1.0a1/framcore/metadata/Member.py +55 -0
- fram_core-0.1.0a1/framcore/metadata/Meta.py +44 -0
- fram_core-0.1.0a1/framcore/metadata/__init__.py +15 -0
- fram_core-0.1.0a1/framcore/populators/Populator.py +108 -0
- fram_core-0.1.0a1/framcore/populators/__init__.py +7 -0
- fram_core-0.1.0a1/framcore/querydbs/CacheDB.py +50 -0
- fram_core-0.1.0a1/framcore/querydbs/ModelDB.py +34 -0
- fram_core-0.1.0a1/framcore/querydbs/QueryDB.py +45 -0
- fram_core-0.1.0a1/framcore/querydbs/__init__.py +11 -0
- fram_core-0.1.0a1/framcore/solvers/Solver.py +48 -0
- fram_core-0.1.0a1/framcore/solvers/SolverConfig.py +272 -0
- fram_core-0.1.0a1/framcore/solvers/__init__.py +9 -0
- fram_core-0.1.0a1/framcore/timeindexes/AverageYearRange.py +20 -0
- fram_core-0.1.0a1/framcore/timeindexes/ConstantTimeIndex.py +17 -0
- fram_core-0.1.0a1/framcore/timeindexes/DailyIndex.py +21 -0
- fram_core-0.1.0a1/framcore/timeindexes/FixedFrequencyTimeIndex.py +762 -0
- fram_core-0.1.0a1/framcore/timeindexes/HourlyIndex.py +21 -0
- fram_core-0.1.0a1/framcore/timeindexes/IsoCalendarDay.py +31 -0
- fram_core-0.1.0a1/framcore/timeindexes/ListTimeIndex.py +197 -0
- fram_core-0.1.0a1/framcore/timeindexes/ModelYear.py +17 -0
- fram_core-0.1.0a1/framcore/timeindexes/ModelYears.py +18 -0
- fram_core-0.1.0a1/framcore/timeindexes/OneYearProfileTimeIndex.py +21 -0
- fram_core-0.1.0a1/framcore/timeindexes/ProfileTimeIndex.py +32 -0
- fram_core-0.1.0a1/framcore/timeindexes/SinglePeriodTimeIndex.py +37 -0
- fram_core-0.1.0a1/framcore/timeindexes/TimeIndex.py +90 -0
- fram_core-0.1.0a1/framcore/timeindexes/WeeklyIndex.py +21 -0
- fram_core-0.1.0a1/framcore/timeindexes/__init__.py +36 -0
- fram_core-0.1.0a1/framcore/timevectors/ConstantTimeVector.py +135 -0
- fram_core-0.1.0a1/framcore/timevectors/LinearTransformTimeVector.py +114 -0
- fram_core-0.1.0a1/framcore/timevectors/ListTimeVector.py +123 -0
- fram_core-0.1.0a1/framcore/timevectors/LoadedTimeVector.py +104 -0
- fram_core-0.1.0a1/framcore/timevectors/ReferencePeriod.py +41 -0
- fram_core-0.1.0a1/framcore/timevectors/TimeVector.py +94 -0
- fram_core-0.1.0a1/framcore/timevectors/__init__.py +17 -0
- fram_core-0.1.0a1/framcore/utils/__init__.py +36 -0
- fram_core-0.1.0a1/framcore/utils/get_regional_volumes.py +369 -0
- fram_core-0.1.0a1/framcore/utils/get_supported_components.py +60 -0
- fram_core-0.1.0a1/framcore/utils/global_energy_equivalent.py +46 -0
- fram_core-0.1.0a1/framcore/utils/isolate_subnodes.py +163 -0
- fram_core-0.1.0a1/framcore/utils/loaders.py +97 -0
- fram_core-0.1.0a1/framcore/utils/node_flow_utils.py +236 -0
- fram_core-0.1.0a1/framcore/utils/storage_subsystems.py +107 -0
- fram_core-0.1.0a1/pyproject.toml +46 -0
- fram_core-0.0.0/PKG-INFO +0 -5
- fram_core-0.0.0/fram_core.egg-info/PKG-INFO +0 -5
- fram_core-0.0.0/fram_core.egg-info/SOURCES.txt +0 -5
- fram_core-0.0.0/fram_core.egg-info/dependency_links.txt +0 -1
- fram_core-0.0.0/fram_core.egg-info/top_level.txt +0 -1
- fram_core-0.0.0/pyproject.toml +0 -8
- fram_core-0.0.0/setup.cfg +0 -4
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
Copyright © 2025 NVE
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
5
|
+
|
|
6
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fram-core
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary:
|
|
5
|
+
License: LICENSE.md
|
|
6
|
+
License-File: LICENSE.md
|
|
7
|
+
Author: The Norwegian Water Resources and Energy Directorate
|
|
8
|
+
Author-email: fram@nve.no
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Classifier: License :: Other/Proprietary License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Dist: numexpr (>=2.10.2)
|
|
17
|
+
Requires-Dist: numpy (>=2.2.2)
|
|
18
|
+
Requires-Dist: pandas (>=2.2.3)
|
|
19
|
+
Requires-Dist: sympy (>=1.13.3)
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# fram-core
|
|
23
|
+
|
|
24
|
+
## About
|
|
25
|
+
|
|
26
|
+
**fram-core** is the main package in **FRAM** modelling framework. The package contains essential features, interfaces and components for running energy market models in FRAM.
|
|
27
|
+
|
|
28
|
+
For package documentation see [fram-core](https://nve.github.io/fram-core){:target="_blank"}.
|
|
29
|
+
|
|
30
|
+
For FRAM documentation see [FRAM mainpage](https://nve.github.io/fram){:target="_blank"}.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
To add the package to your project use:
|
|
35
|
+
|
|
36
|
+
pip install fram-core
|
|
37
|
+
|
|
38
|
+
With poetry:
|
|
39
|
+
|
|
40
|
+
poetry add fram-core
|
|
41
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# fram-core
|
|
2
|
+
|
|
3
|
+
## About
|
|
4
|
+
|
|
5
|
+
**fram-core** is the main package in **FRAM** modelling framework. The package contains essential features, interfaces and components for running energy market models in FRAM.
|
|
6
|
+
|
|
7
|
+
For package documentation see [fram-core](https://nve.github.io/fram-core){:target="_blank"}.
|
|
8
|
+
|
|
9
|
+
For FRAM documentation see [FRAM mainpage](https://nve.github.io/fram){:target="_blank"}.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
To add the package to your project use:
|
|
14
|
+
|
|
15
|
+
pip install fram-core
|
|
16
|
+
|
|
17
|
+
With poetry:
|
|
18
|
+
|
|
19
|
+
poetry add fram-core
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import inspect
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from framcore.events import (
|
|
6
|
+
send_debug_event,
|
|
7
|
+
send_error_event,
|
|
8
|
+
send_event,
|
|
9
|
+
send_info_event,
|
|
10
|
+
send_warning_event,
|
|
11
|
+
)
|
|
12
|
+
from framcore.fingerprints import Fingerprint
|
|
13
|
+
|
|
14
|
+
# TODO: Consider context dict | None in event-methods to support more info (e.g. process id)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Base:
|
|
18
|
+
"""Core base class to share methods."""
|
|
19
|
+
|
|
20
|
+
def _check_type(self, value, class_or_tuple) -> None: # noqa: ANN001
|
|
21
|
+
if not isinstance(value, class_or_tuple):
|
|
22
|
+
message = f"Expected {class_or_tuple} for {self}, got {type(value).__name__}"
|
|
23
|
+
raise TypeError(message)
|
|
24
|
+
|
|
25
|
+
def _ensure_float(self, value: object) -> float:
|
|
26
|
+
with contextlib.suppress(Exception):
|
|
27
|
+
return float(value)
|
|
28
|
+
message = f"Unable to convert {value} to float."
|
|
29
|
+
raise ValueError(message)
|
|
30
|
+
|
|
31
|
+
def _check_int(self, value: int, lower_bound: int | None, upper_bound: int | None) -> None:
|
|
32
|
+
if lower_bound is not None and value < lower_bound:
|
|
33
|
+
message = f"Value {value} is less than lower_bound {lower_bound}."
|
|
34
|
+
raise ValueError(message)
|
|
35
|
+
if upper_bound is not None and value > upper_bound:
|
|
36
|
+
message = f"Value {value} is greater than upper_bound {upper_bound}."
|
|
37
|
+
raise ValueError(message)
|
|
38
|
+
|
|
39
|
+
def _check_float(self, value: float, lower_bound: float | None, upper_bound: float | None) -> None:
|
|
40
|
+
if lower_bound is not None and value < lower_bound:
|
|
41
|
+
message = f"Value {value} is less than lower_bound {lower_bound}."
|
|
42
|
+
raise ValueError(message)
|
|
43
|
+
if upper_bound is not None and value > upper_bound:
|
|
44
|
+
message = f"Value {value} is greater than upper_bound {upper_bound}."
|
|
45
|
+
raise ValueError(message)
|
|
46
|
+
|
|
47
|
+
def _report_errors(self, errors: set[str]) -> None:
|
|
48
|
+
if errors:
|
|
49
|
+
n = len(errors)
|
|
50
|
+
s = "s" if n > 1 else ""
|
|
51
|
+
error_str = "\n".join(errors)
|
|
52
|
+
message = f"Found {n} error{s}:\n{error_str}"
|
|
53
|
+
raise RuntimeError(message)
|
|
54
|
+
|
|
55
|
+
def send_event(self, event_type: str, **kwargs: dict[str, Any]) -> None:
|
|
56
|
+
"""All events in core should use this."""
|
|
57
|
+
send_event(sender=self, event_type=event_type, **kwargs)
|
|
58
|
+
|
|
59
|
+
def send_warning_event(self, message: str) -> None:
|
|
60
|
+
"""Use this to send warning event."""
|
|
61
|
+
send_warning_event(sender=self, message=message)
|
|
62
|
+
|
|
63
|
+
def send_error_event(self, message: str, exception_type_name: str, traceback: str) -> None:
|
|
64
|
+
"""Use this to send error event."""
|
|
65
|
+
send_error_event(sender=self, message=message, exception_type_name=exception_type_name, traceback=traceback)
|
|
66
|
+
|
|
67
|
+
def send_info_event(self, message: str) -> None:
|
|
68
|
+
"""Use this to send info event."""
|
|
69
|
+
send_info_event(sender=self, message=message)
|
|
70
|
+
|
|
71
|
+
def send_debug_event(self, message: str) -> None:
|
|
72
|
+
"""Use this to send debug event."""
|
|
73
|
+
send_debug_event(sender=self, message=message)
|
|
74
|
+
|
|
75
|
+
def get_fingerprint_default(
|
|
76
|
+
self,
|
|
77
|
+
refs: dict[str, str] | None = None,
|
|
78
|
+
excludes: set[str] | None = None,
|
|
79
|
+
) -> Fingerprint:
|
|
80
|
+
"""
|
|
81
|
+
Generate a Fingerprint for the object, optionally including references and excluding specified properties.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
refs : dict[str, str] | None, optional
|
|
86
|
+
Dictionary mapping property names to reference keys to include as references in the fingerprint.
|
|
87
|
+
excludes : set[str] | None, optional
|
|
88
|
+
Set of property names to exclude from the fingerprint.
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
Fingerprint
|
|
93
|
+
The generated fingerprint for the object.
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
fingerprint = Fingerprint(source=self)
|
|
97
|
+
|
|
98
|
+
if refs:
|
|
99
|
+
for ref_prop, ref_key in refs.items():
|
|
100
|
+
if ref_key is not None:
|
|
101
|
+
fingerprint.add_ref(ref_prop, ref_key)
|
|
102
|
+
|
|
103
|
+
default_excludes = {"_parent"}
|
|
104
|
+
|
|
105
|
+
for prop_name, prop_value in self.__dict__.items():
|
|
106
|
+
if callable(prop_value) or (refs and prop_name in refs) or (excludes and prop_name in excludes) or prop_name in default_excludes:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
if prop_value is None:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
fingerprint.add(prop_name, prop_value)
|
|
113
|
+
|
|
114
|
+
return fingerprint
|
|
115
|
+
|
|
116
|
+
def _get_property_name(self, property_reference) -> str | None: # noqa: ANN001
|
|
117
|
+
for name, value in inspect.getmembers(self):
|
|
118
|
+
if value is property_reference:
|
|
119
|
+
return name
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def __repr__(self) -> str:
|
|
123
|
+
"""Display type and non-None fields."""
|
|
124
|
+
type_name = type(self).__name__
|
|
125
|
+
value_fields = []
|
|
126
|
+
for k, v in vars(self).items():
|
|
127
|
+
display_value = self._get_attr_str(k, v)
|
|
128
|
+
if display_value is not None:
|
|
129
|
+
value_fields.append(f"{k}={display_value}")
|
|
130
|
+
value_fields = ", ".join(value_fields)
|
|
131
|
+
return f"{type_name}({value_fields})"
|
|
132
|
+
|
|
133
|
+
def _get_attr_str(self, key: str, value: object) -> str | None:
|
|
134
|
+
if value is None:
|
|
135
|
+
return None
|
|
136
|
+
if isinstance(value, int | float | str | bool):
|
|
137
|
+
return value
|
|
138
|
+
try:
|
|
139
|
+
return value._get_attr_str() # noqa: SLF001
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
return type(value).__name__
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from framcore import Base
|
|
5
|
+
from framcore.components import Component
|
|
6
|
+
from framcore.curves import Curve
|
|
7
|
+
from framcore.expressions import Expr
|
|
8
|
+
from framcore.timevectors import TimeVector
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from framcore.aggregators import Aggregator
|
|
12
|
+
|
|
13
|
+
class ModelDict(dict):
|
|
14
|
+
"""Dict storing only values of type Component | Expr | TimeVector | Curve."""
|
|
15
|
+
def __setitem__(self, key, value):
|
|
16
|
+
if not isinstance(key, str):
|
|
17
|
+
message = f"Expected str for key {key}, got {type(key).__name__}"
|
|
18
|
+
raise TypeError(message)
|
|
19
|
+
if not isinstance(value, Component | Expr | TimeVector | Curve):
|
|
20
|
+
message = f"Expected Component | Expr | TimeVector | Curve for key {key}, got {type(value).__name__}"
|
|
21
|
+
raise TypeError(message)
|
|
22
|
+
return super().__setitem__(key, value)
|
|
23
|
+
|
|
24
|
+
class Model(Base):
|
|
25
|
+
"""Definition of the Model class."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
"""Create a new model instance."""
|
|
29
|
+
self._data = ModelDict()
|
|
30
|
+
self._aggregators: list[Aggregator] = []
|
|
31
|
+
|
|
32
|
+
def disaggregate(self) -> None:
|
|
33
|
+
"""Undo all aggregations in LIFO order."""
|
|
34
|
+
while self._aggregators:
|
|
35
|
+
aggregator = self._aggregators.pop(-1) # last item
|
|
36
|
+
aggregator.disaggregate(self)
|
|
37
|
+
|
|
38
|
+
def get_data(self) -> ModelDict:
|
|
39
|
+
"""Get internal data. Modify this with care."""
|
|
40
|
+
return self._data
|
|
41
|
+
|
|
42
|
+
def get_content_counts(self) -> dict[str, Counter]:
|
|
43
|
+
"""Return number of objects stored in model organized into concepts and types."""
|
|
44
|
+
data_values = self.get_data().values()
|
|
45
|
+
counts = {
|
|
46
|
+
"components": Counter(),
|
|
47
|
+
"timevectors": Counter(),
|
|
48
|
+
"curves": Counter(),
|
|
49
|
+
"expressions": Counter(),
|
|
50
|
+
}
|
|
51
|
+
for obj in data_values:
|
|
52
|
+
if isinstance(obj, Component):
|
|
53
|
+
key = "components"
|
|
54
|
+
elif isinstance(obj, TimeVector):
|
|
55
|
+
key = "timevectors"
|
|
56
|
+
elif isinstance(obj, Curve):
|
|
57
|
+
key = "curves"
|
|
58
|
+
elif isinstance(obj, Expr):
|
|
59
|
+
key = "expressions"
|
|
60
|
+
else:
|
|
61
|
+
key = "unexpected"
|
|
62
|
+
if key not in counts:
|
|
63
|
+
counts[key] = Counter()
|
|
64
|
+
counts[key][type(obj).__name__] += 1
|
|
65
|
+
|
|
66
|
+
assert len(data_values) == sum(c.total() for c in counts.values())
|
|
67
|
+
|
|
68
|
+
counts["aggregators"] = Counter()
|
|
69
|
+
for a in self._aggregators:
|
|
70
|
+
counts["aggregators"][type(a).__name__] += 1
|
|
71
|
+
|
|
72
|
+
return counts
|
|
73
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
|
|
8
|
+
from framcore.Base import Base
|
|
9
|
+
from framcore.components import Component
|
|
10
|
+
from framcore.curves import Curve
|
|
11
|
+
from framcore.expressions import Expr
|
|
12
|
+
from framcore.metadata import Member
|
|
13
|
+
from framcore.Model import Model
|
|
14
|
+
from framcore.timevectors import TimeVector
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Aggregator(Base, ABC):
|
|
18
|
+
"""
|
|
19
|
+
Aggregator interface class.
|
|
20
|
+
|
|
21
|
+
Public API is the aggregate and disaggregate methods.
|
|
22
|
+
|
|
23
|
+
These methods come with the folloing calling rules:
|
|
24
|
+
1. Not allowed to call aggregate twice. Must call disaggregate before aggregate can be called again.
|
|
25
|
+
2. Disaggragate can only be called after aggregate has been called.
|
|
26
|
+
|
|
27
|
+
Implementations should implement _aggregate and _disaggregate.
|
|
28
|
+
- The general approach for aggregation is to group components, aggregated components in the same group, delete the detailed components,
|
|
29
|
+
and add the mapping to self._aggregation_map.
|
|
30
|
+
- The general approach for disaggregation is to restore the detailed components, move results from aggregated components to detailed components,
|
|
31
|
+
and delete the aggregated components.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
"""Initialize the Aggregator with default state for aggregation tracking and data storage."""
|
|
36
|
+
self._is_last_call_aggregate = None
|
|
37
|
+
self._original_data: dict[str, Component | TimeVector | Curve | Expr] | None = None
|
|
38
|
+
self._aggregation_map: dict[str, set[str]] = None
|
|
39
|
+
|
|
40
|
+
def aggregate(self, model: Model) -> None:
|
|
41
|
+
"""Aggregate model. Keep original data in case disaggregate is called."""
|
|
42
|
+
self._check_type(model, Model)
|
|
43
|
+
|
|
44
|
+
if self._is_last_call_aggregate is True:
|
|
45
|
+
message = f"Will overwrite existing aggregation."
|
|
46
|
+
self.send_warning_event(message)
|
|
47
|
+
|
|
48
|
+
self._original_data = deepcopy(model.get_data())
|
|
49
|
+
self._aggregate(model)
|
|
50
|
+
self._is_last_call_aggregate = True
|
|
51
|
+
if self in model._aggregators: # noqa: SLF001
|
|
52
|
+
message = f"{model} has already been aggregated with {self}. Cannot perform the same Aggregation more than once on a Model object."
|
|
53
|
+
raise ValueError(message)
|
|
54
|
+
|
|
55
|
+
# transfer_unambigous_memberships to aggregated components to support further aggregation
|
|
56
|
+
mapping = self.get_aggregation_map()
|
|
57
|
+
reversed_mapping = defaultdict(set)
|
|
58
|
+
new_data = model.get_data()
|
|
59
|
+
for member_id, group_ids in mapping.items():
|
|
60
|
+
self._check_type(group_ids, set)
|
|
61
|
+
for group_id in group_ids:
|
|
62
|
+
self._check_type(group_id, str)
|
|
63
|
+
member_component = self._original_data[member_id]
|
|
64
|
+
group_component = new_data[group_id]
|
|
65
|
+
reversed_mapping[group_component].add(member_component)
|
|
66
|
+
for group_component, member_components in reversed_mapping.items():
|
|
67
|
+
transfer_unambigous_memberships(group_component, member_components)
|
|
68
|
+
|
|
69
|
+
model._aggregators.append(deepcopy(self)) # noqa: SLF001
|
|
70
|
+
|
|
71
|
+
def disaggregate(self, model: Model) -> None:
|
|
72
|
+
"""Disaggregate model back to pre-aggregate form. Move results into the disaggregated objects."""
|
|
73
|
+
self._check_type(model, Model)
|
|
74
|
+
self._check_is_aggregated()
|
|
75
|
+
self._disaggregate(model, self._original_data)
|
|
76
|
+
self._is_last_call_aggregate = False
|
|
77
|
+
self._original_data = None
|
|
78
|
+
self._aggregation_map = None
|
|
79
|
+
|
|
80
|
+
def get_aggregation_map(self) -> dict[str, set[str]]:
|
|
81
|
+
"""
|
|
82
|
+
Return dictionary mapping from disaggregated to aggregated Component IDs.
|
|
83
|
+
|
|
84
|
+
The mapping should tell you which of the original Components were aggregated into which new Components.
|
|
85
|
+
Components which are left as is should not be in the mapping.
|
|
86
|
+
Components which are deleted without being aggregated are mapped to an empty set.
|
|
87
|
+
"""
|
|
88
|
+
if self._aggregation_map is None:
|
|
89
|
+
message = f"{self} has not yet performed an aggregation or the aggregation map was not created during aggregation."
|
|
90
|
+
raise ValueError(message)
|
|
91
|
+
return self._aggregation_map
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def _aggregate(self, model: Model) -> None:
|
|
95
|
+
"""Modify model inplace. Replace components with aggregated components according to some method."""
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def _disaggregate(
|
|
100
|
+
self,
|
|
101
|
+
model: Model,
|
|
102
|
+
original_data: dict[str, Component | TimeVector | Curve | Expr],
|
|
103
|
+
) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Modify model inplace. Restore from aggregated to original components.
|
|
106
|
+
|
|
107
|
+
Transfer any results from aggregated components to restored (disaggregated) components.
|
|
108
|
+
|
|
109
|
+
Implementers should document and handle changes in model instance between aggregation and disaggregation.
|
|
110
|
+
E.g. what to do if an aggregated component has been deleted prior to disaggregate call.
|
|
111
|
+
"""
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
def _check_is_aggregated(self) -> None:
|
|
115
|
+
if self._is_last_call_aggregate in [False, None]:
|
|
116
|
+
message = "Not aggregated. Must call aggregate and disaggregate in pairs."
|
|
117
|
+
raise RuntimeError(message)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def transfer_unambigous_memberships(group_component: Component, member_components: Iterable[Component]) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Transfer unambiguous membership metadata from member components to a group component.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
group_component : Component
|
|
127
|
+
The component to which unambiguous membership metadata will be transferred.
|
|
128
|
+
member_components : Iterable[Component]
|
|
129
|
+
The components from which membership metadata is collected.
|
|
130
|
+
|
|
131
|
+
Notes
|
|
132
|
+
-----
|
|
133
|
+
Only metadata keys with a single unique Member value among all member components are transferred.
|
|
134
|
+
Existing metadata on the group component is not overwritten.
|
|
135
|
+
|
|
136
|
+
"""
|
|
137
|
+
d = defaultdict(set)
|
|
138
|
+
for member in member_components:
|
|
139
|
+
for key in member.get_meta_keys():
|
|
140
|
+
value = member.get_meta(key)
|
|
141
|
+
if not isinstance(value, Member):
|
|
142
|
+
continue
|
|
143
|
+
d[key].add(value)
|
|
144
|
+
for key, value_set in d.items():
|
|
145
|
+
test_value = group_component.get_meta(key)
|
|
146
|
+
if test_value is not None:
|
|
147
|
+
# don't overwrite if already set
|
|
148
|
+
continue
|
|
149
|
+
if len(value_set) != 1:
|
|
150
|
+
# ambigous membership
|
|
151
|
+
continue
|
|
152
|
+
value = next(iter(value_set))
|
|
153
|
+
group_component.add_meta(key, value)
|