flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
pore_doublet.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""WO-V7 case 1 — the PORE DOUBLET: which branch fills first, and when.
3
4Two straight channels of width `w` and `2w` leave a common inlet plenum and rejoin in a common
5outlet plenum. Liquid is injected at the inlet face at a fixed uniform velocity `U`; the doublet
6is gas-filled at t = 0. The question is the classical one (Chatzis & Dullien, *J. Can. Pet.
7Tech.* 22:97, 1983; Moore & Slobod 1956): **which branch does the liquid fill first?**
8
9 capillary-dominated (Ca -> 0), WETTING invasion (theta < 90): the NARROW branch, because the
10 driving capillary pressure 2 sigma cos(theta)/w is larger there;
11 capillary-dominated, NON-WETTING invasion (theta > 90): the WIDE branch, because the
12 *resisting* entry pressure |2 sigma cos(theta)|/w is smaller there;
13 viscous-dominated (Ca -> large): the WIDE branch in BOTH cases, because a slit's hydraulic
14 conductance goes as w^3.
15
16So the wetting sweep is the discriminating one: it must INVERT between low and high Ca, while the
17non-wetting sweep must NOT (both limits pick the wide branch).
18
19CONVENTIONS AND CAVEATS, stated on every run (WO-V7 preamble):
20 * the contact angle is the STATIC V5b fill. The V6 dynamic angle is qualitative until V6b adds
21 the velocity-side Navier slip, so the capillary numbers below are **apparent**: they are
22 mu_l * U_inlet / sigma with U_inlet the prescribed superficial inlet velocity, not a measured
23 contact-line speed.
24 * density ratio 100. Higher is not rated: V2b's uniform-velocity identity is floored at 1.2e-7
25 by the solver's FLOAT momentum-operator storage, and WO-R's G3 gas-over-pool caveat (a pool at
26 ratio 1000 picks up 3e-2 of the inlet speed) is unresolved.
27 * no periodic net body force anywhere (VOF_PLAN 13.2 item 6): the drive is an inflow/outflow pair.
28 * the solid is kept clear of the inflow and outflow planes (flow's open-BC/cut-cell defect,
29 `doc/cutcell_openbc_convergence.md`).
30 * a run whose pressure solve touches its cap is INVALID and is reported as such (rule 3b).
31
32Usage:
33 PYTHONPATH=$PWD/build_cuda python tests/study/pore_scale/pore_doublet.py [--quick]
34 [--theta 45,135] [--ca 1e-4,1e-3,1e-2]
35"""
36import argparse
37import json
38import math
39import os
40import sys
41import time
42
43import numpy as np
44
45import peclet.flow as pf
46
47# ------------------------------------------------------------------ scene (all lengths in cells)
48NX, NY = 88, 4
49PLEN_IN, BRANCH, PLEN_OUT = 20, 48, 20 # x layout: inlet plenum | branches | outlet plenum
50W_NARROW, W_WIDE = 16, 32 # the 1:2 width pair
51Z_WALL_BOT, Z_SEPT, Z_WALL_TOP = 8, 8, 16 # bottom wall / septum / top wall thickness
52# WHERE the SDF wall sits inside its cell is load-bearing, and it is not a nicety (WO-S finding 5,
53# reproduced here at 30x the strength). With the walls on INTEGER coordinates -- i.e. exactly on a
54# cell face, so no wall cell is cut at all -- the channel-mouth corner reads max|u| = 1.12e+02 on
55# the FIRST step against a physical 0.42, and the run then diverges geometrically (max|u| 1.5e+08
56# by step 300 while the dt limiter chases it down to 1e-9). Shifting every wall by a QUARTER cell
57# gives 3.56 on the first step, DECAYING to 1.2 -- same scene, same everything else. See the
58# WO-V7 findings table.
59WALL_SHIFT = 0.25
60NZ = Z_WALL_BOT + W_WIDE + Z_SEPT + W_NARROW + Z_WALL_TOP # 8+32+8+16+16 = 80
61X1, X2 = PLEN_IN, PLEN_IN + BRANCH
62ZW_LO, ZW_HI = Z_WALL_BOT, Z_WALL_BOT + W_WIDE # wide branch [8, 40)
63ZN_LO, ZN_HI = ZW_HI + Z_SEPT, ZW_HI + Z_SEPT + W_NARROW # narrow [48, 64)
64
65# ------------------------------------------------------------------------------------ properties
66SIGMA = 100.0
67RHO_L, RHO_G = 100.0, 1.0 # density ratio 100 (see the docstring)
68MU_L, MU_G = 4.0, 0.04 # viscosity ratio 100
69CAP_CFL = 0.5 # safety factor on the Brackbill capillary limit
70PRESS_CAP = 400
71NPY_DIR = ""
72PRESS_RTOL = 1e-8 # measured: 1e-11 costs 41 -> 60 FCG iterations for nothing
73MG_LEVELS = 5 # measured sweet spot on this grid (3: 81 iters, 8: 41 at +14 %)
74VEL_SWEEPS = 20
75
76
77def box2d(px, pz, xlo, xhi, zlo, zhi):
78 """Exact signed distance (outside) to an axis-aligned box in the (x, z) plane."""
79 cx, cz = 0.5 * (xlo + xhi), 0.5 * (zlo + zhi)
80 ex, ez = 0.5 * (xhi - xlo), 0.5 * (zhi - zlo)
81 qx, qz = np.abs(px - cx) - ex, np.abs(pz - cz) - ez
82 out = np.sqrt(np.maximum(qx, 0.0) ** 2 + np.maximum(qz, 0.0) ** 2)
83 return out + np.minimum(np.maximum(qx, qz), 0.0)
84
85
87 """> 0 in the fluid (flow's convention). Three solid boxes, all clear of x = 0 and x = NX."""
88 x = (np.arange(NX) + 0.5)[:, None]
89 z = (np.arange(NZ) + 0.5)[None, :]
90 BIG = 100.0
91 q = WALL_SHIFT
92 d = np.minimum.reduce([
93 box2d(x, z, X1 + q, X2 + q, -BIG, ZW_LO + q), # floor of the wide branch
94 box2d(x, z, X1 + q, X2 + q, ZW_HI + q, ZN_LO + q), # the septum between the branches
95 box2d(x, z, X1 + q, X2 + q, ZN_HI + q, NZ + BIG), # roof of the narrow branch
96 ])
97 return np.asfortranarray(np.broadcast_to(d[:, None, :], (NX, NY, NZ)).astype(float).copy())
98
99
100class Health:
101 """Rule 3b: a capped pressure solve makes a run INVALID."""
102
103 def __init__(self, cap=PRESS_CAP):
104 self.cap, self.iters, self.div, self.capped = cap, 0, 0.0, 0
105
106 def sample(self, s):
107 it = s.diagnostics.last_pressure_iterations()
108 self.iters = max(self.iters, it)
109 self.capped += int(it >= self.cap)
110 self.div = max(self.div, s.max_open_divergence_projected())
111
112 @property
113 def valid(self):
114 return self.capped == 0
115
116 def __str__(self):
117 return (f"pressure {self.iters}/{self.cap} ({self.capped} capped) "
118 f"{'OK' if self.valid else '*** CAPPED -> RUN INVALID ***'}, "
119 f"max|div(open u)|_projected {self.div:.3e}")
120
121
122def build(theta_deg, U, momentum=True, slip=0.0):
123 s = pf.Solver(NX, NY, NZ)
124 s.set_rho(RHO_L)
125 s.set_mu(MU_L)
126 s.set_dt(0.1)
127 s.set_domain_bc("-x", "inflow", (U, 0.0, 0.0)) # -x inflow, uniform U
128 s.set_domain_bc("+x", "outflow", (0.0, 0.0, 0.0)) # +x outflow
129 s.set_domain_bc("-z", "wall", (0.0, 0.0, 0.0)) # -z wall (buried in the solid)
130 s.set_domain_bc("+z", "wall", (0.0, 0.0, 0.0)) # +z wall (buried in the solid)
131 # y stays periodic: the case is quasi-2D.
132 s.diagnostics.set_velocity_solver_params(VEL_SWEEPS)
133 s.set_pressure_multigrid(True, levels=MG_LEVELS)
134 s.set_pressure_solver_params(80)
135 s.set_solid(doublet_sdf(), cutcell_pressure=True)
136 if slip > 0.0:
137 # WO-V6b gate 5: the VELOCITY half of the dynamic contact line. The tangential wall datum
138 # of the cut-cell closure becomes the Navier condition u_t(wall) = lambda du_t/dn, so the
139 # contact line can run ahead ALONG the wall instead of only at the scheme's own numerical
140 # slip. WO-V7's verdict on this case was that the imbibition ordering flips where the
141 # imposed velocity crosses that numerical slip velocity, not at a physical crossover.
142 s.set_wall_slip_length(slip)
143 s.enable_vof()
144 # t = 0 is the front AT the branch entrances: the inlet plenum is already liquid-filled. This
145 # is not a shortcut for its own sake — filling the plenum from the inlet at Ca = 1e-4 costs as
146 # many steps again as the branches themselves, and the plenum carries no physics.
147 c0 = np.zeros((NX, NY, NZ), order="F")
148 c0[:X1, :, :] = 1.0
149 s.set_vof(c0)
150 s.set_property_model("rho", "linear", "C", [RHO_G, RHO_L - RHO_G])
151 s.set_property_model("mu", "linear", "C", [MU_G, MU_L - MU_G])
152 if momentum:
153 s.enable_vof_momentum(RHO_G, RHO_L)
154 s.set_surface_tension(SIGMA)
155 s.set_capillary_cfl(CAP_CFL)
156 s.set_contact_angle(theta_deg)
157 # THE DRIVER IS SELECTED LAST — `set_property_model("rho", ...)` fires set_density_mode, which
158 # reselects Chebyshev and silently discards an earlier choice (WO-H; vof_open_boundaries.py).
159 s.set_pressure_fcg(True, PRESS_CAP, PRESS_RTOL)
160 s.set_vof_inflow("-x", 1.0) # liquid enters
161 s.set_vof_backflow("+x", 0.0) # gas backflow at the outlet (inletOutlet)
162 return s
163
164
165def _run_end(mask):
166 """Length of the run of True that starts at index 0 (0 if the first entry is False)."""
167 idx = np.nonzero(~mask)[0]
168 return float(idx[0]) if idx.size else float(mask.size)
169
170
171def fronts(C, eps):
172 """Two front positions per branch, in cells from the branch entrance X1, plus the branch
173 liquid saturation.
174
175 `tip` the leading edge: the last x, contiguous from the entrance, at which ANY cell of the
176 cross-section is more than half liquid.
177 `bar` the cross-section-AVERAGED front: the last x, contiguous from the entrance, at which the
178 openness-weighted mean colour of the cross-section exceeds a half.
179
180 Both are reported because they differ by the length of the meniscus, and that length scales
181 with the CHANNEL WIDTH: at theta = 135 the meniscus in the 32-cell branch is about twice as
182 long as in the 16-cell one, so `bar` alone carries a systematic bias of order w/2 in favour of
183 the NARROW branch. Breakthrough is declared on `tip`, which has no such bias.
184 """
185 out = {}
186 for name, (zlo, zhi) in (("narrow", (ZN_LO, ZN_HI)), ("wide", (ZW_LO, ZW_HI))):
187 c = C[X1:X2, :, zlo:zhi]
188 e = eps[X1:X2, :, zlo:zhi]
189 wet = ((c > 0.5) & (e > 0.0)).any(axis=(1, 2))
190 col = (c * e).sum(axis=(1, 2)) / np.maximum(e.sum(axis=(1, 2)), 1e-30)
191 tip = _run_end(wet)
192 bar = _run_end(col > 0.5)
193 sat = float((c * e).sum() / max(e.sum(), 1e-30))
194 out[name] = (tip, bar, sat)
195 return out
196
197
198def run(theta_deg, ca, steps, sample_every, label, momentum=True, budget=None, slip=0.0):
199 U = ca * SIGMA / MU_L
200 Re = RHO_L * U * W_NARROW / MU_L
201 print("\n" + "=" * 100)
202 print(f"PORE DOUBLET theta = {theta_deg:g} deg Ca(apparent) = {ca:g} [{label}]")
203 print("=" * 100)
204 print(f" grid {NX}x{NY}x{NZ}; branches x in [{X1}, {X2}), narrow w = {W_NARROW} "
205 f"z in [{ZN_LO}, {ZN_HI}), wide 2w = {W_WIDE} z in [{ZW_LO}, {ZW_HI})")
206 print(f" sigma {SIGMA:g}, rho {RHO_L:g}/{RHO_G:g} (ratio {RHO_L/RHO_G:g}), "
207 f"mu {MU_L:g}/{MU_G:g} (ratio {MU_L/MU_G:g}), momentum consistency "
208 f"{'ON' if momentum else 'off'}")
209 print(f" inlet U = Ca*sigma/mu_l = {U:.6g} cells/s -> channel Reynolds "
210 f"rho_l U w / mu_l = {Re:.4g}")
211 ct = math.cos(math.radians(theta_deg))
212 pc_n, pc_w = 2 * SIGMA * ct / W_NARROW, 2 * SIGMA * ct / W_WIDE
213 # viscous scale: the pressure drop of the wide branch at the mean branch velocity
214 ubr = U * NZ / (W_NARROW + W_WIDE)
215 dpv = 12.0 * MU_L * ubr * BRANCH / W_WIDE ** 2
216 print(f" capillary pressure 2 sigma cos(theta)/w: narrow {pc_n:+.4g}, wide {pc_w:+.4g}, "
217 f"difference {pc_n - pc_w:+.4g}")
218 print(f" viscous scale 12 mu_l u_branch L / (2w)^2 = {dpv:.4g} -> "
219 f"|dPc| / dPvisc = {abs(pc_n - pc_w)/dpv:.4g} "
220 f"({'CAPILLARY' if abs(pc_n-pc_w) > dpv else 'VISCOUS'}-dominated by this estimate)")
221
222 print(f" wall Navier slip lambda = {slip:g} cells "
223 f"({'ON (WO-V6b)' if slip > 0 else 'OFF — no-slip wall, WO-V7 configuration'})")
224 s = build(theta_deg, U, momentum, slip)
225 if slip > 0.0:
226 print(f" wall_slip_length() = {s.wall_slip_length():g}; one-cell-gap axes kept no-slip "
227 f"{s.diagnostics.wall_slip_sandwich_cells()}")
228 L = s.vof_step_limits()
229 print(f" dt census at t = 0: capillary_dt {L['capillary_dt']:.5g} (x{CAP_CFL} = "
230 f"{CAP_CFL*L['capillary_dt']:.5g}), WY cfl_dt {L['cfl_dt']:.5g}, binding "
231 f"{L['binding']:.5g} -> {'CAPILLARY' if L['capillary_binds'] else 'CFL'} binds")
232 print(" (capillary_dt is a function of the CURRENT density field: with the domain still all")
233 print(" gas it reads the gas value; it relaxes once liquid is present. dt is therefore")
234 print(" re-picked from the solver's own limiter EVERY step.)")
235 dt = CAP_CFL * L["capillary_dt"]
236 s.set_dt(dt)
237
238 h = Health()
239 eps = np.asarray(s.diagnostics.vof_geometry(0))
240 hist = []
241 bt = {"narrow": None, "wide": None}
242 ncap_cfl = 0
243 t0 = time.time()
244 t = 0.0
245 nsteps = 0
246 umax_gas = 0.0
247 for i in range(steps):
248 # keep dt under BOTH limits, re-picked from the solver's own limiter every step
249 L = s.vof_step_limits()
250 dtc = CAP_CFL * L["capillary_dt"]
251 dt = min(dtc, 0.8 * L["cfl_dt"]) if L["cfl_dt"] > 0 else dtc
252 ncap_cfl += int(dt < dtc * 0.999)
253 s.set_dt(dt)
254 s.step()
255 t += dt
256 nsteps += 1
257 h.sample(s)
258 if (i + 1) % sample_every == 0 or i == steps - 1:
259 C = np.asarray(s.get_vof())
260 f = fronts(C, eps)
261 hist.append((t, f["narrow"][0], f["narrow"][1], f["narrow"][2],
262 f["wide"][0], f["wide"][1], f["wide"][2]))
263 if len(hist) % 10 == 1:
264 print(f" step {i+1:7d} t {t:10.4g} tip n/w {f['narrow'][0]:5.0f}/"
265 f"{f['wide'][0]:5.0f} bar n/w {f['narrow'][1]:5.0f}/{f['wide'][1]:5.0f} "
266 f"S n/w {f['narrow'][2]:.3f}/{f['wide'][2]:.3f} "
267 f"({time.time()-t0:.0f} s, {1000*(time.time()-t0)/(i+1):.0f} ms/step)",
268 flush=True)
269 for k in ("narrow", "wide"):
270 if bt[k] is None and f[k][0] >= BRANCH - 1:
271 bt[k] = (t, i + 1)
272 # spurious-current probe: max|u| in the pure gas AHEAD of both fronts
273 xg = int(min(max(f["narrow"][0], 0.0), max(f["wide"][0], 0.0))) + X1 + 6
274 if xg < X2:
275 g = max(np.abs(np.asarray(fn())[xg:X2, :, :]).max()
276 for fn in (s.get_u, s.get_v, s.get_w))
277 umax_gas = max(umax_gas, float(g))
278 if bt["narrow"] and bt["wide"]:
279 break
280 if budget and time.time() - t0 > budget:
281 print(f" *** wall-clock budget {budget:.0f} s reached at step {i+1} "
282 f"(t = {t:.5g}) — the run is reported as a PARTIAL fill ***")
283 break
284 wall = time.time() - t0
285 C = np.asarray(s.get_vof())
286 f = fronts(C, eps)
287 d = s.diagnostics.vof_diagnostics()
288 print(f"\n ran {nsteps} steps to t = {t:.4g} s in {wall:.0f} s "
289 f"({1000*wall/max(nsteps,1):.2f} ms/step, SHARED GPU)")
290 print(f" dt: final {dt:.5g}; the WY CFL limit displaced the capillary one on "
291 f"{ncap_cfl} of the {nsteps} steps ({100.0*ncap_cfl/max(nsteps,1):.1f} % -> the "
292 f"capillary limit binds on the rest)")
293 print(f" final fronts (cells into the branch, of {BRANCH}): TIP narrow {f['narrow'][0]:.0f}"
294 f" / wide {f['wide'][0]:.0f}; MEAN narrow {f['narrow'][1]:.0f} / wide "
295 f"{f['wide'][1]:.0f}")
296 print(f" final branch liquid saturation: narrow {f['narrow'][2]:.4f} "
297 f"wide {f['wide'][2]:.4f}")
298 for k in ("narrow", "wide"):
299 print(f" breakthrough {k:6s}: " + (f"t = {bt[k][0]:.5g} s at step {bt[k][1]}"
300 if bt[k] else "NOT reached in this run"))
301 winner = None
302 if bt["narrow"] and bt["wide"]:
303 winner = "narrow" if bt["narrow"][0] < bt["wide"][0] else "wide"
304 elif bt["narrow"]:
305 winner = "narrow"
306 elif bt["wide"]:
307 winner = "wide"
308 elif f["narrow"][0] != f["wide"][0]:
309 winner = "narrow" if f["narrow"][0] > f["wide"][0] else "wide"
310 elif f["narrow"][2] != f["wide"][2]:
311 winner = "narrow" if f["narrow"][2] > f["wide"][2] else "wide"
312 print(f" FILLS FIRST: {winner if winner else 'undecided (neither front advanced)'}")
313 print(f" max|u| in the gas ahead of both fronts {umax_gas:.4e} = {umax_gas/U:.3f} U "
314 f"-> Ca = mu_l max|u|/sigma = {MU_L*umax_gas/SIGMA:.3e} against the imposed {ca:g}.")
315 print(" This is an UPPER BOUND on the spurious current, not a measurement of it: the gas")
316 print(" ahead of the front is genuinely being displaced, so it carries the real flow too.")
317 print(f" colour: sum {d['sum']:.6g}, solid_sum {d['solid_sum']:.3e}, "
318 f"min/max over uncut fluid {d['min_fluid']:.3e} / {d['max_fluid']:.6f}, "
319 f"clipped {d['clipped_volume']:.3e}")
320 print(f" {h}")
321 if NPY_DIR:
322 os.makedirs(NPY_DIR, exist_ok=True)
323 np.save(os.path.join(NPY_DIR,
324 f"doublet_C_theta{int(theta_deg)}_ca{ca:g}.npy"), C[:, NY // 2, :])
325 np.save(os.path.join(NPY_DIR, "doublet_eps.npy"), eps[:, NY // 2, :])
326 return dict(theta=theta_deg, ca=ca, U=U, Re=Re, steps=nsteps, t=t, wall=wall,
327 ms_per_step=1000 * wall / max(nsteps, 1), dt=dt,
328 tip_narrow=f["narrow"][0], tip_wide=f["wide"][0],
329 bar_narrow=f["narrow"][1], bar_wide=f["wide"][1],
330 sat_narrow=f["narrow"][2], sat_wide=f["wide"][2],
331 bt_narrow=bt["narrow"][0] if bt["narrow"] else None,
332 bt_wide=bt["wide"][0] if bt["wide"] else None,
333 winner=winner, iters=h.iters, capped=h.capped, div=h.div,
334 valid=h.valid, umax_gas=umax_gas, ca_spurious=MU_L * umax_gas / SIGMA,
335 pc_narrow=pc_n, pc_wide=pc_w, dp_visc=dpv, hist=hist)
336
337
338def main():
339 ap = argparse.ArgumentParser()
340 ap.add_argument("--quick", action="store_true")
341 ap.add_argument("--theta", default="45,135")
342 ap.add_argument("--ca", default="1e-4,1e-3,1e-2")
343 ap.add_argument("--slip", type=float, default=0.0,
344 help="WO-V6b Navier slip length in cells (0 = the WO-V7 no-slip wall)")
345 ap.add_argument("--no-momentum", action="store_true")
346 ap.add_argument("--budget", type=float, default=0.0,
347 help="wall-clock seconds per (theta, Ca); 0 = no limit")
348 ap.add_argument("--npy", default="")
349 ap.add_argument("--out", default="")
350 a = ap.parse_args()
351 global NPY_DIR
352 NPY_DIR = a.npy
353 thetas = [float(x) for x in a.theta.split(",")]
354 cas = [float(x) for x in a.ca.split(",")]
355 print("WO-V7 case 1 — the pore doublet. Static contact angle (V5b); the capillary numbers")
356 print("are APPARENT (mu_l U_inlet / sigma), because contact-line mobility is set by the")
357 print("solver's numerical slip until V6b lands (VOF_PLAN 13.2 item 5).")
358 rows = []
359 for th in thetas:
360 for ca in cas:
361 # the travel time is ~ (plenum + branch/contraction)/U; budget 1.6x of it
362 U = ca * SIGMA / MU_L
363 ttrav = BRANCH * (W_NARROW + W_WIDE) / (NZ * U)
364 dt0 = CAP_CFL * math.sqrt((RHO_L + RHO_G) / (4 * math.pi * SIGMA))
365 steps = int(1.7 * ttrav / dt0)
366 if a.quick:
367 steps = min(steps, 400)
368 rows.append(run(th, ca, steps, max(steps // 120, 5),
369 "quick" if a.quick else "full",
370 momentum=not a.no_momentum,
371 budget=a.budget or None, slip=a.slip))
372 print("\n" + "=" * 100)
373 print("SUMMARY — pore doublet")
374 print("=" * 100)
375 print(f"{'theta':>6} {'Ca':>8} {'Re':>7} {'t_bt narrow':>12} {'t_bt wide':>12} "
376 f"{'fills first':>12} {'tip_n':>6} {'tip_w':>6} {'S_n':>7} {'S_w':>7} {'iters':>6} "
377 f"{'ms/step':>8} {'valid':>6}")
378 for r in rows:
379 bn = f"{r['bt_narrow']:.4g}" if r["bt_narrow"] else "-"
380 bw = f"{r['bt_wide']:.4g}" if r["bt_wide"] else "-"
381 print(f"{r['theta']:6g} {r['ca']:8g} {r['Re']:7.3g} {bn:>12} {bw:>12} "
382 f"{str(r['winner']):>12} {r['tip_narrow']:6.0f} {r['tip_wide']:6.0f} "
383 f"{r['sat_narrow']:7.4f} {r['sat_wide']:7.4f} "
384 f"{r['iters']:6d} {r['ms_per_step']:8.2f} "
385 f"{'yes' if r['valid'] else 'NO':>6}")
386 if a.out:
387 with open(a.out, "w") as fh:
388 json.dump(rows, fh, indent=1)
389 print(f"\n wrote {a.out}")
390 return 0 if all(r["valid"] for r in rows) else 1
391
392
393if __name__ == "__main__":
394 sys.exit(main())
__init__(self, cap=PRESS_CAP)
run(theta_deg, ca, steps, sample_every, label, momentum=True, budget=None, slip=0.0)
box2d(px, pz, xlo, xhi, zlo, zhi)