Create Chainage Ticks along a Line using ArcPy

This became a pet project of mine back in 2017 where I first implemented using open source geospatial Python modules, osgeo and shapely. I then converted the workflow to ArcPy before Esri had brought out the Generate Points Along Lines tool. This third iteration of the workflow uses the Generate Points Along Lines tool to reduce the necessary lines of code. You can check out the Esri documentation for the Generate Points Along Lines tool here.

If you’re interested in learning ArcPy, check out this course.

Our goal here is to create transects across linear features at specified distances. Chainage values are often required for linear infrastructure projects. The image below represents an example of the output we want to create.

We need five parameters, three required; in_features, the linear features to create the chainage ticks for, out_feature_class, the output polyine feature class containing the ticks, and distance, the distance separating each tick along the line, and then we have two optional parameters; unique_fld, is used if you want to assign an attribute from the in_features to the out_feature_class (default is the OID field), and tick_length is for the length of the tick transect, 10 Meters is the default meaning 5 meters either side of the line.

We start by importing the ArcPy and math modules

import arcpy
import math

We then setup for our user inputs.

## input linear feature class
in_features = arcpy.GetParameterAsText(0)
## output feature class containg the ticks
out_feature_class = arcpy.GetParameterAsText(1)
## the distance each tick is to be along the line
distance = arcpy.GetParameterAsText(2)
## a unique identifier to separate line records
unique_fld = arcpy.GetParameterAsText(3)
## length of tick traverse, 50% each side of the line
tick_length = arcpy.GetParameterAsText(4)

There are a small amount of required objects for us to help with the workflow.

## required for running the GeneratePointsAlongLines tool
pt_placement = "DISTANCE"
incl_ends = "END_POINTS"
add_chainage_flds = "ADD_CHAINAGE"

## convert to integers
tick_length = int(tick_length)
distance = int(distance.split(" ")[0])

## srs id of the in_features so we can assign the output the same
srs_id = arcpy.Describe(in_features).spatialReference.factoryCode

There are three important functions to create. The first gets the angle between two points, the second gets a point based on the distance and bearing from another point, and the third constructs the chainage tick itself.

## http://wikicode.wikidot.com/get-angle-of-line-between-two-points
def getAngle(pt1, pt2):
    """
    Get the angle between two points

    Args:
        pt1     a tuple containing x,y coordinates
        pt2     a tuple containing x,y coordinates

    Returns:
        A float representing the angle between pt1 and pt2
    """
    ## pt2 X value - pt1 X value
    x_diff = pt2[0] - pt1[0]
    ## pt2 Y value - pt1 Y value
    y_diff = pt2[1] - pt1[1]

    return math.degrees(math.atan2(y_diff, x_diff))

## start and end points of chainage tick
## get the first end point of a tick
def getLineEnds(pt, bearing, dist, end_type="END"):
    """
    Get the start/end point of the chainage tick

    Args:
        pt1         a tuple containing x,y coordinates
        bearing     float representing an angle between two points on the line
        dist        distance along the bearing where we want to locate the point

    Returns:
        A tuple representing x,y coords of start/end of chainage line
    """

    if end_type == "START":
        bearing = bearing + 90

    ## convert to radians
    bearing = math.radians(bearing)
    ## some mathsy stuff that I dont have a clue about
    x = pt[0] + dist * math.cos(bearing)
    y = pt[1] + dist * math.sin(bearing)

    return (x, y)

## make the chainage tick
def makeTick(chainage_pt, angle_pnt):

    ## first point on the line and second
    angle = getAngle(chainage_pt, angle_pnt)

    ## get the start point of the chainage tick to create
    line_start = getLineEnds(chainage_pt, angle, tick_length/2, "START")

    ## angle required to construct tick perpendicular to alignment
    tick_angle = getAngle(line_start, chainage_pt)

    ## get end point of tick
    line_end = getLineEnds(line_start, tick_angle, tick_length)

    ## create tick based on start and end point
    tick = arcpy.Polyline(arcpy.Array(arcpy.Point(*coords) for coords in [line_start, line_end]))

    ## return the geometry
    return tick

Now we dive into the main workflow by using the Generate Points Along Lines tool. The resulting feature class is held in the memory workspace. The attribute table will hold a field names ORIG_LEN which is the chainage value at each point and and ORIG_FID field which is the FID of the linear feature that the points were generated for.

chainage_points = arcpy.management.GeneratePointsAlongLines(in_features,
                    "memory\\chainage_pts", pt_placement, distance, "",
                    incl_ends, add_chainage_flds
)

Next, we create a linear feature class in the memory workspace that we will add the tick records to. If the unique_fld is used by the user as input we will add it to the our feature class, if it is not used, we will use ORIG_FID as the field. And of course we need a field to store our chainage values for each tick created.

## in memory feature class to hold the points
temp_fc = arcpy.arcpy.management.CreateFeatureclass("memory", "temp_fc", "POLYLINE", spatial_reference=srs_id)

## if a unique_fld is set add it to the feature class
if unique_fld:
    fld_type, fld_length = [[fld.type, fld.length] for fld in arcpy.ListFields(in_features) if fld.name == unique_fld][0]
    arcpy.management.AddField(temp_fc, unique_fld, fld_type, field_length=fld_length)
## otherwise add ORIG_FID
else:
    arcpy.management.AddField(temp_fc, "ORIG_FID", "LONG")
    unique_fld = "ORIG_FID"

## add a field to store the chainage values
arcpy.management.AddField(temp_fc, "chainage", "LONG", field_alias="Chainage")

We are at the nerve centre of the workflow now. We get a list of all the FIDs from the original in_features. We then start an insert cursor and iterate through each FID. With each iteration we are adding an entry to a dictionary to represent each point. The key is the chainage value and the value is a tuple containing the X and Y value for the point. We need to iterate through the chainage values sequentially so we create a sorted list of values from the dictionary keys. Iterating through each chainage value we use the information for the current point and the next point to set off our functions and create each chainage tick. We need to account for the last point that it does not have a point after it so we use the point before it.

## get a list of FIDs used for the in_features (from the points created)
unique_fids = sorted(set(row[0] for row in arcpy.da.SearchCursor(chainage_points, "ORIG_FID")))

## use an insert cursor
with arcpy.da.InsertCursor(temp_fc, [unique_fld, "chainage", "SHAPE@"]) as cursor:
    ## for each FID
    for fid in unique_fids:
        ## get all points for the linear feature as a dictionary ORIG_LEN : (x,y)
        chainage_dict = {int(row[0]):(row[1].centroid.X, row[1].centroid.Y) for row in arcpy.da.SearchCursor(chainage_points, ["ORIG_LEN", "SHAPE@", "ORIG_FID"]) if row[2]==fid}

        ## get a sorted list of chainage values
        chainage_list = sorted(chainage_dict.keys())

        ## get the unique value or FID for the chainage tick
        unique_value = list(set(row[0] for row in arcpy.da.SearchCursor(chainage_points, [unique_fld, "ORIG_FID"]) if row[1]==fid))[0]

        ## get the max chainage value as an integer
        max_chainage = int(max(chainage_dict.keys()))

        ## for each chainage value (starting at 0)
        for num, chainage in enumerate(chainage_list):
            ## if the chainage is all but the last one
            if num >= 0 and num < len(chainage_list) - 1:
                ## create the tick line
                tick_line = makeTick(chainage_dict[chainage], chainage_dict[chainage_list[num+1]])
                ## and insert into the memory feature class
                cursor.insertRow((unique_value, chainage, tick_line))

            ## if it is the last chainage
            elif num+1 == len(chainage_list):
                ## we use the point before to create the tick
                tick_line = makeTick(chainage_dict[max_chainage], chainage_dict[chainage_list[-2]])
                ## and insert into the memory feature class
                cursor.insertRow((unique_value, max_chainage, tick_line))

Now that the heavy lifting is complete we export the memory linear feature class and save to disk.

arcpy.conversion.ExportFeatures(temp_fc, out_feature_class)

And as always, we clean up the memory workspace.

arcpy.management.Delete(temp_fc)
arcpy.management.Delete(chainage_points)

Save your script and open up ArcGIS Pro. Right-click on your toolbox/toolset of choice and select New > Script. The New Script window will appear. In the General tab set Name to createChainageTicksAlongLinesLabel to Create Chainage Ticks Along Lines, and the Description to Create chainage ticks along lines at a user specified distance.

In the Parameters tab set as per below. Set the Filter for the Input Features parameter to Polyline. Also set the Filter for the Output Feature Class parameter to Polyline and the Direction to Output. For the Distance parameter set the Type to Optional and the Default to 10 METERS. For the Name Field parameter set the Type to Optional and the Dependency to in_features, set the Filter to Short, Long, Float, Double, and Text fields only. And lastly, for the Tick Length parameter, set the Type to Optional and the Default to 10.

In the Execution tab, click the folder icon in the top-right corner and add your saved Python script.

You can download the tool and other custom tools over on this page.

Here’s the script above in its entirety.

import arcpy
import math

################################################################################
## Esri Documentation
##  https://pro.arcgis.com/en/pro-app/latest/tool-reference/data-management/generate-points-along-lines.htm
##  https://pro.arcgis.com/en/pro-app/latest/arcpy/functions/getparameterastext.htm
##  https://pro.arcgis.com/en/pro-app/latest/arcpy/functions/describe.htm
##  https://pro.arcgis.com/en/pro-app/latest/arcpy/functions/listfields.htm
##  https://pro.arcgis.com/en/pro-app/latest/tool-reference/data-management/add-field.htm
##  https://pro.arcgis.com/en/pro-app/latest/arcpy/classes/polyline.htm
##  https://pro.arcgis.com/en/pro-app/latest/arcpy/classes/array.htm
##  https://pro.arcgis.com/en/pro-app/latest/tool-reference/data-management/create-feature-class.htm
##  https://pro.arcgis.com/en/pro-app/latest/arcpy/data-access/searchcursor-class.htm
##  https://pro.arcgis.com/en/pro-app/latest/arcpy/data-access/updatecursor-class.htm
##  https://pro.arcgis.com/en/pro-app/latest/tool-reference/conversion/export-features.htm
##  https://pro.arcgis.com/en/pro-app/latest/tool-reference/data-management/delete.htm
##
## ArcGIS Pro Version 3.1.0
##
#################################################################################

#################################################################################
## USER INPUTS

## input linear feature class
in_features = arcpy.GetParameterAsText(0)
## output feature class containg the ticks
out_feature_class = arcpy.GetParameterAsText(1)
## the distance each tick is to be along the line
distance = arcpy.GetParameterAsText(2)
## a unique identifier to separate line records
unique_fld = arcpy.GetParameterAsText(3)
## legth of tick traverse, 50% each side of the line
tick_length = arcpy.GetParameterAsText(4)

################################################################################
## REQUIRED OBJECTS

## required for running the GeneratePointsAlongLines tool
pt_placement = "DISTANCE"
incl_ends = "END_POINTS"
add_chainage_flds = "ADD_CHAINAGE"

## convert to integers
tick_length = int(tick_length)
distance = int(distance.split(" ")[0])

## srs id of the in_features so we can assign the output the same
srs_id = arcpy.Describe(in_features).spatialReference.factoryCode

################################################################################
## FUNCTIONS ###################################################################

## http://wikicode.wikidot.com/get-angle-of-line-between-two-points
def getAngle(pt1, pt2):
    """
    Get the angle between two points

    Args:
        pt1     a tuple containing x,y coordinates
        pt2     a tuple containing x,y coordinates

    Returns:
        A float representing the angle between pt1 and pt2
    """
    ## pt2 X value - pt1 X value
    x_diff = pt2[0] - pt1[0]
    ## pt2 Y value - pt1 Y value
    y_diff = pt2[1] - pt1[1]

    return math.degrees(math.atan2(y_diff, x_diff))

## start and end points of chainage tick
## get the first end point of a tick
def getLineEnds(pt, bearing, dist, end_type="END"):
    """
    Get the start/end point of the chainage tick

    Args:
        pt1         a tuple containing x,y coordinates
        bearing     float representing an angle between two points on the line
        dist        distance along the bearing where we want to locate the point

    Returns:
        A tuple representing x,y coords of start/end of chainage line
    """

    if end_type == "START":
        bearing = bearing + 90

    ## convert to radians
    bearing = math.radians(bearing)
    ## some mathsy stuff that I dont have a clue about
    x = pt[0] + dist * math.cos(bearing)
    y = pt[1] + dist * math.sin(bearing)

    return (x, y)

## make the chainage tick
def makeTick(chainage_pt, angle_pnt):

    ## first point on the line and second
    angle = getAngle(chainage_pt, angle_pnt)

    ## get the start point of the chainage tick to create
    line_start = getLineEnds(chainage_pt, angle, tick_length/2, "START")

    ## angle required to construct tick perpendicular to alignment
    tick_angle = getAngle(line_start, chainage_pt)

    ## get end point of tick
    line_end = getLineEnds(line_start, tick_angle, tick_length)

    ## create tick based on start and end point
    tick = arcpy.Polyline(arcpy.Array(arcpy.Point(*coords) for coords in [line_start, line_end]))

    ## return the geometry
    return tick

################################################################################
## GENERATE POINTS ALONG LINES #################################################

chainage_points = arcpy.management.GeneratePointsAlongLines(in_features,
                    "memory\\chainage_pts", pt_placement, distance, "",
                    incl_ends, add_chainage_flds
)

################################################################################
## CREATE TEMP POLYLINE FEATURE CLASS IN MEMORY ################################

## in memory feature class to hold the points
temp_fc = arcpy.arcpy.management.CreateFeatureclass("memory", "temp_fc", "POLYLINE", spatial_reference=srs_id)

## if a unique_fld is set add it to the feature class
if unique_fld:
    fld_type, fld_length = [[fld.type, fld.length] for fld in arcpy.ListFields(in_features) if fld.name == unique_fld][0]
    arcpy.management.AddField(temp_fc, unique_fld, fld_type, field_length=fld_length)
## otherwise add ORIG_FID
else:
    arcpy.management.AddField(temp_fc, "ORIG_FID", "LONG")
    unique_fld = "ORIG_FID"

## add a field to store the chainage values
arcpy.management.AddField(temp_fc, "chainage", "LONG", field_alias="Chainage")

################################################################################
## GENERATE TICKS ##############################################################

## get a list of FIDs used for the in_features (from the points created)
unique_fids = sorted(set(row[0] for row in arcpy.da.SearchCursor(chainage_points, "ORIG_FID")))

## use an insert cursor
with arcpy.da.InsertCursor(temp_fc, [unique_fld, "chainage", "SHAPE@"]) as cursor:
    ## for each FID
    for fid in unique_fids:
        ## get all points for the linear feature as a dictionary ORIG_LEN : (x,y)
        chainage_dict = {int(row[0]):(row[1].centroid.X, row[1].centroid.Y) for row in arcpy.da.SearchCursor(chainage_points, ["ORIG_LEN", "SHAPE@", "ORIG_FID"]) if row[2]==fid}

        ## get a sorted list of chainage values
        chainage_list = sorted(chainage_dict.keys())

        ## get the unique value or FID for the chainage tick
        unique_value = list(set(row[0] for row in arcpy.da.SearchCursor(chainage_points, [unique_fld, "ORIG_FID"]) if row[1]==fid))[0]

        ## get the max chainage value as an integer
        max_chainage = int(max(chainage_dict.keys()))

        ## for each chainage value (starting at 0)
        for num, chainage in enumerate(chainage_list):
            ## if the chainage is all but the last one
            if num >= 0 and num < len(chainage_list) - 1:
                ## create the tick line
                tick_line = makeTick(chainage_dict[chainage], chainage_dict[chainage_list[num+1]])
                ## and insert into the memory feature class
                cursor.insertRow((unique_value, chainage, tick_line))

            ## if it is the last chainage
            elif num+1 == len(chainage_list):
                ## we use the point before to create the tick
                tick_line = makeTick(chainage_dict[max_chainage], chainage_dict[chainage_list[-2]])
                ## and insert into the memory feature class
                cursor.insertRow((unique_value, max_chainage, tick_line))

################################################################################
## SAVE TO DISK ################################################################

arcpy.conversion.ExportFeatures(temp_fc, out_feature_class)

################################################################################
## CLEAN-UP MEMORY WORKSPACE ###################################################

arcpy.management.Delete(temp_fc)
arcpy.management.Delete(chainage_points)

Leave a Comment

Your email address will not be published. Required fields are marked *