Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
af82545
Package versions updated for ga_core compatibility
Navirah Jul 14, 2026
5aaf7f4
Changes to use ga core package for extract and enrich + redundant cod…
Navirah Jul 20, 2026
1097bad
testing framework added + tests written for backend script
Navirah Jul 20, 2026
7225b3f
backend import fixed
Navirah Jul 20, 2026
cf876b1
deleted code cov steps - not needed
Navirah Jul 20, 2026
16459d8
Update copyright information in LICENSE file
Navirah Jul 27, 2026
8899f62
pandas loc 0 fix
Navirah Jul 27, 2026
9c552ef
unit conversion fix in formatText_footprint()
Navirah Jul 27, 2026
dd455a7
fixed minor typo
Navirah Jul 27, 2026
9f73f22
updated ga_core version in requirements.txt
Navirah Jul 27, 2026
24c82eb
added a TODO
Navirah Jul 27, 2026
e49da9a
Merge pull request #29 from Cambridge-Sustainable-Computing-Lab/ga-co…
Navirah Jul 27, 2026
daa2225
fixed ga_config issue and updated readme
Navirah Jul 28, 2026
68adc1c
ga-data url update
Navirah Jul 28, 2026
fade044
removed unused/old args and fn
Navirah Jul 28, 2026
4adde39
edited readme: removed unused args and added contribution
Navirah Jul 28, 2026
dc344ca
linked dashboard in readme
Navirah Jul 28, 2026
523f35b
updated readme
Navirah Jul 28, 2026
f06892f
minor format fix in readme
Navirah Jul 28, 2026
035998c
minor typo fixes
Navirah Aug 3, 2026
78945d0
updated for ga_core v0.1.1: raw logs file read + test cases fixed
Navirah Aug 4, 2026
1381378
added support for reporting bugs
Navirah Aug 4, 2026
d828e14
exception handling added at report bug
Navirah Aug 4, 2026
c7e81b0
reportBugs: calling ga_core.SacctClient to pull slurm logs
Navirah Aug 5, 2026
bd017a3
updates required python version
Navirah Aug 5, 2026
31375bc
minor updates to readme
Navirah Aug 5, 2026
e2e7eca
Merge pull request #33 from Cambridge-Sustainable-Computing-Lab/readm…
Navirah Aug 5, 2026
7f6a2fe
Revise Python environment setup instructions
Navirah Aug 6, 2026
a843773
Minor tweaks to README
Llannelongue Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Python application

on:
push:
branches: [ "main", "dev" ]
pull_request:
branches: [ "main", "dev" ]

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest pytest-cov
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi

- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics

- name: Test with pytest
env:
PYTHONPATH: .
run: |
# Generates pytest-coverage.txt used by the comment action
pytest --doctest-modules --junitxml=junit/test-results.xml --cov-report=term-missing --cov=. | tee pytest-coverage.txt
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Project specific
.idea/
clustersData/
testData/
error_logs_archived/
support_files/
frontend/templates/plotly*
Expand Down Expand Up @@ -145,4 +144,8 @@ dmypy.json
.pytype/

# Cython debug symbols
cython_debug/
cython_debug/
.vscode/launch.json
.gitignore
.vscode/settings.json
.DS_Store
674 changes: 674 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

246 changes: 147 additions & 99 deletions README.md

Large diffs are not rendered by default.

18 changes: 15 additions & 3 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import argparse
import datetime
import os
import sys

from backend import main_backend
from backend import export_debug_logs, main_backend
from frontend import main_frontend

def create_arguments():
Expand Down Expand Up @@ -59,13 +60,14 @@ def create_arguments():
)
group1.add_argument('--reportBugHere', action='store_true',
help='Similar to --reportBug, but exports the output to your home folder.')

group2 = parser.add_mutually_exclusive_group()
group2.add_argument('--useCustomLogs', type=str, default='',
help='This bypasses the workload manager, and enables you to input a custom log file of your jobs. \
This is mostly meant for debugging, but can be useful in some situations. '
'An example of the expected file can be found at `example_files/example_sacctOutput_raw.txt`.')
# Arguments for debugging only (not visible to users)
# To ue arbitrary folder for the infrastructure information
# To use arbitrary folder for the infrastructure information
parser.add_argument('--useOtherInfrastuctureInfo', type=str, default='', help=argparse.SUPPRESS)
# Uses mock aggregated usage data, for offline debugging
group2.add_argument('--use_mock_agg_data', action='store_true', help=argparse.SUPPRESS)
Expand Down Expand Up @@ -120,7 +122,7 @@ def all(self, args):
else:
args.path_infrastucture_info = 'data'

## Organise the unique output directory (used for output report and logs export for debugging)
## Organise the unique output directory (used for output report)
## creating a uniquely named subdirectory in whatever
# Decide if an output directory is needed at all
if (args.output in ['html']) | args.reportBug | args.reportBugHere:
Expand All @@ -144,6 +146,16 @@ def all(self, args):
else:
args.filterWD = None

if args.reportBug | args.reportBugHere:
print("\n(!) Debugging mode activated. This will export raw logs for debugging.\n")
if args.useCustomLogs != '':
print("\n(!) --reportBug and --reportBugHere are ignored when --useCustomLogs is present\n")
else:
# Extract and save debug logs
export_debug_logs(args)
print("\n(!) Exiting after exporting debug logs.\n")
sys.exit(0)

### Validate input
validate_args().all(args)

Expand Down
223 changes: 89 additions & 134 deletions backend/__init__.py
Original file line number Diff line number Diff line change
@@ -1,125 +1,13 @@

import os
import sys
import yaml
import pandas as pd
import numpy as np

from backend.helpers import check_empty_results, simulate_mock_jobs
from backend.slurm_extract import WorkloadManager
import ga_core

from backend import helpers
# print("Working dir1: ", os.getcwd()) # DEBUGONLY

class GA_tools():

def __init__(self, cluster_info, fParams):
self.cluster_info = cluster_info
self.fParams = fParams

def calculate_energies(self, row):
'''
Calculate the energy usaged based on the job's paramaters
:param row: [pd.Series] one row of usage statistics, corresponding to one job
:return: [pd.Series] the same statistics with the energies added
'''
### CPU and GPU
partition_info = self.cluster_info['partitions'][row.PartitionX]
if row.PartitionTypeX == 'CPU':
TDP2use4CPU = partition_info['TDP']
TDP2use4GPU = 0
else:
TDP2use4CPU = partition_info['TDP_CPU']
TDP2use4GPU = partition_info['TDP']

row['energy_CPUs'] = row.TotalCPUtime2useX.total_seconds() / 3600 * TDP2use4CPU / 1000 # in kWh

row['energy_GPUs'] = row.TotalGPUtime2useX.total_seconds() / 3600 * TDP2use4GPU / 1000 # in kWh

### memory
for suffix, memory2use in zip(['','_memoryNeededOnly'], [row.ReqMemX,row.NeededMemX]):
row[f'energy_memory{suffix}'] = row.WallclockTimeX.total_seconds()/3600 * memory2use * self.fParams['power_memory_perGB'] /1000 # in kWh
row[f'energy{suffix}'] = (row.energy_CPUs + row.energy_GPUs + row[f'energy_memory{suffix}']) * self.cluster_info['PUE'] # in kWh

return row

def calculate_carbonFootprint(self, df, col_energy):
return df[col_energy] * self.cluster_info['CI']


def extract_data(args, cluster_info):

if args.use_mock_agg_data: # DEBUGONLY

if args.reportBug | args.reportBugHere:
print("\n(!) --reportBug and --reportBugHere are ignored when --useCustomLogs is present\n")

# df2 = simulate_mock_jobs()
# df2.to_pickle("testData/df_agg_X_mockMultiUsers_1.pkl")

# foo = 'testData/df_agg_test_3.pkl'
foo = 'testData/df_agg_X_1.pkl'
print(f"Overriding df_agg with `{foo}`")
return pd.read_pickle(foo)


### Pull usage statistics from the workload manager
WM = WorkloadManager(args, cluster_info)
WM.pull_logs()

### Log the output for debugging
if args.reportBug | args.reportBugHere:
if args.reportBug:
# Create an error_logs subfolder in the output dir
errorLogsDir = os.path.join(args.outputDir2use['path'], 'error_logs')
os.makedirs(errorLogsDir)
log_path = os.path.join(errorLogsDir, f'sacctOutput.csv')
else:
# i.e. args.reportBugHere is True
log_path = f"{args.userCWD}/sacctOutput_{args.outputDir2use['timestamp']}.csv"

with open(log_path, 'wb') as f:
f.write(WM.logs_raw)
print(f"\nSLURM statistics logged for debuging: {log_path}\n")

### Turn usage logs into DataFrame
WM.convert2dataframe()
check_empty_results(WM.logs_df, args)

# And clean
WM.clean_logs_df()
# Check if there are any jobs during the period from this directory and with these jobIDs
check_empty_results(WM.df_agg, args)

# Check that there is only one user's data
if len(set(WM.df_agg_X.UserX)) > 1:
raise ValueError(f"More than one user's logs was included: {set(WM.df_agg_X.UserX)}")

# WM.df_agg_X.to_pickle("testData/df_agg_X_1.pkl") # DEBUGONLY used to test different steps offline

return WM.df_agg_X

def enrich_data(df, fParams, GA):

### energy
df = df.apply(GA.calculate_energies, axis=1)

df['energy_failedJobs'] = np.where(df.StateX == 0, df.energy, 0)

### carbon footprint
for suffix in ['', '_memoryNeededOnly', '_failedJobs']:
df[f'carbonFootprint{suffix}'] = GA.calculate_carbonFootprint(df, f'energy{suffix}')
# Context metrics (part 1)
df[f'treeMonths{suffix}'] = df[f'carbonFootprint{suffix}'] / fParams['tree_month']
df[f'cost{suffix}'] = df[f'energy{suffix}'] * fParams['electricity_cost'] # TODO use realtime electricity costs

### Context metrics (part 2)
df['driving'] = df.carbonFootprint / fParams['passengerCar_EU_perkm']
df['flying_NY_SF'] = df.carbonFootprint / fParams['flight_NY_SF']
df['flying_PAR_LON'] = df.carbonFootprint / fParams['flight_PAR_LON']
df['flying_NYC_MEL'] = df.carbonFootprint / fParams['flight_NYC_MEL']

return df

def summarise_data(df, args):
def summarise_data(df):
agg_functions_from_raw = {
'n_jobs': ('UserX', 'count'),
'first_job_period': ('SubmitDatetimeX', 'min'),
Expand Down Expand Up @@ -190,7 +78,7 @@ def agg_jobs(data, agg_names=None):
df_userdaily = agg_jobs(df, ['SubmitDate'])
df_overallStats = agg_jobs(df_userdaily)
dict_overallStats = df_overallStats.iloc[0, :].to_dict()
userID = df.UserX[0]
userID = df.UserX.iloc[0]

output = {
"userDaily": df_userdaily,
Expand All @@ -205,17 +93,34 @@ def agg_jobs(data, agg_names=None):

return output

def prepare_config(args):
"""
Prepare the configuration for the GA core, based on the command line arguments.
:param args: [argparse.Namespace] the command line arguments
:return: [dict] the configuration for the GA core
"""
ga_config = {
"useCustomLogs": args.useCustomLogs,
"startDay": args.startDay,
"endDay": args.endDay,
"filterWD": args.filterWD,
"filterJobIDs": args.filterJobIDs,
"filterAccount": args.filterAccount
}

# TODO: Need to be implemented in a better manner, perhaps by importing a model from ga_core
optional_args = ["userCWD", "customSuccessStates"]
for arg in optional_args:
if hasattr(args, arg) and getattr(args, arg):
ga_config[arg] = getattr(args, arg)

def main_backend(args):
'''

:param args:
:return:
'''
### Load cluster specific info
with open(os.path.join(args.path_infrastucture_info, 'cluster_info.yaml'), "r") as stream:
try:
cluster_info = yaml.safe_load(stream)
if cluster_info.get('workload_manager', '') == '':
cluster_info['workload_manager'] = 'slurm' # default to slurm if not specified

except yaml.YAMLError as exc:
print(exc)

Expand All @@ -226,33 +131,83 @@ def main_backend(args):
except yaml.YAMLError as exc:
print(exc)

GA = GA_tools(cluster_info, fParams)
return ga_config, cluster_info, fParams

def main_backend(args):
'''
Loads configurations including cluster information and fixed parameters.
Calls HPCDataProcessor.extract and HPCDataProcessor.enrich functions to produce enriched logs.
Finally, it summarises the data.

:param args: [argparse.Namespace] contains the settings
:return: [dict] contains the summarised data
'''
ga_config, cluster_info, fParams = prepare_config(args)
logs_raw = None

if ga_config.get('useCustomLogs', '') != '':
# Pick raw logs from file
logs_raw = helpers.read_file_bytes(ga_config["useCustomLogs"])
print(f'Overriding logs_raw with: {ga_config["useCustomLogs"]}\n')

df = extract_data(args, cluster_info=cluster_info)
df2 = enrich_data(df, fParams=fParams, GA=GA)
summary_stats = summarise_data(df2, args=args)
dataprocessor = ga_core.HPCDataProcessor(ga_config, cluster_info, fParams, all_users_access = False)
extracted_logs = dataprocessor.extract_data(logs_raw)

enriched_logs = dataprocessor.enrich_data(extracted_logs)
summary_stats = summarise_data(enriched_logs)

return summary_stats

def export_debug_logs(args) -> None:
"""
Exports raw logs to a CSV file for debugging.

:param args: [argparse.Namespace] contains the settings
"""
ga_config, cluster_info, fParams = prepare_config(args)

if args.reportBug:
# Create an error_logs subfolder in the output dir
errorLogsDir = os.path.join(args.outputDir2use['path'], 'error_logs')
os.makedirs(errorLogsDir)
log_path = os.path.join(errorLogsDir, f'extracted_output.txt')
else:
# i.e. args.reportBugHere is True
log_path = f"{args.userCWD}/extracted_output_{args.outputDir2use['timestamp']}.txt"

try:
match cluster_info.get('workload_manager', '').lower():
case 'slurm':
extracted_raw_logs = ga_core.SacctClient.pull_logs_by_time(startDay=ga_config['startDay'], endDay=ga_config['endDay'], all_users=False)
with open(log_path, 'wb') as f:
f.write(extracted_raw_logs)
print(f"\nSLURM statistics logged for debugging: {log_path}\n")
case _:
raise ValueError(f"Unsupported workload manager: {cluster_info['workload_manager']}")

except IOError as e:
print(f"\n[Debug logs] Failed to write debug logs to {log_path}: {e}\n")

except Exception as e:
print(f"[Debug logs] Failed to extract logs: {e}")
sys.exit(1)

if __name__ == "__main__":

#### This is used for testing only ####
#### This is used for testing/DEBUG only ####

from collections import namedtuple
argStruct = namedtuple('argStruct',
'startDay endDay use_mock_agg_data useCustomLogs customSuccessStates filterWD filterJobIDs filterAccount reportBug reportBugHere path_infrastucture_info')
'startDay endDay useCustomLogs customSuccessStates filterWD filterJobIDs filterAccount path_infrastucture_info')
args = argStruct(
startDay='2022-01-01',
endDay='2023-06-30',
useCustomLogs=None,
use_mock_agg_data=True,
useCustomLogs='',
customSuccessStates='',
filterWD=None,
filterJobIDs='all',
filterAccount=None,
reportBug=False,
reportBugHere=False,
path_infrastucture_info="clustersData/CSD3",
path_infrastucture_info="data/",
)

main_backend(args)
Expand Down
Loading
Loading