Hi all,
I am running simple a coupled simulation for heat transfer and hydrogen transport in a ceramic pebble using fairly modest heat flux and tritium source rate values. I am seeing some non-physical results which vary depending on the tolerances - see first screen shot.
The sphere is represented as a 2D circular cross-section in Cartesian coordinates.
Vacuum BCs are applied to each hemisphere of the pebble for hydrogen transport.
Fixed temperature BCs are applied to each hemisphere for heat transfer (~room temp).
When I ‘loosen’ the tolerances, tritium concentrations show non-zero values but peak in just before the boundary edge – almost like it builds up before leaving the pebble surface (strange). See second screen shot.
fyi I have also tried:
-
Using a dolphinx rectangular mesh
-
Applying HeatFluxBCs to the surfaces (for convective cooling) instead of FixedTemperature ones for heat transfer.
-
Applying SurfaceReactionBCs to the surfaces for tritium transport
-
Using a finer (and coarser) mesh.
-
Changing the material properties to Tungsten, see third screen shot
Would really appreciate any help and advice - thanks!
import festim as F
import numpy as np
import matplotlib.pyplot as plt
import dolfinx
import dolfinx.io
from mpi4py import MPI
import os
import gmsh
if not os.path.exists('results'):
os.makedirs('results')
pebble_material = F.Material(
name="LiTiO3",
D_0=9.5E-6, # Diffusion pre-exponential factor [m²/s] 9.5E-6
E_D=0.80, # Activation energy [eV] 0.80
thermal_conductivity=2.5, # Thermal conductivity at 300K [W/m/K]
density=2881, # Density [kg/m³]
heat_capacity=1600 # Heat capacity [J/kg/K]
)
def create_circular_cross_section():
"""
Create 2D circular disk with properly centered left/right boundaries
"""
gmsh.initialize()
gmsh.model.add("circular_pebble_boundaries")
radius = 0.5e-3 # 0.5 mm radius (1 mm diameter pebble)
# Create circle with more control points for smoother boundary
center = gmsh.model.geo.addPoint(0.0, 0.0, 0.0)
# Create more points around the circle for smoother boundary
n_points = 10 # Use even number for symmetric division
points = []
arcs = []
for i in range(n_points):
angle = 2 * np.pi * i / n_points
x = radius * np.cos(angle)
y = radius * np.sin(angle)
point = gmsh.model.geo.addPoint(x, y, 0.0)
points.append(point)
# Create arcs between consecutive points
for i in range(n_points):
next_i = (i + 1) % n_points
arc = gmsh.model.geo.addCircleArc(points[i], center, points[next_i])
arcs.append(arc)
# Left boundary: from top (index 4) to bottom (index 12) going through left side
left_start = n_points // 4 # Index 4 (top)
left_end = 3 * n_points // 4 # Index 12 (bottom)
left_arcs = arcs[left_start:left_end]
# Right boundary: from bottom (index 12) to top (index 4) going through right side
right_arcs = arcs[left_end:] + arcs[:left_start]
# Create surface
curve_loop = gmsh.model.geo.addCurveLoop(arcs)
surface = gmsh.model.geo.addPlaneSurface([curve_loop])
gmsh.model.geo.synchronize()
# Physical groups
gmsh.model.addPhysicalGroup(1, left_arcs, 1, "left_boundary")
gmsh.model.addPhysicalGroup(1, right_arcs, 2, "right_boundary")
gmsh.model.addPhysicalGroup(2, [surface], 3, "volume")
# IMPROVED mesh settings
char_length = radius / 50 # Finer mesh
gmsh.model.mesh.setSize(gmsh.model.getEntities(0), char_length)
# Set mesh algorithm for better quality
gmsh.option.setNumber("Mesh.Algorithm", 6)
gmsh.option.setNumber("Mesh.RecombineAll", 1)
gmsh.option.setNumber("Mesh.Smoothing", 5)
gmsh.model.mesh.generate(2)
mesh_2d, cell_tags, facet_tags = dolfinx.io.gmshio.model_to_mesh(
gmsh.model, comm=MPI.COMM_WORLD, rank=0, gdim=2
)
gmsh.finalize()
return mesh_2d, cell_tags, facet_tags, radius
# FESTIM setup with boundaries
mesh_2d, cell_tags, facet_tags, radius = create_circular_cross_section()
festim_mesh = F.Mesh(mesh_2d)
# Volume subdomain
vol_sphere = F.VolumeSubdomain(id=3, material=pebble_material)
# CORRECTED boundary locator functions
def left_boundary_locator(x):
"""Locate left boundary (neutron-facing side) - properly centered"""
r_coord = x[0] # x-coordinate
z_coord = x[1] # y-coordinate
distance_from_center = np.sqrt(r_coord**2 + z_coord**2)
# Left side of circle (x < 0) and on boundary
return np.logical_and(
np.isclose(distance_from_center, radius, atol=1e-10),
r_coord < 0.0
)
def right_boundary_locator(x):
"""Locate right boundary (coolant side) - properly centered"""
r_coord = x[0] # x-coordinate
z_coord = x[1] # y-coordinate
distance_from_center = np.sqrt(r_coord**2 + z_coord**2)
# Right side of circle (x > 0) and on boundary
return np.logical_and(
np.isclose(distance_from_center, radius, atol=1e-10),
r_coord > 0.0
)
# Surface subdomains
surf_left = F.SurfaceSubdomain(id=1, locator=left_boundary_locator)
surf_right = F.SurfaceSubdomain(id=2, locator=right_boundary_locator)
subdomains = [vol_sphere, surf_left, surf_right]
# 1. CREATE HYDROGEN TRANSPORT PROBLEM
hydrogen_problem = F.HydrogenTransportProblem()
hydrogen_problem.mesh = festim_mesh
hydrogen_problem.subdomains = subdomains
mobile_T = F.Species("T")
hydrogen_problem.species = [mobile_T]
# Tritium source
tritium_source = F.ParticleSource(
value=1e14, # Source rate [atoms/m³/s]
volume=vol_sphere,
species=mobile_T
)
hydrogen_problem.sources = [tritium_source]
# Left boundary:
left_bc = F.DirichletBC(
subdomain=surf_left,
species=mobile_T,
value=0 # Vacuum
)
# Right boundary:
right_bc = F.DirichletBC(
subdomain=surf_right,
value=0, # Vacuum
species=mobile_T
)
hydrogen_problem.boundary_conditions = [left_bc, right_bc]
# 2. CREATE HEAT TRANSFER PROBLEM
heat_problem = F.HeatTransferProblem()
heat_problem.mesh = festim_mesh
heat_problem.subdomains = subdomains
heat_value = 1e3 # Volumetric heat source [W/m³]
heat_source = F.HeatSource(
value=heat_value, # Volumetric heat source [W/m³]
volume=vol_sphere
)
heat_problem.sources = [heat_source]
chamber_temperature = 350 # K
# initital conditions
heat_problem.initial_conditions = [
F.InitialTemperature(
value=chamber_temperature, # Initial temperature [K]
volume=vol_sphere
)
]
# Make sure the boundary condition values make sense
left_bc_T = F.FixedTemperatureBC(
subdomain=surf_left,
value=chamber_temperature # Fixed temperature [K] at boundary
)
right_bc_T = F.FixedTemperatureBC(
subdomain=surf_right,
value=chamber_temperature # Fixed temperature [K] at boundary
)
heat_problem.boundary_conditions = [left_bc_T, right_bc_T]
# simulation settings
# Run time:
run_time = 50
hydrogen_problem.settings = F.Settings(
atol=1e12,
rtol=1e-6,
final_time=run_time,
transient=True
)
hydrogen_problem.settings.stepsize = F.Stepsize(1) # 1 second time step
heat_problem.settings = F.Settings(
atol=1e-6,
rtol=1e-10,
final_time=run_time,
transient=True
)
heat_problem.settings.stepsize = F.Stepsize(1) # 1 second time step
print("Creating coupled problem")
# 3. CREATE COUPLED PROBLEM
model = F.CoupledTransientHeatTransferHydrogenTransport(
heat_problem=heat_problem,
hydrogen_problem=hydrogen_problem
)
# Exports for hydrogen problem
hydrogen_problem.exports = [
F.VTXSpeciesExport(
filename="results/2D/coupled/tritium_transport/pebble_tritium.bp",
field=[mobile_T],
checkpoint=True,
),
F.XDMFExport(
filename="results/2D/coupled/tritium_transport/pebble_tritium.xdmf",
field=[mobile_T]
)
]
# Export temperature data
heat_problem.exports = [
F.VTXTemperatureExport(
filename="results/2D/coupled/heat_transfer/pebble_temperature.bp",
),
]
print("Individual problem exports configured")
# Run the coupled problem
print("Initializing coupled heat transfer and hydrogen transport simulation...")
model.initialise()
print("Running coupled simulation...")
model.run()
print("Coupled simulation completed!")
# Post-process: Read final concentration field
final_concentration = F.read_function_from_file(
filename="results/2D/coupled/tritium_transport/pebble_tritium.bp",
name="T",
timestamp=hydrogen_problem.settings.final_time,
)
def plot_coupled_results():
"""
Plot both temperature and tritium concentration from coupled simulation
"""
# Get temperature and concentration from coupled model
temperature_field = model.heat_problem.u # Temperature solution
concentration_field = model.hydrogen_problem.u # Concentration solution
V = concentration_field.function_space
coords = V.tabulate_dof_coordinates()
x_coords = coords[:, 0] # X coordinates
y_coords = coords[:, 1] # Y coordinates
conc_values = concentration_field.x.array
temp_values = temperature_field.x.array
# Create 2x2 subplot layout
fig, axes = plt.subplots(2, 2, figsize=(15, 12))
# Plot 1: Temperature distribution
scatter1 = axes[0,0].scatter(x_coords*1000, y_coords*1000, c=temp_values,
cmap='plasma', s=20, alpha=0.8)
axes[0,0].set_xlabel('X Position (mm)')
axes[0,0].set_ylabel('Y Position (mm)')
axes[0,0].set_title('Temperature Distribution')
axes[0,0].set_aspect('equal')
axes[0,0].grid(True, alpha=0.3)
plt.colorbar(scatter1, ax=axes[0,0], label='Temperature (K)')
# Plot 2: Tritium concentration distribution
scatter2 = axes[0,1].scatter(x_coords*1000, y_coords*1000, c=conc_values,
cmap='viridis', s=20, alpha=0.8)
axes[0,1].set_xlabel('X Position (mm)')
axes[0,1].set_ylabel('Y Position (mm)')
axes[0,1].set_title('Tritium Concentration Distribution')
axes[0,1].set_aspect('equal')
axes[0,1].grid(True, alpha=0.3)
plt.colorbar(scatter2, ax=axes[0,1], label='Concentration (atoms/m³)')
# Plot 3: Temperature profile (horizontal slice through center)
y_tolerance = radius * 0.05
center_mask = np.abs(y_coords) < y_tolerance
if np.any(center_mask):
x_center = x_coords[center_mask]
temp_center = temp_values[center_mask]
sort_idx = np.argsort(x_center)
axes[1,0].plot(x_center[sort_idx]*1000, temp_center[sort_idx], 'r-o', linewidth=2, markersize=4)
axes[1,0].set_xlabel('X Position (mm)')
axes[1,0].set_ylabel('Temperature (K)')
axes[1,0].set_title('Temperature Profile: Left → Right')
axes[1,0].grid(True, alpha=0.3)
axes[1,0].legend()
# Plot 4: Concentration profile (horizontal slice through center)
if np.any(center_mask):
x_center = x_coords[center_mask]
conc_center = conc_values[center_mask]
sort_idx = np.argsort(x_center)
axes[1,1].plot(x_center[sort_idx]*1000, conc_center[sort_idx], 'b-o', linewidth=2, markersize=4)
axes[1,1].set_xlabel('X Position (mm)')
axes[1,1].set_ylabel('Tritium Concentration (atoms/m³)')
axes[1,1].set_title('Concentration Profile: Left → Right')
axes[1,1].grid(True, alpha=0.3)
axes[1,1].legend()
plt.tight_layout()
# Create output directory
os.makedirs('results/2D/coupled', exist_ok=True)
plt.savefig('results/2D/coupled/coupled_results_analysis.png', dpi=300, bbox_inches='tight')
plt.show()
# Print summary
print(f"\n=== COUPLED SIMULATION RESULTS ===")
print(f"Temperature range: {np.min(temp_values):.1f} - {np.max(temp_values):.1f} K")
print(f"Temperature gradient: {np.max(temp_values) - np.min(temp_values):.1f} K")
print(f"Concentration range: {np.min(conc_values):.2e} - {np.max(conc_values):.2e} atoms/m³")
print(f"Average temperature: {np.mean(temp_values):.1f} K")
print(f"Average concentration: {np.mean(conc_values):.2e} atoms/m³")
print(f"Mesh points: {len(conc_values)}")
print(f"Simulation time: {run_time} seconds")
plot_coupled_results()





