SalmoPy is an individual-based model (IBM) that simulates populations of stream-dwelling and anadromous salmonids. It is a Python port of inSTREAM 7 / inSALMO, originally developed by Steve Railsback and Bret Harvey, extended with marine lifecycle, Baltic Atlantic salmon calibration, and Numba-accelerated computation.
Each fish is an individual (or super-individual) with:
Each simulation day, freshwater fish:
Marine fish experience daily growth based on prey availability and face mortality from seals, cormorants, fishing, bycatch, M74 syndrome, background mortality, and thermal stress.
FRY → PARR → SMOLT → OCEAN → RETURNING_ADULT → spawning → KELT → (optionally) REPEAT_SPAWNER. The full lifecycle is simulated for Baltic Atlantic salmon with ICES-calibrated parameters.
| Process | Mechanism |
|---|---|
| Feeding | Drift encounter rate = f(velocity, depth, fish size, turbidity, light) |
| Growth | Bioenergetics: intake - respiration - excretion |
| Movement | Fitness-based habitat selection within search radius |
| Mortality | Logistic survival functions for each risk factor |
| Spawning | Conditional on season, size, condition, temperature |
| Migration | Downstream movement when habitat fitness < migration fitness |
| Marine survival | Size-dependent seal/cormorant predation, fishing, M74 |
| Panel | Shows |
|---|---|
| Dashboard | Live metrics (alive, deaths, feeding activity, redds) updated during simulation |
| Movement | Animated fish movement on the river map with daily step-through |
| Population | Fish count over time by species |
| Spatial | Map with cell polygons coloured by depth/velocity/food + fish trails |
| Environment | Temperature, flow, and turbidity time series |
| Size Distribution | Length and weight histograms at census dates |
| Redds | Active redd counts, total eggs, emergence tracking |
| Help & Tests | This documentation + built-in test scenarios |
Habitat selection (the dominant cost at ~90% of step time) is accelerated via a batch Numba JIT kernel that processes all fish in a single compiled call. A 912-day Example A simulation completes in ~50 seconds — a 350–1,290× speedup over the NetLogo reference.
SalmoPy reproduces the core equations of inSTREAM 7 (Railsback & Harvey 2020) and inSALMO 7.3/7.4 (Railsback et al. 2013) with targeted extensions for marine lifecycle, Baltic calibration, and high-performance execution. This page documents the equations, explains where SalmoPy diverges from the NetLogo reference, and lists peer- reviewed sources.
Daily growth is net energy intake converted to body mass:
growth_g_per_day = (intake_g_per_day · prey_ED − respiration_g_per_day)
/ fish_ED
where prey_ED, fish_ED are energy densities (J/g). Intake
depends on activity:
intake = capture_area · drift_conc · velocity · 86400 · capture_success
capped at Cmax (temperature × weight-dependent max consumption) and
cell-level available_drift.intake = search_prod · search_area · vel_ratio
where vel_ratio = (max_swim − velocity) / max_swim.Respiration (Hewett-Johnson-style):
R = resp_A · W^resp_B · exp(resp_D · (v / v_max)²) · exp(resp_C · T²)
Cmax temperature function is a species-specific piecewise-linear
table in T ∈ [0, 30] °C. Identical to NetLogo's
trout-cmax-temp-table interpolation (passes rtol 1e-10 on a 330 k-row
reference, tests/test_validation.py::TestGrowthReportMatchesNetLogo).
At every step a fish evaluates every candidate cell × activity triple and picks the one maximising expected fitness to the time horizon:
fitness = (daily_survival · mean_condition_survival)^horizon_days
× length_penalty(length_at_horizon, fitness_length)
Here daily_survival is the product of five sub-terms (hi-temp,
stranding, condition, fish-predation, terrestrial-predation) raised to
the current step length, and length_penalty < 1 when the fish won't
reach maturity length by horizon. This matches NetLogo fitness-for
(InSALMO7.3:2798–2840) exactly.
The fitness produced here is bounded in [0, 1] — which is why SalmoPy's
migration comparator (should_migrate) was rewritten in v0.31.0 to
compare migration_fitness against this per-tick best_habitat_fitness
rather than the older fitness_memory EMA. The EMA mixed raw growth
(g/day, unbounded) with survival probability, producing a scale
mismatch that silently blocked small-fish outmigration.
For each anadromous juvenile at each daily boundary:
migrate = (migration_fit > best_habitat_fit) AND life_history == PARR
where migration_fit = logistic(length, L1, L9) (species-specific
size logistic). SalmoPy's v0.31.0 continuous FRY → PARR promotion
lets an anadromous fry become PARR as soon as length ≥ 4.0 cm OR age ≥ 1, replacing the legacy Jan-1-only gate (which had silently
blocked emergence-year outmigration, contributing to a 21× outmigrant
deficit against NetLogo example_a).
Spawners lay num_eggs = fecund_mult · length^fecund_exp · egg_viability (SalmoPy v0.32: was weight^fecund_exp pre-fix, over-
producing ~13× for a 60 cm / 6.9 kg adult).
Redd development accumulates with temperature:
d_frac/dt = devel_A · (T² · devel_B + T · devel_C) [per day]
When frac_developed ≥ 1.0, emergence spreads over 10 days
(NetLogo InSALMO7.3:4228–4287):
eggs_this_day = ceil(remaining_eggs · emerge_day / 10)
Eggs are aggregated into super-individuals of size superind_max_rep.
Pre-v0.32 SalmoPy emerged all eggs on day 1 with superind_rep = 1 —
a 10× density spike that starved the natal cohort.
Daily available_drift per cell:
drift_supply = 86400 · area · depth · velocity · drift_conc
/ drift_regen_distance [g/day]
Before v0.33 the formula was drift_conc · area · depth · step_length
— missing 86400 · velocity / drift_regen_distance (an 8,640×
factor for example_a). Fish computed correct per-capture intake but
hit a drastically undersized cell pool cap, throttling growth to ~0.003
g/day. The fix alone closed the headline outmigrant_cumulative parity
metric.
No NetLogo counterpart. Daily marine fish:
weight_gain ∝ prey_ED · forage_efficiency(length)
smolt_to_adult_return_mortality = seal · cormorant · fishing · bycatch · M74 · background · thermal
Calibrated against ICES Baltic Atlantic salmon stock assessments (ICES WGBAST). Smolt → adult return (SAR) for Teno-like populations falls in the 3–12 % band (ICES 2023).
| Component | NetLogo | SalmoPy |
|---|---|---|
| Cmax temperature interp | piecewise linear | piecewise linear |
| Wisconsin respiration | eq. (12)-(14) Railsback 2020 | same |
| Logistic risk terms | logistic-with |
evaluate_logistic |
Habitat fitness fitness-for |
daily_survival^horizon | expected_fitness (v0.31.0) |
| Redd development | degree-day | degree-day |
| Fecundity | fecund_mult · L^exp |
fecund_mult · L^exp (fixed v0.32) |
| Emergence spread | ceil(N · days / 10) | same (v0.32) |
| Drift food supply | 86400·A·d·v·C/regen |
same (fixed v0.33) |
| Aspect | NetLogo inSALMO 7.3/7.4 | SalmoPy |
|---|---|---|
| Language | NetLogo 6.4 / 7.0.3 (JVM, Mersenne Twister RNG) | Python 3.11+ + Numba JIT (PCG64 RNG) |
| Concurrency | single-threaded | per-fish Numba prange parallel pass |
| State layout | one object per fish (agent-based) | Structure-of-Arrays (contiguous NumPy) |
| Habitat selection throughput | ~5 s/step (example_a) | ~48 ms/step (~100× faster) |
| Reach topology | directed adjacency list | same + KD-tree spatial index |
| I/O | BehaviorSpace CSV | YAML config + Pydantic validation + CSV time series |
| Outmigrant counting | + trout-superind-rep (rep-weighted) |
superind_rep on each outmigrant record (matches) |
README.md.| Arc | Root cause | Metric impact |
|---|---|---|
| D | fitness_memory EMA used as migration comparator (scale mismatch) | outmigrants 1,943 → 12,090 |
| E | Fecundity formula used weight (not length); single-redd spawning; single-day emergence | 12,090 → 20,117 |
| F | available_drift missing velocity + regen_distance (8,640× too small) |
PASS (~41,146) |
| G | Parity test treated 0.0 juv_length as data, not sentinel | juv_length apparent 0 → 5.21 cm |
| H | Cohort-survival probe: size-selective migration + shorter cohort lifetime explain residual 16 % | diagnostic |
| I | Calibration-framework PoC: Morris ranks migration params top; seed-0 minimum not robust | framework validated |
After Arc G the headline parity metric test_outmigrant_cumulative
passes for the first time since the NetLogo cross-validation test was
written. Two of five metrics pass; three remain with identified causes
(15–25 % residual drift).
See docs/validation/v0.31.0-arc-D-netlogo-comparison.md for the full
report.
src/instream/calibration/ subpackage is an in-process Python/Mesa
port of this framework.Live cross-check of the Baltic Atlantic salmon calibration
(configs/baltic_salmon_species.yaml) against:
standardgraphs.ices.dk/StandardGraphsWebServices.asmx.The full report is at
docs/validation/v0.33.0-ices-baltic-validation.md;
this tab is a condensed version.
Assessment key 13726 (2020), Bayesian life-cycle model. Spawners reported in thousands of individuals (salmon stocks don't use SSB tonnes).
| Year | Spawners (×10³) | Reported catches (t) |
|---|---|---|
| 2000 | 1,676 | 363 |
| 2005 | 1,285 | 257 |
| 2010 | 981 | 127 |
| 2015 | 1,357 | 82 |
| 2019 | 1,766 | 65 |
| 2020 | 1,674 | n/a |
Historic low ≈ 700k in 2007-2008, recovery trend to 2019. WGBAST 2020 projection keeps spawners in 1,500–1,700k through 2027.
Gulf of Finland (sal.27.32): assessment key 19019 (2024),
primarily reared stock — SalmoPy models via is_hatchery
super-individuals with release-shock survival.
No traditional F / MSY reference points — WGBAST targets smolt-production capacity (PSPC): 75% of river PSPC by 2020.
| Parameter | SalmoPy | ICES / literature envelope | ✅/❌ |
|---|---|---|---|
| Fecundity @ 70 cm ♀ | 5,700 eggs | 4,000–12,000 (Brännäs 1988) | ✅ |
| Smolt-to-adult return | 3.61% | 3-12% (ICES WGBAST 2023) | ✅ lower third |
| Juvenile mortality structure | DD in fry-parr | DD in fry-parr (Skoglund 2024) | ✅ match |
| Smolt length threshold | 12 cm | 11-20 cm, south Baltic rivers | ✅ |
| Iteroparity (repeat spawn) | 4.4% | 2-6% Teno (Niemelä 2006); 0-26% Norway (Persson 2023) | ✅ |
| M74 syndrome | constant-rate | stochastic year-effect (Kuikka 2014) | ⚠ present; simpler |
Conclusion: no parameter is inconsistent with the current ICES evidence base.
| Process | SalmoPy (example_a 2011-2013) | ICES / literature |
|---|---|---|
| Freshwater fry → smolt | 5-20% | 5-15% (Skoglund 2024; Einum 2011) |
| Post-smolt marine | 3.6% | 3-12% (ICES WGBAST 2023) |
| Cohort extinction window | Apr-May yr2 | late May (NetLogo ref, Olmos 2018) |
| Egg → fry | 50-80% | 60-80% Baltic (Brännäs 1988) |
All four quantities fall inside the envelope.
Run-level parity test (tests/test_run_level_parity.py):
| Metric | Tolerance | NetLogo | SalmoPy | Status |
|---|---|---|---|---|
| Juvenile peak | rtol 0.30 | 2,151 | within | ✅ pass |
| Adult peak | atol 8 | 21 | +11 | ❌ fail |
| Small outmigrant total | rtol 0.20 | 41,146 | ~41,146 | ✅ pass |
| Juv length 2012-09-30 | rtol 0.10 | 6.23 cm | 5.21 cm | ❌ 16% gap |
| Outmig median date | ±14 d | 2013-01-05 | +22.7 d | ❌ fail |
Two of five pass. The three residuals are traceable to RNG variance or single-seed NetLogo realisation (Arcs G-I diagnosis).
aggregate_trajectories(...).None are blockers for management-scenario use.
ICES SAG data pulls (live 2026-04-20):
Eight releases across 2026-04-20 to 2026-04-21 made SalmoPy's outputs and forcings directly comparable to the ICES Working Group on Baltic Salmon and Sea Trout (WGBAST) assessment. Every knob is opt-in with None/0.0 defaults — runs that don't opt in behave identically to the v0.33.0 baseline and preserve NetLogo InSALMO 7.3 parity.
| Arc | Version | Capability | Key file |
|---|---|---|---|
| K | 0.34.0 | Per-reach smolt production + % PSPC achieved (CSV) | io/output.py::write_smolt_production_by_reach |
| L | 0.35.0 | WGBAST M74 YSFM year-forcing at egg-emergence | modules/egg_emergence_m74.py::apply_m74_cull |
| M | 0.36.0 | 4 Baltic river fixtures (Torne/Simo/Byske/Mörrum) | configs/example_{tornionjoki,simojoki,byskealven,morrumsan}.yaml |
| N | 0.37.0 | Post-smolt survival time-varying forcing | marine/survival_forcing.py, marine/survival.py::marine_survival(current_year=) |
| O | 0.38.0 | Straying + spawner-origin MSA matrix + natal-reach bug fix | marine/config.py::stray_fraction, io/output.py::write_spawner_origin_matrix |
| P | 0.39.0 | HELCOM grey-seal Holling II abundance scaling | marine/seal_forcing.py, marine/survival.py::seal_hazard(current_year=) |
| Q | 0.40.0 | Bayesian SMC wrapper (prior + likelihoods + sampler) | bayesian/{prior,observation_model,smc}.py |
| 0 | 0.41.0 | Arc 0 data-quality pass (literature-traced CSV upgrades) | data/helcom/grey_seal_abundance_baltic.csv |
Arc K — PSPC output. End-of-run writes
smolt_production_by_reach_{year}.csv with columns year, reach_idx,
reach_name, smolts_produced, pspc_smolts_per_year,
pspc_achieved_pct — directly comparable to WGBAST's 75 % PSPC
management target. Activate by setting reach.pspc_smolts_per_year on
any reach. outmigrants.csv widens from 3 to 10 NetLogo-compat columns
(species, timestep, reach_idx, natal_reach_idx, natal_reach_name,
age_years, length_category, length_cm, initial_length_cm, superind_rep).
Arc L — M74 year-effect. Applies the Vuorinen 2021 + WGBAST 2026
yolk-sac-fry mortality fraction as a one-time binomial cull at
egg→fry emergence. Keyed by (year, river_name). Activate via
simulation.m74_forcing_csv: "data/wgbast/m74_ysfm_series.csv" and
set reach.river_name on WGBAST-comparable reaches (e.g. "Tornionjoki",
"Simojoki" — the two rivers in the shipped placeholder series).
Critical life-stage correction: the first-draft plan wired M74 as a marine-stage daily hazard; a 0.50 annual fraction applied daily would compound to 0.5^365 ≈ 10⁻¹¹⁰ marine survival (i.e. all marine fish die). M74 is a freshwater yolk-sac pre-swim-up hazard; moving it to egg-emergence preserves correct life-stage semantics.
Arc M — Multi-river Baltic fixtures. 4 new configs at latitudes spanning 56.17°N (Mörrum) to 65.85°N (Torne) with:
smolt_min_length per AU: 14 cm (AU 1) → 11 cm (Southern),
per Skoglund 2024 Paper III latitudinal gradientGenerated via scripts/_scaffold_wgbast_rivers.py +
scripts/_generate_wgbast_configs.py from the Nemunas-basin template.
Arc N — Post-smolt survival forcing. marine_survival(current_year=)
overrides background_hazard for fish in the post-smolt window
(days_since_ocean_entry < 365) with a per-(smolt-year, stock-unit)
annual-survival lookup. Activate via
marine.post_smolt_survival_forcing_csv and marine.stock_unit.
Semantic subtlety: smolt year (year of ocean entry), not calendar year, is the lookup key. A fish emigrating July Y crossing into Y+1 still receives Y's WGBAST cohort posterior across its full 365-day post-smolt window, avoiding July/January cohort splits.
Arc O — Straying + MSA matrix.
natal_reach_idx = current_reach overwrite at
the SMOLT transition (pre-v0.38 this destroyed the birth-reach
signal that Arc K PSPC analytics and Arc O MSA reconstruction
depend on).marine.stray_fraction applied at adult return; with
probability stray_fraction, a returner's spawning reach_idx is
reassigned to a random non-natal freshwater reach while
natal_reach_idx stays fixed (genetic/birth property).write_spawner_origin_matrix emits a natal × spawning
matrix directly comparable to WGBAST's genetic mixed-stock analysis.Arc P — HELCOM grey-seal Holling II. seal_hazard(current_year=)
scales the length-logistic base by a Holling Type II saturating
multiplier anchored at seal_reference_abundance:
$$\text{mult}(r) = \frac{r / (1 + r/k)}{1 / (1 + 1/k)} \quad \text{where } r = \frac{\text{abundance}}{\text{reference}}$$
With default k_half = 2.0: mult(1) = 1.0 (legacy calibration
preserved), mult(2) = 1.5, mult(10) = 2.5, mult(∞) = 3.0 (asymptote).
Linear scaling across the 15× abundance span 1988 (2.8 k seals) →
2021 (42 k seals) would have projected marine salmon extinction;
Type II matches real predator functional responses.
Arc Q — Bayesian life-cycle wrapper. New instream.bayesian
subpackage wraps the existing calibration framework (Sobol + Morris +
Nelder-Mead + GP surrogate, 13 modules) in a posterior-inference
shell comparable to WGBAST's own Bayesian model (Kuikka et al. 2014).
Prior dataclass with sample(rng, n); BALTIC_SALMON_PRIORS
defaults for post_smolt_survival, m74_baseline, stray_fraction,
fecundity_mult.lambda = simulated * trap_efficiencyk = 50 ≈
CV 15 % at µ=100, per Orell & Erkinaro 2007).run_smc sampler: ABC-SMC with tempered log-likelihood,
ESS-triggered resampling, returns (particles, weights, log_marginal_likelihood, param_names).Arc 0 — Data-quality pass. Literature-traced CSV upgrades. HELCOM grey-seal series now anchored to Harding & Härkönen 1999 (1988 = 3.5 k, not 2.8 k), Lai 2021 (2014 = 32,019 exact), Westphal 2025 (2020 = 40 k, 2023 = 45 k). Pre-2000 values flagged as bounty-statistics backcasts, not aerial counts (coordinated aerial moult surveys began 2000).
# configs/example_tornionjoki.yaml (Arc M fixture; v0.36.0+)
simulation:
start_date: "2011-04-01"
end_date: "2016-03-31"
seed: 42
m74_forcing_csv: "data/wgbast/m74_ysfm_series.csv" # Arc L (v0.35.0)
marine:
post_smolt_survival_forcing_csv: "data/wgbast/post_smolt_survival_baltic.csv"
stock_unit: "sal.27.22-31" # Arc N (v0.37.0)
seal_abundance_csv: "data/helcom/grey_seal_abundance_baltic.csv"
seal_reference_abundance: 30000.0 # Arc P (v0.39.0)
stray_fraction: 0.10 # Arc O (v0.38.0)
reaches:
Nemunas:
river_name: "Tornionjoki" # Arc L key
pspc_smolts_per_year: 880000 # Arc K (v0.34.0)
# ... other reach params
Run → emits smolt_production_by_reach_2015.csv (Arc K PSPC) +
spawner_origin_matrix_2015.csv (Arc O MSA) + standard outputs.
| Arc | Default-off knob | Effect when unset |
|---|---|---|
| L | simulation.m74_forcing_csv = None |
No M74 cull (NetLogo parity) |
| N | marine.post_smolt_survival_forcing_csv = None, current_year = None |
Legacy marine_mort_base |
| O | marine.stray_fraction = 0.0 |
Perfect homing (NetLogo parity) |
| P | marine.seal_abundance_csv = None |
Legacy static seal_hazard |
Runs of configs/example_a.yaml or configs/example_b.yaml (which
leave these fields unset) produce bit-identical outputs to v0.33.0
within the seed-determinism guarantee — the
tests/test_run_level_parity.py::TestExampleARunVsNetLogo metrics
remained stable across all 8 releases.
Four CSVs ship with values traced to accessible peer-reviewed literature but would benefit from direct WGBAST/HELCOM PDF table extraction:
data/wgbast/m74_ysfm_series.csv ← Vuorinen 2021 Supp Table S2data/wgbast/post_smolt_survival_baltic.csv ← WGBAST 2026
Annex Table 2.5.x.x posterior mediansdata/wgbast/observations/smolt_trap_counts.csv ← WGBAST 2026 §3
trap-count tablesdata/helcom/grey_seal_abundance_baltic.csv ← HELCOM core-indicator
report annual tables (v0.41.0 substantively improved 3 values)All replacements are drop-in CSV edits — no code changes required.
Across Arcs K→Q + Arc 0: ~40 new tests, zero regressions to
pre-existing suites. All 7 parity-preserving opt-in defaults are
tested: test_pspc_output, test_m74_forcing,
test_egg_emergence_m74, test_multi_river_baltic,
test_post_smolt_forcing, test_straying, test_seal_forcing,
test_bayesian.
docs/validation/wgbast-roadmap-complete.md — cross-arc summary
with full reference listdocs/releases/v0.34-to-v0.41-wgbast-summary.md — user-facing
release notesdocs/superpowers/plans/2026-04-20-arc-K-to-Q-wgbast-roadmap.md
— 8-iteration-reviewed original plandocs/superpowers/plans/2026-04-20-arc-M-to-Q-expanded.md —
Arcs M-Q full TDD-detail expansion| Control | Description | Default |
|---|---|---|
| Configuration | YAML config file defining species, reaches, and spatial setup | example_a |
| Start Date | First day of simulation | 2011-04-01 |
| End Date | Last day of simulation | 2013-09-30 |
| Backend | Computation backend: numpy (portable) or numba (faster) | numpy |
| Parameter | Description |
|---|---|
| cmax_A, cmax_B | Maximum consumption allometry: Cmax = A * W^B |
| cmax_temp_table | Temperature-dependent consumption multiplier |
| react_dist_A, B | Reactive distance for prey detection (cm) |
| turbid_threshold/min/exp | Turbidity effect on feeding efficiency |
| light_threshold/min/exp | Light effect on feeding efficiency |
| search_area | Area searched per time step (cm^2) |
| Parameter | Description |
|---|---|
| resp_A, B, D | Respiration parameters (standard metabolic rate) |
| weight_A, B | Length-weight relationship: W = A * L^B |
| energy_density | Energy content of fish tissue (J/g) |
| prey_energy_density | Energy content of prey (J/g) |
| Parameter | Description |
|---|---|
| move_radius_max | Maximum habitat search radius (CRS units) |
| move_radius_L1, L9 | Fish lengths where search radius = 10% / 90% of max |
| Parameter | Description |
|---|---|
| mort_high_temp_T1, T9 | Temperature thresholds for thermal mortality |
| mort_condition_S_at_K8, K5 | Survival at condition factors K=0.8, K=0.5 |
| mort_terr_pred_* | Terrestrial predation logistic parameters |
| mort_fish_pred_* | Aquatic predation logistic parameters |
| Parameter | Description |
|---|---|
| spawn_start_day, end_day | Spawning season window (MM-dd) |
| spawn_min_temp, max_temp | Temperature range for spawning |
| spawn_fecund_mult, exp | Fecundity: eggs = mult * length^exp |
| spawn_wt_loss_fraction | Body weight lost to egg production |
| redd_devel_A, B, C | Egg development rate = f(temperature) |
| Parameter | Description | Units |
|---|---|---|
| drift_conc | Density of drifting invertebrate prey | g/cm^3 |
| search_prod | Rate at which search-feeding fish find benthic prey | g/cm^2/hr |
| shelter_speed_frac | Velocity reduction in sheltered microhabitats | fraction |
| prey_energy_density | Energy content of invertebrate prey | J/g |
| fish_pred_min | Minimum daily survival from aquatic predation | probability |
| terr_pred_min | Minimum daily survival from terrestrial predation | probability |
Food supply (drift_conc, search_prod):
Predation (fish_pred_min, terr_pred_min):
Run predefined scenarios to verify model behaviour. Each test sets specific parameters, runs a short simulation, and checks expected outcomes.