fram-core 0.1.0a2__py3-none-any.whl → 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {fram_core-0.1.0a2.dist-info → fram_core-0.1.1.dist-info}/METADATA +4 -4
  2. fram_core-0.1.1.dist-info/RECORD +100 -0
  3. {fram_core-0.1.0a2.dist-info → fram_core-0.1.1.dist-info}/WHEEL +1 -1
  4. framcore/Base.py +22 -3
  5. framcore/Model.py +26 -9
  6. framcore/__init__.py +2 -1
  7. framcore/aggregators/Aggregator.py +30 -11
  8. framcore/aggregators/HydroAggregator.py +35 -23
  9. framcore/aggregators/NodeAggregator.py +65 -30
  10. framcore/aggregators/WindSolarAggregator.py +22 -30
  11. framcore/attributes/Arrow.py +6 -4
  12. framcore/attributes/ElasticDemand.py +13 -13
  13. framcore/attributes/ReservoirCurve.py +3 -17
  14. framcore/attributes/SoftBound.py +2 -5
  15. framcore/attributes/StartUpCost.py +14 -3
  16. framcore/attributes/Storage.py +17 -5
  17. framcore/attributes/TargetBound.py +2 -4
  18. framcore/attributes/__init__.py +2 -4
  19. framcore/attributes/hydro/HydroBypass.py +9 -2
  20. framcore/attributes/hydro/HydroGenerator.py +24 -7
  21. framcore/attributes/hydro/HydroPump.py +32 -10
  22. framcore/attributes/hydro/HydroReservoir.py +4 -4
  23. framcore/attributes/level_profile_attributes.py +250 -53
  24. framcore/components/Component.py +27 -3
  25. framcore/components/Demand.py +18 -4
  26. framcore/components/Flow.py +26 -4
  27. framcore/components/HydroModule.py +45 -4
  28. framcore/components/Node.py +32 -9
  29. framcore/components/Thermal.py +12 -8
  30. framcore/components/Transmission.py +17 -2
  31. framcore/components/wind_solar.py +25 -10
  32. framcore/curves/LoadedCurve.py +0 -9
  33. framcore/expressions/Expr.py +137 -36
  34. framcore/expressions/__init__.py +3 -1
  35. framcore/expressions/_get_constant_from_expr.py +14 -20
  36. framcore/expressions/queries.py +121 -84
  37. framcore/expressions/units.py +30 -3
  38. framcore/fingerprints/fingerprint.py +0 -1
  39. framcore/juliamodels/JuliaModel.py +13 -3
  40. framcore/loaders/loaders.py +0 -2
  41. framcore/metadata/ExprMeta.py +13 -7
  42. framcore/metadata/LevelExprMeta.py +16 -1
  43. framcore/metadata/Member.py +7 -7
  44. framcore/metadata/__init__.py +1 -1
  45. framcore/querydbs/CacheDB.py +1 -1
  46. framcore/solvers/Solver.py +21 -6
  47. framcore/solvers/SolverConfig.py +4 -4
  48. framcore/timeindexes/AverageYearRange.py +9 -2
  49. framcore/timeindexes/ConstantTimeIndex.py +7 -2
  50. framcore/timeindexes/DailyIndex.py +14 -2
  51. framcore/timeindexes/FixedFrequencyTimeIndex.py +105 -53
  52. framcore/timeindexes/HourlyIndex.py +14 -2
  53. framcore/timeindexes/IsoCalendarDay.py +5 -3
  54. framcore/timeindexes/ListTimeIndex.py +103 -23
  55. framcore/timeindexes/ModelYear.py +8 -2
  56. framcore/timeindexes/ModelYears.py +11 -2
  57. framcore/timeindexes/OneYearProfileTimeIndex.py +10 -2
  58. framcore/timeindexes/ProfileTimeIndex.py +14 -3
  59. framcore/timeindexes/SinglePeriodTimeIndex.py +1 -1
  60. framcore/timeindexes/TimeIndex.py +16 -3
  61. framcore/timeindexes/WeeklyIndex.py +14 -2
  62. framcore/{expressions → timeindexes}/_time_vector_operations.py +76 -2
  63. framcore/timevectors/ConstantTimeVector.py +12 -16
  64. framcore/timevectors/LinearTransformTimeVector.py +20 -3
  65. framcore/timevectors/ListTimeVector.py +18 -14
  66. framcore/timevectors/LoadedTimeVector.py +1 -8
  67. framcore/timevectors/ReferencePeriod.py +13 -3
  68. framcore/timevectors/TimeVector.py +26 -12
  69. framcore/utils/__init__.py +0 -1
  70. framcore/utils/get_regional_volumes.py +21 -3
  71. framcore/utils/get_supported_components.py +1 -1
  72. framcore/utils/global_energy_equivalent.py +22 -5
  73. framcore/utils/isolate_subnodes.py +12 -3
  74. framcore/utils/loaders.py +7 -7
  75. framcore/utils/node_flow_utils.py +4 -4
  76. framcore/utils/storage_subsystems.py +3 -4
  77. fram_core-0.1.0a2.dist-info/RECORD +0 -100
  78. {fram_core-0.1.0a2.dist-info → fram_core-0.1.1.dist-info}/licenses/LICENSE.md +0 -0
@@ -1,5 +1,3 @@
1
- """Definition of Solver interface."""
2
-
3
1
  import pickle
4
2
  from abc import ABC, abstractmethod
5
3
  from copy import deepcopy
@@ -10,20 +8,37 @@ from framcore.solvers import SolverConfig
10
8
 
11
9
 
12
10
  class Solver(Base, ABC):
13
- """Solver inteface class."""
11
+ """
12
+ Solver inteface class.
13
+
14
+ In FRAM we call energy market models for Solvers. They take a populated Model and configurations from a SolverConfig,
15
+ and transfers this to the solver software. Then it solves the energy market model, and writes results back to the Model.
16
+ """
14
17
 
15
18
  _FILENAME_MODEL = "model.pickle"
16
19
  _FILENAME_SOLVER = "solver.pickle"
17
20
 
18
21
  def solve(self, model: Model) -> None:
19
- """Solve the models. Use folder to write results."""
22
+ """
23
+ Inititiate the solve.
24
+
25
+ It takes the populated Model and configurations from self.SolverConfig, and transfers this to the solver software.
26
+ Then it solves the energy market model, and writes results back to the Model.
27
+
28
+ At the end of the solve, the Model (now with results) and the Solver object (with configurations) are pickled to the solve folder.
29
+ - model.pickle can be used to inspect results later.
30
+ - solver.pickle allows reuse of the same solver configurations (with solve_folder set to None to avoid overwriting).
31
+ TODO: Could also pickle the Model before solving, to have a record of the input model.
32
+
33
+ """
20
34
  self._check_type(model, Model)
21
35
 
22
36
  config = self.get_config()
23
37
 
24
38
  folder = config.get_solve_folder()
25
39
 
26
- assert folder is not None, "use Solver.get_config().set_solve_folder(folder)"
40
+ if folder is None:
41
+ raise ValueError("A folder for the Solver has not been set yet. Use Solver.get_config().set_solve_folder(folder)")
27
42
 
28
43
  Path.mkdir(folder, parents=True, exist_ok=True)
29
44
 
@@ -44,5 +59,5 @@ class Solver(Base, ABC):
44
59
 
45
60
  @abstractmethod
46
61
  def _solve(self, folder: Path, model: Model) -> None:
47
- """Solve the model inplace. Write to folder."""
62
+ """Solve the model inplace. Write to folder. Must be implemented by specific solvers."""
48
63
  pass
@@ -149,11 +149,11 @@ class SolverConfig(Base, ABC):
149
149
  self.send_warning_event(message)
150
150
 
151
151
  def get_num_cpu_cores(self) -> int:
152
- """Return number of cpu cores JulES can use."""
152
+ """Return number of cpu cores the Solver can use."""
153
153
  return self._num_cpu_cores
154
154
 
155
155
  def set_num_cpu_cores(self, n: int) -> int:
156
- """Set number of cpu cores JulES can use."""
156
+ """Set number of cpu cores the Solver can use."""
157
157
  self._num_cpu_cores = n
158
158
 
159
159
  def set_currency(self, currency: str) -> None:
@@ -166,11 +166,11 @@ class SolverConfig(Base, ABC):
166
166
  return self._currency
167
167
 
168
168
  def set_screen_output_on(self) -> None:
169
- """Print output from JulES to stdout and logfile."""
169
+ """Print output from Solver to stdout and logfile."""
170
170
  self._show_screen_output = True
171
171
 
172
172
  def set_screen_output_off(self) -> None:
173
- """Only print output from JulES to logfile."""
173
+ """Only print output from Solver to logfile."""
174
174
  self._show_screen_output = False
175
175
 
176
176
  def show_screen_output(self) -> bool:
@@ -4,10 +4,17 @@ from framcore.timeindexes.SinglePeriodTimeIndex import SinglePeriodTimeIndex #
4
4
 
5
5
 
6
6
  class AverageYearRange(SinglePeriodTimeIndex):
7
- """AverageYearRange represents an average over a range of years."""
7
+ """AverageYearRange represents a period over a range of years. No extrapolation and represents full iso calendar years."""
8
8
 
9
9
  def __init__(self, start_year: int, num_years: int) -> None:
10
- """Initialize AverageYearRange with a year range."""
10
+ """
11
+ Initialize AverageYearRange with a year range. No extrapolation and represents full iso calendar years.
12
+
13
+ Args:
14
+ start_year (int): First year in the range.
15
+ num_years (int): Number of years in the range.
16
+
17
+ """
11
18
  start_time = datetime.fromisocalendar(start_year, 1, 1)
12
19
  end_time = datetime.fromisocalendar(start_year + num_years, 1, 1)
13
20
  period_duration = end_time - start_time
@@ -4,10 +4,15 @@ from framcore.timeindexes.SinglePeriodTimeIndex import SinglePeriodTimeIndex #
4
4
 
5
5
 
6
6
  class ConstantTimeIndex(SinglePeriodTimeIndex):
7
- """Used in ConstantTimeVector."""
7
+ """
8
+ ConstantTimeIndex that is constant over time. For use in ConstantTimeVector.
9
+
10
+ Represents a period of 52 weeks starting from the iso calendar week 1 of 1985. Extrapolates both first and last point.
11
+
12
+ """
8
13
 
9
14
  def __init__(self) -> None:
10
- """Represent a specified year."""
15
+ """Initialize ConstantTimeIndex."""
11
16
  super().__init__(
12
17
  start_time=datetime.fromisocalendar(1985, 1, 1),
13
18
  period_duration=timedelta(weeks=52),
@@ -4,7 +4,11 @@ from framcore.timeindexes.ProfileTimeIndex import ProfileTimeIndex # NB! full i
4
4
 
5
5
 
6
6
  class DailyIndex(ProfileTimeIndex):
7
- """One or more whole years with daily resolution."""
7
+ """
8
+ ProfileTimeIndex with one or more whole years with daily 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 DailyIndex(ProfileTimeIndex):
12
16
  num_years: int,
13
17
  is_52_week_years: bool = True,
14
18
  ) -> None:
15
- """One or more whole years with daily resolution."""
19
+ """
20
+ Initialize DailyIndex over a number of years. 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,
@@ -6,7 +6,7 @@ from datetime import datetime, timedelta, tzinfo
6
6
  import numpy as np
7
7
  from numpy.typing import NDArray
8
8
 
9
- import framcore.expressions._time_vector_operations as v_ops
9
+ import framcore.timeindexes._time_vector_operations as v_ops
10
10
  from framcore.fingerprints import Fingerprint
11
11
  from framcore.timeindexes.TimeIndex import TimeIndex # NB! full import path needed for inheritance to work
12
12
  from framcore.timevectors import ReferencePeriod
@@ -27,32 +27,25 @@ class FixedFrequencyTimeIndex(TimeIndex):
27
27
  """
28
28
  Initialize a FixedFrequencyTimeIndex.
29
29
 
30
- Parameters
31
- ----------
32
- start_time : datetime
33
- The starting datetime of the time index.
34
- period_duration : timedelta
35
- The duration of each period.
36
- num_periods : int
37
- The number of periods in the time index.
38
- is_52_week_years : bool
39
- Whether to use 52-week years.
40
- extrapolate_first_point : bool
41
- Whether to allow extrapolation of the first point.
42
- extrapolate_last_point : bool
43
- Whether to allow extrapolation of the last point.
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.
44
37
 
45
38
  """
46
- if num_periods < 0:
47
- message = f"num_periods must be a positive integer. Got {num_periods}."
48
- raise ValueError(message)
39
+ if num_periods <= 0:
40
+ msg = f"num_periods must be a positive integer. Got {num_periods}."
41
+ raise ValueError(msg)
49
42
  if period_duration < timedelta(seconds=1):
50
- message = f"period_duration must be at least one second. Got {period_duration}."
51
- raise ValueError(message)
43
+ msg = f"period_duration must be at least one second. Got {period_duration}."
44
+ raise ValueError(msg)
52
45
  if not period_duration.total_seconds().is_integer():
53
- message = f"period_duration must be a whole number of seconds, got {period_duration.total_seconds()} s"
54
- raise ValueError(message)
55
- if is_52_week_years and start_time.isocalendar().week == 53: # original: assert start_time.isocalendar().week != 53 # noqa: PLR2004
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
56
49
  raise ValueError("Week of start_time must not be 53 when is_52_week_years is True.")
57
50
  self._check_type(num_periods, int)
58
51
  self._start_time = start_time
@@ -91,7 +84,7 @@ class FixedFrequencyTimeIndex(TimeIndex):
91
84
  def __repr__(self) -> str:
92
85
  """Return a string representation of the FixedFrequencyTimeIndex."""
93
86
  return (
94
- f"FixedFrequencyTimeIndex("
87
+ f"{type(self).__name__}("
95
88
  f"start_time={self._start_time}, "
96
89
  f"period_duration={self._period_duration}, "
97
90
  f"num_periods={self._num_periods}, "
@@ -133,7 +126,13 @@ class FixedFrequencyTimeIndex(TimeIndex):
133
126
  return self._num_periods == 1 and self._extrapolate_first_point == self._extrapolate_last_point is True
134
127
 
135
128
  def is_whole_years(self) -> bool:
136
- """Return True if index covers one or more full years."""
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
+ """
137
136
  start_time = self.get_start_time()
138
137
  start_year, start_week, start_weekday = start_time.isocalendar()
139
138
  if not start_week == start_weekday == 1:
@@ -144,7 +143,9 @@ class FixedFrequencyTimeIndex(TimeIndex):
144
143
  num_periods = self.get_num_periods()
145
144
  stop_time = start_time + num_periods * period_duration
146
145
  stop_year, stop_week, stop_weekday = stop_time.isocalendar()
147
- assert stop_year >= start_year
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)
148
149
  return stop_week == stop_weekday == 1
149
150
 
150
151
  period_duration = self.get_period_duration()
@@ -157,7 +158,6 @@ class FixedFrequencyTimeIndex(TimeIndex):
157
158
  """Get the reference period (only if is_whole_years() is True)."""
158
159
  if self.is_whole_years():
159
160
  start_year = self.get_start_time().isocalendar().year
160
-
161
161
  if self._is_52_week_years:
162
162
  num_years = (self.get_num_periods() * self.get_period_duration()) // timedelta(weeks=52)
163
163
  else:
@@ -202,7 +202,14 @@ class FixedFrequencyTimeIndex(TimeIndex):
202
202
 
203
203
  def get_period_average(self, vector: NDArray, start_time: datetime, duration: timedelta, is_52_week_years: bool) -> float:
204
204
  """Get the average over the period from the vector."""
205
- assert vector.shape == (self.get_num_periods(),)
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)
206
213
  target_timeindex = FixedFrequencyTimeIndex(
207
214
  start_time=start_time,
208
215
  period_duration=duration,
@@ -443,7 +450,14 @@ class FixedFrequencyTimeIndex(TimeIndex):
443
450
 
444
451
  def get_stop_time(self) -> datetime:
445
452
  """Get the stop time of the TimeIndex."""
446
- return self._start_time + self._period_duration * self._num_periods
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
+ )
447
461
 
448
462
  def slice(
449
463
  self,
@@ -469,6 +483,7 @@ class FixedFrequencyTimeIndex(TimeIndex):
469
483
  self._start_time,
470
484
  target_index.get_start_time(),
471
485
  self._period_duration,
486
+ self._is_52_week_years,
472
487
  )
473
488
  transformed_timeindex = self.copy_with(
474
489
  start_time=target_index.get_start_time(),
@@ -489,6 +504,7 @@ class FixedFrequencyTimeIndex(TimeIndex):
489
504
  self.get_stop_time(),
490
505
  target_index.get_stop_time(),
491
506
  self._period_duration,
507
+ self._is_52_week_years,
492
508
  )
493
509
  transformed_timeindex = self.copy_with(num_periods=self._num_periods - num_periods_to_slice)
494
510
  transformed_vector = input_vector[:-num_periods_to_slice]
@@ -512,7 +528,12 @@ class FixedFrequencyTimeIndex(TimeIndex):
512
528
  if not self._extrapolate_first_point:
513
529
  raise ValueError("Cannot extend start without extrapolation.")
514
530
 
515
- num_periods_to_extend = self._periods_between(self._start_time, target_timeindex.get_start_time(), self._period_duration)
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
+ )
516
537
  extended_vector = np.concatenate((np.full(num_periods_to_extend, input_vector[0]), input_vector))
517
538
 
518
539
  transformed_timeindex = self.copy_with(
@@ -534,6 +555,7 @@ class FixedFrequencyTimeIndex(TimeIndex):
534
555
  self.get_stop_time(),
535
556
  target_timeindex.get_stop_time(),
536
557
  self._period_duration,
558
+ self._is_52_week_years,
537
559
  )
538
560
  extended_vector = np.concatenate((input_vector, np.full(num_periods_to_extend, input_vector[-1])))
539
561
  target_timeindex = self.copy_with(num_periods=self._num_periods + num_periods_to_extend)
@@ -634,27 +656,25 @@ class FixedFrequencyTimeIndex(TimeIndex):
634
656
  if target_timeindex.get_start_time() < self._start_time:
635
657
  if self._extrapolate_first_point:
636
658
  return self._extend_start(input_vector, target_timeindex)
637
- raise ValueError(
638
- (
639
- "Cannot write into fixed frequency: incompatible time indices. "
640
- "Start time of the target index is before the start time of the source index "
641
- "and extrapolate_first_point is False.\n"
642
- f"Input timeindex: {self}\n"
643
- f"Target timeindex: {target_timeindex}"
644
- ),
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}"
645
665
  )
666
+ raise ValueError(msg)
646
667
  if target_timeindex.get_stop_time() > self.get_stop_time():
647
668
  if self._extrapolate_last_point:
648
669
  return self._extend_end(input_vector, target_timeindex)
649
- raise ValueError(
650
- (
651
- "Cannot write into fixed frequency: incompatible time indices. "
652
- "'stop_time' of the target index is after the 'stop_time' of the source index "
653
- "and 'extrapolate_last_point' is False.\n"
654
- f"Input timeindex: {self}\n"
655
- f"Target timeindex: {target_timeindex}"
656
- ),
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}"
657
676
  )
677
+ raise ValueError(msg)
658
678
  if target_timeindex.get_start_time() > self.get_start_time():
659
679
  return self._slice_start(input_vector, target_timeindex)
660
680
 
@@ -662,7 +682,7 @@ class FixedFrequencyTimeIndex(TimeIndex):
662
682
  return self._slice_end(input_vector, target_timeindex)
663
683
  return target_timeindex, input_vector
664
684
 
665
- def _periods_between(self, first_time: datetime, second_time: datetime, period_duration: timedelta) -> int:
685
+ def _periods_between(self, first_time: datetime, second_time: datetime, period_duration: timedelta, is_52_week_years: bool) -> int:
666
686
  """
667
687
  Calculate the number of periods between two times.
668
688
 
@@ -674,6 +694,8 @@ class FixedFrequencyTimeIndex(TimeIndex):
674
694
  The second time point.
675
695
  period_duration : timedelta
676
696
  The duration of each period.
697
+ is_52_week_years : bool
698
+ Whether to use 52-week years.
677
699
 
678
700
  Returns
679
701
  -------
@@ -681,7 +703,15 @@ class FixedFrequencyTimeIndex(TimeIndex):
681
703
  The number of periods between the two times.
682
704
 
683
705
  """
684
- return abs(first_time - second_time) // period_duration
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
685
715
 
686
716
  def copy_with(
687
717
  self,
@@ -740,9 +770,13 @@ class FixedFrequencyTimeIndex(TimeIndex):
740
770
  A new instance with the updated attributes.
741
771
 
742
772
  """
773
+ if reference_period is None:
774
+ raise ValueError("Cannot copy as reference period when provided reference_period is None.")
775
+
743
776
  start_year = reference_period.get_start_year()
744
777
  num_years = reference_period.get_num_years()
745
778
  start_time = datetime.fromisocalendar(start_year, 1, 1)
779
+
746
780
  if self.is_52_week_years():
747
781
  period_duration = timedelta(weeks=52 * num_years)
748
782
  else:
@@ -755,8 +789,26 @@ class FixedFrequencyTimeIndex(TimeIndex):
755
789
  )
756
790
 
757
791
  def get_datetime_list(self) -> list[datetime]:
758
- """Return list of datetime including stop time."""
759
- t = self.get_start_time()
760
- n = self.get_num_periods()
761
- d = self.get_period_duration()
762
- return [t + i * d for i in range(n + 1)]
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
@@ -4,7 +4,11 @@ from framcore.timeindexes.ProfileTimeIndex import ProfileTimeIndex # NB! full i
4
4
 
5
5
 
6
6
  class HourlyIndex(ProfileTimeIndex):
7
- """One or more whole years with hourly resolution."""
7
+ """
8
+ ProfileTimeIndex with one or more whole years with hourly 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 HourlyIndex(ProfileTimeIndex):
12
16
  num_years: int,
13
17
  is_52_week_years: bool = True,
14
18
  ) -> None:
15
- """One or more whole years with hourly resolution."""
19
+ """
20
+ Initialize HourlyIndex over a number of years. 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,
@@ -16,11 +16,13 @@ class IsoCalendarDay(SinglePeriodTimeIndex):
16
16
  """
17
17
  IsoCalendarDay represent a day from datetime.fromisocalendar(year, week, day).
18
18
 
19
- No extrapolation.
19
+ No extrapolation and is_52_week_years=False. Useful for testing.
20
20
 
21
- is_52_week_years=False
21
+ Args:
22
+ year (int): The ISO year.
23
+ week (int): The ISO week number (1-53).
24
+ day (int): The ISO weekday (1=Monday, 7=Sunday).
22
25
 
23
- Useful for testing.
24
26
  """
25
27
  super().__init__(
26
28
  start_time=datetime.fromisocalendar(year, week, day),