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/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