pluscross 0.1.0__py3-none-any.whl
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.
- pluscross/__init__.py +229 -0
- pluscross/py.typed +0 -0
- pluscross/utils.py +66 -0
- pluscross-0.1.0.dist-info/METADATA +75 -0
- pluscross-0.1.0.dist-info/RECORD +6 -0
- pluscross-0.1.0.dist-info/WHEEL +4 -0
pluscross/__init__.py
ADDED
|
@@ -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
|
+
)
|
pluscross/py.typed
ADDED
|
File without changes
|
pluscross/utils.py
ADDED
|
@@ -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
|
|
@@ -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,6 @@
|
|
|
1
|
+
pluscross/__init__.py,sha256=hoNzqDQ9yzZ7p_e4BveJl_WJwjo7Va7rxnTKYRraG_o,8641
|
|
2
|
+
pluscross/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
pluscross/utils.py,sha256=8X09Hfou8JHPMA3DJj61IyjG-ABEO8y-WFGy-7-DiMA,2633
|
|
4
|
+
pluscross-0.1.0.dist-info/WHEEL,sha256=CoDSoyhtC_eO_tlxRYzsTraPv1fPJRXFx91k6ISeAvA,81
|
|
5
|
+
pluscross-0.1.0.dist-info/METADATA,sha256=fElpF_yCa0Q7L9eJazQZCNrEuTrXm0aBK3dcYJ3dFBo,2242
|
|
6
|
+
pluscross-0.1.0.dist-info/RECORD,,
|