Notebook 02 — Identifying the Gravitational Fundamental Frequency

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:

  1. The orbital frequency at the transition radius r₀ (where a = a₀)

  2. The free-fall timescale of the enclosed baryonic mass at r₀

  3. √(G·M_bary / r₀³) — the Keplerian frequency at the MOND transition

  4. 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#

  1. Four candidate fundamental frequencies are computable for any galaxy from observable parameters (M_bary, v_flat, r₀).

  2. 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.

  3. Tully-Fisher constrains the scaling. The correct candidate must produce a tight f-vs-v_flat relation consistent with M ∝ v⁴.

  4. 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.