Skip to content

Commit fb57593

Browse files
author
abs
committed
Optimized
1 parent acb28ac commit fb57593

1 file changed

Lines changed: 80 additions & 27 deletions

File tree

papers/black-hole-singularities/simulations/schwarzschild_counting.py

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ def __init__(
141141

142142
self.r_peak = float(self._r_grid[np.argmax(self._m_grid)])
143143

144+
# Safe fixed step for the (much faster) leapfrog integrator, derived
145+
# from the local "stiffness" of the force curve (max curvature of
146+
# dm/dr), not guessed by hand -- this is what avoids repeating the
147+
# original fixed-dt energy-injection artifact while still using a
148+
# cheap fixed step instead of paying for full adaptive RK45 stiffness.
149+
d2m_dr2 = np.gradient(self._dm_dr_grid, self._r_grid)
150+
stiffness = np.max(np.abs(d2m_dr2))
151+
self._dt_safe = 0.05 / np.sqrt(max(stiffness, 1e-12))
152+
144153
super().__init__(n, r0, spacing, bh, tangential_fraction, radial_fraction, rng_seed)
145154

146155
# ------------------------------------------------------------------
@@ -190,40 +199,84 @@ def _acceleration_batch(self, pos: np.ndarray) -> np.ndarray:
190199
# Deterministic evolution -- adaptive RK45, no fixed-dt artifact risk
191200
# ------------------------------------------------------------------
192201

202+
def evolve_leapfrog(self, dt: float, max_t: float, substep_dt: float | None = None) -> Tuple[np.ndarray, np.ndarray]:
203+
"""
204+
Fast alternative to evolve(): vectorized velocity-Verlet (leapfrog),
205+
symplectic for this purely position-dependent force, so it doesn't
206+
accumulate energy drift the way fixed-step RK4/RK45 can -- meaning
207+
it can use a much larger, fixed internal step without reintroducing
208+
the original energy-injection artifact. `substep_dt` defaults to
209+
self._dt_safe, derived from the force curve's own curvature rather
210+
than picked by hand. `dt` is the output spacing; internally it is
211+
subdivided into substeps of size <= substep_dt.
212+
"""
213+
if substep_dt is None:
214+
substep_dt = self._dt_safe
215+
n_sub = max(1, int(np.ceil(dt / substep_dt)))
216+
h = dt / n_sub
217+
218+
N = len(self.particles)
219+
pos = np.array([p.position() for p in self.particles], dtype=np.float64)
220+
vel = np.array([p.velocity() for p in self.particles], dtype=np.float64)
221+
222+
steps = int(max_t / dt)
223+
times = np.arange(steps, dtype=np.float64) * dt
224+
positions = np.zeros((steps, N, 3), dtype=np.float64)
225+
226+
acc = self._acceleration_batch(pos)
227+
for step_idx in range(steps):
228+
for _ in range(n_sub):
229+
vel_half = vel + 0.5 * h * acc
230+
pos = pos + h * vel_half
231+
acc = self._acceleration_batch(pos)
232+
vel = vel_half + 0.5 * h * acc
233+
positions[step_idx, :, :] = pos
234+
235+
return times, positions
236+
193237
def evolve(self, dt: float, max_t: float, tolerance: float = 1e-8) -> Tuple[np.ndarray, np.ndarray]:
194238
"""
195239
Integrate the dust cloud under the counting-equation acceleration
196-
using adaptive RK45 (scipy solve_ivp). `dt` is the OUTPUT time
197-
spacing (what you'll see in the returned trajectory), not the
198-
integration step -- the solver internally takes whatever step
199-
size `tolerance` (rtol/atol) demands, shrinking automatically
200-
near the steep part of dm/dr close to r=0. This is what actually
201-
fixes the earlier fixed-dt energy-injection artifact, rather than
202-
just picking a smaller dt by hand.
240+
using adaptive RK45 (scipy solve_ivp), ONE PARTICLE AT A TIME.
241+
242+
Particles are integrated independently rather than as one coupled
243+
6N-dimensional system. That matters here specifically: if even one
244+
particle wanders into the steep near-core region, a coupled solve
245+
shrinks its adaptive step for the ENTIRE ensemble, since scipy has
246+
no way to know the particles don't interact. Since this model has
247+
no particle-particle force (each particle only feels the radial
248+
counting-equation field), decoupling is exact, not an
249+
approximation, and lets particles far from the core stay large-
250+
stepped/fast while only the ones actually near the core pay the
251+
cost of fine resolution.
252+
253+
`dt` is the OUTPUT time spacing, not the integration step; the
254+
solver internally takes whatever step size `tolerance` (rtol/atol)
255+
demands.
203256
"""
204257
N = len(self.particles)
205-
y0 = np.concatenate([np.concatenate([p.position(), p.velocity()]) for p in self.particles])
206-
207-
def rhs(t, y):
208-
y2 = y.reshape(N, 6)
209-
pos = y2[:, 0:3]
210-
vel = y2[:, 3:6]
211-
acc = self._acceleration_batch(pos)
212-
dydt = np.empty_like(y2)
213-
dydt[:, 0:3] = vel
214-
dydt[:, 3:6] = acc
215-
return dydt.reshape(-1)
216-
217258
t_eval = np.arange(0.0, max_t, dt)
218-
sol = solve_ivp(
219-
rhs, (0.0, max_t), y0, method="RK45",
220-
t_eval=t_eval, rtol=tolerance, atol=tolerance * 1e-3,
221-
max_step=dt,
222-
)
223-
224259
steps = len(t_eval)
225-
y_out = sol.y.reshape(N, 6, steps)
226-
positions = np.transpose(y_out[:, 0:3, :], (2, 0, 1)) # (steps, N, 3)
260+
positions = np.zeros((steps, N, 3), dtype=np.float64)
261+
262+
def rhs_single(t, y):
263+
pos = y[0:3]
264+
vel = y[3:6]
265+
r = np.sqrt(pos[0]**2 + pos[1]**2 + pos[2]**2)
266+
r_safe = max(r, 1e-9)
267+
r_hat = pos / r_safe
268+
acc = self.dm_dr_of_r(r_safe) * r_hat
269+
return np.concatenate([vel, acc])
270+
271+
for j, p in enumerate(self.particles):
272+
y0 = np.concatenate([p.position(), p.velocity()])
273+
sol = solve_ivp(
274+
rhs_single, (0.0, max_t), y0, method="RK45",
275+
t_eval=t_eval, rtol=tolerance, atol=tolerance * 1e-3,
276+
max_step=dt,
277+
)
278+
positions[:, j, :] = sol.y[0:3, :].T
279+
227280
return t_eval, positions
228281

229282
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)