def calculate_velocities_over_time(output_filename_prefix,
                                   output_filename_extension,
                                   rotation_filenames,
                                   reconstructable_filenames,
                                   threshold_sampling_distance_radians,
                                   time_young,
                                   time_old,
                                   time_increment,
                                   velocity_delta_time=1.0,
                                   anchor_plate_id=0):

    if time_increment <= 0:
        print('The time increment "{0}" is not positive and non-zero.'.format(
            time_increment),
              file=sys.stderr)
        return

    if time_young > time_old:
        print('The young time {0} is older (larger) than the old time {1}.'.
              format(time_young, time_old),
              file=sys.stderr)
        return

    rotation_model = pygplates.RotationModel(rotation_filenames)

    # Read/parse the reconstructable features once so we're not doing at each time iteration.
    reconstructable_features = [
        pygplates.FeatureCollection(reconstructable_filename)
        for reconstructable_filename in reconstructable_filenames
    ]

    # Iterate over the time range.
    time = time_young
    while time <= pygplates.GeoTimeInstant(time_old):

        print('Time {0}'.format(time))

        # Returns a list of tesselated subduction zone points and associated convergence parameters
        # to write to the output file for the current 'time'.
        output_data = calculate_velocities(
            rotation_model, reconstructable_features,
            threshold_sampling_distance_radians, time, velocity_delta_time,
            anchor_plate_id)

        if output_data:
            output_filename = '{0}_{1:0.2f}.{2}'.format(
                output_filename_prefix, time, output_filename_extension)
            write_output_file(output_filename, output_data)

        # Increment the time further into the past.
        time += time_increment

    return 0  # Success
Example #2
0
def get_stage_rotation_for_reconstructed_geometry(
        spreading_feature,
        rotation_model,
        spreading_time = None):
    """
    Find the stage rotation of the spreading feature in the frame of reference of its geometry at the spreading time.
    The stage pole can then be directly geometrically compared to the reconstructed spreading geometry.
    
    spreading_feature: Can be a feature with half-stage rotation reconstruction (using left/right plate IDs)
                       or a regular feature with a conjugate plate ID.
                       An example of the former is a mid-ocean ridge, and of the latter an isochron.
    
    rotation_model: Rotation model of type pygplates.RotationModel.
    
    spreading_time: A time at which spreading is happening.
                    For isochrons this should be its time of appearance (ie, when formed at mid-ocean ridge).
                    For mid-ocean ridges this can be any time when the ridge is actively spreading.
                    Defaults to the time of appearance of 'spreading_feature'.
    
    Returns: The stage rotation that can be applied to the geometry at the spreading time.
             NOTE: It has already had transforms to and from the stage pole reference frame applied.
             So if you get the stage pole from it, using 'get_euler_pole_and_angle()', then it
             will be the stage pole in the frame of reference of the geometry at the spreading time.
             
             Returns None if 'spreading_feature' does not satisfy requirements of a spreading feature.
             (ie, have left/right plate IDs or reconstruction/conjugate plate IDs, and
             have spreading time not in distant past or future, and
             have non-zero stage rotation from 'spreading_time + 1' to 'spreading_time').
    """
    
    # If the spreading time is not specified then default to the feature's time of appearance.
    if spreading_time is None:
        spreading_time, _ = spreading_feature.get_valid_time()
    
    # Spreading time must not be distant past or future.
    if (pygplates.GeoTimeInstant(spreading_time).is_distant_past() or
        pygplates.GeoTimeInstant(spreading_time).is_distant_future()):
        return
    
    # Reconstructing either by plate ID or by half stage rotation.
    if spreading_feature.get_reconstruction_method() == 'ByPlateId':
        
        # See if spreading feature has reconstruction and conjugate plate ids.
        reconstruction_and_conjugate_plate_ids = _get_reconstruction_and_conjugate_plate_ids(spreading_feature)
        if not reconstruction_and_conjugate_plate_ids:
            # Spreading feature has no reconstruction/conjugate plate pair.
            return
        
        reconstruction_plate_id, conjugate_plate_id = reconstruction_and_conjugate_plate_ids
        
        #
        # In order to compare spreading geometries with the pole of the stage rotation at spreading time
        # we need to transform either (1) present day spreading geometries, or (2) geometries reconstructed
        # to spreading time, into the reference frame of the stage rotation (so can compare to stage pole).
        #
        # To help us decide this we start by writing the equation for a regular feature (with a conjugate plate)...
        #
        #   geometry_reconstructed = R(0->t, A->Recon) * geometry_present_day
        #                          = R(0->t, A->Conj) * R(0->t, Conj->Recon) * geometry_present_day
        #                          = R(0->t, A->Conj) * R(t+1->t, Conj->Recon) * R(0->t+1, Conj->Recon) * geometry_present_day
        #
        # ...where 'Recon' is reconstruction plate ID and 'Conj' is conjugate plate ID.
        #
        # We want to transform the spreading geometry into the stage pole reference frame at time 't=spreading_time'.
        # The easiest way to do this is to transform 'geometry_reconstructed' instead of 'geometry_present_day' since
        # it's easier to get into the reference frame of the 'R(t+1->t, Conj->Recon)' rotation
        # which is the stage rotation we're interested in when 't=spreading_time'.
        # Rearranging the above equation we get...
        #
        #   geometry_present_day = inverse[R(0->spreading_time+1, Conj->Recon)]
        #                          * inverse[R(spreading_time+1->spreading_time, Conj->Recon)]
        #                          * inverse[R(0->spreading_time, A->Conj)]
        #                          * geometry_reconstructed
        #                        = inverse[R(0->spreading_time+1, Conj->Recon)]
        #                          * inverse[R(spreading_time+1->spreading_time, Conj->Recon)]
        #                          * geometry_in_stage_pole_reference_frame
        #
        #   geometry_in_stage_pole_reference_frame = inverse[R(0->spreading_time, A->Conj)] * geometry_reconstructed
        #
        # ...where 'geometry_in_stage_pole_reference_frame' is in the stage pole reference frame because it gets rotated by
        # the stage pole rotation 'inverse[R(spreading_time+1->spreading_time, Conj->Recon)]' which differs from
        # 'R(spreading_time+1->spreading_time, Conj->Recon)' only in angle (has negated angle but pole remains the same).
        #
        # So to get reconstructed spreading geometry in the stage pole reference frame we reverse rotate 'geometry_reconstructed'
        # by 'inverse[R(0->spreading_time, A->Conj)]'.
        #
        # So to apply the stage rotation to the reconstructed spreading geometry we rotate it in stage pole reference frame,
        # then apply stage rotation and then rotate back from the stage pole reference frame...
        #
        #   stage_rotate_geometry_reconstructed = R(0->spreading_time, A->Conj)
        #                                         * R(spreading_time+1->spreading_time, Conj->Recon)
        #                                         * inverse[R(0->spreading_time, A->Conj)]
        #                                         * geometry_reconstructed
        #
        # For more detail see:
        #   http://www.gplates.org/docs/pygplates/sample-code/pygplates_split_isochron_into_ridges_and_transforms.html
        #
        stage_rotation = rotation_model.get_rotation(spreading_time, reconstruction_plate_id, spreading_time + 1, conjugate_plate_id)
        if stage_rotation.represents_identity_rotation():
            return
        from_stage_pole_reference_frame = rotation_model.get_rotation(spreading_time, conjugate_plate_id)
        to_stage_pole_reference_frame = from_stage_pole_reference_frame.get_inverse()
        stage_rotation = from_stage_pole_reference_frame * stage_rotation * to_stage_pole_reference_frame
    
    else: # Reconstruction is by half stage rotation...
        
        # See if spreading feature has left and right plate ids (it should).
        left_and_right_plate_ids = _get_left_and_right_plate_ids(spreading_feature)
        if not left_and_right_plate_ids:
            # Spreading feature has no left/right plate pair.
            return
        
        left_plate_id, right_plate_id = left_and_right_plate_ids
        
        #
        # In order to compare spreading geometries with the pole of the stage rotation at birth time
        # we need to transform either (1) present day spreading geometries, or (2) geometries reconstructed
        # to birth time, into the reference frame of the stage rotation (so can compare to stage pole).
        #
        # To help us decide this we start by writing the equation for a mid-ocean ridge (MOR)...
        #
        #   geometry_reconstructed = R(0->t, A->MOR) * geometry_present_day
        #                          = R(0->t, A->Left) * R(0->t, Left->MOR) * geometry_present_day
        #                          = R(0->t, A->Left) * spread(ts->t, Left->Right) * geometry_present_day
        #
        # ...where 'MOR' is not a plate ID, which is why we do half-spreading (or asymmetric spreading) of
        # right plate relative to left plate. The function 'spread()' usually splits the time interval from
        # spreading start time 'ts' to time 't' into N stages and accumulates spreading over those N stages...
        #
        #   geometry_reconstructed = R(0->t, A->Left) * spread(ts->t, Left->Right) * geometry_present_day
        #                          = R(0->t, A->Left)
        #                            * spread(t[N-1]->t, Left->Right) * spread(t[N-2]->t[N-1], Left->Right) * ... * spread(t1->t2, Left->Right) * spread(ts->t1, Left->Right)
        #                            * geometry_present_day
        #
        # ...in GPlates the "gpml:ReconstructionMethodEnumeration" property currently supports 'HalfStageRotation' versions 1, 2 and 3.
        # They only differ in the spreading start time 'ts' and the number of stages N.
        # Version 1 has 'ts=0' and 'N=1'.
        # Version 2 has 'ts=0' and 'N>1'.
        # Version 3 has 'ts=spreading_time' and 'N>1'.
        #
        # We want to transform the spreading geometry into the stage pole reference frame at time 't=spreading_time'.
        # The easiest way to do this is to transform 'geometry_reconstructed' instead of 'geometry_present_day' since
        # it's easier to get into the reference frame of the 'spread(t[N-1]->t, Left->Right)' rotation
        # which is the stage rotation we're interested in when 't=spreading_time'.
        # Rearranging the above equation we get...
        #
        #   geometry_present_day = inverse[spread(ts->t1, Left->Right)] * ... * inverse[spread(t[N-1]->spreading_time, Left->Right)]
        #                          * inverse[R(0->t, A->Left)]
        #                          * geometry_reconstructed
        #                        = inverse[spread(ts->t1, Left->Right)] * ... * inverse[spread(t[N-1]->spreading_time, Left->Right)]
        #                          * geometry_in_stage_pole_reference_frame
        #
        #   geometry_in_stage_pole_reference_frame = inverse[R(0->spreading_time, A->Left)] * geometry_reconstructed
        #
        # ...where 'geometry_in_stage_pole_reference_frame' is in the stage pole reference frame because it gets rotated by
        # the stage pole rotation 'inverse[spread(t[N-1]->spreading_time, Left->Right)]' which differs from
        # 'spread(t[N-1]->spreading_time, Left->Right)' only in angle (has negated angle but pole remains the same).
        #
        # So to get reconstructed spreading geometry in the stage pole reference frame we reverse rotate 'geometry_reconstructed' by 'inverse[R(0->spreading_time, A->Left)]'.
        #
        # So to apply the stage rotation to the reconstructed spreading geometry we rotate it in stage pole reference frame,
        # then apply stage rotation and then rotate back from the stage pole reference frame...
        #
        #   stage_rotate_geometry_reconstructed = R(0->spreading_time, A->Left)
        #                                         * R(spreading_time+1->spreading_time, Left->Right)
        #                                         * inverse[R(0->spreading_time, A->Left)]
        #                                         * geometry_reconstructed
        #
        stage_rotation = rotation_model.get_rotation(spreading_time, right_plate_id, spreading_time + 1, left_plate_id)
        if stage_rotation.represents_identity_rotation():
            return
        from_stage_pole_reference_frame = rotation_model.get_rotation(spreading_time, left_plate_id)
        to_stage_pole_reference_frame = from_stage_pole_reference_frame.get_inverse()
        stage_rotation = from_stage_pole_reference_frame * stage_rotation * to_stage_pole_reference_frame
    
    return stage_rotation
def _ensure_sequence_accuracy(
        rotation_model,
        parent_to_child_rotation_samples,
        child_remove_plate_id,
        remove_plate_id,
        parent_remove_plate_id,
        remove_plate_max_sample_time,
        threshold_rotation_accuracy_degrees,
        threshold_time_interval,
        insert_poles_at_integer_multiples_of_time_interval):
    """Insert new samples at times where the difference between original and new rotation models exceeds a threshold."""
    
    num_original_samples = len(parent_to_child_rotation_samples)

    sample_pair_stack = []
    
    # Add the stage rotation intervals to the stack for later processing.
    for sample_index in range(num_original_samples-1):
        sample1, sample2 = parent_to_child_rotation_samples[sample_index], parent_to_child_rotation_samples[sample_index + 1]
        sample_time1, sample_time2 = sample1.get_time(), sample2.get_time()
        if pygplates.GeoTimeInstant(sample_time2 - sample_time1) > threshold_time_interval:
            sample_pair_stack.append((sample1, sample2))
    
    # Process the stage rotation intervals on the stack until it is empty.
    while sample_pair_stack:
        sample1, sample2 = sample_pair_stack.pop()
        sample_time1, sample_time2 = sample1.get_time(), sample2.get_time()
        
        mid_sample_time = 0.5 * (sample_time1 + sample_time2)
        
        if insert_poles_at_integer_multiples_of_time_interval:
            # Round to the nearest uniformly spaced interval.
            interpolated_sample_time = threshold_time_interval * math.floor((mid_sample_time / threshold_time_interval) + 0.5)
            if interpolated_sample_time > mid_sample_time:
                if interpolated_sample_time >= pygplates.GeoTimeInstant(sample_time2):
                    # We rounded up and the time was greater-or-equal to the end sample time so subtract one time interval.
                    # This is guaranteed to remain within the start/end range since that range should exceed the time interval.
                    interpolated_sample_time -= threshold_time_interval
            else:
                if interpolated_sample_time <= pygplates.GeoTimeInstant(sample_time1):
                    # We rounded down and the time was less-or-equal to the start sample time so add one time interval.
                    # This is guaranteed to remain within the start/end range since that range should exceed the time interval.
                    interpolated_sample_time += threshold_time_interval
                
        else:
            # Just use the sample midway between 'sample1' and 'sample2'.
            interpolated_sample_time = mid_sample_time
        
        interpolated_sample = _add_accurate_sample(
            rotation_model,
            interpolated_sample_time,
            sample1,
            sample2,
            child_remove_plate_id,
            remove_plate_id,
            parent_remove_plate_id,
            remove_plate_max_sample_time,
            threshold_rotation_accuracy_degrees)
        
        if interpolated_sample:
            parent_to_child_rotation_samples.append(interpolated_sample)
            
            # Recurse if the time interval between start sample and the interpolated sample exceeds threshold interval.
            if pygplates.GeoTimeInstant(interpolated_sample_time - sample_time1) > threshold_time_interval:
                sample_pair_stack.append((sample1, interpolated_sample))
            
            # Recurse if the time interval between the interpolated sample and end sample exceeds threshold interval.
            if pygplates.GeoTimeInstant(sample_time2 - interpolated_sample_time) > threshold_time_interval:
                sample_pair_stack.append((interpolated_sample, sample2))
    
    # Sort the sample by time (if we added any new samples).
    if len(parent_to_child_rotation_samples) > num_original_samples:
        parent_to_child_rotation_samples.sort(key = lambda sample: sample.get_time())
def remove_plates(
        rotation_feature_collections,
        plate_ids,
        accuracy_parameters=None):
    # Docstring in numpydoc format...
    """Remove one or more plate IDs from a rotation model (consisting of one or more rotation feature collections).
    
    Any rotations with a fixed plate referencing one of the removed plates will be adjusted such that
    the rotation model effectively remains unchanged.
    
    Optional accuracy threshold parameters can be specified to ensure the rotation model after removing
    plate rotations is very similar to the rotation model before removal.
    
    The results are returned as a list of pygplates.FeatureCollection (one per input rotation feature collection).
    
    Ensure you specify all input rotation feature collections that contain the plate IDs to be removed (either as a moving or fixed plate ID).
    
    Parameters
    ----------
    rotation_feature_collections : sequence of (str, or sequence of pygplates.Feature, or pygplates.FeatureCollection, or pygplates.Feature)
        A sequence of rotation feature collections.
        Each collection in the sequence can be a rotation filename, or a sequence (eg, list of tuple) or features, or
        a feature collection, or even a single feature.
    plate_ids : sequence of int
        Plate IDs to remove from rotation model.
    accuracy_parameters: tuple of (float, float, bool), optional
        First parameter is the threshold rotation accuracy (in degrees), the second parameter is the threshold time interval and
        the third parameter is True if insert poles should have times that are integer multiples of the threshold time interval.
        The first parameter is used to compare the latitude, longitude and angle of two rotations before and after removing a plate rotation.
        If any of those three parameters differ by more than the rotation accuracy (in degrees) then
        samples at times mid-way between samples are inserted to ensure before/after accuracy of rotations.
        This mid-way adaptive bisection is repeated (when there is inaccuracy) until the interval between samples
        becomes smaller than the second parameter (threshold time interval).
        Rotation threshold is in degrees and threshold time interval is in millions of years.
    
    Returns
    -------
    list of pygplates.FeatureCollection
        The (potentially) modified feature collections.
        Returned list is same length as ``rotation_feature_collections``.
    """
    
    # Convert each feature collection into a list of features so we can more easily remove features
    # and insert features at arbitrary locations within each feature collection (for example removing
    # a plate sequence and replacing it with a sequence with the same plate ID).
    rotation_feature_collections = [list(pygplates.FeatureCollection(rotation_feature_collection))
        for rotation_feature_collection in rotation_feature_collections]
    
    # Iterate over the plates to be removed and remove each one separately.
    for remove_plate_id in plate_ids:
        
        # Rotation model before any modifications to rotation features.
        #
        # Previously we only created one instance of RotationModel in this function (to serve all removed plates).
        # However we need to create a new RotationModel instance for each plate ID being removed, in
        # case any rotation sequence referencing the removed plate (as its fixed plate) has a time range
        # further into the past than the removed plate sequence. This is a subtle issue to do with
        # not having a plate circuit *through the removed plate* for times older than supported by
        # the removed plate sequence - in this case RotationModel would just return an identity rotation.
        # However if we create a new RotationModel *without* the removed plate sequence then we
        # avoid this issue altogether.
        #
        # Note that RotationModel clones the current rotation features, so any subsequent
        # feature modifications (in this loop iteration) should not affect it.
        # However the RotationModel in the next loop iteration will be affected of course.
        rotation_model = pygplates.RotationModel(rotation_feature_collections)
        
        # Rotation sequences with the current remove plate ID as the *moving* plate ID.
        # Each sequence will have a different *fixed* plate ID.
        remove_plate_sequences = []
        for rotation_feature_collection in rotation_feature_collections:
            rotation_feature_index = 0
            while rotation_feature_index < len(rotation_feature_collection):
                rotation_feature = rotation_feature_collection[rotation_feature_index]
                total_reconstruction_pole = rotation_feature.get_total_reconstruction_pole()
                if total_reconstruction_pole:
                    fixed_plate_id, moving_plate_id, rotation_sequence = total_reconstruction_pole
                    if moving_plate_id == remove_plate_id:
                        sample_times = [pygplates.GeoTimeInstant(sample.get_time())
                            for sample in rotation_sequence.get_enabled_time_samples()]
                        if sample_times:
                            remove_plate_sequences.append(
                                (fixed_plate_id, sample_times))
                        # Remove plate sequences whose moving plate is the current remove plate.
                        # Note that this won't affect 'rotation_model' (since it used a cloned version of all features).
                        del rotation_feature_collection[rotation_feature_index]
                        rotation_feature_index -= 1
                
                rotation_feature_index += 1
        
        # Sort the remove plate sequences in time order.
        # This helps out below, to find the max sample time over the removed sequences (all having same *moving* plate ID).
        #
        # Easiest way to do this is to sort based on the first time sample in each sequence
        # (since each sequence should already be sorted internally).
        remove_plate_sequences.sort(key = lambda sequence: sequence[1][0])
        
        # Find those sequences that need adjustment due to the plate removal.
        # These are sequences whose *fixed* plate is the plate currently being removed.
        for rotation_feature_collection in rotation_feature_collections:
            rotation_feature_index = 0
            while rotation_feature_index < len(rotation_feature_collection):
                rotation_feature = rotation_feature_collection[rotation_feature_index]
                total_reconstruction_pole = rotation_feature.get_total_reconstruction_pole()
                if total_reconstruction_pole:
                    fixed_plate_id, moving_plate_id, rotation_sequence = total_reconstruction_pole
                    if fixed_plate_id == remove_plate_id:
                        child_remove_plate_id = moving_plate_id
                        child_remove_plate_rotation_feature = rotation_feature
                        child_remove_plate_samples = rotation_sequence.get_time_samples()
                        
                        child_remove_plate_sample_times = [pygplates.GeoTimeInstant(sample.get_time())
                            for sample in child_remove_plate_samples]
                        child_remove_plate_min_sample_time = child_remove_plate_sample_times[0]
                        child_remove_plate_max_sample_time = child_remove_plate_sample_times[-1]
                        
                        # Iterate over the removed sequences whose moving plate matched the current plate being removed.
                        for remove_plate_sequence_index, (parent_remove_plate_id, remove_plate_sample_times) in enumerate(remove_plate_sequences):
                            remove_plate_min_sample_time = remove_plate_sample_times[0]
                            remove_plate_max_sample_time = remove_plate_sample_times[-1]
                            
                            # Find the time overlap of the removed sequence and the (child) sequence requiring modification.
                            min_sample_time = max(remove_plate_min_sample_time, child_remove_plate_min_sample_time)
                            if remove_plate_sequence_index == len(remove_plate_sequences) - 1:
                                # We want the last remove plate sequence to go back to the child max sample time.
                                # If it doesn't go that far back then we will artificially extend the remove plate sequence that far back.
                                max_sample_time = child_remove_plate_max_sample_time
                            else:
                                max_sample_time = min(remove_plate_max_sample_time, child_remove_plate_max_sample_time)
                            
                            # Note that the remove sequences are ordered by time (ie, first sequence should start at 0Ma, etc).
                            # The two sequences must overlap.
                            # Note that this excludes the case where the min of one sequence equals the max of the other (or max and min).
                            if min_sample_time < max_sample_time:
                                sample_times = []
                                # Find those sample times of the child sequence within the overlap range.
                                for child_remove_plate_sample_time in child_remove_plate_sample_times:
                                    if (child_remove_plate_sample_time >= min_sample_time and
                                        child_remove_plate_sample_time <= max_sample_time):
                                        sample_times.append(child_remove_plate_sample_time)
                                # Find those sample times of the remove sequence within the overlap range.
                                # Also avoiding duplicating sample times (times already in the child sequence).
                                for remove_plate_sample_time in remove_plate_sample_times:
                                    # Only add the sample time if it's not already in the list.
                                    if (remove_plate_sample_time not in child_remove_plate_sample_times and
                                        remove_plate_sample_time >= min_sample_time and
                                        remove_plate_sample_time <= max_sample_time):
                                        sample_times.append(remove_plate_sample_time)
                                # Need to sort the sample times (since they're likely interleaved between remove and child sequences).
                                sample_times.sort()
                                
                                # Gather the rotation samples from the child's moving plate to the removed plate's fixed plate.
                                parent_to_child_rotation_samples = _merge_rotation_samples(
                                    rotation_model,
                                    child_remove_plate_id,
                                    remove_plate_id,
                                    parent_remove_plate_id,
                                    child_remove_plate_samples,
                                    child_remove_plate_sample_times,
                                    sample_times,
                                    remove_plate_max_sample_time)
                                
                                # Insert new samples at times where the difference between original and new rotation models exceeds a threshold.
                                if accuracy_parameters is not None:
                                    threshold_rotation_accuracy_degrees, threshold_time_interval, use_uniform_accuracy_times = accuracy_parameters
                                    _ensure_sequence_accuracy(
                                        rotation_model,
                                        parent_to_child_rotation_samples,
                                        child_remove_plate_id,
                                        remove_plate_id,
                                        parent_remove_plate_id,
                                        remove_plate_max_sample_time,
                                        threshold_rotation_accuracy_degrees,
                                        threshold_time_interval,
                                        use_uniform_accuracy_times)
                                
                                # Create a new rotation sequence.
                                parent_to_child_rotation_feature = pygplates.Feature.create_total_reconstruction_sequence(
                                    parent_remove_plate_id,
                                    child_remove_plate_id,
                                    pygplates.GpmlIrregularSampling(parent_to_child_rotation_samples),
                                    child_remove_plate_rotation_feature.get_name(None),         # Note: specifying None avoids a pygplates crash in revs < 20
                                    child_remove_plate_rotation_feature.get_description(None))  # Note: specifying None avoids a pygplates crash in revs < 20
                                
                                # Insert the new rotation feature to the current location in the feature collection.
                                # This is better than adding to the end of the collection and thus reordering the order of rotation sequences
                                # in the output collection/file (making it harder to visually find it in a text editor).
                                # Also note that this won't affect 'rotation_model' (since it used a cloned version of all features).
                                rotation_feature_collection.insert(rotation_feature_index, parent_to_child_rotation_feature)
                                rotation_feature_index += 1
                        
                        # The original rotation feature will no longer be needed because we remove plate sequences
                        # whose fixed plate is the current remove plate.
                        # We would have added one or more sequences above to replace it though.
                        # Also note that this won't affect 'rotation_model' (since it used a cloned version of all features).
                        del rotation_feature_collection[rotation_feature_index]
                        rotation_feature_index -= 1
                        
                rotation_feature_index += 1
        
        # Note that we don't join rotation sequences having the same moving/fixed plates.
        # However they will show up as a 'duplicate geo-time' warning when loading into GPlates.
        # TODO: Remove duplicate geo-times and join the offending rotation sequences.
        #
        # Details: It's possible that a sequence having a crossover (really two sequences with same moving
        # plate but different fixed plates) can have one of its fixed plates removed and hence replaced
        # by the fixed plate of the removed sequence. In this situation the original crossover sequence
        # (really two sequences) could now have the same fixed plate ID, and since it also has the
        # same moving plate ID it should really be one sequence.
    
    # Return our (potentially) modified feature collections as a list of pygplates.FeatureCollection.
    return [pygplates.FeatureCollection(rotation_feature_collection)
        for rotation_feature_collection in rotation_feature_collections]
def subduction_convergence_over_time(output_filename_prefix,
                                     output_filename_extension,
                                     rotation_filenames,
                                     topology_filenames,
                                     threshold_sampling_distance_radians,
                                     time_young,
                                     time_old,
                                     time_increment,
                                     velocity_delta_time=1.0,
                                     anchor_plate_id=0,
                                     output_gpml_filename=None):

    if time_increment <= 0:
        _runtime_warning(
            'The time increment "{0}" is not positive and non-zero.'.format(
                time_increment))
        return

    if time_young > time_old:
        _runtime_warning(
            'The young time {0} is older (larger) than the old time {1}.'.
            format(time_young, time_old))
        return

    rotation_model = pygplates.RotationModel(rotation_filenames)

    # Read/parse the topological features once so we're not doing at each time iteration.
    topology_features = [
        pygplates.FeatureCollection(topology_filename)
        for topology_filename in topology_filenames
    ]

    if output_gpml_filename:
        coverage_features = []

    # Iterate over the time rage.
    time = time_young
    while time <= pygplates.GeoTimeInstant(time_old):

        print('Time {0}'.format(time))

        # Returns a list of tesselated subduction zone points and associated convergence parameters
        # to write to the output file for the current 'time'.
        output_data = subduction_convergence(
            rotation_model, topology_features,
            threshold_sampling_distance_radians, time, velocity_delta_time,
            anchor_plate_id)

        if output_data:
            output_filename = '{0}_{1:0.2f}.{2}'.format(
                output_filename_prefix, time, output_filename_extension)
            write_output_file(output_filename, output_data)

            # Also keep track of convergence data if we need to write out a GPML file.
            if output_gpml_filename:
                coverage_feature = create_coverage_feature_from_convergence_data(
                    output_data, time)
                coverage_features.append(coverage_feature)

        # Increment the time further into the past.
        time += time_increment

    if output_gpml_filename:
        # Write out all coverage features to a single GPML file.
        pygplates.FeatureCollection(coverage_features).write(
            output_gpml_filename)

    return 0  # Success