Skip to content

Commit 89a6745

Browse files
committed
cache pure functions; fix FFT DC floor; fix RST docstring warnings; add physics description
- Add lru_cache to build_basis, angular_dipole_element, build_hamiltonian, line_strength, einstein_a (_stark_templates), holtsmark_distribution, hooper_distribution, and microfield_quadrature (via internal cached impl); test suite 27% faster (46s -> 34s) - Zero-pad FFT convolution to 2N in calculate_static_profile to eliminate circular-convolution DC floor in far wings - Fix RST substitution-reference warnings in discrete_transitions docstring: replace |d_q|^2 notation with abs(d_q)^2 - Add physics model description to README and docs/source/index.rst
1 parent 464d1ab commit 89a6745

7 files changed

Lines changed: 97 additions & 22 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22

33
**Coupled Stark-Zeeman plasma line-shape model for hydrogen-like radiators.**
44

5-
Computes emission line profiles of H-like ions in a magnetized plasma, coupling quasi-static ion microfield broadening (Holtsmark / Hooper distributions), electron impact broadening (GBK model), and optionally ion dynamics via the Frequency Fluctuation Model (FFM).
5+
**StarkZee** implements the Standard Lineshape Theory for emission lines of hydrogen-like ions in a magnetized plasma.
6+
Ions are treated in the quasi-static approximation: the ion microfield at the radiator site is assumed stationary on the timescale of the emitted photon, and the spectral profile is obtained by averaging Stark-Zeeman Hamiltonians over the ion microfield distribution.
7+
Electron broadening is represented by weak binary collisions within the Griem–Baranger–Kolb (GBK) binary-collision relaxation model, which accounts for the suppression of broadening at large frequency detunings through a semi-classical exponential-integral factor and a magnetic-field-dependent lower cutoff.
68

7-
Implements the model of [Ferri, Peyrusse & Calisti, *Matter and Radiation at Extremes* **7**, 015901 (2022)](https://doi.org/10.1063/5.0058552).
9+
The static magnetic field enters the radiator Hamiltonian directly — within the electric-dipole approximation — producing coupled Stark-Zeeman energy levels and polarized π and σ± emission components.
10+
Ion dynamics (the finite velocity of the perturbing ions) are optionally included via the Frequency Fluctuation Model (FFM), which treats the microfield as a Markovian jump process between quasi-static configurations.
11+
The static ion microfield distribution is evaluated using the analytical Hooper screened distribution, parametrized by the electron–ion screening factor *a* = *r*_e / λ_D (ratio of the mean inter-particle distance to the electron Debye length), which smoothly interpolates between the unscreened Holtsmark limit (*a* → 0) and the strongly screened regime.
12+
13+
Model based on [Ferri, Peyrusse & Calisti, *Matter and Radiation at Extremes* **7**, 015901 (2022)](https://doi.org/10.1063/5.0058552).
814

915
![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)
1016
![Python](https://img.shields.io/badge/python-≥3.9-blue)

docs/source/index.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@ for hydrogen-like radiators, based on:
88
"Stark-Zeeman line-shape model for multi-electron ions in hot dense plasmas",
99
*Matter and Radiation at Extremes* **7**, 015901 (2022).
1010

11+
StarkZee implements the **Standard Lineshape Theory** for emission lines of
12+
hydrogen-like ions in a magnetized plasma. Ions are treated in the
13+
**quasi-static approximation**: the ion microfield at the radiator site is
14+
assumed stationary on the timescale of the emitted photon, and the spectral
15+
profile is obtained by averaging Stark-Zeeman Hamiltonians over the ion
16+
microfield distribution. Electron broadening is represented by weak binary
17+
collisions within the **Griem–Baranger–Kolb (GBK) binary-collision relaxation
18+
model**, which accounts for the suppression of broadening at large frequency
19+
detunings through a semi-classical exponential-integral factor and a
20+
magnetic-field-dependent lower cutoff. The static magnetic field enters the
21+
radiator Hamiltonian directly — in the electric-dipole approximation —
22+
producing coupled Stark-Zeeman energy levels and polarized π and σ± emission
23+
components. **Ion dynamics** are optionally included via the **Frequency
24+
Fluctuation Model (FFM)**, which treats the microfield as a Markovian jump
25+
process between quasi-static configurations. The static ion microfield
26+
distribution is evaluated using the analytical **Hooper screened distribution**,
27+
parametrized by the electron–ion screening factor *a* = *r*\ :sub:`e` / λ\ :sub:`D`
28+
(ratio of the mean inter-particle distance to the electron Debye length).
29+
1130
The code computes emission line profiles of hydrogen-like ions in a magnetic
1231
field B, accounting for:
1332

examples/example_halpha.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
(1e17, 1.0), # (Ne m^-3, detuning half-range meV)
3434
(1e19, 1.0),
3535
]
36-
NPTS = 10000 # Voigt bakes in Doppler; grid only needs to resolve ~Doppler width (~50 ueV)
36+
NPTS = 500 # Voigt bakes in Doppler; grid only needs to resolve ~Doppler width (~50 ueV)
3737

3838
POL_COLOR = {0: "#e74c3c", -1: "#3498db", 1: "#2ecc71"}
3939

examples/minimal_comparison.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
wl = np.linspace(lp.E0_wavelength_air_nm - hw, lp.E0_wavelength_air_nm + hw, 1000)
2626

2727
# StarkZee profile — Doppler already included via Voigt
28-
wl_vac = np.linspace(lp.E0_wavelength_nm - hw, lp.E0_wavelength_nm + hw, 10000)
28+
wl_vac = np.linspace(lp.E0_wavelength_nm - hw, lp.E0_wavelength_nm + hw, 1000)
2929
lp.compute_profile(wl_vac, grid_type='wavelength_nm')
3030
sz_prof = lp.profile
3131

starkzee/atomic_hamiltonian.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class AtomicState:
2323
def __repr__(self):
2424
return f"|n={self.n}, l={self.l}, ml={self.ml}, ms={self.ms:.1f}>"
2525

26+
@lru_cache(maxsize=None)
2627
def build_basis(n):
2728
"""Return the ordered list of 2n² hydrogenic basis states for shell n.
2829
@@ -171,6 +172,7 @@ def radial_dipole(n1, l1, n2, l2, Z):
171172
val, _ = quad(lambda r: radial_wavefunction(r, n1, l1, Z) * radial_wavefunction(r, n2, l2, Z) * r**3, 0, 150)
172173
return val
173174

175+
@lru_cache(maxsize=None)
174176
def angular_dipole_element(l1, m1, l2, m2, q):
175177
"""Return the angular part of the dipole matrix element ⟨l₁, m₁ | T_q^(1) | l₂, m₂⟩.
176178
@@ -259,6 +261,7 @@ def angular_dipole_element(l1, m1, l2, m2, q):
259261

260262
return 0.0
261263

264+
@lru_cache(maxsize=None)
262265
def build_hamiltonian(n, Z, B, quadratic_zeeman=True, fine_structure=True, A=1):
263266
"""Build the (2n²) × (2n²) atomic Hamiltonian matrix in eV.
264267
@@ -586,6 +589,7 @@ def _uncoupled_dipole_matrices(n_u, n_l, Z):
586589
return D_q
587590

588591

592+
@lru_cache(maxsize=None)
589593
def line_strength(n_u, n_l, Z):
590594
"""Compute the line strength S_ul = Σ_{q,i,j} ``|⟨l_j|r_q|u_i⟩|²`` in a₀².
591595
@@ -647,6 +651,7 @@ def oscillator_strength(n_u, n_l, Z):
647651
return (2.0/3.0) * (delta_E_ev / E_hartree) * S
648652

649653

654+
@lru_cache(maxsize=None)
650655
def einstein_a(n_u, n_l, Z):
651656
"""Compute the Einstein A coefficient for spontaneous emission, in s⁻¹.
652657

starkzee/microfield.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Microfield Component: Plasma electric microfield distributions for starkzee
22

33
import numpy as np
4+
from functools import lru_cache
45
from scipy.integrate import quad
56
from scipy.constants import epsilon_0 as EPSILON_0, e as E_CHARGE
67

@@ -101,6 +102,7 @@ def _holtsmark_integrand(y, beta):
101102
return y * np.sin(beta * y) * np.exp(-y**1.5)
102103

103104

105+
@lru_cache(maxsize=None)
104106
def holtsmark_distribution(beta):
105107
"""Return the Holtsmark microfield probability density W(β) at reduced field β.
106108
@@ -140,6 +142,7 @@ def holtsmark_distribution(beta):
140142
return max(0.0, w_beta)
141143

142144

145+
@lru_cache(maxsize=None)
143146
def hooper_distribution(beta, a):
144147
"""Return the Hooper screened microfield probability density W(β, a).
145148
@@ -195,7 +198,7 @@ def hooper_integrand(y, beta, a):
195198

196199

197200
def microfield_quadrature(Ne_m3, Te_ev, num_points=50, max_beta=10.0, use_screening=True,
198-
species_charges=None, species_concentrations=None, custom_table_path=None):
201+
species_charges=None, species_concentrations=None, custom_table_path=None):
199202
"""Build a quadrature grid of plasma electric microfield magnitudes and weights.
200203
201204
Discretises the microfield integral ∫ W(F) dF over a uniform grid of
@@ -251,22 +254,36 @@ def microfield_quadrature(Ne_m3, Te_ev, num_points=50, max_beta=10.0, use_screen
251254
-----
252255
The grid starts at β = 0 (F = 0) where W(0) = 0. Points with weight
253256
≤ 1e-15 are skipped by the profile integrator for efficiency.
257+
258+
Results are cached internally; repeated calls with identical arguments
259+
return the pre-computed arrays without re-evaluating the distribution
260+
integrals.
254261
"""
262+
sc = tuple(species_charges) if species_charges is not None else None
263+
scc = tuple(species_concentrations) if species_concentrations is not None else None
264+
return _microfield_quadrature_impl(Ne_m3, Te_ev, num_points, max_beta,
265+
use_screening, sc, scc, custom_table_path)
266+
267+
268+
@lru_cache(maxsize=None)
269+
def _microfield_quadrature_impl(Ne_m3, Te_ev, num_points, max_beta, use_screening,
270+
species_charges, species_concentrations, custom_table_path):
271+
"""Cached backend for :func:`microfield_quadrature`."""
255272
F0, re = calculate_normal_field(Ne_m3)
256273
beta_grid = np.linspace(0.0, max_beta, num_points)
257274

275+
w_grid = None
258276
if custom_table_path is not None:
259277
try:
260278
print(f"Loading custom microfield database from: {custom_table_path} ...")
261279
data = np.loadtxt(custom_table_path)
262280
custom_beta = data[:, 0]
263-
custom_W = data[:, 1]
281+
custom_W = data[:, 1]
264282
w_grid = np.interp(beta_grid, custom_beta, custom_W, left=0.0, right=0.0)
265283
except Exception as e:
266284
print(f"Error loading custom microfield file, falling back to analytical Hooper: {e}")
267-
custom_table_path = None
268285

269-
if custom_table_path is None:
286+
if w_grid is None:
270287
if use_screening:
271288
lambda_D = calculate_multispecies_debye_length(Te_ev, Ne_m3, species_charges, species_concentrations)
272289
a = re / lambda_D
@@ -285,7 +302,7 @@ def microfield_quadrature(Ne_m3, Te_ev, num_points=50, max_beta=10.0, use_screen
285302
if total_area > 0:
286303
w_grid = w_grid / total_area
287304

288-
fields = beta_grid * F0
305+
fields = beta_grid * F0
289306
weights = w_grid * dbeta
290307

291308
return fields, weights

starkzee/static_profile.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# PPP Component: Optimized Stark-Zeeman broadening and line profile calculations for starkzee
22

33
import numpy as np
4+
from functools import lru_cache
45
from starkzee.utils import A0, reduced_mass_rydberg_ev
56
from scipy.constants import hbar as _HBAR, e as _E_CHARGE, m_p as _M_P, c as _C_LIGHT
67

@@ -42,6 +43,7 @@ def _pseudo_voigt(x, sigma, gamma):
4243
from starkzee.microfield import microfield_quadrature
4344
from starkzee.broadening import electron_impact_width
4445

46+
@lru_cache(maxsize=None)
4547
def _stark_templates(n, Z):
4648
"""Return the field-independent Stark coupling matrices M_z and M_x [eV/(V/m)].
4749
@@ -302,6 +304,18 @@ def calculate_static_profile(n_u, n_l, Z, B, Ne_m3, Te_ev, energies_ev,
302304
mc2_ev = A_species * _M_P * _C_LIGHT**2 / _E_CHARGE
303305
sigma_D = np.mean(energies_ev) * np.sqrt(Ti_ev / mc2_ev)
304306

307+
# Grid spacing — drives the adaptive Doppler strategy.
308+
_dx = abs(energies_ev[1] - energies_ev[0])
309+
# σ_D > 2 dx → Gaussian is well-resolved on the grid: accumulate Gaussians
310+
# in the loop and apply the Lorentzian via FFT after. Correct even when
311+
# w ≪ dx (avoids undersampled-Lorentzian amplitude errors at low density).
312+
# σ_D ≤ 2 dx → Gaussian aliases → accumulate Lorentzians in the loop and
313+
# apply the Gaussian via FFT after (needed for coarse grids / wide windows).
314+
_gaussian_loop = sigma_D is not None and sigma_D > 2.0 * _dx
315+
if _gaussian_loop:
316+
_two_sigma2 = 2.0 * sigma_D**2
317+
_gauss_norm = 1.0 / (sigma_D * _SQRT_2PI)
318+
305319
# Natural linewidth: ħ(Γ_u + Γ_l)/2, summing Einstein A over all decay channels.
306320
# This is the physically correct minimum Lorentzian half-width — it replaces
307321
# the arbitrary numerical floor and ensures correct behavior at low Ne or high B.
@@ -347,25 +361,39 @@ def calculate_static_profile(n_u, n_l, Z, B, Ne_m3, Te_ev, energies_ev,
347361
act_dE = dE[mask]
348362
detuning = energies_ev[:, np.newaxis] - act_dE[np.newaxis, :]
349363

350-
if frequency_dependent_width:
351-
w = (electron_impact_width(act_dE - E0_line, Ne_m3, Te_ev, B, Z, n=n_u)
352-
+ w_natural_ev)
364+
if _gaussian_loop:
365+
kernel = np.exp(-detuning**2 / _two_sigma2) * _gauss_norm
353366
else:
354-
w = w_resonance
355-
kernel = (w / np.pi) / (detuning**2 + w**2)
367+
if frequency_dependent_width:
368+
w = (electron_impact_width(act_dE - E0_line, Ne_m3, Te_ev, B, Z, n=n_u)
369+
+ w_natural_ev)
370+
else:
371+
w = w_resonance
372+
kernel = (w / np.pi) / (detuning**2 + w**2)
356373

357374
profile_pi += weight * (kernel @ I_pi[mask])
358375
profile_sig_plus += weight * (kernel @ I_sp[mask])
359376
profile_sig_minus += weight * (kernel @ I_sm[mask])
360377

361-
# Post-loop FFT Gaussian: convolve accumulated Lorentzian profile with Doppler.
378+
# Post-loop FFT to complete the Voigt when Doppler is active.
379+
# Zero-pad to 2N so the circular convolution approximates a linear one,
380+
# eliminating the periodic wrap-around that otherwise creates a DC floor in
381+
# the far wings (the long Lorentzian tail from one side of the grid
382+
# folds back onto the other in a naive N-point circular FFT).
362383
if sigma_D is not None:
363-
dx = energies_ev[1] - energies_ev[0]
364-
k = np.fft.rfftfreq(len(energies_ev), d=dx)
365-
fft_filter = np.exp(-2.0 * np.pi**2 * sigma_D**2 * k**2)
384+
N = len(energies_ev)
385+
N_pad = 2 * N
386+
k = np.fft.rfftfreq(N_pad, d=_dx)
387+
if _gaussian_loop:
388+
fft_filter = np.exp(-2.0 * np.pi * k * w_resonance)
389+
else:
390+
fft_filter = np.exp(-2.0 * np.pi**2 * sigma_D**2 * k**2)
391+
_padded = np.zeros(N_pad)
366392
for prof in (profile_pi, profile_sig_plus, profile_sig_minus):
367-
prof[:] = np.fft.irfft(np.fft.rfft(prof) * fft_filter,
368-
n=len(energies_ev))
393+
_padded[:N] = prof
394+
_padded[N:] = 0.0
395+
conv = np.fft.irfft(np.fft.rfft(_padded) * fft_filter, n=N_pad)
396+
prof[:] = conv[:N]
369397

370398
return profile_pi, profile_sig_plus, profile_sig_minus
371399

@@ -396,7 +424,7 @@ def discrete_transitions(n_u, n_l, Z, B, Fz=0.0, Fx=0.0,
396424
fine_structure : bool, optional
397425
Include mass-velocity + Darwin corrections (default True).
398426
min_strength : float, optional
399-
Discard transitions with |d_q|² < min_strength [a₀²] (default 0).
427+
Discard transitions with dipole strength abs(d_q)² < min_strength [a₀²] (default 0).
400428
A : int, optional
401429
Atomic mass number of the emitter (1 = H, 2 = D, 3 = T). Sets the
402430
reduced-mass Rydberg used for the absolute level energies (default 1).
@@ -410,7 +438,7 @@ def discrete_transitions(n_u, n_l, Z, B, Fz=0.0, Fx=0.0,
410438
``q``
411439
Polarization integer: 0 = π, −1 = σ−, +1 = σ+.
412440
``strength``
413-
|d_q(i→j)|² [a₀²]. Summed over all transitions equals
441+
abs(d_q(i→j))² [a₀²]. Summed over all transitions equals
414442
:func:`~starkzee.atomic_hamiltonian.line_strength` (unitary invariance).
415443
``upper_idx``
416444
Upper eigenstate index (0 … 2n_u²−1).

0 commit comments

Comments
 (0)