Skip to content

0.4.0-0.4.2: network properties, correctness fixes, delivery hygiene - #3

Open
mikejohnson51 wants to merge 33 commits into
mainfrom
network-properties
Open

0.4.0-0.4.2: network properties, correctness fixes, delivery hygiene#3
mikejohnson51 wants to merge 33 commits into
mainfrom
network-properties

Conversation

@mikejohnson51

Copy link
Copy Markdown
Member

Takes main from 0.3.4 to 0.4.2. 25 commits, clean fast-forward (no commits on main are absent here).

Correctness fixes

None of these were ever released, so no published version is affected.

  • hf_upstream_index() returned a degenerate index under its default nexus = NULL. Downstreams resolved only through the nexus map, so an empty map made every flowpath a terminal: each node isolated, num_upstreams 0, no warning. write_hydrofabric() passes nex = NULL whenever the written list has no nexus layer, so it stamped those values into the GeoPackage.
  • as_ogr()'s layer filter was unanchored, silently dropping any real layer whose name merely contained gpkg_, rtree_ or sqlite_ (for example flowpaths_gpkg_v2). Now anchored, and layer_styles/qgis_projects are excluded so a QGIS-styled fabric does not read as ambiguous.
  • gpkg_update_geom() leaked four rtree_* tables per call into the user's GeoPackage, plus triggers and a gpkg_extensions row. Ten updates left forty orphan tables. Cleanup is now complete and inside the same transaction.

Coverage

First tests for gpkg_update_col(), gpkg_update_geom() and gpkg_exec() — three exported functions that write destructively in place and had none. R/gpkg_metadata.R 45.7% to 98.4%; package 76.2% to 81.6%. Suite is 326 tests.

Delivery

  • pkgdown::check_pkgdown() was failing on three exports missing from the reference index, which fails the site build outright. The last green Website run predates those functions, so the break was queued for the next push to main.
  • Copyright settled on Lynker Spatial across NOTICE, LICENSE, THIRD_PARTY_NOTICES and DESCRIPTION.
  • Documentation accuracy pass: a README example that errors as written, a stale dependency badge, a vignette snippet using a column its example frame lacks, and the undocumented add_measures() join key.
  • lintr clean under LINTR_ERROR_ON_LINT=true; spelling now fails the suite on a new word rather than scrolling past.

Local verification

R CMD check --as-cran clean apart from an "unable to verify current time" NOTE, which is a sandbox artifact. Verified on macOS with R 4.4.2 only — this PR exists so the five-config matrix runs before anything reaches main.

Note that Website builds but does not deploy on a pull request; that is expected.

Mike Johnson added 30 commits June 26, 2026 22:06
Native replacement for hydroloom::add_levelpaths matching the get_streamorder/
get_hydroseq style: mainstem follows the largest-weight upstream branch, level
path id = outlet hydrosequence. Integer-indexed topo loop (O(N)).
get_streamorder kept its accumulator as a CHARACTER-named vector and did
so[nd] / so[contribs] / up[[nd]] lookups by name inside the topological loop --
each an O(n) scan, so the whole pass was O(n^2). It stalled mega-basins: ~44 min
on the Mississippi's 461k reaches (and infeasible at Amazon scale). This is what
actually hung the NA reference rebuild (not the splitter). Rewrite to the same
integer-indexed pattern get_hydroseq/get_levelpath already use: map ids to
positions once, integer-indexed children + topo order, accumulate into an
UNNAMED positional vector. Identical Strahler output (existing tests unchanged);
461k now 1.5s vs ~44min. Added a 120k scale-guard test.
- Vendor gpkg metadata helpers (gpkg_exec / update_col / update_geom), topology
  helpers (hf_network_is_dag, hf_assert_network_dag, hf_recompute_hydroseq,
  hf_break_cycles) and hf_fmt_id from the build package; drop dead network_is_dag
- Add topology + fmt-id tests
- README: portable example (drop hardcoded local path)
- Add THIRD_PARTY_NOTICES; tidy invariant comments
- get_pathlength(): distance along the network from each reach's outlet to
  the terminal outlet (NHDPlus PathLength).
- get_streamlevel(): stream level over the level-path graph (NHDPlus StreamLeve).
- get_pfafstetter(): hierarchical Pfafstetter basin codes, verified bit-for-bit
  against a 746-reach reference basin fixture.
- Extend the network-properties vignette to the full get_* family with a
  runnable worked example; add the family to the pkgdown reference index (plus
  previously unindexed exports); cross-link the seven functions via @family.
- Reword get_streamorder()/get_levelpath() docs to describe behavior directly.
- Rename the read_hydrofabric() verbosity env var to hfutils_verbose.
- Version 0.4.0 + NEWS.
Adds upstream_index(x, id, toid) to the network-properties family. A
depth-first pre-order labelling (upstream_id) plus the strict upstream
count (num_upstreams) encode the nested set model, so "everything
upstream of X" becomes an O(1) integer range filter (upstream_id in
(u, u + k]) with no traversal at query time.

Reuses accumulate_downstream for num_upstreams (inheriting its acyclic
check), expands the largest-upstream branch first so main stems stay
contiguous, and flags divergences (duplicate ids) and cycles rather than
producing a misleading index. Sibling of accumulate_downstream and
get_streamorder; consumed by hydrofabric::add_upstream_index().
Groups a network into contiguous same-order runs by walking upstream_index
pre-order, breaking each run at a confluence/branch boundary or (optionally)
where stream order changes. Each group is a contiguous upstream_id range, so a
size-budgeted tiler or partitioner can merge groups into balanced chunks that
are always complete sub-networks. Sibling of upstream_index/get_streamorder.
Adds a TRADEMARKS.md policy and NOTICE trademark section (Apache-2.0 grants no
mark rights; "hfutils" used descriptively), sets Mike Johnson as sole copyright
holder with Justin Singh-Mohudpur as contributor, and bumps to 0.4.1 for the
upstream_index / merge_groups additions.
Convert em-dashes to commas/colons/semicolons and unicode arrows to ASCII
in NEWS, vignettes, CONTRIBUTING, THIRD_PARTY_NOTICES, and TRADEMARKS, and
generalize the O(n^2) note in get_streamorder. Docs/comment-only.
Adds hf_upstream_index(flowpaths, nexus): the schema-aware wrapper that
resolves the flowpath -> nexus -> flowpath topology and computes the
nested-set index via upstream_index(). write_hydrofabric now stamps
upstream_id / num_upstreams onto every flowpath-keyed layer when the list
is a complete hydrofabric (attribute-only, schema-detected, graceful skip),
so any gpkg written through it (per-VPU, merged, or subset) carries a
correct-scope O(1) upstream index.
Two defects, both in the geometry round-trip.

It routed input through terra::makeValid() |> terra::aggregate(). That
perturbed shared boundaries enough that neighbouring groups came out
overlapping a tiling input. Grouped sf::st_union() over already-valid geometry
preserves the input tiling exactly. Input is now repaired only where
sf::st_is_valid() reports a problem, since perturbing valid geometry was the
whole source of the drift.

It also cast the result to POLYGON and kept only the largest part per group,
discarding the rest with a warning. A group whose members are genuinely
disjoint is a multipart catchment, not an error, and dropping the smaller parts
silently deleted ground. Output is MULTIPOLYGON, which is lossless. This path
was latent rather than active in current data, but it is the same defect that
had to be fixed independently in two downstream callers.

Measured on 61,061 real divides in 23,910 groups: overlap introduced 20 km2 ->
0.000000 km2, area delta -0.0005 km2, and 21s rather than ~65s. Faster as well
as exact -- the terra round-trip was buying nothing.

One existing test asserted the discard behaviour, including its warning and
all-POLYGON output. It encoded the bug, so the assertion is deliberately
inverted to require conservation, and a second test now requires that a tiling
input dissolves without creating overlap.

Note this does NOT by itself reduce the aggregate-divide overlap observed
downstream: measurement shows that overlap is introduced after the union, by a
later stage-2 step, which is being localised separately.
Unanchored, ignore_lyrs dropped any real layer whose name merely
contained gpkg_, rtree_ or sqlite_ (flowpaths_gpkg_v2), silently and
with no error. Anchor the prefixes.

Also ignore layer_styles and qgis_projects, which QGIS writes into a
GeoPackage when a style or project is saved to it. Counted as user
layers they made a styled single-layer fabric look ambiguous.

Collapse the three copies of the default into one constant, and update
the multi-layer test, which still asserted pre-d0a35ae behaviour.
lint.yaml runs with LINTR_ERROR_ON_LINT=true. Wrap two over-long SQL
strings, and fix compound semicolons and spacing in the tests.
Downstreams resolved only through the nexus map, so with the default
nexus = NULL the map was empty, every flowpath became a terminal, and
the index came back degenerate: each node isolated, num_upstreams 0,
no warning. write_hydrofabric passes nex = NULL whenever the written
list has no nexus layer, so it stamped those values into the
GeoPackage.

A toid that is already a flowpath id is now a direct link; the rest
still go through the nexus map. Tests cover nexus-less, nexus-hop and
mixed networks, and check the nexus-less result against upstream_index
over the same topology.

Also clears this file's semicolon lints and spells modelled US-style.
read_sf_dataset passed a bare external vector to select(), deprecated
since tidyselect 1.1.0; wrap it in all_of(). Guard the second on.exit
disconnect in write_hydrofabric with suppressWarnings, matching the
one above it. Drop %||%, which had no call sites and shadowed base R
4.4's version with different semantics.

Document the upstream-index stamping in write_hydrofabric, a
user-visible side effect the help page never mentioned.
Copyright read three ways: Mike Johnson in NOTICE, Mike Johnson and
Lynker Spatial in the LICENSE appendix and THIRD_PARTY_NOTICES, and
Mike Johnson as sole cph in DESCRIPTION. Settle on Lynker Spatial as
cph and fnd; Mike Johnson stays aut, cre and maintainer.
check_pkgdown() failed on upstream_index, hf_upstream_index and
merge_groups missing from the reference index, which fails the site
build. The last green Website run predates those functions, so the
break was queued for the next push to main.

Also links Lynker Spatial on the authors page and bumps a stale
actions/checkout@v3.
The shipped QGIS project misspelled the brand TRADEMARKS.md exists to
protect. The .qgs member inside the archive still does, and will clear
when the project is next re-exported.

TRADEMARKS.md was the only top-level policy file missing from
.Rbuildignore, so R CMD check flagged it.
The README opened with as_ogr(gpkg) on a fabric it describes as holding
two layers, which errors as ambiguous. The dependency badge read 16/49
against 18 direct and 72 recursive. The network vignette said "both
functions" while describing seven and accumulated a column its example
frame lacks. add_measures documented neither the columns it adds nor
its join key, though the wrong key silently zeroes areasqkm.

upstream_index, hf_upstream_index and merge_groups appeared in no
narrative docs and no NEWS entry; both vignettes now cover them.
CONTRIBUTING documents the lint, spelling and pkgdown gates.

spelling.R ran with error = FALSE, so 28 unrecognised words had piled
up unnoticed; enable it and fill inst/WORDLIST. Regenerates NAMESPACE
and the Rd files left stale by 9d2e4f7.
Writing the changed features to a temporary layer makes GDAL build a
spatial index beside it: four rtree_* shadow tables, their triggers,
and a gpkg_extensions row. Cleanup removed only the feature table and
its gpkg_contents and gpkg_geometry_columns rows, so the rest stayed
in the file and accumulated per call. Ten updates left forty orphan
tables. Removal now goes through .gpkg_drop_layer inside the same
transaction. Verified over ten updates: no orphans, trigger count
unchanged.

Name the temp layer from tempfile() rather than the clock, which
collided within a centisecond and put a "." in a SQL identifier.

Adds the first tests for the three destructive mutation entry points.
Writing them surfaced a requirement now in gpkg_exec's help: an UPDATE
on a layer with geometry needs drop_triggers_for even when it touches
only attributes, because the R-tree triggers call ST_IsEmpty and
RSQLite has no such function. Coverage 45.7% -> 98.4% on this file.
Seven inline comments explained current behaviour by narrating the
broken state before it. Past tense about code that no longer exists
dates badly and makes a reader reconstruct history to find the rule.

Rewritten in union_polygons, drainage_area_populated, .gpkg_drop_layer,
gpkg_update_geom, hf_upstream_index, get_streamorder and
.hf_ignore_lyrs. The add_measures comment restated the @details block
rather than pointing at it.

The long comments in geom.R, clean_geom.R, auth.R and the lake check
stay: each documents external behaviour that cannot be read off the
code.
No CODECOV_TOKEN secret is set, so codecov-action@v4 falls back to
tokenless upload, which Codecov rate-limits. The upload step has never
actually run: the coverage step failed ahead of it on every previous
run, so this would have been its first execution.
Local lintr 3.2.0 reported clean; CI runs 3.3.0.1, which adds the two
linters below. CONTRIBUTING now says to update lintr before trusting a
local run.

geom.R kept one magrittr pipe so the next call could use the `.`
pronoun, which the native pipe has no equivalent for. Assign the
intermediate instead and drop the now-unused %>% import. Swapping the
pipe without restructuring would have reintroduced the "object '.' not
found" failure recorded in 0.3.4.

get_pfafstetter accumulated results with <<- from inside its recursion.
Use an explicit environment, which is what the linter asks for and
makes the mutable state visible. The 746-reach oracle test confirms
the codes are unchanged.
0.4.0 and 0.4.1 were development versions on an unpushed branch, so
nobody could install either. Three headings would have split the notes
for the single v0.4.2 tag across versions that never existed. All 16
bullets are carried over unchanged; the lead says what the collapse
covers.
Pushing during a long R-devel build left the old run competing with the
new one for a runner; it happened three times on this branch, and the
stale results do not count toward the PR head anyway. R-CMD-check, lint
and test-coverage now cancel in progress. pkgdown keeps its job-level
guard, which already exempts pull requests.
The file held four section comments and no code, naming the four files
the work actually went into (OGRSQLDriver.R, OGRSQLConnection.R,
OGRSQLResult.R, sf_ogr.R). It was never non-empty: a5c4d9f created it
that way and nothing filled it in.

Being in Collate meant it shipped and was sourced on every build, and a
file called OGRSQL.R that does not define OGRSQL() sends anyone
navigating by filename to the wrong place; the function is in
sf_ogr.R. Nothing @includes it, and roxygen regenerates Collate from
the @include directives in sf_ogr.R, so the load order that the S4
classes depend on is unchanged.
man/figures/README-unnamed-chunk-6-1.png came from a5c4d9f, the same
commit that added the empty OGRSQL.R scaffold, and is referenced
nowhere. Every chunk in the current README.Rmd is eval = FALSE apart
from the library load, so no README figure is generated at all. It was
still shipping 56K in the tarball, since man/figures is installed.

The knitr fig.path is left pointing at man/figures/README-, so a future
evaluated chunk still lands in the right place.
Mike Johnson added 3 commits August 8, 2026 18:58
read_qml(), create_style_row() and append_style() come across from the
hydrofabric package, together with QML for divides, flowlines,
flowpaths, hydrolocations, lakes and nexus. Styling is stamped into a
GeoPackage's layer_styles table at write time, so it belongs beside
write_hydrofabric() rather than in the build package, which does not
even import hfutils.

append_style() matched QML to layers by grepping the directory listing
and then pairing it against the caller's layer order positionally.
Those two orders are unrelated, so append_style(gpkg, layer_names =
c("nexus", "divides")) gave nexus the divides symbology and divides the
nexus one, silently. Match on exact basename and index by layer name
throughout. A requested layer with no shipped QML is skipped, and so is
an attribute table with no geometry column: create_style_row() indexed
row 1 of an empty gpkg_geometry_columns query and errored on network.

flowpaths line width was bound to `order` as a raw field, so stream
order was read straight as millimeters and an order-7 mainstem drew as
a 7 mm line. Drive it from tot_drainage_areasqkm instead, log-scaled
because drainage area spans six orders of magnitude, capped at 5 mm,
and render ascending so mainstems sit on top at confluences. Add
flowlines.qml for the reference linework: same attribute, thin and
light blue over a narrower range.

Not wired into write_hydrofabric(); calling it stays explicit.
Waterbodies had no symbology. WB is a distinct layer from lakes,
sharing only id and geometry: lakes carries the NWM reservoir routing
parameters (weir and orifice coefficients, dam length) and is styled as
points, while WB is waterbody polygons. WB.qml is a semi-transparent
pale blue fill so flowpaths stay legible beneath it.

Every geometry-bearing table in the data model now ships a style:
divides and WB as polygon, flowpaths and flowlines as line,
hydrolocations, lakes and nexus as point. network and
flowpath_attributes are attribute-only and take none. Tests assert both
that coverage and that each file declares the geometry type its layer
actually has, since a QML with the wrong layerGeometryType is silently
ignored by QGIS.
Adds `styles`, defaulting to FALSE. layer_styles is a QGIS extension
rather than part of the GeoPackage specification, so writing it
unconditionally would add a table that every reader sees, not just
as_ogr(): sf::st_layers(), ogrinfo and fiona all list it. It also costs
a fixed ~70 KB, most of it divides.qml, which is a large fraction of a
small subset.

The failure modes are not symmetric. Forgetting to style a deliverable
shows up the moment the file is opened and is fixable afterwards with
append_style(). Styling a pipeline intermediate is silent and
propagates into everything built from it. Off by default, on for
deliverables.

Applied after the atomic swap, on the finished file, so a styling
failure warns and leaves a correctly written fabric instead of
aborting one. Layers with no shipped QML and attribute tables with no
geometry are skipped by append_style() already, so passing the whole
layer set is safe.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant