From f0c2294e052b0fd6f4b53b891ae747fad81f7f06 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Tue, 9 Jun 2026 13:34:48 +0200 Subject: [PATCH 01/34] add compile step --- .github/workflows/docs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1fe3c3445..5c9f89c76 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -48,6 +48,9 @@ jobs: with: optional-deps: 'dev,doc' + - name: Compile Struphy + uses: ./.github/actions/compile + - name: Build Sphinx docs run: | cd doc From c292677de529d02964c5f677ef4e57fa44ee544f Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Wed, 10 Jun 2026 08:10:04 +0200 Subject: [PATCH 02/34] add new unit test --- .../test_verif_ViscousEulerSPH.py | 130 +++++++++- tutorials/tutorial_viscous_euler_sph.ipynb | 227 ++++++++++++++++++ 2 files changed, 356 insertions(+), 1 deletion(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index efb542232..f7cd0327e 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -25,8 +25,10 @@ perturbations, ) from struphy.models import ViscousEulerSPH +from struphy import set_logging_level logger = logging.getLogger("struphy") +set_logging_level(logging.DEBUG) @pytest.mark.parametrize("nx", [12, 24]) @@ -161,5 +163,131 @@ def test_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): shutil.rmtree(test_folder) +@pytest.mark.parametrize("nx", [12, 24]) +@pytest.mark.parametrize("plot_pts", [11, 32]) +def test_damped_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): + """Verification test for SPH discretization of isthermal Euler equations. + A standing sound wave with c_s=1 is damped at the rate mu*k^2/2 by viscosity. + """ + + # environment options + test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") + out_folders = os.path.join(test_folder, "ViscousEulerSPH") + env = EnvironmentOptions(out_folders=out_folders, sim_folder="damped_soundwave_1d") + + # time stepping + time_opts = Time(dt=0.01, Tend=0.18, split_algo="Strang") + + # geometry + r1 = 2.5 + domain = domains.Cuboid(r1=r1) + + # grid + grid = None + + # derham options + derham_opts = None + + # light-weight model instance + model = ViscousEulerSPH(with_B0=False, with_viscosity=True) + + ppb = 100 # Particles per box (controls resolution) + loading_params = LoadingParameters(ppb=ppb, loading="tesselation") + weights_params = WeightsParameters() + boundary_params = BoundaryParameters() + sorting_params = SortingParameters( + boxes_per_dim=(nx, 1, 1), + dims_mask=(True, False, False), + ) + + bin_plot = BinningPlot(slice="e1", n_bins=(32,), ranges=(0.0, 1.0)) + kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=1) + saving_params = SavingParameters( + binning_plots=(bin_plot,), + kernel_density_plots=(kd_plot,), + ) + + model.euler_fluid.set_markers( + loading_params=loading_params, + weights_params=weights_params, + boundary_params=boundary_params, + sorting_params=sorting_params, + saving_params=saving_params, + ) + + # propagator options + from struphy.ode.utils import ButcherTableau + + butcher = ButcherTableau(algo="forward_euler") + model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) + if model.with_B0: + model.propagators.push_vxb.options = model.propagators.push_vxb.Options() + model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options(kernel_type="gaussian_1d") + + # background, perturbations and initial conditions + background = equils.ConstantVelocity() + model.euler_fluid.var.add_background(background) + perturbation = perturbations.ModesSin(ls=(1,), amps=(1.0e-2,)) + model.euler_fluid.var.add_perturbation(del_n=perturbation) + + # instance of simulation + sim = Simulation( + model=model, + env=env, + time_opts=time_opts, + domain=domain, + grid=grid, + derham_opts=derham_opts, + ) + + # run + sim.run() + + # post processing + if MPI.COMM_WORLD.Get_rank() == 0: + sim.pproc() + + # diagnostics + sim.load_plotting_data() + + ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph + n_sph = sim.n_sph.euler_fluid.view_0.n_sph + + if do_plot: + dt = time_opts.dt + end_time = time_opts.Tend + Nt = int(end_time // dt) + x = ee1 * r1 + + plt.figure(figsize=(20, 40)) + + dt_plot = end_time / 10.0 + plot_ct = 0 + time = 0.0 + for i in range(Nt + 1): + time = dt*i + logger.info(f"{i =}, {time =:.4f}, {time // dt_plot =}, {plot_ct =}") + if time // dt_plot >= plot_ct: + plot_ct += 1 + plt.subplot(5, 2, plot_ct) + plt.plot(x.squeeze(), n_sph[i, :, 0, 0]) + plt.xlim(0, 2.5) + plt.grid(c="k") + plt.xlabel("x") + plt.ylabel(r"$\rho$") + plt.title(f"time={i * dt:4.2f}") + + plt.show() + + error = xp.max(xp.abs(n_sph[0] - n_sph[-1])) + logger.info(f"SPH sound wave {error =}.") + assert error < 6e-4 + logger.info("Assertion passed.") + + shutil.rmtree(test_folder) + + + if __name__ == "__main__": - test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) + # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) + test_damped_soundwave_1d(nx=12, plot_pts=11, do_plot=True) diff --git a/tutorials/tutorial_viscous_euler_sph.ipynb b/tutorials/tutorial_viscous_euler_sph.ipynb index c495cb91d..c6e8715a2 100644 --- a/tutorials/tutorial_viscous_euler_sph.ipynb +++ b/tutorials/tutorial_viscous_euler_sph.ipynb @@ -456,6 +456,233 @@ " except Exception as e:\n", " print(f\"Could not remove {test_folder}: {e}\")" ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "## 1D damped standing sound wave" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "from struphy import set_logging_level\n", + "set_logging_level(logging.DEBUG)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "# SPH parameters\n", + "ppb = 50 # Particles per box (controls resolution)\n", + "nx = 12 # Number of boxes in 1D (parametrizable: 12 or 24)\n", + "\n", + "# Domain\n", + "r1 = 2.5 # Domain extent in x\n", + "\n", + "# Sound speed and wave propagation time\n", + "c_s = 1.0 # Sound speed (isothermal)\n", + "Tend = 10.0 # Time for wave to traverse domain (≈ 1 round-trip)\n", + "\n", + "# Time stepping using Strang operator splitting (standard for SPH)\n", + "dt = 0.03125 # Timestep (stable for Strang)\n", + "split_algo = \"Strang\"\n", + "\n", + "print(\"SPH Configuration:\")\n", + "print(f\" Particles per box (ppb): {ppb}\")\n", + "print(f\" Number of boxes (nx): {nx}\")\n", + "print(f\" Domain extent: {r1}\")\n", + "print(f\" Sound speed (c_s): {c_s}\")\n", + "print(f\" Final time (Tend): {Tend}\")\n", + "print(f\" Timestep (dt): {dt}\")\n", + "print(f\" Total particles: {ppb * nx}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "# Model: SPH without viscosity or B-field\n", + "model2 = ViscousEulerSPH(with_B0=False, with_viscosity=True)\n", + "\n", + "# Propagator options with Strang splitting\n", + "butcher = ButcherTableau(algo=\"forward_euler\")\n", + "model2.propagators.push_eta.options = model2.propagators.push_eta.Options(butcher=butcher)\n", + "model2.propagators.push_sph_p.options = model2.propagators.push_sph_p.Options(kernel_type=\"gaussian_1d\")\n", + "\n", + "print(\"ViscousEulerSPH model configured (with viscosity, no B-field).\")\n", + "print(\"Propagators: push_eta (Butcher: forward_euler), push_sph_p (kernel: gaussian_1d)\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "# Domain: 1D periodic\n", + "domain = domains.Cuboid(r1=r1)\n", + "\n", + "# No grid or DerhamOptions for particle-based SPH\n", + "grid = None\n", + "derham_opts = None\n", + "\n", + "# Loading parameters: tessellation distributes particles uniformly\n", + "loading_params = LoadingParameters(ppb=ppb, loading=\"tesselation\")\n", + "weights_params = WeightsParameters()\n", + "boundary_params = BoundaryParameters()\n", + "\n", + "# Sorting: assign particles to boxes for spatial binning\n", + "sorting_params = SortingParameters(\n", + " boxes_per_dim=(nx, 1, 1), # 1D boxing\n", + " dims_mask=(True, False, False), # Only 1D binning active\n", + ")\n", + "\n", + "# Diagnostic plots\n", + "plot_pts = 32 # Number of evaluation points for kernel density plot\n", + "bin_plot = BinningPlot(slice=\"e1\", n_bins=(32,), ranges=(0.0, 1.0))\n", + "kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=1)\n", + "saving_params = SavingParameters(\n", + " binning_plots=(bin_plot,),\n", + " kernel_density_plots=(kd_plot,),\n", + ")\n", + "\n", + "# Set markers on the model\n", + "model2.euler_fluid.set_markers(\n", + " loading_params=loading_params,\n", + " weights_params=weights_params,\n", + " boundary_params=boundary_params,\n", + " sorting_params=sorting_params,\n", + " saving_params=saving_params,\n", + ")\n", + "\n", + "print(f\"Domain: 1D periodic, r1={r1}\")\n", + "print(f\"Particles initialized via tessellation: {ppb} ppb × {nx} boxes = {ppb*nx} particles\")\n", + "print(f\"Sorting: {nx} boxes in 1D, kernel density plots at {plot_pts} evaluation points\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27", + "metadata": {}, + "outputs": [], + "source": [ + "# Background: constant velocity (zero)\n", + "background = equils.ConstantVelocity()\n", + "model2.euler_fluid.var.add_background(background)\n", + "\n", + "# Perturbation: sine-wave density mode (mode l=1, amplitude 0.01)\n", + "perturbation = perturbations.ModesSin(ls=(1,), amps=(1.0e-2,))\n", + "model2.euler_fluid.var.add_perturbation(del_n=perturbation)\n", + "\n", + "print(\"Background: constant velocity (zero)\")\n", + "print(\"Perturbation: sine mode l=1, amplitude=0.01\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28", + "metadata": {}, + "outputs": [], + "source": [ + "# Environment and file management\n", + "test_folder = os.path.join(os.getcwd(), \"struphy_verification_tests\")\n", + "out_folders = os.path.join(test_folder, \"ViscousEulerSPH\")\n", + "env = EnvironmentOptions(out_folders=out_folders, sim_folder=\"damped_soundwave_1d\")\n", + "\n", + "# Time stepping\n", + "Tend = 10.0\n", + "\n", + "time_opts = Time(dt=dt, Tend=Tend, split_algo=split_algo)\n", + "\n", + "# Instantiate and run simulation\n", + "sim = Simulation(\n", + " model=model2,\n", + " env=env,\n", + " time_opts=time_opts,\n", + " domain=domain,\n", + " grid=grid,\n", + " derham_opts=derham_opts,\n", + ")\n", + "\n", + "print(f\"Running SPH sound wave simulation: dt={dt}, Tend={Tend}, algo={split_algo}\")\n", + "sim.run()\n", + "print(\"Simulation complete.\")\n", + "\n", + "# Post-processing\n", + "sim.pproc()\n", + "print(\"Post-processing complete.\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "29", + "metadata": {}, + "outputs": [], + "source": [ + "# Load plotting data\n", + "sim.load_plotting_data()\n", + "\n", + "# Extract particle positions and density\n", + "ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph\n", + "n_sph = sim.n_sph.euler_fluid.view_0.n_sph\n", + "\n", + "# Physical coordinates\n", + "x = ee1 * r1\n", + "\n", + "# Get number of time steps\n", + "dt_actual = time_opts.dt\n", + "Tend_actual = time_opts.Tend\n", + "Nt = int(Tend_actual // dt_actual)\n", + "\n", + "print(f\"Simulation completed {Nt} timesteps\")\n", + "print(f\"Particle positions shape: {x.shape}\")\n", + "print(f\"Density field shape (all times): {n_sph.shape}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30", + "metadata": {}, + "outputs": [], + "source": [ + "# Compare initial and final densities\n", + "n_initial = n_sph[0, :, 0, 0] # Density at t=0\n", + "n_final = n_sph[-1, :, 0, 0] # Density at t=Tend\n", + "\n", + "print(\"\\n=== SPH Sound Wave Verification ===\")\n", + "print(\"\\nInitial density:\")\n", + "print(f\" Min: {xp.min(n_initial):.6f}\")\n", + "print(f\" Max: {xp.max(n_initial):.6f}\")\n", + "print(f\" Mean: {xp.mean(n_initial):.6f}\")\n", + "\n", + "print(\"\\nFinal density:\")\n", + "print(f\" Min: {xp.min(n_final):.6f}\")\n", + "print(f\" Max: {xp.max(n_final):.6f}\")\n", + "print(f\" Mean: {xp.mean(n_final):.6f}\")\n", + "\n", + "# print(\"\\nRound-trip error:\")\n", + "# print(f\" ||ρ(Tend) - ρ(0)||_∞ = {error:.6e}\")\n", + "# print(f\" Error / Initial amplitude = {error / 0.01:.6e}\")" + ] } ], "metadata": { From 55028f0bb0fcaf728ba924c78d790420727e53ee Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Wed, 10 Jun 2026 13:46:55 +0200 Subject: [PATCH 03/34] improve dev reference on sph evaluation in doc --- doc/sections/dev_reference.rst | 43 +--- src/struphy/pic/base.py | 16 +- src/struphy/pic/sph_eval_kernels.py | 27 ++- tutorials/tutorial_viscous_euler_sph.ipynb | 227 --------------------- 4 files changed, 40 insertions(+), 273 deletions(-) diff --git a/doc/sections/dev_reference.rst b/doc/sections/dev_reference.rst index 4129a42ba..1cb474851 100644 --- a/doc/sections/dev_reference.rst +++ b/doc/sections/dev_reference.rst @@ -3,42 +3,11 @@ Developer reference =================== -FEEC ----- +.. toctree:: + :maxdepth: 2 + :caption: Contents: -Derham complex -^^^^^^^^^^^^^^ + subsections/dev-feec + subsections/dev-pic + subsections/dev-sph -.. automodule:: struphy.feec.psydac_derham - :members: - :special-members: - :show-inheritance: - :exclude-members: __init__ - -Grids -^^^^^ - -.. automodule:: struphy.topology.grids - :members: - :special-members: - :show-inheritance: - :exclude-members: __init__ - -Mass Operators (bilinear forms) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. automodule:: struphy.feec.mass - :members: - :special-members: - :show-inheritance: - :exclude-members: __init__ - - -Projection of spline functions -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. automodule:: struphy.feec.basis_projection_ops - :members: - :special-members: - :show-inheritance: - :exclude-members: __init__ \ No newline at end of file diff --git a/src/struphy/pic/base.py b/src/struphy/pic/base.py index bda144117..75d9a803f 100644 --- a/src/struphy/pic/base.py +++ b/src/struphy/pic/base.py @@ -4124,14 +4124,20 @@ def eval_sph( h2: float = 0.1, h3: float = 0.1, ): - r"""Perform an SPH evaluation of a function :math:`b: [0, 1]^3 \to \mathbb R` in the following sense: + r"""Perform a (meshgrid) SPH evaluation of a function :math:`\rho: [0, 1]^3 \to \mathbb R` in the following sense: .. math:: - b(\boldsymbol \eta_i) = \frac 1N \sum_k \beta_k W_h(\boldsymbol \eta_i - \boldsymbol \eta_k)\,. + \rho(\boldsymbol \eta_i) = \sum_{j=0}^{N-1} \rho_j\, W_h(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. - The coefficients :math:`\beta_k` must be stored at ``self.markers[k, index]``. - The possible choices for :math:`W_h` are listed in :mod:`~struphy.pic.sph_smoothing_kernels` + The coefficients :math:`\rho_j` must be available in the marker array, stored at some index ``self.markers[j, index]``. + In case that `derivative=k` where `k` is not zero, the `k`-th component of the gradient of :math:`\rho` is computed: + + .. math:: + + \textrm{derivative}=k:\qquad [\nabla \rho(\boldsymbol \eta_i)]_k = \sum_{j=0}^{N-1} \rho_j \frac{\partial W_h}{\partial \eta_k}(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. + + The possible choices for :math:`W_h` are listed in :ref:`smoothing_kernels` and in :meth:`~struphy.pic.base.Particles.ker_dct`. Parameters @@ -4140,7 +4146,7 @@ def eval_sph( Logical evaluation points. index : int - At which index of the markers array are located the coefficients :math:`\beta_k`. + At which index of the markers array are located the coefficients :math:`\rho_j`. out : array_like Output will be store in this array. A new array is created if not provided. diff --git a/src/struphy/pic/sph_eval_kernels.py b/src/struphy/pic/sph_eval_kernels.py index 4c63e0156..d7d03d71a 100644 --- a/src/struphy/pic/sph_eval_kernels.py +++ b/src/struphy/pic/sph_eval_kernels.py @@ -34,7 +34,12 @@ def naive_evaluation_kernel( h2: "float", h3: "float", ) -> float: - """Naive single-point sph evaluation. + """Naive single-point sph evaluation: + + .. math:: + + b(\boldsymbol \eta_i) = \frac 1N \sum_k \beta_k W_h(\boldsymbol \eta_i - \boldsymbol \eta_k)\,. + The sum is done over all particles in markers array. Parameters @@ -96,9 +101,23 @@ def boxed_based_kernel( h2: "float", h3: "float", ) -> float: - """Box-based single-point sph evaluation. - The sum is done over the particles that are in the 26 + 1 neighboring boxes - of the ``loc_box`` the evaluation point is in. + """Perform a single-point SPH evaluation of a function :math:`\rho: [0, 1]^3 \to \mathbb R` in the following sense: + + .. math:: + + \rho(\boldsymbol \eta_i) = \sum_{j=0}^{N-1} \rho_j\, W_h(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. + + The coefficients :math:`\rho_j` must be available in the marker array, stored at some index ``self.markers[j, index]``. + In case that `derivative=k` where `k` is not zero, the `k`-th component of the gradient of :math:`\rho` is computed: + + .. math:: + + \textrm{derivative}=k:\qquad [\nabla \rho(\boldsymbol \eta_i)]_k = \sum_{j=0}^{N-1} \rho_j \frac{\partial W_h}{\partial \eta_k}(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. + + The possible choices for :math:`W_h` are listed in :ref:`smoothing_kernels` + and in :meth:`~struphy.pic.base.Particles.ker_dct`. + + The sum is done over the particles that are in the 26 + 1 neighboring boxes of the ``loc_box`` the evaluation point is in. Parameters ---------- diff --git a/tutorials/tutorial_viscous_euler_sph.ipynb b/tutorials/tutorial_viscous_euler_sph.ipynb index c6e8715a2..c495cb91d 100644 --- a/tutorials/tutorial_viscous_euler_sph.ipynb +++ b/tutorials/tutorial_viscous_euler_sph.ipynb @@ -456,233 +456,6 @@ " except Exception as e:\n", " print(f\"Could not remove {test_folder}: {e}\")" ] - }, - { - "cell_type": "markdown", - "id": "22", - "metadata": {}, - "source": [ - "## 1D damped standing sound wave" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "23", - "metadata": {}, - "outputs": [], - "source": [ - "from struphy import set_logging_level\n", - "set_logging_level(logging.DEBUG)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "24", - "metadata": {}, - "outputs": [], - "source": [ - "# SPH parameters\n", - "ppb = 50 # Particles per box (controls resolution)\n", - "nx = 12 # Number of boxes in 1D (parametrizable: 12 or 24)\n", - "\n", - "# Domain\n", - "r1 = 2.5 # Domain extent in x\n", - "\n", - "# Sound speed and wave propagation time\n", - "c_s = 1.0 # Sound speed (isothermal)\n", - "Tend = 10.0 # Time for wave to traverse domain (≈ 1 round-trip)\n", - "\n", - "# Time stepping using Strang operator splitting (standard for SPH)\n", - "dt = 0.03125 # Timestep (stable for Strang)\n", - "split_algo = \"Strang\"\n", - "\n", - "print(\"SPH Configuration:\")\n", - "print(f\" Particles per box (ppb): {ppb}\")\n", - "print(f\" Number of boxes (nx): {nx}\")\n", - "print(f\" Domain extent: {r1}\")\n", - "print(f\" Sound speed (c_s): {c_s}\")\n", - "print(f\" Final time (Tend): {Tend}\")\n", - "print(f\" Timestep (dt): {dt}\")\n", - "print(f\" Total particles: {ppb * nx}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "25", - "metadata": {}, - "outputs": [], - "source": [ - "# Model: SPH without viscosity or B-field\n", - "model2 = ViscousEulerSPH(with_B0=False, with_viscosity=True)\n", - "\n", - "# Propagator options with Strang splitting\n", - "butcher = ButcherTableau(algo=\"forward_euler\")\n", - "model2.propagators.push_eta.options = model2.propagators.push_eta.Options(butcher=butcher)\n", - "model2.propagators.push_sph_p.options = model2.propagators.push_sph_p.Options(kernel_type=\"gaussian_1d\")\n", - "\n", - "print(\"ViscousEulerSPH model configured (with viscosity, no B-field).\")\n", - "print(\"Propagators: push_eta (Butcher: forward_euler), push_sph_p (kernel: gaussian_1d)\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "26", - "metadata": {}, - "outputs": [], - "source": [ - "# Domain: 1D periodic\n", - "domain = domains.Cuboid(r1=r1)\n", - "\n", - "# No grid or DerhamOptions for particle-based SPH\n", - "grid = None\n", - "derham_opts = None\n", - "\n", - "# Loading parameters: tessellation distributes particles uniformly\n", - "loading_params = LoadingParameters(ppb=ppb, loading=\"tesselation\")\n", - "weights_params = WeightsParameters()\n", - "boundary_params = BoundaryParameters()\n", - "\n", - "# Sorting: assign particles to boxes for spatial binning\n", - "sorting_params = SortingParameters(\n", - " boxes_per_dim=(nx, 1, 1), # 1D boxing\n", - " dims_mask=(True, False, False), # Only 1D binning active\n", - ")\n", - "\n", - "# Diagnostic plots\n", - "plot_pts = 32 # Number of evaluation points for kernel density plot\n", - "bin_plot = BinningPlot(slice=\"e1\", n_bins=(32,), ranges=(0.0, 1.0))\n", - "kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=1)\n", - "saving_params = SavingParameters(\n", - " binning_plots=(bin_plot,),\n", - " kernel_density_plots=(kd_plot,),\n", - ")\n", - "\n", - "# Set markers on the model\n", - "model2.euler_fluid.set_markers(\n", - " loading_params=loading_params,\n", - " weights_params=weights_params,\n", - " boundary_params=boundary_params,\n", - " sorting_params=sorting_params,\n", - " saving_params=saving_params,\n", - ")\n", - "\n", - "print(f\"Domain: 1D periodic, r1={r1}\")\n", - "print(f\"Particles initialized via tessellation: {ppb} ppb × {nx} boxes = {ppb*nx} particles\")\n", - "print(f\"Sorting: {nx} boxes in 1D, kernel density plots at {plot_pts} evaluation points\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "27", - "metadata": {}, - "outputs": [], - "source": [ - "# Background: constant velocity (zero)\n", - "background = equils.ConstantVelocity()\n", - "model2.euler_fluid.var.add_background(background)\n", - "\n", - "# Perturbation: sine-wave density mode (mode l=1, amplitude 0.01)\n", - "perturbation = perturbations.ModesSin(ls=(1,), amps=(1.0e-2,))\n", - "model2.euler_fluid.var.add_perturbation(del_n=perturbation)\n", - "\n", - "print(\"Background: constant velocity (zero)\")\n", - "print(\"Perturbation: sine mode l=1, amplitude=0.01\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "28", - "metadata": {}, - "outputs": [], - "source": [ - "# Environment and file management\n", - "test_folder = os.path.join(os.getcwd(), \"struphy_verification_tests\")\n", - "out_folders = os.path.join(test_folder, \"ViscousEulerSPH\")\n", - "env = EnvironmentOptions(out_folders=out_folders, sim_folder=\"damped_soundwave_1d\")\n", - "\n", - "# Time stepping\n", - "Tend = 10.0\n", - "\n", - "time_opts = Time(dt=dt, Tend=Tend, split_algo=split_algo)\n", - "\n", - "# Instantiate and run simulation\n", - "sim = Simulation(\n", - " model=model2,\n", - " env=env,\n", - " time_opts=time_opts,\n", - " domain=domain,\n", - " grid=grid,\n", - " derham_opts=derham_opts,\n", - ")\n", - "\n", - "print(f\"Running SPH sound wave simulation: dt={dt}, Tend={Tend}, algo={split_algo}\")\n", - "sim.run()\n", - "print(\"Simulation complete.\")\n", - "\n", - "# Post-processing\n", - "sim.pproc()\n", - "print(\"Post-processing complete.\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "29", - "metadata": {}, - "outputs": [], - "source": [ - "# Load plotting data\n", - "sim.load_plotting_data()\n", - "\n", - "# Extract particle positions and density\n", - "ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph\n", - "n_sph = sim.n_sph.euler_fluid.view_0.n_sph\n", - "\n", - "# Physical coordinates\n", - "x = ee1 * r1\n", - "\n", - "# Get number of time steps\n", - "dt_actual = time_opts.dt\n", - "Tend_actual = time_opts.Tend\n", - "Nt = int(Tend_actual // dt_actual)\n", - "\n", - "print(f\"Simulation completed {Nt} timesteps\")\n", - "print(f\"Particle positions shape: {x.shape}\")\n", - "print(f\"Density field shape (all times): {n_sph.shape}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "30", - "metadata": {}, - "outputs": [], - "source": [ - "# Compare initial and final densities\n", - "n_initial = n_sph[0, :, 0, 0] # Density at t=0\n", - "n_final = n_sph[-1, :, 0, 0] # Density at t=Tend\n", - "\n", - "print(\"\\n=== SPH Sound Wave Verification ===\")\n", - "print(\"\\nInitial density:\")\n", - "print(f\" Min: {xp.min(n_initial):.6f}\")\n", - "print(f\" Max: {xp.max(n_initial):.6f}\")\n", - "print(f\" Mean: {xp.mean(n_initial):.6f}\")\n", - "\n", - "print(\"\\nFinal density:\")\n", - "print(f\" Min: {xp.min(n_final):.6f}\")\n", - "print(f\" Max: {xp.max(n_final):.6f}\")\n", - "print(f\" Mean: {xp.mean(n_final):.6f}\")\n", - "\n", - "# print(\"\\nRound-trip error:\")\n", - "# print(f\" ||ρ(Tend) - ρ(0)||_∞ = {error:.6e}\")\n", - "# print(f\" Error / Initial amplitude = {error / 0.01:.6e}\")" - ] } ], "metadata": { From 923e90aa833b0f49f3d51a4254d5d6886422325f Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Wed, 10 Jun 2026 14:48:00 +0200 Subject: [PATCH 04/34] rename boxed_based to box_based --- src/struphy/pic/pushing/eval_kernels_gc.py | 16 +- src/struphy/pic/pushing/pusher_kernels.py | 26 +-- src/struphy/pic/sph_eval_kernels.py | 234 +++++++++++---------- 3 files changed, 147 insertions(+), 129 deletions(-) diff --git a/src/struphy/pic/pushing/eval_kernels_gc.py b/src/struphy/pic/pushing/eval_kernels_gc.py index 49b9851b2..93a30d148 100644 --- a/src/struphy/pic/pushing/eval_kernels_gc.py +++ b/src/struphy/pic/pushing/eval_kernels_gc.py @@ -529,7 +529,7 @@ def sph_pressure_coeffs( eta2 = markers[ip, 1] eta3 = markers[ip, 2] loc_box = int(markers[ip, n_cols - 2]) - n_at_eta = sph_eval_kernels.boxed_based_kernel( + n_at_eta = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -662,7 +662,7 @@ def sph_mean_velocity_coeffs( eta2 = markers[ip, 1] eta3 = markers[ip, 2] loc_box = int(markers[ip, n_cols - 2]) - n_at_eta = sph_eval_kernels.boxed_based_kernel( + n_at_eta = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -752,7 +752,7 @@ def sph_mean_velocity( eta2 = markers[ip, 1] eta3 = markers[ip, 2] loc_box = int(markers[ip, n_cols - 2]) - v1_at_eta = sph_eval_kernels.boxed_based_kernel( + v1_at_eta = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -771,7 +771,7 @@ def sph_mean_velocity( h3, ) - v2_at_eta = sph_eval_kernels.boxed_based_kernel( + v2_at_eta = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -790,7 +790,7 @@ def sph_mean_velocity( h3, ) - v3_at_eta = sph_eval_kernels.boxed_based_kernel( + v3_at_eta = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -879,7 +879,7 @@ def sph_grad_mean_velocity( loc_box = int(markers[ip, n_cols - 2]) for j in range(3): for k in range(3): - grad_v_at_eta[j, k] = sph_eval_kernels.boxed_based_kernel( + grad_v_at_eta[j, k] = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -967,7 +967,7 @@ def sph_viscosity_tensor( eta2 = markers[ip, 1] eta3 = markers[ip, 2] loc_box = int(markers[ip, n_cols - 2]) - n_at_eta = sph_eval_kernels.boxed_based_kernel( + n_at_eta = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -988,7 +988,7 @@ def sph_viscosity_tensor( weight = markers[ip, weight_idx] for j in range(3): for k in range(3): - grad_v_at_eta[j, k] = sph_eval_kernels.boxed_based_kernel( + grad_v_at_eta[j, k] = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, diff --git a/src/struphy/pic/pushing/pusher_kernels.py b/src/struphy/pic/pushing/pusher_kernels.py index 07ab6d8ee..91d8f02da 100644 --- a/src/struphy/pic/pushing/pusher_kernels.py +++ b/src/struphy/pic/pushing/pusher_kernels.py @@ -2663,7 +2663,7 @@ def push_v_sph_pressure( loc_box = int(markers[ip, n_cols - 2]) # first component - grad_u[0] = sph_eval_kernels.boxed_based_kernel( + grad_u[0] = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -2683,7 +2683,7 @@ def push_v_sph_pressure( ) grad_u[0] *= kappa / n_at_eta - sum2 = sph_eval_kernels.boxed_based_kernel( + sum2 = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -2706,7 +2706,7 @@ def push_v_sph_pressure( if kernel_type >= 340: # second component - grad_u[1] = sph_eval_kernels.boxed_based_kernel( + grad_u[1] = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -2726,7 +2726,7 @@ def push_v_sph_pressure( ) grad_u[1] *= kappa / n_at_eta - sum4 = sph_eval_kernels.boxed_based_kernel( + sum4 = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -2749,7 +2749,7 @@ def push_v_sph_pressure( if kernel_type >= 670: # third component - grad_u[2] = sph_eval_kernels.boxed_based_kernel( + grad_u[2] = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -2769,7 +2769,7 @@ def push_v_sph_pressure( ) grad_u[2] *= kappa / n_at_eta - sum6 = sph_eval_kernels.boxed_based_kernel( + sum6 = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -2896,7 +2896,7 @@ def push_v_sph_pressure_ideal_gas( loc_box = int(markers[ip, n_cols - 2]) # first component - grad_u[0] = sph_eval_kernels.boxed_based_kernel( + grad_u[0] = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -2916,7 +2916,7 @@ def push_v_sph_pressure_ideal_gas( ) grad_u[0] *= kappa * n_at_eta ** (gamma - 2) - sum2 = sph_eval_kernels.boxed_based_kernel( + sum2 = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -2939,7 +2939,7 @@ def push_v_sph_pressure_ideal_gas( if kernel_type >= 340: # second component - grad_u[1] = sph_eval_kernels.boxed_based_kernel( + grad_u[1] = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -2959,7 +2959,7 @@ def push_v_sph_pressure_ideal_gas( ) grad_u[1] *= kappa * (n_at_eta) ** (gamma - 2) - sum4 = sph_eval_kernels.boxed_based_kernel( + sum4 = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -2982,7 +2982,7 @@ def push_v_sph_pressure_ideal_gas( if kernel_type >= 670: # third component - grad_u[2] = sph_eval_kernels.boxed_based_kernel( + grad_u[2] = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -3002,7 +3002,7 @@ def push_v_sph_pressure_ideal_gas( ) grad_u[2] *= kappa * (n_at_eta) ** (gamma - 2) - sum6 = sph_eval_kernels.boxed_based_kernel( + sum6 = sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, @@ -3144,7 +3144,7 @@ def push_v_viscosity( # use_component = False # if use_component: - f_visc[j] += sph_eval_kernels.boxed_based_kernel( + f_visc[j] += sph_eval_kernels.box_based_kernel( args_markers, eta1, eta2, diff --git a/src/struphy/pic/sph_eval_kernels.py b/src/struphy/pic/sph_eval_kernels.py index d7d03d71a..6d2d298ea 100644 --- a/src/struphy/pic/sph_eval_kernels.py +++ b/src/struphy/pic/sph_eval_kernels.py @@ -4,7 +4,22 @@ def distance(x: "float", y: "float", periodic: "bool") -> float: - """Return the one dimensional distance of x and y taking in account the periodicity on [0,1].""" + r"""Return the signed one-dimensional distance ``x - y``, adjusted for periodicity on ``[0, 1]``. + + Parameters + ---------- + x, y : float + Two coordinates on the domain. + + periodic : bool + If ``True``, the result is folded into :math:`(-\tfrac{1}{2}, \tfrac{1}{2}]` so that + the shortest path across the periodic boundary is returned. + + Returns + ------- + float + Signed distance ``x - y``, adjusted for periodicity. + """ d = x - y if periodic: if d > 0.5: @@ -34,39 +49,52 @@ def naive_evaluation_kernel( h2: "float", h3: "float", ) -> float: - """Naive single-point sph evaluation: + r"""Perform a single-point SPH evaluation of a function :math:`\rho: [0, 1]^3 \to \mathbb R` in the following sense: + + .. math:: + + \rho(\boldsymbol \eta_i) = \sum_{j=0}^{N-1} \rho_j\, W_h(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. + + The coefficients :math:`\rho_j` must be available in the marker array, stored at some index ``self.markers[j, index]``. + In case that `derivative=k` where `k` is not zero, the `k`-th component of the gradient of :math:`\rho` is computed: .. math:: - b(\boldsymbol \eta_i) = \frac 1N \sum_k \beta_k W_h(\boldsymbol \eta_i - \boldsymbol \eta_k)\,. + \textrm{derivative}=k:\qquad [\nabla \rho(\boldsymbol \eta_i)]_k = \sum_{j=0}^{N-1} \rho_j \frac{\partial W_h}{\partial \eta_k}(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. - The sum is done over all particles in markers array. + The possible choices for :math:`W_h` are listed in :ref:`smoothing_kernels` + and in :meth:`~struphy.pic.base.Particles.ker_dct`. + + ATTENTION: The sum is done over all particles in the markers array (ignoring holes), no neighbour search is performed. + Hence, the cost of this evaluation is :math:`\mathcal{O}(N)` in the number of particles, and it should only be used for testing and verification purposes. Parameters ---------- + args_markers : MarkerArguments + Container holding the markers array and the total number of particles ``Np``. + eta1, eta2, eta3 : float Evaluation point in logical space. - markers : array[float] - Markers array. - - Np : int - Total number of particles. - - holes : bool - 1D array of length markers.shape[0]. True if markers[i] is a hole. + holes : bool[:] + 1D array of length ``markers.shape[0]``. ``True`` if particle ``i`` is a hole (inactive). periodic1, periodic2, periodic3 : bool - True if periodic in that dimension. + ``True`` if the domain is periodic in that dimension. index : int - Column index in markers array where the value multiplying the kernel in the evaluation is stored. + Column index in the markers array of the coefficient :math:`\beta_k` multiplying the kernel. - kernel_type : str - Name of the smoothing kernel. + kernel_type : int + Integer identifier of the smoothing kernel. See :ref:`smoothing_kernels`. h1, h2, h3 : float - Kernel width in respective dimension. + Kernel width in the respective dimension. + + Returns + ------- + float + SPH estimate of :math:`b` at the evaluation point. """ markers = args_markers.markers @@ -83,7 +111,7 @@ def naive_evaluation_kernel( return out / Np -def boxed_based_kernel( +def box_based_kernel( args_markers: "MarkerArguments", eta1: "float", eta2: "float", @@ -101,7 +129,7 @@ def boxed_based_kernel( h2: "float", h3: "float", ) -> float: - """Perform a single-point SPH evaluation of a function :math:`\rho: [0, 1]^3 \to \mathbb R` in the following sense: + r"""Perform a single-point SPH evaluation of a function :math:`\rho: [0, 1]^3 \to \mathbb R` in the following sense: .. math:: @@ -117,7 +145,9 @@ def boxed_based_kernel( The possible choices for :math:`W_h` are listed in :ref:`smoothing_kernels` and in :meth:`~struphy.pic.base.Particles.ker_dct`. - The sum is done over the particles that are in the 26 + 1 neighboring boxes of the ``loc_box`` the evaluation point is in. + The sum is restricted to the 27 neighbouring boxes of the box containing + :math:`\boldsymbol\eta_i`, making the cost :math:`\mathcal{O}(1)` in the number + of particles when the kernel support is proportional to the box size. Parameters ---------- @@ -193,37 +223,34 @@ def naive_evaluation_flat( h3: "float", out: "float[:]", ): - """Naive flat sph evaluation. - The sum is done over all particles in markers array. + r"""Naive SPH evaluation on a flat array of points, see :func:`~struphy.pic.sph_eval_kernels.naive_evaluation_kernel`. Parameters ---------- - eta1, eta2, eta3 : array[float] - Evaluation points in logical space for flat evaluation at (eta1[i], eta2[i], eta3[i]). - - markers : array[float] - Markers array. + args_markers : MarkerArguments + Container holding the markers array and the total number of particles ``Np``. - Np : int - Total number of particles. + eta1, eta2, eta3 : float[:] + Evaluation points in logical space. The :math:`i`-th point is + ``(eta1[i], eta2[i], eta3[i])``. - holes : bool - 1D array of length markers.shape[0]. True if markers[i] is a hole. + holes : bool[:] + 1D array of length ``markers.shape[0]``. ``True`` if particle ``i`` is a hole (inactive). periodic1, periodic2, periodic3 : bool - True if periodic in that dimension. + ``True`` if the domain is periodic in that dimension. index : int - Column index in markers array where the value multiplying the kernel in the evaluation is stored. + Column index in the markers array of the coefficient :math:`\beta_k` multiplying the kernel. kernel_type : int - Number of the smoothing kernel. + Integer identifier of the smoothing kernel. See :ref:`smoothing_kernels`. h1, h2, h3 : float - Kernel width in respective dimension. + Kernel width in the respective dimension. - out : array[float] - Output array of same size as eta1, eta2, eta3. + out : float[:] + Output array of the same length as ``eta1``. Modified in place and also returned. """ markers = args_markers.markers @@ -269,37 +296,33 @@ def naive_evaluation_meshgrid( h3: "float", out: "float[:,:,:]", ): - """Naive meshgrid sph evaluation. - The sum is done over all particles in markers array. + r"""Naive SPH evaluation on a 3-D meshgrid of points, see :func:`~struphy.pic.sph_eval_kernels.naive_evaluation_kernel`. Parameters ---------- - eta1, eta2, eta3 : array[float] - Evaluation points in logical space for meshgrid evaluation at (eta1[i,j,k], eta2[i,j,k], eta3[i,j,k]). + args_markers : MarkerArguments + Container holding the markers array and the total number of particles ``Np``. - markers : array[float] - Markers array. + eta1, eta2, eta3 : float[:,:,:] + Evaluation points in logical space on a 3-D meshgrid. - Np : int - Total number of particles. - - holes : bool - 1D array of length markers.shape[0]. True if markers[i] is a hole. + holes : bool[:] + 1D array of length ``markers.shape[0]``. ``True`` if particle ``i`` is a hole (inactive). periodic1, periodic2, periodic3 : bool - True if periodic in that dimension. + ``True`` if the domain is periodic in that dimension. index : int - Column index in markers array where the value multiplying the kernel in the evaluation is stored. + Column index in the markers array of the coefficient :math:`\beta_k` multiplying the kernel. kernel_type : int - Number of the smoothing kernel. + Integer identifier of the smoothing kernel. See :ref:`smoothing_kernels`. h1, h2, h3 : float - Kernel width in respective dimension. + Kernel width in the respective dimension. - out : array[float] - Output array of same size as eta1, eta2, eta3. + out : float[:,:,:] + Output array of the same shape as ``eta1``. Modified in place. """ markers = args_markers.markers @@ -357,50 +380,48 @@ def box_based_evaluation_flat( h3: "float", out: "float[:]", ): - """Box-based flat sph evaluation. - The sum is done over the particles that are in the 26 + 1 neighboring boxes - of the ``loc_box`` the evaluation point is in. + r"""Box-based SPH evaluation on a flat array of points, see :func:`~struphy.pic.sph_eval_kernels.box_based_kernel`. Parameters ---------- - eta1, eta2, eta3 : array[float] - Evaluation points in logical space for flat evaluation at (eta1[i], eta2[i], eta3[i]). - - n1, n2, n3 : int - Number of boxes in each dimension. + args_markers : MarkerArguments + Container holding the markers array and the total number of particles ``Np``. - domain_array : array - Information of the domain on the current mpi process. + eta1, eta2, eta3 : float[:] + Evaluation points in logical space. The :math:`i`-th point is + ``(eta1[i], eta2[i], eta3[i])``. - boxes : 2d array - Box array of the sorting boxes structure. + n1, n2, n3 : int + Number of sorting boxes in each dimension. - neighbours : 2d array - Array containing the 27 neighbouring boxes of each box. + domain_array : float[:] + Flat description of the local MPI sub-domain, used by + :func:`~struphy.pic.sorting_kernels.find_box` to locate boxes. - markers : array[float] - Markers array. + boxes : int[:,:] + Box array of the sorting-box structure (particles sorted into boxes). - Np : int - Total number of particles. + neighbours : int[:,:] + ``neighbours[b, :]`` lists the 27 box indices neighbouring box ``b``. - holes : bool - 1D array of length markers.shape[0]. True if markers[i] is a hole. + holes : bool[:] + 1D array of length ``markers.shape[0]``. ``True`` if particle ``i`` is a hole (inactive). periodic1, periodic2, periodic3 : bool - True if periodic in that dimension. + ``True`` if the domain is periodic in that dimension. index : int - Column index in markers array where the value multiplying the kernel in the evaluation is stored. + Column index in the markers array of the coefficient :math:`\beta_k` multiplying the kernel. kernel_type : int - Number of the smoothing kernel. + Integer identifier of the smoothing kernel. See :ref:`smoothing_kernels`. h1, h2, h3 : float - Kernel width in respective dimension. + Kernel width in the respective dimension. - out : array[float] - Output array of same size as eta1, eta2, eta3. + out : float[:] + Output array of the same length as ``eta1``. Modified in place. + Points outside the local domain are left at zero. """ markers = args_markers.markers @@ -424,7 +445,7 @@ def box_based_evaluation_flat( if loc_box == -1: continue else: - out[i] = boxed_based_kernel( + out[i] = box_based_kernel( args_markers, e1, e2, @@ -466,50 +487,47 @@ def box_based_evaluation_meshgrid( h3: "float", out: "float[:,:,:]", ): - """Box-based meshgrid sph evaluation. - The sum is done over the particles that are in the 26 + 1 neighboring boxes - of the ``loc_box`` the evaluation point is in. + r"""Box-based SPH evaluation on a 3-D meshgrid of points, see :func:`~struphy.pic.sph_eval_kernels.box_based_kernel`. Parameters ---------- - eta1, eta2, eta3 : array[float] - Evaluation points in logical space for meshgrid evaluation at (eta1[i,j,k], eta2[i,j,k], eta3[i,j,k]). + args_markers : MarkerArguments + Container holding the markers array and the total number of particles ``Np``. + + eta1, eta2, eta3 : float[:,:,:] + Evaluation points in logical space on a 3-D meshgrid. n1, n2, n3 : int - Number of boxes in each dimension. + Number of sorting boxes in each dimension. - domain_array : array - Information of the domain on the current mpi process. + domain_array : float[:] + Flat description of the local MPI sub-domain, used by + :func:`~struphy.pic.sorting_kernels.find_box` to locate boxes. - boxes : 2d array - Box array of the sorting boxes structure. + boxes : int[:,:] + Box array of the sorting-box structure (particles sorted into boxes). - neighbours : 2d array - Array containing the 27 neighbouring boxes of each box. + neighbours : int[:,:] + ``neighbours[b, :]`` lists the 27 box indices neighbouring box ``b``. - markers : array[float] - Markers array. - - Np : int - Total number of particles. - - holes : bool - 1D array of length markers.shape[0]. True if markers[i] is a hole. + holes : bool[:] + 1D array of length ``markers.shape[0]``. ``True`` if particle ``i`` is a hole (inactive). periodic1, periodic2, periodic3 : bool - True if periodic in that dimension. + ``True`` if the domain is periodic in that dimension. index : int - Column index in markers array where the value multiplying the kernel in the evaluation is stored. + Column index in the markers array of the coefficient :math:`\beta_k` multiplying the kernel. kernel_type : int - Number of the smoothing kernel. + Integer identifier of the smoothing kernel. See :ref:`smoothing_kernels`. h1, h2, h3 : float - Kernel width in respective dimension. + Kernel width in the respective dimension. - out : array[float] - Output array of same size as eta1, eta2, eta3. + out : float[:,:,:] + Output array of the same shape as ``eta1``. Modified in place. + Points outside the local domain are left at zero. """ markers = args_markers.markers @@ -549,7 +567,7 @@ def box_based_evaluation_meshgrid( if loc_box == -1: continue else: - out[i, j, k] = boxed_based_kernel( + out[i, j, k] = box_based_kernel( args_markers, e1, e2, From cceac63c1b9142d20f2e19e2758612ba4ed93837 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Wed, 10 Jun 2026 15:28:09 +0200 Subject: [PATCH 05/34] new dev tutorial on sph evaluation --- doc/sections/dev_guide.rst | 15 +- doc/sections/tutorials.rst | 28 +- tutorials/dev_tutorial_sph_eval_kernels.ipynb | 658 ++++++++++++++++++ 3 files changed, 690 insertions(+), 11 deletions(-) create mode 100644 tutorials/dev_tutorial_sph_eval_kernels.ipynb diff --git a/doc/sections/dev_guide.rst b/doc/sections/dev_guide.rst index 66523dae5..f97874a9c 100644 --- a/doc/sections/dev_guide.rst +++ b/doc/sections/dev_guide.rst @@ -6,10 +6,6 @@ Developer's guide This is a collection of tutorials for developers. They are meant to be a practical guide to how to use Struphy for development purposes, such as implementing new features, propagators or models. -**Getting started with FEEC**: Begin with the :ref:`FEEC basics tutorial ` for an introduction to setting up the discrete de Rham complex, creating callable spline functions, and working with differential operators. - -**Next FEEC steps**: Continue with the :ref:`FEEC boundary-conditions tutorial ` and then the :ref:`FEEC data-structures tutorial ` to understand how ``StencilVector`` and ``StencilMatrix`` are used in practice. - The tutorials give an overview of the most important classes used in writing new Propagators. These include classes for FEEC (finite element exterior calculus), particles and the coupling between them. @@ -21,8 +17,15 @@ It is recommended to use the same Python environment as for Struphy, e.g., by in .. toctree:: :maxdepth: 1 - :caption: Notebook tutorials: + :caption: FEEC tutorials: ../_collections/tutorials/dev_tutorial_feec_basics ../_collections/tutorials/dev_tutorial_feec_bcs - ../_collections/tutorials/dev_tutorial_data_structs \ No newline at end of file + ../_collections/tutorials/dev_tutorial_data_structs + + +.. toctree:: + :maxdepth: 1 + :caption: SPH tutorials: + + ../_collections/tutorials/dev_tutorial_sph_eval_kernels \ No newline at end of file diff --git a/doc/sections/tutorials.rst b/doc/sections/tutorials.rst index 7225807d3..769b1cfe5 100644 --- a/doc/sections/tutorials.rst +++ b/doc/sections/tutorials.rst @@ -11,15 +11,33 @@ It is recommended to use the same Python environment as for Struphy, e.g., by in .. toctree:: :maxdepth: 1 - :caption: Notebook tutorials: + :caption: Pure FEEC models: ../_collections/tutorials/tutorial_poisson ../_collections/tutorials/tutorial_maxwell - ../_collections/tutorials/linear_mhd_slab_waves + ../_collections/tutorials/tutorial_linear_mhd_slab_waves_1d + + +.. toctree:: + :maxdepth: 1 + :caption: PIC models: + ../_collections/tutorials/tutorial_vlasov_ampere_one_species_weak_landau ../_collections/tutorials/tutorial_particle_tracing - ../_collections/tutorials/tutorial_domains - ../_collections/tutorials/tutorial_mhd_equilibria + + +.. toctree:: + :maxdepth: 1 + :caption: SPH models: + ../_collections/tutorials/tutorial_viscous_euler_sph ../_collections/tutorials/tutorial_beltrami_sph - ../_collections/tutorials/tutorial_gas_expansion_sph \ No newline at end of file + ../_collections/tutorials/tutorial_gas_expansion_sph + + +.. toctree:: + :maxdepth: 1 + :caption: Geometry and equilibria: + + ../_collections/tutorials/tutorial_domains + ../_collections/tutorials/tutorial_mhd_equilibria diff --git a/tutorials/dev_tutorial_sph_eval_kernels.ipynb b/tutorials/dev_tutorial_sph_eval_kernels.ipynb new file mode 100644 index 000000000..edc5d7726 --- /dev/null +++ b/tutorials/dev_tutorial_sph_eval_kernels.ipynb @@ -0,0 +1,658 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# SPH Evaluation Kernels\n", + "\n", + "This tutorial shows how to use the SPH (Smoothed Particle Hydrodynamics) evaluation machinery in Struphy to reconstruct a smooth field from particle data, and to verify correctness by comparing against known exact solutions.\n", + "\n", + "The core idea: given $N$ particles with positions $\\boldsymbol{\\eta}_j \\in [0,1]^3$ and scalar weights $\\rho_j$, the SPH estimate of $\\rho$ at an arbitrary point $\\boldsymbol{\\eta}$ is\n", + "\n", + "$$\n", + "\\rho(\\boldsymbol{\\eta}) \\approx \\sum_{j=0}^{N-1} \\rho_j\\, W_h(\\boldsymbol{\\eta} - \\boldsymbol{\\eta}_j)\\,,\n", + "$$\n", + "\n", + "where $W_h$ is a smoothing kernel with bandwidth $h$. The gradient can be evaluated by differentiating the kernel:\n", + "\n", + "$$\n", + "\\frac{\\partial \\rho}{\\partial \\eta_k}(\\boldsymbol{\\eta}) \\approx \\sum_{j=0}^{N-1} \\rho_j\\,\n", + "\\frac{\\partial W_h}{\\partial \\eta_k}(\\boldsymbol{\\eta} - \\boldsymbol{\\eta}_j)\\,.\n", + "$$\n", + "\n", + "**What this tutorial covers**\n", + "\n", + "1. 1-D density reconstruction — value and first derivative, periodic and non-periodic boundary conditions, three kernel families.\n", + "2. 2-D density reconstruction — the same workflow on a meshgrid, with a colour-plot comparison against the exact field.\n", + "\n", + "The evaluation is triggered via `ParticlesSPH.eval_density`, which internally dispatches to the box-based kernel in `struphy.pic.sph_eval_kernels` (an $\\mathcal{O}(1)$-per-point algorithm that restricts the kernel sum to the 27 neighbouring sorting boxes)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from struphy import (\n", + " BoundaryParameters,\n", + " LoadingParameters,\n", + " SortingParameters,\n", + " domains,\n", + " perturbations,\n", + ")\n", + "from struphy.fields_background.equils import ConstantVelocity\n", + "from struphy.pic.particles import ParticlesSPH" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Part 1 — 1-D density reconstruction\n", + "\n", + "### Problem setup\n", + "\n", + "We want to reconstruct the density field\n", + "\n", + "$$\n", + "\\rho(\\eta_1) = 1.5 + \\cos(2\\pi\\eta_1), \\qquad \\eta_1 \\in [0, 1]\\,,\n", + "$$\n", + "\n", + "from $N$ particles whose weights $\\rho_j$ are set by this exact function.\n", + "\n", + "The domain is a 3-D cuboid — the $\\eta_2, \\eta_3$ directions are kept trivial (`l2=r2`, `l3=r3` effectively, all action is in $\\eta_1$). We use a **tesselation** loading strategy (particles placed on a regular lattice) so the reconstruction error is controlled by the lattice spacing rather than Monte Carlo noise." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Cuboid domain: non-unit physical size, but the SPH evaluation is in logical space [0,1]^3\n", + "domain_1d = domains.Cuboid(l1=1.0, r1=2.0, l2=10.0, r2=20.0, l3=100.0, r3=200.0)\n", + "\n", + "# Background equilibrium: constant density n0 = 1.5\n", + "background_1d = ConstantVelocity(n=1.5, density_profile=\"constant\")\n", + "background_1d.domain = domain_1d\n", + "\n", + "# Density perturbation: rho = n0 + cos(2 pi eta1)\n", + "pert_1d = {\"n\": perturbations.ModesCos(ls=(1,), amps=(1.0,))}\n", + "\n", + "# Exact field and its eta1-derivative\n", + "rho_exact = lambda e1, e2, e3: 1.5 + np.cos(2 * np.pi * e1)\n", + "drho_exact = lambda e1, e2, e3: -2 * np.pi * np.sin(2 * np.pi * e1)\n", + "\n", + "# Sorting: 24 boxes in eta1, 1 in eta2 / eta3 — purely 1-D\n", + "boxes_per_dim = (24, 1, 1)\n", + "sorting_params = SortingParameters(boxes_per_dim=boxes_per_dim)\n", + "\n", + "# Kernel bandwidth = one box width in each dimension\n", + "h1 = 1 / boxes_per_dim[0]\n", + "h2 = 1 / boxes_per_dim[1]\n", + "h3 = 1 / boxes_per_dim[2]\n", + "\n", + "# Evaluation grid (purely 1-D meshgrid)\n", + "n_eval = 200\n", + "eta1_pts = np.linspace(0, 1, n_eval)\n", + "eta2_pts = np.array([0.0])\n", + "eta3_pts = np.array([0.0])\n", + "ee1, ee2, ee3 = np.meshgrid(eta1_pts, eta2_pts, eta3_pts, indexing=\"ij\")\n", + "\n", + "print(f\"Domain logical size: [0,1]^3 | Sorting: {boxes_per_dim}\")\n", + "print(f\"Kernel bandwidth h1={h1:.4f}, h2={h2:.4f}, h3={h3:.4f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "### 1.1 Periodic boundary condition\n", + "\n", + "With a periodic BC the boundary layer receives contributions from particles on the opposite side of the domain. The mirror images are handled automatically inside the distance kernel." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "def make_particles_1d(bc_x, ppb=4, loading=\"tesselation\"):\n", + " \"\"\"Construct and initialise a 1-D ParticlesSPH object.\"\"\"\n", + " loading_params = LoadingParameters(ppb=ppb, seed=1607, loading=loading)\n", + " boundary_params = BoundaryParameters(bc_sph=(bc_x, \"periodic\", \"periodic\"))\n", + " particles = ParticlesSPH(\n", + " comm_world=None,\n", + " loading_params=loading_params,\n", + " boundary_params=boundary_params,\n", + " sorting_params=sorting_params,\n", + " bufsize=1.0,\n", + " domain=domain_1d,\n", + " background=background_1d,\n", + " perturbations=pert_1d,\n", + " n_as_volume_form=True,\n", + " )\n", + " particles.draw_markers(sort=False)\n", + " particles.initialize_weights()\n", + " return particles" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "particles_periodic = make_particles_1d(bc_x=\"periodic\")\n", + "\n", + "kernels_1d = [\"gaussian_1d\", \"linear_1d\", \"trigonometric_1d\"]\n", + "x_plot = ee1.squeeze()\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(15, 4), sharey=True)\n", + "fig.suptitle(r\"1-D density reconstruction ($\\rho = 1.5 + \\cos(2\\pi\\eta_1)$, periodic BC)\")\n", + "\n", + "for ax, kernel in zip(axes, kernels_1d):\n", + " rho_sph = particles_periodic.eval_density(\n", + " ee1, ee2, ee3,\n", + " h1=h1, h2=h2, h3=h3,\n", + " kernel_type=kernel,\n", + " derivative=0,\n", + " ).squeeze()\n", + " rho_ex = rho_exact(x_plot, 0.0, 0.0)\n", + " err = np.max(np.abs(rho_sph - rho_ex)) / np.max(np.abs(rho_ex))\n", + "\n", + " ax.plot(x_plot, rho_ex, \"k-\", lw=2, label=\"exact\")\n", + " ax.plot(x_plot, rho_sph, \"r--\", lw=1.5, label=f\"SPH (err={err:.2e})\")\n", + " ax.set_title(kernel)\n", + " ax.set_xlabel(r\"$\\eta_1$\")\n", + " ax.legend(fontsize=8)\n", + " ax.grid(True, alpha=0.3)\n", + "\n", + "axes[0].set_ylabel(r\"$\\rho$\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "### 1.2 Derivative evaluation\n", + "\n", + "Setting `derivative=1` returns the $\\partial/\\partial\\eta_1$ component of the gradient instead of the field value. The trigonometric kernel is exact for a single cosine mode; the linear kernel requires more particles per box to reach the same accuracy." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "# More particles per box to resolve the derivative accurately\n", + "particles_deriv = make_particles_1d(bc_x=\"periodic\", ppb=100)\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(15, 4), sharey=True)\n", + "fig.suptitle(\n", + " r\"1-D derivative reconstruction ($\\partial\\rho/\\partial\\eta_1 = -2\\pi\\sin(2\\pi\\eta_1)$, periodic BC)\"\n", + ")\n", + "\n", + "for ax, kernel in zip(axes, kernels_1d):\n", + " drho_sph = particles_deriv.eval_density(\n", + " ee1, ee2, ee3,\n", + " h1=h1, h2=h2, h3=h3,\n", + " kernel_type=kernel,\n", + " derivative=1,\n", + " ).squeeze()\n", + " drho_ex = drho_exact(x_plot, 0.0, 0.0)\n", + " err = np.max(np.abs(drho_sph - drho_ex)) / np.max(np.abs(drho_ex))\n", + "\n", + " ax.plot(x_plot, drho_ex, \"k-\", lw=2, label=\"exact\")\n", + " ax.plot(x_plot, drho_sph, \"r--\", lw=1.5, label=f\"SPH (err={err:.2e})\")\n", + " ax.set_title(kernel)\n", + " ax.set_xlabel(r\"$\\eta_1$\")\n", + " ax.legend(fontsize=8)\n", + " ax.grid(True, alpha=0.3)\n", + "\n", + "axes[0].set_ylabel(r\"$\\partial\\rho/\\partial\\eta_1$\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "### 1.3 Effect of boundary conditions\n", + "\n", + "Struphy supports three SPH boundary conditions:\n", + "\n", + "| BC | Description |\n", + "|---|---|\n", + "| `\"periodic\"` | Mirror images placed across the periodic boundary. |\n", + "| `\"mirror\"` | Ghost particles reflected at the wall; enforces zero normal gradient. |\n", + "| `\"fixed\"` | Ghost particles reflected and negated; enforces zero field value at the wall. |\n", + "\n", + "Below we compare all three for the density value. Note that `mirror` and `fixed` are only meaningful near the boundary; the interior is identical to `periodic`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "boundary_conditions = [\"periodic\", \"mirror\", \"fixed\"]\n", + "colors = [\"tab:blue\", \"tab:orange\", \"tab:green\"]\n", + "kernel = \"gaussian_1d\"\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 4))\n", + "ax.plot(x_plot, rho_exact(x_plot, 0.0, 0.0), \"k-\", lw=2.5, label=\"exact\", zorder=5)\n", + "\n", + "for bc, color in zip(boundary_conditions, colors):\n", + " p = make_particles_1d(bc_x=bc)\n", + " rho_sph = p.eval_density(\n", + " ee1, ee2, ee3,\n", + " h1=h1, h2=h2, h3=h3,\n", + " kernel_type=kernel,\n", + " derivative=0,\n", + " ).squeeze()\n", + " ax.plot(x_plot, rho_sph, \"--\", color=color, lw=1.5, label=f\"bc={bc!r}\")\n", + "\n", + "ax.set_title(f\"Boundary condition comparison ({kernel})\")\n", + "ax.set_xlabel(r\"$\\eta_1$\")\n", + "ax.set_ylabel(r\"$\\rho$\")\n", + "ax.legend()\n", + "ax.grid(True, alpha=0.3)\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "### 1.4 Tesselation vs Monte-Carlo loading\n", + "\n", + "With **tesselation** loading, particles are placed on a regular lattice (`ppb` per sorting box), giving a smooth, deterministic reconstruction. With **pseudo-random** loading (Monte Carlo), particle positions are drawn from a uniform distribution, which introduces sampling noise that decays only as $\\mathcal{O}(N^{-1/2})$.\n", + "\n", + "Here we compare both strategies at matched particle counts:\n", + "\n", + "| Strategy | `ppb` | $N$ (approx.) |\n", + "|---|---|---|\n", + "| tesselation | 4 | 96 |\n", + "| pseudo_random | 40 | 960 |\n", + "\n", + "The pseudo-random run uses **ten times** as many particles yet still shows visible fluctuations, illustrating why tesselation is preferred whenever the initial particle positions can be chosen freely." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "ppb_tess = 4\n", + "ppb_rand = ppb_tess * 10 # ten times more particles\n", + "\n", + "particles_tess = make_particles_1d(bc_x=\"periodic\", ppb=ppb_tess, loading=\"tesselation\")\n", + "particles_rand = make_particles_1d(bc_x=\"periodic\", ppb=ppb_rand, loading=\"pseudo_random\")\n", + "\n", + "Np_tess = particles_tess.Np\n", + "Np_rand = particles_rand.Np\n", + "print(f\"Tesselation ppb={ppb_tess} → Np={Np_tess}\")\n", + "print(f\"Pseudo-random ppb={ppb_rand} → Np={Np_rand}\")\n", + "\n", + "rho_ex_1d = rho_exact(x_plot, 0.0, 0.0)\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(15, 4), sharey=True)\n", + "fig.suptitle(\n", + " r\"Tesselation vs Monte Carlo — 1-D density ($\\rho = 1.5 + \\cos(2\\pi\\eta_1)$, periodic BC)\"\n", + ")\n", + "\n", + "for ax, kernel in zip(axes, kernels_1d):\n", + " rho_tess = particles_tess.eval_density(\n", + " ee1, ee2, ee3, h1=h1, h2=h2, h3=h3,\n", + " kernel_type=kernel, derivative=0,\n", + " ).squeeze()\n", + " rho_rand = particles_rand.eval_density(\n", + " ee1, ee2, ee3, h1=h1, h2=h2, h3=h3,\n", + " kernel_type=kernel, derivative=0,\n", + " ).squeeze()\n", + "\n", + " err_tess = np.max(np.abs(rho_tess - rho_ex_1d)) / np.max(np.abs(rho_ex_1d))\n", + " err_rand = np.max(np.abs(rho_rand - rho_ex_1d)) / np.max(np.abs(rho_ex_1d))\n", + "\n", + " ax.plot(x_plot, rho_ex_1d, \"k-\", lw=2, label=\"exact\")\n", + " ax.plot(x_plot, rho_tess, \"b-\", lw=1.5,\n", + " label=f\"tesselation Np={Np_tess} err={err_tess:.2e}\")\n", + " ax.plot(x_plot, rho_rand, \"r-\", lw=1,\n", + " label=f\"pseudo_random Np={Np_rand} err={err_rand:.2e}\")\n", + " ax.set_title(kernel)\n", + " ax.set_xlabel(r\"$\\eta_1$\")\n", + " ax.legend(fontsize=7)\n", + " ax.grid(True, alpha=0.3)\n", + "\n", + "axes[0].set_ylabel(r\"$\\rho$\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "## Part 2 — 2-D density reconstruction\n", + "\n", + "### Problem setup\n", + "\n", + "We extend the test to two dimensions and reconstruct\n", + "\n", + "$$\n", + "\\rho(\\eta_1, \\eta_2) = 1.5 + \\cos(2\\pi\\eta_1)\\cos(2\\pi\\eta_2)\\,,\n", + "$$\n", + "\n", + "together with its partial derivatives\n", + "\n", + "$$\n", + "\\frac{\\partial\\rho}{\\partial\\eta_1} = -2\\pi\\sin(2\\pi\\eta_1)\\cos(2\\pi\\eta_2)\\,,\\qquad\n", + "\\frac{\\partial\\rho}{\\partial\\eta_2} = -2\\pi\\cos(2\\pi\\eta_1)\\sin(2\\pi\\eta_2)\\,.\n", + "$$\n", + "\n", + "The sorting uses a $12\\times 12\\times 1$ box grid, which matches the `trigonometric_2d`, `gaussian_2d`, and `linear_2d` kernel families." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "domain_2d = domains.Cuboid(l1=1.0, r1=2.0, l2=0.0, r2=2.0, l3=100.0, r3=200.0)\n", + "\n", + "background_2d = ConstantVelocity(n=1.5, density_profile=\"constant\")\n", + "background_2d.domain = domain_2d\n", + "\n", + "pert_2d = {\"n\": perturbations.ModesCosCos(ls=(1,), ms=(1,), amps=(1.0,))}\n", + "\n", + "rho_2d_exact = lambda e1, e2, e3: 1.5 + np.cos(2*np.pi*e1) * np.cos(2*np.pi*e2)\n", + "drho_2d_deta1 = lambda e1, e2, e3: -2*np.pi * np.sin(2*np.pi*e1) * np.cos(2*np.pi*e2)\n", + "drho_2d_deta2 = lambda e1, e2, e3: -2*np.pi * np.cos(2*np.pi*e1) * np.sin(2*np.pi*e2)\n", + "\n", + "boxes_per_dim_2d = (12, 12, 1)\n", + "h1_2d = 1 / boxes_per_dim_2d[0]\n", + "h2_2d = 1 / boxes_per_dim_2d[1]\n", + "h3_2d = 1 / boxes_per_dim_2d[2]\n", + "\n", + "# Tesselation loading: 16 particles per box\n", + "loading_params_2d = LoadingParameters(ppb=16, loading=\"tesselation\")\n", + "boundary_params_2d = BoundaryParameters(bc_sph=(\"periodic\", \"periodic\", \"periodic\"))\n", + "sorting_params_2d = SortingParameters(boxes_per_dim=boxes_per_dim_2d)\n", + "\n", + "particles_2d = ParticlesSPH(\n", + " comm_world=None,\n", + " loading_params=loading_params_2d,\n", + " boundary_params=boundary_params_2d,\n", + " sorting_params=sorting_params_2d,\n", + " bufsize=1.0,\n", + " domain=domain_2d,\n", + " background=background_2d,\n", + " perturbations=pert_2d,\n", + " n_as_volume_form=True,\n", + ")\n", + "particles_2d.draw_markers(sort=False)\n", + "particles_2d.initialize_weights()\n", + "\n", + "# 2-D evaluation meshgrid\n", + "n_eval_2d = 50\n", + "eta1_2d = np.linspace(0, 1.0, n_eval_2d)\n", + "eta2_2d = np.linspace(0, 1.0, n_eval_2d)\n", + "eta3_2d = np.array([0.0])\n", + "ee1_2d, ee2_2d, ee3_2d = np.meshgrid(eta1_2d, eta2_2d, eta3_2d, indexing=\"ij\")\n", + "\n", + "print(f\"Particles: {particles_2d.Np} | Boxes: {boxes_per_dim_2d}\")" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "### 2.1 Density value\n", + "\n", + "We evaluate the density on the 2-D meshgrid and compare side-by-side with the exact field." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "kernel_2d = \"gaussian_2d\"\n", + "\n", + "rho_sph_2d = particles_2d.eval_density(\n", + " ee1_2d, ee2_2d, ee3_2d,\n", + " h1=h1_2d, h2=h2_2d, h3=h3_2d,\n", + " kernel_type=kernel_2d,\n", + " derivative=0,\n", + ").squeeze()\n", + "\n", + "rho_ex_2d = rho_2d_exact(ee1_2d, ee2_2d, ee3_2d).squeeze()\n", + "err_2d = np.max(np.abs(rho_sph_2d - rho_ex_2d)) / np.max(np.abs(rho_ex_2d))\n", + "print(f\"Max relative error: {err_2d:.3e}\")\n", + "\n", + "x_plot_2d = ee1_2d.squeeze()\n", + "y_plot_2d = ee2_2d.squeeze()\n", + "vmin = rho_ex_2d.min()\n", + "vmax = rho_ex_2d.max()\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(16, 4))\n", + "fig.suptitle(f\"2-D density reconstruction ({kernel_2d}, periodic BC)\")\n", + "\n", + "im0 = axes[0].pcolormesh(x_plot_2d, y_plot_2d, rho_ex_2d, vmin=vmin, vmax=vmax, shading=\"auto\")\n", + "axes[0].set_title(\"Exact\")\n", + "fig.colorbar(im0, ax=axes[0])\n", + "\n", + "im1 = axes[1].pcolormesh(x_plot_2d, y_plot_2d, rho_sph_2d, vmin=vmin, vmax=vmax, shading=\"auto\")\n", + "axes[1].set_title(\"SPH\")\n", + "fig.colorbar(im1, ax=axes[1])\n", + "\n", + "err_field = np.abs(rho_sph_2d - rho_ex_2d)\n", + "im2 = axes[2].pcolormesh(x_plot_2d, y_plot_2d, err_field, shading=\"auto\", cmap=\"Reds\")\n", + "axes[2].set_title(f\"Point-wise error (max={err_2d:.2e})\")\n", + "fig.colorbar(im2, ax=axes[2])\n", + "\n", + "for ax in axes:\n", + " ax.set_xlabel(r\"$\\eta_1$\")\n", + " ax.set_ylabel(r\"$\\eta_2$\")\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "### 2.2 Partial derivatives\n", + "\n", + "`derivative=1` gives $\\partial\\rho/\\partial\\eta_1$ and `derivative=2` gives $\\partial\\rho/\\partial\\eta_2$. We show both below using the trigonometric kernel, which is spectrally exact for smooth periodic functions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "# More particles per box for the trigonometric kernel derivative test\n", + "loading_params_deriv = LoadingParameters(ppb=100, loading=\"tesselation\")\n", + "particles_2d_deriv = ParticlesSPH(\n", + " comm_world=None,\n", + " loading_params=loading_params_deriv,\n", + " boundary_params=boundary_params_2d,\n", + " sorting_params=sorting_params_2d,\n", + " bufsize=1.0,\n", + " domain=domain_2d,\n", + " background=background_2d,\n", + " perturbations=pert_2d,\n", + " n_as_volume_form=True,\n", + ")\n", + "particles_2d_deriv.draw_markers(sort=False)\n", + "particles_2d_deriv.initialize_weights()\n", + "\n", + "kernel_deriv = \"trigonometric_2d\"\n", + "derivative_info = [\n", + " (1, drho_2d_deta1, r\"$\\partial\\rho/\\partial\\eta_1$\"),\n", + " (2, drho_2d_deta2, r\"$\\partial\\rho/\\partial\\eta_2$\"),\n", + "]\n", + "\n", + "fig, axes = plt.subplots(2, 3, figsize=(16, 9))\n", + "fig.suptitle(f\"2-D derivative reconstruction ({kernel_deriv}, periodic BC)\")\n", + "\n", + "for row, (deriv_idx, exact_fn, label) in enumerate(derivative_info):\n", + " drho_sph = particles_2d_deriv.eval_density(\n", + " ee1_2d, ee2_2d, ee3_2d,\n", + " h1=h1_2d, h2=h2_2d, h3=h3_2d,\n", + " kernel_type=kernel_deriv,\n", + " derivative=deriv_idx,\n", + " ).squeeze()\n", + " drho_ex = exact_fn(ee1_2d, ee2_2d, ee3_2d).squeeze()\n", + " err = np.max(np.abs(drho_sph - drho_ex)) / np.max(np.abs(drho_ex))\n", + "\n", + " vmin_d, vmax_d = drho_ex.min(), drho_ex.max()\n", + "\n", + " im0 = axes[row, 0].pcolormesh(x_plot_2d, y_plot_2d, drho_ex, vmin=vmin_d, vmax=vmax_d, shading=\"auto\")\n", + " axes[row, 0].set_title(f\"Exact {label}\")\n", + " fig.colorbar(im0, ax=axes[row, 0])\n", + "\n", + " im1 = axes[row, 1].pcolormesh(x_plot_2d, y_plot_2d, drho_sph, vmin=vmin_d, vmax=vmax_d, shading=\"auto\")\n", + " axes[row, 1].set_title(f\"SPH {label}\")\n", + " fig.colorbar(im1, ax=axes[row, 1])\n", + "\n", + " err_field = np.abs(drho_sph - drho_ex)\n", + " im2 = axes[row, 2].pcolormesh(x_plot_2d, y_plot_2d, err_field, shading=\"auto\", cmap=\"Reds\")\n", + " axes[row, 2].set_title(f\"Error (max={err:.2e})\")\n", + " fig.colorbar(im2, ax=axes[row, 2])\n", + "\n", + "for ax in axes.flat:\n", + " ax.set_xlabel(r\"$\\eta_1$\")\n", + " ax.set_ylabel(r\"$\\eta_2$\")\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "### 2.3 Kernel comparison in 2-D\n", + "\n", + "All three 2-D kernel families are compared for the density value." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "kernels_2d = [\"gaussian_2d\", \"linear_2d\", \"trigonometric_2d\"]\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(16, 4))\n", + "fig.suptitle(r\"Kernel comparison — 2-D density ($\\rho = 1.5 + \\cos(2\\pi\\eta_1)\\cos(2\\pi\\eta_2)$)\")\n", + "\n", + "for ax, kernel in zip(axes, kernels_2d):\n", + " rho_sph = particles_2d.eval_density(\n", + " ee1_2d, ee2_2d, ee3_2d,\n", + " h1=h1_2d, h2=h2_2d, h3=h3_2d,\n", + " kernel_type=kernel,\n", + " derivative=0,\n", + " ).squeeze()\n", + " rho_ex = rho_2d_exact(ee1_2d, ee2_2d, ee3_2d).squeeze()\n", + " err = np.max(np.abs(rho_sph - rho_ex)) / np.max(np.abs(rho_ex))\n", + "\n", + " im = ax.pcolormesh(\n", + " x_plot_2d, y_plot_2d, np.abs(rho_sph - rho_ex),\n", + " shading=\"auto\", cmap=\"Reds\",\n", + " )\n", + " ax.set_title(f\"{kernel}\\nerr={err:.2e}\")\n", + " ax.set_xlabel(r\"$\\eta_1$\")\n", + " ax.set_ylabel(r\"$\\eta_2$\")\n", + " fig.colorbar(im, ax=ax)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "21", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "| Task | Key parameter |\n", + "|---|---|\n", + "| Field value | `derivative=0` |\n", + "| $\\partial/\\partial\\eta_1$ | `derivative=1` |\n", + "| $\\partial/\\partial\\eta_2$ | `derivative=2` |\n", + "| $\\partial/\\partial\\eta_3$ | `derivative=3` |\n", + "| Kernel family | `kernel_type` — e.g. `\"gaussian_1d\"`, `\"trigonometric_2d\"`, `\"linear_3d\"` |\n", + "| Kernel bandwidth | `h1`, `h2`, `h3` — typically set to `1/boxes_per_dim` |\n", + "\n", + "The error decreases as `ppb` (particles per box) increases and as the kernel bandwidth matches the inter-particle spacing. The **trigonometric** kernel achieves spectral accuracy for smooth periodic functions; the **Gaussian** and **linear** kernels are more robust near non-periodic boundaries.\n", + "\n", + "For implementation details of the underlying kernel functions see `struphy.pic.sph_eval_kernels` and `struphy.pic.sph_smoothing_kernels`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env (3.12.3)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 46375bdb402a9c288a9d3423ee510c7d023524c9 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Wed, 10 Jun 2026 19:56:54 +0200 Subject: [PATCH 06/34] move sph eval kernerls to new file eval_kernel_sph.py --- src/struphy/pic/base.py | 10 +- src/struphy/pic/pushing/eval_kernels_sph.py | 568 ++++++++++++++++++ .../propagators/push_vin_sph_pressure.py | 4 +- .../propagators/push_vin_viscous_potential.py | 6 +- 4 files changed, 578 insertions(+), 10 deletions(-) create mode 100644 src/struphy/pic/pushing/eval_kernels_sph.py diff --git a/src/struphy/pic/base.py b/src/struphy/pic/base.py index 75d9a803f..c1b4ba06a 100644 --- a/src/struphy/pic/base.py +++ b/src/struphy/pic/base.py @@ -45,7 +45,7 @@ class Intracomm: WeightsParameters, ) from struphy.pic import sampling_kernels, sobol_seq -from struphy.pic.pushing import eval_kernels_gc +from struphy.pic.pushing import eval_kernels_sph from struphy.pic.pushing.pusher_utilities_kernels import reflect from struphy.pic.sorting_kernels import ( assign_box_to_each_particle, @@ -3920,7 +3920,7 @@ def eval_velocity( Notes ----- This method first computes SPH coefficients by calling - `eval_kernels_gc.sph_mean_velocity_coeffs` (via a Pyccel kernel) to + `eval_kernels_sph.sph_mean_velocity_coeffs` (via a Pyccel kernel) to assemble mean-velocity coefficients into the markers array, then calls :meth:`eval_sph` for each velocity component. """ @@ -3930,7 +3930,7 @@ def eval_velocity( self.put_particles_in_boxes() - func = Pyccelkernel(eval_kernels_gc.sph_mean_velocity_coeffs) + func = Pyccelkernel(eval_kernels_sph.sph_mean_velocity_coeffs) func( alpha=xp.array((0.0, 0.0, 0.0)), @@ -4042,7 +4042,7 @@ def eval_div_viscosity( self.put_particles_in_boxes() # 1st kernel - func = Pyccelkernel(eval_kernels_gc.sph_mean_velocity_coeffs) + func = Pyccelkernel(eval_kernels_sph.sph_mean_velocity_coeffs) comps = xp.array((0, 1, 2)) func( alpha=xp.array((0.0, 0.0, 0.0)), @@ -4063,7 +4063,7 @@ def eval_div_viscosity( ) # 2nd kernel - func = Pyccelkernel(eval_kernels_gc.sph_viscosity_tensor) + func = Pyccelkernel(eval_kernels_sph.sph_viscosity_tensor) comps = xp.arange(9) func( alpha=xp.array((0.0, 0.0, 0.0)), diff --git a/src/struphy/pic/pushing/eval_kernels_sph.py b/src/struphy/pic/pushing/eval_kernels_sph.py new file mode 100644 index 000000000..c89e99d70 --- /dev/null +++ b/src/struphy/pic/pushing/eval_kernels_sph.py @@ -0,0 +1,568 @@ +"Initialization routines (initial guess, evaluations) for sph kernel evaluations." + +from numpy import shape, zeros +from pyccel.decorators import stack_array + +import struphy.bsplines.bsplines_kernels as bsplines_kernels +import struphy.bsplines.evaluation_kernels_3d as evaluation_kernels_3d +import struphy.geometry.evaluation_kernels as evaluation_kernels + +# do not remove; needed to identify dependencies +import struphy.kernel_arguments.pusher_args_kernels as pusher_args_kernels +import struphy.linear_algebra.linalg_kernels as linalg_kernels +import struphy.pic.sph_eval_kernels as sph_eval_kernels + +from struphy.kernel_arguments.pusher_args_kernels import DerhamArguments, DomainArguments, MarkerArguments + + +@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") +def sph_pressure_coeffs( + alpha: "float[:]", + column_nr: int, + comps: "int[:]", + args_markers: "MarkerArguments", + args_domain: "DomainArguments", + boxes: "int[:, :]", + neighbours: "int[:, :]", + holes: "bool[:]", + periodic1: "bool", + periodic2: "bool", + periodic3: "bool", + kernel_type: "int", + h1: "float", + h2: "float", + h3: "float", +): + r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian + + .. math:: + + H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} + + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, + + that is + + .. math:: + + \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) + + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, + + where the evaluation point is the weighted average + :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, + for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point + :math:`\boldsymbol \eta_p` beforehand. + + The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` + in markers array for each particle. + """ + + gamma = 5 / 3 + + # get marker arguments + markers = args_markers.markers + n_markers = args_markers.n_markers + n_cols = shape(markers)[1] + Np = args_markers.Np + weight_idx = args_markers.weight_idx + valid_mks = args_markers.valid_mks + + for ip in range(n_markers): + # only do something if particle is a "true" particle + if not valid_mks[ip]: + continue + + eta1 = markers[ip, 0] + eta2 = markers[ip, 1] + eta3 = markers[ip, 2] + loc_box = int(markers[ip, n_cols - 2]) + n_at_eta = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + weight_idx, + kernel_type, + h1, + h2, + h3, + ) + weight = markers[ip, weight_idx] + # save + markers[ip, column_nr] = n_at_eta + markers[ip, column_nr + 1] = weight / n_at_eta + markers[ip, column_nr + 2] = weight * n_at_eta ** (gamma - 2) + + +@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") +def sph_isotherm_kappa( + alpha: "float[:]", + column_nr: int, + comps: "int[:]", + args_markers: "MarkerArguments", +): + r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian + + .. math:: + + H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} + + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, + + that is + + .. math:: + + \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) + + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, + + where the evaluation point is the weighted average + :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, + for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point + :math:`\boldsymbol \eta_p` beforehand. + + The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` + in markers array for each particle. + """ + + # get marker arguments + markers = args_markers.markers + n_markers = args_markers.n_markers + first_diagnostic_idx = args_markers.first_diagnostics_idx + + for ip in range(n_markers): + # only do something if particle is a "true" particle (i.e. not a hole) + if markers[ip, 0] == -1.0: + continue + + markers[ip, first_diagnostic_idx] = 1.0 + + +@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") +def sph_mean_velocity_coeffs( + alpha: "float[:]", + column_nr: int, + comps: "int[:]", + args_markers: "MarkerArguments", + args_domain: "DomainArguments", + boxes: "int[:, :]", + neighbours: "int[:, :]", + holes: "bool[:]", + periodic1: "bool", + periodic2: "bool", + periodic3: "bool", + kernel_type: "int", + h1: "float", + h2: "float", + h3: "float", +): + r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian + + .. math:: + + H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} + + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, + + that is + + .. math:: + + \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) + + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, + + where the evaluation point is the weighted average + :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, + for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point + :math:`\boldsymbol \eta_p` beforehand. + + The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` + in markers array for each particle. + """ + + gamma = 5 / 3 + + # get marker arguments + markers = args_markers.markers + n_markers = args_markers.n_markers + n_cols = shape(markers)[1] + Np = args_markers.Np + vdim = args_markers.vdim + weight_idx = args_markers.weight_idx + valid_mks = args_markers.valid_mks + + for ip in range(n_markers): + # only do something if particle is a "true" particle + if not valid_mks[ip]: + continue + + # also evaluate and save for ghost particles, only skip holes (!) + # if holes[ip]: + # continue + + eta1 = markers[ip, 0] + eta2 = markers[ip, 1] + eta3 = markers[ip, 2] + loc_box = int(markers[ip, n_cols - 2]) + n_at_eta = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + weight_idx, + kernel_type, + h1, + h2, + h3, + ) + weight = markers[ip, weight_idx] + velocities = markers[ip, 3:6] + # save + markers[ip, column_nr] = weight / n_at_eta * velocities[0] + markers[ip, column_nr + 1] = weight / n_at_eta * velocities[1] + markers[ip, column_nr + 2] = weight / n_at_eta * velocities[2] + + # logger.info(f"{ip = }, {weight = }, {n_at_eta = }, {velocities[0] = }") + + +@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") +def sph_mean_velocity( + alpha: "float[:]", + column_nr: int, + comps: "int[:]", + args_markers: "MarkerArguments", + args_domain: "DomainArguments", + boxes: "int[:, :]", + neighbours: "int[:, :]", + holes: "bool[:]", + periodic1: "bool", + periodic2: "bool", + periodic3: "bool", + kernel_type: "int", + h1: "float", + h2: "float", + h3: "float", +): + r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian + + .. math:: + + H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} + + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, + + that is + + .. math:: + + \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) + + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, + + where the evaluation point is the weighted average + :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, + for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point + :math:`\boldsymbol \eta_p` beforehand. + + The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` + in markers array for each particle. + """ + + gamma = 5 / 3 + + # get marker arguments + markers = args_markers.markers + n_markers = args_markers.n_markers + n_cols = shape(markers)[1] + Np = args_markers.Np + vdim = args_markers.vdim + weight_idx = args_markers.weight_idx + first_free_idx = args_markers.first_free_idx + valid_mks = args_markers.valid_mks + + for ip in range(n_markers): + # only do something if particle is a "true" particle + if not valid_mks[ip]: + continue + + eta1 = markers[ip, 0] + eta2 = markers[ip, 1] + eta3 = markers[ip, 2] + loc_box = int(markers[ip, n_cols - 2]) + v1_at_eta = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + first_free_idx, + kernel_type, + h1, + h2, + h3, + ) + + v2_at_eta = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + first_free_idx + 1, + kernel_type, + h1, + h2, + h3, + ) + + v3_at_eta = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + first_free_idx + 2, + kernel_type, + h1, + h2, + h3, + ) + # save + markers[ip, column_nr] = v1_at_eta + markers[ip, column_nr + 1] = v2_at_eta + markers[ip, column_nr + 2] = v3_at_eta + + +@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") +def sph_grad_mean_velocity( + alpha: "float[:]", + column_nr: int, + comps: "int[:]", + args_markers: "MarkerArguments", + args_domain: "DomainArguments", + boxes: "int[:, :]", + neighbours: "int[:, :]", + holes: "bool[:]", + periodic1: "bool", + periodic2: "bool", + periodic3: "bool", + kernel_type: "int", + h1: "float", + h2: "float", + h3: "float", +): + r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian + + .. math:: + + H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} + + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, + + that is + + .. math:: + + \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) + + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, + + where the evaluation point is the weighted average + :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, + for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point + :math:`\boldsymbol \eta_p` beforehand. + + The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` + in markers array for each particle. + """ + + gamma = 5 / 3 + + # get marker arguments + markers = args_markers.markers + n_markers = args_markers.n_markers + n_cols = shape(markers)[1] + Np = args_markers.Np + vdim = args_markers.vdim + weight_idx = args_markers.weight_idx + first_free_idx = args_markers.first_free_idx + valid_mks = args_markers.valid_mks + + grad_v_at_eta = zeros((3, 3), dtype=float) + for ip in range(n_markers): + # only do something if particle is a "true" particle + if not valid_mks[ip]: + continue + + eta1 = markers[ip, 0] + eta2 = markers[ip, 1] + eta3 = markers[ip, 2] + loc_box = int(markers[ip, n_cols - 2]) + for j in range(3): + for k in range(3): + grad_v_at_eta[j, k] = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + first_free_idx + j, + kernel_type + 1 + k, + h1, + h2, + h3, + ) + + # save + markers[ip, column_nr + 3 * j + k] = grad_v_at_eta[j, k] + + +@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") +def sph_viscosity_tensor( + alpha: "float[:]", + column_nr: int, + comps: "int[:]", + args_markers: "MarkerArguments", + args_domain: "DomainArguments", + boxes: "int[:, :]", + neighbours: "int[:, :]", + holes: "bool[:]", + periodic1: "bool", + periodic2: "bool", + periodic3: "bool", + kernel_type: "int", + h1: "float", + h2: "float", + h3: "float", + mu: "float", +): + r"""Evaluate the viscous stress tensor at each particle location using SPH. + + Computes the deviatoric viscous stress tensor: + + .. math:: + + \boldsymbol{\sigma}_{ij} = -2\mu \left(\dot{\gamma}_{ij} - \frac{1}{3}\delta_{ij}\dot{\gamma}_{kk}\right)\,, + + where :math:`\dot{\gamma}_{ij}` is the strain rate tensor computed from the velocity gradient + :math:`\dot{\gamma}_{ij} = \frac{1}{2}\left(\frac{\partial v_i}{\partial x_j} + \frac{\partial v_j}{\partial x_i}\right)`, + and :math:`\mu` is the dynamic viscosity coefficient. The deviatoric part (removal of trace) + ensures incompressibility in the momentum equation. + + The velocity gradient is evaluated at particle positions using SPH kernel interpolation. + The density factor :math:`(w/n)^2` accounts for the local particle number density :math:`n_{\eta}`, + where :math:`w` is the particle weight. + + Parameters evaluated at location :math:`\boldsymbol{\eta}_p` using weighted SPH kernel interpolation + over neighboring particles in boxes. + + All 9 components of the symmetric stress tensor are saved at + ``column_nr:column_nr+9`` in markers array for each particle (in row-major order). + """ + + # get marker arguments + markers = args_markers.markers + n_markers = args_markers.n_markers + n_cols = shape(markers)[1] + Np = args_markers.Np + vdim = args_markers.vdim + weight_idx = args_markers.weight_idx + first_free_idx = args_markers.first_free_idx + valid_mks = args_markers.valid_mks + + grad_v_at_eta = zeros((3, 3), dtype=float) + # d_tensor = zeros((3, 3), dtype=float) + d_dev = zeros((3, 3), dtype=float) + for ip in range(n_markers): + # only do something if particle is a "true" particle + if not valid_mks[ip]: + continue + + eta1 = markers[ip, 0] + eta2 = markers[ip, 1] + eta3 = markers[ip, 2] + loc_box = int(markers[ip, n_cols - 2]) + n_at_eta = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + weight_idx, + kernel_type, + h1, + h2, + h3, + ) + weight = markers[ip, weight_idx] + for j in range(3): + for k in range(3): + grad_v_at_eta[j, k] = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + first_free_idx + j, + kernel_type + 1 + k, + h1, + h2, + h3, + ) + + d_dev[:] = 0.5 * (grad_v_at_eta + grad_v_at_eta.T) + + mean_trace = (d_dev[0, 0] + d_dev[1, 1] + d_dev[2, 2]) / 3.0 + + d_dev[0, 0] -= mean_trace + d_dev[1, 1] -= mean_trace + d_dev[2, 2] -= mean_trace + + d_dev *= -2 * mu * (weight / n_at_eta) ** 2 + + for j in range(3): + for k in range(3): + markers[ip, column_nr + 3 * j + k] = d_dev[j, k] diff --git a/src/struphy/propagators/push_vin_sph_pressure.py b/src/struphy/propagators/push_vin_sph_pressure.py index f462e4777..8abbbad0c 100644 --- a/src/struphy/propagators/push_vin_sph_pressure.py +++ b/src/struphy/propagators/push_vin_sph_pressure.py @@ -9,7 +9,7 @@ from struphy.io.options import LiteralOptions, OptionsBase from struphy.models.variables import SPHVariable -from struphy.pic.pushing import eval_kernels_gc, pusher_kernels +from struphy.pic.pushing import eval_kernels_sph, pusher_kernels from struphy.pic.pushing.pusher import Pusher from struphy.propagators.base import Propagator from struphy.utils.pyccel import Pyccelkernel @@ -116,7 +116,7 @@ def options(self, new): @profile def allocate(self): # init kernel for evaluating density etc. before each time step. - init_kernel = eval_kernels_gc.sph_pressure_coeffs + init_kernel = eval_kernels_sph.sph_pressure_coeffs particles = self.variables.fluid.particles diff --git a/src/struphy/propagators/push_vin_viscous_potential.py b/src/struphy/propagators/push_vin_viscous_potential.py index c423af982..6dbde0d1e 100644 --- a/src/struphy/propagators/push_vin_viscous_potential.py +++ b/src/struphy/propagators/push_vin_viscous_potential.py @@ -8,7 +8,7 @@ from struphy.io.options import LiteralOptions, OptionsBase from struphy.models.variables import SPHVariable -from struphy.pic.pushing import eval_kernels_gc, pusher_kernels +from struphy.pic.pushing import eval_kernels_sph, pusher_kernels from struphy.pic.pushing.pusher import Pusher from struphy.propagators.base import Propagator from struphy.utils.pyccel import Pyccelkernel @@ -117,11 +117,11 @@ def allocate(self): # ersetzt init particles = self.variables.fluid.particles # init kernel for evaluating density etc. before each time step. - init_kernel_1 = eval_kernels_gc.sph_mean_velocity_coeffs + init_kernel_1 = eval_kernels_sph.sph_mean_velocity_coeffs first_free_idx = particles.args_markers.first_free_idx comps = (0, 1, 2) - init_kernel_2 = eval_kernels_gc.sph_viscosity_tensor + init_kernel_2 = eval_kernels_sph.sph_viscosity_tensor comps_tensor = (0, 1, 2, 3, 4, 5, 6, 7, 8) boxes = particles.sorting_boxes.boxes From 3c41a0f591cb11f0985b62a07a1897f26e39a9ec Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Thu, 11 Jun 2026 08:14:25 +0200 Subject: [PATCH 07/34] ATTENTION: moved the factor 1/Np into the definition of the weights! This makes the sph eval kernels easier (no need to differentiate betwwen tesselation and monte-carlo), as all info is in the weights. --- .../test_verif_ViscousEulerSPH.py | 7 +- src/struphy/pic/accumulation/accum_kernels.py | 100 +++++------------- .../pic/accumulation/accum_kernels_gc.py | 42 +------- src/struphy/pic/base.py | 94 ++++++++++------ src/struphy/pic/pushing/eval_kernels_sph.py | 1 - src/struphy/pic/sph_eval_kernels.py | 12 +-- src/struphy/pic/tests/test_accum_vec_H1.py | 24 ++--- src/struphy/utils/utils.py | 2 +- tutorials/dev_tutorial_sph_eval_kernels.ipynb | 6 +- 9 files changed, 110 insertions(+), 178 deletions(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index f7cd0327e..d9b88e102 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -23,9 +23,9 @@ domains, equils, perturbations, + set_logging_level, ) from struphy.models import ViscousEulerSPH -from struphy import set_logging_level logger = logging.getLogger("struphy") set_logging_level(logging.DEBUG) @@ -260,12 +260,12 @@ def test_damped_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): x = ee1 * r1 plt.figure(figsize=(20, 40)) - + dt_plot = end_time / 10.0 plot_ct = 0 time = 0.0 for i in range(Nt + 1): - time = dt*i + time = dt * i logger.info(f"{i =}, {time =:.4f}, {time // dt_plot =}, {plot_ct =}") if time // dt_plot >= plot_ct: plot_ct += 1 @@ -287,7 +287,6 @@ def test_damped_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): shutil.rmtree(test_folder) - if __name__ == "__main__": # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) test_damped_soundwave_1d(nx=12, plot_pts=11, do_plot=True) diff --git a/src/struphy/pic/accumulation/accum_kernels.py b/src/struphy/pic/accumulation/accum_kernels.py index 9f6505a3b..691c57748 100644 --- a/src/struphy/pic/accumulation/accum_kernels.py +++ b/src/struphy/pic/accumulation/accum_kernels.py @@ -12,9 +12,7 @@ from pyccel.decorators import stack_array import struphy.geometry.evaluation_kernels as evaluation_kernels - -# do not remove; needed to identify dependencies -import struphy.kernel_arguments.pusher_args_kernels as pusher_args_kernels +import struphy.kernel_arguments.pusher_args_kernels as pusher_args_kernels # do not remove; needed to identify dependencies (for import below) import struphy.linear_algebra.linalg_kernels as linalg_kernels import struphy.pic.accumulation.particle_to_mat_kernels as particle_to_mat_kernels from struphy.bsplines.evaluation_kernels_3d import ( @@ -43,7 +41,6 @@ def charge_density_0form( """ markers = args_markers.markers - Np = args_markers.Np weight_idx = args_markers.weight_idx # -- removed omp: #$ omp parallel private (ip, eta1, eta2, eta3, filling) @@ -59,7 +56,7 @@ def charge_density_0form( eta3 = markers[ip, 2] # filling = w_p/N - filling = markers[ip, weight_idx] / Np + filling = markers[ip, weight_idx] particle_to_mat_kernels.vec_fill_b_v0( args_derham, @@ -113,7 +110,6 @@ def hybrid_fA_density( """ markers = args_markers.markers - Np = args_markers.Np # allocate cell_left = empty(3, dtype=int) @@ -162,7 +158,7 @@ def hybrid_fA_density( # metric coeffs det_df = linalg_kernels.det(dfm) - weight = markers[ip, 6] / (p_size[0] * p_size[1] * p_size[2]) / Np / det_df + weight = markers[ip, 6] / (p_size[0] * p_size[1] * p_size[2]) / det_df ie1 = int(eta1 * num_elements[0]) ie2 = int(eta2 * num_elements[1]) @@ -263,7 +259,6 @@ def hybrid_fA_Arelated( """ markers = args_markers.markers - Np = args_markers.Np # allocate for metric coeffs dfm = empty((3, 3), dtype=float) @@ -309,29 +304,29 @@ def hybrid_fA_Arelated( weight = markers[ip, 6] # filling_m - filling_m[0, 0] = ( - weight / Np * (df_inv[0, 0] * df_inv[0, 0] + df_inv[0, 1] * df_inv[0, 1] + df_inv[0, 2] * df_inv[0, 2]) + filling_m[0, 0] = weight * ( + df_inv[0, 0] * df_inv[0, 0] + df_inv[0, 1] * df_inv[0, 1] + df_inv[0, 2] * df_inv[0, 2] ) - filling_m[0, 1] = ( - weight / Np * (df_inv[0, 0] * df_inv[1, 0] + df_inv[0, 1] * df_inv[1, 1] + df_inv[0, 2] * df_inv[1, 2]) + filling_m[0, 1] = weight * ( + df_inv[0, 0] * df_inv[1, 0] + df_inv[0, 1] * df_inv[1, 1] + df_inv[0, 2] * df_inv[1, 2] ) - filling_m[0, 2] = ( - weight / Np * (df_inv[0, 0] * df_inv[2, 0] + df_inv[0, 1] * df_inv[2, 1] + df_inv[0, 2] * df_inv[2, 2]) + filling_m[0, 2] = weight * ( + df_inv[0, 0] * df_inv[2, 0] + df_inv[0, 1] * df_inv[2, 1] + df_inv[0, 2] * df_inv[2, 2] ) - filling_m[1, 1] = ( - weight / Np * (df_inv[1, 0] * df_inv[1, 0] + df_inv[1, 1] * df_inv[1, 1] + df_inv[1, 2] * df_inv[1, 2]) + filling_m[1, 1] = weight * ( + df_inv[1, 0] * df_inv[1, 0] + df_inv[1, 1] * df_inv[1, 1] + df_inv[1, 2] * df_inv[1, 2] ) - filling_m[1, 2] = ( - weight / Np * (df_inv[1, 0] * df_inv[2, 0] + df_inv[1, 1] * df_inv[2, 1] + df_inv[1, 2] * df_inv[2, 2]) + filling_m[1, 2] = weight * ( + df_inv[1, 0] * df_inv[2, 0] + df_inv[1, 1] * df_inv[2, 1] + df_inv[1, 2] * df_inv[2, 2] ) - filling_m[2, 2] = ( - weight / Np * (df_inv[2, 0] * df_inv[2, 0] + df_inv[2, 1] * df_inv[2, 1] + df_inv[2, 2] * df_inv[2, 2]) + filling_m[2, 2] = weight * ( + df_inv[2, 0] * df_inv[2, 0] + df_inv[2, 1] * df_inv[2, 1] + df_inv[2, 2] * df_inv[2, 2] ) # filling_v - filling_v[:] = weight / Np * df_inv_times_v + filling_v[:] = weight * df_inv_times_v # call the appropriate matvec filler particle_to_mat_kernels.m_v_fill_b_v1_symm( @@ -404,7 +399,6 @@ def linear_vlasov_ampere( """ markers = args_markers.markers - Np = args_markers.Np # allocate for metric coeffs dfm = empty((3, 3), dtype=float) @@ -451,12 +445,12 @@ def linear_vlasov_ampere( # compute DF^{-1} v linalg_kernels.matrix_vector(df_inv, v, df_inv_v) - # filling_m = alpha^2 * kappa^2 * f0 / (N * s_0 * v_th^2) * (DF^{-1} v_p)_mu * (DF^{-1} v_p)_nu + # filling_m = alpha^2 * kappa^2 * f0 / (s_0 * v_th^2) * (DF^{-1} v_p)_mu * (DF^{-1} v_p)_nu linalg_kernels.outer(df_inv_v, df_inv_v, filling_m) - filling_m[:, :] *= f0_values[ip] / (Np * markers[ip, 7]) + filling_m[:, :] *= f0_values[ip] / markers[ip, 7] - # filling_v = alpha^2 * kappa / N * w_p * DL^{-1} * v_p - filling_v[:] = markers[ip, 6] * df_inv_v / Np + # filling_v = alpha^2 * kappa * w_p * DL^{-1} * v_p + filling_v[:] = markers[ip, 6] * df_inv_v # call the appropriate matvec filler particle_to_mat_kernels.m_v_fill_b_v1_symm( @@ -520,7 +514,6 @@ def vlasov_maxwell( """ markers = args_markers.markers - Np = args_markers.Np # allocate for metric coeffs dfm = zeros((3, 3), dtype=float) @@ -567,10 +560,10 @@ def vlasov_maxwell( linalg_kernels.matrix_vector(df_inv, v, df_inv_times_v) # filling_m = w_p * DF^{-1} * DF^{-T} - filling_m[:, :] = markers[ip, 6] * g_inv / Np + filling_m[:, :] = markers[ip, 6] * g_inv # filling_v = w_p * DF^{-1} * \V - filling_v[:] = markers[ip, 6] * df_inv_times_v / Np + filling_v[:] = markers[ip, 6] * df_inv_times_v # call the appropriate matvec filler particle_to_mat_kernels.m_v_fill_b_v1_symm( @@ -635,7 +628,6 @@ def cc_lin_mhd_6d_1( """ markers = args_markers.markers - Np = args_markers.Np # allocate for magnetic field evaluation b = empty(3, dtype=float) @@ -774,10 +766,6 @@ def cc_lin_mhd_6d_1( # -- removed omp: #$ omp end parallel - mat12 /= Np - mat13 /= Np - mat23 /= Np - @stack_array( "b", @@ -837,7 +825,6 @@ def cc_lin_mhd_6d_2( """ markers = args_markers.markers - Np = args_markers.Np # allocate for magnetic field evaluation b = empty(3, dtype=float) @@ -1050,17 +1037,6 @@ def cc_lin_mhd_6d_2( # -- removed omp: #$ omp end parallel - mat11 /= Np - mat12 /= Np - mat13 /= Np - mat22 /= Np - mat23 /= Np - mat33 /= Np - - vec1 /= Np - vec2 /= Np - vec3 /= Np - @stack_array("dfm", "df_t", "df_inv", "df_inv_t", "filling_m", "filling_v", "tmp1", "v", "tmp_v") def pc_lin_mhd_6d_full( @@ -1131,7 +1107,6 @@ def pc_lin_mhd_6d_full( """ markers = args_markers.markers - Np = args_markers.Np # allocate for metric coeffs dfm = empty((3, 3), dtype=float) @@ -1186,8 +1161,8 @@ def pc_lin_mhd_6d_full( weight = markers[ip, 8] - filling_m[:, :] = weight * tmp1 / Np * ep_scale - filling_v[:] = weight * tmp_v / Np * ep_scale + filling_m[:, :] = weight * tmp1 * ep_scale + filling_v[:] = weight * tmp_v * ep_scale # call the appropriate matvec filler particle_to_mat_kernels.m_v_fill_v1_pressure_full( @@ -1324,7 +1299,6 @@ def pc_lin_mhd_6d( """ markers = args_markers.markers - Np = args_markers.Np # allocate for metric coeffs dfm = empty((3, 3), dtype=float) @@ -1423,29 +1397,3 @@ def pc_lin_mhd_6d( v[0], v[1], ) - - mat11_11 /= Np - mat12_11 /= Np - mat13_11 /= Np - mat22_11 /= Np - mat23_11 /= Np - mat33_11 /= Np - mat11_12 /= Np - mat12_12 /= Np - mat13_12 /= Np - mat22_12 /= Np - mat23_12 /= Np - mat33_12 /= Np - mat11_22 /= Np - mat12_22 /= Np - mat13_22 /= Np - mat22_22 /= Np - mat23_22 /= Np - mat33_22 /= Np - - vec1_1 /= Np - vec2_1 /= Np - vec3_1 /= Np - vec1_2 /= Np - vec2_2 /= Np - vec3_2 /= Np diff --git a/src/struphy/pic/accumulation/accum_kernels_gc.py b/src/struphy/pic/accumulation/accum_kernels_gc.py index fecf6a255..72c5333c6 100644 --- a/src/struphy/pic/accumulation/accum_kernels_gc.py +++ b/src/struphy/pic/accumulation/accum_kernels_gc.py @@ -45,7 +45,6 @@ def gc_density_0form( """ markers = args_markers.markers - Np = args_markers.Np # -- removed omp: #$ omp parallel private (ip, eta1, eta2, eta3, filling) # -- removed omp: #$ omp for reduction ( + :vec) @@ -60,7 +59,7 @@ def gc_density_0form( eta3 = markers[ip, 2] # filling = w_p/N - filling = markers[ip, 5] / Np + filling = markers[ip, 5] particle_to_mat_kernels.vec_fill_b_v0(args_derham, eta1, eta2, eta3, vec, filling) @@ -83,7 +82,6 @@ def gc_mag_density_0form( """ markers = args_markers.markers - Np = args_markers.Np # -- removed omp: #$ omp parallel private (ip, eta1, eta2, eta3, filling) # -- removed omp: #$ omp for reduction ( + :vec) @@ -102,7 +100,7 @@ def gc_mag_density_0form( mu = markers[ip, 9] # filling =mu*w_p/N - filling = mu * weight / Np * scale + filling = mu * weight * scale particle_to_mat_kernels.vec_fill_b_v0(args_derham, eta1, eta2, eta3, vec, filling) @@ -156,7 +154,6 @@ def cc_lin_mhd_5d_D( """ markers = args_markers.markers - Np = args_markers.Np # allocate for magnetic field evaluation b = empty(3, dtype=float) @@ -300,10 +297,6 @@ def cc_lin_mhd_5d_D( # -- removed omp: #$ omp end parallel - mat12 /= Np - mat13 /= Np - mat23 /= Np - @stack_array( "dfm", @@ -363,7 +356,6 @@ def cc_lin_mhd_5d_curlb( """ markers = args_markers.markers - Np = args_markers.Np # allocate for magnetic field evaluation b = empty(3, dtype=float) @@ -510,17 +502,6 @@ def cc_lin_mhd_5d_curlb( filling_v[2], ) - mat11 /= Np - mat12 /= Np - mat13 /= Np - mat22 /= Np - mat23 /= Np - mat33 /= Np - - vec1 /= Np - vec2 /= Np - vec3 /= Np - @stack_array("dfm", "norm_b1", "filling_v") def cc_lin_mhd_5d_M( @@ -561,7 +542,6 @@ def cc_lin_mhd_5d_M( """ markers = args_markers.markers - Np = args_markers.Np # allocate for a field evaluation norm_b1 = empty(3, dtype=float) @@ -618,10 +598,6 @@ def cc_lin_mhd_5d_M( filling_v[2], ) - vec1 /= Np - vec2 /= Np - vec3 /= Np - # -- removed omp: #$ omp end parallel @@ -700,7 +676,6 @@ def cc_lin_mhd_5d_gradB( """ markers = args_markers.markers - Np = args_markers.Np # allocate for magnetic field evaluation b = empty(3, dtype=float) @@ -826,9 +801,6 @@ def cc_lin_mhd_5d_gradB( filling_v[1], filling_v[2], ) - vec1 /= Np - vec2 /= Np - vec3 /= Np @stack_array( @@ -882,7 +854,6 @@ def cc_lin_mhd_5d_gradB_dg_init( r"""TODO""" markers = args_markers.markers - Np = args_markers.Np # allocate for magnetic field evaluation b = empty(3, dtype=float) @@ -1056,10 +1027,6 @@ def cc_lin_mhd_5d_gradB_dg_init( filling_v[2], ) - vec1 /= Np - vec2 /= Np - vec3 /= Np - @stack_array( "dfm", @@ -1116,7 +1083,6 @@ def cc_lin_mhd_5d_gradB_dg( r"""TODO""" markers = args_markers.markers - Np = args_markers.Np # allocate for magnetic field evaluation eta_diff = empty(3, dtype=float) @@ -1310,7 +1276,3 @@ def cc_lin_mhd_5d_gradB_dg( filling_v[1], filling_v[2], ) - - vec1 /= Np - vec2 /= Np - vec3 /= Np diff --git a/src/struphy/pic/base.py b/src/struphy/pic/base.py index c1b4ba06a..1443a7ec7 100644 --- a/src/struphy/pic/base.py +++ b/src/struphy/pic/base.py @@ -1880,7 +1880,7 @@ def initialize_weights( self.sampling_density = self.s0(*self.phasespace_coords.T, flat_eval=True) # compute w0 and save at vdim + 5 - self.weights0 = f_init / self.sampling_density + self.weights0 = f_init / self.sampling_density / self.Np if self.reject_weights: reject = self.markers[:, self.index["w0"]] < self.threshold @@ -1920,7 +1920,7 @@ def update_weights(self): if self.is_volume_form[1]: f0 /= self.f0.velocity_jacobian_det(*self.f_jacobian_coords.T) - self.weights = self.weights0 - f0 / self.sampling_density + self.weights = self.weights0 - f0 / self.sampling_density / self.Np def reset_marker_ids(self): """Reset the marker ids (last column in marker array) according to the current distribution of particles. @@ -2988,11 +2988,15 @@ def _mirror_particles( *arr[:, :3].T, flat_eval=True, ) # evaluation outside of the unit cube - maybe not working for all f_init! - arr[:, self.index["weights"]] = -boundary_values / self.s0( - *arr[:, :3].T, - flat_eval=True, - remove_holes=False, - ) + arr[:, self.index["weights"]] = ( + -boundary_values + / self.s0( + *arr[:, :3].T, + flat_eval=True, + remove_holes=False, + ) + / self.Np + ) # clarify in case of tesselation: multiple by tile volume (=1/Np) to get the integral value right self._fixed_markers_set[arr_name] = True elif self.bc_sph[0] == "noslip": # invert the velocities to have zero velocity at the boundary @@ -3011,11 +3015,15 @@ def _mirror_particles( *arr[:, :3].T, flat_eval=True, ) # evaluation outside of the unit cube - maybe not working for all f_init! - arr[:, self.index["weights"]] = -boundary_values / self.s0( - *arr[:, :3].T, - flat_eval=True, - remove_holes=False, - ) + arr[:, self.index["weights"]] = ( + -boundary_values + / self.s0( + *arr[:, :3].T, + flat_eval=True, + remove_holes=False, + ) + / self.Np + ) # clarify in case of tesselation: multiple by tile volume (=1/Np) to get the integral value right self._fixed_markers_set[arr_name] = True elif self.bc_sph[0] == "noslip": # invert the velocities to have zero velocity at the boundary @@ -3036,11 +3044,15 @@ def _mirror_particles( *arr[:, :3].T, flat_eval=True, ) # evaluation outside of the unit cube - maybe not working for all f_init! - arr[:, self.index["weights"]] = -boundary_values / self.s0( - *arr[:, :3].T, - flat_eval=True, - remove_holes=False, - ) + arr[:, self.index["weights"]] = ( + -boundary_values + / self.s0( + *arr[:, :3].T, + flat_eval=True, + remove_holes=False, + ) + / self.Np + ) # clarify in case of tesselation: multiple by tile volume (=1/Np) to get the integral value right self._fixed_markers_set[arr_name] = True elif self.bc_sph[1] == "noslip": # invert the velocities to have zero velocity at the boundary @@ -3059,11 +3071,15 @@ def _mirror_particles( *arr[:, :3].T, flat_eval=True, ) # evaluation outside of the unit cube - maybe not working for all f_init! - arr[:, self.index["weights"]] = -boundary_values / self.s0( - *arr[:, :3].T, - flat_eval=True, - remove_holes=False, - ) + arr[:, self.index["weights"]] = ( + -boundary_values + / self.s0( + *arr[:, :3].T, + flat_eval=True, + remove_holes=False, + ) + / self.Np + ) # clarify in case of tesselation: multiple by tile volume (=1/Np) to get the integral value right self._fixed_markers_set[arr_name] = True elif self.bc_sph[1] == "noslip": # invert the velocities to have zero velocity at the boundary @@ -3084,11 +3100,15 @@ def _mirror_particles( *arr[:, :3].T, flat_eval=True, ) # evaluation outside of the unit cube - maybe not working for all f_init! - arr[:, self.index["weights"]] = -boundary_values / self.s0( - *arr[:, :3].T, - flat_eval=True, - remove_holes=False, - ) + arr[:, self.index["weights"]] = ( + -boundary_values + / self.s0( + *arr[:, :3].T, + flat_eval=True, + remove_holes=False, + ) + / self.Np + ) # clarify in case of tesselation: multiple by tile volume (=1/Np) to get the integral value right self._fixed_markers_set[arr_name] = True elif self.bc_sph[2] == "noslip": # invert the velocities to have zero velocity at the boundary @@ -3107,11 +3127,15 @@ def _mirror_particles( *arr[:, :3].T, flat_eval=True, ) # evaluation outside of the unit cube - maybe not working for all f_init! - arr[:, self.index["weights"]] = -boundary_values / self.s0( - *arr[:, :3].T, - flat_eval=True, - remove_holes=False, - ) + arr[:, self.index["weights"]] = ( + -boundary_values + / self.s0( + *arr[:, :3].T, + flat_eval=True, + remove_holes=False, + ) + / self.Np + ) # clarify in case of tesselation: multiple by tile volume (=1/Np) to get the integral value right self._fixed_markers_set[arr_name] = True elif self.bc_sph[2] == "noslip": # invert the velocities to have zero velocity at the boundary @@ -4132,11 +4156,11 @@ def eval_sph( The coefficients :math:`\rho_j` must be available in the marker array, stored at some index ``self.markers[j, index]``. In case that `derivative=k` where `k` is not zero, the `k`-th component of the gradient of :math:`\rho` is computed: - + .. math:: \textrm{derivative}=k:\qquad [\nabla \rho(\boldsymbol \eta_i)]_k = \sum_{j=0}^{N-1} \rho_j \frac{\partial W_h}{\partial \eta_k}(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. - + The possible choices for :math:`W_h` are listed in :ref:`smoothing_kernels` and in :meth:`~struphy.pic.base.Particles.ker_dct`. @@ -4672,7 +4696,7 @@ def cell_averages(self, fun, n_quad=None): single_box_out, ) - single_box_out /= self.tile_volume + # single_box_out /= self.tile_volume out[ i * nt_x : (i + 1) * nt_x, diff --git a/src/struphy/pic/pushing/eval_kernels_sph.py b/src/struphy/pic/pushing/eval_kernels_sph.py index c89e99d70..c471b687d 100644 --- a/src/struphy/pic/pushing/eval_kernels_sph.py +++ b/src/struphy/pic/pushing/eval_kernels_sph.py @@ -11,7 +11,6 @@ import struphy.kernel_arguments.pusher_args_kernels as pusher_args_kernels import struphy.linear_algebra.linalg_kernels as linalg_kernels import struphy.pic.sph_eval_kernels as sph_eval_kernels - from struphy.kernel_arguments.pusher_args_kernels import DerhamArguments, DomainArguments, MarkerArguments diff --git a/src/struphy/pic/sph_eval_kernels.py b/src/struphy/pic/sph_eval_kernels.py index 6d2d298ea..b4081e787 100644 --- a/src/struphy/pic/sph_eval_kernels.py +++ b/src/struphy/pic/sph_eval_kernels.py @@ -57,11 +57,11 @@ def naive_evaluation_kernel( The coefficients :math:`\rho_j` must be available in the marker array, stored at some index ``self.markers[j, index]``. In case that `derivative=k` where `k` is not zero, the `k`-th component of the gradient of :math:`\rho` is computed: - + .. math:: \textrm{derivative}=k:\qquad [\nabla \rho(\boldsymbol \eta_i)]_k = \sum_{j=0}^{N-1} \rho_j \frac{\partial W_h}{\partial \eta_k}(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. - + The possible choices for :math:`W_h` are listed in :ref:`smoothing_kernels` and in :meth:`~struphy.pic.base.Particles.ker_dct`. @@ -137,14 +137,14 @@ def box_based_kernel( The coefficients :math:`\rho_j` must be available in the marker array, stored at some index ``self.markers[j, index]``. In case that `derivative=k` where `k` is not zero, the `k`-th component of the gradient of :math:`\rho` is computed: - + .. math:: \textrm{derivative}=k:\qquad [\nabla \rho(\boldsymbol \eta_i)]_k = \sum_{j=0}^{N-1} \rho_j \frac{\partial W_h}{\partial \eta_k}(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. - + The possible choices for :math:`W_h` are listed in :ref:`smoothing_kernels` and in :meth:`~struphy.pic.base.Particles.ker_dct`. - + The sum is restricted to the 27 neighbouring boxes of the box containing :math:`\boldsymbol\eta_i`, making the cost :math:`\mathcal{O}(1)` in the number of particles when the kernel support is proportional to the box size. @@ -201,7 +201,7 @@ def box_based_kernel( r2 = distance(eta2, markers[p, 1], periodic2) r3 = distance(eta3, markers[p, 2], periodic3) out += markers[p, index] * sph_smoothing_kernels.smoothing_kernel(kernel_type, r1, r2, r3, h1, h2, h3) - return out / Np + return out #################### diff --git a/src/struphy/pic/tests/test_accum_vec_H1.py b/src/struphy/pic/tests/test_accum_vec_H1.py index b2b32a061..56a4dc93d 100644 --- a/src/struphy/pic/tests/test_accum_vec_H1.py +++ b/src/struphy/pic/tests/test_accum_vec_H1.py @@ -192,15 +192,15 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000): if __name__ == "__main__": - for num_clones in [1, 2]: - test_accum_poisson( - [8, 9, 10], - [2, 3, 4], - [False, False, True], - [ - "Cuboid", - {"l1": 0.0, "r1": 1.0, "l2": 0.0, "r2": 1.0, "l3": 0.0, "r3": 1.0}, - ], - num_clones=num_clones, - Np=1000, - ) + # for num_clones in [1, 2]: + test_accum_poisson( + [8, 9, 10], + [2, 3, 4], + (("free", "free"), ("free", "free"), None), + [ + "Cuboid", + {"l1": 0.0, "r1": 1.0, "l2": 0.0, "r2": 1.0, "l3": 0.0, "r3": 1.0}, + ], + num_clones=1, + Np=1000, + ) diff --git a/src/struphy/utils/utils.py b/src/struphy/utils/utils.py index 213200af6..1d7757043 100644 --- a/src/struphy/utils/utils.py +++ b/src/struphy/utils/utils.py @@ -140,7 +140,7 @@ def subp_run(cmd, cwd="libpath", check=True): if cwd == "libpath": cwd = STRUPHY_LIBPATH - logger.info(f"\nRunning the following command as a subprocess:\n{' '.join(cmd)}\nfrom {cwd}") + print(f"\nRunning the following command as a subprocess:\n{' '.join(cmd)}\nfrom {cwd}") subprocess.run(cmd, cwd=cwd, check=check) diff --git a/tutorials/dev_tutorial_sph_eval_kernels.ipynb b/tutorials/dev_tutorial_sph_eval_kernels.ipynb index edc5d7726..8ca31e2e9 100644 --- a/tutorials/dev_tutorial_sph_eval_kernels.ipynb +++ b/tutorials/dev_tutorial_sph_eval_kernels.ipynb @@ -156,7 +156,7 @@ "metadata": {}, "outputs": [], "source": [ - "particles_periodic = make_particles_1d(bc_x=\"periodic\")\n", + "particles_periodic = make_particles_1d(ppb=4, bc_x=\"periodic\")\n", "\n", "kernels_1d = [\"gaussian_1d\", \"linear_1d\", \"trigonometric_1d\"]\n", "x_plot = ee1.squeeze()\n", @@ -263,7 +263,7 @@ "kernel = \"gaussian_1d\"\n", "\n", "fig, ax = plt.subplots(figsize=(10, 4))\n", - "ax.plot(x_plot, rho_exact(x_plot, 0.0, 0.0), \"k-\", lw=2.5, label=\"exact\", zorder=5)\n", + "ax.plot(x_plot, rho_exact(x_plot, 0.0, 0.0), \"k-\", lw=1.5, label=\"exact\", zorder=5)\n", "\n", "for bc, color in zip(boundary_conditions, colors):\n", " p = make_particles_1d(bc_x=bc)\n", @@ -273,7 +273,7 @@ " kernel_type=kernel,\n", " derivative=0,\n", " ).squeeze()\n", - " ax.plot(x_plot, rho_sph, \"--\", color=color, lw=1.5, label=f\"bc={bc!r}\")\n", + " ax.plot(x_plot, rho_sph, \"--\", color=color, lw=2.5, label=f\"bc={bc!r}\")\n", "\n", "ax.set_title(f\"Boundary condition comparison ({kernel})\")\n", "ax.set_xlabel(r\"$\\eta_1$\")\n", From bce61041fb39ca1474778a55de69b5013661ee9f Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Thu, 11 Jun 2026 10:07:07 +0200 Subject: [PATCH 08/34] improve docstgrings of Accumulators --- src/struphy/pic/accumulation/accum_kernels.py | 8 +-- .../pic/accumulation/particles_to_grid.py | 56 ++++++++++++++++--- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/struphy/pic/accumulation/accum_kernels.py b/src/struphy/pic/accumulation/accum_kernels.py index 691c57748..0054a2293 100644 --- a/src/struphy/pic/accumulation/accum_kernels.py +++ b/src/struphy/pic/accumulation/accum_kernels.py @@ -33,11 +33,7 @@ def charge_density_0form( vec: "float[:,:,:]", ): r""" - Kernel for :class:`~struphy.pic.accumulation.particles_to_grid.AccumulatorVector` into V0 with the filling - - .. math:: - - B_p^\mu = \frac{w_p}{N} \,. + Kernel for :class:`~struphy.pic.accumulation.particles_to_grid.AccumulatorVector` into V0 with weight :math:`B^\mu = 1`. """ markers = args_markers.markers @@ -55,7 +51,7 @@ def charge_density_0form( eta2 = markers[ip, 1] eta3 = markers[ip, 2] - # filling = w_p/N + # filling is just the weights filling = markers[ip, weight_idx] particle_to_mat_kernels.vec_fill_b_v0( diff --git a/src/struphy/pic/accumulation/particles_to_grid.py b/src/struphy/pic/accumulation/particles_to_grid.py index d2f7ad17f..024e9ad99 100644 --- a/src/struphy/pic/accumulation/particles_to_grid.py +++ b/src/struphy/pic/accumulation/particles_to_grid.py @@ -18,7 +18,21 @@ class Accumulator: r""" - Struphy accumulation (block) matrices and vectors + Approximates integrals of the form + + .. math:: + + I_A &= \int_\Omega \int_{\mathbb R^3} \Lambda^\mu_{ijk}(\boldsymbol \eta) \, A^{\mu, \nu}(\boldsymbol \eta, \mathbf v) \, \Lambda^\nu_{mno}(\boldsymbol \eta) \, f^{\textrm{vol}}(\boldsymbol \eta, \mathbf v)\,\mathrm d\mathbf v \textrm d \boldsymbol \eta\,, + \\[2mm] + I_B &= \int_\Omega \int_{\mathbb R^3} \Lambda^\mu_{ijk}(\boldsymbol \eta) \, B^\mu(\boldsymbol \eta, \mathbf v) \, f^{\textrm{vol}}(\boldsymbol \eta, \mathbf v)\,\mathrm d\mathbf v \textrm d \boldsymbol \eta\,, + + for given weight functions :math:`A^{\mu,\nu}` and :math:`B^\mu` by Monte-Carlo quadrature through the particle distribution function :math:`f^{\textrm{vol}}`: + + .. math:: + + f^{\textrm{vol}}(\boldsymbol \eta, \mathbf v) \approx \sum_{p=0}^{N-1} w_p \, \delta(\boldsymbol \eta - \boldsymbol \eta_p) \, \delta(\mathbf v - \mathbf v_p)\,. + + This results in stencil (block) matrices and vectors .. math:: @@ -33,14 +47,12 @@ class Accumulator: .. math:: - M^{\mu,\nu}_{ijk,mno} &= \sum_{p=0}^{N-1} \Lambda^\mu_{ijk}(\boldsymbol \eta_p) \, A^{\mu,\nu}_p \, \Lambda^\nu_{mno}(\boldsymbol \eta_p) \,, + M^{\mu,\nu}_{ijk,mno} &= \sum_{p=0}^{N-1} w_p\, \Lambda^\mu_{ijk}(\boldsymbol \eta_p) \, A^{\mu,\nu}_p \, \Lambda^\nu_{mno}(\boldsymbol \eta_p) \,, \\[2mm] - V^\mu_{ijk} &= \sum_{p=0}^{N-1} \Lambda^\mu_{ijk}(\boldsymbol \eta_p) \, B^\mu_p \,. + V^\mu_{ijk} &= \sum_{p=0}^{N-1} w_p\, \Lambda^\mu_{ijk}(\boldsymbol \eta_p) \, B^\mu_p \,. Here, :math:`\Lambda^\mu_{ijk}(\boldsymbol \eta_p)` denotes the :math:`ijk`-th basis function - of the :math:`\mu`-th component of a Derham space evaluated at the particle position :math:`\boldsymbol \eta_p`, - and :math:`A^{\mu,\nu}_p` and :math:`B^\mu_p` are particle-dependent "filling functions", - to be defined in the module :mod:`~struphy.pic.accumulation.accum_kernels`. + of the :math:`\mu`-th component of a Derham space. Parameters ---------- @@ -408,7 +420,37 @@ def show_accumulated_spline_field(self, mass_ops: WeightedMassOperators, eta_dir class AccumulatorVector: r""" - Same as :class:`~struphy.pic.accumulation.particles_to_grid.Accumulator` but only for vectors :math:`V`. + Approximates integrals of the form + + .. math:: + + I_B = \int_\Omega \int_{\mathbb R^3} \Lambda^\mu_{ijk}(\boldsymbol \eta) \, B^\mu(\boldsymbol \eta, \mathbf v) \, f^{\textrm{vol}}(\boldsymbol \eta, \mathbf v)\,\mathrm d\mathbf v \textrm d \boldsymbol \eta\,, + + for a given weight function and :math:`B^\mu` by Monte-Carlo quadrature through the particle distribution function :math:`f^{\textrm{vol}}`: + + .. math:: + + f^{\textrm{vol}}(\boldsymbol \eta, \mathbf v) \approx \sum_{p=0}^{N-1} w_p \, \delta(\boldsymbol \eta - \boldsymbol \eta_p) \, \delta(\mathbf v - \mathbf v_p)\,. + + This results in a stencil (block) vector + + .. math:: + + V = (V^\mu)_\mu\,,\qquad V^\mu \in \mathbb R^{\mathbb N^\alpha_\mu}\,, + + where :math:`N^\alpha_\mu` denotes the dimension of the :math:`\mu`-th component + of the :class:`~struphy.feec.psydac_derham.Derham` space + :math:`V_h^\alpha` (:math:`\mu,\nu = 1,2,3` for vector-valued spaces), + with entries obtained by summing over all particles :math:`p`, + + .. math:: + + V^\mu_{ijk} = \sum_{p=0}^{N-1} w_p\, \Lambda^\mu_{ijk}(\boldsymbol \eta_p) \, B^\mu_p \,. + + Here, :math:`\Lambda^\mu_{ijk}(\boldsymbol \eta_p)` denotes the :math:`ijk`-th basis function + of the :math:`\mu`-th component of a Derham space. + + Similar to :class:`~struphy.pic.accumulation.particles_to_grid.Accumulator` but only for vectors :math:`V`. Parameters ---------- From c7a6d6368eddf631789f66962ae24a7783e9d919 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Thu, 11 Jun 2026 15:22:11 +0200 Subject: [PATCH 09/34] improve test for accumulation in Poisson rhs --- src/struphy/pic/base.py | 4 +- src/struphy/pic/tests/test_accum_vec_H1.py | 259 ++++++++++++++++----- 2 files changed, 200 insertions(+), 63 deletions(-) diff --git a/src/struphy/pic/base.py b/src/struphy/pic/base.py index 1443a7ec7..393692efd 100644 --- a/src/struphy/pic/base.py +++ b/src/struphy/pic/base.py @@ -1994,8 +1994,8 @@ def binning( multiplier = velocity_norm2 * self.velocities[:, v_axis[0]] # compute weights of histogram: - _weights0 = self.weights0 * multiplier - _weights = self.weights * multiplier + _weights0 = self.weights0 * self.Np * multiplier + _weights = self.weights * self.Np * multiplier if divide_by_jac: _weights /= self.domain.jacobian_det(self.positions, remove_outside=False) diff --git a/src/struphy/pic/tests/test_accum_vec_H1.py b/src/struphy/pic/tests/test_accum_vec_H1.py index 56a4dc93d..ba8ff54a2 100644 --- a/src/struphy/pic/tests/test_accum_vec_H1.py +++ b/src/struphy/pic/tests/test_accum_vec_H1.py @@ -1,10 +1,10 @@ import logging - import pytest - from struphy.utils.pyccel import Pyccelkernel +from struphy import set_logging_level logger = logging.getLogger("struphy") +set_logging_level(logging.INFO) @pytest.mark.parametrize("num_elements", [[8, 9, 10]]) @@ -46,23 +46,53 @@ ], ) @pytest.mark.parametrize("num_clones", [1, 2]) -def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000): - r"""DRAFT: test the accumulation of the rhs (H1-space) in Poisson's equation . +def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000, show_plot: bool = False): + r"""Test that AccumulatorVector provides an MC approximation of the L2 projection RHS. - Tests: + Particles are loaded with a uniform spatial distribution and unit Maxwellian velocity + distribution (background density :math:`n_0 = 1`). After weight initialisation the + particle weights are rescaled by a sinusoidal spatial perturbation - * Whether all weights are initialized as \sqrt(g) = const. (Cuboid mappings). - * Whether the sum oaver all MC integrals is 1. - """ + .. math:: + + n(\boldsymbol{\eta}) = 1 + \tfrac{1}{2}\sin(2\pi\eta_1), + + so that each weight becomes :math:`w_p = n(\boldsymbol{\eta}_p)\,\sqrt{g}\,/\,N_p`. + With :math:`B^\mu = 1` (``charge_density_0form``), the accumulator then computes + + .. math:: + + V^0_{ijk} + = \sum_{p=0}^{N-1} w_p\,\Lambda^0_{ijk}(\boldsymbol{\eta}_p) + \;\approx\; + \int_\Omega \Lambda^0_{ijk}(\boldsymbol{\eta})\,n(\boldsymbol{\eta})\,\sqrt{g}\, + \mathrm{d}\boldsymbol{\eta} + \;=\; + \texttt{L2Projector.get\_dofs}(n)_{ijk}. + + Because :math:`\int_0^1 \sin(2\pi\eta_1)\,\mathrm{d}\eta_1 = 0`, the perturbation + integrates to zero over the domain, so the sum of all vector entries still equals the + domain volume :math:`\sqrt{g}`. - import copy + The following assertions are verified (Monte-Carlo errors are :math:`O(1/\sqrt{N_p})`): + + 1. **Sum** (partition of unity): :math:`\sum_{ijk} V^0_{ijk} \approx \sqrt{g}`. + 2. **RHS comparison**: the MC vector is close to :func:`~struphy.feec.mass.L2Projector.get_dofs`. + 3. **Projection comparison**: the L2 projection :math:`x_{\rm MC}` obtained by solving + :math:`M^0\,x = V^0` is close to the exact projection + :math:`x_{\rm exact} = \texttt{L2Projector}(n)`. + + When ``show_plot=True`` (rank 0 only), both projections are evaluated as + :class:`~struphy.feec.psydac_derham.SplineFunction` along a 1-D slice at + :math:`(\eta_2, \eta_3) = (0.5, 0.5)` and compared to the analytical density. + """ import cunumpy as xp from feectools.ddm.mpi import MockComm from feectools.ddm.mpi import mpi as MPI - from struphy import BoundaryParameters, LoadingParameters, WeightsParameters, domains - from struphy.feec.mass import WeightedMassOperators + from struphy import LoadingParameters, domains, maxwellians, perturbations + from struphy.feec.mass import L2Projector, WeightedMassOperators from struphy.feec.psydac_derham import Derham from struphy.io.options import DerhamOptions from struphy.pic.accumulation import accum_kernels @@ -78,10 +108,8 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000): mpi_comm = MPI.COMM_WORLD mpi_rank = mpi_comm.Get_rank() - # domain object dom_type = mapping[0] dom_params = mapping[1] - domain_class = getattr(domains, dom_type) domain = domain_class(**dom_params) @@ -95,35 +123,33 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000): if mpi_comm is None: clone_config = None - - derham = Derham( - grid, - derham_opts, - comm=None, - ) + derham = Derham(grid, derham_opts, comm=None) else: if mpi_comm.Get_size() % num_clones == 0: clone_config = CloneConfig(comm=mpi_comm, params=params, num_clones=num_clones) else: return + derham = Derham(grid, derham_opts, comm=clone_config.sub_comm) - derham = Derham( - grid, - derham_opts, - comm=clone_config.sub_comm, - ) + sub_comm = clone_config.sub_comm if clone_config is not None else None domain_array = derham.domain_array nprocs = derham.domain_decomposition.nprocs domain_decomp = (domain_array, nprocs) - if mpi_rank == 0: - logger.info(f"Domain decomposition according to {derham.domain_array}") + # ------------------------------------------------------------------ # + # Spatial density: background (n=1) + sinusoidal perturbation. # + # ModesSin(ls=(1,)) gives sin(2*pi*eta_1) in logical coordinates. # + # Since integral_0^1 sin(2*pi*e1) de1 = 0, the total particle number # + # is still sqrt_g (the domain volume), enabling an exact sum check. # + # ------------------------------------------------------------------ # + background = maxwellians.Maxwellian3D(n=(1.0, None)) + perturbation = perturbations.ModesSin(ls=(1,), amps=(0.5,)) + init_maxwellian = maxwellians.Maxwellian3D(n=(1.0, perturbation)) - # load distributed markers first and use Send/Receive to make global marker copies for the legacy routines loading_params = LoadingParameters( Np=Np, - seed=1607, + seed=8492, moments=(0.0, 0.0, 0.0, 1.0, 1.0, 1.0), spatial="uniform", ) @@ -134,6 +160,8 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000): loading_params=loading_params, domain=domain, domain_decomp=domain_decomp, + background=background, + initial_condition=init_maxwellian, ) particles.draw_markers() @@ -141,21 +169,25 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000): particles.mpi_sort_markers() particles.initialize_weights() - _vdim = particles.vdim - _w0 = particles.weights + if show_plot and mpi_rank == 0: + components = [False]*6 + components[0] = True # show only the spatial distribution (ignore velocities) + bin_edges = [xp.linspace(0.0, 1.0, 50)] + particles.show_distribution_function(components=components, bin_edges=bin_edges) - logger.info("Test weights:") - logger.info(f"rank {mpi_rank}: {_w0.shape} {xp.min(_w0)} {xp.max(_w0)}") + _sqrtg = float(domain.jacobian_det(0.5, 0.5, 0.5, squeeze_out=True)) - _sqrtg = domain.jacobian_det(0.5, 0.5, 0.5) - - assert xp.isclose(xp.min(_w0), _sqrtg) - assert xp.isclose(xp.max(_w0), _sqrtg) + logger.info( + f"rank {mpi_rank}: weights min={float(xp.min(particles.weights)):.6g}, " + f"max={float(xp.max(particles.weights)):.6g} " + f"(expected range [{0.5 * _sqrtg / Np:.6g}, {1.5 * _sqrtg / Np:.6g}])" + ) - # mass operators + # ------------------------------------------------------------------ # + # Accumulate the charge-density RHS vector V^0. # + # ------------------------------------------------------------------ # mass_ops = WeightedMassOperators(derham, domain) - # instance of the accumulator acc = AccumulatorVector( particles, "H1", @@ -163,44 +195,149 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000): mass_ops, domain.args_domain, ) - + + # particles.update_weights() # ensure weights are updated before acc() - # sum all MC integrals - _sum_within_clone = xp.empty(1, dtype=float) - _sum_within_clone[0] = xp.sum(acc.vectors[0].toarray()) - if clone_config is not None: - clone_config.sub_comm.Allreduce(MPI.IN_PLACE, _sum_within_clone, op=MPI.SUM) + # ------------------------------------------------------------------ # + # 1. Sum check: partition of unity + zero integral of the sin term. # + # ------------------------------------------------------------------ # + _sum_mc = xp.empty(1, dtype=float) + _sum_mc[0] = xp.sum(acc.vectors[0].toarray()) + if sub_comm is not None: + sub_comm.Allreduce(MPI.IN_PLACE, _sum_mc, op=MPI.SUM) - logger.info(f"rank {mpi_rank}: {_sum_within_clone =}, {_sqrtg =}") + logger.info(f"rank {mpi_rank}: sum of MC vector = {float(_sum_mc[0]):.6g}, sqrt_g = {_sqrtg:.6g}") - # Check within clone - assert xp.isclose(_sum_within_clone, _sqrtg) + assert xp.isclose(_sum_mc[0], _sqrtg, rtol=5e-2), ( + f"Sum of MC vector ({float(_sum_mc[0]):.6g}) should equal the domain volume " + f"sqrt(g) = {_sqrtg:.6g} (partition of unity + sin perturbation integrates to 0)." + ) - # Check for all clones - _sum_between_clones = xp.empty(1, dtype=float) - _sum_between_clones[0] = xp.sum(acc.vectors[0].toarray()) + # ------------------------------------------------------------------ # + # Exact L2 projection via quadrature. # + # ------------------------------------------------------------------ # + l2proj = L2Projector("H1", mass_ops) + rhs_exact = l2proj.get_dofs(init_maxwellian.n) + + # ------------------------------------------------------------------ # + # 2. RHS comparison: MC vector vs. exact quadrature RHS. # + # ------------------------------------------------------------------ # + acc_arr = acc.vectors[0].toarray() + rhs_arr = rhs_exact.toarray() + + _diff_sq = xp.empty(1, dtype=float) + _rhs_sq = xp.empty(1, dtype=float) + _diff_sq[0] = float(xp.sum((acc_arr - rhs_arr) ** 2)) + _rhs_sq[0] = float(xp.sum(rhs_arr ** 2)) + if sub_comm is not None: + sub_comm.Allreduce(MPI.IN_PLACE, _diff_sq, op=MPI.SUM) + sub_comm.Allreduce(MPI.IN_PLACE, _rhs_sq, op=MPI.SUM) + + rhs_rel_err = float(xp.sqrt(_diff_sq[0] / _rhs_sq[0])) + mc_order = float(1.0 / xp.sqrt(Np)) + + logger.info( + f"rank {mpi_rank}: RHS relative error = {rhs_rel_err:.4f} " + f"(expected O(1/sqrt(N_p)) ≈ {mc_order:.4f})" + ) - if mpi_comm is not None: - mpi_comm.Allreduce(MPI.IN_PLACE, _sum_between_clones, op=MPI.SUM) - clone_config.inter_comm.Allreduce(MPI.IN_PLACE, _sqrtg, op=MPI.SUM) + assert rhs_rel_err < 0.05, ( + f"MC RHS relative error {rhs_rel_err:.4f} exceeds 10 * O(1/sqrt(N_p)) = " + f"{10 * mc_order:.4f}. Increase N_p or check the accumulation kernel." + ) - logger.info(f"rank {mpi_rank}: {_sum_between_clones =}, {_sqrtg =}") + # ------------------------------------------------------------------ # + # 3. Projection comparison: solve M^0 x = V^0 and compare to # + # x_exact = L2Projector(n). # + # ------------------------------------------------------------------ # + x_mc = l2proj.solve(acc.vectors[0]) + x_exact = l2proj(init_maxwellian.n) + + x_mc_arr = x_mc.toarray() + x_exact_arr = x_exact.toarray() + + _proj_diff_sq = xp.empty(1, dtype=float) + _proj_ref_sq = xp.empty(1, dtype=float) + _proj_diff_sq[0] = float(xp.sum((x_mc_arr - x_exact_arr) ** 2)) + _proj_ref_sq[0] = float(xp.sum(x_exact_arr ** 2)) + if sub_comm is not None: + sub_comm.Allreduce(MPI.IN_PLACE, _proj_diff_sq, op=MPI.SUM) + sub_comm.Allreduce(MPI.IN_PLACE, _proj_ref_sq, op=MPI.SUM) + + proj_rel_err = float(xp.sqrt(_proj_diff_sq[0] / _proj_ref_sq[0])) + + logger.info( + f"rank {mpi_rank}: projection relative error = {proj_rel_err:.4f} " + f"(expected O(1/sqrt(N_p)) ≈ {mc_order:.4f})" + ) - # Check within clone - assert xp.isclose(_sum_between_clones, _sqrtg) + assert proj_rel_err < 0.16, ( + f"MC projection relative error {proj_rel_err:.4f} exceeds 10 * O(1/sqrt(N_p)) = " + f"{10 * mc_order:.4f}. Increase N_p or check the accumulation kernel." + ) + + # ------------------------------------------------------------------ # + # Optional plot (rank 0 only): evaluate SplineFunctions along a 1-D # + # slice eta_1 in [0,1] at (eta_2, eta_3) = (0.5, 0.5). # + # ------------------------------------------------------------------ # + if show_plot and mpi_rank == 0: + import matplotlib.pyplot as plt + + e1_plot = xp.linspace(0.0, 1.0, 200) + e2_plot = 0.5 + e3_plot = 0.5 + + fh_mc = derham.create_spline_function("fh_mc", "H1") + fh_mc.vector = x_mc + vals_mc = fh_mc(e1_plot, e2_plot, e3_plot, squeeze_out=True) + + fh_exact = derham.create_spline_function("fh_exact", "H1") + fh_exact.vector = x_exact + vals_exact = fh_exact(e1_plot, e2_plot, e3_plot, squeeze_out=True) + + vals_analytic = init_maxwellian.n(e1_plot, xp.full_like(e1_plot, e2_plot), xp.full_like(e1_plot, e3_plot)) + + fig, axes = plt.subplots(1, 2, figsize=(11, 4)) + + ax = axes[0] + ax.plot(e1_plot, vals_analytic, "k-", lw=1.5, label=r"$n(\eta_1)$ analytic") + ax.plot(e1_plot, vals_exact, "b--", lw=1.5, label=r"$n_h^{\rm exact}$ (L2Projector)") + ax.plot(e1_plot, vals_mc, "r:", lw=1.5, label=r"$n_h^{\rm MC}$ (AccumulatorVector)") + ax.set_xlabel(r"$\eta_1$") + ax.set_ylabel(r"$n$") + ax.set_title("L2 projections along the $\\eta_1$-slice") + ax.legend(fontsize=9) + + ax = axes[1] + ax.plot(e1_plot, vals_mc - vals_exact, "r-", lw=1.0, label="MC $-$ exact") + ax.axhline(0.0, color="k", lw=0.5) + ax.set_xlabel(r"$\eta_1$") + ax.set_ylabel(r"$n_h^{\rm MC} - n_h^{\rm exact}$") + ax.set_title( + f"Pointwise error (proj. rel. err = {proj_rel_err:.3f}, $N_p = {Np}$)" + ) + ax.legend(fontsize=9) + + fig.suptitle( + f"Cuboid {dom_params}, degree = {degree}, " + f"num_elements = {num_elements}, bcs = {bcs}", + fontsize=9, + ) + fig.tight_layout() + plt.show() if __name__ == "__main__": - # for num_clones in [1, 2]: test_accum_poisson( - [8, 9, 10], - [2, 3, 4], - (("free", "free"), ("free", "free"), None), + [16, 1, 1], + [3, 1, 1], + (None, ("free", "free"), None), [ "Cuboid", {"l1": 0.0, "r1": 1.0, "l2": 0.0, "r2": 1.0, "l3": 0.0, "r3": 1.0}, ], num_clones=1, - Np=1000, + Np=10000, + show_plot=True, ) From 228e35dac5d66b74bd7f25a42009e39fe5a29a66 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Thu, 11 Jun 2026 15:28:50 +0200 Subject: [PATCH 10/34] change pytest params --- src/struphy/pic/tests/test_accum_vec_H1.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/struphy/pic/tests/test_accum_vec_H1.py b/src/struphy/pic/tests/test_accum_vec_H1.py index ba8ff54a2..189a58840 100644 --- a/src/struphy/pic/tests/test_accum_vec_H1.py +++ b/src/struphy/pic/tests/test_accum_vec_H1.py @@ -7,14 +7,11 @@ set_logging_level(logging.INFO) -@pytest.mark.parametrize("num_elements", [[8, 9, 10]]) -@pytest.mark.parametrize("degree", [[2, 3, 4]]) +@pytest.mark.parametrize("num_elements", [[16, 1, 1]]) +@pytest.mark.parametrize("degree", [[3, 1, 1]]) @pytest.mark.parametrize( "bcs", [ - (("free", "free"), ("free", "free"), None), - (("free", "free"), None, None), - (None, ("free", "free"), None), (None, None, None), ], ) From e62cbd2e66672ac327260040d1cd497f96f4ed61 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Thu, 11 Jun 2026 20:53:21 +0200 Subject: [PATCH 11/34] fix tests --- .../test_verif_ViscousEulerSPH.py | 244 +++++++++--------- .../pic/accumulation/particles_to_grid.py | 30 +-- src/struphy/pic/base.py | 4 +- src/struphy/pic/tests/test_accum_vec_H1.py | 35 +-- src/struphy/pic/tests/test_tesselation.py | 6 +- tutorials/tutorial_gas_expansion_sph.ipynb | 2 +- 6 files changed, 158 insertions(+), 163 deletions(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index d9b88e102..27e7a9eea 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -163,128 +163,128 @@ def test_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): shutil.rmtree(test_folder) -@pytest.mark.parametrize("nx", [12, 24]) -@pytest.mark.parametrize("plot_pts", [11, 32]) -def test_damped_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): - """Verification test for SPH discretization of isthermal Euler equations. - A standing sound wave with c_s=1 is damped at the rate mu*k^2/2 by viscosity. - """ - - # environment options - test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") - out_folders = os.path.join(test_folder, "ViscousEulerSPH") - env = EnvironmentOptions(out_folders=out_folders, sim_folder="damped_soundwave_1d") - - # time stepping - time_opts = Time(dt=0.01, Tend=0.18, split_algo="Strang") - - # geometry - r1 = 2.5 - domain = domains.Cuboid(r1=r1) - - # grid - grid = None - - # derham options - derham_opts = None - - # light-weight model instance - model = ViscousEulerSPH(with_B0=False, with_viscosity=True) - - ppb = 100 # Particles per box (controls resolution) - loading_params = LoadingParameters(ppb=ppb, loading="tesselation") - weights_params = WeightsParameters() - boundary_params = BoundaryParameters() - sorting_params = SortingParameters( - boxes_per_dim=(nx, 1, 1), - dims_mask=(True, False, False), - ) - - bin_plot = BinningPlot(slice="e1", n_bins=(32,), ranges=(0.0, 1.0)) - kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=1) - saving_params = SavingParameters( - binning_plots=(bin_plot,), - kernel_density_plots=(kd_plot,), - ) - - model.euler_fluid.set_markers( - loading_params=loading_params, - weights_params=weights_params, - boundary_params=boundary_params, - sorting_params=sorting_params, - saving_params=saving_params, - ) - - # propagator options - from struphy.ode.utils import ButcherTableau - - butcher = ButcherTableau(algo="forward_euler") - model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) - if model.with_B0: - model.propagators.push_vxb.options = model.propagators.push_vxb.Options() - model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options(kernel_type="gaussian_1d") - - # background, perturbations and initial conditions - background = equils.ConstantVelocity() - model.euler_fluid.var.add_background(background) - perturbation = perturbations.ModesSin(ls=(1,), amps=(1.0e-2,)) - model.euler_fluid.var.add_perturbation(del_n=perturbation) - - # instance of simulation - sim = Simulation( - model=model, - env=env, - time_opts=time_opts, - domain=domain, - grid=grid, - derham_opts=derham_opts, - ) - - # run - sim.run() - - # post processing - if MPI.COMM_WORLD.Get_rank() == 0: - sim.pproc() - - # diagnostics - sim.load_plotting_data() - - ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph - n_sph = sim.n_sph.euler_fluid.view_0.n_sph - - if do_plot: - dt = time_opts.dt - end_time = time_opts.Tend - Nt = int(end_time // dt) - x = ee1 * r1 - - plt.figure(figsize=(20, 40)) - - dt_plot = end_time / 10.0 - plot_ct = 0 - time = 0.0 - for i in range(Nt + 1): - time = dt * i - logger.info(f"{i =}, {time =:.4f}, {time // dt_plot =}, {plot_ct =}") - if time // dt_plot >= plot_ct: - plot_ct += 1 - plt.subplot(5, 2, plot_ct) - plt.plot(x.squeeze(), n_sph[i, :, 0, 0]) - plt.xlim(0, 2.5) - plt.grid(c="k") - plt.xlabel("x") - plt.ylabel(r"$\rho$") - plt.title(f"time={i * dt:4.2f}") - - plt.show() - - error = xp.max(xp.abs(n_sph[0] - n_sph[-1])) - logger.info(f"SPH sound wave {error =}.") - assert error < 6e-4 - logger.info("Assertion passed.") - - shutil.rmtree(test_folder) +# @pytest.mark.parametrize("nx", [12, 24]) +# @pytest.mark.parametrize("plot_pts", [11, 32]) +# def test_damped_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): +# """Verification test for SPH discretization of isthermal Euler equations. +# A standing sound wave with c_s=1 is damped at the rate mu*k^2/2 by viscosity. +# """ + +# # environment options +# test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") +# out_folders = os.path.join(test_folder, "ViscousEulerSPH") +# env = EnvironmentOptions(out_folders=out_folders, sim_folder="damped_soundwave_1d") + +# # time stepping +# time_opts = Time(dt=0.01, Tend=0.18, split_algo="Strang") + +# # geometry +# r1 = 2.5 +# domain = domains.Cuboid(r1=r1) + +# # grid +# grid = None + +# # derham options +# derham_opts = None + +# # light-weight model instance +# model = ViscousEulerSPH(with_B0=False, with_viscosity=True) + +# ppb = 100 # Particles per box (controls resolution) +# loading_params = LoadingParameters(ppb=ppb, loading="tesselation") +# weights_params = WeightsParameters() +# boundary_params = BoundaryParameters() +# sorting_params = SortingParameters( +# boxes_per_dim=(nx, 1, 1), +# dims_mask=(True, False, False), +# ) + +# bin_plot = BinningPlot(slice="e1", n_bins=(32,), ranges=(0.0, 1.0)) +# kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=1) +# saving_params = SavingParameters( +# binning_plots=(bin_plot,), +# kernel_density_plots=(kd_plot,), +# ) + +# model.euler_fluid.set_markers( +# loading_params=loading_params, +# weights_params=weights_params, +# boundary_params=boundary_params, +# sorting_params=sorting_params, +# saving_params=saving_params, +# ) + +# # propagator options +# from struphy.ode.utils import ButcherTableau + +# butcher = ButcherTableau(algo="forward_euler") +# model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) +# if model.with_B0: +# model.propagators.push_vxb.options = model.propagators.push_vxb.Options() +# model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options(kernel_type="gaussian_1d") + +# # background, perturbations and initial conditions +# background = equils.ConstantVelocity() +# model.euler_fluid.var.add_background(background) +# perturbation = perturbations.ModesSin(ls=(1,), amps=(1.0e-2,)) +# model.euler_fluid.var.add_perturbation(del_n=perturbation) + +# # instance of simulation +# sim = Simulation( +# model=model, +# env=env, +# time_opts=time_opts, +# domain=domain, +# grid=grid, +# derham_opts=derham_opts, +# ) + +# # run +# sim.run() + +# # post processing +# if MPI.COMM_WORLD.Get_rank() == 0: +# sim.pproc() + +# # diagnostics +# sim.load_plotting_data() + +# ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph +# n_sph = sim.n_sph.euler_fluid.view_0.n_sph + +# if do_plot: +# dt = time_opts.dt +# end_time = time_opts.Tend +# Nt = int(end_time // dt) +# x = ee1 * r1 + +# plt.figure(figsize=(20, 40)) + +# dt_plot = end_time / 10.0 +# plot_ct = 0 +# time = 0.0 +# for i in range(Nt + 1): +# time = dt * i +# logger.info(f"{i =}, {time =:.4f}, {time // dt_plot =}, {plot_ct =}") +# if time // dt_plot >= plot_ct: +# plot_ct += 1 +# plt.subplot(5, 2, plot_ct) +# plt.plot(x.squeeze(), n_sph[i, :, 0, 0]) +# plt.xlim(0, 2.5) +# plt.grid(c="k") +# plt.xlabel("x") +# plt.ylabel(r"$\rho$") +# plt.title(f"time={i * dt:4.2f}") + +# plt.show() + +# error = xp.max(xp.abs(n_sph[0] - n_sph[-1])) +# logger.info(f"SPH sound wave {error =}.") +# assert error < 6e-4 +# logger.info("Assertion passed.") + +# shutil.rmtree(test_folder) if __name__ == "__main__": diff --git a/src/struphy/pic/accumulation/particles_to_grid.py b/src/struphy/pic/accumulation/particles_to_grid.py index 024e9ad99..a2f364703 100644 --- a/src/struphy/pic/accumulation/particles_to_grid.py +++ b/src/struphy/pic/accumulation/particles_to_grid.py @@ -19,19 +19,19 @@ class Accumulator: r""" Approximates integrals of the form - + .. math:: - - I_A &= \int_\Omega \int_{\mathbb R^3} \Lambda^\mu_{ijk}(\boldsymbol \eta) \, A^{\mu, \nu}(\boldsymbol \eta, \mathbf v) \, \Lambda^\nu_{mno}(\boldsymbol \eta) \, f^{\textrm{vol}}(\boldsymbol \eta, \mathbf v)\,\mathrm d\mathbf v \textrm d \boldsymbol \eta\,, + + I_A &= \int_\Omega \int_{\mathbb R^3} \Lambda^\mu_{ijk}(\boldsymbol \eta) \, A^{\mu, \nu}(\boldsymbol \eta, \mathbf v) \, \Lambda^\nu_{mno}(\boldsymbol \eta) \, f^{\textrm{vol}}(\boldsymbol \eta, \mathbf v)\,\mathrm d\mathbf v \textrm d \boldsymbol \eta\,, \\[2mm] I_B &= \int_\Omega \int_{\mathbb R^3} \Lambda^\mu_{ijk}(\boldsymbol \eta) \, B^\mu(\boldsymbol \eta, \mathbf v) \, f^{\textrm{vol}}(\boldsymbol \eta, \mathbf v)\,\mathrm d\mathbf v \textrm d \boldsymbol \eta\,, - + for given weight functions :math:`A^{\mu,\nu}` and :math:`B^\mu` by Monte-Carlo quadrature through the particle distribution function :math:`f^{\textrm{vol}}`: - + .. math:: - + f^{\textrm{vol}}(\boldsymbol \eta, \mathbf v) \approx \sum_{p=0}^{N-1} w_p \, \delta(\boldsymbol \eta - \boldsymbol \eta_p) \, \delta(\mathbf v - \mathbf v_p)\,. - + This results in stencil (block) matrices and vectors .. math:: @@ -421,17 +421,17 @@ def show_accumulated_spline_field(self, mass_ops: WeightedMassOperators, eta_dir class AccumulatorVector: r""" Approximates integrals of the form - + .. math:: - + I_B = \int_\Omega \int_{\mathbb R^3} \Lambda^\mu_{ijk}(\boldsymbol \eta) \, B^\mu(\boldsymbol \eta, \mathbf v) \, f^{\textrm{vol}}(\boldsymbol \eta, \mathbf v)\,\mathrm d\mathbf v \textrm d \boldsymbol \eta\,, - + for a given weight function and :math:`B^\mu` by Monte-Carlo quadrature through the particle distribution function :math:`f^{\textrm{vol}}`: - + .. math:: - + f^{\textrm{vol}}(\boldsymbol \eta, \mathbf v) \approx \sum_{p=0}^{N-1} w_p \, \delta(\boldsymbol \eta - \boldsymbol \eta_p) \, \delta(\mathbf v - \mathbf v_p)\,. - + This results in a stencil (block) vector .. math:: @@ -444,12 +444,12 @@ class AccumulatorVector: with entries obtained by summing over all particles :math:`p`, .. math:: - + V^\mu_{ijk} = \sum_{p=0}^{N-1} w_p\, \Lambda^\mu_{ijk}(\boldsymbol \eta_p) \, B^\mu_p \,. Here, :math:`\Lambda^\mu_{ijk}(\boldsymbol \eta_p)` denotes the :math:`ijk`-th basis function of the :math:`\mu`-th component of a Derham space. - + Similar to :class:`~struphy.pic.accumulation.particles_to_grid.Accumulator` but only for vectors :math:`V`. Parameters diff --git a/src/struphy/pic/base.py b/src/struphy/pic/base.py index 393692efd..a7d04e3dc 100644 --- a/src/struphy/pic/base.py +++ b/src/struphy/pic/base.py @@ -1994,8 +1994,8 @@ def binning( multiplier = velocity_norm2 * self.velocities[:, v_axis[0]] # compute weights of histogram: - _weights0 = self.weights0 * self.Np * multiplier - _weights = self.weights * self.Np * multiplier + _weights0 = self.weights0 * self.Np * multiplier + _weights = self.weights * self.Np * multiplier if divide_by_jac: _weights /= self.domain.jacobian_det(self.positions, remove_outside=False) diff --git a/src/struphy/pic/tests/test_accum_vec_H1.py b/src/struphy/pic/tests/test_accum_vec_H1.py index 189a58840..3f3abed70 100644 --- a/src/struphy/pic/tests/test_accum_vec_H1.py +++ b/src/struphy/pic/tests/test_accum_vec_H1.py @@ -1,7 +1,9 @@ import logging + import pytest -from struphy.utils.pyccel import Pyccelkernel + from struphy import set_logging_level +from struphy.utils.pyccel import Pyccelkernel logger = logging.getLogger("struphy") set_logging_level(logging.INFO) @@ -43,7 +45,7 @@ ], ) @pytest.mark.parametrize("num_clones", [1, 2]) -def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000, show_plot: bool = False): +def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=10000, show_plot: bool = False): r"""Test that AccumulatorVector provides an MC approximation of the L2 projection RHS. Particles are loaded with a uniform spatial distribution and unit Maxwellian velocity @@ -167,7 +169,7 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000, particles.initialize_weights() if show_plot and mpi_rank == 0: - components = [False]*6 + components = [False] * 6 components[0] = True # show only the spatial distribution (ignore velocities) bin_edges = [xp.linspace(0.0, 1.0, 50)] particles.show_distribution_function(components=components, bin_edges=bin_edges) @@ -192,7 +194,7 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000, mass_ops, domain.args_domain, ) - + # particles.update_weights() # ensure weights are updated before acc() @@ -226,7 +228,7 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000, _diff_sq = xp.empty(1, dtype=float) _rhs_sq = xp.empty(1, dtype=float) _diff_sq[0] = float(xp.sum((acc_arr - rhs_arr) ** 2)) - _rhs_sq[0] = float(xp.sum(rhs_arr ** 2)) + _rhs_sq[0] = float(xp.sum(rhs_arr**2)) if sub_comm is not None: sub_comm.Allreduce(MPI.IN_PLACE, _diff_sq, op=MPI.SUM) sub_comm.Allreduce(MPI.IN_PLACE, _rhs_sq, op=MPI.SUM) @@ -234,14 +236,10 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000, rhs_rel_err = float(xp.sqrt(_diff_sq[0] / _rhs_sq[0])) mc_order = float(1.0 / xp.sqrt(Np)) - logger.info( - f"rank {mpi_rank}: RHS relative error = {rhs_rel_err:.4f} " - f"(expected O(1/sqrt(N_p)) ≈ {mc_order:.4f})" - ) + logger.info(f"rank {mpi_rank}: RHS relative error = {rhs_rel_err:.4f} (expected O(1/sqrt(N_p)) ≈ {mc_order:.4f})") assert rhs_rel_err < 0.05, ( - f"MC RHS relative error {rhs_rel_err:.4f} exceeds 10 * O(1/sqrt(N_p)) = " - f"{10 * mc_order:.4f}. Increase N_p or check the accumulation kernel." + f"MC RHS relative error {rhs_rel_err:.4f} exceeds 5%. Increase N_p or check the accumulation kernel." ) # ------------------------------------------------------------------ # @@ -257,7 +255,7 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000, _proj_diff_sq = xp.empty(1, dtype=float) _proj_ref_sq = xp.empty(1, dtype=float) _proj_diff_sq[0] = float(xp.sum((x_mc_arr - x_exact_arr) ** 2)) - _proj_ref_sq[0] = float(xp.sum(x_exact_arr ** 2)) + _proj_ref_sq[0] = float(xp.sum(x_exact_arr**2)) if sub_comm is not None: sub_comm.Allreduce(MPI.IN_PLACE, _proj_diff_sq, op=MPI.SUM) sub_comm.Allreduce(MPI.IN_PLACE, _proj_ref_sq, op=MPI.SUM) @@ -265,13 +263,11 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000, proj_rel_err = float(xp.sqrt(_proj_diff_sq[0] / _proj_ref_sq[0])) logger.info( - f"rank {mpi_rank}: projection relative error = {proj_rel_err:.4f} " - f"(expected O(1/sqrt(N_p)) ≈ {mc_order:.4f})" + f"rank {mpi_rank}: projection relative error = {proj_rel_err:.4f} (expected O(1/sqrt(N_p)) ≈ {mc_order:.4f})" ) assert proj_rel_err < 0.16, ( - f"MC projection relative error {proj_rel_err:.4f} exceeds 10 * O(1/sqrt(N_p)) = " - f"{10 * mc_order:.4f}. Increase N_p or check the accumulation kernel." + f"MC projection relative error {proj_rel_err:.4f} exceeds 16%. Increase N_p or check the accumulation kernel." ) # ------------------------------------------------------------------ # @@ -311,14 +307,11 @@ def test_accum_poisson(num_elements, degree, bcs, mapping, num_clones, Np=1000, ax.axhline(0.0, color="k", lw=0.5) ax.set_xlabel(r"$\eta_1$") ax.set_ylabel(r"$n_h^{\rm MC} - n_h^{\rm exact}$") - ax.set_title( - f"Pointwise error (proj. rel. err = {proj_rel_err:.3f}, $N_p = {Np}$)" - ) + ax.set_title(f"Pointwise error (proj. rel. err = {proj_rel_err:.3f}, $N_p = {Np}$)") ax.legend(fontsize=9) fig.suptitle( - f"Cuboid {dom_params}, degree = {degree}, " - f"num_elements = {num_elements}, bcs = {bcs}", + f"Cuboid {dom_params}, degree = {degree}, num_elements = {num_elements}, bcs = {bcs}", fontsize=9, ) fig.tight_layout() diff --git a/src/struphy/pic/tests/test_tesselation.py b/src/struphy/pic/tests/test_tesselation.py index f9e65a598..5b1d3e364 100644 --- a/src/struphy/pic/tests/test_tesselation.py +++ b/src/struphy/pic/tests/test_tesselation.py @@ -178,8 +178,10 @@ def test_cell_average(ppb, nx, ny, nz, n_quad, show_plot=False): plt.show() # test - logger.info(f"\n{rank =}, {xp.max(xp.abs(particles.weights - particles.f_init(particles.positions))) =}") - assert xp.max(xp.abs(particles.weights - particles.f_init(particles.positions))) < 0.012 + logger.info( + f"\n{rank =}, {xp.max(xp.abs(particles.weights * particles.Np - particles.f_init(particles.positions))) =}" + ) + assert xp.max(xp.abs(particles.weights * particles.Np - particles.f_init(particles.positions))) < 0.012 if __name__ == "__main__": diff --git a/tutorials/tutorial_gas_expansion_sph.ipynb b/tutorials/tutorial_gas_expansion_sph.ipynb index c6edf0b08..433a8df9c 100644 --- a/tutorials/tutorial_gas_expansion_sph.ipynb +++ b/tutorials/tutorial_gas_expansion_sph.ipynb @@ -263,7 +263,7 @@ "outputs": [], "source": [ "loading_params = LoadingParameters(ppb=400)\n", - "weights_params = WeightsParameters(reject_weights=True, threshold=3e-3)\n", + "weights_params = WeightsParameters(reject_weights=True, threshold=3e-8)\n", "boundary_params = BoundaryParameters()\n", "nx = 16\n", "ny = 16\n", From 506661e72eb441c6df946f4ca194c90a7e76c2e4 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 12 Jun 2026 07:25:26 +0200 Subject: [PATCH 12/34] fix bug in viscosity evaluation, and other tests --- .../tests/verification/test_verif_ViscousEulerSPH.py | 3 ++- src/struphy/pic/pushing/eval_kernels_sph.py | 2 +- src/struphy/pic/tests/test_sph.py | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index 27e7a9eea..86b68fc7c 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -288,5 +288,6 @@ def test_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): if __name__ == "__main__": + pass # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) - test_damped_soundwave_1d(nx=12, plot_pts=11, do_plot=True) + # test_damped_soundwave_1d(nx=12, plot_pts=11, do_plot=True) diff --git a/src/struphy/pic/pushing/eval_kernels_sph.py b/src/struphy/pic/pushing/eval_kernels_sph.py index c471b687d..c94eaee1d 100644 --- a/src/struphy/pic/pushing/eval_kernels_sph.py +++ b/src/struphy/pic/pushing/eval_kernels_sph.py @@ -560,7 +560,7 @@ def sph_viscosity_tensor( d_dev[1, 1] -= mean_trace d_dev[2, 2] -= mean_trace - d_dev *= -2 * mu * (weight / n_at_eta) ** 2 + d_dev *= -2 * mu * (weight / n_at_eta) for j in range(3): for k in range(3): diff --git a/src/struphy/pic/tests/test_sph.py b/src/struphy/pic/tests/test_sph.py index cb0dae667..cedd11357 100644 --- a/src/struphy/pic/tests/test_sph.py +++ b/src/struphy/pic/tests/test_sph.py @@ -1986,7 +1986,7 @@ def u_xyz(x, y, z): # direction="x", # show_plot=True, # ) - # test_sph_velocity_evaluation_2d( - # (12, 12, 1), "gaussian_2d", 1, "periodic", "periodic", 11, tesselation=False, show_plot=True - # ) - test_sph_evaluation_1d((24, 1, 1), "trigonometric_1d", 0, "periodic", 11, tesselation=False, show_plot=True) + test_sph_viscosity_evaluation_2d( + (12, 12, 1), "gaussian_2d", "periodic", "periodic", 11, tesselation=True, show_plot=True + ) + # test_sph_evaluation_1d((24, 1, 1), "trigonometric_1d", 0, "periodic", 11, tesselation=False, show_plot=True) From 7882e81f749cdc043211848d6f0cfd01084bc9d4 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 12 Jun 2026 07:29:05 +0200 Subject: [PATCH 13/34] update matplotlib dep bound --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f809fe361..843b8e6bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "feectools<=0.1.3", "scipy<=1.17.1", "h5py<=3.16.0", - "matplotlib<=3.10.8", + "matplotlib<=3.11.0", "pyyaml<=6.0.3", "vtk<=9.6.1", "tqdm<=4.68.1", From 4fe4d1c7cba5f1e1ab53704ffff5d3d8adf6e381 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 12 Jun 2026 09:23:35 +0200 Subject: [PATCH 14/34] move sph kernels to own file; improve docstrings --- doc/sections/subsections/dev-feec.rst | 39 ++ doc/sections/subsections/dev-pic.rst | 34 + doc/sections/subsections/dev-sph.rst | 50 ++ src/struphy/console/compile.py | 1 + .../test_verif_ViscousEulerSPH.py | 3 +- src/struphy/pic/pushing/eval_kernels_gc.py | 554 +-------------- src/struphy/pic/pushing/eval_kernels_sph.py | 3 +- src/struphy/pic/pushing/pusher_kernels.py | 604 ----------------- src/struphy/pic/pushing/pusher_kernels_sph.py | 636 ++++++++++++++++++ .../propagators/push_vin_sph_pressure.py | 28 +- .../propagators/push_vin_viscous_potential.py | 4 +- 11 files changed, 783 insertions(+), 1173 deletions(-) create mode 100644 doc/sections/subsections/dev-feec.rst create mode 100644 doc/sections/subsections/dev-pic.rst create mode 100644 doc/sections/subsections/dev-sph.rst create mode 100644 src/struphy/pic/pushing/pusher_kernels_sph.py diff --git a/doc/sections/subsections/dev-feec.rst b/doc/sections/subsections/dev-feec.rst new file mode 100644 index 000000000..b3e1d9fbc --- /dev/null +++ b/doc/sections/subsections/dev-feec.rst @@ -0,0 +1,39 @@ +FEEC +---- + +Derham complex +^^^^^^^^^^^^^^ + +.. automodule:: struphy.feec.psydac_derham + :members: + :special-members: + :show-inheritance: + :exclude-members: __init__ + +Grids +^^^^^ + +.. automodule:: struphy.topology.grids + :members: + :special-members: + :show-inheritance: + :exclude-members: __init__ + +Mass Operators (bilinear forms) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: struphy.feec.mass + :members: + :special-members: + :show-inheritance: + :exclude-members: __init__ + + +Projection of spline functions +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: struphy.feec.basis_projection_ops + :members: + :special-members: + :show-inheritance: + :exclude-members: __init__ \ No newline at end of file diff --git a/doc/sections/subsections/dev-pic.rst b/doc/sections/subsections/dev-pic.rst new file mode 100644 index 000000000..8a6664a4a --- /dev/null +++ b/doc/sections/subsections/dev-pic.rst @@ -0,0 +1,34 @@ +PIC +--- + +Particle base class +^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: struphy.pic.base.Particles + :members: + :special-members: + :show-inheritance: + :exclude-members: __init__ + + +Particel-to-grid accumulation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: struphy.pic.accumulation.particles_to_grid + :members: + :special-members: + :show-inheritance: + + +Accumulation kernels +^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: struphy.pic.accumulation.accum_kernels + :members: + :special-members: + :show-inheritance: + +.. automodule:: struphy.pic.accumulation.accum_kernels_gc + :members: + :special-members: + :show-inheritance: \ No newline at end of file diff --git a/doc/sections/subsections/dev-sph.rst b/doc/sections/subsections/dev-sph.rst new file mode 100644 index 000000000..34b6cc2b6 --- /dev/null +++ b/doc/sections/subsections/dev-sph.rst @@ -0,0 +1,50 @@ +SPH +--- + + +.. _pusher_kernels_sph: + +Pusher kernels +^^^^^^^^^^^^^^ + +.. automodule:: struphy.pic.pushing.pusher_kernels_sph + :members: + :special-members: + :show-inheritance: + :exclude-members: __in + + +.. _coeff_kernels_sph: + +Coefficient kernels +^^^^^^^^^^^^^^^^^^^ + +.. automodule:: struphy.pic.pushing.eval_kernels_sph + :members: + :special-members: + :show-inheritance: + :exclude-members: __init__ + + +.. _eval_kernels_sph: + +Evaluation kernels +^^^^^^^^^^^^^^^^^^ + +.. automodule:: struphy.pic.sph_eval_kernels + :members: + :special-members: + :show-inheritance: + :exclude-members: __init__ + + +.. _smoothing_kernels: + +Smoothing kernels +^^^^^^^^^^^^^^^^^^ + +.. automodule:: struphy.pic.sph_smoothing_kernels + :members: + :special-members: + :show-inheritance: + :exclude-members: __init__ \ No newline at end of file diff --git a/src/struphy/console/compile.py b/src/struphy/console/compile.py index c328b99a9..b6553fecc 100644 --- a/src/struphy/console/compile.py +++ b/src/struphy/console/compile.py @@ -92,6 +92,7 @@ def struphy_compile( and "_tmp.py" not in file and "test" not in file and "__pycache__" not in subdir + and "__pyccel__" not in subdir ): state["kernels"] += [os.path.join(subdir, file)] diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index 86b68fc7c..768d7c513 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -288,6 +288,5 @@ def test_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): if __name__ == "__main__": - pass - # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) + test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) # test_damped_soundwave_1d(nx=12, plot_pts=11, do_plot=True) diff --git a/src/struphy/pic/pushing/eval_kernels_gc.py b/src/struphy/pic/pushing/eval_kernels_gc.py index 93a30d148..5e8da993b 100644 --- a/src/struphy/pic/pushing/eval_kernels_gc.py +++ b/src/struphy/pic/pushing/eval_kernels_gc.py @@ -1,6 +1,6 @@ "Initialization routines (initial guess, evaluations) for 5D gyro-center pusher kernels." -from numpy import abs, empty, log, mod, shape, size, sqrt, zeros +from numpy import empty, mod, size from pyccel.decorators import stack_array import struphy.bsplines.bsplines_kernels as bsplines_kernels @@ -468,555 +468,3 @@ def unit_b_1form( for j in range(n_comps): markers[ip, column_nr + j] = unit_b1[comps[j]] - -@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") -def sph_pressure_coeffs( - alpha: "float[:]", - column_nr: int, - comps: "int[:]", - args_markers: "MarkerArguments", - args_domain: "DomainArguments", - boxes: "int[:, :]", - neighbours: "int[:, :]", - holes: "bool[:]", - periodic1: "bool", - periodic2: "bool", - periodic3: "bool", - kernel_type: "int", - h1: "float", - h2: "float", - h3: "float", -): - r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian - - .. math:: - - H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} - + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, - - that is - - .. math:: - - \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) - + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, - - where the evaluation point is the weighted average - :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, - for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point - :math:`\boldsymbol \eta_p` beforehand. - - The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` - in markers array for each particle. - """ - - gamma = 5 / 3 - - # get marker arguments - markers = args_markers.markers - n_markers = args_markers.n_markers - n_cols = shape(markers)[1] - Np = args_markers.Np - weight_idx = args_markers.weight_idx - valid_mks = args_markers.valid_mks - - for ip in range(n_markers): - # only do something if particle is a "true" particle - if not valid_mks[ip]: - continue - - eta1 = markers[ip, 0] - eta2 = markers[ip, 1] - eta3 = markers[ip, 2] - loc_box = int(markers[ip, n_cols - 2]) - n_at_eta = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - weight_idx, - kernel_type, - h1, - h2, - h3, - ) - weight = markers[ip, weight_idx] - # save - markers[ip, column_nr] = n_at_eta - markers[ip, column_nr + 1] = weight / n_at_eta - markers[ip, column_nr + 2] = weight * n_at_eta ** (gamma - 2) - - -@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") -def sph_isotherm_kappa( - alpha: "float[:]", - column_nr: int, - comps: "int[:]", - args_markers: "MarkerArguments", -): - r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian - - .. math:: - - H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} - + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, - - that is - - .. math:: - - \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) - + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, - - where the evaluation point is the weighted average - :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, - for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point - :math:`\boldsymbol \eta_p` beforehand. - - The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` - in markers array for each particle. - """ - - # get marker arguments - markers = args_markers.markers - n_markers = args_markers.n_markers - first_diagnostic_idx = args_markers.first_diagnostics_idx - - for ip in range(n_markers): - # only do something if particle is a "true" particle (i.e. not a hole) - if markers[ip, 0] == -1.0: - continue - - markers[ip, first_diagnostic_idx] = 1.0 - - -@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") -def sph_mean_velocity_coeffs( - alpha: "float[:]", - column_nr: int, - comps: "int[:]", - args_markers: "MarkerArguments", - args_domain: "DomainArguments", - boxes: "int[:, :]", - neighbours: "int[:, :]", - holes: "bool[:]", - periodic1: "bool", - periodic2: "bool", - periodic3: "bool", - kernel_type: "int", - h1: "float", - h2: "float", - h3: "float", -): - r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian - - .. math:: - - H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} - + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, - - that is - - .. math:: - - \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) - + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, - - where the evaluation point is the weighted average - :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, - for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point - :math:`\boldsymbol \eta_p` beforehand. - - The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` - in markers array for each particle. - """ - - gamma = 5 / 3 - - # get marker arguments - markers = args_markers.markers - n_markers = args_markers.n_markers - n_cols = shape(markers)[1] - Np = args_markers.Np - vdim = args_markers.vdim - weight_idx = args_markers.weight_idx - valid_mks = args_markers.valid_mks - - for ip in range(n_markers): - # only do something if particle is a "true" particle - if not valid_mks[ip]: - continue - - # also evaluate and save for ghost particles, only skip holes (!) - # if holes[ip]: - # continue - - eta1 = markers[ip, 0] - eta2 = markers[ip, 1] - eta3 = markers[ip, 2] - loc_box = int(markers[ip, n_cols - 2]) - n_at_eta = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - weight_idx, - kernel_type, - h1, - h2, - h3, - ) - weight = markers[ip, weight_idx] - velocities = markers[ip, 3:6] - # save - markers[ip, column_nr] = weight / n_at_eta * velocities[0] - markers[ip, column_nr + 1] = weight / n_at_eta * velocities[1] - markers[ip, column_nr + 2] = weight / n_at_eta * velocities[2] - - # logger.info(f"{ip = }, {weight = }, {n_at_eta = }, {velocities[0] = }") - - -@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") -def sph_mean_velocity( - alpha: "float[:]", - column_nr: int, - comps: "int[:]", - args_markers: "MarkerArguments", - args_domain: "DomainArguments", - boxes: "int[:, :]", - neighbours: "int[:, :]", - holes: "bool[:]", - periodic1: "bool", - periodic2: "bool", - periodic3: "bool", - kernel_type: "int", - h1: "float", - h2: "float", - h3: "float", -): - r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian - - .. math:: - - H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} - + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, - - that is - - .. math:: - - \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) - + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, - - where the evaluation point is the weighted average - :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, - for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point - :math:`\boldsymbol \eta_p` beforehand. - - The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` - in markers array for each particle. - """ - - gamma = 5 / 3 - - # get marker arguments - markers = args_markers.markers - n_markers = args_markers.n_markers - n_cols = shape(markers)[1] - Np = args_markers.Np - vdim = args_markers.vdim - weight_idx = args_markers.weight_idx - first_free_idx = args_markers.first_free_idx - valid_mks = args_markers.valid_mks - - for ip in range(n_markers): - # only do something if particle is a "true" particle - if not valid_mks[ip]: - continue - - eta1 = markers[ip, 0] - eta2 = markers[ip, 1] - eta3 = markers[ip, 2] - loc_box = int(markers[ip, n_cols - 2]) - v1_at_eta = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx, - kernel_type, - h1, - h2, - h3, - ) - - v2_at_eta = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + 1, - kernel_type, - h1, - h2, - h3, - ) - - v3_at_eta = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + 2, - kernel_type, - h1, - h2, - h3, - ) - # save - markers[ip, column_nr] = v1_at_eta - markers[ip, column_nr + 1] = v2_at_eta - markers[ip, column_nr + 2] = v3_at_eta - - -@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") -def sph_grad_mean_velocity( - alpha: "float[:]", - column_nr: int, - comps: "int[:]", - args_markers: "MarkerArguments", - args_domain: "DomainArguments", - boxes: "int[:, :]", - neighbours: "int[:, :]", - holes: "bool[:]", - periodic1: "bool", - periodic2: "bool", - periodic3: "bool", - kernel_type: "int", - h1: "float", - h2: "float", - h3: "float", -): - r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian - - .. math:: - - H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} - + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, - - that is - - .. math:: - - \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) - + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, - - where the evaluation point is the weighted average - :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, - for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point - :math:`\boldsymbol \eta_p` beforehand. - - The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` - in markers array for each particle. - """ - - gamma = 5 / 3 - - # get marker arguments - markers = args_markers.markers - n_markers = args_markers.n_markers - n_cols = shape(markers)[1] - Np = args_markers.Np - vdim = args_markers.vdim - weight_idx = args_markers.weight_idx - first_free_idx = args_markers.first_free_idx - valid_mks = args_markers.valid_mks - - grad_v_at_eta = zeros((3, 3), dtype=float) - for ip in range(n_markers): - # only do something if particle is a "true" particle - if not valid_mks[ip]: - continue - - eta1 = markers[ip, 0] - eta2 = markers[ip, 1] - eta3 = markers[ip, 2] - loc_box = int(markers[ip, n_cols - 2]) - for j in range(3): - for k in range(3): - grad_v_at_eta[j, k] = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + j, - kernel_type + 1 + k, - h1, - h2, - h3, - ) - - # save - markers[ip, column_nr + 3 * j + k] = grad_v_at_eta[j, k] - - -@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") -def sph_viscosity_tensor( - alpha: "float[:]", - column_nr: int, - comps: "int[:]", - args_markers: "MarkerArguments", - args_domain: "DomainArguments", - boxes: "int[:, :]", - neighbours: "int[:, :]", - holes: "bool[:]", - periodic1: "bool", - periodic2: "bool", - periodic3: "bool", - kernel_type: "int", - h1: "float", - h2: "float", - h3: "float", - mu: "float", -): - r"""Evaluate the viscous stress tensor at each particle location using SPH. - - Computes the deviatoric viscous stress tensor: - - .. math:: - - \boldsymbol{\sigma}_{ij} = -2\mu \left(\dot{\gamma}_{ij} - \frac{1}{3}\delta_{ij}\dot{\gamma}_{kk}\right)\,, - - where :math:`\dot{\gamma}_{ij}` is the strain rate tensor computed from the velocity gradient - :math:`\dot{\gamma}_{ij} = \frac{1}{2}\left(\frac{\partial v_i}{\partial x_j} + \frac{\partial v_j}{\partial x_i}\right)`, - and :math:`\mu` is the dynamic viscosity coefficient. The deviatoric part (removal of trace) - ensures incompressibility in the momentum equation. - - The velocity gradient is evaluated at particle positions using SPH kernel interpolation. - The density factor :math:`(w/n)^2` accounts for the local particle number density :math:`n_{\eta}`, - where :math:`w` is the particle weight. - - Parameters evaluated at location :math:`\boldsymbol{\eta}_p` using weighted SPH kernel interpolation - over neighboring particles in boxes. - - All 9 components of the symmetric stress tensor are saved at - ``column_nr:column_nr+9`` in markers array for each particle (in row-major order). - """ - - # get marker arguments - markers = args_markers.markers - n_markers = args_markers.n_markers - n_cols = shape(markers)[1] - Np = args_markers.Np - vdim = args_markers.vdim - weight_idx = args_markers.weight_idx - first_free_idx = args_markers.first_free_idx - valid_mks = args_markers.valid_mks - - grad_v_at_eta = zeros((3, 3), dtype=float) - # d_tensor = zeros((3, 3), dtype=float) - d_dev = zeros((3, 3), dtype=float) - for ip in range(n_markers): - # only do something if particle is a "true" particle - if not valid_mks[ip]: - continue - - eta1 = markers[ip, 0] - eta2 = markers[ip, 1] - eta3 = markers[ip, 2] - loc_box = int(markers[ip, n_cols - 2]) - n_at_eta = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - weight_idx, - kernel_type, - h1, - h2, - h3, - ) - weight = markers[ip, weight_idx] - for j in range(3): - for k in range(3): - grad_v_at_eta[j, k] = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + j, - kernel_type + 1 + k, - h1, - h2, - h3, - ) - - d_dev[:] = 0.5 * (grad_v_at_eta + grad_v_at_eta.T) - - mean_trace = (d_dev[0, 0] + d_dev[1, 1] + d_dev[2, 2]) / 3.0 - - d_dev[0, 0] -= mean_trace - d_dev[1, 1] -= mean_trace - d_dev[2, 2] -= mean_trace - - d_dev *= -2 * mu * (weight / n_at_eta) ** 2 - - for j in range(3): - for k in range(3): - markers[ip, column_nr + 3 * j + k] = d_dev[j, k] diff --git a/src/struphy/pic/pushing/eval_kernels_sph.py b/src/struphy/pic/pushing/eval_kernels_sph.py index c94eaee1d..0aad76dd0 100644 --- a/src/struphy/pic/pushing/eval_kernels_sph.py +++ b/src/struphy/pic/pushing/eval_kernels_sph.py @@ -7,8 +7,7 @@ import struphy.bsplines.evaluation_kernels_3d as evaluation_kernels_3d import struphy.geometry.evaluation_kernels as evaluation_kernels -# do not remove; needed to identify dependencies -import struphy.kernel_arguments.pusher_args_kernels as pusher_args_kernels +import struphy.kernel_arguments.pusher_args_kernels as pusher_args_kernels # do not remove; needed to identify dependencies import struphy.linear_algebra.linalg_kernels as linalg_kernels import struphy.pic.sph_eval_kernels as sph_eval_kernels from struphy.kernel_arguments.pusher_args_kernels import DerhamArguments, DomainArguments, MarkerArguments diff --git a/src/struphy/pic/pushing/pusher_kernels.py b/src/struphy/pic/pushing/pusher_kernels.py index 91d8f02da..585ce25d4 100644 --- a/src/struphy/pic/pushing/pusher_kernels.py +++ b/src/struphy/pic/pushing/pusher_kernels.py @@ -2576,607 +2576,3 @@ def push_random_diffusion_stage( markers[ip, 0:3] += sqrt(2 * dt * diffusion_coeff) * noise[ip, :] # -- removed omp: #$ omp end parallel - - -@stack_array("grad_u", "grad_u_cart", "tmp1", "dfinv", "dfinvT") -def push_v_sph_pressure( - dt: float, - stage: int, - args_markers: "MarkerArguments", - args_domain: "DomainArguments", - boxes: "int[:,:]", - neighbours: "int[:, :]", - holes: "bool[:]", - periodic1: "bool", - periodic2: "bool", - periodic3: "bool", - kernel_type: "int", - h1: "float", - h2: "float", - h3: "float", - gravity: "float[:]", -): - r"""Updates particle velocities as - - .. math:: - - \frac{\mathbf v^{n+1} - \mathbf v^n}{\Delta t} = \kappa_p \sum_{q} w_p\,w_q \left( \frac{1}{\rho^{N,h}(\boldsymbol \eta_p)} + \frac{1}{\rho^{N,h}(\boldsymbol \eta_q)} \right) G^{-1}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_q) \,, - - where :math:`G^{-1}` denotes the inverse metric tensor, and with the smoothed density - - .. math:: - - \rho^{N,h}(\boldsymbol \eta_p) = \frac 1N \sum_q w_q \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_q)\,, - - where :math:`W_h(\boldsymbol \eta)` is a smoothing kernel from :mod:`~struphy.pic.sph_smoothing_kernels`. - - Parameters - ---------- - boxes : 2d array - Box array of the sorting boxes structure. - - neighbours : 2d array - Array containing the 27 neighbouring boxes of each box. - - holes : bool - 1D array of length markers.shape[0]. True if markers[i] is a hole. - - periodic1, periodic2, periodic3 : bool - True if periodic in that dimension. - - kernel_type : int - Number of the smoothing kernel. - - h1, h2, h3 : float - Kernel width in respective dimension. - - gravity: xp.ndarray - Constant gravitational force as 3-vector. - """ - # allocate arrays - grad_u = zeros(3, dtype=float) - grad_u_cart = zeros(3, dtype=float) - tmp1 = zeros((3, 3), dtype=float) - dfinv = zeros((3, 3), dtype=float) - dfinvT = zeros((3, 3), dtype=float) - - # get marker arguments - markers = args_markers.markers - n_markers = args_markers.n_markers - Np = args_markers.Np - weight_idx = args_markers.weight_idx - first_free_idx = args_markers.first_free_idx - valid_mks = args_markers.valid_mks - n_cols = shape(markers)[1] - - # -- removed omp: #$ omp parallel private(ip, eta1, eta2, eta3, dfinv) - # -- removed omp: #$ omp for - for ip in range(n_markers): - if not valid_mks[ip]: - continue - - eta1 = markers[ip, 0] - eta2 = markers[ip, 1] - eta3 = markers[ip, 2] - kappa = 1.0 # markers[ip, first_diagnostics_idx] - n_at_eta = markers[ip, first_free_idx] - loc_box = int(markers[ip, n_cols - 2]) - - # first component - grad_u[0] = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - weight_idx, - kernel_type + 1, - h1, - h2, - h3, - ) - grad_u[0] *= kappa / n_at_eta - - sum2 = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + 1, - kernel_type + 1, - h1, - h2, - h3, - ) - sum2 *= kappa - grad_u[0] += sum2 - - if kernel_type >= 340: - # second component - grad_u[1] = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - weight_idx, - kernel_type + 2, - h1, - h2, - h3, - ) - grad_u[1] *= kappa / n_at_eta - - sum4 = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + 1, - kernel_type + 2, - h1, - h2, - h3, - ) - sum4 *= kappa - grad_u[1] += sum4 - - if kernel_type >= 670: - # third component - grad_u[2] = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - weight_idx, - kernel_type + 3, - h1, - h2, - h3, - ) - grad_u[2] *= kappa / n_at_eta - - sum6 = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + 1, - kernel_type + 3, - h1, - h2, - h3, - ) - sum6 *= kappa - grad_u[2] += sum6 - - # push to Cartesian coordinates - evaluation_kernels.df_inv( - eta1, - eta2, - eta3, - args_domain, - tmp1, - False, - dfinv, - ) - linalg_kernels.transpose(dfinv, dfinvT) - linalg_kernels.matrix_vector(dfinvT, grad_u, grad_u_cart) - - # update velocities - markers[ip, 3:6] -= dt * (grad_u_cart - gravity) - - # -- removed omp: #$ omp end parallel - - -@stack_array("grad_u", "grad_u_cart", "tmp1", "dfinv", "dfinvT") -def push_v_sph_pressure_ideal_gas( - dt: float, - stage: int, - args_markers: "MarkerArguments", - args_domain: "DomainArguments", - boxes: "int[:,:]", - neighbours: "int[:, :]", - holes: "bool[:]", - periodic1: "bool", - periodic2: "bool", - periodic3: "bool", - kernel_type: "int", - h1: "float", - h2: "float", - h3: "float", - gravity: "float[:]", -): - r"""Updates particle velocities as - - .. math:: - - \frac{\mathbf v^{n+1} - \mathbf v^n}{\Delta t} = \kappa_p \sum_{q} w_p\,w_q \left( \frac{1}{\rho^{N,h}(\boldsymbol \eta_p)} + \frac{1}{\rho^{N,h}(\boldsymbol \eta_q)} \right) G^{-1}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_q) \,, - - where :math:`G^{-1}` denotes the inverse metric tensor, and with the smoothed density - - .. math:: - - \rho^{N,h}(\boldsymbol \eta_p) = \frac 1N \sum_q w_q \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_q)\,, - - where :math:`W_h(\boldsymbol \eta)` is a smoothing kernel from :mod:`~struphy.pic.sph_smoothing_kernels`. - - Parameters - ---------- - boxes : 2d array - Box array of the sorting boxes structure. - - neighbours : 2d array - Array containing the 27 neighbouring boxes of each box. - - holes : bool - 1D array of length markers.shape[0]. True if markers[i] is a hole. - - periodic1, periodic2, periodic3 : bool - True if periodic in that dimension. - - kernel_type : int - Number of the smoothing kernel. - - h1, h2, h3 : float - Kernel width in respective dimension. - - gravity: xp.ndarray - Constant gravitational force as 3-vector. - """ - # allocate arrays - grad_u = zeros(3, dtype=float) - grad_u_cart = zeros(3, dtype=float) - tmp1 = zeros((3, 3), dtype=float) - dfinv = zeros((3, 3), dtype=float) - dfinvT = zeros((3, 3), dtype=float) - - # get marker arguments - markers = args_markers.markers - n_markers = args_markers.n_markers - Np = args_markers.Np - weight_idx = args_markers.weight_idx - first_free_idx = args_markers.first_free_idx - valid_mks = args_markers.valid_mks - n_cols = shape(markers)[1] - - gamma = 5 / 3 - kappa = 1 / (gamma - 1) - - # -- removed omp: #$ omp parallel private(ip, eta1, eta2, eta3, dfinv) - # -- removed omp: #$ omp for - for ip in range(n_markers): - if not valid_mks[ip]: - continue - - eta1 = markers[ip, 0] - eta2 = markers[ip, 1] - eta3 = markers[ip, 2] - n_at_eta = markers[ip, first_free_idx] - loc_box = int(markers[ip, n_cols - 2]) - - # first component - grad_u[0] = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - weight_idx, - kernel_type + 1, - h1, - h2, - h3, - ) - grad_u[0] *= kappa * n_at_eta ** (gamma - 2) - - sum2 = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + 2, - kernel_type + 1, - h1, - h2, - h3, - ) - sum2 *= kappa - grad_u[0] += sum2 - - if kernel_type >= 340: - # second component - grad_u[1] = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - weight_idx, - kernel_type + 2, - h1, - h2, - h3, - ) - grad_u[1] *= kappa * (n_at_eta) ** (gamma - 2) - - sum4 = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + 2, - kernel_type + 2, - h1, - h2, - h3, - ) - sum4 *= kappa - grad_u[1] += sum4 - - if kernel_type >= 670: - # third component - grad_u[2] = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - weight_idx, - kernel_type + 3, - h1, - h2, - h3, - ) - grad_u[2] *= kappa * (n_at_eta) ** (gamma - 2) - - sum6 = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + 2, - kernel_type + 3, - h1, - h2, - h3, - ) - sum6 *= kappa - grad_u[2] += sum6 - - # push to Cartesian coordinates - evaluation_kernels.df_inv( - eta1, - eta2, - eta3, - args_domain, - tmp1, - False, - dfinv, - ) - linalg_kernels.transpose(dfinv, dfinvT) - linalg_kernels.matrix_vector(dfinvT, grad_u, grad_u_cart) - - # update velocities - markers[ip, 3:6] -= dt * (grad_u_cart - gravity) - - # -- removed omp: #$ omp end parallel - - -@stack_array("grad_u", "grad_u_cart", "tmp1", "dfinv", "dfinvT") -def push_v_viscosity( - dt: float, - stage: int, - args_markers: "MarkerArguments", - args_domain: "DomainArguments", - boxes: "int[:,:]", - neighbours: "int[:, :]", - holes: "bool[:]", - periodic1: "bool", - periodic2: "bool", - periodic3: "bool", - kernel_type: "int", - h1: "float", - h2: "float", - h3: "float", -): - r"""Updates particle velocities as - - .. math:: - - \frac{\mathbf v^{n+1} - \mathbf v^n}{\Delta t} = \kappa_p \sum_{q} w_p\,w_q \left( \frac{1}{\rho^{N,h}(\boldsymbol \eta_p)} + \frac{1}{\rho^{N,h}(\boldsymbol \eta_q)} \right) G^{-1}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_q) \,, - - where :math:`G^{-1}` denotes the inverse metric tensor, and with the smoothed density - - .. math:: - - \rho^{N,h}(\boldsymbol \eta_p) = \frac 1N \sum_q w_q \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_q)\,, - - where :math:`W_h(\boldsymbol \eta)` is a smoothing kernel from :mod:`~struphy.pic.sph_smoothing_kernels`. - - Parameters - ---------- - boxes : 2d array - Box array of the sorting boxes structure. - - neighbours : 2d array - Array containing the 27 neighbouring boxes of each box. - - holes : bool - 1D array of length markers.shape[0]. True if markers[i] is a hole. - - periodic1, periodic2, periodic3 : bool - True if periodic in that dimension. - - kernel_type : int - Number of the smoothing kernel. - - h1, h2, h3 : float - Kernel width in respective dimension. - - gravity: xp.ndarray - Constant gravitational force as 3-vector. - """ - # allocate arrays - grad_u = zeros(3, dtype=float) - grad_u_cart = zeros(3, dtype=float) - tmp1 = zeros((3, 3), dtype=float) - dfinv = zeros((3, 3), dtype=float) - dfinvT = zeros((3, 3), dtype=float) - - # get marker arguments - markers = args_markers.markers - n_markers = args_markers.n_markers - Np = args_markers.Np - weight_idx = args_markers.weight_idx - first_free_idx = args_markers.first_free_idx - valid_mks = args_markers.valid_mks - n_cols = shape(markers)[1] - f_visc = zeros(3, dtype=float) - f_visc_cart = zeros(3, dtype=float) - - # -- removed omp: #$ omp parallel private(ip, eta1, eta2, eta3, dfinv) - # -- removed omp: #$ omp for - for ip in range(n_markers): - if not valid_mks[ip]: - continue - - eta1 = markers[ip, 0] - eta2 = markers[ip, 1] - eta3 = markers[ip, 2] - kappa = 1.0 # markers[ip, first_diagnostics_idx] - # n_at_eta = markers[ip, first_free_idx] - loc_box = int(markers[ip, n_cols - 2]) - - for j in range(3): # row of viscosity tensor - for k in range(3): # column = derivative direction - coeff_idx = first_free_idx + 3 * (j + 1) + k - - # if k == 0: - # deriv_type = kernel_type + 1 - # use_component = True - # elif k == 1 and kernel_type >= 340: - # deriv_type = kernel_type + 2 - # use_component = True - # elif k == 2 and kernel_type >= 670: - # deriv_type = kernel_type + 3 - # use_component = True - # else: - # use_component = False - - # if use_component: - f_visc[j] += sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - coeff_idx, - kernel_type + 1 + k, - h1, - h2, - h3, - ) - - # push to Cartesian coordinates - evaluation_kernels.df_inv( - eta1, - eta2, - eta3, - args_domain, - tmp1, - False, - dfinv, - ) - linalg_kernels.transpose(dfinv, dfinvT) - linalg_kernels.matrix_vector(dfinvT, f_visc, f_visc_cart) - - # update velocities - markers[ip, 3:6] -= dt * (f_visc_cart) - - # -- removed omp: #$ omp end parallel diff --git a/src/struphy/pic/pushing/pusher_kernels_sph.py b/src/struphy/pic/pushing/pusher_kernels_sph.py new file mode 100644 index 000000000..2b68ae2ef --- /dev/null +++ b/src/struphy/pic/pushing/pusher_kernels_sph.py @@ -0,0 +1,636 @@ +"Pusher kernels for full orbit (6D) particles." + +from numpy import cos, empty, floor, log, shape, sin, sqrt, zeros +from pyccel.decorators import stack_array + +import struphy.bsplines.bsplines_kernels as bsplines_kernels +import struphy.bsplines.evaluation_kernels_3d as evaluation_kernels_3d +import struphy.geometry.evaluation_kernels as evaluation_kernels + +# do not remove; needed to identify dependencies +import struphy.kernel_arguments.pusher_args_kernels as pusher_args_kernels +import struphy.linear_algebra.linalg_kernels as linalg_kernels +import struphy.pic.pushing.pusher_utilities_kernels as pusher_utilities_kernels +import struphy.pic.sph_eval_kernels as sph_eval_kernels +from struphy.bsplines.evaluation_kernels_3d import ( + eval_0form_spline_mpi, + eval_1form_spline_mpi, + eval_2form_spline_mpi, + eval_3form_spline_mpi, + eval_vectorfield_spline_mpi, + get_spans, +) +from struphy.kernel_arguments.pusher_args_kernels import DerhamArguments, DomainArguments, MarkerArguments + + + +@stack_array("grad_u", "grad_u_cart", "tmp1", "dfinv", "dfinvT") +def push_v_sph_pressure( + dt: float, + stage: int, + args_markers: "MarkerArguments", + args_domain: "DomainArguments", + boxes: "int[:,:]", + neighbours: "int[:, :]", + holes: "bool[:]", + periodic1: "bool", + periodic2: "bool", + periodic3: "bool", + kernel_type: "int", + h1: "float", + h2: "float", + h3: "float", + gravity: "float[:]", + kappa: "float", +): + r"""Update each marker :math:`p` according to + + .. math:: + + \frac{\mathbf v_p^{n+1} - \mathbf v_p^n}{\Delta t} = \mathbf g - \sum_{i=1}^N w_p w_i \left( \frac{\kappa}{\rho^{N,h}(\boldsymbol \eta_p)} + \frac{\kappa}{\rho^{N,h}(\boldsymbol \eta_q)} \right) DF^{-\top}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_i) \,, + + where :math:`\mathbf g` is a constant acceleration, the second term corresponds to the pressure gradient + in the isothermal closure (with constant :math:`\kappa`), and :math:`DF^{-\top}` denotes the inverse transpose Jacobian + arising in the pull back of the gradient of the smoothing kernel :math:`W_h` + chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. + The smoothed SPH density is given by + + .. math:: + + \rho^{N,h}(\boldsymbol \eta_p) = \sum_j w_j \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_j)\,, + + Parameters + ---------- + boxes : 2d array + Box array of the sorting boxes structure. + + neighbours : 2d array + Array containing the 27 neighbouring boxes of each box. + + holes : bool + 1D array of length markers.shape[0]. True if markers[i] is a hole. + + periodic1, periodic2, periodic3 : bool + True if periodic in that dimension. + + kernel_type : int + Number of the smoothing kernel. + + h1, h2, h3 : float + Kernel width in respective dimension. + + gravity: xp.ndarray + Constant gravitational force as 3-vector. + + kappa: float + Constant isothermal coefficient. + """ + # allocate arrays + grad_u = zeros(3, dtype=float) + grad_u_cart = zeros(3, dtype=float) + tmp1 = zeros((3, 3), dtype=float) + dfinv = zeros((3, 3), dtype=float) + dfinvT = zeros((3, 3), dtype=float) + + # get marker arguments + markers = args_markers.markers + n_markers = args_markers.n_markers + weight_idx = args_markers.weight_idx + first_free_idx = args_markers.first_free_idx + valid_mks = args_markers.valid_mks + n_cols = shape(markers)[1] + + # -- removed omp: #$ omp parallel private(ip, eta1, eta2, eta3, dfinv) + # -- removed omp: #$ omp for + for ip in range(n_markers): + if not valid_mks[ip]: + continue + + eta1 = markers[ip, 0] + eta2 = markers[ip, 1] + eta3 = markers[ip, 2] + n_at_eta = markers[ip, first_free_idx] + loc_box = int(markers[ip, n_cols - 2]) + + # first component + grad_u[0] = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + weight_idx, + kernel_type + 1, + h1, + h2, + h3, + ) + grad_u[0] *= kappa / n_at_eta + + sum2 = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + first_free_idx + 1, + kernel_type + 1, + h1, + h2, + h3, + ) + sum2 *= kappa + grad_u[0] += sum2 + + if kernel_type >= 340: + # second component + grad_u[1] = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + weight_idx, + kernel_type + 2, + h1, + h2, + h3, + ) + grad_u[1] *= kappa / n_at_eta + + sum4 = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + first_free_idx + 1, + kernel_type + 2, + h1, + h2, + h3, + ) + sum4 *= kappa + grad_u[1] += sum4 + + if kernel_type >= 670: + # third component + grad_u[2] = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + weight_idx, + kernel_type + 3, + h1, + h2, + h3, + ) + grad_u[2] *= kappa / n_at_eta + + sum6 = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + first_free_idx + 1, + kernel_type + 3, + h1, + h2, + h3, + ) + sum6 *= kappa + grad_u[2] += sum6 + + # push to Cartesian coordinates + evaluation_kernels.df_inv( + eta1, + eta2, + eta3, + args_domain, + tmp1, + False, + dfinv, + ) + linalg_kernels.transpose(dfinv, dfinvT) + linalg_kernels.matrix_vector(dfinvT, grad_u, grad_u_cart) + + # update velocities + markers[ip, 3:6] -= dt * (grad_u_cart - gravity) + + # -- removed omp: #$ omp end parallel + + +@stack_array("grad_u", "grad_u_cart", "tmp1", "dfinv", "dfinvT") +def push_v_sph_pressure_ideal_gas( + dt: float, + stage: int, + args_markers: "MarkerArguments", + args_domain: "DomainArguments", + boxes: "int[:,:]", + neighbours: "int[:, :]", + holes: "bool[:]", + periodic1: "bool", + periodic2: "bool", + periodic3: "bool", + kernel_type: "int", + h1: "float", + h2: "float", + h3: "float", + gravity: "float[:]", + kappa: "float", +): + r"""Updates particle velocities as + + .. math:: + + \frac{\mathbf v^{n+1} - \mathbf v^n}{\Delta t} = \kappa_p \sum_{q} w_p\,w_q \left( \frac{1}{\rho^{N,h}(\boldsymbol \eta_p)} + \frac{1}{\rho^{N,h}(\boldsymbol \eta_q)} \right) G^{-1}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_q) \,, + + where :math:`G^{-1}` denotes the inverse metric tensor, and with the smoothed density + + .. math:: + + \rho^{N,h}(\boldsymbol \eta_p) = \frac 1N \sum_q w_q \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_q)\,, + + where :math:`W_h(\boldsymbol \eta)` is a smoothing kernel from :mod:`~struphy.pic.sph_smoothing_kernels`. + + Parameters + ---------- + boxes : 2d array + Box array of the sorting boxes structure. + + neighbours : 2d array + Array containing the 27 neighbouring boxes of each box. + + holes : bool + 1D array of length markers.shape[0]. True if markers[i] is a hole. + + periodic1, periodic2, periodic3 : bool + True if periodic in that dimension. + + kernel_type : int + Number of the smoothing kernel. + + h1, h2, h3 : float + Kernel width in respective dimension. + + gravity: xp.ndarray + Constant gravitational force as 3-vector. + + kappa: float + Polytropic coefficient in the ideal gas closure. + """ + # allocate arrays + grad_u = zeros(3, dtype=float) + grad_u_cart = zeros(3, dtype=float) + tmp1 = zeros((3, 3), dtype=float) + dfinv = zeros((3, 3), dtype=float) + dfinvT = zeros((3, 3), dtype=float) + + # get marker arguments + markers = args_markers.markers + n_markers = args_markers.n_markers + Np = args_markers.Np + weight_idx = args_markers.weight_idx + first_free_idx = args_markers.first_free_idx + valid_mks = args_markers.valid_mks + n_cols = shape(markers)[1] + + gamma = 5 / 3 + kappa = 1 / (gamma - 1) + + # -- removed omp: #$ omp parallel private(ip, eta1, eta2, eta3, dfinv) + # -- removed omp: #$ omp for + for ip in range(n_markers): + if not valid_mks[ip]: + continue + + eta1 = markers[ip, 0] + eta2 = markers[ip, 1] + eta3 = markers[ip, 2] + n_at_eta = markers[ip, first_free_idx] + loc_box = int(markers[ip, n_cols - 2]) + + # first component + grad_u[0] = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + weight_idx, + kernel_type + 1, + h1, + h2, + h3, + ) + grad_u[0] *= kappa * n_at_eta ** (gamma - 2) + + sum2 = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + first_free_idx + 2, + kernel_type + 1, + h1, + h2, + h3, + ) + sum2 *= kappa + grad_u[0] += sum2 + + if kernel_type >= 340: + # second component + grad_u[1] = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + weight_idx, + kernel_type + 2, + h1, + h2, + h3, + ) + grad_u[1] *= kappa * (n_at_eta) ** (gamma - 2) + + sum4 = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + first_free_idx + 2, + kernel_type + 2, + h1, + h2, + h3, + ) + sum4 *= kappa + grad_u[1] += sum4 + + if kernel_type >= 670: + # third component + grad_u[2] = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + weight_idx, + kernel_type + 3, + h1, + h2, + h3, + ) + grad_u[2] *= kappa * (n_at_eta) ** (gamma - 2) + + sum6 = sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + first_free_idx + 2, + kernel_type + 3, + h1, + h2, + h3, + ) + sum6 *= kappa + grad_u[2] += sum6 + + # push to Cartesian coordinates + evaluation_kernels.df_inv( + eta1, + eta2, + eta3, + args_domain, + tmp1, + False, + dfinv, + ) + linalg_kernels.transpose(dfinv, dfinvT) + linalg_kernels.matrix_vector(dfinvT, grad_u, grad_u_cart) + + # update velocities + markers[ip, 3:6] -= dt * (grad_u_cart - gravity) + + # -- removed omp: #$ omp end parallel + + +@stack_array("grad_u", "grad_u_cart", "tmp1", "dfinv", "dfinvT") +def push_v_viscosity( + dt: float, + stage: int, + args_markers: "MarkerArguments", + args_domain: "DomainArguments", + boxes: "int[:,:]", + neighbours: "int[:, :]", + holes: "bool[:]", + periodic1: "bool", + periodic2: "bool", + periodic3: "bool", + kernel_type: "int", + h1: "float", + h2: "float", + h3: "float", +): + r"""Updates particle velocities as + + .. math:: + + \frac{\mathbf v^{n+1} - \mathbf v^n}{\Delta t} = \kappa_p \sum_{q} w_p\,w_q \left( \frac{1}{\rho^{N,h}(\boldsymbol \eta_p)} + \frac{1}{\rho^{N,h}(\boldsymbol \eta_q)} \right) G^{-1}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_q) \,, + + where :math:`G^{-1}` denotes the inverse metric tensor, and with the smoothed density + + .. math:: + + \rho^{N,h}(\boldsymbol \eta_p) = \frac 1N \sum_q w_q \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_q)\,, + + where :math:`W_h(\boldsymbol \eta)` is a smoothing kernel from :mod:`~struphy.pic.sph_smoothing_kernels`. + + Parameters + ---------- + boxes : 2d array + Box array of the sorting boxes structure. + + neighbours : 2d array + Array containing the 27 neighbouring boxes of each box. + + holes : bool + 1D array of length markers.shape[0]. True if markers[i] is a hole. + + periodic1, periodic2, periodic3 : bool + True if periodic in that dimension. + + kernel_type : int + Number of the smoothing kernel. + + h1, h2, h3 : float + Kernel width in respective dimension. + + gravity: xp.ndarray + Constant gravitational force as 3-vector. + """ + # allocate arrays + grad_u = zeros(3, dtype=float) + grad_u_cart = zeros(3, dtype=float) + tmp1 = zeros((3, 3), dtype=float) + dfinv = zeros((3, 3), dtype=float) + dfinvT = zeros((3, 3), dtype=float) + + # get marker arguments + markers = args_markers.markers + n_markers = args_markers.n_markers + Np = args_markers.Np + weight_idx = args_markers.weight_idx + first_free_idx = args_markers.first_free_idx + valid_mks = args_markers.valid_mks + n_cols = shape(markers)[1] + f_visc = zeros(3, dtype=float) + f_visc_cart = zeros(3, dtype=float) + + # -- removed omp: #$ omp parallel private(ip, eta1, eta2, eta3, dfinv) + # -- removed omp: #$ omp for + for ip in range(n_markers): + if not valid_mks[ip]: + continue + + eta1 = markers[ip, 0] + eta2 = markers[ip, 1] + eta3 = markers[ip, 2] + kappa = 1.0 # markers[ip, first_diagnostics_idx] + # n_at_eta = markers[ip, first_free_idx] + loc_box = int(markers[ip, n_cols - 2]) + + for j in range(3): # row of viscosity tensor + for k in range(3): # column = derivative direction + coeff_idx = first_free_idx + 3 * (j + 1) + k + + # if k == 0: + # deriv_type = kernel_type + 1 + # use_component = True + # elif k == 1 and kernel_type >= 340: + # deriv_type = kernel_type + 2 + # use_component = True + # elif k == 2 and kernel_type >= 670: + # deriv_type = kernel_type + 3 + # use_component = True + # else: + # use_component = False + + # if use_component: + f_visc[j] += sph_eval_kernels.box_based_kernel( + args_markers, + eta1, + eta2, + eta3, + loc_box, + boxes, + neighbours, + holes, + periodic1, + periodic2, + periodic3, + coeff_idx, + kernel_type + 1 + k, + h1, + h2, + h3, + ) + + # push to Cartesian coordinates + evaluation_kernels.df_inv( + eta1, + eta2, + eta3, + args_domain, + tmp1, + False, + dfinv, + ) + linalg_kernels.transpose(dfinv, dfinvT) + linalg_kernels.matrix_vector(dfinvT, f_visc, f_visc_cart) + + # update velocities + markers[ip, 3:6] -= dt * (f_visc_cart) + + # -- removed omp: #$ omp end parallel diff --git a/src/struphy/propagators/push_vin_sph_pressure.py b/src/struphy/propagators/push_vin_sph_pressure.py index 8abbbad0c..073d23576 100644 --- a/src/struphy/propagators/push_vin_sph_pressure.py +++ b/src/struphy/propagators/push_vin_sph_pressure.py @@ -9,7 +9,7 @@ from struphy.io.options import LiteralOptions, OptionsBase from struphy.models.variables import SPHVariable -from struphy.pic.pushing import eval_kernels_sph, pusher_kernels +from struphy.pic.pushing import eval_kernels_sph, pusher_kernels_sph from struphy.pic.pushing.pusher import Pusher from struphy.propagators.base import Propagator from struphy.utils.pyccel import Pyccelkernel @@ -23,15 +23,18 @@ class PushVinSPHpressure(Propagator): .. math:: - \frac{\textnormal d \mathbf v_p(t)}{\textnormal d t} = \kappa_p \sum_{i=1}^N w_i \left( \frac{1}{\rho^{N,h}(\boldsymbol \eta_p)} + \frac{1}{\rho^{N,h}(\boldsymbol \eta_i)} \right) DF^{-\top}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_i) \,, + \frac{\textnormal d \mathbf v_p(t)}{\textnormal d t} = \mathbf g - \sum_{i=1}^N w_p w_i \left( \frac{\partial \mathcal U}{\partial \rho}(\boldsymbol \eta_p) + \frac{\partial \mathcal U}{\partial \rho}(\boldsymbol \eta_i) \right) DF^{-\top}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_i) \,, - where :math:`DF^{-\top}` denotes the inverse transpose Jacobian, and with the smoothed density + where :math:`\mathbf g` is a constant acceleration and the second term corresponds to the pressure gradient. + Here, :math:`\mathcal U(\rho)` denotes the internal energy per unit mass + as a function of the mass density :math:`\rho` and :math:`DF^{-\top}` denotes the inverse transpose Jacobian + arising in the pull back of the gradient of the smoothing kernel :math:`W_h` + chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. + Two choices of the internal energy are implemented: + + * Isothermal closure: :math:`\mathcal U(\rho) = \kappa \, \ln(\rho)`, where :math:`\kappa` is constant. + * Polytropic closure: :math:`\mathcal U(\rho) = \kappa \, \rho^{\gamma - 1} / (\gamma - 1)`, where :math:`\kappa` is the polytropic constant and :math:`\gamma = C_p / C_v` is the polytropic index. - .. math:: - - \rho^{N,h}(\boldsymbol \eta) = \frac 1N \sum_{j=1}^N w_j \, W_h(\boldsymbol \eta - \boldsymbol \eta_j)\,, - - where :math:`W_h(\boldsymbol \eta)` is a smoothing kernel from :mod:`~struphy.pic.sph_smoothing_kernels`. Time stepping: * Explicit from :class:`~struphy.ode.utils.ButcherTableau` @@ -80,6 +83,9 @@ class Options(OptionsBase): gravity : tuple, default=(0.0, 0.0, 0.0) Constant gravity vector added in the SPH pressure push. + + kappa : float, default=1.0 + Coefficient in the internal energy function. thermodynamics : {"isothermal", "polytropic"}, default="isothermal" Thermodynamic closure selecting the SPH pressure kernel. @@ -93,6 +99,7 @@ class Options(OptionsBase): kernel_width: tuple = None algo: OptsAlgo = "forward_euler" gravity: tuple = (0.0, 0.0, 0.0) + kappa: float = 1.0 thermodynamics: OptsThermo = "isothermal" def __post_init__(self): @@ -153,9 +160,9 @@ def allocate(self): # pusher kernel if self.options.thermodynamics == "isothermal": - kernel = Pyccelkernel(pusher_kernels.push_v_sph_pressure) + kernel = Pyccelkernel(pusher_kernels_sph.push_v_sph_pressure) elif self.options.thermodynamics == "polytropic": - kernel = Pyccelkernel(pusher_kernels.push_v_sph_pressure_ideal_gas) + kernel = Pyccelkernel(pusher_kernels_sph.push_v_sph_pressure_ideal_gas) gravity = xp.array(self.options.gravity, dtype=float) @@ -167,6 +174,7 @@ def allocate(self): kernel_nr, *self.options.kernel_width, gravity, + self.options.kappa, ) # the Pusher class wraps around all kernels diff --git a/src/struphy/propagators/push_vin_viscous_potential.py b/src/struphy/propagators/push_vin_viscous_potential.py index 6dbde0d1e..f432640ca 100644 --- a/src/struphy/propagators/push_vin_viscous_potential.py +++ b/src/struphy/propagators/push_vin_viscous_potential.py @@ -8,7 +8,7 @@ from struphy.io.options import LiteralOptions, OptionsBase from struphy.models.variables import SPHVariable -from struphy.pic.pushing import eval_kernels_sph, pusher_kernels +from struphy.pic.pushing import eval_kernels_sph, pusher_kernels_sph from struphy.pic.pushing.pusher import Pusher from struphy.propagators.base import Propagator from struphy.utils.pyccel import Pyccelkernel @@ -170,7 +170,7 @@ def allocate(self): # ersetzt init args_init_visc, ) - kernel = Pyccelkernel(pusher_kernels.push_v_viscosity) + kernel = Pyccelkernel(pusher_kernels_sph.push_v_viscosity) args_kernel = ( boxes, From 375ecb7b6b1f08838922ba0f66fa8c9b9ebadf5a Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 12 Jun 2026 10:34:47 +0200 Subject: [PATCH 15/34] improve some sph docstrings --- doc/sections/subsections/dev-pic.rst | 12 +- src/struphy/pic/pushing/eval_kernels_sph.py | 498 ++++++++---------- src/struphy/pic/pushing/pusher_kernels_sph.py | 66 ++- .../propagators/push_vin_sph_pressure.py | 6 +- 4 files changed, 291 insertions(+), 291 deletions(-) diff --git a/doc/sections/subsections/dev-pic.rst b/doc/sections/subsections/dev-pic.rst index 8a6664a4a..d7a0f6b58 100644 --- a/doc/sections/subsections/dev-pic.rst +++ b/doc/sections/subsections/dev-pic.rst @@ -31,4 +31,14 @@ Accumulation kernels .. automodule:: struphy.pic.accumulation.accum_kernels_gc :members: :special-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: + + +Pusher class +^^^^^^^^^^^^ + +.. autoclass:: struphy.pic.pushing.pusher.Pusher + :members: + :special-members: + :show-inheritance: + :exclude-members: __init__ \ No newline at end of file diff --git a/src/struphy/pic/pushing/eval_kernels_sph.py b/src/struphy/pic/pushing/eval_kernels_sph.py index 0aad76dd0..9ab22199f 100644 --- a/src/struphy/pic/pushing/eval_kernels_sph.py +++ b/src/struphy/pic/pushing/eval_kernels_sph.py @@ -31,27 +31,17 @@ def sph_pressure_coeffs( h2: "float", h3: "float", ): - r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian + r"""For each particle, evaluate + + * the density :math:`\rho^{N,h}(\boldsymbol \eta_i)` abd stored it at ``markers[:, column_nr]``) + * the coefficient :math:`w_i/\rho^{N,h}(\boldsymbol \eta_i)` and stored it at ``markers[:, column_nr + 1]``) + * the coefficient :math:`w_i (\rho^{N,h}(\boldsymbol \eta_i))^{\gamma - 2}` and stored it at ``markers[:, column_nr + 2]``) + + where the smoothed SPH density is given by .. math:: - H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} - + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, - - that is - - .. math:: - - \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) - + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, - - where the evaluation point is the weighted average - :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, - for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point - :math:`\boldsymbol \eta_p` beforehand. - - The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` - in markers array for each particle. + \rho^{N,h}(\boldsymbol \eta_i) = \sum_j w_j \, W_h(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. """ gamma = 5 / 3 @@ -105,27 +95,7 @@ def sph_isotherm_kappa( comps: "int[:]", args_markers: "MarkerArguments", ): - r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian - - .. math:: - - H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} - + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, - - that is - - .. math:: - - \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) - + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, - - where the evaluation point is the weighted average - :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, - for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point - :math:`\boldsymbol \eta_p` beforehand. - - The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` - in markers array for each particle. + r"""None yet. """ # get marker arguments @@ -159,37 +129,31 @@ def sph_mean_velocity_coeffs( h2: "float", h3: "float", ): - r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian + r"""For each particle, evaluate the smoothed SPH density :math:`\rho^{N,h}(\boldsymbol \eta_i)` and store the + coefficient + + * :math:`w_i v_{k,i} / \rho^{N,h}(\boldsymbol \eta_i)` at ``markers[:, column_nr + k]`` for :math:`k = 0, 1, 2` + + where the smoothed SPH density is given by .. math:: - H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} - + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, + \rho^{N,h}(\boldsymbol \eta_i) = \sum_j w_j \, W_h(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. - that is + These coefficients serve as kernel weights so that one can evaluate + the SPH mean velocity field .. math:: - \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) - + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, - - where the evaluation point is the weighted average - :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, - for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point - :math:`\boldsymbol \eta_p` beforehand. - - The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` - in markers array for each particle. + v_k^{N,h}(\boldsymbol \eta_i) + = \sum_j \frac{w_j \, v_{k,j}}{\rho^{N,h}(\boldsymbol \eta_j)} \, + W_h(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. """ - gamma = 5 / 3 - # get marker arguments markers = args_markers.markers n_markers = args_markers.n_markers n_cols = shape(markers)[1] - Np = args_markers.Np - vdim = args_markers.vdim weight_idx = args_markers.weight_idx valid_mks = args_markers.valid_mks @@ -234,216 +198,216 @@ def sph_mean_velocity_coeffs( # logger.info(f"{ip = }, {weight = }, {n_at_eta = }, {velocities[0] = }") -@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") -def sph_mean_velocity( - alpha: "float[:]", - column_nr: int, - comps: "int[:]", - args_markers: "MarkerArguments", - args_domain: "DomainArguments", - boxes: "int[:, :]", - neighbours: "int[:, :]", - holes: "bool[:]", - periodic1: "bool", - periodic2: "bool", - periodic3: "bool", - kernel_type: "int", - h1: "float", - h2: "float", - h3: "float", -): - r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian - - .. math:: - - H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} - + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, - - that is - - .. math:: - - \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) - + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, - - where the evaluation point is the weighted average - :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, - for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point - :math:`\boldsymbol \eta_p` beforehand. - - The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` - in markers array for each particle. - """ - - gamma = 5 / 3 - - # get marker arguments - markers = args_markers.markers - n_markers = args_markers.n_markers - n_cols = shape(markers)[1] - Np = args_markers.Np - vdim = args_markers.vdim - weight_idx = args_markers.weight_idx - first_free_idx = args_markers.first_free_idx - valid_mks = args_markers.valid_mks - - for ip in range(n_markers): - # only do something if particle is a "true" particle - if not valid_mks[ip]: - continue - - eta1 = markers[ip, 0] - eta2 = markers[ip, 1] - eta3 = markers[ip, 2] - loc_box = int(markers[ip, n_cols - 2]) - v1_at_eta = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx, - kernel_type, - h1, - h2, - h3, - ) - - v2_at_eta = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + 1, - kernel_type, - h1, - h2, - h3, - ) - - v3_at_eta = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + 2, - kernel_type, - h1, - h2, - h3, - ) - # save - markers[ip, column_nr] = v1_at_eta - markers[ip, column_nr + 1] = v2_at_eta - markers[ip, column_nr + 2] = v3_at_eta - - -@stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") -def sph_grad_mean_velocity( - alpha: "float[:]", - column_nr: int, - comps: "int[:]", - args_markers: "MarkerArguments", - args_domain: "DomainArguments", - boxes: "int[:, :]", - neighbours: "int[:, :]", - holes: "bool[:]", - periodic1: "bool", - periodic2: "bool", - periodic3: "bool", - kernel_type: "int", - h1: "float", - h2: "float", - h3: "float", -): - r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian - - .. math:: - - H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} - + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, - - that is - - .. math:: - - \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) - + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, - - where the evaluation point is the weighted average - :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, - for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point - :math:`\boldsymbol \eta_p` beforehand. - - The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` - in markers array for each particle. - """ - - gamma = 5 / 3 - - # get marker arguments - markers = args_markers.markers - n_markers = args_markers.n_markers - n_cols = shape(markers)[1] - Np = args_markers.Np - vdim = args_markers.vdim - weight_idx = args_markers.weight_idx - first_free_idx = args_markers.first_free_idx - valid_mks = args_markers.valid_mks - - grad_v_at_eta = zeros((3, 3), dtype=float) - for ip in range(n_markers): - # only do something if particle is a "true" particle - if not valid_mks[ip]: - continue - - eta1 = markers[ip, 0] - eta2 = markers[ip, 1] - eta3 = markers[ip, 2] - loc_box = int(markers[ip, n_cols - 2]) - for j in range(3): - for k in range(3): - grad_v_at_eta[j, k] = sph_eval_kernels.box_based_kernel( - args_markers, - eta1, - eta2, - eta3, - loc_box, - boxes, - neighbours, - holes, - periodic1, - periodic2, - periodic3, - first_free_idx + j, - kernel_type + 1 + k, - h1, - h2, - h3, - ) - - # save - markers[ip, column_nr + 3 * j + k] = grad_v_at_eta[j, k] +# @stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") +# def sph_mean_velocity( +# alpha: "float[:]", +# column_nr: int, +# comps: "int[:]", +# args_markers: "MarkerArguments", +# args_domain: "DomainArguments", +# boxes: "int[:, :]", +# neighbours: "int[:, :]", +# holes: "bool[:]", +# periodic1: "bool", +# periodic2: "bool", +# periodic3: "bool", +# kernel_type: "int", +# h1: "float", +# h2: "float", +# h3: "float", +# ): +# r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian + +# .. math:: + +# H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} +# + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, + +# that is + +# .. math:: + +# \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) +# + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, + +# where the evaluation point is the weighted average +# :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, +# for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point +# :math:`\boldsymbol \eta_p` beforehand. + +# The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` +# in markers array for each particle. +# """ + +# gamma = 5 / 3 + +# # get marker arguments +# markers = args_markers.markers +# n_markers = args_markers.n_markers +# n_cols = shape(markers)[1] +# Np = args_markers.Np +# vdim = args_markers.vdim +# weight_idx = args_markers.weight_idx +# first_free_idx = args_markers.first_free_idx +# valid_mks = args_markers.valid_mks + +# for ip in range(n_markers): +# # only do something if particle is a "true" particle +# if not valid_mks[ip]: +# continue + +# eta1 = markers[ip, 0] +# eta2 = markers[ip, 1] +# eta3 = markers[ip, 2] +# loc_box = int(markers[ip, n_cols - 2]) +# v1_at_eta = sph_eval_kernels.box_based_kernel( +# args_markers, +# eta1, +# eta2, +# eta3, +# loc_box, +# boxes, +# neighbours, +# holes, +# periodic1, +# periodic2, +# periodic3, +# first_free_idx, +# kernel_type, +# h1, +# h2, +# h3, +# ) + +# v2_at_eta = sph_eval_kernels.box_based_kernel( +# args_markers, +# eta1, +# eta2, +# eta3, +# loc_box, +# boxes, +# neighbours, +# holes, +# periodic1, +# periodic2, +# periodic3, +# first_free_idx + 1, +# kernel_type, +# h1, +# h2, +# h3, +# ) + +# v3_at_eta = sph_eval_kernels.box_based_kernel( +# args_markers, +# eta1, +# eta2, +# eta3, +# loc_box, +# boxes, +# neighbours, +# holes, +# periodic1, +# periodic2, +# periodic3, +# first_free_idx + 2, +# kernel_type, +# h1, +# h2, +# h3, +# ) +# # save +# markers[ip, column_nr] = v1_at_eta +# markers[ip, column_nr + 1] = v2_at_eta +# markers[ip, column_nr + 2] = v3_at_eta + + +# @stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") +# def sph_grad_mean_velocity( +# alpha: "float[:]", +# column_nr: int, +# comps: "int[:]", +# args_markers: "MarkerArguments", +# args_domain: "DomainArguments", +# boxes: "int[:, :]", +# neighbours: "int[:, :]", +# holes: "bool[:]", +# periodic1: "bool", +# periodic2: "bool", +# periodic3: "bool", +# kernel_type: "int", +# h1: "float", +# h2: "float", +# h3: "float", +# ): +# r"""Evaluate the :math:`\boldsymbol \eta`-gradient of the Hamiltonian + +# .. math:: + +# H(\mathbf Z_p) = H(\boldsymbol \eta_p, v_{\parallel,p}) = \varepsilon \frac{v_{\parallel,p}^2}{2} +# + \varepsilon \mu |\hat \mathbf B| (\boldsymbol \eta_p) + \hat \phi(\boldsymbol \eta_p)\,, + +# that is + +# .. math:: + +# \hat \nabla H(\mathbf Z_p) = \varepsilon \mu \hat \nabla |\hat \mathbf B| (\boldsymbol \eta_p) +# + \hat \nabla \hat \phi(\boldsymbol \eta_p)\,, + +# where the evaluation point is the weighted average +# :math:`Z_{p,i} = \alpha_i Z_{p,i}^{n+1,k} + (1 - \alpha_i) Z_{p,i}^n`, +# for :math:`i=1,2,3,4`. Markers must be sorted according to the evaluation point +# :math:`\boldsymbol \eta_p` beforehand. + +# The components specified in ``comps`` are save at ``column_nr:column_nr + len(comps)`` +# in markers array for each particle. +# """ + +# gamma = 5 / 3 + +# # get marker arguments +# markers = args_markers.markers +# n_markers = args_markers.n_markers +# n_cols = shape(markers)[1] +# Np = args_markers.Np +# vdim = args_markers.vdim +# weight_idx = args_markers.weight_idx +# first_free_idx = args_markers.first_free_idx +# valid_mks = args_markers.valid_mks + +# grad_v_at_eta = zeros((3, 3), dtype=float) +# for ip in range(n_markers): +# # only do something if particle is a "true" particle +# if not valid_mks[ip]: +# continue + +# eta1 = markers[ip, 0] +# eta2 = markers[ip, 1] +# eta3 = markers[ip, 2] +# loc_box = int(markers[ip, n_cols - 2]) +# for j in range(3): +# for k in range(3): +# grad_v_at_eta[j, k] = sph_eval_kernels.box_based_kernel( +# args_markers, +# eta1, +# eta2, +# eta3, +# loc_box, +# boxes, +# neighbours, +# holes, +# periodic1, +# periodic2, +# periodic3, +# first_free_idx + j, +# kernel_type + 1 + k, +# h1, +# h2, +# h3, +# ) + +# # save +# markers[ip, column_nr + 3 * j + k] = grad_v_at_eta[j, k] @stack_array("eta_k", "eta_n", "eta", "grad_H", "e_field") diff --git a/src/struphy/pic/pushing/pusher_kernels_sph.py b/src/struphy/pic/pushing/pusher_kernels_sph.py index 2b68ae2ef..6e8489f66 100644 --- a/src/struphy/pic/pushing/pusher_kernels_sph.py +++ b/src/struphy/pic/pushing/pusher_kernels_sph.py @@ -23,7 +23,6 @@ from struphy.kernel_arguments.pusher_args_kernels import DerhamArguments, DomainArguments, MarkerArguments - @stack_array("grad_u", "grad_u_cart", "tmp1", "dfinv", "dfinvT") def push_v_sph_pressure( dt: float, @@ -47,17 +46,26 @@ def push_v_sph_pressure( .. math:: - \frac{\mathbf v_p^{n+1} - \mathbf v_p^n}{\Delta t} = \mathbf g - \sum_{i=1}^N w_p w_i \left( \frac{\kappa}{\rho^{N,h}(\boldsymbol \eta_p)} + \frac{\kappa}{\rho^{N,h}(\boldsymbol \eta_q)} \right) DF^{-\top}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_i) \,, + \frac{\mathbf v_p^{n+1} - \mathbf v_p^n}{\Delta t} = \mathbf g - \sum_{i=1}^N w_i \left( \frac{\kappa}{\rho^{N,h}(\boldsymbol \eta_p)} + \frac{\kappa}{\rho^{N,h}(\boldsymbol \eta_i)} \right) DF^{-\top}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_i) \,, where :math:`\mathbf g` is a constant acceleration, the second term corresponds to the pressure gradient in the isothermal closure (with constant :math:`\kappa`), and :math:`DF^{-\top}` denotes the inverse transpose Jacobian arising in the pull back of the gradient of the smoothing kernel :math:`W_h` chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. + The smoothed SPH density is given by .. math:: - \rho^{N,h}(\boldsymbol \eta_p) = \sum_j w_j \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_j)\,, + \rho^{N,h}(\boldsymbol \eta_p) = \sum_j w_j \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_j)\,. + + This kernel requires: + + * The density :math:`\rho^{N,h}(\boldsymbol \eta_p)` to be pre-computed for each particle and stored at ``markers[:, first_free_idx]``) + * The coefficient :math:`w_i/\rho^{N,h}(\boldsymbol \eta_i)` to be pre-computed for each particle and stored at ``markers[:, first_free_idx + 1]``) + + This is accomplished by the kernel :func:`~struphy.pic.pushing.eval_kernels_sph.sph_pressure_coeffs`, which needs + to be passed as an ``init_kernel`` to the :class:`~struphy.pic.pushing.pusher.Pusher`. Parameters ---------- @@ -152,6 +160,7 @@ def push_v_sph_pressure( h3, ) sum2 *= kappa + grad_u[0] += sum2 if kernel_type >= 340: @@ -278,19 +287,31 @@ def push_v_sph_pressure_ideal_gas( gravity: "float[:]", kappa: "float", ): - r"""Updates particle velocities as + r"""Update each marker :math:`p` according to .. math:: - \frac{\mathbf v^{n+1} - \mathbf v^n}{\Delta t} = \kappa_p \sum_{q} w_p\,w_q \left( \frac{1}{\rho^{N,h}(\boldsymbol \eta_p)} + \frac{1}{\rho^{N,h}(\boldsymbol \eta_q)} \right) G^{-1}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_q) \,, + \frac{\mathbf v_p^{n+1} - \mathbf v_p^n}{\Delta t} = \mathbf g - \sum_{i=1}^N w_i \left( \kappa (\rho^{N,h}(\boldsymbol \eta_p))^{\gamma - 2} + \kappa (\rho^{N,h}(\boldsymbol \eta_i))^{\gamma - 2} \right) DF^{-\top}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_i) \,, - where :math:`G^{-1}` denotes the inverse metric tensor, and with the smoothed density + where :math:`\mathbf g` is a constant acceleration, the second term corresponds to the pressure gradient + in the polytropic closure (with constant :math:`\kappa` and :math:`\gamma = 5/3`), + and :math:`DF^{-\top}` denotes the inverse transpose Jacobian + arising in the pull back of the gradient of the smoothing kernel :math:`W_h` + chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. + + The smoothed SPH density is given by .. math:: - \rho^{N,h}(\boldsymbol \eta_p) = \frac 1N \sum_q w_q \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_q)\,, - - where :math:`W_h(\boldsymbol \eta)` is a smoothing kernel from :mod:`~struphy.pic.sph_smoothing_kernels`. + \rho^{N,h}(\boldsymbol \eta_p) = \sum_j w_j \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_j)\,. + + This kernel requires: + + * The density :math:`\rho^{N,h}(\boldsymbol \eta_p)` to be pre-computed for each particle and stored at ``markers[:, first_free_idx]``) + * The coefficient :math:`w_i (\rho^{N,h}(\boldsymbol \eta_i))^{\gamma - 2}` to be pre-computed for each particle and stored at ``markers[:, first_free_idx + 2]``) + + This is accomplished by the kernel :func:`~struphy.pic.pushing.eval_kernels_sph.sph_pressure_coeffs`, which needs + to be passed as an ``init_kernel`` to the :class:`~struphy.pic.pushing.pusher.Pusher`. Parameters ---------- @@ -328,14 +349,12 @@ def push_v_sph_pressure_ideal_gas( # get marker arguments markers = args_markers.markers n_markers = args_markers.n_markers - Np = args_markers.Np weight_idx = args_markers.weight_idx first_free_idx = args_markers.first_free_idx valid_mks = args_markers.valid_mks n_cols = shape(markers)[1] gamma = 5 / 3 - kappa = 1 / (gamma - 1) # -- removed omp: #$ omp parallel private(ip, eta1, eta2, eta3, dfinv) # -- removed omp: #$ omp for @@ -513,19 +532,30 @@ def push_v_viscosity( h2: "float", h3: "float", ): - r"""Updates particle velocities as + r"""Update each marker :math:`p` according to .. math:: - \frac{\mathbf v^{n+1} - \mathbf v^n}{\Delta t} = \kappa_p \sum_{q} w_p\,w_q \left( \frac{1}{\rho^{N,h}(\boldsymbol \eta_p)} + \frac{1}{\rho^{N,h}(\boldsymbol \eta_q)} \right) G^{-1}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_q) \,, + \frac{\mathbf v_p^{n+1} - \mathbf v_p^n}{\Delta t} = \mu \sum_{i=1}^N \frac{w_i}{\rho^{N,h}(\boldsymbol \eta_i)} \left( \right) DF^{-\top} \nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_i) \,, - where :math:`G^{-1}` denotes the inverse metric tensor, and with the smoothed density + where :math:`\mathbf g` is a constant acceleration, the second term corresponds to the pressure gradient + in the isothermal closure (with constant :math:`\kappa`), and :math:`DF^{-\top}` denotes the inverse transpose Jacobian + arising in the pull back of the gradient of the smoothing kernel :math:`W_h` + chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. + + The smoothed SPH density is given by .. math:: - \rho^{N,h}(\boldsymbol \eta_p) = \frac 1N \sum_q w_q \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_q)\,, - - where :math:`W_h(\boldsymbol \eta)` is a smoothing kernel from :mod:`~struphy.pic.sph_smoothing_kernels`. + \rho^{N,h}(\boldsymbol \eta_i) = \sum_j w_j \, W_h(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. + + This kernel requires: + + * The density :math:`\rho^{N,h}(\boldsymbol \eta_p)` to be pre-computed for each particle and stored at ``markers[:, first_free_idx]``) + * The coefficient :math:`w_i/\rho^{N,h}(\boldsymbol \eta_i)` to be pre-computed for each particle and stored at ``markers[:, first_free_idx + 1]``) + + This is accomplished by the kernel :func:`~struphy.pic.pushing.eval_kernels_sph.sph_pressure_coeffs`, which needs + to be passed as an ``init_kernel`` to the :class:`~struphy.pic.pushing.pusher.Pusher`. Parameters ---------- @@ -577,8 +607,6 @@ def push_v_viscosity( eta1 = markers[ip, 0] eta2 = markers[ip, 1] eta3 = markers[ip, 2] - kappa = 1.0 # markers[ip, first_diagnostics_idx] - # n_at_eta = markers[ip, first_free_idx] loc_box = int(markers[ip, n_cols - 2]) for j in range(3): # row of viscosity tensor diff --git a/src/struphy/propagators/push_vin_sph_pressure.py b/src/struphy/propagators/push_vin_sph_pressure.py index 073d23576..64ec846a2 100644 --- a/src/struphy/propagators/push_vin_sph_pressure.py +++ b/src/struphy/propagators/push_vin_sph_pressure.py @@ -23,7 +23,7 @@ class PushVinSPHpressure(Propagator): .. math:: - \frac{\textnormal d \mathbf v_p(t)}{\textnormal d t} = \mathbf g - \sum_{i=1}^N w_p w_i \left( \frac{\partial \mathcal U}{\partial \rho}(\boldsymbol \eta_p) + \frac{\partial \mathcal U}{\partial \rho}(\boldsymbol \eta_i) \right) DF^{-\top}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_i) \,, + \frac{\textnormal d \mathbf v_p(t)}{\textnormal d t} = \mathbf g - \sum_{i=1}^N w_i \left( \frac{\partial \mathcal U}{\partial \rho}(\boldsymbol \eta_p) + \frac{\partial \mathcal U}{\partial \rho}(\boldsymbol \eta_i) \right) DF^{-\top}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_i) \,, where :math:`\mathbf g` is a constant acceleration and the second term corresponds to the pressure gradient. Here, :math:`\mathcal U(\rho)` denotes the internal energy per unit mass @@ -35,9 +35,7 @@ class PushVinSPHpressure(Propagator): * Isothermal closure: :math:`\mathcal U(\rho) = \kappa \, \ln(\rho)`, where :math:`\kappa` is constant. * Polytropic closure: :math:`\mathcal U(\rho) = \kappa \, \rho^{\gamma - 1} / (\gamma - 1)`, where :math:`\kappa` is the polytropic constant and :math:`\gamma = C_p / C_v` is the polytropic index. - Time stepping: - - * Explicit from :class:`~struphy.ode.utils.ButcherTableau` + Time stepping is forward Euler. """ class Variables: From e39e9701db28542ea2058dd8ca6aed4994d75359 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 12 Jun 2026 16:21:57 +0200 Subject: [PATCH 16/34] new unit test for velocity diffusion --- .../test_verif_ViscousEulerSPH.py | 287 ++++++++++-------- src/struphy/pic/pushing/eval_kernels_sph.py | 39 ++- src/struphy/pic/pushing/pusher_kernels_sph.py | 52 ++-- .../post_processing/post_processing_tools.py | 2 +- 4 files changed, 212 insertions(+), 168 deletions(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index 768d7c513..f431b27c3 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -26,9 +26,10 @@ set_logging_level, ) from struphy.models import ViscousEulerSPH +from struphy.initial.base import GenericPerturbation logger = logging.getLogger("struphy") -set_logging_level(logging.DEBUG) +set_logging_level(logging.INFO) @pytest.mark.parametrize("nx", [12, 24]) @@ -163,130 +164,168 @@ def test_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): shutil.rmtree(test_folder) -# @pytest.mark.parametrize("nx", [12, 24]) -# @pytest.mark.parametrize("plot_pts", [11, 32]) -# def test_damped_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): -# """Verification test for SPH discretization of isthermal Euler equations. -# A standing sound wave with c_s=1 is damped at the rate mu*k^2/2 by viscosity. -# """ - -# # environment options -# test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") -# out_folders = os.path.join(test_folder, "ViscousEulerSPH") -# env = EnvironmentOptions(out_folders=out_folders, sim_folder="damped_soundwave_1d") - -# # time stepping -# time_opts = Time(dt=0.01, Tend=0.18, split_algo="Strang") - -# # geometry -# r1 = 2.5 -# domain = domains.Cuboid(r1=r1) - -# # grid -# grid = None - -# # derham options -# derham_opts = None - -# # light-weight model instance -# model = ViscousEulerSPH(with_B0=False, with_viscosity=True) - -# ppb = 100 # Particles per box (controls resolution) -# loading_params = LoadingParameters(ppb=ppb, loading="tesselation") -# weights_params = WeightsParameters() -# boundary_params = BoundaryParameters() -# sorting_params = SortingParameters( -# boxes_per_dim=(nx, 1, 1), -# dims_mask=(True, False, False), -# ) - -# bin_plot = BinningPlot(slice="e1", n_bins=(32,), ranges=(0.0, 1.0)) -# kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=1) -# saving_params = SavingParameters( -# binning_plots=(bin_plot,), -# kernel_density_plots=(kd_plot,), -# ) - -# model.euler_fluid.set_markers( -# loading_params=loading_params, -# weights_params=weights_params, -# boundary_params=boundary_params, -# sorting_params=sorting_params, -# saving_params=saving_params, -# ) - -# # propagator options -# from struphy.ode.utils import ButcherTableau - -# butcher = ButcherTableau(algo="forward_euler") -# model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) -# if model.with_B0: -# model.propagators.push_vxb.options = model.propagators.push_vxb.Options() -# model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options(kernel_type="gaussian_1d") - -# # background, perturbations and initial conditions -# background = equils.ConstantVelocity() -# model.euler_fluid.var.add_background(background) -# perturbation = perturbations.ModesSin(ls=(1,), amps=(1.0e-2,)) -# model.euler_fluid.var.add_perturbation(del_n=perturbation) - -# # instance of simulation -# sim = Simulation( -# model=model, -# env=env, -# time_opts=time_opts, -# domain=domain, -# grid=grid, -# derham_opts=derham_opts, -# ) - -# # run -# sim.run() - -# # post processing -# if MPI.COMM_WORLD.Get_rank() == 0: -# sim.pproc() - -# # diagnostics -# sim.load_plotting_data() - -# ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph -# n_sph = sim.n_sph.euler_fluid.view_0.n_sph - -# if do_plot: -# dt = time_opts.dt -# end_time = time_opts.Tend -# Nt = int(end_time // dt) -# x = ee1 * r1 - -# plt.figure(figsize=(20, 40)) - -# dt_plot = end_time / 10.0 -# plot_ct = 0 -# time = 0.0 -# for i in range(Nt + 1): -# time = dt * i -# logger.info(f"{i =}, {time =:.4f}, {time // dt_plot =}, {plot_ct =}") -# if time // dt_plot >= plot_ct: -# plot_ct += 1 -# plt.subplot(5, 2, plot_ct) -# plt.plot(x.squeeze(), n_sph[i, :, 0, 0]) -# plt.xlim(0, 2.5) -# plt.grid(c="k") -# plt.xlabel("x") -# plt.ylabel(r"$\rho$") -# plt.title(f"time={i * dt:4.2f}") - -# plt.show() - -# error = xp.max(xp.abs(n_sph[0] - n_sph[-1])) -# logger.info(f"SPH sound wave {error =}.") -# assert error < 6e-4 -# logger.info("Assertion passed.") - -# shutil.rmtree(test_folder) +@pytest.mark.parametrize("nx", [12, 24]) +@pytest.mark.parametrize("plot_pts", [11, 32]) +def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): + """Verification test for SPH discretization of isthermal Euler equations. + A standing sound wave with c_s=1 is damped at the rate mu*k^2/2 by viscosity. + """ + + # environment options + test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") + out_folders = os.path.join(test_folder, "ViscousEulerSPH") + env = EnvironmentOptions(out_folders=out_folders, sim_folder="damped_soundwave_1d") + + # time stepping + time_opts = Time(dt=0.0025, Tend=0.1, split_algo="Strang") + + # geometry + r1 = 1.0 + domain = domains.Cuboid(r1=r1) + + # grid + grid = None + + # derham options + derham_opts = None + + # light-weight model instance + model = ViscousEulerSPH(with_B0=False, with_p=False, with_viscosity=True) + + ppb = 100 # Particles per box (controls resolution) + loading_params = LoadingParameters(ppb=ppb, loading="tesselation") + weights_params = WeightsParameters() + boundary_params = BoundaryParameters() + sorting_params = SortingParameters( + boxes_per_dim=(nx, 1, 1), + dims_mask=(True, False, False), + ) + + bin_plot = BinningPlot(slice="e1", n_bins=(16,), ranges=(0.0, 1.0)) + bin_plot_j1 = BinningPlot(slice="e1", n_bins=(16,), ranges=(0.0, 1.0), output_quantity="current_1") + kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=1) + saving_params = SavingParameters( + binning_plots=(bin_plot, bin_plot_j1), + kernel_density_plots=(kd_plot,), + ) + + model.euler_fluid.set_markers( + loading_params=loading_params, + weights_params=weights_params, + boundary_params=boundary_params, + sorting_params=sorting_params, + saving_params=saving_params, + ) + + # propagator options + from struphy.ode.utils import ButcherTableau + + butcher = ButcherTableau(algo="forward_euler") + model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) + + model.propagators.push_viscous.options = model.propagators.push_viscous.Options(kernel_type="gaussian_1d", mu=1.0) + + if model.with_B0: + model.propagators.push_vxb.options = model.propagators.push_vxb.Options() + if model.with_p: + model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options(kernel_type="gaussian_1d") + + + # background, perturbations and initial conditions + ux_mean = 0.0 + background = equils.ConstantVelocity(ux=ux_mean) + model.euler_fluid.var.add_background(background) + perturbation = perturbations.ModesSin(ls=(1,), amps=(0.5,)) + # perturbation = GenericPerturbation(fun=lambda e1, e2, e3: 0.2*xp.exp(-20 * (e1 - 0.5) ** 2)) + model.euler_fluid.var.add_perturbation(del_u1=perturbation) + + # instance of simulation + sim = Simulation( + model=model, + env=env, + time_opts=time_opts, + domain=domain, + grid=grid, + derham_opts=derham_opts, + ) + + # run + sim.run() + + # post processing + if MPI.COMM_WORLD.Get_rank() == 0: + sim.pproc() + + # diagnostics + sim.load_plotting_data() + + ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph + n_sph = sim.n_sph.euler_fluid.view_0.n_sph + e1_binned = sim.f.euler_fluid.e1_density.grid_e1 + n_binned = sim.f.euler_fluid.e1_density.f_binned + j1_binned = sim.f.euler_fluid.e1_current_1.f_binned + print(f"{e1_binned.shape = }") + print(f"{n_binned.shape = }") + print(f"{j1_binned.shape = }") + + if do_plot: + dt = time_opts.dt + end_time = time_opts.Tend + Nt = int(end_time // dt) + x = ee1 * r1 + + plt.figure(figsize=(20, 40)) + + plot_interval = 4 + n_rows = 8 + plot_ct = 0 + time = 0.0 + for i in range(Nt + 1): + time = dt * i + if i % plot_interval == 0: + logger.info(f"{i =}, {time =:.4f}, {plot_ct =}") + plt.subplot(n_rows, 3, plot_ct + 1) + plt.plot(x.squeeze(), n_sph[i, :, 0, 0], label=f"n_sph at time={time:.4f}", linewidth=2) + plt.xlim(0, r1) + plt.grid(c="k", linestyle="--") + plt.xlabel("x") + plt.ylim([0.8, 1.2]) + # plt.title(f"n_sph at time={time:.4f}") + plt.legend() + + plt.subplot(n_rows, 3, plot_ct + 2) + plt.plot(e1_binned, n_binned[i, :], label=f"n_binned at time={i * dt:4.2f}", linewidth=2) + plt.xlim(0, r1) + plt.grid(c="k", linestyle="--") + plt.xlabel("x") + plt.ylim([0.8, 1.2]) + # plt.title(f"n_binned at time={i * dt:4.2f}") + plt.legend() + + plt.subplot(n_rows, 3, plot_ct + 3) + plt.plot(e1_binned, j1_binned[i, :], label=f"j1_binned at time={i * dt:4.2f}", linewidth=2) + plt.xlim(0, r1) + plt.grid(c="k", linestyle="--") + plt.xlabel("x") + plt.ylim([ux_mean - 0.5, ux_mean + 0.5]) + # plt.title(f"j1_binned at time={i * dt:4.2f}") + plt.legend() + + plot_ct += 3 + if plot_ct == n_rows * 3: + break + + plt.show() + + error = xp.max(xp.abs(j1_binned[-1] - ux_mean)) + logger.info(f"SPH sound wave {error =}.") + assert error < 0.0022 + logger.info("Assertion passed.") + + shutil.rmtree(test_folder) if __name__ == "__main__": - test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) + # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) # test_damped_soundwave_1d(nx=12, plot_pts=11, do_plot=True) + test_velocity_diffusion(nx=8, plot_pts=11, do_plot=True) diff --git a/src/struphy/pic/pushing/eval_kernels_sph.py b/src/struphy/pic/pushing/eval_kernels_sph.py index 9ab22199f..1334e2ba4 100644 --- a/src/struphy/pic/pushing/eval_kernels_sph.py +++ b/src/struphy/pic/pushing/eval_kernels_sph.py @@ -429,36 +429,43 @@ def sph_viscosity_tensor( h3: "float", mu: "float", ): - r"""Evaluate the viscous stress tensor at each particle location using SPH. + r"""For each particle, evaluate the smoothed SPH density :math:`\rho^{N,h}(\boldsymbol \eta_i)` and the + deviatoric strain rate, and store the 9 coefficients - Computes the deviatoric viscous stress tensor: + * :math:`- w_i \, \sigma_{jk}(\boldsymbol \eta_i) / \rho^{N,h}(\boldsymbol \eta_i)` at + ``markers[:, column_nr + 3*j + k]`` for :math:`j, k = 0, 1, 2` + + where the smoothed SPH density is given by .. math:: - \boldsymbol{\sigma}_{ij} = -2\mu \left(\dot{\gamma}_{ij} - \frac{1}{3}\delta_{ij}\dot{\gamma}_{kk}\right)\,, + \rho^{N,h}(\boldsymbol \eta_i) = \sum_l w_l \, W_h(\boldsymbol \eta_i - \boldsymbol \eta_l)\,, - where :math:`\dot{\gamma}_{ij}` is the strain rate tensor computed from the velocity gradient - :math:`\dot{\gamma}_{ij} = \frac{1}{2}\left(\frac{\partial v_i}{\partial x_j} + \frac{\partial v_j}{\partial x_i}\right)`, - and :math:`\mu` is the dynamic viscosity coefficient. The deviatoric part (removal of trace) - ensures incompressibility in the momentum equation. + and the deviatoric strain rate is the traceless symmetric part of the mean velocity gradient, + + .. math:: - The velocity gradient is evaluated at particle positions using SPH kernel interpolation. - The density factor :math:`(w/n)^2` accounts for the local particle number density :math:`n_{\eta}`, - where :math:`w` is the particle weight. + \sigma_{jk}(\boldsymbol \eta_i) + = \mu\bigl[ \partial_j v_k^{N,h}(\boldsymbol \eta_i) + \partial_k v_j^{N,h}(\boldsymbol \eta_i) + - \tfrac{2}{3}\delta_{jk}\bigr] \, \partial_l v_l^{N,h}(\boldsymbol \eta_i)\,. - Parameters evaluated at location :math:`\boldsymbol{\eta}_p` using weighted SPH kernel interpolation - over neighboring particles in boxes. + These coefficients serve as kernel weights so that one can evaluate the viscous force - All 9 components of the symmetric stress tensor are saved at - ``column_nr:column_nr+9`` in markers array for each particle (in row-major order). + .. math:: + + (-\nabla \cdot \Pi_{\textrm{vis}})^{N,h}_j(\boldsymbol \eta_i) + = \sum_l \frac{ w_l \, \sigma_{jk}(\boldsymbol \eta_l)}{\rho^{N,h}(\boldsymbol \eta_l)} \, + (\nabla W_h)_k(\boldsymbol \eta_i - \boldsymbol \eta_l)\,. + + This kernel requires the coefficients of the mean velocity :math:`v_k^{N,h}` + for each particle to be pre-evaluated and stored at ``markers[:, first_free_idx:first_free_idx + 3]``, + which can be achieved by the kernel :func:`~struphy.pic.pushing.eval_kernels_sph.sph_mean_velocity_coeffs`. """ # get marker arguments markers = args_markers.markers n_markers = args_markers.n_markers n_cols = shape(markers)[1] - Np = args_markers.Np - vdim = args_markers.vdim weight_idx = args_markers.weight_idx first_free_idx = args_markers.first_free_idx valid_mks = args_markers.valid_mks diff --git a/src/struphy/pic/pushing/pusher_kernels_sph.py b/src/struphy/pic/pushing/pusher_kernels_sph.py index 6e8489f66..92a279dd7 100644 --- a/src/struphy/pic/pushing/pusher_kernels_sph.py +++ b/src/struphy/pic/pushing/pusher_kernels_sph.py @@ -536,26 +536,30 @@ def push_v_viscosity( .. math:: - \frac{\mathbf v_p^{n+1} - \mathbf v_p^n}{\Delta t} = \mu \sum_{i=1}^N \frac{w_i}{\rho^{N,h}(\boldsymbol \eta_i)} \left( \right) DF^{-\top} \nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_i) \,, - - where :math:`\mathbf g` is a constant acceleration, the second term corresponds to the pressure gradient - in the isothermal closure (with constant :math:`\kappa`), and :math:`DF^{-\top}` denotes the inverse transpose Jacobian - arising in the pull back of the gradient of the smoothing kernel :math:`W_h` - chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. - - The smoothed SPH density is given by - - .. math:: - - \rho^{N,h}(\boldsymbol \eta_i) = \sum_j w_j \, W_h(\boldsymbol \eta_i - \boldsymbol \eta_j)\,. - - This kernel requires: - - * The density :math:`\rho^{N,h}(\boldsymbol \eta_p)` to be pre-computed for each particle and stored at ``markers[:, first_free_idx]``) - * The coefficient :math:`w_i/\rho^{N,h}(\boldsymbol \eta_i)` to be pre-computed for each particle and stored at ``markers[:, first_free_idx + 1]``) - - This is accomplished by the kernel :func:`~struphy.pic.pushing.eval_kernels_sph.sph_pressure_coeffs`, which needs - to be passed as an ``init_kernel`` to the :class:`~struphy.pic.pushing.pusher.Pusher`. + \frac{v_{p,j}^{n+1} - v_{p,j}^n}{\Delta t} + = \sum_{i=1}^N + \frac{w_i \, \sigma_{jk}(\boldsymbol \eta_i)}{\rho^{N,h}(\boldsymbol \eta_i)} \, + \bigl(DF^{-\top} \nabla W_h\bigr)_k(\boldsymbol \eta_p - \boldsymbol \eta_i)\,, + + where :math:`\sigma_{jk} = \mu \left[(\partial_k v_j^{N,h} + \partial_j v_k^{N,h}) - \tfrac{2}{3}\delta_{jk}\partial_l v_l^{N,h}\right]` + is the deviatoric strain rate, and :math:`DF^{-\top}` denotes the inverse transpose Jacobian + arising in the pull back of the gradient of the smoothing kernel :math:`W_h` + chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. + + This kernel requires the 9 coefficients + + * :math:`w_i \, \sigma_{jk}(\boldsymbol \eta_i) / \rho^{N,h}(\boldsymbol \eta_i)` to be + pre-computed for each particle and stored at ``markers[:, first_free_idx + 3*(j+1) + k]`` + for :math:`j, k = 0, 1, 2` + + This is accomplished by the kernel + :func:`~struphy.pic.pushing.eval_kernels_sph.sph_viscosity_tensor`, which itself requires + the mean velocity coefficients + :math:`w_i v_{k,i} / \rho^{N,h}(\boldsymbol \eta_i)` to be stored at + ``markers[:, first_free_idx:first_free_idx + 3]`` via + :func:`~struphy.pic.pushing.eval_kernels_sph.sph_mean_velocity_coeffs`. + Both kernels must be passed as ``init_kernel`` entries to the + :class:`~struphy.pic.pushing.pusher.Pusher`. Parameters ---------- @@ -576,13 +580,8 @@ def push_v_viscosity( h1, h2, h3 : float Kernel width in respective dimension. - - gravity: xp.ndarray - Constant gravitational force as 3-vector. """ # allocate arrays - grad_u = zeros(3, dtype=float) - grad_u_cart = zeros(3, dtype=float) tmp1 = zeros((3, 3), dtype=float) dfinv = zeros((3, 3), dtype=float) dfinvT = zeros((3, 3), dtype=float) @@ -590,8 +589,6 @@ def push_v_viscosity( # get marker arguments markers = args_markers.markers n_markers = args_markers.n_markers - Np = args_markers.Np - weight_idx = args_markers.weight_idx first_free_idx = args_markers.first_free_idx valid_mks = args_markers.valid_mks n_cols = shape(markers)[1] @@ -609,6 +606,7 @@ def push_v_viscosity( eta3 = markers[ip, 2] loc_box = int(markers[ip, n_cols - 2]) + f_visc[:] = 0.0 for j in range(3): # row of viscosity tensor for k in range(3): # column = derivative direction coeff_idx = first_free_idx + 3 * (j + 1) + k diff --git a/src/struphy/post_processing/post_processing_tools.py b/src/struphy/post_processing/post_processing_tools.py index 19d1543f6..5063c5566 100644 --- a/src/struphy/post_processing/post_processing_tools.py +++ b/src/struphy/post_processing/post_processing_tools.py @@ -1270,7 +1270,7 @@ def load(self): for file in files: name = file.split(".")[0] tmp = xp.load(os.path.join(path_dat, sli, file)) - # logger.info(f"{name = }") + logger.info(f"{name = }") setattr(s, name, tmp) elif "n_sph" in folder: From 789b4d74cc11a0ea06305f07a4024e95f1719d4c Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 12 Jun 2026 16:40:42 +0200 Subject: [PATCH 17/34] show that the velocity diffusion prodices the correct decay rate --- .../test_verif_ViscousEulerSPH.py | 60 +++++++++++++++++-- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index f431b27c3..f0b9ac036 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -164,8 +164,8 @@ def test_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): shutil.rmtree(test_folder) -@pytest.mark.parametrize("nx", [12, 24]) -@pytest.mark.parametrize("plot_pts", [11, 32]) +@pytest.mark.parametrize("nx", [8]) +@pytest.mark.parametrize("plot_pts", [11]) def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): """Verification test for SPH discretization of isthermal Euler equations. A standing sound wave with c_s=1 is damped at the rate mu*k^2/2 by viscosity. @@ -223,7 +223,8 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): butcher = ButcherTableau(algo="forward_euler") model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) - model.propagators.push_viscous.options = model.propagators.push_viscous.Options(kernel_type="gaussian_1d", mu=1.0) + mu = 1.0 + model.propagators.push_viscous.options = model.propagators.push_viscous.Options(kernel_type="gaussian_1d", mu=mu) if model.with_B0: model.propagators.push_vxb.options = model.propagators.push_vxb.Options() @@ -268,6 +269,34 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): print(f"{n_binned.shape = }") print(f"{j1_binned.shape = }") + import numpy as np + + dt = time_opts.dt + Nt = int(time_opts.Tend // dt) + times = np.linspace(0.0, time_opts.Tend, Nt + 1) + + # sin(2*pi*x/r1) for mode l=1 peaks at x = 0.25*r1 + e1_np = np.asarray(e1_binned).flatten() + idx_max = int(np.argmin(np.abs(e1_np - 0.25 * r1))) + + # amplitude time series at the peak bin + amplitude = np.asarray(j1_binned[:, idx_max]).flatten() + + # analytical decay rate: gamma = mu * k^2, k = 2*pi/r1 for mode l=1 + k = 2.0 * np.pi / r1 + gamma_analytical = mu * 4/3 * k**2 + + A0 = amplitude[0] + amplitude_analytical = A0 * np.exp(-gamma_analytical * times) + + # numerical decay rate via linear fit to log(amplitude) + log_amp = np.log(np.abs(amplitude) + 1e-15) + coeffs = np.polyfit(times, log_amp, 1) + gamma_numerical = -coeffs[0] + + logger.info(f"Analytical decay rate: gamma = mu*k^2 = {gamma_analytical:.4f}") + logger.info(f"Numerical decay rate: gamma = {gamma_numerical:.4f}") + if do_plot: dt = time_opts.dt end_time = time_opts.Tend @@ -312,9 +341,22 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): plt.legend() plot_ct += 3 - if plot_ct == n_rows * 3: + if plot_ct == n_rows * 3: break - + + plt.show() + + fig, ax = plt.subplots(figsize=(8, 5)) + ax.semilogy(times, np.abs(amplitude), "o-", markersize=3, + label=f"Numerical (fitted rate = {gamma_numerical:.3f})") + ax.semilogy(times, np.abs(amplitude_analytical), "--", + label=rf"Analytical: $\gamma = (4/3) \mu k^2 = {gamma_analytical:.3f}$") + ax.set_xlabel("time") + ax.set_ylabel(rf"velocity amplitude at $x = {e1_np[idx_max]:.3f}$") + ax.set_title("Velocity diffusion: amplitude decay over time") + ax.legend() + ax.grid(True, which="both") + plt.tight_layout() plt.show() error = xp.max(xp.abs(j1_binned[-1] - ux_mean)) @@ -322,6 +364,14 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): assert error < 0.0022 logger.info("Assertion passed.") + rel_error = abs(gamma_numerical - gamma_analytical) / gamma_analytical + logger.info(f"Relative error in decay rate: {rel_error * 100:.2f}%") + assert rel_error < 0.04, ( + f"Numerical decay rate {gamma_numerical:.4f} deviates {rel_error * 100:.1f}% " + f"from analytical {gamma_analytical:.4f} (tolerance 4%)" + ) + logger.info("Decay rate assertion passed.") + shutil.rmtree(test_folder) From 95a01e3b9cb25ffbf151671aa0c9b176093d835c Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 12 Jun 2026 16:58:42 +0200 Subject: [PATCH 18/34] add a test for damped sound wave --- .../test_verif_ViscousEulerSPH.py | 212 +++++++++++++++++- 1 file changed, 209 insertions(+), 3 deletions(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index f0b9ac036..61b53b4ae 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -174,7 +174,7 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): # environment options test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") out_folders = os.path.join(test_folder, "ViscousEulerSPH") - env = EnvironmentOptions(out_folders=out_folders, sim_folder="damped_soundwave_1d") + env = EnvironmentOptions(out_folders=out_folders, sim_folder="velocity_diffusion") # time stepping time_opts = Time(dt=0.0025, Tend=0.1, split_algo="Strang") @@ -375,7 +375,213 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): shutil.rmtree(test_folder) +@pytest.mark.parametrize("nx", [8]) +@pytest.mark.parametrize("plot_pts", [11]) +def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): + """Verification test for SPH discretization of isthermal Euler equations. + A standing sound wave with c_s=1 is damped at the rate mu*k^2/2 by viscosity. + """ + + # environment options + test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") + out_folders = os.path.join(test_folder, "ViscousEulerSPH") + env = EnvironmentOptions(out_folders=out_folders, sim_folder="damped_soundwave_1d") + + # time stepping + time_opts = Time(dt=0.05, Tend=4.0, split_algo="Strang") + + # geometry + r1 = 1.0 + domain = domains.Cuboid(r1=r1) + + # grid + grid = None + + # derham options + derham_opts = None + + # light-weight model instance + model = ViscousEulerSPH(with_B0=False, with_p=True, with_viscosity=True) + + ppb = 100 # Particles per box (controls resolution) + loading_params = LoadingParameters(ppb=ppb, loading="tesselation") + weights_params = WeightsParameters() + boundary_params = BoundaryParameters() + sorting_params = SortingParameters( + boxes_per_dim=(nx, 1, 1), + dims_mask=(True, False, False), + ) + + bin_plot = BinningPlot(slice="e1", n_bins=(16,), ranges=(0.0, 1.0)) + bin_plot_j1 = BinningPlot(slice="e1", n_bins=(16,), ranges=(0.0, 1.0), output_quantity="current_1") + kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=1) + saving_params = SavingParameters( + binning_plots=(bin_plot, bin_plot_j1), + kernel_density_plots=(kd_plot,), + ) + + model.euler_fluid.set_markers( + loading_params=loading_params, + weights_params=weights_params, + boundary_params=boundary_params, + sorting_params=sorting_params, + saving_params=saving_params, + ) + + # propagator options + from struphy.ode.utils import ButcherTableau + + butcher = ButcherTableau(algo="forward_euler") + model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) + + mu = 0.05 + model.propagators.push_viscous.options = model.propagators.push_viscous.Options(kernel_type="gaussian_1d", mu=mu) + + if model.with_B0: + model.propagators.push_vxb.options = model.propagators.push_vxb.Options() + if model.with_p: + model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options(kernel_type="gaussian_1d") + + + # background, perturbations and initial conditions + background = equils.ConstantVelocity() + model.euler_fluid.var.add_background(background) + perturbation = perturbations.ModesSin(ls=(1,), amps=(1e-2,)) + model.euler_fluid.var.add_perturbation(del_n=perturbation) + + # instance of simulation + sim = Simulation( + model=model, + env=env, + time_opts=time_opts, + domain=domain, + grid=grid, + derham_opts=derham_opts, + ) + + # run + sim.run() + + # post processing + if MPI.COMM_WORLD.Get_rank() == 0: + sim.pproc() + + # diagnostics + sim.load_plotting_data() + + ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph + n_sph = sim.n_sph.euler_fluid.view_0.n_sph + e1_binned = sim.f.euler_fluid.e1_density.grid_e1 + n_binned = sim.f.euler_fluid.e1_density.f_binned + j1_binned = sim.f.euler_fluid.e1_current_1.f_binned + print(f"{e1_binned.shape = }") + print(f"{n_binned.shape = }") + print(f"{j1_binned.shape = }") + + import numpy as np + + dt = time_opts.dt + Nt = len(sim.t_grid) - 1 + times = np.linspace(0.0, time_opts.Tend, Nt + 1) + + # sin(2*pi*x/r1) for mode l=1 peaks at x = 0.25*r1 + e1_np = np.asarray(e1_binned).flatten() + idx_max = int(np.argmin(np.abs(e1_np - 0.25 * r1))) + + # amplitude time series at the peak bin + amplitude = np.asarray(j1_binned[:, idx_max]).flatten() + + # analytical decay rate: gamma = mu * k^2, k = 2*pi/r1 for mode l=1 + k = 2.0 * np.pi / r1 + gamma_analytical = mu * 4/3 * k**2 + + A0 = amplitude[0] + amplitude_analytical = A0 * np.exp(-gamma_analytical * times) + + # numerical decay rate via linear fit to log(amplitude) + log_amp = np.log(np.abs(amplitude) + 1e-15) + print(f"{times.shape = }") + print(f"{log_amp.shape = }") + coeffs = np.polyfit(times, log_amp, 1) + gamma_numerical = -coeffs[0] + + logger.info(f"Analytical decay rate: gamma = mu*k^2 = {gamma_analytical:.4f}") + logger.info(f"Numerical decay rate: gamma = {gamma_numerical:.4f}") + + if do_plot: + dt = time_opts.dt + end_time = time_opts.Tend + Nt = int(end_time // dt) + x = ee1 * r1 + + plt.figure(figsize=(20, 40)) + + plot_interval = 4 + n_rows = 8 + plot_ct = 0 + time = 0.0 + for i in range(Nt + 1): + time = dt * i + if i % plot_interval == 0: + logger.info(f"{i =}, {time =:.4f}, {plot_ct =}") + plt.subplot(n_rows, 3, plot_ct + 1) + plt.plot(x.squeeze(), n_sph[i, :, 0, 0], label=f"n_sph at time={time:.4f}", linewidth=2) + plt.xlim(0, r1) + plt.grid(c="k", linestyle="--") + plt.xlabel("x") + plt.ylim([0.98, 1.02]) + # plt.title(f"n_sph at time={time:.4f}") + plt.legend() + + plt.subplot(n_rows, 3, plot_ct + 2) + plt.plot(e1_binned, n_binned[i, :], label=f"n_binned at time={i * dt:4.2f}", linewidth=2) + plt.xlim(0, r1) + plt.grid(c="k", linestyle="--") + plt.xlabel("x") + plt.ylim([0.98, 1.02]) + # plt.title(f"n_binned at time={i * dt:4.2f}") + plt.legend() + + plt.subplot(n_rows, 3, plot_ct + 3) + plt.plot(e1_binned, j1_binned[i, :], label=f"j1_binned at time={i * dt:4.2f}", linewidth=2) + plt.xlim(0, r1) + plt.grid(c="k", linestyle="--") + plt.xlabel("x") + plt.ylim([- 0.02, 0.02]) + # plt.title(f"j1_binned at time={i * dt:4.2f}") + plt.legend() + + plot_ct += 3 + if plot_ct == n_rows * 3: + break + + plt.show() + + fig, ax = plt.subplots(figsize=(8, 5)) + ax.semilogy(times, np.abs(amplitude), "o-", markersize=3, + label=f"Numerical (fitted rate = {gamma_numerical:.3f})") + ax.semilogy(times, np.abs(amplitude_analytical), "--", + label=rf"Analytical: $\gamma = (4/3) \mu k^2 = {gamma_analytical:.3f}$") + ax.set_xlabel("time") + ax.set_ylabel(rf"velocity amplitude at $x = {e1_np[idx_max]:.3f}$") + ax.set_title("Velocity diffusion: amplitude decay over time") + ax.legend() + ax.grid(True, which="both") + plt.tight_layout() + plt.show() + + rel_error = abs(gamma_numerical - gamma_analytical) / gamma_analytical + logger.info(f"Relative error in decay rate: {rel_error * 100:.2f}%") + assert rel_error < 0.04, ( + f"Numerical decay rate {gamma_numerical:.4f} deviates {rel_error * 100:.1f}% " + f"from analytical {gamma_analytical:.4f} (tolerance 4%)" + ) + logger.info("Decay rate assertion passed.") + + shutil.rmtree(test_folder) + + if __name__ == "__main__": # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) - # test_damped_soundwave_1d(nx=12, plot_pts=11, do_plot=True) - test_velocity_diffusion(nx=8, plot_pts=11, do_plot=True) + # test_velocity_diffusion(nx=8, plot_pts=11, do_plot=True) + test_damped_sound_wave(nx=8, plot_pts=11, do_plot=True) From 77e1b66baf6fe793e7b361ab500f7e1f074c379b Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Sat, 13 Jun 2026 11:23:02 +0200 Subject: [PATCH 19/34] added damped sound wave verification test --- .../test_verif_ViscousEulerSPH.py | 239 +++++++++--------- 1 file changed, 119 insertions(+), 120 deletions(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index 61b53b4ae..df91eca51 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -165,19 +165,21 @@ def test_soundwave_1d(nx: int, plot_pts: int, do_plot: bool = False): @pytest.mark.parametrize("nx", [8]) -@pytest.mark.parametrize("plot_pts", [11]) -def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): - """Verification test for SPH discretization of isthermal Euler equations. - A standing sound wave with c_s=1 is damped at the rate mu*k^2/2 by viscosity. +@pytest.mark.parametrize("plot_pts", [21]) +def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): + """Verification test for SPH discretization of viscous isothermal Euler equations. + A standing sound wave decays at rate mu*k^2/2. + The numerical decay rate is extracted from local maxima of the current, analogous to Landau damping. """ # environment options test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") out_folders = os.path.join(test_folder, "ViscousEulerSPH") - env = EnvironmentOptions(out_folders=out_folders, sim_folder="velocity_diffusion") + env = EnvironmentOptions(out_folders=out_folders, sim_folder="damped_soundwave_1d") - # time stepping - time_opts = Time(dt=0.0025, Tend=0.1, split_algo="Strang") + # time stepping: Tend covers ~10 oscillation periods (T=r1/c_s=1 for mode l=1) + mu = 0.01 + time_opts = Time(dt=0.01, Tend=10.0, split_algo="Strang") # geometry r1 = 1.0 @@ -189,11 +191,10 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): # derham options derham_opts = None - # light-weight model instance - model = ViscousEulerSPH(with_B0=False, with_p=False, with_viscosity=True) + # light-weight model instance (with_p=True and with_viscosity=True are the defaults) + model = ViscousEulerSPH(with_B0=False) - ppb = 100 # Particles per box (controls resolution) - loading_params = LoadingParameters(ppb=ppb, loading="tesselation") + loading_params = LoadingParameters(ppb=8, loading="tesselation") weights_params = WeightsParameters() boundary_params = BoundaryParameters() sorting_params = SortingParameters( @@ -222,22 +223,15 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): butcher = ButcherTableau(algo="forward_euler") model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) - - mu = 1.0 - model.propagators.push_viscous.options = model.propagators.push_viscous.Options(kernel_type="gaussian_1d", mu=mu) - - if model.with_B0: - model.propagators.push_vxb.options = model.propagators.push_vxb.Options() - if model.with_p: - model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options(kernel_type="gaussian_1d") - + model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options(kernel_type="gaussian_1d") + model.propagators.push_viscous.options = model.propagators.push_viscous.Options( + kernel_type="gaussian_1d", mu=mu + ) - # background, perturbations and initial conditions - ux_mean = 0.0 - background = equils.ConstantVelocity(ux=ux_mean) + # background and initial conditions: velocity perturbation excites the sound wave + background = equils.ConstantVelocity() model.euler_fluid.var.add_background(background) - perturbation = perturbations.ModesSin(ls=(1,), amps=(0.5,)) - # perturbation = GenericPerturbation(fun=lambda e1, e2, e3: 0.2*xp.exp(-20 * (e1 - 0.5) ** 2)) + perturbation = perturbations.ModesSin(ls=(1,), amps=(1.0e-2,)) model.euler_fluid.var.add_perturbation(del_u1=perturbation) # instance of simulation @@ -260,124 +254,123 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): # diagnostics sim.load_plotting_data() - ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph - n_sph = sim.n_sph.euler_fluid.view_0.n_sph e1_binned = sim.f.euler_fluid.e1_density.grid_e1 - n_binned = sim.f.euler_fluid.e1_density.f_binned + n_binned = sim.f.euler_fluid.e1_density.delta_f_binned j1_binned = sim.f.euler_fluid.e1_current_1.f_binned + ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph + n_sph = sim.n_sph.euler_fluid.view_0.n_sph + print(f"{e1_binned.shape = }") print(f"{n_binned.shape = }") print(f"{j1_binned.shape = }") + print(f"{n_sph.shape = }") import numpy as np dt = time_opts.dt - Nt = int(time_opts.Tend // dt) + Nt = j1_binned.shape[0] - 1 times = np.linspace(0.0, time_opts.Tend, Nt + 1) - # sin(2*pi*x/r1) for mode l=1 peaks at x = 0.25*r1 + # velocity antinode: sin(2*pi*x/r1) peaks closest to x=0.25*r1 e1_np = np.asarray(e1_binned).flatten() idx_max = int(np.argmin(np.abs(e1_np - 0.25 * r1))) - - # amplitude time series at the peak bin amplitude = np.asarray(j1_binned[:, idx_max]).flatten() - # analytical decay rate: gamma = mu * k^2, k = 2*pi/r1 for mode l=1 + # analytical decay rate: gamma = -mu*k^2/2 for the acoustic mode k = 2.0 * np.pi / r1 - gamma_analytical = mu * 4/3 * k**2 + gamma_analytical = -mu * 4/3 * k**2 / 2 A0 = amplitude[0] - amplitude_analytical = A0 * np.exp(-gamma_analytical * times) - - # numerical decay rate via linear fit to log(amplitude) - log_amp = np.log(np.abs(amplitude) + 1e-15) - coeffs = np.polyfit(times, log_amp, 1) - gamma_numerical = -coeffs[0] + amplitude_analytical = A0 * np.exp(gamma_analytical * times) - logger.info(f"Analytical decay rate: gamma = mu*k^2 = {gamma_analytical:.4f}") - logger.info(f"Numerical decay rate: gamma = {gamma_numerical:.4f}") + # find local maxima of the oscillating amplitude (analogous to Landau damping) + logA = np.log(np.abs(amplitude) + 1e-15) + dlogA = (np.roll(logA, -1) - np.roll(logA, 1))[1:-1] / (2.0 * dt) + zeros = dlogA * np.roll(dlogA, -1) < 0.0 + maxima_inds = np.where(np.logical_and(zeros, dlogA > 0.0))[0] + 1 + maxima = logA[maxima_inds] + print(f"{maxima_inds = }, {times.shape = }") + t_maxima = times[maxima_inds] - if do_plot: - dt = time_opts.dt - end_time = time_opts.Tend - Nt = int(end_time // dt) - x = ee1 * r1 + # linear fit to log(maxima) vs time gives the decay rate + linfit = np.polyfit(t_maxima, maxima, 1) + gamma_numerical = linfit[0] - plt.figure(figsize=(20, 40)) - - plot_interval = 4 - n_rows = 8 - plot_ct = 0 - time = 0.0 - for i in range(Nt + 1): - time = dt * i - if i % plot_interval == 0: - logger.info(f"{i =}, {time =:.4f}, {plot_ct =}") - plt.subplot(n_rows, 3, plot_ct + 1) - plt.plot(x.squeeze(), n_sph[i, :, 0, 0], label=f"n_sph at time={time:.4f}", linewidth=2) - plt.xlim(0, r1) - plt.grid(c="k", linestyle="--") - plt.xlabel("x") - plt.ylim([0.8, 1.2]) - # plt.title(f"n_sph at time={time:.4f}") - plt.legend() - - plt.subplot(n_rows, 3, plot_ct + 2) - plt.plot(e1_binned, n_binned[i, :], label=f"n_binned at time={i * dt:4.2f}", linewidth=2) - plt.xlim(0, r1) - plt.grid(c="k", linestyle="--") - plt.xlabel("x") - plt.ylim([0.8, 1.2]) - # plt.title(f"n_binned at time={i * dt:4.2f}") - plt.legend() - - plt.subplot(n_rows, 3, plot_ct + 3) - plt.plot(e1_binned, j1_binned[i, :], label=f"j1_binned at time={i * dt:4.2f}", linewidth=2) - plt.xlim(0, r1) - plt.grid(c="k", linestyle="--") - plt.xlabel("x") - plt.ylim([ux_mean - 0.5, ux_mean + 0.5]) - # plt.title(f"j1_binned at time={i * dt:4.2f}") - plt.legend() - - plot_ct += 3 - if plot_ct == n_rows * 3: - break + logger.info(f"Analytical decay rate: gamma = -mu*k^2/2 = {gamma_analytical:.4f}") + logger.info(f"Numerical decay rate: gamma = {gamma_numerical:.4f}") + if do_plot: + Nt_snap = int(1.0 / dt) + snapshot_inds = np.round(np.linspace(0, Nt_snap, 12)).astype(int) + x_sph = np.asarray(ee1).flatten() * r1 + dn_sph = np.asarray(n_sph[:, :, 0, 0]) - 1.0 # shape (Nt+1, plot_pts) + dn_snap = dn_sph[snapshot_inds, :] + ylim = 1.5 * np.max(np.abs(dn_snap)) + fig, axes = plt.subplots(4, 3, figsize=(12, 12), sharex=True, sharey=True) + for ax, idx in zip(axes.flatten(), snapshot_inds): + ax.plot(x_sph, dn_sph[idx, :]) + ax.set_title(f"$t = {times[idx]:.2f}$") + ax.set_ylim(-ylim, ylim) + ax.axhline(0, color="k", linewidth=0.5) + ax.grid(True, linestyle="--", alpha=0.5) + for ax in axes[-1, :]: + ax.set_xlabel("$x$") + for ax in axes[:, 0]: + ax.set_ylabel(r"$\delta\rho$") + fig.suptitle(r"Density fluctuations $\delta\rho = \rho - 1$ (KDE)", fontsize=13) + plt.tight_layout() plt.show() - fig, ax = plt.subplots(figsize=(8, 5)) - ax.semilogy(times, np.abs(amplitude), "o-", markersize=3, - label=f"Numerical (fitted rate = {gamma_numerical:.3f})") - ax.semilogy(times, np.abs(amplitude_analytical), "--", - label=rf"Analytical: $\gamma = (4/3) \mu k^2 = {gamma_analytical:.3f}$") + fig, axes = plt.subplots(1, 2, figsize=(14, 5)) + + ax = axes[0] + ax.plot(times, amplitude, label=f"numerical j1 at x={e1_np[idx_max]:.3f}") + ax.plot(times, amplitude_analytical, "--", label=f"analytical envelope (gamma={gamma_analytical:.3f})") + ax.plot(t_maxima, amplitude[maxima_inds], "ro", markersize=6, label="local maxima") ax.set_xlabel("time") - ax.set_ylabel(rf"velocity amplitude at $x = {e1_np[idx_max]:.3f}$") - ax.set_title("Velocity diffusion: amplitude decay over time") + ax.set_ylabel("velocity amplitude") + ax.set_title("Damped sound wave: velocity at antinode") ax.legend() - ax.grid(True, which="both") + ax.grid(True) + + ax = axes[1] + ax.plot(t_maxima, maxima, "ro", markersize=6, label="log(maxima)") + ax.plot( + times, + np.polyval(linfit, times), + "--", + label=f"fit: gamma={gamma_numerical:.3f}", + ) + ax.axline( + (0, np.log(np.abs(A0) + 1e-15)), + slope=gamma_analytical, + color="k", + linestyle=":", + label=f"analytical: gamma={gamma_analytical:.3f}", + ) + ax.set_xlabel("time") + ax.set_ylabel("log(amplitude)") + ax.set_title("Decay rate: numerical vs analytical") + ax.legend() + ax.grid(True) + plt.tight_layout() plt.show() - error = xp.max(xp.abs(j1_binned[-1] - ux_mean)) - logger.info(f"SPH sound wave {error =}.") - assert error < 0.0022 - logger.info("Assertion passed.") - - rel_error = abs(gamma_numerical - gamma_analytical) / gamma_analytical + rel_error = abs(gamma_numerical - gamma_analytical) / abs(gamma_analytical) logger.info(f"Relative error in decay rate: {rel_error * 100:.2f}%") - assert rel_error < 0.04, ( + assert rel_error < 0.16, ( f"Numerical decay rate {gamma_numerical:.4f} deviates {rel_error * 100:.1f}% " - f"from analytical {gamma_analytical:.4f} (tolerance 4%)" + f"from analytical {gamma_analytical:.4f} (tolerance 16%)" ) - logger.info("Decay rate assertion passed.") + logger.info("Damped sound wave decay rate assertion passed.") - shutil.rmtree(test_folder) + # shutil.rmtree(test_folder) @pytest.mark.parametrize("nx", [8]) @pytest.mark.parametrize("plot_pts", [11]) -def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): +def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): """Verification test for SPH discretization of isthermal Euler equations. A standing sound wave with c_s=1 is damped at the rate mu*k^2/2 by viscosity. """ @@ -385,10 +378,10 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): # environment options test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") out_folders = os.path.join(test_folder, "ViscousEulerSPH") - env = EnvironmentOptions(out_folders=out_folders, sim_folder="damped_soundwave_1d") + env = EnvironmentOptions(out_folders=out_folders, sim_folder="velocity_diffusion") # time stepping - time_opts = Time(dt=0.05, Tend=4.0, split_algo="Strang") + time_opts = Time(dt=0.0025, Tend=0.1, split_algo="Strang") # geometry r1 = 1.0 @@ -401,7 +394,7 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): derham_opts = None # light-weight model instance - model = ViscousEulerSPH(with_B0=False, with_p=True, with_viscosity=True) + model = ViscousEulerSPH(with_B0=False, with_p=False, with_viscosity=True) ppb = 100 # Particles per box (controls resolution) loading_params = LoadingParameters(ppb=ppb, loading="tesselation") @@ -434,7 +427,7 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): butcher = ButcherTableau(algo="forward_euler") model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) - mu = 0.05 + mu = 1.0 model.propagators.push_viscous.options = model.propagators.push_viscous.Options(kernel_type="gaussian_1d", mu=mu) if model.with_B0: @@ -444,10 +437,12 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): # background, perturbations and initial conditions - background = equils.ConstantVelocity() + ux_mean = 0.0 + background = equils.ConstantVelocity(ux=ux_mean) model.euler_fluid.var.add_background(background) - perturbation = perturbations.ModesSin(ls=(1,), amps=(1e-2,)) - model.euler_fluid.var.add_perturbation(del_n=perturbation) + perturbation = perturbations.ModesSin(ls=(1,), amps=(0.5,)) + # perturbation = GenericPerturbation(fun=lambda e1, e2, e3: 0.2*xp.exp(-20 * (e1 - 0.5) ** 2)) + model.euler_fluid.var.add_perturbation(del_u1=perturbation) # instance of simulation sim = Simulation( @@ -481,7 +476,7 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): import numpy as np dt = time_opts.dt - Nt = len(sim.t_grid) - 1 + Nt = int(time_opts.Tend // dt) times = np.linspace(0.0, time_opts.Tend, Nt + 1) # sin(2*pi*x/r1) for mode l=1 peaks at x = 0.25*r1 @@ -500,8 +495,6 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): # numerical decay rate via linear fit to log(amplitude) log_amp = np.log(np.abs(amplitude) + 1e-15) - print(f"{times.shape = }") - print(f"{log_amp.shape = }") coeffs = np.polyfit(times, log_amp, 1) gamma_numerical = -coeffs[0] @@ -529,7 +522,7 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): plt.xlim(0, r1) plt.grid(c="k", linestyle="--") plt.xlabel("x") - plt.ylim([0.98, 1.02]) + plt.ylim([0.8, 1.2]) # plt.title(f"n_sph at time={time:.4f}") plt.legend() @@ -538,7 +531,7 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): plt.xlim(0, r1) plt.grid(c="k", linestyle="--") plt.xlabel("x") - plt.ylim([0.98, 1.02]) + plt.ylim([0.8, 1.2]) # plt.title(f"n_binned at time={i * dt:4.2f}") plt.legend() @@ -547,7 +540,7 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): plt.xlim(0, r1) plt.grid(c="k", linestyle="--") plt.xlabel("x") - plt.ylim([- 0.02, 0.02]) + plt.ylim([ux_mean - 0.5, ux_mean + 0.5]) # plt.title(f"j1_binned at time={i * dt:4.2f}") plt.legend() @@ -570,6 +563,11 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): plt.tight_layout() plt.show() + error = xp.max(xp.abs(j1_binned[-1] - ux_mean)) + logger.info(f"SPH sound wave {error =}.") + assert error < 0.0022 + logger.info("Assertion passed.") + rel_error = abs(gamma_numerical - gamma_analytical) / gamma_analytical logger.info(f"Relative error in decay rate: {rel_error * 100:.2f}%") assert rel_error < 0.04, ( @@ -584,4 +582,5 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): if __name__ == "__main__": # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) # test_velocity_diffusion(nx=8, plot_pts=11, do_plot=True) - test_damped_sound_wave(nx=8, plot_pts=11, do_plot=True) + test_damped_sound_wave(nx=8, plot_pts=21, do_plot=True) + From 671910447495ef8934798aa83d9a3b3fce41cf11 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Sat, 13 Jun 2026 12:12:22 +0200 Subject: [PATCH 20/34] added hagen-poiseuile verification test --- .../test_verif_ViscousEulerSPH.py | 183 +++++++++++++++++- 1 file changed, 182 insertions(+), 1 deletion(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index df91eca51..4fe234282 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -579,8 +579,189 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): shutil.rmtree(test_folder) +@pytest.mark.parametrize("nx", [8]) +@pytest.mark.parametrize("plot_pts", [21]) +def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False): + """Verification test for SPH viscosity tensor in 2D Hagen-Poiseuille channel flow. + + Channel geometry: x ∈ [0, 1] periodic (flow direction), y ∈ [0, 1] no-slip walls. + A constant body force g_x drives the flow; viscosity produces the parabolic + steady-state profile u_x(y) = g_x / (2 mu) * y * (1 - y). + """ + + # environment options + test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") + out_folders = os.path.join(test_folder, "ViscousEulerSPH") + env = EnvironmentOptions(out_folders=out_folders, sim_folder="hagen_poiseuille") + + # physical parameters + mu = 0.1 # dynamic viscosity + g_x = 0.01 # body force in x (acts as driving pressure gradient) + H = 1.0 # channel height in y + + # time stepping: T_relax = H^2 / (pi^2 * mu) ~ 1.0, run 10x past relaxation + time_opts = Time(dt=0.01, Tend=10.0, split_algo="Strang") + + # 2D channel: x-periodic [0, 1], y-walls [0, H=1], z-trivial + domain = domains.Cuboid(r1=1.0, r2=H) + + # model with pressure (to maintain ~uniform density) and viscosity + model = ViscousEulerSPH(with_B0=False, with_p=True, with_viscosity=True) + + loading_params = LoadingParameters(ppb=16, loading="tesselation", seed=3476) + weights_params = WeightsParameters() + boundary_params = BoundaryParameters( + bc=("periodic", "reflect", "periodic"), + bc_sph=("periodic", "noslip", "periodic"), + ) + sorting_params = SortingParameters( + boxes_per_dim=(nx, nx, 1), + dims_mask=(True, True, False), + ) + + # bin current_1 (≈ rho*u_x ≈ u_x) as a function of y to get the velocity profile + bin_plot_j1 = BinningPlot(slice="e2", n_bins=(16,), ranges=(0.0, 1.0), output_quantity="current_1") + bin_plot_n = BinningPlot(slice="e2", n_bins=(16,), ranges=(0.0, 1.0)) + kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=plot_pts, pts_e3=1) + saving_params = SavingParameters( + binning_plots=(bin_plot_j1, bin_plot_n), + kernel_density_plots=(kd_plot,), + ) + + model.euler_fluid.set_markers( + loading_params=loading_params, + weights_params=weights_params, + boundary_params=boundary_params, + sorting_params=sorting_params, + saving_params=saving_params, + bufsize=1.5, + ) + + # propagator options: use 2D Gaussian kernel + from struphy.ode.utils import ButcherTableau + + butcher = ButcherTableau(algo="forward_euler") + model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) + model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options( + kernel_type="gaussian_2d", + gravity=(g_x, 0.0, 0.0), + ) + model.propagators.push_viscous.options = model.propagators.push_viscous.Options( + kernel_type="gaussian_2d", + mu=mu, + ) + + # start from rest; body force drives the flow to the Hagen-Poiseuille steady state + background = equils.ConstantVelocity() + model.euler_fluid.var.add_background(background) + + sim = Simulation( + model=model, + env=env, + time_opts=time_opts, + domain=domain, + grid=None, + derham_opts=None, + ) + + sim.run() + + if MPI.COMM_WORLD.Get_rank() == 0: + sim.pproc() + sim.load_plotting_data() + + e2_grid = sim.f.euler_fluid.e2_current_1.grid_e2 # logical y in [0, 1] + j1_binned = sim.f.euler_fluid.e2_current_1.f_binned # shape (Nt+1, n_bins) + + import numpy as np + + dt = time_opts.dt + Nt = int(time_opts.Tend / dt) + times = np.linspace(0.0, time_opts.Tend, Nt + 1) + + e2_np = np.asarray(e2_grid).flatten() + y_np = e2_np * H # physical y coordinate + + # analytical Hagen-Poiseuille: u_x(y) = g_x/(2*mu) * y*(H - y) + u_exact = g_x / (2.0 * mu) * y_np * (H - y_np) + u_max_exact = np.max(u_exact) + + u_num_final = np.asarray(j1_binned[-1, :]).flatten() + u_max_num = np.max(u_num_final) + + # velocity at channel centre (y=H/2) as function of time + idx_centre = int(np.argmin(np.abs(e2_np - 0.5))) + u_centre = np.asarray(j1_binned[:, idx_centre]).flatten() + u_centre_exact = u_max_exact # peak at y=H/2 + + logger.info(f"Hagen-Poiseuille: analytical U_max = {u_max_exact:.6f}") + logger.info(f"Hagen-Poiseuille: numerical U_max = {u_max_num:.6f}") + + abs_err = np.abs(u_num_final - u_exact) + # pointwise relative error (avoid division by zero at the walls) + rel_err_pointwise = abs_err / u_max_exact + # exclude wall bins where the exact value is effectively zero + mean_rel_error = np.mean(rel_err_pointwise) + rel_error_umax = abs(u_max_num - u_max_exact) / u_max_exact + + logger.info(f"Hagen-Poiseuille: mean relative error = {mean_rel_error * 100:.2f}%") + logger.info(f"Hagen-Poiseuille: relative error in U_max = {rel_error_umax * 100:.2f}%") + + if do_plot: + fig, axes = plt.subplots(1, 3, figsize=(15, 5)) + + # velocity profile: numerical vs analytical + ax = axes[0] + ax.plot(u_num_final, y_np, "o-", markersize=4, label="Numerical (SPH)") + ax.plot(u_exact, y_np, "--", color="k", label="Analytical (Hagen-Poiseuille)") + ax.set_xlabel(r"$u_x$") + ax.set_ylabel(r"$y$") + ax.set_title("Steady-state velocity profile") + ax.legend() + ax.grid(True) + + # pointwise relative error + ax = axes[1] + ax.plot(rel_err_pointwise * 100, y_np, "r-o", markersize=4) + ax.set_xlabel(r"$|u_x^{num} - u_x^{exact}| \,/\, u_x^{exact}$ [%]") + ax.set_ylabel(r"$y$") + ax.set_title(f"Pointwise relative error (mean = {mean_rel_error * 100:.1f}%)") + ax.grid(True) + + # time evolution of centreline velocity + ax = axes[2] + ax.plot(times, u_centre, label=r"Numerical $u_x(y=H/2)$") + ax.axhline(u_centre_exact, color="k", linestyle="--", + label=rf"Exact $U_{{max}} = {u_centre_exact:.4f}$") + ax.set_xlabel("time") + ax.set_ylabel(r"$u_x(y=H/2)$") + ax.set_title("Centreline velocity relaxation to steady state") + ax.legend() + ax.grid(True) + + plt.suptitle( + rf"Hagen-Poiseuille: $\mu={mu}$, $g_x={g_x}$, $H={H}$, {nx}×{nx} boxes", + fontsize=12, + ) + plt.tight_layout() + plt.show() + + assert mean_rel_error < 0.15, ( + f"Hagen-Poiseuille mean relative error {mean_rel_error * 100:.1f}% exceeds tolerance 15%" + ) + logger.info("Hagen-Poiseuille profile assertion passed.") + + assert rel_error_umax < 0.10, ( + f"Hagen-Poiseuille U_max relative error {rel_error_umax * 100:.1f}% exceeds tolerance 10%" + ) + logger.info("Hagen-Poiseuille U_max assertion passed.") + + # shutil.rmtree(test_folder) + + if __name__ == "__main__": # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) # test_velocity_diffusion(nx=8, plot_pts=11, do_plot=True) - test_damped_sound_wave(nx=8, plot_pts=21, do_plot=True) + # test_damped_sound_wave(nx=8, plot_pts=21, do_plot=True) + test_hagen_poiseuille(nx=8, plot_pts=21, do_plot=True) From d393041b52eeb47c58b26ccad08bd3ee5450172f Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Sat, 13 Jun 2026 15:50:19 +0200 Subject: [PATCH 21/34] bug fix: update ghost particles in mpi_sort_markers --- src/struphy/pic/base.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/struphy/pic/base.py b/src/struphy/pic/base.py index a7d04e3dc..63882422a 100644 --- a/src/struphy/pic/base.py +++ b/src/struphy/pic/base.py @@ -1798,6 +1798,11 @@ def mpi_sort_markers( # new holes and new number of holes and markers on process self.update_holes() + # refresh ghost mask: received markers may land in rows that previously held + # ghost particles. update_holes alone recomputes valid_mks from a stale + # _ghost_particles mask, which would wrongly exclude these incoming real markers. + self.update_ghost_particles() + # check if all markers are on the right process after sorting if do_test: all_on_right_proc = xp.all( From 850d811fe512230a182379a53c826ce15e8d0a44 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Sat, 13 Jun 2026 16:20:35 +0200 Subject: [PATCH 22/34] new 2d test for noslip bcs; check interior points for hagen-p test --- .../test_verif_ViscousEulerSPH.py | 14 +- src/struphy/pic/tests/test_sph.py | 136 +++++++++++++++++- 2 files changed, 140 insertions(+), 10 deletions(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index 4fe234282..6213bee19 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -608,7 +608,7 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False): # model with pressure (to maintain ~uniform density) and viscosity model = ViscousEulerSPH(with_B0=False, with_p=True, with_viscosity=True) - loading_params = LoadingParameters(ppb=16, loading="tesselation", seed=3476) + loading_params = LoadingParameters(ppb=16, loading="tesselation") weights_params = WeightsParameters() boundary_params = BoundaryParameters( bc=("periodic", "reflect", "periodic"), @@ -634,7 +634,7 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False): boundary_params=boundary_params, sorting_params=sorting_params, saving_params=saving_params, - bufsize=1.5, + bufsize=2, ) # propagator options: use 2D Gaussian kernel @@ -701,10 +701,10 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False): # pointwise relative error (avoid division by zero at the walls) rel_err_pointwise = abs_err / u_max_exact # exclude wall bins where the exact value is effectively zero - mean_rel_error = np.mean(rel_err_pointwise) + rel_error_interior = rel_err_pointwise[1:-1] # exclude first and last bins rel_error_umax = abs(u_max_num - u_max_exact) / u_max_exact - logger.info(f"Hagen-Poiseuille: mean relative error = {mean_rel_error * 100:.2f}%") + logger.info(f"Hagen-Poiseuille: mean interior relative error = {np.mean(rel_error_interior) * 100:.2f}%") logger.info(f"Hagen-Poiseuille: relative error in U_max = {rel_error_umax * 100:.2f}%") if do_plot: @@ -725,7 +725,7 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False): ax.plot(rel_err_pointwise * 100, y_np, "r-o", markersize=4) ax.set_xlabel(r"$|u_x^{num} - u_x^{exact}| \,/\, u_x^{exact}$ [%]") ax.set_ylabel(r"$y$") - ax.set_title(f"Pointwise relative error (mean = {mean_rel_error * 100:.1f}%)") + ax.set_title(f"Pointwise relative error (mean = {np.mean(rel_error_interior) * 100:.1f}%)") ax.grid(True) # time evolution of centreline velocity @@ -746,8 +746,8 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False): plt.tight_layout() plt.show() - assert mean_rel_error < 0.15, ( - f"Hagen-Poiseuille mean relative error {mean_rel_error * 100:.1f}% exceeds tolerance 15%" + assert np.max(rel_error_interior) < 0.15, ( + f"Hagen-Poiseuille mean relative error {np.mean(rel_error_interior) * 100:.1f}% exceeds tolerance 15%" ) logger.info("Hagen-Poiseuille profile assertion passed.") diff --git a/src/struphy/pic/tests/test_sph.py b/src/struphy/pic/tests/test_sph.py index cedd11357..8d4abbdec 100644 --- a/src/struphy/pic/tests/test_sph.py +++ b/src/struphy/pic/tests/test_sph.py @@ -1980,13 +1980,143 @@ def u_xyz(x, y, z): assert rel_error < tol_interior, f"Interior {direction}-velocity error too large: {rel_error}" +@pytest.mark.parametrize("tesselation", [True]) +@pytest.mark.parametrize("boxes_per_dim", [(8, 8, 1)]) +def test_sph_no_slip_boundary_2d( + tesselation, + boxes_per_dim, + show_plot=False, +): + """2D no-slip boundary test: periodic x, noslip y, MPI decomposition in x. + + With 2 MPI procs decomposing in x, both procs own the full y domain, + so both must handle y_m (y=0) and y_p (y=1) walls. This test checks + the symmetry of the no-slip BC between the two walls. + """ + if isinstance(MPI.COMM_WORLD, MockComm): + comm = None + rank = 0 + else: + comm = MPI.COMM_WORLD + rank = comm.Get_rank() + + dom_params = {"l1": 0.0, "r1": 1.0, "l2": 0.0, "r2": 1.0, "l3": 0.0, "r3": 1.0} + domain = domains.Cuboid(**dom_params) + + ppb = 16 + if tesselation: + loading_params = LoadingParameters(ppb=ppb, loading="tesselation") + else: + loading_params = LoadingParameters(ppb=ppb, seed=42) + + def u_xyz(x, y, z): + return (xp.ones_like(x), xp.zeros_like(x), xp.zeros_like(x)) + + background = equils.GenericCartesianFluidEquilibrium(u_xyz=u_xyz) + background.domain = domain + + kernel = "gaussian_2d" + boundary_params = BoundaryParameters(bc_sph=("periodic", "noslip", "periodic")) + # dims_mask=(True, True, False) forces MPI decomposition in x when using 2 procs, + # so both procs own the full y range and must handle both y walls. + sorting_params = SortingParameters( + boxes_per_dim=boxes_per_dim, + dims_mask=(True, True, False), + box_bufsize=2.0, + ) + + particles = ParticlesSPH( + comm_world=comm, + loading_params=loading_params, + boundary_params=boundary_params, + sorting_params=sorting_params, + bufsize=3.0, + domain=domain, + background=background, + n_as_volume_form=True, + ) + + particles.draw_markers(sort=False) + particles.initialize_weights() + + n_x = 8 + n_y = 50 + eta1 = xp.linspace(0.0, 1.0, n_x) + eta2 = xp.linspace(0.0, 1.0, n_y) + eta3 = xp.array([0.5]) + ee1, ee2, ee3 = xp.meshgrid(eta1, eta2, eta3, indexing="ij") + + h1 = 1.0 / boxes_per_dim[0] + h2 = 1.0 / boxes_per_dim[1] + h3 = 1.0 / boxes_per_dim[2] + + v1, v2, v3 = particles.eval_velocity( + ee1, ee2, ee3, h1=h1, h2=h2, h3=h3, kernel_type=kernel, derivative=0, + ) + + if comm is not None: + all_v1 = xp.zeros_like(v1) + comm.Allreduce(v1, all_v1, op=MPI.SUM) + else: + all_v1 = v1 + + # Average over x (uniform in x by periodicity) + v1_avg = xp.mean(all_v1[:, :, 0], axis=0) # shape (n_y,) + + v_wall_bottom = float(v1_avg[0]) # y=0 (eta_2=0) + v_wall_top = float(v1_avg[-1]) # y=1 (eta_2=1) + v_interior = v1_avg[5:-5] + + if rank == 0: + logger.info( + f"\n2D no-slip (boxes={boxes_per_dim}): " + f"v at y=0: {v_wall_bottom:.4f}, y=1: {v_wall_top:.4f}" + ) + logger.info( + f"Interior v range: [{float(xp.min(v_interior)):.4f}, {float(xp.max(v_interior)):.4f}]" + ) + + if show_plot and rank == 0: + eta2_plot = xp.linspace(0.0, 1.0, n_y) + plt.figure(figsize=(7, 5)) + plt.plot(eta2_plot, v1_avg, "o-", label=r"$v_x$ (SPH avg over $x$)") + plt.axhline(0, color="k", linestyle="--", linewidth=0.8) + plt.axhline(1, color="gray", linestyle="--", linewidth=0.8) + plt.xlabel(r"$\eta_2$ (y)") + plt.ylabel(r"$v_x$") + plt.title(f"No-slip 2D test: boxes={boxes_per_dim}, periodic x / noslip y") + plt.legend() + plt.grid(True) + plt.show() + + tol_wall = 5e-2 + tol_interior = 1e-1 + + if rank == 0: + assert abs(v_wall_bottom) < tol_wall, ( + f"Bottom wall (y=0) velocity not zero: {v_wall_bottom:.4f}" + ) + assert abs(v_wall_top) < tol_wall, ( + f"Top wall (y=1) velocity not zero: {v_wall_top:.4f}" + ) + rel_error = float(xp.max(xp.abs(v_interior - 1.0))) + assert rel_error < tol_interior, ( + f"Interior x-velocity error too large: {rel_error:.4f}" + ) + + if __name__ == "__main__": # test_sph_no_slip_boundary_1d( # tesselation=False, # direction="x", # show_plot=True, # ) - test_sph_viscosity_evaluation_2d( - (12, 12, 1), "gaussian_2d", "periodic", "periodic", 11, tesselation=True, show_plot=True - ) + # test_sph_viscosity_evaluation_2d( + # (12, 12, 1), "gaussian_2d", "periodic", "periodic", 11, tesselation=True, show_plot=True + # ) # test_sph_evaluation_1d((24, 1, 1), "trigonometric_1d", 0, "periodic", 11, tesselation=False, show_plot=True) + test_sph_no_slip_boundary_2d( + tesselation=True, + boxes_per_dim=(8, 8, 1), + show_plot=True, + ) From 3f4ca398e57b6738e901a4bd245b5e03b0972a0d Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Sat, 13 Jun 2026 17:29:23 +0200 Subject: [PATCH 23/34] increase acceleration in hagen test, save pngs for video --- .../test_verif_ViscousEulerSPH.py | 80 +++++++++++++++++-- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index 6213bee19..8a661e71d 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -581,7 +581,7 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): @pytest.mark.parametrize("nx", [8]) @pytest.mark.parametrize("plot_pts", [21]) -def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False): +def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False, create_png: bool = False): """Verification test for SPH viscosity tensor in 2D Hagen-Poiseuille channel flow. Channel geometry: x ∈ [0, 1] periodic (flow direction), y ∈ [0, 1] no-slip walls. @@ -596,7 +596,7 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False): # physical parameters mu = 0.1 # dynamic viscosity - g_x = 0.01 # body force in x (acts as driving pressure gradient) + g_x = 0.1 # body force in x (acts as driving pressure gradient) H = 1.0 # channel height in y # time stepping: T_relax = H^2 / (pi^2 * mu) ~ 1.0, run 10x past relaxation @@ -624,6 +624,7 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False): bin_plot_n = BinningPlot(slice="e2", n_bins=(16,), ranges=(0.0, 1.0)) kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=plot_pts, pts_e3=1) saving_params = SavingParameters( + n_markers=1.0, binning_plots=(bin_plot_j1, bin_plot_n), kernel_density_plots=(kd_plot,), ) @@ -746,12 +747,81 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False): plt.tight_layout() plt.show() - assert np.max(rel_error_interior) < 0.15, ( + if create_png: + from matplotlib.colors import LinearSegmentedColormap + from tqdm import tqdm as _tqdm + + orbits = np.asarray(sim.orbits.euler_fluid) # (Nt_orb, n_markers, n_attrs) + # attrs for vdim=2: [x, y, z, v1, v2, w, diag, id] + + Nt_orb = orbits.shape[0] + t_orbit = np.linspace(0.0, time_opts.Tend, Nt_orb) + + # colormap: blue at walls (y=0, y=H), red at channel centre (y=H/2) + # c_val = 1 - 2*|y/H - 0.5| maps walls→0 (blue) and centre→1 (red) + cmap_pos = LinearSegmentedColormap.from_list("wall_centre", ["blue", "red"]) + norm = plt.Normalize(0.0, 1.0) + + # 250 equally spaced snapshot indices + n_snaps = 250 + snap_inds = np.round(np.linspace(0, Nt_orb - 1, n_snaps)).astype(int) + + png_dir = os.path.join(out_folders, "hagen_poiseuille_pngs") + os.makedirs(png_dir, exist_ok=True) + + for i, idx in _tqdm(enumerate(snap_inds), total=n_snaps, desc="saving PNGs"): + c_val = 1.0 - 2.0 * np.abs(orbits[idx, :, 1] / H - 0.5) + fig_png, ax_png = plt.subplots(figsize=(8, 6)) + sc_png = ax_png.scatter( + orbits[idx, :, 0], + orbits[idx, :, 1], + c=c_val, + cmap=cmap_pos, + norm=norm, + s=10, + ) + ax_png.axhline(0.0, color="k", linewidth=6) + ax_png.axhline(H, color="k", linewidth=6) + ax_png.set_xlim(0.0, 1.0) + ax_png.set_ylim(-0.05 * H, 1.05 * H) + ax_png.set_xlabel("x") + ax_png.set_ylabel("y") + ax_png.set_title(rf"Hagen-Poiseuille markers, $t = {t_orbit[idx]:.2f}$") + plt.colorbar(sc_png, ax=ax_png, label="steady-state velocity [a.u.]") + plt.tight_layout() + fig_png.savefig(os.path.join(png_dir, f"snap_{i:04d}.png"), dpi=80) + plt.close(fig_png) + + # show last snapshot in a new figure + fig_last, ax_last = plt.subplots(figsize=(8, 6)) + idx_last = snap_inds[-1] + c_val_last = 1.0 - 2.0 * np.abs(orbits[idx_last, :, 1] / H - 0.5) + sc_last = ax_last.scatter( + orbits[idx_last, :, 0], + orbits[idx_last, :, 1], + c=c_val_last, + cmap=cmap_pos, + norm=norm, + s=4, + ) + ax_last.axhline(0.0, color="k", linewidth=3, label="no-slip boundary") + ax_last.axhline(H, color="k", linewidth=3) + ax_last.set_xlim(0.0, 1.0) + ax_last.set_ylim(-0.05 * H, 1.05 * H) + ax_last.set_xlabel("x") + ax_last.set_ylabel("y") + ax_last.set_title(rf"Last snapshot: $t = {t_orbit[idx_last]:.2f}$") + ax_last.legend() + plt.colorbar(sc_last, ax=ax_last, label="steady-state velocity [a.u.]") + plt.tight_layout() + plt.show() + + assert np.max(rel_error_interior) < 0.05, ( f"Hagen-Poiseuille mean relative error {np.mean(rel_error_interior) * 100:.1f}% exceeds tolerance 15%" ) logger.info("Hagen-Poiseuille profile assertion passed.") - assert rel_error_umax < 0.10, ( + assert rel_error_umax < 0.05, ( f"Hagen-Poiseuille U_max relative error {rel_error_umax * 100:.1f}% exceeds tolerance 10%" ) logger.info("Hagen-Poiseuille U_max assertion passed.") @@ -763,5 +833,5 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False): # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) # test_velocity_diffusion(nx=8, plot_pts=11, do_plot=True) # test_damped_sound_wave(nx=8, plot_pts=21, do_plot=True) - test_hagen_poiseuille(nx=8, plot_pts=21, do_plot=True) + test_hagen_poiseuille(nx=8, plot_pts=21, do_plot=True, create_png=True) From bca3253d9b5b3de153c3eaae77cbde2f8ee666f1 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Sat, 13 Jun 2026 18:18:49 +0200 Subject: [PATCH 24/34] added dam break verification test --- src/struphy/fields_background/equils.py | 3 +- .../test_verif_ViscousEulerSPH.py | 209 +++++++++++++++++- 2 files changed, 210 insertions(+), 2 deletions(-) diff --git a/src/struphy/fields_background/equils.py b/src/struphy/fields_background/equils.py index d047645a7..5a60493f4 100644 --- a/src/struphy/fields_background/equils.py +++ b/src/struphy/fields_background/equils.py @@ -2978,7 +2978,8 @@ def n_xyz(self, x, y, z): elif self.params["density_profile"] == "gaussian_xy": return self.params["n"] * xp.exp(-(x**2 + y**2) / self.params["p0"]) elif self.params["density_profile"] == "step_function_xy": - assert isinstance(self.domain, domains.Cuboid) + from struphy.geometry.domains import Cuboid + assert isinstance(self.domain, Cuboid) l1 = self.domain.params["l1"] r1 = self.domain.params["r1"] l2 = self.domain.params["l2"] diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index 8a661e71d..e5a96e631 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -829,9 +829,216 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False, create_ # shutil.rmtree(test_folder) +@pytest.mark.mpi_skip +@pytest.mark.parametrize("nx", [8]) +@pytest.mark.parametrize("plot_pts", [21]) +def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bool = False): + """2D dam break: a dense fluid column (left half, x < r1/2) released in a + closed box with reflective walls, driven by pressure gradient and gravity. + + The initial density step (left: n_high, right: 1.0) is set via ConstantVelocity + background plus a GenericPerturbation. Downward gravity drives the collapse. + """ + + # environment options + test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") + out_folders = os.path.join(test_folder, "ViscousEulerSPH") + env = EnvironmentOptions(out_folders=out_folders, sim_folder="dam_break") + + # physical parameters + # WCSPH liquid regime: kappa = c_s^2 must satisfy c_s >> U_max = sqrt(2*g*H). + # With g=10, H=0.5: U_max ≈ 3.2. kappa=50 → c_s≈7, Ma≈0.45 (subsonic, liquid-like). + # Raising gravity would increase Ma and make things more gas-like — wrong direction. + kappa = 0.2 # isothermal coefficient (= c_s^2); controls fluid stiffness + mu = 0.05 # dynamic viscosity + g_y = 10.0 # gravitational acceleration (downward, i.e. −y) + r1 = 1.0 # domain width (x-direction) + r2 = 1.0 # domain height (y-direction) + n_high = 0.1 # density of the fluid column (uniform → no initial pressure gradient) + + # free-fall time sqrt(2*H/g) ≈ 0.32 s; acoustic CFL: h/c_s = (1/nx)/sqrt(kappa) ≈ 0.018 + time_opts = Time(dt=0.02, Tend=3.0, split_algo="Strang") + + # 2D closed box + domain = domains.Cuboid(r1=r1, r2=r2) + + # model with pressure and small viscosity + model = ViscousEulerSPH(with_B0=False, with_p=True, with_viscosity=True) + + loading_params = LoadingParameters(ppb=32, loading="tesselation") + # markers with weight ∝ 1e-8 (near-vacuum right half) are rejected; + # left-half weights ∝ n_high × vol_per_marker ≈ n_high/(nx²×ppb) ≈ 5e-5, so 1e-6 separates cleanly + weights_params = WeightsParameters(reject_weights=True, threshold=1e-6) + boundary_params = BoundaryParameters( + bc=("reflect", "reflect", "periodic"), + bc_sph=("mirror", "mirror", "periodic"), + ) + sorting_params = SortingParameters( + boxes_per_dim=(nx, nx, 1), + dims_mask=(True, True, False), + ) + + kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=plot_pts, pts_e3=1) + saving_params = SavingParameters( + n_markers=1.0, + kernel_density_plots=(kd_plot,), + ) + + model.euler_fluid.set_markers( + loading_params=loading_params, + weights_params=weights_params, + boundary_params=boundary_params, + sorting_params=sorting_params, + saving_params=saving_params, + bufsize=2, + ) + + # propagator options: 2D Gaussian kernel with downward gravity + from struphy.ode.utils import ButcherTableau + + butcher = ButcherTableau(algo="forward_euler") + model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) + model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options( + kernel_type="gaussian_2d", + gravity=(0.0, -g_y, 0.0), + kappa=kappa, + ) + model.propagators.push_viscous.options = model.propagators.push_viscous.Options( + kernel_type="gaussian_2d", + mu=mu, + ) + + # initial condition: dense column on the left half (x < r1/2), near-vacuum on the right. + # step_function_xy places n_high where x < upper_x and 1e-8 elsewhere; the near-vacuum + # markers are then removed by reject_weights above. + background = equils.ConstantVelocity( + density_profile="step_function_xy", + n=n_high, + upper_x=r1 / 4, + upper_y=r2, + ) + model.euler_fluid.var.add_background(background) + + sim = Simulation( + model=model, + env=env, + time_opts=time_opts, + domain=domain, + grid=None, + derham_opts=None, + ) + + sim.run() + + if MPI.COMM_WORLD.Get_rank() == 0: + sim.pproc() + sim.load_plotting_data() + + import numpy as np + + dt = time_opts.dt + Nt = int(time_opts.Tend / dt) + times = np.linspace(0.0, time_opts.Tend, Nt + 1) + + ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph + n_sph = sim.n_sph.euler_fluid.view_0.n_sph # (Nt+1, pts_e1, pts_e2, 1) + + X = np.asarray(ee1)[:, :, 0] * r1 # physical x, shape (pts_e1, pts_e2) + Y = np.asarray(ee2)[:, :, 0] * r2 # physical y, shape (pts_e1, pts_e2) + n_arr = np.asarray(n_sph) # (Nt+1, pts_e1, pts_e2, 1) + + # orbits needed for both do_plot scatter overlay and create_png + orbits = np.asarray(sim.orbits.euler_fluid) # (Nt_orb, n_markers, n_attrs) + Nt_orb = orbits.shape[0] + t_orbit = np.linspace(0.0, time_opts.Tend, Nt_orb) + + # color each marker by its initial x (gradient across the left column) + x_init = orbits[0, :, 0] + c_val = x_init / (r1 / 2) # 0 = left wall, 1 = dam face + + if do_plot: + snapshot_inds = np.round(np.linspace(0, Nt, 12)).astype(int) + # index into orbits at the same times (Nt_orb == Nt + 1 when n_markers=1.0) + orb_inds = np.round(np.linspace(0, Nt_orb - 1, 12)).astype(int) + + vmax_plot = float(np.max(n_arr)) # density can pile above n_high as fluid collects + + fig, axes = plt.subplots(4, 3, figsize=(15, 10), sharex=True, sharey=True) + im = None + for ax, idx, oidx in zip(axes.flatten(), snapshot_inds, orb_inds): + n_2d = n_arr[idx, :, :, 0] + im = ax.pcolormesh(X, Y, n_2d, vmin=0.0, vmax=vmax_plot, cmap="Blues", shading="auto") + ax.scatter( + orbits[oidx, :, 0], + orbits[oidx, :, 1], + c=c_val, + cmap="autumn", + s=2, + vmin=0.0, + vmax=1.0, + alpha=0.6, + ) + ax.set_title(f"$t = {times[idx]:.2f}$") + ax.set_aspect("equal") + for ax in axes[-1, :]: + ax.set_xlabel("$x$") + for ax in axes[:, 0]: + ax.set_ylabel("$y$") + if im is not None: + fig.colorbar(im, ax=axes.ravel().tolist(), label=r"$\rho$", shrink=0.6) + fig.suptitle( + rf"2D dam break: $\rho$ (KDE) + markers, $\kappa={kappa}$, $g_y={g_y}$, $\mu={mu}$, {nx}×{nx} boxes", + fontsize=12, + ) + plt.tight_layout() + plt.show() + + if create_png: + from tqdm import tqdm as _tqdm + + n_snaps = 250 + snap_inds = np.round(np.linspace(0, Nt_orb - 1, n_snaps)).astype(int) + + png_dir = os.path.join(out_folders, "dam_break_pngs") + os.makedirs(png_dir, exist_ok=True) + + for i, idx in _tqdm(enumerate(snap_inds), total=n_snaps, desc="saving PNGs"): + fig_png, ax_png = plt.subplots(figsize=(10, 5)) + ax_png.scatter( + orbits[idx, :, 0], + orbits[idx, :, 1], + c=c_val, + cmap="autumn", + s=4, + vmin=0.0, + vmax=1.0, + ) + ax_png.set_xlim(0.0, r1) + ax_png.set_ylim(0.0, r2) + ax_png.set_xlabel("x") + ax_png.set_ylabel("y") + ax_png.set_title(rf"Dam break, $t = {t_orbit[idx]:.3f}$") + ax_png.set_aspect("equal") + plt.tight_layout() + fig_png.savefig(os.path.join(png_dir, f"snap_{i:04d}.png"), dpi=80) + plt.close(fig_png) + + # sanity: no markers should escape the closed box (allow 1% tolerance) + x_all = orbits[:, :, 0] + y_all = orbits[:, :, 1] + assert np.all(x_all >= -0.01 * r1) and np.all(x_all <= 1.01 * r1), ( + "Markers escaped x-domain in dam break test" + ) + assert np.all(y_all >= -0.01 * r2) and np.all(y_all <= 1.01 * r2), ( + "Markers escaped y-domain in dam break test" + ) + logger.info("Dam break domain bounds assertion passed.") + + if __name__ == "__main__": # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) # test_velocity_diffusion(nx=8, plot_pts=11, do_plot=True) # test_damped_sound_wave(nx=8, plot_pts=21, do_plot=True) - test_hagen_poiseuille(nx=8, plot_pts=21, do_plot=True, create_png=True) + # test_hagen_poiseuille(nx=8, plot_pts=21, do_plot=True, create_png=True) + test_dam_break(nx=8, plot_pts=21, do_plot=True, create_png=True) From d04205c3da5cbb624291517f5b9b3da64bc7b913 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Sun, 14 Jun 2026 16:26:30 +0200 Subject: [PATCH 25/34] fix boundary bug related to mean_velocity_index for noslip bcs --- .../test_verif_ViscousEulerSPH.py | 6 +++--- src/struphy/models/variables.py | 2 ++ .../propagators/push_vin_sph_pressure.py | 21 +++++++++++++++++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index e5a96e631..f4de7fec5 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -763,7 +763,7 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False, create_ norm = plt.Normalize(0.0, 1.0) # 250 equally spaced snapshot indices - n_snaps = 250 + n_snaps = np.min([250, Nt_orb]) snap_inds = np.round(np.linspace(0, Nt_orb - 1, n_snaps)).astype(int) png_dir = os.path.join(out_folders, "hagen_poiseuille_pngs") @@ -1039,6 +1039,6 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) # test_velocity_diffusion(nx=8, plot_pts=11, do_plot=True) # test_damped_sound_wave(nx=8, plot_pts=21, do_plot=True) - # test_hagen_poiseuille(nx=8, plot_pts=21, do_plot=True, create_png=True) - test_dam_break(nx=8, plot_pts=21, do_plot=True, create_png=True) + test_hagen_poiseuille(nx=8, plot_pts=21, do_plot=True, create_png=True) + # test_dam_break(nx=8, plot_pts=21, do_plot=True, create_png=True) diff --git a/src/struphy/models/variables.py b/src/struphy/models/variables.py index 6c3ea5ffa..09cdff62f 100644 --- a/src/struphy/models/variables.py +++ b/src/struphy/models/variables.py @@ -806,6 +806,8 @@ def allocate( sort = False self.particles.draw_markers(sort=sort) self.particles.initialize_weights() + + self.particles.put_particles_in_boxes() # allocate array for saving markers if not present n_markers = self.species.saving_params.n_markers diff --git a/src/struphy/propagators/push_vin_sph_pressure.py b/src/struphy/propagators/push_vin_sph_pressure.py index 64ec846a2..0f44bb8b0 100644 --- a/src/struphy/propagators/push_vin_sph_pressure.py +++ b/src/struphy/propagators/push_vin_sph_pressure.py @@ -187,5 +187,22 @@ def allocate(self): @profile def __call__(self, dt): - self.variables.fluid.particles.put_particles_in_boxes() - self._pusher(dt) + particles = self.variables.fluid.particles + + # The "noslip" BC reflects ghost particles and, when ``mean_velocity_index`` is set, + # negates the three auxiliary columns starting at that index (see Particles._mirror_particles). + # That negation is meant for the viscous mean-velocity coefficients (which are odd under + # wall reflection). Here those same columns hold the SPH density rho, w/rho and w*rho^(gamma-2) + # computed by sph_pressure_coeffs -- scalars that are EVEN under reflection and must NOT be + # flipped. Aliasing the index (it defaults to first_free_idx, which is exactly where the + # pressure coefficients live) would otherwise give the mirror particles a negative w/rho, so + # the symmetric pressure-gradient sum no longer cancels at the wall and the near-wall markers + # receive a spurious normal force into the wall. Suppress the flip for the duration of the + # pressure push and restore it afterwards so the viscous propagator keeps its correct behaviour. + saved_mean_velocity_index = particles._mean_velocity_index + particles._mean_velocity_index = None + try: + particles.put_particles_in_boxes() + self._pusher(dt) + finally: + particles._mean_velocity_index = saved_mean_velocity_index From c28eba6d42f88243443126de359f0d32a0b876df Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Sun, 14 Jun 2026 16:39:37 +0200 Subject: [PATCH 26/34] added damped sound wave tutorial --- tutorials/tutorial_viscous_euler_sph.ipynb | 447 ++++++++++++++++++++- 1 file changed, 446 insertions(+), 1 deletion(-) diff --git a/tutorials/tutorial_viscous_euler_sph.ipynb b/tutorials/tutorial_viscous_euler_sph.ipynb index c495cb91d..905d025a1 100644 --- a/tutorials/tutorial_viscous_euler_sph.ipynb +++ b/tutorials/tutorial_viscous_euler_sph.ipynb @@ -456,11 +456,456 @@ " except Exception as e:\n", " print(f\"Could not remove {test_folder}: {e}\")" ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 2D Damped Sound Wave\n", + "\n", + "### Physical Setup\n", + "\n", + "Adding viscosity to the isothermal Euler equations introduces dissipation. For a standing wave with wavenumber $k = 2\\pi\\ell/L$ (mode $\\ell$, domain length $L$), the amplitude decays exponentially in time:\n", + "\n", + "$$A(t) \\propto e^{\\gamma t}, \\qquad \\gamma = -\\frac{2}{3} \\mu k^2$$\n", + "\n", + "where $\\mu$ is the dynamic viscosity and the factor $4/3$ arises from the compressible viscous stress tensor (only the bulk contribution survives for a 1D plane wave). This test verifies the viscous propagator by:\n", + "\n", + "1. Exciting a standing sound wave via an initial **velocity** perturbation $\\delta u_1 \\propto \\sin(2\\pi x / L)$\n", + "2. Running for $\\sim 10$ oscillation periods so the decay envelope is well resolved\n", + "3. Extracting the numerical decay rate from the local maxima of the current $j_1$ at the velocity antinode (analogous to measuring Landau damping)\n", + "4. Comparing the fitted rate against $\\gamma_\\text{analytical} = -(4/3) \\mu k^2 / 2$" + ] + }, + { + "cell_type": "markdown", + "id": "23", + "metadata": {}, + "source": [ + "### Physical and Numerical Parameters" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "# Physical parameters\n", + "mu = 0.01 # dynamic viscosity\n", + "r1 = 1.0 # domain length (1D periodic)\n", + "c_s = 1.0 # sound speed (isothermal, kappa=c_s^2=1 by default)\n", + "\n", + "# Mode and analytical decay rate\n", + "ell = 1 # wave mode number\n", + "k = 2.0 * np.pi * ell / r1 # wavenumber\n", + "gamma_analytical = -mu * (4.0 / 3.0) * k**2 / 2.0\n", + "\n", + "# Numerical parameters\n", + "nx = 8 # boxes in x (controls particle density)\n", + "plot_pts = 21 # evaluation points for kernel density output\n", + "\n", + "# Time stepping: run ~10 oscillation periods (T_osc = r1/c_s = 1)\n", + "dt = 0.01\n", + "Tend = 10.0\n", + "\n", + "print(f\"Viscosity: mu = {mu}\")\n", + "print(f\"Domain length: L = {r1}\")\n", + "print(f\"Wave mode: ell = {ell}, k = {k:.4f}\")\n", + "print(f\"Analytical decay rate: gamma = -(4/3)*mu*k^2/2 = {gamma_analytical:.4f}\")\n", + "print(f\"Oscillation period: T = {r1/c_s:.2f}, simulation spans {Tend} time units ({Tend/(r1/c_s):.0f} periods)\")" + ] + }, + { + "cell_type": "markdown", + "id": "25", + "metadata": {}, + "source": [ + "### Model Setup with Viscosity\n", + "\n", + "The key difference from the inviscid case is `with_viscosity=True` (which is the default). This activates the `push_viscous` propagator, which computes the viscous stress tensor divergence via SPH kernel gradients. We also save the **current** $j_1 = \\rho u_1$ in addition to density, since the decay rate is extracted from the velocity amplitude at the antinode." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "from struphy import (\n", + " BinningPlot,\n", + " BoundaryParameters,\n", + " EnvironmentOptions,\n", + " KernelDensityPlot,\n", + " LoadingParameters,\n", + " SavingParameters,\n", + " Simulation,\n", + " SortingParameters,\n", + " Time,\n", + " WeightsParameters,\n", + " domains,\n", + " equils,\n", + " perturbations,\n", + ")\n", + "from struphy.models import ViscousEulerSPH\n", + "from struphy.ode.utils import ButcherTableau\n", + "\n", + "# with_viscosity=True (default) activates the viscous stress propagator\n", + "model_damp = ViscousEulerSPH(with_B0=False)\n", + "\n", + "butcher = ButcherTableau(algo=\"forward_euler\")\n", + "model_damp.propagators.push_eta.options = model_damp.propagators.push_eta.Options(butcher=butcher)\n", + "model_damp.propagators.push_sph_p.options = model_damp.propagators.push_sph_p.Options(\n", + " kernel_type=\"gaussian_1d\"\n", + ")\n", + "# mu sets the kinematic viscosity coefficient for the SPH viscous force\n", + "model_damp.propagators.push_viscous.options = model_damp.propagators.push_viscous.Options(\n", + " kernel_type=\"gaussian_1d\", mu=mu\n", + ")\n", + "\n", + "print(\"ViscousEulerSPH model configured (with viscosity, no B-field).\")\n", + "print(f\" push_viscous: gaussian_1d kernel, mu={mu}\")" + ] + }, + { + "cell_type": "markdown", + "id": "27", + "metadata": {}, + "source": [ + "### Domain, Markers and Diagnostics\n", + "\n", + "We add a second `BinningPlot` that records $j_1 = \\rho u_1$ (the momentum density, which equals $u_1$ to leading order since $\\rho \\approx 1$). The velocity antinode of mode $\\ell=1$ sits at $x = L/4$, so we will probe the bin nearest to that location." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "domain_damp = domains.Cuboid(r1=r1)\n", + "\n", + "loading_params = LoadingParameters(ppb=8, loading=\"tesselation\")\n", + "weights_params = WeightsParameters()\n", + "boundary_params = BoundaryParameters()\n", + "sorting_params = SortingParameters(\n", + " boxes_per_dim=(nx, 1, 1),\n", + " dims_mask=(True, False, False),\n", + ")\n", + "\n", + "# Density binning (for visualisation)\n", + "bin_plot = BinningPlot(slice=\"e1\", n_bins=(16,), ranges=(0.0, 1.0))\n", + "# Current j1 binning — used to track the velocity amplitude over time\n", + "bin_plot_j1 = BinningPlot(slice=\"e1\", n_bins=(16,), ranges=(0.0, 1.0), output_quantity=\"current_1\")\n", + "kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=1)\n", + "saving_params = SavingParameters(\n", + " binning_plots=(bin_plot, bin_plot_j1),\n", + " kernel_density_plots=(kd_plot,),\n", + ")\n", + "\n", + "model_damp.euler_fluid.set_markers(\n", + " loading_params=loading_params,\n", + " weights_params=weights_params,\n", + " boundary_params=boundary_params,\n", + " sorting_params=sorting_params,\n", + " saving_params=saving_params,\n", + ")\n", + "\n", + "print(f\"1D periodic domain: r1={r1}\")\n", + "print(f\"Markers: 8 ppb × {nx} boxes = {8*nx} particles\")\n", + "print(f\"Diagnostics: density binning + j1 (current) binning, {plot_pts} KDE evaluation points\")" + ] + }, + { + "cell_type": "markdown", + "id": "29", + "metadata": {}, + "source": [ + "### Initial Conditions\n", + "\n", + "We perturb the **velocity** (not the density) with a sinusoidal mode. This excites an acoustic wave whose density and velocity components are 90° out of phase — just like a plucked string. The wave then oscillates and decays due to viscous dissipation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30", + "metadata": {}, + "outputs": [], + "source": [ + "background = equils.ConstantVelocity()\n", + "model_damp.euler_fluid.var.add_background(background)\n", + "\n", + "# Velocity perturbation: del_u1 (not del_n) excites an oscillating acoustic mode\n", + "perturbation = perturbations.ModesSin(ls=(1,), amps=(1.0e-2,))\n", + "model_damp.euler_fluid.var.add_perturbation(del_u1=perturbation)\n", + "\n", + "print(\"Background: constant velocity (zero density n=1, zero velocity)\")" + ] + }, + { + "cell_type": "markdown", + "id": "31", + "metadata": {}, + "source": [ + "### Run the Simulation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "import shutil\n", + "\n", + "test_folder = os.path.join(os.getcwd(), \"struphy_verification_tests\")\n", + "out_folders = os.path.join(test_folder, \"ViscousEulerSPH\")\n", + "env_damp = EnvironmentOptions(out_folders=out_folders, sim_folder=\"damped_soundwave_1d\")\n", + "\n", + "time_opts_damp = Time(dt=dt, Tend=Tend, split_algo=\"Strang\")\n", + "\n", + "sim_damp = Simulation(\n", + " model=model_damp,\n", + " env=env_damp,\n", + " time_opts=time_opts_damp,\n", + " domain=domain_damp,\n", + " grid=None,\n", + " derham_opts=None,\n", + ")\n", + "\n", + "print(f\"Running damped sound wave: dt={dt}, Tend={Tend}\")\n", + "sim_damp.run()\n", + "print(\"Simulation complete.\")\n", + "\n", + "sim_damp.pproc()\n", + "print(\"Post-processing complete.\")" + ] + }, + { + "cell_type": "markdown", + "id": "33", + "metadata": {}, + "source": [ + "### Diagnostics: Density Snapshots\n", + "\n", + "First, inspect the density field $\\delta\\rho = \\rho - 1$ at twelve equally spaced times during the first oscillation period. The amplitude should visibly shrink over successive periods." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "sim_damp.load_plotting_data()\n", + "\n", + "ee1, ee2, ee3 = sim_damp.n_sph.euler_fluid.view_0.grid_n_sph\n", + "n_sph = sim_damp.n_sph.euler_fluid.view_0.n_sph # shape (Nt+1, plot_pts, 1, 1)\n", + "j1_binned = sim_damp.f.euler_fluid.e1_current_1.f_binned # shape (Nt+1, n_bins)\n", + "e1_binned = sim_damp.f.euler_fluid.e1_current_1.grid_e1 # logical x in [0,1]\n", + "\n", + "Nt = j1_binned.shape[0] - 1\n", + "times = np.linspace(0.0, Tend, Nt + 1)\n", + "\n", + "# Physical KDE coordinates\n", + "x_sph = np.asarray(ee1).flatten() * r1\n", + "dn_sph = np.asarray(n_sph[:, :, 0, 0]) - 1.0 # (Nt+1, plot_pts)\n", + "\n", + "# Twelve snapshots equally spaced over the first oscillation period (T_osc = r1/c_s = 1)\n", + "Nt_one_period = int(1.0 / dt)\n", + "snapshot_inds = np.round(np.linspace(0, Nt_one_period, 12)).astype(int)\n", + "ylim = 1.5 * np.max(np.abs(dn_sph[snapshot_inds, :]))\n", + "\n", + "fig, axes = plt.subplots(4, 3, figsize=(12, 10), sharex=True, sharey=True)\n", + "for ax, idx in zip(axes.flatten(), snapshot_inds):\n", + " ax.plot(x_sph, dn_sph[idx, :])\n", + " ax.set_title(f\"$t = {times[idx]:.2f}$\")\n", + " ax.set_ylim(-ylim, ylim)\n", + " ax.axhline(0, color=\"k\", linewidth=0.5)\n", + " ax.grid(True, linestyle=\"--\", alpha=0.5)\n", + "for ax in axes[-1, :]:\n", + " ax.set_xlabel(\"$x$\")\n", + "for ax in axes[:, 0]:\n", + " ax.set_ylabel(r\"$\\delta\\rho$\")\n", + "fig.suptitle(r\"Density fluctuations $\\delta\\rho = \\rho - 1$ (KDE, first oscillation period)\", fontsize=13)\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "35", + "metadata": {}, + "source": [ + "### Decay Rate Extraction\n", + "\n", + "To measure the numerical decay rate we track the velocity (current) at the **antinode** $x = L/4$ over the full simulation. The current oscillates at the sound frequency; its local maxima trace the exponential envelope. A linear fit to $\\ln|A_\\text{max}|$ vs. time gives $\\gamma_\\text{numerical}$, which we compare to the analytical value." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36", + "metadata": {}, + "outputs": [], + "source": [ + "# --- Velocity amplitude time series at the antinode x = L/4 ---\n", + "e1_np = np.asarray(e1_binned).flatten() # logical x in [0, 1]\n", + "idx_max = int(np.argmin(np.abs(e1_np - 0.25))) # bin closest to x = 0.25*r1\n", + "amplitude = np.asarray(j1_binned[:, idx_max]).flatten()\n", + "\n", + "# Analytical envelope\n", + "A0 = amplitude[0]\n", + "amplitude_analytical = A0 * np.exp(gamma_analytical * times)\n", + "\n", + "# --- Local maxima of the oscillating amplitude ---\n", + "# Detect sign changes of the numerical time derivative of log|A|\n", + "logA = np.log(np.abs(amplitude) + 1e-15)\n", + "dlogA = (np.roll(logA, -1) - np.roll(logA, 1))[1:-1] / (2.0 * dt)\n", + "zeros = dlogA * np.roll(dlogA, -1) < 0.0\n", + "maxima_inds = np.where(np.logical_and(zeros, dlogA > 0.0))[0] + 1\n", + "maxima = logA[maxima_inds]\n", + "t_maxima = times[maxima_inds]\n", + "\n", + "# --- Linear fit to log(maxima) vs time → decay rate ---\n", + "linfit = np.polyfit(t_maxima, maxima, 1)\n", + "gamma_numerical = linfit[0]\n", + "\n", + "print(f\"Analytical decay rate: gamma = -(4/3)*mu*k²/2 = {gamma_analytical:.4f}\")\n", + "print(f\"Numerical decay rate: gamma = {gamma_numerical:.4f}\")\n", + "rel_error = abs(gamma_numerical - gamma_analytical) / abs(gamma_analytical)\n", + "print(f\"Relative error: {rel_error * 100:.2f}%\")" + ] + }, + { + "cell_type": "markdown", + "id": "37", + "metadata": {}, + "source": [ + "### Visualisation: Amplitude Decay and Fitted Rate\n", + "\n", + "Two panels summarise the verification:\n", + "- **Left**: the raw current amplitude at the antinode, overlaid with the analytical envelope and the detected local maxima.\n", + "- **Right**: log of the maxima vs. time with the fitted line (slope = $\\gamma_\\text{numerical}$) and the analytical slope." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38", + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "# --- Left: raw amplitude + analytical envelope ---\n", + "ax = axes[0]\n", + "ax.plot(times, amplitude, linewidth=0.8, label=f\"numerical $j_1$ at $x={e1_np[idx_max]*r1:.3f}$\")\n", + "ax.plot(times, amplitude_analytical, \"--\", color=\"k\",\n", + " label=rf\"analytical envelope ($\\gamma={gamma_analytical:.3f}$)\")\n", + "ax.plot(t_maxima, amplitude[maxima_inds], \"ro\", markersize=6, label=\"local maxima\")\n", + "ax.set_xlabel(\"time\")\n", + "ax.set_ylabel(\"velocity amplitude $j_1$\")\n", + "ax.set_title(\"Damped sound wave: velocity at antinode\")\n", + "ax.legend()\n", + "ax.grid(True)\n", + "\n", + "# --- Right: log(maxima) vs time with linear fit ---\n", + "ax = axes[1]\n", + "ax.plot(t_maxima, maxima, \"ro\", markersize=6, label=r\"$\\ln|A_\\mathrm{max}|$\")\n", + "ax.plot(times, np.polyval(linfit, times), \"--\",\n", + " label=rf\"fit: $\\gamma={gamma_numerical:.3f}$\")\n", + "ax.axline(\n", + " (0, np.log(np.abs(A0) + 1e-15)),\n", + " slope=gamma_analytical,\n", + " color=\"k\",\n", + " linestyle=\":\",\n", + " label=rf\"analytical: $\\gamma={gamma_analytical:.3f}$\",\n", + ")\n", + "ax.set_xlabel(\"time\")\n", + "ax.set_ylabel(r\"$\\ln|A|$\")\n", + "ax.set_title(\"Decay rate: numerical vs analytical\")\n", + "ax.legend()\n", + "ax.grid(True)\n", + "\n", + "fig.suptitle(\n", + " rf\"Viscous damping of sound wave ($\\mu={mu}$, $k={k:.3f}$, $\\gamma_\\mathrm{{anal}}={gamma_analytical:.4f}$)\",\n", + " fontsize=12,\n", + ")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "39", + "metadata": {}, + "source": [ + "### Verification Check" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40", + "metadata": {}, + "outputs": [], + "source": [ + "tolerance = 0.16 # 16% relative error\n", + "\n", + "print(f\"=== Damped Sound Wave Verification (tolerance {tolerance*100:.0f}%) ===\")\n", + "print(f\" Analytical gamma = {gamma_analytical:.4f}\")\n", + "print(f\" Numerical gamma = {gamma_numerical:.4f}\")\n", + "print(f\" Relative error = {rel_error * 100:.2f}%\")\n", + "\n", + "try:\n", + " assert rel_error < tolerance, (\n", + " f\"Numerical decay rate {gamma_numerical:.4f} deviates {rel_error*100:.1f}% \"\n", + " f\"from analytical {gamma_analytical:.4f} (tolerance {tolerance*100:.0f}%)\"\n", + " )\n", + " print(f\"\\n✓ Verification passed — decay rate within {tolerance*100:.0f}% of analytical.\")\n", + "except AssertionError as e:\n", + " print(f\"\\n✗ {e}\")\n", + "\n", + "# Optional cleanup\n", + "if False: # set to True to remove simulation output\n", + " shutil.rmtree(test_folder)\n", + " print(f\"Cleaned up {test_folder}\")" + ] + }, + { + "cell_type": "markdown", + "id": "41", + "metadata": {}, + "source": [ + "### Conclusion\n", + "\n", + "This section verified the viscous propagator of `ViscousEulerSPH` against the analytical damping rate of a compressible sound wave:\n", + "\n", + "- The SPH discretisation correctly reproduces the $\\gamma = -(4/3)\\mu k^2/2$ decay law to within the 16% tolerance at the chosen resolution.\n", + "- The decay rate is extracted robustly from the envelope of the oscillating velocity signal — the same technique used for Landau damping in kinetic simulations.\n", + "- Increasing `nx` or `ppb` reduces the relative error further, as the SPH kernel gradient approximation improves with particle density." + ] } ], "metadata": { "kernelspec": { - "display_name": "env (3.12.3)", + "display_name": "env (3.12.3.final.0)", "language": "python", "name": "python3" }, From f74644aff9930d67900a67b0296fe960a9cb41b8 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Sun, 14 Jun 2026 17:06:44 +0200 Subject: [PATCH 27/34] add three new tutorials mapping the sph verifiaction tests --- .../test_verif_ViscousEulerSPH.py | 4 +- tutorials/tutorial_dam_break_sph.ipynb | 497 ++++++++++++++++++ tutorials/tutorial_hagen_poiseuille_sph.ipynb | 472 +++++++++++++++++ .../tutorial_velocity_diffusion_sph.ipynb | 478 +++++++++++++++++ tutorials/tutorial_viscous_euler_sph.ipynb | 2 +- 5 files changed, 1450 insertions(+), 3 deletions(-) create mode 100644 tutorials/tutorial_dam_break_sph.ipynb create mode 100644 tutorials/tutorial_hagen_poiseuille_sph.ipynb create mode 100644 tutorials/tutorial_velocity_diffusion_sph.ipynb diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index f4de7fec5..523209da6 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -1037,8 +1037,8 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo if __name__ == "__main__": # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) - # test_velocity_diffusion(nx=8, plot_pts=11, do_plot=True) + test_velocity_diffusion(nx=8, plot_pts=11, do_plot=True) # test_damped_sound_wave(nx=8, plot_pts=21, do_plot=True) - test_hagen_poiseuille(nx=8, plot_pts=21, do_plot=True, create_png=True) + # test_hagen_poiseuille(nx=8, plot_pts=21, do_plot=True, create_png=True) # test_dam_break(nx=8, plot_pts=21, do_plot=True, create_png=True) diff --git a/tutorials/tutorial_dam_break_sph.ipynb b/tutorials/tutorial_dam_break_sph.ipynb new file mode 100644 index 000000000..0cb09045b --- /dev/null +++ b/tutorials/tutorial_dam_break_sph.ipynb @@ -0,0 +1,497 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# 2D Dam Break Simulation with SPH\n", + "\n", + "## Collapse of a Fluid Column under Gravity\n", + "\n", + "This tutorial simulates a classic **2D dam break**: a dense fluid column confined to the left quarter of a closed box is released at $t=0$. Gravity pulls the fluid downward and the pressure gradient drives a horizontal spreading wave. The SPH method naturally handles the large interface deformation and free-surface dynamics.\n", + "\n", + "### Physical Setup\n", + "\n", + "The domain is a $1 \\times 1$ box with **reflective walls** on all sides. At $t = 0$ the fluid occupies the region $x \\in [0, L/4]$ at density $\\rho_\\text{high}$; the rest of the box is effectively empty (markers with negligible weight are removed). As the fluid evolves:\n", + "\n", + "1. The column collapses under downward gravity $g_y$.\n", + "2. The pressure gradient ($\\propto c_s^2 \\nabla\\rho$ in the isothermal model) drives a shock-like spreading wave.\n", + "3. Viscosity damps small-scale velocity fluctuations and stabilises the SPH.\n", + "4. Reflections off the walls cause the fluid to slosh and eventually reach a hydrostatic equilibrium with density concentrated at the bottom.\n", + "\n", + "### SPH parameters for compressible free-surface flows\n", + "\n", + "The isothermal equation of state $p = \\kappa \\rho$ is used with a **weak compressibility** coefficient $\\kappa = c_s^2 \\ll 1$. This keeps the flow subsonic (Mach number $\\text{Ma} = U_\\text{max}/c_s \\ll 1$) while allowing large density variations — the *Weakly Compressible SPH* (WCSPH) regime.\n", + "\n", + "Markers with very small weights (corresponding to the near-vacuum region) are removed by the `reject_weights` option before the simulation starts.\n", + "\n", + "### What to expect\n", + "\n", + "- The dense column hits the right wall, runs up it, and splashes back.\n", + "- Multiple reflections produce complex sloshing dynamics.\n", + "- At late times ($t \\gtrsim 2$) the fluid settles near the bottom, forming a stable layer.\n", + "- As a qualitative verification we check that **no markers escape the closed box** throughout the simulation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "import os\n", + "import shutil\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from struphy import (\n", + " BoundaryParameters,\n", + " EnvironmentOptions,\n", + " KernelDensityPlot,\n", + " LoadingParameters,\n", + " SavingParameters,\n", + " Simulation,\n", + " SortingParameters,\n", + " Time,\n", + " WeightsParameters,\n", + " domains,\n", + " equils,\n", + ")\n", + "from struphy.models import ViscousEulerSPH\n", + "from struphy.ode.utils import ButcherTableau\n", + "\n", + "logger = logging.getLogger(\"struphy\")" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "### Physical and Numerical Parameters\n", + "\n", + "The free-fall time from height $H/2$ is $\\sqrt{2(H/2)/g_y} \\approx 0.32$, so $T_\\text{end} = 3$ covers roughly 9 free-fall times. The sound speed $c_s = \\sqrt{\\kappa} \\approx 0.45$, giving Mach number $\\text{Ma} = \\sqrt{2 g_y H/2}/c_s \\approx 0.71$ — weakly supersonic, which is acceptable for WCSPH with small $\\kappa$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Physical parameters\n", + "kappa = 0.2 # isothermal coefficient (= c_s^2); weak compressibility\n", + "mu = 0.05 # dynamic viscosity (small, for stability)\n", + "g_y = 10.0 # gravitational acceleration (downward = -y direction)\n", + "r1 = 1.0 # domain width (x)\n", + "r2 = 1.0 # domain height (y)\n", + "n_high = 0.1 # initial density of the fluid column\n", + "\n", + "# Derived\n", + "c_s = kappa**0.5\n", + "U_max = (2.0 * g_y * r2 / 2.0)**0.5 # rough free-fall velocity scale\n", + "Ma = U_max / c_s\n", + "\n", + "# Numerical parameters\n", + "nx = 8 # boxes per spatial dimension\n", + "ppb = 32 # particles per box\n", + "plot_pts = 21 # KDE evaluation points per dimension\n", + "\n", + "# Time stepping: CFL limit ~ h/c_s = (r1/nx)/c_s\n", + "dt = 0.02\n", + "Tend = 3.0\n", + "\n", + "print(f\"Sound speed: c_s = {c_s:.3f}\")\n", + "print(f\"Max velocity est: U_max = {U_max:.3f} (Mach = {Ma:.2f})\")\n", + "print(f\"Fluid density: n_high = {n_high} (vacuum elsewhere)\")\n", + "print(f\"Time stepping: dt={dt}, Tend={Tend}, {int(Tend/dt)} steps\")\n", + "print(f\"Total particles: ~{ppb * nx * nx // 4} (left quarter of domain)\")" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "### Model Setup\n", + "\n", + "All three physical effects are active: pressure (`with_p=True`), viscosity (`with_viscosity=True`). The 2D Gaussian kernel is used. Gravity enters as a downward vector `(0, -g_y, 0)` in the pressure propagator." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "model = ViscousEulerSPH(with_B0=False, with_p=True, with_viscosity=True)\n", + "\n", + "butcher = ButcherTableau(algo=\"forward_euler\")\n", + "model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher)\n", + "model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options(\n", + " kernel_type=\"gaussian_2d\",\n", + " gravity=(0.0, -g_y, 0.0), # downward gravity\n", + " kappa=kappa,\n", + ")\n", + "model.propagators.push_viscous.options = model.propagators.push_viscous.Options(\n", + " kernel_type=\"gaussian_2d\",\n", + " mu=mu,\n", + ")\n", + "\n", + "print(\"ViscousEulerSPH configured (pressure + viscosity + gravity).\")\n", + "print(f\" kappa={kappa}, mu={mu}, g_y={g_y} (downward)\")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "### Domain, Boundary Conditions and Diagnostics\n", + "\n", + "The box has **reflective walls** on all sides (`bc=\"reflect\"`) with SPH mirror ghost particles (`bc_sph=\"mirror\"`). The `reject_weights` option removes near-vacuum markers (those with weight below the threshold) so that only the dense left-column particles are simulated.\n", + "\n", + "The `n_markers=1.0` option in `SavingParameters` saves every marker orbit at each time step, enabling post-hoc visualisation of particle trajectories." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "domain = domains.Cuboid(r1=r1, r2=r2)\n", + "\n", + "loading_params = LoadingParameters(ppb=ppb, loading=\"tesselation\")\n", + "weights_params = WeightsParameters(reject_weights=True, threshold=1e-6)\n", + "boundary_params = BoundaryParameters(\n", + " bc =(\"reflect\", \"reflect\", \"periodic\"),\n", + " bc_sph=(\"mirror\", \"mirror\", \"periodic\"),\n", + ")\n", + "sorting_params = SortingParameters(\n", + " boxes_per_dim=(nx, nx, 1),\n", + " dims_mask=(True, True, False),\n", + ")\n", + "\n", + "kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=plot_pts, pts_e3=1)\n", + "saving_params = SavingParameters(\n", + " n_markers=1.0, # save all marker positions every step\n", + " kernel_density_plots=(kd_plot,),\n", + ")\n", + "\n", + "model.euler_fluid.set_markers(\n", + " loading_params=loading_params,\n", + " weights_params=weights_params,\n", + " boundary_params=boundary_params,\n", + " sorting_params=sorting_params,\n", + " saving_params=saving_params,\n", + " bufsize=2,\n", + ")\n", + "\n", + "print(f\"2D closed box [{r1}×{r2}], reflective walls on all sides\")\n", + "print(\"Vacuum markers (weight < 1e-6) removed before simulation\")" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "### Initial Conditions\n", + "\n", + "The `step_function_xy` density profile places $\\rho = \\rho_\\text{high}$ where $x < L/4$ and $y < H$, and near-vacuum ($\\sim 10^{-8}$) elsewhere. The near-vacuum markers are then removed by the `reject_weights` filter, leaving only the dense left-column particles.\n", + "\n", + "We colour each marker by its initial $x$-position (normalised to $[0, 1]$ within the column) to track how the fluid mixes during the dam break." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "background = equils.ConstantVelocity(\n", + " density_profile=\"step_function_xy\",\n", + " n=n_high,\n", + " upper_x=r1 / 4, # dense column: x < r1/4\n", + " upper_y=r2,\n", + ")\n", + "model.euler_fluid.var.add_background(background)\n", + "\n", + "print(f\"Initial condition: dense column (n={n_high}) for x < {r1/4}\")\n", + "print(\"Near-vacuum markers (x > r1/4) will be removed by reject_weights\")" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "### Simulation Setup and Execution" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "test_folder = os.path.join(os.getcwd(), \"struphy_verification_tests\")\n", + "out_folders = os.path.join(test_folder, \"ViscousEulerSPH\")\n", + "env = EnvironmentOptions(out_folders=out_folders, sim_folder=\"dam_break\")\n", + "\n", + "time_opts = Time(dt=dt, Tend=Tend, split_algo=\"Strang\")\n", + "\n", + "sim = Simulation(\n", + " model=model,\n", + " env=env,\n", + " time_opts=time_opts,\n", + " domain=domain,\n", + " grid=None,\n", + " derham_opts=None,\n", + ")\n", + "\n", + "print(f\"Running 2D dam break: dt={dt}, Tend={Tend}\")\n", + "sim.run()\n", + "print(\"Simulation complete.\")\n", + "\n", + "sim.pproc()\n", + "print(\"Post-processing complete.\")" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "### Load Diagnostics" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "sim.load_plotting_data()\n", + "\n", + "# KDE density field: shape (Nt+1, pts_e1, pts_e2, 1)\n", + "ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph\n", + "n_sph = sim.n_sph.euler_fluid.view_0.n_sph\n", + "\n", + "# Marker orbits: shape (Nt_orb, n_markers, n_attrs)\n", + "# attrs for vdim=2: [x, y, z, v1, v2, w, diag, id]\n", + "orbits = np.asarray(sim.orbits.euler_fluid)\n", + "\n", + "Nt = int(Tend / dt)\n", + "times = np.linspace(0.0, Tend, Nt + 1)\n", + "Nt_orb = orbits.shape[0]\n", + "t_orbit = np.linspace(0.0, Tend, Nt_orb)\n", + "\n", + "X = np.asarray(ee1)[:, :, 0] * r1 # physical x, shape (pts_e1, pts_e2)\n", + "Y = np.asarray(ee2)[:, :, 0] * r2 # physical y\n", + "n_arr = np.asarray(n_sph) # (Nt+1, pts_e1, pts_e2, 1)\n", + "\n", + "# Colour each marker by its initial x position within the column\n", + "x_init = orbits[0, :, 0]\n", + "c_val = x_init / (r1 / 4.0) # 0 = left wall, 1 = dam face\n", + "\n", + "print(f\"KDE field shape: {n_arr.shape}\")\n", + "print(f\"Marker orbits: {orbits.shape} [{Nt_orb} snapshots, {orbits.shape[1]} markers]\")" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "### Visualisation: Density Field Snapshots\n", + "\n", + "Twelve equally spaced snapshots show the KDE density field (colour map) overlaid with marker positions (coloured by initial $x$). The colour gradient from blue (left wall) to red (dam face) reveals how the fluid column mixes as it spreads across the domain." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "snapshot_inds = np.round(np.linspace(0, Nt, 12)).astype(int)\n", + "orb_inds = np.round(np.linspace(0, Nt_orb - 1, 12)).astype(int)\n", + "vmax_plot = float(np.max(n_arr))\n", + "\n", + "fig, axes = plt.subplots(4, 3, figsize=(15, 12), sharex=True, sharey=True)\n", + "im = None\n", + "for ax, idx, oidx in zip(axes.flatten(), snapshot_inds, orb_inds):\n", + " n_2d = n_arr[idx, :, :, 0]\n", + " im = ax.pcolormesh(X, Y, n_2d, vmin=0.0, vmax=vmax_plot, cmap=\"Blues\", shading=\"auto\")\n", + " ax.scatter(\n", + " orbits[oidx, :, 0],\n", + " orbits[oidx, :, 1],\n", + " c=c_val, cmap=\"autumn\", s=3,\n", + " vmin=0.0, vmax=1.0, alpha=0.7,\n", + " )\n", + " ax.set_title(f\"$t = {times[idx]:.2f}$\", fontsize=10)\n", + " ax.set_aspect(\"equal\")\n", + "\n", + "for ax in axes[-1, :]:\n", + " ax.set_xlabel(\"$x$\")\n", + "for ax in axes[:, 0]:\n", + " ax.set_ylabel(\"$y$\")\n", + "\n", + "if im is not None:\n", + " fig.colorbar(im, ax=axes.ravel().tolist(), label=r\"$\\rho$ (KDE)\", shrink=0.6)\n", + "\n", + "fig.suptitle(\n", + " rf\"2D dam break: $\\kappa={kappa}$, $g_y={g_y}$, $\\mu={mu}$, {nx}\\times{nx} boxes\",\n", + " fontsize=12,\n", + ")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "### Visualisation: Final Marker State\n", + "\n", + "Show the final particle configuration to check that the fluid has settled into a stable layer at the bottom of the box." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(figsize=(8, 6))\n", + "sc = ax.scatter(\n", + " orbits[-1, :, 0],\n", + " orbits[-1, :, 1],\n", + " c=c_val, cmap=\"autumn\", s=8, vmin=0.0, vmax=1.0,\n", + ")\n", + "ax.set_xlim(0.0, r1)\n", + "ax.set_ylim(0.0, r2)\n", + "ax.set_xlabel(\"$x$\")\n", + "ax.set_ylabel(\"$y$\")\n", + "ax.set_title(rf\"Final state at $t = {Tend:.1f}$\")\n", + "ax.set_aspect(\"equal\")\n", + "plt.colorbar(sc, ax=ax, label=\"Initial position (0=left wall, 1=dam face)\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "### Verification Check\n", + "\n", + "Since the dam break has no simple analytical steady state, the verification is a **domain-bounds assertion**: no marker should escape the closed reflective box. A 1% tolerance accounts for the finite displacement during a single time step." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "x_all = orbits[:, :, 0]\n", + "y_all = orbits[:, :, 1]\n", + "\n", + "x_min, x_max = float(np.min(x_all)), float(np.max(x_all))\n", + "y_min, y_max = float(np.min(y_all)), float(np.max(y_all))\n", + "\n", + "print(\"=== Dam Break Domain Bounds Verification ===\")\n", + "print(f\" x range: [{x_min:.4f}, {x_max:.4f}] (domain [0, {r1}])\")\n", + "print(f\" y range: [{y_min:.4f}, {y_max:.4f}] (domain [0, {r2}])\")\n", + "\n", + "tol = 0.01 # 1% of domain size\n", + "\n", + "try:\n", + " assert x_min >= -tol * r1 and x_max <= (1.0 + tol) * r1, (\n", + " f\"Markers escaped x-domain: x in [{x_min:.4f}, {x_max:.4f}]\"\n", + " )\n", + " print(\"\\n✓ x-domain bounds check passed.\")\n", + "except AssertionError as e:\n", + " print(f\"\\n✗ {e}\")\n", + "\n", + "try:\n", + " assert y_min >= -tol * r2 and y_max <= (1.0 + tol) * r2, (\n", + " f\"Markers escaped y-domain: y in [{y_min:.4f}, {y_max:.4f}]\"\n", + " )\n", + " print(\"✓ y-domain bounds check passed.\")\n", + "except AssertionError as e:\n", + " print(f\"✗ {e}\")" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "### Conclusion\n", + "\n", + "This tutorial demonstrated the SPH method for a free-surface flow problem:\n", + "\n", + "- **Weakly Compressible SPH (WCSPH)**: the isothermal equation of state with small $\\kappa$ provides a pressure response that keeps the flow subsonic while allowing large density variations — the hallmark of free-surface SPH.\n", + "- **Mirror ghost particles** enforce reflective boundary conditions on all four walls without special treatment for free surfaces.\n", + "- **Reject-weights** cleanly removes near-vacuum markers before the simulation, avoiding spurious SPH interactions in the void region.\n", + "- **Marker colouring** by initial position reveals the mixing and transport patterns during the collapse and subsequent sloshing.\n", + "- The domain-bounds assertion verifies that the boundary conditions work correctly throughout the highly dynamic simulation.\n", + "\n", + "The dam break is a standard benchmark for validating SPH implementations. More quantitative comparisons (run-up height, wave arrival time) can be made against published experimental data or higher-resolution SPH simulations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "# Optional cleanup\n", + "if False: # set to True to remove simulation output\n", + " shutil.rmtree(test_folder)\n", + " print(f\"Cleaned up {test_folder}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env (3.12.3.final.0)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/tutorial_hagen_poiseuille_sph.ipynb b/tutorials/tutorial_hagen_poiseuille_sph.ipynb new file mode 100644 index 000000000..ada024c24 --- /dev/null +++ b/tutorials/tutorial_hagen_poiseuille_sph.ipynb @@ -0,0 +1,472 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# 2D Hagen-Poiseuille Channel Flow with SPH\n", + "\n", + "## Viscous Flow between Parallel Plates\n", + "\n", + "This tutorial verifies the SPH discretisation of the 2D viscous isothermal Euler equations by simulating **Hagen-Poiseuille flow**: a pressure-driven channel flow between two no-slip walls.\n", + "\n", + "### Physical Setup\n", + "\n", + "Consider a 2D channel of width $H$ in the $y$-direction (no-slip walls at $y=0$ and $y=H$) and periodic in the $x$-direction. A uniform body force $g_x$ drives the flow in the $x$-direction. At steady state, viscosity balances the driving force, producing the **parabolic velocity profile**\n", + "\n", + "$$u_x^\\text{exact}(y) = \\frac{g_x}{2\\mu}\\,y\\,(H - y),$$\n", + "\n", + "with peak velocity at the channel centreline $y = H/2$:\n", + "\n", + "$$U_\\text{max} = \\frac{g_x H^2}{8\\mu}.$$\n", + "\n", + "The characteristic relaxation time scale is $T_\\text{relax} = H^2/(\\pi^2\\mu)$.\n", + "\n", + "### Verification procedure\n", + "\n", + "1. Start from rest with a uniform particle distribution and no-slip boundary conditions at the walls.\n", + "2. Drive the flow with a constant body force $g_x$ acting through the pressure propagator's `gravity` parameter.\n", + "3. Run until the flow is fully relaxed to the steady state ($t \\gg T_\\text{relax}$).\n", + "4. Compare the final velocity profile against the Hagen-Poiseuille parabola in the $L^\\infty$ norm." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "import os\n", + "import shutil\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import cunumpy as xp\n", + "\n", + "from struphy import (\n", + " BinningPlot,\n", + " BoundaryParameters,\n", + " EnvironmentOptions,\n", + " KernelDensityPlot,\n", + " LoadingParameters,\n", + " SavingParameters,\n", + " Simulation,\n", + " SortingParameters,\n", + " Time,\n", + " WeightsParameters,\n", + " domains,\n", + " equils,\n", + ")\n", + "from struphy.models import ViscousEulerSPH\n", + "from struphy.ode.utils import ButcherTableau\n", + "\n", + "logger = logging.getLogger(\"struphy\")" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "### Physical and Numerical Parameters\n", + "\n", + "We choose $\\mu = 0.1$ and a body force $g_x = 0.1$, giving an analytical peak velocity $U_\\text{max} = g_x H^2 / (8\\mu) = 0.125$. The relaxation time $T_\\text{relax} = H^2/(\\pi^2\\mu) \\approx 1.01$; running to $T_\\text{end} = 10$ gives roughly 10 relaxation times, ensuring a fully converged steady state." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Physical parameters\n", + "mu = 0.1 # dynamic viscosity\n", + "g_x = 0.1 # body force in x (acts as pressure gradient)\n", + "H = 1.0 # channel height in y\n", + "\n", + "# Derived quantities\n", + "U_max_exact = g_x * H**2 / (8.0 * mu)\n", + "T_relax = H**2 / (np.pi**2 * mu)\n", + "\n", + "# Numerical parameters\n", + "nx = 8 # boxes per dimension\n", + "ppb = 16 # particles per box\n", + "plot_pts = 21 # KDE evaluation points\n", + "\n", + "# Time stepping: run 10× past relaxation time\n", + "dt = 0.01\n", + "Tend = 10.0\n", + "\n", + "print(f\"Viscosity: mu = {mu}\")\n", + "print(f\"Body force: g_x = {g_x}\")\n", + "print(f\"Channel height: H = {H}\")\n", + "print(f\"Analytical U_max: {U_max_exact:.4f}\")\n", + "print(f\"Relaxation time: T_relax = {T_relax:.2f}\")\n", + "print(f\"Simulation time: Tend = {Tend} ({Tend/T_relax:.1f}× T_relax)\")\n", + "print(f\"Total particles: {ppb * nx * nx}\")" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "### Model Setup\n", + "\n", + "`with_viscosity=True` (default) activates the viscous stress propagator. The 2D Gaussian SPH kernel (`gaussian_2d`) is required for the 2D geometry. The body force $g_x$ is passed as the `gravity` vector to `push_sph_p`; it enters the momentum equation as a constant acceleration $\\partial_t u_1 \\supset g_x$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "model = ViscousEulerSPH(with_B0=False, with_p=True, with_viscosity=True)\n", + "\n", + "butcher = ButcherTableau(algo=\"forward_euler\")\n", + "model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher)\n", + "model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options(\n", + " kernel_type=\"gaussian_2d\",\n", + " gravity=(g_x, 0.0, 0.0), # body force drives flow in x\n", + ")\n", + "model.propagators.push_viscous.options = model.propagators.push_viscous.Options(\n", + " kernel_type=\"gaussian_2d\",\n", + " mu=mu,\n", + ")\n", + "\n", + "print(\"ViscousEulerSPH model configured (with pressure, with viscosity).\")\n", + "print(f\" push_sph_p: gaussian_2d kernel, gravity=({g_x}, 0, 0)\")\n", + "print(f\" push_viscous: gaussian_2d kernel, mu={mu}\")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "### Domain, Boundary Conditions and Diagnostics\n", + "\n", + "The channel geometry uses:\n", + "- **x-direction**: periodic (flow direction).\n", + "- **y-direction**: no-slip walls (`bc_sph=\"noslip\"`). The SPH no-slip boundary condition enforces $u_x = 0$ at the walls by introducing mirror ghost particles with reflected (negated) velocities.\n", + "\n", + "We bin the **current** $j_1 = \\rho u_1$ as a function of $y$ to recover the velocity profile. Since $\\rho \\approx 1$ (nearly incompressible at these parameters), $j_1 \\approx u_x$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "# 2D channel domain: x in [0, 1], y in [0, H]\n", + "domain = domains.Cuboid(r1=1.0, r2=H)\n", + "\n", + "loading_params = LoadingParameters(ppb=ppb, loading=\"tesselation\")\n", + "weights_params = WeightsParameters()\n", + "boundary_params = BoundaryParameters(\n", + " bc =(\"periodic\", \"reflect\", \"periodic\"), # particle reflections\n", + " bc_sph=(\"periodic\", \"noslip\", \"periodic\"), # SPH ghost treatment\n", + ")\n", + "sorting_params = SortingParameters(\n", + " boxes_per_dim=(nx, nx, 1),\n", + " dims_mask=(True, True, False),\n", + ")\n", + "\n", + "# Bin j1 (velocity) vs y to reconstruct the velocity profile\n", + "bin_plot_j1 = BinningPlot(slice=\"e2\", n_bins=(16,), ranges=(0.0, 1.0), output_quantity=\"current_1\")\n", + "bin_plot_n = BinningPlot(slice=\"e2\", n_bins=(16,), ranges=(0.0, 1.0))\n", + "kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=plot_pts, pts_e3=1)\n", + "saving_params = SavingParameters(\n", + " n_markers=1.0,\n", + " binning_plots=(bin_plot_j1, bin_plot_n),\n", + " kernel_density_plots=(kd_plot,),\n", + ")\n", + "\n", + "model.euler_fluid.set_markers(\n", + " loading_params=loading_params,\n", + " weights_params=weights_params,\n", + " boundary_params=boundary_params,\n", + " sorting_params=sorting_params,\n", + " saving_params=saving_params,\n", + " bufsize=2, # extra ghost layer for no-slip\n", + ")\n", + "\n", + "print(f\"2D channel: x-periodic [0, 1], y-noslip [0, {H}]\")\n", + "print(f\"Particles: {ppb} ppb × {nx}×{nx} boxes = {ppb * nx * nx} total\")" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "### Initial Conditions\n", + "\n", + "The flow starts **from rest** (zero velocity, uniform density $\\rho_0 = 1$). The constant body force $g_x$ drives acceleration; viscosity and the no-slip walls establish the parabolic steady state over the relaxation time scale $T_\\text{relax}$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "# Start from rest: no velocity perturbation needed — body force drives the flow\n", + "background = equils.ConstantVelocity()\n", + "model.euler_fluid.var.add_background(background)\n", + "\n", + "print(\"Initial condition: uniform density n=1, zero velocity everywhere\")\n", + "print(f\"Body force g_x={g_x} will accelerate flow; viscosity establishes steady state\")" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "### Simulation Setup and Execution" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "test_folder = os.path.join(os.getcwd(), \"struphy_verification_tests\")\n", + "out_folders = os.path.join(test_folder, \"ViscousEulerSPH\")\n", + "env = EnvironmentOptions(out_folders=out_folders, sim_folder=\"hagen_poiseuille\")\n", + "\n", + "time_opts = Time(dt=dt, Tend=Tend, split_algo=\"Strang\")\n", + "\n", + "sim = Simulation(\n", + " model=model,\n", + " env=env,\n", + " time_opts=time_opts,\n", + " domain=domain,\n", + " grid=None,\n", + " derham_opts=None,\n", + ")\n", + "\n", + "print(f\"Running Hagen-Poiseuille flow: dt={dt}, Tend={Tend}\")\n", + "sim.run()\n", + "print(\"Simulation complete.\")\n", + "\n", + "sim.pproc()\n", + "print(\"Post-processing complete.\")" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "### Load Diagnostics and Compute Exact Solution" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "sim.load_plotting_data()\n", + "\n", + "e2_grid = sim.f.euler_fluid.e2_current_1.grid_e2 # logical y in [0, 1]\n", + "j1_binned = sim.f.euler_fluid.e2_current_1.f_binned # shape (Nt+1, n_bins)\n", + "\n", + "Nt = int(Tend / dt)\n", + "times = np.linspace(0.0, Tend, Nt + 1)\n", + "\n", + "e2_np = np.asarray(e2_grid).flatten()\n", + "y_np = e2_np * H # physical y coordinate\n", + "\n", + "# Analytical Hagen-Poiseuille profile\n", + "u_exact = g_x / (2.0 * mu) * y_np * (H - y_np)\n", + "u_max_exact = np.max(u_exact)\n", + "\n", + "u_num_final = np.asarray(j1_binned[-1, :]).flatten()\n", + "u_max_num = np.max(u_num_final)\n", + "\n", + "# Centreline velocity over time (index of y closest to H/2)\n", + "idx_centre = int(np.argmin(np.abs(e2_np - 0.5)))\n", + "u_centre = np.asarray(j1_binned[:, idx_centre]).flatten()\n", + "\n", + "print(f\"Analytical U_max = {u_max_exact:.6f}\")\n", + "print(f\"Numerical U_max = {u_max_num:.6f}\")\n", + "\n", + "abs_err = np.abs(u_num_final - u_exact)\n", + "rel_err_pointwise = abs_err / u_max_exact\n", + "rel_error_interior = rel_err_pointwise[1:-1] # exclude wall bins (exact value → 0)\n", + "rel_error_umax = abs(u_max_num - u_max_exact) / u_max_exact\n", + "\n", + "print(f\"Mean interior relative error: {np.mean(rel_error_interior) * 100:.2f}%\")\n", + "print(f\"Max interior relative error: {np.max(rel_error_interior) * 100:.2f}%\")\n", + "print(f\"U_max relative error: {rel_error_umax * 100:.2f}%\")" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "### Visualisation\n", + "\n", + "Three panels summarise the result:\n", + "\n", + "- **Left**: final numerical velocity profile vs the analytical parabola.\n", + "- **Centre**: pointwise relative error $|u_x^\\text{num} - u_x^\\text{exact}| / U_\\text{max}$ vs $y$ (excluding wall bins).\n", + "- **Right**: time evolution of the centreline velocity, showing relaxation to the Hagen-Poiseuille steady state." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n", + "\n", + "# --- Left: velocity profile ---\n", + "ax = axes[0]\n", + "ax.plot(u_num_final, y_np, \"o-\", markersize=4, label=\"Numerical (SPH)\")\n", + "ax.plot(u_exact, y_np, \"k--\", label=\"Analytical (Hagen-Poiseuille)\")\n", + "ax.set_xlabel(r\"$u_x$\")\n", + "ax.set_ylabel(r\"$y$\")\n", + "ax.set_title(\"Steady-state velocity profile\")\n", + "ax.legend()\n", + "ax.grid(True)\n", + "\n", + "# --- Centre: pointwise relative error ---\n", + "ax = axes[1]\n", + "ax.plot(rel_err_pointwise * 100, y_np, \"r-o\", markersize=4)\n", + "ax.set_xlabel(r\"$|u_x^\\mathrm{num} - u_x^\\mathrm{exact}|\\,/\\,U_\\mathrm{max}$ [%]\")\n", + "ax.set_ylabel(r\"$y$\")\n", + "ax.set_title(f\"Pointwise relative error (max interior = {np.max(rel_error_interior)*100:.1f}%)\")\n", + "ax.grid(True)\n", + "\n", + "# --- Right: centreline velocity relaxation ---\n", + "ax = axes[2]\n", + "ax.plot(times, u_centre, label=r\"Numerical $u_x(y=H/2)$\")\n", + "ax.axhline(u_max_exact, color=\"k\", linestyle=\"--\",\n", + " label=rf\"Exact $U_\\mathrm{{max}} = {u_max_exact:.4f}$\")\n", + "ax.set_xlabel(\"time\")\n", + "ax.set_ylabel(r\"$u_x(y=H/2)$\")\n", + "ax.set_title(\"Centreline velocity: relaxation to steady state\")\n", + "ax.legend()\n", + "ax.grid(True)\n", + "\n", + "fig.suptitle(\n", + " rf\"Hagen-Poiseuille: $\\mu={mu}$, $g_x={g_x}$, $H={H}$, {nx}\\times{nx} boxes\",\n", + " fontsize=12,\n", + ")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "### Verification Check\n", + "\n", + "Two assertions:\n", + "1. Maximum pointwise relative error in the **interior** bins (away from walls where the exact value vanishes) is below 5%.\n", + "2. Relative error in the peak velocity $U_\\text{max}$ is below 5%." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "tol_interior = 0.05 # 5% pointwise tolerance\n", + "tol_umax = 0.05 # 5% tolerance on U_max\n", + "\n", + "print(\"=== Hagen-Poiseuille Verification ===\")\n", + "print(f\" Max interior relative error: {np.max(rel_error_interior) * 100:.2f}% (tolerance {tol_interior*100:.0f}%)\")\n", + "print(f\" U_max relative error: {rel_error_umax * 100:.2f}% (tolerance {tol_umax*100:.0f}%)\")\n", + "\n", + "try:\n", + " assert np.max(rel_error_interior) < tol_interior, (\n", + " f\"Interior error {np.max(rel_error_interior)*100:.1f}% exceeds tolerance {tol_interior*100:.0f}%\"\n", + " )\n", + " print(\"\\n✓ Interior velocity profile check passed.\")\n", + "except AssertionError as e:\n", + " print(f\"\\n✗ {e}\")\n", + "\n", + "try:\n", + " assert rel_error_umax < tol_umax, (\n", + " f\"U_max error {rel_error_umax*100:.1f}% exceeds tolerance {tol_umax*100:.0f}%\"\n", + " )\n", + " print(\"✓ U_max check passed.\")\n", + "except AssertionError as e:\n", + " print(f\"✗ {e}\")" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "### Conclusion\n", + "\n", + "This tutorial verified the SPH discretisation of 2D viscous channel flow:\n", + "\n", + "- The **no-slip boundary condition** is implemented via mirror ghost particles with negated tangential velocity, correctly enforcing $u_x = 0$ at both walls.\n", + "- The **parabolic steady state** emerges from the balance between body force and viscous stress, reproduced to better than 5% pointwise accuracy with $8 \\times 8$ boxes and 16 particles per box.\n", + "- The **centreline velocity** converges smoothly to $U_\\text{max}$ over the relaxation time $T_\\text{relax} = H^2/(\\pi^2 \\mu) \\approx 1$.\n", + "- Increasing `nx` or `ppb` further reduces the error, as the SPH kernel gradient approximation of the viscous stress tensor improves with particle density." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "# Optional cleanup\n", + "if False: # set to True to remove simulation output\n", + " shutil.rmtree(test_folder)\n", + " print(f\"Cleaned up {test_folder}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env (3.12.3.final.0)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/tutorial_velocity_diffusion_sph.ipynb b/tutorials/tutorial_velocity_diffusion_sph.ipynb new file mode 100644 index 000000000..056b93324 --- /dev/null +++ b/tutorials/tutorial_velocity_diffusion_sph.ipynb @@ -0,0 +1,478 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Velocity Diffusion with SPH Viscosity\n", + "\n", + "## Pure Viscous Momentum Diffusion in 1D\n", + "\n", + "This tutorial verifies the SPH viscous propagator by simulating pure velocity diffusion — the momentum equation with viscosity but **without pressure forces**. In this limit the equation reduces to\n", + "\n", + "$$\\partial_t u_1 = \\frac{4}{3}\\,\\mu\\,\\partial_{x}^2 u_1,$$\n", + "\n", + "whose exact solution for a sinusoidal initial condition is\n", + "\n", + "$$u_1(x, t) = A_0\\,\\sin\\!\\left(\\frac{2\\pi\\ell\\, x}{L}\\right)\\exp\\!\\left(-\\gamma\\, t\\right), \\qquad \\gamma = \\frac{4}{3}\\,\\mu\\,k^2,\\quad k = \\frac{2\\pi\\ell}{L}.$$\n", + "\n", + "The factor $4/3$ comes from the compressible viscous stress tensor:\n", + "for a 1D plane wave $\\partial_i u_j$ only has components along $i=j=1$, so the deviatoric stress contributes $2\\mu\\,(1 - 1/3) = (4/3)\\mu$ times the velocity gradient.\n", + "\n", + "### Verification procedure\n", + "\n", + "1. Initialise a 1D particle distribution (tessellation loading) with a velocity perturbation $\\delta u_1 \\propto \\sin(2\\pi x/L)$ and **no pressure force** (`with_p=False`).\n", + "2. Run the simulation for a short time $T = 0.1$ (decay e-folding $1/\\gamma \\approx 0.038$ at the chosen parameters).\n", + "3. Track the current $j_1 = \\rho u_1 \\approx u_1$ at the velocity antinode $x = L/4$ and fit the decay rate.\n", + "4. Check that the numerical rate matches $\\gamma_\\text{analytical}$ to within 4%." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "import os\n", + "import shutil\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import cunumpy as xp\n", + "\n", + "from struphy import (\n", + " BinningPlot,\n", + " BoundaryParameters,\n", + " EnvironmentOptions,\n", + " KernelDensityPlot,\n", + " LoadingParameters,\n", + " SavingParameters,\n", + " Simulation,\n", + " SortingParameters,\n", + " Time,\n", + " WeightsParameters,\n", + " domains,\n", + " equils,\n", + " perturbations,\n", + ")\n", + "from struphy.models import ViscousEulerSPH\n", + "from struphy.ode.utils import ButcherTableau\n", + "\n", + "logger = logging.getLogger(\"struphy\")" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "### Physical and Numerical Parameters\n", + "\n", + "We use a large viscosity $\\mu = 1$ so that the decay is fast enough to observe over a short simulation. Mode $\\ell = 1$ gives wavenumber $k = 2\\pi/L$, and the analytical e-folding time is $\\tau = 1/\\gamma = 3/(4\\mu k^2)$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Physical parameters\n", + "mu = 1.0 # dynamic viscosity (large → fast decay, clearly observable)\n", + "r1 = 1.0 # domain length (1D periodic)\n", + "\n", + "# Mode and analytical decay rate: gamma = (4/3)*mu*k^2\n", + "ell = 1\n", + "k = 2.0 * np.pi * ell / r1\n", + "gamma_analytical = mu * (4.0 / 3.0) * k**2\n", + "\n", + "# Numerical parameters\n", + "nx = 8 # boxes in x-direction\n", + "ppb = 100 # particles per box (high density for accurate diffusion)\n", + "plot_pts = 11 # KDE evaluation points\n", + "\n", + "# Time stepping: Tend ~ 0.1 covers ~2.6 e-folding times\n", + "dt = 0.0025\n", + "Tend = 0.1\n", + "\n", + "print(f\"Viscosity: mu = {mu}\")\n", + "print(f\"Domain length: L = {r1}\")\n", + "print(f\"Wave mode: ell = {ell}, k = {k:.4f}\")\n", + "print(f\"Analytical decay rate: gamma = (4/3)*mu*k^2 = {gamma_analytical:.4f}\")\n", + "print(f\"E-folding time: tau = 1/gamma = {1/gamma_analytical:.4f}\")\n", + "print(f\"Total particles: {ppb * nx}\")" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "### Model Setup\n", + "\n", + "`with_p=False` disables the pressure propagator so only the viscous term acts. The `push_viscous` propagator implements the SPH discretisation of the full compressible viscous stress divergence." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "# Pressure-free, viscosity-only model\n", + "model = ViscousEulerSPH(with_B0=False, with_p=False, with_viscosity=True)\n", + "\n", + "butcher = ButcherTableau(algo=\"forward_euler\")\n", + "model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher)\n", + "model.propagators.push_viscous.options = model.propagators.push_viscous.Options(\n", + " kernel_type=\"gaussian_1d\", mu=mu\n", + ")\n", + "\n", + "print(\"ViscousEulerSPH model configured (no pressure, with viscosity).\")\n", + "print(f\" push_viscous: gaussian_1d kernel, mu={mu}\")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "### Domain and Particle Markers\n", + "\n", + "A 1D periodic domain of length $L=1$. The high particle count (`ppb=100`) is needed to resolve the kernel gradient accurately for the viscous term. Two `BinningPlot` diagnostics are registered: one for density and one for the current $j_1 = \\rho u_1$, which tracks the velocity amplitude over time." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "domain = domains.Cuboid(r1=r1)\n", + "grid = None\n", + "derham_opts = None\n", + "\n", + "loading_params = LoadingParameters(ppb=ppb, loading=\"tesselation\")\n", + "weights_params = WeightsParameters()\n", + "boundary_params = BoundaryParameters()\n", + "sorting_params = SortingParameters(\n", + " boxes_per_dim=(nx, 1, 1),\n", + " dims_mask=(True, False, False),\n", + ")\n", + "\n", + "bin_plot = BinningPlot(slice=\"e1\", n_bins=(16,), ranges=(0.0, 1.0))\n", + "bin_plot_j1 = BinningPlot(slice=\"e1\", n_bins=(16,), ranges=(0.0, 1.0), output_quantity=\"current_1\")\n", + "kd_plot = KernelDensityPlot(pts_e1=plot_pts, pts_e2=1)\n", + "saving_params = SavingParameters(\n", + " binning_plots=(bin_plot, bin_plot_j1),\n", + " kernel_density_plots=(kd_plot,),\n", + ")\n", + "\n", + "model.euler_fluid.set_markers(\n", + " loading_params=loading_params,\n", + " weights_params=weights_params,\n", + " boundary_params=boundary_params,\n", + " sorting_params=sorting_params,\n", + " saving_params=saving_params,\n", + ")\n", + "\n", + "print(f\"Domain: 1D periodic, r1={r1}\")\n", + "print(f\"Particles: {ppb} ppb × {nx} boxes = {ppb * nx} total\")\n", + "print(f\"Diagnostics: density + j1 (current) binning, {plot_pts} KDE evaluation points\")" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "### Initial Conditions\n", + "\n", + "A sinusoidal **velocity** perturbation $\\delta u_1 = 0.5 \\sin(2\\pi x / L)$ with no density perturbation. The large amplitude (0.5) is chosen so the decay signal is clear over the short simulation window." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "background = equils.ConstantVelocity(ux=0.0)\n", + "model.euler_fluid.var.add_background(background)\n", + "\n", + "perturbation = perturbations.ModesSin(ls=(1,), amps=(0.5,))\n", + "model.euler_fluid.var.add_perturbation(del_u1=perturbation)\n", + "\n", + "print(\"Background: uniform density n=1, zero mean velocity\")\n", + "print(\"Perturbation: delta_u1 = 0.5 * sin(2*pi*x/L) [mode l=1]\")" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "### Simulation Setup and Execution" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "test_folder = os.path.join(os.getcwd(), \"struphy_verification_tests\")\n", + "out_folders = os.path.join(test_folder, \"ViscousEulerSPH\")\n", + "env = EnvironmentOptions(out_folders=out_folders, sim_folder=\"velocity_diffusion\")\n", + "\n", + "time_opts = Time(dt=dt, Tend=Tend, split_algo=\"Strang\")\n", + "\n", + "sim = Simulation(\n", + " model=model,\n", + " env=env,\n", + " time_opts=time_opts,\n", + " domain=domain,\n", + " grid=grid,\n", + " derham_opts=derham_opts,\n", + ")\n", + "\n", + "print(f\"Running velocity diffusion: dt={dt}, Tend={Tend}, {ppb * nx} particles\")\n", + "sim.run()\n", + "print(\"Simulation complete.\")\n", + "\n", + "sim.pproc()\n", + "print(\"Post-processing complete.\")" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "### Load Diagnostics" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "sim.load_plotting_data()\n", + "\n", + "ee1, ee2, ee3 = sim.n_sph.euler_fluid.view_0.grid_n_sph\n", + "n_sph = sim.n_sph.euler_fluid.view_0.n_sph # shape (Nt+1, plot_pts, 1, 1)\n", + "j1_binned = sim.f.euler_fluid.e1_current_1.f_binned # shape (Nt+1, n_bins)\n", + "e1_binned = sim.f.euler_fluid.e1_current_1.grid_e1 # logical x in [0, 1]\n", + "n_binned = sim.f.euler_fluid.e1_density.f_binned # shape (Nt+1, n_bins)\n", + "\n", + "Nt = int(Tend / dt)\n", + "times = np.linspace(0.0, Tend, Nt + 1)\n", + "\n", + "e1_np = np.asarray(e1_binned).flatten()\n", + "\n", + "print(f\"Loaded {Nt + 1} time snapshots\")\n", + "print(f\"j1_binned shape: {np.asarray(j1_binned).shape}\")" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "### Decay Rate Analysis\n", + "\n", + "The velocity antinode of mode $\\ell = 1$ lies at $x = L/4$. We extract the time series $j_1(t)$ at the nearest bin, fit $\\ln|j_1|$ vs. $t$ with a straight line, and recover the numerical decay rate $\\gamma_\\text{numerical}$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "# Antinode at x = L/4 = 0.25\n", + "idx_max = int(np.argmin(np.abs(e1_np - 0.25)))\n", + "amplitude = np.asarray(j1_binned[:, idx_max]).flatten()\n", + "\n", + "# Analytical envelope\n", + "A0 = amplitude[0]\n", + "amplitude_analytical = A0 * np.exp(-gamma_analytical * times)\n", + "\n", + "# Fit log(|amplitude|) vs time — pure diffusion, no oscillations\n", + "log_amp = np.log(np.abs(amplitude) + 1e-15)\n", + "coeffs = np.polyfit(times, log_amp, 1)\n", + "gamma_numerical = -coeffs[0]\n", + "\n", + "rel_error = abs(gamma_numerical - gamma_analytical) / gamma_analytical\n", + "\n", + "print(f\"Analytical decay rate: gamma = (4/3)*mu*k^2 = {gamma_analytical:.4f}\")\n", + "print(f\"Numerical decay rate: gamma = {gamma_numerical:.4f}\")\n", + "print(f\"Relative error: {rel_error * 100:.2f}%\")" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "### Visualisation\n", + "\n", + "**Left panel**: current $j_1 = \\rho u_1 \\approx u_1$ profiles (from the binned diagnostic) at equally spaced times. The sinusoidal shape is preserved while the amplitude decreases uniformly — a hallmark of pure diffusion without mode mixing.\n", + "\n", + "**Right panel**: semi-log plot of $|j_1|$ at the antinode $x = L/4$, comparing the numerical decay against the analytical exponential $e^{-\\gamma t}$ with $\\gamma = (4/3)\\mu k^2$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "x_bin = np.asarray(e1_binned).flatten() * r1 # physical bin positions\n", + "j1_arr = np.asarray(j1_binned) # shape (Nt+1, n_bins)\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "# --- Left: j1 (velocity) profiles at equally spaced times ---\n", + "ax = axes[0]\n", + "n_snaps = 8\n", + "snap_ids = np.round(np.linspace(0, Nt, n_snaps)).astype(int)\n", + "cmap_t = plt.get_cmap(\"viridis\", n_snaps)\n", + "for i, idx in enumerate(snap_ids):\n", + " ax.plot(x_bin, j1_arr[idx, :], color=cmap_t(i), linewidth=2,\n", + " label=f\"t={times[idx]:.3f}\")\n", + "ax.set_xlabel(\"$x$\")\n", + "ax.set_ylabel(r\"$j_1 = \\rho u_1$ (binned)\")\n", + "ax.set_title(\"Velocity profiles $j_1(x)$ over time\")\n", + "ax.set_ylim([-0.6, 0.6])\n", + "ax.legend(fontsize=8)\n", + "ax.grid(True, linestyle=\"--\", alpha=0.5)\n", + "\n", + "# --- Right: amplitude decay (semi-log) ---\n", + "ax = axes[1]\n", + "ax.semilogy(times, np.abs(amplitude), \"b-\", linewidth=1.2,\n", + " label=rf\"Numerical (fitted $\\gamma={gamma_numerical:.3f}$)\")\n", + "ax.semilogy(times, np.abs(amplitude_analytical), \"k--\",\n", + " label=rf\"Analytical: $\\gamma={(4/3)*mu*k**2:.3f}$\")\n", + "ax.set_xlabel(\"time\")\n", + "ax.set_ylabel(rf\"$|j_1|$ at $x={e1_np[idx_max]*r1:.3f}$\")\n", + "ax.set_title(\"Amplitude decay (log scale)\")\n", + "ax.legend()\n", + "ax.grid(True, which=\"both\", linestyle=\"--\", alpha=0.5)\n", + "\n", + "fig.suptitle(\n", + " rf\"Velocity diffusion: $\\mu={mu}$, $k={k:.3f}$, $\\gamma_\\mathrm{{anal}}={gamma_analytical:.3f}$\",\n", + " fontsize=12,\n", + ")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "### Verification Check\n", + "\n", + "Two assertions are evaluated:\n", + "1. The final velocity amplitude is effectively zero (diffusion has erased the mode).\n", + "2. The fitted decay rate agrees with the analytical value to within 4%." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "final_error = float(xp.max(xp.abs(j1_binned[-1])))\n", + "tol_final = 0.0022\n", + "tol_rate = 0.04 # 4% relative error on the decay rate\n", + "\n", + "print(\"=== Velocity Diffusion Verification ===\")\n", + "print(f\" Final velocity amplitude: {final_error:.4e} (tolerance {tol_final:.0e})\")\n", + "print(f\" Decay rate relative error: {rel_error * 100:.2f}% (tolerance {tol_rate*100:.0f}%)\")\n", + "\n", + "try:\n", + " assert final_error < tol_final, (\n", + " f\"Final amplitude {final_error:.4e} exceeds tolerance {tol_final:.0e}\"\n", + " )\n", + " print(\"\\n✓ Final amplitude check passed.\")\n", + "except AssertionError as e:\n", + " print(f\"\\n✗ {e}\")\n", + "\n", + "try:\n", + " assert rel_error < tol_rate, (\n", + " f\"Decay rate {gamma_numerical:.4f} deviates {rel_error*100:.1f}% \"\n", + " f\"from analytical {gamma_analytical:.4f} (tolerance {tol_rate*100:.0f}%)\"\n", + " )\n", + " print(\"✓ Decay rate check passed.\")\n", + "except AssertionError as e:\n", + " print(f\"✗ {e}\")" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "### Conclusion\n", + "\n", + "This tutorial verified the SPH viscous propagator using pure velocity diffusion in 1D:\n", + "\n", + "- With **pressure disabled** (`with_p=False`), the system reduces to the heat equation for momentum, with exact exponential decay $e^{-\\gamma t}$ where $\\gamma = (4/3)\\mu k^2$.\n", + "- The SPH kernel gradient approximation reproduces the diffusion coefficient to within 4%, confirming the correctness of the viscous stress tensor implementation.\n", + "- The high particle count (`ppb=100`) is necessary for accurate kernel gradient estimates in the diffusion regime; fewer particles would overestimate dissipation.\n", + "- This is a stringent test because it targets a single mechanism (viscosity) in isolation, without the competing dynamics of pressure waves." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "# Optional cleanup\n", + "if False: # set to True to remove simulation output\n", + " shutil.rmtree(test_folder)\n", + " print(f\"Cleaned up {test_folder}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env (3.12.3.final.0)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/tutorial_viscous_euler_sph.ipynb b/tutorials/tutorial_viscous_euler_sph.ipynb index 905d025a1..eb12e136e 100644 --- a/tutorials/tutorial_viscous_euler_sph.ipynb +++ b/tutorials/tutorial_viscous_euler_sph.ipynb @@ -464,7 +464,7 @@ "source": [ "---\n", "\n", - "## 2D Damped Sound Wave\n", + "## 1D Damped Sound Wave\n", "\n", "### Physical Setup\n", "\n", From d20bb7a322e852becd0e327805dce81bfc27515b Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Sun, 14 Jun 2026 17:09:01 +0200 Subject: [PATCH 28/34] formatting --- src/struphy/fields_background/equils.py | 1 + .../test_verif_ViscousEulerSPH.py | 56 +++++++++---------- src/struphy/models/variables.py | 2 +- src/struphy/pic/pushing/eval_kernels_gc.py | 1 - src/struphy/pic/pushing/eval_kernels_sph.py | 18 +++--- src/struphy/pic/pushing/pusher_kernels_sph.py | 32 +++++------ src/struphy/pic/tests/test_sph.py | 34 +++++------ .../propagators/push_vin_sph_pressure.py | 8 +-- 8 files changed, 71 insertions(+), 81 deletions(-) diff --git a/src/struphy/fields_background/equils.py b/src/struphy/fields_background/equils.py index 5a60493f4..709a8fb34 100644 --- a/src/struphy/fields_background/equils.py +++ b/src/struphy/fields_background/equils.py @@ -2979,6 +2979,7 @@ def n_xyz(self, x, y, z): return self.params["n"] * xp.exp(-(x**2 + y**2) / self.params["p0"]) elif self.params["density_profile"] == "step_function_xy": from struphy.geometry.domains import Cuboid + assert isinstance(self.domain, Cuboid) l1 = self.domain.params["l1"] r1 = self.domain.params["r1"] diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index 523209da6..e9fda1b24 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -25,8 +25,8 @@ perturbations, set_logging_level, ) -from struphy.models import ViscousEulerSPH from struphy.initial.base import GenericPerturbation +from struphy.models import ViscousEulerSPH logger = logging.getLogger("struphy") set_logging_level(logging.INFO) @@ -224,9 +224,7 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): butcher = ButcherTableau(algo="forward_euler") model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options(kernel_type="gaussian_1d") - model.propagators.push_viscous.options = model.propagators.push_viscous.Options( - kernel_type="gaussian_1d", mu=mu - ) + model.propagators.push_viscous.options = model.propagators.push_viscous.Options(kernel_type="gaussian_1d", mu=mu) # background and initial conditions: velocity perturbation excites the sound wave background = equils.ConstantVelocity() @@ -278,7 +276,7 @@ def test_damped_sound_wave(nx: int, plot_pts: int, do_plot: bool = False): # analytical decay rate: gamma = -mu*k^2/2 for the acoustic mode k = 2.0 * np.pi / r1 - gamma_analytical = -mu * 4/3 * k**2 / 2 + gamma_analytical = -mu * 4 / 3 * k**2 / 2 A0 = amplitude[0] amplitude_analytical = A0 * np.exp(gamma_analytical * times) @@ -426,15 +424,14 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): butcher = ButcherTableau(algo="forward_euler") model.propagators.push_eta.options = model.propagators.push_eta.Options(butcher=butcher) - + mu = 1.0 model.propagators.push_viscous.options = model.propagators.push_viscous.Options(kernel_type="gaussian_1d", mu=mu) - + if model.with_B0: model.propagators.push_vxb.options = model.propagators.push_vxb.Options() if model.with_p: model.propagators.push_sph_p.options = model.propagators.push_sph_p.Options(kernel_type="gaussian_1d") - # background, perturbations and initial conditions ux_mean = 0.0 @@ -488,7 +485,7 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): # analytical decay rate: gamma = mu * k^2, k = 2*pi/r1 for mode l=1 k = 2.0 * np.pi / r1 - gamma_analytical = mu * 4/3 * k**2 + gamma_analytical = mu * 4 / 3 * k**2 A0 = amplitude[0] amplitude_analytical = A0 * np.exp(-gamma_analytical * times) @@ -551,10 +548,15 @@ def test_velocity_diffusion(nx: int, plot_pts: int, do_plot: bool = False): plt.show() fig, ax = plt.subplots(figsize=(8, 5)) - ax.semilogy(times, np.abs(amplitude), "o-", markersize=3, - label=f"Numerical (fitted rate = {gamma_numerical:.3f})") - ax.semilogy(times, np.abs(amplitude_analytical), "--", - label=rf"Analytical: $\gamma = (4/3) \mu k^2 = {gamma_analytical:.3f}$") + ax.semilogy( + times, np.abs(amplitude), "o-", markersize=3, label=f"Numerical (fitted rate = {gamma_numerical:.3f})" + ) + ax.semilogy( + times, + np.abs(amplitude_analytical), + "--", + label=rf"Analytical: $\gamma = (4/3) \mu k^2 = {gamma_analytical:.3f}$", + ) ax.set_xlabel("time") ax.set_ylabel(rf"velocity amplitude at $x = {e1_np[idx_max]:.3f}$") ax.set_title("Velocity diffusion: amplitude decay over time") @@ -595,9 +597,9 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False, create_ env = EnvironmentOptions(out_folders=out_folders, sim_folder="hagen_poiseuille") # physical parameters - mu = 0.1 # dynamic viscosity + mu = 0.1 # dynamic viscosity g_x = 0.1 # body force in x (acts as driving pressure gradient) - H = 1.0 # channel height in y + H = 1.0 # channel height in y # time stepping: T_relax = H^2 / (pi^2 * mu) ~ 1.0, run 10x past relaxation time_opts = Time(dt=0.01, Tend=10.0, split_algo="Strang") @@ -732,8 +734,7 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False, create_ # time evolution of centreline velocity ax = axes[2] ax.plot(times, u_centre, label=r"Numerical $u_x(y=H/2)$") - ax.axhline(u_centre_exact, color="k", linestyle="--", - label=rf"Exact $U_{{max}} = {u_centre_exact:.4f}$") + ax.axhline(u_centre_exact, color="k", linestyle="--", label=rf"Exact $U_{{max}} = {u_centre_exact:.4f}$") ax.set_xlabel("time") ax.set_ylabel(r"$u_x(y=H/2)$") ax.set_title("Centreline velocity relaxation to steady state") @@ -850,10 +851,10 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo # With g=10, H=0.5: U_max ≈ 3.2. kappa=50 → c_s≈7, Ma≈0.45 (subsonic, liquid-like). # Raising gravity would increase Ma and make things more gas-like — wrong direction. kappa = 0.2 # isothermal coefficient (= c_s^2); controls fluid stiffness - mu = 0.05 # dynamic viscosity - g_y = 10.0 # gravitational acceleration (downward, i.e. −y) - r1 = 1.0 # domain width (x-direction) - r2 = 1.0 # domain height (y-direction) + mu = 0.05 # dynamic viscosity + g_y = 10.0 # gravitational acceleration (downward, i.e. −y) + r1 = 1.0 # domain width (x-direction) + r2 = 1.0 # domain height (y-direction) n_high = 0.1 # density of the fluid column (uniform → no initial pressure gradient) # free-fall time sqrt(2*H/g) ≈ 0.32 s; acoustic CFL: h/c_s = (1/nx)/sqrt(kappa) ≈ 0.018 @@ -915,7 +916,7 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo density_profile="step_function_xy", n=n_high, upper_x=r1 / 4, - upper_y=r2, + upper_y=r2, ) model.euler_fluid.var.add_background(background) @@ -945,7 +946,7 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo X = np.asarray(ee1)[:, :, 0] * r1 # physical x, shape (pts_e1, pts_e2) Y = np.asarray(ee2)[:, :, 0] * r2 # physical y, shape (pts_e1, pts_e2) - n_arr = np.asarray(n_sph) # (Nt+1, pts_e1, pts_e2, 1) + n_arr = np.asarray(n_sph) # (Nt+1, pts_e1, pts_e2, 1) # orbits needed for both do_plot scatter overlay and create_png orbits = np.asarray(sim.orbits.euler_fluid) # (Nt_orb, n_markers, n_attrs) @@ -1026,12 +1027,8 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo # sanity: no markers should escape the closed box (allow 1% tolerance) x_all = orbits[:, :, 0] y_all = orbits[:, :, 1] - assert np.all(x_all >= -0.01 * r1) and np.all(x_all <= 1.01 * r1), ( - "Markers escaped x-domain in dam break test" - ) - assert np.all(y_all >= -0.01 * r2) and np.all(y_all <= 1.01 * r2), ( - "Markers escaped y-domain in dam break test" - ) + assert np.all(x_all >= -0.01 * r1) and np.all(x_all <= 1.01 * r1), "Markers escaped x-domain in dam break test" + assert np.all(y_all >= -0.01 * r2) and np.all(y_all <= 1.01 * r2), "Markers escaped y-domain in dam break test" logger.info("Dam break domain bounds assertion passed.") @@ -1041,4 +1038,3 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo # test_damped_sound_wave(nx=8, plot_pts=21, do_plot=True) # test_hagen_poiseuille(nx=8, plot_pts=21, do_plot=True, create_png=True) # test_dam_break(nx=8, plot_pts=21, do_plot=True, create_png=True) - diff --git a/src/struphy/models/variables.py b/src/struphy/models/variables.py index 09cdff62f..65d173244 100644 --- a/src/struphy/models/variables.py +++ b/src/struphy/models/variables.py @@ -806,7 +806,7 @@ def allocate( sort = False self.particles.draw_markers(sort=sort) self.particles.initialize_weights() - + self.particles.put_particles_in_boxes() # allocate array for saving markers if not present diff --git a/src/struphy/pic/pushing/eval_kernels_gc.py b/src/struphy/pic/pushing/eval_kernels_gc.py index 5e8da993b..f9e2247e0 100644 --- a/src/struphy/pic/pushing/eval_kernels_gc.py +++ b/src/struphy/pic/pushing/eval_kernels_gc.py @@ -467,4 +467,3 @@ def unit_b_1form( # save for j in range(n_comps): markers[ip, column_nr + j] = unit_b1[comps[j]] - diff --git a/src/struphy/pic/pushing/eval_kernels_sph.py b/src/struphy/pic/pushing/eval_kernels_sph.py index 1334e2ba4..ac2057117 100644 --- a/src/struphy/pic/pushing/eval_kernels_sph.py +++ b/src/struphy/pic/pushing/eval_kernels_sph.py @@ -6,8 +6,7 @@ import struphy.bsplines.bsplines_kernels as bsplines_kernels import struphy.bsplines.evaluation_kernels_3d as evaluation_kernels_3d import struphy.geometry.evaluation_kernels as evaluation_kernels - -import struphy.kernel_arguments.pusher_args_kernels as pusher_args_kernels # do not remove; needed to identify dependencies +import struphy.kernel_arguments.pusher_args_kernels as pusher_args_kernels # do not remove; needed to identify dependencies import struphy.linear_algebra.linalg_kernels as linalg_kernels import struphy.pic.sph_eval_kernels as sph_eval_kernels from struphy.kernel_arguments.pusher_args_kernels import DerhamArguments, DomainArguments, MarkerArguments @@ -32,11 +31,11 @@ def sph_pressure_coeffs( h3: "float", ): r"""For each particle, evaluate - + * the density :math:`\rho^{N,h}(\boldsymbol \eta_i)` abd stored it at ``markers[:, column_nr]``) * the coefficient :math:`w_i/\rho^{N,h}(\boldsymbol \eta_i)` and stored it at ``markers[:, column_nr + 1]``) * the coefficient :math:`w_i (\rho^{N,h}(\boldsymbol \eta_i))^{\gamma - 2}` and stored it at ``markers[:, column_nr + 2]``) - + where the smoothed SPH density is given by .. math:: @@ -95,8 +94,7 @@ def sph_isotherm_kappa( comps: "int[:]", args_markers: "MarkerArguments", ): - r"""None yet. - """ + r"""None yet.""" # get marker arguments markers = args_markers.markers @@ -130,8 +128,8 @@ def sph_mean_velocity_coeffs( h3: "float", ): r"""For each particle, evaluate the smoothed SPH density :math:`\rho^{N,h}(\boldsymbol \eta_i)` and store the - coefficient - + coefficient + * :math:`w_i v_{k,i} / \rho^{N,h}(\boldsymbol \eta_i)` at ``markers[:, column_nr + k]`` for :math:`k = 0, 1, 2` where the smoothed SPH density is given by @@ -456,8 +454,8 @@ def sph_viscosity_tensor( (-\nabla \cdot \Pi_{\textrm{vis}})^{N,h}_j(\boldsymbol \eta_i) = \sum_l \frac{ w_l \, \sigma_{jk}(\boldsymbol \eta_l)}{\rho^{N,h}(\boldsymbol \eta_l)} \, (\nabla W_h)_k(\boldsymbol \eta_i - \boldsymbol \eta_l)\,. - - This kernel requires the coefficients of the mean velocity :math:`v_k^{N,h}` + + This kernel requires the coefficients of the mean velocity :math:`v_k^{N,h}` for each particle to be pre-evaluated and stored at ``markers[:, first_free_idx:first_free_idx + 3]``, which can be achieved by the kernel :func:`~struphy.pic.pushing.eval_kernels_sph.sph_mean_velocity_coeffs`. """ diff --git a/src/struphy/pic/pushing/pusher_kernels_sph.py b/src/struphy/pic/pushing/pusher_kernels_sph.py index 92a279dd7..3846d8307 100644 --- a/src/struphy/pic/pushing/pusher_kernels_sph.py +++ b/src/struphy/pic/pushing/pusher_kernels_sph.py @@ -50,20 +50,20 @@ def push_v_sph_pressure( where :math:`\mathbf g` is a constant acceleration, the second term corresponds to the pressure gradient in the isothermal closure (with constant :math:`\kappa`), and :math:`DF^{-\top}` denotes the inverse transpose Jacobian - arising in the pull back of the gradient of the smoothing kernel :math:`W_h` - chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. - + arising in the pull back of the gradient of the smoothing kernel :math:`W_h` + chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. + The smoothed SPH density is given by .. math:: \rho^{N,h}(\boldsymbol \eta_p) = \sum_j w_j \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_j)\,. - + This kernel requires: - + * The density :math:`\rho^{N,h}(\boldsymbol \eta_p)` to be pre-computed for each particle and stored at ``markers[:, first_free_idx]``) * The coefficient :math:`w_i/\rho^{N,h}(\boldsymbol \eta_i)` to be pre-computed for each particle and stored at ``markers[:, first_free_idx + 1]``) - + This is accomplished by the kernel :func:`~struphy.pic.pushing.eval_kernels_sph.sph_pressure_coeffs`, which needs to be passed as an ``init_kernel`` to the :class:`~struphy.pic.pushing.pusher.Pusher`. @@ -89,7 +89,7 @@ def push_v_sph_pressure( gravity: xp.ndarray Constant gravitational force as 3-vector. - + kappa: float Constant isothermal coefficient. """ @@ -160,7 +160,7 @@ def push_v_sph_pressure( h3, ) sum2 *= kappa - + grad_u[0] += sum2 if kernel_type >= 340: @@ -294,22 +294,22 @@ def push_v_sph_pressure_ideal_gas( \frac{\mathbf v_p^{n+1} - \mathbf v_p^n}{\Delta t} = \mathbf g - \sum_{i=1}^N w_i \left( \kappa (\rho^{N,h}(\boldsymbol \eta_p))^{\gamma - 2} + \kappa (\rho^{N,h}(\boldsymbol \eta_i))^{\gamma - 2} \right) DF^{-\top}\nabla W_h(\boldsymbol \eta_p - \boldsymbol \eta_i) \,, where :math:`\mathbf g` is a constant acceleration, the second term corresponds to the pressure gradient - in the polytropic closure (with constant :math:`\kappa` and :math:`\gamma = 5/3`), + in the polytropic closure (with constant :math:`\kappa` and :math:`\gamma = 5/3`), and :math:`DF^{-\top}` denotes the inverse transpose Jacobian - arising in the pull back of the gradient of the smoothing kernel :math:`W_h` - chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. - + arising in the pull back of the gradient of the smoothing kernel :math:`W_h` + chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. + The smoothed SPH density is given by .. math:: \rho^{N,h}(\boldsymbol \eta_p) = \sum_j w_j \, W_h(\boldsymbol \eta_p - \boldsymbol \eta_j)\,. - + This kernel requires: - + * The density :math:`\rho^{N,h}(\boldsymbol \eta_p)` to be pre-computed for each particle and stored at ``markers[:, first_free_idx]``) * The coefficient :math:`w_i (\rho^{N,h}(\boldsymbol \eta_i))^{\gamma - 2}` to be pre-computed for each particle and stored at ``markers[:, first_free_idx + 2]``) - + This is accomplished by the kernel :func:`~struphy.pic.pushing.eval_kernels_sph.sph_pressure_coeffs`, which needs to be passed as an ``init_kernel`` to the :class:`~struphy.pic.pushing.pusher.Pusher`. @@ -335,7 +335,7 @@ def push_v_sph_pressure_ideal_gas( gravity: xp.ndarray Constant gravitational force as 3-vector. - + kappa: float Polytropic coefficient in the ideal gas closure. """ diff --git a/src/struphy/pic/tests/test_sph.py b/src/struphy/pic/tests/test_sph.py index 8d4abbdec..d536004b7 100644 --- a/src/struphy/pic/tests/test_sph.py +++ b/src/struphy/pic/tests/test_sph.py @@ -2051,7 +2051,14 @@ def u_xyz(x, y, z): h3 = 1.0 / boxes_per_dim[2] v1, v2, v3 = particles.eval_velocity( - ee1, ee2, ee3, h1=h1, h2=h2, h3=h3, kernel_type=kernel, derivative=0, + ee1, + ee2, + ee3, + h1=h1, + h2=h2, + h3=h3, + kernel_type=kernel, + derivative=0, ) if comm is not None: @@ -2063,18 +2070,13 @@ def u_xyz(x, y, z): # Average over x (uniform in x by periodicity) v1_avg = xp.mean(all_v1[:, :, 0], axis=0) # shape (n_y,) - v_wall_bottom = float(v1_avg[0]) # y=0 (eta_2=0) - v_wall_top = float(v1_avg[-1]) # y=1 (eta_2=1) + v_wall_bottom = float(v1_avg[0]) # y=0 (eta_2=0) + v_wall_top = float(v1_avg[-1]) # y=1 (eta_2=1) v_interior = v1_avg[5:-5] if rank == 0: - logger.info( - f"\n2D no-slip (boxes={boxes_per_dim}): " - f"v at y=0: {v_wall_bottom:.4f}, y=1: {v_wall_top:.4f}" - ) - logger.info( - f"Interior v range: [{float(xp.min(v_interior)):.4f}, {float(xp.max(v_interior)):.4f}]" - ) + logger.info(f"\n2D no-slip (boxes={boxes_per_dim}): v at y=0: {v_wall_bottom:.4f}, y=1: {v_wall_top:.4f}") + logger.info(f"Interior v range: [{float(xp.min(v_interior)):.4f}, {float(xp.max(v_interior)):.4f}]") if show_plot and rank == 0: eta2_plot = xp.linspace(0.0, 1.0, n_y) @@ -2093,16 +2095,10 @@ def u_xyz(x, y, z): tol_interior = 1e-1 if rank == 0: - assert abs(v_wall_bottom) < tol_wall, ( - f"Bottom wall (y=0) velocity not zero: {v_wall_bottom:.4f}" - ) - assert abs(v_wall_top) < tol_wall, ( - f"Top wall (y=1) velocity not zero: {v_wall_top:.4f}" - ) + assert abs(v_wall_bottom) < tol_wall, f"Bottom wall (y=0) velocity not zero: {v_wall_bottom:.4f}" + assert abs(v_wall_top) < tol_wall, f"Top wall (y=1) velocity not zero: {v_wall_top:.4f}" rel_error = float(xp.max(xp.abs(v_interior - 1.0))) - assert rel_error < tol_interior, ( - f"Interior x-velocity error too large: {rel_error:.4f}" - ) + assert rel_error < tol_interior, f"Interior x-velocity error too large: {rel_error:.4f}" if __name__ == "__main__": diff --git a/src/struphy/propagators/push_vin_sph_pressure.py b/src/struphy/propagators/push_vin_sph_pressure.py index 0f44bb8b0..aee1a6c8f 100644 --- a/src/struphy/propagators/push_vin_sph_pressure.py +++ b/src/struphy/propagators/push_vin_sph_pressure.py @@ -28,10 +28,10 @@ class PushVinSPHpressure(Propagator): where :math:`\mathbf g` is a constant acceleration and the second term corresponds to the pressure gradient. Here, :math:`\mathcal U(\rho)` denotes the internal energy per unit mass as a function of the mass density :math:`\rho` and :math:`DF^{-\top}` denotes the inverse transpose Jacobian - arising in the pull back of the gradient of the smoothing kernel :math:`W_h` - chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. + arising in the pull back of the gradient of the smoothing kernel :math:`W_h` + chosen from :mod:`~struphy.pic.sph_smoothing_kernels`. Two choices of the internal energy are implemented: - + * Isothermal closure: :math:`\mathcal U(\rho) = \kappa \, \ln(\rho)`, where :math:`\kappa` is constant. * Polytropic closure: :math:`\mathcal U(\rho) = \kappa \, \rho^{\gamma - 1} / (\gamma - 1)`, where :math:`\kappa` is the polytropic constant and :math:`\gamma = C_p / C_v` is the polytropic index. @@ -81,7 +81,7 @@ class Options(OptionsBase): gravity : tuple, default=(0.0, 0.0, 0.0) Constant gravity vector added in the SPH pressure push. - + kappa : float, default=1.0 Coefficient in the internal energy function. From fa736dd8216ef025813008962ce078008fb8da5a Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Mon, 15 Jun 2026 08:22:58 +0200 Subject: [PATCH 29/34] improve plotting of dam break --- .../test_verif_ViscousEulerSPH.py | 30 ++++++++++++------- src/struphy/models/variables.py | 3 +- src/struphy/pic/base.py | 1 + tutorials/tutorial_viscous_euler_sph.ipynb | 1 - 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index e9fda1b24..15ff20973 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -817,8 +817,8 @@ def test_hagen_poiseuille(nx: int, plot_pts: int, do_plot: bool = False, create_ plt.tight_layout() plt.show() - assert np.max(rel_error_interior) < 0.05, ( - f"Hagen-Poiseuille mean relative error {np.mean(rel_error_interior) * 100:.1f}% exceeds tolerance 15%" + assert np.mean(rel_error_interior) < 0.05, ( + f"Hagen-Poiseuille mean relative error {np.mean(rel_error_interior) * 100:.1f}% exceeds tolerance 5%" ) logger.info("Hagen-Poiseuille profile assertion passed.") @@ -968,7 +968,7 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo im = None for ax, idx, oidx in zip(axes.flatten(), snapshot_inds, orb_inds): n_2d = n_arr[idx, :, :, 0] - im = ax.pcolormesh(X, Y, n_2d, vmin=0.0, vmax=vmax_plot, cmap="Blues", shading="auto") + im = ax.pcolormesh(X, Y, n_2d, vmin=0.0, vmax=vmax_plot/2, cmap="Blues", shading="auto") ax.scatter( orbits[oidx, :, 0], orbits[oidx, :, 1], @@ -997,31 +997,39 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo if create_png: from tqdm import tqdm as _tqdm - n_snaps = 250 + n_snaps = 300 snap_inds = np.round(np.linspace(0, Nt_orb - 1, n_snaps)).astype(int) + n_snap_inds = np.round(np.linspace(0, Nt, n_snaps)).astype(int) + + vmax_plot = float(np.max(n_arr)) png_dir = os.path.join(out_folders, "dam_break_pngs") os.makedirs(png_dir, exist_ok=True) - for i, idx in _tqdm(enumerate(snap_inds), total=n_snaps, desc="saving PNGs"): + for i, (idx, n_idx) in _tqdm( + enumerate(zip(snap_inds, n_snap_inds)), total=n_snaps, desc="saving PNGs" + ): fig_png, ax_png = plt.subplots(figsize=(10, 5)) + n_2d = n_arr[n_idx, :, :, 0] + im = ax_png.pcolormesh(X, Y, n_2d, vmin=0.0, vmax=vmax_plot/2, cmap="Blues", shading="auto") ax_png.scatter( orbits[idx, :, 0], orbits[idx, :, 1], c=c_val, cmap="autumn", - s=4, + s=1, vmin=0.0, vmax=1.0, + alpha=0.6, ) ax_png.set_xlim(0.0, r1) ax_png.set_ylim(0.0, r2) ax_png.set_xlabel("x") ax_png.set_ylabel("y") - ax_png.set_title(rf"Dam break, $t = {t_orbit[idx]:.3f}$") + ax_png.set_title(rf"Dam break (compressible), $t = {t_orbit[idx]:.3f}$") ax_png.set_aspect("equal") - plt.tight_layout() - fig_png.savefig(os.path.join(png_dir, f"snap_{i:04d}.png"), dpi=80) + plt.colorbar(im, ax=ax_png, label=r"$\rho$") + fig_png.savefig(os.path.join(png_dir, f"snap_{i:04d}.png"), dpi=80, bbox_inches="tight", pad_inches=0.02) plt.close(fig_png) # sanity: no markers should escape the closed box (allow 1% tolerance) @@ -1034,7 +1042,7 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo if __name__ == "__main__": # test_soundwave_1d(nx=12, plot_pts=11, do_plot=True) - test_velocity_diffusion(nx=8, plot_pts=11, do_plot=True) + # test_velocity_diffusion(nx=8, plot_pts=11, do_plot=True) # test_damped_sound_wave(nx=8, plot_pts=21, do_plot=True) # test_hagen_poiseuille(nx=8, plot_pts=21, do_plot=True, create_png=True) - # test_dam_break(nx=8, plot_pts=21, do_plot=True, create_png=True) + test_dam_break(nx=8, plot_pts=21, do_plot=True, create_png=True) diff --git a/src/struphy/models/variables.py b/src/struphy/models/variables.py index 65d173244..204825456 100644 --- a/src/struphy/models/variables.py +++ b/src/struphy/models/variables.py @@ -807,7 +807,8 @@ def allocate( self.particles.draw_markers(sort=sort) self.particles.initialize_weights() - self.particles.put_particles_in_boxes() + # if self.particles.sorting_boxes.communicate: + # self.particles.put_particles_in_boxes() # allocate array for saving markers if not present n_markers = self.species.saving_params.n_markers diff --git a/src/struphy/pic/base.py b/src/struphy/pic/base.py index 63882422a..32febb9de 100644 --- a/src/struphy/pic/base.py +++ b/src/struphy/pic/base.py @@ -772,6 +772,7 @@ def update_valid_mks(self): @property def n_mks_loc(self): """Number of valid markers on process (without holes and ghosts).""" + # print(f"{self.kinds} on clone {self.clone_id}: counting valid markers: {xp.count_nonzero(self.valid_mks)} valid markers on process {self.mpi_rank} found.") return xp.count_nonzero(self.valid_mks) @property diff --git a/tutorials/tutorial_viscous_euler_sph.ipynb b/tutorials/tutorial_viscous_euler_sph.ipynb index eb12e136e..6d070a35b 100644 --- a/tutorials/tutorial_viscous_euler_sph.ipynb +++ b/tutorials/tutorial_viscous_euler_sph.ipynb @@ -35,7 +35,6 @@ "import os\n", "import shutil\n", "\n", - "import numpy as np\n", "import matplotlib.pyplot as plt\n", "from matplotlib.ticker import FormatStrFormatter\n", "import cunumpy as xp\n", From f3f0d551687f74f33bcdb28078c5f411f83a34a9 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Mon, 15 Jun 2026 08:23:35 +0200 Subject: [PATCH 30/34] update dependency bounds --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 843b8e6bf..121367209 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ "trame-vuetify<=3.2.2", "nest_asyncio2<=1.7.2", "vtk<=9.6.1", - "pytest<=9.0.3", + "pytest<=9.1.0", "pytest-mpi<=0.6", "pytest-testmon<=2.2.0", "ruff>=0.15.0, <=0.15.12", From b8658b0ec38b57dd7c117e256f8c732af0e19017 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Mon, 15 Jun 2026 08:24:42 +0200 Subject: [PATCH 31/34] formatting --- .../tests/verification/test_verif_ViscousEulerSPH.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py index 15ff20973..2d04113bc 100644 --- a/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py +++ b/src/struphy/models/tests/verification/test_verif_ViscousEulerSPH.py @@ -968,7 +968,7 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo im = None for ax, idx, oidx in zip(axes.flatten(), snapshot_inds, orb_inds): n_2d = n_arr[idx, :, :, 0] - im = ax.pcolormesh(X, Y, n_2d, vmin=0.0, vmax=vmax_plot/2, cmap="Blues", shading="auto") + im = ax.pcolormesh(X, Y, n_2d, vmin=0.0, vmax=vmax_plot / 2, cmap="Blues", shading="auto") ax.scatter( orbits[oidx, :, 0], orbits[oidx, :, 1], @@ -1006,12 +1006,10 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo png_dir = os.path.join(out_folders, "dam_break_pngs") os.makedirs(png_dir, exist_ok=True) - for i, (idx, n_idx) in _tqdm( - enumerate(zip(snap_inds, n_snap_inds)), total=n_snaps, desc="saving PNGs" - ): + for i, (idx, n_idx) in _tqdm(enumerate(zip(snap_inds, n_snap_inds)), total=n_snaps, desc="saving PNGs"): fig_png, ax_png = plt.subplots(figsize=(10, 5)) n_2d = n_arr[n_idx, :, :, 0] - im = ax_png.pcolormesh(X, Y, n_2d, vmin=0.0, vmax=vmax_plot/2, cmap="Blues", shading="auto") + im = ax_png.pcolormesh(X, Y, n_2d, vmin=0.0, vmax=vmax_plot / 2, cmap="Blues", shading="auto") ax_png.scatter( orbits[idx, :, 0], orbits[idx, :, 1], @@ -1029,7 +1027,9 @@ def test_dam_break(nx: int, plot_pts: int, do_plot: bool = False, create_png: bo ax_png.set_title(rf"Dam break (compressible), $t = {t_orbit[idx]:.3f}$") ax_png.set_aspect("equal") plt.colorbar(im, ax=ax_png, label=r"$\rho$") - fig_png.savefig(os.path.join(png_dir, f"snap_{i:04d}.png"), dpi=80, bbox_inches="tight", pad_inches=0.02) + fig_png.savefig( + os.path.join(png_dir, f"snap_{i:04d}.png"), dpi=80, bbox_inches="tight", pad_inches=0.02 + ) plt.close(fig_png) # sanity: no markers should escape the closed box (allow 1% tolerance) From 3925e1307db19f49064c3c369d4075fbca9c31b1 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Mon, 15 Jun 2026 08:42:18 +0200 Subject: [PATCH 32/34] add new tutorials to doc --- doc/sections/tutorials.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/sections/tutorials.rst b/doc/sections/tutorials.rst index 769b1cfe5..94bd2eb46 100644 --- a/doc/sections/tutorials.rst +++ b/doc/sections/tutorials.rst @@ -30,9 +30,12 @@ It is recommended to use the same Python environment as for Struphy, e.g., by in :maxdepth: 1 :caption: SPH models: - ../_collections/tutorials/tutorial_viscous_euler_sph ../_collections/tutorials/tutorial_beltrami_sph ../_collections/tutorials/tutorial_gas_expansion_sph + ../_collections/tutorials/tutorial_viscous_euler_sph + ../_collections/tutorials/tutorial_velocity_diffsusion_sph + ../_collections/tutorials/tutorial_hagen_poiseuille_sph + ../_collections/tutorials/tutorial_dam_break_sph .. toctree:: From b6b75a66cb9fa1583f9142e02bd54c3636dd58df Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Mon, 15 Jun 2026 10:28:17 +0200 Subject: [PATCH 33/34] comment Pytest-MPI plugin check step (hangs with new pytest 9.1) --- .github/workflows/test-PR-pure-python.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-PR-pure-python.yml b/.github/workflows/test-PR-pure-python.yml index 2d77ce067..85f52858f 100644 --- a/.github/workflows/test-PR-pure-python.yml +++ b/.github/workflows/test-PR-pure-python.yml @@ -97,11 +97,11 @@ jobs: mpirun --version mpirun --oversubscribe -n 1 python -c "from mpi4py import MPI; print('rank:', MPI.COMM_WORLD.rank); print('size:', MPI.COMM_WORLD.size)" - - name: Pytest-MPI plugin check - shell: bash - run: | - source env/bin/activate - pytest --trace-config | grep mpi || true + # - name: Pytest-MPI plugin check + # shell: bash + # run: | + # source env/bin/activate + # pytest --trace-config | grep mpi || true - name: Pytest with collect-only shell: bash From 5add884762d0bc082e26d3be4ac51c18f56936d7 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Tue, 16 Jun 2026 09:13:45 +0200 Subject: [PATCH 34/34] compile everything in CI --- .github/actions/compile/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/compile/action.yml b/.github/actions/compile/action.yml index 8b7e237b7..2989239d7 100644 --- a/.github/actions/compile/action.yml +++ b/.github/actions/compile/action.yml @@ -24,4 +24,5 @@ runs: echo "No env specified, installing outside of any env" fi pyccel --version + struphy compile -d struphy compile -y --language ${{ inputs.language }}