Skip to content

Commit ba263ce

Browse files
cmeestersFelixKnopp13coderabbitai[bot]
authored
fix: time conversion for efficiency reports with jobs taking longer than 1 day (#362)
based on PR #360 and issue #361: The current time conversion for the efficiency report does not consider long jobs, where `sacct` output may be in the D-HH:MM:SS format. Also, the conversion of fractional seconds was not taken into account. While the latter is actually ridiculously playing with accuracy, the HPC world sometimes is concerned about it. Included test cases. This PR is based upon #360 and might replace it. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved SLURM time parsing in efficiency reports to robustly handle many sacct formats (days, hours, minutes, seconds), fractional seconds, varied HH:MM:SS variants and whitespace; treats invalid/empty/NA inputs as 0 to improve runtime and CPU metric accuracy. * **Tests** * Added comprehensive tests validating diverse sacct time formats, fractional seconds, whitespace and NA handling, and integration with existing parsing checks. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: FelixKnopp13 <[email protected]> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 104d0f7 commit ba263ce

File tree

2 files changed

+170
-14
lines changed

2 files changed

+170
-14
lines changed

snakemake_executor_plugin_slurm/efficiency_report.py

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,67 @@
33
from pathlib import Path
44
import subprocess
55
import shlex
6-
7-
import os # only temporarily needed for printf debugging
6+
from datetime import datetime
87
import numpy as np
8+
import os
99

1010

1111
def time_to_seconds(time_str):
12-
"""Convert SLURM time format to seconds."""
13-
if pd.isna(time_str) or time_str.strip() == "":
12+
"""
13+
Convert SLURM sacct time format to seconds.
14+
15+
Handles sacct output formats:
16+
- Elapsed: [D-]HH:MM:SS or [DD-]HH:MM:SS (no fractional seconds)
17+
- TotalCPU: [D-][HH:]MM:SS or [DD-][HH:]MM:SS (with fractional seconds)
18+
19+
Examples:
20+
- "1-12:30:45" -> 131445 seconds (1 day + 12h 30m 45s)
21+
- "23:59:59" -> 86399 seconds
22+
- "45:30" -> 2730 seconds (45 minutes 30 seconds)
23+
- "30.5" -> 30.5 seconds (fractional seconds for TotalCPU)
24+
"""
25+
if (
26+
pd.isna(time_str)
27+
or str(time_str).strip() == ""
28+
or str(time_str).strip() == "invalid"
29+
):
1430
return 0
15-
parts = time_str.split(":")
16-
17-
if len(parts) == 3: # H:M:S
18-
return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
19-
elif len(parts) == 2: # M:S
20-
return int(parts[0]) * 60 + float(parts[1])
21-
elif len(parts) == 1: # S
22-
return float(parts[0])
23-
return 0
31+
32+
time_str = str(time_str).strip()
33+
34+
# Try different SLURM time formats with datetime parsing
35+
time_formats = [
36+
"%d-%H:%M:%S.%f", # D-HH:MM:SS.ffffff (with fractional seconds)
37+
"%d-%H:%M:%S", # D-HH:MM:SS
38+
"%d-%M:%S", # D-MM:SS
39+
"%d-%M:%S.%f", # D-MM:SS.ffffff (with fractional seconds)
40+
"%H:%M:%S.%f", # HH:MM:SS.ffffff (with fractional seconds)
41+
"%H:%M:%S", # HH:MM:SS
42+
"%M:%S.%f", # MM:SS.ffffff (with fractional seconds)
43+
"%M:%S", # MM:SS
44+
"%S.%f", # SS.ffffff (with fractional seconds)
45+
"%S", # SS
46+
]
47+
48+
for fmt in time_formats:
49+
try:
50+
time_obj = datetime.strptime(time_str, fmt)
51+
52+
total_seconds = (
53+
time_obj.hour * 3600
54+
+ time_obj.minute * 60
55+
+ time_obj.second
56+
+ time_obj.microsecond / 1000000
57+
)
58+
59+
# Add days if present (datetime treats day 1 as the first day)
60+
if fmt.startswith("%d-"):
61+
total_seconds += time_obj.day * 86400
62+
63+
return total_seconds
64+
except ValueError:
65+
continue
66+
return 0 # If all parsing attempts fail, return 0
2467

2568

2669
def parse_maxrss(maxrss):

tests/tests.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@
99
import pytest
1010

1111
from snakemake_executor_plugin_slurm import ExecutorSettings
12-
from snakemake_executor_plugin_slurm.efficiency_report import parse_sacct_data
12+
from snakemake_executor_plugin_slurm.efficiency_report import (
13+
parse_sacct_data,
14+
time_to_seconds,
15+
)
1316
from snakemake_executor_plugin_slurm.utils import set_gres_string
1417
from snakemake_executor_plugin_slurm.submit_string import get_submit_command
1518
from snakemake_interface_common.exceptions import WorkflowError
19+
import pandas as pd
1620

1721

1822
class TestWorkflows(snakemake.common.tests.TestWorkflowsLocalStorageBase):
@@ -59,6 +63,115 @@ def test_parse_sacct_data():
5963
assert df.iloc[0]["MaxRSS_MB"] > 0 # Should have actual memory usage from job step
6064

6165

66+
class TestTimeToSeconds:
67+
"""Test the time_to_seconds function with SLURM sacct time formats."""
68+
69+
def test_elapsed_format_with_days(self):
70+
"""
71+
Test Elapsed format: [D-]HH:MM:SS or
72+
[DD-]HH:MM:SS (no fractional seconds).
73+
"""
74+
# Single digit days
75+
assert time_to_seconds("1-00:00:00") == 86400 # 1 day
76+
assert (
77+
time_to_seconds("1-12:30:45") == 86400 + 12 * 3600 + 30 * 60 + 45
78+
) # 131445
79+
assert time_to_seconds("9-23:59:59") == 9 * 86400 + 23 * 3600 + 59 * 60 + 59
80+
81+
# Double digit days
82+
assert (
83+
time_to_seconds("10-01:02:03") == 10 * 86400 + 1 * 3600 + 2 * 60 + 3
84+
) # 867723
85+
86+
def test_elapsed_format_hours_minutes_seconds(self):
87+
"""Test Elapsed format: HH:MM:SS (no fractional seconds)."""
88+
assert time_to_seconds("00:00:00") == 0
89+
assert time_to_seconds("01:00:00") == 3600 # 1 hour
90+
assert time_to_seconds("23:59:59") == 23 * 3600 + 59 * 60 + 59 # 86399
91+
assert time_to_seconds("12:30:45") == 12 * 3600 + 30 * 60 + 45 # 45045
92+
93+
def test_totalcpu_format_with_days(self):
94+
"""
95+
Test TotalCPU format: [D-][HH:]MM:SS or [DD-][HH:]MM:SS
96+
(with fractional seconds).
97+
"""
98+
# With days and hours
99+
assert time_to_seconds("1-12:30:45.5") == 86400 + 12 * 3600 + 30 * 60 + 45.5
100+
assert (
101+
time_to_seconds("10-01:02:03.123") == 10 * 86400 + 1 * 3600 + 2 * 60 + 3.123
102+
)
103+
104+
# With days, no hours (MM:SS format)
105+
assert time_to_seconds("1-30:45") == 86400 + 30 * 60 + 45
106+
assert time_to_seconds("1-30:45.5") == 86400 + 30 * 60 + 45.5
107+
108+
def test_totalcpu_format_minutes_seconds(self):
109+
"""Test TotalCPU format: MM:SS with fractional seconds."""
110+
assert time_to_seconds("00:00") == 0
111+
assert time_to_seconds("01:00") == 60 # 1 minute
112+
assert time_to_seconds("59:59") == 59 * 60 + 59 # 3599
113+
assert time_to_seconds("30:45") == 30 * 60 + 45 # 1845
114+
assert time_to_seconds("30:45.5") == 30 * 60 + 45.5 # 1845.5
115+
116+
def test_totalcpu_format_seconds_only(self):
117+
"""Test TotalCPU format: SS or SS.sss (seconds only with fractional)."""
118+
assert time_to_seconds("0") == 0
119+
assert time_to_seconds("1") == 1
120+
assert time_to_seconds("30") == 30
121+
assert time_to_seconds("59") == 59
122+
123+
# Fractional seconds
124+
assert time_to_seconds("30.5") == 30.5
125+
assert time_to_seconds("0.5") == 0.5
126+
127+
def test_real_world_sacct_examples(self):
128+
"""Test with realistic sacct time values from actual output."""
129+
# From your test data
130+
assert time_to_seconds("00:01:31") == 91 # 1 minute 31 seconds
131+
assert time_to_seconds("00:24.041") == 24.041 # 24.041 seconds
132+
assert time_to_seconds("00:03.292") == 3.292 # 3.292 seconds
133+
assert time_to_seconds("00:20.749") == 20.749 # 20.749 seconds
134+
135+
# Longer running jobs
136+
assert time_to_seconds("02:15:30") == 2 * 3600 + 15 * 60 + 30 # 2h 15m 30s
137+
assert time_to_seconds("1-12:00:00") == 86400 + 12 * 3600 # 1 day 12 hours
138+
assert time_to_seconds("7-00:00:00") == 7 * 86400 # 1 week
139+
140+
def test_empty_and_invalid_inputs(self):
141+
"""Test empty, None, and invalid inputs."""
142+
assert time_to_seconds("") == 0
143+
assert time_to_seconds(" ") == 0
144+
assert time_to_seconds(None) == 0
145+
assert time_to_seconds(pd.NA) == 0
146+
assert time_to_seconds("invalid") == 0
147+
assert time_to_seconds("1:2:3:4") == 0 # Too many colons
148+
assert time_to_seconds("abc:def") == 0
149+
assert time_to_seconds("-1:00:00") == 0 # Negative values
150+
151+
def test_whitespace_handling(self):
152+
"""Test that whitespace is properly handled."""
153+
assert time_to_seconds(" 30 ") == 30
154+
assert time_to_seconds(" 1-02:30:45 ") == 86400 + 2 * 3600 + 30 * 60 + 45
155+
assert time_to_seconds("\t12:30:45\n") == 12 * 3600 + 30 * 60 + 45
156+
157+
def test_pandas_na_values(self):
158+
"""Test pandas NA and NaN values."""
159+
assert time_to_seconds(pd.NA) == 0
160+
assert (
161+
time_to_seconds(pd.NaType()) == 0 if hasattr(pd, "NaType") else True
162+
) # Skip if not available
163+
164+
def test_edge_case_values(self):
165+
"""Test edge case values that might appear in SLURM output."""
166+
# Zero padding variations (should work with datetime parsing)
167+
assert time_to_seconds("01:02:03") == 1 * 3600 + 2 * 60 + 3
168+
assert time_to_seconds("1:2:3") == 1 * 3600 + 2 * 60 + 3
169+
170+
# Single digit values
171+
assert time_to_seconds("5") == 5
172+
assert time_to_seconds("1:5") == 1 * 60 + 5
173+
174+
62175
class TestEfficiencyReport(snakemake.common.tests.TestWorkflowsLocalStorageBase):
63176
__test__ = True
64177

0 commit comments

Comments
 (0)