PDE

Stefan Problem (Moving Boundary)

One-phase melting/solidification PDE on a fixed domain via coordinate transformation.

  • advanced
  • stiff

The Stefan problem couples a parabolic PDE on a domain 0 < x < s(t) with an ODE for the moving boundary s(t) itself. Numerically, it’s a classic free-boundary problem: the domain length is part of the solution.

∂T/∂t = α ∂²T/∂x²,  0 < x < s(t)
T(0, t) = T_hot > T_melt
T(s(t), t) = T_melt
ds/dt = -κ ∂T/∂x |_{x = s(t)}     (Stefan condition)

The example maps ξ = x/s(t) to a fixed domain [0, 1], transforms the PDE accordingly, and integrates the coupled system as method of lines. Useful as a template for any free-boundary problem you might encounter in phase-change physics or finance.

Run it:

cargo run --release --example stefan_problem

Source

numra/examples/stefan_problem.rs on GitHub · Related book chapter

use numra_ode::{DoPri5, OdeSystem, Solver, SolverOptions};
use numra_pde::moving::CoordinateTransform;

/// Stefan problem system.
///
/// State vector: [T_1, T_2, ..., T_{n-1}, s]
/// where T_i are interior temperatures and s is the interface position.
struct StefanSystem {
    /// Thermal diffusivity
    alpha: f64,
    /// Stefan coefficient κ = k / (ρ * L)
    kappa: f64,
    /// Hot wall temperature
    t_hot: f64,
    /// Melting temperature
    t_melt: f64,
    /// Number of interior grid points
    n_interior: usize,
}

impl StefanSystem {
    fn new(alpha: f64, kappa: f64, t_hot: f64, t_melt: f64, n_interior: usize) -> Self {
        Self {
            alpha,
            kappa,
            t_hot,
            t_melt,
            n_interior,
        }
    }

    /// Build full temperature profile including boundaries.
    fn build_full_profile(&self, y: &[f64]) -> Vec<f64> {
        let mut full = Vec::with_capacity(self.n_interior + 2);
        full.push(self.t_hot); // Left BC
        full.extend_from_slice(&y[..self.n_interior]);
        full.push(self.t_melt); // Right BC at interface
        full
    }

    /// Get interface position from state vector.
    fn _interface_position(&self, y: &[f64]) -> f64 {
        y[self.n_interior]
    }
}

impl OdeSystem<f64> for StefanSystem {
    fn dim(&self) -> usize {
        self.n_interior + 1 // Temperature at interior points + interface position
    }

    fn rhs(&self, _t: f64, y: &[f64], dydt: &mut [f64]) {
        let n = self.n_interior;
        let s = y[n]; // Interface position

        // Grid spacing in computational domain [0, 1]
        let dxi = 1.0 / (n as f64 + 1.0);
        let dxi2 = dxi * dxi;

        // Build full temperature array
        let t_full = self.build_full_profile(y);

        // Coordinate transform
        let transform = CoordinateTransform::new(s);

        // Compute interface velocity (Stefan condition)
        // ds/dt = -κ * (∂T/∂x at x=s) = -κ * (1/s) * (∂T/∂ξ at ξ=1)
        let dt_dxi_interface =
            (3.0 * t_full[n + 1] - 4.0 * t_full[n] + t_full[n - 1]) / (2.0 * dxi);
        let dt_dx_interface = transform.transform_d1(dt_dxi_interface);
        let dsdt = -self.kappa * dt_dx_interface;

        // Heat equation in transformed coordinates
        // ∂T/∂t = (α/s²) ∂²T/∂ξ² + (ξ * ds/dt / s) ∂T/∂ξ
        let alpha_s2 = self.alpha / (s * s);

        for (i, dydt_i) in dydt.iter_mut().enumerate().take(n) {
            let idx = i + 1; // Index in full array
            let xi = (i as f64 + 1.0) * dxi; // Computational coordinate

            // Second derivative in ξ
            let d2t_dxi2 = (t_full[idx + 1] - 2.0 * t_full[idx] + t_full[idx - 1]) / dxi2;

            // First derivative in ξ
            let dt_dxi = (t_full[idx + 1] - t_full[idx - 1]) / (2.0 * dxi);

            // Convective term from moving boundary
            let convection = transform.convection_coefficient(dsdt, xi) * dt_dxi;

            // Full RHS
            *dydt_i = alpha_s2 * d2t_dxi2 + convection;
        }

        // Interface velocity
        dydt[n] = dsdt;
    }
}

fn main() {
    println!("=== One-Phase Stefan Problem (Melting) ===\n");

    // Physical parameters (ice melting)
    let alpha = 1.0e-6; // Thermal diffusivity (m²/s) - water/ice
    let kappa = 1.0e-6; // Stefan coefficient (m²/s/K)
    let t_hot = 20.0; // Hot wall temperature (°C)
    let t_melt = 0.0; // Melting temperature (°C)

    // Initial conditions
    let s_initial = 0.001; // Initial melt thickness (1 mm)
    let t_final = 100.0; // Simulation time (s)

    // Grid
    let n_interior = 20;
    let n_total = n_interior + 2;

    println!("Physical Parameters:");
    println!("  Thermal diffusivity α = {:.2e} m²/s", alpha);
    println!("  Stefan coefficient κ = {:.2e} m²/s/K", kappa);
    println!("  Hot wall T = {}°C", t_hot);
    println!("  Melting point T_melt = {}°C", t_melt);
    println!("\nInitial Conditions:");
    println!("  Initial melt thickness s₀ = {:.1} mm", s_initial * 1000.0);
    println!("  Simulation time = {} s", t_final);
    println!("  Grid points: {} interior\n", n_interior);

    // Create system
    let system = StefanSystem::new(alpha, kappa, t_hot, t_melt, n_interior);

    // Initial state: linear temperature profile + interface position
    let mut y0 = vec![0.0; n_interior + 1];
    for (i, y0_i) in y0.iter_mut().enumerate().take(n_interior) {
        let xi = (i as f64 + 1.0) / (n_interior as f64 + 1.0);
        *y0_i = t_hot * (1.0 - xi) + t_melt * xi;
    }
    y0[n_interior] = s_initial;

    // Solve
    let options = SolverOptions::default()
        .rtol(1e-6)
        .atol(1e-9)
        .max_steps(100000);

    println!("Solving with DoPri5...\n");
    let result = DoPri5::solve(&system, 0.0, t_final, &y0, &options).expect("Solver failed");

    println!("Solver Statistics:");
    println!("  Steps accepted: {}", result.stats.n_accept);
    println!("  RHS evaluations: {}", result.stats.n_eval);

    // Extract results
    let y_final = result.y_final().unwrap();
    let s_final = y_final[n_interior];

    println!("\n=== Results ===");
    println!("Final interface position: {:.3} mm", s_final * 1000.0);
    println!(
        "Interface velocity: {:.3} μm/s",
        (s_final - s_initial) / t_final * 1e6
    );

    // Analytical solution for comparison (Neumann solution)
    // s(t) = 2 * λ * sqrt(α * t)
    // where λ satisfies: λ * exp(λ²) * erf(λ) = St / sqrt(π)
    // St = c_p * (T_hot - T_melt) / L (Stefan number)
    println!("\n=== Interface Evolution ===");
    println!("t (s)\t\ts (mm)\t\tds/dt (μm/s)");

    let mut prev_s = s_initial;
    let mut prev_t = 0.0;

    for (i, &t) in result.t.iter().enumerate() {
        if i % (result.t.len() / 10).max(1) == 0 || i == result.t.len() - 1 {
            let s = result.y_at(i)[n_interior];
            let dsdt = if t > prev_t {
                (s - prev_s) / (t - prev_t) * 1e6
            } else {
                0.0
            };
            println!("{:.1}\t\t{:.3}\t\t{:.3}", t, s * 1000.0, dsdt);
            prev_s = s;
            prev_t = t;
        }
    }

    // Temperature profile at final time
    println!("\n=== Final Temperature Profile ===");
    println!("x (mm)\t\tT (°C)");
    let t_profile = system.build_full_profile(&y_final);
    for (i, &t_val) in t_profile.iter().enumerate().take(n_total) {
        let xi = i as f64 / (n_total as f64 - 1.0);
        let x = xi * s_final;
        if i % 4 == 0 || i == n_total - 1 {
            println!("{:.3}\t\t{:.2}", x * 1000.0, t_val);
        }
    }

    // Verify Stefan condition is satisfied
    let dxi = 1.0 / (n_interior as f64 + 1.0);
    let dt_dxi = (3.0 * t_profile[n_total - 1] - 4.0 * t_profile[n_total - 2]
        + t_profile[n_total - 3])
        / (2.0 * dxi);
    let dt_dx = dt_dxi / s_final;
    let expected_dsdt = -kappa * dt_dx;

    println!("\n=== Stefan Condition Verification ===");
    println!("∂T/∂x at interface: {:.2} K/m", dt_dx);
    println!("Expected ds/dt: {:.3} μm/s", expected_dsdt * 1e6);
}