Skip to main content

scirs2_core/physics/
electrodynamics.rs

1//! Electrodynamics: electrostatics, magnetostatics, electromagnetic fields,
2//! circuit theory, and special relativity.
3//!
4//! All functions use SI units.
5//!
6//! # Reference
7//!
8//! * Griffiths — *Introduction to Electrodynamics* (4th ed.)
9//! * Jackson — *Classical Electrodynamics* (3rd ed.)
10//! * Einstein — *On the Electrodynamics of Moving Bodies* (1905)
11
12use crate::constants::physical::{
13    ELECTRIC_CONSTANT, ELEMENTARY_CHARGE, MAGNETIC_CONSTANT, SPEED_OF_LIGHT,
14};
15
16use super::error::{PhysicsError, PhysicsResult};
17
18// Helper: 1 / (4πε₀)
19#[inline(always)]
20fn coulomb_constant() -> f64 {
21    1.0 / (4.0 * std::f64::consts::PI * ELECTRIC_CONSTANT)
22}
23
24// ─── Electrostatics ───────────────────────────────────────────────────────────
25
26/// Coulomb force between two point charges.
27///
28/// `F = kₑ·q₁·q₂/r²`  where `kₑ = 1/(4πε₀) ≈ 8.988×10⁹ N·m²/C²`.
29///
30/// The sign of the returned value indicates attractive (negative, opposite signs)
31/// or repulsive (positive, same sign) force.
32///
33/// # Arguments
34///
35/// * `q1`       – first charge in C
36/// * `q2`       – second charge in C
37/// * `distance` – separation in m (must be > 0)
38///
39/// # Errors
40///
41/// Returns [`PhysicsError::InvalidParameter`] if `distance` is not positive.
42pub fn coulomb_force(q1: f64, q2: f64, distance: f64) -> PhysicsResult<f64> {
43    if distance <= 0.0 {
44        return Err(PhysicsError::InvalidParameter {
45            param: "distance",
46            reason: format!("distance must be positive, got {distance}"),
47        });
48    }
49    Ok(coulomb_constant() * q1 * q2 / (distance * distance))
50}
51
52/// Electric potential due to a point charge.
53///
54/// `V = kₑ·q/r`
55///
56/// # Arguments
57///
58/// * `charge`   – charge in C
59/// * `distance` – distance from the charge in m (must be > 0)
60///
61/// # Errors
62///
63/// Returns [`PhysicsError::InvalidParameter`] if `distance` is not positive.
64pub fn electric_potential(charge: f64, distance: f64) -> PhysicsResult<f64> {
65    if distance <= 0.0 {
66        return Err(PhysicsError::InvalidParameter {
67            param: "distance",
68            reason: format!("distance must be positive, got {distance}"),
69        });
70    }
71    Ok(coulomb_constant() * charge / distance)
72}
73
74/// Electric field magnitude due to a point charge.
75///
76/// `E = kₑ·|q|/r²`
77///
78/// The magnitude is always non-negative.
79///
80/// # Arguments
81///
82/// * `charge`   – charge in C
83/// * `distance` – distance from the charge in m (must be > 0)
84///
85/// # Errors
86///
87/// Returns [`PhysicsError::InvalidParameter`] if `distance` is not positive.
88pub fn electric_field(charge: f64, distance: f64) -> PhysicsResult<f64> {
89    if distance <= 0.0 {
90        return Err(PhysicsError::InvalidParameter {
91            param: "distance",
92            reason: format!("distance must be positive, got {distance}"),
93        });
94    }
95    Ok(coulomb_constant() * charge.abs() / (distance * distance))
96}
97
98/// Electrostatic potential energy of a two-charge system.
99///
100/// `U = kₑ·q₁·q₂/r`
101///
102/// # Arguments
103///
104/// * `q1`       – first charge in C
105/// * `q2`       – second charge in C
106/// * `distance` – separation in m (must be > 0)
107///
108/// # Errors
109///
110/// Returns [`PhysicsError::InvalidParameter`] if `distance` is not positive.
111pub fn electrostatic_potential_energy(q1: f64, q2: f64, distance: f64) -> PhysicsResult<f64> {
112    if distance <= 0.0 {
113        return Err(PhysicsError::InvalidParameter {
114            param: "distance",
115            reason: format!("distance must be positive, got {distance}"),
116        });
117    }
118    Ok(coulomb_constant() * q1 * q2 / distance)
119}
120
121/// Energy stored in a capacitor.
122///
123/// `U = Q²/(2C) = CV²/2`
124///
125/// # Arguments
126///
127/// * `capacitance` – capacitance in F (must be > 0)
128/// * `voltage`     – voltage across the capacitor in V
129///
130/// # Errors
131///
132/// Returns [`PhysicsError::InvalidParameter`] if `capacitance` is not positive.
133pub fn capacitor_energy(capacitance: f64, voltage: f64) -> PhysicsResult<f64> {
134    if capacitance <= 0.0 {
135        return Err(PhysicsError::InvalidParameter {
136            param: "capacitance",
137            reason: format!("capacitance must be positive, got {capacitance}"),
138        });
139    }
140    Ok(0.5 * capacitance * voltage * voltage)
141}
142
143// ─── Magnetostatics ──────────────────────────────────────────────────────────
144
145/// Magnetic force on a moving charged particle (Lorentz force magnitude).
146///
147/// `F = |q| · v · B · |sin(θ)|`
148///
149/// where `θ` is the angle between the velocity and the magnetic field vectors.
150///
151/// # Arguments
152///
153/// * `charge`    – particle charge in C
154/// * `velocity`  – particle speed in m/s (must be ≥ 0)
155/// * `b_field`   – magnetic flux density in T (must be ≥ 0)
156/// * `angle_rad` – angle between v and B in radians
157///
158/// # Errors
159///
160/// Returns [`PhysicsError::InvalidParameter`] if `velocity` or `b_field` is negative.
161pub fn magnetic_force(
162    charge: f64,
163    velocity: f64,
164    b_field: f64,
165    angle_rad: f64,
166) -> PhysicsResult<f64> {
167    if velocity < 0.0 {
168        return Err(PhysicsError::InvalidParameter {
169            param: "velocity",
170            reason: format!("velocity must be non-negative, got {velocity}"),
171        });
172    }
173    if b_field < 0.0 {
174        return Err(PhysicsError::InvalidParameter {
175            param: "b_field",
176            reason: format!("magnetic field must be non-negative, got {b_field}"),
177        });
178    }
179    Ok(charge.abs() * velocity * b_field * angle_rad.sin().abs())
180}
181
182/// Biot-Savart magnetic field magnitude at distance `r` from a long straight wire
183/// carrying current `I`.
184///
185/// `B = μ₀·I / (2π·r)`
186///
187/// # Arguments
188///
189/// * `current`  – current in A (must be ≥ 0)
190/// * `distance` – perpendicular distance to the wire in m (must be > 0)
191///
192/// # Errors
193///
194/// Returns [`PhysicsError::InvalidParameter`] if `current` < 0 or `distance` ≤ 0.
195pub fn biot_savart_wire(current: f64, distance: f64) -> PhysicsResult<f64> {
196    if current < 0.0 {
197        return Err(PhysicsError::InvalidParameter {
198            param: "current",
199            reason: format!("current must be non-negative, got {current}"),
200        });
201    }
202    if distance <= 0.0 {
203        return Err(PhysicsError::InvalidParameter {
204            param: "distance",
205            reason: format!("distance must be positive, got {distance}"),
206        });
207    }
208    Ok(MAGNETIC_CONSTANT * current / (2.0 * std::f64::consts::PI * distance))
209}
210
211/// Cyclotron radius of a charged particle in a uniform magnetic field.
212///
213/// `r = mv / (|q|·B)`
214///
215/// # Arguments
216///
217/// * `mass`     – particle mass in kg (must be > 0)
218/// * `velocity` – speed in m/s (must be ≥ 0)
219/// * `charge`   – charge magnitude in C (must be > 0)
220/// * `b_field`  – magnetic flux density in T (must be > 0)
221///
222/// # Errors
223///
224/// Returns [`PhysicsError::InvalidParameter`] for non-positive `mass`, `charge`, or `b_field`,
225/// or negative `velocity`.
226pub fn cyclotron_radius(mass: f64, velocity: f64, charge: f64, b_field: f64) -> PhysicsResult<f64> {
227    if mass <= 0.0 {
228        return Err(PhysicsError::InvalidParameter {
229            param: "mass",
230            reason: format!("mass must be positive, got {mass}"),
231        });
232    }
233    if velocity < 0.0 {
234        return Err(PhysicsError::InvalidParameter {
235            param: "velocity",
236            reason: format!("velocity must be non-negative, got {velocity}"),
237        });
238    }
239    if charge <= 0.0 {
240        return Err(PhysicsError::InvalidParameter {
241            param: "charge",
242            reason: format!("charge magnitude must be positive, got {charge}"),
243        });
244    }
245    if b_field <= 0.0 {
246        return Err(PhysicsError::InvalidParameter {
247            param: "b_field",
248            reason: format!("magnetic field must be positive, got {b_field}"),
249        });
250    }
251    Ok(mass * velocity / (charge * b_field))
252}
253
254// ─── Special relativity ───────────────────────────────────────────────────────
255
256/// Lorentz factor γ for a particle moving at speed `v`.
257///
258/// `γ = 1 / √(1 − v²/c²)`
259///
260/// # Arguments
261///
262/// * `velocity` – speed in m/s (must be in [0, c))
263///
264/// # Errors
265///
266/// * [`PhysicsError::InvalidParameter`] if `velocity` < 0.
267/// * [`PhysicsError::SuperluminalVelocity`] if `velocity` ≥ c.
268pub fn lorentz_factor(velocity: f64) -> PhysicsResult<f64> {
269    if velocity < 0.0 {
270        return Err(PhysicsError::InvalidParameter {
271            param: "velocity",
272            reason: format!("velocity must be non-negative, got {velocity}"),
273        });
274    }
275    if velocity >= SPEED_OF_LIGHT {
276        return Err(PhysicsError::SuperluminalVelocity {
277            velocity,
278            c: SPEED_OF_LIGHT,
279        });
280    }
281    let beta = velocity / SPEED_OF_LIGHT;
282    Ok(1.0 / (1.0 - beta * beta).sqrt())
283}
284
285/// Relativistic total energy of a particle.
286///
287/// `E = γmc²`
288///
289/// # Arguments
290///
291/// * `mass`     – rest mass in kg (must be > 0)
292/// * `velocity` – speed in m/s (must be in [0, c))
293///
294/// # Errors
295///
296/// Propagates errors from [`lorentz_factor`]; additionally returns
297/// [`PhysicsError::InvalidParameter`] if `mass` is not positive.
298pub fn relativistic_energy(mass: f64, velocity: f64) -> PhysicsResult<f64> {
299    if mass <= 0.0 {
300        return Err(PhysicsError::InvalidParameter {
301            param: "mass",
302            reason: format!("mass must be positive, got {mass}"),
303        });
304    }
305    let gamma = lorentz_factor(velocity)?;
306    Ok(gamma * mass * SPEED_OF_LIGHT * SPEED_OF_LIGHT)
307}
308
309/// Rest energy of a particle: `E₀ = mc²`.
310///
311/// # Arguments
312///
313/// * `mass` – rest mass in kg (must be > 0)
314///
315/// # Errors
316///
317/// Returns [`PhysicsError::InvalidParameter`] if `mass` is not positive.
318pub fn rest_energy(mass: f64) -> PhysicsResult<f64> {
319    if mass <= 0.0 {
320        return Err(PhysicsError::InvalidParameter {
321            param: "mass",
322            reason: format!("mass must be positive, got {mass}"),
323        });
324    }
325    Ok(mass * SPEED_OF_LIGHT * SPEED_OF_LIGHT)
326}
327
328/// Relativistic kinetic energy.
329///
330/// `K = (γ − 1)mc²`
331///
332/// # Arguments
333///
334/// * `mass`     – rest mass in kg (must be > 0)
335/// * `velocity` – speed in m/s (must be in [0, c))
336///
337/// # Errors
338///
339/// Propagates errors from [`lorentz_factor`] and [`rest_energy`].
340pub fn relativistic_kinetic_energy(mass: f64, velocity: f64) -> PhysicsResult<f64> {
341    if mass <= 0.0 {
342        return Err(PhysicsError::InvalidParameter {
343            param: "mass",
344            reason: format!("mass must be positive, got {mass}"),
345        });
346    }
347    let gamma = lorentz_factor(velocity)?;
348    Ok((gamma - 1.0) * mass * SPEED_OF_LIGHT * SPEED_OF_LIGHT)
349}
350
351/// Relativistic momentum `p = γmv`.
352///
353/// # Arguments
354///
355/// * `mass`     – rest mass in kg (must be > 0)
356/// * `velocity` – speed in m/s (must be in [0, c))
357///
358/// # Errors
359///
360/// Propagates errors from [`lorentz_factor`]; additionally returns
361/// [`PhysicsError::InvalidParameter`] if `mass` is not positive.
362pub fn relativistic_momentum(mass: f64, velocity: f64) -> PhysicsResult<f64> {
363    if mass <= 0.0 {
364        return Err(PhysicsError::InvalidParameter {
365            param: "mass",
366            reason: format!("mass must be positive, got {mass}"),
367        });
368    }
369    let gamma = lorentz_factor(velocity)?;
370    Ok(gamma * mass * velocity)
371}
372
373/// Relativistic velocity addition formula.
374///
375/// When an object moves at speed `u` in a frame S', and S' moves at speed `v`
376/// relative to frame S (both in the same direction), the speed in S is:
377///
378/// `w = (u + v) / (1 + uv/c²)`
379///
380/// # Arguments
381///
382/// * `u` – speed in S' in m/s (must be in [0, c))
383/// * `v` – speed of S' relative to S in m/s (must be in [0, c))
384///
385/// # Errors
386///
387/// * [`PhysicsError::InvalidParameter`] if `u` or `v` is negative.
388/// * [`PhysicsError::SuperluminalVelocity`] if `u` or `v` ≥ c.
389pub fn relativistic_velocity_addition(u: f64, v: f64) -> PhysicsResult<f64> {
390    if u < 0.0 {
391        return Err(PhysicsError::InvalidParameter {
392            param: "u",
393            reason: format!("speed u must be non-negative, got {u}"),
394        });
395    }
396    if v < 0.0 {
397        return Err(PhysicsError::InvalidParameter {
398            param: "v",
399            reason: format!("speed v must be non-negative, got {v}"),
400        });
401    }
402    if u >= SPEED_OF_LIGHT {
403        return Err(PhysicsError::SuperluminalVelocity {
404            velocity: u,
405            c: SPEED_OF_LIGHT,
406        });
407    }
408    if v >= SPEED_OF_LIGHT {
409        return Err(PhysicsError::SuperluminalVelocity {
410            velocity: v,
411            c: SPEED_OF_LIGHT,
412        });
413    }
414    let c2 = SPEED_OF_LIGHT * SPEED_OF_LIGHT;
415    Ok((u + v) / (1.0 + u * v / c2))
416}
417
418/// Characteristic impedance of free space: `Z₀ = μ₀·c = √(μ₀/ε₀)`.
419///
420/// Returns the value in Ω (approximately 376.73 Ω).
421#[must_use]
422pub fn impedance_of_free_space() -> f64 {
423    MAGNETIC_CONSTANT * SPEED_OF_LIGHT
424}
425
426/// Electrical resistance using Ohm's law: `R = V/I`.
427///
428/// # Arguments
429///
430/// * `voltage` – voltage in V
431/// * `current` – current in A (must be non-zero)
432///
433/// # Errors
434///
435/// Returns [`PhysicsError::InvalidParameter`] if `current` is zero.
436pub fn ohm_resistance(voltage: f64, current: f64) -> PhysicsResult<f64> {
437    if current == 0.0 {
438        return Err(PhysicsError::InvalidParameter {
439            param: "current",
440            reason: "current must be non-zero to compute resistance".to_string(),
441        });
442    }
443    Ok(voltage / current)
444}
445
446/// Power dissipated in a resistor: `P = I²R = V²/R`.
447///
448/// # Arguments
449///
450/// * `current`    – current in A
451/// * `resistance` – resistance in Ω (must be > 0)
452///
453/// # Errors
454///
455/// Returns [`PhysicsError::InvalidParameter`] if `resistance` is not positive.
456pub fn resistor_power(current: f64, resistance: f64) -> PhysicsResult<f64> {
457    if resistance <= 0.0 {
458        return Err(PhysicsError::InvalidParameter {
459            param: "resistance",
460            reason: format!("resistance must be positive, got {resistance}"),
461        });
462    }
463    Ok(current * current * resistance)
464}
465
466/// Compton wavelength shift in scattering.
467///
468/// `Δλ = (h/(m_e·c)) · (1 − cos θ)` where `m_e` is the electron rest mass.
469///
470/// # Arguments
471///
472/// * `angle_rad` – scattering angle θ in radians
473///
474/// # Returns
475///
476/// Wavelength shift in meters.
477pub fn compton_wavelength_shift(angle_rad: f64) -> f64 {
478    use crate::constants::physical::{COMPTON_WAVELENGTH, ELECTRON_MASS};
479    // Compton wavelength λ_C = h/(m_e·c)
480    let _ = ELECTRON_MASS; // used via constant
481    COMPTON_WAVELENGTH * (1.0 - angle_rad.cos())
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use std::f64::consts::PI;
488
489    const TOL: f64 = 1e-9;
490
491    // ─── Electrostatics ───────────────────────────────────────────────────────
492
493    #[test]
494    fn test_coulomb_force_two_elementary_charges() {
495        // Two protons 1 Å apart
496        let r = 1e-10_f64;
497        let f = coulomb_force(ELEMENTARY_CHARGE, ELEMENTARY_CHARGE, r).expect("should succeed");
498        // kₑ·e²/r² ≈ 23.07 nN
499        assert!(f > 0.0, "Like charges repel");
500        assert!((f - 23.07e-9).abs() < 1e-10, "F = {f:.4e} N");
501    }
502
503    #[test]
504    fn test_coulomb_force_opposite_charges_attract() {
505        let f =
506            coulomb_force(ELEMENTARY_CHARGE, -ELEMENTARY_CHARGE, 1e-10).expect("should succeed");
507        assert!(f < 0.0, "Opposite charges attract");
508    }
509
510    #[test]
511    fn test_coulomb_force_invalid() {
512        assert!(coulomb_force(1.0, 1.0, 0.0).is_err());
513        assert!(coulomb_force(1.0, 1.0, -1.0).is_err());
514    }
515
516    #[test]
517    fn test_electric_potential_sign() {
518        let vp = electric_potential(ELEMENTARY_CHARGE, 1.0).expect("should succeed");
519        let vn = electric_potential(-ELEMENTARY_CHARGE, 1.0).expect("should succeed");
520        assert!(vp > 0.0);
521        assert!(vn < 0.0);
522        assert!((vp + vn).abs() < TOL);
523    }
524
525    #[test]
526    fn test_electric_field_positive() {
527        let e = electric_field(-ELEMENTARY_CHARGE, 1.0).expect("should succeed");
528        assert!(e > 0.0, "electric_field returns magnitude");
529    }
530
531    #[test]
532    fn test_electrostatic_potential_energy() {
533        // U of H atom electron-proton system at Bohr radius a₀
534        use crate::constants::physical::BOHR_RADIUS;
535        let u = electrostatic_potential_energy(ELEMENTARY_CHARGE, -ELEMENTARY_CHARGE, BOHR_RADIUS)
536            .expect("should succeed");
537        // Expected ≈ −27.21 eV = −4.36e-18 J
538        assert!(u < 0.0, "Opposite charges: negative PE");
539        assert!((u + 4.36e-18).abs() < 1e-20, "U = {u:.4e} J");
540    }
541
542    #[test]
543    fn test_capacitor_energy() {
544        // C = 1 μF, V = 10 V => U = 50 μJ
545        let u = capacitor_energy(1e-6, 10.0).expect("should succeed");
546        assert!((u - 50e-6).abs() < 1e-15);
547    }
548
549    // ─── Magnetostatics ───────────────────────────────────────────────────────
550
551    #[test]
552    fn test_magnetic_force_perpendicular() {
553        // F = |q|vB for angle = π/2
554        let f = magnetic_force(ELEMENTARY_CHARGE, 1e6, 1.0, PI / 2.0).expect("should succeed");
555        let expected = ELEMENTARY_CHARGE * 1e6 * 1.0;
556        assert!((f - expected).abs() < 1e-30);
557    }
558
559    #[test]
560    fn test_magnetic_force_parallel_zero() {
561        // Velocity parallel to B => zero force
562        let f = magnetic_force(ELEMENTARY_CHARGE, 1e6, 1.0, 0.0).expect("should succeed");
563        assert!(f.abs() < TOL);
564    }
565
566    #[test]
567    fn test_biot_savart_wire() {
568        // I = 1 A, r = 1 m => B = μ₀/(2π) ≈ 200 nT
569        let b = biot_savart_wire(1.0, 1.0).expect("should succeed");
570        let expected = MAGNETIC_CONSTANT / (2.0 * PI);
571        assert!((b - expected).abs() < 1e-15);
572    }
573
574    #[test]
575    fn test_cyclotron_radius_proton() {
576        use crate::constants::physical::PROTON_MASS;
577        // v = 1e6 m/s, B = 1 T => r = m_p * v / (e * B)
578        let r = cyclotron_radius(PROTON_MASS, 1e6, ELEMENTARY_CHARGE, 1.0).expect("should succeed");
579        let expected = PROTON_MASS * 1e6 / ELEMENTARY_CHARGE;
580        assert!((r - expected).abs() < 1e-15);
581    }
582
583    // ─── Special relativity ───────────────────────────────────────────────────
584
585    #[test]
586    fn test_lorentz_factor_zero_velocity() {
587        let gamma = lorentz_factor(0.0).expect("should succeed");
588        assert!((gamma - 1.0).abs() < TOL);
589    }
590
591    #[test]
592    fn test_lorentz_factor_high_velocity() {
593        // v = 0.99c => gamma ≈ 7.089
594        let v = 0.99 * SPEED_OF_LIGHT;
595        let gamma = lorentz_factor(v).expect("should succeed");
596        assert!((gamma - 7.089).abs() < 0.001, "γ = {gamma:.4}");
597    }
598
599    #[test]
600    fn test_lorentz_factor_superluminal_fails() {
601        assert!(lorentz_factor(SPEED_OF_LIGHT).is_err());
602        assert!(lorentz_factor(SPEED_OF_LIGHT + 1.0).is_err());
603    }
604
605    #[test]
606    fn test_relativistic_energy_at_rest() {
607        // v=0 => E = mc²
608        let m = crate::constants::physical::ELECTRON_MASS;
609        let e_rel = relativistic_energy(m, 0.0).expect("should succeed");
610        let e_rest = rest_energy(m).expect("should succeed");
611        assert!((e_rel - e_rest).abs() < 1e-40);
612    }
613
614    #[test]
615    fn test_relativistic_kinetic_energy_low_v_matches_classical() {
616        // At low v << c, relativistic KE should be > classical KE and the total
617        // energy should closely match mc² + ½mv² (to better than 1 part in 10^9).
618        //
619        // Note: the computation (γ-1)mc² suffers catastrophic f64 cancellation for
620        // tiny (v/c), so we verify the invariant E_total² = (pc)² + (mc²)²
621        // (energy-momentum relation) rather than the Taylor expansion directly.
622        let m = 1.0_f64;
623        let v = 1e7_f64; // 10^7 m/s: v/c ≈ 0.033, small but still significant
624        let k_rel = relativistic_kinetic_energy(m, v).expect("should succeed");
625        let p_rel = relativistic_momentum(m, v).expect("should succeed");
626        let mc2 = rest_energy(m).expect("should succeed");
627        // Energy-momentum invariant: (K + mc²)² = (pc)² + (mc²)²
628        let e_total = k_rel + mc2;
629        let lhs = e_total * e_total;
630        let rhs = (p_rel * SPEED_OF_LIGHT).powi(2) + mc2 * mc2;
631        let rel_err = (lhs - rhs).abs() / rhs;
632        assert!(
633            rel_err < 1e-12,
634            "Energy-momentum invariant violated: LHS={lhs:.8e}, RHS={rhs:.8e}, rel err={rel_err:.2e}"
635        );
636        // Also verify that relativistic KE exceeds classical KE
637        let k_classical = 0.5 * m * v * v;
638        assert!(
639            k_rel > k_classical,
640            "Relativistic KE must exceed classical KE"
641        );
642    }
643
644    #[test]
645    fn test_velocity_addition_stays_subluminal() {
646        // 0.9c + 0.9c should be < c
647        let v = 0.9 * SPEED_OF_LIGHT;
648        let w = relativistic_velocity_addition(v, v).expect("should succeed");
649        assert!(w < SPEED_OF_LIGHT, "w = {w:.6e} m/s must be < c");
650        // Expected: (1.8c)/(1.81) ≈ 0.994c
651        let expected = (v + v) / (1.0 + v * v / (SPEED_OF_LIGHT * SPEED_OF_LIGHT));
652        assert!((w - expected).abs() < 1.0);
653    }
654
655    #[test]
656    fn test_compton_shift_ninety_degrees() {
657        use crate::constants::physical::COMPTON_WAVELENGTH;
658        let shift = compton_wavelength_shift(PI / 2.0);
659        assert!((shift - COMPTON_WAVELENGTH).abs() < 1e-25);
660    }
661
662    #[test]
663    fn test_impedance_of_free_space() {
664        let z0 = impedance_of_free_space();
665        // Standard value ≈ 376.73 Ω
666        assert!((z0 - 376.73).abs() < 0.01, "Z₀ = {z0:.2} Ω");
667    }
668
669    #[test]
670    fn test_ohm_resistance() {
671        let r = ohm_resistance(12.0, 3.0).expect("should succeed");
672        assert!((r - 4.0).abs() < TOL);
673        assert!(ohm_resistance(12.0, 0.0).is_err());
674    }
675}