File Coverage

blib/lib/NBI/EcoScheduler.pm
Criterion Covered Total %
statement 77 79 97.4
branch 34 36 94.4
condition 24 43 55.8
subroutine 11 11 100.0
pod 3 3 100.0
total 149 172 86.6


line stmt bran cond sub pod time code
1             package NBI::EcoScheduler;
2             #ABSTRACT: Find energy-efficient SLURM job start times
3             #
4             # NBI::EcoScheduler - Selects low-energy-price windows for SLURM jobs.
5             #
6             # DESCRIPTION:
7             # Given a job's walltime (hours) and an optional config hash, returns the
8             # next available epoch timestamp that falls within a "cheap energy" window.
9             # Three tiers of slots are considered:
10             # Tier 1 - job fits entirely inside an eco window AND avoids peak hours
11             # Tier 2 - job starts in an eco window, avoids peak hours, but overruns the window
12             # Tier 3 - job starts in an eco window but overlaps peak hours (fallback)
13             #
14             # Public functions:
15             # - find_eco_begin($duration_h, $config, $now) -> ($epoch, $tier) or (undef,undef)
16             # - epoch_to_slurm($epoch) -> "YYYY-MM-DDTHH:MM:SS"
17             # - format_delay($epoch, $now) -> "2h 05m" / "now"
18             #
19             # Private helpers:
20             # - _windows_for_day($midnight, $dow, $cfg) -> list of [start,end] epochs
21             # - _avoid_for_day($midnight, $cfg) -> list of [start,end] epochs
22             # - _parse_window_string($str, $midnight) -> list of [start,end] epochs
23             # - _job_overlaps_avoid($start, $dur_h, \@avoid) -> bool
24             #
25             # RELATIONSHIPS:
26             # - Called by bin/runjob when eco mode is active.
27             # - Results fed into NBI::Opts->{start_date} / NBI::Opts->{start_time}.
28             # - Config hash comes from NBI::Slurm::load_config().
29             #
30              
31 2     2   426451 use strict;
  2         5  
  2         121  
32 2     2   14 use warnings;
  2         5  
  2         142  
33 2     2   18 use POSIX qw(mktime strftime);
  2         5  
  2         19  
34 2     2   258 use Carp qw(carp);
  2         5  
  2         3366  
35              
36             $NBI::EcoScheduler::VERSION = $NBI::Slurm::VERSION // '0.17.0';
37              
38             # ---------------------------------------------------------------------------
39             # Hardcoded defaults — used when config keys are absent.
40             # Mon-Fri: 00:00-06:00 (night window before peak morning commute)
41             # Sat-Sun: 00:00-07:00 and 11:00-16:00 (weekend has longer cheap windows)
42             # Avoid every day: 17:00-20:00 (evening peak)
43             # ---------------------------------------------------------------------------
44             our %DEFAULTS = (
45             eco_windows_weekday => '00:00-06:00',
46             eco_windows_weekend => '00:00-07:00,11:00-16:00',
47             eco_avoid => '17:00-20:00',
48             eco_lookahead_days => 3,
49             eco_default => 1,
50             );
51              
52             # ---------------------------------------------------------------------------
53             # Public API
54             # ---------------------------------------------------------------------------
55              
56             sub find_eco_begin {
57             # Find the next eco-friendly start epoch for a job of $duration_h hours.
58             #
59             # Parameters:
60             # $duration_h - job walltime in hours (default: 1)
61             # $config - hashref from load_config(), may be empty or undef
62             # $now - current epoch (defaults to time(); injectable for testing)
63             #
64             # Returns: ($begin_epoch, $tier) or (undef, undef) if nothing found.
65             # $tier 1 = perfect: fits in window, avoids peak
66             # $tier 2 = acceptable: avoids peak but overruns eco window
67             # $tier 3 = fallback: starts in eco window but overlaps peak hours
68 9     9 1 12075 my ($duration_h, $config, $now) = @_;
69 9   50     30 $duration_h //= 1;
70 9   50     24 $config //= {};
71 9   33     22 $now //= time();
72              
73             # Caller config overrides defaults
74 9         60 my %cfg = (%DEFAULTS, %$config);
75 9         28 my $lookahead = int($cfg{eco_lookahead_days});
76              
77 9         21 my ($best_epoch, $best_tier) = (undef, undef);
78 9         216 my @now_tm = localtime($now);
79              
80 9         38 for my $day_offset (0 .. $lookahead) {
81 16         398 my $day_midnight = mktime(0, 0, 0,
82             $now_tm[3] + $day_offset, $now_tm[4], $now_tm[5]);
83              
84 16         214 my $dow = (localtime($day_midnight))[6]; # 0=Sun … 6=Sat
85 16         95 my @eco = _windows_for_day($day_midnight, $dow, \%cfg);
86 16         42 my @avoid = _avoid_for_day($day_midnight, \%cfg);
87              
88 16         33 my ($day_t1, $day_t2, $day_t3);
89              
90 16         28 for my $window (@eco) {
91 19         68 my ($w_start, $w_end) = @$window;
92              
93             # Earliest possible candidate: 1-minute safety buffer from "now"
94 19 100       45 my $candidate = ($w_start > $now + 60) ? $w_start : $now + 60;
95 19 100       51 next if $candidate >= $w_end; # window already past
96              
97 11         23 my $fits = ($candidate + $duration_h * 3600) <= $w_end;
98 11         25 my $overlaps = _job_overlaps_avoid($candidate, $duration_h, \@avoid);
99              
100 11 100 66     45 if ($fits && !$overlaps) { $day_t1 //= $candidate }
  6 100 66     22  
101 2   33     19 elsif (!$overlaps) { $day_t2 //= $candidate }
102 3   33     16 else { $day_t3 //= $candidate }
103             }
104              
105 16   100     83 my $day_best = $day_t1 // $day_t2 // $day_t3;
      100        
106 16 100       45 my $day_tier = defined $day_t1 ? 1
    100          
    100          
107             : defined $day_t2 ? 2
108             : defined $day_t3 ? 3
109             : undef;
110 16 100       79 next unless defined $day_best;
111              
112             # First candidate found — record it
113 10 100       25 if (!defined $best_epoch) {
    50          
114 8         18 ($best_epoch, $best_tier) = ($day_best, $day_tier);
115             } elsif ($day_tier < $best_tier) {
116             # A later day offers a better tier — upgrade
117 0         0 ($best_epoch, $best_tier) = ($day_best, $day_tier);
118             }
119              
120             # Prefer starting sooner over waiting for a "perfect" distant slot:
121             # once we have a T1 or T2 (avoids peak hours) stop scanning —
122             # a slightly imperfect slot today beats a perfect slot in 3 days.
123 10 100       57 last if $best_tier <= 2;
124             }
125              
126 9 100       61 return ($best_epoch, $best_tier) if defined $best_epoch;
127 1         7 return (undef, undef);
128             }
129              
130             sub epoch_to_slurm {
131             # Convert a Unix epoch to the SLURM --begin format "YYYY-MM-DDTHH:MM:SS".
132 6     6 1 6617 my $epoch = shift;
133 6         302 return strftime("%Y-%m-%dT%H:%M:%S", localtime($epoch));
134             }
135              
136             sub format_delay {
137             # Return a human-readable string describing how far in the future $begin_epoch is.
138             # e.g. "now", "45m", "6h 05m", "1d 2h 30m"
139 5     5 1 15 my ($begin_epoch, $now) = @_;
140 5   33     17 $now //= time();
141 5         10 my $secs = $begin_epoch - $now;
142 5 100       23 return "now" if $secs <= 60;
143 3         11 my $days = int($secs / 86400);
144 3         8 my $hours = int(($secs % 86400) / 3600);
145 3         9 my $mins = int(($secs % 3600) / 60);
146 3 100       16 return sprintf("%dd %dh %02dm", $days, $hours, $mins) if $days;
147 2 100       18 return sprintf("%dh %02dm", $hours, $mins) if $hours;
148 1         9 return sprintf("%dm", $mins);
149             }
150              
151             # ---------------------------------------------------------------------------
152             # Private helpers
153             # ---------------------------------------------------------------------------
154              
155             sub _windows_for_day {
156             # Return eco windows for a given day midnight + day-of-week.
157 16     16   37 my ($day_midnight, $dow, $cfg) = @_;
158 16   100     74 my $is_weekend = ($dow == 0 || $dow == 6);
159             my $str = $is_weekend
160             ? ($cfg->{eco_windows_weekend} // $DEFAULTS{eco_windows_weekend})
161 16 100 33     83 : ($cfg->{eco_windows_weekday} // $DEFAULTS{eco_windows_weekday});
      33        
162 16         43 return _parse_window_string($str, $day_midnight);
163             }
164              
165             sub _avoid_for_day {
166             # Return avoid windows for a given day midnight.
167 16     16   30 my ($day_midnight, $cfg) = @_;
168 16   33     45 my $str = $cfg->{eco_avoid} // $DEFAULTS{eco_avoid};
169 16         33 return _parse_window_string($str, $day_midnight);
170             }
171              
172             sub _parse_window_string {
173             # Parse "HH:MM-HH:MM,HH:MM-HH:MM,..." anchored to $day_midnight.
174             # Returns a list of [start_epoch, end_epoch] pairs.
175 35     35   10681 my ($str, $day_midnight) = @_;
176 35         59 my @windows;
177 35         110 for my $part (split /,/, $str) {
178 39         264 $part =~ s/^\s+|\s+$//g;
179 39 50       225 if ($part =~ /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/) {
180 39         178 my ($sh, $sm, $eh, $em) = ($1, $2, $3, $4);
181 39         166 push @windows, [
182             $day_midnight + $sh * 3600 + $sm * 60,
183             $day_midnight + $eh * 3600 + $em * 60,
184             ];
185             } else {
186 0         0 carp "NBI::EcoScheduler: cannot parse window spec '$part', skipping\n";
187             }
188             }
189 35         116 return @windows;
190             }
191              
192             sub _job_overlaps_avoid {
193             # Return 1 if a job starting at $start_epoch running for $duration_h hours
194             # overlaps any of the avoid windows.
195 15     15   59 my ($start, $duration_h, $avoid_windows) = @_;
196 15         30 my $end = $start + $duration_h * 3600;
197 15         32 for my $w (@$avoid_windows) {
198 15         29 my ($a, $b) = @$w;
199 15 100 66     90 return 1 if ($start < $b && $end > $a);
200             }
201 10         30 return 0;
202             }
203              
204             1;
205              
206             __END__