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.
- {data_availability-0.1.0 → data_availability-0.2.0}/PKG-INFO +50 -7
- {data_availability-0.1.0 → data_availability-0.2.0}/README.md +47 -6
- {data_availability-0.1.0 → data_availability-0.2.0}/pyproject.toml +3 -1
- {data_availability-0.1.0 → data_availability-0.2.0}/src/data_availability/__init__.py +40 -36
- {data_availability-0.1.0 → data_availability-0.2.0}/src/data_availability/availability.py +37 -4
- data_availability-0.2.0/src/data_availability/logger.py +151 -0
- data_availability-0.2.0/src/data_availability/seismic/__init__.py +0 -0
- data_availability-0.2.0/src/data_availability/seismic/plot_seismic.py +181 -0
- data_availability-0.2.0/src/data_availability/seismic/sds.py +217 -0
- data_availability-0.2.0/src/data_availability/utils.py +54 -0
- {data_availability-0.1.0 → data_availability-0.2.0}/src/data_availability/data.py +0 -0
- {data_availability-0.1.0 → data_availability-0.2.0}/src/data_availability/plot.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: data-availability
|
|
3
|
-
Version: 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
|
-
.
|
|
54
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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 `.
|
|
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
|
-
.
|
|
35
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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 `.
|
|
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.
|
|
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.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
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
|
|
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
|
|
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:`
|
|
104
|
+
RuntimeError: If :meth:`select` has not been called first.
|
|
73
105
|
"""
|
|
74
106
|
if self._df is None:
|
|
75
|
-
raise RuntimeError("Call .
|
|
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)
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|