pylgframework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pylgframework/__init__.py +8 -0
- pylgframework/barycentre.py +169 -0
- pylgframework/compare.py +37 -0
- pylgframework/constants.py +34 -0
- pylgframework/fiducials.py +114 -0
- pylgframework/frames.py +43 -0
- pylgframework/rebound_adapter.py +39 -0
- pylgframework/sampling.py +46 -0
- pylgframework-0.1.0.dist-info/METADATA +108 -0
- pylgframework-0.1.0.dist-info/RECORD +13 -0
- pylgframework-0.1.0.dist-info/WHEEL +5 -0
- pylgframework-0.1.0.dist-info/licenses/LICENSE +21 -0
- pylgframework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
pylgframework/compare.py
ADDED
|
@@ -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
|
pylgframework/frames.py
ADDED
|
@@ -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,13 @@
|
|
|
1
|
+
pylgframework/__init__.py,sha256=b_GyFWFqtBn_01J_lCb3nG-YJKB0xr3FX51z3qAo858,346
|
|
2
|
+
pylgframework/barycentre.py,sha256=OMDMfyarsyWhtkiqV3TbtRrzGj3hhI4-OIgl9IEVwC8,6819
|
|
3
|
+
pylgframework/compare.py,sha256=0HKvhehOjMZjqJ3BIL6aMw70IYfsUW-X9hE_JwwIPYk,1664
|
|
4
|
+
pylgframework/constants.py,sha256=kr92pj9PpjtsRUmS-gPwtpL2YyjpKzvdc-GwBpknxTs,1273
|
|
5
|
+
pylgframework/fiducials.py,sha256=p6GoVQsRSYx1hFjjqwSWlhDvn5Y9k0SrsTFlJ7OGoIo,4801
|
|
6
|
+
pylgframework/frames.py,sha256=EbsVUr6wMyv7URGkGmGXc3O1Z2RgkPRc4BiHX_1pms8,1992
|
|
7
|
+
pylgframework/rebound_adapter.py,sha256=Zgvqne47MgmJJe1MBthr-lhm8AQy9SckjMRez5qZHJI,1972
|
|
8
|
+
pylgframework/sampling.py,sha256=ORejJu6TcQJxq1UwluW7oUoMkOvXDHeqpU0vH9EpeNM,1803
|
|
9
|
+
pylgframework-0.1.0.dist-info/licenses/LICENSE,sha256=AGTQdZL28K5NA4Ah2hFqxaPVrRyNM1NnDx1VLei_MVo,1098
|
|
10
|
+
pylgframework-0.1.0.dist-info/METADATA,sha256=PD1-EtDLowX9JzYvxxqQZsY54s1SAlvUvZ5hsH8jU9A,4493
|
|
11
|
+
pylgframework-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
pylgframework-0.1.0.dist-info/top_level.txt,sha256=6r1J7kRlogLPkNJT7zMNMnTZtP9m115t5F300Uvv9Ao,14
|
|
13
|
+
pylgframework-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
pylgframework
|