Flow Through Trees

The simulation of a turbulent flow through an arrangement of trees validates the solver’s volumetric quadratic (Forchheimer) canopy-drag model. Instead of resolving the canopy with an immersed-boundary point cloud, the canopy is modelled as a volumetric momentum sink applied as a fluid body force on every node inside a predicate region (models.volumetric_regions): \(F_\alpha = -\beta\,|u|\,u_\alpha\). For comparison, the results of Qi and Ishihara, 2018 and Kang et al., 2020 were used.

It consists of a 2 x 74 x 6 (m) tree arrangement positioned at 1 m height after an atmospheric flow. The drag coefficient is estimated as \(C_d = 1.6\) and the leaf area density is \(LAD = 1.16\,m^{-1}\), so the quadratic drag coefficient is derived as \(\beta_0 = 0.5\,C_d\,LAD = 0.928\) (lattice units, level 0; rescaled per level by \(1/2^{lvl}\) inside the solver). This starting value was migrated from the previous IBM point-cloud kinetic_energy_correction canopy (issue #743) and must be calibrated against the benchmark below; the domain setup is illustrated next:

localimage

The numerical setup goal is to correctly capture both velocity and turbulent kinetic energy changes caused by the tree arrangement. The domain setup is shown below:

localimage
[1]:
from nassu.cfg.model import ConfigScheme

filename = "./validation/porous_media/01_flow_through_trees/01_flow_through_trees.nassu.yaml"

sim_cfgs = ConfigScheme.sim_cfgs_from_file_dct(filename)
[2]:
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

import nassu.viz as common

common.use_style()

H_num = 7
scale = 1

Load experimental data:

[3]:
base_path = Path("./validation/porous_media/01_flow_through_trees/reference")
positions = ["1H", "2H", "3H", "4H", "5H"]

df_exp = []

vel = pd.read_csv(base_path / "qi2018_exp_velocity_pitot.csv")
kin = pd.read_csv(base_path / "qi2018_exp_kinetic_energy_pitot.csv")
df_csv = pd.concat([vel, kin], axis=1, join="inner")
df_exp.append(df_csv)

for pos in positions:
    vel = pd.read_csv(base_path / f"qi2018_exp_velocity_{pos}.csv")
    kin = pd.read_csv(base_path / f"qi2018_exp_kinetic_energy_{pos}.csv")
    df_csv = pd.concat([vel, kin], axis=1, join="inner")
    df_exp.append(df_csv)
[4]:
base_path = Path("./validation/porous_media/01_flow_through_trees/reference")
positions = ["1H", "2H", "3H", "4H", "5H"]

df_num2 = []

vel = pd.read_csv(base_path / "qi2018_num_velocity_pitot.csv")
kin = pd.read_csv(base_path / "qi2018_num_kinetic_energy_pitot.csv")
df_csv = pd.concat([vel, kin], axis=1, join="inner")
df_num2.append(df_csv)

for pos in positions:
    vel = pd.read_csv(base_path / f"qi2018_num_velocity_{pos}.csv")
    kin = pd.read_csv(base_path / f"qi2018_num_kinetic_energy_{pos}.csv")
    df_csv = pd.concat([vel, kin], axis=1, join="inner")
    df_num2.append(df_csv)

Results

[5]:
sim_cfg = sim_cfgs["flowThroughTrees", 0]

point_ref = sim_cfg.output.exports["velocities"].series.points["velocity_probe"]
df_ref = point_ref.read_full_data("ux")

df_ref = df_ref[df_ref["time_step"] > 10000]

df_point = df_ref

ux_avg = df_ref.mean()
ux_rms = df_ref.std()
Iu = ux_rms / ux_avg


u_ref = ux_avg["0"]
u_ref
[5]:
np.float32(0.02457021)
[6]:
def line_stats(position: str, u_ref) -> pd.DataFrame:
    line_ref = sim_cfg.output.exports["velocities"].series.lines[f"{position}"]
    df_ux = line_ref.read_full_data("ux")
    df_uy = line_ref.read_full_data("uy")
    df_uz = line_ref.read_full_data("uz")

    df_ux = df_ux[df_ux["time_step"] > 10000]
    df_uy = df_uy[df_uy["time_step"] > 10000]
    df_uz = df_uz[df_uz["time_step"] > 10000]

    ux_avg = (df_ux.mean()) / u_ref
    ux_rms = (df_ux.std()) ** 2

    uy_rms = (df_uy.std()) ** 2
    uz_rms = (df_uz.std()) ** 2

    k = (ux_rms + uy_rms + uz_rms) / (2 * u_ref**2)

    df = pd.DataFrame({"ux_avg": ux_avg, "k": k})
    df = df.drop(df.index[0])
    df["pos"] = np.linspace(0, (2 * H_num) / H_num, num=len(df), endpoint=True)
    return df
[7]:
df_num = []
df_csv = line_stats("velocity_profile", u_ref)
df_num.append(df_csv)

for pos in positions:
    df_csv = line_stats(f"pos_{pos}", u_ref)
    df_num.append(df_csv)
[8]:
def plot_curves(position: str, df_num, df_num2, df_exp):
    fig, ax = common.fig_double()
    fig.suptitle(f"{position}")

    ax[0].plot(df_num["ux_avg"], df_num["pos"], **common.markers.sim_line(), label="AeroSim")
    ax[0].plot(
        df_num2["u"],
        df_num2["z"],
        color=common.colors.blue,
        linestyle="--",
        label="Qi and Ishihara (2018)",
    )
    ax[0].plot(df_exp["u"], df_exp["z"], **common.markers.exp(shape="o"), label="Exp")
    ax[0].set_ylabel("$z/H$")
    ax[0].set_xlabel("$u/u_{H}$")
    ax[0].set_xlim(0, 2.0)
    ax[0].set_ylim(0, 1.2)
    ax[0].legend(loc="best")

    ax[1].plot(df_num["k"], df_num["pos"], **common.markers.sim_line(), label="AeroSim")
    ax[1].plot(
        df_num2["k"],
        df_num2["h"],
        color=common.colors.blue,
        linestyle="--",
        label="Qi and Ishihara (2018)",
    )
    ax[1].plot(df_exp["k"], df_exp["h"], **common.markers.exp(shape="o"), label="Exp")
    ax[1].set_ylabel("$z/H$")
    ax[1].set_xlabel("TKE/$u_{H}^{2}$")
    ax[1].set_xlim(0, 0.2)
    ax[1].set_ylim(0, 1.2)
    ax[1].legend(loc="best")

    plt.tight_layout()
    plt.show(fig)
[9]:
## Inflow plot
plot_curves("Inflow", df_num[0], df_num2[0], df_exp[0])

## Post obstacles plots
for i, pos in enumerate(positions):
    plot_curves(pos, df_num[i + 1], df_num2[i + 1], df_exp[i + 1])
../../../_images/validation_porous_media_01_flow_through_trees_01_flow_through_trees_14_0.png
../../../_images/validation_porous_media_01_flow_through_trees_01_flow_through_trees_14_1.png
../../../_images/validation_porous_media_01_flow_through_trees_01_flow_through_trees_14_2.png
../../../_images/validation_porous_media_01_flow_through_trees_01_flow_through_trees_14_3.png
../../../_images/validation_porous_media_01_flow_through_trees_01_flow_through_trees_14_4.png
../../../_images/validation_porous_media_01_flow_through_trees_01_flow_through_trees_14_5.png

It can be seen a satisfactory match between inflow and experimental data despite the coarse resolution applied on the validation case. The qualitative aspects of the flow profile after the tree arrangement are well reproduced and quantitative aspects also have a decent representation. The difference compared to Qi and Ishihara results might be due our resolution of 0.25m against their 0.025m close to the ground.

Flow field

Instantaneous velocity magnitude on the vertical plane through the tree canopy centre (plane_series.mid_canopy).

[10]:
from nassu import viz

viz.enable_offscreen()

PANEL = (1040, 200)
cfg = sim_cfgs["flowThroughTrees", 0]
source = viz.PlaneSource.from_cfg(cfg, series="plane_series", plane="mid_canopy")
view = viz.frame_domain((600.0, 144.0, 64.0), "y", panel=PANEL, slice_coord=72.0)

steps = [source.steps[-1]]
plotter = viz.render_grid(
    [viz.Panel("mid-canopy", source, view)],
    steps=steps,
    scalar="u_mag",
    cmap="viridis",
    clim=(0.0, 0.09),
    bar_title="|u|",
    panel_size=PANEL,
)
plotter.show()
2026-06-29 14:43:36.521 (   6.737s) [    7F2AE0A9B740]vtkXOpenGLRenderWindow.:1460  WARN| bad X server connection. DISPLAY=
/tmp/ipykernel_1967044/2872874502.py:20: UserWarning: Using static image for notebook display.
Install trame for interactive backends: pip install "pyvista[jupyter]"
  plotter.show()
../../../_images/validation_porous_media_01_flow_through_trees_01_flow_through_trees_17_1.png

Version

[11]:
sim_cfg = next(iter(sim_cfgs.values()))
sim_info = sim_cfg.output.read_info()

nassu_commit = sim_info["commit"]
nassu_version = sim_info["version"]
print("Version:", nassu_version)
print("Commit hash:", nassu_commit)
Version: 2.0.1a0
Commit hash: 74086fcfe991b58587b79097f1736c8158040f11

Configuration

[12]:
from IPython.display import Code

Code(filename=filename)
[12]:
variables:
  domain:
    scale: !math 1/1
    plane_height: 4.0
    body_pos: 200
    H_ref:
    pos_025H: !math ${domain.body_pos} + 0.25*${var.H_num}
    pos_05H: !math ${domain.body_pos} + 0.5*${var.H_num}
    pos_1H: !math ${domain.body_pos} + 1.0*${var.H_num}
    pos_2H: !math ${domain.body_pos} + 2.0*${var.H_num}
    pos_3H: !math ${domain.body_pos} + 3.0*${var.H_num}
    pos_4H: !math ${domain.body_pos} + 4.0*${var.H_num}
    pos_5H: !math ${domain.body_pos} + 5.0*${var.H_num}
  var:
    H_num: !math 7*${domain.scale}

simulations:
  - name: flowThroughTrees
    save_path: ./validation/porous_media/01_flow_through_trees/results

    n_steps: 40000

    report:
      frequency: 500

    domain:
      domain_size:
        x: 600
        y: 144
        z: 64
      block_size: 8
      bodies:
        full_plane:
          IBM:
            run: true
            cfg_use: category_II
            order: 0
          geometry_path: fixture/stl/abl/ground.stl
          small_triangles: add
          transformation:
            translation: !math [0, 0, "${domain.plane_height}"]
        plates_obstacles:
          IBM:
            order: 1
          geometry_path: fixture/stl/abl/roughness_elements_cat2.stl
          volumes_limits:
            body_transformed:
              - start: !math [10, 20, 0]
                end: !math [120, 124, 64]
          small_triangles: add
          transformation:
            translation: !math [0, 1, "${domain.plane_height}"]
            scale: !math [1.25, 1.25, "20.0*${domain.scale}"] #2m

      refinement:
        static:
          default:
            volumes_refine:
              # - start: [0, 56, 2]
              #   end: [256, 88, 12]
              #   lvl: 3
              #   is_abs: true
              - start: [0, 34, 0]
                end: [288, 110, 24]
                lvl: 2
                is_abs: true
              - start: [0, 0, 0]
                end: [352, 144, 40]
                lvl: 1
                is_abs: true

    data:
      monitors:
        fields:
          rho_max:
            macrs: [rho]
            stats: [min, max]
            interval: {start_step: 500, frequency: 50}
      exports:
        full_domain:
          macrs: [rho, u, omega_LES, f_IBM]
          interval:
            frequency: 10000
            lvl: 0
          target:
            volume: {}
          outputs:
            instantaneous: true
        full_domain_stats:
          macrs:
            - rho
            - u
          interval:
            frequency: 10
            start_step: 10000
            lvl: 0
          target:
            volume: {}
          outputs:
            instantaneous: false
            stats:
              macrs_1st_order:
                - rho
                - u
              macrs_2nd_order:
                - u
        velocities:
          macrs: ["u"]
          interval: {frequency: 10, lvl: 0}
          target:
            lines:
              inlet_profile:
                dist: 0.25
                start_pos: !math [0, 72.0, "${domain.plane_height}"]
                end_pos: !math [0, 72.0, "${domain.plane_height} + 14*${domain.scale}"]
              velocity_profile:
                dist: 0.25
                start_pos: !math ["${domain.body_pos}-50*${domain.scale}", 72.0, "${domain.plane_height}"]
                end_pos: !math ["${domain.body_pos}-50*${domain.scale}", 72.0, "${domain.plane_height} + 14*${domain.scale}"]
              pos_025H:
                dist: 0.25
                start_pos: !math ["${domain.pos_025H}", 72.0, "${domain.plane_height}"]
                end_pos: !math ["${domain.pos_025H}", 72.0, "${domain.plane_height} + 14*${domain.scale}"]
              pos_05H:
                dist: 0.25
                start_pos: !math ["${domain.pos_05H}", 72.0, "${domain.plane_height}"]
                end_pos: !math ["${domain.pos_05H}", 72.0, "${domain.plane_height} + 14*${domain.scale}"]
              pos_1H:
                dist: 0.25
                start_pos: !math ["${domain.pos_1H}", 72.0, "${domain.plane_height}"]
                end_pos: !math ["${domain.pos_1H}", 72.0, "${domain.plane_height} + 14*${domain.scale}"]
              pos_2H:
                dist: 0.25
                start_pos: !math ["${domain.pos_2H}", 72.0, "${domain.plane_height}"]
                end_pos: !math ["${domain.pos_2H}", 72.0, "${domain.plane_height} + 14*${domain.scale}"]
              pos_3H:
                dist: 0.25
                start_pos: !math ["${domain.pos_3H}", 72.0, "${domain.plane_height}"]
                end_pos: !math ["${domain.pos_3H}", 72.0, "${domain.plane_height} + 14*${domain.scale}"]
              pos_4H:
                dist: 0.25
                start_pos: !math ["${domain.pos_4H}", 72.0, "${domain.plane_height}"]
                end_pos: !math ["${domain.pos_4H}", 72.0, "${domain.plane_height} + 14*${domain.scale}"]
              pos_5H:
                dist: 0.25
                start_pos: !math ["${domain.pos_5H}", 72.0, "${domain.plane_height}"]
                end_pos: !math ["${domain.pos_5H}", 72.0, "${domain.plane_height} + 14*${domain.scale}"]
            points:
              velocity_probe:
                pos: !math ["${domain.body_pos}-50*${domain.scale}", 72, "${domain.plane_height} + 7*${domain.scale}"]
          outputs:
            instantaneous: true
        plane_series:
          macrs: ["u"]
          interval: {frequency: 5000, lvl: 0}
          target:
            planes:
              # Coarse vertical overview plane through the canopy centre
              # (one node apart, full domain): debug-grade view.
              mid_canopy:
                axis: y
                axis_pos: 72
                dist: 1
          outputs:
            instantaneous: true
        plane_series_roi:
          macrs: ["u"]
          interval: {frequency: 2000, lvl: 0}
          target:
            planes:
              # Fine plane restricted to the canopy region of interest
              # (canopy at x = 200, H = 7).
              mid_canopy_roi:
                axis: y
                axis_pos: 72
                min: !math ["${domain.body_pos} - 8*${var.H_num}", 0]
                max: !math ["${domain.body_pos} + 12*${var.H_num}", "4*${var.H_num}"]
                dist: 0.25

          outputs:
            instantaneous: true
    models:
      precision:
        default: single

      LBM:
        tau: 0.500011
        vel_set: D3Q27
        coll_oper: RRBGK
      initialization:
        inlet_field: true

      engine:
        name: CUDA

      BC:
        periodic_dims: [false, false, false]
        BC_map:
          - pos: E
            BC: RegularizedNeumannOutlet
            rho: 1.0
            wall_normal: E
            order: 2

          - 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: 20, y: 20, z: 20}
            domain_limits_yz:
              start: [20, -20]
              end: [124, 40]
          profile:
            csv_profile_data: "fixture/SEM/category_vprofile/profile_log_cat4_H150_Uh0.06.csv"
            z_offset: !math "${domain.plane_height}"
            K: 1.0

      LES:
        model: Smagorinsky
        sgs_cte: 0.17

      # Canopy quadratic (Forchheimer) drag region: F = -beta * |u| * u, applied
      # as a fluid Guo body force over the canopy bounding box. Migrated from the
      # IBM point-cloud canopy (kinetic_energy_correction) per #743. The bbox is
      # the transformed extent of fixture/point_clouds/cube_2x74x6.csv
      # (scale [1,1,1], translation [200, 72, 5.0=1.0+plane_height]).
      # beta starting value derived from the canopy Cd*LAD (0.5 * Cd * LAD =
      # 0.5 * 1.6 * 1.16 = 0.928); PENDING GPU calibration against the
      # Qi & Ishihara (2018) benchmark - not yet validated.
      volumetric_regions:
        - pos: "(x >= 200) & (x <= 202) & (y >= 35) & (y <= 109) & (z >= 5) & (z <= 11)"
          porous_alpha: 0.0
          porous_beta: !math 0.5*1.6*1.16

      IBM:
        body_cfgs:
          category_II:
            n_iterations: 3
            forces_factor: 0.5
            wall_model:
              name: EqLog
              dist_ref: 3.125
              dist_shell: 0.125
              start_step: 5000
              params:
                z0: !math "0.01*${domain.scale}"
                TDMA_max_error: 1e-04
                TDMA_max_iters: 10
                TDMA_min_div: 51
                TDMA_max_div: 51
          default: {}