#!/usr/bin/env python3 """AOSP's fling spline, transcribed independently of the Rust port. This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table` and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code grading its own homework. Every test iris's fling had before 2026-09-07 compared the curve with itself -- monotonic, signed, integrates to the closed form -- and all of them passed while `distance_fraction(t)` was returning exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a test have to come from somewhere else, and this is the somewhere else. Transcribed by hand from, and only from: * frameworks/base `core/java/android/widget/OverScroller.java`, `SplineOverScroller`'s static initialiser, `getSplineDeceleration`, `getSplineFlingDistance`, `getSplineFlingDuration` and `update`. * androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt` (`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and `FlingCalculator.kt` (`computeDeceleration`, `flingDistance`, `flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for line, which is why iris ports one curve rather than two. Run it with no arguments; it prints the table entries and the (velocity, density, t) points the Rust tests assert on. """ NB_SAMPLES = 100 INFLEXION = 0.35 START_TENSION = 0.5 END_TENSION = 1.0 P1 = START_TENSION * INFLEXION P2 = 1.0 - END_TENSION * (1.0 - INFLEXION) SCROLL_FRICTION = 0.015 TUNING = 0.84 GRAVITY_EARTH = 9.80665 INCHES_PER_METER = 39.37 import math DECELERATION_RATE = math.log(0.78) / math.log(0.9) def spline_positions(): """SPLINE_POSITION: distance fraction at each of 101 even time steps.""" position = [0.0] * (NB_SAMPLES + 1) x_min = 0.0 for i in range(NB_SAMPLES): alpha = i / NB_SAMPLES x_max = 1.0 while True: x = x_min + (x_max - x_min) / 2.0 coef = 3.0 * x * (1.0 - x) tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x if abs(tx - alpha) < 1e-5: break if tx > alpha: x_max = x else: x_min = x position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x position[NB_SAMPLES] = 1.0 return position POSITION = spline_positions() def fling_sample(t): """(distance fraction, velocity fraction) at time fraction `t`.""" t = min(max(t, 0.0), 1.0) index = int(t * NB_SAMPLES) if index >= NB_SAMPLES: return 1.0, 0.0 t_inf = index / NB_SAMPLES t_sup = (index + 1) / NB_SAMPLES velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf) return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef def physical_coefficient(density): return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING def deceleration(velocity, density): return math.log( INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density)) ) def fling_distance(velocity, density): l = deceleration(velocity, density) return ( SCROLL_FRICTION * physical_coefficient(density) * math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l) ) def fling_duration_s(velocity, density): l = deceleration(velocity, density) return math.exp(l / (DECELERATION_RATE - 1.0)) def position_at(velocity, density, t_seconds): d = fling_duration_s(velocity, density) return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0] def velocity_at(velocity, density, t_seconds): d = fling_duration_s(velocity, density) return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d if __name__ == "__main__": print("SPLINE_POSITION at a few indices (index: value)") for i in (0, 1, 10, 25, 50, 75, 99, 100): print(f" {i:3}: {POSITION[i]:.6f}") print() print("distance/velocity fraction at time fractions") for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0): d, v = fling_sample(t) print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}") print() # 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md); # 2.75 is this checkout's emulator. for density in (2.55, 2.75): # 15250 is `transcript-fixture/touch/flick-120hz.touch`'s own # release velocity (velocity_reference.py), so `phone_screen.rs` # can bound the fling it produces from *here* rather than from the # `FlingCalculator` under test (docs/REVIEW-2026-09-07.md's T1). for velocity in (5000.0, 11064.0, 15250.0): dur = fling_duration_s(velocity, density) print( f"density={density} v={velocity}: " f"distance={fling_distance(velocity, density):.3f}px " f"duration={dur:.4f}s" ) # Deliberately not round fractions. The velocity coefficient is # piecewise *constant* across each of the 100 samples, so it # steps at t = k/100 and a test asserting on 0.75 is asserting # on which side of a discontinuity the last float landed -- # which is genuinely different between Python and Rust and says # nothing about the curve. for frac in (0.125, 0.335, 0.505, 0.755): t = frac * dur print( f" t={frac:>4} of duration ({t:.4f}s): " f"pos={position_at(velocity, density, t):.3f}px " f"vel={velocity_at(velocity, density, t):.3f}px/s" )