Quick start
The recommended entry point is the LineProfile
class. It wraps all solver calls, stores results as attributes, and provides
convenience properties for common observation geometries.
Choosing static, FFM, or discrete output
The Python API does not select a model automatically:
compute_profile() and
compute_static_profile() always use
the quasi-static-ion solver, whereas
compute_ffm_profile() explicitly uses
the Frequency Fluctuation Model (FFM). Use
compute_discrete() when a stick
spectrum at one specified electric-field configuration is required instead of
a plasma-averaged profile.
flowchart TD
A(["What output do you need?"]) --> B{"Fixed field<br/>configuration (Fz, Fx)?"}
B -- Yes --> C["compute_discrete(Fz, Fx)"]
C --> D["Diagonalize upper/lower<br/>Hamiltonians once"]
D --> E(["Energies, polarizations,<br/>strengths"])
B -- "No, plasma-<br/>averaged profile" --> G{"Hydrogen-like radiator,<br/>within-shell Stark mixing<br/>only?"}
G -- No --> H(["Use a model with multi-<br/>electron / inter-shell coupling"])
G -- Yes --> I{"Ion microfield frozen on the<br/>emission timescale?<br/>(νᵢ ≪ Stark/SDT spread)"}
I -- Yes --> J["compute_static_profile()"]
I -- No --> K["compute_ffm_profile()"]
I -- Unsure --> L["Run both,<br/>compare the line core"]
L -.-> J
L -.-> K
J --> M["π, σ+, σ− profiles"]
K --> M
M --> N["Combine for<br/>observation angle"] --> O["Optional instrument<br/>convolution"] --> P(["Observable spectrum"])
classDef decision fill:#fff3cd,stroke:#c9971d,color:#4a3800
classDef discreteNode fill:#f0fdfa,stroke:#0d9488,color:#0f3d38
classDef staticNode fill:#dbeafe,stroke:#2563eb,color:#1e3a5f
classDef ffmNode fill:#ede9fe,stroke:#7c3aed,color:#3b1f6b
classDef deadend fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
classDef terminal fill:#dcfce7,stroke:#16a34a,color:#14532d
class B,G,I decision
class C,D discreteNode
class J staticNode
class K ffmNode
class H deadend
class A,E,P terminal
Here ν_i denotes the ion jumping rate in energy units (the code multiplies
the rate in s-1 by \(\hbar\)). FFM becomes more relevant as the
ions move faster (larger Ti_ev and smaller ion mass), and for line cores
whose Stark-dressed structure is not well separated, often at lower magnetic
field or higher principal quantum number. Density changes both the
microfield distribution and its fluctuation rate, so density alone is not a
universal decision boundary.
When the two regimes are not clearly separated, compare both solvers using
the same num_f, num_mu, max_beta, microfield distribution,
electron model, and Doppler setting. For the closest implementation-level
comparison, set frequency_dependent_width=False on the static solver,
because the FFM currently uses one resonance electron-impact width for all
Stark-dressed transitions. In the theoretical limit ν_i -> 0, the FFM
reduces to the static profile.
Basic profile
import numpy as np
import matplotlib.pyplot as plt
from starkzee.line_profile import LineProfile
# Hα (n=3→2), DIII-D edge conditions
lp = LineProfile(n_u=3, n_l=2, B=5.0, Ne_m3=1e20, Te_ev=5.0)
# Build an energy grid ±3 nm around the line center
lam0 = lp.E0_wavelength_nm # ≈ 656.1 nm
HC = 1239.84193 # hc/e [eV·nm]
energies = np.linspace(HC/(lam0+3), HC/(lam0-3), 1000)
lp.compute_profile(energies, num_f=30, num_mu=8,
use_screening=True, quadratic_zeeman=False)
# Observation at 90° to B (transverse): π + ½(σ+ + σ-)
plt.plot(lp.wavelengths_nm - lam0, lp.profile_transverse, label="90°")
# Along B (0°): σ+ + σ-
plt.plot(lp.wavelengths_nm - lam0, lp.profile_parallel,
ls="--", label="0°")
plt.xlabel("Δλ (nm)")
plt.legend()
plt.show()
The profile is stored as three polarization components
(profile_pi, profile_sig_plus, profile_sig_minus).
Use profile_at_angle() for
arbitrary observation angle θ.
Discrete transition stick spectrum
lp.compute_discrete(Fz=0.0, Fx=0.0)
disc = lp.discrete
print(disc) # DiscreteTransitions(89 transitions, ...)
# Sorted by energy; q = 0 (π), -1 (σ+, blue-shifted), +1 (σ-, red-shifted)
for E, q, S in zip(disc.energy_ev, disc.q, disc.strength):
print(f" E={E:.4f} eV q={q:+d} |d|²={S:.4e} a₀²")
Both compute_profile and compute_discrete return self, so they can
be chained:
lp.compute_profile(energies).compute_discrete()
Balmer series at once
from starkzee.line_profile import LineProfile
import numpy as np
lines = [(3,2,"Hα"), (4,2,"Hβ"), (5,2,"Hγ"), (6,2,"Hδ")]
B, Ne, Te = 5.0, 1e20, 5.0
profiles = {}
for n_u, n_l, label in lines:
lp = LineProfile(n_u=n_u, n_l=n_l, B=B, Ne_m3=Ne, Te_ev=Te)
HC = 1239.84193
lam0 = lp.E0_wavelength_nm
energies = np.linspace(HC/(lam0+2), HC/(lam0-2), 800)
lp.compute_profile(energies, num_f=30, num_mu=8)
profiles[label] = lp
Lower-level API
The class delegates to calculate_static_profile()
and discrete_transitions(). These can be
called directly for more control:
from starkzee.static_profile import calculate_static_profile
import numpy as np
from starkzee.utils import RYDBERG_EV
E0 = RYDBERG_EV * (1/4 - 1/9) # Hα
energies = E0 + np.linspace(-0.005, 0.005, 1000)
pi, sig_plus, sig_minus = calculate_static_profile(
n_u=3, n_l=2, Z=1, B=5.0,
Ne_m3=1e20, Te_ev=5.0,
energies_ev=energies,
num_f=30, num_mu=8,
)
FFM (dynamic ion) profile
Use the FFM when the ion microfield changes appreciably on the line-formation
timescale. From LineProfile, set Ti_ev on the constructor and call
compute_ffm_profile() instead of
compute_profile. It populates the same result attributes
(profile_pi, profile, profile_transverse, …) and applies Doppler
broadening by default:
lp_ffm = LineProfile(n_u=3, n_l=2, B=5.0,
Ne_m3=1e23, Te_ev=5.0, Ti_ev=0.1)
lp_ffm.compute_ffm_profile(energies, num_f=30, num_mu=8,
sdt_bin_tol=1e-5)
plt.plot(lp_ffm.wavelengths_nm, lp_ffm.profile_transverse)
sdt_bin_tol (eV) merges Stark-dressed transitions closer together than
this tolerance before the Markov solve — often a 10-100x speedup with
negligible accuracy loss; omit it (default None) for the exact,
unbinned calculation.
The lower-level calculate_ffm_profile() can also be
called directly for more control:
from starkzee.ffm import calculate_ffm_profile
pi, sp, sm = calculate_ffm_profile(
n_u=3, n_l=2, Z=1, B=5.0,
Ne_m3=1e23, Te_ev=5.0, Ti_ev=0.1,
A_ion=1, energies_ev=energies,
num_f=30, num_mu=8, sdt_bin_tol=1e-5,
)
Doppler and instrumental broadening
Doppler broadening can be applied inside either solver:
The static path includes it when
Ti_evis set onLineProfile(or passed directly tocalculate_static_profile).The FFM path requires
Ti_evfor the ion fluctuation rate and applies Doppler broadening by default; passapply_doppler=Falseto disable it.
Instrumental broadening is never automatic. The standalone
apply_doppler_broadening() and
apply_instrument_broadening() helpers are useful
when post-processing explicitly. Do not apply the Doppler helper to a profile
that already included Doppler inside its solver. Both helpers require a
uniform wavelength grid:
from starkzee.convolutions import (
apply_doppler_broadening,
apply_instrument_broadening,
)
# Compute directly on a uniform *wavelength* grid so lam below is uniform
# (an energy grid mapped through E=hc/lambda is not uniform in wavelength).
lam = np.linspace(lp.E0_wavelength_nm - 3, lp.E0_wavelength_nm + 3, 1000)
# This lp has no Ti_ev, so the static result below is Doppler-free.
lp.compute_profile(lam, grid_type='wavelength_nm', num_f=30, num_mu=8)
total = lp.profile_transverse
# Doppler: Ti=0.5 eV, hydrogen emitter (species='H')
broadened = apply_doppler_broadening(lam, total, Ti_ev=0.5, species='H')
# Instrument: FWHM = 0.05 nm
broadened = apply_instrument_broadening(lam, broadened, fwhm_nm=0.05)
The complete pipeline is \(I_\text{obs} = (I_\text{SZ} * G_D) * G_\text{inst}\).