Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ cellular automata, one and two dimensional.
- **Update schemes**: `Synchronous` (default) and `Stochastic(rate)` for
per-cell probabilistic updates, given an explicit `rng`.
- **Neighborhoods**: `Moore` (default, square) and `VonNeumann` (diamond) shapes
for `Life`, extensible to custom `AbstractNeighborhood` subtypes.
for `Life`, plus a weighted, circular `Kernel` shape (groundwork for future
convolution-based rules such as Lenia); extensible to custom
`AbstractNeighborhood` subtypes.
- **Functional core** (`next_state`, `rollout`): non-mutating, AD/GPU-friendly
building blocks for custom or neural cellular automata, alongside the
stateful `CellularAutomaton` wrapper.
Expand Down
2 changes: 2 additions & 0 deletions docs/src/api/twodim.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@
AbstractNeighborhood
Moore
VonNeumann
Kernel
neighborhood_offsets
neighborhood_weight
```
5 changes: 3 additions & 2 deletions src/CellularAutomata.jl
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@ include("random.jl")
export AbstractCellularAutomaton, AbstractCellularAutomatonRule
export AbstractBoundaryCondition, Periodic, Reflecting, ConstantBoundary
export AbstractUpdateScheme, Synchronous, Stochastic
export AbstractNeighborhood, Moore, VonNeumann
export AbstractNeighborhood, Moore, VonNeumann, Kernel
export AbstractDiscreteCellularAutomatonRule
export AbstractContinuousCellularAutomatonRule
export AbstractTotalisticCellularAutomatonRule
export AbstractLifeLikeCellularAutomatonRule
export CellularAutomaton
export next_state, rollout, spatial_dimensions
export cellular_automaton_rule, evolution_history, generation_count
export neighborhood_offsets, neighborhood_radius, rule_lookup_table, cell_state_count
export neighborhood_offsets, neighborhood_radius, neighborhood_weight
export rule_lookup_table, cell_state_count
export DCA
export CCA
export TCA
Expand Down
55 changes: 54 additions & 1 deletion src/generics.jl
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ Supertype for two-dimensional neighborhood shapes, used to enumerate the cells
around a given cell (excluding the cell itself).

Subtypes must implement [`neighborhood_offsets`](@ref) and
[`neighborhood_radius`](@ref).
[`neighborhood_radius`](@ref); [`neighborhood_weight`](@ref) is optional and defaults
to `1` for every offset.
"""
abstract type AbstractNeighborhood end

Expand Down Expand Up @@ -170,6 +171,58 @@ function neighborhood_radius end
neighborhood_radius(neighborhood::Moore) = neighborhood.radius
neighborhood_radius(neighborhood::VonNeumann) = neighborhood.radius

"""
Kernel(radius, weight)

Circular, weighted neighborhood: every cell within Euclidean distance `radius` of the
center (excluding the center itself) is weighted by `weight(distance / radius)`, a
function of the normalized distance in `(0, 1]`. This is the neighborhood shape used
by Lenia-style continuous cellular automata, where `weight` is the kernel's radial
profile. No built-in rule consumes [`neighborhood_weight`](@ref) yet; `Kernel` combined
with an existing rule such as [`Life`](@ref) only affects which cells are counted, not
their contribution.
"""
struct Kernel{F} <: AbstractNeighborhood
radius::Int
weight::F
function Kernel{F}(radius::Int, weight::F) where {F}
radius >= 1 || throw(ArgumentError("radius must be at least 1"))
return new{F}(radius, weight)
end
end

Kernel(radius::Int, weight::F) where {F} = Kernel{F}(radius, weight)

function Base.show(io::IO, neighborhood::Kernel)
return print(io, "Kernel(", neighborhood.radius, ", ", neighborhood.weight, ")")
end

function neighborhood_offsets(neighborhood::Kernel)
radius = neighborhood.radius
return (
(row, column)
for row in (-radius):radius, column in (-radius):radius
if !(row == 0 && column == 0) && row^2 + column^2 <= radius^2
)
end

neighborhood_radius(neighborhood::Kernel) = neighborhood.radius

"""
neighborhood_weight(neighborhood::AbstractNeighborhood, row_offset, column_offset)

Return the weight applied to the cell at `(row_offset, column_offset)` in
`neighborhood`. Defaults to `1` for every offset, matching unweighted neighborhoods
such as [`Moore`](@ref) and [`VonNeumann`](@ref); [`Kernel`](@ref) overrides this to
return its radial weight profile.
"""
neighborhood_weight(::AbstractNeighborhood, row_offset, column_offset) = 1

function neighborhood_weight(neighborhood::Kernel, row_offset, column_offset)
distance = sqrt(row_offset^2 + column_offset^2) / neighborhood.radius
return neighborhood.weight(distance)
end

"""
AbstractUpdateScheme

Expand Down
30 changes: 30 additions & 0 deletions test/neighborhood_test.jl
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@ CellularAutomata.neighborhood_offsets(::HorizontalNeighborhood) = ((0, -1), (0,
@testset "constructor validation" begin
@test neighborhood_radius(Moore(2)) == 2
@test neighborhood_radius(VonNeumann(; radius = 2)) == 2
@test neighborhood_radius(Kernel(2, identity)) == 2
@test_throws ArgumentError Moore(-1)
@test_throws ArgumentError VonNeumann(-1)
@test_throws ArgumentError Kernel(0, identity)
@test_throws ArgumentError Life(((3,), (2, 3)); radius = 0)
@test_throws ArgumentError Life(((3,), (2, 3)); neighborhood = VonNeumann(0))
end

@testset "show methods" begin
@test repr(Moore(1)) == "Moore(1)"
@test repr(VonNeumann(2)) == "VonNeumann(2)"
@test repr(Kernel(1, identity)) == "Kernel(1, identity)"
@test repr(Life(((3,), (2, 3)))) == "Life(((3,), (2, 3)); radius=1)"
@test repr(Life(((3,), (2, 3)); neighborhood = VonNeumann(1))) ==
"Life(((3,), (2, 3)); neighborhood=VonNeumann(1))"
Expand All @@ -37,6 +40,33 @@ end
@test (0, 0) ∉ vonneumann_offsets
@test (1, 1) ∉ vonneumann_offsets
@test (1, 0) ∈ vonneumann_offsets

kernel_offsets = Set(neighborhood_offsets(Kernel(2, identity)))
@test (0, 0) ∉ kernel_offsets
@test (1, 0) ∈ kernel_offsets
@test (2, 2) ∉ kernel_offsets # outside the circular radius, unlike Moore
@test kernel_offsets == Set([
(row, column)
for row in -2:2, column in -2:2
if !(row == 0 && column == 0) && row^2 + column^2 <= 4
])
@test all(row^2 + column^2 <= 4 for (row, column) in kernel_offsets)
end

@testset "neighborhood_weight" begin
@test neighborhood_weight(Moore(1), 1, 1) == 1
@test neighborhood_weight(VonNeumann(1), 1, 0) == 1

kernel = Kernel(2, r -> 1 - r)
@test neighborhood_weight(kernel, 2, 0) ≈ 0
@test neighborhood_weight(kernel, 1, 0) ≈ 0.5
# symmetric under sign flips: weight depends only on Euclidean distance
@test neighborhood_weight(kernel, -1, 0) ≈ neighborhood_weight(kernel, 1, 0)
@test neighborhood_weight(kernel, 0, -1) ≈ neighborhood_weight(kernel, 1, 0)
@test all(
0 <= neighborhood_weight(kernel, row, column) <= 1
for (row, column) in neighborhood_offsets(kernel)
)
end

@testset "VonNeumann and Moore neighborhoods can disagree" begin
Expand Down
Loading