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.
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.
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 isNone, usually because a WKT parse failed upstream. Validate withshapely.wkt.loadsand checkboundary.is_validbefore searching.- All distances return
0.0incorrectly — the boundary geometry and the designations are in different CRSs, sogeopandasaligns them to the same nominal coordinates and reports overlaps that are not real. Confirmboundaryis inEPSG:27700and reproject the layer ifto_epsg()disagrees. shapely.errors.GEOSException: TopologyExceptionon.distance()— a designation polygon is invalid. Repair the layer in the database withUPDATE 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.
Related
- Compliance Reporting and Statutory Boundary Validation — the section overview this report belongs to.
- Validating Features Against Scheduled Monument Boundaries — the feature-level test that complements this site-level search.
- Exporting Planning Authority Submission Packages — bundle this report into a formal deliverable.
- Scheduling Automated Boundary Validation Jobs — regenerate constraint reports on a schedule as designations change.
- Spatial Relationship Modeling in Heritage DBs — the intersection and distance predicates behind the search.