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

Possible issues with the IComparable interface and associated operators #1367

Open
lipchev opened this issue Feb 19, 2024 · 8 comments
Open
Labels

Comments

@lipchev
Copy link
Collaborator

lipchev commented Feb 19, 2024

Hey guys,
I've mentioned this issue back in #838 where the current implementation of the CompareTo is prone to the same rounding issues as with the old equality contract. Here's the test that I expect would still fail in the current code base:

    [Fact]
    public void CompareTo_WithSameQuantityInAnotherUnit_ReturnsZero()
    {
        var firstMass = Mass.FromGrams(0.001);
        var secondMass = firstMass.ToUnit(MassUnit.Microgram);

        Assert.Equal(0, firstMass.CompareTo(secondMass));
        Assert.Equal(0, secondMass.CompareTo(firstMass));
    }

This might not be an issue when sorting a collection (although it might not be obvious why sorting [x1, x2, x3] might yield a different ordering as compared to the result of sorting [x2, x1, x3]), however I'm quite worried about the > operator.

Here's the typical use-case that may be problematic:
if (mass > Capacity) // possible rounding error here

I'm starting think that my proposal for handling the result of both x1.As(x2.Unit).CompareTo(x2.Value) and x2.As(x1.Unit).CompareTo(x1.Value) might be "better" than what we have right now (covering 99% of the use cases) however, there is still the possibility of getting an unexpected result from Capacity.ToUnit(x).ToUnit(y) > Capacity..

I don't know, the stricter alternative is simply too painful to imagine..

PS There is still the option of trying to use rational numbers for the underlying type: now that there is only one interface to implement this could probably be hidden from the client (say we store the fraction inside the QuantityValue). There is probably just the issue with serializing a rational using the single-value contract (some precision maybe lost), however we could roll out a RationSerializationSerializer for those that need it..
Obviously this wouldn't be as "lightweight" as what we have right now, and there is also the possible issues with the external dependency (e.g. compatibility with the older/compact frameworks etc)..

@angularsen
Copy link
Owner

My head hurts 😅

Yes there is a potential rounding error on all these:

  • IComparison.CompareTo()
  • < > comparison operators
  • Equals(quantity, tolerance)

However, I believe this only happens if the units are different.

I don't immediately see how we can avoid this though. Any ideas?

I'm starting think that my proposal for handling the result of both x1.As(x2.Unit).CompareTo(x2.Value) and x2.As(x1.Unit).CompareTo(x1.Value) might be "better" than what we have right now

Could you elaborate? Do you mean to take an average of the two results?

There is still the option of trying to use rational numbers for the underlying type: now that there is only one interface to implement this could probably be hidden from the client (say we store the fraction inside the QuantityValue)

Rational numbers can probably help in the cases where rational numbers can be defined, but I guess it can't solve all our units?
In v6 the QuantityValue has now been removed by the way. It's all double now.

I've redirected #1368 in here too, as it seems to touch the same topic.

@lipchev
Copy link
Collaborator Author

lipchev commented Mar 1, 2024

var asFirstUnit = other.GetValueAs(this.Unit);
var asSecondUnit = GetValueAs(other.Unit);
return (_value.CompareTo(asFirstUnit) - other.Value.CompareTo(asSecondUnit)) / 2;

The idea was that the rounding error may only occur in the one direction so that (hopefully) at least of the CompareTo operations has it right.

As for the rationals - I don't see why using something like this wouldn't work (as long as we keep the fractions normalized - we could have the exact equality contract back). The only downside I see is the performance hit (and the extra dependency, should we decide the take it/expose it).
I think there is already a fork of UnitsNet that went that route, but I cannot seem to find it right now..
Soo, I don't know how you feel about it- it's been ages since I wanted to try this approach- been eying the removal of the IDecimalQuantity, but must have missed that QuantityValue is going as well.. Have you planned a release date for v6?

@angularsen
Copy link
Owner

No release date for v6 yet, some work still remains and there are already pretty big changes in there, so I plan to keep prerelease versions out for some time so we can gather a bit of feedback.

Fraction nuget is new to me, interesting. I have not tried it nor BigInteger, but it does sound like it maybe will incur a performance penalty to represent all numbers and perform arithmetic using rational numbers.

I'd be interested to test it out and get a better feel of the tradeoffs, but in essence, it would be like replacing double with a super version of decimal with a lot more bits to represent fixed precision, right? There would still be a limit to the precision, just a lot higher?

@lipchev
Copy link
Collaborator Author

lipchev commented Mar 2, 2024

I'm not sure there is a limit to the BigInterger- i think you can put as many bits as you like..

@lipchev
Copy link
Collaborator Author

lipchev commented Mar 2, 2024

Ok, just a heads up- I've started work on "re-fractioning" the v6 here. I'll create a PR as soon as I figure out the Math.Sin({x}..

@angularsen
Copy link
Owner

Cool, keep me posted on how it works out 🙌

@lipchev
Copy link
Collaborator Author

lipchev commented Mar 5, 2024

@angularsen
This is looking very good (personally I'm 100% convinced):

  Name Value Type
  (Mass.FromGrams(1) / 3).ToUnit(MassUnit.SolarMass).ToUnit(MassUnit.Gram) * 3 == Mass.FromGrams(1) true bool
  Name Value Type
  BitRate.FromBytesPerSecond(1).As(BitRateUnit.BitPerSecond) == 8 true bool

And here are the benchmark results (the conversion methods are just about 10x slower- which I think is a very small price to pay):

  1. Before:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19045
AMD Ryzen 9 7900X, 1 CPU, 24 logical and 12 physical cores
.NET SDK=8.0.200
  [Host]     : .NET 6.0.27 (6.0.2724.6912), X64 RyuJIT
  DefaultJob : .NET 6.0.27 (6.0.2724.6912), X64 RyuJIT
Method Mean Error StdDev Gen 0 Gen 1 Allocated
Constructor 4.561 ns 0.0372 ns 0.0348 ns - - -
Constructor_SI 211.041 ns 1.8334 ns 1.7150 ns 0.0114 - 192 B
FromMethod 12.953 ns 0.1071 ns 0.1002 ns - - -
ToProperty 26.804 ns 0.2964 ns 0.2772 ns - - -
As 26.983 ns 0.2244 ns 0.1989 ns - - -
As_SI 211.884 ns 2.5722 ns 2.4061 ns 0.0114 - 192 B
ToUnit 24.616 ns 0.3022 ns 0.2679 ns - - -
ToUnit_SI 211.295 ns 1.8718 ns 1.7509 ns 0.0114 - 192 B
ToStringTest 712.395 ns 7.7697 ns 7.2678 ns 0.0448 - 760 B
Parse 49,103.580 ns 804.3356 ns 752.3761 ns 4.0894 0.1221 69,245 B
TryParseValid 47,693.856 ns 355.5437 ns 296.8951 ns 4.0894 0.1831 69,238 B
TryParseInvalid 35,195.649 ns 472.2180 ns 441.7130 ns 3.1738 0.1831 54,003 B
QuantityFrom 27.279 ns 0.1479 ns 0.1235 ns 0.0033 - 56 B
IQuantity_As 28.635 ns 0.2681 ns 0.2508 ns 0.0014 - 24 B
IQuantity_As_SI 210.206 ns 2.4136 ns 2.2576 ns 0.0114 - 192 B
IQuantity_ToUnit 26.915 ns 0.4073 ns 0.3810 ns 0.0033 - 56 B
IQuantity_ToStringTest 721.960 ns 7.6593 ns 7.1645 ns 0.0448 - 760 B

After:


BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19045
AMD Ryzen 9 7900X, 1 CPU, 24 logical and 12 physical cores
.NET SDK=8.0.200
  [Host]     : .NET 6.0.27 (6.0.2724.6912), X64 RyuJIT
  DefaultJob : .NET 6.0.27 (6.0.2724.6912), X64 RyuJIT
Method Mean Error StdDev Gen 0 Gen 1 Allocated
Constructor 12.46 ns 0.130 ns 0.122 ns - - -
Constructor_SI 230.78 ns 3.937 ns 3.683 ns 0.0114 - 192 B
FromMethod 12.75 ns 0.133 ns 0.124 ns - - -
ToProperty 318.87 ns 3.288 ns 3.075 ns - - -
As 317.32 ns 3.353 ns 3.136 ns - - -
As_SI 212.07 ns 2.185 ns 2.044 ns 0.0114 - 192 B
ToUnit 310.23 ns 3.109 ns 2.908 ns - - -
ToUnit_SI 469.49 ns 3.715 ns 3.293 ns 0.0114 - 192 B
ToStringTest 726.00 ns 4.699 ns 3.924 ns 0.0467 - 792 B
Parse 49,045.47 ns 768.965 ns 719.290 ns 4.1504 0.1221 69,485 B
TryParseValid 49,352.21 ns 787.202 ns 736.349 ns 4.1504 0.1831 69,478 B
TryParseInvalid 34,432.10 ns 180.113 ns 140.620 ns 3.1738 0.1831 54,035 B
QuantityFrom 55.98 ns 0.716 ns 0.670 ns 0.0052 - 88 B
IQuantity_As 321.34 ns 3.456 ns 3.232 ns 0.0014 - 24 B
IQuantity_As_SI 219.85 ns 1.307 ns 1.223 ns 0.0114 - 192 B
IQuantity_ToUnit 319.19 ns 3.556 ns 3.326 ns 0.0052 - 88 B
IQuantity_ToStringTest 756.37 ns 9.583 ns 8.495 ns 0.0467 - 792 B

And here is a snippet of the generated code:

                // MassUnit -> BaseUnit
                (MassUnit.Centigram, MassUnit.Kilogram) => new Mass(_value / 100000, MassUnit.Kilogram),
                (MassUnit.Decagram, MassUnit.Kilogram) => new Mass(_value / 100, MassUnit.Kilogram),
                (MassUnit.Decigram, MassUnit.Kilogram) => new Mass(_value / 10000, MassUnit.Kilogram),
                (MassUnit.EarthMass, MassUnit.Kilogram) => new Mass(_value * 597220 * BigInteger.Pow(10, 19), MassUnit.Kilogram),

I'll review my changes later (or more likely tomorrow) and open the PR. The tests still do not compile (haven't even started updating them yet) but I think we have enough to get us started on the discussion of the future role (if any) of the doubles, the QuantityType and the use of Fraction for the interfaces..

@lipchev
Copy link
Collaborator Author

lipchev commented Mar 5, 2024

PS In the second benchmark I'm returning Fractions instead of doubles (As/ToProperty), so the comparison isn't exactly fair- we'd have to possibly include the cost of converting from a Fraction to double (but that's another benchmark- which we should offer to the original library, with our best regards and so on..)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants