def testPointFilteringShanghaiJump(self): classicJumpTrip1 = self.trips[0] self.loadPointsForTrip(classicJumpTrip1.get_id()) classicJumpSections1 = [s for s in self.sections if s.trip_id == classicJumpTrip1.get_id()] outlier_algo = eaics.BoxplotOutlier() jump_algo = eaicj.SmoothZigzag() for i, section in enumerate(classicJumpSections1): logging.debug("-" * 20 + "Considering section %s" % i + "-" * 20) section_df = self.ts.get_data_df("background/filtered_location", esds.get_time_query_for_section(section.get_id())) with_speeds_df = eaicl.add_dist_heading_speed(section_df) maxSpeed = outlier_algo.get_threshold(with_speeds_df) logging.debug("Max speed for section %s = %s" % (i, maxSpeed)) jump_algo.filter(with_speeds_df) logging.debug("Retaining points %s" % np.nonzero(jump_algo.inlier_mask_)) to_delete_mask = np.logical_not(jump_algo.inlier_mask_) logging.debug("Deleting points %s" % np.nonzero(to_delete_mask)) delete_ids = list(with_speeds_df[to_delete_mask]._id) logging.debug("Deleting ids %s" % delete_ids) # Automated checks. Might be able to remove logging statements later if i != 2: # Not the bad section. Should not be filtered self.assertEqual(np.count_nonzero(to_delete_mask), 0) self.assertEqual(len(delete_ids), 0) else: # The bad section, should have the third point filtered self.assertEqual(np.count_nonzero(to_delete_mask), 1) self.assertEqual([str(id) for id in delete_ids], ["55d8c4837d65cb39ee983cb4"])
def testPointFilteringRichmondJump(self): classicJumpTrip1 = self.trip_entries[6] self.loadPointsForTrip(classicJumpTrip1.get_id()) classicJumpSections1 = [s for s in self.section_entries if s.data.trip_id == classicJumpTrip1.get_id()] outlier_algo = eaics.BoxplotOutlier() jump_algo = eaicj.SmoothZigzag(False, 100) for i, section_entry in enumerate(classicJumpSections1): logging.debug("-" * 20 + "Considering section %s" % i + "-" * 20) section_df = self.ts.get_data_df("background/filtered_location", esda.get_time_query_for_trip_like(esda.RAW_SECTION_KEY, section_entry.get_id())) with_speeds_df = eaicl.add_dist_heading_speed(section_df) maxSpeed = outlier_algo.get_threshold(with_speeds_df) logging.debug("Max speed for section %s = %s" % (i, maxSpeed)) jump_algo.filter(with_speeds_df) logging.debug("Retaining points %s" % np.nonzero(jump_algo.inlier_mask_)) to_delete_mask = np.logical_not(jump_algo.inlier_mask_) logging.debug("Deleting points %s" % np.nonzero(to_delete_mask)) delete_ids = list(with_speeds_df[to_delete_mask]._id) logging.debug("Deleting ids %s" % delete_ids) # There is only one section self.assertEqual(i, 0) # The bad section, should have the third point filtered self.assertEqual(np.count_nonzero(to_delete_mask), 1) self.assertEqual([str(id) for id in delete_ids], ["55e86dbb7d65cb39ee987e09"])
def get_median_speed(self, mcs, mce): loc_df = self.seg_method.filter_points_for_range( self.seg_method.location_points, mcs, mce) if len(loc_df) > 0: loc_df.reset_index(inplace=True) with_speed_df = eaicl.add_dist_heading_speed(loc_df) return with_speed_df.speed.median() else: return None
def testPointFilteringZigzag(self): classicJumpTrip1 = self.trip_entries[8] self.loadPointsForTrip(classicJumpTrip1.get_id()) classicJumpSections1 = [ s for s in self.section_entries if s.data.trip_id == classicJumpTrip1.get_id() ] outlier_algo = eaics.BoxplotOutlier() jump_algo = eaicj.SmoothZigzag(False, 100) for i, section_entry in enumerate(classicJumpSections1): logging.debug("-" * 20 + "Considering section %s" % i + "-" * 20) section_df = self.ts.get_data_df( "background/filtered_location", esda.get_time_query_for_trip_like(esda.RAW_SECTION_KEY, section_entry.get_id())) with_speeds_df = eaicl.add_dist_heading_speed(section_df) maxSpeed = outlier_algo.get_threshold(with_speeds_df) logging.debug("Max speed for section %s = %s" % (i, maxSpeed)) jump_algo.filter(with_speeds_df) logging.debug("Retaining points %s" % np.nonzero(jump_algo.inlier_mask_.to_numpy())) to_delete_mask = np.logical_not(jump_algo.inlier_mask_) logging.debug("Deleting points %s" % np.nonzero(to_delete_mask.to_numpy())) delete_ids = list(with_speeds_df[to_delete_mask]._id) logging.debug("Deleting ids %s" % delete_ids) if i == 0: # this is the zigzag section self.assertEqual( np.nonzero(to_delete_mask.to_numpy())[0].tolist(), [25, 64, 114, 115, 116, 117, 118, 119, 120, 123, 126]) self.assertEqual(delete_ids, [ boi.ObjectId('55edafe77d65cb39ee9882ff'), boi.ObjectId('55edcc157d65cb39ee98836e'), boi.ObjectId('55edcc1f7d65cb39ee988400'), boi.ObjectId('55edcc1f7d65cb39ee988403'), boi.ObjectId('55edcc1f7d65cb39ee988406'), boi.ObjectId('55edcc1f7d65cb39ee988409'), boi.ObjectId('55edcc1f7d65cb39ee98840c'), boi.ObjectId('55edcc207d65cb39ee988410'), boi.ObjectId('55edcc207d65cb39ee988412'), boi.ObjectId('55edcc217d65cb39ee98841f'), boi.ObjectId('55edcc217d65cb39ee988429') ]) else: self.assertEqual(len(np.nonzero(to_delete_mask.to_numpy())[0]), 0) self.assertEqual(len(delete_ids), 0)
def get_filtered_points(section, filtered_section_data): logging.debug("Getting filtered points for section %s" % section) ts = esta.TimeSeries.get_time_series(section.user_id) loc_entry_it = ts.find_entries(["background/filtered_location"], esda.get_time_query_for_trip_like( esda.RAW_SECTION_KEY, section.get_id())) loc_entry_list = [ecwe.Entry(e) for e in loc_entry_it] # We know that the assertion fails in the geojson conversion code and we # handle it there, so we are just going to comment this out for now. # assert (loc_entry_list[-1].data.loc == section.data.end_loc, # "section_location_array[-1].loc != section.end_loc even after df.ts fix", # (loc_entry_list[-1].data.loc, section.data.end_loc)) # Find the list of points to filter filtered_points_entry_doc = ts.get_entry_at_ts("analysis/smoothing", "data.section", section.get_id()) if filtered_points_entry_doc is None: logging.debug( "No filtered_points_entry, filtered_points_list is empty") filtered_point_id_list = [] else: # TODO: Figure out how to make collections work for the wrappers and then change this to an Entry filtered_points_entry = ad.AttrDict(filtered_points_entry_doc) filtered_point_id_list = list( filtered_points_entry.data.deleted_points) logging.debug("deleting %s points from section points" % len(filtered_point_id_list)) filtered_loc_list = remove_outliers(loc_entry_list, filtered_point_id_list) # filtered_loc_list has removed the outliers. Now, we resample the data at # 30 sec intervals resampled_loc_df = resample(filtered_loc_list, interval=30) # If this is the first section, we need to find the start place of the parent trip # and actually start from there. That will fix the distances but not the duration # because we haven't yet figured out how to get the correct start time. # TODO: Fix this!! # For now, we will fudge this in the geojson converter, as always with_speeds_df = eaicl.add_dist_heading_speed(resampled_loc_df) with_speeds_df["idx"] = np.arange(0, len(with_speeds_df)) with_speeds_df_nona = with_speeds_df.dropna() logging.info("removed %d entries containing n/a" % (len(with_speeds_df_nona) - len(with_speeds_df))) return with_speeds_df_nona
def get_filtered_points(section, filtered_section_data): logging.debug("Getting filtered points for section %s" % section) ts = esta.TimeSeries.get_time_series(section.user_id) loc_entry_it = ts.find_entries(["background/filtered_location"], esda.get_time_query_for_trip_like( esda.RAW_SECTION_KEY, section.get_id())) loc_entry_list = [ecwe.Entry(e) for e in loc_entry_it] # We know that the assertion fails in the geojson conversion code and we # handle it there, so we are just going to comment this out for now. # assert (loc_entry_list[-1].data.loc == section.data.end_loc, # "section_location_array[-1].loc != section.end_loc even after df.ts fix", # (loc_entry_list[-1].data.loc, section.data.end_loc)) # Find the list of points to filter filtered_points_entry_doc = ts.get_entry_at_ts("analysis/smoothing", "data.section", section.get_id()) if filtered_points_entry_doc is None: logging.debug("No filtered_points_entry, filtered_points_list is empty") filtered_point_id_list = [] else: # TODO: Figure out how to make collections work for the wrappers and then change this to an Entry filtered_points_entry = ad.AttrDict(filtered_points_entry_doc) filtered_point_id_list = list(filtered_points_entry.data.deleted_points) logging.debug("deleting %s points from section points" % len( filtered_point_id_list)) filtered_loc_list = remove_outliers(loc_entry_list, filtered_point_id_list) # filtered_loc_list has removed the outliers. Now, we resample the data at # 30 sec intervals resampled_loc_df = resample(filtered_loc_list, interval=30) # If this is the first section, we need to find the start place of the parent trip # and actually start from there. That will fix the distances but not the duration # because we haven't yet figured out how to get the correct start time. # TODO: Fix this!! # For now, we will fudge this in the geojson converter, as always with_speeds_df = eaicl.add_dist_heading_speed(resampled_loc_df) with_speeds_df["idx"] = np.arange(0, len(with_speeds_df)) with_speeds_df_nona = with_speeds_df.dropna() logging.info("removed %d entries containing n/a" % (len(with_speeds_df_nona) - len(with_speeds_df))) return with_speeds_df_nona
def testPointFilteringZigzag(self): classicJumpTrip1 = self.trip_entries[8] self.loadPointsForTrip(classicJumpTrip1.get_id()) classicJumpSections1 = [s for s in self.section_entries if s.data.trip_id == classicJumpTrip1.get_id()] outlier_algo = eaics.BoxplotOutlier() jump_algo = eaicj.SmoothZigzag(False, 100) for i, section_entry in enumerate(classicJumpSections1): logging.debug("-" * 20 + "Considering section %s" % i + "-" * 20) section_df = self.ts.get_data_df("background/filtered_location", esda.get_time_query_for_trip_like(esda.RAW_SECTION_KEY, section_entry.get_id())) with_speeds_df = eaicl.add_dist_heading_speed(section_df) maxSpeed = outlier_algo.get_threshold(with_speeds_df) logging.debug("Max speed for section %s = %s" % (i, maxSpeed)) jump_algo.filter(with_speeds_df) logging.debug("Retaining points %s" % np.nonzero(jump_algo.inlier_mask_)) to_delete_mask = np.logical_not(jump_algo.inlier_mask_) logging.debug("Deleting points %s" % np.nonzero(to_delete_mask)) delete_ids = list(with_speeds_df[to_delete_mask]._id) logging.debug("Deleting ids %s" % delete_ids) if i == 0: # this is the zigzag section self.assertEqual(np.nonzero(to_delete_mask)[0].tolist(), [25, 64, 114, 115, 116, 117, 118, 119, 120, 123, 126]) self.assertEqual(delete_ids, [boi.ObjectId('55edafe77d65cb39ee9882ff'), boi.ObjectId('55edcc157d65cb39ee98836e'), boi.ObjectId('55edcc1f7d65cb39ee988400'), boi.ObjectId('55edcc1f7d65cb39ee988403'), boi.ObjectId('55edcc1f7d65cb39ee988406'), boi.ObjectId('55edcc1f7d65cb39ee988409'), boi.ObjectId('55edcc1f7d65cb39ee98840c'), boi.ObjectId('55edcc207d65cb39ee988410'), boi.ObjectId('55edcc207d65cb39ee988412'), boi.ObjectId('55edcc217d65cb39ee98841f'), boi.ObjectId('55edcc217d65cb39ee988429')]) else: self.assertEqual(len(np.nonzero(to_delete_mask)[0]), 0) self.assertEqual(len(delete_ids), 0)
def testPointFilteringShanghaiJump(self): classicJumpTrip1 = self.trip_entries[0] self.loadPointsForTrip(classicJumpTrip1.get_id()) classicJumpSections1 = [ s for s in self.section_entries if s.data.trip_id == classicJumpTrip1.get_id() ] outlier_algo = eaics.BoxplotOutlier() jump_algo = eaicj.SmoothZigzag(False, 100) for i, section_entry in enumerate(classicJumpSections1): logging.debug("-" * 20 + "Considering section %s" % i + "-" * 20) section_df = self.ts.get_data_df( "background/filtered_location", esda.get_time_query_for_trip_like(esda.RAW_SECTION_KEY, section_entry.get_id())) with_speeds_df = eaicl.add_dist_heading_speed(section_df) maxSpeed = outlier_algo.get_threshold(with_speeds_df) logging.debug("Max speed for section %s = %s" % (i, maxSpeed)) jump_algo.filter(with_speeds_df) logging.debug("Retaining points %s" % np.nonzero(jump_algo.inlier_mask_.to_numpy())) to_delete_mask = np.logical_not(jump_algo.inlier_mask_) logging.debug("Deleting points %s" % np.nonzero(to_delete_mask.to_numpy())) delete_ids = list(with_speeds_df[to_delete_mask]._id) logging.debug("Deleting ids %s" % delete_ids) # Automated checks. Might be able to remove logging statements later if i != 2: # Not the bad section. Should not be filtered self.assertEqual(np.count_nonzero(to_delete_mask), 0) self.assertEqual(len(delete_ids), 0) else: # The bad section, should have the third point filtered self.assertEqual(np.count_nonzero(to_delete_mask), 1) self.assertEqual([str(id) for id in delete_ids], ["55d8c4837d65cb39ee983cb4"])
def section_to_geojson(section, tl): """ This is the trickiest part of the visualization. The section is basically a collection of points with a line through them. So the representation is a feature in which one feature which is the line, and one feature collection which is the set of point features. :param section: the section to be converted :return: a feature collection which is the geojson version of the section """ ts = esta.TimeSeries.get_time_series(section.user_id) entry_it = ts.find_entries(["background/filtered_location"], esds.get_time_query_for_section(section.get_id())) # points_df = ts.get_data_df("background/filtered_location", esds.get_time_query_for_section(section.get_id())) # points_df = points_df.drop("elapsedRealTimeNanos", axis=1) # logging.debug("points_df.columns = %s" % points_df.columns) # TODO: Decide whether we want to use Rewrite to use dataframes throughout instead of python arrays. # dataframes insert nans. We could use fillna to fill with default values, but if we are not actually # using dataframe features here, it is unclear how much that would help. feature_array = [] section_location_array = [ecwl.Location(ts._to_df_entry(entry)) for entry in entry_it] logging.debug("first element in section_location_array = %s" % section_location_array[0]) # Fudge the end point so that we don't have a gap because of the ts != write_ts mismatch # TODO: Fix this once we are able to query by the data timestamp instead of the metadata ts if section_location_array[-1].loc != section.end_loc: last_loc_doc = ts.get_entry_at_ts("background/filtered_location", "data.ts", section.end_ts) last_loc_data = ecwe.Entry(last_loc_doc).data last_loc_data["_id"] = last_loc_doc["_id"] section_location_array.append(last_loc_data) logging.debug("Adding new entry %s to fill the end point gap between %s and %s" % (last_loc_data.loc, section_location_array[-2].loc, section.end_loc)) # Find the list of points to filter filtered_points_entry_doc = ts.get_entry_at_ts("analysis/smoothing", "data.section", section.get_id()) if filtered_points_entry_doc is None: logging.debug("No filtered_points_entry, returning unchanged array") filtered_section_location_array = section_location_array else: # TODO: Figure out how to make collections work for the wrappers and then change this to an Entry filtered_points_entry = ad.AttrDict(filtered_points_entry_doc) filtered_point_list = list(filtered_points_entry.data.deleted_points) logging.debug("deleting %s points from section points" % len(filtered_point_list)) filtered_section_location_array = [l for l in section_location_array if l.get_id() not in filtered_point_list] with_speeds = eaicl.add_dist_heading_speed(pd.DataFrame(filtered_section_location_array)) speeds = list(with_speeds.speed) distances = list(with_speeds.distance) for idx, row in with_speeds.iterrows(): # TODO: Remove instance of setting value without going through wrapper class filtered_section_location_array[idx]["speed"] = row["speed"] filtered_section_location_array[idx]["distance"] = row["distance"] points_feature_array = [location_to_geojson(l) for l in filtered_section_location_array] points_line_feature = point_array_to_line(filtered_section_location_array) # If this is the first section, we already start from the trip start. But we actually need to start from the # prior place. Fudge this too. Note also that we may want to figure out how to handle this properly in the model # without needing fudging. TODO: Unclear how exactly to do this if section.start_stop is None: # This is the first section. So we need to find the start place of the parent trip parent_trip = tl.get_object(section.trip_id) start_place_of_parent_trip = tl.get_object(parent_trip.start_place) points_line_feature.geometry.coordinates.insert(0, start_place_of_parent_trip.location.coordinates) for i, point_feature in enumerate(points_feature_array): point_feature.properties["idx"] = i points_line_feature.id = str(section.get_id()) points_line_feature.properties = copy.copy(section) points_line_feature.properties["feature_type"] = "section" points_line_feature.properties["sensed_mode"] = str(points_line_feature.properties.sensed_mode) points_line_feature.properties["distance"] = sum(distances) points_line_feature.properties["speeds"] = speeds points_line_feature.properties["distances"] = distances _del_non_derializable(points_line_feature.properties, ["start_loc", "end_loc"]) feature_array.append(gj.FeatureCollection(points_feature_array)) feature_array.append(points_line_feature) return gj.FeatureCollection(feature_array)
def section_to_geojson(section, tl): """ This is the trickiest part of the visualization. The section is basically a collection of points with a line through them. So the representation is a feature in which one feature which is the line, and one feature collection which is the set of point features. :param section: the section to be converted :return: a feature collection which is the geojson version of the section """ ts = esta.TimeSeries.get_time_series(section.user_id) entry_it = ts.find_entries(["background/filtered_location"], esds.get_time_query_for_section( section.get_id())) # points_df = ts.get_data_df("background/filtered_location", esds.get_time_query_for_section(section.get_id())) # points_df = points_df.drop("elapsedRealTimeNanos", axis=1) # logging.debug("points_df.columns = %s" % points_df.columns) # TODO: Decide whether we want to use Rewrite to use dataframes throughout instead of python arrays. # dataframes insert nans. We could use fillna to fill with default values, but if we are not actually # using dataframe features here, it is unclear how much that would help. feature_array = [] section_location_array = [ ecwl.Location(ts._to_df_entry(entry)) for entry in entry_it ] if len(section_location_array) != 0: logging.debug("first element in section_location_array = %s" % section_location_array[0]) # Fudge the end point so that we don't have a gap because of the ts != write_ts mismatch # TODO: Fix this once we are able to query by the data timestamp instead of the metadata ts if section_location_array[-1].loc != section.end_loc: last_loc_doc = ts.get_entry_at_ts("background/filtered_location", "data.ts", section.end_ts) last_loc_data = ecwe.Entry(last_loc_doc).data last_loc_data["_id"] = last_loc_doc["_id"] section_location_array.append(last_loc_data) logging.debug( "Adding new entry %s to fill the end point gap between %s and %s" % (last_loc_data.loc, section_location_array[-2].loc, section.end_loc)) # Find the list of points to filter filtered_points_entry_doc = ts.get_entry_at_ts("analysis/smoothing", "data.section", section.get_id()) if filtered_points_entry_doc is None: logging.debug("No filtered_points_entry, returning unchanged array") filtered_section_location_array = section_location_array else: # TODO: Figure out how to make collections work for the wrappers and then change this to an Entry filtered_points_entry = ad.AttrDict(filtered_points_entry_doc) filtered_point_list = list(filtered_points_entry.data.deleted_points) logging.debug("deleting %s points from section points" % len(filtered_point_list)) filtered_section_location_array = [ l for l in section_location_array if l.get_id() not in filtered_point_list ] with_speeds = eaicl.add_dist_heading_speed( pd.DataFrame(filtered_section_location_array)) speeds = list(with_speeds.speed) distances = list(with_speeds.distance) if len(filtered_section_location_array) != 0: for idx, row in with_speeds.iterrows(): # TODO: Remove instance of setting value without going through wrapper class filtered_section_location_array[idx]["speed"] = row["speed"] filtered_section_location_array[idx]["distance"] = row["distance"] points_feature_array = [ location_to_geojson(l) for l in filtered_section_location_array ] points_line_feature = point_array_to_line(filtered_section_location_array) # If this is the first section, we already start from the trip start. But we actually need to start from the # prior place. Fudge this too. Note also that we may want to figure out how to handle this properly in the model # without needing fudging. TODO: Unclear how exactly to do this if section.start_stop is None: # This is the first section. So we need to find the start place of the parent trip parent_trip = tl.get_object(section.trip_id) start_place_of_parent_trip = tl.get_object(parent_trip.start_place) points_line_feature.geometry.coordinates.insert( 0, start_place_of_parent_trip.location.coordinates) for i, point_feature in enumerate(points_feature_array): point_feature.properties["idx"] = i points_line_feature.id = str(section.get_id()) points_line_feature.properties = copy.copy(section) points_line_feature.properties["feature_type"] = "section" points_line_feature.properties["sensed_mode"] = str( points_line_feature.properties.sensed_mode) points_line_feature.properties["distance"] = sum(distances) points_line_feature.properties["speeds"] = speeds points_line_feature.properties["distances"] = distances _del_non_derializable(points_line_feature.properties, ["start_loc", "end_loc"]) feature_array.append(gj.FeatureCollection(points_feature_array)) feature_array.append(points_line_feature) return gj.FeatureCollection(feature_array)
def get_merge_direction(self, streak_start, streak_end): """ Checks to decide merge direction - if either direction is WALKING and speed is greater than 1.4 + slosh then must be the other direction - pick direction that is closer to the median speed """ start_change = self.motion_changes[streak_start] end_change = self.motion_changes[streak_end] ssm, sem = start_change esm, eem = end_change if streak_start == 0: # There is no before section - only choices are to merge backward # or make a new section logging.debug( "get_merge_direction: at beginning of changes, can only merge backward" ) return MergeResult(Direction.BACKWARD, FinalMode.UNMERGED) before_motion = self.motion_changes[streak_start - 1] bsm, bem = before_motion if streak_end + 1 == len(self.motion_changes): # There is no after section - only one way to merge! logging.debug( "get_merge_direction: at end of changes, can only merge forward" ) return MergeResult(Direction.FORWARD, FinalMode.UNMERGED) after_motion = self.motion_changes[streak_end + 1] asm, aem = after_motion if bsm.type == asm.type: logging.debug( "before type = %s, after type = %s, merge direction is don't care, returning forward" % (bsm.type, asm.type)) return MergeResult(Direction.FORWARD, FinalMode.UNMERGED) loc_points = self.seg_method.filter_points_for_range( self.seg_method.location_points, ssm, eem) loc_points.reset_index(inplace=True) with_speed_loc_points = eaicl.add_dist_heading_speed(loc_points) points_before = self.seg_method.filter_points_for_range( self.seg_method.location_points, bsm, bem) points_before.reset_index(inplace=True) with_speed_points_before = eaicl.add_dist_heading_speed(points_before) points_after = self.seg_method.filter_points_for_range( self.seg_method.location_points, asm, aem) points_after.reset_index(inplace=True) with_speed_points_after = eaicl.add_dist_heading_speed(points_after) curr_median_speed = self.get_section_speed(loc_points, with_speed_loc_points, points_before, points_after) # check for walking speed, which is the one constant is a cruel, # shifting world where there is no truth if (eaid.is_walking_type(asm.type) and (not eaid.is_walking_speed(curr_median_speed))): logging.debug( "after is walking, but speed is %d, merge forward, returning 1" % curr_median_speed) return MergeResult(Direction.FORWARD, FinalMode.UNMERGED) elif (eaid.is_walking_type(bsm.type) and (not eaid.is_walking_speed(curr_median_speed))): logging.debug( "before is walking, but speed is %d, merge backward, returning -1" ) return MergeResult(Direction.BACKWARD, FinalMode.UNMERGED) logging.debug( "while merging, comparing curr speed %s with before %s and after %s" % (curr_median_speed, with_speed_points_before.speed.median(), with_speed_points_after.speed.median())) if (abs(curr_median_speed - with_speed_points_before.speed.median()) < abs(curr_median_speed - with_speed_points_after.speed.median())): # speed is closer to before than after, merge with before, merge forward logging.debug("before is closer, merge forward, returning 1") return MergeResult(Direction.FORWARD, FinalMode.UNMERGED) else: logging.debug("after is closer, merge backward, returning -1") return MergeResult(Direction.BACKWARD, FinalMode.UNMERGED)