Skip to content
The Lumenary

Math laboratory

Point counts mod p: Hasse, Sato-Tate and Birch-Swinnerton-Dyer

Counts points of four elliptic curves over F_p for all primes p < 1000 (exact). Checks the Hasse bound in every case, tabulates the Sato-Tate angles of 37a1 against the predicted density, and recomputes the Euler products that led Birch and Swinnerton-Dyer to their conjecture. The last two parts are numerical illustrations, not evidence strong enough to prove or even test the asymptotic statements.

Back to the labExact computationExhaustive finite checkNumerical approximation
Point counts mod p: Hasse, Sato-Tate and Birch-Swinnerton-DyerPythonExact computationExhaustive finite checkNumerical approximationComputationally tested
"""Counting points over finite fields: Hasse, Sato-Tate and Birch-Swinnerton-Dyer.

For an elliptic curve E over Q with integer coefficients and a prime p, let
N_p be the number of points of E over F_p (including the point at infinity)
and a_p = p + 1 - N_p. We count points exactly for all p below a bound and

1. check Hasse's bound |a_p| <= 2 sqrt(p) (a theorem; here a finite check);
2. compare the angles theta_p in a_p = 2 sqrt(p) cos(theta_p) with the
   Sato-Tate density (2/pi) sin^2(theta) (a theorem for curves like 37a1,
   proved 2008-2011; here a small sample);
3. repeat Birch and Swinnerton-Dyer's 1960s experiment: the partial products
   prod_{p <= X} N_p / p for curves of rank 0, 1, 2 and 3. Their conjecture
   grew out of the observation that these grow roughly like (log X)^rank.

Ranks and conductors are quoted from Cremona's tables, not computed here.
"""

import math

#: Weierstrass coefficients (a1, a2, a3, a4, a6) and ranks from Cremona's tables.
CURVES = {
    "11a1": ((0, -1, 1, -10, -20), 0),
    "37a1": ((0, 0, 1, -1, 0), 1),
    "389a1": ((0, 1, 1, -2, 0), 2),
    "5077a1": ((0, 0, 1, -7, 6), 3),
}

def primes_below(n):
    """All primes p < n (sieve of Eratosthenes)."""
    sieve = bytearray([1]) * max(n, 2)
    sieve[0] = sieve[1] = 0
    for i in range(2, int(n ** 0.5) + 1):
        if sieve[i]:
            sieve[i * i::i] = bytearray(len(range(i * i, n, i)))
    return [i for i in range(n) if sieve[i]]

def count_points(coeffs, p):
    """N_p: points of y^2 + a1 xy + a3 y = x^3 + a2 x^2 + a4 x + a6 over F_p."""
    a1, a2, a3, a4, a6 = coeffs
    if p == 2:
        return 1 + sum(1 for x in range(2) for y in range(2)
                       if (y * y + a1 * x * y + a3 * y
                           - x ** 3 - a2 * x * x - a4 * x - a6) % 2 == 0)
    # Complete the square: (2y + a1 x + a3)^2 = 4(x^3 + a2 x^2 + a4 x + a6)
    # + (a1 x + a3)^2, and count square roots of the right-hand side.
    roots = [0] * p
    for y in range(p):
        roots[y * y % p] += 1
    total = 1  # the point at infinity
    for x in range(p):
        rhs = 4 * (x ** 3 + a2 * x * x + a4 * x + a6) + (a1 * x + a3) ** 2
        total += roots[rhs % p]
    return total

def trace_of_frobenius(coeffs, p):
    """a_p = p + 1 - N_p."""
    return p + 1 - count_points(coeffs, p)

def sato_tate_mass(lo, hi):
    """Integral of (2/pi) sin^2(t) dt from lo to hi."""
    def F(t):
        return (t - math.sin(t) * math.cos(t)) / math.pi
    return F(hi) - F(lo)

def euler_products(coeffs, checkpoints, primes):
    """prod_{p <= X} N_p / p at each X in checkpoints (sorted)."""
    out, prod, k = [], 1.0, 0
    for p in primes:
        while k < len(checkpoints) and p > checkpoints[k]:
            out.append(prod)
            k += 1
        prod *= count_points(coeffs, p) / p
    while k < len(checkpoints):
        out.append(prod)
        k += 1
    return out

def main():
    primes = primes_below(1000)
    coeffs37 = CURVES["37a1"][0]
    good = [p for p in primes if p != 37]
    ratios = [abs(trace_of_frobenius(coeffs37, p)) / (2 * math.sqrt(p))
              for p in good]
    worst = max(range(len(good)), key=lambda i: ratios[i])
    print(f"37a1: y^2 + y = x^3 - x, all {len(good)} good primes p < 1000")
    print(f"1. Hasse: max |a_p| / (2 sqrt p) = {ratios[worst]:.4f} "
          f"(at p = {good[worst]}); <= 1 every time")
    print()
    print("2. Sato-Tate angles theta_p, a_p = 2 sqrt(p) cos(theta_p):")
    print("   bin (degrees)   observed   Sato-Tate prediction")
    angles = [math.acos(trace_of_frobenius(coeffs37, p) / (2 * math.sqrt(p)))
              for p in good]
    for k in range(6):
        lo, hi = k * math.pi / 6, (k + 1) * math.pi / 6
        seen = sum(1 for t in angles if lo <= t < hi or (k == 5 and t == hi))
        expected = len(angles) * sato_tate_mass(lo, hi)
        print(f"   {30 * k:3d}-{30 * k + 30:3d}       {seen:8d}   {expected:10.1f}")
    print()
    print("3. Birch-Swinnerton-Dyer products prod_{p <= X} N_p / p:")
    checkpoints = [100, 250, 500, 1000]
    print("   curve   rank" + "".join(f"   X = {x:4d}" for x in checkpoints))
    for name, (coeffs, rank) in CURVES.items():
        values = euler_products(coeffs, checkpoints, primes)
        print(f"   {name:7s} {rank:3d}" + "".join(f"  {v:9.1f}" for v in values))
    print()
    print("Epistemic status: the point counts are exact and the Hasse check is")
    print("exhaustive for these primes, but only Hasse's theorem covers all p.")
    print(f"The Sato-Tate table ({len(angles)} angles) and the BSD products are "
          "numerical")
    print("illustrations: the products grow faster for larger rank, as Birch and")
    print("Swinnerton-Dyer saw, but finite data cannot prove or refute the")
    print("conjecture; ranks are quoted from Cremona's tables, not computed.")

if __name__ == "__main__":
    main()
Runs entirely in your browser, in an isolated worker. Nothing is sent to a server.

Used in