Skip to content
Snippets Groups Projects
Commit 18f31e95 authored by xo30xoqa's avatar xo30xoqa
Browse files

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.
parent e3245182
No related branches found
No related tags found
No related merge requests found
...@@ -14,3 +14,7 @@ Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" ...@@ -14,3 +14,7 @@ Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3"
StatsBase = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" StatsBase = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91"
TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
[compat]
julia = ">= 1.9"
Agents = ">= 5.6"
\ No newline at end of file
...@@ -52,8 +52,10 @@ export ...@@ -52,8 +52,10 @@ export
@distanceto, @distanceto,
@distancetoedge, @distancetoedge,
@countanimals, @countanimals,
@rand,
#functions #functions
simulate, simulate,
simulate!,
initialise, initialise,
stepsimulation!, stepsimulation!,
createevent!, createevent!,
......
...@@ -53,7 +53,8 @@ function getsettings(configfile::String, seed::Union{Int64,Nothing}=nothing) ...@@ -53,7 +53,8 @@ function getsettings(configfile::String, seed::Union{Int64,Nothing}=nothing)
# pre-process certain parameters # pre-process certain parameters
if !isnothing(seed) if !isnothing(seed)
settings["core"]["seed"] = seed settings["core"]["seed"] = seed
elseif settings["core"]["seed"] == 0 end
if settings["core"]["seed"] == 0
settings["core"]["seed"] = abs(rand(RandomDevice(), Int32)) settings["core"]["seed"] = abs(rand(RandomDevice(), Int32))
end end
defaultoutdir = defaults["core"]["outdir"] defaultoutdir = defaults["core"]["outdir"]
...@@ -74,7 +75,7 @@ Certain software parameters can be set via the commandline. ...@@ -74,7 +75,7 @@ Certain software parameters can be set via the commandline.
""" """
function parsecommandline() function parsecommandline()
versionstring = """ versionstring = """
Persephone $(@project_version), commit $(read(`git rev-parse HEAD`, String)[1:8]) Persephone $(pkgversion(Persephone))
© 2022-2023 Daniel Vedder, Lea Kolb (MIT license) © 2022-2023 Daniel Vedder, Lea Kolb (MIT license)
https://git.idiv.de/xo30xoqa/persephone https://git.idiv.de/xo30xoqa/persephone
""" """
...@@ -96,6 +97,7 @@ function parsecommandline() ...@@ -96,6 +97,7 @@ function parsecommandline()
arg_type = String arg_type = String
required = false required = false
end end
#XXX this changes the global RNG?! (https://github.com/carlobaldassi/ArgParse.jl/issues/121)
args = parse_args(s) args = parse_args(s)
for a in keys(args) for a in keys(args)
(args[a] == nothing) && delete!(args, a) (args[a] == nothing) && delete!(args, a)
......
...@@ -72,14 +72,23 @@ function finalise!(model::AgentBasedModel) ...@@ -72,14 +72,23 @@ function finalise!(model::AgentBasedModel)
end end
""" """
simulate(config=PARAMFILE, seed=nothing) simulate!(model)
Carry out a complete simulation run, optionally specifying a configuration file Carry out a complete simulation run using a pre-initialised model object.
and a seed for the RNG.
""" """
function simulate(config::String=PARAMFILE, seed::Union{Int64,Nothing}=nothing) function simulate!(model::AgentBasedModel)
model = initialise(config, seed)
runtime = Dates.value(@param(core.enddate)-@param(core.startdate))+1 runtime = Dates.value(@param(core.enddate)-@param(core.startdate))+1
step!(model, dummystep, stepsimulation!, runtime) step!(model, dummystep, stepsimulation!, runtime)
finalise!(model) finalise!(model)
end 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
...@@ -52,7 +52,7 @@ function initfields!(model::AgentBasedModel) ...@@ -52,7 +52,7 @@ function initfields!(model::AgentBasedModel)
model.landscape[x,y].fieldid = objectid model.landscape[x,y].fieldid = objectid
push!(model[objectid].pixels, (x,y)) push!(model[objectid].pixels, (x,y))
else 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 model.landscape[x,y].fieldid = fp.id
convertid[rawid] = fp.id convertid[rawid] = fp.id
n += 1 n += 1
......
...@@ -372,7 +372,9 @@ Return a random number or element from the sample, using the model RNG. ...@@ -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`. This is a utility wrapper that can only be used nested within `@phase` or `@habitat`.
""" """
macro rand(args...) macro rand(args...)
:(rand($(esc(:model)).rng, $(map(esc, args)...))) :($(esc(:rand))($(esc(:model)).rng, $(map(esc, args)...)))
end end
##TODO @chance macro: @chance(0.5) => rand(model.rng) < 0.5
##TODO add movement macros ##TODO add movement macros
...@@ -24,7 +24,7 @@ of a deer. ...@@ -24,7 +24,7 @@ of a deer.
and occasionally reproduce by spontaneous parthogenesis... and occasionally reproduce by spontaneous parthogenesis...
""" """
@phase lifephase begin @phase lifephase begin
direction = Tuple(rand([-1,1], 2)) direction = Tuple(@rand([-1,1], 2))
for i in 1:@rand(1:@trait(maxspeed)) for i in 1:@rand(1:@trait(maxspeed))
walk!(animal, direction, model; ifempty=false) walk!(animal, direction, model; ifempty=false)
end end
......
...@@ -4,9 +4,11 @@ ...@@ -4,9 +4,11 @@
### ###
@testset "Model configuration" begin @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) space = GridSpace((10,10), periodic=false)
model = AgentBasedModel(Animal, space, properties=properties, warn=false) model = AgentBasedModel(Animal, space, properties=properties, warn=false)
@test @param(core.configfile) == TESTPARAMETERS @test @param(core.configfile) == TESTPARAMETERS
@test @param(core.startdate) == Date(2022, 2, 1) @test @param(core.startdate) == Date(2022, 2, 1)
@test @param(nature.targetspecies) == ["Wolpertinger", "Wyvern"] @test @param(nature.targetspecies) == ["Wolpertinger", "Wyvern"]
......
...@@ -30,7 +30,8 @@ function smalltestlandscape(agenttype::Type=Animal) ...@@ -30,7 +30,8 @@ function smalltestlandscape(agenttype::Type=Animal)
:events=>Vector{FarmEvent}(), :events=>Vector{FarmEvent}(),
:dataoutputs=>Vector{DataOutput}(), :dataoutputs=>Vector{DataOutput}(),
:settings=>TESTSETTINGS) :settings=>TESTSETTINGS)
return AgentBasedModel(agenttype, space, properties=properties, warn=false) return AgentBasedModel(agenttype, space, properties=properties,
rng=StableRNG(TESTSETTINGS["core"]["seed"]), warn=false)
end end
@testset "Landscape initialisation" begin @testset "Landscape initialisation" begin
......
...@@ -68,15 +68,15 @@ end ...@@ -68,15 +68,15 @@ end
@testset "Species macros" begin @testset "Species macros" begin
# create a model landscape and a test species # create a model landscape and a test species
#TODO test @rand
model = smalltestlandscape(Union{Animal,Farmer,FarmPlot}) model = smalltestlandscape(Union{Animal,Farmer,FarmPlot})
@species Mermaid begin @species Mermaid begin
ageofmaturity = 2 ageofmaturity = 2
pesticidemortality = 1.0 pesticidemortality = 1.0
@initialise(@habitat(@landcover() == Persephone.water), pairs=true) @initialise(@habitat(@landcover() == Persephone.water), pairs=true)
@phase life begin @phase life begin
@debug "$(Persephone.animalid(animal)) is swimming happily in its pond." @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) @respond Persephone.harvest @setphase(drought)
@debug "Animal: $animal" @debug "Animal: $animal"
if @trait(sex) == Persephone.female && @countanimals() < 3 && if @trait(sex) == Persephone.female && @countanimals() < 3 &&
...@@ -123,12 +123,16 @@ end ...@@ -123,12 +123,16 @@ end
@test Ps.countanimals(pond, model) == 3 @test Ps.countanimals(pond, model) == 3
createevent!(model, [pond], Ps.pesticide) createevent!(model, [pond], Ps.pesticide)
@test_logs((:debug, "Mermaid 1 is swimming happily in its pond."), @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 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 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, min_level=Logging.Debug, match_mode=:any,
stepsimulation!(model)) stepsimulation!(model))
@test Ps.countanimals(pond, model) == 0 @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 end
...@@ -6,12 +6,13 @@ ...@@ -6,12 +6,13 @@
using Pkg using Pkg
Pkg.activate("..") Pkg.activate("..")
using Agents
using Dates
using Logging
using Persephone using Persephone
using Test
using Random using Random
using Logging using StableRNGs
using Dates using Test
using Agents
const Ps = Persephone const Ps = Persephone
......
...@@ -15,19 +15,24 @@ ...@@ -15,19 +15,24 @@
end end
@testset "Model simulation" begin @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) Random.seed!(1)
@test isapprox(rand(), 0.07337, atol=0.0001)
model = simulate(TESTPARAMETERS, 218)
@test @param(core.seed) == 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) @test model.date == Date(2022,4,1)
nwol = sum((typeof(a)==Animal && a.traits["name"]=="Wolpertinger") for a in allagents(model)) @test rand() == rand1
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)
end end
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment