Cell division model#
https://juliadynamics.github.io/Agents.jl/stable/examples/delaunay/
Using JuliaDynamics/Agents.jl and JuliaGeometry/DelaunayTriangulation.jl
using Agents
using StaticArrays
import DelaunayTriangulation as DT
using DelaunayTriangulation
using LinearAlgebra
using StreamSampling
using Random
using StatsBase
using CairoMakie
CairoMakie.activate!(px_per_unit = 0.5)
Define agents (3 colors of cells)
@enum CellType begin
Red
Blue
Orange
end
@agent struct Cell(ContinuousAgent{2,Float64})
const color::CellType
const birth::Float64
death::Float64 = Inf
end
Defining methods for the agents
DT.getx(cell::Cell) = cell.pos[1]
DT.gety(cell::Cell) = cell.pos[2]
DT.number_type(::Type{Cell}) = Float64
DT.number_type(::Type{Vector{Cell}}) = Float64
DT.is_point2(::Cell) = true
Compute parameters for a pair of cells
spring_constant(model, i::Int, j::Int, t) = spring_constant(model, model[i], model[j], t)
function spring_constant(model, p::Cell, q::Cell, t; μ=20.0)
δ = norm(p.pos - q.pos)
s = rest_length(model, p, q, t)
t < 1 && return μ ## no adhesion for the initial population
μₕₑₜ = ifelse(p.color == q.color, 1.0, 0.1) ## heterotypic_spring_constant
return ifelse(δ > s, μₕₑₜ * μ, μ)
end
spring_constant (generic function with 2 methods)
rest_length(model, i::Int, j::Int, t) = rest_length(model, model[i], model[j]..., t)
function rest_length(model, p, q, t;
s=1.0, ## mature_cell_spring_rest_length
ε=0.05, ## expansion_rate
)
return min(s, (s - ε) * t + ε)
end
rest_length (generic function with 2 methods)
function proliferation_rate(model, i::Int, t; tₘᵢₙ = 1.0, K=100.0^2, Aₘᵢₙ=0.01)
p = model[i]
age = t - p.birth
tₘₐₓ = p.color == Red ? 15.0 : p.color == Blue ? 20.0 : 3.0
A = get_area(model.tessellation, i)
if age ≤ tₘᵢₙ || age ≥ tₘₐₓ || A < Aₘᵢₙ
return 0.0
end
Aᵢ = get_area(model.tessellation, i)
β = p.color == Red ? 0.4 : p.color == Blue ? 0.5 : 0.8
return max(0.0, β * (1 - 1 / (K * Aᵢ)))
end
proliferation_rate (generic function with 1 method)
Forces between two cells
force(model, i::Int, j::Int, t) = force(model, model[i], model[j], t)
function force(model, p::Cell, q::Cell, t; ℓₘₐₓ = 1.5)
rᵢⱼ = q.pos - p.pos
δ = norm(rᵢⱼ)
if δ > ℓₘₐₓ
return SVector(0.0, 0.0)
end
μ = spring_constant(model, p, q, t)
s = rest_length(model, p, q, t)
return μ * (δ - s) * rᵢⱼ / δ
end
force (generic function with 2 methods)
Final random tug
function random_force(model, i::Int; ξ=0.01)
p = model[i]
Δt = model.dt
return sqrt(2ξ / Δt) * SVector(randn(), randn())
end
random_force (generic function with 1 method)
Accumulate forces acting on one cell
function force(model, i::Int, t)
F = SVector(0.0, 0.0)
for j in get_neighbours(model.triangulation, i)
DT.is_ghost_vertex(j) && continue
F = F + force(model, i, j, t)
end
F = F + random_force(model, i)
return F
end
force (generic function with 3 methods)
Cell movement velocity
velocity(model, i, t; η=0.5) = force(model, i, t) / η
velocity (generic function with 1 method)
First update_velocities!()
and then update_positions!()
for each cell to avoid positional errors
function update_velocities!(model, t)
for i in each_solid_vertex(model.triangulation)
model[i].vel = velocity(model, i, t)
end
return model
end
function new_position(model, i, t)
xᵢ = model[i]
r = xᵢ.pos + model.dt * xᵢ.vel
x, y = r
xmax, ymax = spacesize(model)
# Freeze if the new position is out of bounds
if x < 0 || x > xmax || y < 0 || y > ymax
r = xᵢ.pos
end
return r
end
function update_positions!(model, t)
update_velocities!(model, t)
for i in each_solid_vertex(model.triangulation)
model[i].pos = new_position(model, i, t)
end
return model
end
update_positions! (generic function with 1 method)
Cumulative sum of proliferation probabilities
function proliferation_probability(model, t)
Δt = model.dt
# Technically nagents is not the number of alive agents, but with the way we are handling agents this is correct
probs = zeros(nagents(model))
for i in allids(model)
# Skip empty and dead cells
if !DT.has_vertex(model.triangulation, i) || (i ∈ model.dead_cells)
probs[i] = 0.0
else
probs[i] = proliferation_rate(model, i, t) * Δt
end
end
return probs
end
select_proliferative_cell(model, probs) = StatsBase.sample(weights(probs))
select_proliferative_cell (generic function with 1 method)
sampling from a Voronoi cell
function sample_triangle(tri::Triangulation, T)
i, j, k = triangle_vertices(T)
p, q, r = get_point(tri, i, j, k)
px, py = getxy(p)
qx, qy = getxy(q)
rx, ry = getxy(r)
a = (qx - px, qy - py)
b = (rx - px, ry - py)
u₁, u₂ = rand(), rand()
if u₁ + u₂ > 1
u₁, u₂ = 1 - u₁, 1 - u₂
end
ax, ay = getxy(a)
bx, by = getxy(b)
wx, wy = u₁ * ax + u₂ * bx, u₁ * ay + u₂ * by
return SVector(px + wx, py + wy)
end
sample_triangle (generic function with 1 method)
select a random triangle from a triangulation
function random_triangle(tri::Triangulation)
triangles = DT.each_solid_triangle(tri)
area(T) = DT.triangle_area(get_point(tri, triangle_vertices(T)...)...)
T = itsample(triangles, area)
return T
end
random_triangle (generic function with 1 method)
First, triangulate the Voronoi cell. Then, sample a triangle from the triangulation, and finally sample a point from the triangle
function triangulate_voronoi_cell(vorn::VoronoiTessellation, i)
S = @view get_polygon(vorn, i)[1:end-1]
points = DT.get_polygon_points(vorn)
return triangulate_convex(points, S)
end
function sample_voronoi_cell(vorn::VoronoiTessellation, i)
tri = triangulate_voronoi_cell(vorn, i)
T = random_triangle(tri)
return sample_triangle(tri, T)
end
mutation_probability(p) = p.color == Red ? 0.3 : p.color == Blue ? 0.5 : 0.05 # pₘᵤₜ
mutation_probability (generic function with 1 method)
computing the daughter cell and performing the proliferation event.
function place_daughter_cell!(model, i::Int, t)
parent = model[i]
# this is an SVector, not a Cell
daughterpos = sample_voronoi_cell(model.tessellation, i)
u = rand()
clr = parent.color
if u < mutation_probability(parent)
newclr = clr == Red ? Blue : clr == Blue ? Orange : Red
else
newclr = clr
end
add_agent!(daughterpos, model; color=newclr, birth=t, vel=SVector(0.0, 0.0))
return daughterpos
end
function proliferate_cells!(model, t)
probs = proliferation_probability(model, t)
u = rand()
event = u < sum(probs)
!event && return false
i = select_proliferative_cell(model, probs)
daughterpos = place_daughter_cell!(model, i, t)
return true
end
max_age(p) = p.color == Red ? 10.0 : p.color == Blue ? 10.0 : 3.0 # dₘₐₓ
death_rate(p) = p.color == Red ? 0.001 : p.color == Blue ? 0.00005 : 0.0001 # psick
death_rate (generic function with 1 method)
Mark cell i as dead
function cull_cell!(model, i::Int, t)
p = model[i]
elder = t - p.birth> max_age(p)
sick = rand() < model.dt * death_rate(p)
xmax, ymax = spacesize(model)
x, y = p.pos
outside = x < 0 || x > xmax || y < 0 || y > ymax
if elder || sick || outside
push!(model.dead_cells, i)
p.death = t
end
return model
end
function cull_cells!(model, t)
for i in each_solid_vertex(model.triangulation)
cull_cell!(model, i, t)
end
return model
end
cull_cells! (generic function with 1 method)
(Finally,) define the stepping function of the ABModel
function model_step!(model)
stepn = abmtime(model)
t = stepn * model.dt
cull_cells!(model, t)
proliferate_cells!(model, t)
update_positions!(model, t)
points = [a.pos for a in allagents(model)]
model.triangulation = retriangulate(model.triangulation, points; skip_points=model.dead_cells)
model.tessellation = voronoi(model.triangulation, clip=true)
return model
end
model_step! (generic function with 1 method)
Initialize the model
function initialize_cell_model(;
ninit=50,
radius=2.0,
dt=0.01,
sides=SVector(20.0, 20.0),
seed=0)
Random.seed!(seed)
# Generate the initial random positions
cent = SVector(sides[1] / 2, sides[2] / 2)
cells = map(1:ninit) do i
θ = 2π * rand()
r = radius * sqrt(rand())
pos = cent + SVector(r * cos(θ), r * sin(θ))
cell = Cell(; id=i, pos=pos,
color=Red, birth=0.0, vel=SVector(0.0, 0.0))
end
positions = [cell.pos for cell in cells]
# Compute the triangulation and the tessellation
triangulation = triangulate(positions)
tessellation = voronoi(triangulation, clip=true)
# Define the model parameters
properties = Dict(
:triangulation => triangulation,
:tessellation => tessellation,
:dt => dt,
:dead_cells => Set{Int}()
)
# Define the space
space = ContinuousSpace(sides; periodic=false)
# Define the model
model = StandardABM(Cell, space; model_step!, properties, container=Vector)
# Add cells
for (id, pos) in pairs(positions)
add_agent!(pos, model; color=Red, birth=0.0, vel=SVector(0.0, 0.0))
end
return model
end
initialize_cell_model (generic function with 1 method)
Helper functions
function count_cell_type(model, type)
stepn = abmtime(model)
t = stepn * model.dt
n = 0
for i in each_solid_vertex(model.triangulation)
n += model[i].color == type
end
return n
end
count_red(model) = count_cell_type(model, Red)
count_blue(model) = count_cell_type(model, Blue)
count_orange(model) = count_cell_type(model, Orange)
count_total(model) = num_solid_vertices(model.triangulation)
function average_cell_area(model)
area_itr = (get_area(model.tessellation, i) for i in each_solid_vertex(model.triangulation))
mean_area = mean(area_itr)
return mean_area
end
function cell_diameter(vorn, i)
S = get_polygon(vorn, i)
# This is an O(|S|^2) method, but |S| is small so it is fine
max_d = 0.0
for i in S
p = get_polygon_point(vorn, i)
for j in S
i == j && continue
q = get_polygon_point(vorn, j)
d = norm(getxy(p) .- getxy(q))
max_d = max(max_d, d)
end
end
return max_d
end
function average_cell_diameter(model)
diam_itr = (cell_diameter(model.tessellation, i) for i in each_solid_vertex(model.triangulation))
mean_diam = mean(diam_itr)
return mean_diam
end
function average_spring_length(model)
spring_itr = (norm(model[i].pos - model[j].pos) for (i, j) in each_solid_edge(model.triangulation))
mean_spring = mean(spring_itr)
return mean_spring
end
average_spring_length (generic function with 1 method)
Run the simulations
finalT = 50.0
model = initialize_cell_model()
nsteps = Int(finalT / model.dt)
mdata = [count_red, count_blue, count_orange, count_total,
average_cell_area, average_cell_diameter, average_spring_length]
@time agent_df, model_df = run!(model, nsteps; mdata);
88.497031 seconds (319.14 M allocations: 43.143 GiB, 4.13% gc time, 7.27% compilation time)
Visualize using CairoMakie
time = 0:model.dt:finalT
fig = Figure(fontsize=24)
ax = Axis(fig[1, 1], xlabel="Time", ylabel="Count", width=600, height=400)
lines!(ax, time, model_df[!, :count_red], color=:red, label="Red", linewidth=3)
lines!(ax, time, model_df[!, :count_blue], color=:blue, label="Blue", linewidth=3)
lines!(ax, time, model_df[!, :count_orange], color=:orange, label="Orange", linewidth=3)
lines!(ax, time, model_df[!, :count_total], color=:black, label="Total", linewidth=3)
axislegend(ax, position=:lt)
ax = Axis(fig[1, 2], xlabel="Time", ylabel="Average", width=600, height=400)
lines!(ax, time, model_df[!, :average_cell_area], color=:black, label="Cell area", linewidth=3)
lines!(ax, time, model_df[!, :average_cell_diameter], color=:magenta, label="Cell diameter", linewidth=3)
lines!(ax, time, model_df[!, :average_spring_length], color=:red, label="Spring length", linewidth=3)
axislegend(ax, position=:rb)
resize_to_layout!(fig)
fig

Animate the results
voronoi_marker = (model, cell) -> begin
id = cell.id
verts = get_polygon_coordinates(model.tessellation, id)
return Makie.Polygon([Point2f(getxy(q) .- cell.pos) for q in verts])
end
voronoi_color(cell) = cell.color == Red ? :red : cell.color == Blue ? :blue : :orange
model = initialize_cell_model() ## reinitialise the model for the animation
fig, ax, amobs = abmplot(model, agent_marker=cell -> voronoi_marker(model, cell), agent_color=voronoi_color,
agentsplotkwargs=(strokewidth=1,), figure=(; size=(1600, 800), fontsize=34), mdata=mdata,
axis=(; width=800, height=800), when=10)
current_time = Observable(0.0)
t = Observable([0.0])
nred = Observable(amobs.mdf[][!, :count_red])
nblue = Observable(amobs.mdf[][!, :count_blue])
norange = Observable(amobs.mdf[][!, :count_orange])
ntotal = Observable(amobs.mdf[][!, :count_total])
avg_area = Observable(amobs.mdf[][!, :average_cell_area])
avg_diam = Observable(amobs.mdf[][!, :average_cell_diameter])
avg_spring = Observable(amobs.mdf[][!, :average_spring_length])
plot_layout = fig[:, end+1] = GridLayout()
count_layout = plot_layout[1, 1] = GridLayout()
ax_count = Axis(count_layout[1, 1], xlabel="Time", ylabel="Count", width=600, height=400)
lines!(ax_count, t, nred, color=:red, label="Red", linewidth=3)
lines!(ax_count, t, nblue, color=:blue, label="Blue", linewidth=3)
lines!(ax_count, t, norange, color=:orange, label="Orange", linewidth=3)
lines!(ax_count, t, ntotal, color=:black, label="Total", linewidth=3)
vlines!(ax_count, current_time, color=:grey, linestyle=:dash, linewidth=3)
xlims!(ax_count, 0, finalT)
ylims!(ax_count, 0, 800)
avg_layout = plot_layout[2, 1] = GridLayout()
ax_avg = Axis(avg_layout[1, 1], xlabel="Time", ylabel="Average", width=600, height=400)
lines!(ax_avg, t, avg_area, color=:black, label="Cell area", linewidth=3)
lines!(ax_avg, t, avg_diam, color=:magenta, label="Cell diameter", linewidth=3)
lines!(ax_avg, t, avg_spring, color=:red, label="Spring length", linewidth=3)
vlines!(ax_avg, current_time, color=:grey, linestyle=:dash, linewidth=3)
axislegend(ax_avg, position=:rt)
xlims!(ax_avg, 0, finalT)
ylims!(ax_avg, 0, 2)
resize_to_layout!(fig)
on(amobs.mdf) do mdf
current_time[] = abmtime(amobs.model[]) * model.dt
t.val = mdf[!, :time] .* model.dt
nred[] = mdf[!, :count_red]
nblue[] = mdf[!, :count_blue]
norange[] = mdf[!, :count_orange]
ntotal[] = mdf[!, :count_total]
avg_area[] = mdf[!, :average_cell_area]
avg_diam[] = mdf[!, :average_cell_diameter]
avg_spring[] = mdf[!, :average_spring_length]
end
ObserverFunction defined at /home/runner/work/jl-abm/jl-abm/.cache/docs/09-celldiv.ipynb:41 operating on Observable(1×8 DataFrame
Row │ time count_red count_blue count_orange count_total average_cell_a ⋯
│ Int64 Int64 Int64 Int64 Int64 Float64 ⋯
─────┼──────────────────────────────────────────────────────────────────────────
1 │ 0 50 0 0 50 0.181 ⋯
3 columns omitted)
Record the results for every 10 steps
vio = Makie.Record(fig, 1:(nsteps ÷ 10), framerate=24) do i
step!(amobs, 10)
end