Solid and Fluid Coupling of OpenMC and MOOSE
In this tutorial, you will learn how to:
Couple OpenMC to 2 separate MOOSE applications solving for the solid and fluid thermal-fluids
Couple OpenMC to mixed-dimension feedback with 3-D heat conduction and 1-D fluid flow
Establish coupling between OpenMC and MOOSE for nested universe OpenMC models
Apply homogenized temperature feedback to heterogeneous OpenMC cells
To access this tutorial:
cd cardinal/tutorials/gas_assembly
This tutorial also requires you to download an OpenMC XML file from Box. Please download the files from the gas_assembly
folder here and place these files within the same directory structure in tutorials/gas_assembly
.
In this tutorial, we couple OpenMC to the MOOSE heat transfer module and the Thermal Hydraulics Module (THM) , a set of 1-D systems-level thermal-hydraulics kernels. OpenMC will receive temperature feedback from both the MOOSE heat transfer module (for the solid regions) and from THM (for the fluid regions). Density feedback will be provided by THM for the fluid regions. This tutorial models a full-height TRISO-fueled prismatic gas reactor fuel assembly, and is an extension of an earlier tutorial which coupled OpenMC and MOOSE heat conduction (i.e. without an application providing fluid feedback) for a unit cell version of the same geometry. In this tutorial, we add fluid feedback and also describe several nuances associated with setting up feedback in OpenMC lattices.
This tutorial was developed with support from the NEAMS Thermal Fluids Center of Excellence. A paper Novak et al. (2021) describing the physics models and mesh refinement studies provides additional context beyond the scope of this tutorial.
Due to the tall height of the full assembly (about 6 meters), the converged results shown in Novak et al. (2021) and in the figures in this tutorial used a finer mesh and more particles than in the input files we set up for this tutorial - the input files in this tutorial still require parallel resources to run, but should be faster-running. Parameters that have been set to coarser values are noted where applicable.
Geometry and Computational Model
The geometry consists of a TRISO-fueled gas reactor assembly INL (2016). A top-down view of the geometry is shown in Figure 1. The assembly is a graphite prismatic hexagonal block with 108 helium coolant channels, 210 fuel compacts, and 6 poison compacts. Each fuel compact contains TRISO particles dispersed in a graphite matrix with a packing fraction of 15%. All channels and compacts are ordered in a triangular lattice with pitch . Due to irradiation- and temperature-induced swelling of graphite, small helium gaps exist between assemblies. In this tutorial, rather than explicitly model the inter-assembly flow, we treat the gap regions as solid graphite. There are also graphite reflectors above and below the assembly.
The TRISO particles use a conventional design that consists of a central fissile uranium oxycarbide kernel enclosed in a carbon buffer, an inner PyC layer, a silicon carbide layer, and finally an outer PyC layer. The geometric specifications for the assembly dimensions are shown in Figure 1, while dimensions for the TRISO particles are summarized in Table 1.
Parameter | Value (cm) |
---|---|
TRISO kernel radius | 214.85e-4 |
Buffer layer radius | 314.85e-4 |
Inner PyC layer radius | 354.85e-4 |
Silicon carbide layer radius | 389.85e-4 |
Outer PyC layer radius | 429.85e-4 |
Heat is produced in the TRISO particles to yield a total power of 16.7 MWth. This heat is removed by helium flowing downwards through the coolant channels with a total mass flowrate of 9.775 kg/s, which is assumed to be uniformly distributed among the coolant channels. The outlet pressure is 7.1 MPa.
Heat Conduction Model
The MOOSE heat transfer module is used to solve for steady-state heat conduction,
(1)
where is the solid thermal conductivity, is the solid temperature, and is a volumetric heat source in the solid.
To greatly reduce meshing requirements, the TRISO particles are homogenized into the compact regions by volume-averaging material properties. The solid mesh is shown in Figure 2. The only sideset in the domain is the coolant channel surface, which is named fluid_solid_interface
. The solid geometry uses a length unit of meters. The solid mesh is created using the MOOSE reactor module Shemon et al. (2021), which provides easy-to-use mesh generators to programmatically construct reactor core meshes as building blocks of bundle and pincell meshes.
The file used to generate the solid mesh is shown below. The mesh is created by first building pincell meshes for a fuel pin, a coolant pin, a poison pin, and a graphite "pin" (to represent the central graphite region). The pin meshes are then combined together into a bundle pattern and extruded.
n_layers = 100 # number of axial extrusion layers; for the converged case,
# we set this to 300 to get a finer mesh
[GlobalParams]
quad_center_elements = true
[]
[Mesh]
[fuel_pin]
type = PolygonConcentricCircleMeshGenerator
num_sides = 6
polygon_size = ${fparse fuel_to_coolant_distance / 2.0}
ring_radii = '${fparse 0.8 * compact_diameter / 2.0} ${fparse compact_diameter / 2.0}'
ring_intervals = '1 1'
num_sectors_per_side = '4 4 4 4 4 4'
ring_block_ids = '2 2'
ring_block_names = 'compacts compacts'
background_block_ids = '1'
background_block_names = 'graphite'
background_intervals = 2
[]
[coolant_pin]
type = PolygonConcentricCircleMeshGenerator
num_sides = 6
polygon_size = ${fparse fuel_to_coolant_distance / 2.0}
ring_radii = '${fparse channel_diameter / 2.0}'
ring_intervals = '2'
num_sectors_per_side = '4 4 4 4 4 4'
ring_block_ids = '101 101'
ring_block_names = 'coolant coolant'
background_block_ids = '1'
background_block_names = 'graphite'
interface_boundary_id_shift = 100
create_inward_interface_boundaries = true
background_intervals = 2
[]
[poison_pin]
type = PolygonConcentricCircleMeshGenerator
num_sides = 6
polygon_size = ${fparse fuel_to_coolant_distance / 2.0}
ring_radii = '${fparse compact_diameter / 2.0}'
ring_intervals = '2'
num_sectors_per_side = '4 4 4 4 4 4'
ring_block_ids = '4 4'
ring_block_names = 'poison poison'
background_block_ids = '1'
background_block_names = 'graphite'
background_intervals = 2
[]
[graphite_pin]
type = PolygonConcentricCircleMeshGenerator
num_sides = 6
polygon_size = ${fparse fuel_to_coolant_distance / 2.0}
ring_radii = '${fparse compact_diameter / 2.0}'
ring_intervals = '2'
num_sectors_per_side = '4 4 4 4 4 4'
ring_block_ids = '1 1'
ring_block_names = 'graphite graphite'
background_block_ids = '1'
background_block_names = 'graphite'
[]
[bundle]
type = PatternedHexMeshGenerator
inputs = 'fuel_pin coolant_pin poison_pin graphite_pin'
hexagon_size = ${fparse bundle_flat_to_flat / 2.0 + bundle_gap_width / 2.0}
pattern = '2 0 1 0 0 1 0 0 1 0 2;
0 1 0 0 1 0 0 1 0 0 1 0;
1 0 0 1 0 0 1 0 0 1 0 0 1;
0 0 1 0 0 1 0 0 1 0 0 1 0 0;
0 1 0 0 1 0 0 1 0 0 1 0 0 1 0;
1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1;
0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0;
0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0;
1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1;
0 0 1 0 0 1 0 0 1 3 3 1 0 0 1 0 0 1 0 0;
2 1 0 0 1 0 0 1 0 3 3 3 0 1 0 0 1 0 0 1 2;
0 0 1 0 0 1 0 0 1 3 3 1 0 0 1 0 0 1 0 0;
1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1;
0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0;
0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0;
1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1;
0 1 0 0 1 0 0 1 0 0 1 0 0 1 0;
0 0 1 0 0 1 0 0 1 0 0 1 0 0;
1 0 0 1 0 0 1 0 0 1 0 0 1;
0 1 0 0 1 0 0 1 0 0 1 0;
2 0 1 0 0 1 0 0 1 0 2'
rotate_angle = 0
background_block_id = '1'
[]
[extrude]
type = AdvancedExtruderGenerator
input = bundle
heights = ${height}
num_layers = ${n_layers}
direction = '0 0 1'
[]
[delete_coolant]
type = BlockDeletionGenerator
input = extrude
block = '101'
[]
[rename_coolant_sideset]
type = RenameBoundaryGenerator
input = delete_coolant
old_boundary = 102
new_boundary = 'fluid_solid_interface'
[]
construct_side_list_from_node_list = true
[]
# The following content is adding postprocessor(s) to check sideset areas.
# The reactor module is unfortunately quite brittle in its assignment of sideset
# IDs, so we want to be extra sure that any changes to sideset numbering are detected
# in our test suite.
(tutorials/gas_assembly/solid_mesh.i)You can create this mesh by running:
cardinal-opt -i common_input.i solid_mesh.i --mesh-only
which will create a mesh named solid_mesh_in.e
. Note that the above command takes advantage of a MOOSE feature for combining input files together by placing some common parameters used by the other applications into a file named common_input.i
.
The temperature on the fluid-solid interface is provided by Thermal Hydraulics Module (THM), while the heat source is provided by OpenMC. Because MOOSE heat conduction will run first in the coupled case, the initial fluid temperature is set to an axial distribution given by bulk energy conservation () given the inlet temperature , mass flowrate , and fluid isobaric specific heat . The initial heat source distribution is assumed uniform in the radial direction with a sinusoidal dependence in the axial direction.
OpenMC Model
The OpenMC model is built using CSG. The TRISO positions are sampled using the RSA algorithm in OpenMC. OpenMC's Python API is used to create the model with the script shown below. First, we define materials for the various regions. Next, we create a single TRISO particle universe consisting of the five layers of the particle and an infinite extent of graphite filling all other space. We then pack uniform-radius spheres into a cylindrical region representing a fuel compact, setting each sphere to be filled with the TRISO universe.
Next, we loop over 50 axial layers and create a unique hexagonal lattice for each layer. This hexagonal lattice defines the fuel assembly structure, and consists of four different universes:
A fuel pin plus surronding matrix (
f
),A coolant channel plus surrounding matrix (
c
),A boron carbide poision pin plus surrounding matrix (
p
), andA homogeneous graphite hexagonal pincell to fill the "boundaries" and centermost region (
g
).
In each layer we set up the lattice structure by listing the universes in each "ring" of the lattice, with ring0
being the centermost ring and ring11
being the outermost ring.
Recall that temperatures in OpenMC can be set directly on the cell, but that fluid densities can only be set on materials. For this reason, we need to create 108 unique coolant materials for each axial plane if we want to be able to set unique densities in each coolant channel region. Rather than creating 108 materials in a loop, we use the clone()
feature in OpenMC to clone an existing coolant material 108 times per layer. This duplicates the material properties (densities and isotopic composition), but assigns a new ID that allows individual tracking of density. The Python script used to create the OpenMC model is shown below.
#!/bin/env python
from argparse import ArgumentParser
import math
import numpy as np
import matplotlib.pyplot as plt
import openmc
import sys
import os
# Get common input parameters shared by other physics
script_dir = os.path.dirname(__file__)
sys.path.append(script_dir)
import common_input as specs
def coolant_temp(t_in, t_out, l, z):
"""
Computes the coolant temperature based on an expected cosine power distribution
for a specified temperature rise. The total core temperature rise is governed
by energy conservation as dT = Q / m / Cp, where dT is the total core temperature
rise, Q is the total core power, m is the mass flowrate, and Cp is the fluid
isobaric specific heat. If you neglect axial heat conduction and assume steady
state, then the temperature rise in a layer of fluid i can be related to the
ratio of the power in that layer to the total power,
dT_i / dT = Q_i / Q. We assume here a sinusoidal power distribution to get
a reasonable estimate of an initial coolant temperature distribution.
Parameters
----------
t_in : float
Inlet temperature of the channel
t_out : float
Outlet temperature of the channel
l : float
Length of the channel
z : float or 1-D numpy.array
Axial position where the temperature will be computed
Returns
-------
float or 1-D numpy array of float depending on z
"""
dT = t_out - t_in
Q = 2 * l / math.pi
Qi = (l - l * np.cos(math.pi * z / l)) / math.pi
t = t_in + Qi / Q * dT
return t
def coolant_density(t):
"""
Computes the helium density (kg/m3) from temperature assuming a fixed operating pressure.
Parameters
----------
t : float
Fluid temperature
Returns
_______
float or 1-D numpy array of float depending on t
"""
p_in_bar = specs.outlet_P * 1.0e-5
return 48.14 * p_in_bar / (t + 0.4446 * p_in_bar / math.pow(t, 0.2))
# -------------- Unit Conversions: OpenMC requires cm -----------
m = 100.0
# estimate the outlet temperature using bulk energy conservation for steady state
coolant_outlet_temp = specs.power / specs.mdot / specs.fluid_Cp + specs.inlet_T
# geometry parameters
coolant_channel_diam = specs.channel_diameter * m
reactor_bottom = 0.0
reactor_height = specs.height * m
reactor_top = reactor_bottom + reactor_height
bundle_pitch = specs.bundle_flat_to_flat * m + specs.bundle_gap_width * m
cell_pitch = specs.fuel_to_coolant_distance * m
fuel_channel_diam = specs.compact_diameter * m
top_reflector_height = specs.top_reflector_thickness * m
bottom_reflector_height = specs.bottom_reflector_thickness * m
def assembly(n_ax_zones, n_inactive, n_active, add_entropy_mesh=False):
axial_section_height = reactor_height / n_ax_zones
# superimposed search lattice
triso_lattice_shape = (4, 4, int(axial_section_height / 0.05))
model = openmc.model.Model()
enrichment = 0.155 # U-235 enrichment (weight percent)
enrichment_234 = 2e-3 # U-234 enrichment (weight percent)
kernel_density = 10820 # fissile kernel density (kg/m3)
buffer_density = 1050 # buffer density (kg/m3)
PyC_density = 1900 # PyC density (kg/m3)
SiC_density = 3203 # SiC density (kg/m3)
matrix_density = 1700 # graphite matrix density (kg/m3)
# ----- uranium oxycarbide fuel ----- #
m_fuel = openmc.Material(name='fuel')
mass_234 = openmc.data.atomic_mass('U234')
mass_235 = openmc.data.atomic_mass('U235')
mass_238 = openmc.data.atomic_mass('U238')
n_234 = enrichment_234 / mass_234
n_235 = enrichment / mass_235
n_238 = (1.0 - enrichment - enrichment_234) / mass_238
total_n = n_234 + n_235 + n_238
m_fuel.add_nuclide('U234', n_234 / total_n)
m_fuel.add_nuclide('U235', n_235 / total_n)
m_fuel.add_nuclide('U238', n_238 / total_n)
m_fuel.add_element('C' , 1.50)
m_fuel.add_element('O' , 0.50)
m_fuel.set_density('kg/m3', kernel_density)
# ----- graphite buffer ----- #
m_graphite_c_buffer = openmc.Material(name='buffer')
m_graphite_c_buffer.add_element('C', 1.0)
m_graphite_c_buffer.add_s_alpha_beta('c_Graphite')
m_graphite_c_buffer.set_density('kg/m3', buffer_density)
# ----- pyrolitic carbon ----- #
m_graphite_pyc = openmc.Material(name='pyc')
m_graphite_pyc.add_element('C', 1.0)
m_graphite_pyc.add_s_alpha_beta('c_Graphite')
m_graphite_pyc.set_density('kg/m3', PyC_density)
# ----- silicon carbide ----- #
m_sic = openmc.Material(name='sic')
m_sic.add_element('C' , 1.0)
m_sic.add_element('Si', 1.0)
m_sic.set_density('kg/m3', SiC_density)
# ----- matrix graphite ----- #
m_graphite_matrix = openmc.Material(name='graphite moderator')
m_graphite_matrix.add_element('C', 1.0)
m_graphite_matrix.add_s_alpha_beta('c_Graphite')
m_graphite_matrix.set_density('kg/m3', matrix_density)
# ----- helium coolant ----- #
m_coolant = openmc.Material(name='Helium coolant')
m_coolant.add_element('He', 1.0, 'ao')
# we don't set density here because we'll set it as a function of temperature
m_b4c = openmc.Material(name='B4C')
enrichment_10 = specs.B10_enrichment
mass_10 = openmc.data.atomic_mass('B10')
mass_11 = openmc.data.atomic_mass('B11')
# number of atoms in one gram of boron mixture
n_10 = enrichment_10 / mass_10
n_11 = (1.0 - enrichment_10) / mass_11
total_n = n_10 + n_11
grams_10 = n_10 / total_n
grams_11 = n_11 / total_n
# now, figure out how much carbon needs to be in the poison to get
# an overall specified B10 weight percent
total_b10_weight_percent = specs.total_B10_wt_percent
total_mass = grams_10 / total_b10_weight_percent
carbon_mass = total_mass - grams_10 - grams_11
m_b4c.add_nuclide('B10', grams_10 / total_mass, 'wo')
m_b4c.add_nuclide('B11', grams_11 / total_mass, 'wo')
m_b4c.add_element('C', carbon_mass / total_mass, 'wo')
m_b4c.set_density('kg/m3', specs.B4C_density)
# reflector is 40 percent helium by volume (arbitrary assumption), with helium
# at the inlet conditions
rho = coolant_density(specs.inlet_T) # kg/m3
reflector_porosity = 0.40
n_helium = reflector_porosity * rho / 4.002602
n_carbon = (1.0 - reflector_porosity) * matrix_density / 12.0107
combined_density = rho * reflector_porosity + matrix_density * (1.0 - reflector_porosity)
m_reflector = openmc.Material(name='reflector')
m_reflector.add_element('He', n_helium / (n_helium + n_carbon))
m_reflector.add_element('C', n_carbon / (n_helium + n_carbon))
m_reflector.set_density('kg/m3', combined_density)
# TRISO particle
radius_pyc_outer = specs.oPyC_radius * m
s_fuel = openmc.Sphere(r=specs.kernel_radius*m)
s_c_buffer = openmc.Sphere(r=specs.buffer_radius*m)
s_pyc_inner = openmc.Sphere(r=specs.iPyC_radius*m)
s_sic = openmc.Sphere(r=specs.SiC_radius*m)
s_pyc_outer = openmc.Sphere(r=radius_pyc_outer)
c_triso_fuel = openmc.Cell(name='c_triso_fuel' , fill=m_fuel, region=-s_fuel)
c_triso_c_buffer = openmc.Cell(name='c_triso_c_buffer' , fill=m_graphite_c_buffer, region=+s_fuel & -s_c_buffer)
c_triso_pyc_inner = openmc.Cell(name='c_triso_pyc_inner', fill=m_graphite_pyc, region=+s_c_buffer & -s_pyc_inner)
c_triso_sic = openmc.Cell(name='c_triso_sic' , fill=m_sic, region=+s_pyc_inner & -s_sic)
c_triso_pyc_outer = openmc.Cell(name='c_triso_pyc_outer', fill=m_graphite_pyc, region=+s_sic & -s_pyc_outer)
c_triso_matrix = openmc.Cell(name='c_triso_matrix' , fill=m_graphite_matrix, region=+s_pyc_outer)
u_triso = openmc.Universe(cells=[c_triso_fuel, c_triso_c_buffer, c_triso_pyc_inner, c_triso_sic, c_triso_pyc_outer, c_triso_matrix])
# Channel surfaces
fuel_cyl = openmc.ZCylinder(r=0.5 * fuel_channel_diam)
coolant_cyl = openmc.ZCylinder(r=0.5 * coolant_channel_diam)
poison_cyl = openmc.ZCylinder(r=0.5 * fuel_channel_diam)
graphite_cyl = openmc.ZCylinder(r=0.5 * fuel_channel_diam)
# create a TRISO lattice for one axial section (to be used in the rest of the axial zones)
# center the TRISO region on the origin so it fills lattice cells appropriately
min_z = openmc.ZPlane(z0=-0.5 * axial_section_height)
max_z = openmc.ZPlane(z0=0.5 * axial_section_height)
# region in which TRISOs are generated
r_triso = -fuel_cyl & +min_z & -max_z
rand_spheres = openmc.model.pack_spheres(radius=radius_pyc_outer, region=r_triso, pf=specs.triso_pf, seed=1.0)
random_trisos = [openmc.model.TRISO(radius_pyc_outer, u_triso, i) for i in rand_spheres]
llc, urc = r_triso.bounding_box
pitch = (urc - llc) / triso_lattice_shape
# insert TRISOs into a lattice to accelerate point location queries
triso_lattice = openmc.model.create_triso_lattice(random_trisos, llc, pitch, triso_lattice_shape, m_graphite_matrix)
axial_coords = np.linspace(reactor_bottom, reactor_top, n_ax_zones + 1)
lattice_univs = []
bundle_univs = []
m_colors = {}
for z_min, z_max in zip(axial_coords[0:-1], axial_coords[1:]):
# use the middle of the axial section to compute the temperature and density
ax_pos = 0.5 * (z_min + z_max)
T = coolant_temp(specs.inlet_T, coolant_outlet_temp, reactor_height, ax_pos)
# create solid cells, which don't require us to clone materials in order to set temperatures
fuel_ch_cell = openmc.Cell(region=-fuel_cyl, fill=triso_lattice)
fuel_ch_cell.temperature = T
fuel_ch_matrix_cell = openmc.Cell(region=+fuel_cyl, fill=m_graphite_matrix)
fuel_ch_matrix_cell.temperature = T
poison_cell = openmc.Cell(region=-poison_cyl, fill=m_b4c)
poison_cell.temperature = T
poison_matrix_cell = openmc.Cell(region=+poison_cyl, fill=m_graphite_matrix)
poison_matrix_cell.temperature = T
graphite_cell = openmc.Cell(fill=m_graphite_matrix)
graphite_cell.temperature = T
coolant_matrix_cell = openmc.Cell(region=+coolant_cyl, fill=m_graphite_matrix)
coolant_matrix_cell.temperature = T
# create fluid cells, which require us to clone the material in order to be able to
# set unique densities
coolant_cell = openmc.Cell(region=-coolant_cyl, fill=m_coolant)
coolant_cell.fill = [m_coolant.clone() for i in range(specs.n_coolant_channels_per_block)]
for mat in range(len(coolant_cell.fill)):
m_colors[coolant_cell.fill[mat]] = 'red'
# Define a universe for each type of solid-only pin (fuel, poison, and graphite)
f = openmc.Universe(cells=[fuel_ch_cell, fuel_ch_matrix_cell])
p = openmc.Universe(cells=[poison_cell, poison_matrix_cell])
c = openmc.Universe(cells=[coolant_cell, coolant_matrix_cell])
g = openmc.Universe(cells=[graphite_cell])
d = [f] * 2
ring2 = ([f] + [c]) * 6
ring3 = ([c] + d) * 6
ring4 = (d + [c] + [f]) * 6
ring5 = ([f] + [c] + d + [c]) * 6
ring6 = ([c] + d + [c] + d) * 6
ring7 = (d + [c] + d + [c] + [f]) * 6
ring8 = ([f] + [c] + d + [c] + d + [c]) * 6
ring9 = ([c] + d + [c] + d + [c] + d) * 6
ring10 = ([p] + [f] + [c] + d + [c] + d + [c] + [f]) * 6
ring11 = [g] * 66
# inner two rings where there aren't any fuel/compact/poison pins
ring1 = [g] * 6
ring0 = [g]
lattice_univs.append([ring11, ring10, ring9, ring8, ring7, ring6, ring5, ring4, ring3, ring2, ring1, ring0])
# create a hexagonal lattice used in each axial zone to represent the cell
hex_lattice = openmc.HexLattice(name="Bundle cell lattice")
hex_lattice.orientation = 'x'
hex_lattice.center = (0.0, 0.0, 0.5 * (reactor_bottom + reactor_top))
hex_lattice.pitch = (cell_pitch, axial_section_height)
hex_lattice.universes = lattice_univs
hexagon_volume = reactor_height * math.sqrt(3) / 2.0 * bundle_pitch**2
coolant_channel_volume = math.pi * coolant_channel_diam**2 / 4.0 * reactor_height
graphite_outer_cell = openmc.Cell(fill=m_graphite_matrix)
graphite_outer_cell.temperature = T
inf_graphite_univ = openmc.Universe(cells=[graphite_outer_cell])
hex_lattice.outer = inf_graphite_univ
# create additional axial regions
axial_planes = [openmc.ZPlane(z0=coord) for coord in axial_coords]
# axial planes
min_z = axial_planes[0]
max_z = axial_planes[-1]
# fill the unit cell with the hex lattice
hex_prism = openmc.model.HexagonalPrism(bundle_pitch / math.sqrt(3.0), 'x', boundary_type='periodic')
outer_cell = openmc.Cell(region=-hex_prism & +min_z & -max_z, fill=hex_lattice)
# add the top and bottom reflector
top_refl_z = reactor_height + top_reflector_height
top_refl = openmc.ZPlane(z0=top_refl_z, boundary_type='vacuum')
bottom_refl_z = -bottom_reflector_height
bottom_refl = openmc.ZPlane(z0=bottom_refl_z, boundary_type='vacuum')
top_refl_cell = openmc.Cell(region=-hex_prism & +max_z & -top_refl, fill=m_reflector)
bottom_refl_cell = openmc.Cell(region=-hex_prism & -min_z & +bottom_refl, fill=m_reflector)
top_refl_cell.temperature = specs.inlet_T
bottom_refl_cell.temperature = coolant_outlet_temp
model.geometry = openmc.Geometry([outer_cell, top_refl_cell, bottom_refl_cell])
### Settings ###
settings = openmc.Settings()
settings.particles = 10000
settings.inactive = n_inactive
settings.batches = settings.inactive + n_active
settings.temperature['method'] = 'interpolation'
settings.temperature['range'] = (294.0, 1500.0)
l = bundle_pitch / math.sqrt(3.0)
lower_left = (-l, -l, reactor_bottom)
upper_right = (l, l, reactor_top)
source_dist = openmc.stats.Box(lower_left, upper_right, only_fissionable=True)
source = openmc.IndependentSource(space=source_dist)
settings.source = source
if (add_entropy_mesh):
entropy_mesh = openmc.RegularMesh()
entropy_mesh.lower_left = lower_left
entropy_mesh.upper_right = upper_right
entropy_mesh.dimension = (6, 6, 20)
settings.entropy_mesh = entropy_mesh
vol_calc = openmc.VolumeCalculation([outer_cell],
100_000_000, lower_left, upper_right)
settings.volume_calculations = [vol_calc]
model.settings = settings
m_colors[m_fuel] = 'palegreen'
m_colors[m_graphite_c_buffer] = 'sandybrown'
m_colors[m_graphite_pyc] = 'orange'
m_colors[m_sic] = 'yellow'
m_colors[m_graphite_matrix] = 'darkblue'
m_colors[m_b4c] = 'lightskyblue'
bundle_p_rounded = int(bundle_pitch)
plot1 = openmc.Plot()
plot1.filename = 'plot1'
plot1.width = (2 * bundle_pitch, 2 * axial_section_height)
plot1.basis = 'xz'
plot1.origin = (0.0, 0.0, reactor_height / 2.0)
plot1.pixels = (100 * 2 * bundle_p_rounded, int(100 * 3 * axial_section_height))
plot1.color_by = 'cell'
plot2 = openmc.Plot()
plot2.filename = 'plot2'
plot2.width = (1.5 * bundle_pitch, 1.5 * bundle_pitch)
plot2.basis = 'xy'
plot2.origin = (0.0, 0.0, axial_section_height / 4.0)
plot2.pixels = (500 * bundle_p_rounded, 500 * bundle_p_rounded)
plot2.color_by = 'material'
plot2.colors = m_colors
plot3 = openmc.Plot()
plot3.filename = 'plot3'
plot3.width = plot2.width
plot3.basis = plot2.basis
plot3.origin = plot2.origin
plot3.pixels = plot2.pixels
plot3.color_by = 'cell'
model.plots = openmc.Plots([plot1, plot2, plot3])
return model
def main():
ap = ArgumentParser()
ap.add_argument('-n', dest='n_axial', type=int, default=50,
help='Number of axial cell divisions')
ap.add_argument('-s', '--entropy', action='store_true',
help='Whether to add a Shannon entropy mesh')
ap.add_argument('-i', dest='n_inactive', type=int, default=500,
help='Number of inactive cycles')
ap.add_argument('-a', dest='n_active', type=int, default=2000,
help='Number of active cycles')
args = ap.parse_args()
model = assembly(args.n_axial, args.n_inactive, args.n_active, args.entropy)
model.export_to_xml()
if __name__ == "__main__":
main()
(tutorials/gas_assembly/assembly.py)The level on which we will apply feedback from MOOSE is 1, because the geometry consists of a hexagonal lattice (level 0), and we want to apply individual cell feedback within that lattice (level 1). For the solid phase, this selection is equivalent to applying a single temperature (per compact and per layer) for a compact region - all TRISO particles and the surrounding matrix in each compact receives a uniform temperature. Finally, to accelerate the particle tracking, we:
Repeat the same TRISO universe in each axial layer and within each compact
Superimpose a Cartesian search lattice in the fuel channel regions.
The OpenMC geometry, colored by either cell ID or instance, is shown in Figure 3. Not shown are the axial reflectors on the top and bottom of the assembly. The lateral faces are periodic, while the top and bottom boundaries are vacuum. The Cartesian search lattice in the fuel compact regions is also visible in Figure 3.
Cardinal applies uniform temperature and density feedback to OpenMC for each unique cell ID instance combination. For this setup, OpenMC receives on each axial plane a total of 721 temperatures and 108 densities (one density per coolant channel). With references to the colors shown in Figure 3, the 721 cell temperatures correspond to:
The solid temperature is provided by the MOOSE heat transfer module, while the fluid temperature and density are provided by THM. Because we will run OpenMC second, the initial fluid temperature is set to the same initial condition that is imposed in the MOOSE solid model. The fluid density is then set using the ideal gas EOS at a fixed pressure of 7.1 MPa given the imposed temperature, i.e. .
To create the XML files required to run OpenMC, run the script:
python assembly.py
You can also use the XML files checked in to the tutorials/gas_assembly
directory; if you use these already-existing files, you will also need to download the geometry.xml
file from Box; this file is large due to the saved TRISO geometry information.
THM Model
The Thermal Hydraulics Module (THM) solves for conservation of mass, momentum, and energy with 1-D area averages of the Navier-Stokes equations,
(2)
(3)
(4)
where is the coordinate along the flow length, is the channel cross-sectional area, is the fluid density, is the -component of velocity, is the average pressure on the curve boundary, is the fluid total energy, is the friction factor, is the wall heat transfer coefficient, is the heat transfer area density, is the wall temperature, and is the area average bulk fluid temperature.
In this tutorial, we use 50 elements for each channel. The mesh is constructed automatically within THM. To simplify the specification of material properties, the fluid geometry uses a length unit of meters. The heat flux imposed in the THM elements is obtained by area averaging the heat flux from the heat conduction model in 50 layers along the fluid-solid interface. For the reverse transfer, the wall temperature sent to MOOSE heat conduction is set to a uniform value along the fluid-solid interface according to a nearest-node mapping to the THM elements.
Because THM will run last in the coupled case, initial conditions are only required for pressure, fluid temperature, and velocity, which are set to uniform distributions.
Multiphysics Coupling
A summary of the data transfers between the three applications is shown in Figure 4. The inset describes the 1-D/3-D data transfers with THM.
Solid Input Files
The solid phase is solved with the MOOSE heat transfer module, and is described in the solid.i
input. We define a number of constants at the beginning of the file and set up the mesh from a file.
# copy-pasta from common_input.i
inlet_T = 598.0 # inlet fluid temperature (K)
buffer_k = 0.5 # buffer thermal conductivity (W/m/K)
PyC_k = 4.0 # PyC thermal conductivity (W/m/K)
SiC_k = 13.9 # SiC thermal conductivity (W/m/K)
kernel_k = 3.5 # fissil kernel thermal conductivity (W/m/K)
matrix_k = 15.0 # graphite matrix thermal conductivity (W/m/K)
num_layers_for_plots = 50 # number of layers to average fields over for plotting
triso_pf = 0.15 # TRISO packing fraction (%)
kernel_radius = 214.85e-6 # fissile kernel outer radius (m)
buffer_radius = 314.85e-6 # buffer outer radius (m)
iPyC_radius = 354.85e-6 # inner PyC outer radius (m)
SiC_radius = 389.85e-6 # SiC outer radius (m)
oPyC_radius = 429.85e-6 # outer PyC outer radius (m)
# compute the volume fraction of each TRISO layer in a TRISO particle
# for use in computing average thermophysical properties
kernel_fraction = ${fparse kernel_radius^3 / oPyC_radius^3}
buffer_fraction = ${fparse (buffer_radius^3 - kernel_radius^3) / oPyC_radius^3}
ipyc_fraction = ${fparse (iPyC_radius^3 - buffer_radius^3) / oPyC_radius^3}
sic_fraction = ${fparse (SiC_radius^3 - iPyC_radius^3) / oPyC_radius^3}
opyc_fraction = ${fparse (oPyC_radius^3 - SiC_radius^3) / oPyC_radius^3}
[Mesh]
type = FileMesh
file = solid_mesh_in.e
[]
(tutorials/gas_assembly/solid.i)Next, we define the temperature variable, T
, and specify the governing equations and boundary conditions we will apply.
[Variables]
[T]
initial_condition = ${inlet_T}
[]
[]
[Kernels]
[diffusion]
type = HeatConduction
variable = T
[]
[source]
type = CoupledForce
variable = T
v = power
block = 'compacts'
[]
[]
[BCs]
[pin_outer]
type = MatchedValueBC
variable = T
v = thm_temp
boundary = 'fluid_solid_interface'
[]
[]
(tutorials/gas_assembly/solid.i)The MOOSE heat transfer module will receive power from OpenMC in the form of an AuxVariable, so we define a receiver variable for the fission power, as power
. The MOOSE heat conduction module will also receive a fluid wall temperature from THM as another AuxVariable which we name thm_temp
. Finally, the MOOSE heat transfer module will send the heat flux to THM, so we add a variable named flux
that we will use to compute the heat flux using the DiffusionFluxAux auxiliary kernel.
[AuxVariables]
[thm_temp]
[]
[flux]
family = MONOMIAL
order = CONSTANT
[]
[power]
family = MONOMIAL
order = CONSTANT
[]
[]
[AuxKernels]
[flux]
type = DiffusionFluxAux
diffusion_variable = T
component = normal
diffusivity = thermal_conductivity
variable = flux
boundary = 'fluid_solid_interface'
[]
[]
(tutorials/gas_assembly/solid.i)We use functions to define the thermal conductivities. The material properties for the TRISO compacts are taken as volume averages of the various constituent materials. We will evaluate the thermal conductivity for the boron carbide as a function of temperature by using t
(which usually is interpeted as time) as a variable to represent temperature. This is syntax supported by the HeatConductionMaterials used to apply these functions to the thermal conductivity.
[Functions]
[k_graphite]
type = ParsedFunction
expression = '${matrix_k}'
[]
[k_TRISO]
type = ParsedFunction
expression = '${kernel_fraction} * ${kernel_k} + ${buffer_fraction} * ${buffer_k} + ${fparse ipyc_fraction + opyc_fraction} * ${PyC_k} + ${sic_fraction} * ${SiC_k}'
[]
[k_compacts]
type = ParsedFunction
expression = '${triso_pf} * k_TRISO + ${fparse 1.0 - triso_pf} * k_graphite'
symbol_names = 'k_TRISO k_graphite'
symbol_values = 'k_TRISO k_graphite'
[]
[k_b4c]
type = ParsedFunction
expression = '5.096154e-6 * t - 1.952360e-2 * t + 2.558435e1'
[]
[]
[Materials]
[graphite]
type = HeatConductionMaterial
thermal_conductivity_temperature_function = k_graphite
temp = T
block = 'graphite'
[]
[compacts]
type = HeatConductionMaterial
thermal_conductivity_temperature_function = k_compacts
temp = T
block = 'compacts'
[]
[poison]
type = HeatConductionMaterial
thermal_conductivity_temperature_function = k_b4c
temp = T
block = 'poison'
[]
[]
(tutorials/gas_assembly/solid.i)We define a number of postprocessors for querying the solution as well as for normalizing the fission power and heat flux, to be described at greater length in Neutronics Input Files .
[Postprocessors]
[flux_integral]
# evaluate the total heat flux for normalization
type = SideDiffusiveFluxIntegral
diffusivity = thermal_conductivity
variable = T
boundary = 'fluid_solid_interface'
[]
[max_fuel_T]
type = ElementExtremeValue
variable = T
value_type = max
block = 'compacts'
[]
[max_block_T]
type = ElementExtremeValue
variable = T
value_type = max
block = 'graphite'
[]
[power]
# evaluate the total power for normalization
type = ElementIntegralVariablePostprocessor
variable = power
block = 'compacts'
execute_on = 'transfer'
[]
[]
(tutorials/gas_assembly/solid.i)For visualization purposes only, we add LayeredAverages for the fuel and block temperatures. These will average the temperature in layers oriented in the direction, which we will use for plotting axial temperature distributions. We output the results of these userobjects to CSV using SpatialUserObjectVectorPostprocessors and by setting csv = true
in the output.
[UserObjects]
[average_fuel_axial]
type = LayeredAverage
variable = T
direction = z
num_layers = ${num_layers_for_plots}
block = 'compacts'
[]
[average_block_axial]
type = LayeredAverage
variable = T
direction = z
num_layers = ${num_layers_for_plots}
block = 'graphite'
[]
[]
[VectorPostprocessors]
[fuel_axial_avg]
type = SpatialUserObjectVectorPostprocessor
userobject = average_fuel_axial
[]
[block_axial_avg]
type = SpatialUserObjectVectorPostprocessor
userobject = average_block_axial
[]
[]
[Outputs]
exodus = true
csv = true
print_linear_residuals = false
[]
(tutorials/gas_assembly/solid.i)Finally, we specify a Transient executioner. Because there are no time-dependent kernels in this input file, this is equivalent in practice to using a Steady executioner, but allows you to potentially sub-cycle the MOOSE heat conduction solve relative to the OpenMC solve (such as if you wanted to converge the CHT fully inbetween data exchanges with OpenMC).
[Executioner]
type = Transient
nl_abs_tol = 1e-5
nl_rel_tol = 1e-16
petsc_options_value = 'hypre boomeramg'
petsc_options_iname = '-pc_type -pc_hypre_type'
[]
(tutorials/gas_assembly/solid.i)Fluid Input Files
The fluid phase is solved with THM, and is described in the thm.i
input. This input file is built using syntax specific to THM - we will only briefly cover this syntax, and instead refer users to the THM documentation for more information. First we define a number of constants at the beginning of the file and apply some global settings. We set the initial conditions for pressure, velocity, and temperature and indicate the fluid EOS object using IdealGasFluidProperties.
# copy-pasta from common.i
inlet_T = 598.0 # inlet fluid temperature (K)
mdot = ${fparse 117.3 / 12 / 108} # fluid mass flowrate (kg/s)
outlet_P = 7.1e6 # fluid outlet pressure (Pa)
channel_diameter = 0.016 # diameter of the coolant channels (m)
height = 6.343 # height of the assembly (m)
num_layers_for_THM = 50 # number of elements in the THM model; for the converged case,
# we set this to 150
[GlobalParams]
initial_p = ${outlet_P}
initial_T = ${inlet_T}
initial_vel = ${fparse mdot / outlet_P / 8.3144598 * 4.0e-3 / inlet_T / (pi * channel_diameter * channel_diameter / 4.0)}
rdg_slope_reconstruction = full
closures = none
fp = helium
[]
[Closures]
[none]
type = Closures1PhaseNone
[]
[]
[FluidProperties]
[helium]
type = IdealGasFluidProperties
molar_mass = 4e-3
gamma = 1.668282 # should correspond to Cp = 5189 J/kg/K
k = 0.2556
mu = 3.22639e-5
[]
[]
(tutorials/gas_assembly/thm.i)Next, we define the "components" in the domain. These components essentially consist of the physics equations and boundary conditions solved by THM, but expressed in THM-specific syntax. These components define single-phase flow in a pipe, an inlet mass flowrate boundary condition, an outlet pressure boundary condition, and heat transfer to the pipe wall.
[Components]
[channel]
type = FlowChannel1Phase
position = '0 0 ${height}'
orientation = '0 0 -1'
A = '${fparse pi * channel_diameter * channel_diameter / 4}'
D_h = ${channel_diameter}
length = ${height}
n_elems = ${num_layers_for_THM}
[]
[inlet]
type = InletMassFlowRateTemperature1Phase
input = 'channel:in'
m_dot = ${mdot}
T = ${inlet_T}
[]
[outlet]
type = Outlet1Phase
input = 'channel:out'
p = ${outlet_P}
[]
[ht_ext]
type = HeatTransferFromExternalAppHeatFlux1Phase
flow_channel = channel
P_hf = '${fparse channel_diameter * pi}'
[]
[]
(tutorials/gas_assembly/thm.i)Associated with these components are a number of closures, defined as materials. We set up the Churchill correlation for the friction factor and the Dittus-Boelter correlation for the convective heat transfer coefficient. Additional materials are created to represent dimensionless numbers and other auxiliary terms, such as the wall temperature. As can be seen here, the Material system is not always used to represent quantities traditionally thought of as "material properties."
[Materials]
# wall friction closure
[f_mat]
type = ADWallFrictionChurchillMaterial
block = channel
D_h = D_h
f_D = f_D
vel = vel
rho = rho
mu = mu
[]
# Wall heat transfer closure (all important is in Nu_mat)
[Re_mat]
type = ADReynoldsNumberMaterial
block = channel
Re = Re
D_h = D_h
mu = mu
vel = vel
rho = rho
[]
[Pr_mat]
type = ADPrandtlNumberMaterial
block = channel
cp = cp
mu = mu
k = k
[]
[Nu_mat]
type = ADParsedMaterial
block = channel
# Dittus-Boelter
expression = '0.022 * pow(Re, 0.8) * pow(Pr, 0.4)'
property_name = 'Nu'
material_property_names = 'Re Pr'
[]
[Hw_mat]
type = ADConvectiveHeatTransferCoefficientMaterial
block = channel
D_h = D_h
Nu = Nu
k = k
[]
[T_wall]
type = ADTemperatureWall3EqnMaterial
Hw = Hw
T = T
q_wall = q_wall
[]
[]
(tutorials/gas_assembly/thm.i)THM computes the wall temperature to apply a boundary condition in the MOOSE heat transfer module. To convert the T_wall
material into an auxiliary variable, we use the ADMaterialRealAux.
[AuxVariables]
[T_wall]
family = MONOMIAL
order = CONSTANT
[]
[]
[AuxKernels]
[Tw_aux]
type = ADMaterialRealAux
block = channel
variable = T_wall
property = T_wall
[]
[]
(tutorials/gas_assembly/thm.i)Finally, we set the preconditioner, a Transient executioner, and set an Exodus output. The steady_state_detection
and steady_state_tolerance
parameters will automatically terminate the THM solution once the relative change in the solution is less than .
[Preconditioning]
[pc]
type = SMP
full = true
[]
[]
[Executioner]
type = Transient
dt = 0.1
start_time = 0
steady_state_detection = true
steady_state_tolerance = 1e-08
nl_rel_tol = 1e-5
nl_abs_tol = 1e-6
petsc_options_iname = '-pc_type'
petsc_options_value = 'lu'
solve_type = NEWTON
line_search = basic
[]
[Outputs]
exodus = true
print_linear_residuals = false
[console]
type = Console
outlier_variable_norms = false
[]
[]
(tutorials/gas_assembly/thm.i)As you may notice, this THM input file only models a single coolant flow channel. We will leverage a feature in MOOSE that allows a single application to be repeated multiple times throughout a main application without having to merge input files or perform other transformations. We will run OpenMC as the main application; the syntax needed to achieve this setup is covered next.
Neutronics Input Files
The neutronics physics is solved with OpenMC over the entire domain. The OpenMC wrapping is described in the openmc.i
file. We begin by defining a number of constants and by setting up the mesh mirror on which OpenMC will receive temperature and density from the coupled applications, and on which OpenMC will write the fission heat source. Because the coupled MOOSE applications use length units of meters, the mesh mirror must also be in units of meters in order to obtain correct data transfers. For simplicity, we use the same solid mesh as used by the solid heat conduction solution, but this is not required. For the fluid region, we use MOOSE mesh generators to construct a mesh for a single coolant channel, and then repeat it for the 108 coolant channels.
num_layers_for_THM = 50 # number of elements in the THM model; for the converged
# case, we set this to 150
[Mesh]
# mesh mirror for the solid regions
[solid]
type = FileMeshGenerator
file = solid_mesh_in.e
[]
# create a mesh for a single coolant channel; because we will receive uniform
# temperatures and densities from THM on each x-y plane, we can use a very coarse
# mesh in the radial direction
[coolant_face]
type = AnnularMeshGenerator
nr = 1
nt = 8
rmin = 0.0
rmax = ${fparse channel_diameter / 2.0}
[]
[extrude]
type = AdvancedExtruderGenerator
input = coolant_face
num_layers = ${num_layers_for_THM}
direction = '0 0 1'
heights = '${height}'
top_boundary = '300' # inlet
bottom_boundary = '400' # outlet
[]
[rename]
type = RenameBlockGenerator
input = extrude
old_block = '1'
new_block = '101'
[]
# repeat the coolant channels and then combine together to get a combined mesh mirror
[repeat]
type = CombinerGenerator
inputs = rename
positions_file = coolant_channel_positions.txt
[]
[add]
type = CombinerGenerator
inputs = 'solid repeat'
[]
[]
(tutorials/gas_assembly/openmc.i)Before progressing further, we first need to describe the multiapp structure connecting OpenMC, MOOSE heat conduction, and THM. We set the main application to OpenMC, and will have both MOOSE heat conduction and THM as "sibling" sub-applications.
At the time of writing, the MOOSE framework does not support "sibling" multiapp transfers, meaning that the data to be communicated between MOOSE heat conduction and THM (the heat flux and wall temperature data transfers in Figure 4) must go "up a level" to their common main application. This has since been relaxed.
Therefore, we will see in the OpenMC input file information related to data transfers between MOOSE heat conduction and THM. The auxiliary variables defined for the OpenMC model are shown below.
[AuxVariables]
[material_id]
family = MONOMIAL
order = CONSTANT
[]
[cell_temperature]
family = MONOMIAL
order = CONSTANT
[]
[cell_density]
family = MONOMIAL
order = CONSTANT
[]
[thm_temp_wall]
block = '101'
[]
[flux]
[]
# just for postprocessing purposes
[thm_pressure]
block = '101'
[]
[thm_velocity]
block = '101'
[]
[z]
family = MONOMIAL
order = CONSTANT
block = 'compacts'
[]
[]
[AuxKernels]
[material_id]
type = CellMaterialIDAux
variable = material_id
[]
[cell_temperature]
type = CellTemperatureAux
variable = cell_temperature
[]
[cell_density]
type = CellDensityAux
variable = cell_density
[]
[density]
type = FluidDensityAux
variable = density
p = ${outlet_P}
T = thm_temp
fp = helium
execute_on = 'timestep_begin linear'
[]
[z]
type = ParsedAux
variable = z
use_xyzt = true
expression = 'z'
[]
[]
(tutorials/gas_assembly/openmc.i)For visualization purposes, we use a CellTemperatureAux to view the temperature set in each OpenMC cell and a CellDensityAux to view the density set in each fluid OpenMC cell. To understand how the OpenMC model maps to the [Mesh]
, we also include CellMaterialIDAux. Cardinal will also automatically output a variable named cell_id
(CellIDAux) and a variable named cell_instance
( CellInstanceAux) to show the spatial mapping. Next, we add a receiver flux
variable that will hold the heat flux received from MOOSE (and sent to THM) and another receiver variable thm_temp_wall
that will hold the wall temperature received from THM (and sent to MOOSE).
Finally, to reduce the number of transfers from THM, we will receive fluid temperature from THM, but re-compute the density locally in the OpenMC wrapping using a FluidDensityAux with the same EOS as used in the THM input files.
[FluidProperties]
[helium]
type = IdealGasFluidProperties
molar_mass = 4e-3
gamma = 1.668282 # should correspond to Cp = 5189 J/kg/K
k = 0.2556
mu = 3.22639e-5
[]
[]
(tutorials/gas_assembly/openmc.i)Next, we set initial conditions for the fluid wall temperature, the fluid bulk temperature, and the heat source. We set these initial conditions in the OpenMC wrapper because the very first time that the transfers to the MOOSE heat conduction module and to THM occur, these initial conditions will be passed.
[ICs]
[fluid_temp_wall]
type = FunctionIC
variable = thm_temp_wall
function = temp_ic
[]
[fluid_temp]
type = FunctionIC
variable = thm_temp
function = temp_ic
[]
[heat_source]
type = ConstantIC
variable = heat_source
block = 'compacts'
value = ${fparse power / (n_bundles * n_fuel_compacts_per_block) / (pi * compact_diameter * compact_diameter / 4.0 * height)}
[]
[]
[Functions]
[temp_ic]
type = ParsedFunction
expression = '${inlet_T} + (${height} - z) / ${height} * ${power} / ${mdot} / ${fluid_Cp}'
[]
[]
(tutorials/gas_assembly/openmc.i)The [Problem]
and [Tallies]
blocks are then used to specify settings for the OpenMC wrapping. We define the total power for normalization, indicate that blocks 1, 2, and 4 are solid (graphite, compacts, and poison) while block 101 is fluid. We add a CellTally to block 2, the fuel compacts. Because OpenMC solves in units of centimeters, we specify a scaling
of 100, i.e. a multiplicative factor to apply to the [Mesh]
to get into OpenMC's centimeter units.
[Problem]
type = OpenMCCellAverageProblem
identical_cell_fills = '2'
power = '${fparse power / n_bundles}'
scaling = 100.0
cell_level = 1
relaxation = constant
relaxation_factor = 0.5
# to get a faster-running tutorial, we use only 1000 particles per batch; converged
# results are instead obtained by increasing this parameter to 10000. We also use fewer
# batches to speed things up; the converged results were obtained with 500 inactive batches
# and 2000 active batches
particles = 1000
inactive_batches = 200
batches = 1000
# we will read temperature from THM (for the fluid) and MOOSE (for the solid)
# into variables we name as 'solid_temp' and 'thm_temp'. This syntax will automatically
# create those variabes for us
temperature_variables = 'solid_temp; thm_temp'
temperature_blocks = '1 2 4; 101'
density_blocks = '101'
[Tallies]
[heat_source]
type = CellTally
blocks = '2'
name = heat_source
check_equal_mapped_tally_volumes = true
output = 'unrelaxed_tally_std_dev'
[]
[]
[]
(tutorials/gas_assembly/openmc.i)Other features we use include an output of the fission tally standard deviation in units of W/m to the [Mesh]
by setting output = 'unrelaxed_tally_std_dev'
. This is used to obtain uncertainty estimates of the heat source distribution from OpenMC in the same units as the heat source. We also leverage a helper utility in CellTally by setting check_equal_mapped_tally_volumes = true
. This parameter will throw an error if the tallied OpenMC cells map to different volumes in the MOOSE domain. Because we know a priori that the equal-volume OpenMC tally cells should all map to equal volumes, this will help ensure that the volumes used for heat source normalization are also all equal. For further discussion of this setting and a pictorial description of the possible effect of non-equal mapped vlumes, please see the OpenMCCellAverageProblem documentation.
We also set identical_cell_fills
to the set of subdomains for which OpenMC's cells have identical fills. This is an optimization that greatly reduces the initialization time for large TRISO problems. During setup of an OpenMC wrapping, we need to cache all the cells contained within the TRISO compacts so that we know all the contained cells to set the temperatures for. This process can be quite time-consuming if the search needs to be repeated for every single TRISO compact cell (210 compacts times 50 axial layers = 10,500 contained cell searches). The identical_cell_fills
option is used to indicate whether your problem can leverage a speedup that applies to models where every lattice/universe-filled tally cell has exactly the same filling lattice/universe. In other words, we set up our problem to use the same TRISO universe in each layer of each fuel compact. This means that the cells filling each TRISO compact can be deduced by following a pattern based on the first two fuel compacts, letting us omit 10,498 of the contained cell searches.
When first using this optimization for a new problem, we recommend setting check_identical_cell_fills = true
so that you can do an exact comparison against the "rigorous" approach to be sure that your problem setup has the necessary prerequisites to use this feature. After you verify that no errors are thrown during setup, set check_identical_cell_fills
to false
(the default) to use this initialization speedup feature.
Because the blocks in the OpenMC mesh mirror receive temperatures from different applications, we use the temperature_variables
and temperature_blocks
parameters of OpenMCCellAverageProblem to automatically create separate variables for OpenMC to read temperature from in different parts of the domain. The temperature_blocks
and temperature_variables
parameters allow you to customize exactly the variable names from which to read temperature.
Finally, we apply a constant relaxation model to the heat source. A constant relaxation will compute the heat source in iteration as an average of the heat source from iteration and the most-recently-computed heat source, indicated here as a generic operator which represents the Monte Carlo solve:
(5)
In Eq. (5), is the relaxation factor, which we set here to 0.5. In other words, the heat source in iteration is an average of the most recent Monte Carlo solution and the previous iterate. This is necessary to accelerate the fixed point iterations and achieve convergence in a reasonable time - otherwise oscillations can occur in the coupled physics.
We run OpenMC as the main application, so we next need to define MultiApps to run the solid heat conduction model and the THM fluid model as the sub-applications. We also require a number of transfers both for 1) sending necessary coupling data between the three applications and 2) visualizing the combined THM output. To couple OpenMC to MOOSE heat conduction, we use four transfers:
MultiAppShapeEvaluationTransfer to transfer:
power from OpenMC to MOOSE (with conservation of total power)
MultiAppGeometricInterpolationTransfer to transfer:
solid temperature from MOOSE to OpenMC
wall temperature from OpenMC (which doesn't directly compute the wall temperature, but instead receives it from THM through a separate transfer) to MOOSE
MultiAppGeneralFieldNearestLocationTransfer to transfer and conserve heat flux from MOOSE to OpenMC (which isn't used directly in OpenMC, but instead gets sent later to THM through a separate transfer)
To couple OpenMC to THM, we require three transfers:
MultiAppGeneralFieldUserObjectTransfer to send the layer-averaged wall heat flux from OpenMC (which computes the layered-average heat flux from the heat flux received from MOOSE heat conduction) to THM
MultiAppGeneralFieldNearestLocationTransfer to transfer:
fluid wall temperature from THM to OpenMC (which isn't used directly in OpenMC, but instead gets sent to MOOSE heat conduction in a separate transfer)
fluid bulk temperature from THM to OpenMC
For visualization purposes, we also send the pressure and velocity computed by THM to the OpenMC mesh mirror.
[MultiApps]
[bison]
type = TransientMultiApp
input_files = 'solid.i'
execute_on = timestep_begin
[]
[thm]
type = FullSolveMultiApp
input_files = 'thm.i'
execute_on = timestep_end
max_procs_per_app = 1
bounding_box_padding = '0.1 0.1 0'
positions_file = coolant_channel_positions.txt
output_in_position = true
[]
[]
[Transfers]
[solid_temp_to_openmc]
type = MultiAppGeometricInterpolationTransfer
source_variable = T
variable = solid_temp
from_multi_app = bison
[]
[heat_flux_to_openmc]
type = MultiAppGeneralFieldNearestLocationTransfer
source_variable = flux
variable = flux
from_multi_app = bison
from_boundaries = 'fluid_solid_interface'
to_boundaries = 'fluid_solid_interface'
from_postprocessors_to_be_preserved = flux_integral
to_postprocessors_to_be_preserved = flux_integral
[]
[source_to_bison]
type = MultiAppGeneralFieldShapeEvaluationTransfer
source_variable = heat_source
variable = power
to_multi_app = bison
from_postprocessors_to_be_preserved = heat_source
to_postprocessors_to_be_preserved = power
[]
[thm_temp_to_bison]
type = MultiAppGeometricInterpolationTransfer
source_variable = thm_temp_wall
variable = thm_temp
to_multi_app = bison
[]
[q_wall_to_thm]
type = MultiAppGeneralFieldUserObjectTransfer
variable = q_wall
to_multi_app = thm
source_user_object = q_wall_avg
[]
[T_wall_from_thm]
type = MultiAppGeneralFieldNearestLocationTransfer
source_variable = T_wall
from_multi_app = thm
variable = thm_temp_wall
to_boundaries = 'fluid_solid_interface'
[]
[T_bulk_from_thm]
type = MultiAppGeneralFieldNearestLocationTransfer
source_variable = T
from_multi_app = thm
variable = thm_temp
[]
# just for postprocessing purposes
[pressure_from_thm]
type = MultiAppGeneralFieldNearestLocationTransfer
source_variable = p
from_multi_app = thm
variable = thm_pressure
[]
[velocity_from_thm]
type = MultiAppGeneralFieldNearestLocationTransfer
source_variable = vel_z
from_multi_app = thm
variable = thm_velocity
[]
[]
(tutorials/gas_assembly/openmc.i)To compute the layer-averaged heat flux on the surface of each coolant channel (which is used as a boundary condition in THM), we use a NearestPointLayeredSideAverage user object, where by providing the center points of each of the coolant channels, we can get a unique heat flux along each channel wall. We also add several LayeredAverage user objects in order to compute radially-averaged power, temperatures, pressures, and velocities that we will use later in making axial plots of the solution. We can automatically output these user objects into CSV format by translating the user objects into SpatialUserObjectVectorPostprocessors. A number of postprocessors are included related to the Monte Carlo solution, as well as inlet pressure and pressure drop. See the next section for further description.
[UserObjects]
[q_wall_avg]
type = NearestPointLayeredSideAverage
boundary = 'fluid_solid_interface'
variable = flux
# Note: make this to match the num_elems in the channel
direction = z
num_layers = ${num_layers_for_THM}
points_file = coolant_channel_positions.txt
direction_min = 0.0
direction_max = ${height}
[]
[average_power_axial]
type = LayeredAverage
variable = heat_source
direction = z
num_layers = ${num_layers_for_plots}
block = 'compacts'
[]
[average_fluid_axial]
type = LayeredAverage
variable = thm_temp
direction = z
num_layers = ${num_layers_for_plots}
block = '101'
[]
[average_pressure]
type = LayeredAverage
variable = thm_pressure
direction = z
num_layers = ${num_layers_for_plots}
block = '101'
[]
[average_axial_velocity]
type = LayeredAverage
variable = thm_velocity
direction = z
num_layers = ${num_layers_for_plots}
block = '101'
[]
[]
[VectorPostprocessors]
[power_avg]
type = SpatialUserObjectVectorPostprocessor
userobject = average_power_axial
[]
[fluid_avg]
type = SpatialUserObjectVectorPostprocessor
userobject = average_fluid_axial
[]
[pressure_avg]
type = SpatialUserObjectVectorPostprocessor
userobject = average_pressure
[]
[velocity_avg]
type = SpatialUserObjectVectorPostprocessor
userobject = average_axial_velocity
[]
[]
[Postprocessors]
[flux_integral]
type = SideIntegralVariablePostprocessor
variable = flux
boundary = 'fluid_solid_interface'
execute_on = 'transfer linear'
[]
[heat_source]
type = ElementIntegralVariablePostprocessor
variable = heat_source
execute_on = 'transfer initial timestep_end'
[]
[max_tally_rel_err]
type = TallyRelativeError
value_type = max
[]
[k]
type = KEigenvalue
[]
[k_std_dev]
type = KStandardDeviation
[]
[min_power]
type = ElementExtremeValue
variable = heat_source
value_type = min
block = 'compacts'
[]
[max_power]
type = ElementExtremeValue
variable = heat_source
value_type = max
block = 'compacts'
[]
[z_max_power]
type = ElementExtremeValue
proxy_variable = heat_source
variable = z
block = 'compacts'
[]
[max_Tf]
type = ElementExtremeValue
variable = thm_temp
block = '101'
[]
[P_in]
type = SideAverageValue
variable = thm_pressure
boundary = '300'
[]
[pressure_drop]
type = LinearCombinationPostprocessor
pp_names = 'P_in'
pp_coefs = '1.0'
b = '${fparse -outlet_P}'
[]
[]
(tutorials/gas_assembly/openmc.i)Finally, we use a Transient executioner and specify Exodus and CSV output formats. Note that the time step size is inconsequential in this case, but instead represents the Picard iteration. We will run for 10 "time steps," which represent fixed point iterations.
[Executioner]
type = Transient
num_steps = 10
[]
[Outputs]
exodus = true
csv = true
hide = 'P_in flux_integral z'
[]
(tutorials/gas_assembly/openmc.i)Execution and Postprocessing
To run the coupled calculation,
mpiexec -np 4 cardinal-opt -i common_input.i openmc.i --n-threads=2
This will run with 4 MPI processes and 2 OpenMP threads per rank. This tutorial uses quite large meshes due to the 6 meter height of the domain - if you wish to run this tutorial with fewer computational resources, you can reduce the height and the various mesh parameters (number of extruded layers and elements in the THM domain) and then recreate the OpenMC model and meshes. Recall that all results shown in this section correspond to the same input files but with 300 extrusion layers in the solid, 150 THM elements per channel, 2000 particles per batch, 500 inactive batches, and 2000 active batches - to get a faster-running tutorial, we reduced all of these parameters to coarser values.
When the simulation has completed, you will have created a number of different output files:
openmc_out.e
, an Exodus file with the OpenMC solution and the data that was ultimately transferred in/out of OpenMCopenmc_out_bison0.e
, an Exodus file with the solid solutionopenmc_out_thm<n>.e
, Exodus files with each of the<n>
THM solutionsopenmc_out.csv
, a CSV file with the results of the postprocessors in the OpenMC wrapping input file for each time stepopenmc_out_bison0.csv
, a CSV file with the results of the postprocessors in the solid heat conduction input file for each time stepopenmc_out_bison0_block_axial_avg_<n>
.csv, a CSV file with the layer-averaged block temperature at time step<n>
openmc_out_bison0_flux_axial_avg_<n>
.csv, a CSV file with the layer-averaged fluid-solid interface heat flux at time step<n>
openmc_out_bison0_fuel_axial_avg_<n>
.csv, a CSV file with the layer-averaged compact temperature at time step<n>
openmc_out_fluid_avg_<n>.csv
, a CSV file with the layer-averaged fluid bulk temperature at time step<n>
openmc_out_power_avg_<n>
.csv, a CSV file with the layer-averaged heat source at time step<n>
openmc_out_pressure_avg_<n>
.csv, a CSV file with the layer-averaged pressure at time step<n>
openmc_out_velocity_avg_<n>.csv
, a CSV file with the layer-average axial fluid velocity at time step<n>
Coupled convergence is defined at the point when 1) the eigenvalue is within the uncertainty band of the previous iteration and 2) there is less than 2 K absolute change in maximum fuel, block, and fluid temperatures. Convergence is obtained in 6 fixed point iterations. Figure 5 shows the maximum fuel, compact, and fluid temperatures, along with the eigenvalue, as a function of iteration number for all 10 iterations run. For each iteration, we first run MOOSE heat conduction (which we indicate as iterations A1, A2, and so on), followed by OpenMC (which we indicate as iterations B1, B2, and so on), and finally THM (which we indicate as iterations C1, C2, and so on). In other words, in iteration :
Iteration A represents a MOOSE heat conduction solve using the power and fluid-solid wall temperature from iteration
Iteration B represents an OpenMC solve using the solid temperature from iteration and the fluid temperature and density from iteration
Iteration C represents a THM solve using the fluid-solid wall heat flux from iteration
Figure 6 shows the radially-averaged power from OpenMC as a function of iteration number. There is essentially no change in the axial distribution beyond 6 fixed point iterations, which further confirms that we have obtained a converged solution. The remainder of the depicted results correpond to iteration 6.
Figure 7 shows the fission power distribution computed by OpenMC. The inset shows the power distribution on an plane 2.15 meters from the inlet, where the maximum power occurs. Slight azimuthal asymmetries exist due to the finite tally uncertainty. Neutrons reflecting back into the fuel region from the axial reflectors cause local power peaking at the ends of the assembly, while the negative fuel temperature coefficient causes the power distribution to shift towards the reactor inlet where temperatures are lower. The six poison compacts in the corners result in local power depression such that the highest compact powers occur near the center of the assembly.
Figure 8 shows the solid temperature (left) computed by MOOSE and the cell temperature imposed in OpenMC (right). The bottom row shows the temperature on an plane 2.15 meters from the inlet. The insulated boundary conditions, combined with a lower "density" of coolant channels near the lateral faces, result in higher compact temperatures near the assembly peripheries, except in the vicinity of the poison pins where the increased absorption reduces the local power densities. Each OpenMC cell is set to the volume-average temperature from the mesh mirror elements whose centroids mapped to each cell; a similar procedure is used to set cell temperatures and densities for the fluid cells.
Figure 9 shows the solid temperature computed by MOOSE on several planes with the fluid temperature computed by THM shown as tubes. An inset shows the fluid temperature on the outlet plane. The absence of compacts in the center region results in the lowest fluid temperatures in this region, while the highest fluid temperatures are observed for channels surrounded by 6 compacts that are sufficiently close to the periphery to be affected by the lateral insulated boundary conditions.
Finally, Figure 10 shows the radially-averaged fission distribution and fluid, compact, and graphite temperatures (left); and velocity and pressure (right) as a function of axial position. The negative temperature feedback results in a top-peaked power distribution. The fuel temperature peaks near the mid-plane due to the combined effects of the relatively high power density and the continually-increasing fluid temperature with distance from the inlet. The pressure gradient is nearly constant with axial position. Due to mass conservation, the heating of the fluid results in the velocity increasing with distance from the inlet.
References
- INL.
High-Temperature Gas-Cooled Test Reactor Point Design.
Technical Report INL/EXT-16-38296, Idaho National Laboratory, 2016.
URL: https://tinyurl.com/v9a4hym5.[BibTeX]
- A.J. Novak, D. Andrs, P. Shriwise, D. Shaver, P.K. Romano, E. Merzari, and P. Keutelian.
Coupled Monte Carlo and Thermal-Hydraulics Modeling of a Prismatic Gas Reactor Fuel Assembly Using Cardinal.
In Proceedings of Physor. 2021.[BibTeX]
- E. Shemon, K. Mo, Y. Miao, Y. Jung, S. Richards, A. Oaks, and S. Kumar.
MOOSE Framework Enhancements for Meshing Reactor Geometries.
In Proceedings of Physor. 2021.[BibTeX]