SieveComP
  • Home
  • Installation
  • TOML Schema

Tutorials

  • Python API Quickstart
  • SeisComP SQL Queries Introduction
  • Optimizing Duplicates Search
    • Querying SeisComP6 Database
    • Checking for Duplicates - Initial Implementation
    • Checking for duplicates - Micro-optimization of the adjacent-only logic
    • Checking for duplicates - Vectorized adjacent-only logic
    • Checking Duplicates - Sorted Sliding Window Approach
    • Checking Duplicates - Vectorized Sorted Sliding Window Approach
      • Overall results
  • Polygon validation
  • SieveComP: Advanced Usage
  • Generating TOML files with AI

Interfaces & Apps

  • Command Line Interface (CLI)
  • Streamlit Web Application
  • API Reference
SieveComP
  • Tutorials
  • Optimizing Duplicates Search
  • Edit on KenethGarcia/SieveComP

This notebook was developed by Keneth Garcia. Source and license info are on GitHub.

In [1]:
Copied!
import time
import math
import numpy as np
import pandas as pd
from tqdm import trange
from sievecomp.io.sql import load_sql
from sievecomp.core.runner import Runner
import time import math import numpy as np import pandas as pd from tqdm import trange from sievecomp.io.sql import load_sql from sievecomp.core.runner import Runner

Optimizing Duplicates Search¶

Actually, the SieveComP package includes a function to check for duplicates, which is based on the time and geographical distance between events. This notebook summarizes the optimization process developed on the duplicates search, by using a more efficient algorithm that can reduce the time complexity of the search.

Querying SeisComP6 Database¶

Let's start by querying the SeisComP6 database to get the events for a given time range. We will wrap the SQL contained in the seismic_revision_routine.toml config file into a function that can be called with different time ranges. The function will return a pandas dataframe with the events for the given time range (see more details at the Advanced Usage notebook).

Given that SieveComP returns a RunResult object, we can use the total_events attribute to get the events dataframe. For now, we will ignore the other attributes of the RunResult object, since we are only interested in the events dataframe, including the checks and duplicates.

In [2]:
Copied!
def fetch_events_by_time_range(
    runner: Runner,
    query_name: str,
    start_time: pd.Timestamp,
    end_time: pd.Timestamp
) -> pd.DataFrame:
    """
    Fetches events from a specific SeisComP database query within a dynamic time window.
    Bypasses the engine's checks and duplicates logic.
    """
    # 1. Retrieve the configured time column used for sorting and filtering
    time_col = runner._cm.get_time_column()

    # 2. Prepare the dynamic time bounds and SQL parameters
    sql_params = {}
    where_clauses = []

    if start_time:
        sql_params["start_time"] = start_time.strftime('%Y-%m-%d %H:%M:%S')
        where_clauses.append(f"base_query.{time_col} >= %(start_time)s")

    if end_time:
        sql_params["end_time"] = end_time.strftime('%Y-%m-%d %H:%M:%S')
        where_clauses.append(f"base_query.{time_col} < %(end_time)s")

    where_sql = " AND ".join(where_clauses)

    # 3. Load the base SQL text strictly as it is defined in the TOML configuration
    query_cfg = runner._cm.select_query(name=query_name)
    base_sql = load_sql(
        query_cfg=query_cfg,
        base_dir=runner._cm.base_dir,
        config_name=runner._cm.config_path.name
    )

    # 4. Wrap the base query as a subquery, applying the new WHERE clauses and sorting
    wrapped_sql = f"""SELECT * FROM ({base_sql}) AS base_query WHERE {where_sql} ORDER BY base_query.{time_col} ASC"""

    # 5. Fetch the events (skipping the check/duplicate evaluation steps for raw data retrieval)
    result = runner.run(
        query_name=query_name,
        multi_query=False,
        perform_duplicates=False,
        perform_checks=False,
        sql_text_override=wrapped_sql,
        sql_params=sql_params
    )

    # 6. Return the raw queried events DataFrame
    return result.total_events
def fetch_events_by_time_range( runner: Runner, query_name: str, start_time: pd.Timestamp, end_time: pd.Timestamp ) -> pd.DataFrame: """ Fetches events from a specific SeisComP database query within a dynamic time window. Bypasses the engine's checks and duplicates logic. """ # 1. Retrieve the configured time column used for sorting and filtering time_col = runner._cm.get_time_column() # 2. Prepare the dynamic time bounds and SQL parameters sql_params = {} where_clauses = [] if start_time: sql_params["start_time"] = start_time.strftime('%Y-%m-%d %H:%M:%S') where_clauses.append(f"base_query.{time_col} >= %(start_time)s") if end_time: sql_params["end_time"] = end_time.strftime('%Y-%m-%d %H:%M:%S') where_clauses.append(f"base_query.{time_col} < %(end_time)s") where_sql = " AND ".join(where_clauses) # 3. Load the base SQL text strictly as it is defined in the TOML configuration query_cfg = runner._cm.select_query(name=query_name) base_sql = load_sql( query_cfg=query_cfg, base_dir=runner._cm.base_dir, config_name=runner._cm.config_path.name ) # 4. Wrap the base query as a subquery, applying the new WHERE clauses and sorting wrapped_sql = f"""SELECT * FROM ({base_sql}) AS base_query WHERE {where_sql} ORDER BY base_query.{time_col} ASC""" # 5. Fetch the events (skipping the check/duplicate evaluation steps for raw data retrieval) result = runner.run( query_name=query_name, multi_query=False, perform_duplicates=False, perform_checks=False, sql_text_override=wrapped_sql, sql_params=sql_params ) # 6. Return the raw queried events DataFrame return result.total_events

With this function, we can now fetch events from the SeisComP6 database for any given time range, and we can use this data to test the duplicates search function. Let's fetch the events for the time range from 2026-03-17 to 2026-10-09, and see how many events we have in this time range:

In [3]:
Copied!
# Define the time range for the query
start_time = pd.Timestamp('2026-03-17 00:00:00')
end_time = pd.Timestamp('2026-10-09 00:00:00')

# Create a Runner instance (assuming the config file is in the current directory)
runner = Runner(config_path='../../examples/data/configs/seismic_revision_routine.toml')

# Fetch the events for the specified time range
event_df3 = fetch_events_by_time_range(runner, "SeisComP6", start_time, end_time)

# Describe the dataset by counting how many events we have in this time range by event type
event_counts = event_df3['event_type'].value_counts()
print("Event counts by type:")
print(event_counts)
# Define the time range for the query start_time = pd.Timestamp('2026-03-17 00:00:00') end_time = pd.Timestamp('2026-10-09 00:00:00') # Create a Runner instance (assuming the config file is in the current directory) runner = Runner(config_path='../../examples/data/configs/seismic_revision_routine.toml') # Fetch the events for the specified time range event_df3 = fetch_events_by_time_range(runner, "SeisComP6", start_time, end_time) # Describe the dataset by counting how many events we have in this time range by event type event_counts = event_df3['event_type'].value_counts() print("Event counts by type:") print(event_counts)
                                           
Event counts by type:
event_type
not locatable                  30241
earthquake                     18812
outside of network interest      970
explosion                        698
not existing                     136
volcanic eruption                 38
Name: count, dtype: int64

Checking for Duplicates - Initial Implementation¶

Let's start by defining the first basic function to check duplicates. This implementation is based on the adjacent-only logic, which means that it only compares each event with the next one in the list using a for loop. This is a good approach to reduce the time complexity of the search, given that the probability of having three or more earthquakes within a window of 10-12 s is low, but it can be further optimized by using some micro-optimizations as we will see later. The function will return a pandas dataframe with the duplicates found, including the observations for each event:

In [4]:
Copied!
columns = ['time_value', 'publicID', 'text', 'depth_value', 'magnitude_value', 'magnitude_type',
           'quality_standardError', 'depth_uncertainty', 'latitude_uncertainty', 'longitude_uncertainty',
           'quality_associatedPhaseCount', 'creationInfo_author', 'event_type', 'creationInfo_agencyID']

def calculate_haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """
    Calculates the great-circle distance in kilometers between two points
    on the Earth's surface using the Haversine formula.
    """
    R = 6371.0  # Earth radius in kilometers

    # Convert decimal degrees to radians
    phi1 = math.radians(lat1)
    phi2 = math.radians(lat2)
    dphi = math.radians(lat2 - lat1)
    dlambda = math.radians(lon2 - lon1)

    # Haversine formula
    a = math.sin(dphi / 2.0)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2.0)**2
    c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))

    return R * c


def check_duplicates(
        events: pd.DataFrame
):
    """
    Identifies duplicate events based on time and geographical distance.

    Parameters:
    -----------
    events: pandas dataframe
        Seismic data for the given time range. Generally obtained from the connect2mysql function.

    Returns:
    --------
    duplicates: A pandas dataframe
        Table with information about duplicate events.
    """
    # Filter events with the following types
    checks = ["earthquake", "volcanic eruption", "explosion", "outside of network interest"]
    selections = events[events['event_type'].isin(checks)].reset_index(drop=True)

    # Make a pandas dataframe to store the info of the duplicates
    duplicates = pd.DataFrame()

    # Loop over al earthquakes, ordered by time
    for i in trange(len(selections) - 1, desc="Checking duplicates", unit=" events", leave=False):
        event1 = selections.iloc[i]  # Take the i-esim event on the list
        event2 = selections.iloc[i + 1]  # Compared to the next event

        # Check if the events are within 4 seconds of each other
        time_diff = abs((event2['time_value'] - event1['time_value']).total_seconds())
        if time_diff <= 4:
            # Estimate the distance between the two events using haversine formula
            distance = calculate_haversine_distance(event1['latitude_value'], event1['longitude_value'],
                                                    event2['latitude_value'], event2['longitude_value'])
            if distance <= 100:  # If both events are within 100 km and 4 seconds, they can be duplicates
                dup_1 = event1[columns].copy()
                dup_1['Observations'] = f'Possible duplicate event of {event2["publicID"]}'
                dup_2 = event2[columns].copy()
                dup_2['Observations'] = f'Possible duplicate event of {event1["publicID"]}'
                # Add the two events to the duplicates dataframe
                duplicates = pd.concat([duplicates, dup_1.to_frame().T], ignore_index=True)
                duplicates = pd.concat([duplicates, dup_2.to_frame().T], ignore_index=True)
    return duplicates
columns = ['time_value', 'publicID', 'text', 'depth_value', 'magnitude_value', 'magnitude_type', 'quality_standardError', 'depth_uncertainty', 'latitude_uncertainty', 'longitude_uncertainty', 'quality_associatedPhaseCount', 'creationInfo_author', 'event_type', 'creationInfo_agencyID'] def calculate_haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: """ Calculates the great-circle distance in kilometers between two points on the Earth's surface using the Haversine formula. """ R = 6371.0 # Earth radius in kilometers # Convert decimal degrees to radians phi1 = math.radians(lat1) phi2 = math.radians(lat2) dphi = math.radians(lat2 - lat1) dlambda = math.radians(lon2 - lon1) # Haversine formula a = math.sin(dphi / 2.0)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2.0)**2 c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) return R * c def check_duplicates( events: pd.DataFrame ): """ Identifies duplicate events based on time and geographical distance. Parameters: ----------- events: pandas dataframe Seismic data for the given time range. Generally obtained from the connect2mysql function. Returns: -------- duplicates: A pandas dataframe Table with information about duplicate events. """ # Filter events with the following types checks = ["earthquake", "volcanic eruption", "explosion", "outside of network interest"] selections = events[events['event_type'].isin(checks)].reset_index(drop=True) # Make a pandas dataframe to store the info of the duplicates duplicates = pd.DataFrame() # Loop over al earthquakes, ordered by time for i in trange(len(selections) - 1, desc="Checking duplicates", unit=" events", leave=False): event1 = selections.iloc[i] # Take the i-esim event on the list event2 = selections.iloc[i + 1] # Compared to the next event # Check if the events are within 4 seconds of each other time_diff = abs((event2['time_value'] - event1['time_value']).total_seconds()) if time_diff <= 4: # Estimate the distance between the two events using haversine formula distance = calculate_haversine_distance(event1['latitude_value'], event1['longitude_value'], event2['latitude_value'], event2['longitude_value']) if distance <= 100: # If both events are within 100 km and 4 seconds, they can be duplicates dup_1 = event1[columns].copy() dup_1['Observations'] = f'Possible duplicate event of {event2["publicID"]}' dup_2 = event2[columns].copy() dup_2['Observations'] = f'Possible duplicate event of {event1["publicID"]}' # Add the two events to the duplicates dataframe duplicates = pd.concat([duplicates, dup_1.to_frame().T], ignore_index=True) duplicates = pd.concat([duplicates, dup_2.to_frame().T], ignore_index=True) return duplicates

As you can see from the function, there are two criteria to consider an event as a duplicate: the time difference between the two events must be less than or equal to 4 seconds, and the geographical distance between the two events must be less than or equal to 100 km. The function loops over all the events, and compares each event with the next one in the list. If both criteria are met, the two events are considered duplicates, and their information is stored in a new dataframe called duplicates.

The idea of checking only the next event in the list is based on the fact that usually the time difference between earthquakes is greater than 4 seconds, so it is unlikely that two events that are far apart in the list are duplicates. Let's test the results for all the earthquakes fetched and see how many duplicates we can find and the time it takes to run the function.

In [5]:
Copied!
time1 = time.time()
duplicates_previous = check_duplicates(event_df3)
time2 = time.time()
print(f"Number of duplicates found: {len(duplicates_previous)}")
print(f"Time taken to run the function: {time2 - time1} seconds")
time1 = time.time() duplicates_previous = check_duplicates(event_df3) time2 = time.time() print(f"Number of duplicates found: {len(duplicates_previous)}") print(f"Time taken to run the function: {time2 - time1} seconds")
                                                                                 
Number of duplicates found: 22
Time taken to run the function: 7.795006990432739 seconds

Checking for duplicates - Micro-optimization of the adjacent-only logic¶

The previous function is based on the adjacent-only logic, which means that it only compares each event with the next one in the list. This is a good approach to reduce the time complexity of the search, but it can be further optimized by using some micro-optimizations. For example, we can pre-extract the relevant columns as NumPy arrays to avoid the overhead of pandas indexing in the loop. We can also accumulate the duplicate rows in a list and create the DataFrame at the end, instead of concatenating in each iteration. Let's implement this micro-optimized version of the adjacent-only logic:

In [7]:
Copied!
def check_duplicates_adjacent(events: pd.DataFrame) -> pd.DataFrame:
    """
    Identifies duplicate events based on time and geographical distance.
    Same adjacent-only logic as the original, but micro-optimized:
      - Pre-extracted NumPy arrays (avoids per-row iloc overhead)
      - Row accumulation in a list (avoids repeated pd.concat in loop)
      - Single pd.concat at the end

    Time complexity: O(n) comparisons — only i vs i+1
    Parameters:
    -----------
    events : pd.DataFrame
        Seismic data for the given time range.
    Returns:
    --------
    duplicates : pd.DataFrame
        Table with information about duplicate events.
    """
    checks = ["earthquake", "volcanic eruption", "explosion", "outside of network interest"]
    selections = events[events['event_type'].isin(checks)].reset_index(drop=True)

    # Edge case: empty or single-event dataset
    if len(selections) < 2:
        return pd.DataFrame(columns=columns + ['Observations'])

    # --- Pre-extract arrays for fast access ---
    times  = selections['time_value'].values          # numpy datetime64
    lats   = selections['latitude_value'].values.astype(np.float64)
    lons   = selections['longitude_value'].values.astype(np.float64)
    ids    = selections['publicID'].values

    TIME_WINDOW    = np.timedelta64(4, 's')
    DIST_THRESHOLD = 100  # km

    dup_rows = []  # Accumulate rows here, build DF once at the end

    for i in trange(len(selections) - 1, desc="Checking duplicates", unit=" events", leave=False):
        # Time check using numpy (faster than .total_seconds() on Timedelta)
        if abs(times[i + 1] - times[i]) <= TIME_WINDOW:
            # Distance check using haversine formula
            distance = calculate_haversine_distance(lats[i], lons[i], lats[i + 1], lons[i + 1])
            if distance <= DIST_THRESHOLD:
                row_i = selections.iloc[i][columns].copy()
                row_i['Observations'] = f'Possible duplicate event of {ids[i + 1]}'
                row_j = selections.iloc[i + 1][columns].copy()
                row_j['Observations'] = f'Possible duplicate event of {ids[i]}'
                dup_rows.extend([row_i, row_j])

    # Concat duplicates to one single Dataframe
    if dup_rows:
        duplicates = pd.DataFrame(dup_rows, columns=columns + ['Observations']).reset_index(drop=True)
    else:
        duplicates = pd.DataFrame(columns=columns + ['Observations'])

    return duplicates
def check_duplicates_adjacent(events: pd.DataFrame) -> pd.DataFrame: """ Identifies duplicate events based on time and geographical distance. Same adjacent-only logic as the original, but micro-optimized: - Pre-extracted NumPy arrays (avoids per-row iloc overhead) - Row accumulation in a list (avoids repeated pd.concat in loop) - Single pd.concat at the end Time complexity: O(n) comparisons — only i vs i+1 Parameters: ----------- events : pd.DataFrame Seismic data for the given time range. Returns: -------- duplicates : pd.DataFrame Table with information about duplicate events. """ checks = ["earthquake", "volcanic eruption", "explosion", "outside of network interest"] selections = events[events['event_type'].isin(checks)].reset_index(drop=True) # Edge case: empty or single-event dataset if len(selections) < 2: return pd.DataFrame(columns=columns + ['Observations']) # --- Pre-extract arrays for fast access --- times = selections['time_value'].values # numpy datetime64 lats = selections['latitude_value'].values.astype(np.float64) lons = selections['longitude_value'].values.astype(np.float64) ids = selections['publicID'].values TIME_WINDOW = np.timedelta64(4, 's') DIST_THRESHOLD = 100 # km dup_rows = [] # Accumulate rows here, build DF once at the end for i in trange(len(selections) - 1, desc="Checking duplicates", unit=" events", leave=False): # Time check using numpy (faster than .total_seconds() on Timedelta) if abs(times[i + 1] - times[i]) <= TIME_WINDOW: # Distance check using haversine formula distance = calculate_haversine_distance(lats[i], lons[i], lats[i + 1], lons[i + 1]) if distance <= DIST_THRESHOLD: row_i = selections.iloc[i][columns].copy() row_i['Observations'] = f'Possible duplicate event of {ids[i + 1]}' row_j = selections.iloc[i + 1][columns].copy() row_j['Observations'] = f'Possible duplicate event of {ids[i]}' dup_rows.extend([row_i, row_j]) # Concat duplicates to one single Dataframe if dup_rows: duplicates = pd.DataFrame(dup_rows, columns=columns + ['Observations']).reset_index(drop=True) else: duplicates = pd.DataFrame(columns=columns + ['Observations']) return duplicates
In [8]:
Copied!
# Repeat the test with the adjacent-only logic, but micro-optimized
time1 = time.time()
duplicates_adjacent = check_duplicates_adjacent(event_df3)
time2 = time.time()
print(f"Number of duplicates found: {len(duplicates_adjacent)}")
print(f"Time taken to run the function: {time2 - time1} seconds")
# Repeat the test with the adjacent-only logic, but micro-optimized time1 = time.time() duplicates_adjacent = check_duplicates_adjacent(event_df3) time2 = time.time() print(f"Number of duplicates found: {len(duplicates_adjacent)}") print(f"Time taken to run the function: {time2 - time1} seconds")
                                                                                   
Number of duplicates found: 22
Time taken to run the function: 0.2341165542602539 seconds
In [9]:
Copied!
# Check if the results are the same
print(f"Number of duplicates found with original adjacent-only: {len(duplicates_previous)}")
print(f"Number of duplicates found with micro-optimized adjacent-only: {len(duplicates_adjacent)}")
# Check if the results are the same print(f"Number of duplicates found with original adjacent-only: {len(duplicates_previous)}") print(f"Number of duplicates found with micro-optimized adjacent-only: {len(duplicates_adjacent)}")
Number of duplicates found with original adjacent-only: 22
Number of duplicates found with micro-optimized adjacent-only: 22
In [11]:
Copied!
# Find differences between the two results
differences = pd.concat([duplicates_previous, duplicates_adjacent, duplicates_adjacent]).drop_duplicates(keep=False)
print(f"Number of differences between original and micro-optimized adjacent-only: {len(differences)}")
# Find differences between the two results differences = pd.concat([duplicates_previous, duplicates_adjacent, duplicates_adjacent]).drop_duplicates(keep=False) print(f"Number of differences between original and micro-optimized adjacent-only: {len(differences)}")
Number of differences between original and micro-optimized adjacent-only: 0

As you can see, the results are the same, but the time taken to run the function is significantly reduced. This is because we have avoided the overhead of pandas indexing in the loop, and we have also avoided the repeated concatenation of dataframes in each iteration. The time complexity of this function is still O(n), since we are only comparing each event with the next one in the list, but the constant factors have been reduced, which can make a big difference when there are many events in the dataset.

There are a significant improvement in the time taken to run the function. This micro-optimized version of the adjacent-only logic can be used as a baseline for further optimizations, such as using a more efficient algorithm that can reduce the time complexity of the search even further.

Checking for duplicates - Vectorized adjacent-only logic¶

Further improving: We can just calculate all the temporal and spatial distances between all the events in one-single shot using vectorized NumPy operations, and then filter the results to find the duplicates. This will reduce the time complexity of the search to O(n^2), but it will be much faster than the previous versions, since we are using vectorized operations instead of loops. Let's implement this version:

In [12]:
Copied!
def haversine_np(lat1, lon1, lat2, lon2):
    """
    Fully vectorized haversine distance (km) between paired lat/lon arrays.
    Replaces the scalar `hs.haversine()` calls inside the original loop.
    """
    R = 6371.0088
    lat1r, lat2r = np.radians(lat1), np.radians(lat2)
    dlat = lat2r - lat1r
    dlon = np.radians(lon2 - lon1)
    a = np.sin(dlat / 2.0) ** 2 + np.cos(lat1r) * np.cos(lat2r) * np.sin(dlon / 2.0) ** 2
    return 2 * R * np.arcsin(np.sqrt(a))

def check_duplicates_vectorized(
        events: pd.DataFrame,
        time_window: int,
        dist_threshold: float,
) -> pd.DataFrame:
    """
    Identifies duplicate events based on time proximity and geographical
    distance between temporally-adjacent events (after sorting by time).

    Time complexity: O(n) — all comparisons are vectorized NumPy array
    operations; only the (typically rare) confirmed duplicate pairs are
    iterated in Python to build per-row observation text.

    Parameters
    ----------
    events : pd.DataFrame
        Seismic data for the given time range. Must contain 'time_value',
        'latitude_value', 'longitude_value', and 'publicID' columns.
    time_window : int
        Time window in seconds to detect duplicated events.
    dist_threshold : float
        Distance threshold in kilometers to detect duplicates.

    Returns
    -------
    pd.DataFrame
        Subset of `events` (all original columns preserved) with an added
        'Observations' column, containing only the flagged duplicate rows.
    """
    if len(events) < 2:
        empty = events.copy()
        empty['Observations'] = pd.Series(dtype='object')
        return empty.iloc[0:0]

    sorted_events = events.sort_values('time_value').reset_index(drop=True)

    times = sorted_events['time_value'].to_numpy()
    lats  = sorted_events['latitude_value'].to_numpy(dtype=np.float64)
    lons  = sorted_events['longitude_value'].to_numpy(dtype=np.float64)
    ids   = sorted_events['publicID'].to_numpy()

    time_diff   = np.abs(times[1:] - times[:-1])
    within_time = time_diff <= np.timedelta64(time_window, 's')

    distances   = haversine_np(lats[:-1], lons[:-1], lats[1:], lons[1:])
    within_dist = distances <= dist_threshold

    adjacent_dup = within_time & within_dist
    dup_idx = np.where(adjacent_dup)[0]

    if dup_idx.size == 0:
        empty = events.copy()
        empty['Observations'] = pd.Series(dtype='object')
        return empty.iloc[0:0]

    obs_map: dict[int, list[str]] = {}
    for i in dup_idx:
        obs_map.setdefault(int(i), []).append(f'Possible duplicate event of {ids[i + 1]}')
        obs_map.setdefault(int(i) + 1, []).append(f'Possible duplicate event of {ids[i]}')

    flagged_positions = sorted(obs_map.keys())
    flagged = sorted_events.iloc[flagged_positions].copy()
    flagged['Observations'] = ['; '.join(obs_map[p]) for p in flagged_positions]

    return flagged.reset_index(drop=True)
def haversine_np(lat1, lon1, lat2, lon2): """ Fully vectorized haversine distance (km) between paired lat/lon arrays. Replaces the scalar `hs.haversine()` calls inside the original loop. """ R = 6371.0088 lat1r, lat2r = np.radians(lat1), np.radians(lat2) dlat = lat2r - lat1r dlon = np.radians(lon2 - lon1) a = np.sin(dlat / 2.0) ** 2 + np.cos(lat1r) * np.cos(lat2r) * np.sin(dlon / 2.0) ** 2 return 2 * R * np.arcsin(np.sqrt(a)) def check_duplicates_vectorized( events: pd.DataFrame, time_window: int, dist_threshold: float, ) -> pd.DataFrame: """ Identifies duplicate events based on time proximity and geographical distance between temporally-adjacent events (after sorting by time). Time complexity: O(n) — all comparisons are vectorized NumPy array operations; only the (typically rare) confirmed duplicate pairs are iterated in Python to build per-row observation text. Parameters ---------- events : pd.DataFrame Seismic data for the given time range. Must contain 'time_value', 'latitude_value', 'longitude_value', and 'publicID' columns. time_window : int Time window in seconds to detect duplicated events. dist_threshold : float Distance threshold in kilometers to detect duplicates. Returns ------- pd.DataFrame Subset of `events` (all original columns preserved) with an added 'Observations' column, containing only the flagged duplicate rows. """ if len(events) < 2: empty = events.copy() empty['Observations'] = pd.Series(dtype='object') return empty.iloc[0:0] sorted_events = events.sort_values('time_value').reset_index(drop=True) times = sorted_events['time_value'].to_numpy() lats = sorted_events['latitude_value'].to_numpy(dtype=np.float64) lons = sorted_events['longitude_value'].to_numpy(dtype=np.float64) ids = sorted_events['publicID'].to_numpy() time_diff = np.abs(times[1:] - times[:-1]) within_time = time_diff <= np.timedelta64(time_window, 's') distances = haversine_np(lats[:-1], lons[:-1], lats[1:], lons[1:]) within_dist = distances <= dist_threshold adjacent_dup = within_time & within_dist dup_idx = np.where(adjacent_dup)[0] if dup_idx.size == 0: empty = events.copy() empty['Observations'] = pd.Series(dtype='object') return empty.iloc[0:0] obs_map: dict[int, list[str]] = {} for i in dup_idx: obs_map.setdefault(int(i), []).append(f'Possible duplicate event of {ids[i + 1]}') obs_map.setdefault(int(i) + 1, []).append(f'Possible duplicate event of {ids[i]}') flagged_positions = sorted(obs_map.keys()) flagged = sorted_events.iloc[flagged_positions].copy() flagged['Observations'] = ['; '.join(obs_map[p]) for p in flagged_positions] return flagged.reset_index(drop=True)
In [13]:
Copied!
# Repeat the test with the adjacent-only logic, but vectorized
time1 = time.time()
# Filter events by label:
filtered = event_df3[event_df3['event_type'].isin(["earthquake", "volcanic eruption", "explosion", "outside of network interest"])].reset_index(drop=True)
duplicates_vectorized = check_duplicates_vectorized(filtered, time_window=4, dist_threshold=100)
time2 = time.time()
print(f"Number of duplicates found: {len(duplicates_vectorized)}")
print(f"Time taken to run the function: {time2 - time1} seconds")
# Repeat the test with the adjacent-only logic, but vectorized time1 = time.time() # Filter events by label: filtered = event_df3[event_df3['event_type'].isin(["earthquake", "volcanic eruption", "explosion", "outside of network interest"])].reset_index(drop=True) duplicates_vectorized = check_duplicates_vectorized(filtered, time_window=4, dist_threshold=100) time2 = time.time() print(f"Number of duplicates found: {len(duplicates_vectorized)}") print(f"Time taken to run the function: {time2 - time1} seconds")
Number of duplicates found: 22
Time taken to run the function: 0.02883005142211914 seconds

The apparent discrepancy between the results is due to the fact that the vectorized version joins the observations for each event into a single string, while the previous versions created separate rows for each observation. This means that if an event is flagged as a duplicate of multiple other events, it will appear only once in the vectorized result, with all relevant observations concatenated. In contrast, the previous versions would create multiple rows for that event, one for each duplicate relationship.

Ignoring this difference in representation, the underlying logic for identifying duplicates is consistent across all implementations. The vectorized approach is significantly faster due to its use of NumPy operations, which are optimized for performance and can handle large datasets more efficiently than iterative loops in Python.

Checking Duplicates - Sorted Sliding Window Approach¶

Now, let's use a improved version created using the Sorted Sliding Window approach. We will also compare the results with the previous version of the function, which was based on a nested loop that compared all events with each other, and see how much time we can save with the new algorithm.

In [16]:
Copied!
def check_duplicates_2(events: pd.DataFrame) -> pd.DataFrame:
    """
        Identifies duplicate events based on time and geographical distance. Optimized using a sorted sliding time window (O(n log n)).

    Parameters:
    -----------
    events : pd.DataFrame
        Seismic data for the given time range.

    Returns:
    --------
    duplicates : pd.DataFrame
        Table with information about duplicate events.
    """
    checks = ["earthquake", "volcanic eruption", "explosion", "outside of network interest"]
    selections = (
        events[events['event_type'].isin(checks)]
        .sort_values('time_value')
        .reset_index(drop=True)
    )

    # Edge case: empty or single-event dataset
    if len(selections) < 2:
        return pd.DataFrame(columns=columns + ['Observations'])

    # Pre-extract arrays for fast access (avoids per-row iloc overhead)
    times = selections['time_value'].values
    lats  = selections['latitude_value'].values
    lons  = selections['longitude_value'].values
    ids   = selections['publicID'].values

    TIME_WINDOW   = 4    # seconds
    DIST_THRESHOLD = 100  # km

    # Track which (i, j) pairs were already flagged to avoid duplicate rows
    flagged_pairs = set()
    dup_rows = []

    left = 0  # Left pointer of the sliding window

    for right in trange(1, len(selections), desc="Checking duplicates", unit=" events", leave=False):
        # Advance left pointer: drop events outside the 4-second window
        while left < right:
            delta = (times[right] - times[left]) / pd.Timedelta('1s')
            if delta > TIME_WINDOW:
                left += 1
            else:
                break

        # Compare `right` against every event still inside the window [left, right-1]
        for i in range(left, right):
            pair = (i, right) if i < right else (right, i)
            if pair in flagged_pairs:
                continue

            time_diff = abs(
                (selections.iloc[right]['time_value'] - selections.iloc[i]['time_value'])
                .total_seconds()
            )
            if time_diff > TIME_WINDOW:
                continue

            distance = calculate_haversine_distance(lats[i], lons[i], lats[right], lons[right])
            if distance <= DIST_THRESHOLD:
                flagged_pairs.add(pair)

                row_i = selections.iloc[i][columns].copy()
                row_i['Observations'] = f'Possible duplicate event of {ids[right]}'

                row_j = selections.iloc[right][columns].copy()
                row_j['Observations'] = f'Possible duplicate event of {ids[i]}'

                dup_rows.extend([row_i, row_j])

    # Build duplicates dataframe in one shot (avoids repeated pd.concat overhead)
    if dup_rows:
        duplicates = pd.DataFrame(dup_rows, columns=columns + ['Observations']).reset_index(drop=True)
    else:
        duplicates = pd.DataFrame(columns=columns + ['Observations'])

    return duplicates
def check_duplicates_2(events: pd.DataFrame) -> pd.DataFrame: """ Identifies duplicate events based on time and geographical distance. Optimized using a sorted sliding time window (O(n log n)). Parameters: ----------- events : pd.DataFrame Seismic data for the given time range. Returns: -------- duplicates : pd.DataFrame Table with information about duplicate events. """ checks = ["earthquake", "volcanic eruption", "explosion", "outside of network interest"] selections = ( events[events['event_type'].isin(checks)] .sort_values('time_value') .reset_index(drop=True) ) # Edge case: empty or single-event dataset if len(selections) < 2: return pd.DataFrame(columns=columns + ['Observations']) # Pre-extract arrays for fast access (avoids per-row iloc overhead) times = selections['time_value'].values lats = selections['latitude_value'].values lons = selections['longitude_value'].values ids = selections['publicID'].values TIME_WINDOW = 4 # seconds DIST_THRESHOLD = 100 # km # Track which (i, j) pairs were already flagged to avoid duplicate rows flagged_pairs = set() dup_rows = [] left = 0 # Left pointer of the sliding window for right in trange(1, len(selections), desc="Checking duplicates", unit=" events", leave=False): # Advance left pointer: drop events outside the 4-second window while left < right: delta = (times[right] - times[left]) / pd.Timedelta('1s') if delta > TIME_WINDOW: left += 1 else: break # Compare `right` against every event still inside the window [left, right-1] for i in range(left, right): pair = (i, right) if i < right else (right, i) if pair in flagged_pairs: continue time_diff = abs( (selections.iloc[right]['time_value'] - selections.iloc[i]['time_value']) .total_seconds() ) if time_diff > TIME_WINDOW: continue distance = calculate_haversine_distance(lats[i], lons[i], lats[right], lons[right]) if distance <= DIST_THRESHOLD: flagged_pairs.add(pair) row_i = selections.iloc[i][columns].copy() row_i['Observations'] = f'Possible duplicate event of {ids[right]}' row_j = selections.iloc[right][columns].copy() row_j['Observations'] = f'Possible duplicate event of {ids[i]}' dup_rows.extend([row_i, row_j]) # Build duplicates dataframe in one shot (avoids repeated pd.concat overhead) if dup_rows: duplicates = pd.DataFrame(dup_rows, columns=columns + ['Observations']).reset_index(drop=True) else: duplicates = pd.DataFrame(columns=columns + ['Observations']) return duplicates
In [17]:
Copied!
time1 = time.time()
duplicates_sstw = check_duplicates_2(event_df3)
time2 = time.time()
print(f"Number of duplicates found: {len(duplicates_sstw)}")
print(f"Time taken to run the function: {time2 - time1} seconds")
time1 = time.time() duplicates_sstw = check_duplicates_2(event_df3) time2 = time.time() print(f"Number of duplicates found: {len(duplicates_sstw)}") print(f"Time taken to run the function: {time2 - time1} seconds")
                                                                                  
Number of duplicates found: 22
Time taken to run the function: 0.9724502563476562 seconds

As you can see from here, the time taken to run the function is slightly higher than the micro-optimized adjacent-only version (51% slower), but it is still significantly lower than the original nested loop version (90% faster). Moreover, this method can find duplicates that are not adjacent in the list, as long as they are within the 4-second time window. This can be useful in cases where there are multiple events that occur within a short time frame, but are not necessarily adjacent in the list.

The time complexity of this function is O(n log n) due to the initial sorting step, and O(n * k) for the sliding window comparisons, where k is the average number of events within the 4-second window. In practice, this can be much faster than the O(n^2) complexity of the original nested loop approach, especially when there are many events in the dataset. Let's check if the duplicates found with this new approach are the same as the ones found with the adjacent-only logic, and see if there are any additional duplicates that were not found with the previous method.

In [19]:
Copied!
# Check if the results are the same
print(f"Number of duplicates found with adjacent-only: {len(duplicates_adjacent)}")
print(f"Number of duplicates found with sorted sliding window: {len(duplicates_sstw)}")
differences = pd.concat([duplicates_sstw, duplicates_adjacent, duplicates_adjacent]).drop_duplicates(keep=False)
print(f"Number of additional duplicates found with sorted sliding window: {len(differences)}")
# Check if the results are the same print(f"Number of duplicates found with adjacent-only: {len(duplicates_adjacent)}") print(f"Number of duplicates found with sorted sliding window: {len(duplicates_sstw)}") differences = pd.concat([duplicates_sstw, duplicates_adjacent, duplicates_adjacent]).drop_duplicates(keep=False) print(f"Number of additional duplicates found with sorted sliding window: {len(differences)}")
Number of duplicates found with adjacent-only: 22
Number of duplicates found with sorted sliding window: 22
Number of additional duplicates found with sorted sliding window: 0

Checking Duplicates - Vectorized Sorted Sliding Window Approach¶

Now, let's implement a fully vectorized version of the sorted sliding window approach. This will allow us to leverage NumPy's efficient array operations to further reduce the time complexity of the search. The idea is to precompute the time and distance matrices for all events, and then filter the results based on the specified time and distance thresholds. This will allow us to identify duplicate events without the need for nested loops, resulting in a significant speedup for large datasets.

In [21]:
Copied!
def check_duplicates_2_vectorized(
        events: pd.DataFrame,
        columns: list[str],
        time_window: int,
        dist_threshold: float,
) -> pd.DataFrame:
    """
    Identifies duplicate events based on time proximity and geographical
    distance, using a sliding time window (compares every event against
    ALL others within time_window seconds, not just its direct neighbor).

    Parameters
    ----------
    events : pd.DataFrame
        Seismic data for the given time range. Must contain the columns
        referenced by `columns`.
    columns : list[str]
        Ordered list [time_col, lat_col, lon_col, id_col] identifying which
        columns hold time, latitude, longitude, and event ID values.
    time_window : int
        Time window in seconds to detect duplicated events.
    dist_threshold : float
        Distance threshold in kilometers to detect duplicates.

    Returns
    -------
    pd.DataFrame
        Subset of `events` (all original columns preserved) with an added
        'Observations' column, one row per flagged duplicate event —
        chains of 3+ nearby events are merged into a single row per event
        rather than duplicated across multiple pair-rows.
    """
    time_col, lat_col, lon_col, id_col = columns
    selections = events.copy()

    if len(selections) < 2:
        empty = events.copy()
        empty['Observations'] = pd.Series(dtype='object')
        return empty.iloc[0:0]

    sorted_events = selections.sort_values(time_col).reset_index(drop=True)

    times = sorted_events[time_col].to_numpy()
    lats  = sorted_events[lat_col].to_numpy(dtype=np.float64)
    lons  = sorted_events[lon_col].to_numpy(dtype=np.float64)
    ids   = sorted_events[id_col].to_numpy()
    n     = len(sorted_events)

    window_delta = np.timedelta64(time_window, 's')

    # For every i, right_bound[i] = first index j where times[j] > times[i] + window
    right_bound = np.searchsorted(times, times + window_delta, side='right')

    obs_map: dict[int, list[str]] = {}

    for i in range(n - 1):
        j_end = right_bound[i]
        if j_end <= i + 1:
            continue

        window_lats = lats[i + 1:j_end]
        window_lons = lons[i + 1:j_end]
        distances = haversine_np(lats[i], lons[i], window_lats, window_lons)

        matched_offsets = np.where(distances <= dist_threshold)[0]
        if matched_offsets.size == 0:
            continue

        matched_js = i + 1 + matched_offsets
        for j in matched_js:
            obs_map.setdefault(i, []).append(f'Possible duplicate event of {ids[j]}')
            obs_map.setdefault(int(j), []).append(f'Possible duplicate event of {ids[i]}')

    if not obs_map:
        empty = events.copy()
        empty['Observations'] = pd.Series(dtype='object')
        return empty.iloc[0:0]

    flagged_positions = sorted(obs_map.keys())
    flagged = sorted_events.iloc[flagged_positions].copy()
    flagged['Observations'] = ['; '.join(obs_map[p]) for p in flagged_positions]

    return flagged.reset_index(drop=True)
def check_duplicates_2_vectorized( events: pd.DataFrame, columns: list[str], time_window: int, dist_threshold: float, ) -> pd.DataFrame: """ Identifies duplicate events based on time proximity and geographical distance, using a sliding time window (compares every event against ALL others within time_window seconds, not just its direct neighbor). Parameters ---------- events : pd.DataFrame Seismic data for the given time range. Must contain the columns referenced by `columns`. columns : list[str] Ordered list [time_col, lat_col, lon_col, id_col] identifying which columns hold time, latitude, longitude, and event ID values. time_window : int Time window in seconds to detect duplicated events. dist_threshold : float Distance threshold in kilometers to detect duplicates. Returns ------- pd.DataFrame Subset of `events` (all original columns preserved) with an added 'Observations' column, one row per flagged duplicate event — chains of 3+ nearby events are merged into a single row per event rather than duplicated across multiple pair-rows. """ time_col, lat_col, lon_col, id_col = columns selections = events.copy() if len(selections) < 2: empty = events.copy() empty['Observations'] = pd.Series(dtype='object') return empty.iloc[0:0] sorted_events = selections.sort_values(time_col).reset_index(drop=True) times = sorted_events[time_col].to_numpy() lats = sorted_events[lat_col].to_numpy(dtype=np.float64) lons = sorted_events[lon_col].to_numpy(dtype=np.float64) ids = sorted_events[id_col].to_numpy() n = len(sorted_events) window_delta = np.timedelta64(time_window, 's') # For every i, right_bound[i] = first index j where times[j] > times[i] + window right_bound = np.searchsorted(times, times + window_delta, side='right') obs_map: dict[int, list[str]] = {} for i in range(n - 1): j_end = right_bound[i] if j_end <= i + 1: continue window_lats = lats[i + 1:j_end] window_lons = lons[i + 1:j_end] distances = haversine_np(lats[i], lons[i], window_lats, window_lons) matched_offsets = np.where(distances <= dist_threshold)[0] if matched_offsets.size == 0: continue matched_js = i + 1 + matched_offsets for j in matched_js: obs_map.setdefault(i, []).append(f'Possible duplicate event of {ids[j]}') obs_map.setdefault(int(j), []).append(f'Possible duplicate event of {ids[i]}') if not obs_map: empty = events.copy() empty['Observations'] = pd.Series(dtype='object') return empty.iloc[0:0] flagged_positions = sorted(obs_map.keys()) flagged = sorted_events.iloc[flagged_positions].copy() flagged['Observations'] = ['; '.join(obs_map[p]) for p in flagged_positions] return flagged.reset_index(drop=True)
In [22]:
Copied!
# Repeat the test with the vectorized sswa
time1 = time.time()
duplicates_vectorized_sswa = check_duplicates_2_vectorized(filtered, time_window=4, dist_threshold=100, columns=['time_value', 'latitude_value', 'longitude_value', 'publicID'])
time2 = time.time()
print(f"Number of duplicates found: {len(duplicates_vectorized_sswa)}")
print(f"Time taken to run the function: {time2 - time1} seconds")
# Repeat the test with the vectorized sswa time1 = time.time() duplicates_vectorized_sswa = check_duplicates_2_vectorized(filtered, time_window=4, dist_threshold=100, columns=['time_value', 'latitude_value', 'longitude_value', 'publicID']) time2 = time.time() print(f"Number of duplicates found: {len(duplicates_vectorized_sswa)}") print(f"Time taken to run the function: {time2 - time1} seconds")
Number of duplicates found: 22
Time taken to run the function: 0.03940939903259277 seconds

Overall results¶

Summarizing the results of the different duplicate detection methods, we have for 50987 events:

  1. Original, for loop version: 7.795 s
  2. Original, micro-optimized adjacent-only: 0.234 s
  3. Vectorized adjacent-only: 0.029 s
  4. Sorted Sliding Window: 0.972 s
  5. Vectorized Sorted Sliding Window: 0.039 s

Therefore, the overall recommendation is to use the vectorized adjacent-only version if the time window is similar to the mean temporal separation between events, since it is the fastest and most efficient method. However, if the time window is larger than the mean temporal separation between events, the sorted sliding window approach may be more appropriate, as it can detect duplicates that are not adjacent in the list.

At SieveComP, we are constantly working to improve the performance and efficiency of our algorithms, and we hope that this tutorial has provided some insight into the optimization process for duplicate detection in seismic event data.

NOTE: Actually we have implemented the vectorized versions of the adjacent-only and sorted sliding window approaches in the latest version of the package, which can be used directly by calling the check_duplicates function with the appropriate parameters.

Previous Next

Built with MkDocs using a theme provided by Read the Docs.
KenethGarcia/SieveComP « Previous Next »