Research-Stack/4-Infrastructure/NoDupeLabs/scripts/pulsar_marble_jar_multiscale.py

500 lines
20 KiB
Python

#!/usr/bin/env python3
"""
Pulsar Marble-Jar Multiscale Simulation
Candidate Research Stack model v0.1
Purpose
-------
Fixes the earlier human-timeframe error. A pulsar model cannot honestly use one
arbitrary dt for spin-down, vortex creep, glitch rise, and post-glitch recovery.
Those processes live on wildly separated clocks.
This script uses an event-driven multiscale loop:
cruise phase : years to centuries per step
glitch phase : milliseconds to seconds per substep
recovery phase : seconds to days per substep
Physical grammar
----------------
crustal lattice / charged component -> rigid outer jar / basin rim
neutron superfluid vortex reservoir -> marbles / chandelier filaments
vortex creep -> slow grinding of marbles in jar
pinning threshold -> stored torsion / stress limit
unpinning avalanche -> flash / phase transition
magnetic dipole braking -> slow external energy bleed
glitch spin-up -> angular momentum redistribution
Doppler beaming -> blue/red shift trace
three route weights -> genus-3 reduced circulation channels
Authority boundary
------------------
This is a simulation sketch, not proof and not an empirical fit. It is designed
to emit traces and invariants for later audit.
"""
from __future__ import annotations
import json
import math
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Dict, List, Tuple
import matplotlib.pyplot as plt
import numpy as np
# Exact SI anchors where applicable.
C = 299_792_458.0
H = 6.62607015e-34
YEAR_S = 365.25 * 24.0 * 3600.0
DAY_S = 24.0 * 3600.0
# Reference measured seed. Keep uncertainty/audit outside this sketch.
M_N = 1.67492749804e-27
KAPPA = H / (2.0 * M_N) # neutron superfluid circulation quantum proxy, m^2/s
@dataclass
class GlitchEvent:
index: int
time_years: float
rise_time_seconds: float
recovery_time_days: float
omega_c_before: float
omega_c_after: float
omega_s_before: float
omega_s_after: float
lag_before: float
lag_after: float
fractional_spin_jump: float
released_fraction: float
unpinned_macro_vortices: int
real_vortex_flux_proxy: float
flash_energy_proxy: float
angular_momentum_residual: float
doppler_before: float
doppler_after: float
route_open_field: float
route_closed_field: float
route_return_sheet: float
@dataclass
class RouteState:
"""Genus-3 reduced circulation abstraction: three noncontractible channels."""
open_field: float = 0.34
closed_field: float = 0.33
return_sheet: float = 0.33
def array(self) -> np.ndarray:
v = np.array([self.open_field, self.closed_field, self.return_sheet], dtype=float)
v = np.maximum(v, 1e-12)
return v / v.sum()
def drift(self, released_fraction: float, rng: np.random.Generator) -> "RouteState":
weights = self.array()
# Large avalanches bias the return-sheet/reconnection-like route.
kick = np.array([0.00, -0.015, 0.025]) * min(1.0, released_fraction / 0.10)
noise = rng.normal(0.0, 0.010 + 0.025 * min(1.0, released_fraction / 0.10), size=3)
updated = np.maximum(weights + kick + noise, 1e-6)
updated /= updated.sum()
return RouteState(float(updated[0]), float(updated[1]), float(updated[2]))
def doppler_factor(omega: float, radius_m: float, theta_obs_rad: float) -> float:
"""Rotational Doppler/beaming proxy D = 1/(gamma(1-beta cos theta))."""
beta = min(0.85, abs(omega * radius_m) / C)
gamma = 1.0 / math.sqrt(max(1e-14, 1.0 - beta * beta))
return 1.0 / (gamma * (1.0 - beta * math.cos(theta_obs_rad)))
def vortex_count_proxy(omega_s: float, radius_m: float) -> float:
"""Feynman relation integrated over a circular cross-section."""
area = math.pi * radius_m * radius_m
return 2.0 * omega_s * area / KAPPA
def magnetic_dipole_spin_down(omega_c: float, tau_years: float, omega_ref: float) -> float:
"""
Scaled braking law dΩ/dt = -Ω_ref/tau * (Ω/Ω_ref)^3.
This preserves the Ω^3 dipole-braking shape without pretending we fitted B,
inclination, radius, and moment of inertia from data.
Returns rad/s per year.
"""
return -(omega_ref / tau_years) * (omega_c / omega_ref) ** 3
def route_entropy(route: RouteState) -> float:
w = route.array()
return -float(np.sum(w * np.log(w + 1e-12)))
def simulate(
total_years: float = 250_000.0,
cruise_dt_years: float = 0.25,
seed: int = 7,
) -> Tuple[Dict[str, np.ndarray], List[GlitchEvent]]:
"""
Event-driven multiscale simulation.
Cruise clock uses years. Glitch and recovery use internal seconds/days-scale
maps rather than forcing the whole run through tiny timesteps.
"""
rng = np.random.default_rng(seed)
# Pulsar-shaped reference values.
radius_m = 12_000.0
theta_obs = math.radians(35.0)
omega_ref = 2.0 * math.pi * 11.2 # rad/s, Vela-like order
# Two-component moments of inertia in normalized total units.
I_total = 1.0
I_c = 0.12
I_s = I_total - I_c
omega_c = omega_ref
omega_s = omega_ref * (1.0 + 4.5e-4)
# Long clocks.
spin_down_tau_years = 1.0e6
creep_tau_years = 75.0
# Pinning/glitch settings.
lag_crit = 0.0200 # rad/s
lag_jitter = 0.0012 # disorder in pinning substrate
release_fraction_base = 0.020 # typical fractional release of lag
release_fraction_max = 0.20
# Vortex coarse-graining. One macro-vortex stands for many real vortices.
macro_vortices = 1024
route = RouteState()
glitches: List[GlitchEvent] = []
# Downsampled traces; one row per cruise step plus event discontinuities.
rows: List[Dict[str, float]] = []
time_years = 0.0
event_index = 0
L_expected = I_c * omega_c + I_s * omega_s
def append_row(kind: str) -> None:
lag = omega_s - omega_c
entropy = route_entropy(route)
phase_volume = math.exp(-75.0 * abs(lag)) * (entropy / math.log(3.0))
torsion_proxy = abs(lag) * (1.0 + 3.0 * (1.0 - entropy / math.log(3.0)))
vortex_count = vortex_count_proxy(omega_s, radius_m)
L_total = I_c * omega_c + I_s * omega_s
w = route.array()
rows.append({
"time_years": time_years,
"kind": float({"cruise": 0, "pre_glitch": 1, "post_glitch": 2, "recovery": 3}.get(kind, -1)),
"omega_c": omega_c,
"omega_s": omega_s,
"frequency_c_hz": omega_c / (2.0 * math.pi),
"frequency_s_hz": omega_s / (2.0 * math.pi),
"lag": lag,
"L_total": L_total,
"L_expected": L_expected,
"L_residual": L_total - L_expected,
"doppler": doppler_factor(omega_c, radius_m, theta_obs),
"vortex_count_proxy": vortex_count,
"torsion_proxy": torsion_proxy,
"accessible_phase_proxy": phase_volume,
"route_open_field": w[0],
"route_closed_field": w[1],
"route_return_sheet": w[2],
})
append_row("cruise")
while time_years < total_years:
lag = omega_s - omega_c
# ---- Cruise phase: years-scale integration ----
d_omega_ext = magnetic_dipole_spin_down(omega_c, spin_down_tau_years, omega_ref)
# Vortex creep / mutual friction: slow internal coupling. The sign here
# transfers angular momentum from superfluid to crust when Ω_s > Ω_c.
route_weights = route.array()
route_coupling = 0.70 * route_weights[0] + 0.45 * route_weights[1] + 1.20 * route_weights[2]
N_creep = route_coupling * lag / creep_tau_years
domega_c_dt = d_omega_ext + (I_s / I_total) * N_creep
domega_s_dt = -(I_c / I_total) * N_creep
omega_c += domega_c_dt * cruise_dt_years
omega_s += domega_s_dt * cruise_dt_years
L_expected += I_c * d_omega_ext * cruise_dt_years
time_years += cruise_dt_years
# Disorder-jittered pinning threshold.
threshold = lag_crit + lag_jitter * math.sin(2.0 * math.pi * time_years / 81.0) + rng.normal(0.0, 0.00025)
threshold = max(0.010, threshold)
if (omega_s - omega_c) >= threshold:
append_row("pre_glitch")
before_c = omega_c
before_s = omega_s
before_lag = before_s - before_c
before_L = I_c * before_c + I_s * before_s
before_doppler = doppler_factor(before_c, radius_m, theta_obs)
# ---- Glitch phase: seconds-scale event map ----
excess = max(0.0, before_lag - threshold)
release_fraction = release_fraction_base * (1.0 + excess / max(1e-9, lag_crit))
release_fraction *= (0.70 * route_weights[0] + 0.55 * route_weights[1] + 1.35 * route_weights[2])
release_fraction = float(np.clip(release_fraction, 0.004, release_fraction_max))
# Event duration is fast: seconds to minutes, larger avalanches run longer.
rise_time_seconds = float(0.06 + 42.0 * release_fraction + rng.lognormal(mean=-0.3, sigma=0.35))
recovery_time_days = float(0.15 + 18.0 * release_fraction + rng.lognormal(mean=0.6, sigma=0.45))
delta_lag = release_fraction * before_lag
# Conservation over the internal event:
# I_c ΔΩ_c + I_s ΔΩ_s = 0
# ΔΩ_c - ΔΩ_s = delta_lag
delta_omega_c = (I_s / I_total) * delta_lag
delta_omega_s = -(I_c / I_total) * delta_lag
omega_c += delta_omega_c
omega_s += delta_omega_s
after_lag = omega_s - omega_c
after_L = I_c * omega_c + I_s * omega_s
angular_residual = after_L - before_L
after_doppler = doppler_factor(omega_c, radius_m, theta_obs)
# Vortex-count proxy and macro-vortex event size.
vortex_before = vortex_count_proxy(before_s, radius_m)
vortex_after = vortex_count_proxy(omega_s, radius_m)
real_flux_proxy = abs(vortex_before - vortex_after)
fraction_of_reservoir = min(1.0, real_flux_proxy / max(1e-9, vortex_before))
unpinned_macro = max(1, int(round(macro_vortices * fraction_of_reservoir)))
# Flash-energy proxy: rotational energy exchange in normalized inertia units.
flash_energy = (
0.5 * I_s * (before_s * before_s - omega_s * omega_s)
+ 0.5 * I_c * (omega_c * omega_c - before_c * before_c)
)
event = GlitchEvent(
index=event_index,
time_years=time_years,
rise_time_seconds=rise_time_seconds,
recovery_time_days=recovery_time_days,
omega_c_before=before_c,
omega_c_after=omega_c,
omega_s_before=before_s,
omega_s_after=omega_s,
lag_before=before_lag,
lag_after=after_lag,
fractional_spin_jump=delta_omega_c / before_c,
released_fraction=release_fraction,
unpinned_macro_vortices=unpinned_macro,
real_vortex_flux_proxy=real_flux_proxy,
flash_energy_proxy=flash_energy,
angular_momentum_residual=angular_residual,
doppler_before=before_doppler,
doppler_after=after_doppler,
route_open_field=float(route_weights[0]),
route_closed_field=float(route_weights[1]),
route_return_sheet=float(route_weights[2]),
)
glitches.append(event)
event_index += 1
append_row("post_glitch")
# ---- Recovery phase: seconds-to-days-scale map ----
# We do not integrate every second in the long run; instead we apply a
# small relaxation correction and advance the physical clock by days.
rec_years = recovery_time_days / 365.25
rec_coupling = min(0.15, 0.02 + 0.12 * release_fraction)
rec_transfer = rec_coupling * (omega_s - omega_c)
omega_c += (I_s / I_total) * rec_transfer
omega_s -= (I_c / I_total) * rec_transfer
time_years += rec_years
route = route.drift(release_fraction, rng)
append_row("recovery")
else:
append_row("cruise")
# Convert row dicts to arrays.
keys = list(rows[0].keys())
traces = {k: np.array([r[k] for r in rows], dtype=float) for k in keys}
return traces, glitches
def plot_outputs(traces: Dict[str, np.ndarray], glitches: List[GlitchEvent], outdir: Path) -> None:
outdir.mkdir(parents=True, exist_ok=True)
t = traces["time_years"]
glitch_times = [g.time_years for g in glitches]
plt.figure(figsize=(12, 5))
plt.plot(t, traces["frequency_c_hz"], label="crust / charged component")
plt.plot(t, traces["frequency_s_hz"], label="superfluid reservoir", alpha=0.70)
for gt in glitch_times:
plt.axvline(gt, alpha=0.18, linewidth=0.6)
plt.title("Multiscale pulsar spin-down with event-driven glitch flashes")
plt.xlabel("physical time (years)")
plt.ylabel("frequency proxy (Hz)")
plt.legend()
plt.tight_layout()
plt.savefig(outdir / "multiscale_spin_down.png", dpi=180)
plt.close()
plt.figure(figsize=(12, 5))
plt.plot(t, traces["lag"], label="lag Ω_s - Ω_c")
plt.plot(t, traces["torsion_proxy"], label="torsion proxy", alpha=0.82)
plt.plot(t, traces["accessible_phase_proxy"], label="accessible phase volume proxy", alpha=0.82)
for gt in glitch_times:
plt.axvline(gt, alpha=0.18, linewidth=0.6)
plt.title("Slow lag accumulation, torsion growth, and phase-volume contraction")
plt.xlabel("physical time (years)")
plt.legend()
plt.tight_layout()
plt.savefig(outdir / "multiscale_lag_torsion_phase.png", dpi=180)
plt.close()
plt.figure(figsize=(12, 5))
plt.plot(t, traces["L_residual"], label="angular momentum residual")
for gt in glitch_times:
plt.axvline(gt, alpha=0.18, linewidth=0.6)
plt.title("Conservation audit: internal glitches should not create angular momentum")
plt.xlabel("physical time (years)")
plt.ylabel("L_total - integrated external torque")
plt.legend()
plt.tight_layout()
plt.savefig(outdir / "multiscale_angular_momentum_residual.png", dpi=180)
plt.close()
plt.figure(figsize=(12, 5))
plt.plot(t, traces["route_open_field"], label="cycle 1: open-field route")
plt.plot(t, traces["route_closed_field"], label="cycle 2: closed-field route")
plt.plot(t, traces["route_return_sheet"], label="cycle 3: return-sheet route")
for gt in glitch_times:
plt.axvline(gt, alpha=0.18, linewidth=0.6)
plt.title("Genus-3 route weights drift after avalanche events")
plt.xlabel("physical time (years)")
plt.ylabel("route weight")
plt.legend()
plt.tight_layout()
plt.savefig(outdir / "multiscale_genus3_routes.png", dpi=180)
plt.close()
plt.figure(figsize=(12, 5))
plt.plot(t, traces["doppler"], label="Doppler / beaming factor")
vortex_norm = traces["vortex_count_proxy"] / max(1e-12, np.max(traces["vortex_count_proxy"]))
plt.plot(t, vortex_norm, label="vortex count proxy normalized", alpha=0.82)
for gt in glitch_times:
plt.axvline(gt, alpha=0.18, linewidth=0.6)
plt.title("Blue/red-shift proxy and vortex reservoir trace")
plt.xlabel("physical time (years)")
plt.legend()
plt.tight_layout()
plt.savefig(outdir / "multiscale_doppler_vortex.png", dpi=180)
plt.close()
if glitches:
sizes = np.array([g.fractional_spin_jump for g in glitches])
waits = np.diff(np.array([g.time_years for g in glitches])) if len(glitches) > 1 else np.array([])
plt.figure(figsize=(10, 5))
plt.scatter([g.time_years for g in glitches], sizes, s=22)
plt.yscale("log")
plt.title("Glitch sizes over physical time")
plt.xlabel("physical time (years)")
plt.ylabel("fractional crust spin jump ΔΩ_c/Ω_c")
plt.tight_layout()
plt.savefig(outdir / "multiscale_glitch_sizes.png", dpi=180)
plt.close()
if len(waits) > 0:
plt.figure(figsize=(10, 5))
plt.hist(waits, bins=min(30, max(5, len(waits) // 2)))
plt.title("Inter-glitch waiting-time distribution")
plt.xlabel("years")
plt.ylabel("count")
plt.tight_layout()
plt.savefig(outdir / "multiscale_waiting_times.png", dpi=180)
plt.close()
def write_outputs(traces: Dict[str, np.ndarray], glitches: List[GlitchEvent], outdir: Path) -> Dict[str, object]:
outdir.mkdir(parents=True, exist_ok=True)
glitch_times = np.array([g.time_years for g in glitches], dtype=float)
waits = np.diff(glitch_times) if len(glitch_times) > 1 else np.array([])
report = {
"model_id": "pulsar_marble_jar_multiscale_v0",
"status": "HOLD",
"proof_status": "simulation_sketch_not_proof",
"timeframe_fix": "Cruise uses physical years; glitch rise uses seconds; recovery uses days. No arbitrary single dt controls all regimes.",
"rule": "No canonical vertical in n-space. Descent is increasing torsion, decreasing accessible phase volume, and loss of available free energy.",
"constants": {
"c_exact_SI_m_s": C,
"h_exact_SI_J_s": H,
"m_n_measured_reference_kg": M_N,
"kappa_proxy_h_over_2mn_m2_s": KAPPA,
},
"summary": {
"trace_rows": int(len(traces["time_years"])),
"simulated_years": float(traces["time_years"][-1]),
"glitch_count": len(glitches),
"mean_wait_years": float(np.mean(waits)) if len(waits) else None,
"median_wait_years": float(np.median(waits)) if len(waits) else None,
"initial_crust_frequency_hz": float(traces["frequency_c_hz"][0]),
"final_crust_frequency_hz": float(traces["frequency_c_hz"][-1]),
"max_lag_rad_s": float(np.max(traces["lag"])),
"max_torsion_proxy": float(np.max(traces["torsion_proxy"])),
"min_accessible_phase_proxy": float(np.min(traces["accessible_phase_proxy"])),
"max_abs_angular_momentum_residual": float(np.max(np.abs(traces["L_residual"]))),
"max_doppler_factor": float(np.max(traces["doppler"])),
"max_fractional_spin_jump": float(max([g.fractional_spin_jump for g in glitches], default=0.0)),
},
"glitches": [asdict(g) for g in glitches],
"outputs": [
str(outdir / "multiscale_spin_down.png"),
str(outdir / "multiscale_lag_torsion_phase.png"),
str(outdir / "multiscale_angular_momentum_residual.png"),
str(outdir / "multiscale_genus3_routes.png"),
str(outdir / "multiscale_doppler_vortex.png"),
str(outdir / "multiscale_glitch_sizes.png"),
str(outdir / "multiscale_waiting_times.png"),
str(outdir / "pulsar_marble_jar_multiscale_report.json"),
str(outdir / "pulsar_marble_jar_multiscale_traces.csv"),
],
"acceptance_tests": {
"lag_accumulates_between_glitches": "inspect lag sawtooth in multiscale_lag_torsion_phase.png",
"glitch_spinup_occurs": "glitch log should show positive fractional_spin_jump",
"internal_event_conserves_angular_momentum": "angular_momentum_residual should remain bounded near numerical/event-map tolerance",
"route_weights_change_after_events": "multiscale_genus3_routes.png should show event-linked drift",
"time_scales_are_separated": "report includes years for cruise, seconds for rise, days for recovery",
},
"next_gate": "Compare trace classes against real pulsar glitch phenomenology; keep HOLD until validated against data/literature.",
}
(outdir / "pulsar_marble_jar_multiscale_report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
names = list(traces.keys())
matrix = np.column_stack([traces[name] for name in names])
np.savetxt(outdir / "pulsar_marble_jar_multiscale_traces.csv", matrix, delimiter=",", header=",".join(names), comments="")
return report
def main() -> None:
outdir = Path("research-stack/models/pulsar_marble_jar_multiscale_outputs")
traces, glitches = simulate()
plot_outputs(traces, glitches, outdir)
report = write_outputs(traces, glitches, outdir)
print(json.dumps(report["summary"], indent=2))
if __name__ == "__main__":
main()