def measure_velocity_moments(data: dict, box_size: float,
mass_bins: list[tuple[float, float]],
r_edges: np.ndarray,
mass_key: str = "Mtot") -> dict:
"""Compute pairwise velocity central moments per mass-bin pair.
Returns a dict keyed by 'M{i}_M{j}' (i <= j) with the standard PairVel
fields plus the bin centres and mass-bin metadata.
"""
_ensure_pairvel_on_path()
from pairvel import compute_moments
r_edges = np.asarray(r_edges, dtype=np.float64)
r_centres = 0.5 * (r_edges[:-1] + r_edges[1:])
selections = []
for lo, hi in mass_bins:
sel = _select_mass_bin(data, lo, hi, mass_key=mass_key)
pos = np.array([data["x"][sel], data["y"][sel], data["z"][sel]])
vel = np.array([data["vx"][sel], data["vy"][sel], data["vz"][sel]])
selections.append((pos, vel))
out: dict = {}
nbins = len(mass_bins)
for i in range(nbins):
for j in range(i, nbins):
posL, velL = selections[i]
posR, velR = selections[j]
n1 = posL.shape[1]
n2 = posR.shape[1]
entry: dict = {
"r": r_centres,
"log10M1": np.array(mass_bins[i], dtype=np.float64),
"log10M2": np.array(mass_bins[j], dtype=np.float64),
"n1": int(n1),
"n2": int(n2),
}
if n1 == 0 or n2 == 0:
for k in MOMENT_KEYS:
entry[k] = np.full(len(r_centres),
0.0 if k == "npairs" else np.nan)
else:
if i == j:
res = compute_moments(posL, velL, r_edges, box_size)
else:
res = compute_moments(
posL, velL, r_edges, box_size,
positions_right=posR, velocities_right=velR,
)
for k in MOMENT_KEYS:
entry[k] = np.asarray(res[k])
out[f"M{i}_M{j}"] = entry
out["_meta"] = {
"r_edges": r_edges,
"box_size": float(box_size),
"mass_bins": np.array(mass_bins, dtype=np.float64),
}
return out