Some documents declare a frame and still leave you guessing. “UTM zone 31” names a projection and a zone but not a datum, and the codes that match it place the same point up to two hundred metres apart. “OSGB” names a grid whose historic and current realisations differ. This guide handles the case where a declaration exists but underdetermines the code, and decides what the agent may safely claim afterwards — the consequential half of retrieval-augmented CRS resolution.
When to Use This Approach
Use it when a frame is named but not coded, or coded ambiguously, and there is surrounding text to read. It is distinct from inference over retrieved neighbours: here the evidence is in the document itself, which makes it stronger and narrower.
| Declaration found | Ambiguity | Resolve from |
|---|---|---|
| Projection and zone, no datum | Several datums per zone | Survey date, agency, stated accuracy |
| Grid name with historic realisations | Which realisation | Publication era, revision note |
| Code that is deprecated | Successor mapping | Registry successor, plus a note |
| Units named but frame implied | Geographic against projected | Unit words in the same sentence |
The common thread is that the document usually says enough, in prose, to close the gap — and that prose is discarded by every pipeline that extracts the code with a regular expression and moves on.
Implementation
The resolver reads the declaration and a context window around it, applies era and agency hints, and returns either a single code with a rationale or an ambiguity record listing what it could not separate.
import logging
import re
from dataclasses import dataclass
from typing import Optional
from pyproj import CRS
from pyproj.exceptions import CRSError
log = logging.getLogger("epsg_disambiguation")
_YEAR = re.compile(r"\b(19|20)\d{2}\b")
_UNIT_METRIC = re.compile(r"\b(metre|meter|metres|meters|m)\b", re.IGNORECASE)
_UNIT_DEGREE = re.compile(r"\b(degree|degrees|decimal degrees|lat(itude)?)\b", re.IGNORECASE)
@dataclass(frozen=True)
class Resolution:
epsg: Optional[int]
candidates: tuple[int, ...]
confidence: float
rationale: str
def _latest_year(context: str) -> Optional[int]:
years = [int(m.group(0)) for m in _YEAR.finditer(context)]
return max(years) if years else None
def _constructs(epsg: int) -> bool:
try:
CRS.from_epsg(epsg)
return True
except CRSError:
return False
def resolve_ambiguous(
candidates: tuple[int, ...],
context: str,
era_map: dict[int, tuple[int, int]], # epsg -> (valid_from, valid_to)
agency_map: dict[str, int], # agency phrase -> preferred epsg
) -> Resolution:
"""Narrow a candidate set using document context. Never raises; may return None."""
usable = tuple(c for c in candidates if _constructs(c))
if not usable:
return Resolution(None, (), 0.0, "no candidate code constructs")
if len(usable) == 1:
return Resolution(usable[0], usable, 0.8, "only one constructible candidate")
lowered = context.lower()
for phrase, epsg in agency_map.items():
if phrase in lowered and epsg in usable:
return Resolution(epsg, usable, 0.9, f"agency phrase {phrase!r} in context")
year = _latest_year(context)
if year is not None:
in_era = [c for c in usable
if c in era_map and era_map[c][0] <= year <= era_map[c][1]]
if len(in_era) == 1:
return Resolution(in_era[0], usable, 0.75, f"era {year} matches one realisation")
if in_era:
usable = tuple(in_era) # narrowed but not settled
metric = bool(_UNIT_METRIC.search(context))
degrees = bool(_UNIT_DEGREE.search(context))
if metric != degrees: # exactly one unit family mentioned
want_projected = metric
filtered = tuple(c for c in usable
if CRS.from_epsg(c).is_geographic != want_projected)
if len(filtered) == 1:
return Resolution(filtered[0], usable, 0.7, "unit words select one candidate")
if filtered:
usable = filtered
log.info("ambiguous frame unresolved among %s", usable)
return Resolution(None, usable, 0.0,
f"context did not separate {len(usable)} candidates")
The ordering of the three hints is not arbitrary. Agency is strongest because an organisation’s standard frame is a documented fact rather than an inference. Era is next because datum realisations have published validity periods. Units are weakest because a document can name metres while storing degrees, and the check only fires when exactly one unit family appears — a document mentioning both tells you nothing.
Returning None with a populated candidate list is the important shape. It lets the caller say “this is one of these three, and they differ by up to two hundred metres” rather than choosing arbitrarily, which is the honest answer and, for most downstream questions, a usable one.
Validation & Testing
ERA = {4277: (1936, 2001), 27700: (1936, 2100), 4258: (1989, 2100)}
AGENCY = {"national mapping agency": 27700}
def test_agency_phrase_settles_the_choice():
r = resolve_ambiguous((4277, 27700), "surveyed by the national mapping agency", ERA, AGENCY)
assert r.epsg == 27700 and r.confidence >= 0.9
def test_unresolvable_returns_candidates_not_a_guess():
r = resolve_ambiguous((4277, 27700), "no useful context here", ERA, AGENCY)
assert r.epsg is None and set(r.candidates) == {4277, 27700}
def test_nonconstructible_candidates_are_dropped():
r = resolve_ambiguous((999999, 27700), "context", ERA, AGENCY)
assert r.epsg == 27700 and r.rationale.startswith("only one")
The second test is the one that protects the design. Every future change that makes the resolver “more decisive” will fail it, which is exactly when a human should look at whether the new decisiveness is earned.
The third test guards a subtler property: a candidate list containing a code that no longer exists in the registry should shrink rather than fail. Registries do change, and a pipeline that raises on an unknown code will stop resolving every document that mentions it, including the ones where a perfectly good alternative candidate was available all along.
Run all three against the real era and agency maps rather than fixtures of them. These maps are configuration that drifts — an agency changes its standard, a datum realisation is superseded — and a test suite that uses its own copies will keep passing after the production configuration has stopped being correct.
Gotchas & Edge Cases
A year in the context that is not the survey year. Documents cite regulations, reference earlier reports, and carry publication dates unrelated to the data. Taking the maximum year is a heuristic that fails on a 2024 report about a 1975 survey; prefer a year found in the same sentence as the declaration, and fall back to the maximum only when none is nearby.
Deprecated codes with more than one successor. The registry does not always offer a single replacement, and choosing among successors is the same ambiguity one level down. Return the successor set rather than the first entry, and let the same narrowing logic run over it.
Agency phrases that appear in a bibliography. A citation to another organisation’s report is not evidence about this document’s frame. Restrict the agency search to a window around the declaration rather than the whole document, and prefer phrases in a methods or metadata section.
Confidence that survives a change of evidence. Once a resolution is cached, a later correction to the document does not invalidate it unless the cache key includes a document version. Key on the document’s content hash so an edited document resolves afresh.
Codes that differ only in axis order. Some pairs describe the same datum with swapped axes, and no amount of context resolves which one an exporter used. Detect that case explicitly and treat it as an axis-order question rather than a frame question — a different check with a different fix.
Frequently Asked Questions
How large should the context window around the declaration be?
A few hundred characters either side, or the enclosing section if the document has structure. Wider windows pull in unrelated years and agency names and make the heuristics noisier, which shows up as confident wrong answers rather than as failures. If a document has a metadata block, prefer it over proximity — a declared frame in a structured header with its own date is stronger evidence than any prose within reach.
What positional uncertainty should a narrowed result report?
The maximum displacement between the candidate frames over the document's extent, computed rather than assumed. Two realisations of the same datum can differ by centimetres in one region and by hundreds of metres in another, so a fixed figure is misleading in both directions. Transform a corner of the extent through each candidate and take the largest separation.
Should the agent mention the ambiguity in its answer?
Whenever the answer is a measurement and the ambiguity exceeds the precision being reported. Saying "roughly 400 metres, though the source's datum is ambiguous by up to 200 metres" is honest and actionable; saying "412 metres" from a narrowed resolution is a false precision that the pipeline knew about and suppressed. For descriptive answers, the ambiguity usually does not need surfacing.
Can the era and agency maps be learned from the corpus?
Partly, and it is worth doing for agency preferences, which are stable and observable — an organisation's documents consistently declare the same frame. Era validity should come from the registry rather than from data, since the corpus will contain documents that used a datum outside its official period and learning from them encodes the error.
Related
- Up to the parent topic: Retrieval-Augmented CRS Resolution
- Inferring CRS from Retrieved Spatial Context
- Technique: Choosing a Canonical CRS for Spatial LLM Pipelines
- Concept: Fallback Routing for Geospatial Queries