"""Prescribed Warp mechanics, chemistry, growth, and division in model units.
Division grows the active prefix up to caller-owned capacity. Mechanics stores a
potential gradient and applies ``X_next = X - lr * f_net``; the differentiable
emulator stores a physical force and adds it. The paths share abstractions and
provide purpose-built kernels and cell-type behavior. ``CT`` encodes
mesenchymal as ``0`` and epithelial as ``1``.
"""
from math import isfinite as _isfinite
import warp as wp
from .constants import EPS_DEN, EPS_NORM, FOUR_THIRDS_PI, HASH_GRID_DIM, RAND_EPS
EPS_DIST = 25e-2
K_REP = 3.0
K_ATT_EE = 1.5
K_ATT_MM = 0.15
K_ATT_EM = 0.15
ATR_EE_CUTOFF = 0.25
ATR_EE = 0.14
ATR_MM = 0.1
ATR_EM = 0.06
D_SHIFT_EM = 0.08
K_THICK_EE = 6.0
H_THICK_EE = 0.08
@wp.func
def safe_div(num: wp.float32, den: wp.float32) -> wp.float32:
return num / (den + EPS_DEN)
@wp.func
def volume_from_radius(r: wp.float32) -> wp.float32:
return FOUR_THIRDS_PI * r * r * r
@wp.func
def adj_weight(dist: wp.float32, ri: wp.float32, rj: wp.float32) -> wp.bool:
return wp.bool((dist - (ri + rj)) <= EPS_DIST)
@wp.func
def probs(p: wp.float32, ref: wp.float32):
r"""Size-driven mesenchymal division probability.
.. math::
p_i = \frac{r_i^{20}}{r_i^{20} + r_{\mathrm{ref}}^{20}}.
The fixed Hill exponent makes the transition steep near ``ref``.
"""
return (p**20.0) / ((p**20.0) + (ref**20.0))
@wp.func
def softmax2d(p: wp.vec2f):
m = wp.max(p.x, p.y)
ex = wp.exp(p.x - m)
ey = wp.exp(p.y - m)
inv_sum = 1.0 / (ex + ey)
return wp.vec2f(ex * inv_sum, ey * inv_sum)
@wp.func
def gumbel(key: wp.uint32):
"""Draw one Gumbel variate while advancing the per-particle key."""
u = wp.randf(key, 0.0, 1.0)
u = wp.clamp(u, RAND_EPS, 1.0 - RAND_EPS)
return wp.randu(key), -wp.log(-wp.log(u))
@wp.kernel
def fill_key_array(
key_array: wp.array(dtype=wp.uint32),
) -> None:
i = wp.tid()
key_array[i] = wp.rand_init(i)
[docs]
def gen_key_array(size: int, device: str = "cuda") -> wp.array:
"""Initialize one deterministic RNG key per array index."""
key_array = wp.zeros(shape=size, dtype=wp.uint32, device=device)
wp.launch(fill_key_array, dim=key_array.size, inputs=[key_array], device=device)
return key_array
@wp.func
def sticky_sphere_forces(
x_i: wp.vec3f,
x_j: wp.vec3f,
r_i: wp.float32,
r_j: wp.float32,
ct_i: wp.uint32,
ct_j: wp.uint32,
):
r"""Cell-type soft-sphere potential gradient for one pair.
For :math:`q=x_i-x_j`, :math:`d=\lVert q\rVert_2+EPS_NORM`,
:math:`u=q/d`, and
:math:`d_0=r_i+r_j+\mathrm{d\_shift}`,
.. math::
f_{ij}=\left[k_{\mathrm{rep}}\max(d_0-d,0)
-f_{\mathrm{att}}(d)\right](-u).
For EE, :math:`\delta=\max(d-d_0,0)` and attraction is
:math:`k_{\mathrm{att}}[\delta+\delta^3/(ATR_{EE}^2+EPS_NORM)]` until its
outer support boundary :math:`d\le d_0+ATR_{EE\_CUTOFF}`. MM and EM use
:math:`k_{\mathrm{att}}\max(d_0+ATR_{pair}-d,0)\mathbb{1}[d>d_0]`, where
:math:`ATR_{pair}` is ``ATR_MM`` or ``ATR_EM``. ``D_SHIFT_EM`` offsets the
heterotypic preferred distance. The result is a potential-gradient buffer
subtracted by :func:`gd_update`.
"""
d = x_i - x_j
dist = wp.length(d) + EPS_NORM
u = d / dist
rs = r_i + r_j
k_att = K_ATT_MM
atr = ATR_MM
d_shift = 0.0
is_ee = (ct_i == wp.uint32(1)) and (ct_j == wp.uint32(1))
is_em = ct_i != ct_j
if is_ee:
k_att = K_ATT_EE
atr = ATR_EE
elif is_em:
k_att = K_ATT_EM
atr = ATR_EM
d_shift = D_SHIFT_EM
d0 = rs + d_shift
t = dist - d0
comp = wp.max(-t, wp.float32(0.0))
ext = wp.max(t, wp.float32(0.0))
f_rep = K_REP * comp
f_att = 0.0
if is_ee:
invL2 = wp.float32(1.0) / (atr * atr + EPS_NORM)
f_att = k_att * (ext + ext * ext * ext * invL2)
if dist > d0 + ATR_EE_CUTOFF:
return wp.vec3f(0.0), wp.vec3f(0.0)
else:
if dist > d0 + atr:
return wp.vec3f(0.0), wp.vec3f(0.0)
att_ramp = wp.max((d0 + atr) - dist, wp.float32(0.0)) * wp.float32(dist > d0)
f_att = k_att * att_ramp
fmag = f_rep - f_att
F_ij = fmag * -u
return F_ij, -F_ij
@wp.func
def epi_polarity_grads(
x_i: wp.vec3f,
x_j: wp.vec3f,
p_i: wp.vec3f,
p_j: wp.vec3f,
):
r"""Implemented epithelial-polarity derivative mapping for one pair.
.. math::
s=\lVert x_i-x_j\rVert_2+EPS_NORM,\qquad
u=(x_i-x_j)/s.
With :math:`a=p_i^Tu`, :math:`b=p_j^Tu`, and :math:`g=ap_i+bp_j`, the
polarity outputs are :math:`au` and :math:`bu`; the position output is
:math:`[g-u(u^Tg)]/s`, with the opposite sign on ``j``.
"""
d = x_i - x_j
dist = wp.length(d) + EPS_NORM
u = d / dist
a = wp.dot(p_i, u)
b = wp.dot(p_j, u)
grad_p_i = a * u
grad_p_j = b * u
g_u = a * p_i + b * p_j
proj = g_u - u * wp.dot(u, g_u)
grad_d = proj / dist
grad_x_i = grad_d
grad_x_j = -grad_d
return grad_x_i, grad_x_j, grad_p_i, grad_p_j
@wp.func
def epi_thickness_grads(x_i: wp.vec3f, x_j: wp.vec3f, p_i: wp.vec3f, p_j: wp.vec3f):
r"""Analytic position gradient of the epithelial thickness penalty.
.. math::
U=\tfrac12 K_{\mathrm{thick}}
\max(|(x_i-x_j)^Tn|-H_{\mathrm{thick}},0)^2.
``n`` is the sign-aligned polarity sum divided by its norm plus ``EPS_NORM``
and is held fixed with respect to position. The hinge contributes zero
inside its slack band.
"""
d = x_i - x_j
if wp.dot(p_i, -p_j) > wp.dot(p_i, p_j):
p_j = -p_j
s = p_i + p_j
ns = wp.length(s) + EPS_NORM
n = s / ns
dn = wp.dot(d, n)
adn = wp.abs(dn)
excess = wp.max(adn - H_THICK_EE, wp.float32(0.0))
sign = wp.float32(1.0)
if dn < wp.float32(0.0):
sign = wp.float32(-1.0)
grad_x_i = K_THICK_EE * excess * sign * n
grad_x_j = -grad_x_i
return grad_x_i, grad_x_j
@wp.func
def mes_polarity_grads(p_i: wp.vec3f, p_j: wp.vec3f):
r"""Analytic gradient of unoriented mesenchymal polarity alignment.
.. math::
U=-\tfrac12(p_i^Tp_j)^2,\qquad
\nabla_{p_i}U=-(p_i^Tp_j)p_j,
\quad \nabla_{p_j}U=-(p_i^Tp_j)p_i.
"""
c = wp.dot(p_i, p_j)
grad_p_i = -c * p_j
grad_p_j = -c * p_i
return grad_p_i, grad_p_j
@wp.func
def mes_wnt_polarity_grads(p_i: wp.vec3f, u_to_higher_wnt: wp.vec3f, w_higher: wp.float32):
r"""Analytic mesenchymal-polarity gradient toward higher WNT.
.. math::
U=-\tfrac12 c_{j,A}(p_i^Tu_{ji})^2,\qquad
\nabla_{p_i}U=-c_{j,A}(p_i^Tu_{ji})u_{ji}.
Callers use :math:`u_{ji}=(x_j-x_i)/(\lVert x_j-x_i\rVert+EPS_NORM)`;
positions and concentrations are held fixed.
"""
c = wp.dot(p_i, u_to_higher_wnt)
return -w_higher * c * u_to_higher_wnt
@wp.kernel(enable_backward=False)
def sticky_sphere_grads(
grid: wp.uint64,
X: wp.array(dtype=wp.vec3f),
R: wp.array(dtype=wp.float32),
P: wp.array(dtype=wp.vec3f),
CT: wp.array(dtype=wp.uint32),
query_radius: wp.float32,
f_net: wp.array(dtype=wp.vec3f),
f_pol: wp.array(dtype=wp.vec3f),
):
r"""Accumulate type-dependent mechanics and polarity gradients.
Epithelial polarity position-gradient components are clipped to
:math:`\pm\max(|f_{soft}|,10^{-3})`; thickness gradients retain their full values.
"""
tid = wp.tid()
i = wp.hash_grid_point_id(grid, tid)
x_i = X[i]
r_i = R[i]
c_i = CT[i]
p_i = P[i]
for j in wp.hash_grid_query(grid, x_i, query_radius):
if j <= i:
continue
x_j = X[j]
r_j = R[j]
c_j = CT[j]
p_j = P[j]
grad_x_i_f, grad_x_j_f = sticky_sphere_forces(x_i, x_j, r_i, r_j, c_i, c_j)
wp.atomic_add(f_net, i, grad_x_i_f)
wp.atomic_add(f_net, j, grad_x_j_f)
dist = wp.norm_l2(x_i - x_j)
w = adj_weight(dist, r_i, r_j)
if not wp.bool(w):
continue
if (c_i == wp.uint32(1)) and (c_j == wp.uint32(1)):
grad_x_i_p, grad_x_j_p, grad_p_i, grad_p_j = epi_polarity_grads(x_i, x_j, p_i, p_j)
grad_x_i_t, grad_x_j_t = epi_thickness_grads(x_i, x_j, p_i, p_j)
for k in range(3):
v_i = wp.max(wp.abs(grad_x_i_f[k]), wp.float32(1e-3))
v_j = wp.max(wp.abs(grad_x_j_f[k]), wp.float32(1e-3))
grad_x_i_p[k] = wp.clamp(grad_x_i_p[k], -1.0 * v_i, 1.0 * v_i)
grad_x_j_p[k] = wp.clamp(grad_x_j_p[k], -1.0 * v_j, 1.0 * v_j)
wp.atomic_add(f_net, i, grad_x_i_p)
wp.atomic_add(f_net, j, grad_x_j_p)
wp.atomic_add(f_net, i, grad_x_i_t)
wp.atomic_add(f_net, j, grad_x_j_t)
wp.atomic_add(f_pol, i, grad_p_i)
wp.atomic_add(f_pol, j, grad_p_j)
if (c_i == wp.uint32(0)) and (c_j == wp.uint32(0)):
grad_p_i, grad_p_j = mes_polarity_grads(p_i, p_j)
wp.atomic_add(f_pol, i, grad_p_i)
wp.atomic_add(f_pol, j, grad_p_j)
@wp.kernel(enable_backward=False)
def sticky_sphere_wnt_grads(
grid: wp.uint64,
X: wp.array(dtype=wp.vec3f),
R: wp.array(dtype=wp.float32),
P: wp.array(dtype=wp.vec3f),
CT: wp.array(dtype=wp.uint32),
WNT: wp.array(dtype=wp.float32),
query_radius: wp.float32,
gp: wp.array(dtype=wp.vec3f),
):
"""Accumulate optional WNT-driven mesenchymal polarity gradients."""
tid = wp.tid()
i = wp.hash_grid_point_id(grid, tid)
x_i = X[i]
r_i = R[i]
c_i = CT[i]
p_i = P[i]
w_i = safe_div(WNT[i], volume_from_radius(r_i))
for j in wp.hash_grid_query(grid, x_i, query_radius):
if j <= i:
continue
x_j = X[j]
r_j = R[j]
dist = wp.norm_l2(x_i - x_j)
w = adj_weight(dist, r_i, r_j)
if not wp.bool(w):
continue
c_j = CT[j]
p_j = P[j]
w_j = safe_div(WNT[j], volume_from_radius(r_j))
dist_safe = dist + EPS_NORM
if (c_i == wp.uint32(0)) and (w_j > w_i):
u_i_to_j = (x_j - x_i) / dist_safe
wp.atomic_add(gp, i, mes_wnt_polarity_grads(p_i, u_i_to_j, w_j))
if (c_j == wp.uint32(0)) and (w_i > w_j):
u_j_to_i = (x_i - x_j) / dist_safe
wp.atomic_add(gp, j, mes_wnt_polarity_grads(p_j, u_j_to_i, w_i))
@wp.kernel
def gd_update(
X: wp.array(dtype=wp.vec3f),
f_net: wp.array(dtype=wp.vec3f),
lr: wp.float32,
X_next: wp.array(dtype=wp.vec3f),
):
r"""Apply :math:`X_{next}=X-lr\,f_{net}` for a potential-gradient buffer."""
i = wp.tid()
X_next[i] = X[i] - lr * f_net[i]
@wp.kernel
def gd_update_normalized(
P: wp.array(dtype=wp.vec3f),
f_pol: wp.array(dtype=wp.vec3f),
lr: wp.float32,
P_next: wp.array(dtype=wp.vec3f),
):
"""Apply polarity descent and renormalize nonzero vectors."""
i = wp.tid()
P_next[i] = wp.normalize(P[i] - lr * f_pol[i])
[docs]
def mech_step_sticky(
X: wp.array(dtype=wp.vec3f),
R: wp.array(dtype=wp.float32),
P: wp.array(dtype=wp.vec3f),
CT: wp.array(dtype=wp.uint32),
particle_count: wp.int32,
dt: float,
X_next: wp.array(dtype=wp.vec3f),
P_next: wp.array(dtype=wp.vec3f),
device: str = "cuda",
grad_consist: bool = False,
grid: "wp.HashGrid | None" = None,
*,
wnt: "wp.array | None" = None,
):
"""Advance prescribed mechanics using explicit pair derivatives.
The active contact graph is rebuilt with a Warp HashGrid. Optional WNT adds
mesenchymal polarity alignment; epithelial polarity position gradients use
the componentwise soft-force clamp documented by :func:`sticky_sphere_grads`.
``grad_consist`` emits replay markers.
"""
f_net = wp.zeros_like(X, device=device)
f_pol = wp.zeros_like(P, device=device)
r_max = float(R.numpy()[:particle_count].max())
query_radius = 2.0 * r_max + ATR_EE_CUTOFF
if grid is None:
grid = wp.HashGrid(HASH_GRID_DIM, HASH_GRID_DIM, HASH_GRID_DIM, device=device)
grid.build(X[:particle_count], query_radius)
wp.launch(
sticky_sphere_grads,
dim=particle_count,
inputs=[wp.uint64(grid.id), X, R, P, CT, query_radius],
outputs=[f_net, f_pol],
device=device,
)
if wnt is not None:
wp.launch(
sticky_sphere_wnt_grads,
dim=particle_count,
inputs=[wp.uint64(grid.id), X, R, P, CT, wnt, query_radius],
outputs=[f_pol],
device=device,
)
if grad_consist:
f_net.mark_write()
wp.launch(
gd_update,
dim=particle_count,
inputs=[X, f_net, dt, X_next],
device=device,
)
wp.launch(
gd_update_normalized,
dim=particle_count,
inputs=[P, f_pol, dt, P_next],
device=device,
)
if grad_consist:
X.mark_read()
P.mark_read()
f_net.mark_read()
f_pol.mark_read()
X_next.mark_write()
P_next.mark_write()
return f_net
@wp.func
def epi_polarity_potential(
x_i: wp.vec3f,
x_j: wp.vec3f,
p_i: wp.vec3f,
p_j: wp.vec3f,
) -> wp.float32:
r"""Epithelial polarity potential.
.. math::
U=\tfrac12(p_i\cdot u)^2+\tfrac12(p_j\cdot u)^2,
\qquad u=\frac{x_i-x_j}{\lVert x_i-x_j\rVert_2+EPS_NORM}.
"""
d = x_i - x_j
dist = wp.length(d) + EPS_NORM
u = d / dist
a = wp.dot(p_i, u)
b = wp.dot(p_j, u)
return wp.float32(0.5) * a * a + wp.float32(0.5) * b * b
@wp.func
def epi_thickness_potential(
x_i: wp.vec3f,
x_j: wp.vec3f,
p_i: wp.vec3f,
p_j: wp.vec3f,
) -> wp.float32:
r"""Epithelial thickness potential.
.. math::
U=\tfrac12K_{\mathrm{thick}}
\max(|d\cdot n|-H_{\mathrm{thick}},0)^2.
``n`` is the sign-aligned polarity sum divided by its norm plus ``EPS_NORM``.
"""
d = x_i - x_j
p_j_use = p_j
if wp.dot(p_i, -p_j) > wp.dot(p_i, p_j):
p_j_use = -p_j
s = p_i + p_j_use
ns = wp.length(s) + EPS_NORM
n = s / ns
dn = wp.dot(d, n)
adn = wp.abs(dn)
excess = wp.max(adn - H_THICK_EE, wp.float32(0.0))
return wp.float32(0.5) * K_THICK_EE * excess * excess
@wp.func
def mes_polarity_potential(p_i: wp.vec3f, p_j: wp.vec3f) -> wp.float32:
r"""Unoriented mesenchymal polarity potential.
.. math:: U=-\tfrac12(p_i\cdot p_j)^2.
"""
c = wp.dot(p_i, p_j)
return wp.float32(-0.5) * c * c
@wp.func
def mes_wnt_polarity_potential(
p_i: wp.vec3f,
u_to_higher_wnt: wp.vec3f,
w_higher: wp.float32,
) -> wp.float32:
r"""Mesenchymal polarity potential toward higher WNT.
.. math:: U=-\tfrac12c_{j,A}(p_i^Tu_{ji})^2.
Callers supply :math:`u=(x_j-x_i)/(\lVert x_j-x_i\rVert+EPS_NORM)`.
"""
c = wp.dot(p_i, u_to_higher_wnt)
return wp.float32(-0.5) * w_higher * c * c
@wp.kernel(enable_backward=False)
def sticky_sphere_grads_implicit(
grid: wp.uint64,
X: wp.array(dtype=wp.vec3f),
R: wp.array(dtype=wp.float32),
P: wp.array(dtype=wp.vec3f),
CT: wp.array(dtype=wp.uint32),
query_radius: wp.float32,
f_net: wp.array(dtype=wp.vec3f),
f_pol: wp.array(dtype=wp.vec3f),
):
"""Use :func:`warp.grad` for polarity and thickness potentials.
Soft-sphere gradients remain explicit. Epithelial polarity position-gradient
components use the same soft-force clamp as :func:`sticky_sphere_grads`.
"""
tid = wp.tid()
i = wp.hash_grid_point_id(grid, tid)
x_i = X[i]
r_i = R[i]
c_i = CT[i]
p_i = P[i]
for j in wp.hash_grid_query(grid, x_i, query_radius):
if j <= i:
continue
x_j = X[j]
r_j = R[j]
c_j = CT[j]
p_j = P[j]
grad_x_i_f, grad_x_j_f = sticky_sphere_forces(x_i, x_j, r_i, r_j, c_i, c_j)
wp.atomic_add(f_net, i, grad_x_i_f)
wp.atomic_add(f_net, j, grad_x_j_f)
dist = wp.norm_l2(x_i - x_j)
w = adj_weight(dist, r_i, r_j)
if not wp.bool(w):
continue
if (c_i == wp.uint32(1)) and (c_j == wp.uint32(1)):
grad_x_i_p, grad_x_j_p, grad_p_i, grad_p_j = wp.grad(epi_polarity_potential)(
x_i, x_j, p_i, p_j
)
grad_x_i_t, grad_x_j_t, _, _ = wp.grad(epi_thickness_potential)(x_i, x_j, p_i, p_j)
for k in range(3):
v_i = wp.max(wp.abs(grad_x_i_f[k]), wp.float32(1e-3))
v_j = wp.max(wp.abs(grad_x_j_f[k]), wp.float32(1e-3))
grad_x_i_p[k] = wp.clamp(grad_x_i_p[k], -1.0 * v_i, 1.0 * v_i)
grad_x_j_p[k] = wp.clamp(grad_x_j_p[k], -1.0 * v_j, 1.0 * v_j)
wp.atomic_add(f_net, i, grad_x_i_p)
wp.atomic_add(f_net, j, grad_x_j_p)
wp.atomic_add(f_net, i, grad_x_i_t)
wp.atomic_add(f_net, j, grad_x_j_t)
wp.atomic_add(f_pol, i, grad_p_i)
wp.atomic_add(f_pol, j, grad_p_j)
if (c_i == wp.uint32(0)) and (c_j == wp.uint32(0)):
grad_p_i, grad_p_j = wp.grad(mes_polarity_potential)(p_i, p_j)
wp.atomic_add(f_pol, i, grad_p_i)
wp.atomic_add(f_pol, j, grad_p_j)
@wp.kernel(enable_backward=False)
def sticky_sphere_wnt_grads_implicit(
grid: wp.uint64,
X: wp.array(dtype=wp.vec3f),
R: wp.array(dtype=wp.float32),
P: wp.array(dtype=wp.vec3f),
CT: wp.array(dtype=wp.uint32),
WNT: wp.array(dtype=wp.float32),
query_radius: wp.float32,
gp: wp.array(dtype=wp.vec3f),
):
"""Accumulate WNT alignment with a local :func:`warp.grad` derivative."""
tid = wp.tid()
i = wp.hash_grid_point_id(grid, tid)
x_i = X[i]
r_i = R[i]
c_i = CT[i]
p_i = P[i]
w_i = safe_div(WNT[i], volume_from_radius(r_i))
for j in wp.hash_grid_query(grid, x_i, query_radius):
if j <= i:
continue
x_j = X[j]
r_j = R[j]
dist = wp.norm_l2(x_i - x_j)
w = adj_weight(dist, r_i, r_j)
if not wp.bool(w):
continue
c_j = CT[j]
p_j = P[j]
w_j = safe_div(WNT[j], volume_from_radius(r_j))
dist_safe = dist + EPS_NORM
if (c_i == wp.uint32(0)) and (w_j > w_i):
u_i_to_j = (x_j - x_i) / dist_safe
grad_p_i, _grad_u_i, _grad_w_i = wp.grad(mes_wnt_polarity_potential)(p_i, u_i_to_j, w_j)
wp.atomic_add(gp, i, grad_p_i)
if (c_j == wp.uint32(0)) and (w_i > w_j):
u_j_to_i = (x_i - x_j) / dist_safe
grad_p_j, _grad_u_j, _grad_w_j = wp.grad(mes_wnt_polarity_potential)(p_j, u_j_to_i, w_i)
wp.atomic_add(gp, j, grad_p_j)
[docs]
def mech_step_sticky_implicit(
X: wp.array(dtype=wp.vec3f),
R: wp.array(dtype=wp.float32),
P: wp.array(dtype=wp.vec3f),
CT: wp.array(dtype=wp.uint32),
particle_count: wp.int32,
dt: float,
X_next: wp.array(dtype=wp.vec3f),
P_next: wp.array(dtype=wp.vec3f),
device: str = "cuda",
grid: "wp.HashGrid | None" = None,
*,
wnt: "wp.array | None" = None,
):
"""Advance mechanics with local autodiff for polarity and thickness potentials."""
f_net = wp.zeros_like(X, device=device)
f_pol = wp.zeros_like(P, device=device)
r_max = float(R.numpy()[:particle_count].max())
query_radius = 2.0 * r_max + ATR_EE_CUTOFF
if grid is None:
grid = wp.HashGrid(HASH_GRID_DIM, HASH_GRID_DIM, HASH_GRID_DIM, device=device)
grid.build(X[:particle_count], query_radius)
wp.launch(
sticky_sphere_grads_implicit,
dim=particle_count,
inputs=[wp.uint64(grid.id), X, R, P, CT, query_radius],
outputs=[f_net, f_pol],
device=device,
)
if wnt is not None:
wp.launch(
sticky_sphere_wnt_grads_implicit,
dim=particle_count,
inputs=[wp.uint64(grid.id), X, R, P, CT, wnt, query_radius],
outputs=[f_pol],
device=device,
)
wp.launch(
gd_update,
dim=particle_count,
inputs=[X, f_net, dt, X_next],
device=device,
)
wp.launch(
gd_update_normalized,
dim=particle_count,
inputs=[P, f_pol, dt, P_next],
device=device,
)
return f_net
@wp.kernel
def reaction_diffs(
grid: wp.uint64,
X: wp.array(dtype=wp.vec3f),
R: wp.array(dtype=wp.float32),
query_radius: wp.float32,
A: wp.array(dtype=wp.float32),
I: wp.array(dtype=wp.float32),
lapA: wp.array(dtype=wp.float32),
lapI: wp.array(dtype=wp.float32),
):
"""Accumulate symmetric contact-graph diffusion for activator and inhibitor."""
tid = wp.tid()
i = wp.hash_grid_point_id(grid, tid)
x_i = X[i]
Ri = R[i]
Vi = volume_from_radius(Ri)
cAi = safe_div(A[i], Vi)
cIi = safe_div(I[i], Vi)
for j in wp.hash_grid_query(grid, x_i, query_radius):
if j <= i:
continue
Rj = R[j]
dist = wp.norm_l2(x_i - X[j])
w = adj_weight(dist, Ri, Rj)
if not wp.bool(w):
continue
Vj = volume_from_radius(Rj)
cAj = safe_div(A[j], Vj)
cIj = safe_div(I[j], Vj)
dA = wp.float32(w) * (cAj - cAi)
dI = wp.float32(w) * (cIj - cIi)
wp.atomic_add(lapA, i, dA)
wp.atomic_add(lapA, j, -dA)
wp.atomic_add(lapI, i, dI)
wp.atomic_add(lapI, j, -dI)
@wp.kernel
def reaction_step(
A: wp.array(dtype=wp.float32),
I: wp.array(dtype=wp.float32),
R: wp.array(dtype=wp.float32),
lapA: wp.array(dtype=wp.float32),
lapI: wp.array(dtype=wp.float32),
chi: wp.array(dtype=wp.float32),
gamma: wp.array(dtype=wp.float32),
D_inhib: wp.float32,
dt: wp.float32,
A_next: wp.array(dtype=wp.float32),
I_next: wp.array(dtype=wp.float32),
):
r"""Advance the activator-inhibitor system by one bounded discrete step.
With regularized concentrations :math:`a=A/(V+EPS_DEN)`,
:math:`i=I/(V+EPS_DEN)`, and contact fluxes
:math:`\ell_A=\sum_j(a_j-a_i)`, :math:`\ell_I=\sum_j(i_j-i_i)`,
.. math::
A_i' = \operatorname{clip}\!\left(
\frac{A_i+dt\,\gamma[\chi D_I\ell_A+
\min(\frac{a_i^2}{i_i+EPS_DEN},
\frac{a_i^2}{i_i^2+EPS_DEN})]}{1+dt\,\gamma},0,10^4\right),
I_i' = \operatorname{clip}\!\left(
\frac{I_i+dt\,\gamma[D_I\ell_I+a_i^2]}{1+dt\,\gamma},0,10^4\right).
The production cap, semi-implicit linear damping, and output clamp differ
from an uncapped explicit reaction-diffusion ODE.
"""
i = wp.tid()
V = volume_from_radius(R[i])
cA = safe_div(A[i], V)
cI = safe_div(I[i], V)
cA2 = cA * cA
ciI = cI
prodA_lin = safe_div(cA2, ciI)
prodA_quad = safe_div(cA2, ciI * ciI)
prodA = prodA_lin
if wp.float32(prodA_quad) < wp.float32(prodA_lin):
prodA = prodA_quad
prodI = cA2
diffA = (chi[0] * D_inhib) * lapA[i]
diffI = D_inhib * lapI[i]
zAi = A[i] + dt * gamma[0] * (diffA + prodA)
zIi = I[i] + dt * gamma[0] * (diffI + prodI)
inv = 1.0 / (1.0 + dt * gamma[0])
Ai_next = zAi * inv
Ii_next = zIi * inv
A_next[i] = wp.clamp(Ai_next, 0.0, 1e4)
I_next[i] = wp.clamp(Ii_next, 0.0, 1e4)
@wp.kernel
def reaction_step_masked(
A: wp.array(dtype=wp.float32),
I: wp.array(dtype=wp.float32),
R: wp.array(dtype=wp.float32),
lapA: wp.array(dtype=wp.float32),
lapI: wp.array(dtype=wp.float32),
chi: wp.array(dtype=wp.float32),
gamma: wp.array(dtype=wp.float32),
D_inhib: wp.float32,
dt: wp.float32,
CT: wp.array(dtype=wp.uint32),
reaction_cell_type: wp.uint32,
A_next: wp.array(dtype=wp.float32),
I_next: wp.array(dtype=wp.float32),
):
"""Apply nonlinear reaction to the selected cell type and diffusion to the remaining cells.
All cells diffuse; unmatched cells receive pure diffusion. Matched cells use
the same production cap, semi-implicit damping, and output clamp as
:func:`reaction_step`.
"""
i = wp.tid()
diffA = (chi[0] * D_inhib) * lapA[i]
diffI = D_inhib * lapI[i]
if CT[i] != reaction_cell_type:
Ai_next = A[i] + dt * gamma[0] * diffA
Ii_next = I[i] + dt * gamma[0] * diffI
A_next[i] = wp.clamp(Ai_next, 0.0, 1e4)
I_next[i] = wp.clamp(Ii_next, 0.0, 1e4)
return
V = volume_from_radius(R[i])
cA = safe_div(A[i], V)
cI = safe_div(I[i], V)
cA2 = cA * cA
ciI = cI
prodA_lin = safe_div(cA2, ciI)
prodA_quad = safe_div(cA2, ciI * ciI)
prodA = prodA_lin
if wp.float32(prodA_quad) < wp.float32(prodA_lin):
prodA = prodA_quad
prodI = cA2
zAi = A[i] + dt * gamma[0] * (diffA + prodA)
zIi = I[i] + dt * gamma[0] * (diffI + prodI)
inv = 1.0 / (1.0 + dt * gamma[0])
Ai_next = zAi * inv
Ii_next = zIi * inv
A_next[i] = wp.clamp(Ai_next, 0.0, 1e4)
I_next[i] = wp.clamp(Ii_next, 0.0, 1e4)
[docs]
def chem_step(
A: wp.array,
I: wp.array,
X: wp.array,
R: wp.array,
lapA: wp.array,
lapI: wp.array,
chi: wp.array,
gamma: wp.array,
D_inhib: float,
dt: float,
particle_count: int,
A_next: wp.array,
I_next: wp.array,
device: str = "cuda",
grad_consist: bool = True,
grid: "wp.HashGrid | None" = None,
*,
CT: "wp.array | None" = None,
reaction_cell_type: int | None = None,
):
"""Run contact diffusion followed by masked or unmasked reaction.
``reaction_cell_type`` restricts nonlinear reaction while retaining
diffusion for every cell and requires ``CT``. ``grad_consist`` emits replay
markers. ``lapA`` and ``lapI`` must be zeroed by the caller because pair
fluxes accumulate atomically.
"""
if reaction_cell_type is not None and CT is None:
raise ValueError("CT must be provided when reaction_cell_type is set")
r_max = float(R.numpy()[:particle_count].max())
query_radius = 2.0 * r_max + EPS_DIST
if grid is None:
grid = wp.HashGrid(HASH_GRID_DIM, HASH_GRID_DIM, HASH_GRID_DIM, device=device)
grid.build(X[:particle_count], query_radius)
wp.launch(
reaction_diffs,
dim=particle_count,
inputs=[wp.uint64(grid.id), X, R, query_radius, A, I],
outputs=[lapA, lapI],
device=device,
)
if grad_consist:
lapA.mark_write()
lapI.mark_write()
if reaction_cell_type is None:
wp.launch(
reaction_step,
dim=particle_count,
inputs=[A, I, R, lapA, lapI, chi, gamma, D_inhib, dt],
outputs=[A_next, I_next],
device=device,
)
else:
wp.launch(
reaction_step_masked,
dim=particle_count,
inputs=[
A,
I,
R,
lapA,
lapI,
chi,
gamma,
D_inhib,
dt,
CT,
wp.uint32(reaction_cell_type),
],
outputs=[A_next, I_next],
device=device,
)
if grad_consist:
lapA.mark_read()
lapI.mark_read()
A.mark_read()
I.mark_read()
A_next.mark_write()
I_next.mark_write()
@wp.kernel
def count_neighbors(
grid: wp.uint64,
X: wp.array(dtype=wp.vec3f),
R: wp.array(dtype=wp.float32),
CT: wp.array(dtype=wp.uint32),
query_radius: wp.float32,
n_tot: wp.array(dtype=wp.int32),
n_epi: wp.array(dtype=wp.int32),
n_mes: wp.array(dtype=wp.int32),
):
tid = wp.tid()
i = wp.hash_grid_point_id(grid, tid)
x_i = X[i]
r_i = R[i]
for j in wp.hash_grid_query(grid, x_i, query_radius):
if j <= i:
continue
r_j = R[j]
dist = wp.norm_l2(x_i - X[j])
w = adj_weight(dist, r_i, r_j)
if not wp.bool(w):
continue
wp.atomic_add(n_tot, i, 1)
wp.atomic_add(n_tot, j, 1)
if CT[j] == wp.uint32(1):
wp.atomic_add(n_epi, i, 1)
else:
wp.atomic_add(n_mes, i, 1)
if CT[i] == wp.uint32(1):
wp.atomic_add(n_epi, j, 1)
else:
wp.atomic_add(n_mes, j, 1)
[docs]
def count_neighbors_step(
X: wp.array,
R: wp.array,
CT: wp.array,
particle_count: int,
n_tot: wp.array,
n_epi: wp.array,
n_mes: wp.array,
device: str = "cuda",
grid: "wp.HashGrid | None" = None,
) -> None:
"""Atomically accumulate into caller-zeroed neighbor counters."""
r_max = float(R.numpy()[:particle_count].max())
query_radius = 2.0 * r_max + EPS_DIST
if grid is None:
grid = wp.HashGrid(HASH_GRID_DIM, HASH_GRID_DIM, HASH_GRID_DIM, device=device)
grid.build(X[:particle_count], query_radius)
wp.launch(
count_neighbors,
dim=particle_count,
inputs=[wp.uint64(grid.id), X, R, CT, query_radius],
outputs=[n_tot, n_epi, n_mes],
device=device,
)
[docs]
def growth_step(
R: wp.array(dtype=wp.float32),
R_eq: wp.array(dtype=wp.float32),
A: wp.array(dtype=wp.float32),
CT: wp.array(dtype=wp.uint32),
keys: wp.array(dtype=wp.uint32),
alpha_grow: wp.array(dtype=wp.float32),
ell_sw: wp.array(dtype=wp.float32),
dt: float,
R_ref: wp.float32,
R_max: wp.float32,
particle_count: int,
R_next: wp.array(dtype=wp.float32),
R_eq_next: wp.array(dtype=wp.float32),
device: str = "cuda",
grad_consist: bool = True,
) -> None:
r"""Apply bounded growth in model units.
Mesenchymal equilibrium radii use a stochastic rate in ``[0.8, 1)`` and
.. math::
\dot r_i^{eq}=\lambda_i\frac{a_i^{\alpha}}
{\ell_{sw}^{\alpha}+a_i^{\alpha}+EPS_DEN},\qquad
\dot r_i=(1-r_i/r_i^{eq})^2.
Here :math:`a_i=A_i/(V_i+EPS_DEN)`; the physical-radius ratio is regularized
by the same denominator constant. Physical radii below their target advance
toward it and stop at that bound. Mesenchymal keys advance in place.
Mesenchymal targets are capped at ``R_max``; epithelial targets copy through
and physical radii approach ``R_ref``. ``dt`` and ``R_max`` must be finite and
nonnegative; ``R_ref`` must be finite and positive.
"""
if not _isfinite(dt) or dt < 0:
raise ValueError("dt must be finite and nonnegative")
if not _isfinite(R_max) or R_max < 0:
raise ValueError("R_max must be finite and nonnegative")
if not _isfinite(R_ref) or R_ref <= 0:
raise ValueError("R_ref must be finite and positive")
wp.launch(
growth_step_inner,
dim=particle_count,
inputs=[R, R_eq, A, CT, keys, alpha_grow, ell_sw, dt, R_ref, R_max, R_next, R_eq_next],
device=device,
)
if grad_consist:
R.mark_read()
R_eq.mark_read()
R_next.mark_write()
R_eq_next.mark_write()
@wp.kernel
def growth_step_inner(
R: wp.array(dtype=wp.float32),
R_eq: wp.array(dtype=wp.float32),
A: wp.array(dtype=wp.float32),
CT: wp.array(dtype=wp.uint32),
keys: wp.array(dtype=wp.uint32),
alpha_grow: wp.array(dtype=wp.float32),
ell_sw: wp.array(dtype=wp.float32),
dt: wp.float32,
R_ref: wp.float32,
R_max: wp.float32,
R_next: wp.array(dtype=wp.float32),
R_eq_next: wp.array(dtype=wp.float32),
) -> None:
i = wp.tid()
if CT[i] == wp.uint32(0):
r_i = R[i]
target = wp.min(R_eq[i], R_max)
V = volume_from_radius(R[i])
num = safe_div(A[i], V) ** alpha_grow[0]
key = keys[i]
lam = wp.randf(key, 0.8, 1.0)
key = wp.randu(key)
frac = safe_div(num, (ell_sw[0] ** alpha_grow[0]) + num)
R_eq_next[i] = wp.min(target + frac * lam * dt, R_max)
if target > 0.0 and r_i < target:
increment = ((1.0 - safe_div(r_i, target)) ** 2.0) * dt
R_next[i] = wp.min(r_i + increment, target)
else:
R_next[i] = r_i
keys[i] = key
if CT[i] == wp.uint32(1):
r_i = R[i]
R_eq_next[i] = R_eq[i]
if R_ref > 0.0 and r_i < R_ref:
increment = ((1.0 - safe_div(r_i, R_ref)) ** 2.0) * dt
R_next[i] = wp.min(r_i + increment, R_ref)
else:
R_next[i] = r_i
@wp.func
def st_gumbel_softmax_bernoulli(
p: wp.float32,
key: wp.uint32,
t: wp.float32,
tmax: wp.float32,
tau: wp.float32,
):
"""Return hard Gumbel-Softmax Bernoulli sample and soft relaxation.
``p`` is clamped to ``[RAND_EPS, 1-RAND_EPS]``. For positive ``tau`` and
``tmax``, the sampled temperature is
``max(0.1, tau * exp(-log(tau / 0.1) * t / tmax))``.
:func:`division_decision` bases division on the hard sample; the soft relaxation remains
available to callers.
"""
p = wp.clamp(p, RAND_EPS, 1.0 - RAND_EPS)
key, g0 = gumbel(key)
key, g1 = gumbel(key)
k = wp.log(tau / 0.1) / tmax
tau = wp.max(0.1, tau * wp.exp(-k * t))
p0 = (g1 + wp.log(1.0 - p)) / tau
p1 = (g0 + wp.log(p)) / tau
v = softmax2d(wp.vec2f(p0, p1))
s = wp.dot(v, wp.vec2f(0.0, 1.0))
s_straight = wp.int32(wp.argmax(v))
return key, s_straight, s
@wp.func
def _reserve_division_slot(
div_count: wp.array(dtype=wp.int32), max_particles: wp.int32
) -> wp.int32:
current = div_count[0]
while current < max_particles:
observed = wp.atomic_cas(div_count, wp.int32(0), current, current + wp.int32(1))
if observed == current:
return current
current = observed
return wp.int32(-1)
@wp.kernel
def division_decision(
X: wp.array(dtype=wp.vec3f),
R: wp.array(dtype=wp.float32),
CT: wp.array(dtype=wp.uint32),
keys: wp.array(dtype=wp.uint32),
n_epi: wp.array(dtype=wp.int32),
n_mes: wp.array(dtype=wp.int32),
div_count: wp.array(dtype=wp.int32),
div_slots: wp.array(dtype=wp.int32),
R_div_ref: wp.float32,
p_epi: wp.float32,
epi_max_neighbors: wp.int32,
t: wp.float32,
tmax: wp.float32,
tau: wp.float32,
max_particles: wp.int32,
):
r"""Sample divisions and atomically reserve bounded daughter slots.
Mesenchymal eligibility follows the radius Hill probability. Epithelial
eligibility uses ``p_epi`` beside mesenchyme and below ``epi_max_neighbors``
epithelial neighbors. Accepted parents receive unique indices below
``max_particles``; the counter stays within capacity. ``div_count[0]`` must
identify the first free slot, launched ``div_slots`` entries must start at
``-1``, and state arrays used by the division kernels must have at least
``max_particles`` rows. Eligible sampled cells advance their keys in place.
"""
parent = wp.tid()
key = keys[parent]
p = wp.float32(0.0)
if CT[parent] == wp.uint32(1):
if n_mes[parent] <= wp.int32(0):
keys[parent] = key
return
if n_epi[parent] >= wp.int32(epi_max_neighbors):
keys[parent] = key
return
p = p_epi
else:
p = probs(R[parent], R_div_ref)
key, s_hard, _s_soft = st_gumbel_softmax_bernoulli(p, key, t, tmax, tau)
keys[parent] = key
if s_hard == wp.int32(0):
return
child = _reserve_division_slot(div_count, max_particles)
if child < wp.int32(0):
return
div_slots[parent] = child
@wp.kernel
def division_logic(
X: wp.array(dtype=wp.vec3f),
R: wp.array(dtype=wp.float32),
R_eq: wp.array(dtype=wp.float32),
A: wp.array(dtype=wp.float32),
I: wp.array(dtype=wp.float32),
P: wp.array(dtype=wp.vec3f),
CT: wp.array(dtype=wp.uint32),
div_slots: wp.array(dtype=wp.int32),
):
"""Split accepted parents along a polarity-perpendicular surface.
Both daughters inherit type and polarity and receive half the chemical
abundance. Mesenchymal physical radii conserve half-volume; epithelial
physical radii copy. Both equilibrium radii reset to the resulting daughter
radius. Under the unit-polarity caller contract, each center moves
``1.02 * daughter_radius`` along a perpendicular axis, giving separation
``2.04 * daughter_radius``.
"""
parent = wp.tid()
child = div_slots[parent]
if child == -1:
return
a_p, i_p = A[parent], I[parent]
A[parent] = 0.5 * a_p
A[child] = 0.5 * a_p
I[parent] = 0.5 * i_p
I[child] = 0.5 * i_p
ct = CT[parent]
CT[child] = ct
r = R[parent]
if ct == wp.uint32(0):
r = r / wp.cbrt(2.0)
R[child] = r
R_eq[child] = r
R[parent] = r
R_eq[parent] = r
v = P[parent]
P[child] = v
a = wp.vec3f(1.0, 0.0, 0.0)
if wp.abs(v[0]) > wp.float32(0.9):
a = wp.vec3f(0.0, 1.0, 0.0)
u = wp.normalize(wp.cross(v, a))
x = X[parent]
sep = 1.02 * r
X[parent] = x + u * sep
X[child] = x - u * sep
@wp.kernel
def division_logic_mes_polarity(
X: wp.array(dtype=wp.vec3f),
R: wp.array(dtype=wp.float32),
R_eq: wp.array(dtype=wp.float32),
A: wp.array(dtype=wp.float32),
I: wp.array(dtype=wp.float32),
P: wp.array(dtype=wp.vec3f),
CT: wp.array(dtype=wp.uint32),
div_slots: wp.array(dtype=wp.int32),
):
"""Split mesenchymal daughters along polarity and epithelial daughters across it.
Inheritance, chemistry, radii, and separation distance match
:func:`division_logic`; mesenchymal separation follows polarity.
"""
parent = wp.tid()
child = div_slots[parent]
if child == -1:
return
a_p, i_p = A[parent], I[parent]
A[parent] = 0.5 * a_p
A[child] = 0.5 * a_p
I[parent] = 0.5 * i_p
I[child] = 0.5 * i_p
ct = CT[parent]
CT[child] = ct
r = R[parent]
if ct == wp.uint32(0):
r = r / wp.cbrt(2.0)
R[child] = r
R_eq[child] = r
R[parent] = r
R_eq[parent] = r
v = P[parent]
P[child] = v
u = wp.normalize(v)
if ct == wp.uint32(1):
a = wp.vec3f(1.0, 0.0, 0.0)
if wp.abs(v[0]) > wp.float32(0.9):
a = wp.vec3f(0.0, 1.0, 0.0)
u = wp.normalize(wp.cross(v, a))
x = X[parent]
sep = 1.02 * r
X[parent] = x + u * sep
X[child] = x - u * sep