1414LOGGER = logging .getLogger (__name__ )
1515
1616
17- def _parse_group_hours ( # noqa: PLR0912
17+ def _parse_group_hours (
1818 group_hours : dict [str , str ],
1919) -> list [tuple [datetime .time , datetime .time ]]:
2020 """
2121 Parse group hours data into a list of outage time ranges.
2222
2323 'GPV1.1': {
24- '1': 'yes',
25- ...
26- '12': 'yes',
27- '13': 'second',
28- '14': 'no',
29- '15': 'no',
30- '16': 'no',
31- '17': 'first',
32- '18': 'yes',
33- ...
34- '24': 'yes',
24+ '1': 'yes',
25+ ...
26+ '12': 'yes',
27+ '13': 'second',
28+ '14': 'no',
29+ '15': 'no',
30+ '16': 'no',
31+ '17': 'first',
32+ '18': 'yes',
33+ ...
34+ '24': 'yes',
3535 },
3636 Supports two hour formats:
3737 - Hours starting from '1' (corresponding to 0:00) up to '24'
@@ -40,122 +40,101 @@ def _parse_group_hours( # noqa: PLR0912
4040 ranges = []
4141 outage_start = None
4242
43- # Check if '0' key is present to determine hour format
44- # If '0' exists, hours are 0-23 (keys '0' to '23')
45- # If no '0', hours are 1-24 (keys '1' to '24')
46- if "0" in group_hours :
47- # Parse 24-hour format starting from 0
48- start_n , end_n = 0 , 24
49- else :
50- # Parse 24-hour format starting from 1
51- start_n , end_n = 1 , 25
52-
53- for n in range (start_n , end_n ):
54- # Calculate actual hour (0-23) from n
55- if "0" in group_hours :
56- hour = n # '0' key -> hour 0, '1' key -> hour 1, etc.
57- key = str (n )
58- else :
59- hour = n - 1 # '1' key -> hour 0, '2' key -> hour 1, etc.
60- key = str (n )
43+ hours_range = range (24 )
44+ get_key = lambda h : str (h + 1 ) # noqa: E731
45+ if "0" in group_hours : # 0-23 or 1-24 hour format
46+ get_key = str
47+
48+ def safe_time (hour : int , minute : int = 0 ) -> datetime .time :
49+ """Create datetime.time handling hour 24 as midnight (0:00)."""
50+ if hour >= 24 : # noqa: PLR2004
51+ return datetime .time (0 , minute )
52+ return datetime .time (hour , minute )
6153
62- # Get status for this hour slot
54+ for hour in hours_range :
55+ key = get_key (hour )
6356 status = group_hours .get (key , "yes" )
6457
58+ prev_key = get_key (hour - 1 ) if hour > 0 else None
59+ next_key = get_key (hour + 1 ) if hour < 23 else None # noqa: PLR2004
60+
61+ prev_status = group_hours .get (prev_key , "yes" ) if prev_key else "yes"
62+ next_status = group_hours .get (next_key , "yes" ) if next_key else "yes"
63+
6564 if status == "yes" :
66- # Power is on - close any open outage period
6765 if outage_start is not None :
68- ranges .append ((outage_start , datetime .time (hour , 0 )))
66+ ranges .append ((outage_start , safe_time (hour )))
67+ outage_start = None
68+ elif status in ("second" , "msecond" ):
69+ if prev_status == "yes" or (
70+ prev_status in ("first" , "mfirst" ) and outage_start is None
71+ ):
72+ # Start new outage at 30 minutes
73+ outage_start = safe_time (hour , 30 )
74+ elif outage_start is None :
75+ # Continue from previous outage, start at beginning of hour
76+ outage_start = safe_time (hour )
77+ elif status in ("first" , "mfirst" ):
78+ if outage_start is None :
79+ outage_start = safe_time (hour )
80+ if next_status == "yes" or (next_status in ("second" , "msecond" )):
81+ # End outage at 30 minutes
82+ ranges .append ((outage_start , safe_time (hour , 30 )))
6983 outage_start = None
70- else : # "no", "first", "mfirst", "second", "msecond" - all indicate outages
71- # Power is out - start or continue outage period
72- if outage_start is None : # Start new outage at appropriate time
73- if status in ("second" , "msecond" ):
74- outage_start = datetime .time (hour , 30 ) # Start at half-hour
75- elif status in ("first" , "mfirst" , "no" ):
76- outage_start = datetime .time (hour , 0 ) # Start at top of hour
77-
78- # Handle end times for 30-minute slots - but only end if next hour is "yes"
79- if status in ("first" , "mfirst" ):
80- next_status = group_hours .get (str (n + 1 ), "yes" )
81- if next_status == "yes" : # Only end if next hour is "yes"
82- ranges .append ((outage_start , datetime .time (hour , 30 )))
83- outage_start = None
84-
85- # Close any remaining open outage period at end of day
84+ elif status in ("no" , "maybe" ) and outage_start is None :
85+ outage_start = safe_time (hour )
86+
87+ # Close any remaining outage at end of day
8688 if outage_start is not None :
8789 ranges .append ((outage_start , datetime .time (23 , 59 , 59 )))
8890
8991 return ranges
9092
9193
92- def _parse_preset_group_hours ( # noqa: PLR0912
93- group_hours : dict [ str , str ],
94+ def _merge_ranges (
95+ ranges : list [ tuple [ datetime . time , datetime . time ] ],
9496) -> list [tuple [datetime .time , datetime .time ]]:
9597 """
96- Parse preset group hours data into a list of scheduled outage time ranges.
97-
98- Based on time_type mapping:
99- - "yes": no outage
100- - "maybe", "no", "first", "second", "mfirst", "msecond": scheduled outages
98+ Merge adjacent or overlapping time ranges.
10199
102- Handles 30-minute precision for "first"/"second"/"mfirst"/"msecond".
103- """
104- ranges = []
105- outage_start = None
100+ Args:
101+ ranges: List of time ranges to merge
106102
107- # Check if '0' key is present to determine hour format
108- # If '0' exists, hours are 0-23 (keys '0' to '23')
109- # If no '0', hours are 1-24 (keys '1' to '24')
110- if "0" in group_hours :
111- # Parse 24-hour format starting from 0
112- start_n , end_n = 0 , 24
113- else :
114- # Parse 24-hour format starting from 1
115- start_n , end_n = 1 , 25
116-
117- # Hours are 1-24 (keys '1' to '24')
118- for n in range (start_n , end_n ):
119- hour = n - 1 # '1' key -> hour 0, '2' key -> hour 1, etc.
120- key = str (n )
121-
122- # Get status for this hour slot
123- status = group_hours .get (key , "yes" )
103+ Returns:
104+ List of merged time ranges
124105
125- if status == "yes" :
126- # Power is on - close any open outage period
127- if outage_start is not None :
128- ranges .append ((outage_start , datetime .time (hour , 0 )))
129- outage_start = None
130- else : # All non-"yes" values indicate scheduled outages
131- # Power is scheduled to be out - start or continue outage period
132- if outage_start is None : # Start new outage at appropriate time
133- if status in ("second" , "msecond" ):
134- outage_start = datetime .time (hour , 30 ) # Start at half-hour
135- elif status in ("first" , "mfirst" ):
136- outage_start = datetime .time (hour , 0 ) # Start at top of hour
137- else : # "maybe", "no"
138- outage_start = datetime .time (hour , 0 ) # Start at top of hour
139-
140- # Handle end times for 30-minute slots
141- if status in ("first" , "mfirst" ):
142- # End at hour:30
143- ranges .append ((outage_start , datetime .time (hour , 30 )))
144- outage_start = None
145- elif status in ("second" , "msecond" ):
146- # End at hour:59:59 (next slot will determine continuation)
147- if outage_start is None :
148- # This is just the second half - start was previous slot
149- ranges .append (
150- (datetime .time (hour , 30 ), datetime .time (hour , 59 , 59 ))
151- )
152- # If outage_start was set, continue to next slot
106+ """
107+ if not ranges :
108+ return []
109+
110+ # Sort ranges by start time
111+ sorted_ranges = sorted (ranges , key = lambda x : x [0 ])
112+
113+ merged = []
114+ current_start , current_end = sorted_ranges [0 ]
115+
116+ for start , end in sorted_ranges [1 :]:
117+ # Check if ranges are adjacent or overlapping
118+ # For time ranges, we consider them adjacent if start <= current_end
119+ if start <= current_end :
120+ # Ranges overlap or are adjacent, merge them
121+ # If end is 59:59, use the next hour boundary
122+ if end .minute == 59 and end .second == 59 : # noqa: PLR2004
123+ if end .hour < 23 : # noqa: PLR2004
124+ current_end = datetime .time (end .hour + 1 )
125+ else :
126+ current_end = datetime .time (23 , 59 , 59 )
127+ else :
128+ current_end = max (current_end , end )
129+ else :
130+ # No overlap, add current range and start a new one
131+ merged .append ((current_start , current_end ))
132+ current_start , current_end = start , end
153133
154- # Close any remaining open outage period at end of day
155- if outage_start is not None :
156- ranges .append ((outage_start , datetime .time (23 , 59 , 59 )))
134+ # Add the last range
135+ merged .append ((current_start , current_end ))
157136
158- return ranges
137+ return merged
159138
160139
161140class DtekAPIBase :
@@ -219,7 +198,9 @@ def get_events(
219198 second = 0 ,
220199 microsecond = 0 ,
221200 )
222- if end_time .hour == 23 and end_time .minute == 59 : # noqa: PLR2004
201+ if (end_time .hour == 23 and end_time .minute == 59 ) or ( # noqa: PLR2004
202+ end_time .hour == 0 and end_time .minute == 0
203+ ):
223204 event_end = (day_dt + datetime .timedelta (days = 1 )).replace (
224205 hour = 0 ,
225206 minute = 0 ,
@@ -295,7 +276,7 @@ def get_scheduled_events(
295276 if not day_data :
296277 continue
297278
298- time_ranges = _parse_preset_group_hours (day_data )
279+ time_ranges = _parse_group_hours (day_data )
299280
300281 for start_time , end_time in time_ranges :
301282 event_start = day_start .replace (
@@ -305,7 +286,9 @@ def get_scheduled_events(
305286 microsecond = 0 ,
306287 )
307288
308- if end_time .hour == 23 and end_time .minute == 59 : # noqa: PLR2004
289+ if (end_time .hour == 23 and end_time .minute == 59 ) or ( # noqa: PLR2004
290+ end_time .hour == 0 and end_time .minute == 0
291+ ):
309292 event_end = day_end
310293 else :
311294 event_end = day_start .replace (
0 commit comments