pylgframework 0.1.0__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.
- pylgframework-0.1.0/LICENSE +21 -0
- pylgframework-0.1.0/PKG-INFO +108 -0
- pylgframework-0.1.0/README.md +75 -0
- pylgframework-0.1.0/pyproject.toml +39 -0
- pylgframework-0.1.0/setup.cfg +4 -0
- pylgframework-0.1.0/src/pylgframework/__init__.py +8 -0
- pylgframework-0.1.0/src/pylgframework/barycentre.py +169 -0
- pylgframework-0.1.0/src/pylgframework/compare.py +37 -0
- pylgframework-0.1.0/src/pylgframework/constants.py +34 -0
- pylgframework-0.1.0/src/pylgframework/fiducials.py +114 -0
- pylgframework-0.1.0/src/pylgframework/frames.py +43 -0
- pylgframework-0.1.0/src/pylgframework/rebound_adapter.py +39 -0
- pylgframework-0.1.0/src/pylgframework/sampling.py +46 -0
- pylgframework-0.1.0/src/pylgframework.egg-info/PKG-INFO +108 -0
- pylgframework-0.1.0/src/pylgframework.egg-info/SOURCES.txt +20 -0
- pylgframework-0.1.0/src/pylgframework.egg-info/dependency_links.txt +1 -0
- pylgframework-0.1.0/src/pylgframework.egg-info/requires.txt +16 -0
- pylgframework-0.1.0/src/pylgframework.egg-info/top_level.txt +1 -0
- pylgframework-0.1.0/tests/test_core.py +68 -0
- pylgframework-0.1.0/tests/test_paper_values.py +52 -0
- pylgframework-0.1.0/tests/test_rebound_adapter.py +24 -0
- pylgframework-0.1.0/tests/test_rebound_simulation.py +88 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Moisés Frutos Plaza
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pylgframework
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local Group barycentre with propagated observational uncertainty (Monte Carlo)
|
|
5
|
+
Author: Moisés Frutos Plaza
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mfrutosp/pylgframework
|
|
8
|
+
Project-URL: Issues, https://github.com/mfrutosp/pylgframework/issues
|
|
9
|
+
Keywords: Local Group,barycentre,Monte Carlo,astropy,REBOUND
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: numpy
|
|
20
|
+
Requires-Dist: pandas
|
|
21
|
+
Requires-Dist: astropy>=7.2
|
|
22
|
+
Provides-Extra: rebound
|
|
23
|
+
Requires-Dist: rebound; extra == "rebound"
|
|
24
|
+
Provides-Extra: analysis
|
|
25
|
+
Requires-Dist: scipy; extra == "analysis"
|
|
26
|
+
Requires-Dist: scikit-learn; extra == "analysis"
|
|
27
|
+
Requires-Dist: diptest; extra == "analysis"
|
|
28
|
+
Requires-Dist: matplotlib; extra == "analysis"
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: pytest; extra == "test"
|
|
31
|
+
Requires-Dist: rebound; extra == "test"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# pylgframework
|
|
35
|
+
|
|
36
|
+
A software framework for the Local Group.
|
|
37
|
+
|
|
38
|
+
The barycentre of the Local Group is routinely adopted as a point estimate. `pylgframework` replaces the
|
|
39
|
+
single-line centre-of-mass calculation with a Monte Carlo that propagates the observational uncertainty of the
|
|
40
|
+
member galaxies, and returns the barycentre together with its uncertainty region.
|
|
41
|
+
|
|
42
|
+
> Frutos Plaza, M. (2026). *Membership Systematics of the Local Group Barycentre: Uncertainty Region, Dependence
|
|
43
|
+
> on Member Galaxies, and Consistency with the CMB Dipole.* (manuscript; data and code at doi:10.5281/zenodo.23440772)
|
|
44
|
+
|
|
45
|
+
## Status
|
|
46
|
+
|
|
47
|
+
[](https://github.com/mfrutosp/pylgframework/actions/workflows/tests.yml)
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install -e . # core: numpy, pandas, astropy
|
|
53
|
+
pip install -e ".[rebound]" # adapter for REBOUND simulations
|
|
54
|
+
pip install -e ".[test]" # development and tests
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from pylgframework import barycentre, compare, fiducials
|
|
61
|
+
|
|
62
|
+
res = barycentre(fiducials.WU, n=1000, seed=2000)
|
|
63
|
+
print(res) # position, velocity, principal axes, CMB-frame speed, assumptions
|
|
64
|
+
|
|
65
|
+
res.median_position # kpc, Galactocentric
|
|
66
|
+
res.axes # 1-sigma principal axes of the barycentre cloud (kpc)
|
|
67
|
+
res.vcmb("4") # speed w.r.t. the CMB (16th, 50th, 84th percentile, km/s)
|
|
68
|
+
res.cloud # DataFrame with every realization
|
|
69
|
+
|
|
70
|
+
# Paired audit of two literature fiducials (identical random draws)
|
|
71
|
+
compare(fiducials.WU, fiducials.VDM2012, n=1000, seed=3000)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Available fiducials: `WU` (Wu et al. 2026), `SAWALA` (Sawala et al. 2025), `VDM2019`, `VDM2012` and
|
|
75
|
+
`VDM2012_3B` (van der Marel et al. 2012, four-body and faithful three-body variants). Each one carries its
|
|
76
|
+
reference and notes (`fiducials.WU.reference`).
|
|
77
|
+
|
|
78
|
+
Memberships: `"2"` (MW+M31), `"3M"` (+M33), `"3L"` (+LMC), `"4"` (all four).
|
|
79
|
+
|
|
80
|
+
## Assumptions
|
|
81
|
+
|
|
82
|
+
* Observables (distance modulus, proper motions, radial velocity, mass) are drawn from Gaussians truncated at
|
|
83
|
+
±2σ (the effective standard deviation is about 0.88σ).
|
|
84
|
+
* Sky positions are held fixed; the MW is at rest at the origin of the Galactocentric frame
|
|
85
|
+
(R0 = 8.122 kpc, v⊙ = (12.9, 245.6, 7.78) km/s).
|
|
86
|
+
* The output is the uncertainty **induced by the declared errors**, not a complete physical uncertainty region.
|
|
87
|
+
* The uncertainty model is fixed across fiducials unless `sigmas=` is supplied.
|
|
88
|
+
|
|
89
|
+
## Reproducibility
|
|
90
|
+
|
|
91
|
+
Realization `i` of an ensemble with seed `s` uses `numpy.random.default_rng([s, i])`. This scheme is part of the
|
|
92
|
+
public interface. The test suite reproduces Table 1 and Sects. 3 and 4 of the manuscript:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pytest # fast tests
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Verified against the published values (Table 1 of the manuscript) with astropy 7.2.0 (Linux, Python 3.12)
|
|
99
|
+
and astropy 8.0.2 (Linux, Python 3.12; Windows, Python 3.11).
|
|
100
|
+
|
|
101
|
+
## References
|
|
102
|
+
|
|
103
|
+
The bibliography of the manuscript, including the sources of every fiducial and of the CMB dipole, is in
|
|
104
|
+
[`docs/references.bib`](docs/references.bib).
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# pylgframework
|
|
2
|
+
|
|
3
|
+
A software framework for the Local Group.
|
|
4
|
+
|
|
5
|
+
The barycentre of the Local Group is routinely adopted as a point estimate. `pylgframework` replaces the
|
|
6
|
+
single-line centre-of-mass calculation with a Monte Carlo that propagates the observational uncertainty of the
|
|
7
|
+
member galaxies, and returns the barycentre together with its uncertainty region.
|
|
8
|
+
|
|
9
|
+
> Frutos Plaza, M. (2026). *Membership Systematics of the Local Group Barycentre: Uncertainty Region, Dependence
|
|
10
|
+
> on Member Galaxies, and Consistency with the CMB Dipole.* (manuscript; data and code at doi:10.5281/zenodo.23440772)
|
|
11
|
+
|
|
12
|
+
## Status
|
|
13
|
+
|
|
14
|
+
[](https://github.com/mfrutosp/pylgframework/actions/workflows/tests.yml)
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install -e . # core: numpy, pandas, astropy
|
|
20
|
+
pip install -e ".[rebound]" # adapter for REBOUND simulations
|
|
21
|
+
pip install -e ".[test]" # development and tests
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from pylgframework import barycentre, compare, fiducials
|
|
28
|
+
|
|
29
|
+
res = barycentre(fiducials.WU, n=1000, seed=2000)
|
|
30
|
+
print(res) # position, velocity, principal axes, CMB-frame speed, assumptions
|
|
31
|
+
|
|
32
|
+
res.median_position # kpc, Galactocentric
|
|
33
|
+
res.axes # 1-sigma principal axes of the barycentre cloud (kpc)
|
|
34
|
+
res.vcmb("4") # speed w.r.t. the CMB (16th, 50th, 84th percentile, km/s)
|
|
35
|
+
res.cloud # DataFrame with every realization
|
|
36
|
+
|
|
37
|
+
# Paired audit of two literature fiducials (identical random draws)
|
|
38
|
+
compare(fiducials.WU, fiducials.VDM2012, n=1000, seed=3000)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Available fiducials: `WU` (Wu et al. 2026), `SAWALA` (Sawala et al. 2025), `VDM2019`, `VDM2012` and
|
|
42
|
+
`VDM2012_3B` (van der Marel et al. 2012, four-body and faithful three-body variants). Each one carries its
|
|
43
|
+
reference and notes (`fiducials.WU.reference`).
|
|
44
|
+
|
|
45
|
+
Memberships: `"2"` (MW+M31), `"3M"` (+M33), `"3L"` (+LMC), `"4"` (all four).
|
|
46
|
+
|
|
47
|
+
## Assumptions
|
|
48
|
+
|
|
49
|
+
* Observables (distance modulus, proper motions, radial velocity, mass) are drawn from Gaussians truncated at
|
|
50
|
+
±2σ (the effective standard deviation is about 0.88σ).
|
|
51
|
+
* Sky positions are held fixed; the MW is at rest at the origin of the Galactocentric frame
|
|
52
|
+
(R0 = 8.122 kpc, v⊙ = (12.9, 245.6, 7.78) km/s).
|
|
53
|
+
* The output is the uncertainty **induced by the declared errors**, not a complete physical uncertainty region.
|
|
54
|
+
* The uncertainty model is fixed across fiducials unless `sigmas=` is supplied.
|
|
55
|
+
|
|
56
|
+
## Reproducibility
|
|
57
|
+
|
|
58
|
+
Realization `i` of an ensemble with seed `s` uses `numpy.random.default_rng([s, i])`. This scheme is part of the
|
|
59
|
+
public interface. The test suite reproduces Table 1 and Sects. 3 and 4 of the manuscript:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pytest # fast tests
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Verified against the published values (Table 1 of the manuscript) with astropy 7.2.0 (Linux, Python 3.12)
|
|
66
|
+
and astropy 8.0.2 (Linux, Python 3.12; Windows, Python 3.11).
|
|
67
|
+
|
|
68
|
+
## References
|
|
69
|
+
|
|
70
|
+
The bibliography of the manuscript, including the sources of every fiducial and of the CMB dipole, is in
|
|
71
|
+
[`docs/references.bib`](docs/references.bib).
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pylgframework"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local Group barycentre with propagated observational uncertainty (Monte Carlo)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Science/Research",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Astronomy",
|
|
18
|
+
]
|
|
19
|
+
license = "MIT"
|
|
20
|
+
license-files = ["LICENSE"]
|
|
21
|
+
authors = [{ name = "Moisés Frutos Plaza" }]
|
|
22
|
+
keywords = ["Local Group", "barycentre", "Monte Carlo", "astropy", "REBOUND"]
|
|
23
|
+
dependencies = ["numpy", "pandas", "astropy>=7.2"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/mfrutosp/pylgframework"
|
|
27
|
+
Issues = "https://github.com/mfrutosp/pylgframework/issues"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
rebound = ["rebound"]
|
|
31
|
+
analysis = ["scipy", "scikit-learn", "diptest", "matplotlib"]
|
|
32
|
+
test = ["pytest", "rebound"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
markers = ["slow: reproduces published values with N = 1000 (seconds to a minute)"]
|
|
39
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""pylgframework: Local Group barycentre with propagated observational uncertainty."""
|
|
2
|
+
from . import fiducials
|
|
3
|
+
from .barycentre import Result, barycentre
|
|
4
|
+
from .compare import Comparison, compare
|
|
5
|
+
from .sampling import DEFAULT_SIGMAS
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
__all__ = ["barycentre", "compare", "fiducials", "Result", "Comparison", "DEFAULT_SIGMAS"]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Barycentre of the Local Group with propagated observational uncertainty."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
import astropy.units as u
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from astropy.coordinates import ICRS, SkyCoord
|
|
9
|
+
|
|
10
|
+
from .constants import KMS, MEMBERSHIPS, NAMES, V_MW_CMB
|
|
11
|
+
from .fiducials import Fiducial, get as _get_fiducial
|
|
12
|
+
from .frames import galactocentric_frame, helio_to_galactocentric
|
|
13
|
+
from .sampling import DEFAULT_SIGMAS, sample_ensemble
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _resolve(source):
|
|
17
|
+
"""-> (name, obs dict, zero_mass tuple)."""
|
|
18
|
+
if isinstance(source, str):
|
|
19
|
+
source = _get_fiducial(source)
|
|
20
|
+
if isinstance(source, Fiducial):
|
|
21
|
+
return source.name, source.obs, source.zero_mass
|
|
22
|
+
if isinstance(source, dict):
|
|
23
|
+
return "custom", source, ()
|
|
24
|
+
raise TypeError("source must be a Fiducial, a fiducial name or an observables dict")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def phase_space(obs_list, zero_mass=()):
|
|
28
|
+
"""Masses (K,4), positions (K,4,3) kpc, velocities (K,4,3) km/s (MW at rest at the origin)."""
|
|
29
|
+
K = len(obs_list)
|
|
30
|
+
m = np.array([[o[nm]["m"] for nm in NAMES] for o in obs_list], float)
|
|
31
|
+
r, v = np.zeros((K, 4, 3)), np.zeros((K, 4, 3))
|
|
32
|
+
for j, nm in enumerate(NAMES):
|
|
33
|
+
if nm == "MilkyWay":
|
|
34
|
+
continue
|
|
35
|
+
g = lambda k: [o[nm][k] for o in obs_list]
|
|
36
|
+
r[:, j], v[:, j] = helio_to_galactocentric(g("ra"), g("dec"), g("mu"),
|
|
37
|
+
g("pmra"), g("pmdec"), g("vlos"))
|
|
38
|
+
for nm in zero_mass:
|
|
39
|
+
m[:, NAMES.index(nm)] = 0.0
|
|
40
|
+
return m, r, v
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bc(m, r, v, idx):
|
|
44
|
+
mm = m[:, idx]
|
|
45
|
+
M = mm.sum(1)[:, None]
|
|
46
|
+
return (mm[..., None] * r[:, idx]).sum(1) / M, (mm[..., None] * v[:, idx]).sum(1) / M
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_cloud(m, r, v, membership="4"):
|
|
50
|
+
"""DataFrame with the barycentre of every realization and the membership diagnostics."""
|
|
51
|
+
idx = MEMBERSHIPS[membership]
|
|
52
|
+
rb, vb = _bc(m, r, v, idx)
|
|
53
|
+
c = SkyCoord(x=rb[:, 0] * u.kpc, y=rb[:, 1] * u.kpc, z=rb[:, 2] * u.kpc,
|
|
54
|
+
frame=galactocentric_frame())
|
|
55
|
+
icrs, gal = c.transform_to(ICRS()), c.transform_to("galactic")
|
|
56
|
+
xyz = icrs.cartesian.xyz.to_value(u.kpc)
|
|
57
|
+
df = pd.DataFrame({
|
|
58
|
+
"bc0_x": rb[:, 0], "bc0_y": rb[:, 1], "bc0_z": rb[:, 2],
|
|
59
|
+
"bc0_vx": vb[:, 0], "bc0_vy": vb[:, 1], "bc0_vz": vb[:, 2],
|
|
60
|
+
"bc0_ra": icrs.ra.deg, "bc0_dec": icrs.dec.deg,
|
|
61
|
+
"bc0_dist": icrs.distance.to_value(u.kpc),
|
|
62
|
+
"bc0_l": gal.l.deg, "bc0_b": gal.b.deg,
|
|
63
|
+
"bc0_icrs_x": xyz[0], "bc0_icrs_y": xyz[1], "bc0_icrs_z": xyz[2]})
|
|
64
|
+
bcs = {t: _bc(m, r, v, i) for t, i in MEMBERSHIPS.items()}
|
|
65
|
+
(r2, v2), (r3, v3), (r4, v4), (r5, v5) = bcs["2"], bcs["3M"], bcs["4"], bcs["3L"]
|
|
66
|
+
n = lambda x: np.linalg.norm(x, axis=1)
|
|
67
|
+
df["shift_M33_kpc"], df["dv_M33_kms"] = n(r3 - r2), n(v3 - v2)
|
|
68
|
+
df["shift_LMC_kpc"], df["dv_LMC_kms"] = n(r5 - r2), n(v5 - v2)
|
|
69
|
+
df["shift_M33_LMC_kpc"], df["dv_M33_LMC_kms"] = n(r4 - r3), n(v4 - v3)
|
|
70
|
+
df["shift_2to4_kpc"], df["dv_2to4_kms"] = n(r4 - r2), n(v4 - v2)
|
|
71
|
+
for T in (5.0, 10.0):
|
|
72
|
+
df[f"drift_2to4_{T:g}Gyr_kpc"] = n((r4 - r2) + (v4 - v2) * KMS * T)
|
|
73
|
+
for tag, (_, vv) in bcs.items():
|
|
74
|
+
df[f"vcmb_{tag}_kms"] = n(vv + V_MW_CMB[None, :])
|
|
75
|
+
return df
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Result:
|
|
80
|
+
"""Barycentre cloud and its summary statistics."""
|
|
81
|
+
cloud: pd.DataFrame
|
|
82
|
+
nominal_position: np.ndarray # kpc, Galactocentric, unsampled central values
|
|
83
|
+
nominal_velocity: np.ndarray # km/s
|
|
84
|
+
fiducial: str
|
|
85
|
+
n: int
|
|
86
|
+
seed: int
|
|
87
|
+
membership: str
|
|
88
|
+
sigmas: Any
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def positions(self):
|
|
92
|
+
return self.cloud[["bc0_x", "bc0_y", "bc0_z"]].to_numpy()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def velocities(self):
|
|
96
|
+
return self.cloud[["bc0_vx", "bc0_vy", "bc0_vz"]].to_numpy()
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def median_position(self):
|
|
100
|
+
return np.median(self.positions, axis=0)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def median_velocity(self):
|
|
104
|
+
return np.median(self.velocities, axis=0)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def covariance(self):
|
|
108
|
+
return np.cov(self.positions.T)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def principal_axes(self):
|
|
112
|
+
"""(semiaxes 1-sigma, descending; unit vectors as columns)."""
|
|
113
|
+
w, U = np.linalg.eigh(self.covariance)
|
|
114
|
+
o = np.argsort(w)[::-1]
|
|
115
|
+
return np.sqrt(w[o]), U[:, o]
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def axes(self):
|
|
119
|
+
return self.principal_axes[0]
|
|
120
|
+
|
|
121
|
+
def vcmb(self, membership="4"):
|
|
122
|
+
"""(16th, 50th, 84th percentile) of the barycentre speed w.r.t. the CMB, km/s."""
|
|
123
|
+
return tuple(np.percentile(self.cloud[f"vcmb_{membership}_kms"], [16, 50, 84]))
|
|
124
|
+
|
|
125
|
+
def summary(self):
|
|
126
|
+
a = self.axes
|
|
127
|
+
c = self.cloud
|
|
128
|
+
q = self.vcmb(self.membership)
|
|
129
|
+
return "\n".join([
|
|
130
|
+
f"Local Group barycentre | fiducial: {self.fiducial} | members: {self.membership} "
|
|
131
|
+
f"| N = {self.n}, seed = {self.seed}",
|
|
132
|
+
f" median position (Galactocentric, kpc): {np.round(self.median_position, 1)}",
|
|
133
|
+
f" median velocity (MW rest frame, km/s): {np.round(self.median_velocity, 1)}",
|
|
134
|
+
f" 1-sigma principal axes (kpc) : {np.round(a, 1)}",
|
|
135
|
+
f" median ICRS: RA = {c.bc0_ra.median():.1f} deg, Dec = {c.bc0_dec.median():+.1f} deg, "
|
|
136
|
+
f"d = {c.bc0_dist.median():.0f} kpc",
|
|
137
|
+
f" speed w.r.t. the CMB (16/50/84 %) : {np.round(q, 1)} km/s",
|
|
138
|
+
"Assumptions:",
|
|
139
|
+
" * Gaussian sampling truncated at +-2 sigma (effective std ~0.88 sigma);",
|
|
140
|
+
" * sky positions held fixed; MW at rest at the origin of the Galactocentric frame;",
|
|
141
|
+
" * the output is the uncertainty INDUCED by the declared errors, not a complete",
|
|
142
|
+
" physical uncertainty region.",
|
|
143
|
+
])
|
|
144
|
+
|
|
145
|
+
def __repr__(self):
|
|
146
|
+
return self.summary()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def barycentre(source, n=1000, seed=2000, sigmas: Optional[dict] = None, membership="4"):
|
|
150
|
+
"""Barycentre of the Local Group with Monte Carlo uncertainty.
|
|
151
|
+
|
|
152
|
+
Parameters
|
|
153
|
+
----------
|
|
154
|
+
source : Fiducial, str or dict
|
|
155
|
+
A fiducial (``fiducials.WU``), its name, or a dict of heliocentric observables
|
|
156
|
+
with the structure of ``Fiducial.obs``.
|
|
157
|
+
n : number of Monte Carlo realizations.
|
|
158
|
+
seed : realization ``i`` uses ``default_rng([seed, i])`` (reproducible one by one).
|
|
159
|
+
sigmas : 1-sigma uncertainties; defaults to Table 1 of Wu et al. (2026).
|
|
160
|
+
membership : ``"2"`` (MW+M31), ``"3M"`` (+M33), ``"3L"`` (+LMC) or ``"4"`` (all).
|
|
161
|
+
"""
|
|
162
|
+
name, base, zero = _resolve(source)
|
|
163
|
+
sig = DEFAULT_SIGMAS if sigmas is None else sigmas
|
|
164
|
+
obs_list = sample_ensemble(base, n, seed, sig)
|
|
165
|
+
m, r, v = phase_space(obs_list, zero)
|
|
166
|
+
df = build_cloud(m, r, v, membership)
|
|
167
|
+
m0, r0, v0 = phase_space([base], zero)
|
|
168
|
+
(rn, vn) = _bc(m0, r0, v0, MEMBERSHIPS[membership])
|
|
169
|
+
return Result(df, rn[0], vn[0], name, n, seed, membership, sig)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Paired comparison between two fiducials (identical random draws)."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from .barycentre import Result, barycentre
|
|
7
|
+
from .constants import MEMBERSHIPS
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Comparison:
|
|
12
|
+
a: Result
|
|
13
|
+
b: Result
|
|
14
|
+
offset_kpc: float # |nominal_b - nominal_a|
|
|
15
|
+
offset_principal: np.ndarray # offset in the principal-axis frame of ``a`` (kpc)
|
|
16
|
+
offset_sigma: float # |component along the major axis of a| / a1
|
|
17
|
+
dvcmb: dict # median v_CMB(b) - median v_CMB(a), per membership (km/s)
|
|
18
|
+
|
|
19
|
+
def __repr__(self):
|
|
20
|
+
d = ", ".join(f"{k}: {v:+.0f}" for k, v in self.dvcmb.items())
|
|
21
|
+
return (f"Comparison({self.a.fiducial} -> {self.b.fiducial}): offset = {self.offset_kpc:.1f} kpc "
|
|
22
|
+
f"({self.offset_sigma:.1f} sigma along the major axis of {self.a.fiducial}); "
|
|
23
|
+
f"dv_CMB [km/s] = {d}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def compare(a, b, n=1000, seed=3000, sigmas=None, membership="4"):
|
|
27
|
+
"""Paired experiment: realization i of both fiducials uses the same random draw,
|
|
28
|
+
so differences measure the effect of the adopted central values, not Monte Carlo noise.
|
|
29
|
+
"""
|
|
30
|
+
ra = barycentre(a, n=n, seed=seed, sigmas=sigmas, membership=membership)
|
|
31
|
+
rb = barycentre(b, n=n, seed=seed, sigmas=sigmas, membership=membership)
|
|
32
|
+
d = rb.nominal_position - ra.nominal_position
|
|
33
|
+
axes, U = ra.principal_axes
|
|
34
|
+
proj = d @ U
|
|
35
|
+
dv = {t: float(rb.cloud[f"vcmb_{t}_kms"].median() - ra.cloud[f"vcmb_{t}_kms"].median())
|
|
36
|
+
for t in MEMBERSHIPS}
|
|
37
|
+
return Comparison(ra, rb, float(np.linalg.norm(d)), proj, float(abs(proj[0]) / axes[0]), dv)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Single source of truth for the constants shared by the whole package."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
KMS = 1.022712165 # 1 km/s in kpc/Gyr
|
|
5
|
+
|
|
6
|
+
# Solar Galactocentric frame (Sawala et al. 2025; Wu et al. 2026)
|
|
7
|
+
SUN_R0 = 8.122 # kpc
|
|
8
|
+
SUN_V = (12.9, 245.6, 7.78) # km/s
|
|
9
|
+
|
|
10
|
+
# Truncation of the Gaussian sampling, in sigmas
|
|
11
|
+
TRUNC = 2.0
|
|
12
|
+
|
|
13
|
+
# Solar dipole w.r.t. the CMB rest frame:
|
|
14
|
+
# Planck 2015 results. V. LFI calibration (Planck Collaboration 2016, A&A 594, A5).
|
|
15
|
+
V_SUN_CMB = 369.82 # km/s
|
|
16
|
+
L_CMB, B_CMB = 264.021, 48.253 # deg (Galactic l, b)
|
|
17
|
+
|
|
18
|
+
_U_SUN_CMB = V_SUN_CMB * np.array([
|
|
19
|
+
np.cos(np.deg2rad(B_CMB)) * np.cos(np.deg2rad(L_CMB)),
|
|
20
|
+
np.cos(np.deg2rad(B_CMB)) * np.sin(np.deg2rad(L_CMB)),
|
|
21
|
+
np.sin(np.deg2rad(B_CMB))])
|
|
22
|
+
# Velocity of the MW (origin of the Galactocentric frame) w.r.t. the CMB
|
|
23
|
+
V_MW_CMB = _U_SUN_CMB - np.array(SUN_V)
|
|
24
|
+
|
|
25
|
+
# Body order is part of the public contract (indices used everywhere)
|
|
26
|
+
NAMES = ("MilkyWay", "M31", "M33", "LMC")
|
|
27
|
+
|
|
28
|
+
# Membership definitions: tag -> indices into NAMES
|
|
29
|
+
MEMBERSHIPS = {
|
|
30
|
+
"2": (0, 1), # MW + M31
|
|
31
|
+
"3M": (0, 1, 2), # MW + M31 + M33
|
|
32
|
+
"3L": (0, 1, 3), # MW + M31 + LMC
|
|
33
|
+
"4": (0, 1, 2, 3), # MW + M31 + M33 + LMC
|
|
34
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Literature fiducials with explicit provenance.
|
|
2
|
+
|
|
3
|
+
Each fiducial stores HELIOCENTRIC observables in the format used by the
|
|
4
|
+
sampler (``mu`` distance modulus, ``pmra``/``pmdec`` in uas/yr, ``vlos`` in
|
|
5
|
+
km/s, ``m`` = M200 in Msun, ``c`` = NFW concentration). RA/Dec are the usual
|
|
6
|
+
catalogued centres and are not sampled.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from pylgframework import fiducials
|
|
11
|
+
fiducials.WU
|
|
12
|
+
fiducials.get("vdm2012")
|
|
13
|
+
"""
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Dict, Tuple
|
|
16
|
+
|
|
17
|
+
_LMC = dict(m=1.5e11, c=10.0, ra=80.894, dec=-69.756, mu=18.477,
|
|
18
|
+
pmra=1910.0, pmdec=229.0, vlos=262.2)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Fiducial:
|
|
23
|
+
name: str
|
|
24
|
+
obs: Dict[str, Dict[str, float]]
|
|
25
|
+
reference: str
|
|
26
|
+
notes: str = ""
|
|
27
|
+
zero_mass: Tuple[str, ...] = field(default_factory=tuple) # bodies removed AFTER sampling
|
|
28
|
+
|
|
29
|
+
def __repr__(self):
|
|
30
|
+
return f"Fiducial({self.name!r}, {self.reference})"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
WU = Fiducial(
|
|
34
|
+
name="wu2026",
|
|
35
|
+
reference="Wu, Huang, Zhang, Sun & Shao (2026), ApJ 1001, L19, Table 1, "
|
|
36
|
+
"doi:10.3847/2041-8213/ae5799 (arXiv:2603.22863)",
|
|
37
|
+
notes="Follows the framework of Sawala et al. (2025). RA/Dec are the usual catalogued "
|
|
38
|
+
"centres (McConnachie 2012), NOT taken from the paper table (to be verified).",
|
|
39
|
+
obs={
|
|
40
|
+
"MilkyWay": dict(m=1.0e12, c=10.0),
|
|
41
|
+
"M31": dict(m=1.3e12, c=15.0, ra=10.6847, dec=41.2690, mu=24.407,
|
|
42
|
+
pmra=45.9, pmdec=-20.5, vlos=-301.0),
|
|
43
|
+
"M33": dict(m=3.0e11, c=10.0, ra=23.4621, dec=30.6602, mu=24.670,
|
|
44
|
+
pmra=30.6, pmdec=19.0, vlos=-179.2),
|
|
45
|
+
"LMC": dict(_LMC),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
SAWALA = Fiducial(
|
|
49
|
+
name="sawala2025",
|
|
50
|
+
reference="Sawala et al. (2025), Nat. Astron. 9, 1206, doi:10.1038/s41550-025-02563-1 "
|
|
51
|
+
"(fiducial of the companion notebook)",
|
|
52
|
+
notes="Masses converted from the 1e10 Msun units of the notebook.",
|
|
53
|
+
obs={
|
|
54
|
+
"MilkyWay": dict(m=1.0e12, c=10.0),
|
|
55
|
+
"M31": dict(m=1.3e12, c=10.0, ra=10.6847, dec=41.2690, mu=24.407,
|
|
56
|
+
pmra=48.9, pmdec=-36.9, vlos=-301.0),
|
|
57
|
+
"M33": dict(m=3.0e11, c=10.0, ra=23.4621, dec=30.6602, mu=24.67,
|
|
58
|
+
pmra=31.0, pmdec=-29.0, vlos=-179.2),
|
|
59
|
+
"LMC": dict(_LMC),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
VDM2019 = Fiducial(
|
|
63
|
+
name="vdM2019",
|
|
64
|
+
reference="Sawala et al. (2025) fiducial with the M31 proper motion of "
|
|
65
|
+
"van der Marel et al. (2019), ApJ 872, 24, doi:10.3847/1538-4357/ab001b",
|
|
66
|
+
notes="Identical to SAWALA except for the M31 proper motion (49, -38) +- 11 uas/yr, "
|
|
67
|
+
"the combined Gaia DR2 + HST value of that paper.",
|
|
68
|
+
obs={
|
|
69
|
+
"MilkyWay": dict(m=1.0e12, c=10.0),
|
|
70
|
+
"M31": dict(m=1.3e12, c=10.0, ra=10.6847, dec=41.2690, mu=24.407,
|
|
71
|
+
pmra=49.0, pmdec=-38.0, vlos=-301.0),
|
|
72
|
+
"M33": dict(m=3.0e11, c=10.0, ra=23.4621, dec=30.6602, mu=24.67,
|
|
73
|
+
pmra=31.0, pmdec=-29.0, vlos=-179.2),
|
|
74
|
+
"LMC": dict(_LMC),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
_VDM2012_OBS = {
|
|
78
|
+
"MilkyWay": dict(m=1.5e12, c=10.0), # Paper III canonical model
|
|
79
|
+
# M31: (v_W, v_N) = (-125.2 +- 30.8, -73.8 +- 28.4) km/s at D = 770 kpc
|
|
80
|
+
# -> pmra = +34.3, pmdec = -20.2 uas/yr
|
|
81
|
+
"M31": dict(m=1.5e12, c=10.0, ra=10.6847, dec=41.2690, mu=24.43,
|
|
82
|
+
pmra=34.3, pmdec=-20.2, vlos=-301.0),
|
|
83
|
+
# M33: Paper II Sec. 6.1 Galactocentric velocity -> pmra = +23.2, pmdec = +7.5 uas/yr
|
|
84
|
+
# at D = 794 kpc; matches the VLBA maser PM of Brunthaler et al. (2005)
|
|
85
|
+
"M33": dict(m=1.5e11, c=10.0, ra=23.4621, dec=30.6602, mu=24.50,
|
|
86
|
+
pmra=23.2, pmdec=7.5, vlos=-180.0),
|
|
87
|
+
"LMC": dict(_LMC), # NOT in vdM2012 (see notes)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
VDM2012 = Fiducial(
|
|
91
|
+
name="vdm2012",
|
|
92
|
+
reference="van der Marel et al. (2012), ApJ 753, 8 (doi:10.1088/0004-637X/753/1/8) "
|
|
93
|
+
"and 9 (doi:10.1088/0004-637X/753/1/9), Papers II and III",
|
|
94
|
+
notes="Four-body variant. vdM2012 did NOT include the LMC; its entry is inherited "
|
|
95
|
+
"from WU for comparability.",
|
|
96
|
+
obs=_VDM2012_OBS)
|
|
97
|
+
|
|
98
|
+
VDM2012_3B = Fiducial(
|
|
99
|
+
name="vdm2012_3b",
|
|
100
|
+
reference="van der Marel et al. (2012), ApJ 753, 8 (doi:10.1088/0004-637X/753/1/8) "
|
|
101
|
+
"and 9 (doi:10.1088/0004-637X/753/1/9), Papers II and III",
|
|
102
|
+
notes="Faithful three-body variant (MW+M31+M33). The LMC is sampled (so draws stay "
|
|
103
|
+
"paired with the other fiducials) and its mass is set to zero afterwards.",
|
|
104
|
+
obs=_VDM2012_OBS, zero_mass=("LMC",))
|
|
105
|
+
|
|
106
|
+
ALL = {f.name: f for f in (WU, SAWALA, VDM2019, VDM2012, VDM2012_3B)}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get(name):
|
|
110
|
+
"""Fiducial by name (``wu2026``, ``sawala2025``, ``vdM2019``, ``vdm2012``, ``vdm2012_3b``)."""
|
|
111
|
+
try:
|
|
112
|
+
return ALL[name]
|
|
113
|
+
except KeyError:
|
|
114
|
+
raise KeyError(f"unknown fiducial {name!r}; available: {sorted(ALL)}") from None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Heliocentric observables <-> Galactocentric phase space (vectorized)."""
|
|
2
|
+
import astropy.units as u
|
|
3
|
+
import numpy as np
|
|
4
|
+
from astropy.coordinates import CartesianDifferential, Galactocentric, ICRS, SkyCoord
|
|
5
|
+
|
|
6
|
+
from .constants import SUN_R0, SUN_V
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def galactocentric_frame():
|
|
10
|
+
return Galactocentric(galcen_distance=SUN_R0 * u.kpc,
|
|
11
|
+
galcen_v_sun=CartesianDifferential(list(SUN_V) * (u.km / u.s)))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def mu_to_kpc(mu):
|
|
15
|
+
"""Distance modulus -> distance in kpc."""
|
|
16
|
+
return 10.0 ** ((np.asarray(mu, float) + 5.0) / 5.0) / 1.0e3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def helio_to_galactocentric(ra, dec, mu, pmra, pmdec, vlos):
|
|
20
|
+
"""Arrays (or scalars) of heliocentric observables -> position (K,3) kpc, velocity (K,3) km/s.
|
|
21
|
+
|
|
22
|
+
ra, dec in deg; mu in mag; pmra (=mu_alpha*) and pmdec in uas/yr; vlos in km/s.
|
|
23
|
+
"""
|
|
24
|
+
a = lambda x: np.atleast_1d(np.asarray(x, float))
|
|
25
|
+
c = SkyCoord(ra=a(ra) * u.deg, dec=a(dec) * u.deg, distance=mu_to_kpc(a(mu)) * u.kpc,
|
|
26
|
+
pm_ra_cosdec=a(pmra) * u.uas / u.yr, pm_dec=a(pmdec) * u.uas / u.yr,
|
|
27
|
+
radial_velocity=a(vlos) * u.km / u.s, frame="icrs")
|
|
28
|
+
g = c.transform_to(galactocentric_frame())
|
|
29
|
+
return g.cartesian.xyz.to_value(u.kpc).T, g.velocity.d_xyz.to_value(u.km / u.s).T
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def galactocentric_to_observables(pos, vel):
|
|
33
|
+
"""Inverse transform: position (kpc) and velocity (km/s) -> dict of heliocentric observables."""
|
|
34
|
+
pos, vel = np.atleast_2d(pos), np.atleast_2d(vel)
|
|
35
|
+
c = SkyCoord(x=pos[:, 0] * u.kpc, y=pos[:, 1] * u.kpc, z=pos[:, 2] * u.kpc,
|
|
36
|
+
v_x=vel[:, 0] * u.km / u.s, v_y=vel[:, 1] * u.km / u.s, v_z=vel[:, 2] * u.km / u.s,
|
|
37
|
+
frame=galactocentric_frame())
|
|
38
|
+
i = c.transform_to(ICRS())
|
|
39
|
+
d_pc = i.distance.to_value(u.pc)
|
|
40
|
+
return dict(ra=i.ra.deg, dec=i.dec.deg, mu=5.0 * np.log10(d_pc) - 5.0,
|
|
41
|
+
pmra=i.pm_ra_cosdec.to_value(u.uas / u.yr),
|
|
42
|
+
pmdec=i.pm_dec.to_value(u.uas / u.yr),
|
|
43
|
+
vlos=i.radial_velocity.to_value(u.km / u.s))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Optional adapter for REBOUND simulations (requires ``pip install pylgframework[rebound]``)."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from .constants import KMS, NAMES
|
|
5
|
+
from .frames import galactocentric_to_observables
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def observables_from_sim(sim, template, names=NAMES, rotation=None):
|
|
9
|
+
"""Heliocentric observables of the particles of a REBOUND simulation at its CURRENT state.
|
|
10
|
+
|
|
11
|
+
Call it on the initial state (t = 0): the observables are the present-day values.
|
|
12
|
+
|
|
13
|
+
The simulation must use kpc, Msun and Gyr, with the particles ordered as ``names``
|
|
14
|
+
(particle 0 is the Milky Way). Positions and velocities are taken relative to particle 0,
|
|
15
|
+
so a simulation moved to its centre of mass (``sim.move_to_com()``) is handled directly.
|
|
16
|
+
If the simulation axes are rotated with respect to the Galactocentric frame
|
|
17
|
+
(``x_sim = rotation @ x_galactocentric``), pass that matrix as ``rotation``.
|
|
18
|
+
|
|
19
|
+
Masses come from the particles and concentrations from ``template`` (a dict like
|
|
20
|
+
``Fiducial.obs``). The result can be passed to ``barycentre(..., sigmas=...)``.
|
|
21
|
+
Uncertainties are defined on the observables (distance modulus, proper motions, radial
|
|
22
|
+
velocity), not on Cartesian coordinates, so the correlations of the frame transformation
|
|
23
|
+
are preserved.
|
|
24
|
+
"""
|
|
25
|
+
ps = sim.particles
|
|
26
|
+
pos = np.array([[p.x, p.y, p.z] for p in ps])
|
|
27
|
+
vel = np.array([[p.vx, p.vy, p.vz] for p in ps]) / KMS # kpc/Gyr -> km/s
|
|
28
|
+
pos, vel = pos - pos[0], vel - vel[0] # MW at rest at the origin
|
|
29
|
+
if rotation is not None:
|
|
30
|
+
R = np.asarray(rotation, float)
|
|
31
|
+
pos, vel = pos @ R, vel @ R # rows: (R.T @ x) for each x
|
|
32
|
+
obs = {}
|
|
33
|
+
ob = galactocentric_to_observables(pos[1:], vel[1:])
|
|
34
|
+
for j, nm in enumerate(names):
|
|
35
|
+
d = dict(m=float(ps[j].m), c=float(template[nm]["c"]))
|
|
36
|
+
if j > 0:
|
|
37
|
+
d.update({k: float(ob[k][j - 1]) for k in ob})
|
|
38
|
+
obs[nm] = d
|
|
39
|
+
return obs
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Truncated-Gaussian sampling of the observables.
|
|
2
|
+
|
|
3
|
+
The draw scheme is a public contract: realization ``i`` of an ensemble with
|
|
4
|
+
seed ``s`` uses ``numpy.random.default_rng([s, i])`` and consumes the random
|
|
5
|
+
numbers in the order given by the ``sigmas`` dictionary. Changing it breaks
|
|
6
|
+
reproducibility of every published ensemble (and the paired design of
|
|
7
|
+
``compare``).
|
|
8
|
+
"""
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .constants import TRUNC
|
|
12
|
+
|
|
13
|
+
# 1-sigma uncertainties of the 20 observables (Wu et al. 2026, Table 1).
|
|
14
|
+
# Masses in Msun; mu in mag; proper motions in uas/yr; vlos in km/s.
|
|
15
|
+
DEFAULT_SIGMAS = {
|
|
16
|
+
"MilkyWay": dict(m=2.0e11, c=2.0),
|
|
17
|
+
"M31": dict(m=4.0e11, c=2.5, mu=0.032, pmra=8.1, pmdec=6.6, vlos=1.0),
|
|
18
|
+
"M33": dict(m=1.0e11, c=2.0, mu=0.070, pmra=5.7, pmdec=5.7, vlos=1.8),
|
|
19
|
+
"LMC": dict(m=0.5e11, c=2.0, mu=0.026, pmra=20.0, pmdec=47.0, vlos=3.4),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def trunc_normal(rng, mean, sigma, k=TRUNC):
|
|
24
|
+
"""Gaussian truncated at +-k sigma, by rejection."""
|
|
25
|
+
while True:
|
|
26
|
+
z = rng.standard_normal()
|
|
27
|
+
if abs(z) <= k:
|
|
28
|
+
return float(mean + sigma * z)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sample_obs(rng, base, sigmas=None):
|
|
32
|
+
"""One realization with the same structure as ``base`` (sky positions fixed)."""
|
|
33
|
+
sigmas = DEFAULT_SIGMAS if sigmas is None else sigmas
|
|
34
|
+
obs = {}
|
|
35
|
+
for gal, sig in sigmas.items():
|
|
36
|
+
obs[gal] = dict(base[gal])
|
|
37
|
+
for key, s in sig.items():
|
|
38
|
+
obs[gal][key] = trunc_normal(rng, base[gal][key], s)
|
|
39
|
+
for gal in base: # galaxies without sigmas stay fixed
|
|
40
|
+
obs.setdefault(gal, dict(base[gal]))
|
|
41
|
+
return obs
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sample_ensemble(base, n, seed, sigmas=None):
|
|
45
|
+
"""List of ``n`` realizations; realization i uses ``default_rng([seed, i])``."""
|
|
46
|
+
return [sample_obs(np.random.default_rng([seed, i]), base, sigmas) for i in range(n)]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pylgframework
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local Group barycentre with propagated observational uncertainty (Monte Carlo)
|
|
5
|
+
Author: Moisés Frutos Plaza
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mfrutosp/pylgframework
|
|
8
|
+
Project-URL: Issues, https://github.com/mfrutosp/pylgframework/issues
|
|
9
|
+
Keywords: Local Group,barycentre,Monte Carlo,astropy,REBOUND
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: numpy
|
|
20
|
+
Requires-Dist: pandas
|
|
21
|
+
Requires-Dist: astropy>=7.2
|
|
22
|
+
Provides-Extra: rebound
|
|
23
|
+
Requires-Dist: rebound; extra == "rebound"
|
|
24
|
+
Provides-Extra: analysis
|
|
25
|
+
Requires-Dist: scipy; extra == "analysis"
|
|
26
|
+
Requires-Dist: scikit-learn; extra == "analysis"
|
|
27
|
+
Requires-Dist: diptest; extra == "analysis"
|
|
28
|
+
Requires-Dist: matplotlib; extra == "analysis"
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: pytest; extra == "test"
|
|
31
|
+
Requires-Dist: rebound; extra == "test"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# pylgframework
|
|
35
|
+
|
|
36
|
+
A software framework for the Local Group.
|
|
37
|
+
|
|
38
|
+
The barycentre of the Local Group is routinely adopted as a point estimate. `pylgframework` replaces the
|
|
39
|
+
single-line centre-of-mass calculation with a Monte Carlo that propagates the observational uncertainty of the
|
|
40
|
+
member galaxies, and returns the barycentre together with its uncertainty region.
|
|
41
|
+
|
|
42
|
+
> Frutos Plaza, M. (2026). *Membership Systematics of the Local Group Barycentre: Uncertainty Region, Dependence
|
|
43
|
+
> on Member Galaxies, and Consistency with the CMB Dipole.* (manuscript; data and code at doi:10.5281/zenodo.23440772)
|
|
44
|
+
|
|
45
|
+
## Status
|
|
46
|
+
|
|
47
|
+
[](https://github.com/mfrutosp/pylgframework/actions/workflows/tests.yml)
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install -e . # core: numpy, pandas, astropy
|
|
53
|
+
pip install -e ".[rebound]" # adapter for REBOUND simulations
|
|
54
|
+
pip install -e ".[test]" # development and tests
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from pylgframework import barycentre, compare, fiducials
|
|
61
|
+
|
|
62
|
+
res = barycentre(fiducials.WU, n=1000, seed=2000)
|
|
63
|
+
print(res) # position, velocity, principal axes, CMB-frame speed, assumptions
|
|
64
|
+
|
|
65
|
+
res.median_position # kpc, Galactocentric
|
|
66
|
+
res.axes # 1-sigma principal axes of the barycentre cloud (kpc)
|
|
67
|
+
res.vcmb("4") # speed w.r.t. the CMB (16th, 50th, 84th percentile, km/s)
|
|
68
|
+
res.cloud # DataFrame with every realization
|
|
69
|
+
|
|
70
|
+
# Paired audit of two literature fiducials (identical random draws)
|
|
71
|
+
compare(fiducials.WU, fiducials.VDM2012, n=1000, seed=3000)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Available fiducials: `WU` (Wu et al. 2026), `SAWALA` (Sawala et al. 2025), `VDM2019`, `VDM2012` and
|
|
75
|
+
`VDM2012_3B` (van der Marel et al. 2012, four-body and faithful three-body variants). Each one carries its
|
|
76
|
+
reference and notes (`fiducials.WU.reference`).
|
|
77
|
+
|
|
78
|
+
Memberships: `"2"` (MW+M31), `"3M"` (+M33), `"3L"` (+LMC), `"4"` (all four).
|
|
79
|
+
|
|
80
|
+
## Assumptions
|
|
81
|
+
|
|
82
|
+
* Observables (distance modulus, proper motions, radial velocity, mass) are drawn from Gaussians truncated at
|
|
83
|
+
±2σ (the effective standard deviation is about 0.88σ).
|
|
84
|
+
* Sky positions are held fixed; the MW is at rest at the origin of the Galactocentric frame
|
|
85
|
+
(R0 = 8.122 kpc, v⊙ = (12.9, 245.6, 7.78) km/s).
|
|
86
|
+
* The output is the uncertainty **induced by the declared errors**, not a complete physical uncertainty region.
|
|
87
|
+
* The uncertainty model is fixed across fiducials unless `sigmas=` is supplied.
|
|
88
|
+
|
|
89
|
+
## Reproducibility
|
|
90
|
+
|
|
91
|
+
Realization `i` of an ensemble with seed `s` uses `numpy.random.default_rng([s, i])`. This scheme is part of the
|
|
92
|
+
public interface. The test suite reproduces Table 1 and Sects. 3 and 4 of the manuscript:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pytest # fast tests
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Verified against the published values (Table 1 of the manuscript) with astropy 7.2.0 (Linux, Python 3.12)
|
|
99
|
+
and astropy 8.0.2 (Linux, Python 3.12; Windows, Python 3.11).
|
|
100
|
+
|
|
101
|
+
## References
|
|
102
|
+
|
|
103
|
+
The bibliography of the manuscript, including the sources of every fiducial and of the CMB dipole, is in
|
|
104
|
+
[`docs/references.bib`](docs/references.bib).
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/pylgframework/__init__.py
|
|
5
|
+
src/pylgframework/barycentre.py
|
|
6
|
+
src/pylgframework/compare.py
|
|
7
|
+
src/pylgframework/constants.py
|
|
8
|
+
src/pylgframework/fiducials.py
|
|
9
|
+
src/pylgframework/frames.py
|
|
10
|
+
src/pylgframework/rebound_adapter.py
|
|
11
|
+
src/pylgframework/sampling.py
|
|
12
|
+
src/pylgframework.egg-info/PKG-INFO
|
|
13
|
+
src/pylgframework.egg-info/SOURCES.txt
|
|
14
|
+
src/pylgframework.egg-info/dependency_links.txt
|
|
15
|
+
src/pylgframework.egg-info/requires.txt
|
|
16
|
+
src/pylgframework.egg-info/top_level.txt
|
|
17
|
+
tests/test_core.py
|
|
18
|
+
tests/test_paper_values.py
|
|
19
|
+
tests/test_rebound_adapter.py
|
|
20
|
+
tests/test_rebound_simulation.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pylgframework
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from pylgframework import barycentre, compare, fiducials
|
|
5
|
+
from pylgframework.frames import galactocentric_to_observables, helio_to_galactocentric
|
|
6
|
+
from pylgframework.sampling import sample_ensemble
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_realizations_are_reproducible_one_by_one():
|
|
10
|
+
a = sample_ensemble(fiducials.WU.obs, 5, seed=7)
|
|
11
|
+
b = sample_ensemble(fiducials.WU.obs, 8, seed=7)
|
|
12
|
+
assert a == b[:5] # realization i depends only on (seed, i)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_truncation_is_respected():
|
|
16
|
+
base = fiducials.WU.obs
|
|
17
|
+
from pylgframework.sampling import DEFAULT_SIGMAS
|
|
18
|
+
for o in sample_ensemble(base, 200, seed=1):
|
|
19
|
+
for g, sig in DEFAULT_SIGMAS.items():
|
|
20
|
+
for k, s in sig.items():
|
|
21
|
+
assert abs(o[g][k] - base[g][k]) <= 2.0 * s + 1e-9
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_sky_positions_are_fixed():
|
|
25
|
+
base = fiducials.WU.obs
|
|
26
|
+
for o in sample_ensemble(base, 20, seed=1):
|
|
27
|
+
for g in ("M31", "M33", "LMC"):
|
|
28
|
+
assert o[g]["ra"] == base[g]["ra"] and o[g]["dec"] == base[g]["dec"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_transform_roundtrip():
|
|
32
|
+
o = fiducials.WU.obs["M31"]
|
|
33
|
+
r, v = helio_to_galactocentric(o["ra"], o["dec"], o["mu"], o["pmra"], o["pmdec"], o["vlos"])
|
|
34
|
+
back = galactocentric_to_observables(r, v)
|
|
35
|
+
for k in ("ra", "dec", "mu", "pmra", "pmdec", "vlos"):
|
|
36
|
+
assert back[k][0] == pytest.approx(o[k], rel=1e-6, abs=1e-6)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_three_body_variant_degenerates_exactly():
|
|
40
|
+
c = barycentre("vdm2012_3b", n=50, seed=3000).cloud
|
|
41
|
+
assert (c.shift_LMC_kpc == 0).all() and (c.dv_LMC_kms == 0).all()
|
|
42
|
+
np.testing.assert_allclose(c.shift_2to4_kpc, c.shift_M33_kpc)
|
|
43
|
+
np.testing.assert_allclose(c.vcmb_4_kms, c.vcmb_3M_kms)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_paired_comparison_uses_identical_draws():
|
|
47
|
+
# Same fiducial against itself: zero offset, zero velocity difference.
|
|
48
|
+
c = compare(fiducials.WU, fiducials.WU, n=50, seed=3000)
|
|
49
|
+
assert c.offset_kpc == pytest.approx(0.0, abs=1e-9)
|
|
50
|
+
assert all(abs(v) < 1e-9 for v in c.dvcmb.values())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_custom_sigmas_change_the_cloud():
|
|
54
|
+
from pylgframework import DEFAULT_SIGMAS
|
|
55
|
+
tight = {g: {k: v / 10 for k, v in s.items()} for g, s in DEFAULT_SIGMAS.items()}
|
|
56
|
+
a = barycentre(fiducials.WU, n=200, seed=1)
|
|
57
|
+
b = barycentre(fiducials.WU, n=200, seed=1, sigmas=tight)
|
|
58
|
+
assert b.axes[0] < 0.2 * a.axes[0]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_summary_states_the_assumptions():
|
|
62
|
+
s = barycentre(fiducials.WU, n=20, seed=1).summary()
|
|
63
|
+
assert "truncated" in s and "INDUCED" in s
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_unknown_fiducial():
|
|
67
|
+
with pytest.raises(KeyError):
|
|
68
|
+
fiducials.get("nope")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Regression against the values published in Frutos Plaza (2026), Table 1 and Sect. 3.
|
|
2
|
+
|
|
3
|
+
Tolerances reflect the rounding of the published numbers (and astropy version differences).
|
|
4
|
+
"""
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from pylgframework import barycentre, compare, fiducials
|
|
9
|
+
|
|
10
|
+
pytestmark = pytest.mark.slow
|
|
11
|
+
|
|
12
|
+
# fiducial: (a1, a2, a3, D_BC, RA, Dec, vCMB(2), vCMB(4), Delta_2to4) [Table 1, seed 3000]
|
|
13
|
+
TABLE1 = {
|
|
14
|
+
"wu2026": (53.6, 7.3, 0.2, 448, 13.4, 38.8, 621, 629, 34),
|
|
15
|
+
"sawala2025": (53.6, 7.3, 0.2, 448, 13.4, 38.8, 619, 627, 34),
|
|
16
|
+
"vdM2019": (53.6, 7.3, 0.2, 448, 13.4, 38.8, 619, 626, 34),
|
|
17
|
+
"vdm2012": (48.8, 5.3, 0.2, 381, 11.6, 39.9, 604, 614, 15),
|
|
18
|
+
"vdm2012_3b": (49.6, 5.5, 0.0, 401, 11.4, 40.2, 604, 602, 21),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.parametrize("name", sorted(TABLE1))
|
|
23
|
+
def test_table1(name):
|
|
24
|
+
a1, a2, a3, d, ra, dec, v2, v4, d24 = TABLE1[name]
|
|
25
|
+
r = barycentre(name, n=1000, seed=3000)
|
|
26
|
+
c = r.cloud
|
|
27
|
+
np.testing.assert_allclose(r.axes, [a1, a2, a3], atol=0.15)
|
|
28
|
+
assert abs(c.bc0_dist.median() - d) < 1.0
|
|
29
|
+
assert abs(c.bc0_ra.median() - ra) < 0.1
|
|
30
|
+
assert abs(c.bc0_dec.median() - dec) < 0.1
|
|
31
|
+
assert abs(c.vcmb_2_kms.median() - v2) < 1.0
|
|
32
|
+
assert abs(c.vcmb_4_kms.median() - v4) < 1.0
|
|
33
|
+
assert abs(c.shift_2to4_kpc.median() - d24) < 1.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_main_text_ensemble():
|
|
37
|
+
"""Sect. 3.1 and 3.3 (seed 2000)."""
|
|
38
|
+
r = barycentre(fiducials.WU, n=1000, seed=2000)
|
|
39
|
+
np.testing.assert_allclose(r.axes, [54.8, 7.7, 0.2], atol=0.15)
|
|
40
|
+
assert abs(np.linalg.norm(r.median_position) - 452) < 1.5
|
|
41
|
+
assert abs(r.cloud.vcmb_2_kms.median() - 622) < 1.0
|
|
42
|
+
assert abs(r.cloud.vcmb_4_kms.median() - 630) < 1.0
|
|
43
|
+
assert abs(r.cloud.shift_M33_kpc.median() - 53) < 1.0
|
|
44
|
+
assert abs(r.cloud.shift_LMC_kpc.median() - 27) < 1.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_fiducial_audit():
|
|
48
|
+
"""Sect. 4: 68 kpc (1.2 sigma) and 17 km/s between the modern and the HST-era fiducial."""
|
|
49
|
+
c = compare(fiducials.WU, fiducials.VDM2012, n=1000, seed=3000)
|
|
50
|
+
assert abs(c.offset_kpc - 67.8) < 0.5
|
|
51
|
+
assert abs(c.offset_sigma - 1.2) < 0.1
|
|
52
|
+
assert abs(c.dvcmb["2"] + 17) < 1.0
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
rebound = pytest.importorskip("rebound")
|
|
5
|
+
|
|
6
|
+
from pylgframework import barycentre, fiducials
|
|
7
|
+
from pylgframework.constants import KMS, NAMES
|
|
8
|
+
from pylgframework.barycentre import phase_space
|
|
9
|
+
from pylgframework.rebound_adapter import observables_from_sim
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_sim_roundtrip_reproduces_the_nominal_barycentre():
|
|
13
|
+
base = fiducials.WU.obs
|
|
14
|
+
m, r, v = phase_space([base])
|
|
15
|
+
sim = rebound.Simulation()
|
|
16
|
+
sim.G = 4.30091727e-6 * KMS**2 # kpc^3 / (Msun Gyr^2); the adapter only reads state
|
|
17
|
+
for j in range(4):
|
|
18
|
+
sim.add(m=m[0, j], x=r[0, j, 0], y=r[0, j, 1], z=r[0, j, 2],
|
|
19
|
+
vx=v[0, j, 0] * KMS, vy=v[0, j, 1] * KMS, vz=v[0, j, 2] * KMS)
|
|
20
|
+
obs = observables_from_sim(sim, base)
|
|
21
|
+
a = barycentre(base, n=1, seed=0, sigmas={})
|
|
22
|
+
b = barycentre(obs, n=1, seed=0, sigmas={})
|
|
23
|
+
np.testing.assert_allclose(b.nominal_position, a.nominal_position, atol=1e-3)
|
|
24
|
+
np.testing.assert_allclose(b.nominal_velocity, a.nominal_velocity, atol=1e-3)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""REBOUND simulation of the Local Group used as a worked example of ``pylgframework``.
|
|
2
|
+
|
|
3
|
+
The simulation is built as in the dynamical code of the manuscript (IAS15, G in
|
|
4
|
+
kpc^3 Msun^-1 Gyr^-2, four bodies MW, M31, M33, LMC, 5 kpc softening, moved to the centre of
|
|
5
|
+
mass), without dynamical friction or mass transfer, which are not part of this package.
|
|
6
|
+
|
|
7
|
+
Usage pattern::
|
|
8
|
+
|
|
9
|
+
sim = build_local_group_sim(fiducials.WU) # your own REBOUND simulation
|
|
10
|
+
obs = observables_from_sim(sim, fiducials.WU.obs) # recover the heliocentric observables
|
|
11
|
+
res = barycentre(obs, n=1000, seed=2000) # barycentre with its uncertainty
|
|
12
|
+
print(res) # replaces sim.com()
|
|
13
|
+
"""
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
rebound = pytest.importorskip("rebound")
|
|
18
|
+
|
|
19
|
+
from pylgframework import barycentre, fiducials
|
|
20
|
+
from pylgframework.barycentre import phase_space
|
|
21
|
+
from pylgframework.constants import KMS, NAMES
|
|
22
|
+
from pylgframework.rebound_adapter import observables_from_sim
|
|
23
|
+
|
|
24
|
+
G = 4.30091727e-6 * KMS**2 # kpc^3 / (Msun Gyr^2)
|
|
25
|
+
SOFTENING = 5.0 # kpc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_local_group_sim(fiducial, rotation=None):
|
|
29
|
+
"""Four-body Local Group simulation from a fiducial, in the barycentric frame."""
|
|
30
|
+
m, r, v = phase_space([fiducial.obs], fiducial.zero_mass)
|
|
31
|
+
sim = rebound.Simulation()
|
|
32
|
+
sim.integrator = "ias15"
|
|
33
|
+
sim.G = G
|
|
34
|
+
sim.softening = SOFTENING
|
|
35
|
+
sim.collision = "none"
|
|
36
|
+
for j, name in enumerate(NAMES):
|
|
37
|
+
rj, vj = r[0, j], v[0, j] * KMS # km/s -> kpc/Gyr
|
|
38
|
+
if rotation is not None:
|
|
39
|
+
rj, vj = rotation @ rj, rotation @ vj
|
|
40
|
+
sim.add(m=m[0, j], x=rj[0], y=rj[1], z=rj[2], vx=vj[0], vy=vj[1], vz=vj[2], name=name)
|
|
41
|
+
sim.move_to_com()
|
|
42
|
+
return sim
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def sim():
|
|
47
|
+
return build_local_group_sim(fiducials.WU)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_simulation_is_in_the_barycentric_frame(sim):
|
|
51
|
+
com = sim.com()
|
|
52
|
+
assert np.linalg.norm([com.x, com.y, com.z]) < 1e-9
|
|
53
|
+
assert np.linalg.norm([com.vx, com.vy, com.vz]) < 1e-9
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_adapter_recovers_the_nominal_barycentre(sim):
|
|
57
|
+
"""sim.com() is a point; the adapter + barycentre() gives the same point, with uncertainty."""
|
|
58
|
+
obs = observables_from_sim(sim, fiducials.WU.obs)
|
|
59
|
+
res = barycentre(obs, n=1, seed=0, sigmas={})
|
|
60
|
+
ref = barycentre(fiducials.WU, n=1, seed=0, sigmas={})
|
|
61
|
+
np.testing.assert_allclose(res.nominal_position, ref.nominal_position, atol=1e-3)
|
|
62
|
+
np.testing.assert_allclose(res.nominal_velocity, ref.nominal_velocity, atol=1e-3)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_uncertainty_from_the_simulation_matches_the_fiducial(sim):
|
|
66
|
+
obs = observables_from_sim(sim, fiducials.WU.obs)
|
|
67
|
+
a = barycentre(obs, n=300, seed=2000)
|
|
68
|
+
b = barycentre(fiducials.WU, n=300, seed=2000)
|
|
69
|
+
np.testing.assert_allclose(a.median_position, b.median_position, atol=0.05)
|
|
70
|
+
np.testing.assert_allclose(a.axes, b.axes, atol=0.05)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_rotated_simulation_axes_are_handled():
|
|
74
|
+
th = np.deg2rad(37.0)
|
|
75
|
+
R = np.array([[np.cos(th), -np.sin(th), 0.0], [np.sin(th), np.cos(th), 0.0], [0.0, 0.0, 1.0]])
|
|
76
|
+
sim_r = build_local_group_sim(fiducials.WU, rotation=R)
|
|
77
|
+
obs = observables_from_sim(sim_r, fiducials.WU.obs, rotation=R)
|
|
78
|
+
res = barycentre(obs, n=1, seed=0, sigmas={})
|
|
79
|
+
ref = barycentre(fiducials.WU, n=1, seed=0, sigmas={})
|
|
80
|
+
np.testing.assert_allclose(res.nominal_position, ref.nominal_position, atol=1e-3)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_centre_of_mass_is_ballistic_during_integration(sim):
|
|
84
|
+
"""Internal forces conserve momentum: the barycentre stays at the origin (bookkeeping check)."""
|
|
85
|
+
sim.integrate(2.0)
|
|
86
|
+
com = sim.com()
|
|
87
|
+
assert np.linalg.norm([com.x, com.y, com.z]) < 1e-6
|
|
88
|
+
assert np.linalg.norm([com.vx, com.vy, com.vz]) < 1e-6
|