Skip to content

Commit 57800af

Browse files
author
Benjamin Pachev
committed
Add single-user report without comparison.
1 parent 8b11e87 commit 57800af

5 files changed

Lines changed: 173 additions & 37 deletions

File tree

scripts/generate_user_reports.py

Lines changed: 139 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@
2828
# from src.analysis.frequency_analysis import FrequencyAnalysis # noqa: E402
2929
from src.config.enum_constants import EfficiencyCategoryEnum # noqa: E402
3030
from src.analysis.user_comparison import UserComparison # noqa: E402
31+
from src.analysis.user import get_metrics as get_user_metrics # noqa: E402
3132
from src.utilities.report_generation import ( # noqa: E402
3233
calculate_analysis_period,
3334
calculate_summary_statistics,
3435
calculate_comparison_statistics,
3536
generate_recommendations,
36-
get_total_job_count,
3737
)
3838

3939

@@ -101,20 +101,11 @@ def generate_user_report(
101101
# Calculate analysis period using shared utility
102102
analysis_period = calculate_analysis_period(user_jobs)
103103

104-
# === CALCULATE SUMMARY STATISTICS USING SHARED UTILITIES ===
105-
# Use the get_total_job_count function if db connection is provided
106-
if db:
107-
total_all_jobs = get_total_job_count(db, analysis_period)
108-
else:
109-
total_all_jobs = len(job_data) # Fallback to current dataset size
110-
111104
# Use shared utility for summary statistics
112105
summary_stats = calculate_summary_statistics(
113106
user_jobs=user_jobs,
114-
all_jobs_count=total_all_jobs,
115107
analysis_period=analysis_period
116108
)
117-
118109
# === CALCULATE COMPARISON STATISTICS ===
119110
# Use shared utility for comparison statistics
120111
if user_comparison and user_comparison._cached_all_users_metrics is not None:
@@ -271,6 +262,131 @@ def check_directory_writable(directory_path: str) -> tuple[bool, str | None]:
271262
return False, str(e)
272263

273264

265+
def generate_single_report(
266+
user_id: str,
267+
db_path: str = "./slurm_data.db",
268+
output_dir: str = "./reports/user_reports",
269+
template_path: str = "./reports/user_no_compare_report_template.qmd",
270+
output_format: str = "html",
271+
) -> None:
272+
"""Generate a report for a single user without comparison."""
273+
274+
user_jobs, user_metrics = get_user_metrics(db_path, user_id)
275+
276+
if not len(user_jobs):
277+
print("No jobs available.")
278+
return
279+
280+
user_output_dir = os.path.join(output_dir, f"user_{user_id}")
281+
os.makedirs(user_output_dir, exist_ok=True)
282+
283+
output_file = f"{user_id}_report.{output_format}"
284+
final_output_path = os.path.join(user_output_dir, output_file)
285+
286+
# Check that the template exists
287+
if not os.path.exists(template_path):
288+
abs_template_path = os.path.abspath(template_path)
289+
if os.path.exists(abs_template_path):
290+
template_path = abs_template_path
291+
else:
292+
print(f"Error: Template file not found at: {template_path}")
293+
print(f"Absolute path also not found: {abs_template_path}")
294+
return None
295+
296+
# Calculate dates for the report
297+
start_date = user_jobs["StartTime"].min().strftime("%Y-%m-%d")
298+
end_date = user_jobs["StartTime"].max().strftime("%Y-%m-%d")
299+
300+
# Calculate analysis period using shared utility
301+
analysis_period = calculate_analysis_period(user_jobs)
302+
303+
# Use shared utility for summary statistics
304+
summary_stats = calculate_summary_statistics(
305+
user_jobs=user_jobs,
306+
analysis_period=analysis_period
307+
)
308+
print(summary_stats)
309+
310+
# === PREPARE GPU TYPE DATA ===
311+
if "GPUType" in user_jobs.columns and "primary_gpu_type" in user_jobs.columns:
312+
gpu_type_data = user_jobs["primary_gpu_type"].value_counts().reset_index()
313+
gpu_type_data.columns = ["gpu_type", "job_count"]
314+
else:
315+
gpu_type_data = pd.DataFrame(columns=["gpu_type", "job_count"])
316+
317+
# Generate recommendations using shared utility
318+
recommendations = generate_recommendations(user_jobs, user_metrics)
319+
320+
# === SAVE DATA TO JSON FOR QUARTO ===
321+
322+
temp_data_pkl = {
323+
"user_id": user_id,
324+
"start_date": start_date,
325+
"end_date": end_date,
326+
"analysis_period": analysis_period,
327+
"summary_stats": summary_stats,
328+
"gpu_type_data": gpu_type_data,
329+
"recommendations": recommendations,
330+
"user_data": pd.DataFrame(user_metrics),
331+
"user_jobs": pd.DataFrame(user_jobs),
332+
}
333+
334+
# get abs path to the template in the reports folder
335+
template_file = os.path.join(project_root, template_path)
336+
337+
with tempfile.NamedTemporaryFile(suffix=".pkl", delete=False) as f:
338+
pickle.dump(temp_data_pkl, f, protocol=pickle.HIGHEST_PROTOCOL)
339+
temp_pickle_path = f.name
340+
341+
permanent_path = os.path.join(project_root, "reports", "test_data.pkl")
342+
shutil.copy(temp_pickle_path, permanent_path)
343+
344+
# Use absolute path for output file to avoid path resolution issues
345+
output_file_abs = os.path.abspath(final_output_path)
346+
347+
cmd = ["quarto", "render", template_file, "--to", output_format, "-o", output_file, "--execute"]
348+
cmd.extend(["-P", f"pickle_file:{temp_pickle_path}"])
349+
try:
350+
# Run Quarto in the reports directory (full path)
351+
reports_dir = os.path.join(project_root, "reports")
352+
print("Starting Quarto rendering...")
353+
print(f"Output file: {output_file_abs}")
354+
print(f"Command: {' '.join(cmd)}")
355+
356+
result = subprocess.run(
357+
cmd,
358+
check=False, # Don't raise exception, handle it manually
359+
capture_output=True,
360+
text=True,
361+
cwd=reports_dir,
362+
)
363+
output_path = os.path.join(reports_dir, output_file)
364+
# Check if command was successful
365+
if result.returncode != 0:
366+
print(f" ❌ Quarto rendering failed (code {result.returncode})")
367+
if result.stderr:
368+
print(f" Error: {result.stderr.strip()}")
369+
if result.stdout:
370+
print(f" Output: {result.stdout.strip()}")
371+
return None
372+
373+
# Check if output file was created
374+
if not os.path.exists(output_path):
375+
print(" ❌ Output file not created")
376+
print(f" Expected at: {output_file_abs}")
377+
return None
378+
379+
print(f" ✅ Report saved: {os.path.basename(final_output_path)}")
380+
return final_output_path
381+
382+
except subprocess.CalledProcessError as e:
383+
print(f" ❌ Process error: {e}")
384+
return None
385+
except Exception as e:
386+
print(f" ❌ Unexpected error: {e}")
387+
return None
388+
389+
274390
def generate_reports_for_specific_users(
275391
user_list: list,
276392
db_path: str = "./slurm_data.db",
@@ -485,6 +601,7 @@ def generate_all_reports(
485601
users_parser.add_argument(
486602
"--min-jobs", type=int, default=10, help="Minimum number of jobs a user must have (default: 10)"
487603
)
604+
users_parser.add_argument("--no-compare", action="store_true", default=False)
488605

489606
# Common arguments for both modes
490607
parser.add_argument(
@@ -541,14 +658,18 @@ def generate_all_reports(
541658
print("👥 GENERATING REPORTS FOR SPECIFIC USERS")
542659
print(f" Users: {', '.join(user_list)}")
543660
print(f" Minimum jobs required: {args.min_jobs}")
544-
report_paths = generate_reports_for_specific_users(
545-
user_list=user_list,
546-
db_path=args.db_path,
547-
output_dir=args.output_dir,
548-
template_path=args.template,
549-
min_jobs=args.min_jobs,
550-
output_format=args.format,
551-
)
661+
if args.no_compare:
662+
for user in user_list:
663+
generate_single_report(user, args.db_path)
664+
else:
665+
report_paths = generate_reports_for_specific_users(
666+
user_list=user_list,
667+
db_path=args.db_path,
668+
output_dir=args.output_dir,
669+
template_path=args.template,
670+
min_jobs=args.min_jobs,
671+
output_format=args.format,
672+
)
552673

553674
print("\n🎉 ALL TASKS COMPLETED!")
554675
print(f" Output directory: {args.output_dir}")

src/analysis/efficiency_analysis.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import subprocess
1111

1212
import numpy as np
13-
from typing import Generic, TypeVar, Annotated, cast
13+
from typing import Generic, TypeVar, cast
1414
import pandas as pd
1515
from pathlib import Path
1616
from src.config.constants import DEFAULT_MIN_ELAPSED_SECONDS
@@ -66,6 +66,7 @@ def load_preprocessed_jobs_dataframe_from_duckdb(
6666
# Generic type for metrics enums constrained to our abstract base Enum class
6767
MetricsDFNameEnumT = TypeVar("MetricsDFNameEnumT", bound=MetricsDataFrameNameBase)
6868

69+
6970
class EfficiencyAnalysis(Generic[MetricsDFNameEnumT]):
7071
"""
7172
Class to encapsulate the efficiency analysis of jobs based on various metrics.

src/config/snapshots/partition_info.json

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"node_count": 4,
1515
"maxtime": "14 days",
1616
"deftime": "1 hour",
17-
"max_ram": 560,
17+
"max_ram": 450,
1818
"max_cpus": 72
1919
},
2020
{
@@ -29,7 +29,7 @@
2929
{
3030
"name": "astroth-cpu",
3131
"type": "cpu",
32-
"node_count": 9,
32+
"node_count": 8,
3333
"maxtime": "30 days",
3434
"deftime": "1 hour",
3535
"max_ram": 1000,
@@ -92,7 +92,7 @@
9292
{
9393
"name": "cpu",
9494
"type": "cpu",
95-
"node_count": 153,
95+
"node_count": 135,
9696
"maxtime": "2 days",
9797
"deftime": "1 hour",
9898
"max_ram": 1510,
@@ -101,7 +101,7 @@
101101
{
102102
"name": "cpu-preempt",
103103
"type": "cpu",
104-
"node_count": 144,
104+
"node_count": 173,
105105
"maxtime": "2 days",
106106
"deftime": "1 hour",
107107
"max_ram": 1510,
@@ -137,7 +137,7 @@
137137
{
138138
"name": "gpu",
139139
"type": "gpu",
140-
"node_count": 153,
140+
"node_count": 157,
141141
"maxtime": "2 days",
142142
"deftime": "1 hour",
143143
"max_ram": 2010,
@@ -206,6 +206,15 @@
206206
"max_ram": 250,
207207
"max_cpus": 64
208208
},
209+
{
210+
"name": "ood-shared",
211+
"type": "cpu",
212+
"node_count": 10,
213+
"maxtime": "8 hours",
214+
"deftime": "1 hour",
215+
"max_ram": 370,
216+
"max_cpus": 40
217+
},
209218
{
210219
"name": "power9",
211220
"type": "cpu",
@@ -281,7 +290,7 @@
281290
{
282291
"name": "uri-cpu",
283292
"type": "cpu",
284-
"node_count": 49,
293+
"node_count": 57,
285294
"maxtime": "30 days",
286295
"deftime": "1 hour",
287296
"max_ram": 1000,
@@ -299,7 +308,7 @@
299308
{
300309
"name": "uri-richamp",
301310
"type": "cpu",
302-
"node_count": 5,
311+
"node_count": 8,
303312
"maxtime": "Unlimited",
304313
"deftime": "1 hour",
305314
"max_ram": 120,
@@ -314,6 +323,15 @@
314323
"max_ram": 370,
315324
"max_cpus": 48
316325
},
326+
{
327+
"name": "workflow",
328+
"type": "cpu",
329+
"node_count": 1,
330+
"maxtime": "2 days",
331+
"deftime": "1 hour",
332+
"max_ram": 370,
333+
"max_cpus": 24
334+
},
317335
{
318336
"name": "zhoulin-cpu",
319337
"type": "cpu",

src/utilities/load_and_preprocess_jobs.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def load_and_preprocess_jobs(
2525
min_elapsed_seconds: int = DEFAULT_MIN_ELAPSED_SECONDS,
2626
random_state: pd._typing.RandomState | None = None,
2727
sample_size: int | None = None,
28+
user: str | None = None,
2829
) -> pd.DataFrame:
2930
"""
3031
Load jobs DataFrame from a DuckDB database with standard filtering and preprocess it.
@@ -46,6 +47,7 @@ def load_and_preprocess_jobs(
4647
Defaults to DEFAULT_MIN_ELAPSED_SECONDS.
4748
random_state (pd._typing.RandomState, optional): Random state for reproducibility. Defaults to None.
4849
sample_size (int, optional): Number of rows to sample from the DataFrame. Defaults to None (no sampling).
50+
user (str, optional): user ID to get jobs for
4951
5052
Returns:
5153
pd.DataFrame: Preprocessed DataFrame containing the filtered job data.
@@ -84,6 +86,8 @@ def load_and_preprocess_jobs(
8486
if not include_failed_cancelled_jobs:
8587
conditions_arr.append(f"Status != '{StatusEnum.FAILED.value}'")
8688
conditions_arr.append(f"Status != '{StatusEnum.CANCELLED.value}'")
89+
if user is not None:
90+
conditions_arr.append(f"User = '{user}'")
8791

8892
query = f"SELECT * EXCLUDE {excluded_columns} FROM {table_name} WHERE {' AND '.join(conditions_arr)}"
8993
jobs_df = db.fetch_query(query=query)

0 commit comments

Comments
 (0)