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    /// What this field is measured in — `"Pa"`, `"C"`, `"V"`.
38    ///
39    /// The fifth thing a layer above had to know a domain by name to find out. A legend, an axis
40    /// and a CSV header all need it, and none of them should have to match on domain types to
41    /// get a two-character string.
42    ///
43    /// Defaults to `""`, which renders as a quantity with no unit — visibly odd rather than
44    /// quietly wrong, which is the best a default can do here.
45    fn unit(&self) -> &'static str {
46        ""
47    }
48
49    /// ∇f, by central differences over `h`. Units are the field's per metre.
50    fn gradient(&self, p: LengthVec, t: Time, h: Length) -> DVec3 {
51        let step = h.to_si();
52        let mut out = DVec3::ZERO;
53        for axis in 0..3 {
54            let mut d = DVec3::ZERO;
55            d[axis] = step;
56            let plus = self.at(p + LengthVec::from_si(d), t);
57            let minus = self.at(p - LengthVec::from_si(d), t);
58            out[axis] = (plus - minus) / (2.0 * step);
59        }
60        out
61    }
62
63    /// ∇²f — the operator that makes diffusion diffuse. A point hotter than the
64    /// average of its neighbours has a negative Laplacian and cools; that is the
65    /// whole content of the heat equation.
66    fn laplacian(&self, p: LengthVec, t: Time, h: Length) -> f64 {
67        let step = h.to_si();
68        let centre = self.at(p, t);
69        let mut sum = 0.0;
70        for axis in 0..3 {
71            let mut d = DVec3::ZERO;
72            d[axis] = step;
73            sum += self.at(p + LengthVec::from_si(d), t) + self.at(p - LengthVec::from_si(d), t)
74                - 2.0 * centre;
75        }
76        sum / (step * step)
77    }
78
79    /// ∂f/∂t, by a central difference in time.
80    fn rate(&self, p: LengthVec, t: Time, dt: Time) -> f64 {
81        (self.at(p, t + dt) - self.at(p, t - dt)) / (2.0 * dt.to_si())
82    }
83}
84
85/// A vector field: velocity, force per volume, electric field, heat flux.
86pub trait VectorField {
87    /// The value at a place and time, in this field's SI base unit.
88    fn at(&self, p: LengthVec, t: Time) -> DVec3;
89
90    /// ∇·F — net outflow per unit volume. Zero everywhere means nothing is being
91    /// created or destroyed, which is how a conservation law is written locally.
92    fn divergence(&self, p: LengthVec, t: Time, h: Length) -> f64 {
93        let step = h.to_si();
94        let mut sum = 0.0;
95        for axis in 0..3 {
96            let mut d = DVec3::ZERO;
97            d[axis] = step;
98            sum += (self.at(p + LengthVec::from_si(d), t)[axis]
99                - self.at(p - LengthVec::from_si(d), t)[axis])
100                / (2.0 * step);
101        }
102        sum
103    }
104
105    /// ∇×F — local rotation. Zero everywhere means the field is a gradient of
106    /// something, which is why an electrostatic field has a potential and a
107    /// magnetic one does not.
108    fn curl(&self, p: LengthVec, t: Time, h: Length) -> DVec3 {
109        let step = h.to_si();
110        let d = |axis: usize| {
111            let mut e = DVec3::ZERO;
112            e[axis] = step;
113            let plus = self.at(p + LengthVec::from_si(e), t);
114            let minus = self.at(p - LengthVec::from_si(e), t);
115            (plus - minus) / (2.0 * step)
116        };
117        let (dx, dy, dz) = (d(0), d(1), d(2));
118        DVec3::new(dy.z - dz.y, dz.x - dx.z, dx.y - dy.x)
119    }
120}
121
122/// The same value everywhere and always. Useful as an ambient condition, and as
123/// the thing a finite-difference test should return zero derivatives for.
124#[derive(Clone, Copy, Debug, PartialEq)]
125pub struct Uniform(pub f64);
126
127impl ScalarField for Uniform {
128    fn at(&self, _p: LengthVec, _t: Time) -> f64 {
129        self.0
130    }
131}
132
133/// A field from a closure, for the common case where the physics is a formula.
134pub struct Analytic<F>(pub F);
135
136impl<F: Fn(LengthVec, Time) -> f64> ScalarField for Analytic<F> {
137    fn at(&self, p: LengthVec, t: Time) -> f64 {
138        (self.0)(p, t)
139    }
140}
141
142impl<F: Fn(LengthVec, Time) -> DVec3> VectorField for Analytic<F> {
143    fn at(&self, p: LengthVec, t: Time) -> DVec3 {
144        (self.0)(p, t)
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    const H: Length = Length::from_si(1e-4);
153
154    /// Checked against closed forms, not against another discretisation: for
155    /// f = x² + 2y² + 3z², ∇f = (2x, 4y, 6z) and ∇²f = 2 + 4 + 6 = 12, exactly,
156    /// everywhere.
157    #[test]
158    fn derivatives_match_the_closed_form() {
159        let f = Analytic(|p: LengthVec, _t: Time| {
160            let v = p.to_si();
161            v.x * v.x + 2.0 * v.y * v.y + 3.0 * v.z * v.z
162        });
163        let p = LengthVec::m(0.3, -0.2, 0.5);
164        let t = Time::ZERO;
165
166        let grad = f.gradient(p, t, H);
167        let expected = DVec3::new(0.6, -0.8, 3.0);
168        assert!((grad - expected).length() < 1e-9, "got {grad}");
169
170        // A quadratic's second difference is exact, so this is a tight check.
171        let lap = f.laplacian(p, t, H);
172        assert!((lap - 12.0).abs() < 1e-6, "got {lap}");
173    }
174
175    /// Divergence and curl, against a field whose answers are known: a rigid
176    /// rotation about z has zero divergence and a curl of exactly 2ω ẑ.
177    #[test]
178    fn divergence_and_curl_match_the_closed_form() {
179        let omega = 3.0;
180        let rotation = Analytic(|p: LengthVec, _t: Time| {
181            let v = p.to_si();
182            DVec3::new(-3.0 * v.y, 3.0 * v.x, 0.0)
183        });
184        let p = LengthVec::m(0.4, 0.1, -0.2);
185        let t = Time::ZERO;
186
187        assert!(
188            VectorField::divergence(&rotation, p, t, H).abs() < 1e-9,
189            "a rotation moves fluid around, not outwards"
190        );
191        let curl = rotation.curl(p, t, H);
192        assert!(
193            (curl - DVec3::new(0.0, 0.0, 2.0 * omega)).length() < 1e-9,
194            "got {curl}"
195        );
196    }
197
198    /// A radial outflow has divergence 3 and no curl — the complement of the
199    /// previous test, and the pair distinguishes a sign error from a correct
200    /// implementation.
201    #[test]
202    fn a_radial_field_diverges_without_rotating() {
203        let outflow = Analytic(|p: LengthVec, _t: Time| p.to_si());
204        let p = LengthVec::m(0.2, -0.4, 0.7);
205        let t = Time::ZERO;
206        assert!((VectorField::divergence(&outflow, p, t, H) - 3.0).abs() < 1e-9);
207        assert!(outflow.curl(p, t, H).length() < 1e-9);
208    }
209
210    /// Time derivatives too, and a field that does not move has none.
211    #[test]
212    fn a_uniform_field_has_no_derivatives_of_any_kind() {
213        let u = Uniform(4.2);
214        let p = LengthVec::m(1.0, 2.0, 3.0);
215        assert_eq!(u.at(p, Time::s(9.0)), 4.2);
216        assert_eq!(u.gradient(p, Time::ZERO, H), DVec3::ZERO);
217        assert_eq!(u.laplacian(p, Time::ZERO, H), 0.0);
218        assert_eq!(u.rate(p, Time::ZERO, Time::s(0.1)), 0.0);
219    }
220
221    #[test]
222    fn time_derivatives_match_the_closed_form() {
223        // f = t², so df/dt = 2t.
224        let f = Analytic(|_p: LengthVec, t: Time| t.to_si() * t.to_si());
225        let rate = f.rate(LengthVec::ZERO, Time::s(3.0), Time::s(1e-4));
226        assert!((rate - 6.0).abs() < 1e-9, "got {rate}");
227    }
228}