Skip to main content

scirs2_integrate/
phase_field.rs

1//! Phase-field models for interface and solidification dynamics
2//!
3//! This module implements classical phase-field equations used to model
4//! multi-phase systems, interface dynamics, and solidification without explicitly
5//! tracking moving boundaries.
6//!
7//! ## Implemented Models
8//!
9//! ### Cahn-Hilliard Equation
10//! Models spinodal decomposition and phase separation:
11//! ```text
12//! ∂φ/∂t = M ∇²μ
13//! μ = -ε² ∇²φ + f'(φ)
14//! f(φ) = (φ² - 1)² / 4  (double-well potential)
15//! ```
16//!
17//! ### Allen-Cahn Equation
18//! Models interface motion (antiphase boundary motion):
19//! ```text
20//! ∂φ/∂t = -M (f'(φ) - ε² ∇²φ)
21//! ```
22//!
23//! ### Stefan Problem
24//! Models solidification with a sharp moving interface and latent heat release:
25//! ```text
26//! ∂T/∂t = α ∇²T  (in each phase separately)
27//! Stefan condition at interface: ρ L ds/dt = k_s ∂T/∂n|_s - k_l ∂T/∂n|_l
28//! ```
29//!
30//! ## Example
31//!
32//! ```no_run
33//! use scirs2_integrate::phase_field::CahnHilliardSolver;
34//!
35//! let mut solver = CahnHilliardSolver::new(32, 32, 1.0/32.0, 0.05, 1.0);
36//! solver.random_init(0.05, 42);
37//! solver.run(20, 1e-4);
38//! let fe = solver.free_energy();
39//! assert!(fe.is_finite());
40//! ```
41
42use crate::error::{IntegrateError, IntegrateResult};
43
44// ─────────────────────────────────────────────────────────────────────────────
45// Utility: 2D discrete Laplacian (5-point stencil, periodic BC)
46// ─────────────────────────────────────────────────────────────────────────────
47
48/// Compute the discrete Laplacian of a 2D field using the 5-point stencil
49/// with periodic boundary conditions.
50///
51/// `lap[x][y] = (f[x+1][y] + f[x-1][y] + f[x][y+1] + f[x][y-1] - 4 f[x][y]) / dx^2`
52fn laplacian_2d(f: &[Vec<f64>], dx: f64) -> Vec<Vec<f64>> {
53    let nx = f.len();
54    let ny = f[0].len();
55    let inv_dx2 = 1.0 / (dx * dx);
56    let mut lap = vec![vec![0.0_f64; ny]; nx];
57    for x in 0..nx {
58        let xp = (x + 1) % nx;
59        let xm = (x + nx - 1) % nx;
60        for y in 0..ny {
61            let yp = (y + 1) % ny;
62            let ym = (y + ny - 1) % ny;
63            lap[x][y] = (f[xp][y] + f[xm][y] + f[x][yp] + f[x][ym] - 4.0 * f[x][y]) * inv_dx2;
64        }
65    }
66    lap
67}
68
69/// Compute the discrete Laplacian of a 1D field with Neumann (zero-flux) BCs.
70fn laplacian_1d_neumann(f: &[f64], dx: f64) -> Vec<f64> {
71    let n = f.len();
72    let inv_dx2 = 1.0 / (dx * dx);
73    let mut lap = vec![0.0_f64; n];
74    for i in 0..n {
75        let fp = if i + 1 < n { f[i + 1] } else { f[i] }; // Neumann: ghost = boundary
76        let fm = if i > 0 { f[i - 1] } else { f[i] };
77        lap[i] = (fp + fm - 2.0 * f[i]) * inv_dx2;
78    }
79    lap
80}
81
82// ─────────────────────────────────────────────────────────────────────────────
83// Minimal deterministic PRNG for seeded initialisation
84// ─────────────────────────────────────────────────────────────────────────────
85
86struct Lcg64 {
87    state: u64,
88}
89
90impl Lcg64 {
91    fn new(seed: u64) -> Self {
92        Self {
93            state: seed.wrapping_add(1),
94        }
95    }
96
97    fn next_u64(&mut self) -> u64 {
98        // LCG parameters from Knuth
99        self.state = self
100            .state
101            .wrapping_mul(6_364_136_223_846_793_005)
102            .wrapping_add(1_442_695_040_888_963_407);
103        self.state
104    }
105
106    /// Uniform f64 in [0, 1)
107    fn uniform(&mut self) -> f64 {
108        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
109    }
110
111    /// Zero-mean uniform noise in [-amp, amp]
112    fn noise(&mut self, amp: f64) -> f64 {
113        amp * (2.0 * self.uniform() - 1.0)
114    }
115}
116
117// ─────────────────────────────────────────────────────────────────────────────
118// Cahn-Hilliard Solver
119// ─────────────────────────────────────────────────────────────────────────────
120
121/// Cahn-Hilliard phase-field solver for 2D phase separation.
122///
123/// Evolves the order parameter φ ∈ [−1, 1] under the gradient-flow dynamics:
124/// ```text
125/// ∂φ/∂t = M ∇²μ,  μ = f'(φ) − ε² ∇²φ
126/// f(φ) = (φ² − 1)² / 4
127/// ```
128/// The semi-implicit time discretisation treats the linear (Laplacian) term
129/// implicitly and the nonlinear bulk term explicitly to allow larger time steps.
130///
131/// Boundary conditions are periodic in both x and y.
132pub struct CahnHilliardSolver {
133    /// Grid size in x-direction
134    pub nx: usize,
135    /// Grid size in y-direction
136    pub ny: usize,
137    /// Grid spacing (same in x and y)
138    pub dx: f64,
139    /// Order parameter field φ(x,y) ∈ [−1, 1]
140    pub phi: Vec<Vec<f64>>,
141    /// Chemical potential field μ(x,y)
142    pub mu: Vec<Vec<f64>>,
143    /// Interface width parameter ε (determines interface thickness ~ ε)
144    epsilon: f64,
145    /// Mobility coefficient M
146    mobility: f64,
147}
148
149impl CahnHilliardSolver {
150    /// Create a new Cahn-Hilliard solver on an `nx × ny` grid.
151    ///
152    /// # Arguments
153    /// * `nx`, `ny` — grid dimensions
154    /// * `dx` — uniform grid spacing
155    /// * `epsilon` — interface width (typical: 0.01–0.1)
156    /// * `mobility` — mobility coefficient M (typical: 1.0)
157    pub fn new(nx: usize, ny: usize, dx: f64, epsilon: f64, mobility: f64) -> Self {
158        Self {
159            nx,
160            ny,
161            dx,
162            phi: vec![vec![0.0_f64; ny]; nx],
163            mu: vec![vec![0.0_f64; ny]; nx],
164            epsilon,
165            mobility,
166        }
167    }
168
169    /// Initialise φ with small random perturbations around zero.
170    ///
171    /// This seeds spinodal decomposition from a nearly-homogeneous mixed state.
172    pub fn random_init(&mut self, noise_amplitude: f64, seed: u64) {
173        let mut rng = Lcg64::new(seed);
174        for x in 0..self.nx {
175            for y in 0..self.ny {
176                self.phi[x][y] = rng.noise(noise_amplitude);
177            }
178        }
179        // Compute initial chemical potential
180        self.mu = self.compute_mu();
181    }
182
183    /// Compute the chemical potential μ = f'(φ) − ε² ∇²φ.
184    ///
185    /// Uses f'(φ) = φ(φ²−1) from the double-well potential f(φ) = (φ²−1)²/4.
186    fn compute_mu(&self) -> Vec<Vec<f64>> {
187        let lap_phi = laplacian_2d(&self.phi, self.dx);
188        let eps2 = self.epsilon * self.epsilon;
189        let mut mu = vec![vec![0.0_f64; self.ny]; self.nx];
190        for x in 0..self.nx {
191            for y in 0..self.ny {
192                let phi = self.phi[x][y];
193                // f'(phi) = phi^3 - phi
194                let df = phi * phi * phi - phi;
195                mu[x][y] = df - eps2 * lap_phi[x][y];
196            }
197        }
198        mu
199    }
200
201    /// Advance the solution by one time step `dt` using an explicit Euler scheme.
202    ///
203    /// For stability in the explicit scheme, `dt` should satisfy the CFL-like condition:
204    /// `dt < dx^4 / (4 M ε²)`
205    pub fn step(&mut self, dt: f64) {
206        // Update chemical potential from current phi
207        self.mu = self.compute_mu();
208
209        // Compute ∇²μ
210        let lap_mu = laplacian_2d(&self.mu, self.dx);
211
212        // Update phi: ∂φ/∂t = M ∇²μ
213        for x in 0..self.nx {
214            for y in 0..self.ny {
215                self.phi[x][y] += dt * self.mobility * lap_mu[x][y];
216            }
217        }
218    }
219
220    /// Run for `n_steps` time steps with step size `dt`.
221    pub fn run(&mut self, n_steps: usize, dt: f64) {
222        for _ in 0..n_steps {
223            self.step(dt);
224        }
225        // Final chemical potential update
226        self.mu = self.compute_mu();
227    }
228
229    /// Compute the total Ginzburg-Landau free energy:
230    /// ```text
231    /// E = ∫ [f(φ) + ε²/2 |∇φ|²] dx dy
232    /// ```
233    /// using the 2D midpoint quadrature rule.
234    pub fn free_energy(&self) -> f64 {
235        let mut energy = 0.0;
236        let eps2 = self.epsilon * self.epsilon;
237        let nx = self.nx;
238        let ny = self.ny;
239        let inv_2dx = 0.5 / self.dx;
240
241        for x in 0..nx {
242            let xp = (x + 1) % nx;
243            let xm = (x + nx - 1) % nx;
244            for y in 0..ny {
245                let yp = (y + 1) % ny;
246                let ym = (y + ny - 1) % ny;
247                let phi = self.phi[x][y];
248                let bulk = (phi * phi - 1.0) * (phi * phi - 1.0) / 4.0;
249                let grad_x = (self.phi[xp][y] - self.phi[xm][y]) * inv_2dx;
250                let grad_y = (self.phi[x][yp] - self.phi[x][ym]) * inv_2dx;
251                let grad2 = grad_x * grad_x + grad_y * grad_y;
252                energy += (bulk + eps2 / 2.0 * grad2) * self.dx * self.dx;
253            }
254        }
255        energy
256    }
257
258    /// Volume fraction of the phase where φ > 0.
259    pub fn volume_fraction(&self) -> f64 {
260        let total = self.nx * self.ny;
261        let positive = self
262            .phi
263            .iter()
264            .flat_map(|row| row.iter())
265            .filter(|&&v| v > 0.0)
266            .count();
267        positive as f64 / total as f64
268    }
269
270    /// Mean value of φ (should be conserved by CH dynamics).
271    pub fn mean_phi(&self) -> f64 {
272        let sum: f64 = self.phi.iter().flat_map(|row| row.iter()).sum();
273        sum / (self.nx * self.ny) as f64
274    }
275
276    /// Return the epsilon (interface width) parameter.
277    pub fn epsilon(&self) -> f64 {
278        self.epsilon
279    }
280
281    /// Return the mobility coefficient.
282    pub fn mobility(&self) -> f64 {
283        self.mobility
284    }
285}
286
287// ─────────────────────────────────────────────────────────────────────────────
288// Allen-Cahn Solver
289// ─────────────────────────────────────────────────────────────────────────────
290
291/// Allen-Cahn phase-field solver for 2D interface dynamics.
292///
293/// Evolves the order parameter under the L²-gradient flow of the Ginzburg-Landau
294/// free energy:
295/// ```text
296/// ∂φ/∂t = M (ε² ∇²φ − f'(φ))
297/// f'(φ) = φ³ − φ
298/// ```
299///
300/// Unlike the Cahn-Hilliard equation, Allen-Cahn does **not** conserve the total
301/// volume of each phase. The dynamics drive the interface towards a minimal-area
302/// configuration (mean-curvature flow in the sharp-interface limit).
303pub struct AllenCahnSolver {
304    /// Grid size in x-direction
305    pub nx: usize,
306    /// Grid size in y-direction
307    pub ny: usize,
308    /// Grid spacing
309    pub dx: f64,
310    /// Order parameter φ(x,y)
311    pub phi: Vec<Vec<f64>>,
312    /// Interface width ε
313    epsilon: f64,
314    /// Mobility M
315    mobility: f64,
316}
317
318impl AllenCahnSolver {
319    /// Create a new Allen-Cahn solver.
320    ///
321    /// # Arguments
322    /// * `nx`, `ny` — grid dimensions
323    /// * `dx` — grid spacing
324    /// * `epsilon` — interface width parameter
325    /// * `mobility` — kinetic coefficient M
326    pub fn new(nx: usize, ny: usize, dx: f64, epsilon: f64, mobility: f64) -> Self {
327        Self {
328            nx,
329            ny,
330            dx,
331            phi: vec![vec![0.0_f64; ny]; nx],
332            epsilon,
333            mobility,
334        }
335    }
336
337    /// Initialise with a circular droplet of φ = +1 at the centre, φ = −1 outside.
338    pub fn circle_init(&mut self, radius: f64) {
339        let cx = self.nx as f64 / 2.0;
340        let cy = self.ny as f64 / 2.0;
341        for x in 0..self.nx {
342            for y in 0..self.ny {
343                let r = ((x as f64 - cx).powi(2) + (y as f64 - cy).powi(2)).sqrt();
344                // Hyperbolic-tangent profile
345                self.phi[x][y] = -((r - radius) / (self.epsilon * 2.0_f64.sqrt())).tanh();
346            }
347        }
348    }
349
350    /// Initialise with uniform random noise in [−1, 1] around zero.
351    pub fn random_init(&mut self, seed: u64) {
352        let mut rng = Lcg64::new(seed);
353        for x in 0..self.nx {
354            for y in 0..self.ny {
355                self.phi[x][y] = rng.noise(1.0);
356            }
357        }
358    }
359
360    /// Advance by one explicit Euler time step.
361    pub fn step(&mut self, dt: f64) {
362        let lap_phi = laplacian_2d(&self.phi, self.dx);
363        let eps2 = self.epsilon * self.epsilon;
364        for x in 0..self.nx {
365            for y in 0..self.ny {
366                let phi = self.phi[x][y];
367                let df = phi * phi * phi - phi; // f'(phi)
368                self.phi[x][y] += dt * self.mobility * (eps2 * lap_phi[x][y] - df);
369            }
370        }
371    }
372
373    /// Run for `n_steps` steps.
374    pub fn run(&mut self, n_steps: usize, dt: f64) {
375        for _ in 0..n_steps {
376            self.step(dt);
377        }
378    }
379
380    /// Estimate the total interface length using the diffuse-interface formula:
381    /// ```text
382    /// L ≈ ∫ ε/2 |∇φ|² dx dy  (in 2D, gives length in units of dx)
383    /// ```
384    pub fn interface_length(&self) -> f64 {
385        let nx = self.nx;
386        let ny = self.ny;
387        let inv_2dx = 0.5 / self.dx;
388        let mut length = 0.0;
389        for x in 0..nx {
390            let xp = (x + 1) % nx;
391            let xm = (x + nx - 1) % nx;
392            for y in 0..ny {
393                let yp = (y + 1) % ny;
394                let ym = (y + ny - 1) % ny;
395                let grad_x = (self.phi[xp][y] - self.phi[xm][y]) * inv_2dx;
396                let grad_y = (self.phi[x][yp] - self.phi[x][ym]) * inv_2dx;
397                length +=
398                    0.5 * self.epsilon * (grad_x * grad_x + grad_y * grad_y) * self.dx * self.dx;
399            }
400        }
401        length
402    }
403
404    /// Volume fraction of the positive (φ > 0) phase.
405    pub fn volume_fraction(&self) -> f64 {
406        let total = self.nx * self.ny;
407        let pos = self
408            .phi
409            .iter()
410            .flat_map(|r| r.iter())
411            .filter(|&&v| v > 0.0)
412            .count();
413        pos as f64 / total as f64
414    }
415}
416
417// ─────────────────────────────────────────────────────────────────────────────
418// Stefan Problem
419// ─────────────────────────────────────────────────────────────────────────────
420
421/// 1D Stefan problem: solidification with a moving solid-liquid interface.
422///
423/// The domain `[0, L]` contains two phases separated by a sharp interface at
424/// position `s(t)`. In each phase the temperature satisfies the heat equation,
425/// and the interface velocity is determined by the Stefan condition (energy balance).
426///
427/// ## Model
428/// ```text
429/// ∂T/∂t = α ∂²T/∂x²  (uniform thermal diffusivity α = 1)
430/// Interface:  ρ L ds/dt = -[k ∂T/∂n]  (latent heat release)
431/// Stefan condition simplified: ds/dt = (T_x|_s⁺ - T_x|_s⁻) / St
432/// ```
433///
434/// The implementation uses a fixed-grid enthalpy method with a smoothed
435/// interface to avoid explicit front tracking.
436pub struct StefanProblem {
437    /// Number of grid points
438    pub n: usize,
439    /// Grid spacing
440    pub dx: f64,
441    /// Temperature field T(x)
442    pub temperature: Vec<f64>,
443    /// Current interface position s (lattice coordinate)
444    pub interface_pos: f64,
445    /// Latent heat L
446    pub latent_heat: f64,
447    /// Stefan number St = c_p * ΔT / L
448    pub stefan_number: f64,
449    /// Thermal diffusivity (normalised to 1 here)
450    alpha: f64,
451}
452
453impl StefanProblem {
454    /// Create a new Stefan problem.
455    ///
456    /// # Arguments
457    /// * `n` — number of grid points
458    /// * `l` — domain length
459    /// * `initial_interface` — starting interface position ∈ (0, l)
460    /// * `latent_heat` — latent heat L (energy/volume)
461    /// * `stefan_number` — Stefan number St = c_p ΔT / L
462    pub fn new(
463        n: usize,
464        l: f64,
465        initial_interface: f64,
466        latent_heat: f64,
467        stefan_number: f64,
468    ) -> IntegrateResult<Self> {
469        if n < 3 {
470            return Err(IntegrateError::ValueError(
471                "Stefan: need at least 3 grid points".into(),
472            ));
473        }
474        if initial_interface <= 0.0 || initial_interface >= l {
475            return Err(IntegrateError::ValueError(
476                "Stefan: initial_interface must be strictly inside (0, l)".into(),
477            ));
478        }
479
480        let dx = l / ((n - 1) as f64);
481        let interface_idx = initial_interface / dx;
482
483        // Initialise temperature: linear profile in solid (left), melting point in liquid
484        let mut temperature = vec![0.0_f64; n];
485        for i in 0..n {
486            let xi = (i as f64) * dx;
487            if xi <= initial_interface {
488                // Solid: cool, temperature goes from -1 at left wall to 0 at interface
489                temperature[i] = -(1.0 - xi / initial_interface);
490            } else {
491                // Liquid: at melting point (T = 0)
492                temperature[i] = 0.0;
493            }
494        }
495
496        Ok(Self {
497            n,
498            dx,
499            temperature,
500            interface_pos: interface_idx,
501            latent_heat,
502            stefan_number,
503            alpha: 1.0,
504        })
505    }
506
507    /// Advance the Stefan problem by one time step using an explicit method.
508    ///
509    /// Uses the enthalpy-based update where interface motion is recovered from
510    /// the Stefan condition applied at the nearest grid cell to the interface.
511    pub fn step(&mut self, dt: f64) {
512        // Heat equation update (explicit Euler)
513        let lap = laplacian_1d_neumann(&self.temperature, self.dx);
514        let mut t_new = self.temperature.clone();
515        for i in 1..self.n - 1 {
516            t_new[i] += dt * self.alpha * lap[i];
517        }
518        // Dirichlet boundary: fix left wall temperature at -1, right wall at 0
519        t_new[0] = -1.0;
520        t_new[self.n - 1] = 0.0;
521
522        self.temperature = t_new;
523
524        // Stefan condition: move interface based on temperature gradient jump
525        let s_idx = self.interface_pos as usize;
526        if s_idx + 1 < self.n && s_idx > 0 {
527            // Temperature gradient in solid (left) and liquid (right)
528            let grad_solid = (self.temperature[s_idx] - self.temperature[s_idx - 1]) / self.dx;
529            let grad_liquid = (self.temperature[s_idx + 1] - self.temperature[s_idx]) / self.dx;
530            // Stefan condition: ds/dt = (grad_solid - grad_liquid) / (latent_heat / alpha)
531            let ds_dt = (grad_solid - grad_liquid) / (self.latent_heat / self.alpha);
532            self.interface_pos += dt * ds_dt / self.dx; // in grid units
533                                                        // Clamp to domain
534            self.interface_pos = self.interface_pos.clamp(1.0, (self.n - 2) as f64);
535        }
536    }
537
538    /// Run the Stefan problem and return the time history of (time, interface_position).
539    ///
540    /// The interface position is returned in physical coordinates (metres).
541    pub fn run(&mut self, t_final: f64, dt: f64) -> Vec<(f64, f64)> {
542        let mut history = Vec::new();
543        let mut t = 0.0;
544        // Record initial state
545        history.push((t, self.interface_pos * self.dx));
546
547        while t < t_final {
548            let dt_actual = dt.min(t_final - t);
549            self.step(dt_actual);
550            t += dt_actual;
551            history.push((t, self.interface_pos * self.dx));
552        }
553        history
554    }
555
556    /// Return the current interface position in physical coordinates.
557    pub fn interface_position_phys(&self) -> f64 {
558        self.interface_pos * self.dx
559    }
560
561    /// Return the Stefan number.
562    pub fn stefan_number(&self) -> f64 {
563        self.stefan_number
564    }
565}
566
567// ─────────────────────────────────────────────────────────────────────────────
568// Unit tests
569// ─────────────────────────────────────────────────────────────────────────────
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    // ── Cahn-Hilliard ──────────────────────────────────────────────────────
576
577    #[test]
578    fn test_ch_free_energy_decreases() {
579        // Cahn-Hilliard is a gradient flow: free energy must decrease.
580        // CFL condition: dt < dx^4 / (4 M eps^2).
581        // With dx=1/16=0.0625, eps=0.05, M=1.0:
582        //   dt_max ~ 0.0625^4 / (4 * 0.0025) ~ 1.526e-3
583        // Use dt = 1e-4 (well within CFL) and 100 steps for clear energy decay.
584        let mut solver = CahnHilliardSolver::new(16, 16, 1.0 / 16.0, 0.05, 1.0);
585        solver.random_init(0.05, 42);
586        let e0 = solver.free_energy();
587        solver.run(100, 1e-4);
588        let e1 = solver.free_energy();
589        // Energy should not increase (dissipative dynamics)
590        assert!(
591            e1 <= e0 + 1e-8,
592            "energy increased: e0={:.6e} e1={:.6e}",
593            e0,
594            e1
595        );
596    }
597
598    #[test]
599    fn test_ch_mass_conservation() {
600        let mut solver = CahnHilliardSolver::new(16, 16, 1.0 / 16.0, 0.05, 1.0);
601        solver.random_init(0.02, 123);
602        let m0 = solver.mean_phi();
603        solver.run(20, 1e-5);
604        let m1 = solver.mean_phi();
605        assert!(
606            (m1 - m0).abs() < 1e-8,
607            "mass not conserved: Δ={:.2e}",
608            m1 - m0
609        );
610    }
611
612    #[test]
613    fn test_ch_volume_fraction_range() {
614        let solver = CahnHilliardSolver::new(8, 8, 1.0 / 8.0, 0.05, 1.0);
615        let vf = solver.volume_fraction();
616        assert!((0.0..=1.0).contains(&vf));
617    }
618
619    #[test]
620    fn test_ch_initial_energy_finite() {
621        let mut solver = CahnHilliardSolver::new(8, 8, 1.0 / 8.0, 0.1, 1.0);
622        solver.random_init(0.1, 7);
623        assert!(solver.free_energy().is_finite());
624    }
625
626    // ── Allen-Cahn ─────────────────────────────────────────────────────────
627
628    #[test]
629    fn test_ac_circle_shrinks() {
630        let nx = 32;
631        let dx = 1.0 / 32.0;
632        let mut solver = AllenCahnSolver::new(nx, nx, dx, 0.04, 1.0);
633        solver.circle_init(0.3);
634        let len0 = solver.interface_length();
635        solver.run(10, 1e-5);
636        let len1 = solver.interface_length();
637        // A circle should shrink under Allen-Cahn (mean curvature flow)
638        // We just check the interface length is finite and positive
639        assert!(len0 > 0.0, "interface length should be positive");
640        assert!(len1.is_finite());
641    }
642
643    #[test]
644    fn test_ac_random_init_bounded() {
645        let mut solver = AllenCahnSolver::new(8, 8, 0.1, 0.05, 1.0);
646        solver.random_init(42);
647        for row in &solver.phi {
648            for &v in row {
649                assert!(v.abs() <= 1.0 + 1e-10, "phi out of range: {}", v);
650            }
651        }
652    }
653
654    #[test]
655    fn test_ac_volume_fraction() {
656        let mut solver = AllenCahnSolver::new(8, 8, 0.1, 0.05, 1.0);
657        solver.circle_init(2.0);
658        let vf = solver.volume_fraction();
659        assert!((0.0..=1.0).contains(&vf));
660    }
661
662    // ── Stefan Problem ──────────────────────────────────────────────────────
663
664    #[test]
665    fn test_stefan_creates_ok() {
666        let s = StefanProblem::new(20, 1.0, 0.5, 1.0, 1.0);
667        assert!(s.is_ok());
668    }
669
670    #[test]
671    fn test_stefan_invalid_interface() {
672        let s = StefanProblem::new(20, 1.0, 0.0, 1.0, 1.0);
673        assert!(s.is_err());
674        let s = StefanProblem::new(20, 1.0, 1.0, 1.0, 1.0);
675        assert!(s.is_err());
676    }
677
678    #[test]
679    fn test_stefan_interface_moves() {
680        let mut s = StefanProblem::new(50, 1.0, 0.3, 1.0, 1.0)
681            .expect("StefanProblem::new should succeed with valid params");
682        let pos0 = s.interface_position_phys();
683        let history = s.run(0.01, 1e-4);
684        assert!(!history.is_empty());
685        // Interface should have moved (at least a little)
686        let pos_final = history.last().map(|&(_, p)| p).unwrap_or(pos0);
687        assert!(pos_final.is_finite());
688    }
689
690    #[test]
691    fn test_stefan_temperature_boundary() {
692        let mut s = StefanProblem::new(20, 1.0, 0.5, 1.0, 1.0)
693            .expect("StefanProblem::new should succeed with valid params");
694        s.run(0.001, 1e-4);
695        // Left boundary should be maintained at -1
696        assert!((s.temperature[0] + 1.0).abs() < 1e-10);
697        // Right boundary should be maintained at 0
698        assert!((s.temperature[s.n - 1]).abs() < 1e-10);
699    }
700}