I've not made much progress with this, but have only uncovered more mysteries.
Trying to reduce to a minimal program that reproduces the issue. First up, disjunction_okay.py, which *does not* solve your initial problem, but which works.
If you run this program with no command line arguments, it solves the 5 demand node case with two vehicles, both with capacity of 3:
python disjunction_okay.py
The Objective Value is 75
...
Total distance of all routes: 75m
Total load of all routes: 5
I set up command line parameters to increase and decrease the size of the problem, and to set up disjunctions. If you turn on disjuctions with a minimal penalty, no nodes are served:
python disjunction_okay.py -d True --singlepenalty 10
single node disjunction penalty is 10
added 5 disjunctions, one per node
The Objective Value is 50
...
Total distance of all routes: 0m
Total load of all routes: 0
So that is good. If you bump up the penalty to 18, the nodes are no longer dropped, and the solution returns to the original solution.
python disjunction_okay.py -d True --singlepenalty 18
single node disjunction penalty is 18
added 5 disjunctions, one per node
The Objective Value is 75
...
Total distance of all routes: 75m
Total load of all routes: 5
Disjunctions are important when you bump the problem up to seven nodes (command line --seven True) because then of course the solver *must* drop one node or there is no solution.
In any event, all works as expected with the program disjunction_okay.py
The problem comes when I return to the original problem. Consider disjunction_fail.py. The only real difference is that now there are 4 vehicles instead of two, AND there is that constraint in there requiring that only one of a pair of vehicles is used. Code:
```
data['vehicle_capacities'] = [3, 1, 3, 1]
data['vehicle_costs'] = [5, 1, 5, 1]
...
for veh_pair in range(0, num_veh//2):
# buggy dead reckoning, but truck-trailer is first, truck single unit second
combo = veh_pair*2
single = veh_pair*2 + 1
index_combo = routing.End(combo)
index_single = routing.End(single)
end_time_combo = time_dimension.CumulVar(index_combo)
end_time_single = time_dimension.CumulVar(index_single)
combo_on = end_time_combo > 0
single_on = end_time_single > 0
# constrain solver to preven both being on
# truth table
#
# combo_on single_on multiply descr
# 0 0 0 both unused; okay
# 1 0 0 combo on only; okay
# 0 1 0 single on only; okay
# 1 1 1 both on; prevent
#
solver.Add(combo_on * single_on == 0)
```
Running the program with no command line arguments uses a demand matrix with 5 pickup nodes, and the solution is to use two combo trucks, one with 3 loads, one with 2:
python disjunction_fail.py
The Objective Value is 75
Truck 0 travel time
combo: 40
single: 0
Truck 1 travel time
combo: 35
single: 0
Route for vehicle 0:
If you set the --four True command line parameter, it uses a four node problem, in which the ideal solution is to choose one combo, one single unit:
python disjunction_fail.py --four True
The Objective Value is 46
Truck 0 travel time
combo: 0
single: 6
Truck 1 travel time
combo: 40
single: 0
Route for vehicle 0:
So that's great, the constraint is doing its job and the solver is picking the best vehicle. But turning on the disjuctions (for example to solve the --seven True case), the solver acts very strangely
With disjunctions low (5), all nodes are dropped:
python disjunction_fail.py -d True --singlepenalty 5
single node disjunction penalty is 5
added 5 disjunctions, one per node
The Objective Value is 25
Truck 0 travel time
combo: 0
single: 0
Truck 1 travel time
combo: 0
single: 0
With disjuction at 6, you get a weird case where one vehicle is used, so ignore that and bump to penalty of 7
python disjunction_fail.py -d True --singlepenalty 7
single node disjunction penalty is 7
added 5 disjunctions, one per node
The Objective Value is 33
Truck 0 travel time
combo: 0
single: 6
Truck 1 travel time
combo: 0
single: 6
Route for vehicle 0:
...
So that's correct. Using both single unit trucks for a cost of 6 each is cheaper than dropping those two nodes.
But when the disjunction penalty is bumped up so as to force the use of the combo trucks, things get weird.
python disjunction_fail.py -l True -d True --singlepenalty 22
single node disjunction penalty is 22
added 5 disjunctions, one per node
The Objective Value is 78
Truck 0 travel time
combo: 0
single: 6
Truck 1 travel time
combo: 0
single: 6
Route for vehicle 0:
So notice. Without per-node disjunctions on, the solver computes a solution using two combo trucks, for a total cost of 75. Now with disjunctions on, and with a penalty of 22, the solver chooses to drop 3 nodes and use just the two single-unit trucks for a total cost of 78---higher than 75.
Turning on logging, I see:
**With disjunctions**
WARNING: Logging before InitGoogleLogging() is written to STDERR
I0607 17:10:29.849189 340 routing.cc:2833] All Unperformed Solution (110, time = 1 ms, memory used = 42.73 MB)
I0607 17:10:29.849252 340 search.cc:252] Start search (memory used = 42.73 MB)
I0607 17:10:29.849391 340 search.cc:252] Root node processed (time = 0 ms, constraints = 125, memory used = 42.73 MB)
I0607
17:10:29.849684 340 search.cc:252] Solution #0 (118, time = 0 ms,
branches = 38, failures = 0, depth = 33, memory used = 42.77 MB)
I0607
17:10:29.849941 340 search.cc:252] Solution #1 (94, objective maximum
= 118, time = 0 ms, branches = 41, failures = 2, depth = 33,
Relocate<1>, neighbors = 1, filtered neighbors = 1, accepted
neighbors = 1, memory used = 42.77 MB)
I0607 17:10:29.850567 340
search.cc:252] Solution #2 (78, objective maximum = 118, time = 1 ms,
branches = 45, failures = 4, depth = 33, MakeActiveOperator, neighbors =
19, filtered neighbors = 2, accepted neighbors = 2, memory used = 42.77
MB)
I0607 17:10:29.857329 340 search.cc:252] Finished search tree
(time = 8 ms, branches = 423, failures = 327, neighbors = 180, filtered
neighbors = 96, accepted neigbors = 2, memory used = 42.77 MB)
I0607
17:10:29.857430 340 search.cc:252] End search (time = 8 ms, branches =
423, failures = 327, memory used = 42.77 MB, speed = 52875 branches/s)
The Objective Value is 78
**Without disjunctions**
WARNING: Logging before InitGoogleLogging() is written to STDERR
I0607 17:13:08.286882 341 search.cc:252] Start search (memory used = 42.73 MB)
I0607 17:13:08.287050 341 search.cc:252] Root node processed (time = 0 ms, constraints = 110, memory used = 42.73 MB)
I0607 17:13:08.287578 341 search.cc:252] Solution #0 (75, time = 0 ms, branches = 52, failures = 8, depth = 33, memory used = 42.73 MB)
I0607 17:13:08.317746 341 search.cc:252] Finished search tree (time = 30 ms, branches = 2599, failures = 1518, neighbors = 338, filtered neighbors = 133, accepted neigbors = 0, memory used = 42.73 MB)
I0607 17:13:08.317854 341 search.cc:252] End search (time = 30 ms, branches = 2599, failures = 1518, memory used = 42.73 MB, speed = 86633 branches/s)
The Objective Value is 75
Even stranger, if you bump the penalty a lot higher, the solver will eventually pick one combo unit, but never both:
python disjunction_fail.py -l True -d True --singlepenalty 30
single node disjunction penalty is 30
added 5 disjunctions, one per node
WARNING: Logging before InitGoogleLogging() is written to STDERR
I0607 17:15:57.426460 347 routing.cc:2833] All Unperformed Solution (150, time = 0 ms, memory used = 42.73 MB)
I0607 17:15:57.426518 347 search.cc:252] Start search (memory used = 42.73 MB)
I0607 17:15:57.426826 347 search.cc:252] Root node processed (time = 0 ms, constraints = 125, memory used = 42.73 MB)
I0607 17:15:57.427151 347 search.cc:252] Solution #0 (150, time = 0 ms, branches = 38, failures = 0, depth = 33, memory used = 42.77 MB)
I0607 17:15:57.427507 347 search.cc:252] Solution #1 (126, objective maximum = 150, time = 0 ms, branches = 41, failures = 2, depth = 33, Relocate<1>, neighbors = 1, filtered neighbors = 1, accepted neighbors = 1, memory used = 42.77 MB)
I0607 17:15:57.428037 347 search.cc:252] Solution #2 (102, objective maximum = 150, time = 1 ms, branches = 45, failures = 4, depth = 33, MakeActiveOperator, neighbors = 19, filtered neighbors = 2, accepted neighbors = 2, memory used = 42.77 MB)
I0607 17:15:57.434772 347 search.cc:252] Finished search tree (time = 8 ms, branches = 423, failures = 327, neighbors = 180, filtered neighbors = 96, accepted neigbors = 2, memory used = 42.77 MB)
I0607 17:15:57.434851 347 search.cc:252] End search (time = 8 ms, branches = 423, failures = 327, memory used = 42.77 MB, speed = 52875 branches/s)
The Objective Value is 102
Truck 0 travel time
combo: 0
single: 6
Truck 1 travel time
combo: 0
single: 6
But at a penalty of 31:
python disjunction_fail.py -l True -d True --singlepenalty 31
single node disjunction penalty is 31
added 5 disjunctions, one per node
WARNING: Logging before InitGoogleLogging() is written to STDERR
I0607 17:16:23.665720 348 routing.cc:2833] All Unperformed Solution (155, time = 0 ms, memory used = 42.73 MB)
I0607 17:16:23.665779 348 search.cc:252] Start search (memory used = 42.73 MB)
I0607 17:16:23.665977 348 search.cc:252] Root node processed (time = 0 ms, constraints = 125, memory used = 42.73 MB)
I0607 17:16:23.666378 348 search.cc:252] Solution #0 (154, time = 0 ms, branches = 38, failures = 0, depth = 33, memory used = 42.77 MB)
I0607 17:16:23.666610 348 search.cc:252] Solution #1 (130, objective maximum = 154, time = 0 ms, branches = 41, failures = 2, depth = 33, Relocate<1>, neighbors = 1, filtered neighbors = 1, accepted neighbors = 1, memory used = 42.77 MB)
I0607 17:16:23.667462 348 search.cc:252] Solution #2 (129, objective maximum = 154, time = 1 ms, branches = 45, failures = 5, depth = 33, MakeActiveOperator, neighbors = 18, filtered neighbors = 3, accepted neighbors = 2, memory used = 42.77 MB)
I0607 17:16:23.667804 348 search.cc:252] Solution #3 (103, objective maximum = 154, time = 1 ms, branches = 48, failures = 7, depth = 33, MakeActiveOperator, neighbors = 19, filtered neighbors = 4, accepted neighbors = 3, memory used = 42.77 MB)
I0607 17:16:23.668157 348 search.cc:252] Solution #4 (77, objective maximum = 154, time = 2 ms, branches = 53, failures = 9, depth = 33, MakeActiveOperator, neighbors = 20, filtered neighbors = 5, accepted neighbors = 4, memory used = 42.77 MB)
I0607 17:16:23.684505 348 search.cc:252] Finished search tree (time = 18 ms, branches = 871, failures = 644, neighbors = 300, filtered neighbors = 147, accepted neigbors = 4, memory used = 42.77 MB)
I0607 17:16:23.684626 348 search.cc:252] End search (time = 18 ms, branches = 871, failures = 644, memory used = 42.77 MB, speed = 48388 branches/s)
The Objective Value is 77
Truck 0 travel time
combo: 0
single: 6
Truck 1 travel time
combo: 40
single: 0
Boosting the penalty to any larger value fails to turn on the second combo truck.
python disjunction_fail.py -l True -d True --singlepenalty 1000000000000000000
single node disjunction penalty is 1000000000000000000
added 5 disjunctions, one per node
WARNING: Logging before InitGoogleLogging() is written to STDERR
I0607 17:17:21.113255 352 routing.cc:2833] All Unperformed Solution (5000000000000000000, time = 0 ms, memory used = 42.73 MB)
I0607 17:17:21.113317 352 search.cc:252] Start search (memory used = 42.73 MB)
I0607 17:17:21.113423 352 search.cc:252] Root node processed (time = 0 ms, constraints = 125, memory used = 42.73 MB)
I0607 17:17:21.113732 352 search.cc:252] Solution #0 (4000000000000000030, time = 0 ms, branches = 38, failures = 0, depth = 33, memory used = 42.77 MB)
I0607 17:17:21.114017 352 search.cc:252] Solution #1 (4000000000000000006, objective maximum = 4000000000000000030, time = 0 ms, branches = 41, failures = 2, depth = 33, Relocate<1>, neighbors = 1, filtered neighbors = 1, accepted neighbors = 1, memory used = 42.77 MB)
I0607 17:17:21.114799 352 search.cc:252] Solution #2 (3000000000000000036, objective maximum = 4000000000000000030, time = 1 ms, branches = 45, failures = 5, depth = 33, MakeActiveOperator, neighbors = 18, filtered neighbors = 3, accepted neighbors = 2, memory used = 42.77 MB)
I0607 17:17:21.115105 352 search.cc:252] Solution #3 (2000000000000000041, objective maximum = 4000000000000000030, time = 1 ms, branches = 48, failures = 7, depth = 33, MakeActiveOperator, neighbors = 19, filtered neighbors = 4, accepted neighbors = 3, memory used = 42.77 MB)
I0607 17:17:21.115414 352 search.cc:252] Solution #4 (1000000000000000046, objective maximum = 4000000000000000030, time = 2 ms, branches = 53, failures = 9, depth = 33, MakeActiveOperator, neighbors = 20, filtered neighbors = 5, accepted neighbors = 4, memory used = 42.77 MB)
I0607 17:17:21.131691 352 search.cc:252] Finished search tree (time = 18 ms, branches = 827, failures = 622, neighbors = 300, filtered neighbors = 147, accepted neigbors = 4, memory used = 42.77 MB)
I0607 17:17:21.131822 352 search.cc:252] End search (time = 18 ms, branches = 827, failures = 622, memory used = 42.77 MB, speed = 45944 branches/s)
The Objective Value is 1000000000000000046
Truck 0 travel time
combo: 0
single: 6
Truck 1 travel time
combo: 40
single: 0
Route for vehicle 0:
0 Load(0) Cost(0) -> 0 Load(0) Cost(0)
Distance of the route: 0m
Load of the route: 0
There's got to be a bug or a missed search branch somewhere perhaps? It seems that the binary case of using one vehicle or the other is pushing the search into a local minimum that it cannot get out of, no matter what. Or maybe this is just a case that OR Tools cannot handle. I will open an issue on github if the devs on this list think it is worth pursuing.
Regards,
James