Skip to main content

dualis_core/
field.rs

1//! Quantities that vary over space and time.
2//!
3//! Continuum physics is fields most of the way down: a temperature field, a
4//! velocity field, a stress field, an electromagnetic field. They differ in what
5//! they hold and in the equation that governs them, but the three operators used
6//! to write those equations — gradient, divergence, Laplacian — are the same
7//! three every time. Implementing them once is most of what a kernel owes a
8//! continuum domain.
9//!
10//! # Values are raw SI numbers, and that is a real limitation
11//!
12//! A [`ScalarField`] returns `f64` and a [`VectorField`] returns `DVec3`, both in
13//! SI base units, rather than the dimensioned types from `dualis-units`. The
14//! dimension varies per field — kelvin here, pascals there — and expressing "the
15//! gradient of this field has the field's dimension divided by a length" needs
16//! arithmetic on const generic parameters, which is unstable. So the dimension
17//! lives on the concrete type that implements the trait, and is documented rather
18//! than checked, at exactly this one boundary.
19//!
20//! # The derivatives are finite differences, and the step matters
21//!
22//! Central differences, second-order accurate, with the step passed in. There is
23//! no good default for it: too large and the truncation error dominates, too small
24//! and cancellation in the subtraction does. For a field with curvature scale `Lc`
25//! and values near 1, `h ≈ Lc * 1e-5` is a reasonable start, and a field that
26//! knows its own analytic derivative should override these methods and skip the
27//! question entirely.
28
29use dualis_units::{Length, LengthVec, Time};
30use glam::DVec3;
31
32/// A scalar field: temperature, pressure, concentration, potential.
33pub trait ScalarField {
34    /// The value at a place and time, in this field's SI base unit.
35    fn at(&self, p: LengthVec, t: Time) -> f64;
36
37    /// ∇f, by central differences over `h`. Units are the field's per metre.
38    fn gradient(&self, p: LengthVec, t: Time, h: Length) -> DVec3 {
39        let step = h.to_si();
40        let mut out = DVec3::ZERO;
41        for axis in 0..3 {
42            let mut d = DVec3::ZERO;
43            d[axis] = step;
44            let plus = self.at(p + LengthVec::from_si(d), t);
45            let minus = self.at(p - LengthVec::from_si(d), t);
46            out[axis] = (plus - minus) / (2.0 * step);
47        }
48        out
49    }
50
51    /// ∇²f — the operator that makes diffusion diffuse. A point hotter than the
52    /// average of its neighbours has a negative Laplacian and cools; that is the
53    /// whole content of the heat equation.
54    fn laplacian(&self, p: LengthVec, t: Time, h: Length) -> f64 {
55        let step = h.to_si();
56        let centre = self.at(p, t);
57        let mut sum = 0.0;
58        for axis in 0..3 {
59            let mut d = DVec3::ZERO;
60            d[axis] = step;
61            sum += self.at(p + LengthVec::from_si(d), t) + self.at(p - LengthVec::from_si(d), t)
62                - 2.0 * centre;
63        }
64        sum / (step * step)
65    }
66
67    /// ∂f/∂t, by a central difference in time.
68    fn rate(&self, p: LengthVec, t: Time, dt: Time) -> f64 {
69        (self.at(p, t + dt) - self.at(p, t - dt)) / (2.0 * dt.to_si())
70    }
71}
72
73/// A vector field: velocity, force per volume, electric field, heat flux.
74pub trait VectorField {
75    /// The value at a place and time, in this field's SI base unit.
76    fn at(&self, p: LengthVec, t: Time) -> DVec3;
77
78    /// ∇·F — net outflow per unit volume. Zero everywhere means nothing is being
79    /// created or destroyed, which is how a conservation law is written locally.
80    fn divergence(&self, p: LengthVec, t: Time, h: Length) -> f64 {
81        let step = h.to_si();
82        let mut sum = 0.0;
83        for axis in 0..3 {
84            let mut d = DVec3::ZERO;
85            d[axis] = step;
86            sum += (self.at(p + LengthVec::from_si(d), t)[axis]
87                - self.at(p - LengthVec::from_si(d), t)[axis])
88                / (2.0 * step);
89        }
90        sum
91    }
92
93    /// ∇×F — local rotation. Zero everywhere means the field is a gradient of
94    /// something, which is why an electrostatic field has a potential and a
95    /// magnetic one does not.
96    fn curl(&self, p: LengthVec, t: Time, h: Length) -> DVec3 {
97        let step = h.to_si();
98        let d = |axis: usize| {
99            let mut e = DVec3::ZERO;
100            e[axis] = step;
101            let plus = self.at(p + LengthVec::from_si(e), t);
102            let minus = self.at(p - LengthVec::from_si(e), t);
103            (plus - minus) / (2.0 * step)
104        };
105        let (dx, dy, dz) = (d(0), d(1), d(2));
106        DVec3::new(dy.z - dz.y, dz.x - dx.z, dx.y - dy.x)
107    }
108}
109
110/// The same value everywhere and always. Useful as an ambient condition, and as
111/// the thing a finite-difference test should return zero derivatives for.
112#[derive(Clone, Copy, Debug, PartialEq)]
113pub struct Uniform(pub f64);
114
115impl ScalarField for Uniform {
116    fn at(&self, _p: LengthVec, _t: Time) -> f64 {
117        self.0
118    }
119}
120
121/// A field from a closure, for the common case where the physics is a formula.
122pub struct Analytic<F>(pub F);
123
124impl<F: Fn(LengthVec, Time) -> f64> ScalarField for Analytic<F> {
125    fn at(&self, p: LengthVec, t: Time) -> f64 {
126        (self.0)(p, t)
127    }
128}
129
130impl<F: Fn(LengthVec, Time) -> DVec3> VectorField for Analytic<F> {
131    fn at(&self, p: LengthVec, t: Time) -> DVec3 {
132        (self.0)(p, t)
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    const H: Length = Length::from_si(1e-4);
141
142    /// Checked against closed forms, not against another discretisation: for
143    /// f = x² + 2y² + 3z², ∇f = (2x, 4y, 6z) and ∇²f = 2 + 4 + 6 = 12, exactly,
144    /// everywhere.
145    #[test]
146    fn derivatives_match_the_closed_form() {
147        let f = Analytic(|p: LengthVec, _t: Time| {
148            let v = p.to_si();
149            v.x * v.x + 2.0 * v.y * v.y + 3.0 * v.z * v.z
150        });
151        let p = LengthVec::m(0.3, -0.2, 0.5);
152        let t = Time::ZERO;
153
154        let grad = f.gradient(p, t, H);
155        let expected = DVec3::new(0.6, -0.8, 3.0);
156        assert!((grad - expected).length() < 1e-9, "got {grad}");
157
158        // A quadratic's second difference is exact, so this is a tight check.
159        let lap = f.laplacian(p, t, H);
160        assert!((lap - 12.0).abs() < 1e-6, "got {lap}");
161    }
162
163    /// Divergence and curl, against a field whose answers are known: a rigid
164    /// rotation about z has zero divergence and a curl of exactly 2ω ẑ.
165    #[test]
166    fn divergence_and_curl_match_the_closed_form() {
167        let omega = 3.0;
168        let rotation = Analytic(|p: LengthVec, _t: Time| {
169            let v = p.to_si();
170            DVec3::new(-3.0 * v.y, 3.0 * v.x, 0.0)
171        });
172        let p = LengthVec::m(0.4, 0.1, -0.2);
173        let t = Time::ZERO;
174
175        assert!(
176            VectorField::divergence(&rotation, p, t, H).abs() < 1e-9,
177            "a rotation moves fluid around, not outwards"
178        );
179        let curl = rotation.curl(p, t, H);
180        assert!(
181            (curl - DVec3::new(0.0, 0.0, 2.0 * omega)).length() < 1e-9,
182            "got {curl}"
183        );
184    }
185
186    /// A radial outflow has divergence 3 and no curl — the complement of the
187    /// previous test, and the pair distinguishes a sign error from a correct
188    /// implementation.
189    #[test]
190    fn a_radial_field_diverges_without_rotating() {
191        let outflow = Analytic(|p: LengthVec, _t: Time| p.to_si());
192        let p = LengthVec::m(0.2, -0.4, 0.7);
193        let t = Time::ZERO;
194        assert!((VectorField::divergence(&outflow, p, t, H) - 3.0).abs() < 1e-9);
195        assert!(outflow.curl(p, t, H).length() < 1e-9);
196    }
197
198    /// Time derivatives too, and a field that does not move has none.
199    #[test]
200    fn a_uniform_field_has_no_derivatives_of_any_kind() {
201        let u = Uniform(4.2);
202        let p = LengthVec::m(1.0, 2.0, 3.0);
203        assert_eq!(u.at(p, Time::s(9.0)), 4.2);
204        assert_eq!(u.gradient(p, Time::ZERO, H), DVec3::ZERO);
205        assert_eq!(u.laplacian(p, Time::ZERO, H), 0.0);
206        assert_eq!(u.rate(p, Time::ZERO, Time::s(0.1)), 0.0);
207    }
208
209    #[test]
210    fn time_derivatives_match_the_closed_form() {
211        // f = t², so df/dt = 2t.
212        let f = Analytic(|_p: LengthVec, t: Time| t.to_si() * t.to_si());
213        let rate = f.rate(LengthVec::ZERO, Time::s(3.0), Time::s(1e-4));
214        assert!((rate - 6.0).abs() < 1e-9, "got {rate}");
215    }
216}