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 +120 -0
- gwbpy-0.1.0a1/README.md +107 -0
- gwbpy-0.1.0a1/gwbpy/__init__.py +5 -0
- gwbpy-0.1.0a1/gwbpy/animate.py +299 -0
- gwbpy-0.1.0a1/gwbpy/physics.py +133 -0
- gwbpy-0.1.0a1/gwbpy/scripts/install_env.sh +60 -0
- gwbpy-0.1.0a1/gwbpy/setup_env.py +81 -0
- gwbpy-0.1.0a1/gwbpy/workers/dynamics_worker.py +100 -0
- gwbpy-0.1.0a1/gwbpy.egg-info/PKG-INFO +120 -0
- gwbpy-0.1.0a1/gwbpy.egg-info/SOURCES.txt +13 -0
- gwbpy-0.1.0a1/gwbpy.egg-info/dependency_links.txt +1 -0
- gwbpy-0.1.0a1/gwbpy.egg-info/requires.txt +1 -0
- gwbpy-0.1.0a1/gwbpy.egg-info/top_level.txt +1 -0
- gwbpy-0.1.0a1/pyproject.toml +24 -0
- gwbpy-0.1.0a1/setup.cfg +4 -0
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.
|
gwbpy-0.1.0a1/README.md
ADDED
|
@@ -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,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
|
+
|
|
@@ -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"]
|
gwbpy-0.1.0a1/setup.cfg
ADDED