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