Where are British Columbia’s floodplains, and what is happening to them? This repo answers that one watershed group at a time: it maps the valley bottom a salmon-bearing river can flood, then measures how the land cover inside it changed between 2017 and 2023 and tries to say why.
It is a tool for finding where to look. Nothing here has been checked on the ground — read Reading the outputs before you use a number from it.
The code, the per-area configs and the issue tracker are on GitHub: https://github.com/NewGraphEnvironment/floodplains.
For an area — a watershed group, optionally a single reach — the pipeline runs three steps:
link.
See What “accessible” means: it is
a connectivity model, not modelled spawning or rearing
habitat.flooded,
on the national 30 m DEM.lulc_annual — find
the patches that changed, and tag each one against overlay layers —
fire, harvest — via drift.The method lives in the packages. This repo is the thin driver: config per area, one runner, and a provenance record of what produced each output.
This is a screening tool, not a validated change-detection product. The floodplain is modelled from a DEM. The land cover is a global product applied to BC valley bottoms. Neither has been field-validated. Treat every patch as a candidate — somewhere worth looking — rather than a measurement.
Used that way it works: the model surfaces real riparian change in the Bulkley that would otherwise have to be found by eye, at a scale no one is going to walk.
Attribution is the part that is growing. Each change
patch is intersected with the overlay layers named in
config/disturbance.yml and tagged with what it hits. Fire
and harvest are the causes that file lists today, and between them they
source about a third of the tree loss above — a third that used to sit
undifferentiated. The framework is layer-agnostic: any spatial
layer we can get is a config line, not a code change. Confirmed
riparian clearing for agriculture, forest health, tenure — each is one
entry away. A patch can carry several tags at once, which is the honest
answer for salvage logging after a fire. Some layers locate change
without explaining it: a patch touching a mapped FWA wetland carries
in_wetland, and one touching the Agricultural Land Reserve
carries in_alr, so that change can be found and reviewed;
neither counts toward the attributed share above. How much of the
floodplain, and of its change, lies in the ALR or in a wetland is
counted per cell in a separate composition table, because a patch that
merely touches the reserve is not all inside it. The same holds for a
fire in the 15 years before the change window
(in_fire_prior): it may explain regrowth or delayed tree
loss, but it is reported beside the unattributed share rather than
subtracted from it until reference labels say whether that change is
real.
What we still do not know:
io-lulc-annual-v02 items are one composite per zone per
year and carry no acquisition date and no created or
updated property. A wet year against a dry one could skew a
figure and we cannot quantify that.ff scenario names it is not a
flood-frequency product.Everything is published openly so it can be interrogated. Please do, and file what you find.
The network we delineate against is the accessible network: stream connected to the ocean without an intervening natural barrier, below a per-species gradient ceiling. It is a connectivity model, not modelled spawning or rearing habitat — which bcfishpass also produces and which is far more restrictive.
The ceiling is per species. These values are copied
from link’s config bundle at
extdata/configs/bcfishpass/parameters_fresh.csv, column
access_gradient_max — verified against link 0.50.0 on
2026-09-04. They can move with a link release and nothing here would
notice, so re-check them when link is upgraded:
| species | access_gradient_max |
|---|---|
| CH, CM, CO, PK, SK | 0.15 |
| ST, WCT | 0.20 |
| BT, CT, DV, RB | 0.25 |
So a chinook or coho area is delineated against stream up to 15% gradient and a bull trout area up to 25%. That is why bull trout shows more accessible km than chinook in the same group, and why coho and chinook networks are identical wherever both are modelled.
The gap between accessible and modelled habitat is large, so the distinction is not academic. Measured 2026-09-03 at order ≥ 3:
| WSG | species | accessible km | spawning km | rearing km | spawning as % of accessible |
|---|---|---|---|---|---|
| FRAN | ch | 2139 | 587 | 803 | 27% |
| BOWR | ch | 1017 | 649 | 736 | 64% |
| LNTH | ch | 694 | 448 | 509 | 65% |
| KOTL | bt | 1921 | 921 | 1636 | 48% |
| PCEA | bt | 2194 | 888 | 1743 | 40% |
Read a published area as “floodplain along stream this species can reach”, never as “floodplain along spawning habitat”.
Which config bundle produced a network is recorded, not
assumed — fresh.log.config_name carries it per
group and step 1 resolves it into provenance.json. The
bcfishpass bundle opts subsurface flow in as a barrier; the
default bundle leaves it out, and the two disagree by about
0.7% province-wide.
ff0N is a scenario, not a return
period. Each one widens the same delineation to a different ecological
extent. Every area carries its own
config/<area>/flood_scenarios.csv; this table is read
from config/bulk/, the group the figures above come
from:
| scenario | flood factor | extent | run by default |
|---|---|---|---|
| co_ff01 | 1 | Bankfull channel extent | no |
| co_ff02 | 2 | Flood-prone width / active channel margin | yes |
| co_ff04 | 4 | Functional floodplain | yes |
| co_ff06 | 6 | Valley bottom extent | yes |
| co_ff08 | 8 | Extended valley - large wood recruitment | no |
| co_ff12 | 12 | Channel migration zone - full process width | no |
Three of these are modelled by default; the rest are configured and
off. Turning one on is a run column edit, not a code
change.
scripts/; the per-area bits live in
config/<area>/. Adding an area is adding a directory,
not a code change.streams_co3, co_ff04 /
ch_ff06), so MORR carries both coho and chinook without
either overwriting the other.config/regions/<region>.yml names the groups and
either network: whole (every group is the whole stream
network at order 2 and up, species all) or a species
preference. A region run reconciles each area’s config
rather than regenerating it, so a second species’ scenario rows,
citations and every comment survive. DRY=1 writes nothing
at all.attribute_by: adds one row per watercourse, so a
delineation answers “where is the floodplain of this river”.
Rows overlap where ground is genuinely shared at a
confluence — on MORR that is 43.6% of the floodplain, so a hard
partition would mis-assign nearly half of it. A companion
patch_watercourse_* table carries two fractions, and only
one of them adds up: overlap_frac describes coverage,
apportion_weight sums to 1 and is the one to aggregate
with.wsg, species, scenario — so many
areas can merge into one GeoPackage and stay separable by
attribute.config/neexdzii/ must
reproduce known-good numbers — coho-3 network 673.5 km,
floodplain co_ff04 142.0 km², tree loss
773.4 ha — before any change is trusted. Re-baselined
2026-10-13 under flooded 0.7.0, which fixes the DEM grid,
so a re-run on the same software reproduces it byte for byte. An earlier
contract (678.2 / 171.0 / 943.13) is dead, not
superseded: it came from a bankfull regression fed hectares
where Hall et al. specify km² and mm where they specify cm/yr. Record
each as a fresh contract, never as a delta.scripts/fp_gpkg.R) and
scripts/floodplain_lcc/gpkg_determinism-check.R asserts it.
One stated limit: rewriting a single layer into an existing
GeoPackage is not byte-stable, because SQLite records write history in
its header.Read from config/regions/*.yml, which is what the region
runner itself consumes:
| region | network | watershed groups |
|---|---|---|
| columbia | whole (species all) |
DUNC, KOTL, LARL, SLOC |
| fraser | whole (species all) |
BOWR, FRAN, LCHL, LNTH, LSAL, MCGR, MORK, NECR, TABR, THOM, UFRA, UNTH, WILL |
| peace | whole (species all) |
PARS, PCEA, PINE |
| skeena | whole (species all) |
BULK, KISP, MORR |
Plus the neexdzii parity fixture, which is a reach
inside BULK and is deliberately not part of any region. A whole-network
region runs every group as the whole stream network at order 2 and up,
with habitat joined afterwards rather than cut into the floodplain. A
species preference is only that: the runner takes the first species
actually modelled in a group at the configured order.
This is what is configured, which is not the same as
what has been modelled, published, or re-run at the current model
version — every published floodplain predates the DEM grid fix in
flooded 0.7.0 (#117). MCGR and PINE were blocked on their
species networks (#76); their whole networks are present and match the
reference, but neither has run in whole mode yet. For what is actually
available to download, ask the catalogue rather than this page.
Rscript scripts/run_area.R <area> [steps] # steps default to 1,2,3
Rscript scripts/run_region.R <region> # every group in a region, resumable
Run a non-default species with environment overrides rather than a
config edit:
FP_SPECIES=ch FP_PRIMARY_SCENARIO=ch_ff06 Rscript scripts/run_area.R <area>.
Prerequisites: a local fwapg PostgreSQL (standard libpq
environment variables); link ≥ 0.44.0, flooded
≥ 0.7.0, drift ≥ 0.10.0, fresh,
terra ≥ 1.9-25 (see scripts/packages.R);
internet for the national MRDEM-30 DEM and the Microsoft Planetary
Computer STAC; and the disturbance overlay layers loaded into
fwapg via bcdata bc2pg.
Long runs want caffeinate -s in front of them — macOS
idle sleep kills a detached job, and the region runner is resumable so
an interrupted group picks up where it stopped.
Every run writes data/<area>/provenance.json
recording what produced the outputs: the link log row
for the network, the VCA parameters and DEM geometry for the floodplain,
and a content digest of the classified rasters for the land cover.
Each section splits into inputs (a function of the
inputs, byte-stable across reruns, summarised as one
inputs_hash) and outputs (a digest of what
came out). Two questions, two answers: same ingredients? and
same result? — and the second is deliberately not folded into
the first, because “same answer from different data” is worth knowing
separately from “different answer”.
The land-cover fingerprint is a digest of the classified
rasters, not the STAC item ids: an io-lulc item id is
<tile>-<year> and the items carry no
created or updated, so an upstream reprocess
in place leaves every id identical.
Rscript scripts/floodplain_lcc/provenance-check.R <area> # no database needed
Modelling and publishing are separate repos and the dependency points
one way: stac_floodplains_bc
reads this repo’s data/ and publishes it. This repo knows
nothing about it beyond the reminder a run prints. What the catalogue
holds, how to query it, and how to download from it are documented there
— including the collection id, which is hyphenated and
is not the repository name.
The valley confinement approach, the flood-factor scenarios and their ecological framing come from the literature the scenario config cites:
A separate debt, and one the papers above do not cover — the software this pipeline actually runs on. Verified against each project’s own record on 2026-09-04:
| whose | what | licence |
|---|---|---|
| Devin Cairns — BlueGeo | the Valley Confinement Algorithm implementation that flooded
adapts |
MIT |
| USDA Rocky Mountain Research Station | the original valley-confinement method — the VCA Toolbox, RMRS-GTR-321 | — |
Simon Norris —
bcfishpass |
lateral habitat assembly, and the modelling approach link
reproduces |
three-part: Apache 2.0 software, ODbL database, DbCL contents |
The bcfishpass entry is a method
citation, not a licence obligation, and the distinction is load-bearing
rather than polite: ODbL is share-alike, so redistributing that
database would oblige us differently from any of the sources above.
Nothing here does. The networks are built by link against
the Freshwater Atlas in a local fwapg, reproducing the
approach — the geometry is FWA-derived, and no bcfishpass
override data is redistributed.
Those three and 9 more sit in the citations column of
config/bulk/flood_scenarios.csv, one set per scenario.
The code in this repository is MIT.
The two figures are the only derived data this repo redistributes,
and they carry obligations from their inputs: land cover from Impact
Observatory’s io-lulc-annual-v02 (CC BY 4.0),
modified by clipping to modelled floodplain extents and
cross-tabulating 2017 against 2023; terrain containing information
licensed under the Open Government Licence – Canada (MRDEM-30, Natural
Resources Canada); and stream network containing information licensed
under the Open Government Licence – British Columbia (Freshwater Atlas,
Province of British Columbia).
The published products are a different question with
a different answer, and it is documented where they live — see stac_floodplains_bc/ATTRIBUTION.md.