Architecture
CarboKitten is modular in design. This gives us the advantage of few repetitions in the code, but it also means a little study getting in to developing CarboKitten.
Models
A model is a module that must have certain data structures and methods implemented.
struct Input <: AbstractInput
: contains all input parametersstruct State <: AbstractState
: contains all run-time statefunction initial_state(input)
: creates an initialState
from anInput
function step!(input)(state)
: curried function to advance state and return aFrame
function write_header(fid, input)
: writes meta-data to an open HDF5 file
For most models the Input
and State
structures are generated from a hierarchy of components, which is explained below.
step!
The step!
method deserves some extra attention: this is where the high-level logic of a model goes. Running a model is nothing but a repeated application of step!(input)
on the state. Note that the step!
function is curried: the input
and state
parameters are given on separate occasions. This allows the model to prepare later execution on state
in a more efficient manner. For example, we may allocate memory or prepare quantities derived from input variables.
The step!
function should modify the state and return a Frame
, which is defined as follows:
@kwdef struct Frame
disintegration::Union{Array{Sediment,3},Nothing} = nothing # facies, x, y
production::Union{Array{Sediment,3},Nothing} = nothing
deposition::Union{Array{Sediment,3},Nothing} = nothing
end
Here disintegration
is the amount of sediment that was disintegrated, production
the amount of sediment produced in situ, and deposition
the amount of sediment deposited from the active layer. The only reason why this Frame
is passed on, is for later storage. So the only routine that needs to understand the Frame
structure is the write_frame
method belonging to your output writer (currently H5Writer
or MemoryOutput
).
Components
A model in CarboKitten is composed of components. Each component extends the Input
and/or State
data structures with members and can also provide functions that work on those elements. Components can inherit from each other. For instance, the WaterDepth
component inherits from Boxes
component to have a notion of the grid size, and the TimeIntegration
component to obtain a time coordinate from the state.
A component has the following syntax:
@compose module MyComponent
@mixin DependencyA, DependencyB
using ..Common
@kwdef struct Input <: AbstractInput
...
end
@kwdef struct State <: AbstractState
...
end
...
end
Here the Common
module is a normal Julia module living in the same scope as the component. This Common
module contains common definitions used throughout the CarboKitten code base.
Module Mixins
CarboKitten uses a non-idiomatic method of abstraction to structure the components. The @compose
macro is rather special. It takes care of a form of inheritance that is normally absent in the Julia language. All the modules that are mentioned in a @mixin
clause will be merged into a single module, where all the structs that were defined in those modules are combined. This allows us to layer our code, separating concerns.
The problem is that this abstraction is neither idiomatic for those used to Julia nor those used to traditional object oriented languages. These layers together build two major important structures: Input
and State
. This includes any nested data structures (mainly Facies
for the input data).
Component | Input | Facies | State |
---|---|---|---|
Boxes | box | ||
FaciesBase | facies | label = Nothing | |
Time | time | step | |
WaterDepth | sea_level | sediment_height | |
initial_topography | |||
subsidence_rate | |||
etc. |
The Julia language allows us to work with abstract interfaces by using dispatch, but it has no built-in method to compose fully formed concrete structures like this. Note that the WaterDepth
component defines subsidence_rate
, but to compute water depth from that, we need to know the time. This is why the WaterDepth
component depends/inherits/mixins the Time
component.
Each component is automatically documented with a graph that shows the components it inherits from and which members it adds to the structs.
Because this mechanism is highly idiosyncratic, we're still exploring other ways to modularize CarboKitten.
Model run
A model run is nothing but a loop running the step!
function n
times. The run_model
method is overloaded to provide multiple levels of abstraction. In its most basic form, the for
loop is abstracted into a do
block, so we can run:
state = M.initial_state(input)
run_model(Model{M}, input, state) do step, frame
...
end
Note that the state
variable is mutable, and we can use this to store/handle/inspect intermediate model states.
"""
run_model(f, ::Type{Model{M}}, input::AbstractInput) where M
run_model(f, ::Type{Model{M}}, input::AbstractInput, state::AbstractState) where M
Run a model and send `Frame`s to callback `f`. The type parameter `M` should be a model,
being a module with both `initial_state!` and `step!` defined.
The second version with explicit state is used by `H5Writer` so we can perform an additional
action between creating the initial state and starting the model run (saving metadata to the
HDF5 file).
"""
run_model(f, ::Type{Model{M}}, input::AbstractInput) where M =
run_model(f, Model{M}, input, M.initial_state(input))
function run_model(f, ::Type{Model{M}}, input::AbstractInput, state::AbstractState) where M
step! = M.step!(input)
@progress for w = 1:n_steps(input)
f(w, step!(state))
end
end
module RunModel
import ..CarboKitten: n_steps, run_model, Model, AbstractInput, AbstractState
using ProgressLogging
<<run-model>>
end