flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
test_hydro_force_units.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""The hydrodynamic force and torque getters are unit-covariant, the public one is accurate, and
3the traction diagnostic reads what it is documented to read.
4
5The public getter is `hydro_force_torque_reaction()` (the discrete reaction). The traction
6integral is the diagnostic `diagnostics.hydro_force_torque_traction()`; its old name
7`hydro_force_torque()` is deprecated and must return the same array (2026-09-30, register
8coupling.md "The public force API returns the reaction").
9
10One sphere in a periodic cube (phi = 0.216, the regression's Zick & Homsy case) is solved in THREE
11unit systems that pose the same index-space problem (the same viscous number beta = nu dt / h^2 = 6
12and, for the spinning sphere, the same Omega dt):
13
14 * "cell" -- no extent (spacing 1), rho 1, mu 0.1, dt 60, f 1e-3: the historical cell-unit API;
15 * "unit" -- extent L = 1, rho = mu = f = 1;
16 * "odd" -- extent L = 7.3, rho 850, mu 0.037, f 2.5: no reference scale is 1.
17
18Every DIMENSIONLESS number formed from a getter's output must then agree across the three systems
19to round-off (the index-space arithmetic differs only by the scaling of its inputs). That is the gate that failed before 2026-09-30: `hydro_force_torque()`
20applied the index -> physical conversion TWICE (force x forceTotalToPhys^2, torque x
21torqueToPhys^2), which is the identity in cell units -- so every cell-unit gate passed -- and under
22a physical domain read 0.0194 of the reaction at L = 1 and 3e-8 of it at L = 7.3.
23
24Checks, translating sphere (body force along +x, marched to steady Stokes):
25 1. K = f L^3 / (3 pi mu D U) within 1.5 % of Zick & Homsy's 7.442 (N = 24: -0.7 % measured);
26 2. the reaction identity: sum F_reaction = f * V_fluid within 1 % (geometric V_fluid);
27 3. traction / reaction in [0.60, 0.80] -- the documented resolution-independent ~29 % under-read
28 (0.698 at N = 24 and 32);
29 4. K, F_reaction / (f V_fluid), traction / reaction and the pressure / viscous split of the
30 traction identical across the three systems to 1e-10 relative.
31Spinning sphere (Omega about z, fixed step count -- the index-space states are proportional, not
32steady):
33 5. T_z / (mu a^3 Omega) from the reaction AND from the traction identical across the systems to
34 1e-10 relative, and nonzero.
35The names:
36 6. the deprecated hydro_force_torque() returns what diagnostics.hydro_force_torque_traction()
37 returns (shape (4, n, 3), equal to the atomics' round-off), and raises ONE DeprecationWarning
38 that names hydro_force_torque_reaction().
39Torque accuracy (unit system only, marched to steady Stokes):
40 7. translating sphere on the vertex: the reaction torque is zero (|T| / (|F| R) < 1e-10);
41 8. steadily spinning sphere, phi 0.027, N 32 (R/h 6): the reaction torque within (1.00, 1.04)
42 of 8 pi mu a^3 Omega / (1 - phi) (1.0275 measured), the traction's in (0.50, 0.70) (0.593);
43 9. translating sphere OFF the vertex (true torque zero, no discrete symmetry): the reaction's
44 spurious |T| / (|F| R) below 3e-3 (1.1e-3 measured) and below the traction's (4.4e-3).
45
46Run: OMP_NUM_THREADS=8 PYTHONPATH=<build> python tests/python/test_hydro_force_units.py
47Exit 0 pass, 1 fail, 77 skipped (no module).
48"""
49import math
50import sys
51import warnings
52
53import numpy as np
54
55try:
56 import peclet.flow as pf
57except ImportError:
58 print("SKIP: peclet.flow not importable")
59 sys.exit(77)
60
61N = 24
62PHI = 0.216
63K_ZH = 7.442
64BETA = 6.0 # nu dt / h^2 -- the cell-unit dt 60 at nu 0.1
65OMEGA_DT = 0.06 # the spinning sphere's rotation per step (cell units: Omega 1e-3 at dt 60)
66SYSTEMS = { # name: (L or None for cell units, rho, mu, f)
67 "cell": (None, 1.0, 0.1, 1e-3),
68 "unit": (1.0, 1.0, 1.0, 1.0),
69 "odd": (7.3, 850.0, 0.037, 2.5),
70}
71TOL_X = 1e-10 # cross-system agreement of a dimensionless number (measured <= 3.1e-14)
72TOL_ATOMIC = 1e-12 # two calls of one getter: atomics are tolerance-reproducible, not bitwise
73
74failures = []
75
76
77def check(ok, what):
78 print((" ok " if ok else " FAIL ") + what)
79 if not ok:
80 failures.append(what)
81
82
83def build(name, spin, n=N, phi=PHI, beta=BETA, shift=(0.0, 0.0, 0.0)):
84 L, rho, mu, f = SYSTEMS[name]
85 if L is None:
86 L = float(n)
87 s = pf.Solver((n, n, n))
88 else:
89 s = pf.Solver((n, n, n), extent=(L, L, L))
90 h = L / n
91 dt = beta * h * h * rho / mu
92 s.set_rho(rho)
93 s.set_mu(mu)
94 s.set_dt(dt)
95 s.set_body_force((0.0 if spin else f, 0.0, 0.0))
96 s.set_advection(False)
97 s.set_pressure_multigrid(True, levels=3)
98 s.set_pressure_pcg(True, 200, 1e-12)
99 s.diagnostics.set_velocity_solver_params(400, 1e-13)
100 R = (3.0 * phi / (4.0 * math.pi)) ** (1.0 / 3.0) * L
101 # The sphere centre on a cell VERTEX, the same relative placement in every system. The
102 # cell-unit scene puts cell centre (i, j, k) at (i, j, k); a physical one at (i + 1/2) h.
103 # `shift` (in cells) moves it off the vertex, breaking the discrete mirror symmetries.
104 c0 = 0.5 * L - (0.5 if s.unit_scales["identity"] else 0.0)
105 c = [c0 + shift[k] * h for k in range(3)]
106 ni = np.array([1, -1, -1], dtype=np.int32)
107 nr = np.zeros(16)
108 nr[0] = R
109 nr[14] = 1.0
110 nr[15] = 1.0
111 ii = np.array([0, -1], dtype=np.int32)
112 ir = np.zeros(17)
113 ir[0:3] = c
114 ir[6] = 1.0
115 ir[7] = 1.0
116 s.set_scene(ni, nr, ii, ir, periodic=True)
117 omega = OMEGA_DT / dt
118 if spin:
119 s.set_instance_motion(0, ang_vel=[0.0, 0.0, omega], center=c)
120 s.set_solid_from_scene(cutcell_pressure=True)
121 return s, dict(L=L, rho=rho, mu=mu, f=f, R=R, h=h, dt=dt, omega=omega)
122
123
124def translating(name):
125 s, p = build(name, spin=False)
126 prev = 0.0
127 for it in range(600):
128 s.step()
129 um = float(np.mean(s.get_u()))
130 if it > 10 and abs(um - prev) < 1e-9 * abs(um):
131 break
132 prev = um
133 U = float(np.mean(s.get_u())) # superficial: solid cells hold zero
134 rea = np.asarray(s.hydro_force_torque_reaction())
135 tra = np.asarray(s.diagnostics.hydro_force_torque_traction())
136 with warnings.catch_warnings(record=True) as caught: # deprecated name of the same traction
137 warnings.simplefilter("always")
138 old = np.asarray(s.hydro_force_torque())
139 warned = [w for w in caught if issubclass(w.category, DeprecationWarning)
140 and "hydro_force_torque_reaction()" in str(w.message)]
141 D = 2.0 * p["R"]
142 fV = p["f"] * (p["L"] ** 3 - math.pi / 6.0 * D ** 3)
143 r = dict(steps=it + 1,
144 K=p["f"] * p["L"] ** 3 / (3.0 * math.pi * p["mu"] * D * U),
145 rea_over_fV=rea[0, 0, 0] / fV,
146 tra_over_rea=tra[0, 0, 0] / rea[0, 0, 0],
147 tra_p_over_rea=tra[2, 0, 0] / rea[0, 0, 0],
148 tra_v_over_rea=tra[3, 0, 0] / rea[0, 0, 0],
149 old_vs_tra=float(np.max(np.abs(old - tra)) / np.max(np.abs(tra))),
150 old_shape=old.shape == tra.shape == (4, 1, 3),
151 old_warned=len(warned) == 1,
152 T_rea_rel=float(np.linalg.norm(rea[1, 0]) / (np.linalg.norm(rea[0, 0]) * p["R"])))
153 print(" %-4s steps %3d K %.9f F_rea/(f V_fl) %.9f traction/reaction %.9f "
154 "(pressure %.6f + viscous %.6f)" % (name, r["steps"], r["K"], r["rea_over_fV"],
155 r["tra_over_rea"], r["tra_p_over_rea"],
156 r["tra_v_over_rea"]))
157 return r
158
159
160def spinning(name, steps=30):
161 s, p = build(name, spin=True)
162 for _ in range(steps):
163 s.step()
164 T0 = p["mu"] * p["R"] ** 3 * p["omega"]
165 r = dict(T_rea=np.asarray(s.hydro_force_torque_reaction())[1, 0, 2] / T0,
166 T_tra=np.asarray(s.diagnostics.hydro_force_torque_traction())[1, 0, 2] / T0)
167 print(" %-4s T_z/(mu a^3 Omega): reaction %.9f traction %.9f" % (name, r["T_rea"],
168 r["T_tra"]))
169 return r
170
171
172def steady(s, probe, maxit=3000, tol=1e-9):
173 """March to steady Stokes: stop when probe() changes by < tol relative over 10 steps."""
174 prev = None
175 for it in range(maxit):
176 s.step()
177 if it % 10 == 9:
178 v = probe()
179 if prev is not None and abs(v - prev) < tol * abs(v):
180 return it + 1
181 prev = v
182 return maxit
183
184
185def spinning_steady(n, phi):
186 """Steady spinning sphere: T_z against the periodic-array value 8 pi mu a^3 Omega / (1 - phi)
187 (a rigid rotation sets the cell's mean vorticity, so the fluid counter-rotates at -phi Omega
188 to leading order; phi 0.008 and 0.027 at equal R/h agree to 3e-4 under this reference)."""
189 s, p = build("unit", True, n=n, phi=phi, beta=BETA * (n / N) ** 2)
190 steps = steady(s, lambda: float(np.asarray(s.hydro_force_torque_reaction())[1, 0, 2]))
191 T0 = 8.0 * math.pi * p["mu"] * p["R"] ** 3 * p["omega"] / (1.0 - phi)
192 rea = np.asarray(s.hydro_force_torque_reaction())
193 tra = np.asarray(s.diagnostics.hydro_force_torque_traction())
194 r = dict(T_rea=-rea[1, 0, 2] / T0, T_tra=-tra[1, 0, 2] / T0,
195 off_axis=float(np.linalg.norm(rea[1, 0, :2]) / abs(rea[1, 0, 2])))
196 print(" unit N %d phi %.3f R/h %.2f steps %d: -T_z (1 - phi) / (8 pi mu a^3 Omega) reaction "
197 "%.6f traction %.6f" % (n, phi, p["R"] / p["h"], steps, r["T_rea"], r["T_tra"]))
198 return r
199
200
201def translating_offset(n, phi, shift):
202 """Steady translating sphere OFF the cell vertex: the true torque is zero, and no discrete
203 symmetry makes it so -- what is left is each getter's own torque error."""
204 s, p = build("unit", False, n=n, phi=phi, shift=shift)
205 steps = steady(s, lambda: float(np.mean(s.get_u())))
206 rea = np.asarray(s.hydro_force_torque_reaction())
207 tra = np.asarray(s.diagnostics.hydro_force_torque_traction())
208 r = dict(rea=float(np.linalg.norm(rea[1, 0]) / (np.linalg.norm(rea[0, 0]) * p["R"])),
209 tra=float(np.linalg.norm(tra[1, 0]) / (np.linalg.norm(tra[0, 0]) * p["R"])))
210 print(" unit N %d phi %.3f R/h %.2f centre + %s h, steps %d: |T| / (|F| R) reaction %.2e "
211 "traction %.2e" % (n, phi, p["R"] / p["h"], shift, steps, r["rea"], r["tra"]))
212 return r
213
214
215def agree(rows, key):
216 v = np.array([rows[n][key] for n in SYSTEMS])
217 return float(np.max(np.abs(v - v[0])) / abs(v[0]))
218
219
220print("translating sphere, phi %.3f, N %d, D/h %.1f" % (PHI, N,
221 2 * (3 * PHI / (4 * math.pi)) ** (1 / 3) * N))
222tr = {n: translating(n) for n in SYSTEMS}
223for n, r in tr.items():
224 check(abs(r["K"] / K_ZH - 1.0) < 0.015, "%s: K %.5f within 1.5 %% of Zick-Homsy %.3f" %
225 (n, r["K"], K_ZH))
226 check(abs(r["rea_over_fV"] - 1.0) < 0.01, "%s: reaction = f V_fluid (%.5f)" %
227 (n, r["rea_over_fV"]))
228 check(0.60 <= r["tra_over_rea"] <= 0.80, "%s: traction / reaction %.5f in [0.60, 0.80]" %
229 (n, r["tra_over_rea"]))
230 check(r["old_shape"] and r["old_vs_tra"] < TOL_ATOMIC,
231 "%s: deprecated hydro_force_torque() == diagnostics.hydro_force_torque_traction() "
232 "(max rel diff %.1e)" % (n, r["old_vs_tra"]))
233 check(r["old_warned"], "%s: hydro_force_torque() raises one DeprecationWarning pointing at "
234 "hydro_force_torque_reaction()" % n)
235for key in ("K", "rea_over_fV", "tra_over_rea", "tra_p_over_rea", "tra_v_over_rea"):
236 d = agree(tr, key)
237 check(d < TOL_X, "%s identical across unit systems (max rel diff %.2e)" % (key, d))
238
239print("spinning sphere, Omega dt %.3f, 30 steps" % OMEGA_DT)
240sp = {n: spinning(n) for n in SYSTEMS}
241check(abs(sp["cell"]["T_rea"]) > 1.0 and abs(sp["cell"]["T_tra"]) > 1.0,
242 "the torques are not trivially zero")
243for key in ("T_rea", "T_tra"):
244 d = agree(sp, key)
245 check(d < TOL_X, "%s identical across unit systems (max rel diff %.2e)" % (key, d))
246
247print("torque accuracy (the public getter hydro_force_torque_reaction vs the traction diagnostic)")
248for n, r in tr.items():
249 check(r["T_rea_rel"] < 1e-10, "%s: translating sphere on the vertex, reaction torque zero "
250 "(|T| / (|F| R) = %.1e)" % (n, r["T_rea_rel"]))
251ss = spinning_steady(32, 0.027)
252# Measured 2026-09-30 (flow force-api): reaction 1.0275 / 1.0252 / 1.0193 at R/h 6 / 9 / 12 and
253# phi 0.027 (1.0270 / 1.0248 at phi 0.008); traction 0.593 / 0.653 / 0.723.
254check(1.0 < ss["T_rea"] < 1.04, "steady spin: reaction torque %.4f of 8 pi mu a^3 Omega / (1 - phi) "
255 "in (1.00, 1.04)" % ss["T_rea"])
256check(0.50 < ss["T_tra"] < 0.70, "steady spin: traction torque %.4f in (0.50, 0.70) -- the "
257 "documented under-read" % ss["T_tra"])
258check(ss["off_axis"] < 1e-10, "steady spin: reaction torque about z only (%.1e)" % ss["off_axis"])
259to = translating_offset(32, 0.027, (0.31, 0.17, 0.43))
260# Measured: reaction 1.1e-3, traction 4.4e-3 at this case; 4.9e-5 against 2.8e-3 at R/h 18.
261check(to["rea"] < 3e-3, "off-vertex translating sphere: spurious reaction torque %.1e < 3e-3"
262 % to["rea"])
263check(to["rea"] < to["tra"], "off-vertex translating sphere: reaction torque error below the "
264 "traction's (%.1e vs %.1e)" % (to["rea"], to["tra"]))
265
266if failures:
267 print("FAIL: %d check(s)" % len(failures))
268 sys.exit(1)
269print("PASS")