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,90 @@
1
+ """A utility module for loading gravitational wave interferometer configurations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ast import literal_eval
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ from pycbc.detector import add_detector_on_earth
10
+
11
+ # The default base path for detector configuration files
12
+ DEFAULT_DETECTOR_BASE_PATH = Path(__file__).parent / "detectors"
13
+
14
+
15
+ def _bilby_to_pycbc_detector_parameters(bilby_params: dict) -> dict:
16
+ """
17
+ Convert Bilby detector parameters to PyCBC-compatible parameters.
18
+
19
+ This function handles the conversion of units and conventions between Bilby and PyCBC,
20
+ including latitude/longitude to radians, length from km to meters, and azimuth adjustments
21
+ due to different reference conventions (Bilby: from East counterclockwise; PyCBC/LAL: from North clockwise).
22
+
23
+ Args:
24
+ bilby_params (dict): Dictionary of Bilby parameters (e.g., 'latitude', 'xarm_azimuth', etc.).
25
+
26
+ Returns:
27
+ dict: Dictionary of converted PyCBC parameters.
28
+ """
29
+ pycbc_params = {
30
+ "name": bilby_params["name"],
31
+ "latitude": np.deg2rad(bilby_params["latitude"]),
32
+ "longitude": np.deg2rad(bilby_params["longitude"]),
33
+ "height": bilby_params["elevation"],
34
+ "xangle": (np.pi / 2 - np.deg2rad(bilby_params["xarm_azimuth"])) % (2 * np.pi),
35
+ "yangle": (np.pi / 2 - np.deg2rad(bilby_params["yarm_azimuth"])) % (2 * np.pi),
36
+ "xaltitude": bilby_params["xarm_tilt"],
37
+ "yaltitude": bilby_params["yarm_tilt"],
38
+ "xlength": bilby_params["length"] * 1000,
39
+ "ylength": bilby_params["length"] * 1000,
40
+ }
41
+
42
+ return pycbc_params
43
+
44
+
45
+ def load_interferometer_config(config_file: str | Path, encoding: str = "utf-8") -> str:
46
+ """
47
+ Load a .interferometer config file and add its detector using pycbc.detector.add_detector_on_earth.
48
+
49
+ Args:
50
+ config_file: The path to the config file.
51
+ encoding: The file encoding to use when reading the config file. Default is 'utf-8'.
52
+
53
+ Returns:
54
+ str: Added detector name (e.g., "E1").
55
+ """
56
+ # Load the .interferometer config file
57
+ config_file = Path(config_file)
58
+ if not config_file.exists():
59
+ raise FileNotFoundError(f"Config file {config_file} not found.")
60
+
61
+ bilby_params = {}
62
+ with config_file.open(encoding=encoding) as f:
63
+ lines = f.readlines()
64
+ for line in lines:
65
+ if line[0] == "#" or line[0] == "\n":
66
+ continue
67
+ split_line = line.split("=")
68
+ key = split_line[0].strip()
69
+ if key == "power_spectral_density":
70
+ continue
71
+ value = literal_eval("=".join(split_line[1:]))
72
+ bilby_params[key] = value
73
+
74
+ params = _bilby_to_pycbc_detector_parameters(bilby_params)
75
+ det_name = params["name"]
76
+
77
+ add_detector_on_earth(
78
+ name=det_name,
79
+ latitude=params["latitude"],
80
+ longitude=params["longitude"],
81
+ height=params["height"],
82
+ xangle=params["xangle"],
83
+ yangle=params["yangle"],
84
+ xaltitude=params["xaltitude"],
85
+ yaltitude=params["yaltitude"],
86
+ xlength=params["xlength"],
87
+ ylength=params["ylength"],
88
+ )
89
+
90
+ return det_name
@@ -0,0 +1,7 @@
1
+ """Glitch simulators for gravitational-wave data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gwsim.glitch.base import GlitchSimulator
6
+
7
+ __all__ = ["GlitchSimulator"]
gwsim/glitch/base.py ADDED
@@ -0,0 +1,69 @@
1
+ """Base class for glitch simulators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import cast
6
+
7
+ import numpy as np
8
+
9
+ from gwsim.mixin.detector import DetectorMixin
10
+ from gwsim.mixin.randomness import RandomnessMixin
11
+ from gwsim.mixin.time_series import TimeSeriesMixin
12
+ from gwsim.simulator.base import Simulator
13
+
14
+
15
+ class GlitchSimulator(TimeSeriesMixin, RandomnessMixin, DetectorMixin, Simulator):
16
+ """Base class for glitch simulators."""
17
+
18
+ def __init__(
19
+ self,
20
+ start_time: int = 0,
21
+ duration: float = 1024,
22
+ sampling_frequency: float = 4096,
23
+ max_samples: int | None = None,
24
+ dtype: type = np.float64,
25
+ seed: int | None = None,
26
+ detectors: list[str] | None = None,
27
+ **kwargs,
28
+ ) -> None:
29
+ """Initialize the base glitch simulator.
30
+
31
+ Args:
32
+ start_time: Start time of the first glitch segment in GPS seconds. Default is 0.
33
+ duration: Duration of each glitch segment in seconds. Default is 1024.
34
+ sampling_frequency: Sampling frequency of the glitches in Hz. Default is 4096.
35
+ max_samples: Maximum number of samples to generate. None means infinite.
36
+ dtype: Data type for the time series data. Default is np.float64.
37
+ seed: Seed for the random number generator. If None, the RNG is not initialized.
38
+ detectors: List of detector names. Default is None.
39
+ **kwargs: Additional arguments absorbed by subclasses and mixins.
40
+ """
41
+ super().__init__(
42
+ start_time=start_time,
43
+ duration=duration,
44
+ sampling_frequency=sampling_frequency,
45
+ max_samples=max_samples,
46
+ dtype=dtype,
47
+ seed=seed,
48
+ detectors=detectors,
49
+ **kwargs,
50
+ )
51
+
52
+ @property
53
+ def metadata(self) -> dict:
54
+ """Get the metadata of the simulator.
55
+
56
+ Returns:
57
+ Metadata dictionary.
58
+ """
59
+ meta = super().metadata
60
+ return meta
61
+
62
+ def update_state(self) -> None:
63
+ """Update internal state after each sample generation.
64
+
65
+ This method can be overridden by subclasses to update any internal state
66
+ after generating a sample. The default implementation does nothing.
67
+ """
68
+ self.counter = cast(int, self.counter) + 1
69
+ self.start_time += self.duration
@@ -0,0 +1,8 @@
1
+ """Mixins for GW simulation classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .randomness import RandomnessMixin
6
+ from .time_series import TimeSeriesMixin
7
+
8
+ __all__ = ["RandomnessMixin", "TimeSeriesMixin"]
@@ -0,0 +1,203 @@
1
+ """Detector mixin for simulators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import cast
7
+
8
+ import numpy as np
9
+ from gwpy.timeseries import TimeSeries as GWpyTimeSeries
10
+ from scipy.interpolate import interp1d
11
+
12
+ from gwsim.data.time_series.time_series import TimeSeries
13
+ from gwsim.detector.base import Detector
14
+ from gwsim.detector.utils import DEFAULT_DETECTOR_BASE_PATH
15
+
16
+
17
+ class DetectorMixin: # pylint: disable=too-few-public-methods
18
+ """Mixin class to add detector information to simulators."""
19
+
20
+ def __init__(self, detectors: list[str] | None = None, **kwargs): # pylint: disable=unused-argument
21
+ """Initialize the DetectorMixin.
22
+
23
+ Args:
24
+ detectors (list[str] | None): List of detector names. If None, use all available detectors.
25
+ **kwargs: Additional arguments.
26
+ """
27
+ super().__init__(**kwargs)
28
+ self._metadata = {"detector": {"arguments": {"detectors": detectors}}}
29
+ self.detectors = detectors
30
+
31
+ @property
32
+ def detectors(self) -> list[Detector]:
33
+ """Get the list of detectors.
34
+
35
+ Returns:
36
+ List of detector names or Detector instances, or None if not set.
37
+ """
38
+ return self._detectors
39
+
40
+ @detectors.setter
41
+ def detectors(self, value: list[str] | None) -> None:
42
+ """Set the list of detectors.
43
+
44
+ Args:
45
+ value (list[str | Path | Detector] | None):
46
+ List of detector names, config file paths, or Detector instances.
47
+ If None, no detectors are set.
48
+ """
49
+ if value is None:
50
+ self._detectors = []
51
+ elif isinstance(value, list):
52
+ self._detectors = []
53
+ for det in value:
54
+ if Path(det).is_file() or (DEFAULT_DETECTOR_BASE_PATH / det).is_file():
55
+ self._detectors.append(Detector(configuration_file=det))
56
+ else:
57
+ self._detectors.append(Detector(name=str(det)))
58
+ else:
59
+ raise ValueError("detectors must be a list.")
60
+
61
+ def detectors_are_configured(self) -> bool:
62
+ """Check if all detectors are configured.
63
+
64
+ Returns:
65
+ True if all detectors are configured, False otherwise.
66
+ """
67
+ if all(det.is_configured() for det in self.detectors):
68
+ return True
69
+ return False
70
+
71
+ def project_polarizations( # pylint: disable=too-many-locals,unused-argument
72
+ self,
73
+ polarizations: dict[str, GWpyTimeSeries],
74
+ right_ascension: float,
75
+ declination: float,
76
+ polarization_angle: float,
77
+ earth_rotation: bool = True,
78
+ **kwargs,
79
+ ) -> TimeSeries:
80
+ """Project waveform polarizations onto detectors using antenna patterns.
81
+
82
+ This method projects the plus and cross polarizations of a gravitational wave
83
+ onto each detector in the network, accounting for antenna response and
84
+ time delays.
85
+
86
+ Args:
87
+ polarizations: Dictionary with 'plus' and 'cross' keys containing
88
+ TimeSeries objects of the waveform polarizations.
89
+ right_ascension: RA of source in radians.
90
+ declination: Declination of source in radians.
91
+ polarization_angle: Polarization angle in radians.
92
+ earth_rotation: If True, account for Earth's rotation by computing
93
+ antenna patterns at multiple times and interpolating.
94
+ Defaults to True.
95
+
96
+ Returns:
97
+ Dictionary mapping detector names (str) to projected TimeSeries objects.
98
+ Keys are detector names, values are projected strain TimeSeries.
99
+
100
+ Raises:
101
+ ValueError: If detectors are not configured.
102
+ ValueError: If polarizations dict doesn't contain 'plus' and 'cross' keys,
103
+ or if detector is not initialized.
104
+ TypeError: If polarizations values are not TimeSeries objects.
105
+ """
106
+ # Validate the detector list
107
+ if not self.detectors_are_configured():
108
+ raise ValueError("Detectors are not configured in the simulator.")
109
+
110
+ # Validate inputs
111
+ if not isinstance(polarizations, dict):
112
+ raise TypeError("polarizations must be a dictionary")
113
+ if "plus" not in polarizations or "cross" not in polarizations:
114
+ raise ValueError("polarizations dict must contain 'plus' and 'cross' keys")
115
+ if not isinstance(polarizations["plus"], GWpyTimeSeries):
116
+ raise TypeError("polarizations['plus'] must be a GWpyTimeSeries")
117
+ if not isinstance(polarizations["cross"], GWpyTimeSeries):
118
+ raise TypeError("polarizations['cross'] must be a GWpyTimeSeries")
119
+
120
+ hp = polarizations["plus"]
121
+ hc = polarizations["cross"]
122
+
123
+ # Convert TimeSeries data to numpy arrays for computation
124
+ # Interpolate the hp and hc data to ensure smooth evaluation
125
+ time_array = cast(np.ndarray, hp.times.to_value())
126
+ reference_time = 0.5 * (time_array[0] + time_array[-1])
127
+
128
+ # Compute the time_array minus the reference_time to avoid the systematic large time values
129
+ time_array_wrt_reference = time_array - reference_time
130
+
131
+ hp_func = interp1d(time_array_wrt_reference, hp.to_value(), kind="cubic", bounds_error=False, fill_value=0.0)
132
+ hc_func = interp1d(time_array_wrt_reference, hc.to_value(), kind="cubic", bounds_error=False, fill_value=0.0)
133
+
134
+ if earth_rotation:
135
+ # Calculate the time delays first
136
+ time_delays = [
137
+ det.time_delay_from_earth_center(
138
+ right_ascension=right_ascension, declination=declination, t_gps=time_array
139
+ )
140
+ for det in self.detectors
141
+ ]
142
+
143
+ else:
144
+ # Calculate the antenna patterns at the reference time
145
+ reference_time = 0.5 * (time_array[0] + time_array[-1])
146
+ antenna_patterns = [
147
+ det.antenna_pattern(
148
+ right_ascension=right_ascension,
149
+ declination=declination,
150
+ polarization=polarization_angle,
151
+ t_gps=reference_time,
152
+ polarization_type="tensor",
153
+ )
154
+ for det in self.detectors
155
+ ]
156
+
157
+ # Calculate the time delays at the reference time
158
+ time_delays = [
159
+ det.time_delay_from_earth_center(
160
+ right_ascension=right_ascension, declination=declination, t_gps=reference_time
161
+ )
162
+ for det in self.detectors
163
+ ]
164
+
165
+ # Evaluate the detector responses
166
+ detector_responses = np.zeros((len(self.detectors), len(time_array)))
167
+ for i, det in enumerate(self.detectors):
168
+ time_delay = time_delays[i]
169
+
170
+ # Shift the waveform data according to time delays
171
+ shifted_times = time_array_wrt_reference + time_delay
172
+
173
+ if earth_rotation:
174
+ # Evaluate antenna patterns exactly at the delayed times
175
+ fp_vals, fc_vals = det.antenna_pattern(
176
+ right_ascension=right_ascension,
177
+ declination=declination,
178
+ polarization=polarization_angle,
179
+ t_gps=time_array + time_delay,
180
+ polarization_type="tensor",
181
+ )
182
+ else:
183
+ # Use constant antenna patterns (from earlier calculation)
184
+ fp_vals, fc_vals = antenna_patterns[i]
185
+
186
+ hp_shifted = hp_func(shifted_times)
187
+ hc_shifted = hc_func(shifted_times)
188
+
189
+ detector_responses[i, :] = fp_vals * hp_shifted + fc_vals * hc_shifted
190
+
191
+ # Create TimeSeries for projected strain
192
+ start_time = cast(float, time_array[0])
193
+ projected_ts = TimeSeries(data=detector_responses, start_time=start_time, sampling_frequency=hp.sample_rate)
194
+ return projected_ts
195
+
196
+ @property
197
+ def metadata(self) -> dict:
198
+ """Get metadata including detector information.
199
+
200
+ Returns:
201
+ Dictionary containing the list of detectors.
202
+ """
203
+ return self._metadata
gwsim/mixin/gwf.py ADDED
@@ -0,0 +1,192 @@
1
+ """GWF (Gravitational Wave Frame) file output utilities using gwpy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ from gwpy.io.gwf import write_frames
10
+ from gwpy.timeseries import TimeSeries
11
+
12
+
13
+ def save_timeseries_to_gwf(
14
+ data: np.ndarray,
15
+ file_path: str | Path,
16
+ channel: str = "H1:STRAIN",
17
+ sampling_frequency: float = 4096,
18
+ start_time: float = 0,
19
+ overwrite: bool = False,
20
+ ) -> None:
21
+ """Save time series data to GWF frame file using gwpy.
22
+
23
+ Args:
24
+ data: Time series data array.
25
+ file_path: Output GWF file path.
26
+ channel: Channel name (e.g., "H1:STRAIN"). Default is "H1:STRAIN".
27
+ sampling_frequency: Sampling rate in Hz. Default is 4096.
28
+ start_time: GPS start time. Default is 0.
29
+ overwrite: Whether to overwrite existing files. Default is False.
30
+
31
+ Raises:
32
+ ImportError: If gwpy is not available.
33
+ FileExistsError: If file exists and overwrite=False.
34
+ ValueError: If data is empty or parameters are invalid.
35
+ """
36
+
37
+ if len(data) == 0:
38
+ raise ValueError("Data array cannot be empty")
39
+
40
+ if sampling_frequency <= 0:
41
+ raise ValueError("Sample rate must be positive")
42
+
43
+ # Convert to Path object
44
+ file_path = Path(file_path)
45
+
46
+ # Check if file exists
47
+ if file_path.exists() and not overwrite:
48
+ raise FileExistsError(f"File {file_path} already exists and overwrite=False")
49
+
50
+ # Create gwpy TimeSeries
51
+ timeseries = TimeSeries(data=data, t0=start_time, sample_rate=sampling_frequency, channel=channel, name=channel)
52
+
53
+ # Ensure output directory exists
54
+ file_path.parent.mkdir(parents=True, exist_ok=True)
55
+
56
+ # Write to GWF file
57
+ timeseries.write(str(file_path), format="gwf")
58
+
59
+
60
+ def combine_timeseries_to_gwf(
61
+ timeseries_list: list[tuple[np.ndarray, str]],
62
+ file_path: str | Path,
63
+ sampling_frequency: float = 4096,
64
+ start_time: float = 0,
65
+ overwrite: bool = False,
66
+ ) -> None:
67
+ """Combine multiple time series into a single GWF file.
68
+
69
+ Args:
70
+ timeseries_list: List of (data, channel_name) tuples.
71
+ file_path: Output GWF file path.
72
+ sampling_frequency: Sampling rate in Hz. Default is 4096.
73
+ start_time: GPS start time. Default is 0.
74
+ overwrite: Whether to overwrite existing files. Default is False.
75
+
76
+ Raises:
77
+ ImportError: If gwpy is not available.
78
+ ValueError: If timeseries_list is empty or data shapes don't match.
79
+ """
80
+ if not timeseries_list:
81
+ raise ValueError("timeseries_list cannot be empty")
82
+
83
+ # Convert to Path object
84
+ file_path = Path(file_path)
85
+
86
+ # Check if file exists
87
+ if file_path.exists() and not overwrite:
88
+ raise FileExistsError(f"File {file_path} already exists and overwrite=False")
89
+
90
+ # Validate all data arrays have the same length
91
+ lengths = [len(data) for data, _ in timeseries_list]
92
+ if not all(length == lengths[0] for length in lengths):
93
+ raise ValueError("All time series must have the same length")
94
+
95
+ # Create gwpy TimeSeries objects
96
+ gwpy_timeseries = []
97
+ for data, channel in timeseries_list:
98
+ ts = TimeSeries(data, sampling_frequency=sampling_frequency, epoch=start_time, name=channel, channel=channel)
99
+ gwpy_timeseries.append(ts)
100
+
101
+ # Ensure output directory exists
102
+ file_path.parent.mkdir(parents=True, exist_ok=True)
103
+
104
+ # Write multiple channels to GWF file
105
+ # gwpy handles multiple timeseries automatically
106
+ write_frames(str(file_path), gwpy_timeseries)
107
+
108
+
109
+ class GWFOutputMixin:
110
+ """Mixin to add GWF file output capabilities to simulators.
111
+
112
+ This mixin provides methods for saving simulator output to GWF frame files
113
+ using gwpy. It assumes the simulator has sampling_frequency, start_time, and
114
+ generates numpy arrays.
115
+ """
116
+
117
+ def __init__(self, **kwargs) -> None:
118
+ """Initialize GWFOutputMixin.
119
+
120
+ Args:
121
+ **kwargs: Additional arguments absorbed by subclasses.
122
+ """
123
+ super().__init__(**kwargs)
124
+
125
+ def save_to_gwf(
126
+ self,
127
+ data: np.ndarray,
128
+ file_path: str | Path,
129
+ channel: str | None = None,
130
+ overwrite: bool = False,
131
+ ) -> None:
132
+ """Save simulator data to GWF file.
133
+
134
+ Args:
135
+ data: Time series data from simulator.
136
+ file_path: Output GWF file path.
137
+ channel: Channel name. If None, uses a default based on class name.
138
+ overwrite: Whether to overwrite existing files.
139
+ """
140
+ # Get parameters from simulator
141
+ sampling_frequency = getattr(self, "sampling_frequency", None)
142
+ start_time = getattr(self, "start_time", 0)
143
+
144
+ if sampling_frequency is None:
145
+ raise ValueError("Simulator must have sampling_frequency attribute")
146
+
147
+ # Generate default channel name if not provided
148
+ if channel is None:
149
+ class_name = self.__class__.__name__
150
+ # Convert CamelCase to UPPER_CASE
151
+ channel = re.sub("([a-z0-9])([A-Z])", r"\1_\2", class_name).upper()
152
+ channel = f"SIM:{channel}"
153
+
154
+ save_timeseries_to_gwf(
155
+ data=data,
156
+ file_path=file_path,
157
+ channel=channel,
158
+ sampling_frequency=sampling_frequency,
159
+ start_time=start_time,
160
+ overwrite=overwrite,
161
+ )
162
+
163
+ def save_batch_to_gwf( # pylint: disable=unused-argument
164
+ self,
165
+ batch: list[np.ndarray] | np.ndarray,
166
+ file_path: str | Path,
167
+ channel: str | None = None,
168
+ overwrite: bool = False,
169
+ **kwargs,
170
+ ) -> None:
171
+ """Save a batch of simulator data to GWF file.
172
+
173
+ Args:
174
+ batch: Batch of time series data (list of arrays or 2D array).
175
+ file_path: Output GWF file path.
176
+ channel: Channel name. If None, uses a default based on class name.
177
+ overwrite: Whether to overwrite existing files.
178
+ """
179
+ # Flatten batch if needed
180
+ if isinstance(batch, list):
181
+ concatenated = np.concatenate(batch)
182
+ elif isinstance(batch, np.ndarray) and batch.ndim == 2:
183
+ concatenated = batch.flatten()
184
+ else:
185
+ concatenated = batch
186
+
187
+ self.save_to_gwf(
188
+ data=concatenated,
189
+ file_path=file_path,
190
+ channel=channel,
191
+ overwrite=overwrite,
192
+ )