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}