Overview
rfp_project_create() assembles a complete field-ready
QGIS project in one call. It downloads spatial data, copies field forms
and templates, and builds a project directory ready to push to Mergin
Maps for mobile collection.
Quick start
library(rfp)
rfp_project_create(
name = "elk_river_2026",
watershed_groups = c("ELKR"),
template = "bcfishpass_mobile",
forms = c("pscis", "fiss_site")
)
#> ℹ Creating project directory: ~/Projects/gis/elk_river_2026
#> ℹ Downloading bcdata layers
#> ℹ Downloading fwa layers
#> ℹ Downloading aws layers
#> ℹ Copying QGIS template: bcfishpass_mobile
#> ℹ Copying form: pscis
#> ℹ Copying form: fiss_site
#> ✔ Project created: ~/Projects/gis/elk_river_2026Output directory structure
~/Projects/gis/elk_river_2026/
├── elk_river_2026.qgs QGIS project file
├── background_layers.gpkg Spatial data (16+ layers)
├── habitat_lateral.tif Lateral habitat raster
├── form_pscis.gpkg PSCIS assessment form
├── form_fiss_site.gpkg Fish habitat site form
└── ignore_mobile/
├── photos/ Field photo directory
├── methods/ Provincial methods PDFs
└── templates/ Provincial form templates
The ignore_mobile/ directory is excluded from Mergin
Maps sync — it holds reference documents for office use.
A small thematic project
A template is a starting point, not a fixed layer set. Pass
layer_config to build a project around one theme, and the
.qgs is reconciled to what was downloaded — you keep the
template’s cartography for the layers you asked for and QGIS opens
without a Handle Bad Layers dialog.
cfg <- rfp_project_layers("bcrestoration_mobile")
floodplains <- cfg[cfg$source_layer %in% c(
"whse_basemapping.cwb_floodplains_bc_area_svw",
"whse_basemapping.fwa_lakes_poly",
"bcfishpass.streams_vw",
"whse_basemapping.transport_line"
), ]
rfp_project_create(
name = "floodplains_bulkley",
watershed_groups = "BULK",
template = "bcrestoration_mobile",
layer_config = floodplains
)
#> ℹ Trimmed 45 layers with no data in this project:
#> Conservancy whse_tantalis.ta_conservancy_areas_svw
#> Fire Severity whse_forest_vegetation.veg_burn_severity_sp
#> …
#> ✔ Project created: ~/Projects/gis/floodplains_bulkleyTwo things follow from the fact that layer_config
selects tables:
- One table can back several styled layers. Asking for
bcfishpass.streams_vwkeeps all four of the restoration template’s stream layers —Streams - all,streams_bt,streams_salmon,streams_st— each with its own symbology. To pick among them, subset afterwards withrfp_project_subset(), whoselayers =names layers rather than tables. - Basemaps and any other source that is not a file inside the project are never trimmed. Only an artifact rfp can point at on disk, and prove is missing, is removed — so a layer type added to the gq registry later (a STAC floodplain COG, a WMS collection) survives with no change to rfp.
A layer that was requested but failed to download is kept, with a warning, rather than trimmed. That keeps a transient outage recoverable by a refresh instead of quietly costing you the styled layer.
The project says what it is
Creation writes rfp_project.json beside the
manifest:
jsonlite::read_json("~/Projects/gis/floodplains_bulkley/rfp_project.json")
#> $template
#> [1] "bcrestoration_mobile"
#> $created
#> [1] "2026-08-13"
#> $rfp_version
#> [1] "0.15.0"The template is the part that gets used.
rfp_project_update(), rfp_project_audit() and
rfp_manifest_create() read it, so they stop assuming
bcfishpass_mobile when you do not pass
template =:
# no template argument - resolved from the project
rfp_project_audit("~/Projects/gis/floodplains_bulkley")
#> ℹ Template: bcrestoration_mobile (recorded by the project)That matters because the registries differ. Without the record,
rfp_manifest_create() on a restoration project marked
whse_basemapping.cwb_floodplains_bc_area_svw as
frozen — never refreshed, and a hard error later if it ever
went missing.
An explicit template = always wins, so you can override
a stale record without editing the project. A project created before
this existed has no record and behaves exactly as it did.
The project’s birth certificate
Creation writes rfp_manifest.csv at the project root, so
the project starts inside the lifecycle instead of needing one
backfilled:
rfp_manifest_read("~/Projects/gis/floodplains_bulkley/rfp_manifest.csv")
#> # A tibble: 4 x 2
#> source_layer source_type
#> <chr> <chr>
#> 1 whse_basemapping.cwb_floodplains_bc_area_svw bcdata
#> 2 whse_basemapping.fwa_lakes_poly bcdata
#> 3 whse_basemapping.fwa_watershed_groups_poly bcdata
#> 4 bcfishpass.streams_vw awsThe watershed-group layer is there even though the
layer_config above never asked for it — every source pass
rewrites it, so it is always part of a project’s intent.
Two things follow from the manifest being an intent record rather than an inventory:
- It is derived from what you asked for, not from what landed. A layer
that was empty in this area, or whose download failed, stays listed —
rfp_project_audit()reports it as informational and the nextrfp_project_update()adds it. That is the same reasoning that keeps such a layer in the.qgs. - It is itself a valid
layer_config, so a project can be rebuilt from one:
m <- rfp_manifest_read("~/Projects/gis/floodplains_bulkley/rfp_manifest.csv")
rfp_project_create("floodplains_morice", watershed_groups = "MORR",
template = "bcrestoration_mobile", layer_config = m)Edit the CSV to change what the project carries — that edit
is the intent, which is why rfp_project_update()
has no add = argument in manifest mode.
What a rebuild carries over, and what it does not
The manifest is two columns, so it restores the layer
set — and from that, the new project’s own manifest,
its rfp_project.json and a .qgs trimmed to
those layers. It does not, on its own, restore the source project’s
arrangement:
- layer-tree group placement and order for anything added after creation
- a map theme that exists only in that one project’s
.qgs
Map themes are the part that is now recoverable, because a theme is data — see the next section. What no artifact records is where a layer sits in the tree and in what order, which is why a rebuilt project draws its added layers wherever the template puts them.
For the example above this costs nothing — a new area gets a fresh project and there is no arrangement to carry. It matters when you are rebuilding a project you want back the way it was.
A map theme is data too
A theme is a list of layer names and visibility flags, so it lives in a shipped registry rather than inside whichever project happened to have it first:
reg <- rfp_qgs_theme_lookup()
unique(reg$layers$theme)
#> [1] "High Detail - Crossings" "Imagery"
#> [3] "Land Tenure" "Low Detail - Bull Trout Model"
#> [5] "Low Detail - Salmon Model" "Low Detail - Steelhead Model"rfp_project_create() applies every theme in it by
default. That is safe because a theme already in the project is
left alone — so the five a restoration template carries
are skipped, and only Imagery is added:
rfp_project_create("floodplains_morice", watershed_groups = "MORR",
template = "bcrestoration_mobile")
#> ℹ 5 themes already in the project and left alone: …
#> ✔ Set 1 theme in …An existing theme is skipped rather than rewritten on purpose.
Writing a theme regenerates it from scratch, so anything the writer does
not read would be destroyed — and a theme someone curated in QGIS
Desktop is exactly what a re-run must not flatten. Replacing one takes
force = TRUE.
To give an existing project a theme, or to change your mind later:
rfp_qgs_theme_add("~/Projects/gis/floodplains_bulkley", "Imagery")
#> ℹ 5 themes already in the project and left alone: …
#> ✔ Set 1 theme in …Two things about how far a theme travels:
-
Its layer list goes anywhere. A layer the project
does not have drops its reference and is reported, rather than leaving a
broken one behind — so
Imageryreaches 11 of its 13 layers in both shipped templates and needs no per-project variant. -
Its group state does not. Which layer-tree groups a
theme checks is recorded per template, because a group path is recorded
from the project group down — so every one begins
bcfishpass Mobile /orbcrestoration Mobile /and resolves in one template only. (Not because the templates name their groups differently: measured, they spell every shared group identically. The templates do not carry all the same groups, which is the second and narrower reason.) Applied to the template it was recorded against, a theme comes back exactly; applied elsewhere, the group state is inferred from where the layers sit and the theme still works.
A theme is rarely authored from nothing — it is nearly always another
theme plus a small delta. rfp_qgs_theme_create() says that
directly:
rfp_qgs_theme_create(
"~/Projects/gis/floodplains_bulkley",
name = "Low Detail - Bull Trout Model",
base = "Low Detail - Salmon Model",
add = "streams_bt",
drop = "streams_salmon"
)That really is the difference between those two shipped themes. Every
one of the seven non-Imagery themes contains all 21 of the
layers they share and differs by four to seven members.
To change one layer’s membership without rebuilding the theme, use
rfp_qgs_theme_layer_add() /
rfp_qgs_theme_layer_rm(). A theme hides a layer by
absence, so visible = FALSE is the way to
keep a layer reachable in a theme without drawing it:
rfp_qgs_theme_layer_add(p, "Trails", themes = "all")
rfp_qgs_theme_layer_add(p, "ESRI World Topo", themes = "all", visible = FALSE)rfp_qgs_themes() and rfp_qgs_theme_set()
remain the low level — read one project’s themes and write them into
another, with no intermediate file. Prefer them for a whole-theme port;
prefer the two above for a single layer, because
rfp_qgs_theme_set() regenerates every preset it
touches.
The data carries its own cartography
The background GeoPackages are written with a
layer_styles table taken from the project’s own
.qgs. QGIS reads that table on Add Layer —
never on project open — so this is what makes a layer arrive styled
somewhere else:
# in another project, or a blank one
# Layer > Add Layer > Add Vector Layer >
# floodplains_bulkley/background_layers/whse_basemapping.fwa_lakes_poly.gpkg
# draws with the restoration template's symbology, no .qgs involvedA table styled several ways keeps all of them, distinguished by
styleName, with the project’s first-listed layer as the
default — so bcfishpass.streams_vw carries
Streams - all, streams_bt,
streams_salmon and streams_st, and opens as
Streams - all.
Adding a layer to an existing project runs the other way around: it
has to be added in QGIS Desktop to display at all, and the next
rfp_project_update() then writes its style into the
data.
Recording where the crew walked
Mergin Maps can record a track while a crew works. It is off by
default; pass tracking = TRUE and the project ships ready
to record:
rfp_project_create("elk_river_2026", watershed_groups = "ELKR",
tracking = TRUE)
#> ✔ Added "Position track" to …/elk_river_2026.qgs
#> ✔ Position tracking enabled (normal) in …/elk_river_2026.qgsThat writes three things together — the
tracking_layer.gpkg the app appends to, a
Position track layer declaring it, and the project
properties that switch recording on. They go together on purpose: the
setting names the layer by id, so a project with tracking on and no
layer records nothing and reports nothing.
Check or change it later:
rfp_qgs_tracking_get("~/Projects/gis/elk_river_2026/elk_river_2026.qgs")
#> $enabled [1] TRUE
#> $frequency [1] "normal"
#> $resolves [1] TRUE
rfp_qgs_tracking_set(qgs, frequency = "best")Two things worth knowing before choosing a frequency:
-
It is not an interval.
"best","normal"and"low"mean different things on each platform, and iOS is distance-only — a stationary phone records nothing at any setting. See?rfp_qgs_tracking_setfor the table. - A track is written once, when the crew taps Stop. A crash before that loses the session, and on Android recording stops with the screen off.
A track carries a timestamp on every vertex, so points collected separately — observations, photos — are matched to it afterwards by time rather than being attached at capture.
Naming a track in the field
A recorded track says when, how far and who — and nothing about what
the session was. track_fields = TRUE adds three
columns and gives them widgets, so the person who walked it can say:
rfp_project_create("elk_river_2026", watershed_groups = "ELKR",
tracking = TRUE, track_fields = TRUE)| field | |
|---|---|
track_name |
what the session was |
track_description |
free text — conditions, what was found, why it ended |
track_type |
day log (default), stream survey, bushwhack, access route, drive, other |
named_by |
fills in automatically — who named it, where that differs from who walked it |
An existing project takes the two halves directly, in this order — the project half refuses to configure a field the GeoPackage does not have:
rfp_tracking_fields_add("~/Projects/gis/elk_river_2026/tracking_layer.gpkg")
rfp_qgs_tracking_fields_add(qgs)Both are idempotent, so re-running against a project already in the field adds nothing and leaves the GeoPackage byte-identical.
track_type starts at day log, because that
is the normal case — a track runs all day so anything recorded
separately can be found by its time. The others name the exceptions.
Verified on a phone: the form opens on tapping a track, both the text
box and the dropdown take input, and named_by fills itself
in. It is still FALSE by default so existing calls are
unchanged. rfp_project_audit() reports which halves a
project carries, and
rfp_qgs_tracking_fields_add(force = TRUE) updates a project
that already has an older version of the set.
Available templates
rfp_project_templates()
#> # A tibble: 2 x 3
#> name description path
#> <chr> <chr> <chr>
#> 1 bcfishpass_mobile Fish passage assessment workflow /...
#> 2 bcrestoration_mobile Restoration planning and monitoring workflow /...Available forms
Forms are pre-built GeoPackage databases shipped with the package. The list is rendered from the registry rather than written out here: a hand-maintained copy of something the package can print drifts, and this one had listed six of the sixteen forms for several releases.
forms <- rfp::rfp_form_types()
knitr::kable(
forms[, c("type", "label", "parent", "description")],
row.names = FALSE,
col.names = c("Form", "Label", "Parent", "Description")
)| Form | Label | Parent | Description |
|---|---|---|---|
| pscis | PSCIS | Fish Passage Stream Crossing Assessment | |
| fiss_site | FISS Site | Fish Habitat Site Assessment | |
| fish_sample | Fish Sample | Fish Individual Sample Data | |
| fhap | FHAP | Fish Habitat Assessment Protocol | |
| edna | eDNA | Environmental DNA Sampling | |
| monitoring | Monitoring | Restoration Monitoring | |
| vri_qa | VRI QA | VRI Polygon Field QA | |
| transition_qa | Transition QA | Land Cover Transition QA | |
| exceedance | Exceedance | LRMP Zero-Target Exceedance | |
| viewscape | Viewscape | Viewscape Repeat Photo Station | |
| monitoring_fish_passage | Fish Passage Monitoring | Fish Passage Effectiveness Monitoring | |
| monitoring_general | General Monitoring | Inventory and monitoring of past and potential restoration sites | |
| scan | Scan | Phone LiDAR or Photogrammetry Scan | |
| observation | Observation | Generic Incidental Observation | |
| cabin_visit | CABIN Visit | CABIN Benthic Site Visit | |
| cabin_visit_pebble | CABIN Pebbles | cabin_visit | CABIN Pebble Count |
label is what a field crew sees in the layer list and in
the observation form’s “form wanted” dropdown. A row with a
non-blank Parent is a child table — it has
no GeoPackage of its own and is deployed by its parent, so it cannot be
passed to rfp_form_create() directly.
Customising data sources
By default, rfp_project_create() downloads all three
source types (bcdata, fwa, aws). To skip a source:
rfp_project_create(
name = "quick_project",
watershed_groups = c("ADMS"),
sources = c("bcdata", "fwa") # skip aws
)Applying styles
After creating a project, apply gq registry styles so layers look right in QGIS:
rfp_styles_apply("~/Projects/gis/elk_river_2026/background_layers.gpkg")
#> ✔ Styles applied: 11 styled, 5 skippedSee vignette("styles") for details on the style
pipeline.
Deploying to Mergin Maps
Push the project to Mergin Maps for mobile field collection:
rfp_mergin_create(
"newgraph/elk_river_2026",
"~/Projects/gis/elk_river_2026"
)
# Grant access to field crew
rfp_mergin_share("newgraph/elk_river_2026", "field_tech_1", "writer")
rfp_mergin_share("newgraph/elk_river_2026", "field_tech_2", "writer")See vignette("mergin") for the full sync workflow.
