data-availability 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: data-availability
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Plot data availability for seismic
5
5
  Keywords: volcano,volcanology,seismic,data,plot,availability
6
6
  Author: Martanto
@@ -11,7 +11,9 @@ Classifier: Intended Audience :: Science/Research
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: loguru>=0.7.3
14
15
  Requires-Dist: matplotlib>=3.10.9
16
+ Requires-Dist: obspy>=1.5.0
15
17
  Requires-Dist: openpyxl>=3.1.5
16
18
  Requires-Dist: pandas>=3.0.2
17
19
  Requires-Python: >=3.11
@@ -50,8 +52,8 @@ from data_availability import PlotAvailability
50
52
 
51
53
  fig = (
52
54
  PlotAvailability("data.xlsx")
53
- .load_data(years="2023")
54
- .plot_availability(title="Sensor Uptime", tile_shape="squircle")
55
+ .select(years="2023")
56
+ .plot(title="Sensor Uptime", tile_shape="squircle")
55
57
  )
56
58
  plt.savefig("availability.png", dpi=150, bbox_inches="tight")
57
59
  ```
@@ -70,21 +72,42 @@ df = pd.read_csv("data.csv")
70
72
  fig = plot_from_df(df, title="My Data")
71
73
  ```
72
74
 
75
+ ### Seismic SDS data
76
+
77
+ ```python
78
+ from data_availability import PlotSeismicAvailability
79
+
80
+ fig = (
81
+ PlotSeismicAvailability(
82
+ start_date="2023-01-01",
83
+ end_date="2023-12-31",
84
+ sds_dir="/data/sds",
85
+ station="IJEN",
86
+ channel="EHZ",
87
+ network="VG",
88
+ location="00",
89
+ n_jobs=4,
90
+ )
91
+ .plot(title="IJEN EHZ Availability 2023")
92
+ )
93
+ fig.savefig("ijen_availability.png", dpi=150, bbox_inches="tight")
94
+ ```
95
+
73
96
  ## API reference
74
97
 
75
98
  ### `PlotAvailability(filepath)`
76
99
 
77
- Fluent builder class.
100
+ Fluent builder class for Excel/CSV data.
78
101
 
79
102
  ```python
80
103
  fig = (
81
104
  PlotAvailability("data.xlsx")
82
- .load_data(
105
+ .select(
83
106
  date_column="date", # column name for dates
84
107
  completeness_column="completeness", # column name for values (0–100)
85
108
  years=["2022", "2023"], # filter to specific years (optional)
86
109
  )
87
- .plot_availability(
110
+ .plot(
88
111
  title="Data Availability",
89
112
  tile_shape="square", # "square" or "squircle"
90
113
  hspace=0.2,
@@ -98,9 +121,29 @@ fig = (
98
121
  )
99
122
  ```
100
123
 
124
+ ### `PlotSeismicAvailability(...)`
125
+
126
+ Reads a SeisComP Data Structure (SDS) archive, computes per-day completeness,
127
+ and renders the heatmap. Supports parallel processing via `n_jobs`.
128
+
129
+ ```python
130
+ PlotSeismicAvailability(
131
+ start_date="2023-01-01", # YYYY-MM-DD
132
+ end_date="2023-12-31", # YYYY-MM-DD (inclusive)
133
+ sds_dir="/data/sds", # root of the SDS archive
134
+ station="IJEN",
135
+ channel="EHZ",
136
+ network="VG",
137
+ location="00",
138
+ channel_type="D", # SDS data-type qualifier (default "D")
139
+ n_jobs=1, # parallel workers (default 1 = serial)
140
+ verbose=False,
141
+ ).plot(title="IJEN EHZ")
142
+ ```
143
+
101
144
  ### `plot_from_file(filepath, **kwargs)` / `plot_from_df(df, **kwargs)`
102
145
 
103
- Functional alternatives that accept the same keyword arguments as `.plot_availability()` plus `date_column` and `completeness_column`.
146
+ Functional alternatives that accept the same keyword arguments as `.plot()` plus `date_column` and `completeness_column`.
104
147
 
105
148
  ### `load_data(filepath, date_column, completeness_column)`
106
149
 
@@ -31,8 +31,8 @@ from data_availability import PlotAvailability
31
31
 
32
32
  fig = (
33
33
  PlotAvailability("data.xlsx")
34
- .load_data(years="2023")
35
- .plot_availability(title="Sensor Uptime", tile_shape="squircle")
34
+ .select(years="2023")
35
+ .plot(title="Sensor Uptime", tile_shape="squircle")
36
36
  )
37
37
  plt.savefig("availability.png", dpi=150, bbox_inches="tight")
38
38
  ```
@@ -51,21 +51,42 @@ df = pd.read_csv("data.csv")
51
51
  fig = plot_from_df(df, title="My Data")
52
52
  ```
53
53
 
54
+ ### Seismic SDS data
55
+
56
+ ```python
57
+ from data_availability import PlotSeismicAvailability
58
+
59
+ fig = (
60
+ PlotSeismicAvailability(
61
+ start_date="2023-01-01",
62
+ end_date="2023-12-31",
63
+ sds_dir="/data/sds",
64
+ station="IJEN",
65
+ channel="EHZ",
66
+ network="VG",
67
+ location="00",
68
+ n_jobs=4,
69
+ )
70
+ .plot(title="IJEN EHZ Availability 2023")
71
+ )
72
+ fig.savefig("ijen_availability.png", dpi=150, bbox_inches="tight")
73
+ ```
74
+
54
75
  ## API reference
55
76
 
56
77
  ### `PlotAvailability(filepath)`
57
78
 
58
- Fluent builder class.
79
+ Fluent builder class for Excel/CSV data.
59
80
 
60
81
  ```python
61
82
  fig = (
62
83
  PlotAvailability("data.xlsx")
63
- .load_data(
84
+ .select(
64
85
  date_column="date", # column name for dates
65
86
  completeness_column="completeness", # column name for values (0–100)
66
87
  years=["2022", "2023"], # filter to specific years (optional)
67
88
  )
68
- .plot_availability(
89
+ .plot(
69
90
  title="Data Availability",
70
91
  tile_shape="square", # "square" or "squircle"
71
92
  hspace=0.2,
@@ -79,9 +100,29 @@ fig = (
79
100
  )
80
101
  ```
81
102
 
103
+ ### `PlotSeismicAvailability(...)`
104
+
105
+ Reads a SeisComP Data Structure (SDS) archive, computes per-day completeness,
106
+ and renders the heatmap. Supports parallel processing via `n_jobs`.
107
+
108
+ ```python
109
+ PlotSeismicAvailability(
110
+ start_date="2023-01-01", # YYYY-MM-DD
111
+ end_date="2023-12-31", # YYYY-MM-DD (inclusive)
112
+ sds_dir="/data/sds", # root of the SDS archive
113
+ station="IJEN",
114
+ channel="EHZ",
115
+ network="VG",
116
+ location="00",
117
+ channel_type="D", # SDS data-type qualifier (default "D")
118
+ n_jobs=1, # parallel workers (default 1 = serial)
119
+ verbose=False,
120
+ ).plot(title="IJEN EHZ")
121
+ ```
122
+
82
123
  ### `plot_from_file(filepath, **kwargs)` / `plot_from_df(df, **kwargs)`
83
124
 
84
- Functional alternatives that accept the same keyword arguments as `.plot_availability()` plus `date_column` and `completeness_column`.
125
+ Functional alternatives that accept the same keyword arguments as `.plot()` plus `date_column` and `completeness_column`.
85
126
 
86
127
  ### `load_data(filepath, date_column, completeness_column)`
87
128
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "data-availability"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  authors = [
9
9
  {name = "Martanto", email = "martanto@live.com"},
10
10
  ]
@@ -14,7 +14,9 @@ license = "MIT"
14
14
  keywords = ["volcano", "volcanology", "seismic", "data", "plot", "availability"]
15
15
  requires-python = ">=3.11"
16
16
  dependencies = [
17
+ "loguru>=0.7.3",
17
18
  "matplotlib>=3.10.9",
19
+ "obspy>=1.5.0",
18
20
  "openpyxl>=3.1.5",
19
21
  "pandas>=3.0.2",
20
22
  ]
@@ -1,36 +1,40 @@
1
- """data-availability: GitHub-style calendar heatmaps for data completeness.
2
-
3
- Generates matplotlib figures showing data completeness over time, one subplot
4
- per calendar year, color-coded on a red-yellow-green gradient.
5
-
6
- Example:
7
- >>> from data_availability import plot_from_file
8
- >>> fig = plot_from_file("data.csv", title="Sensor Uptime")
9
- >>> fig.savefig("availability.png", dpi=150)
10
- """
11
-
12
- from importlib.metadata import version
13
-
14
- from data_availability.data import load_data
15
- from data_availability.plot import plot_from_df, plot_from_file
16
- from data_availability.availability import PlotAvailability
17
-
18
-
19
- __version__ = version("data-availability")
20
- __author__ = "Martanto"
21
- __author_email__ = "martanto@live.com"
22
- __license__ = "MIT"
23
- __copyright__ = "Copyright (c) 2026, Martanto"
24
- __url__ = "https://github.com/martanto/data-availability"
25
-
26
- __all__ = [
27
- "__version__",
28
- "__author__",
29
- "__author_email__",
30
- "__license__",
31
- "__copyright__",
32
- "PlotAvailability",
33
- "load_data",
34
- "plot_from_df",
35
- "plot_from_file",
36
- ]
1
+ """data-availability: GitHub-style calendar heatmaps for data completeness.
2
+
3
+ Generates matplotlib figures showing data completeness over time, one subplot
4
+ per calendar year, color-coded on a red-yellow-green gradient.
5
+
6
+ Example:
7
+ >>> from data_availability import plot_from_file
8
+ >>> fig = plot_from_file("data.csv", title="Sensor Uptime")
9
+ >>> fig.savefig("availability.png", dpi=150)
10
+ """
11
+
12
+ from importlib.metadata import version
13
+
14
+ from data_availability.data import load_data
15
+ from data_availability.plot import plot_from_df, plot_from_file
16
+ from data_availability.logger import configure_logging
17
+ from data_availability.availability import PlotAvailability
18
+ from data_availability.seismic.plot_seismic import PlotSeismicAvailability
19
+
20
+
21
+ __version__ = version("data-availability")
22
+ __author__ = "Martanto"
23
+ __author_email__ = "martanto@live.com"
24
+ __license__ = "MIT"
25
+ __copyright__ = "Copyright (c) 2026, Martanto"
26
+ __url__ = "https://github.com/martanto/data-availability"
27
+
28
+ __all__ = [
29
+ "__version__",
30
+ "__author__",
31
+ "__author_email__",
32
+ "__license__",
33
+ "__copyright__",
34
+ "PlotAvailability",
35
+ "PlotSeismicAvailability",
36
+ "configure_logging",
37
+ "load_data",
38
+ "plot_from_df",
39
+ "plot_from_file",
40
+ ]
@@ -11,18 +11,50 @@ from data_availability.plot import plot_from_df as _plot_from_df
11
11
 
12
12
 
13
13
  class PlotAvailability:
14
+ """Fluent builder for GitHub-style calendar heatmaps from file-based data.
15
+
16
+ Chain :meth:`select` then :meth:`plot` to load data and produce a figure.
17
+
18
+ Args:
19
+ filepath: Path to an ``.xlsx``, ``.xls``, or ``.csv`` file.
20
+
21
+ Example:
22
+ >>> fig = (
23
+ ... PlotAvailability("data.xlsx")
24
+ ... .select(years="2023")
25
+ ... .plot(title="Sensor Uptime", tile_shape="squircle")
26
+ ... )
27
+ >>> fig.savefig("availability.png", dpi=150, bbox_inches="tight")
28
+ """
29
+
14
30
  def __init__(self, filepath: str | Path) -> None:
15
31
  self._filepath = Path(filepath)
16
32
  self._df: pd.DataFrame | None = None
17
33
  self._date_column: str = "date"
18
34
  self._completeness_column: str = "completeness"
19
35
 
20
- def load_data(
36
+ def select(
21
37
  self,
22
38
  date_column: str = "date",
23
39
  completeness_column: str = "completeness",
24
40
  years: str | list[str] | None = None,
25
41
  ) -> PlotAvailability:
42
+ """Load and optionally filter data from the file supplied at construction.
43
+
44
+ Args:
45
+ date_column: Name of the date column in the source file.
46
+ completeness_column: Name of the completeness column (0–100) in the
47
+ source file.
48
+ years: One or more years to keep, e.g. ``"2023"`` or
49
+ ``["2022", "2023"]``. All years are kept when omitted.
50
+
51
+ Returns:
52
+ ``self`` — enables method chaining.
53
+
54
+ Raises:
55
+ KeyError: If the required columns are absent from the file.
56
+ ValueError: If ``years`` is specified but no matching rows exist.
57
+ """
26
58
  self._date_column = date_column
27
59
  self._completeness_column = completeness_column
28
60
  self._df = load_data(self._filepath, date_column, completeness_column)
@@ -36,7 +68,7 @@ class PlotAvailability:
36
68
  )
37
69
  return self
38
70
 
39
- def plot_availability(
71
+ def plot(
40
72
  self,
41
73
  title: str = "Data Availability",
42
74
  hspace: float = 0.2,
@@ -69,10 +101,11 @@ class PlotAvailability:
69
101
  A :class:`matplotlib.figure.Figure` containing the heatmap.
70
102
 
71
103
  Raises:
72
- RuntimeError: If :meth:`load_data` has not been called first.
104
+ RuntimeError: If :meth:`select` has not been called first.
73
105
  """
74
106
  if self._df is None:
75
- raise RuntimeError("Call .load_data() before .plot_availability().")
107
+ raise RuntimeError("Call .select() before .plot().")
108
+
76
109
  return _plot_from_df(
77
110
  self._df,
78
111
  title=title,
@@ -0,0 +1,151 @@
1
+ import os
2
+ import sys
3
+
4
+ from loguru import logger
5
+
6
+ from data_availability.utils import ensure_dir
7
+
8
+
9
+ # Retention periods for log files.
10
+ _GENERAL_LOG_RETENTION = "30 days"
11
+ _ERROR_LOG_RETENTION = "90 days"
12
+
13
+ # Tracks whether logging is currently enabled.
14
+ _logging_enabled: bool = True
15
+
16
+ # Default log directory — not created until logging is first configured.
17
+ DEFAULT_LOG_DIR = os.path.join(os.getcwd(), "logs")
18
+
19
+ _FILE_FORMAT = (
20
+ "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}"
21
+ )
22
+
23
+ _CONSOLE_FORMAT = (
24
+ "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | "
25
+ "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
26
+ )
27
+
28
+ logger.remove()
29
+
30
+
31
+ def _configure_handlers(log_dir: str, console_level: str = "INFO") -> None:
32
+ """Remove all existing handlers and re-add console + file handlers.
33
+
34
+ Centralises handler configuration so that module-level setup, set_log_level(),
35
+ and set_log_directory() all use the same retention periods and formats.
36
+
37
+ Args:
38
+ log_dir (str): Directory path for log file output.
39
+ console_level (str, optional): Log level for the console handler.
40
+ Defaults to "INFO".
41
+ """
42
+ ensure_dir(log_dir)
43
+ logger.remove()
44
+
45
+ logger.add(
46
+ sys.stderr,
47
+ format=_CONSOLE_FORMAT,
48
+ level=console_level.upper(),
49
+ colorize=True,
50
+ )
51
+
52
+ logger.add(
53
+ os.path.join(log_dir, "availability_{time:YYYY-MM-DD}.log"),
54
+ rotation="00:00",
55
+ retention=_GENERAL_LOG_RETENTION,
56
+ compression="zip",
57
+ format=_FILE_FORMAT,
58
+ level="DEBUG",
59
+ enqueue=True,
60
+ )
61
+
62
+ logger.add(
63
+ os.path.join(log_dir, "errors_{time:YYYY-MM-DD}.log"),
64
+ rotation="00:00",
65
+ retention=_ERROR_LOG_RETENTION,
66
+ compression="zip",
67
+ format=_FILE_FORMAT,
68
+ level="ERROR",
69
+ enqueue=True,
70
+ )
71
+
72
+
73
+ def configure_logging(log_dir: str | None = None, console_level: str = "INFO") -> None:
74
+ """Enable file + console logging for the package.
75
+
76
+ Must be called explicitly by the application; the library does **not** create
77
+ log files or directories on import.
78
+
79
+ Args:
80
+ log_dir: Directory for log files. Defaults to ``./logs`` relative to the
81
+ current working directory at call time.
82
+ console_level: Log level for the console handler (default ``"INFO"``).
83
+ """
84
+ global DEFAULT_LOG_DIR
85
+ if log_dir is not None:
86
+ DEFAULT_LOG_DIR = os.path.abspath(log_dir)
87
+ _configure_handlers(DEFAULT_LOG_DIR, console_level=console_level)
88
+
89
+
90
+ def get_logger():
91
+ """Return the package-wide loguru logger instance.
92
+
93
+ Returns:
94
+ loguru.Logger: The configured logger instance with console and file handlers.
95
+ """
96
+ return logger
97
+
98
+
99
+ def set_log_level(level: str) -> None:
100
+ """Change the console log level dynamically.
101
+
102
+ Removes all existing handlers and re-adds them with the new console level.
103
+ File handlers retain their original levels.
104
+
105
+ Args:
106
+ level (str): Desired log level for the console handler. One of
107
+ ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, ``"ERROR"``, or
108
+ ``"CRITICAL"``. Case-insensitive.
109
+ """
110
+ _configure_handlers(DEFAULT_LOG_DIR, console_level=level)
111
+
112
+
113
+ def set_log_directory(log_dir: str) -> None:
114
+ """Change the log file directory dynamically.
115
+
116
+ Updates the global ``DEFAULT_LOG_DIR``, creates the directory if needed,
117
+ then reconfigures all handlers to write to the new location.
118
+
119
+ Args:
120
+ log_dir (str): Absolute or relative path to the new log directory.
121
+ Created automatically if it does not exist.
122
+ """
123
+ global DEFAULT_LOG_DIR
124
+ DEFAULT_LOG_DIR = ensure_dir(os.path.abspath(log_dir))
125
+ _configure_handlers(DEFAULT_LOG_DIR)
126
+ logger.info(f"Log directory changed to: {DEFAULT_LOG_DIR}")
127
+
128
+
129
+ def disable_logging() -> None:
130
+ """Disable all logging output globally.
131
+
132
+ Remove all active loguru handlers so no messages are written to the
133
+ console or log files. Call :func:`enable_logging` to restore handlers.
134
+ """
135
+ global _logging_enabled
136
+ _logging_enabled = False
137
+ os.environ["DISABLE_LOGGING"] = "1"
138
+ logger.remove()
139
+
140
+
141
+ def enable_logging() -> None:
142
+ """Re-enable logging after a previous :func:`disable_logging` call.
143
+
144
+ Restore console and file handlers using the current ``DEFAULT_LOG_DIR``.
145
+ Has no effect if logging is already enabled.
146
+ """
147
+ global _logging_enabled
148
+ if not _logging_enabled:
149
+ _logging_enabled = True
150
+ os.environ.pop("DISABLE_LOGGING", None)
151
+ _configure_handlers(DEFAULT_LOG_DIR)
@@ -0,0 +1,181 @@
1
+ from typing import Literal
2
+ from datetime import datetime
3
+ from functools import cached_property
4
+ from multiprocessing import Pool
5
+
6
+ import pandas as pd
7
+ from matplotlib import pyplot as plt
8
+
9
+ from data_availability import plot_from_df
10
+ from data_availability.utils import to_datetime
11
+ from data_availability.logger import logger
12
+ from data_availability.seismic.sds import SDS
13
+
14
+
15
+ class PlotSeismicAvailability:
16
+ """Build a GitHub-style calendar heatmap from a SeisComP Data Structure (SDS) archive.
17
+
18
+ Iterates over every day in ``[start_date, end_date]``, computes the
19
+ completeness percentage for the given NSLC code via :class:`~data_availability.seismic.sds.SDS`,
20
+ and delegates figure construction to :func:`~data_availability.plot.plot_from_df`.
21
+ Parallel processing is supported through Python's :class:`multiprocessing.Pool`.
22
+
23
+ Args:
24
+ start_date: First date of the range in ``"YYYY-MM-DD"`` format.
25
+ end_date: Last date of the range in ``"YYYY-MM-DD"`` format (inclusive).
26
+ sds_dir: Root directory of the SDS archive.
27
+ station: Station code (case-insensitive).
28
+ channel: Channel code, e.g. ``"EHZ"`` (case-insensitive).
29
+ network: Network code, e.g. ``"VG"`` (case-insensitive).
30
+ location: Location code (case-insensitive).
31
+ channel_type: SDS data-type qualifier. Defaults to ``"D"``.
32
+ n_jobs: Number of parallel worker processes. Defaults to ``1`` (serial).
33
+ verbose: When ``True``, emit detailed log messages during processing.
34
+
35
+ Note:
36
+ On Windows, Python's ``multiprocessing`` uses the ``spawn`` start method.
37
+ When using ``n_jobs > 1`` in a script, wrap the call inside
38
+ ``if __name__ == "__main__":`` to prevent recursive worker spawning.
39
+
40
+ Example:
41
+ >>> fig = (
42
+ ... PlotSeismicAvailability(
43
+ ... start_date="2023-01-01",
44
+ ... end_date="2023-12-31",
45
+ ... sds_dir="/data/sds",
46
+ ... station="IJEN",
47
+ ... channel="EHZ",
48
+ ... network="VG",
49
+ ... location="00",
50
+ ... n_jobs=4,
51
+ ... )
52
+ ... .plot(title="IJEN EHZ Availability 2023")
53
+ ... )
54
+ >>> fig.savefig("ijen_availability.png", dpi=150, bbox_inches="tight")
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ start_date: str,
60
+ end_date: str,
61
+ sds_dir: str,
62
+ station: str,
63
+ channel: str,
64
+ network: str,
65
+ location: str,
66
+ channel_type: str = "D",
67
+ n_jobs: int = 1,
68
+ verbose: bool = False,
69
+ ):
70
+ self.start_date = to_datetime(start_date)
71
+ self.end_date = to_datetime(end_date)
72
+ self.dates = pd.date_range(self.start_date, self.end_date)
73
+ if n_jobs < 1:
74
+ raise ValueError("n_jobs must be >= 1")
75
+ self.sds = SDS(
76
+ sds_dir=sds_dir,
77
+ station=station,
78
+ network=network,
79
+ channel=channel,
80
+ location=location,
81
+ channel_type=channel_type,
82
+ verbose=verbose,
83
+ )
84
+ self.n_jobs = n_jobs
85
+ self.verbose = verbose
86
+
87
+ @cached_property
88
+ def _jobs(self) -> list[tuple[int, datetime]]:
89
+ """Generate jobs for multiprocessing.
90
+
91
+ Creates a list of (job_index, date) tuples for parallel processing,
92
+ one per day in the date range.
93
+
94
+ Returns:
95
+ list[tuple[int, datetime]]: List of (job_index, date) tuples.
96
+
97
+ Examples:
98
+ >>> psa = PlotSeismicAvailability(start_date="2025-01-01", end_date="2025-01-03", ...)
99
+ >>> print(len(psa._jobs)) # 3 days
100
+ """
101
+ return [(job_index, date) for job_index, date in enumerate(self.dates)]
102
+
103
+ def _get_completeness(self, job_index: int, date: datetime) -> dict:
104
+ """Compute completeness for a single date (used as a pool worker target).
105
+
106
+ Args:
107
+ job_index: Sequential job index used for progress logging.
108
+ date: Date for which to calculate completeness.
109
+
110
+ Returns:
111
+ A dict with ``"date"`` (datetime) and ``"completeness"`` (float) keys.
112
+ """
113
+ date_str = date.strftime("%Y-%m-%d")
114
+ if self.verbose:
115
+ logger.info(f"Running job {job_index}: {date_str}")
116
+ return {"date": date, "completeness": self.sds.get_completeness(date)}
117
+
118
+ def plot(
119
+ self,
120
+ title: str | None = None,
121
+ hspace: float = 0.2,
122
+ cbar_bottom: int = 20,
123
+ cbar_height: int = 10,
124
+ tile_gap: float = 0.9,
125
+ figsize_per_year: float = 2.2,
126
+ missing_color: str = "#e0e0e0",
127
+ tile_shape: Literal["square", "squircle"] = "square",
128
+ title_pad: int = 40,
129
+ ) -> plt.Figure:
130
+ """Compute seismic completeness and render a calendar heatmap.
131
+
132
+ Calculates completeness for every day in the configured date range,
133
+ then passes the resulting DataFrame to :func:`~data_availability.plot.plot_from_df`.
134
+ Days with zero completeness are excluded from the figure.
135
+
136
+ Args:
137
+ title: Figure super-title. Defaults to the NSLC string
138
+ (e.g. ``"VG.IJEN.00.EHZ"``).
139
+ hspace: Vertical spacing between year subplots.
140
+ cbar_bottom: Gap in pixels between the bottom of the last subplot
141
+ and the top of the colorbar.
142
+ cbar_height: Height of the colorbar in pixels.
143
+ tile_gap: Side length of each day tile; values less than 1 add
144
+ whitespace between tiles.
145
+ figsize_per_year: Figure height in inches allocated per year subplot.
146
+ missing_color: Color for calendar days absent from the dataset.
147
+ tile_shape: ``"square"`` for plain rectangles; ``"squircle"`` for
148
+ rounded corners.
149
+ title_pad: Gap in pixels between the top of the first subplot and
150
+ the figure super-title.
151
+
152
+ Returns:
153
+ A :class:`matplotlib.figure.Figure` containing the heatmap.
154
+
155
+ Raises:
156
+ ValueError: If no completeness results are produced.
157
+ """
158
+ if self.n_jobs == 1:
159
+ results = [self._get_completeness(*job) for job in self._jobs]
160
+ else:
161
+ if self.verbose:
162
+ logger.info(f"Running on {self.n_jobs} job(s)")
163
+
164
+ with Pool(self.n_jobs) as pool:
165
+ results = pool.starmap(self._get_completeness, self._jobs)
166
+
167
+ df = pd.DataFrame(results)
168
+ df = df[df["completeness"] > 0]
169
+
170
+ return plot_from_df(
171
+ df,
172
+ title=title or self.sds.nslc,
173
+ hspace=hspace,
174
+ cbar_bottom=cbar_bottom,
175
+ cbar_height=cbar_height,
176
+ tile_gap=tile_gap,
177
+ figsize_per_year=figsize_per_year,
178
+ missing_color=missing_color,
179
+ tile_shape=tile_shape,
180
+ title_pad=title_pad,
181
+ )
@@ -0,0 +1,217 @@
1
+ import os
2
+ from datetime import datetime
3
+
4
+ import numpy as np
5
+ from obspy import Trace, Stream, ObsPyReadingError, read
6
+
7
+ from data_availability.utils import to_datetime
8
+ from data_availability.logger import logger
9
+
10
+
11
+ class SDS:
12
+ """Reader for seismic data stored in SeisComP Data Structure (SDS) format.
13
+
14
+ SDS organises miniSEED files under a directory tree structured as
15
+ ``<sds_dir>/<year>/<network>/<station>/<channel.type>/``.
16
+
17
+ Args:
18
+ sds_dir: Root directory of the SDS archive.
19
+ station: Station code (case-insensitive, stored uppercased).
20
+ channel: Channel code, e.g. ``"EHZ"`` (case-insensitive).
21
+ network: Network code, e.g. ``"VG"`` (case-insensitive).
22
+ location: Location code (case-insensitive).
23
+ channel_type: SDS data-type qualifier. Defaults to ``"D"`` (waveform data).
24
+ verbose: When ``True``, emit DEBUG/INFO log messages for each loaded file.
25
+
26
+ Raises:
27
+ FileNotFoundError: If ``sds_dir`` does not exist.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ sds_dir: str,
33
+ station: str,
34
+ channel: str,
35
+ network: str,
36
+ location: str,
37
+ channel_type: str = "D",
38
+ verbose: bool = False,
39
+ ):
40
+ self.sds_dir = sds_dir
41
+ self.station = station.upper()
42
+ self.channel = channel.upper()
43
+ self.network = network.upper()
44
+ self.location = location.upper()
45
+ self.channel_type = channel_type.upper()
46
+ self.verbose = verbose
47
+ self.nslc = f"{self.network}.{self.station}.{self.location}.{self.channel}"
48
+
49
+ if not os.path.exists(sds_dir):
50
+ raise FileNotFoundError(f"Directory {sds_dir} does not exist")
51
+
52
+ def get_filepath(self, date: datetime) -> str:
53
+ """Return the expected miniSEED file path for a given date.
54
+
55
+ Constructs the path following the SDS convention:
56
+ ``<sds_dir>/<year>/<network>/<station>/<channel.type>/<nslc>.D.<year>.<julian_day>``.
57
+
58
+ Args:
59
+ date: Date for which to build the path.
60
+
61
+ Returns:
62
+ Absolute file path string (the file may or may not exist).
63
+ """
64
+ year = date.year
65
+ julian_day = date.strftime("%j")
66
+
67
+ # Construct SDS directory structure
68
+ data_dir = os.path.join(
69
+ self.sds_dir,
70
+ str(year),
71
+ self.network,
72
+ self.station,
73
+ f"{self.channel}.{self.channel_type}",
74
+ )
75
+
76
+ filename = f"{self.nslc}.D.{year}.{julian_day}"
77
+ filepath = os.path.join(data_dir, filename)
78
+
79
+ return filepath
80
+
81
+ def load_stream(self, filepath: str, date_str: str) -> Stream:
82
+ """Load seismic stream from miniSEED file.
83
+
84
+ Reads the miniSEED file using ObsPy and merges any gaps using interpolation.
85
+
86
+ Args:
87
+ filepath (str): Absolute path to miniSEED file.
88
+ date_str (str): Date string (YYYY-MM-DD) for logging purposes.
89
+
90
+ Returns:
91
+ Stream: ObsPy Stream object with traces merged, or an empty Stream
92
+ if the file cannot be read.
93
+ """
94
+ try:
95
+ stream = read(filepath, format="MSEED")
96
+ stream = stream.merge(fill_value=None)
97
+
98
+ if self.verbose:
99
+ logger.debug(
100
+ f"{date_str} :: Loaded {len(stream)} trace(s) from {filepath}"
101
+ )
102
+
103
+ return stream
104
+
105
+ except ObsPyReadingError as e:
106
+ logger.error(f"{date_str} :: Failed to read miniSEED file: {filepath}")
107
+ logger.error(f"{date_str} :: Error: {e}")
108
+ return Stream()
109
+
110
+ except Exception as e:
111
+ logger.error(f"{date_str} :: Unexpected error loading {filepath}: {e}")
112
+ return Stream()
113
+
114
+ def get(self, date: str | datetime) -> Stream:
115
+ """Load the seismic stream for a given date.
116
+
117
+ Resolves the SDS path for ``date``, reads the miniSEED file, and merges
118
+ any gap-separated traces. Missing or unreadable files are logged and an
119
+ empty :class:`~obspy.core.stream.Stream` is returned.
120
+
121
+ Args:
122
+ date: Date to load, as a ``datetime`` object or ``"YYYY-MM-DD"`` string.
123
+
124
+ Returns:
125
+ An :class:`~obspy.core.stream.Stream` containing the merged trace(s),
126
+ or an empty Stream when no data is available.
127
+ """
128
+ _date: datetime = to_datetime(date)
129
+ date_str = _date.strftime("%Y-%m-%d")
130
+ filepath = self.get_filepath(_date)
131
+
132
+ # Check if file exists
133
+ if not os.path.exists(filepath):
134
+ if self.verbose:
135
+ logger.debug(f"{date_str} :: miniSEED file not found: {filepath}")
136
+ return Stream()
137
+
138
+ # Load stream from file
139
+ stream = self.load_stream(filepath, date_str)
140
+
141
+ # Log results
142
+ if len(stream) == 0:
143
+ logger.warning(f"{date_str} :: No traces found in {filepath}")
144
+ elif self.verbose:
145
+ trace: Trace = stream[0]
146
+ n_samples = len(trace.data)
147
+ sampling_rate = trace.stats.sampling_rate
148
+ duration = n_samples / sampling_rate if sampling_rate > 0 else 0
149
+
150
+ logger.debug(f"{date_str} :: Stream loaded successfully")
151
+ logger.info(
152
+ f"{date_str} :: {len(stream)} trace(s), {n_samples} samples, "
153
+ f"{duration:.1f}s duration @ {sampling_rate}Hz."
154
+ )
155
+
156
+ return stream
157
+
158
+ def get_trace(self, date: datetime) -> Trace | None:
159
+ """Retrieve a single Trace object for the given date.
160
+
161
+ Loads the seismic stream for the specified date and returns its single
162
+ merged trace. Returns None when no data is available.
163
+
164
+ Args:
165
+ date (datetime): Date for which to retrieve data.
166
+
167
+ Returns:
168
+ Trace | None: The merged Trace object, or None if no data is available.
169
+
170
+ Raises:
171
+ ValueError: If the stream contains more than one trace after merging.
172
+ """
173
+ stream = self.get(date)
174
+
175
+ if len(stream) == 0:
176
+ return None
177
+
178
+ if len(stream) > 1:
179
+ date_str = date.strftime("%Y-%m-%d")
180
+ raise ValueError(
181
+ f"{date_str} :: Stream has more than one trace ({len(stream)} trace(s)). "
182
+ f"SDS should have only one trace after merge."
183
+ )
184
+
185
+ return stream[0]
186
+
187
+ def get_completeness(self, date: datetime) -> float:
188
+ """Calculate the data completeness percentage for a given date.
189
+
190
+ Computes completeness as the fraction of non-zero samples relative to
191
+ the expected daily sample count at the trace's sampling rate.
192
+
193
+ Args:
194
+ date: Date for which to compute completeness.
195
+
196
+ Returns:
197
+ Completeness percentage in the range [0, 100], rounded to two
198
+ decimal places. Returns ``0.0`` when no data is available.
199
+ """
200
+ trace = self.get_trace(date)
201
+ date_str = date.strftime("%Y-%m-%d")
202
+
203
+ if trace is None:
204
+ if self.verbose:
205
+ logger.warning(f"{date_str} :: Completeness: 0.0%")
206
+ return 0.0
207
+
208
+ sampling_rate = trace.stats.sampling_rate
209
+ daily_sampling_rate = sampling_rate * 60 * 60 * 24
210
+ non_zero: float = np.round(
211
+ (np.count_nonzero(trace.data) / daily_sampling_rate) * 100, 2
212
+ )
213
+
214
+ if self.verbose:
215
+ logger.info(f"{date_str} :: Completeness: {non_zero}%")
216
+
217
+ return float(non_zero)
@@ -0,0 +1,54 @@
1
+ import os
2
+ from datetime import datetime
3
+
4
+
5
+ def to_datetime(date: str | datetime, variable_name: str | None = None) -> datetime:
6
+ """Ensure date object is a datetime object.
7
+
8
+ Converts date strings in YYYY-MM-DD format to datetime objects. If already a
9
+ datetime object, returns it unchanged. Used for standardizing date inputs.
10
+
11
+ Args:
12
+ date (str | datetime): Date string in YYYY-MM-DD format or datetime object.
13
+ variable_name (str | None, optional): Variable name for error messages.
14
+ Defaults to None.
15
+
16
+ Returns:
17
+ datetime: Datetime object.
18
+
19
+ Raises:
20
+ ValueError: If date string is not in YYYY-MM-DD format.
21
+
22
+ Examples:
23
+ >>> to_datetime("2025-03-20")
24
+ datetime.datetime(2025, 3, 20, 0, 0)
25
+ >>> to_datetime(datetime(2025, 3, 20))
26
+ datetime.datetime(2025, 3, 20, 0, 0)
27
+ """
28
+ if isinstance(date, datetime):
29
+ return date
30
+
31
+ variable_name = f"{variable_name}" if variable_name else "Date"
32
+
33
+ try:
34
+ return datetime.strptime(date, "%Y-%m-%d")
35
+ except ValueError:
36
+ raise ValueError(
37
+ f"{variable_name} value {date} is not in valid YYYY-MM-DD format."
38
+ )
39
+
40
+
41
+ def ensure_dir(path: str) -> str:
42
+ """Create a directory (and any missing parents) if it does not already exist.
43
+
44
+ A thin, named wrapper around ``os.makedirs(path, exist_ok=True)`` that
45
+ returns the path so callers can chain it inline.
46
+
47
+ Args:
48
+ path (str): Directory path to create.
49
+
50
+ Returns:
51
+ str: The same ``path`` that was passed in.
52
+ """
53
+ os.makedirs(path, exist_ok=True)
54
+ return path