- Home
- LLBLGen Pro
- Bugs & Issues
Truncate problem with conversion to less decimals
Joined: 21-Jul-2021
Hi,
We noticed that the default for EntityCore<T>.ScaleOverflowCorrectionActionToUse the value Truncate is but SD.LLBLGen.Pro.ORMSupportClasses.FieldUtilities.CorrectScale does not handle the truncation correctly. It uses a ToString() whereas the Round does use a nice type check.
We have a double with value: -0.000000000307431677337711, which has a ToString() of -3.07431677337711E-10
The target scale is 6 and rounding will do so but the Truncate takes the first 6 digits after the decimal dot and ignores the "E-10" part completely.
Sounds like a bug to me, wouldn't it be better to do a type check as is done by Round but instead of Math.Round, use for example the Math.Floor combined with a multiplication + division by 10^scale?
Best, Dick
Filename | File size | Added on | Approval |
---|---|---|---|
truncate_problem.png | 18,428 | 24-Nov-2021 11:04.29 | Approved |
Joined: 17-Aug-2003
The problem with using Math.floor and then multiply/divide is that double and single values are IEEE encoded and not precise: doing multiply/divide could alter the resulting value and in some cases cause loss of value. In fact, single/double don't have a 'scale' set internally. The tostring route was chosen as it was a somewhat 'reliable' way to avoid any problems wrt multiply and divide and has been in the framework since the beginning. You're the first which found this edge case!
Looking at it again, I think it's a bit weird we do truncate for double/single values at all. The main issue is that you can't avoid having a repetitive number with infinite scale with a double or single. E.g. take this simple code:
double d = 2.1;
double e = 4.1;
double f = e - d;
Console.WriteLine(f);
It prints: 1,9999999999999996
, which, if we set the scale to e.g. 5, results in a scale truncation but it shouldn't as it has, in theory, a fraction of 0. As doubles/singles are imprecise for this, scale is arbitrary and for string usage mostly, there's no overflow for scale in a database anyway (or better: databases supporting doubles will round).
I think at the time we thought it was a good idea, but looking at it again, it doesn't really make sense to truncate a double/single as it's imprecise; it should always round. There's also no solution that will work with all situations, not even a ToString("Fscale + 1"), which works with your value, but not with 0.199999999999, which will get rounded to 0.20000 for a scale of 5, and lead to an incorrect value (if you can call it that).
In any case, the truncate on single/double isn't useful, and we'll remove it for these types. But removing / changing features is something we don't like to do in existing versions. We will change it for v5.9. We can't change it for v5.7.x/v5.8.x, as it's a breaking change: should we ignore the scale overflow or do a rounding?
To work around this, assign an IValidator instance to an entity using dependency injection, and in the IValidator implementation simply ignore single/double value set actions if the scale overflowcorrection action to use is truncate. See: https://www.llblgen.com/Documentation/5.8/LLBLGen%20Pro%20RTF/Using%20the%20generated%20code/gencode_validation.htm#validation-logic-inside-validator-classes
Another workaround could be to pre-round the value when it's set in an override of the entitycore method: PreProcessValueToSet
(do that in a partial class of CommonEntityBase for instance, or the particular entity class)
Joined: 21-Jul-2021
I am aware of the problems with such small numbers and using math.floor (incl mul/div), the error on our end with the current ToString way of doing the truncation is even more significant though...
Anyway, I agree the fact that truncating is not useful for double/single values so we will likely use the global setting (in the app.config) to change the default of scaleOverflowCorrectionActionToUse to 2 (rounding)...
Dick
Joined: 17-Aug-2003
We still needed to find a solution to the problem for 5.9, and the main question is: to round or to leave it as-is. So doing the mul/div route on just the fraction will solve that.
We think we have found a solution, and will implement that on v5.7.x and 5.8.x as this isn't a breaking change
example, scale of 6:
double a = 1234.123456789123456;
double b = Math.Round(a);
double c = Math.Abs(a) - Math.Abs(b);
double d = Math.Pow(10, 6);
double e = Math.Floor(c * d);
double f = e / d;
double result = (a < 0) ? b - f : b + f;
Console.WriteLine(a);
Console.WriteLine(b);
Console.WriteLine(c);
Console.WriteLine(d);
Console.WriteLine(e);
Console.WriteLine(f);
Console.WriteLine(result);
gives:
1234,1234567891236 1234 0,12345678912356561 1000000 123456 0,123456 1234,123456
This will work up to a scale of 15 as it will otherwise overflow. If the scale is > 15 we'll ignore it, as a fraction that small is bigger than the error you get with IEEE arithmetic anyway (Math.Round will fail with scales > 15)
Joined: 21-Jul-2021
Wouldn't the following also suffice?
int scale = 6;
double a = -0.000000000307431677337711; // 1234.123456289123456;
double b = a - Math.Sign(a) * (.5 / Math.Pow(10, scale));
double c = Math.Round(b, scale, MidpointRounding.AwayFromZero);
double result = c;
Console.WriteLine(a);
Console.WriteLine(b);
Console.WriteLine(c);
Console.WriteLine(result);
Dick
Joined: 17-Aug-2003
Sure, tho expressing it in a bit more steps makes it simpler to understand . The main advantage however of having more steps is that we can check if there's a fraction in the value multiplied by 10^scale. If not, no action should be taken. If there is, only then the action has to be performed, as that's the API's designed behavior.
We've uploaded hotfixes for v5.7 (v5.7.7) and v5.8 (v5.8.4) to your site and nuget which should correct this problem. they'll only check for scale overflow and only perform a round or truncate, they'll never throw an exception.
Rounding is done when specified and scale is < 16. if the scale is 16 or higher, no work is performed as it would lead an overflow in double anyway when performing a round, so it will fall through and do a truncation.
Hopefully this fixes the validation problems you had
Joined: 21-Jul-2021
Thanks for the fix, we will update our sources and see if it works.
What I meant with my snippet of code is that instead of the Abs-es and Floor, you would also be able to use the Round if you first subtract 0.5/10^scale, this will be like doing a floor and not having to calculate with portions of the value and adding them up in the end.
Best, Dick
Joined: 17-Aug-2003
Thanks yes that was clear, the only thing we ran into was, that we have to determine if we need to do anything at all. For the truncation, that's fine, you can just truncate and if the fraction has less digits, no problem. But for the rounding, this is required and to determine if we needed to do anything, we have to determine if there are digits in a fraction after we've multiplied it with the 10^scale. It's not really a big thing, this code should be faster than the string based code we had in any way
Let us know if the new code still gives problems or fails in another way
Joined: 21-Jul-2021
Hi,
Our testcase was working fine but the new implementation does not respect the type of the value and always returns a double, for example:
var value = SD.LLBLGen.Pro.ORMSupportClasses.FieldUtilities.CheckPrecisionScaleSingleDouble(90f, ScaleOverflowCorrectionAction.Truncate, 2);
"value" will be of type double even though we started with a Single value. Resulting in an exception when the getter of the property is called:
public virtual System.Single Percentage
{
get { return (System.Single)GetValue((int)MyEntityFieldIndex.Percentage, true); }
set { SetValue((int)MyEntityFieldIndex.Percentage, value); }
}
--> the GetValue returns a boxed double resulting in the cast "(System.Single)" to crash.
Would you be able to fix it in an update?
Best, Dick
Joined: 17-Aug-2003
Hmm, that's indeed an oversight. As the functions required are only available for double, we used the same function for floats as well, but didn't implement a convert. We'll correct that asap.
Joined: 21-Jul-2021
Otis wrote:
Fixed in the new hotfix builds for 5.7.7 and 5.8.4. Sorry for this inconvenience
Hi,
This issue is hunting us, so it seems... we have a double property with scale 4 and want to assign 24.11 (can be stored in a double) to it... it results in the property having the value 24.1099 due to the fact that 24.11 - 24 (calculation of the fraction variable) results in 0.10999..9943.
We are using the default ScaleOverflowCorrectionAction.Truncate.
So on our end, the variable is still fine and just having 2 decimals but during the assignment, significance gets lost. If we would use ScaleOverflowCorrectionAction.Round, it works for this case but that might change other calculations.
How to solve this?
Best, Dick
Joined: 21-Jul-2021
To get back to the original issue, maybe the string formatting was the easiest but you should use something like:
var small = -0.000000000307431677337711;
var smallstr = String.Format("{0:F20}", small);
Console.WriteLine(smallstr);
It will result in the string without the exponent...
Best, Dick
Joined: 17-Aug-2003
Aren't you running into the limitations of IEEE encoded floating point values? If you want a value to always have a fraction with 2 decimals, you should use a Decimal, not a double, as that will lead to problems one way or the other (which is also the reason, as you might know, money related values aren't stored in floating point types)
I tried the string route, but couldn't find a formatting that would lead to something that wouldn't bug at some point. In the end the double/float route for rounding is one way or the other, arbitrary, simply because the result of the calculation/rounding might be exact, the storage of it might not, as a double with scale '4' actually doesn't mean anything.
I suspect you're using Oracle? ODP.NET has the sad tendency to convert some NUMBER(x, y) variants to System.Double / Single instead of always using System.Decimal. ref This is still the case to this day in v21.x I don't see a way around this as the oracle / ODP.NET datareader e.g. returns a value from a column with type NUMBER(1, 12) as a double. The lame thing is that their Entity Framework Core variant of ODP.NET does return it as a decimal in all cases. (which isn't ideal either in some cases, but alas).
So, to recap: what exactly is the problem and what exactly are the specs you have to work with as both are unclear to me.
Joined: 21-Jul-2021
Hi Otis,
Agree that decimal should be prefered for currencies but some things can't be changed... And yes, we are using Oracle.
In what case does the string.format not give the required results to go back to the initial method of truncating via string?
I am sure it is a limitation of IEEE we see now but it is because of the calculations on your end. I would say that if the value is already in range (24.11 fits a scale of 4 already), no additional calculations are required and the value should be stored as-is.
To recap: I would like 24.11 to be stored as 24.11 and not a value very close to that.
Dick
Joined: 17-Aug-2003
You make the mistake that you assume 24.11 is really 24.11 and therefore can be truncated at all. Maybe it's 24.110006 and stays that way. (It is)
I did this test:
[Test]
public void ScaleOverflowTest2()
{
try
{
EntityBase2.ScaleOverflowCorrectionActionToUse = ScaleOverflowCorrectionAction.Truncate;
var e = new TestdefaultEntity();
var value = 24.11f;
e.FloatField= value;
var fieldValue = e.FloatField;
Assert.AreEqual(value, fieldValue);
Assert.AreEqual(24.11f, fieldValue);
}
finally
{
EntityBase2.ScaleOverflowCorrectionActionToUse = ScaleOverflowCorrectionAction.None;
}
}
This test succeeds. FloatField
is a NUMBER(5, 2)
. Not sure why it fails on your side tho. To be clear, value
after the assignment is 24.110006
even though I specified a constant in the code: 24.11f
and fieldValue
is also 24.110006
, also after truncation. That is because truncating float/double is imprecise.
I do the same calculations one could do in this case:
public static object CheckPrecisionScaleSingleDouble(object valueAsObject, ScaleOverflowCorrectionAction overflowActionToUse, int scale)
{
if(valueAsObject == null)
{
return null;
}
bool isFloat = valueAsObject is float;
double value = Convert.ToDouble(valueAsObject);
double valueWithoutFraction = Math.Truncate(value);
double fraction = Math.Abs(value) - Math.Abs(valueWithoutFraction);
double scalePower = Math.Pow(10, scale);
double fractionMultipliedWithPower = scalePower * fraction;
// The digits outside the scale are in the fraction of the fractionMultipliedWithPower
double fractionMultipliedWithPowerTruncated = Math.Truncate(fractionMultipliedWithPower);
double fractionOfFractionMultipliedWithPower = fractionMultipliedWithPower - fractionMultipliedWithPowerTruncated;
double valueToReturn = value;
if(fractionOfFractionMultipliedWithPower > 0)
{
switch(overflowActionToUse)
{
// there's an overflow in the scale.
// we'll get an overflow otherwise, so we'll fall through and truncate.
case ScaleOverflowCorrectionAction.Round when scale < 16:
// simply round it. no exception check needed.
valueToReturn = Math.Round(value, scale);
break;
case ScaleOverflowCorrectionAction.None:
// no action is performed
valueToReturn = value;
break;
default:
double truncatedFraction = fractionMultipliedWithPowerTruncated / scalePower;
// construct the value to return. Make sure to add the fraction back the right way in the case the original value is negative
valueToReturn = (value < 0) ? valueWithoutFraction - truncatedFraction : valueWithoutFraction + truncatedFraction;
break;
}
}
// return the value we received, nothing has to be done
if(isFloat)
{
float toReturn = Convert.ToSingle(valueToReturn);
return toReturn;
}
return valueToReturn;
}
When using the string route we did run into problems with your original test values IIRC (but I didn't keep them, so I don't know exactly which ones failed. I do know the double with the large fraction above did fail). There was no string construct that could make it do a 100% roundtrip that worked in all cases. As the .net framework uses the same code, multiply with a power, e.g. in the double.Round() route (See Double.InternalRound() where the value ends up), we decided to do the same thing as we now have control over when not to apply it.
To recap: I would like 24.11 to be stored as 24.11 and not a value very close to that.
I don't see how you can not store that as my test makes the entity value stay 24.11f, but it's as always an approximation. You order a truncate, there's a tiny fraction in the .11
that justifies a truncate so the code will do that and constructs a value back, which will always have that fraction. There are no 'wrong' calculations, as there are no better calculations to my knowledge that work in all cases, and make a float/double be precise. You want 24.11 to be precisely 24.11. That's not possible with a float/double, no matter what calculations you do. Not sure why you insist my calculations are wrong, but if you have better ones which work in all cases, sure, show me.
Yes it sucks, it sucked since 2003 when Oracle decided to use float/double values for NUMBER(x,y) where scale is < 8 or < 16. I wished I could change that, but the values coming from the data-reader are what they are. A type converter isn't suitable either, as it works on the value coming from the datareader.
To be honest truncate should be changed to round to 0 (but that too will still be imprecise). Not sure what Oracle does internally when the constant 24.110006
is persisted in a NUMBER(5.2) field: will it truncate or round? If the problem for you is to calculate with 24.110006
in-memory, you could consider a type converter on the field to convert it to a Decimal field. This type converter isn't built-in but is easy to build. I've placed one below, not tested, but it's straight forward:
using System;
using System.ComponentModel;
namespace SD.LLBLGen.Pro.TypeConverters
{
public class DecimalDoubleConverter : TypeConverter
{
public DecimalDoubleConverter()
{
}
/// <summary>
/// Returns whether this converter can convert an object of the given type to the type of this converter (Decimal).
/// </summary>
/// <param name="context">Ignored</param>
/// <param name="sourceType">A <see cref="T:System.Type"/> that represents the type you want to convert from.</param>
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType.FullName=="System.Double";
}
/// <summary>
/// Returns whether this converter can convert the object to the specified type.
/// </summary>
/// <param name="context">Ignored</param>
/// <param name="destinationType">A <see cref="T:System.Type"/> that represents the type you want to convert to.</param>
public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
{
return sourceType.FullName=="System.Double";
}
/// <summary>
/// Converts the given object to the type of this converter (Decimal).
/// </summary>
/// <param name="context">Ignored</param>
/// <param name="culture">Ignored</param>
/// <param name="value">The <see cref="T:System.Object"/> to convert.</param>
public override object ConvertFrom(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value)
{
return Convert.ToDecimal((double)value);
}
/// <summary>
/// Converts the given value object to the specified type
/// </summary>
/// <param name="context">Ignored</param>
/// <param name="culture">Ignored</param>
/// <param name="value">The <see cref="T:System.Object"/> to convert.</param>
/// <param name="destinationType">The <see cref="T:System.Type"/> to convert the <paramref name="value"/> parameter to.</param>
public override object ConvertTo(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value, Type destinationType)
{
if(value==null)
{
throw new ArgumentNullException("value", "Value can't be null");
}
if(! (value is Double))
{
throw new ArgumentException("Value isn't of type Double", "value");
}
return (double)((decimal)value);
}
/// <summary>
/// Creates an instance of the Type that this <see cref="T:System.ComponentModel.TypeConverter"/> is associated with (decimal)
/// </summary>
/// <param name="context">ignored.</param>
/// <param name="propertyValues">ignored.</param>
public override object CreateInstance(ITypeDescriptorContext context, System.Collections.IDictionary propertyValues)
{
return 0.0M;
}
}
}
Compile into a .net framework dll for the designer and a .net standard dll for the runtime (multitargeted csproj).
Joined: 21-Jul-2021
Hi Otis,
Would not surprise you if I did my own test, right?!
Anyway, consider the following:
var amount = 24.11d;
Console.WriteLine("Initial value: " + amount);
var ent = new MyEntity();
EntityBase2.ScaleOverflowCorrectionActionToUse = ScaleOverflowCorrectionAction.Truncate;
ent.AmountBalance = amount;
Console.WriteLine("Using Truncate: " + ent.AmountBalance);
EntityBase2.ScaleOverflowCorrectionActionToUse = ScaleOverflowCorrectionAction.Round;
ent.AmountBalance = amount;
Console.WriteLine("Using Round: " + ent.AmountBalance);
EntityBase2.ScaleOverflowCorrectionActionToUse = ScaleOverflowCorrectionAction.None;
ent.AmountBalance = amount;
Console.WriteLine("Using None: " + ent.AmountBalance);
Console.ReadKey();
Where AmountBalance is a double and has a scale of 4 (number(12,4)). By changing the ScaleOverflowCorrectionActionToUse on the EntityBase2 we get the following results:
Initial value: 24.11 Using Truncate: 24.1099 Using Round: 24.11 Using None: 24.11
Your calculation starts with a float constant whereas we are having a double containing that value and assign it to a property of type double.
Guess it will be an arbitrary choice between Truncate and Round as both might have their own implications. From a usage perspective, I would suspect the Trunctate not to alter the value if the number of decimal is already less or equal to the scale.
Dick
Joined: 17-Aug-2003
Sena wrote:
Hi Otis,
Would not surprise you if I did my own test, right?!
Anyway, consider the following:
var amount = 24.11d; Console.WriteLine("Initial value: " + amount); var ent = new MyEntity(); EntityBase2.ScaleOverflowCorrectionActionToUse = ScaleOverflowCorrectionAction.Truncate; ent.AmountBalance = amount; Console.WriteLine("Using Truncate: " + ent.AmountBalance); EntityBase2.ScaleOverflowCorrectionActionToUse = ScaleOverflowCorrectionAction.Round; ent.AmountBalance = amount; Console.WriteLine("Using Round: " + ent.AmountBalance); EntityBase2.ScaleOverflowCorrectionActionToUse = ScaleOverflowCorrectionAction.None; ent.AmountBalance = amount; Console.WriteLine("Using None: " + ent.AmountBalance); Console.ReadKey();
Where AmountBalance is a double and has a scale of 4 (number(12,4)). By changing the ScaleOverflowCorrectionActionToUse on the EntityBase2 we get the following results:
Initial value: 24.11 Using Truncate: 24.1099 Using Round: 24.11 Using None: 24.11
Try to step through it with a debugger and then look at what amount is. Here in my debugger, Rider latest version, it's 24.1099999999999
. Interestingly, in VS 2022 it says it's 24.11 but we'll see below that's a lie .
You still assume 24.11 is expressible as an exact value with a Double. It's not. It's close, but not quite there. It's the base of the issue at hand: you can't determine if the double value has a scale of 2. You have to calculate it by doing a series of calculations. During these calculations the not exact double values will result in a non-exact end result. So the value you pass in, 24.109999999999, has to be broken down to be able to determine what to do with it: does it have a fraction of 0, 2, 3, 10 digits? The only way to determine that is by multiplying it by a number, then round it down, then divide it by that number again to get the fraction back. But that's not a precise calculation, even if it looks like it is; the double is an approximation. So to truncate a double or float, the calculations with the multiply/divide has to take place. there's no other way to determine reliably how big the fraction is and where to truncate.
Let's break down what happens during that calculation if the fraction fits or not. I'll use vs 2022 here as it had the tendency to report the initial value was 24.11. I've copied the code and have specified what the result is of the statement:
bool isFloat = valueAsObject is float; // False
double value = Convert.ToDouble(valueAsObject); // 24.11
double valueWithoutFraction = Math.Truncate(value); // 24
double fraction = Math.Abs(value) - Math.Abs(valueWithoutFraction); // 0.10999999999999943
double scalePower = Math.Pow(10, scale); // 10000
double fractionMultipliedWithPower = scalePower * fraction; // 1099.9999999999943
As you can see, what looked like a perfect 24.11d turns out to be not so perfect. Here there's little else we can do: we can't use the string route (which is just as imprecise if I might add), we can only use perfectly legitimate calculations which should result in values like 0.11, but turn out to be slightly different. These then turn out to have a big influence at the end result.
Your calculation starts with a float constant whereas we are having a double containing that value and assign it to a property of type double.
That really doesn't matter. The 24.11d is still not exactly 24.11 like one would get with a System.Decimal. Some values are expressible in double/float values exactly as you'd expect, but most aren't. That it prints as 24.11 is nice, but not the reality.
Guess it will be an arbitrary choice between Truncate and Round as both might have their own implications. From a usage perspective, I would suspect the Trunctate not to alter the value if the number of decimal is already less or equal to the scale.
Of course one would, but that's the theory. In practice the double/float values aren't as precise as expected and therefore the end result isn't either. I truly wish I was able to determine the fraction of a double reliably and deterministically (or better: that Oracle gets their act together and treats Number(10, 4 like a decimal), but all we have today is this.
With a validator object you can skip validation for e.g. this field btw, but there's no 'turn key solution' that works for all possible double fields.
Joined: 21-Jul-2021
Haha, using VS2022 here indeed... Agree that 24.11 is not exactly represented by the double but clearly VS2022 is hiding that from me.
Since we can't migrate to decimal types (for now), we will have to accept some (rounding) differences.
Tx, Dick
Joined: 17-Aug-2003
Sena wrote:
Haha, using VS2022 here indeed... Agree that 24.11 is not exactly represented by the double but clearly VS2022 is hiding that from me.
Since we can't migrate to decimal types (for now), we will have to accept some (rounding) differences.
Tx, Dick
If it's really unacceptable, you can fine tune the validation btw. If this is one of the fields you really don't need any built-in truncation on, you can set the entity's Validator object to an instance of a class that implements IValidator. You then get the call for validation in that object instead of the built-in validation method. it's a bit cumbersome tho. When you can migrate to a decimal, use the typeconverter route as that's the easiest. When converting back to the db type (double) simply return the decimal value instead so the parameter gets set to the exact decimal value in the entity field. But it's a workaround for what oracle throws at us, sadly...