Showcase smoothing length iteration algorithlm#

  7 import matplotlib.pyplot as plt
  8 import numpy as np
  9
 10 import shamrock
 11
 12 rng = np.random.default_rng()
 13
 14
 15 def compute_sums(pmass, id_a, h_a, W, dhW, positions: np.ndarray):
 16     rho_sum = 0
 17     sumdWdh = 0
 18
 19     for j in range(positions.shape[0]):
 20         dr = positions[id_a, :] - positions[j, :]
 21         rab2 = dr.dot(dr)
 22
 23         rab = np.sqrt(rab2)
 24         rho_sum += pmass * W(rab, h_a)
 25         sumdWdh += pmass * dhW(rab, h_a)
 26
 27     return rho_sum, sumdWdh
 28
 29
 30 def count_neighbors(id_a, h_a, W, positions: np.ndarray):
 31     count = 0
 32
 33     for j in range(positions.shape[0]):
 34         dr = positions[id_a, :] - positions[j, :]
 35         rab2 = dr.dot(dr)
 36
 37         rab = np.sqrt(rab2)
 38         if W(rab, h_a) > 0:
 39             count += 1
 40
 41     return count
 42
 43
 44 def W(r, h):
 45     return shamrock.math.sphkernel.M4_W3d(r, h)
 46
 47
 48 def dhW(r, h):
 49     return shamrock.math.sphkernel.M4_dhW3d(r, h)
 50
 51
 52 def rho_h(m, h, hfact):
 53     return m * (hfact / h) * (hfact / h) * (hfact / h)
 54
 55
 56 hfact = 1.2  # shamrock.math.sphkernel.hfactd
 57
 58 # SolverConfig.hpp defaults (src/shammodels/sph/include/shammodels/sph/SolverConfig.hpp:708-714)
 59 epsilon_h = 1e-6  # convergence threshold on eps = |new_h - h_a| / h_old
 60 h_evol_iter_max = 1.1  # htol_up_fine_cycle: per Newton-step clamp on new_h/h_a
 61 h_evol_max = 1.1  # htol_up_coarse_cycle: per subcycle clamp on new_h/ha_0 (h at subcycle start)
 62 h_iter_per_subcycles = 50  # LoopSmoothingLengthIter's Newton sweep count per subcycle
 63 h_max_subcycles_count = 100  # sph_prestep's ghost-zone-rebuild subcycle count
 64
 65
 66 def f_df(rho_ha, rho_sum, sumdWdh, h_a):
 67     f_iter = rho_sum - rho_ha
 68     df_iter = sumdWdh + 3 * rho_ha / h_a
 69     return f_iter, df_iter
 70
 71
 72 def f_kernel(q):
 73     return shamrock.math.sphkernel.M4_f(q)
 74
 75
 76 def df_kernel(q):
 77     return shamrock.math.sphkernel.M4_df(q)
 78
 79
 80 def plot_f_df_kernel():
 81     q = np.linspace(0, 4, 1000)
 82
 83     f_values = np.array([f_kernel(x) for x in q])
 84     df_values = np.array([df_kernel(x) for x in q])
 85
 86     fig, ax = plt.subplots(figsize=(10, 5))
 87     ax.plot(q, f_values, label=r"$f(q)$")
 88     ax.plot(q, df_values, label=r"$df(q)$")
 89     ax.plot(q, f_values + df_values * q / 3, label=r"$f(q) + df(q) \cdot q / 3$")
 90     ax.set_xlabel(r"$q$")
 91     ax.legend()
 92     plt.show()
 93
 94
 95 def newton_iterate_new_h(h_a, positions, state_vars: dict):
 96     """One Newton-Raphson sweep, reproducing the per-particle branch of
 97     IterateSmoothingLengthDensity.cpp (src/shammodels/sph/src/modules/
 98     IterateSmoothingLengthDensity.cpp:52-119).
 99
100     state_vars["ha_0"] is h_old: the h value at the start of the current
101     subcycle (reset by the caller each time sph_prestep would rebuild the
102     ghost zone), NOT the previous Newton iterate.
103
104     Returns (new_h, eps). eps == -1 is the sentinel the real kernel uses to
105     mean "new_h would exceed ha_0 * h_evol_max (htol_up_coarse_cycle)": the
106     caller must treat this as sph_prestep does and start a fresh subcycle.
107     """
108     ha_0 = state_vars["ha_0"]
109
110     rho_ha = rho_h(pmass, h_a, hfact)
111     rho_sum, sumdWdh = compute_sums(pmass, id_a, h_a, W, dhW, positions)
112     f_iter, df_iter = f_df(rho_ha, rho_sum, sumdWdh, h_a)
113     new_h = h_a - f_iter / df_iter
114
115     # per-iteration clamp (htol_up_fine_cycle), relative to the previous iterate h_a
116     h_max_evol_m = 1.0 / h_evol_iter_max
117     h_max_evol_p = h_evol_iter_max
118     new_h = max(new_h, h_a * h_max_evol_m)
119     new_h = min(new_h, h_a * h_max_evol_p)
120
121     # per-subcycle clamp (htol_up_coarse_cycle), relative to ha_0 (h at subcycle start)
122     if new_h < ha_0 * h_evol_max:
123         eps = abs(new_h - h_a) / ha_0
124     else:
125         new_h = ha_0 * h_evol_max
126         eps = -1.0
127
128     return new_h, eps
129
130
131 def newton_iterate_new_h_neigh_lim(h_a, positions, state_vars: dict, trigger_threshold=500):
132     """One Newton-Raphson sweep with a neighbor-count safety limiter,
133     reproducing the per-particle branch of IterateSmoothingLengthDensityNeighLim.cpp
134     (src/shammodels/sph/src/modules/IterateSmoothingLengthDensityNeighLim.cpp:59-148).
135
136     On top of newton_iterate_new_h's clamps, this adds two neighbor-count
137     guards evaluated before any clamp is applied:
138       - if h_a already has more than trigger_threshold neighbors, shrink h_a
139         by h_evol_iter_max and report eps=0 (the caller treats eps < epsilon_h
140         as converged, so this freezes the particle at the shrunk h_a for the
141         rest of the subcycle).
142       - if growing h_a up to h_evol_iter_max * h_a would push the neighbor
143         count over trigger_threshold and the raw Newton step wants to grow
144         h_a, leave h_a unchanged and also report eps=0.
145     """
146     ha_0 = state_vars["ha_0"]
147
148     h_max_evol_m = 1.0 / h_evol_iter_max
149     h_max_evol_p = h_evol_iter_max
150
151     count_within = count_neighbors(id_a, h_a, W, positions)
152     count_within_next = count_neighbors(id_a, h_a * h_max_evol_p, W, positions)
153
154     rho_ha = rho_h(pmass, h_a, hfact)
155     rho_sum, sumdWdh = compute_sums(pmass, id_a, h_a, W, dhW, positions)
156     f_iter, df_iter = f_df(rho_ha, rho_sum, sumdWdh, h_a)
157     new_h = h_a - f_iter / df_iter
158
159     if count_within > trigger_threshold:
160         return h_max_evol_m * h_a, 0.0
161
162     if count_within_next > trigger_threshold and new_h > h_a:
163         return h_a, 0.0
164
165     # per-iteration clamp (htol_up_fine_cycle), relative to the previous iterate h_a
166     new_h = max(new_h, h_a * h_max_evol_m)
167     new_h = min(new_h, h_a * h_max_evol_p)
168
169     # per-subcycle clamp (htol_up_coarse_cycle), relative to ha_0 (h at subcycle start)
170     if new_h < ha_0 * h_evol_max:
171         eps = abs(new_h - h_a) / ha_0
172     else:
173         new_h = ha_0 * h_evol_max
174         eps = -1.0
175
176     return new_h, eps
177
178
179 algs = {
180     "Newton": newton_iterate_new_h,
181     "Newton (neigh lim)": newton_iterate_new_h_neigh_lim,
182     # "Bisection": bisect_iterate_new_h,
183     # "Bisection + NR": bisect_NR_iterate_new_h,
184 }
185
186
187 def simulate_h_iter(init_h_a, positions: np.ndarray, id_a: int, pmass: float, iterate_new_h):
188     """Run the full h iteration (outer ghost-zone subcycles + inner Newton
189     sweeps) starting from init_h_a, and return its history.
190
191     Returns (history_h_a, history_f, history_df, history_neigh_count, converged,
192     subcycle_end_indices).
193     """
194     h_a = init_h_a
195     history_h_a = [h_a]
196     history_f = []
197     history_df = []
198     history_neigh_count = []
199     subcycle_end_indices = []
200     converged = False
201
202     # outer loop: sph_prestep's ghost-zone-rebuild subcycle
203     # (src/shammodels/sph/src/Solver.cpp:1235, hstep_cnt < h_max_subcycles_count)
204     for hstep_cnt in range(h_max_subcycles_count):
205         # each subcycle resets h_old to the current h (Solver.cpp:1245)
206         state_vars = {"ha_0": h_a}
207
208         # inner loop: LoopSmoothingLengthIter's Newton sweep count
209         # (LoopSmoothingLengthIter.cpp:31, iter_h < h_iter_per_subcycles)
210         for iter_h in range(h_iter_per_subcycles):
211             h_a, eps = iterate_new_h(h_a, positions, state_vars)
212             history_h_a.append(h_a)
213
214             rho_ha = rho_h(pmass, h_a, hfact)
215             rho_sum, sumdWdh = compute_sums(pmass, id_a, h_a, W, dhW, positions)
216             f_iter, df_iter = f_df(rho_ha, rho_sum, sumdWdh, h_a)
217             history_f.append(f_iter)
218             history_df.append(df_iter)
219             history_neigh_count.append(count_neighbors(id_a, h_a, W, positions))
220
221             if eps < 0:
222                 # stuck: h wants to exceed ha_0 * h_evol_max this subcycle.
223                 # sph_prestep would rebuild a wider ghost zone here and retry;
224                 # break the inner loop to start a fresh subcycle anchored at
225                 # the (clamped) current h.
226                 break
227             if eps < epsilon_h:
228                 converged = True
229                 break
230
231         # per-subcycle clamp (htol_up_coarse_cycle): whatever the inner Newton
232         # loop did, h_a can never end a subcycle above ha_0 * h_evol_max
233         # (Solver.cpp:1235-1425, ha_0 is h_old reset at the top of each hstep_cnt).
234         ha_0 = state_vars["ha_0"]
235         assert h_a <= h_evol_max * ha_0, (
236             f"h_a = {h_a} is larger than h_evol_max * ha_0 = {h_evol_max * ha_0}"
237         )
238
239         # mark where this iter_h subcycle ended, whichever way it ended
240         subcycle_end_indices.append(len(history_h_a) - 1)
241
242         if converged:
243             break
244
245     return (
246         history_h_a,
247         history_f,
248         history_df,
249         history_neigh_count,
250         converged,
251         subcycle_end_indices,
252     )
253
254
255 def analyse_h_convergence(
256     positions: np.ndarray, id_a: int, pmass: float, iterate_new_h, test_h_values: np.ndarray
257 ):
258
259     histories = []
260     for init_h_a in test_h_values:
261         (
262             history_h_a,
263             history_f,
264             history_df,
265             history_neigh_count,
266             converged,
267             subcycle_end_indices,
268         ) = simulate_h_iter(init_h_a, positions, id_a, pmass, iterate_new_h)
269         histories.append(
270             (init_h_a, history_h_a, history_f, history_neigh_count, converged, subcycle_end_indices)
271         )
272
273     candidates = [entry for entry in histories if entry[1][-1] < 10]
274     best_entry = min(candidates, key=lambda entry: np.abs(entry[2][-1]))
275     found_h_a = best_entry[1][-1]
276
277     # run one more simulation starting exactly at the found fixed point, and
278     # insert it in sorted order (by init_h_a) alongside the other traces
279     (
280         history_h_a,
281         history_f,
282         history_df,
283         history_neigh_count,
284         converged,
285         subcycle_end_indices,
286     ) = simulate_h_iter(found_h_a, positions, id_a, pmass, iterate_new_h)
287     insert_pos = np.searchsorted([entry[0] for entry in histories], found_h_a)
288     histories.insert(
289         insert_pos,
290         (found_h_a, history_h_a, history_f, history_neigh_count, converged, subcycle_end_indices),
291     )
292
293     iteration_counts = [
294         (len(history_h_a) - 1 if converged else np.nan)
295         for _, history_h_a, _, _, converged, _ in histories
296     ]
297
298     final_f_values = [history_f[-1] for _, _, history_f, _, _, _ in histories]
299
300     final_neigh_counts = [
301         history_neigh_count[-1] for _, _, _, history_neigh_count, _, _ in histories
302     ]
303
304     return histories, found_h_a, iteration_counts, final_f_values, final_neigh_counts
305
306
307 def plot_h_convergence(histories, found_h_a, axs):
308     ax_h, ax_neigh = axs
309
310     for (
311         init_h_a,
312         history_h_a,
313         history_f,
314         history_neigh_count,
315         converged,
316         subcycle_end_indices,
317     ) in histories:
318         end_idx = np.array(subcycle_end_indices)
319
320         (line,) = ax_h.plot(np.array(history_h_a) - found_h_a, label=f"init_h_a = {init_h_a}")
321         ax_h.plot(
322             end_idx,
323             np.array(history_h_a)[end_idx] - found_h_a,
324             marker="x",
325             linestyle="none",
326             color=line.get_color(),
327         )
328
329         # history_neigh_count has no entry for the initial h_a, so its index i
330         # lines up with history_h_a's index i + 1 on the shared x-axis
331         neigh_x = np.arange(1, len(history_neigh_count) + 1)
332         ax_neigh.plot(neigh_x, history_neigh_count, color=line.get_color())
333         ax_neigh.plot(
334             end_idx,
335             np.array(history_neigh_count)[end_idx - 1],
336             marker="x",
337             linestyle="none",
338             color=line.get_color(),
339         )
340
341     ax_h.set_yscale("symlog", linthresh=1e-3)
342     ax_h.set_ylabel(r"$\delta h_a$")
343     ax_h.legend()
344
345     ax_neigh.set_xlabel("iteration count")
346     ax_neigh.set_ylabel("neighbor count")
347     ax_neigh.set_yscale("log")
348
349
350 def plot_rho_f_df(h_a_test):
351     f_values = np.zeros(h_a_test.shape)
352     df_values = np.zeros(h_a_test.shape)
353
354     rho_sum_values = np.zeros(h_a_test.shape)
355     rho_h_values = np.zeros(h_a_test.shape)
356
357     for i in range(h_a_test.shape[0]):
358         rho_ha = rho_h(pmass, h_a_test[i], hfact)
359         rho_sum, sumdWdh = compute_sums(pmass, id_a, h_a_test[i], W, dhW, positions)
360         rho_sum_values[i] = rho_sum
361         rho_h_values[i] = rho_ha
362         f_values[i], df_values[i] = f_df(rho_ha, rho_sum, sumdWdh, h_a_test[i])
363
364     fig_rho, ax_rho = plt.subplots(figsize=(10, 5))
365     ax_rho.plot(
366         h_a_test, f_values, label=r"$f(h_a) = \sum_b m_b W(r_{ab}, h_a) - \rho_h(m_a, h_a)$"
367     )
368     ax_rho.plot(
369         h_a_test,
370         df_values,
371         label=r"$f'(h_a) = \sum_b m_b \frac{\partial W}{\partial h}(r_{ab}, h_a) + 3 \rho_h(m_a, h_a) / h_a$",
372     )
373     ax_rho.plot(h_a_test, rho_h_values, label=r"$\rho_h(m_a, h_a)$")
374     ax_rho.plot(h_a_test, rho_sum_values, label=r"$\rho_sum(m_a, h_a)$")
375
376     ax_rho.set_yscale("symlog", linthresh=1e-4)
377     ax_rho.set_xscale("log")
378     ax_rho.set_xlabel("h_a")
379     ax_rho.legend()
380
381
382 def compare_algs_h_convergence(test_h_values, algs):
383     results = {}
384     for name, alg in algs.items():
385         fig, axs = plt.subplots(2, 1, figsize=(10, 8), sharex=True)
386         fig.suptitle(name)
387
388         histories, found_h_a, iteration_counts, final_f_values, final_neigh_counts = (
389             analyse_h_convergence(positions, id_a, pmass, alg, test_h_values)
390         )
391
392         plot_h_convergence(histories, found_h_a, axs)
393
394         # histories may hold one more entry than test_h_values (the extra run
395         # seeded at found_h_a), so derive the x-axis from histories itself
396         init_h_a_values = [entry[0] for entry in histories]
397         results[name] = (init_h_a_values, iteration_counts, final_f_values, final_neigh_counts)
398
399         plt.tight_layout()
400
401     fig, axs = plt.subplots(3, 1, figsize=(10, 12))
402     fig.suptitle("Algorithm comparison")
403
404     bar_width = 0.8 / len(results)
405     for i, (
406         name,
407         (init_h_a_values, iteration_counts, final_f_values, final_neigh_counts),
408     ) in enumerate(results.items()):
409         x = np.arange(len(init_h_a_values)) + i * bar_width
410         axs[0].bar(x, iteration_counts, width=bar_width, label=name)
411         axs[1].bar(x, final_f_values, width=bar_width, label=name)
412         axs[2].bar(x, final_neigh_counts, width=bar_width, label=name)
413
414     first_init_h_a_values = next(iter(results.values()))[0]
415     xticks = np.arange(len(first_init_h_a_values)) + bar_width * (len(results) - 1) / 2
416     xticklabels = [f"{v:.3g}" for v in first_init_h_a_values]
417
418     axs[0].set_yscale("log")
419     axs[0].set_xticks(xticks)
420     axs[0].set_xticklabels(xticklabels)
421     axs[0].set_xlabel("init_h_a")
422     axs[0].set_ylabel("iteration count")
423     axs[0].set_title("Convergence speed")
424     axs[0].legend()
425
426     axs[1].set_yscale("symlog", linthresh=1e-14)
427     axs[1].set_xticks(xticks)
428     axs[1].set_xticklabels(xticklabels)
429     axs[1].set_xlabel("init_h_a")
430     axs[1].set_ylabel(r"$f(h_a)$")
431     axs[1].set_title("Residual at convergence")
432     axs[1].legend()
433
434     axs[2].set_yscale("log")
435     axs[2].set_xticks(xticks)
436     axs[2].set_xticklabels(xticklabels)
437     axs[2].set_xlabel("init_h_a")
438     axs[2].set_ylabel("neighbor count")
439     axs[2].set_title("Neighbor count at convergence")
440     axs[2].legend()
441
442     plt.tight_layout()
443
444
445 def generate_cubic_distrib(Nside):
446     positions = []
447
448     id_a = 0
449     for ix in range(Nside):
450         for iy in range(Nside):
451             for iz in range(Nside):
452                 positions.append((ix, iy, iz))
453                 # positions.append(np.random.rand(3))
454
455                 if ix == 10 and iy == 10 and iz == 10:
456                     id_a = len(positions) - 1
457
458     positions = np.array(positions)
459
460     return positions, id_a
461
462
463 def generate_random_distrib(Nside):
464     positions = []
465
466     id_a = 0
467     for ix in range(Nside):
468         for iy in range(Nside):
469             for iz in range(Nside):
470                 positions.append(rng.random(3))
471
472                 if ix == 10 and iy == 10 and iz == 10:
473                     id_a = len(positions) - 1
474
475     positions = np.array(positions)
476
477     return positions, id_a
478
479
480 def generate_random_distrib_giantpart(Nside):
481     positions = []
482
483     id_a = 0
484     for ix in range(Nside):
485         for iy in range(Nside):
486             for iz in range(Nside):
487                 positions.append(rng.random(3))
488
489     positions.append((10, 0, 0))
490     id_a = len(positions) - 1
491
492     positions = np.array(positions)
493
494     return positions, id_a
501 plot_f_df_kernel()
run h convergence

Cubic distrib#

507 positions, id_a = generate_cubic_distrib(Nside=10)
508 pmass = 1.0 / 1000.0
512 h_a_test = np.logspace(-3, 2, 1000)
513
514 plot_rho_f_df(h_a_test)
run h convergence
518 # sample 10 equally spaced values in h_a_test indexes
519 test_h_values = np.logspace(-3, 2, 10)
520
521 compare_algs_h_convergence(test_h_values, algs)
522
523 plt.show()
  • Newton
  • Newton (neigh lim)
  • Algorithm comparison, Convergence speed, Residual at convergence, Neighbor count at convergence

Random distrib#

529 positions, id_a = generate_random_distrib(Nside=10)
530 pmass = 1.0 / 1000.0
534 h_a_test = np.logspace(-3, 2, 1000)
535
536 plot_rho_f_df(h_a_test)
run h convergence
540 # sample 10 equally spaced values in h_a_test indexes
541 test_h_values = np.logspace(-3, 2, 10)
542
543 compare_algs_h_convergence(test_h_values, algs)
544
545 plt.show()
  • Newton
  • Newton (neigh lim)
  • Algorithm comparison, Convergence speed, Residual at convergence, Neighbor count at convergence

Random distrib (giant particle)#

552 positions, id_a = generate_random_distrib_giantpart(Nside=10)
553 pmass = 1.0 / 1000.0
557 h_a_test = np.logspace(-3, 2, 1000)
558
559 plot_rho_f_df(h_a_test)
run h convergence
563 # sample 10 equally spaced values in h_a_test indexes
564 test_h_values = np.logspace(-3, 2, 10)
565
566 compare_algs_h_convergence(test_h_values, algs)
567
568 plt.show()
  • Newton
  • Newton (neigh lim)
  • Algorithm comparison, Convergence speed, Residual at convergence, Neighbor count at convergence

Total running time of the script: (0 minutes 53.024 seconds)

Estimated memory usage: 168 MB

Gallery generated by Sphinx-Gallery