GMPRO docs: Route optimization for NEMT operations
Continuing our series on niche topics in route optimization, we dive into what matters most when selecting a route optimization solver for Non Emergency Medical Transportation (NEMT) services. I explain what NEMT services are, discuss the unique routing challenges they present, and show you how the Google Maps Route Optimization API (GMPRO) can effectively address them.
Note: Many commercial and open source route optimization solvers work just fine. This blog posts highlights the specific features you need to ensure your solution can support to meet the complex demands of NEMT operations. Whether you choose GMPRO or an alternative (and probably more cost effective) option, is up to you.
Part 1: GMPRO: Google Maps Platform route optimization API
Part 2: GMPRO TSP solver: Google Maps with more than 25 waypoints
Part 3: Google Maps route optimization: multi vehicle
Part 4: GMPRO fleet routing app - free route planner for multiple stops
Part 5: GMPRO docs: Fixed vehicle costs
Part 6: GMPRO docs: Territory optimization and route planning
Part 7: GMPRO docs: Solving the VRP with route clustering and soft constraints
Part 8: GMPRO docs: Driver load balancing with soft constraints
Part 9: GMPRO docs: Driver breaks
Part 10: GMPRO docs: Complete deliveries before pickups in cargo bike logistics
Part 11: GMPRO docs: Force stop sequences using precedence rules
Part 12: GMPRO docs: Route optimization for NEMT operations (this article)
What is NEMT?
Non Emergency Medical Transportation (NEMT) is a specialized service designed for individuals who are unable to use regular public transit or drive due to mobility or medical access needs. Unlike ambulances, which are reserved for emergencies and urgent medical situations, NEMT provides scheduled, non emergency transport for routine appointments and daily activities. This differs from paratransit, which caters to people with disabilities who are unable to use regular public transit services.
In the US, NEMT services are usually run by private transportation companies that contract with health insurance providers and hospitals. Almost of all of them use commercial routing and scheduling software to handle bookings, schedule drivers and plan routes.
Who is this for?
This article explores the routing technology behind NEMT routing software and how it efficiently schedules trips to boost on-time performance and cut fuel costs. It does not review or compare specific NEMT software providers. This blog post is for you if you operate an NEMT service and are evaluating routing software to meet your operational needs, or if you're a software provider seeking a route optimization API that can support the specific demands of NEMT clients.
How are NEMT rides scheduled?
Rides are scheduled in advance, often 1 - 2 days ahead. The process begins when a patient, caregiver, or healthcare provider submits a request - usually by phone, but increasingly through a website or mobile app. These requests include essential details such as pickup and dropoff locations, appointment time, mobility needs (e.g. wheelchair or stretcher access), and whether a return trip or caregiver accompaniment is required. NEMT routing software will then use a route optimization solver to batch rides efficiently, assigning vehicles based on capacity and accessibility, while respecting time windows, service durations, and other operational constraints.
How does NEMT routing work?
NEMT routing falls under a class of operations research challenges known as the Dial a Ride Problem (DARP) - so called because in the past passengers would literally "dial" a phone number to request a ride (they still do). At its most basic, the Dial a Ride Problem involves:
- A set of customer requests (bookings) each with:
- a pickup location,
- a dropoff location,
- the number of passengers e.g. 2 if there's a patient and a caregiver,
- time windows for pickup and/or drop-off,
- and a maximum allowable ride time.
- A fleet of vehicles (drivers) with limited capacity, typically starting at a depot.
NEMT routing software will collect all this data and then call a route optimization solver to produce a cost effective route plan that minimizes total mileage, fuel use, and driver hours while:
- satisfying all customer requests,
- respecting vehicle capacities,
- adhering to time windows and ride time constraints,
Additionally, a good NEMT routing solver must account for wheelchair users and other passengers with special needs. These riders often require vehicles with specific equipment - such as wheelchair lifts or extra space, and may need more time for boarding and disembarking. Failing to consider these factors can lead to missed appointments, overloaded vehicles, and non-compliance with regulations like the Americans with Disabilities Act.
Converting the Dial a Ride Problem to a route optimization problem
In GMPRO, we can model the dial a ride problem as a capacitated vehicle routing problem with time windows. Each customer request (booking) and vehicle (driver) is represented in the optimization request as a shipment
and vehicle
object with these attributes:
Bookings (shipments
)
GMPRO | |
---|---|
Pickup Location |
{ { "pickups": [ { "arrivalLocation": { "latitude": 49.2688067, "longitude": -123.2099986 } } ] } } |
Dropoff Location |
{ { "deliveries": [ { "arrivalLocation": { "latitude": 49.2728387, "longitude": -123.1603492 } } ] } } |
No. of Passengers |
{ "loadDemands": { "persons": { "amount": 2 } } } |
Pickup Time Windows |
{ "timeWindows": [ { "startTime": "2024-07-08T16:00:00Z", "endTime": "2024-07-08T18:00:00Z" } ] } |
Dropoff Time Windows |
{ "timeWindows": [ { "startTime": "2024-07-08T16:00:00Z", "endTime": "2024-07-08T18:00:00Z" } ] } |
Max Allowable Ride Time |
"pickupToDeliveryTimeLimit": "1800s" |
Drivers (vehicles
)
GMPRO | |
---|---|
Start Location |
{ "startLocation": { "latitude": 49.2593121, "longitude": -123.2474966 } } |
Capacity |
{ "loadLimits": { "persons": { "maxLoad": 5 } } } |
GMPRO NEMT routing example
Here’s an example of a single van with a 5-person capacity picking up two groups: one with 2 passengers and another with 3, from Vancouver’s west side, and dropping them off at two different hospitals - Point Grey Hospital on Cornwall Avenue and Vancouver General Hospital in Fairview.
Pickups can occur at any time, but dropoffs must take place between 09:00 and 11:00 to ensure patients arrive in time for their appointments.
Input
{
"model": {
"shipments": [
{
"loadDemands": {
"persons": {
"amount": 2
}
},
"label": "yvr123",
"pickups": [
{
"arrivalLocation": {
"latitude": 49.2688067,
"longitude": -123.2099986
},
"timeWindows": [
{}
],
"duration": "600s"
}
],
"deliveries": [
{
"arrivalLocation": {
"latitude": 49.2728387,
"longitude": -123.1603492
},
"timeWindows": [
{
"startTime": "2024-07-08T16:00:00Z",
"endTime": "2024-07-08T18:00:00Z"
}
],
"duration": "600s"
}
]
},
{
"loadDemands": {
"persons": {
"amount": 3
}
},
"label": "yvr456",
"pickups": [
{
"arrivalLocation": {
"latitude": 49.2585237,
"longitude": -123.1921195
},
"timeWindows": [
{}
],
"duration": "600s"
}
],
"deliveries": [
{
"arrivalLocation": {
"latitude": 49.2614006,
"longitude": -123.1221562
},
"timeWindows": [
{
"startTime": "2024-07-08T16:00:00Z",
"endTime": "2024-07-08T18:00:00Z"
}
],
"duration": "600s"
}
]
}
],
"vehicles": [
{
"travelMode": "DRIVING",
"startLocation": {
"latitude": 49.2593121,
"longitude": -123.2474966
},
"startTimeWindows": [
{
"startTime": "2024-07-08T16:00:00Z"
}
],
"endTimeWindows": [
{
"endTime": "2024-07-08T18:00:00Z"
}
],
"loadLimits": {
"persons": {
"maxLoad": 5
}
},
"label": "mark-yvr",
"costPerKilometer": 1,
"fixedCost": 25
}
],
"globalStartTime": "2024-07-08T16:00:00.000Z",
"globalEndTime": "2024-07-10T16:00:00.000Z"
},
"populatePolylines": true
}
Output
{
"routes": [
{
"vehicleLabel": "mark-yvr",
"vehicleStartTime": "2024-07-08T16:00:00Z",
"vehicleEndTime": "2024-07-08T17:14:48Z",
"visits": [
{
"isPickup": true,
"startTime": "2024-07-08T16:07:07Z",
"detour": "0s",
"shipmentLabel": "yvr123",
"loadDemands": {
"persons": {
"amount": "2"
}
}
},
{
"shipmentIndex": 1,
"isPickup": true,
"startTime": "2024-07-08T16:23:45Z",
"detour": "934s",
"shipmentLabel": "yvr456",
"loadDemands": {
"persons": {
"amount": "3"
}
}
},
{
"startTime": "2024-07-08T16:43:42Z",
"detour": "1157s",
"shipmentLabel": "yvr123",
"loadDemands": {
"persons": {
"amount": "-2"
}
}
},
{
"shipmentIndex": 1,
"startTime": "2024-07-08T17:04:48Z",
"detour": "1076s",
"shipmentLabel": "yvr456",
"loadDemands": {
"persons": {
"amount": "-3"
}
}
}
],
"transitions": [
{
"travelDuration": "427s",
"travelDistanceMeters": 4429,
"waitDuration": "0s",
"totalDuration": "427s",
"startTime": "2024-07-08T16:00:00Z",
"vehicleLoads": {
"persons": {}
}
},
{
"travelDuration": "398s",
"travelDistanceMeters": 3151,
"waitDuration": "0s",
"totalDuration": "398s",
"startTime": "2024-07-08T16:17:07Z",
"vehicleLoads": {
"persons": {
"amount": "2"
}
}
},
{
"travelDuration": "597s",
"travelDistanceMeters": 4107,
"waitDuration": "0s",
"totalDuration": "597s",
"startTime": "2024-07-08T16:33:45Z",
"vehicleLoads": {
"persons": {
"amount": "5"
}
}
},
{
"travelDuration": "666s",
"travelDistanceMeters": 4206,
"waitDuration": "0s",
"totalDuration": "666s",
"startTime": "2024-07-08T16:53:42Z",
"vehicleLoads": {
"persons": {
"amount": "3"
}
}
},
{
"travelDuration": "0s",
"waitDuration": "0s",
"totalDuration": "0s",
"startTime": "2024-07-08T17:14:48Z",
"vehicleLoads": {
"persons": {}
}
}
],
"routePolyline": {
"points": "}}skHbwfoVdAy@DCq@{Cm@}B@I@K?G@GlEeDLIjCwBhBwAjByAVU~DyCvB_BNQFEXQLILGLEHAH?F?@@B@@@B?B?B?B?@?BABA@ABABE@CBC@C@E?C@E?C@E?C?C?A?E?EAC?EACAEACACACCCAAAAGKGOEMCII[AEGYI[ESGSu@{CEOGQSw@g@yB_@aB]_B[qAMm@@E@C?C@E?C?I?EAC?EAC?CACACACCCCEECA?AAA?AAOUEGCKOm@Mc@S{@AAOo@I]u@{CIWGS]qAEQa@uAa@{AEOOo@Om@Mk@EUe@gCOy@QgAQoAKq@Gk@G][uCAQGs@Gq@Eo@ImAAWE{@Cq@A[ASAw@EiBCkB?wB?U?I@s@?a@@{B?_@@wA@w@Bg@?C?u@@aC@kE?W@]?K@yC@wE?C@aF@wC@gB_@Aw@Aa@AA?g@?s@Ac@?u@AoAAIA{AAwACu@?e@Ai@Aa@?OAyAAyAEmA?I?sACUCmAAwACG?oACa@?e@?[AkAAoAE{@CU?M?iAAwACkAAG?qBCc@AY??i@BcD@eD?U@gB@}A?W@u@?y@@{C@cC@oA?iA@w@@qB?g@@W?_A@q@D_C?[?cA?Q@M?OBMB[XoBTuANeARuAZqB?ADYDW@S@CB_@@a@?C@I@gA?e@?ADY@OAo@Cc@Ea@COCKCOEUUmAOw@?A[eBESCUCUAYASAU?Q?e@@Q?u@?w@@k@@eD?M@kA@_@@aABaG@[@iCDyBCUDiE@oA@o@?i@?S@}B?o@?a@?o@?UA}@C}@AgA?m@Ee@?sEx@BJ?F?`A@Z?bCBZ@?@?@@??@?@?@@??@@??@@??@@?@?@??A@??A@??A@??A?A?A@??A?AhABB?lA@dABF?~@BN??@?@?@?@?@@??@?@@??@@??@@??@@?@?@?@??A@??A@??A@??A?A@??A?A?A?A?A?A@@dA@tA@b@@t@?fA@N?T?fA?zA@P@fABv@@`@@rA@|@@b@@vA@xA@|ABxABvABzA@?r@AfD?v@CzGAn@Ar@?d@Al@?TWBE@EDCDABALAR?@?B@H@F@BDFBBHBPD?|D?r@`@?BkF@a@?MB{E?q@BqI@oB@mB@iEBsD?m@@mB?i@BwE@{E?S@M?k@?Y?}B@oB?y@@gA@yB@cB@kB@m@@}@?u@?cA@m@@_F?U?a@@_B@_D@m@?K?i@@aABcC?oA?O?_A@w@?u@@{BBcC?q@?Y?q@?kB?sAAAAA?A?E?U?c@Ce@Ke@M@o@Ag@AiAAQAkAAK?a@?y@Au@Cc@?yACYA}@AyAAM?mAAEA_BCqACqACK?wA@]Am@@QBm@Hg@Hi@JUBQ@u@@[?A?_AASAsAAIAiACqAA_A?S?qACG?Y?}@AoACI?}@AY?]A_AAuACqDGu@CEAEACCCCCACECGCKk@oBe@{AACs@{BCMa@sA]iAK[CGEUGUEWAGAOCU?W@q@?y@@qD@w@Aq@@k@@gABwH?_@@o@?O?aA@{@?M?W@iCBkCBy@@e@?W?KBeF@kA?_@?O?c@AW?W?U?MBiD?iB@yB?g@@mA?g@@sB@gA?m@?]?}@@e@@yB@qB?a@?{@?]@aA?u@@y@?I?Y?E@cA?S@_A?G?G?]?M?]?Y@mAAs@ZNHF@?VHTDN@RBB?`@@z@BV?d@@hA?`@@r@@T?h@?X@xABj@?h@@RB`@?`@B~@@XFLS`@?V@JAn@Af@Ab@BdAB@@`@@Z?b@?dCBx@@V?`@@L?b@?tABP?dABP?n@BR?V@l@@l@?xABzAB|AB~A@@kBBkJBaFDY@S@W?_A@sB@{E?W@cB@qCBmC?oCFsC?y@?yA?kCG]D}D@[@}E?sFD{E?CDcFFSASDaF@_F?_@DkF?E?aA?sA@iAEi@@qB?E?M?sC@U?M@gA[AgAAc@Ag@A"
},
"metrics": {
"performedShipmentCount": 2,
"travelDuration": "2088s",
"waitDuration": "0s",
"delayDuration": "0s",
"breakDuration": "0s",
"visitDuration": "2400s",
"totalDuration": "4488s",
"travelDistanceMeters": 15893,
"maxLoads": {
"persons": {
"amount": "5"
}
},
"performedMandatoryShipmentCount": 2
},
"routeCosts": {
"model.vehicles.cost_per_kilometer": 15.893,
"model.vehicles.fixed_cost": 25
},
"routeTotalCost": 40.893,
"vehicleFullness": {
"maxFullness": 1,
"maxLoad": 1,
"activeSpan": 0.6233333333333333
}
}
],
"metrics": {
"aggregatedRouteMetrics": {
"performedShipmentCount": 2,
"travelDuration": "2088s",
"waitDuration": "0s",
"delayDuration": "0s",
"breakDuration": "0s",
"visitDuration": "2400s",
"totalDuration": "4488s",
"travelDistanceMeters": 15893,
"maxLoads": {
"persons": {
"amount": "5"
}
},
"performedMandatoryShipmentCount": 2
},
"usedVehicleCount": 1,
"earliestVehicleStartTime": "2024-07-08T16:00:00Z",
"latestVehicleEndTime": "2024-07-08T17:14:48Z",
"totalCost": 40.893,
"costs": {
"model.vehicles.cost_per_kilometer": 15.893,
"model.vehicles.fixed_cost": 25
}
}
}
In the route solution above, both pickups of 2 and 3 passengers were located along the path to the hospitals and fit within the van’s 5-person capacity, making it possible to group them into a single trip.
The importance of enforcing vehicle capacity limits
It’s important that a route optimization solver does not violate capacity constraints because doing so can lead to serious safety and service quality issues.
In GMPRO, the number of passengers each trip involves is represented using the loadDemands
field, while the maximum capacity of each vehicle is specified using loadLimits
. These two fields work together to ensure that the routing algorithm never assigns more passengers to a vehicle than it can safely carry. For example, if a van has a capacity of 5 passengers, its loadLimits
would be set to 5.
{
"loadLimits": {
"persons": {
"maxLoad": 5
}
}
}
Each trip request would then contribute to the total load using its loadDemands
value - typically set to 1 per passenger, or more if a rider is accompanied by a caregiver.
{
"loadDemands": {
"persons": {
"amount": 1
}
}
}
If we update our earlier example so that the 5-person van needs to pick up two groups of 3 passengers each, it will no longer be able to transport both groups at the same time. Instead, it must pick up and drop off the first group before collecting the second.
Input
{
"model": {
"shipments": [
{
"loadDemands": {
"persons": {
"amount": 3
}
},
"label": "yvr123",
"pickups": [
{
"arrivalLocation": {
"latitude": 49.2688067,
"longitude": -123.2099986
},
"timeWindows": [
{}
],
"duration": "600s"
}
],
"deliveries": [
{
"arrivalLocation": {
"latitude": 49.2728387,
"longitude": -123.1603492
},
"timeWindows": [
{
"startTime": "2024-07-08T16:00:00Z",
"endTime": "2024-07-08T18:00:00Z"
}
],
"duration": "600s"
}
]
},
{
"loadDemands": {
"persons": {
"amount": 3
}
},
"label": "yvr456",
"pickups": [
{
"arrivalLocation": {
"latitude": 49.2585237,
"longitude": -123.1921195
},
"timeWindows": [
{}
],
"duration": "600s"
}
],
"deliveries": [
{
"arrivalLocation": {
"latitude": 49.2614006,
"longitude": -123.1221562
},
"timeWindows": [
{
"startTime": "2024-07-08T16:00:00Z",
"endTime": "2024-07-08T18:00:00Z"
}
],
"duration": "600s"
}
]
}
],
"vehicles": [
{
"travelMode": "DRIVING",
"startLocation": {
"latitude": 49.2593121,
"longitude": -123.2474966
},
"startTimeWindows": [
{
"startTime": "2024-07-08T16:00:00Z"
}
],
"endTimeWindows": [
{
"endTime": "2024-07-08T18:00:00Z"
}
],
"loadLimits": {
"persons": {
"maxLoad": 5
}
},
"label": "mark-yvr",
"costPerKilometer": 1,
"fixedCost": 25
}
],
"globalStartTime": "2024-07-08T16:00:00.000Z",
"globalEndTime": "2024-07-10T16:00:00.000Z"
},
"populatePolylines": true
}
Why time windows matter in NEMT routing
A time window is a defined time range e.g. 10:00 - 11:00 during which a pickup or dropoff must occur. Delivery time windows are critical in NEMT routing because they ensure that patients arrive at their medical appointments on time. Many medical services, such as dialysis, chemotherapy or specialist visits operate on strict appointment times. Late arrivals can result in missed care, rescheduling, or even medical complications.
In GMPRO, time windows are defined using the timeWindows
array. Each TimeWindow object within the array includes a startTime
and endTime
, both formatted as ISO8601 date-time strings, to specify the beginning and end of the allowed time window.
{
"timeWindows": [
{
"startTime": "2024-07-08T16:00:00Z",
"endTime": "2024-07-08T18:00:00Z"
}
]
}
Most NEMT operators add a 30 minute buffer to the scheduled appointment time because most clinics require patients to check in 10 - 15 minutes early and to account for traffic or unexpected delays e.g. no shows along the way. For example, if a patient in Vancouver has a clinic appointment at 10:00 on July 28, 2025, you would set the startTime
to "2025-07-28T16:30:00Z"
(which is 09:30 Pacific Time) and the endTime
to "2025-07-28T17:00:00Z"
(10:00 Pacific Time) to ensure that he arrives.
If we adjust our original example so that the drop-off at Vancouver General Hospital (booking yvr456
) must occur before 10:00, the route sequence changes to prioritize that dropoff first - even though the hospital is farther away.
Input
{
"model": {
"shipments": [
{
"loadDemands": {
"persons": {
"amount": 2
}
},
"label": "yvr123",
"pickups": [
{
"arrivalLocation": {
"latitude": 49.2688067,
"longitude": -123.2099986
},
"timeWindows": [
{}
],
"duration": "600s"
}
],
"deliveries": [
{
"arrivalLocation": {
"latitude": 49.2728387,
"longitude": -123.1603492
},
"timeWindows": [
{
"startTime": "2024-07-08T16:00:00Z",
"endTime": "2024-07-08T18:00:00Z"
}
],
"duration": "600s"
}
]
},
{
"loadDemands": {
"persons": {
"amount": 3
}
},
"label": "yvr456",
"pickups": [
{
"arrivalLocation": {
"latitude": 49.2585237,
"longitude": -123.1921195
},
"timeWindows": [
{}
],
"duration": "600s"
}
],
"deliveries": [
{
"arrivalLocation": {
"latitude": 49.2614006,
"longitude": -123.1221562
},
"timeWindows": [
{
"startTime": "2024-07-08T16:00:00Z",
"endTime": "2024-07-08T17:00:00Z"
}
],
"duration": "600s"
}
]
}
],
"vehicles": [
{
"travelMode": "DRIVING",
"startLocation": {
"latitude": 49.2593121,
"longitude": -123.2474966
},
"startTimeWindows": [
{
"startTime": "2024-07-08T16:00:00Z"
}
],
"endTimeWindows": [
{
"endTime": "2024-07-08T18:00:00Z"
}
],
"loadLimits": {
"persons": {
"maxLoad": 5
}
},
"label": "mark-yvr",
"costPerKilometer": 1,
"fixedCost": 25
}
],
"globalStartTime": "2024-07-08T16:00:00.000Z",
"globalEndTime": "2024-07-10T16:00:00.000Z"
},
"populatePolylines": true
}
Output
{
"routes": [
{
"vehicleLabel": "mark-yvr",
"vehicleStartTime": "2024-07-08T16:00:00Z",
"vehicleEndTime": "2024-07-08T17:18:11Z",
"visits": [
{
"isPickup": true,
"startTime": "2024-07-08T16:07:07Z",
"detour": "0s",
"shipmentLabel": "yvr123",
"loadDemands": {
"persons": {
"amount": "2"
}
}
},
{
"shipmentIndex": 1,
"isPickup": true,
"startTime": "2024-07-08T16:23:45Z",
"detour": "934s",
"shipmentLabel": "yvr456",
"loadDemands": {
"persons": {
"amount": "3"
}
}
},
{
"shipmentIndex": 1,
"startTime": "2024-07-08T16:46:52Z",
"detour": "0s",
"shipmentLabel": "yvr456",
"loadDemands": {
"persons": {
"amount": "-3"
}
}
},
{
"startTime": "2024-07-08T17:08:11Z",
"detour": "2626s",
"shipmentLabel": "yvr123",
"loadDemands": {
"persons": {
"amount": "-2"
}
}
}
],
"transitions": [
{
"travelDuration": "427s",
"travelDistanceMeters": 4431,
"waitDuration": "0s",
"totalDuration": "427s",
"startTime": "2024-07-08T16:00:00Z",
"vehicleLoads": {
"persons": {}
}
},
{
"travelDuration": "398s",
"travelDistanceMeters": 3151,
"waitDuration": "0s",
"totalDuration": "398s",
"startTime": "2024-07-08T16:17:07Z",
"vehicleLoads": {
"persons": {
"amount": "2"
}
}
},
{
"travelDuration": "787s",
"travelDistanceMeters": 5831,
"waitDuration": "0s",
"totalDuration": "787s",
"startTime": "2024-07-08T16:33:45Z",
"vehicleLoads": {
"persons": {
"amount": "5"
}
}
},
{
"travelDuration": "679s",
"travelDistanceMeters": 4231,
"waitDuration": "0s",
"totalDuration": "679s",
"startTime": "2024-07-08T16:56:52Z",
"vehicleLoads": {
"persons": {
"amount": "2"
}
}
},
{
"travelDuration": "0s",
"waitDuration": "0s",
"totalDuration": "0s",
"startTime": "2024-07-08T17:18:11Z",
"vehicleLoads": {
"persons": {}
}
}
],
"routePolyline": {
"points": "}}skHbwfoVdAy@DCq@{Cm@}B@I@K?G@GlEeDLIjCwBhBwAjByAVU~DyCvB_BNQFEXQLILGLEHAH?F?@@B@@@B?B?B?B?@?BABA@ABABE@CBC@C@E?C@E?C@E?C?C?A?E?EAC?EACAEACACACCCAAAAGKGOEMCII[AEGYI[ESGSu@{CEOGQSw@g@yB_@aB]_B[qAMm@@E@C?C@E?C?I?EAC?EAC?CACACACCCCEECA?AAA?AAOUEGCKOm@Mc@S{@AAOo@I]u@{CIWGS]qAEQa@uAa@{AEOOo@Om@Mk@EUe@gCOy@QgAQoAKq@Gk@G][uCAQGs@Gq@Eo@ImAAWE{@Cq@A[ASAw@EiBCkB?wB?U?I@s@?a@@{B?_@@wA@w@Bg@?C?u@@aC@kE?W@]?K@yC@wE?C@aF@wC@gB_@Aw@Aa@AA?g@?s@Ac@?u@AoAAIA{AAwACu@?e@Ai@Aa@?OAyAAyAEmA?I?sACUCmAAwACG?oACa@?e@?[AkAAoAE{@CU?M?iAAwACkAAG?qBCc@AY??i@BcD@eD?U@gB@}A?W@u@?y@@{C@cC@oA?iA@w@@qB?g@@W?_A@q@D_C?[?cA?Q@M?OBMB[XoBTuANeARuAZqB?ADYDW@S@CB_@@a@?C@I@gA?e@?ADY@OAo@Cc@Ea@COCKCOEUUmAOw@?A[eBESCUCUAYASAU?Q?e@@Q?u@?w@@k@@eD?M@kA@_@@aABaG@[@iCDyBCUDiE@oA@o@?i@?S@}B?o@?a@?o@?UA}@C}@AgA?m@Ee@?sEx@BJ?F?`A@Z?bCBZ@?@?@@??@?@?@@??@@??@@??@@?@?@??A@??A@??A@??A?A?A@??A?AhABB?lA@dABF?~@BN??@?@?@?@?@@??@?@@??@@??@@??@@?@?@?@??A@??A@??A@??A?A@??A?A?A?A?A?A@@dA@tA@b@@t@?fA@N?T?fA?zA@P@fABv@@`@@rA@|@@b@@vA@xA@|ABxABvABzA@?r@AfD?v@CzGAn@Ar@?d@Al@?TWBE@EDCDABALAR?@?B@H@F@BDFBBHBPD?|D?r@`@?BkF@a@?MB{E?q@BqI@oB@mB@iEBsD?m@@mB?i@BwE@{E?S@M?k@?Y?}B@oB?y@@gA@yB@cB@kB@m@@}@?u@?cA@m@@_F?U?a@@_B@_D@m@?K?i@@aABcC?oA?O?_A@w@?u@@{BBcC?q@?Y?q@?kB?sABqC@e@@}AB}FDuHJaC?GAECWBM@M@[?y@?I@u@?w@?u@DyMBeD@mBBgE@mB@mB?mBBmC@eD@w@?iB@_A?]?I@mA?W?Q?I?U?_@@eA@cABaEDgN?QBoFBuES@KAY?C?a@A[AaAAG?qAAG?e@?o@Ac@Aw@AwACm@Ao@A_BE@kBBkJBaFDY@S@W?_A@sB@{E?W@cB@qCBmC?oCFsC?y@?yA?kCG]D}D@[@}E?sFD{E?CDcFFSASDaF@_F?_@DkF?E?aA?sA@iAEi@@qB?E?M?sC@U?M@gA[AgAAc@Ag@Af@@b@@fA@Z@AfA?LAT?rC?L?DApBIh@ChCAzACnF?VAhA?h@?f@?l@AtC?vBFTEbF?BEzE?rFA|EAZE|DE\\CjC?vACx@CtD?`A?j@ClCArC?xBC|EApB?`BFd@C`FCjJAjB_BA}AC{ACyACm@?m@AWAS?o@CQ?eACQ?uACc@?M?a@AW?y@AeCCc@?[?a@AAAeACc@Cg@@o@@K@WAa@?MQ[@k@AQ?]?w@AwAGQ?eA?wACe@Cs@CiAAsACQ?m@Eg@EUEICOKGEIIKGMKAr@@r@AlA?X?\\?L?\\?F?FA~@?RAbA?D?X?HAx@?t@A`A?\\?z@?`@ApBAxBAd@?|@?\\?l@AfAArB?f@AlA?f@AxB?hBChD?L?T?V@V?b@?N?^AjACdF?J?VAd@Cx@CjCAhC?V?LAz@?`A?NAn@"
},
"metrics": {
"performedShipmentCount": 2,
"travelDuration": "2291s",
"waitDuration": "0s",
"delayDuration": "0s",
"breakDuration": "0s",
"visitDuration": "2400s",
"totalDuration": "4691s",
"travelDistanceMeters": 17644,
"maxLoads": {
"persons": {
"amount": "5"
}
},
"performedMandatoryShipmentCount": 2
},
"routeCosts": {
"model.vehicles.cost_per_kilometer": 17.644,
"model.vehicles.fixed_cost": 25
},
"routeTotalCost": 42.644,
"vehicleFullness": {
"maxFullness": 1,
"maxLoad": 1,
"activeSpan": 0.6515277777777778
}
}
],
"metrics": {
"aggregatedRouteMetrics": {
"performedShipmentCount": 2,
"travelDuration": "2291s",
"waitDuration": "0s",
"delayDuration": "0s",
"breakDuration": "0s",
"visitDuration": "2400s",
"totalDuration": "4691s",
"travelDistanceMeters": 17644,
"maxLoads": {
"persons": {
"amount": "5"
}
},
"performedMandatoryShipmentCount": 2
},
"usedVehicleCount": 1,
"earliestVehicleStartTime": "2024-07-08T16:00:00Z",
"latestVehicleEndTime": "2024-07-08T17:18:11Z",
"totalCost": 42.644,
"costs": {
"model.vehicles.cost_per_kilometer": 17.644,
"model.vehicles.fixed_cost": 25
}
}
}
Wheelchair access considerations in NEMT routing
Most vans and shuttles used in NEMT can be configured to carry a mix of regular passengers and wheelchairs. To represent this tradeoff in GMPRO, you can use its multiple loads and capacity constraints feature.
For example, suppose a van can hold either:
- 6 passengers, or
- 2 passengers and 1 wheelchair
In the shipment
object for a wheelchair pickup, set loadDemands
to reflect the equivalent of 4 regular passengers and 1 wheelchair - meaning the wheelchair occupies the space of 4 passengers (persons
).
{
"loadDemands": {
"persons": {
"amount": 4
},
"wheelchairs": {
"amount": 1
}
}
}
In the vehicle object, set loadLimits
to specify a maximum of 6 regular passengers and 1 wheelchair. If a wheelchair is picked up, it will occupy the space of 4 regular passengers and 1 wheelchair slot, leaving room for only 2 additional passengers.
{
"loadLimits": {
"persons": {
"maxLoad": 6
},
"wheelchairs": {
"maxLoad": 1
}
}
}
This way, there is no need to dynamically “reconfigure” the van - GMPRO will automatically select feasible combinations based on capacity.
In the example below, a 6-person van is assigned to two pickups: one group of 3 regular passengers and one individual using a wheelchair. Because the wheelchair occupies space equivalent to 4 passengers, combining both pickups would exceed the van’s capacity (3 + 4 = 7). As a result, GMPRO schedules the pickups separately to stay within the vehicle's capacity limit.
Input
{
"model": {
"shipments": [
{
"loadDemands": {
"persons": {
"amount": 3
},
"wheelchairs": {
"amount": 0
}
},
"label": "yvr123",
"pickups": [
{
"arrivalLocation": {
"latitude": 49.2688067,
"longitude": -123.2099986
},
"timeWindows": [
{}
],
"duration": "600s"
}
],
"deliveries": [
{
"arrivalLocation": {
"latitude": 49.2728387,
"longitude": -123.1603492
},
"timeWindows": [
{
"startTime": "2024-07-08T16:00:00Z",
"endTime": "2024-07-08T18:00:00Z"
}
],
"duration": "600s"
}
]
},
{
"loadDemands": {
"persons": {
"amount": 4
},
"wheelchairs": {
"amount": 1
}
},
"label": "yvr456",
"pickups": [
{
"arrivalLocation": {
"latitude": 49.2585237,
"longitude": -123.1921195
},
"timeWindows": [
{}
],
"duration": "600s"
}
],
"deliveries": [
{
"arrivalLocation": {
"latitude": 49.2614006,
"longitude": -123.1221562
},
"timeWindows": [
{
"startTime": "2024-07-08T16:00:00Z",
"endTime": "2024-07-08T18:00:00Z"
}
],
"duration": "600s"
}
]
}
],
"vehicles": [
{
"travelMode": "DRIVING",
"startLocation": {
"latitude": 49.2593121,
"longitude": -123.2474966
},
"startTimeWindows": [
{
"startTime": "2024-07-08T16:00:00Z"
}
],
"endTimeWindows": [
{
"endTime": "2024-07-08T18:00:00Z"
}
],
"loadLimits": {
"persons": {
"maxLoad": 6
},
"wheelchairs": {
"maxLoad": 1
}
},
"label": "mark-yvr",
"costPerKilometer": 1,
"fixedCost": 25
}
],
"globalStartTime": "2024-07-08T16:00:00.000Z",
"globalEndTime": "2024-07-10T16:00:00.000Z"
},
"populatePolylines": true
}
Output
{
"routes": [
{
"vehicleLabel": "mark-yvr",
"vehicleStartTime": "2024-07-08T16:00:00Z",
"vehicleEndTime": "2024-07-08T17:15:58Z",
"visits": [
{
"isPickup": true,
"startTime": "2024-07-08T16:07:07Z",
"detour": "0s",
"shipmentLabel": "yvr123",
"loadDemands": {
"wheelchairs": {},
"persons": {
"amount": "3"
}
}
},
{
"startTime": "2024-07-08T16:24:25Z",
"detour": "0s",
"shipmentLabel": "yvr123",
"loadDemands": {
"persons": {
"amount": "-3"
},
"wheelchairs": {}
}
},
{
"shipmentIndex": 1,
"isPickup": true,
"startTime": "2024-07-08T16:42:51Z",
"detour": "2080s",
"shipmentLabel": "yvr456",
"loadDemands": {
"wheelchairs": {
"amount": "1"
},
"persons": {
"amount": "4"
}
}
},
{
"shipmentIndex": 1,
"startTime": "2024-07-08T17:05:58Z",
"detour": "0s",
"shipmentLabel": "yvr456",
"loadDemands": {
"wheelchairs": {
"amount": "-1"
},
"persons": {
"amount": "-4"
}
}
}
],
"transitions": [
{
"travelDuration": "427s",
"travelDistanceMeters": 4431,
"waitDuration": "0s",
"totalDuration": "427s",
"startTime": "2024-07-08T16:00:00Z",
"vehicleLoads": {
"wheelchairs": {},
"persons": {}
}
},
{
"travelDuration": "438s",
"travelDistanceMeters": 3992,
"waitDuration": "0s",
"totalDuration": "438s",
"startTime": "2024-07-08T16:17:07Z",
"vehicleLoads": {
"persons": {
"amount": "3"
},
"wheelchairs": {}
}
},
{
"travelDuration": "506s",
"travelDistanceMeters": 3867,
"waitDuration": "0s",
"totalDuration": "506s",
"startTime": "2024-07-08T16:34:25Z",
"vehicleLoads": {
"persons": {},
"wheelchairs": {}
}
},
{
"travelDuration": "787s",
"travelDistanceMeters": 5831,
"waitDuration": "0s",
"totalDuration": "787s",
"startTime": "2024-07-08T16:52:51Z",
"vehicleLoads": {
"persons": {
"amount": "4"
},
"wheelchairs": {
"amount": "1"
}
}
},
{
"travelDuration": "0s",
"waitDuration": "0s",
"totalDuration": "0s",
"startTime": "2024-07-08T17:15:58Z",
"vehicleLoads": {
"persons": {},
"wheelchairs": {}
}
}
],
"routePolyline": {
"points": "}}skHbwfoVdAy@DCq@{Cm@}B@I@K?G@GlEeDLIjCwBhBwAjByAVU~DyCvB_BNQFEXQLILGLEHAH?F?@@B@@@B?B?B?B?@?BABA@ABABE@CBC@C@E?C@E?C@E?C?C?A?E?EAC?EACAEACACACCCAAAAGKGOEMCII[AEGYI[ESGSu@{CEOGQSw@g@yB_@aB]_B[qAMm@@E@C?C@E?C?I?EAC?EAC?CACACACCCCEECA?AAA?AAOUEGCKOm@Mc@S{@AAOo@I]u@{CIWGS]qAEQa@uAa@{AEOOo@Om@Mk@EUe@gCOy@QgAQoAKq@Gk@G][uCAQGs@Gq@Eo@ImAAWE{@Cq@A[ASAw@EiBCkB?wB?U?I@s@?a@@{B?_@@wA@w@Bg@?C?u@@aC@kE?W@]?K@yC@wE?C@aF@wC@gB_@Aw@Aa@AA?g@?s@Ac@?u@AoAAIA{AAwACu@?e@Ai@Aa@?OAyAAyAEmA?I?sACUCmAAwACG?oACa@?e@?[AkAAoAE{@CU?M?iAAwACkAAG?qBCc@AY??i@BcD@eD?U@gB@}A?W@u@?y@@{C@cC@oA?iA@w@@qB?g@@W?_A@q@D_C?[?cA?Q@M?OBMB[XoBTuANeARuAZqB?ADYDW@S@CB_@@a@?C@I@gA?e@?ADY@OAo@Cc@Ea@COCKCOEUUmAOw@?A[eBESCUCUAYASAU?Q?e@@Q?u@?w@@k@@eD?M@kA@_@@aABaG@[@iCDyBCUDiE@oA@o@?i@?S@}B?o@?a@?o@?UA}@C}@AgA?m@Ee@?sE?[@m@?Y@gC?q@?aBBaCB{G@wA@oA@aE?]BgE@yB@mADmK?WBmI@[?U@eD@gD@_@@{I@M@{DBeI@_A@_DoACI?}@AY?]A_AAuACqDGu@CEAEACCCCCACECGCKk@oBe@{AACs@{BCMa@sA]iAK[CGEUGUEWAGAOCU?W@q@?y@@qD@w@Aq@@k@@gABwH?_@?^CvHAfAAj@@p@Av@ApD?x@Ap@?VBT@N@FDVFTDTBFJZ\\hA`@rABLr@zB@Bd@zAj@nBBJBFBDB@BBBBD@D@t@BpDFtAB~@@\\@X?|@@H?nAB|@@X?F?pABR?~@?pA@hABH@rA@R@~@@@?Z?t@APATCh@Kf@Il@IPCl@A\\@vAAJ?pABpAB~ABD@lA@L?xA@|@@X@xABb@?t@Bx@@`@?J?jA@P@hA@f@@n@@@^?RA~@?RAbE?t@?`BChF?t@AP?F?HAlACbA?fDAf@ApBAxC?v@At@?fAAfDCzCAt@AxAAnBAlCAt@A|AAbC?FAv@?@?T?`@@x@ArC?N?t@?hBC`D@v@CbEG~J?r@AfD?v@CzGAn@Ar@?d@Al@?TWBE@EDCDABALAR?@?B@H@F@BDFBBHBPD?|D?r@`@?BkF@a@?MB{E?q@BqI@oB@mB@iEBsD?m@@mB?i@BwE@{E?S@M?k@?Y?}B@oB?y@@gA@yB@cB@kB@m@@}@?u@?cA@m@@_F?U?a@@_B@_D@m@?K?i@@aABcC?oA?O?_A@w@?u@@{BBcC?q@?Y?q@?kB?sABqC@e@@}AB}FDuHJaC?GAECWBM@M@[?y@?I@u@?w@?u@DyMBeD@mBBgE@mB@mB?mBBmC@eD@w@?iB@_A?]?I@mA?W?Q?I?U?_@@eA@cABaEDgN?QBoFBuES@KAY?C?a@A[AaAAG?qAAG?e@?o@Ac@Aw@AwACm@Ao@A_BE@kBBkJBaFDY@S@W?_A@sB@{E?W@cB@qCBmC?oCFsC?y@?yA?kCG]D}D@[@}E?sFD{E?CDcFFSASDaF@_F?_@DkF?E?aA?sA@iAEi@@qB?E?M?sC@U?M@gA[AgAAc@Ag@A"
},
"metrics": {
"performedShipmentCount": 2,
"travelDuration": "2158s",
"waitDuration": "0s",
"delayDuration": "0s",
"breakDuration": "0s",
"visitDuration": "2400s",
"totalDuration": "4558s",
"travelDistanceMeters": 18121,
"maxLoads": {
"persons": {
"amount": "4"
},
"wheelchairs": {
"amount": "1"
}
},
"performedMandatoryShipmentCount": 2
},
"routeCosts": {
"model.vehicles.cost_per_kilometer": 18.121,
"model.vehicles.fixed_cost": 25
},
"routeTotalCost": 43.120999999999995,
"vehicleFullness": {
"maxFullness": 1,
"maxLoad": 1,
"activeSpan": 0.6330555555555556
}
}
],
"metrics": {
"aggregatedRouteMetrics": {
"performedShipmentCount": 2,
"travelDuration": "2158s",
"waitDuration": "0s",
"delayDuration": "0s",
"breakDuration": "0s",
"visitDuration": "2400s",
"totalDuration": "4558s",
"travelDistanceMeters": 18121,
"maxLoads": {
"wheelchairs": {
"amount": "1"
},
"persons": {
"amount": "4"
}
},
"performedMandatoryShipmentCount": 2
},
"usedVehicleCount": 1,
"earliestVehicleStartTime": "2024-07-08T16:00:00Z",
"latestVehicleEndTime": "2024-07-08T17:15:58Z",
"totalCost": 43.120999999999995,
"costs": {
"model.vehicles.fixed_cost": 25,
"model.vehicles.cost_per_kilometer": 18.121
}
}
}
Setting maximum allowable ride time
The last requirement that a route optimization solver needs to meet is to ensure that each trip stays within the maximum allowed ride time. Many NEMT passengers are elderly or medically fragile and cannot tolerate long rides. To protect these patients and ensure timely access to care, Medicaid programs and managed care organizations often set ride time limits - typically 60 to 90 minutes.
To make sure that the maximum allowable ride time constraint is not exceeded, all we need to do in GMPRO is to use the pickupToDeliveryTimeLimit
field on the shipment
object (docs) to specify the maximum duration from when the drivers picks up the passenger to when he drops him off.
For example, if we add "pickupToDeliveryTimeLimit": "1800s"
to booking yvr123
, GMPRO will be required to ensure that this passenger is dropped off within 30 minutes (1800 seconds) of pickup. In the original route, this wasn't possible due to the additional time needed to pick up yvr456
. As a result, the solver adjusts the route to pick up yvr456
first, then yvr123
, ensuring yvr123
reaches the destination within the 30 minute limit.
Some closing thoughts on NEMT routing
Running an NEMT service is not an easy business. The industry is highly regulated and providers must comply with strict Medicaid and ADA regulations, use specially certified vehicles, hire trained staff, and are typically reimbursed by Medicare or Medicaid (a painful and time consuming process) rather than paid directly. Since passenger volumes are relatively low, fixed costs like fuel, vehicle maintenance, and driver salaries are spread over fewer trips - making each ride more expensive to operate. This is exactly where route optimization adds value: it enables efficient trip batching to reduce fuel use and driver mileage at scale.
At Afi Labs, we build tools that help NEMT providers route smarter and serve patients better. If you’re ready to optimize your operations without compromising on care quality or compliance, get in touch - we’d love to help.
👋 As always, if you have any questions or suggestions for me, please reach out or say hello on LinkedIn.