diff --git a/.codecov.yml b/.codecov.yml index 0a79ffc464..18ab7cee7f 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,7 +1,6 @@ ignore: - "examples" # No need to cover the examples - "src/models" # Examples provide integration testing here - - "src/visualization" # Plot recipes are soon to be unsupported + - "src/visualizations" # Plot recipes aren't tested - "test" - "docs" - diff --git a/CHANGELOG.md b/CHANGELOG.md index 48beb887ef..edce084eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # main +# v5.15 +- Agents.jl moved to Julia 1.9+, and now exports visualization + and interactive applications automatically once Makie (or Makie backends + such as GLMakie) come into scope, using the new package extension system. + The only downside of this is that now to visualize ABMs on open street + maps, the package OSMMakie.jl must be explicitly loaded as well. - Nearby look-ups with `nearby_positions`, `nearby_ids` and derivatives are now incrementally faster than before more the radius increases. - The `randomwalk!` function is now supported for any number of dimensions in ContinuousSpace when used to create isotropic/uniform random walks. For all type of `AbstractGridSpace`, the `randomwalk!` function supports a new keyword `force_motion`, which is false by default. See the docs to be informed on the effect of setting this keyword. Besides, in the continuous space default case random walks are up to 2 times faster than before. - The `ByProperty` scheduler can now accept any type of (ordered) properties, while before it was restricted to only floats. The `ByID` scheduler of an `UnremovableABM` is now as fast as the `Fastest` scheduler since in this case they are actually equivalent. diff --git a/Project.toml b/Project.toml index d95553c972..dbd4422c2b 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "Agents" uuid = "46ada45e-f475-11e8-01d0-f70cc89e6671" -authors = ["George Datseris", "Tim DuBois", "Aayush Sabharwal", "Ali Vahdati"] -version = "5.14.0" +authors = ["George Datseris", "Tim DuBois", "Aayush Sabharwal", "Ali Vahdati", "Adriano Meligrana"] +version = "5.15.0" [deps] CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" @@ -18,12 +18,19 @@ LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" ProgressMeter = "92933f4c-e287-5a05-a399-4b506db050ca" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" -Requires = "ae029012-a4dd-5104-9daa-d747884805df" Rotations = "6038ab10-8711-5258-84ad-4b1120ba62dc" Scratch = "6c6a2e73-6563-6170-7368-637461726353" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" StatsBase = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" +[weakdeps] +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" +OSMMakie = "76b6901f-8821-46bb-9129-841bc9cfe677" + +[extensions] +AgentsVisualizations = "Makie" +AgentsOSMVisualizations = "OSMMakie" + [compat] CSV = "0.9.7, 0.10" DataFrames = "0.21, 0.22, 1" @@ -34,12 +41,11 @@ JLD2 = "0.4" LazyArtifacts = "1.3.0" LightOSM = "0.2.0" ProgressMeter = "1.5" -Requires = "0.5, 0.6, 0.7, 1.0, 1.1" Rotations = "1.3" Scratch = "1" StaticArrays = "1" StatsBase = "0.32, 0.33, 0.34" -julia = "1.5" +julia = "1.9" [extras] BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" diff --git a/README.md b/README.md index b83ea8f413..c0c80e582f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -# Agents.jl: agent-based modeling framework in Julia - ![Agents.jl](https://github.com/JuliaDynamics/JuliaDynamics/blob/master/videos/agents/agents4_logo.gif?raw=true) [![](https://img.shields.io/badge/docs-stable-blue.svg)](https://JuliaDynamics.github.io/Agents.jl/stable) @@ -8,20 +6,23 @@ [![codecov](https://codecov.io/gh/JuliaDynamics/Agents.jl/branch/main/graph/badge.svg)](https://codecov.io/gh/JuliaDynamics/Agents.jl) [![Package Downloads](https://shields.io/endpoint?url=https://pkgs.genieframework.com/api/v1/badge/Agents)](https://pkgs.genieframework.com?packages=Agents) -Agents.jl is a [Julia](https://julialang.org/) framework for agent-based modeling (ABM). All further information about Agents.jl are provided in the docs. +Agents.jl is a pure [Julia](https://julialang.org/) framework for agent-based modeling (ABM): a computational simulation methodology where autonomous agents react to their environment (including other agents) given a predefined set of rules. +Some major highlights of Agents.jl are: -## Contributions +1. It is fast (faster than MASON, NetLogo, or Mesa) +2. It is simple: has a very short learning curve and requires writing minimal code +3. Has an extensive interface of thousands of out-of-the box possible agent actions +4. Straightforwardly allows simulations on Open Street Maps -Any contribution to Agents.jl is welcome! For example you can: +The simplicity of Agents.jl is due to the intuitive space-agnostic modelling approach we have implemented: agent actions are specified using generically named functions (such as "move agent" or "find nearby agents") that do not depend on the actual space the agents exist in, nor on the properties of the agents themselves. Overall this leads to ultra fast model prototyping where even changing the space the agents live in is matter of only a couple of lines of code. - * Add new feature or improve an existing one (plenty to choose from the "Issues" page) - * Improve the existing documentation - * Add new example ABMs into our existing pool of examples - * Report bugs and suggestions in the Issues page +More information and an extensive list of features can be found in the documentation, which you can either find [online](https://juliadynamics.github.io/Agents.jl/stable/) or build locally by running the `docs/make.jl` file. ## Citation -If you use this package in a publication, please cite the paper below: +If you use this package in a publication, or simply want to refer to it, +please cite the paper below: + ``` @article{Agents.jl, doi = {10.1177/00375497211068820}, @@ -36,4 +37,4 @@ If you use this package in a publication, please cite the paper below: volume = {0}, number = {0}, } -``` +``` \ No newline at end of file diff --git a/docs/Project.toml b/docs/Project.toml index 9d639dbe2d..420645dbf9 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -13,9 +13,9 @@ FileIO = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" GraphRecipes = "bd48cda9-67a9-57be-86fa-5b3c104eda73" ImageMagick = "6218d12a-5da1-5696-b52f-db25d2ecc6d1" -InteractiveDynamics = "ec714cd0-5f51-11eb-0b6e-452e7367ff84" Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" LightOSM = "d1922b25-af4e-4ba3-84af-fe9bea896051" +OSMMakie = "76b6901f-8821-46bb-9129-841bc9cfe677" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7" diff --git a/docs/logo/example_zoo.jl b/docs/logo/example_zoo.jl new file mode 100644 index 0000000000..481aa9cd3a --- /dev/null +++ b/docs/logo/example_zoo.jl @@ -0,0 +1,885 @@ +using Agents, CairoMakie + +# used +model_names = [ + "Daisyworld", + "Flocking", + "Mountain runners", + "Growing bacteria", + "Forest fire", + "Ant colony", + "Zombie outbreak", + "Fractal growth", + "Social distancing", +] +steps_per_frame = [ + 1, + 2, + 5, + 100, + 1, + 2, + 1, + 20, + 3, +] +models = Any[nothing for _ in 1:9] +rules = Any[nothing for _ in 1:9] +unikwargs = (add_colorbar = false, add_controls = false, adjust_aspect = false,) + +fig = Figure(resolution = (1200, 1220)) +axs = Axis[] +for (i, c) in enumerate(CartesianIndices((3,3))) + ax = Axis(fig[c.I...]; title = model_names[i]) + hidedecorations!(ax) + push!(axs, ax) +end + +Label(fig[0, :], "Agents.jl zoo of examples"; + tellheight = true, tellwidth = false, + valign = :bottom, padding = (0,0,0,0), + font = "TeX Gyre Heros Bold", + height = 20, fontsize = 30, +) + +# DaisyWorld +daisypath = joinpath(pathof(Agents), "../../", "ext", "src", "daisyworld_def.jl") +include(daisypath) +daisy_model, daisy_step!, daisyworld_step! = daisyworld(; + solar_luminosity = 1.0, solar_change = 0.0, scenario = :change +) +daisycolor(a::Daisy) = a.breed # agent color +as = 15 # agent size +am = '✿' # agent marker +scatterkwargs = (strokewidth = 1.0,) # add stroke around each agent +heatarray = :temperature +heatkwargs = (colorrange = (-20, 60), colormap = :thermal) +plotkwargs = (; + ac = daisycolor, as, am, + scatterkwargs = (strokewidth = 0.5,), + heatarray, heatkwargs, unikwargs..., +) + +daisy_obs = abmplot!(axs[1], daisy_model; +agent_step! = daisy_step!, model_step! = daisyworld_step!, +plotkwargs..., unikwargs...,) +models[1] = daisy_obs + +# Flocking +@agent Bird ContinuousAgent{2} begin + speed::Float64 + cohere_factor::Float64 + separation::Float64 + separate_factor::Float64 + match_factor::Float64 + visual_distance::Float64 +end + +function flocking_model(; + n_birds = 100, + speed = 2.0, + cohere_factor = 0.4, + separation = 4.0, + separate_factor = 0.25, + match_factor = 0.02, + visual_distance = 5.0, + extent = (100, 100), + seed = 42, +) + space2d = ContinuousSpace(extent; spacing = visual_distance/1.5) + rng = Random.MersenneTwister(seed) + + model = ABM(Bird, space2d; rng, scheduler = Schedulers.Randomly()) + for _ in 1:n_birds + vel = Tuple(rand(model.rng, 2) * 2 .- 1) + add_agent!( + model, + vel, + speed, + cohere_factor, + separation, + separate_factor, + match_factor, + visual_distance, + ) + end + return model +end + +function bird_step!(bird, model) + neighbor_ids = nearby_ids(bird, model, bird.visual_distance) + N = 0 + match = separate = cohere = (0.0, 0.0) + for id in neighbor_ids + N += 1 + neighbor = model[id].pos + heading = neighbor .- bird.pos + + cohere = cohere .+ heading + if euclidean_distance(bird.pos, neighbor, model) < bird.separation + separate = separate .- heading + end + match = match .+ model[id].vel + end + N = max(N, 1) + cohere = cohere ./ N .* bird.cohere_factor + separate = separate ./ N .* bird.separate_factor + match = match ./ N .* bird.match_factor + bird.vel = (bird.vel .+ cohere .+ separate .+ match) ./ 2 + bird.vel = bird.vel ./ norm(bird.vel) + move_agent!(bird, model, bird.speed) +end +const bird_polygon = Makie.Polygon(Point2f[(-1, -1), (2, 0), (-1, 1)]) +function bird_marker(b::Bird) + φ = atan(b.vel[2], b.vel[1]) #+ π/2 + π + rotate_polygon(bird_polygon, φ) +end + +flock_model = flocking_model() +flock_obs = abmplot!(axs[2], flock_model; + agent_step! = bird_step!, + am = bird_marker, unikwargs..., +) +models[2] = flock_obs + +# Zombie outbreak +using OSMMakie +default_colors = OSMMakie.WAYTYPECOLORS +default_colors["primary"] = colorant"#a1777f" +default_colors["secondary"] = colorant"#a18f78" +default_colors["tertiary"] = colorant"#b3b381" + +@agent Zombie OSMAgent begin + infected::Bool + speed::Float64 +end +function initialise_zombies(; seed = 1234) + map_path = OSM.test_map() + properties = Dict(:dt => 1 / 60) + model = ABM( + Zombie, + OpenStreetMapSpace(map_path); + properties = properties, + rng = Random.MersenneTwister(seed) + ) + + for id in 1:100 + start = random_position(model) # At an intersection + speed = rand(model.rng) * 5.0 + 2.0 # Random speed from 2-7kmph + human = Zombie(id, start, false, speed) + add_agent_pos!(human, model) + OSM.plan_random_route!(human, model; limit = 50) # try 50 times to find a random route + end + start = OSM.nearest_road((9.9351811, 51.5328328), model) + finish = OSM.nearest_node((9.945125635913511, 51.530876112711745), model) + + speed = rand(model.rng) * 5.0 + 2.0 # Random speed from 2-7kmph + zombie = add_agent!(start, model, true, speed) + plan_route!(zombie, finish, model) + return model +end +function zombie_step!(agent, model) + distance_left = move_along_route!(agent, model, agent.speed * model.dt) + if is_stationary(agent, model) && rand(model.rng) < 0.1 + OSM.plan_random_route!(agent, model; limit = 50) + move_along_route!(agent, model, distance_left) + end + if agent.infected + map(i -> model[i].infected = true, nearby_ids(agent, model, 0.01)) + end + return +end + +zombie_color(agent) = agent.infected ? :green : :black +zombie_size(agent) = agent.infected ? 15 : 10 +zombies = initialise_zombies() +zombies_obs = abmplot!(axs[7], zombies; + ac = zombie_color, as = zombie_size, unikwargs..., + scatterkwargs = (strokecolor = :white, strokewidth = 1), + agent_step! = zombie_step!, + +) +models[7] = zombies_obs + +# Growing bacteria +using Agents, LinearAlgebra +using Random # hide +mutable struct SimpleCell <: AbstractAgent + id::Int + pos::NTuple{2,Float64} + length::Float64 + orientation::Float64 + growthprog::Float64 + growthrate::Float64 + + ## node positions/forces + p1::NTuple{2,Float64} + p2::NTuple{2,Float64} + f1::NTuple{2,Float64} + f2::NTuple{2,Float64} +end +function SimpleCell(id, pos, l, φ, g, γ) + a = SimpleCell(id, pos, l, φ, g, γ, (0.0, 0.0), (0.0, 0.0), (0.0, 0.0), (0.0, 0.0)) + update_nodes!(a) + return a +end + +function update_nodes!(a::SimpleCell) + offset = 0.5 * a.length .* unitvector(a.orientation) + a.p1 = a.pos .+ offset + a.p2 = a.pos .- offset +end +unitvector(φ) = reverse(sincos(φ)) +cross2D(a, b) = a[1] * b[2] - a[2] * b[1] +function bacteria_model_step!(model) + for a in allagents(model) + if a.growthprog ≥ 1 + ## When a cell has matured, it divides into two daughter cells on the + ## positions of its nodes. + add_agent!(a.p1, model, 0.0, a.orientation, 0.0, 0.1 * rand(model.rng) + 0.05) + add_agent!(a.p2, model, 0.0, a.orientation, 0.0, 0.1 * rand(model.rng) + 0.05) + remove_agent!(a, model) + else + ## The rest lengh of the internal spring grows with time. This causes + ## the nodes to physically separate. + uv = unitvector(a.orientation) + internalforce = model.hardness * (a.length - a.growthprog) .* uv + a.f1 = -1 .* internalforce + a.f2 = internalforce + end + end + ## Bacteria can interact with more than on other cell at the same time, therefore, + ## we need to specify the option `:all` in `interacting_pairs` + for (a1, a2) in interacting_pairs(model, 2.0, :all) + interact!(a1, a2, model) + end +end +function bacterium_step!(agent::SimpleCell, model::ABM) + fsym, compression, torque = transform_forces(agent) + direction = model.dt * model.mobility .* fsym + walk!(agent, direction, model) + agent.length += model.dt * model.mobility .* compression + agent.orientation += model.dt * model.mobility .* torque + agent.growthprog += model.dt * agent.growthrate + update_nodes!(agent) + return agent.pos +end +function interact!(a1::SimpleCell, a2::SimpleCell, model) + n11 = noderepulsion(a1.p1, a2.p1, model) + n12 = noderepulsion(a1.p1, a2.p2, model) + n21 = noderepulsion(a1.p2, a2.p1, model) + n22 = noderepulsion(a1.p2, a2.p2, model) + a1.f1 = @. a1.f1 + (n11 + n12) + a1.f2 = @. a1.f2 + (n21 + n22) + a2.f1 = @. a2.f1 - (n11 + n21) + a2.f2 = @. a2.f2 - (n12 + n22) +end + +function noderepulsion(p1::NTuple{2,Float64}, p2::NTuple{2,Float64}, model::ABM) + delta = p1 .- p2 + distance = norm(delta) + if distance ≤ 1 + uv = delta ./ distance + return (model.hardness * (1 - distance)) .* uv + end + return (0, 0) +end + +function transform_forces(agent::SimpleCell) + ## symmetric forces (CM movement) + fsym = agent.f1 .+ agent.f2 + ## antisymmetric forces (compression, torque) + fasym = agent.f1 .- agent.f2 + uv = unitvector(agent.orientation) + compression = dot(uv, fasym) + torque = 0.5 * cross2D(uv, fasym) + return fsym, compression, torque +end + +bacteria_model = ABM( + SimpleCell, + ContinuousSpace((14, 9); spacing = 1.0, periodic = false); + properties = Dict(:dt => 0.005, :hardness => 1e2, :mobility => 1.0), + rng = MersenneTwister(1680) +) + +add_agent!((6.5, 4.0), bacteria_model, 0.0, 0.3, 0.0, 0.1) +add_agent!((7.5, 4.0), bacteria_model, 0.0, 0.0, 0.0, 0.1) + +function cassini_oval(agent) + t = LinRange(0, 2π, 50) + a = agent.growthprog + b = 1 + m = @. 2 * sqrt((b^4 - a^4) + a^4 * cos(2 * t)^2) + 2 * a^2 * cos(2 * t) + C = sqrt.(m / 2) + + x = C .* cos.(t) + y = C .* sin.(t) + + uv = reverse(sincos(agent.orientation)) + θ = atan(uv[2], uv[1]) + R = [cos(θ) -sin(θ); sin(θ) cos(θ)] + + bacteria = R * permutedims([x y]) + coords = [Point2f(x, y) for (x, y) in zip(bacteria[1, :], bacteria[2, :])] + scale_polygon(Makie.Polygon(coords), 0.5) +end +bacteria_color(b) = RGBf(b.id * 3.14 % 1, 0.2, 0.2) + +bacteria_obs = abmplot!(axs[4], bacteria_model; + am = cassini_oval, ac = bacteria_color, unikwargs..., + agent_step! = bacterium_step!, model_step! = bacteria_model_step!, +) +models[4] = bacteria_obs + +# Mountain runners +using Agents.Pathfinding +@agent Runner GridAgent{2} begin end +using FileIO + +function initialize_runners(map_url; goal = (128, 409), seed = 88) + heightmap = floor.(Int, convert.(Float64, load(download(map_url))) * 255) + space = GridSpace(size(heightmap); periodic = false) + pathfinder = AStar(space; cost_metric = PenaltyMap(heightmap, MaxDistance{2}())) + model = ABM( + Runner, + space; + rng = MersenneTwister(seed), + properties = Dict(:goal => goal, :pathfinder => pathfinder) + ) + for _ in 1:10 + runner = add_agent!((rand(model.rng, 100:350), rand(model.rng, 50:200)), model) + plan_route!(runner, goal, model.pathfinder) + end + return model +end +runner_step!(agent, model) = move_along_route!(agent, model, model.pathfinder) + +map_url = + "https://raw.githubusercontent.com/JuliaDynamics/" * + "JuliaDynamics/master/videos/agents/runners_heightmap.jpg" +runners_model = initialize_runners(map_url) + +runners_preplot!(ax, model) = scatter!(ax, model.goal; color = (:red, 50), marker = 'x') + +plotkw = ( + figurekwargs = (resolution = (700, 700),), + ac = :black, + as = 8, + unikwargs..., + scatterkwargs = (strokecolor = :white, strokewidth = 2), + heatarray = model -> penaltymap(model.pathfinder), + heatkwargs = (colormap = :terrain,), + static_preplot! = runners_preplot!, +) + +runners_obs = abmplot!(axs[3], runners_model; + plotkw..., unikwargs..., + agent_step! = runner_step!, +) +models[3] = runners_obs + +# Forest fire +function forest_fire(; density = 0.7, griddims = (100, 100), seed = 2) + space = GridSpaceSingle(griddims; periodic = false, metric = :manhattan) + rng = Random.MersenneTwister(seed) + ## The `trees` field is coded such that + ## Empty = 0, Green = 1, Burning = 2, Burnt = 3 + forest = ABM(GridAgent{2}, space; rng, properties = (trees = zeros(Int, griddims),)) + for I in CartesianIndices(forest.trees) + if rand(forest.rng) < density + ## Set the trees at the left edge on fire + forest.trees[I] = I[1] == 1 ? 2 : 1 + end + end + return forest +end +function forest_step!(forest) + ## Find trees that are burning (coded as 2) + for I in findall(isequal(2), forest.trees) + for idx in nearby_positions(I.I, forest) + ## If a neighbor is Green (1), set it on fire (2) + if forest.trees[idx...] == 1 + forest.trees[idx...] = 2 + end + end + ## Finally, any burning tree is burnt out (2) + forest.trees[I] = 3 + end +end +forest_model = forest_fire() +forestkwargs = ( + unikwargs..., + heatarray = :trees, + heatkwargs = ( + colorrange = (0, 3), + colormap = cgrad([:white, :green, :red, :darkred]; categorical = true), + ), +) + +forest_obs = abmplot!(axs[5], forest_model; + forestkwargs..., unikwargs..., model_step! = forest_step!, +) + +models[5] = forest_obs + +# Ants +@agent Ant GridAgent{2} begin + has_food::Bool + facing_direction::Int + food_collected::Int + food_collected_once::Bool +end +AntWorld = ABM{<:GridSpace, Ant} +const adjacent_dict = Dict( + 1 => (0, -1), # S + 2 => (1, -1), # SE + 3 => (1, 0), # E + 4 => (1, 1), # NE + 5 => (0, 1), # N + 6 => (-1, 1), # NW + 7 => (-1, 0), # W + 8 => (-1, -1), # SW +) +const number_directions = length(adjacent_dict) +mutable struct AntWorldProperties + pheremone_trails::Matrix + food_amounts::Matrix + nest_locations::Matrix + food_source_number::Matrix + food_collected::Int + diffusion_rate::Int + tick::Int + x_dimension::Int + y_dimension::Int + nest_size::Int + evaporation_rate::Int + pheremone_amount::Int + spread_pheremone::Bool + pheremone_floor::Int + pheremone_ceiling::Int +end +function initialize_antworld(;number_ants::Int = 125, dimensions::Tuple = (70, 70), diffusion_rate::Int = 50, food_size::Int = 7, random_seed::Int = 2954, nest_size::Int = 5, evaporation_rate::Int = 10, pheremone_amount::Int = 60, spread_pheremone::Bool = false, pheremone_floor::Int = 5, pheremone_ceiling::Int = 100) + rng = Random.Xoshiro(random_seed) + + furthest_distance = sqrt(dimensions[1] ^ 2 + dimensions[2] ^ 2) + + x_center = dimensions[1] / 2 + y_center = dimensions[2] / 2 + + nest_locations = zeros(Float32, dimensions) + pheremone_trails = zeros(Float32, dimensions) + + food_amounts = zeros(dimensions) + food_source_number = zeros(dimensions) + + food_center_1 = (round(Int, x_center + 0.6 * x_center), round(Int, y_center)) + food_center_2 = (round(Int, 0.4 * x_center), round(Int, 0.4 * y_center)) + food_center_3 = (round(Int, 0.2 * x_center), round(Int, y_center + 0.8 * y_center)) + + food_collected = 0 + + for x_val in 1:dimensions[1] + for y_val in 1:dimensions[2] + nest_locations[x_val, y_val] = ((furthest_distance - sqrt((x_val - x_center) ^ 2 + (y_val - y_center) ^ 2)) / furthest_distance) * 100 + food_1 = (sqrt((x_val - food_center_1[1]) ^ 2 + (y_val - food_center_1[2]) ^ 2)) < food_size + food_2 = (sqrt((x_val - food_center_2[1]) ^ 2 + (y_val - food_center_2[2]) ^ 2)) < food_size + food_3 = (sqrt((x_val - food_center_3[1]) ^ 2 + (y_val - food_center_3[2]) ^ 2)) < food_size + food_amounts[x_val, y_val] = food_1 || food_2 || food_3 ? rand(rng, [1, 2]) : 0 + if food_1 + food_source_number[x_val, y_val] = 1 + elseif food_2 + food_source_number[x_val, y_val] = 2 + elseif food_3 + food_source_number[x_val, y_val] = 3 + end + end + end + + properties = AntWorldProperties( + pheremone_trails, + food_amounts, + nest_locations, + food_source_number, + food_collected, + diffusion_rate, + 0, + dimensions[1], + dimensions[2], + nest_size, + evaporation_rate, + pheremone_amount, + spread_pheremone, + pheremone_floor, + pheremone_ceiling + ) + + model = UnremovableABM( + Ant, + GridSpace(dimensions, periodic = false); + properties, + rng, + scheduler = Schedulers.Randomly() + ) + + for n in 1:number_ants + agent = Ant(n, (x_center, y_center), false, rand(model.rng, range(1, 8)), 0, false) + add_agent_pos!(agent, model) + end + return model +end +function detect_change_direction(agent::Ant, model_layer::Matrix) + x_dimension = size(model_layer)[1] + y_dimension = size(model_layer)[2] + left_pos = adjacent_dict[mod1(agent.facing_direction - 1, number_directions)] + right_pos = adjacent_dict[mod1(agent.facing_direction + 1, number_directions)] + + scent_ahead = model_layer[mod1(agent.pos[1] + adjacent_dict[agent.facing_direction][1], x_dimension), + mod1(agent.pos[2] + adjacent_dict[agent.facing_direction][2], y_dimension)] + scent_left = model_layer[mod1(agent.pos[1] + left_pos[1], x_dimension), + mod1(agent.pos[2] + left_pos[2], y_dimension)] + scent_right = model_layer[mod1(agent.pos[1] + right_pos[1], x_dimension), + mod1(agent.pos[2] + right_pos[2], y_dimension)] + + if (scent_right > scent_ahead) || (scent_left > scent_ahead) + if scent_right > scent_left + agent.facing_direction = mod1(agent.facing_direction + 1, number_directions) + else + agent.facing_direction = mod1(agent.facing_direction - 1, number_directions) + end + end +end +function wiggle(agent::Ant, model::AntWorld) + direction = rand(model.rng, [0, rand(model.rng, [-1, 1])]) + agent.facing_direction = mod1(agent.facing_direction + direction, number_directions) +end +function apply_pheremone(agent::Ant, model::AntWorld; pheremone_val::Int = 60, spread_pheremone::Bool = false) + model.pheremone_trails[agent.pos...] += pheremone_val + model.pheremone_trails[agent.pos...] = model.pheremone_trails[agent.pos...] ≥ model.pheremone_floor ? model.pheremone_trails[agent.pos...] : 0 + + if spread_pheremone + left_pos = adjacent_dict[mod1(agent.facing_direction - 2, number_directions)] + right_pos = adjacent_dict[mod1(agent.facing_direction + 2, number_directions)] + + model.pheremone_trails[mod1(agent.pos[1] + left_pos[1], model.x_dimension), + mod1(agent.pos[2] + left_pos[2], model.y_dimension)] += (pheremone_val / 2) + model.pheremone_trails[mod1(agent.pos[1] + right_pos[1], model.x_dimension), + mod1(agent.pos[2] + right_pos[2], model.y_dimension)] += (pheremone_val / 2) + end +end +function diffuse(model_layer::Matrix, diffusion_rate::Int) + x_dimension = size(model_layer)[1] + y_dimension = size(model_layer)[2] + + for x_val in 1:x_dimension + for y_val in 1:y_dimension + sum_for_adjacent = model_layer[x_val, y_val] * (diffusion_rate / 100) / number_directions + for (_, i) in adjacent_dict + model_layer[mod1(x_val + i[1], x_dimension), mod1(y_val + i[2], y_dimension)] += sum_for_adjacent + end + model_layer[x_val, y_val] *= ((100 - diffusion_rate) / 100) + end + end +end +turn_around(agent) = agent.facing_direction = mod1(agent.facing_direction + number_directions / 2, number_directions) + +function ant_step!(agent::Ant, model::AntWorld) + if agent.has_food + if model.nest_locations[agent.pos...] > 100 - model.nest_size + @debug "$(agent.n) arrived at nest with food" + agent.food_collected += 1 + agent.food_collected_once = true + model.food_collected += 1 + agent.has_food = false + turn_around(agent) + else + detect_change_direction(agent, model.nest_locations) + end + apply_pheremone(agent, model, pheremone_val = model.pheremone_amount) + else + if model.food_amounts[agent.pos...] > 0 + agent.has_food = true + model.food_amounts[agent.pos...] -= 1 + apply_pheremone(agent, model, pheremone_val = model.pheremone_amount) + turn_around(agent) + elseif model.pheremone_trails[agent.pos...] > model.pheremone_floor + detect_change_direction(agent, model.pheremone_trails) + end + end + wiggle(agent, model) + move_agent!(agent, (mod1(agent.pos[1] + adjacent_dict[agent.facing_direction][1], model.x_dimension), mod1(agent.pos[2] + adjacent_dict[agent.facing_direction][2], model.y_dimension)), model) +end +function antworld_step!(model::AntWorld) + diffuse(model.pheremone_trails, model.diffusion_rate) + map!((x) -> x ≥ model.pheremone_floor ? x * (100 - model.evaporation_rate) / 100 : 0.0, model.pheremone_trails, model.pheremone_trails) + model.tick += 1 +end + +function antworld_heatmap(model::AntWorld) + heatmap = zeros((model.x_dimension, model.y_dimension)) + for x_val in 1:model.x_dimension + for y_val in 1:model.y_dimension + if model.nest_locations[x_val, y_val] > 100 - model.nest_size + heatmap[x_val, y_val] = 150 + elseif model.food_amounts[x_val, y_val] > 0 + heatmap[x_val, y_val] = 200 + elseif model.pheremone_trails[x_val, y_val] > model.pheremone_floor + heatmap[x_val, y_val] = model.pheremone_trails[x_val, y_val] ≥ model.pheremone_floor ? clamp(model.pheremone_trails[x_val, y_val], model.pheremone_floor, model.pheremone_ceiling) : 0 + else + heatmap[x_val, y_val] = NaN + end + end + end + return heatmap +end + +ant_color(ant::Ant) = ant.has_food ? :red : :black + +plotkwargs = ( + ac = ant_color, as = 20, am = '♦', + heatarray = antworld_heatmap, unikwargs..., + heatkwargs = (colormap = Reverse(:viridis), colorrange = (0, 200),) +) +antworld = initialize_antworld(;number_ants = 125, random_seed = 6666, pheremone_amount = 60, evaporation_rate = 5) + +antworld_obs = abmplot!(axs[6], antworld; + plotkwargs..., unikwargs..., + agent_step! = ant_step!, model_step! = antworld_step!, +) + +models[6] = antworld_obs + +# Fractal growth +@agent FractalParticle ContinuousAgent{2} begin + radius::Float64 + is_stuck::Bool + spin_axis::Array{Float64,1} +end +FractalParticle( + id::Int, + radius::Float64, + spin_clockwise::Bool; + pos = (0.0, 0.0), + is_stuck = false, +) = FractalParticle(id, pos, (0.0, 0.0), radius, is_stuck, [0.0, 0.0, spin_clockwise ? -1.0 : 1.0]) + +rand_circle(rng) = (θ = rand(rng, 0.0:0.1:359.9); (cos(θ), sin(θ))) +function particle_radius(min_radius::Float64, max_radius::Float64, rng) + min_radius <= max_radius ? rand(rng, min_radius:0.01:max_radius) : min_radius +end + +function initialize_fractal(; + initial_particles::Int = 100, # initial particles in the model, not including the seed + ## size of the space in which particles exist + space_extents::NTuple{2,Float64} = (150.0, 150.0), + speed = 0.5, # speed of particle movement + vibration = 0.55, # amplitude of particle vibration + attraction = 0.45, # velocity of particles towards the center + spin = 0.55, # tangential velocity with which particles orbit the center + ## fraction of particles orbiting clockwise. The rest are anticlockwise + clockwise_fraction = 0.0, + min_radius = 1.0, # minimum radius of any particle + max_radius = 2.0, # maximum radius of any particle + seed = 42, +) + properties = Dict( + :speed => speed, + :vibration => vibration, + :attraction => attraction, + :spin => spin, + :clockwise_fraction => clockwise_fraction, + :min_radius => min_radius, + :max_radius => max_radius, + :spawn_count => 0, + ) + ## space is periodic to allow particles going off one edge to wrap around to the opposite + space = ContinuousSpace(space_extents; spacing = 1.0, periodic = true) + model = ABM(FractalParticle, space; properties, rng = Random.MersenneTwister(seed)) + center = space_extents ./ 2.0 + for i in 1:initial_particles + particle = FractalParticle( + i, + particle_radius(min_radius, max_radius, model.rng), + rand(model.rng) < clockwise_fraction, + ) + ## `add_agent!` automatically gives the particle a random position in the space + add_agent!(particle, model) + end + ## create the seed particle + particle = FractalParticle( + initial_particles + 1, + particle_radius(min_radius, max_radius, model.rng), + true; + pos = center, + is_stuck = true, + ) + ## `add_agent_pos!` will use the position of the agent passed in, instead of assigning it + ## to a random value + add_agent_pos!(particle, model) + return model +end +function fractal_particle_step!(agent::FractalParticle, model) + agent.is_stuck && return + + for id in nearby_ids(agent.pos, model, agent.radius) + if model[id].is_stuck + agent.is_stuck = true + ## increment count to make sure another particle is spawned as this one gets stuck + model.spawn_count += 1 + return + end + end + ## radial vector towards the center of the space + radial = model.space.extent ./ 2.0 .- agent.pos + radial = radial ./ norm(radial) + ## tangential vector in the direction of orbit of the particle + tangent = Tuple(cross([radial..., 0.0], agent.spin_axis)[1:2]) + agent.vel = + ( + radial .* model.attraction .+ tangent .* model.spin .+ + rand_circle(model.rng) .* model.vibration + ) ./ (agent.radius^2.0) + move_agent!(agent, model, model.speed) +end + +# The `fractal_step!` function serves the sole purpose of spawning additional particles +# as they get stuck to the growing fractal. +function fractal_step!(model) + while model.spawn_count > 0 + particle = FractalParticle( + nextid(model), + particle_radius(model.min_radius, model.max_radius, model.rng), + rand(model.rng) < model.clockwise_fraction; + pos = (rand_circle(model.rng) .+ 1.0) .* model.space.extent .* 0.49, + ) + add_agent_pos!(particle, model) + model.spawn_count -= 1 + end +end + +model = initialize_fractal() +fparticle_color(a::FractalParticle) = a.is_stuck ? :red : :blue +fparticle_size(a::FractalParticle) = 7.5 * a.radius + +fractal_obs = abmplot!(axs[8], model; + ac = fparticle_color, + as = fparticle_size, + am = '●', + unikwargs..., + agent_step! = fractal_particle_step!, + model_step! = fractal_step!, +) + +models[8] = fractal_obs + +# Social distancing +@agent PoorSoul ContinuousAgent{2} begin + mass::Float64 + days_infected::Int # number of days since is infected + status::Symbol # :S, :I or :R + β::Float64 +end +const steps_per_day = 24 + +function socialdistancing_init(; + infection_period = 30 * steps_per_day, + detection_time = 14 * steps_per_day, + reinfection_probability = 0.05, + isolated = 0.0, # in percentage + interaction_radius = 0.012, + dt = 1.0, + speed = 0.002, + death_rate = 0.044, # from website of WHO + N = 1000, + initial_infected = 5, + seed = 42, + βmin = 0.4, + βmax = 0.8, +) + + properties = (; + infection_period, + reinfection_probability, + detection_time, + death_rate, + interaction_radius, + dt, + ) + space = ContinuousSpace((1,1); spacing = 0.02) + model = ABM(PoorSoul, space, properties = properties, rng = MersenneTwister(seed)) + + ## Add initial individuals + for ind in 1:N + pos = Tuple(rand(model.rng, 2)) + status = ind ≤ N - initial_infected ? :S : :I + isisolated = ind ≤ isolated * N + mass = isisolated ? Inf : 1.0 + vel = isisolated ? (0.0, 0.0) : sincos(2π * rand(model.rng)) .* speed + + ## very high transmission probability + ## we are modelling close encounters after all + β = (βmax - βmin) * rand(model.rng) + βmin + add_agent!(pos, model, vel, mass, 0, status, β) + end + + return model +end + +function transmit!(a1, a2, rp) + ## for transmission, only 1 can have the disease (otherwise nothing happens) + count(a.status == :I for a in (a1, a2)) ≠ 1 && return + infected, healthy = a1.status == :I ? (a1, a2) : (a2, a1) + + rand(model.rng) > infected.β && return + + if healthy.status == :R + rand(model.rng) > rp && return + end + healthy.status = :I +end + +function sir_agent_step!(agent, model) + move_agent!(agent, model, model.dt) + update!(agent) + recover_or_die!(agent, model) +end +update!(agent) = agent.status == :I && (agent.days_infected += 1) +function recover_or_die!(agent, model) + if agent.days_infected ≥ model.infection_period + if rand(model.rng) ≤ model.death_rate + remove_agent!(agent, model) + else + agent.status = :R + agent.days_infected = 0 + end + end +end +function sir_model_step!(model) + r = model.interaction_radius + for (a1, a2) in interacting_pairs(model, r, :nearest) + transmit!(a1, a2, model.reinfection_probability) + elastic_collision!(a1, a2, :mass) + end +end + +sir_model = socialdistancing_init(isolated = 0.8) +sir_colors(a) = a.status == :S ? "#2b2b33" : a.status == :I ? "#bf2642" : "#338c54" + +sir_obs = abmplot!(axs[9], sir_model; +ac = sir_colors, +as = 10, unikwargs..., +agent_step! = sir_agent_step!, model_step! = sir_model_step!, +) + +models[9] = sir_obs + +display(fig) + +record(fig, "showcase.mp4", 1:100; framerate = 10) do i + for j in 1:9 + obs = models[j] + Agents.step!(obs, steps_per_frame[j]) + end +end + +display(fig) \ No newline at end of file diff --git a/docs/logo/logo.jl b/docs/logo/logo.jl index 75fd96c404..4b3a7674ae 100644 --- a/docs/logo/logo.jl +++ b/docs/logo/logo.jl @@ -1,7 +1,6 @@ using Agents, Random using StatsBase: sample using GLMakie -using InteractiveDynamics # Input diff --git a/docs/make.jl b/docs/make.jl index 63c5433e0a..0050585bcf 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,18 +1,38 @@ cd(@__DIR__) -using Pkg; -Pkg.activate(@__DIR__); -const CI = get(ENV, "CI", nothing) == "true" -println("Loading Packages") -println("Documenter...") -using Documenter -println("Agents...") +println("Loading packages...") using Agents -println("Literate...") -import Literate -println("InteractiveDynamics...") -using InteractiveDynamics -println("LightOSM...") using LightOSM +using CairoMakie +import Literate + +pages = [ + "Introduction" => "index.md", + "Tutorial" => "tutorial.md", + "Examples" => [ + "examples/schelling.md", + "examples/sir.md", + "examples/flock.md", + "examples/zombies.md", + "examples/predator_prey.md", + "examples/rabbit_fox_hawk.md", + # "models.md", # I'm removing this from the docs; will be deprecated in the future + "examples.md" + ], + "api.md", + "Plotting and Interactivity" => "examples/agents_visualizations.md", + "Ecosystem Integration" => [ + "BlackBoxOptim.jl" => "examples/optim.md", + "DifferentialEquations.jl" => "examples/diffeq.md", + "Graphs.jl" => "examples/schoolyard.md", + "Measurements.jl" => "examples/measurements.md", + "CellListMap.jl" => "examples/celllistmap.md", + ], + "performance_tips.md", + "comparison.md", + "devdocs.md", +] + +# %% println("Converting Examples...") @@ -26,97 +46,19 @@ for file in readdir(indir) Literate.markdown(joinpath(indir, file), outdir; credit = false) end -# Also bring in visualizations from interactive dynamics docs: -using Literate -infile = joinpath(pkgdir(InteractiveDynamics), "docs", "src", "agents.jl") -outdir = joinpath(@__DIR__, "src") -Literate.markdown(infile, outdir; credit = false, name = "agents_visualizations") - # %% -# download the themes -println("Theme-ing") -using DocumenterTools:Themes +println("Documentation Build") + import Downloads -for file in ( - "juliadynamics-lightdefs.scss", - "juliadynamics-darkdefs.scss", - "juliadynamics-style.scss", -) - Downloads.download( - "https://raw.githubusercontent.com/JuliaDynamics/doctheme/master/$file", - joinpath(@__DIR__, file), - ) -end -# create the themes -for w in ("light", "dark") - header = read(joinpath(@__DIR__, "juliadynamics-style.scss"), String) - theme = read(joinpath(@__DIR__, "juliadynamics-$(w)defs.scss"), String) - write(joinpath(@__DIR__, "juliadynamics-$(w).scss"), header * "\n" * theme) -end -# compile the themes -Themes.compile( - joinpath(@__DIR__, "juliadynamics-light.scss"), - joinpath(@__DIR__, "src/assets/themes/documenter-light.css"), -) -Themes.compile( - joinpath(@__DIR__, "juliadynamics-dark.scss"), - joinpath(@__DIR__, "src/assets/themes/documenter-dark.css"), +Downloads.download( + "https://raw.githubusercontent.com/JuliaDynamics/doctheme/master/build_docs_with_style.jl", + joinpath(@__DIR__, "build_docs_with_style.jl") ) +include("build_docs_with_style.jl") -# %% -println("Documentation Build") -ENV["JULIA_DEBUG"] = "Documenter" -makedocs( - modules = [Agents, InteractiveDynamics, LightOSM], - sitename = "Agents.jl", - authors = "Tim DuBois, George Datseris, Aayush Sabharwal, Ali R. Vahdati and contributors.", - doctest = false, - format = Documenter.HTML( - prettyurls = CI, - assets = [ - asset( - "https://fonts.googleapis.com/css?family=Montserrat|Source+Code+Pro&display=swap", - class = :css, - ), - ], - collapselevel = 1, - ), - pages = [ - "Introduction" => "index.md", - "Tutorial" => "tutorial.md", - "Examples" => [ - "examples/schelling.md", - "examples/sir.md", - "examples/flock.md", - "examples/zombies.md", - "examples/predator_prey.md", - "examples/rabbit_fox_hawk.md", - "models.md", - "examples.md" - ], - "api.md", - "Plotting and Interactivity" => "agents_visualizations.md", - "Ecosystem Integration" => [ - "BlackBoxOptim.jl" => "examples/optim.md", - "DifferentialEquations.jl" => "examples/diffeq.md", - "Graphs.jl" => "examples/schoolyard.md", - "Measurements.jl" => "examples/measurements.md", - "CellListMap.jl" => "examples/celllistmap.md", - ], - "performance_tips.md", - "comparison.md", - "devdocs.md", - ], +build_docs_with_style(pages, Agents, LightOSM; + expandfirst = ["index.md"], + authors = "George Datseris and contributors.", ) -@info "Deploying Documentation" -if CI - deploydocs( - repo = "github.com/JuliaDynamics/Agents.jl.git", - target = "build", - push_preview = true, - devbranch = "main", - ) -end - println("Finished") diff --git a/docs/src/index.md b/docs/src/index.md index 9c8a90abb8..96d987c635 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,37 +1,63 @@ -![Agents.jl](https://github.com/JuliaDynamics/JuliaDynamics/blob/master/videos/agents/agents4_logo.gif?raw=true) +```@docs +Agents +``` -Agents.jl is a pure [Julia](https://julialang.org/) framework for agent-based modeling (ABM). -Agents.jl is part of [JuliaDynamics](https://juliadynamics.github.io/JuliaDynamics/). -To get started, please read the [Tutorial](@ref) page. +```@setup MAIN +using CairoMakie, Agents +``` !!! info "Star us on GitHub!" If you have found this package useful, please consider starring it on [GitHub](https://github.com/JuliaDynamics/Agents.jl). This gives us an accurate lower bound of the (satisfied) user count. -!!! tip "Latest news: Agents.jl v5.5" - New minor release with a major change on creating agents! - - The `@agent` macro has been re-written and is now more general and more safe. It now also allows inheriting fields from any other type. - - The `@agent` macro is now THE way to create agent types for Agents.jl simulations. Directly creating structs by hand is no longer mentioned in the documentation at all. - - In the future, making agent types manually (without `@agent`) may be completely disallowed, resulting in error. Therefore, making agent types manually is considered deprecated. - - The minimal agent types like `GraphAgent` can be used normally as standard types that only have the mandatory fields. This is now clear in the docs. (this was possible also before, just not clear) +!!! tip "Latest news: Agents.jl v5.15" + - Agents.jl moved to Julia 1.9+, and now exports visualization + and interactive applications automatically once Makie (or Makie backends + such as GLMakie) come into scope, using the new package extension system. + The only downside of this is that now to visualize ABMs on open street + maps, the package OSMMakie.jl must be explicitly loaded as well. + InteractiveDynamics.jl is now obsolete. + - Overall big performance increase in the following functionality: random walks, random nearby agents, nearby agent searches. + - DEI-motivated name change for all names that remove agents: + - `genocide! -> remove_all!` + - `kill_agent! -> remove_agent!` + - `UnkillableABM -> UnremovableABM` + - Several new API functions and functionality increase: `random_nearby_position, empty_nearby_position, randomwalk!, random_agent`. + - We have created an objective, fully automated, extensive framework for comparing open source agent based modelling software. It shows that Agents.jl is much faster than competing alternatives (MASON, NetLogo, Mesa). It also shows that models implemented in Agents.jl have significantly smaller and simpler code than MASON or NetLogo. The repository is here: + + +## Highlights + +### Software quality - Please see the [CHANGELOG.md](https://github.com/JuliaDynamics/Agents.jl/blob/main/CHANGELOG.md) for more details! +* Free and open source. +* Small learning curve due to intuitive design based on a modular space-agnostic function-based modelling implementation. +* Extremely high performance when compared to other open source frameworks, routinely being 100x faster versus other ABM frameworks ([proof](https://github.com/JuliaDynamics/ABM_Framework_Comparisons)) +* User-created models typically have much smaller source code versus implementations in other open source ABM frameworks ([proof](https://github.com/JuliaDynamics/ABM_Framework_Comparisons)) +* High quality, extensive documentation featuring tutorials, example ABM implementations, an [extra zoo of ABM examples](https://juliadynamics.github.io/AgentsExampleZoo.jl/dev/), and integration examples with other Julia packages -## Features -* Free, open source and extremely transparent. -* Intuitive simple-to-learn software with high quality, extensive documentation. +```@raw html + +``` + + +### Agent based modelling + * Universal model structure where agents are identified by a unique id: [`AgentBasedModel`](@ref). -* Powerful, feature-full and extendable [API](@ref). -* Modular, function-based design. -* Support for many types of space: arbitrary graphs, regular grids, continuous space, or even instances of Open Street Map. -- Multi-agent support, for interactions between disparate agent species. +* Extendable [API](@ref) that provides out of the box thousands of possible agent actions. +* Support for many types of space: arbitrary graphs, regular grids, continuous space +* Support for simulations on Open Street Maps including support for utilizing the road's max speed limit, finding nearby agents/roads/destinations and pathfinding +* Multi-agent support, for interactions between disparate agent species * Scheduler interface (with default schedulers), making it easy to activate agents in a specific order (e.g. by the value of some property) * Automatic data collection in a `DataFrame` at desired intervals * Aggregating collected data during model evolution * Distributed computing * Batch running and batch data collection -* Customizable visualization support for all kinds of models via the [Makie](https://makie.juliaplots.org/stable/) ecosystem. +* Extensive pathfinding capabilities in continuous or discrete spaces +* Customizable visualization support for all kinds of models via the [Makie](https://makie.juliaplots.org/stable/) ecosystem: publication-quality graphics and video output * Interactive applications for any agent based models, which are created with only 5 lines of code and look like this: ```@raw html @@ -40,14 +66,17 @@ To get started, please read the [Tutorial](@ref) page. ``` -## Installation +## Getting started -The package is in Julia's package list. Install it using this command: +To install Agents.jl, launch Julia and then run this command: ``` using Pkg; Pkg.add("Agents") ``` +To learn how to use Agents.jl, please visit the [Tutorial](@ref) before anything else. + + ## Design philosophy of Agents.jl Agents.jl was designed with the following philosophy in mind: @@ -91,6 +120,16 @@ You're looking for support for Agents.jl? Look no further! Here's some things yo 3. Post a question in the [Julia discourse](https://discourse.julialang.org/) in the category “Modelling and simulations”, using “agent” as a tag! 4. If you believe that you have encountered unexpected behavior or a bug in Agents.jl, then please do open an issue on our [GitHub page](https://github.com/JuliaDynamics/Agents.jl) providing a minimal working example! +## Contributing + +Any contribution to Agents.jl is welcome! For example you can: + +* Add new feature or improve an existing one (plenty to choose from the "Issues" page) +* Improve the existing documentation +* Add new example ABMs into our existing pool of examples +* Report bugs and suggestions in the Issues page + +Have a look at [contributor's guide](https://github.com/SciML/ColPrac) of the SciML organization for some good information on contributing to Julia packages! ## Citation diff --git a/examples/agents_visualizations.jl b/examples/agents_visualizations.jl new file mode 100644 index 0000000000..7660015673 --- /dev/null +++ b/examples/agents_visualizations.jl @@ -0,0 +1,282 @@ +# # Visualizations and Animations for Agent Based Models +# ```@raw html +# +# ``` + +# This page describes functions that can be used with the [Makie](https://docs.makie.org/stable/) +# plotting ecosystem to animate and interact with agent based models. +# ALl the functionality described here uses Julia's package extensions and therefore comes +# into scope once `Makie` (or any of its backends such as `CairoMakie`) gets loaded. + +# The animation at the start of the page is created using the code of this page, see below. + +# The docs are built using versions: +using Pkg +Pkg.status(["Agents", "CairoMakie"]; + mode = PKGMODE_MANIFEST, io=stdout +) + +# ## Static plotting of ABMs + +# Static plotting, which is also the basis for creating custom plots that include +# an ABM plot, is done using the [`abmplot`](@ref) function. Its usage is exceptionally +# straight-forward, and in principle one simply defines functions for how the +# agents should be plotted. Here we will use a pre-defined model, the Daisyworld +# as an example throughout this docpage. +# To learn about this model you can visit the [example hosted at AgentsExampleZoo +# ](https://juliadynamics.github.io/AgentsExampleZoo.jl/dev/examples/daisyworld/), +using Agents, CairoMakie + +daisypath = joinpath(pathof(Agents), "../../", "ext", "src", "daisyworld_def.jl") +include(daisypath) +model, daisy_step!, daisyworld_step! = daisyworld(; + solar_luminosity = 1.0, solar_change = 0.0, scenario = :change +) +model + +# Now, to plot daisyworld we provide a function for the color +# for the agents that depend on the agent properties, and +# a size and marker style that are constants, +daisycolor(a::Daisy) = a.breed # agent color +as = 20 # agent size +am = '✿' # agent marker +scatterkwargs = (strokewidth = 1.0,) # add stroke around each agent +fig, ax, abmobs = abmplot(model; ac = daisycolor, as, am, scatterkwargs) +fig + +# Besides agents, we can also plot spatial properties as a heatmap. +# Here we plot the temperature of the planet by providing the name +# of the property as the "heat array": +heatarray = :temperature +heatkwargs = (colorrange = (-20, 60), colormap = :thermal) +plotkwargs = (; + ac = daisycolor, as, am, + scatterkwargs = (strokewidth = 1.0,), + heatarray, heatkwargs +) + +fig, ax, abmobs = abmplot(model; plotkwargs...) +fig + + +# ```@docs +# abmplot +# ``` + +# ## Interactive ABM Applications + +# Continuing from the Daisyworld plots above, we can turn them into interactive +# applications straightforwardly, simply by providing the stepping functions +# as illustrated in the documentation of [`abmplot`](@ref). +# Note that [`GLMakie`](https://makie.juliaplots.org/v0.15/documentation/backends_and_output/) +# should be used instead of `CairoMakie` when wanting to use the interactive +# aspects of the plots. +fig, ax, abmobs = abmplot(model; + agent_step! = daisy_step!, model_step! = daisyworld_step!, + plotkwargs...) +fig + +# One could click the run button and see the model evolve. +# Furthermore, one can add more sliders that allow changing the model parameters. +params = Dict( + :surface_albedo => 0:0.01:1, + :solar_change => -0.1:0.01:0.1, +) +fig, ax, abmobs = abmplot(model; + agent_step! = daisy_step!, model_step! = daisyworld_step!, + params, plotkwargs...) +fig + +# One can furthermore collect data while the model evolves and visualize them using the +# convenience function [`abmexploration`](@ref) +using Statistics: mean +black(a) = a.breed == :black +white(a) = a.breed == :white +adata = [(black, count), (white, count)] +temperature(model) = mean(model.temperature) +mdata = [temperature, :solar_luminosity] +fig, abmobs = abmexploration(model; + agent_step! = daisy_step!, model_step! = daisyworld_step!, params, plotkwargs..., + adata, alabels = ["Black daisys", "White daisys"], mdata, mlabels = ["T", "L"] +) +nothing # hide + +# ```@raw html +# +# ``` + +# ```@docs +# abmexploration +# ``` + +# ## ABM Videos +# ```@docs +# abmvideo +# ``` +# E.g., continuing from above, +model, daisy_step!, daisyworld_step! = daisyworld() +abmvideo( + "daisyworld.mp4", + model, daisy_step!, daisyworld_step!; + title = "Daisy World", frames = 150, + plotkwargs... +) + +# ```@raw html +# +# ``` + + +# ## Agent inspection + +# It is possible to inspect agents at a given position by hovering the mouse cursor over +# the scatter points in the agent plot. Inspection is automatically enabled for interactive +# applications (i.e. when either agent or model stepping functions are provided). To +# manually enable this functionality, simply add `enable_inspection = true` as an +# additional keyword argument to the `abmplot`/`abmplot!` call. +# A tooltip will appear which by default provides the name of the agent type, its `id`, +# `pos`, and all other fieldnames together with their current values. This is especially +# useful for interactive exploration of micro data on the agent level. + +# ![RabbitFoxHawk inspection example](https://github.com/JuliaDynamics/JuliaDynamics/tree/master/videos/agents/RabbitFoxHawk_inspection.png) + +# The tooltip can be customized by extending `Agents.agent2string`. +# ```@docs +# Agents.agent2string +# ``` + +# ## Creating custom ABM plots +# The existing convenience function [`abmexploration`](@ref) will +# always display aggregated collected data as scatterpoints connected with lines. +# In cases where more granular control over the displayed plots is needed, we need to take +# a few extra steps and utilize the [`ABMObservable`](@ref) returned by [`abmplot`](@ref). +# The same steps are necessary when we want to create custom plots that compose +# animations of the model space and other aspects. + +# ```@docs +# ABMObservable +# ``` +# To do custom animations you need to have a good idea of how Makie's animation system works. +# Have a look [at this tutorial](https://www.youtube.com/watch?v=L-gyDvhjzGQ) if you are +# not familiar yet. + +# create a basic abmplot with controls and sliders +model, = daisyworld(; solar_luminosity = 1.0, solar_change = 0.0, scenario = :change) +fig, ax, abmobs = abmplot(model; + agent_step! = daisy_step!, model_step! = daisyworld_step!, params, plotkwargs..., + adata, mdata, figure = (; resolution = (1600,800)) +) +fig + +# + +abmobs + +# + +# create a new layout to add new plots to to the right of the abmplot +plot_layout = fig[:,end+1] = GridLayout() + +# create a sublayout on its first row and column +count_layout = plot_layout[1,1] = GridLayout() + +# collect tuples with x and y values for black and white daisys +blacks = @lift(Point2f.($(abmobs.adf).step, $(abmobs.adf).count_black)) +whites = @lift(Point2f.($(abmobs.adf).step, $(abmobs.adf).count_white)) + +# create an axis to plot into and style it to our liking +ax_counts = Axis(count_layout[1,1]; + backgroundcolor = :lightgrey, ylabel = "Number of daisies by color") + +# plot the data as scatterlines and color them accordingly +scatterlines!(ax_counts, blacks; color = :black, label = "black") +scatterlines!(ax_counts, whites; color = :white, label = "white") + +# add a legend to the right side of the plot +Legend(count_layout[1,2], ax_counts; bgcolor = :lightgrey) + +# and another plot, written in a more condensed format +ax_hist = Axis(plot_layout[2,1]; + ylabel = "Distribution of mean temperatures\nacross all time steps") +hist!(ax_hist, @lift($(abmobs.mdf).temperature); + bins = 50, color = :red, + strokewidth = 2, strokecolor = (:black, 0.5), +) + +fig + +# Now, once we step the `abmobs::ABMObservable`, the whole plot will be updated +Agents.step!(abmobs, 1) +Agents.step!(abmobs, 1) +fig + +# Of course, you need to actually adjust axis limits given that the plot is interactive +autolimits!(ax_counts) +autolimits!(ax_hist) + +# Or, simply trigger them on any update to the model observable: +on(abmobs.model) do m + autolimits!(ax_counts) + autolimits!(ax_hist) +end + +# and then marvel at everything being auto-updated by calling `step!` :) + +for i in 1:100; step!(abmobs, 1); end +fig + +# ## GraphSpace models +# While the `ac, as, am` keyword arguments generally relate to *agent* colors, markersizes, +# and markers, they are handled a bit differently in the case of [`GraphSpace models`](https://juliadynamics.github.io/Agents.jl/stable/api/#Agents.GraphSpace). +# Here, we collect those plot attributes for each node of the underlying graph which can +# contain multiple agents. +# If we want to use a function for this, we therefore need to handle an iterator of agents. +# Keeping this in mind, we can create an [exemplary GraphSpace model](https://juliadynamics.github.io/Agents.jl/stable/examples/sir/) +# and plot it with [`abmplot`](@ref). +sir_model, sir_agent_step!, sir_model_step! = Models.sir() +city_size(agents_here) = 0.005 * length(agents_here) +function city_color(agents_here) + agents_here = length(agents_here) + infected = count(a.status == :I for a in agents_here) + recovered = count(a.status == :R for a in agents_here) + return RGBf(infected / agents_here, recovered / agents_here, 0) +end + +# To further style the edges and nodes of the resulting graph plot, we can leverage +# the functionality of [GraphMakie.graphplot](https://graph.makie.org/stable/#GraphMakie.graphplot) +# and pass all the desired keyword arguments to it via a named tuple called +# `graphplotkwargs`. +# When using functions for edge color and width, they should return either one color or +# a vector with the same length (or twice) as current number of edges in the underlying +# graph. +# In the example below, the `edge_color` function colors all edges to a semi-transparent +# shade of grey and the `edge_width` function makes use of the special ability of +# `linesegments` to be tapered (i.e. one end is wider than the other). +using Graphs: edges +using GraphMakie: Shell +edge_color(model) = fill((:grey, 0.25), ne(model.space.graph)) +function edge_width(model) + w = zeros(ne(model.space.graph)) + for e in edges(model.space.graph) + push!(w, 0.004 * length(model.space.stored_ids[e.src])) + push!(w, 0.004 * length(model.space.stored_ids[e.dst])) + end + return w +end +graphplotkwargs = ( + layout = Shell(), # node positions + arrow_show = false, # hide directions of graph edges + edge_color = edge_color, # change edge colors and widths with own functions + edge_width = edge_width, + edge_plottype = :linesegments # needed for tapered edge widths +) +fig, ax, abmobs = abmplot(sir_model; + agent_step! = sir_agent_step!, model_step! = sir_model_step!, + as = city_size, ac = city_color, graphplotkwargs) +fig diff --git a/examples/celllistmap.jl b/examples/celllistmap.jl index 030bc7316b..226e7c8f0d 100644 --- a/examples/celllistmap.jl +++ b/examples/celllistmap.jl @@ -43,7 +43,7 @@ using Agents end Particle(; id, pos, vel, r, k, mass) = Particle(id, pos, vel, r, k, mass) -# ## Required and data structures for CellListMap.jl +# ## Required and data structures for CellListMap.jl # # We will use the high-level interface provided by the `PeriodicSystems` module # (requires version ≥0.7.22): @@ -60,23 +60,23 @@ using StaticArrays # 1. `positions`: `CellListMap` requires a vector of (preferentially) static vectors as the positions # of the particles. To avoid creating this array on every call, a buffer to # which the `agent.pos` positions will be copied is stored in this data structure. -# 2. `forces`: In this example, the property to be computed using `CellListMap.jl` is +# 2. `forces`: In this example, the property to be computed using `CellListMap.jl` is # the forces between particles, which are stored here in a `Vector{<:SVector}`, of # the same type as the positions. These forces will be updated by the `map_pairwise!` # function. # # Additionally, the computation with `CellListMap.jl` requires the definition of a `cutoff`, # which will be twice the maximum interacting radii of the particles, and the geometry of the -# the system, given by the `unitcell` of the periodic box. -# -# More complex output data, variable system geometries and other options are supported, -# according to the [CellListMap.PeriodicSystems](https://m3g.github.io/CellListMap.jl/stable/PeriodicSystems/) +# the system, given by the `unitcell` of the periodic box. +# +# More complex output data, variable system geometries and other options are supported, +# according to the [CellListMap.PeriodicSystems](https://m3g.github.io/CellListMap.jl/stable/PeriodicSystems/) # user guide. # # ## Model initialization # We create the model with a keyword-accepting function as is recommended in Agents.jl. # The keywords here control number of particles and sizes. -function initialize_model(; +function initialize_bouncing(; number_of_particles=10_000, sides=SVector(500.0, 500.0), dt=0.001, @@ -162,7 +162,7 @@ end # forces for all particles. The first argument of the call is # the function to be computed for each pair of particles, which closes-over # the `model` data to call the `calc_forces!` function defined above. -# +# function model_step!(model::ABM) ## Update the pairwise forces at this step map_pairwise!( @@ -200,14 +200,14 @@ end # Finally, the function below runs an example simulation, for 1000 steps. function simulate(model=nothing; nsteps=1_000, number_of_particles=10_000) if isnothing(model) - model = initialize_model(number_of_particles=number_of_particles) + model = initialize_bouncing(number_of_particles=number_of_particles) end Agents.step!( model, agent_step!, model_step!, nsteps, false, ) end # Which should be quite fast -model = initialize_model() +model = initialize_bouncing() simulate(model) # compile @time simulate(model) @@ -215,14 +215,14 @@ simulate(model) # compile # to see them bouncing around. The marker size is set by the # radius of each particle, and the marker color by the # corresponding repulsion constant. -using InteractiveDynamics + using CairoMakie CairoMakie.activate!() # hide -model = initialize_model(number_of_particles=1000) +model = initialize_bouncing(number_of_particles=1000) abmvideo( "celllistmap.mp4", model, agent_step!, model_step!; framerate=20, frames=200, spf=5, - title="Bouncing particles with CellListMap.jl acceleration", + title="Softly bouncing particles with CellListMap.jl", as=p -> p.r, # marker size ac=p -> p.k # marker color ) diff --git a/examples/flock.jl b/examples/flock.jl index 3d75b3cd98..d462bd348c 100644 --- a/examples/flock.jl +++ b/examples/flock.jl @@ -47,11 +47,11 @@ end # a model object using default values. function initialize_model(; n_birds = 100, - speed = 1.0, - cohere_factor = 0.25, + speed = 2.0, + cohere_factor = 0.4, separation = 4.0, separate_factor = 0.25, - match_factor = 0.01, + match_factor = 0.02, visual_distance = 5.0, extent = (100, 100), seed = 42, @@ -114,7 +114,7 @@ function agent_step!(bird, model) end # ## Plotting the flock -using InteractiveDynamics + using CairoMakie CairoMakie.activate!() # hide @@ -123,14 +123,15 @@ CairoMakie.activate!() # hide # create a `Polygon`: a triangle with same orientation as the bird's velocity. # It is as simple as defining the following function: -const bird_polygon = Polygon(Point2f[(-0.5, -0.5), (1, 0), (-0.5, 0.5)]) +const bird_polygon = Makie.Polygon(Point2f[(-1, -1), (2, 0), (-1, 1)]) function bird_marker(b::Bird) φ = atan(b.vel[2], b.vel[1]) #+ π/2 + π - scale(rotate2D(bird_polygon, φ), 2) + rotate_polygon(bird_polygon, φ) end -# Where we have used the utility functions `scale` and `rotate2D` to act on a -# predefined polygon. We now give `bird_marker` to `abmplot`, and notice how +# Where we have used the utility functions `scale_polygon` and `rotate_polygon` to act on a +# predefined polygon. `translate_polygon` is also available. +# We now give `bird_marker` to `abmplot`, and notice how # the `as` keyword is meaningless when using polygons as markers. model = initialize_model() diff --git a/examples/predator_prey.jl b/examples/predator_prey.jl index f33375ab24..2b79c6dc82 100644 --- a/examples/predator_prey.jl +++ b/examples/predator_prey.jl @@ -205,7 +205,7 @@ end # %% #src # We will run the model for 500 steps and record the number of sheep, wolves and consumable # grass patches after each step. First: initialize the model. -using InteractiveDynamics + using CairoMakie CairoMakie.activate!() # hide diff --git a/examples/rabbit_fox_hawk.jl b/examples/rabbit_fox_hawk.jl index b020af658f..1a44a6b79a 100644 --- a/examples/rabbit_fox_hawk.jl +++ b/examples/rabbit_fox_hawk.jl @@ -15,8 +15,8 @@ # walk on suitable portions of the map, hawks are capable of flight and can fly over a much # larger region of the map. # -# Similar to the [Predator-prey dynamics](https://juliadynamics.github.io/AgentsExampleZoo.jl/dev/examples/predator_prey_fast/) -# example, agent types are distinguished using a +# Because agents share all their properties, to optimize performance +# agent types are distinguished using a # `type` field. Agents also have an additional `energy` field, which is consumed to move around # and reproduce. Eating food (grass or rabbits) replenishes `energy` by a fixed amount. using Agents, Agents.Pathfinding @@ -375,17 +375,17 @@ function model_step!(model) end # ## Visualization -# Now we use `InteractiveDynamics` to create a visualization of the model running in 3D space +# Now we use `Makie` to create a visualization of the model running in 3D space # # The agents are color-coded according to their `type`, to make them easily identifiable in # the visualization. # ```julia -# using InteractiveDynamics -# using GLMakie # CairoMakie doesn't do 3D plots +# +# using GLMakie # CairoMakie doesn't do 3D plots well # ``` -animalcolor(a) = +function animalcolor(a) if a.type == :rabbit :brown elseif a.type == :fox @@ -393,6 +393,7 @@ animalcolor(a) = else :blue end +end # We use `surface!` to plot the terrain as a mesh, and colour it using the `:terrain` # colormap. Since the heightmap dimensions don't correspond to the dimensions of the space, diff --git a/examples/schelling.jl b/examples/schelling.jl index a7b1f01c51..6f9b5ebf7f 100644 --- a/examples/schelling.jl +++ b/examples/schelling.jl @@ -120,7 +120,7 @@ agent = schelling[2] # by `ABM` because it allows deletion of agents (at a performance deficit) and we # don't need that feature here. # Instead, we should use [`UnremovableABM`](@ref). -# The only change necessary for this to work is to simply change the call to +# The only change necessary for this to work is to simply change the call to # `ABM` to a call to `UnremovableABM`. schelling = UnremovableABM(SchellingAgent, space; properties) @@ -208,16 +208,15 @@ step!(model, agent_step!) step!(model, agent_step!, 3) # ## Visualizing the data + # There is a dedicated tutorial for visualization, animation, and interaction for # agent based models. See [Visualizations and Animations for Agent Based Models](@ref). # We can use the [`abmplot`](@ref) function to plot the distribution of agents on a -# 2D grid at every generation, via the -# [InteractiveDynamics.jl](https://juliadynamics.github.io/InteractiveDynamics.jl/dev/) package -# and the [Makie.jl](http://makie.juliaplots.org/stable/) plotting ecosystem. +# 2D grid at every generation, using the +# and the [Makie](http://makie.juliaplots.org/stable/) plotting ecosystem. # Let's color the two groups orange and blue and make one a square and the other a circle. -using InteractiveDynamics using CairoMakie # choosing a plotting backend CairoMakie.activate!() # hide diff --git a/examples/schoolyard.jl b/examples/schoolyard.jl index 97e1dc0541..ef52201e32 100644 --- a/examples/schoolyard.jl +++ b/examples/schoolyard.jl @@ -140,7 +140,7 @@ model = schoolyard() # ## Visualising the system # Now, we can watch the dynamics of the social system unfold: -using InteractiveDynamics + using CairoMakie CairoMakie.activate!() # hide diff --git a/examples/sir.jl b/examples/sir.jl index d7df81041f..8533573f9e 100644 --- a/examples/sir.jl +++ b/examples/sir.jl @@ -228,7 +228,7 @@ end # At the moment [`abmplot`](@ref) does not plot `GraphSpace`s, but we can still # utilize the [`ABMObservable`](@ref). We do not need to collect data here, # only the current status of the model will be used in visualization -using InteractiveDynamics + using CairoMakie CairoMakie.activate!() # hide abmobs = ABMObservable(model; agent_step!) diff --git a/examples/zombies.jl b/examples/zombies.jl index 3d394c8e84..a93596a5af 100644 --- a/examples/zombies.jl +++ b/examples/zombies.jl @@ -38,7 +38,7 @@ end # Unfortunately one of the population has turned and will begin infecting anyone who # comes close. -function initialise(; seed = 1234) +function initialise_zombies(; seed = 1234) map_path = OSM.test_map() properties = Dict(:dt => 1 / 60) model = ABM( @@ -70,7 +70,7 @@ end # business, but start eating people along the way. Perhaps they can finally express their distaste # for city commuting. -function agent_step!(agent, model) +function zombie_step!(agent, model) ## Each agent will progress along their route ## Keep track of distance left to move this step, in case the agent reaches its ## destination early @@ -91,15 +91,19 @@ function agent_step!(agent, model) end # ## Visualising the fall of humanity -using InteractiveDynamics -using CairoMakie + +# Notice that to visualize Open Street Maps, the package OSMMakie.jl must be loaded +# as well, besides any Makie plotting backend such as CairoMakie.jl. +using CairoMakie, OSMMakie CairoMakie.activate!() # hide -ac(agent) = agent.infected ? :green : :black -as(agent) = agent.infected ? 10 : 8 -model = initialise() +zombie_color(agent) = agent.infected ? :green : :black +zombie_size(agent) = agent.infected ? 10 : 8 +zombies = initialise_zombies() -abmvideo("outbreak.mp4", model, agent_step!; -title = "Zombie outbreak", framerate = 15, frames = 200, as, ac) +abmvideo("outbreak.mp4", zombies, zombie_step!; + title = "Zombie outbreak", framerate = 15, frames = 200, + ac = zombie_color, as = zombie_size +) # ```@raw html #