Repository: pythonnet/pythonnet (language: python)

Issue:
Numerical accuracy when converting from Single to float
### Environment

-   Pythonnet version: 3.0.0rc4
-   Python version: 3.10.5
-   Operating System: Windows 10 20H2
-   .NET Runtime: 6.0.6

### Details

-   Describe what you were trying to get done.

    I'm sorry it's me again with some weird low-level issue. I use a DLL where a function returns a `System.Single`. Its return value is `0.01`. Before pythonnet version 3.0 this got converted to a Python `float` also of value `0.01`. With Pythonnet 3.0 I get a Python `float` `0.009999999776482582` instead. I can reproduce this issue by creating `System.Single` objects.

-   What commands did you run to trigger this issue? If you can provide a
    [Minimal, Complete, and Verifiable example](http://stackoverflow.com/help/mcve)
    this will help us understand the issue.

    ```python
    import clr
    import System

    # Parse a string to avoid conversion from Python float to dotnet
    print(System.Single.Parse('1e-2'))
    # Create a pure dotnet object without conversion back to Python (I think .ToString() gets called here?)
    print(System.Single(0.01))
    # Create a pure dotnet object without conversion back to Python and convert back to Python
    print(System.Single(0.01).MemberwiseClone())
    ```
    ```
    0.009999999776482582
    0.01
    0.009999999776482582
    ```

    With pythonnet 2.5.2 this will return
    ```
    0.01
    0.01
    0.01
    ```



Discussion / hints:
Always check your types )

The three expressions perhaps surprisingly give values of two different types: the 1st and 3rd are Python `float` (which in CPython is a `float64`, so same as `System.Double`), and the 2nd is `System.Single` (`float32`). And the values are actually equal. The reason you see different strings has to do with how precision of the number type affects rounding.

Sadly, it looks like the current behavior deserves its place in [the famous JavaScript WAT video](https://www.destroyallsoftware.com/talks/wat).

The cause for type to be different is that in most cases any `System.Single` value returned to Python from a call to .NET is automatically widened without loss of precision to Python `float` (again, which is == `float64` == `System.Double`) similarly to how `System.Int32` is widened to Python `int` - for convenience. The direct invocation of `System.Single` "constructor" is AFAIK the only exception because it is used to explicitly convert type to `System.Single` in order to choose a .NET overload when necessary.

So I guess that WAT-like behavior is by design, and we won't change it in 3.0.0

Though while playing with your code I noticed that in 3.0 (at least) `float(System.Single(42))` fails, and so does any attempt to use `int()` or `float()` on instances of primitive .NET types, which looks like a bug, but it also might be intentional.

@filmor thoughts on the float(System.Single(42)) issue?

My experiments:
```python
>>> parsed = System.Single.Parse('1e-2')
>>> from_py = System.Single(0.01)
>>> cloned = from_py.MemberwiseClone()
>>> parsed
0.009999999776482582
>>> from_py
<System.Single object at 0x000001E25534B580>
>>> cloned
0.009999999776482582
>>> from_py.ToString()
'0.01'
>>> float(from_py)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: float() argument must be a string or a real number, not 'Single'
>>> parsed == from_py
True
>>> float(from_py)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: float() argument must be a string or a real number, not 'Single'
>>> from_py2 = System.Single(0.02)
>>> parsed == from_py2
False
>>> System.Double(from_py)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: System.Single value cannot be converted to System.Double
>>> System.Array[int](10).Length
10
>>> type(System.Array[int](10).Length)
<class 'int'>
>>> float(System.Int32(42))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: float() argument must be a string or a real number, not 'Int32'
>>> int(System.Int32(42))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'Int32'
```
Thank you for that detailed explanation!

> The reason you see different strings has to do with how precision of the number type affects rounding.

I guessed it's something related to this. That's why I chose that title with _accuracy_. Floats can be evil sometimes.

> Sadly, it looks like the current behavior deserves its place in [the famous JavaScript WAT video](https://www.destroyallsoftware.com/talks/wat).

Thank you, I did not know the video. I laughed out loud and can't wait to send it to colleagues tomorrow. 😂 

> Though while playing with your code I noticed that in 3.0 (at least) `float(System.Single(42))` fails, and so does any attempt to use `int()` or `float()` on instances of primitive .NET types, which looks like a bug, but it also might be intentional.

That's reassuring, I thought I was just too stupid to make it a Python datatype again and therefore came up with

```python
System.Single(0.01).MemberwiseClone()
```

I guess `float(Single(42))` used to work because we implicitly converted `Single` to Python's `float`. We currently don't implement `__float__` or `__int__` on any .NET types, that is what's missing here.
As a workaround I can use `numpy` for the rescue and its `float32`-datatype

```python
import clr
import numpy as np
import System

s = System.Single(0.01).MemberwiseClone()
print(s)
n = np.float32(s)
print(n)
```
```python
0.009999999776482582
0.01
```

Fix patch:
diff --git a/src/runtime/Native/ITypeOffsets.cs b/src/runtime/Native/ITypeOffsets.cs
index 2c4fdf59a..0920a312a 100644
--- a/src/runtime/Native/ITypeOffsets.cs
+++ b/src/runtime/Native/ITypeOffsets.cs
@@ -22,6 +22,7 @@ interface ITypeOffsets
         int nb_true_divide { get; }
         int nb_and { get; }
         int nb_int { get; }
+        int nb_float { get; }
         int nb_or { get; }
         int nb_xor { get; }
         int nb_lshift { get; }
diff --git a/src/runtime/Native/TypeOffset.cs b/src/runtime/Native/TypeOffset.cs
index 59c5c3ee0..847f1766d 100644
--- a/src/runtime/Native/TypeOffset.cs
+++ b/src/runtime/Native/TypeOffset.cs
@@ -31,6 +31,7 @@ static partial class TypeOffset
         internal static int nb_or { get; private set; }
         internal static int nb_xor { get; private set; }
         internal static int nb_int { get; private set; }
+        internal static int nb_float { get; private set; }
         internal static int nb_lshift { get; private set; }
         internal static int nb_rshift { get; private set; }
         internal static int nb_remainder { get; private set; }
diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs
index 1e3c325cc..943841e25 100644
--- a/src/runtime/Types/ClassBase.cs
+++ b/src/runtime/Types/ClassBase.cs
@@ -529,6 +529,37 @@ static NewReference tp_call_impl(BorrowedReference ob, BorrowedReference args, B
             return callBinder.Invoke(ob, args, kw);
         }
 
+        static NewReference DoConvert(BorrowedReference ob)
+        {
+            var self = (CLRObject)GetManagedObject(ob)!;
+            using var python = self.inst.ToPython();
+            return python.NewReferenceOrNull();
+        }
+
+        static NewReference DoConvertInt(BorrowedReference ob)
+        {
+            var self = (CLRObject)GetManagedObject(ob)!;
+            return Runtime.PyLong_FromLongLong(Convert.ToInt64(self.inst));
+        }
+
+        static NewReference DoConvertUInt(BorrowedReference ob)
+        {
+            var self = (CLRObject)GetManagedObject(ob)!;
+            return Runtime.PyLong_FromUnsignedLongLong(Convert.ToUInt64(self.inst));
+        }
+
+        static NewReference DoConvertBooleanInt(BorrowedReference ob)
+        {
+            var self = (CLRObject)GetManagedObject(ob)!;
+            return Runtime.PyInt_FromInt32((bool)self.inst ? 1 : 0);
+        }
+
+        static NewReference DoConvertFloat(BorrowedReference ob)
+        {
+            var self = (CLRObject)GetManagedObject(ob)!;
+            return Runtime.PyFloat_FromDouble(Convert.ToDouble(self.inst));
+        }
+
         static IEnumerable<MethodInfo> GetCallImplementations(Type type)
             => type.GetMethods(BindingFlags.Public | BindingFlags.Instance)
                 .Where(m => m.Name == "__call__");
@@ -564,6 +595,29 @@ public virtual void InitializeSlots(BorrowedReference pyType, SlotsHolder slotsH
             {
                 TypeManager.InitializeSlotIfEmpty(pyType, TypeOffset.mp_length, new Interop.B_P(MpLengthSlot.impl), slotsHolder);
             }
+
+            switch (Type.GetTypeCode(type.Value))
+            {
+                case TypeCode.Boolean:
+                case TypeCode.SByte:
+                case TypeCode.Int16:
+                case TypeCode.Int32:
+                case TypeCode.Int64:
+                    TypeManager.InitializeSlotIfEmpty(pyType, TypeOffset.nb_int, new Interop.B_N(DoConvertInt), slotsHolder);
+                    TypeManager.InitializeSlotIfEmpty(pyType, TypeOffset.nb_float, new Interop.B_N(DoConvertFloat), slotsHolder);
+                    break;
+                case TypeCode.Byte:
+                case TypeCode.UInt16:
+                case TypeCode.UInt32:
+                case TypeCode.UInt64:
+                    TypeManager.InitializeSlotIfEmpty(pyType, TypeOffset.nb_int, new Interop.B_N(DoConvertUInt), slotsHolder);
+                    TypeManager.InitializeSlotIfEmpty(pyType, TypeOffset.nb_float, new Interop.B_N(DoConvertFloat), slotsHolder);
+                    break;
+                case TypeCode.Double:
+                case TypeCode.Single:
+                    TypeManager.InitializeSlotIfEmpty(pyType, TypeOffset.nb_float, new Interop.B_N(DoConvertFloat), slotsHolder);
+                    break;
+            }
         }
 
         public virtual bool HasCustomNew() => this.GetType().GetMethod("tp_new") is not null;
diff --git a/src/runtime/Types/OperatorMethod.cs b/src/runtime/Types/OperatorMethod.cs
index 0c6127f65..88939a51d 100644
--- a/src/runtime/Types/OperatorMethod.cs
+++ b/src/runtime/Types/OperatorMethod.cs
@@ -53,6 +53,7 @@ static OperatorMethod()
                 ["op_UnaryPlus"] = new SlotDefinition("__pos__", TypeOffset.nb_positive),
 
                 ["__int__"] = new SlotDefinition("__int__", TypeOffset.nb_int),
+                ["__float__"] = new SlotDefinition("__float__", TypeOffset.nb_float),
             };
             ComparisonOpMap = new Dictionary<string, string>
             {


Test patch:
diff --git a/tests/test_conversion.py b/tests/test_conversion.py
index f8d8039c6..69e7ec63f 100644
--- a/tests/test_conversion.py
+++ b/tests/test_conversion.py
@@ -720,3 +720,28 @@ def test_intptr_construction():
         with pytest.raises(OverflowError):
             UIntPtr(v)
 
+def test_explicit_conversion():
+    from System import (
+        Int64, UInt64, Int32, UInt32, Int16, UInt16, Byte, SByte, Boolean
+    )
+    from System import Double, Single
+
+    assert int(Boolean(False)) == 0
+    assert int(Boolean(True)) == 1
+
+    for t in [UInt64, UInt32, UInt16, Byte]:
+        assert int(t(127)) == 127
+        assert float(t(127)) == 127.0
+
+    for t in [Int64, Int32, Int16, SByte]:
+        assert int(t(127)) == 127
+        assert int(t(-127)) == -127
+        assert float(t(127)) == 127.0
+        assert float(t(-127)) == -127.0
+
+    assert int(Int64.MaxValue) == 2**63 - 1
+    assert int(Int64.MinValue) == -2**63
+    assert int(UInt64.MaxValue) == 2**64 - 1
+
+    for t in [Single, Double]:
+        assert float(t(0.125)) == 0.125