RMSE-to-Agency-Submission Workflow in Python

The number that decides whether a transformed cadastral batch may be filed is its horizontal root-mean-square error against certified control monuments, and turning that statistic into a pass/fail submission record is the accuracy gate inside compliance report generation for agency submission, part of Batch Transformation & Automation for Cadastral Coordinate Pipelines. This page computes the horizontal RMSE of transformed points against certified monuments, compares it to the agency’s tolerance gate, and emits the structured pass/fail record the submission carries.

RMSE tolerance-gate workflow Transformed points and matched certified control monuments feed a residual step that yields per-point planar separations. Those reduce to one batch horizontal RMSE, which enters a tolerance gate comparing it to the agency limit. Within tolerance emits a PASS submission record; over tolerance emits a FAIL record for rework. transformed + control E/N per-point residuals RMSE ≤ τ ? tolerance gate yes PASS record no FAIL — rework

Figure — matched points reduce to residuals, then a batch RMSE the gate turns into a submission verdict.

The Statistic and the Gate

Each transformed point ii has an easting/northing residual against its matched certified monument, (EiEictrl,NiNictrl)(E_i - E_i^{\text{ctrl}}, N_i - N_i^{\text{ctrl}}). The per-point planar residual is the Euclidean separation ri=(EiEictrl)2+(NiNictrl)2r_i = \sqrt{(E_i - E_i^{\text{ctrl}})^2 + (N_i - N_i^{\text{ctrl}})^2}, and the batch horizontal RMSE is the root of the mean of those squared separations:

Order of operations from residuals to a submitted statistic Five ordered steps. Compute the residual at every monument. Screen for blunders first, because one blunder inflates RMSE more than a dozen ordinary errors. Compute RMSE and the maximum on the screened set. Compare against the class tolerance. Emit the statistic together with the count of monuments screened out and why — a statistic without its exclusions is not reviewable. 1 Residuals per monument 2 Screen blunders first 3 Compute RMSE and max 4 Compare against class tolerance 5 Emit with the exclusion list

Figure — the order of the gate matters: screen, then compute, then compare.

RMSEH=1ni=1nri2=1ni=1n[(EiEictrl)2+(NiNictrl)2]\text{RMSE}_H = \sqrt{\frac{1}{n} \sum_{i=1}^{n} r_i^{\,2}} = \sqrt{\frac{1}{n} \sum_{i=1}^{n} \left[ (E_i - E_i^{\text{ctrl}})^2 + (N_i - N_i^{\text{ctrl}})^2 \right]}

Two properties matter for compliance. This is a two-dimensional RMS — the sum of squared full separations divided by nn, not by 2n2n — so it reports the radial positional error a surveyor expects, roughly 2\sqrt{2} larger than a per-axis one-dimensional figure. And it is a root-mean-square, dominated by the largest residuals; it is not the mean error, which can hide a scattered set of large misses behind cancellation. The gate is the plain comparison RMSEHτ\text{RMSE}_H \le \tau against the agency tolerance τ\tau, commonly 0.020 m horizontal for cadastral work.

Complete Runnable Implementation

The function below computes the per-point residuals, the batch RMSE, and the worst offender, then packages the tolerance verdict into a structured submission record with units carried explicitly.

from __future__ import annotations

import json
import logging
from dataclasses import dataclass, asdict

import numpy as np

logger = logging.getLogger("rmse_submission")


@dataclass(frozen=True)
class SubmissionRecord:
    """Pass/fail accuracy record for an agency submission."""
    n_points: int
    rmse_h_m: float          # batch horizontal RMSE, metres
    max_residual_m: float    # largest single planar residual, metres
    worst_index: int         # row of the worst offender, for diagnosis
    tolerance_m: float       # agency gate, metres
    passed: bool

    def to_json(self) -> str:
        return json.dumps(asdict(self), sort_keys=True, indent=2)


def rmse_submission(
    transformed_en: np.ndarray,   # (n, 2) easting, northing in metres
    control_en: np.ndarray,       # (n, 2) certified monument E/N in metres
    tolerance_m: float = 0.020,
) -> SubmissionRecord:
    """Compute horizontal RMSE against certified control and gate it.

    Both arrays must be metre-scale easting/northing pairs in the same CRS.
    Returns a SubmissionRecord; the caller inspects `passed` before filing.
    """
    if transformed_en.shape != control_en.shape:
        raise ValueError("transformed and control arrays must match in shape")
    if transformed_en.ndim != 2 or transformed_en.shape[1] != 2:
        raise ValueError("expected (n, 2) easting/northing arrays in metres")

    diff = transformed_en.astype(np.float64) - control_en.astype(np.float64)
    per_point = np.hypot(diff[:, 0], diff[:, 1])          # full planar residual
    # Two-dimensional RMS: mean of squared separations, then root (÷ n, not 2n).
    rmse = float(np.sqrt(np.mean(per_point ** 2)))
    worst = int(np.argmax(per_point))

    record = SubmissionRecord(
        n_points=int(transformed_en.shape[0]),
        rmse_h_m=round(rmse, 4),
        max_residual_m=round(float(per_point[worst]), 4),
        worst_index=worst,
        tolerance_m=tolerance_m,
        passed=rmse <= tolerance_m,
    )
    logger.info("rmse_submission %s", json.dumps(asdict(record)))
    return record

Parameter Reference

Name Type Units Valid range Notes
transformed_en np.ndarray metres shape (n, 2) transformed easting/northing; must be projected, not degrees
control_en np.ndarray metres shape (n, 2) certified monument coordinates, same CRS and axis order
tolerance_m float metres > 0 agency gate; 0.020 m is a common cadastral horizontal limit
rmse_h_m float metres ≥ 0 two-dimensional batch RMSE, the headline gate figure
max_residual_m float metres ≥ 0 worst single point; a lever for diagnosing an outlier
worst_index int row index 0 … n−1 which monument drove the worst residual
passed bool False blocks the submission and routes to rework

Worked Example

A four-point batch against certified monuments, with residuals a few millimetres each, clears a 20 mm gate.

transformed = np.array([[500000.006, 5000000.004],
                        [500100.005, 5000100.007],
                        [500200.008, 5000200.003],
                        [500300.002, 5000300.009]])
control = np.array([[500000.008, 5000000.006],
                    [500100.007, 5000100.010],
                    [500200.010, 5000200.007],
                    [500300.004, 5000300.012]])

rec = rmse_submission(transformed, control, tolerance_m=0.020)
print(rec.rmse_h_m, rec.max_residual_m, rec.passed)
# 0.0036 0.005 True

The batch RMSE is about 3.6 mm and the worst point about 5 mm, both well inside the gate, so passed is True and the record is ready to attach to the submission.

Validation Check

Gate the verdict and confirm the statistic is a genuine RMS — never below the mean of the residuals — before the record leaves the pipeline.

Effect of a single blunder on the reported RMSE Bar chart of RMSE in metres over twenty monuments as one monument is degraded: with no blunder the RMSE is 0.018 metres; with one monument out by 0.10 metres it rises to 0.028; by 0.25 metres to 0.058; by 0.50 metres to 0.113; by 1.00 metre to 0.224. A single bad monument moves the reported statistic by an order of magnitude, which is why screening comes before the statistic and not after it. 0 0.1 0.2 m 0.018 none 0.028 0.10 m 0.058 0.25 m 0.113 0.50 m 0.224 1.00 m

Figure — what one blunder does to an RMSE computed over twenty monuments.

diff = transformed - control
per_point = np.hypot(diff[:, 0], diff[:, 1])
assert rec.rmse_h_m >= float(np.mean(per_point)) - 1e-9, "RMS below mean is impossible"
assert rec.rmse_h_m <= rec.tolerance_m, "batch exceeds agency tolerance"
assert 0 <= rec.worst_index < rec.n_points, "worst index out of range"

Common Mistakes

Reporting a one-dimensional RMSE for a two-dimensional position
Summing squared residuals over both axes and dividing by 2n gives a per-axis figure that is about a factor of √2 smaller than the radial horizontal RMSE an agency expects. Divide the sum of squared full separations by n, as np.mean(per_point ** 2) does here.
Using the mean error instead of the root-mean-square
The mean of the residuals understates accuracy because it is not dominated by the worst points and, for signed per-axis errors, lets positive and negative misses cancel. Compliance gates are defined on the RMS, which squares before averaging so large residuals cannot be masked.
Mixing millimetres and metres
If the transformed array is in metres but the control is in millimetres — or the tolerance is entered as 20 meaning millimetres against a metre-scale RMSE — the gate compares incommensurable quantities and passes or fails nonsensically. Keep every array and the tolerance in metres, and assert the unit at the boundary.
Comparing against uncertified or stale control
An RMSE is only meaningful against monuments whose coordinates are themselves certified in the target realization. Checking against provisional, superseded, or wrong-epoch control produces a residual that measures the reference error, not the transformation, and a passing gate then certifies nothing.

Frequently Asked Questions

Should RMSE be computed before or after blunder screening?

After, and the screening must be reported alongside it. One monument out by a metre in a set of twenty raises the RMSE by an order of magnitude, so an unscreened statistic describes the blunder rather than the survey. What makes this honest rather than flattering is publishing the exclusion list: every monument removed, its residual and the reason.

Is RMSE the same as standard deviation?

Only when the mean residual is zero. RMSE combines the systematic and the random parts, while the standard deviation describes the scatter about whatever mean is present. A network with a systematic ten centimetre bias and two centimetre scatter has a small standard deviation and a large RMSE, and the difference between the two numbers is exactly the bias you have not removed yet.

What if the agency specifies a statistic I do not compute?

Compute theirs, and publish yours alongside it if it is more informative. Agencies vary in whether they ask for RMSE, a 95 per cent radial statistic, or a maximum, and converting between them after the fact requires assumptions that may not hold. Producing the requested statistic from the same screened residual set is straightforward; arguing about which statistic is better after a submission is rejected is not.