// Builds the Link fare-media and farebox-recovery table from Sound Transit Fare Revenue Reports. // Inputs are typed from the reports (see sources in research-notes.md); outputs data/link-fare-annual.csv and data/analyses.json const fs=require('fs'); const rows=[ // year, total boardings, boardings without fare media, fare revenue, operating expenses, reported fare-media share, reported farebox recovery, inspection-based evasion rate {year:2017,boardings:23002263,no_media:null,fare_revenue:38019746,op_exp:null,fm_reported:null,farebox_reported:0.43,insp_evasion:null}, {year:2018,boardings:24470264,no_media:null,fare_revenue:41637005,op_exp:null,fm_reported:null,farebox_reported:0.38,insp_evasion:0.027}, {year:2019,boardings:24761684,no_media:null,fare_revenue:43241285,op_exp:null,fm_reported:0.86,farebox_reported:0.34,insp_evasion:0.024}, {year:2020,boardings:7921941,no_media:null,fare_revenue:11330360,op_exp:null,fm_reported:null,farebox_reported:0.08,insp_evasion:0.13}, {year:2021,boardings:11516117,no_media:4767837,fare_revenue:15591469,op_exp:null,fm_reported:null,farebox_reported:null,insp_evasion:null}, {year:2022,boardings:23706210,no_media:10438916,fare_revenue:29126877,op_exp:178417310,fm_reported:null,farebox_reported:0.16,insp_evasion:null}, {year:2023,boardings:26867750,no_media:11750286,fare_revenue:32358465,op_exp:205314451,fm_reported:0.56,farebox_reported:0.16,insp_evasion:null}, {year:2024,boardings:29843190,no_media:11647114,fare_revenue:38933386,op_exp:323776934,fm_reported:0.61,farebox_reported:0.12,insp_evasion:null}, {year:2025,boardings:37948202,no_media:14626071,fare_revenue:50918782,op_exp:408058374,fm_reported:0.61,farebox_reported:0.12,insp_evasion:null}, ]; const r4=x=>Math.round(x*10000)/10000; for(const r of rows){ r.fm_share = r.no_media!=null ? r4((r.boardings-r.no_media)/r.boardings) : r.fm_reported; r.fm_share_basis = r.no_media!=null ? 'derived' : (r.fm_reported!=null?'reported':''); r.farebox_computed = r.op_exp!=null ? r4(r.fare_revenue/r.op_exp) : null; } const cols=['year','boardings','no_media','fm_share','fm_share_basis','fm_reported','fare_revenue','op_exp','farebox_computed','farebox_reported','insp_evasion']; const csv=[cols.join(',')].concat(rows.map(r=>cols.map(c=>r[c]==null?'':r[c]).join(','))).join('\n')+'\n'; fs.writeFileSync('data/link-fare-annual.csv',csv); // Analysis 1: fare media share series const a1=rows.filter(r=>r.fm_share!=null).map(r=>({year:String(r.year),fareMediaShare:r.fm_share,noFareMediaShare:r4(1-r.fm_share),basis:r.fm_share_basis})); // Analysis 2: farebox recovery series const a2=rows.filter(r=>r.farebox_reported!=null).map(r=>({year:String(r.year),fareboxRecovery:r.farebox_reported})); // Analysis 3: revenue at 2019 fare-media rate, youth-adjusted const youth2019=1557030/24761684; // youth share of 2019 Link boardings (youth paid then; free since Sept 2022) const target=0.86-youth2019; const a3=rows.filter(r=>r.year>=2023).map(r=>{const paid=r.boardings-r.no_media;const perPaid=r.fare_revenue/paid; const extraHi=(0.86-r.fm_share)*r.boardings*perPaid; const extraLo=(target-r.fm_share)*r.boardings*perPaid; return {year:String(r.year),revenuePerFareMediaBoarding:Math.round(perPaid*100)/100,unrealizedLow:Math.round(extraLo),unrealizedHigh:Math.round(extraHi), fareboxActual:r4(r.fare_revenue/r.op_exp),fareboxIfLow:r4((r.fare_revenue+extraLo)/r.op_exp),fareboxIfHigh:r4((r.fare_revenue+extraHi)/r.op_exp)};}); // Analysis 4: cost vs revenue growth 2019-2025 (reported in 2025 report): computed from table where possible const a4={linkFareRevenueChange2019to2025:r4(50918782/43241285-1),boardingsChange2019to2025:r4(37948202/24761684-1),opExpChange2019to2025Reported:2.19}; fs.writeFileSync('data/analyses.json',JSON.stringify({youth2019:r4(youth2019),a1,a2,a3,a4},null,1)); console.log(csv);console.log(JSON.stringify({youth2019,a1,a2,a3,a4},null,1));