Planner methods
This page explains, in plain language, how the OpenTransit Lab Planner turns public data into the maps,
numbers and draft service zones you see. It is written for city and transit-agency staff. The code that
does the work lives in services/planning (Python), one module per step; module names are given in
brackets so an analyst can check the formulas against the code.
Everything the Planner shows is a modeled estimate, not observed data. The numbers are meant to start a conversation about where on-demand service might help, not to forecast ridership or to recommend a plan. Every model default below is marked verify: a person should check it against local information before any decision.
Contents
- Data sources and vintages
- Synthetic fixtures (FAKE DATA)
- The grid
- Commute flows
- Access to jobs, groceries and health care
- Transit gap index
- Demand model
- Candidate zones
- Scenario comparison
- Cost model
- Calibration with observed trips
- Privacy
- Limitations
- Planning with your own data (workspace)
1. Data sources and vintages
| Data | Publisher | What we use | Default vintage | License |
|---|---|---|---|---|
| American Community Survey (ACS) 5-year estimates | U.S. Census Bureau | Residents, households, households without a car, residents 65 and older, residents below the poverty line, median household income (block groups); residents with a disability and households with limited English (tracts, shared out to block groups by population) | 2019-2023 release (ACS_YEAR=2023), verify |
Public domain |
| TIGER/Line block groups | U.S. Census Bureau | Block group boundaries (TIGERweb service, or the TIGER/Line shapefile when the optional [geo] extra is installed) |
Same year as ACS | Public domain |
| LEHD Origin-Destination Employment Statistics (LODES) version 8 | U.S. Census Bureau, Longitudinal Employer-Household Dynamics (LEHD) program | Home-to-work pairs of census blocks (OD), jobs by workplace block and industry (WAC: Workplace Area Characteristics), block coordinates (crosswalk) | 2021 (LODES_YEAR), verify the latest year for your state |
Public domain |
| General Transit Feed Specification (GTFS) feeds | Each transit agency | Stops, routes, weekday schedules | Downloaded on the ingest date | Each agency's terms (listed in planning.yaml) |
| OpenStreetMap points of interest | OpenStreetMap contributors | Grocery stores, health care (hospitals, clinics, doctors, pharmacies), schools, parks, via the Overpass service | Extract on the ingest date | Open Database License (ODbL) |
Attribution: "U.S. Census Bureau (public domain)" and "© OpenStreetMap contributors (ODbL)".
Every dataset is recorded in data/manifest.yaml with its source address, vintage, license, a SHA-256
checksum of the file, whether it came from a live download or a synthetic fixture, and when it was
fetched. The brief's methods page and the web preview's methods.json repeat these vintages.
Commands: python -m opentransit_planning ingest --city <slug> --live downloads real data. An optional
free Census key goes in the CENSUS_API_KEY environment variable; without it the keyless,
rate-limited service is used. A GTFS feed whose address in planning.yaml is empty (null) or is not
a GTFS zip file falls back to its synthetic fixture, and the manifest says so.
2. Synthetic fixtures (FAKE DATA)
The default ingest --fixture mode runs offline. It invents data in the same layouts as the real
sources, from the City Pack's study-area box, its hubs (stops.geojson features of kind hub) and a
random seed taken from the city's slug, so the same city always gets the same fixture. Fixtures exist so
the software can be tested; they say nothing about the real city (for example, they put residents on
water). Every fixture and every output built from one is labeled FAKE DATA / synthetic in the
manifest, in output metadata (meta.synthetic: true), on the brief and in the web preview.
3. The grid [grid.py]
The study area (planning.yaml → study_area.bbox) is covered with H3 hexagons (a global hexagon grid)
at resolution 9 (about 0.1 km² per cell, roughly 25 acres) or 8 (about 0.74 km²), as set by
h3_resolution.
- Counts from block groups (residents, households, zero-car households, seniors, low-income residents,
residents with a disability, residents with limited English) are shared out by area:
cell value = Σ over block groups of (block group value × overlap area ÷ block group area). This assumes people are spread evenly inside a block group. - Median income is the resident-weighted average of the overlapping block groups.
- Jobs come from LODES workplace counts, placed at each census block's internal point. Jobs are also split into the three LODES sectors: goods-producing, trade-transportation-utilities, and all other services.
- Points of interest are counted by the cell they fall in.
4. Commute flows [flows.py]
LODES home-to-work pairs are moved to the cells that contain the home block and the work block and summed. Each pair of cells becomes a desire line with its straight-line distance in kilometres. Planners can filter by distance and by time band. LODES has no time of day, so a time band (for example AM peak, 6 to 10 am) shows the count multiplied by the share of daily trips that start in that band (section 7). LODES counts primary jobs held on a reference date, not daily trips.
5. Access to jobs, groceries and health care [access.py]
For each cell we estimate how many jobs, grocery stores and health care locations can be reached by walking and transit within 30 and 45 minutes, leaving on a weekday morning peak (7 to 9 am).
Preferred method: r5py. When R5PY_ENABLED=1, the optional [r5] extra and Java 21 are installed and
an OpenStreetMap extract is present at data/raw/<slug>/osm.pbf, the R5 routing engine computes door-to-door
times on the real street network and timetables. The Docker image includes Java for this.
Fallback method: simple_transit (used everywhere else, including all tests):
- Walk from the cell centre to a stop at 4.8 km/h, at most 800 m in a straight line.
- Wait half the route's average headway (time between buses or trains) in the 7 to 9 am weekday peak, taken from the GTFS timetable.
- Ride at a fixed line speed, 18 km/h for buses and 35 km/h for rail, along the route's stop sequence.
- Each transfer adds a 5-minute penalty plus the new route's wait; walking transfers up to 400 m are allowed.
- Walk from the last stop to the destination cell, at most 800 m.
- Walking the whole way is also allowed.
Travel time to a cell is the fastest of these options. A cell's access is the total number of
opportunities in all cells reached within the time budget. Every access result records which method
produced it (method). Clicking a point on the map returns an isochrone: the outline of cells reached
within the chosen minutes.
A stop is frequent when a route serving it runs at or under the feed's frequent_headway_min
(default 15 minutes) in the morning peak.
6. Transit gap index [gap.py]
The gap index (0 to 100) shows where need for transit is high and service is weak.
Need components (shares, so big and small cells compare fairly):
- zero-car households ÷ households
- residents 65 and older ÷ residents
- residents below the poverty line ÷ residents
- residents with a disability ÷ residents
Poor-supply components:
- distance to the nearest frequent stop, capped at 2,000 m
- low job access = 1 − (normalised jobs reachable in 45 minutes)
Each component is min-max normalised across populated cells: the lowest cell scores 0 and the highest scores 1. Then
gap = 100 × Σ (weight × component) ÷ Σ weights
with weights from planning.yaml → gap_index_weights (planners can try other weights in the
Planner; the API accepts weights as query parameters or in the request body). Need is the same
weighted average over the four need components only (0 to 1). High-need cells are the top fifth
(quintile) of populated cells by need. Cells with fewer than 10 residents get a gap of 0 and are marked
not populated.
7. Demand model [demand.py]
All outputs here are modeled estimates. Defaults come from planning.yaml → demand_model, which
carries its own provenance and is marked verify.
- Trip generation. Trip ends in a cell = households ×
trips_per_household_day(default 5.6, verify; references the National Household Travel Survey, NHTS) + jobs ×trips_per_job_day(default 1.4 to 1.6, verify). Every trip has two ends, so a cell starts half that many trips. - Distribution (gravity model). Trips from cell i go to cell j in proportion to the trip ends at j
times
exp(−β × distance in km), with β =gravity_beta(default 0.35 to 0.40, verify). Distance is straight-line between cell centres; inside one cell it is half a hexagon edge. - Loose calibration to LODES. LODES home-to-work pairs inside the study area give weekday commute
trips = workers × 2 × 0.85 (share of workers commuting on a given weekday, verify). If commutes are
20% of all trips (verify; NHTS puts it near 15 to 20%), all trips should total commute trips ÷ 0.20.
The model's trips are multiplied by that target ÷ modeled total, kept between 0.25 and 4 so one bad
input cannot run away. The factor is reported as
scale_factor. - On-demand capture. The share of trips between two cells that would use on-demand service:
capture = capture_rate_base × (1 + capture_rate_gap_boost × average gap of the two cells ÷ 100) × length factor. The length factor is 0 for trips longer thanmax_on_demand_trip_km, 0.5 for trips under 0.8 km (easy to walk) and 1 otherwise.capture_rate_baseandcapture_rate_gap_boostare assumptions until calibrated with observed trips (section 11). - Time of day. Daily trips are spread over 24 hours with these shares of daily trips (they sum to 1; illustrative weekday shape referencing NHTS trip start times, verify):
| Hour | Share | Hour | Share | Hour | Share | Hour | Share |
|---|---|---|---|---|---|---|---|
| 0-1 | 0.50% | 6-7 | 3.50% | 12-13 | 6.20% | 18-19 | 6.80% |
| 1-2 | 0.30% | 7-8 | 6.80% | 13-14 | 6.00% | 19-20 | 5.00% |
| 2-3 | 0.20% | 8-9 | 7.20% | 14-15 | 6.20% | 20-21 | 3.80% |
| 3-4 | 0.20% | 9-10 | 5.50% | 15-16 | 7.20% | 21-22 | 2.80% |
| 4-5 | 0.40% | 10-11 | 5.20% | 16-17 | 8.00% | 22-23 | 1.80% |
| 5-6 | 1.20% | 11-12 | 5.60% | 17-18 | 8.50% | 23-24 | 1.10% |
The output is a sparse hourly origin-destination (OD) matrix of modeled on-demand trips by cell, with an
H3 resolution 8 summary (od_hourly_r8.csv).
8. Candidate zones [zones.py]
Candidate zones are illustrative, for discussion.
- Each cell gets a score = normalised gap × normalised modeled on-demand trip ends.
- Zones grow from the City Pack's hubs (rail stations and bus hubs in
stops.geojson), so every candidate contains or touches a hub. Hubs are tried in order of the total score within three rings of cells around them. - From the hub's cell, the zone adds the neighbouring cell with the highest score, one at a time, while
the area stays at or under the upper target (
zone_suggestion.target_area_sq_mi). Once the zone reaches the lower target, it stops when the best neighbour scores below the median populated cell. - Zones do not overlap. A hub whose zone cannot reach the lower target (boxed in by the study-area edge
or by other zones) is skipped. At most
max_zonesare returned. - The outline is the union of the hexagon boundaries.
For each candidate we report its area, anchored hubs, residents, average gap, and modeled trips per day with both ends inside the zone (an on-demand zone serves trips inside it). The fleet estimate:
- peak-hour trips = zone trips per day × the largest hourly share (8.5%)
- vehicles in service at the peak = peak-hour trips ÷ target passengers per vehicle-revenue-hour, rounded
up (target from
city.yaml→kpi_targets.passengers_per_vrh, default 5) - fleet = vehicles in service × 1.15 (15% spare, verify), rounded up, at least 1.
export-pack writes a candidate as a draft City Pack in city-packs/<slug>/drafts/_<scenario>/
that passes the same schema checks as a real pack.
9. Scenario comparison [scenario.py]
A scenario is a set of zones, service hours per day, a fare and a fleet size. Baseline and proposed scenarios are compared side by side.
- Trips per day = modeled on-demand trips with both ends in a zone × share of daily trips inside the service hours × fare factor. Fare factor = 1 − 0.3 × (fare − $2) ÷ $2 (a fare elasticity of −0.3 around a $2 reference fare, verify), never below 0.1. A free fare gives 1.3; a $4 fare gives 0.7.
- Fleet. A scenario names a number of vehicles, or
auto. Withautothe Planner right-sizes the fleet: enough vehicles to carry the modeled peak hour at the City Pack's productivity target (kpi_targets.passengers_per_vrh), plus 15% spares (same method as the candidate-zone fleet estimate). Spare vehicles are leased and insured but do not run revenue hours. - Passengers per vehicle-revenue-hour = trips ÷ (vehicles in service × hours). Above 8 the scenario is flagged as likely under-supplied.
- Cost uses the cost model in section 10 with vehicle-revenue-hours = vehicles in service × hours and vehicle miles = revenue hours × 11 mph (average speed, verify). Cost per trip = daily cost ÷ daily trips. A scenario with no zones runs no service and costs nothing.
- Access to jobs. On-demand service is treated as a feeder: a resident of a zone cell can ride to a hub in the same zone. Ride time = average wait (half the service pattern's maximum wait, by default) + straight-line distance at 20 km/h. The cell's 45-minute job access becomes the larger of its walk-and-transit access and the jobs reachable from the hub in the minutes left. We report the resident-weighted change for affected cells.
- Equity. The share of the scenario's trips that start in high-need cells, and the share of affected residents who live in high-need cells.
10. Cost model [costmodel.py]
The Planner uses exactly the same daily cost model as the funding tools (packages/funding/src/cost-model.ts);
inputs come from city.yaml → cost_inputs. All amounts are kept in whole cents.
| Line | Formula (per day) |
|---|---|
| Drivers | revenue hours × 1.10 (pull-out, breaks and deadhead, an assumption) × hourly wage × (1 + driver overhead) |
| Leasing | vehicles × monthly lease × 12 ÷ 365 (0 when the operator supplies vehicles) |
| Insurance | vehicles × yearly premium ÷ 365 |
| Energy | vehicle miles × energy cost per mile |
| Maintenance | vehicle miles × maintenance cost per mile |
| Software | flat monthly OpenTransit fee × 12 ÷ 365 (never charged per ride) |
| Overhead | overhead percentage × the sum of the lines above |
11. Calibration with observed trips [calibration.py]
When a service runs, upload trip counts already aggregated by cell (H3 resolution 8 or the grid's
own resolution) and hour: [{cell_r8, hour, count}], plus the number of days they cover. The Planner
compares modeled and observed trips for each cell and hour present in the upload:
- MAE (mean absolute error) = average of |modeled − observed|
- RMSE (root mean square error) = square root of the average of (modeled − observed)²
- MAPE (mean absolute percentage error) = average of |modeled − observed| ÷ observed, for observed > 0
One-click re-fit. Cells are split into thirds (terciles) by gap index. Each uploaded cell is assigned
to the third that holds most of its modeled trips, and each third gets a multiplier = observed total ÷
modeled total. The re-fitted capture rate for each third is capture_rate_base × its multiplier. The
re-fit returns the new rates and the error before and after.
12. Privacy
- Planner views never show individual trip records. Calibration only accepts counts aggregated by cell and hour.
- For observed data, any cell with fewer than 10 trips is suppressed (not shown) in Planner, public and
funder views; the threshold comes from
packages/core/src/privacy.ts. Suppressed rows still count in the overall error measures, which reveal no single cell. - Exports coarsen trip ends to H3 resolution 8 and times to 15 minutes.
- Census and LODES data are already published with disclosure protection; the Planner adds no personal data.
13. Limitations
- The fallback access method uses straight lines, not streets, and fixed line speeds rather than timetables; r5py is more accurate.
- Areal apportionment assumes residents are spread evenly inside block groups (including parks and water). Disability and limited-English counts come from tracts.
- LODES covers primary jobs only, misses most self-employment, and lags by two or more years. Flows that leave the study area are not in the internal commute count.
- Trip rates, gravity β, capture rates, the hourly profile, the fare elasticity and the cost inputs are defaults marked verify. Until calibrated with observed trips, treat trip and fleet numbers as a range, not a point.
- The gap index is relative: it ranks cells within one study area and cannot be compared across cities.
- Candidate zones follow simple rules and ignore barriers (rivers, highways), curb access and political boundaries. They are starting points for staff and community discussion.
14. Planning with your own data (workspace) [packages/ingest, packages/planner]
The workspace at /planner/studio runs entirely in the browser. Files are never uploaded.
Reading files. The format is detected from the file's content, not only its name: CSV or TSV, JSON records,
GeoJSON, KML or KMZ, GPX, a zipped Shapefile (reprojected to latitude/longitude when a .prj is present) and GTFS.
Columns are matched by meaning (for example PU Lat, pickup_latitude, start_lat and Origin Latitude all mean
origin latitude), and each match carries a confidence from the column name and its values. A trip needs an origin
and a destination, given as coordinates, a point column ("lat, lng" or WKT), stop IDs (placed with a loaded GTFS
feed) or H3 cells. Aggregated tables use a trip-count column as a weight. Dates and times are read in common export
formats (ISO, US month/day/year, 12- or 24-hour clocks, Unix epochs, spreadsheet serial dates); zoned timestamps are
converted to the place's time zone. Latitude and longitude columns that look swapped are read the other way round,
and projected coordinates (for example State Plane feet) are reported, not guessed. Rows that cannot be placed are
counted by reason.
A typical day. Multi-day records are averaged: each trip counts its weight divided by the number of days, rounded at random with a fixed seed so totals match. Trips with no time take one from the data's own hourly profile; if no trip has a time, they are spread over 7am to 7pm and the workspace says so.
Suggested zone. The resolution-8 H3 cells holding the most trip ends are added until they hold 80% of all trip ends, then outlined; the connected piece with the most trip ends is kept.
Fleet size. For a ladder of fleet sizes around a rough estimate (peak trips per hour × twice the median trip time ÷ an occupancy of up to 1.6), the dispatch simulator runs the typical day: the agency's trips, simulated vehicles, pooling, and the wait and detour limits. The recommendation is the smallest fleet whose average wait meets the target with at most 5% of requests unserved and zero rule violations. Every result is a modeled estimate. Unit costs are placeholders until replaced.
Demand ahead. Low, base and high bands:
trips(y) = (today + short car trips × switch share + (new residents × trips per resident + new jobs × trips per job) × capture) × (1 + growth)^y × (planned wait ÷ wait today)^elasticity. Unknown inputs stay blank and add nothing.
The starting ranges are OpenTransit Lab assumptions (verify), not findings.
Where automated vehicles can run. Each road segment is scored pass, caution or fail with its reasons. By default, posted speeds at or under 30 mph pass, over 30 to 40 mph are caution and over 40 mph fail; limited-access roads fail, primary arterials and roads with more than 4 lanes are caution, and roads with no known speed are caution. All rules are editable. Demand reach is the share of trip ends within about 150 m (two H3 resolution-10 rings) of a passing road. "Roads to fix" ranks caution and fail segments by the not-yet-reached trip ends near them, until about 10 more points of demand. Weather and the state's legal position are entered by the planner with a source and show as TBD until then. This is a screening aid, not a safety case.