Select to view content in your preferred language

Writing polygon values to point feature class based on an intersection of geometry.

519
5
04-01-2026 08:08 PM
ScumBagsSurfer
Occasional Contributor

Hello,

I want to create an automated scheduled task with Notebooks/Python working with hosted feature layers, where polygon values are written to point features based on an intersection of geometry.

I have 2 point feature classes, let's call them:

PointFC1

PointFC2

and 2 polygon feature classes, let's call them:

PolyFC1

PolyFC2

I would like to write PolyFC1 'Location' field to PointFC1 and PointFC2 'Location' field, and write PolyFC2 'Zone' to PointFC1 and PointFC2 'Zone' fields based on whether those point features fall within the boundary of said polygons. I would only like to run this calculation on points where 'Location' or 'Zone' is '<Null>', so redundant processing is removed. 

I have done this in ArcGIS Pro with field calculations via Python on the individual layers with:

getZone(!Shape!)

------------------------------------------------------

import arcpy

def getZone(point_geom):
    polygon_layer = "polygon_layer"  # just the layer name as it appears in ArcGIS Pro
    
    with arcpy.da.SearchCursor(polygon_layer, ["SHAPE@", "field_name"]) as cursor:
        for row in cursor:
            if row[0].contains(point_geom):
                return row[1]
    return None

I now want to group all the field calculations and run an automated task on them fortnightly, while only doing the points that are <Null> in those fields, as both point feature classes link to a Survey123 form where data is collected daily. That way, only features that need to be processed are processed.

Any help on this would be much appreciated.

Cheers.

0 Kudos
5 Replies
RobertMatthewmanCTCLUSI
Occasional Contributor

This is a great use case for some arcpy! 

Just off the top of my head, you'll probably need to do two nested cursors for this. I would start with an UpdateCursor over your point feature class. You can filter with a cursor using where_clause as an argument.

I'd probably use nested cursors; an UpdateCursor for the point FC (and I would do the point feature classes one at a time) and then a SearchCursor for each Polygon FC:

with arcpy.da.UpdateCursor(point_layer, ["SHAPE@", "field_name1", "field_name2"], where_clause="relevant_field IS NULL") as u_cursor:
        for row in u_cursor:
            point_geom = row[0]
            with arcpy.da.SearchCursor(polygon_layer1, ["SHAPE@, "poly1_field"]) as s_cursor1:
                for s_row1 in s_cursor1:
                    if s_row1[0].contains(point_geom):
                        row[1] = s_row1[1]
            with arcpy.da.SearchCursor(polygon_layer2, ["SHAPE@, "poly2_field"]) as s_cursor2:
                for s_row2 in s_cursor2:
                    if s_row2[0].contains(point_geom):
                        row[2] = s_row1[1]
        u_cursor.updateRow(row)

 

0 Kudos
HaydenWelch
MVP Regular Contributor

It's kinda dangerous to nest cursors like that. If something goes wrong, you can end up with borked data.

0 Kudos
TonyAlmeida
MVP Regular Contributor

try something like, 

 

import arcpy

arcpy.env.overwriteOutput = True

point_fcs = ["PointFC1", "PointFC2"]

poly1 = "PolyFC1"  # has 'Location'
poly2 = "PolyFC2"  # has 'Zone'

def update_points(point_fc):

    pt_lyr = "pt_lyr"
    arcpy.management.MakeFeatureLayer(
        point_fc,
        pt_lyr,
        "Location IS NULL OR Zone IS NULL"
    )

    if int(arcpy.management.GetCount(pt_lyr)[0]) == 0:
        print(f"No NULL Location/Zone in {point_fc}")
        return

    # --- Load polygon geometries into memory (fast lookup) ---
    poly1_geoms = []
    with arcpy.da.SearchCursor(poly1, ["SHAPE@", "Location"]) as cur:
        for geom, val in cur:
            if val is not None:
                poly1_geoms.append((geom, val))

    poly2_geoms = []
    with arcpy.da.SearchCursor(poly2, ["SHAPE@", "Zone"]) as cur:
        for geom, val in cur:
            if val is not None:
                poly2_geoms.append((geom, val))

    # --- Update points ---
    with arcpy.da.UpdateCursor(pt_lyr, ["SHAPE@", "Location", "Zone"]) as ucur:
        for geom, loc, zone in ucur:

            # Only compute what's missing
            if loc is None:
                for poly_geom, poly_val in poly1_geoms:
                    if poly_geom.contains(geom):
                        loc = poly_val
                        break

            if zone is None:
                for poly_geom, poly_val in poly2_geoms:
                    if poly_geom.contains(geom):
                        zone = poly_val
                        break

            ucur.updateRow((geom, loc, zone))

    arcpy.management.Delete(pt_lyr)

    print(f"Updated {point_fc}")

# -----------------------------
for pt in point_fcs:
    update_points(pt)

print("Done.")
0 Kudos
DavidSolari
MVP Regular Contributor

You can do all of this in Python, but working in the Spatial Join tool should be more performant as your datasets grow. For each point FC you can join in both polygon FCs, then loop over the results table in a Search Cursor to build up a dictionary of TARGET_FID to Location & Zone, then plug the Object ID of each point into that dictionary during the Update Cursor. This is what my Spatial Join parameters roughly looked like:

arcpy.analysis.SpatialJoin(
    target_features="PT1",
    join_features="PG1 JC1;PG2 JC2",
    out_feature_class=r"memory\PT1_SpatialJoin",
    join_operation="JOIN_ONE_TO_ONE",
    join_type="KEEP_ALL",
    field_mapping='location "location" true true false 255 Text 0 0,First,#,PG1,location,0,254;zone "zone" true true false 255 Text 0 0,First,#,PG2,zone,0,254',
    match_option="INTERSECT"
)
0 Kudos
HaydenWelch
MVP Regular Contributor

I prefer to use Cursors for all my joins and relates since in my experience they're much faster than any other option. SearchCursors are cheap and you can use them to extract and manipulate data all you want, then structure that and apply it in one UpdateCursor operation:

from collections.abc import Iterator, Sequence
from typing import Any

from arcpy import Polygon
from arcpy.da import SearchCursor, UpdateCursor

type UpdateMap = dict[int, dict[str, Any]]
"""Mapping of OBJECTID to Field Updates {FieldName: FieldValue, ...}"""

type FeatureMap = dict[str, str]
"""Mapping of FeatureClass to extracted field"""

def get_shapes(fc: str, field: str) -> Iterator[tuple[Polygon, str]]:
    yield from SearchCursor(fc, ['SHAPE@', field])

def update_feats(fc: str, updates: UpdateMap, fields: Sequence[str]) -> None:
    """Applies an UpdateMap to the target FeatureClass (Only supplied fields are updated)"""
    with UpdateCursor(fc, ['OID@'] + list(fields)) as cur:
        for oid, _ in filter(lambda r: r[0] in updates, cur):
            cur.updateRow([updates[oid][f] for f in cur.fields[1:]])

def get_intersecting(fc: str, field: str, shape: Polygon, value: Any) -> UpdateMap:
    """Gets all features in the input FeatureClass and matches them with 
    the target value based on spatial relation
    """
    return {
        oid: {field: value}
        for oid, 
        in SearchCursor(
            fc, ['OID@'], 
            spatial_filter=shape, 
            where_clause=f'{field} != {value} OR {field} IS NULL',
            search_order='SPATIALFIRST',
        )
    }

def merge_updates(*updates: UpdateMap) -> UpdateMap:
    """Merges multiple UpdateMaps into one"""
    merged: UpdateMap = {}
    for update in updates:
        for oid, vals in update.items():
            if oid in merged:
                merged[oid].update(vals)
            else:
                merged[oid] = vals
    return merged

def apply_join(fc: str, feat_map: FeatureMap):
    """Applies Updates to the input features based on the mapping supplied"""
    updates: list[UpdateMap] = []
    for mapped_feat, field in feat_map.items():
        for shape, val in get_shapes(mapped_feat, field):
            updates.append(get_intersecting(fc, field, shape, val))
    update_feats(fc, merge_updates(*updates), list(feat_map.values()))
           
def main():
    poly_features: FeatureMap = {
        'PolyFC1': 'Zone',
        'PolyFC2': 'Location',
    }
    apply_join('PointFC1', poly_features)
    apply_join('PointFC2', poly_features)

 

That apply_join function is the entire business logic, everything else is just to abstract the cursor operations away.

1) Iterate the PolygonClass/Value mapping (PolyFC1 has Zone, etc.)

2) Iterate all Shape/Value combos in that featureclass

3) Get all intersecting points for each shape and add them to an update mapping

4) Merge all updates into one Update map for the Target Point features

5) Apply the updates

 

Additionally, if you aren't sure that your target features have a matching field name, you can ensure it with a guard clause in the update_feats function:

 

def update_feats(fc: str, updates: UpdateMap, fields: Sequence[str]) -> None:
    """Applies an UpdateMap to the target FeatureClass (Only supplied fields are updated)"""
    
    for field in fields:
        ensure_field(fc, field)
    get_fields.cache_clear()
    
    with UpdateCursor(fc, ['OID@'] + list(fields)) as cur:
        for oid, _ in filter(lambda r: r[0] in updates, cur):
            cur.updateRow([updates[oid][f] for f in cur.fields[1:]])

@cache
def get_fields(fc: str) -> list[str]:
    return [f.name for f in ListFields(fc)]

def ensure_field(fc: str, field: str, field_info: dict[str, Any] | None = None, default_type: str = 'TEXT'):    
    if not field_info:
        field_info = {}
    if field not in get_fields(fc):
        AddField(fc, field, field_info.get(field, default_type))

 

the @cache decorator is from functools and will prevent the ListFields function from running for every input field. We need to clear the cache after ensuring fields since we've now update the field list. If you want, you can use a global map for the field_info parameter, or you can expose that in update_feats and pass it along.

This is definitely a overengineered solution to this, but I always prefer to be able to adjust the main loop when things change without manually adding tons more code blocks.