flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
rotation_gate.py
Go to the documentation of this file.
1"""ROTATION GATE: the reaction TORQUE against the exact Stokes torque 8*pi*mu*a^3*Omega.
2
3Part A (statics): a sphere spinning in place. The geometry never moves (a sphere is invariant
4under its own rotation), so this isolates the torque evaluation completely. Before the v3
5transposed-stress wall term the reaction torque was a STRUCTURAL -31% (the budget measures the
6Laplacian-form wall flux; the physical traction adds mu*(grad u)^T.n = mu*(n x Omega), whose force
7integral is zero -- which is why the force never saw it -- and whose torque integral is exactly one
8third). With it, the error is the aperture first-moment discretisation and CONVERGES.
9
10Part B (dynamics): the full ResolvedCfdDem hand-off. The same sphere, given physical inertia
11I = (2/5) m R^2 and an initial spin, decays by the torque the flow hands to dem
12(apply_torque=True). Reference: the decay rate 1/tau = 8*pi*mu*R^3*lambda_rot / I with lambda_rot
13the SAME BOX's own rotational drag measured in part A -- the settling-gate calibration pattern, so
14the discretisation bias cancels between calibration and prediction.
15
16Needs flow (+ dem + coupling for part B) on PYTHONPATH.
17"""
18import os
19import sys
20import numpy as np
21from peclet import flow as sdflow
22
23RHO, MU = 1.0, 0.1
24OM0 = 1e-3
25KN_R, KI_I, KI_R = 16, 2, 17
26
27
28def make(N, R, dt, om, advect=False):
29 x0 = 0.5 * N
30 ni = np.array([1, -1, -1], dtype=np.int32)
31 nr = np.zeros(KN_R); nr[0] = R; nr[14] = 1.0; nr[15] = 1.0
32 ii = np.zeros((1, KI_I), dtype=np.int32); ii[0] = (0, -1)
33 ir = np.zeros((1, KI_R)); ir[0, 0:3] = (x0, x0, x0); ir[0, 6] = 1.0; ir[0, 7] = 1.0
34 s = sdflow.Solver(N, N, N)
35 s.set_rho(RHO); s.set_mu(MU); s.set_dt(dt); s.set_advection(advect)
36 s.set_velocity_solver_params(100); s.set_pressure_solver_params(25)
37 # The net-force-at-round-off criterion below needs the momentum solve run to the FIXED sweep
38 # count: on branches where a residual stop (1e-5) is the default, that stop leaves a 1e-6..1e-5
39 # net force on a spinning sphere (measured 2026-09-02) -- physics unchanged, gate meaning lost.
40 if hasattr(s, "set_velocity_residual_tolerance"):
41 s.set_velocity_residual_tolerance(0.0)
42 s.set_pressure_multigrid(True, levels=4)
43 s.set_scene(ni, nr, ii.ravel(), ir.ravel(), periodic=True)
44 s.set_instance_motion(0, lin_vel=[0, 0, 0], ang_vel=[0, 0, om], center=[x0, x0, x0])
45 s.set_solid_from_scene(True)
46 return s
47
48
49def static_lambda(N, R, steps=1000, dt=20.0):
50 s = make(N, R, dt, OM0)
51 for _ in range(steps):
52 s.step()
53 T = abs(np.asarray(s.hydro_force_torque_reaction())[1][0][2])
54 F = np.linalg.norm(np.asarray(s.hydro_force_torque_reaction())[0][0])
55 return T / (8 * np.pi * MU * R ** 3 * OM0), F / (MU * R ** 2 * OM0)
56
57
58def part_a():
59 print("A. static spin vs T = 8 pi mu a^3 Omega (lambda_rot = T_measured / T_exact)")
60 rungs = [(64, 9.6), (96, 9.6), (96, 14.4)]
61 if os.environ.get("ROT_FULL", "0") == "1":
62 rungs += [(128, 9.6), (128, 19.2)]
63 lam = {}
64 ok = True
65 prev = None
66 for N, R in rungs:
67 l, fnet = static_lambda(N, R)
68 lam[(N, R)] = l
69 print(" N=%3d R=%5.2f lambda_rot = %.5f (err %+5.2f%%) |F|net/(mu R^2 Om) = %.1e"
70 % (N, R, l, 100 * (l - 1), fnet))
71 ok = ok and abs(l - 1) < 0.05 and fnet < 1e-9
72 # convergence: the two box rungs at R=9.6 must move TOWARD 1
73 e64, e96 = abs(lam[(64, 9.6)] - 1), abs(lam[(96, 9.6)] - 1)
74 conv = e96 < e64
75 print(" GATE A %s [|err| < 5%% at every rung; box growth moves it toward the exact "
76 "answer (%.2f%% -> %.2f%%); the net force stays at round-off]"
77 % ("PASS" if (ok and conv) else "FAIL", 100 * e64, 100 * e96))
78 return ok and conv, lam[(64, 9.6)]
79
80
81def part_b(lam):
82 from peclet import dem as pdem
83 from peclet_coupling.resolved import ResolvedCfdDem
84 N, R, DT = 64, 9.6, 5.0
85 RHO_P = 5.0
86 m = RHO_P * 4 / 3 * np.pi * R ** 3
87 I = 0.4 * m * R ** 2
88 tau_pred = I / (8 * np.pi * MU * R ** 3 * lam)
89 nstep = int(1.2 * tau_pred / DT)
90 print("B. spin-decay through ResolvedCfdDem (apply_torque=True): I dOmega/dt = -T(Omega)")
91 print(" tau_pred = I/(8 pi mu R^3 lambda_rot) = %.1f (%d steps at dt=%.0f)"
92 % (tau_pred, nstep, DT))
93 d = pdem.Simulation(8)
94 d.set_gravity(0.0, 0.0, 0.0)
95 d.set_sphere_shape(1.0)
96 d.set_global_scale(R)
97 d.set_positions(np.array([[0.5 * N, 0.5 * N, 0.5 * N]], dtype=np.float32))
98 d.set_inv_mass(np.array([1.0 / m], dtype=np.float32))
99 d.set_inv_inertia(np.array([[1.0 / I] * 3], dtype=np.float32))
100 d.set_angular_velocities(np.array([[0.0, 0.0, OM0]], dtype=np.float32))
101 s = sdflow.Solver(N, N, N)
102 s.set_rho(RHO); s.set_mu(MU); s.set_dt(DT); s.set_advection(False)
103 s.set_velocity_solver_params(100); s.set_pressure_solver_params(25)
104 # The net-force-at-round-off criterion below needs the momentum solve run to the FIXED sweep
105 # count: on branches where a residual stop (1e-5) is the default, that stop leaves a 1e-6..1e-5
106 # net force on a spinning sphere (measured 2026-09-02) -- physics unchanged, gate meaning lost.
107 if hasattr(s, "set_velocity_residual_tolerance"):
108 s.set_velocity_residual_tolerance(0.0)
109 s.set_pressure_multigrid(True, levels=4)
110 drv = ResolvedCfdDem(s, d, radius=R, mu=MU, rho_f=RHO, fluid_dt=DT, dem_substeps=10,
111 periodic=True, rho_p=RHO_P, buoyancy=False, move=True,
112 apply_torque=True)
113 ts, oms = [], []
114 for k in range(nstep):
115 drv.step()
116 ts.append((k + 1) * DT)
117 oms.append(float(np.asarray(d.get_angular_velocities())[0, 2]))
118 ts = np.array(ts); oms = np.array(oms)
119 # fit the decay rate over the first predicted tau (before the co-rotating fluid feeds back)
120 mfit = ts <= tau_pred
121 rate = -np.polyfit(ts[mfit], np.log(oms[mfit] / OM0), 1)[0]
122 ratio = rate * tau_pred
123 print(" Omega decayed %.4f -> %.4f of Omega0 over %.0f time units"
124 % (1.0, oms[-1] / OM0, ts[-1]))
125 print(" fitted decay rate * tau_pred = %.4f (1 = the same box's own calibrated drag)"
126 % ratio)
127 ok = abs(ratio - 1) < 0.10
128 print(" GATE B %s [claim: the dem-integrated decay matches the same box's calibrated "
129 "rotational drag within 10%%; the co-rotating-fluid feedback makes the tail slower "
130 "than exponential, which is physics, so the fit window is the first tau]"
131 % ("PASS" if ok else "FAIL"))
132 return ok
133
134
135if __name__ == "__main__":
136 ok_a, lam = part_a()
137 ok_b = True
138 if os.environ.get("ROT_STATIC_ONLY", "0") != "1":
139 ok_b = part_b(lam)
140 print("ROTATION GATE %s" % ("PASS" if (ok_a and ok_b) else "FAIL"))
make(N, R, dt, om, advect=False)
static_lambda(N, R, steps=1000, dt=20.0)