PlasmaToroid / src /physics.js
AK51's picture
Upload 195 files
e0276cc verified
Raw History Blame Contribute Delete
9.4 kB
// Simplified plasma toroid physics model (pure ESM, no three.js).
// All equations and constants follow .agents/tasks/plan.md.
// Educational approximation only — not engineering guidance.
export const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
export function smoothstep(e0, e1, x) {
const t = clamp((x - e0) / (e1 - e0), 0, 1);
return t * t * (3 - 2 * t);
}
// Soft window: 1 inside [a, b], fading to 0 over ~15 % outside each edge.
export function windowFn(x, a, b) {
return smoothstep(a * 0.85, a, x) * (1 - smoothstep(b, b * 1.15, x));
}
// Townsend/Paschen coefficients A (1/cm/Torr), B (V/cm/Torr), first ionization energy,
// glow color and empirical mode thresholds (pressures in Torr, powers in W).
export const GASES = {
He: { name: 'Helium', A: 3, B: 34, ionization_eV: 24.59, color: '#ffb08a', pRingMin: 1.0, pRingPower: 60, pBean: null, n0: null },
Ne: { name: 'Neon', A: 4, B: 100, ionization_eV: 21.56, color: '#ff4d6d', pRingMin: 0.5, pRingPower: 45, pBean: 2.5, n0: 14 },
Ar: { name: 'Argon', A: 12, B: 180, ionization_eV: 15.76, color: '#b46cff', pRingMin: 0.2, pRingPower: 25, pBean: 3.0, n0: 12 },
Kr: { name: 'Krypton', A: 17, B: 240, ionization_eV: 14.0, color: '#d9d4ff', pRingMin: 0.1, pRingPower: 18, pBean: 1.8, n0: 12 },
Xe: { name: 'Xenon', A: 26, B: 350, ionization_eV: 12.13, color: '#8f7bff', pRingMin: 0.08, pRingPower: 15, pBean: 1.2, n0: 13 },
};
// Bead-count limits for the plasma-bean ring.
export const BEAD_MIN = 6;
export const BEAD_MAX = 32;
// dFactor: characteristic discharge length / vessel diameter (torus = tube bore).
export const SHAPES = {
sphere: { name: 'Sphere', dFactor: 1.0, coupling: 0.7, beanShift: 1.0 },
cylinder: { name: 'Cylinder', dFactor: 1.0, coupling: 0.65, beanShift: 1.1 },
torus: { name: 'Torus (donut)', dFactor: 0.38, coupling: 0.8, beanShift: 0.9 },
};
export const COOLING_OPTIONS = {
none: 'None',
heatsink: 'Heatsink',
fan: 'Heatsink + fan',
};
export const PARAM_RANGES = {
shape: { options: Object.keys(SHAPES), default: 'sphere', unit: '' },
vesselDiameter_mm: { min: 60, max: 130, step: 5, default: 80, unit: 'mm' },
gas: { options: Object.keys(GASES), default: 'Xe', unit: '' },
pressure_torr: { min: 0.01, max: 10, step: null, log: true, default: 0.5, unit: 'Torr' },
supplyVoltage_V: { min: 6, max: 36, step: 0.5, default: 24, unit: 'V' },
frequency_MHz: { min: 5, max: 20, step: 0.1, default: 12, unit: 'MHz' },
coilTurns: { min: 2, max: 8, step: 1, default: 4, unit: 'turns' },
drive_pct: { min: 0, max: 100, step: 1, default: 70, unit: '%' },
currentLimit_A: { min: 1, max: 5, step: 0.1, default: 3.5, unit: 'A' },
ambient_C: { min: 0, max: 40, step: 1, default: 25, unit: '°C' },
cooling: { options: Object.keys(COOLING_OPTIONS), default: 'fan', unit: '' },
mosfetLimit_C: { min: 80, max: 175, step: 1, default: 125, unit: '°C' },
autoShutdown: { type: 'boolean', default: true },
autoIgnite: { type: 'boolean', default: true },
timeSpeed: { options: [1, 2, 5, 10, 30, 60], default: 1, unit: '×' },
};
// Circuit constants.
export const Q_TANK = 4;
export const R_EQ = 6; // Ω, equivalent load seen by the supply
export const GAMMA = 0.01; // secondary emission coefficient
const LN_LN_GAMMA = Math.log(Math.log(1 + 1 / GAMMA)); // ≈ 1.529
export const SPARK_FACTOR = 0.6; // spark-assisted ignition threshold (× V_b)
export const SUSTAIN_FACTOR = 0.55; // sustain threshold once lit (× V_b)
export const RDS_TEMPCO = 0.004; // MOSFET loss rise per °C of junction temperature above 25 °C (R_ds(on) derating)
/** Resonant frequency of the tank in MHz (L = 0.125 µH·N², 4 turns → 12 MHz). */
export const resonanceMHz = (N) => 12 * (4 / N);
/** Raizer-form Paschen breakdown voltage (V). Infinity left of the Paschen curve. */
export function breakdownVoltage(gas, p_torr, d_cm) {
const g = typeof gas === 'string' ? GASES[gas] : gas;
const pd = p_torr * d_cm;
if (!(pd > 0)) return Infinity;
const den = Math.log(g.A * pd) - LN_LN_GAMMA;
if (den <= 0.05) return Infinity;
return (g.B * pd) / den;
}
/** Ignition / sustain state machine step. Returns the new lit state. */
export function ignitionCheck({ lit, sparkActive, driverOn, V_coil, V_b }) {
if (!driverOn || !Number.isFinite(V_b)) return false;
if (lit) return V_coil >= SUSTAIN_FACTOR * V_b;
return V_coil >= V_b || (!!sparkActive && V_coil >= SPARK_FACTOR * V_b);
}
/**
* Compute the electrical operating point, losses, plasma state and visual targets.
* @param {object} params simulation parameters (see PARAM_RANGES keys)
* @param {{lit?:boolean, driverOn?:boolean, tempJ?:number, mosfetFailed?:boolean}} st
*/
export function computeOperatingPoint(params, { lit = false, driverOn = true, tempJ = 25, mosfetFailed = false } = {}) {
const gas = GASES[params.gas];
const shape = SHAPES[params.shape];
const Vs = params.supplyVoltage_V;
const f = params.frequency_MHz;
const N = params.coilTurns;
const p = params.pressure_torr;
const D = params.vesselDiameter_mm;
const u = clamp(params.drive_pct / 100, 0, 1);
const on = driverOn && !mosfetFailed;
const isLit = on && lit;
const f0 = resonanceMHz(N);
const g = 1 / Math.sqrt(1 + (Q_TANK * (f / f0 - f0 / f)) ** 2);
const V_coil = on ? 36 * Vs * u * g * Math.sqrt(N / 4) : 0;
const L = 0.125e-6 * N * N;
const I_coil = V_coil / (2 * Math.PI * f * 1e6 * L);
const d_cm = (D / 10) * shape.dFactor;
const V_b = breakdownVoltage(gas, p, d_cm);
const P_lim = Vs * params.currentLimit_A;
const P_demand = on ? ((Vs * u) ** 2 / R_EQ) * (0.3 + 0.7 * g) * (isLit ? 1.3 : 1.0) : 0;
const P_in = Math.min(P_demand, P_lim);
const currentLimited = on && P_demand > P_lim;
const I_in = Vs > 0 ? P_in / Vs : 0;
const eta = clamp(0.8 - 0.025 * (f - 8), 0.55, 0.85);
const P_mos0 = P_in * (1 - eta); // MOSFET loss at T_j = 25 °C
const P_mos = P_mos0 * (1 + RDS_TEMPCO * (tempJ - 25));
const P_plasma = isLit ? P_in * eta * shape.coupling : 0;
const R_ac = 0.05 * (N / 4) * Math.sqrt(f / 10);
const P_coil = I_coil * I_coil * R_ac + 0.04 * P_plasma;
const P_glass = 0.6 * P_plasma;
const B = clamp(P_plasma / 60, 0, 1.5);
const m_s = Number.isFinite(V_b) ? V_coil / (SUSTAIN_FACTOR * V_b) : 0;
const inst = isLit
? clamp(Math.max((1.15 - m_s) / 0.15, (0.6 - g) / 0.3, currentLimited && P_demand > 1.25 * P_lim ? 0.6 : 0), 0, 1)
: 0;
const pb = gas.pBean != null ? gas.pBean * shape.beanShift : null;
// Plasma state (priority order).
let state;
let reason = '';
if (!isLit) {
state = 'off';
if (mosfetFailed) reason = 'MOSFET failed';
else if (!driverOn) reason = 'driver off';
else if (!Number.isFinite(V_b)) reason = 'pressure too low to break down (left of Paschen minimum)';
else if (V_coil < V_b) reason = `below breakdown: need ${Math.round(V_b)} V, have ${Math.round(V_coil)} V`;
else reason = 'not ignited';
} else if (inst >= 0.5) {
state = 'unstable';
reason = g < 0.6 ? 'detuned from resonance' : m_s < 1.15 ? 'barely sustained' : 'overdriven / current limited';
} else if (p < gas.pRingMin || P_plasma < gas.pRingPower) {
state = 'diffuse';
reason = p < gas.pRingMin ? 'pressure below ring threshold' : 'plasma power below ring threshold';
} else if (pb != null && p >= pb && P_plasma >= 20 && P_plasma <= 80) {
state = 'bean';
} else {
state = 'ring';
}
// Beads only show in the 'bean' state (keeps the visuals consistent with the HUD): half-formed at p_bean,
// fully formed by 1.1 × p_bean.
const beanMix = state === 'bean' ? 0.5 + 0.5 * smoothstep(pb, 1.1 * pb, p) : 0;
// Azimuthal mode number m ∝ √(p·D) (striation length shrinks with p·R), weak power term.
const beadCount = pb != null
? clamp(Math.round(gas.n0 * Math.sqrt((p / pb) * (D / 80)) * (P_plasma / 40) ** 0.3), BEAD_MIN, BEAD_MAX)
: BEAD_MIN; // no bean mode for this gas; minimum keeps shader math finite
const Bc = Math.min(B, 1);
let ringRadiusFrac, ringHeightFrac;
if (params.shape === 'sphere') {
ringRadiusFrac = 0.62 - 0.08 * B;
ringHeightFrac = -0.35 + 0.25 * Bc;
} else if (params.shape === 'cylinder') {
ringRadiusFrac = 0.6;
ringHeightFrac = -0.6 + 0.2 * Bc;
} else {
ringRadiusFrac = 1.0; // torus: fraction of the major radius
ringHeightFrac = 0;
}
// minorFrac in vessel-radius units; torus capped at 0.8 × tube radius (tube = 0.3 R).
let minorFrac = clamp(0.12 * (0.5 / p) ** 0.35, 0.03, 0.35);
if (params.shape === 'torus') minorFrac = Math.min(minorFrac, 0.8 * 0.3);
const rotSpeed = 0.4 + 2.5 * B * Math.sqrt(f / 10);
const beadSpeed = 0.6 * rotSpeed;
const wobble = 0.02 + 0.15 * inst;
const flicker = inst;
const columnStretch = params.shape === 'cylinder' && (state === 'diffuse' || p > 2) ? 1 + 1.5 * Bc : 1;
const diffuseAmount = { diffuse: 1.0, unstable: 0.5, ring: 0.15, bean: 0.1, off: 0 }[state];
return {
g, f0, V_coil, I_coil, V_b, P_demand, P_in, I_in, currentLimited, eta, P_mos, P_mos0, P_coil, P_glass, P_plasma,
B, inst, state, reason, beanMix, beadCount, ringRadiusFrac, ringHeightFrac, minorFrac, rotSpeed, beadSpeed,
wobble, flicker, columnStretch, diffuseAmount, color: gas.color,
};
}