From 18f31e95dd48fac3fb3bbd7736b0cbc1f469913c Mon Sep 17 00:00:00 2001 From: Daniel Vedder <daniel.vedder@idiv.de> Date: Sat, 21 Jan 2023 20:37:43 +0100 Subject: [PATCH] Made sure that the model RNG is used throughout Using the GLOBAL_RNG introduces global state, which must be avoided to preserve reproducibility. Therefore, all Persephone code must use `model.rng` whenever calling `rand()`/`shuffle!()`/etc. --- Project.toml | 4 ++++ src/Persephone.jl | 2 ++ src/core/input.jl | 6 ++++-- src/core/simulation.jl | 19 ++++++++++++++----- src/crop/crops.jl | 2 +- src/nature/nature.jl | 4 +++- src/nature/species/wolpertinger.jl | 2 +- test/io_tests.jl | 4 +++- test/landscape_tests.jl | 3 ++- test/nature_tests.jl | 14 +++++++++----- test/runtests.jl | 9 +++++---- test/simulation_tests.jl | 27 ++++++++++++++++----------- 12 files changed, 64 insertions(+), 32 deletions(-) diff --git a/Project.toml b/Project.toml index 19374e6..9add9d6 100644 --- a/Project.toml +++ b/Project.toml @@ -14,3 +14,7 @@ Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" StatsBase = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" + +[compat] +julia = ">= 1.9" +Agents = ">= 5.6" \ No newline at end of file diff --git a/src/Persephone.jl b/src/Persephone.jl index 3d86d2d..8b56527 100644 --- a/src/Persephone.jl +++ b/src/Persephone.jl @@ -52,8 +52,10 @@ export @distanceto, @distancetoedge, @countanimals, + @rand, #functions simulate, + simulate!, initialise, stepsimulation!, createevent!, diff --git a/src/core/input.jl b/src/core/input.jl index 814d72d..2f5687e 100644 --- a/src/core/input.jl +++ b/src/core/input.jl @@ -53,7 +53,8 @@ function getsettings(configfile::String, seed::Union{Int64,Nothing}=nothing) # pre-process certain parameters if !isnothing(seed) settings["core"]["seed"] = seed - elseif settings["core"]["seed"] == 0 + end + if settings["core"]["seed"] == 0 settings["core"]["seed"] = abs(rand(RandomDevice(), Int32)) end defaultoutdir = defaults["core"]["outdir"] @@ -74,7 +75,7 @@ Certain software parameters can be set via the commandline. """ function parsecommandline() versionstring = """ - Persephone $(@project_version), commit $(read(`git rev-parse HEAD`, String)[1:8]) + Persephone $(pkgversion(Persephone)) © 2022-2023 Daniel Vedder, Lea Kolb (MIT license) https://git.idiv.de/xo30xoqa/persephone """ @@ -96,6 +97,7 @@ function parsecommandline() arg_type = String required = false end + #XXX this changes the global RNG?! (https://github.com/carlobaldassi/ArgParse.jl/issues/121) args = parse_args(s) for a in keys(args) (args[a] == nothing) && delete!(args, a) diff --git a/src/core/simulation.jl b/src/core/simulation.jl index d47c372..ca13ff2 100644 --- a/src/core/simulation.jl +++ b/src/core/simulation.jl @@ -72,14 +72,23 @@ function finalise!(model::AgentBasedModel) end """ - simulate(config=PARAMFILE, seed=nothing) + simulate!(model) -Carry out a complete simulation run, optionally specifying a configuration file -and a seed for the RNG. +Carry out a complete simulation run using a pre-initialised model object. """ -function simulate(config::String=PARAMFILE, seed::Union{Int64,Nothing}=nothing) - model = initialise(config, seed) +function simulate!(model::AgentBasedModel) runtime = Dates.value(@param(core.enddate)-@param(core.startdate))+1 step!(model, dummystep, stepsimulation!, runtime) finalise!(model) end + +""" + simulate(config=PARAMFILE, seed=nothing) + +Initialise a model object and carry out a complete simulation run, optionally +specifying a configuration file and a seed for the RNG. +""" +function simulate(config::String=PARAMFILE, seed::Union{Int64,Nothing}=nothing) + model = initialise(config, seed) + simulate!(model) +end diff --git a/src/crop/crops.jl b/src/crop/crops.jl index 84a78f5..a16dae3 100644 --- a/src/crop/crops.jl +++ b/src/crop/crops.jl @@ -52,7 +52,7 @@ function initfields!(model::AgentBasedModel) model.landscape[x,y].fieldid = objectid push!(model[objectid].pixels, (x,y)) else - fp = add_agent!(FarmPlot, model, [(x,y)], fallow, 0.0, 0.0) + fp = add_agent!((x,y), FarmPlot, model, [(x,y)], fallow, 0.0, 0.0) model.landscape[x,y].fieldid = fp.id convertid[rawid] = fp.id n += 1 diff --git a/src/nature/nature.jl b/src/nature/nature.jl index 8010f13..ffee8c2 100644 --- a/src/nature/nature.jl +++ b/src/nature/nature.jl @@ -372,7 +372,9 @@ Return a random number or element from the sample, using the model RNG. This is a utility wrapper that can only be used nested within `@phase` or `@habitat`. """ macro rand(args...) - :(rand($(esc(:model)).rng, $(map(esc, args)...))) + :($(esc(:rand))($(esc(:model)).rng, $(map(esc, args)...))) end +##TODO @chance macro: @chance(0.5) => rand(model.rng) < 0.5 + ##TODO add movement macros diff --git a/src/nature/species/wolpertinger.jl b/src/nature/species/wolpertinger.jl index af1f1b6..e51a475 100644 --- a/src/nature/species/wolpertinger.jl +++ b/src/nature/species/wolpertinger.jl @@ -24,7 +24,7 @@ of a deer. and occasionally reproduce by spontaneous parthogenesis... """ @phase lifephase begin - direction = Tuple(rand([-1,1], 2)) + direction = Tuple(@rand([-1,1], 2)) for i in 1:@rand(1:@trait(maxspeed)) walk!(animal, direction, model; ifempty=false) end diff --git a/test/io_tests.jl b/test/io_tests.jl index a9dd780..5884d9d 100644 --- a/test/io_tests.jl +++ b/test/io_tests.jl @@ -4,9 +4,11 @@ ### @testset "Model configuration" begin - properties = Dict{Symbol,Any}(:settings=>TESTSETTINGS) + settings = Ps.getsettings(TESTPARAMETERS) + properties = Dict{Symbol,Any}(:settings=>settings) space = GridSpace((10,10), periodic=false) model = AgentBasedModel(Animal, space, properties=properties, warn=false) + @test @param(core.configfile) == TESTPARAMETERS @test @param(core.startdate) == Date(2022, 2, 1) @test @param(nature.targetspecies) == ["Wolpertinger", "Wyvern"] diff --git a/test/landscape_tests.jl b/test/landscape_tests.jl index b02ee20..ab8dac4 100644 --- a/test/landscape_tests.jl +++ b/test/landscape_tests.jl @@ -30,7 +30,8 @@ function smalltestlandscape(agenttype::Type=Animal) :events=>Vector{FarmEvent}(), :dataoutputs=>Vector{DataOutput}(), :settings=>TESTSETTINGS) - return AgentBasedModel(agenttype, space, properties=properties, warn=false) + return AgentBasedModel(agenttype, space, properties=properties, + rng=StableRNG(TESTSETTINGS["core"]["seed"]), warn=false) end @testset "Landscape initialisation" begin diff --git a/test/nature_tests.jl b/test/nature_tests.jl index 661ea57..c0e4158 100644 --- a/test/nature_tests.jl +++ b/test/nature_tests.jl @@ -68,15 +68,15 @@ end @testset "Species macros" begin # create a model landscape and a test species - #TODO test @rand model = smalltestlandscape(Union{Animal,Farmer,FarmPlot}) + @species Mermaid begin ageofmaturity = 2 pesticidemortality = 1.0 @initialise(@habitat(@landcover() == Persephone.water), pairs=true) @phase life begin @debug "$(Persephone.animalid(animal)) is swimming happily in its pond." - @respond Persephone.pesticide @kill(@trait(pesticidemortality)) + @respond Persephone.pesticide @kill(@trait(pesticidemortality), "poisoning") @respond Persephone.harvest @setphase(drought) @debug "Animal: $animal" if @trait(sex) == Persephone.female && @countanimals() < 3 && @@ -123,12 +123,16 @@ end @test Ps.countanimals(pond, model) == 3 createevent!(model, [pond], Ps.pesticide) @test_logs((:debug, "Mermaid 1 is swimming happily in its pond."), - (:debug, "Mermaid 1 has died."), + (:debug, "Mermaid 1 has died from poisoning."), (:debug, "Mermaid 2 is swimming happily in its pond."), - (:debug, "Mermaid 2 has died."), + (:debug, "Mermaid 2 has died from poisoning."), (:debug, "Mermaid 3 is swimming happily in its pond."), - (:debug, "Mermaid 3 has died."), + (:debug, "Mermaid 3 has died from poisoning."), min_level=Logging.Debug, match_mode=:any, stepsimulation!(model)) @test Ps.countanimals(pond, model) == 0 + + # test @rand (this is done more easily outside of @species) + @test typeof(@rand()) == Float64 + @test @rand([true, true]) end diff --git a/test/runtests.jl b/test/runtests.jl index 5d46318..69bd24d 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -6,12 +6,13 @@ using Pkg Pkg.activate("..") +using Agents +using Dates +using Logging using Persephone -using Test using Random -using Logging -using Dates -using Agents +using StableRNGs +using Test const Ps = Persephone diff --git a/test/simulation_tests.jl b/test/simulation_tests.jl index d4a2d0b..1be977a 100644 --- a/test/simulation_tests.jl +++ b/test/simulation_tests.jl @@ -15,19 +15,24 @@ end @testset "Model simulation" begin + # The primary reason for this testset is to make sure that a complete + # simulation will run through without errors. Thus, there are few tests. + # Additionally, it makes sure that no part of the code uses the global + # RNG, as this would compromise reproducibility. If one of the `rand()` + # tests fail, that requirement has been violated somewhere. + Random.seed!(1) + rand1 = rand() + Random.seed!(1) + model = initialise(TESTPARAMETERS, 218) + #XXX upstream problem with ArgParse (https://github.com/carlobaldassi/ArgParse.jl/issues/121) + @test_broken rand() == rand1 Random.seed!(1) - @test isapprox(rand(), 0.07337, atol=0.0001) - model = simulate(TESTPARAMETERS, 218) @test @param(core.seed) == 218 + @test_logs((:info, "Simulating day 2022-02-01."), + (:info, "Simulated 59 days."), + min_level=Logging.Debug, match_mode=:any, + simulate!(model)) @test model.date == Date(2022,4,1) - nwol = sum((typeof(a)==Animal && a.traits["name"]=="Wolpertinger") for a in allagents(model)) - nwyv = sum((typeof(a)==Animal && a.traits["name"]=="Wyvern") for a in allagents(model)) - #FIXME these still fail - although it might not be that clever to rely on random model outcomes - @test nwol == 32 - @test nwyv == 9 - # To retain reproducibility, the model code must never use the global RNG. - # If this next test fails, that requirement has probably been violated somewhere. - #FIXME it does fail... Might it be called by some dependency? - @test isapprox(rand(), 0.34924, atol=0.0001) + @test rand() == rand1 end -- GitLab