I think I understand what you're after. I reworked some of the code internals to simplify things, so I'd try copying this version, rather than updating anything you're currently using (unless you're comfortable doing that). The outputs should be the same, with the addition of the new label field. I'm using a label expression to show the "point_id" + "quadrant" labels.
This takes the angle of the line (say, 45 degrees) and then assigns the quadrant (NE). It then inverts that to get the opposing quadrant (SE). The code is set up so that if the line is perfectly north-south or east-west (90 degrees, 180 degrees) then those labels will be "N" and "S" or "W" and "E", without the secondary direction. Let me know if something doesn't work!
Here's an W-E example, which I suppose would only occur... ~1% of the time?
"""
RANDOM ANGLE TRANSECT LINES CENTERED ON SAMPLING POINTS
"""
#-----------------------------------------------------------------------------#
# IMPORTS
import math
import os
import random
import sys
from typing import Literal, Union, Tuple
import arcpy
#-----------------------------------------------------------------------------#
# INPUTS
# Full path to the geodatabase where the sampling points are, and where outputs will go.
workspace = r"your_full_path\geodatabase_name.gdb"
# Name of the point feature class in the above geodatabase that houses the
# sampling points.
in_pnt_fc = "_SAMPLE_POINT"
# Distance between sampling points (along x and y, not diagonals).
# Units match coordinate system.
point_spacing = 400 # feet, in my case
box_width = 80
# NORTH_BOX: squares will be oriented to grid-north
# NORTH_DIAMOND: diamonds will be oriented to grid-north
# ANGLE_BOX: square sides oriented to the tranect lines
# ANGLE_DIAMOND: diamonds angled to the transect lines
box_rotation = 'ANGLE_BOX' # OPTIONS: (NORTH_BOX, NORTH_DIAMOND, ANGLE_BOX, ANGLE_DIAMOND)
# Name of the output feature class to be created.
fc_line = "TRANSECT_LINE"
fc_endpoints = "TRANSECT_LINE_ENDPOINTS"
fc_box = f"TRANSECT_{box_rotation}"
# -----------------------------------------------------------------------------#
# SETTINGS
# This just overwrites previous outputs to help with testing.
arcpy.env.overwriteOutput = True
#-----------------------------------------------------------------------------#
# FUNCTIONS
def rotate_point(coordinate: tuple[int, int],
pivot_point: tuple[int, int],
angle: Union[int, float],
angle_format: Literal['DEGREES', 'RADIANS']='DEGREES'
) -> tuple[float, float]:
"""Rotates coordinate values around another point.
:coordinate: (x, y) coordinate pair to be rotated
:pivot_point: (x, y) coordinate pair around which point will be rotated
:angle: angle to rotate the coordinate
:angle_format: angle format, RADIANS or DEGREES
|(x_prime, y_prime)| rotated point coordinates"""
if angle_format == 'DEGREES':
angle = math.radians(angle)
x, y = coordinate
X, Y = pivot_point
x_trans = x - X
y_trans = y - Y
sin = math.sin(angle)
cos = math.cos(angle)
# Counter-clockwise rotation:
x_transprime = cos * x_trans - sin * y_trans
y_transprime = sin * x_trans + cos * y_trans
x_prime = x_transprime + X
y_prime = y_transprime + Y
# print(f"XY TRANS: {x_trans:,.0f}, {y_trans:,.0f}\n"
# f"XY TRANSPRIME: {x_transprime:,.0f}, {y_transprime:,.0f}\n"
# f"XY PRIME: {x_prime:,.0f}, {y_prime:,.0f}\n")
return (x_prime, y_prime)
def create_box(method: Literal['NORTH_BOX', 'NORTH_DIAMOND', 'ANGLE_BOX', 'ANGLE_DIAMOND'],
center_pnt: arcpy.Point,
width: Union[int, float],
angle: Union[int, float],
spat_ref: int) -> arcpy.Polygon:
"""Create a square box around a given point. Optionally rotate it.
:center_pnt: centroid of the square box
:width: width of side of box
:angle: angle the box should be rotated
|box_geom| geometry object representing the box"""
angle_dict = {'NORTH_BOX': 45,
'NORTH_DIAMOND': 0,
'ANGLE_BOX': angle + 45,
'ANGLE_DIAMOND': angle}
# Distance to move point based on known hypotenuse (1/2 width of box).
offset = math.sqrt(2 * ((width / 2) ** 2))
x, y = center_pnt.X, center_pnt.Y
Nx, Ny = x, y + offset
Ex, Ey = x + offset, y
Sx, Sy = x, y - offset
Wx, Wy = x - offset, y
box_corners = []
for coordinate in [(Nx, Ny), (Ex, Ey), (Sx, Sy), (Wx, Wy), (Nx, Ny)]:
box_corners.append(rotate_point(coordinate=coordinate,
pivot_point=(x, y),
angle=angle_dict[method],
angle_format='DEGREES'))
pnt_array = arcpy.Array([arcpy.Point(*corner) for corner in box_corners])
box_geom = arcpy.Polygon(pnt_array, spatial_reference=spat_ref)
return box_geom
def get_angle_quadrant(a):
"""As previously defined, angle resctricted [1-360]."""
# Check for 90 degree cardinal directions.
if a == 90:
return "N"
elif a == 180:
return "W"
elif a == 270:
return "S"
elif a == 360:
return "E"
# Determine non-90 quadrants.
if 0 < a < 90:
return "NE"
elif 90 < a < 180:
return "NW"
elif 180 < a < 270:
return "SW"
elif 270 < a < 360:
return "SE"
else:
raise ValueError("Angle value must be numeric and between 1-360, inclusive.")
#-----------------------------------------------------------------------------#
# MAIN
in_points = os.path.join(workspace, in_pnt_fc)
transect_lines = []
sample_box_dict = {}
pnt_desc = arcpy.da.Describe(in_points)
pnt_oid_fld = pnt_desc['OIDFieldName']
pnt_spat_ref = pnt_desc['spatialReference'].factoryCode
with arcpy.da.SearchCursor(in_points, [pnt_oid_fld, 'SHAPE@XY']) as scurs:
for oid, (x, y) in scurs:
# Random angle in degrees, 1-360; convert to radians.
angle_deg = random.randint(1, 360)
angle_rad = math.radians(angle_deg)
print(f"POINT {oid}, ANGLE: {angle_deg}")
# Get x/y offset of random point on the imaginary circle around each point.
start_x = (point_spacing/2) * math.cos(angle_rad)
start_y = (point_spacing/2) * math.sin(angle_rad)
# Using the sample point coordinate as the starting point,
# calculate real-world x and y of both start and end of transect line.
start_point = arcpy.Point(x + start_x, y + start_y)
end_point = arcpy.Point(x - start_x, y - start_y)
# -S/-E suffixes differentiate start/end boxes. Not currently used, but
# documented just in case (also, unique keys are required).
sample_box_dict[f'{oid}-S'] = create_box(box_rotation, start_point,
box_width, angle_deg, pnt_spat_ref)
sample_box_dict[f'{oid}-E'] = create_box(box_rotation, end_point,
box_width, angle_deg, pnt_spat_ref)
# Create a Polyline Geometry Object. Append dict entry to list.
new_line = arcpy.Polyline(arcpy.Array([start_point, end_point]))
transect_lines.append({'OID': oid, 'GEOM': new_line, 'ANGLE': angle_deg})
# Create three feature classes.
for fc, geom in [(fc_line, 'POLYLINE'), (fc_endpoints, 'POINT'), (fc_box, 'POLYGON')]:
arcpy.management.CreateFeatureclass(out_path=workspace, out_name=fc,
geometry_type=geom, spatial_reference=pnt_spat_ref)
print(f"CREATED FC {fc}")
# FC paths.
ln_fc = os.path.join(workspace, fc_line)
pt_fc = os.path.join(workspace, fc_endpoints)
box_fc = os.path.join(workspace, fc_box)
# Add and ID field to the points, the lines, and box FCs.
for fc in (ln_fc, pt_fc, box_fc):
arcpy.management.AddField(fc, 'POINT_OID', 'SHORT')
# Add angle field to the line FC.
arcpy.management.AddField(ln_fc, 'LINE_ANGLE', 'SHORT')
# Add the point type and the point angle to the point FC.
arcpy.management.AddField(pt_fc, 'POINT_TYPE', 'TEXT', field_length=5)
arcpy.management.AddField(pt_fc, 'POINT_QUAD', 'TEXT', field_length=5)
print("ALL FIELDS ADDED")
# Write transect line features----------------------------------#
with arcpy.da.InsertCursor(ln_fc, ['SHAPE@', 'POINT_OID', 'LINE_ANGLE']) as icurs:
for feature_dict in transect_lines:
icurs.insertRow([feature_dict['GEOM'],
feature_dict['OID'],
feature_dict['ANGLE']])
print("TRANSECTS WRITTEN")
# Write endpoint features----------------------------------#
invert_quad = {"NE": "SW", "SE": "NW", "SW": "NE", "NW": "SE",
"N": "S", "S": "N", "E": "W", "W": "E", }
with arcpy.da.InsertCursor(pt_fc, ['SHAPE@', 'POINT_OID', 'POINT_TYPE', 'POINT_QUAD']) as icurs:
for feature_dict in transect_lines:
start_pnt = feature_dict['GEOM'].firstPoint
end_pnt = feature_dict['GEOM'].lastPoint
angle = feature_dict['ANGLE']
quadrant_1 = get_angle_quadrant(angle)
quadrant_2 = invert_quad[quadrant_1]
icurs.insertRow([start_pnt, feature_dict['OID'], "START", quadrant_1])
icurs.insertRow([end_pnt, feature_dict['OID'], "END", quadrant_2])
print("ENDPOINTS WRITTEN")
# Create Sampling Zones----------------------------------#
# Transect Line Oriented
with arcpy.da.InsertCursor(box_fc, ['SHAPE@', 'POINT_OID']) as icurs:
for oid, geom in sample_box_dict.items():
icurs.insertRow([geom, oid.split('-')[0]])
print("BOXES WRITTEN")