Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
214e94a
feat: added notebook + slides
pepe5p May 26, 2025
3629e44
feat: updated setup.py
pepe5p May 26, 2025
fdfeed0
feat: moved to correct catalog
pepe5p May 26, 2025
d35912e
feat: initial sandbox
pepe5p May 26, 2025
0a42e50
chore: ugly test draft
pepe5p May 26, 2025
8f1e61b
refactor: better draft
pepe5p May 26, 2025
25369f3
refactor: moved logic to functions outside notebook
pepe5p Jun 2, 2025
de83460
fix: set time to 1 in pympdata solution, correct mu_x and mu_y calcul…
kacpermajchrzak Jun 9, 2025
09d5201
fix: tests fix + run linters
pepe5p Jun 9, 2025
2f214ca
chore: devops_tests update
pepe5p Jun 9, 2025
0d0104a
chore: removed unnecessary files
pepe5p Jun 9, 2025
b892024
refactor: improved solution and run linters
pepe5p Jun 9, 2025
a4beaf9
feat: improved tests
Eniterusx Jun 9, 2025
7db3611
fix: renamed folder
Eniterusx Jun 9, 2025
719120b
refactor: format code
NorbertKlockiewicz Jun 10, 2025
bc658a4
Update tests/smoke_tests/comparison_against_pypde_2025/test_compariso…
NorbertKlockiewicz Jun 18, 2025
ffcc383
addressing comments
pepe5p Jun 20, 2025
5daa2f0
addressing comments
pepe5p Jun 20, 2025
d57175c
fixed pdoc test not passing
Eniterusx Jun 21, 2025
1819370
pre-commit test fix
Eniterusx Jun 21, 2025
0b383b2
(hopefully) fixed most of the devops tests
Eniterusx Jun 21, 2025
f59080e
Merge branch 'main' into comparison-with-pypde
Eniterusx Jun 21, 2025
74166c5
Fixed py-pde version conflict
Eniterusx Jun 21, 2025
cf58aec
fix type problem
kacpermajchrzak Jun 27, 2025
29f6ace
fix pylint
kacpermajchrzak Jun 27, 2025
4a3f476
fix pylint2
kacpermajchrzak Jun 27, 2025
8d9def1
fix pylint3
kacpermajchrzak Jun 27, 2025
74dbecd
use Python 3.13 in pylint job; drop pylint-disable for pde imports; r…
slayoo Jun 27, 2025
ac77645
bump Python version for pdoc job
slayoo Jun 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Comment thread
pepe5p marked this conversation as resolved.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""
Test the similarity of solutions from MPDATA and PyPDE for 2D diffusion
"""

from dataclasses import dataclass

import numpy as np
import numpy.typing as npt
from pde import CartesianGrid, DiffusionPDE
from pde import ScalarField as PDEScalarField

from PyMPDATA import Options, ScalarField, Solver, Stepper, VectorField
from PyMPDATA.boundary_conditions import Periodic


@dataclass
class InitialConditions:
"""
Initial conditions for the 2D diffusion problem.
"""

def __init__(
self,
*,
diffusion_coefficient: float,
time_step: float,
time_end: float,
grid_shape: tuple[int, int],
grid_range_x: tuple[float, float],
grid_range_y: tuple[float, float],
pulse_position: tuple[float, float],
) -> None:
self.diffusion_coefficient = diffusion_coefficient
self.time_step = time_step
self.time_end = time_end
self.grid_shape = grid_shape
self.grid_range_x = grid_range_x
self.grid_range_y = grid_range_y
self.pulse_position = pulse_position
self.nx, self.ny = grid_shape
self.min_x, self.max_x = grid_range_x
self.min_y, self.max_y = grid_range_y
self.pulse_x, self.pulse_y = pulse_position
Comment thread
pepe5p marked this conversation as resolved.
Outdated

def __repr__(self) -> str:
return (
f"InitialConditions(diffusion_coefficient={self.diffusion_coefficient}, "
f"time_step={self.time_step}, time_end={self.time_end}, "
f"grid_shape={self.grid_shape}, grid_range_x={self.grid_range_x}, "
f"grid_range_y={self.grid_range_y}, pulse_position={self.pulse_position})"
)

@property
def dx(self) -> float:
"""Calculate the grid spacing in the x-direction."""
return (self.max_x - self.min_x) / self.nx

@property
def dy(self) -> float:
"""Calculate the grid spacing in the y-direction."""
return (self.max_y - self.min_y) / self.ny

@property
def n_steps(self) -> int:
"""Calculate the number of time steps based on the time range and time step."""
return int(self.time_end / self.time_step)


type Two2DiffusionSolution = npt.NDArray[np.float64]
Comment thread
pepe5p marked this conversation as resolved.
Outdated
Comment thread
pepe5p marked this conversation as resolved.
Outdated


def py_pde_solution(initial_conditions: InitialConditions) -> Two2DiffusionSolution:
"""
Solve the 2D diffusion equation using PyPDE.
"""
grid = CartesianGrid(
bounds=[
initial_conditions.grid_range_x,
initial_conditions.grid_range_y,
],
shape=initial_conditions.grid_shape,
)
state = PDEScalarField(grid=grid)
state.insert(
point=np.array(initial_conditions.pulse_position),
amount=1,
)
eq = DiffusionPDE(diffusivity=initial_conditions.diffusion_coefficient)
result = eq.solve(
state=state,
t_range=1,
dt=initial_conditions.time_step,
)
return result.data


def mpdata_solution(initial_conditions: InitialConditions) -> Two2DiffusionSolution:
"""
Solve the 2D diffusion equation using PyMPDATA.
"""
opt = Options(
n_iters=2,
non_zero_mu_coeff=True,
)
stepper = Stepper(
options=opt,
n_dims=2,
)

def create_pde_like_data(ic) -> npt.NDArray[np.float64]:
"""
Create a 2D array with a pulse at the specified position.
"""
x = np.linspace(ic.min_x + ic.dx / 2, ic.max_x - ic.dx / 2, ic.nx)
y = np.linspace(ic.min_y + ic.dy / 2, ic.max_y - ic.dy / 2, ic.ny)
result = np.zeros((ic.nx, ic.ny))
# Locate cell nearest (0, 1)
i = np.argmin(np.abs(x - ic.pulse_x))
j = np.argmin(np.abs(y - ic.pulse_y))
# Distribute mass over 2x2 cells (py-pde seems to do this internally)
mass_per_cell = 1.0 / (4 * ic.dx * ic.dy)
Comment thread
pepe5p marked this conversation as resolved.
Outdated
result[i, j] = mass_per_cell
result[i + 1, j] = mass_per_cell
result[i, j + 1] = mass_per_cell
result[i + 1, j + 1] = mass_per_cell
return result

data = create_pde_like_data(ic=initial_conditions)

advectee = ScalarField(
data=data, halo=opt.n_halo, boundary_conditions=(Periodic(), Periodic())
)

cx = np.zeros(
shape=(initial_conditions.nx + 1, initial_conditions.ny),
dtype=opt.dtype,
)
cy = np.zeros(
shape=(initial_conditions.nx, initial_conditions.ny + 1),
dtype=opt.dtype,
)
advector = VectorField(
data=(cx, cy),
halo=opt.n_halo,
boundary_conditions=(Periodic(), Periodic()),
)

solver = Solver(
stepper=stepper,
advector=advector,
advectee=advectee,
)
mu_x = (
initial_conditions.diffusion_coefficient
* initial_conditions.time_step
/ initial_conditions.dx**2
)
mu_y = (
initial_conditions.diffusion_coefficient
* initial_conditions.time_step
/ initial_conditions.dy**2
)
solver.advance(
n_steps=initial_conditions.n_steps,
mu_coeff=(mu_x, mu_y),
)
return solver.advectee.get()
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def get_long_description():
"joblib" + ("==1.4.0" if CI else ""),
"imageio",
"nbformat",
"py-pde" + ("==0.45.0" if CI else ""),
]
},
author="https://github.com/open-atmos/PyMPDATA/graphs/contributors",
Expand Down
Empty file.
115 changes: 115 additions & 0 deletions tests/smoke_tests/comparison_against_pypde_2025/test_comparison.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""
Test the similarity of solutions from MPDATA and PyPDE for 2D diffusion
"""

import numpy as np
import pytest
import time
from numpy.ma.testutils import assert_almost_equal

from examples.PyMPDATA_examples.comparison_against_pypde_2025.diffusion_2d import (
InitialConditions,
Two2DiffusionSolution,
mpdata_solution,
py_pde_solution,
)


@pytest.fixture(name="initial_conditions")
def _initial_conditions() -> InitialConditions:
"""Fixture providing initial conditions for the diffusion problem."""

return InitialConditions(
diffusion_coefficient=0.1,
time_step=0.001,
time_end=1,
grid_shape=(30, 18),
grid_range_x=(-1.0, 1.0),
grid_range_y=(0.0, 2.0),
pulse_position=(0.0, 1.0),
)


def test_initial_conditions(initial_conditions: InitialConditions) -> None:
"""Test that the initial conditions are set up correctly."""

assert initial_conditions.diffusion_coefficient > 0
assert initial_conditions.time_step > 0
assert initial_conditions.time_end > 0
assert isinstance(initial_conditions.grid_shape, tuple)
assert len(initial_conditions.grid_shape) == 2
assert all(isinstance(dim, int) for dim in initial_conditions.grid_shape)
assert all(dim > 0 for dim in initial_conditions.grid_shape)
assert isinstance(initial_conditions.grid_range_x, tuple)
assert len(initial_conditions.grid_range_x) == 2
assert isinstance(initial_conditions.grid_range_y, tuple)
assert len(initial_conditions.grid_range_y) == 2
assert isinstance(initial_conditions.pulse_position, tuple)
assert len(initial_conditions.pulse_position) == 2


def test_similarity_of_solutions(initial_conditions: InitialConditions) -> None:
"""Test that the solutions from PyPDE and MPDATA for 2D diffusion are similar."""

# initial solutions
py_pde_result: Two2DiffusionSolution = py_pde_solution(
initial_conditions=initial_conditions,
)

mpdata_result: Two2DiffusionSolution = mpdata_solution(
initial_conditions=initial_conditions,
)

# calculate solutions again to time them and ensure they are consistent across runs
py_pde_start = time.perf_counter()
py_pde_result2: Two2DiffusionSolution = py_pde_solution(
initial_conditions=initial_conditions,
)
assert np.all(py_pde_result == py_pde_result2), (
"PyPDE results are not consistent across runs"
)

py_pde_elapsed = time.perf_counter() - py_pde_start
assert py_pde_elapsed < 10, "PyPDE solution took too long to compute"
Comment thread
pepe5p marked this conversation as resolved.
Outdated

mpdata_start = time.perf_counter()
mpdata_result2: Two2DiffusionSolution = mpdata_solution(
initial_conditions=initial_conditions,
)
mpdata_elapsed = time.perf_counter() - mpdata_start
assert mpdata_elapsed < 10, "MPDATA solution took too long to compute"

assert np.all(mpdata_result == mpdata_result2), (
"MPDATA results are not consistent across runs"
)

# sanity checks
assert py_pde_result.shape == mpdata_result.shape
assert np.all(np.isfinite(py_pde_result))
assert np.all(np.isfinite(mpdata_result))
assert np.all(py_pde_result >= 0)
assert np.all(mpdata_result >= 0)

# total mass check
assert_almost_equal(py_pde_result.sum(), mpdata_result.sum(), decimal=5)

Comment thread
NorbertKlockiewicz marked this conversation as resolved.
Outdated
py_pde_total_mass = (
py_pde_result.sum() * initial_conditions.dx * initial_conditions.dy
)
mpdata_total_mass = (
mpdata_result.sum() * initial_conditions.dx * initial_conditions.dy
)
assert np.isclose(py_pde_total_mass, 1.0, rtol=1e-3)
assert np.isclose(mpdata_total_mass, 1.0, rtol=1e-3)

# compare results
corr = np.corrcoef(py_pde_result.ravel(), mpdata_result.ravel())[0, 1]
diff = py_pde_result - mpdata_result
rmse = np.sqrt(np.mean(diff**2))
l1_error = np.sum(np.abs(diff)) * initial_conditions.dx * initial_conditions.dy
l2_norm = np.linalg.norm(py_pde_result - mpdata_result)
Comment thread
pepe5p marked this conversation as resolved.
Outdated
assert np.allclose(py_pde_result, mpdata_result, rtol=1e-2, atol=1e-1)
assert corr > 0.99
assert rmse < 0.02
assert l1_error < 0.05
assert l2_norm < 0.5
Loading