gwsim 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.
- gwsim/__init__.py +11 -0
- gwsim/__main__.py +8 -0
- gwsim/cli/__init__.py +0 -0
- gwsim/cli/config.py +88 -0
- gwsim/cli/default_config.py +56 -0
- gwsim/cli/main.py +101 -0
- gwsim/cli/merge.py +150 -0
- gwsim/cli/repository/__init__.py +0 -0
- gwsim/cli/repository/create.py +91 -0
- gwsim/cli/repository/delete.py +51 -0
- gwsim/cli/repository/download.py +54 -0
- gwsim/cli/repository/list_depositions.py +63 -0
- gwsim/cli/repository/main.py +38 -0
- gwsim/cli/repository/metadata/__init__.py +0 -0
- gwsim/cli/repository/metadata/main.py +24 -0
- gwsim/cli/repository/metadata/update.py +58 -0
- gwsim/cli/repository/publish.py +52 -0
- gwsim/cli/repository/upload.py +74 -0
- gwsim/cli/repository/utils.py +47 -0
- gwsim/cli/repository/verify.py +61 -0
- gwsim/cli/simulate.py +220 -0
- gwsim/cli/simulate_utils.py +596 -0
- gwsim/cli/utils/__init__.py +85 -0
- gwsim/cli/utils/checkpoint.py +178 -0
- gwsim/cli/utils/config.py +347 -0
- gwsim/cli/utils/hash.py +23 -0
- gwsim/cli/utils/retry.py +62 -0
- gwsim/cli/utils/simulation_plan.py +439 -0
- gwsim/cli/utils/template.py +56 -0
- gwsim/cli/utils/utils.py +149 -0
- gwsim/cli/validate.py +255 -0
- gwsim/data/__init__.py +8 -0
- gwsim/data/serialize/__init__.py +9 -0
- gwsim/data/serialize/decoder.py +59 -0
- gwsim/data/serialize/encoder.py +44 -0
- gwsim/data/serialize/serializable.py +33 -0
- gwsim/data/time_series/__init__.py +3 -0
- gwsim/data/time_series/inject.py +104 -0
- gwsim/data/time_series/time_series.py +355 -0
- gwsim/data/time_series/time_series_list.py +182 -0
- gwsim/detector/__init__.py +8 -0
- gwsim/detector/base.py +156 -0
- gwsim/detector/detectors/E1_2L_Aligned_Sardinia.interferometer +22 -0
- gwsim/detector/detectors/E1_2L_Misaligned_Sardinia.interferometer +22 -0
- gwsim/detector/detectors/E1_Triangle_EMR.interferometer +19 -0
- gwsim/detector/detectors/E1_Triangle_Sardinia.interferometer +19 -0
- gwsim/detector/detectors/E2_2L_Aligned_EMR.interferometer +22 -0
- gwsim/detector/detectors/E2_2L_Misaligned_EMR.interferometer +22 -0
- gwsim/detector/detectors/E2_Triangle_EMR.interferometer +19 -0
- gwsim/detector/detectors/E2_Triangle_Sardinia.interferometer +19 -0
- gwsim/detector/detectors/E3_Triangle_EMR.interferometer +19 -0
- gwsim/detector/detectors/E3_Triangle_Sardinia.interferometer +19 -0
- gwsim/detector/noise_curves/ET_10_HF_psd.txt +3000 -0
- gwsim/detector/noise_curves/ET_10_full_cryo_psd.txt +3000 -0
- gwsim/detector/noise_curves/ET_15_HF_psd.txt +3000 -0
- gwsim/detector/noise_curves/ET_15_full_cryo_psd.txt +3000 -0
- gwsim/detector/noise_curves/ET_20_HF_psd.txt +3000 -0
- gwsim/detector/noise_curves/ET_20_full_cryo_psd.txt +3000 -0
- gwsim/detector/noise_curves/ET_D_psd.txt +3000 -0
- gwsim/detector/utils.py +90 -0
- gwsim/glitch/__init__.py +7 -0
- gwsim/glitch/base.py +69 -0
- gwsim/mixin/__init__.py +8 -0
- gwsim/mixin/detector.py +203 -0
- gwsim/mixin/gwf.py +192 -0
- gwsim/mixin/population_reader.py +175 -0
- gwsim/mixin/randomness.py +107 -0
- gwsim/mixin/time_series.py +295 -0
- gwsim/mixin/waveform.py +47 -0
- gwsim/noise/__init__.py +19 -0
- gwsim/noise/base.py +134 -0
- gwsim/noise/bilby_stationary_gaussian.py +117 -0
- gwsim/noise/colored_noise.py +275 -0
- gwsim/noise/correlated_noise.py +257 -0
- gwsim/noise/pycbc_stationary_gaussian.py +112 -0
- gwsim/noise/stationary_gaussian.py +44 -0
- gwsim/noise/white_noise.py +51 -0
- gwsim/repository/__init__.py +0 -0
- gwsim/repository/zenodo.py +269 -0
- gwsim/signal/__init__.py +11 -0
- gwsim/signal/base.py +137 -0
- gwsim/signal/cbc.py +61 -0
- gwsim/simulator/__init__.py +7 -0
- gwsim/simulator/base.py +315 -0
- gwsim/simulator/state.py +85 -0
- gwsim/utils/__init__.py +11 -0
- gwsim/utils/datetime_parser.py +44 -0
- gwsim/utils/et_2l_geometry.py +165 -0
- gwsim/utils/io.py +167 -0
- gwsim/utils/log.py +145 -0
- gwsim/utils/population.py +48 -0
- gwsim/utils/random.py +69 -0
- gwsim/utils/retry.py +75 -0
- gwsim/utils/triangular_et_geometry.py +164 -0
- gwsim/version.py +7 -0
- gwsim/waveform/__init__.py +7 -0
- gwsim/waveform/factory.py +83 -0
- gwsim/waveform/pycbc_wrapper.py +37 -0
- gwsim-0.1.0.dist-info/METADATA +157 -0
- gwsim-0.1.0.dist-info/RECORD +103 -0
- gwsim-0.1.0.dist-info/WHEEL +4 -0
- gwsim-0.1.0.dist-info/entry_points.txt +2 -0
- gwsim-0.1.0.dist-info/licenses/LICENSE +21 -0
gwsim/simulator/base.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Refactored base simulator with clean separation of concerns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from gwsim import __version__
|
|
14
|
+
from gwsim.simulator.state import StateAttribute
|
|
15
|
+
from gwsim.utils.io import get_file_name_from_template
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("gwsim")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Simulator(ABC):
|
|
21
|
+
"""Base simulator class providing core interface and iteration capabilities.
|
|
22
|
+
|
|
23
|
+
This class provides the minimal common interface that all simulators share:
|
|
24
|
+
- State management and persistence
|
|
25
|
+
- Iterator protocol for data generation
|
|
26
|
+
- Metadata handling
|
|
27
|
+
- File I/O operations
|
|
28
|
+
|
|
29
|
+
Specialized functionality (randomness, timing, etc.) should be added
|
|
30
|
+
via mixins to avoid bloating the base interface.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
max_samples: Maximum number of samples to generate. None means infinite.
|
|
34
|
+
**kwargs: Additional arguments absorbed by subclasses and mixins.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# State attributes using StateAttribute descriptor
|
|
38
|
+
counter = StateAttribute(default=0)
|
|
39
|
+
|
|
40
|
+
def __init__(self, max_samples: int | float | None = None, **kwargs):
|
|
41
|
+
"""Initialize the base simulator.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
max_samples: Maximum number of samples to generate.
|
|
45
|
+
**kwargs: Additional arguments for subclasses and mixins.
|
|
46
|
+
"""
|
|
47
|
+
# Absorb unused kwargs to enable flexible parameter passing
|
|
48
|
+
if kwargs:
|
|
49
|
+
logger.debug("Unused kwargs in Simulator.__init__: %s", kwargs)
|
|
50
|
+
|
|
51
|
+
# Non-state attributes
|
|
52
|
+
if max_samples is None:
|
|
53
|
+
self.max_samples = np.inf
|
|
54
|
+
logger.debug("max_samples set to None, interpreted as infinite.")
|
|
55
|
+
else:
|
|
56
|
+
self.max_samples = max_samples
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def max_samples(self) -> int | float:
|
|
60
|
+
"""Get the maximum number of samples.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Maximum number of samples (np.inf for unlimited).
|
|
64
|
+
"""
|
|
65
|
+
return self._max_samples
|
|
66
|
+
|
|
67
|
+
@max_samples.setter
|
|
68
|
+
def max_samples(self, value: int | float) -> None:
|
|
69
|
+
"""Set the maximum number of samples.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
value: Maximum number of samples. None interpreted as infinite.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
ValueError: If value is negative.
|
|
76
|
+
"""
|
|
77
|
+
if value < 0:
|
|
78
|
+
raise ValueError("Max samples cannot be negative.")
|
|
79
|
+
self._max_samples = value
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def state(self) -> dict:
|
|
83
|
+
"""Get the current simulator state.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Dictionary containing all state attributes.
|
|
87
|
+
"""
|
|
88
|
+
# Get state attributes from all classes in MRO (set by StateAttribute descriptors)
|
|
89
|
+
state_attrs: list[Any] = []
|
|
90
|
+
for cls in self.__class__.__mro__:
|
|
91
|
+
state_attrs.extend(getattr(cls, "_state_attributes", []))
|
|
92
|
+
# Remove duplicates while preserving order
|
|
93
|
+
seen = set()
|
|
94
|
+
state_attrs = [x for x in state_attrs if not (x in seen or seen.add(x))]
|
|
95
|
+
return {key: getattr(self, key) for key in state_attrs}
|
|
96
|
+
|
|
97
|
+
@state.setter
|
|
98
|
+
def state(self, state: dict) -> None:
|
|
99
|
+
"""Set the simulator state.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
state: Dictionary of state values.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: If state contains unregistered attributes.
|
|
106
|
+
"""
|
|
107
|
+
# Get state attributes from all classes in MRO (set by StateAttribute descriptors)
|
|
108
|
+
state_attrs: list[Any] = []
|
|
109
|
+
for cls in self.__class__.__mro__:
|
|
110
|
+
state_attrs.extend(getattr(cls, "_state_attributes", []))
|
|
111
|
+
# Remove duplicates while preserving order
|
|
112
|
+
seen = set()
|
|
113
|
+
state_attrs = [x for x in state_attrs if not (x in seen or seen.add(x))]
|
|
114
|
+
|
|
115
|
+
for key, value in state.items():
|
|
116
|
+
if key not in state_attrs:
|
|
117
|
+
raise ValueError(f"Attribute {key} is not registered as a state attribute.")
|
|
118
|
+
setattr(self, key, value)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def metadata(self) -> dict:
|
|
122
|
+
"""Get simulator metadata.
|
|
123
|
+
|
|
124
|
+
This can be overridden by subclasses to include additional metadata.
|
|
125
|
+
Mixins should call super().metadata and update the returned dictionary.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Dictionary containing metadata.
|
|
129
|
+
"""
|
|
130
|
+
metadata = {
|
|
131
|
+
"max_samples": self.max_samples,
|
|
132
|
+
"counter": self.counter,
|
|
133
|
+
"version": __version__,
|
|
134
|
+
"state": {},
|
|
135
|
+
}
|
|
136
|
+
metadata["state"].update(self.state)
|
|
137
|
+
return metadata
|
|
138
|
+
|
|
139
|
+
# Iterator protocol
|
|
140
|
+
def __iter__(self):
|
|
141
|
+
"""Return iterator interface."""
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __next__(self):
|
|
145
|
+
"""Generate next sample.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Next generated sample.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
StopIteration: When max_samples is reached.
|
|
152
|
+
"""
|
|
153
|
+
if self.counter >= self.max_samples:
|
|
154
|
+
raise StopIteration("Maximum number of samples reached.")
|
|
155
|
+
|
|
156
|
+
sample = self.simulate()
|
|
157
|
+
self.update_state()
|
|
158
|
+
return sample
|
|
159
|
+
|
|
160
|
+
def save_state(self, file_name: str | Path, overwrite: bool = False, encoding: str = "utf-8") -> None:
|
|
161
|
+
"""Save simulator state to file.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
file_name: Output file path (must have .yml or .yaml extension).
|
|
165
|
+
overwrite: Whether to overwrite existing files.
|
|
166
|
+
encoding: File encoding to use when writing the file.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ValueError: If file extension is not .yml or .yaml.
|
|
170
|
+
FileExistsError: If file exists and overwrite=False.
|
|
171
|
+
"""
|
|
172
|
+
file_name = Path(file_name)
|
|
173
|
+
|
|
174
|
+
if file_name.suffix.lower() not in [".yml", ".yaml"]:
|
|
175
|
+
raise ValueError(f"Unsupported file format: {file_name.suffix}. Supported: [.yml, .yaml]")
|
|
176
|
+
|
|
177
|
+
if not overwrite and file_name.exists():
|
|
178
|
+
raise FileExistsError(f"File '{file_name}' already exists. Use overwrite=True to overwrite it.")
|
|
179
|
+
|
|
180
|
+
with file_name.open("w", encoding=encoding) as f:
|
|
181
|
+
yaml.safe_dump(self.state, f)
|
|
182
|
+
|
|
183
|
+
def load_state(self, file_name: str | Path, encoding: str = "utf-8") -> None:
|
|
184
|
+
"""Load simulator state from file.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
file_name: Input file path (must have .yml or .yaml extension).
|
|
188
|
+
encoding: File encoding to use when reading the file.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
FileNotFoundError: If file doesn't exist.
|
|
192
|
+
ValueError: If file extension is not .yml or .yaml.
|
|
193
|
+
"""
|
|
194
|
+
file_name = Path(file_name)
|
|
195
|
+
|
|
196
|
+
if file_name.suffix.lower() not in [".yml", ".yaml"]:
|
|
197
|
+
raise ValueError(f"Unsupported file format: {file_name.suffix}. Supported: [.yml, .yaml]")
|
|
198
|
+
|
|
199
|
+
if not file_name.exists():
|
|
200
|
+
raise FileNotFoundError(f"File '{file_name}' does not exist.")
|
|
201
|
+
|
|
202
|
+
with file_name.open("r", encoding=encoding) as f:
|
|
203
|
+
state = yaml.safe_load(f)
|
|
204
|
+
|
|
205
|
+
self.state = state
|
|
206
|
+
|
|
207
|
+
def save_metadata(
|
|
208
|
+
self,
|
|
209
|
+
file_name: str | Path,
|
|
210
|
+
output_directory: str | Path | None = None,
|
|
211
|
+
overwrite: bool = False,
|
|
212
|
+
encoding: str = "utf-8",
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Save simulator metadata to file.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
file_name: Output file path (must have .yml or .yaml extension).
|
|
218
|
+
output_directory: Optional output directory to prepend to the file name.
|
|
219
|
+
overwrite: Whether to overwrite existing files.
|
|
220
|
+
encoding: File encoding to use when writing the file.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
ValueError: If file extension is not .yml or .yaml.
|
|
224
|
+
FileExistsError: If file exists and overwrite=False.
|
|
225
|
+
"""
|
|
226
|
+
file_name_resolved = get_file_name_from_template(
|
|
227
|
+
template=str(file_name),
|
|
228
|
+
instance=self,
|
|
229
|
+
output_directory=output_directory,
|
|
230
|
+
)
|
|
231
|
+
if not isinstance(file_name_resolved, Path):
|
|
232
|
+
raise ValueError("Resolved file name for metadata must be a single Path.")
|
|
233
|
+
|
|
234
|
+
if file_name_resolved.suffix.lower() not in [".yml", ".yaml"]:
|
|
235
|
+
raise ValueError(f"Unsupported file format: {file_name_resolved.suffix}. Supported: [.yml, .yaml]")
|
|
236
|
+
|
|
237
|
+
if not overwrite and file_name_resolved.exists():
|
|
238
|
+
raise FileExistsError(f"File '{file_name_resolved}' already exists. Use overwrite=True to overwrite it.")
|
|
239
|
+
|
|
240
|
+
with file_name_resolved.open("w", encoding=encoding) as f:
|
|
241
|
+
yaml.safe_dump(self.metadata, f)
|
|
242
|
+
|
|
243
|
+
def update_state(self) -> None:
|
|
244
|
+
"""Update internal state after each sample generation.
|
|
245
|
+
|
|
246
|
+
This method must be implemented by all simulator subclasses.
|
|
247
|
+
"""
|
|
248
|
+
self.counter = cast(int, self.counter) + 1
|
|
249
|
+
|
|
250
|
+
# Abstract methods that subclasses must implement
|
|
251
|
+
@abstractmethod
|
|
252
|
+
def simulate(self, *args, **kwargs) -> Any:
|
|
253
|
+
"""Generate a single sample.
|
|
254
|
+
|
|
255
|
+
This method must be implemented by all simulator subclasses.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
A single generated sample.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
@abstractmethod
|
|
262
|
+
def _save_data(self, data: Any, file_name: str | Path | np.ndarray[Any, np.dtype[np.object_]], **kwargs) -> None:
|
|
263
|
+
"""Internal method to save data to a file.
|
|
264
|
+
|
|
265
|
+
This method must be implemented by all simulator subclasses.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
batch: Batch of generated samples.
|
|
269
|
+
file_name: Output file path.
|
|
270
|
+
overwrite: Whether to overwrite existing files.
|
|
271
|
+
**kwargs: Additional arguments for specific file formats.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def save_data(
|
|
275
|
+
self,
|
|
276
|
+
data: Any,
|
|
277
|
+
file_name: str | Path,
|
|
278
|
+
output_directory: str | Path | None = None,
|
|
279
|
+
overwrite: bool = False,
|
|
280
|
+
**kwargs,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Save data to a file.
|
|
283
|
+
|
|
284
|
+
This method must be implemented by all simulator subclasses.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
batch: Batch of generated samples.
|
|
288
|
+
file_name: Output file path.
|
|
289
|
+
If the file_name contains placeholders (e.g., {{detector}}, {{duration}}),
|
|
290
|
+
they are filled by the attributes of the simulator.
|
|
291
|
+
output_directory: Optional output directory to prepend to the file name.
|
|
292
|
+
overwrite: Whether to overwrite existing files.
|
|
293
|
+
save_metadata: Whether to save metadata alongside the data.
|
|
294
|
+
**kwargs: Additional arguments for specific file formats.
|
|
295
|
+
"""
|
|
296
|
+
file_name_resolved = get_file_name_from_template(
|
|
297
|
+
template=str(file_name),
|
|
298
|
+
instance=self,
|
|
299
|
+
output_directory=output_directory,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if not overwrite:
|
|
303
|
+
if isinstance(file_name_resolved, Path):
|
|
304
|
+
if file_name_resolved.exists():
|
|
305
|
+
raise FileExistsError(
|
|
306
|
+
f"File '{file_name_resolved}' already exists. " f"Use overwrite=True to overwrite it."
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
for single_file in file_name_resolved.flatten():
|
|
310
|
+
if single_file.exists():
|
|
311
|
+
raise FileExistsError(
|
|
312
|
+
f"File '{single_file}' already exists. " f"Use overwrite=True to overwrite it."
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
self._save_data(data=data, file_name=file_name_resolved, **kwargs)
|
gwsim/simulator/state.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A descriptor class to handle a state attribute.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, Generic, TypeVar, overload
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StateAttribute(Generic[T]):
|
|
14
|
+
"""A state attribute."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
default: T | None = None,
|
|
19
|
+
default_factory: Callable[[], T] | None = None,
|
|
20
|
+
post_set_hook: Callable[[Any, T], None] | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""A state attribute.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
default (T | None, optional): The default value of the attribute. Defaults to None.
|
|
26
|
+
default_factory (Callable[[], T] | None, optional): A factory to create the default value.
|
|
27
|
+
This is useful when you want to have a list as a state attribute, and
|
|
28
|
+
do not want to share the list across instances. Defaults to None.
|
|
29
|
+
post_set_hook (Callable[[Any, T], None] | None): Called after the value of the attribute is set.
|
|
30
|
+
"""
|
|
31
|
+
self.default = default
|
|
32
|
+
self.default_factory = default_factory
|
|
33
|
+
self.post_set_hook = post_set_hook
|
|
34
|
+
self.name = None
|
|
35
|
+
|
|
36
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
37
|
+
"""Set the name of the attribute.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
owner (type): Owner of the attribute.
|
|
41
|
+
name (str): Name.
|
|
42
|
+
"""
|
|
43
|
+
self.name = name
|
|
44
|
+
# Ensure each class has its OWN _state_attributes list (not inherited from parent).
|
|
45
|
+
# We check __dict__ directly to avoid inheriting a parent's list.
|
|
46
|
+
if "_state_attributes" not in owner.__dict__:
|
|
47
|
+
owner._state_attributes = []
|
|
48
|
+
if name not in owner._state_attributes:
|
|
49
|
+
owner._state_attributes.append(name)
|
|
50
|
+
|
|
51
|
+
@overload
|
|
52
|
+
def __get__(self, instance: None, owner: type) -> StateAttribute[T]: ...
|
|
53
|
+
|
|
54
|
+
@overload
|
|
55
|
+
def __get__(self, instance: Any, owner: type) -> T: ...
|
|
56
|
+
|
|
57
|
+
def __get__(self, instance: Any, owner: type) -> T | StateAttribute[T]:
|
|
58
|
+
"""Get the value of the attribute.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
instance (Any): Instance of the owner.
|
|
62
|
+
owner (type): Placeholder.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
T | StateAttribute: Value of the attribute.
|
|
66
|
+
"""
|
|
67
|
+
if instance is None:
|
|
68
|
+
return self
|
|
69
|
+
if self.name not in instance.__dict__:
|
|
70
|
+
if self.default_factory is not None:
|
|
71
|
+
instance.__dict__[self.name] = self.default_factory()
|
|
72
|
+
else:
|
|
73
|
+
instance.__dict__[self.name] = self.default
|
|
74
|
+
return instance.__dict__[self.name]
|
|
75
|
+
|
|
76
|
+
def __set__(self, instance: Any, value: T) -> None:
|
|
77
|
+
"""Set the value of the attribute.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
instance (Any): An instance of the owner.
|
|
81
|
+
value (T): Value to set.
|
|
82
|
+
"""
|
|
83
|
+
instance.__dict__[self.name] = value
|
|
84
|
+
if self.post_set_hook is not None:
|
|
85
|
+
self.post_set_hook(instance, value)
|
gwsim/utils/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Utilities for parsing human-readable time durations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
SECOND_UNITS = {
|
|
8
|
+
"second": 1,
|
|
9
|
+
"minute": 60,
|
|
10
|
+
"hour": 3_600,
|
|
11
|
+
"day": 86_400,
|
|
12
|
+
"week": 604_800,
|
|
13
|
+
# approximate values for longer units
|
|
14
|
+
"month": 2_592_000, # 30 days
|
|
15
|
+
"year": 31_536_000, # 365 days
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_duration_to_seconds(duration: str) -> float:
|
|
20
|
+
"""Convert a human-friendly duration like "1 day" into seconds.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
duration: A string such as "1 week", "2 days", "1.5 hours".
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Number of seconds represented by the duration.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: If the string cannot be parsed or the unit is unsupported.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
pattern = re.compile(r"^\s*(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>\w+)s?\s*$", re.IGNORECASE)
|
|
33
|
+
match = pattern.match(duration)
|
|
34
|
+
if not match:
|
|
35
|
+
raise ValueError("Duration must be of the form '<number> <unit>' (e.g. '1 week').")
|
|
36
|
+
|
|
37
|
+
value = float(match.group("value"))
|
|
38
|
+
unit = match.group("unit").lower().rstrip("s")
|
|
39
|
+
|
|
40
|
+
seconds = SECOND_UNITS.get(unit)
|
|
41
|
+
if seconds is None:
|
|
42
|
+
raise ValueError(f"Unsupported duration unit '{unit}'. Supported units: {', '.join(SECOND_UNITS)}.")
|
|
43
|
+
|
|
44
|
+
return value * seconds
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Script to compute the ET 2L aligned/misaligned geometry at a given location"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pymap3d as pm
|
|
7
|
+
from pycbc.detector import Detector, add_detector_on_earth
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_unit_vector_angles(unit_vector: np.ndarray, ellipsoid_position: np.ndarray) -> np.ndarray:
|
|
11
|
+
"""
|
|
12
|
+
Compute the azimuthal angle and altitude (elevation) of a given unit vector
|
|
13
|
+
relative to the local tangent plane at the specified ellipsoid position.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
unit vector (np.ndarray): A 3-element array representing the unit vector in
|
|
17
|
+
geocentric (ECEF) coordinates.
|
|
18
|
+
ellipsoid_position (np.ndarray): A 3-element array specifying the reference position
|
|
19
|
+
[latitude (rad), longitude (rad), height (meters)] on the Earth's ellipsoid
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
(np.ndarray): A 2-element array [azimuth (rad), altitude (rad)], where:
|
|
23
|
+
- azimuth is the angle from local north (0 to 2π, increasing eastward),
|
|
24
|
+
- altitude is the elevation angle from the local horizontal plane (-π/2 to π/2).
|
|
25
|
+
"""
|
|
26
|
+
lat, lon, _ = ellipsoid_position
|
|
27
|
+
normal_vector = np.array([np.cos(lat) * np.cos(lon), np.cos(lat) * np.sin(lon), np.sin(lat)])
|
|
28
|
+
north_vector = np.array([-np.sin(lat) * np.cos(lon), -np.sin(lat) * np.sin(lon), np.cos(lat)])
|
|
29
|
+
east_vector = np.array([-np.sin(lon), np.cos(lon), 0])
|
|
30
|
+
altitude = np.arcsin(np.dot(unit_vector, normal_vector))
|
|
31
|
+
azimuth = np.mod(np.arctan2(np.dot(unit_vector, east_vector), np.dot(unit_vector, north_vector)), 2 * np.pi)
|
|
32
|
+
|
|
33
|
+
return np.array([azimuth, altitude])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def add_et_2l_detectors_at_location( # pylint: disable=too-many-locals
|
|
37
|
+
e1_latitude: float,
|
|
38
|
+
e1_longitude: float,
|
|
39
|
+
e1_height: float,
|
|
40
|
+
e2_latitude: float,
|
|
41
|
+
e2_longitude: float,
|
|
42
|
+
e2_height: float,
|
|
43
|
+
alpha: float,
|
|
44
|
+
e1_location_name: str,
|
|
45
|
+
e2_location_name: str,
|
|
46
|
+
et_arm_l: float = 15000,
|
|
47
|
+
) -> tuple[Detector, Detector]:
|
|
48
|
+
"""
|
|
49
|
+
Add the 2L Einstein Telescope detectors with PyCBC at two given locations and heights,
|
|
50
|
+
for a given relative angle alpha.
|
|
51
|
+
The arms of the detectors are defined on the tangent plane at their vertex position.
|
|
52
|
+
The arm 1 of E1 has the same azimuth angle and altitude of the Virgo arm 1 in the
|
|
53
|
+
local horizontal coordinate system center at the E1 vertex.
|
|
54
|
+
All the angles are measured clockwise in the local horizontal coordinate system (North to East).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
E1_latitude (float): E1 vertex latitude (rad)
|
|
58
|
+
E1_longitude (float): E1 vertex longitude (rad)
|
|
59
|
+
E1_height (float): E1 vertex height above the standard reference ellipsoidal earth (meters)
|
|
60
|
+
E2_latitude (float): E2 vertex latitude (rad)
|
|
61
|
+
E2_longitude (float): E2 vertex longitude (rad)
|
|
62
|
+
E2_height (float): E2 vertex height above the standard reference ellipsoidal earth (meters)
|
|
63
|
+
alpha (float): Relative orientation angle alpha in radians. Alpha is defined
|
|
64
|
+
as the relative angle between the two detectors, oriented w.r.t their local North.
|
|
65
|
+
E1_location_name (str): Name of the E1 location (e.g., Sardinia, EMR, Cascina, ...)
|
|
66
|
+
for detector naming convention
|
|
67
|
+
E2_location_name (str): Name of the E1 location (e.g., Sardinia, EMR, Cascina, ...)
|
|
68
|
+
for detector naming convention
|
|
69
|
+
ETArmL (float, optional): ET arm length (meters). Default to 10000 meters.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
(Detector, Detector): pycbc.detector.Detector objects for E1, E2.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
if alpha == 0:
|
|
76
|
+
config = "Aligned"
|
|
77
|
+
elif alpha == np.pi / 4:
|
|
78
|
+
config = "Misaligned"
|
|
79
|
+
else:
|
|
80
|
+
raise ValueError("Only alpha = 0 (aligned configuration) and π/4 (misaligned configuration) are supported.")
|
|
81
|
+
|
|
82
|
+
# === Detector E1 ===
|
|
83
|
+
|
|
84
|
+
e1_ellipsoid = [e1_latitude, e1_longitude, e1_height]
|
|
85
|
+
|
|
86
|
+
# E1 vertex location in geocentric (ECEF) coordinates
|
|
87
|
+
e1 = np.array(pm.geodetic2ecef(*e1_ellipsoid, deg=False))
|
|
88
|
+
|
|
89
|
+
# Normal vector to the tangent plane at the E1 vertex (ECEF coordinates)
|
|
90
|
+
e1_norm_vec = np.array(
|
|
91
|
+
[np.cos(e1_latitude) * np.cos(e1_longitude), np.cos(e1_latitude) * np.sin(e1_longitude), np.sin(e1_latitude)]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Azimuth and altitude of Virgo arm 1 from LAL
|
|
95
|
+
v1_arm1_az = 0.3391628563404083
|
|
96
|
+
v1_arm1_alt = 0.0
|
|
97
|
+
|
|
98
|
+
# Define the arm 1 of E1 with the same azimuth and altitude of the Virgo arm 1 (ECEF coordinates)
|
|
99
|
+
e1_arm1 = np.array(
|
|
100
|
+
pm.aer2ecef(
|
|
101
|
+
az=v1_arm1_az, el=v1_arm1_alt, srange=1, lat0=e1_latitude, lon0=e1_longitude, alt0=e1_height, deg=False
|
|
102
|
+
)
|
|
103
|
+
- e1
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Vector perpendicular to E1Arm1 on the same plane
|
|
107
|
+
e1_arm2 = np.cross(e1_arm1, e1_norm_vec)
|
|
108
|
+
|
|
109
|
+
e1_arm1_angles = get_unit_vector_angles(e1_arm1, e1_ellipsoid)
|
|
110
|
+
e1_arm2_angles = get_unit_vector_angles(e1_arm2, e1_ellipsoid)
|
|
111
|
+
|
|
112
|
+
add_detector_on_earth( # pylint: disable=duplicate-code
|
|
113
|
+
name=f"E1_{config}_" + e1_location_name,
|
|
114
|
+
latitude=e1_ellipsoid[0],
|
|
115
|
+
longitude=e1_ellipsoid[1],
|
|
116
|
+
height=e1_ellipsoid[2],
|
|
117
|
+
xangle=e1_arm1_angles[0],
|
|
118
|
+
yangle=e1_arm2_angles[0],
|
|
119
|
+
xaltitude=e1_arm1_angles[1],
|
|
120
|
+
yaltitude=e1_arm2_angles[1],
|
|
121
|
+
xlength=et_arm_l,
|
|
122
|
+
ylength=et_arm_l,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# === Detector E2 ===
|
|
126
|
+
|
|
127
|
+
e2_ellipsoid = [e2_latitude, e2_longitude, e2_height]
|
|
128
|
+
|
|
129
|
+
# E2 vertex location in geocentric (ECEF) coordinates
|
|
130
|
+
e2 = np.array(pm.geodetic2ecef(*e2_ellipsoid, deg=False))
|
|
131
|
+
|
|
132
|
+
# Normal vector to the tangent plane at the E2 vertex (ECEF coordinates)
|
|
133
|
+
e2_norm_vec = np.array(
|
|
134
|
+
[np.cos(e2_latitude) * np.cos(e2_longitude), np.cos(e2_latitude) * np.sin(e2_longitude), np.sin(e2_latitude)]
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Define the arm 1 of E2 with the same azimuth and altitude of the Virgo arm 1 + alpha (ECEF coordinates)
|
|
138
|
+
e2_arm1_az = v1_arm1_az + alpha
|
|
139
|
+
e2_arm1 = np.array(
|
|
140
|
+
pm.aer2ecef(
|
|
141
|
+
az=e2_arm1_az, el=v1_arm1_alt, srange=1, lat0=e2_latitude, lon0=e2_longitude, alt0=e2_height, deg=False
|
|
142
|
+
)
|
|
143
|
+
- e2
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Vector perpendicular to E2Arm1 on the same plane
|
|
147
|
+
e2_arm2 = np.cross(e2_arm1, e2_norm_vec)
|
|
148
|
+
|
|
149
|
+
e2_arm1_angles = get_unit_vector_angles(e2_arm1, e2_ellipsoid)
|
|
150
|
+
e2_arm2_angles = get_unit_vector_angles(e2_arm2, e2_ellipsoid)
|
|
151
|
+
|
|
152
|
+
add_detector_on_earth(
|
|
153
|
+
name=f"E2_{config}_" + e2_location_name,
|
|
154
|
+
latitude=e2_ellipsoid[0],
|
|
155
|
+
longitude=e2_ellipsoid[1],
|
|
156
|
+
height=e2_ellipsoid[2],
|
|
157
|
+
xangle=e2_arm1_angles[0],
|
|
158
|
+
yangle=e2_arm2_angles[0],
|
|
159
|
+
xaltitude=e2_arm1_angles[1],
|
|
160
|
+
yaltitude=e2_arm2_angles[1],
|
|
161
|
+
xlength=et_arm_l,
|
|
162
|
+
ylength=et_arm_l,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return Detector(f"E1_{config}_" + e1_location_name), Detector(f"E2_{config}_" + e2_location_name)
|