fram-core 0.1.0a1__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.0a1.dist-info → fram_core-0.1.1.dist-info}/METADATA +6 -5
  2. fram_core-0.1.1.dist-info/RECORD +100 -0
  3. {fram_core-0.1.0a1.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 +37 -25
  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.0a1.dist-info/RECORD +0 -100
  78. {fram_core-0.1.0a1.dist-info → fram_core-0.1.1.dist-info}/licenses/LICENSE.md +0 -0
@@ -6,8 +6,8 @@ import numpy as np
6
6
  from numpy.typing import NDArray
7
7
 
8
8
  from framcore.fingerprints import Fingerprint
9
- from framcore.timeindexes import FixedFrequencyTimeIndex
10
- from framcore.timeindexes.TimeIndex import TimeIndex # NB! full import path needed for inheritance to work
9
+ from framcore.timeindexes import FixedFrequencyTimeIndex, TimeIndex
10
+ from framcore.timeindexes._time_vector_operations import period_duration
11
11
 
12
12
 
13
13
  class ListTimeIndex(TimeIndex):
@@ -28,15 +28,33 @@ class ListTimeIndex(TimeIndex):
28
28
  extrapolate_first_point: bool,
29
29
  extrapolate_last_point: bool,
30
30
  ) -> None:
31
- """Initialize the ListTimeIndex class."""
31
+ """
32
+ Initialize the ListTimeIndex class.
33
+
34
+ Args:
35
+ datetime_list (list[datetime]): List of datetime objects defining the time index. Must be ordered and contain more than one element.
36
+ is_52_week_years (bool): Whether to use 52-week years. If False, full iso calendar years are used.
37
+ extrapolate_first_point (bool): Whether to extrapolate the first point.
38
+ extrapolate_last_point (bool): Whether to extrapolate the last point.
39
+
40
+ Raises:
41
+ ValueError: If datetime_list has less than two elements or is not ordered.
42
+
43
+ """
32
44
  dts = datetime_list
33
45
  if len(dts) <= 1:
34
- message = f"datetime_list must contain more than one element. Got {datetime_list}"
35
- raise ValueError(message)
46
+ msg = f"datetime_list must contain more than one element. Got {datetime_list}"
47
+ raise ValueError(msg)
36
48
  if not all(dts[i] < dts[i + 1] for i in range(len(dts) - 1)):
37
- message = f"All elements of datetime_list must be smaller/lower than the succeeding element. Dates must be ordered. Got {datetime_list}."
38
- raise ValueError(message)
39
- assert len(set(dt.tzinfo for dt in dts if dt is not None)) <= 1
49
+ msg = f"All elements of datetime_list must be smaller/lower than the succeeding element. Dates must be ordered. Got {datetime_list}."
50
+ raise ValueError(msg)
51
+ if len(set(dt.tzinfo for dt in dts if dt is not None)) > 1:
52
+ msg = f"Datetime objects in datetime_list have differing time zone information: {set(dt.tzinfo for dt in dts if dt is not None)}"
53
+ raise ValueError(msg)
54
+ if is_52_week_years and any(dts[i].isocalendar().week == 53 for i in range(len(dts))): # noqa: PLR2004
55
+ msg = "When is_52_week_years is True, datetime_list should not contain week 53 datetimes."
56
+ raise ValueError(msg)
57
+
40
58
  self._datetime_list = datetime_list
41
59
  self._is_52_week_years = is_52_week_years
42
60
  self._extrapolate_first_point = extrapolate_first_point
@@ -103,27 +121,38 @@ class ListTimeIndex(TimeIndex):
103
121
  start_time = self._datetime_list[0]
104
122
  stop_time = self._datetime_list[-1]
105
123
  start_year, start_week, start_weekday = start_time.isocalendar()
124
+
125
+ if not start_weekday == start_week == 1:
126
+ return False
127
+
106
128
  if self._is_52_week_years:
107
- return (start_weekday == 1) and (start_week == 1) and (stop_time == start_time + timedelta(weeks=52))
129
+ expected_stop_time = start_time + timedelta(weeks=52)
130
+ if expected_stop_time.isocalendar().week == 53: # noqa: PLR2004
131
+ expected_stop_time += timedelta(weeks=1)
132
+ return stop_time == expected_stop_time
133
+
108
134
  stop_year, stop_week, stop_weekday = stop_time.isocalendar()
109
- return (start_year + 1 == stop_year) and (start_weekday == stop_weekday == 1) and (start_week == stop_week == 1)
135
+ return (start_year + 1 == stop_year) and (stop_weekday == stop_week == 1)
110
136
 
111
137
  def is_whole_years(self) -> bool:
112
138
  """Return True if index covers one or more full years."""
113
139
  start_time = self._datetime_list[0]
114
- start_year, start_week, start_weekday = start_time.isocalendar()
140
+ _, start_week, start_weekday = start_time.isocalendar()
141
+
115
142
  if not start_week == start_weekday == 1:
116
143
  return False
117
144
 
118
145
  stop_time = self._datetime_list[-1]
146
+
119
147
  if not self.is_52_week_years():
120
- stop_year, stop_week, stop_weekday = stop_time.isocalendar()
121
- assert stop_year >= start_year
148
+ _, stop_week, stop_weekday = stop_time.isocalendar()
122
149
  return stop_week == stop_weekday == 1
123
150
 
124
- seconds_52_week_year = 52 * 168 * 3600
125
- num_years = (stop_time - start_time).total_seconds() / seconds_52_week_year
126
- return num_years.is_integer()
151
+ total_period = self.total_duration()
152
+ total_seconds = int(total_period.total_seconds())
153
+ seconds_per_year = 52 * 7 * 24 * 3600
154
+
155
+ return total_seconds % seconds_per_year == 0
127
156
 
128
157
  def extrapolate_first_point(self) -> bool:
129
158
  """Check if the TimeIndex should extrapolate the first point."""
@@ -135,7 +164,31 @@ class ListTimeIndex(TimeIndex):
135
164
 
136
165
  def get_period_average(self, vector: NDArray, start_time: datetime, duration: timedelta, is_52_week_years: bool) -> float:
137
166
  """Get the average over the period from the vector."""
138
- # assert vector.shape == (self.get_num_periods(),), f"Vector shape {vector.shape} does not match timeindex {self}"
167
+ self._check_type(vector, np.ndarray)
168
+ self._check_type(start_time, datetime)
169
+ self._check_type(duration, timedelta)
170
+ self._check_type(is_52_week_years, bool)
171
+
172
+ if vector.shape != (self.get_num_periods(),):
173
+ msg = f"Vector shape {vector.shape} does not match number of periods {self.get_num_periods()} of timeindex ({self})."
174
+ raise ValueError(msg)
175
+
176
+ if not self.extrapolate_first_point():
177
+ if start_time < self._datetime_list[0]:
178
+ msg = f"start_time {start_time} is before start of timeindex {self._datetime_list[0]}, and extrapolate_first_point is False."
179
+ raise ValueError(msg)
180
+ if (start_time + duration) < self._datetime_list[0]:
181
+ msg = f"End time {start_time + duration} is before start of timeindex {self._datetime_list[0]}, and extrapolate_first_point is False."
182
+ raise ValueError(msg)
183
+
184
+ if not self.extrapolate_last_point():
185
+ if (start_time + duration) > self._datetime_list[-1]:
186
+ msg = f"End time {start_time + duration} is after end of timeindex {self._datetime_list[-1]}, and extrapolate_last_point is False."
187
+ raise ValueError(msg)
188
+ if start_time > self._datetime_list[-1]:
189
+ msg = f"start_time {start_time} is after end of timeindex {self._datetime_list[-1]}, and extrapolate_last_point is False."
190
+ raise ValueError(msg)
191
+
139
192
  target_timeindex = FixedFrequencyTimeIndex(
140
193
  start_time=start_time,
141
194
  period_duration=duration,
@@ -144,6 +197,7 @@ class ListTimeIndex(TimeIndex):
144
197
  extrapolate_first_point=self.extrapolate_first_point(),
145
198
  extrapolate_last_point=self.extrapolate_last_point(),
146
199
  )
200
+
147
201
  target_vector = np.zeros(1, dtype=vector.dtype)
148
202
  self.write_into_fixed_frequency(
149
203
  target_vector=target_vector,
@@ -158,18 +212,22 @@ class ListTimeIndex(TimeIndex):
158
212
  target_timeindex: FixedFrequencyTimeIndex,
159
213
  input_vector: NDArray,
160
214
  ) -> None:
161
- """Write the input vector into the target vector using the target timeindex."""
215
+ """Write the input vector into the target vector using the target FixedFrequencyTimeIndex."""
216
+ self._check_type(target_vector, np.ndarray)
217
+ self._check_type(target_timeindex, FixedFrequencyTimeIndex)
218
+ self._check_type(input_vector, np.ndarray)
219
+
162
220
  dts: list[datetime] = self._datetime_list
163
221
 
164
- durations = set(self._microseconds(dts[i + 1] - dts[i]) for i in range(len(dts) - 1))
222
+ durations = set(self._microseconds(period_duration(dts[i], dts[i + 1], self._is_52_week_years)) for i in range(len(dts) - 1))
165
223
  smallest_common_period_duration = functools.reduce(math.gcd, durations)
166
224
 
167
- num_periods_ff = self._microseconds(dts[-1] - dts[0]) // smallest_common_period_duration
225
+ num_periods_ff = self._microseconds(self.total_duration()) // smallest_common_period_duration
168
226
  input_vector_ff = np.zeros(num_periods_ff, dtype=target_vector.dtype)
169
227
 
170
228
  i_start_ff = 0
171
229
  for i in range(len(dts) - 1):
172
- num_periods = self._microseconds(dts[i + 1] - dts[i]) // smallest_common_period_duration
230
+ num_periods = self._microseconds(period_duration(dts[i], dts[i + 1], self._is_52_week_years)) // smallest_common_period_duration
173
231
  i_stop_ff = i_start_ff + num_periods
174
232
  input_vector_ff[i_start_ff:i_stop_ff] = input_vector[i]
175
233
  i_start_ff = i_stop_ff
@@ -189,9 +247,31 @@ class ListTimeIndex(TimeIndex):
189
247
  input_vector=input_vector_ff,
190
248
  )
191
249
 
250
+ def total_duration(self) -> timedelta:
251
+ """
252
+ Return the total duration covered by the time index.
253
+
254
+ Returns
255
+ -------
256
+ timedelta
257
+ The duration from the first to the last datetime in the index, skipping all weeks 53 periods if 52-week time format.
258
+
259
+ """
260
+ start_time = self._datetime_list[0]
261
+ end_time = self._datetime_list[-1]
262
+ return period_duration(start_time, end_time, self.is_52_week_years())
263
+
192
264
  def _microseconds(self, duration: timedelta) -> int:
193
265
  return int(duration.total_seconds() * 1e6)
194
266
 
195
267
  def is_constant(self) -> bool:
196
- """Check if the time index is constant."""
197
- return super().is_constant()
268
+ """
269
+ Return True if the time index is constant (single period and both extrapolation flags are True).
270
+
271
+ Returns
272
+ -------
273
+ bool
274
+ True if the time index is constant, False otherwise.
275
+
276
+ """
277
+ return self.get_num_periods() == 1 and self.extrapolate_first_point() == self.extrapolate_last_point() is True
@@ -4,10 +4,16 @@ from framcore.timeindexes.SinglePeriodTimeIndex import SinglePeriodTimeIndex #
4
4
 
5
5
 
6
6
  class ModelYear(SinglePeriodTimeIndex):
7
- """ModelYear represent one 52-week-year. No extrapolation."""
7
+ """ModelYear represent a period of 52 weeks starting from the iso calendar week 1 of a specified year. No extrapolation."""
8
8
 
9
9
  def __init__(self, year: int) -> None:
10
- """Represent a specified year. Use 52-week-year starting on monday in week 1. No extrapolation."""
10
+ """
11
+ Initialize ModelYear to a period of 52 weeks starting from the iso calendar week 1 of the specified year. No extrapolation.
12
+
13
+ Args:
14
+ year (int): Year to represent.
15
+
16
+ """
11
17
  super().__init__(
12
18
  start_time=datetime.fromisocalendar(year, 1, 1),
13
19
  period_duration=timedelta(weeks=52),
@@ -4,10 +4,19 @@ from framcore.timeindexes.ListTimeIndex import ListTimeIndex # NB! full import
4
4
 
5
5
 
6
6
  class ModelYears(ListTimeIndex):
7
- """ModelYears represents a collection of years as a ListTimeIndex."""
7
+ """ModelYears represents a collection of years as a ListTimeIndex. Extrapolation is enabled and full iso calendar is used."""
8
8
 
9
9
  def __init__(self, years: list[int]) -> None:
10
- """Initialize ModelYears with a list of years."""
10
+ """
11
+ Initialize ModelYears with a list of years.
12
+
13
+ Args:
14
+ years (list[int]): List of years to represent.
15
+
16
+ """
17
+ if not years:
18
+ raise ValueError("At least one year must be provided.")
19
+
11
20
  datetime_list = [datetime.fromisocalendar(year, 1, 1) for year in years]
12
21
  datetime_list.append(datetime.fromisocalendar(years[-1] + 1, 1, 1))
13
22
  super().__init__(
@@ -4,12 +4,20 @@ from framcore.timeindexes.ProfileTimeIndex import ProfileTimeIndex # NB! full i
4
4
 
5
5
 
6
6
  class OneYearProfileTimeIndex(ProfileTimeIndex):
7
- """Fixed frequency over one year."""
7
+ """
8
+ ProfileTimeIndex with fixed frequency over one year of either 52 or 53 weeks. No extrapolation inherited from ProfileTimeIndex.
9
+
10
+ Attributes:
11
+ period_duration (timedelta): Duration of each period.
12
+ is_52_week_years (bool): Whether to use 52-week years.
13
+
14
+ """
8
15
 
9
16
  def __init__(self, period_duration: timedelta, is_52_week_years: bool) -> None:
10
17
  """
11
- Initialize a OneYearProfileTimeIndex with a fixed frequency over one year.
18
+ Initialize a ProfileTimeIndex with a fixed frequency over one year.
12
19
 
20
+ If is_52_week_years is True, the period_duration must divide evenly into 52 weeks. If False, it must divide evenly into 53 weeks.
13
21
  We use 1982 for 52-week years and 1981 for 53-week years.
14
22
 
15
23
  Args:
@@ -4,7 +4,7 @@ from framcore.timeindexes.FixedFrequencyTimeIndex import FixedFrequencyTimeIndex
4
4
 
5
5
 
6
6
  class ProfileTimeIndex(FixedFrequencyTimeIndex):
7
- """ProfileTimeIndex represent one or more whole years with fixed time resolution standard."""
7
+ """ProfileTimeIndex represent one or more whole years with fixed time resolution standard. No extrapolation."""
8
8
 
9
9
  def __init__(
10
10
  self,
@@ -13,14 +13,25 @@ class ProfileTimeIndex(FixedFrequencyTimeIndex):
13
13
  period_duration: timedelta,
14
14
  is_52_week_years: bool,
15
15
  ) -> None:
16
- """Initialize the ProfileTimeIndex."""
16
+ """
17
+ Initialize the ProfileTimeIndex. No extrapolation.
18
+
19
+ Args:
20
+ start_year (int): First year in the index.
21
+ num_years (int): Number of years in the index.
22
+ period_duration (timedelta): Duration of each period in the index.
23
+ is_52_week_years (bool): Whether to use 52-week years. If False, full iso calendar years are used.
24
+
25
+ """
17
26
  start_time = datetime.fromisocalendar(start_year, 1, 1)
18
27
  if not is_52_week_years:
19
28
  stop_time = datetime.fromisocalendar(start_year + num_years, 1, 1)
20
29
  num_periods = (stop_time - start_time).total_seconds() / period_duration.total_seconds()
21
30
  else:
22
31
  num_periods = timedelta(weeks=52 * num_years).total_seconds() / period_duration.total_seconds()
23
- assert num_periods.is_integer()
32
+ if not num_periods.is_integer():
33
+ msg = f"Number of periods derived from input arguments must be an integer/whole number. Got {num_periods}."
34
+ raise ValueError(msg)
24
35
  num_periods = int(num_periods)
25
36
  super().__init__(
26
37
  start_time=start_time,
@@ -6,7 +6,7 @@ from framcore.timeindexes.FixedFrequencyTimeIndex import FixedFrequencyTimeIndex
6
6
 
7
7
 
8
8
  class SinglePeriodTimeIndex(FixedFrequencyTimeIndex):
9
- """FrequencyTimeIndex with just one single step."""
9
+ """FixedFrequencyTimeIndex with just one single step."""
10
10
 
11
11
  def __init__(
12
12
  self,
@@ -48,8 +48,7 @@ class TimeIndex(Base, ABC):
48
48
  """
49
49
  Check if the TimeIndex represents a single year.
50
50
 
51
- Must be False if
52
- extrapolate_first_point and or extrapolate_last_point is True.
51
+ Must be False if extrapolate_first_point and or extrapolate_last_point is True.
53
52
 
54
53
  When True, can be repeted in profiles.
55
54
  """
@@ -57,6 +56,7 @@ class TimeIndex(Base, ABC):
57
56
 
58
57
  @abstractmethod
59
58
  def is_whole_years(self) -> bool:
59
+ """Check if the TimeIndex represents whole years."""
60
60
  pass
61
61
 
62
62
  @abstractmethod
@@ -81,7 +81,20 @@ class TimeIndex(Base, ABC):
81
81
  target_timeindex: FixedFrequencyTimeIndex,
82
82
  input_vector: NDArray,
83
83
  ) -> None:
84
- """Write the input vector into the target vector based on the target FixedFrequencyTimeIndex."""
84
+ """
85
+ Write the input vector into the target vector based on the target FixedFrequencyTimeIndex.
86
+
87
+ Main functionality in FRAM to extracts data to the correct time period and resolution.
88
+ A conversion of the data into a specific time period and resolution follows these steps:
89
+ - If the TimeIndex is not a FixedFrequencyTimeIndex, convert the TimeIndex and the vector to this format.
90
+ - Then convert the data according to the target TimeIndex.
91
+ - It is easier to efficiently do time series operations between FixedFrequencyTimeIndex
92
+ and we only need to implement all the other conversion functionality once here.
93
+ For example, converting between 52-week and ISO-time TimeVectors, selecting a period, extrapolation or changing the resolution.
94
+ - And when we implement a new TimeIndex, we only need to implement the conversion to FixedFrequencyTimeIndex
95
+ and the rest of the conversion functionality can be reused.
96
+
97
+ """
85
98
  pass
86
99
 
87
100
  @abstractmethod
@@ -4,7 +4,11 @@ from framcore.timeindexes.ProfileTimeIndex import ProfileTimeIndex # NB! full i
4
4
 
5
5
 
6
6
  class WeeklyIndex(ProfileTimeIndex):
7
- """One or more whole years with weekly resolution."""
7
+ """
8
+ ProfileTimeIndex with one or more whole years with weekly resolution. Either years with 52 weeks or full iso calendar years.
9
+
10
+ No extrapolation inherited from ProfileTimeIndex.
11
+ """
8
12
 
9
13
  def __init__(
10
14
  self,
@@ -12,7 +16,15 @@ class WeeklyIndex(ProfileTimeIndex):
12
16
  num_years: int,
13
17
  is_52_week_years: bool = True,
14
18
  ) -> None:
15
- """One or more whole years with weekly resolution."""
19
+ """
20
+ Initialize WeeklyIndex with one or more whole years with weekly resolution. Either years with 52 weeks or full iso calendar years.
21
+
22
+ Args:
23
+ start_year (int): First year in the index.
24
+ num_years (int): Number of years in the index.
25
+ is_52_week_years (bool, optional): Whether to use 52-week years. If False, full iso calendar years are used. Defaults to True.
26
+
27
+ """
16
28
  super().__init__(
17
29
  start_year=start_year,
18
30
  num_years=num_years,
@@ -80,7 +80,11 @@ def convert_to_modeltime(input_vector: NDArray, startdate: datetime, period_dura
80
80
  # check if the remaining period is compatible with the target period duration
81
81
  if remaining_period % period_duration != timedelta(0):
82
82
  suggested_period_duration = _common_compatible_period_duration(whole_duration, remaining_period)
83
- err_message = f"Incompatible period duration detected! The resulting vector would be incompatible with period duration of {period_duration} after week 53 data is removed. Solution: use period duration that is compatible with both input and resulting vectors. Suggested period duration: {suggested_period_duration}."
83
+ err_message = (
84
+ f"Incompatible period duration detected! The resulting vector would be incompatible with period duration of {period_duration} "
85
+ f"after week 53 data is removed. Solution: use period duration that is compatible with both input and resulting vectors. "
86
+ f"Suggested period duration: {suggested_period_duration}."
87
+ )
84
88
  raise ValueError(err_message)
85
89
 
86
90
  sub_periods = _find_all_sub_periods(startdate, end_date, week_53_periods)
@@ -199,7 +203,12 @@ def convert_to_isotime(
199
203
  # check if the extended period is compatible with the target period duration
200
204
  if extended_total_duration % period_duration != timedelta(0):
201
205
  suggested_period_duration = _common_compatible_period_duration(total_duration, extended_total_duration)
202
- err_message = f"Incompatible period duration detected when converting to ISO-time! The resulting vector would be incompatible with period duration of {period_duration} after week 53 data is added. Solution: use period duration that is compatible with both input and resulting vectors. Suggested period duration: {suggested_period_duration}."
206
+ err_message = (
207
+ f"Incompatible period duration detected when converting to ISO-time! "
208
+ f"The resulting vector would be incompatible with period duration of {period_duration} "
209
+ f"after week 53 data is added. Solution: use period duration that is compatible with both "
210
+ f"input and resulting vectors. Suggested period duration: {suggested_period_duration}."
211
+ )
203
212
  raise ValueError(err_message)
204
213
 
205
214
  sub_periods = _find_all_sub_periods(startdate, end_date, week_53_periods)
@@ -613,3 +622,68 @@ def _find_all_week_53_periods(startdate: datetime, enddate: datetime) -> list[tu
613
622
  if start < end:
614
623
  week_53_periods.append((start, end))
615
624
  return week_53_periods
625
+
626
+ def calculate_52_week_years_stop_time(
627
+ start_time: datetime,
628
+ period_duration: timedelta,
629
+ num_periods: int,
630
+ ) -> datetime:
631
+ """
632
+ Calculate the stop time of an isotime time series.
633
+
634
+ Args:
635
+ start_time (datetime): The start date of the time series.
636
+ period_duration (timedelta): The duration of each period in the time series.
637
+ num_periods (int): The number of periods in the time series.
638
+
639
+ Returns:
640
+ datetime: The calculated stop time of the time series.
641
+
642
+ """
643
+ assert isinstance(start_time, datetime)
644
+ assert isinstance(period_duration, timedelta)
645
+ assert period_duration.total_seconds() > 0, "Period duration must be greater than zero."
646
+ assert isinstance(num_periods, int)
647
+ assert num_periods > 0, "Number of periods must be greater than zero."
648
+
649
+ stop_time = start_time + period_duration * num_periods
650
+ week_53_periods = _find_all_week_53_periods(startdate=start_time, enddate=stop_time)
651
+
652
+ if week_53_periods:
653
+ stop_time += timedelta(weeks=len(week_53_periods))
654
+
655
+ if stop_time.isocalendar().week == 53:
656
+ stop_time += timedelta(weeks=1)
657
+
658
+ return stop_time
659
+
660
+ def period_duration(start_time: datetime, end_time: datetime, is_52_week_years: bool) -> timedelta:
661
+ if not is_52_week_years:
662
+ return end_time - start_time
663
+
664
+ return _period_duration_excluded_weeks_53(start_time, end_time)
665
+
666
+ def _period_duration_excluded_weeks_53(start_time: datetime, end_time: datetime) -> timedelta:
667
+ """
668
+ Calculate the period duration excluding all week 53 periods.
669
+
670
+ Parameters
671
+ ----------
672
+ start_time : datetime
673
+ The start datetime.
674
+ end_time : datetime
675
+ The end datetime.
676
+
677
+ Returns
678
+ -------
679
+ timedelta
680
+ The period duration excluding all week 53 periods.
681
+
682
+ """
683
+ if end_time < start_time:
684
+ raise ValueError("end_time must be after or equal to start_time")
685
+
686
+ week_53_periods = _find_all_week_53_periods(startdate=start_time, enddate=end_time)
687
+ excluded_duration = _total_duration(week_53_periods)
688
+ total_duration = end_time - start_time
689
+ return total_duration - excluded_duration
@@ -19,14 +19,14 @@ class ConstantTimeVector(TimeVector):
19
19
  reference_period: ReferencePeriod | None = None,
20
20
  ) -> None:
21
21
  """
22
- Initialize the ListTimeVector class.
22
+ Initialize the ConstantTimeVector class.
23
23
 
24
24
  Args:
25
- scalar (float): Constant float value of the timevector.
25
+ scalar (float): Constant float value of the TimeVector.
26
26
  unit (str | None): Unit of the value in the vector.
27
27
  is_max_level (bool | None): Whether the vector represents the maximum level, average level given a
28
28
  reference period, or not a level at all.
29
- is_zero_one_profile (bool | None): Whether the vector represents aprofile with values between 0 and 1, a
29
+ is_zero_one_profile (bool | None): Whether the vector represents a profile with values between 0 and 1, a
30
30
  profile with values averaging to 1 over a given reference period, or is
31
31
  not a profile.
32
32
  reference_period (ReferencePeriod | None, optional): Given reference period if the vector represents average
@@ -39,14 +39,6 @@ class ConstantTimeVector(TimeVector):
39
39
  """
40
40
  self._scalar = float(scalar)
41
41
  self._unit = unit
42
-
43
- if (is_max_level is not None and is_zero_one_profile is not None) or (is_max_level is None and is_zero_one_profile is None):
44
- message = (
45
- f"Input arguments for {self}: Must have exactly one 'non-None'"
46
- "value for is_max_level and is_zero_one_profile. "
47
- "A TimeVector is either a level or a profile."
48
- )
49
- raise ValueError(message)
50
42
  self._is_max_level = is_max_level
51
43
  self._is_zero_one_profile = is_zero_one_profile
52
44
  self._reference_period = reference_period
@@ -57,13 +49,14 @@ class ConstantTimeVector(TimeVector):
57
49
  self._check_type(is_zero_one_profile, (bool, type(None)))
58
50
  self._check_type(reference_period, (ReferencePeriod, type(None)))
59
51
 
52
+ self._check_is_level_or_profile()
53
+
60
54
  def __repr__(self) -> str:
61
55
  """Return the string representation of the ConstantTimeVector."""
62
56
  ref_period = None
63
57
  if self._reference_period is not None:
64
58
  start_year = self._reference_period.get_start_year()
65
59
  num_years = self._reference_period.get_num_years()
66
- assert num_years > 0
67
60
  ref_period = f"{start_year}-{start_year + num_years - 1}"
68
61
  unit = f", unit={self._unit}" if self._unit is not None else ""
69
62
  ref_period = f", reference_period={ref_period}" if ref_period is not None else ""
@@ -73,7 +66,7 @@ class ConstantTimeVector(TimeVector):
73
66
  def __eq__(self, other: object) -> bool:
74
67
  """Check equality between two ConstantTimeVector objects."""
75
68
  if not isinstance(other, ConstantTimeVector):
76
- return NotImplemented
69
+ return False
77
70
  return (
78
71
  self._scalar == other._scalar
79
72
  and self._unit == other._unit
@@ -88,7 +81,10 @@ class ConstantTimeVector(TimeVector):
88
81
 
89
82
  def get_expr_str(self) -> str:
90
83
  """Simpler representation of self to show in Expr."""
91
- return f"{self._scalar} {self._unit if self._unit else ''}"
84
+ if self._unit:
85
+ return f"{self._scalar} {self._unit}"
86
+
87
+ return f"{self._scalar}"
92
88
 
93
89
  def get_vector(self, is_float32: bool) -> NDArray:
94
90
  """Get the values of the TimeVector."""
@@ -105,11 +101,11 @@ class ConstantTimeVector(TimeVector):
105
101
  """Check if the TimeVector is constant."""
106
102
  return True
107
103
 
108
- def is_max_level(self) -> bool:
104
+ def is_max_level(self) -> bool | None:
109
105
  """Check if TimeVector is a level representing maximum Volume/Capacity."""
110
106
  return self._is_max_level
111
107
 
112
- def is_zero_one_profile(self) -> bool:
108
+ def is_zero_one_profile(self) -> bool | None:
113
109
  """Check if TimeVector is a profile with values between zero and one."""
114
110
  return self._is_zero_one_profile
115
111
 
@@ -9,7 +9,7 @@ from framcore.timevectors.TimeVector import TimeVector # NB! full import path n
9
9
 
10
10
 
11
11
  class LinearTransformTimeVector(TimeVector):
12
- """LinearTransformTimeVector(TimeVector). Immutable."""
12
+ """LinearTransformTimeVector represents a TimeVector as scale * timevector + shift. Immutable."""
13
13
 
14
14
  def __init__(
15
15
  self,
@@ -22,9 +22,24 @@ class LinearTransformTimeVector(TimeVector):
22
22
  reference_period: ReferencePeriod | None = None,
23
23
  ) -> None:
24
24
  """
25
- Transform timevector into scale * timevector + shift.
25
+ Initialize LinearTransformTimeVector with a TimeVector, scale and shift.
26
+
27
+ May also override unit, is_max_level, is_zero_one_profile and reference_period of the original timevector.
28
+
29
+ Args:
30
+ timevector (TimeVector): TimeVector.
31
+ scale (float): Scale factor.
32
+ shift (float): Shift value.
33
+ unit (str | None): Unit of the values in the transformed vector.
34
+ is_max_level (bool | None, optional): Whether the transformed vector represents the maximum level,
35
+ average level given a reference period, or not a level at all.
36
+ Defaults to None.
37
+ is_zero_one_profile (bool | None, optional): Whether the transformed vector represents a profile with values
38
+ between 0 and 1, a profile with values averaging to 1 over a given
39
+ reference period, or is not a profile. Defaults to None.
40
+ reference_period (ReferencePeriod | None, optional): Given reference period if the transformed vector
41
+ represents average level or mean one profile. Defaults to None.
26
42
 
27
- May also override unit, is_max_level, is_zero_one_profile and reference_period.
28
43
  """
29
44
  self._check_type(timevector, TimeVector)
30
45
  self._check_type(scale, float)
@@ -41,6 +56,8 @@ class LinearTransformTimeVector(TimeVector):
41
56
  self._is_zero_one_profile = is_zero_one_profile
42
57
  self._reference_period = reference_period
43
58
 
59
+ self._check_is_level_or_profile()
60
+
44
61
  def get_vector(self, is_float32: bool) -> NDArray:
45
62
  """Get the values of the TimeVector."""
46
63
  vector = self._timevector.get_vector(is_float32)