View Javadoc
1   package org.opentrafficsim.road.gtu.operational;
2   
3   import java.util.ArrayList;
4   import java.util.LinkedHashSet;
5   import java.util.List;
6   import java.util.Optional;
7   import java.util.Set;
8   
9   import org.djunits.value.vdouble.scalar.Acceleration;
10  import org.djunits.value.vdouble.scalar.Angle;
11  import org.djunits.value.vdouble.scalar.Direction;
12  import org.djunits.value.vdouble.scalar.Duration;
13  import org.djunits.value.vdouble.scalar.Length;
14  import org.djutils.draw.curve.BezierCubic2d;
15  import org.djutils.draw.curve.Flattener2d;
16  import org.djutils.draw.curve.Flattener2d.MaxDeviationAndAngle;
17  import org.djutils.draw.point.DirectedPoint2d;
18  import org.djutils.draw.point.Point2d;
19  import org.djutils.exceptions.Throw;
20  import org.djutils.exceptions.Try;
21  import org.djutils.math.AngleUtil;
22  import org.opentrafficsim.base.DistancedObject;
23  import org.opentrafficsim.base.geometry.FractionalProjectionHelper.FractionalFallback;
24  import org.opentrafficsim.base.geometry.OtsGeometryUtil;
25  import org.opentrafficsim.base.geometry.OtsLine2d;
26  import org.opentrafficsim.core.gtu.plan.operational.OperationalPlan;
27  import org.opentrafficsim.core.gtu.plan.operational.Segments;
28  import org.opentrafficsim.core.network.LateralDirectionality;
29  import org.opentrafficsim.road.gtu.LaneBasedGtu;
30  import org.opentrafficsim.road.gtu.LaneBookkeeping;
31  import org.opentrafficsim.road.network.Lane;
32  import org.opentrafficsim.road.network.LanePosition;
33  
34  /**
35   * Builder for several often used operational plans. E.g., decelerate to come to a full stop at the end of a shape; accelerate
36   * to reach a certain speed at the end of a curve; drive constant on a curve; decelerate or accelerate to reach a given end
37   * speed at the end of a curve, etc.<br>
38   * <p>
39   * Copyright (c) 2013-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
40   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
41   * </p>
42   * @author Alexander Verbraeck
43   * @author Peter Knoppers
44   * @author Wouter Schakel
45   */
46  public final class LaneOperationalPlanBuilder
47  {
48  
49      /** Length within which GTUs snap to the lane center line. */
50      private static final Length SNAP = Length.ofSI(1e-3);
51  
52      /** Typical lane width for which the typical lane change duration applies. */
53      private static final Length LANE_WIDTH = Length.ofSI(3.5);
54  
55      /** Max angle of numerical simplification. */
56      private static final double FLATTEN_ANGLE = Math.PI / 360.0;
57  
58      /** Flattener for paths. */
59      private static final Flattener2d FLATTENER = new MaxDeviationAndAngle(0.01, FLATTEN_ANGLE);
60  
61      /** Constructor. */
62      private LaneOperationalPlanBuilder()
63      {
64          // class should not be instantiated
65      }
66  
67      /**
68       * Builds a plan from any position not on a lane, towards a reasonable location on a lane on the route.
69       * @param gtu GTU
70       * @param acceleration acceleration
71       * @param duration time step
72       * @param tManeuver maneuver time, e.g. lane change
73       * @return plan from any position not on a lane, to a reasonable location on a lane on the route
74       * @throws IllegalStateException if the GTU is on a lane
75       */
76      public static LaneBasedOperationalPlan buildRoamingPlan(final LaneBasedGtu gtu, final Acceleration acceleration,
77              final Duration duration, final Duration tManeuver)
78      {
79          gtu.setLaneChangeDirection(LateralDirectionality.NONE);
80          LanePosition nearestPosition = gtu.getRoamingPosition();
81          Length deviation = Length.ZERO;
82          boolean deviative = true;
83          OtsLine2d path = getPath(gtu, nearestPosition, acceleration, duration, tManeuver, deviation, deviative).path();
84          Duration now = gtu.getSimulator().getSimulatorTime();
85          Segments segments = Segments.off(gtu.getSpeed(), duration, acceleration);
86          return Try.assign(() -> new LaneBasedOperationalPlan(gtu, path, now, segments, deviative),
87                  "Building roaming plan produced inconsistent LaneBasedOperationalPlan.");
88      }
89  
90      /**
91       * Build operational plan from a simple plan. Sets or resets the GTU lane change direction. Deviation is applied if it is
92       * within a reasonable maneuver range.
93       * @param gtu GTU
94       * @param simplePlan simple operational plan
95       * @param tManeuver maneuver time, e.g. lane change
96       * @param deviation desired lateral deviation
97       * @return plan from position on a lane
98       * @throws IllegalStateException if the GTU is not on a lane
99       */
100     public static LaneBasedOperationalPlan buildPlanFromSimplePlan(final LaneBasedGtu gtu,
101             final SimpleOperationalPlan simplePlan, final Duration tManeuver, final DistancedObject<Length> deviation)
102     {
103         Throw.when(gtu.getLane() == null, IllegalStateException.class,
104                 "Requested to build plan from simple plan for roaming GTU.");
105 
106         Duration now = gtu.getSimulator().getSimulatorTime();
107 
108         if (gtu.getSpeed().lt(OperationalPlan.DRIFTING_SPEED)
109                 && simplePlan.getAcceleration().lt(OperationalPlan.DRIFTING_ACCELERATION))
110         {
111             DirectedPoint2d location = gtu.getLocation();
112             Point2d next = OtsGeometryUtil.translatePoint(location, 1.0);
113             OtsLine2d path = new OtsLine2d(location, next);
114             LanePosition nearestPosition = gtu.getPosition();
115             boolean deviative = nearestPosition.getLocation().distance(gtu.getLocation()) > SNAP.si;
116             Segments segments = Segments.standStill(simplePlan.getDuration());
117             return Try.assign(() -> new LaneBasedOperationalPlan(gtu, path, now, segments, deviative),
118                     "Building operational plan produced inconsistent LaneBasedOperationalPlan.");
119         }
120 
121         boolean deviative = false;
122         LanePosition nearestPosition;
123         if (simplePlan.isLaneChange())
124         {
125             boolean start = gtu.getBookkeeping().equals(LaneBookkeeping.START)
126                     || (gtu.getBookkeeping().isStartAndEdge() && gtu.getSpeed().lt(LaneBookkeeping.START_THRESHOLD));
127             if (gtu.getBookkeeping().equals(LaneBookkeeping.INSTANT) || start)
128             {
129                 // changes the bookkeeping only, not the position
130                 gtu.changeLaneInstantaneously(simplePlan.getLaneChangeDirection());
131                 nearestPosition = gtu.getPosition();
132                 // only for INSTANT deviative should be false to make a jump to the center line
133                 deviative = start;
134             }
135             else
136             {
137                 gtu.setLaneChangeDirection(simplePlan.getLaneChangeDirection());
138                 deviative = true;
139                 Lane lane = gtu.getPosition().lane().getAdjacentLane(simplePlan.getLaneChangeDirection(), gtu.getType())
140                         .orElseThrow(() -> new IllegalStateException("Starting lane change without adjacent lane."));
141                 double fraction = lane.getCenterLine().projectFractionalAt(lane.getLink().getStartNode().getHeading(),
142                         lane.getLink().getEndNode().getHeading(), gtu.getLocation().x, gtu.getLocation().y,
143                         FractionalFallback.ENDPOINT);
144                 nearestPosition = new LanePosition(lane, lane.getLength().times(fraction));
145             }
146         }
147         else
148         {
149             gtu.setLaneChangeDirection(LateralDirectionality.NONE);
150             nearestPosition = gtu.getPosition();
151             deviative = deviative || nearestPosition.getLocation().distance(gtu.getLocation()) > SNAP.si;
152         }
153         Length deviationHorizon = Length.max(tManeuver.times(gtu.getSpeed()), gtu.getVehicleModel().getTurnRadius(gtu));
154         Length targetDeviation = deviation.distance().gt(deviationHorizon) ? Length.ZERO : deviation.object();
155         deviative = deviative || targetDeviation.abs().gt(SNAP);
156         PathResults pathResults = getPath(gtu, nearestPosition, simplePlan.getAcceleration(), simplePlan.getDuration(),
157                 tManeuver, targetDeviation, deviative);
158         Segments segments = Segments.off(gtu.getSpeed(), simplePlan.getDuration(), simplePlan.getAcceleration());
159         boolean finalDeviative = deviative || pathResults.neededDeviation();
160         return Try.assign(() -> new LaneBasedOperationalPlan(gtu, pathResults.path(), now, segments, finalDeviative),
161                 "Building operational plan produced inconsistent LaneBasedOperationalPlan.");
162     }
163 
164     /**
165      * Returns a path towards a target point that is found by moving along the lanes from the nearest position.
166      * @param gtu GTU
167      * @param nearestPosition nearest position, i.e. the the location of the GTU projected to the (target) lane, or some closest
168      *            point during roaming
169      * @param acceleration acceleration
170      * @param timeStep time step
171      * @param tManeuver maneuver time, e.g. lane change
172      * @param deviation desired deviation from lane center
173      * @param deviative true if the GTU will not strictly follow the center line
174      * @return path towards a target point that is found by moving along the lanes from the start position
175      */
176     private static PathResults getPath(final LaneBasedGtu gtu, final LanePosition nearestPosition,
177             final Acceleration acceleration, final Duration timeStep, final Duration tManeuver, final Length deviation,
178             final boolean deviative)
179     {
180         Length laneGap = Length.ZERO;
181         HorizonSpace horizonSpace =
182                 getHorizonSpace(gtu, nearestPosition, acceleration, timeStep, tManeuver, deviation, laneGap);
183         if (deviative)
184         {
185             return bezierToHorizon(gtu, nearestPosition, deviation, horizonSpace);
186         }
187         return getCenterLinePath(gtu, nearestPosition, horizonSpace.horizon(), acceleration, timeStep, tManeuver, deviation);
188     }
189 
190     /**
191      * Create path as a Bezier to a target point that will be on the horizon.
192      * @param gtu GTU
193      * @param nearestPosition nearest position, i.e. the the location of the GTU projected to the (target) lane, or some closest
194      *            point during roaming
195      * @param deviation desired deviation from lane center
196      * @param horizonSpace information regarding the considered horizon
197      * @return path as a Bezier to a target point that will be on the horizon
198      */
199     private static PathResults bezierToHorizon(final LaneBasedGtu gtu, final LanePosition nearestPosition,
200             final Length deviation, final HorizonSpace horizonSpace)
201     {
202         DirectedPoint2d target = getTargetPoint(gtu, nearestPosition, deviation, horizonSpace);
203         target = extrapolateToHorizon(gtu, target, horizonSpace.horizon());
204         return new PathResults(bezierToTarget(gtu.getLocation(), target), false);
205     }
206 
207     /**
208      * Returns a path constructed from lane center lines. If a gap between lanes is found, this method will revert back to a
209      * deviative path. The horizon is then recalculated including the found lane gap. A path to a target point on the horizon is
210      * then returned.
211      * @param gtu GTU
212      * @param nearestPosition start position
213      * @param length minimum
214      * @param acceleration acceleration
215      * @param timeStep time step
216      * @param tManeuver maneuver time, e.g. lane change
217      * @param deviation desired deviation from lane center
218      * @return path constructed from lane center lines, or a bezier path in case of a lane gap between center lines
219      */
220     private static PathResults getCenterLinePath(final LaneBasedGtu gtu, final LanePosition nearestPosition,
221             final Length length, final Acceleration acceleration, final Duration timeStep, final Duration tManeuver,
222             final Length deviation)
223     {
224         Length startDistance = nearestPosition.position();
225         Lane lane = nearestPosition.lane();
226         List<Point2d> points = new ArrayList<>();
227         Point2d lastPoint = null;
228         double cumulDist = 0.0;
229         while (lane != null)
230         {
231             if (startDistance.lt(lane.getLength()))
232             {
233                 OtsLine2d centerLine = startDistance.gt0() ? lane.getCenterLine().extract(startDistance, lane.getLength())
234                         : lane.getCenterLine();
235                 if (lastPoint != null && lastPoint.distance(centerLine.getFirst()) > SNAP.si)
236                 {
237                     // It is not appropriate to follow the lane center lines due to a lane gap
238                     Length laneGap = Length.ofSI(lastPoint.distance(centerLine.getFirst()));
239                     HorizonSpace horizonSpace =
240                             getHorizonSpace(gtu, nearestPosition, acceleration, timeStep, tManeuver, deviation, laneGap);
241                     return new PathResults(bezierToHorizon(gtu, nearestPosition, deviation, horizonSpace).path(), true);
242                 }
243                 for (Point2d point : centerLine)
244                 {
245                     if (lastPoint != null)
246                     {
247                         double d = lastPoint.distance(point);
248                         if (d < SNAP.si)
249                         {
250                             continue;
251                         }
252                         cumulDist += d;
253                     }
254                     points.add(point);
255                     if (cumulDist >= length.si)
256                     {
257                         return new PathResults(new OtsLine2d(points), false);
258                     }
259                     lastPoint = point;
260                 }
261             }
262             startDistance = Length.ZERO;
263             lane = gtu.getNextLaneForRoute(lane).orElse(null);
264         }
265         // Minimum length not reached, add extrapolated point to reach required length
266         Point2d lastLastPoint = points.get(points.size() - 2);
267         double direction = lastLastPoint.directionTo(lastPoint);
268         double r = length.si - cumulDist + SNAP.si;
269         points.add(lastPoint.translate(r * Math.cos(direction), r * Math.sin(direction)));
270         return new PathResults(new OtsLine2d(points), false);
271     }
272 
273     /**
274      * Returns relevant information regarding the considered horizon. The horizon (radius) is determined as:
275      * <ul>
276      * <li>At least the length of the operational plan</li>
277      * <li>At least the GTU turn radius (=diameter)</li>
278      * <li>At least several factors on tManeuver at the current speed:
279      * <ul>
280      * <li>[0...1] for a deviation from the desired deviation of [0...3.5]m (with a quarter sine shape), or 1 for larger
281      * deviation</li>
282      * <li>[0...1] for an angle of [0...pi/4] between the direction of the GTU and the direction at the target, or 1 for larger
283      * angles</li>
284      * <li>[2...0] for an angle of [0...pi/2] between the direction of the GTU and the direction towards the target, or 0 for
285      * larger angles</li>
286      * </ul>
287      * </li>
288      * </ul>
289      * The bullet on deviation implements a typical lane change horizon.<br>
290      * The before-last bullet reflects that maneuvers take more length when the GTU is at an angle.<br>
291      * The last bullet reflects roaming situations where the GTU is approaching the target at a near-right angle, in which case
292      * more rotation is required than a normal maneuver, and hence a factor larger than 1 is applied.
293      * @param gtu GTU
294      * @param nearestPosition nearest position, i.e. the location of the GTU projected to the (target) lane, or some closest
295      *            point during roaming
296      * @param acceleration acceleration
297      * @param timeStep time step
298      * @param tManeuver maneuver time, e.g. lane change
299      * @param deviation desired deviation from lane center
300      * @param laneGap known gap between longitudinally connected lanes
301      * @return information regarding the considered horizon
302      */
303     private static HorizonSpace getHorizonSpace(final LaneBasedGtu gtu, final LanePosition nearestPosition,
304             final Acceleration acceleration, final Duration timeStep, final Duration tManeuver, final Length deviation,
305             final Length laneGap)
306     {
307         DirectedPoint2d nearestPoint = nearestPosition.getLocation();
308         double dx = nearestPoint.x - gtu.getLocation().x;
309         double dy = nearestPoint.y - gtu.getLocation().y;
310         double dCenterLine = Math.hypot(dx, dy);
311         double leftOfLane;
312         if (dCenterLine <= SNAP.si)
313         {
314             leftOfLane = 1.0;
315         }
316         else if (dy == 0.0)
317         {
318             leftOfLane = -Math.signum(dx);
319         }
320         else if (dx == 0.0)
321         {
322             leftOfLane = -Math.signum(dy);
323         }
324         else
325         {
326             leftOfLane = Math.signum(Math.cos(Math.sin(nearestPoint.dirZ) * dx - nearestPoint.dirZ) * dy);
327         }
328         Length distanceToNearest = Length.ofSI(Math.abs(deviation.si - leftOfLane * dCenterLine));
329 
330         // Lateral deviation: 0 to 1 within first {LANE_WIDTH}m with sine shape (also applies to lane gap)
331         double fLatDeviation =
332                 Math.sin(.5 * Math.PI * Math.min(1.0, Math.max(distanceToNearest.si, laneGap.si) / LANE_WIDTH.si));
333 
334         // Difference direction at target point and vehicle direction: 0 to 1 within pi/4
335         double dDirection = Math.abs(AngleUtil.normalizeAroundZero(nearestPoint.dirZ - gtu.getLocation().dirZ));
336         double fDirection = Math.min(1, 4 * dDirection / Math.PI);
337 
338         // Difference direction to target and vehicle direction: 2 to 0 within pi/2
339         // For these maneuvers more time is required than a normal lane change
340         Direction dirToNearest = Direction.ofSI(Math.atan2(dy, dx));
341         double fToTarget = 0.0;
342         if (dCenterLine > SNAP.si)
343         {
344             double dToTarget = Math.abs(AngleUtil.normalizeAroundZero(dirToNearest.si - gtu.getLocation().dirZ));
345             fToTarget = Math.max(0, 2 - 4 * dToTarget / Math.PI);
346         }
347 
348         // Combine factors
349         double tHorizon = tManeuver.si * Math.max(Math.max(fLatDeviation, fDirection), fToTarget);
350 
351         // Figure out horizon
352         Length turnDiameter = gtu.getVehicleModel().getTurnRadius(gtu);
353         double rPlan;
354         if (acceleration.lt0() && gtu.getSpeed().si / -acceleration.si < timeStep.si)
355         {
356             double t = gtu.getSpeed().si / -acceleration.si;
357             rPlan = gtu.getSpeed().si * t + .5 * acceleration.si * t * t;
358         }
359         else
360         {
361             rPlan = gtu.getSpeed().si * timeStep.si + .5 * acceleration.si * timeStep.si * timeStep.si;
362         }
363         Length horizon = Length.ofSI(Math.max(Math.max(turnDiameter.si, rPlan), gtu.getSpeed().si * tHorizon));
364         return new HorizonSpace(distanceToNearest, dirToNearest, horizon, turnDiameter, nearestPoint);
365     }
366 
367     /**
368      * Hold information on the horizon.
369      * @param distanceToNearest distance towards the nearest point
370      * @param dirToNearest distance to the nearest point
371      * @param horizon horizon radius
372      * @param turnDiameter GTU turn radius (=diameter)
373      * @param nearestPoint nearest point, e.g. GTU position projected to (target) lane
374      */
375     private record HorizonSpace(Length distanceToNearest, Direction dirToNearest, Length horizon, Length turnDiameter,
376             DirectedPoint2d nearestPoint)
377     {
378     }
379 
380     /**
381      * Returns a target point that is found by moving along the lanes from the nearest position. The general procedure is:
382      * <ul>
383      * <li>Far (closest point on lane is beyond horizon)</li>
384      * <ul>
385      * <li>Ahead (closest point on lane is within pi/4 of straight ahead)</li>
386      * <ul>
387      * <li>go to closest point on lane</li>
388      * </ul>
389      * <li>Behind</li>
390      * <ul>
391      * <li>turn to horizon edge closest to closest point on lane</li>
392      * </ul>
393      * </ul>
394      * <li>Close</li>
395      * <ul>
396      * <li>Intersect (horizon intersects lane within pi/4* of straight ahead)</li>
397      * <ul>
398      * <li>go to point where horizon intersects lane</li>
399      * </ul>
400      * <li>Prevent turn flipping (horizon edges closest to same point on lane)</li>
401      * <ul>
402      * <li>go to point on lane closest to either horizon edge</li>
403      * </ul>
404      * <li>Turn</li>
405      * <ul>
406      * <li>turn to horizon edge closest to lane</li>
407      * </ul>
408      * </ul>
409      * </ul>
410      * *) or narrower when limited by vehicle turning radius
411      * @param gtu GTU
412      * @param nearestPosition nearest position, i.e. the the location of the GTU projected to the (target) lane, or some closest
413      *            point during roaming
414      * @param deviation desired deviation from lane center
415      * @param horizonSpace information on horizon
416      * @return target point that is found by moving along the lanes from the nearest position
417      */
418     private static DirectedPoint2d getTargetPoint(final LaneBasedGtu gtu, final LanePosition nearestPosition,
419             final Length deviation, final HorizonSpace horizonSpace)
420     {
421         if (horizonSpace.distanceToNearest().gt(horizonSpace.horizon()))
422         {
423             // Far away: lane is beyond horizon, go to closest point
424             if (Math.abs(horizonSpace.dirToNearest().si - gtu.getLocation().dirZ) < Math.PI / 4.0)
425             {
426                 // Closest point is ahead, use direction but limit to horizon for reasonable path curvature
427                 return new DirectedPoint2d(
428                         gtu.getLocation().x + horizonSpace.horizon().si * Math.cos(horizonSpace.dirToNearest().si),
429                         gtu.getLocation().y + horizonSpace.horizon().si * Math.sin(horizonSpace.dirToNearest().si),
430                         horizonSpace.dirToNearest().si);
431             }
432             else
433             {
434                 // Closest point is behind, go to edge of the horizon (left or right) that is closest to closest point on lane
435                 double alpha = Math.PI / 2.0;
436                 double alphaMin = gtu.getLocation().dirZ - alpha;
437                 double alphaMax = gtu.getLocation().dirZ + alpha;
438 
439                 double x1 = gtu.getLocation().x + horizonSpace.horizon().si * Math.cos(alphaMin);
440                 double y1 = gtu.getLocation().y + horizonSpace.horizon().si * Math.sin(alphaMin);
441                 Point2d nearest1 = nearestPosition.lane().getCenterLine().closestPointOnPolyLine(new Point2d(x1, y1));
442                 double dist1sq = Math.hypot(x1 - nearest1.x, y1 - nearest1.y);
443 
444                 double x2 = gtu.getLocation().x + horizonSpace.horizon().si * Math.cos(alphaMax);
445                 double y2 = gtu.getLocation().y + horizonSpace.horizon().si * Math.sin(alphaMax);
446                 Point2d nearest2 = nearestPosition.lane().getCenterLine().closestPointOnPolyLine(new Point2d(x2, y2));
447                 double dist2sq = Math.hypot(x2 - nearest2.x, y2 - nearest2.y);
448 
449                 if (nearest1.directionTo(nearest2) < SNAP.si)
450                 {
451                     // Same point, let's not flip left/right each step, but just go there
452                     double dirToAdjustedTarget = Math.atan2(nearest1.y - gtu.getLocation().y, nearest1.x - gtu.getLocation().x);
453                     return new DirectedPoint2d(nearest1, dirToAdjustedTarget);
454                 }
455                 if (dist1sq <= dist2sq)
456                 {
457                     return new DirectedPoint2d(x1, y1, gtu.getLocation().dirZ + Math.PI);
458                 }
459                 return new DirectedPoint2d(x2, y2, gtu.getLocation().dirZ - Math.PI);
460             }
461         }
462 
463         // Close(ish): go to point where horizon intersects lane
464         double alpha = Math.PI / 4.0;
465         double rVehicle = horizonSpace.turnDiameter().si;
466         double rHorizon = horizonSpace.horizon().si;
467         if (rVehicle > .5 * rHorizon)
468         {
469             // Not the whole horizon might be reachable due to turn radius
470 
471             /* {@formatter:off}
472              * Intersection of 2 circles to find max horizon angle (alpha|a)
473              *  1) circle with radius rHorizon around (0, 0) = A
474              *  2) circle with radius rVehicle around (-rVehicle, 0) = B
475              * Intersection P creates triangle with circle centers A and B.
476              * Arc A-P is how the vehicle can turn towards horizon at P.
477              * Side A-P of length rHorizon is split at mid-point "#".
478              * A new line B-# creates two right triangles /\#-B-P & /\#-B-A.
479              * Line B-# has length "z". To find alpha:
480              *  - Note that /_P-A-B is 90deg - a. Angle /_#-B-A is 90deg
481              *    minus /_P-A-B, and therefore /_#-B-A = a.
482              *  - From this: a = arctan(.5 * rHorizon / z) =>
483              *    z = .5 * rHorizon / tan(a)
484              *  - Pythagoras gives: z = sqrt(rVehicle^2 - (.5 * rHorizon)^2)
485              *  - Equating and solving for a, simplifying fraction in tan by
486              *    multiplying numerator and denominator by 2=sqrt(4), gives:
487              *
488              *     a = arctan(rHorizon / sqrt(4 * rVehicle^2 - rHorizon^2))
489              *
490              *            T    |   | <--"ahead" in plane of vehicle
491              *             '.2a|   |
492              *               '.P-''|''-..
493              *    rVehicle .-'  \  |rHorizon
494              *          .-'/ rHor#a|       \
495              *       .-'  /       \|        \ <--horizon at rHorizon
496              *     B---------------A         |
497              *       ^  rVehicle    (0, 0)  /
498              *       |     \               /
499              *  turn radius '.           .'
500              *                '-..___..-'
501              *
502              * {@formatter:on}
503              */
504             alpha = Math.min(alpha, Math.atan(rHorizon / Math.sqrt(4 * rVehicle * rVehicle - rHorizon * rHorizon)));
505         }
506 
507         // Return intersection of lane path and horizon
508         LanePosition endPosition = getTargetLanePosition(gtu, nearestPosition, horizonSpace.horizon(), Angle.ofSI(alpha));
509         if (endPosition != null)
510         {
511             DirectedPoint2d targetPoint = endPosition.getLocation();
512             if (deviation.eq0())
513             {
514                 return targetPoint;
515             }
516             // translate laterally by deviation
517             return OtsGeometryUtil.offsetPoint(targetPoint, deviation.si);
518         }
519 
520         // Make turn
521         double alphaMin = gtu.getLocation().dirZ - alpha;
522         double alphaMax = gtu.getLocation().dirZ + alpha;
523         Point2d pMin = new Point2d(gtu.getLocation().x + rHorizon * Math.cos(alphaMin),
524                 gtu.getLocation().y + rHorizon * Math.sin(alphaMin));
525         Point2d nearest1 = nearestPosition.lane().getCenterLine().closestPointOnPolyLine(pMin);
526         double dist1sq = nearest1.distance(pMin);
527         Point2d pMax = new Point2d(gtu.getLocation().x + rHorizon * Math.cos(alphaMax),
528                 gtu.getLocation().y + rHorizon * Math.sin(alphaMax));
529         Point2d nearest2 = nearestPosition.lane().getCenterLine().closestPointOnPolyLine(pMax);
530         double dist2sq = nearest2.distance(pMax);
531         if (nearest1.distance(nearest2) < SNAP.si)
532         {
533             // Same point, let's not flip left/right every step, but just go there
534             return new DirectedPoint2d(nearest1, gtu.getLocation().directionTo(nearest1));
535         }
536         else if (dist1sq <= dist2sq)
537         {
538             /*
539              * 2 * alpha is added or subtracted. This is to obtain the direction of line line P-T in the figure above, which is
540              * the same as the angle of the arc A-P at P. The arc hits P at an angle 'a' with the line A-P (symmetry). The line
541              * A-P is also at an angle 'a' relative to vertical. Hence, line P-T makes an angle of 2a as this is the opposite
542              * corner between vertical and P-T.
543              */
544             return new DirectedPoint2d(nearest1, gtu.getLocation().dirZ - 2 * alpha);
545         }
546         return new DirectedPoint2d(nearest2, gtu.getLocation().dirZ + 2 * alpha);
547     }
548 
549     /**
550      * Returns the target position by following lanes from the start position until a point is found that is at the horizon
551      * distance removed from the GTU. If such a point is not within the viewport, {@code null} is returned. This method is part
552      * of {@code getTargetPoint()}.
553      * @param gtu GTU
554      * @param startPosition start position of path, which should be the projected location on an adjacent lane for a lane change
555      * @param horizon distance as the crow flies of the next target point
556      * @param viewport horizontal angle within which the horizon is considered, relative to straight ahead, both left and right
557      * @return lane path for a movement step of a GTU, {@code null} if no such point within viewport
558      */
559     private static LanePosition getTargetLanePosition(final LaneBasedGtu gtu, final LanePosition startPosition,
560             final Length horizon, final Angle viewport)
561     {
562         DirectedPoint2d loc0 = gtu.getLocation();
563         OtsLine2d laneCenter =
564                 startPosition.lane().getCenterLine().extract(startPosition.position(), startPosition.lane().getLength());
565         Lane lane = startPosition.lane();
566         Set<Lane> coveredLanes = new LinkedHashSet<>();
567         double r2 = horizon.si * horizon.si;
568         double distCumulLane = startPosition.position().si;
569         while (!coveredLanes.contains(lane))
570         {
571             coveredLanes.add(lane);
572 
573             // If the first point on the next lane is beyond the horizon, the horizon is in a gap between two lanes. The first
574             // point of the next lane can be considered the focus point.
575             if (Math.hypot(laneCenter.getFirst().x - loc0.x, laneCenter.getFirst().y - loc0.y) > horizon.si)
576             {
577                 double alpha = Math.atan2(laneCenter.getFirst().y - loc0.y, laneCenter.getFirst().x - loc0.x) - loc0.dirZ;
578                 return Math.abs(alpha) <= viewport.si ? new LanePosition(lane, Length.ZERO) : null;
579             }
580 
581             // Find horizon point on lane
582             for (int i = 0; i < laneCenter.size() - 1; i++)
583             {
584                 double dx = laneCenter.getX(i + 1) - laneCenter.getX(i);
585                 double dy = laneCenter.getY(i + 1) - laneCenter.getY(i);
586                 double dr = Math.hypot(dx, dy);
587 
588                 // Check if line segment crosses circle (method from https://mathworld.wolfram.com/Circle-LineIntersection.html)
589                 double det = (laneCenter.getX(i) - loc0.x) * (laneCenter.getY(i + 1) - loc0.y)
590                         - (laneCenter.getX(i + 1) - loc0.x) * (laneCenter.getY(i) - loc0.y);
591                 double dr2 = dr * dr;
592                 double det2 = det * det;
593                 double discriminant = r2 * dr2 - det2;
594                 if (discriminant >= 0.0)
595                 {
596                     double sgn = dy < 0.0 ? -1.0 : 1.0;
597                     double sqrtDisc = Math.sqrt(discriminant);
598                     // Up to two crossing points
599                     double pointSign = 1.0;
600                     for (int j = 0; j < 2; j++)
601                     {
602                         double xP = loc0.x + (det * dy + pointSign * sgn * dx * sqrtDisc) / dr2;
603                         double yP = loc0.y + (-det * dx + pointSign * Math.abs(dy) * sqrtDisc) / dr2;
604                         double alpha = AngleUtil.normalizeAroundZero(Math.atan2(yP - loc0.y, xP - loc0.x) - loc0.dirZ);
605 
606                         double f = Math.abs(dx) > 0.0 && Math.abs(dx) > Math.abs(dy) ? (xP - laneCenter.getX(i)) / dx
607                                 : (dy == 0.0 ? 0.0 : (yP - laneCenter.getY(i)) / dy);
608                         // Crosses within line segment?
609                         if (f >= 0.0 && f <= 1.0)
610                         {
611                             // In viewing port?
612                             if (Math.abs(alpha) <= viewport.si)
613                             {
614                                 return new LanePosition(lane, Length.ofSI(distCumulLane + f * dr));
615                             }
616                             else
617                             {
618                                 // Crossing with center line outside of viewport (could be turn radius). Increase horizon and
619                                 // try again but with full default viewport.
620                                 return getTargetLanePosition(gtu, startPosition, horizon.times(2.0), Angle.ofSI(Math.PI / 4.0));
621                             }
622                         }
623                         pointSign = -1.0;
624                     }
625                 }
626                 distCumulLane += dr;
627             }
628 
629             // Move to next lane
630             Optional<Lane> nextLane = gtu.getNextLaneForRoute(lane);
631             if (nextLane.isEmpty())
632             {
633                 return new LanePosition(lane, Length.ofSI(startPosition.lane().getCenterLine().getLength()));
634             }
635             lane = nextLane.get();
636             laneCenter = lane.getCenterLine();
637             distCumulLane = 0.0;
638         }
639 
640         // Encountered a loop, return last position if within alpha
641         double alpha = AngleUtil
642                 .normalizeAroundZero(Math.atan2(laneCenter.getLast().y - loc0.y, laneCenter.getLast().x - loc0.x) - loc0.dirZ);
643         return Math.abs(alpha) <= viewport.si ? new LanePosition(lane, lane.getLength()) : null;
644     }
645 
646     /**
647      * Extrapolate target point further in its direction up to horizon, if it is closer than horizon. This can happen if the end
648      * of a route is reached.
649      * @param gtu GTU
650      * @param target target point
651      * @param horizon horizon
652      * @return extrapolated target point further in its direction up to horizon
653      */
654     private static DirectedPoint2d extrapolateToHorizon(final LaneBasedGtu gtu, final DirectedPoint2d target,
655             final Length horizon)
656     {
657         double dx = target.x - gtu.getLocation().x;
658         double dy = target.y - gtu.getLocation().y;
659         double dist = Math.hypot(dx, dy);
660         if (dist >= horizon.si)
661         {
662             return target;
663         }
664         /*
665          * {@formatter:off}
666          * Relative to vehicle (A), we need a point P on the horizon at an
667          * angle 'a'. This point is 'h' extrapolated beyond target (B) at
668          * (dx, dy) at angle target.dirZ.
669          *  x = dx + h * cos(target.dirZ) = horizon * cos(a)       [1]
670          *  y = dy + h * sin(target.dirZ) = horizon * sin(a)       [2]
671          * Solving a = f(...) for [2] and substituting in [1], and
672          * solving this for h, gives a large equation with two solutions.
673          *
674          *                 ..--''' <-- Horizon at horizon from A
675          * target.dirZ  .-'h
676          *     <-------P------B (dx, dy) <-- target
677          *      (x, y)' ''-.  a\
678          *           |      ''-.A <-- vehicle
679          *            .          (0, 0)
680          * {@formatter:on}
681          */
682         double cosTarget = Math.cos(target.dirZ);
683         double sinTarget = Math.sin(target.dirZ);
684         double c = cosTarget * dx;
685         double s = sinTarget * dy;
686         double d = Math.sqrt(
687                 -cosTarget * cosTarget * dy * dy + 2.0 * c * s - sinTarget * sinTarget * dx * dx + horizon.si * horizon.si);
688         double x = Math.max(-c - s - d, -c - s + d); // positive solution
689         return new DirectedPoint2d(target.x + x * cosTarget, target.y + x * sinTarget, target.dirZ);
690     }
691 
692     /**
693      * Create Bezier path from one location to a target point. The path is flattened with a default flattener using a maximum
694      * deviation of 0.1m and maximum angle 0.5 degrees.
695      * @param from from point
696      * @param target target point
697      * @return Bezier path from current location of the GTU towards the target point
698      */
699     public static OtsLine2d bezierToTarget(final DirectedPoint2d from, final DirectedPoint2d target)
700     {
701         double angleShift = Math.abs(AngleUtil.normalizeAroundZero(from.dirZ - target.dirZ));
702         double dirToTarget = from.directionTo(target);
703         if (angleShift < FLATTEN_ANGLE && Math.abs(AngleUtil.normalizeAroundZero(dirToTarget - target.dirZ)) < FLATTEN_ANGLE
704                 && Math.abs(AngleUtil.normalizeAroundZero(dirToTarget - from.dirZ)) < FLATTEN_ANGLE)
705         {
706             // current position and direction sufficiently in line with target position and direction to simplify as straight
707             return new OtsLine2d(from, target);
708         }
709         // Shape points at shapeFactor of inter-point distance:
710         // 1/3rd when angle between points < pi/2
711         // then increases linearly to 2/3rds for an angle of pi
712         double shapeFactor = (1.0 + 2.0 * Math.max(0.0, angleShift - 0.5 * Math.PI) / Math.PI) / 3;
713         double rControl = shapeFactor * Math.hypot(from.x - target.x, from.y - target.y);
714         Point2d p2 = OtsGeometryUtil.translatePoint(from, rControl);
715         Point2d p3 = OtsGeometryUtil.translatePoint(target, -rControl);
716         BezierCubic2d bezier = new BezierCubic2d(from, p2, p3, target);
717         return new OtsLine2d(bezier.toPolyLine(FLATTENER));
718     }
719 
720     /**
721      * Record to return results of path building.
722      * @param path path
723      * @param neededDeviation needed to revert back to a deviative path due to gaps between lanes
724      */
725     private record PathResults(OtsLine2d path, boolean neededDeviation)
726     {
727     };
728 
729 }