Skip to main content

pantometry_electrical/
lib.rs

1//! pantometry-electrical: resistive dissipation, as a domain built on the `pantometry-core` kernel.
2//!
3//! The physics that *produces* the watts every other domain in this workspace has so far been
4//! handed. `pantometry-thermal` consumes heat, `pantometry-optics` publishes it from absorbed light,
5//! `pantometry-mechanics` from a dashpot, `pantometry-acoustic` from an absorbing duct end — and a motor
6//! or a heater or a trace on a board gets hot for none of those reasons. It gets hot because
7//! current went through resistance.
8//!
9//! Until this crate, the workspace's own examples papered over that with a source of a stated
10//! number of watts. That is fine as a stand-in and it is not a model: nothing decides the number,
11//! so nothing can be wrong about it.
12//!
13//! ```
14//! use pantometry_electrical::Winding;
15//! use pantometry_units::{Current, Length, Temperature};
16//!
17//! // A copper winding carrying 3 A. Its resistance is what decides the heat.
18//! let coil = Winding::of_copper("coil", Length::m(24.0), 0.35e-6, Temperature::celsius(25.0))
19//!     .driven_at(Current::a(3.0));
20//! // rho*L/A at 20 C is 1.18217 ohm; five kelvin of copper adds 1.965%.
21//! assert!((coil.resistance().to_si() - 1.205401).abs() < 1e-6);
22//! assert!((coil.dissipation().to_si() - 10.848610).abs() < 1e-6);   // I^2 R
23//! ```
24//!
25//! # The coupling this crate deliberately does not have
26//!
27//! Copper's resistivity rises about 0.393% per kelvin, so a winding that gets hot dissipates
28//! more, which makes it hotter. That feedback is the whole reason thermal runaway is a thing a
29//! designer worries about, and **it is not expressible here.**
30//!
31//! A domain would need to read another domain's *temperature* inside the step loop.
32//! [`Exchange`] carries amounts — joules, coulombs — and not state, which
33//! is exactly what makes the conservation audit an equality rather than an approximation. There
34//! is no `peek_temperature`, and adding one would not be a small thing: a channel carrying state
35//! is not conserved, cannot be audited, and is a short step from domains reading each other,
36//! which is the property the crate split exists to hold.
37//!
38//! So this crate models the resistance at a temperature **you state**, and
39//! [`Winding::at_temperature`] is how you state it. The number is right for that temperature and
40//! the feedback is the caller's to close, between steps, with both temperatures in hand. What
41//! that costs and whether the kernel should grow something is written up rather than decided
42//! here — see the repository's `FRICTION.md`.
43//!
44//! # What the audit can and cannot see
45//!
46//! A [`Winding`] holds a finite [`reserve`](Winding::with_reserve) of joules, like every other
47//! source in this workspace, because a source with an unlimited supply creates energy from
48//! nothing every step.
49//!
50//! **The domain refuses that itself rather than leaving it to the audit**, and the reason is
51//! worth stating: an infinite reserve does not fail the audit, it *disables* it. The ledger
52//! reports `inf` before and `inf` after, `inf` compares equal to itself, and a winding pouring
53//! joules into a plate runs green at any tolerance. This test was written expecting the audit to
54//! catch it and it did not.
55//!
56//! What the audit cannot check is whether `I²R` is the right number of watts. Both sides of the
57//! bus agree perfectly about whatever is published, so a resistance wrong by a factor of two
58//! balances the books exactly. The tests are therefore against closed forms computed
59//! independently: the resistivity of copper at a stated temperature, `P = I²R`, and the exact
60//! equivalence of the constant-current and constant-voltage forms at the same operating point.
61
62#![deny(missing_docs)]
63#![forbid(unsafe_code)]
64
65use pantometry_core::conserved::quantity;
66use pantometry_core::{Domain, Exchange, Kind, Ledger, Reading, Violation};
67use pantometry_units::{Current, Length, Power, Resistance, Temperature, Time, Voltage};
68
69/// The channel resistive loss is published on.
70///
71/// **`quantity::ENERGY`, not a string of this crate's own choosing.** The first version of this
72/// crate declared `"heat"`, which reads correctly and is a different channel from the one
73/// `pantometry-thermal` takes from — so a winding published joules that nothing consumed, in a
74/// coupling whose whole point was that the two crates already agree without naming each other.
75///
76/// The audit caught it on the first step, by name and with the amount: *heat, published but not
77/// consumed, 0.045*. That is the library's own argument working on its author, and it is the
78/// reason the channel is a shared constant rather than a literal in two places.
79pub const HEAT: &str = quantity::ENERGY;
80
81/// Resistivity of annealed copper at 20 °C, in ohm-metres.
82///
83/// The IACS reference value. Hard-drawn copper runs about 2% higher, and this is the number a
84/// wire table is built from.
85pub const COPPER_RESISTIVITY_20C: f64 = 1.724e-8;
86
87pub mod conductor;
88
89pub use conductor::Conductor;
90
91/// Temperature coefficient of copper's resistivity, per kelvin, referenced to 20 °C.
92///
93/// `ρ(T) = ρ₂₀(1 + α(T − 20 °C))`. Linear, which is good to a per cent or so from about −50 °C
94/// to 200 °C and is not good at cryogenic temperatures, where the residual resistivity of the
95/// particular sample takes over and no coefficient describes it.
96pub const COPPER_ALPHA: f64 = 0.00393;
97
98/// How a winding is driven.
99#[derive(Clone, Copy, Debug, PartialEq)]
100enum Drive {
101    /// Constant current: dissipation is `I²R`, and rises with resistance.
102    Current(f64),
103    /// Constant voltage: dissipation is `V²/R`, and *falls* with resistance.
104    Voltage(f64),
105}
106
107/// A length of conductor carrying current, dissipating `I²R` onto the heat channel.
108///
109/// The resistance is computed from the conductor's geometry and its temperature rather than
110/// stated, so it is a model rather than a number: get the length or the cross-section wrong and
111/// the dissipation is wrong in a way a closed form can catch.
112pub struct Winding {
113    name: String,
114    /// Ohms at the reference temperature, before the coefficient is applied.
115    resistance_20c: f64,
116    alpha: f64,
117    temperature: f64,
118    drive: Drive,
119    /// Joules left to spend. `f64::INFINITY` until [`Winding::with_reserve`] says otherwise,
120    /// and `step` refuses while it is — an infinite ledger entry is not a large number, it is a
121    /// number the audit cannot subtract.
122    reserve: f64,
123    dissipated: f64,
124}
125
126impl Winding {
127    /// A winding of copper: `length` of wire with a given cross-section, at a temperature.
128    ///
129    /// `R = ρ(T)·L/A`. The cross-section is in square metres — 0.35 mm² of magnet wire is
130    /// `0.35e-6`, which is roughly AWG 22.
131    pub fn of_copper(
132        name: impl Into<String>,
133        length: Length,
134        cross_section_m2: f64,
135        at: Temperature,
136    ) -> Winding {
137        let r20 = COPPER_RESISTIVITY_20C * length.to_si() / cross_section_m2.max(f64::MIN_POSITIVE);
138        Winding {
139            name: name.into(),
140            resistance_20c: r20,
141            alpha: COPPER_ALPHA,
142            temperature: at.to_si(),
143            drive: Drive::Current(0.0),
144            reserve: f64::INFINITY,
145            dissipated: 0.0,
146        }
147    }
148
149    /// A winding of stated resistance at 20 °C, for a conductor this crate has no geometry for.
150    ///
151    /// `alpha` is the temperature coefficient per kelvin; pass `0.0` for a resistor whose
152    /// coefficient you do not want to model, which is the honest choice for a wirewound part
153    /// whose datasheet quotes a tolerance band rather than a number.
154    pub fn of_resistance(
155        name: impl Into<String>,
156        at_20c: Resistance,
157        alpha: f64,
158        at: Temperature,
159    ) -> Winding {
160        Winding {
161            name: name.into(),
162            resistance_20c: at_20c.to_si(),
163            alpha,
164            temperature: at.to_si(),
165            drive: Drive::Current(0.0),
166            reserve: f64::INFINITY,
167            dissipated: 0.0,
168        }
169    }
170
171    /// Drive it at a constant current. Dissipation is `I²R` and **rises** as it warms.
172    pub fn driven_at(mut self, current: Current) -> Winding {
173        self.drive = Drive::Current(current.to_si());
174        self
175    }
176
177    /// Drive it from a constant voltage. Dissipation is `V²/R` and **falls** as it warms.
178    ///
179    /// The opposite sign of feedback from [`driven_at`](Winding::driven_at), and the difference
180    /// decides whether a runaway is possible at all: a constant-current winding can run away, a
181    /// constant-voltage one cannot.
182    pub fn driven_from(mut self, voltage: Voltage) -> Winding {
183        self.drive = Drive::Voltage(voltage.to_si());
184        self
185    }
186
187    /// Joules it may dissipate before it goes quiet.
188    ///
189    /// **Not optional in practice.** Without it the reserve is infinite, and `step` refuses —
190    /// see the comment there for why the refusal is the domain's job rather than the audit's.
191    /// Saying where the energy comes from is the point.
192    pub fn with_reserve(mut self, joules: f64) -> Winding {
193        self.reserve = joules.max(0.0);
194        self
195    }
196
197    /// Tell it what temperature it is now.
198    ///
199    /// **The manual half of a coupling this crate cannot close by itself.** A domain cannot read
200    /// another's temperature inside the step loop — see the module documentation — so a caller
201    /// wanting the electro-thermal feedback reads the thermal domain's temperature between
202    /// steps and passes it here.
203    pub fn at_temperature(&mut self, t: Temperature) {
204        self.temperature = t.to_si();
205    }
206
207    /// Resistance at its current temperature: `R₂₀(1 + α(T − 20 °C))`.
208    pub fn resistance(&self) -> Resistance {
209        let dt = self.temperature - Temperature::celsius(20.0).to_si();
210        Resistance::from_si(self.resistance_20c * (1.0 + self.alpha * dt))
211    }
212
213    /// Power it is dissipating right now.
214    pub fn dissipation(&self) -> Power {
215        self.dissipation_at(Temperature::from_si(self.temperature))
216    }
217
218    /// Resistance at an arbitrary temperature, without changing the winding.
219    pub fn resistance_at(&self, at: Temperature) -> Resistance {
220        let dt = at.to_si() - Temperature::celsius(20.0).to_si();
221        Resistance::from_si(self.resistance_20c * (1.0 + self.alpha * dt))
222    }
223
224    /// What it would dissipate at a temperature, without changing the winding.
225    ///
226    /// **A pure function, and that is the point.** Everything else here is a `Domain` method,
227    /// which means it can only be reached by stepping — and the electro-thermal feedback needs
228    /// `P(T)` evaluated at a temperature the electrical domain has no way to learn, since
229    /// [`Exchange`] carries amounts and not state.
230    ///
231    /// As a plain function it is composable by whoever *does* hold both sides. A caller with a
232    /// thermal network and a winding can write `coil.dissipation_at(net.temperature(node))`
233    /// between frames — which is what `pantometry-world`'s scene 13 does — and any future in-loop
234    /// coupling needs this same function rather than a different one. So it is correct under
235    /// every answer to that design question, which is why it exists before the question is
236    /// settled.
237    pub fn dissipation_at(&self, at: Temperature) -> Power {
238        let r = self.resistance_at(at).to_si();
239        Power::from_si(match self.drive {
240            Drive::Current(i) => i * i * r,
241            // A short across an ideal source is not a physical answer, and returning an infinity
242            // here would arrive at the audit as a NaN a step later, where its origin is lost.
243            Drive::Voltage(v) if r > 0.0 => v * v / r,
244            Drive::Voltage(_) => 0.0,
245        })
246    }
247
248    /// The current at which this winding's feedback overcomes a heat path of conductance `g`.
249    ///
250    /// Thermal runaway is `dP/dT > dQ_out/dT`. For a constant-current winding `P = I²R₂₀(1+αΔT)`
251    /// so `dP/dT = I²R₂₀α`, and against a path that sheds `g` watts per kelvin the threshold is
252    /// exact:
253    ///
254    /// ```text
255    ///     I_crit = √( g / (R₂₀ α) )
256    /// ```
257    ///
258    /// `g` is the conductance of the **whole** path to ambient, which for anything with joints
259    /// in it is not the surface's. A motor whose winding reaches air through 0.9 W/K and
260    /// 2.4 W/K of joints and then 0.294 W/K of convection has a series `g` of 0.203 W/K, not
261    /// 0.294 — and the threshold falls from 4.95 A to 4.11 A, a 17% margin a lumped model
262    /// reports as present when it is not. That difference is the argument for
263    /// [`ThermalNetwork`](https://docs.rs/pantometry-thermal) over one body.
264    ///
265    /// **Do not assemble `g` by hand.** Those three numbers are a convection-only path, and the
266    /// real one on that motor is 0.220 W/K because the housing also radiates at its operating
267    /// temperature — so the hand-computed 4.11 A understates the true 4.28 A by 4%. Ask the
268    /// network: `ThermalNetwork::path_conductance(node, at)` takes the slope of its own solved
269    /// balance and therefore includes every path out, radiative terms and interior environments
270    /// alike. The hand formula was found to be wrong by a sizing tool written against 0.6.0,
271    /// which is what `FRICTION.md` 20 is about.
272    ///
273    /// Returns `None` for a voltage-driven winding, which cannot run away: `P = V²/R` *falls*
274    /// as it warms, so the feedback has the opposite sign and there is no threshold to report.
275    pub fn runaway_current(&self, path: pantometry_units::Conductance) -> Option<Current> {
276        match self.drive {
277            Drive::Voltage(_) => None,
278            Drive::Current(_) => {
279                let denom = self.resistance_20c * self.alpha;
280                if denom <= 0.0 || !path.to_si().is_finite() || path.to_si() <= 0.0 {
281                    return None;
282                }
283                Some(Current::from_si((path.to_si() / denom).sqrt()))
284            }
285        }
286    }
287
288    /// The current through it, whichever way it is driven.
289    pub fn current(&self) -> Current {
290        let r = self.resistance().to_si();
291        Current::from_si(match self.drive {
292            Drive::Current(i) => i,
293            Drive::Voltage(v) if r > 0.0 => v / r,
294            Drive::Voltage(_) => 0.0,
295        })
296    }
297
298    /// The voltage across it, whichever way it is driven.
299    pub fn voltage(&self) -> Voltage {
300        Voltage::from_si(match self.drive {
301            Drive::Current(i) => i * self.resistance().to_si(),
302            Drive::Voltage(v) => v,
303        })
304    }
305
306    /// Joules it has put onto the bus over the run.
307    pub fn dissipated_energy(&self) -> pantometry_units::Energy {
308        pantometry_units::Energy::from_si(self.dissipated)
309    }
310
311    /// Joules it has left to spend.
312    pub fn reserve(&self) -> pantometry_units::Energy {
313        pantometry_units::Energy::from_si(self.reserve)
314    }
315}
316
317impl Domain for Winding {
318    fn books_balance(&self) -> bool {
319        true
320    }
321
322    fn name(&self) -> &str {
323        &self.name
324    }
325
326    /// Nothing here is integrated: the dissipation is a closed-form function of the state, so
327    /// there is no state to march and no stability limit to respect.
328    fn kind(&self) -> Kind {
329        Kind::QuasiStatic
330    }
331
332    fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
333        // An infinite reserve does not merely go uncaught — it turns the audit *off*. The ledger
334        // reports `inf` before and `inf` after, `inf` compares equal to itself, and a winding
335        // pouring joules into a plate produces a run that is green at any tolerance. This
336        // repository has shipped that failure once already, in `pantometry-world`'s lamp, where a
337        // skipped `with_reserve` left the reserve infinite and a scene audited clean at
338        // tolerance zero with the lamp doing nothing.
339        //
340        // So the domain refuses rather than trusting the audit to notice, because the audit
341        // structurally cannot.
342        let watts = self.dissipation().to_si();
343        if !watts.is_finite() || watts < 0.0 {
344            return Err(Violation::at(
345                &self.name,
346                "dissipation is not a power",
347                watts,
348            ));
349        }
350        // Refused when it would actually publish, rather than on construction: a winding sitting
351        // at zero current puts nothing on the bus and blinds nothing, so demanding a reserve of
352        // it would be a rule about the API instead of about the physics. The drive is fixed at
353        // construction — `driven_at` and `driven_from` consume `self` — so a winding that
354        // publishes nothing on the first step publishes nothing ever, and this cannot be dodged
355        // by starting at zero.
356        if watts > 0.0 && !self.reserve.is_finite() {
357            return Err(Violation::at(
358                &self.name,
359                "no reserve was set, so this winding supplies energy from nowhere and the audit \
360                 cannot see it: an infinite ledger entry is not a large number, it is one that \
361                 cannot be subtracted. Call with_reserve",
362                watts,
363            ));
364        }
365        let joules = (watts * dt.to_si()).min(self.reserve).max(0.0);
366        self.reserve -= joules;
367        self.dissipated += joules;
368        bus.publish(HEAT, joules);
369        Ok(())
370    }
371
372    /// **What is left to spend, not what has passed through.**
373    ///
374    /// The joules published are gone from here and are being reported by whoever took them.
375    /// Adding them back would make the total grow by the heat that moved, which this workspace
376    /// has written down more than once as the most common way to author a domain that audits
377    /// green and is wrong.
378    fn ledger(&self) -> Ledger {
379        Ledger::new().with(quantity::ENERGY, self.reserve)
380    }
381
382    /// What it is dissipating, at what resistance, and what it has spent.
383    ///
384    /// The resistance is here because it is the *reason* the dissipation moves: at fixed current
385    /// the power ratio is the resistance ratio, and a table with both columns shows that in a way
386    /// a table with one cannot.
387    fn readings(&self) -> Vec<Reading> {
388        vec![
389            Reading::new(&self.name, "dissipating", self.dissipation().to_si(), "W"),
390            Reading::new(&self.name, "resistance", self.resistance().to_si(), "ohm"),
391            Reading::new(&self.name, "spent", self.dissipated.max(0.0), "J"),
392        ]
393    }
394
395    fn as_any(&self) -> Option<&dyn std::any::Any> {
396        Some(self)
397    }
398
399    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
400        Some(self)
401    }
402}