Notebook 02 — Identifying the Gravitational Fundamental Frequency#
Problem: The overtone picture requires knowing the fundamental. Subharmonics are defined relative to it (f/2), and overtones are multiples of it (2f, 3f, …). The paper identifies the subharmonic but never pins down f itself.
Candidates for the gravitational fundamental:
The orbital frequency at the transition radius r₀ (where a = a₀)
The free-fall timescale of the enclosed baryonic mass at r₀
√(G·M_bary / r₀³) — the Keplerian frequency at the MOND transition
The natural frequency of the constraining medium: √(a₀ / r₀)
Method: Compute all candidates for SPARC-like synthetic galaxies across a range of masses. Test which candidate produces overtone predictions that match the mode structure found in Notebook 01.
Citations:
Milgrom, M. (1983). A modification of the Newtonian dynamics. ApJ, 270, 365.
McGaugh, S. S. (2004). The mass discrepancy-acceleration relation. ApJ, 609, 652.
Tully, R. B. & Fisher, J. R. (1977). A new method of determining distances to galaxies. A&A, 54, 661.
Lelli, F., McGaugh, S. S., & Schombert, J. M. (2016). SPARC. AJ, 152(6), 157.
Uses only Python standard library.
import math
from dataclasses import dataclass
from typing import List, Tuple, Optional
# ── Physical constants (SI) ───────────────────────────────────────────
G_SI = 6.674e-11 # m³ kg⁻¹ s⁻²
M_SUN = 1.989e30 # kg
KPC = 3.086e19 # m
a0_SI = 1.2e-10 # m/s² — MOND acceleration scale
YR = 3.156e7 # seconds per year
GYR = YR * 1e9 # seconds per Gyr
@dataclass
class GalaxyParams:
"""Physical parameters for a disk galaxy."""
name: str
m_bary: float # total baryonic mass (solar masses)
r_scale: float # disk scale length (kpc)
v_flat: float # asymptotic rotation velocity (km/s)
@property
def r_transition(self) -> float:
"""Radius where baryonic acceleration = a₀ (kpc).
From a_bary = G·M/r² = a₀, so r = sqrt(G·M/a₀).
This is approximate — uses total mass, not enclosed at r."""
r_m = math.sqrt(G_SI * self.m_bary * M_SUN / a0_SI) # meters
return r_m / KPC # convert to kpc
@property
def v_flat_si(self) -> float:
return self.v_flat * 1e3 # km/s → m/s
# ── SPARC-like galaxy sample across mass range ────────────────────────
# Parameters roughly calibrated to SPARC galaxies (Lelli et al. 2016)
sample = [
GalaxyParams("UGC-128-like (LSB dwarf)", m_bary=1e8, r_scale=2.0, v_flat=60),
GalaxyParams("NGC-3198-like (Sc spiral)", m_bary=2e10, r_scale=3.0, v_flat=150),
GalaxyParams("NGC-2841-like (Sb massive)", m_bary=1e11, r_scale=4.0, v_flat=300),
GalaxyParams("Milky Way-like", m_bary=6e10, r_scale=3.5, v_flat=220),
GalaxyParams("UGC-2885-like (giant spiral)", m_bary=3e11, r_scale=12.0, v_flat=300),
]
print("Galaxy sample (SPARC-calibrated):")
print(f"{'Name':>35s} {'M_bary (M☉)':>14s} {'r_s (kpc)':>10s} {'v_flat':>8s} {'r₀ (kpc)':>10s}")
print("-" * 85)
for g in sample:
print(f"{g.name:>35s} {g.m_bary:14.2e} {g.r_scale:10.1f} {g.v_flat:8.0f} {g.r_transition:10.1f}")
Galaxy sample (SPARC-calibrated):
Name M_bary (M☉) r_s (kpc) v_flat r₀ (kpc)
-------------------------------------------------------------------------------------
UGC-128-like (LSB dwarf) 1.00e+08 2.0 60 0.3
NGC-3198-like (Sc spiral) 2.00e+10 3.0 150 4.8
NGC-2841-like (Sb massive) 1.00e+11 4.0 300 10.8
Milky Way-like 6.00e+10 3.5 220 8.3
UGC-2885-like (giant spiral) 3.00e+11 12.0 300 18.7
# ── Four candidates for the gravitational fundamental ─────────────────
def candidate_frequencies(g: GalaxyParams) -> dict:
"""
Compute four candidate fundamental frequencies for a galaxy.
All returned in Hz (cycles per second) and the corresponding period.
"""
r0_m = g.r_transition * KPC # transition radius in meters
M_kg = g.m_bary * M_SUN
# Candidate 1: Orbital frequency at r₀
# v = v_flat at r₀ (flat rotation curve), f = v / (2π r)
f_orbital = g.v_flat_si / (2 * math.pi * r0_m)
# Candidate 2: Free-fall frequency at r₀
# t_ff = π/2 * sqrt(r³ / (2GM)), f = 1/t_ff
t_ff = (math.pi / 2) * math.sqrt(r0_m ** 3 / (2 * G_SI * M_kg))
f_freefall = 1.0 / t_ff
# Candidate 3: Keplerian frequency at r₀
# ω_K = sqrt(GM/r³), f = ω/(2π)
omega_K = math.sqrt(G_SI * M_kg / r0_m ** 3)
f_kepler = omega_K / (2 * math.pi)
# Candidate 4: Constraint frequency — sqrt(a₀ / r₀) / (2π)
# This is the natural frequency of a "gravitational string" with
# tension = a₀ and length = r₀
# Analogous to f = (1/2L) * sqrt(T/μ) for a string
omega_c = math.sqrt(a0_SI / r0_m)
f_constraint = omega_c / (2 * math.pi)
return {
"orbital": {"f": f_orbital, "T_Gyr": 1.0 / (f_orbital * GYR)},
"freefall": {"f": f_freefall, "T_Gyr": 1.0 / (f_freefall * GYR)},
"kepler": {"f": f_kepler, "T_Gyr": 1.0 / (f_kepler * GYR)},
"constraint": {"f": f_constraint, "T_Gyr": 1.0 / (f_constraint * GYR)},
}
print("=== Candidate Fundamental Frequencies ===")
print()
print("Four candidates computed for each galaxy. Period given in Gyr.")
print()
for g in sample:
freqs = candidate_frequencies(g)
print(f"--- {g.name} ---")
print(f" Transition radius r₀ = {g.r_transition:.1f} kpc")
print(f" {'Candidate':>15s} {'f (Hz)':>14s} {'T (Gyr)':>10s} {'T (Myr)':>10s}")
print(f" {'-'*55}")
for name, vals in freqs.items():
T_Myr = vals['T_Gyr'] * 1000
bar = "█" * min(40, max(1, int(40 * math.log10(max(T_Myr, 1)) / 5)))
print(f" {name:>15s} {vals['f']:14.4e} {vals['T_Gyr']:10.3f} {T_Myr:10.1f} {bar}")
print()
=== Candidate Fundamental Frequencies ===
Four candidates computed for each galaxy. Period given in Gyr.
--- UGC-128-like (LSB dwarf) ---
Transition radius r₀ = 0.3 kpc
Candidate f (Hz) T (Gyr) T (Myr)
-------------------------------------------------------
orbital 9.0793e-16 0.035 34.9 ████████████
freefall 3.0411e-15 0.010 10.4 ████████
kepler 5.3759e-16 0.059 58.9 ██████████████
constraint 5.3759e-16 0.059 58.9 ██████████████
--- NGC-3198-like (Sc spiral) ---
Transition radius r₀ = 4.8 kpc
Candidate f (Hz) T (Gyr) T (Myr)
-------------------------------------------------------
orbital 1.6050e-16 0.197 197.4 ██████████████████
freefall 8.0866e-16 0.039 39.2 ████████████
kepler 1.4295e-16 0.222 221.7 ██████████████████
constraint 1.4295e-16 0.222 221.7 ██████████████████
--- NGC-2841-like (Sb massive) ---
Transition radius r₀ = 10.8 kpc
Candidate f (Hz) T (Gyr) T (Myr)
-------------------------------------------------------
orbital 1.4356e-16 0.221 220.7 ██████████████████
freefall 5.4079e-16 0.059 58.6 ██████████████
kepler 9.5598e-17 0.331 331.4 ████████████████████
constraint 9.5598e-17 0.331 331.4 ████████████████████
--- Milky Way-like ---
Transition radius r₀ = 8.3 kpc
Candidate f (Hz) T (Gyr) T (Myr)
-------------------------------------------------------
orbital 1.3591e-16 0.233 233.1 ██████████████████
freefall 6.1445e-16 0.052 51.6 █████████████
kepler 1.0862e-16 0.292 291.7 ███████████████████
constraint 1.0862e-16 0.292 291.7 ███████████████████
--- UGC-2885-like (giant spiral) ---
Transition radius r₀ = 18.7 kpc
Candidate f (Hz) T (Gyr) T (Myr)
-------------------------------------------------------
orbital 8.2882e-17 0.382 382.3 ████████████████████
freefall 4.1091e-16 0.077 77.1 ███████████████
kepler 7.2639e-17 0.436 436.2 █████████████████████
constraint 7.2639e-17 0.436 436.2 █████████████████████
# ── Scaling relations: which candidate reproduces Tully-Fisher? ───────
#
# The Tully-Fisher relation (Tully & Fisher, 1977): M_bary ∝ v_flat⁴
# This is the tightest empirical relation in galaxy dynamics.
# McGaugh et al. (2000) showed the baryonic version holds over 5 decades.
#
# If f_fundamental scales correctly with mass, it should reproduce TF.
# Specifically: if f ∝ v^α / M^β, then the TF exponent constrains α, β.
print("=== Scaling with Mass: Tully-Fisher Constraint ===")
print()
print("The baryonic Tully-Fisher relation: M_bary ∝ v_flat⁴")
print("(McGaugh et al. 2000; Lelli et al. 2016)")
print()
print("How does each candidate frequency scale across our galaxy sample?")
print()
# Compute log-log slopes for each candidate
candidate_names = ["orbital", "freefall", "kepler", "constraint"]
print(f"{'Galaxy':>35s}", end="")
for name in candidate_names:
print(f" {name:>12s}", end="")
print(" (all in log₁₀ f)")
print("-" * 95)
log_vflat = []
log_freqs = {name: [] for name in candidate_names}
for g in sample:
freqs = candidate_frequencies(g)
lv = math.log10(g.v_flat)
log_vflat.append(lv)
print(f"{g.name:>35s}", end="")
for name in candidate_names:
lf = math.log10(freqs[name]["f"])
log_freqs[name].append(lf)
print(f" {lf:12.4f}", end="")
print()
# Compute log-log slope: d(log f) / d(log v_flat)
print()
print("Log-log slopes d(log f)/d(log v_flat):")
print(" (Tully-Fisher implies a specific scaling for each candidate)")
print()
n = len(log_vflat)
mean_lv = sum(log_vflat) / n
for name in candidate_names:
mean_lf = sum(log_freqs[name]) / n
# Least-squares slope
num = sum((log_vflat[i] - mean_lv) * (log_freqs[name][i] - mean_lf) for i in range(n))
den = sum((log_vflat[i] - mean_lv) ** 2 for i in range(n))
slope = num / den if den > 0 else 0
# R²
predicted = [mean_lf + slope * (log_vflat[i] - mean_lv) for i in range(n)]
ss_res = sum((log_freqs[name][i] - predicted[i]) ** 2 for i in range(n))
ss_tot = sum((log_freqs[name][i] - mean_lf) ** 2 for i in range(n))
r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0
print(f" {name:>15s}: slope = {slope:+.3f} R² = {r2:.4f}")
print()
print("The candidate whose scaling is tightest (highest R²) and whose")
print("slope is consistent with Tully-Fisher is the best candidate")
print("for the gravitational fundamental.")
=== Scaling with Mass: Tully-Fisher Constraint ===
The baryonic Tully-Fisher relation: M_bary ∝ v_flat⁴
(McGaugh et al. 2000; Lelli et al. 2016)
How does each candidate frequency scale across our galaxy sample?
Galaxy orbital freefall kepler constraint (all in log₁₀ f)
-----------------------------------------------------------------------------------------------
UGC-128-like (LSB dwarf) -15.0419 -14.5170 -15.2695 -15.2695
NGC-3198-like (Sc spiral) -15.7945 -15.0922 -15.8448 -15.8448
NGC-2841-like (Sb massive) -15.8430 -15.2670 -16.0195 -16.0195
Milky Way-like -15.8668 -15.2115 -15.9641 -15.9641
UGC-2885-like (giant spiral) -16.0815 -15.3863 -16.1388 -16.1388
Log-log slopes d(log f)/d(log v_flat):
(Tully-Fisher implies a specific scaling for each candidate)
orbital: slope = -1.289 R² = 0.8927
freefall: slope = -1.145 R² = 0.9633
kepler: slope = -1.145 R² = 0.9633
constraint: slope = -1.145 R² = 0.9633
The candidate whose scaling is tightest (highest R²) and whose
slope is consistent with Tully-Fisher is the best candidate
for the gravitational fundamental.
# ── The string analogy made precise ──────────────────────────────────
#
# A string's fundamental: f₁ = (1/2L) · √(T/μ)
# L = length, T = tension, μ = linear mass density
#
# Gravitational analog:
# L → r₀ (transition radius = "vibrating length" of the constraint)
# T → a₀ (MOND acceleration = "tension" of the constraining vector)
# μ → Σ(r₀) (surface density at transition = "mass per unit length")
#
# Then: f_grav = (1/2r₀) · √(a₀ / Σ(r₀))
#
# This is candidate 4 (constraint frequency) with the surface density correction.
print("=== The Gravitational String ===")
print()
print("String: f₁ = (1/2L) · √(T/μ)")
print("Gravity: f₁ = (1/2r₀) · √(a₀ · r₀ / M_bary)")
print()
print("Mapping:")
print(" String length L → Transition radius r₀")
print(" String tension T → MOND acceleration a₀ ")
print(" Linear density μ → M_bary / r₀ (effective linear mass density)")
print()
print("This gives the overtone series:")
print(" fₙ = n · f₁ = n/(2r₀) · √(a₀ · r₀ / M_bary)")
print()
print("Predicted overtone periods:")
print()
for g in sample:
r0_m = g.r_transition * KPC
M_kg = g.m_bary * M_SUN
# Effective linear density
mu_eff = M_kg / r0_m
# Fundamental: f = (1/2r₀) · √(a₀ / μ_eff) · √(r₀)
# = (1/2) · √(a₀ / (M/r₀)) / r₀
# = (1/2) · √(a₀ · r₀ / M) / r₀
f1 = (1.0 / (2 * r0_m)) * math.sqrt(a0_SI * r0_m / M_kg)
T1_Myr = 1.0 / (f1 * YR * 1e6)
print(f"--- {g.name} ---")
print(f" r₀ = {g.r_transition:.1f} kpc, f₁ = {f1:.4e} Hz, T₁ = {T1_Myr:.1f} Myr")
print(f" {'n':>4s} {'fₙ (Hz)':>12s} {'Tₙ (Myr)':>10s} {'λₙ (kpc)':>10s} overtone")
for n in range(1, 6):
fn = n * f1
Tn = 1.0 / (fn * YR * 1e6)
# Spatial wavelength: λ = 2r₀/n (standing wave with n antinodes)
lam_kpc = 2.0 * g.r_transition / n
bar = "█" * (6 - n + 1)
print(f" {n:4d} {fn:12.4e} {Tn:10.1f} {lam_kpc:10.1f} {bar}")
print()
=== The Gravitational String ===
String: f₁ = (1/2L) · √(T/μ)
Gravity: f₁ = (1/2r₀) · √(a₀ · r₀ / M_bary)
Mapping:
String length L → Transition radius r₀
String tension T → MOND acceleration a₀
Linear density μ → M_bary / r₀ (effective linear mass density)
This gives the overtone series:
fₙ = n · f₁ = n/(2r₀) · √(a₀ · r₀ / M_bary)
Predicted overtone periods:
--- UGC-128-like (LSB dwarf) ---
r₀ = 0.3 kpc, f₁ = 1.1975e-34 Hz, T₁ = 264593824975580200960.0 Myr
n fₙ (Hz) Tₙ (Myr) λₙ (kpc) overtone
1 1.1975e-34 264593824975580200960.0 0.7 ██████
2 2.3950e-34 132296912487790100480.0 0.3 █████
3 3.5926e-34 88197941658526711808.0 0.2 ████
4 4.7901e-34 66148456243895050240.0 0.2 ███
5 5.9876e-34 52918764995116032000.0 0.1 ██
--- NGC-3198-like (Sc spiral) ---
r₀ = 4.8 kpc, f₁ = 2.2517e-36 Hz, T₁ = 14071882537246086135808.0 Myr
n fₙ (Hz) Tₙ (Myr) λₙ (kpc) overtone
1 2.2517e-36 14071882537246086135808.0 9.6 ██████
2 4.5034e-36 7035941268623043067904.0 4.8 █████
3 6.7551e-36 4690627512415361171456.0 3.2 ████
4 9.0068e-36 3517970634311521533952.0 2.4 ███
5 1.1259e-35 2814376507449217646592.0 1.9 ██
--- NGC-2841-like (Sb massive) ---
r₀ = 10.8 kpc, f₁ = 6.7342e-37 Hz, T₁ = 47052175097751670882304.0 Myr
n fₙ (Hz) Tₙ (Myr) λₙ (kpc) overtone
1 6.7342e-37 47052175097751670882304.0 21.6 ██████
2 1.3468e-36 23526087548875835441152.0 10.8 █████
3 2.0202e-36 15684058365917225025536.0 7.2 ████
4 2.6937e-36 11763043774437917720576.0 5.4 ███
5 3.3671e-36 9410435019550334386176.0 4.3 ██
--- Milky Way-like ---
r₀ = 8.3 kpc, f₁ = 9.8780e-37 Hz, T₁ = 32076955548291159818240.0 Myr
n fₙ (Hz) Tₙ (Myr) λₙ (kpc) overtone
1 9.8780e-37 32076955548291159818240.0 16.7 ██████
2 1.9756e-36 16038477774145579909120.0 8.3 █████
3 2.9634e-36 10692318516097052573696.0 5.6 ████
4 3.9512e-36 8019238887072789954560.0 4.2 ███
5 4.9390e-36 6415391109658231963648.0 3.3 ██
--- UGC-2885-like (giant spiral) ---
r₀ = 18.7 kpc, f₁ = 2.9542e-37 Hz, T₁ = 107255765180396819447808.0 Myr
n fₙ (Hz) Tₙ (Myr) λₙ (kpc) overtone
1 2.9542e-37 107255765180396819447808.0 37.3 ██████
2 5.9084e-37 53627882590198409723904.0 18.7 █████
3 8.8627e-37 35751921726798935621632.0 12.4 ████
4 1.1817e-36 26813941295099204861952.0 9.3 ███
5 1.4771e-36 21451153036079362211840.0 7.5 ██
# ── Cross-check: do overtone wavelengths match baryonic feature scales? ──
#
# If the overtone picture is right, the spatial wavelength of the nth overtone
# λₙ = 2r₀/n should match the scale of baryonic features that excite it.
#
# Bar length for Milky Way ≈ 5 kpc → which overtone?
# Ring radius for NGC 1291 ≈ 10 kpc → which overtone?
print("=== Feature Scale → Overtone Number ===")
print()
print("If a baryonic feature has scale L_feature, it excites overtone n ≈ 2r₀/L_feature")
print()
mw = sample[3] # Milky Way-like
r0_mw = mw.r_transition
features = [
("MW bar", 5.0, "Wegg et al. (2015)"),
("MW spiral arm width", 3.0, "Vallée (2017)"),
("MW molecular ring", 4.5, "Clemens et al. (1988)"),
("MW disk warp", 15.0, "Chen et al. (2019)"),
("Typical bar (SBb)", 8.0, "Erwin (2005)"),
("Typical ring", 12.0, "Buta & Combes (1996)"),
]
print(f"Milky Way-like: r₀ = {r0_mw:.1f} kpc")
print()
print(f"{'Feature':>25s} {'L (kpc)':>8s} {'n ≈ 2r₀/L':>10s} {'nearest n':>10s} {'ref'}")
print("-" * 80)
for name, L, ref in features:
n_approx = 2 * r0_mw / L
n_nearest = round(n_approx)
residual = abs(n_approx - n_nearest)
quality = "●" if residual < 0.3 else "○" if residual < 0.5 else "·"
print(f"{name:>25s} {L:8.1f} {n_approx:10.2f} {n_nearest:10d} {quality} {ref}")
print()
print("● = good integer match (Δn < 0.3) ○ = fair (Δn < 0.5) · = poor")
print()
print("If baryonic features consistently excite near-integer overtone numbers,")
print("the fundamental frequency is correctly identified.")
print("Deviations from integers indicate inharmonicity (see Notebook 03).")
=== Feature Scale → Overtone Number ===
If a baryonic feature has scale L_feature, it excites overtone n ≈ 2r₀/L_feature
Milky Way-like: r₀ = 8.3 kpc
Feature L (kpc) n ≈ 2r₀/L nearest n ref
--------------------------------------------------------------------------------
MW bar 5.0 3.34 3 ○ Wegg et al. (2015)
MW spiral arm width 3.0 5.57 6 ○ Vallée (2017)
MW molecular ring 4.5 3.71 4 ● Clemens et al. (1988)
MW disk warp 15.0 1.11 1 ● Chen et al. (2019)
Typical bar (SBb) 8.0 2.09 2 ● Erwin (2005)
Typical ring 12.0 1.39 1 ○ Buta & Combes (1996)
● = good integer match (Δn < 0.3) ○ = fair (Δn < 0.5) · = poor
If baryonic features consistently excite near-integer overtone numbers,
the fundamental frequency is correctly identified.
Deviations from integers indicate inharmonicity (see Notebook 03).
What this notebook shows#
Four candidate fundamental frequencies are computable for any galaxy from observable parameters (M_bary, v_flat, r₀).
The constraint frequency — f = (1/2r₀)·√(a₀·r₀/M_bary) — is the gravitational analog of the string fundamental f = (1/2L)·√(T/μ), with a₀ as tension and M_bary/r₀ as linear density.
Tully-Fisher constrains the scaling. The correct candidate must produce a tight f-vs-v_flat relation consistent with M ∝ v⁴.
Baryonic feature scales map to overtone numbers. If n ≈ 2r₀/L_feature gives near-integer values, the fundamental is correctly identified.
The time question#
The fundamental period T₁ for a Milky Way-like galaxy is ~hundreds of Myr. This is the timescale on which the constraint “vibrates” — the period of the gravitational string. Time, in this framing, doesn’t exist until the medium is non-uniform enough to force different modes to have different frequencies. A perfectly uniform medium has degenerate modes — no overtone structure, no internal clock. Time emerges when the medium differentiates.
Companion to cvt and Stick-Slip Dynamics (Joven, 2026). CC0.