From 17f4ed442076f821f641bf01d69080ac4aa40d69 Mon Sep 17 00:00:00 2001
From: Christoph Lehmann <christoph.lehmann@ufz.de>
Date: Fri, 26 Apr 2024 16:37:47 +0200
Subject: [PATCH] [T/CT] Added steady-state advection-diffusion-reaction
 analytical solution

---
 .../MassFlux/grad_c_and_grad_p_analytical.py  | 229 ++++++++++++++++++
 1 file changed, 229 insertions(+)
 create mode 100755 Tests/Data/Parabolic/ComponentTransport/MassFlux/grad_c_and_grad_p_analytical.py

diff --git a/Tests/Data/Parabolic/ComponentTransport/MassFlux/grad_c_and_grad_p_analytical.py b/Tests/Data/Parabolic/ComponentTransport/MassFlux/grad_c_and_grad_p_analytical.py
new file mode 100755
index 00000000000..f1ee3b043ed
--- /dev/null
+++ b/Tests/Data/Parabolic/ComponentTransport/MassFlux/grad_c_and_grad_p_analytical.py
@@ -0,0 +1,229 @@
+#!/usr/bin/env python
+
+import matplotlib.pyplot as plt
+import numpy as np
+import pyvista as pv
+
+
+class Solution:
+    """
+    Computes the analytical solution to a diffusion-advection-reaction equation
+    in steady state on a 1D domain subject to a pressure gradient from p0 (left)
+    to p1 (right) and a concentration gradient from c0 (left) to c1 (right).
+
+    The porosities in the left and right halves of the domain differ.
+
+    The analytical solution works for non-zero Darcy velocities only.
+    """
+
+    _phi1 = 0.65
+    _phi0 = 0.15
+    L = 0.8
+    _D = 1e-9
+
+    def __init__(self, *, c0, c1, p0, p1, r):
+        phi0 = Solution._phi0
+        phi1 = Solution._phi1
+        D = Solution._D
+        L = Solution.L
+
+        mu = 1  # viscosity
+        k = 1e-9  # permeability
+        v_x = -k * (p1 - p0) / (mu * L)  # Darcy velocity
+
+        print(f"Darcy velocity is {v_x} m/s")
+
+        a0 = v_x / (phi0 * D)
+        a1 = v_x / (phi1 * D)
+
+        eps0 = np.exp(a0 * L / 2)
+        eps1 = np.exp(a1 * L / 2)
+
+        phit = phi0 / phi1
+        rho = -r / v_x
+
+        d0 = (rho / a1 * (1 - phit) * (1 - eps1) + c1 - c0 + rho * L) / (
+            (eps0 - 1) / a0 + phit * eps0 * (eps1 - 1) / a1
+        )
+
+        d1 = (phit * d0 * eps0 + rho * (1 - phit)) / eps1
+
+        f0 = c0 - d0 / a0
+        f1 = c1 - d1 / a1 * eps1**2 + rho * L
+
+        self._a0 = a0
+        self._a1 = a1
+        self._d0 = d0
+        self._d1 = d1
+        self._f0 = f0
+        self._f1 = f1
+        self.v_x = v_x
+        self.p0 = p0
+        self.p1 = p1
+        self._r = r
+
+        # check BCs
+        assert self.c_ana(0) == c0
+        # print(abs(self.c_ana(L) - c1))
+        assert abs(self.c_ana(L) - c1) < 1e-14
+        # check concenctration continuity
+        eps = 1e-8
+        # print(abs(self.c_ana(L/2 - eps) - self.c_ana(L/2 + eps)))
+        assert abs(self.c_ana(L / 2 - eps) - self.c_ana(L / 2 + eps)) < 1e-7
+        # check flux continuity
+        # print(abs(self.flux_ana(L/2 - eps)[0] - self.flux_ana(L/2 + eps))[0])
+        assert np.all(
+            np.abs(self.flux_ana(L / 2 - eps) - self.flux_ana(L / 2 + eps)) < 1e-15
+        )
+
+    # general solution
+    def _gs(self, d, a, f, x):
+        r = self._r
+        v = self.v_x
+        return d / a * np.exp(a * x) + f + r * x / v
+
+    # general gradient
+    def _gg(self, d, a, x):
+        r = self._r
+        v = self.v_x
+        return d * np.exp(a * x) + r / v
+
+    # analytical solution (concentration profile)
+    def c_ana(self, x):
+        a0 = self._a0
+        a1 = self._a1
+        d0 = self._d0
+        d1 = self._d1
+        f0 = self._f0
+        f1 = self._f1
+        L = Solution.L
+
+        return np.where(x < L / 2, self._gs(d0, a0, f0, x), self._gs(d1, a1, f1, x))
+
+    # analytical solution (concentration gradient)
+    def flux_ana(self, x):
+        a0 = self._a0
+        a1 = self._a1
+        d0 = self._d0
+        d1 = self._d1
+        v_x = self.v_x
+        phi0 = Solution._phi0
+        phi1 = Solution._phi1
+        D = Solution._D
+        L = Solution.L
+
+        phi = np.where(x < L / 2, phi0, phi1)
+        grad_c = np.where(x < L / 2, self._gg(d0, a0, x), self._gg(d1, a1, x))
+        c = self.c_ana(x)
+        flux_x = v_x * c - phi * D * grad_c
+
+        return np.c_[flux_x, np.zeros_like(x), np.zeros_like(x)]
+
+
+def create_quasi_1D_mesh(L, n):
+    mesh = pv.ImageData()
+    mesh.dimensions = (n + 1, 2, 2)
+    mesh.origin = (0, 0, 0)
+    mesh.spacing = np.array([L, L, L]) / n
+    mesh = mesh.cast_to_unstructured_grid()
+    mesh.points_to_double()
+    return mesh
+
+
+def plot_solution_to_files(sol):
+    xs = np.linspace(0, Solution.L, 1000)
+    cs = sol.c_ana(xs)
+    fluxes = sol.flux_ana(xs)
+
+    fig, ax = plt.subplots()
+
+    ax.plot(xs, cs)
+    ax.set_xlabel("$x$ / m")
+    ax.set_ylabel("$c$")
+    ax.axvline(Solution.L / 2, ls=":", color="0.5")
+    fig.subplots_adjust(left=0.18)
+
+    fig.savefig("cs.pdf")
+
+    fig, ax = plt.subplots()
+
+    ax.plot(xs, fluxes[:, 0])
+    ax.set_xlabel("$x$ / m")
+    ax.set_ylabel("total mass flux")
+    ax.axvline(Solution.L / 2, ls=":", color="0.5")
+    fig.subplots_adjust(left=0.18)
+
+    fig.savefig("fluxes.pdf")
+
+
+def generate_meshes(sol, file_prefix, num_cells):
+    L = Solution.L
+
+    # mesh with initial data
+    mesh = create_quasi_1D_mesh(L, num_cells)
+    xs = mesh.points[:, 0]
+    mesh.point_data["C_ini"] = sol.c_ana(xs)
+    p0 = sol.p0
+    p1 = sol.p1
+    mesh.point_data["p_ini"] = (p0 * (L - xs) + p1 * xs) / L
+    mesh.cell_data["MaterialIDs"] = (mesh.cell_centers().points[:, 0] > L / 2).astype(
+        np.int32
+    )
+    mesh.point_data["bulk_node_ids"] = np.arange(len(xs), dtype=np.uint64)
+    mesh.save(file_prefix + ".vtu")
+
+    # extract boundary meshes
+    eps = L * 1e-6
+    surf = mesh.extract_surface()
+    surf_ctr_xs = surf.cell_centers().points[:, 0]
+    surf_left = surf.extract_cells(np.nonzero(surf_ctr_xs < eps)[0])
+    surf_left.save(file_prefix + "_left.vtu")
+    surf_right = surf.extract_cells(np.nonzero(surf_ctr_xs > L - eps)[0])
+    surf_right.save(file_prefix + "_right.vtu")
+
+    # mesh with reference data
+    mesh = create_quasi_1D_mesh(L, num_cells)
+
+    xs = mesh.points[:, 0]
+    mesh.point_data["pressure"] = (p0 * (L - xs) + p1 * xs) / L
+    mesh.point_data["C"] = sol.c_ana(xs)
+
+    fluxes = sol.flux_ana(xs)
+    mesh.point_data["CFlux"] = fluxes
+
+    # The constant and linear flux profiles from these tests can be extrapolated
+    # exactly on a FEM mesh.
+    dim = fluxes.shape[1]
+    mesh.cell_data["CFlux_residual"] = np.zeros((mesh.n_cells, dim))
+
+    v = np.pad((sol.v_x,), (0, dim - 1))
+    mesh.point_data["darcy_velocity"] = np.tile(v, (mesh.n_points, 1))
+    mesh.cell_data["darcy_velocity_residual"] = np.zeros((mesh.n_cells, dim))
+
+    mesh.save(file_prefix + "_ts_4_t_400000000.000000.vtu")
+
+
+#### Advection only
+
+# This test case has spatially homogeneous concentration and mass flux profiles.
+# Therefore, it can be represented exactly on a low resolution FEM mesh.
+sol = Solution(c0=1, c1=1, p0=0.3, p1=0, r=0)
+generate_meshes(sol, "only_grad_p", 10)
+# plot_solution_to_files(sol)
+
+
+#### Advection and diffusion
+
+# Spatially homogeneous flux profile, but varying concentrations.
+sol = Solution(c0=0, c1=1, p0=0.3, p1=0, r=0)
+generate_meshes(sol, "grad_c_and_grad_p", 1000)
+# plot_solution_to_files(sol)
+
+
+#### Advection and diffusion and reaction
+
+# The spatially homogeneous reaction rate term leads to a constant slope flux
+# profile, which will be computed rather exactly by OGS.
+sol = Solution(c0=0.5, c1=1, p0=0.3, p1=0, r=-5e-10)
+generate_meshes(sol, "grad_c_and_grad_p_and_r", 1000)
+# plot_solution_to_files(sol)
-- 
GitLab