pluscross 0.1.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.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: pluscross
3
+ Version: 0.1.0
4
+ Summary: IO for the waveform_catalog HDF5 format: frequency-domain waveform polarization catalogs
5
+ Keywords: gravitational-waves,hdf5,waveforms
6
+ Author: Bernardo Veronese
7
+ Author-email: Bernardo Veronese <bernardopveronese@gmail.com>
8
+ License-Expression: MIT
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
13
+ Classifier: Topic :: Scientific/Engineering :: Physics
14
+ Requires-Dist: numpy>=1.26
15
+ Requires-Dist: h5py>=3.10
16
+ Requires-Dist: pytest>=8 ; extra == 'test'
17
+ Requires-Python: >=3.11
18
+ Project-URL: Repository, https://github.com/binado/pluscross
19
+ Project-URL: Issues, https://github.com/binado/pluscross/issues
20
+ Provides-Extra: test
21
+ Description-Content-Type: text/markdown
22
+
23
+ # pluscross (Python)
24
+
25
+ Pure-IO implementation of the `waveform_catalog` HDF5 format (see `../SPEC.md`):
26
+ catalogs of frequency-domain waveform polarizations.
27
+
28
+ ## Installation
29
+
30
+ ```sh
31
+ pip install pluscross
32
+ ```
33
+
34
+ From source:
35
+
36
+ ```sh
37
+ git clone https://github.com/binado/pluscross
38
+ cd pluscross/python
39
+ uv sync --extra test
40
+ ```
41
+
42
+ ```python
43
+ import numpy as np
44
+
45
+ from pluscross import WaveformCatalog, save_catalog, load_catalog
46
+
47
+ catalog = WaveformCatalog(
48
+ frequencies=np.array([20.0, 30.0, 40.0]),
49
+ plus=np.array([[1.0 + 0.1j, 2.0 + 0.2j, 3.0 + 0.3j]]),
50
+ cross=np.array([[0.5 - 0.1j, 1.0 - 0.2j, 1.5 - 0.3j]]),
51
+ source_parameters={"mass_1": np.array([35.0]), "mass_2": np.array([30.0])},
52
+ approximant="IMRPhenomXAS",
53
+ minimum_frequency=20.0,
54
+ maximum_frequency=40.0,
55
+ reference_frequency=20.0,
56
+ sampling_frequency=4096.0,
57
+ )
58
+
59
+ catalog.save("catalog.h5")
60
+ loaded = WaveformCatalog.load("catalog.h5")
61
+
62
+ loaded.frequencies # (nfreq,) float64
63
+ loaded.plus # (nsamples, nfreq) complex128
64
+ loaded.cross # (nsamples, nfreq) complex128
65
+ loaded.source_parameters # dict[str, (nsamples,) float64]
66
+
67
+ save_catalog("copy.h5", loaded)
68
+ copy = load_catalog("copy.h5")
69
+ copy.source_parameters
70
+ ```
71
+
72
+ No derived quantities (e.g. polarization power) are computed here — consumers do
73
+ that themselves.
74
+
75
+ Run tests with `uv run --extra test pytest tests/`.
@@ -0,0 +1,53 @@
1
+ # pluscross (Python)
2
+
3
+ Pure-IO implementation of the `waveform_catalog` HDF5 format (see `../SPEC.md`):
4
+ catalogs of frequency-domain waveform polarizations.
5
+
6
+ ## Installation
7
+
8
+ ```sh
9
+ pip install pluscross
10
+ ```
11
+
12
+ From source:
13
+
14
+ ```sh
15
+ git clone https://github.com/binado/pluscross
16
+ cd pluscross/python
17
+ uv sync --extra test
18
+ ```
19
+
20
+ ```python
21
+ import numpy as np
22
+
23
+ from pluscross import WaveformCatalog, save_catalog, load_catalog
24
+
25
+ catalog = WaveformCatalog(
26
+ frequencies=np.array([20.0, 30.0, 40.0]),
27
+ plus=np.array([[1.0 + 0.1j, 2.0 + 0.2j, 3.0 + 0.3j]]),
28
+ cross=np.array([[0.5 - 0.1j, 1.0 - 0.2j, 1.5 - 0.3j]]),
29
+ source_parameters={"mass_1": np.array([35.0]), "mass_2": np.array([30.0])},
30
+ approximant="IMRPhenomXAS",
31
+ minimum_frequency=20.0,
32
+ maximum_frequency=40.0,
33
+ reference_frequency=20.0,
34
+ sampling_frequency=4096.0,
35
+ )
36
+
37
+ catalog.save("catalog.h5")
38
+ loaded = WaveformCatalog.load("catalog.h5")
39
+
40
+ loaded.frequencies # (nfreq,) float64
41
+ loaded.plus # (nsamples, nfreq) complex128
42
+ loaded.cross # (nsamples, nfreq) complex128
43
+ loaded.source_parameters # dict[str, (nsamples,) float64]
44
+
45
+ save_catalog("copy.h5", loaded)
46
+ copy = load_catalog("copy.h5")
47
+ copy.source_parameters
48
+ ```
49
+
50
+ No derived quantities (e.g. polarization power) are computed here — consumers do
51
+ that themselves.
52
+
53
+ Run tests with `uv run --extra test pytest tests/`.
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "pluscross"
3
+ version = "0.1.0"
4
+ description = "IO for the waveform_catalog HDF5 format: frequency-domain waveform polarization catalogs"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ authors = [{ name = "Bernardo Veronese", email = "bernardopveronese@gmail.com" }]
8
+ license = "MIT"
9
+ keywords = ["gravitational-waves", "hdf5", "waveforms"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3.11",
12
+ "Programming Language :: Python :: 3.12",
13
+ "Intended Audience :: Science/Research",
14
+ "Topic :: Scientific/Engineering :: Astronomy",
15
+ "Topic :: Scientific/Engineering :: Physics",
16
+ ]
17
+ dependencies = [
18
+ "numpy>=1.26",
19
+ "h5py>=3.10",
20
+ ]
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/binado/pluscross"
24
+ Issues = "https://github.com/binado/pluscross/issues"
25
+
26
+ [project.optional-dependencies]
27
+ test = ["pytest>=8"]
28
+
29
+ [dependency-groups]
30
+ dev = ["ruff>=0.9", "ty>=0.0.1a"]
31
+
32
+ [build-system]
33
+ requires = ["uv_build>=0.11,<0.12"]
34
+ build-backend = "uv_build"
35
+
36
+ [tool.ruff]
37
+ line-length = 88
38
+ target-version = "py311"
39
+
40
+ [tool.ruff.lint]
41
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,229 @@
1
+ """IO for the ``waveform_catalog`` HDF5 format (see SPEC.md at the repo root).
2
+
3
+ Stores catalogs of frequency-domain waveform polarizations: complex ``h_plus`` /
4
+ ``h_cross`` on a shared frequency axis, per-sample source parameters, and the
5
+ waveform-generation attributes. Pure IO: no derived quantities are computed here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from importlib.metadata import version as _version
12
+ from pathlib import Path
13
+
14
+ import h5py
15
+ import numpy as np
16
+ from numpy.typing import NDArray
17
+
18
+ from .utils import frequency_array
19
+
20
+ __version__ = _version("pluscross")
21
+
22
+ __all__ = [
23
+ "WaveformCatalog",
24
+ "save_catalog",
25
+ "load_catalog",
26
+ "frequency_array",
27
+ "__version__",
28
+ ]
29
+
30
+ FORMAT_NAME = "waveform_catalog"
31
+ FORMAT_VERSION = 1
32
+ DOMAIN_FREQUENCY = "frequency"
33
+
34
+ #: Target uncompressed chunk size in complex128 elements (~64 MiB).
35
+ _CHUNK_ELEMENTS = 2**22
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class WaveformCatalog:
40
+ """In-memory frequency-domain waveform polarization catalog.
41
+
42
+ ``plus`` and ``cross`` have shape ``(nsamples, nfreq)`` — sample axis
43
+ first, matching the on-disk C-order layout.
44
+ """
45
+
46
+ frequencies: NDArray[np.float64]
47
+ plus: NDArray[np.complex128]
48
+ cross: NDArray[np.complex128]
49
+ source_parameters: dict[str, NDArray[np.float64]] = field(default_factory=dict)
50
+ approximant: str = ""
51
+ minimum_frequency: float = 0.0
52
+ maximum_frequency: float = 0.0
53
+ reference_frequency: float = 0.0
54
+ sampling_frequency: float = 0.0
55
+
56
+ def __post_init__(self) -> None:
57
+ object.__setattr__(
58
+ self, "frequencies", np.asarray(self.frequencies, dtype=np.float64)
59
+ )
60
+ object.__setattr__(self, "plus", np.asarray(self.plus, dtype=np.complex128))
61
+ object.__setattr__(self, "cross", np.asarray(self.cross, dtype=np.complex128))
62
+ object.__setattr__(
63
+ self,
64
+ "source_parameters",
65
+ {
66
+ name: np.asarray(values, dtype=np.float64)
67
+ for name, values in self.source_parameters.items()
68
+ },
69
+ )
70
+ _validate_arrays(
71
+ self.frequencies, self.plus, self.cross, self.source_parameters
72
+ )
73
+
74
+ @property
75
+ def nfreq(self) -> int:
76
+ return self.frequencies.shape[0]
77
+
78
+ @property
79
+ def nsamples(self) -> int:
80
+ return self.plus.shape[0]
81
+
82
+ def save(self, path: str | Path) -> None:
83
+ """Write this catalog to ``path`` in waveform_catalog format v1."""
84
+ save_catalog(path, self)
85
+
86
+ @classmethod
87
+ def load(cls, path: str | Path) -> WaveformCatalog:
88
+ """Read a waveform_catalog v1 file into a catalog."""
89
+ return load_catalog(path)
90
+
91
+
92
+ def _validate_arrays(
93
+ frequencies: NDArray[np.float64],
94
+ plus: NDArray[np.complex128],
95
+ cross: NDArray[np.complex128],
96
+ source_parameters: dict[str, NDArray[np.float64]],
97
+ label: str = "WaveformCatalog",
98
+ ) -> None:
99
+ if frequencies.ndim != 1:
100
+ raise ValueError(f"{label}: frequencies must be one-dimensional")
101
+ if frequencies.shape[0] > 1 and not np.all(np.diff(frequencies) > 0.0):
102
+ raise ValueError(f"{label}: frequencies must be strictly increasing")
103
+ if plus.ndim != 2 or cross.ndim != 2:
104
+ raise ValueError(f"{label}: plus and cross must be two-dimensional")
105
+ if plus.shape != cross.shape:
106
+ raise ValueError(
107
+ f"{label}: plus {plus.shape} and cross {cross.shape} shapes differ"
108
+ )
109
+ if plus.shape[1] != frequencies.shape[0]:
110
+ raise ValueError(
111
+ f"{label}: polarization frequency axis ({plus.shape[1]}) does not "
112
+ f"match frequencies ({frequencies.shape[0]})"
113
+ )
114
+ nsamples = plus.shape[0]
115
+ for name, values in source_parameters.items():
116
+ if values.ndim != 1 or values.shape[0] != nsamples:
117
+ raise ValueError(
118
+ f"{label}: source parameter {name!r} must be 1-D with length "
119
+ f"{nsamples}, got shape {values.shape}"
120
+ )
121
+
122
+
123
+ def _chunks(nsamples: int, nfreq: int) -> tuple[int, int]:
124
+ """On-disk C-order chunk shape: full frequency rows for a bounded sample count."""
125
+ per_chunk = min(nsamples, max(1, _CHUNK_ELEMENTS // max(nfreq, 1)))
126
+ return (per_chunk, nfreq)
127
+
128
+
129
+ def save_catalog(path: str | Path, catalog: WaveformCatalog) -> None:
130
+ """Write ``catalog`` to ``path`` in waveform_catalog format v1."""
131
+ nsamples, nfreq = catalog.plus.shape
132
+ with h5py.File(path, "w") as f:
133
+ f.attrs["format_name"] = FORMAT_NAME
134
+ f.attrs["format_version"] = np.int64(FORMAT_VERSION)
135
+ f.attrs["domain"] = DOMAIN_FREQUENCY
136
+ f.attrs["approximant"] = catalog.approximant
137
+ f.attrs["minimum_frequency"] = np.float64(catalog.minimum_frequency)
138
+ f.attrs["maximum_frequency"] = np.float64(catalog.maximum_frequency)
139
+ f.attrs["reference_frequency"] = np.float64(catalog.reference_frequency)
140
+ f.attrs["sampling_frequency"] = np.float64(catalog.sampling_frequency)
141
+
142
+ f.create_dataset("frequencies", data=catalog.frequencies)
143
+ pol = f.create_group("polarizations")
144
+ chunks = _chunks(nsamples, nfreq)
145
+ for name, data in (("plus", catalog.plus), ("cross", catalog.cross)):
146
+ pol.create_dataset(
147
+ name,
148
+ data=np.ascontiguousarray(data),
149
+ chunks=chunks,
150
+ compression="gzip",
151
+ )
152
+ params = f.create_group("source_parameters")
153
+ for name, values in catalog.source_parameters.items():
154
+ params.create_dataset(name, data=values)
155
+
156
+
157
+ def _require_attr(f: h5py.File, name: str, label: str) -> str | int | float:
158
+ if name not in f.attrs:
159
+ raise ValueError(f"{label}: missing required attribute {name!r}")
160
+ value = f.attrs[name]
161
+ if isinstance(value, bytes):
162
+ return value.decode()
163
+ if isinstance(value, (str, int, float, np.integer, np.floating)):
164
+ # np.integer/np.floating satisfy int()/float(); plain cast for typing.
165
+ return value # type: ignore[return-value]
166
+ raise ValueError(f"{label}: attribute {name!r} has unsupported type")
167
+
168
+
169
+ def _require_dataset(f: h5py.File, key: str, label: str) -> h5py.Dataset:
170
+ if key not in f:
171
+ raise ValueError(f"{label}: missing required object {key!r}")
172
+ obj = f[key]
173
+ if not isinstance(obj, h5py.Dataset):
174
+ raise ValueError(f"{label}: {key!r} is not a dataset")
175
+ return obj
176
+
177
+
178
+ def load_catalog(path: str | Path) -> WaveformCatalog:
179
+ """Read a waveform_catalog v1 file into a :class:`WaveformCatalog`."""
180
+ label = Path(path).name
181
+ with h5py.File(path, "r") as f:
182
+ format_name = str(_require_attr(f, "format_name", label))
183
+ if format_name != FORMAT_NAME:
184
+ raise ValueError(
185
+ f"{label}: format_name is {format_name!r}, expected {FORMAT_NAME!r}"
186
+ )
187
+ version = int(_require_attr(f, "format_version", label))
188
+ if version != FORMAT_VERSION:
189
+ raise ValueError(
190
+ f"{label}: format_version is {version}, expected {FORMAT_VERSION}"
191
+ )
192
+ domain = str(_require_attr(f, "domain", label))
193
+ if domain != DOMAIN_FREQUENCY:
194
+ raise ValueError(
195
+ f"{label}: domain is {domain!r}, expected {DOMAIN_FREQUENCY!r}"
196
+ )
197
+ approximant = str(_require_attr(f, "approximant", label))
198
+ physics = {
199
+ name: float(_require_attr(f, name, label))
200
+ for name in (
201
+ "minimum_frequency",
202
+ "maximum_frequency",
203
+ "reference_frequency",
204
+ "sampling_frequency",
205
+ )
206
+ }
207
+
208
+ frequencies = np.asarray(_require_dataset(f, "frequencies", label)[...])
209
+ plus = np.asarray(_require_dataset(f, "polarizations/plus", label)[...])
210
+ cross = np.asarray(_require_dataset(f, "polarizations/cross", label)[...])
211
+ source_parameters: dict[str, NDArray[np.float64]] = {}
212
+ if "source_parameters" in f:
213
+ group = f["source_parameters"]
214
+ if not isinstance(group, h5py.Group):
215
+ raise ValueError(f"{label}: 'source_parameters' is not a group")
216
+ for name in (str(k) for k in group):
217
+ source_parameters[name] = np.asarray(
218
+ _require_dataset(f, f"source_parameters/{name}", label)[...]
219
+ )
220
+
221
+ _validate_arrays(frequencies, plus, cross, source_parameters, label=label)
222
+ return WaveformCatalog(
223
+ frequencies=frequencies,
224
+ plus=plus,
225
+ cross=cross,
226
+ source_parameters=source_parameters,
227
+ approximant=approximant,
228
+ **physics,
229
+ )
File without changes
@@ -0,0 +1,66 @@
1
+ """Frequency-axis helpers, independent of the ``waveform_catalog`` IO format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from numpy.typing import NDArray
7
+
8
+ __all__ = ["frequency_array"]
9
+
10
+
11
+ def frequency_array(
12
+ *,
13
+ sampling_frequency: float,
14
+ duration: float,
15
+ minimum_frequency: float,
16
+ maximum_frequency: float,
17
+ ) -> tuple[NDArray[np.float64], NDArray[np.bool_]]:
18
+ """Build the 0-to-Nyquist frequency axis and an in-band mask.
19
+
20
+ Returns ``(frequencies, in_band_mask)``: ``frequencies`` runs from 0 Hz to
21
+ the Nyquist frequency (``sampling_frequency / 2``) in steps of
22
+ ``1 / duration``, and ``in_band_mask`` is ``True`` where
23
+ ``minimum_frequency <= frequencies <= maximum_frequency``.
24
+
25
+ ``duration * sampling_frequency`` must be an even integer (the number of
26
+ time-domain samples), so the grid spacing is exactly ``1 / duration`` and
27
+ the last frequency lands exactly on the Nyquist frequency.
28
+ """
29
+ if sampling_frequency <= 0.0:
30
+ raise ValueError(
31
+ f"sampling_frequency must be positive, got {sampling_frequency}"
32
+ )
33
+ if duration <= 0.0:
34
+ raise ValueError(f"duration must be positive, got {duration}")
35
+ nyquist_frequency = sampling_frequency / 2.0
36
+ if not (0.0 <= minimum_frequency <= maximum_frequency <= nyquist_frequency):
37
+ raise ValueError(
38
+ "expected 0 <= minimum_frequency <= maximum_frequency <= "
39
+ f"nyquist_frequency ({nyquist_frequency}), got "
40
+ f"minimum_frequency={minimum_frequency}, "
41
+ f"maximum_frequency={maximum_frequency}"
42
+ )
43
+
44
+ n_samples_exact = duration * sampling_frequency
45
+ n_samples = round(n_samples_exact)
46
+ tolerance = 4.0 * np.finfo(np.float64).eps * max(1.0, abs(n_samples_exact))
47
+ if abs(n_samples_exact - n_samples) > tolerance:
48
+ raise ValueError(
49
+ "duration * sampling_frequency must be an integer number of "
50
+ f"samples, got duration={duration}, "
51
+ f"sampling_frequency={sampling_frequency} "
52
+ f"({n_samples_exact} samples)"
53
+ )
54
+ if n_samples % 2 != 0:
55
+ raise ValueError(
56
+ "duration * sampling_frequency must be an even integer so the "
57
+ "frequency grid reaches the Nyquist frequency exactly, got "
58
+ f"{n_samples} samples (duration={duration}, "
59
+ f"sampling_frequency={sampling_frequency})"
60
+ )
61
+
62
+ frequencies = np.fft.rfftfreq(n_samples, d=1.0 / sampling_frequency)
63
+ in_band_mask = (frequencies >= minimum_frequency) & (
64
+ frequencies <= maximum_frequency
65
+ )
66
+ return frequencies, in_band_mask