Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Float.Extra.[equalWithin,interpolateFrom] #54

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 60 additions & 3 deletions src/Float/Extra.elm
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
module Float.Extra exposing
( aboutEqual
( aboutEqual, equalWithin
, toFixedDecimalPlaces, toFixedSignificantDigits, boundaryValuesAsUnicode
, range
, modBy
, interpolateFrom
)

{-| Convenience functions for dealing with Floats.


# Equality

@docs aboutEqual
@docs aboutEqual, equalWithin


# Formatting Floats
Expand All @@ -27,6 +28,11 @@ module Float.Extra exposing

@docs modBy


# Interpolation

@docs interpolateFrom

-}

-- toFixedDecimalDigits implementation
Expand Down Expand Up @@ -310,7 +316,7 @@ boundaryValuesAsUnicode formatter value =



-- aboutEqual
-- Equality


{-| Comparing Floats with `==` is usually wrong, unless you basically care for reference equality, since floating point
Expand Down Expand Up @@ -343,6 +349,24 @@ aboutEqual a b =
abs (a - b) <= 1.0e-5 + 1.0e-8 * abs a


{-| Check if two values are equal within a given tolerance.

Float.Extra.equalWithin 1.0e-6 1.9999 2.0001
--> False

Float.Extra.equalWithin 1.0e-3 1.9999 2.0001
--> True

-}
equalWithin : Float -> Float -> Float -> Bool
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of wonder if a elm-test style type Tolerance = Relative Float | Absolute Float | RelativeOrAbsolute Float Float wouldn't be nicer here as the first argument?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps...I've always been suspicious of relative tolerances myself, but that may be because I personally often work with values that are positions/coordinates instead of fundamentally being magnitudes. When working with positions it really doesn't make sense to use a relative tolerance, since a "small" value is just one that happens to be close to your chosen origin point, which is arbitrary. (Said another way, if I'm comparing the positions of two points in space to see if they're approximately equal, that comparison should not depend on my choice of origin point - but if I use a relative tolerance to compare coordinate values, then the comparison will depend on the choice of origin point.)

My hunch is that in most cases it's better to choose a tolerance value based on some higher-level context than individual points/values (e.g. the size of a geometric bounding box, or more generally some nominal value representing the general scale of things that you're working with), and then use that fixed tolerance as an 'absolute' tolerance for all comparisons. (If relative tolerances are so great, then why do you usually have to combine them with a 'fudge factor' for comparisons near zero?)

Of course, all that said, with an API like you propose people like me can just choose to use Absolute and others can decide for themselves if Relative or RelativeOrAbsolute is appropriate for their use case 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On that note...if we do use a relative tolerance, should it be based on abs firstValue or max (abs firstValue) (abs secondValue)?

  • The first case (as used currently by aboutEqual) has the advantage that all comparisons against the same reference value can use the same tolerance
  • The second case has the advantage that the comparison is symmetric, e.g. aboutEqual a b will always return the same result as aboutEqual b a, which is not true of the current implementation

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great comment and an excellent explanation! I think the best action really would be to adapt it into extra documentation for aboutEqual as guidance on the correctness advantages of switching to equalWithin and how to choose a correct tolerance parameter.

equalWithin tolerance firstValue secondValue =
if isInfinite firstValue || isInfinite secondValue then
firstValue == secondValue

else
abs (secondValue - firstValue) <= tolerance



-- Range

Expand Down Expand Up @@ -408,3 +432,36 @@ in `Float.Extra.modBy modulus x`.
modBy : Float -> Float -> Float
modBy modulus x =
x - modulus * toFloat (floor (x / modulus))


{-| Interpolate from the first value to the second, based on a parameter that
ranges from zero to one. Passing a parameter value of zero will return the start
value and passing a parameter value of one will return the end value.

Float.Extra.interpolateFrom 5 10 0 == 5

Float.Extra.interpolateFrom 5 10 1 == 10

Float.Extra.interpolateFrom 5 10 0.6 == 8

The end value can be less than the start value:

Float.Extra.interpolateFrom 10 5 0.1 == 9.5

Parameter values less than zero or greater than one can be used to extrapolate:

Float.Extra.interpolateFrom 5 10 1.5 == 12.5

Float.Extra.interpolateFrom 5 10 -0.5 == 2.5

Float.Extra.interpolateFrom 10 5 -0.2 == 11

Note that in some cases, due to numerical roundoff, passing a parameter value of 1.0
may not return _exactly_ the end value, e.g.

Float.Extra.interpolateFrom 1.0e6 1.0e-6 1.0 == 1.00000761449337e-6

-}
interpolateFrom : Float -> Float -> Float -> Float
interpolateFrom start end parameter =
start + parameter * (end - start)
62 changes: 60 additions & 2 deletions tests/FloatTests.elm
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
module FloatTests exposing (modByTests, testAboutEqual, testBoundaryValuesAsUnicode, testRange, testToFixedDecimalPlaces, testToFixedSignificantDigits)
module FloatTests exposing
( modByTests
, testAboutEqual
, testBoundaryValuesAsUnicode
, testEqualWithin
, testInterpolateFrom
, testRange
, testToFixedDecimalPlaces
, testToFixedSignificantDigits
)

import Expect exposing (FloatingPointTolerance(..))
import Expect exposing (Expectation, FloatingPointTolerance(..))
import Float.Extra
import Fuzz exposing (Fuzzer)
import List.Extra exposing (Step(..))
Expand Down Expand Up @@ -214,6 +223,32 @@ testAboutEqual =
]


testEqualWithin : Test
testEqualWithin =
describe "equalWithin should compare numbers as equal within a given tolerance"
[ test "small positive error" <|
\() ->
Float.Extra.equalWithin 0.01 1 1.001
|> Expect.equal True
, test "small negative error" <|
\() ->
Float.Extra.equalWithin 0.01 1 0.999
|> Expect.equal True
, test "large positive error" <|
\() ->
Float.Extra.equalWithin 0.01 1 1.1
|> Expect.equal False
, test "large negative error" <|
\() ->
Float.Extra.equalWithin 0.01 1 0.9
|> Expect.equal False
, test "infinity not equal to itself within any finite tolerance" <|
\() ->
Float.Extra.equalWithin 0.01 (1 / 0) (1 / 0)
|> Expect.equal False
]

gampleman marked this conversation as resolved.
Show resolved Hide resolved

testRange : Test
testRange =
describe "range start stop step"
Expand Down Expand Up @@ -364,3 +399,26 @@ modByTests =
Float.Extra.modBy (toFloat a) (toFloat b)
|> Expect.within (Absolute 1.0e-20) (toFloat (modBy a b))
]


testInterpolateFrom : Test
testInterpolateFrom =
describe "interpolateFrom"
[ fuzz2 Fuzz.niceFloat Fuzz.niceFloat "should return the start value exactly if given 0" <|
\a b -> Float.Extra.interpolateFrom a b 0 |> expectExactly a
, fuzz2 Fuzz.niceFloat Fuzz.niceFloat "should return the end value exactly if given 1" <|
\a b -> Float.Extra.interpolateFrom a b 1 |> expectExactly b
, fuzz2 Fuzz.niceFloat Fuzz.niceFloat "should return the mean if given 0.5" <|
\a b ->
Float.Extra.interpolateFrom a b 0.5
|> Float.Extra.aboutEqual ((a + b) / 2)
|> Expect.equal True
]


{-| In some cases (like above in testInterpolateFrom)
you _do_ actually want to check two floating-point numbers for equality
-}
expectExactly : Float -> Float -> Expectation
expectExactly expected actual =
actual |> Expect.within (Expect.Absolute 0.0) expected
Loading