use core::f64::consts::PI;
use gravitics::electrodes::{Element, Ring, charges, field, field_at, polyline, solve, stack};
use gravitics::electrogravity::{
ZHANG_KMQ, alignment_torque, image_force, sphere_force, spheroid_polarisability, torsion_constant, vortex_constant, vortex_spin,
};
use gravitics::propulsion::ion_wind_thrust;
use magnetics::magnets::{solenoid_bz, susceptibility_force};
use metrology::{C, EPS_0, G, G0};
use plasmoid::discharge::{
AIR, AIR_DIAMETER, AIR_ION_MOBILITY, AIR_MOLAR_MASS, AIR_VISCOSITY, N2_ION_MASS, floating_potential, gas_density,
ion_mobility, mean_free_path, mean_speed, paschen, peek_onset, sphere_drag, sphere_spin_drag,
};
const WIRE: f64 = 0.75e-3;
const TANK_RADIUS: f64 = 0.05;
const SUSPENSION: [f64; 3] = [0.0, 0.0, 0.05];
const COIL_RADIUS: f64 = 0.035;
const LEAKAGE: f64 = 10e-6 / 50e3;
const RIPPLE_FREQUENCY: f64 = 30e3;
const LUJIANG: (f64, f64) = (31.25, 117.29);
const GLOW_CHARGING: f64 = 1e-3;
const DT: f64 = 1e-3;
const LEAD_TOP_INNER: [[f64; 3]; 5] = [[0.0, -0.035, 0.06], [-0.005, -0.054, 0.056], [-0.016, -0.061, 0.04], [-0.016, -0.061, -0.016], [-0.016, -0.061, -0.027]];
const LEAD_BOTTOM_INNER: [[f64; 3]; 5] = [[0.0, -0.035, -0.06], [0.005, -0.054, -0.056], [0.016, -0.061, -0.04], [0.016, -0.061, 0.016], [0.016, -0.061, 0.027]];
const LEAD_TOP_OUTER: [[f64; 3]; 6] = [[0.0, -0.035, 0.18], [0.0, -0.05, 0.205], [-0.09, -0.03, 0.215], [-0.21, 0.0, 0.15], [-0.25, 0.0, -0.06], [-0.26, 0.0, -0.176]];
const LEAD_BOTTOM_OUTER: [[f64; 3]; 4] = [[0.0, -0.035, -0.18], [0.0, -0.05, -0.205], [-0.06, -0.06, -0.225], [-0.12, -0.06, -0.234]];
fn geometry() -> (Vec<Element>, Vec<bool>) {
let mut parts: Vec<(Element, bool)> = Vec::new();
for (z0, step, live) in [(0.065, 0.01, true), (-0.065, -0.01, false)] {
parts.extend(stack(COIL_RADIUS, z0, step, 12).into_iter().map(|r| (Element::Ring(r), live)));
}
for (points, step, live) in [
(&LEAD_TOP_INNER[..], 0.01, true),
(&LEAD_BOTTOM_INNER[..], 0.01, false),
(&LEAD_TOP_OUTER[..], 0.02, true),
(&LEAD_BOTTOM_OUTER[..], 0.02, false),
] {
parts.extend(polyline(points, step).into_iter().map(|s| (Element::Segment(s), live)));
}
parts.into_iter().unzip()
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Drive {
Single,
Symmetric,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Ball {
pub radius: f64,
pub density: f64,
pub eps_r: f64,
pub susceptibility: f64,
pub aspect: f64,
pub charge: f64,
pub dipole: f64,
}
impl Ball {
fn polyethylene() -> Ball {
let radius = (3.0 * 0.35e-3 / 950.0 / (4.0 * PI)).cbrt();
Ball { radius, density: 950.0, eps_r: 2.3, susceptibility: -9.7e-6, aspect: 1.01, charge: 0.2e-9, dipole: 0.1e-9 * radius }
}
fn ptfe() -> Ball {
Ball { density: 2200.0, eps_r: 2.1, susceptibility: -1.05e-5, ..Ball::polyethylene() }
}
fn volume(&self) -> f64 {
4.0 / 3.0 * PI * self.radius.powi(3)
}
fn mass(&self) -> f64 {
self.density * self.volume()
}
fn inertia(&self) -> f64 {
0.4 * self.mass() * self.radius * self.radius
}
}
#[derive(Clone, Debug, PartialEq)]
struct Settings {
pub volts: f64,
pub drive: Drive,
pub pressure: f64,
pub temperature: f64,
pub ball: Ball,
pub torsion: f64,
pub thread_q: f64,
pub rise: f64,
pub fall: f64,
pub ripple: f64,
pub electron_temperature: f64,
pub earth_field: [f64; 3],
pub vortex: Option<f64>,
}
impl Default for Settings {
fn default() -> Settings {
let ball = Ball::polyethylene();
Settings {
volts: 50e3,
drive: Drive::Single,
pressure: 1.0,
temperature: 293.15,
ball,
torsion: torsion_constant(ball.inertia(), 30.0),
thread_q: 20.0,
rise: 0.05,
fall: 1.0,
ripple: 0.01,
electron_temperature: 3.0,
earth_field: earth_field(),
vortex: None,
}
}
}
fn earth_field() -> [f64; 3] {
let e = magnetics::main_field::wmm2025().elements(2025.0, LUJIANG.0.to_radians(), LUJIANG.1.to_radians(), 0.0);
[e.x * 1e-9, -e.y * 1e-9, -e.z * 1e-9]
}
#[derive(Clone, Debug)]
struct Rig {
pub settings: Settings,
elements: Vec<Element>,
unit: Vec<f64>,
centre: f64,
capacitance: f64,
energy: f64,
}
impl Rig {
fn new(settings: Settings) -> Rig {
let (elements, live) = geometry();
let (top, bottom) = match settings.drive {
Drive::Single => (1.0, 0.0),
Drive::Symmetric => (0.5, -0.5),
};
let volts: Vec<f64> = live.iter().map(|&l| if l { top } else { bottom }).collect();
let unit = solve(&elements, WIRE, &volts);
let capacitance = unit.iter().zip(&live).filter(|(_, l)| **l).map(|(q, _)| q).sum();
let energy = 0.5 * unit.iter().zip(&volts).map(|(q, v)| q * v).sum::<f64>();
let [x, y, z] = field_at(&elements, &unit, [0.0; 3]);
Rig { settings, elements, unit, centre: (x * x + y * y + z * z).sqrt(), capacitance, energy }
}
fn at_rest(&self) -> State {
State { charge: self.settings.ball.charge, dipole: self.settings.ball.dipole, ..State::default() }
}
fn capacitance(&self) -> f64 {
self.capacitance
}
fn tip_field(&self) -> f64 {
let tips = [LEAD_TOP_INNER[4], LEAD_BOTTOM_INNER[4]];
let near = |p: [f64; 3], q: [f64; 3]| (0..3).map(|k| (p[k] - q[k]).powi(2)).sum::<f64>().sqrt() < 1e-9;
self.elements
.iter()
.zip(&self.unit)
.filter_map(|(e, q)| match e {
Element::Segment(s) if tips.iter().any(|&t| near(s.b, t)) => {
let l = (0..3).map(|k| (s.b[k] - s.a[k]).powi(2)).sum::<f64>().sqrt();
Some((q / l).abs() / (2.0 * PI * EPS_0 * WIRE))
}
_ => None,
})
.fold(0.0, f64::max)
}
fn energy(&self) -> f64 {
self.energy
}
fn unit_field(&self, p: [f64; 3]) -> [f64; 3] {
field_at(&self.elements, &self.unit, p)
}
fn breakdown(&self) -> Option<f64> {
let span = 2.0 * TANK_RADIUS;
paschen(AIR, 0.01, self.settings.pressure * span).map(|v| v / (self.centre * span))
}
fn step(&self, s: &mut State) -> Report {
let set = &self.settings;
let ball = &set.ball;
let (m, a, vol) = (ball.mass(), ball.radius, ball.volume());
let target = if s.on { set.volts } else { 0.0 };
let tau = if s.on { set.rise } else { set.fall };
let before = s.supply;
s.supply += (target - before) * (1.0 - (-DT / tau).exp());
let ramp = (s.supply - before) / DT;
let wall_before = s.wall;
let limit = self.breakdown();
let clamping = match limit {
Some(l) if (s.supply - s.wall).abs() > l * (1.0 + 1e-9) => {
s.wall = s.supply - l * (s.supply - s.wall).signum();
true
}
_ => false,
};
let sustained = limit.is_some_and(|l| set.ripple * s.supply.abs() > l);
s.glow = clamping || sustained;
let across = s.supply - s.wall;
let mut glow_current = self.capacitance * (s.wall - wall_before).abs() / DT;
if sustained {
glow_current += 2.0 * PI * RIPPLE_FREQUENCY * self.capacitance * set.ripple * s.supply.abs();
}
let glow_current = glow_current.min(LEAKAGE * set.volts);
let field = |p: [f64; 3]| self.unit_field(p).map(|e| e * across);
let r = s.position;
let e = field(r);
let h = 1e-4;
let shifted = |k: usize, d: f64| {
let mut p = r;
p[k] += d;
field(p)
};
let grad_e2: [f64; 3] = core::array::from_fn(|k| (norm2(shifted(k, h)) - norm2(shifted(k, -h))) / (2.0 * h));
if s.glow {
let floating = 4.0 * PI * EPS_0 * a * floating_potential(set.electron_temperature, N2_ION_MASS);
let decay = (-DT / GLOW_CHARGING).exp();
s.charge = floating + (s.charge - floating) * decay;
s.dipole *= decay;
}
let q = s.charge;
let rho_gas = gas_density(set.pressure, set.temperature, AIR_MOLAR_MASS);
let knudsen = mean_free_path(set.pressure, set.temperature, AIR_DIAMETER) / a;
let weight = [0.0, 0.0, -m * G0];
let buoyancy = [0.0, 0.0, rho_gas * vol * G0];
let polarisation = grad_e2.map(|g| sphere_force(a, ball.eps_r, g));
let coulomb = e.map(|x| q * x);
let axis = [s.twist.cos(), s.twist.sin(), 0.0];
let dipole_force = {
let plus = field([r[0] + h * axis[0], r[1] + h * axis[1], r[2]]);
let minus = field([r[0] - h * axis[0], r[1] - h * axis[1], r[2]]);
core::array::from_fn(|k| s.dipole * (plus[k] - minus[k]) / (2.0 * h))
};
let image = {
let off = r[1].hypot(r[2]);
if off == 0.0 {
[0.0; 3]
} else {
let f = image_force(q, TANK_RADIUS - off, 6.0) - image_force(q, TANK_RADIUS + off, 6.0);
[0.0, f * r[1] / off, f * r[2] / off]
}
};
let ion_drag = {
let size = norm2(e).sqrt();
let push = ion_wind_thrust(glow_current, 2.0 * TANK_RADIUS, ion_mobility(AIR_ION_MOBILITY, set.pressure, set.temperature)) * (a / TANK_RADIUS).powi(2);
if size > 0.0 { e.map(|x| push * x / size) } else { [0.0; 3] }
};
let gas_drag = s.velocity.map(|v| -sphere_drag(a, AIR_VISCOSITY, knudsen) * v);
let current = LEAKAGE * s.supply + self.capacitance * ramp;
let coil = |z: f64| solenoid_bz(0.12 - z, COIL_RADIUS, 0.12, 100.0, current);
let b = [set.earth_field[0], set.earth_field[1], set.earth_field[2] + coil(r[2])];
let lorentz = cross(s.velocity, b).map(|x| q * x);
let magnetisation = [0.0, 0.0, susceptibility_force(vol, ball.susceptibility, b[2] * (coil(r[2] + h) - coil(r[2] - h)) / (2.0 * h))];
let field_gravity = match set.drive {
Drive::Single => {
let to_coil = [-r[0], -r[1], 0.12 - r[2]];
let d = norm2(to_coil).sqrt();
let pull = G * m * self.energy * s.supply * s.supply / (C * C * d * d);
to_coil.map(|x| pull * x / d)
}
Drive::Symmetric => [0.0; 3],
};
let total: [f64; 3] = core::array::from_fn(|k| {
weight[k] + buoyancy[k] + polarisation[k] + coulomb[k] + dipole_force[k] + image[k] + ion_drag[k] + gas_drag[k] + lorentz[k] + magnetisation[k] + field_gravity[k]
});
let mut v: [f64; 3] = core::array::from_fn(|k| s.velocity[k] + total[k] / m * DT);
let moved: [f64; 3] = core::array::from_fn(|k| r[k] + v[k] * DT - SUSPENSION[k]);
let length = SUSPENSION[2];
let n = moved.map(|x| x / norm2(moved).sqrt());
s.position = core::array::from_fn(|k| SUSPENSION[k] + length * n[k]);
let radial = v[0] * n[0] + v[1] * n[1] + v[2] * n[2];
v = core::array::from_fn(|k| v[k] - radial * n[k]);
s.velocity = v;
let inertia = ball.inertia();
let horizontal = e[0].hypot(e[1]);
let azimuth = e[1].atan2(e[0]);
let (long, short) = spheroid_polarisability(ball.eps_r, ball.aspect);
let thread_torque = -set.torsion * s.twist;
let damping_torque = -(inertia * (set.torsion / inertia).sqrt() / set.thread_q + sphere_spin_drag(a, AIR_VISCOSITY, rho_gas, mean_speed(set.temperature, AIR_MOLAR_MASS))) * s.spin;
let alignment = -alignment_torque(vol, long - short, horizontal, s.twist - azimuth);
let dipole_torque = s.dipole * horizontal * (azimuth - s.twist).sin();
let vortex_torque = set.vortex.map_or(0.0, |f| inertia * vortex_spin(b[2], f));
s.spin += (thread_torque + damping_torque + alignment + dipole_torque + vortex_torque) / inertia * DT;
s.twist += s.spin * DT;
s.t += DT;
Report {
field: e,
across,
glow: s.glow,
glow_current,
current,
b,
weight,
buoyancy,
polarisation,
coulomb,
dipole_force,
image,
ion_drag,
gas_drag,
lorentz,
magnetisation,
field_gravity,
thread_torque,
damping_torque,
alignment,
dipole_torque,
vortex_torque,
}
}
fn run(&self, s: &mut State, duration: f64) -> Report {
let mut report = Report::default();
for _ in 0..(duration / DT).round() as usize {
report = self.step(s);
}
report
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct State {
pub t: f64,
pub on: bool,
pub supply: f64,
pub wall: f64,
pub position: [f64; 3],
pub velocity: [f64; 3],
pub twist: f64,
pub spin: f64,
pub charge: f64,
pub dipole: f64,
pub glow: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Report {
pub field: [f64; 3],
pub across: f64,
pub glow: bool,
pub glow_current: f64,
pub current: f64,
pub b: [f64; 3],
pub weight: [f64; 3],
pub buoyancy: [f64; 3],
pub polarisation: [f64; 3],
pub coulomb: [f64; 3],
pub dipole_force: [f64; 3],
pub image: [f64; 3],
pub ion_drag: [f64; 3],
pub gas_drag: [f64; 3],
pub lorentz: [f64; 3],
pub magnetisation: [f64; 3],
pub field_gravity: [f64; 3],
pub thread_torque: f64,
pub damping_torque: f64,
pub alignment: f64,
pub dipole_torque: f64,
pub vortex_torque: f64,
}
fn norm2(v: [f64; 3]) -> f64 {
v[0] * v[0] + v[1] * v[1] + v[2] * v[2]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn main() {
let rig = Rig::new(Settings::default());
let e = rig.unit_field([0.0; 3]).map(|x| x * 50e3);
println!("US 12,417,870 at 50 kV: field at the ball ({:.3e}, {:.3e}, {:.3e}) V/m, tilted {:.1}° by the leads", e[0], e[1], e[2], e[0].hypot(e[1]).atan2(-e[2]).to_degrees());
println!(" supply side {:.2} pF, {:.2} mJ stored", rig.capacitance() * 1e12, rig.energy() * 2.5e9 * 1e3);
let tip = rig.tip_field();
println!(" the bare copper tips reach {:.1} MV/m: corona in air above {:.1} kV (Peek)", tip * 50e3 / 1e6, peek_onset(WIRE, 1.0) / tip / 1e3);
let b = rig.settings.earth_field;
println!(" Earth's field at Lujiang {LUJIANG:?}: ({:.1}, {:.1}, {:.1}) µT", b[0] * 1e6, b[1] * 1e6, b[2] * 1e6);
let mut s = State { on: true, ..rig.at_rest() };
println!("switched on, then off at 4 s ({} Pa, ball {:.1} nC, patches {:.1} nC across its radius, 1% out of round):", rig.settings.pressure, s.charge * 1e9, s.dipole / rig.settings.ball.radius * 1e9);
for step in 1..=8000 {
if step == 4001 {
s.on = false;
}
let r = rig.step(&mut s);
if step % 1000 == 0 {
println!(" {:.0} s: supply {:5.1} kV, turned {:+6.1}°, swung {:5.1} µm, glow {}", s.t, s.supply / 1e3, s.twist.to_degrees(), s.position[0].hypot(s.position[1]) * 1e6, s.glow);
}
if step == 3000 {
print_forces(&r, rig.settings.ball.mass());
}
}
let neutral = Ball { charge: 0.0, dipole: 0.0, aspect: 1.0, ..Ball::polyethylene() };
for (name, settings) in [
("a neutral, round ball", Settings { ball: neutral, ..Settings::default() }),
("the PTFE ball", Settings { ball: Ball::ptfe(), ..Settings::default() }),
("the symmetric drive", Settings { drive: Drive::Symmetric, ..Settings::default() }),
("at 10 Pa", Settings { pressure: 10.0, ..Settings::default() }),
] {
let rig = Rig::new(settings);
let mut s = State { on: true, ..rig.at_rest() };
let (mut turn, mut swing) = (0.0f64, 0.0f64);
for _ in 0..4000 {
rig.step(&mut s);
turn = turn.max(s.twist.abs());
swing = swing.max(s.position[0].hypot(s.position[1]));
}
let gas = rig.breakdown().map_or("no breakdown".to_string(), |v| format!("breaks down past {v:.0} V"));
println!("{name:<22} turns up to {:5.1}°, swings up to {:6.1} µm ({gas})", turn.to_degrees(), swing * 1e6);
}
for &(name, k) in &ZHANG_KMQ {
let rig = Rig::new(Settings { vortex: Some(vortex_constant(k)), ..Settings::default() });
let r = rig.run(&mut rig.at_rest(), DT);
println!("Zhang's vortex, k_mq {name}: {:.1e} N m, {:.1e} times what the thread resists per radian", r.vortex_torque, r.vortex_torque / rig.settings.torsion);
}
println!("the weighing rig, the polarisation pull on the nylon ring (reported: −1.0 g, +0.9 g):");
for (name, top, bottom) in [("top live", 55e3, 0.0), ("bottom live", 0.0, 55e3), ("±27.5 kV", 27.5e3, -27.5e3)] {
let lift = ring_pull(top, bottom) / G0 * 1e3; let grams = |x: f64| if x.abs() < 5e-3 { 0.0 } else { -x }; println!(" {name:<12} weighs {:+.2} to {:+.2} g", grams(lift * 2.6), grams(lift * 0.722));
if top == -bottom {
assert!(lift.abs() < 1e-6); } else {
assert!(lift.abs() > 0.03 && lift.abs() < 0.2 && lift.signum() == if top > 0.0 { 1.0 } else { -1.0 });
}
}
}
fn print_forces(r: &Report, mass: f64) {
let w = mass * G0;
let size = |v: [f64; 3]| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt() / w;
println!(" every force at 3 s, as a fraction of the weight (field {:.3e} V/m, {:.0} V across the gas, coil current {:.1} µA, B {:.1} µT):", size(r.field) * w, r.across, r.current * 1e6, r.b[2] * 1e6);
for (name, v) in [
("buoyancy", r.buoyancy),
("polarisation", r.polarisation),
("Coulomb", r.coulomb),
("charge dipole", r.dipole_force),
("image in the wall", r.image),
("ion drag", r.ion_drag),
("gas drag", r.gas_drag),
("Lorentz", r.lorentz),
("diamagnetism", r.magnetisation),
("field energy (GR)", r.field_gravity),
] {
println!(" {name:<18} {:.1e}", size(v));
}
println!(" torques (N m): thread {:.1e}, damping {:.1e}, shape {:.1e}, charge patches {:.1e}, vortex {:.1e}; glow {} ({:.1e} A)", r.thread_torque, r.damping_torque, r.alignment, r.dipole_torque, r.vortex_torque, r.glow, r.glow_current);
assert!(r.weight[2] < 0.0);
}
fn ring_pull(top: f64, bottom: f64) -> f64 {
let (a, n) = (0.75e-3, 200);
let mut rings: Vec<Ring> = stack(a, 0.025 + a, 2.0 * a, n);
rings.extend(stack(a, -0.025 - a, -2.0 * a, n));
let volts: Vec<f64> = (0..2 * n).map(|i| if i < n { top } else { bottom }).collect();
let q = charges(&rings, a, &volts);
let e2 = |rho: f64, z: f64| {
let [x, y] = field(&rings, &q, rho, z);
x * x + y * y
};
let steps = 60;
let dr = 0.03 / steps as f64;
let integral: f64 = (0..=steps)
.map(|i| {
let rho = 0.02 + dr * i as f64;
let w = if i == 0 || i == steps { 0.5 } else { 1.0 };
w * (e2(rho, 0.025) - e2(rho, -0.025)) * 2.0 * PI * rho * dr
})
.sum();
0.5 * metrology::EPS_0 * integral
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f64, b: f64, rel: f64) -> bool {
(a / b - 1.0).abs() < rel
}
fn horizontal(p: [f64; 3]) -> f64 {
p[0].hypot(p[1])
}
fn switch_on(settings: Settings, seconds: f64) -> (Rig, State, f64, f64) {
let rig = Rig::new(settings);
let mut s = State { on: true, ..rig.at_rest() };
let (mut twist, mut swing) = (0.0f64, 0.0f64);
for _ in 0..(seconds / DT) as usize {
rig.step(&mut s);
twist = twist.max(s.twist.abs());
swing = swing.max(horizontal(s.position));
}
(rig, s, twist, swing)
}
#[test]
fn the_field_matches_an_independent_solution() {
let rig = Rig::new(Settings::default());
let [x, y, z] = rig.unit_field([0.0; 3]);
assert!(close(x, 0.996_212, 1e-5) && close(y, 0.525_028, 1e-5) && close(z, -5.229_824, 1e-5), "{x} {y} {z}");
assert!(close(rig.capacitance(), 9.4622e-12, 1e-4) && close(rig.energy(), 4.7311e-12, 1e-4));
assert!(rig.breakdown().is_none()); }
#[test]
fn earth_field_at_zhangs_lab() {
let [x, y, z] = earth_field();
let h = x.hypot(y);
assert!(close((h * h + z * z).sqrt(), 50.0e-6, 2e-3));
assert!(((-z).atan2(h).to_degrees() - 48.1).abs() < 0.1);
assert!(((-y).atan2(x).to_degrees() + 5.69).abs() < 0.05, "{}", (-y).atan2(x).to_degrees());
}
#[test]
fn switched_off_nothing_moves() {
let rig = Rig::new(Settings::default());
let mut s = rig.at_rest();
let r = rig.run(&mut s, 2.0);
assert!(s.position == [0.0; 3] && s.twist == 0.0 && s.supply == 0.0);
assert!(r.coulomb == [0.0; 3] && r.alignment == 0.0 && r.dipole_torque == 0.0);
}
#[test]
fn a_charged_ball_turns_and_swings() {
let (_, s, twist, swing) = switch_on(Settings::default(), 5.0);
assert!(twist > 0.5 && twist < 1.5, "{twist}");
assert!(swing > 1e-4 && swing < 5e-4, "{swing}");
assert!(!s.glow);
let rig = Rig::new(Settings::default());
let mut s = State { on: true, ..rig.at_rest() };
let r = rig.run(&mut s, 0.5);
let w = -r.weight[2];
assert!(close(r.coulomb[2] / w, -0.0152, 0.02), "{}", r.coulomb[2] / w);
assert!(horizontal(r.polarisation) < horizontal(r.coulomb) / 10.0);
assert!(r.dipole_torque.abs() > 100.0 * r.alignment.abs());
assert!(r.field_gravity[2] / w < 1e-27 && r.lorentz.iter().all(|f| f.abs() < 1e-16));
}
#[test]
fn a_neutral_round_ball_barely_moves() {
let ball = Ball { charge: 0.0, dipole: 0.0, aspect: 1.0, ..Ball::polyethylene() };
let (_, _, twist, swing) = switch_on(Settings { ball, ..Settings::default() }, 5.0);
assert!(twist < 1e-12, "{twist}");
assert!(swing > 3e-6 && swing < 3e-5, "{swing}");
}
#[test]
fn a_glow_clamps_the_field_and_drains_the_ball() {
let settings = Settings { pressure: 10.0, ..Settings::default() };
let rig = Rig::new(settings.clone());
assert!(close(rig.breakdown().unwrap(), 574.3, 1e-3), "{:?}", rig.breakdown());
let (rig, s, _, _) = switch_on(settings, 2.0);
let r = rig.run(&mut s.clone(), DT);
assert!(close(r.across, 574.3, 1e-3) && !r.glow, "{}", r.across);
assert!(close(norm2(r.field).sqrt(), 574.3 * 5.3497, 1e-3));
let floating = 4.0 * PI * EPS_0 * Ball::polyethylene().radius * floating_potential(3.0, N2_ION_MASS);
assert!(close(s.charge, floating, 1e-6) && close(floating, -6.68e-12, 1e-2), "{floating:e}");
}
#[test]
fn the_symmetric_drive() {
let single = Rig::new(Settings::default());
let sym = Rig::new(Settings { drive: Drive::Symmetric, ..Settings::default() });
let (a, b) = (single.unit_field([0.0; 3]), sym.unit_field([0.0; 3]));
assert!(close(a[2], b[2], 0.01));
let pull = |rig: &Rig| {
let mut s = State { on: true, ..State::default() };
rig.run(&mut s, 0.5).polarisation[2]
};
let (ps, pm) = (pull(&single), pull(&sym));
assert!(ps > 0.0 && pm < 0.0 && pm.abs() < ps, "{ps:e} {pm:e}");
}
#[test]
fn zhangs_vortex_field_with_his_constants() {
let f: Vec<f64> = ZHANG_KMQ.iter().map(|&(_, k)| vortex_constant(k)).collect();
let rig = Rig::new(Settings { vortex: Some(f[0]), ball: Ball { charge: 0.0, dipole: 0.0, aspect: 1.0, ..Ball::polyethylene() }, ..Settings::default() });
let mut s = State { on: true, ..State::default() };
let r = rig.run(&mut s, 0.1);
assert!((r.vortex_torque / rig.settings.torsion).abs() < 1e-12);
let rig = Rig::new(Settings { vortex: Some(f[1]), ..Settings::default() });
let r = rig.step(&mut rig.at_rest());
assert!(r.vortex_torque.abs() > 1e20, "{:e}", r.vortex_torque);
}
#[test]
fn reference_values_for_the_web_model() {
let rig = Rig::new(Settings::default());
let u = rig.unit_field([0.0; 3]);
for (a, b) in u.iter().zip([0.996_211_955_909_649_1, 0.525_028_041_381_924, -5.229_823_828_310_781]) {
assert!(close(*a, b, 1e-12));
}
assert!(close(rig.capacitance(), 9.462_167_488_509_768e-12, 1e-12) && close(rig.energy(), 4.731_083_744_254_884e-12, 1e-12));
for (a, b) in rig.settings.earth_field.iter().zip([3.324_258_878_254_768e-5, 3.313_419_922_402_510_4e-6, -3.721_905_735_339_772_4e-5]) {
assert!(close(*a, b, 1e-12));
}
assert!(close(rig.settings.torsion, 1.214_551_168_410_677_5e-10, 1e-12));
assert!(close(rig.tip_field(), 363.016_540_111_795_3, 1e-9), "{}", rig.tip_field());
let mut s = State { on: true, ..rig.at_rest() };
rig.run(&mut s, 1.0);
assert!(close(s.twist, 0.928_723_630_577_296_3, 1e-9) && close(s.spin, 0.429_074_213_809_598_7, 1e-9));
assert!(close(s.position[0], 7.407_758_897_706_475e-5, 1e-9) && close(s.position[1], 4.120_759_664_600_089e-5, 1e-9));
let rig = Rig::new(Settings { pressure: 10.0, ..Settings::default() });
let mut s = State { on: true, ..rig.at_rest() };
rig.run(&mut s, 1.0);
assert!(close(s.wall, 49_425.712_880_056_68, 1e-12) && close(s.charge, -6.681_877_402_210_595e-12, 1e-9));
assert!(close(s.twist, 2.804_066_201_676_511e-5, 1e-6));
}
#[test]
fn the_ptfe_ball_is_the_mass_test() {
let pe = Rig::new(Settings::default());
let ptfe = Rig::new(Settings { ball: Ball::ptfe(), ..Settings::default() });
let push = |rig: &Rig| {
let mut s = State { on: true, supply: 50e3, charge: 0.2e-9, ..State::default() };
rig.step(&mut s).coulomb[0] / rig.settings.ball.mass()
};
assert!(close(push(&ptfe) / push(&pe), 950.0 / 2200.0, 1e-9));
}
#[test]
fn the_patent_rig_runs() {
main();
}
#[test]
fn in_two_seconds_the_ball_turns_and_swings() {
let rig = Rig::new(Settings::default());
let mut s = State { on: true, ..rig.at_rest() };
let (mut turn, mut swing) = (0.0f64, 0.0f64);
for _ in 0..2000 {
let r = rig.step(&mut s);
assert!(r.field[2] <= 0.0); turn = turn.max(s.twist.abs());
swing = swing.max(s.position[0].hypot(s.position[1]));
}
assert!(turn.to_degrees() > 20.0 && swing < 1e-3);
}
}