1515from .configure import get_runtime_config
1616from .constants import CATS_ASCII_BANNER_COLOUR , CATS_ASCII_BANNER_NO_COLOUR
1717from .plotting import plotplan
18- from .forecast import CarbonIntensityAverageEstimate , WindowedForecast
18+ from .forecast import (
19+ CarbonIntensityAverageEstimate ,
20+ WindowedForecast ,
21+ )
1922
2023__version__ = "1.1.0"
2124
@@ -28,6 +31,68 @@ def indent_lines(lines, spaces):
2831 return "\n " .join (" " * spaces + line for line in lines .split ("\n " ))
2932
3033
34+ def parse_time_constraint (
35+ time_str : str , timezone_info = None
36+ ) -> Optional [datetime .datetime ]:
37+ """
38+ Parse a time constraint string into a datetime object.
39+
40+ :param time_str: Time string in various formats (HH:MM, YYYY-MM-DDTHH:MM, etc.)
41+ :param timezone_info: Default timezone if not specified in the string
42+ :return: Parsed datetime object
43+ :raises ValueError: If the time string cannot be parsed
44+ """
45+ if not time_str :
46+ return None
47+
48+ # If timezone_info is not provided, use system local timezone
49+ if timezone_info is None :
50+ timezone_info = datetime .datetime .now ().astimezone ().tzinfo
51+
52+ # Try to parse as full ISO format first
53+ try :
54+ if "T" in time_str :
55+ # Full datetime string
56+ if time_str .endswith ("Z" ):
57+ time_str = time_str [:- 1 ] + "+00:00"
58+ elif time_str [- 6 ] not in ["+" , "-" ] and time_str [- 3 ] != ":" :
59+ # No timezone info, add default
60+ dt = datetime .datetime .fromisoformat (time_str )
61+ return dt .replace (tzinfo = timezone_info )
62+ return datetime .datetime .fromisoformat (time_str )
63+ else :
64+ # Time only (HH:MM or HH:MM:SS)
65+ time_part = datetime .time .fromisoformat (time_str )
66+ today = datetime .datetime .now ().date ()
67+ return datetime .datetime .combine (today , time_part , tzinfo = timezone_info )
68+ except ValueError as e :
69+ raise ValueError (f"Unable to parse time constraint '{ time_str } ': { e } " )
70+
71+
72+ def validate_window_constraints (
73+ start_window : Optional [datetime .datetime ],
74+ end_window : Optional [datetime .datetime ],
75+ window_minutes : int ,
76+ ) -> tuple [Optional [datetime .datetime ], Optional [datetime .datetime ], int ]:
77+ """
78+ Validate window constraints.
79+
80+ :param start_window: Start window constraint datetime
81+ :param end_window: End window constraint datetime
82+ :param window_minutes: Maximum window duration in minutes
83+ :return: Tuple of (start_datetime, end_datetime, validated_window_minutes)
84+ :raises ValueError: If constraints are invalid
85+ """
86+ if window_minutes < 1 or window_minutes > 2820 :
87+ raise ValueError ("Window must be between 1 and 2820 minutes (47 hours)" )
88+
89+ if start_window and end_window :
90+ if start_window >= end_window :
91+ raise ValueError ("Start window must be before end window" )
92+
93+ return start_window , end_window , window_minutes
94+
95+
3196def parse_arguments ():
3297 """
3398 Parse command line arguments
@@ -201,6 +266,27 @@ def positive_integer(string):
201266 "\" pip install 'climate-aware-task-scheduler[plots]'\" " ,
202267 action = "store_true" ,
203268 )
269+ parser .add_argument (
270+ "--window" ,
271+ type = positive_integer ,
272+ help = "Maximum time window to search for optimal start time, in minutes. "
273+ "Must be between 1 and 2820 (47 hours). Default: 2820 minutes (47 hours)." ,
274+ default = 2820 ,
275+ )
276+ parser .add_argument (
277+ "--start-window" ,
278+ type = parse_time_constraint ,
279+ help = "Earliest time the job is allowed to start, in ISO format (e.g., '2024-01-15T09:00'). "
280+ "If only time is provided (e.g., '09:00'), today's date is assumed. "
281+ "Timezone info is optional and defaults to system timezone." ,
282+ )
283+ parser .add_argument (
284+ "--end-window" ,
285+ type = parse_time_constraint ,
286+ help = "Latest time the job is allowed to start, in ISO format (e.g., '2024-01-15T17:00'). "
287+ "If only time is provided (e.g., '17:00'), today's date is assumed. "
288+ "Timezone info is optional and defaults to system timezone." ,
289+ )
204290
205291 return parser
206292
@@ -324,9 +410,6 @@ def main(arguments=None) -> int:
324410 args = parser .parse_args (arguments )
325411 colour_output = args .no_colour or args .no_color
326412
327- # Print CATS ASCII art banner, before any output from printing or logging
328- print_banner (colour_output )
329-
330413 if args .command and not args .scheduler :
331414 print (
332415 "cats: To run a command or sbatch script with the -c or --command option, you must\n "
@@ -335,11 +418,27 @@ def main(arguments=None) -> int:
335418 return 1
336419
337420 CI_API_interface , location , duration , jobinfo , PUE = get_runtime_config (args )
338- if duration > CI_API_interface .max_duration :
339- print (
340- f"""API allows a maximum job duration of { CI_API_interface .max_duration } minutes.
341- This is usually due to forecast limitations."""
421+
422+ # Validate and parse window constraints
423+ try :
424+ start_constraint , end_constraint , max_window = validate_window_constraints (
425+ args .start_window , args .end_window , args .window
342426 )
427+ except ValueError as e :
428+ print (f"Error in window constraints: { e } " )
429+ return 1
430+ # Check against both API limit and user-specified window
431+ effective_max_duration = min (CI_API_interface .max_duration , max_window )
432+ if duration > effective_max_duration :
433+ if max_window < CI_API_interface .max_duration :
434+ print (
435+ f"""Job duration ({ duration } minutes) exceeds specified window ({ max_window } minutes)."""
436+ )
437+ else :
438+ print (
439+ f"""API allows a maximum job duration of { CI_API_interface .max_duration } minutes.
440+ This is usually due to forecast limitations."""
441+ )
343442 return 1
344443
345444 ########################
@@ -362,8 +461,21 @@ def main(arguments=None) -> int:
362461
363462 # Find best possible average carbon intensity, along
364463 # with corresponding job start time.
464+ search_start = datetime .datetime .now ().astimezone ()
465+
466+ # Apply start window constraint if provided
467+ if start_constraint :
468+ # Ensure start constraint is in the same timezone as search_start
469+ if start_constraint .tzinfo != search_start .tzinfo :
470+ start_constraint = start_constraint .astimezone (search_start .tzinfo )
471+ search_start = max (search_start , start_constraint )
472+
365473 wf = WindowedForecast (
366- CI_forecast , duration , start = datetime .datetime .now ().astimezone ()
474+ CI_forecast ,
475+ duration ,
476+ start = search_start ,
477+ max_window_minutes = max_window ,
478+ end_constraint = end_constraint ,
367479 )
368480 now_avg , best_avg = wf [0 ], min (wf )
369481 output = CATSOutput (now_avg , best_avg , location , "GBR" , colour = not colour_output )
@@ -390,6 +502,8 @@ def main(arguments=None) -> int:
390502 dateformat = args .dateformat or ""
391503 print (output .to_json (dateformat , sort_keys = True , indent = 2 ))
392504 else :
505+ # Print CATS ASCII art banner, before any output from printing or logging
506+ print_banner (colour_output )
393507 print (output )
394508 if args .plot :
395509 plotplan (CI_forecast , output )
0 commit comments