The SimOptDecisions Architecture
Why the framework is designed this way
The Core Idea
SimOptDecisions separates a decision problem into three concerns:
- What might happen — represented by
Scenarioobjects - What we decide — represented by
Policyobjects - How the system responds — defined by user callbacks (
run_timestep,compute_outcome, etc.)
This separation is not arbitrary. It reflects the structure of decision problems under deep uncertainty, where the same physical system must be evaluated under many different assumptions about the future and many different candidate decisions.
The Five Types
| Type | What it represents | Varies across… |
|---|---|---|
Config |
Fixed system parameters (house size, planning horizon) | Nothing—shared by all runs |
Scenario |
One plausible future (storm levels, damage parameters, discount rate) | The scenario ensemble |
State |
System memory at a point in time (current elevation, cumulative damage) | Time steps within a simulation |
Action |
What is done at a single time step (elevate by X feet) | Time steps, depending on policy |
Policy |
A decision rule that maps state to action | The policy search space |
Why separate Config from Scenario?
Config holds quantities that are not uncertain: the house exists, it has a certain size, you’re planning over a fixed horizon. Scenario holds quantities that are uncertain: how bad will storms be? What discount rate should we use?
This distinction matters because explore() and optimize() loop over scenarios and policies but hold the config fixed. Putting non-uncertain parameters in Config avoids needlessly varying them.
Why pre-generate scenario data?
Scenarios in SimOptDecisions contain pre-generated data (e.g., a 70-year time series of water levels), not distribution parameters. This is a deliberate design choice with three benefits:
- Reproducibility: The same scenario always produces the same simulation result, regardless of execution order or parallelism.
- Common Random Numbers (CRN): Every policy sees the exact same water level sequence for a given scenario, so differences in outcomes are due to the policy, not random noise.
- Inspectability: You can examine, plot, and filter scenarios before running any simulations.
Why is Policy a rule, not a fixed choice?
A Policy maps system state to an action via get_action(policy, state, timestep, scenario). For a simple problem, this might always return the same action (elevate 4 feet). But the same interface supports adaptive policies that condition on state (e.g., “elevate further if cumulative damages exceed a threshold”). This is important for DMDU because real-world decisions are often sequential and adaptive.
The Five Callbacks
Rather than asking for a single run_model() function, SimOptDecisions decomposes the simulation into five callbacks:
| Callback | Purpose |
|---|---|
initialize(config, scenario, rng) |
Create the initial State |
time_axis(config, scenario) |
Define the time points (e.g., 1:70) |
get_action(policy, state, t, scenario) |
Map policy + state → action |
run_timestep(state, action, t, config, scenario, rng) |
Execute one time step; return new state and step record |
compute_outcome(step_records, config, scenario) |
Aggregate step records into a single outcome |
This decomposition enables the framework to:
- Inject CRN: The
rngargument ensures reproducible random streams per scenario - Support tracing: Step records capture the full simulation history when needed
- Parallelize: Each (policy, scenario) pair runs independently
- Store incrementally: Results can be streamed to disk via storage backends
Parameter Types and explore()
explore() assembles results into a YAXArray Dataset with labeled dimensions (:policy, :scenario). For this to work, Scenario, Policy, and Outcome fields must be parameter types (ContinuousParameter, DiscreteParameter, etc.).
The definition macros handle wrapping automatically:
@scenariodef MyScenario begin
@continuous growth_rate # becomes ContinuousParameter{T}
@timeseries water_levels # becomes TimeSeriesParameter{T,Int}
end
@outcomedef MyOutcome begin
@continuous total_cost # becomes ContinuousParameter{T}
endThis is what makes the result matrix indexable:
result[:total_cost][policy=3, scenario=42] # single value
result[:total_cost].data[3, :] # all scenarios for policy 3simulate() and optimize() do not require parameter wrappers—plain NamedTuples work. Only explore() needs the wrapper types so it can build the right array structure.