diff --git a/docs/source/pythonapi/index.rst b/docs/source/pythonapi/index.rst index f3a8ed46f..e58107a03 100644 --- a/docs/source/pythonapi/index.rst +++ b/docs/source/pythonapi/index.rst @@ -87,6 +87,9 @@ Defining techniques Techniques are enabled by calling methods on the ``mcdc.simulation`` singleton: - ``mcdc.simulation.implicit_capture(active=True)`` +- ``mcdc.simulation.forced_collisions(cells=[], weight_thresholds=[], weight_targets=[])`` +- ``mcdc.simulation.weighted_emission(active=True, weight_target=1.0)`` +- ``mcdc.simulation.weight_windows(weight_windows, mesh=None, energy=None)`` - ``mcdc.simulation.global_weight_roulette(weight_threshold=0.0, weight_target=1.0)`` - ``mcdc.simulation.population_control(active=True)`` - ``mcdc.simulation.weighted_emission(active=True, weight_target=1.0)`` diff --git a/docs/source/theory/variance_reduction.rst b/docs/source/theory/variance_reduction.rst index 84a33183c..0c037aa2a 100644 --- a/docs/source/theory/variance_reduction.rst +++ b/docs/source/theory/variance_reduction.rst @@ -42,6 +42,59 @@ effective in highly absorbing media. to eliminate very-low-weight particles, a memory overhead can build up over time. + +Forced Collisions +------------------ + +.. warning:: + + Forced collisions are currently valid only for neutral particles. + +Forced collisions force a neutral particle to undergo a collision within selected material cells before it reaches the next surface. +This is done by splitting the incident particle into two components: + +- a transmitted component that travels to the next surface without collision, with weight + + .. math:: + + w_{\text{trans}} = w_0 e^{-\Sigma_t d} + +- a collided component that undergoes a forced collision in the cell, with weight + + .. math:: + + w_{\text{coll}} = w_0 \left(1 - e^{-\Sigma_t d}\right) + +where :math:`d` is the distance to the next surface and :math:`\Sigma_t` is the total macroscopic cross section. +The collision distance is sampled from the following distribution: + +.. math:: + + s = -\frac{1}{\Sigma_t} \ln \left(1 - \xi \left(1 - e^{-\Sigma_t d}\right)\right) + +Additionally, collided particles are continually forced to undergo collisions in the cell of interest. +To prevent tracking particles with extremely low weight, weight roulette is used. + +**Usage:** + +.. code-block:: python3 + + mcdc.simulation.forced_collisions(cells=[cell]) + +Optional roulette parameters can be supplied for each forced-collision cell: + +.. code-block:: python3 + + mcdc.simulation.forced_collisions( + cells=[cell_1, cell_2], + threshold_weights=[0.25, 0.25], + target_weights=[1.0, 1.0] + ) + +If ``threshold_weights`` or ``target_weights`` are omitted, default values of ``0.5`` and ``1.0`` are assumed for each cell. +Forced collisions may only be enabled on cells with material fills. + + Weight Roulette ---------------- diff --git a/mcdc/mcdc_get/__init__.py b/mcdc/mcdc_get/__init__.py index 31b31afac..31999c285 100644 --- a/mcdc/mcdc_get/__init__.py +++ b/mcdc/mcdc_get/__init__.py @@ -86,6 +86,8 @@ import mcdc.mcdc_get.settings as settings +import mcdc.mcdc_get.forced_collisions as forced_collisions + import mcdc.mcdc_get.global_weight_roulette as global_weight_roulette import mcdc.mcdc_get.implicit_capture as implicit_capture diff --git a/mcdc/mcdc_get/forced_collisions.py b/mcdc/mcdc_get/forced_collisions.py new file mode 100644 index 000000000..24e9b1bbd --- /dev/null +++ b/mcdc/mcdc_get/forced_collisions.py @@ -0,0 +1,90 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def cell_IDs(index, forced_collisions, data): + offset = forced_collisions["cell_IDs_offset"] + return data[offset + index] + + +@njit +def cell_IDs_all(forced_collisions, data): + start = forced_collisions["cell_IDs_offset"] + size = forced_collisions["cell_IDs_length"] + end = start + size + return data[start:end] + + +@njit +def cell_IDs_last(forced_collisions, data): + start = forced_collisions["cell_IDs_offset"] + size = forced_collisions["cell_IDs_length"] + end = start + size + return data[end - 1] + + +@njit +def cell_IDs_chunk(start, length, forced_collisions, data): + start += forced_collisions["cell_IDs_offset"] + end = start + length + return data[start:end] + + +@njit +def threshold_weights(index, forced_collisions, data): + offset = forced_collisions["threshold_weights_offset"] + return data[offset + index] + + +@njit +def threshold_weights_all(forced_collisions, data): + start = forced_collisions["threshold_weights_offset"] + size = forced_collisions["threshold_weights_length"] + end = start + size + return data[start:end] + + +@njit +def threshold_weights_last(forced_collisions, data): + start = forced_collisions["threshold_weights_offset"] + size = forced_collisions["threshold_weights_length"] + end = start + size + return data[end - 1] + + +@njit +def threshold_weights_chunk(start, length, forced_collisions, data): + start += forced_collisions["threshold_weights_offset"] + end = start + length + return data[start:end] + + +@njit +def target_weights(index, forced_collisions, data): + offset = forced_collisions["target_weights_offset"] + return data[offset + index] + + +@njit +def target_weights_all(forced_collisions, data): + start = forced_collisions["target_weights_offset"] + size = forced_collisions["target_weights_length"] + end = start + size + return data[start:end] + + +@njit +def target_weights_last(forced_collisions, data): + start = forced_collisions["target_weights_offset"] + size = forced_collisions["target_weights_length"] + end = start + size + return data[end - 1] + + +@njit +def target_weights_chunk(start, length, forced_collisions, data): + start += forced_collisions["target_weights_offset"] + end = start + length + return data[start:end] diff --git a/mcdc/mcdc_set/__init__.py b/mcdc/mcdc_set/__init__.py index e91e83a32..cde388a11 100644 --- a/mcdc/mcdc_set/__init__.py +++ b/mcdc/mcdc_set/__init__.py @@ -86,6 +86,8 @@ import mcdc.mcdc_set.settings as settings +import mcdc.mcdc_set.forced_collisions as forced_collisions + import mcdc.mcdc_set.global_weight_roulette as global_weight_roulette import mcdc.mcdc_set.implicit_capture as implicit_capture diff --git a/mcdc/mcdc_set/forced_collisions.py b/mcdc/mcdc_set/forced_collisions.py new file mode 100644 index 000000000..eda92e70e --- /dev/null +++ b/mcdc/mcdc_set/forced_collisions.py @@ -0,0 +1,90 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def cell_IDs(index, forced_collisions, data, value): + offset = forced_collisions["cell_IDs_offset"] + data[offset + index] = value + + +@njit +def cell_IDs_all(forced_collisions, data, value): + start = forced_collisions["cell_IDs_offset"] + size = forced_collisions["cell_IDs_length"] + end = start + size + data[start:end] = value + + +@njit +def cell_IDs_last(forced_collisions, data, value): + start = forced_collisions["cell_IDs_offset"] + size = forced_collisions["cell_IDs_length"] + end = start + size + data[end - 1] = value + + +@njit +def cell_IDs_chunk(start, length, forced_collisions, data, value): + start += forced_collisions["cell_IDs_offset"] + end = start + length + data[start:end] = value + + +@njit +def threshold_weights(index, forced_collisions, data, value): + offset = forced_collisions["threshold_weights_offset"] + data[offset + index] = value + + +@njit +def threshold_weights_all(forced_collisions, data, value): + start = forced_collisions["threshold_weights_offset"] + size = forced_collisions["threshold_weights_length"] + end = start + size + data[start:end] = value + + +@njit +def threshold_weights_last(forced_collisions, data, value): + start = forced_collisions["threshold_weights_offset"] + size = forced_collisions["threshold_weights_length"] + end = start + size + data[end - 1] = value + + +@njit +def threshold_weights_chunk(start, length, forced_collisions, data, value): + start += forced_collisions["threshold_weights_offset"] + end = start + length + data[start:end] = value + + +@njit +def target_weights(index, forced_collisions, data, value): + offset = forced_collisions["target_weights_offset"] + data[offset + index] = value + + +@njit +def target_weights_all(forced_collisions, data, value): + start = forced_collisions["target_weights_offset"] + size = forced_collisions["target_weights_length"] + end = start + size + data[start:end] = value + + +@njit +def target_weights_last(forced_collisions, data, value): + start = forced_collisions["target_weights_offset"] + size = forced_collisions["target_weights_length"] + end = start + size + data[end - 1] = value + + +@njit +def target_weights_chunk(start, length, forced_collisions, data, value): + start += forced_collisions["target_weights_offset"] + end = start + length + data[start:end] = value diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index 056ce1778..a4bf0e245 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -561,6 +561,16 @@ ('gpu_storage', int64), ]) +forced_collisions = into_dtype([ + ('active', bool), + ('cell_IDs_offset', int64), + ('cell_IDs_length', int64), + ('threshold_weights_offset', int64), + ('threshold_weights_length', int64), + ('target_weights_offset', int64), + ('target_weights_length', int64), +]) + global_weight_roulette = into_dtype([ ('active', bool), ('weight_threshold', float64), @@ -831,6 +841,7 @@ def set_simulation(N: dict): ('N_tracklength_tally', int64), ('settings', settings), ('implicit_capture', implicit_capture), + ('forced_collisions', forced_collisions), ('weighted_emission', weighted_emission), ('global_weight_roulette', global_weight_roulette), ('weight_windows', weight_windows), diff --git a/mcdc/object_/simulation.py b/mcdc/object_/simulation.py index 48e7e3433..0057b1aa6 100644 --- a/mcdc/object_/simulation.py +++ b/mcdc/object_/simulation.py @@ -3,6 +3,7 @@ from mcdc.object_.technique import ( ImplicitCapture, + ForcedCollisions, PopulationControl, GlobalWeightRoulette, WeightWindows, @@ -81,6 +82,7 @@ class Simulation(ObjectSingleton): # Techniques implicit_capture: ImplicitCapture + forced_collisions: ForcedCollisions weighted_emission: WeightedEmission global_weight_roulette: GlobalWeightRoulette weight_windows: WeightWindows @@ -167,6 +169,7 @@ def __init__(self): # Techniques self.implicit_capture = ImplicitCapture() + self.forced_collisions = ForcedCollisions() self.weighted_emission = WeightedEmission() self.global_weight_roulette = GlobalWeightRoulette() self.weight_windows = WeightWindows() diff --git a/mcdc/object_/technique.py b/mcdc/object_/technique.py index e2adfb803..7080ead06 100644 --- a/mcdc/object_/technique.py +++ b/mcdc/object_/technique.py @@ -1,8 +1,11 @@ +from typing import TYPE_CHECKING, Annotated from numpy.typing import NDArray import numpy as np -from typing import Annotated -from mcdc.constant import INF +from mcdc.constant import FILL_MATERIAL, INF from mcdc.object_.base import ObjectSingleton + +if TYPE_CHECKING: + from mcdc.object_.cell import Cell from mcdc.object_.mesh import MeshBase, MeshUniform from mcdc.print_ import print_error @@ -23,6 +26,47 @@ def __call__(self, active: bool = True): self.active = active +# ====================================================================================== +# ForcedCollisions +# ====================================================================================== + + +class ForcedCollisions(ObjectSingleton): + # Annotations for Numba mode + label: str = "forced_collisions" + active: bool + + cell_IDs: list[np.int64] + threshold_weights: list[float] + target_weights: list[float] + + def __init__(self): + self.active = False + self.cell_IDs = [] + self.threshold_weights = [] + self.target_weights = [] + + def __call__(self, cells, threshold_weights=None, target_weights=None): + self.active = True + if threshold_weights is None: + threshold_weights = [0.5] * len(cells) + if target_weights is None: + target_weights = [1.0] * len(cells) + if len(cells) != len(threshold_weights) or len(cells) != len(target_weights): + print_error( + f"Expected cells, threshold_weights, and target_weights to be the same size, but got {len(cells)}, {len(threshold_weights)}, and {len(target_weights)} instead" + ) + + for cell in cells: + if cell.fill_type != FILL_MATERIAL: + print_error( + f"Invalid cell fill on cell: \n{cell}\nForced collision technique is only valid on cells with material fill" + ) + self.cell_IDs.append(cell.ID) + self.threshold_weights = threshold_weights + self.target_weights = target_weights + + # ====================================================================================== # Weighted emission # ====================================================================================== diff --git a/mcdc/transport/particle.py b/mcdc/transport/particle.py index e524e1c21..edeb0bd67 100644 --- a/mcdc/transport/particle.py +++ b/mcdc/transport/particle.py @@ -49,3 +49,24 @@ def copy_as_child(child_particle_container, parent_particle_container): # Evolve parent seed rng.lcg(parent_particle_container) + + +@njit +def copy_run_state(target_particle_container, source_particle_container): + """ + Helper for copying runtime particle state into new particle. + + Parameters + ---------- + target_particle_container : ndarray + Container holding the particle to copy data into. + source_particle_container : ndarray + Container holding the particle to copy data from. + """ + target_particle = target_particle_container[0] + source_particle = source_particle_container[0] + target_particle["alive"] = source_particle["alive"] + target_particle["material_ID"] = source_particle["material_ID"] + target_particle["cell_ID"] = source_particle["cell_ID"] + target_particle["surface_ID"] = source_particle["surface_ID"] + target_particle["event"] = source_particle["event"] diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index ef3cef349..f1e2c8a6a 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -30,6 +30,35 @@ def particle_speed(particle_container, simulation, data): # ====================================================================================== +@njit +def total_xs(particle_container, simulation, data): + """ + Convenience helper for getting specifically the total cross section. + + Parameters + ---------- + particle_container : ndarray + Container holding the particle. + simulation : object + Simulation object. + data : object + Simulation data for array access. + + Returns + ------- + float + Total macroscopic cross section. + """ + particle = particle_container[0] + if particle["particle_type"] == PARTICLE_NEUTRON: + total = NEUTRON_REACTION_TOTAL + return neutron.macro_xs(total, particle_container, simulation, data) + elif particle["particle_type"] == PARTICLE_ELECTRON: + total = ELECTRON_REACTION_TOTAL + return electron.macro_xs(total, particle_container, simulation, data) + return 0.0 + + @njit def macro_xs(reaction_type, particle_container, simulation, data): particle = particle_container[0] @@ -57,14 +86,8 @@ def neutron_production_xs(reaction_type, particle_container, simulation, data): @njit def collision_distance(particle_container, simulation, data): - particle = particle_container[0] - # Get total cross-section - SigmaT = 0.0 - if particle["particle_type"] == PARTICLE_NEUTRON: - SigmaT = macro_xs(NEUTRON_REACTION_TOTAL, particle_container, simulation, data) - elif particle["particle_type"] == PARTICLE_ELECTRON: - SigmaT = macro_xs(ELECTRON_REACTION_TOTAL, particle_container, simulation, data) + SigmaT = total_xs(particle_container, simulation, data) # Vacuum material? if SigmaT == 0.0: @@ -76,6 +99,41 @@ def collision_distance(particle_container, simulation, data): return distance +@njit +def forced_collision_distance(particle_container, surface_distance, simulation, data): + """ + Method for finding the distance for a forced collision particle to travel. + + Parameters + ---------- + particle_container : ndarray + Container holding the particle. + surface_distance: + The distance to the next surface along the particles direction. + simulation : object + Simulation object. + data : object + Simulation data for array access. + + Returns + ------- + distance : float + Distance for particle to travel. + """ + # Get total cross-section + SigmaT = total_xs(particle_container, simulation, data) + + # Vacuum material? + if SigmaT == 0.0: + return INF + + # Sample collision distance + xi = rng.lcg(particle_container) + + distance = -math.log(1 - xi * (1 - math.exp(-surface_distance * SigmaT))) / SigmaT + return distance + + @njit def collision(particle_container, collision_data_container, program, data): particle = particle_container[0] diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index bd31952a7..5d3050573 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -362,6 +362,9 @@ def step_particle(particle_container, program, data): if simulation["global_weight_roulette"]["active"]: technique.global_weight_roulette(particle_container, simulation) + if simulation["forced_collisions"]["active"]: + technique.forced_collision_roulette(particle_container, program, data) + @njit def move_to_event(particle_container, simulation, data): @@ -413,7 +416,12 @@ def move_to_event(particle_container, simulation, data): ) # Distance to next collision - d_collision = physics.collision_distance(particle_container, simulation, data) + if technique.in_forced_collision_cell(particle_container, simulation, data): + d_collision = technique.forced_collisions( + particle_container, d_boundary, simulation, data + ) + else: + d_collision = physics.collision_distance(particle_container, simulation, data) # ================================================================================== # Determine event(s) @@ -447,40 +455,10 @@ def move_to_event(particle_container, simulation, data): # ================================================================================== # Move particle # ================================================================================== - # Score tracklength tallies - if simulation["cycle_active"]: - # Cell tallies - cell = simulation["cells"][particle["cell_ID"]] - for i in range(cell["N_tally"]): - tally_base_ID = int(mcdc_get.cell.tally_IDs(i, cell, data)) - tally_base = simulation["tallies"][tally_base_ID] - - # Skip non-tracklength tallies - if tally_base["child_type"] != TALLY_TRACKLENGTH: - continue - - tally = simulation["tracklength_tallies"][tally_base["child_ID"]] - tally_module.score.tracklength_tally( - particle_container, distance, tally, simulation, data - ) - - # Other tracklength tallies - for i in range(simulation["N_tracklength_tally"]): - tally = simulation["tracklength_tallies"][i] - - # Skip cell tallies - if tally["spatial_filter_type"] == SPATIAL_FILTER_CELL: - continue - - tally_module.score.tracklength_tally( - particle_container, distance, tally, simulation, data - ) - - if settings["neutron_eigenvalue_mode"]: - tally_module.score.eigenvalue_tally( - particle_container, distance, simulation, data - ) + tally_module.score.score_tracklength_tallies( + particle_container, distance, simulation, data + ) # Move particle particle_module.move(particle_container, distance, simulation, data) diff --git a/mcdc/transport/tally/score.py b/mcdc/transport/tally/score.py index 924c03386..6c3633d51 100644 --- a/mcdc/transport/tally/score.py +++ b/mcdc/transport/tally/score.py @@ -26,6 +26,8 @@ SCORE_NET_CURRENT, SCORE_ENERGY_DEPOSITION, SPATIAL_FILTER_MESH, + SPATIAL_FILTER_CELL, + TALLY_TRACKLENGTH, ) from mcdc.transport.geometry.surface import get_normal_component from mcdc.transport.tally.filter import get_filter_indices @@ -140,6 +142,52 @@ def collision_tally( # ====================================================================================== +@njit +def score_tracklength_tallies(particle_container, distance, simulation, data): + """ + Helper for scoring traveled distance on all track length tallies in the simulation. + + Parameters + ---------- + particle_container : ndarray + Container holding the particle. + distance: + The distance traveled to score. + simulation : object + Simulation object. + data : object + Simulation data for array access. + """ + particle = particle_container[0] + + if simulation["cycle_active"]: + # Cell tallies + cell = simulation["cells"][particle["cell_ID"]] + for i in range(cell["N_tally"]): + tally_base_ID = int(mcdc_get.cell.tally_IDs(i, cell, data)) + tally_base = simulation["tallies"][tally_base_ID] + + # Skip non-tracklength tallies + if tally_base["child_type"] != TALLY_TRACKLENGTH: + continue + + tally = simulation["tracklength_tallies"][tally_base["child_ID"]] + tracklength_tally(particle_container, distance, tally, simulation, data) + + # Other tracklength tallies + for i in range(simulation["N_tracklength_tally"]): + tally = simulation["tracklength_tallies"][i] + + # Skip cell tallies + if tally["spatial_filter_type"] == SPATIAL_FILTER_CELL: + continue + + tracklength_tally(particle_container, distance, tally, simulation, data) + + if simulation["settings"]["neutron_eigenvalue_mode"]: + eigenvalue_tally(particle_container, distance, simulation, data) + + @njit def tracklength_tally(particle_container, distance, tally, simulation, data): particle = particle_container[0] diff --git a/mcdc/transport/technique.py b/mcdc/transport/technique.py index 5de637998..77d5bcaa4 100644 --- a/mcdc/transport/technique.py +++ b/mcdc/transport/technique.py @@ -2,15 +2,229 @@ import math from numba import njit +import numba #### import mcdc.mcdc_get.weight_windows as ww_get import mcdc.numba_types as type_ +from mcdc.transport.mesh import get_indices as get_mesh_indices +import mcdc.transport.geometry as geometry_module import mcdc.transport.particle as particle_module import mcdc.transport.particle_bank as particle_bank_module +import mcdc.transport.tally as tally_module import mcdc.transport.rng as rng +from mcdc.transport.physics import interface as physics import mcdc.transport.util as util +import mcdc.mcdc_get as mcdc_get +from mcdc.constant import PARTICLE_NEUTRON + +# ====================================================================================== +# Forced Collisions +# ====================================================================================== + + +@njit +def forced_collisions(particle_container, surface_distance, program, data): + """ + Applies the method of forced collisions, splitting the source particle into a collided and transmitted particle. This method returns the distance for the collided particle to travel, letting the `simulation.move_to_event` handle the actual transport for the collided particle. + + Parameters + ---------- + particle_container : ndarray + Container holding the original particle to copy over all data from. + surface_distance : float + The distance to the surface the transmitted particle will be moved to. + program : object + Program object containing simulation state with forced collision data. + data : object + Simulation data for array access. + + Returns + ------- + collision_distance : float + Distance for the collided component to travel. + """ + simulation = util.access_simulation(program) + + # find weight multiplier + SigmaT = physics.total_xs(particle_container, simulation, data) + weight_multiplier = math.exp(-surface_distance * SigmaT) + + # transmitted particle + bank_transmitted_particle( + particle_container, weight_multiplier, surface_distance, program, data + ) + + # alias input particle as collided particle + collided_container = particle_container + # update collided particle + collided = collided_container[0] + collided["w"] *= 1 - weight_multiplier + + # return distance to forced collision, let simulation handle the rest (tallies) + collision_distance = physics.forced_collision_distance( + collided_container, surface_distance, simulation, data + ) + return collision_distance + + +@njit +def bank_transmitted_particle( + particle_container, weight_multiplier, surface_distance, program, data +): + """ + Helper for creating and banking the transmitted component. If the + transmitted particle leaves the simulation through a boundary surface, the + particle is not banked. Additionally, the particle is scored over all + tracklength tallies via the helper in `tally.score`. + + Parameters + ---------- + particle_container : ndarray + Container holding the original particle to copy over all data from. + weight_multiplier : float + The multiplier to adjust the particle weight by. + surface_distance : float + The distance to the surface the transmitted particle will be moved to. + program : object + Program object containing simulation state with forced collision data. + data : object + Simulation data for array access. + """ + simulation = util.access_simulation(program) + + # create child copy of collided particle history + transmitted_container = util.local_array(1, type_.particle) + particle_module.copy_as_child(transmitted_container, particle_container) + particle_module.copy_run_state(transmitted_container, particle_container) + + # assign weight + transmitted = transmitted_container[0] + transmitted["w"] *= weight_multiplier + + # score tracklength tallies + tally_module.score.score_tracklength_tallies( + transmitted_container, surface_distance, simulation, data + ) + + # update position and perform surface crossing + particle_module.move(transmitted_container, surface_distance, simulation, data) + geometry_module.surface_crossing(transmitted_container, simulation, data) + + # particle could leave through BC, so check if alive before banking + if transmitted["alive"]: + particle_bank_module.bank_active_particle(transmitted_container, program) + + +@njit +def forced_collision_roulette(particle_container, program, data): + """ + Roulette procedure for forced collision. Particle is only rouletted if in a cell marked for forced collisions + + Parameters + ---------- + particle_container : ndarray + Container holding the particle. + program : object + Program object containing simulation state with forced collision data. + data : object + Simulation data for array access. + """ + # skip if not a neutron + if particle_container[0]["particle_type"] != PARTICLE_NEUTRON: + return + + simulation = util.access_simulation(program) + fc_object = simulation["forced_collisions"] + + # check if particle is in a cell with forced collisions + if not in_forced_collision_cell(particle_container, simulation, data): + return + + # get index into arrays + index = get_forced_collision_cell_index(particle_container, fc_object, data) + if index < 0: + return + + # get weights + threshold = mcdc_get.forced_collisions.threshold_weights(index, fc_object, data) + target = mcdc_get.forced_collisions.target_weights(index, fc_object, data) + + # roulette + weight_roulette(particle_container, threshold, target) + + +@njit +def get_forced_collision_cell_index(particle_container, fc_object, data): + """ + Helper for getting the getter index for weight roulette parameters + + Parameters + ---------- + particle_container : ndarray + Container holding the particle. + fc_object : object + Forced collision object for use in mcdc_get methods. + data : object + Simulation data for array access. + + Returns + ------- + index : int + The flattened index for getting weight roulette parameters. + """ + particle = particle_container[0] + + # grab all cell ids + cell_ids = mcdc_get.forced_collisions.cell_IDs_all(fc_object, data) + + # find the cell index + for index in range(len(cell_ids)): + if cell_ids[index] == particle["cell_ID"]: + return index + + # should never hit this, but just to be safe + return -1 + + +@njit +def in_forced_collision_cell(particle_container, simulation, data): + """ + Check if particle is in a cell marked for forced collision + + Parameters + ---------- + particle_container : ndarray + Container holding the particle. + program : object + Program object containing simulation state with forced collision data. + data : object + Simulation data for array access. + + Returns + ------- + bool + True if particle in cell marked for forced collision. + """ + # skip if not a neutron + if particle_container[0]["particle_type"] != PARTICLE_NEUTRON: + return False + + fc_object = simulation["forced_collisions"] + + # not active, dont need to query cells + if not fc_object["active"]: + return False + + # active, need to check if in active cell + cell_ids = mcdc_get.forced_collisions.cell_IDs_all(fc_object, data) + if particle_container[0]["cell_ID"] in cell_ids: + return True + + # not in active cell + return False + from mcdc.transport.mesh import get_indices as get_mesh_indices diff --git a/test/regression/slab_beam_forced_collision/answer.h5 b/test/regression/slab_beam_forced_collision/answer.h5 new file mode 100644 index 000000000..6d43b155f Binary files /dev/null and b/test/regression/slab_beam_forced_collision/answer.h5 differ diff --git a/test/regression/slab_beam_forced_collision/input.py b/test/regression/slab_beam_forced_collision/input.py new file mode 100644 index 000000000..7b391604e --- /dev/null +++ b/test/regression/slab_beam_forced_collision/input.py @@ -0,0 +1,56 @@ +import numpy as np +import os +import mcdc + +os.environ["MCDC_LIB"] = "../mcdc-regression_test_data/" + +# ====================================================================================== +# Set model +# ====================================================================================== +# Finite homogeneous pure-absorbing slab + +# Set materials +# Set materials +generate_material = lambda atomdensity: mcdc.Material( + nuclide_composition={"H1": atomdensity} +) +m1 = generate_material(0.0001) + +# Set surfaces +s1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") +s2 = mcdc.Surface.PlaneX(x=1.0, boundary_condition="vacuum") + +# Set cells +low_xs_cell = mcdc.Cell(region=+s1 & -s2, fill=m1) + +# ====================================================================================== +# Set source +# ====================================================================================== +# Isotropic beam from left-end + +mcdc.Source(position=(0.0, 0.0, 0.0), direction=(1.0, 0.0, 0.0)) + +# ====================================================================================== +# Set tallies, settings, and run MC/DC +# ====================================================================================== + +# Tallies +# energy deposition for actual VR against analog +mesh = mcdc.MeshUniform() +mcdc.Tally( + mesh=mesh, + scores=["energy_deposition"], +) +# flux to make sure tracklength is unbiased +mcdc.Tally(cell=low_xs_cell, scores=["flux"]) +# net-current to make sure surface is unbiased +mcdc.Tally(surface=s2, scores=["net-current"]) + +# Settings +mcdc.settings.N_particle = 5000 +mcdc.settings.N_batch = 2 + +mcdc.simulation.forced_collisions(cells=[low_xs_cell]) + +# Run +mcdc.run() diff --git a/test/unit/object_/technique/forced_collisions.py b/test/unit/object_/technique/forced_collisions.py new file mode 100644 index 000000000..6276d5943 --- /dev/null +++ b/test/unit/object_/technique/forced_collisions.py @@ -0,0 +1,84 @@ +import numpy as np +import pytest +import mcdc + +# ============================================================================= +# Model base fixture +# ============================================================================= + + +@pytest.fixture +def pin_cell_model(): + # Material + fuel = mcdc.MaterialMG( + capture=np.array([1.0 / 3.0]), + scatter=np.array([[1.0 / 3.0]]), + ) + moderator = mcdc.MaterialMG( + capture=np.array([1.0 / 3.0]), + scatter=np.array([[1.0 / 3.0]]), + ) + + # Geometry + cylinder = mcdc.Surface.CylinderZ(radius=0.5) + pitch = 1.5 + 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) + mod_cell = mcdc.Cell(+x0 & -x1 & +y0 & -y1 & +cylinder, fill=moderator) + + yield fuel_cell, mod_cell + + +# ============================================================================= +# Error throwing in object creation +# ============================================================================= + + +@pytest.mark.parametrize( + "cells_builder, thresholds, targets, expected_msg", + [ + ( + lambda fuel_cell: [fuel_cell], + [0.5, 0.5], + [1.0], + "Expected cells, threshold_weights, and target_weights to be the same size", + ), + ( + lambda fuel_cell: [fuel_cell], + [0.5], + [1.0, 1.0], + "Expected cells, threshold_weights, and target_weights to be the same size", + ), + ( + lambda fuel_cell: [mcdc.Cell(fill=mcdc.Universe(cells=[fuel_cell]))], + None, + None, + "Invalid cell fill on cell", + ), + ], +) +def test_forced_collisions_error_throw( + pin_cell_model, capsys, cells_builder, thresholds, targets, expected_msg +): + fuel_cell, mod_cell = pin_cell_model + + cells = cells_builder(fuel_cell) + + with pytest.raises(SystemExit): + mcdc.simulation.forced_collisions( + cells, + threshold_weights=thresholds, + target_weights=targets, + ) + + captured = capsys.readouterr() + assert expected_msg in captured.out + + +# ============================================================================= +# Method tests +# =============================================================================