View Javadoc
1   package org.opentrafficsim.road.gtu.perception.mental.channel;
2   
3   import java.util.Iterator;
4   import java.util.LinkedHashMap;
5   import java.util.LinkedHashSet;
6   import java.util.Map;
7   import java.util.Map.Entry;
8   import java.util.Optional;
9   import java.util.Set;
10  import java.util.SortedSet;
11  import java.util.TreeSet;
12  import java.util.UUID;
13  import java.util.function.Function;
14  
15  import org.djunits.unit.SpeedUnit;
16  import org.djunits.value.vdouble.scalar.Duration;
17  import org.djunits.value.vdouble.scalar.Length;
18  import org.djunits.value.vdouble.scalar.Speed;
19  import org.djutils.exceptions.Throw;
20  import org.djutils.exceptions.Try;
21  import org.djutils.immutablecollections.ImmutableSet;
22  import org.opentrafficsim.base.DistancedObject;
23  import org.opentrafficsim.base.OtsRuntimeException;
24  import org.opentrafficsim.base.parameters.ParameterException;
25  import org.opentrafficsim.base.parameters.ParameterTypeDouble;
26  import org.opentrafficsim.base.parameters.ParameterTypeDuration;
27  import org.opentrafficsim.base.parameters.ParameterTypeLength;
28  import org.opentrafficsim.base.parameters.ParameterTypes;
29  import org.opentrafficsim.base.parameters.Parameters;
30  import org.opentrafficsim.base.parameters.constraint.DualBound;
31  import org.opentrafficsim.base.parameters.constraint.NumericConstraint;
32  import org.opentrafficsim.core.network.Link;
33  import org.opentrafficsim.core.network.Node;
34  import org.opentrafficsim.road.gtu.LaneBasedGtu;
35  import org.opentrafficsim.road.gtu.perception.LanePerception;
36  import org.opentrafficsim.road.gtu.perception.PerceptionCollectable;
37  import org.opentrafficsim.road.gtu.perception.RelativeLane;
38  import org.opentrafficsim.road.gtu.perception.categories.IntersectionPerception;
39  import org.opentrafficsim.road.gtu.perception.categories.neighbors.PerceivedGtuType;
40  import org.opentrafficsim.road.gtu.perception.mental.AbstractTask;
41  import org.opentrafficsim.road.gtu.perception.object.PerceivedGtu;
42  import org.opentrafficsim.road.network.conflict.Conflict;
43  import org.opentrafficsim.road.network.speed.SpeedLimit;
44  
45  /**
46   * Task demand due to intersection, including conflicts. This class implements the task demand model by Yiyun et al. (2026).
47   * This defines the task demand as:<br>
48   * <br>
49   * <i>TD</i> = <i>b</i> * exp(<i>x</i> / <i>Beta_yl</i>) + <i>c</i> * exp(<i>TTCP</i> / <i>Beta_con</i>)<br>
50   * <br>
51   * where <i>x</i> is the distance to the yield line and <i>TTCP</i> is the time-to-conflict-point of the nearest (least
52   * <i>TTCP</i>) conflicting vehicle.
53   * <p>
54   * This class extents this model to perception with channels. A channel is added for each group of conflicts. A group of
55   * conflicts is formed if there is any overlap of their upstream nodes within <i>x0</i>. This upstream search branches at merges
56   * but stops at splits.
57   * <p>
58   * For each channel <i>i</i>, task demand is determined as:<br>
59   * <br>
60   * <i>TDi</i> = <i>Fi</i> * <i>b</i> * exp(<i>x</i> / <i>Beta_yl</i>) + <i>c</i> * exp(<i>TTCPi</i> / <i>Beta_con</i>)<br>
61   * <br>
62   * Here, <i>Fi</i> is the factor of the first component that assigns some of the yield line related task demand to channel
63   * <i>i</i>. This factor is the results of a weighted average, where each weight is defined as:<br>
64   * <br>
65   * <i>Wi</i> = 1 + <i>c</i> * exp(<i>TTCPi</i> / <i>Beta_con</i>)<br>
66   * <br>
67   * This equation captures a balance between uniform attention distribution, and attention being given only to the most critical
68   * channel. Finally, <i>TTCPi</i> is the least <i>TTCP</i> of all conflicting vehicles upstream of conflicts in the group of
69   * channel <i>i</i>.
70   * <p>
71   * Copyright (c) 2026-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
72   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
73   * </p>
74   * @author Wouter Schakel
75   */
76  // TODO add @see ref to paper by Yiyun et al.
77  public class ChannelTaskIntersection extends AbstractTask implements ChannelTask
78  {
79  
80      /** Look-ahead distance. */
81      public static final ParameterTypeLength LOOKAHEAD = ParameterTypes.LOOKAHEAD;
82  
83      /** Maximum ego task demand. */
84      public static final ParameterTypeDouble TD_B = new ParameterTypeDouble("td_b",
85              "Maximum task demand due to ego distance to intersection", 0.3 / (0.3 + 0.1), DualBound.UNITINTERVAL)
86      {
87          @Override
88          public void check(final Double value, final Parameters params) throws ParameterException
89          {
90              Throw.when(params.contains(TD_C) && params.getParameter(TD_C) + value >= 1.0, ParameterException.class,
91                      "Values for td_b and td_c should sum to a value below 1.0");
92          }
93      };
94  
95      /** Ego decay parameter for distance to the yield line. */
96      public static final ParameterTypeLength BETA_YL = new ParameterTypeLength("beta_yl",
97              "Exponential decay of conflict task by ego distance", Length.ofSI(25.12), NumericConstraint.POSITIVEZERO);
98  
99      /** Maximum task demand due to conflicting vehicle. */
100     public static final ParameterTypeDouble TD_C =
101             new ParameterTypeDouble("td_c", "Maximum task demand due to time-to-conflict-point of conflicting vehicle",
102                     0.1 / (0.3 + 0.1), DualBound.UNITINTERVAL)
103             {
104                 @Override
105                 public void check(final Double value, final Parameters params) throws ParameterException
106                 {
107                     Throw.when(params.contains(TD_B) && params.getParameter(TD_B) + value >= 1.0, ParameterException.class,
108                             "Values for td_b and td_c should sum to a value below 1.0");
109                 }
110             };
111 
112     /** Conflicting vehicle decay parameter. */
113     public static final ParameterTypeDuration BETA_CON =
114             new ParameterTypeDuration("beta_con", "Exponential decay of conflict task from time-to-conflict-point",
115                     Duration.ofSI(12.13), NumericConstraint.POSITIVEZERO);
116 
117     /** Speed of ghost vehicle when no speed limit is given. */
118     private static final Speed GHOST_SPEED = new Speed(50.0, SpeedUnit.KM_PER_HOUR);
119 
120     /**
121      * Standard supplier that supplies a task per grouped set of conflicts based on common upstream nodes.
122      */
123     public static final Function<LanePerception, Set<ChannelTask>> SUPPLIER = (perception) ->
124     {
125         Set<ChannelTask> tasksOut = new LinkedHashSet<>();
126         ChannelMental channelMental =
127                 (perception.getMental().isPresent() && perception.getMental().get() instanceof ChannelMental m) ? m : null;
128         Set<SortedSet<DistancedObject<Conflict>>> groups = findConflictGroups(perception);
129         IntersectionTaskGroup intersectionTaskGroup = new IntersectionTaskGroup();
130         if (!groups.isEmpty())
131         {
132             DistancedObject<Conflict> first = null;
133             for (SortedSet<DistancedObject<Conflict>> group : groups)
134             {
135                 for (DistancedObject<Conflict> conflict : group)
136                 {
137                     if (!conflict.object().getConflictType().isSplit()
138                             && (first == null || first.distance().gt(conflict.distance())))
139                     {
140                         first = conflict;
141                     }
142                 }
143             }
144 
145             // add task without any conflict that will relate to FRONT for the case there are no conflicting moving vehicles
146             tasksOut.add(new ChannelTaskIntersection(perception.getGtu(), first, new TreeSet<>(), intersectionTaskGroup));
147 
148             // groups are inherently ordered as perception returns conflicts from close to far
149             for (SortedSet<DistancedObject<Conflict>> group : groups)
150             {
151                 splitCarFollowing(tasksOut, group, channelMental);
152                 if (!group.isEmpty())
153                 {
154                     tasksOut.add(new ChannelTaskIntersection(perception.getGtu(), first, group, intersectionTaskGroup));
155                     // make sure the channel (key is first conflict) can be found for all individual conflicts
156                     if (channelMental != null)
157                     {
158                         group.forEach((c) -> channelMental.mapToChannel(c.object(), group.first().object()));
159                     }
160                 }
161             }
162         }
163         return tasksOut;
164     };
165 
166     /** GTU. */
167     private final LaneBasedGtu gtu;
168 
169     /** First conflict on intersection. */
170     private final DistancedObject<Conflict> first;
171 
172     /** Conflicts in the group. */
173     private final SortedSet<DistancedObject<Conflict>> conflicts;
174 
175     /** Group of all instantaneous intersection tasks. */
176     private final IntersectionTaskGroup intersectionTaskGroup;
177 
178     /** Conflicting task demand. */
179     private Double conflictingTaskDemand;
180 
181     /**
182      * Constructor.
183      * @param gtu GTU
184      * @param first first conflict in the intersection
185      * @param conflicts conflicts in the group
186      * @param intersectionTaskGroup group of all instantaneous intersection tasks
187      */
188     protected ChannelTaskIntersection(final LaneBasedGtu gtu, final DistancedObject<Conflict> first,
189             final SortedSet<DistancedObject<Conflict>> conflicts, final IntersectionTaskGroup intersectionTaskGroup)
190     {
191         super(getId(conflicts));
192         this.gtu = gtu;
193         this.first = first;
194         this.conflicts = conflicts;
195         this.intersectionTaskGroup = intersectionTaskGroup;
196         intersectionTaskGroup.addTask(this);
197     }
198 
199     /**
200      * Creates an ID for this task based on the conflicts.
201      * @param conflicts conflicts
202      * @return ID for this task based on the conflicts
203      */
204     private static String getId(final SortedSet<DistancedObject<Conflict>> conflicts)
205     {
206         if (conflicts.isEmpty())
207         {
208             return UUID.randomUUID().toString();
209         }
210         return conflicts.first().object().getFullId();
211     }
212 
213     @Override
214     public Object getChannel()
215     {
216         return this.conflicts.isEmpty() ? FRONT : this.conflicts.first().object();
217     }
218 
219     @Override
220     public double calculateTaskDemand(final LanePerception perception)
221     {
222         Length betaYl = Try.assign(() -> this.gtu.getParameters().getParameter(BETA_YL), "Parameter Beta_yl not present.");
223         double tdB = Try.assign(() -> this.gtu.getParameters().getParameter(TD_B), "Parameter TD_B not present.");
224         double egoDistance = this.first.distance().si < 0.0 ? 0.0 : this.first.distance().si;
225         return this.intersectionTaskGroup.getWeightedFactor(this) * tdB * Math.exp(-egoDistance / betaYl.si)
226                 + getConflictingTaskDemand();
227     }
228 
229     /**
230      * Returns the relevance of this specific channel in the general intersection context.
231      * @return relevance of this specific channel in the general intersection context
232      */
233     private double getWeight()
234     {
235         return this.conflicts.isEmpty() ? 0.0 : 1.0 + getConflictingTaskDemand();
236     }
237 
238     /**
239      * Returns conflicting task demand.
240      * @return conflicting task demand
241      */
242     private double getConflictingTaskDemand()
243     {
244         if (this.conflictingTaskDemand == null)
245         {
246             Duration conflictingTimeToConflict = Duration.POSITIVE_INFINITY;
247             Length x0 = this.gtu.getParameters().getOptionalParameter(LOOKAHEAD)
248                     .orElseThrow(() -> new OtsRuntimeException("Parameter Lookahead not present."));
249             for (DistancedObject<Conflict> conflict : this.conflicts)
250             {
251                 if (conflict.distance().ge0())
252                 {
253                     PerceptionCollectable<PerceivedGtu, LaneBasedGtu> conflictingGtus =
254                             conflict.object().getOtherConflict().getUpstreamGtus(this.gtu, PerceivedGtuType.WRAP, x0);
255                     if (conflictingGtus.isEmpty())
256                     {
257                         Optional<SpeedLimit> speedLimitLane = conflict.object().getOtherConflict().getLane().getSpeedLimit();
258                         Speed speedLimit = speedLimitLane.isPresent() ? speedLimitLane.get().speed() : GHOST_SPEED;
259                         conflictingTimeToConflict = Duration.min(conflictingTimeToConflict, x0.divide(speedLimit));
260                     }
261                     else
262                     {
263                         PerceivedGtu conflictingGtu = conflictingGtus.first();
264                         conflictingTimeToConflict =
265                                 Duration.min(conflictingTimeToConflict, conflictingGtu.getKinematics().getOverlap().isParallel()
266                                         ? Duration.ZERO : conflictingGtu.getDistance().divide(conflictingGtu.getSpeed()));
267                     }
268                 }
269             }
270             double tdC = this.gtu.getParameters().getOptionalParameter(TD_C)
271                     .orElseThrow(() -> new OtsRuntimeException("Parameter TD_C not present."));
272             Duration betaCon = this.gtu.getParameters().getOptionalParameter(BETA_CON)
273                     .orElseThrow(() -> new OtsRuntimeException("Parameter Beta_con not present."));
274             this.conflictingTaskDemand = tdC * Math.exp(-conflictingTimeToConflict.si / betaCon.si);
275         }
276         return this.conflictingTaskDemand;
277     }
278 
279     /**
280      * Returns conflict groups, which are grouped based on overlap in the upstream nodes of the conflicting lanes.
281      * @param perception perception
282      * @return conflict groups
283      */
284     private static Set<SortedSet<DistancedObject<Conflict>>> findConflictGroups(final LanePerception perception)
285     {
286         IntersectionPerception intersection =
287                 Try.assign(() -> perception.getPerceptionCategory(IntersectionPerception.class), "No intersection perception.");
288         Iterator<DistancedObject<Conflict>> conflicts =
289                 intersection.getConflicts(RelativeLane.CURRENT).underlyingWithDistance();
290 
291         // Find groups of conflicts when their upstream nodes are intersecting sets
292         Map<SortedSet<DistancedObject<Conflict>>, Set<Node>> groups = new LinkedHashMap<>();
293         Length x0 = perception.getGtu().getParameters().getOptionalParameter(LOOKAHEAD)
294                 .orElseThrow(() -> new OtsRuntimeException("No x0 parameter."));
295         while (conflicts.hasNext())
296         {
297             DistancedObject<Conflict> conflict = conflicts.next();
298             Set<Node> nodes = getUpstreamNodes(conflict.object().getOtherConflict(), x0);
299             // find overlap
300             Entry<SortedSet<DistancedObject<Conflict>>, Set<Node>> group = null;
301             Iterator<Entry<SortedSet<DistancedObject<Conflict>>, Set<Node>>> groupIterator = groups.entrySet().iterator();
302             while (groupIterator.hasNext())
303             {
304                 Entry<SortedSet<DistancedObject<Conflict>>, Set<Node>> entry = groupIterator.next();
305                 if (entry.getValue().stream().anyMatch(nodes::contains))
306                 {
307                     // overlap with this entry
308                     if (group == null)
309                     {
310                         entry.getKey().add(conflict);
311                         entry.getValue().addAll(nodes);
312                         group = entry;
313                         // keep looping to also merge other groups if they overlap with the upstream nodes of this conflict
314                     }
315                     else
316                     {
317                         // the nodes overlap with multiple groups that did so far not yet overlap, merge the other group too
318                         group.getKey().addAll(entry.getKey());
319                         group.getValue().addAll(entry.getValue());
320                         groupIterator.remove();
321                     }
322                 }
323             }
324             if (group == null)
325             {
326                 // no overlap found, make new group
327                 SortedSet<DistancedObject<Conflict>> key = new TreeSet<>();
328                 key.add(conflict);
329                 groups.put(key, nodes);
330             }
331         }
332         return groups.keySet();
333     }
334 
335     /**
336      * Finds all nodes within a given distance upstream of a conflict, stopping at any diverge, branging at merges.
337      * @param conflict conflict.
338      * @param x0 distance to loop upstream.
339      * @return set of all upstream nodes within distance.
340      */
341     private static Set<Node> getUpstreamNodes(final Conflict conflict, final Length x0)
342     {
343         Set<Node> nodes = new LinkedHashSet<>();
344         Link link = conflict.getLane().getLink();
345         Length distance = link.getLength().times(conflict.getLane().fraction(conflict.getLongitudinalPosition()) - 1.0);
346         appendUpstreamNodes(link, distance, x0, nodes);
347         return nodes;
348     }
349 
350     /**
351      * Append upstream nodes, branging upstream at merges, stopping at any diverge.
352      * @param link next link to move along.
353      * @param distance distance between end of link and conflict, upstream of conflict.
354      * @param x0 search distance.
355      * @param nodes collected nodes.
356      */
357     private static void appendUpstreamNodes(final Link link, final Length distance, final Length x0, final Set<Node> nodes)
358     {
359         Length nextDistance = distance.plus(link.getLength());
360         if (nextDistance.le(x0))
361         {
362             Node start = link.getStartNode();
363             ImmutableSet<Link> links = start.getLinks();
364             Set<Link> upstreamLinks = new LinkedHashSet<>();
365             for (Link next : links)
366             {
367                 if (!next.equals(link))
368                 {
369                     if (next.getStartNode().equals(start))
370                     {
371                         // diverge
372                         nodes.add(start);
373                         return;
374                     }
375                     upstreamLinks.add(next);
376                 }
377             }
378             nodes.add(start);
379             for (Link upstreamLink : upstreamLinks)
380             {
381                 appendUpstreamNodes(upstreamLink, nextDistance, x0, nodes);
382             }
383         }
384     }
385 
386     /**
387      * Apply car-following task on each split in the group, and remove it from the group.
388      * @param tasks tasks to add any split related task to
389      * @param group group of conflicts
390      * @param channelMental mental module, can be {@code null}
391      */
392     private static void splitCarFollowing(final Set<ChannelTask> tasks, final SortedSet<DistancedObject<Conflict>> group,
393             final ChannelMental channelMental)
394     {
395         Iterator<DistancedObject<Conflict>> iterator = group.iterator();
396         while (iterator.hasNext())
397         {
398             DistancedObject<Conflict> conflict = iterator.next();
399             if (conflict.object().getConflictType().isSplit())
400             {
401                 iterator.remove();
402                 tasks.add(new ChannelTaskCarFollowing((p) ->
403                 {
404                     // this provides the first leader on the other split conflict with distance towards perceiving GTU
405                     Conflict otherconflict = conflict.object().getOtherConflict();
406                     PerceptionCollectable<PerceivedGtu, LaneBasedGtu> conflictingGtus =
407                             otherconflict.getDownstreamGtus(p.getGtu(), PerceivedGtuType.WRAP, otherconflict.getLength());
408                     if (conflictingGtus.isEmpty())
409                     {
410                         return null;
411                     }
412                     DistancedObject<LaneBasedGtu> leader = conflictingGtus.underlyingWithDistance().next();
413                     return new DistancedObject<LaneBasedGtu>(leader.object(), conflict.distance().plus(leader.distance()));
414                 }));
415                 // make sure the channel (key is front) can be found for the split conflict
416                 if (channelMental != null)
417                 {
418                     channelMental.mapToChannel(conflict.object(), FRONT);
419                 }
420             }
421         }
422     }
423 
424     /**
425      * Group of intersection tasks.
426      */
427     private static final class IntersectionTaskGroup
428     {
429 
430         /** Set of currently relevant intersection tasks. */
431         private final Map<ChannelTaskIntersection, Double> weights = new LinkedHashMap<>();
432 
433         /** Total weight. */
434         private double totalWeight = 0.0;
435 
436         /**
437          * Add task.
438          * @param task task
439          */
440         public void addTask(final ChannelTaskIntersection task)
441         {
442             double weight = task.getWeight();
443             this.weights.put(task, weight);
444             this.totalWeight += weight;
445         }
446 
447         /**
448          * Get weighted factor of task demand for this specific task (for a specific channel).
449          * @param channelTaskIntersection task
450          * @return weighted factor of task demand for this specific task (for a specific channel)
451          */
452         public double getWeightedFactor(final ChannelTaskIntersection channelTaskIntersection)
453         {
454             if (channelTaskIntersection.conflicts.isEmpty())
455             {
456                 return this.totalWeight == 0.0 ? 1.0 : 0.0;
457             }
458             return this.totalWeight == 0.0 ? 0.0 : (this.weights.get(channelTaskIntersection) / this.totalWeight);
459         }
460 
461     }
462 
463 }