diff --git a/Project.toml b/Project.toml index 905cae3..d62ffd4 100644 --- a/Project.toml +++ b/Project.toml @@ -7,7 +7,14 @@ version = "0.1.3" ConcreteStructs = "2569d6c7-a4a2-43d3-a901-331e8e4be471" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +[weakdeps] +Agents = "46ada45e-f475-11e8-01d0-f70cc89e6671" + +[extensions] +CellularAutomataAgentsExt = "Agents" + [compat] +Agents = "7" Aqua = "0.8" ConcreteStructs = "0.2" ForwardDiff = "0.10, 1" @@ -18,6 +25,7 @@ Test = "1.10" julia = "1.10" [extras] +Agents = "46ada45e-f475-11e8-01d0-f70cc89e6671" Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b" @@ -25,4 +33,4 @@ SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Test", "SafeTestsets", "Aqua", "ForwardDiff", "JET"] +test = ["Test", "SafeTestsets", "Aqua", "ForwardDiff", "JET", "Agents"] diff --git a/docs/src/api/general.md b/docs/src/api/general.md index 220081c..54bee3d 100644 --- a/docs/src/api/general.md +++ b/docs/src/api/general.md @@ -26,3 +26,13 @@ spatial_dimensions lempel_ziv ``` + +## Extensions + +```@docs + cellular_automaton_abm + cellular_automaton_state +``` + +`cellular_automaton_abm`/`cellular_automaton_state` are implemented in a package +extension and require `Agents` to be loaded. diff --git a/ext/CellularAutomataAgentsExt.jl b/ext/CellularAutomataAgentsExt.jl new file mode 100644 index 0000000..3abfeed --- /dev/null +++ b/ext/CellularAutomataAgentsExt.jl @@ -0,0 +1,49 @@ +module CellularAutomataAgentsExt + +using CellularAutomata: + CellularAutomata, AbstractCellularAutomatonRule, AbstractBoundaryCondition, + AbstractUpdateScheme, Periodic, Synchronous, next_state +using Agents: Agents, StandardABM, GridSpaceSingle, dummystep +using Random: default_rng + +mutable struct __CAField{R, S, B, U, G} + rule::R + state::S + boundary::B + scheme::U + rng::G +end + +function __step_ca!(field::__CAField) + field.state = next_state( + field.rule, field.state; boundary = field.boundary, scheme = field.scheme, + rng = field.rng + ) + return field.state +end + +function CellularAutomata.cellular_automaton_abm( + AgentType, rule::AbstractCellularAutomatonRule, state; + boundary::AbstractBoundaryCondition = Periodic(), + scheme::AbstractUpdateScheme = Synchronous(), + rng = default_rng(), + space = GridSpaceSingle(size(state); periodic = boundary isa Periodic), + agent_step! = dummystep, + model_step! = Returns(nothing), + properties::NamedTuple = NamedTuple(), + kwargs... + ) + haskey(properties, :cellular_automaton) && + throw(ArgumentError("properties already has a `cellular_automaton` key")) + field = __CAField(rule, state, boundary, scheme, rng) + merged_properties = merge((; cellular_automaton = field), properties) + wrapped_model_step! = model -> (__step_ca!(field); model_step!(model); nothing) + return StandardABM( + AgentType, space; properties = merged_properties, + agent_step! = agent_step!, model_step! = wrapped_model_step!, kwargs... + ) +end + +CellularAutomata.cellular_automaton_state(model) = model.cellular_automaton.state + +end # module diff --git a/src/CellularAutomata.jl b/src/CellularAutomata.jl index b2403f9..a139e50 100644 --- a/src/CellularAutomata.jl +++ b/src/CellularAutomata.jl @@ -10,6 +10,7 @@ include("rules/cca.jl") include("rules/tca.jl") include("rules/life.jl") include("measures.jl") +include("extensions.jl") export AbstractCellularAutomaton, AbstractCellularAutomatonRule export AbstractBoundaryCondition, Periodic, Reflecting, ConstantBoundary @@ -28,5 +29,6 @@ export CCA export TCA export Life export lempel_ziv +export cellular_automaton_abm, cellular_automaton_state end # module diff --git a/src/extensions.jl b/src/extensions.jl new file mode 100644 index 0000000..600d3bc --- /dev/null +++ b/src/extensions.jl @@ -0,0 +1,42 @@ +""" + cellular_automaton_abm(AgentType, rule, state; kwargs...) + +Build an `Agents.StandardABM` that advances `state` under `rule` once per model step, +alongside independently-scheduled agents of type `AgentType`. Requires `Agents` to be +loaded (implemented in a package extension). + +# Arguments + + - `AgentType`: agent type living on the model's grid, as required by + `Agents.StandardABM`. + - `rule`: an [`AbstractCellularAutomatonRule`](@ref) advanced each model step. + - `state`: the initial cellular-automaton state array. + +# Keywords + + - `boundary`: boundary condition passed to [`next_state`](@ref). Defaults to + `Periodic()`. + - `scheme`: update scheme passed to [`next_state`](@ref). Defaults to `Synchronous()`. + - `rng`: random number generator, required by stochastic schemes. + - `space`: an `Agents.AbstractSpace`. Defaults to a `GridSpaceSingle` sized to `state`, + periodic if `boundary isa Periodic`. + - `agent_step!`: per-agent step function, as in `Agents.StandardABM`. + - `model_step!`: additional model step function, called after the cellular automaton + has been advanced. Defaults to a no-op. + - `properties`: a `NamedTuple` of additional model properties, merged with the + cellular-automaton state stored under the `cellular_automaton` key. + - other `kwargs` are passed through to `Agents.StandardABM`. + +# Throws + + - `ArgumentError`: if `properties` already has a `cellular_automaton` key. +""" +function cellular_automaton_abm end + +""" + cellular_automaton_state(model) + +Return the current cellular-automaton state array stored in an ABM built by +[`cellular_automaton_abm`](@ref). Requires `Agents` to be loaded. +""" +function cellular_automaton_state end diff --git a/test/agents_ext_test.jl b/test/agents_ext_test.jl new file mode 100644 index 0000000..90b4aca --- /dev/null +++ b/test/agents_ext_test.jl @@ -0,0 +1,55 @@ +using CellularAutomata +using Agents +using Test + +@agent struct Forager(GridAgent{2}) end +@agent struct Point1D(GridAgent{1}) end + +@testset "cellular automaton advances like rollout" begin + glider = [0 0 1 0 0; 0 0 0 1 0; 0 1 1 1 0; 0 0 0 0 0; 0 0 0 0 0] + life = Life((3, (2, 3))) + + model = cellular_automaton_abm( + Forager, life, glider; + agent_step! = (agent, model) -> walk!(agent, (rand((-1, 0, 1)), rand((-1, 0, 1))), model) + ) + add_agent!(model) + add_agent!(model) + + step!(model, 3) + + @test cellular_automaton_state(model) == rollout(life, glider, 3) + @test nagents(model) == 2 +end + +@testset "no agent_step! given, model_step! still advances the automaton" begin + dca = DCA(30) + state = [0, 0, 1, 0, 0] + + model = cellular_automaton_abm(Point1D, dca, state) + step!(model, 2) + + @test cellular_automaton_state(model) == rollout(dca, state, 2) +end + +@testset "extra model_step! runs after the automaton update" begin + dca = DCA(30) + state = [0, 0, 1, 0, 0] + calls = Ref(0) + + model = cellular_automaton_abm( + Point1D, dca, state; model_step! = model -> (calls[] += 1) + ) + step!(model, 3) + + @test calls[] == 3 + @test cellular_automaton_state(model) == rollout(dca, state, 3) +end + +@testset "rejects a conflicting properties key" begin + dca = DCA(30) + state = [0, 0, 1, 0] + @test_throws ArgumentError cellular_automaton_abm( + Point1D, dca, state; properties = (; cellular_automaton = nothing) + ) +end diff --git a/test/runtests.jl b/test/runtests.jl index 7e3f9d8..5cd48cf 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -44,4 +44,8 @@ if GROUP in ("All", "Core") @safetestset "Life blinker" include("blinker_test.jl") @safetestset "Neighborhoods" include("neighborhood_test.jl") end + + @testset "Extensions" begin + @safetestset "Agents.jl" include("agents_ext_test.jl") + end end