Hello, I'm hoping that someone smarter than me can possibly assist me devising a code snippet to generate points along a line (specifically a "route" line; a line with M values) at a given interval (100 ft). I am basically trying to replicate the labeling from "route" line layer that is visible in ArcGIS Pro using point features so that I may use the point features in ArcGIS Online (as it seems M aware features/functionality in AGOL are not supported).
I know the GP tool "Generate Points Along Line" currently performs this action and works well for a portion of my data where the initial M value (stationing) is a whole number divisible by 100 (EX: 41700, 86000, etc.). Where the issue occurs though is when the initial M value (stationing) is NOT divisible by 100 (41618, 87224, etc.). Using the GP tool causes an offset from the desired "100 ft interval" due to the initial M value (stationing) not starting at a value divisible by 100. Below screenshot was created using existing GP tool.
Basically, when the initial M value is divisible by 100, sequence would read for example: 41700, 41800, 41900, etc. But, when the initial M value is NOT divisible by 100, sequence would read for example: 41618, 41718, 41818, etc. How can I create points at the beginning and end of line as well as at every 100ft interval where the M value (stationing) of the point is divisible by 100. For example ideal output stationing: 41618, 41700, 41800, 41900,...,45000,45100,45121.
Hi @KevinPiraino2,
Maybe this might help. Have a read through and let me know: Create Chainage Ticks Along a Line Using ArcPy and ArcGIS Pro
All the best,
Glen
I've had to do something similar before and usually use the Polyline.positionAlongLine method. Here's an example of a generator/iterator based approach to grabbing those point measures:
from collections.abc import Generator, Iterator
import arcpy
from pathlib import Path
# NOTE:
# This code is meant to utilize the lazy iteration of both Cursors and Python Generator expressions
# To prevent overutilization of memory during the measure generation process. Since all points are
# generated lazily, at no point is there more than one Polyline and one measure point loaded into memory
# This isn;t strictly necessary, but can be helpful if you end up using this on a dataset with say, millions
# of line features (e.g. the Census TIGER dataset)
def _get_line_measures(
line: arcpy.Polyline,
measure: int,
include_start: bool,
include_end: bool) -> Iterator[arcpy.PointGeometry | None]:
"""Internal helper for handling per-line measures
Will yield measures for all polyline points along with an optional start and end
"""
m = 0
if line.length == 0:
return None
if include_start:
yield arcpy.PointGeometry(line.firstPoint, line.spatialReference)
for m in range(0, int(line.length), measure):
yield line.positionAlongLine(m)
if include_end and m != line.length:
yield arcpy.PointGeometry(line.lastPoint, line.spatialReference)
def get_measure_points(
lines: Path|str, measure: int, ref: arcpy.SpatialReference|None=None, *,
include_start: bool=True,
include_end: bool=True) -> Iterator[tuple[int, Iterator[arcpy.PointGeometry | None]]]:
"""Get all measure points for a given set of line features
Args:
lines (Path|str): A workspace path to the lines you want measure points for
measure (int): The base measure between ticks in ref units (ref or line units)
ref (SpatialReference): The reference to use for the measurement (default: line feature units)
include_start (bool): include the exact end point of the line
include_end (bool): Include the exact start poit of the line
"""
lines = str(lines) # Get Pathstring if Path is passed
with arcpy.da.SearchCursor(lines, ['OID@', 'SHAPE@'], spatial_reference=ref) as cur:
for oid, line in cur:
# Reveal types
oid: int
line: arcpy.Polyline
if isinstance(line, arcpy.Polyline): # Ensure Polyline type
yield (oid, _get_line_measures(line, measure, include_start, include_end))
else: # Don't add measures to non-Polyline objects, raise Value Error
raise ValueError(
f'{lines} is of type {type(line)} and is supposed to be Polyline'
)
def main():
line_feats = r'path/to/line/feats'
point_feats = r'path/to/point/feats'
with arcpy.da.InsertCursor(point_feats, ['AssocLine', 'SHAPE@']) as cur:
for oid, points in get_measure_points(line_feats, 100):
for point in points:
if point is None: # Capture None sentinel from _get_line_measures
print(f'Line {oid} is of length 0 and has no valid measures!')
else:
cur.insertRow([oid, point])
if __name__ == '__main__':
main()
I usually try and utilize lazy iteration when possible to prevent using a huge amount of memory to store a ton of Python objects. Here's a super simplified version that uses the same business logic, but is a single function:
def get_measures(in_lines: str, out_points: str, measure: int=100):
with(
arcpy.da.SearchCursor(in_lines, ['OID@', 'SHAPE@']) as line_cur,
arcpy.da.InsertCursor(out_points, ['LineID', 'SHAPE@']) as point_cur
):
for oid, line in line_cur:
oid: int
line: arcpy.Polyline
fp = arcpy.PointGeometry(line.firstPoint, line.spatialReference)
lp = arcpy.PointGeometry(line.lastPoint, line.spatialReference)
point_cur.insertRow((oid, fp))
for m in range(0, line.length, measure):
point_cur.insertRow((oid, line.positionAlongLine(m)))
point_cur.insertRow((oid, lp))
General idea is to treat the start and end separately from the measures. The for m in range(...) bit handles the truncated measures, starting at the first 100 units from the start point, then ending at the last evenly divisible measure from the end.
This operation is wrapped with adding the start and end points without worrying about their measure. From here you can use the ArcPro measure along line tool to transfer the M values of the line to the evenly spaced points.
Addendum:
This will give you evenly spaced lines using line measure (0-line.length units). If you want to access the lines starting m value, you can grab the M of the first point, then use a modulo operation to get the offset from an even measure (line.firstPoint.M % measure) and use that as your starting point instead of 0 which is just the first point of the line.
@HaydenWelch, thanks for the reply. Unfortunately, neither code block was the solution to my problem; I also tried your comment from the addendum and this also was not the solution. Below are two screenshots that show the results of the suggestions you provided.
The first screenshot shows the results of the code block without the addendum suggestion (both the longer one and the simplified one). The second screenshot is with the added addendum suggestion. The red labeling in both screenshots is the "Measure" or M value's as derived from the line for each point while the black labeling M values needed. Basically the points need to be exactly in the location of the lines labeled station points/tick marks.
As you can see from the first screenshot, just the code block as is (no addendum / modulo), the created points do not fall at the correct interval and are instead at 100 ft intervals starting from the 1st point. The second shot shows that the included addendum gets closer to the solution, but still does not get the exact measures/locations needed (see red labeling vs. black labeling).
From my own testing and python hacking, I originally came to the same conclusion as you regarding the use of the modulo (line.firstPoint.M % measure), but ultimately the results of my own python code block were about the same as the second screenshot. What I was finding though was after returning the first point after the starting point that has an M value divisible by 100 (use the modulo method), the value needed to measure along line (use positionAlongLine function; line 12 and 13) did NOT find a point at the location on the line where the stationing / M value was a value divisible by 100 even though the "dist_to_meas" value + firstPoint.M == M value divisible by 100 (EX: 41700). I'm not sure if its a geometry thing or spatial reference/coordinate system thing, but I couldn't figure out the correct method to find the "dist_to_meas" value to use in the positionAlongLine function that would return a point at the exact station / M value needed.
My Code Block:
#lines_data --> list/array of line data as pulled using SearchCursor
for line in lines_data:
pts_array = [] #empty array to store points
line_geo = line[-1] #get line geometry
firstPoint = line_geo.firstPoint #get first point in line
firstPoint_M = firstPoint.M #get first point station val
lastPoint = line_geo.lastPoint
lastPoint_M = lastPoint.M
pts_array.append(firstPoint)
pts_array.append(lastPoint)
#needs to be exact
dist_to_meas = 100 - (firstPoint_M % 100) #find distance till next station divisible by 100
nextPt = line_geo.positionAlongLine(dist_to_meas,False) #returns point geometry
pts_array.append(nextPt[0])
interval = nextPt[0].M #starting measure
#print(interval)
while interval < lastPoint_M:
interval += 100
dist_to_meas += 100
#print(interval)
nxtPt = line_geo.positionAlongLine(dist_to_meas,False)
pts_array.append(nxtPt[0])Code Block as-is:
Code Block w/ Addendum (modulo):
You just have a rounding error with the addendum code. Python modulo keeps the fraction, so if you cast the start to an int it should get you to the exact measure.
>>> 123.456 % 100
23.456000000000003
>>> int(123.456 % 100)
23
I'm not sure that's necessarily the issue though. I believe the issue I am running into is due to the fact the first point's M value is a float value and not an int (whole) number; the first point's M value in the polyline is 41617.703125. The next nearest M value along the line divisible by 100 should be found using the following method:
Is there a level of imprecision that this geometry function has baked in? Are their differences between what an M value is and what is returned by positionAlongLine function (i.e. can't use the return of the modulo as its calculated based on M value as the "measure" distance value in the function)?
Using your suggestion of casting the return from the modulo line from float int did not resolve the issue. The updated function below:
#AGOL community post function:
def get_measures(in_lines: str, out_points: str, measure: int=100):
with(
arcpy.da.SearchCursor(in_lines, ['OID@', 'SHAPE@']) as line_cur,
arcpy.da.InsertCursor(out_points, ['OID@', 'SHAPE@']) as point_cur
):
for oid, line in line_cur:
oid: int
line: arcpy.Polyline
#name = line[1]
fp = arcpy.PointGeometry(line.firstPoint, line.spatialReference)
lp = arcpy.PointGeometry(line.lastPoint, line.spatialReference)
point_cur.insertRow((oid, fp))
first_meas = 100 - int((line.firstPoint.M % measure))
for m in range(int(first_meas), int(line.length), measure):
point_cur.insertRow((oid, line.positionAlongLine(m)))
point_cur.insertRow((oid, lp))The results:
Are you sure that both features are in the same coordinate system? You can get errors if you're using Planar functions in a Geodesic and vice versa. You can set the position to be calculated along the geodesic by using the positionAlongLine(.., geodesic=True) flag.
Depending on the orientation of the GCS, your error will vary based on bearing of the lines as well. Seems like the minute variation you're getting could be a small curvature error along the system latitude.
Hayden,
Yes, I checked both the coordinate systems and they are the same. I also tested the geodesic parameter and that also did not resolve the issue (it didn't seem to affect the result in any meaningful way; not sure if it's a scale thing).
I'm also not sure if it's a precision thing between what is stored in the geodatabase and what is being read by python. When attempting alter a point's M value from 41617.703125 to 41618.0 using the regular GP tools, the M value will read the appropriate whole number, but when querying the point via arcpy, it will return a floating point number (e.g. 41617.99328513).
In any case, I was able to build a workaround for this issue by creating an Event Table, populating it with the routes and M values needed, and then using the GP tool "Make Route Event Layer". This created the necessary points along each route at the intended locations. My unfamiliarity with the available linear referencing GP tools in ArcGIS Pro caused me to not read into it deeply enough.
Glad you got a working solution! One last question, now that you've said the GP tool worked, is your map in a different system from your features? e.g. the features are in WGS84 and the map is in a projected system?
When using GP tools on a layer, the map coordinate system is taken into account, while raw data manipulation will use the feature system by default. If that was the case you could solve the issue by setting the Cursor spatial references to the map system and projection should be handled automatically.
Coordinate systems can be a massive pain to deal with when you're operating on datasets with different base projections, so I try to always write my scripts to use the active map's system for all spatial geometry operations since it reduces the headaches of units changing around and small floating point errors on systems that are close but different.
Hayden,
Nope, both map and data are in the exact same coordinate system (NAD 1983 StatePlane Illinois East FIPS 1201 (US Feet); WKID 3435). Everything related to this project was in the same coordinate system. Again, I'm not entirely sure what the issue is, it appears on the surface to be how the Desktop GUI software is interpreting certain values vs. what is either stored in GDB and accessed by python or how the python interface is interpreting the data.
Basically the Desktop GUI will read and write the whole number 41618.00, but the python interface interprets that as 41617.99235 either because that's what is stored in the GDB / geometry or something else entirely.