Reading Results¶
solve() returns a VRPSolution object with typed access to routes, metrics, and feasibility status.
Solution Overview¶
solution = router.solve(instance)
print(f"Status: {solution.solution_status}") # "Feasible" | "FeasibleWithSoftViolations" | "Infeasible"
print(f"Feasible: {solution.is_feasible}") # True unless a hard constraint is violated
print(f"Soft violations: {solution.has_soft_violations}")
print(f"Total duration: {solution.total_route_duration}")
print(f"Total driving: {solution.total_driving_time}")
print(f"Total service: {solution.total_service_time}")
print(f"Total visits: {solution.total_n_visits}")
print(f"Total delivery load: {solution.total_delivery_load}")
print(f"Number of routes: {len(solution.routes)}")
VRPSolution Fields¶
| Field | Type | Description |
|---|---|---|
total_route_duration |
float |
Sum of all route durations (driving + service + waiting) |
total_driving_time |
float |
Sum of driving time across all routes |
total_driving_distance |
float or None |
Sum of driving distances (only when a separate distance matrix is provided) |
total_service_time |
float |
Sum of service time across all routes |
total_n_visits |
int |
Total customer visits across all routes |
total_delivery_load |
float or list[float] |
Total delivery load across all routes (a [weight, volume] list on 2-D instances) |
total_pickup_load |
float, list[float] or None |
Total pickup load (only for pickup-and-delivery problems) |
solution_status |
str |
"Feasible", "Infeasible", or "FeasibleWithSoftViolations" |
routes |
list[Route] |
List of routes (one per active vehicle) |
constraint_violations |
list[ConstraintViolation] |
Reported violations; empty when fully feasible, non-empty for infeasible and soft-violating solutions |
is_feasible |
bool |
True when no hard constraint is violated — i.e. status is "Feasible" or "FeasibleWithSoftViolations" |
has_soft_violations |
bool |
True when the solution is feasible but violates a soft-configured constraint (status "FeasibleWithSoftViolations") |
total_waiting_time |
float or None |
Total waiting time (only for time-window problems) |
total_profit |
float or None |
Total profit collected (only for prize-collecting problems) |
unvisited_customers |
list[int or str] or None |
IDs of customers left unvisited (only for prize-collecting problems) |
Feasibility status¶
solution_status has three values:
| Status | is_feasible |
has_soft_violations |
Meaning |
|---|---|---|---|
"Feasible" |
True |
False |
All constraints satisfied |
"FeasibleWithSoftViolations" |
True |
True |
All hard constraints satisfied; one or more soft constraints violated (priced into the objective and listed in constraint_violations) |
"Infeasible" |
False |
False |
At least one hard constraint is violated |
Use is_feasible to accept a solution and has_soft_violations to detect priced soft breaches.
Routes¶
Each route represents one vehicle's journey:
for route in solution.routes:
print(f"Route {route.id}:")
print(f" Vehicle type: {route.vehicle_type_id}")
print(f" Customers visited: {route.n_visits}")
print(f" Start time: {route.route_start_time}")
print(f" End time: {route.route_end_time}")
print(f" Duration: {route.route_duration}")
print(f" Driving time: {route.route_driving_time}")
print(f" Service time: {route.route_service_time}")
print(f" Delivery load: {route.route_delivery_load}")
print(f" Peak load: {route.route_peak_load}")
Route Fields¶
| Field | Type | Description |
|---|---|---|
id |
int |
Route index (0-based) |
vehicle_type_id |
str or int |
Which vehicle type is assigned to this route |
n_visits |
int |
Number of customers on this route (reload stops excluded) |
route_start_time |
float or None |
When the vehicle departs the depot (default: earliest with minimum waiting) |
route_end_time |
float or None |
When the vehicle returns to the depot |
route_duration |
float |
Total duration (driving + service + waiting) |
route_driving_time |
float |
Driving time only |
route_distance |
float or None |
Total route distance (only when a separate distance matrix is provided) |
route_service_time |
float |
Service time only |
route_delivery_load |
float or list[float] |
Delivery load carried (a [weight, volume] list on 2-D instances) |
route_pickup_load |
float, list[float] or None |
Pickup load carried (pickup-and-delivery problems only) |
route_peak_load |
float, list[float] or None |
Maximum vehicle load at any point during the route (pickup-and-delivery problems only) |
customer_sequence |
list[CustomerVisit \| ReloadStop] |
Ordered sequence of stops — includes ReloadStop entries on multi-trip routes |
visits |
list[CustomerVisit] |
Property: customer visits only |
reload_stops |
list[ReloadStop] |
Property: mid-route depot reloads only (see Multi-Trip Routes) |
route_waiting_time |
float or None |
Waiting time (time-window problems only) |
route_profit |
float or None |
Profit collected (prize-collecting only) |
route_earliest_departure |
float or None |
Absolute earliest feasible depot departure — may involve extra waiting (schedule field) |
route_latest_departure |
float or None |
Latest feasible depot departure (schedule field) |
Customer Visits¶
route.visits lists the customer stops of a route. route.customer_sequence is the full stop timeline: on multi-trip routes it also contains ReloadStop entries between the visits.
Each visit has detailed timing information:
for route in solution.routes:
for visit in route.visits:
print(f" Customer {visit.customer_id}:")
print(f" Arrival (ETA): {visit.eta}")
print(f" Service start: {visit.start_service_time}")
print(f" Service end: {visit.end_service_time}")
if visit.waiting_time is not None:
print(f" Waiting: {visit.waiting_time}")
CustomerVisit Fields¶
| Field | Type | Description |
|---|---|---|
customer_id |
str or int |
Customer identifier (your custom ID or positional index) |
eta |
float |
Estimated time of arrival |
start_service_time |
float |
When service begins |
end_service_time |
float |
When service ends |
waiting_time |
float or None |
Time spent waiting before service (time-window problems only) |
earliest_arrival |
float or None |
Earliest feasible arrival without violating any constraints (schedule field) |
latest_arrival |
float or None |
Latest feasible arrival at this customer (schedule field) |
no_wait_arrival |
float or None |
Earliest arrival with zero waiting time (schedule field) |
Timing Relationship
eta ≤ start_service_time ≤ end_service_time
If the vehicle arrives early (before the time window opens), it waits: waiting_time = start_service_time - eta.
Schedule Awareness¶
When the problem includes time windows, vehicle shifts, or duration limits, the solver automatically computes arrival windows for each customer and departure bounds for each route. These fields help you understand how much scheduling flexibility exists.
How schedule fields are computed
TQRouter.solve() automatically calls solution.compute_schedule(instance) after solving, so schedule fields are immediately available. You only need to call compute_schedule() manually when loading a solution from a JSON file — see Loading a Solution from JSON below.
When are schedule fields populated?
Schedule fields (earliest_arrival, latest_arrival, no_wait_arrival, route_earliest_departure, route_latest_departure) are populated when the problem has at least one of:
- Customer time windows
- Vehicle
shift_end - Vehicle
max_duration
For pure capacity-only problems (CVRP) without any time constraints, these fields remain None.
Departure Window¶
Each route has a feasible departure range:
route = solution.routes[0]
print(f"Earliest departure: {route.route_earliest_departure}")
print(f"Latest departure: {route.route_latest_departure}")
The vehicle can leave the depot at any time between route_earliest_departure and route_latest_departure while keeping the entire route feasible.
Open routes
For a vehicle type without end_location the route ends at its last customer, and all schedule fields are computed without a return leg — route_latest_departure, the arrival windows and the durations recomputed by set_departure_time() all stop at the last stop, exactly like the solver's own route_end_time.
The default route_start_time is the earliest departure that minimises total waiting time. This is not the same as route_earliest_departure — departing at route_earliest_departure is feasible but may cause the vehicle to arrive early at time-windowed customers and wait. The solver picks the earliest time at which no avoidable waiting occurs, which is always ≥ route_earliest_departure.
Arrival Windows¶
Each customer visit has an arrival window:
for visit in route.visits:
print(f"Customer {visit.customer_id}:")
print(f" Can arrive: [{visit.earliest_arrival}, {visit.latest_arrival}]")
print(f" No-wait from: {visit.no_wait_arrival}")
print(f" Planned ETA: {visit.eta}")
| Field | Meaning |
|---|---|
earliest_arrival |
Earliest the vehicle can arrive at this customer without violating any constraints — accounts for travel time, service time, and time windows of all previous stops |
latest_arrival |
Latest the vehicle can arrive and still serve all remaining customers on time. When max_duration is set, this value depends on the departure time |
no_wait_arrival |
Earliest arrival that does not require waiting at this customer (max(earliest_arrival, time_window.start)) |
Understanding slack
The difference latest_arrival - eta tells you how much delay this customer can absorb before downstream stops are affected. This is useful for real-world dispatching where delays are inevitable.
Changing the Departure Time¶
Use set_departure_time() to recalculate all ETAs and timing metrics for a route based on a custom departure time:
solution = router.solve(instance)
route = solution.routes[0]
print(f"Default departure: {route.route_start_time}") # earliest with min waiting
print(f"Default duration: {route.route_duration}")
# Override the departure time and recalculate all ETAs
solution.set_departure_time(route_id=0, departure_time="09:00:00")
print(f"New departure: {route.route_start_time}") # 32400.0 (09:00)
print(f"New duration: {route.route_duration}")
for visit in route.visits:
print(f" {visit.customer_id}: ETA={visit.eta}, wait={visit.waiting_time}")
set_departure_time() updates the following fields on the route and its visits:
route.route_start_time,route.route_end_time,route.route_duration,route.route_waiting_timevisit.eta,visit.waiting_time,visit.start_service_time,visit.end_service_timesolution.total_route_duration,solution.total_waiting_timevisit.latest_arrival— only whenmax_durationis set on the vehicle type (see note below)
Departure bounds (route_earliest_departure, route_latest_departure), earliest_arrival, and no_wait_arrival are not affected — they are fixed properties of the route sequence and constraints, independent of the chosen departure time.
latest_arrival and max_duration
When a vehicle type has max_duration set, latest_arrival depends on the departure time: a later departure shifts the duration deadline forward, allowing later arrivals at each stop. Because of this, set_departure_time() automatically recomputes latest_arrival for every visit on the route to reflect the new duration deadline.
Without max_duration, latest_arrival is determined solely by time windows and depot end time, and does not change when the departure time changes.
Warning
Choosing a departure time outside [route_earliest_departure, route_latest_departure] will produce a schedule, but it may violate time window or duration constraints. The arrival windows indicate where feasible scheduling is possible.
Loading a Solution from JSON¶
When loading a solution from a file, schedule fields are not computed automatically. Call compute_schedule() manually:
from tqrouting import VRPSolution, VRPInstance
instance = VRPInstance.from_json("problem.json")
solution = VRPSolution.from_json("solution.json")
solution.compute_schedule(instance)
# Now schedule fields are available
print(solution.routes[0].route_earliest_departure)
# And set_departure_time works
solution.set_departure_time(route_id=0, departure_time="08:00:00")
A loaded solution keeps the time format of the file it came from, so saving it again writes the same representation. After compute_schedule(instance) it uses the instance's format, exactly like a solution returned by solve().
The file also carries the decimal the solution was solved with (see Matrix precision), so compute_schedule() rounds the duration matrix exactly as the solver did and the arrival windows agree with eta.
Handling Infeasible Solutions¶
When the solver cannot find a solution that satisfies all constraints, it returns the best solution found along with violation details:
solution = router.solve(instance)
if not solution.is_feasible:
print("Solution has constraint violations:")
for v in solution.constraint_violations:
print(f" {v.constraint} on route {v.route_id} (excess: {v.excess_value})")
ConstraintViolation Fields¶
| Field | Type | Description |
|---|---|---|
constraint |
str |
Name of the violated constraint |
route_id |
int |
Which route has the violation |
excess_value |
float |
By how much the constraint is exceeded |
dimension |
int or None |
For "capacity" violations on 2-D instances: which dimension (0 = weight, 1 = volume) |
customer_id |
int, str or None |
For "time_window" violations: the customer whose window was missed (mapped to your custom ID) |
Possible constraint names:
| Constraint | Meaning |
|---|---|
"capacity" |
Route load exceeds vehicle capacity (carries dimension on 2-D instances) |
"time_window" |
Service starts after the customer's time window end (carries customer_id) |
"shift_end" |
Vehicle returns after shift_end |
"max_duration" |
Route duration exceeds max_duration |
"max_distance" |
Route distance exceeds max_distance |
A violation is reported for both hard (infeasible) and soft (FeasibleWithSoftViolations) constraint breaches — check solution_status / has_soft_violations to tell which.
Time Formatting¶
All time fields on the VRPSolution dataclass are always stored as seconds (float), regardless of how you specified times in the input. This makes it easy to do arithmetic directly on the solution:
solution = router.solve(instance)
solution.total_route_duration # 5400.0 (seconds), not "01:30:00"
route = solution.routes[0]
route.route_driving_time # 3600.0 (seconds), not "01:00:00"
route.visits[0].eta # 30600.0 (seconds), not "08:30:00"
String formatting happens only when you call to_dict() or to_json(). If your input used HH:MM:SS strings, the solution formats its times the same way (fractions of a second are rounded to the nearest second):
from tqrouting import TQRouter, VRPInstance, Customer, VehicleType, Location, TimeWindow
router = TQRouter()
instance = VRPInstance(
customers=[
Customer(
location=Location(matrix_id=1),
time_window=TimeWindow(start="08:00:00", end="17:00:00"),
service_time="00:30:00",
),
],
fleet=[VehicleType(start_location=Location(matrix_id=0), end_location=Location(matrix_id=0))],
duration_matrix=[[0, 900], [900, 0]], # 15 minutes each way
)
solution = router.solve(instance, time_limit=1)
result = solution.to_dict()
result["routes"][0]["route_start_time"] # "07:45:00" — leave so as to arrive as the window opens
result["routes"][0]["customer_sequence"][0]["eta"] # "08:00:00"
result["routes"][0]["customer_sequence"][0]["end_service_time"] # "08:30:00"
result["routes"][0]["route_end_time"] # "08:45:00"
solution.routes[0].customer_sequence[0].eta # 28800.0 — the dataclass keeps seconds
You can also override the format explicitly:
solution.to_dict(time_format="HH:MM:SS") # Always HH:MM:SS
solution.to_dict(time_format="seconds") # Raw seconds (float)
Serialization¶
Save and load solutions as JSON or Python dicts:
# Save to JSON
solution.to_json("result.json")
# Load from JSON
from tqrouting import VRPSolution
solution = VRPSolution.from_json("result.json")
# Or work with dicts directly
solution_dict = solution.to_dict()
solution = VRPSolution.from_dict(solution_dict)
to_dict() and to_json() both accept an optional time_format keyword argument to control how time values are serialized:
solution.to_dict(time_format="HH:MM:SS") # Always HH:MM:SS
solution.to_dict(time_format="seconds") # Raw seconds (float)
solution.to_json("result.json", time_format="HH:MM:SS")
If time_format is not specified, the format is automatically detected from the input instance: if you used HH:MM:SS strings in the input, the solution output uses the same format.
to_dict() produces a clean format: None fields are omitted, time values use the detected format, and all field names are lowercase.
Complete Example¶
Three orders, one truck type with room for two of them per trip:
from tqrouting import TQRouter, VRPInstance, Customer, VehicleType, Location
router = TQRouter()
instance = VRPInstance(
customers=[
Customer(id="ORD-001", location=Location(matrix_id=1), delivery_load=10, service_time=60),
Customer(id="ORD-002", location=Location(matrix_id=2), delivery_load=10, service_time=60),
Customer(id="ORD-003", location=Location(matrix_id=3), delivery_load=8, service_time=60),
],
fleet=[
VehicleType(
id="truck-A",
start_location=Location(matrix_id=0),
end_location=Location(matrix_id=0),
capacity_load=20,
n_vehicles=2,
),
],
duration_matrix=[
[0, 30, 45, 60],
[30, 0, 40, 25],
[45, 40, 0, 35],
[60, 25, 35, 0],
],
)
solution = router.solve(instance, time_limit=1)
solution.solution_status # "Feasible"
solution.is_feasible # True
solution.total_route_duration # 380.0
solution.total_driving_time # 200.0
solution.total_service_time # 180.0
solution.total_n_visits # 3
solution.total_delivery_load # 28.0
route = solution.routes[0]
route.id # 0
route.vehicle_type_id # "truck-A"
route.n_visits # 1
route.route_start_time # 0.0
route.route_end_time # 120.0
route.route_duration # 120.0
visit = route.visits[0]
visit.customer_id # "ORD-001"
visit.eta # 30.0
visit.start_service_time # 30.0
visit.end_service_time # 90.0
solution.to_dict():
{
"total_route_duration": 380.0,
"total_driving_time": 200.0,
"total_service_time": 180.0,
"total_n_visits": 3,
"total_delivery_load": 28.0,
"solution_status": "Feasible",
"routes": [
{
"id": 0,
"vehicle_type_id": "truck-A",
"n_visits": 1,
"route_start_time": 0.0,
"route_end_time": 120.0,
"route_duration": 120.0,
"route_driving_time": 60.0,
"route_service_time": 60.0,
"route_delivery_load": 10.0,
"customer_sequence": [
{
"customer_id": "ORD-001",
"eta": 30.0,
"start_service_time": 30.0,
"end_service_time": 90.0
}
]
},
{
"id": 1,
"vehicle_type_id": "truck-A",
"n_visits": 2,
"route_start_time": 0.0,
"route_end_time": 260.0,
"route_duration": 260.0,
"route_driving_time": 140.0,
"route_service_time": 120.0,
"route_delivery_load": 18.0,
"customer_sequence": [
{
"customer_id": "ORD-003",
"eta": 60.0,
"start_service_time": 60.0,
"end_service_time": 120.0
},
{
"customer_id": "ORD-002",
"eta": 155.0,
"start_service_time": 155.0,
"end_service_time": 215.0
}
]
}
]
}
solution.to_dict(time_format="HH:MM:SS"):
{
"total_route_duration": "00:06:20",
"total_driving_time": "00:03:20",
"total_service_time": "00:03:00",
"total_n_visits": 3,
"total_delivery_load": 28.0,
"solution_status": "Feasible",
"routes": [
{
"id": 0,
"vehicle_type_id": "truck-A",
"n_visits": 1,
"route_start_time": "00:00:00",
"route_end_time": "00:02:00",
"route_duration": "00:02:00",
"route_driving_time": "00:01:00",
"route_service_time": "00:01:00",
"route_delivery_load": 10.0,
"customer_sequence": [
{
"customer_id": "ORD-001",
"eta": "00:00:30",
"start_service_time": "00:00:30",
"end_service_time": "00:01:30"
}
]
},
{
"id": 1,
"vehicle_type_id": "truck-A",
"n_visits": 2,
"route_start_time": "00:00:00",
"route_end_time": "00:04:20",
"route_duration": "00:04:20",
"route_driving_time": "00:02:20",
"route_service_time": "00:02:00",
"route_delivery_load": 18.0,
"customer_sequence": [
{
"customer_id": "ORD-003",
"eta": "00:01:00",
"start_service_time": "00:01:00",
"end_service_time": "00:02:00"
},
{
"customer_id": "ORD-002",
"eta": "00:02:35",
"start_service_time": "00:02:35",
"end_service_time": "00:03:35"
}
]
}
]
}