fractional (decimal) heart rate zone calculation

What's the "best" algorithm to calculate fractional HR zones (i.e: zone: 3.45)?

Splitting from: forums.garmin.com/.../how-edge-calculate-current-heart-zone-decimal-value

  • Maybe we should just lock both threads haha

  • Simple code to demonstrate, compare:

  • import Toybox.Lang;
    import Toybox.Test;

    const TEST_HR_ZONES = [128, 153, 162, 180, 185, 190] as Array<Number>;

    function computeHrZoneFlocsy(heartRate as Number, isValidHR as Boolean) as Float {
    var z = INVALID_ZONE, fZ = INVALID_FRACTIONAL_ZONE;
    var heartRateZones = TEST_HR_ZONES;
    for (z = 0; z < 5 && heartRate >= heartRateZones[z] + 1; z++) {
    }
    var min = z > 0 ? heartRateZones[z - 1] + 1 : 30; // use 30 as min for zone 0 to get similar fractional zone number as Garmin
    var max = heartRateZones[z];
    fZ = z + (100.0 * (heartRate - min) / (max + 1 - min)) / 100;
    return fZ;
    }
    function computeHrZoneFlowState(heartRate as Number, isValidHR as Boolean) as Float {
    var z = INVALID_ZONE, fZ = INVALID_FRACTIONAL_ZONE;
    var heartRateZones = TEST_HR_ZONES;
    for (z = 0; z < 5 && heartRate >= heartRateZones[z]; z++) {
    }
    var min = z > 0 ? heartRateZones[z - 1] : 30; // use 30 as min for zone 0 to get similar fractional zone number as Garmin
    var max = heartRateZones[z];
    fZ = z + (100.0 * (heartRate - min) / (max - min)) / 100;
    return fZ;
    }
    (:test)
    function compareHrZones(logger as Logger) as Boolean {
    var prev = 0;
    for (var hr = 0; hr < 256; hr++) {
    var old = computeHrZoneFlocsy(hr, true);
    var new1 = computeHrZoneFlowState(hr, true);
    logger.debug("HR: " + hr + ", HR zone: old: " + old + ", new: " + new1 + ", angle: " + (new1 - prev));
    prev = new1;
    }
    return true;
    }
  • My algorithm doesn't have any multiplication or division by 100, and it also has an absolute max for the input HR (either zone 5 max, 220, or some other number at the implementer's discretion).

    But I guess it's best to ignore those differences for the purposes of directly comparing two algorithms.

    I will reiterate that to be match Garmin's behaviour, you would return 6.0 for any value >= to the user's max HR (which is zone 5 max HR). This would be equivalent to setting the absolute max for the input HR to zone 5 max.

    So if your definition of "best" includes "closest to Garmin", maybe these differences *do* matter.


    Not that it matters, and not that I really care, but here's why I prefer my algorithm:

    - I think it's simpler in some ways (e.g. fewer arbitrary implementation decisions - specifically no need to adjust zones, and no need to decide *how* to adjust zones)

    - it can be used with HR values as real numbers (hypothetical situation ofc), as well as the actual case of integers (so it's a general solution, which is "nicer")

    - it would produce the same values (after scaling) if you took zones of equal width and combined them, or if you divided zones into smaller zones of equal widths. (disregarding values in zone 0 and zone 6, since they're kind of special)

    - my intuition is that it's probably closer to what Garmin does.

    For example, I'm almost certain that Garmin does *not* adjust the user-configured zones (to make them "non-touching") such that zone N min is (zone N-1 max) + 1, as in the other algorithm. As a matter of fact, Garmin does the opposite adjustment, for time in zones (where such an adjustment makes sense): Garmin sets (zone N+1 min) = (zone N max), and reassigns (zone N max) = (zone N+1 min) - 1.

    As far as decimal zones go, my wild guess is that Garmin doesn't do any zone adjustment at all.

    - if all points are graphed and all the points are connected, I claim that my algorithm will produce fewer bends than the other algorithm. I claim that my algorithm will have at most 1 bend (non-differentiable point) per zone border, whereas the other algorithm could have up to 2. (This goes back to the simplicity argument: I think my graph will look simpler.) But this is the claim I have the least confidence about


    But in the end none of this matters and I won't be offended if nobody likes my algorithm haha.

  • For what it's worth, this is closer to what I had mind.

    Really no different except:

    - HR values greater than zone 5 max always result in 6.0

    - HR is explicitly prevented from being lower than 30 (although we know that's not possible)

    - no multiplication / division by 100, but I added the requisite multiplication by 1.0 to change the numerator to a Float

    EDIT: this code is wrong (doesn't match my algorithm), but I'll leave it as-is for the purposes of the historical record (otherwise the next comment won't make any sense)

    function computeHrZoneFlowState(heartRate as Number, isValidHR as Boolean) as Float {
        var z = INVALID_ZONE;
        var fZ = INVALID_FRACTIONAL_ZONE;
        var heartRateZones = TEST_HR_ZONES;
    
        const absolute_min = 30;
        const absolute_max = 220;
    
        // ofc this is superfluous bc Garmin can't read an HR less than 30,
        // but let's pretend we don't know that
        if (heartRate < absolute_min) {
            heartRate = absolute_min;
        }
    
        // match garmin behaviour (any hr>= 220 will produce a decimal zone of 6.0)
        if (heartRate > absolute_max) {
            heartRate = absolute_max;
        }
    
        for (z = 0; z < 5 && heartRate >= heartRateZones[z]; z++) {}
    
        var min = z > 0 ? heartRateZones[z - 1] : absolute_min; // use 30 as min for zone 0 to get similar fractional zone number as Garmin
        var max = heartRateZones[z];
    
        // fZ = z + (100.0 * (heartRate - min) / (max - min)) / 100;
        // This is closer to what I originally had in pseudocode
        fZ = z + 1.0 * (heartRate - min) / (max - min);
        return fZ;
    }

  • Nice. But I just checked it, and the function I called computeHrZoneFlowState above (now renamed to  computeHrZoneTouching) and your's give the exact same numbers for all hr values [30, 220], touching gives negative numbers below 30, so your's is deffinitely better, and above 220, well, your's doesn't do what you think IMHO (but it doesn't do it even above max HR), because above 190 it'll give >6.0 values (which I like), and at 220 it actually gives 12.0. Above 220, well, not sure how realistic it is, but  for example for 255 my touching gives 19.0, your's 12.0. I think I'll add the lower capping at 30, but won't cap at 220. 

  • our's give the exact same numbers for all hr values [30, 220], touching gives negative numbers below 30, so your's is deffinitely better, and above 220, well, your's doesn't do what you think IMHO (but it doesn't do it even above max HR)

    Yeah those are bugs, not intentional parts of the design.

    Part of it is the fact that you literally wrote my code for me (based on your code), and I just tried to change as little as possible to make it look like my original vision.

    The other part of it is that this isn't literally my job, so I made some mistakes when I typed out my implementation.

    your's give the exact same numbers for all hr values [30, 220],

    If you mean that both implementations give the same results for those inputs, then something is obviously wrong.

  • See this is why I kinda regret going down this rabbit hole. We are both expending an unreasonable amount of time and effort arguing about something that doesn't even matter to either of us.

    This is even worse than doing "free labour" for Garmin in helping other users.

    We're not helping anyone with this.

  • your's give the exact same numbers for all hr values [30, 220],

    If you mean that both implementations give the same results for those inputs, then something is obviously wrong.

    I just tried your version of my algorithm, and my version of my algorithm (after changing const to var to fix the build issues).

    Neither of them produce the same values as your algorithm, so I'm not sure what you're talking about. But I'll post a corrected version in a bit, to fix the issues that you mentioned.