Multiblock

The multiblock feature allows for subdivision of the computational domain in finer grid refinement regions. This is illustrated by the figure below

Resolution change

Multiblock example

The refinement structure is separated in levels whose resolution is always double the previous level, beginning at level 0.

The transition between levels must always occur from a previous to a next level (e.g: a transition from level 1 to level 3 must include a level 2 region in-between).

The refined regions are determined in domain.refinement field:

    domain:
      refinement:
        static:
          volume_refinement_lvl1:
            volumes_refine:
              - is_abs: true
                lvl: 1
                start: [0.0, 0.0, 0.0]
                end: [32.0, 160.0, 64.0]
            volumes_not_refine:
              - is_abs: false
                start: [0, 0.15, 0.1]
                end: [0.1, 0.2, 0.3]
          body_refinement_lvl3:
            bodies:
              - body_name: solid-body1
                lvl: 3
                normal_offsets: !range [-0.25, 0.2501, 0.125]
            volume_refinement_limit:
              is_abs: true
              start: [0, 10, 0]
              end: [40, 50, 150]

Important

It is recommended to avoid placing too many Lagrangian mesh nodes in the level transition regions. Hence when using the body refinement, an offset interval of at least +/- 1 block may be necessary.

To fill the regions between offset limits, we suggest to use !range as shown above.

Oriented (rotated) refinement volumes

By default a refinement volume is axis-aligned: its start/end corners are read directly in domain coordinates. To refine a region that sits at an angle to the domain axes (for example a tight box hugging a rotated building), add an optional transformation. The start/end corners then define the box in a local frame and the transformation maps it into the domain, producing an oriented box.

    domain:
      refinement:
        static:
          oriented_volume:
            volumes_refine:
              - is_abs: true # required when a rotation is given
                lvl: 4
                start: [0, 0, 0]
                end: [40, 20, 60]
                transformation:
                  rotation: [0.0, 0.0, 0.7853981634] # 45 deg about z (radians)
                  translation: [60.0, 40.0, 0.0]
                  fixed_point: [20.0, 10.0, 30.0] # box centre

The transformation fields (rotation in radians, translation, fixed_point, scale) match the body transformation, so a refinement volume and a body can share the same transformation and stay co-located. When transformation is omitted the volume stays axis-aligned. The same transformation field is also accepted on export, monitor and statistics volumes.

Note

A non-zero rotation requires is_abs: true: a rotated box can only be interpreted unambiguously in absolute coordinates. lattice_margin shells expand the box in its local frame and are mapped through the same transformation.

On an export volume the transformation does two things: it selects the blocks overlapping the oriented box (as for refinement), and it rigidly transforms the exported geometry so the volume is written rotated in space. The transform is applied to the output node coordinates on top of the global export rescale, and the affected blocks are emitted as an explicit structured grid (XDMF 3DSMesh, a vtkStructuredGrid) so the orientation survives into ParaView. The macroscopic values are written unchanged - there is no resampling onto a new grid, only the coordinates move. An export volume with no transformation keeps the regular axis-aligned grid (3DCoRectMesh) and its output is unchanged.

Overlap F2C

In the current multiblock framework, the communication between coarse and fine level requires an overlap of both grids, as shown below:

Overlap Fine to Coarse

Overlap in F2C (fine to coarse)

Important

For turbulent flows, it is common practice to use at least 2 nodes for the overlap to provide certain flow development between level transitions.

This configuration is set in the models.multiblock field:

      multiblock:
        overlap_F2C: 2
        # It's possible to define custom overlaps of F2C for specific levels
        # To do so, use as [lvl_from]: overlap to use
        custom_overlap_F2C:
          1: 3
          2: 3
          5: 1

Interface coupling scheme

The operator that couples coarse and fine grids at a refinement interface is selected with models.multiblock.interface_coupling:

      multiblock:
        interface_coupling: value_interp   # or: conservative
  • value_interp (default) is the value-interpolation coupling: a cubic four-point coarse-to-fine prolongation of the macroscopics and a fine-to-coarse point injection.

  • conservative replaces the transfer of the fluid conserved moments (density and momentum) with a conservative pair on the same 2:1 nodes: a positivity-preserving momentum-space prolongation (density and momentum are interpolated with convex weights and the velocity is recovered as momentum over density) and a fine-to-coarse restriction that averages the fine children. The non-equilibrium stress channel, its LES stress rescaling and the temporal interpolation are shared by both schemes.

The conservative scheme keeps mass and momentum continuous across the interface, which forbids the negative-density interface failure that the cubic prolongation can produce in strong turbulence at near-inviscid relaxation. On smooth flows it recovers the interface momentum the value interpolation loses: for a laminar channel with a refinement interface across the flow, the peak-velocity error against the analytical parabola drops from about 4% to below 1%. The operator definitions are given in the multiblock communication theory page.

Note

interface_coupling: conservative currently applies the conservative transfer to the fluid density and momentum. Transported scalar and energy fields keep their existing interface transfer under either setting.

Interface checkerboard stabilization

At near-inviscid relaxation (omega -> 2) in strong turbulence, a refinement interface can excite a grid-scale (2dx) odd-even checkerboard mode that grows and diverges. The optional models.multiblock.interface_stabilization adds an adaptive dissipation that suppresses this mode without over-dissipating the resolved flow:

      multiblock:
        interface_coupling: conservative
        interface_stabilization:
          strength: 0.2    # extra-viscosity gain (a local Smagorinsky-constant boost)
          threshold: 0.5   # sensor value (0 smooth, 1 grid-scale) above which it ramps in

A per-node 2dx odd-even (Jameson) sensor measures how much of the local field is the grid-scale mode (0 for smooth flow, approaching 1 for a pure checkerboard). Where the sensor exceeds threshold, a graded extra subgrid viscosity strength^2 * |S| * gate is added through the LES relaxation - a local boost of the Smagorinsky constant. Because the sensor distinguishes the checkerboard from physical shear, the extra dissipation is inactive in smooth resolved flow (a smooth laminar profile is unchanged to within rounding), so it localizes the global-Cs cure to where it is needed. It requires models.LES; omitting it disables the feature and leaves the generated kernels byte-identical.

Important

strength has a working window: too small does not suppress the mode, too large introduces its own near-interface viscosity discontinuity that can diverge sooner. A value comparable to the base Smagorinsky constant is a good starting point; pair it with interface_coupling: conservative, which already reduces the dissipation needed.

Saving and loading a BlockForest

Building the BlockForest from the YAML refinement program is the dominant cost at simulation startup for non-trivial CWE cases. To avoid replaying the divide_volume / divide_geometry calls every time, the forest can be serialised to disk and reloaded:

from nassu.api import load_block_forest
from nassu.LBM import VelocitySet
from nassu.multiblock import BlockForest

vel_set = VelocitySet("D3Q27")
bf = BlockForest(vel_set, (256, 128, 128), 8, (False, False, False))
# ... apply divide_volume / divide_geometry as usual ...

bf.save("mygrid.nassu-bf")          # writes mygrid.nassu-bf.json
bf2 = load_block_forest("mygrid.nassu-bf.json", vel_set)

The output is a single self-describing JSON file with a .nassu-bf.json extension. The domain header (dimensionality, periodicity, refinement levels, block counts, etc.) is human-readable; the per-block records are stored as a base64-encoded binary payload at the end of the object so a head or jq inspection workflow shows the readable fields first:

jq 'del(.blocks.encoded_payload_b64)' mygrid.nassu-bf.json

Integrity is verified on load via SHA-256 of the decoded payload, and the loader cross-checks vel_set.dim against the file’s dim field. Loading a file written by an incompatible Nassu version (different JSON schema or block record layout version) raises BlockForestFormatError rather than silently loading garbage.

Note

Only the grid topology is serialised - boundary-condition assignments, populations, macroscopics, and any other simulation state are not part of the file and are rebuilt as usual when the forest is loaded.

From the config file

The save/load primitive is wired into the domain block so the build-once / reuse loop works entirely from YAML, with no Python glue:

    domain:
      domain_size: {x: 512, y: 256, z: 256}
      block_size: 8
      # Write the constructed forest after the refinement program runs.
      block_forest_save: !sub "${NASSU_FILE_FOLDER}/grids/city.nassu-bf.json"
      refinement:
        static:
          city:
            bodies:
              - {body_name: city, lvl: 3, normal_offsets: [0.0]}
      bodies:
        city: {geometry_path: !sub "${NASSU_FILE_FOLDER}/city.lnas"}

A later run points block_forest_load at the saved file to skip the re-refinement entirely:

    domain:
      domain_size: {x: 512, y: 256, z: 256}
      block_size: 8
      block_forest_load: !sub "${NASSU_FILE_FOLDER}/grids/city.nassu-bf.json"
  • The declared domain_size, block_size and periodic dimensions are cross-checked against the loaded file; a mismatch is a configuration error (the loaded forest is authoritative for the base grid).

  • Any refinement.static declared alongside block_forest_load is applied on top of the loaded forest. This lets you reuse a cached grid and refine it further (for example, add a finer box around a new region of interest) without rebuilding from scratch. For pure reuse, omit refinement entirely.

  • block_forest_save and block_forest_load are independent; relative paths follow the same convention as geometry_path (use ${NASSU_FILE_FOLDER} to anchor to the config folder).

Estimating disk storage from Python

See also

This section is the narrative how-to. For the full nassu.api surface (every function, its signature and the result types), see the Python API reference.

nassu.api.preview_simulation_storage mirrors the nassu preview --storage CLI flag for notebook and submission tooling that already has a SimulationConfigs and a BlockForest in hand. The helper is GPU-free and does not build a SimulationModulesHandler or MacrsHandler: macroscopic instance counts and the per-element itemsize come directly from sim_cfg.models. It runs in seconds even on huge cases:

from nassu.api import load_block_forest, preview_simulation_storage
from nassu.cfg.model import ConfigScheme
from nassu.LBM import VelocitySet

sim_cfg = ConfigScheme.sim_cfgs_from_file("my_case.nassu.yaml")[0]
bf = load_block_forest("my_case.nassu-bf.json", VelocitySet("D3Q27"))
estimate = preview_simulation_storage(sim_cfg, bf)
print(estimate.total_bytes, "bytes")
for entry in estimate.by_output:
    print(entry.name, entry.kind, entry.total_bytes)

Pass ibm_node_counts / probe_point_counts to cover bodies and file-backed probes the helper cannot enumerate from the config alone. Like the CLI, the estimate ignores compression and per-format overhead - it is the same rough number you would get from nassu preview --storage.

Per-output composable helpers

For callers that only need to size a single output (a probe series, a specific instantaneous export, a monitor, etc.) without computing the whole-sim aggregate, the per-kind helpers are re-exported on nassu.api:

  • estimate_instantaneous_storage(name, cfg, sim_cfg, block_forest)

  • estimate_stats_storage(name, cfg, sim_cfg, block_forest)

  • estimate_series_storage(name, cfg, sim_cfg) (alias of estimate_instantaneous_storage for series targets)

  • estimate_monitor_storage(field_name, cfg, sim_cfg)

  • estimate_ibm_nodes_storage(name, cfg, sim_cfg)

Each takes one data.exports.<name> entry (cfg) and returns a single OutputStorageEstimate matching the corresponding entry in preview_simulation_storage(...).by_output (named exports.<name>.{instantaneous,stats}). All accept an optional macr_itemsize keyword to override the precision derived from sim_cfg, which is handy for “what-if I switch to single precision” sizing studies without rebuilding the config:

from nassu.api import estimate_series_storage

series_cfg = sim_cfg.data.exports["line_inlet"]
est = estimate_series_storage("line_inlet", series_cfg, sim_cfg)
print(est.total_bytes, est.n_export_steps, est.n_points)

The pure counters count_volume_nodes, count_points_in_line and count_series_points, the precision helper macr_itemsize_from_precision and the macroscopic resolver resolve_macr_instance_names are also exposed on nassu.api for sizing checks that do not need a byte tally.

Naming convention:

  • preview_simulation_storage - the whole-sim entry point.

  • estimate_<kind>_storage - per-output composable helpers.

  • count_<thing> - pure counters.

  • resolve_macr_instance_names - the macroscopic resolver.

Estimating GPU memory from Python

See also

The Python API reference is the structured reference for the whole nassu.api surface, including the memory result types.

nassu.api.preview_simulation_memory is the device-memory counterpart of preview_simulation_storage, mirroring the GPU-memory figure that nassu preview reports. It is GPU-free and handler-free in exactly the same way - macroscopic instance counts and the per-element itemsize come straight from sim_cfg.models, so a notebook or submission UI can size device memory from a SimulationConfigs plus a BlockForest alone:

from nassu.api import load_block_forest, preview_simulation_memory
from nassu.cfg.model import ConfigScheme
from nassu.LBM import VelocitySet

sim_cfg = ConfigScheme.sim_cfgs_from_file("my_case.nassu.yaml")[0]
bf = load_block_forest("my_case.nassu-bf.json", VelocitySet("D3Q27"))
estimate = preview_simulation_memory(sim_cfg, bf)
print(estimate.total_bytes, "bytes")
for family in estimate.by_family:
    print(family.family, family.n_bytes)
    for component in family.by_item:
        print("  ", component.name, component.n_bytes)

The estimate is broken down per GPU buffer family - macrs (one component per stored macroscopic group), populations (the border/halo shell), scalar (one component per models.scalar_transports field), lattice_map, optionally ibm, and a rough overhead residual given as an explicit, labelled line. Transported (scalar / DDF) fields are counted by default. The figure is rough in the same spirit as the CLI: dominant consumers are explicit, the rest is the overhead residual.

Exact IBM accounting

Pass exact=True together with ibm_node_counts={body_name: n_nodes} to size the per-body IBM Lagrangian-node device arrays explicitly instead of folding them into the overhead residual. This mirrors nassu preview --exact-memory (which collects the node counts off the live setup for you):

estimate = preview_simulation_memory(
    sim_cfg, bf, exact=True, ibm_node_counts={"building": 12000}
)

Per-family composable helpers

For callers that only need to size one family, the per-family helpers are re-exported on nassu.api, each returning a single MemoryFamilyEstimate:

  • estimate_macrs_memory(sim_cfg, block_forest)

  • estimate_populations_memory(sim_cfg, block_forest)

  • estimate_scalar_memory(sim_cfg, block_forest)

  • estimate_lattice_map_memory(block_forest)

  • estimate_ibm_memory(sim_cfg, ibm_node_counts=...)

  • estimate_overhead_memory(sized_total_bytes)

All accept an optional macr_itemsize keyword (where applicable) to override the precision derived from sim_cfg, matching the storage helpers.