Automating Heritage Constraint Search Reports

Before a scheme reaches design, a developer or their consultant needs to know which heritage constraints apply to a site: is any part of it scheduled, does it lie in a conservation area, how close is the nearest listed building, is it inside an Area of Archaeological Priority? Answering this by opening layers one at a time and measuring by hand is slow and unrepeatable. This guide, part of the compliance reporting and statutory boundary validation workflow, takes a single site boundary polygon and intersects it against every designation layer, then renders a structured constraints report stating which designations apply, which lie within a search radius, and the distance to each.

Constraint search fan-out A single site boundary is tested against several designation layers in parallel, producing per-layer applies-or-nearby results that combine into one report. Constraint search fan-out Site boundary Scheduled monuments Conservation areas Listed curtilage Constraints report

Context & When to Use

A constraint search differs from feature validation in its unit of analysis: the input is one site boundary (not many individual features), and the output is a per-designation-type summary aimed at a non-specialist reader. Use it for desk-based assessment, pre-application enquiry responses, and the constraints section of a heritage statement. The key parameter is the search radius — designations that do not touch the site but lie within, say, 500 m still bear on setting and archaeological potential. Set the radius per designation type: scheduled monuments and Grade I listed buildings warrant a wider search than a locally listed building. The report distinguishes applies (the designation intersects the site) from nearby (within radius), because the two carry different legal weight.

The reason to make the radius a per-type dictionary rather than a single global figure is that the designations are not equivalent in reach. A scheduled monument’s setting can extend hundreds of metres and is a material consideration in planning; a locally listed building’s contribution is usually confined to its immediate streetscape. A flat 500 m radius applied to every layer would bury the genuinely relevant hits under a mass of distant, low-weight ones and make the report harder, not easier, to act on. Tuning the radius per type is how you keep the output proportionate to the statutory weight of each constraint. Because the search runs against a fixed boundary snapshot, it is also cheap to re-run whenever the developer revises the red-line boundary, so treat it as a live check during design rather than a one-off.

Implementation

The search runs each designation type against the boundary in a single spatial query per type, driven by a small config that sets the radius. A Jinja-style template renders the result; here a dependency-free string.Template keeps it self-contained.

# requirements.txt
# geopandas==1.0.1
# shapely==2.0.5
# psycopg==3.2.1
# pyproj==3.6.1
from dataclasses import dataclass
from datetime import date
from string import Template

import geopandas as gpd
from shapely.geometry import base

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

# Per-type search radius in metres. Wider for higher-status designations.
SEARCH_RADIUS_M = {
    "scheduled_monument": 500,
    "conservation_area": 250,
    "listed_building_curtilage": 250,
    "area_of_archaeological_priority": 100,
}


@dataclass
class ConstraintHit:
    designation_type: str
    list_entry: str
    name: str
    status: str          # 'applies' | 'nearby'
    distance_m: float


def run_constraint_search(conn_str: str, boundary: base.BaseGeometry) -> list[ConstraintHit]:
    """Intersect one site boundary against each designation type within its radius."""
    hits: list[ConstraintHit] = []
    all_desig = gpd.read_postgis(
        "SELECT list_entry, designation_type, name, geom FROM compliance.designations",
        conn_str, geom_col="geom",
    )
    if all_desig.crs.to_epsg() != TARGET_EPSG:
        all_desig = all_desig.to_crs(epsg=TARGET_EPSG)

    for dtype, radius in SEARCH_RADIUS_M.items():
        layer = all_desig[all_desig["designation_type"] == dtype]
        # Distance from the boundary polygon to each designation, in metres (BNG).
        layer = layer.assign(distance_m=layer.geometry.distance(boundary))
        within = layer[layer["distance_m"] <= radius]
        for _, r in within.iterrows():
            status = "applies" if r["distance_m"] == 0 else "nearby"
            hits.append(ConstraintHit(dtype, r["list_entry"], r["name"],
                                      status, round(float(r["distance_m"]), 1)))
    # Applies-first, then ascending distance, so the report leads with hard constraints.
    hits.sort(key=lambda h: (h.status != "applies", h.distance_m))
    return hits


REPORT_TMPL = Template(
    """HERITAGE CONSTRAINT SEARCH
Site: $site_name        Date: $today        CRS: EPSG:$epsg

$body
Designation types with a hit: $type_count    Total constraints: $total
""")


def render_report(site_name: str, hits: list[ConstraintHit]) -> str:
    if not hits:
        body = "No designated heritage constraints identified within the search radii.\n"
    else:
        lines = []
        for h in hits:
            where = "ON SITE" if h.status == "applies" else f"{h.distance_m:.0f} m away"
            lines.append(f"  [{h.status.upper():7}] {h.designation_type:32} "
                         f"{h.list_entry or '-':10} {h.name or '':30} ({where})")
        body = "\n".join(lines) + "\n"
    return REPORT_TMPL.substitute(
        site_name=site_name, today=date.today().isoformat(), epsg=TARGET_EPSG,
        body=body, type_count=len({h.designation_type for h in hits}), total=len(hits),
    )

The distance is computed in Python via geopandas for readability, but on large HER layers push it into PostGIS with an ST_DWithin prefilter so only candidates within the maximum radius return — the same index-assisted pattern the feature validation guide uses for its proximity join.

Field note. "Curtilage listing" has no mapped boundary in the NHLE — the statutory protection extends to structures within the curtilage of a listed building as it stood in 1948, which is a legal test, not a polygon. A constraint search can flag proximity to the listed building point or footprint, but it cannot decide curtilage. Always state in the report that curtilage extent requires specialist judgement, or you imply a precision the data does not hold.

Verification

Feed a boundary with known relationships and assert the classification. A site drawn to overlap a conservation area but sit 120 m from the nearest scheduled monument should report the former as applies and the latter as nearby.

hits = run_constraint_search(CONN, test_boundary)
by_type = {h.designation_type: h for h in hits}

assert by_type["conservation_area"].status == "applies"
assert by_type["conservation_area"].distance_m == 0.0
assert by_type["scheduled_monument"].status == "nearby"
assert 100 <= by_type["scheduled_monument"].distance_m <= 130
print(render_report("Test Site A", hits))

Expected report fragment:

  [APPLIES] conservation_area                -          St Mary's CA                    (ON SITE)
  [NEARBY ] scheduled_monument               1012345    Moated site NE of church       (120 m away)

Common Errors & Fixes

  • AttributeError: 'NoneType' object has no attribute 'distance' — the site boundary passed in is None, usually because a WKT parse failed upstream. Validate with shapely.wkt.loads and check boundary.is_valid before searching.
  • All distances return 0.0 incorrectly — the boundary geometry and the designations are in different CRSs, so geopandas aligns them to the same nominal coordinates and reports overlaps that are not real. Confirm boundary is in EPSG:27700 and reproject the layer if to_epsg() disagrees.
  • shapely.errors.GEOSException: TopologyException on .distance() — a designation polygon is invalid. Repair the layer in the database with UPDATE compliance.designations SET geom = ST_MakeValid(geom) WHERE NOT ST_IsValid(geom); and reload, or apply .buffer(0) to the GeoSeries as a last-resort fix in memory.

Part of Artifact & Feature Spatial Database Design.