@remidm, @williamhayes123, @nickharris808, Hello!
I would like to contribute to the discussion.
A more general idea is to distinguish three hydrogen populations: adsorbed hydrogen at the surface, hydrogen in subsurface sites (which may differ from bulk sites), and hydrogen in the bulk. Such models have been investigated in several recent papers (Ono et al. (2019), Hamamoto et al. (2020), Shimohata et al. (2021) ).
The governing equations are:
\begin{aligned}
\frac{\partial c_s}{\partial t} &= J_{vs}+J_{bs}-J_{sb},\\
\frac{\partial q_{ss}}{\partial t} &= J_{sb}-J_{bs}-J_{\mathrm{dif}},\\
\frac{\partial c_m}{\partial t} &= \nabla\cdot(D\nabla c_m),
\end{aligned}
where q_{ss} is the subsurface inventory per unit area.
If we assume that the subsurface concentration equals the bulk concentration at the interface in 1D-0D coupling (c_{ss}=c_m(0,t) and q_{ss}=\lambda_{\mathrm{IS}}c_{ss}), the subsurface balance reduces to
J_{\mathrm{dif}}=J_{sb}-J_{bs}-\lambda_{\mathrm{IS}}\frac{\partial c_m(0,t)}{\partial t}.
This is precisely the dynamic boundary condition used in FESTIM 1.4.
To illustrate the role of these populations, here is a small test based on Remi’s solution above (though in 2D geometry, since 1D+0D coupling doesn’t work for me with FESTIM2.2rc2), comparing three formulations:
- Explicit subsurface inventory, coupled to bulk diffusion through a discrete Fickian flux.
- Reduced formulation with c_{ss}=c_m(0,t), retaining the subsurface storage term (aka FESTIM 1.4).
- Same reduced formulation, but neglecting the subsurface storage term.
FESTIM script
import matplotlib.pyplot as plt
import numpy as np
from dolfinx.mesh import create_unit_square
from mpi4py import MPI
import festim as F
D = 0.25
n_surf = 1.0
n_IS = 3.0
k_sb = 1.4
k_bs = 0.65
J_vs = 0.4
final_time = 4.0
nx = 60
dx = 1 / nx
lambda_values = (1e-3, 1)
modes = ("full", "storage", "no_storage")
def j_sb(c_s, c_ss):
return k_sb * c_s * (1 - c_ss / n_IS)
def j_bs(c_s, c_ss):
return k_bs * c_ss * (1 - c_s / n_surf)
def run(mode, lambda_IS):
mesh = F.Mesh(create_unit_square(MPI.COMM_WORLD, nx, 2))
material = F.Material(D_0=D, E_D=0.0)
bulk = F.VolumeSubdomain(
id=1,
material=material,
locator=lambda x: np.ones(x.shape[1], dtype=bool),
)
surface = F.VolumeSubdomain(
id=2,
dim=1,
material=material,
locator=lambda x: np.isclose(x[0], 0.0),
)
c_m = F.Species("c_m", subdomains=[bulk])
c_s = F.Species("c_s", mobile=False, subdomains=[surface])
species = [c_m, c_s]
if mode == "full":
q_ss = F.Species("q_ss", mobile=False, subdomains=[surface])
species.append(q_ss)
def j_dif(c_bulk, q):
return D / dx * (q / lambda_IS - c_bulk)
bcs = [
F.ParticleFluxBC(
subdomain=surface,
species=c_m,
value=lambda c_bulk, q: j_dif(c_bulk, q),
species_dependent_value={"c_bulk": c_m, "q": q_ss},
)
]
sources = [
F.ParticleSource(
volume=surface,
species=c_s,
value=lambda c_surface, q: (
J_vs
+ j_bs(c_surface, q / lambda_IS)
- j_sb(c_surface, q / lambda_IS)
),
species_dependent_value={"c_surface": c_s, "q": q_ss},
),
F.ParticleSource(
volume=surface,
species=q_ss,
value=lambda c_bulk, c_surface, q: (
j_sb(c_surface, q / lambda_IS)
- j_bs(c_surface, q / lambda_IS)
- j_dif(c_bulk, q)
),
species_dependent_value={"c_bulk": c_m, "c_surface": c_s, "q": q_ss},
),
]
else:
def surface_flux(c_bulk, c_surface):
value = j_sb(c_surface, c_bulk) - j_bs(c_surface, c_bulk)
if mode == "storage":
old_bulk = c_m.subdomain_to_prev_solution[bulk]
value -= lambda_IS * (c_bulk - old_bulk) / model.dt
return value
bcs = [
F.ParticleFluxBC(
subdomain=surface,
species=c_m,
value=surface_flux,
species_dependent_value={"c_bulk": c_m, "c_surface": c_s},
)
]
sources = [
F.ParticleSource(
volume=surface,
species=c_s,
value=lambda c_bulk, c_surface: (
J_vs + j_bs(c_surface, c_bulk) - j_sb(c_surface, c_bulk)
),
species_dependent_value={"c_bulk": c_m, "c_surface": c_s},
)
]
N_bulk = F.TotalVolume(field=c_m, volume=bulk)
N_surf = F.TotalVolume(field=c_s, volume=surface)
c_m0 = F.TotalSurface(field=c_m, surface=surface)
exports = [N_bulk, N_surf, c_m0]
if mode == "full":
N_ss = F.TotalVolume(field=q_ss, volume=surface)
exports.append(N_ss)
model = F.HydrogenTransportProblemDiscontinuous(
mesh=mesh,
subdomains=[bulk, surface],
species=species,
boundary_conditions=bcs,
sources=sources,
temperature=300,
exports=exports,
settings=F.Settings(
atol=1e-9,
rtol=1e-9,
max_iterations=40,
final_time=final_time,
stepsize=F.Stepsize(
initial_value=1e-3,
growth_factor=1.1,
cutback_factor=0.5,
target_nb_iterations=4,
max_stepsize=0.05,
),
),
)
model.show_progress_bar = False
model.initialise()
model.run()
t = np.asarray(N_bulk.t)
cs = np.asarray(N_surf.data)
cm0 = np.asarray(c_m0.data)
q = (
np.asarray(N_ss.data)
if mode == "full"
else lambda_IS * cm0
if mode == "storage"
else np.zeros_like(t)
)
nm = np.asarray(N_bulk.data)
total = nm + cs + q
err = np.max(np.abs(total - J_vs * t))
print(f"lambda_IS={lambda_IS:g}, {mode}: mass-balance error = {err:.3e}")
return t, cs, cm0, q, nm
fig, axes = plt.subplots(2, 4, figsize=(14, 6))
labels = (
"Surface inventory",
"Bulk boundary concentration",
"Subsurface inventory",
"Bulk inventory",
)
for row, lambda_IS in enumerate(lambda_values):
results = {mode: run(mode, lambda_IS) for mode in modes}
t, cs, cm0, _, _ = results["storage"]
t0, cs0, cm00, _, _ = results["no_storage"]
print(
f"lambda_IS={lambda_IS:g}: "
f"max |Delta c_s|={np.max(np.abs(cs - np.interp(t, t0, cs0))):.3e}, "
f"max |Delta c_m(0)|={np.max(np.abs(cm0 - np.interp(t, t0, cm00))):.3e}"
)
for mode, (time, *curves) in results.items():
for ax, values in zip(axes[row], curves):
ax.plot(time, values, label=mode)
for ax, label in zip(axes[row], labels):
ax.set(xlabel="Time", ylabel=label)
axes[row, 0].set_title(f"lambda_IS = {lambda_IS:g}")
axes[0, 0].legend()
fig.tight_layout()
fig.savefig("surface_kinetics_three_models.png", dpi=150)
plt.show()
The results show that the full model and the reduced formulation with subsurface storage give nearly identical solutions in this test. Omitting the storage term changes the distribution of hydrogen between the surface and the bulk, while the total inventory remains conserved. Of course, the magnitude of the effect depends on the problem parameters.
I hope you find this helpful.