Skip to content

Planner data sources, model assumptions and limitations

This page is generated from docs/planner-methods.md in the repository. See also open source.

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

  1. Data sources and vintages
  2. Synthetic fixtures (FAKE DATA)
  3. The grid
  4. Commute flows
  5. Access to jobs, groceries and health care
  6. Transit gap index
  7. Demand model
  8. Candidate zones
  9. Scenario comparison
  10. Cost model
  11. Calibration with observed trips
  12. Privacy
  13. Limitations
  14. 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.

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):

  1. Walk from the cell centre to a stop at 4.8 km/h, at most 800 m in a straight line.
  2. 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.
  3. Ride at a fixed line speed, 18 km/h for buses and 35 km/h for rail, along the route's stop sequence.
  4. Each transfer adds a 5-minute penalty plus the new route's wait; walking transfers up to 400 m are allowed.
  5. Walk from the last stop to the destination cell, at most 800 m.
  6. 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):

Poor-supply components:

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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 than max_on_demand_trip_km, 0.5 for trips under 0.8 km (easy to walk) and 1 otherwise. capture_rate_base and capture_rate_gap_boost are assumptions until calibrated with observed trips (section 11).
  5. 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.

  1. Each cell gets a score = normalised gap × normalised modeled on-demand trip ends.
  2. 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.
  3. 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.
  4. 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_zones are returned.
  5. 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:

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.

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:

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

13. Limitations

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.