ChannelTaskConflict.java

package org.opentrafficsim.road.gtu.lane.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.function.Function;

import org.djunits.value.vdouble.scalar.Duration;
import org.djunits.value.vdouble.scalar.Length;
import org.djutils.exceptions.Try;
import org.djutils.immutablecollections.ImmutableSet;
import org.opentrafficsim.base.parameters.ParameterTypeDuration;
import org.opentrafficsim.base.parameters.ParameterTypeLength;
import org.opentrafficsim.base.parameters.ParameterTypes;
import org.opentrafficsim.base.parameters.constraint.NumericConstraint;
import org.opentrafficsim.core.gtu.perception.EgoPerception;
import org.opentrafficsim.core.network.Link;
import org.opentrafficsim.core.network.Node;
import org.opentrafficsim.road.gtu.lane.LaneBasedGtu;
import org.opentrafficsim.road.gtu.lane.perception.LanePerception;
import org.opentrafficsim.road.gtu.lane.perception.PerceptionCollectable;
import org.opentrafficsim.road.gtu.lane.perception.PerceptionCollectable.UnderlyingDistance;
import org.opentrafficsim.road.gtu.lane.perception.RelativeLane;
import org.opentrafficsim.road.gtu.lane.perception.categories.IntersectionPerception;
import org.opentrafficsim.road.gtu.lane.perception.categories.neighbors.PerceivedGtuType;
import org.opentrafficsim.road.gtu.lane.perception.mental.AbstractTask;
import org.opentrafficsim.road.gtu.lane.perception.mental.Mental;
import org.opentrafficsim.road.gtu.lane.perception.object.PerceivedGtu;
import org.opentrafficsim.road.network.lane.conflict.Conflict;

/**
 * Task demand for a group of conflicts pertaining to the same conflicting road. This is defined as
 * {@code exp(-max(T_ego, min(T_conf))/h)} where {@code T_ego} is the ego time until the first conflict, {@code T_conf} is the
 * time until the respective conflict of a conflicting vehicle and {@code h} is the car-following task parameter that scales it.
 * <p>
 * Copyright (c) 2024-2025 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 <a href="https://github.com/wjschakel">Wouter Schakel</a>
 */
public final class ChannelTaskConflict extends AbstractTask implements ChannelTask
{
    /** Look-ahead distance. */
    public static final ParameterTypeLength LOOKAHEAD = ParameterTypes.LOOKAHEAD;

    /** Ego decay parameter. */
    public static final ParameterTypeDuration HEGO = new ParameterTypeDuration("h_ego",
            "Exponential decay of conflict task by ego approaching time.", Duration.ofSI(4.46), NumericConstraint.POSITIVEZERO);

    /** Conflicting decay parameter. */
    public static final ParameterTypeDuration HCONF =
            new ParameterTypeDuration("h_conf", "Exponential decay of conflict task by conflicting approaching time.",
                    Duration.ofSI(2.49), NumericConstraint.POSITIVEZERO);

    /**
     * Standard supplier that supplies a task per grouped set of conflicts based on common upstream nodes. This also adds a
     * scanning task demand to each of these channels.
     */
    public static final Function<LanePerception, Set<ChannelTask>> SUPPLIER = (perception) ->
    {
        Set<ChannelTask> tasks = new LinkedHashSet<>();
        Optional<Mental> mental = perception.getMental();
        ChannelMental channelMental = (mental.isPresent() && mental.get() instanceof ChannelMental m) ? m : null;
        for (SortedSet<UnderlyingDistance<Conflict>> group : findConflictGroups(perception))
        {
            splitCarFollowing(tasks, group, channelMental);
            if (!group.isEmpty())
            {
                tasks.add(new ChannelTaskConflict(group));
                tasks.add(new ChannelTaskScan(group.first().object()));
                // 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 tasks;
    };

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

    /**
     * Constructor.
     * @param conflicts conflicts in the group.
     */
    private ChannelTaskConflict(final SortedSet<UnderlyingDistance<Conflict>> conflicts)
    {
        super("conflicts");
        this.conflicts = conflicts;
    }

    @Override
    public String getId()
    {
        return this.conflicts.first().object().getFullId();
    }

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

    @Override
    public double calculateTaskDemand(final LanePerception perception)
    {
        // In the following, 'headway' means time until static conflict is reached, i.e. approaching time.

        // Get minimum headway of first vehicle on each conflict in the group
        Duration conflictHeadway = Duration.POSITIVE_INFINITY;
        LaneBasedGtu gtu = Try.assign(() -> perception.getGtu(), "Gtu not initialized.");
        Length x0 = Try.assign(() -> perception.getGtu().getParameters().getParameter(LOOKAHEAD), "No x0 parameter.");
        for (UnderlyingDistance<Conflict> conflict : this.conflicts)
        {
            PerceptionCollectable<PerceivedGtu, LaneBasedGtu> conflictingGtus =
                    conflict.object().getOtherConflict().getUpstreamGtus(gtu, PerceivedGtuType.WRAP, x0);
            if (!conflictingGtus.isEmpty())
            {
                PerceivedGtu conflictingGtu = conflictingGtus.first();
                conflictHeadway = Duration.min(conflictHeadway, conflictingGtu.getKinematics().getOverlap().isParallel()
                        ? Duration.ZERO : conflictingGtu.getDistance().divide(conflictingGtu.getSpeed()));
            }
        }

        // Get own approaching time
        EgoPerception<?, ?> ego =
                Try.assign(() -> perception.getPerceptionCategory(EgoPerception.class), "EgoPerception not present.");
        Duration egoHeadway = this.conflicts.first().distance().divide(ego.getSpeed());

        // Find least critical
        Duration hEgo =
                Try.assign(() -> perception.getGtu().getParameters().getParameter(HEGO), "Parameter h_ego not present.");
        Duration hConf =
                Try.assign(() -> perception.getGtu().getParameters().getParameter(HCONF), "Parameter h_conf not present.");
        return Math.min(0.999, Math.exp(-Math.min(egoHeadway.si / hEgo.si, conflictHeadway.si / hConf.si)));
    }

    /**
     * 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<UnderlyingDistance<Conflict>>> findConflictGroups(final LanePerception perception)
    {
        IntersectionPerception intersection =
                Try.assign(() -> perception.getPerceptionCategory(IntersectionPerception.class), "No intersection perception.");
        Iterator<UnderlyingDistance<Conflict>> conflicts =
                intersection.getConflicts(RelativeLane.CURRENT).underlyingWithDistance();

        // Find groups of conflicts when their upstream nodes are intersecting sets
        Map<SortedSet<UnderlyingDistance<Conflict>>, Set<Node>> groups = new LinkedHashMap<>();
        Length x0 = Try.assign(() -> perception.getGtu().getParameters().getParameter(LOOKAHEAD), "No x0 parameter.");
        while (conflicts.hasNext())
        {
            UnderlyingDistance<Conflict> conflict = conflicts.next();
            Set<Node> nodes = getUpstreamNodes(conflict.object().getOtherConflict(), x0);
            // find overlap
            Entry<SortedSet<UnderlyingDistance<Conflict>>, Set<Node>> group = null;
            Iterator<Entry<SortedSet<UnderlyingDistance<Conflict>>, Set<Node>>> groupIterator = groups.entrySet().iterator();
            while (groupIterator.hasNext())
            {
                Entry<SortedSet<UnderlyingDistance<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<UnderlyingDistance<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
                        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<UnderlyingDistance<Conflict>> group,
            final ChannelMental channelMental)
    {
        Iterator<UnderlyingDistance<Conflict>> iterator = group.iterator();
        while (iterator.hasNext())
        {
            UnderlyingDistance<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;
                    }
                    UnderlyingDistance<LaneBasedGtu> leader = conflictingGtus.underlyingWithDistance().next();
                    return new UnderlyingDistance<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);
                }
            }
        }
    }

}