From b880d955bbbb133494701f6135870bdc793fa4a0 Mon Sep 17 00:00:00 2001 From: EdHone Date: Tue, 10 Feb 2026 14:31:13 +0000 Subject: [PATCH 1/8] A python library containing a reader and data class for vernier output --- post-processing/lib/vernier/__init__.py | 2 + post-processing/lib/vernier/vernier_data.py | 69 +++++++++++++++++++ post-processing/lib/vernier/vernier_reader.py | 52 ++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 post-processing/lib/vernier/__init__.py create mode 100644 post-processing/lib/vernier/vernier_data.py create mode 100644 post-processing/lib/vernier/vernier_reader.py diff --git a/post-processing/lib/vernier/__init__.py b/post-processing/lib/vernier/__init__.py new file mode 100644 index 00000000..b45b4f8d --- /dev/null +++ b/post-processing/lib/vernier/__init__.py @@ -0,0 +1,2 @@ +from .vernier_data import VernierData +from .vernier_reader import VernierReader \ No newline at end of file diff --git a/post-processing/lib/vernier/vernier_data.py b/post-processing/lib/vernier/vernier_data.py new file mode 100644 index 00000000..fbea117f --- /dev/null +++ b/post-processing/lib/vernier/vernier_data.py @@ -0,0 +1,69 @@ +import numpy as np +from pathlib import Path +from typing import Optional + +class VernierData(): + """Class to hold Vernier data in a structured way, and provide methods for filtering and outputting the data.""" + + def __init__(self): + + self.data = {} + + return + + + def add_caliper(self, caliper_key): + """Adds a new caliper to the data structure, with empty arrays for each metric.""" + + # Create empty data arrays + self.data[caliper_key] = { + "%time" : [], + "cumul" : [], + "self" : [], + "total" : [], + "n_calls" : [] + } + + + def filter(self, caliper_keys: list[str]): + """Filters the Vernier data to include only calipers matching the provided keys. + The filtering is done in a glob-like fashion, so an input key of "timestep" + will match any caliper with "timestep" in its name.""" + + filtered_data = VernierData() + + # Filter data for a given caliper key + for timer in self.data.keys(): + if any(caliper_key in timer for caliper_key in caliper_keys): + filtered_data.data[timer] = self.data[timer] + + if len(filtered_data.data) == 0: + raise ValueError(f"No calipers found matching the provided keys: {caliper_keys}") + + return filtered_data + + + def write_txt_output(self, txt_path: Optional[Path] = None): + """Writes the Vernier data to a text output in a human-readable table format. + If an output path is provided, the table is written to that file. Otherwise, + it is printed to the terminal.""" + + txt_table = [] + txt_table.append(["Routine", "Total time (s)", "Self (s)", "No. calls", "% time", "Time per call (s)"]) + for caliper in self.data.keys(): + txt_table.append([ + f"{caliper.replace('@0', '')}", + f"{round(np.mean(self.data[caliper]['total']), 5)}", + f"{round(np.mean(self.data[caliper]['self']), 5)}", + f"{self.data[caliper]['n_calls'][0]}", + f"{round(np.mean(self.data[caliper]['%time']), 5)}", + f"{round(np.mean(self.data[caliper]['total']) / self.data[caliper]['n_calls'][0], 5)}" + ]) + + if txt_path is None: + for row in txt_table: + print('| {:>32} | {:>16} | {:>12} | {:>10} | {:>10} | {:>18} |'.format(*row)) + else: + with open(txt_path, 'w') as f: + for row in txt_table: + f.write('| {:>32} | {:>16} | {:>12} | {:>10} | {:>10} | {:>18} |\n'.format(*row)) \ No newline at end of file diff --git a/post-processing/lib/vernier/vernier_reader.py b/post-processing/lib/vernier/vernier_reader.py new file mode 100644 index 00000000..1a6ff79b --- /dev/null +++ b/post-processing/lib/vernier/vernier_reader.py @@ -0,0 +1,52 @@ +from pathlib import Path +from .vernier_data import VernierData + +class VernierReader(): + """Class handling the reading of Vernier output files, and converting them into a VernierData object.""" + + def __init__(self, vernier_path: Path): + + self.path = vernier_path + + return + + + def _load_from_file(self) -> VernierData: + """ + Loads Vernier data from a single file, and returns it as a VernierData object. + """ + + handle = open(self.path, 'r') + + loaded = VernierData() + + # Populate data + contents = handle.readlines() + for line in contents: + sline = line.split() + if len(sline) > 0: + if sline[0].isdigit(): + + caliper = sline[-1] + if not caliper in loaded.data: + loaded.add_caliper(caliper) + + loaded.data[caliper]["%time"].append(float(sline[1])) + loaded.data[caliper]["cumul"].append(float(sline[2])) + loaded.data[caliper]["self"].append(float(sline[3])) + loaded.data[caliper]["total"].append(float(sline[4])) + if not int(sline[5]) in loaded.data[caliper]["n_calls"]: + loaded.data[caliper]["n_calls"].append(int(sline[5])) + + return loaded + + + def load(self) -> VernierData: + """Generic load routine for Vernier data, aiming to handle both single + files and directories of files.""" + + if self.path.is_file(): + return self._load_from_file() + + elif self.path.is_dir(): + raise NotImplementedError("Loading from a directory of Vernier output files is not yet implemented.") \ No newline at end of file From 15add26c7edf5a07f87e9d2befd26e754958d950 Mon Sep 17 00:00:00 2001 From: EdHone Date: Wed, 11 Feb 2026 12:22:31 +0000 Subject: [PATCH 2/8] Add example which converts output to txt --- .gitignore | 5 +- post-processing/example/process_vernier.py | 35 +++++ .../example/vernier-output-example-collated | 132 ++++++++++++++++++ post-processing/lib/vernier/vernier_data.py | 1 + 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 post-processing/example/process_vernier.py create mode 100644 post-processing/example/vernier-output-example-collated diff --git a/.gitignore b/.gitignore index 1075a1ce..fe557699 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,7 @@ _deps build/ # Doxygen error log -documentation/Sphinx/doxylog \ No newline at end of file +documentation/Sphinx/doxylog + +# Python junk +__pycache__/ \ No newline at end of file diff --git a/post-processing/example/process_vernier.py b/post-processing/example/process_vernier.py new file mode 100644 index 00000000..09813eb4 --- /dev/null +++ b/post-processing/example/process_vernier.py @@ -0,0 +1,35 @@ +from pathlib import Path +import argparse +import sys +sys.path.append(str(Path(__file__).parent.parent / "lib")) +from vernier import VernierReader + + +def process_args(): + """ + Take the vernier output path as a command-line argument. + """ + parser = argparse.ArgumentParser( + description="Process Vernier output file and generate summary." + ) + parser.add_argument( + "vernier_output", + type=Path, + help="Path to the Vernier output file or directory." + ) + + return parser.parse_args() + + +if __name__ == "__main__": + + args = process_args() + timers = VernierReader(args.vernier_output).load() + + timers.write_txt_output() + + # Just get the timers we want (these filters act in glob-like fashion, so + # "field" will match any timer with "field" in its name) + timers = timers.filter(["algorithm", "field"]) + + timers.write_txt_output() \ No newline at end of file diff --git a/post-processing/example/vernier-output-example-collated b/post-processing/example/vernier-output-example-collated new file mode 100644 index 00000000..33c1917e --- /dev/null +++ b/post-processing/example/vernier-output-example-collated @@ -0,0 +1,132 @@ + +Task 1 of 6 : MPI rank ID 0 +Profiling on 6 thread(s). + + # % Time Cumul Self Total # of calls Self Total Routine@ + (Size; Size/sec; Size/call; MinSize; MaxSize) + (self) (sec) (sec) (sec) ms/call ms/call + + 1 44.130 2.583 2.583 5.854 1 2583.167 5853.527 __example_app__@0 + 2 34.563 4.606 2.023 2.077 2 1011.582 1038.697 io.finalise_context@0 + 3 18.597 5.695 1.089 1.089 110 9.896 9.896 io.write_fldg@0 + 4 0.926 5.749 0.054 0.054 1 54.226 54.226 io.context_finalize@0 + 5 0.722 5.791 0.042 0.063 1 42.262 62.676 io.init_context@0 + 6 0.620 5.828 0.036 0.037 1 36.294 36.656 example_algorithm@0 + 7 0.279 5.844 0.016 0.016 1 16.354 16.354 io.close_context_definition@0 + 8 0.057 5.847 0.003 0.003 14 0.239 0.239 halo_routing_creation@0 + 9 0.049 5.850 0.003 0.003 9 0.320 0.320 fs.constructor@0 + 10 0.031 5.852 0.002 0.002 11 0.165 0.165 clock.update_calendar@0 + 11 0.011 5.853 0.001 1.089 110 0.006 9.903 field.write@0 + 12 0.008 5.853 0.000 0.004 14 0.033 0.272 field.halo_ex_1@0 + 13 0.004 5.853 0.000 0.000 353 0.001 0.001 __vernier__@0 + 14 0.001 5.854 0.000 0.000 78 0.001 0.001 field.initialise@0 + +Task 2 of 6 : MPI rank ID 1 +Profiling on 6 thread(s). + + # % Time Cumul Self Total # of calls Self Total Routine@ + (Size; Size/sec; Size/call; MinSize; MaxSize) + (self) (sec) (sec) (sec) ms/call ms/call + + 1 43.991 3.009 3.009 3.069 2 1504.362 1534.740 io.finalise_context@0 + 2 37.835 5.596 2.588 6.839 1 2587.638 6839.336 __example_app__@0 + 3 15.751 6.674 1.077 1.077 110 9.794 9.794 io.write_fldg@0 + 4 0.888 6.734 0.061 0.061 1 60.754 60.754 io.context_finalize@0 + 5 0.629 6.777 0.043 0.063 1 43.047 63.469 io.init_context@0 + 6 0.532 6.814 0.036 0.037 1 36.405 36.792 example_algorithm@0 + 7 0.240 6.830 0.016 0.016 1 16.389 16.389 io.close_context_definition@0 + 8 0.051 6.834 0.004 0.004 14 0.251 0.251 halo_routing_creation@0 + 9 0.038 6.836 0.003 0.003 9 0.291 0.291 fs.constructor@0 + 10 0.024 6.838 0.002 0.002 11 0.150 0.150 clock.update_calendar@0 + 11 0.008 6.839 0.001 1.078 110 0.005 9.799 field.write@0 + 12 0.007 6.839 0.000 0.004 14 0.033 0.286 field.halo_ex_1@0 + 13 0.004 6.839 0.000 0.000 353 0.001 0.001 __vernier__@0 + 14 0.001 6.839 0.000 0.000 78 0.001 0.001 field.initialise@0 + +Task 3 of 6 : MPI rank ID 2 +Profiling on 6 thread(s). + + # % Time Cumul Self Total # of calls Self Total Routine@ + (Size; Size/sec; Size/call; MinSize; MaxSize) + (self) (sec) (sec) (sec) ms/call ms/call + + 1 51.055 3.996 3.996 4.056 2 1997.848 2028.230 io.finalise_context@0 + 2 33.227 6.596 2.600 7.826 1 2600.448 7826.276 __example_app__@0 + 3 13.735 7.671 1.075 1.075 110 9.772 9.772 io.write_fldg@0 + 4 0.776 7.732 0.061 0.061 1 60.759 60.759 io.context_finalize@0 + 5 0.471 7.769 0.037 0.037 1 36.851 37.211 example_algorithm@0 + 6 0.412 7.801 0.032 0.053 1 32.210 52.583 io.init_context@0 + 7 0.210 7.817 0.016 0.016 1 16.401 16.401 io.close_context_definition@0 + 8 0.043 7.821 0.003 0.003 14 0.241 0.241 halo_routing_creation@0 + 9 0.034 7.823 0.003 0.003 9 0.296 0.296 fs.constructor@0 + 10 0.017 7.825 0.001 0.001 11 0.122 0.122 clock.update_calendar@0 + 11 0.008 7.825 0.001 0.004 14 0.046 0.287 field.halo_ex_1@0 + 12 0.008 7.826 0.001 1.076 110 0.006 9.779 field.write@0 + 13 0.003 7.826 0.000 0.000 353 0.001 0.001 __vernier__@0 + 14 0.001 7.826 0.000 0.000 78 0.001 0.001 field.initialise@0 + +Task 4 of 6 : MPI rank ID 3 +Profiling on 6 thread(s). + + # % Time Cumul Self Total # of calls Self Total Routine@ + (Size; Size/sec; Size/call; MinSize; MaxSize) + (self) (sec) (sec) (sec) ms/call ms/call + + 1 44.493 2.596 2.596 5.834 1 2595.890 5834.339 __example_app__@0 + 2 34.343 4.600 2.004 2.064 2 1001.852 1032.244 io.finalise_context@0 + 3 18.668 5.689 1.089 1.089 110 9.901 9.901 io.write_fldg@0 + 4 1.042 5.750 0.061 0.061 1 60.779 60.779 io.context_finalize@0 + 5 0.623 5.786 0.036 0.037 1 36.330 36.708 example_algorithm@0 + 6 0.388 5.809 0.023 0.043 1 22.666 43.053 io.init_context@0 + 7 0.281 5.825 0.016 0.016 1 16.389 16.389 io.close_context_definition@0 + 8 0.060 5.828 0.004 0.004 14 0.251 0.251 halo_routing_creation@0 + 9 0.045 5.831 0.003 0.003 9 0.290 0.290 fs.constructor@0 + 10 0.033 5.833 0.002 0.002 11 0.173 0.173 clock.update_calendar@0 + 11 0.011 5.834 0.001 1.090 110 0.006 9.908 field.write@0 + 12 0.008 5.834 0.000 0.004 14 0.035 0.286 field.halo_ex_1@0 + 13 0.004 5.834 0.000 0.000 353 0.001 0.001 __vernier__@0 + 14 0.001 5.834 0.000 0.000 78 0.001 0.001 field.initialise@0 + +Task 5 of 6 : MPI rank ID 4 +Profiling on 6 thread(s). + + # % Time Cumul Self Total # of calls Self Total Routine@ + (Size; Size/sec; Size/call; MinSize; MaxSize) + (self) (sec) (sec) (sec) ms/call ms/call + + 1 45.082 2.627 2.627 5.828 1 2627.337 5827.847 __example_app__@0 + 2 34.270 4.625 1.997 2.058 2 998.596 1028.978 io.finalise_context@0 + 3 17.969 5.672 1.047 1.047 110 9.520 9.520 io.write_fldg@0 + 4 1.043 5.733 0.061 0.061 1 60.759 60.759 io.context_finalize@0 + 5 0.625 5.769 0.036 0.037 1 36.400 36.742 example_algorithm@0 + 6 0.571 5.802 0.033 0.054 1 33.255 53.602 io.init_context@0 + 7 0.280 5.818 0.016 0.016 1 16.329 16.329 io.close_context_definition@0 + 8 0.060 5.822 0.003 0.003 14 0.249 0.249 halo_routing_creation@0 + 9 0.045 5.825 0.003 0.003 9 0.291 0.291 fs.constructor@0 + 10 0.031 5.826 0.002 0.002 11 0.165 0.165 clock.update_calendar@0 + 11 0.011 5.827 0.001 1.048 110 0.006 9.527 field.write@0 + 12 0.008 5.828 0.000 0.004 14 0.034 0.283 field.halo_ex_1@0 + 13 0.004 5.828 0.000 0.000 353 0.001 0.001 __vernier__@0 + 14 0.001 5.828 0.000 0.000 78 0.001 0.001 field.initialise@0 + +Task 6 of 6 : MPI rank ID 5 +Profiling on 6 thread(s). + + # % Time Cumul Self Total # of calls Self Total Routine@ + (Size; Size/sec; Size/call; MinSize; MaxSize) + (self) (sec) (sec) (sec) ms/call ms/call + + 1 51.038 3.993 3.993 4.054 2 1996.512 2026.900 io.finalise_context@0 + 2 33.296 6.598 2.605 7.824 1 2604.951 7823.629 __example_app__@0 + 3 13.796 7.677 1.079 1.079 110 9.813 9.813 io.write_fldg@0 + 4 0.777 7.738 0.061 0.061 1 60.769 60.769 io.context_finalize@0 + 5 0.469 7.775 0.037 0.037 1 36.726 37.110 example_algorithm@0 + 6 0.300 7.798 0.023 0.044 1 23.472 43.742 io.init_context@0 + 7 0.210 7.815 0.016 0.016 1 16.418 16.418 io.close_context_definition@0 + 8 0.043 7.818 0.003 0.003 14 0.238 0.238 halo_routing_creation@0 + 9 0.034 7.821 0.003 0.003 9 0.296 0.296 fs.constructor@0 + 10 0.018 7.822 0.001 0.001 11 0.130 0.130 clock.update_calendar@0 + 11 0.007 7.823 0.001 0.004 14 0.042 0.280 field.halo_ex_1@0 + 12 0.007 7.823 0.001 1.080 110 0.005 9.819 field.write@0 + 13 0.003 7.824 0.000 0.000 353 0.001 0.001 __vernier__@0 + 14 0.001 7.824 0.000 0.000 78 0.001 0.001 field.initialise@0 diff --git a/post-processing/lib/vernier/vernier_data.py b/post-processing/lib/vernier/vernier_data.py index fbea117f..151cb570 100644 --- a/post-processing/lib/vernier/vernier_data.py +++ b/post-processing/lib/vernier/vernier_data.py @@ -63,6 +63,7 @@ def write_txt_output(self, txt_path: Optional[Path] = None): if txt_path is None: for row in txt_table: print('| {:>32} | {:>16} | {:>12} | {:>10} | {:>10} | {:>18} |'.format(*row)) + print("\n") else: with open(txt_path, 'w') as f: for row in txt_table: From 3a0521f0bde2d9700093788f4a5fa2262d469a03 Mon Sep 17 00:00:00 2001 From: EdHone Date: Wed, 18 Feb 2026 13:02:30 +0000 Subject: [PATCH 3/8] Add unit tests --- post-processing/lib/tests/__init__.py | 0 .../lib/tests/data/vernier-output-test | 20 +++++ .../lib/tests/test_vernier_data.py | 81 +++++++++++++++++++ .../lib/tests/test_vernier_reader.py | 40 +++++++++ post-processing/lib/vernier/vernier_reader.py | 7 +- 5 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 post-processing/lib/tests/__init__.py create mode 100644 post-processing/lib/tests/data/vernier-output-test create mode 100644 post-processing/lib/tests/test_vernier_data.py create mode 100644 post-processing/lib/tests/test_vernier_reader.py diff --git a/post-processing/lib/tests/__init__.py b/post-processing/lib/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/post-processing/lib/tests/data/vernier-output-test b/post-processing/lib/tests/data/vernier-output-test new file mode 100644 index 00000000..29bc4c68 --- /dev/null +++ b/post-processing/lib/tests/data/vernier-output-test @@ -0,0 +1,20 @@ + +Task 1 of 2 : MPI rank ID 0 +Profiling on 1 thread(s). + + # % Time Cumul Self Total # of calls Self Total Routine@ + (Size; Size/sec; Size/call; MinSize; MaxSize) + (self) (sec) (sec) (sec) ms/call ms/call + + 1 44.130 2.583 2.583 5.854 1 2583.167 5853.527 __test_app__ + 2 34.563 4.606 2.023 2.077 2 1011.582 1038.697 some_process + +Task 2 of 2 : MPI rank ID 1 +Profiling on 1 thread(s). + + # % Time Cumul Self Total # of calls Self Total Routine@ + (Size; Size/sec; Size/call; MinSize; MaxSize) + (self) (sec) (sec) (sec) ms/call ms/call + + 1 43.991 3.009 3.009 3.069 2 1504.362 1534.740 some_process + 2 37.835 5.596 2.588 6.839 1 2587.638 6839.336 __test_app__ diff --git a/post-processing/lib/tests/test_vernier_data.py b/post-processing/lib/tests/test_vernier_data.py new file mode 100644 index 00000000..4b0d98bc --- /dev/null +++ b/post-processing/lib/tests/test_vernier_data.py @@ -0,0 +1,81 @@ +from pathlib import Path +from io import StringIO +import tempfile +import unittest +import sys +sys.path.append('../vernier') +from vernier.vernier_data import VernierData + +class TestVernierData(unittest.TestCase): + """ + Tests for the VernierData class + """ + def setUp(self): + self.test_data = VernierData() + + def test_add_empty_caliper(self): + self.test_data.add_caliper("test_caliper") + self.assertIn("test_caliper", self.test_data.data) + self.assertEqual(self.test_data.data["test_caliper"]["%time"], []) + self.assertEqual(self.test_data.data["test_caliper"]["cumul"], []) + self.assertEqual(self.test_data.data["test_caliper"]["self"], []) + self.assertEqual(self.test_data.data["test_caliper"]["total"], []) + self.assertEqual(self.test_data.data["test_caliper"]["n_calls"], []) + + def test_filter_caliper(self): + self.test_data.add_caliper("timestep_caliper") + self.test_data.add_caliper("other_caliper") + filtered = self.test_data.filter(["timestep"]) + self.assertIn("timestep_caliper", filtered.data) + self.assertNotIn("other_caliper", filtered.data) + + def test_filter_no_match(self): + self.test_data.add_caliper("timestep_caliper") + with self.assertRaises(ValueError): + self.test_data.filter(["nonexistent"]) + + def test_filter_multiple_matches(self): + self.test_data.add_caliper("timestep_caliper_1") + self.test_data.add_caliper("timestep_caliper_2") + filtered = self.test_data.filter(["timestep"]) + self.assertIn("timestep_caliper_1", filtered.data) + self.assertIn("timestep_caliper_2", filtered.data) + + def test_filter_empty_keys(self): + self.test_data.add_caliper("timestep_caliper") + with self.assertRaises(ValueError): + self.test_data.filter([]) + + def test_write_txt_output_file(self): + self.test_data.add_caliper("test_caliper") + self.test_data.data["test_caliper"]["%time"] = [10.0, 20.0] + self.test_data.data["test_caliper"]["cumul"] = [30.0, 40.0] + self.test_data.data["test_caliper"]["self"] = [5.0, 15.0] + self.test_data.data["test_caliper"]["total"] = [25.0, 35.0] + self.test_data.data["test_caliper"]["n_calls"] = [2] + + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + self.test_data.write_txt_output(Path(tmp_file.name)) + contents = Path(tmp_file.name).read_text().splitlines() + self.assertEqual("| Routine | Total time (s) | Self (s) | No. calls | % time | Time per call (s) |", contents[0]) + self.assertEqual("| test_caliper | 30.0 | 10.0 | 2 | 15.0 | 15.0 |", contents[1]) + + + def test_write_txt_output_terminal(self): + self.test_data.add_caliper("test_caliper") + self.test_data.data["test_caliper"]["%time"] = [50.0, 40.0] + self.test_data.data["test_caliper"]["cumul"] = [10.0, 12.0] + self.test_data.data["test_caliper"]["self"] = [3.0, 4.0] + self.test_data.data["test_caliper"]["total"] = [15.0, 55.0] + self.test_data.data["test_caliper"]["n_calls"] = [2] + + write_output = StringIO() + sys.stdout = write_output + self.test_data.write_txt_output() + sys.stdout = sys.__stdout__ + + self.assertEqual("| Routine | Total time (s) | Self (s) | No. calls | % time | Time per call (s) |", write_output.getvalue().splitlines()[0]) + self.assertEqual("| test_caliper | 35.0 | 3.5 | 2 | 45.0 | 17.5 |", write_output.getvalue().splitlines()[1]) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/post-processing/lib/tests/test_vernier_reader.py b/post-processing/lib/tests/test_vernier_reader.py new file mode 100644 index 00000000..eea8d8ea --- /dev/null +++ b/post-processing/lib/tests/test_vernier_reader.py @@ -0,0 +1,40 @@ +from pathlib import Path +import unittest +import sys +sys.path.append('../vernier') +from vernier.vernier_reader import VernierReader + +class TestVernierReader(unittest.TestCase): + """ + Tests for the VernierReader class + """ + def setUp(self): + test_data = Path(__file__).parent / "data/vernier-output-test" + self.test_reader = VernierReader(test_data) + + def test_nonexistent_file(self): + with self.assertRaises(ValueError): + VernierReader(Path(__file__).parent / "data/nonexistent-file").load() + + def test_load_from_file(self): + loaded_data = self.test_reader.load() + self.assertIn("__test_app__", loaded_data.data) + self.assertIn("some_process", loaded_data.data) + self.assertEqual(loaded_data.data["__test_app__"]["n_calls"], [1]) + self.assertEqual(loaded_data.data["some_process"]["n_calls"], [2]) + self.assertEqual(loaded_data.data["__test_app__"]["%time"], [44.130, 37.835]) + self.assertEqual(loaded_data.data["some_process"]["%time"], [34.563, 43.991]) + self.assertEqual(loaded_data.data["__test_app__"]["cumul"], [2.583, 5.596]) + self.assertEqual(loaded_data.data["some_process"]["cumul"], [4.606, 3.009]) + self.assertEqual(loaded_data.data["__test_app__"]["self"], [2.583, 2.588]) + self.assertEqual(loaded_data.data["some_process"]["self"], [2.023, 3.009]) + self.assertEqual(loaded_data.data["__test_app__"]["total"], [5.854, 6.839]) + self.assertEqual(loaded_data.data["some_process"]["total"],[2.077, 3.069]) + + def test_load_from_directory(self): + with self.assertRaises(NotImplementedError): + VernierReader(Path(__file__).parent / "data").load() + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/post-processing/lib/vernier/vernier_reader.py b/post-processing/lib/vernier/vernier_reader.py index 1a6ff79b..063dda9f 100644 --- a/post-processing/lib/vernier/vernier_reader.py +++ b/post-processing/lib/vernier/vernier_reader.py @@ -38,6 +38,8 @@ def _load_from_file(self) -> VernierData: if not int(sline[5]) in loaded.data[caliper]["n_calls"]: loaded.data[caliper]["n_calls"].append(int(sline[5])) + handle.close() + return loaded @@ -49,4 +51,7 @@ def load(self) -> VernierData: return self._load_from_file() elif self.path.is_dir(): - raise NotImplementedError("Loading from a directory of Vernier output files is not yet implemented.") \ No newline at end of file + raise NotImplementedError("Loading from a directory of Vernier output files is not yet implemented.") + + else: + raise ValueError(f"Provided path {self.path} is neither a file nor a directory.") \ No newline at end of file From 85a3b4c502655ff214056cadfa9402c858388f7a Mon Sep 17 00:00:00 2001 From: EdHone Date: Wed, 18 Feb 2026 13:44:08 +0000 Subject: [PATCH 4/8] Add python CI --- .github/workflows/test-python-lib.yml | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/test-python-lib.yml diff --git a/.github/workflows/test-python-lib.yml b/.github/workflows/test-python-lib.yml new file mode 100644 index 00000000..79c0c9e3 --- /dev/null +++ b/.github/workflows/test-python-lib.yml @@ -0,0 +1,33 @@ +# ------------------------------------------------------------------------------ +# (c) Crown copyright Met Office. All rights reserved. +# The file LICENCE, distributed with this code, contains details of the terms +# under which the code may be used. +# ------------------------------------------------------------------------------ +name: Test Python library + +on: + workflow_dispatch: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: 3.13 + - name: Run unit tests + run: | + python -m unittest discover -s post-processing/lib + - name: Run example script + run: | + python -m pip install --upgrade pip + python -m pip install numpy + cd post-processing/example + python process_vernier.py vernier-output-example-collated \ No newline at end of file From aa6b699fb79824af48e1e68a33152549946851a8 Mon Sep 17 00:00:00 2001 From: EdHone Date: Wed, 18 Feb 2026 13:50:39 +0000 Subject: [PATCH 5/8] Fix python requirements --- .github/workflows/test-python-lib.yml | 6 ++++-- post-processing/lib/requirements.txt | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 post-processing/lib/requirements.txt diff --git a/.github/workflows/test-python-lib.yml b/.github/workflows/test-python-lib.yml index 79c0c9e3..a2645122 100644 --- a/.github/workflows/test-python-lib.yml +++ b/.github/workflows/test-python-lib.yml @@ -22,12 +22,14 @@ jobs: uses: actions/setup-python@v3 with: python-version: 3.13 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r post-processing/lib/requirements.txt - name: Run unit tests run: | python -m unittest discover -s post-processing/lib - name: Run example script run: | - python -m pip install --upgrade pip - python -m pip install numpy cd post-processing/example python process_vernier.py vernier-output-example-collated \ No newline at end of file diff --git a/post-processing/lib/requirements.txt b/post-processing/lib/requirements.txt new file mode 100644 index 00000000..296d6545 --- /dev/null +++ b/post-processing/lib/requirements.txt @@ -0,0 +1 @@ +numpy \ No newline at end of file From 48871b3079dd01365b76d515a32fa0536452edc0 Mon Sep 17 00:00:00 2001 From: EdHone Date: Wed, 18 Feb 2026 14:16:21 +0000 Subject: [PATCH 6/8] Tweak reader --- post-processing/lib/vernier/vernier_reader.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/post-processing/lib/vernier/vernier_reader.py b/post-processing/lib/vernier/vernier_reader.py index 063dda9f..102dbbcd 100644 --- a/post-processing/lib/vernier/vernier_reader.py +++ b/post-processing/lib/vernier/vernier_reader.py @@ -16,16 +16,14 @@ def _load_from_file(self) -> VernierData: Loads Vernier data from a single file, and returns it as a VernierData object. """ - handle = open(self.path, 'r') - loaded = VernierData() # Populate data - contents = handle.readlines() + contents = self.path.read_text().splitlines() for line in contents: sline = line.split() - if len(sline) > 0: - if sline[0].isdigit(): + if len(sline) > 0: # Line contains data + if sline[0].isdigit(): # Caliper lines start with a digit caliper = sline[-1] if not caliper in loaded.data: @@ -38,8 +36,6 @@ def _load_from_file(self) -> VernierData: if not int(sline[5]) in loaded.data[caliper]["n_calls"]: loaded.data[caliper]["n_calls"].append(int(sline[5])) - handle.close() - return loaded @@ -54,4 +50,4 @@ def load(self) -> VernierData: raise NotImplementedError("Loading from a directory of Vernier output files is not yet implemented.") else: - raise ValueError(f"Provided path {self.path} is neither a file nor a directory.") \ No newline at end of file + raise ValueError(f"Provided path '{self.path}' is neither a file nor a directory.") \ No newline at end of file From 58ac066a83214a3b4ba23dafc8aa224344113477 Mon Sep 17 00:00:00 2001 From: EdHone Date: Wed, 18 Feb 2026 14:59:12 +0000 Subject: [PATCH 7/8] Dataclass implementation for calipers --- .../lib/tests/test_vernier_caliper.py | 47 ++++++++++++++ .../lib/tests/test_vernier_data.py | 38 ++++++------ .../lib/tests/test_vernier_reader.py | 20 +++--- post-processing/lib/vernier/vernier_data.py | 61 +++++++++++++------ post-processing/lib/vernier/vernier_reader.py | 11 ++-- 5 files changed, 124 insertions(+), 53 deletions(-) create mode 100644 post-processing/lib/tests/test_vernier_caliper.py diff --git a/post-processing/lib/tests/test_vernier_caliper.py b/post-processing/lib/tests/test_vernier_caliper.py new file mode 100644 index 00000000..1f2b23fb --- /dev/null +++ b/post-processing/lib/tests/test_vernier_caliper.py @@ -0,0 +1,47 @@ +import unittest +import sys +sys.path.append('../vernier') +from vernier.vernier_data import VernierCaliper + +class TestVernierCaliper(unittest.TestCase): + + def setUp(self): + self.caliper_a = VernierCaliper("test_caliper_a") + self.caliper_b = VernierCaliper("test_caliper_b") + + def test_init(self): + self.assertEqual(self.caliper_a.name, "test_caliper_a") + self.assertEqual(self.caliper_a.time_percent, []) + self.assertEqual(self.caliper_a.cumul_time, []) + self.assertEqual(self.caliper_a.self_time, []) + self.assertEqual(self.caliper_a.total_time, []) + self.assertEqual(self.caliper_a.n_calls, []) + + def test_reduce(self): + self.caliper_a.time_percent = [10.0, 20.0] + self.caliper_a.cumul_time = [30.0, 40.0] + self.caliper_a.self_time = [5.0, 15.0] + self.caliper_a.total_time = [25.0, 35.0] + self.caliper_a.n_calls = [2, 2] + + reduced_data = self.caliper_a.reduce() + self.assertEqual(reduced_data[0], "test_caliper_a") + self.assertEqual(reduced_data[1], 30.0) + self.assertEqual(reduced_data[2], 10.0) + self.assertEqual(reduced_data[3], 2) + self.assertEqual(reduced_data[4], 15.0) + self.assertEqual(reduced_data[5], 15.0) + + def test_compare(self): + self.caliper_b.time_percent = [12.0, 25.0] + self.caliper_b.cumul_time = [35.0, 46.0] + self.caliper_b.self_time = [6.0, 19.0] + self.caliper_b.total_time = [28.0, 39.0] + self.caliper_b.n_calls = [2, 2] + + self.assertTrue(self.caliper_a < self.caliper_b) + self.assertFalse(self.caliper_a > self.caliper_b) + self.assertFalse(self.caliper_a == self.caliper_b) + +if __name__ == '__main__': + unittest.main() diff --git a/post-processing/lib/tests/test_vernier_data.py b/post-processing/lib/tests/test_vernier_data.py index 4b0d98bc..6d2edf08 100644 --- a/post-processing/lib/tests/test_vernier_data.py +++ b/post-processing/lib/tests/test_vernier_data.py @@ -16,11 +16,11 @@ def setUp(self): def test_add_empty_caliper(self): self.test_data.add_caliper("test_caliper") self.assertIn("test_caliper", self.test_data.data) - self.assertEqual(self.test_data.data["test_caliper"]["%time"], []) - self.assertEqual(self.test_data.data["test_caliper"]["cumul"], []) - self.assertEqual(self.test_data.data["test_caliper"]["self"], []) - self.assertEqual(self.test_data.data["test_caliper"]["total"], []) - self.assertEqual(self.test_data.data["test_caliper"]["n_calls"], []) + self.assertEqual(self.test_data.data["test_caliper"].time_percent, []) + self.assertEqual(self.test_data.data["test_caliper"].cumul_time, []) + self.assertEqual(self.test_data.data["test_caliper"].self_time, []) + self.assertEqual(self.test_data.data["test_caliper"].total_time, []) + self.assertEqual(self.test_data.data["test_caliper"].n_calls, []) def test_filter_caliper(self): self.test_data.add_caliper("timestep_caliper") @@ -48,34 +48,34 @@ def test_filter_empty_keys(self): def test_write_txt_output_file(self): self.test_data.add_caliper("test_caliper") - self.test_data.data["test_caliper"]["%time"] = [10.0, 20.0] - self.test_data.data["test_caliper"]["cumul"] = [30.0, 40.0] - self.test_data.data["test_caliper"]["self"] = [5.0, 15.0] - self.test_data.data["test_caliper"]["total"] = [25.0, 35.0] - self.test_data.data["test_caliper"]["n_calls"] = [2] + self.test_data.data["test_caliper"].time_percent = [10.0, 20.0] + self.test_data.data["test_caliper"].cumul_time = [30.0, 40.0] + self.test_data.data["test_caliper"].self_time = [5.0, 15.0] + self.test_data.data["test_caliper"].total_time = [25.0, 35.0] + self.test_data.data["test_caliper"].n_calls = [2] with tempfile.NamedTemporaryFile(delete=False) as tmp_file: self.test_data.write_txt_output(Path(tmp_file.name)) contents = Path(tmp_file.name).read_text().splitlines() - self.assertEqual("| Routine | Total time (s) | Self (s) | No. calls | % time | Time per call (s) |", contents[0]) - self.assertEqual("| test_caliper | 30.0 | 10.0 | 2 | 15.0 | 15.0 |", contents[1]) + self.assertEqual("| Routine | Total time (s) | Self (s) | No. calls | % time | Time per call (s) |", contents[0]) + self.assertEqual("| test_caliper | 30.0 | 10.0 | 2 | 15.0 | 15.0 |", contents[1]) def test_write_txt_output_terminal(self): self.test_data.add_caliper("test_caliper") - self.test_data.data["test_caliper"]["%time"] = [50.0, 40.0] - self.test_data.data["test_caliper"]["cumul"] = [10.0, 12.0] - self.test_data.data["test_caliper"]["self"] = [3.0, 4.0] - self.test_data.data["test_caliper"]["total"] = [15.0, 55.0] - self.test_data.data["test_caliper"]["n_calls"] = [2] + self.test_data.data["test_caliper"].time_percent = [50.0, 40.0] + self.test_data.data["test_caliper"].cumul_time = [10.0, 12.0] + self.test_data.data["test_caliper"].self_time = [3.0, 4.0] + self.test_data.data["test_caliper"].total_time = [15.0, 55.0] + self.test_data.data["test_caliper"].n_calls = [2] write_output = StringIO() sys.stdout = write_output self.test_data.write_txt_output() sys.stdout = sys.__stdout__ - self.assertEqual("| Routine | Total time (s) | Self (s) | No. calls | % time | Time per call (s) |", write_output.getvalue().splitlines()[0]) - self.assertEqual("| test_caliper | 35.0 | 3.5 | 2 | 45.0 | 17.5 |", write_output.getvalue().splitlines()[1]) + self.assertEqual("| Routine | Total time (s) | Self (s) | No. calls | % time | Time per call (s) |", write_output.getvalue().splitlines()[0]) + self.assertEqual("| test_caliper | 35.0 | 3.5 | 2 | 45.0 | 17.5 |", write_output.getvalue().splitlines()[1]) if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/post-processing/lib/tests/test_vernier_reader.py b/post-processing/lib/tests/test_vernier_reader.py index eea8d8ea..f6ac2660 100644 --- a/post-processing/lib/tests/test_vernier_reader.py +++ b/post-processing/lib/tests/test_vernier_reader.py @@ -20,16 +20,16 @@ def test_load_from_file(self): loaded_data = self.test_reader.load() self.assertIn("__test_app__", loaded_data.data) self.assertIn("some_process", loaded_data.data) - self.assertEqual(loaded_data.data["__test_app__"]["n_calls"], [1]) - self.assertEqual(loaded_data.data["some_process"]["n_calls"], [2]) - self.assertEqual(loaded_data.data["__test_app__"]["%time"], [44.130, 37.835]) - self.assertEqual(loaded_data.data["some_process"]["%time"], [34.563, 43.991]) - self.assertEqual(loaded_data.data["__test_app__"]["cumul"], [2.583, 5.596]) - self.assertEqual(loaded_data.data["some_process"]["cumul"], [4.606, 3.009]) - self.assertEqual(loaded_data.data["__test_app__"]["self"], [2.583, 2.588]) - self.assertEqual(loaded_data.data["some_process"]["self"], [2.023, 3.009]) - self.assertEqual(loaded_data.data["__test_app__"]["total"], [5.854, 6.839]) - self.assertEqual(loaded_data.data["some_process"]["total"],[2.077, 3.069]) + self.assertEqual(loaded_data.data["__test_app__"].n_calls, [1, 1]) + self.assertEqual(loaded_data.data["some_process"].n_calls, [2, 2]) + self.assertEqual(loaded_data.data["__test_app__"].time_percent, [44.130, 37.835]) + self.assertEqual(loaded_data.data["some_process"].time_percent, [34.563, 43.991]) + self.assertEqual(loaded_data.data["__test_app__"].cumul_time, [2.583, 5.596]) + self.assertEqual(loaded_data.data["some_process"].cumul_time, [4.606, 3.009]) + self.assertEqual(loaded_data.data["__test_app__"].self_time, [2.583, 2.588]) + self.assertEqual(loaded_data.data["some_process"].self_time, [2.023, 3.009]) + self.assertEqual(loaded_data.data["__test_app__"].total_time, [5.854, 6.839]) + self.assertEqual(loaded_data.data["some_process"].total_time,[2.077, 3.069]) def test_load_from_directory(self): with self.assertRaises(NotImplementedError): diff --git a/post-processing/lib/vernier/vernier_data.py b/post-processing/lib/vernier/vernier_data.py index 151cb570..bc3f7f61 100644 --- a/post-processing/lib/vernier/vernier_data.py +++ b/post-processing/lib/vernier/vernier_data.py @@ -1,7 +1,44 @@ +from dataclasses import dataclass import numpy as np from pathlib import Path from typing import Optional +@dataclass(order=True) +class VernierCaliper(): + """Class to hold data for a single Vernier caliper, including arrays for each metric.""" + + total_time: list[float] + time_percent: list[float] + self_time: list[float] + cumul_time: list[float] + n_calls: list[int] + name: str + + def __init__(self, name: str): + + self.name = name + self.time_percent = [] + self.cumul_time = [] + self.self_time = [] + self.total_time = [] + self.n_calls = [] + + return + + def reduce(self) -> list[str]: + """Reduces the data for this caliper to a single row of summary data.""" + + return [ + self.name.replace('@0', ''), # caliper name + round(np.mean(self.total_time), 5), # mean total time across calls + round(np.mean(self.self_time), 5), # mean self time across calls + round(np.mean(self.cumul_time), 5), # mean cumulative time across calls + self.n_calls[0], # number of calls (should be the same for all entries, so just take the first) + round(np.mean(self.time_percent), 5), # mean percentage of time across calls + round(np.mean(self.total_time) / self.n_calls[0], 5) # mean time per call + ] + + class VernierData(): """Class to hold Vernier data in a structured way, and provide methods for filtering and outputting the data.""" @@ -16,13 +53,7 @@ def add_caliper(self, caliper_key): """Adds a new caliper to the data structure, with empty arrays for each metric.""" # Create empty data arrays - self.data[caliper_key] = { - "%time" : [], - "cumul" : [], - "self" : [], - "total" : [], - "n_calls" : [] - } + self.data[caliper_key] = VernierCaliper(caliper_key) def filter(self, caliper_keys: list[str]): @@ -49,22 +80,16 @@ def write_txt_output(self, txt_path: Optional[Path] = None): it is printed to the terminal.""" txt_table = [] - txt_table.append(["Routine", "Total time (s)", "Self (s)", "No. calls", "% time", "Time per call (s)"]) + txt_table.append(["Routine", "Total time (s)", "Self (s)", "Cumul time (s)", "No. calls", "% time", "Time per call (s)"]) for caliper in self.data.keys(): - txt_table.append([ - f"{caliper.replace('@0', '')}", - f"{round(np.mean(self.data[caliper]['total']), 5)}", - f"{round(np.mean(self.data[caliper]['self']), 5)}", - f"{self.data[caliper]['n_calls'][0]}", - f"{round(np.mean(self.data[caliper]['%time']), 5)}", - f"{round(np.mean(self.data[caliper]['total']) / self.data[caliper]['n_calls'][0], 5)}" - ]) + txt_table.append(self.data[caliper].reduce()) + max_caliper_len = max([len(line[0]) for line in txt_table]) if txt_path is None: for row in txt_table: - print('| {:>32} | {:>16} | {:>12} | {:>10} | {:>10} | {:>18} |'.format(*row)) + print('| {:>{}} | {:>14} | {:>8} | {:>14} | {:>9} | {:>7} | {:>17} |'.format(row[0], max_caliper_len, *row[1:])) print("\n") else: with open(txt_path, 'w') as f: for row in txt_table: - f.write('| {:>32} | {:>16} | {:>12} | {:>10} | {:>10} | {:>18} |\n'.format(*row)) \ No newline at end of file + f.write('| {:>{}} | {:>14} | {:>8} | {:>14} | {:>9} | {:>7} | {:>17} |\n'.format(row[0], max_caliper_len, *row[1:])) \ No newline at end of file diff --git a/post-processing/lib/vernier/vernier_reader.py b/post-processing/lib/vernier/vernier_reader.py index 102dbbcd..317cb9bd 100644 --- a/post-processing/lib/vernier/vernier_reader.py +++ b/post-processing/lib/vernier/vernier_reader.py @@ -29,12 +29,11 @@ def _load_from_file(self) -> VernierData: if not caliper in loaded.data: loaded.add_caliper(caliper) - loaded.data[caliper]["%time"].append(float(sline[1])) - loaded.data[caliper]["cumul"].append(float(sline[2])) - loaded.data[caliper]["self"].append(float(sline[3])) - loaded.data[caliper]["total"].append(float(sline[4])) - if not int(sline[5]) in loaded.data[caliper]["n_calls"]: - loaded.data[caliper]["n_calls"].append(int(sline[5])) + loaded.data[caliper].time_percent.append(float(sline[1])) + loaded.data[caliper].cumul_time.append(float(sline[2])) + loaded.data[caliper].self_time.append(float(sline[3])) + loaded.data[caliper].total_time.append(float(sline[4])) + loaded.data[caliper].n_calls.append(int(sline[5])) return loaded From dbe9b8cf311363acdcd8302b90dece2c9a0b93fb Mon Sep 17 00:00:00 2001 From: EdHone Date: Wed, 18 Feb 2026 15:00:39 +0000 Subject: [PATCH 8/8] Upgrade actions --- .github/workflows/test-python-lib.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-python-lib.yml b/.github/workflows/test-python-lib.yml index a2645122..a40d4717 100644 --- a/.github/workflows/test-python-lib.yml +++ b/.github/workflows/test-python-lib.yml @@ -17,9 +17,9 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v6 with: python-version: 3.13 - name: Install dependencies