Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
5145871
feat: graph-native FVM fluid node (M0-M3)
NicholasEhsanRoy May 2, 2026
b7f4713
feat: FVM validation suite (T0-T4 + perf) + IBM force-field fix
NicholasEhsanRoy May 2, 2026
2235822
feat: surface-integral IBM force extraction (Fix A1)
NicholasEhsanRoy May 2, 2026
0ba6b5e
fix(A3): T3 re-run with surface-integral force, shell=(1.5,3.5)
NicholasEhsanRoy May 2, 2026
aab55f3
perf(C): precompute mesh.V_owner / V_neighbour to skip constant-folding
NicholasEhsanRoy May 2, 2026
0957ecd
feat(B): FVMFluidNode supports surface_integral force_method + T4 res…
NicholasEhsanRoy May 2, 2026
b5efa44
perf(P1): add FFT-based Poisson + Helmholtz solvers (cuFFT path)
NicholasEhsanRoy May 2, 2026
035c44d
test(P2): unconfined sphere drag vs Schiller-Naumann + perf inline RC
NicholasEhsanRoy May 2, 2026
dd26733
fix(P3): semi-implicit Maxey-Riley integrator for IBM-coupled 6DOF
NicholasEhsanRoy May 2, 2026
71c6ada
test(P4): resolution sweep for confined Stokes drag (3 λ × 3 cpr)
NicholasEhsanRoy May 2, 2026
df5baeb
test(P4b): diagnose λ=0.1 regression — was test-config bug, not RC
NicholasEhsanRoy May 2, 2026
9445efe
diag(R4-P1,P2): root cause of confined Stokes drag errors
NicholasEhsanRoy May 2, 2026
43c09f4
diag(R4-P3): T4 NaN was JAX scan, not physics
NicholasEhsanRoy May 2, 2026
5aae3bc
diag(R4-P4): perf sweep — dense beats FFT at every size on RTX 2060
NicholasEhsanRoy May 2, 2026
631c402
feat(R5-Fix2): momentum-deficit drag method
NicholasEhsanRoy May 2, 2026
dc500c5
diag(R5-Fix3): T4 scan NaN was Re=100, not JAX
NicholasEhsanRoy May 2, 2026
9adabd7
feat(R6): Poiseuille inlet helpers + retire T4 equilibrium target
NicholasEhsanRoy May 2, 2026
a50a3a1
feat(M0): lifting/homogenisation inlet BC for DST-compatible Dirichlet
NicholasEhsanRoy May 2, 2026
2a8a155
feat(M2): GNNFluxCorrectedFVMNode architecture, sweep config
NicholasEhsanRoy May 2, 2026
b2f2ec3
feat(M1): first MIME pulsatile millibot scenario, BEM comparison
NicholasEhsanRoy May 2, 2026
449addb
fix(Fix1): make_pipe_mesh — isotropic dx=dy=dz=robot_radius/cpr
NicholasEhsanRoy May 3, 2026
54686b6
fix(Fix2): momentum_deficit_drag enforces inlet/outlet BC clearance
NicholasEhsanRoy May 3, 2026
6012337
fix(Fix3): M1 matched-reference K_inertial, 3 cycles, smooth start
NicholasEhsanRoy May 3, 2026
fdc4146
diag(T3): isotropic-mesh re-run still gives K_FVM < 0 at λ=0.1, 0.3
NicholasEhsanRoy May 3, 2026
e42d77f
fix: reconstruct full p_lift in momentum_deficit_drag for lifted flow
NicholasEhsanRoy May 3, 2026
8d0371c
test: re-run T3 and M1 at cpr=8 with p_lift correction
NicholasEhsanRoy May 3, 2026
0ad2d9e
fix(T3): surface_integral_force for Stokes drag, correct method
NicholasEhsanRoy May 3, 2026
37de5bb
test(M1): re-run at cpr=6 with analytical Womersley lift
NicholasEhsanRoy May 3, 2026
c7815b7
test(gnn-step0): autodiff check through GNN correction
NicholasEhsanRoy May 3, 2026
7aa187c
data(gnn-step1): local training data generation, 3 train + 1 val
NicholasEhsanRoy May 3, 2026
ae83aeb
train(gnn-step2): local training loop, 50 epochs on 3 configs
NicholasEhsanRoy May 3, 2026
c41c4e6
eval(gnn-step3): held-out validation, drag and velocity MSE
NicholasEhsanRoy May 3, 2026
c8dbe62
infra(gnn-step4): sweep runner with resumability, SkyPilot YAML
NicholasEhsanRoy May 3, 2026
43481d8
feat(gnn): add ∂u/∂t acceleration as edge feature, in_dim 13 → 14
NicholasEhsanRoy May 3, 2026
ef8c1aa
bench(gnn): cpr sweep, speed and accuracy with/without correction
NicholasEhsanRoy May 3, 2026
a2cdb6d
perf(gnn): amortise GNN across outer fori_loop steps via gnn_stride
NicholasEhsanRoy May 3, 2026
ed89496
perf(gnn): float16 MLP inference + finalise make_pipe_mesh isotropy
NicholasEhsanRoy May 3, 2026
066bca1
feat(sweep): enhanced data collection — waveforms, WSS, phase lag, pr…
NicholasEhsanRoy May 3, 2026
ab5da5c
feat(ar4+ik): closed-loop EE position+orientation tracking with DLS-I…
NicholasEhsanRoy May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ build/
experiments/umr_confinement/output/
xla_diagnostic.py
.mime_git_hash
data/gnn_training/
data/gnn_sweep_dryrun/
data/sweep_manifest_test/
experiments/ar4_helical_drive/recordings/
.claude/scheduled_tasks.lock
390 changes: 362 additions & 28 deletions experiments/ar4_helical_drive/control/controller.py

Large diffs are not rendered by default.

109 changes: 100 additions & 9 deletions experiments/ar4_helical_drive/physics/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
VESSEL_NAME = "1/4\""
MU_PA_S = 1e-3
DELTA_RHO_KG_M3 = 410.0
DT_PHYS = 5e-4
DT_PHYS = 5e-4 # matches legacy dejongh

# ── Lubrication correction (Goldman-Cox-Brenner) ─────────────────────
USE_LUBRICATION = True
Expand Down Expand Up @@ -54,12 +54,13 @@
# origin, well clear of any wrist-mesh collision. Computed offline
# with optax.adam against the position objective + small joint-
# regularisation for a "neutral-shaped" solution.
# 20 cm standoff (max AR4 reach above the tube). Going further is
# infeasible — IK fails beyond 20 cm given the AR4 base position
# fixed by the desk geometry. At 20 cm the orbital amplitude is
# halved vs 15 cm (~0.37 mm vs 0.80 mm) while |B| at the helix is
# still ~360 µT, well above the step-out edge for FL-9.
ARM_HOME_RAD = (-0.02763, 0.49873, -1.51875, 1.54673, 1.55147, 0.00000)
# 15 cm standoff — matches the de Jongh paper's RPM placement. At
# this distance the elliptical-field gradient at the UMR is small
# enough that the helix's tilt instability grows slowly enough for
# the AR4 to track via the closed-loop controller. Earlier attempt
# at 20 cm (max reach) had the gradient too strong for the
# controller's mechanical bandwidth to suppress.
ARM_HOME_RAD = (-0.02761, 0.23896, -0.77475, 1.55611, 1.54628, 0.00000)

# Add the RNEA gravity vector to commanded_joint_torques each step
# so a zero-torque controller (or any controller that doesn't include
Expand Down Expand Up @@ -106,7 +107,15 @@
EARTH_FIELD_WORLD_T = (0.0, 0.0, 0.0)

# ── Live-editable actuation knob (ParameterPanel) ────────────────────
FIELD_FREQUENCY_HZ = 10.0
# Drive frequency — matches the de Jongh paper's 10 Hz at the
# 15 cm standoff. With B ≈ 757 µT at the helix (Mahoney/Abbott
# Eq 1, RMS over the elliptical cycle) the FL-9 helix's step-out
# is around 21 Hz, so 10 Hz is well below step-out and the helix
# locks to the field. Going lower hurts swim speed; going higher
# crosses step-out at this geometry and causes asynchronous
# tumble. Compare to the 20 cm setup where step-out was 9.5 Hz
# and we had to drop to 3 Hz.
FIELD_FREQUENCY_HZ = 3.0

# ── Visualisation-fast vs. publication-fidelity ──────────────────────
# True : Gauss-Seidel coupling group on body↔magnet (high fidelity,
Expand All @@ -115,4 +124,86 @@
# invisible at cm-scale UMR motion). Default for this
# experiment is False since the headline use case is
# interactive viz iteration.
USE_COUPLING_GROUP = False
# Coupling group across body ↔ ext_magnet ↔ magnet ↔ mlp_drag ↔ lub:
# resolves the body↔drag implicit system within each timestep using
# Gauss-Seidel iteration. Required because the lubrication drag near
# the vessel wall is stiffer than dt·R/m allows for explicit Euler —
# without the coupling group the body's velocity flips sign with
# growing magnitude each step and teleports between vessel end-caps.
# 10× slower than staggered back-edges but the simulation actually
# stays inside the tube.
USE_COUPLING_GROUP = True

# ── Closed-loop controller (control/controller.py) ───────────────────
# Joint-space PD gains used by the AR4 tracking controller. These run
# *on top of* RobotArmNode's auto_gravity_compensation so the PD law
# only handles the tracking residual, not gravity load.
CONTROL_K_P = 10000.0 # 1/s² (10 ms time const, ζ=1)
CONTROL_K_D = 200.0 # 1/s
CONTROL_IK_LAMBDA = 0.05 # damped-LS Newton regulariser
# Differential-IK step gain (dimensionless). 1.0 = one full Newton
# step per call: q_target = q + J⁺·e, which puts the joint target
# at ≈ the IK solution. The IDPD then tracks it at K_p rate. Larger
# values overshoot (q_target = q + (K_ik-1)·J⁺·e past the target);
# smaller values settle slower but more conservatively.
CONTROL_K_IK = 1.0
# Inner Newton iters for the differential IK each physics step. 5
# is enough to converge from q (current state) to q_target through
# rotation gaps up to ~π/2; single-step (1 iter) lags during fast
# helix tumble.
CONTROL_IK_INNER_ITERS = 5

# Closed-loop position tracking (M3): EE follows the body's x
# position at this height above the tube. 15 cm matches de Jongh.
CONTROL_STANDOFF_M = 0.15

# Low-pass coefficient on the target pose (per physics step).
# 0.005 = 100 ms time const at dt = 5e-4. Heavily filters
# orientation feedback: only AVERAGE helix tilt drift (over many
# rotation cycles) drives the rotor. High-frequency body wobble
# from the elliptical-field tilt mode is ignored — chasing it
# would whip the AR4 around (the wrist's IK couples rotation to
# substantial position changes), which itself drives field-source
# motion → helix tumble → positive feedback. Slow tracking
# breaks the loop.
CONTROL_TARGET_ALPHA = 0.005

# Orientation feedback is OFF by default after diagnostics (see
# scene/_diagnose_compensation.py). Turning it ON causes the AR4
# to thrash: the IK that aligns EE-z with body-z also translates
# the wrist by 10s of cm (a 6-DOF AR4's position and orientation
# subspaces are coupled), which moves the field source erratically
# and itself drives helix tumble. With FB OFF the position-
# tracking controller keeps the rotor above the helix while the
# rotor's spin axis stays along world-x; the field is steady, and
# the helix swims cleanly at paper rate (~4 mm/s).
#
# Set ON to study the closed-loop instability mechanism described
# above, or once the AR4-position coupling is fixed (e.g. via
# task-priority IK that doesn't translate the wrist when only
# orientation is required).
ENABLE_ORIENTATION_FEEDBACK = False

# Velocity feedforward lookahead (s). The controller projects body
# orientation forward by this much using its angular velocity, so
# the IDPD's steady-state τ·ω lag is cancelled. Should match the
# IDPD time constant 1/sqrt(K_p) — at K_p=10000, that's 10 ms.
CONTROL_LOOKAHEAD_S = 0.010

# Reach envelope (M5): the AR4 at the configured base position +
# 20 cm standoff above the tube can only reach roughly x ∈ [-0.05,
# +0.05] m without driving its joints into limits or singularities
# (verified by the IK reach probe in scripts/check_ar4_reach.py).
# Beyond this, the controller clamps the target to the envelope —
# the rotor stays at the edge of its reach while the helix swims
# past, at which point the field at the helix loses the
# perpendicular geometry and the misalignment effect returns
# (which is fine for visualisation; real labs widen the workspace
# by mounting the manipulator on a linear stage).
CONTROL_X_MIN_M = -0.05
CONTROL_X_MAX_M = +0.05

# Jacobian-conditioning safety: if σ_min(J) drops below this the
# IK is approaching singularity and we freeze the target instead
# of trying to push through. Logged when triggered.
CONTROL_SIGMA_MIN = 1e-3
91 changes: 91 additions & 0 deletions experiments/ar4_helical_drive/scene/_diagnose_closed_loop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Compare closed-loop AR4 with vs. without orientation feedback.

Runs the full graph for 5 s under the controller, with and without
``ENABLE_ORIENTATION_FEEDBACK``. Prints alignment, swim distance,
and orbital amplitude — the headline numbers for M6 visual
acceptance.

Without feedback, the elliptical-field tilt instability tumbles the
helix off-axis within seconds. With feedback, the rotor reorients to
keep the helix locked, alignment stays high, and the helix
corkscrews along the tube cleanly.
"""
from __future__ import annotations
import os, sys
os.environ.setdefault("JAX_PLATFORMS", "cpu")
sys.path.insert(0, "/home/nick/MSF/MIME/src")
sys.path.insert(0, "/home/nick/MSF/MIME")

import importlib.util
import jax.numpy as jnp
import numpy as np

PARAMS_PATH = "/home/nick/MSF/MIME/experiments/ar4_helical_drive/physics/params.py"
SETUP_PATH = "/home/nick/MSF/MIME/experiments/ar4_helical_drive/physics/setup.py"
CONTROLLER_PATH = "/home/nick/MSF/MIME/experiments/ar4_helical_drive/control/controller.py"


def _load_module(name, path):
spec = importlib.util.spec_from_file_location(name, path)
m = importlib.util.module_from_spec(spec); sys.modules[name] = m
spec.loader.exec_module(m); return m


def quat_to_R(q):
w, x, y, z = q
return np.array([
[1-2*(y*y+z*z), 2*(x*y-w*z), 2*(x*z+w*y)],
[2*(x*y+w*z), 1-2*(x*x+z*z), 2*(y*z-w*x)],
[2*(x*z-w*y), 2*(y*z+w*x), 1-2*(x*x+y*y)],
])


def run_one(label: str, enable_orientation_feedback: bool, sim_s: float = 5.0):
ns: dict = {}
with open(PARAMS_PATH) as fh: exec(fh.read(), ns)
params = {k: v for k, v in ns.items() if not k.startswith("_") and k.isupper()}
params["ENABLE_ORIENTATION_FEEDBACK"] = enable_orientation_feedback

setup = _load_module("ar4_setup", SETUP_PATH)
controller = _load_module("ar4_controller", CONTROLLER_PATH)
controller._controller_instance = None
gm = setup.build_graph(params)

dt = float(params["DT_PHYS"])
n_steps = int(sim_s / dt)
prev_state = {n: gm.get_node_state(n) for n in gm._nodes}

align_log = []
body_x_log = []
yz_log = []
for step in range(n_steps):
ext = controller.get_external_inputs(params, step, state=prev_state)
gm.step(ext)
prev_state = {n: gm.get_node_state(n) for n in gm._nodes}
q = np.asarray(prev_state["body"]["orientation"])
pos = np.asarray(prev_state["body"]["position"])
R = quat_to_R(q)
align_log.append(R[0, 2]) # body-z dot world-x
body_x_log.append(pos[0])
yz_log.append(np.sqrt(pos[1]**2 + pos[2]**2))

align = np.array(align_log)
body_x = np.array(body_x_log)
yz = np.array(yz_log)
well_aligned = float(np.mean(np.abs(align) > 0.9))
swim_mm_s = (body_x[-1] - body_x[0]) * 1000 / sim_s
orbit_mm = float((yz.max() - yz.min())) * 1000

print(f"=== {label} ===")
print(f" fraction time |body-z·world-x| > 0.9: {well_aligned*100:.1f}%")
print(f" swim speed: {swim_mm_s:+.2f} mm/s")
print(f" body x at end: {body_x[-1]*1000:+.3f} mm")
print(f" orbit yz amplitude: {orbit_mm:.3f} mm")
print(f" alignment at start={align[0]:+.3f}, mid={align[n_steps//2]:+.3f}, end={align[-1]:+.3f}")
print()


run_one("M3 only — orientation feedback OFF (instability expected)",
enable_orientation_feedback=False)
run_one("M4 — orientation feedback ON (stable swim expected)",
enable_orientation_feedback=True)
82 changes: 82 additions & 0 deletions experiments/ar4_helical_drive/scene/_diagnose_compensation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""A/B test: is the AR4's aggressive orientation-tracking helping or
hurting the helix? Compares feedback ON vs feedback OFF.

Logs body angular velocity magnitude (should be ~18.85 rad/s = 3 Hz
if helix is properly synced; much higher if tumbling). Also logs
the rotor pose magnitude — if the AR4 is thrashing, the rotor
position changes rapidly, perturbing the field source.
"""
from __future__ import annotations
import os, sys
os.environ.setdefault("JAX_PLATFORMS", "cpu")
sys.path.insert(0, "/home/nick/MSF/MIME/src")
sys.path.insert(0, "/home/nick/MSF/MIME")

import importlib.util
import jax.numpy as jnp
import numpy as np

PARAMS_PATH = "/home/nick/MSF/MIME/experiments/ar4_helical_drive/physics/params.py"
SETUP_PATH = "/home/nick/MSF/MIME/experiments/ar4_helical_drive/physics/setup.py"
CONTROLLER_PATH = "/home/nick/MSF/MIME/experiments/ar4_helical_drive/control/controller.py"


def _load(name, path):
spec = importlib.util.spec_from_file_location(name, path)
m = importlib.util.module_from_spec(spec); sys.modules[name] = m
spec.loader.exec_module(m); return m


def quat_to_R(q):
w, x, y, z = q
return np.array([
[1-2*(y*y+z*z), 2*(x*y-w*z), 2*(x*z+w*y)],
[2*(x*y+w*z), 1-2*(x*x+z*z), 2*(y*z-w*x)],
[2*(x*z-w*y), 2*(y*z+w*x), 1-2*(x*x+y*y)],
])


def run(label, enable_orientation_feedback):
ns: dict = {}
with open(PARAMS_PATH) as fh: exec(fh.read(), ns)
params = {k: v for k, v in ns.items() if not k.startswith("_") and k.isupper()}
params["ENABLE_ORIENTATION_FEEDBACK"] = enable_orientation_feedback

setup = _load("ar4_setup", SETUP_PATH)
ctrl = _load("ar4_ctrl", CONTROLLER_PATH)
ctrl._controller_instance = None

gm = setup.build_graph(params)
dt = float(params["DT_PHYS"])
N = int(0.6 / dt)

print(f"\n{'='*78}\n{label} (orient_fb={enable_orientation_feedback})")
print(f"{'='*78}")
print(f"{'t_ms':>6} {'body_z·x':>10} {'|ω_body|':>10} "
f"{'ω_drive*':>9} {'rotor_y':>9} {'rotor_z':>9} "
f"{'body_x_mm':>10}")
drive_omega = 2 * np.pi * params["FIELD_FREQUENCY_HZ"]
print(f"{'':>6} {'':>10} {'rad/s':>10} {drive_omega:>9.2f} "
f"{'mm':>9} {'mm':>9} {'':>10}")

prev = {n: gm.get_node_state(n) for n in gm._nodes}
for step in range(N):
ext = ctrl.get_external_inputs(params, step, state=prev)
gm.step(ext)
prev = {n: gm.get_node_state(n) for n in gm._nodes}
if step % 100 == 0 or step in (10, 50, N-1):
body_q = np.asarray(prev["body"]["orientation"])
body_w = np.asarray(prev["body"]["angular_velocity"])
R_b = quat_to_R(body_q)
rotor_pos = np.asarray(prev["motor"]["rotor_pose_world"])[:3] \
if "rotor_pose_world" in prev["motor"] \
else np.asarray(prev["arm"]["end_effector_pose_world"])[:3]
print(f"{step*dt*1000:>6.0f} {R_b[0,2]:>+10.4f} "
f"{float(np.linalg.norm(body_w)):>10.2f} "
f"{'':>9} "
f"{rotor_pos[1]*1000:>+9.4f} {rotor_pos[2]*1000:>+9.4f} "
f"{prev['body']['position'][0]*1000:>+10.4f}")


run("orientation feedback OFF — pure M3 baseline", False)
run("orientation feedback ON — M4 closed-loop", True)
64 changes: 64 additions & 0 deletions experiments/ar4_helical_drive/scene/_diagnose_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""End-to-end smoke test: build the full AR4 graph, load the
controller, step a few times via the runner-style call, and confirm
no NaN/exceptions and that the rotor pose tracks the body's x.

Used for M6 visual validation prep — confirms the experiment is in
a state the MICROROBOTICA runner will execute cleanly.
"""
from __future__ import annotations
import os, sys
os.environ.setdefault("JAX_PLATFORMS", "cpu")
sys.path.insert(0, "/home/nick/MSF/MIME/src")
sys.path.insert(0, "/home/nick/MSF/MIME")

import importlib.util
import jax.numpy as jnp
import numpy as np

PARAMS_PATH = "/home/nick/MSF/MIME/experiments/ar4_helical_drive/physics/params.py"
SETUP_PATH = "/home/nick/MSF/MIME/experiments/ar4_helical_drive/physics/setup.py"
CONTROLLER_PATH = "/home/nick/MSF/MIME/experiments/ar4_helical_drive/control/controller.py"


def _load_module(name, path):
spec = importlib.util.spec_from_file_location(name, path)
m = importlib.util.module_from_spec(spec); sys.modules[name] = m
spec.loader.exec_module(m); return m

ns: dict = {}
with open(PARAMS_PATH) as fh: exec(fh.read(), ns)
params = {k: v for k, v in ns.items() if not k.startswith("_") and k.isupper()}

setup = _load_module("ar4_setup", SETUP_PATH)
controller = _load_module("ar4_controller", CONTROLLER_PATH)
controller._controller_instance = None

print("Building full AR4 + helix chain...", flush=True)
gm = setup.build_graph(params)
print(f" Built {len(gm._nodes)} nodes: {list(gm._nodes)}", flush=True)

prev_state = {n: gm.get_node_state(n) for n in gm._nodes}
print(f" Body initial pos: {np.asarray(prev_state['body']['position'])}", flush=True)
print(f" Arm initial q: {np.asarray(prev_state['arm']['joint_angles'])}", flush=True)

# Step 50 times (25 ms of physics). Slow first step due to JIT,
# subsequent should fly.
import time
for step in range(50):
t0 = time.perf_counter()
ext = controller.get_external_inputs(params, step, state=prev_state)
gm.step(ext)
prev_state = {n: gm.get_node_state(n) for n in gm._nodes}
dt = time.perf_counter() - t0
if step < 3 or step == 49:
body_x = float(prev_state["body"]["position"][0])
arm_link5 = np.asarray(prev_state["arm"]["link_poses_world"][5][:3])
any_nan = any(
np.any(np.isnan(np.asarray(v)))
for n in prev_state for v in prev_state[n].values()
if hasattr(v, "shape")
)
print(f" step {step}: dt={dt*1000:.1f}ms, body.x={body_x*1000:+.4f}mm, "
f"link5_world={arm_link5}, any_nan={any_nan}", flush=True)

print("OK — controller + graph integrate cleanly through 50 steps.")
Loading
Loading