Skip to content

Commit 94d6436

Browse files
authored
Merge pull request #7 from budjensen/lee/fields
Add fields.py and new unit tests
2 parents b4368bf + 09d187a commit 94d6436

File tree

5 files changed

+471
-75
lines changed

5 files changed

+471
-75
lines changed

src/test_particle_sim_1d/fields.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
fields.py
3+
4+
Defines analytic electric and magnetic field configurations for a
5+
1D-in-space, 3D-in-velocity (1D3V) plasma simulation.
6+
7+
Each field function takes an array of particle positions `z` and returns
8+
a NumPy array of shape (N, 3), representing the (Ex, Ey, Ez) or (Bx, By, Bz)
9+
components at each position.
10+
11+
Functions
12+
---------
13+
E_uniform : Return a uniform electric field of specified magnitude and direction.
14+
B_uniform : Return a uniform magnetic field of specified magnitude and direction.
15+
B_mirror : Return a magnetic mirror field with strength increasing ∝ z².
16+
17+
Examples
18+
--------
19+
>>> import numpy as np
20+
>>> from test_particle_sim_1d import fields
21+
>>> z = np.linspace(-0.1, 0.1, 5)
22+
>>> fields.E_uniform(z, E0=1.0, direction="x")
23+
array([[1., 0., 0.],
24+
[1., 0., 0.],
25+
[1., 0., 0.],
26+
[1., 0., 0.],
27+
[1., 0., 0.]])
28+
>>> fields.B_mirror(z, B0=1.0, z_mirror=0.05)[:3]
29+
array([[0., 0., 5.],
30+
[0., 0., 2.],
31+
[0., 0., 1.]])
32+
"""
33+
34+
from __future__ import annotations
35+
36+
import numpy as np
37+
38+
# ----------------------------------------------------------------------
39+
# Electric field definitions
40+
# ----------------------------------------------------------------------
41+
42+
43+
def E_uniform(z: np.ndarray, E0: float = 0.0, direction: str = "z") -> np.ndarray:
44+
"""
45+
Uniform electric field of magnitude E0 along a specified axis.
46+
47+
Parameters
48+
----------
49+
z : np.ndarray
50+
Particle positions (ignored for uniform field)
51+
E0 : float, optional
52+
Field strength [V/m] (default 0)
53+
direction : {'x', 'y', 'z'}, optional
54+
Axis of field direction (default 'z')
55+
56+
Returns
57+
-------
58+
np.ndarray
59+
Electric field array of shape (len(z), 3)
60+
"""
61+
E = np.zeros((len(z), 3))
62+
idx = {"x": 0, "y": 1, "z": 2}[direction]
63+
E[:, idx] = E0
64+
return E
65+
66+
67+
# ----------------------------------------------------------------------
68+
# Magnetic field definitions
69+
# ----------------------------------------------------------------------
70+
71+
72+
def B_uniform(z: np.ndarray, B0: float = 0.0, direction: str = "z") -> np.ndarray:
73+
"""
74+
Uniform magnetic field of magnitude B0 along a specified axis.
75+
76+
Parameters
77+
----------
78+
z : np.ndarray
79+
Particle positions (ignored)
80+
B0 : float
81+
Magnetic field strength [T]
82+
direction : {'x', 'y', 'z'}, optional
83+
Direction of the field vector
84+
85+
Returns
86+
-------
87+
np.ndarray
88+
Magnetic field array of shape (len(z), 3)
89+
"""
90+
B = np.zeros((len(z), 3))
91+
idx = {"x": 0, "y": 1, "z": 2}[direction]
92+
B[:, idx] = B0
93+
return B
94+
95+
96+
def B_mirror(z: np.ndarray, B0: float = 1.0, z_mirror: float = 0.05) -> np.ndarray:
97+
"""
98+
Simple magnetic mirror field that increases with z^2 near the ends.
99+
100+
Parameters
101+
----------
102+
z : np.ndarray
103+
Positions along z [m]
104+
B0 : float
105+
Base magnetic field [T]
106+
z_mirror : float
107+
Mirror length scale [m]
108+
109+
Returns
110+
-------
111+
np.ndarray
112+
Magnetic field [T] along z
113+
"""
114+
Bz = B0 * (1 + (z / z_mirror) ** 2)
115+
return np.column_stack((np.zeros_like(Bz), np.zeros_like(Bz), Bz))

src/test_particle_sim_1d/integrators.py

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@
1818
- Magnetic rotation
1919
- Second half electric acceleration
2020
21-
References:
22-
J. P. Boris, "Relativistic Plasma Simulation—Optimization of a Hybrid Code",
23-
Proceedings of the Fourth Conference on Numerical Simulation of Plasmas, 1970.
24-
Qin, H. et al. (2013). "Why is Boris algorithm so good?"
21+
References
22+
----------
23+
J. P. Boris, "Relativistic Plasma Simulation—Optimization of a Hybrid Code",
24+
Proceedings of the Fourth Conference on Numerical Simulation of Plasmas, 1970.
25+
26+
Qin, H. et al. (2013). "Why is Boris algorithm so good?"
2527
"""
2628

2729
from __future__ import annotations
@@ -38,26 +40,70 @@ def boris_1d3v_z(
3840
B_func,
3941
dt: float,
4042
n_steps: int,
43+
record_history: bool = False,
4144
):
4245
"""
43-
Advance 1D-in-z, 3D-in-velocity particles using the Boris algorithm.
44-
45-
Args:
46-
z (np.ndarray): Particle positions along z [m], shape (N,)
47-
v (np.ndarray): Particle velocities [m/s], shape (N, 3)
48-
q (np.ndarray): Charges [C], shape (N,)
49-
m (np.ndarray): Masses [kg], shape (N,)
50-
E_func (callable): E(z) -> (N, 3) electric field [V/m]
51-
B_func (callable): B(z) -> (N, 3) magnetic field [T]
52-
dt (float): Time step [s]
53-
n_steps (int): Number of steps
54-
55-
Returns:
56-
tuple[np.ndarray, np.ndarray]: Final (z, v)
46+
Advance particles in 1D (z) with 3D velocity using the Boris pusher.
47+
48+
This algorithm integrates the Lorentz force equation in a time-centered,
49+
energy-conserving way. It applies a half-step electric acceleration,
50+
a magnetic rotation, and a second half electric acceleration.
51+
52+
Parameters
53+
----------
54+
z : np.ndarray
55+
Particle positions along z-axis [m], shape (N,).
56+
v : np.ndarray
57+
Particle velocities [m/s], shape (N, 3).
58+
q : np.ndarray
59+
Particle charges [C], shape (N,).
60+
m : np.ndarray
61+
Particle masses [kg], shape (N,).
62+
E_func : callable
63+
Function that returns the electric field array for given z positions.
64+
Must have signature ``E_func(z) -> np.ndarray`` of shape (N, 3).
65+
B_func : callable
66+
Function that returns the magnetic field array for given z positions.
67+
Must have signature ``B_func(z) -> np.ndarray`` of shape (N, 3).
68+
dt : float
69+
Time step [s].
70+
n_steps : int
71+
Number of time steps to advance.
72+
record_history : bool, optional
73+
If True, store and return full velocity history as a 3D array of
74+
shape (n_steps, N, 3). Default is False.
75+
76+
Returns
77+
-------
78+
tuple of np.ndarray
79+
- z : np.ndarray
80+
Final particle positions along z, shape (N,).
81+
- v : np.ndarray
82+
Final particle velocities, shape (N, 3).
83+
- v_hist : np.ndarray, optional
84+
Velocity history (n_steps, N, 3), only returned if
85+
``record_history=True``.
86+
87+
Raises
88+
------
89+
ValueError
90+
If z or v arrays have inconsistent shapes.
91+
TypeError
92+
If E_func or B_func do not return arrays of shape (N, 3).
93+
94+
Notes
95+
-----
96+
The Boris scheme is widely used in plasma and particle-in-cell simulations
97+
due to its excellent long-term energy conservation and numerical stability.
98+
It is exact for constant magnetic fields when E=0.
99+
57100
"""
58101
q_over_m = (q / m)[:, None]
59102

60-
for _ in range(n_steps):
103+
if record_history:
104+
v_hist = np.zeros((n_steps, len(v), 3), dtype=v.dtype)
105+
106+
for i in range(n_steps):
61107
# 1. Fields at current positions
62108
E = E_func(z)
63109
B = B_func(z)
@@ -79,4 +125,9 @@ def boris_1d3v_z(
79125
# 5. Position update using vz
80126
z += v[:, 2] * dt
81127

128+
if record_history:
129+
v_hist[i] = v
130+
131+
if record_history:
132+
return z, v, v_hist
82133
return z, v

tests/test_exb_drift.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""
2+
tests/test_exb_drift.py
3+
4+
Checks that a charged particle starting from rest in crossed E and B fields
5+
develops the correct ExB drift velocity over time and that gyration energy
6+
is conserved in the drift frame.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import numpy as np
12+
13+
from test_particle_sim_1d import fields
14+
from test_particle_sim_1d.integrators import boris_1d3v_z
15+
from test_particle_sim_1d.particles import Species
16+
17+
18+
def test_exb_drift_from_rest():
19+
q, m = 1.0, 1.0
20+
E0, B0 = 0.5, 1.0
21+
v_d_expected = -E0 / B0 # E (+x) x B (+z) = -ŷ
22+
23+
# Create species (start at rest)
24+
sp = Species(q=q, m=m, capacity=1)
25+
sp.add_particles(np.array([0.0]), np.array([[0.0, 0.0, 0.0]]))
26+
27+
# Define fields
28+
def E_func(z):
29+
return fields.E_uniform(z, E0=E0, direction="x")
30+
31+
def B_func(z):
32+
return fields.B_uniform(z, B0=B0, direction="z")
33+
34+
# Integration setup
35+
omega_c = q * B0 / m
36+
T = 2 * np.pi / omega_c
37+
dt = T / 200
38+
n_steps = int(20 * 2 * np.pi / (omega_c * dt)) # ~20 gyroperiods
39+
40+
z, v = sp.z.copy(), np.column_stack((sp.vx, sp.vy, sp.vz))
41+
_, _, v_hist = boris_1d3v_z(
42+
z,
43+
v,
44+
np.array([q]),
45+
np.array([m]),
46+
E_func,
47+
B_func,
48+
dt,
49+
n_steps,
50+
record_history=True,
51+
)
52+
53+
# Ignore transients (first few gyroperiods)
54+
n_transient = n_steps // 2
55+
v_hist_steady = v_hist[n_transient:, 0, :]
56+
57+
# 1. Average drift velocity (in y)
58+
avg_vy = np.mean(v_hist_steady[:, 1])
59+
np.testing.assert_allclose(avg_vy, v_d_expected, rtol=1e-2)
60+
61+
# 2. Check conservation of kinetic energy *in the drift frame*
62+
v_d = np.array([0.0, v_d_expected, 0.0])
63+
v_rel = v_hist_steady - v_d
64+
v_mag_rel = np.linalg.norm(v_rel, axis=1)
65+
np.testing.assert_allclose(v_mag_rel / v_mag_rel.mean(), 1.0, rtol=1e-3)

0 commit comments

Comments
 (0)