Skip to content
27 changes: 20 additions & 7 deletions src/jade/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@


class JadeApp:
def __init__(self, root: PathLike | None = None, skip_init: bool = False):
def __init__(
self,
root: PathLike | None = None,
skip_init: bool = False,
):
if root is None:
root = os.getcwd()

Expand All @@ -51,12 +55,18 @@ def __init__(self, root: PathLike | None = None, skip_init: bool = False):
# parse the post-processing config
self.pp_cfg = PostProcessConfig(self.tree.cfg.bench_pp)

# Compute the global status
logging.info("Initializing the global status")
self.status = GlobalStatus(
simulations_path=self.tree.simulations,
raw_results_path=self.tree.raw,
)
self._status = None

@property
def status(self) -> GlobalStatus:
"""Lazy-load the global status on first access."""
if self._status is None:
logging.info("Initializing the global status (lazy-loaded)")
self._status = GlobalStatus(
simulations_path=self.tree.simulations,
raw_results_path=self.tree.raw,
)
return self._status
Comment on lines +58 to +69
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Invalidate the cached GlobalStatus after write operations.

This property memoizes one GlobalStatus snapshot for the lifetime of JadeApp. After run_benchmarks() or raw_process() changes the simulations/raw trees, later calls still consult stale simulations / raw_data unless callers refresh manually — tests/app/test_app.py Lines 63-72 now has to do that explicitly. A same-session raw_process()post_process() flow can therefore miss the raw results it just produced.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/jade/app/app.py` around lines 58 - 69, The cached GlobalStatus returned
by JadeApp.status can become stale after write operations; update JadeApp so
that methods that mutate the simulations/raw trees (e.g., run_benchmarks,
raw_process, and any other writer methods) invalidate the cache by setting
self._status = None after completing their write work, and ensure any helper
functions that modify the tree do the same; locate the status property and add
cache-clearing calls in the implementations of run_benchmarks and raw_process
(and post_process if it mutates data) so subsequent accesses to JadeApp.status
construct a fresh GlobalStatus.


def initialize_log(self) -> None:
"""Initialize the custom python logger for JADE."""
Expand Down Expand Up @@ -304,6 +314,9 @@ def post_process(self):
)
continue

# check that the benchmark versions are consistent with each other
self.status.validate_codelibs(code_libs, benchmark)

# prepare the new paths
pp_path = self.tree.get_new_post_bench_path(benchmark)
excel_folder = Path(pp_path, "excel")
Expand Down
111 changes: 102 additions & 9 deletions src/jade/config/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
print_code_lib,
)
from jade.helper.constants import CODE
from jade.helper.errors import VersionInconsistencyError


class GlobalStatus:
Expand All @@ -36,12 +37,39 @@ def __init__(self, simulations_path: PathLike, raw_results_path: PathLike):
"""
self.simulations_path = simulations_path
self.raw_results_path = raw_results_path
self.update()

def update(self):
"""Update the status of the simulations and raw results"""
self.simulations = self._parse_simulations_folder(self.simulations_path)
self.raw_data = self._parse_raw_results_folder(self.raw_results_path)
self._simulations = None
self._raw_data = None
self._raw_metadata = None

@property
def simulations(self) -> dict[tuple[CODE, str, str], CodeLibRunStatus]:
if self._simulations is None:
self._simulations = self._parse_simulations_folder(self.simulations_path)
return self._simulations
Comment on lines +46 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a refresh path for cached simulation state.

After the first simulations access, this instance never rescans simulations_path. update_raw_results() only refreshes raw outputs, so was_simulated() and get_successful_simulations() can stay stale for the lifetime of a long-lived GlobalStatus.

Possible fix
+    def update_simulations(self) -> None:
+        """Update the simulations by re-parsing the simulations folder."""
+        self._simulations = self._parse_simulations_folder(self.simulations_path)
+
     def update_raw_results(self) -> None:
         """Update the raw results by re-parsing the raw results folder. It should be used
         after processing new raw results to update the status.
         """
         self._raw_data, self._raw_metadata = self._parse_raw_results_folder(
             self.raw_results_path
         )

Also applies to: 66-72

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/jade/config/status.py` around lines 46 - 48, The cached attribute
self._simulations (accessed by the simulations property) is never refreshed
after first access, so methods like was_simulated() and
get_successful_simulations() can become stale; modify the simulations property
(and the similar block at lines ~66-72) to add an explicit refresh path (e.g.,
an optional force_refresh flag or refresh method) that calls
self._parse_simulations_folder(self.simulations_path) to repopulate
self._simulations when update_raw_results() runs or when callers request a fresh
scan; update GlobalStatus to call that refresh (or pass force_refresh=True)
before was_simulated() / get_successful_simulations() use the cache so the
folder is rescanned for new/removed simulations.


@property
def raw_data(self) -> dict[tuple[CODE, str, str], list[str]]:
if self._raw_data is None:
self._raw_data, self._raw_metadata = self._parse_raw_results_folder(
self.raw_results_path
)
Comment on lines +52 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make raw-data discovery tolerant to missing metadata, then fail cleanly during validation.

raw_data now depends on metadata.json being present for every non-exp benchmark, so folders without that file will break simple availability queries. _validate_libs_processing() then indexes "benchmark_version" directly, which turns malformed metadata into a raw KeyError instead of a clear consistency error.

Possible fix
-                available_raw_data[(CODE(code), lib, benchmark)] = os.listdir(
-                    bench_path
-                )
+                available_raw_data[(CODE(code), lib, benchmark)] = [
+                    entry for entry in os.listdir(bench_path)
+                    if entry != "metadata.json"
+                ]
                 if lib != "exp":  # for the experiments we don't have metadata
-                    with open(os.path.join(bench_path, "metadata.json")) as infile:
-                        metadata[(CODE(code), lib, benchmark)] = json.load(infile)
+                    metadata_file = Path(bench_path, "metadata.json")
+                    if metadata_file.is_file():
+                        with metadata_file.open() as infile:
+                            metadata[(CODE(code), lib, benchmark)] = json.load(infile)
...
         versions = {}
         for lib in libs:
-            versions[lib] = self.raw_metadata[(code, lib, benchmark)][
-                "benchmark_version"
-            ]
+            version = self.raw_metadata.get((code, lib, benchmark), {}).get(
+                "benchmark_version"
+            )
+            if version is None:
+                raise VersionInconsistencyError(
+                    f"Missing benchmark_version metadata for {code} / {lib} / {benchmark}"
+                )
+            versions[lib] = version

Also applies to: 60-63, 144-146, 292-294

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/jade/config/status.py` around lines 52 - 55, The raw-data discovery
currently requires metadata.json and lets missing or malformed metadata raise
KeyError later; update the logic in the raw_data getter and in
_parse_raw_results_folder to tolerate absent/malformed metadata by returning
None or an empty dict for self._raw_metadata when metadata.json is missing or
invalid, and change _validate_libs_processing to check for the presence of keys
like "benchmark_version" via .get or explicit presence checks on
self._raw_metadata before indexing so it raises a clear ConsistencyError (or
similar) with a descriptive message instead of a raw KeyError; ensure
raw_results_path, _raw_metadata, _raw_data, _parse_raw_results_folder, and
_validate_libs_processing are the symbols updated so availability queries
succeed and validation fails cleanly.

return self._raw_data

@property
def raw_metadata(self) -> dict[tuple[CODE, str, str], dict]:
if self._raw_metadata is None:
self._raw_data, self._raw_metadata = self._parse_raw_results_folder(
self.raw_results_path
)
return self._raw_metadata

def update_raw_results(self) -> None:
"""Update the raw results by re-parsing the raw results folder. It should be used
after processing new raw results to update the status.
"""
self._raw_data, self._raw_metadata = self._parse_raw_results_folder(
self.raw_results_path
)

def _parse_simulations_folder(
self, simulations_path: PathLike
Expand All @@ -65,7 +93,9 @@ def _parse_simulations_folder(
for sub_bench in os.listdir(bench_path):
# check if the run was successful
sub_bench_path = Path(bench_path, sub_bench)
success = CODE_CHECKERS[code](sub_bench_path)
success = CODE_CHECKERS[code].check_success(
os.listdir(sub_bench_path)
)
if success:
successful.append(sub_bench)
else:
Expand Down Expand Up @@ -93,9 +123,12 @@ def _parse_simulations_folder(

def _parse_raw_results_folder(
self, path_raw: PathLike
) -> dict[tuple[CODE, str, str], list[str]]:
) -> tuple[
dict[tuple[CODE, str, str], list[str]], dict[tuple[CODE, str, str], dict]
]:
# simply store a dictionary with the processed raw results
available_raw_data = {}
metadata = {}
for code_lib in os.listdir(path_raw):
codelib_path = Path(path_raw, code_lib)
if not codelib_path.is_dir():
Expand All @@ -108,7 +141,10 @@ def _parse_raw_results_folder(
available_raw_data[(CODE(code), lib, benchmark)] = os.listdir(
bench_path
)
return available_raw_data
if lib != "exp": # for the experiments we don't have metadata
with open(os.path.join(bench_path, "metadata.json")) as infile:
metadata[(CODE(code), lib, benchmark)] = json.load(infile)
return available_raw_data, metadata

def was_simulated(self, code: CODE, lib: str, benchmark: str) -> bool:
"""Check if a simulation was already performed and if it was successful.
Expand Down Expand Up @@ -230,6 +266,63 @@ def is_raw_available(self, codelib: str, benchmark: str) -> bool:
return True
return False

def _validate_libs_processing(
self, code: CODE, benchmark: str, libs: list[str]
) -> None:
"""Check that the post-processing can be performed for the given code and
benchmark. This is true if the benchmark version for the requested libs
are the same.

Parameters
----------
code : CODE
code used in the simulation.
benchmark : str
benchmark name.
libs : list[str]
list of libraries to check.

Returns
-------
bool
True if the post-processing can be performed, False otherwise.
Comment on lines +285 to +288
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Docstring incorrectly states return type.

The docstring states "Returns -> bool: True if the post-processing can be performed" but the method returns None or raises VersionInconsistencyError. Update the docstring to reflect the actual behavior.

📝 Proposed docstring fix
-        Returns
-        -------
-        bool
-            True if the post-processing can be performed, False otherwise.
+        Raises
+        ------
+        VersionInconsistencyError
+            If the benchmark versions are not consistent across the requested libraries.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/jade/config/status.py` around lines 285 - 288, The docstring is
incorrect: update the docstring for the function that raises
VersionInconsistencyError (the function documented in this block) to state that
it returns None on success and to include a Raises section documenting
VersionInconsistencyError instead of claiming it returns bool; specifically
change the Returns section to "None" (or remove it) and add a
"Raises\n-------\nVersionInconsistencyError: when post-processing cannot be
performed" so the docstring matches the actual behavior.

"""
versions = {}
for lib in libs:
# I want only the major version
versions[lib] = self.raw_metadata[(code, lib, benchmark)][
"benchmark_version"
].split(".")[0]
if len(set(versions.values())) > 1:
raise VersionInconsistencyError(
f"The versions of the requested libraries for {code} and benchmark {benchmark} are not consistent: {versions}"
)

def validate_codelibs(
self, codelibs: list[tuple[CODE, str]], benchmarks: str
) -> None:
"""Check that a list of codelibs can be post-processed together for a given benchmark.
This is true if the benchmark version for the requested libs are the same.

Parameters
----------
codelibs : list[tuple[CODE, str]]
list of codelibs to check. Each codelib is a tuple with the code and library.
benchmark : str
benchmark name.
Comment on lines +301 to +312
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Parameter name and docstring inconsistency.

The parameter is named benchmarks (line 302) but the docstring references benchmark (line 311-312). Since the type hint indicates str (singular benchmark), consider renaming the parameter to benchmark for consistency.

📝 Proposed fix
     def validate_codelibs(
-        self, codelibs: list[tuple[CODE, str]], benchmarks: str
+        self, codelibs: list[tuple[CODE, str]], benchmark: str
     ) -> None:
         """Check that a list of codelibs can be post-processed together for a given benchmark.
...
         for code, lib_list in libs.items():
-            self._validate_libs_processing(code, benchmarks, lib_list)
+            self._validate_libs_processing(code, benchmark, lib_list)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/jade/config/status.py` around lines 301 - 312, The validate_codelibs
method's parameter is named benchmarks but its type hint and docstring describe
a single benchmark; rename the parameter to benchmark to match the docstring and
type hint and update any internal uses and callers accordingly (search for
validate_codelibs and change the parameter name in its signature and all
invocations to benchmark) so documentation and code are consistent.

"""
libs = {}
for code, lib in codelibs:
code = CODE(code)
if lib == "exp": # for the experiments we don't have metadata
continue
if code not in libs:
libs[code] = []
libs[code].append(lib)

for code, lib_list in libs.items():
self._validate_libs_processing(code, benchmarks, lib_list)


@dataclass
class CodeLibRunStatus:
Expand Down
82 changes: 57 additions & 25 deletions src/jade/helper/aux_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import importlib.metadata
import os
import re
from abc import ABC, abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING, Union

Expand Down Expand Up @@ -68,32 +69,61 @@ def print_code_lib(code: CODE, lib: Library | str, pretty: bool = False) -> str:
return f"_{code.value}_-_{lib_name}_"


def check_run_mcnp(folder: PathLike) -> bool:
"""check if mcnp run was successful"""
try:
MCNPSimOutput.retrieve_files(folder)
return True
except FileNotFoundError:
return False
class SimulationChecker(ABC):
"""Abstract base class for simulation success checkers.

All code-specific checkers must inherit from this class and implement
the check_success method with the same signature.
"""

def check_run_openmc(folder: PathLike) -> bool:
"""check if openmc run was successful"""
try:
OpenMCSimOutput.retrieve_file(folder)
return True
except FileNotFoundError:
return False
@abstractmethod
def check_success(self, files: list[str]) -> bool:
"""Check if a simulation run was successful.

Parameters
----------
files : list[str]
List of output files to check for success.

Returns
-------
bool
True if the simulation completed successfully, False otherwise.
"""
pass


class MCNPChecker(SimulationChecker):
"""Checker for MCNP simulations."""

def check_success(self, files: list[str]) -> bool:
"""Check if MCNP run was successful by verifying output files exist."""
return MCNPSimOutput.is_successfully_simulated(files)


class OpenMCChecker(SimulationChecker):
"""Checker for OpenMC simulations."""

def check_success(self, files: list[str]) -> bool:
"""Check if OpenMC run was successful by verifying output files exist."""
return OpenMCSimOutput.is_successfully_simulated(files)


class SerpentChecker(SimulationChecker):
"""Checker for Serpent simulations."""

def check_success(self, files: list[str]) -> bool:
"""Check if Serpent run was successful."""
# TODO implement the logic to check if the Serpent run was successful
raise NotImplementedError("Serpent checker not yet implemented")

def check_run_serpent(folder: PathLike) -> bool:
# TODO implement the logic to check if the Serpent run was successful
raise NotImplementedError

class D1SChecker(SimulationChecker):
"""Checker for D1S simulations."""

def check_run_d1s(folder: PathLike) -> bool:
"""check if d1s run was successful"""
return check_run_mcnp(folder)
def check_success(self, files: list[str]) -> bool:
"""Check if D1S run was successful (uses same logic as MCNP)."""
return MCNPChecker().check_success(files)
Comment on lines +72 to +126
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep the run directory in check_success().

The new files: list[str] contract strips the context needed to validate actual completion. As used by src/jade/config/status.py Lines 94-98 and src/jade/run/benchmark.py Lines 441-445, these checkers now have to infer success from filename presence alone, which can mark interrupted runs with leftover artifacts as complete and then skip reruns or post-process bad outputs.

💡 Suggested interface direction
 class SimulationChecker(ABC):
     """Abstract base class for simulation success checkers.
@@
     `@abstractmethod`
-    def check_success(self, files: list[str]) -> bool:
+    def check_success(
+        self, run_dir: PathLike, files: list[str] | None = None
+    ) -> bool:
         """Check if a simulation run was successful.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/jade/helper/aux_functions.py` around lines 72 - 126, The checkers lost
run-directory context because SimulationChecker.check_success(files: list[str])
only receives filenames; change the abstract signature to check_success(files:
list[str], run_dir: str | Path) -> bool (or add an optional run_dir: str | Path
= None) on SimulationChecker and update all concrete implementations
(MCNPChecker.check_success, OpenMCChecker.check_success,
SerpentChecker.check_success, D1SChecker.check_success) to accept and use
run_dir when resolving/validating outputs (keeping existing behavior if run_dir
is None for backward compatibility); then update callers (e.g., the code paths
in src/jade/config/status.py and src/jade/run/benchmark.py) to pass the run
directory into check_success and update docstrings/type hints accordingly.



def get_jade_version() -> str:
Expand Down Expand Up @@ -124,11 +154,13 @@ def add_rmode0(path: PathLike) -> None:
f.write("RMODE 0\n")


CODE_CHECKERS = {
CODE.MCNP: check_run_mcnp,
CODE.OPENMC: check_run_openmc,
CODE.SERPENT: check_run_serpent,
CODE.D1S: check_run_d1s,
# Dictionary mapping CODE enums to checker instances
# All checkers implement the SimulationChecker interface
CODE_CHECKERS: dict[CODE, SimulationChecker] = {
CODE.MCNP: MCNPChecker(),
CODE.OPENMC: OpenMCChecker(),
CODE.SERPENT: SerpentChecker(),
CODE.D1S: D1SChecker(),
}


Expand Down
8 changes: 8 additions & 0 deletions src/jade/helper/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ def __init__(self, message="There is an error in the configuration"):
super().__init__(self.message)


class VersionInconsistencyError(ConfigError):
"""Exception raised for version inconsistencies in the configuration."""

def __init__(self, message="There is a version inconsistency in the sims"):
self.message = message
super().__init__(self.message)


class PostProcessConfigError(Exception):
"""Exception raised for errors in the configuration of post processing."""

Expand Down
24 changes: 24 additions & 0 deletions src/jade/post/sim_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,18 @@ def retrieve_files(results_path: PathLike) -> tuple[Path, Path, Path | None]:

return mctal, outp, meshtal

@staticmethod
def is_successfully_simulated(files: list[str]) -> bool:
"""Check if the simulation was successful by verifying output files exist."""
mctal_found = False
output_found = False
for file in files:
if file.endswith(".m"):
mctal_found = True
elif file.endswith(".o"):
output_found = True
return mctal_found and output_found


class OpenMCSimOutput(AbstractSimOutput):
def __init__(
Expand Down Expand Up @@ -333,6 +345,18 @@ def retrieve_file(

return file1, file2, file3

@staticmethod
def is_successfully_simulated(files: list[str]) -> bool:
"""Check if the simulation was successful by verifying output files exist."""
statepoint_found = False
output_found = False
for file in files:
if file.startswith("statepoint") and file.endswith(".h5"):
statepoint_found = True
elif file.endswith(".out"):
output_found = True
return statepoint_found and output_found

def _create_dataframes(
self, tallies: dict
) -> tuple[dict[int, pd.DataFrame], dict[int, pd.DataFrame]]:
Expand Down
2 changes: 1 addition & 1 deletion src/jade/run/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ def _get_continue_run_command(self, code: CODE, lib: Library) -> str:
for single_run_folder in os.listdir(benchmark_root):
single_run_root = Path(benchmark_root, single_run_folder)
# check if the simulation has been completed
flag_run = CODE_CHECKERS[code](single_run_root)
flag_run = CODE_CHECKERS[code].check_success(os.listdir(single_run_root))
if flag_run:
continue

Expand Down
Loading
Loading