Forward simulator: a mechanochemical epithelial-mesenchymal case study#

0. Setup#

We render off-screen (headless) so every figure embeds as a static image in the docs.

import numpy as np
import matplotlib.pyplot as plt

import pyglet
pyglet.options["headless"] = True
import pyvista as pv
pv.OFF_SCREEN = True

import warp as wp
from waxmorph import simulator, render

wp.init()
device = "cuda"
np.random.seed(42)

# --- Tutorial-scale configuration -------------------------------------------
N0 = 100                 # initial number of cells
N_MES = 14               # mesenchymal cells (soft, patterning core)
N_EPI = N0 - N_MES       # epithelial cells (polarized enclosing shell)
MAX_PARTICLES = 30_000   # preallocated capacity that division grows into
TARGET_CELLS = 15_000    # stop the morphogenesis loop at this many cells

# Cell-type codes shared with the Warp kernels: epithelial cells are encoded 1.
MES, EPI = 0, 1
def _auto_limits(pos, radii, pad=1.35):
    """A cubic view box that comfortably contains a cloud."""
    lo = float((pos.min(axis=0) - radii.max()).min())
    hi = float((pos.max(axis=0) + radii.max()).max())
    c, half = 0.5 * (lo + hi), 0.5 * (hi - lo) * pad
    return c - half, c + half


def render_cloud(pos, radii, *, cell_types=None, activator=None, title=None,
                 limits=None, window_size=(560, 480), theta_res=10, phi_res=6):
    """Screenshot a cell cloud off-screen, tinted by cell type or activator.

    Rendering statically (rather than as an interactive widget) makes the figure
    embed reliably in the built HTML docs.
    """
    n = len(pos)
    if activator is not None:
        a = np.asarray(activator, np.float32)
        morph = (a - a.min()) / (np.ptp(a) + 1e-9) * 0.9
    else:
        morph = np.zeros(n, np.float32)
    pol = np.zeros((n, 3), np.float32)
    pol[:, 2] = 1.0
    blim, tlim = limits if limits is not None else _auto_limits(pos, radii)
    plotter = render.PyVistaInterface.draw_3d_view(
        pos, radii, morph, pol, n, blim=blim, tlim=tlim, alpha=1.0,
        theta_res=theta_res, phi_res=phi_res, show_polarities=False,
        cell_types=None if cell_types is None else np.asarray(cell_types),
    )
    plotter.remove_bounds_axes()
    plotter.hide_axes()          # drop the little XYZ orientation gizmo
    img = plotter.screenshot(return_img=True, window_size=window_size)
    plotter.close()
    fig, ax = plt.subplots(figsize=(window_size[0] / 120, window_size[1] / 120))
    ax.imshow(img)
    ax.axis("off")
    if title:
        ax.set_title(title, fontsize=10)
    plt.show()


def render_filmstrip(snaps, *, title=None, window_size=(300, 280)):
    """Lay out several ``(label, pos, radii, cell_types)`` snapshots side by side.

    All frames share one view box (taken from the largest snapshot) so the growth
    of the aggregate is visible across the strip.
    """
    big_pos, big_rad = snaps[-1][1], snaps[-1][2]
    limits = _auto_limits(big_pos, big_rad)
    shots = []
    for label, pos, radii, ct in snaps:
        n = len(pos)
        pol = np.zeros((n, 3), np.float32)
        pol[:, 2] = 1.0
        plotter = render.PyVistaInterface.draw_3d_view(
            pos, radii, np.zeros(n, np.float32), pol, n, blim=limits[0],
            tlim=limits[1], alpha=1.0, theta_res=8, phi_res=6,
            show_polarities=False, cell_types=np.asarray(ct),
        )
        plotter.remove_bounds_axes()
        plotter.hide_axes()
        shots.append((label, plotter.screenshot(return_img=True, window_size=window_size)))
        plotter.close()
    fig, axes = plt.subplots(1, len(shots), figsize=(2.6 * len(shots), 2.8))
    for ax, (label, im) in zip(np.atleast_1d(axes), shots):
        ax.imshow(im)
        ax.axis("off")
        ax.set_title(label, fontsize=9)
    if title:
        fig.suptitle(title, y=1.02)
    plt.tight_layout()
    plt.show()

1. Initialise the aggregate#

def fibonacci_sphere(n, radius):
    """`n` near-uniform points on a sphere of the given radius."""
    i = np.arange(n)
    phi = np.pi * (3.0 - np.sqrt(5.0))       # golden angle
    y = 1.0 - 2.0 * i / (n - 1)
    r = np.sqrt(np.clip(1.0 - y * y, 0.0, None))
    theta = phi * i
    return np.stack([np.cos(theta) * r * radius, y * radius,
                     np.sin(theta) * r * radius], axis=1)


def uniform_ball(n, radius):
    """`n` points sampled uniformly by volume inside a ball."""
    v = np.random.normal(size=(n, 3))
    v /= np.linalg.norm(v, axis=1, keepdims=True)
    return v * (np.random.rand(n) ** (1 / 3) * radius)[:, None]


R_SHELL, R_INNER, R_PROTO = 3.0, 1.8, 0.6

# Positions: mesenchymal ball inside, epithelial shell outside, with a little jitter.
centers = np.zeros((MAX_PARTICLES, 3), np.float32)
centers[:N_MES] = uniform_ball(N_MES, R_INNER)
centers[N_MES:N0] = fibonacci_sphere(N_EPI, R_SHELL)
centers[:N0] += 0.05 * np.random.normal(size=(N0, 3))

# Radii: physical and equilibrium start equal; inactive slots are parked far below zero.
radii = np.full(MAX_PARTICLES, -1000.0, np.float32)
radii_eq = np.full(MAX_PARTICLES, -1000.0, np.float32)
radii[:N0] = R_PROTO
radii_eq[:N0] = R_PROTO

# Activator / inhibitor: seed the mesenchyme only (ratio ~5); the epithelium carries none.
activators = np.zeros(MAX_PARTICLES, np.float32)
inhibitors = np.zeros(MAX_PARTICLES, np.float32)
a0 = (R_PROTO ** 3 * 0.75) / 2.0
activators[:N_MES] = a0 + 1e-3 * np.random.normal(size=N_MES)
inhibitors[:N_MES] = a0 / 5.0 + 1e-3 * np.random.normal(size=N_MES)

# Polarity: unit vectors pointing radially outward from the aggregate centre.
polarities = np.zeros((MAX_PARTICLES, 3), np.float32)
polarities[:, 2] = 1.0
d = centers[:N0] - centers[:N0].mean(axis=0)
polarities[:N0] = d / np.clip(np.linalg.norm(d, axis=1, keepdims=True), 1e-9, None)

# Cell type: 0 = mesenchymal, 1 = epithelial.
cell_types = np.full(MAX_PARTICLES, EPI, np.uint32)
cell_types[:N_MES] = MES

# Upload the state to the GPU as Warp arrays.
X = wp.from_numpy(centers, dtype=wp.vec3f, device=device)
R = wp.from_numpy(radii, dtype=wp.float32, device=device)
R_eq = wp.from_numpy(radii_eq, dtype=wp.float32, device=device)
P = wp.from_numpy(polarities, dtype=wp.vec3f, device=device)
A = wp.from_numpy(activators, dtype=wp.float32, device=device)
I = wp.from_numpy(inhibitors, dtype=wp.float32, device=device)
CT = wp.from_numpy(cell_types, dtype=wp.uint32, device=device)
keys = simulator.gen_key_array(MAX_PARTICLES)   # per-cell RNG keys for division/growth

pcount = N0
print(f"{N0} cells: {N_MES} mesenchymal core + {N_EPI} epithelial shell")
Module waxmorph.simulator 0df436c load on device 'cuda:0' took 501.68 ms  (compiled)
100 cells: 14 mesenchymal core + 86 epithelial shell

The starting aggregate#

We colour cells by type: the epithelial shell wraps the mesenchymal core. A cutaway (keeping one half) reveals the soft interior the shell encloses.

pos0, rad0, ct0 = X.numpy()[:pcount], R.numpy()[:pcount], CT.numpy()[:pcount]
render_cloud(pos0, rad0, cell_types=ct0,
             title="Initial aggregate — epithelial shell (type 1) around mesenchyme (type 0)")

# Cutaway: keep the x <= mean half so the core is visible inside the shell.
half = pos0[:, 0] <= pos0[:, 0].mean()
render_cloud(pos0[half], rad0[half], cell_types=ct0[half],
             title="Cutaway — soft mesenchymal core inside the shell")
../_images/6f3921ead72ffbfdc2cc1330149ab8294e9158f2cc0050fa695c2319133a2aa0.png ../_images/5e8e29a7e85934c19b586919dbbb5825b3e973ab634bd81f12a2abbdac1bf557.png

2. Sticky-sphere mechanics#

DT_MECH = 5e-2
for _ in range(600):
    simulator.mech_step_sticky(X, R, P, CT, pcount, DT_MECH, X, P, grad_consist=False)

render_cloud(X.numpy()[:pcount], R.numpy()[:pcount], cell_types=CT.numpy()[:pcount],
             title="After sticky-sphere relaxation")
../_images/086374e1778ca849c1da2973dfeb9b08f0a85e8aef672db8d34d81eca3725c4c.png

3. Reaction-diffusion patterning#

CHI = wp.full(1, 5e-3, dtype=wp.float32)      # activator diffusivity relative to inhibitor
GAMMA = wp.full(1, 1e-3, dtype=wp.float32)    # reaction rate (1e-3 -> branching; 1e-2 -> undulation)
D_INHIB = 10.0                                # inhibitor diffusivity
DT_CHEM = 5e-2
lapA, lapI = wp.zeros_like(A), wp.zeros_like(I)

a_trace, i_trace = [], []
for _ in range(4000):
    lapA.zero_()
    lapI.zero_()
    simulator.chem_step(A, I, X, R, lapA, lapI, CHI, GAMMA, D_INHIB, DT_CHEM,
                        pcount, A, I, grad_consist=False, CT=CT, reaction_cell_type=MES)
    a_trace.append(float(A.numpy()[0]))
    i_trace.append(float(I.numpy()[0]))

print(f"cell 0 settled at activator={a_trace[-1]:.3f}, inhibitor={i_trace[-1]:.3f}")
cell 0 settled at activator=0.505, inhibitor=0.614
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(a_trace, label=r"activator $c_A$")
ax.plot(i_trace, label=r"inhibitor $c_I$")
ax.set_xlabel("reaction–diffusion step")
ax.set_ylabel("concentration")
ax.set_title("Activator–inhibitor kinetics in one mesenchymal cell")
ax.legend()
ax.grid(alpha=0.3)
plt.show()

# The activator field lives in the core; show the mesenchyme coloured by activator.
mes = CT.numpy()[:pcount] == MES
render_cloud(X.numpy()[:pcount][mes], R.numpy()[:pcount][mes],
             activator=A.numpy()[:pcount][mes],
             title="Activator concentration in the mesenchymal core")
../_images/ae137a37fe51e16a82964760f8277f1575aacdd145d791692479a8c30e00dd16.png ../_images/23430bcdd37981f85c26908493213519e4aaaf58e947b395442d03f364121ab0.png

4. Growth#

ALPHA_GROW = wp.full(1, 10.0, dtype=wp.float32, requires_grad=True)  # growth Hill exponent
ELL_SW = wp.full(1, 0.5, dtype=wp.float32, requires_grad=True)       # switch concentration
DT_GROWTH = 5e-3
R_REF, R_MAX = 0.6, 1.4      # epithelial reference radius; mesenchymal equilibrium cap

# A short, division-free growth phase so the swelling is visible on its own.
# (Sustained growth without division would overfill the shell; in Section 5 the
# epithelium divides to keep pace, so the core stays enclosed.)
for _ in range(1000):
    simulator.mech_step_sticky(X, R, P, CT, pcount, DT_MECH, X, P, grad_consist=False)
    simulator.growth_step(R, R_eq, A, CT, keys, ALPHA_GROW, ELL_SW, DT_GROWTH,
                          R_REF, R_MAX, pcount, R, R_eq, grad_consist=False)

r_now = R.numpy()[:pcount]
print(f"radius range after growth: {r_now.min():.2f}{r_now.max():.2f} "
      f"(mesenchyme swells; epithelium stays near {R_REF})")

# Cutaway again: the mesenchymal cells (type 0) have swelled inside the shell.
pos, rad, ct = X.numpy()[:pcount], R.numpy()[:pcount], CT.numpy()[:pcount]
half = pos[:, 0] <= pos[:, 0].mean()
render_cloud(pos[half], rad[half], cell_types=ct[half],
             title="Cutaway — activator-driven swelling of the mesenchymal core")
radius range after growth: 0.60 – 1.12 (mesenchyme swells; epithelium stays near 0.6)
../_images/fc8e1705c301ccaee60e69d7dae86481b68eca9a650a353d1d7b9cba8a56a73e.png

5. Division and morphogenesis#

R_DIV_REF = 1.0            # division reference radius (mesenchymal Hill)
EPI_MAX_NEIGHBORS = 6      # epithelial monolayer neighbour cap
P_EPI = 1.0                # epithelial division probability when eligible
SUBSTEPS = 20              # mechanics + chemistry sub-steps per outer iteration
t_div, tmax, tau = 1.0, 1.0, 1.0   # Gumbel-softmax division-sampling controls

n_tot = wp.zeros(MAX_PARTICLES, dtype=wp.int32, device=device)
n_epi = wp.zeros(MAX_PARTICLES, dtype=wp.int32, device=device)
n_mes = wp.zeros(MAX_PARTICLES, dtype=wp.int32, device=device)

def snapshot(label):
    return (label, X.numpy()[:pcount].copy(), R.numpy()[:pcount].copy(),
            CT.numpy()[:pcount].copy())

snaps = [snapshot(f"{pcount} cells")]
milestones = [1000, 3000, 7000, TARGET_CELLS]

while pcount < TARGET_CELLS:
    for _ in range(SUBSTEPS):
        lapA.zero_(); lapI.zero_()
        simulator.mech_step_sticky(X, R, P, CT, pcount, DT_MECH, X, P, grad_consist=False)
        simulator.chem_step(A, I, X, R, lapA, lapI, CHI, GAMMA, D_INHIB, DT_CHEM,
                            pcount, A, I, grad_consist=False, CT=CT, reaction_cell_type=MES)
    simulator.growth_step(R, R_eq, A, CT, keys, ALPHA_GROW, ELL_SW, DT_GROWTH,
                          R_REF, R_MAX, pcount, R, R_eq, grad_consist=False)

    n_tot.zero_(); n_epi.zero_(); n_mes.zero_()
    simulator.count_neighbors_step(X, R, CT, pcount, n_tot, n_epi, n_mes, device=device)

    div_count = wp.full(1, value=pcount, dtype=wp.int32, device=device)
    div_slots = wp.full(pcount, value=-1, dtype=wp.int32, device=device)
    wp.launch(simulator.division_decision, dim=pcount,
              inputs=[X, R, CT, keys, n_epi, n_mes, div_count, div_slots,
                      R_DIV_REF, P_EPI, EPI_MAX_NEIGHBORS, t_div, tmax, tau, MAX_PARTICLES],
              device=device)
    wp.launch(simulator.division_logic, dim=pcount,
              inputs=[X, R, R_eq, A, I, P, CT, div_slots], device=device)
    pcount = min(int(div_count.numpy().item()), MAX_PARTICLES)

    if milestones and pcount >= milestones[0]:
        snaps.append(snapshot(f"{pcount} cells"))
        milestones.pop(0)

print(f"grew to {pcount} cells")
grew to 15005 cells

The filmstrip below traces the aggregate from a smooth sphere to an elongated, deformed shape; the final render shows the reshaped epithelial shell.

render_filmstrip(snaps, title="Coupled growth and division: sphere → deformed aggregate")
render_cloud(X.numpy()[:pcount], R.numpy()[:pcount], cell_types=CT.numpy()[:pcount],
             title=f"Final aggregate — {pcount} cells")
../_images/5a810d283c4b2216f6956ec90750ea67613ff766855f948b7a5e2f2bc0cd08bc.png ../_images/466fc6838e7b57330d0f2024c6d65d961bbef74f9cd7ed5c12e597c10f7b7fdb.png

6. The differentiable path#

X_a = wp.from_numpy(centers.copy(), dtype=wp.vec3f, device=device)
P_a = wp.from_numpy(polarities.copy(), dtype=wp.vec3f, device=device)
X_d = wp.from_numpy(centers.copy(), dtype=wp.vec3f, device=device)
P_d = wp.from_numpy(polarities.copy(), dtype=wp.vec3f, device=device)

for _ in range(200):
    simulator.mech_step_sticky(X_a, R, P_a, CT, N0, DT_MECH, X_a, P_a, grad_consist=False)
    simulator.mech_step_sticky_implicit(X_d, R, P_d, CT, N0, DT_MECH, X_d, P_d)

gap = np.abs(X_a.numpy()[:N0] - X_d.numpy()[:N0]).max()
print(f"max position difference (analytic vs autodiff) after 200 steps: {gap:.2e}")
max position difference (analytic vs autodiff) after 200 steps: 5.96e-07

7. (Optional) Export a movie#

import os
os.makedirs("Output", exist_ok=True)

with render.WarpMovieRenderer(
    filename="Output/epi_mes_morphogenesis.usd",
    max_particles=MAX_PARTICLES,
    backend="usd",
    fps=8,
    device=device,
) as mov:
    for f, (label, pos, radii, ct) in enumerate(snaps):
        n = len(pos)
        colors = np.where((ct == EPI)[:, None], [0.85, 0.55, 0.25], [0.30, 0.55, 0.85]).astype(np.float32)
        mov.write_frame_from_numpy(
            t=f / 8.0, centers=pos, radii=radii, colors=colors, particle_count=n,
        )
print("wrote Output/epi_mes_morphogenesis.usd")
Saved the USD stage file at `/insomnia001/depts/morpheus/users/ob2391/Waxmorph/Output/epi_mes_morphogenesis.usd`
wrote Output/epi_mes_morphogenesis.usd