"""The Live Guide: a worked example that runs on the bundled sample data.
River Architect's modules chain together - a lifespan map needs a detrended DEM and a
depth-to-water-table raster, and those have to be built first. Reading that in the
documentation is one thing; doing it is another, and the step people get stuck on is the
first one, because a condition that is not prepared produces either an error or, worse, an
empty map that looks like an answer.
This module holds that walkthrough as **data**: an ordered tuple of :class:`GuideStep`,
each naming the tab it belongs to, the settings to enter and what the result should look
like. Both front ends render the same tuple, so the guide cannot say one thing in the Qt
window and another in the tkinter one, and a test can check that the tabs it names exist.
The reach is ``2100_sample``, a real gravel-cobble reach in U.S. customary units that ships
in ``sample-data/`` of a source clone. :func:`sample_data_dir` finds it;
:func:`activate_sample_data` points the project home at it.
"""
import os
from . import config
__all__ = ["GuideStep", "STEPS", "TITLE", "CONDITION", "DOCS_URL",
"sample_data_dir", "activate_sample_data", "sample_data_status", "as_text"]
#: Title of the guide, shown in the Help menu and as the window title.
TITLE = "Live Guide: Example"
#: The condition the guide works on.
CONDITION = "2100_sample"
#: Where the full documentation lives.
DOCS_URL = "https://riverarchitect.readthedocs.io/"
[docs]
class GuideStep:
"""One step of the walkthrough.
Args:
key (str): short identifier, for tests and for deep links.
title (str): heading, e.g. ``"2. Lifespan mapping"``.
group (str): the top-level tab the step happens in, as :data:`TAB_GROUPS` names it.
tab (str): the module tab within that group. Equal to ``group`` for a lone tab.
body (str): the explanation, as paragraphs separated by a blank line.
settings (tuple): ``(label, value)`` pairs to enter in the tab.
expect (str): what the result should look like, so a reader can tell it worked.
writes (tuple): the files the step produces, relative to the project directory.
"""
def __init__(self, key, title, group, tab, body, settings=(), expect="", writes=()):
self.key = key
self.title = title
self.group = group
self.tab = tab
self.body = body.strip()
self.settings = tuple(settings)
self.expect = expect.strip()
self.writes = tuple(writes)
[docs]
def paragraphs(self):
"""The body split into paragraphs, for a renderer that lays them out itself."""
return [" ".join(block.split())
for block in self.body.split("\n\n") if block.strip()]
def __repr__(self):
return "GuideStep(%r)" % self.key
# --------------------------------------------------------------------- sample data
[docs]
def sample_data_dir():
"""Locate the bundled ``sample-data`` directory, or return ``None``.
It ships in a source clone, not in a wheel, so an installed copy of River Architect
normally has no sample data and the guide says so rather than failing.
"""
here = os.path.dirname(os.path.abspath(__file__))
candidates = [
# editable install or a run from a clone: src/riverarchitect -> repository root
os.path.join(here, os.pardir, os.pardir, "sample-data"),
# a layout that keeps the data beside the package
os.path.join(here, "sample-data"),
os.path.join(os.getcwd(), "sample-data"),
]
for candidate in candidates:
candidate = os.path.abspath(candidate)
if os.path.isdir(os.path.join(candidate, "01_Conditions", CONDITION)):
return candidate
return None
[docs]
def activate_sample_data():
"""Point the project home at the bundled sample data.
Returns:
str: the directory now in use.
Raises:
FileNotFoundError: when no bundled sample data can be found.
"""
directory = sample_data_dir()
if directory is None:
raise FileNotFoundError(
"no bundled sample-data directory found. It ships with a source clone of "
"River Architect, not with an installed wheel - clone the repository, or set "
"the project directory to your own data and follow the guide against that.")
config.set_project_home(directory)
return directory
[docs]
def sample_data_status():
"""One line describing whether the guide can run, and against what.
Returns:
tuple: ``(ready, message)``.
"""
directory = sample_data_dir()
if directory is None:
return False, ("No bundled sample data found. The guide still describes every "
"step; run it against your own condition, or clone the repository "
"to get sample-data/.")
if os.path.abspath(config.project_home()) == os.path.abspath(directory):
return True, "Project directory is the sample data: %s" % directory
return True, ("Sample data found at %s. Use the button below to work on it."
% directory)
# --------------------------------------------------------------------------- steps
STEPS = (
GuideStep(
key="start",
title="0. What you are about to do",
group="Get Started",
tab="Get Started",
body="""
This walkthrough runs the whole chain on 2100_sample: a real gravel-cobble reach
in U.S. customary units, with a DEM, a mean grain size raster, a DEM of difference
and 60 pairs of modelled flow depth and velocity rasters between 300 and 88053 cfs.
The order matters. Every later module reads a raster that Get Started produces, so
preparing the condition is not optional housekeeping - it is step one of the
analysis. The chain is: prepare the condition, map feature lifespans, pick the best
feature per cell, terraform the ground those features need, then the three
ecohydraulic analyses - which are independent of one another but all need the
prepared condition.
Set the units to U.S. customary before you start (Units menu). The sample rasters
are in feet and feet per second, and a unit mismatch does not raise an error - it
quietly produces wrong thresholds.
""",
settings=(("Project directory", "sample-data/"),
("Units", "U.S. customary"),
("Condition", CONDITION)),
expect="The status bar shows the sample-data directory and the condition list "
"offers 2100_sample.",
),
GuideStep(
key="getstarted",
title="1. Prepare the condition (Get Started)",
group="Get Started",
tab="Get Started",
body="""
Build the three terrain products the later modules read. Use 750 cfs as the
reference discharge: it is an in-channel low flow, which is what a detrended DEM and
a water surface should be keyed to.
The detrended DEM is elevation above the local thalweg, which is what makes an
elevation comparable between the upstream and downstream ends of a reach. The water
surface product extrapolates a continuous water level out of the wetted area and
writes wle.tif, h_interp.tif and d2w.tif - the depth to the water table is what the
vegetation-planting features are keyed to. Morphological units classify the wetted
area into pools, riffles, runs and the rest from depth and velocity.
The condition already ships dem_detrend.tif, d2w.tif and mu.tif, so you can compare
what you build against them. They agree closely (r = 0.99); the small constant
offset is the different reference discharge, not an error.
Build the fourth product too: analyze flows. Point it at
00_Flows/2100_sample/flow_series_2020.csv and it writes one seasonal flow duration
curve per species and lifestage. Habitat area is integrated over that curve, so
without it step 5 can report usable areas but not SHArea - the single number the
project gets judged on.
""",
settings=(("Condition", CONDITION),
("Reference discharge", "750 cfs"),
("Interpolation", "nearest"),
("Daily flow record", "00_Flows/2100_sample/flow_series_2020.csv"),
("Products", "detrended DEM; water surface, depth and depth to water "
"table; morphological units; analyze flows")),
expect="Roughly 11 000 thalweg cells are reported, eight morphological unit types "
"are found in the wetted area, and 14 flow duration workbooks are written.",
writes=("01_Conditions/2100_sample/dem_detrend.tif",
"01_Conditions/2100_sample/wle.tif",
"01_Conditions/2100_sample/h_interp.tif",
"01_Conditions/2100_sample/d2w.tif",
"01_Conditions/2100_sample/mu.tif",
"00_Flows/2100_sample/flow_duration_<code>.xlsx"),
),
GuideStep(
key="lifespan",
title="2. Lifespan and design mapping",
group="Lifespan",
tab="Lifespan Design",
body="""
For each restoration feature this asks, per cell: how many years is it expected to
survive here? Every modelled discharge carries a flood return period from
input_definitions.inp, and for every criterion the feature has a threshold for, the
analysis finds the lowest return period at which that threshold is exceeded. Cells
that survive every modelled flood stay NoData - their lifespan is longer than the
largest modelled event and cannot be quantified from the data.
Start with Angular boulders (rocks). It is the clearest case: a boulder fails when
the flow can move a grain of the size actually present, so its lifespan map is the
earliest flood that mobilises the bed, and its design map is the stable grain size
at the 20-year flood. Then run all features to see the spread.
Two results are worth reading rather than glancing at. Angular boulders map only
about 18 sqft, because the feature is restricted to cells that scour by 3 ft or more
and this reach barely does - without that restriction it is 66 000 sqft. And several
features report identical areas: their hydraulic criteria differ, but at the largest
floods every cell inside their shared depth-to-water-table band fails eventually, so
the mapped extent is the same even though the lifespans within it are not.
""",
settings=(("Condition", CONDITION),
("Features", "Angular boulders (rocks), then all"),
("Units", "U.S. customary")),
expect="19 features map. Streamwood covers the most ground (~332 000 sqft), "
"Angular boulders the least. Design maps appear for backwt, rocks, gravin "
"and gravou.",
writes=("Output/LifespanDesign/2100_sample/lf_<feature>.tif",
"Output/LifespanDesign/2100_sample/ds_<feature>.tif"),
),
GuideStep(
key="maxlifespan",
title="3. Best feature per cell (Max Lifespan)",
group="Lifespan",
tab="Max Lifespan",
body="""
Lifespan mapping answers "how long does this feature last here?". This answers the
planner's question instead: which feature belongs here, and how long will it last?
It takes the cell-wise maximum across the lifespan rasters of step 2, and a feature
wins a cell where its own lifespan equals that maximum. Ties are kept rather than
broken, so a cell where two features both reach the maximum appears in both layers -
that is deliberate, and it is why the shares add up to more than 100%. It tells you
the choice is yours.
Each winner is written as a mask raster and polygonised into a GeoPackage, so the
result can be drawn as action areas on a map.
""",
settings=(("Lifespan directory", "Output/LifespanDesign/2100_sample"),
("Write polygons", "yes")),
expect="About 335 000 sqft is mapped. Streamwood wins roughly three quarters of it "
"and other nature-based engineering most of the rest.",
writes=("Output/MaxLifespan/2100_sample/max_lf.tif",
"Output/MaxLifespan/2100_sample/best_<feature>.tif",
"Output/MaxLifespan/2100_sample/best_<feature>.gpkg"),
),
GuideStep(
key="terraforming",
title="4. Terraforming and earthworks (Morphology)",
group="Morphology",
tab="Terraforming",
body="""
Step 3 says which feature belongs where. This step asks what the terrain would have
to look like for those features to work, and what that costs in earth movement.
Where a planned planting sits further above the water table than its roots can
reach, the ground is lowered by exactly the excess - so the cell lands at the
deepest tolerable depth to water and no lower. The limit defaults to 7 ft, the
smallest depth-to-water tolerance among the planting features: the terrain has to
suit the most demanding species planned, not the least.
Point it at the Max Lifespan output folder from step 3. Features are applied in
sequence and each works on the terrain the previous one left, because lowering the
ground also lowers its depth to the water table.
Then open Volume Assessment beside it, give it dem.tif as the original and
dem_terraformed.tif as the modified DEM, and it reports the excavation volume.
Volumes are integrated under the triangulated surface, not summed as prisms, so the
quantity is comparable with the original software and with a contractor's estimate.
Remember that a threshold-based modification is a proposal, not a construction
drawing: it needs computer-aided design - edge smoothing and real-world geometry -
before anyone digs.
""",
settings=(("Condition", CONDITION),
("Feature action rasters", "Output/MaxLifespan/2100_sample"),
("Max. depth to water table", "7 ft"),
("Then", "Volume Assessment on dem.tif vs dem_terraformed.tif")),
expect="A few hundred cells are lowered, the deepest cut around 9 ft, and Volume "
"Assessment reports excavation only - lowering ground never places fill.",
writes=("Output/Terraforming/2100_sample/dem_terraformed.tif",
"Output/Terraforming/2100_sample/cut_depth.tif",
"Output/Terraforming/2100_sample/d2w_terraformed.tif"),
),
GuideStep(
key="sharc",
title="5. Habitat suitability and usable area (SHArC)",
group="Ecohydraulics",
tab="Habitat Area (SHArC)",
body="""
This is the first of the three ecohydraulic analyses and the one the other two lean
on. Habitat suitability curves from Fish.xlsx map flow depth and velocity onto an
index between 0 and 1; their geometric mean is the composite habitat suitability
index (cHSI), masked to the wetted area. Usable habitat area at a discharge is the
area where cHSI exceeds the threshold - 0.4 by default.
Run Chinook Salmon, spawning. Because the flow duration workbook
00_Flows/2100_sample/flow_duration_chsp.xlsx exists for that species code, the
result also carries SHArea: usable area integrated over the flow duration curve, so
habitat that only exists at a rare discharge counts for little. That single number
is what a project gets judged on.
Read the mean cHSI column, not only the usable area. Mean cHSI falls cleanly from
0.51 at 300 cfs to 0.04 at 42200, which is what you would expect: spawning wants
shallow, moderate flow. Usable area is not monotonic - it falls to about 16 000 sqft
near 4000 cfs, then climbs to a second peak of about 66 000 sqft near 9750 cfs,
because at that flow a large area of channel margin is inundated shallowly enough to
clear the threshold even though the reach as a whole is less suitable. Area and
quality are different questions, and SHArea is the one that weighs them together.
Two discharges break the pattern entirely, and both are the data rather than the
analysis. At 550 cfs the usable area collapses because u000550.tif tops out at
1.4 ft/s where its neighbours at 500 and 600 cfs reach 4.5. At 88053 cfs the depth
raster peaks at 6.8 ft where 42200 cfs reaches 22 ft, so it is a low-flow result
wearing a flood's file name. Real conditions contain rasters like these, and finding
them is part of the work.
""",
settings=(("Condition", CONDITION),
("Species", "Chinook Salmon"),
("Lifestage", "spawning"),
("Combine method", "geometric mean"),
("cHSI threshold", "0.4"),
("Flow duration", "00_Flows/2100_sample/flow_duration_chsp.xlsx")),
expect="60 discharges are evaluated; usable area is about 50 000 sqft at 300 cfs "
"and peaks near 66 000 sqft at 9750 cfs, and SHArea is around 24 000 sqft.",
writes=("Output/SHArC/2100_sample/csi_chsp<Q>.tif",),
),
GuideStep(
key="stranding",
title="6. Stranding risk",
group="Ecohydraulics",
tab="Stranding Risk",
body="""
As discharge falls the wetted area shrinks and breaks apart, and pools that lose
their connection to the main channel trap fish. Threshold each depth raster at the
minimum swimming depth of the species and lifestage, label the wetted regions, and
every region that does not reach the main channel is a stranding risk.
Run Chinook Salmon fry: the minimum swimming depth comes from Fish.xlsx and is
0.2 ft. That threshold is the single most influential parameter in the analysis, so
report it alongside any result you quote.
The main channel is defined once, at the lowest analysed discharge, and every higher
discharge is judged against it - the same target the original built its least-cost
escape routes towards. Q_disconnect.tif records the highest discharge at which each
cell was disconnected, which is the flow at which that spot becomes a trap as the
hydrograph recedes.
One caveat on this reach: the 88053 cfs rasters look like a low-flow result rather
than a 50-year flood, so ignore that row. It is an upstream data problem, and it is
also why the stranded percentage is reported against the largest *measured* wetted
extent rather than against the highest discharge's.
""",
settings=(("Condition", CONDITION),
("Species", "Chinook Salmon"),
("Lifestage", "fry"),
("Minimum swimming depth", "0.2 ft"),
("Connectivity", "4")),
expect="Almost every one of the 60 discharges produces disconnected pools. The "
"worst is 7250 cfs with 63 pools and about 2200 sqft stranded; roughly "
"13 000 sqft is disconnected at some point in the recession.",
writes=("Output/StrandingRisk/2100_sample/disconnected_<Q>.tif",
"Output/StrandingRisk/2100_sample/Q_disconnect.tif",
"Output/StrandingRisk/2100_sample/pools_<Q>.gpkg"),
),
GuideStep(
key="recruitment",
title="7. Riparian recruitment",
group="Ecohydraulics",
tab="Riparian Seedling Recruitment",
body="""
Cottonwood and willow seedlings establish only where four things happen in the right
order over a single season: a winter flow clears a seedbed, the water table then
drops slowly enough for roots to follow, the seedling is not drowned by prolonged
inundation, and no later flow uproots it. Each is scored 1, 0.5 or 0, and the
recruitment potential is their product - a zero anywhere is a zero overall.
This is the one module that needs a **daily flow record**, because bed preparation,
recession and scour are about when flows happened, not just which flows are
possible. The sample condition ships flow duration curves but no dated record, so a
synthetic water year is provided:
00_Flows/2100_sample/flow_series_2020.csv. Its discharges are the reach's own,
taken from its flow duration curve; only their ordering in time is invented. Treat
recruitment results here as a demonstration of the method rather than as a finding
about the reach.
Select 2020 as the analysis year. The crop area - where recruitment is possible at
all - is the band between the lowest and highest wetted extent during seed
dispersal, because seeds only land where the water reached.
""",
settings=(("Condition", CONDITION),
("Flow series", "00_Flows/2100_sample/flow_series_2020.csv"),
("Year", "2020"),
("Species", "Fremont Cottonwood")),
expect="A crop area of about 72 000 sqft, of which roughly 17 000 sqft reaches full "
"recruitment potential. Bed preparation is the limiting objective.",
writes=("Output/RiparianRecruitment/2100_sample_2020/recruitment_potential.tif",
"Output/RiparianRecruitment/2100_sample_2020/bed_preparation.tif",
"Output/RiparianRecruitment/2100_sample_2020/desiccation_survival.tif",
"Output/RiparianRecruitment/2100_sample_2020/inundation_survival.tif",
"Output/RiparianRecruitment/2100_sample_2020/scour_survival.tif"),
),
GuideStep(
key="maps",
title="8. Make maps, and what to check",
group="Maps",
tab="Mapping",
body="""
The Maps tab draws any of those rasters into a QGIS print layout and exports a PDF.
It needs QGIS bindings, which are built against the system Python rather than the
conda environment; without them the tab still opens and says so.
Before trusting any of the numbers above on your own data, check three things. Are
the units right - the whole analysis is silent about a unit mismatch. Is the
condition prepared - a missing d2w.tif does not raise, it just drops the criterion
that needed it. And does input_definitions.inp list a return period for every
discharge - lifespan mapping only uses the discharges that carry one, which on this
reach is 17 of the 60 rasters on disk.
""",
settings=(("Condition", CONDITION),
("Map type", "lifespan, habitat or stranding")),
expect="A QGIS project and a PDF under 02_Maps/, or a clear message that QGIS is "
"not available.",
writes=("02_Maps/",),
),
)
[docs]
def as_text(steps=None, width=88):
"""Render the guide as plain text, for a terminal or a log.
Args:
steps (tuple): the steps to render. Defaults to :data:`STEPS`.
width (int): wrap width.
Returns:
str: the whole guide.
"""
import textwrap
lines = [TITLE, "=" * len(TITLE), ""]
for step in (steps or STEPS):
lines.append(step.title)
lines.append("-" * len(step.title))
location = step.group if step.tab == step.group else "%s > %s" % (step.group,
step.tab)
lines.append("Tab: %s" % location)
lines.append("")
for paragraph in step.paragraphs():
lines.extend(textwrap.wrap(paragraph, width))
lines.append("")
if step.settings:
lines.append("Settings:")
for label, value in step.settings:
lines.extend(textwrap.wrap("%s: %s" % (label, value), width,
initial_indent=" - ",
subsequent_indent=" "))
lines.append("")
if step.expect:
lines.extend(textwrap.wrap("Expect: %s" % step.expect, width,
subsequent_indent=" "))
lines.append("")
if step.writes:
lines.append("Writes:")
lines.extend(" %s" % path for path in step.writes)
lines.append("")
return "\n".join(lines).rstrip() + "\n"