Repository: graphhopper/graphhopper (language: java)

Issue:
Using turn costs, not just turn restrictions
OSM data only contains turn restrictions like 'right only' etc., but graphhopper routing can also handle turn *costs*, for example to prefer routes with less turns, less left-turns or similar. However, there are some open questions how turn costs should be handled, especially for weightings other than `FastestWeighting`. See discussion in: #1586 and the same problem with `headingPenalty` for, e.g., `ShortestWeighting`: https://github.com/graphhopper/graphhopper/issues/485


Discussion / hints:
> But which use case do you have in mind where "time != weight"?

I could think of a weighting function that e.g. includes toll-road fees, gas consumption, distance and time. To combine these factors one has to use conversion factors, just like we do for ShortestFastestWeighting. Putting a penalty on turns when not using the fastest (purely time-based) weighting can also be achieved using such a conversion factor, so I agree it should be no problem to think of the turn costs as turn *times* and for some special weightings one has to use a conversion factor.

> Hmmh, indeed. This is ugly and one reason more (for me) to remove ShortestWeighting :D

Ok, but I am not fully convinced the `ShortestWeighting` is the real problem here. The problem rather seems that the weightings cannot control how turn costs are applied, because this is done by the `TurnWeighting` without 'asking' its underlying weighting.
The ShortestWeighting is at least useful for testing sometimes :)
>  As the defaultUTurnCost is in seconds - is this correct?

Good question, it looks like the default u-turn costs are not included in `TurnWeighting#calcMillis` at all (but for `calcWeight` they are added as seconds (at least for the fastest weighting...). -> added this to #1585. (Update: fixed via #1635)
I think it would be a good idea to let *all* weightings return the weight in units of time (same for edge weights, turn costs etc., make everything based on time), and probably also use the same units everywhere as well (for example seconds) (so we can clearly communicate which range of values is supported). Of course not all weightings *are* based on time, but then they can just use some (weighting specific) conversion factors. Like we already do for shortest-fastest-weighting where there have to be such factors. Even for a pure distance-based weighting (shortest-weighting) its the same, just divide the distance by a constant 'speed'.

I think this makes it a lot easier to reason about 'weights' than the current approach where weights do not have a specified unit. Also I think it saves us a lot of confusion about turn-costs, turn-times and turn-time-costs ;)

Before this I think we should get rid of `TurnWeighting`, see ~~#1520~~ #1820

Fix patch:
diff --git a/config-example.yml b/config-example.yml
index a7c0e7a0141..64468f20520 100644
--- a/config-example.yml
+++ b/config-example.yml
@@ -33,6 +33,7 @@ graphhopper:
 #     turn_costs:
 #       vehicle_types: [motorcar, motor_vehicle]
 #       u_turn_costs: 60
+#     for more advanced turn costs, see #2957 or bike_tc.yml
      custom_model_files: [car.json]
 
 #   You can use the following in-built profiles. After you start GraphHopper it will print which encoded values you'll have to add to graph.encoded_values in this config file.
@@ -67,7 +68,7 @@ graphhopper:
   # It's possible to speed up routing by doing a special graph preparation (Contraction Hierarchies, CH). This requires
   # more RAM/disk space for holding the prepared graph but also means less memory usage per request. Using the following
   # list you can define for which of the above routing profiles such preparation shall be performed. Note that to support
-  # profiles with `turn_costs: true` a more elaborate preparation is required (longer preparation time and more memory
+  # profiles with `turn_costs` a more elaborate preparation is required (longer preparation time and more memory
   # usage) and the routing will also be slower than with `turn_costs: false`.
   profiles_ch:
     - profile: car
diff --git a/core/src/main/java/com/graphhopper/routing/DefaultWeightingFactory.java b/core/src/main/java/com/graphhopper/routing/DefaultWeightingFactory.java
index a1b6b5c5ac4..531b84b6d7d 100644
--- a/core/src/main/java/com/graphhopper/routing/DefaultWeightingFactory.java
+++ b/core/src/main/java/com/graphhopper/routing/DefaultWeightingFactory.java
@@ -20,6 +20,8 @@
 
 import com.graphhopper.config.Profile;
 import com.graphhopper.routing.ev.BooleanEncodedValue;
+import com.graphhopper.routing.ev.DecimalEncodedValue;
+import com.graphhopper.routing.ev.Orientation;
 import com.graphhopper.routing.ev.TurnRestriction;
 import com.graphhopper.routing.util.EncodingManager;
 import com.graphhopper.routing.weighting.DefaultTurnCostProvider;
@@ -31,6 +33,7 @@
 import com.graphhopper.util.CustomModel;
 import com.graphhopper.util.PMap;
 import com.graphhopper.util.Parameters;
+import com.graphhopper.util.TurnCostsConfig;
 
 import static com.graphhopper.routing.weighting.TurnCostProvider.NO_TURN_COST_PROVIDER;
 import static com.graphhopper.util.Helper.toLowerCase;
@@ -59,8 +62,12 @@ public Weighting createWeighting(Profile profile, PMap requestHints, boolean dis
             BooleanEncodedValue turnRestrictionEnc = encodingManager.getTurnBooleanEncodedValue(TurnRestriction.key(profile.getName()));
             if (turnRestrictionEnc == null)
                 throw new IllegalArgumentException("Cannot find turn restriction encoded value for " + profile.getName());
+            DecimalEncodedValue oEnc = encodingManager.hasEncodedValue(Orientation.KEY) ? encodingManager.getDecimalEncodedValue(Orientation.KEY) : null;
+            if (profile.getTurnCostsConfig().hasLeftRightStraightCosts() && oEnc == null)
+                throw new IllegalArgumentException("Using left_costs,left_sharp_costs,right_costs,right_sharp_costs or straight_costs for turn_costs requires 'orientation' in graph.encoded_values");
             int uTurnCosts = hints.getInt(Parameters.Routing.U_TURN_COSTS, profile.getTurnCostsConfig().getUTurnCosts());
-            turnCostProvider = new DefaultTurnCostProvider(turnRestrictionEnc, graph.getTurnCostStorage(), uTurnCosts);
+            TurnCostsConfig tcConfig = new TurnCostsConfig(profile.getTurnCostsConfig()).setUTurnCosts(uTurnCosts);
+            turnCostProvider = new DefaultTurnCostProvider(turnRestrictionEnc, oEnc, graph, tcConfig);
         } else {
             turnCostProvider = NO_TURN_COST_PROVIDER;
         }
diff --git a/core/src/main/java/com/graphhopper/routing/ev/DefaultImportRegistry.java b/core/src/main/java/com/graphhopper/routing/ev/DefaultImportRegistry.java
index 0ff15f83a61..efc94d40285 100644
--- a/core/src/main/java/com/graphhopper/routing/ev/DefaultImportRegistry.java
+++ b/core/src/main/java/com/graphhopper/routing/ev/DefaultImportRegistry.java
@@ -98,6 +98,11 @@ else if (MaxLength.KEY.equals(name))
                     (lookup, props) -> new OSMMaxLengthParser(
                             lookup.getDecimalEncodedValue(MaxLength.KEY))
             );
+        else if (Orientation.KEY.equals(name))
+            return ImportUnit.create(name, props -> Orientation.create(),
+                    (lookup, props) -> new OrientationCalculator(
+                            lookup.getDecimalEncodedValue(Orientation.KEY))
+            );
         else if (Surface.KEY.equals(name))
             return ImportUnit.create(name, props -> Surface.create(),
                     (lookup, props) -> new OSMSurfaceParser(
diff --git a/core/src/main/java/com/graphhopper/routing/ev/Orientation.java b/core/src/main/java/com/graphhopper/routing/ev/Orientation.java
new file mode 100644
index 00000000000..85863dd7df8
--- /dev/null
+++ b/core/src/main/java/com/graphhopper/routing/ev/Orientation.java
@@ -0,0 +1,28 @@
+/*
+ *  Licensed to GraphHopper GmbH under one or more contributor
+ *  license agreements. See the NOTICE file distributed with this work for
+ *  additional information regarding copyright ownership.
+ *
+ *  GraphHopper GmbH licenses this file to you under the Apache License,
+ *  Version 2.0 (the "License"); you may not use this file except in
+ *  compliance with the License. You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package com.graphhopper.routing.ev;
+
+public class Orientation {
+    public static final String KEY = "orientation";
+
+    // Due to pillar nodes we need 2 values: the orientation at the adjacent node and the reverse
+    // value for orientation at the base node. Store in degrees.
+    public static DecimalEncodedValue create() {
+        return new DecimalEncodedValueImpl(KEY, 5, 0, 360 / 30.0, false, true, false);
+    }
+}
diff --git a/core/src/main/java/com/graphhopper/routing/util/parsers/OrientationCalculator.java b/core/src/main/java/com/graphhopper/routing/util/parsers/OrientationCalculator.java
new file mode 100644
index 00000000000..945919a6fb5
--- /dev/null
+++ b/core/src/main/java/com/graphhopper/routing/util/parsers/OrientationCalculator.java
@@ -0,0 +1,52 @@
+/*
+ *  Licensed to GraphHopper GmbH under one or more contributor
+ *  license agreements. See the NOTICE file distributed with this work for
+ *  additional information regarding copyright ownership.
+ *
+ *  GraphHopper GmbH licenses this file to you under the Apache License,
+ *  Version 2.0 (the "License"); you may not use this file except in
+ *  compliance with the License. You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package com.graphhopper.routing.util.parsers;
+
+import com.graphhopper.reader.ReaderWay;
+import com.graphhopper.routing.ev.DecimalEncodedValue;
+import com.graphhopper.routing.ev.EdgeIntAccess;
+import com.graphhopper.storage.IntsRef;
+import com.graphhopper.util.PointList;
+
+import static com.graphhopper.util.AngleCalc.ANGLE_CALC;
+
+public class OrientationCalculator implements TagParser {
+
+    private final DecimalEncodedValue orientationEnc;
+
+    public OrientationCalculator(DecimalEncodedValue orientationEnc) {
+        this.orientationEnc = orientationEnc;
+    }
+
+    @Override
+    public void handleWayTags(int edgeId, EdgeIntAccess edgeIntAccess, ReaderWay way, IntsRef relationFlags) {
+        PointList pointList = way.getTag("point_list", null);
+        if (pointList != null) {
+            // store orientation in degrees and use the end of the edge
+            double azimuth = ANGLE_CALC.calcAzimuth(pointList.getLat(pointList.size() - 2), pointList.getLon(pointList.size() - 2),
+                    pointList.getLat(pointList.size() - 1), pointList.getLon(pointList.size() - 1));
+            orientationEnc.setDecimal(false, edgeId, edgeIntAccess, azimuth);
+
+            // same for the opposite direction
+            double revAzimuth = ANGLE_CALC.calcAzimuth(pointList.getLat(1), pointList.getLon(1),
+                    pointList.getLat(0), pointList.getLon(0));
+            orientationEnc.setDecimal(true, edgeId, edgeIntAccess, revAzimuth);
+        }
+    }
+}
+
diff --git a/core/src/main/java/com/graphhopper/routing/weighting/DefaultTurnCostProvider.java b/core/src/main/java/com/graphhopper/routing/weighting/DefaultTurnCostProvider.java
index 4823e08ae4d..1be39c134a6 100644
--- a/core/src/main/java/com/graphhopper/routing/weighting/DefaultTurnCostProvider.java
+++ b/core/src/main/java/com/graphhopper/routing/weighting/DefaultTurnCostProvider.java
@@ -18,10 +18,14 @@
 
 package com.graphhopper.routing.weighting;
 
-import com.graphhopper.util.TurnCostsConfig;
 import com.graphhopper.routing.ev.BooleanEncodedValue;
+import com.graphhopper.routing.ev.DecimalEncodedValue;
+import com.graphhopper.routing.ev.EdgeIntAccess;
+import com.graphhopper.storage.BaseGraph;
+import com.graphhopper.storage.Graph;
 import com.graphhopper.storage.TurnCostStorage;
 import com.graphhopper.util.EdgeIterator;
+import com.graphhopper.util.TurnCostsConfig;
 
 import static com.graphhopper.util.TurnCostsConfig.INFINITE_U_TURN_COSTS;
 
@@ -31,46 +35,92 @@ public class DefaultTurnCostProvider implements TurnCostProvider {
     private final int uTurnCostsInt;
     private final double uTurnCosts;
 
-    public DefaultTurnCostProvider(BooleanEncodedValue turnRestrictionEnc, TurnCostStorage turnCostStorage) {
-        this(turnRestrictionEnc, turnCostStorage, TurnCostsConfig.INFINITE_U_TURN_COSTS);
-    }
+    private final double minAngle;
+    private final double minSharpAngle;
+    private final double minUTurnAngle;
 
-    /**
-     * @param uTurnCosts the costs of a u-turn in seconds, for {@link TurnCostsConfig#INFINITE_U_TURN_COSTS} the u-turn costs
-     *                   will be infinite
-     */
-    public DefaultTurnCostProvider(BooleanEncodedValue turnRestrictionEnc, TurnCostStorage turnCostStorage, int uTurnCosts) {
-        if (uTurnCosts < 0 && uTurnCosts != INFINITE_U_TURN_COSTS) {
+    private final double leftCosts;
+    private final double leftSharpCosts;
+    private final double straightCosts;
+    private final double rightCosts;
+    private final double rightSharpCosts;
+    private final BaseGraph graph;
+    private final EdgeIntAccess edgeIntAccess;
+    private final DecimalEncodedValue orientationEnc;
+
+    public DefaultTurnCostProvider(BooleanEncodedValue turnRestrictionEnc, DecimalEncodedValue orientationEnc,
+                                   Graph graph, TurnCostsConfig tcConfig) {
+        this.uTurnCostsInt = tcConfig.getUTurnCosts();
+        if (uTurnCostsInt < 0 && uTurnCostsInt != INFINITE_U_TURN_COSTS) {
             throw new IllegalArgumentException("u-turn costs must be positive, or equal to " + INFINITE_U_TURN_COSTS + " (=infinite costs)");
         }
-        this.uTurnCostsInt = uTurnCosts;
-        this.uTurnCosts = uTurnCosts < 0 ? Double.POSITIVE_INFINITY : uTurnCosts;
-        if (turnCostStorage == null) {
+        this.uTurnCosts = uTurnCostsInt < 0 ? Double.POSITIVE_INFINITY : uTurnCostsInt;
+        if (graph.getTurnCostStorage() == null) {
             throw new IllegalArgumentException("No storage set to calculate turn weight");
         }
         // if null the TurnCostProvider can be still useful for edge-based routing
         this.turnRestrictionEnc = turnRestrictionEnc;
-        this.turnCostStorage = turnCostStorage;
-    }
+        this.turnCostStorage = graph.getTurnCostStorage();
 
-    public BooleanEncodedValue getTurnRestrictionEnc() {
-        return turnRestrictionEnc;
+        this.orientationEnc = orientationEnc;
+        if (tcConfig.getMinUTurnAngle() > 180)
+            throw new IllegalArgumentException("Illegal min_u_turn_angle = " + tcConfig.getMinUTurnAngle());
+        if (tcConfig.getMinSharpAngle() > tcConfig.getMinUTurnAngle())
+            throw new IllegalArgumentException("Illegal min_sharp_angle = " + tcConfig.getMinSharpAngle());
+        if (tcConfig.getMinAngle() > tcConfig.getMinSharpAngle() || tcConfig.getMinAngle() < 0)
+            throw new IllegalArgumentException("Illegal min_angle = " + tcConfig.getMinAngle());
+        if (tcConfig.getLeftCosts() > tcConfig.getLeftSharpCosts())
+            throw new IllegalArgumentException("The costs for 'left_costs' (" + tcConfig.getLeftCosts()
+                    + ") must be lower than for 'left_sharp_costs' (" + tcConfig.getLeftSharpCosts() + ")");
+        if (tcConfig.getRightCosts() > tcConfig.getRightSharpCosts())
+            throw new IllegalArgumentException("The costs for 'right_costs' (" + tcConfig.getRightCosts()
+                    + ") must be lower than for 'right_sharp_costs' (" + tcConfig.getRightSharpCosts() + ")");
+
+        this.minAngle = tcConfig.getMinAngle();
+        this.minSharpAngle = tcConfig.getMinSharpAngle();
+        this.minUTurnAngle = tcConfig.getMinUTurnAngle();
+
+        this.leftCosts = tcConfig.getLeftCosts();
+        this.leftSharpCosts = tcConfig.getLeftSharpCosts();
+        this.straightCosts = tcConfig.getStraightCosts();
+        this.rightCosts = tcConfig.getRightCosts();
+        this.rightSharpCosts = tcConfig.getRightSharpCosts();
+
+        this.graph = graph.getBaseGraph();
+        this.edgeIntAccess = graph.getBaseGraph().getEdgeAccess();
     }
 
     @Override
-    public double calcTurnWeight(int edgeFrom, int nodeVia, int edgeTo) {
-        if (!EdgeIterator.Edge.isValid(edgeFrom) || !EdgeIterator.Edge.isValid(edgeTo)) {
+    public double calcTurnWeight(int inEdge, int viaNode, int outEdge) {
+        if (!EdgeIterator.Edge.isValid(inEdge) || !EdgeIterator.Edge.isValid(outEdge)) {
             return 0;
         }
-        double tCost = 0;
-        if (edgeFrom == edgeTo) {
+
+        if (inEdge == outEdge) {
             // note that the u-turn costs overwrite any turn costs set in TurnCostStorage
-            tCost = uTurnCosts;
+            return uTurnCosts;
         } else {
-            if (turnRestrictionEnc != null)
-                tCost = turnCostStorage.get(turnRestrictionEnc, edgeFrom, nodeVia, edgeTo) ? Double.POSITIVE_INFINITY : 0;
+            if (turnRestrictionEnc != null && turnCostStorage.get(turnRestrictionEnc, inEdge, viaNode, outEdge))
+                return Double.POSITIVE_INFINITY;
         }
-        return tCost;
+
+        if (orientationEnc != null) {
+            double changeAngle = calcChangeAngle(inEdge, viaNode, outEdge);
+            if (changeAngle > -minAngle && changeAngle < minAngle)
+                return straightCosts;
+            else if (changeAngle >= minAngle && changeAngle < minSharpAngle)
+                return rightCosts;
+            else if (changeAngle >= minSharpAngle && changeAngle <= minUTurnAngle)
+                return rightSharpCosts;
+            else if (changeAngle <= -minAngle && changeAngle > -minSharpAngle)
+                return leftCosts;
+            else if (changeAngle <= -minSharpAngle && changeAngle >= -minUTurnAngle)
+                return leftSharpCosts;
+
+            // Too sharp turn is like an u-turn.
+            return uTurnCosts;
+        }
+        return 0;
     }
 
     @Override
@@ -87,4 +137,26 @@ public long calcTurnMillis(int inEdge, int viaNode, int outEdge) {
     public String toString() {
         return "default_tcp_" + uTurnCostsInt;
     }
+
+    double calcChangeAngle(int inEdge, int viaNode, int outEdge) {
+        // this is slightly faster than calling getEdgeIteratorState as it avoids creating a new
+        // object and accesses only one node but is slightly less safe as it cannot check that at
+        // least one node must be identical (the case where getEdgeIteratorState returns null)
+        boolean inEdgeReverse = !graph.isAdjNode(inEdge, viaNode);
+        double prevAzimuth = orientationEnc.getDecimal(inEdgeReverse, inEdge, edgeIntAccess);
+
+        boolean outEdgeReverse = !graph.isAdjNode(outEdge, viaNode);
+        double azimuth = orientationEnc.getDecimal(outEdgeReverse, outEdge, edgeIntAccess);
+
+        // bring parallel to prevOrientation
+        if (azimuth >= 180) azimuth -= 180;
+        else azimuth += 180;
+
+        double changeAngle = azimuth - prevAzimuth;
+
+        // keep in [-180, 180]
+        if (changeAngle > 180) changeAngle -= 360;
+        else if (changeAngle < -180) changeAngle += 360;
+        return changeAngle;
+    }
 }
diff --git a/core/src/main/java/com/graphhopper/storage/BaseGraph.java b/core/src/main/java/com/graphhopper/storage/BaseGraph.java
index e915fa87320..3abc4c19528 100644
--- a/core/src/main/java/com/graphhopper/storage/BaseGraph.java
+++ b/core/src/main/java/com/graphhopper/storage/BaseGraph.java
@@ -426,6 +426,15 @@ public boolean isAdjacentToNode(int edge, int node) {
         return isAdjacentToNode(node, edgePointer);
     }
 
+    /**
+     * @return true if the specified node is the adjacent node of the specified edge
+     * (relative to the direction in which the edge is stored).
+     */
+    public boolean isAdjNode(int edge, int node) {
+        long edgePointer = store.toEdgePointer(edge);
+        return node == store.getNodeB(edgePointer);
+    }
+
     private void setWayGeometry_(PointList pillarNodes, long edgePointer, boolean reverse) {
         if (pillarNodes != null && !pillarNodes.isEmpty()) {
             if (pillarNodes.getDimension() != nodeAccess.getDimension())
diff --git a/core/src/main/java/com/graphhopper/util/AngleCalc.java b/core/src/main/java/com/graphhopper/util/AngleCalc.java
index 4a0ab5939fe..63b9a743df2 100644
--- a/core/src/main/java/com/graphhopper/util/AngleCalc.java
+++ b/core/src/main/java/com/graphhopper/util/AngleCalc.java
@@ -59,10 +59,9 @@ public double calcOrientation(double lat1, double lon1, double lat2, double lon2
 
     /**
      * Return orientation of line relative to east.
-     * <p>
      *
      * @param exact If false the atan gets calculated faster, but it might contain small errors
-     * @return Orientation in interval -pi to +pi where 0 is east
+     * @return Orientation in interval -pi to +pi where 0 is east and the "bottom" arc is negative
      */
     public double calcOrientation(double lat1, double lon1, double lat2, double lon2, boolean exact) {
         double shrinkFactor = cos(toRadians((lat1 + lat2) / 2));
diff --git a/core/src/main/resources/com/graphhopper/custom_models/bike_tc.json b/core/src/main/resources/com/graphhopper/custom_models/bike_tc.json
new file mode 100644
index 00000000000..6f1cf336c2f
--- /dev/null
+++ b/core/src/main/resources/com/graphhopper/custom_models/bike_tc.json
@@ -0,0 +1,29 @@
+// Configures bike with turn costs (3sec for left and right turns) which reduces zig-zag routes.
+// Note, it is not recommended to increase these costs heavily as otherwise larger, bike-unfriendly
+// roads will be preferred as they often require less turns.
+//
+// to use this custom model you need to set the following option in the config.yml
+// graph.elevation.provider: srtm   # enables elevation
+// graph.encoded_values: bike_priority, bike_access, roundabout, bike_average_speed, average_slope, orientation
+// profiles:
+//    - name: bike
+//      turn_costs:
+//        vehicle_types: [bicycle]
+//        u_turn_costs: 20
+//        left_costs: 3
+//        left_sharp_costs: 3
+//        right_costs: 3
+//        right_sharp_costs: 3
+//      custom_model_files: [bike_tc.json, bike_elevation.json]
+
+{
+  "priority": [
+    { "if": "true",  "multiply_by": "bike_priority" },
+    { "if": "!bike_access && (!backward_bike_access || roundabout)",  "multiply_by": "0" },
+    { "else_if": "!bike_access && backward_bike_access",  "multiply_by": "0.2" }
+  ],
+  "speed": [
+    { "if": "true", "limit_to": "bike_average_speed" },
+    { "if": "!bike_access && backward_bike_access", "limit_to": "5" }
+  ]
+}
diff --git a/web-api/src/main/java/com/graphhopper/util/CustomModel.java b/web-api/src/main/java/com/graphhopper/util/CustomModel.java
index 843cf94e933..02e4136ce0a 100644
--- a/web-api/src/main/java/com/graphhopper/util/CustomModel.java
+++ b/web-api/src/main/java/com/graphhopper/util/CustomModel.java
@@ -159,7 +159,8 @@ public String toString() {
     private String createContentString() {
         // used to check against stored custom models, see #2026
         return "distanceInfluence=" + distanceInfluence + "|headingPenalty=" + headingPenalty
-                + "|speedStatements=" + speedStatements + "|priorityStatements=" + priorityStatements + "|areas=" + areas;
+                + "|speedStatements=" + speedStatements + "|priorityStatements=" + priorityStatements
+                + "|areas=" + areas;
     }
 
     /**
@@ -178,8 +179,8 @@ public static CustomModel merge(CustomModel baseModel, CustomModel queryModel) {
             mergedCM.headingPenalty = queryModel.headingPenalty;
         mergedCM.speedStatements.addAll(queryModel.getSpeed());
         mergedCM.priorityStatements.addAll(queryModel.getPriority());
-        mergedCM.addAreas(queryModel.getAreas());
 
+        mergedCM.addAreas(queryModel.getAreas());
         return mergedCM;
     }
 }
diff --git a/web-api/src/main/java/com/graphhopper/util/TurnCostsConfig.java b/web-api/src/main/java/com/graphhopper/util/TurnCostsConfig.java
index b13e38d3995..7d4aa853ce1 100644
--- a/web-api/src/main/java/com/graphhopper/util/TurnCostsConfig.java
+++ b/web-api/src/main/java/com/graphhopper/util/TurnCostsConfig.java
@@ -2,11 +2,25 @@
 
 import com.fasterxml.jackson.annotation.JsonProperty;
 
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 
 public class TurnCostsConfig {
     public static final int INFINITE_U_TURN_COSTS = -1;
+    private double leftCosts; // in seconds
+    private double leftSharpCosts; // in seconds
+    private double straightCosts;
+    private double rightCosts;
+    private double rightSharpCosts;
+
+    // The "right" and "left" turns are symmetric and the negative values are used for "left".
+    // From 0 to minAngle no turn costs are added.
+    // From minAngle to minSharpAngle the rightCosts (or leftCosts) are added.
+    // From minSharpAngle to minUTurnAngle the rightSharpCosts (or leftSharpCosts) are added.
+    // And beyond minUTurnAngle the uTurnCosts are added.
+    private double minAngle = 25, minSharpAngle = 80, minUTurnAngle = 180;
+
     private int uTurnCosts = INFINITE_U_TURN_COSTS;
     private List<String> vehicleTypes;
     // ensure that no typos can occur like motor_car vs motorcar or bike vs bicycle
@@ -27,10 +41,24 @@ public static TurnCostsConfig bike() {
         return new TurnCostsConfig(List.of("bicycle"));
     }
 
-    // jackson
     public TurnCostsConfig() {
     }
 
+    public TurnCostsConfig(TurnCostsConfig copy) {
+        leftCosts = copy.leftCosts;
+        leftSharpCosts = copy.leftSharpCosts;
+        straightCosts = copy.straightCosts;
+        rightCosts = copy.rightCosts;
+        rightSharpCosts = copy.rightSharpCosts;
+        uTurnCosts = copy.uTurnCosts;
+
+        minAngle = copy.minAngle;
+        minSharpAngle = copy.minSharpAngle;
+        minUTurnAngle = copy.minUTurnAngle;
+        if (copy.vehicleTypes != null)
+            vehicleTypes = new ArrayList<>(copy.vehicleTypes);
+    }
+
     public TurnCostsConfig(List<String> vehicleTypes) {
         this.vehicleTypes = check(vehicleTypes);
     }
@@ -40,10 +68,6 @@ public TurnCostsConfig(List<String> vehicleTypes, int uTurnCost) {
         this.uTurnCosts = uTurnCost;
     }
 
-    public void setVehicleTypes(List<String> vehicleTypes) {
-        this.vehicleTypes = check(vehicleTypes);
-    }
-
     List<String> check(List<String> restrictions) {
         if (restrictions == null || restrictions.isEmpty())
             throw new IllegalArgumentException("turn_costs cannot have empty vehicle_types");
@@ -54,12 +78,21 @@ List<String> check(List<String> restrictions) {
         return restrictions;
     }
 
+    public TurnCostsConfig setVehicleTypes(List<String> vehicleTypes) {
+        this.vehicleTypes = check(vehicleTypes);
+        return this;
+    }
+
     @JsonProperty("vehicle_types")
     public List<String> getVehicleTypes() {
         check(vehicleTypes);
         return vehicleTypes;
     }
 
+    /**
+     * @param uTurnCosts the costs of an u-turn in seconds, for {@link TurnCostsConfig#INFINITE_U_TURN_COSTS}
+     *                   the u-turn costs will be infinite
+     */
     public TurnCostsConfig setUTurnCosts(int uTurnCosts) {
         this.uTurnCosts = uTurnCosts;
         return this;
@@ -70,8 +103,96 @@ public int getUTurnCosts() {
         return uTurnCosts;
     }
 
+    public boolean hasLeftRightStraightCosts() {
+        return leftCosts != 0 || leftSharpCosts != 0 || straightCosts != 0 || rightCosts != 0 || rightSharpCosts != 0;
+    }
+
+    public TurnCostsConfig setLeftCosts(double leftCosts) {
+        this.leftCosts = leftCosts;
+        return this;
+    }
+
+    @JsonProperty("left_costs")
+    public double getLeftCosts() {
+        return leftCosts;
+    }
+
+    public TurnCostsConfig setLeftSharpCosts(double leftSharpCosts) {
+        this.leftSharpCosts = leftSharpCosts;
+        return this;
+    }
+
+    @JsonProperty("left_sharp_costs")
+    public double getLeftSharpCosts() {
+        return leftSharpCosts;
+    }
+
+    public TurnCostsConfig setRightCosts(double rightCosts) {
+        this.rightCosts = rightCosts;
+        return this;
+    }
+
+    @JsonProperty("right_costs")
+    public double getRightCosts() {
+        return rightCosts;
+    }
+
+    public TurnCostsConfig setRightSharpCosts(double rightSharpCosts) {
+        this.rightSharpCosts = rightSharpCosts;
+        return this;
+    }
+
+    @JsonProperty("right_sharp_costs")
+    public double getRightSharpCosts() {
+        return rightSharpCosts;
+    }
+
+    public TurnCostsConfig setStraightCosts(double straightCosts) {
+        this.straightCosts = straightCosts;
+        return this;
+    }
+
+    @JsonProperty("straight_costs")
+    public double getStraightCosts() {
+        return straightCosts;
+    }
+
+    @JsonProperty("min_angle")
+    public TurnCostsConfig setMinAngle(double minAngle) {
+        this.minAngle = minAngle;
+        return this;
+    }
+
+    public double getMinAngle() {
+        return minAngle;
+    }
+
+    @JsonProperty("min_sharp_angle")
+    public TurnCostsConfig setMinSharpAngle(double minSharpAngle) {
+        this.minSharpAngle = minSharpAngle;
+        return this;
+    }
+
+    public double getMinSharpAngle() {
+        return minSharpAngle;
+    }
+
+    @JsonProperty("min_u_turn_angle")
+    public TurnCostsConfig setMinUTurnAngle(double minUTurnAngle) {
+        this.minUTurnAngle = minUTurnAngle;
+        return this;
+    }
+
+    public double getMinUTurnAngle() {
+        return minUTurnAngle;
+    }
+
     @Override
     public String toString() {
-        return "vehicleTypes=" + vehicleTypes + ", uTurnCosts=" + uTurnCosts;
+        return "left=" + leftCosts + ", leftSharp=" + leftSharpCosts
+                + ", straight=" + straightCosts
+                + ", right=" + rightCosts + ", rightSharp=" + rightSharpCosts
+                + ", minAngle=" + minAngle + ", minSharpAngle=" + minSharpAngle + ", minUTurnAngle=" + minUTurnAngle
+                + ", uTurnCosts=" + uTurnCosts + ", vehicleTypes=" + vehicleTypes;
     }
 }


Test patch:
diff --git a/client-hc/src/test/java/com/graphhopper/api/GraphHopperWebTest.java b/client-hc/src/test/java/com/graphhopper/api/GraphHopperWebTest.java
index 14da63ccec8..99cc6577aa0 100644
--- a/client-hc/src/test/java/com/graphhopper/api/GraphHopperWebTest.java
+++ b/client-hc/src/test/java/com/graphhopper/api/GraphHopperWebTest.java
@@ -112,8 +112,8 @@ public void customModel() throws JsonProcessingException {
                 "\"type\":\"FeatureCollection\",\"features\":[" +
                 "{\"id\":\"area_1\",\"type\":\"Feature\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[48.019324184801185,11.28021240234375],[48.019324184801185,11.53564453125],[48.11843396091691,11.53564453125],[48.11843396091691,11.28021240234375],[48.019324184801185,11.28021240234375]]]},\"properties\":{}}," +
                 "{\"id\":\"area_2\",\"type\":\"Feature\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[48.15509285476017,11.53289794921875],[48.15509285476017,11.8212890625],[48.281365151571755,11.8212890625],[48.281365151571755,11.53289794921875],[48.15509285476017,11.53289794921875]]]},\"properties\":{}}]}," +
-                "\"speed\":[{\"if\":\"road_class == MOTORWAY\",\"limit_to\":\"80\"}]," +
-                "\"priority\":[{\"if\":\"surface == DIRT\",\"multiply_by\":\"0.7\"},{\"if\":\"surface == SAND\",\"multiply_by\":\"0.6\"}]}");
+                "\"priority\":[{\"if\":\"surface == DIRT\",\"multiply_by\":\"0.7\"},{\"if\":\"surface == SAND\",\"multiply_by\":\"0.6\"}]," +
+                "\"speed\":[{\"if\":\"road_class == MOTORWAY\",\"limit_to\":\"80\"}]}");
         assertEquals(expected, objectMapper.valueToTree(customModelJson));
 
         CustomModel cm = objectMapper.readValue("{\"distance_influence\":null}", CustomModel.class);
diff --git a/core/src/test/java/com/graphhopper/routing/weighting/DefaultTurnCostProviderTest.java b/core/src/test/java/com/graphhopper/routing/weighting/DefaultTurnCostProviderTest.java
new file mode 100644
index 00000000000..839f8e9e2ed
--- /dev/null
+++ b/core/src/test/java/com/graphhopper/routing/weighting/DefaultTurnCostProviderTest.java
@@ -0,0 +1,133 @@
+package com.graphhopper.routing.weighting;
+
+import com.graphhopper.config.Profile;
+import com.graphhopper.reader.ReaderWay;
+import com.graphhopper.routing.DefaultWeightingFactory;
+import com.graphhopper.routing.ev.*;
+import com.graphhopper.routing.util.EncodingManager;
+import com.graphhopper.routing.util.parsers.OrientationCalculator;
+import com.graphhopper.storage.BaseGraph;
+import com.graphhopper.util.*;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static com.graphhopper.json.Statement.If;
+import static com.graphhopper.json.Statement.Op.LIMIT;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class DefaultTurnCostProviderTest {
+
+    @Test
+    public void testRawTurnWeight() {
+        EncodingManager encodingManager = new EncodingManager.Builder().add(Orientation.create()).build();
+        DecimalEncodedValue orientationEnc = encodingManager.getDecimalEncodedValue(Orientation.KEY);
+        OrientationCalculator calc = new OrientationCalculator(orientationEnc);
+        BaseGraph graph = new BaseGraph.Builder(encodingManager).withTurnCosts(true).create();
+        graph.getNodeAccess().setNode(1, 0.030, 0.011);
+        graph.getNodeAccess().setNode(2, 0.020, 0.009);
+        graph.getNodeAccess().setNode(3, 0.010, 0.000);
+        graph.getNodeAccess().setNode(4, 0.000, 0.008);
+
+        EdgeIntAccess edgeIntAccess = graph.getEdgeAccess();
+        //      1
+        //      |
+        //   /--2
+        //   3-/|
+        //      4
+        EdgeIteratorState edge12 = handleWayTags(edgeIntAccess, calc, graph.edge(1, 2));
+        EdgeIteratorState edge24 = handleWayTags(edgeIntAccess, calc, graph.edge(2, 4));
+        EdgeIteratorState edge23 = handleWayTags(edgeIntAccess, calc, graph.edge(2, 3), Arrays.asList(0.020, 0.002));
+        EdgeIteratorState edge23down = handleWayTags(edgeIntAccess, calc, graph.edge(2, 3), Arrays.asList(0.010, 0.005));
+
+        TurnCostsConfig tcConfig = new TurnCostsConfig();
+        DefaultTurnCostProvider tcp = new DefaultTurnCostProvider(null, orientationEnc, graph, tcConfig);
+        assertEquals(-12, tcp.calcChangeAngle(edge12.getEdge(), 2, edge24.getEdge()), 1);
+        assertEquals(-12, tcp.calcChangeAngle(edge23down.getEdge(), 2, edge12.getEdge()), 1);
+
+        // left
+        assertEquals(-84, tcp.calcChangeAngle(edge24.getEdge(), 2, edge23.getEdge()), 1);
+        assertEquals(-84, tcp.calcChangeAngle(edge23.getEdge(), 2, edge12.getEdge()), 1);
+
+        // right
+        assertEquals(96, tcp.calcChangeAngle(edge23down.getEdge(), 3, edge23.getEdge()), 1);
+        assertEquals(84, tcp.calcChangeAngle(edge12.getEdge(), 2, edge23.getEdge()), 1);
+    }
+
+    @Test
+    public void testCalcTurnWeight() {
+        BooleanEncodedValue tcAccessEnc = VehicleAccess.create("car");
+        DecimalEncodedValue tcAvgSpeedEnc = VehicleSpeed.create("car", 5, 5, true);
+        DecimalEncodedValue orientEnc = Orientation.create();
+        EncodingManager em = new EncodingManager.Builder().add(tcAccessEnc).add(tcAvgSpeedEnc).
+                add(orientEnc).addTurnCostEncodedValue(TurnRestriction.create("car")).build();
+        BaseGraph turnGraph = new BaseGraph.Builder(em).withTurnCosts(true).create();
+
+        //       4   5
+        //   0 - 1 - 2
+        //        3  6
+
+        turnGraph.getNodeAccess().setNode(0, 51.0362, 13.714);
+        turnGraph.getNodeAccess().setNode(1, 51.0362, 13.720);
+        turnGraph.getNodeAccess().setNode(2, 51.0362, 13.726);
+        turnGraph.getNodeAccess().setNode(3, 51.0358, 13.7205);
+        turnGraph.getNodeAccess().setNode(4, 51.0366, 13.720);
+        turnGraph.getNodeAccess().setNode(5, 51.0366, 13.726);
+        turnGraph.getNodeAccess().setNode(6, 51.0358, 13.726);
+
+        Profile profile = new Profile("car");
+        TurnCostsConfig config = new TurnCostsConfig().
+                setRightCosts(0.5).setRightSharpCosts(1).
+                setLeftCosts(6).setLeftSharpCosts(12);
+        profile.setCustomModel(new CustomModel().addToSpeed(If("true", LIMIT, tcAvgSpeedEnc.getName())));
+        profile.setTurnCostsConfig(config);
+        Weighting weighting = new DefaultWeightingFactory(turnGraph, em).createWeighting(profile, new PMap(), false);
+        OrientationCalculator calc = new OrientationCalculator(orientEnc);
+        EdgeIntAccess edgeIntAccess = turnGraph.getEdgeAccess();
+        EdgeIteratorState edge01 = handleWayTags(edgeIntAccess, calc, turnGraph.edge(0, 1).setDistance(500).set(tcAvgSpeedEnc, 15).set(tcAccessEnc, true, true));
+        EdgeIteratorState edge13 = handleWayTags(edgeIntAccess, calc, turnGraph.edge(1, 3).setDistance(500).set(tcAvgSpeedEnc, 15).set(tcAccessEnc, true, true));
+        EdgeIteratorState edge14 = handleWayTags(edgeIntAccess, calc, turnGraph.edge(1, 4).setDistance(500).set(tcAvgSpeedEnc, 15).set(tcAccessEnc, true, true));
+        EdgeIteratorState edge26 = handleWayTags(edgeIntAccess, calc, turnGraph.edge(2, 6).setDistance(500).set(tcAvgSpeedEnc, 15).set(tcAccessEnc, true, true));
+        EdgeIteratorState edge25 = handleWayTags(edgeIntAccess, calc, turnGraph.edge(2, 5).setDistance(500).set(tcAvgSpeedEnc, 15).set(tcAccessEnc, true, true));
+        EdgeIteratorState edge12 = handleWayTags(edgeIntAccess, calc, turnGraph.edge(1, 2).setDistance(500).set(tcAvgSpeedEnc, 15).set(tcAccessEnc, true, true));
+
+        // from top to left => sharp right turn
+        assertEquals(1, weighting.calcTurnWeight(edge14.getEdge(), 1, edge01.getEdge()), 0.01);
+        // left to right => straight
+        assertEquals(0.0, weighting.calcTurnWeight(edge01.getEdge(), 1, edge12.getEdge()), 0.01);
+        // top to right => sharp left turn
+        assertEquals(12, weighting.calcTurnWeight(edge14.getEdge(), 1, edge12.getEdge()), 0.01);
+        // left to down => right turn
+        assertEquals(0.5, weighting.calcTurnWeight(edge01.getEdge(), 1, edge13.getEdge()), 0.01);
+        // bottom to left => left turn
+        assertEquals(6, weighting.calcTurnWeight(edge13.getEdge(), 1, edge01.getEdge()), 0.01);
+
+        // left to top => sharp left turn => here like 'straight'
+        assertEquals(12, weighting.calcTurnWeight(edge12.getEdge(), 2, edge25.getEdge()), 0.01);
+        // down to left => sharp left turn => here again like 'straight'
+        assertEquals(12, weighting.calcTurnWeight(edge26.getEdge(), 2, edge12.getEdge()), 0.01);
+        // top to left => sharp right turn
+        assertEquals(1, weighting.calcTurnWeight(edge25.getEdge(), 2, edge12.getEdge()), 0.01);
+    }
+
+    EdgeIteratorState handleWayTags(EdgeIntAccess edgeIntAccess, OrientationCalculator calc, EdgeIteratorState edge) {
+        return handleWayTags(edgeIntAccess, calc, edge, List.of());
+    }
+
+    EdgeIteratorState handleWayTags(EdgeIntAccess edgeIntAccess, OrientationCalculator calc, EdgeIteratorState edge, List<Double> rawPointList) {
+        if (rawPointList.size() % 2 != 0) throw new IllegalArgumentException();
+        if (!rawPointList.isEmpty()) {
+            PointList list = new PointList();
+            for (int i = 0; i < rawPointList.size(); i += 2) {
+                list.add(rawPointList.get(0), rawPointList.get(1));
+            }
+            edge.setWayGeometry(list);
+        }
+
+        ReaderWay way = new ReaderWay(1);
+        way.setTag("point_list", edge.fetchWayGeometry(FetchMode.ALL));
+        calc.handleWayTags(edge.getEdge(), edgeIntAccess, way, null);
+        return edge;
+    }
+}
diff --git a/core/src/test/java/com/graphhopper/routing/weighting/custom/CustomWeightingTest.java b/core/src/test/java/com/graphhopper/routing/weighting/custom/CustomWeightingTest.java
index 2ae5a98dcb9..08fa5a98c8c 100644
--- a/core/src/test/java/com/graphhopper/routing/weighting/custom/CustomWeightingTest.java
+++ b/core/src/test/java/com/graphhopper/routing/weighting/custom/CustomWeightingTest.java
@@ -404,7 +404,8 @@ public void testTime() {
     public void calcWeightAndTime_withTurnCosts() {
         BaseGraph graph = new BaseGraph.Builder(encodingManager).withTurnCosts(true).create();
         CustomModel customModel = createSpeedCustomModel(avSpeedEnc);
-        Weighting weighting = CustomModelParser.createWeighting(encodingManager, new DefaultTurnCostProvider(turnRestrictionEnc, graph.getTurnCostStorage()), customModel);
+        Weighting weighting = CustomModelParser.createWeighting(encodingManager,
+                new DefaultTurnCostProvider(turnRestrictionEnc, null, graph, new TurnCostsConfig()), customModel);
         graph.edge(0, 1).set(avSpeedEnc, 60, 60).setDistance(100);
         EdgeIteratorState edge = graph.edge(1, 2).set(avSpeedEnc, 60, 60).setDistance(100);
         setTurnRestriction(graph, 0, 1, 2);
@@ -417,7 +418,8 @@ public void calcWeightAndTime_withTurnCosts() {
     public void calcWeightAndTime_uTurnCosts() {
         BaseGraph graph = new BaseGraph.Builder(encodingManager).withTurnCosts(true).create();
         CustomModel customModel = createSpeedCustomModel(avSpeedEnc);
-        Weighting weighting = CustomModelParser.createWeighting(encodingManager, new DefaultTurnCostProvider(turnRestrictionEnc, graph.getTurnCostStorage(), 40), customModel);
+        Weighting weighting = CustomModelParser.createWeighting(encodingManager,
+                new DefaultTurnCostProvider(turnRestrictionEnc, null, graph, new TurnCostsConfig().setUTurnCosts(40)), customModel);
         EdgeIteratorState edge = graph.edge(0, 1).set(avSpeedEnc, 60, 60).setDistance(100);
         assertEquals(6 + 40, GHUtility.calcWeightWithTurnWeight(weighting, edge, false, 0), 1.e-6);
         assertEquals(6 * 1000, GHUtility.calcMillisWithTurnMillis(weighting, edge, false, 0), 1.e-6);
diff --git a/web/src/test/java/com/graphhopper/application/resources/MapMatchingResourceTurnCostsTest.java b/web/src/test/java/com/graphhopper/application/resources/MapMatchingResourceTurnCostsTest.java
index 2959c159272..74a5feca871 100644
--- a/web/src/test/java/com/graphhopper/application/resources/MapMatchingResourceTurnCostsTest.java
+++ b/web/src/test/java/com/graphhopper/application/resources/MapMatchingResourceTurnCostsTest.java
@@ -23,9 +23,9 @@
 import com.graphhopper.config.CHProfile;
 import com.graphhopper.config.LMProfile;
 import com.graphhopper.jackson.ResponsePathDeserializerHelper;
-import com.graphhopper.util.TurnCostsConfig;
 import com.graphhopper.routing.TestProfiles;
 import com.graphhopper.util.Helper;
+import com.graphhopper.util.TurnCostsConfig;
 import io.dropwizard.testing.junit5.DropwizardAppExtension;
 import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
 import org.junit.jupiter.api.AfterAll;
diff --git a/web/src/test/java/com/graphhopper/application/resources/RouteResourceCustomModelTest.java b/web/src/test/java/com/graphhopper/application/resources/RouteResourceCustomModelTest.java
index eddbf85c691..85a52715531 100644
--- a/web/src/test/java/com/graphhopper/application/resources/RouteResourceCustomModelTest.java
+++ b/web/src/test/java/com/graphhopper/application/resources/RouteResourceCustomModelTest.java
@@ -27,6 +27,7 @@
 import com.graphhopper.routing.TestProfiles;
 import com.graphhopper.util.GHUtility;
 import com.graphhopper.util.Helper;
+import com.graphhopper.util.TurnCostsConfig;
 import io.dropwizard.testing.junit5.DropwizardAppExtension;
 import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
 import org.junit.jupiter.api.AfterAll;
@@ -62,15 +63,18 @@ private static GraphHopperServerConfiguration createConfig() {
                 putObject("prepare.min_network_size", 200).
                 putObject("datareader.file", "../core/files/north-bayreuth.osm.gz").
                 putObject("graph.location", DIR).
-                putObject("graph.encoded_values", "max_height,max_weight,max_width,hazmat,toll,surface,track_type,hgv,average_slope,max_slope,bus_access").
                 putObject("custom_areas.directory", "./src/test/resources/com/graphhopper/application/resources/areas").
                 putObject("import.osm.ignored_highways", "").
                 putObject("graph.encoded_values", "max_height, max_weight, max_width, hazmat, toll, surface, track_type, hgv, average_slope, max_slope, bus_access, " +
-                        "car_access, car_average_speed, bike_access, bike_priority, bike_average_speed, road_class, road_access, get_off_bike, roundabout, foot_access, foot_priority, foot_average_speed, country").
+                        "car_access, car_average_speed, bike_access, bike_priority, bike_average_speed, road_class, road_access, get_off_bike, roundabout, foot_access, foot_priority, foot_average_speed, country, orientation").
                 setProfiles(List.of(
                         TestProfiles.constantSpeed("roads", 120),
-                        new Profile("car").setCustomModel(TestProfiles.accessAndSpeed("unused", "car").getCustomModel().setDistanceInfluence(70d)),
-                        new Profile("car_with_area").setCustomModel(TestProfiles.accessAndSpeed("unused", "car").getCustomModel().addToPriority(If("in_external_area52", MULTIPLY, "0.05"))),
+                        new Profile("car").setCustomModel(TestProfiles.accessAndSpeed("unused", "car").
+                                getCustomModel().setDistanceInfluence(70d)),
+                        new Profile("car_tc_left").setCustomModel(TestProfiles.accessAndSpeed("car_tc_left", "car").
+                                getCustomModel().setDistanceInfluence(70d)).setTurnCostsConfig(new TurnCostsConfig(List.of("motor_vehicle")).setLeftCosts(100.0).setLeftSharpCosts(100.0)),
+                        new Profile("car_with_area").setCustomModel(TestProfiles.accessAndSpeed("unused", "car").
+                                getCustomModel().addToPriority(If("in_external_area52", MULTIPLY, "0.05"))),
                         TestProfiles.accessSpeedAndPriority("bike"),
                         new Profile("bus").setCustomModel(null).putHint("custom_model_files", List.of("bus.json")),
                         new Profile("cargo_bike").setCustomModel(null).putHint("custom_model_files", List.of("cargo_bike.json")),
@@ -344,6 +348,30 @@ public void testHgv() {
         assertEquals(944 * 1000, path.get("time").asLong(), 1_000);
     }
 
+    @Test
+    public void testTurnCosts() {
+        String body = "{\"points\": [[11.508198,50.015441], [11.505063,50.01737]], \"profile\": \"car_tc_left\", \"ch.disable\":true }";
+        JsonNode path = getPath(body);
+        assertEquals(1067, path.get("distance").asDouble(), 10);
+    }
+
+    @Test
+    public void testTurnCostsAlternativeBug() {
+        String body = "{\"points\": [[11.503027,49.987546], [11.503149,49.986786]], \"profile\": \"car_tc_left\", \"ch.disable\":true}";
+        JsonNode path = getPath(body);
+        assertEquals(545, path.get("distance").asDouble(), 10);
+
+        body = "{\"points\": [[11.503027,49.987546], [11.503149,49.986786]], \"profile\": \"car_tc_left\", \"ch.disable\":true," +
+                "\"algorithm\":\"alternative_route\"}";
+        final Response response = query(body, 200);
+        JsonNode json = response.readEntity(JsonNode.class);
+        assertFalse(json.get("info").has("errors"));
+
+        // TODO LATER: alternative route bug as the two routes are identical!?
+        assertEquals(2, json.get("paths").size());
+        assertEquals(545, json.get("paths").get(0).get("distance").asDouble(), 10);
+    }
+
     private void assertMessageStartsWith(JsonNode jsonNode, String message) {
         assertNotNull(jsonNode.get("message"));
         assertTrue(jsonNode.get("message").asText().startsWith(message), "Expected error message to start with:\n" +