Source code for linref.route

"""
===============================================================================

Developed by Tariq Shihadah
tariq.shihadah@gmail.com

Created:
10/22/2019

Modified:
4/6/2022

===============================================================================
"""


################
# DEPENDENCIES #
################


from xml.dom.minidom import Attr
import numpy as np
from shapely.geometry import LineString, MultiLineString
import shapely
from rangel import RangeCollection
import copy, math


#############
# MLS ROUTE #
#############

[docs]class MLSRoute(object): """ An object class to manage route mile-post information for each vertex of a shapely MultiLineString (MLS). An MLSRoute object instance will be able to convert between the actual linear distance along an MLS (starting from the origin of the MLS and moving downstream along the line, ignoring spaces between individual LineStrings) to the route distance along the MLS based on provided route break information. Parameters ---------- mls : shapely MultiLineString The linear geometry being represented by the route object. rte_breaks : list of lists of numerical values Numerical information representing the route distance values at each vertice of the route MultiLineString. To include breaks in route values, input a separate list for each contiguous group of vertices. Each list should have a number of elements equal to the number of vertices in each LineString contained in the MultiLineString. If rte_breaks is used, rte_ranges should not be input. rte_ranges : list of tuples of numerical values Numerical information representing the start and end distance values for each LineString in the MultiLineString. If rte_ranges is used, rte_breaks will be automatically computed and should not be input. closed : str {'left', 'right', 'both', 'neither'}, default 'both' Whether intervals are closed on the left-side, right-side, both or neither. """ # Define class options _closed_ops = {'left', 'right', 'both', 'neither'} def __init__(self, mls, rte_breaks=None, rte_ranges=None, closed='both', **kwargs): # Validate input parameters self.closed = closed self.mls = mls self.rte_breaks = rte_breaks if rte_ranges is None else \ self._ranges_to_breaks(rte_ranges) def __len__(self): return self.rte_length def __str__(self): return self.wkt def __repr__(self): return self.wkt @property def mls(self): return self._mls @mls.setter def mls(self, obj): # Validate geometry if not isinstance(obj, MultiLineString): if isinstance(obj, LineString): obj = MultiLineString([obj]) else: raise TypeError( "Input geometry must be a shapely MultiLineString or " "LineString.") # Log geometry self._mls = obj self._wkt = None # Accumulate vector lengths to generate geometry breaks self._mls_breaks = \ np.concatenate([[0]] + self.element_lengths).cumsum() # Define geometry ranges rc = RangeCollection.from_breaks( breaks=self._mls_breaks, closed=self._closed) try: rc.ends[-1] = self.mls_length # Avoid rounding error except IndexError: pass self._mls_ranges = rc @property def wkt(self): try: assert self._wkt is not None return self._wkt except AssertionError: self._wkt = self.to_wkt() return self._wkt @property def mls_breaks(self): return self._mls_breaks @property def mls_ranges(self): return self._mls_ranges @property def rte_breaks(self): return self._rte_breaks @rte_breaks.setter def rte_breaks(self, data): # If no data, copy geometry breaks if data is None: self._rte_breaks = self._mls_breaks.copy() return # Check shape try: # Coerce np.ndarray data = [np.asarray(x) for x in data] # Check data length assert all(len(r)==len(l.coords) for \ r, l in zip(data, self._mls.geoms)) except: raise ValueError( "Route breaks data should be list of array-like of numeric " "values with lengths equal to the number of vertices in " "each corresponding line in the provided MultiLineString.") # Log data self._rte_breaks = data # Define M-value ranges self._rte_ranges = RangeCollection.from_breaks( breaks=data, closed=self._closed, sort=False) @property def rte_ranges(self): return self._rte_ranges @property def rte_length(self): return self.rte_ranges.total_length @property def mls_length(self): try: return self._mls_length except AttributeError: self._mls_length = self.mls.length return self._mls_length @property def num_lines(self): try: return self._num_lines except AttributeError: self._num_lines = len(self.mls.geoms) return self._num_lines @property def vertices(self): try: return self._vertices except AttributeError: self._vertices = [np.insert(x.cumsum(),0,0) \ for x in self.element_lengths] return self._vertices @property def element_lengths(self): try: return self._element_lengths except AttributeError: self._element_lengths = self._compute_element_lengths() return self._element_lengths @property def closed(self): return self._closed @closed.setter def closed(self, label): if not label in self._closed_ops: raise ValueError( f"Closed parameter must be one of {self._closed_ops}.") self._closed = label
[docs] @classmethod def from_2d_paths(cls, paths, **kwargs): """ Create MLSRoute instance from a list of paths, made up of a list of three-element tuples with X, Y, and range location (i.e., M-value). """ # Parse input paths into LineStrings and route breakpoint information lines = [] breaks = [] for path in paths: lines.append(LineString([(x[0], x[1]) for x in path])) breaks.append([x[2] for x in path]) # Return generated MLSRoute instance based on input paths return cls(MultiLineString(lines), rte_breaks=breaks, **kwargs)
[docs] @classmethod def from_lines(cls, lines, begs, ends, **kwargs): """ Create an MLSRoute instance from a list of LineStrings or a single MultiLineString and lists of begin and end mile post values with lengths equal to the number of LineStrings within the provided geometry. Parameters ---------- lines : MultiLineString, LineString, or list of either A collection of shapely linear geometries (LineStrings or MultiLineStrings) to use as the basis for the MLSRoute. begs : list of numeric values or a single numeric value A list of begin mile post values equal in length to the provided lines. This correlates to a single begin mile post value for each linear geometry in the provided collection. If a single mile post value is provided, begin mile post values for multiple lines will be linearly interpolated. ends : list of numeric values or a single numeric value A list of end mile post values equal in length to the provided lines. This correlates to a single end mile post value for each line in the provided collection. If a single mile post value is provided, end mile post values for multiple lines will be linearly interpolated. **kwargs Keyword arguments to be input in the MLSRoute constructor. """ # Single geometry provided # - LineString if isinstance(lines, LineString): full_lines = [MultiLineString([lines])] # - MultiLineString elif isinstance(lines, MultiLineString): full_lines = [lines] # List of geometries provided elif isinstance(lines, (list, tuple)): # - LineStrings if all(isinstance(i, LineString) for i in lines): full_lines = [MultiLineString(lines)] # - MultiLineStrings elif all(isinstance(i, MultiLineString) for i in lines): full_lines = lines else: raise ValueError( "Input lines must be all LineString or all " "MultiLineString shapely objects.") else: raise TypeError( "Input lines must be valid shapely linear geometries or list " "or tuple of the same. Provided lines are of type " f"{type(lines)}.") # Ensure valid breaks information provided # - Enforce list-data try: begs = list(begs) except TypeError: begs = [begs] try: ends = list(ends) except TypeError: ends = [ends] # Check for input type # - Single begin/end point provided if len(begs) == len(ends) == 1: full_lines = combine_mpgs(full_lines, cls=MultiLineString) distributed = \ _distribute_dimensions(full_lines, begs[0], ends[0]) ranges = np.stack([distributed[0], distributed[1]]).T # - One begin/end point per MultiLineString provided elif len(begs) == len(ends) == len(full_lines): begs_new = [] ends_new = [] for line, beg, end in zip(full_lines, begs, ends): distributed = \ _distribute_dimensions(line, beg, end) begs_new.extend(list(distributed[0])) ends_new.extend(list(distributed[1])) full_lines = combine_mpgs(full_lines, cls=MultiLineString) ranges = np.stack([begs_new, ends_new]).T # - One begin/end point per LineString provided elif len(begs) == len(ends) == sum(len(i.geoms) for i in full_lines): ranges = np.stack([begs, ends]).T full_lines = combine_mpgs(full_lines, cls=MultiLineString) # - Invalid input type else: raise ValueError( "Must provide a number of begin and end mile post values " "equal to the number of lines if providing multiple values. " f"Provided: {len(begs):,.0f} begs, {len(ends):,.0f} ends, " f"{len(mls.geoms):,.0f} lines.") # Return generated MLSRoute instance based on input parameters return cls(full_lines, rte_ranges=ranges, **kwargs)
[docs] @classmethod def from_wkt(cls, wkt, **kwargs): """ Create an MLSRoute instance from a WKT string for a MULTILINESTRING or LINESTRING with three to four dimensions, with the last dimension being interpreted as M values. Parameters ---------- wkt : str WKT string representing a MULTILINESTRING or LINESTRING with three to four dimensions, with the last dimension representing the geometry's M values. **kwargs Keyword arguments to be input in the MLSRoute constructor. """ # Simplify text and validate content if 'MULTILINESTRING' in wkt: simplified = wkt.split('((')[1] \ .replace('),',';').replace('(','').replace(')','') elif 'LINESTRING' in wkt: simplified = wkt.split('(')[1].replace(')','') else: raise ValueError( "Provided WKT must represent multilinestring or linestring " "data.") # Iterate through groups of coordinates to parse M values data = [] breaks = [] for coord_group in simplified.split(';'): # Iterate through individual coordinates in the group data_group = [] breaks_group = [] for coord in coord_group.split(','): try: coords = [float(x) for x in coord.strip().split(' ')] dims = len(coords) assert (dims > 2) & (dims < 5) except AssertionError: raise ValueError( "Input WKT must have between three and four " "dimensions.") data_group.append(coords[:-1]) breaks_group.append(coords[-1]) data.append(data_group) breaks.append(breaks_group) # Create MLSRoute return MLSRoute(MultiLineString(data), rte_breaks=breaks, **kwargs)
[docs] @classmethod def concatenate(cls, routes, **kwargs): """ Combine a list of MLSRoute objects into a single MLSRoute. NOTE: - MLSRoute objects will be concatenated in the order they are provided. - Behavior of this method under non-trivial conditions has not been tested. """ # Validate input try: for route in routes: assert isinstance(route, cls) except: raise TypeError( "Input routes must be list-like of MLSRoute class instances.") # Combine route breaks rte_breaks = \ [rte_break for route in routes for rte_break in route.rte_breaks] # Combine MLS geometries mls = combine_mpgs(\ [route.mls for route in routes], cls=MultiLineString) # Generate MLS Route instance mr = cls(mls, rte_breaks=rte_breaks, **kwargs) return mr
def _compute_element_lengths(self): """ Get individual lengths of each linear element of the route MultiLineString. """ # Store all vectors lengths = [] for line in self.mls.geoms: # Log vector length coords = line.coords lengths.append(np.asarray([LineString(coords[i-1:i+1]).length \ for i in range(1, len(coords))])) return lengths def _ranges_to_breaks(self, data): """ Convert begin and end range data to breaks for the generation of an MLSRoute instance. """ # Confirm valid input route values if not data is None: # Coerce as numpy array, check shape try: data = np.asarray(data) assert data.shape == (len(self._mls.geoms), 2) except: raise ValueError( "Input ranges should be array-like of numeric values with " "a shape of (n,2) where n equals the number of lines in " "the provided MultiLineString.") # Convert to breaks all_breaks = [] for lengths, rng in zip(self.element_lengths, data): try: delta = rng[-1] - rng[0] except IndexError: raise IndexError( "Input route ranges information must be provided as a " "list of tuples of start and end values.") lengths = lengths / lengths.sum() # Normalize lengths = (lengths.cumsum() * delta) + rng[0] lengths[-1] = rng[-1] # Snap last value to range bound all_breaks.append(np.concatenate([rng[0], lengths], axis=None)) return all_breaks
[docs] def to_wkt(self, decimals=None): """ Produce a WKT string representing the object with the underlying MultiLineString appended with M values represented in the rte_breaks property. """ # Define number formatter if decimals is None: fmt = lambda x: str(x) elif isinstance(decimals, int): fmt = lambda x: '{x:.{decimals:.0f}f}' \ .format(decimals=decimals, x=float(x)) else: raise ValueError("Decimals parameter must be an integer.") # Retrieve the base WKT for the multilinestring wkt = self.mls.wkt simplified = \ wkt.split('((')[1].replace('),',';').replace('(','').replace(')','') # Iterate through groups of coordinates to append M values data = [] zipped = zip(simplified.split(';'), self.rte_breaks) for coord_group, m_group in zipped: # Iterate through individual coordinates in the group data_group = [] for coord, m in zip(coord_group.split(','), m_group): point = ' '.join(fmt(x) for x in coord.strip().split(' ') + [m]) data_group.append(point) data.append('(' + ', '.join(data_group) + ')') # Determine WKT prefix prefix = wkt.split(' (')[0] if prefix[-1] == 'Z': prefix += 'M ' else: prefix += ' M ' # Return combined WKT string return prefix + '(' + ', '.join(data) + ')'
[docs] def copy(self, deep=False): """ Create an exact copy of the MLS route object instance. """ if deep: return copy.deepcopy(self) else: return copy.copy(self)
[docs] def locate_mls(self, loc, normalized=False, choose='first', bounded=False): """ Get the range index and the proportional distance along that range of the input MLS location. Parameters ---------- ... bounded : boolean, default False Whether to raise an error when the location information falls outside the minimum and maximum bounds of the MLS range. If False, negative loc values will be snapped to 0 and loc values greater than mls_length will be snapped to mls_length. If True, a ValueError will be raised for values which fall outside of this range. """ # Convert normalized values to MLS values if normalized: loc = loc * self.mls_length # Validate input if loc < 0: if bounded: raise ValueError( "Location value cannot be negative when the bounded " "parameter is True. Change to False to snap negative " "values to zero.") else: loc = 0 elif loc > self.mls_length: if bounded: raise ValueError( "Location value cannot be greater than the total length " "of the MLS when the bounded parameter is True. Change " "to False to snap negative values to the total length of " "the MLS.") else: loc = self.mls_length # Compute and return the index and proportional distance return self.mls_ranges.locate(loc, choose=choose)
[docs] def locate_rte(self, loc, normalized=False, choose='first', snap=None): """ Get the range index and the proportional distance along that range of the input route location. Parameters ---------- snap : {None, 'near', 'left', 'right'}, default None If the input location does not fall within any ranges, snap to the nearest match based on distance, choosing the closest range to the left, right, or either side ('near'). If None, a value error will be raised when no intersecting ranges are found. """ # Convert normalized values to MLS values if normalized: loc = loc * self.mls_length # Compute and return the index and proportional distance return self.mls_ranges.locate(loc, choose=choose, snap=snap) else: # Compute and return the index and proportional distance return self.rte_ranges.locate(loc, choose=choose, snap=snap)
[docs] def normalize(self, loc, by_mls=False, snap=None, **kwargs): """ Normalize a location as an actual route location or the absolute distance along the route's MultiLineString. Parameters ---------- loc : numerical The distance along the route which will be normalized. Can be route location or MultiLineString distance. by_mls : boolean, default False Whether to interpret the provided location along the route in terms of the actual cumulative length of the route's MultiLineString. If False, interpret as route location. If True, interpret as MLS location. snap : {None, 'near', 'left', 'right'}, default None If the input location does not fall within any ranges, snap to the nearest match based on distance, choosing the closest range to the left, right, or either side ('near'). If None, a value error will be raised when no intersecting ranges are found. """ # Convert to MLS if required if not by_mls: loc = self.convert_to_mls( loc, normalized=False, snap=snap, **kwargs) # Normalize location res = loc / self.mls_length return res
[docs] def convert(self, mls_loc=None, rte_loc=None, choose='first'): """ Convert an mls location to a reference location or vice versa. """ if not mls_loc is None: return self.convert_to_rte(mls_loc, choose=choose) elif not rte_loc is None: return self.convert_to_mls(rte_loc, choose=choose) else: raise ValueError("No locator inputs provided.")
[docs] def convert_to_rte(self, loc=None, normalized=False, choose='first', bounded=False): """ Convert an MLS or normalized reference location to a route location. Parameters ---------- loc : numerical The location along the route in terms of the absolute distance along the route's MultiLineString. normalized : boolean, default False Whether to interpret the provided location along the route in terms of proportional distance along the route. If False, the location along the route will be interpreted normally. choose : {'first', 'last', 'all'}, default 'first' Which range to return information for if multiple ranges are found which intersect with the provided location. bounded : boolean, default False Whether to raise an error when the location information falls outside the minimum and maximum bounds of the MLS range. If False, negative loc values will be snapped to 0 and loc values greater than mls_length will be snapped to mls_length. If True, a ValueError will be raised for values which fall outside of this range. """ # Locate the reference value on the route reference = self.locate_mls( loc=loc, normalized=normalized, choose=choose, bounded=bounded) return self.rte_ranges.project(*reference)
[docs] def convert_to_mls(self, loc=None, normalized=False, choose='first', snap=None): """ Convert a route or normalized reference location to an MLS location. Parameters ---------- loc : numerical The location along the route in terms of route's defined location values. normalized : boolean, default False Whether to interpret the provided location along the route in terms of proportional distance along the route. If False, the location along the route will be interpreted normally. choose : {'first', 'last', 'all'}, default 'first' Which range to return information for if multiple ranges are found which intersect with the provided location. snap : {None, 'near', 'left', 'right'}, default None If the input location does not fall within any ranges, snap to the nearest match based on distance, choosing the closest range to the left, right, or either side ('near'). If None, a value error will be raised when no intersecting ranges are found. """ # Locate the mls value on the route reference = self.locate_rte(loc=loc, normalized=normalized, choose=choose, snap=snap) return self.mls_ranges.project(*reference)
[docs] def project(self, obj, by_mls=False, normalized=False): """ Find the location along the route to a point nearest the input object. This can be done using a normalized proportional distance along the route, or the distance along the route in terms of route location or MultiLineString absolute length. Parameters ---------- obj : shapely geometry object A shapely geometry object to be projected along the route. by_mls : boolean, default False Whether to return the projected distance along the route in terms of the actual cumulative length of the route's MultiLineString. If False, interpret as route locations. If True, interpret as MLS location. If the normalized parameter is True, this will be superseded and proportional distance will be used. normalized : boolean, default False Whether to return the projected distance in terms of proportional distance along the route. If False, the distance along the route will be returned according to the by_mls parameter. """ # Project along the MultiLineString loc = self.mls.project(obj, normalized=True) # Convert to requested projection if normalized: return loc else: if by_mls: return self.convert_to_mls(loc=loc, normalized=True) else: return self.convert_to_rte(loc=loc, normalized=True)
[docs] def interpolate(self, loc, by_mls=False, normalized=False, snap=None, **kwargs): """ Return a point at the specified location along the route. This can be done using a normalized proportional distance along the route, or the distance along the route in terms of route location or MultiLineString absolute length. Parameters ---------- loc : numerical The distance along the route at which to create the point. Can be proportional distance, route location, or MultiLineString distance. by_mls : boolean, default False Whether to interpret the provided location along the route in terms of the actual cumulative length of the route's MultiLineString. If False, interpret as route locations. If True, interpret as MLS location. If the normalized parameter is True, this will be superseded and proportional distance will be used. normalized : boolean, default False Whether to interpret the provided location along the route in terms of proportional distance along the route. If False, the location along the route will be interpreted according to the by_mls parameter. snap : {None, 'near', 'left', 'right'}, default None If the input location does not fall within any ranges, snap to the nearest match based on distance, choosing the closest range to the left, right, or either side ('near'). If None, a value error will be raised when no intersecting ranges are found. """ # Convert to mls reference if normalized: loc = self.convert_to_mls(loc, normalized=True, snap=snap) elif not by_mls: loc = self.convert_to_mls(loc, normalized=False, snap=snap) # Interpolate along the MultiLineString point = self.mls.interpolate(loc, normalized=False) return point
[docs] def cut(self, beg, end, by_mls=False, normalized=False): """ Cut the MLS route at the given begin and end points. This can be done in terms of the route measure information (by_mls=False), in terms of MultiLineString actual cumulative length (by_mls=True), or in terms of proportional distances along the route (normalized=True). Parameters ---------- beg : float The location value at which the new route should begin. end : float The location value at which the new route should end. by_mls : boolean, default False Whether to interpret the begin and end points in terms of the actual cumulative length of the route's MultiLineString. If False, interpret as route locations. If True, interpret as MLS locations. If the normalized parameter is True, this will be superseded and proportional distance will be used. normalized : boolean, default False Whether to interpret the begin and end points in terms of proportional distances along the route. If False, the begin and end points will be interpreted according to the by_mls parameter. Returns ------- route : MLSRoute A new MLSRoute object instance with route information and a MultiLineString which has been cut according to the given parameters. """ # Validate input try: # Ensure positive numeric values beg = max(float(beg), 0) end = max(float(end), 0) except: raise ValueError("Invalid begin or end input values.") # Convert to MLS locations if normalized: beg = beg * self.mls_length end = end * self.mls_length elif not by_mls: beg = self.convert_to_mls( loc=beg, normalized=False, choose='first', snap='right') end = self.convert_to_mls( loc=end, normalized=False, choose='first', snap='left') # Interpolate begin point and compute range index of begin point if not beg is None: beg_point = self.interpolate(beg, by_mls=True, normalized=False).coords[0] beg_index, beg_dist = self.mls_ranges.locate(loc=beg, choose='last', closed='both', snap='right') beg_loc = self.rte_ranges.project(beg_index, beg_dist) else: beg_point = self.mls.geoms[0].coords[0] beg_index = 0 beg_loc = self.rte_ranges.begs[0] # Interpolate end point and compute range index of end point if not end is None: end_point = self.interpolate(end, by_mls=True, normalized=False).coords[0] end_index, end_dist = self.mls_ranges.locate(loc=end, choose='first', closed='both', snap='left') end_loc = self.rte_ranges.project(end_index, end_dist) else: end_point = self.mls.geoms[-1].coords[-1] end_index = self.mls_ranges.num_ranges - 1 end_loc = self.rte_ranges.ends[-1] # Unique case: begin and end points both fall between the same two # vertices if beg_index == end_index: mls = MultiLineString([LineString([beg_point, end_point])]) breaks = [[beg_loc, end_loc]] return MLSRoute(mls, rte_breaks=breaks, closed=self.closed) # Construct new MultiLineString based on new begin and end points total_size = 0 lines = [] breaks = [] # Iterate over the LineStrings in the MLS for num, (line, breaks_all) in enumerate(zip(self.mls.geoms, self.rte_breaks)): # Get the number of ranges in the LineString points_all = list(line.coords) size = len(points_all) # Compute the start slicing parameter if the LineString is to be # included if beg_index >= total_size + size - 1: # Not included, upstream of cut: skip total_size += size - 1 continue elif beg_index >= total_size: # Included: cut within line i = beg_index - total_size + 1 else: # Included: not cut within line i = None # Compute the stop slicing parameter if the LineString is to be # included if end_index < total_size: # Not included, downstream of cut: break break elif end_index < total_size + size - 1: # Included: cut within line j = end_index - total_size + 1 else: # Included: not cut within line j = None # Collect the valid points based on slicing computations if i is None: points_select = [] breaks_select = [] else: points_select = [beg_point] breaks_select = [beg_loc] slicer = slice(i,j) points_select += points_all[slicer] breaks_select += breaks_all.tolist()[slicer] if j is None: lines.append(LineString(points_select)) breaks.append(np.asarray(breaks_select)) total_size += size - 1 continue else: points_select += [end_point] breaks_select += [end_loc] lines.append(LineString(points_select)) breaks.append(breaks_select) break # Create MLS route based on computed results mls = MultiLineString(lines) return MLSRoute(mls, rte_breaks=breaks, closed=self.closed)
[docs] def segment(self, cuts, by_mls=False, normalized=False, **kwargs): """ Cut the MLS Route into segments based on the given cut points. Parameters ---------- cuts : array-like Array-like of numeric values representing the locations at which to cut the route, either in terms of MultiLineString distance or defined route locations. by_mls : boolean, default False Whether to interpret the provided location along the route in terms of the actual cumulative length of the route's MultiLineString. If False, interpret as route locations. If True, interpret as MLS location. If the normalized parameter is True, this will be superseded and proportional distance will be used. normalized : boolean, default False Whether to interpret the provided location along the route in terms of proportional distance along the route. If False, the location along the route will be interpreted according to the by_mls parameter. Returns ------- segments : list of MLSRoutes A list of new MLSRoute object instances, each with route information and a MultiLineString which has been cut according to the given parameters. """ # Validate inputs try: cuts = np.sort(np.asarray(cuts)) except: raise ValueError( "Must provide segment cuts as array-like of numeric cutting " "point values.") # Compute the valid ranges of the routes beg = self.mls_ranges.begs.min() if by_mls \ else self.rte_ranges.begs.min() end = self.mls_ranges.ends.max() if by_mls \ else self.rte_ranges.ends.max() # Iterate over cut points segments = [] for beg_i, end_i in zip(cuts[:-1], cuts[1:]): # Check for valid location if end_i < beg or beg_i > end: continue # Perform cut segment_i = self.cut(beg_i, end_i, by_mls=by_mls, normalized=normalized) # Append new segment to the list of created segments segments.append(segment_i) return segments
[docs] def snap(self, loc, by_mls=False, normalized=False): """ Snap a provided location value to the bounds of the route based on the provided parameters. If the location falls within the bounds of the route, the same value will be returned. Parameters ---------- loc : scalar Location value to snap to the route bounds. by_mls : boolean, default False Whether to interpret the provided location along the route in terms of the actual cumulative length of the route's MultiLineString. If False, interpret as route location. If True, interpret as MLS location. normalized : boolean, default False Whether to interpret the provided location along the route in terms of proportional distance along the route. If False, the location along the route will be interpreted according to the by_mls parameter. """ # Snap location by selected range collection if normalized: return max(min(loc, 1), 0) elif by_mls: return self.mls_ranges.snap(loc) else: return self.rte_ranges.snap(loc)
[docs] def bearing(self, positive=True, invert=False): """ Approximate the bearing angle of the route, based on the first and last points in the route's MLS. Parameters ---------- positive : bool, default True Whether to enforce a positive range on the computed bearing angle. If True, the bearing angle will fall on the range [0,360). If False, the bearing angle will fall on the range (-180,180]. invert : bool, default False Whether to invert the computed bearing angle, effectively reversing the direction of the route. """ # Capture x and y distance between points x_diff = self.mls.geoms[-1].xy[0][-1] - self.mls.geoms[0].xy[0][0] y_diff = self.mls.geoms[-1].xy[1][-1] - self.mls.geoms[0].xy[1][0] # Compute bearing angle bearing = math.degrees(math.atan2(y_diff, x_diff)) # Invert if requested if invert: bearing += 180 # Enforce range if positive and bearing < 0: bearing += 360 elif not positive and bearing > 180: bearing -= 360 return bearing
##################### # SUPPORT FUNCTIONS # #####################
[docs]def combine_mpgs(objs, cls=None): """ Combine multiple multipart geometries into a single multipart geometry of geometry collection. """ # Generate new list of individual geometries new = [] for obj in objs: if isinstance(obj, shapely.geometry.base.BaseMultipartGeometry): new.extend(list(obj.geoms)) elif isinstance(obj, shapely.geometry.base.BaseGeometry): new.extend([obj]) else: raise TypeError("Invalid geometry type") # Convert list to geometry collection or provided class if cls is None: new = shapely.geometry.collection.GeometryCollection(new) else: new = cls(new) return new
def _distribute_dimensions(mls, beg, end): # Validate input if not isinstance(mls, MultiLineString): raise ValueError("Input MLS must be MultiLineString type.") if mls.is_empty: raise ValueError("Input MLS is empty.") # Compute dimensions delta = end - beg lengths = np.array([ls.length for ls in mls.geoms]) proportions = np.cumsum(lengths / lengths.sum() * delta) begs = np.insert(proportions[:-1] + beg, 0, beg) ends = proportions + beg # Return proportions return begs, ends # Sample use if __name__ == '__main__': # Create a generic MLS mls = MultiLineString([[(0,0), (0,5), (5,5), (5,0)], [(5,0), (5,-5), (10,-5), (10,0)], [(10,0), (10,5), (15,5), (15,0)], [(20,0), (20,-5), (25,-5), (25,0)]]) # Create a generic range set rte_ranges = [(0,150), (200,350), (400,550), (600,650)] # Create a MLS route route = MLSRoute(mls, rte_ranges=rte_ranges) # Cut the sample route test = route.cut(0,0.80, normalized=True)