flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
wall_transform_gate.py
Go to the documentation of this file.
1"""R1 gate: set_wall_transform -- rigid placement of an ANALYTIC dem wall.
2
3Two claims, both at OMP_NUM_THREADS=1 (dem determinism, ยง6.0b):
4
5 A. PLACEMENT IS EXACT. A wall authored in its home frame and then placed by (q, t) must give
6 BITWISE-identical wall SDF, on a probe battery, to the SAME tree authored with (q, t) already
7 baked into its root transform. That is a real claim, not a tautology: setWallTransform
8 composes the world placement onto the AUTHORED root transform via composeTransform, and for
9 an identity authored root that composition must reduce to the placement itself, bit for bit
10 (mulQuat with the identity quaternion and rotate() of the zero vector are exact). If it did
11 not, a stirrer would drift a little every revolution.
12
13 Also checked: the placement is ABSOLUTE, not incremental -- calling it 100 times with the
14 same (q, t) leaves the field bitwise unchanged, and calling it with theta then re-calling
15 with 0 restores the home field bitwise. A compounding implementation fails both.
16
17 B. THE DRUM IS UNDISTURBED. The axisymmetric case is the control: a barrel is a body of
18 revolution, so rotating its GEOMETRY about its own axis is analytically the identity. Driving
19 set_wall_transform every step alongside the set_wall_velocity path that already shipped must
20 therefore leave a settled bed's trajectory alone. It is NOT bitwise -- the composed transform
21 re-rounds in float32, so the wall SDF moves by ~1e-7 -- so the claim is quantitative: the
22 field difference is at the float32 evaluation floor, and the 400-step trajectory difference
23 stays far below a grain radius. Both numbers are reported, neither is asserted a priori.
24"""
25import os
26import numpy as np
27
28assert os.environ.get("OMP_NUM_THREADS") == "1", "run with OMP_NUM_THREADS=1 (dem determinism)"
29
30from peclet import geom
31from peclet import dem as pdem
32
33RB, HB = 6.0, 3.0 # drum barrel radius / half-length
34SHAFT_R, BLADE = 0.5, 2.4 # stirrer shaft radius, blade half-length
35
36
37def stirrer_tree(b, theta=0.0):
38 """Shaft capsule + two pitched blade boxes, optionally AUTHORED already rotated by theta."""
39 q = (0.0, 0.0, float(np.sin(0.5 * theta)), float(np.cos(0.5 * theta)))
40 shaft = b.add_leaf("capsule", [SHAFT_R, 2.0], rotation=[1.0, 0.0, 0.0, 0.0])
41 bl1 = b.add_leaf("box", [BLADE, 0.15, 0.9], translation=[BLADE, 0.0, 0.0])
42 bl2 = b.add_leaf("box", [BLADE, 0.15, 0.9], translation=[-BLADE, 0.0, 0.0])
43 u = b.add_union(b.add_union(shaft, bl1), bl2)
44 return b.add_reframed(u, translation=[0.0, 0.0, 0.0], rotation=list(q)) if theta else u
45
46
47def build_wall(sim, theta_authored=0.0):
48 b = geom.SceneBuilder()
49 root = stirrer_tree(b, theta_authored)
50 ni, nr, _, _ = b.encode()
51 return sim.add_analytic_wall(np.asarray(ni, dtype=np.int32),
52 np.asarray(nr, dtype=np.float32),
53 root, False, 0.3, 0.4)
54
55
56def probe_points(m=4000, seed=3):
57 rng = np.random.default_rng(seed)
58 return (rng.uniform(-4.0, 4.0, size=(m, 3))).astype(np.float32)
59
60
61def gate_a():
62 print("A. placed-vs-authored wall SDF, bitwise")
63 pts = probe_points()
64 theta = 0.7391
65 q = (0.0, 0.0, float(np.sin(0.5 * theta)), float(np.cos(0.5 * theta)))
66 t = (0.31, -0.17, 0.05)
67
68 s1 = pdem.Simulation(64)
69 w1 = build_wall(s1, 0.0)
70 home = np.asarray(s1.wall_sdf_at(w1, pts), dtype=np.float32)
71 s1.set_wall_transform(w1, t, q)
72 placed = np.asarray(s1.wall_sdf_at(w1, pts), dtype=np.float32)
73
74 s2 = pdem.Simulation(64)
75 w2 = build_wall(s2, theta)
76 # the authored-at-theta tree, translated by the same t
77 s2.set_wall_transform(w2, t, (0.0, 0.0, 0.0, 1.0))
78 authored = np.asarray(s2.wall_sdf_at(w2, pts), dtype=np.float32)
79
80 bitwise = np.array_equal(placed.view(np.int32), authored.view(np.int32))
81 moved = float(np.abs(placed - home).max())
82 print(" probes %d placed vs authored: bitwise-equal %s max|diff| %.3e"
83 % (len(pts), bitwise, float(np.abs(placed - authored).max())))
84 print(" (the placement is not a no-op: max|placed - home| = %.4f)" % moved)
85
86 for _ in range(100):
87 s1.set_wall_transform(w1, t, q)
88 again = np.asarray(s1.wall_sdf_at(w1, pts), dtype=np.float32)
89 absolute = np.array_equal(again.view(np.int32), placed.view(np.int32))
90 s1.set_wall_transform(w1, (0.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0))
91 back = np.asarray(s1.wall_sdf_at(w1, pts), dtype=np.float32)
92 restores = np.array_equal(back.view(np.int32), home.view(np.int32))
93 print(" absolute (100 identical calls bitwise stable): %s returns home bitwise: %s"
94 % (absolute, restores))
95 ok = bitwise and absolute and restores and moved > 0.1
96 print(" GATE A %s" % ("PASS" if ok else "FAIL"))
97 return ok
98
99
100AXIS_Q = [float(np.sin(np.pi / 4)), 0.0, 0.0, float(np.cos(np.pi / 4))] # capsule y axis -> z
101
102
104 """A barrel of revolution about world z (core's capsule is a y-axis primitive)."""
105 b = geom.SceneBuilder()
106 barrel = b.add_leaf("capsule", [RB, HB], rotation=AXIS_Q)
107 ni, nr, _, _ = b.encode()
108 return ni, nr, barrel
109
110
111def drum(rotate_geometry, nsteps=400, omega=0.8, phase=0.0):
112 """Settled bed in an axisymmetric barrel; optionally rotate the barrel GEOMETRY each step."""
113 ni, nr, barrel = barrel_scene()
114 s = pdem.Simulation(4096)
115 s.set_gravity(0.0, -9.81, 0.0)
116 s.set_sphere_shape(1.0)
117 s.set_global_scale(0.4)
118 w = s.add_analytic_wall(np.asarray(ni, dtype=np.int32), np.asarray(nr, dtype=np.float32),
119 barrel, True, 0.2, 1.0)
120 rng = np.random.default_rng(11)
121 pos, r = [], 0.4
122 for _ in range(400):
123 p = rng.uniform(-1, 1, 3) * np.array([RB - 1.2, RB - 1.2, HB - 0.8])
124 if p[0] ** 2 + p[1] ** 2 < (RB - 1.2) ** 2:
125 pos.append(p)
126 pos = np.asarray(pos[:120], dtype=np.float32)
127 s.set_positions(pos)
128 s.set_dt(2e-3)
129 for _ in range(600): # settle with a static barrel, identical in both runs
130 s.step(2e-3)
131 theta = 0.0
132 for _ in range(nsteps):
133 theta += omega * 2e-3
134 s.set_wall_velocity(w, (0.0, 0.0, 0.0), (0.0, 0.0, omega), (0.0, 0.0, 0.0))
135 if rotate_geometry:
136 a = theta + phase
137 s.set_wall_transform(w, (0.0, 0.0, 0.0),
138 (0.0, 0.0, float(np.sin(0.5 * a)), float(np.cos(0.5 * a))))
139 s.step(2e-3)
140 return np.asarray(s.get_positions(), dtype=np.float32)
141
142
143def gate_b():
144 print("B. axisymmetric control: rotating the drum GEOMETRY must not change the trajectory")
145 # (i) the field difference under a self-axis rotation -- pure float32 re-rounding
146 ni, nr, barrel = barrel_scene()
147 s0 = pdem.Simulation(64)
148 w0 = s0.add_analytic_wall(np.asarray(ni, dtype=np.int32), np.asarray(nr, dtype=np.float32),
149 barrel, True, 0.2, 1.0)
150 pts = (np.random.default_rng(5).uniform(-5.0, 5.0, size=(4000, 3))).astype(np.float32)
151 f0 = np.asarray(s0.wall_sdf_at(w0, pts), dtype=np.float32)
152 th = 0.64
153 s0.set_wall_transform(w0, (0.0, 0.0, 0.0),
154 (0.0, 0.0, float(np.sin(0.5 * th)), float(np.cos(0.5 * th))))
155 f1 = np.asarray(s0.wall_sdf_at(w0, pts), dtype=np.float32)
156 dsdf = float(np.abs(f1 - f0).max())
157 print(" (i) self-axis rotation of the barrel: max|dSDF| = %.3e over %d probes (float32 "
158 "re-rounding of the composed transform; the analytic value is 0)" % (dsdf, len(pts)))
159 # (ii) the trajectory. A cascading bed is chaotic, so a 2.4e-6 field perturbation grows: the
160 # question is whether the rotated run differs from the static one by MORE than two
161 # analytically-identical rotated runs differ from each other. Two rotation phases are the
162 # control -- both are the identity on a body of revolution, exactly like the static case,
163 # and they bracket the rounding noise.
164 rad = 0.4
165 a = drum(False)
166 c = drum(True, phase=0.0)
167 e = drum(True, phase=0.37)
168 def rep(lbl, u, v):
169 d = np.abs(u - v)
170 print(" (ii) %-28s max|dx| %.3e (%.2e grain radii) rms %.3e"
171 % (lbl, float(d.max()), float(d.max()) / rad, float(np.sqrt((d ** 2).mean()))))
172 return float(d.max()) / rad
173 m_sr = rep("static vs rotated", a, c)
174 m_rr = rep("rotated vs rotated (control)", c, e)
175 m_sr2 = rep("static vs rotated(phase)", a, e)
176 worst = max(m_sr, m_rr, m_sr2)
177 # The per-grain spread is chaotic; the BULK state is not. Compare the observables anyone
178 # would actually quote off a drum run.
179 def bulk(u):
180 return np.array([u[:, 0].mean(), u[:, 1].mean(), u[:, 2].mean()])
181 b_a, b_c, b_e = bulk(a), bulk(c), bulk(e)
182 dcom = float(np.abs(b_a - b_c).max()) / rad
183 print(" (ii) bed centre of mass: static %s rotated %s -> |dCOM| = %.2e grain radii"
184 % (np.round(b_a, 5), np.round(b_c, 5), dcom))
185 ok = (dsdf < 1e-5 and worst < 0.5 and m_sr <= 3.0 * max(m_rr, 1e-12) and dcom < 0.02)
186 print(" GATE B %s (field difference at the float32 floor; the per-grain spread is bounded "
187 "by the ROTATED-vs-ROTATED control at %.2f grain radii -- two runs that both move the "
188 "geometry differ by MORE than static-vs-rotated does, so the spread is float32 rounding "
189 "amplified by a chaotic cascade, not an effect of moving the geometry; the bulk state "
190 "agrees to %.1e grain radii)" % ("PASS" if ok else "FAIL", m_rr, dcom))
191 return ok
192
193
194if __name__ == "__main__":
195 a, b = gate_a(), gate_b()
196 print("R1 GATE %s" % ("PASS" if (a and b) else "FAIL"))
probe_points(m=4000, seed=3)
drum(rotate_geometry, nsteps=400, omega=0.8, phase=0.0)
build_wall(sim, theta_authored=0.0)
static double mean(const std::vector< double > &a)