Creating Persistent Spatial Identifiers

The problem

Longitudinal polygon datasets often lack identifiers that remain meaningful when boundaries or labels change. spatpersist creates those identifiers from spatial continuity. It does not require prior IDs and does not use names to determine identity.

This vignette uses a synthetic dataset containing four common cases:

library(spatpersist)

polygons <- example_units()
sf::st_drop_geometry(polygons)
#>   year       name
#> 1 2000      Alpha
#> 2 2001      Alpha
#> 3 2000       Beta
#> 4 2001       Beta
#> 5 2000      Gamma
#> 6 2001      Delta
#> 7 2000      Omega
#> 8 2001 Omega East
#> 9 2001 Omega West

Create IDs

The default rule continues an identity when at least 75 percent of the earlier polygon is preserved. Candidate links are selected one-to-one.

result <- persist_ids(
  polygons,
  time = "year",
  threshold = 0.75,
  metric = "share_old"
)

sf::st_drop_geometry(result)[
  , c(
    "year",
    "name",
    "spatial_id",
    "spatial_version_id",
    "lineage_id",
    "parent_id",
    "transition_type"
  )
]
#>   year       name spatial_id spatial_version_id lineage_id parent_id
#> 1 2000      Alpha  SID000001     SID000001-V001  LID000001      <NA>
#> 2 2001      Alpha  SID000001     SID000001-V001  LID000001      <NA>
#> 3 2000       Beta  SID000003     SID000003-V001  LID000003      <NA>
#> 4 2001       Beta  SID000003     SID000003-V002  LID000003      <NA>
#> 5 2000      Gamma  SID000002     SID000002-V001  LID000002      <NA>
#> 6 2001      Delta  SID000002     SID000002-V001  LID000002      <NA>
#> 7 2000      Omega  SID000004     SID000004-V001  LID000004      <NA>
#> 8 2001 Omega East  SID000006     SID000006-V001  LID000004 SID000004
#> 9 2001 Omega West  SID000005     SID000005-V001  LID000004 SID000004
#>   transition_type
#> 1         initial
#> 2    continuation
#> 3         initial
#> 4    continuation
#> 5         initial
#> 6    continuation
#> 7         initial
#> 8           split
#> 9           split

The output separates four concepts:

  1. spatial_id identifies one continuing unit.
  2. spatial_version_id identifies consecutive boundary spells within that unit.
  3. lineage_id connects related units through splits, mergers, or replacements.
  4. parent_id records the strongest predecessor when a related observation starts a new identity.

The renamed polygon retains its identity because its geometry is unchanged. The expanded polygon retains its identity but advances to a new geometry version. Under the default rule, neither half of the split preserves 75 percent of the old polygon, so both receive new identities while remaining in the old polygon’s lineage.

Matching rules and ambiguity

Three spatial metrics are available:

match_rule = "greedy" ranks all eligible links and selects them one-to-one. match_rule = "mutual_best" considers only links that are best for both sides.

Near ties are controlled separately:

conservative <- persist_ids(
  polygons,
  time = "year",
  metric = "iou",
  threshold = 0.75,
  match_rule = "mutual_best",
  ambiguity_tolerance = 0.05,
  ambiguity_action = "new"
)

The three ambiguity actions are:

match_confidence combines the selected spatial score with its separation from eligible competitors. A score near zero indicates a tie or a candidate that lost a stronger competing link.

Audit transitions

Automatic identity decisions should remain inspectable. The transition table contains every positive-area overlap, not just selected links.

transitions <- id_transitions(result)
transitions[
  , c(
    "old_spatial_id",
    "new_spatial_id",
    "share_old",
    "share_new",
    "iou",
    "eligible",
    "mutual_best",
    "ambiguous",
    "selected",
    "lineage_link"
  )
]
#>   old_spatial_id new_spatial_id share_old share_new       iou eligible
#> 1      SID000001      SID000001       1.0 1.0000000 1.0000000     TRUE
#> 2      SID000002      SID000002       1.0 1.0000000 1.0000000     TRUE
#> 3      SID000003      SID000003       1.0 0.8333333 0.8333333     TRUE
#> 4      SID000004      SID000005       0.5 1.0000000 0.5000000    FALSE
#> 5      SID000004      SID000006       0.5 1.0000000 0.5000000    FALSE
#>   mutual_best ambiguous selected lineage_link
#> 1        TRUE     FALSE     TRUE         TRUE
#> 2        TRUE     FALSE     TRUE         TRUE
#> 3        TRUE     FALSE     TRUE         TRUE
#> 4       FALSE     FALSE    FALSE         TRUE
#> 5       FALSE     FALSE    FALSE         TRUE

An identity-level summary is also available:

id_lineages(result, time = "year")
#>   spatial_id lineage_id parent_id first_time last_time first_transition
#> 1  SID000001  LID000001      <NA>       2000      2001          initial
#> 2  SID000002  LID000002      <NA>       2000      2001          initial
#> 3  SID000003  LID000003      <NA>       2000      2001          initial
#> 4  SID000004  LID000004      <NA>       2000      2000          initial
#> 5  SID000005  LID000004 SID000004       2001      2001            split
#> 6  SID000006  LID000004 SID000004       2001      2001            split
#>   n_observations n_versions registry_observations ambiguous_matches
#> 1              2          1                     0                 0
#> 2              2          1                     0                 0
#> 3              2          2                     0                 0
#> 4              1          1                     0                 0
#> 5              1          1                     0                 0
#> 6              1          1                     0                 0
#>   min_match_confidence
#> 1                    1
#> 2                    1
#> 3                    1
#> 4                   NA
#> 5                   NA
#> 6                   NA

Visualize splits and mergers

plot_lineage(
  result,
  time = "year",
  lineage_id = "LID000004"
)

Solid edges are selected identity continuations. Dashed edges are broader lineage links. In the example, the two dashed branches show the split without forcing either child to reuse the predecessor’s identity.

Validate the result

issues <- validate_ids(result, time = "year")
issues
#> [1] severity check    row      message 
#> <0 rows> (or 0-length row.names)

A zero-row issue table means the result passed all implemented checks. These include within-period uniqueness, lineage consistency, sequential geometry versions, parent availability, transition labels, and match diagnostics.

Validation confirms structural consistency; it does not prove that a chosen spatial continuity rule is substantively correct for a particular research design. Thresholds and diagnostics should still be reviewed.

Calibrate before production

Matching parameters encode a research decision and should be calibrated before IDs are frozen. A practical calibration workflow is:

  1. Transform geographic coordinates to an appropriate equal-area projected CRS and choose a precision suitable for the source maps.
  2. Define the relative cost of a false continuation and a false new identity.
  3. Compare a small grid of metrics, thresholds, and matching rules against any trusted links or independently reviewed transitions.
  4. Inspect disagreement cases and selected links with low confidence or match_ambiguous = TRUE.
  5. Set max_time_gap explicitly when continuity should not cross missing periods.
  6. Record the chosen parameters and preserve the accepted output as a registry.

Agreement with an existing identifier system is evidence, not proof. A reference system may use names, institutional knowledge, or other attributes that spatpersist intentionally does not use. Persistent disagreements should therefore be interpreted substantively rather than resolved by maximizing a single aggregate score.

Preserve IDs across reruns

Passing a prior result as registry protects issued IDs when rows are reordered or new units are added.

rerun <- persist_ids(
  polygons,
  time = "year",
  registry = result
)

all(rerun$registry_matched)
#> [1] TRUE
identical(rerun$spatial_id, result$spatial_id)
#> [1] TRUE

Registry recovery uses same-time geometry and defaults to an IoU of 0.999999. Conflicting identity or geometry-version anchors stop with an error. New identities are numbered above the registry’s largest existing spatial ID. If new spatial evidence joins observations anchored to multiple existing lineages, the joined component deliberately consolidates to the lexicographically smallest existing lineage_id.

All generated identifiers are dataset-local. Separate projects independently start with identifiers such as SID000001 and LID000001, so combine outputs only with an additional project or dataset key. Persistence is scoped to one dataset and its registry chain, not to a global namespace.

Geometry and time controls

By default, invalid geometries stop processing. They can be repaired explicitly:

result <- persist_ids(
  polygons,
  time = "year",
  geometry_action = "repair"
)

geometry_precision sets an sf precision scale before spatial operations. For example, geometry_precision = 100 rounds coordinates to two decimal places when geometries are passed to spatial libraries.

After repair and precision are applied, exact coextensive geometries within one time period are rejected. Because attributes are intentionally excluded from matching, no reproducible spatial tie-breaker exists for those observations.

max_time_gap prevents continuity across large gaps in numeric or date-based time columns. Its units are the time column’s numeric units, days for Date, and seconds for POSIXt.

Interpretation limits