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
gwsim/utils/io.py ADDED
@@ -0,0 +1,167 @@
1
+ """Utility functions for file input/output operations with safety checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ import logging
7
+ import re
8
+ from functools import wraps
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ from astropy.units import Quantity
13
+ from numpy.typing import NDArray
14
+
15
+ logger = logging.getLogger("gwsim")
16
+
17
+
18
+ def check_file_overwrite():
19
+ """A decorator to check the existence of the file,
20
+ and avoid overwriting it unintentionally.
21
+
22
+ Provides safe file handling with clear error messages and logging.
23
+ """
24
+
25
+ def decorator(func):
26
+ @wraps(func)
27
+ def wrapper(*args, file_name: str | Path, overwrite: bool = False, **kwargs):
28
+ file_name = Path(file_name)
29
+
30
+ # Create parent directories if they don't exist
31
+ file_name.parent.mkdir(parents=True, exist_ok=True)
32
+
33
+ if file_name.exists():
34
+ if not overwrite:
35
+ raise FileExistsError(
36
+ f"File '{file_name}' already exists. "
37
+ f"Use overwrite=True or --overwrite flag to overwrite it."
38
+ )
39
+ file_size = file_name.stat().st_size
40
+ logger.warning("File '%s' already exists (size: %d bytes). Overwriting...", file_name, file_size)
41
+
42
+ return func(*args, file_name=file_name, overwrite=overwrite, **kwargs)
43
+
44
+ return wrapper
45
+
46
+ return decorator
47
+
48
+
49
+ def check_file_exist():
50
+ """A decorator to check the existence of a file."""
51
+
52
+ def decorator(func):
53
+ @wraps(func)
54
+ def wrapper(*args, file_name: str | Path, **kwargs):
55
+ file_name = Path(file_name)
56
+ if not file_name.is_file():
57
+ raise FileNotFoundError(f"File {file_name} does not exist.")
58
+ return func(*args, file_name=file_name, **kwargs)
59
+
60
+ return wrapper
61
+
62
+ return decorator
63
+
64
+
65
+ def get_file_name_from_template( # pylint: disable=too-many-locals
66
+ template: str,
67
+ instance: object | None = None,
68
+ output_directory: str | Path | None = None,
69
+ exclude: set[str] | None = None,
70
+ ) -> Path | NDArray[Path]:
71
+ """Get the file name(s) from a template string.
72
+
73
+ The template string uses double curly brackets for placeholders (e.g., '{{ x }}-{{ y }}.txt').
74
+ If any placeholder refers to an array-like attribute (list, tuple, or iterable),
75
+ the function generates all combinations of file names by iterating over the Cartesian product.
76
+ For example, '{{ non-list }}-{{ list-A }}-{{ list-B }}' with list-A having X elements
77
+ and list-B having Y elements returns a list of X * Y file names.
78
+
79
+ Excluded placeholders are left unsubstituted (e.g., '{{ excluded }}' remains as-is).
80
+
81
+ Args:
82
+ template (str): A template string with placeholders.
83
+ instance (object): An instance from which to retrieve attribute values.
84
+ output_directory (str | Path | None): Optional output directory to prepend to the file names.
85
+ exclude (set[str] | None): Set of attribute names to exclude from expansion. Defaults to None.
86
+
87
+ Returns:
88
+ str | np.ndarray: A single file name string if no array-like attributes are used,
89
+ otherwise a nested list structure matching the dimensionality of the array placeholders.
90
+
91
+ Raises:
92
+ ValueError: If a placeholder refers to a non-existent attribute.
93
+ """
94
+ if exclude is None:
95
+ exclude = set()
96
+
97
+ # Find all unique placeholders in the template
98
+ placeholders = list(dict.fromkeys(re.findall(r"\{\{\s*(\w+)\s*\}\}", template)))
99
+
100
+ # Remove excluded placeholders from the list
101
+ placeholders = [p for p in placeholders if p not in exclude]
102
+
103
+ # Collect values for each placeholder
104
+ values_dict = {}
105
+ for label in placeholders:
106
+ if label in exclude:
107
+ continue
108
+ try:
109
+ value = getattr(instance, label)
110
+ except AttributeError as e:
111
+ raise ValueError(f"Attribute '{label}' not found in instance of type {type(instance).__name__}") from e
112
+
113
+ if isinstance(value, Quantity):
114
+ x = value.value
115
+ if x.is_integer():
116
+ x = int(x)
117
+ values_dict[label] = [str(x)]
118
+ # Check if value is array-like (list, tuple, or iterable but not str)
119
+ elif isinstance(value, (list, tuple)) or (hasattr(value, "__iter__") and not isinstance(value, str)):
120
+ values_dict[label] = [str(ele) for ele in list(value)]
121
+ else:
122
+ values_dict[label] = [str(value)]
123
+
124
+ # Prepare lists for Cartesian product
125
+ product_lists = [values_dict[label] for label in placeholders]
126
+
127
+ # Generate all combinations
128
+ combinations = list(itertools.product(*product_lists))
129
+
130
+ # Helper function for substitution
131
+ def substitute_template(combo_dict: dict) -> str:
132
+ def replace(matched):
133
+ label = matched.group(1).strip()
134
+ if label in exclude:
135
+ return matched.group(0) # Return the original placeholder unchanged
136
+ return str(combo_dict[label])
137
+
138
+ return re.sub(r"\{\{\s*(\w+)\s*\}\}", replace, template)
139
+
140
+ # Substitute for each combination
141
+ results = [Path(substitute_template(dict(zip(placeholders, combo, strict=False)))) for combo in combinations]
142
+
143
+ if output_directory is not None:
144
+ output_directory = Path(output_directory)
145
+ results = [output_directory / result for result in results]
146
+
147
+ # Reshape into nested list if there are array placeholders
148
+ array_placeholders = [p for p in placeholders if len(values_dict[p]) > 1]
149
+ if array_placeholders:
150
+ lengths = [len(values_dict[p]) for p in array_placeholders]
151
+
152
+ def reshape_to_nested(flat_list: list, lengths: list[int]):
153
+ if not lengths:
154
+ return flat_list[0] if flat_list else ""
155
+ size = lengths[0]
156
+ sub_size = len(flat_list) // size
157
+ nested = []
158
+ for i in range(size):
159
+ start = i * sub_size
160
+ end = (i + 1) * sub_size
161
+ sub_list = flat_list[start:end]
162
+ nested.append(reshape_to_nested(sub_list, lengths[1:]))
163
+ return nested
164
+
165
+ return np.array(reshape_to_nested(results, lengths))
166
+ # No arrays: return single string
167
+ return results[0] if results else Path("")
gwsim/utils/log.py ADDED
@@ -0,0 +1,145 @@
1
+ """Utility functions for logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from importlib.metadata import PackageNotFoundError, distribution
7
+ from importlib.metadata import version as get_package_version
8
+ from pathlib import Path
9
+
10
+ from gwsim.version import __version__
11
+
12
+ logger = logging.getLogger("gwsim")
13
+
14
+
15
+ def get_version_information() -> str:
16
+ """Get the version information.
17
+
18
+ Returns:
19
+ str: Version information.
20
+ """
21
+ return __version__
22
+
23
+
24
+ def _get_dependencies_from_distribution() -> list[str]:
25
+ """Extract main dependencies from the installed gwsim distribution.
26
+
27
+ This uses importlib.metadata to query the installed package metadata,
28
+ which works in both development and installed environments.
29
+
30
+ Returns:
31
+ List of package names from the distribution's requires metadata.
32
+ Returns empty list if gwsim distribution cannot be found.
33
+ """
34
+ try:
35
+ dist = distribution("gwsim")
36
+ if dist.requires is None:
37
+ return []
38
+
39
+ # Parse dependency specifiers from the requires list
40
+ # Format: "package_name (>=version); extra == 'condition'" or "package_name>=version"
41
+ package_names = []
42
+ for req in dist.requires:
43
+ # Remove version specifiers and extras
44
+ # Split on common delimiters: >, <, =, !, [, ;, space
45
+ package_name = (
46
+ req.split(">")[0].split("<")[0].split("=")[0].split("!")[0].split("[")[0].split(";")[0].strip()
47
+ )
48
+ if package_name and package_name not in package_names: # Avoid duplicates
49
+ package_names.append(package_name)
50
+
51
+ return package_names
52
+ except PackageNotFoundError:
53
+ logger.debug("gwsim distribution not found - unable to extract dependencies")
54
+ return []
55
+ except Exception as e: # pylint: disable=broad-exception-caught
56
+ logger.debug("Failed to extract dependencies from distribution: %s", e)
57
+ return []
58
+
59
+
60
+ def get_dependency_versions() -> dict[str, str | None]:
61
+ """Get versions of gwsim and its main dependencies.
62
+
63
+ This retrieves version information for gwsim and all dependencies listed
64
+ in the installed distribution's metadata (from pyproject.toml at build time),
65
+ useful for metadata and reproducibility tracking.
66
+
67
+ Works in both development and installed environments since it queries
68
+ the installed distribution metadata rather than the source pyproject.toml.
69
+
70
+ Returns:
71
+ Dictionary with package names as keys and version strings as values.
72
+ If a package version cannot be determined, the value is None.
73
+ Always includes 'gwsim' as the first key.
74
+ """
75
+ versions: dict[str, str | None] = {}
76
+
77
+ # Always include gwsim first
78
+ try:
79
+ versions["gwsim"] = get_package_version("gwsim")
80
+ except PackageNotFoundError:
81
+ versions["gwsim"] = None
82
+
83
+ # Get dependencies from the installed distribution metadata
84
+ dependencies = _get_dependencies_from_distribution()
85
+
86
+ for package in dependencies:
87
+ try:
88
+ versions[package] = get_package_version(package)
89
+ except PackageNotFoundError:
90
+ # Package not installed or version cannot be determined
91
+ versions[package] = None
92
+
93
+ return versions
94
+
95
+
96
+ def setup_logger(
97
+ outdir: str = ".", label: str | None = None, log_level: str | int = "INFO", print_version: bool = False
98
+ ):
99
+ """Setup logging output: call at the start of the script to use
100
+
101
+ Args:
102
+ outdir (str, optional): If supplied, write the logging output to outdir/label.log. Defaults to '.'.
103
+ label (str, optional): If supplied, write the logging output to outdir/label.log. Defaults to None.
104
+ log_level (str, optional): ['debug', 'info', 'warning']
105
+ Either a string from the list above, or an integer as specified
106
+ in https://docs.python.org/2/library/logging.html#logging-levels
107
+ Defaults to 'INFO'.
108
+ print_version (bool): If true, print version information. Defaults to False.
109
+ """
110
+
111
+ if isinstance(log_level, str):
112
+ try:
113
+ level = getattr(logging, log_level.upper())
114
+ except AttributeError as err:
115
+ raise ValueError(f"log_level {log_level} not understood") from err
116
+ else:
117
+ level = int(log_level)
118
+
119
+ logger.propagate = False
120
+ logger.setLevel(level)
121
+
122
+ if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
123
+ stream_handler = logging.StreamHandler()
124
+ stream_handler.setFormatter(
125
+ logging.Formatter("%(asctime)s %(name)s %(levelname)-8s: %(message)s", datefmt="%H:%M")
126
+ )
127
+ stream_handler.setLevel(level)
128
+ logger.addHandler(stream_handler)
129
+
130
+ if not any(isinstance(h, logging.FileHandler) for h in logger.handlers):
131
+ if label:
132
+ Path(outdir).mkdir(parents=True, exist_ok=True)
133
+ log_file = f"{outdir}/{label}.log"
134
+ file_handler = logging.FileHandler(log_file)
135
+ file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-8s: %(message)s", datefmt="%H:%M"))
136
+
137
+ file_handler.setLevel(level)
138
+ logger.addHandler(file_handler)
139
+
140
+ for handler in logger.handlers:
141
+ handler.setLevel(level)
142
+
143
+ if print_version:
144
+ version = get_version_information()
145
+ logger.info("Running gwsim version: %s", version)
@@ -0,0 +1,48 @@
1
+ """Utility functions for reading population files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import h5py
8
+ import pandas as pd
9
+
10
+
11
+ def read_pycbc_population_file(file_name):
12
+ """
13
+ Read a PyCBC population file (.hdf, .h5) and return a pandas DataFrame.
14
+
15
+ Supports:
16
+ - HDF5 population files from pycbc_population
17
+ Parameters
18
+ ----------
19
+ file_name : Path or str
20
+ Path to the PyCBC population file.
21
+
22
+ Returns
23
+ -------
24
+ pd.DataFrame
25
+ DataFrame containing population parameters.
26
+ """
27
+ file_path = Path(file_name)
28
+ if not file_path.exists():
29
+ raise FileNotFoundError(f"File not found: {file_path}")
30
+
31
+ suffix = file_path.suffix.lower()
32
+
33
+ # --- HDF5 (.hdf or .h5) ---
34
+ if suffix in {".hdf", ".h5"}:
35
+ with h5py.File(file_path, "r") as f:
36
+
37
+ # PyCBC stores parameters as datasets
38
+ data = {key: value[()] for key, value in f.items()}
39
+
40
+ # Collect attributes (Include static parameters in config files)
41
+ attrs = dict(f.attrs.items())
42
+
43
+ df = pd.DataFrame(data)
44
+ df.attrs.update(attrs) # Attach metadata
45
+ else:
46
+ raise ValueError(f"Unsupported file type: {suffix}")
47
+
48
+ return df
gwsim/utils/random.py ADDED
@@ -0,0 +1,69 @@
1
+ """A random number manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from numpy.random import Generator, SeedSequence, default_rng
6
+
7
+
8
+ class RandomManager:
9
+ """Singleton manager for random number generation."""
10
+
11
+ _rng = default_rng()
12
+
13
+ @classmethod
14
+ def get_rng(cls) -> Generator:
15
+ """Get the random number generator.
16
+
17
+ Returns:
18
+ Generator: Random number generator.
19
+ """
20
+ return cls._rng
21
+
22
+ @classmethod
23
+ def seed(cls, seed_: int):
24
+ """Set the seed of the random number generator.
25
+
26
+ Args:
27
+ seed_ (int): Seed.
28
+ """
29
+ cls._rng = default_rng(seed_)
30
+
31
+ @classmethod
32
+ def generate_seeds(cls, n_seeds: int) -> list:
33
+ """Generate the seeds using the numpy SeedSequence class such that
34
+ the BitGenerators are independent and very probably non-overlapping.
35
+
36
+ Args:
37
+ n_seeds (int): Number of seeds.
38
+
39
+ Returns:
40
+ list: A list of SeedSequence.
41
+ """
42
+ return SeedSequence(cls._rng.integers(0, 2**63 - 1, size=4)).spawn(n_seeds)
43
+
44
+ @classmethod
45
+ def get_state(cls) -> dict:
46
+ """Get the current state of the random number generator.
47
+
48
+ Returns:
49
+ dict: The state of the RNG's bit generator.
50
+ """
51
+ return cls._rng.bit_generator.state
52
+
53
+ @classmethod
54
+ def set_state(cls, state: dict):
55
+ """Set the state of the random number generator.
56
+
57
+ Args:
58
+ state (dict): The state of the RNG's bit generator.
59
+ """
60
+ cls._rng = default_rng() # Create new generator to avoid state conflicts
61
+ cls._rng.bit_generator.state = state
62
+
63
+
64
+ # Alias for easy access
65
+ get_rng = RandomManager.get_rng
66
+ set_seed = RandomManager.seed
67
+ generate_seeds = RandomManager.generate_seeds
68
+ get_state = RandomManager.get_state
69
+ set_state = RandomManager.set_state
gwsim/utils/retry.py ADDED
@@ -0,0 +1,75 @@
1
+ """Retry decorator for handling transient failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import logging
7
+ import time
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger("gwsim")
12
+
13
+
14
+ def retry_on_failure(
15
+ max_retries: int = 3,
16
+ initial_delay: float = 1.0,
17
+ backoff_factor: float = 2.0,
18
+ exceptions: tuple[type[Exception], ...] = (Exception,),
19
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
20
+ """Decorator to retry a function on failure with exponential backoff.
21
+
22
+ Retries the decorated function up to `max_retries` times on specified exceptions,
23
+ with exponential backoff between attempts. Useful for handling transient I/O errors.
24
+
25
+ Args:
26
+ max_retries: Maximum number of retry attempts (default: 3).
27
+ initial_delay: Initial delay in seconds before the first retry (default: 1.0).
28
+ backoff_factor: Factor by which the delay increases after each retry (default: 2.0).
29
+ exceptions: Tuple of exception types to catch and retry on (default: all Exceptions).
30
+
31
+ Returns:
32
+ The decorated function.
33
+
34
+ Example:
35
+ @retry_on_failure(max_retries=5, initial_delay=0.5)
36
+ def unstable_io_operation():
37
+ # Code that might fail transiently
38
+ pass
39
+ """
40
+
41
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
42
+ @functools.wraps(func)
43
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
44
+ delay = initial_delay
45
+ last_exception: Exception | None = None
46
+
47
+ for attempt in range(max_retries + 1):
48
+ try:
49
+ return func(*args, **kwargs)
50
+ except exceptions as e:
51
+ last_exception = e
52
+ if attempt < max_retries:
53
+ logger.warning(
54
+ "Attempt %d/%d failed for %s: %s. Retrying in %.2f seconds...",
55
+ attempt + 1,
56
+ max_retries + 1,
57
+ func.__name__,
58
+ e,
59
+ delay,
60
+ )
61
+ time.sleep(delay)
62
+ delay *= backoff_factor
63
+ else:
64
+ logger.error(
65
+ "All %d attempts failed for %s: %s",
66
+ max_retries + 1,
67
+ func.__name__,
68
+ e,
69
+ )
70
+ raise last_exception from e
71
+ return None
72
+
73
+ return wrapper
74
+
75
+ return decorator
@@ -0,0 +1,164 @@
1
+ """Script to compute the ET Triangular 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
17
+ in 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_triangular_detector_at_location( # pylint: disable=too-many-locals,duplicate-code
37
+ e1_latitude: float, e1_longitude: float, e1_height: float, location_name: str, et_arm_l: float = 10000
38
+ ) -> tuple[Detector, Detector, Detector]:
39
+ """
40
+ Add the triangular Einstein Telescope detector with PyCBC at a given location and height.
41
+ The ET triangular configuration follows T1400308.
42
+ The arms 1 and 2 of E1 are defined on the tangent plane at the E1 vertex position.
43
+ The arm 1 has the same azimuth angle and altitude of the Virgo arm 1
44
+ in the local horizontal coordinate system center at the E1 vertex.
45
+
46
+ Args:
47
+ E1_latitude (float): E1 vertex latitude (rad)
48
+ E1_longitude (float): E1 vertex longitude (rad)
49
+ E1_height (float): E1 vertex height above the standard reference ellipsoidal earth (meters)
50
+ location_name (str): Name of the ET location (e.g., Sardinia, EMR, Cascina, ...)
51
+ for detector naming convention
52
+ ETArmL (float, optional): ET arm length (meters). Default to 10000 meters.
53
+
54
+ Returns:
55
+ (Detector, Detector, Detector): pycbc.detector.Detector objects for E1, E2 and E3.
56
+ """
57
+
58
+ e1_ellipsoid = [e1_latitude, e1_longitude, e1_height]
59
+
60
+ # E1 vertex location in geocentric (ECEF) coordinates
61
+ e1 = np.array(pm.geodetic2ecef(*e1_ellipsoid, deg=False))
62
+
63
+ # Normal vector to the tangent plane at the E1 vertex (ECEF coordinates)
64
+ e1_norm_vec = np.array(
65
+ [np.cos(e1_latitude) * np.cos(e1_longitude), np.cos(e1_latitude) * np.sin(e1_longitude), np.sin(e1_latitude)]
66
+ )
67
+
68
+ # Azimuth and altitude of Virgo arm 1 from LAL
69
+ v1_arm1_az = 0.3391628563404083
70
+ v1_arm1_alt = 0.0
71
+
72
+ # Define the arm 1 of E1 with the same azimuth and altitude of the Virgo arm 1 (ECEF coordinates)
73
+ e1_arm1 = np.array(
74
+ pm.aer2ecef(
75
+ az=v1_arm1_az, el=v1_arm1_alt, srange=1, lat0=e1_latitude, lon0=e1_longitude, alt0=e1_height, deg=False
76
+ )
77
+ - e1
78
+ )
79
+
80
+ # E2 vertex location
81
+ e2 = e1 + (et_arm_l * e1_arm1)
82
+
83
+ # Calculating rotation matrix to define E2 and E3 arms
84
+ ux, uy, uz = e1_norm_vec
85
+ theta = 60
86
+ cos_t = np.cos(np.deg2rad(theta))
87
+ sin_t = np.sin(np.deg2rad(theta))
88
+ re1 = np.array(
89
+ [
90
+ [cos_t + ux**2 * (1 - cos_t), ux * uy * (1 - cos_t) - uz * sin_t, ux * uz * (1 - cos_t) + uy * sin_t],
91
+ [ux * uy * (1 - cos_t) + uz * sin_t, cos_t + uy**2 * (1 - cos_t), uy * uz * (1 - cos_t) - ux * sin_t],
92
+ [ux * uz * (1 - cos_t) - uy * sin_t, uy * uz * (1 - cos_t) + ux * sin_t, cos_t + uz**2 * (1 - cos_t)],
93
+ ]
94
+ )
95
+
96
+ # Apply rotational matrix to E1 arm 1 vector to define E1 arm 2
97
+ e1_arm2 = re1 @ e1_arm1
98
+
99
+ # E3 vertex location
100
+ e3 = e1 + (et_arm_l * e1_arm2)
101
+
102
+ # E2 arm vectors
103
+ e2_arm1 = -e1_arm1 + e1_arm2
104
+ e2_arm2 = -e1_arm1
105
+
106
+ # E3 arm vectors
107
+ e3_arm1 = -e1_arm2
108
+ e3_arm2 = -e2_arm1
109
+
110
+ # Calculate the vertex positions in geodetic (ellipsoidal) coordinates
111
+ e2_ellipsoid = np.array(pm.ecef2geodetic(*e2, deg=False))
112
+ e3_ellipsoid = np.array(pm.ecef2geodetic(*e3, deg=False))
113
+
114
+ # Calculate the unit vector angles (azimuth and altitude)
115
+ e1_arm1_angles = get_unit_vector_angles(e1_arm1, e1_ellipsoid)
116
+ e1_arm2_angles = get_unit_vector_angles(e1_arm2, e1_ellipsoid)
117
+ e2_arm1_angles = get_unit_vector_angles(e2_arm1, e2_ellipsoid)
118
+ e2_arm2_angles = get_unit_vector_angles(e2_arm2, e2_ellipsoid)
119
+ e3_arm1_angles = get_unit_vector_angles(e3_arm1, e3_ellipsoid)
120
+ e3_arm2_angles = get_unit_vector_angles(e3_arm2, e3_ellipsoid)
121
+
122
+ # Add detectors with PyCBC
123
+ add_detector_on_earth(
124
+ name="E1_60deg_" + location_name,
125
+ latitude=e1_ellipsoid[0],
126
+ longitude=e1_ellipsoid[1],
127
+ height=e1_ellipsoid[2],
128
+ xangle=e1_arm1_angles[0],
129
+ yangle=e1_arm2_angles[0],
130
+ xaltitude=e1_arm1_angles[1],
131
+ yaltitude=e1_arm2_angles[1],
132
+ xlength=et_arm_l,
133
+ ylength=et_arm_l,
134
+ )
135
+ add_detector_on_earth( # pylint: disable=duplicate-code
136
+ name="E2_60deg_" + location_name,
137
+ latitude=e2_ellipsoid[0],
138
+ longitude=e2_ellipsoid[1],
139
+ height=e2_ellipsoid[2],
140
+ xangle=e2_arm1_angles[0],
141
+ yangle=e2_arm2_angles[0],
142
+ xaltitude=e2_arm1_angles[1],
143
+ yaltitude=e2_arm2_angles[1],
144
+ xlength=et_arm_l,
145
+ ylength=et_arm_l,
146
+ )
147
+ add_detector_on_earth(
148
+ name="E3_60deg_" + location_name,
149
+ latitude=e3_ellipsoid[0],
150
+ longitude=e3_ellipsoid[1],
151
+ height=e3_ellipsoid[2],
152
+ xangle=e3_arm1_angles[0],
153
+ yangle=e3_arm2_angles[0],
154
+ xaltitude=e3_arm1_angles[1],
155
+ yaltitude=e3_arm2_angles[1],
156
+ xlength=et_arm_l,
157
+ ylength=et_arm_l,
158
+ )
159
+
160
+ return (
161
+ Detector("E1_60deg_" + location_name),
162
+ Detector("E2_60deg_" + location_name),
163
+ Detector("E3_60deg_" + location_name),
164
+ )
gwsim/version.py ADDED
@@ -0,0 +1,7 @@
1
+ """Version information for gwsim package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import version
6
+
7
+ __version__ = version("gwsim")
@@ -0,0 +1,7 @@
1
+ """Init file for waveform module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gwsim.waveform.pycbc_wrapper import pycbc_waveform_wrapper
6
+
7
+ __all__ = ["pycbc_waveform_wrapper"]