def generate_warpx_input(config: SimConfig) -> str:
"""Generate a WarpX input script string from a SimConfig.
Returns the input file content as a string. This can be written
to disk and passed to WarpX via ``warpx.inputs``.
"""
domain = config.nozzle.domain
res = config.nozzle.resolution
cfg_hash = _config_hash(config)
has_electrons = "e-" in config.plasma.species
# Random seed
seed = config.random_seed
if seed is None:
seed = int(cfg_hash, 16) % (2**31)
is_3d = getattr(res, "geometry", "2d_rz") == "3d"
lines = [
f"# WarpX input file generated by Helicon v{_helicon_version()}",
f"# Config hash: {cfg_hash}",
"#",
f"# Geometry: {'3D Cartesian' if is_3d else '2D RZ axisymmetric'}",
f"# Nozzle type: {config.nozzle.type}",
"",
"################",
"# GEOMETRY",
"################",
]
if is_3d:
np_phi = getattr(res, "np_phi", 32)
r_max = domain.r_max
lines.extend(
[
"geometry.dims = 3",
f"geometry.prob_lo = {domain.z_min} {-r_max} {-r_max}",
f"geometry.prob_hi = {domain.z_max} {r_max} {r_max}",
"",
f"amr.n_cell = {res.nz} {res.nr} {np_phi}",
"amr.max_level = 0",
"",
"# NOTE: 3D geometry is expensive on Apple Silicon CPU.",
"# Use cloud HPC offload (helicon v1.3) for production 3D runs.",
"",
"################",
"# BOUNDARY (3D)",
"################",
"boundary.field_lo = none none pec",
"boundary.field_hi = pml pml pml",
"boundary.particle_lo = absorbing absorbing absorbing",
"boundary.particle_hi = absorbing absorbing absorbing",
]
)
else:
lines.extend(
[
"geometry.dims = RZ",
f"geometry.prob_lo = {domain.z_min} 0.0",
f"geometry.prob_hi = {domain.z_max} {domain.r_max}",
"",
f"amr.n_cell = {res.nz} {res.nr}",
"amr.max_level = 0",
"",
"################",
"# BOUNDARY",
"################",
"boundary.field_lo = none pec",
"boundary.field_hi = pml pml",
"boundary.particle_lo = absorbing reflecting",
"boundary.particle_hi = absorbing absorbing",
]
)
lines.extend(
[
"",
"################",
"# TIMESTEP",
"################",
f"max_step = {config.timesteps}",
f"warpx.cfl = {config.dt_multiplier}",
"",
"################",
"# PARTICLES",
"################",
]
)
species_names = []
for sp in config.plasma.species:
safe_name = sp.replace("+", "_plus").replace("-", "_minus")
species_names.append(safe_name)
lines.append(f"particles.species_names = {' '.join(species_names)}")
lines.append("")
eV_to_J = 1.602176634e-19
m_e_physical = SPECIES_MASS["e-"]
# Reduced mass ratio: override electron mass
reduced_mass_ratio = config.plasma.mass_ratio is not None and has_electrons
if reduced_mass_ratio:
mr = config.plasma.mass_ratio
# Quantitative guidance (spec §8 v0.3: reduced mass ratio guidance documentation)
# Physical D/e ratio = 3670. Error bounds from PIC benchmarking literature:
# mr < 100 → qualitative only; η_d errors typically >30 %
# 100 ≤ mr < 500 → qualitative trends correct; ~20–30 % η_d error
# 500 ≤ mr < 1836 → semi-quantitative; ~5–15 % η_d error
# mr ≥ 1836 → near-physical; errors typically <5 % for D/e
if mr < 100:
_mr_guidance = (
"Qualitative trends only; quantitative errors >30 % in η_d. "
"Do not cite detachment numbers from this run."
)
elif mr < 500:
_mr_guidance = (
f"~20–30 % error in η_d vs full ratio ({3670:.0f}). "
"Suitable for fast parameter-space exploration only."
)
elif mr < 1836:
_mr_guidance = (
f"~5–15 % error in η_d vs full ratio ({3670:.0f}). "
"Semi-quantitative; use full ratio for validation cases."
)
else:
_mr_guidance = f"Near-physical ratio; η_d errors typically <5 % vs {3670:.0f}."
log.warning(
"Reduced mass ratio m_i/m_e = %.1f requested. %s "
"Output will be flagged mass_ratio_reduced=true.",
mr,
_mr_guidance,
)
# Use the lightest ion mass to compute effective electron mass
ion_species = [s for s in config.plasma.species if s != "e-"]
_default_mass = 1.67e-27
if ion_species:
m_ion_ref = min(SPECIES_MASS.get(s, _default_mass) for s in ion_species)
else:
m_ion_ref = _default_mass
m_e_effective = m_ion_ref / mr
else:
m_e_effective = m_e_physical
# Fluid-hybrid electron mode
fluid_electrons = config.plasma.electron_model == "fluid" and has_electrons
if fluid_electrons:
lines.extend(
[
"################",
"# HYBRID ELECTRON MODEL",
"################",
"# Fluid (hybrid) electron model: faster than fully kinetic,",
"# less accurate for electron detachment physics.",
"warpx.do_hybrid_model = 1",
f"hybrid_model.Te_eV = {config.plasma.T_e_eV:.4f}",
f"hybrid_model.n0_m3 = {config.plasma.n0:.6e}",
"hybrid_model.plasma_resistivity = 0.0",
"",
]
)
for sp, safe_name in zip(config.plasma.species, species_names, strict=True):
# Skip electron particle species when using fluid model
if sp == "e-" and fluid_electrons:
continue
mass = SPECIES_MASS.get(sp)
charge = SPECIES_CHARGE.get(sp)
if mass is None or charge is None:
lines.append(f"# WARNING: Unknown species {sp}, using placeholder values")
mass = mass or 1.67262192595e-27
charge = charge or 1.602176634e-19
# Override electron mass for reduced mass ratio
if sp == "e-" and reduced_mass_ratio:
mass = m_e_effective
T_eV = config.plasma.T_e_eV if sp == "e-" else config.plasma.T_i_eV
T_J = T_eV * eV_to_J
v_th = math.sqrt(T_J / mass)
lines.extend(
[
(
f"{safe_name}.species_type = proton"
if "+" in sp
else f"{safe_name}.species_type = electron"
),
f"{safe_name}.mass = {mass:.10e}",
f"{safe_name}.charge = {charge:.10e}",
f"{safe_name}.injection_style = NUniformPerCell",
f"{safe_name}.num_particles_per_cell = 64",
f"{safe_name}.profile = constant",
f"{safe_name}.density = {config.plasma.n0:.6e}",
f"{safe_name}.momentum_distribution_type = gaussian",
f"{safe_name}.uz_m = {config.plasma.v_injection_ms:.6e}",
f"{safe_name}.ux_th = {v_th:.6e}",
f"{safe_name}.uy_th = {v_th:.6e}",
f"{safe_name}.uz_th = {v_th:.6e}",
"",
]
)
if reduced_mass_ratio:
lines.append(
f"# METADATA: mass_ratio_reduced = true (m_i/m_e = {config.plasma.mass_ratio})"
)
if fluid_electrons:
lines.append("# METADATA: electron_model = fluid")
# Monte Carlo neutrals (spec §2.1)
neutrals = config.plasma.neutrals
if neutrals is not None:
neutral_name = neutrals.species.replace("+", "_plus").replace("-", "_minus")
lines.extend(
[
"",
"################",
"# MONTE CARLO NEUTRALS",
"################",
f"# Background {neutrals.species} neutral gas: Monte Carlo Collision (MCC)",
f"particles.species_names += {neutral_name}",
f"{neutral_name}.species_type = neutral",
f"{neutral_name}.mass = "
f"{SPECIES_MASS.get(neutrals.species + '+', 3.34e-27):.10e}",
f"{neutral_name}.charge = 0.0",
f"{neutral_name}.injection_style = NUniformPerCell",
f"{neutral_name}.num_particles_per_cell = 16",
f"{neutral_name}.profile = constant",
f"{neutral_name}.density = {neutrals.n_neutral_m3:.6e}",
f"{neutral_name}.momentum_distribution_type = gaussian",
f"{neutral_name}.ux_th = {_neutral_vth(neutrals, eV_to_J):.6e}",
f"{neutral_name}.uy_th = {_neutral_vth(neutrals, eV_to_J):.6e}",
f"{neutral_name}.uz_th = {_neutral_vth(neutrals, eV_to_J):.6e}",
"",
"# Charge-exchange collisions",
"mcc.species = "
+ " ".join(
s.replace("+", "_plus").replace("-", "_minus")
for s in config.plasma.species
if "+" in s
),
f"mcc.neutral_species = {neutral_name}",
f"mcc.CX_cross_section = {neutrals.cx_cross_section_m2:.6e}",
]
)
if neutrals.ionization_cross_section_m2 is not None:
lines.extend(
[
f"mcc.ionization_cross_section = "
f"{neutrals.ionization_cross_section_m2:.6e}",
"mcc.do_ionization = 1",
]
)
lines.extend(
[
"################",
"# EXTERNAL B-FIELD",
"################",
"# Applied B-field is loaded from HDF5 file pre-computed by Helicon",
"# Use: warpx.read_fields_from_path = <bfield.h5>",
"",
"################",
"# DIAGNOSTICS",
"################",
]
)
# Build diags_names list once (avoids duplicate key)
diag_names = ["diag1"]
if config.diagnostics.mode == "analysis":
diag_names.append("diag_particles")
lines.append(f"diagnostics.diags_names = {' '.join(diag_names)}")
lines.extend(
[
f"diag1.intervals = {config.diagnostics.field_dump_interval}",
"diag1.diag_type = Full",
"diag1.format = openpmd",
"diag1.openpmd_backend = h5",
"diag1.fields_to_plot = Br Bz Er Ez jr jz rho",
"",
]
)
if config.diagnostics.mode == "analysis":
lines.extend(
[
f"diag_particles.intervals = {config.diagnostics.particle_dump_interval}",
"diag_particles.diag_type = Full",
"diag_particles.format = openpmd",
"diag_particles.openpmd_backend = h5",
f"diag_particles.species = {' '.join(species_names)}",
"",
]
)
lines.extend(
[
"################",
"# REPRODUCIBILITY",
"################",
f"warpx.random_seed = {seed}",
"warpx.verbose = 1",
]
)
return "\n".join(lines) + "\n"