Number vs Float discrepancy between simulator and device

I’ve run into a weird issue and would like your opinions.

I have code that performs some calculations that should produce a Number, and then instantiates an object whose constructor expects a Number.

The compiler seems happy that these calculations result in a Number and allows me to pass the result into the constructor. Later, I store that value in a member typed as Object, and later convert it back to a Number again. At that point I had a safety instanceof check to confirm the Object really is a Number.

In the simulator everything works fine, but on some real devices (under some conditions) it looks like the calculation results in a Float. Monkey C “duck typing” then lets the Float propagate without complaint until my explicit check, which is how I noticed.

To me the core issue is that the compiler believes the code results in a Number, the simulator behaves accordingly, but real devices sometimes behave differently.

Here is the relevant code (with comments):

var minValue = sitemapNumeric.getMinValue(); // returns a Number
var maxValue = sitemapNumeric.getMaxValue(); // returns a Number
var step     = sitemapNumeric.getStep();     // returns a Number

var currentValue =
    numericItem.hasState()
    ? numericItem.getNumericState() // returns a Number
    : (minValue + Math.round((maxValue - minValue) / (2 * step)) * step).toNumber();

for (var i = minValue; i <= maxValue; i += step) {
    if (_currentIndex == -1 && currentValue <= i) {
        _currentIndex = _pickables.size();
        if (i != currentValue) {
            _nonConforming = new NumericPickable(currentValue, unit); // expects Number
            _pickables.add(_nonConforming);
        }
    }
    _pickables.add(new NumericPickable(i, unit)); // expects Number
}

And here is NumericPickable:

class NumericPickable extends CustomPickable {
    public function initialize(value as Number, unit as String) {

        // Workaround: force it to be a Number, so later reads are consistent
        value = value.toNumber();

        // CustomPickable stores the first parameter as Object
        CustomPickable.initialize(value, value.toString() + unit);
    }
}

Has anyone seen something like this before?

Once I understand what’s going on, the workaround is easy (the extra toNumber() in NumericPickable). But I’d really like to understand why the compiler and simulator treat this as a Number, while some real devices end up producing a Float.

If anyone is interested, here are the full source files. The first file contains the calculation discussed above, starting at line 39:

https://github.com/openhab/openhab-garmin/blob/main/source/user-interface/control-views/numeric/NumbericPickerFactory.mc

https://github.com/openhab/openhab-garmin/blob/main/source/user-interface/control-views/numeric/NumericPickable.mc

  • 1. if indeed everything is Number then you don't need the Math.round, because then it's integer arithmetic and you anyway have a Number there.

    2. for the same reason in theory you don't need the toNumber in the same line.

    3. you didn't tell us where the functions in sitemapNumeric get the values from. This is important probably, because probably there is some assumption that might be incorrect.

    Even in the past 2 weeks there was some discussion about some SDK function that is documented as returning Number, but if the value gets big then it returns Float. Similar changes also happened about a year ago in some firmware update if I remember correctly. So maybe all you need to do is to assume you get Numeric instead of Number.

  • As flocsy said, the most likely answer is that sitemap.getMinValue/getMaxValue/getStep don't always return Number, as you assume.

    Looking into the code, those functions return SitemapNumeric._minValue/_maxValue/_step, which are initialized from a JSON object.

    But there's no code that actually ensures the data extracted from JSON objects is actually of type Number (or Boolean, or String, or object, for that matter).

    Your code is *casting* the data in JSON objects to the desired type (Number, etc.), rather than doing runtime type checks (using instanceof) as it should be.

    I see that this JSON data is coming from an external server which would explain why it sometimes shows up in Monkey C as Float instead of a Number. (I *think* JSON values with no decimal place like "5" will be deserialized as Number in Monkey C, while JSON values like "5.0" will be deserialized as Float.)

    EDIT: no, as discussed below, the real problem is more likely that a real device always deserializes JSON numbers as Float, while the simulator deserializes as either Float or Number.

    The answer here (as with typescript) is that for data which crosses an I/O boundary, the compile-time type checker is useless. You can't assert at compile time that some value from the network is a Number, you have to actually enforce that with a runtime check.

    Btw, your use of "...Numeric" is misleading in a Monkey C app. In Monkey C, "Numeric" doesn't mean "relating to the Number type", it's a type alias which is used for numerical [*] data and comprises these 4 types: Float, Double, Number and Long.

    [*] ("numerical" in the generic English sense)

    [https://developer.garmin.com/connect-iq/api-docs/Toybox/Lang.html#Numeric-named_type]

  • To expand on what flocsy said, let's revisit this code:

    var currentValue =
        numericItem.hasState()
        ? numericItem.getNumericState() // returns a Number
        : (minValue + Math.round((maxValue - minValue) / (2 * step)) * step).toNumber();

    Yes, you have Math.round() and toNumber() here, but if you really thought minValue, maxValue and step were always Number (and not Float), those calls would be unnecessary.

    Given that those calls are there, why isn't currentValue always a Number? Because of "minValue +" at the beginning of the final sub-expression. If minValue is a Float, then the expression will be Float + Number which results in a Float.

    The only reason the compiler thinks that expression is a Number is because you said that minValue is a Number, but as discussed in the previous comment, minValue comes from the network and you did nothing to actually ensure it's a Number (on the client side).

  • (Sorry for the comment-as-image, but the forum wouldn't let me post it normally)

  • The compiler seems happy that these calculations result in a Number

    To drive the point home, the compiler (type checker) is happy because the code contains type casts, the sole purpose of which is to (trivially) make the type checker happy, which is why they're so dangerous.

  • 1. if indeed everything is Number then you don't need the Math.round, because then it's integer arithmetic and you anyway have a Number there.
    Yes, you have Math.round() and toNumber() here, but if you really thought minValue, maxValue and step were always Number (and not Float), those calls would be unnecessary.

    The calculation involves a division, which may produce a non-integer result even when all inputs are integers. Therefore, I apply Math.round() and toNumber() on that line.

    But there's no code that actually ensures the data extracted from JSON objects is actually of type Number (or Boolean, or String, or object, for that matter).

    That’s a very good point, and I’ll address it. When I originally wrote that code, I was implicitly assuming that an incorrect cast would result in a runtime error.

    In this particular case, however, I know that all values involved are intended to be numbers. The minValue and maxValue are not set in the JSON I received from the affected user, so they fall back to the numeric defaults defined in the code. The step value is set in the JSON, but as "5" rather than "5.0".

    I’ve also been testing the same JSON in the simulator, where it works as expected and all values end up being treated as Numbers. This suggests that the issue is not the logic itself, but rather a behavioral difference between the simulator and the real device.

    That said, reading the step value from the JSON may indeed be the source of this discrepancy. Adding the checks you suggested should help clarify what’s actually happening.

    I also need to do some additional testing on my own device. So far, I’ve received ERA error reports for this issue from Fenix 6 Pro, Fenix 7, and Fenix 8 devices, and one of the affected users has been helping me with debugging. However, I haven’t been able to reproduce the problem on my own Epix 2 Pro.

  • The calculation involves a division, which may produce a non-integer result even when all inputs are integers. Therefore, I apply Math.round() and toNumber() on that line.

    Actually, not true. In Monkey C, x / y (where both x and y are integers) results in integer division, just like a few other C-like languages. 

    System.println("5 / 3 = " + (5 / 3));
    System.println("5.0 / 3 = " + (5.0 / 3));

    Output:

    5 / 3 = 1
    5.0 / 3 = 1.6666666

    If you actually want floating-point division to be performed, you have to ensure one of the operands is a float (e.g. multiply by 1.0).

  • This suggests that the issue is not the logic itself, but rather a behavioral difference between the simulator and the real device.

    What's probably happening is that JSON numbers are generally (or always) deserialized as Float on a real device, regardless of whether they have a decimal point or not. (I know I typed that I think they might be deserialized as Number in some cases, but tbh I had a lot of doubts about that.)

    I will say that I still think the issue is in logic: the logic assumes that something like "5" in JSON will be deserialized as a Number.

    I get what you mean, but it's still a bug in program logic, in a real sense. It's just too bad that it's obscured by the simulator.

    On a somewhat related note, there's a long-standing complaint that makeWebRequest will deserialize JSON numbers to Float even with the number has so much precision that a Double would be more suitable.

  • I will say that I still think the issue is in logic: the logic assumes that "5" in JSON will be deserialized as a Number.

    Yes, in that sense I agree, what I did there wasn’t safe and proper. And thanks again for pointing it out; when analyzing the values, I didn’t mentally go back that far. I more or less assumed that if the accessors return Number, then the underlying values must also be numbers, but of course that doesn’t have to be the case.

    I’ll fix this, and I don’t think it should be too difficult. All JSON access is centralized in a single class, with one accessor function per data type, so I only need to adjust a few functions to make at least the JSON access type-safe.

  • Cool. And note the edit I made to my previous reply:

    The calculation involves a division, which may produce a non-integer result even when all inputs are integers. Therefore, I apply Math.round() and toNumber() on that line.

    Actually, not true. In Monkey C, x / y where both x and y are integers results in integer division, just like a few other C-like languages.