Stochastic Simulations with NekRS

In this tutorial, you will learn how to:

  • Stochastically perturb multiphysics NekRS simulations for forward Uncertainty Quantification (UQ) using the MOOSE Stochastic Tools Module.

The MOOSE Stochastic Tools Module (STM) is an open-source module in the MOOSE framework for seamlessly integrating massively-parallel UQ, surrogate modeling, and machine learning with MOOSE-based simulations. Here, we will perform a "parameter study" of a conjugate heat transfer coupling of MOOSE heat conduction and NekRS. We will stochastically sample a parameter (the thermal conductivity in the NekRS model) from a distribution times, each time re-running the multiphysics simulation. For each independent run, we will compute a Quantity of Interest (QOI) (maximum NekRS temperature) and evaluate various statistical quantities for that QOI (e.g. mean, standard deviation, confidence intervals).

Cardinal's interface between NekRS and the STM is designed in an infinitely-flexible manner. Any quantity in NekRS which can be modified from the .udf and .oudf files can be stochastically perturbed by MOOSE. In this example, we will just perturb one quantity (thermal conductivity), though you can simultaneously perturb an arbitrary number of parameters. Other notable features include:

  • You can perturb parameters nested arbitrarily "beneath" the driver stochastic application. We will use this to send a thermal conductivity value from the STM, to a solid heat conduction sub-app, which then passes the value to a NekRS sub-sub app.

  • You can use a single perturbed value in multiple locations in dependent applications. For example, if you use a multiscale fluid model with NekRS in one part of the domain and the MOOSE Navier-Stokes module in another region of space, you could send a single perturbed value for fluid density to both applications concurrently.

Before reading this tutorial, we recommend reading through Examples 1 through 3 on the STM page to familiarize yourself with the basic syntax for stochastic simulations.

To access this tutorial,


cd cardinal/tutorials/nek_stochastic

Problem Setup

In this problem, we couple a MOOSE solid heat conduction model with a NekRS heat conduction model in two adjacent domains, shown in Figure 1. A uniform heat flux of 1000 W/m is applied on the left face of the MOOSE region, whereas a fixed Dirichlet temperature of 500 K is applied on the right face of the NekRS region. MOOSE and NekRS are coupled via heat flux and temperature on the interface. MOOSE will send a heat flux boundary condition to NekRS, while NekRS will return an interface temperature to MOOSE for use as a Dirichlet condition. All other surfaces are insulated.

Figure 1: Two-region heat conduction model used for demonstrating stochastic simulations

We choose this problem for demonstration because the steady-state solution has a simple analytic form. The general shape for the temperature distribution is shown as the orange line in the right of Figure 1, where the slopes of the temperature in the two blocks are governed by their respective thermal conductivities. The temperature at the interface, , between MOOSE and NekRS is governed by

(1)

where is the imposed heat flux on the solid model, is the thermal conductivity of the NekRS domain, is the width of the NekRS region. Similarly, the temperature at the left face, , is governed by

where is the thermal conductivity of the MOOSE domain and is the width of the MOOSE region. We will stochastically perturb , the thermal conductivity of the NekRS domain, by sampling it from a normal distribution with mean value of 5.0 and standard deviation of 1.0. For each choice of , we will run this coupled heat transfer model, and predict as our QOI.

NekRS Model

First, we need to build a mesh for NekRS. We use MOOSE mesh generators to build the rectangular prism with . Because NekRS's sidesets must be numbered contiguously beginning from 1, we need to shift the default sideset IDs created by the GeneratedMeshGenerator.

[Mesh<<<{"href": "../syntax/Mesh/index.html"}>>>]
  [box]
    type = GeneratedMeshGenerator<<<{"description": "Create a line, square, or cube mesh with uniformly spaced or biased elements.", "href": "../source/meshgenerators/GeneratedMeshGenerator.html"}>>>
    dim<<<{"description": "The dimension of the mesh to be generated"}>>> = 3
    nx<<<{"description": "Number of elements in the X direction"}>>> = 10
    ny<<<{"description": "Number of elements in the Y direction"}>>> = 4
    nz<<<{"description": "Number of elements in the Z direction"}>>> = 4
    xmin<<<{"description": "Lower X Coordinate of the generated mesh"}>>> = 0.0
    xmax<<<{"description": "Upper X Coordinate of the generated mesh"}>>> = 1.0
    ymin<<<{"description": "Lower Y Coordinate of the generated mesh"}>>> = 0.0
    ymax<<<{"description": "Upper Y Coordinate of the generated mesh"}>>> = 1.0
    zmin<<<{"description": "Lower Z Coordinate of the generated mesh"}>>> = 0.0
    zmax<<<{"description": "Upper Z Coordinate of the generated mesh"}>>> = 2.0
    bias_x<<<{"description": "The amount by which to grow (or shrink) the cells in the x-direction."}>>> = 1.2
    elem_type<<<{"description": "The type of element from libMesh to generate (default: linear element for requested dimension)"}>>> = HEX20
  []
  [rename]
    type = RenameBoundaryGenerator<<<{"description": "Changes the boundary IDs and/or boundary names for a given set of boundaries defined by either boundary ID or boundary name. The changes are independent of ordering. The merging of boundaries is supported.", "href": "../source/meshgenerators/RenameBoundaryGenerator.html"}>>>
    input<<<{"description": "The mesh we want to modify"}>>> = box
    old_boundary<<<{"description": "Elements with these boundary ID(s)/name(s) will be given the new boundary information specified in 'new_boundary'"}>>> = '0 1 2 3 4 5'
    new_boundary<<<{"description": "The new boundary ID(s)/name(s) to be given by the boundary elements defined in 'old_boundary'."}>>> = '1 2 3 4 5 6'
  []
[]
(tutorials/nek_stochastic/channel.i)

We can generate an Exodus mesh by running in --mesh-only mode:


cardinal-opt -i channel.i --mesh-only
mv channel_in.e channel.exo

Then, we convert into NekRS's custom .re2 mesh format by running the exo2nek script. Alternatively, you can simply use the channel.re2 file already checked into the repository. The remaining NekRS input files are:

  • channel.par: High-level settings for the solver, boundary condition mappings to sidesets, and the equations to solve

  • channel.udf: User-defined C++ functions for on-line postprocessing and model setup

  • channel.oudf: User-defined OCCA kernels for boundary conditions and source terms

In channel.par, we specify that we want to solve for temperature while disabling the fluid solve by setting solver = none in the [VELOCITY] block. The default initial condition for velocity is a zero value, so this input file is essentially solving for solid heat conduction,

where and will be specified by MOOSE. Normally, all that you need to do to set thermal conductivity in NekRS is to set the conductivity parameter in the [TEMPERATURE] block. Here, we will instead retrieve this value from MOOSE through the .udf.

warningwarning

However, the user must still set conductivity in the .par file because this value is used to initialize the elliptic solvers in NekRS. Do not simply put an arbitrary value here, because an unrealistic value will hamper the convergence of the elliptic solvers for the entire NekRS simulation. In order to initialize the elliptic solvers and preconditioners, we recommend setting the properties in the .par file to their mean values.

[OCCA]
  backend = CPU

[GENERAL]
  stopAt = numSteps
  numSteps = 400
  dt = 1.0
  timeStepper = tombo2
  writeControl = steps
  writeInterval = 500
  polynomialOrder = 5

[VELOCITY]
  solver = none

[PRESSURE]

[TEMPERATURE]
  rhoCp = 50.0
  boundaryTypeMap = insulated, insulated, t, insulated, flux, insulated
  residualTol = 1.0e-6

  # You should set the mean values for any material properties you are
  # perturbing, because these are used to initialize the elliptic solvers
  # for the entire simulation, and you want to use a reasonably representative
  # value here.
  conductivity = 5.0
(tutorials/nek_stochastic/channel.par)

Next, in the channel.oudf we specify our boundary conditions in the NekRS model – a Dirichlet temperature of 500 on sideset 3 and a heat flux from MOOSE on sideset 5.

void scalarDirichletConditions(bcData * bc)
{
  bc->s = 500.0;
}

void scalarNeumannConditions(bcData * bc)
{
  // note: when running with Cardinal, Cardinal will allocate the usrwrk
  // array. If running with NekRS standalone (e.g. nrsmpi), you need to
  // replace the usrwrk with some other value or allocate it youself from
  // the udf and populate it with values.
  bc->flux = bc->usrwrk[bc->idM];
}
(tutorials/nek_stochastic/channel.oudf)

Finally, the channel.udf file contains user-defined functions. Here, we are going to use the udf.properties to apply custom material properties. In the uservp function, we are reading thermal conductivity from the scratch space nrs->usrwrk (to be described in more detail soon). This scratch space is where MOOSE writes any data going into NekRS – which we have used extensively so far to send heat flux, volumetric heating, etc. from MOOSE to NekRS. Now, we will use this same scratch space to fetch a stochastically sampled value for thermal conductivity.

This value is then passed into the thermal conductivity array, slot 0 in o_SProp, by calling the platform->linAlg->fill function. If you wanted to stochastically perturb other quantities, like the fluid viscosity, you would access those in a different way. For a summary of how to access other quantities in NekRS, we will provide a table at the end of this tutorial.

#include "udf.hpp"

void uservp(nrs_t *nrs, double time, occa::memory o_U, occa::memory o_S, occa::memory o_UProp, occa::memory o_SProp)
{
  if (!nrs->usrwrk)
    return;

  dfloat k = nrs->usrwrk[1 * nrs->fieldOffset + 0];

  // apply that value; in nekRS, the 0th slot in o_SProp represents the 'conductivity'
  // field in the par file
  auto mesh = nrs->cds->mesh[0];
  auto o_diff = o_SProp + 0 * nrs->fieldOffset * sizeof(dfloat);
  platform->linAlg->fill(mesh->Nlocal, k, o_diff);
}

void UDF_LoadKernels(occa::properties & kernelInfo)
{
}

void UDF_Setup(nrs_t *nrs)
{
  udf.properties = &uservp;

  auto mesh = nrs->cds->mesh[0];

  int n_gll_points = mesh->Np * mesh->Nelements;
  for (int n = 0; n < n_gll_points; ++n)
    nrs->cds->S[n + 0 * nrs->cds->fieldOffset[0]] = 500.0; // temperature
}
(tutorials/nek_stochastic/channel.udf)

In order to ensure that the most up-to-date stochastic data from MOOSE is used in each scalar and flow solver, we add an extra call to udf.properties in Cardinal each time MOOSE sends new data to NekRS. So, udf.properties is called:

  1. Once before time stepping begins (within nekrs::setup). At this point, nrs->usrwrk does not exist yet, which is why we return without doing anything if nrs->usrwrk is a null pointer (the if (!nrs->usrwrk) line). This means that when the elliptic solvers are initialized, they are using whatever property values are set in the .par file (as already described).

  2. (special to Cardinal) Immediately after MOOSE sends new stochastic data to NekRS

  3. Between each scalar solve and the ensuing flow solve

Steps 1 and 3 already occur in standalone NekRS simulations, so Cardinal is just adding an extra call to ensure consistency.

The last thing we need is the thin wrapper input file which runs NekRS. We start by building a mesh mirror of the NekRS mesh, as a NekRSMesh. We will couple via conjugate heat transfer through boundary 5 in the NekRS domain. Next, we use NekRSProblem to specify that we will replace MOOSE solves with NekRS solves and to add the necessary FieldTransfers to facilitate the conjugate heat transfer boundary conditions. We also add a NekScalarValue scalar transfer, which is how we will send single numbers into NekRS's backend (described shortly). The NekTimeStepper finally will then allow NekRS to control its time stepping, aside from synchronization points imposed by the main app.

[Mesh<<<{"href": "../syntax/Mesh/index.html"}>>>]
  type = NekRSMesh
  boundary = '5'
[]

[Problem<<<{"href": "../syntax/Problem/index.html"}>>>]
  type = NekRSProblem
  casename<<<{"description": "Case name for the NekRS input files; this is <case> in <case>.par, <case>.udf, <case>.oudf, and <case>.re2."}>>> = 'channel'

  [FieldTransfers<<<{"href": "../syntax/Problem/FieldTransfers/index.html"}>>>]
    [flux]
      type = NekBoundaryFlux<<<{"description": "Reads/writes boundary flux data between NekRS and MOOSE."}>>>
      direction<<<{"description": "Direction in which to send data"}>>> = to_nek
      usrwrk_slot<<<{"description": "When 'direction = to_nek', the slot(s) in the usrwrk array to write the incoming data; provide one entry for each quantity being passed"}>>> = 0
    []
    [temperature]
      type = NekFieldVariable<<<{"description": "Reads/writes volumetric field data between NekRS and MOOSE."}>>>
      field<<<{"description": "NekRS field variable to read/write; defaults to the name of the object"}>>> = temperature
      direction<<<{"description": "Direction in which to send data"}>>> = from_nek
    []
  []

  [ScalarTransfers<<<{"href": "../syntax/Problem/ScalarTransfers/index.html"}>>>]
    [k]
      type = NekScalarValue<<<{"description": "Transfers a scalar value into NekRS"}>>>
      direction<<<{"description": "Direction in which to send data"}>>> = to_nek
      usrwrk_slot<<<{"description": "When 'direction = to_nek', the slot in the usrwrk array to write the incoming data"}>>> = 1
      output_postprocessor<<<{"description": "Name of the postprocessor to output the value sent into NekRS"}>>> = k_from_stm
    []
  []
[]

[Executioner<<<{"href": "../syntax/Executioner/index.html"}>>>]
  type = Transient

  [TimeStepper<<<{"href": "../syntax/Executioner/TimeStepper/index.html"}>>>]
    type = NekTimeStepper
  []
[]

[Postprocessors<<<{"href": "../syntax/Postprocessors/index.html"}>>>]
  [max_temp]
    type = NekVolumeExtremeValue<<<{"description": "Extreme value (max/min) of a field over the NekRS mesh", "href": "../source/postprocessors/NekVolumeExtremeValue.html"}>>>
    field<<<{"description": "Field to apply this object to"}>>> = temperature
  []
  [k_from_stm] # this will just print to the screen the value of k received
    type = Receiver<<<{"description": "Reports the value stored in this processor, which is usually filled in by another object. The Receiver does not compute its own value.", "href": "../source/postprocessors/Receiver.html"}>>>
  []
  [expect_max_T]
    type = ParsedPostprocessor<<<{"description": "Computes a parsed expression with post-processors", "href": "../source/postprocessors/ParsedPostprocessor.html"}>>>
    function = '1000.0 / k_from_stm + 500.0'
    pp_names<<<{"description": "Post-processors arguments"}>>> = k_from_stm
  []
[]

[Outputs<<<{"href": "../syntax/Outputs/index.html"}>>>]
  csv<<<{"description": "Output the scalar variable and postprocessors to a *.csv file using the default CSV output."}>>> = true
  hide<<<{"description": "A list of the variables and postprocessors that should NOT be output to the Exodus file (may include Variables, ScalarVariables, and Postprocessor names)."}>>> = 'flux_integral'
[]

[Controls<<<{"href": "../syntax/Controls/index.html"}>>>]
  [stm]
    type = SamplerReceiver<<<{"description": "Control for receiving data from a Sampler via SamplerParameterTransfer.", "href": "../source/controls/SamplerReceiver.html"}>>>
  []
[]
(tutorials/nek_stochastic/nek.i)

Then, we define a number of postprocessors. The max_temp postprocessor will be our QOI evaluating which will get passed upwards in the multiapp hierarchy. The k_from_stem Receiver postprocessor will be written by our NekScalarValue object for convenience to display the value held by the NekScalarValue transfer to the console so that we can easily see each random value for getting received. For sanity check, we also add expect_max_T to compute the analytic value for according to Eq. (1).

[Mesh<<<{"href": "../syntax/Mesh/index.html"}>>>]
  type = NekRSMesh
  boundary = '5'
[]

[Problem<<<{"href": "../syntax/Problem/index.html"}>>>]
  type = NekRSProblem
  casename<<<{"description": "Case name for the NekRS input files; this is <case> in <case>.par, <case>.udf, <case>.oudf, and <case>.re2."}>>> = 'channel'

  [FieldTransfers<<<{"href": "../syntax/Problem/FieldTransfers/index.html"}>>>]
    [flux]
      type = NekBoundaryFlux<<<{"description": "Reads/writes boundary flux data between NekRS and MOOSE."}>>>
      direction<<<{"description": "Direction in which to send data"}>>> = to_nek
      usrwrk_slot<<<{"description": "When 'direction = to_nek', the slot(s) in the usrwrk array to write the incoming data; provide one entry for each quantity being passed"}>>> = 0
    []
    [temperature]
      type = NekFieldVariable<<<{"description": "Reads/writes volumetric field data between NekRS and MOOSE."}>>>
      field<<<{"description": "NekRS field variable to read/write; defaults to the name of the object"}>>> = temperature
      direction<<<{"description": "Direction in which to send data"}>>> = from_nek
    []
  []

  [ScalarTransfers<<<{"href": "../syntax/Problem/ScalarTransfers/index.html"}>>>]
    [k]
      type = NekScalarValue<<<{"description": "Transfers a scalar value into NekRS"}>>>
      direction<<<{"description": "Direction in which to send data"}>>> = to_nek
      usrwrk_slot<<<{"description": "When 'direction = to_nek', the slot in the usrwrk array to write the incoming data"}>>> = 1
      output_postprocessor<<<{"description": "Name of the postprocessor to output the value sent into NekRS"}>>> = k_from_stm
    []
  []
[]

[Executioner<<<{"href": "../syntax/Executioner/index.html"}>>>]
  type = Transient

  [TimeStepper<<<{"href": "../syntax/Executioner/TimeStepper/index.html"}>>>]
    type = NekTimeStepper
  []
[]

[Postprocessors<<<{"href": "../syntax/Postprocessors/index.html"}>>>]
  [max_temp]
    type = NekVolumeExtremeValue<<<{"description": "Extreme value (max/min) of a field over the NekRS mesh", "href": "../source/postprocessors/NekVolumeExtremeValue.html"}>>>
    field<<<{"description": "Field to apply this object to"}>>> = temperature
  []
  [k_from_stm] # this will just print to the screen the value of k received
    type = Receiver<<<{"description": "Reports the value stored in this processor, which is usually filled in by another object. The Receiver does not compute its own value.", "href": "../source/postprocessors/Receiver.html"}>>>
  []
  [expect_max_T]
    type = ParsedPostprocessor<<<{"description": "Computes a parsed expression with post-processors", "href": "../source/postprocessors/ParsedPostprocessor.html"}>>>
    function = '1000.0 / k_from_stm + 500.0'
    pp_names<<<{"description": "Post-processors arguments"}>>> = k_from_stm
  []
[]

[Outputs<<<{"href": "../syntax/Outputs/index.html"}>>>]
  csv<<<{"description": "Output the scalar variable and postprocessors to a *.csv file using the default CSV output."}>>> = true
  hide<<<{"description": "A list of the variables and postprocessors that should NOT be output to the Exodus file (may include Variables, ScalarVariables, and Postprocessor names)."}>>> = 'flux_integral'
[]
(tutorials/nek_stochastic/nek.i)

Lastly, we need to have a SamplerReceiver, which will actually facilitate receiving the stochastic data into the NekScalarValue object. Any input file receiving stochastic data just needs to have one of these objects.

[Controls<<<{"href": "../syntax/Controls/index.html"}>>>]
  [stm]
    type = SamplerReceiver<<<{"description": "Control for receiving data from a Sampler via SamplerParameterTransfer.", "href": "../source/controls/SamplerReceiver.html"}>>>
  []
[]
(tutorials/nek_stochastic/nek.i)

Heat Conduction Model

For the MOOSE heat conduction model, we firt set up a mesh and create the nonlinear variables, kernels, and boundary conditions to solve the solid heat conduction equation. The flux auxiliary variable will be used to compute the interface heat flux (which will get sent to NekRS), while the nek_temp auxiliary variable will be a receiver for interface temperature from NekRS. NekRS must run using a transient executioner, so we will use the steady_state_detection feature here to ensure that each NekRS simulation has reached the pseudo-steady state.

[Mesh<<<{"href": "../syntax/Mesh/index.html"}>>>]
  [box]
    type = GeneratedMeshGenerator<<<{"description": "Create a line, square, or cube mesh with uniformly spaced or biased elements.", "href": "../source/meshgenerators/GeneratedMeshGenerator.html"}>>>
    dim<<<{"description": "The dimension of the mesh to be generated"}>>> = 3
    nx<<<{"description": "Number of elements in the X direction"}>>> = 10
    ny<<<{"description": "Number of elements in the Y direction"}>>> = 10
    nz<<<{"description": "Number of elements in the Z direction"}>>> = 10
    xmin<<<{"description": "Lower X Coordinate of the generated mesh"}>>> = -0.2
    xmax<<<{"description": "Upper X Coordinate of the generated mesh"}>>> = 0.0
    ymin<<<{"description": "Lower Y Coordinate of the generated mesh"}>>> = 0.0
    ymax<<<{"description": "Upper Y Coordinate of the generated mesh"}>>> = 1.0
    zmin<<<{"description": "Lower Z Coordinate of the generated mesh"}>>> = 0.0
    zmax<<<{"description": "Upper Z Coordinate of the generated mesh"}>>> = 2.0
  []
[]

[Variables<<<{"href": "../syntax/Variables/index.html"}>>>]
  [temperature]
    initial_condition<<<{"description": "Specifies a constant initial condition for this variable"}>>> = 500.0
  []
[]

[Kernels<<<{"href": "../syntax/Kernels/index.html"}>>>]
  [diffusion]
    type = HeatConduction<<<{"description": "Diffusive heat conduction term $-\\nabla\\cdot(k\\nabla T)$ of the thermal energy conservation equation", "href": "../source/kernels/HeatConduction.html"}>>>
    variable<<<{"description": "The name of the variable that this residual object operates on"}>>> = temperature
    diffusion_coefficient<<<{"description": "Property name of the diffusion coefficient"}>>> = thermal_conductivity
  []
[]

[BCs<<<{"href": "../syntax/BCs/index.html"}>>>]
  [left]
    type = NeumannBC<<<{"description": "Imposes the integrated boundary condition $\\frac{\\partial u}{\\partial n}=h$, where $h$ is a constant, controllable value.", "href": "../source/bcs/NeumannBC.html"}>>>
    variable<<<{"description": "The name of the variable that this residual object operates on"}>>> = temperature
    value<<<{"description": "For a Laplacian problem, the value of the gradient dotted with the normals on the boundary."}>>> = 1000.0
    boundary<<<{"description": "The list of boundary IDs from the mesh where this object applies"}>>> = 'left'
  []
  [interface]
    type = MatchedValueBC<<<{"description": "Implements a NodalBC which equates two different Variables' values on a specified boundary.", "href": "../source/bcs/MatchedValueBC.html"}>>>
    variable<<<{"description": "The name of the variable that this residual object operates on"}>>> = temperature
    v<<<{"description": "The variable whose value we are to match."}>>> = nek_temp
    boundary<<<{"description": "The list of boundary IDs from the mesh where this object applies"}>>> = 'right'
  []
[]

[AuxVariables<<<{"href": "../syntax/AuxVariables/index.html"}>>>]
  [flux]
    family<<<{"description": "Specifies the family of FE shape functions to use for this variable"}>>> = MONOMIAL
    order<<<{"description": "Specifies the order of the FE shape function to use for this variable (additional orders not listed are allowed)"}>>> = CONSTANT
  []
  [nek_temp]
    initial_condition<<<{"description": "Specifies a constant initial condition for this variable"}>>> = 500.0
  []
[]

[AuxKernels<<<{"href": "../syntax/AuxKernels/index.html"}>>>]
  [flux]
    type = DiffusionFluxAux<<<{"description": "Compute components of flux vector for diffusion problems $(\\vec{J} = -D \\nabla C)$.", "href": "../source/auxkernels/DiffusionFluxAux.html"}>>>
    variable<<<{"description": "The name of the variable that this object applies to"}>>> = flux
    diffusion_variable<<<{"description": "The name of the variable"}>>> = temperature
    component<<<{"description": "The desired component of flux."}>>> = normal
    diffusivity<<<{"description": "The name of the diffusivity material property that will be used in the flux computation."}>>> = thermal_conductivity
    boundary<<<{"description": "The list of boundaries (ids or names) from the mesh where this object applies"}>>> = 'right'
  []
[]

[Materials<<<{"href": "../syntax/Materials/index.html"}>>>]
  [k]
    type = GenericConstantMaterial<<<{"description": "Declares material properties based on names and values prescribed by input parameters.", "href": "../source/materials/GenericConstantMaterial.html"}>>>
    prop_names<<<{"description": "The names of the properties this material will have"}>>> = 'thermal_conductivity'
    prop_values<<<{"description": "The values associated with the named properties"}>>> = '1.5'
  []
[]

[Postprocessors<<<{"href": "../syntax/Postprocessors/index.html"}>>>]
  [flux_integral]
    type = SideIntegralVariablePostprocessor<<<{"description": "Computes a surface integral of the specified variable", "href": "../source/postprocessors/SideIntegralVariablePostprocessor.html"}>>>
    variable<<<{"description": "The name of the variable which this postprocessor integrates"}>>> = flux
    boundary<<<{"description": "The list of boundary IDs from the mesh where this object applies"}>>> = 'right'
  []
[]

[Executioner<<<{"href": "../syntax/Executioner/index.html"}>>>]
  type = Transient
  dt = 10.0
  nl_abs_tol = 1e-8

  steady_state_detection = true
  steady_state_tolerance = 5e-4
  steady_state_start_time = 30.0
[]
(tutorials/nek_stochastic/ht.i)

Next, we add NekRS as a sub-app, and define the transfers. The temperature, flux, and flux_integral transfers we have seen many times before – these are transferring data necessary to apply the heat flux and temperature coupled boundary conditions on the interfaces. The MultiAppReporterTransfer is used to fetch the value held by the max_temp postprocessor in the nek.i input file. A Reporter is basically a vector-valued postprocessor which could be used to hold multiple QOIs. In this example, we are just interested in one value.

[MultiApps<<<{"href": "../syntax/MultiApps/index.html"}>>>]
  [nek]
    type = TransientMultiApp<<<{"description": "MultiApp for performing coupled simulations with the parent and sub-application both progressing in time.", "href": "../source/multiapps/TransientMultiApp.html"}>>>
    input_files<<<{"description": "The input file for each App.  If this parameter only contains one input file it will be used for all of the Apps.  When using 'positions_from_file' it is also admissable to provide one input_file per file."}>>> = 'nek.i'
    sub_cycling<<<{"description": "Set to true to allow this MultiApp to take smaller timesteps than the rest of the simulation.  More than one timestep will be performed for each parent application timestep"}>>> = true
    execute_on<<<{"description": "The list of flag(s) indicating when this object should be executed. For a description of each flag, see https://mooseframework.inl.gov/source/interfaces/SetupInterface.html."}>>> = timestep_end
    wait_for_first_app_init<<<{"description": "Create the first sub-application on rank 0, then MPI_Barrier before creating the next N-1 apps (on all ranks). This is only needed if your sub-application needs to perform some setup actions in quiet, without other sub-applications working at the same time."}>>> = true
  []
[]

[Transfers<<<{"href": "../syntax/Transfers/index.html"}>>>]
  [temperature]
    type = MultiAppGeneralFieldNearestLocationTransfer<<<{"description": "Transfers field data at the MultiApp position by finding the value at the nearest neighbor(s) in the origin application.", "href": "../source/transfers/MultiAppGeneralFieldNearestLocationTransfer.html"}>>>
    source_variable<<<{"description": "The variable to transfer from."}>>> = temperature
    from_multi_app<<<{"description": "The name of the MultiApp to receive data from"}>>> = nek
    variable<<<{"description": "The auxiliary variable to store the transferred values in."}>>> = nek_temp
  []
  [flux]
    type = MultiAppGeneralFieldNearestLocationTransfer<<<{"description": "Transfers field data at the MultiApp position by finding the value at the nearest neighbor(s) in the origin application.", "href": "../source/transfers/MultiAppGeneralFieldNearestLocationTransfer.html"}>>>
    source_variable<<<{"description": "The variable to transfer from."}>>> = flux
    to_multi_app<<<{"description": "The name of the MultiApp to transfer the data to"}>>> = nek
    variable<<<{"description": "The auxiliary variable to store the transferred values in."}>>> = flux
    from_boundaries<<<{"description": "The boundary we are transferring from (if not specified, whole domain is used)."}>>> = 'right'
  []
  [flux_integral]
    type = MultiAppPostprocessorTransfer<<<{"description": "Transfers postprocessor data between the master application and sub-application(s).", "href": "../source/transfers/MultiAppPostprocessorTransfer.html"}>>>
    to_postprocessor<<<{"description": "The name of the Postprocessor in the MultiApp to transfer the value to.  This should most likely be a Reporter Postprocessor."}>>> = flux_integral
    from_postprocessor<<<{"description": "The name of the Postprocessor in the Master to transfer the value from."}>>> = flux_integral
    to_multi_app<<<{"description": "The name of the MultiApp to transfer the data to"}>>> = nek
  []
  [get_qoi]
    type = MultiAppReporterTransfer<<<{"description": "Transfers reporter data between two applications.", "href": "../source/transfers/MultiAppReporterTransfer.html"}>>>
    from_multi_app<<<{"description": "The name of the MultiApp to receive data from"}>>> = nek
    from_reporters<<<{"description": "List of the reporter names (object_name/value_name) to transfer the value from."}>>> = 'max_temp/value'
    to_reporters<<<{"description": "List of the reporter names (object_name/value_name) to transfer the value to."}>>> = 'receive/nek_max_T'
  []
[]

[Outputs<<<{"href": "../syntax/Outputs/index.html"}>>>]
  print_linear_residuals<<<{"description": "Enable printing of linear residuals to the screen (Console)"}>>> = false
  hide<<<{"description": "A list of the variables and postprocessors that should NOT be output to the Exodus file (may include Variables, ScalarVariables, and Postprocessor names)."}>>> = 'flux_integral'
[]
(tutorials/nek_stochastic/ht.i)

Finally, we declare a ConstantReporter to be the receiving point of the data fetched by the get_qoi transfer. Note that we do not need a SamplerReceiver in this input file because the MOOSE heat conduction model itself is not receiving any stochastic inputs - the NekRS thermal conductivity passes straight from the STM to NekRS.

[Reporters<<<{"href": "../syntax/Reporters/index.html"}>>>]
  [receive]
    type = ConstantReporter<<<{"description": "Reporter with constant values to be accessed by other objects, can be modified using transfers.", "href": "../source/reporters/ConstantReporter.html"}>>>
    real_names<<<{"description": "Names for each real value."}>>> = 'nek_max_T'

    # This value doesnt do anything - you just need to have a dummy here
    real_values<<<{"description": "Values for reals."}>>> = 0.0
  []
[]
(tutorials/nek_stochastic/ht.i)

Stochastic Tools Module

For stochastic simulations, a STM input "drives" the entire simulation as the main app. First, we define random distributions and sampling methods for our random data. Here, we will use a normal distribution for . We choose a MonteCarlo sampler to randomly sample from this normal distribution. Here, num_rows is the number of independent samples to take.

[StochasticTools<<<{"href": "../syntax/StochasticTools/index.html"}>>>]
[]

[Distributions<<<{"href": "../syntax/Distributions/index.html"}>>>]
  [dist]
    type = Normal<<<{"description": "Normal distribution", "href": "../source/distributions/Normal.html"}>>>
    mean<<<{"description": "Mean (or expectation) of the distribution."}>>> = 5.0
    standard_deviation<<<{"description": "Standard deviation of the distribution "}>>> = 1.0
  []
[]

[Samplers<<<{"href": "../syntax/Samplers/index.html"}>>>]
  [sample]
    type = MonteCarlo<<<{"description": "Monte Carlo Sampler.", "href": "../source/samplers/MonteCarloSampler.html"}>>>
    num_rows<<<{"description": "The number of rows per matrix to generate."}>>> = 3
    distributions<<<{"description": "The distribution names to be sampled, the number of distributions provided defines the number of columns per matrix."}>>> = 'dist'
  []
[]
(tutorials/nek_stochastic/driver.i)

Next, we add the MOOSE heat conduction model as a SamplerFullSolveMultiApp, which will launch one new physics simulation for each random sample. We next use a SamplerParameterTransfer to send the random numbers from the sample object into the k UserObject in the sub-sub app named nek. In order to fetch the QOI from the nested physics apps, we use a SamplerReporterTransfer to get the from the sub-app. This will be held by the storage StochasticMatrix. Finally, we use a StatisticsReporter to conduct some statistical analysis on our QOI.

[MultiApps<<<{"href": "../syntax/MultiApps/index.html"}>>>]
  [ht]
    type = SamplerFullSolveMultiApp<<<{"description": "Creates a full-solve type sub-application for each row of each Sampler matrix.", "href": "../source/multiapps/SamplerFullSolveMultiApp.html"}>>>
    input_files<<<{"description": "The input file for each App.  If this parameter only contains one input file it will be used for all of the Apps.  When using 'positions_from_file' it is also admissable to provide one input_file per file."}>>> = ht.i
    sampler<<<{"description": "The Sampler object to utilize for creating MultiApps."}>>> = sample
    mode<<<{"description": "The operation mode, 'normal' creates one sub-application for each row in the Sampler and 'batch-reset' and 'batch-restore' creates N sub-applications, where N is the minimum of 'num_rows' in the Sampler and floor(number of processes / min_procs_per_app). To run the rows in the Sampler, 'batch-reset' will destroy and re-create sub-apps as needed, whereas the 'batch-restore' will backup and restore sub-apps to the initial state prior to execution, without destruction."}>>> = batch-restore
    wait_for_first_app_init<<<{"description": "Create the first sub-application on rank 0, then MPI_Barrier before creating the next N-1 apps (on all ranks). This is only needed if your sub-application needs to perform some setup actions in quiet, without other sub-applications working at the same time."}>>> = true
  []
[]

[Transfers<<<{"href": "../syntax/Transfers/index.html"}>>>]
  [transer_random_inputs_to_nek]
    type = SamplerParameterTransfer<<<{"description": "Copies Sampler data to a SamplerReceiver object.", "href": "../source/transfers/SamplerParameterTransfer.html"}>>>
    to_multi_app<<<{"description": "The name of the MultiApp to transfer the data to"}>>> = ht
    sampler<<<{"description": "A the Sampler object that Transfer is associated.."}>>> = sample

    # you can send data down to arbitrarily-nested sub-apps
    parameters<<<{"description": "A list of parameters (on the sub application) to control with the Sampler data. The order of the parameters listed here should match the order of the items in the Sampler."}>>> = 'nek:Problem/ScalarTransfers/k/value'
  []
  [results]
    type = SamplerReporterTransfer<<<{"description": "Transfers data from Reporters on the sub-application to a StochasticReporter on the main application.", "href": "../source/transfers/SamplerReporterTransfer.html"}>>>
    from_multi_app<<<{"description": "The name of the MultiApp to receive data from"}>>> = ht
    sampler<<<{"description": "A the Sampler object that Transfer is associated.."}>>> = sample
    stochastic_reporter<<<{"description": "The name of the StochasticReporter object to transfer values to."}>>> = storage
    from_reporter<<<{"description": "The name(s) of the Reporter(s) on the sub-app to transfer from."}>>> = 'receive/nek_max_T'
  []
[]

[Reporters<<<{"href": "../syntax/Reporters/index.html"}>>>]
  [storage]
    type = StochasticMatrix<<<{"description": "Tool for extracting Sampler object data and storing data from stochastic simulations.", "href": "../source/reporters/StochasticMatrix.html"}>>>
    sampler<<<{"description": "The sample from which to extract distribution data."}>>> = sample
    parallel_type<<<{"description": "This parameter will determine how the stochastic data is gathered. It is common for outputting purposes that this parameter be set to ROOT, otherwise, many files will be produced showing the values on each processor. However, if there are lot of samples, gathering on root may be memory restrictive."}>>> = ROOT
  []
  [stats]
    type = StatisticsReporter<<<{"description": "Compute statistical values of a given VectorPostprocessor objects and vectors.", "href": "../source/reporters/StatisticsReporter.html"}>>>
    reporters<<<{"description": "List of Reporter values to utilized for statistic computations."}>>> = 'storage/results:receive:nek_max_T'
    compute<<<{"description": "The statistic(s) to compute for each of the supplied vector postprocessors."}>>> = 'mean stddev'
    ci_method<<<{"description": "The method to use for computing confidence level intervals."}>>> = 'percentile'
    ci_levels<<<{"description": "A vector of confidence levels to consider, values must be in (0, 1)."}>>> = '0.05 0.95'
  []
[]

[Outputs<<<{"href": "../syntax/Outputs/index.html"}>>>]
  execute_on<<<{"description": "The list of flag(s) indicating when this object should be executed. For a description of each flag, see https://mooseframework.inl.gov/source/interfaces/SetupInterface.html."}>>> = timestep_end
  [out]
    type = JSON<<<{"description": "Output for Reporter values using JSON format.", "href": "../source/outputs/JSONOutput.html"}>>>
  []
[]
(tutorials/nek_stochastic/driver.i)

Summary

Figure 2 summarizes the data flow for this stochastic simulation. The only specialization for NekRS is the use of NekScalarValue to receive the data, which the user is then responsible for applying in the NekRS input files as appropriate.

Figure 2: Summary of major data transfers and objects used to accomplish stochastic simulations with NekRS

Execution and Postprocessing

To run the stochastic simulation


mpiexec -np 2 cardinal-opt -i driver.i

When you first run this input file, you will see a table print at the beginning of the NekRS solve which shows:


 -----------------------------------------------------------------------------------------------------------
 | Slot | MOOSE quantity |          How to Access (.oudf)          |         How to Access (.udf)          |
 -----------------------------------------------------------------------------------------------------------
 |    0 | flux           | bc->usrwrk[0 * bc->fieldOffset+bc->idM] | nrs->usrwrk[0 * nrs->fieldOffset + n] |
 |    1 | k              | bc->usrwrk[1 * bc->fieldOffset + 0]     | nrs->usrwrk[1 * nrs->fieldOffset + 0] |
 |    2 | unused         | bc->usrwrk[2 * bc->fieldOffset+bc->idM] | nrs->usrwrk[2 * nrs->fieldOffset + n] |
 |    3 | unused         | bc->usrwrk[3 * bc->fieldOffset+bc->idM] | nrs->usrwrk[3 * nrs->fieldOffset + n] |
 |    4 | unused         | bc->usrwrk[4 * bc->fieldOffset+bc->idM] | nrs->usrwrk[4 * nrs->fieldOffset + n] |
 |    5 | unused         | bc->usrwrk[5 * bc->fieldOffset+bc->idM] | nrs->usrwrk[5 * nrs->fieldOffset + n] |
 |    6 | unused         | bc->usrwrk[6 * bc->fieldOffset+bc->idM] | nrs->usrwrk[6 * nrs->fieldOffset + n] |
 -----------------------------------------------------------------------------------------------------------

This table will show you how the NekScalarValues map to particular locations in the scratch space. It is from this table that we knew the value of thermal conductivity would be located at nrs->usrwrk[1 * nrs->fieldOffset + 0], which is what we used in the channel.udf.

commentnote

When you run this input file, the screen output from NekRS itself is very messy, because NekRS will output any print statements which occur on rank 0 of the local communicator. For running stochastic simulations via MOOSE, we split a starting communicator into smaller pieces, which means that multiple NekRS cases are all trying to print to the console at the same time, clobbering each other. We are working on a better solution.

Because we specified JSON output in the driver.i, this will create a number of output files:

  • driver_out.json, which contains the stochastic results for QOIs. We used parallel_type = ROOT in the StochasticMatrix, which will collect all the stochastic data into one file at the end of the simulation. If you had omitted this value, you would end up with JSON output files, where is the number of processors.

  • driver_out_ht<n>_nek0.csv, CSV files with the NekRS postprocessors, where <n> is each independent coupled simulation (i.e. each represents a conjugate heat transfer simulation with a different value for ).

We can use Python scripts in the MOOSE stochastic tools module to plot our QOI as a histogram. Using the make_histogram.py script, we can indicate that we want to plot the results:receive:nek_max_T data we fetched with the StochasticMatrix, and save the histogram in a file named Ti.pdf, shown in Figure 3 for 200 samples.


python ../../contrib/moose/modules/stochastic_tools/python/make_histogram.py driver_out.json* -v results:receive:nek_max_T --xlabel 'Interface Temperature' --output Ti.pdf

Figure 3: Histogram of when running with num_rows = 200 samples

We can also directly plot the random values sampled, i.e. the input distribution. These are shown in Figure 4.


python ../../contrib/moose/modules/stochastic_tools/python/make_histogram.py driver_out.json* -v sample_0 --xlabel 'Input Distribution' --output samples.pdf

Figure 4: Actual sampled input distribution for

Because we added the the StatisticsReporter, we also have access to the mean, standard deviation, and confidence intervals automatically (of course, you could post-compute those as well). We can output this data as a table, using the visualize_statistics.py script.


python ../../contrib/moose/modules/stochastic_tools/python/visualize_statistics.py driver_out.json --markdown-table --names '{"storage_results:receive:nek_max_T":"Interface Temperature"}'

which will output


| Values                                 | MEAN                 | STDDEV              |
|:---------------------------------------|:---------------------|:--------------------|
| Interface Temperature (5.0%, 95.0%) CI | 714.4 (709.3, 719.8) | 45.1 (39.98, 50.22) |

We invite you to explore these plotting scripts and documentation in the STM, as this tutorial only scratches the surface with the simulations performed. You can make bar plots and many other data presentation formats.

Other Features

By default, when you run NekRS in a stochastic simulation, each individual run will write the NekRS solution to the same field files – <case>0.f*. These will all clobber eachother. If you want to preserve a unique output file for each of the NekRS runs, you can set write_fld_files = true in the [Problem] block, which will write output files labeled as a00case, a01case, ..., z98case, z99case, giving you 2574 possible output files.

Perturbing NekRS Parameters

In this example, we perturbed the thermal conductivity, but there are many other parameters which can be modified in NekRS via the udf.properties function. Table 1 summarizes the parameters which can be modified by the udf.properties function. Because NekRS simulations can be run with any number of passive scalars, the content and ordering will depend on how many scalars you have. So, to be as general as possible, we've written Table 1 assuming you have a simulation with 3 passive scalars.

Table 1: Commmon parameters to modify in NekRS via udf.properties

ParameterHow to Access
Coefficient on time derivative of 0th scalar equation0th slot in o_SProp
Coefficient on time derivative of 1st scalar equation1st slot in o_SProp
Coefficient on time derivative of 2nd scalar equation2nd slot in o_SProp
Coefficient on diffusion operator of 0th scalar equation3rd slot in o_SProp
Coefficient on diffusion operator of 1st scalar equation4th slot in o_SProp
Coefficient on diffusion operator of 2nd scalar equation5th slot in o_SProp
Fluid dynamic viscosity0th slot in o_UProp
Fluid density1st slot in o_UProp

Other quantities which you can perturb include:

  • Boundary conditions

  • Anything in a GPU kernel, such as momentum or heat source terms

  • Geometry