Skip to content

Commit

Permalink
achieve more accurate infinite sequences of evenly spaced values by u…
Browse files Browse the repository at this point in the history
…sing Decimal instead of Fraction; UniformSequence(0, 0.04) now works
  • Loading branch information
artgoldberg committed Jan 4, 2020
1 parent a2fbf3b commit e20f914
Show file tree
Hide file tree
Showing 2 changed files with 69 additions and 47 deletions.
47 changes: 28 additions & 19 deletions tests/util/test_uniform_seq.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
:License: MIT
"""

from math import pi
import unittest
import sys

Expand All @@ -16,15 +15,18 @@
class TestUniformSequence(unittest.TestCase):

def test_uniform_sequence(self):
initial_values = [((0, 1), (0, 1, 2, 3)),
((2, 1), (2, 3)),
((0, -1), (0, -1, -2, -3)),
((0, .1), (0, .1, .2, .3, .4, .5, .6, .7, .8, .9, 1.)),
((0, .3), (0, .3, .6, .9, 1.2)),
# an example from Guido van Rossum: http://code.activestate.com/recipes/577068/
((0, .7), (0, .7, 1.4, 2.1)),
]
for args, expected_seq in initial_values:
initial_and_expected_values = \
[((0, 1), (0, 1, 2, 3)),
((2, 1), (2, 3)),
((0, -1), (0, -1, -2, -3)),
((0, .1), (0, .1, .2, .3, .4, .5, .6, .7, .8, .9, 1.)),
((0, .100), (0, .1, .2, .3, .4, .5, .6, .7, .8, .9, 1.)),
((0, .3), (0, .3, .6, .9, 1.2)),
# example from Guido van Rossum: http://code.activestate.com/recipes/577068/
((0, .7), (0, .7, 1.4, 2.1)),
((0, .04), (0, 0.04, 0.08, 0.12, 0.16, 0.2, 0.24, 0.28, 0.32)),
]
for args, expected_seq in initial_and_expected_values:
start, period = args
us = UniformSequence(start, period)
for expected in expected_seq:
Expand All @@ -35,14 +37,21 @@ def test_uniform_sequence(self):
us = UniformSequence(0, 1)
self.assertEqual(us.__iter__(), us)

with self.assertRaisesRegex(ValueError, "UniformSequence: step .* can't be a fraction"):
UniformSequence(0, pi)
bad_steps = [0, float('nan'), float('inf'), -float('inf')]
for bad_step in bad_steps:
with self.assertRaisesRegex(ValueError, "UniformSequence: step=.* can't be 0, NaN, "
"infinite, or subnormal"):
UniformSequence(0, bad_step)

us = UniformSequence(sys.float_info.max, 2)
with self.assertRaisesRegex(StopIteration, "UniformSequence: floating-point rounding error:"):
us.__next__()
us.__next__()
with self.assertRaisesRegex(ValueError, "precision in start=.* exceeds UNIFORM_SEQ_PRECISION threshold"):
UniformSequence(1/3, 1)

us = UniformSequence(pi, 1)
with self.assertRaisesRegex(StopIteration, "UniformSequence: truncation error"):
us.truncate(us.__next__())
nonterminating_steps = [2**0.5, 1/3]
for nonterminating_step in nonterminating_steps:
with self.assertRaisesRegex(ValueError, "precision in step=.* exceeds UNIFORM_SEQ_PRECISION threshold"):
UniformSequence(0, nonterminating_step)

excessively_precise_steps = [0.123456789, 0.10101010101]
for excessively_precise_step in excessively_precise_steps:
with self.assertRaisesRegex(ValueError, "precision in step=.* exceeds UNIFORM_SEQ_PRECISION threshold"):
UniformSequence(0, excessively_precise_step)
69 changes: 41 additions & 28 deletions wc_utils/util/uniform_seq.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,63 +6,76 @@
:License: MIT
"""

from fractions import Fraction
from decimal import Decimal, getcontext
import collections

from wc_utils.config.core import get_config
uniform_seq_precision = get_config()['wc_utils']['misc']['uniform_seq_precision']
UNIFORM_SEQ_PRECISION = get_config()['wc_utils']['misc']['uniform_seq_precision']


class UniformSequence(collections.abc.Iterator):
""" Generate an infinite sequence of evenly spaced values, especially for non-integral step sizes
Avoids floating-point roundoff errors by using a :obj:`Fraction` to represent the step size as
a ratio of integers, and raising exceptions when errors occur.
The start and step size must be an integer or a float whose mantissa must contain no more
than `UNIFORM_SEQ_PRECISION` digits.
Avoids floating-point roundoff errors by using a :obj:`Decimal` to represent the step size.
Attributes:
_start (:obj:`float`): starting point of the sequence
_fraction_step (:obj:`Fraction`): step size for the sequence
_start (:obj:`Decimal`): starting point of the sequence
_step (:obj:`Decimal`): step size for the sequence
_num_steps (:obj:`int`): number of steps taken in the sequence
"""
MAX_DENOMINATOR = 1_000_000

def __init__(self, start, step):
""" Initialize a :obj:`UniformSequence`
Args:
start (:obj:`float`): starting point of the sequence
step (:obj:`float`): step size for the sequence
Raises:
:obj:`ValueError`: if the step size is 0, NaN, or infinite, or
if the precision in `start` or `step` exceeds `UNIFORM_SEQ_PRECISION`
"""
self._start = start
self._fraction_step = Fraction(step).limit_denominator(max_denominator=self.MAX_DENOMINATOR)
if step * self._fraction_step.denominator != self._fraction_step.numerator:
raise ValueError(f"UniformSequence: step {step} can't be a fraction of integers "
f"denominator <= {self.MAX_DENOMINATOR}")
self._start = Decimal(start)
getcontext().prec = UNIFORM_SEQ_PRECISION
self._step = Decimal(step)
if not self._step.is_normal():
raise ValueError(f"UniformSequence: step={step} can't be 0, NaN, infinite, or subnormal")

# start and step truncated to the Decimal precision must be within 1E-(UNIFORM_SEQ_PRECISION+1)
# of start and step, respectively
atol = 10**-(UNIFORM_SEQ_PRECISION+1)
if atol < abs(float(str(self._start * 1)) - start):
raise ValueError(f"UniformSequence: precision in start={start} exceeds UNIFORM_SEQ_PRECISION "
f"threshold={UNIFORM_SEQ_PRECISION}")
if atol < abs(float(str(self._step * 1)) - step):
raise ValueError(f"UniformSequence: precision in step={step} exceeds UNIFORM_SEQ_PRECISION "
f"threshold={UNIFORM_SEQ_PRECISION}")
self._num_steps = 0

def __iter__(self):
""" Get this :obj:`UniformSequence`
Returns:
:obj:`UniformSequence`: this :obj:`UniformSequence`
"""
return self

def __next__(self):
""" Get next value in the sequence
Raises:
:obj:`StopIteration`: if the next value encounters a floating-point rounding error
Returns:
:obj:`float`: next value in this :obj:`UniformSequence`
"""
next_value = self._start + \
(self._num_steps * self._fraction_step.numerator) / self._fraction_step.denominator
if (next_value - self._start) * self._fraction_step.denominator != \
self._num_steps * self._fraction_step.numerator:
raise StopIteration(f'UniformSequence: floating-point rounding error:\n'
f'_start: {self._start}; '
f'_num_steps: {self._num_steps}; '
f'_fraction_step.numerator: {self._fraction_step.numerator}; '
f'_fraction_step.denominator: {self._fraction_step.denominator}')
# ensure that next_value can be safely truncated
self.truncate(next_value)
next_value = self._start + self._num_steps * self._step
self._num_steps += 1
return next_value
if next_value.is_zero():
return 0.
return float(next_value)

# todo: support scientific notation in truncate() so that sequences like this work
# ((0, 1E-11), (0, .1E-10, .2E-10, .3E-10, .4E-10, .5E-10, .6E-10, .7E-10, .8E-10, .9E-10)),
@staticmethod
def truncate(value):
""" Truncate a uniform sequence value into fixed-point notation for output
Expand All @@ -72,9 +85,9 @@ def truncate(value):
Raises:
:obj:`StopIteration`: if the truncated value does not equal `value`
"""
truncated_value = f'{value:.{uniform_seq_precision}f}'
truncated_value = f'{value:.{UNIFORM_SEQ_PRECISION}f}'
if float(truncated_value) != value:
raise StopIteration(f'UniformSequence: truncation error:\n'
f'value: {value}; truncated_value: {truncated_value} '
f'num digits precision: {uniform_seq_precision}; ')
f'num digits precision: {UNIFORM_SEQ_PRECISION}; ')
return truncated_value

0 comments on commit e20f914

Please sign in to comment.