Skip to main content

numra_pde/
moving.rs

1//! Moving boundary infrastructure for problems like the Stefan problem.
2//!
3//! Supports:
4//! - Fixed and moving boundaries
5//! - Stefan condition (phase change)
6//! - Coordinate transforms to fixed computational domain
7//!
8//! Author: Moussa Leblouba
9//! Date: 2 February 2026
10//! Modified: 2 May 2026
11
12use numra_core::Scalar;
13
14/// 1D domain with possibly moving boundaries.
15#[derive(Clone, Debug)]
16pub struct Domain1D<S: Scalar> {
17    /// Left boundary
18    pub left: Bound<S>,
19    /// Right boundary
20    pub right: Bound<S>,
21    /// Number of grid points
22    pub n_points: usize,
23}
24
25impl<S: Scalar> Domain1D<S> {
26    /// Create a fixed domain.
27    pub fn fixed(x_min: S, x_max: S, n_points: usize) -> Self {
28        Self {
29            left: Bound::Fixed(x_min),
30            right: Bound::Fixed(x_max),
31            n_points,
32        }
33    }
34
35    /// Create a domain with moving right boundary (common for Stefan problems).
36    pub fn with_moving_right(x_min: S, s_initial: S, n_points: usize) -> Self {
37        Self {
38            left: Bound::Fixed(x_min),
39            right: Bound::Moving(MovingBound::new(s_initial)),
40            n_points,
41        }
42    }
43
44    /// Get current domain bounds.
45    pub fn bounds(&self) -> (S, S) {
46        (self.left.position(), self.right.position())
47    }
48
49    /// Get current domain length.
50    pub fn length(&self) -> S {
51        let (lo, hi) = self.bounds();
52        hi - lo
53    }
54
55    /// Generate grid points for current domain configuration.
56    pub fn grid_points(&self) -> Vec<S> {
57        let (lo, hi) = self.bounds();
58        let n = self.n_points;
59        (0..n)
60            .map(|i| lo + (hi - lo) * S::from_usize(i) / S::from_usize(n - 1))
61            .collect()
62    }
63
64    /// Get uniform grid spacing.
65    pub fn dx(&self) -> S {
66        self.length() / S::from_usize(self.n_points - 1)
67    }
68}
69
70/// A boundary that can be fixed or moving.
71#[derive(Clone, Debug)]
72pub enum Bound<S: Scalar> {
73    /// Fixed boundary at constant position.
74    Fixed(S),
75    /// Moving boundary.
76    Moving(MovingBound<S>),
77}
78
79impl<S: Scalar> Bound<S> {
80    /// Get current position.
81    pub fn position(&self) -> S {
82        match self {
83            Bound::Fixed(x) => *x,
84            Bound::Moving(mb) => mb.position,
85        }
86    }
87
88    /// Check if boundary is fixed.
89    pub fn is_fixed(&self) -> bool {
90        matches!(self, Bound::Fixed(_))
91    }
92
93    /// Check if boundary is moving.
94    pub fn is_moving(&self) -> bool {
95        matches!(self, Bound::Moving(_))
96    }
97
98    /// Update position (only for moving boundaries).
99    pub fn set_position(&mut self, new_pos: S) {
100        if let Bound::Moving(mb) = self {
101            mb.position = new_pos;
102        }
103    }
104}
105
106/// Moving boundary specification.
107#[derive(Clone, Debug)]
108pub struct MovingBound<S: Scalar> {
109    /// Current position
110    pub position: S,
111    /// Stefan condition if applicable
112    pub stefan: Option<StefanCondition<S>>,
113}
114
115impl<S: Scalar> MovingBound<S> {
116    /// Create a moving boundary at given position.
117    pub fn new(position: S) -> Self {
118        Self {
119            position,
120            stefan: None,
121        }
122    }
123
124    /// Create a moving boundary with Stefan condition.
125    pub fn with_stefan(position: S, stefan: StefanCondition<S>) -> Self {
126        Self {
127            position,
128            stefan: Some(stefan),
129        }
130    }
131}
132
133/// Stefan condition: ds/dt = -κ ∂u/∂n at the moving boundary.
134///
135/// This models phase change where:
136/// - ρL ds/dt = -k ∂T/∂x (energy balance)
137/// - κ = k / (ρL) is the Stefan coefficient
138#[derive(Clone, Debug)]
139pub struct StefanCondition<S: Scalar> {
140    /// Stefan coefficient κ = k / (ρL)
141    /// where k = thermal conductivity, ρ = density, L = latent heat
142    pub coefficient: S,
143}
144
145impl<S: Scalar> StefanCondition<S> {
146    /// Create a Stefan condition with given coefficient.
147    pub fn new(coefficient: S) -> Self {
148        Self { coefficient }
149    }
150
151    /// Compute boundary velocity from flux.
152    ///
153    /// ds/dt = -κ * ∂u/∂n
154    /// where ∂u/∂n is the normal derivative (positive outward)
155    pub fn velocity(&self, flux: S) -> S {
156        -self.coefficient * flux
157    }
158}
159
160/// Coordinate transformation for moving boundary problems.
161///
162/// Maps physical domain [0, s(t)] to fixed computational domain [0, 1].
163/// This avoids remeshing as the boundary moves.
164#[derive(Clone)]
165pub struct CoordinateTransform<S: Scalar> {
166    /// Current boundary position
167    pub s: S,
168}
169
170impl<S: Scalar> CoordinateTransform<S> {
171    /// Create a transform for domain [0, s].
172    pub fn new(s: S) -> Self {
173        Self { s }
174    }
175
176    /// Update boundary position.
177    pub fn update(&mut self, s: S) {
178        self.s = s;
179    }
180
181    /// Transform physical coordinate x to computational ξ.
182    /// ξ = x / s(t)
183    pub fn physical_to_computational(&self, x: S) -> S {
184        x / self.s
185    }
186
187    /// Transform computational coordinate ξ to physical x.
188    /// x = ξ * s(t)
189    pub fn computational_to_physical(&self, xi: S) -> S {
190        xi * self.s
191    }
192
193    /// Jacobian of the transform: ∂x/∂ξ = s
194    pub fn jacobian(&self) -> S {
195        self.s
196    }
197
198    /// Transform first derivative: ∂u/∂x = (1/s) ∂u/∂ξ
199    pub fn transform_d1(&self, du_dxi: S) -> S {
200        du_dxi / self.s
201    }
202
203    /// Transform second derivative: ∂²u/∂x² = (1/s²) ∂²u/∂ξ²
204    pub fn transform_d2(&self, d2u_dxi2: S) -> S {
205        d2u_dxi2 / (self.s * self.s)
206    }
207
208    /// Additional convective term from moving boundary.
209    ///
210    /// When using transformed coordinates, the heat equation becomes:
211    /// ∂u/∂t = α/s² ∂²u/∂ξ² + (ds/dt * ξ / s) ∂u/∂ξ
212    ///
213    /// This returns the coefficient for the convective term.
214    pub fn convection_coefficient(&self, dsdt: S, xi: S) -> S {
215        dsdt * xi / self.s
216    }
217}
218
219/// Field state for moving boundary problems.
220///
221/// Contains the solution field along with boundary information.
222#[derive(Clone)]
223pub struct FieldState1D<S: Scalar> {
224    /// Solution values at grid points
225    pub values: Vec<S>,
226    /// Domain specification
227    pub domain: Domain1D<S>,
228}
229
230impl<S: Scalar> FieldState1D<S> {
231    /// Create a new field state.
232    pub fn new(values: Vec<S>, domain: Domain1D<S>) -> Self {
233        assert_eq!(values.len(), domain.n_points);
234        Self { values, domain }
235    }
236
237    /// Get value at index i.
238    pub fn get(&self, i: usize) -> S {
239        self.values[i]
240    }
241
242    /// Set value at index i.
243    pub fn set(&mut self, i: usize, value: S) {
244        self.values[i] = value;
245    }
246
247    /// Compute boundary flux at left boundary.
248    /// Uses second-order one-sided difference.
249    pub fn flux_left(&self) -> S {
250        let dx = self.domain.dx();
251        let v = &self.values;
252        let n = v.len();
253        if n < 3 {
254            return (v[1] - v[0]) / dx;
255        }
256        (S::from_f64(-3.0) * v[0] + S::from_f64(4.0) * v[1] - v[2]) / (S::from_f64(2.0) * dx)
257    }
258
259    /// Compute boundary flux at right boundary.
260    /// Uses second-order one-sided difference.
261    pub fn flux_right(&self) -> S {
262        let dx = self.domain.dx();
263        let v = &self.values;
264        let n = v.len();
265        if n < 3 {
266            return (v[n - 1] - v[n - 2]) / dx;
267        }
268        (S::from_f64(3.0) * v[n - 1] - S::from_f64(4.0) * v[n - 2] + v[n - 3])
269            / (S::from_f64(2.0) * dx)
270    }
271
272    /// Evaluate field at arbitrary point using linear interpolation.
273    pub fn evaluate(&self, x: S) -> S {
274        let points = self.domain.grid_points();
275        let (lo, hi) = self.domain.bounds();
276
277        if x <= lo {
278            return self.values[0];
279        }
280        if x >= hi {
281            return *self.values.last().unwrap();
282        }
283
284        // Find bracketing indices
285        let dx = self.domain.dx();
286        let i = ((x - lo) / dx).to_f64().floor() as usize;
287        let i = i.min(self.values.len() - 2);
288
289        let x0 = points[i];
290        let x1 = points[i + 1];
291        let t = (x - x0) / (x1 - x0);
292
293        self.values[i] * (S::ONE - t) + self.values[i + 1] * t
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_fixed_domain() {
303        let domain = Domain1D::fixed(0.0_f64, 1.0, 11);
304        assert!((domain.length() - 1.0).abs() < 1e-10);
305        assert!((domain.dx() - 0.1).abs() < 1e-10);
306
307        let points = domain.grid_points();
308        assert_eq!(points.len(), 11);
309        assert!((points[0]).abs() < 1e-10);
310        assert!((points[10] - 1.0).abs() < 1e-10);
311    }
312
313    #[test]
314    fn test_moving_domain() {
315        let mut domain = Domain1D::with_moving_right(0.0_f64, 0.5, 11);
316        assert!((domain.length() - 0.5).abs() < 1e-10);
317
318        // Move boundary
319        domain.right.set_position(0.8);
320        assert!((domain.length() - 0.8).abs() < 1e-10);
321    }
322
323    #[test]
324    fn test_stefan_condition() {
325        let stefan = StefanCondition::new(0.1_f64);
326
327        // flux = 10 (into the domain) → ds/dt = -0.1 * 10 = -1 (boundary retracts)
328        let velocity = stefan.velocity(10.0);
329        assert!((velocity - (-1.0)).abs() < 1e-10);
330
331        // flux = -5 (out of domain) → ds/dt = -0.1 * (-5) = 0.5 (boundary advances)
332        let velocity = stefan.velocity(-5.0);
333        assert!((velocity - 0.5).abs() < 1e-10);
334    }
335
336    #[test]
337    fn test_coordinate_transform() {
338        let transform = CoordinateTransform::new(2.0_f64);
339
340        // Physical to computational: x = 1 → ξ = 0.5
341        let xi = transform.physical_to_computational(1.0);
342        assert!((xi - 0.5).abs() < 1e-10);
343
344        // Computational to physical: ξ = 0.5 → x = 1
345        let x = transform.computational_to_physical(0.5);
346        assert!((x - 1.0).abs() < 1e-10);
347
348        // Jacobian
349        assert!((transform.jacobian() - 2.0).abs() < 1e-10);
350
351        // Derivative transforms
352        // ∂u/∂x = (1/2) ∂u/∂ξ
353        let du_dx = transform.transform_d1(4.0);
354        assert!((du_dx - 2.0).abs() < 1e-10);
355
356        // ∂²u/∂x² = (1/4) ∂²u/∂ξ²
357        let d2u_dx2 = transform.transform_d2(8.0);
358        assert!((d2u_dx2 - 2.0).abs() < 1e-10);
359    }
360
361    #[test]
362    fn test_field_state_flux() {
363        // Linear field: u = x on [0, 1]
364        let domain = Domain1D::fixed(0.0_f64, 1.0, 11);
365        let values: Vec<f64> = (0..11).map(|i| i as f64 * 0.1).collect();
366        let field = FieldState1D::new(values, domain);
367
368        // For u = x, du/dx = 1 everywhere
369        let flux_left = field.flux_left();
370        let flux_right = field.flux_right();
371
372        assert!((flux_left - 1.0).abs() < 0.01);
373        assert!((flux_right - 1.0).abs() < 0.01);
374    }
375
376    #[test]
377    fn test_field_evaluate() {
378        let domain = Domain1D::fixed(0.0_f64, 1.0, 11);
379        let values: Vec<f64> = (0..11).map(|i| i as f64 * 0.1).collect();
380        let field = FieldState1D::new(values, domain);
381
382        // Evaluate at grid points
383        assert!((field.evaluate(0.0) - 0.0).abs() < 1e-10);
384        assert!((field.evaluate(0.5) - 0.5).abs() < 1e-10);
385        assert!((field.evaluate(1.0) - 1.0).abs() < 1e-10);
386
387        // Evaluate between grid points
388        assert!((field.evaluate(0.25) - 0.25).abs() < 0.01);
389        assert!((field.evaluate(0.75) - 0.75).abs() < 0.01);
390    }
391}