Compliance Reporting and Statutory Boundary Validation in PostGIS

Every excavation and watching brief carried out inside or near a designated heritage asset triggers a statutory obligation: features and finds must be tested against scheduled monument boundaries, conservation area limits, and listed-building curtilage, and the results must be reported to the planning authority in a defensible, reproducible form. Doing this by eye in a desktop GIS does not scale past a handful of features and leaves no audit trail. This section of the Artifact & Feature Spatial Database Design framework shows how to ingest statutory designation layers into PostGIS, validate spatial features against them with ST_Contains, ST_Intersects, and ST_DWithin, and emit constraint-search reports and submission packages that a curatorial officer can accept without re-checking your geometry. The four guides below drill into the concrete tasks: validating features against scheduled monument boundaries, exporting planning authority submission packages, automating heritage constraint search reports, and scheduling automated boundary validation jobs.

Statutory boundary validation pipeline Designation layers and feature geometries flow through a PostGIS validation stage into violation records, constraint reports, and a submission package. Statutory boundary validation pipeline Designation layers ingested Feature & find geometries Validate ST_Intersects / DWithin Violation records Constraint report Submission package

Statutory designation data in England reaches you as vector layers from Historic England’s National Heritage List for England (NHLE), from local authority Historic Environment Records (HERs), and from Ordnance Survey conservation area extracts. Each carries its own attribute schema, its own currency, and — critically — its own coordinate reference system, usually EPSG:27700 (British National Grid) but occasionally supplied in EPSG:4326. Validation is only meaningful when every layer and every feature you test shares one projected CRS, because ST_DWithin distances are expressed in the CRS unit (metres for BNG), and a WGS84 comparison returns degrees.

Prerequisites

Library / tool Version Purpose
PostgreSQL 16 Relational engine hosting the spatial database
PostGIS 3.4 Spatial predicates, geometry storage, indexing
psycopg 3.2.1 Python driver for parameterised SQL execution
geopandas 1.0.1 Reading designation layers, spatial joins in Python
pyproj 3.6.1 CRS resolution and reprojection
GDAL/ogr2ogr 3.9.1 Loading and exporting designation layers
shapely 2.0.5 In-memory geometry predicates for pre-checks

Assumptions: you have a PostGIS database with excavation and feature geometries already loaded (see PostGIS schema design for excavation units), write access to a compliance schema, and current designation extracts licensed for your organisation. All examples use EPSG:27700; substitute your site’s EPSG where marked.

Implementation

The workflow runs field-to-archive: ingest designations, normalise CRS, validate features, record violations, then report and export.

1. Create a dedicated compliance schema

Isolating designation layers and validation output in their own schema keeps statutory reference data separate from your interpretive excavation records, and lets you grant read-only access to reporting jobs.

-- PostgreSQL 16 / PostGIS 3.4
CREATE SCHEMA IF NOT EXISTS compliance;

CREATE TABLE IF NOT EXISTS compliance.designations (
    designation_id   BIGSERIAL PRIMARY KEY,
    list_entry       VARCHAR(20),          -- NHLE reference, e.g. '1012345'
    designation_type VARCHAR(40) NOT NULL, -- scheduled_monument | conservation_area | listed_building_curtilage
    name             TEXT,
    grade            VARCHAR(8),           -- I, II*, II for listed buildings
    source           VARCHAR(80),          -- 'Historic England NHLE 2026-06'
    geom             GEOMETRY(MultiPolygon, 27700) NOT NULL,
    CONSTRAINT designations_valid CHECK (ST_IsValid(geom))
);

CREATE INDEX IF NOT EXISTS idx_designations_geom
    ON compliance.designations USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_designations_type
    ON compliance.designations (designation_type);

2. Ingest designation layers with ogr2ogr

Load each source layer directly into the designations table, forcing the target SRID and multipolygon type so a mix of Polygon and MultiPolygon sources coexists. The -nlt PROMOTE_TO_MULTI flag prevents the classic ERROR: Geometry type (Polygon) does not match column type (MultiPolygon) failure.

# GDAL 3.9.1 / ogr2ogr
ogr2ogr -f PostgreSQL \
  PG:"host=localhost dbname=archaeology_db user=heritage" \
  scheduled_monuments.gpkg \
  -nln compliance.designations_stage \
  -t_srs EPSG:27700 \
  -nlt PROMOTE_TO_MULTI \
  -lco GEOMETRY_NAME=geom \
  -overwrite

Then map staged columns into the canonical schema so every source shares one attribute contract:

-- PostgreSQL 16 / PostGIS 3.4
INSERT INTO compliance.designations
    (list_entry, designation_type, name, source, geom)
SELECT list_entry, 'scheduled_monument', name,
       'Historic England NHLE 2026-06',
       ST_Multi(ST_MakeValid(geom))
FROM compliance.designations_stage;

3. Normalise feature CRS before validation

Features arriving from total station or RTK-GNSS exports may sit in a local grid. Reproject to the designation CRS in Python before any predicate runs.

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

TARGET_EPSG = 27700  # British National Grid; substitute your site's EPSG

def normalise(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    if gdf.crs is None:
        raise ValueError("Feature layer has no CRS; reconcile against site datum first.")
    if gdf.crs.to_epsg() != TARGET_EPSG:
        gdf = gdf.to_crs(epsg=TARGET_EPSG)
    return gdf

4. Run the core validation predicates

Three predicates cover most statutory tests: ST_Intersects flags any feature that touches or overlaps a designation, ST_Contains isolates features wholly inside a boundary, and ST_DWithin catches features within a proximity buffer (e.g. 50 m of a scheduled monument, a common consultation trigger).

-- PostgreSQL 16 / PostGIS 3.4
SELECT f.feature_id,
       d.list_entry,
       d.designation_type,
       ST_Contains(d.geom, f.geom)              AS fully_inside,
       ST_Intersects(d.geom, f.geom)            AS intersects,
       ST_DWithin(d.geom, f.geom, 50)           AS within_50m,
       round(ST_Distance(d.geom, f.geom)::numeric, 2) AS distance_m
FROM   features f
JOIN   compliance.designations d
       ON ST_DWithin(d.geom, f.geom, 50)   -- index-assisted proximity join
WHERE  d.designation_type = 'scheduled_monument';

The ST_DWithin join condition is index-assisted, so the GIST index on geom narrows candidates before the exact distance is computed — far cheaper than a Cartesian ST_Distance < 50.

5. Persist violations to an auditable table

Materialise results so a report reflects a fixed run, not a live query that shifts as data changes. The feature validation guide covers the violations table in depth.

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

CRS & Geometry Validation

Because ST_DWithin and ST_Distance return values in the CRS unit, mixing SRIDs is the single most common failure. PostGIS refuses cross-SRID operations outright with ERROR: Operation on mixed SRID geometries, which is preferable to a silent wrong answer, but it means every layer must be reprojected on ingest, not at query time. Confirm alignment before validating:

-- PostgreSQL 16 / PostGIS 3.4
SELECT DISTINCT ST_SRID(geom) FROM compliance.designations;   -- expect 27700
SELECT DISTINCT ST_SRID(geom) FROM features;                  -- expect 27700

Designation polygons from public downloads frequently contain self-intersections and ring-order errors that make ST_Contains return unexpected results. Repair them once, on ingest, with ST_MakeValid, and verify with ST_IsValidReason to see the exact defect:

SELECT list_entry, ST_IsValidReason(geom)
FROM compliance.designations
WHERE NOT ST_IsValid(geom);
-- e.g. 'Self-intersection[529412 181004]'

Automated QA/QC

A validation run should be reproducible and gated. Assign each run a run_id (UUID), record the count of features tested and violations found, and refuse to promote a report if the designation source is older than your organisation’s currency threshold (Historic England updates the NHLE frequently; stale extracts miss new schedulings). Route three outcomes distinctly: clean features (logged, no action), proximity flags (reported for consultation), and containment violations (escalated). The scheduling guide builds this into a nightly job with an audit log keyed on run_id, so any historical report can be regenerated exactly.

Field note. Scheduled monument boundaries in the NHLE are drawn to the extent of the legal designation, which often differs from the visible earthwork. A feature that looks "outside the monument" on an orthophoto can still fall inside the scheduled area, and disturbing it without consent is an offence. Always validate against the current legal boundary geometry, never against a digitised interpretation of the surface remains.

Integration with adjacent workflows

Validation consumes the geometries defined by your schema and the spatial predicates modelled elsewhere. The proximity and containment joins here reuse patterns from Spatial Relationship Modeling in Heritage DBs, where ST_Touches, ST_Contains, and ST_DWithin are formalised as context rules. On large multi-season datasets the ST_DWithin designation join can dominate runtime, so the index and planner strategies in Query Optimization for Large Excavation Datasets apply directly to the validation query. Downstream, the persisted violations feed the constraint search report and the submission package export.

Troubleshooting

  • ERROR: Operation on mixed SRID geometries — a designation layer and the feature layer carry different SRIDs. Reproject on ingest with -t_srs EPSG:27700 in ogr2ogr, or ST_Transform(geom, 27700) in SQL. Never compare geometries across SRIDs.
  • ERROR: Geometry type (Polygon) does not match column type (MultiPolygon) — the source layer mixes singular and multi geometries. Add -nlt PROMOTE_TO_MULTI to ogr2ogr, or wrap inserts in ST_Multi().
  • ST_Contains returns false for a feature clearly inside — the designation polygon is invalid. Run SELECT ST_IsValidReason(geom) to locate the self-intersection, then repair with ST_MakeValid().
  • ST_DWithin distances look absurd (e.g. 0.0004) — geometries are in EPSG:4326; the “distance” is in degrees. Reproject to a metric CRS before any distance test.
  • Proximity join is slow on a full county HER — the planner is not using the GIST index. Confirm with EXPLAIN ANALYZE, then ANALYZE compliance.designations; to refresh statistics after bulk load.
  • psycopg.errors.InsufficientPrivilege: permission denied for schema compliance — the reporting role lacks USAGE; grant GRANT USAGE ON SCHEMA compliance TO report_role;.

Compliance notes

Statutory reporting carries metadata obligations beyond the geometry itself. Record, per validation run: the designation source and its publication date (traceable to Historic England or the HER), the CRS authority (EPSG:27700), and the run timestamp — these satisfy the lineage and temporal-extent elements of ISO 19115. Populate MIDAS Heritage monument identifiers (the NHLE list entry number) so each violation links back to the national record. Align file naming and attribute definitions with the local authority’s deposition guidelines, and keep every input and output under a persistent identifier to meet FAIR findability and reusability. The report itself should state which designation currency was used, so a curatorial officer can judge whether re-validation is required.

Part of Artifact & Feature Spatial Database Design.