@@ -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