Validating Features Against Scheduled Monument Boundaries

When an excavation trench or a recorded artifact falls inside — or within the consultation buffer of — a scheduled monument, that fact must be established mechanically, recorded, and traceable, not inferred from a supervisor squinting at a basemap. This guide implements the core test of the compliance reporting and statutory boundary validation workflow: given a table of feature geometries and a table of scheduled monument polygons in EPSG:27700, flag every feature that intersects a boundary or sits within a proximity buffer, and write the results to a durable violations table with enough context for a curatorial officer to act on.

Feature-to-boundary test decision flow A feature is tested for containment, then intersection, then proximity within fifty metres, routing to a violation record or a clear result. Feature-to-boundary test flow Feature geometry ST_Contains? inside boundary ST_DWithin? within 50 m Violation recorded Clear result

Context & When to Use

Run this test whenever new feature geometry is committed on a site that overlaps or abuts a scheduled area, and always before submitting a report. The 50 m buffer is a pragmatic default reflecting the setting-consultation distances many curators apply, but it is a policy parameter, not a law — confirm the threshold with the relevant authority and make it configurable. The trade-off is between recall and reviewer workload: a wide buffer flags more features and burdens the officer, a narrow one risks missing a feature that genuinely affects the monument’s setting. Containment (ST_Contains) is unambiguous and always escalated; proximity (ST_DWithin) is advisory and drives consultation. Do the exact distance in the database, not in Python round-trips, so the GIST index does the culling.

Two predicate choices deserve care. Use ST_Contains(d.geom, f.geom) rather than ST_Within(f.geom, d.geom) for the containment test — they are logically equivalent with arguments swapped, but ordering the designation first keeps the code readable against the boundary being the reference frame. And prefer ST_DWithin over a hand-rolled ST_Buffer plus ST_Intersects: buffering every designation polygon on the fly is expensive and defeats the spatial index, whereas ST_DWithin uses the index directly and computes the true minimum distance without an intermediate geometry. A point find, a polyline section drawing, and a polygon trench all validate through the same query because these predicates are geometry-type agnostic.

Implementation

The violations table records one row per feature-designation-rule match, keyed to a run_id so a report reflects a fixed snapshot.

-- PostgreSQL 16 / PostGIS 3.4
CREATE TABLE IF NOT EXISTS compliance.boundary_violations (
    violation_id BIGSERIAL PRIMARY KEY,
    run_id       UUID        NOT NULL,
    feature_id   VARCHAR(40) NOT NULL,
    list_entry   VARCHAR(20),                 -- NHLE scheduled monument reference
    monument     TEXT,
    rule         VARCHAR(24) NOT NULL,        -- 'inside_scheduled' | 'within_buffer'
    distance_m   NUMERIC(10,2) NOT NULL,
    detected_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (run_id, feature_id, list_entry, rule)
);

The Python driver runs the test in one parameterised statement and inserts results in the same transaction, so a report never sees a half-written run.

# requirements.txt
# psycopg==3.2.1
# geopandas==1.0.1
# pyproj==3.6.1
import uuid
import psycopg

BUFFER_M = 50           # setting-consultation distance; confirm with the curator
TARGET_EPSG = 27700     # British National Grid; substitute your site's EPSG

VALIDATE_SQL = """
INSERT INTO compliance.boundary_violations
    (run_id, feature_id, list_entry, monument, rule, distance_m)
SELECT %(run_id)s,
       f.feature_id,
       d.list_entry,
       d.name,
       CASE WHEN ST_Contains(d.geom, f.geom) THEN 'inside_scheduled'
            ELSE 'within_buffer' END,
       round(ST_Distance(d.geom, f.geom)::numeric, 2)
FROM   features f
JOIN   compliance.designations d
       ON  d.designation_type = 'scheduled_monument'
       AND ST_DWithin(d.geom, f.geom, %(buffer)s)   -- index-assisted prefilter
ON CONFLICT (run_id, feature_id, list_entry, rule) DO NOTHING;
"""

def validate_features(conn_str: str, buffer_m: float = BUFFER_M) -> tuple[str, int]:
    """Test every feature against scheduled monuments; return (run_id, violation_count)."""
    run_id = str(uuid.uuid4())
    with psycopg.connect(conn_str) as conn:
        with conn.cursor() as cur:
            # Guard against the classic mixed-SRID failure before running the join.
            cur.execute("SELECT DISTINCT ST_SRID(geom) FROM features;")
            srids = {row[0] for row in cur.fetchall()}
            if srids != {TARGET_EPSG}:
                raise ValueError(
                    f"features not uniformly EPSG:{TARGET_EPSG}: found SRIDs {srids}. "
                    "Reproject before validation."
                )
            cur.execute(VALIDATE_SQL, {"run_id": run_id, "buffer": buffer_m})
            count = cur.rowcount
        conn.commit()
    return run_id, count
Field note. A scheduled monument boundary and its buffer can overlap two adjacent monuments, so one feature legitimately produces two violation rows. Do not de-duplicate on feature_id alone — a find that lies inside monument A and within 50 m of monument B must be reported against both, because consent from one does not cover the other.

If you collapse them you will under-report to the planning authority.

Verification

After a run, confirm the counts and inspect the escalation-worthy rows. Containment violations should always have distance_m = 0 because a contained geometry has zero distance to its container.

-- PostgreSQL 16 / PostGIS 3.4
SELECT rule, count(*) AS n, min(distance_m), max(distance_m)
FROM   compliance.boundary_violations
WHERE  run_id = '<run-id-from-python>'
GROUP  BY rule;

Expected output for a run flagging two contained and three nearby features:

      rule       | n | min  |  max
-----------------+---+------+-------
 inside_scheduled| 2 | 0.00 |  0.00
 within_buffer   | 3 | 4.12 | 47.80

Assertion checks worth wiring into a test: every inside_scheduled row has distance_m = 0; every within_buffer row has 0 < distance_m <= BUFFER_M; and re-running the same run_id inserts zero new rows (the ON CONFLICT ... DO NOTHING idempotency guarantee).

Common Errors & Fixes

  • ERROR: Operation on mixed SRID geometries — the features and designations tables hold different SRIDs, so PostGIS refuses the ST_DWithin join. The Python guard above catches this early; to fix the data, UPDATE features SET geom = ST_Transform(geom, 27700); or reload with ogr2ogr -t_srs EPSG:27700.
  • ERROR: GEOSContains: TopologyException: side location conflict — a designation polygon is invalid, and ST_Contains cannot evaluate it. Repair once with UPDATE compliance.designations SET geom = ST_MakeValid(geom) WHERE NOT ST_IsValid(geom); then re-run.
  • Every feature flagged within_buffer at fractional distances — geometries are in degrees (EPSG:4326), so the 50-unit buffer spans continents. Confirm with SELECT ST_SRID(geom) FROM features LIMIT 1; and reproject to the metric BNG CRS before validating.

Part of Artifact & Feature Spatial Database Design.