oktmoR: follow codes, identities and dates

Aleksandr Kazakov

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.

1 · NormalizePreserve leading zeros and expand supported 5-, 8- or 11-digit representations.
2 · IdentifyResolve the input code’s stable identity, using the source observation date when a code was reused.
3 · ResolveFind that identity’s code on the target date. Report absent coverage and competing candidates.

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    11

Five- and eight-digit codes gain trailing zeros; eleven-digit codes stay unchanged and leading zeros are preserved.

Normalization 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.

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.

oktmo_harmonize(rep("01601", 2), c("2019-12-31", "2020-01-01"), registry)
#>   query_id     code_in date_from   date_tar           guid    code_out  status
#> 1        1 01601000000      <NA> 2019-12-31 municipality-A 01601000000 matched
#> 2        2 01601000000      <NA> 2020-01-01 municipality-A 01701000000 matched

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.

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.

Two open-ended code intervals overlap at the 2023 target date for a single stable identity.

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_target

Repeated 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

One query in each of the seven diagnostic statuses; seven input queries yield nine candidate output rows.

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.

oktmo_hist_get(c("01601", "99001", "99999", NA), "2023-01-01", registry)
#> [1] "01701000000" NA            NA            NA

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>

Two synthetic overlapping polygons show unique A and B hits, an ambiguous interior overlap and an outside point.

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 identity

Reversed 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] 1

Before 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.