utdquake 0.2.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {utdquake-0.2.2/utdquake.egg-info → utdquake-0.3.0}/PKG-INFO +1 -1
  2. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/__init__.py +12 -2
  3. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/bank/bank.py +16 -7
  4. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/bank/utils.py +118 -24
  5. utdquake-0.3.0/utdquake/figures.py +442 -0
  6. utdquake-0.3.0/utdquake/hub.py +418 -0
  7. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/qc/catalog.py +67 -34
  8. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/qc/travel_time.py +84 -2
  9. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/utils/cache.py +1 -1
  10. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/utils/plot.py +34 -25
  11. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/utils/utils.py +26 -0
  12. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/writers/parquet.py +106 -1
  13. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/writers/schema.py +24 -7
  14. {utdquake-0.2.2 → utdquake-0.3.0/utdquake.egg-info}/PKG-INFO +1 -1
  15. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake.egg-info/SOURCES.txt +2 -0
  16. {utdquake-0.2.2 → utdquake-0.3.0}/LICENSE +0 -0
  17. {utdquake-0.2.2 → utdquake-0.3.0}/README.md +0 -0
  18. {utdquake-0.2.2 → utdquake-0.3.0}/setup.cfg +0 -0
  19. {utdquake-0.2.2 → utdquake-0.3.0}/setup.py +0 -0
  20. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/bank/__init__.py +0 -0
  21. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/core/__init__.py +0 -0
  22. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/core/config.py +0 -0
  23. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/core/data.py +0 -0
  24. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/core/load.py +0 -0
  25. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/core/patch.py +0 -0
  26. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/core/utdquake.py +0 -0
  27. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/core/utils.py +0 -0
  28. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/qc/__init__.py +0 -0
  29. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/qc/config.py +0 -0
  30. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/qc/events.py +0 -0
  31. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/qc/log.py +0 -0
  32. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/qc/picks.py +0 -0
  33. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/utils/__init__.py +0 -0
  34. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/writers/__init__.py +0 -0
  35. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake/writers/manifest.py +0 -0
  36. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake.egg-info/dependency_links.txt +0 -0
  37. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake.egg-info/requires.txt +0 -0
  38. {utdquake-0.2.2 → utdquake-0.3.0}/utdquake.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: utdquake
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: University of Texas at Dallas Earthquake Dataset
5
5
  Home-page: https://github.com/ecastillot/UTDQuake
6
6
  Author: ecastillot (Emmanuel Castillo)
@@ -9,6 +9,10 @@ includes:
9
9
  - `Network`: Class for network-specific access, including EventBank, picks, and stations.
10
10
  - `download_snapshot`: Function to download UTDQuake data from Hugging Face.
11
11
  - `load`: Function to load datasets from Hugging Face by key.
12
+ - `publish_network`: Function to publish a network's manifests to the shared Hub repo.
13
+ - `remove_network`: Function to remove a network's files from the shared Hub repo.
14
+ - `generate_network_figures`: Function to generate a network's documentation figures.
15
+ - `publish_network_figures`: Function to publish those figures to GitHub.
12
16
 
13
17
  Usage
14
18
  -----
@@ -20,11 +24,17 @@ Usage
20
24
  >>> net.events.head()
21
25
 
22
26
  """
23
- __version__ = "0.2.2"
27
+ __version__ = "0.3.0"
24
28
 
25
29
  from .core.utdquake import Dataset, Network
26
30
  from .core.data import download_snapshot, load
27
31
  from .core.patch import utdquake_obspy_patch
32
+ from .hub import publish_network, remove_network
33
+ from .figures import generate_network_figures, publish_network_figures
28
34
 
29
35
  utdquake_obspy_patch()
30
- __all__ = ["Dataset", "Network", "download_snapshot", "load"]
36
+ __all__ = [
37
+ "Dataset", "Network", "download_snapshot", "load",
38
+ "publish_network", "remove_network",
39
+ "generate_network_figures", "publish_network_figures",
40
+ ]
@@ -73,6 +73,7 @@ class UTDQBank(obsplus.EventBank):
73
73
  self.das = das
74
74
  self.contributor = os.path.basename(self.bank_path)
75
75
  self.db_paths = self.__prepare_paths()
76
+ self.utdq_paths = self.db_paths
76
77
 
77
78
  def __prepare_paths(self) -> None:
78
79
  """
@@ -297,7 +298,7 @@ class UTDQBank(obsplus.EventBank):
297
298
  raise FileNotFoundError(f"Folder not found: {folder_path}")
298
299
 
299
300
  # Build full glob pattern (e.g., /path/to/folder/*.xml)
300
- pattern = os.path.join(folder_path, file_extension)
301
+ pattern = os.path.join(folder_path, "**", file_extension)
301
302
 
302
303
  # Retrieve all matching files
303
304
  files: List[str] = glob.glob(pattern, recursive=True)
@@ -343,7 +344,8 @@ class UTDQBank(obsplus.EventBank):
343
344
  def put_utdq_stations(self, stations, starttime=None, endtime=None,
344
345
  chunk_seconds=86400,
345
346
  das=False,
346
- calculate_d_az=True) -> None:
347
+ calculate_d_az=True,
348
+ station_sample_size=None) -> None:
347
349
  """
348
350
  Save station metadata to events in the event bank over a given time range.
349
351
 
@@ -367,15 +369,21 @@ class UTDQBank(obsplus.EventBank):
367
369
  calculate_d_az : bool, optional
368
370
  Whether to calculate distance and azimuth between
369
371
  events and stations (default is True).
372
+ station_sample_size : int, optional
373
+ At most this many station records (one per arrival) per station
374
+ database, spread over the events: enough for the station-location
375
+ summary (calculated position and its spread). None (default)
376
+ keeps one per arrival. Distances and azimuths of all arrivals
377
+ are computed either way.
370
378
 
371
379
  """
372
380
 
373
381
  ebank_index_path = os.path.join(self.bank_path, ".index.db")
374
382
 
375
383
  if starttime is None:
376
- starttime = self.read_index()["time"].min()
384
+ starttime = self.read_index()["time"].min() - pd.Timedelta(seconds=1)
377
385
  if endtime is None:
378
- endtime = self.read_index()["time"].max()
386
+ endtime = self.read_index()["time"].max() + pd.Timedelta(seconds=1)
379
387
 
380
388
  iteration = 0
381
389
  for catalog_dict in ut.catalog_generator(self,starttime=starttime,
@@ -397,7 +405,8 @@ class UTDQBank(obsplus.EventBank):
397
405
  df_stations=stations,
398
406
  calculate_d_az=calculate_d_az,
399
407
  ebank_index_path=ebank_index_path,
400
- das=das
408
+ das=das,
409
+ station_sample_size=station_sample_size,
401
410
  )
402
411
 
403
412
  new_len_catalog = len(catalog)
@@ -536,7 +545,7 @@ class UTDQBank(obsplus.EventBank):
536
545
  i,
537
546
  total_chunks,
538
547
  )
539
- catalog.apply_utdq_qc()
548
+ catalog = catalog.apply_utdq_qc()
540
549
 
541
550
  events_df = catalog.utdq_events_to_df()
542
551
  picks_df = catalog.utdq_picks_to_df()
@@ -628,7 +637,7 @@ class UTDQBank(obsplus.EventBank):
628
637
  )
629
638
  """
630
639
 
631
- df = ut._read_table(self.index_path, query)
640
+ df = ut._read_table(self.utdq_paths[".utdquake/export/db/stations"], query)
632
641
 
633
642
  df = sanitize_dataframe(df,
634
643
  order_cols=PREF_STATIONS_ORDER,
@@ -1228,11 +1228,14 @@ def get_stations_summary(stations_folder,
1228
1228
  # --- Collapse into one row ---
1229
1229
  summary = {}
1230
1230
  exclude_cols = {"dist_deg", "esaz", "used", "origin_id", "creation_time"}
1231
+ std_cols = {"calculated_latitude", "calculated_longitude"}
1231
1232
  for col in df.columns:
1232
1233
  if col in exclude_cols:
1233
1234
  continue
1234
1235
  if pd.api.types.is_float_dtype(df[col]):
1235
1236
  summary[col] = df[col].mean()
1237
+ if col in std_cols:
1238
+ summary[f"{col}_std"] = df[col].std()
1236
1239
  elif pd.api.types.is_bool_dtype(df[col]):
1237
1240
  summary[col] = df[col].any() # True if any True
1238
1241
  elif pd.api.types.is_integer_dtype(df[col]):
@@ -1242,6 +1245,12 @@ def get_stations_summary(stations_folder,
1242
1245
  else:
1243
1246
  summary[col] = df[col].iloc[0] # fallback
1244
1247
 
1248
+ # Number of arrivals that contributed to the calculated position
1249
+ # (one row per origin at this point, since drop_duplicates above
1250
+ # collapses to one entry per origin_id).
1251
+ if "calculated_latitude" in df.columns:
1252
+ summary["calculated_num_entries"] = int(df["calculated_latitude"].notna().sum())
1253
+
1245
1254
  summary["db_path"] = db_path
1246
1255
  summary["creation_time"] = UTCDateTime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
1247
1256
  return summary
@@ -1265,13 +1274,23 @@ def process_arrival(arrival, source_loc,
1265
1274
  ebank_index_path,
1266
1275
  # table="/stations/index",
1267
1276
  calculate_d_az = False,
1268
- das=False
1277
+ das=False,
1278
+ row_sink=None,
1279
+ station_lookup=None,
1280
+ stations_folder=None,
1269
1281
  ):
1270
1282
  """
1271
1283
  Process an individual arrival: verify pick and station information,
1272
1284
  optionally update distance and azimuth, and add missing or confirmed
1273
1285
  station records to the database.
1274
1286
 
1287
+ With ``row_sink`` (a dict), the station record is appended to
1288
+ ``row_sink[<station db path>]`` instead of being written at once; the
1289
+ caller writes each station database once (see
1290
+ :func:`write_station_rows`). ``station_lookup`` ({(network, station):
1291
+ DataFrame}) and ``stations_folder`` avoid repeating per arrival what is
1292
+ the same for the whole catalog.
1293
+
1275
1294
  Parameters:
1276
1295
  arrival (Arrival): The arrival object to process.
1277
1296
  source_loc (tuple): Tuple of (latitude, longitude) for the event origin.
@@ -1294,15 +1313,15 @@ def process_arrival(arrival, source_loc,
1294
1313
  "network_type": None,
1295
1314
  "station": None,
1296
1315
  "channel": None,
1297
- "dist_deg": np.NaN,
1298
- "esaz": np.NaN,
1316
+ "dist_deg": np.nan,
1317
+ "esaz": np.nan,
1299
1318
  "confirmed": False,
1300
- "confirmed_latitude": np.NaN,
1301
- "confirmed_longitude": np.NaN,
1302
- "confirmed_elevation": np.NaN,
1319
+ "confirmed_latitude": np.nan,
1320
+ "confirmed_longitude": np.nan,
1321
+ "confirmed_elevation": np.nan,
1303
1322
  "calculated": False,
1304
- "calculated_latitude": np.NaN,
1305
- "calculated_longitude": np.NaN,
1323
+ "calculated_latitude": np.nan,
1324
+ "calculated_longitude": np.nan,
1306
1325
  "used": False,
1307
1326
  "origin_id": origin_id,
1308
1327
  "creation_time": UTCDateTime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
@@ -1323,7 +1342,10 @@ def process_arrival(arrival, source_loc,
1323
1342
  info["channel"] = cha
1324
1343
  phase = arrival.phase
1325
1344
 
1326
- df_sta = df_stations[(df_stations.network == net) & (df_stations.station == sta)]
1345
+ if station_lookup is not None:
1346
+ df_sta = station_lookup.get((net, sta), df_stations.iloc[0:0])
1347
+ else:
1348
+ df_sta = df_stations[(df_stations.network == net) & (df_stations.station == sta)]
1327
1349
 
1328
1350
  if df_sta is None:
1329
1351
  pass
@@ -1393,18 +1415,18 @@ def process_arrival(arrival, source_loc,
1393
1415
  # stations_folder = os.path.join( os.path.dirname(ebank_index_path),".stations")
1394
1416
 
1395
1417
  #finding the corresponding stations folder for the network
1396
- network_name = os.path.basename(os.path.dirname(ebank_index_path))
1397
- utdq_paths = get_utdq_paths(network_name)
1398
- stations_folder = utdq_paths[".utdquake/export/db/stations/.stations"]
1399
-
1400
- # stations_folder = os.path.join( stations_folder,".stations")
1401
-
1402
- os.makedirs(stations_folder, exist_ok=True)
1418
+ if stations_folder is None:
1419
+ stations_folder = get_stations_folder(ebank_index_path)
1420
+ os.makedirs(stations_folder, exist_ok=True)
1403
1421
  if das:
1404
1422
  stations_db_ev_path = os.path.join(stations_folder,f".{net}_{sta}_{cha}.db")
1405
1423
  else:
1406
1424
  stations_db_ev_path = os.path.join(stations_folder,f".{net}_{sta}.db")
1407
1425
 
1426
+ if row_sink is not None:
1427
+ row_sink.setdefault(stations_db_ev_path, []).append(info)
1428
+ return info
1429
+
1408
1430
  logger.info(stations_db_ev_path)
1409
1431
 
1410
1432
  with sqlite3.connect(stations_db_ev_path) as con:
@@ -1420,6 +1442,50 @@ def process_arrival(arrival, source_loc,
1420
1442
 
1421
1443
  return info
1422
1444
 
1445
+ def get_stations_folder(ebank_index_path):
1446
+ """The folder of the per-station databases of the network of an event bank."""
1447
+ network_name = os.path.basename(os.path.dirname(ebank_index_path))
1448
+ utdq_paths = get_utdq_paths(network_name)
1449
+ return utdq_paths[".utdquake/export/db"] / "stations" / f".{network_name}"
1450
+
1451
+
1452
+ def build_station_lookup(df_stations):
1453
+ """{(network, station): rows of df_stations}, to look stations up once per arrival without filtering."""
1454
+ if df_stations is None or df_stations.empty:
1455
+ return {}
1456
+ return {key: group for key, group in df_stations.groupby(["network", "station"], sort=False)}
1457
+
1458
+
1459
+ def write_station_rows(row_sink, station_sample_size=None):
1460
+ """
1461
+ Write the station records collected in ``row_sink`` ({db path: [records]}),
1462
+ one transaction per station database.
1463
+
1464
+ With ``station_sample_size``, each station database keeps at most that
1465
+ many records in total (spread evenly over the events of this call): enough
1466
+ for the station-location summary (the mean and spread of the positions
1467
+ back-projected from the arrivals), without one record per pick. Distances
1468
+ and azimuths of the arrivals themselves are not affected.
1469
+
1470
+ Returns the number of records written.
1471
+ """
1472
+ written = 0
1473
+ for path, rows in row_sink.items():
1474
+ with sqlite3.connect(path) as con:
1475
+ if station_sample_size is not None:
1476
+ try:
1477
+ have = con.execute('SELECT COUNT(*) FROM "/stations/index"').fetchone()[0]
1478
+ except sqlite3.OperationalError:
1479
+ have = 0
1480
+ room = max(station_sample_size - have, 0)
1481
+ if room < len(rows):
1482
+ keep = np.unique(np.linspace(0, len(rows) - 1, room).round().astype(int)) if room else []
1483
+ rows = [rows[i] for i in keep]
1484
+ if rows:
1485
+ pd.DataFrame(rows).to_sql("/stations/index", con, if_exists="append", index=False)
1486
+ written += len(rows)
1487
+ return written
1488
+
1423
1489
  # ------------------------------------------------------
1424
1490
  def update_origin_quality(origin, distances, esazs, stations_used, used_phase_count):
1425
1491
  """
@@ -1458,7 +1524,10 @@ def parse_origin(origin,
1458
1524
  df_stations=None,
1459
1525
  calculate_d_az=True,
1460
1526
  require_arrivals=True,
1461
- das=False
1527
+ das=False,
1528
+ row_sink=None,
1529
+ station_lookup=None,
1530
+ stations_folder=None,
1462
1531
  ):
1463
1532
  """
1464
1533
  Process all arrivals in a seismic Origin: verify picks, update
@@ -1498,7 +1567,10 @@ def parse_origin(origin,
1498
1567
  df_stations=df_stations,
1499
1568
  calculate_d_az=calculate_d_az,
1500
1569
  ebank_index_path=ebank_index_path,
1501
- das=das
1570
+ das=das,
1571
+ row_sink=row_sink,
1572
+ station_lookup=station_lookup,
1573
+ stations_folder=stations_folder,
1502
1574
  )
1503
1575
  # print("############### INFO",info)
1504
1576
  if not info["available"]:
@@ -1586,8 +1658,8 @@ def xxx(origin, df_stations,
1586
1658
  missing_info ={
1587
1659
  "network": net,
1588
1660
  "station": sta,
1589
- "latitude": np.NaN,
1590
- "longitude": np.NaN,
1661
+ "latitude": np.nan,
1662
+ "longitude": np.nan,
1591
1663
  "status": "missing",
1592
1664
  "creation_time": UTCDateTime.now().strftime("%Y-%m-%d %H:%M:%S.%f")}
1593
1665
  missing_info = pd.DataFrame([missing_info])
@@ -1673,8 +1745,8 @@ def xxx(origin, df_stations,
1673
1745
  missing_info = {
1674
1746
  "network": net,
1675
1747
  "station": sta,
1676
- "latitude": np.NaN,
1677
- "longitude": np.NaN,
1748
+ "latitude": np.nan,
1749
+ "longitude": np.nan,
1678
1750
  "status": "missing",
1679
1751
  "creation_time": UTCDateTime.now().strftime("%Y-%m-%d %H:%M:%S.%f")}
1680
1752
  missing_info = pd.DataFrame([missing_info])
@@ -1776,7 +1848,8 @@ def random_string(length=8):
1776
1848
  def append_stations_to_catalog(catalog: Catalog, df_stations,
1777
1849
  ebank_index_path: str,
1778
1850
  calculate_d_az=True,
1779
- das=False
1851
+ das=False,
1852
+ station_sample_size=None,
1780
1853
  ) -> tuple[Catalog, pd.DataFrame]:
1781
1854
  """
1782
1855
  Append distance and azimuth to each arrival in a catalog,
@@ -1789,13 +1862,27 @@ def append_stations_to_catalog(catalog: Catalog, df_stations,
1789
1862
  df_stations : pandas.DataFrame
1790
1863
  DataFrame containing station metadata with columns:
1791
1864
  ['network', 'station', 'latitude', 'longitude'].
1865
+ station_sample_size : int, optional
1866
+ At most this many records per station database (see
1867
+ :func:`write_station_rows`); None (default) keeps one per arrival.
1792
1868
 
1793
1869
  Returns
1794
1870
  -------
1795
1871
  catalog : obspy.core.event.Catalog
1796
1872
  Modified catalog with distance, azimuth, and quality metrics added.
1873
+
1874
+ Notes
1875
+ -----
1876
+ The station records are collected in memory and each station database is
1877
+ written once per call (one transaction per station instead of one per
1878
+ arrival: the per-arrival writes made this step take hours on a network
1879
+ filesystem). The records themselves are the same.
1797
1880
  """
1798
1881
  random_id = random_string(8)
1882
+ stations_folder = get_stations_folder(ebank_index_path)
1883
+ os.makedirs(stations_folder, exist_ok=True)
1884
+ station_lookup = build_station_lookup(df_stations)
1885
+ row_sink = {}
1799
1886
  logger.info(f"Starting to process {len(catalog.events)} events in catalog {random_id}. (This id is created just for logging purposes)")
1800
1887
 
1801
1888
  for event in catalog:
@@ -1810,7 +1897,10 @@ def append_stations_to_catalog(catalog: Catalog, df_stations,
1810
1897
  ebank_index_path=ebank_index_path,
1811
1898
  df_stations=df_stations,
1812
1899
  calculate_d_az=calculate_d_az,
1813
- das=das
1900
+ das=das,
1901
+ row_sink=row_sink,
1902
+ station_lookup=station_lookup,
1903
+ stations_folder=stations_folder,
1814
1904
  )
1815
1905
  if not gd_proc:
1816
1906
  logger.warning(f"Removing event {event.resource_id.id} due to too many missing picks.")
@@ -1818,6 +1908,10 @@ def append_stations_to_catalog(catalog: Catalog, df_stations,
1818
1908
  catalog.events.remove(event)
1819
1909
 
1820
1910
 
1911
+ n_rows = sum(len(r) for r in row_sink.values())
1912
+ written = write_station_rows(row_sink, station_sample_size=station_sample_size)
1913
+ logger.info(f"Wrote {written} of {n_rows} station records to {len(row_sink)} station databases "
1914
+ f"(station_sample_size={station_sample_size}).")
1821
1915
  logger.info(f"Finishing to process {len(catalog.events)} events in catalog {random_id}.")
1822
1916
 
1823
1917
  return catalog