Boundary Conditions

The boundary conditions (BCs) must be configured in order to run a simulation. The computational domain is a parallelepiped as illustrated below:

Computational domain

Computational domain

The BCs are defined for each plane in the models.BC field. The example below, shows a basic wind tunnel setup with a uniform inlet inflow:

    models:
      BC:
        periodic_dims: [false, true, false]
        BC_map:
          - pos: W
            BC: UniformFlow
            wall_normal: W
            order: 2
            params:
              rho: 1.0
              ux: 0.05
              uy: 0
              uz: 0
          - pos: E
            BC: RegularizedNeumannOutlet
            wall_normal: E
            order: 2
            params:
              rho: 1.0
          - pos: F
            BC: Neumann
            wall_normal: F
            order: 1
          - pos: B
            BC: RegularizedHWBB
            wall_normal: B
            order: 1

Note

Each BC_map entry keeps its structural keys (pos, system, BC, wall_normal, order) at the top level and gathers the BC-specific parameters under a nested params: field, validated at parse time against the selected BC. The entry is extra="forbid", so a misspelled or misplaced key is rejected on load. BCs that take no parameters (such as Neumann, RegularizedHWBB, RegularizedNeumannSlip and HWBB) omit params: entirely.

If periodicity is set true for a certain direction, it does not require definition of a BC. The order determines which BC will overwrite the other at edges and corners of computational domain.

Important

It’s recommended to execute the lateral boundaries first, then top/floor, and inlet/outlet.

It’s also possible to define a BC for edges as shown below:

    models:
      BC:
        BC_map:
          - pos: NW
            BC: Neumann
            wall_normal: W
            order: 3

The wall_normal determines the offset direction to perform certain BCs and can only be defined for the domain face boundaries.

Moving wall

A wall translating at a prescribed velocity uses RegularizedVelocityWall (the moment-based moving wall). It imposes the wall velocity, treats the wall density according to rho_wall, and reconstructs the rate-of-strain from the wall-normal finite difference - the no-slip RegularizedHWBB is the zero-velocity case of the same closure. The wall velocity components are required:

    models:
      BC:
        BC_map:
          - pos: N
            BC: RegularizedVelocityWall
            wall_normal: N
            order: 1
            params:
              ux: 0.02
              uy: 0
              uz: 0

Wall density treatment (rho_wall)

Both wall closures (the no-slip RegularizedHWBB and the moving RegularizedVelocityWall) accept an optional rho_wall that selects how the wall density is set:

  • Omitted (default): zero-normal-gradient. The wall density is extrapolated from the interior wall-normal neighbour (rho = rho_d1). This is the physically correct closure for a no-slip wall, where the wall-normal pressure gradient is negligible, and for recirculating flows (for example a lid-driven cavity) where the wall pressure varies along the wall. It is the recommended default for solid walls.

  • Supplied: fixed density. Giving a positive rho_wall fixes the wall density to that constant (the wall pressure is held uniform). Use this only for outlet-like or genuinely uniform-pressure walls (for example Couette / Poiseuille channels); fixing the density on a wall with a varying pressure field degrades accuracy.

The choice is made per wall node, so different walls of the same type may mix the two treatments. To fix the density, add rho_wall to the BC entry:

          - pos: N
            BC: RegularizedVelocityWall
            wall_normal: N
            order: 1
            params:
              ux: 0.02
              uy: 0
              uz: 0
              rho_wall: 1.0

Predicate-based BC regions

Besides the cardinal aliases (N, S, E, W, F, B and their edge/corner combinations), the pos field accepts a boolean predicate over the node coordinates x, y, z. This selects every node whose coordinate satisfies the expression, allowing interior walls, slabs, cuboids, pipes, spheres and any boolean composition of those without an STL/IBM geometry.

    models:
      BC:
        BC_map:
          # Interior axis-aligned baffle (a cuboid slab cutting the domain)
          - pos: "(x >= 14) & (x <= 17) & (8 <= y) & (y <= 24)"
            BC: RegularizedHWBB
            wall_normal: E
            order: 2
          # A circular pipe wall region
          - pos: "(x - 50)**2 + (y - 50)**2 >= 40**2"
            BC: RegularizedHWBB
            wall_normal: E
            order: 1

The grammar supports the coordinate symbols x, y, z, real constants, the operators + - * / and integer powers (** or ^), the comparisons <, <=, >, >=, ==, !=, and the boolean operators and/&, or/|, not/~. The expression must evaluate to a boolean (a comparison or a boolean combination of comparisons). Transcendental functions (sin, log, …) and non-integer powers are outside the grammar and raise a configuration error.

The coordinates are the global node coordinates of the domain; on a single-level domain these are the lattice-node indices. A cardinal alias is exactly equivalent to a predicate (N is y >= Ny - 1, S is y <= 0, and so on), so existing configurations keep working unchanged.

On a refined domain the predicate is evaluated in those same global level-0 lattice coordinates on every block, whatever its refinement level, so a region overlapping a refined block selects that block’s nodes at the finer spacing. The selected node count therefore follows the local spacing, and a region straddling a refinement interface is denser on the fine side.

The same pos grammar applies to the per-scalar boundary conditions under models.scalar_transports.<name>.BC.BC_map, so an emitting footprint on the ground, a road line or any interior surface is expressed for a scalar exactly as it is for the fluid:

    models:
      scalar_transports:
        pollutant:
          BC:
            BC_map:
              # Fixed concentration over a ground footprint
              - pos: "(z <= 0) & (x >= 120) & (x <= 180) & (y >= 40) & (y <= 90)"
                BC: ScalarRegularizedDirichlet
                wall_normal: B
                order: 1
                params:
                  phi_w: 1.0

Important

The wall_normal is the outward cardinal direction of the wall and is stated explicitly on the entry. It is never inferred from pos: a predicate region takes the same explicit cardinal normal a cardinal alias does. Every scheme that reconstructs along the wall normal requires it (only UniformFlow and ScalarUniformInlet do not), and an entry that omits it is rejected at config load.

Floating/curved regions defined by a single comparison (x^2 + y^2 < R^2, a tilted half-space, …) additionally carry a true continuous unit normal derived analytically from the predicate gradient and stored per node (snorm int8 per component) in a global array, exported with the wall-normal map. The continuous-normal array is allocated only when a floating region needs it.

Planes, lines and points: the thickness convention

A predicate is evaluated once per node, so a geometry with zero measure - a plane, a line, a point - contains no node except by accident and has to be thickened before it selects anything. Nassu uses one convention everywhere:

Authored extents are closed. Thickened (zero-measure) directions are half-open and exactly one baseline cell wide.

h is the level-0 (baseline) cell size expressed in the frame the predicate is written in. In lattice coordinates that number is exactly 1, because a predicate sees the global node coordinate block.pos + index * block.interval with interval = 0.5 ** level. A predicate written in a domain.systems frame (metres, say) uses the baseline cell size in that frame.

Geometry

Predicate

Endpoints

Point at (a, b, c)

(x > a - h/2) & (x <= a + h/2) & ... on all three axes

half-open on every axis

Plane, normal z at c

(z > c - h/2) & (z <= c + h/2), plus closed in-plane bounds if wanted

half-open on the normal, closed in-plane

Line, dominant axis x, slope s_b = d_b/d_a

(x >= x0) & (x <= x1) & (y - s_y*x > o_y - h/2) & (y - s_y*x <= o_y + h/2) & ... with o_b = b0 - s_b*a0

closed along the segment, half-open transverse

Box

(x >= x0) & (x <= x1) & ...

closed on every face

Two properties follow, and they are the reason for the shapes above:

  • A half-open interval of width exactly h tiles the axis, so it selects exactly one node layer wherever the geometry falls relative to the node grid. A closed interval of the same width selects two layers whenever a node plane lands on a face.

  • The thickness is the baseline cell, not the local refined cell. The grammar has only x, y and z, so a level-aware width cannot be written. Inside a refinement of level L a thickened direction holds 2**L node layers; sizing it to the finest resolution instead would select nothing at all in unrefined space.

The line form is a discrete line rather than a tube of radius h/2: it selects exactly one node per lattice step of its dominant axis, and it is never empty. An isotropic tube of radius h/2 is empty for an axis-parallel line offset half a cell on both transverse axes, which sits sqrt(2)/2 * h from every node.

Placing a region with a typed shape

The predicate above can also be named as a shape: pos takes a kind-tagged mapping as readily as it takes a string. Both spell the same region and select the same nodes, because the shape is lowered to the equivalent predicate at config load, following the thickness convention above, so the convention has one owner and a client never derives it.

The kinds and their fields, the same vocabulary an export probe uses:

kind

Fields

Region

point

pos

the one node whose cell contains the point

points

points

one node per listed point

line

start, end

the segment, one node per step of its dominant axis

plane

axis, axis_pos, min, max

one node layer, optionally bounded in-plane

box

start, end, is_abs

the closed axis-aligned extent

predicate

predicate

the predicate string itself

    models:
      BC:
        BC_map:
          - pos: {kind: point, pos: [10, 20, 5]}
            BC: RegularizedHWBB
            wall_normal: E
            order: 1
          - pos: {kind: points, points: [[10, 20, 5], [11, 20, 5]]}
            BC: RegularizedHWBB
            wall_normal: E
            order: 1
          - pos: {kind: line, start: [10, 20, 5], end: [40, 20, 5]}
            BC: RegularizedHWBB
            wall_normal: E
            order: 1
          - pos: {kind: plane, axis: z, axis_pos: 0.0, min: [10, 10], max: [50, 50]}
            BC: RegularizedHWBB
            wall_normal: B
            order: 1
          - pos: {kind: box, start: [10, 10, 0], end: [50, 50, 20], is_abs: true}
            BC: RegularizedHWBB
            wall_normal: E
            order: 1
          - pos: {kind: predicate, predicate: "(x - 50)**2 + (y - 50)**2 < 400"}
            BC: RegularizedHWBB
            wall_normal: E
            order: 1

A position’s coordinates are lattice coordinates unless the entry names a frame. Two optional fields place it, exactly as they place a refinement volume:

  • system, on the entry beside pos: the name of a domain.systems frame the coordinates are written in, defaulting to domain.default_system. Rotated systems are supported. It qualifies pos in either spelling, a predicate string or a shape, and is not valid on a cardinal alias, which is an lbm-frame domain face by definition.

  • transformation, on the shape: a rigid transform (rotation, translation, scale about fixed_point) mapping the shape’s own local frame into that system.

          - pos:
              kind: plane
              axis: z
              axis_pos: 0.0
              min: [10, 10]
              max: [50, 50]
              transformation:
                rotation: [0, 0, 0.7854]
            system: site
            BC: RegularizedHWBB
            wall_normal: B
            order: 1

The frame is baked into the lowered predicate, and the thickness follows it: a thickened direction is one lattice cell wide whatever units the shape is authored in. A frame that scales the axes differently has no single cell size and is rejected for the thickened kinds (point, points, line, plane); box and predicate thicken nothing and accept it.

Only is_abs: true boxes can carry a frame. A relative box (is_abs: false) is a fraction of the domain size, like a relative refinement volume, and has no physical frame.

Note

dist is a probe field: an export geometry generates sample points and needs a spacing between them. A placement shape selects the nodes the lattice already has, so it takes no dist and rejects one.

Volumetric (region) boundary conditions

Surface BCs act on the domain faces; a volumetric boundary condition acts on every fluid node inside a region of the domain. Volumetric regions are configured under models.volumetric_regions and are placed by the same pos a surface BC takes, so a cuboid is (x >= xa) & (x <= xb) & .... Region nodes are flagged on the lattice bitmap (a reserved per-node bit, no extra memory), and the volumetric term is applied in the LBM bulk only where the flag is set.

A volumetric region applies a porous-medium momentum sink to the fluid on region nodes, combining two body-force contributions:

  • a linear (Darcy) term F[a] = -porous_alpha * u[a], damping momentum proportionally to the local velocity (e.g. a porous block at the outlet to suppress pressure waves and near-outlet velocity);

  • a quadratic (Forchheimer / canopy) term F[a] = -porous_beta * |u| * u[a], modelling pressure-decay / canopy drag whose resistance grows with the square of the speed (e.g. a forest-canopy momentum sink).

models:
  volumetric_regions:
    # Linear Darcy outlet sponge (beta omitted -> 0).
    - pos: "(x >= 480) & (x <= 500)"
      porous_alpha: 0.2
      porous_beta: 0.05
    # Quadratic canopy drag region (shares the single alpha/beta), placed as a
    # typed shape instead of a predicate.
    - pos: {kind: box, start: [100, 0, 0], end: [200, 500, 20]}
      porous_alpha: 0.2
      porous_beta: 0.05
    # A porous ground layer, named by the cardinal alias for the B face.
    - pos: B
      porous_alpha: 0.2

A volumetric region takes every spelling of pos a surface BC takes - a cardinal alias, a predicate or a typed shape - with the same kinds and the same system / transformation envelope. A cardinal alias on a volume names the node layer at that domain face.

Both porous_alpha and porous_beta (>= 0, lattice units) are compile-time constants baked into the generated kernel, so all configured regions share the same porous_alpha and the same porous_beta. porous_beta defaults to 0 (linear-only).

Turbulent inflow

In many simulations it’s desired to use a turbulent inflow. This is configured under models.BC.inlet_turbulence, which selects an inlet turbulence generator by its type field.

  • type: sem: the synthetic eddy method (SEM).

  • type: podfs: the proper orthogonal decomposition Fourier series (PODFS) method.

The turbulent inflow is limited to the W boundary.

Synthetic eddy method (SEM)

A simulation set to use SEM as inlet will have the following aspect:

    models:
      BC:
        periodic_dims: [false, false, false]
        BC_map:
          - pos: E
            BC: RegularizedNeumannOutlet
            wall_normal: E
            order: 2
            params:
              rho: 1.0
          - pos: F
            BC: Neumann
            wall_normal: F
            order: 1
          - pos: B
            BC: RegularizedHWBB
            wall_normal: B
            order: 1
          - pos: N
            BC: Neumann
            wall_normal: N
            order: 0
          - pos: S
            BC: Neumann
            wall_normal: S
            order: 0
        inlet_turbulence:
          type: sem
          eddies:
            lengthscale: {x: 14, y: 14, z: 14}
            eddies_vol_density: 10
            seed_rand: 0
            domain_limits_yz:
              start: [-14, -14]
              end: [174, 78]
          profile:
            csv_profile_data: "../SEM/vprofile.csv"
            z_offset: 3.6
            K: 1
            length_mul: 1
            vel_mul: 1

The .csv file for profile.csv_profile_data describe the velocity profile and its stress tensor. It must contain the headers: z,ux,Rxx,Ryy,Rzz,Rxz,Rxy,Ryz

  • z: Height for given values

  • ux: Average velocity for the given height

  • Rxx,Ryy,Rzz,Rxz,Rxy,Ryz: Reynolds stress tensor for given height

Note

The values in between are found using linear interpolation, or constant extrapolation for values higher or lower than z max, min.

Density of eddies is given by eddies_vol_density, which is the density of eddies by volume, so for 10 it means that every SEM domain node will have 10 eddies affecting it in average. The randomization of eddies generation can be controlled using the seed_rand, so when using the same seed_rand and other configurations the same series of eddies are generated (making it possible to reproduce the same inlet multiple times).

The height of velocity profile can be adjusted with profile.z_offset. The profile is a function of height \(z\) only; the inlet is homogeneous in the lateral (\(y\)) direction.

Note

Examples can be found in nassu/fixture/SEM/ folder.

Proper orthogonal decomposition Fourier series (PODFS)

PODFS is a data-driven inlet generator: it builds a compact temporal basis from a turbulent precursor and replays it at the inlet through a Fourier series, so the injected field is continuous, periodic and independent of the solver time step. The theory is described in the PODFS theory page.

PODFS uses a two-step, load-only workflow. The plane-sampling to POD transform is a separate offline process; the solver only loads a finished basis file and replays it.

Step 1 - build the basis offline. Run nassu podfs-build on a small build-spec YAML (a standalone file, not a .nassu.yaml sim config) that describes the precursor source and the build parameters. It writes a single, self-describing basis file (HDF5) holding the mean profile, the POD modes, the Fourier coefficients, the analysis-grid coordinates, and an optional calibrated high-frequency supplement:

uv run nassu podfs-build my_case.podfs-build.yaml

A build-spec describes either a synthetic precursor (synthetic:) or a recorded plane-series export (plane: pointing at an .xdmf), plus the build knobs:

synthetic:
  grid_shape: [16, 24]      # structured (n_a, n_b) analysis grid
  extent: [12.0, 12.0]      # physical span (extent_a, extent_b); a -> y, b -> z
  n_snapshots: 256
  dt_s: 1.0
  mean_profile: log         # the mean ux(z) baked into the basis file
  mean_amplitude: 1.0
  modes:
    - { kind: sin_a, frequency: 0.1, wavenumber_a: 1.0, wavenumber_b: 1.0, amplitude: 1.0 }
  target_variance: { ux: 0.04, uy: 0.01, uz: 0.01 }
  seed: 0
energy_target: 0.99         # cumulative POD energy retained (or set n_modes)
high_freq: false            # calibrate and store the high-frequency supplement
supplement_seed: 0
weight:                     # optional weighted POD (omit for the uniform POD)
  z: { kind: variance, eps: 0.05 }                  # data-driven 1/variance along z
  y: { kind: gaussian, sigma: 0.3, scale: 80 }      # parametric bell along y
out_path: ./basis.h5

All POD decisions (energy target, mode and harmonic counts, the mean profile, the supplement, and the weighting) are made here, offline, and frozen into the file.

Weighted POD (weight). By default the POD is energy-optimal in the uniform norm, so it allocates modes by global energy and can under-resolve the near-wall small scales. The optional weight block biases the decomposition with a per-axis separable weight \(w = w_z(z)\,w_y(y)\); each axis (z, y) independently picks its method, or is omitted to stay uniform - so you can mix, e.g. data-driven variance in z and a parametric gaussian in y:

  • gaussian - a 1-D bell peaking at 1 (at the ground for z, at center (default span mid) for y) and equal to sigma at scale from the peak (\(s = \text{scale}/\sqrt{2\ln(1/\sigma)}\), no floor - beyond scale it keeps decaying, so set scale to where you want the weight to be sigma, typically the domain extent).

  • variance - the self-tuning \(1/\text{variance}\) profile along that axis (the per-point fluctuation variance averaged over the other axis), regularised by eps (a fraction of the mean variance, capping the boost at \(\sim 1/\texttt{eps}\)) and optionally max_ratio. This targets uniform relative fidelity - it boosts wherever the energy is low - rather than a geometric region, and which part of the axis it emphasises depends on the data (inspect the stored weight.png). It tends to spike on the first near-wall node where the absolute variance collapses with the mean velocity.

  • ti - proportional to the streamwise turbulence intensity \(I_u = \sqrt{\langle u_x'^2\rangle}/|U_x|\) along that axis (eps regularises the mean velocity near the wall). A smoother, physically meaningful emphasis on the high-relative-turbulence region than variance (the \(\sqrt{}\) compresses the dynamic range so it does not spike on the first node).

The leading modes then resolve the emphasised region better at a given mode count. This is purely an offline build choice - the basis-file format and the runtime injection are unchanged.

The chosen weight is stored in the basis file and, when a weight block is given, nassu podfs-build writes a <basis>.weight.png (the \(w(y,z)\) heatmap plus its profiles through the peak) so the emphasis can be inspected and tuned.

Step 2 - run the case, load-only. The runtime config just points basis_path at the prebuilt file and sets the unit/replay knobs:

    models:
      BC:
        inlet_turbulence:
          type: podfs
          basis_path: "../PODFS/basis.h5"
          vel_mul: 1.0
          time_rescale: 1.0
          high_freq: true

The mean velocity profile now lives inside the basis file (no runtime CSV). The unit conversion uses two independent linear knobs: vel_mul rescales the whole velocity (mean and fluctuation together, preserving the turbulence-intensity ratio), and time_rescale maps the basis temporal axis onto the solver clock (effective period \(T_\mathrm{eff} = T / \texttt{time\_rescale}\)). high_freq (default true) uses the high-frequency supplement stored in the basis file when present; it has no effect when the basis carries none.

Spatial placement (spatial). The PODFS basis lives on its own structured analysis grid, decoupled from the inlet lattice. That grid (grid_shape, extent) is chosen at build time and stored with the basis coordinates. At runtime the mean and each POD mode are bilinearly resampled from the analysis grid onto the inlet nodes of every refinement level, through an explicit affine placement so the precursor may differ from the inlet in resolution, extent, and spatial scale (for example a 12 m precursor onto a 10 m inlet). The placement is symmetric in y and grounded in z:

          spatial:
            y_center: 80.0   # span centreline (default: inlet mid-width)
            y_scale: 1.0     # spanwise stretch of the basis width about y_center
            z_offset: 5.01   # ground level (default: basis recorded z-bottom)
            z_scale: 1.0     # vertical stretch of the basis height from z_offset

The basis width maps symmetrically about y_center (so a span mismatch displaces both edges equally instead of piling deviation onto one edge, matching the usual centred refinement), and the basis height maps upward from z_offset (the ground). Below z_offset both the mean and the fluctuation are zero (treated as below-ground); outside the lateral span the mean profile still applies. When spatial is omitted the precursor is placed at its native span, centred on the inlet mid-width and grounded at its recorded height (no distortion). PODFS supports multiple refinement levels at the inlet: every level receives its own resampled modes and is injected at its own physical time.

Load-time options. A rich basis can be built once and replayed as a cheaper subset, without rebuilding the POD. The mode count is cropped by either count or energy: n_modes keeps the first N energy-ranked modes, or energy_crop (0-1) keeps the fewest modes reaching that cumulative energy fraction (the two are mutually exclusive). n_harmonics keeps the DC term plus the first K Fourier harmonics. All clamp to what the basis holds; omit to use everything. This makes mode-count / energy sensitivity sweeps a config change rather than a rebuild. The stored mean can also be replaced at runtime with mean_profile (a CSV with columns z, ux, plus z_offset/length_mul); vel_mul then scales the override along with the fluctuation.

Inspecting the basis. nassu podfs-build writes two companions alongside the basis file. <basis>.modes.xdmf opens in ParaView - scrub the time slider to step through the spatial structure of each POD mode (frame 0 is the mean, frames 1..N are the modes), colouring by ux/uy/uz. <basis>.energy.png shows the per-mode energy (log scale) and the cumulative-energy curve with the 90/95/99% crossings marked, so you can pick a sensible n_modes / energy_crop for the run.

Important

For more details check the models documentation.