Algorithm Search⌘K

Payne-Hanek argument reduction in Python

Accurate reduction of very large arguments modulo π/2 using high-precision bits of 2/π.

File
payne_hanek_argument_reduction.py, 104 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
payne_hanek_argument_reduction.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/payne-hanek-argument-reduction/. The authors are listed in prov/H4GFMJ.jsonl.

"""Payne-Hanek reduction of a binary64 argument modulo pi/2.

reduce(x) returns (k, r_hi, r_lo) with k in {0, 1, 2, 3} and
x = (4j + k) * pi/2 + r for some integer j, where r_hi + r_lo approximates
r with |r| <= pi/4 (up to a tiny excess at the boundary).

The bits of 2/pi and pi/2 are computed once with integer arithmetic from
Machin's formula, so the module needs no stored constant.
"""

import math

P = 245          # bits of the product kept after the binary point
Q = 245          # bits of pi/2 used to turn the fraction into radians
N = 971 + P + 8  # bits of 2/pi needed for the largest binary64 exponent

_PI_OVER_4 = 0.7853981633974483  # fl(pi/4), which is below pi/4


def _arctan_inv(n, bits):
    """floor-ish of 2^bits * arctan(1/n) for an integer n >= 2."""
    total = 0
    term = (1 << bits) // n
    n2 = n * n
    k = 1
    sign = 1
    while term:
        total += sign * (term // k)
        term //= n2
        k += 2
        sign = -sign
    return total


def _pi_scaled(bits):
    """An integer within a few units of 2^bits * pi (Machin's formula)."""
    g = 32
    s = 16 * _arctan_inv(5, bits + g) - 4 * _arctan_inv(239, bits + g)
    return s >> g


_EXTRA = 64
_PI_BIG = _pi_scaled(N + _EXTRA)
# TWO_OVER_PI = floor(2^N * 2/pi): bit i of 2/pi (i >= 1) is bit N - i here.
TWO_OVER_PI = (1 << (2 * N + _EXTRA + 1)) // _PI_BIG
# PIO2 = round(2^Q * pi/2).
PIO2 = (_PI_BIG + (1 << (N + _EXTRA - Q))) >> (N + _EXTRA - Q + 1)


def _to_double_double(num, shift):
    """Round num / 2^shift to (hi, lo) with hi = fl(v) and lo = fl(v - hi)."""
    hi = math.ldexp(float(num), -shift) if num else 0.0
    n, d = hi.as_integer_ratio()
    rest = num - (n << shift) // d          # exact: hi * 2^shift is an integer here
    lo = math.ldexp(float(rest), -shift) if rest else 0.0
    return hi, lo


def reduce(x):
    """Return (k, r_hi, r_lo) for a finite binary64 x."""
    if not math.isfinite(x):
        raise ValueError("argument must be finite")
    if abs(x) <= _PI_OVER_4:                               # @line L1
        return 0, x, 0.0
    m, e = math.frexp(abs(x))                              # @line L2
    M = int(math.ldexp(m, 53))
    E = e - 53                                             # |x| = M * 2^E
    i0 = max(1, E - 1)                                     # @line L3
    i1 = E + P
    W = (TWO_OVER_PI >> (N - i1)) & ((1 << (i1 - i0 + 1)) - 1)  # @line L4
    Z = M * W                                              # @line L5
    k = (Z >> P) & 3                                       # @line L6
    F = Z & ((1 << P) - 1)
    if F >= 1 << (P - 1):                                  # @line L7
        k = (k + 1) & 3
        F -= 1 << P
    hi, lo = _to_double_double(F * PIO2, P + Q)            # @line L8
    if x < 0:                                              # @line L9
        k = (-k) & 3
        hi, lo = -hi, -lo
    return k, hi, lo                                       # @line L10


def sin_via_reduction(x):
    """sin(x) from the reduced argument, to show how k is used."""
    k, hi, lo = reduce(x)
    s = math.sin(hi) + math.cos(hi) * lo
    c = math.cos(hi) - math.sin(hi) * lo
    return (s, c, -s, -c)[k]


def naive_reduce(x):
    """The textbook one-constant reduction, which fails for large x."""
    k = round(x * (2 / math.pi))
    return k & 3, x - k * (math.pi / 2)


if __name__ == "__main__":
    for x in (1e22, 6381956970095103 * 2.0 ** 797, 1e300, -5.0):
        k, hi, lo = reduce(x)
        print(repr(x), k, repr(hi), repr(lo), sin_via_reduction(x), math.sin(x))
test_payne_hanek_argument_reduction.py 104 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/payne-hanek-argument-reduction/. The authors are listed in prov/H4GFMJ.jsonl.

import math
import random
from fractions import Fraction

from payne_hanek_argument_reduction import P, reduce, naive_reduce, sin_via_reduction

REF_BITS = 2600


def _atan_inv(n, bits):
    total, term, k, sign = 0, (1 << bits) // n, 1, 1
    while term:
        total += sign * (term // k)
        term //= n * n
        k += 2
        sign = -sign
    return total


def _pi_reference(bits):
    # Gauss's formula, independent of the Machin formula in the implementation.
    g = 40
    b = bits + g
    s = 48 * _atan_inv(18, b) + 32 * _atan_inv(57, b) - 20 * _atan_inv(239, b)
    return Fraction(s >> g, 1 << bits)


PI = _pi_reference(REF_BITS)


def exact_reduce(x):
    """Reference k and r with |r| <= pi/4, from a 2600-bit pi."""
    X = Fraction(x)
    t = X * 2 / PI
    j = math.floor(t + Fraction(1, 2))
    r = X - j * PI / 2
    return j % 4, r


def check(x):
    k, hi, lo = reduce(x)
    kr, r = exact_reduce(x)
    assert k == kr, (x, k, kr)
    err = abs(Fraction(hi) + Fraction(lo) - r)
    assert err <= abs(r) * Fraction(1, 2 ** 104), (x, float(err / abs(r)))
    assert hi == float(r), (x, hi, float(r))


def test_known_values():
    for x in (1e22, 1e300, -5.0, 1.0, 2.0, 3.0, 100.0, 2.0 ** 1023, 1.7976931348623157e308):
        check(x)


def test_small_arguments_unchanged():
    for x in (0.0, -0.0, 5e-324, 1e-300, 0.5, -0.785, 0.7853981633974483):
        assert reduce(x) == (0, x, 0.0)


def test_worst_case_binary64():
    # Closest binary64 to a multiple of pi/2 (Muller, Elementary Functions, Ch. 11).
    x = 6381956970095103 * 2.0 ** 797
    check(x)
    k, hi, lo = reduce(x)
    assert 2.0 ** -61 < hi < 2.0 ** -60


def test_random_all_exponents():
    rng = random.Random(20261107)
    for _ in range(1500):
        e = rng.randint(-1, 1023)
        m = rng.random() + 1.0
        x = math.ldexp(m, e) * rng.choice((1, -1))
        if math.isfinite(x):
            check(x)


def test_near_multiples_of_pi_over_2():
    # Floats next to k*pi/2 for moderate k stress the cancellation.
    for n in range(1, 400):
        c = float(n * PI / 2)
        for x in (c, math.nextafter(c, 0), math.nextafter(c, math.inf)):
            check(x)


def test_sin_matches_libm():
    rng = random.Random(7)
    for _ in range(2000):
        x = math.ldexp(rng.random() + 1.0, rng.randint(0, 1023))
        a, b = sin_via_reduction(x), math.sin(x)
        assert abs(a - b) <= 2 * math.ulp(b), (x, a, b)


def test_naive_reduction_fails_for_large_x():
    k, r = naive_reduce(1e22)
    kr, rr = exact_reduce(1e22)
    assert abs(r - float(rr)) > 0.1


def test_window_error_bound():
    # The dropped tail of 2/pi is below 2^(53 - P) in units of the fraction.
    assert 53 - P <= -190

What the next level needs

  • the tests did not run in this build

Read the full page about Payne-Hanek argument reduction