oktmoR: follow codes, identities and dates
Aleksandr Kazakov
- What the package does
- Keep codes as character strings
- Watch a target date change the answer
- A reused code needs a source date
- Overlapping target histories are a separate ambiguity
- Diagnose queries before joining the results
- Coordinate lookup: unique, overlapping and absent hits
- Bring your own defensible registry
- R help and reproducibility
A code is not a permanent identity. oktmoR follows a code through a user-supplied history to the identity behind it, then returns the code valid on your target date. It retains uncertain candidates instead of silently selecting a first match.
What the package does
OKTMO is the Russian classification of municipal territories. Code
changes complicate joins between observations collected at different
dates. oktmoR provides character-safe normalization,
inclusive validity-date lookup, explicit ambiguity diagnostics and
optional point-to-polygon lookup.
The package does not supply an official historical registry or infer mergers, splits and boundary comparability. The examples are fabricated. A trustworthy stable identity and date coverage must come from your data and research design.
Keep codes as character strings
A five-digit municipal representation or eight-digit subdivision representation is padded on the right to eleven digits. Leading zeros remain meaningful. Do not convert codes to numbers before importing them: a lost leading zero cannot be reconstructed safely.
codes <- c("01601", "01601402", "01601402101")
data.frame(input = codes, digits = nchar(codes),
normalized = normalize_oktmo(codes), level = oktmo_level(codes))
#> input digits normalized level
#> 1 01601 5 01601000000 5
#> 2 01601402 8 01601402000 8
#> 3 01601402101 11 01601402101 11Normalization checks supported syntax, not official
classifier membership. For dirty input,
normalize_oktmo(x, invalid = "NA") returns missing values
for invalid strings; the default instead raises an error.
oktmo_harmonize() records invalid query strings
diagnostically.
Watch a target date change the answer
A stable guid links an entity’s historical codes. These
three example identities change codes on 2020-01-01 while retaining
their assigned identity. Move the date selector to see the actual code
returned by the package. All plots and tables are computed during the R
build and embedded for offline viewing.
Target: 2016-01-01
| code_in | guid | code_out | status |
|---|---|---|---|
| 01601000000 | municipality-A | 01601000000 | matched |
| 01601402000 | subdivision-A | 01601402000 | matched |
| 01601402101 | settlement-A | 01601402101 | matched |
Reproduce in R
oktmo_harmonize(c("01601", "01601402", "01601402101"),
"2016-01-01", registry)
Target: 2019-12-31
| code_in | guid | code_out | status |
|---|---|---|---|
| 01601000000 | municipality-A | 01601000000 | matched |
| 01601402000 | subdivision-A | 01601402000 | matched |
| 01601402101 | settlement-A | 01601402101 | matched |
Reproduce in R
oktmo_harmonize(c("01601", "01601402", "01601402101"),
"2019-12-31", registry)
Target: 2020-01-01
| code_in | guid | code_out | status |
|---|---|---|---|
| 01601000000 | municipality-A | 01701000000 | matched |
| 01601402000 | subdivision-A | 01701402000 | matched |
| 01601402101 | settlement-A | 01701402101 | matched |
Reproduce in R
oktmo_harmonize(c("01601", "01601402", "01601402101"),
"2020-01-01", registry)
Target: 2023-01-01
| code_in | guid | code_out | status |
|---|---|---|---|
| 01601000000 | municipality-A | 01701000000 | matched |
| 01601402000 | subdivision-A | 01701402000 | matched |
| 01601402101 | settlement-A | 01701402101 | matched |
Reproduce in R
oktmo_harmonize(c("01601", "01601402", "01601402101"),
"2023-01-01", registry)
Validity intervals include both endpoints. The old
municipality code is valid through 2019-12-31; the new one starts on
2020-01-01. NA in odate_end means open-ended,
not an unknown start date. A historical input code can be mapped to a
later code without being valid on the target date itself: identity links
the two dates.
A reused code needs a source date
The fictional code 99001 belongs to
old-identity until 2017 and to
new-identity in 2018–2019. At the 2023 target date
those identities have different codes. The input string alone cannot
choose the identity; its observation date can.
No source date · two possible identities
| date_from | guid | code_out | status |
|---|---|---|---|
| NA | new-identity | 99003000000 | ambiguous_identity |
| NA | old-identity | 99002000000 | ambiguous_identity |
oktmo_harmonize("99001", "2023-01-01", registry)
Source 2015 · old identity
| date_from | guid | code_out | status |
|---|---|---|---|
| 2015-01-01 | old-identity | 99002000000 | matched |
oktmo_harmonize("99001", "2023-01-01", registry,
date_from = "2015-01-01")
Source 2019 · new identity
| date_from | guid | code_out | status |
|---|---|---|---|
| 2019-01-01 | new-identity | 99003000000 | matched |
oktmo_harmonize("99001", "2023-01-01", registry,
date_from = "2019-01-01")
Without a source date, the diagnostic function returns both
candidates with ambiguous_identity. A source date
outside every interval instead returns no_source_interval;
it does not choose a nearby interval. An explicitly missing source date
in a supplied date vector is an invalid_query, not a
request to ignore that date for the row.
Overlapping target histories are a separate ambiguity
Even when the source identity is known, two codes can be valid at the
target date. The example overlap identity has
99004 from 2014 onwards and 99005 from 2020
onwards. That is retained as ambiguous_target, not resolved
by row order.
oktmo_harmonize("99004", "2023-01-01", registry, date_from = "2015-01-01")
#> query_id code_in date_from date_tar guid code_out
#> 1 1 99004000000 2015-01-01 2023-01-01 overlap 99004000000
#> 2 1 99004000000 2015-01-01 2023-01-01 overlap 99005000000
#> status
#> 1 ambiguous_target
#> 2 ambiguous_targetRepeated rows that lead to the same target code do not themselves create multiple-code ambiguity. Different identities remain distinct even if their output code happens to be identical.
Diagnose queries before joining the results
One input query can produce multiple output rows. Retain
query_id when joining diagnostics back to your input and
inspect the status before using code_out. The example audit
below deliberately exercises all seven statuses. It runs separate calls
to demonstrate both absent and supplied source dates, then assigns an
input ID to each query.
queries <- list(
list(codes = "01601", date_tar = "2023-01-01", date_from = "2015-01-01"),
list(codes = "bad", date_tar = "2023-01-01"),
list(codes = "99999", date_tar = "2023-01-01"),
list(codes = "01601", date_tar = "2023-01-01", date_from = "2021-01-01"),
list(codes = "99001", date_tar = "2023-01-01"),
list(codes = "01601", date_tar = "2010-01-01"),
list(codes = "99004", date_tar = "2023-01-01", date_from = "2015-01-01")
)
audit <- do.call(rbind, lapply(seq_along(queries), function(i) {
out <- do.call(oktmo_harmonize, c(queries[[i]], list(history = registry)))
out$query_id <- i
out
}))
audit[, c("query_id", "code_in", "date_from", "date_tar", "code_out", "status")]
#> query_id code_in date_from date_tar code_out status
#> 1 1 01601000000 2015-01-01 2023-01-01 01701000000 matched
#> 2 2 <NA> <NA> 2023-01-01 <NA> invalid_query
#> 3 3 99999000000 <NA> 2023-01-01 <NA> unknown_code
#> 4 4 01601000000 2021-01-01 2023-01-01 <NA> no_source_interval
#> 5 5 99001000000 <NA> 2023-01-01 99003000000 ambiguous_identity
#> 6 5 99001000000 <NA> 2023-01-01 99002000000 ambiguous_identity
#> 7 6 01601000000 <NA> 2010-01-01 <NA> no_target_interval
#> 8 7 99004000000 2015-01-01 2023-01-01 99004000000 ambiguous_target
#> 9 7 99004000000 2015-01-01 2023-01-01 99005000000 ambiguous_target| Status | Meaning | Useful next step |
|---|---|---|
matched |
One identified entity and one target code. | Join to the input and retain date/identity provenance. |
invalid_query |
Unsupported code syntax or a missing query date. | Repair the input; normalization does not recover lost zeros. |
unknown_code |
A syntactically valid code absent from this registry. | Check spelling, supported level and registry coverage. |
no_source_interval |
Code exists, but not on the supplied source date. | Verify the source observation date and interval coverage. |
ambiguous_identity |
Multiple identities match the source code. | Supply a credible source date or examine the identity history. |
no_target_interval |
The resolved identity has no interval at the target date. | Obtain suitable coverage; the function does not extrapolate. |
ambiguous_target |
More than one distinct code is valid for the identity at the target date. | Examine overlaps and retain competing candidates until justified. |
When you need exactly one output per input,
oktmo_hist_get() preserves vector order and length.
Unmatched or ambiguous inputs yield NA;
ambiguity = "error" stops on ambiguity. NA
alone cannot tell you why a match failed, so use the diagnostic API when
auditing a merge.
Coordinate lookup: unique, overlapping and absent hits
The optional sf integration transforms
longitude/latitude points into the map’s CRS and returns one code per
point. These two overlapping rectangles are fabricated geometries.
Interior overlap illustrates an ambiguous hit; an outside point has no
hit. Both return NA by default, so keep spatial QA separate
from historical lookup diagnostics.
if (requireNamespace("sf", quietly = TRUE)) {
rectangle <- function(x0, x1) sf::st_polygon(list(rbind(
c(x0, 0), c(x1, 0), c(x1, 2), c(x0, 2), c(x0, 0))))
map <- sf::st_sf(oktmo = c("99001", "99002"), geometry = sf::st_sfc(
rectangle(0, 2), rectangle(1, 3), crs = 4326))
points <- data.frame(lon = c(.5, 1.5, 2.5, 4, NA), lat = c(1, 1, 1, 1, 1),
interpretation = c("A only", "A + B overlap", "B only", "Outside", "Missing longitude"))
points$code <- oktmo_map_get(points$lon, points$lat, map)
print(points)
par(mar = c(4, 4, 3, 1))
plot(c(-.3, 4.5), c(-.2, 2.5), type = "n", asp = 1, xlab = "Longitude", ylab = "Latitude",
main = "Synthetic polygons: inspect overlap before using a code", col.main = navy)
rect(0, 0, 2, 2, col = grDevices::adjustcolor(blue, .25), border = blue, lwd = 2)
rect(1, 0, 3, 2, col = grDevices::adjustcolor(red, .25), border = red, lwd = 2)
text(c(.5, 2.5), 1.8, c("A · 99001", "B · 99002"), col = navy, font = 2, cex = .95)
graphics::points(points$lon[1:4], points$lat[1:4], pch = 21, bg = "white", col = navy, cex = 1.5, lwd = 2)
text(points$lon[1:4], .72, points$interpretation[1:4], cex = .78, col = navy)
text(points$lon[1:4], .47, ifelse(is.na(points$code[1:4]), "NA", points$code[1:4]), cex = .9, font = 2, col = navy)
} else {
cat("Install sf to run the optional coordinate example. Temporal lookup works without sf.\n")
}
#> lon lat interpretation code
#> 1 0.5 1 A only 99001
#> 2 1.5 1 A + B overlap <NA>
#> 3 2.5 1 B only 99002
#> 4 4.0 1 Outside <NA>
#> 5 NA 1 Missing longitude <NA>Use geometry valid for the intended date. A spatial hit does not by
itself establish historical identity or territorial comparability.
Boundary behavior follows the map geometry, predicate and current
sf_use_s2() setting; the function leaves that setting
unchanged. Several intersecting polygons with the same code produce one
code, while several distinct codes are ambiguous. Set
ambiguity = "error" to stop on multiple distinct hits. Map
codes are returned at their supplied width; temporal normalization is a
separate operation.
Bring your own defensible registry
The required columns are guid, oktmo,
odate_start and odate_end. Extra metadata is
preserved. Keep dates as Date or ISO strings and codes as
characters; start dates, codes and identities cannot be missing. Use
oktmo_history() for a data frame or
oktmo_matcher_load(path) for an RDS file supplied by you.
Neither function downloads a registry or assumes a machine-specific file
location.
registry <- oktmo_history(oktmo_example)
head(registry)
#> guid oktmo odate_start odate_end settlement
#> 1 municipality-A 01601000000 2014-01-01 2019-12-31 Example municipality A
#> 2 municipality-A 01701000000 2020-01-01 <NA> Example municipality A
#> 3 new-identity 99001000000 2018-01-01 2019-12-31 Example new identity
#> 4 new-identity 99003000000 2020-01-01 <NA> Example new identity
#> 5 old-identity 99001000000 2014-01-01 2017-12-31 Example old identity
#> 6 old-identity 99002000000 2018-01-01 <NA> Example old identityReversed intervals raise an error by default. Explicitly dropping them records a count and does not invent corrected dates:
with_bad_row <- oktmo_example
with_bad_row$odate_end[1] <- with_bad_row$odate_start[1] - 1
cleaned <- oktmo_history(with_bad_row, invalid_intervals = "drop")
attr(cleaned, "dropped_intervals")
#> [1] 1Before interpreting a longitudinal join, establish the registry’s date coverage, identity lineage and treatment of mergers, splits and boundary changes. Equal IDs and successfully harmonized codes are only as credible as that correspondence. Harmonization alone does not allocate population, income or other aggregates across changed territories. Check the source’s redistribution terms before distributing a registry; no official or private registry is bundled here.
R help and reproducibility
In R, use help(package = "oktmoR"),
?oktmo_harmonize, ?oktmo_map_get, and
?oktmo_example. Open this installed tutorial with
vignette("introduction", package = "oktmoR").
Built with oktmoR 0.1.1 and R Under development (unstable) (2026-10-10 r90659); spatial example uses sf 1.1.3. All code histories and geometries shown here are synthetic.