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}