Repository: graphhopper/graphhopper (java)

Issue:
Allow cross-querying weightings for hybrid mode
Currently we can prepare a profile for hybrid mode (calculate landmarks). The weighting we use with these landmarks does not necessarily have to be the same as the one we used for the preparation. We can use any weighting as long as all edge weights calculated with this weighting are equal or larger in weight than the ones used during the preparation. So it would be useful to be able to 'cross-query': Use some existing landmarks with a weighting other than the one that was used during preparation.

Maybe we can do it like this:

```yaml
profiles:
  - name: my_car
    vehicle: car
    weighting: some_weighting
  - name: my_adjusted_car
    vehicle: car
    weighting: other_weighting
lm_profiles:
   # this means we will calculate landmarks for the 'my_car' profile
  - name: my_car 
  # this means we do *not* calculate landmarks for the 'my_adjusted_car', but we can use still use 
  # the 'my_adjusted_car' profile in hybrid mode and it will use the landmarks from the 'my_car' profile
  - name: my_adjusted_car
    landmarks: my_car
```

The route calculation will only be correct if all edge-weights returned by `other_weighting` are equal or larger than those returned from `some_weighting`.

Pull request description:
Add config option `preparation_profile` to allow cross-querying in hybrid mode
Fixes #1980. @karussell can you check if this way this is also useful for #1776?

This is how it works
```yaml
profiles:
  - name: my_car
    vehicle: car
    weighting: some_weighting
  - name: my_adjusted_car
    vehicle: car
    weighting: other_weighting
lm_profiles:
   # this means we will calculate landmarks for the 'my_car' profile
  - name: my_car 
  # this means we do *not* calculate landmarks for the 'my_adjusted_car', but we can use still use 
  # the 'my_adjusted_car' profile in hybrid mode and it will use the landmarks from the 'my_car' profile
  - name: my_adjusted_car
    preparation_profile: my_car
```

Patch:
diff --git a/api/src/main/java/com/graphhopper/config/LMProfileConfig.java b/api/src/main/java/com/graphhopper/config/LMProfileConfig.java
index 7621a231e..0baa97a00 100644
--- a/api/src/main/java/com/graphhopper/config/LMProfileConfig.java
+++ b/api/src/main/java/com/graphhopper/config/LMProfileConfig.java
@@ -28,6 +28,7 @@ import static com.graphhopper.config.ProfileConfig.validateProfileName;
  */
 public class LMProfileConfig {
     private String profile = "";
+    private String preparationProfile = "this";
     private double maximumLMWeight = -1;
 
     private LMProfileConfig() {
@@ -47,17 +48,35 @@ public class LMProfileConfig {
         this.profile = profile;
     }
 
+    public boolean usesOtherPreparation() {
+        return !preparationProfile.equals("this");
+    }
+
+    public String getPreparationProfile() {
+        return preparationProfile;
+    }
+
+    public LMProfileConfig setPreparationProfile(String preparationProfile) {
+        validateProfileName(preparationProfile);
+        if (maximumLMWeight >= 0)
+            throw new IllegalArgumentException("Using non-default maximum_lm_weight and preparation_profile at the same time is not allowed");
+        this.preparationProfile = preparationProfile;
+        return this;
+    }
+
     public double getMaximumLMWeight() {
         return maximumLMWeight;
     }
 
     public LMProfileConfig setMaximumLMWeight(double maximumLMWeight) {
+        if (usesOtherPreparation())
+            throw new IllegalArgumentException("Using non-default maximum_lm_weight and preparation_profile at the same time is not allowed");
         this.maximumLMWeight = maximumLMWeight;
         return this;
     }
 
     @Override
     public String toString() {
-        return profile + "|maximum_lm_weight=" + maximumLMWeight;
+        return profile + "|preparation_profile=" + preparationProfile + "|maximum_lm_weight=" + maximumLMWeight;
     }
 }
diff --git a/config-example.yml b/config-example.yml
index 3dd523229..2831cf017 100644
--- a/config-example.yml
+++ b/config-example.yml
@@ -63,6 +63,12 @@ graphhopper:
   # Hybrid mode:
   # Similar to speed mode, the hybrid mode (Landmarks, LM) also speeds up routing by doing calculating auxiliary data
   # in advance. Its not as fast as speed mode, but more flexible.
+  #
+  # Advanced usage: It is possible to use the same preparation for multiple profiles which saves memory and preparation
+  # time. To do this use e.g. `preparation_profile: my_other_profile` where `my_other_profile` is the name of another
+  # profile for which an LM profile exists. Important: This only will give correct routing results if the weights
+  # calculated for the profile are equal or larger (for every edge) than those calculated for the profile that was used
+  # for the preparation (`my_other_profile`)
   profiles_lm: []
 
   ##### Elevation #####
diff --git a/core/src/main/java/com/graphhopper/GraphHopper.java b/core/src/main/java/com/graphhopper/GraphHopper.java
index 46e8f2764..4c69cd528 100644
--- a/core/src/main/java/com/graphhopper/GraphHopper.java
+++ b/core/src/main/java/com/graphhopper/GraphHopper.java
@@ -837,15 +837,26 @@ public class GraphHopper implements GraphHopperAPI {
                 throw new IllegalArgumentException("CH profile references unknown profile '" + chConfig.getProfile() + "'");
             }
         }
-        Set<String> lmProfileSet = new LinkedHashSet<>(lmPreparationHandler.getLMProfileConfigs().size());
+        Map<String, LMProfileConfig> lmProfileMap = new LinkedHashMap<>(lmPreparationHandler.getLMProfileConfigs().size());
         for (LMProfileConfig lmConfig : lmPreparationHandler.getLMProfileConfigs()) {
-            boolean added = lmProfileSet.add(lmConfig.getProfile());
-            if (!added) {
-                throw new IllegalArgumentException("Duplicate LM reference to profile '" + lmConfig.getProfile() + "'");
+            LMProfileConfig previous = lmProfileMap.put(lmConfig.getProfile(), lmConfig);
+            if (previous != null) {
+                throw new IllegalArgumentException("Multiple LM profiles are using the same profile '" + lmConfig.getProfile() + "'");
             }
             if (!profilesByName.containsKey(lmConfig.getProfile())) {
                 throw new IllegalArgumentException("LM profile references unknown profile '" + lmConfig.getProfile() + "'");
             }
+            if (lmConfig.usesOtherPreparation() && !profilesByName.containsKey(lmConfig.getPreparationProfile())) {
+                throw new IllegalArgumentException("LM profile references unknown preparation profile '" + lmConfig.getPreparationProfile() + "'");
+            }
+        }
+        for (LMProfileConfig lmConfig : lmPreparationHandler.getLMProfileConfigs()) {
+            if (lmConfig.usesOtherPreparation() && !lmProfileMap.containsKey(lmConfig.getPreparationProfile())) {
+                throw new IllegalArgumentException("Unknown LM preparation profile '" + lmConfig.getPreparationProfile() + "' in LM profile '" + lmConfig.getProfile() + "' cannot be used as preparation_profile");
+            }
+            if (lmConfig.usesOtherPreparation() && lmProfileMap.get(lmConfig.getPreparationProfile()).usesOtherPreparation()) {
+                throw new IllegalArgumentException("Cannot use '" + lmConfig.getPreparationProfile() + "' as preparation_profile for LM profile '" + lmConfig.getProfile() + "', because it uses another profile for preparation itself.");
+            }
         }
     }
 
@@ -861,7 +872,15 @@ public class GraphHopper implements GraphHopperAPI {
         if (chPreparationHandler.isEnabled() && !disableCH) {
             return chPreparationHandler.getAlgorithmFactory(profile);
         } else if (lmPreparationHandler.isEnabled() && !disableLM) {
-            return lmPreparationHandler.getAlgorithmFactory(profile);
+            for (LMProfileConfig lmp : lmPreparationHandler.getLMProfileConfigs()) {
+                if (lmp.getProfile().equals(profile)) {
+                    return lmp.usesOtherPreparation()
+                            // cross-querying
+                            ? lmPreparationHandler.getAlgorithmFactory(lmp.getPreparationProfile())
+                            : lmPreparationHandler.getAlgorithmFactory(lmp.getProfile());
+                }
+            }
+            throw new IllegalArgumentException("Cannot find LM preparation for the requested profile: '" + profile + "'");
         } else {
             return new RoutingAlgorithmFactorySimple();
         }
@@ -895,11 +914,13 @@ public class GraphHopper implements GraphHopperAPI {
             return;
 
         for (LMProfileConfig lmConfig : lmPreparationHandler.getLMProfileConfigs()) {
+            if (lmConfig.usesOtherPreparation())
+                continue;
             ProfileConfig profile = profilesByName.get(lmConfig.getProfile());
             // Note that we have to make sure the weighting used for LM preparation does not include turn costs, because
             // the LM preparation is running node-based and the landmark weights will be wrong if there are non-zero
             // turn costs, see discussion in #1960
-            // Running the preparation without turn costs can also be useful to allow e.g. changing the u_turn_costs per
+            // Running the preparation without turn costs is also useful to allow e.g. changing the u_turn_costs per
             // request (we have to use the minimum weight settings (= no turn costs) for the preparation)
             Weighting weighting = createWeighting(profile, new PMap(), true);
             lmPreparationHandler.addLMProfile(new LMProfile(profile.getName(), weighting));
@@ -1015,14 +1036,12 @@ public class GraphHopper implements GraphHopperAPI {
         try {
             if (!request.getVehicle().isEmpty())
                 throw new IllegalArgumentException("GHRequest may no longer contain a vehicle, use the profile parameter instead, see #1958");
-            // todo: #1980, weighting should be also forbidden
-//            if (!request.getWeighting().isEmpty())
-//                throw new IllegalArgumentException("GHRequest may no longer contain a weighting, use the profile parameter instead, see #1958");
+            if (!request.getWeighting().isEmpty())
+                throw new IllegalArgumentException("GHRequest may no longer contain a weighting, use the profile parameter instead, see #1958");
             if (request.getHints().has(Routing.TURN_COSTS))
                 throw new IllegalArgumentException("GHRequest may no longer contain the turn_costs=true/false parameter, use the profile parameter instead, see #1958");
-            // todo: #1980, edge based should also be forbidden
-//            if (request.getHints().has(Routing.EDGE_BASED))
-//                throw new IllegalArgumentException("GHRequest may no longer contain the edge_based=true/false parameter, use the profile parameter instead, see #1958");
+            if (request.getHints().has(Routing.EDGE_BASED))
+                throw new IllegalArgumentException("GHRequest may no longer contain the edge_based=true/false parameter, use the profile parameter instead, see #1958");
 
             // todo later: do not allow things like short_fastest.distance_factor or u_turn_costs unless CH is disabled and only under certain conditions for LM
 
diff --git a/core/src/main/java/com/graphhopper/routing/lm/LMPreparationHandler.java b/core/src/main/java/com/graphhopper/routing/lm/LMPreparationHandler.java
index cbf2ad4fe..7e8b615c6 100644
--- a/core/src/main/java/com/graphhopper/routing/lm/LMPreparationHandler.java
+++ b/core/src/main/java/com/graphhopper/routing/lm/LMPreparationHandler.java
@@ -130,6 +130,8 @@ public class LMPreparationHandler {
         this.lmProfileConfigs.clear();
         this.maximumWeights.clear();
         for (LMProfileConfig config : lmProfileConfigs) {
+            if (config.usesOtherPreparation())
+                continue;
             maximumWeights.put(config.getProfile(), config.getMaximumLMWeight());
         }
         this.lmProfileConfigs.addAll(lmProfileConfigs);
diff --git a/tools/src/main/java/com/graphhopper/tools/Measurement.java b/tools/src/main/java/com/graphhopper/tools/Measurement.java
index 108789be0..3219b50e0 100644
--- a/tools/src/main/java/com/graphhopper/tools/Measurement.java
+++ b/tools/src/main/java/com/graphhopper/tools/Measurement.java
@@ -304,11 +304,10 @@ public class Measurement {
         ghConfig.setCHProfiles(chProfiles);
         List<LMProfileConfig> lmProfiles = new ArrayList<>();
         if (useLM) {
-            // as currently we do not allow cross-querying LM with turn costs=true/false we have to add both
-            // profiles and this currently leads to two identical LM preparations
             lmProfiles.add(new LMProfileConfig("profile_no_tc"));
             if (turnCosts)
-                lmProfiles.add(new LMProfileConfig("profile_tc"));
+                // no need for a second LM preparation, we can do cross queries here
+                lmProfiles.add(new LMProfileConfig("profile_tc").setPreparationProfile("profile_no_tc"));
         }
         ghConfig.setLMProfiles(lmProfiles);
         return ghConfig;
diff --git a/web-bundle/src/main/java/com/graphhopper/resources/RouteResource.java b/web-bundle/src/main/java/com/graphhopper/resources/RouteResource.java
index d2b774062..8b97a2379 100644
--- a/web-bundle/src/main/java/com/graphhopper/resources/RouteResource.java
+++ b/web-bundle/src/main/java/com/graphhopper/resources/RouteResource.java
@@ -245,10 +245,9 @@ public class RouteResource {
 
     private void removeLegacyParameters(GHRequest request) {
         // these parameters should only be used to resolve the profile, but should not be passed to GraphHopper
-        // todo: #1980, these parameters should be removed as well?!
-//        request.getHints().setWeighting("");
+        request.getHints().setWeighting("");
         request.getHints().setVehicle("");
-//        request.getHints().remove("edge_based");
+        request.getHints().remove("edge_based");
         request.getHints().remove("turn_costs");
     }
 


Test patch:
diff --git a/core/src/test/java/com/graphhopper/GraphHopperProfileConfigTest.java b/core/src/test/java/com/graphhopper/GraphHopperProfileConfigTest.java
index 610e45d88..faa781d10 100644
--- a/core/src/test/java/com/graphhopper/GraphHopperProfileConfigTest.java
+++ b/core/src/test/java/com/graphhopper/GraphHopperProfileConfigTest.java
@@ -160,9 +160,63 @@ public class GraphHopperProfileConfigTest {
             public void run() {
                 hopper.load(GH_LOCATION);
             }
-        }, "Duplicate LM reference to profile 'profile'");
+        }, "Multiple LM profiles are using the same profile 'profile'");
     }
 
+    @Test
+    public void unknownLMPreparationProfile_error() {
+        final GraphHopper hopper = createHopper(EncodingManager.create("car"));
+        hopper.setProfiles(new ProfileConfig("profile").setVehicle("car"));
+        hopper.getLMPreparationHandler().setLMProfileConfigs(
+                new LMProfileConfig("profile").setPreparationProfile("xyz")
+        );
+        assertIllegalArgument(new Runnable() {
+            @Override
+            public void run() {
+                hopper.load(GH_LOCATION);
+            }
+        }, "LM profile references unknown preparation profile 'xyz'");
+    }
+
+    @Test
+    public void lmPreparationProfileChain_error() {
+        final GraphHopper hopper = createHopper(EncodingManager.create("car,bike,foot"));
+        hopper.setProfiles(
+                new ProfileConfig("profile1").setVehicle("car"),
+                new ProfileConfig("profile2").setVehicle("bike"),
+                new ProfileConfig("profile3").setVehicle("foot")
+        );
+        hopper.getLMPreparationHandler().setLMProfileConfigs(
+                new LMProfileConfig("profile1"),
+                new LMProfileConfig("profile2").setPreparationProfile("profile1"),
+                new LMProfileConfig("profile3").setPreparationProfile("profile2")
+        );
+        assertIllegalArgument(new Runnable() {
+            @Override
+            public void run() {
+                hopper.load(GH_LOCATION);
+            }
+        }, "Cannot use 'profile2' as preparation_profile for LM profile 'profile3', because it uses another profile for preparation itself.");
+    }
+
+    @Test
+    public void noLMProfileForPreparationProfile_error() {
+        final GraphHopper hopper = createHopper(EncodingManager.create("car,bike,foot"));
+        hopper.setProfiles(
+                new ProfileConfig("profile1").setVehicle("car"),
+                new ProfileConfig("profile2").setVehicle("bike"),
+                new ProfileConfig("profile3").setVehicle("foot")
+        );
+        hopper.getLMPreparationHandler().setLMProfileConfigs(
+                new LMProfileConfig("profile1").setPreparationProfile("profile2")
+        );
+        assertIllegalArgument(new Runnable() {
+            @Override
+            public void run() {
+                hopper.load(GH_LOCATION);
+            }
+        }, "Unknown LM preparation profile 'profile2' in LM profile 'profile1' cannot be used as preparation_profile");
+    }
 
     private GraphHopper createHopper(EncodingManager encodingManager) {
         final GraphHopper hopper = new GraphHopper();
diff --git a/reader-osm/src/test/java/com/graphhopper/GraphHopperIT.java b/reader-osm/src/test/java/com/graphhopper/GraphHopperIT.java
index 7a43ece3f..74abee42e 100644
--- a/reader-osm/src/test/java/com/graphhopper/GraphHopperIT.java
+++ b/reader-osm/src/test/java/com/graphhopper/GraphHopperIT.java
@@ -1356,6 +1356,54 @@ public class GraphHopperIT {
         // note: combining hybrid & speed mode is currently not possible and should be avoided: #1082
     }
 
+    @Test
+    public void testCrossQuery() {
+        final String profile1 = "p1";
+        final String profile2 = "p2";
+        final String profile3 = "p3";
+        final String vehicle = "car";
+        GraphHopper hopper = createGraphHopper(vehicle).
+                setOSMFile(MONACO).
+                setProfiles(
+                        new ProfileConfig(profile1).setVehicle("car").setWeighting("short_fastest").putHint("short_fastest.distance_factor", 0.07),
+                        new ProfileConfig(profile2).setVehicle("car").setWeighting("short_fastest").putHint("short_fastest.distance_factor", 0.10),
+                        new ProfileConfig(profile3).setVehicle("car").setWeighting("short_fastest").putHint("short_fastest.distance_factor", 0.15)
+                ).
+                setStoreOnFlush(true);
+
+        hopper.getLMPreparationHandler().
+                setLMProfileConfigs(
+                        // we have an LM setup for each profile, but only one LM preparation that we use for all of them!
+                        // this works because profile1's weight is the lowest for every edge
+                        new LMProfileConfig(profile1),
+                        new LMProfileConfig(profile2).setPreparationProfile(profile1),
+                        new LMProfileConfig(profile3).setPreparationProfile(profile1)
+                ).
+                setDisablingAllowed(true);
+        hopper.importOrLoad();
+
+        // flex
+        testCrossQueryAssert(profile1, hopper, 528.3, 152, true);
+        testCrossQueryAssert(profile2, hopper, 636.0, 150, true);
+        testCrossQueryAssert(profile3, hopper, 815.4, 146, true);
+
+        // LM (should be the same as flex, but with less visited nodes!)
+        testCrossQueryAssert(profile1, hopper, 528.3, 106, false);
+        testCrossQueryAssert(profile2, hopper, 636.0, 78, false);
+        // this is actually interesting: the number of visited nodes *increases* once again (while it strictly decreases
+        // with rising distance factor for flex): cross-querying 'works', but performs *worse*, because the landmarks
+        // were not customized for the weighting in use. Creating a separate LM preparation for profile3 yields 74
+        // instead of 124 visited nodes (not shown here)
+        testCrossQueryAssert(profile3, hopper, 815.4, 124, false);
+    }
+
+    private void testCrossQueryAssert(String profile, GraphHopper hopper, double expectedWeight, int expectedVisitedNodes, boolean disableLM) {
+        GHResponse response = hopper.route(new GHRequest(43.727687, 7.418737, 43.74958, 7.436566).setProfile(profile).putHint("lm.disable", disableLM));
+        assertEquals(expectedWeight, response.getBest().getRouteWeight(), 0.1);
+        int visitedNodes = response.getHints().getInt("visited_nodes.sum", 0);
+        assertEquals(expectedVisitedNodes, visitedNodes);
+    }
+
     @Test
     public void testCreateWeightingHintsMerging() {
         final String profile = "profile";
diff --git a/web/src/test/java/com/graphhopper/http/resources/RouteResourceTurnCostsTest.java b/web/src/test/java/com/graphhopper/http/resources/RouteResourceTurnCostsTest.java
index 71f12d888..2593eb7d5 100644
--- a/web/src/test/java/com/graphhopper/http/resources/RouteResourceTurnCostsTest.java
+++ b/web/src/test/java/com/graphhopper/http/resources/RouteResourceTurnCostsTest.java
@@ -68,8 +68,9 @@ public class RouteResourceTurnCostsTest {
                         new CHProfileConfig("my_car_no_turn_costs")
                 ))
                 .setLMProfiles(Arrays.asList(
-                        new LMProfileConfig("my_car_turn_costs"),
-                        new LMProfileConfig("my_car_no_turn_costs")
+                        new LMProfileConfig("my_car_no_turn_costs"),
+                        // no need for a second LM preparation: we can just cross query here
+                        new LMProfileConfig("my_car_turn_costs").setPreparationProfile("my_car_no_turn_costs")
                 ));
         return config;
     }