diff --git a/CHANGELOG.md b/CHANGELOG.md index ce1d4ba..b69549e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ Target release: 0.16.0, the first versioned MC/DC-VVP release. ### Added +- Add Kornreich k-eigenvalue slabs problem +- Add the analytical neutron $k$-eigenvalue MMS one-group slab, from [@ilhamv] +- Add the analytical neutron fixed-source MMS two-group slab, from [@ilhamv] - Add the analytical neutron $k$-eigenvalue suite with subcritical and supercritical SHEM-361 cases, analytical matrix-eigenvalue references, active-cycle convergence studies, and uncertainty plots, from [@ilhamv] - Add energy-dependent weight-window and time-census variants of the infinite homogeneous SHEM-361 problem, from [@ilhamv] - Add AZURV1 variants for basic variance-reduction techniques, analytical spatial weight windows, time censuses, and census-based tallies, from [@ilhamv] @@ -19,6 +22,7 @@ Target release: 0.16.0, the first versioned MC/DC-VVP release. ### Changed +- Distribute the analytical fixed-source slab cases across the $x$, $y$, and $z$ axes to exercise every Cartesian slab orientation, from [@ilhamv] - Migration to Maestro-based launch, from [@ilhamv] - Update analytical fixed-source cases for the simulation-owned MC/DC interface and unified material model, from [@ilhamv] - Standardize **suite** and **case** as the VVP repository's organizational terminology, from [@ilhamv] diff --git a/README.md b/README.md index e673e0a..f13c07f 100644 --- a/README.md +++ b/README.md @@ -15,21 +15,36 @@ Workflow orchestration is performed using [Maestro](https://github.com/llnl/maes ```text configs/ Shared platform, user, and launch configurations verification/ Verification suites and their cases -results/metadata.yaml Append-only launch and processing history -results// Processed results for one recorded launch +results/ Processed results organized by suite launch.py Launch all enabled suites -process.py Process one recorded launch +process.py Process suites and collect their results +cleanup.py Remove generated outputs and processed results ``` -MC/DC-VVP uses **suite** and **case** as standard terms for its two organizational levels: +MC/DC-VVP uses **suite**, **case**, and **task** as standard terms for its organizational hierarchy: + +```text +suite +└── case + └── task +``` - A **suite** is a self-contained collection of related VVP cases with a shared launch and processing workflow. - A **case** is one individual problem definition and its inputs, reference solution or data, and processing logic. +- A **task** is one execution of a case at one sampling level, such as one `N_particle` or `N_active` value. -The top-level workflow launches and processes enabled suites, while each suite workflow runs and processes its cases. +A suite contains cases, and each case generates one or more tasks from the suite's `task.yaml` configuration. +Maestro currently represents each case as one workflow step, and `run_case.py` executes that case's tasks sequentially. +The hierarchy also defines restart behavior: an existing output skips its task, a case with all task outputs is omitted, and a suite with all cases complete does not launch. Every integrated suite provides a README that describes its layout, configuration, workflow, and cases. +## Adding content + +To add a case, create its directory under the appropriate suite's `cases/`, implement the common files described in the suite README, and register its task sequence in the suite's `task.yaml`. +Place data shared by multiple cases in the suite's `data/` directory when appropriate. +A new suite should provide its own README, `launch.py`, `process.py`, and `cleanup.py`, then be registered in `configs/launch_config.py.template`. + ## Configuration Create the local launch configuration: @@ -40,6 +55,11 @@ cp configs/launch_config.py.template configs/launch_config.py Edit `configs/launch_config.py` to enable the desired suites and set their platform and launch options. Use `platform=None` for local execution or a name from `configs/platform_config.py` for HPC execution. +For HPC execution, `N_node` sets the number of nodes and each node uses all available CPU cores. +For HPC execution, a suite's base `walltime` in hours is scaled by each case's `walltime_factor` in that suite's `task.yaml`. +The scaled value is rounded up to the scheduler's supported resolution, the platform maximum remains the final limit, and local execution ignores walltime settings. +Cases with every expected output are skipped, while partially complete cases retain their existing outputs and run only the missing sampling levels. +Run the top-level `python cleanup.py` before launching when the entire configured campaign should start fresh. For HPC execution, also create `configs/user_config.py` from its template and provide the account and optional queue, reservation, and Python paths for the target platform. @@ -59,40 +79,45 @@ python launch.py --platform tuolumne The `--platform` option selects suites with a matching configured platform. -After the latest launch has completed, process all suites submitted by that launch: +Process registered suites and collect their results: ```bash python process.py ``` -Process a specific recorded launch by passing the launch ID printed by `launch.py` and stored in `results/metadata.yaml`: +For each suite registered in `configs/launch_config.py`, the top-level processor invokes the suite processor when a Maestro run is available and then moves the generated `results/` directory under the same suite path in the top-level `results/` directory. +An existing suite `results/` directory can still be collected when no Maestro run is present, and suites with neither are skipped. +Within each suite, `convergence/` contains study-wide convergence figures and `comparison/` contains plots or animations from the largest-statistics result. +Collecting a suite replaces that suite's existing top-level results. + +Remove generated case outputs and processed results from every registered suite: ```bash -python process.py 20260817T120000123456Z +python cleanup.py ``` -Each launch is processed into its own `results//` directory, which contains a metadata snapshot and the suite result hierarchy. -Reprocessing one launch replaces only that launch's subfolder and does not affect results from other launches. +Cleanup removes Maestro run directories and retains reference data. ## Suites ### Analytical verification -Analytical verification demonstrates the expected statistical convergence of MC/DC by comparing numerical solutions against analytical reference solutions as the sampling effort is increased. +Analytical verification demonstrates the expected statistical convergence of MC/DC by comparing numerical solutions against analytical and semi-analytical reference solutions as the sampling effort is increased. | Physics | Suite | Description | | :------ | :---- | :---------- | -| Neutron transport | [Fixed-source](verification/analytical/neutron/fixed_source/README.md) | Multigroup steady-state and transient fixed-source cases, including Reed's problem, AZURV1 variants, and infinite SHEM-361 benchmarks. | -| Neutron transport | [$k$-eigenvalue](verification/analytical/neutron/k_eigenvalue/README.md) | Infinite homogeneous SHEM-361 criticality cases with analytical eigenvalue and energy-spectrum references. | +| Neutron transport | [Fixed-source](verification/analytical/neutron/fixed_source/README.md) | Multigroup steady-state and transient cases, including a two-group manufactured solution, Reed's problem, AZURV1 variants, and infinite SHEM-361 benchmarks. | +| Neutron transport | [k-eigenvalue](verification/analytical/neutron/k_eigenvalue/README.md) | Homogeneous and Kornreich-Parsons one-group slab benchmarks, plus infinite homogeneous SHEM-361 cases. | -### Benchmark verification +### Code-to-code verification -Benchmark verification compares MC/DC against established reference Monte Carlo codes for cases without analytical solutions. +Code-to-code verification assesses whether relative differences among independently implemented transport codes decrease at the expected statistical rate as their sampling effort increases. +Convergence proportional to $N^{-1/2}$ supports that the participating codes are approaching the same solution at the expected Monte Carlo rate, although agreement alone cannot exclude shared bias. +The arithmetic mean of all participating code estimates at the largest sampling level defines a fixed comparison reference for every level, allowing a case to include two or more codes without designating one as exact. | Physics | Suite | Description | | :------ | :---- | :---------- | -| Neutron transport (multigroup) | Benchmark multigroup | Time-dependent benchmark cases, including the Kobayashi Dog-Leg and C5G7 transient benchmarks. | -| Neutron transport (continuous energy) | [Benchmark continuous energy](verification/benchmark/neutron/continuous_energy/README.md) | Continuous-energy benchmark cases for representative reactor systems. | +| Neutron transport | [Code-to-code](verification/code_to_code/neutron/README.md) | Time-dependent C5G7 and Kobayashi comparisons among participating codes. | ## Validation diff --git a/cleanup.py b/cleanup.py new file mode 100644 index 0000000..8793b8b --- /dev/null +++ b/cleanup.py @@ -0,0 +1,65 @@ +"""Remove generated outputs and processed results from all registered VVP suites.""" + +import shutil +import subprocess +import sys +from pathlib import Path + +# ====================================================================================== +# Bootstrap VVP imports +# ====================================================================================== + +REPO_DIR = Path(__file__).resolve().parent + +# Support running this script from any working directory without installing VVP. +if str(REPO_DIR) not in sys.path: + sys.path.insert(0, str(REPO_DIR)) + + +# ====================================================================================== +# Load suite configuration +# ====================================================================================== + +from configs.launch_config import LAUNCH_CONFIG + +# ====================================================================================== +# Clean registered suites +# ====================================================================================== + +for suite in LAUNCH_CONFIG: + suite_dir = REPO_DIR / suite + cleaner = suite_dir / "cleanup.py" + + if not suite_dir.is_dir(): + raise FileNotFoundError(f"Suite directory not found: {suite_dir}") + + if not cleaner.is_file(): + print(f"Skip suite without cleanup.py: {suite}") + continue + + print("=" * 80) + print(f"Cleaning suite: {suite}") + print("=" * 80) + + subprocess.run( + [sys.executable, str(cleaner)], + cwd=suite_dir, + check=True, + ) + + +# ====================================================================================== +# Remove collected results +# ====================================================================================== + +results_dir = REPO_DIR / "results" +if results_dir.is_dir(): + shutil.rmtree(results_dir) + + +# ====================================================================================== +# Summary +# ====================================================================================== + +print() +print("Cleanup complete.") diff --git a/configs/launch_config.py.template b/configs/launch_config.py.template index 808614b..d63564e 100644 --- a/configs/launch_config.py.template +++ b/configs/launch_config.py.template @@ -2,15 +2,19 @@ LAUNCH_CONFIG = { "verification/analytical/neutron/fixed_source": { "enabled": False, "platform": None, - "mpi": False, + "N_node": 1, "walltime": None, - "rewrite": False, }, "verification/analytical/neutron/k_eigenvalue": { "enabled": False, "platform": None, - "mpi": False, + "N_node": 1, + "walltime": None, + }, + "verification/code_to_code/neutron": { + "enabled": False, + "platform": None, + "N_node": 1, "walltime": None, - "rewrite": False, }, } diff --git a/configs/platform_config.py b/configs/platform_config.py index 2656364..b8b551f 100644 --- a/configs/platform_config.py +++ b/configs/platform_config.py @@ -12,7 +12,8 @@ "gpus_per_node": 0, "max_nodes": 128, "max_walltime_hours": 24, - "walltime_format": "{hours}:00:00", + "walltime_resolution_seconds": 1, + "walltime_format": "{hours}:{minutes:02d}:{seconds:02d}", }, "lassen": { "host": "lassen", @@ -23,7 +24,8 @@ "gpus_per_node": 4, "max_nodes": 128, # actual system limit: 256 "max_walltime_hours": 24, # actual system limit may be lower - "walltime_format": "{hours}:00", + "walltime_resolution_seconds": 60, + "walltime_format": "{hours}:{minutes:02d}", }, "tuolumne": { "host": "tuolumne", @@ -34,6 +36,7 @@ "gpus_per_node": 4, "max_nodes": 128, "max_walltime_hours": 24, - "walltime_format": "{hours}:00:00", + "walltime_resolution_seconds": 1, + "walltime_format": "{hours}:{minutes:02d}:{seconds:02d}", }, } diff --git a/configs/util.py b/configs/util.py new file mode 100644 index 0000000..2d3e0a4 --- /dev/null +++ b/configs/util.py @@ -0,0 +1,58 @@ +"""Shared helpers for VVP configuration and launch processing.""" + +import math +from pathlib import Path + + +def case_outputs_complete(case_dir, sample_counts): + """Return whether every expected sample output exists for a case.""" + case_dir = Path(case_dir) + return all( + (case_dir / f"output_{int(count)}.h5").is_file() for count in sample_counts + ) + + +def get_case_walltime(task, platform, walltime_base=None): + """Scale the launch walltime for one case and apply the platform limit.""" + walltime_factor = task.get("walltime_factor", 1.0) + + # Validate the optional launch-wide base duration. + if walltime_base is not None and ( + isinstance(walltime_base, bool) + or not isinstance(walltime_base, (int, float)) + or not math.isfinite(walltime_base) + or walltime_base <= 0.0 + ): + raise ValueError("Launch walltime must be greater than zero.") + + # Validate the multiplier supplied by the individual case. + if ( + isinstance(walltime_factor, bool) + or not isinstance(walltime_factor, (int, float)) + or not math.isfinite(walltime_factor) + or walltime_factor <= 0.0 + ): + raise ValueError("Case walltime_factor must be greater than zero.") + + # Use the platform limit as the base when the launch does not specify one. + if walltime_base is None: + walltime_base = platform["max_walltime_hours"] + + # Round up to the scheduler resolution, then enforce the platform limit. + resolution = platform["walltime_resolution_seconds"] + max_seconds = ( + math.floor(platform["max_walltime_hours"] * 3600.0 / resolution) * resolution + ) + walltime_seconds = ( + math.ceil(walltime_base * walltime_factor * 3600.0 / resolution) * resolution + ) + walltime_seconds = min(walltime_seconds, max_seconds) + + # Convert seconds into the scheduler-specific walltime syntax. + hours, remainder = divmod(walltime_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + return platform["walltime_format"].format( + hours=hours, + minutes=minutes, + seconds=seconds, + ) diff --git a/launch.py b/launch.py index da1db70..0abad37 100644 --- a/launch.py +++ b/launch.py @@ -1,14 +1,10 @@ -"""Launch configured VVP suites and record enough context to reproduce each run.""" +"""Launch the configured MC/DC VVP suites.""" import argparse -import datetime -import importlib.metadata import subprocess import sys from pathlib import Path -import yaml - # ====================================================================================== # Bootstrap VVP imports # ====================================================================================== @@ -26,46 +22,6 @@ from configs.launch_config import LAUNCH_CONFIG -# ====================================================================================== -# Helper functions -# ====================================================================================== - - -def get_git_hash(repo_dir): - """Return the commit that identifies the VVP source used for the launch.""" - return subprocess.check_output( - ["git", "-C", str(repo_dir), "rev-parse", "HEAD"], - stderr=subprocess.DEVNULL, - text=True, - ).strip() - - -def is_git_dirty(repo_dir): - """Report whether the launch includes changes not captured by the commit hash.""" - result = subprocess.run( - ["git", "-C", str(repo_dir), "status", "--porcelain"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - check=True, - ) - return bool(result.stdout.strip()) - - -def get_mcdc_version(): - """Return the installed MC/DC version when package metadata is available.""" - try: - return importlib.metadata.version("mcdc") - except importlib.metadata.PackageNotFoundError: - return None - - -def write_metadata(metadata_file, metadata): - """Persist launch metadata after each submission milestone.""" - with metadata_file.open("w") as f: - yaml.dump(metadata, f, sort_keys=False) - - # ====================================================================================== # Command-line arguments # ====================================================================================== @@ -79,69 +35,24 @@ def write_metadata(metadata_file, metadata): args = parser.parse_args() active_platform = args.platform +platform_name = active_platform or "local" - -# ====================================================================================== -# Set up results metadata -# ====================================================================================== - -results_dir = REPO_DIR / "results" -metadata_file = results_dir / "metadata.yaml" - -results_dir.mkdir(parents=True, exist_ok=True) - -if metadata_file.is_file(): - with metadata_file.open("r") as f: - metadata = yaml.safe_load(f) or {} -else: - metadata = {} - -metadata.setdefault("launches", []) - -# Keep an append-only launch history so reruns do not erase provenance. -launch_time = datetime.datetime.now(datetime.UTC) -launch_record = { - "launch_id": launch_time.strftime("%Y%m%dT%H%M%S%fZ"), - "launched_at": launch_time.isoformat(), - "active_platform": active_platform, - "mcdc_version": get_mcdc_version(), - "mcdc_vvp_hash": get_git_hash(REPO_DIR), - "mcdc_vvp_dirty": is_git_dirty(REPO_DIR), - "launch_config": LAUNCH_CONFIG, - "suite_runs": {}, +# Select only suites assigned to this launch invocation. +selected_suites = { + suite: options + for suite, options in LAUNCH_CONFIG.items() + if options.get("enabled", False) and options.get("platform") == active_platform } -metadata["launches"].append(launch_record) - -write_metadata(metadata_file, metadata) - -print("=" * 80) -print("Prepared VVP results metadata") -print(f"Results directory : {results_dir}") -print(f"Metadata file : {metadata_file}") -print(f"Launch ID : {launch_record['launch_id']}") -print(f"Active platform : {active_platform}") -print("=" * 80) +if not selected_suites: + parser.error(f"No enabled suites are configured for platform '{platform_name}'.") # ====================================================================================== # Launch enabled, compatible suites # ====================================================================================== -for suite, options in LAUNCH_CONFIG.items(): - if not options.get("enabled", False): - print(f"Skip disabled suite: {suite}") - continue - +for suite, options in selected_suites.items(): suite_platform = options.get("platform") - - # Launch only suites explicitly assigned to this invocation's platform. - if suite_platform != active_platform: - print( - f"Skip platform mismatch: {suite} " - f"({suite_platform} != {active_platform})" - ) - continue - suite_dir = REPO_DIR / suite launcher = suite_dir / "launch.py" @@ -152,53 +63,29 @@ def write_metadata(metadata_file, metadata): raise FileNotFoundError(f"Suite launcher not found: {launcher}") # Reuse the active interpreter so suite launchers inherit this environment. - command = [ - sys.executable, - str(launcher), - ] + command = [sys.executable, str(launcher)] # Forward only options that are meaningful for the configured suite. if suite_platform is not None: command.extend(["--platform", suite_platform]) - if options.get("mpi", False): - command.append("--mpi") + if options.get("N_node") is not None: + command.extend(["--N_node", str(options["N_node"])]) if options.get("walltime") is not None: command.extend(["--walltime", str(options["walltime"])]) - if options.get("rewrite", False): - command.append("--rewrite") - print("=" * 80) print(f"Launching suite: {suite}") print("Command:", " ".join(command)) print("=" * 80) - maestro_runs_before = set(suite_dir.glob("maestro_run_*")) subprocess.run(command, cwd=suite_dir, check=True) - # Associate this campaign with the exact generated suite launch. - maestro_runs_after = set(suite_dir.glob("maestro_run_*")) - new_maestro_runs = maestro_runs_after - maestro_runs_before - - if not new_maestro_runs: - raise RuntimeError(f"Suite did not create a new Maestro run: {suite}") - - maestro_run = max(new_maestro_runs, key=lambda path: path.stat().st_mtime) - launch_record["suite_runs"][suite] = str(maestro_run.relative_to(REPO_DIR)) - write_metadata(metadata_file, metadata) - # ====================================================================================== # Summary # ====================================================================================== -launch_record["submission_completed_at"] = datetime.datetime.now( - datetime.UTC -).isoformat() -write_metadata(metadata_file, metadata) - print() -print(f"Launch ID: {launch_record['launch_id']}") print("Launch complete.") diff --git a/process.py b/process.py index 8ebd68b..b9f5470 100644 --- a/process.py +++ b/process.py @@ -1,150 +1,70 @@ -"""Process one recorded VVP launch into an isolated results directory.""" +"""Process registered VVP suites and collect their results.""" -import argparse -import datetime import shutil import subprocess import sys from pathlib import Path -import yaml - -REPO_DIR = Path(__file__).resolve().parent - - # ====================================================================================== -# Helper functions +# Bootstrap VVP imports # ====================================================================================== +REPO_DIR = Path(__file__).resolve().parent -def get_launch_id(launch): - """Return the stored launch ID or derive one for legacy metadata.""" - if "launch_id" in launch: - return launch["launch_id"] - - launched_at = datetime.datetime.fromisoformat(launch["launched_at"]) - return launched_at.astimezone(datetime.UTC).strftime("%Y%m%dT%H%M%SZ") - - -def write_metadata(metadata_file, metadata): - """Persist updated processing provenance.""" - with metadata_file.open("w") as f: - yaml.dump(metadata, f, sort_keys=False) +# Support running this script from any working directory without installing VVP. +if str(REPO_DIR) not in sys.path: + sys.path.insert(0, str(REPO_DIR)) # ====================================================================================== -# Command-line arguments +# Load suite configuration # ====================================================================================== -parser = argparse.ArgumentParser(description="Process a recorded MC/DC VVP launch.") -parser.add_argument( - "launch_id", - nargs="?", - default=None, - help="Metadata launch ID to process. Defaults to the latest launch.", -) -args = parser.parse_args() +from configs.launch_config import LAUNCH_CONFIG # ====================================================================================== # Set up results # ====================================================================================== results_dir = REPO_DIR / "results" -metadata_file = results_dir / "metadata.yaml" - -if not metadata_file.is_file(): - raise FileNotFoundError( - "Top-level launch metadata not found. Run launch.py before process.py." - ) - -with metadata_file.open("r") as f: - metadata = yaml.safe_load(f) or {} - -launches = metadata.get("launches", []) -if not launches: - raise ValueError(f"No launches are recorded in {metadata_file}") - -if args.launch_id is None: - launch = launches[-1] -else: - matches = [item for item in launches if get_launch_id(item) == args.launch_id] - if not matches: - available = ", ".join(get_launch_id(item) for item in launches) - raise ValueError( - f"Launch ID '{args.launch_id}' was not found. Available launches: {available}" - ) - launch = matches[-1] - -launch_id = get_launch_id(launch) -launch_config = launch["launch_config"] -suite_runs = launch.get("suite_runs", {}) -has_recorded_suite_runs = "suite_runs" in launch -launch_results = results_dir / launch_id -launch_results.mkdir(parents=True, exist_ok=True) - -if not has_recorded_suite_runs: - print( - "Warning: this legacy launch does not record exact suite runs; " - "each suite will process its latest Maestro run." - ) - -print("=" * 80) -print(f"Processing launch : {launch_id}") -print(f"Launched at : {launch['launched_at']}") -print(f"Results directory : {launch_results}") -print("=" * 80) +results_dir.mkdir(parents=True, exist_ok=True) # ====================================================================================== -# Process enabled suites +# Process registered suites and collect their results # ====================================================================================== -for suite, options in launch_config.items(): - if not options.get("enabled", False): - print(f"Skip disabled suite: {suite}") - continue - - if options.get("platform") != launch.get("active_platform"): - print(f"Skip suite not submitted by this launch: {suite}") - continue - - if has_recorded_suite_runs and suite not in suite_runs: - print(f"Skip suite without a recorded Maestro run: {suite}") - continue - +for suite in LAUNCH_CONFIG: suite_dir = REPO_DIR / suite processor = suite_dir / "process.py" + suite_results = suite_dir / "results" if not suite_dir.is_dir(): raise FileNotFoundError(f"Suite directory not found: {suite_dir}") - if not processor.is_file(): - raise FileNotFoundError(f"Suite processor not found: {processor}") - - print("=" * 80) - print(f"Processing suite: {suite}") - print("=" * 80) - - suite_results = suite_dir / "results" - - # Prevent figures from a previous suite-processing run leaking into this launch. - if suite_results.exists(): - shutil.rmtree(suite_results) - - command = [sys.executable, str(processor)] - if suite in suite_runs: - maestro_run = REPO_DIR / suite_runs[suite] - if not maestro_run.is_dir(): - raise FileNotFoundError(f"Recorded Maestro run not found: {maestro_run}") - command.append(str(maestro_run)) - - subprocess.run(command, cwd=suite_dir, check=True) + maestro_runs = list(suite_dir.glob("maestro_run_*")) + + # Generate suite results from the latest Maestro run when one is available. + if processor.is_file() and maestro_runs: + print("=" * 80) + print(f"Processing suite: {suite}") + print("=" * 80) + subprocess.run( + [sys.executable, str(processor)], + cwd=suite_dir, + check=True, + ) + # Existing suite results remain collectable without a recorded Maestro run. if not suite_results.is_dir(): - print(f"No suite results found: {suite_results}") + print(f"Skip suite without processable results: {suite}") continue - destination = launch_results / suite + print("=" * 80) + print(f"Collecting suite: {suite}") + print("=" * 80) + + destination = results_dir / suite destination.parent.mkdir(parents=True, exist_ok=True) if destination.exists(): @@ -153,24 +73,10 @@ def write_metadata(metadata_file, metadata): shutil.move(str(suite_results), str(destination)) -# ====================================================================================== -# Store processing metadata -# ====================================================================================== - -launch["launch_id"] = launch_id -launch["processed_at"] = datetime.datetime.now(datetime.UTC).isoformat() -launch["results_directory"] = str(launch_results.relative_to(REPO_DIR)) -write_metadata(metadata_file, metadata) - -with (launch_results / "metadata.yaml").open("w") as f: - yaml.dump(launch, f, sort_keys=False) - - # ====================================================================================== # Summary # ====================================================================================== print() -print(f"Launch : {launch_id}") -print(f"Results : {launch_results}") -print("Processing complete.") +print(f"Results: {results_dir}") +print("Processing and collection complete.") diff --git a/validation/sinbad/fng-sic/README.md b/validation/sinbad/fng-sic/README.md new file mode 100644 index 0000000..95b0296 --- /dev/null +++ b/validation/sinbad/fng-sic/README.md @@ -0,0 +1,50 @@ +# FNG SiC integral experiment + +This directory contains a preliminary MC/DC reconstruction of the [FNG SiC experiment](https://www.oecd-nea.org/science/wprs/shielding/sinbad/fng_sic/fngsic-a.htm) described by the public SINBAD entry NEA-1553/56. + +It is not a transcription of the complete SINBAD package. + +## Public-entry information + +The experiment measured activation reaction rates and nuclear heating at several depths in a silicon-carbide block irradiated by the 14 MeV Frascati Neutron Generator. + +| Item | Public-entry specification | +| --- | --- | +| Block | 45.72 cm × 45.72 cm × 71.12 cm | +| Construction | 116 sintered SiC bricks at an average density of 3.158 g/cm³ | +| Listed impurities | 0.19 wt% B, 0.79 wt% Al, and 0.14 wt% Fe | +| Source | FNG D-T source with angle-dependent intensity and energy, 5.3 cm before the block | +| Activation positions | 10.41, 25.65, 40.89, and 56.13 cm from the front surface | +| Activation reactions | Au-197(n,γ), Ni-58(n,p), Al-27(n,α), and Nb-93(n,2n) | +| Heating positions | 14.99, 30.23, 45.47, and 60.71 cm from the front surface | +| Heating detector | GR-200 LiF:Mg,Cu,P thermoluminescent dosimeters | + +The public abstract catalogs the detailed source tables, measured responses, detector models, and calculation inputs, but those assets are not exposed by the currently accessible abstract page. + +## Assumptions requiring official-package review + +- The brick assembly is represented as one homogeneous rectangular block. +- The Si and C balance is assigned as stoichiometric SiC after subtracting the impurity fractions shown on the public page. +- The public TUD and FNG pages report different Al and Fe impurity fractions for the nominally shared block, so the official composition must be confirmed. +- The angle-energy-dependent source is replaced by an isotropic 14 MeV point source. +- Small mesh volumes sample the reported depths without explicitly modeling foils, TLDs, or their holders. +- The neutron energy grid uses 240 logarithmic bins from 10⁻⁹ to 20 MeV rather than an official group structure. +- The calculated spectra are not yet folded with the four dosimetry cross sections. +- TLD heating is not reproduced because the required neutron sensitivity and coupled neutron–photon transport are not yet included. +- The plots therefore show intermediate neutron flux spectra rather than the measured reaction rates and dose. + +The complete package should refine the composition, brick and detector geometry, source distribution, response functions, and experimental comparison. + +## Reconstructed diagram + +The following diagram summarizes the public-entry description and is not an original SINBAD figure. + +![Reconstructed FNG SiC arrangement](figures/geometry.svg) + +## Files + +- `input.py` defines the neutron model and flux tallies at the activation and heating depths. +- `plot.py` plots the intermediate neutron spectra and their Monte Carlo uncertainties. +- `figures/` contains a reconstructed diagram based on the public entry. + +MC/DC requires a native-data library containing the natural isotopes of Si, C, B, Al, and Fe. diff --git a/validation/sinbad/fng-sic/figures/geometry.svg b/validation/sinbad/fng-sic/figures/geometry.svg new file mode 100644 index 0000000..6fadad1 --- /dev/null +++ b/validation/sinbad/fng-sic/figures/geometry.svg @@ -0,0 +1,24 @@ + + + FNG SiC integral experiment + + D-T source + + + Homogenized SiC brick assembly + + + + + + + + + A1A2A3A4 + H1H2H3H4 + + Activation-foil depths + TLD heating depths + Reconstructed diagram—not an original SINBAD figure; dimensions are not to scale. + + diff --git a/validation/sinbad/fng-sic/input.py b/validation/sinbad/fng-sic/input.py new file mode 100644 index 0000000..5d205c3 --- /dev/null +++ b/validation/sinbad/fng-sic/input.py @@ -0,0 +1,105 @@ +"""Public-entry reconstruction of the integral FNG SiC experiment.""" + +import numpy as np + +import mcdc + +AVOGADRO_BARN_CM = 0.602214076 +ACTIVATION_POSITIONS = [10.41, 25.65, 40.89, 56.13] +HEATING_POSITIONS = [14.99, 30.23, 45.47, 60.71] +TALLY_HALF_WIDTH = 0.1 + + +def atomic_density(mass_density, mass_fraction, atomic_weight): + """Convert a mass fraction and density to atoms/(barn cm).""" + return AVOGADRO_BARN_CM * mass_density * mass_fraction / atomic_weight + + +simulation = mcdc.Simulation("SINBAD FNG SiC public-entry model") + +# Allocate the composition remaining after the listed impurities to +# stoichiometric SiC. +impurities = {"B": 0.0019, "Al": 0.0079, "Fe": 0.0014} +sic_fraction = 1.0 - sum(impurities.values()) +silicon_fraction = sic_fraction * 28.085 / (28.085 + 12.011) +carbon_fraction = sic_fraction - silicon_fraction +mass_fractions = { + "Si": silicon_fraction, + "C": carbon_fraction, + **impurities, +} +atomic_weights = { + "Si": 28.085, + "C": 12.011, + "B": 10.81, + "Al": 26.9815, + "Fe": 55.845, +} +sic = mcdc.Material( + name="FNG SiC block", + element_composition={ + element: atomic_density(3.158, fraction, atomic_weights[element]) + for element, fraction in mass_fractions.items() + }, +) + +# Approximate the brick mock-up by a homogeneous block with a source-side void. +x_source = mcdc.Surface.PlaneX(x=-5.31, boundary_condition="vacuum") +x_front = mcdc.Surface.PlaneX(x=0.0) +x_back = mcdc.Surface.PlaneX(x=71.12, boundary_condition="vacuum") +y_min = mcdc.Surface.PlaneY(y=-22.86, boundary_condition="vacuum") +y_max = mcdc.Surface.PlaneY(y=22.86, boundary_condition="vacuum") +z_min = mcdc.Surface.PlaneZ(z=-22.86, boundary_condition="vacuum") +z_max = mcdc.Surface.PlaneZ(z=22.86, boundary_condition="vacuum") +transverse_region = +y_min & -y_max & +z_min & -z_max + +source_void = mcdc.Cell( + name="Source-side void", + region=+x_source & -x_front & transverse_region, +) +sic_block = mcdc.Cell( + name="Homogenized SiC block", + region=+x_front & -x_back & transverse_region, + fill=sic, +) +simulation.set_model([source_void, sic_block]) + +# Replace the public entry's tabulated angle-energy distribution by a 14 MeV +# isotropic point source at the reported stand-off. +source = mcdc.Source( + name="FNG D-T source approximation", + position=[-5.3, 0.0, 0.0], + isotropic=True, + energy=14.0e6, +) +simulation.set_sources([source]) + +# Record neutron spectra at the foil and TLD depths for later response folding. +energy = np.geomspace(1.0e-3, 20.0e6, 241) +tallies = [] +for kind, positions in ( + ("activation", ACTIVATION_POSITIONS), + ("heating", HEATING_POSITIONS), +): + for index, x in enumerate(positions, start=1): + mesh = mcdc.MeshStructured( + x=[x - TALLY_HALF_WIDTH, x + TALLY_HALF_WIDTH], + y=[-TALLY_HALF_WIDTH, TALLY_HALF_WIDTH], + z=[-TALLY_HALF_WIDTH, TALLY_HALF_WIDTH], + ) + tallies.append( + mcdc.Tally( + name=f"{kind}_flux_p{index}", + mesh=mesh, + particle_type="neutron", + energy=energy, + scores=["flux"], + ) + ) +simulation.set_tallies(tallies) + +simulation.settings.N_particle = 100_000 +simulation.settings.N_batch = 20 +simulation.settings.output_name = "output" + +simulation.run() diff --git a/validation/sinbad/fng-sic/plot.py b/validation/sinbad/fng-sic/plot.py new file mode 100644 index 0000000..5a3f93c --- /dev/null +++ b/validation/sinbad/fng-sic/plot.py @@ -0,0 +1,72 @@ +"""Plot neutron spectra sampled in the integral FNG SiC model.""" + +import argparse + +import h5py +import matplotlib.pyplot as plt +import numpy as np + +POSITION_LABELS = { + "activation": [10.41, 25.65, 40.89, 56.13], + "heating": [14.99, 30.23, 45.47, 60.71], +} + +parser = argparse.ArgumentParser() +parser.add_argument("input", nargs="?", default="output.h5") +parser.add_argument("--output", default="neutron-spectra.png") +args = parser.parse_args() + + +def read_spectrum(output, name): + """Return a volume- and energy-differential neutron flux spectrum.""" + tally = output[f"tallies/{name}"] + energy = tally["grid/energy"][:] + volume = np.prod( + [ + np.diff(tally["grid/x"])[0], + np.diff(tally["grid/y"])[0], + np.diff(tally["grid/z"])[0], + ] + ) + energy_width = np.diff(energy) * 1.0e-6 + mean = np.asarray(tally["flux/mean"][:]).squeeze() / volume / energy_width + sdev = np.asarray(tally["flux/sdev"][:]).squeeze() / volume / energy_width + return np.sqrt(energy[:-1] * energy[1:]) * 1.0e-6, mean, sdev + + +# Separate the activation and heating locations because they correspond to +# different experimental responses in the official benchmark. +fig, axes = plt.subplots(1, 2, figsize=(12.0, 4.5), sharex=True, sharey=True) +with h5py.File(args.input, "r") as output: + for ax, (kind, positions) in zip(axes, POSITION_LABELS.items()): + for index, position in enumerate(positions, start=1): + energy, spectrum, spectrum_sdev = read_spectrum( + output, f"{kind}_flux_p{index}" + ) + (line,) = ax.step( + energy, + spectrum, + where="mid", + label=f"P{index}: {position:.2f} cm", + ) + ax.fill_between( + energy, + np.maximum(spectrum - spectrum_sdev, np.finfo(float).tiny), + spectrum + spectrum_sdev, + step="mid", + color=line.get_color(), + alpha=0.12, + linewidth=0.0, + ) + ax.set_xscale("log") + ax.set_yscale("log") + ax.set_xlabel("Neutron energy [MeV]") + ax.set_title(f"{kind.capitalize()} positions") + ax.grid(which="both", alpha=0.25) + ax.legend() + +axes[0].set_ylabel(r"Neutron flux [source$^{-1}$ cm$^{-2}$ MeV$^{-1}$]") +fig.suptitle("FNG SiC public-entry model") +fig.tight_layout() +fig.savefig(args.output, dpi=200) +plt.close(fig) diff --git a/validation/sinbad/fng-tud-sic/README.md b/validation/sinbad/fng-tud-sic/README.md new file mode 100644 index 0000000..eb9980c --- /dev/null +++ b/validation/sinbad/fng-tud-sic/README.md @@ -0,0 +1,48 @@ +# FNG/TUD SiC spectral experiment + +This directory contains a preliminary MC/DC reconstruction of the [FNG/TUD SiC experiment](https://www.oecd-nea.org/science/wprs/shielding/sinbad/tud_sic/tudsic-a.htm) described by the public SINBAD entry NEA-1553/70. + +It is not a transcription of the complete SINBAD package. + +## Public-entry information + +The experiment measured neutron and photon flux spectra at four depths in a thick silicon-carbide block irradiated by the 14 MeV Frascati Neutron Generator. + +| Item | Public-entry specification | +| --- | --- | +| Block | 45.7 cm × 45.7 cm × 71.1 cm along the measurement axis | +| Construction | SiC bricks represented in the published calculation at 3.158 g/cm³ | +| Composition | 68.9 wt% Si, 30.8 wt% C, 0.19 wt% B, 0.079 wt% Al, and 0.014 wt% Fe | +| Source | FNG D-T source with angle-dependent intensity and energy | +| Target stand-off | 5.3 cm before the block | +| Measurement positions | 12.70, 27.94, 43.18, and 58.42 cm along the central axis | +| Detector | Cylindrical NE-213 scintillator, 3.8 cm in diameter and height | +| Reported quantities | Absolute unfolded neutron and photon flux spectra | + +The public abstract catalogs the detailed source tables, spectra, MCNP model, and figures, but those assets are not exposed by the currently accessible abstract page. + +## Assumptions requiring official-package review + +- The 116-brick assembly is represented as one homogeneous rectangular block. +- The reported target location is aligned with the x-axis used for the measurement depths. +- The angle-energy-dependent source is replaced by an isotropic 14 MeV point source. +- Small mesh volumes sample the four positions without explicitly modeling the NE-213 detector or its perturbation. +- The neutron energy grid uses 160 logarithmic bins from 0.001 to 20 MeV rather than the experimental bin boundaries. +- Only neutron transport is modeled because MC/DC does not yet support the coupled neutron–photon production required for the measured photon spectra. +- The plots contain no unfolded experimental spectra or detector-response treatment. + +The complete package should refine the brick geometry, target assembly, source distribution, detector model, energy bins, and experimental comparison. + +## Reconstructed diagram + +The following diagram summarizes the public-entry description and is not an original SINBAD figure. + +![Reconstructed FNG/TUD SiC arrangement](figures/geometry.svg) + +## Files + +- `input.py` defines the neutron model and flux tallies at the four reported positions. +- `plot.py` plots the calculated neutron spectra and their Monte Carlo uncertainties. +- `figures/` contains a reconstructed diagram based on the public entry. + +MC/DC requires a native-data library containing the natural isotopes of Si, C, B, Al, and Fe. diff --git a/validation/sinbad/fng-tud-sic/figures/geometry.svg b/validation/sinbad/fng-tud-sic/figures/geometry.svg new file mode 100644 index 0000000..12629f8 --- /dev/null +++ b/validation/sinbad/fng-tud-sic/figures/geometry.svg @@ -0,0 +1,23 @@ + + + FNG/TUD SiC spectral experiment + + + D-T source + + + Homogenized SiC block + 71.1 cm × 45.7 cm × 45.7 cm + + + + + P1 12.70 cmP2 27.94 cmP3 43.18 cmP4 58.42 cm + + + + + 5.3 cm + Reconstructed diagram—not an original SINBAD figure; dimensions are not to scale. + + diff --git a/validation/sinbad/fng-tud-sic/input.py b/validation/sinbad/fng-tud-sic/input.py new file mode 100644 index 0000000..46891f6 --- /dev/null +++ b/validation/sinbad/fng-tud-sic/input.py @@ -0,0 +1,93 @@ +"""Public-entry reconstruction of the FNG/TUD SiC spectral experiment.""" + +import numpy as np + +import mcdc + +AVOGADRO_BARN_CM = 0.602214076 +DETECTOR_POSITIONS = { + "p1": 12.70, + "p2": 27.94, + "p3": 43.18, + "p4": 58.42, +} +TALLY_HALF_WIDTH = 0.1 + + +def atomic_density(mass_density, mass_fraction, atomic_weight): + """Convert a mass fraction and density to atoms/(barn cm).""" + return AVOGADRO_BARN_CM * mass_density * mass_fraction / atomic_weight + + +simulation = mcdc.Simulation("SINBAD FNG/TUD SiC public-entry model") + +# Use the bulk composition stated in the public SINBAD abstract. +sic = mcdc.Material( + name="FNG SiC block", + element_composition={ + "Si": atomic_density(3.158, 0.689, 28.085), + "C": atomic_density(3.158, 0.308, 12.011), + "B": atomic_density(3.158, 0.0019, 10.81), + "Al": atomic_density(3.158, 0.00079, 26.9815), + "Fe": atomic_density(3.158, 0.00014, 55.845), + }, +) + +# Approximate the brick assembly by one homogeneous block preceded by a void +# region containing the FNG target position. +x_source = mcdc.Surface.PlaneX(x=-5.31, boundary_condition="vacuum") +x_front = mcdc.Surface.PlaneX(x=0.0) +x_back = mcdc.Surface.PlaneX(x=71.1, boundary_condition="vacuum") +y_min = mcdc.Surface.PlaneY(y=-22.85, boundary_condition="vacuum") +y_max = mcdc.Surface.PlaneY(y=22.85, boundary_condition="vacuum") +z_min = mcdc.Surface.PlaneZ(z=-22.85, boundary_condition="vacuum") +z_max = mcdc.Surface.PlaneZ(z=22.85, boundary_condition="vacuum") +transverse_region = +y_min & -y_max & +z_min & -z_max + +source_void = mcdc.Cell( + name="Source-side void", + region=+x_source & -x_front & transverse_region, +) +sic_block = mcdc.Cell( + name="Homogenized SiC block", + region=+x_front & -x_back & transverse_region, + fill=sic, +) +simulation.set_model([source_void, sic_block]) + +# Replace the tabulated angle-energy source by a monoenergetic isotropic source +# at the reported 5.3 cm stand-off. +source = mcdc.Source( + name="FNG D-T source approximation", + position=[-5.3, 0.0, 0.0], + isotropic=True, + energy=14.0e6, +) +simulation.set_sources([source]) + +# Sample the neutron spectrum in small volumes centered on the four reported +# detector positions without perturbing the block with provisional detector cells. +energy = np.geomspace(1.0e3, 20.0e6, 161) +tallies = [] +for label, x in DETECTOR_POSITIONS.items(): + mesh = mcdc.MeshStructured( + x=[x - TALLY_HALF_WIDTH, x + TALLY_HALF_WIDTH], + y=[-TALLY_HALF_WIDTH, TALLY_HALF_WIDTH], + z=[-TALLY_HALF_WIDTH, TALLY_HALF_WIDTH], + ) + tallies.append( + mcdc.Tally( + name=f"neutron_flux_{label}", + mesh=mesh, + particle_type="neutron", + energy=energy, + scores=["flux"], + ) + ) +simulation.set_tallies(tallies) + +simulation.settings.N_particle = 100_000 +simulation.settings.N_batch = 20 +simulation.settings.output_name = "output" + +simulation.run() diff --git a/validation/sinbad/fng-tud-sic/plot.py b/validation/sinbad/fng-tud-sic/plot.py new file mode 100644 index 0000000..fc92709 --- /dev/null +++ b/validation/sinbad/fng-tud-sic/plot.py @@ -0,0 +1,64 @@ +"""Plot neutron spectra for the FNG/TUD SiC public-entry model.""" + +import argparse + +import h5py +import matplotlib.pyplot as plt +import numpy as np + +POSITIONS = { + "p1": "P1: 12.70 cm", + "p2": "P2: 27.94 cm", + "p3": "P3: 43.18 cm", + "p4": "P4: 58.42 cm", +} + +parser = argparse.ArgumentParser() +parser.add_argument("input", nargs="?", default="output.h5") +parser.add_argument("--output", default="neutron-spectra.png") +args = parser.parse_args() + + +def read_spectrum(output, name): + """Return a volume- and energy-differential neutron flux spectrum.""" + tally = output[f"tallies/{name}"] + energy = tally["grid/energy"][:] + volume = np.prod( + [ + np.diff(tally["grid/x"])[0], + np.diff(tally["grid/y"])[0], + np.diff(tally["grid/z"])[0], + ] + ) + energy_width = np.diff(energy) * 1.0e-6 + mean = np.asarray(tally["flux/mean"][:]).squeeze() / volume / energy_width + sdev = np.asarray(tally["flux/sdev"][:]).squeeze() / volume / energy_width + return np.sqrt(energy[:-1] * energy[1:]) * 1.0e-6, mean, sdev + + +# Plot the four calculated neutron spectra without an experimental overlay. +fig, ax = plt.subplots(figsize=(7.0, 4.5)) +with h5py.File(args.input, "r") as output: + for label, description in POSITIONS.items(): + energy, spectrum, spectrum_sdev = read_spectrum(output, f"neutron_flux_{label}") + (line,) = ax.step(energy, spectrum, where="mid", label=description) + ax.fill_between( + energy, + np.maximum(spectrum - spectrum_sdev, np.finfo(float).tiny), + spectrum + spectrum_sdev, + step="mid", + color=line.get_color(), + alpha=0.12, + linewidth=0.0, + ) + +ax.set_xscale("log") +ax.set_yscale("log") +ax.set_xlabel("Neutron energy [MeV]") +ax.set_ylabel(r"Neutron flux [source$^{-1}$ cm$^{-2}$ MeV$^{-1}$]") +ax.set_title("FNG/TUD SiC public-entry model") +ax.grid(which="both", alpha=0.25) +ax.legend() +fig.tight_layout() +fig.savefig(args.output, dpi=200) +plt.close(fig) diff --git a/validation/sinbad/oktavian-si-60/README.md b/validation/sinbad/oktavian-si-60/README.md new file mode 100644 index 0000000..87dd1a2 --- /dev/null +++ b/validation/sinbad/oktavian-si-60/README.md @@ -0,0 +1,55 @@ +# OKTAVIAN Si-60 + +This directory contains a preliminary MC/DC reconstruction of the 60 cm silicon-sphere experiment described by the public [SINBAD OKTAVIAN silicon entry](https://www.oecd-nea.org/science/wprs/shielding/sinbad/oktav_si/oksi-abs.htm). + +It is not a transcription of the licensed revised SINBAD package. + +## Public-entry information + +The experiment measured the neutron and neutron-induced photon leakage spectra from a granular silicon pile irradiated by the central D-T source at the OKTAVIAN facility. + +The public entry gives the following information for the 60 cm configuration. + +| Item | Public-entry specification | +| --- | --- | +| Outer vessel | 61.0 cm outer diameter | +| Central void | 20 cm diameter | +| Reentrant hole | 11 cm diameter target-beam hole | +| Vessel walls | 0.5 cm external and 0.2 cm internal thickness | +| Silicon | At least 99.9% pure granular silicon at 1.29 g/cm³ | +| Stainless steel | 18.5 wt% Cr, 70.4 wt% Fe, and 11.1 wt% Ni at 7.86 g/cm³ | +| Source | Central D-T source produced by 250 keV deuterons on a tritium target | +| Analysis assumption | Isotropic source direction | +| Neutron detector | NE-218 scintillator about 11 m away and 55° from the deuteron beam axis | +| Reported response | Outer-surface leakage current normalized per source neutron | + +The public entry describes a tabulated source spectrum and measured leakage spectrum, but their numerical tables are distributed with SINBAD rather than exposed by the abstract page. + +## Assumptions requiring official-package review + +- The source is represented as an isotropic 14 MeV point source rather than the tabulated D-T source spectrum. +- The vessel is represented by concentric spherical shells, and the reentrant hole is assumed to be a one-sided void along the positive z-axis. +- The materials use the stated bulk compositions with natural elemental abundances, without additional impurities or construction details. +- Only neutron transport is modeled; the neutron-induced photon measurement is not yet included. +- The NE-218 detector and polyethylene–iron collimator are not included in the transport geometry. +- The tally integrates outward neutron current over the complete external vessel surface rather than selecting the detector's viewing direction at 55°. +- The energy grid uses 160 logarithmic bins from 0.01 to 20 MeV as a provisional development grid rather than the experimental bin boundaries. +- The plot contains no experimental overlay or detector-response treatment. + +The complete official package should be used to refine the source, geometry, materials, directional tally, energy bins, detector-response treatment, and experimental comparison. + +## Reconstructed diagrams + +These diagrams summarize the public-entry description and are not reproductions of the original SINBAD figures. + +![Reconstructed Type-III vessel cross section](figures/model-geometry.svg) + +![Reconstructed measurement arrangement](figures/measurement-layout.svg) + +## Files + +- `input.py` defines the continuous-energy MC/DC model and outward leakage-current tally. +- `plot.py` converts the energy-bin currents to a differential spectrum and plots their Monte Carlo uncertainty. +- `figures/` contains reconstructed diagrams based on the dimensions and arrangement stated in the public entry. + +MC/DC requires a native-data library containing the natural isotopes of Si, Cr, Fe, and Ni. diff --git a/validation/sinbad/oktavian-si-60/figures/measurement-layout.svg b/validation/sinbad/oktavian-si-60/figures/measurement-layout.svg new file mode 100644 index 0000000..ff046eb --- /dev/null +++ b/validation/sinbad/oktavian-si-60/figures/measurement-layout.svg @@ -0,0 +1,28 @@ + + + Public-entry measurement arrangement + Schematic only; distances are labeled but the drawing is not to scale. + + + 250 keV deuteron beam axis + + + + 60 cm silicon pile + + + 55° + + + + + Polyethylene–iron + pre-collimator + + NE-218 detector + 12.7 cm diameter + 5.1 cm long + about 11 m source-to-detector + + Reconstructed diagram—not an original SINBAD figure and not a transport-model representation of the detector hall. + diff --git a/validation/sinbad/oktavian-si-60/figures/model-geometry.svg b/validation/sinbad/oktavian-si-60/figures/model-geometry.svg new file mode 100644 index 0000000..fcb2e86 --- /dev/null +++ b/validation/sinbad/oktavian-si-60/figures/model-geometry.svg @@ -0,0 +1,35 @@ + + + OKTAVIAN Si-60 public-entry reconstruction + Cross section through the modeled beam axis; dimensions are from the public NEA abstract. + + + + + + + + +z / target-beam axis + + + 30.5 cm outer radius + + 10 cm void radius + + Granular natural silicon + 1.29 g/cm³; 99.9% minimum purity + + Outer stainless-steel wall + 0.5 cm thickness + + Inner stainless-steel wall + 0.2 cm thickness + + Reentrant void + 11 cm diameter; modeled one-sided + + Central isotropic source + 14 MeV approximation + + Reconstructed diagram—not an original SINBAD figure and not a substitute for the revised package geometry. + diff --git a/validation/sinbad/oktavian-si-60/input.py b/validation/sinbad/oktavian-si-60/input.py new file mode 100644 index 0000000..738fa30 --- /dev/null +++ b/validation/sinbad/oktavian-si-60/input.py @@ -0,0 +1,105 @@ +"""Public-entry reconstruction of the OKTAVIAN 60-cm silicon sphere.""" + +import numpy as np + +import mcdc + +AVOGADRO_BARN_CM = 0.602214076 + + +def atomic_density(mass_density, mass_fraction, atomic_weight): + """Convert a mass fraction and density to atoms/(barn cm).""" + return AVOGADRO_BARN_CM * mass_density * mass_fraction / atomic_weight + + +simulation = mcdc.Simulation("SINBAD OKTAVIAN Si-60 public-entry model") + +# Build the natural-element materials from the densities and compositions stated in +# the public SINBAD entry. +silicon = mcdc.Material( + name="Granular natural silicon", + element_composition={ + "Si": atomic_density(1.29, 1.0, 28.085), + }, +) +stainless_steel = mcdc.Material( + name="JIS SUS-304 approximation", + element_composition={ + "Cr": atomic_density(7.86, 0.185, 51.9961), + "Fe": atomic_density(7.86, 0.704, 55.845), + "Ni": atomic_density(7.86, 0.111, 58.6934), + }, +) + +# Represent the Type-III vessel as concentric spheres with a one-sided, void +# reentrant hole along +z. +inner_void = mcdc.Surface.Sphere(name="Inner void", radius=10.0) +inner_vessel = mcdc.Surface.Sphere(name="Inner vessel outer wall", radius=10.2) +silicon_outer = mcdc.Surface.Sphere(name="Silicon outer surface", radius=30.0) +vessel_outer = mcdc.Surface.Sphere( + name="Vessel outer surface", + radius=30.5, + boundary_condition="vacuum", +) +beam_duct = mcdc.Surface.CylinderZ(name="Reentrant hole", radius=5.5) +duct_midplane = mcdc.Surface.PlaneZ(name="Reentrant-hole midplane", z=0.0) + +reentrant_hole = -beam_duct & +duct_midplane & -vessel_outer +source_void_cell = mcdc.Cell(name="Central source void", region=-inner_void) +duct_void_cell = mcdc.Cell( + name="Reentrant-hole void", + region=reentrant_hole & +inner_void, +) +inner_vessel_cell = mcdc.Cell( + name="Inner stainless-steel vessel", + region=+inner_void & -inner_vessel & ~reentrant_hole, + fill=stainless_steel, +) +silicon_cell = mcdc.Cell( + name="Granular silicon pile", + region=+inner_vessel & -silicon_outer & ~reentrant_hole, + fill=silicon, +) +outer_vessel_cell = mcdc.Cell( + name="Outer stainless-steel vessel", + region=+silicon_outer & -vessel_outer & ~reentrant_hole, + fill=stainless_steel, +) +simulation.set_model( + [ + source_void_cell, + duct_void_cell, + inner_vessel_cell, + silicon_cell, + outer_vessel_cell, + ] +) + +# The public entry states that benchmark analyses assume isotropic emission but does +# not expose its tabulated source spectrum, so this preliminary model uses 14 MeV. +source = mcdc.Source( + name="Central isotropic D-T source", + position=[0.0, 0.0, 0.0], + isotropic=True, + energy=14.0e6, +) +simulation.set_sources([source]) + +# Score the outward leakage current per source neutron on the vessel surface. +energy = np.geomspace(1.0e4, 20.0e6, 161) +leakage = mcdc.Tally( + name="neutron_leakage", + surface=vessel_outer, + particle_type="neutron", + energy=energy, + scores=["current-out"], +) +simulation.set_tallies([leakage]) + +# Provide a useful exploratory run while leaving production histories to the suite +# launcher once this case is integrated into the validation workflow. +simulation.settings.N_particle = 100_000 +simulation.settings.N_batch = 20 +simulation.settings.output_name = "output" + +simulation.run() diff --git a/validation/sinbad/oktavian-si-60/plot.py b/validation/sinbad/oktavian-si-60/plot.py new file mode 100644 index 0000000..2419516 --- /dev/null +++ b/validation/sinbad/oktavian-si-60/plot.py @@ -0,0 +1,52 @@ +"""Plot the MC/DC neutron leakage spectrum for the public-entry model.""" + +import argparse + +import h5py +import matplotlib.pyplot as plt +import numpy as np + +parser = argparse.ArgumentParser() +parser.add_argument("input", nargs="?", default="output.h5") +parser.add_argument("--output", default="neutron-leakage.png") +args = parser.parse_args() + +# Load the source-normalized surface-current tally and its Monte Carlo uncertainty. +with h5py.File(args.input, "r") as output: + tally = output["tallies/neutron_leakage"] + energy = tally["grid/energy"][:] + current = np.asarray(tally["current-out/mean"][:]).squeeze() + current_sdev = np.asarray(tally["current-out/sdev"][:]).squeeze() + +# Convert each integrated energy-bin current to a differential spectrum in MeV^-1. +energy_midpoint = np.sqrt(energy[:-1] * energy[1:]) * 1.0e-6 +energy_width = np.diff(energy) * 1.0e-6 +spectrum = current / energy_width +spectrum_sdev = current_sdev / energy_width +lower = np.maximum(spectrum - spectrum_sdev, np.finfo(float).tiny) +upper = spectrum + spectrum_sdev + +# Display the calculated spectrum only because the public entry does not expose the +# numerical experimental leakage table. +fig, ax = plt.subplots(figsize=(7.0, 4.5)) +ax.step(energy_midpoint, spectrum, where="mid", color="tab:blue", label="MC/DC") +ax.fill_between( + energy_midpoint, + lower, + upper, + step="mid", + color="tab:blue", + alpha=0.25, + linewidth=0.0, + label=r"MC standard deviation", +) +ax.set_xscale("log") +ax.set_yscale("log") +ax.set_xlabel("Neutron energy [MeV]") +ax.set_ylabel(r"Outward leakage current [source$^{-1}$ MeV$^{-1}$]") +ax.set_title("OKTAVIAN Si-60 public-entry model") +ax.grid(which="both", alpha=0.25) +ax.legend() +fig.tight_layout() +fig.savefig(args.output, dpi=200) +plt.close(fig) diff --git a/validation/sinbad/rfnc-photon-compounds/README.md b/validation/sinbad/rfnc-photon-compounds/README.md new file mode 100644 index 0000000..778c51b --- /dev/null +++ b/validation/sinbad/rfnc-photon-compounds/README.md @@ -0,0 +1,63 @@ +# RFNC photon spectra from H₂O, SiO₂, and NaCl + +This directory contains a preliminary MC/DC geometry and neutron-transport precursor for the [RFNC compound experiment](https://www.oecd-nea.org/science/wprs/shielding/sinbad/rfnc_ph2/rfnc_g2-a.htm) described by the public SINBAD entry NEA-1517/80. + +It is not yet a model of the benchmark's measured photon observable. + +## Public-entry information + +The experiment measured photon leakage spectra from spherical and hemispherical compound samples irradiated by a central 14 MeV neutron source. + +| Item | Public-entry specification | +| --- | --- | +| Samples | H₂O, SiO₂, and NaCl spheres, plus SiO₂ and NaCl hemispheres | +| Inner diameter | 100 mm | +| Outer diameter | 200 mm | +| Source | Central D-T source from a 200 keV deuteron accelerator | +| Published calculation source | Isotropic, monoenergetic 14 MeV neutron source | +| Photon detector | 60 mm × 60 mm stilbene scintillator | +| Measured photon range | 0.3–8.0 MeV | +| Neutron suppression | 30 mm-diameter, 400 mm-long steel rod and a polyethylene insert | +| Shielding and collimation | 1.5 m concrete wall with a collimator and 50 mm lead detector shield | +| Reported uncertainty | 12% combined measurement uncertainty | + +The public abstract catalogs sample parameters, measured spectra, an MCNP input, and figures, but those assets are not exposed by the currently accessible page. + +## Current precursor + +`input.py` defaults to the SiO₂ full-sphere configuration selected by the `SAMPLE` constant. + +The available provisional full-sphere selections are `h2o-sphere`, `sio2-sphere`, and `nacl-sphere`. + +The current tally is outgoing neutron current and is only a diagnostic of the neutron field that would drive photon production. + +It must not be compared with the experimental photon spectrum. + +## Assumptions requiring official-package review + +- The sample is represented as a simple compound shell between radii of 5 and 10 cm. +- The source target, sample container, steel delay rod, collimator, shielding wall, polyethylene insert, and detector are omitted. +- Provisional material densities are used because the sample-parameter table is not available on the abstract page. +- Only the full-sphere variants are represented; the hemisphere orientation and container details require the complete geometry. +- The current MC/DC kernel does not provide the coupled neutron-induced photon production and photon transport needed by this benchmark. +- The neutron diagnostic uses 160 logarithmic bins from 0.001 to 20 MeV rather than an official neutron energy structure. +- The eventual photon tally must use the benchmark's experimental boundaries and detector-response treatment over the measured range. +- No experimental data are included in the plot. + +The complete package and future MC/DC photon capability are both required before this precursor becomes a validation case. + +## Reconstructed diagrams + +These diagrams summarize the public-entry description and are not reproductions of original SINBAD figures. + +![Reconstructed RFNC sample geometry](figures/sample-geometry.svg) + +![Reconstructed RFNC measurement arrangement](figures/measurement-layout.svg) + +## Files + +- `input.py` defines the selectable full-sphere geometry and diagnostic neutron tally. +- `plot.py` plots the diagnostic neutron leakage and its Monte Carlo uncertainty. +- `figures/` contains reconstructed diagrams based on the public entry. + +The selected configuration requires a native-data library containing the natural isotopes of its constituent elements. diff --git a/validation/sinbad/rfnc-photon-compounds/figures/measurement-layout.svg b/validation/sinbad/rfnc-photon-compounds/figures/measurement-layout.svg new file mode 100644 index 0000000..5691031 --- /dev/null +++ b/validation/sinbad/rfnc-photon-compounds/figures/measurement-layout.svg @@ -0,0 +1,21 @@ + + + RFNC photon measurement arrangement + + + Compound sample + + Steel delay rod + + + 1.5 m concrete wall + + Polyethylene insert + + + Lead shield + Stilbene detector + + Reconstructed diagram—not an original SINBAD figure; spacing is schematic. + + diff --git a/validation/sinbad/rfnc-photon-compounds/figures/sample-geometry.svg b/validation/sinbad/rfnc-photon-compounds/figures/sample-geometry.svg new file mode 100644 index 0000000..b42cb33 --- /dev/null +++ b/validation/sinbad/rfnc-photon-compounds/figures/sample-geometry.svg @@ -0,0 +1,18 @@ + + + RFNC compound-sphere geometry + + + + D-T + source + + 5 cm + + 10 cm + + H₂O, SiO₂, or NaCl + + Central target void + Reconstructed diagram—not an original SINBAD figure; target and container details are omitted. + diff --git a/validation/sinbad/rfnc-photon-compounds/input.py b/validation/sinbad/rfnc-photon-compounds/input.py new file mode 100644 index 0000000..7a49f34 --- /dev/null +++ b/validation/sinbad/rfnc-photon-compounds/input.py @@ -0,0 +1,92 @@ +"""Neutron precursor for the RFNC photon-leakage compound experiment.""" + +import numpy as np + +import mcdc + +AVOGADRO_BARN_CM = 0.602214076 +SAMPLE = "sio2-sphere" +SAMPLES = { + "h2o-sphere": (1.0, {"H": 2, "O": 1}), + "sio2-sphere": (2.2, {"Si": 1, "O": 2}), + "nacl-sphere": (2.165, {"Na": 1, "Cl": 1}), +} +ATOMIC_WEIGHTS = { + "H": 1.008, + "O": 15.999, + "Si": 28.085, + "Na": 22.9898, + "Cl": 35.45, +} + + +def compound_composition(mass_density, stoichiometry): + """Return elemental atomic densities for a stoichiometric compound.""" + molecular_weight = sum( + ATOMIC_WEIGHTS[element] * count for element, count in stoichiometry.items() + ) + molecule_density = AVOGADRO_BARN_CM * mass_density / molecular_weight + return { + element: count * molecule_density for element, count in stoichiometry.items() + } + + +if SAMPLE not in SAMPLES: + raise ValueError(f"Unknown RFNC sample: {SAMPLE}") + +simulation = mcdc.Simulation("SINBAD RFNC photon-compound neutron precursor") + +# Use provisional bulk densities because the public abstract does not expose the +# sample-parameter table cataloged with the full benchmark. +sample_density, sample_stoichiometry = SAMPLES[SAMPLE] +sample_material = mcdc.Material( + name=SAMPLE, + element_composition=compound_composition( + sample_density, + sample_stoichiometry, + ), +) + +# Represent the selected full-sphere configuration as a 5-cm-radius source void +# surrounded by a 5-cm-thick compound shell. +inner_surface = mcdc.Surface.Sphere(name="Sample inner surface", radius=5.0) +outer_surface = mcdc.Surface.Sphere( + name="Sample outer surface", + radius=10.0, + boundary_condition="vacuum", +) +source_void = mcdc.Cell(name="Central target void", region=-inner_surface) +sample_shell = mcdc.Cell( + name="Compound sample", + region=+inner_surface & -outer_surface, + fill=sample_material, +) +simulation.set_model([source_void, sample_shell]) + +# Follow the simplifying source treatment used by the calculation described in +# the public entry. +source = mcdc.Source( + name="Central isotropic D-T source", + position=[0.0, 0.0, 0.0], + isotropic=True, + energy=14.0e6, +) +simulation.set_sources([source]) + +# Record neutron leakage as a transport diagnostic until coupled photon +# production and transport can represent the experimental observable. +energy = np.geomspace(1.0e3, 20.0e6, 161) +neutron_leakage = mcdc.Tally( + name="neutron_leakage_diagnostic", + surface=outer_surface, + particle_type="neutron", + energy=energy, + scores=["current-out"], +) +simulation.set_tallies([neutron_leakage]) + +simulation.settings.N_particle = 100_000 +simulation.settings.N_batch = 20 +simulation.settings.output_name = "output" + +simulation.run() diff --git a/validation/sinbad/rfnc-photon-compounds/plot.py b/validation/sinbad/rfnc-photon-compounds/plot.py new file mode 100644 index 0000000..ed5b935 --- /dev/null +++ b/validation/sinbad/rfnc-photon-compounds/plot.py @@ -0,0 +1,49 @@ +"""Plot the RFNC precursor's diagnostic neutron leakage spectrum.""" + +import argparse + +import h5py +import matplotlib.pyplot as plt +import numpy as np + +parser = argparse.ArgumentParser() +parser.add_argument("input", nargs="?", default="output.h5") +parser.add_argument("--output", default="neutron-leakage-diagnostic.png") +args = parser.parse_args() + +# Load the diagnostic neutron current and convert it to a differential spectrum. +with h5py.File(args.input, "r") as output: + tally = output["tallies/neutron_leakage_diagnostic"] + energy = tally["grid/energy"][:] + current = np.asarray(tally["current-out/mean"][:]).squeeze() + current_sdev = np.asarray(tally["current-out/sdev"][:]).squeeze() + +energy_midpoint = np.sqrt(energy[:-1] * energy[1:]) * 1.0e-6 +energy_width = np.diff(energy) * 1.0e-6 +spectrum = current / energy_width +spectrum_sdev = current_sdev / energy_width + +# Label the figure as an intermediate diagnostic to avoid confusing it with the +# measured photon-leakage spectrum. +fig, ax = plt.subplots(figsize=(7.0, 4.5)) +ax.step(energy_midpoint, spectrum, where="mid", color="tab:blue", label="MC/DC") +ax.fill_between( + energy_midpoint, + np.maximum(spectrum - spectrum_sdev, np.finfo(float).tiny), + spectrum + spectrum_sdev, + step="mid", + color="tab:blue", + alpha=0.25, + linewidth=0.0, + label=r"MC standard deviation", +) +ax.set_xscale("log") +ax.set_yscale("log") +ax.set_xlabel("Neutron energy [MeV]") +ax.set_ylabel(r"Outward neutron current [source$^{-1}$ MeV$^{-1}$]") +ax.set_title("RFNC compound model: neutron precursor diagnostic") +ax.grid(which="both", alpha=0.25) +ax.legend() +fig.tight_layout() +fig.savefig(args.output, dpi=200) +plt.close(fig) diff --git a/verification/analytical/neutron/fixed_source/README.md b/verification/analytical/neutron/fixed_source/README.md index afb4e99..8960791 100644 --- a/verification/analytical/neutron/fixed_source/README.md +++ b/verification/analytical/neutron/fixed_source/README.md @@ -12,25 +12,43 @@ The suite can be executed independently or as part of the top-level MC/DC-VVP wo cases/ Verification case definitions and processing scripts data/ Shared multigroup cross-section data maestro_run_*/ Generated Maestro workflow directories -results/ Generated figures from processed cases +results/ + launch_config.yaml Effective suite launch configuration + task.yaml Task-generation configuration used by the launch + convergence/ Statistical-convergence figures + comparison/ Highest-statistics reference comparisons and animations -task.yaml Configure the particle-count study for each case +task.yaml Configure task generation for each case study.yaml Generated Maestro study definition launch.py Build and launch the Maestro study -run_case.py Run one case over its particle-count study +run_case.py Run the tasks for one case process.py Process a completed Maestro study cleanup.py Remove generated outputs and figures util.py Provide shared processing and plotting utilities ``` +Each case contains a common set of files: + +```text +input.py Define and run the MC/DC model +reference.py Generate the analytical reference solution +process.py Evaluate convergence across the study +plot.py Inspect one MC/DC result against the reference +``` + ## Configuration The `task.yaml` file selects the cases and defines the particle-count range and number of tasks for each case. +One task is one MC/DC execution at one generated `N_particle` value. +An optional `walltime_factor` scales the launch-level walltime for an individual HPC case and defaults to `1.0`. +The base walltime is specified in hours, and the scaled value is rounded up to the scheduler resolution and limited by the platform maximum. +Local execution ignores walltime. Edit this file to change the study without modifying the launch or processing scripts. HPC runs use the shared platform settings in the repository's `configs/platform_config.py` and the user-specific settings in `configs/user_config.py`. +The `N_node` option sets the number of nodes, with all available CPU cores used on each node. ## Launching and processing @@ -43,10 +61,12 @@ python launch.py Launch the study on a supported HPC platform: ```bash -python launch.py --platform tuolumne --mpi +python launch.py --platform tuolumne --N_node 1 ``` -Use `--walltime HOURS` to limit the requested walltime and `--rewrite` to replace existing case output. +Use `--walltime HOURS` to set the base walltime. +Cases with every expected MC/DC output are omitted from the Maestro study, while partially complete cases run only their missing particle levels. +Run `python cleanup.py` before launching to remove existing case outputs and start the suite fresh. After all jobs have completed, process the latest Maestro run: @@ -60,7 +80,8 @@ Pass a Maestro run directory to process a specific run: python process.py maestro_run_ ``` -Processed figures are written to this suite's `results/` directory. +Convergence figures are written to `results/convergence/`. +Comparisons using each case's largest particle count are written to `results/comparison/`, with time-dependent comparisons stored as GIF animations. The top-level `process.py` collects these figures under the repository's `results/` directory. ## Cases @@ -68,6 +89,7 @@ The top-level `process.py` collects these figures under the repository's `result | Case | Description | | :--- | :---------- | | [`slab_absorbium`](cases/slab_absorbium/) | Steady-state flux distribution in a purely absorbing multilayer slab. | +| [`mms_two_group_slab`](cases/mms_two_group_slab/) | Manufactured steady-state two-group slab with positive linear volume sources and isotropic incoming boundary fluxes. | | [`slab_isobeam_td`](cases/slab_isobeam_td/) | Time-dependent flux propagation from an isotropic planar source. | | [`reed`](cases/reed/) | Reed's classic one-dimensional transport benchmark. | | [`azurv1`](cases/azurv1/) | AZURV1 transient benchmark. | @@ -82,6 +104,29 @@ The top-level `process.py` collects these figures under the repository's `result | [`inf_shem361_td-census`](cases/inf_shem361_td-census/) | Time-dependent SHEM-361 spectrum evolution with time censuses. | | [`inf_shem361-weight_windows`](cases/inf_shem361-weight_windows/) | Infinite homogeneous SHEM-361 with energy-dependent weight windows derived from the analytical spectrum. | +### MMS two-group slab + +This steady-state, one-dimensional problem uses a 10 cm slab with vacuum boundaries, two energy groups, and isotropic scattering. +Both groups have $\Sigma_t=1$ cm$^{-1}$ and $\Sigma_c=0.6$ cm$^{-1}$, and every entry of the outgoing-by-incoming scattering matrix is $0.2$ cm$^{-1}$. +We manufacture the right-hand sides (sum of in-scattering and non-homogeneous source) of the group-wise transport equation: + +$$ +R_1(x)=0.5+0.01x, +\qquad +R_2(x)=0.6-0.01x. +$$ + +The equal-and-opposite slopes yield isotropic incoming boundary fluxes and the positive, angle-integrated volume sources + +$$ +q_1(x)=0.56+0.02x, +\qquad +q_2(x)=0.76-0.02x. +$$ + +MC/DC samples these sources with piecewise-linear spatial distributions and the incoming fluxes with white half-space boundary sources. +The analytical scalar flux is $\phi_g(x)=2R_g(x)+R'_g(x)[E_3(x)-E_3(10-x)]$. + ### SHEM-361 multigroup data The SHEM-361 cases use `data/SHEM-361.npz` as their multigroup cross-section dataset. diff --git a/verification/analytical/neutron/fixed_source/cases/azurv1-basic_techniques/plot.py b/verification/analytical/neutron/fixed_source/cases/azurv1-basic_techniques/plot.py index 0e57ebe..980320c 100644 --- a/verification/analytical/neutron/fixed_source/cases/azurv1-basic_techniques/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/azurv1-basic_techniques/plot.py @@ -54,6 +54,11 @@ def animate(k): return line1, line2, text -# Uncomment to animate all time bins. -# _simulation = animation.FuncAnimation(fig, animate, frames=K) -plt.show() +# Preserve the complete space-time comparison as a portable animation. +simulation = animation.FuncAnimation(fig, animate, frames=K) +simulation.save( + "flux.gif", + writer=animation.PillowWriter(fps=max(2, K // 10)), + dpi=120, +) +plt.close(fig) diff --git a/verification/analytical/neutron/fixed_source/cases/azurv1-census-tally/plot.py b/verification/analytical/neutron/fixed_source/cases/azurv1-census-tally/plot.py index 0e57ebe..980320c 100644 --- a/verification/analytical/neutron/fixed_source/cases/azurv1-census-tally/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/azurv1-census-tally/plot.py @@ -54,6 +54,11 @@ def animate(k): return line1, line2, text -# Uncomment to animate all time bins. -# _simulation = animation.FuncAnimation(fig, animate, frames=K) -plt.show() +# Preserve the complete space-time comparison as a portable animation. +simulation = animation.FuncAnimation(fig, animate, frames=K) +simulation.save( + "flux.gif", + writer=animation.PillowWriter(fps=max(2, K // 10)), + dpi=120, +) +plt.close(fig) diff --git a/verification/analytical/neutron/fixed_source/cases/azurv1-census/plot.py b/verification/analytical/neutron/fixed_source/cases/azurv1-census/plot.py index 0e57ebe..980320c 100644 --- a/verification/analytical/neutron/fixed_source/cases/azurv1-census/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/azurv1-census/plot.py @@ -54,6 +54,11 @@ def animate(k): return line1, line2, text -# Uncomment to animate all time bins. -# _simulation = animation.FuncAnimation(fig, animate, frames=K) -plt.show() +# Preserve the complete space-time comparison as a portable animation. +simulation = animation.FuncAnimation(fig, animate, frames=K) +simulation.save( + "flux.gif", + writer=animation.PillowWriter(fps=max(2, K // 10)), + dpi=120, +) +plt.close(fig) diff --git a/verification/analytical/neutron/fixed_source/cases/azurv1-weight_windows/plot.py b/verification/analytical/neutron/fixed_source/cases/azurv1-weight_windows/plot.py index 0e57ebe..980320c 100644 --- a/verification/analytical/neutron/fixed_source/cases/azurv1-weight_windows/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/azurv1-weight_windows/plot.py @@ -54,6 +54,11 @@ def animate(k): return line1, line2, text -# Uncomment to animate all time bins. -# _simulation = animation.FuncAnimation(fig, animate, frames=K) -plt.show() +# Preserve the complete space-time comparison as a portable animation. +simulation = animation.FuncAnimation(fig, animate, frames=K) +simulation.save( + "flux.gif", + writer=animation.PillowWriter(fps=max(2, K // 10)), + dpi=120, +) +plt.close(fig) diff --git a/verification/analytical/neutron/fixed_source/cases/azurv1/plot.py b/verification/analytical/neutron/fixed_source/cases/azurv1/plot.py index 0e57ebe..980320c 100644 --- a/verification/analytical/neutron/fixed_source/cases/azurv1/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/azurv1/plot.py @@ -54,6 +54,11 @@ def animate(k): return line1, line2, text -# Uncomment to animate all time bins. -# _simulation = animation.FuncAnimation(fig, animate, frames=K) -plt.show() +# Preserve the complete space-time comparison as a portable animation. +simulation = animation.FuncAnimation(fig, animate, frames=K) +simulation.save( + "flux.gif", + writer=animation.PillowWriter(fps=max(2, K // 10)), + dpi=120, +) +plt.close(fig) diff --git a/verification/analytical/neutron/fixed_source/cases/azurv1_sub/plot.py b/verification/analytical/neutron/fixed_source/cases/azurv1_sub/plot.py index 0e57ebe..980320c 100644 --- a/verification/analytical/neutron/fixed_source/cases/azurv1_sub/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/azurv1_sub/plot.py @@ -54,6 +54,11 @@ def animate(k): return line1, line2, text -# Uncomment to animate all time bins. -# _simulation = animation.FuncAnimation(fig, animate, frames=K) -plt.show() +# Preserve the complete space-time comparison as a portable animation. +simulation = animation.FuncAnimation(fig, animate, frames=K) +simulation.save( + "flux.gif", + writer=animation.PillowWriter(fps=max(2, K // 10)), + dpi=120, +) +plt.close(fig) diff --git a/verification/analytical/neutron/fixed_source/cases/azurv1_super/plot.py b/verification/analytical/neutron/fixed_source/cases/azurv1_super/plot.py index 0e57ebe..980320c 100644 --- a/verification/analytical/neutron/fixed_source/cases/azurv1_super/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/azurv1_super/plot.py @@ -54,6 +54,11 @@ def animate(k): return line1, line2, text -# Uncomment to animate all time bins. -# _simulation = animation.FuncAnimation(fig, animate, frames=K) -plt.show() +# Preserve the complete space-time comparison as a portable animation. +simulation = animation.FuncAnimation(fig, animate, frames=K) +simulation.save( + "flux.gif", + writer=animation.PillowWriter(fps=max(2, K // 10)), + dpi=120, +) +plt.close(fig) diff --git a/verification/analytical/neutron/fixed_source/cases/inf_shem361-weight_windows/plot.py b/verification/analytical/neutron/fixed_source/cases/inf_shem361-weight_windows/plot.py index 5395fcb..8b2eeff 100644 --- a/verification/analytical/neutron/fixed_source/cases/inf_shem361-weight_windows/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/inf_shem361-weight_windows/plot.py @@ -30,4 +30,5 @@ plt.ylabel(r"$E\phi(E)$") plt.grid() plt.legend() -plt.show() +plt.savefig("flux.png", dpi=200, bbox_inches="tight") +plt.close() diff --git a/verification/analytical/neutron/fixed_source/cases/inf_shem361/plot.py b/verification/analytical/neutron/fixed_source/cases/inf_shem361/plot.py index 5395fcb..8b2eeff 100644 --- a/verification/analytical/neutron/fixed_source/cases/inf_shem361/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/inf_shem361/plot.py @@ -30,4 +30,5 @@ plt.ylabel(r"$E\phi(E)$") plt.grid() plt.legend() -plt.show() +plt.savefig("flux.png", dpi=200, bbox_inches="tight") +plt.close() diff --git a/verification/analytical/neutron/fixed_source/cases/inf_shem361_td-census/plot.py b/verification/analytical/neutron/fixed_source/cases/inf_shem361_td-census/plot.py index 83ad298..11e2a3d 100644 --- a/verification/analytical/neutron/fixed_source/cases/inf_shem361_td-census/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/inf_shem361_td-census/plot.py @@ -90,6 +90,11 @@ def animate(k): return line1, line2 -# Uncomment to animate all time bins. -# _simulation = animation.FuncAnimation(fig, animate, frames=K) -plt.show() +# Preserve the complete time-energy comparison as a portable animation. +simulation = animation.FuncAnimation(fig, animate, frames=K) +simulation.save( + "flux.gif", + writer=animation.PillowWriter(fps=max(2, K // 10)), + dpi=120, +) +plt.close(fig) diff --git a/verification/analytical/neutron/fixed_source/cases/inf_shem361_td/plot.py b/verification/analytical/neutron/fixed_source/cases/inf_shem361_td/plot.py index 83ad298..11e2a3d 100644 --- a/verification/analytical/neutron/fixed_source/cases/inf_shem361_td/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/inf_shem361_td/plot.py @@ -90,6 +90,11 @@ def animate(k): return line1, line2 -# Uncomment to animate all time bins. -# _simulation = animation.FuncAnimation(fig, animate, frames=K) -plt.show() +# Preserve the complete time-energy comparison as a portable animation. +simulation = animation.FuncAnimation(fig, animate, frames=K) +simulation.save( + "flux.gif", + writer=animation.PillowWriter(fps=max(2, K // 10)), + dpi=120, +) +plt.close(fig) diff --git a/verification/analytical/neutron/fixed_source/cases/mms_two_group_slab/input.py b/verification/analytical/neutron/fixed_source/cases/mms_two_group_slab/input.py new file mode 100644 index 0000000..35ed0de --- /dev/null +++ b/verification/analytical/neutron/fixed_source/cases/mms_two_group_slab/input.py @@ -0,0 +1,87 @@ +"""Define the analytical two-group slab MMS fixed-source problem.""" + +import numpy as np + +import mcdc + +LENGTH = 10.0 + +simulation = mcdc.Simulation("Two-group slab manufactured solution") + +# ====================================================================================== +# Set model +# ====================================================================================== +# The identical scattering columns make the two groups' opposite boundary-layer +# contributions cancel in the isotropic scattering source. + +material = mcdc.Material.multigroup( + capture=np.array([0.6, 0.6]), + scatter=np.full((2, 2), 0.2), +) + +left = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") +right = mcdc.Surface.PlaneX(x=LENGTH, boundary_condition="vacuum") +cell = mcdc.Cell(region=+left & -right, fill=material) +simulation.set_model([cell]) + +# ====================================================================================== +# Set sources +# ====================================================================================== +# Each positive linear volume-source profile is sampled exactly by the tabulated +# spatial PDF. Raw probabilities are the corresponding integrated source rates. + +volume_source_1 = mcdc.Source( + x=([0.0, LENGTH], [0.56, 0.76]), + isotropic=True, + energy=0, + probability=6.6, +) +volume_source_2 = mcdc.Source( + x=([0.0, LENGTH], [0.76, 0.56]), + isotropic=True, + energy=1, + probability=6.6, +) + +# The manufactured incoming angular flux is isotropic on each boundary, so white +# half-space sources sample its incident-current distribution exactly. +boundary_sources = [ + mcdc.Source( + position=(0.0, 0.0, 0.0), + white_direction=(1.0, 0.0, 0.0), + energy=0, + probability=0.25, + ), + mcdc.Source( + position=(0.0, 0.0, 0.0), + white_direction=(1.0, 0.0, 0.0), + energy=1, + probability=0.30, + ), + mcdc.Source( + position=(LENGTH, 0.0, 0.0), + white_direction=(-1.0, 0.0, 0.0), + energy=0, + probability=0.30, + ), + mcdc.Source( + position=(LENGTH, 0.0, 0.0), + white_direction=(-1.0, 0.0, 0.0), + energy=1, + probability=0.25, + ), +] +simulation.set_sources([volume_source_1, volume_source_2, *boundary_sources]) + +# ====================================================================================== +# Set tallies, settings, and run MC/DC +# ====================================================================================== + +mesh = mcdc.MeshStructured(x=np.linspace(0.0, LENGTH, 101)) +tally = mcdc.Tally(mesh=mesh, energy="all", scores=["flux"]) +simulation.set_tallies([tally]) + +simulation.settings.N_particle = 100 +simulation.settings.N_batch = 2 + +simulation.run() diff --git a/verification/analytical/neutron/fixed_source/cases/mms_two_group_slab/plot.py b/verification/analytical/neutron/fixed_source/cases/mms_two_group_slab/plot.py new file mode 100644 index 0000000..d609184 --- /dev/null +++ b/verification/analytical/neutron/fixed_source/cases/mms_two_group_slab/plot.py @@ -0,0 +1,65 @@ +"""Plot the fixed source and MC/DC-to-MMS solution comparison.""" + +import sys + +import h5py +import matplotlib.pyplot as plt +import numpy as np + +from reference import ( + LENGTH, + TOTAL_SOURCE_STRENGTH, + cell_average_scalar_flux, + scalar_source, +) + +output_file = sys.argv[1] + +with h5py.File(output_file, "r") as output: + x = output["tallies/tracklength_tally_0/grid/x"][:] + flux = output["tallies/tracklength_tally_0/flux/mean"][:] + flux_sdev = output["tallies/tracklength_tally_0/flux/sdev"][:] + +dx = np.diff(x) +x_midpoint = 0.5 * (x[:-1] + x[1:]) +flux = flux / dx +flux_sdev = flux_sdev / dx +reference = cell_average_scalar_flux(x) + +for group in range(2): + color = f"C{group}" + plt.plot(x_midpoint, flux[group], color=color, label=f"MC/DC group {group + 1}") + plt.fill_between( + x_midpoint, + flux[group] - flux_sdev[group], + flux[group] + flux_sdev[group], + color=color, + alpha=0.2, + ) + plt.plot( + x_midpoint, + reference[group], + "--", + color=color, + label=f"MMS group {group + 1}", + ) + +plt.xlabel(r"$x$, cm") +plt.ylabel("Scalar flux per source particle") +plt.grid() +plt.legend() +plt.savefig("flux.png", dpi=200, bbox_inches="tight") +plt.close() + +x_plot = np.linspace(0.0, LENGTH, 201) +source = scalar_source(x_plot) / TOTAL_SOURCE_STRENGTH + +for group in range(2): + plt.plot(x_plot, source[group], label=f"Group {group + 1}") + +plt.xlabel(r"$x$, cm") +plt.ylabel("Volume-source density per source particle") +plt.grid() +plt.legend() +plt.savefig("source.png", dpi=200, bbox_inches="tight") +plt.close() diff --git a/verification/analytical/neutron/fixed_source/cases/mms_two_group_slab/process.py b/verification/analytical/neutron/fixed_source/cases/mms_two_group_slab/process.py new file mode 100644 index 0000000..c3aafc9 --- /dev/null +++ b/verification/analytical/neutron/fixed_source/cases/mms_two_group_slab/process.py @@ -0,0 +1,36 @@ +"""Measure convergence against the analytical manufactured solution.""" + +import sys +from pathlib import Path + +import h5py +import numpy as np + +from reference import cell_average_scalar_flux + +SUITE_DIR = Path(__file__).resolve().parents[2] +if str(SUITE_DIR) not in sys.path: + sys.path.insert(0, str(SUITE_DIR)) + +import util + +logN_min = int(sys.argv[1]) +logN_max = int(sys.argv[2]) +N_task = int(sys.argv[3]) +particle_counts = np.logspace(logN_min, logN_max, N_task, dtype=int) + +error = np.zeros(N_task) +error_max = np.zeros(N_task) + +for index, N_particle in enumerate(particle_counts): + with h5py.File(f"output_{N_particle}.h5", "r") as output: + x = output["tallies/tracklength_tally_0/grid/x"][:] + dx = np.diff(x) + flux = output["tallies/tracklength_tally_0/flux/mean"][:] + + flux = flux / dx + reference = cell_average_scalar_flux(x) + error[index] = util.rerror(flux, reference) + error_max[index] = util.rerror_max(flux, reference) + +util.plot_convergence("flux", particle_counts, error, error_max) diff --git a/verification/analytical/neutron/fixed_source/cases/mms_two_group_slab/reference.py b/verification/analytical/neutron/fixed_source/cases/mms_two_group_slab/reference.py new file mode 100644 index 0000000..2438e9c --- /dev/null +++ b/verification/analytical/neutron/fixed_source/cases/mms_two_group_slab/reference.py @@ -0,0 +1,78 @@ +"""Analytical manufactured fields for the fixed-source two-group slab case.""" + +import numpy as np +from scipy.special import expn + +LENGTH = 10.0 +TOTAL_SOURCE_STRENGTH = 14.3 + +# R_g(x) = intercept_g + slope_g * x is the manufactured total isotropic +# right-hand side of the transport equation. +INTERCEPT = np.array([0.5, 0.6]) +SLOPE = np.array([0.01, -0.01]) +SCATTER_SOURCE = 0.22 + + +def total_rhs(x): + """Return the manufactured isotropic transport right-hand side.""" + x = np.atleast_1d(x) + return INTERCEPT[:, None] + SLOPE[:, None] * x + + +def scalar_source(x): + """Return the angle-integrated external volumetric source.""" + return 2.0 * (total_rhs(x) - SCATTER_SOURCE) + + +def angular_flux(x, mu): + """Return the exact angular flux per total external source particle.""" + x = np.atleast_1d(x) + mu = np.atleast_1d(mu) + x_grid, mu_grid = np.meshgrid(x, mu, indexing="ij") + distance = np.where(mu_grid > 0.0, x_grid, LENGTH - x_grid) + + flux = np.empty((2, len(x), len(mu))) + for group in range(2): + rhs = INTERCEPT[group] + SLOPE[group] * x_grid + correction = np.zeros_like(x_grid) + nonzero = mu_grid != 0.0 + correction[nonzero] = ( + mu_grid[nonzero] + * SLOPE[group] + * np.exp(-distance[nonzero] / np.abs(mu_grid[nonzero])) + ) + flux[group] = rhs - mu_grid * SLOPE[group] + correction + + return flux / TOTAL_SOURCE_STRENGTH + + +def scalar_flux(x): + """Return the exact scalar flux per total external source particle.""" + x = np.atleast_1d(x) + boundary_term = expn(3, x) - expn(3, LENGTH - x) + + flux = np.empty((2, len(x))) + for group in range(2): + rhs = INTERCEPT[group] + SLOPE[group] * x + flux[group] = 2.0 * rhs + SLOPE[group] * boundary_term + + return flux / TOTAL_SOURCE_STRENGTH + + +def cell_average_scalar_flux(x): + """Return exact cell averages per total external source particle.""" + x = np.atleast_1d(x) + dx = np.diff(x) + + # -E_4(x) - E_4(L-x) is an antiderivative of + # E_3(x) - E_3(L-x). + boundary_antiderivative = -expn(4, x) - expn(4, LENGTH - x) + boundary_average = np.diff(boundary_antiderivative) / dx + x_average = 0.5 * (x[:-1] + x[1:]) + + flux = np.empty((2, len(dx))) + for group in range(2): + rhs_average = INTERCEPT[group] + SLOPE[group] * x_average + flux[group] = 2.0 * rhs_average + SLOPE[group] * boundary_average + + return flux / TOTAL_SOURCE_STRENGTH diff --git a/verification/analytical/neutron/fixed_source/cases/reed/plot.py b/verification/analytical/neutron/fixed_source/cases/reed/plot.py index ee883ca..73a1936 100644 --- a/verification/analytical/neutron/fixed_source/cases/reed/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/reed/plot.py @@ -33,5 +33,5 @@ plt.grid() plt.legend() plt.title(r"$\bar{\phi}_i$") -plt.savefig(CASE_DIR / "scalar_flux.png", dpi=300) -plt.show() +plt.savefig(CASE_DIR / "flux.png", dpi=200, bbox_inches="tight") +plt.close() diff --git a/verification/analytical/neutron/fixed_source/cases/slab_absorbium/plot.py b/verification/analytical/neutron/fixed_source/cases/slab_absorbium/plot.py index 90d9285..b70c76f 100644 --- a/verification/analytical/neutron/fixed_source/cases/slab_absorbium/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/slab_absorbium/plot.py @@ -53,7 +53,8 @@ plt.grid() plt.legend() plt.title(r"$\bar{\phi}_i$") -plt.show() +plt.savefig("flux.png", dpi=200, bbox_inches="tight") +plt.close() # Angular flux - spatial average vmin = min(np.min(psi_ref), np.min(psi)) @@ -72,4 +73,5 @@ cbar_ax = fig.add_axes([0.85, 0.15, 0.05, 0.7]) cbar = fig.colorbar(im, cax=cbar_ax) cbar.set_label("Angular flux") -plt.show() +plt.savefig("angular_flux.png", dpi=200, bbox_inches="tight") +plt.close(fig) diff --git a/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/input.py b/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/input.py index 805ece8..36c0b7d 100644 --- a/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/input.py +++ b/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/input.py @@ -13,8 +13,8 @@ m = mcdc.Material.multigroup(capture=np.array([1.0])) # Set surfaces -s1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") -s2 = mcdc.Surface.PlaneX(x=5.0, boundary_condition="vacuum") +s1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") +s2 = mcdc.Surface.PlaneY(y=5.0, boundary_condition="vacuum") # Set cells cell = mcdc.Cell(region=+s1 & -s2, fill=m) @@ -23,11 +23,11 @@ # ====================================================================================== # Set source # ====================================================================================== -# Isotropic beam from left-end +# Isotropic beam from the lower-y boundary source = mcdc.Source( - position=(1e-10, 0.0, 0.0), - white_direction=(1.0, 0.0, 0.0), + position=(0.0, 1e-10, 0.0), + white_direction=(0.0, 1.0, 0.0), energy=0, time=[0.0, 5.0], ) @@ -38,7 +38,7 @@ # ====================================================================================== # Tallies -mesh = mcdc.MeshUniform(x=(0.0, 0.1, 50)) +mesh = mcdc.MeshUniform(y=(0.0, 0.1, 50)) tally = mcdc.Tally( mesh=mesh, scores=["flux"], diff --git a/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/plot.py b/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/plot.py index b78c26c..a1cc55d 100644 --- a/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/plot.py +++ b/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/plot.py @@ -9,27 +9,27 @@ # Reference solution with h5py.File(output, "r") as f: - x = f["tallies/tracklength_tally_0/grid/x"][:] + y = f["tallies/tracklength_tally_0/grid/y"][:] t = f["tallies/tracklength_tally_0/grid/time"][:] -phi_ref = reference(x, t) +phi_ref = reference(y, t) # Get results with h5py.File(output, "r") as f: - x = f["tallies/tracklength_tally_0/grid/x"][:] + y = f["tallies/tracklength_tally_0/grid/y"][:] t = f["tallies/tracklength_tally_0/grid/time"][:] - dx = x[1] - x[0] - x_mid = 0.5 * (x[:-1] + x[1:]) + dy = y[1] - y[0] + y_mid = 0.5 * (y[:-1] + y[1:]) dt = t[1:] - t[:-1] K = len(dt) - J = len(x_mid) + J = len(y_mid) phi = f["tallies/tracklength_tally_0/flux/mean"][:] phi_sd = f["tallies/tracklength_tally_0/flux/sdev"][:] # Normalize for k in range(K): - phi[k] *= 0.5 / dx - phi_sd[k] *= 0.5 / dx + phi[k] *= 0.5 / dy + phi_sd[k] *= 0.5 / dy for j in range(J): phi[:, j] /= dt phi_sd[:, j] /= dt @@ -38,7 +38,7 @@ fig = plt.figure() ax = plt.axes(ylim=(5e-5, 4e-1)) ax.grid() -ax.set_xlabel(r"$x$") +ax.set_xlabel(r"$y$") ax.set_ylabel(r"Flux") ax.set_title(r"$\bar{\phi}_{j}(t)$") ax.set_yscale("log") @@ -52,19 +52,24 @@ def animate(k): global fb fb.remove() - line1.set_data(x_mid, phi[k, :]) + line1.set_data(y_mid, phi[k, :]) fb = ax.fill_between( - x_mid, + y_mid, phi[k, :] - phi_sd[k, :], phi[k, :] + phi_sd[k, :], alpha=0.2, color="b", ) - line2.set_data(x_mid, phi_ref[k, :]) + line2.set_data(y_mid, phi_ref[k, :]) text.set_text(r"$t \in [%.1f, %.1f]$ s" % (t[k], t[k + 1])) return line1, line2, text -# Uncomment to animate all time bins. -# _simulation = animation.FuncAnimation(fig, animate, frames=K) -plt.show() +# Preserve the complete space-time comparison as a portable animation. +simulation = animation.FuncAnimation(fig, animate, frames=K) +simulation.save( + "flux.gif", + writer=animation.PillowWriter(fps=max(2, K // 10)), + dpi=120, +) +plt.close(fig) diff --git a/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/process.py b/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/process.py index 9273e97..080981a 100644 --- a/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/process.py +++ b/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/process.py @@ -14,9 +14,9 @@ # Reference solution with h5py.File("output_%i.h5" % (int(N_particle_list[0])), "r") as f: - x = f["tallies/tracklength_tally_0/grid/x"][:] + y = f["tallies/tracklength_tally_0/grid/y"][:] t = f["tallies/tracklength_tally_0/grid/time"][:] -phi_ref = reference(x, t) +phi_ref = reference(y, t) # Error containers error = np.zeros(len(N_particle_list)) @@ -26,18 +26,18 @@ for i, N_particle in enumerate(N_particle_list): # Get results with h5py.File("output_%i.h5" % (int(N_particle)), "r") as f: - x = f["tallies/tracklength_tally_0/grid/x"][:] + y = f["tallies/tracklength_tally_0/grid/y"][:] t = f["tallies/tracklength_tally_0/grid/time"][:] K = len(t) - 1 - J = len(x) - 1 - dx = x[1:] - x[:-1] + J = len(y) - 1 + dy = y[1:] - y[:-1] dt = t[1:] - t[:-1] phi = f["tallies/tracklength_tally_0/flux/mean"][:] # Normalize for k in range(K): - phi[k] *= 0.5 / dx + phi[k] *= 0.5 / dy for j in range(J): phi[:, j] /= dt diff --git a/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/reference.py b/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/reference.py index 5bd0ab2..1c59e56 100644 --- a/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/reference.py +++ b/verification/analytical/neutron/fixed_source/cases/slab_isobeam_td/reference.py @@ -10,39 +10,39 @@ # Point-wise solution -def phi_(x, t): - if x > v * t: +def phi_(y, t): + if y > v * t: return 0.0 else: return ( 1.0 / T * ( - SigmaT * x * (exp1(SigmaT * v * t) - exp1(SigmaT * x)) - + np.e ** (-SigmaT * x) - - x / (v * t) * np.e ** (-SigmaT * v * t) + SigmaT * y * (exp1(SigmaT * v * t) - exp1(SigmaT * y)) + + np.e ** (-SigmaT * y) + - y / (v * t) * np.e ** (-SigmaT * v * t) ) ) -def phi_X(t, x1, x2): - return quad(phi_, x1, x2, args=(t))[0] / (x2 - x1) +def phi_Y(t, y1, y2): + return quad(phi_, y1, y2, args=(t))[0] / (y2 - y1) -def reference(x, t): - x_mid = 0.5 * (x[:-1] + x[1:]) +def reference(y, t): + y_mid = 0.5 * (y[:-1] + y[1:]) dt = t[1:] - t[:-1] K = len(dt) - J = len(x_mid) + J = len(y_mid) phi = np.zeros([K, J]) for k in range(K): for j in range(J): - x1 = x[j] - x2 = x[j + 1] + y1 = y[j] + y2 = y[j + 1] t1 = t[k] t2 = t[k + 1] - phi[k, j] = quad(phi_X, t1, t2, args=(x1, x2))[0] / (t2 - t1) - # phi[k, j] = quad(phi_, x1, x2, args=(t[k + 1]))[0] / (x2 - x1) + phi[k, j] = quad(phi_Y, t1, t2, args=(y1, y2))[0] / (t2 - t1) + # phi[k, j] = quad(phi_, y1, y2, args=(t[k + 1]))[0] / (y2 - y1) return phi diff --git a/verification/analytical/neutron/fixed_source/cleanup.py b/verification/analytical/neutron/fixed_source/cleanup.py index 02871ec..2134416 100644 --- a/verification/analytical/neutron/fixed_source/cleanup.py +++ b/verification/analytical/neutron/fixed_source/cleanup.py @@ -1,7 +1,22 @@ -import os +"""Remove generated fixed-source outputs and processed figures.""" -# Delete output files -os.system("rm cases/*/output*") +import shutil +from pathlib import Path -# Delete results -os.system("rm results/*png") +suite_dir = Path(__file__).resolve().parent +cases_dir = suite_dir / "cases" + +# Remove simulation outputs and any figures left by interrupted processing. +for pattern in ("*/output*.h5", "*/*.png", "*/*.gif"): + for generated_file in cases_dir.glob(pattern): + generated_file.unlink() + +# Remove the complete processed-results hierarchy. +results_dir = suite_dir / "results" +if results_dir.is_dir(): + shutil.rmtree(results_dir) + +# Remove generated Maestro workflow directories. +for maestro_run in suite_dir.glob("maestro_run_*"): + if maestro_run.is_dir(): + shutil.rmtree(maestro_run) diff --git a/verification/analytical/neutron/fixed_source/launch.py b/verification/analytical/neutron/fixed_source/launch.py index 953c417..b285c1b 100644 --- a/verification/analytical/neutron/fixed_source/launch.py +++ b/verification/analytical/neutron/fixed_source/launch.py @@ -5,6 +5,7 @@ import subprocess from pathlib import Path +import numpy as np import yaml # ====================================================================================== @@ -25,6 +26,7 @@ # ====================================================================================== from configs.platform_config import PLATFORMS +from configs.util import case_outputs_complete, get_case_walltime # User overrides are optional for local runs. try: @@ -41,13 +43,25 @@ description="Launch the MC/DC VVP analytical neutron fixed-source suite." ) parser.add_argument("--platform", default="local", choices=["local"] + list(PLATFORMS)) -parser.add_argument("--mpi", action="store_true") -parser.add_argument("--walltime", type=int, default=None) -parser.add_argument("--rewrite", action="store_true") +parser.add_argument( + "--N_node", + type=int, + default=1, + help="Set the number of compute nodes.", +) +parser.add_argument( + "--walltime", + type=float, + default=None, + help="Set the base walltime in hours; each case scales it by walltime_factor.", +) args = parser.parse_args() -if args.mpi and args.platform == "local": - parser.error("--mpi requires a cluster platform. Specify --platform .") +if args.N_node < 1: + parser.error("--N_node must be at least one.") + +if args.platform == "local" and args.N_node != 1: + parser.error("Local execution supports only --N_node 1.") # ====================================================================================== @@ -80,13 +94,11 @@ scheduler = platform["scheduler"] cpu_cores = platform["cpu_cores_per_node"] - # Never request more walltime than the platform permits. - walltime_hours = ( - platform["max_walltime_hours"] - if args.walltime is None - else min(args.walltime, platform["max_walltime_hours"]) - ) - walltime = platform["walltime_format"].format(hours=walltime_hours) + if args.N_node > platform["max_nodes"]: + parser.error( + f"--N_node exceeds the {platform['max_nodes']}-node limit for " + f"{args.platform}." + ) account = user_platform_config.get("account") queue = user_platform_config.get("queue") @@ -111,33 +123,45 @@ # Build Maestro study # ====================================================================================== +# Convert each configured case into one independent Maestro step. steps = [] +case_walltimes = {} +skipped_cases = [] + +for case_name, task in tasks.items(): + case_dir = suite_dir / "cases" / case_name + counts = np.logspace( + task["logN_min"], + task["logN_max"], + task["N_task"], + dtype=int, + ) + + # Do not allocate a Maestro step when every sampling level is already present. + if case_outputs_complete(case_dir, counts): + skipped_cases.append(case_name) + print(f"Skip complete case: {case_name}") + continue -for case_name in tasks: # Normalize case names into stable Maestro step identifiers. safe_case_name = case_name.replace("-", "_") command = f"{mcdc_python} {run_case} --name {case_name}" - # Maestro replaces LAUNCHER with the scheduler-specific MPI launch command. - if args.mpi: - command += ' --mpi "$(LAUNCHER)"' - - if args.rewrite: - command += " --rewrite" + # Maestro replaces LAUNCHER with the scheduler-specific process launcher. + if not local: + command += ' --launcher "$(LAUNCHER)"' run = {"cmd": command} # Scheduled studies require explicit resources; local studies run directly. if not local: - run["nodes"] = 1 + run["nodes"] = args.N_node + walltime = get_case_walltime(task, platform, args.walltime) run["walltime"] = walltime - - if args.mpi: - run["procs"] = cpu_cores - run["exclusive"] = True - else: - run["procs"] = 1 + case_walltimes[case_name] = walltime + run["procs"] = args.N_node * cpu_cores + run["exclusive"] = True steps.append( { @@ -147,6 +171,11 @@ } ) +if not steps: + print("All configured cases are complete; nothing to launch.") + raise SystemExit(0) + +# Assemble the complete Maestro study from the generated case steps. study = { "description": { "name": "maestro_run", @@ -231,9 +260,12 @@ launch_config = { "platform": args.platform, - "mpi": args.mpi, + "scheduler": "local" if local else scheduler, + "N_node": args.N_node, + "N_process": 1 if local else args.N_node * cpu_cores, "walltime": args.walltime, - "rewrite": args.rewrite, + "case_walltimes": case_walltimes, + "mcdc_python": mcdc_python, } # Snapshot the effective launch and task configuration with the generated run. @@ -252,8 +284,7 @@ # ====================================================================================== print(f"Platform : {args.platform}") -print(f"MPI : {args.mpi}") -print(f"Rewrite : {args.rewrite}") +print(f"Nodes : {args.N_node}") print(f"Python : {mcdc_python}") print(f"Study : {study_file}") @@ -262,8 +293,10 @@ print(f"Account : {account}") print(f"Queue : {queue}") print(f"Reserv. : {reservation}") - print(f"Walltime : {walltime}") - print(f"Nodes : 1") - print(f"Procs : {cpu_cores if args.mpi else 1}") + print("Walltimes:") + for case_name, walltime in case_walltimes.items(): + print(f" {case_name}: {walltime}") + print(f"Procs : {args.N_node * cpu_cores}") print(f"Cases : {len(steps)}") +print(f"Skipped : {len(skipped_cases)}") diff --git a/verification/analytical/neutron/fixed_source/process.py b/verification/analytical/neutron/fixed_source/process.py index 3e869a2..5678329 100644 --- a/verification/analytical/neutron/fixed_source/process.py +++ b/verification/analytical/neutron/fixed_source/process.py @@ -1,4 +1,5 @@ import argparse +import shutil import subprocess import sys from pathlib import Path @@ -21,6 +22,25 @@ args = parser.parse_args() +# ====================================================================================== +# Helper functions +# ====================================================================================== + + +def clear_case_figures(case_dir): + """Remove figures left by an earlier processing run.""" + for pattern in ("*.png", "*.gif"): + for figure in case_dir.glob(pattern): + figure.unlink() + + +def collect_case_figures(case_dir, destination, case_name): + """Move generated case figures into a named suite results directory.""" + for pattern in ("*.png", "*.gif"): + for figure in case_dir.glob(pattern): + figure.replace(destination / f"{case_name}_{figure.name}") + + # ====================================================================================== # Paths # ====================================================================================== @@ -63,6 +83,21 @@ with task_file.open("r") as f: tasks = yaml.safe_load(f) +results_dir = suite_dir / "results" +convergence_dir = results_dir / "convergence" +comparison_dir = results_dir / "comparison" + +# Start with an empty suite results hierarchy on every processing run. +if results_dir.is_dir(): + shutil.rmtree(results_dir) + +convergence_dir.mkdir(parents=True, exist_ok=True) +comparison_dir.mkdir(parents=True, exist_ok=True) + +# Keep the effective launch and task definitions beside the processed figures. +shutil.copy2(launch_config_file, results_dir / "launch_config.yaml") +shutil.copy2(task_file, results_dir / "task.yaml") + # ====================================================================================== # Process cases @@ -83,7 +118,9 @@ N_task = task["N_task"] print(f"Processing {case_name}") + clear_case_figures(case_dir) + # Generate and collect convergence figures over the full particle-count study. subprocess.run( [ sys.executable, @@ -95,17 +132,17 @@ cwd=case_dir, check=True, ) + collect_case_figures(case_dir, convergence_dir, case_name) - # ================================================================================== - # Collect figures - # ================================================================================== - - results_dir = suite_dir / "results" - results_dir.mkdir(exist_ok=True) - - for figure in case_dir.glob("*.png"): - destination = results_dir / f"{case_name}_{figure.name}" - figure.replace(destination) + # Compare the highest-statistics result directly with its reference solution. + plot_script = case_dir / "plot.py" + output = case_dir / f"output_{int(10**logN_max)}.h5" + subprocess.run( + [sys.executable, str(plot_script), str(output)], + cwd=case_dir, + check=True, + ) + collect_case_figures(case_dir, comparison_dir, case_name) # ====================================================================================== @@ -115,7 +152,7 @@ print() print(f"Maestro run: {maestro_run}") print(f"Platform : {launch_config['platform']}") -print(f"MPI : {launch_config['mpi']}") -print(f"Rewrite : {launch_config['rewrite']}") +print(f"Nodes : {launch_config['N_node']}") +print(f"Processes : {launch_config['N_process']}") print(f"Cases : {len(tasks)}") print("Processing complete.") diff --git a/verification/analytical/neutron/fixed_source/run_case.py b/verification/analytical/neutron/fixed_source/run_case.py index 1851abe..71591b7 100644 --- a/verification/analytical/neutron/fixed_source/run_case.py +++ b/verification/analytical/neutron/fixed_source/run_case.py @@ -16,14 +16,9 @@ parser.add_argument("--name", required=True, help="Verification case name.") parser.add_argument("--task-file", default="task.yaml") parser.add_argument( - "--mpi", + "--launcher", default="", - help="MPI launch command supplied by Maestro, e.g. 'srun -n 112'.", -) -parser.add_argument( - "--rewrite", - action="store_true", - help="Rewrite existing output files instead of skipping them.", + help="Process launch command supplied by Maestro.", ) args = parser.parse_args() @@ -71,15 +66,11 @@ output_file = case_dir / f"{output}.h5" if output_file.is_file(): - if args.rewrite: - print(f"Rewrite (remove existing output): {args.name}, N={N_particle}") - output_file.unlink() - else: - print(f"Skip (output exists): {args.name}, N={N_particle}") - continue + print(f"Skip existing output: {args.name}, N={N_particle}") + continue command = ( - f"{args.mpi} {sys.executable} input.py " + f"{args.launcher} {sys.executable} input.py " f"--mode=numba " f"--N_particle={N_particle} " f"--output={output} " diff --git a/verification/analytical/neutron/fixed_source/task.yaml b/verification/analytical/neutron/fixed_source/task.yaml index 13ec96f..3f46dd4 100644 --- a/verification/analytical/neutron/fixed_source/task.yaml +++ b/verification/analytical/neutron/fixed_source/task.yaml @@ -45,68 +45,88 @@ azurv1: logN_min: 5 logN_max: 7 N_task: 7 + walltime_factor: 0.716 azurv1-basic_techniques: logN_min: 5 logN_max: 7 N_task: 7 + walltime_factor: 0.715 azurv1-weight_windows: logN_min: 4 logN_max: 6 N_task: 7 + walltime_factor: 0.715 azurv1-census: logN_min: 5 logN_max: 7 N_task: 7 + walltime_factor: 0.715 azurv1-census-tally: logN_min: 4 logN_max: 6 N_task: 7 + walltime_factor: 0.679 azurv1_sub: logN_min: 5 logN_max: 7 N_task: 7 + walltime_factor: 0.679 azurv1_super: logN_min: 5 logN_max: 7 N_task: 7 + walltime_factor: 0.679 slab_absorbium: logN_min: 5 logN_max: 7 N_task: 7 + walltime_factor: 0.679 slab_isobeam_td: logN_min: 5 logN_max: 7 N_task: 7 + walltime_factor: 0.679 inf_shem361: logN_min: 5 logN_max: 7 N_task: 7 + walltime_factor: 0.786 inf_shem361-weight_windows: logN_min: 3 logN_max: 5 N_task: 7 + walltime_factor: 0.715 inf_shem361_td: logN_min: 5 logN_max: 7 N_task: 7 + walltime_factor: 0.787 inf_shem361_td-census: logN_min: 5 logN_max: 7 N_task: 7 + walltime_factor: 0.968 reed: logN_min: 5 logN_max: 7 N_task: 7 + walltime_factor: 0.717 + +mms_two_group_slab: + logN_min: 4 + logN_max: 6 + N_task: 7 + walltime_factor: 0.645 diff --git a/verification/analytical/neutron/k_eigenvalue/README.md b/verification/analytical/neutron/k_eigenvalue/README.md index d701724..ea0955c 100644 --- a/verification/analytical/neutron/k_eigenvalue/README.md +++ b/verification/analytical/neutron/k_eigenvalue/README.md @@ -1,7 +1,7 @@ # Analytical Neutron $k$-Eigenvalue Verification -This suite verifies MC/DC neutron $k$-eigenvalue calculations using infinite homogeneous multigroup problems with analytical reference solutions. -It compares the estimated multiplication factor and fundamental-mode energy spectrum against the dominant solution of the corresponding generalized matrix eigenvalue problem. +This suite verifies MC/DC neutron $k$-eigenvalue calculations using one-group and multigroup problems with analytical and semi-analytical reference solutions. +It compares estimated multiplication factors and fundamental-mode flux shapes against dominant solutions of the corresponding transport eigenvalue problems. Verification checks for the expected $N_\mathrm{active}^{-1/2}$ statistical convergence as the number of active cycles increases. The number of particles per cycle and the number of inactive cycles are fixed in each case input so that the study isolates convergence with active cycles. @@ -12,28 +12,46 @@ The suite can be executed independently or as part of the top-level MC/DC-VVP wo ```text cases/ Verification case definitions and processing scripts maestro_run_*/ Generated Maestro workflow directories -results/ Generated figures from processed cases +results/ + launch_config.yaml Effective suite launch configuration + task.yaml Task-generation configuration used by the launch + convergence/ Active-cycle convergence and uncertainty figures + comparison/ Highest-statistics flux and cycle-history comparisons -task.yaml Configure the active-cycle study for each case +task.yaml Configure task generation for each case study.yaml Generated Maestro study definition launch.py Build and launch the Maestro study -run_case.py Run one case over its active-cycle study +run_case.py Run the tasks for one case process.py Process a completed Maestro study cleanup.py Remove generated outputs and figures util.py Provide shared processing and plotting utilities ``` -The cases use the SHEM-361 dataset in the neighboring fixed-source suite at `../fixed_source/data/SHEM-361.npz`. +Each case contains a common set of files: + +```text +input.py Define and run the MC/DC model +reference.py Generate the analytical reference solution +process.py Evaluate convergence across the study +plot.py Inspect one MC/DC result against the reference +``` + +The multigroup cases use the SHEM-361 dataset in the neighboring fixed-source suite at `../fixed_source/data/SHEM-361.npz`. ## Configuration The `task.yaml` file selects the cases and defines the minimum and maximum active-cycle counts and the number of tasks for each case. -The active-cycle counts are geometrically spaced so that convergence can be assessed efficiently over a range of tens to hundreds of cycles. +One task is one MC/DC execution at one generated `N_active` value. +The active-cycle counts are geometrically spaced so that convergence can be assessed efficiently over a range of tens to thousands of cycles. +An optional `walltime_factor` scales the launch-level walltime for an individual HPC case and defaults to `1.0`. +The base walltime is specified in hours, and the scaled value is rounded up to the scheduler resolution and limited by the platform maximum. +Local execution ignores walltime. The fixed particle and inactive-cycle counts are defined in each case's `input.py` and should be tuned with a preliminary source-convergence and variance study before running the active-cycle campaign. HPC runs use the shared platform settings in the repository's `configs/platform_config.py` and the user-specific settings in `configs/user_config.py`. +The `N_node` option sets the number of nodes, with all available CPU cores used on each node. ## Launching and processing @@ -46,10 +64,12 @@ python launch.py Launch the study on a supported HPC platform: ```bash -python launch.py --platform tuolumne --mpi +python launch.py --platform tuolumne --N_node 1 ``` -Use `--walltime HOURS` to limit the requested walltime and `--rewrite` to replace existing case output. +Use `--walltime HOURS` to set the base walltime. +Cases with every expected MC/DC output are omitted from the Maestro study, while partially complete cases run only their missing active-cycle levels. +Run `python cleanup.py` before launching to remove existing case outputs and start the suite fresh. After all jobs have completed, process the latest Maestro run: @@ -63,26 +83,43 @@ Pass a Maestro run directory to process a specific run: python process.py maestro_run_ ``` -Processed figures are written to this suite's `results/` directory. +Convergence figures are written to `results/convergence/`. +Flux and cycle-history plots from each case's largest active-cycle result are written to `results/comparison/`. The top-level `process.py` collects these figures under the repository's `results/` directory. -Each case's `plot.py` can inspect one result interactively, showing both the energy spectrum and the cycle-by-cycle multiplication factor: +Each case's `plot.py` saves the flux shape and cycle-by-cycle multiplication factor for one result: ```bash -python cases/inf_shem361_subcritical/plot.py cases/inf_shem361_subcritical/output_320.h5 +python cases/inf_shem361_subcritical/plot.py cases/inf_shem361_subcritical/output_2000.h5 ``` ## Cases | Case | Description | | :--- | :---------- | +| [`one_group_slab`](cases/one_group_slab/) | Homogeneous finite one-group slab with vacuum boundaries and a semi-analytical, leakage-dependent fundamental eigenpair. | +| [`kornreich`](cases/kornreich/) | Kornreich-Parsons seven-region, one-group slab with a published Green's-function eigenvalue. | | [`inf_shem361_subcritical`](cases/inf_shem361_subcritical/) | Infinite homogeneous SHEM-361 with the capture cross section increased by 50%. | | [`inf_shem361_supercritical`](cases/inf_shem361_supercritical/) | Infinite homogeneous SHEM-361 with the original capture cross section. | -For each case, the analytical multiplication factor and spectrum are obtained from +### One-group finite slab + +This case uses a homogeneous 10 cm slab with vacuum boundaries, $(\Sigma_c,\Sigma_s,\Sigma_f)=(0.25,0.50,0.25)$ cm$^{-1}$, and $\nu_p=2.519421$. +A refined $E_1$ collision-integral eigenproblem supplies the cell-averaged fundamental flux and $k=1.2$. + +### Kornreich-Parsons slab + +This case alternates four beryllium-reflector regions with three uranium-fuel regions, each one mean free path thick, with vacuum boundaries. +The reflector has $(\Sigma_t,\Sigma_s,\nu\Sigma_f)=(0.371,0.334,0)$ cm$^{-1}$, while the fuel has $(0.415,0.334,0.178)$ cm$^{-1}$. +The published Green's-function reference is $k_\mathrm{eff}=1.17361$. +MC/DC assigns all non-scattering fuel removal to fission to preserve the published $\nu\Sigma_f$, and a refined collision-integral solution supplies the reference flux shape. + +### SHEM-361 cases + +For each multigroup case, the analytical multiplication factor and spectrum are obtained from $$ -\left[\operatorname{diag}(\Sigma_t)-\Sigma_s\right]\phi +\left[\mathrm{diag}(\Sigma_t)-\Sigma_s\right]\phi = \frac{1}{k}\nu\Sigma_f\phi. $$ @@ -90,4 +127,5 @@ SciPy solves the generalized eigenvalue problem, and the dominant real eigenpair ## References +- D. E. Kornreich and D. K. Parsons, [*The Green's Function Method for Effective Multiplication Benchmark Calculations in Multi-Region Slab Geometry*](https://doi.org/10.1016/j.anucene.2004.03.012), Annals of Nuclear Energy, vol. 31, no. 13, pp. 1477–1494, 2004. - A. Hébert and A. Santamarina, *Refinement of the Santamarina-Hfaiedh Energy Mesh Between 22.5 eV and 11.4 keV*, PHYSOR 2008, vol. 2, pp. 929–938, 2008. diff --git a/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_subcritical/plot.py b/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_subcritical/plot.py index 79a3197..7ce9511 100644 --- a/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_subcritical/plot.py +++ b/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_subcritical/plot.py @@ -51,11 +51,18 @@ plt.ylabel(r"$E\phi(E)$") plt.grid() plt.legend() -plt.show() +plt.savefig("flux.png", dpi=200, bbox_inches="tight") +plt.close() # Show convergence of the cycle estimates and distinguish active statistics. cycles = np.arange(1, len(k_cycle) + 1) active_cycles = cycles[N_inactive:] +active_k = k_cycle[N_inactive:] +k_min = np.min(active_k) +k_max = np.max(active_k) +k_padding = 0.05 * (k_max - k_min) +if k_padding == 0.0: + k_padding = max(0.01 * abs(k_min), 1.0e-6) plt.plot(cycles, k_cycle, "-b", label="MC/DC cycle") plt.axhline(k_reference, color="k", linestyle="--", label="analytical") @@ -83,6 +90,9 @@ plt.xlabel("Cycle") plt.ylabel(r"$k$") +# Focus the vertical scale on fluctuations after source convergence. +plt.ylim(k_min - k_padding, k_max + k_padding) plt.grid() plt.legend() -plt.show() +plt.savefig("k_history.png", dpi=200, bbox_inches="tight") +plt.close() diff --git a/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_subcritical/process.py b/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_subcritical/process.py index 9d09d97..992825a 100644 --- a/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_subcritical/process.py +++ b/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_subcritical/process.py @@ -27,17 +27,26 @@ # Calculate the dominant analytical eigenpair once for all tasks. k_reference, flux_reference = reference() k_error = np.zeros(N_task) +k_estimate = np.zeros(N_task) +k_uncertainty = np.zeros(N_task) flux_error = np.zeros(N_task) for index, N_active in enumerate(active_cycle_counts): with h5py.File(f"output_{N_active}.h5", "r") as output: - k_effective = output["k_mean"][()] + k_estimate[index] = output["k_mean"][()] + k_uncertainty[index] = output["k_sdev"][()] flux = output["tallies/tracklength_tally_0/flux/mean"][:] # Only the spectrum shape matters for this infinite-medium eigenproblem. flux /= np.sum(flux) - k_error[index] = abs(k_effective - k_reference) / k_reference + k_error[index] = abs(k_estimate[index] - k_reference) / k_reference flux_error[index] = util.relative_error(flux, flux_reference) util.plot_convergence("k-effective", active_cycle_counts, k_error) +util.plot_k_estimates( + active_cycle_counts, + k_estimate, + k_uncertainty, + k_reference, +) util.plot_convergence("flux", active_cycle_counts, flux_error) diff --git a/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_supercritical/plot.py b/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_supercritical/plot.py index 79a3197..7ce9511 100644 --- a/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_supercritical/plot.py +++ b/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_supercritical/plot.py @@ -51,11 +51,18 @@ plt.ylabel(r"$E\phi(E)$") plt.grid() plt.legend() -plt.show() +plt.savefig("flux.png", dpi=200, bbox_inches="tight") +plt.close() # Show convergence of the cycle estimates and distinguish active statistics. cycles = np.arange(1, len(k_cycle) + 1) active_cycles = cycles[N_inactive:] +active_k = k_cycle[N_inactive:] +k_min = np.min(active_k) +k_max = np.max(active_k) +k_padding = 0.05 * (k_max - k_min) +if k_padding == 0.0: + k_padding = max(0.01 * abs(k_min), 1.0e-6) plt.plot(cycles, k_cycle, "-b", label="MC/DC cycle") plt.axhline(k_reference, color="k", linestyle="--", label="analytical") @@ -83,6 +90,9 @@ plt.xlabel("Cycle") plt.ylabel(r"$k$") +# Focus the vertical scale on fluctuations after source convergence. +plt.ylim(k_min - k_padding, k_max + k_padding) plt.grid() plt.legend() -plt.show() +plt.savefig("k_history.png", dpi=200, bbox_inches="tight") +plt.close() diff --git a/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_supercritical/process.py b/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_supercritical/process.py index 9d09d97..992825a 100644 --- a/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_supercritical/process.py +++ b/verification/analytical/neutron/k_eigenvalue/cases/inf_shem361_supercritical/process.py @@ -27,17 +27,26 @@ # Calculate the dominant analytical eigenpair once for all tasks. k_reference, flux_reference = reference() k_error = np.zeros(N_task) +k_estimate = np.zeros(N_task) +k_uncertainty = np.zeros(N_task) flux_error = np.zeros(N_task) for index, N_active in enumerate(active_cycle_counts): with h5py.File(f"output_{N_active}.h5", "r") as output: - k_effective = output["k_mean"][()] + k_estimate[index] = output["k_mean"][()] + k_uncertainty[index] = output["k_sdev"][()] flux = output["tallies/tracklength_tally_0/flux/mean"][:] # Only the spectrum shape matters for this infinite-medium eigenproblem. flux /= np.sum(flux) - k_error[index] = abs(k_effective - k_reference) / k_reference + k_error[index] = abs(k_estimate[index] - k_reference) / k_reference flux_error[index] = util.relative_error(flux, flux_reference) util.plot_convergence("k-effective", active_cycle_counts, k_error) +util.plot_k_estimates( + active_cycle_counts, + k_estimate, + k_uncertainty, + k_reference, +) util.plot_convergence("flux", active_cycle_counts, flux_error) diff --git a/verification/analytical/neutron/k_eigenvalue/cases/kornreich/input.py b/verification/analytical/neutron/k_eigenvalue/cases/kornreich/input.py new file mode 100644 index 0000000..66befd8 --- /dev/null +++ b/verification/analytical/neutron/k_eigenvalue/cases/kornreich/input.py @@ -0,0 +1,95 @@ +"""Define the Kornreich-Parsons seven-region slab benchmark.""" + +import numpy as np + +import mcdc + +N_PARTICLE = 10000 +N_INACTIVE = 20 +N_ACTIVE = 20 +N_CELL_PER_REGION = 50 + +SIGMA_T_FUEL = 0.415 +SIGMA_T_REFLECTOR = 0.371 +SIGMA_S = 0.334 +NU_SIGMA_F = 0.178 + +FUEL_WIDTH = 1.0 / SIGMA_T_FUEL +REFLECTOR_WIDTH = 1.0 / SIGMA_T_REFLECTOR +REGION_WIDTHS = np.array( + [ + REFLECTOR_WIDTH, + FUEL_WIDTH, + REFLECTOR_WIDTH, + FUEL_WIDTH, + REFLECTOR_WIDTH, + FUEL_WIDTH, + REFLECTOR_WIDTH, + ] +) +REGION_EDGES = np.concatenate(([0.0], np.cumsum(REGION_WIDTHS))) + +simulation = mcdc.Simulation("Kornreich-Parsons seven-region slab") + +# ====================================================================================== +# Set model +# ====================================================================================== +# The published data specify nu-Sigma-f rather than Sigma-f and nu separately. +# Assigning all non-scattering fuel removal to fission preserves Sigma-t, Sigma-s, +# and nu-Sigma-f, which fully determine this one-group transport eigenproblem. + +fuel_fission = SIGMA_T_FUEL - SIGMA_S +fuel = mcdc.Material.multigroup( + capture=np.array([0.0]), + scatter=np.array([[SIGMA_S]]), + fission=np.array([fuel_fission]), + nu_p=np.array([NU_SIGMA_F / fuel_fission]), +) +reflector = mcdc.Material.multigroup( + capture=np.array([SIGMA_T_REFLECTOR - SIGMA_S]), + scatter=np.array([[SIGMA_S]]), +) + +surfaces = [mcdc.Surface.PlaneX(x=REGION_EDGES[0], boundary_condition="vacuum")] +surfaces.extend(mcdc.Surface.PlaneX(x=edge) for edge in REGION_EDGES[1:-1]) +surfaces.append(mcdc.Surface.PlaneX(x=REGION_EDGES[-1], boundary_condition="vacuum")) + +materials = [reflector, fuel, reflector, fuel, reflector, fuel, reflector] +cells = [ + mcdc.Cell(region=+surfaces[i] & -surfaces[i + 1], fill=material) + for i, material in enumerate(materials) +] +simulation.set_model(cells) + +# ====================================================================================== +# Set initial source +# ====================================================================================== +# A uniform isotropic source preserves the symmetry of the fundamental mode. + +source = mcdc.Source(x=[REGION_EDGES[0], REGION_EDGES[-1]], isotropic=True, energy=0) +simulation.set_sources([source]) + +# ====================================================================================== +# Set tallies, settings, techniques, and run MC/DC +# ====================================================================================== + +mesh_edges = np.concatenate( + [ + np.linspace(left, right, N_CELL_PER_REGION + 1)[:-1] + for left, right in zip(REGION_EDGES[:-1], REGION_EDGES[1:]) + ] + + [REGION_EDGES[-1:]] +) +mesh = mcdc.MeshStructured(x=mesh_edges) +tally = mcdc.Tally(mesh=mesh, scores=["flux"]) +simulation.set_tallies([tally]) + +# Particle and inactive-cycle counts stay fixed while the study overrides N_active. +simulation.settings.N_particle = N_PARTICLE +simulation.settings.set_eigenmode(N_inactive=N_INACTIVE, N_active=N_ACTIVE) +simulation.settings.census_bank_buffer_ratio = 2.0 +simulation.settings.source_bank_buffer_ratio = 2.0 + +simulation.technique.population_control() + +simulation.run() diff --git a/verification/analytical/neutron/k_eigenvalue/cases/kornreich/plot.py b/verification/analytical/neutron/k_eigenvalue/cases/kornreich/plot.py new file mode 100644 index 0000000..dd5f42b --- /dev/null +++ b/verification/analytical/neutron/k_eigenvalue/cases/kornreich/plot.py @@ -0,0 +1,87 @@ +"""Plot the Kornreich eigenfunction and cycle-by-cycle multiplication factor.""" + +import sys + +import h5py +import matplotlib.pyplot as plt +import numpy as np + +from reference import REGION_EDGES, reference + +output_file = sys.argv[1] + +with h5py.File(output_file, "r") as output: + x = output["tallies/tracklength_tally_0/grid/x"][:] + flux = output["tallies/tracklength_tally_0/flux/mean"][:] + flux_sdev = output["tallies/tracklength_tally_0/flux/sdev"][:] + k_cycle = output["k_cycle"][:] + k_mean = output["k_mean"][()] + k_sdev = output["k_sdev"][()] + N_inactive = int(output["settings/N_inactive"][()]) + +dx = np.diff(x) +x_midpoint = 0.5 * (x[:-1] + x[1:]) +normalization = np.sum(flux) +flux = flux / dx / normalization +flux_sdev = flux_sdev / dx / normalization +k_reference, flux_reference = reference(x) + +plt.plot(x_midpoint, flux, "-b", label="MC/DC") +plt.fill_between( + x_midpoint, + flux - flux_sdev, + flux + flux_sdev, + alpha=0.2, + color="b", +) +plt.plot(x_midpoint, flux_reference, "--r", label="Reference") +for edge in REGION_EDGES[1:-1]: + plt.axvline(edge, color="0.7", linewidth=0.8, linestyle=":") +plt.xlabel(r"$x$, cm") +plt.ylabel("Normalized scalar flux") +plt.grid() +plt.legend() +plt.savefig("flux.png", dpi=200, bbox_inches="tight") +plt.close() + +cycles = np.arange(1, len(k_cycle) + 1) +active_cycles = cycles[N_inactive:] +active_k = k_cycle[N_inactive:] +k_min = np.min(active_k) +k_max = np.max(active_k) +k_padding = 0.05 * (k_max - k_min) +if k_padding == 0.0: + k_padding = max(0.01 * abs(k_min), 1.0e-6) + +plt.plot(cycles, k_cycle, "-b", label="MC/DC cycle") +plt.axhline(k_reference, color="k", linestyle="--", label="Reference") +plt.plot( + active_cycles, + np.full(active_cycles.size, k_mean), + ":r", + label="MC/DC active mean", +) +plt.fill_between( + active_cycles, + k_mean - k_sdev, + k_mean + k_sdev, + alpha=0.2, + color="r", +) + +if N_inactive > 0: + plt.axvline( + N_inactive + 0.5, + color="0.5", + linestyle=":", + label="active cycles begin", + ) + +plt.xlabel("Cycle") +plt.ylabel(r"$k$") +# Focus the vertical scale on fluctuations after source convergence. +plt.ylim(k_min - k_padding, k_max + k_padding) +plt.grid() +plt.legend() +plt.savefig("k_history.png", dpi=200, bbox_inches="tight") +plt.close() diff --git a/verification/analytical/neutron/k_eigenvalue/cases/kornreich/process.py b/verification/analytical/neutron/k_eigenvalue/cases/kornreich/process.py new file mode 100644 index 0000000..c8f126e --- /dev/null +++ b/verification/analytical/neutron/k_eigenvalue/cases/kornreich/process.py @@ -0,0 +1,58 @@ +"""Measure active-cycle convergence against the Kornreich reference.""" + +import sys +from pathlib import Path + +import h5py +import numpy as np + +from reference import reference + +SUITE_DIR = Path(__file__).resolve().parents[2] +if str(SUITE_DIR) not in sys.path: + sys.path.insert(0, str(SUITE_DIR)) + +import util + +N_active_min = int(sys.argv[1]) +N_active_max = int(sys.argv[2]) +N_task = int(sys.argv[3]) +active_cycle_counts = np.rint( + np.geomspace( + N_active_min, + N_active_max, + N_task, + ) +).astype(int) + +k_error = np.zeros(N_task) +k_estimate = np.zeros(N_task) +k_uncertainty = np.zeros(N_task) +flux_error = np.zeros(N_task) + +# Calculate the semi-analytical flux once and use the published multiplication factor. +with h5py.File(f"output_{active_cycle_counts[0]}.h5", "r") as output: + x_reference = output["tallies/tracklength_tally_0/grid/x"][:] +k_reference, flux_reference = reference(x_reference) + +for index, N_active in enumerate(active_cycle_counts): + with h5py.File(f"output_{N_active}.h5", "r") as output: + k_estimate[index] = output["k_mean"][()] + k_uncertainty[index] = output["k_sdev"][()] + x = output["tallies/tracklength_tally_0/grid/x"][:] + dx = np.diff(x) + flux = output["tallies/tracklength_tally_0/flux/mean"][:] / dx + + # Eigenvectors have arbitrary amplitude, so compare unit-integral shapes. + flux /= np.sum(flux * dx) + k_error[index] = abs(k_estimate[index] - k_reference) / k_reference + flux_error[index] = util.relative_error(flux, flux_reference) + +util.plot_convergence("k-effective", active_cycle_counts, k_error) +util.plot_k_estimates( + active_cycle_counts, + k_estimate, + k_uncertainty, + k_reference, +) +util.plot_convergence("flux", active_cycle_counts, flux_error) diff --git a/verification/analytical/neutron/k_eigenvalue/cases/kornreich/reference.py b/verification/analytical/neutron/k_eigenvalue/cases/kornreich/reference.py new file mode 100644 index 0000000..aa219ce --- /dev/null +++ b/verification/analytical/neutron/k_eigenvalue/cases/kornreich/reference.py @@ -0,0 +1,151 @@ +"""Semi-analytical eigenpair for the Kornreich-Parsons base case.""" + +import numpy as np +from scipy.linalg import matmul_toeplitz +from scipy.special import expn + +K_EFFECTIVE = 1.17361 +REFINEMENT = 8 + +SIGMA_T_FUEL = 0.415 +SIGMA_T_REFLECTOR = 0.371 +SIGMA_S = 0.334 +NU_SIGMA_F = 0.178 + +FUEL_WIDTH = 1.0 / SIGMA_T_FUEL +REFLECTOR_WIDTH = 1.0 / SIGMA_T_REFLECTOR +REGION_WIDTHS = np.array( + [ + REFLECTOR_WIDTH, + FUEL_WIDTH, + REFLECTOR_WIDTH, + FUEL_WIDTH, + REFLECTOR_WIDTH, + FUEL_WIDTH, + REFLECTOR_WIDTH, + ] +) +REGION_EDGES = np.concatenate(([0.0], np.cumsum(REGION_WIDTHS))) + + +def _expected_mesh(N_cell_per_region): + """Return a mesh with equal optical-width cells in every region.""" + return np.concatenate( + [ + np.linspace(left, right, N_cell_per_region + 1)[:-1] + for left, right in zip(REGION_EDGES[:-1], REGION_EDGES[1:]) + ] + + [REGION_EDGES[-1:]] + ) + + +def _collision_kernel_first_column(N_cell_per_region): + """Return the cell-integrated E1 kernel on the uniform optical mesh.""" + N = 7 * N_cell_per_region + optical_width = 1.0 / N_cell_per_region + column = np.empty(N) + + # Integrate the logarithmic diagonal singularity analytically. + column[0] = 2.0 * (1.0 - expn(2, 0.5 * optical_width)) + + offset = np.arange(1, N) + lower_distance = (offset - 0.5) * optical_width + upper_distance = (offset + 0.5) * optical_width + column[1:] = expn(2, lower_distance) - expn(2, upper_distance) + return column + + +def _kernel_product(column, vector): + """Apply one half of the collision kernel to a source vector.""" + return 0.5 * matmul_toeplitz( + (column, column), + vector, + check_finite=False, + ) + + +def _fixed_source_solve(column, scatter_ratio, source, initial): + """Converge the within-generation isotropic scattering source.""" + flux = initial.copy() + + for _ in range(1000): + updated = source + _kernel_product(column, scatter_ratio * flux) + if np.linalg.norm(updated - flux) <= 1.0e-13 * np.linalg.norm(updated): + return updated + flux = updated + + raise RuntimeError("Kornreich reference scattering iteration did not converge.") + + +def _dominant_eigenpair(column, scatter_ratio, fission_ratio): + """Solve the positive transport eigenproblem by power iteration.""" + flux = np.ones(len(column)) + flux /= np.linalg.norm(flux) + eigenvalue_previous = 0.0 + + for _ in range(200): + fission_source = _kernel_product(column, fission_ratio * flux) + updated = _fixed_source_solve( + column, + scatter_ratio, + fission_source, + flux, + ) + eigenvalue = float(flux @ updated / (flux @ flux)) + updated /= np.linalg.norm(updated) + + if abs(eigenvalue - eigenvalue_previous) <= 1.0e-12 * eigenvalue: + return eigenvalue, updated + eigenvalue_previous = eigenvalue + flux = updated + + raise RuntimeError("Kornreich reference power iteration did not converge.") + + +def reference(x): + """Return the published eigenvalue and semi-analytical cell-averaged flux.""" + x = np.asarray(x) + N_cell = len(x) - 1 + + if N_cell % 7 != 0: + raise ValueError("Kornreich reference requires equal cell counts per region.") + + N_cell_per_region = N_cell // 7 + if not np.allclose(x, _expected_mesh(N_cell_per_region)): + raise ValueError("Kornreich reference mesh must use equal optical-width cells.") + + N_fine_per_region = REFINEMENT * N_cell_per_region + is_fuel = np.tile( + np.concatenate( + ( + np.zeros(N_fine_per_region, dtype=bool), + np.ones(N_fine_per_region, dtype=bool), + ) + ), + 3, + ) + is_fuel = np.concatenate((is_fuel, np.zeros(N_fine_per_region, dtype=bool))) + + total = np.where(is_fuel, SIGMA_T_FUEL, SIGMA_T_REFLECTOR) + scatter_ratio = SIGMA_S / total + fission_ratio = np.where(is_fuel, NU_SIGMA_F / total, 0.0) + + column = _collision_kernel_first_column(N_fine_per_region) + computed_k, fine_flux = _dominant_eigenpair( + column, + scatter_ratio, + fission_ratio, + ) + if not np.isclose(computed_k, K_EFFECTIVE, rtol=1.0e-6): + raise RuntimeError( + "Kornreich integral reference does not reproduce published k-effective." + ) + + optical_width = 1.0 / N_fine_per_region + fine_dx = optical_width / total + fine_flux /= np.sum(fine_flux * fine_dx) + flux = np.mean( + fine_flux.reshape(N_cell, REFINEMENT), + axis=1, + ) + return K_EFFECTIVE, flux diff --git a/verification/analytical/neutron/k_eigenvalue/cases/one_group_slab/input.py b/verification/analytical/neutron/k_eigenvalue/cases/one_group_slab/input.py new file mode 100644 index 0000000..8128743 --- /dev/null +++ b/verification/analytical/neutron/k_eigenvalue/cases/one_group_slab/input.py @@ -0,0 +1,56 @@ +"""Define the one-group finite-slab eigenvalue benchmark.""" + +import numpy as np + +import mcdc + +LENGTH = 10.0 +N_PARTICLE = 10000 +N_INACTIVE = 10 +N_ACTIVE = 20 + +simulation = mcdc.Simulation("One-group finite-slab eigenvalue") + +# ====================================================================================== +# Set model +# ====================================================================================== +# Vacuum boundaries produce a leakage-dependent finite-slab eigenfunction. +# The prompt yield is selected to give k = 1.2 for this geometry. + +material = mcdc.Material.multigroup( + capture=np.array([0.25]), + scatter=np.array([[0.50]]), + fission=np.array([0.25]), + nu_p=np.array([2.519421]), +) + +lower = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") +upper = mcdc.Surface.PlaneZ(z=LENGTH, boundary_condition="vacuum") +cell = mcdc.Cell(region=+lower & -upper, fill=material) +simulation.set_model([cell]) + +# ====================================================================================== +# Set initial source +# ====================================================================================== +# A uniform isotropic source initializes iteration toward the finite-slab mode. + +source = mcdc.Source(z=[0.0, LENGTH], isotropic=True, energy=0) +simulation.set_sources([source]) + +# ====================================================================================== +# Set tallies, settings, techniques, and run MC/DC +# ====================================================================================== + +mesh = mcdc.MeshStructured(z=np.linspace(0.0, LENGTH, 101)) +tally = mcdc.Tally(mesh=mesh, scores=["flux"]) +simulation.set_tallies([tally]) + +# Particle and inactive-cycle counts stay fixed while the study overrides N_active. +simulation.settings.N_particle = N_PARTICLE +simulation.settings.set_eigenmode(N_inactive=N_INACTIVE, N_active=N_ACTIVE) +simulation.settings.census_bank_buffer_ratio = 2.0 +simulation.settings.source_bank_buffer_ratio = 2.0 + +simulation.technique.population_control() + +simulation.run() diff --git a/verification/analytical/neutron/k_eigenvalue/cases/one_group_slab/plot.py b/verification/analytical/neutron/k_eigenvalue/cases/one_group_slab/plot.py new file mode 100644 index 0000000..74da344 --- /dev/null +++ b/verification/analytical/neutron/k_eigenvalue/cases/one_group_slab/plot.py @@ -0,0 +1,85 @@ +"""Plot the spatial eigenfunction and cycle-by-cycle multiplication factor.""" + +import sys + +import h5py +import matplotlib.pyplot as plt +import numpy as np + +from reference import reference + +output_file = sys.argv[1] + +with h5py.File(output_file, "r") as output: + z = output["tallies/tracklength_tally_0/grid/z"][:] + flux = output["tallies/tracklength_tally_0/flux/mean"][:] + flux_sdev = output["tallies/tracklength_tally_0/flux/sdev"][:] + k_cycle = output["k_cycle"][:] + k_mean = output["k_mean"][()] + k_sdev = output["k_sdev"][()] + N_inactive = int(output["settings/N_inactive"][()]) + +dz = np.diff(z) +z_midpoint = 0.5 * (z[:-1] + z[1:]) +normalization = np.sum(flux) +flux = flux / dz / normalization +flux_sdev = flux_sdev / dz / normalization +k_reference, flux_reference = reference(z) + +plt.plot(z_midpoint, flux, "-b", label="MC/DC") +plt.fill_between( + z_midpoint, + flux - flux_sdev, + flux + flux_sdev, + alpha=0.2, + color="b", +) +plt.plot(z_midpoint, flux_reference, "--r", label="Reference") +plt.xlabel(r"$z$, cm") +plt.ylabel("Normalized scalar flux") +plt.grid() +plt.legend() +plt.savefig("flux.png", dpi=200, bbox_inches="tight") +plt.close() + +cycles = np.arange(1, len(k_cycle) + 1) +active_cycles = cycles[N_inactive:] +active_k = k_cycle[N_inactive:] +k_min = np.min(active_k) +k_max = np.max(active_k) +k_padding = 0.05 * (k_max - k_min) +if k_padding == 0.0: + k_padding = max(0.01 * abs(k_min), 1.0e-6) + +plt.plot(cycles, k_cycle, "-b", label="MC/DC cycle") +plt.axhline(k_reference, color="k", linestyle="--", label="Reference") +plt.plot( + active_cycles, + np.full(active_cycles.size, k_mean), + ":r", + label="MC/DC active mean", +) +plt.fill_between( + active_cycles, + k_mean - k_sdev, + k_mean + k_sdev, + alpha=0.2, + color="r", +) + +if N_inactive > 0: + plt.axvline( + N_inactive + 0.5, + color="0.5", + linestyle=":", + label="active cycles begin", + ) + +plt.xlabel("Cycle") +plt.ylabel(r"$k$") +# Focus the vertical scale on fluctuations after source convergence. +plt.ylim(k_min - k_padding, k_max + k_padding) +plt.grid() +plt.legend() +plt.savefig("k_history.png", dpi=200, bbox_inches="tight") +plt.close() diff --git a/verification/analytical/neutron/k_eigenvalue/cases/one_group_slab/process.py b/verification/analytical/neutron/k_eigenvalue/cases/one_group_slab/process.py new file mode 100644 index 0000000..857701d --- /dev/null +++ b/verification/analytical/neutron/k_eigenvalue/cases/one_group_slab/process.py @@ -0,0 +1,58 @@ +"""Measure active-cycle convergence against the semi-analytical eigenpair.""" + +import sys +from pathlib import Path + +import h5py +import numpy as np + +from reference import reference + +SUITE_DIR = Path(__file__).resolve().parents[2] +if str(SUITE_DIR) not in sys.path: + sys.path.insert(0, str(SUITE_DIR)) + +import util + +N_active_min = int(sys.argv[1]) +N_active_max = int(sys.argv[2]) +N_task = int(sys.argv[3]) +active_cycle_counts = np.rint( + np.geomspace( + N_active_min, + N_active_max, + N_task, + ) +).astype(int) + +k_error = np.zeros(N_task) +k_estimate = np.zeros(N_task) +k_uncertainty = np.zeros(N_task) +flux_error = np.zeros(N_task) + +# Calculate the finite-slab reference once for the shared tally mesh. +with h5py.File(f"output_{active_cycle_counts[0]}.h5", "r") as output: + z_reference = output["tallies/tracklength_tally_0/grid/z"][:] +k_reference, flux_reference = reference(z_reference) + +for index, N_active in enumerate(active_cycle_counts): + with h5py.File(f"output_{N_active}.h5", "r") as output: + k_estimate[index] = output["k_mean"][()] + k_uncertainty[index] = output["k_sdev"][()] + z = output["tallies/tracklength_tally_0/grid/z"][:] + dz = np.diff(z) + flux = output["tallies/tracklength_tally_0/flux/mean"][:] / dz + + # Eigenvectors have arbitrary amplitude, so compare unit-integral shapes. + flux /= np.sum(flux * dz) + k_error[index] = abs(k_estimate[index] - k_reference) / k_reference + flux_error[index] = util.relative_error(flux, flux_reference) + +util.plot_convergence("k-effective", active_cycle_counts, k_error) +util.plot_k_estimates( + active_cycle_counts, + k_estimate, + k_uncertainty, + k_reference, +) +util.plot_convergence("flux", active_cycle_counts, flux_error) diff --git a/verification/analytical/neutron/k_eigenvalue/cases/one_group_slab/reference.py b/verification/analytical/neutron/k_eigenvalue/cases/one_group_slab/reference.py new file mode 100644 index 0000000..6dbcec5 --- /dev/null +++ b/verification/analytical/neutron/k_eigenvalue/cases/one_group_slab/reference.py @@ -0,0 +1,77 @@ +"""Semi-analytical eigenpair for the one-group finite-slab benchmark.""" + +import numpy as np +from scipy.linalg import matmul_toeplitz +from scipy.special import expn + +LENGTH = 10.0 +CAPTURE = 0.25 +SCATTER = 0.50 +FISSION = 0.25 +NU_PROMPT = 2.519421 +REFINEMENT = 40 +TOTAL = CAPTURE + SCATTER + FISSION + + +def _collision_kernel_first_column(N): + """Return the cell-integrated E1 kernel for a uniform fine mesh.""" + width = LENGTH / N + column = np.empty(N) + + # Integrate the logarithmic diagonal singularity analytically. + half_optical_width = 0.5 * TOTAL * width + column[0] = 2.0 / TOTAL * (1.0 - expn(2, half_optical_width)) + + offset = np.arange(1, N) + lower_distance = TOTAL * (offset - 0.5) * width + upper_distance = TOTAL * (offset + 0.5) * width + column[1:] = (expn(2, lower_distance) - expn(2, upper_distance)) / TOTAL + return column + + +def _dominant_eigenpair(column): + """Solve the positive symmetric Toeplitz eigenproblem by power iteration.""" + flux = np.ones(len(column)) + flux /= np.linalg.norm(flux) + eigenvalue_previous = 0.0 + + for _ in range(500): + product = matmul_toeplitz( + (column, column), + flux, + check_finite=False, + ) + eigenvalue = float(flux @ product) + flux = product / np.linalg.norm(product) + + if abs(eigenvalue - eigenvalue_previous) <= 1.0e-13 * eigenvalue: + break + eigenvalue_previous = eigenvalue + else: + raise RuntimeError("Finite-slab reference power iteration did not converge.") + + return eigenvalue, flux + + +def reference(z): + """Return the finite-slab multiplication factor and cell-averaged flux.""" + z = np.asarray(z) + dz = np.diff(z) + + if not np.allclose(dz, dz[0]): + raise ValueError("Finite-slab reference requires a uniform tally mesh.") + if not np.isclose(z[0], 0.0) or not np.isclose(z[-1], LENGTH): + raise ValueError("Finite-slab reference mesh must span the full slab.") + + N_cell = len(dz) + N_fine = REFINEMENT * N_cell + kernel_column = _collision_kernel_first_column(N_fine) + kernel_eigenvalue, fine_flux = _dominant_eigenpair(kernel_column) + + critical_collision_source = 2.0 / kernel_eigenvalue + k_effective = NU_PROMPT * FISSION / (critical_collision_source - SCATTER) + + fine_width = LENGTH / N_fine + fine_flux /= np.sum(fine_flux * fine_width) + flux = np.mean(fine_flux.reshape(N_cell, REFINEMENT), axis=1) + return k_effective, flux diff --git a/verification/analytical/neutron/k_eigenvalue/cleanup.py b/verification/analytical/neutron/k_eigenvalue/cleanup.py index 963c1f8..981ce7f 100644 --- a/verification/analytical/neutron/k_eigenvalue/cleanup.py +++ b/verification/analytical/neutron/k_eigenvalue/cleanup.py @@ -1,13 +1,23 @@ """Remove generated case outputs and processed figures from this suite.""" +import shutil from pathlib import Path suite_dir = Path(__file__).resolve().parent -for output in (suite_dir / "cases").glob("*/output*.h5"): - output.unlink() +cases_dir = suite_dir / "cases" +# Remove simulation outputs and any figures left by interrupted processing. +for pattern in ("*/output*.h5", "*/*.png", "*/*.gif"): + for generated_file in cases_dir.glob(pattern): + generated_file.unlink() + +# Remove the complete processed-results hierarchy. results_dir = suite_dir / "results" if results_dir.is_dir(): - for figure in results_dir.glob("*.png"): - figure.unlink() + shutil.rmtree(results_dir) + +# Remove generated Maestro workflow directories. +for maestro_run in suite_dir.glob("maestro_run_*"): + if maestro_run.is_dir(): + shutil.rmtree(maestro_run) diff --git a/verification/analytical/neutron/k_eigenvalue/launch.py b/verification/analytical/neutron/k_eigenvalue/launch.py index b1fc3d8..2987d38 100644 --- a/verification/analytical/neutron/k_eigenvalue/launch.py +++ b/verification/analytical/neutron/k_eigenvalue/launch.py @@ -6,6 +6,7 @@ import sys from pathlib import Path +import numpy as np import yaml # ====================================================================================== @@ -24,6 +25,7 @@ # ====================================================================================== from configs.platform_config import PLATFORMS +from configs.util import case_outputs_complete, get_case_walltime # User overrides are optional for local runs. try: @@ -40,17 +42,29 @@ description="Launch the MC/DC VVP analytical neutron k-eigenvalue suite." ) parser.add_argument("--platform", default="local", choices=["local"] + list(PLATFORMS)) -parser.add_argument("--mpi", action="store_true") -parser.add_argument("--walltime", type=int, default=None) -parser.add_argument("--rewrite", action="store_true") +parser.add_argument( + "--N_node", + type=int, + default=1, + help="Set the number of compute nodes.", +) +parser.add_argument( + "--walltime", + type=float, + default=None, + help="Set the base walltime in hours; each case scales it by walltime_factor.", +) args = parser.parse_args() -if args.mpi and args.platform == "local": - parser.error("--mpi requires a cluster platform. Specify --platform .") +if args.N_node < 1: + parser.error("--N_node must be at least one.") + +if args.platform == "local" and args.N_node != 1: + parser.error("Local execution supports only --N_node 1.") # ====================================================================================== -# Paths and platform settings +# Paths # ====================================================================================== suite_dir = Path(__file__).resolve().parent @@ -58,10 +72,17 @@ run_case = suite_dir / "run_case.py" study_file = suite_dir / "study.yaml" + +# ====================================================================================== +# Platform settings +# ====================================================================================== + local = args.platform == "local" user_platform_config = USER_CONFIG.get(args.platform, {}) mcdc_python = user_platform_config.get("mcdc_python") + +# Use the active interpreter unless this platform specifies another MC/DC environment. if mcdc_python is None: mcdc_python = sys.executable else: @@ -72,13 +93,11 @@ scheduler = platform["scheduler"] cpu_cores = platform["cpu_cores_per_node"] - # Never request more walltime than the platform permits. - walltime_hours = ( - platform["max_walltime_hours"] - if args.walltime is None - else min(args.walltime, platform["max_walltime_hours"]) - ) - walltime = platform["walltime_format"].format(hours=walltime_hours) + if args.N_node > platform["max_nodes"]: + parser.error( + f"--N_node exceeds the {platform['max_nodes']}-node limit for " + f"{args.platform}." + ) account = user_platform_config.get("account") queue = user_platform_config.get("queue") @@ -92,36 +111,56 @@ # ====================================================================================== -# Build Maestro study +# Load tasks # ====================================================================================== with task_file.open("r") as f: tasks = yaml.safe_load(f) + +# ====================================================================================== +# Build Maestro study +# ====================================================================================== + +# Convert each configured case into one independent Maestro step. steps = [] +case_walltimes = {} +skipped_cases = [] + +for case_name, task in tasks.items(): + case_dir = suite_dir / "cases" / case_name + counts = np.rint( + np.geomspace( + task["N_active_min"], + task["N_active_max"], + task["N_task"], + ) + ).astype(int) -for case_name in tasks: + # Do not allocate a Maestro step when every sampling level is already present. + if case_outputs_complete(case_dir, counts): + skipped_cases.append(case_name) + print(f"Skip complete case: {case_name}") + continue + + # Normalize case names into stable Maestro step identifiers. safe_case_name = case_name.replace("-", "_") command = f"{mcdc_python} {run_case} --name {case_name}" - # Maestro replaces LAUNCHER with the scheduler-specific MPI launch command. - if args.mpi: - command += ' --mpi "$(LAUNCHER)"' - - if args.rewrite: - command += " --rewrite" + # Maestro replaces LAUNCHER with the scheduler-specific process launcher. + if not local: + command += ' --launcher "$(LAUNCHER)"' run = {"cmd": command} # Scheduled studies require explicit resources; local studies run directly. if not local: - run["nodes"] = 1 + run["nodes"] = args.N_node + walltime = get_case_walltime(task, platform, args.walltime) run["walltime"] = walltime - if args.mpi: - run["procs"] = cpu_cores - run["exclusive"] = True - else: - run["procs"] = 1 + case_walltimes[case_name] = walltime + run["procs"] = args.N_node * cpu_cores + run["exclusive"] = True steps.append( { @@ -131,6 +170,11 @@ } ) +if not steps: + print("All configured cases are complete; nothing to launch.") + raise SystemExit(0) + +# Assemble the complete Maestro study from the generated case steps. study = { "description": { "name": "maestro_run", @@ -141,6 +185,7 @@ } if not local: + # Attach batch settings only when Maestro submits to a scheduler. batch = { "type": scheduler, "host": platform["host"], @@ -152,6 +197,11 @@ batch["reservation"] = reservation study["batch"] = batch + +# ====================================================================================== +# Write Maestro study +# ====================================================================================== + with study_file.open("w") as f: yaml.dump(study, f, sort_keys=False) @@ -165,11 +215,14 @@ maestro_python = user_platform_config.get("maestro_python") env = os.environ.copy() + +# Use the configured Maestro environment when it differs from the active one. if maestro_python is None: maestro_command = ["maestro", "run", "study.yaml"] else: maestro_python = Path(maestro_python).expanduser() maestro_bin = maestro_python.parent + # Keep executables spawned by Maestro in the same configured environment. env["PATH"] = f"{maestro_bin}:{env['PATH']}" maestro_command = [ str(maestro_python), @@ -186,6 +239,7 @@ # Store launch metadata # ====================================================================================== +# Maestro creates timestamped run directories, so capture the newly generated launch. maestro_runs = sorted( suite_dir.glob("maestro_run_*"), key=lambda path: path.stat().st_mtime, @@ -196,11 +250,15 @@ latest_run = maestro_runs[-1] launch_config = { "platform": args.platform, - "mpi": args.mpi, + "scheduler": "local" if local else scheduler, + "N_node": args.N_node, + "N_process": 1 if local else args.N_node * cpu_cores, "walltime": args.walltime, - "rewrite": args.rewrite, + "case_walltimes": case_walltimes, + "mcdc_python": mcdc_python, } +# Snapshot the effective launch and task configuration with the generated run. with (latest_run / "launch_config.yaml").open("w") as f: yaml.dump(launch_config, f, sort_keys=False) @@ -213,8 +271,7 @@ # ====================================================================================== print(f"Platform : {args.platform}") -print(f"MPI : {args.mpi}") -print(f"Rewrite : {args.rewrite}") +print(f"Nodes : {args.N_node}") print(f"Python : {mcdc_python}") print(f"Study : {study_file}") @@ -223,8 +280,10 @@ print(f"Account : {account}") print(f"Queue : {queue}") print(f"Reserv. : {reservation}") - print(f"Walltime : {walltime}") - print("Nodes : 1") - print(f"Procs : {cpu_cores if args.mpi else 1}") + print("Walltimes:") + for case_name, walltime in case_walltimes.items(): + print(f" {case_name}: {walltime}") + print(f"Procs : {args.N_node * cpu_cores}") print(f"Cases : {len(steps)}") +print(f"Skipped : {len(skipped_cases)}") diff --git a/verification/analytical/neutron/k_eigenvalue/process.py b/verification/analytical/neutron/k_eigenvalue/process.py index 86d1b59..73cdf3c 100644 --- a/verification/analytical/neutron/k_eigenvalue/process.py +++ b/verification/analytical/neutron/k_eigenvalue/process.py @@ -1,6 +1,7 @@ """Process the analytical neutron k-eigenvalue verification suite.""" import argparse +import shutil import subprocess import sys from pathlib import Path @@ -23,6 +24,25 @@ args = parser.parse_args() +# ====================================================================================== +# Helper functions +# ====================================================================================== + + +def clear_case_figures(case_dir): + """Remove figures left by an earlier processing run.""" + for pattern in ("*.png", "*.gif"): + for figure in case_dir.glob(pattern): + figure.unlink() + + +def collect_case_figures(case_dir, destination, case_name): + """Move generated case figures into a named suite results directory.""" + for pattern in ("*.png", "*.gif"): + for figure in case_dir.glob(pattern): + figure.replace(destination / f"{case_name}_{figure.name}") + + # ====================================================================================== # Resolve the launch and load its configuration # ====================================================================================== @@ -55,14 +75,26 @@ with task_file.open("r") as f: tasks = yaml.safe_load(f) +results_dir = suite_dir / "results" +convergence_dir = results_dir / "convergence" +comparison_dir = results_dir / "comparison" + +# Start with an empty suite results hierarchy on every processing run. +if results_dir.is_dir(): + shutil.rmtree(results_dir) + +convergence_dir.mkdir(parents=True, exist_ok=True) +comparison_dir.mkdir(parents=True, exist_ok=True) + +# Keep the effective launch and task definitions beside the processed figures. +shutil.copy2(launch_config_file, results_dir / "launch_config.yaml") +shutil.copy2(task_file, results_dir / "task.yaml") + # ====================================================================================== # Process cases and collect their figures # ====================================================================================== -results_dir = suite_dir / "results" -results_dir.mkdir(exist_ok=True) - for case_name, task in tasks.items(): case_dir = suite_dir / "cases" / case_name process_script = case_dir / "process.py" @@ -72,6 +104,9 @@ continue print(f"Processing {case_name}") + clear_case_figures(case_dir) + + # Generate and collect convergence figures over the active-cycle study. subprocess.run( [ sys.executable, @@ -83,10 +118,17 @@ cwd=case_dir, check=True, ) + collect_case_figures(case_dir, convergence_dir, case_name) - for figure in case_dir.glob("*.png"): - destination = results_dir / f"{case_name}_{figure.name}" - figure.replace(destination) + # Inspect the result with the largest number of active cycles. + plot_script = case_dir / "plot.py" + output = case_dir / f"output_{task['N_active_max']}.h5" + subprocess.run( + [sys.executable, str(plot_script), str(output)], + cwd=case_dir, + check=True, + ) + collect_case_figures(case_dir, comparison_dir, case_name) # ====================================================================================== @@ -96,7 +138,7 @@ print() print(f"Maestro run: {maestro_run}") print(f"Platform : {launch_config['platform']}") -print(f"MPI : {launch_config['mpi']}") -print(f"Rewrite : {launch_config['rewrite']}") +print(f"Nodes : {launch_config['N_node']}") +print(f"Processes : {launch_config['N_process']}") print(f"Cases : {len(tasks)}") print("Processing complete.") diff --git a/verification/analytical/neutron/k_eigenvalue/run_case.py b/verification/analytical/neutron/k_eigenvalue/run_case.py index 17f5d89..519e821 100644 --- a/verification/analytical/neutron/k_eigenvalue/run_case.py +++ b/verification/analytical/neutron/k_eigenvalue/run_case.py @@ -18,14 +18,9 @@ parser.add_argument("--name", required=True, help="Verification case name.") parser.add_argument("--task-file", default="task.yaml") parser.add_argument( - "--mpi", + "--launcher", default="", - help="MPI launch command supplied by Maestro, e.g. 'srun -n 112'.", -) -parser.add_argument( - "--rewrite", - action="store_true", - help="Rewrite existing output files instead of skipping them.", + help="Process launch command supplied by Maestro.", ) args = parser.parse_args() @@ -69,15 +64,11 @@ output_file = case_dir / f"{output}.h5" if output_file.is_file(): - if args.rewrite: - print(f"Rewrite (remove existing output): {args.name}, N_active={N_active}") - output_file.unlink() - else: - print(f"Skip (output exists): {args.name}, N_active={N_active}") - continue + print(f"Skip existing output: {args.name}, N_active={N_active}") + continue command = ( - f"{args.mpi} {sys.executable} input.py " + f"{args.launcher} {sys.executable} input.py " "--mode=numba " f"--N_active={N_active} " f"--output={output} " diff --git a/verification/analytical/neutron/k_eigenvalue/task.yaml b/verification/analytical/neutron/k_eigenvalue/task.yaml index 8085d85..e49618d 100644 --- a/verification/analytical/neutron/k_eigenvalue/task.yaml +++ b/verification/analytical/neutron/k_eigenvalue/task.yaml @@ -15,12 +15,26 @@ # # ============================================================================= +one_group_slab: + N_active_min: 20 + N_active_max: 2000 + N_task: 5 + walltime_factor: 0.438 + +kornreich: + N_active_min: 20 + N_active_max: 2000 + N_task: 5 + walltime_factor: 0.470 + inf_shem361_subcritical: N_active_min: 20 N_active_max: 2000 N_task: 5 + walltime_factor: 0.940 inf_shem361_supercritical: N_active_min: 20 N_active_max: 2000 N_task: 5 + walltime_factor: 0.971 diff --git a/verification/analytical/neutron/k_eigenvalue/util.py b/verification/analytical/neutron/k_eigenvalue/util.py index f0e18af..cea0bd6 100644 --- a/verification/analytical/neutron/k_eigenvalue/util.py +++ b/verification/analytical/neutron/k_eigenvalue/util.py @@ -31,3 +31,26 @@ def plot_convergence(name, active_cycles, error): plt.legend() plt.savefig(f"{name}.png") plt.clf() + + +def plot_k_estimates(active_cycles, estimates, uncertainties, reference): + """Plot multiplication-factor estimates with their reported uncertainties.""" + fig, ax = plt.subplots() + ax.errorbar( + active_cycles, + estimates, + yerr=uncertainties, + fmt="bo", + fillstyle="none", + capsize=3, + label="MC/DC", + ) + ax.axhline(reference, color="r", linestyle="--", label="Reference") + ax.set_xscale("log") + ax.set_xlabel(r"Active cycles, $N_\mathrm{active}$") + ax.set_ylabel(r"$k$") + ax.set_title("k-effective estimates") + ax.grid() + ax.legend() + fig.savefig("k-effective_errorbar.png", bbox_inches="tight") + plt.close(fig) diff --git a/verification/benchmark/neutron/continuous_energy/README.md b/verification/benchmark/neutron/continuous_energy/README.md deleted file mode 100644 index c3203eb..0000000 --- a/verification/benchmark/neutron/continuous_energy/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Continuous-energy verification - -The continuous-energy benchmarks require a nuclear-data library generated with the same MC/DC version used to run the cases. - -Set `MCDC_LIB` to the generated HDF5 library before launching a benchmark: - -```bash -export MCDC_LIB=/path/to/mcdc/library -``` - -Generate or refresh the library with MC/DC's `tools/data_library_generator/neutron/generate.py` utility. -The source ACE data location is configured with `MCDC_ACELIB`; see the generator's README in the MC/DC repository for installation and invocation details. - -The benchmark inputs perform a lightweight schema check before compiling so an outdated library produces an actionable error rather than a low-level HDF5 exception. diff --git a/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-helium/compare-spectrum.py b/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-helium/compare-spectrum.py deleted file mode 100644 index 1c94e12..0000000 --- a/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-helium/compare-spectrum.py +++ /dev/null @@ -1,50 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import openmc -import os -import shutil - -# Get results -with openmc.StatePoint("openmc_/statepoint.30.h5") as sp: - tally = sp.get_tally(scores=["flux"]) - flux_openmc = tally.mean.reshape((200, 500)) - sdev_openmc = tally.std_dev.reshape((200, 500)) - -with h5py.File("mcdc/output.h5", "r") as f: - flux_mcdc = f["tallies/tracklength_tally_0/flux/mean"][()].transpose() - sdev_mcdc = f["tallies/tracklength_tally_0/flux/sdev"][()].transpose() - - # Grids - t = f["tallies/tracklength_tally_0/grid/time"][()] - dt = t[1:] - t[:-1] - t_mid = 0.5 * (t[1:] + t[:-1]) - - E = f["tallies/tracklength_tally_0/grid/energy"][()] - E_mid = 0.5 * (E[1:] + E[:-1]) - dE = E[1:] - E[:-1] - -for i in range(200): - y = E_mid * flux_openmc[i, :] / dE / dt[i] - sd = E_mid * sdev_openmc[i, :] / dE / dt[i] - plt.plot(E_mid, y, "r-", label="OpenMC") - plt.fill_between(E_mid, y - sd, y + sd, alpha=0.2, color="r") - - y = E_mid * flux_mcdc[i, :] / dE / dt[i] - sd = E_mid * sdev_mcdc[i, :] / dE / dt[i] - plt.plot(E_mid, y, "b--", label="MC/DC") - plt.fill_between(E_mid, y - sd, y + sd, alpha=0.2, color="b") - - plt.text(0.02, 0.9, f"$t$ $=$ {t_mid[i]:.3g} s", transform=plt.gca().transAxes) - - plt.xlabel("Energy [eV]") - plt.ylabel(r"Spectrum, $E\phi(E, t)$") - plt.xscale("log") - plt.grid() - plt.title("Pulsed Pincell: UO2 and Helium") - - plt.savefig( - f"figures/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0 - ) - plt.close() diff --git a/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-helium/mcdc/input.py b/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-helium/mcdc/input.py deleted file mode 100644 index 876b325..0000000 --- a/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-helium/mcdc/input.py +++ /dev/null @@ -1,47 +0,0 @@ -import mcdc -import numpy as np - -simulation = mcdc.Simulation("Pulsed UO2-helium pincell") - -# Material -fuel = mcdc.Material( - nuclide_composition={ - "U235": 0.0001654509603995036, - "U238": 0.022801089905717036, - "O16": 0.04593308173223308, - } -) -moderator = mcdc.Material(nuclide_composition={"He4": 0.00024042816805671608}) - -# Geometry -cylinder = mcdc.Surface.CylinderZ(radius=0.45720) -pitch = 1.25984 -x0 = mcdc.Surface.PlaneX(x=-pitch / 2, boundary_condition="reflective") -x1 = mcdc.Surface.PlaneX(x=pitch / 2, boundary_condition="reflective") -y0 = mcdc.Surface.PlaneY(y=-pitch / 2, boundary_condition="reflective") -y1 = mcdc.Surface.PlaneY(y=pitch / 2, boundary_condition="reflective") -# -fuel_cell = mcdc.Cell(-cylinder, fill=fuel) -moderator_cell = mcdc.Cell(+x0 & -x1 & +y0 & -y1 & +cylinder, fill=moderator) -simulation.set_model([fuel_cell, moderator_cell]) - -# Source -source = mcdc.Source(position=[0.0, 0.0, 0.0], isotropic=True, time=0.0, energy=14.1e6) -simulation.set_sources([source]) - -# Setting -simulation.settings.N_particle = 10000 -simulation.settings.N_batch = 30 -simulation.settings.time_boundary = 1.0 -simulation.settings.active_bank_buffer = 1000 - -# Tally -t_grid = np.insert(np.logspace(-9, -4, 200), 0, 0.0) -e_min, e_max = 1e-5, 20.0e6 -groups = 500 -energies = np.logspace(np.log10(e_min), np.log10(e_max), groups + 1) - -tally = mcdc.Tally(scores=["flux"], time=t_grid, energy=energies) -simulation.set_tallies([tally]) - -simulation.run() diff --git a/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-helium/openmc_/build-xml.py b/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-helium/openmc_/build-xml.py deleted file mode 100644 index 7eda2ae..0000000 --- a/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-helium/openmc_/build-xml.py +++ /dev/null @@ -1,51 +0,0 @@ -import openmc -import numpy as np - -# Materials -fuel = openmc.Material() -fuel.add_nuclide("U235", 0.0001654509603995036) -fuel.add_nuclide("U238", 0.022801089905717036) -fuel.add_nuclide("O16", 0.04593308173223308) -# -moderator = openmc.Material() -moderator.add_nuclide("He4", 0.00024042816805671608) -# -materials = openmc.Materials([fuel, moderator]) -materials.export_to_xml() - -# Geometry -cylinder = openmc.ZCylinder(r=0.45720, name="Fuel OR") -pitch = 1.25984 -box = openmc.model.RectangularPrism(pitch, pitch, boundary_type="reflective") -# -fuel_cell = openmc.Cell(fill=fuel, region=-cylinder) -moderator_cell = openmc.Cell(fill=moderator, region=+cylinder & -box) -# -geometry = openmc.Geometry([fuel_cell, moderator_cell]) -geometry.export_to_xml() - -# Setting -settings = openmc.Settings() -settings.run_mode = "fixed source" -settings.batches = 30 -settings.particles = 10000 -settings.cutoff = {"time_neutron": 1.0} -space = openmc.stats.Point() # At the origin (0, 0, 0) -energy = openmc.stats.delta_function(14.1e6) # At 14.1 MeV -settings.source = openmc.IndependentSource(space=space, energy=energy) -settings.export_to_xml() - -t_grid = np.insert(np.logspace(-9, -4, 200), 0, 0.0) -time_filter = openmc.TimeFilter(t_grid) - -e_min, e_max = 1e-5, 20.0e6 -groups = 500 -energies = np.logspace(np.log10(e_min), np.log10(e_max), groups + 1) -energy_filter = openmc.EnergyFilter(energies) - -tally = openmc.Tally() -tally.filters = [time_filter, energy_filter] -tally.scores = ["flux"] - -tallies = openmc.Tallies([tally]) -tallies.export_to_xml() diff --git a/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-water/compare-spectrum.py b/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-water/compare-spectrum.py deleted file mode 100644 index 03981fd..0000000 --- a/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-water/compare-spectrum.py +++ /dev/null @@ -1,50 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import openmc -import os -import shutil - -# Get results -with openmc.StatePoint("openmc_/statepoint.30.h5") as sp: - tally = sp.get_tally(scores=["flux"]) - flux_openmc = tally.mean.reshape((200, 500)) - sdev_openmc = tally.std_dev.reshape((200, 500)) - -with h5py.File("mcdc/output.h5", "r") as f: - flux_mcdc = f["tallies/tracklength_tally_0/flux/mean"][()].transpose() - sdev_mcdc = f["tallies/tracklength_tally_0/flux/sdev"][()].transpose() - - # Grids - t = f["tallies/tracklength_tally_0/grid/time"][()] - dt = t[1:] - t[:-1] - t_mid = 0.5 * (t[1:] + t[:-1]) - - E = f["tallies/tracklength_tally_0/grid/energy"][()] - E_mid = 0.5 * (E[1:] + E[:-1]) - dE = E[1:] - E[:-1] - -for i in range(200): - y = E_mid * flux_openmc[i, :] / dE / dt[i] - sd = E_mid * sdev_openmc[i, :] / dE / dt[i] - plt.plot(E_mid, y, "r-", label="OpenMC") - plt.fill_between(E_mid, y - sd, y + sd, alpha=0.2, color="r") - - y = E_mid * flux_mcdc[i, :] / dE / dt[i] - sd = E_mid * sdev_mcdc[i, :] / dE / dt[i] - plt.plot(E_mid, y, "b--", label="MC/DC") - plt.fill_between(E_mid, y - sd, y + sd, alpha=0.2, color="b") - - plt.text(0.02, 0.9, f"$t$ $=$ {t_mid[i]:.3g} s", transform=plt.gca().transAxes) - - plt.xlabel("Energy [eV]") - plt.ylabel(r"Spectrum, $E\phi(E, t)$") - plt.xscale("log") - plt.grid() - plt.title("Pulsed Pincell: UO2 and Borated Water") - - plt.savefig( - f"figures/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0 - ) - plt.close() diff --git a/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-water/mcdc/input.py b/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-water/mcdc/input.py deleted file mode 100644 index 9cbcc77..0000000 --- a/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-water/mcdc/input.py +++ /dev/null @@ -1,53 +0,0 @@ -import mcdc -import numpy as np - -simulation = mcdc.Simulation("Pulsed UO2-water pincell") - -# Material -fuel = mcdc.Material( - nuclide_composition={ - "U235": 0.0001654509603995036, - "U238": 0.022801089905717036, - "O16": 0.04593308173223308, - } -) -moderator = mcdc.Material( - nuclide_composition={ - "H1": 0.05129627050184732, - "O16": 0.024622209840886707, - "B10": 4.103701640147785e-05, - } -) - -# Geometry -cylinder = mcdc.Surface.CylinderZ(radius=0.45720) -pitch = 1.25984 -x0 = mcdc.Surface.PlaneX(x=-pitch / 2, boundary_condition="reflective") -x1 = mcdc.Surface.PlaneX(x=pitch / 2, boundary_condition="reflective") -y0 = mcdc.Surface.PlaneY(y=-pitch / 2, boundary_condition="reflective") -y1 = mcdc.Surface.PlaneY(y=pitch / 2, boundary_condition="reflective") -# -fuel_cell = mcdc.Cell(-cylinder, fill=fuel) -moderator_cell = mcdc.Cell(+x0 & -x1 & +y0 & -y1 & +cylinder, fill=moderator) -simulation.set_model([fuel_cell, moderator_cell]) - -# Source -source = mcdc.Source(position=[0.0, 0.0, 0.0], isotropic=True, time=0.0, energy=14.1e6) -simulation.set_sources([source]) - -# Setting -simulation.settings.N_particle = 10000 -simulation.settings.N_batch = 30 -simulation.settings.time_boundary = 1.0 -simulation.settings.active_bank_buffer = 1000 - -# Tally -t_grid = np.insert(np.logspace(-9, -4, 200), 0, 0.0) -e_min, e_max = 1e-5, 20.0e6 -groups = 500 -energies = np.logspace(np.log10(e_min), np.log10(e_max), groups + 1) - -tally = mcdc.Tally(scores=["flux"], time=t_grid, energy=energies) -simulation.set_tallies([tally]) - -simulation.run() diff --git a/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-water/openmc_/build-xml.py b/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-water/openmc_/build-xml.py deleted file mode 100644 index 6cf2628..0000000 --- a/verification/benchmark/neutron/continuous_energy/pulsed_pincells/uo2-water/openmc_/build-xml.py +++ /dev/null @@ -1,53 +0,0 @@ -import openmc -import numpy as np - -# Materials -fuel = openmc.Material() -fuel.add_nuclide("U235", 0.0001654509603995036) -fuel.add_nuclide("U238", 0.022801089905717036) -fuel.add_nuclide("O16", 0.04593308173223308) -# -moderator = openmc.Material() -moderator.add_nuclide("H1", 0.05129627050184732) -moderator.add_nuclide("O16", 0.024622209840886707) -moderator.add_nuclide("B10", 4.103701640147785e-05) -# -materials = openmc.Materials([fuel, moderator]) -materials.export_to_xml() - -# Geometry -cylinder = openmc.ZCylinder(r=0.45720, name="Fuel OR") -pitch = 1.25984 -box = openmc.model.RectangularPrism(pitch, pitch, boundary_type="reflective") -# -fuel_cell = openmc.Cell(fill=fuel, region=-cylinder) -moderator_cell = openmc.Cell(fill=moderator, region=+cylinder & -box) -# -geometry = openmc.Geometry([fuel_cell, moderator_cell]) -geometry.export_to_xml() - -# Setting -settings = openmc.Settings() -settings.run_mode = "fixed source" -settings.batches = 30 -settings.particles = 10000 -settings.cutoff = {"time_neutron": 1.0} -space = openmc.stats.Point() # At the origin (0, 0, 0) -energy = openmc.stats.delta_function(14.1e6) # At 14.1 MeV -settings.source = openmc.IndependentSource(space=space, energy=energy) -settings.export_to_xml() - -t_grid = np.insert(np.logspace(-9, -4, 200), 0, 0.0) -time_filter = openmc.TimeFilter(t_grid) - -e_min, e_max = 1e-5, 20.0e6 -groups = 500 -energies = np.logspace(np.log10(e_min), np.log10(e_max), groups + 1) -energy_filter = openmc.EnergyFilter(energies) - -tally = openmc.Tally() -tally.filters = [time_filter, energy_filter] -tally.scores = ["flux"] - -tallies = openmc.Tallies([tally]) -tallies.export_to_xml() diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-fission-sdev.py b/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-fission-sdev.py deleted file mode 100644 index fa0a82b..0000000 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-fission-sdev.py +++ /dev/null @@ -1,97 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import os -import shutil - -# Get fission rates -with h5py.File("output_4.h5", "r") as f: - fissions = f["tallies/tracklength_tally_0/fission/mean"][()] - fissions_sd = f["tallies/tracklength_tally_0/fission/sdev"][()] - - x = f["tallies/tracklength_tally_0/grid/x"][()] - y = f["tallies/tracklength_tally_0/grid/y"][()] - z = f["tallies/tracklength_tally_0/grid/z"][()] - t = f["tallies/tracklength_tally_0/grid/time"][()] - -# The grids -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Relative stdevs -fissions_sd[fissions == 0.0] = 0.0 -non_zeros = fissions != 0.0 -fissions_sd[non_zeros] /= fissions[non_zeros] - -# Average relative stdev (in %) -fission_sd_avg = np.average(fissions_sd, axis=(1, 2, 3)) * 100.0 - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("fission-sdev"): - shutil.rmtree("fission-sdev") # Remove the existing folder -os.makedirs("fission-sdev") # Create a new folder - -# Iterate over time step and create figures -N = len(fissions) -for i in range(N): - fission_sd = fissions_sd[i] - - # Calculate fission averages - fission_x_sd = np.average(fission_sd, axis=0) - fission_y_sd = np.average(fission_sd, axis=1) - fission_z_sd = np.average(fission_sd, axis=2) - - # Plot - fig = plt.figure(figsize=(8, 5)) - gs = gridspec.GridSpec( - 2, 3, width_ratios=[0.7, 1, 1], height_ratios=[1, 1], hspace=0.5 - ) - - ax1 = fig.add_subplot(gs[0, 0]) # Top-left - ax2 = fig.add_subplot(gs[1, 0]) # Bottom-left - ax3 = fig.add_subplot(gs[:, 1]) # Entire second column - ax4 = fig.add_subplot(gs[:, 2]) # Entire third column - - # Total fission curve - ax1.plot(t_mid, fission_sd_avg, "b") - ax1.set_yscale("log") - ax1.set_ylabel("Average relative sdev (%)") - ax1.set_xlabel("Time") - ax1.set_title("Average relative sdev") - # Total fission point - ax1.plot(t_mid[i], fission_sd_avg[i], "ro", fillstyle="none") - - # XY fission - ax2.pcolormesh(XY_X, XY_Y, fission_z_sd) - ax2.set_aspect("equal") - ax2.set_xlabel(r"$x$") - ax2.set_ylabel(r"$y$") - ax2.set_title("Fission-XY") - - # XZ fission - ax3.pcolormesh(XZ_X, XZ_Z, fission_y_sd) - ax3.set_aspect("equal") - ax3.set_xlabel(r"$x$") - ax3.set_ylabel(r"$z$") - ax3.set_title("Fission-XZ") - pos = ax3.get_position() - ax3.set_position( - [pos.x0 + 0.02, pos.y0, pos.width, pos.height] - ) # shift right by 0.02 - - # YZ fission - ax4.pcolormesh(YZ_Y, YZ_Z, fission_x_sd) - ax4.set_aspect("equal") - ax4.set_xlabel(r"$y$") - ax4.set_ylabel(r"$z$") - ax4.set_title("Fission-YZ") - - plt.suptitle("MC/DC result - Fission Rate Relative Sdev.") - plt.savefig( - f"fission-sdev/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0 - ) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-fission.py b/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-fission.py deleted file mode 100644 index 3c63e92..0000000 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-fission.py +++ /dev/null @@ -1,91 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import os -import shutil - -# Get results -with h5py.File("output_4.h5", "r") as f: - fissions = f["tallies/tracklength_tally_0/fission/mean"][()] - x = f["tallies/tracklength_tally_0/grid/x"][()] - y = f["tallies/tracklength_tally_0/grid/y"][()] - z = f["tallies/tracklength_tally_0/grid/z"][()] - t = f["tallies/tracklength_tally_0/grid/time"][()] - -# Total fission -fission_total = np.average(fissions, axis=(1, 2, 3)) -fission_total /= fission_total[0] - -# The grids -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("fission"): - shutil.rmtree("fission") # Remove the existing folder -os.makedirs("fission") # Create a new folder - -# Iterate over time step and create figures -N = len(fissions) -for i in range(N): - fission = fissions[i] - - # Calculate fission averages - fission_x = np.average(fission, axis=0) - fission_y = np.average(fission, axis=1) - fission_z = np.average(fission, axis=2) - - # Plot - fig = plt.figure(figsize=(8, 5)) - gs = gridspec.GridSpec( - 2, 3, width_ratios=[0.7, 1, 1], height_ratios=[1, 1], hspace=0.5 - ) - - ax1 = fig.add_subplot(gs[0, 0]) # Top-left - ax2 = fig.add_subplot(gs[1, 0]) # Bottom-left - ax3 = fig.add_subplot(gs[:, 1]) # Entire second column - ax4 = fig.add_subplot(gs[:, 2]) # Entire third column - - # Total fission curve - ax1.plot(t_mid, fission_total, "b") - ax1.set_yscale("log") - ax1.set_ylabel("Total fission rate") - ax1.set_xlabel("Time") - ax1.set_title("Total fission rate") - # Total fission point - ax1.plot(t_mid[i], fission_total[i], "ro", fillstyle="none") - - # XY fission - ax2.pcolormesh(XY_X, XY_Y, fission_z) - ax2.set_aspect("equal") - ax2.set_xlabel(r"$x$") - ax2.set_ylabel(r"$y$") - ax2.set_title("Fission-XY") - - # XZ fission - ax3.pcolormesh(XZ_X, XZ_Z, fission_y) - ax3.set_aspect("equal") - ax3.set_xlabel(r"$x$") - ax3.set_ylabel(r"$z$") - ax3.set_title("Fission-XZ") - pos = ax3.get_position() - ax3.set_position( - [pos.x0 + 0.02, pos.y0, pos.width, pos.height] - ) # shift right by 0.02 - - # YZ fission - ax4.pcolormesh(YZ_Y, YZ_Z, fission_x) - ax4.set_aspect("equal") - ax4.set_xlabel(r"$y$") - ax4.set_ylabel(r"$z$") - ax4.set_title("Fission-YZ") - - plt.suptitle("MC/DC result - Fission Rate") - plt.savefig( - f"fission/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0 - ) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-flux-fast-sdev.py b/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-flux-fast-sdev.py deleted file mode 100644 index fa48188..0000000 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-flux-fast-sdev.py +++ /dev/null @@ -1,88 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import os -import shutil - -# Get results -with h5py.File("output.h5", "r") as f: - fission_total = f["tallies/tracklength_tally_0/fission/sdev"][()] - fluxes = f["tallies/tracklength_tally_2/flux/sdev"][:, 0, :, :] - x = f["tallies/tracklength_tally_2/grid/x"][()] - y = f["tallies/tracklength_tally_2/grid/y"][()] - z = f["tallies/tracklength_tally_2/grid/z"][()] - t = f["tallies/tracklength_tally_2/grid/time"][()] - -# The grids -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("flux-fast-sdev"): - shutil.rmtree("flux-fast-sdev") # Remove the existing folder -os.makedirs("flux-fast-sdev") # Create a new folder - -# Iterate over time step and create figures -N = len(fluxes) -for i in range(N): - flux = fluxes[i] - - # Calculate flux averages - flux_x = np.average(flux, axis=0) - flux_y = np.average(flux, axis=1) - flux_z = np.average(flux, axis=2) - - # Plot - fig = plt.figure(figsize=(8, 5)) - gs = gridspec.GridSpec( - 2, 3, width_ratios=[0.7, 1, 1], height_ratios=[1, 1], hspace=0.5 - ) - - ax1 = fig.add_subplot(gs[0, 0]) # Top-left - ax2 = fig.add_subplot(gs[1, 0]) # Bottom-left - ax3 = fig.add_subplot(gs[:, 1]) # Entire second column - ax4 = fig.add_subplot(gs[:, 2]) # Entire third column - - # Total fission curve - ax1.plot(t_mid, fission_total, "b") - ax1.set_yscale("log") - ax1.set_ylabel("Std. dev.") - ax1.set_xlabel("Time") - ax1.set_title("Total fission rate") - # Total fission point - ax1.plot(t_mid[i], fission_total[i], "ro", fillstyle="none") - - # XY flux - ax2.pcolormesh(XY_X, XY_Y, flux_z) - ax2.set_aspect("equal") - ax2.set_xlabel(r"$x$") - ax2.set_ylabel(r"$y$") - ax2.set_title("Flux-XY") - - # XZ flux - ax3.pcolormesh(XZ_X, XZ_Z, flux_y) - ax3.set_aspect("equal") - ax3.set_xlabel(r"$x$") - ax3.set_ylabel(r"$z$") - ax3.set_title("Flux-XZ") - pos = ax3.get_position() - ax3.set_position( - [pos.x0 + 0.02, pos.y0, pos.width, pos.height] - ) # shift right by 0.02 - - # YZ flux - ax4.pcolormesh(YZ_Y, YZ_Z, flux_x) - ax4.set_aspect("equal") - ax4.set_xlabel(r"$y$") - ax4.set_ylabel(r"$z$") - ax4.set_title("Flux-YZ") - - plt.suptitle("MC/DC result - Fast Flux Standard Deviation") - plt.savefig( - f"flux-fast-sdev/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0 - ) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-flux-fast.py b/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-flux-fast.py deleted file mode 100644 index 5d82b4f..0000000 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-flux-fast.py +++ /dev/null @@ -1,88 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import os -import shutil - -# Get results -with h5py.File("output.h5", "r") as f: - fission_total = f["tallies/tracklength_tally_0/fission/mean"][()] - fluxes = f["tallies/tracklength_tally_2/flux/mean"][:, 0, :, :] - x = f["tallies/tracklength_tally_2/grid/x"][()] - y = f["tallies/tracklength_tally_2/grid/y"][()] - z = f["tallies/tracklength_tally_2/grid/z"][()] - t = f["tallies/tracklength_tally_2/grid/time"][()] - -# The grids -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("flux-fast"): - shutil.rmtree("flux-fast") # Remove the existing folder -os.makedirs("flux-fast") # Create a new folder - -# Iterate over time step and create figures -N = len(fluxes) -for i in range(N): - flux = fluxes[i] - - # Calculate flux averages - flux_x = np.average(flux, axis=0) - flux_y = np.average(flux, axis=1) - flux_z = np.average(flux, axis=2) - - # Plot - fig = plt.figure(figsize=(8, 5)) - gs = gridspec.GridSpec( - 2, 3, width_ratios=[0.7, 1, 1], height_ratios=[1, 1], hspace=0.5 - ) - - ax1 = fig.add_subplot(gs[0, 0]) # Top-left - ax2 = fig.add_subplot(gs[1, 0]) # Bottom-left - ax3 = fig.add_subplot(gs[:, 1]) # Entire second column - ax4 = fig.add_subplot(gs[:, 2]) # Entire third column - - # Total fission curve - ax1.plot(t_mid, fission_total, "b") - ax1.set_yscale("log") - ax1.set_ylabel("Total fission rate") - ax1.set_xlabel("Time") - ax1.set_title("Total fission rate") - # Total fission point - ax1.plot(t_mid[i], fission_total[i], "ro", fillstyle="none") - - # XY flux - ax2.pcolormesh(XY_X, XY_Y, flux_z) - ax2.set_aspect("equal") - ax2.set_xlabel(r"$x$") - ax2.set_ylabel(r"$y$") - ax2.set_title("Flux-XY") - - # XZ flux - ax3.pcolormesh(XZ_X, XZ_Z, flux_y) - ax3.set_aspect("equal") - ax3.set_xlabel(r"$x$") - ax3.set_ylabel(r"$z$") - ax3.set_title("Flux-XZ") - pos = ax3.get_position() - ax3.set_position( - [pos.x0 + 0.02, pos.y0, pos.width, pos.height] - ) # shift right by 0.02 - - # YZ flux - ax4.pcolormesh(YZ_Y, YZ_Z, flux_x) - ax4.set_aspect("equal") - ax4.set_xlabel(r"$y$") - ax4.set_ylabel(r"$z$") - ax4.set_title("Flux-YZ") - - plt.suptitle("MC/DC result - Fast Flux") - plt.savefig( - f"flux-fast/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0 - ) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-flux-thermal-sdev.py b/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-flux-thermal-sdev.py deleted file mode 100644 index 1c2313d..0000000 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-flux-thermal-sdev.py +++ /dev/null @@ -1,91 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import os -import shutil - -# Get results -with h5py.File("output.h5", "r") as f: - fission_total = f["tallies/tracklength_tally_0/fission/sdev"][()] - fluxes = f["tallies/tracklength_tally_2/flux/sdev"][:, 0, :, :] - x = f["tallies/tracklength_tally_2/grid/x"][()] - y = f["tallies/tracklength_tally_2/grid/y"][()] - z = f["tallies/tracklength_tally_2/grid/z"][()] - t = f["tallies/tracklength_tally_2/grid/time"][()] - -# The grids -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("flux-thermal-sdev"): - shutil.rmtree("flux-thermal-sdev") # Remove the existing folder -os.makedirs("flux-tharmal-sdev") # Create a new folder - -# Iterate over time step and create figures -N = len(fluxes) -for i in range(N): - flux = fluxes[i] - - # Calculate flux averages - flux_x = np.average(flux, axis=0) - flux_y = np.average(flux, axis=1) - flux_z = np.average(flux, axis=2) - - # Plot - fig = plt.figure(figsize=(8, 5)) - gs = gridspec.GridSpec( - 2, 3, width_ratios=[0.7, 1, 1], height_ratios=[1, 1], hspace=0.5 - ) - - ax1 = fig.add_subplot(gs[0, 0]) # Top-left - ax2 = fig.add_subplot(gs[1, 0]) # Bottom-left - ax3 = fig.add_subplot(gs[:, 1]) # Entire second column - ax4 = fig.add_subplot(gs[:, 2]) # Entire third column - - # Total fission curve - ax1.plot(t_mid, fission_total, "b") - ax1.set_yscale("log") - ax1.set_ylabel("Std. dev.") - ax1.set_xlabel("Time") - ax1.set_title("Total fission rate") - # Total fission point - ax1.plot(t_mid[i], fission_total[i], "ro", fillstyle="none") - - # XY flux - ax2.pcolormesh(XY_X, XY_Y, flux_z) - ax2.set_aspect("equal") - ax2.set_xlabel(r"$x$") - ax2.set_ylabel(r"$y$") - ax2.set_title("Flux-XY") - - # XZ flux - ax3.pcolormesh(XZ_X, XZ_Z, flux_y) - ax3.set_aspect("equal") - ax3.set_xlabel(r"$x$") - ax3.set_ylabel(r"$z$") - ax3.set_title("Flux-XZ") - pos = ax3.get_position() - ax3.set_position( - [pos.x0 + 0.02, pos.y0, pos.width, pos.height] - ) # shift right by 0.02 - - # YZ flux - ax4.pcolormesh(YZ_Y, YZ_Z, flux_x) - ax4.set_aspect("equal") - ax4.set_xlabel(r"$y$") - ax4.set_ylabel(r"$z$") - ax4.set_title("Flux-YZ") - - plt.suptitle("MC/DC result - Thermal Flux Standard Deviation") - plt.savefig( - f"flux-tharmal-sdev/figure_{i:03}.png", - dpi=300, - bbox_inches="tight", - pad_inches=0, - ) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-flux-thermal.py b/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-flux-thermal.py deleted file mode 100644 index 90bdb18..0000000 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/plot-flux-thermal.py +++ /dev/null @@ -1,88 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import os -import shutil - -# Get results -with h5py.File("output.h5", "r") as f: - fission_total = f["tallies/tracklength_tally_0/fission/mean"][()] - fluxes = f["tallies/tracklength_tally_2/flux/mean"][:, 1, :, :] - x = f["tallies/tracklength_tally_2/grid/x"][()] - y = f["tallies/tracklength_tally_2/grid/y"][()] - z = f["tallies/tracklength_tally_2/grid/z"][()] - t = f["tallies/tracklength_tally_2/grid/time"][()] - -# The grids -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("flux-thermal"): - shutil.rmtree("flux-thermal") # Remove the existing folder -os.makedirs("flux-thermal") # Create a new folder - -# Iterate over time step and create figures -N = len(fluxes) -for i in range(N): - flux = fluxes[i] - - # Calculate flux averages - flux_x = np.average(flux, axis=0) - flux_y = np.average(flux, axis=1) - flux_z = np.average(flux, axis=2) - - # Plot - fig = plt.figure(figsize=(8, 5)) - gs = gridspec.GridSpec( - 2, 3, width_ratios=[0.7, 1, 1], height_ratios=[1, 1], hspace=0.5 - ) - - ax1 = fig.add_subplot(gs[0, 0]) # Top-left - ax2 = fig.add_subplot(gs[1, 0]) # Bottom-left - ax3 = fig.add_subplot(gs[:, 1]) # Entire second column - ax4 = fig.add_subplot(gs[:, 2]) # Entire third column - - # Total fission curve - ax1.plot(t_mid, fission_total, "b") - ax1.set_yscale("log") - ax1.set_ylabel("Total fission rate") - ax1.set_xlabel("Time") - ax1.set_title("Total fission rate") - # Total fission point - ax1.plot(t_mid[i], fission_total[i], "ro", fillstyle="none") - - # XY flux - ax2.pcolormesh(XY_X, XY_Y, flux_z) - ax2.set_aspect("equal") - ax2.set_xlabel(r"$x$") - ax2.set_ylabel(r"$y$") - ax2.set_title("Flux-XY") - - # XZ flux - ax3.pcolormesh(XZ_X, XZ_Z, flux_y) - ax3.set_aspect("equal") - ax3.set_xlabel(r"$x$") - ax3.set_ylabel(r"$z$") - ax3.set_title("Flux-XZ") - pos = ax3.get_position() - ax3.set_position( - [pos.x0 + 0.02, pos.y0, pos.width, pos.height] - ) # shift right by 0.02 - - # YZ flux - ax4.pcolormesh(YZ_Y, YZ_Z, flux_x) - ax4.set_aspect("equal") - ax4.set_xlabel(r"$y$") - ax4.set_ylabel(r"$z$") - ax4.set_title("Flux-YZ") - - plt.suptitle("MC/DC result - Thermal Flux") - plt.savefig( - f"flux-thermal/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0 - ) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/MGXS-C5G7.h5 b/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/MGXS-C5G7.h5 deleted file mode 100644 index 218b300..0000000 Binary files a/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/MGXS-C5G7.h5 and /dev/null differ diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/build-xml.py b/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/build-xml.py deleted file mode 100644 index 7e2ffe8..0000000 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/build-xml.py +++ /dev/null @@ -1,502 +0,0 @@ -import h5py -import matplotlib.pyplot as plt - -import numpy as np - -import openmc, sys - -############################################################################### -# Create multigroup data - -# Library data -groups = openmc.mgxs.EnergyGroups( - group_edges=[1e-5, 0.0635, 10.0, 1.0e2, 1.0e3, 0.5e6, 1.0e6, 20.0e6] -) - -# Library data -lib = h5py.File("MGXS-C5G7.h5", "r") - - -# The setter helper -def set_xs_data(name, mat): - SigmaC = mat["capture"][:] - SigmaS = mat["scatter"][:] - SigmaF = mat["fission"][:] - nu_p = mat["nu_p"][:] - nu_d = mat["nu_d"][:] - chi_p = mat["chi_p"][:] - chi_d = mat["chi_d"][:] - speed = mat["speed"][:] - decay = mat["decay"][:] - - xsdata = openmc.XSdata(name, groups, num_delayed_groups=8) - xsdata.order = 0 - - xsdata.set_inverse_velocity(1.0 / speed) - xsdata.set_decay_rate(decay) - - SigmaS = SigmaS.transpose() - SigmaA = SigmaC + SigmaF - SigmaS_total = np.sum(SigmaS, 1) - SigmaT = SigmaA + SigmaS_total - chi_p = chi_p[:, 0] - chi_d = chi_d.transpose() - - xsdata.set_total(SigmaT) - xsdata.set_absorption(SigmaA) - xsdata.set_fission(SigmaF) - xsdata.set_scatter_matrix(np.expand_dims(SigmaS, 2)) - - xsdata.set_prompt_nu_fission(nu_p * SigmaF) - xsdata.set_delayed_nu_fission(nu_d * SigmaF) - xsdata.set_chi_prompt(chi_p) - xsdata.set_chi_delayed(chi_d) - - return xsdata - - -# Set up the XS data -mg_cross_sections_file = openmc.MGXSLibrary(groups, 8) -mg_cross_sections_file.add_xsdatas( - [ - set_xs_data("UO2", lib["uo2"]), - set_xs_data("MOX43", lib["mox43"]), - set_xs_data("MOX7", lib["mox7"]), - set_xs_data("MOX87", lib["mox87"]), - set_xs_data("Guide Tube", lib["gt"]), - set_xs_data("Fission Chamber", lib["fc"]), - set_xs_data("Control Rod", lib["cr"]), - set_xs_data("Moderator", lib["mod"]), - ] -) -mg_cross_sections_file.export_to_hdf5() - -############################################################################### -# Create materials for the problem - -# Fuel: UO2 -mat_uo2 = openmc.Material(name="UO2") -mat_uo2.set_density("macro", 1.0) -mat_uo2.add_macroscopic(openmc.Macroscopic("UO2")) - -# Fuel: MOX 4.3% -mat_mox43 = openmc.Material(name="MOX43") -mat_mox43.set_density("macro", 1.0) -mat_mox43.add_macroscopic(openmc.Macroscopic("MOX43")) - -# Fuel: MOX 7% -mat_mox7 = openmc.Material(name="MOX7") -mat_mox7.set_density("macro", 1.0) -mat_mox7.add_macroscopic(openmc.Macroscopic("MOX7")) - -# Fuel: MOX 8.7% -mat_mox87 = openmc.Material(name="MOX87") -mat_mox87.set_density("macro", 1.0) -mat_mox87.add_macroscopic(openmc.Macroscopic("MOX87")) - -# Guide tube -mat_gt = openmc.Material(name="Guide Tube") -mat_gt.set_density("macro", 1.0) -mat_gt.add_macroscopic(openmc.Macroscopic("Guide Tube")) - -# Fission chamber -mat_fc = openmc.Material(name="Fission Chamber") -mat_fc.set_density("macro", 1.0) -mat_fc.add_macroscopic(openmc.Macroscopic("Fission Chamber")) - -# Control rod -mat_cr = openmc.Material(name="Control Rod") -mat_cr.set_density("macro", 1.0) -mat_cr.add_macroscopic(openmc.Macroscopic("Control Rod")) - -# Moderator -mat_mod = openmc.Material(name="Moderator") -mat_mod.set_density("macro", 1.0) -mat_mod.add_macroscopic(openmc.Macroscopic("Moderator")) - -# Instantiate a Materials collection and export to XML -materials_file = openmc.Materials( - [mat_uo2, mat_mox43, mat_mox7, mat_mox87, mat_gt, mat_fc, mat_cr, mat_mod] -) -materials_file.cross_sections = "mgxs.h5" -materials_file.export_to_xml() - -colors = { - mat_uo2: "red", - mat_mox43: "orange", - mat_mox7: "brown", - mat_mox87: "yellow", - mat_gt: "gray", - mat_fc: "silver", - mat_cr: "green", - mat_mod: "blue", -} - -############################################################################### -# Problem specifications - -# Reactor geometry sizes -pitch = 1.26 -radius = 0.54 -core_height = 128.52 -reflector_thickness = 21.42 - -# Control rod banks fractions -# All out: 0.0 -# All in : 1.0 -cr1 = np.array([1.0, 1.0, 0.89, 1.0]) -cr1_t = np.array([0.0, 10.0, 15.0, 15.0 + 1.0 - cr1[-2]]) - -cr2 = np.array([1.0, 1.0, 0.0, 0.0, 0.8]) -cr2_t = np.array([0.0, 5.0, 10.0, 15.0, 15.8]) - -cr3 = np.array([0.75, 0.75, 1.0]) -cr3_t = np.array([0.0, 15.0, 15.25]) - -cr4 = np.array([1.0, 1.0, 0.5, 0.5, 1.0]) -cr4_t = np.array( - [0.0, 5.0, 5.0 + (cr4[1] - cr4[2]) / 2 * 10, 15.0, 15.0 + 1.0 - cr4[-2]] -) - -# Tips of the control rod banks -cr1_bottom = core_height * (0.5 - cr1) -cr2_bottom = core_height * (0.5 - cr2) -cr3_bottom = core_height * (0.5 - cr3) -cr4_bottom = core_height * (0.5 - cr4) -cr1_top = cr1_bottom + core_height -cr2_top = cr2_bottom + core_height -cr3_top = cr3_bottom + core_height -cr4_top = cr4_bottom + core_height - -# Durations of the moving tips -cr1_durations = cr1_t[1:] - cr1_t[:-1] -cr2_durations = cr2_t[1:] - cr2_t[:-1] -cr3_durations = cr3_t[1:] - cr3_t[:-1] -cr4_durations = cr4_t[1:] - cr4_t[:-1] - -# Velocities of the moving tips -cr1_velocities = np.zeros((len(cr1) - 1, 3)) -cr2_velocities = np.zeros((len(cr2) - 1, 3)) -cr3_velocities = np.zeros((len(cr3) - 1, 3)) -cr4_velocities = np.zeros((len(cr4) - 1, 3)) -cr1_velocities[:, 2] = (cr1_top[1:] - cr1_top[:-1]) / cr1_durations -cr2_velocities[:, 2] = (cr2_top[1:] - cr2_top[:-1]) / cr2_durations -cr3_velocities[:, 2] = (cr3_top[1:] - cr3_top[:-1]) / cr3_durations -cr4_velocities[:, 2] = (cr4_top[1:] - cr4_top[:-1]) / cr4_durations - -############################################################################### -# Pin cells - -# Surfaces -cy = openmc.ZCylinder(x0=0.0, y0=0.0, r=radius) -# Control rod top and bottom tips -z1_top = openmc.ZPlane(z0=cr1_top[0], surface_id=9911) -z1_bottom = openmc.ZPlane(z0=cr1_bottom[0], surface_id=9910) -z2_top = openmc.ZPlane(z0=cr2_top[0], surface_id=9921) -z2_bottom = openmc.ZPlane(z0=cr2_bottom[0], surface_id=9920) -z3_top = openmc.ZPlane(z0=cr3_top[0], surface_id=9931) -z3_bottom = openmc.ZPlane(z0=cr3_bottom[0], surface_id=9930) -z4_top = openmc.ZPlane(z0=cr4_top[0], surface_id=9941) -z4_bottom = openmc.ZPlane(z0=cr4_bottom[0], surface_id=9940) -# Fuel top -# (Bottom is bounded by the universe cell) -zf = openmc.ZPlane(z0=0.5 * core_height) - -# Move the control rods' tips -z1_top.move(cr1_velocities, cr1_durations) -z1_bottom.move(cr1_velocities, cr1_durations) -z2_top.move(cr2_velocities, cr2_durations) -z2_bottom.move(cr2_velocities, cr2_durations) -z3_top.move(cr3_velocities, cr3_durations) -z3_bottom.move(cr3_velocities, cr3_durations) -z4_top.move(cr4_velocities, cr4_durations) -z4_bottom.move(cr4_velocities, cr4_durations) - -# Fission chamber pin -fc = openmc.Cell(region=-cy, fill=mat_fc) -mod_fc = openmc.Cell(region=+cy, fill=mat_mod) -fission_chamber = openmc.Universe(cells=[fc, mod_fc]) - -# Fuel rods -uo2 = openmc.Cell(region=-cy & -zf, fill=mat_uo2) -mox4 = openmc.Cell(region=-cy & -zf, fill=mat_mox43) -mox7 = openmc.Cell(region=-cy & -zf, fill=mat_mox7) -mox8 = openmc.Cell(region=-cy & -zf, fill=mat_mox87) -mod_uo2 = openmc.Cell(region=+cy, fill=mat_mod) -mod_mox4 = openmc.Cell(region=+cy, fill=mat_mod) -mod_mox7 = openmc.Cell(region=+cy, fill=mat_mod) -mod_mox8 = openmc.Cell(region=+cy, fill=mat_mod) -moda_uo2 = openmc.Cell(region=-cy & +zf, fill=mat_mod) # Water above pin -moda_mox4 = openmc.Cell(region=-cy & +zf, fill=mat_mod) # Water above pin -moda_mox7 = openmc.Cell(region=-cy & +zf, fill=mat_mod) # Water above pin -moda_mox8 = openmc.Cell(region=-cy & +zf, fill=mat_mod) # Water above pin -fuel_uo2 = openmc.Universe(cells=[uo2, mod_uo2, moda_uo2]) -fuel_mox43 = openmc.Universe(cells=[mox4, mod_mox4, moda_mox4]) -fuel_mox7 = openmc.Universe(cells=[mox7, mod_mox7, moda_mox7]) -fuel_mox87 = openmc.Universe(cells=[mox8, mod_mox8, moda_mox8]) - -# Control rods and guide tubes -cr1 = openmc.Cell(region=-cy & +z1_bottom & -z1_top, fill=mat_cr) -gt1_lower = openmc.Cell(region=-cy & -z1_bottom, fill=mat_gt) -gt1_upper = openmc.Cell(region=-cy & +z1_top, fill=mat_gt) -# -cr2 = openmc.Cell(region=-cy & +z2_bottom & -z2_top, fill=mat_cr) -gt2_lower = openmc.Cell(region=-cy & -z2_bottom, fill=mat_gt) -gt2_upper = openmc.Cell(region=-cy & +z2_top, fill=mat_gt) -# -cr3 = openmc.Cell(region=-cy & +z3_bottom & -z3_top, fill=mat_cr) -gt3_lower = openmc.Cell(region=-cy & -z3_bottom, fill=mat_gt) -gt3_upper = openmc.Cell(region=-cy & +z3_top, fill=mat_gt) -# -cr4 = openmc.Cell(region=-cy & +z4_bottom & -z4_top, fill=mat_cr) -gt4_lower = openmc.Cell(region=-cy & -z4_bottom, fill=mat_gt) -gt4_upper = openmc.Cell(region=-cy & +z4_top, fill=mat_gt) -# -mod_cr1 = openmc.Cell(region=+cy, fill=mat_mod) -mod_cr2 = openmc.Cell(region=+cy, fill=mat_mod) -mod_cr3 = openmc.Cell(region=+cy, fill=mat_mod) -mod_cr4 = openmc.Cell(region=+cy, fill=mat_mod) -# -control_rod1 = openmc.Universe(cells=[cr1, gt1_lower, gt1_upper, mod_cr1]) -control_rod2 = openmc.Universe(cells=[cr2, gt2_lower, gt2_upper, mod_cr2]) -control_rod3 = openmc.Universe(cells=[cr3, gt3_lower, gt3_upper, mod_cr3]) -control_rod4 = openmc.Universe(cells=[cr4, gt4_lower, gt4_upper, mod_cr4]) - -############################################################################### -# Fuel lattices - -# UO2 lattice 1 -u = fuel_uo2 -c = control_rod1 -f = fission_chamber -lattice_1 = openmc.RectLattice() -lattice_1.pitch = (pitch, pitch) -lattice_1.lower_left = (-pitch * 17 / 2, -pitch * 17 / 2) -lattice_1.universes = np.array( - [ - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, u, u, u, c, u, u, c, u, u, c, u, u, u, u, u], - [u, u, u, c, u, u, u, u, u, u, u, u, u, c, u, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, c, u, u, c, u, u, c, u, u, c, u, u, c, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, c, u, u, c, u, u, f, u, u, c, u, u, c, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, c, u, u, c, u, u, c, u, u, c, u, u, c, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, u, c, u, u, u, u, u, u, u, u, u, c, u, u, u], - [u, u, u, u, u, c, u, u, c, u, u, c, u, u, u, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - ] -) - -# MOX lattice 2 -l = fuel_mox43 -m = fuel_mox7 -n = fuel_mox87 -c = control_rod2 -f = fission_chamber -lattice_2 = openmc.RectLattice() -lattice_2.pitch = (pitch, pitch) -lattice_2.lower_left = (-pitch * 17 / 2, -pitch * 17 / 2) -lattice_2.universes = np.array( - [ - [l, l, l, l, l, l, l, l, l, l, l, l, l, l, l, l, l], - [l, m, m, m, m, m, m, m, m, m, m, m, m, m, m, m, l], - [l, m, m, m, m, c, m, m, c, m, m, c, m, m, m, m, l], - [l, m, m, c, m, n, n, n, n, n, n, n, m, c, m, m, l], - [l, m, m, m, n, n, n, n, n, n, n, n, n, m, m, m, l], - [l, m, c, n, n, c, n, n, c, n, n, c, n, n, c, m, l], - [l, m, m, n, n, n, n, n, n, n, n, n, n, n, m, m, l], - [l, m, m, n, n, n, n, n, n, n, n, n, n, n, m, m, l], - [l, m, c, n, n, c, n, n, f, n, n, c, n, n, c, m, l], - [l, m, m, n, n, n, n, n, n, n, n, n, n, n, m, m, l], - [l, m, m, n, n, n, n, n, n, n, n, n, n, n, m, m, l], - [l, m, c, n, n, c, n, n, c, n, n, c, n, n, c, m, l], - [l, m, m, m, n, n, n, n, n, n, n, n, n, m, m, m, l], - [l, m, m, c, m, n, n, n, n, n, n, n, m, c, m, m, l], - [l, m, m, m, m, c, m, m, c, m, m, c, m, m, m, m, l], - [l, m, m, m, m, m, m, m, m, m, m, m, m, m, m, m, l], - [l, l, l, l, l, l, l, l, l, l, l, l, l, l, l, l, l], - ] -) - -# MOX lattice 3 -l = fuel_mox43 -m = fuel_mox7 -n = fuel_mox87 -c = control_rod3 -f = fission_chamber -lattice_3 = openmc.RectLattice() -lattice_3.pitch = (pitch, pitch) -lattice_3.lower_left = (-pitch * 17 / 2, -pitch * 17 / 2) -lattice_3.universes = np.array( - [ - [l, l, l, l, l, l, l, l, l, l, l, l, l, l, l, l, l], - [l, m, m, m, m, m, m, m, m, m, m, m, m, m, m, m, l], - [l, m, m, m, m, c, m, m, c, m, m, c, m, m, m, m, l], - [l, m, m, c, m, n, n, n, n, n, n, n, m, c, m, m, l], - [l, m, m, m, n, n, n, n, n, n, n, n, n, m, m, m, l], - [l, m, c, n, n, c, n, n, c, n, n, c, n, n, c, m, l], - [l, m, m, n, n, n, n, n, n, n, n, n, n, n, m, m, l], - [l, m, m, n, n, n, n, n, n, n, n, n, n, n, m, m, l], - [l, m, c, n, n, c, n, n, f, n, n, c, n, n, c, m, l], - [l, m, m, n, n, n, n, n, n, n, n, n, n, n, m, m, l], - [l, m, m, n, n, n, n, n, n, n, n, n, n, n, m, m, l], - [l, m, c, n, n, c, n, n, c, n, n, c, n, n, c, m, l], - [l, m, m, m, n, n, n, n, n, n, n, n, n, m, m, m, l], - [l, m, m, c, m, n, n, n, n, n, n, n, m, c, m, m, l], - [l, m, m, m, m, c, m, m, c, m, m, c, m, m, m, m, l], - [l, m, m, m, m, m, m, m, m, m, m, m, m, m, m, m, l], - [l, l, l, l, l, l, l, l, l, l, l, l, l, l, l, l, l], - ] -) - -# UO2 lattice 4 -u = fuel_uo2 -c = control_rod4 -f = fission_chamber -lattice_4 = openmc.RectLattice() -lattice_4.pitch = (pitch, pitch) -lattice_4.lower_left = (-pitch * 17 / 2, -pitch * 17 / 2) -lattice_4.universes = np.array( - [ - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, u, u, u, c, u, u, c, u, u, c, u, u, u, u, u], - [u, u, u, c, u, u, u, u, u, u, u, u, u, c, u, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, c, u, u, c, u, u, c, u, u, c, u, u, c, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, c, u, u, c, u, u, f, u, u, c, u, u, c, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, c, u, u, c, u, u, c, u, u, c, u, u, c, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, u, c, u, u, u, u, u, u, u, u, u, c, u, u, u], - [u, u, u, u, u, c, u, u, c, u, u, c, u, u, u, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - [u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u, u], - ] -) - -############################################################################### -# Assemblies and core - -# Surfaces -x0 = openmc.XPlane(x0=0.0, boundary_type="reflective") -x1 = openmc.XPlane(x0=pitch * 17) -x2 = openmc.XPlane(x0=pitch * 17 * 2) -x3 = openmc.XPlane(x0=pitch * 17 * 3, boundary_type="vacuum") - -y0 = openmc.YPlane(y0=-pitch * 17 * 3, boundary_type="vacuum") -y1 = openmc.YPlane(y0=-pitch * 17 * 2) -y2 = openmc.YPlane(y0=-pitch * 17) -y3 = openmc.YPlane(y0=0.0, boundary_type="reflective") - -z0 = openmc.ZPlane(z0=-(core_height / 2 + reflector_thickness), boundary_type="vacuum") -z1 = openmc.ZPlane(z0=-(core_height / 2)) -z2 = openmc.ZPlane(z0=(core_height / 2 + reflector_thickness), boundary_type="vacuum") - -# Assembly cells -center = np.array([pitch * 17 / 2, -pitch * 17 / 2, 0.0]) -assembly_1 = openmc.Cell(region=+x0 & -x1 & +y2 & -y3 & +z1 & -z2, fill=lattice_1) -assembly_1.translation = center - -assembly_2 = openmc.Cell(region=+x1 & -x2 & +y2 & -y3 & +z1 & -z2, fill=lattice_2) -assembly_2.translation = assembly_1.translation + np.array([pitch * 17, 0.0, 0.0]) - -assembly_3 = openmc.Cell(region=+x0 & -x1 & +y1 & -y2 & +z1 & -z2, fill=lattice_3) -assembly_3.translation = center -assembly_3.translation = assembly_2.translation + np.array( - [-pitch * 17, -pitch * 17, 0.0] -) - -assembly_4 = openmc.Cell(region=+x1 & -x2 & +y1 & -y2 & +z1 & -z2, fill=lattice_4) -assembly_4.translation = assembly_3.translation + np.array([pitch * 17, 0.0, 0.0]) - -# Bottom reflector cell -reflector_bottom = openmc.Cell(region=+x0 & -x3 & +y0 & -y3 & +z0 & -z1, fill=mat_mod) - -# Side reflectors -reflector_south = openmc.Cell(region=+x0 & -x3 & +y0 & -y1 & +z1 & -z2, fill=mat_mod) -reflector_east = openmc.Cell(region=+x2 & -x3 & +y1 & -y3 & +z1 & -z2, fill=mat_mod) - -# Root universe -root_universe = openmc.Universe( - universe_id=0, - cells=[ - assembly_1, - assembly_2, - assembly_3, - assembly_4, - reflector_bottom, - reflector_south, - reflector_east, - ], -) - -# Create a geometry with the two cells and export to XML -geometry = openmc.Geometry(root_universe) -geometry.export_to_xml() - -############################################################################### -# Define problem settings - -# Instantiate a Settings object, set all runtime parameters, and export to XML -settings = openmc.Settings() -settings.run_mode = "fixed source" -settings.energy_mode = "multi-group" -settings.batches = 30 -settings.particles = int(sys.argv[1]) -settings.cutoff = {"time_neutron": 20} - -# Create an initial uniform spatial source distribution over fissionable zones -space = openmc.stats.CartesianIndependent( - x=openmc.stats.Uniform( - pitch * 17 * 3 / 2 - pitch / 2, pitch * 17 * 3 / 2 + pitch / 2 - ), - y=openmc.stats.Uniform( - -pitch * 17 * 3 / 2 - pitch / 2, -pitch * 17 * 3 / 2 + pitch / 2 - ), - z=openmc.stats.Uniform(-core_height / 2, core_height / 2), -) -energy = openmc.stats.Discrete([1.4e7], [1.0]) -time = openmc.stats.Uniform(0.0, 15.0) -settings.source = openmc.IndependentSource(space=space, time=time, energy=energy) -settings.output = {"tallies": False} -settings.export_to_xml() - -############################################################################### -# Define tallies - -Nt = 200 -Nx = 17 * 2 -Ny = 17 * 2 -Nz = 17 * 6 - -# Create a mesh that will be used for tallying -mesh = openmc.RectilinearMesh() -mesh.x_grid = np.linspace(0.0, pitch * 17 * 2, Nx + 1) -mesh.y_grid = np.linspace(-pitch * 17 * 2, 0.0, Ny + 1) -mesh.z_grid = np.linspace(-core_height / 2, core_height / 2, Nz + 1) -time = np.linspace(0.0, 20.0, Nt + 1) - -# Create a mesh filter that can be used in a tally -time_filter = openmc.TimeFilter(time) -time_mesh_filter = openmc.TimedMeshFilter(mesh, time) - -# Now use the mesh filter in a tally and indicate what scores are desired -mesh_tally = openmc.Tally(name="pincell fission") -mesh_tally.filters = [time_mesh_filter] -mesh_tally.estimator = "tracklength" -mesh_tally.scores = ["fission"] - -# Instantiate a Tallies collection and export to XML -tallies = openmc.Tallies([mesh_tally]) -tallies.export_to_xml() diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/get_reference.py b/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/get_reference.py deleted file mode 100644 index d72cb1e..0000000 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/get_reference.py +++ /dev/null @@ -1,8 +0,0 @@ -import os - -# Install reference files from Zenodo -os.system("wget https://zenodo.org/records/15719118/files/output_0.h5") -os.system("wget https://zenodo.org/records/15719118/files/output_1.h5") -os.system("wget https://zenodo.org/records/15719118/files/output_2.h5") -os.system("wget https://zenodo.org/records/15719118/files/output_3.h5") -os.system("wget https://zenodo.org/records/15719118/files/output_4.h5") diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/plot-fission-sdev.py b/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/plot-fission-sdev.py deleted file mode 100644 index c45e64a..0000000 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/plot-fission-sdev.py +++ /dev/null @@ -1,108 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import os, openmc -import shutil - -Nt = 200 -Nx = 17 * 2 -Ny = 17 * 2 -Nz = 17 * 6 - -# Get fission rates -with openmc.StatePoint("output_4.h5") as sp: - tally = sp.get_tally(name="pincell fission") - fissions = tally.mean.reshape((Nt, Nz, Ny, Nx)) - fissions = np.swapaxes(fissions, 1, 3) - - fissions_sd = tally.std_dev.reshape((Nt, Nz, Ny, Nx)) - fissions_sd = np.swapaxes(fissions_sd, 1, 3) - -# Relative stdevs -fissions_sd[fissions == 0.0] = 0.0 -non_zeros = fissions != 0.0 -fissions_sd[non_zeros] /= fissions[non_zeros] - -# Average relative stdev (in %) -fission_sd_avg = np.average(fissions_sd, axis=(1, 2, 3)) * 100.0 - -pitch = 1.26 -core_height = 128.52 -x = np.linspace(0.0, pitch * 17 * 2, Nx + 1) -y = np.linspace(-pitch * 17 * 2, 0.0, Ny + 1) -z = np.linspace(-core_height / 2, core_height / 2, Nz + 1) -t = np.linspace(0.0, 20.0, Nt + 1) - -# The grids -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("fission-sdev"): - shutil.rmtree("fission-sdev") # Remove the existing folder -os.makedirs("fission-sdev") # Create a new folder - -# Iterate over time step and create figures -N = len(fissions) -for i in range(N): - fission_sd = fissions_sd[i] - - # Calculate fission averages - fission_x_sd = np.average(fission_sd, axis=0) - fission_y_sd = np.average(fission_sd, axis=1) - fission_z_sd = np.average(fission_sd, axis=2) - - # Plot - fig = plt.figure(figsize=(8, 5)) - gs = gridspec.GridSpec( - 2, 3, width_ratios=[0.7, 1, 1], height_ratios=[1, 1], hspace=0.5 - ) - - ax1 = fig.add_subplot(gs[0, 0]) # Top-left - ax2 = fig.add_subplot(gs[1, 0]) # Bottom-left - ax3 = fig.add_subplot(gs[:, 1]) # Entire second column - ax4 = fig.add_subplot(gs[:, 2]) # Entire third column - - # Total fission curve - ax1.plot(t_mid, fission_sd_avg, "b") - ax1.set_yscale("log") - ax1.set_ylabel("Average relative sdev (%)") - ax1.set_xlabel("Time") - ax1.set_title("Average relative sdev") - # Total fission point - ax1.plot(t_mid[i], fission_sd_avg[i], "ro", fillstyle="none") - - # XY fission - ax2.pcolormesh(XY_X, XY_Y, fission_z_sd) - ax2.set_aspect("equal") - ax2.set_xlabel(r"$x$") - ax2.set_ylabel(r"$y$") - ax2.set_title("Fission-XY") - - # XZ fission - ax3.pcolormesh(XZ_X, XZ_Z, fission_y_sd) - ax3.set_aspect("equal") - ax3.set_xlabel(r"$x$") - ax3.set_ylabel(r"$z$") - ax3.set_title("Fission-XZ") - pos = ax3.get_position() - ax3.set_position( - [pos.x0 + 0.02, pos.y0, pos.width, pos.height] - ) # shift right by 0.02 - - # YZ fission - ax4.pcolormesh(YZ_Y, YZ_Z, fission_x_sd) - ax4.set_aspect("equal") - ax4.set_xlabel(r"$y$") - ax4.set_ylabel(r"$z$") - ax4.set_title("Fission-YZ") - - plt.suptitle("OpenMC result - Fission Rate Relative Sdev.") - plt.savefig( - f"fission-sdev/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0 - ) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/plot-fission.py b/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/plot-fission.py deleted file mode 100644 index fc4fa7f..0000000 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/openmc_/plot-fission.py +++ /dev/null @@ -1,101 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import os, openmc -import shutil - -Nt = 200 -Nx = 17 * 2 -Ny = 17 * 2 -Nz = 17 * 6 - -# Get fission rate -with openmc.StatePoint("output_4.h5") as sp: - tally = sp.get_tally(name="pincell fission") - fissions = tally.mean.reshape((Nt, Nz, Ny, Nx)) - fissions = np.swapaxes(fissions, 1, 3) - -# Total fission -fission_total = np.average(fissions, axis=(1, 2, 3)) -fission_total /= fission_total[0] - -pitch = 1.26 -core_height = 128.52 -x = np.linspace(0.0, pitch * 17 * 2, Nx + 1) -y = np.linspace(-pitch * 17 * 2, 0.0, Ny + 1) -z = np.linspace(-core_height / 2, core_height / 2, Nz + 1) -t = np.linspace(0.0, 20.0, Nt + 1) - -# The grids -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("fission"): - shutil.rmtree("fission") # Remove the existing folder -os.makedirs("fission") # Create a new folder - -# Iterate over time step and create figures -N = len(fissions) -for i in range(N): - fission = fissions[i] - - # Calculate fission averages - fission_x = np.average(fission, axis=0) - fission_y = np.average(fission, axis=1) - fission_z = np.average(fission, axis=2) - - # Plot - fig = plt.figure(figsize=(8, 5)) - gs = gridspec.GridSpec( - 2, 3, width_ratios=[0.7, 1, 1], height_ratios=[1, 1], hspace=0.5 - ) - - ax1 = fig.add_subplot(gs[0, 0]) # Top-left - ax2 = fig.add_subplot(gs[1, 0]) # Bottom-left - ax3 = fig.add_subplot(gs[:, 1]) # Entire second column - ax4 = fig.add_subplot(gs[:, 2]) # Entire third column - - # Total fission curve - ax1.plot(t_mid, fission_total, "b") - ax1.set_yscale("log") - ax1.set_ylabel("Total fission rate") - ax1.set_xlabel("Time") - ax1.set_title("Total fission rate") - # Total fission point - ax1.plot(t_mid[i], fission_total[i], "ro", fillstyle="none") - - # XY fission - ax2.pcolormesh(XY_X, XY_Y, fission_z) - ax2.set_aspect("equal") - ax2.set_xlabel(r"$x$") - ax2.set_ylabel(r"$y$") - ax2.set_title("Fission-XY") - - # XZ fission - ax3.pcolormesh(XZ_X, XZ_Z, fission_y) - ax3.set_aspect("equal") - ax3.set_xlabel(r"$x$") - ax3.set_ylabel(r"$z$") - ax3.set_title("Fission-XZ") - pos = ax3.get_position() - ax3.set_position( - [pos.x0 + 0.02, pos.y0, pos.width, pos.height] - ) # shift right by 0.02 - - # YZ fission - ax4.pcolormesh(YZ_Y, YZ_Z, fission_x) - ax4.set_aspect("equal") - ax4.set_xlabel(r"$y$") - ax4.set_ylabel(r"$z$") - ax4.set_title("Fission-YZ") - - plt.suptitle("OpenMC result - Fission Rate") - plt.savefig( - f"fission/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0 - ) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/plot-convergence.py b/verification/benchmark/neutron/multigroup/c5g7-4phase/plot-convergence.py deleted file mode 100644 index a50c4f7..0000000 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/plot-convergence.py +++ /dev/null @@ -1,66 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import numpy as np -import openmc -import os -import shutil - -N_list = np.array([100000, 316227, 1000000, 3162277, 10000000]) * 30 - -NN = len(N_list) - -Nt = 200 -Nx = 17 * 2 -Ny = 17 * 2 -Nz = 17 * 6 - -difference = np.zeros(NN) - -# Getting the reference -with h5py.File("mcdc/output_4.h5", "r") as f: - fission_mcdc = f["tallies/tracklength_tally_0/fission/mean"][()] - -with openmc.StatePoint("openmc_/output_4.h5") as sp: - tally = sp.get_tally(name="pincell fission") - fission_openmc = tally.mean.reshape((Nt, Nz, Ny, Nx)) - fission_openmc = np.swapaxes(fission_openmc, 1, 3) - -zero_mcdc = fission_mcdc == 0.0 -zero_openmc = fission_openmc == 0.0 -reference = 0.5 * (fission_mcdc + fission_openmc) -reference[zero_mcdc] = fission_openmc[zero_mcdc] -reference[zero_openmc] = fission_mcdc[zero_openmc] -non_zeros = reference != 0.0 -reference = reference[non_zeros] - -for n in range(NN): - # Get results - with h5py.File("mcdc/output_%i.h5" % n, "r") as f: - fission_mcdc = f["tallies/tracklength_tally_0/fission/mean"][()] - - # Get results - with openmc.StatePoint("openmc_/output_%i.h5" % n) as sp: - tally = sp.get_tally(name="pincell fission") - fission_openmc = tally.mean.reshape((Nt, Nz, Ny, Nx)) - fission_openmc = np.swapaxes(fission_openmc, 1, 3) - - difference[n] = np.linalg.norm( - (fission_mcdc[non_zeros] - fission_openmc[non_zeros]) / reference - ) - -plt.plot(N_list, difference, "bo", fillstyle="none") - -mid = int(len(N_list) / 2) -line = 1.0 / np.sqrt(N_list) -line *= difference[mid] / line[mid] -plt.plot(N_list, line, "r--", label=r"$O(N^{-0.5})$") - -plt.xscale("log") -plt.yscale("log") -plt.ylabel("2-norm of relative difference") -plt.xlabel(r"# of histories, $N$") -plt.grid() -plt.legend() -plt.title("Convergence of MC/DC and OpenMC relative difference") -plt.savefig(f"convergence.png", dpi=300, bbox_inches="tight", pad_inches=0) -plt.show() diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/plot-difference.py b/verification/benchmark/neutron/multigroup/c5g7-4phase/plot-difference.py deleted file mode 100644 index 649ebfe..0000000 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/plot-difference.py +++ /dev/null @@ -1,132 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import openmc -import os -import shutil - -from matplotlib.colors import TwoSlopeNorm - -# Get results -with h5py.File("mcdc/output_4.h5", "r") as f: - fissions_mcdc = f["tallies/tracklength_tally_0/fission/mean"][()] - x = f["tallies/tracklength_tally_0/grid/x"][()] - y = f["tallies/tracklength_tally_0/grid/y"][()] - z = f["tallies/tracklength_tally_0/grid/z"][()] - t = f["tallies/tracklength_tally_0/grid/time"][()] - -# Get results -Nt = 200 -Nx = 17 * 2 -Ny = 17 * 2 -Nz = 17 * 6 -with openmc.StatePoint("openmc_/output_4.h5") as sp: - tally = sp.get_tally(name="pincell fission") - fissions = tally.mean.reshape((Nt, Nz, Ny, Nx)) - fissions_openmc = np.swapaxes(fissions, 1, 3) - -# Average of relative difference -diff = np.zeros_like(fissions_mcdc) -num = abs(fissions_mcdc - fissions_openmc) -denom = 0.5 * (fissions_mcdc + fissions_openmc) -# -zero_mcdc = fissions_mcdc == 0.0 -zero_openmc = fissions_openmc == 0.0 -# -denom[zero_mcdc] = fissions_openmc[zero_mcdc] -denom[zero_openmc] = fissions_mcdc[zero_openmc] -# -idx = denom != 0.0 -diff[idx] = num[idx] / denom[idx] -diff_avg = np.average(diff, axis=(1, 2, 3)) * 100.0 # in % - -# The grids -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("differences"): - shutil.rmtree("differences") # Remove the existing folder -os.makedirs("differences") # Create a new folder - -# Iterate over time step and create figures -N = len(fissions_mcdc) -for i in range(N): - fission_mcdc = fissions_mcdc[i] - fission_openmc = fissions_openmc[i] - - # The difference - fission = np.zeros_like(fission_mcdc) - - num = fission_mcdc - fission_openmc - denom = 0.5 * (fission_mcdc + fission_openmc) - - zero_mcdc = fission_mcdc == 0.0 - zero_openmc = fission_openmc == 0.0 - - denom[zero_mcdc] = fission_openmc[zero_mcdc] - denom[zero_openmc] = fission_mcdc[zero_openmc] - - idx = denom != 0.0 - - fission[idx] = num[idx] / denom[idx] - - # Calculate fission averages - fission_x = np.average(fission, axis=0) - fission_y = np.average(fission, axis=1) - fission_z = np.average(fission, axis=2) - - # Plot - fig = plt.figure(figsize=(8, 5)) - gs = gridspec.GridSpec( - 2, 3, width_ratios=[0.7, 1, 1], height_ratios=[1, 1], hspace=0.5 - ) - - ax1 = fig.add_subplot(gs[0, 0]) # Top-left - ax2 = fig.add_subplot(gs[1, 0]) # Bottom-left - ax3 = fig.add_subplot(gs[:, 1]) # Entire second column - ax4 = fig.add_subplot(gs[:, 2]) # Entire third column - - # Total fission curve - ax1.plot(t_mid, diff_avg, "b") - ax1.set_yscale("log") - ax1.set_ylabel("Avg. relative diff. (%)") - ax1.set_xlabel("Time") - ax1.set_title("Avg. relative diff.") - # Total fission point - ax1.plot(t_mid[i], diff_avg[i], "ro", fillstyle="none") - - # XY fission - ax2.pcolormesh(XY_X, XY_Y, fission_z, cmap="RdBu_r", norm=TwoSlopeNorm(0)) - ax2.set_aspect("equal") - ax2.set_xlabel(r"$x$") - ax2.set_ylabel(r"$y$") - ax2.set_title("Fission-XY") - - # XZ fission - ax3.pcolormesh(XZ_X, XZ_Z, fission_y, cmap="RdBu_r", norm=TwoSlopeNorm(0)) - ax3.set_aspect("equal") - ax3.set_xlabel(r"$x$") - ax3.set_ylabel(r"$z$") - ax3.set_title("Fission-XZ") - pos = ax3.get_position() - ax3.set_position( - [pos.x0 + 0.02, pos.y0, pos.width, pos.height] - ) # shift right by 0.02 - - # YZ fission - ax4.pcolormesh(YZ_Y, YZ_Z, fission_x, cmap="RdBu_r", norm=TwoSlopeNorm(0)) - ax4.set_aspect("equal") - ax4.set_xlabel(r"$y$") - ax4.set_ylabel(r"$z$") - ax4.set_title("Fission-YZ") - - plt.suptitle("MC/DC and OpenMC relative difference") - plt.savefig( - f"differences/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0 - ) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/kobayashi/mcdc/plot-flux.py b/verification/benchmark/neutron/multigroup/kobayashi/mcdc/plot-flux.py deleted file mode 100644 index bd3ee6a..0000000 --- a/verification/benchmark/neutron/multigroup/kobayashi/mcdc/plot-flux.py +++ /dev/null @@ -1,81 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import os -import shutil - -# Get results -with h5py.File("output_4.h5", "r") as f: - fluxes = f["tallies/tracklength_tally_0/flux/mean"][()] - densities = f["tallies/tracklength_tally_0/density/mean"][()] - x = f["tallies/tracklength_tally_0/grid/x"][()] - y = f["tallies/tracklength_tally_0/grid/y"][()] - z = f["tallies/tracklength_tally_0/grid/z"][()] - t = f["tallies/tracklength_tally_0/grid/time"][()] - -# The grids -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("flux"): - shutil.rmtree("flux") # Remove the existing folder -os.makedirs("flux") # Create a new folder - -# Iterate over time step and create figures -N = len(fluxes) -for i in range(N): - flux = fluxes[i] - - # Calculate flux averages - flux_x = np.average(flux, axis=0) - flux_y = np.average(flux, axis=1) - flux_z = np.average(flux, axis=2) - - # Plot - fig, ax = plt.subplots( - 2, - 2, - figsize=(8, 6), - gridspec_kw={"width_ratios": [1, 2], "height_ratios": [1, 1], "hspace": 0.5}, - ) - - # Density curve - ax[0, 0].plot(t_mid, densities, "b") - ax[0, 0].set_yscale("log") - ax[0, 0].set_ylabel("Density") - ax[0, 0].set_xlabel("Time") - ax[0, 0].set_title("Density") - - # Density point - ax[0, 0].plot(t_mid[i], densities[i], "ro", fillstyle="none") - - # XY flux - ax[0, 1].pcolormesh(XY_Y, XY_X, flux_z) - ax[0, 1].set_aspect("equal") - ax[0, 1].set_xlabel(r"$y$") - ax[0, 1].set_ylabel(r"$x$") - ax[0, 1].invert_yaxis() - ax[0, 1].set_title("Flux-XY (Top View)") - - # XZ flux - ax[1, 0].pcolormesh(XZ_X, XZ_Z, flux_y) - ax[1, 0].set_aspect("equal") - ax[1, 0].set_xlabel(r"$x$") - ax[1, 0].set_ylabel(r"$z$") - ax[1, 0].set_title("Flux-XZ (Front View)") - - # YZ flux - ax[1, 1].pcolormesh(YZ_Y, YZ_Z, flux_x) - ax[1, 1].set_aspect("equal") - ax[1, 1].set_xlabel(r"$y$") - ax[1, 1].set_ylabel(r"$z$") - ax[1, 1].set_title("Flux-YZ (Side View)") - - plt.suptitle("MC/DC result") - plt.savefig(f"flux/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/kobayashi/mcdc/plot-sdev.py b/verification/benchmark/neutron/multigroup/kobayashi/mcdc/plot-sdev.py deleted file mode 100644 index 98c80ab..0000000 --- a/verification/benchmark/neutron/multigroup/kobayashi/mcdc/plot-sdev.py +++ /dev/null @@ -1,91 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import os -import shutil - -from PIL import Image - -# Get results -with h5py.File("output_4.h5", "r") as f: - fluxes = f["tallies/tracklength_tally_0/flux/mean"][()] - densities = f["tallies/tracklength_tally_0/density/mean"][()] - fluxes_sd = f["tallies/tracklength_tally_0/flux/sdev"][()] - densities_sd = f["tallies/tracklength_tally_0/density/sdev"][()] - x = f["tallies/tracklength_tally_0/grid/x"][()] - y = f["tallies/tracklength_tally_0/grid/y"][()] - z = f["tallies/tracklength_tally_0/grid/z"][()] - t = f["tallies/tracklength_tally_0/grid/time"][()] - -# The grids -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Relative stdevs -densities_sd /= densities -fluxes_sd[fluxes == 0.0] = 0.0 -non_zeros = fluxes != 0.0 -fluxes_sd[non_zeros] /= fluxes[non_zeros] - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("sdev"): - shutil.rmtree("sdev") # Remove the existing folder -os.makedirs("sdev") # Create a new folder - -# Iterate over time step and create figures -N = len(fluxes) -for i in range(N): - flux_sd = fluxes_sd[i] - - # Calculate flux averages - flux_x_sd = np.average(flux_sd, axis=0) - flux_y_sd = np.average(flux_sd, axis=1) - flux_z_sd = np.average(flux_sd, axis=2) - - # Plot - fig, ax = plt.subplots( - 2, - 2, - figsize=(8, 6), - gridspec_kw={"width_ratios": [1, 2], "height_ratios": [1, 1], "hspace": 0.5}, - ) - - # Density curve - ax[0, 0].plot(t_mid, densities_sd, "b") - ax[0, 0].set_yscale("log") - ax[0, 0].set_ylabel("Relative sdev.") - ax[0, 0].set_xlabel("Time") - ax[0, 0].set_title("Density") - - # Density point - ax[0, 0].plot(t_mid[i], densities_sd[i], "ro", fillstyle="none") - - # XY flux - ax[0, 1].pcolormesh(XY_Y, XY_X, flux_z_sd) - ax[0, 1].set_aspect("equal") - ax[0, 1].set_xlabel(r"$y$") - ax[0, 1].set_ylabel(r"$x$") - ax[0, 1].invert_yaxis() - ax[0, 1].set_title("Flux-XY (Top View)") - - # XZ flux - ax[1, 0].pcolormesh(XZ_X, XZ_Z, flux_y_sd) - ax[1, 0].set_aspect("equal") - ax[1, 0].set_xlabel(r"$x$") - ax[1, 0].set_ylabel(r"$z$") - ax[1, 0].set_title("Flux-XZ (Front View)") - - # YZ flux - ax[1, 1].pcolormesh(YZ_Y, YZ_Z, flux_x_sd) - ax[1, 1].set_aspect("equal") - ax[1, 1].set_xlabel(r"$y$") - ax[1, 1].set_ylabel(r"$z$") - ax[1, 1].set_title("Flux-YZ (Side View)") - - plt.suptitle("MC/DC relative standard deviation") - plt.savefig(f"sdev/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/kobayashi/openmc_/build-xml.py b/verification/benchmark/neutron/multigroup/kobayashi/openmc_/build-xml.py deleted file mode 100644 index 8d551f8..0000000 --- a/verification/benchmark/neutron/multigroup/kobayashi/openmc_/build-xml.py +++ /dev/null @@ -1,116 +0,0 @@ -import numpy as np - -import openmc - -############################################################################### -# Create materials for the problem - -# Instantiate some Macroscopic Data -shield_data = openmc.Macroscopic("Shield") -void_data = openmc.Macroscopic("Void") - -# Instantiate some Materials and register the appropriate Macroscopic objects -shield = openmc.Material(name="Shield") -shield.set_density("macro", 1.0) -shield.add_macroscopic(shield_data) - -void = openmc.Material(name="Void") -void.set_density("macro", 1.0) -void.add_macroscopic(void_data) - -# Instantiate a Materials collection and export to XML -materials_file = openmc.Materials([shield, void]) -materials_file.cross_sections = "mgxs.h5" -materials_file.export_to_xml() - -############################################################################### -# Define problem geometry - -# Create surfaces -sx1 = openmc.XPlane(0.0, boundary_type="reflective") -sx2 = openmc.XPlane(10.0) -sx3 = openmc.XPlane(30.0) -sx4 = openmc.XPlane(40.0) -sx5 = openmc.XPlane(60.0, boundary_type="vacuum") -sy1 = openmc.YPlane(0.0, boundary_type="reflective") -sy2 = openmc.YPlane(10.0) -sy3 = openmc.YPlane(50.0) -sy4 = openmc.YPlane(60.0) -sy5 = openmc.YPlane(100.0, boundary_type="vacuum") -sz1 = openmc.ZPlane(0.0, boundary_type="reflective") -sz2 = openmc.ZPlane(10.0) -sz3 = openmc.ZPlane(30.0) -sz4 = openmc.ZPlane(40.0) -sz5 = openmc.ZPlane(60.0, boundary_type="vacuum") - -# Instantiate Cells -# Soruce -S1 = openmc.Cell(region=+sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2, fill=shield) -# Voids -V1 = openmc.Cell(region=+sx1 & -sx2 & +sy2 & -sy3 & +sz1 & -sz2, fill=void) -V2 = openmc.Cell(region=+sx1 & -sx3 & +sy3 & -sy4 & +sz1 & -sz2, fill=void) -V3 = openmc.Cell(region=+sx3 & -sx4 & +sy3 & -sy4 & +sz1 & -sz3, fill=void) -V4 = openmc.Cell(region=+sx3 & -sx4 & +sy3 & -sy5 & +sz3 & -sz4, fill=void) -# Shield -Sh1 = openmc.Cell(region=+sx1 & -sx3 & +sy1 & -sy5 & +sz2 & -sz5, fill=shield) -Sh2 = openmc.Cell(region=+sx2 & -sx5 & +sy1 & -sy3 & +sz1 & -sz2, fill=shield) -Sh3 = openmc.Cell(region=+sx3 & -sx5 & +sy1 & -sy3 & +sz2 & -sz5, fill=shield) -Sh4 = openmc.Cell(region=+sx3 & -sx5 & +sy4 & -sy5 & +sz1 & -sz3, fill=shield) -Sh5 = openmc.Cell(region=+sx4 & -sx5 & +sy4 & -sy5 & +sz3 & -sz5, fill=shield) -Sh6 = openmc.Cell(region=+sx4 & -sx5 & +sy3 & -sy4 & +sz1 & -sz5, fill=shield) -Sh7 = openmc.Cell(region=+sx3 & -sx4 & +sy3 & -sy5 & +sz4 & -sz5, fill=shield) -Sh8 = openmc.Cell(region=+sx1 & -sx3 & +sy4 & -sy5 & +sz1 & -sz2, fill=shield) - -# Create a geometry with the two cells and export to XML -geometry = openmc.Geometry([S1, V1, V2, V3, V4, Sh1, Sh2, Sh3, Sh4, Sh5, Sh6, Sh7, Sh8]) -geometry.export_to_xml() - -############################################################################### -# Define problem settings - -# Instantiate a Settings object, set all runtime parameters, and export to XML -settings = openmc.Settings() -settings.run_mode = "fixed source" -settings.energy_mode = "multi-group" -settings.batches = 30 -settings.particles = 100000000 -settings.cutoff = {"time_neutron": 200} - -# Create an initial uniform spatial source distribution over fissionable zones -lower_left = (0.0, 0.0, 0.0) -upper_right = (10.0, 10.0, 10.0) -space = openmc.stats.Box((0.0, 0.0, 0.0), (10.0, 10.0, 10.0)) -time = openmc.stats.Uniform(0.0, 50.0) -energy = openmc.stats.Discrete([1.0], [1.0]) -settings.source = openmc.IndependentSource(space=space, time=time, energy=energy) -settings.output = {"tallies": False} -settings.export_to_xml() - -############################################################################### -# Define tallies - -# Create a mesh that will be used for tallying -mesh = openmc.RectilinearMesh() -mesh.x_grid = np.linspace(0.0, 60.0, 61) -mesh.y_grid = np.linspace(0.0, 100.0, 101) -mesh.z_grid = np.linspace(0.0, 60.0, 61) -time = np.linspace(0.0, 200.0, 101) - -# Create a mesh filter that can be used in a tally -time_mesh_filter = openmc.TimedMeshFilter(mesh, time) -time_filter = openmc.TimeFilter(time) - -# Now use the mesh filter in a tally and indicate what scores are desired -mesh_tally = openmc.Tally(name="flux") -mesh_tally.filters = [time_mesh_filter] -mesh_tally.estimator = "tracklength" -mesh_tally.scores = ["flux"] - -density_tally = openmc.Tally(name="density") -density_tally.filters = [time_filter] -density_tally.estimator = "tracklength" -density_tally.scores = ["inverse-velocity"] - -# Instantiate a Tallies collection and export to XML -tallies = openmc.Tallies([mesh_tally, density_tally]) -tallies.export_to_xml() diff --git a/verification/benchmark/neutron/multigroup/kobayashi/openmc_/get_reference.py b/verification/benchmark/neutron/multigroup/kobayashi/openmc_/get_reference.py deleted file mode 100644 index 18f6bdc..0000000 --- a/verification/benchmark/neutron/multigroup/kobayashi/openmc_/get_reference.py +++ /dev/null @@ -1,8 +0,0 @@ -import os - -# Install reference files from Zenodo -os.system("wget https://zenodo.org/records/15069882/files/output_0.h5") -os.system("wget https://zenodo.org/records/15069882/files/output_1.h5") -os.system("wget https://zenodo.org/records/15069882/files/output_2.h5") -os.system("wget https://zenodo.org/records/15069882/files/output_3.h5") -os.system("wget https://zenodo.org/records/15069882/files/output_4.h5") diff --git a/verification/benchmark/neutron/multigroup/kobayashi/openmc_/plot-flux.py b/verification/benchmark/neutron/multigroup/kobayashi/openmc_/plot-flux.py deleted file mode 100644 index cb50675..0000000 --- a/verification/benchmark/neutron/multigroup/kobayashi/openmc_/plot-flux.py +++ /dev/null @@ -1,86 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import openmc -import os -import shutil - -# Get results -with openmc.StatePoint("output_4.h5") as sp: - tally = sp.get_tally(scores=["flux"]) - fluxes = tally.mean.reshape((100, 60, 100, 60)) - fluxes = np.swapaxes(fluxes, 1, 3) - tally = sp.get_tally(scores=["inverse-velocity"]) - densities = tally.mean.reshape((100)) - -# The grids -with h5py.File("../mcdc/output.h5", "r") as f: - x = f["tallies/tracklength_tally_0/grid/x"][()] - y = f["tallies/tracklength_tally_0/grid/y"][()] - z = f["tallies/tracklength_tally_0/grid/z"][()] - t = f["tallies/tracklength_tally_0/grid/time"][()] -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("flux"): - shutil.rmtree("flux") # Remove the existing folder -os.makedirs("flux") # Create a new folder - -# Iterate over time step and create figures -N = len(fluxes) -for i in range(N): - flux = fluxes[i] - - # Calculate flux averages - flux_x = np.average(flux, axis=0) - flux_y = np.average(flux, axis=1) - flux_z = np.average(flux, axis=2) - - # Plot - fig, ax = plt.subplots( - 2, - 2, - figsize=(8, 6), - gridspec_kw={"width_ratios": [1, 2], "height_ratios": [1, 1], "hspace": 0.5}, - ) - - # Density curve - ax[0, 0].plot(t_mid, densities, "b") - ax[0, 0].set_yscale("log") - ax[0, 0].set_ylabel("Density") - ax[0, 0].set_xlabel("Time") - ax[0, 0].set_title("Density") - - # Density point - ax[0, 0].plot(t_mid[i], densities[i], "ro", fillstyle="none") - - # XY flux - ax[0, 1].pcolormesh(XY_Y, XY_X, flux_z) - ax[0, 1].set_aspect("equal") - ax[0, 1].set_xlabel(r"$y$") - ax[0, 1].set_ylabel(r"$x$") - ax[0, 1].invert_yaxis() - ax[0, 1].set_title("Flux-XY (Top View)") - - # XZ flux - ax[1, 0].pcolormesh(XZ_X, XZ_Z, flux_y) - ax[1, 0].set_aspect("equal") - ax[1, 0].set_xlabel(r"$x$") - ax[1, 0].set_ylabel(r"$z$") - ax[1, 0].set_title("Flux-XZ (Front View)") - - # YZ flux - ax[1, 1].pcolormesh(YZ_Y, YZ_Z, flux_x) - ax[1, 1].set_aspect("equal") - ax[1, 1].set_xlabel(r"$y$") - ax[1, 1].set_ylabel(r"$z$") - ax[1, 1].set_title("Flux-YZ (Side View)") - - plt.suptitle("OpenMC result") - plt.savefig(f"flux/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/kobayashi/openmc_/plot-sdev.py b/verification/benchmark/neutron/multigroup/kobayashi/openmc_/plot-sdev.py deleted file mode 100644 index 0374730..0000000 --- a/verification/benchmark/neutron/multigroup/kobayashi/openmc_/plot-sdev.py +++ /dev/null @@ -1,95 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import openmc -import os -import shutil - -# Get results -with openmc.StatePoint("output_4.h5") as sp: - tally = sp.get_tally(scores=["flux"]) - fluxes = tally.mean.reshape((100, 60, 100, 60)) - fluxes = np.swapaxes(fluxes, 1, 3) - fluxes_sd = tally.std_dev.reshape((100, 60, 100, 60)) - fluxes_sd = np.swapaxes(fluxes_sd, 1, 3) - tally = sp.get_tally(scores=["inverse-velocity"]) - densities = tally.mean.reshape((100)) - densities_sd = tally.std_dev.reshape((100)) - -# The grids -with h5py.File("../mcdc/output.h5", "r") as f: - x = f["tallies/tracklength_tally_0/grid/x"][()] - y = f["tallies/tracklength_tally_0/grid/y"][()] - z = f["tallies/tracklength_tally_0/grid/z"][()] - t = f["tallies/tracklength_tally_0/grid/time"][()] -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Relative stdevs -densities_sd /= densities -fluxes_sd[fluxes == 0.0] = 0.0 -non_zeros = fluxes != 0.0 -fluxes_sd[non_zeros] /= fluxes[non_zeros] - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("sdev"): - shutil.rmtree("sdev") # Remove the existing folder -os.makedirs("sdev") # Create a new folder - -# Iterate over time step and create figures -N = len(fluxes) -for i in range(N): - flux_sd = fluxes_sd[i] - - # Calculate flux averages - flux_x_sd = np.average(flux_sd, axis=0) - flux_y_sd = np.average(flux_sd, axis=1) - flux_z_sd = np.average(flux_sd, axis=2) - - # Plot - fig, ax = plt.subplots( - 2, - 2, - figsize=(8, 6), - gridspec_kw={"width_ratios": [1, 2], "height_ratios": [1, 1], "hspace": 0.5}, - ) - - # Density curve - ax[0, 0].plot(t_mid, densities_sd, "b") - ax[0, 0].set_yscale("log") - ax[0, 0].set_ylabel("Relative sdev.") - ax[0, 0].set_xlabel("Time") - ax[0, 0].set_title("Density") - - # Density point - ax[0, 0].plot(t_mid[i], densities_sd[i], "ro", fillstyle="none") - - # XY flux - ax[0, 1].pcolormesh(XY_Y, XY_X, flux_z_sd) - ax[0, 1].set_aspect("equal") - ax[0, 1].set_xlabel(r"$y$") - ax[0, 1].set_ylabel(r"$x$") - ax[0, 1].invert_yaxis() - ax[0, 1].set_title("Flux-XY (Top View)") - - # XZ flux - ax[1, 0].pcolormesh(XZ_X, XZ_Z, flux_y_sd) - ax[1, 0].set_aspect("equal") - ax[1, 0].set_xlabel(r"$x$") - ax[1, 0].set_ylabel(r"$z$") - ax[1, 0].set_title("Flux-XZ (Front View)") - - # YZ flux - ax[1, 1].pcolormesh(YZ_Y, YZ_Z, flux_x_sd) - ax[1, 1].set_aspect("equal") - ax[1, 1].set_xlabel(r"$y$") - ax[1, 1].set_ylabel(r"$z$") - ax[1, 1].set_title("Flux-YZ (Side View)") - - plt.suptitle("OpenMC relative standard deviation") - plt.savefig(f"sdev/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/kobayashi/plot-convergence.py b/verification/benchmark/neutron/multigroup/kobayashi/plot-convergence.py deleted file mode 100644 index 23aacde..0000000 --- a/verification/benchmark/neutron/multigroup/kobayashi/plot-convergence.py +++ /dev/null @@ -1,59 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import numpy as np -import openmc -import os -import shutil - -N_list = np.array([100000000, 316227766, 1000000000, 3162277660, 10000000000]) * 30 - -NN = len(N_list) - -difference = np.zeros(NN) - -# Getting the reference -with h5py.File("mcdc/output_4.h5", "r") as f: - flux_mcdc = f["tallies/tracklength_tally_0/flux/mean"][()] -with openmc.StatePoint("openmc_/output_4.h5") as sp: - tally = sp.get_tally(scores=["flux"]) - flux_openmc = tally.mean.reshape((100, 60, 100, 60)) - flux_openmc = np.swapaxes(flux_openmc, 1, 3) -zero_mcdc = flux_mcdc == 0.0 -zero_openmc = flux_openmc == 0.0 -reference = 0.5 * (flux_mcdc + flux_openmc) -reference[zero_mcdc] = flux_openmc[zero_mcdc] -reference[zero_openmc] = flux_mcdc[zero_openmc] -non_zeros = reference != 0.0 -reference = reference[non_zeros] - -for n in range(NN): - # Get results - with h5py.File("mcdc/output_%i.h5" % n, "r") as f: - flux_mcdc = f["tallies/tracklength_tally_0/flux/mean"][()] - - # Get results - with openmc.StatePoint("openmc_/output_%i.h5" % n) as sp: - tally = sp.get_tally(scores=["flux"]) - flux_openmc = tally.mean.reshape((100, 60, 100, 60)) - flux_openmc = np.swapaxes(flux_openmc, 1, 3) - - difference[n] = np.linalg.norm( - (flux_mcdc[non_zeros] - flux_openmc[non_zeros]) / reference - ) - -plt.plot(N_list, difference, "bo", fillstyle="none") - -mid = int(len(N_list) / 2) -line = 1.0 / np.sqrt(N_list) -line *= difference[mid] / line[mid] -plt.plot(N_list, line, "r--", label=r"$O(N^{-0.5})$") - -plt.xscale("log") -plt.yscale("log") -plt.ylabel("2-norm of relative difference") -plt.xlabel(r"# of histories, $N$") -plt.grid() -plt.legend() -plt.title("Convergence of MC/DC and OpenMC relative difference") -plt.savefig(f"convergence.png", dpi=300, bbox_inches="tight", pad_inches=0) -plt.show() diff --git a/verification/benchmark/neutron/multigroup/kobayashi/plot-difference.py b/verification/benchmark/neutron/multigroup/kobayashi/plot-difference.py deleted file mode 100644 index 4a07163..0000000 --- a/verification/benchmark/neutron/multigroup/kobayashi/plot-difference.py +++ /dev/null @@ -1,115 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec -import numpy as np -import openmc -import os -import shutil - -from matplotlib.colors import TwoSlopeNorm - -# Get results -with h5py.File("mcdc/output_4.h5", "r") as f: - fluxes_mcdc = f["tallies/tracklength_tally_0/flux/mean"][()] - densities_mcdc = f["tallies/tracklength_tally_1/density/mean"][()] - x = f["tallies/tracklength_tally_0/grid/x"][()] - y = f["tallies/tracklength_tally_0/grid/y"][()] - z = f["tallies/tracklength_tally_0/grid/z"][()] - t = f["tallies/tracklength_tally_0/grid/time"][()] - -# Get results -with openmc.StatePoint("openmc_/output_4.h5") as sp: - tally = sp.get_tally(scores=["flux"]) - fluxes_openmc = tally.mean.reshape((100, 60, 100, 60)) - fluxes_openmc = np.swapaxes(fluxes_openmc, 1, 3) - tally = sp.get_tally(scores=["inverse-velocity"]) - densities_openmc = tally.mean.reshape((100)) - -densities = abs(densities_mcdc - densities_openmc) / ( - 0.5 * (densities_mcdc + densities_openmc) -) - -# The grids -t_mid = 0.5 * (t[:-1] + t[1:]) -XY_X, XY_Y = np.meshgrid(x, y, indexing="ij") -XZ_X, XZ_Z = np.meshgrid(x, z, indexing="ij") -YZ_Y, YZ_Z = np.meshgrid(y, z, indexing="ij") - -# Create clean folder for output figures -# Check if the folder exists -if os.path.exists("differences"): - shutil.rmtree("differences") # Remove the existing folder -os.makedirs("differences") # Create a new folder - -# Iterate over time step and create figures -N = len(fluxes_mcdc) -for i in range(N): - flux_mcdc = fluxes_mcdc[i] - flux_openmc = fluxes_openmc[i] - - # The difference - flux = np.zeros_like(flux_mcdc) - - num = flux_mcdc - flux_openmc - denom = 0.5 * (flux_mcdc + flux_openmc) - - zero_mcdc = flux_mcdc == 0.0 - zero_openmc = flux_openmc == 0.0 - - denom[zero_mcdc] = flux_openmc[zero_mcdc] - denom[zero_openmc] = flux_mcdc[zero_openmc] - - idx = denom != 0.0 - - flux[idx] = num[idx] / denom[idx] - - # Calculate flux averages - flux_x = np.average(flux, axis=0) - flux_y = np.average(flux, axis=1) - flux_z = np.average(flux, axis=2) - - # Plot - fig, ax = plt.subplots( - 2, - 2, - figsize=(8, 6), - gridspec_kw={"width_ratios": [1, 2], "height_ratios": [1, 1], "hspace": 0.5}, - ) - - # Density curve - ax[0, 0].plot(t_mid, densities, "b") - ax[0, 0].set_yscale("log") - ax[0, 0].set_ylabel("Relative difference") - ax[0, 0].set_xlabel("Time") - ax[0, 0].set_title("Density") - - # Density point - ax[0, 0].plot(t_mid[i], densities[i], "ro", fillstyle="none") - - # XY flux - ax[0, 1].pcolormesh(XY_Y, XY_X, flux_z, cmap="RdBu_r", norm=TwoSlopeNorm(0)) - ax[0, 1].set_aspect("equal") - ax[0, 1].set_xlabel(r"$y$") - ax[0, 1].set_ylabel(r"$x$") - ax[0, 1].invert_yaxis() - ax[0, 1].set_title("Flux-XY (Top View)") - - # XZ flux - ax[1, 0].pcolormesh(XZ_X, XZ_Z, flux_y, cmap="RdBu_r", norm=TwoSlopeNorm(0)) - ax[1, 0].set_aspect("equal") - ax[1, 0].set_xlabel(r"$x$") - ax[1, 0].set_ylabel(r"$z$") - ax[1, 0].set_title("Flux-XZ (Front View)") - - # YZ flux - ax[1, 1].pcolormesh(YZ_Y, YZ_Z, flux_x, cmap="RdBu_r", norm=TwoSlopeNorm(0)) - ax[1, 1].set_aspect("equal") - ax[1, 1].set_xlabel(r"$y$") - ax[1, 1].set_ylabel(r"$z$") - ax[1, 1].set_title("Flux-YZ (Side View)") - - plt.suptitle("MC/DC and OpenMC relative difference") - plt.savefig( - f"differences/figure_{i:03}.png", dpi=300, bbox_inches="tight", pad_inches=0 - ) - plt.close() diff --git a/verification/benchmark/neutron/multigroup/task.yaml b/verification/benchmark/neutron/multigroup/task.yaml deleted file mode 100644 index e971972..0000000 --- a/verification/benchmark/neutron/multigroup/task.yaml +++ /dev/null @@ -1,9 +0,0 @@ -kobayashi: - logN_min: 8 - logN_max: 10 - N_runs: 5 - -c5g7-4phase: - logN_min: 5 - logN_max: 7 - N_runs: 5 diff --git a/verification/code_to_code/neutron/.gitignore b/verification/code_to_code/neutron/.gitignore new file mode 100644 index 0000000..154bd29 --- /dev/null +++ b/verification/code_to_code/neutron/.gitignore @@ -0,0 +1,7 @@ +maestro_run_*/ +results/ +study.yaml + +cases/*/output*.h5 +cases/*/reference/ + diff --git a/verification/code_to_code/neutron/README.md b/verification/code_to_code/neutron/README.md new file mode 100644 index 0000000..8808a54 --- /dev/null +++ b/verification/code_to_code/neutron/README.md @@ -0,0 +1,138 @@ +# Neutron Code-to-Code Verification + +This suite compares neutron transport results from multiple independently implemented codes. +The current participating codes are MC/DC and OpenMC, and the workflow is intended to accommodate additional codes. +It exercises time-dependent multigroup transport, moving geometry, delayed neutrons, void streaming, and multidimensional space-time tallies. +The arithmetic mean of the participating code estimates at the largest sampling level defines the fixed comparison reference for every level. +The reported metrics are the pair-averaged L2 norm and maximum absolute pairwise relative difference normalized by that fixed reference. +Verification checks for $N^{-1/2}$ decay in both metrics as the participating calculations scale their sampling efforts together. +Observing that rate supports that the codes are converging statistically toward the same solution, while a plateau can indicate bias or a modeling discrepancy. +Agreement does not by itself establish correctness because independently implemented codes can still share a common bias. + +The suite can be executed independently or as part of the top-level MC/DC-VVP workflow. + +## Directory layout + +```text +cases/ Verification case definitions and processing scripts +data/ Shared multigroup cross-section data +maestro_run_*/ Generated Maestro workflow directories +results/ + launch_config.yaml Effective suite launch configuration + task.yaml Task-generation configuration used by the launch + convergence/ Statistical-convergence figures + comparison/ Largest-sample comparison and difference animations + +task.yaml Configure task generation for each case +study.yaml Generated Maestro study definition + +launch.py Build and launch the Maestro study +run_case.py Run the tasks for one case +process.py Process a completed Maestro study + +cleanup.py Remove generated outputs and figures +util.py Provide shared processing and plotting utilities +``` + +Each case contains a common set of files: + +```text +input.py Define and run the MC/DC model +reference.py Download and verify the participating OpenMC outputs +process.py Evaluate code-to-code convergence +plot.py Animate one MC/DC and OpenMC result pair +``` + +## Configuration + +The `task.yaml` file selects the cases and defines the particle-count range and number of tasks for each case. +One task is one MC/DC execution at one generated `N_particle` value, paired during processing with the corresponding result from every participating code. +Each case defines a `walltime_factor` that scales the launch-level walltime for HPC execution. +The base walltime is specified in hours, and the scaled value is rounded up to the scheduler resolution and limited by the platform maximum. +For example, a base of `1.5` hours gives C5G7 1 hour 30 minutes and Kobayashi 45 minutes. +Local execution ignores walltime. +Each case uses 30 batches, matching the corresponding OpenMC campaign. +The total number of particle histories shown during processing is therefore the particle count per batch multiplied by 30. +The largest particle-count task supplies the participating results used to construct the fixed comparison reference. + +The OpenMC statepoints are canonical external comparison data because they are too large to track in this repository. +Download and checksum them from their Zenodo records before processing: + +```bash +python cases/c5g7-4phase/reference.py +python cases/kobayashi/reference.py +``` + +HPC runs use the shared platform settings in the repository's `configs/platform_config.py` and the user-specific settings in `configs/user_config.py`. +The `N_node` option sets the number of nodes, with all available CPU cores used on each node. + +## Launching and processing + +From this suite directory, launch the study locally: + +```bash +python launch.py +``` + +Launch the study on a supported HPC platform: + +```bash +python launch.py --platform tuolumne --N_node 1 +``` + +Use `--walltime HOURS` to set the base walltime. +Cases with all five MC/DC outputs are omitted from the Maestro study, while partially complete cases run only their missing particle levels. +Run `python cleanup.py` before launching to remove existing case outputs and start the suite fresh. + +After all jobs have completed and the OpenMC comparison data have been downloaded, process the latest Maestro run: + +```bash +python process.py +``` + +Pass a Maestro run directory to process a specific run: + +```bash +python process.py maestro_run_ +``` + +Convergence figures are written to `results/convergence/`. +Animated spatial comparisons and relative-difference evolution at the largest shared sample size are written to `results/comparison/`. +Each case produces `comparison.gif` for the participating-code solutions and `difference.gif` for their relative differences. +The top-level `process.py` collects these figures under the repository's `results/` directory. + +Each case's `plot.py` can inspect one MC/DC and OpenMC result pair using the largest-sample pair as the fixed comparison reference: + +```bash +python cases/kobayashi/plot.py \ + cases/kobayashi/output_100000000.h5 \ + cases/kobayashi/reference/output_0.h5 \ + cases/kobayashi/output_10000000000.h5 \ + cases/kobayashi/reference/output_4.h5 +``` + +## Cases + +| Case | Description | +| :--- | :---------- | +| [`c5g7-4phase`](cases/c5g7-4phase/) | Four-phase C5G7 transient with a pulsed source and continuously moving control rods. | +| [`kobayashi`](cases/kobayashi/) | Pulsed three-dimensional Kobayashi dog-leg shielding problem with a void channel. | + +### Four-phase C5G7 transient + +This seven-group problem adapts the heterogeneous C5G7-TD core into four source- and control-rod-driven phases over 20 seconds. +The quantity of interest is the pin-pitch-resolved space-time fission-rate distribution. +The MC/DC model uses the canonical cross sections in `data/MGXS-C5G7.h5`, while the OpenMC statepoints are provided by the associated Zenodo record. + +### Time-dependent Kobayashi dog-leg + +This one-group problem adapts the steady-state Kobayashi shielding benchmark by pulsing the source through a three-dimensional dog-leg void channel. +The quantities of interest are the space-time flux distribution and total neutron density in time. +The OpenMC statepoints are provided by the associated Zenodo record. + +## References + +- I. Variansyah, [*Four-Phase C5G7 Transient Benchmark for Neutron Transport*](https://doi.org/10.5281/zenodo.15719118), Zenodo, 2025. +- I. Variansyah, [*Time-Dependent Kobayashi Dog-Leg Benchmark for Neutron Transport*](https://doi.org/10.5281/zenodo.15069882), Zenodo, 2025. +- J. Hou, K. N. Ivanov, V. F. Boyarinov, and P. A. Fomichenko, [*OECD/NEA Benchmark for Time-Dependent Neutron Transport Calculations Without Spatial Homogenization*](https://doi.org/10.1016/j.nucengdes.2017.02.008), Nuclear Engineering and Design, vol. 317, pp. 177–189, 2017. +- K. Kobayashi, N. Sugimura, and Y. Nagaya, [*3D Radiation Transport Benchmark Problems and Results for Simple Geometries with Void Region*](https://doi.org/10.1016/S0149-1970(01)00007-5), Progress in Nuclear Energy, vol. 39, no. 2, pp. 119–144, 2001. diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/input.py b/verification/code_to_code/neutron/cases/c5g7-4phase/input.py similarity index 98% rename from verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/input.py rename to verification/code_to_code/neutron/cases/c5g7-4phase/input.py index fb2e5f9..9458a8f 100644 --- a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/input.py +++ b/verification/code_to_code/neutron/cases/c5g7-4phase/input.py @@ -1,4 +1,4 @@ -import math +from pathlib import Path import h5py import numpy as np @@ -11,8 +11,9 @@ # Materials # ============================================================================= -# Load material data -lib = h5py.File("MGXS-C5G7.h5", "r") +# Load the suite's canonical C5G7 multigroup cross sections. +DATA_FILE = Path(__file__).resolve().parents[2] / "data" / "MGXS-C5G7.h5" +lib = h5py.File(DATA_FILE, "r") # Setter @@ -39,6 +40,7 @@ def set_mat(mat): mat_fc = set_mat(lib["fc"]) # Fission chamber mat_cr = set_mat(lib["cr"]) # Control rod mat_mod = set_mat(lib["mod"]) # Moderator +lib.close() # ============================================================================= # Pin cells diff --git a/verification/code_to_code/neutron/cases/c5g7-4phase/plot.py b/verification/code_to_code/neutron/cases/c5g7-4phase/plot.py new file mode 100644 index 0000000..e589796 --- /dev/null +++ b/verification/code_to_code/neutron/cases/c5g7-4phase/plot.py @@ -0,0 +1,66 @@ +"""Animate one C5G7 MC/DC and OpenMC result pair.""" + +import argparse + +import h5py +import numpy as np + +from process import load_mcdc_fission, load_openmc_fission +from util import ( + animate_spatial_comparison, + animate_spatial_difference, + comparison_reference, + relative_difference, +) + +parser = argparse.ArgumentParser() +parser.add_argument("mcdc_output") +parser.add_argument("openmc_output") +parser.add_argument("mcdc_reference_output") +parser.add_argument("openmc_reference_output") +args = parser.parse_args() + +mcdc_reference, _ = load_mcdc_fission(args.mcdc_reference_output) +openmc_reference = load_openmc_fission(args.openmc_reference_output) +reference = comparison_reference(mcdc_reference, openmc_reference) +del mcdc_reference, openmc_reference + +with h5py.File(args.mcdc_output, "r") as f: + fission_mcdc = f["tallies/tracklength_tally_0/fission/mean"][:] + time = f["tallies/tracklength_tally_0/grid/time"][:] + x = f["tallies/tracklength_tally_0/grid/x"][:] + y = f["tallies/tracklength_tally_0/grid/y"][:] + z = f["tallies/tracklength_tally_0/grid/z"][:] + +fission_openmc = load_openmc_fission(args.openmc_output) +time_mid = 0.5 * (time[:-1] + time[1:]) + +animate_spatial_comparison( + "fission", + time_mid, + (x, y, z), + fission_mcdc, + fission_openmc, + "MC/DC", + "OpenMC", +) + +rms_difference = np.empty(len(time_mid)) +for index in range(len(time_mid)): + difference = relative_difference( + reference[index], + fission_mcdc[index], + fission_openmc[index], + ) + rms_difference[index] = np.sqrt(np.mean(np.square(difference))) + +animate_spatial_difference( + "fission", + time_mid, + (x, y, z), + reference, + fission_mcdc, + fission_openmc, + rms_difference, + "RMS relative difference (%)", +) diff --git a/verification/code_to_code/neutron/cases/c5g7-4phase/process.py b/verification/code_to_code/neutron/cases/c5g7-4phase/process.py new file mode 100644 index 0000000..6c813d9 --- /dev/null +++ b/verification/code_to_code/neutron/cases/c5g7-4phase/process.py @@ -0,0 +1,89 @@ +"""Process the C5G7 particle-count study with archived OpenMC results.""" + +import argparse +import sys +from pathlib import Path + +import h5py +import numpy as np + +SUITE_DIR = Path(__file__).resolve().parents[2] +if str(SUITE_DIR) not in sys.path: + sys.path.insert(0, str(SUITE_DIR)) + +from util import ( + comparison_reference, + load_openmc_tally, + particle_counts, + plot_convergence, + relative_difference_metrics, + require_reference_files, +) + +N_TIME = 200 +N_X = 17 * 2 +N_Y = 17 * 2 +N_Z = 17 * 6 + + +def load_mcdc_fission(path): + """Load the MC/DC space-time fission mesh and batch count.""" + with h5py.File(path, "r") as f: + fission = f["tallies/tracklength_tally_0/fission/mean"][:] + N_batch = int(f["settings/N_batch"][()]) + return fission, N_batch + + +def load_openmc_fission(path): + """Load the OpenMC space-time fission mesh in MC/DC axis order.""" + fission = load_openmc_tally(path, "pincell fission").reshape( + (N_TIME, N_Z, N_Y, N_X) + ) + return np.swapaxes(fission, 1, 3) + + +def main(): + """Process every particle-count task and generate the convergence figure.""" + parser = argparse.ArgumentParser() + parser.add_argument("logN_min", type=float) + parser.add_argument("logN_max", type=float) + parser.add_argument("N_task", type=int) + args = parser.parse_args() + + case_dir = Path(__file__).resolve().parent + N_particle = particle_counts(args.logN_min, args.logN_max, args.N_task) + reference_files = require_reference_files(case_dir, args.N_task) + difference_l2 = np.zeros(args.N_task) + difference_max = np.zeros(args.N_task) + + mcdc_reference, N_batch = load_mcdc_fission( + case_dir / f"output_{int(N_particle[-1])}.h5" + ) + openmc_reference = load_openmc_fission(reference_files[-1]) + reference = comparison_reference(mcdc_reference, openmc_reference) + del mcdc_reference, openmc_reference + + for index, (count, reference_file) in enumerate(zip(N_particle, reference_files)): + mcdc_fission, current_N_batch = load_mcdc_fission( + case_dir / f"output_{int(count)}.h5" + ) + openmc_fission = load_openmc_fission(reference_file) + difference_l2[index], difference_max[index] = relative_difference_metrics( + reference, + mcdc_fission, + openmc_fission, + ) + + if current_N_batch != N_batch: + raise ValueError("All MC/DC outputs must use the same number of batches.") + + plot_convergence( + "fission", + N_particle * N_batch, + difference_l2, + difference_max, + ) + + +if __name__ == "__main__": + main() diff --git a/verification/code_to_code/neutron/cases/c5g7-4phase/reference.py b/verification/code_to_code/neutron/cases/c5g7-4phase/reference.py new file mode 100644 index 0000000..75b26ca --- /dev/null +++ b/verification/code_to_code/neutron/cases/c5g7-4phase/reference.py @@ -0,0 +1,59 @@ +"""Download the canonical OpenMC reference outputs for the C5G7 case.""" + +import hashlib +import urllib.request +from pathlib import Path + +RECORD_ID = "15719118" +DOI = "10.5281/zenodo.15719118" +CHECKSUMS = { + "output_0.h5": "d1c14f5174ab77b7a92d33cbf3378fba", + "output_1.h5": "2c8adc956831a9d536e0455b7b2094af", + "output_2.h5": "c40b4bbf34f92686594767e42e993b01", + "output_3.h5": "d32b2e9fb039f2ac71b489d3d2b4cffc", + "output_4.h5": "6cfbeedad4bae898b9b9678340312d44", +} + + +def checksum(path): + """Return the MD5 checksum used by the Zenodo record.""" + digest = hashlib.md5() + with path.open("rb") as f: + for block in iter(lambda: f.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def download_references(): + """Download and verify every OpenMC statepoint used by this case.""" + reference_dir = Path(__file__).resolve().parent / "reference" + reference_dir.mkdir(exist_ok=True) + + for filename, expected_checksum in CHECKSUMS.items(): + destination = reference_dir / filename + + if destination.is_file() and checksum(destination) == expected_checksum: + print(f"Reference is current: {destination}") + continue + + temporary = reference_dir / f"{filename}.part" + temporary.unlink(missing_ok=True) + url = f"https://zenodo.org/records/{RECORD_ID}/files/{filename}?download=1" + + print(f"Downloading {url}") + urllib.request.urlretrieve(url, temporary) + + actual_checksum = checksum(temporary) + if actual_checksum != expected_checksum: + temporary.unlink() + raise ValueError( + f"Checksum mismatch for {filename}: " + f"expected {expected_checksum}, got {actual_checksum}" + ) + + temporary.replace(destination) + print(f"Installed reference: {destination}") + + +if __name__ == "__main__": + download_references() diff --git a/verification/benchmark/neutron/multigroup/kobayashi/mcdc/input.py b/verification/code_to_code/neutron/cases/kobayashi/input.py similarity index 92% rename from verification/benchmark/neutron/multigroup/kobayashi/mcdc/input.py rename to verification/code_to_code/neutron/cases/kobayashi/input.py index 83ae74b..86cd2e3 100644 --- a/verification/benchmark/neutron/multigroup/kobayashi/mcdc/input.py +++ b/verification/code_to_code/neutron/cases/kobayashi/input.py @@ -48,7 +48,7 @@ # ====================================================================================== # Set source # ====================================================================================== -# The source pulses in t=[0,5] +# The source pulses over t=[0, 50]. source = mcdc.Source( x=[0.0, 10.0], @@ -66,15 +66,17 @@ # Tallies time_grid = np.linspace(0.0, 200.0, 101) -# mesh = mcdc.MeshUniform(x=(0.0, 1.0, 60), y=(0.0, 1.0, 100), z=(0.0, 1.0, 60)) -mesh = mcdc.MeshUniform(x=(0.0, 1.0, 60)) +mesh = mcdc.MeshUniform( + x=(0.0, 1.0, 60), + y=(0.0, 1.0, 100), + z=(0.0, 1.0, 60), +) flux_tally = mcdc.Tally(mesh=mesh, scores=["flux"], time=time_grid) density_tally = mcdc.Tally(scores=["density"], time=time_grid) simulation.set_tallies([flux_tally, density_tally]) # Settings -# simulation.settings.N_particle = int(1e9) -simulation.settings.N_particle = int(1e2) +simulation.settings.N_particle = 100 simulation.settings.N_batch = 30 # Run diff --git a/verification/code_to_code/neutron/cases/kobayashi/plot.py b/verification/code_to_code/neutron/cases/kobayashi/plot.py new file mode 100644 index 0000000..7eb3329 --- /dev/null +++ b/verification/code_to_code/neutron/cases/kobayashi/plot.py @@ -0,0 +1,74 @@ +"""Animate one Kobayashi MC/DC and OpenMC result pair.""" + +import argparse + +import h5py +import numpy as np + +from process import load_mcdc_results, load_openmc_results +from util import ( + animate_spatial_comparison, + animate_spatial_difference, + comparison_reference, + relative_difference, +) + +parser = argparse.ArgumentParser() +parser.add_argument("mcdc_output") +parser.add_argument("openmc_output") +parser.add_argument("mcdc_reference_output") +parser.add_argument("openmc_reference_output") +args = parser.parse_args() + +mcdc_flux_reference, mcdc_density_reference, _ = load_mcdc_results( + args.mcdc_reference_output +) +openmc_flux_reference, openmc_density_reference = load_openmc_results( + args.openmc_reference_output +) +reference = comparison_reference(mcdc_flux_reference, openmc_flux_reference) +reference_density = comparison_reference( + mcdc_density_reference, + openmc_density_reference, +) +del ( + mcdc_flux_reference, + mcdc_density_reference, + openmc_flux_reference, + openmc_density_reference, +) + +with h5py.File(args.mcdc_output, "r") as f: + flux_mcdc = f["tallies/tracklength_tally_0/flux/mean"][:] + density_mcdc = f["tallies/tracklength_tally_1/density/mean"][:] + time = f["tallies/tracklength_tally_0/grid/time"][:] + x = f["tallies/tracklength_tally_0/grid/x"][:] + y = f["tallies/tracklength_tally_0/grid/y"][:] + z = f["tallies/tracklength_tally_0/grid/z"][:] + +flux_openmc, density_openmc = load_openmc_results(args.openmc_output) +time_mid = 0.5 * (time[:-1] + time[1:]) + +animate_spatial_comparison( + "flux", + time_mid, + (x, y, z), + flux_mcdc, + flux_openmc, + "MC/DC", + "OpenMC", +) + +density_difference = np.abs( + relative_difference(reference_density, density_mcdc, density_openmc) +) +animate_spatial_difference( + "flux", + time_mid, + (x, y, z), + reference, + flux_mcdc, + flux_openmc, + density_difference, + "Density relative difference (%)", +) diff --git a/verification/code_to_code/neutron/cases/kobayashi/process.py b/verification/code_to_code/neutron/cases/kobayashi/process.py new file mode 100644 index 0000000..cca6bb6 --- /dev/null +++ b/verification/code_to_code/neutron/cases/kobayashi/process.py @@ -0,0 +1,121 @@ +"""Process the Kobayashi particle-count study with archived OpenMC results.""" + +import argparse +import sys +from pathlib import Path + +import h5py +import numpy as np + +SUITE_DIR = Path(__file__).resolve().parents[2] +if str(SUITE_DIR) not in sys.path: + sys.path.insert(0, str(SUITE_DIR)) + +from util import ( + comparison_reference, + load_openmc_tally, + particle_counts, + plot_convergence, + relative_difference_metrics, + require_reference_files, +) + +N_TIME = 100 +N_X = 60 +N_Y = 100 +N_Z = 60 + + +def load_mcdc_results(path): + """Load the MC/DC flux, density, and batch count.""" + with h5py.File(path, "r") as f: + flux = f["tallies/tracklength_tally_0/flux/mean"][:] + density = f["tallies/tracklength_tally_1/density/mean"][:] + N_batch = int(f["settings/N_batch"][()]) + return flux, density, N_batch + + +def load_openmc_results(path): + """Load the OpenMC flux and density in MC/DC axis order.""" + flux = load_openmc_tally(path, "flux").reshape((N_TIME, N_Z, N_Y, N_X)) + density = load_openmc_tally(path, "density").reshape(N_TIME) + return np.swapaxes(flux, 1, 3), density + + +def main(): + """Process every particle-count task and generate the convergence figures.""" + parser = argparse.ArgumentParser() + parser.add_argument("logN_min", type=float) + parser.add_argument("logN_max", type=float) + parser.add_argument("N_task", type=int) + args = parser.parse_args() + + case_dir = Path(__file__).resolve().parent + N_particle = particle_counts(args.logN_min, args.logN_max, args.N_task) + reference_files = require_reference_files(case_dir, args.N_task) + flux_difference_l2 = np.zeros(args.N_task) + flux_difference_max = np.zeros(args.N_task) + density_difference_l2 = np.zeros(args.N_task) + density_difference_max = np.zeros(args.N_task) + + mcdc_flux_reference, mcdc_density_reference, N_batch = load_mcdc_results( + case_dir / f"output_{int(N_particle[-1])}.h5" + ) + openmc_flux_reference, openmc_density_reference = load_openmc_results( + reference_files[-1] + ) + flux_reference = comparison_reference( + mcdc_flux_reference, + openmc_flux_reference, + ) + density_reference = comparison_reference( + mcdc_density_reference, + openmc_density_reference, + ) + del ( + mcdc_flux_reference, + mcdc_density_reference, + openmc_flux_reference, + openmc_density_reference, + ) + + for index, (count, reference_file) in enumerate(zip(N_particle, reference_files)): + mcdc_flux, mcdc_density, current_N_batch = load_mcdc_results( + case_dir / f"output_{int(count)}.h5" + ) + openmc_flux, openmc_density = load_openmc_results(reference_file) + flux_difference_l2[index], flux_difference_max[index] = ( + relative_difference_metrics( + flux_reference, + mcdc_flux, + openmc_flux, + ) + ) + density_difference_l2[index], density_difference_max[index] = ( + relative_difference_metrics( + density_reference, + mcdc_density, + openmc_density, + ) + ) + + if current_N_batch != N_batch: + raise ValueError("All MC/DC outputs must use the same number of batches.") + + N_history = N_particle * N_batch + plot_convergence( + "flux", + N_history, + flux_difference_l2, + flux_difference_max, + ) + plot_convergence( + "density", + N_history, + density_difference_l2, + density_difference_max, + ) + + +if __name__ == "__main__": + main() diff --git a/verification/code_to_code/neutron/cases/kobayashi/reference.py b/verification/code_to_code/neutron/cases/kobayashi/reference.py new file mode 100644 index 0000000..bf6d49b --- /dev/null +++ b/verification/code_to_code/neutron/cases/kobayashi/reference.py @@ -0,0 +1,59 @@ +"""Download the canonical OpenMC reference outputs for the Kobayashi case.""" + +import hashlib +import urllib.request +from pathlib import Path + +RECORD_ID = "15069882" +DOI = "10.5281/zenodo.15069882" +CHECKSUMS = { + "output_0.h5": "09c9bb21ba70eaaca75d8e212ff09dca", + "output_1.h5": "dedb2019a27fde10aa70af955720d227", + "output_2.h5": "176ad33ba2dd3e8bbc48306a0c7cd173", + "output_3.h5": "852e33bd30e7401b08d98ab0ca11965d", + "output_4.h5": "936593f835c6127955723ff7a39d1352", +} + + +def checksum(path): + """Return the MD5 checksum used by the Zenodo record.""" + digest = hashlib.md5() + with path.open("rb") as f: + for block in iter(lambda: f.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def download_references(): + """Download and verify every OpenMC statepoint used by this case.""" + reference_dir = Path(__file__).resolve().parent / "reference" + reference_dir.mkdir(exist_ok=True) + + for filename, expected_checksum in CHECKSUMS.items(): + destination = reference_dir / filename + + if destination.is_file() and checksum(destination) == expected_checksum: + print(f"Reference is current: {destination}") + continue + + temporary = reference_dir / f"{filename}.part" + temporary.unlink(missing_ok=True) + url = f"https://zenodo.org/records/{RECORD_ID}/files/{filename}?download=1" + + print(f"Downloading {url}") + urllib.request.urlretrieve(url, temporary) + + actual_checksum = checksum(temporary) + if actual_checksum != expected_checksum: + temporary.unlink() + raise ValueError( + f"Checksum mismatch for {filename}: " + f"expected {expected_checksum}, got {actual_checksum}" + ) + + temporary.replace(destination) + print(f"Installed reference: {destination}") + + +if __name__ == "__main__": + download_references() diff --git a/verification/code_to_code/neutron/cleanup.py b/verification/code_to_code/neutron/cleanup.py new file mode 100644 index 0000000..1b18d2f --- /dev/null +++ b/verification/code_to_code/neutron/cleanup.py @@ -0,0 +1,23 @@ +"""Remove generated neutron code-to-code outputs and figures.""" + +import shutil +from pathlib import Path + +suite_dir = Path(__file__).resolve().parent + +cases_dir = suite_dir / "cases" + +# Remove simulation outputs and any figures left by interrupted processing. +for pattern in ("*/output*.h5", "*/*.png", "*/*.gif"): + for generated_file in cases_dir.glob(pattern): + generated_file.unlink() + +# Remove the complete processed-results hierarchy. +results_dir = suite_dir / "results" +if results_dir.is_dir(): + shutil.rmtree(results_dir) + +# Remove generated Maestro workflow directories. +for maestro_run in suite_dir.glob("maestro_run_*"): + if maestro_run.is_dir(): + shutil.rmtree(maestro_run) diff --git a/verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/MGXS-C5G7.h5 b/verification/code_to_code/neutron/data/MGXS-C5G7.h5 similarity index 100% rename from verification/benchmark/neutron/multigroup/c5g7-4phase/mcdc/MGXS-C5G7.h5 rename to verification/code_to_code/neutron/data/MGXS-C5G7.h5 diff --git a/verification/code_to_code/neutron/launch.py b/verification/code_to_code/neutron/launch.py new file mode 100644 index 0000000..e635173 --- /dev/null +++ b/verification/code_to_code/neutron/launch.py @@ -0,0 +1,296 @@ +"""Build and launch the neutron code-to-code verification study with Maestro.""" + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +import yaml + +# ====================================================================================== +# Bootstrap VVP imports +# ====================================================================================== + +REPO_DIR = Path(__file__).resolve().parents[3] + +# Support launching this suite directly without installing MC/DC-VVP. +if str(REPO_DIR) not in sys.path: + sys.path.insert(0, str(REPO_DIR)) + + +# ====================================================================================== +# Load shared VVP configs +# ====================================================================================== + +from configs.platform_config import PLATFORMS +from configs.util import case_outputs_complete, get_case_walltime +from util import particle_counts + +# User overrides are optional for local runs. +try: + from configs.user_config import USER_CONFIG +except ImportError: + USER_CONFIG = {} + + +# ====================================================================================== +# Command-line arguments +# ====================================================================================== + +parser = argparse.ArgumentParser( + description="Launch the MC/DC VVP neutron code-to-code suite." +) +parser.add_argument("--platform", default="local", choices=["local"] + list(PLATFORMS)) +parser.add_argument( + "--N_node", + type=int, + default=1, + help="Set the number of compute nodes.", +) +parser.add_argument( + "--walltime", + type=float, + default=None, + help="Set the base walltime in hours; each case scales it by walltime_factor.", +) +args = parser.parse_args() + +if args.N_node < 1: + parser.error("--N_node must be at least one.") + +if args.platform == "local" and args.N_node != 1: + parser.error("Local execution supports only --N_node 1.") + + +# ====================================================================================== +# Paths +# ====================================================================================== + +suite_dir = Path(__file__).resolve().parent +task_file = suite_dir / "task.yaml" +run_case = suite_dir / "run_case.py" +study_file = suite_dir / "study.yaml" + + +# ====================================================================================== +# Platform settings +# ====================================================================================== + +local = args.platform == "local" +user_platform_config = USER_CONFIG.get(args.platform, {}) + +mcdc_python = user_platform_config.get("mcdc_python") + +# Use the active interpreter unless this platform specifies another MC/DC environment. +if mcdc_python is None: + mcdc_python = sys.executable +else: + mcdc_python = str(Path(mcdc_python).expanduser()) + +if not local: + platform = PLATFORMS[args.platform] + scheduler = platform["scheduler"] + cpu_cores = platform["cpu_cores_per_node"] + + if args.N_node > platform["max_nodes"]: + parser.error( + f"--N_node exceeds the {platform['max_nodes']}-node limit for " + f"{args.platform}." + ) + + account = user_platform_config.get("account") + queue = user_platform_config.get("queue") + reservation = user_platform_config.get("reservation") + + if account is None: + raise ValueError( + f"Platform '{args.platform}' requires an account. " + "Create configs/user_config.py from configs/user_config.py.template." + ) + + +# ====================================================================================== +# Load tasks +# ====================================================================================== + +with task_file.open("r") as f: + tasks = yaml.safe_load(f) + + +# ====================================================================================== +# Build Maestro study +# ====================================================================================== + +# Convert each configured case into one independent Maestro step. +steps = [] +case_walltimes = {} +skipped_cases = [] + +for case_name, task in tasks.items(): + case_dir = suite_dir / "cases" / case_name + counts = particle_counts(task["logN_min"], task["logN_max"], task["N_task"]) + + # Do not allocate a Maestro step when every sampling level is already present. + if case_outputs_complete(case_dir, counts): + skipped_cases.append(case_name) + print(f"Skip complete case: {case_name}") + continue + + # Normalize case names into stable Maestro step identifiers. + safe_case_name = case_name.replace("-", "_") + + command = f"{mcdc_python} {run_case} --name {case_name}" + + # Maestro replaces LAUNCHER with the scheduler-specific process launcher. + if not local: + command += ' --launcher "$(LAUNCHER)"' + + run = {"cmd": command} + + # Scheduled studies require explicit resources; local studies run directly. + if not local: + run["nodes"] = args.N_node + walltime = get_case_walltime(task, platform, args.walltime) + run["walltime"] = walltime + case_walltimes[case_name] = walltime + run["procs"] = args.N_node * cpu_cores + run["exclusive"] = True + + steps.append( + { + "name": safe_case_name, + "description": f"Run case: {case_name}", + "run": run, + } + ) + +if not steps: + print("All configured cases are complete; nothing to launch.") + raise SystemExit(0) + +# Assemble the complete Maestro study from the generated case steps. +study = { + "description": { + "name": "maestro_run", + "description": "MC/DC verification - neutron code-to-code suite", + }, + "env": { + "variables": {}, + }, + "study": steps, +} + +if not local: + # Attach batch settings only when Maestro submits to a scheduler. + batch = { + "type": scheduler, + "host": platform["host"], + "bank": account, + } + + if queue is not None: + batch["queue"] = queue + + if reservation is not None: + batch["reservation"] = reservation + + study["batch"] = batch + + +# ====================================================================================== +# Write Maestro study +# ====================================================================================== + +with study_file.open("w") as f: + yaml.dump(study, f, sort_keys=False) + + +# ====================================================================================== +# Launch Maestro +# ====================================================================================== + +maestro_python = None + +if not local: + maestro_python = user_platform_config.get("maestro_python") + +env = os.environ.copy() + +# Use the configured Maestro environment when it differs from the active one. +if maestro_python is None: + maestro_command = ["maestro", "run", "study.yaml"] +else: + maestro_python = Path(maestro_python).expanduser() + maestro_bin = maestro_python.parent + # Keep executables spawned by Maestro in the same configured environment. + env["PATH"] = f"{maestro_bin}:{env['PATH']}" + + maestro_command = [ + str(maestro_python), + "-m", + "maestrowf.maestro", + "run", + "study.yaml", + ] + +subprocess.run(maestro_command, cwd=suite_dir, check=True, env=env) + + +# ====================================================================================== +# Store launch metadata +# ====================================================================================== + +# Maestro creates timestamped run directories, so capture the newly generated launch. +maestro_runs = sorted( + suite_dir.glob("maestro_run_*"), + key=lambda path: path.stat().st_mtime, +) + +if not maestro_runs: + raise RuntimeError("Maestro did not create a maestro_run_* directory.") + +latest_run = maestro_runs[-1] + +launch_config = { + "platform": args.platform, + "scheduler": "local" if local else scheduler, + "N_node": args.N_node, + "N_process": 1 if local else args.N_node * cpu_cores, + "walltime": args.walltime, + "case_walltimes": case_walltimes, + "mcdc_python": mcdc_python, +} + +# Snapshot the effective launch and task configuration with the generated run. +with (latest_run / "launch_config.yaml").open("w") as f: + yaml.dump(launch_config, f, sort_keys=False) + +with task_file.open("r") as f: + task_config = yaml.safe_load(f) + +with (latest_run / "task.yaml").open("w") as f: + yaml.dump(task_config, f, sort_keys=False) + + +# ====================================================================================== +# Summary +# ====================================================================================== + +print(f"Platform : {args.platform}") +print(f"Nodes : {args.N_node}") +print(f"Python : {mcdc_python}") +print(f"Study : {study_file}") + +if not local: + print(f"Scheduler: {scheduler}") + print(f"Account : {account}") + print(f"Queue : {queue}") + print(f"Reserv. : {reservation}") + print("Walltimes:") + for case_name, walltime in case_walltimes.items(): + print(f" {case_name}: {walltime}") + print(f"Procs : {args.N_node * cpu_cores}") + +print(f"Cases : {len(steps)}") +print(f"Skipped : {len(skipped_cases)}") diff --git a/verification/code_to_code/neutron/process.py b/verification/code_to_code/neutron/process.py new file mode 100644 index 0000000..6600a9b --- /dev/null +++ b/verification/code_to_code/neutron/process.py @@ -0,0 +1,165 @@ +"""Process a completed neutron code-to-code Maestro study.""" + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +import yaml + +from util import particle_counts, require_reference_files + +# ====================================================================================== +# Command-line arguments +# ====================================================================================== + +parser = argparse.ArgumentParser( + description="Process the MC/DC VVP neutron code-to-code suite." +) +parser.add_argument( + "maestro_run", + nargs="?", + default=None, + help="Maestro run directory to process. Defaults to the latest maestro_run_*.", +) +args = parser.parse_args() + + +# ====================================================================================== +# Helper functions +# ====================================================================================== + + +def clear_case_figures(case_dir): + """Remove figures left by an earlier processing run.""" + for pattern in ("*.png", "*.gif"): + for figure in case_dir.glob(pattern): + figure.unlink() + + +def collect_case_figures(case_dir, destination, case_name): + """Move generated case figures into a named suite results directory.""" + for pattern in ("*.png", "*.gif"): + for figure in case_dir.glob(pattern): + figure.replace(destination / f"{case_name}_{figure.name}") + + +# ====================================================================================== +# Paths +# ====================================================================================== + +suite_dir = Path(__file__).resolve().parent + +if args.maestro_run is None: + maestro_runs = sorted( + suite_dir.glob("maestro_run_*"), + key=lambda path: path.stat().st_mtime, + ) + + if not maestro_runs: + raise FileNotFoundError("No maestro_run_* directory found.") + + maestro_run = maestro_runs[-1] +else: + maestro_run = Path(args.maestro_run).expanduser() + + if not maestro_run.is_absolute(): + maestro_run = suite_dir / maestro_run + +launch_config_file = maestro_run / "launch_config.yaml" +task_file = maestro_run / "task.yaml" + +if not launch_config_file.is_file(): + raise FileNotFoundError(f"Launch config not found: {launch_config_file}") + +if not task_file.is_file(): + raise FileNotFoundError(f"Task config not found: {task_file}") + + +# ====================================================================================== +# Load configuration and tasks +# ====================================================================================== + +with launch_config_file.open("r") as f: + launch_config = yaml.safe_load(f) + +with task_file.open("r") as f: + tasks = yaml.safe_load(f) + +results_dir = suite_dir / "results" +convergence_dir = results_dir / "convergence" +comparison_dir = results_dir / "comparison" + +# Start with an empty suite results hierarchy on every processing run. +if results_dir.is_dir(): + shutil.rmtree(results_dir) + +convergence_dir.mkdir(parents=True, exist_ok=True) +comparison_dir.mkdir(parents=True, exist_ok=True) + +# Keep the effective launch and task definitions beside the processed figures. +shutil.copy2(launch_config_file, results_dir / "launch_config.yaml") +shutil.copy2(task_file, results_dir / "task.yaml") + + +# ====================================================================================== +# Process cases +# ====================================================================================== + +for case_name, task in tasks.items(): + case_dir = suite_dir / "cases" / case_name + process_script = case_dir / "process.py" + + if not process_script.is_file(): + print(f"Skipping {case_name}: no process.py") + continue + + print(f"Processing {case_name}") + clear_case_figures(case_dir) + + # Generate and collect convergence figures over the particle-count study. + subprocess.run( + [ + sys.executable, + str(process_script), + str(task["logN_min"]), + str(task["logN_max"]), + str(task["N_task"]), + ], + cwd=case_dir, + check=True, + ) + collect_case_figures(case_dir, convergence_dir, case_name) + + # Compare the participating codes at the largest shared sample size. + counts = particle_counts(task["logN_min"], task["logN_max"], task["N_task"]) + mcdc_output = case_dir / f"output_{int(counts[-1])}.h5" + reference_output = require_reference_files(case_dir, task["N_task"])[-1] + plot_script = case_dir / "plot.py" + subprocess.run( + [ + sys.executable, + str(plot_script), + str(mcdc_output), + str(reference_output), + str(mcdc_output), + str(reference_output), + ], + cwd=case_dir, + check=True, + ) + collect_case_figures(case_dir, comparison_dir, case_name) + + +# ====================================================================================== +# Summary +# ====================================================================================== + +print() +print(f"Maestro run: {maestro_run}") +print(f"Platform : {launch_config['platform']}") +print(f"Nodes : {launch_config['N_node']}") +print(f"Processes : {launch_config['N_process']}") +print(f"Cases : {len(tasks)}") +print("Processing complete.") diff --git a/verification/code_to_code/neutron/run_case.py b/verification/code_to_code/neutron/run_case.py new file mode 100644 index 0000000..6cfe0d0 --- /dev/null +++ b/verification/code_to_code/neutron/run_case.py @@ -0,0 +1,93 @@ +"""Run one neutron code-to-code case over its particle-count study.""" + +import argparse +import subprocess +import sys +from pathlib import Path + +import yaml + +from util import particle_counts + +# ====================================================================================== +# Command-line arguments +# ====================================================================================== + +parser = argparse.ArgumentParser( + description="Run one MC/DC VVP neutron code-to-code case." +) +parser.add_argument("--name", required=True, help="Verification case name.") +parser.add_argument("--task-file", default="task.yaml") +parser.add_argument( + "--launcher", + default="", + help="Process launch command supplied by Maestro.", +) +args = parser.parse_args() + + +# ====================================================================================== +# Paths +# ====================================================================================== + +suite_dir = Path(__file__).resolve().parent +case_dir = suite_dir / "cases" / args.name +task_file = suite_dir / args.task_file + +if not case_dir.is_dir(): + raise FileNotFoundError(f"Case directory not found: {case_dir}") + + +# ====================================================================================== +# Load task definition +# ====================================================================================== + +with task_file.open("r") as f: + tasks = yaml.safe_load(f) + +if args.name not in tasks: + raise ValueError(f"Case '{args.name}' is not listed in {task_file}") + +task = tasks[args.name] + + +# ====================================================================================== +# Run particle-count tasks +# ====================================================================================== + +for N_particle in particle_counts( + task["logN_min"], + task["logN_max"], + task["N_task"], +): + N_particle = int(N_particle) + + output = f"output_{N_particle}" + output_file = case_dir / f"{output}.h5" + + if output_file.is_file(): + print(f"Skip existing output: {args.name}, N={N_particle}") + continue + + command = ( + f"{args.launcher} {sys.executable} input.py " + f"--mode=numba " + f"--N_particle={N_particle} " + f"--output={output} " + "--no-progress_bar " + "--caching" + ).strip() + + print("=" * 80) + print(f"Case : {args.name}") + print(f"Particles per batch : {N_particle}") + print(f"Python : {sys.executable}") + print(f"Command : {command}") + print("=" * 80) + + subprocess.run( + command, + shell=True, + cwd=case_dir, + check=True, + ) diff --git a/verification/code_to_code/neutron/task.yaml b/verification/code_to_code/neutron/task.yaml new file mode 100644 index 0000000..f245d38 --- /dev/null +++ b/verification/code_to_code/neutron/task.yaml @@ -0,0 +1,11 @@ +c5g7-4phase: + logN_min: 5 + logN_max: 7 + N_task: 5 + walltime_factor: 6.0 + +kobayashi: + logN_min: 8 + logN_max: 10 + N_task: 5 + walltime_factor: 1.0 diff --git a/verification/code_to_code/neutron/util.py b/verification/code_to_code/neutron/util.py new file mode 100644 index 0000000..77baabb --- /dev/null +++ b/verification/code_to_code/neutron/util.py @@ -0,0 +1,361 @@ +"""Shared processing helpers for neutron code-to-code cases.""" + +from pathlib import Path + +import h5py +import matplotlib.animation as animation +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.colors import LogNorm, Normalize + + +def particle_counts(logN_min, logN_max, N_task): + """Return logarithmically spaced particles per batch for a case study.""" + return np.logspace(logN_min, logN_max, N_task, dtype=int) + + +def require_reference_files(case_dir, N_task): + """Return the expected reference files or explain how to obtain them.""" + reference_files = [ + case_dir / "reference" / f"output_{index}.h5" for index in range(N_task) + ] + missing = [path for path in reference_files if not path.is_file()] + + if missing: + raise FileNotFoundError( + f"Missing {len(missing)} reference output(s) for {case_dir.name}. " + f"Run: python {case_dir / 'reference.py'}" + ) + + return reference_files + + +def load_openmc_tally(path, name): + """Load a named tally mean directly from an OpenMC statepoint.""" + with h5py.File(path, "r") as statepoint: + tallies = statepoint["tallies"] + + for key, tally in tallies.items(): + if not key.startswith("tally ") or "name" not in tally: + continue + + tally_name = tally["name"][()] + if isinstance(tally_name, bytes): + tally_name = tally_name.decode() + + if tally_name != name: + continue + + N_realization = int(tally["n_realizations"][()]) + tally_sum = tally["results"][..., 0] + return np.asarray(tally_sum).reshape(-1) / N_realization + + raise KeyError(f"OpenMC tally '{name}' not found in {path}") + + +def comparison_reference(*results): + """Return the arithmetic mean of the participating code estimates.""" + if len(results) < 2: + raise ValueError("At least two code estimates are required.") + + results = tuple(np.asarray(result) for result in results) + if any(result.shape != results[0].shape for result in results[1:]): + raise ValueError("All code estimates must have the same shape.") + + dtype = np.result_type(*(result.dtype for result in results), np.float64) + reference = np.zeros_like(results[0], dtype=dtype) + for result in results: + reference += result + return reference / len(results) + + +def relative_difference(reference, first, second): + """Return the pointwise difference between two codes relative to a reference.""" + reference = np.asarray(reference) + first = np.asarray(first) + second = np.asarray(second) + + if first.shape != reference.shape or second.shape != reference.shape: + raise ValueError("The reference and code estimates must have the same shape.") + + difference = np.zeros_like(reference, dtype=np.float64) + nonzero = np.abs(reference) > 0.0 + difference[nonzero] = (first[nonzero] - second[nonzero]) / reference[nonzero] + return difference + + +def relative_difference_metrics(reference, *results): + """Return pairwise L2 and maximum differences against a fixed reference.""" + reference = np.asarray(reference) + results = tuple(np.asarray(result) for result in results) + + if len(results) < 2: + raise ValueError("At least two code estimates are required.") + if any(result.shape != reference.shape for result in results): + raise ValueError("The reference and code estimates must have the same shape.") + + nonzero = np.abs(reference) > 0.0 + + if not np.any(nonzero): + return 0.0, 0.0 + + squared_norms = [] + maximum = 0.0 + for first in range(len(results) - 1): + for second in range(first + 1, len(results)): + difference = ( + results[first][nonzero] - results[second][nonzero] + ) / reference[nonzero] + squared_norms.append(np.linalg.norm(difference) ** 2) + maximum = max(maximum, float(np.max(np.abs(difference)))) + + return np.sqrt(np.mean(squared_norms)), maximum + + +def relative_difference_l2(reference, *results): + """Return the pair-averaged L2 norm against a fixed comparison reference.""" + return relative_difference_metrics(reference, *results)[0] + + +def relative_difference_max(reference, *results): + """Return the maximum absolute pairwise difference against a fixed reference.""" + return relative_difference_metrics(reference, *results)[1] + + +def _spatial_projections(result): + """Integrate a time-dependent 3D field into its three planar projections.""" + result = np.asarray(result) + if result.ndim != 4: + raise ValueError("A space-time result must have shape (time, x, y, z).") + + return ( + np.sum(result, axis=3), + np.sum(result, axis=2), + np.sum(result, axis=1), + ) + + +def _positive_norm(*results): + """Return a shared logarithmic scale for nonnegative code estimates.""" + maximum = max(float(np.nanmax(result)) for result in results) + if maximum <= 0.0: + return Normalize(vmin=0.0, vmax=1.0) + + minima = [ + float(np.nanmin(result[result > 0.0])) + for result in results + if np.any(result > 0.0) + ] + minimum = max(min(minima), maximum * 1.0e-6) + if minimum >= maximum: + minimum = 0.5 * maximum + return LogNorm(vmin=minimum, vmax=maximum) + + +def animate_spatial_comparison( + score, + time, + spatial_edges, + first, + second, + first_label, + second_label, + filename="comparison.gif", +): + """Animate orthogonal projections of two participating-code estimates.""" + time = np.asarray(time) + x, y, z = (np.asarray(edges) for edges in spatial_edges) + first_projections = _spatial_projections(first) + second_projections = _spatial_projections(second) + projection_data = ( + ("XY", x, y, first_projections[0], second_projections[0]), + ("XZ", x, z, first_projections[1], second_projections[1]), + ("YZ", y, z, first_projections[2], second_projections[2]), + ) + + fig, axes = plt.subplots(2, 3, figsize=(12, 7), constrained_layout=True) + images = [] + for column, (plane, horizontal, vertical, first_data, second_data) in enumerate( + projection_data + ): + norm = _positive_norm(first_data, second_data) + first_image = axes[0, column].imshow( + first_data[0].T, + extent=(horizontal[0], horizontal[-1], vertical[0], vertical[-1]), + origin="lower", + aspect="auto", + cmap="viridis", + norm=norm, + ) + second_image = axes[1, column].imshow( + second_data[0].T, + extent=(horizontal[0], horizontal[-1], vertical[0], vertical[-1]), + origin="lower", + aspect="auto", + cmap="viridis", + norm=norm, + ) + axes[0, column].set_title(f"{first_label} {plane}") + axes[1, column].set_title(f"{second_label} {plane}") + axes[1, column].set_xlabel(plane[0].lower()) + axes[0, column].set_ylabel(plane[1].lower()) + axes[1, column].set_ylabel(plane[1].lower()) + fig.colorbar(first_image, ax=axes[:, column], label=score.capitalize()) + images.append((first_image, second_image, first_data, second_data)) + + title = fig.suptitle(f"{score.capitalize()} comparison, t = {time[0]:.3g}") + + def update(frame): + for first_image, second_image, first_data, second_data in images: + first_image.set_data(first_data[frame].T) + second_image.set_data(second_data[frame].T) + title.set_text(f"{score.capitalize()} comparison, t = {time[frame]:.3g}") + return [title, *(image for pair in images for image in pair[:2])] + + simulation = animation.FuncAnimation(fig, update, frames=len(time)) + simulation.save( + filename, + writer=animation.PillowWriter(fps=max(2, len(time) // 10)), + dpi=120, + ) + plt.close(fig) + + +def animate_spatial_difference( + score, + time, + spatial_edges, + reference, + first, + second, + metric, + metric_label, + filename="difference.gif", +): + """Animate the history and spatial projections of code-to-code differences.""" + time = np.asarray(time) + metric = 100.0 * np.asarray(metric) + x, y, z = (np.asarray(edges) for edges in spatial_edges) + + reference_projections = _spatial_projections(reference) + first_projections = _spatial_projections(first) + second_projections = _spatial_projections(second) + differences = tuple( + 100.0 * relative_difference(current_reference, current_first, current_second) + for current_reference, current_first, current_second in zip( + reference_projections, + first_projections, + second_projections, + ) + ) + projection_data = ( + ("XY", x, y, differences[0]), + ("XZ", x, z, differences[1]), + ("YZ", y, z, differences[2]), + ) + + percentiles = [ + float(np.nanpercentile(np.abs(difference), 99.0)) for difference in differences + ] + color_limit = min(100.0, max(1.0, *percentiles)) + norm = Normalize(vmin=-color_limit, vmax=color_limit) + + fig, axes = plt.subplots(2, 2, figsize=(12, 9), constrained_layout=True) + metric_axis = axes[0, 0] + positive_metric = np.where(metric > 0.0, metric, np.nan) + metric_axis.plot(time, positive_metric, "b") + marker = metric_axis.plot([], [], "ro", fillstyle="none")[0] + if np.any(metric > 0.0): + metric_axis.set_yscale("log") + metric_axis.set_xlabel("Time") + metric_axis.set_ylabel(metric_label) + metric_axis.grid() + + images = [] + for axis, (plane, horizontal, vertical, difference) in zip( + (axes[0, 1], axes[1, 0], axes[1, 1]), projection_data + ): + image = axis.imshow( + difference[0].T, + extent=(horizontal[0], horizontal[-1], vertical[0], vertical[-1]), + origin="lower", + aspect="auto", + cmap="RdBu_r", + norm=norm, + ) + axis.set_title(f"{score.capitalize()}-{plane}") + axis.set_xlabel(plane[0].lower()) + axis.set_ylabel(plane[1].lower()) + images.append((image, difference)) + + fig.colorbar( + images[0][0], + ax=(axes[0, 1], axes[1, 0], axes[1, 1]), + label="Relative difference (%)", + ) + title = fig.suptitle(f"MC/DC and OpenMC relative difference, t = {time[0]:.3g}") + + def update(frame): + marker.set_data([time[frame]], [metric[frame]]) + for image, difference in images: + image.set_data(difference[frame].T) + title.set_text(f"MC/DC and OpenMC relative difference, t = {time[frame]:.3g}") + return [title, marker, *(image for image, _ in images)] + + simulation = animation.FuncAnimation(fig, update, frames=len(time)) + simulation.save( + filename, + writer=animation.PillowWriter(fps=max(2, len(time) // 10)), + dpi=120, + ) + plt.close(fig) + + +def plot_convergence(score, N_history, difference_l2, difference_max): + """Plot code-to-code differences with Monte Carlo convergence guides.""" + N_history = np.asarray(N_history) + difference_l2 = np.asarray(difference_l2) + difference_max = np.asarray(difference_max) + + fig, ax = plt.subplots() + ax.plot( + N_history, + difference_l2, + "bo", + fillstyle="none", + label="2-norm", + ) + ax.plot( + N_history, + difference_max, + "gD", + fillstyle="none", + label="Maximum", + ) + + for index, difference in enumerate((difference_l2, difference_max)): + positive = difference > 0.0 + if not np.any(positive): + continue + + positive_indices = np.flatnonzero(positive) + anchor = positive_indices[len(positive_indices) // 2] + expected = 1.0 / np.sqrt(N_history) + expected *= difference[anchor] / expected[anchor] + label = r"$O(N^{-1/2})$" if index == 0 else None + ax.plot(N_history, expected, "r--", label=label) + + ax.set_xscale("log") + ax.set_yscale("log") + ax.set_xlabel(r"Number of particle histories, $N$") + ax.set_ylabel("Relative difference") + ax.set_title(f"{score.capitalize()} convergence") + ax.grid() + ax.legend() + fig.savefig( + Path(f"convergence_{score}.png"), + dpi=300, + bbox_inches="tight", + pad_inches=0, + ) + plt.close(fig)