Last active
January 21, 2019 16:21
-
-
Save AndroxxTraxxon/eeb788811a8b481326cd5f6a8cd2e090 to your computer and use it in GitHub Desktop.
A fully-fleshed solution for Floating point ranges in Python
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# -*- coding: utf-8 -*- | |
from numbers import Real | |
class FloatRange: | |
""" | |
step/precision used as the tolerance in __contains__ method. Defaults to 1/(10**10). | |
This is used to attempt to mitigate some of the floating point rounding error, but scales with the step size | |
so that smaller step sizes are not susceptible to catching more than they ought. | |
Basically, there's a 2 in <precision> chance that a number will randomly | |
""" | |
_precision = 10**10 | |
_start, _step = 0, 1 # stop must be defined by user. No use putting it in here. | |
""" | |
Here we set up our constraints as the user passes their values: | |
- You can pass the values as follows: | |
- stop (start defaults to 0, step defaults to 1) | |
- start, stop (step again defaults to 1) | |
- start, stop, step | |
- start, step, and stop must be Real numbers | |
- The step must be non-zero | |
- The step must be in the same direction as the difference between stop and start | |
""" | |
def __init__(self, *args, **kwargs): | |
self.start = self._start | |
self.step = self._step | |
self.precision = self._precision | |
self.counter = 0 | |
if len(args) == 1: | |
(self.stop, ) = args # FloatRange | |
elif len(args) == 2: | |
(self.start, self.stop) = args | |
elif len(args) == 3: | |
(self.start, self.stop, self.step) = args | |
else: | |
raise TypeError("FloatRange accepts 1, 2, or 3 arguments. ({0} given)".format(len(args))) | |
for num in self.start, self.step, self.stop: | |
if not isinstance(num, Real): | |
raise TypeError("FloatRange only accepts Real number arguments. ({0} : {1} given)".format(type(num), str(num))) | |
if self.step == 0: | |
raise ValueError("FloatRange step cannot be 0") | |
if (self.stop-self.start)/self.step < 0: | |
raise ValueError("FloatRange value must be in the same direction as the start->stop") | |
self.set_precision(self._precision) # x in FloatRange will return True for values within 0.1% of step size | |
if len(kwargs) > 0: | |
for key, value in kwargs.items(): | |
if key == "precision": | |
if value < 0: | |
raise ValueError("FloatRange precision must be positive") | |
self.tolerance = self.step/self.precision | |
else: | |
raise ValueError("Unknown kwargs key: {0}".format(key)) | |
""" | |
Returns the next value in the iterator, or it stops the iteration and resets. | |
""" | |
def __next__(self): | |
output = self.start + (self.counter * self.step) | |
if ((self.step > 0 and output >= self.stop) or | |
(self.step < 0 and output <= self.stop)) : | |
self.counter = 0 | |
raise StopIteration | |
self.counter += 1 | |
return output | |
""" | |
The class already implements __next__(), so it is its own iterable | |
""" | |
def __iter__(self): | |
return self | |
def set_precision(self, precision=None): | |
if precision is None: | |
self.precision = self._precision | |
elif isinstance(precision, Real): | |
if precision < 0: | |
raise ValueError("FloatRange precision cannot be a negative number.") | |
self.precision = precision | |
else: | |
raise ValueError("FloatRange precision must be a Real number.") | |
self.tolerance = abs(self.step/self.precision) | |
""" | |
len(my_FloatRange) | |
""" | |
def __len__(self): | |
# we have to do this minute addition here so that floating point rounding does not fail. | |
return int((self.stop - self.start + self.step/10**13) / self.step) | |
""" | |
x in my_FloatRange | |
Evaluates whether a given number is contained in the range, in constant time. | |
Non-exact values will return True if they are within the provided tolerance. | |
Use set_precision(precision) to define the precision:step ratio (the tolerance) | |
""" | |
def __contains__(self, item): | |
diff = (item - self.start) % self.step | |
# if we're dealing with exact cases (not recommended, but okay.) | |
if (self.step > 0 and | |
item >= self.start-self.tolerance and | |
item < self.stop): | |
return (min(diff, self.step-diff) < self.tolerance) | |
elif (self.step < 0 and | |
item <= self.start+self.tolerance and | |
item > self.stop | |
): | |
return (min(abs(diff), abs(self.step-diff)) < self.tolerance) | |
return False | |
def __str__(self): | |
return self.__repr__() | |
def __repr__(self): | |
ext = "" | |
if not self.step == 1: | |
ext += ", {0}".format(self.step) | |
if self.precision != self._precision: | |
ext += ", precision={0}, tolerance={1}".format( | |
self.precision, self.tolerance | |
) | |
return "FloatRange({0}, {1}{2})".format( | |
self.start, | |
self.stop, | |
ext | |
) | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# -*- coding: utf-8 -*- | |
import unittest | |
from FloatRange import FloatRange | |
class TestCase_FloatRange(unittest.TestCase): | |
def test_compare_basic(self, start=None, stop=1, step=None, verbose=False): | |
my_range = None | |
my_FloatRange = None | |
if step is None: | |
if start is None: | |
my_range = range(stop) | |
my_FloatRange = FloatRange(stop) | |
else: | |
my_range = range(start, stop) | |
my_FloatRange = FloatRange(start, stop) | |
else: | |
my_range = range(start, stop, step) | |
my_FloatRange = FloatRange(start, stop, step) | |
if verbose: | |
print("Validating:[{0}] == [{1}]".format( | |
my_range, my_FloatRange)) | |
for x,y in zip(my_range, my_FloatRange): | |
try: | |
self.assertEqual(x,y) | |
except: | |
print("{0} and {1} failed to produce the same values.".format( | |
my_range, my_FloatRange | |
)) | |
raise | |
def test_compare_range_functionality(self): | |
_length = 10 # arbitrary number for adequate length | |
_step = 2 | |
_start = 5 | |
self.test_compare_basic(stop = _length) | |
self.test_compare_basic(start =_start, | |
stop = _length) | |
self.test_compare_basic(start=_start, | |
stop= _start+_length) | |
self.test_compare_basic(start=_start, | |
stop= _start+_length*_step, | |
step= _step) | |
def test_correct_length(self): | |
for _divisor in range(1, 100): | |
for _step_base in range(1, 100): | |
for _length in range(1, 100): | |
_step = _step_base / _divisor | |
_start = 1 / _divisor + 1 | |
_stop = _start + _length*_step | |
my_FloatRange = FloatRange(_start, | |
_stop, | |
_step) | |
try: | |
self.assertEqual(len(my_FloatRange), _length) | |
except Exception: | |
print("Length test failed with parameters:\n\tstart:{0}\n\tstop :{1}\n\tstep: {2}\n\tvalue: {2}".format( | |
_start, _stop, _step, len(my_FloatRange) | |
)) | |
raise | |
def test_value_set(self, subject=FloatRange(1), values=[0], verbose=False): | |
if verbose: | |
print("Validating {0} produces {1}".format(subject, values)) | |
try: | |
self.assertEqual(len(subject), len(values)) | |
except: | |
print("{0} and {1} do not have the same length!".format(subject, values)) | |
raise | |
for f, v in zip(subject, values): | |
try: | |
self.assertAlmostEqual(f, v) # floating point rounding doesn't allow for exact equality. | |
except: | |
print("{0} does not produce {1}".format(subject, values)) | |
raise | |
def test_values(self): | |
self.test_value_set(FloatRange(0, 10, 1/3), [(x/3) for x in range(30)]) | |
self.test_value_set(FloatRange(5, 15, 1/3), [(5+(x/3)) for x in range(30)]) | |
self.test_value_set(FloatRange(1, 11, 1/7), [(1+(x/7)) for x in range(70)]) | |
self.test_value_set(FloatRange(8, 18, 1/7), [(8+(x/7)) for x in range(70)]) | |
if __name__ == '__main__': | |
unittest.main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment