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,814 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from datetime import datetime, timedelta, tzinfo
5
+
6
+ import numpy as np
7
+ from numpy.typing import NDArray
8
+
9
+ import framcore.timeindexes._time_vector_operations as v_ops
10
+ from framcore.fingerprints import Fingerprint
11
+ from framcore.timeindexes.TimeIndex import TimeIndex # NB! full import path needed for inheritance to work
12
+ from framcore.timevectors import ReferencePeriod
13
+
14
+
15
+ class FixedFrequencyTimeIndex(TimeIndex):
16
+ """TimeIndex with fixed frequency."""
17
+
18
+ def __init__(
19
+ self,
20
+ start_time: datetime,
21
+ period_duration: timedelta,
22
+ num_periods: int,
23
+ is_52_week_years: bool,
24
+ extrapolate_first_point: bool,
25
+ extrapolate_last_point: bool,
26
+ ) -> None:
27
+ """
28
+ Initialize a FixedFrequencyTimeIndex.
29
+
30
+ Args:
31
+ start_time (datetime): The starting datetime of the time index.
32
+ period_duration (timedelta): The duration of each period.
33
+ num_periods (int): The number of periods in the time index. Must be greater than 0.
34
+ is_52_week_years (bool): Whether to use 52-week years.
35
+ extrapolate_first_point (bool): Whether to allow extrapolation of the first point.
36
+ extrapolate_last_point (bool): Whether to allow extrapolation of the last point.
37
+
38
+ """
39
+ if num_periods <= 0:
40
+ msg = f"num_periods must be a positive integer. Got {num_periods}."
41
+ raise ValueError(msg)
42
+ if period_duration < timedelta(seconds=1):
43
+ msg = f"period_duration must be at least one second. Got {period_duration}."
44
+ raise ValueError(msg)
45
+ if not period_duration.total_seconds().is_integer():
46
+ msg = f"period_duration must be a whole number of seconds, got {period_duration.total_seconds()} s"
47
+ raise ValueError(msg)
48
+ if is_52_week_years and start_time.isocalendar().week == 53: # noqa: PLR2004
49
+ raise ValueError("Week of start_time must not be 53 when is_52_week_years is True.")
50
+ self._check_type(num_periods, int)
51
+ self._start_time = start_time
52
+ self._period_duration = period_duration
53
+ self._num_periods = num_periods
54
+ self._is_52_week_years = is_52_week_years
55
+ self._extrapolate_first_point = extrapolate_first_point
56
+ self._extrapolate_last_point = extrapolate_last_point
57
+
58
+ def __eq__(self, other) -> bool: # noqa: ANN001
59
+ """Check if equal to other."""
60
+ if not isinstance(other, FixedFrequencyTimeIndex):
61
+ return False
62
+ return (
63
+ self._start_time == other._start_time
64
+ and self._period_duration == other._period_duration
65
+ and self._num_periods == other._num_periods
66
+ and self._is_52_week_years == other._is_52_week_years
67
+ and self._extrapolate_first_point == other._extrapolate_first_point
68
+ and self._extrapolate_last_point == other._extrapolate_last_point
69
+ )
70
+
71
+ def __hash__(self) -> int:
72
+ """Return the hash value for the FixedFrequencyTimeIndex."""
73
+ return hash(
74
+ (
75
+ self._start_time,
76
+ self._period_duration,
77
+ self._num_periods,
78
+ self._is_52_week_years,
79
+ self._extrapolate_first_point,
80
+ self._extrapolate_last_point,
81
+ ),
82
+ )
83
+
84
+ def __repr__(self) -> str:
85
+ """Return a string representation of the FixedFrequencyTimeIndex."""
86
+ return (
87
+ f"{type(self).__name__}("
88
+ f"start_time={self._start_time}, "
89
+ f"period_duration={self._period_duration}, "
90
+ f"num_periods={self._num_periods}, "
91
+ f"is_52_week_years={self._is_52_week_years}, "
92
+ f"extrapolate_first_point={self._extrapolate_first_point}, "
93
+ f"extrapolate_last_point={self._extrapolate_last_point})"
94
+ )
95
+
96
+ def get_fingerprint(self) -> Fingerprint:
97
+ """Get the fingerprint."""
98
+ return self.get_fingerprint_default()
99
+
100
+ def get_timezone(self) -> tzinfo | None:
101
+ """Get the timezone."""
102
+ return self._start_time.tzinfo
103
+
104
+ def get_start_time(self) -> datetime:
105
+ """Get the start time."""
106
+ return self._start_time
107
+
108
+ def get_period_duration(self) -> timedelta:
109
+ """Get the period duration."""
110
+ return self._period_duration
111
+
112
+ def get_num_periods(self) -> int:
113
+ """Get the number of points."""
114
+ return self._num_periods
115
+
116
+ def is_constant(self) -> bool:
117
+ """
118
+ Return True if the time index is constant (single period and both extrapolation flags are True).
119
+
120
+ Returns
121
+ -------
122
+ bool
123
+ True if the time index is constant, False otherwise.
124
+
125
+ """
126
+ return self._num_periods == 1 and self._extrapolate_first_point == self._extrapolate_last_point is True
127
+
128
+ def is_whole_years(self) -> bool:
129
+ """
130
+ Return True if index covers one or more full years.
131
+
132
+ The start_time must be the first week and weekday of a year. For real ISO time,
133
+ the stop_time must also be the first week and weekday of a year. For 52-week years,
134
+ the total duration must be an integer number of 52-week years.
135
+ """
136
+ start_time = self.get_start_time()
137
+ start_year, start_week, start_weekday = start_time.isocalendar()
138
+ if not start_week == start_weekday == 1:
139
+ return False
140
+
141
+ if not self.is_52_week_years():
142
+ period_duration = self.get_period_duration()
143
+ num_periods = self.get_num_periods()
144
+ stop_time = start_time + num_periods * period_duration
145
+ stop_year, stop_week, stop_weekday = stop_time.isocalendar()
146
+ if stop_year < start_year:
147
+ msg = f"Stop year must be after start year. Current stop year: {stop_year} and start year: {start_year}"
148
+ raise ValueError(msg)
149
+ return stop_week == stop_weekday == 1
150
+
151
+ period_duration = self.get_period_duration()
152
+ num_periods = self.get_num_periods()
153
+ seconds_52_week_year = 52 * 168 * 3600
154
+ num_years = (period_duration * num_periods).total_seconds() / seconds_52_week_year
155
+ return num_years.is_integer()
156
+
157
+ def get_reference_period(self) -> ReferencePeriod | None:
158
+ """Get the reference period (only if is_whole_years() is True)."""
159
+ if self.is_whole_years():
160
+ start_year = self.get_start_time().isocalendar().year
161
+ if self._is_52_week_years:
162
+ num_years = (self.get_num_periods() * self.get_period_duration()) // timedelta(weeks=52)
163
+ else:
164
+ stop_year = self.get_stop_time().isocalendar().year
165
+ num_years = stop_year - start_year
166
+ return ReferencePeriod(start_year=start_year, num_years=num_years)
167
+ return None
168
+
169
+ def is_52_week_years(self) -> bool:
170
+ """Return True if 52-week years and False if real ISO time."""
171
+ return self._is_52_week_years
172
+
173
+ def is_one_year(self) -> bool:
174
+ """Return True if exactly one whole year."""
175
+ start_time = self.get_start_time()
176
+ start_year, start_week, start_weekday = start_time.isocalendar()
177
+ if not start_week == start_weekday == 1:
178
+ return False
179
+
180
+ if not self.is_52_week_years():
181
+ period_duration = self.get_period_duration()
182
+ num_periods = self.get_num_periods()
183
+ stop_time = start_time + num_periods * period_duration
184
+ stop_year, stop_week, stop_weekday = stop_time.isocalendar()
185
+ if not stop_week == stop_weekday == 1:
186
+ return False
187
+ return start_year + 1 == stop_year
188
+
189
+ period_duration = self.get_period_duration()
190
+ num_periods = self.get_num_periods()
191
+ seconds_52_week_year = 52 * 168 * 3600
192
+ num_years = (period_duration * num_periods).total_seconds() / seconds_52_week_year
193
+ return num_years == 1.0
194
+
195
+ def extrapolate_first_point(self) -> bool:
196
+ """Return True if first value can be extrapolated backwards to fill missing values."""
197
+ return self._extrapolate_first_point
198
+
199
+ def extrapolate_last_point(self) -> bool:
200
+ """Return True if last value can be extrapolated forward to fill missing values."""
201
+ return self._extrapolate_last_point
202
+
203
+ def get_period_average(self, vector: NDArray, start_time: datetime, duration: timedelta, is_52_week_years: bool) -> float:
204
+ """Get the average over the period from the vector."""
205
+ self._check_type(vector, np.ndarray)
206
+ self._check_type(start_time, datetime)
207
+ self._check_type(duration, timedelta)
208
+ self._check_type(is_52_week_years, bool)
209
+
210
+ if vector.shape != (self.get_num_periods(),):
211
+ msg = f"Vector shape {vector.shape} does not match number of periods {self.get_num_periods()} of timeindex ({self})."
212
+ raise ValueError(msg)
213
+ target_timeindex = FixedFrequencyTimeIndex(
214
+ start_time=start_time,
215
+ period_duration=duration,
216
+ num_periods=1,
217
+ is_52_week_years=is_52_week_years,
218
+ extrapolate_first_point=self.extrapolate_first_point(),
219
+ extrapolate_last_point=self.extrapolate_last_point(),
220
+ )
221
+ target_vector = np.zeros(1, dtype=vector.dtype)
222
+ self.write_into_fixed_frequency(
223
+ target_vector=target_vector,
224
+ target_timeindex=target_timeindex,
225
+ input_vector=vector,
226
+ )
227
+ return target_vector[0]
228
+
229
+ def write_into_fixed_frequency(
230
+ self,
231
+ target_vector: NDArray,
232
+ target_timeindex: FixedFrequencyTimeIndex,
233
+ input_vector: NDArray,
234
+ ) -> None:
235
+ """
236
+ Write the given input_vector into the target_vector according to the target_timeindex, applying necessary transformations.
237
+
238
+ Parameters
239
+ ----------
240
+ target_vector : NDArray
241
+ The array where the input_vector will be written to, modified in place.
242
+ target_timeindex : FixedFrequencyTimeIndex
243
+ The time index defining the fixed frequency structure for writing the input_vector into the target_vector.
244
+ input_vector : NDArray
245
+ The array containing the data to be written into the target_vector.
246
+
247
+ Notes
248
+ -----
249
+ - If the object is constant (as determined by `self.is_constant()`), the input_vector is expected to have a single value,
250
+ which will be used to fill the entire target_vector.
251
+ - Otherwise, the method delegates the operation to `_write_into_fixed_frequency_recursive` for handling more complex cases.
252
+
253
+ """
254
+ if self.is_constant():
255
+ assert input_vector.size == 1
256
+ target_vector.fill(input_vector[0])
257
+ else:
258
+ self._write_into_fixed_frequency_recursive(target_vector, target_timeindex, input_vector)
259
+
260
+ def _write_into_fixed_frequency_recursive( # noqa: C901
261
+ self,
262
+ target_vector: NDArray,
263
+ target_timeindex: FixedFrequencyTimeIndex,
264
+ input_vector: NDArray,
265
+ _depth: int = 0, # only for recursion depth tracking
266
+ ) -> None:
267
+ """
268
+ Recursively write the input_vector into the target_vector according to the target_timeindex, applying necessary transformations.
269
+
270
+ Parameters
271
+ ----------
272
+ target_vector : NDArray
273
+ The array where the input_vector will be written to, modified in place.
274
+ target_timeindex : FixedFrequencyTimeIndex
275
+ The time index defining the fixed frequency structure for writing the input_vector into the target_vector.
276
+ input_vector : NDArray
277
+ The array containing the data to be written into the target_vector.
278
+
279
+ """
280
+ if _depth > 100: # noqa: PLR2004
281
+ raise RecursionError("Maximum recursion depth (100) exceeded in _write_into_fixed_frequency_recursive.")
282
+
283
+ if self == target_timeindex:
284
+ np.copyto(target_vector, input_vector)
285
+ return
286
+
287
+ transformed_timeindex = None
288
+
289
+ # Check differences between self and target_timeindex and apply transformations recursively
290
+ if not target_timeindex._is_compatible_resolution(self):
291
+ transformed_timeindex, transformed_vector = self._transform_to_compatible_resolution(input_vector, target_timeindex)
292
+
293
+ elif target_timeindex.is_52_week_years() and not self.is_52_week_years():
294
+ transformed_timeindex, transformed_vector = self._convert_to_52_week_years(input_vector=input_vector)
295
+
296
+ elif not target_timeindex.is_52_week_years() and self.is_52_week_years():
297
+ transformed_timeindex, transformed_vector = self._convert_to_iso_time(input_vector=input_vector)
298
+
299
+ elif not self._is_same_period(target_timeindex):
300
+ if self.is_one_year():
301
+ transformed_timeindex, transformed_vector = self._repeat_oneyear(input_vector, target_timeindex)
302
+ else:
303
+ transformed_timeindex, transformed_vector = self._adjust_period(input_vector, target_timeindex)
304
+
305
+ elif not self.is_same_resolution(target_timeindex):
306
+ if target_timeindex.get_period_duration() < self._period_duration:
307
+ v_ops.disaggregate(
308
+ input_vector=input_vector,
309
+ output_vector=target_vector,
310
+ is_disaggfunc_repeat=True,
311
+ )
312
+ else:
313
+ v_ops.aggregate(
314
+ input_vector=input_vector,
315
+ output_vector=target_vector,
316
+ is_aggfunc_sum=False,
317
+ )
318
+
319
+ # Recursively write the transformed vector into the target vector
320
+ if transformed_timeindex is not None:
321
+ transformed_timeindex._write_into_fixed_frequency_recursive( # noqa: SLF001
322
+ target_vector=target_vector,
323
+ target_timeindex=target_timeindex,
324
+ input_vector=transformed_vector,
325
+ _depth=_depth + 1,
326
+ )
327
+
328
+ def _convert_to_iso_time(self, input_vector: NDArray) -> tuple[FixedFrequencyTimeIndex, NDArray]:
329
+ """
330
+ Convert the input vector to ISO time format.
331
+
332
+ Parameters
333
+ ----------
334
+ input_vector : NDArray
335
+ The input vector to be transformed into ISO time format.
336
+
337
+ Returns
338
+ -------
339
+ tuple[FixedFrequencyTimeIndex, NDArray]
340
+ A tuple containing the transformed FixedFrequencyTimeIndex and the transformed input vector.
341
+
342
+ """
343
+ transformed_vector = v_ops.convert_to_isotime(input_vector=input_vector, startdate=self._start_time, period_duration=self._period_duration)
344
+
345
+ transformed_timeindex = self.copy_with(
346
+ start_time=self._start_time,
347
+ num_periods=transformed_vector.size,
348
+ is_52_week_years=False,
349
+ )
350
+
351
+ return transformed_timeindex, transformed_vector
352
+
353
+ def _convert_to_52_week_years(self, input_vector: NDArray) -> tuple[FixedFrequencyTimeIndex, NDArray]:
354
+ """
355
+ Convert the input vector to a 52-week year format.
356
+
357
+ This method adjusts the start time of the source index (if needed) and transforms the input vector to match the 52-week year format.
358
+
359
+ Parameters
360
+ ----------
361
+ input_vector : NDArray
362
+ The input vector to be transformed.
363
+ startdate : datetime
364
+ The start date of the input vector.
365
+ period_duration : timedelta
366
+ The duration of each period in the input vector.
367
+
368
+ Returns
369
+ -------
370
+ tuple[FixedFrequencyTimeIndex, NDArray]
371
+ A tuple containing the transformed FixedFrequencyTimeIndex and the transformed input vector.
372
+
373
+ """
374
+ adjusted_start_time, transformed_vector = v_ops.convert_to_modeltime(
375
+ input_vector=input_vector,
376
+ startdate=self._start_time,
377
+ period_duration=self._period_duration,
378
+ )
379
+ transformed_timeindex = self.copy_with(
380
+ start_time=adjusted_start_time,
381
+ num_periods=transformed_vector.size,
382
+ is_52_week_years=True,
383
+ )
384
+
385
+ return transformed_timeindex, transformed_vector
386
+
387
+ def _is_compatible_resolution(self, other: FixedFrequencyTimeIndex) -> bool:
388
+ """Check if the period duration and start time are compatible with another FixedFrequencyTimeIndex."""
389
+ return self._is_compatible_period(other) and self._is_compatible_starttime(other)
390
+
391
+ def _is_compatible_period(self, other: FixedFrequencyTimeIndex) -> bool:
392
+ modulus = self._period_duration.total_seconds() % other.get_period_duration().total_seconds()
393
+ return modulus == 0
394
+
395
+ def _is_compatible_starttime(self, other: FixedFrequencyTimeIndex) -> bool:
396
+ delta = abs(self._start_time - other.get_start_time()).total_seconds()
397
+ modulus = delta % other._period_duration.total_seconds()
398
+ return modulus == 0
399
+
400
+ def _transform_to_compatible_resolution(
401
+ self,
402
+ input_vector: NDArray,
403
+ target_timeindex: FixedFrequencyTimeIndex,
404
+ ) -> tuple[FixedFrequencyTimeIndex, NDArray]:
405
+ """
406
+ Transform the input vector and source time index to match the target time index resolution.
407
+
408
+ Parameters
409
+ ----------
410
+ input_vector : NDArray
411
+ The input vector to be transformed.
412
+ target_timeindex : FixedFrequencyTimeIndex
413
+ The target time index to match the resolution of.
414
+
415
+ Returns
416
+ -------
417
+ tuple[FixedFrequencyTimeIndex, NDArray]
418
+ A tuple containing the transformed FixedFrequencyTimeIndex and the transformed input vector.
419
+
420
+ """
421
+ new_period_duration = timedelta(
422
+ seconds=math.gcd(
423
+ int(self._period_duration.total_seconds()),
424
+ int(target_timeindex.get_period_duration().total_seconds()),
425
+ int((self._start_time - target_timeindex.get_start_time()).total_seconds()),
426
+ ),
427
+ )
428
+
429
+ transformed_timeindex = self.copy_with(
430
+ period_duration=new_period_duration,
431
+ num_periods=int(self._period_duration.total_seconds() // new_period_duration.total_seconds()) * self._num_periods,
432
+ )
433
+
434
+ transformed_vector = np.zeros(transformed_timeindex.get_num_periods(), dtype=input_vector.dtype)
435
+ v_ops.disaggregate(
436
+ input_vector=input_vector,
437
+ output_vector=transformed_vector,
438
+ is_disaggfunc_repeat=True,
439
+ )
440
+
441
+ return transformed_timeindex, transformed_vector
442
+
443
+ def _is_same_period(self, other: FixedFrequencyTimeIndex) -> bool:
444
+ """Check if the start and stop times are the same."""
445
+ return self._start_time == other.get_start_time() and self.get_stop_time() == other.get_stop_time()
446
+
447
+ def is_same_resolution(self, other: FixedFrequencyTimeIndex) -> bool:
448
+ """Check if the period duration is the same."""
449
+ return self._period_duration == other.get_period_duration()
450
+
451
+ def get_stop_time(self) -> datetime:
452
+ """Get the stop time of the TimeIndex."""
453
+ if not self._is_52_week_years:
454
+ return self._start_time + self._period_duration * self._num_periods
455
+
456
+ return v_ops.calculate_52_week_years_stop_time(
457
+ start_time=self._start_time,
458
+ period_duration=self._period_duration,
459
+ num_periods=self._num_periods,
460
+ )
461
+
462
+ def slice(
463
+ self,
464
+ input_vector: NDArray,
465
+ start_year: int,
466
+ num_years: int,
467
+ target_start_year: int,
468
+ target_num_years: int,
469
+ ) -> NDArray:
470
+ """Periodize the input vector to match the target timeindex."""
471
+ if self._is_52_week_years:
472
+ return v_ops.periodize_modeltime(input_vector, start_year, num_years, target_start_year, target_num_years)
473
+ return v_ops.periodize_isotime(input_vector, start_year, num_years, target_start_year, target_num_years)
474
+
475
+ def _slice_start(self, input_vector: NDArray, target_index: FixedFrequencyTimeIndex) -> tuple[FixedFrequencyTimeIndex, NDArray]:
476
+ """
477
+ Slice the input vector to match the target time index.
478
+
479
+ This method handles slicing the input vector to fit the target time index,
480
+ ensuring that the start time aligns correctly.
481
+ """
482
+ num_periods_to_slice = self._periods_between(
483
+ self._start_time,
484
+ target_index.get_start_time(),
485
+ self._period_duration,
486
+ self._is_52_week_years,
487
+ )
488
+ transformed_timeindex = self.copy_with(
489
+ start_time=target_index.get_start_time(),
490
+ num_periods=self._num_periods - num_periods_to_slice,
491
+ )
492
+ transformed_vector = input_vector[num_periods_to_slice:]
493
+
494
+ return transformed_timeindex, transformed_vector
495
+
496
+ def _slice_end(self, input_vector: NDArray, target_index: FixedFrequencyTimeIndex) -> tuple[FixedFrequencyTimeIndex, NDArray]:
497
+ """
498
+ Slice the input vector to match the target time index.
499
+
500
+ This method handles slicing the input vector to fit the target time index,
501
+ ensuring that the stop time aligns correctly.
502
+ """
503
+ num_periods_to_slice = self._periods_between(
504
+ self.get_stop_time(),
505
+ target_index.get_stop_time(),
506
+ self._period_duration,
507
+ self._is_52_week_years,
508
+ )
509
+ transformed_timeindex = self.copy_with(num_periods=self._num_periods - num_periods_to_slice)
510
+ transformed_vector = input_vector[:-num_periods_to_slice]
511
+
512
+ return transformed_timeindex, transformed_vector
513
+
514
+ def total_duration(self) -> timedelta:
515
+ """Get the duration of the TimeIndex."""
516
+ return self._period_duration * self._num_periods
517
+
518
+ def _extend_start(
519
+ self,
520
+ input_vector: NDArray,
521
+ target_timeindex: FixedFrequencyTimeIndex,
522
+ ) -> tuple[FixedFrequencyTimeIndex, NDArray]:
523
+ """
524
+ Extend the start of the input vector to match the target time index.
525
+
526
+ This method handles extrapolation of the first point if allowed.
527
+ """
528
+ if not self._extrapolate_first_point:
529
+ raise ValueError("Cannot extend start without extrapolation.")
530
+
531
+ num_periods_to_extend = self._periods_between(
532
+ self._start_time,
533
+ target_timeindex.get_start_time(),
534
+ self._period_duration,
535
+ self._is_52_week_years,
536
+ )
537
+ extended_vector = np.concatenate((np.full(num_periods_to_extend, input_vector[0]), input_vector))
538
+
539
+ transformed_timeindex = self.copy_with(
540
+ start_time=target_timeindex.get_start_time(),
541
+ num_periods=self._num_periods + num_periods_to_extend,
542
+ )
543
+
544
+ return transformed_timeindex, extended_vector
545
+
546
+ def _extend_end(
547
+ self,
548
+ input_vector: NDArray,
549
+ target_timeindex: FixedFrequencyTimeIndex,
550
+ ) -> tuple[FixedFrequencyTimeIndex, NDArray]:
551
+ if not self._extrapolate_last_point:
552
+ raise ValueError("Cannot extend end without extrapolation.")
553
+
554
+ num_periods_to_extend = self._periods_between(
555
+ self.get_stop_time(),
556
+ target_timeindex.get_stop_time(),
557
+ self._period_duration,
558
+ self._is_52_week_years,
559
+ )
560
+ extended_vector = np.concatenate((input_vector, np.full(num_periods_to_extend, input_vector[-1])))
561
+ target_timeindex = self.copy_with(num_periods=self._num_periods + num_periods_to_extend)
562
+
563
+ return target_timeindex, extended_vector
564
+
565
+ def _repeat_oneyear(self, input_vector: NDArray, target_timeindex: FixedFrequencyTimeIndex) -> tuple[FixedFrequencyTimeIndex, NDArray]:
566
+ """
567
+ Repeat the one-year time index.
568
+
569
+ This method creates a new time vector by repeating the input vector over the time period defined by the target time index.
570
+
571
+ Parameters
572
+ ----------
573
+ input_vector : NDArray
574
+ The input vector to be repeated.
575
+ target_timeindex : FixedFrequencyTimeIndex
576
+ The target time index defining the start and duration of the target period.
577
+
578
+ Returns
579
+ -------
580
+ tuple[FixedFrequencyTimeIndex, NDArray]
581
+ A tuple containing the new FixedFrequencyTimeIndex and the transformed input vector.
582
+
583
+ """
584
+ if self.is_52_week_years():
585
+ transformed_vector = self._repeat_one_year_modeltime(
586
+ input_vector=input_vector,
587
+ target_timeindex=target_timeindex,
588
+ )
589
+ else:
590
+ transformed_vector = self._repeat_one_year_isotime(
591
+ input_vector=input_vector,
592
+ target_timeindex=target_timeindex,
593
+ )
594
+ transformed_timeindex = self.copy_with(
595
+ start_time=target_timeindex.get_start_time(),
596
+ num_periods=transformed_vector.size,
597
+ )
598
+
599
+ return transformed_timeindex, transformed_vector
600
+
601
+ def _repeat_one_year_isotime(self, input_vector: NDArray, target_timeindex: FixedFrequencyTimeIndex) -> NDArray:
602
+ """
603
+ Repeat the one-year ISO time index.
604
+
605
+ This method creates a new time vector by repeating the input vector over the time period defined by the target time index.
606
+
607
+ Parameters
608
+ ----------
609
+ input_vector : NDArray
610
+ The input vector to be repeated.
611
+ target_timeindex : FixedFrequencyTimeIndex
612
+ The target time index defining the start and stop times for the repetition.
613
+
614
+ Returns
615
+ -------
616
+ NDArray
617
+ The repeated vector that matches the target time index.
618
+
619
+ """
620
+ return v_ops.repeat_oneyear_isotime(
621
+ input_vector=input_vector,
622
+ input_start_date=self._start_time,
623
+ period_duration=self.get_period_duration(),
624
+ output_start_date=target_timeindex.get_start_time(),
625
+ output_end_date=target_timeindex.get_stop_time(),
626
+ )
627
+
628
+ def _repeat_one_year_modeltime(self, input_vector: NDArray, target_timeindex: FixedFrequencyTimeIndex) -> NDArray:
629
+ """
630
+ Repeat the one-year model time index.
631
+
632
+ This method creates a new time vector by repeating the input vector over the time period defined by the target time index.
633
+
634
+ Parameters
635
+ ----------
636
+ input_vector : NDArray
637
+ The input vector to be repeated.
638
+ target_timeindex : FixedFrequencyTimeIndex
639
+ The target time index defining the start and stop times for the repetition.
640
+
641
+ Returns
642
+ -------
643
+ NDArray
644
+ The repeated vector that matches the target time index.
645
+
646
+ """
647
+ return v_ops.repeat_oneyear_modeltime(
648
+ input_vector=input_vector,
649
+ input_start_date=self._start_time,
650
+ period_duration=self.get_period_duration(),
651
+ output_start_date=target_timeindex.get_start_time(),
652
+ output_end_date=target_timeindex.get_stop_time(),
653
+ )
654
+
655
+ def _adjust_period(self, input_vector: NDArray, target_timeindex: FixedFrequencyTimeIndex) -> tuple[FixedFrequencyTimeIndex, NDArray]:
656
+ if target_timeindex.get_start_time() < self._start_time:
657
+ if self._extrapolate_first_point:
658
+ return self._extend_start(input_vector, target_timeindex)
659
+ msg = (
660
+ "Cannot write into fixed frequency: incompatible time indices. "
661
+ "Start time of the target index is before the start time of the source index "
662
+ "and extrapolate_first_point is False.\n"
663
+ f"Input timeindex: {self}\n"
664
+ f"Target timeindex: {target_timeindex}"
665
+ )
666
+ raise ValueError(msg)
667
+ if target_timeindex.get_stop_time() > self.get_stop_time():
668
+ if self._extrapolate_last_point:
669
+ return self._extend_end(input_vector, target_timeindex)
670
+ msg = (
671
+ "Cannot write into fixed frequency: incompatible time indices. "
672
+ "'stop_time' of the target index is after the 'stop_time' of the source index "
673
+ "and 'extrapolate_last_point' is False.\n"
674
+ f"Input timeindex: {self}\n"
675
+ f"Target timeindex: {target_timeindex}"
676
+ )
677
+ raise ValueError(msg)
678
+ if target_timeindex.get_start_time() > self.get_start_time():
679
+ return self._slice_start(input_vector, target_timeindex)
680
+
681
+ if target_timeindex.get_stop_time() < self.get_stop_time():
682
+ return self._slice_end(input_vector, target_timeindex)
683
+ return target_timeindex, input_vector
684
+
685
+ def _periods_between(self, first_time: datetime, second_time: datetime, period_duration: timedelta, is_52_week_years: bool) -> int:
686
+ """
687
+ Calculate the number of periods between two times.
688
+
689
+ Parameters
690
+ ----------
691
+ first_time : datetime
692
+ The first time point.
693
+ second_time : datetime
694
+ The second time point.
695
+ period_duration : timedelta
696
+ The duration of each period.
697
+ is_52_week_years : bool
698
+ Whether to use 52-week years.
699
+
700
+ Returns
701
+ -------
702
+ int
703
+ The number of periods between the two times.
704
+
705
+ """
706
+ start = min(first_time, second_time)
707
+ end = max(first_time, second_time)
708
+ total_period = end - start
709
+
710
+ if is_52_week_years:
711
+ weeks_53 = v_ops._find_all_week_53_periods(start, end) # noqa: SLF001
712
+ total_period -= timedelta(weeks=len(weeks_53))
713
+
714
+ return abs(total_period) // period_duration
715
+
716
+ def copy_with(
717
+ self,
718
+ start_time: datetime | None = None,
719
+ period_duration: timedelta | None = None,
720
+ num_periods: int | None = None,
721
+ is_52_week_years: bool | None = None,
722
+ extrapolate_first_point: bool | None = None,
723
+ extrapolate_last_point: bool | None = None,
724
+ ) -> FixedFrequencyTimeIndex:
725
+ """
726
+ Create a copy of the FixedFrequencyTimeIndex with the same attributes, allowing specific fields to be overridden.
727
+
728
+ Parameters
729
+ ----------
730
+ start_time : datetime, optional
731
+ Override for the start time.
732
+ period_duration : timedelta, optional
733
+ Override for the period duration.
734
+ num_periods : int, optional
735
+ Override for the number of periods.
736
+ is_52_week_years : bool, optional
737
+ Override for 52-week years flag.
738
+ extrapolate_first_point : bool, optional
739
+ Override for extrapolate first point flag.
740
+ extrapolate_last_point : bool, optional
741
+ Override for extrapolate last point flag.
742
+
743
+ Returns
744
+ -------
745
+ FixedFrequencyTimeIndex
746
+ A new instance with the updated attributes.
747
+
748
+ """
749
+ return FixedFrequencyTimeIndex(
750
+ start_time=start_time if start_time is not None else self._start_time,
751
+ period_duration=period_duration if period_duration is not None else self._period_duration,
752
+ num_periods=num_periods if num_periods is not None else self._num_periods,
753
+ is_52_week_years=is_52_week_years if is_52_week_years is not None else self._is_52_week_years,
754
+ extrapolate_first_point=extrapolate_first_point if extrapolate_first_point is not None else self._extrapolate_first_point,
755
+ extrapolate_last_point=extrapolate_last_point if extrapolate_last_point is not None else self._extrapolate_last_point,
756
+ )
757
+
758
+ def copy_as_reference_period(self, reference_period: ReferencePeriod) -> FixedFrequencyTimeIndex:
759
+ """
760
+ Create a copy of the FixedFrequencyTimeIndex with one period matching the given reference period.
761
+
762
+ Parameters
763
+ ----------
764
+ reference_period : ReferencePeriod
765
+ The reference period to match for the output.
766
+
767
+ Returns
768
+ -------
769
+ FixedFrequencyTimeIndex
770
+ A new instance with the updated attributes.
771
+
772
+ """
773
+ if reference_period is None:
774
+ raise ValueError("Cannot copy as reference period when provided reference_period is None.")
775
+
776
+ start_year = reference_period.get_start_year()
777
+ num_years = reference_period.get_num_years()
778
+ start_time = datetime.fromisocalendar(start_year, 1, 1)
779
+
780
+ if self.is_52_week_years():
781
+ period_duration = timedelta(weeks=52 * num_years)
782
+ else:
783
+ stop_time = datetime.fromisocalendar(start_year + num_years, 1, 1)
784
+ period_duration = stop_time - start_time
785
+ return self.copy_with(
786
+ start_time=start_time,
787
+ num_periods=1,
788
+ period_duration=period_duration,
789
+ )
790
+
791
+ def get_datetime_list(self) -> list[datetime]:
792
+ """
793
+ Return list of datetime including stop time.
794
+
795
+ Note: When `is_52_week_years` is True, the returned list will skip any datetimes that fall in week 53.
796
+ """
797
+ start_time = self.get_start_time()
798
+ num_periods = self.get_num_periods()
799
+ period_duration = self.get_period_duration()
800
+
801
+ if not self._is_52_week_years:
802
+ return [start_time + i * period_duration for i in range(num_periods + 1)]
803
+
804
+ datetime_list = []
805
+ i = 0
806
+ count = 0
807
+ while count <= num_periods:
808
+ current = start_time + i * period_duration
809
+ if current.isocalendar().week != 53: # noqa: PLR2004
810
+ datetime_list.append(current)
811
+ count += 1
812
+ i += 1
813
+
814
+ return datetime_list