Skip to content

Commit 4bd5c06

Browse files
committed
[Iter 312] Code modification in PDE_D_RatioCTC.py
[Automated commit by Claude]
1 parent 2b312ac commit 4bd5c06

1 file changed

Lines changed: 354 additions & 0 deletions

File tree

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
import torch
2+
import torch_geometric as pyg
3+
import torch_geometric.utils as pyg_utils
4+
from ParticleGraph.utils import to_numpy
5+
6+
class PDE_D_RatioCTC(pyg.nn.MessagePassing):
7+
"""
8+
CTC using C1/C2 concentration ratio instead of C1 alone, with pp damping.
9+
10+
Hypothesis: Standard CTC uses only C1 for threshold coupling, wasting the
11+
C2 information. In Brusselator dynamics, C1 and C2 are anti-correlated at
12+
Turing peaks (high C1 = low C2). The ratio C1/C2 amplifies the signal-to-noise
13+
of positional information, potentially providing sharper threshold sensing.
14+
15+
At Brusselator steady state: C1=A, C2=B/A, so ratio = A^2/B.
16+
At Turing peaks: C1 high, C2 low -> ratio >> A^2/B.
17+
At Turing troughs: C1 low, C2 high -> ratio << A^2/B.
18+
19+
The CTC threshold is set on the ratio: T_ratio = ctc_threshold * A^2/B.
20+
Particles move toward/away from T_ratio isoline.
21+
22+
pp damping uses the SAME ratio-based distance from threshold, providing
23+
consistent position sensing across both fp and pp channels.
24+
25+
Also includes velocity-dependent fp drag for 2-type compatibility.
26+
27+
Physics:
28+
1. fp: Durotaxis + ratio-CTC + velocity drag
29+
R = C1 / (C2 + eps)
30+
R_ref = A^2 / B (steady-state ratio)
31+
T_ratio = ctc_threshold * R_ref
32+
v = M * (1+alpha*|gradC1|) * (-tanh(steep*(R - T_ratio)/R_ref)) * grad * dir
33+
v_damped = v / (1 + fp_drag * |vel_i| / v_ref)
34+
2. pf: Standard consumption/production
35+
3. pp: Ratio-damped attraction-repulsion
36+
f_pp = f_standard * (1 - damping * exp(-(R - T_ratio)^2 / (2*(width*R_ref)^2)))
37+
38+
Literature:
39+
- Wolpert, L. (1969) J Theor Biol 25:1-47
40+
"Positional information and the spatial pattern of cellular differentiation"
41+
- Lo, C. M. et al. (2000) Biophysical Journal 79:144-152
42+
- Painter, K. J. & Hillen, T. (2002) Can Appl Math Q 10(4):501-543
43+
- Tranquillo, R. T. & Lauffenburger, D. A. (1987) J Math Biol 25:229-262
44+
- Meinhardt, H. (1982) Models of Biological Pattern Formation, Academic Press
45+
(ratio-based positional information)
46+
47+
Per-type params layout: [M1, M2, consumption, production, ar_p1, ar_p2, ar_p3, ar_p4]
48+
"""
49+
50+
PARAMS_DOC = {
51+
"model_name": "RatioCTC",
52+
"literature": "Wolpert (1969); Meinhardt (1982); Lo (2000); Painter & Hillen (2002); Tranquillo (1987)",
53+
"description": "CTC using C1/C2 ratio for positional sensing + ratio-based pp damping + optional fp drag",
54+
"equations": {
55+
"field_to_particle": "v = M*(1+alpha*|gradC1|)*(-tanh(3*(R-T_ratio)/R_ref))*grad*dir / (1+fp_drag*|vel|/v_ref)",
56+
"ratio": "R = C1/(C2+eps), R_ref = A^2/B, T_ratio = ctc_threshold * R_ref",
57+
"particle_to_field": "dC1 = -consumption * w(r), dC2 = production * w(r)",
58+
"particle_to_particle": "f = f_AR * (1 - damping * exp(-(R-T_ratio)^2 / (2*(width*R_ref)^2)))"
59+
},
60+
"params_mesh": [
61+
{
62+
"row": 0, "description": "C1 field parameters + CTC threshold",
63+
"slots": [
64+
{"index": 0, "name": "D1", "description": "Diffusion coeff for C1"},
65+
{"index": 1, "name": "Da_c", "description": "Damkohler number"},
66+
{"index": 2, "name": "A", "description": "Brusselator A"},
67+
{"index": 3, "name": "B", "description": "Brusselator B"},
68+
{"index": 4, "name": "mu", "description": "Morphological param"},
69+
{"index": 5, "name": "M1", "description": "Mobility for C1 gradients"},
70+
{"index": 6, "name": "grad_amp_alpha", "description": "Durotaxis amplification"},
71+
{"index": 7, "name": "ctc_threshold", "description": "Ratio CTC threshold (T_ratio=ctc*A^2/B)"}
72+
]
73+
},
74+
{
75+
"row": 1, "description": "C2 field + pp damping params",
76+
"slots": [
77+
{"index": 0, "name": "D2", "description": "Diffusion coeff for C2"},
78+
{"index": 1, "name": "M2", "description": "Mobility for C2 gradients"},
79+
{"index": 2, "name": "pp_damping", "description": "pp damping strength near ratio threshold"},
80+
{"index": 3, "name": "pp_damping_width", "description": "Width of ratio damping zone (units of R_ref)"}
81+
]
82+
},
83+
{
84+
"row": 2, "description": "Particle-field coupling + fp drag",
85+
"slots": [
86+
{"index": 0, "name": "Pe", "description": "Peclet number"},
87+
{"index": 1, "name": "consumption", "description": "Consumption rate of C1"},
88+
{"index": 2, "name": "production", "description": "Production rate of C2"},
89+
{"index": 3, "name": "influence_radius", "description": "Gaussian pf influence radius"},
90+
{"index": 4, "name": "fp_drag", "description": "Velocity-dependent fp drag (0=off)"},
91+
{"index": 5, "name": "cross_type_factor", "description": "Per-type ratio threshold spread"}
92+
]
93+
}
94+
],
95+
"width_constraint": "ALL rows of params_mesh MUST have same number of columns (8). Pad shorter rows."
96+
}
97+
98+
def __init__(self, aggr_type='mean', p=None, particle_params=None, bc_dpos=None, dimension=2, sigma=0.005):
99+
super(PDE_D_RatioCTC, self).__init__(aggr=aggr_type)
100+
101+
self.p = p
102+
self.particle_params = particle_params
103+
self.bc_dpos = bc_dpos
104+
self.dimension = dimension
105+
self.sigma = sigma
106+
107+
self.M1 = p[0, 5]
108+
self.M2 = p[1, 1]
109+
self.consumption_rate = p[2, 1]
110+
self.production_rate = p[2, 2]
111+
self.influence_radius = p[2, 3]
112+
self.Pe = p[2, 0]
113+
self.repulsion_strength = 50
114+
self.repulsion_range = 0.04
115+
116+
# Durotaxis
117+
self.grad_amp_alpha = p[0, 6] if p.shape[1] > 6 else 0.0
118+
119+
# Brusselator parameters for ratio reference
120+
self.A_ref = p[0, 2] # A
121+
self.B_ref = p[0, 3] # B
122+
# Steady-state ratio: R_ref = A^2/B
123+
self.R_ref = self.A_ref ** 2 / (self.B_ref + 1e-6)
124+
125+
# CTC threshold on ratio
126+
self.ctc_threshold = p[0, 7] if p.shape[1] > 7 else 0.0
127+
# T_ratio = ctc_threshold * R_ref
128+
self.T_ratio = self.ctc_threshold * self.R_ref
129+
130+
# Per-type threshold spread
131+
self.cross_type_factor = p[2, 5] if p.shape[1] > 5 else 0.0
132+
133+
# pp damping parameters
134+
self.pp_damping = p[1, 2] if p.shape[1] > 2 else 0.0
135+
self.pp_damping_width = p[1, 3] if p.shape[1] > 3 else 0.5
136+
137+
# Velocity-dependent fp drag
138+
self.fp_drag = p[2, 4] if p.shape[1] > 4 else 0.0
139+
self.v_ref = 0.01
140+
141+
print(f"initialized PDE_D_RatioCTC with parameters:")
142+
print(f" mobility: M1={self.M1.item()}, M2={self.M2.item()}")
143+
ga_val = self.grad_amp_alpha.item() if hasattr(self.grad_amp_alpha, 'item') else self.grad_amp_alpha
144+
print(f" grad_amp_alpha={ga_val:.3f} (durotaxis, Lo 2000)")
145+
print(f" Brusselator: A={self.A_ref.item():.2f}, B={self.B_ref.item():.2f}")
146+
print(f" R_ref (steady-state ratio A^2/B) = {self.R_ref.item():.4f}")
147+
ctc_val = self.ctc_threshold.item() if hasattr(self.ctc_threshold, 'item') else self.ctc_threshold
148+
print(f" ctc_threshold={ctc_val:.3f}, T_ratio={self.T_ratio.item():.4f} (Meinhardt 1982)")
149+
damp_val = self.pp_damping.item() if hasattr(self.pp_damping, 'item') else self.pp_damping
150+
damp_w = self.pp_damping_width.item() if hasattr(self.pp_damping_width, 'item') else self.pp_damping_width
151+
print(f" pp_damping={damp_val:.3f}, pp_damping_width={damp_w:.3f} (ratio-based, Painter & Hillen 2002)")
152+
fp_drag_val = self.fp_drag.item() if hasattr(self.fp_drag, 'item') else self.fp_drag
153+
print(f" fp_drag={fp_drag_val:.3f}, v_ref={self.v_ref:.4f} (Tranquillo 1987)")
154+
ctf_val = self.cross_type_factor.item() if hasattr(self.cross_type_factor, 'item') else self.cross_type_factor
155+
if ctf_val > 0 and particle_params is not None:
156+
n_types = particle_params.shape[0]
157+
mean_idx = (n_types - 1) / 2.0
158+
for t in range(n_types):
159+
t_offset = ctf_val * (t - mean_idx)
160+
t_ratio = self.T_ratio.item() * (1.0 + t_offset)
161+
print(f" Type {t}: ratio threshold = {t_ratio:.4f} (offset={t_offset:+.2f})")
162+
print(f" Pe={self.Pe.item():.3f}, sigma={self.sigma}")
163+
print(f" particle->field: consumption={self.consumption_rate.item()}, production={self.production_rate.item()}, influence_radius={self.influence_radius.item():.3f}")
164+
if particle_params is not None:
165+
print(f" multi-type support: {particle_params.shape[0]} particle types")
166+
167+
def forward(self, data, direction='fp'):
168+
x, edge_index = data.x, data.edge_index
169+
edge_index, _ = pyg_utils.remove_self_loops(edge_index)
170+
171+
if self.particle_params is not None:
172+
particle_type = x[:, 1 + 2*self.dimension].long()
173+
max_type = particle_type.max().item()
174+
n_param_rows = self.particle_params.shape[0]
175+
if max_type >= n_param_rows:
176+
raise ValueError(
177+
f"PDE_D_RatioCTC: particle_params has {n_param_rows} rows but found "
178+
f"particle type {max_type}. Need {max_type + 1} rows in simulation.params."
179+
)
180+
parameters = self.particle_params[to_numpy(particle_type), :]
181+
else:
182+
parameters = None
183+
184+
if direction == 'interpolate':
185+
result = self.propagate(edge_index, x=x, mode='interpolate', parameters=parameters)
186+
pos = x[:, 1:self.dimension+1]
187+
in_box = ((pos >= 0) & (pos <= 1)).all(dim=1, keepdim=True)
188+
result = result * in_box.float()
189+
return result
190+
elif direction == 'fp':
191+
result = self.propagate(edge_index, x=x, mode='fp', parameters=parameters)
192+
pos = x[:, 1:self.dimension+1]
193+
in_box = ((pos >= 0) & (pos <= 1)).all(dim=1, keepdim=True)
194+
result = result * in_box.float()
195+
return result
196+
elif direction == 'pf':
197+
result = self.propagate(edge_index, x=x, mode='pf', parameters=parameters)
198+
return result
199+
else:
200+
result = self.propagate(edge_index, x=x, mode='pp', parameters=parameters)
201+
return result
202+
203+
def message(self, edge_index_i, edge_index_j, x_i, x_j, mode=None, parameters_i=None):
204+
pos_i = x_i[:, 1:self.dimension+1]
205+
pos_j = x_j[:, 1:self.dimension+1]
206+
207+
d_pos = self.bc_dpos(pos_j - pos_i)
208+
dist = torch.sqrt(torch.sum(d_pos**2, dim=1))
209+
dist_safe = torch.clamp(dist, min=1e-6)
210+
211+
if mode == 'interpolate':
212+
C1_mesh = x_j[:, 6:7]
213+
C2_mesh = x_j[:, 7:8]
214+
weight = torch.exp(-dist / 0.01).unsqueeze(1)
215+
return torch.cat([C1_mesh * weight, C2_mesh * weight, weight], dim=1)
216+
217+
elif mode == 'fp':
218+
fields_i = x_i[:, 6:8]
219+
fields_j = x_j[:, 6:8]
220+
221+
dC1 = fields_j[:, 0:1] - fields_i[:, 0:1]
222+
dC2 = fields_j[:, 1:2] - fields_i[:, 1:2]
223+
224+
kernel = torch.exp(-dist / 0.05)
225+
dir_norm = d_pos / dist_safe.unsqueeze(1)
226+
domain_scale = 32.0
227+
grad_C1 = (dC1 * kernel.unsqueeze(1)) / (dist_safe.unsqueeze(1) * domain_scale)
228+
grad_C2 = (dC2 * kernel.unsqueeze(1)) / (dist_safe.unsqueeze(1) * domain_scale)
229+
230+
if parameters_i is not None:
231+
M1 = parameters_i[:, 0:1]
232+
M2 = parameters_i[:, 1:2]
233+
else:
234+
M1 = self.M1
235+
M2 = self.M2
236+
237+
velocity_raw = (M1 * grad_C1 + M2 * grad_C2) * dir_norm
238+
239+
# 1. Durotaxis (Lo et al. 2000)
240+
if self.grad_amp_alpha > 0:
241+
grad_mag = torch.abs(grad_C1)
242+
grad_mag_clamped = torch.clamp(grad_mag, max=1.0)
243+
amp_factor = 1.0 + self.grad_amp_alpha * grad_mag_clamped
244+
velocity_raw = velocity_raw * amp_factor
245+
246+
# 2. Ratio-CTC (Wolpert 1969 + Meinhardt 1982)
247+
if self.ctc_threshold > 0:
248+
C1_local = fields_i[:, 0:1]
249+
C2_local = fields_i[:, 1:2]
250+
251+
# Compute local ratio R = C1 / (C2 + eps)
252+
R_local = C1_local / (C2_local + 1e-4)
253+
254+
R_ref = self.R_ref
255+
base_T_ratio = self.T_ratio
256+
steepness = 3.0
257+
258+
# Per-type thresholds
259+
if (parameters_i is not None and self.cross_type_factor > 0
260+
and x_i.numel() > 0):
261+
type_i = x_i[:, 1 + 2*self.dimension].long()
262+
n_types = type_i.max().item() + 1 if type_i.numel() > 0 else 1
263+
mean_idx = (n_types - 1) / 2.0
264+
type_offset = self.cross_type_factor * (type_i.float() - mean_idx)
265+
T = base_T_ratio * (1.0 + type_offset.unsqueeze(1))
266+
else:
267+
T = base_T_ratio
268+
269+
sign_factor = -torch.tanh(steepness * (R_local - T) / (R_ref + 1e-6))
270+
velocity_raw = velocity_raw * sign_factor
271+
272+
# 3. Velocity-dependent fp drag (Tranquillo & Lauffenburger 1987)
273+
if self.fp_drag > 0:
274+
vel_i = x_i[:, 1+self.dimension:1+2*self.dimension]
275+
speed = torch.sqrt(torch.sum(vel_i**2, dim=1, keepdim=True))
276+
drag_factor = 1.0 / (1.0 + self.fp_drag * speed / self.v_ref)
277+
velocity_raw = velocity_raw * drag_factor
278+
279+
return velocity_raw
280+
281+
elif mode == 'pf':
282+
weights = torch.exp(-dist**2 / (2 * (self.influence_radius/3)**2))
283+
284+
if parameters_i is not None:
285+
consumption = parameters_i[:, 2]
286+
production = parameters_i[:, 3]
287+
else:
288+
consumption = self.consumption_rate
289+
production = self.production_rate
290+
291+
field_updates = torch.zeros((pos_i.size(0), 2), device=pos_i.device)
292+
field_updates[:, 0] = -consumption * weights
293+
field_updates[:, 1] = production * weights
294+
return field_updates
295+
296+
else: # mode == 'pp'
297+
if parameters_i is not None:
298+
p1 = parameters_i[:, 4]
299+
p2 = parameters_i[:, 5]
300+
p3 = parameters_i[:, 6]
301+
p4 = parameters_i[:, 7]
302+
303+
f = (p1 * torch.exp(-dist ** (2 * p2) / (2 * self.sigma ** 2))
304+
- p3 * torch.exp(-dist ** (2 * p4) / (2 * self.sigma ** 2)))
305+
306+
forces = f[:, None] * d_pos / dist_safe.unsqueeze(1)
307+
else:
308+
forces = torch.zeros_like(pos_i)
309+
in_range = dist < self.repulsion_range
310+
if in_range.any():
311+
dir_norm = d_pos / dist_safe.unsqueeze(1)
312+
repulsion_mag = self.repulsion_strength * torch.exp(
313+
-5.0 * dist[in_range] / self.repulsion_range
314+
)
315+
forces[in_range] = -dir_norm[in_range] * repulsion_mag.unsqueeze(1)
316+
317+
# Ratio-based pp damping (Painter & Hillen 2002)
318+
if self.pp_damping > 0 and self.ctc_threshold > 0:
319+
C1_local = x_i[:, 6:7].squeeze(1)
320+
C2_local = x_i[:, 7:8].squeeze(1)
321+
322+
# Local ratio
323+
R_local = C1_local / (C2_local + 1e-4)
324+
325+
R_ref = self.R_ref
326+
base_T_ratio = self.T_ratio
327+
328+
# Per-type threshold for damping zone
329+
if (parameters_i is not None and self.cross_type_factor > 0
330+
and x_i.numel() > 0):
331+
type_i = x_i[:, 1 + 2*self.dimension].long()
332+
n_types = type_i.max().item() + 1 if type_i.numel() > 0 else 1
333+
mean_idx = (n_types - 1) / 2.0
334+
type_offset = self.cross_type_factor * (type_i.float() - mean_idx)
335+
T_local = base_T_ratio * (1.0 + type_offset)
336+
else:
337+
T_local = base_T_ratio
338+
339+
width = self.pp_damping_width * R_ref
340+
deviation = (R_local - T_local)
341+
damping_factor = 1.0 - self.pp_damping * torch.exp(-deviation**2 / (2 * width**2 + 1e-8))
342+
forces = forces * damping_factor.unsqueeze(1)
343+
344+
return forces
345+
346+
def update(self, aggr_out, mode=None):
347+
if mode == 'interpolate':
348+
C1_weighted = aggr_out[:, 0:1]
349+
C2_weighted = aggr_out[:, 1:2]
350+
weight_sum = aggr_out[:, 2:3]
351+
weight_sum = torch.clamp(weight_sum, min=1e-10)
352+
return torch.cat([C1_weighted / weight_sum, C2_weighted / weight_sum], dim=1)
353+
else:
354+
return aggr_out

0 commit comments

Comments
 (0)