Skip to main content

pantometry_electrical/
conductor.rs

1//! Current as a field, so `I²R` is a consequence of a shape rather than a number somebody typed.
2//!
3//! [`Winding`](crate::Winding) states its resistance from `ρL/A` and dissipates `I²R`. That is
4//! exactly right for a wire, which *is* a uniform bar, and it is the model a motor designer
5//! actually uses. It is also the whole of what a lumped electrical model can say: given `R`, it
6//! returns `I²R`, and the interesting question — where the heat is, and why `R` is what it is —
7//! is assumed rather than answered.
8//!
9//! A [`Conductor`] is the other half. It is a block of material with a conductivity per cell and
10//! two electrodes, and it **solves** for the potential:
11//!
12//! ```text
13//!   ∇·(σ ∇φ) = 0        inside
14//!   φ = 0, φ = V        on the two electrodes
15//!   J·n = 0             everywhere else
16//! ```
17//!
18//! From that, `J = −σ∇φ` and the dissipation is `∫ σ|∇φ|² dV`. Nobody states a resistance; it
19//! comes out, and for a uniform bar it comes out as `ρL/A` **exactly** — which is what makes this
20//! checkable rather than merely plausible.
21//!
22//! # What the field formulation buys
23//!
24//! Three things a lumped resistor cannot say:
25//!
26//! - **A shape that is not a bar has no `ρL/A`.** A constriction, a via, a busbar with a corner,
27//!   a contact patch — each has a resistance that is a property of its geometry, and the closed
28//!   forms that exist for them (spreading resistance is `ρ/4a` for a circular contact into a
29//!   half-space) are limits rather than formulas you can apply to a shape.
30//! - **Where the heat is.** `I²R` gives a total. A current crowding into a corner dissipates in
31//!   that corner, and a joint fails at the hot spot rather than at the average. This domain hands
32//!   the density over as a field, so a thermal domain on the other side of the bus can take it
33//!   *where it landed* rather than as a lump.
34//! - **Series and parallel are consequences, not cases.** Two materials in series add their
35//!   resistances and two side by side add their conductances, and neither is coded — both fall
36//!   out of the same solve. That is what the tests check, because a formulation that got one of
37//!   them wrong would still look like electricity.
38//!
39//! # Quasi-static, and why the solve is not a march
40//!
41//! This is [`Kind::QuasiStatic`]. Charge relaxes in a metal in about `ε/σ` — 1.5×10⁻¹⁹ s for
42//! copper — so on any timescale a simulation cares about the current distribution is the
43//! *solution* of an elliptic problem and not the state of a marched one. Nothing here has a
44//! stability limit, and the step is a solve rather than an advance.
45//!
46//! # The solve, and what it refuses to do quietly
47//!
48//! Conjugate gradients on the symmetric positive-definite system the finite-volume
49//! discretisation gives. Deterministic: a fixed iteration order, a fixed starting vector, no
50//! threads, no clock. [`Conductor::residual`] reports what it achieved and
51//! [`Conductor::converged`] whether it met the tolerance — and a step that did **not** converge
52//! returns a [`Violation`] rather than a plausible-looking potential field.
53//!
54//! That last one is deliberate and is the failure this workspace keeps finding. An iterative
55//! solver that stops at its iteration cap returns something shaped exactly like an answer: smooth,
56//! bounded, roughly right in the middle and wrong at the edges. Nothing downstream can tell.
57
58use glam::DVec3;
59use pantometry_core::conserved::quantity;
60use pantometry_core::{Domain, Exchange, Kind, Ledger, Reading, ScalarField, Violation};
61use pantometry_units::{
62    Conductivity, Current, CurrentDensity, Energy, Length, LengthVec, Power, Resistance,
63    Resistivity, Time, Voltage,
64};
65
66use crate::HEAT;
67
68/// How hard the solver tries before it gives up, as a multiple of the cell count.
69///
70/// Conjugate gradients converges in at most `n` iterations in exact arithmetic; in floating point
71/// it usually needs far fewer and occasionally a few more. Four times the cell count is generous
72/// enough that hitting it means the system is ill-conditioned rather than merely large — an
73/// insulating island, a conductivity ratio of 10¹⁵ — and that is worth reporting rather than
74/// grinding at.
75const ITERATION_BUDGET: usize = 4;
76
77/// A block of conducting material with two electrodes, solved for its potential.
78///
79/// Cells are cubes. The electrodes are the whole `x = 0` and `x = L` faces, held at fixed
80/// potentials; every other surface is insulating, so no current leaves through it. That is the
81/// four-terminal arrangement a resistance is *defined* by, and it is what makes `ρL/A` the exact
82/// answer for a uniform block rather than an approximation to it.
83#[derive(Clone, Debug)]
84pub struct Conductor {
85    name: String,
86    counts: (usize, usize, usize),
87    dx: f64,
88    /// Conductivity per cell, S/m.
89    sigma: Vec<f64>,
90    /// Potential per cell, V. The solve's output.
91    phi: Vec<f64>,
92    drive: f64,
93    residual: f64,
94    converged: bool,
95    dissipated: f64,
96    tolerance: f64,
97    /// Iterations `step` will spend before it refuses. `None` is the default budget.
98    max_iterations: Option<usize>,
99}
100
101impl Conductor {
102    /// A uniform block of `counts` cubic cells of side `dx`, driven by a potential difference
103    /// across its x faces.
104    pub fn new(
105        name: impl Into<String>,
106        counts: (usize, usize, usize),
107        dx: Length,
108        material: Resistivity,
109        drive: Voltage,
110    ) -> Conductor {
111        let counts = (counts.0.max(1), counts.1.max(1), counts.2.max(1));
112        let cells = counts.0 * counts.1 * counts.2;
113        let sigma = material.conductivity().to_si();
114        let mut built = Conductor {
115            name: name.into(),
116            counts,
117            dx: dx.to_si(),
118            sigma: vec![sigma; cells],
119            phi: vec![0.0; cells],
120            drive: drive.to_si(),
121            residual: f64::INFINITY,
122            converged: false,
123            dissipated: 0.0,
124            tolerance: 1e-12,
125            max_iterations: None,
126        };
127        // **Solved at construction**, because a quasi-static domain has no state before its
128        // solve. Leaving it unsolved meant the first captured frame reported a resistance
129        // computed from a potential field of zeros — 24x below the value `ρL/A` puts a floor
130        // under, beside a residual of `inf` that nothing was reading. A number that wrong is
131        // easy to spot; the point is that nothing *stopped* it being reported.
132        //
133        // The mutators below invalidate it. They set `converged` false and `residual` infinite,
134        // so a caller who changes the material and reads the answer without re-solving is told.
135        built.solve(built.tolerance);
136        built
137    }
138
139    /// What [`Domain::step`] asks of the solver, and what it refuses below.
140    ///
141    /// Exposed because a caller with a hard time budget may prefer a bounded solve to an
142    /// unbounded one — and because the refusal path needs to be reachable from a test. A domain
143    /// whose only failure mode cannot be provoked is a domain whose failure mode is untested.
144    pub fn with_solver(mut self, tolerance: f64, max_iterations: usize) -> Conductor {
145        self.tolerance = tolerance;
146        self.max_iterations = Some(max_iterations);
147        self
148    }
149
150    /// Cell counts along x, y and z.
151    pub fn counts(&self) -> (usize, usize, usize) {
152        self.counts
153    }
154
155    /// The cell side.
156    pub fn spacing(&self) -> Length {
157        Length::from_si(self.dx)
158    }
159
160    /// The block's extent — cells times spacing.
161    pub fn size(&self) -> LengthVec {
162        let (nx, ny, nz) = self.counts;
163        LengthVec::from_si(DVec3::new(nx as f64, ny as f64, nz as f64) * self.dx)
164    }
165
166    /// The cross-section the current passes through.
167    pub fn section(&self) -> pantometry_units::Area {
168        let (_, ny, nz) = self.counts;
169        pantometry_units::Area::from_si((ny * nz) as f64 * self.dx * self.dx)
170    }
171
172    /// The potential difference across the electrodes.
173    pub fn drive(&self) -> Voltage {
174        Voltage::from_si(self.drive)
175    }
176
177    /// Give one cell a different material.
178    ///
179    /// The point of the whole domain: a block that is not one material has no `ρL/A`, and this is
180    /// how it stops being one. Out of range is ignored.
181    pub fn set_resistivity(&mut self, i: usize, j: usize, k: usize, material: Resistivity) {
182        if let Some(idx) = self.index(i, j, k) {
183            self.sigma[idx] = material.conductivity().to_si();
184            self.converged = false;
185            self.residual = f64::INFINITY;
186        }
187    }
188
189    /// Give a whole slab of cells one material, by a predicate on cell indices.
190    ///
191    /// The readable way to build a series or parallel arrangement, and the way the closed-form
192    /// tests do it.
193    pub fn set_region(
194        &mut self,
195        mut which: impl FnMut(usize, usize, usize) -> bool,
196        material: Resistivity,
197    ) {
198        let (nx, ny, nz) = self.counts;
199        let sigma = material.conductivity().to_si();
200        for k in 0..nz {
201            for j in 0..ny {
202                for i in 0..nx {
203                    if which(i, j, k) {
204                        self.sigma[i + nx * (j + ny * k)] = sigma;
205                    }
206                }
207            }
208        }
209        self.converged = false;
210        self.residual = f64::INFINITY;
211    }
212
213    /// The flat index of a cell, or `None` out of range.
214    pub fn index(&self, i: usize, j: usize, k: usize) -> Option<usize> {
215        let (nx, ny, nz) = self.counts;
216        (i < nx && j < ny && k < nz).then(|| i + nx * (j + ny * k))
217    }
218
219    /// The potential at one cell centre.
220    pub fn potential_at(&self, i: usize, j: usize, k: usize) -> Voltage {
221        let (nx, ny, nz) = self.counts;
222        let idx = self
223            .index(i.min(nx - 1), j.min(ny - 1), k.min(nz - 1))
224            .expect("clamped indices are in range");
225        Voltage::from_si(self.phi[idx])
226    }
227
228    /// The current density at one cell, by central differences on the potential.
229    ///
230    /// `J = −σ∇φ`. At a cell against an insulating face the normal component is zero by
231    /// construction, because there is no neighbour to differ from.
232    pub fn current_density_at(&self, i: usize, j: usize, k: usize) -> DVec3 {
233        let (nx, ny, nz) = self.counts;
234        let (i, j, k) = (i.min(nx - 1), j.min(ny - 1), k.min(nz - 1));
235        let here = self.index(i, j, k).expect("clamped");
236        let axis = |lo: Option<usize>, hi: Option<usize>| -> f64 {
237            match (lo, hi) {
238                (Some(a), Some(b)) => (self.phi[b] - self.phi[a]) / (2.0 * self.dx),
239                // Against a face: one-sided, over one spacing rather than two.
240                (None, Some(b)) => (self.phi[b] - self.phi[here]) / self.dx,
241                (Some(a), None) => (self.phi[here] - self.phi[a]) / self.dx,
242                (None, None) => 0.0,
243            }
244        };
245        let grad = DVec3::new(
246            axis(
247                i.checked_sub(1).and_then(|a| self.index(a, j, k)),
248                self.index(i + 1, j, k),
249            ),
250            axis(
251                j.checked_sub(1).and_then(|b| self.index(i, b, k)),
252                self.index(i, j + 1, k),
253            ),
254            axis(
255                k.checked_sub(1).and_then(|c| self.index(i, j, c)),
256                self.index(i, j, k + 1),
257            ),
258        );
259        -self.sigma[here] * grad
260    }
261
262    /// The total current through the block, measured at the driven electrode.
263    ///
264    /// Measured rather than derived: the current is the sum of what actually crosses the
265    /// electrode faces in the solved field. A solve that had not converged would report a current
266    /// that disagreed with the one measured at the other electrode, which is exactly the check
267    /// [`Conductor::current_balance`] makes.
268    pub fn current(&self) -> Current {
269        Current::from_si(self.electrode_current(true))
270    }
271
272    /// How much the current in disagrees with the current out, relative to the current itself.
273    ///
274    /// Zero for a converged solve, because charge does not accumulate. This is the number that
275    /// says whether the answer is an answer — and it is measured from the two electrodes
276    /// independently rather than being a residual the solver reports about itself.
277    pub fn current_balance(&self) -> f64 {
278        // In at the driven electrode against out at the grounded one, which is the negative of
279        // what flows *into* the block there.
280        let (a, b) = (self.electrode_current(true), -self.electrode_current(false));
281        let scale = a.abs().max(b.abs());
282        if scale <= 0.0 {
283            0.0
284        } else {
285            (a - b).abs() / scale
286        }
287    }
288
289    /// The resistance the geometry has, `V/I`.
290    ///
291    /// **Not stated anywhere.** For a uniform block this comes out as `ρL/A` to machine
292    /// precision; for anything else it comes out as whatever the shape gives, which is the reason
293    /// the domain exists.
294    pub fn resistance(&self) -> Resistance {
295        let i = self.electrode_current(true);
296        if i.abs() <= 0.0 {
297            return Resistance::from_si(f64::INFINITY);
298        }
299        Resistance::from_si(self.drive / i)
300    }
301
302    /// The power dissipated, `∫σ|∇φ|²dV`, summed over the faces where the gradient actually is.
303    ///
304    /// Computed from the field rather than as `V·I`, so that the two agreeing is a check and not
305    /// a tautology. They agree to machine precision for a converged solve — that is Tellegen's
306    /// theorem, and it is the sharpest single statement about whether the discretisation is
307    /// self-consistent.
308    pub fn dissipation(&self) -> Power {
309        let mut total = 0.0;
310        for (a, b, g) in self.faces() {
311            let dphi = self.phi_of(b) - self.phi_of(a);
312            total += g * dphi * dphi;
313        }
314        Power::from_si(total)
315    }
316
317    /// Energy dissipated over the run.
318    pub fn dissipated_energy(&self) -> Energy {
319        Energy::from_si(self.dissipated)
320    }
321
322    /// Whether the last solve met its tolerance.
323    pub fn converged(&self) -> bool {
324        self.converged
325    }
326
327    /// The relative residual the last solve reached.
328    pub fn residual(&self) -> f64 {
329        self.residual
330    }
331
332    /// Solve for the potential, to a relative residual of `tolerance`.
333    ///
334    /// Conjugate gradients, which is exact for this system in `n` steps in exact arithmetic and
335    /// is symmetric positive definite because the conductances are positive and the coupling is
336    /// symmetric. Deterministic: fixed order, fixed start, no threads.
337    ///
338    /// Returns whether it converged. A caller that ignores the answer gets a field that looks
339    /// like a field, which is why [`Domain::step`] refuses instead.
340    pub fn solve(&mut self, tolerance: f64) -> bool {
341        let budget = self
342            .max_iterations
343            .unwrap_or(ITERATION_BUDGET * self.phi.len() + 32);
344        self.solve_within(tolerance, budget)
345    }
346
347    /// Solve, spending at most `max_iterations`.
348    ///
349    /// Returns whether the tolerance was met. **A `false` here is the whole reason the method
350    /// returns anything**: the potential field left behind is smooth, bounded and shaped exactly
351    /// like an answer, and nothing downstream can tell it from one.
352    pub fn solve_within(&mut self, tolerance: f64, max_iterations: usize) -> bool {
353        let n = self.phi.len();
354        let budget = max_iterations;
355
356        // b holds what the electrodes inject; A is the interior coupling plus the electrode
357        // conductances on the diagonal.
358        let b = self.source();
359        let mut x = std::mem::take(&mut self.phi);
360        if x.len() != n {
361            x = vec![0.0; n];
362        }
363        let mut r = b.clone();
364        let ax = self.apply(&x);
365        for (ri, axi) in r.iter_mut().zip(&ax) {
366            *ri -= axi;
367        }
368        let mut p = r.clone();
369        let mut rr: f64 = r.iter().map(|v| v * v).sum();
370        let scale: f64 = b
371            .iter()
372            .map(|v| v * v)
373            .sum::<f64>()
374            .sqrt()
375            .max(f64::MIN_POSITIVE);
376
377        let mut iterations = 0;
378        while rr.sqrt() / scale > tolerance && iterations < budget {
379            let ap = self.apply(&p);
380            let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
381            if pap <= 0.0 {
382                // Not positive definite, which for this discretisation means every conductance
383                // vanished. Stopping is right; pretending is not.
384                break;
385            }
386            let alpha = rr / pap;
387            for (xi, pi) in x.iter_mut().zip(&p) {
388                *xi += alpha * pi;
389            }
390            for (ri, api) in r.iter_mut().zip(&ap) {
391                *ri -= alpha * api;
392            }
393            let rr_next: f64 = r.iter().map(|v| v * v).sum();
394            let beta = rr_next / rr;
395            for (pi, ri) in p.iter_mut().zip(&r) {
396                *pi = ri + beta * *pi;
397            }
398            rr = rr_next;
399            iterations += 1;
400        }
401
402        self.phi = x;
403        self.residual = rr.sqrt() / scale;
404        self.converged = self.residual <= tolerance;
405        self.converged
406    }
407
408    /// Every interior face, as `(cell a, cell b, conductance)`.
409    ///
410    /// The conductance across a face between two cells of different material is the **harmonic**
411    /// mean, not the arithmetic one, because two half-cells in series add their resistances. An
412    /// arithmetic mean is the classic mistake here and it is invisible for a uniform block —
413    /// which is why the series test uses two materials four orders of magnitude apart.
414    fn faces(&self) -> Vec<(Side, Side, f64)> {
415        let (nx, ny, nz) = self.counts;
416        let area = self.dx * self.dx;
417        let mut out = Vec::new();
418        // Electrode faces: cell centre to the electrode is half a spacing.
419        for k in 0..nz {
420            for j in 0..ny {
421                let low = i_index(0, j, k, nx, ny);
422                out.push((
423                    Side::Electrode(false),
424                    Side::Cell(low),
425                    self.sigma[low] * area / (0.5 * self.dx),
426                ));
427                let high = i_index(nx - 1, j, k, nx, ny);
428                out.push((
429                    Side::Cell(high),
430                    Side::Electrode(true),
431                    self.sigma[high] * area / (0.5 * self.dx),
432                ));
433            }
434        }
435        let mut interior = |a: usize, b: usize| {
436            let (sa, sb) = (self.sigma[a], self.sigma[b]);
437            let g = if sa <= 0.0 || sb <= 0.0 {
438                0.0
439            } else {
440                // Two half-cells in series: 1/G = dx/2/(sa*A) + dx/2/(sb*A).
441                area / (0.5 * self.dx / sa + 0.5 * self.dx / sb)
442            };
443            out.push((Side::Cell(a), Side::Cell(b), g));
444        };
445        for k in 0..nz {
446            for j in 0..ny {
447                for i in 0..nx - 1 {
448                    interior(i_index(i, j, k, nx, ny), i_index(i + 1, j, k, nx, ny));
449                }
450            }
451        }
452        for k in 0..nz {
453            for j in 0..ny - 1 {
454                for i in 0..nx {
455                    interior(i_index(i, j, k, nx, ny), i_index(i, j + 1, k, nx, ny));
456                }
457            }
458        }
459        for k in 0..nz - 1 {
460            for j in 0..ny {
461                for i in 0..nx {
462                    interior(i_index(i, j, k, nx, ny), i_index(i, j, k + 1, nx, ny));
463                }
464            }
465        }
466        out
467    }
468
469    fn phi_of(&self, s: Side) -> f64 {
470        match s {
471            Side::Cell(i) => self.phi[i],
472            Side::Electrode(high) => {
473                if high {
474                    self.drive
475                } else {
476                    0.0
477                }
478            }
479        }
480    }
481
482    /// `A·x` for the finite-volume operator: the sum over faces of `G·(x_a − x_b)`.
483    fn apply(&self, x: &[f64]) -> Vec<f64> {
484        let mut y = vec![0.0; x.len()];
485        for (a, b, g) in self.faces() {
486            match (a, b) {
487                (Side::Cell(i), Side::Cell(j)) => {
488                    let d = g * (x[i] - x[j]);
489                    y[i] += d;
490                    y[j] -= d;
491                }
492                (Side::Cell(i), Side::Electrode(_)) | (Side::Electrode(_), Side::Cell(i)) => {
493                    y[i] += g * x[i];
494                }
495                (Side::Electrode(_), Side::Electrode(_)) => {}
496            }
497        }
498        y
499    }
500
501    /// What the electrodes inject: `G·φ_electrode` for each face touching one.
502    fn source(&self) -> Vec<f64> {
503        let mut b = vec![0.0; self.phi.len()];
504        for (a, c, g) in self.faces() {
505            match (a, c) {
506                (Side::Electrode(high), Side::Cell(i)) | (Side::Cell(i), Side::Electrode(high)) => {
507                    b[i] += g * if high { self.drive } else { 0.0 };
508                }
509                _ => {}
510            }
511        }
512        b
513    }
514
515    /// The current flowing **from** one electrode **into** the block.
516    ///
517    /// One convention, stated once. Positive at the driven electrode, which sources; negative at
518    /// the grounded one, which sinks. The first version of this carried a per-face sign *and* a
519    /// negation at the end, and the two cancelled into an answer of exactly the right magnitude
520    /// and the wrong sign — which every closed-form test caught immediately, because a resistance
521    /// cannot be negative.
522    fn electrode_current(&self, high: bool) -> f64 {
523        let phi_e = if high { self.drive } else { 0.0 };
524        let mut total = 0.0;
525        for (a, b, g) in self.faces() {
526            let cell = match (a, b) {
527                (Side::Electrode(h), Side::Cell(i)) | (Side::Cell(i), Side::Electrode(h))
528                    if h == high =>
529                {
530                    Some(i)
531                }
532                _ => None,
533            };
534            if let Some(i) = cell {
535                total += g * (phi_e - self.phi[i]);
536            }
537        }
538        total
539    }
540}
541
542/// One end of a face: an interior cell, or one of the two electrodes.
543#[derive(Clone, Copy, PartialEq, Eq, Debug)]
544enum Side {
545    Cell(usize),
546    /// `true` for the driven electrode at `x = L`, `false` for the grounded one at `x = 0`.
547    Electrode(bool),
548}
549
550fn i_index(i: usize, j: usize, k: usize, nx: usize, ny: usize) -> usize {
551    i + nx * (j + ny * k)
552}
553
554impl Domain for Conductor {
555    fn books_balance(&self) -> bool {
556        true
557    }
558
559    fn name(&self) -> &str {
560        &self.name
561    }
562
563    /// Quasi-static: charge relaxes in `ε/σ`, which for copper is 1.5×10⁻¹⁹ s. On any timescale a
564    /// simulation cares about, the current distribution is a solution and not a state.
565    fn kind(&self) -> Kind {
566        Kind::QuasiStatic
567    }
568
569    fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
570        let want = self.tolerance;
571        if !self.solve(want) {
572            return Err(Violation {
573                quantity: "solver residual".to_string(),
574                site: format!("{} (conjugate gradients did not converge)", self.name),
575                before: want,
576                after: self.residual,
577                scale: 1.0,
578                tolerance: want,
579            });
580        }
581        let joules = self.dissipation().to_si() * dt.to_si();
582        self.dissipated += joules;
583        bus.publish(HEAT, joules);
584        Ok(())
585    }
586
587    /// What it has left to give, which for a source held at a fixed voltage is a negative number
588    /// that keeps getting more negative.
589    ///
590    /// The same bookkeeping [`Winding`](crate::Winding) uses: a domain paying joules out has to
591    /// say so, or the audit sees energy appear from nowhere. It is not a reserve — an ideal
592    /// voltage source has none — so what is reported is the debt.
593    fn ledger(&self) -> Ledger {
594        Ledger::new().with(quantity::ENERGY, -self.dissipated)
595    }
596
597    /// The resistance, the current, the dissipation and how well the solve converged.
598    ///
599    /// **The residual is a reading**, not merely an internal number, and that is the point of
600    /// having it here: an iterative solve that quietly stopped early produces a field shaped like
601    /// an answer, and the only thing that would ever say otherwise is a column somebody can look
602    /// at.
603    fn readings(&self) -> Vec<Reading> {
604        vec![
605            Reading::new(&self.name, "resistance", self.resistance().to_si(), "ohm"),
606            Reading::new(&self.name, "current", self.current().to_si(), "A"),
607            Reading::new(&self.name, "dissipating", self.dissipation().to_si(), "W"),
608            Reading::new(&self.name, "spent", self.dissipated, "J"),
609            Reading::new(&self.name, "residual", self.residual, ""),
610        ]
611    }
612
613    fn as_any(&self) -> Option<&dyn std::any::Any> {
614        Some(self)
615    }
616
617    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
618        Some(self)
619    }
620
621    /// The **potential**, in volts, as a field.
622    ///
623    /// Not the current density, which is a vector and has no `ScalarField`. A view wanting to see
624    /// where the current crowds should take `|J|` from
625    /// [`current_density_at`](Conductor::current_density_at); the potential is what the solve
626    /// produces and what a contour plot of an electrical problem conventionally shows.
627    fn as_field(&self) -> Option<&dyn ScalarField> {
628        Some(self)
629    }
630}
631
632impl ScalarField for Conductor {
633    fn unit(&self) -> &'static str {
634        "V"
635    }
636
637    /// Trilinear between cell centres, clamped at the faces.
638    fn at(&self, p: LengthVec, _t: Time) -> f64 {
639        let (nx, ny, nz) = self.counts;
640        let q = p.to_si() / self.dx - DVec3::splat(0.5);
641        if q.is_nan() {
642            return self.phi[0];
643        }
644        let axis = |v: f64, n: usize| -> (usize, f64) {
645            let last = n.saturating_sub(1);
646            if v <= 0.0 {
647                (0, 0.0)
648            } else if v >= last as f64 {
649                (last, 0.0)
650            } else {
651                let i = v.floor();
652                (i as usize, v - i)
653            }
654        };
655        let (i, fx) = axis(q.x, nx);
656        let (j, fy) = axis(q.y, ny);
657        let (k, fz) = axis(q.z, nz);
658        let (i1, j1, k1) = (
659            (i + 1).min(nx - 1),
660            (j + 1).min(ny - 1),
661            (k + 1).min(nz - 1),
662        );
663        let g = |a: usize, b: usize, c: usize| self.phi[i_index(a, b, c, nx, ny)];
664        let lerp = |lo: f64, hi: f64, t: f64| lo * (1.0 - t) + hi * t;
665        let z0 = lerp(
666            lerp(g(i, j, k), g(i1, j, k), fx),
667            lerp(g(i, j1, k), g(i1, j1, k), fx),
668            fy,
669        );
670        let z1 = lerp(
671            lerp(g(i, j, k1), g(i1, j, k1), fx),
672            lerp(g(i, j1, k1), g(i1, j1, k1), fx),
673            fy,
674        );
675        lerp(z0, z1, fz)
676    }
677
678    /// `∇φ`, whose negative times `σ` is the current density.
679    fn gradient(&self, p: LengthVec, t: Time, h: Length) -> DVec3 {
680        let d = h.to_si().max(self.dx);
681        let sample = |o: DVec3| self.at(LengthVec::from_si(p.to_si() + o), t);
682        DVec3::new(
683            (sample(DVec3::X * d) - sample(-DVec3::X * d)) / (2.0 * d),
684            (sample(DVec3::Y * d) - sample(-DVec3::Y * d)) / (2.0 * d),
685            (sample(DVec3::Z * d) - sample(-DVec3::Z * d)) / (2.0 * d),
686        )
687    }
688
689    /// Zero. A quasi-static potential does not evolve; it is re-solved when something changes.
690    fn rate(&self, _p: LengthVec, _t: Time, _dt: Time) -> f64 {
691        0.0
692    }
693}
694
695/// The current density as a vector, for a caller that wants `J` rather than `φ`.
696impl Conductor {
697    /// `|J|` at a cell, which is what a picture of current crowding wants.
698    pub fn current_density_magnitude(&self, i: usize, j: usize, k: usize) -> CurrentDensity {
699        CurrentDensity::from_si(self.current_density_at(i, j, k).length())
700    }
701
702    /// The conductivity of one cell.
703    pub fn conductivity_at(&self, i: usize, j: usize, k: usize) -> Conductivity {
704        let (nx, ny, nz) = self.counts;
705        let idx = self
706            .index(i.min(nx - 1), j.min(ny - 1), k.min(nz - 1))
707            .expect("clamped");
708        Conductivity::from_si(self.sigma[idx])
709    }
710}