---
title: "Using OPCC"
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{Using OPCC}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---
```{r setup, include = FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
```
OPCC links Ontario postal codes to Statistics Canada 2021 census geographies
through open, redistributable evidence.
**Two access paths:**
| You want to... | Go to |
|---|---|
| Look up postal codes or join census geographies to your own data | [Part 1](#part-1-use-opcc-with-your-data) -- uses pre-built artifacts downloaded automatically by the package; no source checkout needed |
| Verify artifacts or reproduce the DA roll-up from the DB artifact | [Part 2](#part-2-reproduce-and-verify-artifacts) -- uses package functions only; no source checkout needed |
| Rebuild all artifacts from raw Statistics Canada sources | [Rebuild all artifacts](#rebuild-all-artifacts-from-raw-sources) -- uses package functions for download and build; requires `sf`, `dplyr`, `readr` |
Most code below is shown but not run when this vignette is built. Those chunks
download versioned release artifacts, read files from paths you supply, or need
packages listed under `Suggests`. Chunks that run with the installed package
alone and no network are evaluated.
## Part 1: Use OPCC with your data
### Install
Requires R >= 4.1.
```{r, eval = FALSE}
install.packages("OPCC")
```
### Key concepts
OPCC links postal codes to two Statistics Canada census geographies:
- **DB (Dissemination Block):** the smallest census unit, typically a city
block or rural area. Identified by `DBUID`.
- **DA (Dissemination Area):** a group of DBs, roughly 400-700 persons.
Identified by `DAUID`. Every DB belongs to exactly one DA.
Each postal code can map to multiple geographies. OPCC retains all defensible
links with an `allocation_weight` (the share of observed address evidence for
that link). The `best_link` flag marks the single strongest link per postal
code. Unmatched postal codes are reported, never fabricated.
### Normalize postal codes
All OPCC functions normalize internally, but you can normalize explicitly:
```{r}
library(OPCC)
normalize_postal_code(c("m5v3a8", "K1A-0A6", "k1a0a6"))
```
### Look up a single postal code
The first call downloads and caches the artifact (~15 MB). Subsequent calls
use the local cache.
```{r, eval = FALSE}
# Best dissemination area (DA) link
pc_to_geo("M5V 3A8", level = "DA", all_links = FALSE)
# Every defensible DA link with allocation weights
pc_to_geo("M5V 3A8", level = "DA")
# Best dissemination block (DB) link
pc_to_geo("M5V 3A8", level = "DB", all_links = FALSE)
# Every DB link
pc_to_geo("M5V 3A8", level = "DB")
```
Unmatched postal codes are not silently dropped. They appear in the `unmatched`
attribute of the result:
```{r, eval = FALSE}
result <- pc_to_geo(c("M5V 3A8", "ZZZ 9Z9"), level = "DA")
attr(result, "unmatched")
#> [1] "ZZZ 9Z9"
```
### Join OPCC geographies to your own data
The most common workflow: you have a data frame with a postal-code column and
you want census geography identifiers. `get_correspondence()` and
`get_da_correspondence()` download a release artifact, so the joins below are
not evaluated here.
```{r, eval = FALSE}
library(OPCC)
my_data <- data.frame(
id = 1:5,
postal_code = c("M5V 3A8", "K1A 0A6", "N6A 1B1", "P7A 1A1", "ZZZ 9Z9"),
value = c(10, 20, 30, 40, 50),
stringsAsFactors = FALSE
)
# --- DA join (one best DA per postal code) ---
da <- get_da_correspondence(vintage = "2026-07-20")
da_best <- da[da$best_link, c("postal_code", "DAUID", "allocation_weight")]
merged_da <- merge(
my_data,
da_best,
by = "postal_code",
all.x = TRUE
)
# Rows with unmatched postal codes keep NA for DAUID and allocation_weight.
# --- DB join (one best DB per postal code) ---
db <- get_correspondence(vintage = "2026-07-19-geonames-amendment")
db_best <- db[db$best_link, c("postal_code", "DBUID", "DAUID", "allocation_weight")]
merged_db <- merge(
my_data,
db_best,
by = "postal_code",
all.x = TRUE
)
# --- Keep all many-to-many links ---
# Skip the best_link filter to retain every candidate geography and its
# allocation weight. Your join will produce multiple rows per postal code.
merged_all <- merge(my_data, da, by = "postal_code", all.x = TRUE)
```
**Columns returned by the join:**
| Column | Meaning |
|---|---|
| `postal_code` | Normalized `A1A 1A1` postal code |
| `DBUID` | 2021 Dissemination Block identifier (DB artifact) |
| `DAUID` | 2021 Dissemination Area identifier (both artifacts) |
| `allocation_weight` | Share of observed address evidence for this link; sums to 1 per postal code |
| `best_link` | `TRUE` for the single strongest link per postal code |
| `n_contributing_dbs` | Number of DBs contributing to a DA link (DA artifact) |
| `contributing_dbuids` | Pipe-delimited DBUID list behind a DA link (DA artifact) |
| `source_vintages` | Source evidence vintage(s) behind the link |
With `dplyr`, which is a `Suggests` package and so is also not evaluated here:
```{r, eval = FALSE}
library(dplyr)
da <- get_da_correspondence(vintage = "2026-07-20")
my_data |>
mutate(postal_code = normalize_postal_code(postal_code)) |>
left_join(
da |> filter(best_link) |> select(postal_code, DAUID, allocation_weight),
by = "postal_code"
)
```
### Download and cache artifacts
`get_correspondence()` and `get_da_correspondence()` download the versioned
artifact, verify its SHA-256 checksum, cache it locally, and return a data
frame. Subsequent calls use the cache.
By default, artifacts are cached in a session temporary directory, so OPCC
never writes to your home filespace unless you ask it to. To keep artifacts
across sessions, pass an explicit `cache_dir`, set the `OPCC.cache_dir` option
or the `OPCC_CACHE_DIR` environment variable, or accept the one-time prompt
that an interactive session offers. See `?clear_opcc_cache` for the full
resolution order.
Listing vintages reads an index shipped with the package, so it runs here:
```{r}
list_vintages(level = "DB")
list_vintages(level = "DA")
```
Downloading a release does need the network, so the rest is shown but not run:
```{r, eval = FALSE}
# Download using the default session cache
db <- get_correspondence(vintage = "2026-07-19-geonames-amendment")
da <- get_da_correspondence(vintage = "2026-07-20")
# Or use an explicit cache directory
cache <- file.path(tempdir(), "opcc-cache")
db <- get_correspondence(
vintage = "2026-07-19-geonames-amendment",
cache_dir = cache
)
# Pass a pre-loaded artifact to skip re-reading
pc_to_geo("M5V 3A8", level = "DA", correspondence = da)
pc_to_geo("M5V 3A8", level = "DB", correspondence = db)
```
### Verify a release
```{r, eval = FALSE}
# Download, checksum, and validate schema and invariants
validate_release(vintage = "2026-07-19-geonames-amendment", level = "DB",
cache_dir = cache)
validate_release(vintage = "2026-07-20", level = "DA", cache_dir = cache)
# Inspect provenance metadata
release_manifest(vintage = "2026-07-20", level = "DA", cache_dir = cache)
```
### Offline and air-gapped use
After caching a verified release once, use `offline = TRUE` to require the
local cache and block network access:
```{r, eval = FALSE}
validate_release(vintage = "2026-07-20", level = "DA",
cache_dir = cache, offline = TRUE)
da <- get_da_correspondence(vintage = "2026-07-20",
cache_dir = cache, offline = TRUE)
```
You can also supply a local artifact file directly to `pc_to_point()`:
```{r, eval = FALSE}
pts <- pc_to_point("K0A 0A1",
point_file = "/path/to/opcc_m1_geonames_points.csv.gz")
```
### Source-qualified point lookups
`pc_to_point()` returns supplementary GeoNames point observations. These are
source-labeled and separate from NAR address evidence. It downloads its own
point artifact, so the chunk is not evaluated:
```{r, eval = FALSE}
pc_to_point("K0A 0A1")
pc_to_point("K0A 0A1", source = "geonames")
```
### Import your own open evidence locally
Use this only for redistributable, open data. Local layers stay separate from
canonical OPCC releases. The API rejects Canada Post, PCCF, and PCCF+ inputs.
The paths below are placeholders for your own files, so the chunk is not
evaluated.
```{r, eval = FALSE}
my_source <- utils::read.csv("/path/to/municipal-postal-data.csv",
stringsAsFactors = FALSE)
```
Describing the source is pure metadata, so this part runs. A real `checksum`
comes from your own file; a literal stands in for it here:
```{r}
adapter <- new_source_adapter(
source_id = "municipal_registry",
licence = "Open Government Licence - Municipality",
lineage = "Municipal open address registry",
retrieval_date = "2026-07-20",
schema_map = list(
postal_code = "postal",
latitude = "lat",
longitude = "lon"
),
checksum = strrep("0", 64L)
)
adapter
```
Building the layer, profiling it, and packaging a contribution bundle all need
your own data, so they are shown but not run:
```{r, eval = FALSE}
layer <- build_source_layer(my_source, adapter, on_invalid = "quarantine")
profile_source_layer(layer)
# `output_dir` must be an explicit path you choose; nothing is written
# to the working directory by default.
bundle <- contribution_bundle(layer,
output_dir = file.path(tempdir(), "contributions"),
fixture_rows = 100L)
contribution_issue_url(bundle)
```
## Part 2: Reproduce and verify artifacts
Every published artifact can be downloaded, verified, and reproduced using
package functions alone. No source checkout is needed.
### Verify published artifacts
Download each artifact, check its SHA-256 against the release index, and
validate schema and correspondence invariants (unique keys, weight sums,
one best link per postal code):
```{r, eval = FALSE}
validate_release(vintage = "2026-07-19-geonames-amendment", level = "DB")
validate_release(vintage = "2026-07-20", level = "DA")
```
Inspect the full provenance metadata (source checksums, code version, build
timestamp, row counts):
```{r, eval = FALSE}
release_manifest(vintage = "2026-07-19-geonames-amendment", level = "DB")
release_manifest(vintage = "2026-07-20", level = "DA")
```
### Reproduce the DA artifact from the DB artifact
The DA correspondence (M5) is a deterministic attribute roll-up of the DB
correspondence (M2). `aggregate_da_correspondence()` performs the same
computation as the M5 build script: it sums DB allocation weights within
each DA, preserves contributing-DB lineage, and selects the best DA link.
The published DA artifacts were built from the NAR-only M2 baseline
(`2026-06-26`), so use that vintage to reproduce them exactly. Both lookups
download a release artifact, so the chunk is not evaluated:
```{r, eval = FALSE}
db <- get_correspondence(vintage = "2026-06-26")
da_reproduced <- aggregate_da_correspondence(db)
# Compare with the published DA artifact
da_published <- get_da_correspondence(vintage = "2026-07-20")
stopifnot(identical(nrow(da_reproduced), nrow(da_published)))
stopifnot(identical(
sort(unique(da_reproduced$postal_code)),
sort(unique(da_published$postal_code))
))
```
The GeoNames amendment vintage (`2026-07-19-geonames-amendment`) adds
17,334 supplementary postal codes. Rolling it up with
`aggregate_da_correspondence()` produces a valid superset of the published
DA, but it will not match the published artifact row-for-row.
### Reproduce offline
After caching artifacts once, all verification and reproduction works without
network access:
```{r, eval = FALSE}
cache <- file.path(tempdir(), "opcc-cache")
validate_release(vintage = "2026-06-26", level = "DB",
cache_dir = cache, offline = TRUE)
validate_release(vintage = "2026-07-20", level = "DA",
cache_dir = cache, offline = TRUE)
db <- get_correspondence(vintage = "2026-06-26",
cache_dir = cache, offline = TRUE)
da <- aggregate_da_correspondence(db)
```
### Rebuild all artifacts from raw sources
The full pipeline from raw Statistics Canada inputs to verified artifacts
uses package functions only. No source checkout is needed. Each step is a
separate function call so you can inspect intermediate results.
The build requires `sf` (system libraries GDAL, GEOS, PROJ), `dplyr`, and
`readr`. On Ubuntu: `sudo apt-get install libgdal-dev libgeos-dev
libproj-dev`. On macOS: `brew install gdal geos proj`. On Windows: install
[Rtools](https://cran.r-project.org/bin/windows/Rtools/) and use the `sf`
binary from CRAN.
None of the build steps below are evaluated when this vignette is built: they
download the raw public inputs and need `sf`, `dplyr`, and `readr`. Each step
also consumes the output of the step before it. Raw source files are large, so
all download helpers require an explicit directory that you manage.
```{r, eval = FALSE}
packages <- c("digest", "dplyr", "jsonlite", "readr", "sf")
missing <- packages[!vapply(packages, requireNamespace, logical(1),
quietly = TRUE)]
if (length(missing)) install.packages(missing)
```
**Step 1: Download all public inputs.**
Each download is cached; re-running skips files already present.
```{r, eval = FALSE}
build_cache <- file.path(tempdir(), "opcc-build")
dir.create(build_cache, recursive = TRUE, showWarnings = FALSE)
nar_dir <- download_nar(cache_dir = build_cache)
geonames_txt <- download_geonames(cache_dir = build_cache)
bounds <- download_census_boundaries(cache_dir = build_cache)
gaf_csv <- download_gaf(cache_dir = build_cache)
```
**Step 2: Build postal code centroids (M1).**
Reads NAR address points and GeoNames, computes the mean coordinate per
postal code. NAR centroids take priority; GeoNames fills gaps.
`output_dir` is required: OPCC never picks a destination for large build
products on your behalf.
```{r, eval = FALSE}
centroids_csv <- build_centroids(nar_dir, geonames_txt,
output_dir = file.path(build_cache, "centroids"))
```
**Step 3: Assign centroids to DBs and join GAF (M1).**
Validates Ontario boundary membership, assigns each centroid to one 2021
Dissemination Block via point-in-polygon, and joins the Geographic Attribute
File for DAUID and higher geographies.
```{r, eval = FALSE}
rollup_csv <- build_db_assignment(
centroids_csv, bounds$province, bounds$db, gaf_csv,
output_dir = file.path(build_cache, "rollup")
)
```
**Step 4: Build the DB correspondence (M2).**
Reads raw NAR address points, assigns them to DBs, aggregates evidence per
postal-code/DB pair, and appends GeoNames supplementary links.
```{r, eval = FALSE}
m2_csv <- build_m2(nar_dir, bounds$db, gaf_csv, rollup_csv,
output_dir = file.path(build_cache, "m2"))
```
**Step 5: Reproduce the DA roll-up (M5).**
This is a deterministic attribute roll-up of the M2 artifact. No spatial
join or new input is needed.
```{r, eval = FALSE}
db <- utils::read.csv(m2_csv, stringsAsFactors = FALSE)
da <- aggregate_da_correspondence(db)
```
**Step 6: Verify.**
```{r, eval = FALSE}
stopifnot(!anyDuplicated(db[c("postal_code", "DBUID")]))
weights <- tapply(db$allocation_weight, db$postal_code, sum)
stopifnot(all(abs(weights - 1) < 1e-8))
stopifnot(all(tapply(db$best_link, db$postal_code, sum) == 1L))
```
**Exact byte-level rebuild.**
To reproduce the published artifact bytes exactly, check out the producer
revision recorded in the published manifest rather than the tip of `main`.
See
[docs/m2-reproduction.md](https://github.com/lennon-li/OPCC/blob/main/docs/m2-reproduction.md)
and
[docs/m5-reproduction.md](https://github.com/lennon-li/OPCC/blob/main/docs/m5-reproduction.md)
for the pinned revisions, expected SHA-256 values, and full verification
sequences.