Minimalist (and minimally intrusive) macro set for extracting information from complex objects, e.g. simulations.
Given a number of complicated data structures MiniObserve
lets you generate functions to extract and print information from these objects by means of a simple, near declarative interface. It can for example be used to extract information from a simulation model at each time step and write that information to a file.
Using MiniObserve has several advantages over hand-written analysis code:
- a concise declarative interface that puts all information in one place
- output variables are declared once and output is automatically formatted correctly => adding and removing output variables becomes much less error-prone
- a lot of tedious, repetitive and thus brittle code can be avoided
Most of the heavy lifting is done by the @observe
macro:
@observe(statstype, arg1 [, arg2...], declarations)
It will generate a custom data type to hold the desired information and an overload of the observe
function that - given a model object - will calculate the information and return a data object.
As a simple example, let us assume we have the following @observe
declaration:
@observe Data model user1 user2 begin
@record "time" model.time
@record "N" Int length(model.population)
for ind in model.population
@stat("capital", MaxMinAcc{Float64}, MeanVarAcc{FloatT}) <| ind.capital
@stat("n_alone", CountAcc) <| has_neighbours(ind)
end
@record u1 user1
@record u2 user1 * user2
end
Then a type Data
will be generated that provides (at least) the following members:
struct Data
time :: Float64
N :: Int
capital :: @NamedTuple{max :: Float64, min :: Float64, mean :: Float64, var :: Float64}
n_alone :: @NamedTuple{N :: Int}
u1 :: Float64
u2 :: Float64
end
Now we can call observe
to obtain a data object filled with the corresponding values:
m = Model()
data = observe(Data, m, 1, 2)
And use print_header
and log_results
(both exported from MiniObserve) to print the content of that data object to a CSV file or a stream:
print_header(stdout, Data)
log_results(stdout, data)
Fundamentally @observe
's operation is very simple, which makes it very flexible, but also easy to break. Any code in the declaration block will be copied into the observe
function verbatim. The only changes @observe
applies are:
- At the beginning of the function a number of local variables containing the single analysis results are created.
- Every occurence of
@record
and@stat
is replaced with the appropriate code to store the result or add it to an accumulator object (see below), respectively. - At the end of the function the constructor of the analysis data type is called, collating all results into one data structure.
@observe
does not perform any further sanity checks on code outside of the "pseudo-macros", so it is the user's responsibility to make sure not to break anything (it is for example a very bad idea to add a return statement to the analysis code).
An important part of MiniObserve is the ability to analyse collections of items by funneling them through "accumulator" objects. This is particularly important for models that operate on populations of objects, such as agent-based or individual-based models.
In the example above this is used in the expression
for ind in model.population
@stat("capital", MaxMinAcc{Float64}, MeanVarAcc{FloatT}) <| ind.capital
@stat("n_alone", CountAcc) <| has_neighbours(ind)
end
The code generated by the macro iterates through model.population
and adds some properties of each element to several accumulators that calculate maximum, minimum, variance and mean or count the number of true predicate values, respectively.
A few simple accumulators are provided in StatsAccumulator
.