Note
Go to the end to download the full example code.
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()

Cubic distrib#
512 h_a_test = np.logspace(-3, 2, 1000)
513
514 plot_rho_f_df(h_a_test)

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()
Random distrib#
534 h_a_test = np.logspace(-3, 2, 1000)
535
536 plot_rho_f_df(h_a_test)

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()
Random distrib (giant particle)#
557 h_a_test = np.logspace(-3, 2, 1000)
558
559 plot_rho_f_df(h_a_test)

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()
Total running time of the script: (0 minutes 53.024 seconds)
Estimated memory usage: 168 MB








