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}