Aetheria/docs/research/gravity-tail-taming/probe.py
Meta Vens ca37b45687 Research: taming summed gravity tails (screened K0, caps, force switch, disc subtraction, exponent, hyperbolic kernel), for ruling tame-tails-parent-only-rails
Eyes pass eyes-ag3-tail-taming, session self-2026-10-15-ag3. Facts file plus
numpy probe over the orbit-probe bodies: enclosed/parent speed ratio by child
class, mid-zone inward pull, planets round a median sun, HLSL cost per well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:19:49 +02:00

127 lines
7.3 KiB
Python

# Tail-taming probe over docs/research/2026-10-13-orbit-probe/data/bodies.csv (1501 zones, 31,640 nodes).
# For each candidate law: (1) relative radial pull on a child, phase-averaged over random phases, over its parent's own
# pull -> circular-speed ratio sqrt(a_rel / a_parent); (2) B21's mid-zone inward pull; (3) parent-only rail speeds.
# a_rel = radial component, toward the parent, of [field at the child from every body outside the child's family]
# minus [field at the parent from every body outside the parent's family] (the parent rides its own rail),
# with the disc term included where a law subtracts one.
# Usage: python probe.py bodies.csv [draws] [law-index,...]
import csv, sys, os, collections, time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from laws import *
rng = np.random.default_rng(7)
DRAWS = int(sys.argv[2]) if len(sys.argv) > 2 else 6
rows = [r for r in csv.DictReader(open(sys.argv[1])) if r.get('type')]
zones = collections.defaultdict(list)
for r in rows: zones[(r['density'], r['seed'])].append(r)
def hill2(D, c, cpar): # 2 x B25's tidal radius d_H = D sqrt(c / (2 c_parent)); a root or bodyless-parent child: D
ok = cpar > 0
return np.where(ok, 2 * D * np.sqrt(c / (2 * np.where(ok, cpar, 1.0))), D)
ONE = lambda m, D, Z, c, cp: np.ones_like(m)
# (name, tail, p, scale(m, D, Z, c, c_parent) -> per-body parameter, subtract disc)
LAWS = [
('log (today)', tail_log, 0.2, ONE, False),
('K0 lam=0.5*D', tail_k0, 0.2, lambda m, D, Z, c, cp: 0.5 * D, False),
('K0 lam=1*D', tail_k0, 0.2, lambda m, D, Z, c, cp: 1.0 * D, False),
('K0 lam=2*d_Hill', tail_k0, 0.2, lambda m, D, Z, c, cp: hill2(D, c, cp), False),
('cap c*L/r^2, L=100m^.25', tail_cap, 0.2, lambda m, D, Z, c, cp: 100 * m ** 0.25, False),
('cap c*L/r^2, L=300m^.25', tail_cap, 0.2, lambda m, D, Z, c, cp: 300 * m ** 0.25, False),
('switch L..2L, L=100m^.25', tail_switch, 0.2, lambda m, D, Z, c, cp: 100 * m ** 0.25, False),
('switch L..2L, L=300m^.25', tail_switch, 0.2, lambda m, D, Z, c, cp: 300 * m ** 0.25, False),
('uniform-disc subtraction', tail_log, 0.2, ONE, True),
('exponent p=0.5', tail_log, 0.5, ONE, False),
('hyperbolic kernel ell=Z/2', tail_hyp, 0.2, lambda m, D, Z, c, cp: 0.5 * Z * np.ones_like(m), False),
]
pick = [int(x) for x in sys.argv[3].split(',')] if len(sys.argv) > 3 else range(len(LAWS))
def zone_struct(z):
idx = {int(r['idx']): k for k, r in enumerate(z)}
n = len(z)
par = np.array([idx.get(int(r['parentIdx']), -1) for r in z])
D = np.array([float(r['distance']) for r in z]); m = np.array([float(r['mass']) for r in z])
body = np.array([r['empty'] == '0' and r['belt'] == '0' for r in z])
cls = ['Belt' if r['belt'] == '1' else r['type'] for r in z]
fam = np.eye(n, dtype=bool) # fam[i, j]: j is i or a descendant of i
depth = np.zeros(n, int)
for j in range(n):
a = par[j]
while a >= 0:
fam[a, j] = True; depth[j] += 1; a = par[a]
order = list(np.argsort(depth, kind='stable'))
return par, D, m, body, cls, fam, order
def place(par, D, order):
pos = np.zeros((len(par), 2)); th = rng.uniform(0, 2 * np.pi, len(par))
for k in order:
if par[k] >= 0: pos[k] = pos[par[k]] + D[k] * np.array([np.cos(th[k]), np.sin(th[k])])
return pos
def field(pos_b, c, R, s, tail, x):
"""Per-body acceleration contributions at points x: (n, nb, 2), each toward its body."""
d = pos_b[None, :, :] - x[:, None, :]; r = np.linalg.norm(d, axis=2)
rs = np.maximum(r, 1e-6)
F = np.where(r < 1e-6, 0, pull(tail, c[None, :], R[None, :], rs, s[None, :]))
return d / rs[:, :, None] * F[:, :, None]
def disc_acc(x, C, Z): # field of a uniform disc of total strength C over radius Z centred at 0 (inward)
r = np.linalg.norm(x, axis=1, keepdims=True); rs = np.maximum(r, 1e-9)
return -x / rs * np.where(r < Z, C * r / Z ** 2, C / rs)
def q(v, ps=(10, 50, 90)):
v = np.sort(np.asarray(v))
return [round(float(v[min(len(v) - 1, int(p / 100 * len(v)))]), 2) for p in ps] if len(v) else []
pre = {k: zone_struct(z) for k, z in zones.items()}
sunm = np.sort([float(r['mass']) for r in rows if r['type'] == 'Sun' and r['empty'] == '0'])
SUN_LO, SUN_HI = sunm[int(.4 * len(sunm))], sunm[int(.6 * len(sunm))] # 'median sun': mass between p40 and p60
for li in pick:
name, tail, p, scale, disc = LAWS[li]
t0 = time.time()
ratio = collections.defaultdict(list); vpar = collections.defaultdict(list); vsp = []; vmed = []
inward = collections.defaultdict(list)
for key, z in zones.items():
par, D, m, body, cls, fam, order = pre[key]
Z = float(z[0]['zoneRadius'])
c = np.where(body, strength(m, p), 0.0); R = core_radius(m)
cpar = np.array([c[par[k]] if par[k] >= 0 else 0.0 for k in range(len(par))])
Dz = np.where(par < 0, Z, D)
s = scale(m, Dz, Z, c, cpar)
bi = np.where(body)[0]; bpos = {j: t for t, j in enumerate(bi)}
kids = [k for k in range(len(par)) if par[k] >= 0 and body[par[k]]]
a_own = {k: float(pull(tail, c[par[k]], R[par[k]], D[k], s[par[k]])) for k in kids}
for k in kids:
v = np.sqrt(a_own[k] * D[k]); vpar[cls[k]].append(v)
if cls[k] == 'Planet' and z[par[k]]['type'] == 'Sun':
vsp.append(v)
if SUN_LO <= m[par[k]] <= SUN_HI: vmed.append(v)
acc_rel = collections.defaultdict(float); mids = []
fb = fam[:, bi][:, :, None]
for dr in range(DRAWS):
pos = place(par, D, order)
A = field(pos[bi], c[bi], R[bi], s[bi], tail, pos)
Aout = np.where(fb, 0, A).sum(axis=1) # at node k, from bodies outside k's family
if disc: Aout = Aout - disc_acc(pos, c.sum(), Z)
for k in kids:
pk = par[k]; u = pos[pk] - pos[k]; u /= np.linalg.norm(u)
acc_rel[k] += np.dot(Aout[k] - Aout[pk], u) / DRAWS # Aout[k] includes the parent's own pull
th = np.arange(8) * np.pi / 4; X = 0.5 * Z * np.stack([np.cos(th), np.sin(th)], 1)
AX = field(pos[bi], c[bi], R[bi], s[bi], tail, X).sum(axis=1)
if disc: AX = AX - disc_acc(X, c.sum(), Z)
mids.append(np.mean(-(AX * X).sum(1) / np.linalg.norm(X, axis=1)))
inward[Z].append(np.mean(mids))
for k in kids:
if a_own[k] > 0: ratio[cls[k]].append(np.sqrt(max(acc_rel[k], 0) / a_own[k]))
vsp = np.array(vsp)
print(f'== [{li}] {name} ({time.time() - t0:.0f}s, {DRAWS} draws)')
for k in ['Sun', 'GasGiant', 'Planet', 'Planetoid', 'Belt']:
print(f' {k:9s} ratio p10/50/90/99 {q(ratio[k], (10, 50, 90, 99))} parent-only v p10/50/90 {q(vpar[k])} n {len(ratio[k])}')
print(f' planets round suns: parent-only v p10/50/90 {q(vsp)}; share in 12-18 u/s {np.mean((vsp >= 12) & (vsp <= 18)):.2f}')
vmed = np.array(vmed)
print(f' planets round a median sun (mass p40-p60, {SUN_LO:.0f}-{SUN_HI:.0f}): v p10/50/90 {q(vmed)}; share in 12-18 {np.mean((vmed >= 12) & (vmed <= 18)):.2f}; n {len(vmed)}')
nz = {k: sum(1 for x in vpar[k] if x == 0) for k in vpar}
print(' children with no parent pull at their distance: ' + str({k: v for k, v in nz.items() if v}))
print(' mid-zone inward pull by Z: ' + ', '.join(f'{Z:.0f}: {np.median(v):.2f}' for Z, v in sorted(inward.items())))
sys.stdout.flush()