Run and Perturb a Real Ecosystem in 30 Minutes¶
Tutorial validated against OSMOSE Python 0.12.0 (commit
3db8d5f). Results may differ slightly on other versions. The underlying Java engine is not required — this tutorial uses the Python in-memory engine exclusively.
This tutorial runs the calibrated Baltic Sea 8-species OSMOSE configuration, zooms in on three focal species (cod, sprat, stickleback), then perturbs cod’s predation accessibility to sprat and observes the trophic cascade.
You do not need to know marine ecology to follow along. Every ecological term is defined on first use, and the tutorial is structured so you see results before explanations.
Audience: Python-fluent developers or scientists new to OSMOSE. You should
be comfortable with pip, virtual environments, and running Python scripts.
No fisheries background required.
Time: approximately 30 minutes (including ~25 s Numba JIT warmup on first run; subsequent runs complete in under 5 s).
What you will produce:
tutorial-work/biomass.html— interactive Plotly time-series of the three focal species over 30 simulated years.tutorial-work/biomass_perturbed.html— the same plot after dropping cod’s access to sprat, showing the cascade signal.Terminal output of equilibrium biomass means for both runs.
Before you start¶
1. Install OSMOSE Python¶
From the repository root:
pip install -e ".[dev]"
This installs the package in editable mode including Plotly and all development dependencies. Verify the installation with:
python -c "import osmose; print(osmose.__version__)"
Expected output: 0.12.0
2. Numba JIT warning¶
OSMOSE’s Python engine uses Numba-JIT-compiled kernels. The first
invocation compiles and caches the kernels (~25 s); every subsequent call
is fast (<5 s for a 30-year Baltic run). The compilation is silent by
default. If you see messages like numba: compiling …, that is normal.
To pre-warm the cache before running the tutorial:
python -c "from osmose.engine import PythonEngine; PythonEngine()._warmup()"
3. Version stamp¶
The version shipped with this tutorial:
0.12.0
If your installed version differs, equilibrium biomass values in Beats 2-4 may differ from the printed reference numbers — but the qualitative story (cod << stickleback << sprat) will be the same.
Beat 1 — Paste, run, observe¶
Goal: run the 30-year Baltic simulation and open the biomass plot in your
browser. No editing required — paste the entire block below into a file
called tutorial.py and run it.
"""30-minute OSMOSE tutorial: run and perturb a Baltic ecosystem.
See docs/tutorials/30-minute-ecosystem.md for the narrative.
Run from any directory:
python tutorial.py
"""
from pathlib import Path
import shutil
import osmose
import plotly.express as px
from osmose.engine import PythonEngine
from osmose.config.reader import OsmoseConfigReader
# -- [Beat 1] Locate Baltic source data inside the installed package ----------
BALTIC_SRC = Path(osmose.__file__).resolve().parent.parent / "data" / "baltic"
if not BALTIC_SRC.exists():
raise FileNotFoundError(
f"Baltic data not found at {BALTIC_SRC}.\n"
"Install the package from the repository root: pip install -e '.[dev]'"
)
# -- [Beat 1] Set up a working directory next to wherever you run from --------
WORK = Path("tutorial-work").absolute()
WORK.mkdir(exist_ok=True)
BALTIC = WORK / "baltic"
# Copy the calibrated Baltic config into our workdir on first run.
# Subsequent runs preserve any reader edits (e.g., Beat 6 perturbation).
if not BALTIC.exists():
shutil.copytree(BALTIC_SRC, BALTIC)
# -- [Beat 2] Load the config; override nyear=30 for tutorial pacing ----------
# The canonical Baltic calibration runs 50 years. 30 years captures the key
# transient dynamics (sprat plateau ~yr 20, stickleback boom-bust yr 4-15,
# cod near baseline throughout) while keeping the tutorial fast.
cfg = OsmoseConfigReader().read(str(BALTIC / "baltic_all-parameters.csv"))
cfg["simulation.time.nyear"] = "30"
# -- [Beat 3] Run the simulation -----------------------------------------------
# First run: ~25 s (Numba JIT compilation). Subsequent runs: <5 s.
# seed=42 makes the stochastic run reproducible.
print("Running Baltic simulation (30 years) ...")
result = PythonEngine().run_in_memory(config=cfg, seed=42)
print("Simulation complete.")
# -- [Beat 4] Reshape biomass to tidy long form --------------------------------
# result.biomass() returns a wide DataFrame:
# columns = [Time, cod, herring, sprat, ..., species]
# where "species" is a constant "all" sentinel. We drop it before melting.
bio_wide = result.biomass()
drop_cols = [c for c in ["species"] if c in bio_wide.columns]
bio_long = bio_wide.drop(columns=drop_cols).melt(
id_vars="Time", var_name="species", value_name="biomass"
)
# -- [Beat 5] Filter to the three focal species --------------------------------
# Cod (Gadus morhua): top-level predator, overfished in the Baltic.
# Sprat (Sprattus sprattus): abundant planktivore; key forage fish.
# Stickleback (Gasterosteus aculeatus): small invertivore; indicator species.
focal = ["cod", "sprat", "stickleback"]
bio_focal = bio_long[bio_long["species"].isin(focal)].copy()
# -- [Beat 1] Write the interactive Plotly chart -------------------------------
fig = px.line(
bio_focal,
x="Time",
y="biomass",
color="species",
log_y=True, # cod (~12t) is invisible against sprat (~7M) on linear scale
title="Baltic Sea -- 3 focal species, biomass over 30 years (seed=42)",
labels={"Time": "Year", "biomass": "Biomass (tonnes)"},
template="plotly_white",
)
html_path = WORK / "biomass.html"
fig.write_html(html_path)
print(f"\nOpen in your browser: {html_path}")
# -- Equilibrium summary (years 25-30 mean) ------------------------------------
eq = bio_focal[bio_focal["Time"] >= 25].groupby("species")["biomass"].mean()
print("\nEquilibrium biomass (years 25-30 mean):")
print(eq.to_string())
Run it:
python tutorial.py
Expected terminal output (values approximate):
Running Baltic simulation (30 years) ...
Simulation complete.
Open in your browser: /path/to/tutorial-work/biomass.html
Equilibrium biomass (years 25-30 mean):
species
cod ~1 000 t
sprat ~5 500 000 t
stickleback ~550 000 t
Open tutorial-work/biomass.html in your browser.
What you should see:
Sprat climbs from near-zero to a plateau around 6–7 million tonnes by year 20. It is the dominant species by biomass throughout the run.
Stickleback explodes to a peak of roughly 7 million tonnes around year 4, then crashes back to near-zero by year 15. This boom-bust is genuine Baltic dynamics: stickleback population explosions during regime shifts are documented in the literature. It is not a model artefact.
Cod stays near 10–50 tonnes throughout — roughly five orders of magnitude below sprat. On the log y-axis you can trace its faint trajectory: a slight rise in the first decade, then a slow decline. Baltic cod has been ecologically suppressed by overfishing and grey-seal predation for decades; this model reflects that present-day state, not the historical 1970s population.
The plot uses a log y-axis because biomass spans five or more orders of magnitude. On a linear scale, cod would be a flat line indistinguishable from zero and stickleback’s boom-bust would be invisible against sprat’s plateau.
If your plot looks substantially different — all-zero lines, runaway exponentials, or a species missing entirely — stop and consult the troubleshooting table at the bottom of this tutorial before reading on.
The plot is interactive: hover to read exact values; click a legend label to toggle a species on or off.
If the script fails outright, see the Troubleshooting section at the end of this document.
What just happened? (Beat 1 debrief)¶
The Baltic Sea ecosystem¶
The Baltic Sea is one of the most studied marine ecosystems in the world. It has eight modelled species in this OSMOSE configuration:
Code |
Common name |
Trophic level |
|---|---|---|
cod |
Atlantic cod |
~4.0 |
herring |
Atlantic herring |
~3.2 |
sprat |
European sprat |
~3.0 |
flounder |
European flounder |
~3.5 |
stickleback |
3-spine stickleback |
~3.2 |
perch |
European perch |
~3.5 |
pikeperch |
Pikeperch |
~4.0 |
smelt |
European smelt |
~3.0 |
We highlight three species because they illustrate a trophic cascade (Beat 6): cod eats sprat, cod eats stickleback — so changes in cod’s feeding opportunity ripple through the food web.
How OSMOSE works¶
OSMOSE is an Individual-Based Model (IBM): every school (cohort) of fish is tracked separately through time. At each time step (24 per year in the Baltic config), the engine:
Grows each school according to the von Bertalanffy growth equation.
Feeds each school: schools overlap spatially and predators consume accessible prey proportionally to encounter probability.
Reproduces stock-recruit relationships produce new age-0 cohorts.
Applies mortality: fishing, starvation, out-of-domain losses.
The Python engine (osmose.engine.PythonEngine) re-implements the Java
reference engine in NumPy/Numba and runs fully in-process, enabling fast
parameter sweeps and notebook integration.
Beat 2 — The grid and the clock¶
The script’s # -- [Beat 2] comment anchors the config-loading step. Here
is what each piece of the configuration means.
The master config file. data/baltic/baltic_all-parameters.csv is the
single entry point. It does not contain all parameters directly — instead it
includes every other CSV in the directory via lines like:
include;data/baltic/baltic_species-parameters.csv
include;data/baltic/predation-accessibility.csv
OsmoseConfigReader().read(...) resolves those includes recursively,
merges them into a single flat dict, and returns a value the Python engine
(PythonEngine) can consume without touching the file system again.
The Baltic grid. The simulation domain is a 50 × 40 cell grid covering the Baltic Sea basin: longitude 10°–30°E, latitude 54°–66°N. Each cell is approximately 40 km wide × 33 km tall (~1 320 km²). Of the 2 000 cells, 612 are ocean — the remainder are land masked out by the Baltic coastline.
The clock. The tutorial overrides simulation.time.nyear to 30:
# in the script, after reading the config:
cfg["simulation.time.nyear"] = "30"
The canonical calibrated run uses 50 years. 30 years is enough to capture the system’s transient dynamics: sprat reaches its growth plateau by year 20; stickleback boom-busts in the first decade; cod remains near its post-collapse baseline throughout. The cascade signal we care about in Beat 6 is clearly present at year 25, where the equilibrium-window comparison (years 25–30 mean) captures the system’s transient mean for each species.
Within each year the clock ticks 24 times (simulation.time.ndtperyear=24),
giving a fortnightly time step. A 30-year run therefore executes 720 time
steps.
Beat 3 — The 8 species, with 3 highlighted¶
Baltic food web in brief. The eight focal species form a complete Baltic food web spanning four trophic levels:
Apex predators: cod (Gadus morhua), flounder (Platichthys flesus), perch (Perca fluviatilis), pikeperch (Sander lucioperca). These eat smaller fish and invertebrates.
Mid-trophic planktivores: herring (Clupea harengus), sprat (Sprattus sprattus), stickleback (Gasterosteus aculeatus), smelt (Osmerus eperlanus). These eat zooplankton and small invertebrates.
Lower trophic level (LTL) forcing: six groups of phytoplankton and zooplankton (see Beat 4) provide the food chain’s foundation.
We highlight cod / sprat / stickleback because they form a clean three-level cascade chain that is easy to perturb and measure:
Species |
Linf |
Lifespan |
Role in the cascade |
|---|---|---|---|
cod |
110 cm |
20 yr |
apex predator; eats herring, sprat, stickleback, smelt, and cannibal |
sprat |
16 cm |
8 yr |
small pelagic planktivore; main forage fish for cod |
stickleback |
8 cm |
4 yr |
tiny coastal forage fish; under some cod predation pressure |
Baltic dynamics over 30 years are transient rather than steady-state: sprat reaches its growth plateau by year 20; stickleback boom-busts in the first decade; cod remains at its post-collapse baseline throughout. The equilibrium-window comparison used in Beat 6 (years 25–30 mean) captures the system’s transient mean — which is the meaningful quantity to compare between baseline and perturbed runs.
A note on real Baltic dynamics. Grey-seal predation on cod is a major
ecological force in the present-day Baltic. The model proxies it via an
additional mortality rate (mortality.additional.rate.sp0=0.2 yr⁻¹) on
cod, which is why cod biomass stays low even though cod is not heavily fished
in the simulation. Post-2015 Baltic cod also shows documented growth
impairment linked to hypoxia and energy deficits — this is NOT currently
represented in the model, so the cod in the simulation is a healthy-growth cod
under extra mortality, not an impaired one.
For the other five species (herring, flounder, perch, pikeperch, smelt) and
full parameter provenance, see docs/baltic_example.md.
Beat 4 — LTL forcing: where the food chain starts¶
OSMOSE does not dynamically model phytoplankton or zooplankton. Instead it reads Lower Trophic Level (LTL) biomass from a NetCDF file at each time step. In the Baltic configuration:
The data source. LTL forcing comes from the CMEMS Baltic biogeochemistry
reanalysis product (cmems_mod_bal_bgc_anfc_P1M-m). Depth-integrated
0–50 m monthly fields are extracted, regridded onto the 50 × 40 Baltic grid,
and stored in data/baltic/baltic_ltl_biomass.nc (12 monthly frames,
one per LTL group, units: g wet weight m⁻²).
Six LTL groups. The NetCDF contains six variables:
diatoms (phytoplankton, large)
dinoflagellates (phytoplankton, small)
microzooplankton (< 0.2 mm)
mesozooplankton (0.2 – 2 mm) ← most important forage for sprat/herring
macrozooplankton (> 2 mm)
benthos (combined benthic invertebrates)
How it is generated. The tutorial’s canonical 2024-vintage NetCDF is
already present in the repository. If you need to refresh it (e.g., for a
different year or domain), the generator is at
mcp_servers/copernicus/server.py::generate_osmose_ltl() — it requires
CMEMS credentials stored in .env.
Why this matters. The foundation of the food chain is real ocean observation data, refreshed annually from satellite and in-situ reanalysis. When sprat collapses in the model, it is because the model’s zooplankton abundance — drawn from real data — cannot support that sprat biomass. The cascade you will induce in Beat 6 therefore propagates through a real LTL substrate, not an arbitrary constant.
Beat 5 — Who eats whom: the predation accessibility matrix¶
Open tutorial-work/baltic/predation-accessibility.csv (it was copied from
data/baltic/predation-accessibility.csv when you ran tutorial.py). The
file has 14 rows × 8 columns:
Rows: 8 focal prey species + 6 LTL groups (total 14 potential prey types)
Columns: 8 focal predators (one column per species, no LTL predators)
What accessibility means. Each cell value is the fraction of encountered prey biomass that is actually consumed by that predator. It is NOT an encounter probability — encounter is determined separately by the size-ratio kernel (predator length / prey length must fall within configured bounds) and by spatial overlap (both must be in the same grid cell at the same time step). Accessibility is the post-encounter consumption efficiency.
The cod column. In the csv the cod column has nonzero entries for:
prey species cod accessibility
herring 0.6
sprat 0.4 ← you will change this in Beat 6
flounder 0.1
smelt 0.5
stickleback 0.2
cod (cannibal) 0.05
macrozooplankton 0.3
benthos 0.2
The cascade triangle. Cod (predator) → sprat (prey). Sprat in turn suppresses stickleback via competition for zooplankton (cross-trophic indirect effect). When cod’s access to sprat drops, sprat population stays higher, but cod also starves slightly — reducing cod’s direct predation on stickleback as well. Both pathways nudge stickleback biomass upward.
Provenance. The matrix is hand-coded from published Baltic stomach-content
studies and audited twice against the literature (see docs/baltic_example.md
provenance section). Real Baltic trophic cascades are subtler than a synthetic
3-species model would produce because the current ecosystem is bottom-up
controlled: cod biomass is far below its 1970s historical level, so cod’s
top-down control signal is weak but still detectable.
Beat 6 — Perturb and watch the cascade¶
Now you will make a single-line edit, re-run the script, and compare the equilibrium biomass to the baseline.
Step 1 — Edit the accessibility matrix.
Open tutorial-work/baltic/predation-accessibility.csv in any text editor.
Search for the exact substring:
sprat;0.4;
There is exactly one match (the sprat row, cod column). Change it to:
sprat;0.05;
Save the file. This reduces cod’s accessibility to sprat by a factor of 8.
Step 2 — Re-run the script.
python tutorial.py
The second run uses the cached Numba JIT and completes in under 2 s. The
script reads tutorial-work/baltic/predation-accessibility.csv (your edited
copy, not the original in data/baltic/), runs the simulation, writes
tutorial-work/biomass_perturbed.html, and prints a new equilibrium summary.
Step 3 — Compare the two equilibrium summaries.
Side by side in the terminal:
baseline perturbed
cod ~1 000 t ~900 t (-10 %)
sprat ~5 500 000 t ~5 700 000 t (+4 %)
stickleback ~550 000 t ~635 000 t (+13 %)
Exact values vary slightly with version; the direction and order-of-magnitude should match. Stickleback is the clearest signal: roughly +13 % higher biomass.
Why this matters. “The cascade is real but subtler than synthetic models would suggest.” Cod’s population in the present-day Baltic is small (kept low by additional mortality from grey-seal predation). A large fractional change in cod’s accessibility to sprat produces a modest absolute change in sprat biomass — which in turn produces a modest further change in stickleback. A 13 % biomass shift is exactly the order-of-magnitude effect that ecosystem-based fisheries management (EBFM) cares about: it is detectable in stock-assessment surveys, it changes sustainable yield estimates, and it can trigger gear-restriction decisions. The model reveals the mechanism even when the magnitude is modest.
Where next¶
Full Baltic provenance and parameter sources:
docs/baltic_example.mdcontains the literature citations behind every growth parameter, the ICES reference-point priors used for calibration, and the provenance of the spawning distribution maps.Calibrate Baltic to ICES stock advice:
docs/baltic_ices_validation_2026-04-18.mddescribes the multi-phase calibration workflow;scripts/calibrate_baltic.pyis the entry point (supports--optimizer {de,cmaes,surrogate-de}).Engine internals and Java parity:
docs/parity-roadmap.mddocuments the 14-point parity test suite (bit-exact within 1 OoM across all EEC and Baltic fixtures); the Shiny UI is launched withshiny run app.py --host 0.0.0.0 --port 8000.
Troubleshooting¶
Symptom |
Cause |
Fix |
|---|---|---|
|
venv not activated, or repo not installed |
|
|
OSMOSE not pip-installed (script uses |
|
First run appears to hang (~25–30 s) |
Numba JIT compilation |
Wait. Subsequent runs complete in under 2 s. |
Re-run gives identical results after Beat 6 edit |
Edited the wrong file |
Edits go in |
Plot file path printed but the browser won’t open it |
Headless server with no display |
The terminal equilibrium summary contains all the numbers you need; open the HTML on a machine with a browser. |
|
Plotly not installed |
|
Want to start completely fresh |
Stale |
|
Beat 6 perturbation didn’t produce a visible change in the plot |
The cascade signal is ~13 % — easy to miss visually |
Compare the printed equilibrium numbers directly: |
First run is very slow (> 5 minutes) |
Numba JIT cache directory not writable (e.g., read-only container) |
|
Simulation raises an exception immediately |
Incomplete Baltic data directory |
Verify: |
Tutorial maintained in docs/tutorials/30-minute-ecosystem.md.
Report issues at https://github.com/razinkele/osmopy.