gwbpy 0.1.0a1__tar.gz

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.
gwbpy-0.1.0a1/PKG-INFO ADDED
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: gwbpy
3
+ Version: 0.1.0a1
4
+ Summary: Gravitational-wave visualizations for Blender (alpha)
5
+ Author: Ellis Eisenberg
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Science/Research
8
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
9
+ Classifier: Topic :: Scientific/Engineering :: Physics
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: numpy
13
+
14
+ # gwbpy
15
+
16
+ Gravitational-wave visualization for Blender.
17
+
18
+ **Status: alpha (v0.1.0). Not on PyPI yet. Expect rough edges.**
19
+
20
+ Currently one function, `animate_binary_physical()`: two black holes spiral
21
+ together and merge, driven by SEOBNRv5HM effective-one-body dynamics.
22
+
23
+ ## Tested on
24
+
25
+ - Blender 5.0 on Windows, with Ubuntu 24.04 under WSL2
26
+ - The setup script on Ubuntu 24.04
27
+
28
+ Not tested: macOS, other Linux distributions, Windows without WSL.
29
+
30
+ ## How it works
31
+
32
+ Blender's Python cannot run the physics libraries, so gwbpy builds a
33
+ separate Python 3.11 environment at `~/.gwbpy/dynamics-env` (inside WSL on
34
+ Windows) containing pyseobnr and lalsuite. Animation calls run a small worker
35
+ in that environment, read the result back, and keyframe it in Blender.
36
+
37
+ ## Install
38
+
39
+ 1. Install system packages once, in a Linux or WSL terminal:
40
+
41
+ sudo apt update && sudo apt install -y python3-venv libgsl-dev swig build-essential libfftw3-dev libhdf5-dev
42
+
43
+ 2. Make gwbpy importable in Blender. Until it is on PyPI, clone this
44
+ repository and, in Blender's Scripting tab, run (with the folder that
45
+ contains `gwbpy/`):
46
+
47
+ import sys
48
+ sys.path.insert(0, r"PATH_TO_FOLDER_CONTAINING_gwbpy")
49
+ import gwbpy
50
+
51
+ 3. Build the physics environment, once:
52
+
53
+ gwbpy.setup()
54
+
55
+ The first run builds from source and takes about 10 minutes. Blender may
56
+ look frozen meanwhile. Window > Toggle System Console shows progress. If
57
+ system packages are missing, setup stops and prints the exact command to
58
+ run. Use `gwbpy.setup(force=True)` to rebuild.
59
+
60
+ ## Example
61
+
62
+ gwbpy.animate_binary_physical(m1=30, m2=15, merge_frame=100, frames=200,
63
+ f_lower=80.0, meters_per_unit=20000.0)
64
+
65
+ ## Main parameters
66
+
67
+ | Parameter | Meaning |
68
+ |---|---|
69
+ | `m1`, `m2` | Masses in solar masses |
70
+ | `f_lower` | Starting GW frequency in Hz. Higher means fewer orbits |
71
+ | `initial_separation_km` | Alternative to `f_lower` (converted with Kepler's law) |
72
+ | `merge_frame` | Frame at which the dynamics end (merger) |
73
+ | `frames` | Total clip length (default `merge_frame + 50`) |
74
+ | `meters_per_unit` | Real meters per Blender unit; scales orbit and spheres together |
75
+ | `close_merger` | Schematically close any remaining gap (default True) |
76
+ | `show_paths` | Show motion-path trails |
77
+
78
+ The console reports the real inspiral duration, the slowdown factor, the
79
+ number of frames per orbit at the start, and how long any schematic closing
80
+ step is. Read these before putting numbers on a slide.
81
+
82
+ ## What is physics and what is not
83
+
84
+ - Orbital separation and phase come from the SEOBNRv5HM effective-one-body
85
+ dynamics. Separation is the EOB radial coordinate, not a literal horizon
86
+ position.
87
+ - Sphere sizes are the Schwarzschild radii of the two masses. The bodies are
88
+ non-spinning and do not deform.
89
+ - Each body sits at its Newtonian center-of-mass fraction of the separation.
90
+ - Time is stretched. The slowdown factor is printed.
91
+ - If the data ends slightly short of the horizons touching, a short schematic
92
+ closing step finishes the merger. It is a visual stand-in, not physics.
93
+ `close_merger=False` removes it. The ringdown is not modeled.
94
+ - Non-spinning only for now.
95
+
96
+ ## Known limits
97
+
98
+ - The model never starts closer than a separation of 10 M. A requested start
99
+ frequency above about 2044 / (m1 + m2) Hz (masses in solar masses) is
100
+ lowered to that value, and a note is printed. Every run therefore has at
101
+ least about 5 orbits.
102
+ - Frames per orbit matters. If the console warns about more than half a turn
103
+ per frame, the motion will look aliased. Raise `merge_frame`, or reduce the
104
+ number of orbits with a higher `f_lower`.
105
+
106
+ ## Citing
107
+
108
+ pyseobnr asks that results made with it acknowledge the software and the
109
+ model papers:
110
+
111
+ - Mihaylov et al., "pySEOBNR: a software package for the next generation of
112
+ effective-one-body multipolar waveform models", arXiv:2303.18203
113
+ - Pompili et al., "Laying the foundation of the effective-one-body waveform
114
+ models SEOBNRv5", arXiv:2303.18039
115
+
116
+ (Citations copied from the pyseobnr PyPI page. Check them before use.)
117
+
118
+ ## License
119
+
120
+ To be decided. pyseobnr is GPL-3.0-or-later.
@@ -0,0 +1,107 @@
1
+ # gwbpy
2
+
3
+ Gravitational-wave visualization for Blender.
4
+
5
+ **Status: alpha (v0.1.0). Not on PyPI yet. Expect rough edges.**
6
+
7
+ Currently one function, `animate_binary_physical()`: two black holes spiral
8
+ together and merge, driven by SEOBNRv5HM effective-one-body dynamics.
9
+
10
+ ## Tested on
11
+
12
+ - Blender 5.0 on Windows, with Ubuntu 24.04 under WSL2
13
+ - The setup script on Ubuntu 24.04
14
+
15
+ Not tested: macOS, other Linux distributions, Windows without WSL.
16
+
17
+ ## How it works
18
+
19
+ Blender's Python cannot run the physics libraries, so gwbpy builds a
20
+ separate Python 3.11 environment at `~/.gwbpy/dynamics-env` (inside WSL on
21
+ Windows) containing pyseobnr and lalsuite. Animation calls run a small worker
22
+ in that environment, read the result back, and keyframe it in Blender.
23
+
24
+ ## Install
25
+
26
+ 1. Install system packages once, in a Linux or WSL terminal:
27
+
28
+ sudo apt update && sudo apt install -y python3-venv libgsl-dev swig build-essential libfftw3-dev libhdf5-dev
29
+
30
+ 2. Make gwbpy importable in Blender. Until it is on PyPI, clone this
31
+ repository and, in Blender's Scripting tab, run (with the folder that
32
+ contains `gwbpy/`):
33
+
34
+ import sys
35
+ sys.path.insert(0, r"PATH_TO_FOLDER_CONTAINING_gwbpy")
36
+ import gwbpy
37
+
38
+ 3. Build the physics environment, once:
39
+
40
+ gwbpy.setup()
41
+
42
+ The first run builds from source and takes about 10 minutes. Blender may
43
+ look frozen meanwhile. Window > Toggle System Console shows progress. If
44
+ system packages are missing, setup stops and prints the exact command to
45
+ run. Use `gwbpy.setup(force=True)` to rebuild.
46
+
47
+ ## Example
48
+
49
+ gwbpy.animate_binary_physical(m1=30, m2=15, merge_frame=100, frames=200,
50
+ f_lower=80.0, meters_per_unit=20000.0)
51
+
52
+ ## Main parameters
53
+
54
+ | Parameter | Meaning |
55
+ |---|---|
56
+ | `m1`, `m2` | Masses in solar masses |
57
+ | `f_lower` | Starting GW frequency in Hz. Higher means fewer orbits |
58
+ | `initial_separation_km` | Alternative to `f_lower` (converted with Kepler's law) |
59
+ | `merge_frame` | Frame at which the dynamics end (merger) |
60
+ | `frames` | Total clip length (default `merge_frame + 50`) |
61
+ | `meters_per_unit` | Real meters per Blender unit; scales orbit and spheres together |
62
+ | `close_merger` | Schematically close any remaining gap (default True) |
63
+ | `show_paths` | Show motion-path trails |
64
+
65
+ The console reports the real inspiral duration, the slowdown factor, the
66
+ number of frames per orbit at the start, and how long any schematic closing
67
+ step is. Read these before putting numbers on a slide.
68
+
69
+ ## What is physics and what is not
70
+
71
+ - Orbital separation and phase come from the SEOBNRv5HM effective-one-body
72
+ dynamics. Separation is the EOB radial coordinate, not a literal horizon
73
+ position.
74
+ - Sphere sizes are the Schwarzschild radii of the two masses. The bodies are
75
+ non-spinning and do not deform.
76
+ - Each body sits at its Newtonian center-of-mass fraction of the separation.
77
+ - Time is stretched. The slowdown factor is printed.
78
+ - If the data ends slightly short of the horizons touching, a short schematic
79
+ closing step finishes the merger. It is a visual stand-in, not physics.
80
+ `close_merger=False` removes it. The ringdown is not modeled.
81
+ - Non-spinning only for now.
82
+
83
+ ## Known limits
84
+
85
+ - The model never starts closer than a separation of 10 M. A requested start
86
+ frequency above about 2044 / (m1 + m2) Hz (masses in solar masses) is
87
+ lowered to that value, and a note is printed. Every run therefore has at
88
+ least about 5 orbits.
89
+ - Frames per orbit matters. If the console warns about more than half a turn
90
+ per frame, the motion will look aliased. Raise `merge_frame`, or reduce the
91
+ number of orbits with a higher `f_lower`.
92
+
93
+ ## Citing
94
+
95
+ pyseobnr asks that results made with it acknowledge the software and the
96
+ model papers:
97
+
98
+ - Mihaylov et al., "pySEOBNR: a software package for the next generation of
99
+ effective-one-body multipolar waveform models", arXiv:2303.18203
100
+ - Pompili et al., "Laying the foundation of the effective-one-body waveform
101
+ models SEOBNRv5", arXiv:2303.18039
102
+
103
+ (Citations copied from the pyseobnr PyPI page. Check them before use.)
104
+
105
+ ## License
106
+
107
+ To be decided. pyseobnr is GPL-3.0-or-later.
@@ -0,0 +1,5 @@
1
+ from .setup_env import setup
2
+ from .animate import animate_binary_physical
3
+
4
+ __version__ = "0.1.0a1"
5
+ __all__ = ["setup", "animate_binary_physical"]
@@ -0,0 +1,299 @@
1
+ """
2
+ gwbpy.animate
3
+
4
+ Physics-driven animation of a compact binary inspiral in Blender.
5
+ Orbital separation and phase come from the SEOBNRv5HM effective-one-body
6
+ dynamics (via pyseobnr, run in a separate environment; see gwbpy.physics).
7
+
8
+ Units convention: all masses (m1, m2) are in solar masses (M_sun).
9
+ """
10
+
11
+ import numpy as np
12
+
13
+ try:
14
+ import bpy
15
+ import bmesh
16
+ except ImportError:
17
+ bpy = None
18
+ bmesh = None
19
+
20
+ # Physical constants (SI units)
21
+ G = 6.67430e-11
22
+ C = 2.99792458e8
23
+ M_SUN = 1.98892e30 # kg
24
+
25
+
26
+ def _schwarzschild_radius_m(mass_solar: float) -> float:
27
+ """r_sch = 2GM / c^2, in meters, for a mass given in solar masses."""
28
+ return 2 * G * mass_solar * M_SUN / C ** 2
29
+
30
+
31
+ def _f_lower_for_initial_separation(m1: float, m2: float, separation_km: float) -> float:
32
+ """Convert initial separation (km) to starting GW frequency (Hz)
33
+ via Newtonian Kepler's third law (f_gw = 2 * f_orb)."""
34
+ total_mass_kg = (m1 + m2) * M_SUN
35
+ r_m = separation_km * 1000.0
36
+ omega_orb = np.sqrt(G * total_mass_kg / r_m ** 3)
37
+ return omega_orb / np.pi
38
+
39
+
40
+ def _make_sphere(name: str, radius: float):
41
+ mesh = bpy.data.meshes.new(name + "_mesh")
42
+ bm = bmesh.new()
43
+ bmesh.ops.create_uvsphere(bm, u_segments=32, v_segments=16, radius=radius)
44
+ bm.to_mesh(mesh)
45
+ bm.free()
46
+ obj = bpy.data.objects.new(name, mesh)
47
+ bpy.context.collection.objects.link(obj)
48
+ return obj
49
+
50
+
51
+ def _show_motion_path(obj, frame_start: int, frame_end: int):
52
+ obj.select_set(True)
53
+ bpy.context.view_layer.objects.active = obj
54
+ bpy.ops.object.paths_calculate(display_type='RANGE', range='SCENE')
55
+
56
+
57
+ def _set_linear_interpolation(obj):
58
+ """Force LINEAR interpolation on location F-curves. Handles both the
59
+ old action.fcurves API and Blender 4.4+/5.0 layered actions."""
60
+ if not (obj.animation_data and obj.animation_data.action):
61
+ return
62
+ action = obj.animation_data.action
63
+ if hasattr(action, "fcurves"):
64
+ fcurves = list(action.fcurves)
65
+ else:
66
+ fcurves = []
67
+ for layer in action.layers:
68
+ for strip in layer.strips:
69
+ for channelbag in strip.channelbags:
70
+ fcurves.extend(channelbag.fcurves)
71
+ for fcurve in fcurves:
72
+ for kp in fcurve.keyframe_points:
73
+ kp.interpolation = 'LINEAR'
74
+
75
+
76
+ def animate_binary_physical(m1: float = 30.0, m2: float = 25.0,
77
+ merge_frame: int = 200,
78
+ frames: int = None,
79
+ f_lower: float = 40.0,
80
+ initial_separation_km: float = None,
81
+ meters_per_unit: float = 15000.0,
82
+ close_merger: bool = True,
83
+ max_close_frames: float = 30.0,
84
+ show_paths: bool = True):
85
+ """
86
+ Physics-driven binary inspiral from SEOBNRv5HM effective-one-body
87
+ dynamics (pyseobnr).
88
+
89
+ Physics inputs: m1, m2 (solar masses) and f_lower (Hz) OR
90
+ initial_separation_km. These set how many orbits happen before merger.
91
+ The model may start the inspiral earlier than requested for short
92
+ waveforms (it prints a warning); quote the printed start separation.
93
+
94
+ Timing inputs: merge_frame is the Blender frame where the dynamics
95
+ reach their end. frames is the total clip length (default
96
+ merge_frame + 50). Real time is stretched; the slowdown factor is
97
+ printed. If frames < merge_frame, the clip ends before the merger.
98
+
99
+ Separation is the EOB radial coordinate, not a literal horizon
100
+ position. Real data is used until the sum of the Schwarzschild radii
101
+ is reached, or until the data ends. If close_merger is True, any
102
+ remaining gap is closed by continuing the measured rotation rate and
103
+ inward speed (minimum 1 frame, at most max_close_frames, sampled at
104
+ 0.1-frame steps). That closing step is a visual stand-in, not
105
+ physics. The merged state is then held until the end of the clip.
106
+ If close_merger is False, the bodies hold at their last real position.
107
+
108
+ Sphere sizes are real Schwarzschild radii, converted with the same
109
+ meters_per_unit as the orbit, so relative proportions stay physical.
110
+
111
+ Parameters
112
+ ----------
113
+ m1, m2 : float
114
+ Masses, in solar masses (M_sun).
115
+ merge_frame : int
116
+ Frame at which the dynamics end (merger).
117
+ frames : int, optional
118
+ Total animation length. Defaults to merge_frame + 50.
119
+ f_lower : float
120
+ Starting GW frequency (Hz). Higher = fewer orbits, easier to read.
121
+ Ignored if initial_separation_km is given.
122
+ initial_separation_km : float, optional
123
+ Starting separation (km), converted to f_lower via Kepler's law.
124
+ meters_per_unit : float
125
+ Real meters per Blender unit (applies to orbit and body size).
126
+ close_merger : bool
127
+ Schematically close any remaining gap into one merged object.
128
+ max_close_frames : float
129
+ Longest the schematic closing step may take, in frames.
130
+ show_paths : bool
131
+ Show motion path trails.
132
+ """
133
+ if bpy is None:
134
+ raise RuntimeError("animate_binary_physical() must be called from inside Blender.")
135
+
136
+ from . import physics
137
+
138
+ if frames is None:
139
+ frames = merge_frame + 50
140
+ merge_frame = max(int(merge_frame), 2)
141
+
142
+ if initial_separation_km is not None:
143
+ f_lower = _f_lower_for_initial_separation(m1, m2, initial_separation_km)
144
+ print(f"[gwbpy] initial_separation_km={initial_separation_km} -> "
145
+ f"f_lower={f_lower:.3f} Hz (Kepler approximation)")
146
+ # pyseobnr never starts closer than a Newtonian separation of 10 M
147
+ # (orbital omega = 10**-1.5 in units of 1/M). Clamp here so the user
148
+ # sees the limit explicitly instead of the model changing it silently.
149
+ t_unit = G * (m1 + m2) * M_SUN / C ** 3
150
+ f_max_start = 10 ** -1.5 / (np.pi * t_unit)
151
+ if f_lower > f_max_start:
152
+ print(f"[gwbpy] NOTE: the SEOBNRv5HM model cannot start closer than "
153
+ f"a separation of 10 M ({10 * t_unit * C / 1000:.0f} km for "
154
+ f"M = {m1 + m2:g} Msun), which is a GW start frequency of "
155
+ f"about {f_max_start:.1f} Hz. You asked for {f_lower:.1f} Hz, "
156
+ f"so the inspiral starts at {f_max_start:.1f} Hz instead "
157
+ f"(about 5 orbits before merger). The model cannot show fewer orbits, "
158
+ f"a larger merge_frame only slows the rotation on screen. ")
159
+
160
+ f_lower = f_max_start * (1 - 1e-9)
161
+ print("[gwbpy] Requesting EOB dynamics from physics worker...")
162
+ data = physics.generate_dynamics(m1=m1, m2=m2, f_lower=f_lower)
163
+ print(f"[gwbpy] Requested start {f_lower:.1f} Hz; model actually started at "
164
+ f"about {float(data['f_start_hz']):.1f} Hz.")
165
+ merger_idx = int(data["merger_idx"])
166
+ times = data["times"][:merger_idx + 1]
167
+ radius_units = data["radius_m"][:merger_idx + 1] / meters_per_unit
168
+ orbital_angle = data["orbital_angle"][:merger_idx + 1]
169
+
170
+ size1 = _schwarzschild_radius_m(m1) / meters_per_unit
171
+ size2 = _schwarzschild_radius_m(m2) / meters_per_unit
172
+ touch_distance = size1 + size2
173
+
174
+ # Fixed time mapping: real start -> frame 1, real end -> merge_frame.
175
+ t0, t1 = times[0], times[-1]
176
+ real_duration = float(t1 - t0)
177
+ fps = bpy.context.scene.render.fps / bpy.context.scene.render.fps_base
178
+ slowdown = (merge_frame / fps) / real_duration
179
+ print(f"[gwbpy] Real inspiral duration: {real_duration:.4f} s. "
180
+ f"On screen: {merge_frame / fps:.2f} s at {fps:.0f} fps "
181
+ f"({slowdown:.0f}x slower than real time).")
182
+ frame_floats = 1 + (times - t0) / (t1 - t0) * (merge_frame - 1)
183
+ k = min(5, len(times) - 1)
184
+ if k > 0 and frame_floats[k] > frame_floats[0]:
185
+ rate = (orbital_angle[k] - orbital_angle[0]) / (frame_floats[k] - frame_floats[0])
186
+ if rate > 0:
187
+ print(f"[gwbpy] At the start, one orbit takes about "
188
+ f"{2 * np.pi / rate:.0f} frames.")
189
+ # Stop real data at the first sample where the horizons touch.
190
+ below = np.where(radius_units <= touch_distance)[0]
191
+ touched = len(below) > 0
192
+ if touched:
193
+ end_i = int(below[0])
194
+ frame_floats = frame_floats[:end_i + 1]
195
+ radius_units = radius_units[:end_i + 1]
196
+ orbital_angle = orbital_angle[:end_i + 1]
197
+ times = times[:end_i + 1]
198
+ print(f"[gwbpy] Horizons touch at frame {frame_floats[-1]:.1f}.")
199
+ n_real = len(times)
200
+
201
+ # Aliasing check: how far do the bodies turn between whole frames?
202
+ last_whole = max(int(frame_floats[-1]), 2)
203
+ int_frames = np.arange(1, last_whole + 1)
204
+ angle_at_frames = np.interp(int_frames, frame_floats, orbital_angle)
205
+ turn_per_frame = np.abs(np.diff(angle_at_frames))
206
+ n_bad = int(np.sum(turn_per_frame > np.pi))
207
+ if n_bad > 0:
208
+ print(f"[gwbpy] WARNING: in {n_bad} frames the bodies rotate more than "
209
+ f"half a turn per frame "
210
+ f"(max {turn_per_frame.max() / (2 * np.pi):.2f} turns/frame), "
211
+ f"so motion will look aliased/chaotic. Try a higher f_lower, "
212
+ f"a smaller initial_separation_km, or a larger merge_frame.")
213
+
214
+ # Keep samples inside the clip, thinning the dense ones.
215
+ inside = np.where(frame_floats <= frames)[0]
216
+ if len(inside) == 0:
217
+ raise RuntimeError("No samples fall inside the requested frame range.")
218
+ if frames < merge_frame:
219
+ print(f"[gwbpy] NOTE: frames ({frames}) < merge_frame ({merge_frame}); "
220
+ f"the clip may end before the merger.")
221
+
222
+ keep = [inside[0]]
223
+ last_f = frame_floats[inside[0]]
224
+ for i in inside[1:]:
225
+ if frame_floats[i] - last_f >= 0.1 or i == inside[-1]:
226
+ keep.append(i)
227
+ last_f = frame_floats[i]
228
+
229
+ body1 = _make_sphere("gwbpy_body1", size1)
230
+ body2 = _make_sphere("gwbpy_body2", size2)
231
+ bpy.context.scene.frame_start = 1
232
+ bpy.context.scene.frame_end = frames
233
+
234
+ frac1 = m2 / (m1 + m2)
235
+ frac2 = m1 / (m1 + m2)
236
+
237
+ def key(frame_val, r, a):
238
+ body1.location = (frac1 * r * np.cos(a), frac1 * r * np.sin(a), 0)
239
+ body1.keyframe_insert(data_path="location", frame=float(frame_val))
240
+ body2.location = (-frac2 * r * np.cos(a), -frac2 * r * np.sin(a), 0)
241
+ body2.keyframe_insert(data_path="location", frame=float(frame_val))
242
+
243
+ for i in keep:
244
+ key(frame_floats[i], radius_units[i], orbital_angle[i])
245
+
246
+ closing_frames = 0.0
247
+ reached_end = keep[-1] == n_real - 1
248
+
249
+ if reached_end and frames > frame_floats[-1]:
250
+ f_end = float(frame_floats[-1])
251
+ r_last = float(radius_units[-1])
252
+ a_last = float(orbital_angle[-1])
253
+
254
+ if close_merger and r_last > 0:
255
+ # Measure rates over (at least) the last whole frame of data.
256
+ j = int(np.searchsorted(frame_floats, f_end - 1.0))
257
+ j = min(j, n_real - 2) if n_real >= 2 else 0
258
+ df = f_end - float(frame_floats[j])
259
+ if n_real >= 2 and df > 0:
260
+ omega = (a_last - float(orbital_angle[j])) / df
261
+ rdot = (r_last - float(radius_units[j])) / df
262
+ else:
263
+ omega, rdot = 0.0, -r_last
264
+ if rdot > -1e-12:
265
+ rdot = -r_last # not closing; fall back to 1 frame
266
+
267
+ duration = min(max(r_last / abs(rdot), 1.0), max_close_frames)
268
+ n_steps = max(int(round(duration / 0.1)), 1)
269
+ last_key_frame = f_end
270
+ for s_i in range(1, n_steps + 1):
271
+ s = duration * s_i / n_steps
272
+ if f_end + s > frames:
273
+ break
274
+ key(f_end + s, r_last * (1 - s / duration), a_last + omega * s)
275
+ last_key_frame = f_end + s
276
+ closing_frames = last_key_frame - f_end
277
+ reached_zero = closing_frames >= duration - 1e-9
278
+ hold_r = 0.0 if reached_zero else None
279
+ hold_a = a_last + omega * duration
280
+ else:
281
+ hold_r, hold_a = r_last, a_last
282
+ last_key_frame = f_end
283
+
284
+ # Hold the final state until the end of the clip.
285
+ if hold_r is not None and frames > last_key_frame:
286
+ key(frames, hold_r, hold_a)
287
+
288
+ _set_linear_interpolation(body1)
289
+ _set_linear_interpolation(body2)
290
+ bpy.context.scene.frame_set(1)
291
+
292
+ if show_paths:
293
+ _show_motion_path(body1, 1, frames)
294
+ _show_motion_path(body2, 1, frames)
295
+
296
+ print(f"[gwbpy] animate_binary_physical complete: {len(keep)} real keyframes, "
297
+ f"merger mapped to frame {merge_frame}, clip length {frames}, "
298
+ f"schematic closing step: {closing_frames:.1f} frames, "
299
+ f"r_sch = {size1:.3e}/{size2:.3e} units")
@@ -0,0 +1,133 @@
1
+ """
2
+ gwbpy.physics
3
+
4
+ Bridges Blender's Python to the separate physics environment (pyseobnr on
5
+ Python 3.11), located at ~/.gwbpy/dynamics-env.
6
+
7
+ On Linux/macOS the environment's Python is called directly. On Windows,
8
+ Blender runs natively while the environment lives inside WSL, so calls go
9
+ through wsl.exe, using the default WSL distribution (override with the
10
+ GWBPY_WSL_DISTRO environment variable).
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import numpy as np
19
+
20
+ WORKER_DIR = os.path.join(os.path.dirname(__file__), "workers")
21
+ IS_WINDOWS = sys.platform == "win32"
22
+
23
+ ENV_PYTHON_REL = ".gwbpy/dynamics-env/bin/python"
24
+
25
+ _python_cache = None
26
+
27
+
28
+ def _wsl_prefix() -> list:
29
+ distro = os.environ.get("GWBPY_WSL_DISTRO")
30
+ return ["wsl.exe", "-d", distro] if distro else ["wsl.exe"]
31
+
32
+
33
+ def _to_wsl_path(path: str) -> str:
34
+ """Convert a Windows path (drive letter or \\\\wsl$ share) to the path
35
+ WSL itself sees. Returns the input unchanged if it isn't a Windows path."""
36
+ m = re.match(r"^\\\\wsl(?:\$|\.localhost)\\[^\\]+\\(.*)$", path)
37
+ if m:
38
+ return "/" + m.group(1).replace("\\", "/")
39
+ m = re.match(r"^([A-Za-z]):\\(.*)$", path)
40
+ if m:
41
+ return f"/mnt/{m.group(1).lower()}/" + m.group(2).replace("\\", "/")
42
+ return path
43
+
44
+
45
+ def _find_env_python() -> str:
46
+ """Locate the physics environment's Python (inside WSL on Windows)."""
47
+ global _python_cache
48
+ if _python_cache:
49
+ return _python_cache
50
+
51
+ if IS_WINDOWS:
52
+ res = subprocess.run(
53
+ _wsl_prefix() + ["-e", "sh", "-c", "echo $HOME"],
54
+ capture_output=True, text=True, encoding="utf-8",
55
+ errors="replace", timeout=120,
56
+ )
57
+ home = res.stdout.strip()
58
+ if res.returncode != 0 or not home.startswith("/"):
59
+ raise RuntimeError(
60
+ "Could not reach WSL. Install WSL with a Linux distribution, "
61
+ "or set GWBPY_WSL_DISTRO to the distribution that holds the "
62
+ "gwbpy physics environment."
63
+ )
64
+ python = f"{home}/{ENV_PYTHON_REL}"
65
+ ok = subprocess.run(
66
+ _wsl_prefix() + ["-e", "test", "-x", python],
67
+ capture_output=True, timeout=120,
68
+ ).returncode == 0
69
+ else:
70
+ python = os.path.expanduser(f"~/{ENV_PYTHON_REL}")
71
+ ok = os.path.exists(python)
72
+
73
+ if not ok:
74
+ raise RuntimeError(
75
+ f"gwbpy physics environment not found at {python}. "
76
+ "Run gwbpy.setup() first."
77
+ )
78
+
79
+ print(f"[gwbpy] Using physics environment: {python}")
80
+ _python_cache = python
81
+ return python
82
+
83
+
84
+ def _run_worker(worker_name: str, args: list) -> None:
85
+ worker_path = os.path.join(WORKER_DIR, worker_name)
86
+ if not os.path.exists(worker_path):
87
+ raise RuntimeError(f"Worker script not found: {worker_path}")
88
+
89
+ python = _find_env_python()
90
+
91
+ if IS_WINDOWS:
92
+ cmd = _wsl_prefix() + ["-e", python, _to_wsl_path(worker_path),
93
+ *[_to_wsl_path(a) for a in args]]
94
+ else:
95
+ cmd = [python, worker_path, *args]
96
+
97
+ print(f"[gwbpy] Running physics worker: {worker_name}")
98
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
99
+ text=True, bufsize=1)
100
+ if process.stdout:
101
+ for line in process.stdout:
102
+ print(f" {line.rstrip()}")
103
+ process.wait()
104
+
105
+ if process.returncode != 0:
106
+ raise RuntimeError(
107
+ f"Physics worker '{worker_name}' failed (exit code {process.returncode})."
108
+ )
109
+
110
+
111
+ def _load_npz(out_path: str) -> dict:
112
+ data = np.load(out_path)
113
+ result = {key: data[key] for key in data.files}
114
+ data.close() # Windows is strict about open handles before tempdir cleanup
115
+ return result
116
+
117
+
118
+ def generate_dynamics(m1: float, m2: float, f_lower: float = 20.0,
119
+ chi1: float = 0.0, chi2: float = 0.0) -> dict:
120
+ """
121
+ EOB dynamics (SEOBNRv5HM via pyseobnr): separation and orbital phase
122
+ vs time. Returns times (s), radius_m, orbital_angle, merger_idx,
123
+ f_start_hz. m1, m2 in solar masses; chi1, chi2 are aligned
124
+ dimensionless spins.
125
+ """
126
+ with tempfile.TemporaryDirectory() as tmpdir:
127
+ out_path = os.path.join(tmpdir, "dynamics_result.npz")
128
+ _run_worker(
129
+ "dynamics_worker.py",
130
+ ["--m1", str(m1), "--m2", str(m2), "--f-lower", str(f_lower),
131
+ "--chi1", str(chi1), "--chi2", str(chi2), "--out", out_path],
132
+ )
133
+ return _load_npz(out_path)
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bash
2
+ # gwbpy: create the physics environment at ~/.gwbpy/dynamics-env
3
+ # (pyseobnr + lalsuite on Python 3.11).
4
+ # Run directly or via gwbpy.setup().
5
+ # usage: install_env.sh [--force]
6
+ # Exit codes: 0 = ready, 1 = failed, 2 = system packages missing.
7
+
8
+ ENV_DIR="$HOME/.gwbpy/dynamics-env"
9
+ BOOT_DIR="$HOME/.gwbpy/bootstrap"
10
+ APT_LINE="sudo apt update && sudo apt install -y python3-venv libgsl-dev swig build-essential libfftw3-dev libhdf5-dev"
11
+
12
+ echo "[gwbpy] Checking system requirements..."
13
+ missing=""
14
+ command -v python3 >/dev/null 2>&1 || missing="$missing python3"
15
+ python3 -c "import ensurepip, venv" >/dev/null 2>&1 || missing="$missing python3-venv"
16
+ command -v gsl-config >/dev/null 2>&1 || missing="$missing libgsl-dev"
17
+ command -v swig >/dev/null 2>&1 || missing="$missing swig"
18
+ command -v gcc >/dev/null 2>&1 || missing="$missing build-essential"
19
+
20
+ if [ -n "$missing" ]; then
21
+ echo "[gwbpy] Missing system packages:$missing"
22
+ echo "[gwbpy] Run this once in a Linux/WSL terminal, then call setup() again:"
23
+ echo " $APT_LINE"
24
+ exit 2
25
+ fi
26
+
27
+ if [ "${1:-}" = "--force" ]; then
28
+ echo "[gwbpy] --force: removing existing environment"
29
+ rm -rf "$ENV_DIR"
30
+ fi
31
+
32
+ if [ -x "$ENV_DIR/bin/python" ] && "$ENV_DIR/bin/python" -c "import pyseobnr" >/dev/null 2>&1; then
33
+ echo "[gwbpy] Found working environment at $ENV_DIR, skipping setup."
34
+ echo "[gwbpy] (use setup(force=True) to rebuild)"
35
+ exit 0
36
+ fi
37
+ rm -rf "$ENV_DIR" # clear out any broken leftovers
38
+
39
+ mkdir -p "$HOME/.gwbpy"
40
+ if [ ! -x "$BOOT_DIR/bin/uv" ]; then
41
+ echo "[gwbpy] Installing the uv helper..."
42
+ python3 -m venv "$BOOT_DIR" || exit 1
43
+ "$BOOT_DIR/bin/pip" install --progress-bar off uv || exit 1
44
+ fi
45
+ UV="$BOOT_DIR/bin/uv"
46
+
47
+ echo "[gwbpy] Creating Python 3.11 environment (downloads Python if needed)..."
48
+ "$UV" venv "$ENV_DIR" --python 3.11 || exit 1
49
+ PY="$ENV_DIR/bin/python"
50
+
51
+ echo "[gwbpy] Installing packages (first time takes about 10 minutes)..."
52
+ # pygsl-lite (a pyseobnr dependency) has an old build script that breaks
53
+ # under the newest setuptools. Build it without isolation, using the older
54
+ # setuptools installed here, plus setuptools-scm for its version number.
55
+ "$UV" pip install --python "$PY" "setuptools<70" "setuptools-scm[toml]>=3.4.3" wheel numpy || exit 1
56
+ "$UV" pip install --python "$PY" --no-build-isolation-package pygsl-lite pyseobnr lalsuite || exit 1
57
+
58
+ echo "[gwbpy] Verifying..."
59
+ "$PY" -c "from pyseobnr.generate_waveform import generate_modes_opt" || exit 1
60
+ echo "[gwbpy] Setup complete: $PY"
@@ -0,0 +1,81 @@
1
+ """
2
+ gwbpy.setup_env
3
+
4
+ One-time setup of the physics environment at ~/.gwbpy/dynamics-env
5
+ (pyseobnr + lalsuite on Python 3.11). The work is done by
6
+ scripts/install_env.sh, which runs on Linux or inside WSL. On Windows,
7
+ Blender runs natively, so the script is run through wsl.exe.
8
+
9
+ Supported platforms: Ubuntu, and Windows with WSL (Ubuntu). Other
10
+ platforms are untested.
11
+ """
12
+
13
+ import os
14
+ import subprocess
15
+ import sys
16
+
17
+ from .physics import _wsl_prefix, _to_wsl_path
18
+
19
+ SCRIPT = os.path.join(os.path.dirname(__file__), "scripts", "install_env.sh")
20
+
21
+
22
+ def setup(force: bool = False) -> bool:
23
+ """
24
+ Create the physics environment (pyseobnr + lalsuite on Python 3.11).
25
+ Run once from Blender's scripting tab:
26
+
27
+ import gwbpy
28
+ gwbpy.setup()
29
+
30
+ Takes several minutes the first time, and Blender may look frozen
31
+ meanwhile (Window > Toggle System Console shows the progress).
32
+ If system libraries are missing, it prints the exact apt command to
33
+ run once in a Linux/WSL terminal.
34
+
35
+ Parameters
36
+ ----------
37
+ force : bool
38
+ Delete and rebuild the environment even if it already works.
39
+
40
+ Returns
41
+ -------
42
+ bool
43
+ True if the environment is ready.
44
+ """
45
+ if not os.path.exists(SCRIPT):
46
+ print(f"[gwbpy] ERROR: setup script not found at {SCRIPT}")
47
+ return False
48
+
49
+ extra = ["--force"] if force else []
50
+ if sys.platform == "win32":
51
+ cmd = _wsl_prefix() + ["-e", "bash", _to_wsl_path(SCRIPT), *extra]
52
+ else:
53
+ cmd = ["bash", SCRIPT, *extra]
54
+
55
+ print("[gwbpy] Starting setup...")
56
+ try:
57
+ process = subprocess.Popen(
58
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
59
+ text=True, encoding="utf-8", errors="replace", bufsize=1,
60
+ )
61
+ except FileNotFoundError:
62
+ print("[gwbpy] ERROR: could not start the setup command "
63
+ "(is WSL installed? On Windows, run 'wsl --install' in PowerShell).")
64
+ return False
65
+
66
+ if process.stdout:
67
+ for line in process.stdout:
68
+ print(f" {line.rstrip()}")
69
+ process.wait()
70
+
71
+ if process.returncode == 0:
72
+ print("[gwbpy] Setup finished. You can now call animate_binary_physical().")
73
+ return True
74
+ if process.returncode == 2:
75
+ print("[gwbpy] Setup stopped: install the system packages listed above, "
76
+ "then run gwbpy.setup() again.")
77
+ else:
78
+ print(f"[gwbpy] ERROR: setup failed (exit code {process.returncode}). "
79
+ "See the output above. If there is no output at all on Windows, "
80
+ "WSL may not be installed or set up.")
81
+ return False
@@ -0,0 +1,100 @@
1
+ """
2
+ gwbpy.workers.dynamics_worker
3
+
4
+ Runs inside the pyseobnr environment. Generates the SEOBNRv5HM effective-
5
+ one-body DYNAMICS (separation and orbital phase vs time) and saves it as
6
+ an .npz file.
7
+
8
+ The raw integrator output is unevenly spaced, so it is resampled to one
9
+ point per 0.1 rad of orbital phase. Otherwise Blender draws each orbit as
10
+ a coarse polygon.
11
+
12
+ Note: r is the EOB radial coordinate, not a literal horizon position.
13
+ """
14
+
15
+ import argparse
16
+ import numpy as np
17
+ from scipy.interpolate import CubicSpline
18
+ from pyseobnr.generate_waveform import generate_modes_opt
19
+
20
+ G = 6.67430e-11
21
+ C = 2.99792458e8
22
+ M_SUN = 1.98892e30
23
+
24
+ PHASE_STEP = 0.1 # radians of orbital phase between output samples
25
+
26
+
27
+ def main():
28
+ p = argparse.ArgumentParser()
29
+ p.add_argument("--m1", type=float, required=True)
30
+ p.add_argument("--m2", type=float, required=True)
31
+ p.add_argument("--chi1", type=float, default=0.0)
32
+ p.add_argument("--chi2", type=float, default=0.0)
33
+ p.add_argument("--f-lower", type=float, default=20.0)
34
+ p.add_argument("--out", type=str, required=True)
35
+ a = p.parse_args()
36
+
37
+ # pyseobnr wants q >= 1 with the heavier body first.
38
+ m1, m2, chi1, chi2 = a.m1, a.m2, a.chi1, a.chi2
39
+ if m2 > m1:
40
+ m1, m2, chi1, chi2 = m2, m1, chi2, chi1
41
+ q = m1 / m2
42
+ total = m1 + m2
43
+
44
+ t_unit = G * total * M_SUN / C ** 3 # seconds per unit of M
45
+ r_unit = G * total * M_SUN / C ** 2 # meters per unit of M
46
+
47
+ # f_lower is the requested starting GW frequency; omega0 is the
48
+ # orbital frequency in geometric units (GW freq = 2 x orbital freq).
49
+ omega0 = np.pi * a.f_lower * t_unit
50
+
51
+ print(f"[worker] q={q:.3f}, M={total} Msun, omega0={omega0:.5f}")
52
+ _, _, model = generate_modes_opt(q, chi1, chi2, omega0, debug=True)
53
+ t, r, phi, pr, pphi, H, Omega, _ = model.dynamics.T
54
+
55
+ # Starting GW frequency actually used, from the phase's rate of
56
+ # change over the first few samples (f_gw = 2 * f_orb).
57
+ n0 = 1
58
+ f_start_hz = float((phi[n0] - phi[0]) / (t[n0] - t[0]) / (np.pi * t_unit))
59
+ print(f"[worker] requested start: {a.f_lower:.1f} Hz, "
60
+ f"model started at about {f_start_hz:.1f} Hz")
61
+
62
+ dphi_raw = np.diff(phi)
63
+ print(f"[worker] raw samples: {len(t)}, phase step per sample: "
64
+ f"max {dphi_raw.max():.2f} rad, median {np.median(dphi_raw):.2f} rad")
65
+
66
+ # Keep only strictly increasing phase so it can be inverted.
67
+ keep = np.concatenate(([True], np.diff(phi) > 0))
68
+ t, r, phi = t[keep], r[keep], phi[keep]
69
+
70
+ # Resample at uniform steps in orbital phase.
71
+ phi_grid = np.arange(phi[0], phi[-1], PHASE_STEP)
72
+ phi_grid = np.append(phi_grid, phi[-1])
73
+ t_grid = np.interp(phi_grid, phi, t)
74
+ r_grid = CubicSpline(t, r)(t_grid)
75
+
76
+ times = t_grid * t_unit
77
+ radius_m = r_grid * r_unit
78
+ merger_idx = len(times) - 1
79
+
80
+ np.savez(
81
+ a.out,
82
+ times=times,
83
+ radius_m=radius_m,
84
+ orbital_angle=phi_grid,
85
+ merger_time=times[merger_idx],
86
+ merger_idx=merger_idx,
87
+ f_start_hz=f_start_hz,
88
+ m1=a.m1,
89
+ m2=a.m2,
90
+ )
91
+
92
+ print(f"[worker] resampled to {len(times)} samples, "
93
+ f"{(phi[-1] - phi[0]) / (2 * np.pi):.1f} orbits")
94
+ print(f"[worker] separation start/end: {radius_m[0]/1000:.0f} km / {radius_m[-1]/1000:.0f} km")
95
+ print(f"[worker] horizons touch at: {2*r_unit/1000:.0f} km")
96
+ print(f"[worker] Saved to {a.out}")
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: gwbpy
3
+ Version: 0.1.0a1
4
+ Summary: Gravitational-wave visualizations for Blender (alpha)
5
+ Author: Ellis Eisenberg
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Science/Research
8
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
9
+ Classifier: Topic :: Scientific/Engineering :: Physics
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: numpy
13
+
14
+ # gwbpy
15
+
16
+ Gravitational-wave visualization for Blender.
17
+
18
+ **Status: alpha (v0.1.0). Not on PyPI yet. Expect rough edges.**
19
+
20
+ Currently one function, `animate_binary_physical()`: two black holes spiral
21
+ together and merge, driven by SEOBNRv5HM effective-one-body dynamics.
22
+
23
+ ## Tested on
24
+
25
+ - Blender 5.0 on Windows, with Ubuntu 24.04 under WSL2
26
+ - The setup script on Ubuntu 24.04
27
+
28
+ Not tested: macOS, other Linux distributions, Windows without WSL.
29
+
30
+ ## How it works
31
+
32
+ Blender's Python cannot run the physics libraries, so gwbpy builds a
33
+ separate Python 3.11 environment at `~/.gwbpy/dynamics-env` (inside WSL on
34
+ Windows) containing pyseobnr and lalsuite. Animation calls run a small worker
35
+ in that environment, read the result back, and keyframe it in Blender.
36
+
37
+ ## Install
38
+
39
+ 1. Install system packages once, in a Linux or WSL terminal:
40
+
41
+ sudo apt update && sudo apt install -y python3-venv libgsl-dev swig build-essential libfftw3-dev libhdf5-dev
42
+
43
+ 2. Make gwbpy importable in Blender. Until it is on PyPI, clone this
44
+ repository and, in Blender's Scripting tab, run (with the folder that
45
+ contains `gwbpy/`):
46
+
47
+ import sys
48
+ sys.path.insert(0, r"PATH_TO_FOLDER_CONTAINING_gwbpy")
49
+ import gwbpy
50
+
51
+ 3. Build the physics environment, once:
52
+
53
+ gwbpy.setup()
54
+
55
+ The first run builds from source and takes about 10 minutes. Blender may
56
+ look frozen meanwhile. Window > Toggle System Console shows progress. If
57
+ system packages are missing, setup stops and prints the exact command to
58
+ run. Use `gwbpy.setup(force=True)` to rebuild.
59
+
60
+ ## Example
61
+
62
+ gwbpy.animate_binary_physical(m1=30, m2=15, merge_frame=100, frames=200,
63
+ f_lower=80.0, meters_per_unit=20000.0)
64
+
65
+ ## Main parameters
66
+
67
+ | Parameter | Meaning |
68
+ |---|---|
69
+ | `m1`, `m2` | Masses in solar masses |
70
+ | `f_lower` | Starting GW frequency in Hz. Higher means fewer orbits |
71
+ | `initial_separation_km` | Alternative to `f_lower` (converted with Kepler's law) |
72
+ | `merge_frame` | Frame at which the dynamics end (merger) |
73
+ | `frames` | Total clip length (default `merge_frame + 50`) |
74
+ | `meters_per_unit` | Real meters per Blender unit; scales orbit and spheres together |
75
+ | `close_merger` | Schematically close any remaining gap (default True) |
76
+ | `show_paths` | Show motion-path trails |
77
+
78
+ The console reports the real inspiral duration, the slowdown factor, the
79
+ number of frames per orbit at the start, and how long any schematic closing
80
+ step is. Read these before putting numbers on a slide.
81
+
82
+ ## What is physics and what is not
83
+
84
+ - Orbital separation and phase come from the SEOBNRv5HM effective-one-body
85
+ dynamics. Separation is the EOB radial coordinate, not a literal horizon
86
+ position.
87
+ - Sphere sizes are the Schwarzschild radii of the two masses. The bodies are
88
+ non-spinning and do not deform.
89
+ - Each body sits at its Newtonian center-of-mass fraction of the separation.
90
+ - Time is stretched. The slowdown factor is printed.
91
+ - If the data ends slightly short of the horizons touching, a short schematic
92
+ closing step finishes the merger. It is a visual stand-in, not physics.
93
+ `close_merger=False` removes it. The ringdown is not modeled.
94
+ - Non-spinning only for now.
95
+
96
+ ## Known limits
97
+
98
+ - The model never starts closer than a separation of 10 M. A requested start
99
+ frequency above about 2044 / (m1 + m2) Hz (masses in solar masses) is
100
+ lowered to that value, and a note is printed. Every run therefore has at
101
+ least about 5 orbits.
102
+ - Frames per orbit matters. If the console warns about more than half a turn
103
+ per frame, the motion will look aliased. Raise `merge_frame`, or reduce the
104
+ number of orbits with a higher `f_lower`.
105
+
106
+ ## Citing
107
+
108
+ pyseobnr asks that results made with it acknowledge the software and the
109
+ model papers:
110
+
111
+ - Mihaylov et al., "pySEOBNR: a software package for the next generation of
112
+ effective-one-body multipolar waveform models", arXiv:2303.18203
113
+ - Pompili et al., "Laying the foundation of the effective-one-body waveform
114
+ models SEOBNRv5", arXiv:2303.18039
115
+
116
+ (Citations copied from the pyseobnr PyPI page. Check them before use.)
117
+
118
+ ## License
119
+
120
+ To be decided. pyseobnr is GPL-3.0-or-later.
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ gwbpy/__init__.py
4
+ gwbpy/animate.py
5
+ gwbpy/physics.py
6
+ gwbpy/setup_env.py
7
+ gwbpy.egg-info/PKG-INFO
8
+ gwbpy.egg-info/SOURCES.txt
9
+ gwbpy.egg-info/dependency_links.txt
10
+ gwbpy.egg-info/requires.txt
11
+ gwbpy.egg-info/top_level.txt
12
+ gwbpy/scripts/install_env.sh
13
+ gwbpy/workers/dynamics_worker.py
@@ -0,0 +1 @@
1
+ numpy
@@ -0,0 +1 @@
1
+ gwbpy
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gwbpy"
7
+ version = "0.1.0a1"
8
+ description = "Gravitational-wave visualizations for Blender (alpha)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = ["numpy"]
12
+ authors = [{name = "Ellis Eisenberg"}]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Science/Research",
16
+ "Topic :: Scientific/Engineering :: Astronomy",
17
+ "Topic :: Scientific/Engineering :: Physics",
18
+ ]
19
+
20
+ [tool.setuptools.packages.find]
21
+ include = ["gwbpy*"]
22
+
23
+ [tool.setuptools.package-data]
24
+ gwbpy = ["scripts/*.sh", "workers/*.py"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+