This notebook was developed by Keneth Garcia. Source and license info are on GitHub.
import numpy as np
import pandas as pd
from sievecomp.io.sql import load_sql
from sievecomp.core.runner import Runner
SieveComP: Advanced Usage¶
For those user that do not want to rely on the default TOML structure of SieveComP, this notebook will show how to use the library in a more advanced way, without requiring the user to create a TOML file. This is useful for those that want to use SieveComP in a more programmatic way, or for those that want to integrate SieveComP into their own codebase.
As a baseline of comparison, we will state some examples where I will show both the TOML and the programmatic way of using SieveComP. The examples stated here are real uses cases that I have encountered in my work, and I hope they will be useful for you as well.
One of SieveComP's most powerful features is its ability to bridge static configurations with dynamic Python applications. While your TOML files define standardized, reproducible SQL queries, building custom interfaces (like a CLI or GUI) often requires filtering data on the fly based on user input, such as date ranges.
Building Dynamic SeisComP Queries with the Runner Class¶
As we already show in the duplicates tutorial notebook, instead of hardcoding new SQL files for every possible filter combination, you can use a "query wrapping" technique. By loading the static SQL from your configuration and wrapping it inside a dynamic subquery, you can safely inject new parameters without breaking the original SQL structure.
This is really useful when developing custom applications on top of SieveComP, given that you often need to fetch raw catalog data for a specific time window before deciding whether to run the full quality-control engine. To achieve this, we can write a wrapper function that intercepts the SQL query defined in your TOML file, injects dynamic WHERE clauses for the start and end times, and executes it using the Runner's override parameters.
NOTE: In static cases where you don't need to create a lot of new SQL or TOML files, you can just use the
Runnerclass with a full query including the time range to make this easier (see the Quickstart guide).
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
How the Wrapper Works:
Config Extraction (Steps 1 & 3): We use the
Runner's internal Configuration Manager (_cm) to extract the exact name of the time column and the raw SQL string from your existing TOML definitions. This ensures your script remains synchronized with your configuration file.Safe Parameterization (Step 2): We map the pandas Timestamps into standard SQL datetime strings and load them into a dictionary (
sql_params). SieveComP's engine passes these to the database driver safely, preventing SQL injection vulnerabilities.The Subquery Trick (Step 4): Simply appending
WHERE start_time = Xto a complex SQL file full ofJOINstatements will usually cause syntax errors. By placing the original query insideFROM (...) AS base_query, we create a clean, safe table representation that we can easily filter and sort.Engine Overrides (Step 5): The
runner.run()method accepts asql_text_override. By settingperform_duplicates=Falseandperform_checks=False, we tell the SieveComP engine to act purely as a data fetcher, returning the unflagged data instantly.
Usage Example:
Once defined, you can use this function to easily paginate through days or months of data (don't forget to set the connection parameters in your TOML file):
# Initialize the runner
runner = Runner(config_path='../../examples/data/configs/seismic_revision_routine.toml')
start = pd.to_datetime("2026-04-01 00:00:00", utc=True)
end = pd.to_datetime("2026-08-01 00:00:00", utc=True)
# Fetch just the events for the specified window
events_df = fetch_events_by_time_range(
runner=runner,
query_name="SeisComP6",
start_time=start,
end_time=end
)
print("Number of events by event_type:")
print(events_df['event_type'].value_counts())
Number of events by event_type: event_type not locatable 18275 earthquake 9069 outside of network interest 640 explosion 456 not existing 95 volcanic eruption 9 Name: count, dtype: int64
Beyond the TOML: Custom Workflows and Advanced Architecture¶
While SieveComP's TOML files excel at creating standardized, reproducible quality-control pipelines, building custom tools—such as an interactive Jupyter dashboard, a Streamlit web app, or a specialized data parser—demands greater flexibility. You often need to filter data on the fly based on user input, tweak checking parameters without editing text files, or simply process raw data at maximum speed.
To bridge the gap between static configurations and dynamic Python applications, SieveComP is built with a layered architecture. Depending on your specific use case, you can interact with the engine at three distinct depths:
The Dynamic Wrapper (High-Level): As we explained previously, the dynamic wrapper allows you to intercept static SQL queries from your configuration and wrap them in dynamic bounds to fetch specific data slices directly from the database.
In-Memory Rule Injection (Mid-Level): Bypassing the
.tomlfile entirely by constructing programmatic condition dictionaries and pushing them directly into theRunner's active memory for instant re-evaluations.Raw Vectorized Masking (Low-Level): Stripping away the configuration manager completely to apply SieveComP's lightning-fast, core NumPy masking functions directly to your Pandas DataFrames.
The following examples will break down how to implement the last two approaches, giving you complete programmatic control over how you query, evaluate, and filter your seismic catalogs.
1. In-Memory Rule Injection - High RMS Events¶
Let's suppose that you want to filter out events with a high RMS value, but you don't want to create a new TOML file for this. Instead, you can define the condition in memory and inject it into the Runner's active configuration.
Because SieveComP parses TOML files directly into Python dictionaries under the hood, creating a programmatic instance of a TOML configuration simply means constructing the equivalent Python dictionary and injecting it into the Runner class:
# Check for events with a high RMS value (quality_standardError > 1.50)
high_RMS_rules = [
{
"name": "High RMS",
"event_type": "earthquake",
"conditions": [
{
"rule_type": "numeric",
"column": "quality_standardError",
"mode": "gt",
"threshold": 1.0
}
]
}
]
Once you have your programmatic dictionary, you can bypass the .toml file entirely by modifying the ConfigManager's data directly in memory.
# 1. Initialize the runner with a base configuration (for DB connection, etc.)
runner = runner
# 2. We will use the cached events DataFrame from the previous example, but you can also fetch new events using the dynamic wrapper function.
raw_events = events_df
# 3. Define the programmatic check (stated above)
high_RMS_rules = high_RMS_rules
# 4. Inject the custom check directly into the Runner's configuration memory
runner._cm.config_data["checks"] = high_RMS_rules
# 5. Run the engine using the cached data
# CRITICAL: set reload_config=False so the Runner uses our injected dictionary instead of reading the TOML file again!
result = runner.update_from_cache(
events_df=raw_events,
reload_config=False
)
print(f"Events matching the programmatic High RMS check: {len(result.checks)}")
Events matching the programmatic High RMS check: 1322
2. Raw Vectorized Masking - Invalid Labeling¶
Let's suppose you want to filter out events that have invalid labeling, such as events with a label value of None or an empty string. Instead of creating a new TOML file or injecting rules into the Runner, you can directly apply vectorized masking using SieveComP's core functions. This approach is the fastest and most flexible, allowing you to manipulate your DataFrame directly.
Translating a categorical TOML check into a raw vectorized mask requires mapping the keys from the [[checks.conditions]] block:
[[checks]]
name = "Invalid label"
[[checks.conditions]]
rule_type = "category"
column = "event_type"
mode = "not_in"
values = ["earthquake", "not locatable", "volcanic eruption", "explosion", "not existing", "outside of network interest"]
directly to the arguments of the available functions at sievecomp.checks.vectorized_masks. The following example shows how to implement this check programmatically:
from sievecomp.checks.vectorized_masks import non_numeric_mask
valid_labels = [
"earthquake",
"not locatable",
"volcanic eruption",
"explosion",
"not existing",
"outside of network interest"
]
# Translate the TOML condition directly into the vectorized function
invalid_label_mask = non_numeric_mask(
events=events_df,
column="event_type",
mode="not_in",
values=valid_labels
)
# Apply the boolean mask to isolate events with invalid labels
flagged_events = events_df[invalid_label_mask]
print(f"Events with invalid types found: {len(flagged_events)}")
Events with invalid types found: 0
Extended Examples and Custom Workflows¶
With the techniques outlined above, you can now build highly customized workflows that leverage SieveComP's powerful engine without being constrained by static TOML files. Whether you're developing a real-time monitoring dashboard, a batch processing pipeline, or an interactive data exploration tool, these programmatic approaches give you the flexibility to adapt SieveComP to your specific needs. This long LONG LONG section of the tutorial is meant to show you how to use SieveComP in a more advanced way, without requiring the user to create a TOML file. This is useful for those that want to use SieveComP in a more programmatic way, or for those that want to integrate SieveComP into their own codebase.
NOTE: The following examples are REAL use cases that I have encountered in my work, and I hope they will be useful for you as well. The examples are based on the seismic revision routine at RSNC, which is a real-world application of SieveComP. Take into account that, for the provided time range, all the events have been checked and modified if they satisfy the conditions of the checks. Therefore, the number of events that satisfy the conditions of the checks may be zero. However, the code is still valid and can be used to check for events that satisfy the conditions of the checks in other time ranges or datasets.
from sievecomp.checks.vectorized_masks import (
numeric_mask,
column_column_mask,
non_numeric_mask,
polygon_mask,
temporal_mask,
combine_masks
)
a. Locatable events: The Main Challenge of Single Comparisons¶
A check of the seismic revision routine at RSNC is to identify events that are locatable, but are labeled incorrectly as "not locatable". At RSNC, an event is locatable if it has at least 4 p phases and 2 s phases associated to it and are used to the localization model. This check is important because it can help to identify earthquakes that may have been misclassified and may require further investigation.
However, previous versions of the routine only checks a verification on the quality_associatedPhaseCount column to be greater than or equal to 8 (plus one due to an event with 6 phases in SeisComP3 will have a _quality_associatedPhaseCount_ of 7). Then, an event with for example 8 p phases and 0 s phases would be flagged as locatable, which is not correct.
The challenge here is that query the number of p and s phases associated to each event requires a join between the Origin and Arrival tables in the database, which can be time-consuming, especially for large datasets. Additionally, the check needs to be performed for each event individually, which can further increase the time taken to perform the check. Therefore, the strategy here is to use the columns quality_associatedPhaseCount, quality_usedPhaseCount, quality_usedStationCount and quality_associatedStationCount to create a vectorized check that can identify locatable events without the need for a join between the tables. This approach can significantly reduce the time taken to perform the check, while still providing accurate results.
After internal research at SGC, the locatable check is a composition of a column-to-column comparison and a numeric comparison. The conditions to check are:
In particular if an event with not locatable label have:
$$ quality\_usedStationCount \geq 4 \quad \text{and} \quad quality\_associatedPhaseCount \geq quality\_usedStationCount + 2$$
then it can be flagged as locatable, and therefore it should be reviewed to check if the label is correct or not. For SeisComP3 we can use the previous verification based on:
$$ quality\_associatedPhaseCount \geq 8$$
where the cutoff for SeisComP3 and SeisComP6 is 2026-03-17 00:00:00. We can implement this check by creating a boolean mask for the total condition, defined as:
$$ (event\_type = 'not locatable') \quad \text{and} \quad (quality\_usedStationCount \geq 4) \quad \text{and} \quad (quality\_associatedPhaseCount \geq quality\_usedStationCount + 2) \quad \text{if} \quad time\_value \geq 2026-03-17 \quad 00:00:00$$
$$\text{or}$$
$$ (event\_type = 'not locatable') \quad \text{and} \quad (quality\_associatedPhaseCount \geq 8) \quad \text{if} \quad time\_value < 2026-03-17 \quad 00:00:00$$
The next code snippet shows how to implement this check using the vectorized functions from SieveComP. The final result is a DataFrame with all the events that are locatable but are labeled as "not locatable". This check can be used to identify events that may have been misclassified and may require further investigation.
WARNING: Since 11-03-2025 17:00 HL the not locatable check was modified for events inside volcanic zones. Previously, all events inside these zones MUST have the not locatable label unless they have a comment with the word "DESTACADO". However, since that date, events inside these zones can be locatable and MUST have the SGC preferred origin.
# 1. Event type is 'not locatable'
not_locatable_mask = non_numeric_mask(
events=events_df,
column='event_type',
mode='in',
values=['not locatable']
)
# 2. Time condition for SeisComP3 and SeisComP6
time_condition_mask = temporal_mask(
events=events_df,
column='time_value',
mode='ge',
threshold='2026-03-17 00:00:00'
)
# 3. Quality conditions for SeisComP6
used_station_mask = numeric_mask(
events=events_df,
column='quality_usedStationCount',
mode='ge',
threshold=4.0
)
associated_phase_mask = column_column_mask(
events=events_df,
left_col='quality_associatedPhaseCount',
mode='ge',
right_col='quality_usedStationCount',
offset=2.0
)
# 4. Quality condition for SeisComP3
associated_phase_mask_sc3 = numeric_mask(
events=events_df,
column='quality_associatedPhaseCount',
mode='ge',
threshold=8.0
)
# Combine masks for SeisComP6 and SeisComP3 conditions
sc6_condition = combine_masks([not_locatable_mask, time_condition_mask, used_station_mask, associated_phase_mask], logic="and")
sc3_condition = combine_masks([not_locatable_mask, ~time_condition_mask, associated_phase_mask_sc3], logic="and")
final_mask = combine_masks([sc6_condition, sc3_condition], logic="or")
locatable_events = events_df[final_mask]
print(f"Number of locatable events in SeisComP6: {len(events_df[sc6_condition])}")
print(f"Number of locatable events in SeisComP3: {len(events_df[sc3_condition])}")
print(f"Total number of locatable events: {len(locatable_events)}")
Number of locatable events in SeisComP6: 54 Number of locatable events in SeisComP3: 0 Total number of locatable events: 54
b. Check if the event is inside/outside local zone and has wrong label¶
An example to apply polygonal checks is the case of events where the event type is "earthquake" or "volcanic eruption", but the event is located outside of the local zone defined by the polygon in the arealocal.txt file. This check can be implemented by creating a subset of the events that have event type "earthquake" or "volcanic eruption", and then applying the polygonal check to this subset to identify which events are outside of the local zone.
For this check we will consider two cases:
- The event is outside the local zone (i.e., outside of the polygon defined in the
arealocal.txtfile) and event_type is NOT "outside of network interest" or "not locatable" or ''not existing''. - The event is inside the local zone (i.e., inside the polygon defined in the
arealocal.txtfile) and event_type is "outside of network interest".
from shapely.geometry import Polygon
# 1. Event is outside the local zone and event_type is "earthquake" or "volcanic eruption"
path_to_polygons = "../../examples/data/polygons/SGC/RSNC/"
polygon_to_load = "arealocal.txt"
coords = np.loadtxt(path_to_polygons + polygon_to_load, delimiter=",", skiprows=1)
local_zone_polygon = Polygon(coords)
subset = events_df[events_df['event_type'].isin(["earthquake", "volcanic eruption"])]
# Use SieveComP's vectorized polygon mask
zone_mask = polygon_mask(
events=subset,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=local_zone_polygon,
mode='outside'
)
events_outside_local_zone = subset[zone_mask]
print(f"Number of events outside the local zone with event type 'earthquake' or 'volcanic eruption': {len(events_outside_local_zone)}")
Number of events outside the local zone with event type 'earthquake' or 'volcanic eruption': 0
# 2. Event is inside the local zone and event_type is "outside of network interest"
subset = events_df[events_df['event_type'] == "outside of network interest"]
# Use SieveComP's vectorized polygon mask
inside_mask = polygon_mask(
events=subset,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=local_zone_polygon,
mode='inside'
)
events_inside_local_zone = subset[inside_mask]
print(f"Number of events inside the local zone with event type 'outside of network interest': {len(events_inside_local_zone)}")
Number of events inside the local zone with event type 'outside of network interest': 0
c. Check if the event has not been processed by the user¶
To check if an event has not been processed by the user, we need to satisfy two conditions:
- The
creationInfo_authorshould be inside ["scanloc", "scautoloc_reg", "scanlocbay", "AI_picker"] - The
event['type']must be non "not existing" - The
creationInfo_agencyIDmust be "SGC"
all joined with the 'and' condition. We can implement this check by creating three boolean masks for each condition, and then combining them using the combine_masks function:
# Check if the event has not been processed by the user
# 1. creationInfo_author in ["scanloc", "scautoloc_reg", "scanlocbay", "AI_picker"]
author_mask = non_numeric_mask(
events=events_df,
column='creationInfo_author',
mode='in',
values=["scanloc", "scautoloc_reg", "scanlocbay", "AI_picker"]
)
# 2. event_type not "not existing"
event_type_mask = non_numeric_mask(
events=events_df,
column='event_type',
mode='not_in',
values=["not existing"]
)
# 3. creationInfo_agencyID is "SGC"
agency_mask = non_numeric_mask(
events=events_df,
column='creationInfo_agencyID',
mode='in',
values=["SGC"]
)
# Combine masks with 'and' condition
not_processed_mask = combine_masks([author_mask, event_type_mask, agency_mask], logic="and")
events_not_processed = events_df[not_processed_mask]
print(f"Number of events that have not been processed by the user: {len(events_not_processed)}")
Number of events that have not been processed by the user: 0
d. Check if any event inside the Caribbean Plate and Pacific Plate zones has depth higher than 30 km¶
For this check, we need to satisfy three conditions:
- The event is inside the Caribbean Plate or Pacific Plate zones, which are defined by the polygons in the
caribe.txtandnazca.txtfiles. - The event has a depth higher than 30 km.
- The event type is "earthquake" or "outside of network interest".
We can implement this check by creating a boolean mask for each condition, and then combining them using the combine_masks function:
# Load Caribbean Plate and Pacific Plate polygons
caribbean_polygon = Polygon(np.loadtxt(path_to_polygons + "caribe.txt", delimiter=",", skiprows=1))
pacific_polygon = Polygon(np.loadtxt(path_to_polygons + "nazca.txt", delimiter=",", skiprows=1))
# Filter dataframe by event_type
subset = events_df[events_df['event_type'].isin(["earthquake", "outside of network interest"])]
# Check if any event inside the Caribbean Plate and Pacific Plate zones has depth higher than 30 km
# 1. Event is inside the Caribbean Plate or Pacific Plate zones
caribbean_mask = polygon_mask(
events=subset,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=caribbean_polygon,
mode='inside'
)
pacific_mask = polygon_mask(
events=subset,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=pacific_polygon,
mode='inside'
)
zone_mask = combine_masks([caribbean_mask, pacific_mask], logic="or")
# 2. Event has depth higher than 30 km
depth_mask = numeric_mask(
events=subset,
column='depth_value',
mode='gt',
threshold=30.0
)
# Combine masks with 'and' condition
final_mask = combine_masks([zone_mask, depth_mask], logic="and")
events_in_zone_with_depth = subset[final_mask]
print(f"Number of events inside the Caribbean Plate and Pacific Plate zones with depth higher than 30 km: {len(events_in_zone_with_depth)}")
Number of events inside the Caribbean Plate and Pacific Plate zones with depth higher than 30 km: 0
e. Check if a 'DESTACADO' event has the correct label¶
At this section we need to check the following conditions:
- The event is NOT, ["not locatable", "outside of network interest", "volcanic eruption", "explosion", "not existing"]
- The event have magnitude higher than 4.0
- The event have NOT a comment with the word "DESTACADO" (considering both byte string and regular string)
However, since 2026 those events with longitudes lower than -82.5 (adjusted for Colombian local area) have a higher limit for the magnitude, which is 5.0 instead of 4.0, due to the low capability of the local network to locate events in that area. Here we can use two different approaches:
- Continue with the previous boolean mask for magnitude higher than 4.0, and add a mask for events with longitudes lower than -82.5 that have magnitude higher than 5.0, and then combine them with an 'or' condition.
- Create a new boolean mask considering the longitude condition. Then join the 'DESTACADO' comment mask with the new magnitude mask, which considers the longitude condition, using an 'and' condition.
We will implement the second approach, as it is more efficient and easier to read:
$$ (longitude \leq -82.5 \quad \text{and} \quad magnitude > 5.0) \quad \text{or} \quad (longitude > -82.5 \quad \text{and} \quad magnitude > 4.0)$$
# Filter dataframe by event_type
subset = events_df[~events_df['event_type'].isin([
"not locatable", "outside of network interest",
"volcanic eruption", "explosion", "not existing"
])]
# 1. Comment mask checking for missing "DESTACADO" label
# (Byte-string normalization is handled automatically by SieveComP)
destacado_mask = non_numeric_mask(
events=subset,
column='comment',
mode='not_in',
values=['DESTACADO']
)
# 2. Longitude condition (<= -82.5)
longitude_mask = numeric_mask(
events=subset,
column='longitude_value',
mode='le',
threshold=-82.5
)
# 3. Magnitude thresholds (5.0 and 4.0)
magnitude_mask_5 = numeric_mask(
events=subset,
column='magnitude_value',
mode='gt',
threshold=5.0
)
magnitude_mask_4 = numeric_mask(
events=subset,
column='magnitude_value',
mode='gt',
threshold=4.0
)
# 4. Combine longitude and magnitude conditions
# (longitude <= -82.5 AND mag > 5.0)
cond_far_west = combine_masks([longitude_mask, magnitude_mask_5], logic="and")
# (longitude > -82.5 AND mag > 4.0)
cond_local = combine_masks([~longitude_mask, magnitude_mask_4], logic="and")
# Join the two spatial magnitude rules
magnitude_mask = combine_masks([cond_far_west, cond_local], logic="or")
# 5. Combine the final magnitude logic with the 'DESTACADO' comment mask
final_mask = combine_masks([magnitude_mask, destacado_mask], logic="and")
events_destacado_without_label = subset[final_mask]
print(f"Number of 'DESTACADO' events without the correct label considering longitude condition: {len(events_destacado_without_label)}")
Number of 'DESTACADO' events without the correct label considering longitude condition: 2
f. Verify if 'DESTACADO' events inside NonLinLoc zone use the correct earth model¶
To check this condition, we need to verify the following:
- The event has a comment with the word "DESTACADO" (considering both byte string and regular string)
- The event is inside the NonLinLoc zone, which is defined by the polygon in the
NLL.txtfile. However, it must not be inside the VMM zone, which is defined by the polygon in theVMM.txtfile, as events inside the VMM zone should use a different earth model. - The event has the correct earth model specified by the 'earthModelID' column with value
Poveda_et_al_2018. - The creationInfo_agencyID should be "SGC", as the NonLinLoc locations are only performed for events processed by SGC.
# Load NonLinLoc and VMM polygons
nll_polygon = Polygon(np.loadtxt(path_to_polygons + "NLL.txt", delimiter=",", skiprows=1))
vmm_polygon = Polygon(np.loadtxt(path_to_polygons + "VMM.txt", delimiter=",", skiprows=1))
# Check if 'DESTACADO' events inside NonLinLoc zone use the correct earth model
# 1. Comment with the word "DESTACADO"
# (Byte-string normalization is handled automatically)
destacado_mask = non_numeric_mask(
events=events_df,
column='comment',
mode='in',
values=['DESTACADO']
)
# 2. Event is inside the NonLinLoc zone but not inside the VMM zone
nll_mask = polygon_mask(
events=events_df,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=nll_polygon,
mode='inside'
)
vmm_mask = polygon_mask(
events=events_df,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=vmm_polygon,
mode='outside'
)
zone_mask = combine_masks([nll_mask, vmm_mask], logic="and")
# 3. Earth model is NOT 'Poveda_et_al_2018'
earth_model_mask = non_numeric_mask(
events=events_df,
column='earthModelID',
mode='not_in',
values=['Poveda_et_al_2018']
)
# 4. Agency must be SGC
sgc_agency_mask = non_numeric_mask(
events=events_df,
column='creationInfo_agencyID',
mode='in',
values=["SGC"]
)
# Combine all masks with 'and' condition
destacado_without_nll = combine_masks(
[destacado_mask, zone_mask, earth_model_mask, sgc_agency_mask],
logic="and"
)
events_destacado_without_nll = events_df[destacado_without_nll]
print(f"Number of 'DESTACADO' events inside NonLinLoc zone without the correct earth model: {len(events_destacado_without_nll)}")
Number of 'DESTACADO' events inside NonLinLoc zone without the correct earth model: 0
g. Check for events outside NLL zone with NLL earth model¶
This check is similar to the previous one, but in this case we need to check for events that are outside the NonLinLoc zone but have the Poveda_et_al_2018 earth model, which is only used for events inside the NonLinLoc zone. The conditions to check are:
- The event is outside the NonLinLoc zone, which is defined by the polygon in the
NLL.txtfile. - The event has the
Poveda_et_al_2018earth model specified
# 1. Event is outside the NonLinLoc zone
nll_mask = polygon_mask(
events=events_df,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=nll_polygon,
mode='outside'
)
# 2. Earth model is 'Poveda_et_al_2018'
earth_model_mask = non_numeric_mask(
events=events_df,
column='earthModelID',
mode='in',
values=['Poveda_et_al_2018']
)
# Combine masks with 'and' condition
outside_nll_with_nll_model = combine_masks([nll_mask, earth_model_mask], logic="and")
events_outside_nll_with_nll_model = events_df[outside_nll_with_nll_model]
print(f"Number of events outside NonLinLoc zone with NonLinLoc earth model: {len(events_outside_nll_with_nll_model)}")
Number of events outside NonLinLoc zone with NonLinLoc earth model: 14
h. Potentially locatable events¶
As explained earlier, the criteria for those events that are not locatable but need one p or one s phase to be locatable looks like:
If an event have 3 p and at least 2 s phases, it requires only one p to be locatable, or if an event have 4 p and at least 1 s phase, it requires only one s to be locatable:
$$ quality\_usedStationCount == 3 \quad \text{and} \quad quality\_associatedPhaseCount \geq quality\_usedStationCount + 2$$ $$ or $$ $$ quality\_usedStationCount \geq 4 \quad \text{and} \quad quality\_associatedPhaseCount \geq quality\_usedStationCount + 1$$
Using the same structure, for SeisComP3 we can use the previous established criteria:
$$ quality\_associatedPhaseCount \leq 7$$
# Check for potentially locatable events
# 1. Event type is 'not locatable'
not_locatable_mask = non_numeric_mask(
events=events_df,
column='event_type',
mode='in',
values=['not locatable']
)
# 2. Time condition for seiscomp3 and seiscomp6
time_condition_mask = temporal_mask(
events=events_df,
column='time_value',
mode='ge',
threshold='2026-03-17 00:00:00'
)
# 3. Quality conditions for seiscomp6
used_station_mask_1 = numeric_mask(
events=events_df,
column='quality_usedStationCount',
mode='eq',
threshold=3.0
)
associated_phase_mask_1 = column_column_mask(
events=events_df,
left_col='quality_associatedPhaseCount',
mode='ge',
right_col='quality_usedStationCount',
offset=2.0
)
potentially_locatable_mask_1 = combine_masks([used_station_mask_1, associated_phase_mask_1], logic="and")
used_station_mask_2 = numeric_mask(
events=events_df,
column='quality_usedStationCount',
mode='ge',
threshold=4.0
)
associated_phase_mask_2 = column_column_mask(
events=events_df,
left_col='quality_associatedPhaseCount',
mode='ge',
right_col='quality_usedStationCount',
offset=1.0
)
potentially_locatable_mask_2 = combine_masks([used_station_mask_2, associated_phase_mask_2], logic="and")
potentially_locatable_mask_sc6 = combine_masks(
[not_locatable_mask, time_condition_mask, combine_masks([potentially_locatable_mask_1, potentially_locatable_mask_2], logic="or")],
logic="and"
)
# 4. Quality condition for seiscomp3
associated_phase_mask_sc3 = numeric_mask(
events=events_df,
column='quality_associatedPhaseCount',
mode='le',
threshold=7.0
)
potentially_locatable_mask_sc3 = combine_masks(
[not_locatable_mask, ~time_condition_mask, associated_phase_mask_sc3],
logic="and"
)
# Combine masks for seiscomp6 and seiscomp3 conditions
final_mask = combine_masks([potentially_locatable_mask_sc6, potentially_locatable_mask_sc3], logic="or")
potentially_locatable_events = events_df[final_mask]
print(f"Number of potentially locatable events in seiscomp6: {len(events_df[potentially_locatable_mask_sc6])}")
print(f"Number of potentially locatable events in seiscomp3: {len(events_df[potentially_locatable_mask_sc3])}")
print(f"Total number of potentially locatable events: {len(potentially_locatable_events)}")
Number of potentially locatable events in seiscomp6: 2083 Number of potentially locatable events in seiscomp3: 0 Total number of potentially locatable events: 2083
i: Check correspondence between zones and magnitude labels¶
At the RSNC we consider two types of polygons:
- Seven objects for selecting magnitude_type: called
zona[1-5].txt,VMM.txtandptogaitan.txt, where magnitude_type should beMLr_[1-5],MLr_vmmandMLr_PtoGtnrespectively. - Four objects for selecting special earth models:
VMM.txtshould have only events with 'VMM' earth model,carma.txtwith 'CARMA' earth model,ptogaitan.txtwith 'Pto_Gaitan' earth model andcesar.txtwith 'modelCesar2'.
The two polygon types are NOT equal, excepting for VMM and Pto_Gaitan zones: For example, it can exist events with CARMA model with MLr_3 or MLr_4 magnitudes, but there aren't events with VMM model with magnitudes different from MLr_vmm, or events with Pto_Gaitan model with magnitudes different from MLr_PtoGtn. Also, zona_vmm.txt is inside zona3.txt and zona_PtoGaitan.txt is inside zona5.txt, but the rest of the polygons are not nested.
Furthermore, the check must be applied in opposite cases: Check for any event outside the corresponding polygon with the corresponding magnitude type, and check for any event inside the corresponding polygon with a magnitude type different from the corresponding one. For example, for the zona3.txt polygon, we need to check for events outside this polygon with magnitude type 'MLr_3' (a mistake), and events inside this polygon with magnitude type different from 'MLr_3' (another mistake). This means that for each polygon we need to check for two different conditions, which can be implemented by creating two boolean masks for each polygon and magnitude type, and then combining them with an 'or' condition.
One last thing: This check must be applied for every event different from not existing and not locatable and if the event have a 'DESTACADO' comment, it can have a moment magnitud (Mw) fixed instead of the corresponding MLr magnitude, so those events with 'DESTACADO' comment should be considered from this check (MUST NOT be ignored due that they can have a fixed Mw instead of the corresponding MLr magnitude, but they also can have a MLr magnitude different from the corresponding one, which is also a mistake).
i.1: Check for events outside zona3 with magnitude type MLr_3 and events inside zona3 with magnitude type different from MLr_3¶
The condition follows:
$$ ((magnitude\_type = MLr_3 \quad \text{and} \quad event \quad \text{is outside} \quad zona3) \quad \text{or} \quad (magnitude\_type \neq MLr_3 \quad \text{and} \quad event \quad \text{is inside} \quad zona3)) \quad \text{and} \quad (event\_type \neq 'not existing' \quad \text{and} \quad event\_type \neq 'not locatable')$$
and should be applied for events with event_type different from 'not existing' and 'not locatable'. Moreover, we must consider the 'DESTACADO' condition also, where events with this comment can have a moment magnitude (Mw) instead of the corresponding MLr magnitude, even if they are inside the corresponding polygon. This means that for events with 'DESTACADO' comment, the condition should be:
$$ If \quad comment \quad contains \quad 'DESTACADO' \quad then \quad magnitude\_type \quad in \quad 'Mw'$$
Summaryzing:
$$\text{Valid\_Event\_Type} \quad \mathbf{AND} \quad (\text{mlr3\_mask} \quad \mathbf{XOR} \quad \text{inside\_zona3\_mask}) \quad \mathbf{AND} \quad \mathbf{NOT} \quad (D \quad \mathbf{AND} \quad M)$$
where D is the condition that the comment contains 'DESTACADO' and M is the condition that the magnitude type is in ['MLr_3', 'Mw'].
# First condition: Filter dataframe by event_type
subset = events_df[~events_df['event_type'].isin(['not existing', 'not locatable'])]
# Load zona3 polygon
zona3_polygon = Polygon(np.loadtxt(path_to_polygons + "zona3.txt", delimiter=",", skiprows=1))
# Second condition: inside_zone3_mask
is_inside_zone3 = polygon_mask(
events=subset,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=zona3_polygon,
mode='inside'
)
# Third condition: mlr3_mask
have_mlr3 = non_numeric_mask(
events=subset,
column='magnitude_type',
mode='in',
values=['MLr_3']
)
# Fourth condition: DESTACADO comment mask (handled automatically)
destacado_mask = non_numeric_mask(
events=subset,
column='comment',
mode='in',
values=['DESTACADO']
)
# Fifth condition: magnitude type in 'Mw'
mw_mask = non_numeric_mask(
events=subset,
column='magnitude_type',
mode='in',
values=['Mw']
)
# Combine conditions
nested_1 = combine_masks([have_mlr3, is_inside_zone3], logic="xor") # mlr3_mask XOR is_inside_zone3
nested_2 = combine_masks([destacado_mask, mw_mask], logic="and") # DESTACADO AND Mw
final_mask = combine_masks([nested_1, ~nested_2], logic="and")
events_with_magnitude_zone3_issue = subset[final_mask]
print(f"Number of events with magnitude type and zona3 mismatch: {len(events_with_magnitude_zone3_issue)}")
Number of events with magnitude type and zona3 mismatch: 2
i.2. Check for events outside zona4 with magnitude type MLr_4 and events inside zona4 with magnitude type different from MLr_4¶
A similar approach can be followed for the zona4.txt polygon, where the magnitude type should be 'MLr_4'. Let be:
- I = Events inside zona4 polygon
- M = magnitude_type == 'MLr_4'
- V = valid event type, meaning event_type is neither 'not existing' nor 'not locatable'
- D = comment contains 'DESTACADO'
- A = magnitude_type == 'Mw'
Then the condition for zona4 is:
$$ V \quad \mathbf{AND} \quad (M \quad \mathbf{XOR} \quad I) \quad \mathbf{AND} \quad \mathbf{NOT} \quad (D \quad \mathbf{AND} \quad A)$$
where the first part of the condition is the same as for zona3, but changing the magnitude type to 'MLr_4', and the second part of the condition is also the same, where events with 'DESTACADO' comment can have a moment magnitude (Mw) instead of the corresponding MLr magnitude, even if they are inside the corresponding polygon.
# First condition: Filter dataframe by event_type
subset = events_df[~events_df['event_type'].isin(['not existing', 'not locatable'])]
# Load zona4 polygon (assumes path_to_polygons is defined from the previous cell)
zona4_polygon = Polygon(np.loadtxt(path_to_polygons + "zona4.txt", delimiter=",", skiprows=1))
# Second condition: inside_zone4_mask
is_inside_zone4 = polygon_mask(
events=subset,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=zona4_polygon,
mode='inside'
)
# Third condition: mlr4_mask
have_mlr4 = non_numeric_mask(
events=subset,
column='magnitude_type',
mode='in',
values=['MLr_4']
)
# Combine conditions
# (Assumes destacado_mask and mw_mask are already in memory from the previous step)
nested_1 = combine_masks([have_mlr4, is_inside_zone4], logic="xor") # mlr4_mask XOR is_inside_zone4
nested_2 = combine_masks([destacado_mask, mw_mask], logic="and") # DESTACADO AND Mw
final_mask = combine_masks([nested_1, ~nested_2], logic="and")
events_with_magnitude_zone4_issue = subset[final_mask]
print(f"Number of events with magnitude type and zona4 mismatch: {len(events_with_magnitude_zone4_issue)}")
Number of events with magnitude type and zona4 mismatch: 9
i.3. Zone2¶
As we see in previous sections, the format for checking regular zones follows the same format:
$$ V \quad \mathbf{AND} \quad (M \quad \mathbf{XOR} \quad I) \quad \mathbf{AND} \quad \mathbf{NOT} \quad (D \quad \mathbf{AND} \quad A)$$
where V is the valid event type condition, M is the magnitude type condition, I is the inside polygon condition, D is the 'DESTACADO' comment condition and A is the magnitude type in 'Mw' condition. However, for the zone 2 we have an extra condition: The VMM polygon is inside the zona2 polygon, but events inside the VMM polygon should have magnitude type 'MLr_vmm' instead of 'MLr_2', so we need to add another condition for events inside the VMM polygon with magnitude type 'MLr_vmm'. As we only want to check events for zone2 discarding events inside VMM zone, we can create a mask for events inside zona2 but outside VMM, and then combine it with the magnitude type condition with a 'xor' condition, and follows the same structure as before:
$$ I_{2,outer} \quad = \quad I_{2} \quad \mathbf{AND} \quad \mathbf{NOT} \quad I_{VMM}$$
and finally:
$$ V \quad \mathbf{AND} \quad (M \quad \mathbf{XOR} \quad I_{2,outer}) \quad \mathbf{AND} \quad \mathbf{NOT} \quad (D \quad \mathbf{AND} \quad A)$$
# Load zona2 polygon (fixed variable name to zona2_polygon)
zona2_polygon = Polygon(np.loadtxt(path_to_polygons + "zona2.txt", delimiter=",", skiprows=1))
# Load VMM polygon (updated filename to match the image structure)
zonavmm_polygon = Polygon(np.loadtxt(path_to_polygons + "VMM.txt", delimiter=",", skiprows=1))
# Second condition: inside_zone2_mask
is_inside_zone2 = polygon_mask(
events=subset,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=zona2_polygon,
mode='inside'
)
# Third condition: inside_vmm_mask
is_inside_vmm = polygon_mask(
events=subset,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=zonavmm_polygon,
mode='inside'
)
# Fourth condition: inside_zone2_outer_mask
is_inside_zone2_outer = combine_masks([is_inside_zone2, ~is_inside_vmm], logic="and")
# Fifth condition: mlr2_mask
have_mlr2 = non_numeric_mask(
events=subset,
column='magnitude_type',
mode='in',
values=['MLr_2']
)
# Combine conditions
# (Assumes destacado_mask and mw_mask are already in memory from previous cells)
nested_1 = combine_masks([have_mlr2, is_inside_zone2_outer], logic="xor") # mlr2_mask XOR is_inside_zone2_outer
nested_2 = combine_masks([destacado_mask, mw_mask], logic="and") # DESTACADO AND Mw
final_mask = combine_masks([nested_1, ~nested_2], logic="and")
events_with_magnitude_zone2_issue = subset[final_mask]
print(f"Number of events with magnitude type and zona2 mismatch: {len(events_with_magnitude_zone2_issue)}")
Number of events with magnitude type and zona2 mismatch: 14
i.4. Zone5¶
Zone 5 follows the same format as zone 2, but in this case the inner polygon is zona_PtoGaitan.txt and the magnitude type for events inside this inner polygon should be 'MLr_PtoGtn' instead of 'MLr_5'. So:
# Load zona5 polygon
zona5_polygon = Polygon(np.loadtxt(path_to_polygons + "zona5.txt", delimiter=",", skiprows=1))
# Load Pto Gaitan polygon (updated filename to match the image structure)
zonaPtoGtn_polygon = Polygon(np.loadtxt(path_to_polygons + "ptogaitan.txt", delimiter=",", skiprows=1))
is_inside_zone5 = polygon_mask(
events=subset,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=zona5_polygon,
mode='inside'
)
is_inside_PtoGtn = polygon_mask(
events=subset,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=zonaPtoGtn_polygon,
mode='inside'
)
is_inside_zone5_outer = combine_masks([is_inside_zone5, ~is_inside_PtoGtn], logic="and")
# Fifth condition: mlr5_mask (Updated to non_numeric_mask)
have_mlr5 = non_numeric_mask(
events=subset,
column='magnitude_type',
mode='in',
values=['MLr_5']
)
# Combine conditions
# (Assumes destacado_mask and mw_mask are already in memory from previous cells)
nested_1 = combine_masks([have_mlr5, is_inside_zone5_outer], logic="xor") # mlr5_mask XOR is_inside_zone5_outer
nested_2 = combine_masks([destacado_mask, mw_mask], logic="and") # DESTACADO AND Mw
final_mask = combine_masks([nested_1, ~nested_2], logic="and")
events_with_magnitude_zone5_issue = subset[final_mask]
print(f"Number of events with magnitude type and zona5 mismatch: {len(events_with_magnitude_zone5_issue)}")
Number of events with magnitude type and zona5 mismatch: 0
j. DESTACADO events with M<3 and outside interest network¶
It is almost impossible that an event with magnitude lower than 3 and outside network interest (defined in arealocal.txt) will be a DESTACADO event, as these events are usually not significant events in terms of impact and felt reports, and therefore they are usually not felt and do not cause damage. Let's implement a check to identify these events, which can be a mistake in the labeling of the event or in the magnitude estimation.
# First condition: DESTACADO comment mask (not mentioned cause it exists in previous cells)
# Second condition: Filter by magnitude (< 3.0)
mag_less_than_3 = numeric_mask(
events=subset,
column='magnitude_value',
mode='lt',
threshold=3.0
)
# Third condition: Filter by outside of network interest
# (Updated filename to match the new image structure)
outside_network_polygon = Polygon(np.loadtxt(path_to_polygons + "arealocal.txt", delimiter=",", skiprows=1))
outside_network_mask = polygon_mask(
events=subset,
lon_col='longitude_value',
lat_col='latitude_value',
polygon=outside_network_polygon,
mode='outside'
)
# Combine conditions with 'and' logic
final_mask = combine_masks([destacado_mask, mag_less_than_3, outside_network_mask], logic="and")
destacado_events_with_mag_less_than_3_outside_network = subset[final_mask]
print(f"Number of DESTACADO events with magnitude less than 3 and outside network interest: {len(destacado_events_with_mag_less_than_3_outside_network)}")
Number of DESTACADO events with magnitude less than 3 and outside network interest: 0