Inferring CRS from Retrieved Spatial Context

Turn retrieved neighbours into a scored coordinate reference system hypothesis using magnitude, precision, provenance and geographic agreement — with a flagged fallback.

Retrieval gives you neighbours; it does not give you an answer. A handful of documents that mention the same place, each with its own coordinates and its own declared frame, have to be turned into one scored hypothesis about the reference system of the mention in hand. This guide sets out the four signals that do that work and how to combine them without manufacturing false confidence, as the scoring stage of retrieval-augmented CRS resolution.

When to Use This Approach

Use inference when a mention arrives with coordinates but no declared frame, and there is a corpus with enough overlap to supply evidence. Do not use it when the frame is declared — validate the declaration instead — and do not use it when the corpus covers a different region from the mention, where retrieved neighbours are noise wearing the clothes of evidence.

Signal What it rules out Strength alone
Coordinate magnitude Frames whose axes cannot hold these numbers Weak — many frames overlap
Decimal precision Degrees against metres Moderate — six decimals implies degrees
Neighbour agreement Frames no nearby document uses Moderate — corpora are biased
Declared provenance Everything but the source’s own frame Strong — when present

None of the four is decisive on its own, which is the whole difficulty. Magnitude narrows a registry of thousands to a few dozen; precision usually separates geographic from projected; agreement and provenance do the rest. A design that leans on any single one produces confident answers exactly where the evidence is weakest.

Four signals narrowing the candidate setMagnitude narrows thousands of frames to dozens, precision separates geographic from projected, neighbour agreement narrows to a handful and provenance selects one, with confidence rising at each stage.magnitudeprecisionagreementprovenancethousands to dozensdegrees or metresdozens to a fewa few to oneConfidence should rise across this chain, never before it
Each stage is a filter, not a vote. Scoring before the candidate set is narrowed produces high numbers early, which is how a magnitude coincidence ends up presented as a determination.

Implementation

The scorer takes a coordinate pair and a set of retrieved neighbours, applies the four signals in order, and returns a ranked list with an explicit rationale for each candidate.

import logging
import math
from collections import Counter
from dataclasses import dataclass
from typing import Sequence

from pyproj import CRS
from pyproj.exceptions import CRSError

log = logging.getLogger("crs_inference")


@dataclass(frozen=True)
class Neighbour:
    epsg: int
    declared: bool          # the source stated this frame, rather than it being assumed
    similarity: float       # retrieval score in [0, 1]


@dataclass(frozen=True)
class Hypothesis:
    epsg: int
    score: float
    rationale: str


def _looks_geographic(x: float, y: float, decimals: int) -> bool:
    """Degrees are small and precise; projected metres are large and blunt."""
    in_range = -180.0 <= x <= 180.0 and -90.0 <= y <= 90.0
    return in_range and decimals >= 4


def _magnitude_ok(epsg: int, x: float, y: float) -> bool:
    """Reject frames whose own axis ranges cannot hold these numbers."""
    try:
        crs = CRS.from_epsg(epsg)
    except CRSError:
        return False
    if crs.is_geographic:
        return -180.0 <= x <= 180.0 and -90.0 <= y <= 90.0
    # Projected frames: reject values no plausible grid uses.
    return abs(x) < 1.0e7 and abs(y) < 1.0e7 and (abs(x) > 1000.0 or abs(y) > 1000.0)


def infer_crs(
    x: float,
    y: float,
    decimals: int,
    neighbours: Sequence[Neighbour],
) -> list[Hypothesis]:
    """Rank candidate frames for an undeclared coordinate pair. Never raises."""
    if not all(math.isfinite(v) for v in (x, y)):
        log.warning("non-finite coordinate (%r, %r) — no hypothesis possible", x, y)
        return []

    geographic = _looks_geographic(x, y, decimals)
    counts = Counter(n.epsg for n in neighbours)
    declared = {n.epsg for n in neighbours if n.declared}
    best_sim = {n.epsg: 0.0 for n in neighbours}
    for n in neighbours:
        best_sim[n.epsg] = max(best_sim[n.epsg], n.similarity)

    out: list[Hypothesis] = []
    for epsg, count in counts.items():
        if not _magnitude_ok(epsg, x, y):
            continue                                   # magnitude gate: hard exclusion
        try:
            crs = CRS.from_epsg(epsg)
        except CRSError:
            continue
        if crs.is_geographic != geographic:
            continue                                   # precision gate: hard exclusion

        agreement = min(0.30, 0.10 * count)            # capped: three neighbours, then no more
        provenance = 0.35 if epsg in declared else 0.0
        similarity = 0.25 * best_sim[epsg]
        score = round(min(1.0, 0.10 + agreement + provenance + similarity), 3)
        reason = (f"{count} neighbour(s), "
                  f"{'declared' if provenance else 'assumed'} frame, "
                  f"{'geographic' if geographic else 'projected'} magnitudes")
        out.append(Hypothesis(epsg, score, reason))

    return sorted(out, key=lambda h: h.score, reverse=True)

The two gates are exclusions rather than penalties, and that asymmetry is deliberate. A frame whose axes cannot hold the numbers is not unlikely, it is impossible, and letting a strong provenance score outweigh an impossibility is how a pipeline produces answers that no amount of downstream validation can rescue.

The floor of 0.10 on the score means even the best-supported hypothesis tops out below the fallback threshold used in the parent topic unless it has both provenance and agreement. That is the intended behaviour: inference from context should rarely reach high confidence on its own, and a design where it usually does is one that has stopped distinguishing evidence from coincidence.

Decimal precision as a separator between degrees and metresCoordinate pairs grouped by magnitude and decimal count. Small values with many decimals indicate degrees; large values with few decimals indicate projected metres; small values with no decimals are genuinely ambiguous.-3.192834, 55.946325612, 673481412, 388six decimals, in rangegeographic degreessix figures, no decimalsprojected metressmall and bluntgenuinely ambiguousThe third case is the one to flag rather than resolve
Precision is evidence, not proof. An exporter that rounds degrees to two decimals produces exactly the ambiguous third case, which is why the gate keys on range and precision together rather than on either alone.

Validation & Testing

def test_impossible_frame_is_excluded_not_ranked_low():
    # Geographic-looking coordinates cannot belong to a projected grid.
    hyps = infer_crs(-3.19, 55.95, 6, [Neighbour(27700, True, 0.95)])
    assert all(h.epsg != 27700 for h in hyps)


def test_declared_provenance_outranks_bare_agreement():
    ns = [Neighbour(4326, True, 0.5), Neighbour(4258, False, 0.5), Neighbour(4258, False, 0.5)]
    top = infer_crs(-3.19, 55.95, 6, ns)[0]
    assert top.epsg == 4326


def test_no_neighbours_yields_no_hypothesis():
    assert infer_crs(-3.19, 55.95, 6, []) == []


def test_non_finite_input_returns_empty_not_raises():
    assert infer_crs(float("nan"), 55.95, 6, [Neighbour(4326, True, 1.0)]) == []

The second test encodes the ranking policy explicitly, which matters because it is the assertion that fails when someone raises the agreement cap to “make the scores more decisive”. The third is equally important and easy to omit: a scorer that returns a hypothesis with no evidence at all is worse than one that returns nothing, because the caller cannot tell the two apart from the shape of the result.

Gotchas & Edge Cases

Corpus bias masquerading as agreement. If nine tenths of the corpus uses one frame, neighbour agreement will favour it for every mention, including the ones that belong to something else. Cap the agreement contribution, as the code does, and consider normalising by the frame’s overall prevalence in the corpus so a common frame has to earn its lead.

Corpus bias inflating neighbour agreementA corpus dominated by one frame supplies most neighbours in that frame regardless of the mention, so raw agreement counts favour it everywhere until the contribution is capped and normalised by prevalence.Neighbours returned for a mention that genuinely belongs to the rarer frameeight neighbours in the corpus-dominant frametwo correctraw count says: dominantcapped count says: unresolvedCap the agreement term, then normalise by how common the frame is overall
Agreement measures the corpus, not the mention. Capping the contribution keeps a dominant frame from winning by weight of numbers alone, which is the failure that makes inference look reliable in testing and behave badly in production.

Northing and easting confusion. A pair swapped at export looks like a plausible position in the same frame, and no signal here detects it. The check that does is geographic: transform to geographic coordinates and ask whether the result falls in the region the document is about. This is the same class of error covered in axis-order handling for declared frames.

Precision inflation from reprojection. A projected coordinate reprojected to degrees acquires many decimals that carry no real accuracy, so a document full of six-decimal degrees may be a reprojection of a metre-precision survey. The signal remains useful for typing the frame and should not be read as evidence about the data’s accuracy.

Frames that are equivalent in practice. Several codes describe datums that differ by centimetres, and choosing between them from retrieved context is not possible. Group such codes and return the group with a note, rather than picking one and implying a distinction the evidence cannot support.

Neighbours retrieved from the mention’s own document. Self-agreement is not evidence. Exclude neighbours that share a source identifier with the mention before counting, or a single document with a repeated frame will reliably reach the agreement cap on its own.

Frequently Asked Questions

How many neighbours should be retrieved?

Enough to see disagreement, which in practice means eight to twelve. Fewer than five and a single unusual source dominates; more than about fifteen and you are mostly adding weakly related documents that inflate the agreement count without adding information. Since the agreement contribution is capped anyway, the marginal value of the tenth neighbour is close to zero.

Can a model be asked to do this inference directly?

It can be asked and it will answer, which is the problem. Frame inference is exactly the task where a plausible-sounding answer is indistinguishable from a correct one without the deterministic checks above, and the model has no way to run them. Use it, if at all, to rank a shortlist the gates have already produced — never to generate the candidates.

What should the caller do with two hypotheses of equal score?

Treat it as an unresolved case and fall back with a flag, recording both. A tie means the evidence does not separate them, and picking the first is arbitrary in a way that will be wrong roughly half the time. If the two are members of an equivalent-datum group, say so in the note — that is a different situation from two genuinely different candidate frames.

Does the scoring need to be recomputed when the corpus changes?

Yes, and this is an argument for caching resolutions with a corpus version rather than indefinitely. Neighbour agreement is a property of the corpus at a moment in time; ingesting a large new source can change which frame dominates a region and therefore change what the same mention would resolve to. Recording the corpus version alongside the resolution makes that change visible instead of confusing.