"""Who Rides: every figure in Vugo's post, rebuilt from the federal travel survey's public data.

Post: https://www.govugo.com/blog/who-rides-rideshare-passenger-demographics/
Data: FHWA National Household Travel Survey (NHTS), public-use CSV files, free at https://nhts.ornl.gov/downloads
  2022 NHTS, version 2: https://nhts.ornl.gov/assets/2022/download/csv.zip (codebook: .../2022/download/codebook.xlsx)
    unzip it and pass perv2pub.csv, the person file.
  2017 NHTS: https://nhts.ornl.gov/assets/2016/download/csv.zip (codebook: .../2016/download/codebook.xlsx)
    unzip it and pass perpub.csv, the person file, and trippub.csv, the trip file.

SHA-256 of the files this was run on (downloaded 2026-10-29), so you can confirm you have the same versions:
  perv2pub.csv  fa4d816ccd4d1975ff8afd394ce50b1d2326fa2c0b07b8d7f36636ea670fd89c
  perpub.csv    e75e9e1f0ff1438e37a8227e0c166b6db9ef8444cff5db1fa79085e36d0609cc
  trippub.csv   17449ee491a9acc64db65aa2336e38e1a228cba200b6baacae0448fe4e9e2e36
The output, who-rides-figures.csv, is published beside this script.

Run:  python3 who-rides-figures.py perv2pub.csv perpub.csv trippub.csv > who-rides-figures.csv
Python 3 standard library only. Every figure is weighted with the survey's own final weights
(WTPERFIN for people, WTTRDFIN for trips); n is the unweighted number of survey respondents (or trips).
No replicate weights are used, so the output carries no margins of error.

Definitions (variable names and codes are from each year's codebook):
  2022 rider: LAST30_RDSHR = 01 ("used rideshare, e.g. Uber or Lyft, in the last 30 days").
  Base: everyone who answered the question yes (01) or no (02); the question is asked of people 16 and older,
  so the valid skips (-1) are the 2,193 people under 16. "Don't know" answers (-8) are reported as a second base for the headline.
  Days: RIDESHARE22 = number of days rideshare was used in the last 30 days (riders only).
  Age R_AGE; household income HHFAMINC (01-04 under $35,000; 10-11 $150,000+);
  education EDUC (07 master's, 08 professional or doctorate); vehicles HHVEHCNT (0 = none);
  urban or rural URBRUR (01 urban, 02 rural); metro size MSASIZE (05 = 3 million or more).
  Taxi: LAST30_TAXI = 01, base 01 or 02.
  2017 rider: RIDESHARE (times a rideshare app was used in the last 30 days) > 0, people 16 and older.
  2017 trips: TRPTRANS = 17 (taxi or limousine, including Uber and Lyft). Destination is the trip's
  non-home end: WHYTO, or WHYFROM when WHYTO is home (01 or 02). Start time STRTTIME; weekend TDWKND = 01.
Group missing values (negative codes) are left out of that group's figures, never counted as "no".
"""
import csv, sys

def code(v):
    try: return int(float(v))
    except (TypeError, ValueError): return None

out = csv.writer(sys.stdout)
out.writerow(['figure', 'value', 'survey', 'definition', 'unweighted_n_base', 'unweighted_n_riders'])
def row(name, value, survey, definition, n_base='', n_riders=''):
    out.writerow([name, value, survey, definition, n_base, n_riders])
def pct(a, b): return f'{100 * a / b:.2f}%'

# ---- 2022 person file --------------------------------------------------------------------------
people = []
for r in csv.DictReader(open(sys.argv[1], newline='')):
    people.append(r)

base = [p for p in people if code(p['LAST30_RDSHR']) in (1, 2)]
dk = [p for p in people if code(p['LAST30_RDSHR']) == -8]
riders = [p for p in base if code(p['LAST30_RDSHR']) == 1]
W = lambda rows: sum(float(p['WTPERFIN']) for p in rows)
wb, wr = W(base), W(riders)
row('Used rideshare in the past 30 days, people 16+', pct(wr, wb), 'NHTS 2022', 'LAST30_RDSHR=01; base 01 or 02', len(base), len(riders))
row('Same, counting "don\'t know" answers in the base', pct(wr, wb + W(dk)), 'NHTS 2022', 'LAST30_RDSHR=01; base 01, 02 or -8', len(base) + len(dk), len(riders))
row('Riders 16+, people', f'{wr / 1e6:.1f} million', 'NHTS 2022', 'sum of WTPERFIN, LAST30_RDSHR=01', '', len(riders))

def group(name, test, survey='NHTS 2022', definition=''):
    """Share of the group who rode, and the group's share of all riders."""
    g = [p for p in base if test(p)]
    gr = [p for p in g if code(p['LAST30_RDSHR']) == 1]
    row(f'{name}: share who rode', pct(W(gr), W(g)), survey, definition, len(g), len(gr))
    row(f'{name}: share of all riders', pct(W(gr), wr), survey, definition + '; base all riders', len(riders), len(gr))

age = lambda p: code(p['R_AGE'])
for lo, hi in ((16, 20), (21, 29), (30, 49), (50, 64), (65, 200)):
    label = f'Ages {lo} to {hi}' if hi < 200 else 'Ages 65 and older'
    group(label, lambda p, lo=lo, hi=hi: age(p) is not None and lo <= age(p) <= hi, definition=f'R_AGE {lo}-{hi}' if hi < 200 else 'R_AGE 65+')
group('Ages 21 and older', lambda p: age(p) is not None and age(p) >= 21, definition='R_AGE 21+')
days = lambda p: max(code(p['RIDESHARE22']) or 0, 0)
d_all = sum(float(p['WTPERFIN']) * days(p) for p in riders)
d_21 = sum(float(p['WTPERFIN']) * days(p) for p in riders if (age(p) or 0) >= 21)
row('Ages 21 and older: share of all rideshare days', pct(d_21, d_all), 'NHTS 2022', 'RIDESHARE22 days x WTPERFIN, R_AGE 21+', len(riders), sum(1 for p in riders if (age(p) or 0) >= 21))
row('Riders 21+, people', f'{W([p for p in riders if (age(p) or 0) >= 21]) / 1e6:.1f} million', 'NHTS 2022', 'sum of WTPERFIN, R_AGE 21+', '', sum(1 for p in riders if (age(p) or 0) >= 21))

inc = lambda p: code(p['HHFAMINC'])
group('Household income $150,000 or more', lambda p: inc(p) in (10, 11), definition='HHFAMINC 10-11')
group('Household income under $35,000', lambda p: inc(p) in (1, 2, 3, 4), definition='HHFAMINC 01-04')
group('Graduate degree', lambda p: code(p['EDUC']) in (7, 8), definition='EDUC 07-08')
group("Bachelor's degree", lambda p: code(p['EDUC']) == 6, definition='EDUC 06')
group('No vehicle in the household', lambda p: code(p['HHVEHCNT']) == 0, definition='HHVEHCNT 0')
group('Urban areas', lambda p: code(p['URBRUR']) == 1, definition='URBRUR 01')
group('Rural areas', lambda p: code(p['URBRUR']) == 2, definition='URBRUR 02')
group('Metro of 3 million or more', lambda p: code(p['MSASIZE']) == 5, definition='MSASIZE 05')

# How often: mean days, and the share of rideshare days taken by the most frequent half of riders.
with_days = [(days(p), float(p['WTPERFIN'])) for p in riders if days(p) > 0]
tw = sum(w for _, w in with_days)
row('Mean rideshare days in 30, riders', f'{sum(d * w for d, w in with_days) / tw:.2f}', 'NHTS 2022', 'RIDESHARE22 > 0, weighted mean', len(with_days), len(with_days))
half, got, cum = tw / 2, 0.0, 0.0
for d, w in sorted(with_days, reverse=True):
    take = min(w, half - cum)
    if take <= 0: break
    got += d * take; cum += take
row('Most frequent half of riders: share of rideshare days', pct(got, sum(d * w for d, w in with_days)), 'NHTS 2022', 'riders ranked by RIDESHARE22, top 50% of rider weight', len(with_days), len(with_days))

taxi_base = [p for p in people if code(p['LAST30_TAXI']) in (1, 2)]
taxi = [p for p in taxi_base if code(p['LAST30_TAXI']) == 1]
row('Used a taxi service in the past 30 days, people 16+', pct(W(taxi), W(taxi_base)), 'NHTS 2022', 'LAST30_TAXI=01; base 01 or 02', len(taxi_base), len(taxi))
row('Taxi users 16+, people', f'{W(taxi) / 1e6:.1f} million', 'NHTS 2022', 'sum of WTPERFIN, LAST30_TAXI=01', '', len(taxi))

# ---- 2017 person file --------------------------------------------------------------------------
b17 = r17 = 0.0; nb17 = nr17 = 0
for p in csv.DictReader(open(sys.argv[2], newline='')):
    a, t = code(p['R_AGE']), code(p['RIDESHARE'])
    if a is None or a < 16 or t is None or t < 0: continue
    w = float(p['WTPERFIN']); b17 += w; nb17 += 1
    if t > 0: r17 += w; nr17 += 1
row('Used rideshare in the past 30 days, people 16+', pct(r17, b17), 'NHTS 2017', 'RIDESHARE > 0, R_AGE 16+', nb17, nr17)

# ---- 2017 trip file ----------------------------------------------------------------------------
names = {3: 'Work', 15: 'Recreation (parks, movies, bars, museums)', 13: 'Restaurants (buy meals)', 17: 'Visiting friends or relatives',
         11: 'Shopping (buy goods)', 7: 'Change of mode (airport, station)', 18: 'Health care', 4: 'Work-related'}
dest, tot, n, night, wkend = {}, 0.0, 0, 0.0, 0.0
for t in csv.DictReader(open(sys.argv[3], newline='')):
    if code(t['TRPTRANS']) != 17: continue
    w = float(t['WTTRDFIN']); to, fr = code(t['WHYTO']), code(t['WHYFROM'])
    end = fr if to in (1, 2) else to
    dest[end] = dest.get(end, 0) + w; tot += w; n += 1
    if 1900 <= (code(t['STRTTIME']) or 0) <= 2359: night += w
    if code(t['TDWKND']) == 1: wkend += w
for c, label in names.items():
    row(f'Taxi and rideshare trips by destination: {label}', pct(dest.get(c, 0), tot), 'NHTS 2017', f'TRPTRANS=17; non-home end code {c:02d}; WTTRDFIN', n, '')
row('Taxi and rideshare trips starting 7 p.m. to midnight', pct(night, tot), 'NHTS 2017', 'TRPTRANS=17; STRTTIME 1900-2359', n, '')
row('Taxi and rideshare trips on weekends', pct(wkend, tot), 'NHTS 2017', 'TRPTRANS=17; TDWKND=01', n, '')
