Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -291,13 +291,19 @@ public enum StrainScorer {
// MARK: - Logarithmic map

/// Map accumulated TRIMP onto [0, 100] via 100 × ln(TRIMP+1) / ln(D), 2 dp.
/// TRIMP ≤ 0 → 0.
/// TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain. The output is
/// unbounded as D → 1⁺ — an upper clamp is tracked separately.
///
/// The default D is **Edwards'**. A Banister TRIMP passed here without an explicit denominator is
/// scored against the wrong ceiling and reads low — prefer `strain(…)`, which resolves the
/// method's own denominator, or pass `logMapDenominator(method:sex:)` yourself. (#1545)
public static func trimpToStrain(_ trimp: Double, denominator: Double = strainDenominator) -> Double {
if trimp <= 0 { return 0 }
// D ≤ 1 (and NaN) is outside the map's domain: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1
// flips the sign, and ln(D) is NaN at or below 0. Out-of-domain D is no score, like
// TRIMP ≤ 0 — before this guard D = 1 returned +Inf here and a saturated 9.2e16 on Android
// for the same input (the denominator-domain fix). The default 7201 is unaffected.
guard denominator > 1 else { return 0 }
let value = maxStrain * log(trimp + 1.0) / log(denominator)
return (value * 100).rounded() / 100
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import XCTest
@testable import StrandAnalytics

/// Log-map denominator behavior outside the map's domain.
///
/// D ≤ 1 has no ln-based score: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 flips the sign, and ln(D)
/// is NaN at or below 0. Before the guard, Swift returned `+Inf` for D = 1 while Kotlin's
/// `roundToLong()` saturated to 9.223372036854776E16 — the same input, two different answers.
/// The Kotlin twin is `StrainScorerDenominatorDomainTest`; its expected literals are the stdout of
/// this file's formula compiled standalone.
final class StrainDenominatorDomainTests: XCTestCase {

func testDenominatorAtOrBelowOneScoresZero() {
for denominator in [1.0, 0.5, 0.0, -3.0, Double.nan] {
let s = StrainScorer.trimpToStrain(1, denominator: denominator)
XCTAssertTrue(s.isFinite, "D = \(denominator) produced a non-finite Effort")
XCTAssertEqual(s, 0.0, accuracy: 0, "D = \(denominator)")
}
}

func testValidDenominatorsAreUnchanged() {
XCTAssertEqual(StrainScorer.trimpToStrain(1, denominator: 7201), 7.8, accuracy: 1e-9)
XCTAssertEqual(StrainScorer.trimpToStrain(100, denominator: 7201), 51.96, accuracy: 1e-9)
XCTAssertEqual(StrainScorer.trimpToStrain(7200, denominator: 7201), 100.0, accuracy: 1e-9)
XCTAssertEqual(StrainScorer.trimpToStrain(1, denominator: 2), 100.0, accuracy: 1e-9)
}

/// Just above the boundary the score is enormous but finite, and it must stay a Double on both
/// platforms — this is the value Kotlin's `roundToLong()` clipped to Long.MAX / 100.
///
/// Loosely toleranced on purpose: ln(1 + 2⁻⁵²) is ~2.2e-16, so a 1-ulp difference between
/// Darwin's and glibc's `log` moves the quotient by ~±30 absolute. Linux/glibc observes exactly
/// 3.1216573840826803e+17; the assertion checks the magnitude, not the last bits.
func testDenominatorJustAboveOneStaysFinite() {
let s = StrainScorer.trimpToStrain(1, denominator: 1.0000000000000002)
XCTAssertTrue(s.isFinite)
XCTAssertEqual(s, 3.1216573840826803e+17, accuracy: 1e4)
}

/// Non-finite TRIMP propagates rather than being caught by the domain guard — the guard is about
/// D, not TRIMP. Both platforms agree here only because Kotlin no longer rounds through Long
/// (`roundToLong()` mapped +∞ to Long.MAX), so pin the non-finite propagation behavior.
func testNonFiniteTrimpPropagates() {
let inf = StrainScorer.trimpToStrain(.infinity, denominator: 7201)
XCTAssertFalse(inf.isFinite)
XCTAssertFalse(inf.isNaN)
XCTAssertGreaterThan(inf, 0)
XCTAssertTrue(StrainScorer.trimpToStrain(.nan, denominator: 7201).isNaN)
}
}
19 changes: 17 additions & 2 deletions android/app/src/main/java/com/noop/analytics/StrainScorer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -340,16 +340,31 @@ object StrainScorer {

/**
* Map accumulated TRIMP onto [0, 100] via 100 × ln(TRIMP+1) / ln(D), 2 dp.
* TRIMP ≤ 0 → 0.
* TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain. The output is
* unbounded as D → 1⁺ — an upper clamp is tracked separately.
*
* The default D is **Edwards'**. A Banister TRIMP passed here without an explicit denominator is
* scored against the wrong ceiling and reads low — prefer [strain], which resolves the method's own
* denominator, or pass [logMapDenominator] yourself. (#1545)
*/
fun trimpToStrain(trimp: Double, denominator: Double = strainDenominator): Double {
if (trimp <= 0) return 0.0
// D ≤ 1 (and NaN) is outside the map's domain: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1
// flips the sign, and ln(D) is NaN at or below 0. Out-of-domain D is no score, like
// TRIMP ≤ 0 — before this guard D = 1 returned a saturated 9.2e16 here and +Inf on Swift
// for the same input (the denominator-domain fix). The default 7201 is unaffected.
if (!(denominator > 1)) return 0.0
val value = maxStrain * ln(trimp + 1.0) / ln(denominator)
return (value * 100).roundToLong() / 100.0
val scaled = value * 100
// Round in Double space: roundToLong() clips anything past Long.MAX_VALUE, which Swift's
// .rounded() does not, so a D just above 1 still disagreed across platforms (the
// denominator-domain fix). Above 2^53 every Double is already an integer, so passing it
// through IS the rounded value.
// Keep this comparison in this direction: abs(NaN) < 2^53 is false, so NaN intentionally
// takes the pass-through; reversing it to `>= 2^53` would send NaN through `roundToLong()`,
// collapse it to 0.0, and break parity with Swift.
val rounded = if (abs(scaled) < 9007199254740992.0) scaled.roundToLong().toDouble() else scaled
return rounded / 100.0
}

// ---- Denominator calibration ----
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.noop.analytics

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

/**
* Log-map denominator behavior outside the map's domain, against the compiled Swift twin.
*
* D ≤ 1 has no ln-based score: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 flips the sign, and ln(D)
* is NaN at or below 0. Before the guard, Swift returned `+Inf` for D = 1 while this side's
* `roundToLong()` saturated to 9.223372036854776E16 — the same input, two different answers.
*
* Every expected value below is the verbatim stdout of `StrainScorer.swift`'s `trimpToStrain`,
* extracted and compiled standalone on Linux (`swiftc -O main.swift -o t && ./t`), printed as
* `trimp|D|effort` with `%.17g`:
*
* ```
* 1.0000|1|0
* 1.0000|0.5|0
* 1.0000|0|0
* 1.0000|-3|0
* 1.0000|nan|0
* 0.0000|7201|0
* 1.0000|7201|7.7999999999999998
* 100.0000|7201|51.960000000000001
* 7200.0000|7201|100
* 1.0000|1.0000000000000002|3.1216573840826803e+17
* 1.0000|2|100
* ```
*/
class StrainScorerDenominatorDomainTest {

@Test
fun `denominator at or below one scores zero`() {
for (denominator in listOf(1.0, 0.5, 0.0, -3.0, Double.NaN)) {
val s = StrainScorer.trimpToStrain(1.0, denominator)
assertTrue("D = $denominator produced a non-finite Effort", s.isFinite())
assertEquals("D = $denominator", 0.0, s, 0.0)
}
}

@Test
fun `valid denominators are unchanged`() {
assertEquals(7.7999999999999998, StrainScorer.trimpToStrain(1.0, 7201.0), 1e-9)
assertEquals(51.960000000000001, StrainScorer.trimpToStrain(100.0, 7201.0), 1e-9)
assertEquals(100.0, StrainScorer.trimpToStrain(7200.0, 7201.0), 1e-9)
assertEquals(100.0, StrainScorer.trimpToStrain(1.0, 2.0), 1e-9)
}

/**
* Just above the boundary the score is enormous but finite, and it must stay a Double on both
* platforms — this is the value `roundToLong()` clipped to Long.MAX / 100.
*
* Loosely toleranced on purpose: ln(1 + 2⁻⁵²) is ~2.2e-16, so a 1-ulp difference between the
* JVM's and Darwin's `log` moves the quotient by ~±30 absolute. Linux observes exactly
* 3.1216573840826803e+17 on both sides; the assertion checks the magnitude, not the last bits.
*/
@Test
fun `denominator just above one stays finite`() {
val s = StrainScorer.trimpToStrain(1.0, 1.0000000000000002)
assertTrue(s.isFinite())
assertEquals(3.1216573840826803e+17, s, 1e4)
}

/**
* Non-finite TRIMP propagates rather than being caught by the domain guard — the guard is about
* D, not TRIMP. Both platforms agree here only because this side no longer rounds through Long
* (`roundToLong()` mapped +∞ to Long.MAX), so pin the non-finite propagation behavior. Swift
* oracle, same formula:
* `inf|7201.0|inf`, `nan|7201.0|nan`.
*/
@Test
fun `non-finite trimp propagates`() {
val inf = StrainScorer.trimpToStrain(Double.POSITIVE_INFINITY, 7201.0)
assertTrue(!inf.isFinite())
assertTrue(!inf.isNaN())
assertTrue(inf > 0)
assertTrue(StrainScorer.trimpToStrain(Double.NaN, 7201.0).isNaN())
}
}