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}