ChannelTaskIntersection.java

package org.opentrafficsim.road.gtu.perception.mental.channel;

import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.UUID;
import java.util.function.Function;

import org.djunits.unit.SpeedUnit;
import org.djunits.value.vdouble.scalar.Duration;
import org.djunits.value.vdouble.scalar.Length;
import org.djunits.value.vdouble.scalar.Speed;
import org.djutils.exceptions.Throw;
import org.djutils.exceptions.Try;
import org.djutils.immutablecollections.ImmutableSet;
import org.opentrafficsim.base.DistancedObject;
import org.opentrafficsim.base.OtsRuntimeException;
import org.opentrafficsim.base.parameters.ParameterException;
import org.opentrafficsim.base.parameters.ParameterTypeDouble;
import org.opentrafficsim.base.parameters.ParameterTypeDuration;
import org.opentrafficsim.base.parameters.ParameterTypeLength;
import org.opentrafficsim.base.parameters.ParameterTypes;
import org.opentrafficsim.base.parameters.Parameters;
import org.opentrafficsim.base.parameters.constraint.DualBound;
import org.opentrafficsim.base.parameters.constraint.NumericConstraint;
import org.opentrafficsim.core.network.Link;
import org.opentrafficsim.core.network.Node;
import org.opentrafficsim.road.gtu.LaneBasedGtu;
import org.opentrafficsim.road.gtu.perception.LanePerception;
import org.opentrafficsim.road.gtu.perception.PerceptionCollectable;
import org.opentrafficsim.road.gtu.perception.RelativeLane;
import org.opentrafficsim.road.gtu.perception.categories.IntersectionPerception;
import org.opentrafficsim.road.gtu.perception.categories.neighbors.PerceivedGtuType;
import org.opentrafficsim.road.gtu.perception.mental.AbstractTask;
import org.opentrafficsim.road.gtu.perception.object.PerceivedGtu;
import org.opentrafficsim.road.network.conflict.Conflict;
import org.opentrafficsim.road.network.speed.SpeedLimit;

/**
 * Task demand due to intersection, including conflicts. This class implements the task demand model by Yiyun et al. (2026).
 * This defines the task demand as:<br>
 * <br>
 * <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>
 * <br>
 * 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
 * <i>TTCP</i>) conflicting vehicle.
 * <p>
 * This class extents this model to perception with channels. A channel is added for each group of conflicts. A group of
 * conflicts is formed if there is any overlap of their upstream nodes within <i>x0</i>. This upstream search branches at merges
 * but stops at splits.
 * <p>
 * For each channel <i>i</i>, task demand is determined as:<br>
 * <br>
 * <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>
 * <br>
 * Here, <i>Fi</i> is the factor of the first component that assigns some of the yield line related task demand to channel
 * <i>i</i>. This factor is the results of a weighted average, where each weight is defined as:<br>
 * <br>
 * <i>Wi</i> = 1 + <i>c</i> * exp(<i>TTCPi</i> / <i>Beta_con</i>)<br>
 * <br>
 * This equation captures a balance between uniform attention distribution, and attention being given only to the most critical
 * channel. Finally, <i>TTCPi</i> is the least <i>TTCP</i> of all conflicting vehicles upstream of conflicts in the group of
 * channel <i>i</i>.
 * <p>
 * Copyright (c) 2026-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
 * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
 * </p>
 * @author Wouter Schakel
 */
// TODO add @see ref to paper by Yiyun et al.
public class ChannelTaskIntersection extends AbstractTask implements ChannelTask
{

    /** Look-ahead distance. */
    public static final ParameterTypeLength LOOKAHEAD = ParameterTypes.LOOKAHEAD;

    /** Maximum ego task demand. */
    public static final ParameterTypeDouble TD_B = new ParameterTypeDouble("td_b",
            "Maximum task demand due to ego distance to intersection", 0.3 / (0.3 + 0.1), DualBound.UNITINTERVAL)
    {
        @Override
        public void check(final Double value, final Parameters params) throws ParameterException
        {
            Throw.when(params.contains(TD_C) && params.getParameter(TD_C) + value >= 1.0, ParameterException.class,
                    "Values for td_b and td_c should sum to a value below 1.0");
        }
    };

    /** Ego decay parameter for distance to the yield line. */
    public static final ParameterTypeLength BETA_YL = new ParameterTypeLength("beta_yl",
            "Exponential decay of conflict task by ego distance", Length.ofSI(25.12), NumericConstraint.POSITIVEZERO);

    /** Maximum task demand due to conflicting vehicle. */
    public static final ParameterTypeDouble TD_C =
            new ParameterTypeDouble("td_c", "Maximum task demand due to time-to-conflict-point of conflicting vehicle",
                    0.1 / (0.3 + 0.1), DualBound.UNITINTERVAL)
            {
                @Override
                public void check(final Double value, final Parameters params) throws ParameterException
                {
                    Throw.when(params.contains(TD_B) && params.getParameter(TD_B) + value >= 1.0, ParameterException.class,
                            "Values for td_b and td_c should sum to a value below 1.0");
                }
            };

    /** Conflicting vehicle decay parameter. */
    public static final ParameterTypeDuration BETA_CON =
            new ParameterTypeDuration("beta_con", "Exponential decay of conflict task from time-to-conflict-point",
                    Duration.ofSI(12.13), NumericConstraint.POSITIVEZERO);

    /** Speed of ghost vehicle when no speed limit is given. */
    private static final Speed GHOST_SPEED = new Speed(50.0, SpeedUnit.KM_PER_HOUR);

    /**
     * Standard supplier that supplies a task per grouped set of conflicts based on common upstream nodes.
     */
    public static final Function<LanePerception, Set<ChannelTask>> SUPPLIER = (perception) ->
    {
        Set<ChannelTask> tasksOut = new LinkedHashSet<>();
        ChannelMental channelMental =
                (perception.getMental().isPresent() && perception.getMental().get() instanceof ChannelMental m) ? m : null;
        Set<SortedSet<DistancedObject<Conflict>>> groups = findConflictGroups(perception);
        IntersectionTaskGroup intersectionTaskGroup = new IntersectionTaskGroup();
        if (!groups.isEmpty())
        {
            DistancedObject<Conflict> first = null;
            for (SortedSet<DistancedObject<Conflict>> group : groups)
            {
                for (DistancedObject<Conflict> conflict : group)
                {
                    if (!conflict.object().getConflictType().isSplit()
                            && (first == null || first.distance().gt(conflict.distance())))
                    {
                        first = conflict;
                    }
                }
            }

            // add task without any conflict that will relate to FRONT for the case there are no conflicting moving vehicles
            tasksOut.add(new ChannelTaskIntersection(perception.getGtu(), first, new TreeSet<>(), intersectionTaskGroup));

            // groups are inherently ordered as perception returns conflicts from close to far
            for (SortedSet<DistancedObject<Conflict>> group : groups)
            {
                splitCarFollowing(tasksOut, group, channelMental);
                if (!group.isEmpty())
                {
                    tasksOut.add(new ChannelTaskIntersection(perception.getGtu(), first, group, intersectionTaskGroup));
                    // make sure the channel (key is first conflict) can be found for all individual conflicts
                    if (channelMental != null)
                    {
                        group.forEach((c) -> channelMental.mapToChannel(c.object(), group.first().object()));
                    }
                }
            }
        }
        return tasksOut;
    };

    /** GTU. */
    private final LaneBasedGtu gtu;

    /** First conflict on intersection. */
    private final DistancedObject<Conflict> first;

    /** Conflicts in the group. */
    private final SortedSet<DistancedObject<Conflict>> conflicts;

    /** Group of all instantaneous intersection tasks. */
    private final IntersectionTaskGroup intersectionTaskGroup;

    /** Conflicting task demand. */
    private Double conflictingTaskDemand;

    /**
     * Constructor.
     * @param gtu GTU
     * @param first first conflict in the intersection
     * @param conflicts conflicts in the group
     * @param intersectionTaskGroup group of all instantaneous intersection tasks
     */
    protected ChannelTaskIntersection(final LaneBasedGtu gtu, final DistancedObject<Conflict> first,
            final SortedSet<DistancedObject<Conflict>> conflicts, final IntersectionTaskGroup intersectionTaskGroup)
    {
        super(getId(conflicts));
        this.gtu = gtu;
        this.first = first;
        this.conflicts = conflicts;
        this.intersectionTaskGroup = intersectionTaskGroup;
        intersectionTaskGroup.addTask(this);
    }

    /**
     * Creates an ID for this task based on the conflicts.
     * @param conflicts conflicts
     * @return ID for this task based on the conflicts
     */
    private static String getId(final SortedSet<DistancedObject<Conflict>> conflicts)
    {
        if (conflicts.isEmpty())
        {
            return UUID.randomUUID().toString();
        }
        return conflicts.first().object().getFullId();
    }

    @Override
    public Object getChannel()
    {
        return this.conflicts.isEmpty() ? FRONT : this.conflicts.first().object();
    }

    @Override
    public double calculateTaskDemand(final LanePerception perception)
    {
        Length betaYl = Try.assign(() -> this.gtu.getParameters().getParameter(BETA_YL), "Parameter Beta_yl not present.");
        double tdB = Try.assign(() -> this.gtu.getParameters().getParameter(TD_B), "Parameter TD_B not present.");
        double egoDistance = this.first.distance().si < 0.0 ? 0.0 : this.first.distance().si;
        return this.intersectionTaskGroup.getWeightedFactor(this) * tdB * Math.exp(-egoDistance / betaYl.si)
                + getConflictingTaskDemand();
    }

    /**
     * Returns the relevance of this specific channel in the general intersection context.
     * @return relevance of this specific channel in the general intersection context
     */
    private double getWeight()
    {
        return this.conflicts.isEmpty() ? 0.0 : 1.0 + getConflictingTaskDemand();
    }

    /**
     * Returns conflicting task demand.
     * @return conflicting task demand
     */
    private double getConflictingTaskDemand()
    {
        if (this.conflictingTaskDemand == null)
        {
            Duration conflictingTimeToConflict = Duration.POSITIVE_INFINITY;
            Length x0 = this.gtu.getParameters().getOptionalParameter(LOOKAHEAD)
                    .orElseThrow(() -> new OtsRuntimeException("Parameter Lookahead not present."));
            for (DistancedObject<Conflict> conflict : this.conflicts)
            {
                if (conflict.distance().ge0())
                {
                    PerceptionCollectable<PerceivedGtu, LaneBasedGtu> conflictingGtus =
                            conflict.object().getOtherConflict().getUpstreamGtus(this.gtu, PerceivedGtuType.WRAP, x0);
                    if (conflictingGtus.isEmpty())
                    {
                        Optional<SpeedLimit> speedLimitLane = conflict.object().getOtherConflict().getLane().getSpeedLimit();
                        Speed speedLimit = speedLimitLane.isPresent() ? speedLimitLane.get().speed() : GHOST_SPEED;
                        conflictingTimeToConflict = Duration.min(conflictingTimeToConflict, x0.divide(speedLimit));
                    }
                    else
                    {
                        PerceivedGtu conflictingGtu = conflictingGtus.first();
                        conflictingTimeToConflict =
                                Duration.min(conflictingTimeToConflict, conflictingGtu.getKinematics().getOverlap().isParallel()
                                        ? Duration.ZERO : conflictingGtu.getDistance().divide(conflictingGtu.getSpeed()));
                    }
                }
            }
            double tdC = this.gtu.getParameters().getOptionalParameter(TD_C)
                    .orElseThrow(() -> new OtsRuntimeException("Parameter TD_C not present."));
            Duration betaCon = this.gtu.getParameters().getOptionalParameter(BETA_CON)
                    .orElseThrow(() -> new OtsRuntimeException("Parameter Beta_con not present."));
            this.conflictingTaskDemand = tdC * Math.exp(-conflictingTimeToConflict.si / betaCon.si);
        }
        return this.conflictingTaskDemand;
    }

    /**
     * Returns conflict groups, which are grouped based on overlap in the upstream nodes of the conflicting lanes.
     * @param perception perception
     * @return conflict groups
     */
    private static Set<SortedSet<DistancedObject<Conflict>>> findConflictGroups(final LanePerception perception)
    {
        IntersectionPerception intersection =
                Try.assign(() -> perception.getPerceptionCategory(IntersectionPerception.class), "No intersection perception.");
        Iterator<DistancedObject<Conflict>> conflicts =
                intersection.getConflicts(RelativeLane.CURRENT).underlyingWithDistance();

        // Find groups of conflicts when their upstream nodes are intersecting sets
        Map<SortedSet<DistancedObject<Conflict>>, Set<Node>> groups = new LinkedHashMap<>();
        Length x0 = perception.getGtu().getParameters().getOptionalParameter(LOOKAHEAD)
                .orElseThrow(() -> new OtsRuntimeException("No x0 parameter."));
        while (conflicts.hasNext())
        {
            DistancedObject<Conflict> conflict = conflicts.next();
            Set<Node> nodes = getUpstreamNodes(conflict.object().getOtherConflict(), x0);
            // find overlap
            Entry<SortedSet<DistancedObject<Conflict>>, Set<Node>> group = null;
            Iterator<Entry<SortedSet<DistancedObject<Conflict>>, Set<Node>>> groupIterator = groups.entrySet().iterator();
            while (groupIterator.hasNext())
            {
                Entry<SortedSet<DistancedObject<Conflict>>, Set<Node>> entry = groupIterator.next();
                if (entry.getValue().stream().anyMatch(nodes::contains))
                {
                    // overlap with this entry
                    if (group == null)
                    {
                        entry.getKey().add(conflict);
                        entry.getValue().addAll(nodes);
                        group = entry;
                        // keep looping to also merge other groups if they overlap with the upstream nodes of this conflict
                    }
                    else
                    {
                        // the nodes overlap with multiple groups that did so far not yet overlap, merge the other group too
                        group.getKey().addAll(entry.getKey());
                        group.getValue().addAll(entry.getValue());
                        groupIterator.remove();
                    }
                }
            }
            if (group == null)
            {
                // no overlap found, make new group
                SortedSet<DistancedObject<Conflict>> key = new TreeSet<>();
                key.add(conflict);
                groups.put(key, nodes);
            }
        }
        return groups.keySet();
    }

    /**
     * Finds all nodes within a given distance upstream of a conflict, stopping at any diverge, branging at merges.
     * @param conflict conflict.
     * @param x0 distance to loop upstream.
     * @return set of all upstream nodes within distance.
     */
    private static Set<Node> getUpstreamNodes(final Conflict conflict, final Length x0)
    {
        Set<Node> nodes = new LinkedHashSet<>();
        Link link = conflict.getLane().getLink();
        Length distance = link.getLength().times(conflict.getLane().fraction(conflict.getLongitudinalPosition()) - 1.0);
        appendUpstreamNodes(link, distance, x0, nodes);
        return nodes;
    }

    /**
     * Append upstream nodes, branging upstream at merges, stopping at any diverge.
     * @param link next link to move along.
     * @param distance distance between end of link and conflict, upstream of conflict.
     * @param x0 search distance.
     * @param nodes collected nodes.
     */
    private static void appendUpstreamNodes(final Link link, final Length distance, final Length x0, final Set<Node> nodes)
    {
        Length nextDistance = distance.plus(link.getLength());
        if (nextDistance.le(x0))
        {
            Node start = link.getStartNode();
            ImmutableSet<Link> links = start.getLinks();
            Set<Link> upstreamLinks = new LinkedHashSet<>();
            for (Link next : links)
            {
                if (!next.equals(link))
                {
                    if (next.getStartNode().equals(start))
                    {
                        // diverge
                        nodes.add(start);
                        return;
                    }
                    upstreamLinks.add(next);
                }
            }
            nodes.add(start);
            for (Link upstreamLink : upstreamLinks)
            {
                appendUpstreamNodes(upstreamLink, nextDistance, x0, nodes);
            }
        }
    }

    /**
     * Apply car-following task on each split in the group, and remove it from the group.
     * @param tasks tasks to add any split related task to
     * @param group group of conflicts
     * @param channelMental mental module, can be {@code null}
     */
    private static void splitCarFollowing(final Set<ChannelTask> tasks, final SortedSet<DistancedObject<Conflict>> group,
            final ChannelMental channelMental)
    {
        Iterator<DistancedObject<Conflict>> iterator = group.iterator();
        while (iterator.hasNext())
        {
            DistancedObject<Conflict> conflict = iterator.next();
            if (conflict.object().getConflictType().isSplit())
            {
                iterator.remove();
                tasks.add(new ChannelTaskCarFollowing((p) ->
                {
                    // this provides the first leader on the other split conflict with distance towards perceiving GTU
                    Conflict otherconflict = conflict.object().getOtherConflict();
                    PerceptionCollectable<PerceivedGtu, LaneBasedGtu> conflictingGtus =
                            otherconflict.getDownstreamGtus(p.getGtu(), PerceivedGtuType.WRAP, otherconflict.getLength());
                    if (conflictingGtus.isEmpty())
                    {
                        return null;
                    }
                    DistancedObject<LaneBasedGtu> leader = conflictingGtus.underlyingWithDistance().next();
                    return new DistancedObject<LaneBasedGtu>(leader.object(), conflict.distance().plus(leader.distance()));
                }));
                // make sure the channel (key is front) can be found for the split conflict
                if (channelMental != null)
                {
                    channelMental.mapToChannel(conflict.object(), FRONT);
                }
            }
        }
    }

    /**
     * Group of intersection tasks.
     */
    private static final class IntersectionTaskGroup
    {

        /** Set of currently relevant intersection tasks. */
        private final Map<ChannelTaskIntersection, Double> weights = new LinkedHashMap<>();

        /** Total weight. */
        private double totalWeight = 0.0;

        /**
         * Add task.
         * @param task task
         */
        public void addTask(final ChannelTaskIntersection task)
        {
            double weight = task.getWeight();
            this.weights.put(task, weight);
            this.totalWeight += weight;
        }

        /**
         * Get weighted factor of task demand for this specific task (for a specific channel).
         * @param channelTaskIntersection task
         * @return weighted factor of task demand for this specific task (for a specific channel)
         */
        public double getWeightedFactor(final ChannelTaskIntersection channelTaskIntersection)
        {
            if (channelTaskIntersection.conflicts.isEmpty())
            {
                return this.totalWeight == 0.0 ? 1.0 : 0.0;
            }
            return this.totalWeight == 0.0 ? 0.0 : (this.weights.get(channelTaskIntersection) / this.totalWeight);
        }

    }

}