Skip to content

Commit 503af44

Browse files
authored
Merge pull request #78 from cwoolfo1/main
New Boundary Conditions and Fixing Bug in Esirkepov Current Deposition
2 parents 1704b6e + 5d6739d commit 503af44

23 files changed

Lines changed: 1741 additions & 213 deletions

PyPIC3D/__main__.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929

3030
from PyPIC3D.utils import (
3131
dump_parameters_to_toml, load_config_file, compute_energy,
32-
setup_pmd_files
32+
setup_pmd_files, add_external_fields
3333
)
3434

3535
from PyPIC3D.initialization import (
@@ -63,9 +63,11 @@ def run_PyPIC3D(config_file):
6363
scalar_field_names = ["rho", "mass_density"]
6464
vector_field_names = ["E", "B", "J"]
6565

66-
E, B, J, rho, *rest = fields
66+
E, B, J, rho, phi, external_fields, *rest = fields
6767
# unpack the fields
68-
e_energy, b_energy, kinetic_energy = compute_energy(particles, E, B, world, constants)
68+
total_E, total_B = add_external_fields(E, B, external_fields)
69+
# energy diagnostics use the fields seen by the particle pusher
70+
e_energy, b_energy, kinetic_energy = compute_energy(particles, total_E, total_B, world, constants)
6971
# Compute the energy of the system
7072
initial_energy = e_energy + b_energy + kinetic_energy
7173

@@ -85,10 +87,12 @@ def run_PyPIC3D(config_file):
8587
plot_num = t // plotting_parameters['plotting_interval']
8688
# determine the plot number
8789

88-
E, B, J, rho, *rest = fields
90+
E, B, J, rho, phi, external_fields, *rest = fields
8991
# unpack the fields
9092

91-
e_energy, b_energy, kinetic_energy = compute_energy(particles, E, B, world, constants)
93+
total_E, total_B = add_external_fields(E, B, external_fields)
94+
# energy diagnostics use the fields seen by the particle pusher
95+
e_energy, b_energy, kinetic_energy = compute_energy(particles, total_E, total_B, world, constants)
9296
# Compute the energy of the system
9397
write_data(f"{output_dir}/data/total_energy.txt", t * dt, e_energy + b_energy + kinetic_energy)
9498
write_data(f"{output_dir}/data/energy_error.txt", t * dt, abs( initial_energy - (e_energy + b_energy + kinetic_energy)) / max(initial_energy, 1e-10))
@@ -144,7 +148,7 @@ def run_PyPIC3D(config_file):
144148
write_openpmd_fields(fields, world, os.path.join(output_dir, "data"), plot_num, t, "fields", ".h5")
145149
# Write the fields in openPMD format
146150

147-
fields = (E, B, J, rho, *rest)
151+
fields = (E, B, J, rho, phi, external_fields, *rest)
148152
# repack the fields
149153

150154
particles, fields = jit_loop(
@@ -186,10 +190,12 @@ def main():
186190
end = time.time()
187191
# end the timer
188192

189-
E, B, J, *rest = fields
193+
E, B, J, rho, phi, external_fields, *rest = fields
190194
# unpack the fields
191195

192-
e_energy, b_energy, kinetic_energy = compute_energy(particles, E, B, world, constants)
196+
total_E, total_B = add_external_fields(E, B, external_fields)
197+
# energy diagnostics use the fields seen by the particle pusher
198+
e_energy, b_energy, kinetic_energy = compute_energy(particles, total_E, total_B, world, constants)
193199
# compute the energy of the system
194200
print(f"Final Electric Field Energy: {e_energy}")
195201
print(f"Final Magnetic Field Energy: {b_energy}")

PyPIC3D/boundary_conditions/PML.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import math
2+
3+
import jax.numpy as jnp
4+
5+
from PyPIC3D.boundary_conditions.boundaryconditions import update_ghost_cells
6+
from PyPIC3D.boundary_conditions.grid_and_stencil import BC_CONDUCTING, BC_PERIODIC
7+
8+
9+
PML_WALLS = ["-x", "+x", "-y", "+y", "-z", "+z"]
10+
11+
_AXIS_FOR_WALL = {"-x": "x", "+x": "x", "-y": "y", "+y": "y", "-z": "z", "+z": "z"}
12+
_AXIS_INDEX = {"x": 0, "y": 1, "z": 2}
13+
_SPACING_KEY = {"x": "dx", "y": "dy", "z": "dz"}
14+
_COUNT_KEY = {"x": "Nx", "y": "Ny", "z": "Nz"}
15+
16+
17+
def load_pml_from_toml(raw_pml, world, constants):
18+
"""
19+
Read PML wall layers from TOML-style config and build the stretch profiles.
20+
21+
The returned tuple is
22+
23+
active, pml_x, pml_y, pml_z, profiles
24+
25+
where `profiles = (sigma_x, sigma_y, sigma_z)`. The layer tuples built here
26+
are kept local because callers only need the finished coordinate-stretch
27+
profiles, not a second configuration object.
28+
"""
29+
raw_layers = [] if raw_pml is None else raw_pml
30+
raw_layers = [raw_layers] if isinstance(raw_layers, dict) else list(raw_layers)
31+
32+
pml_layers = []
33+
seen_walls = set()
34+
for raw in raw_layers:
35+
wall = raw.get("wall")
36+
if wall not in PML_WALLS:
37+
raise ValueError(f"Invalid PML wall: {wall}. Expected one of {PML_WALLS}")
38+
if wall in seen_walls:
39+
raise ValueError(f"Duplicate PML wall: {wall}")
40+
seen_walls.add(wall)
41+
42+
axis = _AXIS_FOR_WALL[wall]
43+
thickness = int(raw.get("thickness", 0))
44+
active_cells = int(world[_COUNT_KEY[axis]])
45+
if thickness <= 0:
46+
raise ValueError(f"PML thickness for {wall} must be positive")
47+
if thickness > active_cells:
48+
raise ValueError(
49+
f"PML thickness for {wall} exceeds active cells on {axis}: {thickness} > {active_cells}"
50+
)
51+
52+
order = float(raw.get("order", 3.0))
53+
target_reflection = float(raw.get("target_reflection", 1.0e-8))
54+
if "sigma_max" in raw:
55+
sigma_max = float(raw["sigma_max"])
56+
else:
57+
layer_width = thickness * float(world[_SPACING_KEY[axis]])
58+
sigma_max = -((order + 1.0) * float(constants["C"]) * math.log(target_reflection)) / (
59+
2.0 * layer_width
60+
)
61+
62+
pml_layers.append((wall, axis, thickness, order, sigma_max))
63+
64+
pml_x = any(axis == "x" for _, axis, _, _, _ in pml_layers)
65+
pml_y = any(axis == "y" for _, axis, _, _, _ in pml_layers)
66+
pml_z = any(axis == "z" for _, axis, _, _, _ in pml_layers)
67+
68+
return bool(pml_layers), pml_x, pml_y, pml_z, build_pml(world, tuple(pml_layers))
69+
70+
71+
def build_pml(world, pml_layers):
72+
"""
73+
Build ghost-celled damping profiles for the coordinate stretch.
74+
75+
`sigma_x`, `sigma_y`, and `sigma_z` are the real damping strengths in the
76+
frequency-space stretch factors. The ramp is weak at the interface with the
77+
physical domain and grows toward the outer wall, so the outgoing wave sees a
78+
gradual coordinate stretch rather than a sharp jump.
79+
"""
80+
shape = (int(world["Nx"]) + 2, int(world["Ny"]) + 2, int(world["Nz"]) + 2)
81+
82+
sigma_x = jnp.zeros(shape)
83+
sigma_y = jnp.zeros(shape)
84+
sigma_z = jnp.zeros(shape)
85+
profiles = (sigma_x, sigma_y, sigma_z)
86+
87+
for wall, axis, thickness, order, sigma_max in pml_layers:
88+
axis_index = _AXIS_INDEX[axis]
89+
profile_index = _AXIS_INDEX[axis]
90+
sigma_profile = profiles[profile_index]
91+
92+
for i in range(int(thickness)):
93+
sigma = float(sigma_max) * ((i + 1) / int(thickness)) ** float(order)
94+
if wall[0] == "-":
95+
index = int(thickness) - i
96+
else:
97+
index = shape[axis_index] - 1 - int(thickness) + i
98+
99+
slices = [slice(None), slice(None), slice(None)]
100+
slices[axis_index] = index
101+
sigma_profile = sigma_profile.at[tuple(slices)].set(sigma)
102+
103+
profiles = profiles[:profile_index] + (sigma_profile,) + profiles[profile_index + 1 :]
104+
105+
return profiles
106+
107+
108+
def initialize_pml_state(world):
109+
"""
110+
Allocate ADE memory for stretched derivatives.
111+
112+
`pml_state = (E_memory, B_memory)`.
113+
114+
E_memory stores B-derivative history in this order:
115+
dBz_dy, dBy_dz, dBx_dz, dBz_dx, dBy_dx, dBx_dy
116+
117+
B_memory stores E-derivative history in this order:
118+
dEz_dy, dEy_dz, dEx_dz, dEz_dx, dEy_dx, dEx_dy
119+
120+
Each memory array has physical-interior shape `(Nx, Ny, Nz)`, matching the
121+
finite-difference arrays that enter the Yee curl.
122+
"""
123+
shape = (int(world["Nx"]), int(world["Ny"]), int(world["Nz"]))
124+
125+
e_memory = tuple(jnp.zeros(shape) for _ in range(6))
126+
b_memory = tuple(jnp.zeros(shape) for _ in range(6))
127+
128+
return e_memory, b_memory
129+
130+
131+
def stretch_spatial_derivative(derivative, memory, sigma, dt):
132+
"""
133+
Apply one coordinate-stretched PML derivative.
134+
135+
In frequency space, a PML is a complex coordinate stretch. For an x
136+
derivative,
137+
138+
d_dx -> (1 / sigma(w)) d_dx
139+
140+
where `sigma(w)` is the frequency-domain stretch factor. Equivalently,
141+
one often writes `s_x(w) = 1 + sigma_x / (i w)`, so the derivative becomes
142+
`(1 / s_x) d_dx`. The real array `sigma` below is the local damping profile
143+
inside the PML. The memory term is the time-domain bookkeeping that applies
144+
this stretched derivative without storing the whole field history.
145+
"""
146+
b = jnp.exp(-sigma * dt)
147+
memory_new = b * memory + (b - 1.0) * derivative
148+
return derivative + memory_new, memory_new
149+
150+
151+
def apply_pml_to_e_curl(derivatives, world, pml_state):
152+
"""
153+
Stretch the B derivatives before assembling the curl used in Ampere's law.
154+
"""
155+
dBz_dy, dBy_dz, dBx_dz, dBz_dx, dBy_dx, dBx_dy = derivatives
156+
# The PML state is (E_memory, B_memory), but the E curl only needs the B memory.
157+
e_memory, b_memory = pml_state
158+
(
159+
memory_dBz_dy,
160+
memory_dBy_dz,
161+
memory_dBx_dz,
162+
memory_dBz_dx,
163+
memory_dBy_dx,
164+
memory_dBx_dy,
165+
) = e_memory
166+
# unpack the memory in the same order as the derivatives, so we can apply stretch_spatial_derivative
167+
168+
_, _, _, _, profiles = world["pml"]
169+
sigma_x, sigma_y, sigma_z = profiles
170+
sigma_x = sigma_x[1:-1, 1:-1, 1:-1]
171+
sigma_y = sigma_y[1:-1, 1:-1, 1:-1]
172+
sigma_z = sigma_z[1:-1, 1:-1, 1:-1]
173+
dt = world["dt"]
174+
# unpack the profiles and select only interior cells (no ghost cells)
175+
176+
dBz_dy, memory_dBz_dy = stretch_spatial_derivative(dBz_dy, memory_dBz_dy, sigma_y, dt)
177+
dBy_dz, memory_dBy_dz = stretch_spatial_derivative(dBy_dz, memory_dBy_dz, sigma_z, dt)
178+
dBx_dz, memory_dBx_dz = stretch_spatial_derivative(dBx_dz, memory_dBx_dz, sigma_z, dt)
179+
dBz_dx, memory_dBz_dx = stretch_spatial_derivative(dBz_dx, memory_dBz_dx, sigma_x, dt)
180+
dBy_dx, memory_dBy_dx = stretch_spatial_derivative(dBy_dx, memory_dBy_dx, sigma_x, dt)
181+
dBx_dy, memory_dBx_dy = stretch_spatial_derivative(dBx_dy, memory_dBx_dy, sigma_y, dt)
182+
# apply the stretch to each derivative and update the memory
183+
184+
curl_x = dBz_dy - dBy_dz
185+
curl_y = dBx_dz - dBz_dx
186+
curl_z = dBy_dx - dBx_dy
187+
# assemble the curl from the stretched derivatives
188+
189+
e_memory = (
190+
memory_dBz_dy,
191+
memory_dBy_dz,
192+
memory_dBx_dz,
193+
memory_dBz_dx,
194+
memory_dBy_dx,
195+
memory_dBx_dy,
196+
)
197+
# pack the updated memory in the same order as the derivatives
198+
199+
return (curl_x, curl_y, curl_z), (e_memory, b_memory)
200+
# return the curl and the updated PML state (with the new E memory and unchanged B memory)
201+
202+
203+
def apply_pml_to_b_curl(derivatives, world, pml_state):
204+
"""
205+
Stretch the E derivatives before assembling the curl used in Faraday's law.
206+
"""
207+
dEz_dy, dEy_dz, dEx_dz, dEz_dx, dEy_dx, dEx_dy = derivatives
208+
e_memory, b_memory = pml_state
209+
(
210+
memory_dEz_dy,
211+
memory_dEy_dz,
212+
memory_dEx_dz,
213+
memory_dEz_dx,
214+
memory_dEy_dx,
215+
memory_dEx_dy,
216+
) = b_memory
217+
# unpack the memory in the same order as the derivatives, so we can apply stretch_spatial_derivative
218+
219+
_, _, _, _, profiles = world["pml"]
220+
sigma_x, sigma_y, sigma_z = profiles
221+
sigma_x = sigma_x[1:-1, 1:-1, 1:-1]
222+
sigma_y = sigma_y[1:-1, 1:-1, 1:-1]
223+
sigma_z = sigma_z[1:-1, 1:-1, 1:-1]
224+
dt = world["dt"]
225+
# unpack the profiles and select only interior cells (no ghost cells)
226+
227+
dEz_dy, memory_dEz_dy = stretch_spatial_derivative(dEz_dy, memory_dEz_dy, sigma_y, dt)
228+
dEy_dz, memory_dEy_dz = stretch_spatial_derivative(dEy_dz, memory_dEy_dz, sigma_z, dt)
229+
dEx_dz, memory_dEx_dz = stretch_spatial_derivative(dEx_dz, memory_dEx_dz, sigma_z, dt)
230+
dEz_dx, memory_dEz_dx = stretch_spatial_derivative(dEz_dx, memory_dEz_dx, sigma_x, dt)
231+
dEy_dx, memory_dEy_dx = stretch_spatial_derivative(dEy_dx, memory_dEy_dx, sigma_x, dt)
232+
dEx_dy, memory_dEx_dy = stretch_spatial_derivative(dEx_dy, memory_dEx_dy, sigma_y, dt)
233+
# apply the stretch to each derivative and update the memory
234+
235+
curl_x = dEz_dy - dEy_dz
236+
curl_y = dEx_dz - dEz_dx
237+
curl_z = dEy_dx - dEx_dy
238+
# assemble the curl from the stretched derivatives
239+
240+
b_memory = (
241+
memory_dEz_dy,
242+
memory_dEy_dz,
243+
memory_dEx_dz,
244+
memory_dEz_dx,
245+
memory_dEy_dx,
246+
memory_dEx_dy,
247+
)
248+
249+
return (curl_x, curl_y, curl_z), (e_memory, b_memory)
250+
251+
252+
def update_ghost_cells_for_pml(field, world):
253+
"""
254+
Fill ghost cells without periodically wrapping across a PML wall.
255+
256+
The PML damping happens in the stretched derivatives above. This ghost-cell
257+
rule only prevents a periodic stencil from feeding a wave back through the
258+
opposite side of a PML-active axis.
259+
"""
260+
bc_x = world["boundary_conditions"]["x"]
261+
bc_y = world["boundary_conditions"]["y"]
262+
bc_z = world["boundary_conditions"]["z"]
263+
_, pml_x, pml_y, pml_z, _ = world["pml"]
264+
265+
bc_x = jnp.where((pml_x) & (bc_x == BC_PERIODIC), BC_CONDUCTING, bc_x)
266+
bc_y = jnp.where((pml_y) & (bc_y == BC_PERIODIC), BC_CONDUCTING, bc_y)
267+
bc_z = jnp.where((pml_z) & (bc_z == BC_PERIODIC), BC_CONDUCTING, bc_z)
268+
269+
return update_ghost_cells(field, bc_x, bc_y, bc_z)

0 commit comments

Comments
 (0)