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.
Files changed (103) hide show
  1. gwsim/__init__.py +11 -0
  2. gwsim/__main__.py +8 -0
  3. gwsim/cli/__init__.py +0 -0
  4. gwsim/cli/config.py +88 -0
  5. gwsim/cli/default_config.py +56 -0
  6. gwsim/cli/main.py +101 -0
  7. gwsim/cli/merge.py +150 -0
  8. gwsim/cli/repository/__init__.py +0 -0
  9. gwsim/cli/repository/create.py +91 -0
  10. gwsim/cli/repository/delete.py +51 -0
  11. gwsim/cli/repository/download.py +54 -0
  12. gwsim/cli/repository/list_depositions.py +63 -0
  13. gwsim/cli/repository/main.py +38 -0
  14. gwsim/cli/repository/metadata/__init__.py +0 -0
  15. gwsim/cli/repository/metadata/main.py +24 -0
  16. gwsim/cli/repository/metadata/update.py +58 -0
  17. gwsim/cli/repository/publish.py +52 -0
  18. gwsim/cli/repository/upload.py +74 -0
  19. gwsim/cli/repository/utils.py +47 -0
  20. gwsim/cli/repository/verify.py +61 -0
  21. gwsim/cli/simulate.py +220 -0
  22. gwsim/cli/simulate_utils.py +596 -0
  23. gwsim/cli/utils/__init__.py +85 -0
  24. gwsim/cli/utils/checkpoint.py +178 -0
  25. gwsim/cli/utils/config.py +347 -0
  26. gwsim/cli/utils/hash.py +23 -0
  27. gwsim/cli/utils/retry.py +62 -0
  28. gwsim/cli/utils/simulation_plan.py +439 -0
  29. gwsim/cli/utils/template.py +56 -0
  30. gwsim/cli/utils/utils.py +149 -0
  31. gwsim/cli/validate.py +255 -0
  32. gwsim/data/__init__.py +8 -0
  33. gwsim/data/serialize/__init__.py +9 -0
  34. gwsim/data/serialize/decoder.py +59 -0
  35. gwsim/data/serialize/encoder.py +44 -0
  36. gwsim/data/serialize/serializable.py +33 -0
  37. gwsim/data/time_series/__init__.py +3 -0
  38. gwsim/data/time_series/inject.py +104 -0
  39. gwsim/data/time_series/time_series.py +355 -0
  40. gwsim/data/time_series/time_series_list.py +182 -0
  41. gwsim/detector/__init__.py +8 -0
  42. gwsim/detector/base.py +156 -0
  43. gwsim/detector/detectors/E1_2L_Aligned_Sardinia.interferometer +22 -0
  44. gwsim/detector/detectors/E1_2L_Misaligned_Sardinia.interferometer +22 -0
  45. gwsim/detector/detectors/E1_Triangle_EMR.interferometer +19 -0
  46. gwsim/detector/detectors/E1_Triangle_Sardinia.interferometer +19 -0
  47. gwsim/detector/detectors/E2_2L_Aligned_EMR.interferometer +22 -0
  48. gwsim/detector/detectors/E2_2L_Misaligned_EMR.interferometer +22 -0
  49. gwsim/detector/detectors/E2_Triangle_EMR.interferometer +19 -0
  50. gwsim/detector/detectors/E2_Triangle_Sardinia.interferometer +19 -0
  51. gwsim/detector/detectors/E3_Triangle_EMR.interferometer +19 -0
  52. gwsim/detector/detectors/E3_Triangle_Sardinia.interferometer +19 -0
  53. gwsim/detector/noise_curves/ET_10_HF_psd.txt +3000 -0
  54. gwsim/detector/noise_curves/ET_10_full_cryo_psd.txt +3000 -0
  55. gwsim/detector/noise_curves/ET_15_HF_psd.txt +3000 -0
  56. gwsim/detector/noise_curves/ET_15_full_cryo_psd.txt +3000 -0
  57. gwsim/detector/noise_curves/ET_20_HF_psd.txt +3000 -0
  58. gwsim/detector/noise_curves/ET_20_full_cryo_psd.txt +3000 -0
  59. gwsim/detector/noise_curves/ET_D_psd.txt +3000 -0
  60. gwsim/detector/utils.py +90 -0
  61. gwsim/glitch/__init__.py +7 -0
  62. gwsim/glitch/base.py +69 -0
  63. gwsim/mixin/__init__.py +8 -0
  64. gwsim/mixin/detector.py +203 -0
  65. gwsim/mixin/gwf.py +192 -0
  66. gwsim/mixin/population_reader.py +175 -0
  67. gwsim/mixin/randomness.py +107 -0
  68. gwsim/mixin/time_series.py +295 -0
  69. gwsim/mixin/waveform.py +47 -0
  70. gwsim/noise/__init__.py +19 -0
  71. gwsim/noise/base.py +134 -0
  72. gwsim/noise/bilby_stationary_gaussian.py +117 -0
  73. gwsim/noise/colored_noise.py +275 -0
  74. gwsim/noise/correlated_noise.py +257 -0
  75. gwsim/noise/pycbc_stationary_gaussian.py +112 -0
  76. gwsim/noise/stationary_gaussian.py +44 -0
  77. gwsim/noise/white_noise.py +51 -0
  78. gwsim/repository/__init__.py +0 -0
  79. gwsim/repository/zenodo.py +269 -0
  80. gwsim/signal/__init__.py +11 -0
  81. gwsim/signal/base.py +137 -0
  82. gwsim/signal/cbc.py +61 -0
  83. gwsim/simulator/__init__.py +7 -0
  84. gwsim/simulator/base.py +315 -0
  85. gwsim/simulator/state.py +85 -0
  86. gwsim/utils/__init__.py +11 -0
  87. gwsim/utils/datetime_parser.py +44 -0
  88. gwsim/utils/et_2l_geometry.py +165 -0
  89. gwsim/utils/io.py +167 -0
  90. gwsim/utils/log.py +145 -0
  91. gwsim/utils/population.py +48 -0
  92. gwsim/utils/random.py +69 -0
  93. gwsim/utils/retry.py +75 -0
  94. gwsim/utils/triangular_et_geometry.py +164 -0
  95. gwsim/version.py +7 -0
  96. gwsim/waveform/__init__.py +7 -0
  97. gwsim/waveform/factory.py +83 -0
  98. gwsim/waveform/pycbc_wrapper.py +37 -0
  99. gwsim-0.1.0.dist-info/METADATA +157 -0
  100. gwsim-0.1.0.dist-info/RECORD +103 -0
  101. gwsim-0.1.0.dist-info/WHEEL +4 -0
  102. gwsim-0.1.0.dist-info/entry_points.txt +2 -0
  103. gwsim-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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)
@@ -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)
@@ -0,0 +1,11 @@
1
+ """Utility functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .random import generate_seeds, get_rng, set_seed
6
+
7
+ __all__ = [
8
+ "generate_seeds",
9
+ "get_rng",
10
+ "set_seed",
11
+ ]
@@ -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)