Dispatch now prices rides with a basic model inspired by public information on Uber and Lyft (docs/pricing.md): base + per mile + per minute, times a per-zone surge multiplier from recent requests against available drivers, plus a booking fee, with a minimum. Riders get an upfront quote per ride type and book against it; drivers see and earn a configurable share (75%) of the fare. Rate cards are per market, chosen by pickup location: San Francisco (third-party 2026 rates) and Fairbanks, AK (its own UberX and UberXL rates, Comfort from Anchorage, no Black). Everything is configurable via PRICING_FILE or POST /test/reset. Tests use a simple test card by default so auction scenarios keep their numbers; new scenarios cover SF rates, Fairbanks rates and surge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
7.2 KiB
TypeScript
148 lines
7.2 KiB
TypeScript
// Pricing model for the dispatch server: a basic model inspired by how Uber and Lyft are
|
||
// publicly known to price rides. Their real models are proprietary; this is not them.
|
||
//
|
||
// fare = max(minimumFare, (baseFare + perMile × miles + perMinute × minutes) × surge + bookingFee)
|
||
//
|
||
// - Rate cards are per market (a city and its surroundings) and ride type; docs/pricing.md
|
||
// has the sources.
|
||
// - Surge is per zone: recent ride requests against available drivers nearby.
|
||
// - The rider is quoted upfront; the driver earns a fixed share of what the rider pays.
|
||
|
||
import type { Coord } from '../src/lib/types';
|
||
import type { Market, PricingConfig, PricingOverrides, RateCard, SurgeConfig } from '../src/lib/dispatch/protocol';
|
||
|
||
const METERS_PER_MILE = 1609.344;
|
||
|
||
// San Francisco, 2026. UberX is RideWise's March 2026 card (checked against receipts);
|
||
// the minimum is TaxiFareFinder's. Uber doesn't publish rate cards any more, and the
|
||
// other tiers are estimates scaled from reported national averages and older SF rates.
|
||
const SAN_FRANCISCO: Record<string, RateCard> = {
|
||
x: { baseFare: 2.5, perMile: 1.55, perMinute: 0.32, bookingFee: 3, minimumFare: 7 },
|
||
comfort: { baseFare: 3, perMile: 1.85, perMinute: 0.38, bookingFee: 3, minimumFare: 9 },
|
||
xl: { baseFare: 3, perMile: 2.1, perMinute: 0.4, bookingFee: 3, minimumFare: 9.25 },
|
||
black: { baseFare: 8, perMile: 3.75, perMinute: 0.6, bookingFee: 3, minimumFare: 15 },
|
||
};
|
||
|
||
// Fairbanks, September 2026, from uphail.com. UberX and UberXL are Fairbanks's own rates;
|
||
// Fairbanks has no Comfort rates listed, so Comfort is Anchorage's from the same source.
|
||
// Neither city lists Black, so it isn't offered there.
|
||
const FAIRBANKS: Record<string, RateCard> = {
|
||
x: { baseFare: 2.77, perMile: 1.86, perMinute: 0.31, bookingFee: 2.55, minimumFare: 8.43 },
|
||
comfort: { baseFare: 3.74, perMile: 1.92, perMinute: 0.4, bookingFee: 3.58, minimumFare: 11.75 },
|
||
xl: { baseFare: 3.25, perMile: 2.25, perMinute: 0.36, bookingFee: 2.55, minimumFare: 8.7 },
|
||
};
|
||
|
||
const MARKETS: Record<string, Market> = {
|
||
sf: { name: 'San Francisco', center: { latitude: 37.7749, longitude: -122.4194 }, radiusKm: 60 },
|
||
// Includes North Pole and Fort Wainwright.
|
||
fairbanks: { name: 'Fairbanks', center: { latitude: 64.8378, longitude: -147.7164 }, radiusKm: 60 },
|
||
};
|
||
|
||
export const DEFAULT_PRICING: PricingConfig = {
|
||
markets: MARKETS,
|
||
defaultMarket: 'sf',
|
||
rateCards: { sf: SAN_FRANCISCO, fairbanks: FAIRBANKS },
|
||
surge: {
|
||
enabled: true,
|
||
// About 2 km across in San Francisco.
|
||
zoneSizeDegrees: 0.02,
|
||
// Uber's surge was measured updating every 5 minutes in 2015; a minute feels closer to today.
|
||
intervalMs: 60_000,
|
||
demandWindowMs: 10 * 60_000,
|
||
// Surge starts once requests outnumber drivers, adding 0.5× per extra request per driver.
|
||
threshold: 1,
|
||
sensitivity: 0.5,
|
||
step: 0.1,
|
||
maxMultiplier: 3,
|
||
},
|
||
// Reported take rates run about 25–29% (Uber); Lyft guarantees drivers at least 70%.
|
||
driverShare: 0.75,
|
||
quoteTtlMs: 2 * 60_000,
|
||
};
|
||
|
||
export const round2 = (n: number) => Math.round(n * 100) / 100;
|
||
|
||
export function mergePricing(base: PricingConfig, overrides: PricingOverrides = {}): PricingConfig {
|
||
const rateCards: PricingConfig['rateCards'] = {};
|
||
for (const market of new Set([...Object.keys(base.rateCards), ...Object.keys(overrides.rateCards ?? {})])) {
|
||
rateCards[market] = { ...base.rateCards[market] };
|
||
for (const [type, card] of Object.entries(overrides.rateCards?.[market] ?? {})) {
|
||
rateCards[market][type] = { ...rateCards[market][type], ...card } as RateCard;
|
||
}
|
||
}
|
||
return {
|
||
markets: { ...base.markets, ...overrides.markets },
|
||
defaultMarket: overrides.defaultMarket ?? base.defaultMarket,
|
||
rateCards,
|
||
surge: { ...base.surge, ...overrides.surge },
|
||
driverShare: overrides.driverShare ?? base.driverShare,
|
||
quoteTtlMs: overrides.quoteTtlMs ?? base.quoteTtlMs,
|
||
};
|
||
}
|
||
|
||
export function validatePricing(p: PricingConfig): string | null {
|
||
for (const [market, cards] of Object.entries(p.rateCards)) {
|
||
for (const [type, card] of Object.entries(cards)) {
|
||
for (const [field, value] of Object.entries(card)) {
|
||
if (!(typeof value === 'number' && value >= 0)) return `rateCards.${market}.${type}.${field} must be a number ≥ 0`;
|
||
}
|
||
}
|
||
}
|
||
for (const [id, market] of Object.entries(p.markets)) {
|
||
if (!p.rateCards[id]) return `markets.${id} has no rate cards`;
|
||
if (!(market.radiusKm > 0)) return `markets.${id}.radiusKm must be more than 0`;
|
||
}
|
||
if (!p.markets[p.defaultMarket]) return `defaultMarket "${p.defaultMarket}" isn't one of the markets`;
|
||
if (!(p.driverShare > 0 && p.driverShare <= 1)) return 'driverShare must be more than 0 and at most 1';
|
||
if (!(p.quoteTtlMs > 0)) return 'quoteTtlMs must be more than 0';
|
||
const s = p.surge;
|
||
if (!(s.zoneSizeDegrees > 0)) return 'surge.zoneSizeDegrees must be more than 0';
|
||
if (!(s.step > 0)) return 'surge.step must be more than 0';
|
||
if (!(s.maxMultiplier >= 1)) return 'surge.maxMultiplier must be at least 1';
|
||
return null;
|
||
}
|
||
|
||
/** The market whose rate cards apply to a pickup: the nearest one in range, else the default. */
|
||
export function marketFor(pickup: Coord, pricing: PricingConfig): string {
|
||
let best: { id: string; km: number } | null = null;
|
||
for (const [id, market] of Object.entries(pricing.markets)) {
|
||
const km = distanceKm(pickup, market.center);
|
||
if (km <= market.radiusKm && (!best || km < best.km)) best = { id, km };
|
||
}
|
||
return best?.id ?? pricing.defaultMarket;
|
||
}
|
||
|
||
function distanceKm(a: Coord, b: Coord): number {
|
||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||
const dLat = toRad(b.latitude - a.latitude);
|
||
const dLon = toRad(b.longitude - a.longitude);
|
||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.latitude)) * Math.cos(toRad(b.latitude)) * Math.sin(dLon / 2) ** 2;
|
||
return 2 * 6371 * Math.asin(Math.sqrt(h));
|
||
}
|
||
|
||
/** The upfront fare for one ride type. */
|
||
export function fareFor(card: RateCard, distanceMeters: number, durationSeconds: number, surge: number): number {
|
||
const miles = distanceMeters / METERS_PER_MILE;
|
||
const minutes = durationSeconds / 60;
|
||
const ride = card.baseFare + card.perMile * miles + card.perMinute * minutes;
|
||
return round2(Math.max(card.minimumFare, ride * surge + card.bookingFee));
|
||
}
|
||
|
||
export const earningsFor = (fare: number, pricing: PricingConfig) => round2(fare * pricing.driverShare);
|
||
|
||
/** The surge zone a point falls in: a square of the grid, as "row:column". */
|
||
export function zoneOf(point: Coord, surge: SurgeConfig): string {
|
||
const size = surge.zoneSizeDegrees;
|
||
return `${Math.floor(point.latitude / size)}:${Math.floor(point.longitude / size)}`;
|
||
}
|
||
|
||
/** The surge multiplier for a zone with this many recent requests and available drivers. */
|
||
export function surgeMultiplier(demand: number, supply: number, surge: SurgeConfig): number {
|
||
if (!surge.enabled) return 1;
|
||
// With no drivers at all, count as if there were one: every request is unmet demand.
|
||
const ratio = demand / Math.max(supply, 1);
|
||
const raw = 1 + (ratio - surge.threshold) * surge.sensitivity;
|
||
// Round down to the step, with a little slack so 1.5 doesn't become 1.4 from float error.
|
||
const stepped = Math.floor((raw + 1e-9) / surge.step) * surge.step;
|
||
return round2(Math.min(surge.maxMultiplier, Math.max(1, stepped)));
|
||
}
|