// 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, }; }