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.

Two panels. Left: the Bulkley watershed group, its coho-accessible stream network, and the modelled ff04 floodplain drawn as a dark ribbon along the valleys, with a red box marking a detail window. Right: that window at three flood-factor scenarios nested inside one another - ff02 flood-prone width / active channel margin, ff04 functional floodplain, ff06 valley bottom extent.

The code, the per-area configs and the issue tracker are on GitHub: https://github.com/NewGraphEnvironment/floodplains.

What it does

For an area — a watershed group, optionally a single reach — the pipeline runs three steps:

  1. Network. Pull the stream a species can actually reach, via link. See What “accessible” means: it is a connectivity model, not modelled spawning or rearing habitat.
  2. Floodplain. Delineate the valley bottom around that network with the Valley Confinement Algorithm, via flooded, on the national 30 m DEM.
  3. Land-cover change. Classify 10 m Impact Observatory land cover over the change interval — its endpoints and midpoint by default, every year where an area sets 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.

Reading the outputs (experimental)

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.

Top: the Bulkley watershed group with every modelled floodplain tree-loss patch 2017-2023 drawn as a dot sized by area and coloured by attributed cause - fire in red, harvest in amber, and grey where nothing yet attributes it. Bottom: a stacked bar of the same loss by area, showing those sources accounting for roughly a third of it between them.

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:

  • The rest of the tree loss has no source yet. Some of it is real removal we cannot tie to a layer; some is drought or seasonal drying, water-level change, terrain shadow or plain misclassification. Those have not been separated from one another, so the residual is a work list, not a measurement.
  • “Tree loss” is a class transition, not confirmed forest removal. What is measured is pixels leaving the tree class.
  • Acquisition timing is unknown and unrecoverable. 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.
  • Groups are not comparable to each other yet. With no treed-area denominator (#38), a group whose floodplain is mostly open water cannot be set against a forested one on hectares lost.
  • No patch-level field QA has been done (#27).
  • The floodplain is modelled too — VCA output on a 30 m DEM, against the accessible network. Despite the 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.

What “accessible” means

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.

What a flood factor is

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.

Design

  • One codebase, config per area. Code is single-source in scripts/; the per-area bits live in config/<area>/. Adding an area is adding a directory, not a code change.
  • Several species coexist in one area. Outputs are keyed by species (streams_co3, co_ff04 / ch_ff06), so MORR carries both coho and chinook without either overwriting the other.
  • Regions batch a set of groups. 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.
  • A floodplain can be attributed per watercourse. Optional 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.
  • Every published layer carries the item key — wsg, species, scenario — so many areas can merge into one GeoPackage and stay separable by attribute.
  • Parity fixture. 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.
  • Rebuilds are byte-reproducible. GDAL stamps every GeoPackage with the wall-clock time of the write, which would make each rebuild a new checksum regardless of content. The stamp is pinned to a fixed epoch (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.

Where it has been run

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.

Running it

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.

Run provenance

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

Publishing

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.

Method

The valley confinement approach, the flood-factor scenarios and their ecological framing come from the literature the scenario config cites:

  • Nagel, D.E., J.M. Buffington, S.L. Parkes, S. Wenger and J.R. Goode. 2014. A landscape scale valley confinement algorithm: delineating unconfined valley bottoms for geomorphic, aquatic, and riparian applications. USDA Forest Service General Technical Report RMRS-GTR-321 — https://research.fs.usda.gov/treesearch/45516
  • Hall, J.E., D.M. Holzer and T.J. Beechie. 2007. Predicting river floodplain and lateral channel migration for salmon habitat conservation. Journal of the American Water Resources Association 43(3):786–797 — https://doi.org/10.1111/j.1752-1688.2007.00063.x
  • Opperman, J.J., R. Luster, B.A. McKenney, M. Roberts and A.W. Meadows. 2010. Ecologically functional floodplains: connectivity, flow regime, and scale. Journal of the American Water Resources Association 46(2):211–226 — https://doi.org/10.1111/j.1752-1688.2010.00426.x

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.

Licence

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.

Roadmap