Normalizing Milepost & Cross-Street Locations for Rural Incidents

A rollover is reported at 02:14 on a two-lane state highway forty miles from the nearest town. The caller cannot see a house, a business, or an intersection sign — only a green marker that reads “MM 128.” The next caller, on a county road, gives no address at all: “the corner of Reservoir Road and Old Mill.” Neither location will geocode. A commercial address geocoder returns nothing for “MM 128” and drops a low-confidence guess in the wrong county for “Old Mill,” and the responding engine now has a dashboard point that is either empty or plausibly wrong by miles. This page solves that one narrow problem: converting a rural location expressed as a highway milepost or a cross-street pair into a single, defensible incident point — with a confidence score and an audit record — instead of a blank map or a confident lie.

Root Cause and Operational Impact

The failure is a mismatch between how rural locations are reported and how geocoders work. An address geocoder is a range-interpolation engine: it expects a house number, a street name, and a postal range it can interpolate along. A highway milepost carries none of that. It is a linear-referencing measure — a distance along a named route, calibrated to physical mile markers — and the only geometry that can resolve it is the route centerline itself, walked to the reported distance. A cross-street is the opposite shape of problem: no distance, but two named lines whose intersection is the point. Feed either one to an address geocoder and you get silence or a false match, because the operation it needs (interpolate-along-line, or intersect-two-lines) is not the operation the geocoder performs (interpolate-within-address-range).

That gap is dangerous, not merely inconvenient, because the derived point drives dispatch. A milepost resolved against an uncalibrated or reversed route can place the incident on the correct highway but ten miles in the wrong direction, sending mutual-aid units away from a trapped occupant while the clock runs. A cross-street matched to the wrong “Old Mill Road” in an adjacent county crosses a jurisdiction boundary and hands the call to responders with no authority there. Because the National Incident Management System (NIMS) and the Federal Emergency Management Agency (FEMA) both require incident locations to be reconstructable for after-action review, a point that silently invents coordinates from a bad route match is not defensible. The normalization has to be auditable — every derived coordinate must carry the raw text it came from, the method used, and a confidence — which is why this belongs inside the disciplined real-time geocoding and location normalization path and not in an ad-hoc dispatch macro.

Milepost interpolation and cross-street intersection converging on a scored, audited incident point The left path takes a highway milepost report, looks up the calibrated route centerline whose measures run from mile 120 to mile 135, interpolates the reported mile 128 as a distance along that line, and produces a candidate point. The right path takes a cross-street pair, selects two named road centerlines, computes their geometric intersection, and picks the intersection node nearest a jurisdiction hint when more than one exists. Both candidate points feed a single confidence-scoring and audit-emission stage that stamps the raw text, method, and coordinate before the point is published to the common operating picture. Milepost → interpolate along route "SH-9 MM 128" raw 911 text MM 120 MM 135 MM 128 · interpolated measure calibration → distance along line Cross-street → intersect centerlines "Reservoir Rd & Old Mill" raw 911 text Reservoir Rd Old Mill Rd nearest intersection select node nearest jurisdiction hint Score confidence + emit audit raw text · method · coordinate · confidence Common operating picture

Tiered Resolution Strategy

Resolve the location in ordered tiers, from the most authoritative method down to a safe default that is always flagged for review. Never publish an unscored point, and never silently drop a call — an unresolved location still has to reach a human dispatcher.

  1. Linear-reference a milepost against calibrated route geometry (definitive). When the text names a route and a marker value, look up the route’s calibrated measure, convert the milepost to a distance along the line, and interpolate the point. This is exact where the route measures are surveyed and continuous.
  2. Intersect two named centerlines for a cross-street (definitive). When the text names two roads, select both centerlines and take their geometric intersection. A clean single intersection is a high-confidence point.
  3. Disambiguate multiple candidates with a spatial hint (qualified). When a route matches several segments, or a cross-street yields more than one intersection, rank candidates by distance to a hint — the responding jurisdiction centroid or the originating cell sector — take the nearest, and lower the confidence accordingly.
  4. Snap to the route or nearest addressed feature (hardening). Where a milepost falls near a gap in calibration, snap the interpolated point onto the route line so it never floats off the road, and cross-check against the nearest known addressed feature.
  5. Hold as unresolved with a low-confidence default and audit flag (safe default). If no route matches or no intersection exists, return the jurisdiction centroid at minimum confidence, mark the record unresolved, and emit an audit entry so a dispatcher confirms before dispatch — never a fabricated precise coordinate.

Rural location references are not addresses with pieces missing — they are a different addressing system, and the shapes they arrive in are surprisingly few.

Four shapes of rural location reference and how each resolves to a coordinate Four kinds of location text that arrive from rural dispatch. A milepost reference such as US-550 MP 42.7 resolves by linear referencing along the route geometry and is accurate to roughly the marker spacing. A cross-street reference such as CR-14 at CR-9 resolves to the intersection node of the two centrelines and is accurate to the intersection itself. A distance-and-bearing reference such as 2.4 miles north of Cuba on US-84 resolves by measuring along the route from a named anchor. A landmark reference such as the Ranger Station at Gallina resolves only through a curated gazetteer of named places, and fails entirely if no gazetteer entry exists. The first three are computable from data an agency already holds; the fourth is a data-collection problem disguised as a parsing problem. four shapes, three of them computable from route geometry you already have US-550 MP 42.7 linear reference along the route geometry · accurate to roughly the marker spacing needs: routed centrelines with measures CR-14 at CR-9 intersection node of the two centrelines · accurate to the intersection itself needs: a noded road graph and consistent route naming 2.4 mi N of Cuba on US-84 measured along the route from a named anchor · accurate to the anchor plus the measure needs: place anchors on the route, and a direction convention Ranger Station at Gallina resolvable only through a curated gazetteer — no geometry can infer it a data-collection problem wearing a parsing problem's clothes

Separating the four is what makes the parser tractable, because each has a distinct grammar and a distinct data dependency. Mileposts need routed centrelines carrying measures; cross-streets need a properly noded graph and consistent route naming between the dispatch system and the road layer; distance-and-bearing needs anchors and a direction convention. All three are things a state or county DOT already maintains, and all three fail in ways a validator can describe precisely — “route US-550 has no measure at 42.7” is an actionable message.

The fourth is categorically different and should be treated as such rather than parsed harder. No amount of string handling turns “the Ranger Station at Gallina” into a coordinate; only a gazetteer entry does. The productive response is to log every unresolved landmark reference with its verbatim text and review the list monthly — rural dispatch reuses the same few dozen landmarks, so a gazetteer built from actual traffic reaches high coverage quickly, whereas one built by imagining what responders might say does not.

The direction convention deserves a specific warning. “North of” in dispatch usage frequently means along the route in the increasing-measure direction rather than true north, and on a road that runs east-west for a stretch the two differ by ninety degrees. Confirm which convention the agency uses before implementing, and record the assumption in the audit block, because a 2.4-mile error in the wrong direction on a rural highway is a very long drive.

Production Python Implementation

The routine below carries the full resolution path: parsing the reported text, milepost interpolation via linear referencing, cross-street intersection with hint-based disambiguation, confidence scoring, structured logging, explicit exception handling, and an immutable audit record for every derived point. Thresholds and the reference layers are injected rather than hard-coded, so the same resolver runs against any jurisdiction’s route and centerline data. Senior-engineer assumptions apply: shapely and geopandas are available, all reference layers are pre-projected to a common metric coordinate reference system, and route measures are stored as an ascending m_start/m_end calibration per segment.

python
from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from enum import Enum
from typing import Optional

import geopandas as gpd
from shapely.geometry import Point
from shapely.ops import nearest_points

logger = logging.getLogger("incidentgis.location_normalize")

# Matches "SH-9 MM 128", "US 50 milepost 128.4", "MP128" and similar.
_MILEPOST_RE = re.compile(
    r"(?P<route>[A-Z]{1,3}[- ]?\d{1,4})\D{0,12}?(?:mm|mp|mile\s?post|milepost)\.?\s*"
    r"(?P<value>\d{1,4}(?:\.\d{1,2})?)",
    re.IGNORECASE,
)
# Matches "Reservoir Rd & Old Mill", "Reservoir and Old Mill Rd".
_CROSS_RE = re.compile(
    r"(?P<a>[\w .'-]+?)\s*(?:&|/|\band\b|\bat\b|\bx\b)\s*(?P<b>[\w .'-]+)",
    re.IGNORECASE,
)


class Method(str, Enum):
    MILEPOST = "milepost_linear_reference"
    CROSS_STREET = "cross_street_intersection"
    UNRESOLVED = "unresolved_default"
    ERROR = "error_safe_default"


@dataclass
class ResolvedLocation:
    lon: Optional[float]
    lat: Optional[float]
    method: str
    confidence: float
    resolved: bool


@dataclass
class AuditEntry:
    """Immutable record of one normalization, appended to the audit trail."""
    raw_text: str
    method: str
    lon: Optional[float]
    lat: Optional[float]
    confidence: float
    candidate_count: int
    reference_version: str
    recorded_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat()
    )


class RuralLocationNormalizer:
    """Resolve milepost / cross-street 911 text to a scored incident point.

    Every resolution — success or fallback — is logged and appended to
    ``audit_log`` so a derived coordinate can be reconstructed against the
    exact reference layers and thresholds that produced it.
    """

    def __init__(
        self,
        routes: gpd.GeoDataFrame,        # calibrated: cols route_id, m_start, m_end, geometry
        centerlines: gpd.GeoDataFrame,   # cols name, geometry
        reference_version: str,
        jurisdiction_centroid: Point,    # fallback + disambiguation hint (same CRS)
        min_confidence: float = 0.2,
    ) -> None:
        self.routes = routes
        self.centerlines = centerlines
        self.reference_version = reference_version
        self.hint = jurisdiction_centroid
        self.min_confidence = min_confidence
        self.audit_log: list[AuditEntry] = []

    def _audit(self, raw: str, loc: ResolvedLocation, candidates: int) -> None:
        entry = AuditEntry(
            raw_text=raw,
            method=loc.method,
            lon=loc.lon,
            lat=loc.lat,
            confidence=loc.confidence,
            candidate_count=candidates,
            reference_version=self.reference_version,
        )
        self.audit_log.append(entry)
        logger.info("location_normalized", extra={"audit": asdict(entry)})

    def _milepost(self, route_id: str, value: float) -> tuple[Optional[Point], float]:
        """Interpolate a point at ``value`` miles along the calibrated route."""
        segs = self.routes[self.routes["route_id"].str.upper() == route_id.upper()]
        if segs.empty:
            return None, 0.0
        seg = segs[(segs["m_start"] <= value) & (segs["m_end"] >= value)]
        if seg.empty:
            return None, 0.0  # milepost outside the route's calibrated range
        row = seg.iloc[0]
        span = row["m_end"] - row["m_start"]
        if span <= 0:
            return None, 0.0
        # Fraction along the segment, then interpolate along its geometry length.
        frac = (value - row["m_start"]) / span
        line = row.geometry
        pt = line.interpolate(frac * line.length)
        # Confidence scales with calibration density: tighter segments interpolate better.
        confidence = 0.95 if span <= 5 else max(0.6, 0.95 - (span - 5) * 0.03)
        return pt, round(confidence, 3)

    def _cross_street(self, name_a: str, name_b: str) -> tuple[Optional[Point], float, int]:
        """Intersect two named centerlines, choosing the node nearest the hint."""
        a = self.centerlines[self.centerlines["name"].str.contains(
            re.escape(name_a.strip()), case=False, na=False)]
        b = self.centerlines[self.centerlines["name"].str.contains(
            re.escape(name_b.strip()), case=False, na=False)]
        if a.empty or b.empty:
            return None, 0.0, 0
        inter = a.union_all().intersection(b.union_all())
        if inter.is_empty:
            return None, 0.0, 0
        # Normalize to a list of candidate points (single Point or multi-geometry).
        pts = [inter] if inter.geom_type == "Point" else list(getattr(inter, "geoms", []))
        pts = [p for p in pts if p.geom_type == "Point"]
        if not pts:
            return None, 0.0, 0
        best = min(pts, key=lambda p: p.distance(self.hint))
        confidence = 0.9 if len(pts) == 1 else 0.65  # ambiguity lowers confidence
        return best, confidence, len(pts)

    def resolve(self, raw_text: str) -> ResolvedLocation:
        try:
            mp = _MILEPOST_RE.search(raw_text)
            if mp:
                pt, conf = self._milepost(mp.group("route"), float(mp.group("value")))
                if pt is not None:
                    loc = ResolvedLocation(pt.x, pt.y, Method.MILEPOST.value, conf, True)
                    self._audit(raw_text, loc, 1)
                    return loc
                logger.warning("milepost_no_route", extra={"raw": raw_text})

            xs = _CROSS_RE.search(raw_text)
            if xs:
                pt, conf, n = self._cross_street(xs.group("a"), xs.group("b"))
                if pt is not None:
                    loc = ResolvedLocation(pt.x, pt.y, Method.CROSS_STREET.value, conf, True)
                    self._audit(raw_text, loc, n)
                    return loc
                logger.warning("cross_street_no_intersection", extra={"raw": raw_text})

            # Safe default: jurisdiction centroid, flagged unresolved for a human.
            loc = ResolvedLocation(
                self.hint.x, self.hint.y, Method.UNRESOLVED.value,
                self.min_confidence, False,
            )
            self._audit(raw_text, loc, 0)
            return loc

        except (ValueError, AttributeError, KeyError) as exc:
            # Malformed input or reference data: degrade to a flagged default.
            logger.error("location_normalize_failed", exc_info=exc,
                         extra={"raw": raw_text})
            loc = ResolvedLocation(
                self.hint.x, self.hint.y, Method.ERROR.value, self.min_confidence, False,
            )
            self._audit(raw_text, loc, 0)
            return loc

The audit_log is the load-bearing output. Persisting it as a committed, content-hashed artifact lets a post-incident reviewer replay every normalization and confirm that no coordinate was fabricated from a bad route match — and it lets the address-standardization path for 911 logs reuse the same audit contract for the addressed calls that arrive on the same feed.

One number decides how much any of this is worth, and it is not the parser’s success rate.

Positional uncertainty of each rural reference type, against what a responder needs Uncertainty in metres for four ways of locating a rural incident. A cross-street resolution is accurate to the intersection itself, within about 20 metres. A milepost resolved by linear referencing is accurate to roughly half the marker spacing, about 400 metres where markers are spaced at 0.5 miles. A distance-and-bearing reference inherits both the anchor's uncertainty and the reported distance's rounding, around 800 metres. A landmark resolved from a gazetteer is as good as the gazetteer entry, typically 150 metres. Against these, a structure fire needs the responder within sight of the address, roughly 100 metres, while a highway collision only needs the correct side of the correct segment, roughly 800 metres. Milepost precision is adequate for the incident types that actually occur on mileposted roads, which is why the coarse answer is usually the right one. positional uncertainty by reference type, against what the incident actually needs cross-street node milepost · 0.5 mi markers distance and bearing gazetteer landmark 20 m400 m800 m150 m structure fire needs ~100 m highway collision needs ~800 m 0200400600800 m Milepost precision is adequate for the incidents that actually happen on mileposted roads.

Read against what each incident type needs, the coarse answers stop looking like failures. A milepost resolution with 400 metres of uncertainty is useless for a structure fire and entirely adequate for a highway collision — and highway collisions are what happens on mileposted highways. The reference type and the incident type are correlated, so the apparent mismatch between “we can only get within 400 metres” and “responders need 100” mostly does not arise in practice.

What follows is that the resolver should publish the uncertainty alongside the coordinate rather than only the coordinate. A dispatcher looking at a marker with a 400-metre radius drawn around it knows to expect a search; one looking at a bare point does not, and will report the marker as wrong when the unit arrives and sees nothing. The radius is the difference between a coarse answer and a wrong one.

It also gives the review queue a sensible priority. An unresolved landmark on a rural highway is worth a gazetteer entry; an unresolved cross-street in a jurisdiction whose road graph should have that node is a data defect in the road layer. Sorting the queue by reference type rather than by age puts the fixable-once problems at the top.

Validation Checklist

Verify every item before deploying the normalizer to a live dispatch feed.

  • Route and centerline reference layers are pre-projected to a single metric coordinate reference system, and reference_version is stamped from the layer release so each audit entry is traceable.
  • Route calibration is ascending and continuous; m_start/m_end gaps and re-measures are documented and lower the interpolation confidence.
  • Milepost values outside a route’s calibrated range return no match and fall through to the flagged default rather than clamping to an endpoint.
  • Cross-street resolution returns every candidate intersection, selects the nearest to the hint, and records candidate_count in the audit trail.
  • The jurisdiction centroid used as the disambiguation hint and the fallback is in the same coordinate reference system as the reference layers.
  • Unresolved and error results set resolved = False and carry min_confidence, so downstream consumers never treat a default as a confirmed location.
  • Structured logs route to the incident logging sink, not stdout, and every resolution appears in audit_log.
  • Downstream consumers (dashboard, router, dispatch queue) read and honour the confidence and resolved fields before assigning units.

Edge Cases and Gotchas

  • Axis-order inversion. Reference layers loaded as EPSG:4326 in latitude-first order will interpolate and intersect in the wrong hemisphere while looking superficially valid. Project every layer to a metric coordinate reference system and run any transform with always_xy=True; this is the same contract enforced across the real-time geocoding and location normalization path.
  • Null-island and centroid drift. An unresolved call that defaults to a jurisdiction centroid must never be confused with a real fix at (0.0, 0.0). Keep the resolved flag authoritative and guard the null-island coordinates check downstream so a flagged default is not later “corrected” into a precise-looking point.
  • Reversed or re-measured routes. A route whose measures decrease, or that was re-calibrated after a realignment, interpolates a milepost in the wrong direction — the classic ten-miles-off error. Validate that m_start < m_end per segment at load time and reject non-monotonic calibration rather than silently interpolating it.
  • Duplicated road names across jurisdictions. “Old Mill Road” may exist in three adjacent counties. Constrain the centerline candidate set to the responding jurisdiction before intersecting, and let the hint break residual ties, or a cross-street will resolve confidently to the wrong county.
  • Offline and legacy device quirks. Mobile data terminals sometimes transmit mileposts with a route abbreviation the reference layer does not use (“SR” vs “SH” vs “State Route”). Normalize route identifiers through an alias table at ingest, and reuse the resolved points where they feed the wider spatial workload described in optimizing spatial joins for incident data.

Frequently Asked Questions

Why can’t a rural milepost just be geocoded like a street address? A commercial address geocoder expects a house number on a named street with a matching postal range. A milepost is a linear-referencing measure along a specific route, not an address, and rural routes often have no address ranges at all. Resolving it means interpolating a distance along a calibrated route geometry, which is a linear-referencing operation the geocoder was never designed to perform.

How accurate is a point interpolated from a milepost? Accuracy depends entirely on the density and correctness of the route’s measure calibration. Where mile markers are physically surveyed and the route measures are continuous, interpolation is typically accurate to well within a marker spacing. Where calibration is sparse or the route has gaps and re-measures, error grows between control points, which is why every derived point must carry a confidence score rather than being treated as exact.

What happens when a cross-street pair has more than one intersection? Two rural roads can legitimately cross more than once, or share names across jurisdictions. The resolver returns every candidate intersection, selects the one nearest a supplied hint such as the responding jurisdiction centroid or the reporting tower, lowers the confidence score, and records all candidates in the audit trail so a dispatcher can override the choice.

Up: Real-Time Geocoding & Location Normalization

Other guides in Real-Time Geocoding & Location Normalization