Something went wrong on our end
-
Marco Matthies authoredMarco Matthies authored
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
output.jl 9.82 KiB
### Persefone.jl - a model of agricultural landscapes and ecosystems in Europe.
###
### This file includes functions for saving the model output.
###
const LOGFILE = "simulation.log"
## Much of this code was adapted from the GeMM model by Leidinger et al.
## (https://github.com/CCTB-Ecomods/gemm/blob/master/src/output.jl)
"""
createdatadir(outdir, overwrite)
Creates the output directory, dealing with possible conflicts.
"""
function createdatadir(outdir::String, overwrite::Union{Bool,String})
if isdir(outdir)
if overwrite == "ask"
println("The chosen output directory $(outdir) already exists.")
println("Type 'yes' to overwrite this directory. Otherwise, the simulation will abort.")
print("Overwrite? ")
answer = readline()
overwrite = (answer == "yes" || answer == "y")
end
!overwrite ? Base.error("Output directory exists, will not overwrite. Aborting.") :
@warn "Overwriting existing output directory $(outdir)."
#TODO replace with exception
end
mkpath(outdir)
end
"""
modellogger(loglevel, outdir)
Create a logger object that writes output both to screen and to a logfile.
This object is stored as `model.logger` and can then be used with `with_logger()`.
Note: requires [`createdatadir`](@ref) to be run first.
"""
function modellogger(loglevel::String, outdir::String)
!isdir(outdir) && #TODO replace with exception
Base.error("$(outdir) does not exist. Call `createdatadir()` before `modellogger()`.")
loglevel == "debug" ? loglevel = Logging.Debug :
loglevel == "warn" ? loglevel = Logging.Warn :
loglevel == "info" ? loglevel = Logging.Info :
Base.error("Invalid loglevel $loglevel, should be debug/info/warn.") #TODO make exception
#XXX If this is a parallel run, should we turn off logging to screen?
logfile = open(joinpath(outdir, LOGFILE), "w+")
TeeLogger(ConsoleLogger(logfile, loglevel),
ConsoleLogger(stdout, loglevel))
end
"""
withtestlogger(model)
Replace the model logger with the currently active logger. This is intended to be used
in the testsuite to circumvent a [Julia issue](https://github.com/JuliaLang/julia/issues/48456),
where `@test_logs` doesn't work with local loggers.
"""
function withtestlogger(model::AgentBasedModel)
# copied together from https://github.com/JuliaLang/julia/blob/master/base/logging.jl
logstate = current_task().logstate
logstate == nothing ? model.logger = global_logger() : model.logger = logstate.logger
model
end
"""
saveinputfiles(model)
Copy all input files into the output directory, including the actual parameter
settings used. This allows replicating a run in future.
"""
function saveinputfiles(model::AgentBasedModel)
#XXX If this is a parallel run, we should save the global config to the top-level
# output directory
@debug "Setting up output directory $(@param(core.outdir))."
currentcommit = read(`git rev-parse HEAD`, String)[1:8]
open(joinpath(@param(core.outdir), basename(@param(core.configfile))), "w") do f
println(f, "#\n# --- Persefone configuration parameters ---")
println(f, "# This file was generated automatically.")
println(f, "# Simulation run on $(string(Dates.format(Dates.now(), "d u Y HH:MM:SS"))),")
# Record the current git commit and versions of dependencies for reproducibility
println(f, "# with Persefone $(pkgversion(Persefone)), git commit $(currentcommit),")
println(f, "# running on Julia $(VERSION) with Agents.jl $(pkgversion(Agents)).\n#\n")
if !isempty(strip(read(`git status -s`, String)))
println(f, "# WARNING: Your repository contains uncommitted changes. This may")
println(f, "# compromise the reproducibility of this simulation run.\n")
end
TOML.print(f, prepareTOML(model.settings))
end
# Copy the map files to the output folder
lcmap = @param(world.landcovermap)
ffmap = @param(world.farmfieldsmap)
#TODO replace errors with exceptions
!(isfile(lcmap)) && Base.error("The map file $(lcmap) doesn't exist.")
!(isfile(ffmap)) && Base.error("The map file $(ffmap) doesn't exist.")
cp(lcmap, joinpath(@param(core.outdir), basename(lcmap)), force = true)
cp(ffmap, joinpath(@param(core.outdir), basename(ffmap)), force = true)
end
"""
prepareTOML(dict)
An internal utility function to re-convert the one-dimensional dict created
by [`flattenTOML`](@ref) into the two-dimensional dict needed by `TOML.print`,
and convert any data types into TOML-compatible types where necessary.
"""
function prepareTOML(settings)
# convert data types
settings["core.loglevel"] == Logging.Debug ? settings["core.loglevel"] = "debug" :
settings["core.loglevel"] == Logging.Warn ? settings["core.loglevel"] = "warn" :
settings["core.loglevel"] = "info"
# convert dict structure
fulldict = Dict{String, Dict{String, Any}}()
for parameter in keys(settings)
domain, param = split(parameter, ".")
!(domain in keys(fulldict)) && (fulldict[domain] = Dict{String,Any}())
fulldict[domain][param] = settings[parameter]
end
fulldict
end
"""
DataOutput
A struct for organising model output. This is used to collect model data
in an in-memory dataframe or for CSV output. Submodels can register their
own output functions using [`newdataoutput!`](@ref).
Struct fields:
- name: a string identifier for the data collection (used as file name)
- header: a list of column names
- outputfunction: a function that takes a model object and returns data values to record (formatted as a vector of vectors)
- frequency: how often to call the output function (daily/monthly/yearly/end/never)
- plotfunction: a function that takes a model object and returns a Makie figure object (optional)
"""
struct DataOutput
name::String
header::Vector{String}
outputfunction::Function
frequency::String
plotfunction::Union{Function,Nothing}
end
"""
newdataoutput!(model, name, header, outputfunction, frequency)
Create and register a new data output. This function must be called by all submodels
that want to have their output functions called regularly.
"""
function newdataoutput!(model::AgentBasedModel, name::String, header::Vector{String},
outputfunction::Function, frequency::String,
plotfunction::Union{Function,Nothing}=nothing)
if !(frequency in ("daily", "monthly", "yearly", "end", "never"))
Base.error("Invalid frequency '$frequency' for $name.") #TODO replace with exception
end
ndo = DataOutput(name, header, outputfunction, frequency, plotfunction)
append!(model.dataoutputs, [ndo])
if frequency != "never"
if @param(core.csvoutput)
open(joinpath(@param(core.outdir), name*".csv"), "w") do f
println(f, join(header, ","))
end
end
if @param(core.storedata)
df = DataFrame()
for h in header
df[!,h] = Any[] #XXX allow specifying types?
end
model.datatables[name] = df
end
end
end
"""
outputdata(model, force=false)
Cycle through all registered data outputs and activate them according to their
configured frequency. If `force` is `true`, activate all outputs regardless
of their configuration.
"""
function outputdata(model::AgentBasedModel, force=false)
#XXX enable output every X days, or weekly?
#XXX all output functions except for "end" are run on the first update
# -> should they all be run on the last update, too?
startdate = @param(core.startdate)
isnextmonth = d -> (day(d) == day(startdate))
isnextyear = d -> (month(d) == month(startdate) && day(d) == day(startdate))
for output in model.dataoutputs
(!force && output.frequency == "never") && continue
# check if this output should be activated today
if force || (output.frequency == "daily") ||
(output.frequency == "monthly" && isnextmonth(model.date)) ||
(output.frequency == "yearly" && isnextyear(model.date)) ||
(output.frequency == "end" && model.date == @param(core.enddate))
data = output.outputfunction(model)
if @param(core.csvoutput)
open(joinpath(@param(core.outdir), output.name*".csv"), "a") do f
for row in data
println(f, join(row, ","))
end
end
end
if @param(core.storedata)
for row in data
push!(model.datatables[output.name], row)
end
end
end
end
end
"""
visualiseoutput(model)
Cycle through all data outputs and call their respective plot functions,
saving each figure to file.
"""
function visualiseoutput(model::AgentBasedModel)
@debug "Visualising output."
#CairoMakie.activate!() # make sure we're using Cairo
for output in model.dataoutputs
isnothing(output.plotfunction) && continue
figure = output.plotfunction(model)
save(joinpath(@param(core.outdir), output.name*"."*@param(core.figureformat)),
figure)
end
end
"""
savemodelobject(model, filename)
Serialise a model object and save it to file for later reference.
Includes the current model and Julia versions for compatibility checking.
WARNING: produces large files (>100 MB) and takes a while to execute.
"""
function savemodelobject(model::AgentBasedModel, filename::String)
object = Dict("model"=>model,
"modelversion"=>pkgversion(Persefone),
"juliaversion"=>VERSION)
!endswith(filename, ".dat") && (filename *= ".dat")
filename = joinpath(@param(core.outdir), filename)
serialize(filename, object)
@debug "Saved model object to $(filename)."
end