Warm a simulation to equilibrium before the observed period, watch recovery after, and control which phases are exported
Introduction
Most ecological runs want more than the years for which we have data. A model often needs to warm up to a stable state before the observed period, and to keep running afterward to see how the system recovers from a disturbance. Josh supports this with two optional phases around the observed window:
Spin-up — runs before the observed period, drawing each year’s forcing by resampling historical years, so the system settles into equilibrium.
Observed — the real period, driven 1:1 by the actual data.
Spin-down — runs after the observed period, again resampling historical years, to watch recovery.
The spin-up / spin-down language itself is engine-side — the start spinup / start spindown blocks, the sample discrete uniform form, and how the data year is resampled are part of Josh (see the Josh documentation for the language reference). This tutorial focuses on what changes for you as a joshpy user:
Authoring scenarios — a plain run and a warmed-up run live side by side in one .josh file, selected by name.
The anchored clock — step 0 is always the first observed step, so spin-up steps are negative and spin-down steps run past steps.high.
output_phases — choosing which phases get written to the CSV.
Note
Spin-up / spin-down requires a josh build that includes the feature. As with the other runnable tutorials, this page uses JarMode.DEV.
The model: two simulations in one file
Because simulations are named and selected at run time, spin-up belongs to the scenario, not to the shared data resources. That makes it natural to keep a plain run and a warmed-up run in the same file. Our example defines both Main (no spin-up) and MainWithSpinup:
from pathlib import Pathjosh_source = Path("../../examples/spinup_spindown.josh").read_text()print(josh_source)
# Spin-up / spin-down demo.
#
# Warm up the system to equilibrium for 20 years *before* the observed period
# (0-10), then keep running for 10 years *after* to watch recovery. Forcing for
# the warm-up / cool-down years is drawn by resampling observed years, so no new
# data is needed. The spin-up/spin-down language is engine-side -- see the Josh
# documentation at https://joshsim.org for the language reference.
#
# Two simulations share one file: a plain `Main` and a warmed-up
# `MainWithSpinup`. Pick one at run time with RunConfig(simulation=...).
start simulation Main
grid.size = 1000 m
grid.low = 33.70 degrees latitude, -115.40 degrees longitude
grid.high = 33.72 degrees latitude, -115.42 degrees longitude
grid.patch = "Default"
steps.low = 0 years
steps.high = 10 years
exportFiles.patch = "file:///tmp/spinup_main.csv"
end simulation
start simulation MainWithSpinup
grid.size = 1000 m
grid.low = 33.70 degrees latitude, -115.40 degrees longitude
grid.high = 33.72 degrees latitude, -115.42 degrees longitude
grid.patch = "Default"
steps.low = 0 years
steps.high = 10 years
start spinup
duration = 20 years
year = sample discrete uniform from 0 years to 10 years
end spinup
start spindown
duration = 10 years
year = sample discrete uniform from 5 years to 10 years
end spindown
exportFiles.patch = "file:///tmp/spinup_withspinup.csv"
end simulation
start patch Default
ForeverTree.init = create 5 count of ForeverTree
ForeverTree.step = {
const alive = prior.ForeverTree[prior.ForeverTree.alive]
const offspring = count(prior.ForeverTree[prior.ForeverTree.reproduces])
const newTrees = create offspring of ForeverTree
return alive | newTrees
}
export.treeCount.step = count(ForeverTree)
export.phase.step = meta.phase
export.stepCount.step = meta.stepCount
end patch
start organism ForeverTree
age.init = 0 years
age.step = prior.age + 1 year
mature.step = current.age >= 3 years
alive.init = true
alive.step = {
if (current.mature) {
const roll = sample uniform from 0.0 to 1.0
return roll < 0.85
} else {
return true
}
}
reproduces.init = false
reproduces.step = {
if (current.mature and current.alive) {
const roll = sample uniform from 0.0 to 1.0
return roll < 0.3
} else {
return false
}
}
end organism
start unit years
alias year
alias yr
alias yrs
end unit
A few things to note:
The two simulations share the same grid, steps, and entity logic; only MainWithSpinup adds the start spinup / start spindown blocks.
Each block names a duration (how long the phase runs) and a year (which data year’s forcing is felt, resampled each step).
The patch exports meta.phase and meta.stepCount as ordinary variables so we can see the clock in the output. Exporting them is optional — the engine doesn’t add a phase column on its own.
Running a scenario
You pick the scenario with RunConfig(simulation=...), exactly as for any multi-simulation file. First the plain Main run:
Notice the step range: −20 … 20. The step clock is anchored at the observed period — step 0 is always the first observed step, regardless of how long spin-up is. So:
spin-up counts backward into the negatives (−20 … −1 here),
observed is 0 … 10 (the steps.low/steps.high window),
spin-down continues past the end (11 … 20).
This is the key thing to internalize for analysis: a spun-up run’s step column contains negative values, and step 0 lines up across runs whether or not spin-up exists. The phase column here is just the meta.phase export, which maps cleanly to the step ranges:
min max
phase
spinup -20 -1
observed 0 10
spindown 11 20
Controlling which phases are exported
Spin-up is often throwaway — you want the system warmed up, but you don’t necessarily want 20 years of warm-up rows in your results. RunConfig takes an output_phases option (mirroring output_steps) that emits josh’s --output-phases flag. It’s a comma-separated subset of spinup,observed,spindown; unset means all phases.
The warm-up still runs — its state is the observed run’s initial condition — but its rows are not written. Filtering happens in the engine, so you save the I/O and storage rather than discarding rows after the fact. The same option is available wherever output_steps is: on JobConfig for sweeps, and in bottled run.sh scripts.
Implications for analysis
When you ingest a spun-up run into a registry and query it, the spin-up and spin-down rows are present unless you filtered them at export. A couple of practical notes:
step can be negative. The DuckDB cell_data.step column is a signed integer, so negatives store and query fine — but aggregate statistics (means, CVs, parameter comparisons) will include warm-up and cool-down unless you scope them. The cleanest scoping is WHERE step BETWEEN 0 AND <steps.high> (the observed window), or export with output_phases="observed" in the first place.
phase is a normal variable column. Because we exported meta.phase, it lands in cell_data like any other model variable — queryable directly:
# `full_df` still holds the all-phases run; scope it to the observed window two ways.by_step = full_df[(full_df["step"] >=0) & (full_df["step"] <=10)]by_phase = full_df[full_df["phase"] =="observed"]print(f"observed window by step: {len(by_step)} rows")print(f"observed window by phase: {len(by_phase)} rows")print("equivalent:", len(by_step) ==len(by_phase))
observed window by step: 66 rows
observed window by phase: 66 rows
equivalent: True
Both give the same observed subset — pick whichever reads more clearly in your analysis.
Related
Project Organization — multiple simulations per .josh file, of which spin-up scenarios are a natural example.
Josh documentation — the engine-side language reference for the spin-up / spin-down blocks.