"""Reproduce the e-scooter estimate published at utdmobility.com/estimate/. Every input is either a sourced figure (see the references on that page) or an assumption named here with a low, central and high value. Standard library only. Run: python3 estimate.py """ from itertools import product from math import exp # Sourced inputs --------------------------------------------------------------- # UN Comtrade, HS 8711.60 imports (units), reporters 842 (US) and 124 (Canada). IMPORTS = { 'US': {2017: 581_852, 2018: 1_372_582, 2019: 1_927_159, 2020: 5_278_245, 2021: 3_259_069, 2022: 4_394_817, 2023: 3_294_324, 2024: 3_542_400, 2025: 2_375_629}, 'CA': {2017: 38_244, 2018: 55_636, 2019: 124_718, 2020: 256_275, 2021: 488_012, 2022: 528_245, 2023: 408_328, 2024: 496_248, 2025: 554_381}, } # US e-bike imports (units). 2020 and 2021: LEVA via Electrek. 2022: back-calculated from eCycleElectric's # 2023 figure of 990,000, which Vosper reports as down 10%. 2023: eCycleElectric (low) and Bicycle Market # Research (high). 2024: BMR (low) and LEVA's 2025 figure divided by 1.24, since LEVA reports 2025 up 24% (high). 2025: BMR (low) # and LEVA/eCycleElectric (high). EBIKES_LOW = {2020: 450_000, 2021: 790_000, 2022: 1_100_000, 2023: 990_000, 2024: 1_500_000, 2025: 1_300_000} EBIKES_HIGH = {2020: 450_000, 2021: 790_000, 2022: 1_100_000, 2023: 1_300_000, 2024: 1_770_000, 2025: 2_200_000} POP = {'US': 341_784_857, 'CA': 41_798_407, 'DE': 83_500_000} US_HOUSEHOLDS = 134_790_000 DE_PRIVATE_SCOOTERS = 1_370_000 # GDV, 2025 CPSC_RENTAL_INJURIES = 16_000 # US ED visits, 2023, rental e-scooters CPSC_OTHER_INJURIES = 24_300 # non-rental or unspecified US_SHARED_SCOOTER_TRIPS = 63_900_000 # NABSA, 2023 US_SHARE_OF_SHARED_SCOOTERS = 145 / 154 # NABSA, 2023: 145k of 154k shared e-scooters in the US and Canada # Assumptions (low, central, high) ---------------------------------------------- KICK_SHARE = (0.40, 0.55, 0.70) # kick scooters as a share of non-e-bike HS 8711.60 units # Shared-fleet scooters imported each year, to subtract: 154,000 deployed on an average day in # 2023 (NABSA), replaced every 1 to 2 years (Hollingsworth et al. 2019). Listed in the order that # gives a low, central, high result. FLEET_IMPORTS = (154_000, 110_000, 77_000) MEAN_LIFE = (2.5, 3.5, 5.0) # years, exponential survival YTD_2026 = 10 / 12 # 2026 imports to date, at the 2025 pace AS_OF = 2026.8 # late October 2026; imports dated mid-year DE_RATE_FACTOR = {'US': (0.30, 0.60, 1.00), 'CA': (0.20, 0.45, 0.75)} # NA rate relative to Germany SURVEY_HH_SHARE = (0.06, 0.07, 0.09) # US households with at least one e-scooter UNITS_PER_HH = (1.0, 1.25, 1.5) OWNED_SHARE_OF_OTHER = (0.75, 0.90, 1.00) # owned (not borrowed or unknown) share of non-rental injuries RISK_RATIO = (1.5, 1.0, 0.5) # private injury rate per trip relative to shared TRIPS_PER_SCOOTER = (250, 150, 100) # trips a year by a regularly ridden private scooter def scooter_share(year: int, ebikes: dict, kick: float) -> float: """Kick scooters as a share of all US HS 8711.60 units in a year.""" total = IMPORTS['US'][year] return max(total - ebikes[year], 0) / total * kick def stock(country: str, ebikes: dict, kick: float, life: float, fleet: float = FLEET_IMPORTS[1]) -> float: """Privately owned kick scooters still in use, from imports in 2020–2025 plus 2026 to date.""" fleet *= US_SHARE_OF_SHARED_SCOOTERS if country == 'US' else 1 - US_SHARE_OF_SHARED_SCOOTERS private = lambda units: max(units - fleet, 0) total = sum(private(IMPORTS[country][y] * scooter_share(y, ebikes, kick)) * exp(-(AS_OF - (y + 0.5)) / life) for y in range(2020, 2026)) ytd = IMPORTS[country][2025] * YTD_2026 * scooter_share(2025, ebikes, kick) total += max(ytd - fleet * YTD_2026, 0) * exp(-(AS_OF - 2026.4) / life) return total def imports_method(): out = {} for c in ('US', 'CA'): runs = sorted(stock(c, e, k, l, f) for e, k, l, f in product((EBIKES_LOW, EBIKES_HIGH), KICK_SHARE, MEAN_LIFE, FLEET_IMPORTS)) central = (stock(c, EBIKES_LOW, KICK_SHARE[1], MEAN_LIFE[1]) + stock(c, EBIKES_HIGH, KICK_SHARE[1], MEAN_LIFE[1])) / 2 out[c] = (runs[0], central, runs[-1]) return out def germany_method(): rate = DE_PRIVATE_SCOOTERS / POP['DE'] return rate, {c: tuple(POP[c] * rate * f for f in DE_RATE_FACTOR[c]) for c in ('US', 'CA')} def survey_method(): return tuple(US_HOUSEHOLDS * s * u for s, u in zip(SURVEY_HH_SHARE, UNITS_PER_HH)) def injury_method(): shared_rate = CPSC_RENTAL_INJURIES / US_SHARED_SCOOTER_TRIPS fleets = [] for owned, risk, trips in zip(OWNED_SHARE_OF_OTHER, RISK_RATIO, TRIPS_PER_SCOOTER): private_trips = CPSC_OTHER_INJURIES * owned / (shared_rate * risk) fleets.append(private_trips / trips) return shared_rate, tuple(fleets) def intervals(n: int = 20_000, seed: int = 7): """80% intervals for US + Canada, separately for the imports and Germany methods. Assumptions are drawn from triangular distributions over their (low, central, high); e-bike counts are interpolated uniformly between the low and high series. The two methods are reported side by side rather than averaged, since their errors are not independent enough to narrow the range. """ import random rng = random.Random(seed) tri = lambda lo, mid, hi: rng.triangular(lo, hi, mid) rate = DE_PRIVATE_SCOOTERS / POP['DE'] imports, germany = [], [] for _ in range(n): w = rng.random() ebikes = {y: EBIKES_LOW[y] + w * (EBIKES_HIGH[y] - EBIKES_LOW[y]) for y in EBIKES_LOW} kick, life, fleet = tri(*KICK_SHARE), tri(*MEAN_LIFE), tri(*sorted(FLEET_IMPORTS)) imports.append(stock('US', ebikes, kick, life, fleet) + stock('CA', ebikes, kick, life, fleet)) germany.append(sum(POP[c] * rate * tri(*DE_RATE_FACTOR[c]) for c in ('US', 'CA'))) pick = lambda d: (sorted(d)[n // 10], sorted(d)[n // 2], sorted(d)[n * 9 // 10]) return pick(imports), pick(germany) if __name__ == '__main__': m = lambda x: f'{x / 1e6:.3f}M' print('Non-e-bike share of US HS 8711.60 units by year (low e-bikes / high e-bikes):') for y in range(2020, 2026): print(f' {y}: {1 - EBIKES_LOW[y] / IMPORTS["US"][y]:.0%} / {1 - EBIKES_HIGH[y] / IMPORTS["US"][y]:.0%}') imp = imports_method() rate, de = germany_method() survey = survey_method() shared_rate, ridden = injury_method() print('\nImports method (low, central, high)') for c in ('US', 'CA'): print(f' {c}: ' + ', '.join(m(v) for v in imp[c])) print(f'\nGermany: {rate:.2%} of residents own a private e-scooter') for c in ('US', 'CA'): print(f' {c}: ' + ', '.join(m(v) for v in de[c])) print('\nSurvey method, US: ' + ', '.join(m(v) for v in survey)) print(f'\nInjury method: {shared_rate * 1e5:.1f} injuries per 100,000 shared trips') print(' US ridden regularly: ' + ', '.join(m(v) for v in ridden)) combined = [imp['US'][i] + imp['CA'][i] for i in range(3)], [de['US'][i] + de['CA'][i] for i in range(3)] print('\nUS + Canada, imports: ' + ', '.join(m(v) for v in combined[0])) print('US + Canada, Germany: ' + ', '.join(m(v) for v in combined[1])) imp_i, de_i = intervals() print(f'\nUS + Canada, imports: median {m(imp_i[1])}, 80% interval {m(imp_i[0])} to {m(imp_i[2])}') print(f'US + Canada, Germany: median {m(de_i[1])}, 80% interval {m(de_i[0])} to {m(de_i[2])}') print('\nOne-at-a-time sensitivity, imports method, US + Canada (others at central)') mid = lambda e, k, l: stock('US', e, k, l) + stock('CA', e, k, l) base = (mid(EBIKES_LOW, KICK_SHARE[1], MEAN_LIFE[1]) + mid(EBIKES_HIGH, KICK_SHARE[1], MEAN_LIFE[1])) / 2 print(f' central {m(base)}') print(f' e-bike counts low / high: {m(mid(EBIKES_LOW, KICK_SHARE[1], MEAN_LIFE[1]))} / {m(mid(EBIKES_HIGH, KICK_SHARE[1], MEAN_LIFE[1]))}') for name, vals, idx in (('kick-scooter share', KICK_SHARE, 1), ('mean life', MEAN_LIFE, 2)): lo_hi = [] for v in (vals[0], vals[2]): k = v if idx == 1 else KICK_SHARE[1] l = v if idx == 2 else MEAN_LIFE[1] lo_hi.append((mid(EBIKES_LOW, k, l) + mid(EBIKES_HIGH, k, l)) / 2) print(f' {name} {vals[0]} / {vals[2]}: {m(lo_hi[0])} / {m(lo_hi[1])}') fl = [(stock('US', e, KICK_SHARE[1], MEAN_LIFE[1], f) + stock('CA', e, KICK_SHARE[1], MEAN_LIFE[1], f)) for f in (FLEET_IMPORTS[0], FLEET_IMPORTS[2]) for e in (EBIKES_LOW, EBIKES_HIGH)] print(f' shared-fleet imports {FLEET_IMPORTS[0]:,} / {FLEET_IMPORTS[2]:,}: {m((fl[0] + fl[1]) / 2)} / {m((fl[2] + fl[3]) / 2)}')