From 2d834965c079a02b59aaec9f5e9d0c20fbebf2be Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Thu, 6 Aug 2026 17:19:13 +0800 Subject: [PATCH] feat(javaTest): rank routes by live traffic quality --- .../traffic/RouteCandidateEvaluation.java | 18 ++ .../wcs/demo/traffic/RoutePlanDecision.java | 37 ++++ .../ft546/wcs/demo/traffic/RouteQuality.java | 70 ++++++++ .../demo/traffic/RouteQualityEvaluator.java | 139 +++++++++++++++ .../demo/traffic/TrafficRouteSnapshot.java | 166 ++++++++++++++++++ .../traffic/RouteQualityEvaluatorTest.java | 122 +++++++++++++ 6 files changed, 552 insertions(+) create mode 100644 javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidateEvaluation.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/demo/traffic/RoutePlanDecision.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQuality.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluator.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/demo/traffic/TrafficRouteSnapshot.java create mode 100644 javaTest/src/test/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluatorTest.java diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidateEvaluation.java b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidateEvaluation.java new file mode 100644 index 0000000..5d7d6b1 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidateEvaluation.java @@ -0,0 +1,18 @@ +package com.ft546.wcs.demo.traffic; + +/** 把一条地图候选与同一交通快照下算出的质量证据绑定。 */ +public final class RouteCandidateEvaluation { + private final RouteCandidate candidate; + private final RouteQuality quality; + + RouteCandidateEvaluation(RouteCandidate candidate, RouteQuality quality) { + this.candidate = candidate; + this.quality = quality; + } + + /** 返回被评价的候选路线。 */ + public RouteCandidate getCandidate() { return candidate; } + + /** 返回该候选的完整业务质量元组。 */ + public RouteQuality getQuality() { return quality; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RoutePlanDecision.java b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RoutePlanDecision.java new file mode 100644 index 0000000..f959277 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RoutePlanDecision.java @@ -0,0 +1,37 @@ +package com.ft546.wcs.demo.traffic; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** 一次接近段或服务段规划的最终选择及全部审计证据。 */ +public final class RoutePlanDecision { + private final RouteCandidateSet candidateSet; + private final List evaluations; + private final RouteCandidateEvaluation selected; + private final String decisionReason; + + RoutePlanDecision(RouteCandidateSet candidateSet, + List evaluations, + RouteCandidateEvaluation selected, String decisionReason) { + this.candidateSet = candidateSet; + this.evaluations = Collections.unmodifiableList( + new ArrayList(evaluations)); + this.selected = selected; + this.decisionReason = decisionReason; + } + + public RouteCandidateSet getCandidateSet() { return candidateSet; } + public List getEvaluations() { return evaluations; } + public RouteCandidateEvaluation getSelected() { return selected; } + public List getSelectedPath() { return selected.getCandidate().getPath(); } + public RouteQuality getSelectedQuality() { return selected.getQuality(); } + public String getDecisionReason() { return decisionReason; } + + /** 返回选中距离相对物理最短距离的倍率,1.0 表示没有绕行。 */ + public double getDetourRatio() { + double shortest = candidateSet.getShortestPhysicalDistance(); + return shortest == 0.0d ? 1.0d + : selected.getCandidate().getPhysicalDistance() / shortest; + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQuality.java b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQuality.java new file mode 100644 index 0000000..6fec67d --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQuality.java @@ -0,0 +1,70 @@ +package com.ft546.wcs.demo.traffic; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** 一条候选路线按固定业务顺序组成的可解释比较元组。 */ +public final class RouteQuality implements Comparable { + private final int oppositeDirectionConflictCount; + private final int occupiedNodeCount; + private final int incompatibleReservedEdgeCount; + private final int activeRouteOverlapEdgeCount; + private final int turnCount; + private final double physicalDistance; + private final List canonicalPath; + + RouteQuality(int oppositeDirectionConflictCount, int occupiedNodeCount, + int incompatibleReservedEdgeCount, int activeRouteOverlapEdgeCount, + int turnCount, double physicalDistance, List canonicalPath) { + this.oppositeDirectionConflictCount = oppositeDirectionConflictCount; + this.occupiedNodeCount = occupiedNodeCount; + this.incompatibleReservedEdgeCount = incompatibleReservedEdgeCount; + this.activeRouteOverlapEdgeCount = activeRouteOverlapEdgeCount; + this.turnCount = turnCount; + this.physicalDistance = physicalDistance; + this.canonicalPath = Collections.unmodifiableList( + new ArrayList(canonicalPath)); + } + + public int getOppositeDirectionConflictCount() { return oppositeDirectionConflictCount; } + public int getOccupiedNodeCount() { return occupiedNodeCount; } + public int getIncompatibleReservedEdgeCount() { return incompatibleReservedEdgeCount; } + public int getActiveRouteOverlapEdgeCount() { return activeRouteOverlapEdgeCount; } + public int getTurnCount() { return turnCount; } + public double getPhysicalDistance() { return physicalDistance; } + public List getCanonicalPath() { return canonicalPath; } + + /** + * 按中心调度确认的硬顺序逐项比较,不使用会掩盖高优先级风险的加权总分。 + */ + @Override + public int compareTo(RouteQuality other) { + int result = Integer.compare(oppositeDirectionConflictCount, + other.oppositeDirectionConflictCount); + if (result != 0) return result; + result = Integer.compare(occupiedNodeCount, other.occupiedNodeCount); + if (result != 0) return result; + result = Integer.compare(incompatibleReservedEdgeCount, + other.incompatibleReservedEdgeCount); + if (result != 0) return result; + result = Integer.compare(activeRouteOverlapEdgeCount, + other.activeRouteOverlapEdgeCount); + if (result != 0) return result; + result = Integer.compare(turnCount, other.turnCount); + if (result != 0) return result; + result = Double.compare(physicalDistance, other.physicalDistance); + if (result != 0) return result; + return comparePaths(canonicalPath, other.canonicalPath); + } + + /** 以完整站点序列作最终稳定决胜,确保重复仿真得到同一路线。 */ + private int comparePaths(List left, List right) { + int common = Math.min(left.size(), right.size()); + for (int index = 0; index < common; index++) { + int result = left.get(index).compareTo(right.get(index)); + if (result != 0) return result; + } + return Integer.compare(left.size(), right.size()); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluator.java b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluator.java new file mode 100644 index 0000000..5f98b1b --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluator.java @@ -0,0 +1,139 @@ +package com.ft546.wcs.demo.traffic; + +import com.ft546.wcs.demo.model.Site; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** 使用同一个实时交通快照为有界候选排序并选择业务质量最优路线。 */ +public final class RouteQualityEvaluator { + + /** + * 评价全部候选并按固定质量元组选择第一名。 + * + * @param map 权威站点坐标 + * @param candidates 已通过物理距离边界的候选集合 + * @param snapshot 同一 tick 冻结的交通证据 + * @param taskId 当前任务,用于排除自己已持有的资源 + * @param vehicleId 当前车辆,用于排除自己的起点占用 + * @return 包含所有候选质量和最终业务原因的决定 + */ + public RoutePlanDecision select(GridMap map, RouteCandidateSet candidates, + TrafficRouteSnapshot snapshot, + String taskId, String vehicleId) { + if (candidates == null || candidates.getCandidates().isEmpty()) { + throw new IllegalArgumentException("at least one route candidate is required"); + } + List evaluations = + new ArrayList(); + RouteCandidateEvaluation baseline = null; + for (RouteCandidate candidate : candidates.getCandidates()) { + RouteCandidateEvaluation evaluation = new RouteCandidateEvaluation(candidate, + evaluate(map, candidate, snapshot, taskId, vehicleId)); + evaluations.add(evaluation); + if (candidate.getPath().equals(candidates.getShortestPath())) baseline = evaluation; + } + Collections.sort(evaluations, Comparator.comparing( + RouteCandidateEvaluation::getQuality)); + RouteCandidateEvaluation selected = evaluations.get(0); + if (baseline == null) baseline = selected; + return new RoutePlanDecision(candidates, evaluations, selected, + decisionReason(selected, baseline)); + } + + /** + * 计算单条候选的交通风险、操作复杂度和物理距离元组。 + */ + public RouteQuality evaluate(GridMap map, RouteCandidate candidate, + TrafficRouteSnapshot snapshot, + String taskId, String vehicleId) { + if (map == null || candidate == null || snapshot == null + || taskId == null || vehicleId == null) { + throw new IllegalArgumentException("map, candidate, snapshot and owners are required"); + } + List path = candidate.getPath(); + int opposite = 0; + int occupied = 0; + int incompatible = 0; + int overlap = 0; + + // 路径下标 0 是当前车辆所在点,只有未来节点被其他车辆占用才形成候选风险。 + for (int index = 1; index < path.size(); index++) { + String from = path.get(index - 1); + String to = path.get(index); + if (snapshot.isOccupiedByOther(to, vehicleId)) occupied++; + boolean oppositeOnEdge = snapshot.hasOppositeDirectionOwnedByOther( + from, to, taskId); + if (oppositeOnEdge) opposite++; + // 同一物理边已计为对向风险时不在“不兼容边预约”中重复处罚。 + if (!oppositeOnEdge + && snapshot.isExclusiveEdgeOwnedByOther(from, to, taskId)) { + incompatible++; + } + if (snapshot.overlapsOtherActiveRoute(from, to, taskId)) overlap++; + } + + return new RouteQuality(opposite, occupied, incompatible, overlap, + countTurns(map, path), candidate.getPhysicalDistance(), path); + } + + /** 计算连续边方向向量改变次数;同一直线上的不同边长不算转弯。 */ + private int countTurns(GridMap map, List path) { + int turns = 0; + for (int index = 2; index < path.size(); index++) { + Site first = map.getSite(path.get(index - 2)); + Site middle = map.getSite(path.get(index - 1)); + Site last = map.getSite(path.get(index)); + if (first == null || middle == null || last == null) { + throw new IllegalArgumentException("candidate references an unknown site"); + } + if (first.getFloor() != middle.getFloor() + || middle.getFloor() != last.getFloor()) { + continue; + } + int firstX = normalize(middle.getX() - first.getX()); + int firstY = normalize(middle.getY() - first.getY()); + int secondX = normalize(last.getX() - middle.getX()); + int secondY = normalize(last.getY() - middle.getY()); + if (firstX != secondX || firstY != secondY) turns++; + } + return turns; + } + + /** 当前地图为正交网格,方向只需要保留坐标增量的符号。 */ + private int normalize(int value) { + return Integer.compare(value, 0); + } + + /** 解释选中路线相对物理最短路线最先胜出的业务指标。 */ + private String decisionReason(RouteCandidateEvaluation selected, + RouteCandidateEvaluation baseline) { + if (selected.getCandidate().getPath().equals(baseline.getCandidate().getPath())) { + return "SHORTEST_AND_BEST"; + } + RouteQuality winner = selected.getQuality(); + RouteQuality shortest = baseline.getQuality(); + if (winner.getOppositeDirectionConflictCount() + < shortest.getOppositeDirectionConflictCount()) { + return "FEWER_OPPOSITE_DIRECTION_CONFLICTS"; + } + if (winner.getOccupiedNodeCount() < shortest.getOccupiedNodeCount()) { + return "FEWER_OCCUPIED_NODES"; + } + if (winner.getIncompatibleReservedEdgeCount() + < shortest.getIncompatibleReservedEdgeCount()) { + return "FEWER_INCOMPATIBLE_RESERVED_EDGES"; + } + if (winner.getActiveRouteOverlapEdgeCount() + < shortest.getActiveRouteOverlapEdgeCount()) { + return "FEWER_ACTIVE_ROUTE_OVERLAPS"; + } + if (winner.getTurnCount() < shortest.getTurnCount()) return "FEWER_TURNS"; + if (winner.getPhysicalDistance() < shortest.getPhysicalDistance()) { + return "SHORTER_PHYSICAL_DISTANCE"; + } + return "CANONICAL_PATH_TIE_BREAK"; + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/TrafficRouteSnapshot.java b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/TrafficRouteSnapshot.java new file mode 100644 index 0000000..d2a3455 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/TrafficRouteSnapshot.java @@ -0,0 +1,166 @@ +package com.ft546.wcs.demo.traffic; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 保存一次路线比较所使用的不可变实时交通证据。 + * + *

同一候选集合必须使用同一快照,避免前一条路线和后一条路线读取到不同 tick 的占用状态。 + */ +public final class TrafficRouteSnapshot { + private final Map> occupiedNodes; + private final Map> exclusiveEdges; + private final Map> directedReservations; + private final Map> activeRoutes; + + private TrafficRouteSnapshot(Builder builder) { + this.occupiedNodes = immutableSets(builder.occupiedNodes); + this.exclusiveEdges = immutableSets(builder.exclusiveEdges); + this.directedReservations = immutableSets(builder.directedReservations); + Map> routes = new LinkedHashMap>(); + for (Map.Entry> entry : builder.activeRoutes.entrySet()) { + routes.put(entry.getKey(), Collections.unmodifiableList( + new ArrayList(entry.getValue()))); + } + this.activeRoutes = Collections.unmodifiableMap(routes); + } + + /** 创建快照构造器,调用方可从车辆、资源台账和走廊预约逐项填充证据。 */ + public static Builder builder() { + return new Builder(); + } + + /** 返回没有任何临时交通风险的空快照,适用于纯地图规划和单元测试。 */ + public static TrafficRouteSnapshot empty() { + return builder().build(); + } + + /** 判断候选未来节点是否由其他车辆占用;当前车辆自己的节点不形成风险。 */ + boolean isOccupiedByOther(String siteId, String vehicleId) { + return containsOther(occupiedNodes.get(siteId), vehicleId); + } + + /** 判断物理边是否由其他任务独占;当前任务已经合法持有的边可以继续使用。 */ + boolean isExclusiveEdgeOwnedByOther(String from, String to, String taskId) { + return containsOther(exclusiveEdges.get(canonicalEdge(from, to)), taskId); + } + + /** 判断同一走廊是否已经被其他任务预约为候选移动的相反方向。 */ + boolean hasOppositeDirectionOwnedByOther(String from, String to, String taskId) { + return containsOther(directedReservations.get(directedEdge(to, from)), taskId); + } + + /** 判断候选物理边是否与其他活动任务尚未执行的路线重合。 */ + boolean overlapsOtherActiveRoute(String from, String to, String taskId) { + String edge = canonicalEdge(from, to); + for (Map.Entry> entry : activeRoutes.entrySet()) { + if (entry.getKey().equals(taskId)) continue; + List route = entry.getValue(); + for (int index = 1; index < route.size(); index++) { + if (edge.equals(canonicalEdge(route.get(index - 1), route.get(index)))) { + return true; + } + } + } + return false; + } + + /** 返回集合中是否至少有一个非当前业务所有者。 */ + private boolean containsOther(Set owners, String currentOwner) { + if (owners == null) return false; + for (String owner : owners) { + if (!owner.equals(currentOwner)) return true; + } + return false; + } + + /** 将内部可变集合复制为不可变证据,后续资源变化不会污染本次决策。 */ + private static Map> immutableSets(Map> source) { + Map> result = new LinkedHashMap>(); + for (Map.Entry> entry : source.entrySet()) { + result.put(entry.getKey(), Collections.unmodifiableSet( + new LinkedHashSet(entry.getValue()))); + } + return Collections.unmodifiableMap(result); + } + + /** 把无向资源边规范为相同键,使 A-B 与 B-A 指向同一物理边。 */ + static String canonicalEdge(String left, String right) { + return left.compareTo(right) <= 0 ? left + "\u0000" + right : right + "\u0000" + left; + } + + /** 保留方向生成走廊预约键,使 A→B 与 B→A 可以分别判断。 */ + static String directedEdge(String from, String to) { + return from + "\u0000" + to; + } + + /** 在决策时收集当前 tick 交通状态的可变构造器。 */ + public static final class Builder { + private final Map> occupiedNodes = + new LinkedHashMap>(); + private final Map> exclusiveEdges = + new LinkedHashMap>(); + private final Map> directedReservations = + new LinkedHashMap>(); + private final Map> activeRoutes = + new LinkedHashMap>(); + + /** 登记车辆当前实际占据的节点。 */ + public Builder occupyNode(String siteId, String vehicleId) { + add(occupiedNodes, siteId, vehicleId); + return this; + } + + /** 登记资源台账中不能与其他任务同时持有的物理边。 */ + public Builder reserveEdge(String from, String to, String taskId) { + add(exclusiveEdges, canonicalEdge(from, to), taskId); + return this; + } + + /** 登记走廊从 from 到 to 的方向预约。 */ + public Builder reserveDirection(String from, String to, String taskId) { + add(directedReservations, directedEdge(from, to), taskId); + return this; + } + + /** 登记其他活动任务从当前位置开始尚未走完的节点序列。 */ + public Builder activeRoute(String taskId, List remainingRoute) { + requireText(taskId, "task id"); + if (remainingRoute == null || remainingRoute.isEmpty()) { + throw new IllegalArgumentException("active route is required"); + } + activeRoutes.put(taskId, new ArrayList(remainingRoute)); + return this; + } + + /** 冻结本轮交通证据,后续候选评价只读该对象。 */ + public TrafficRouteSnapshot build() { + return new TrafficRouteSnapshot(this); + } + + /** 向同一资源追加合法所有者,支持同向走廊存在多个任务预约。 */ + private void add(Map> target, String resource, String owner) { + requireText(resource, "resource"); + requireText(owner, "owner"); + Set owners = target.get(resource); + if (owners == null) { + owners = new LinkedHashSet(); + target.put(resource, owners); + } + owners.add(owner); + } + + /** 拒绝空业务标识,避免快照中出现无法解释的匿名资源。 */ + private void requireText(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + } + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluatorTest.java b/javaTest/src/test/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluatorTest.java new file mode 100644 index 0000000..b383631 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluatorTest.java @@ -0,0 +1,122 @@ +package com.ft546.wcs.demo.traffic; + +import com.ft546.wcs.demo.model.Site; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RouteQualityEvaluatorTest { + + @Test + void countsLiveTrafficRiskWithoutChargingResourcesOwnedByCurrentTaskOrVehicle() { + GridMap map = map(); + RouteCandidate candidate = new RouteCandidate(Arrays.asList("A", "B", "D"), 2.0d); + TrafficRouteSnapshot snapshot = TrafficRouteSnapshot.builder() + .occupyNode("A", "AGV-1") + .occupyNode("B", "AGV-2") + .reserveDirection("D", "B", "TASK-2") + .reserveEdge("B", "D", "TASK-2") + .activeRoute("TASK-2", Arrays.asList("D", "B")) + .reserveEdge("A", "B", "TASK-1") + .build(); + + RouteQuality quality = new RouteQualityEvaluator().evaluate( + map, candidate, snapshot, "TASK-1", "AGV-1"); + + assertEquals(1, quality.getOppositeDirectionConflictCount()); + assertEquals(1, quality.getOccupiedNodeCount()); + assertEquals(0, quality.getIncompatibleReservedEdgeCount()); + assertEquals(1, quality.getActiveRouteOverlapEdgeCount()); + assertEquals(0, quality.getTurnCount()); + } + + @Test + void lexicographicOrderPrefersConflictAvoidanceBeforeDistance() { + GridMap map = map(); + RouteCandidate shortestWithOppositeConflict = + new RouteCandidate(Arrays.asList("A", "B", "D"), 2.0d); + RouteCandidate longerWithoutConflict = + new RouteCandidate(Arrays.asList("A", "C", "D"), 2.8d); + RouteCandidateSet candidates = new RouteCandidateSet( + shortestWithOppositeConflict.getPath(), 2.0d, + Arrays.asList(shortestWithOppositeConflict, longerWithoutConflict), false); + TrafficRouteSnapshot snapshot = TrafficRouteSnapshot.builder() + .reserveDirection("D", "B", "OTHER") + .build(); + + RoutePlanDecision decision = new RouteQualityEvaluator().select( + map, candidates, snapshot, "TASK-1", "AGV-1"); + + assertEquals(Arrays.asList("A", "C", "D"), decision.getSelectedPath()); + assertEquals("FEWER_OPPOSITE_DIRECTION_CONFLICTS", decision.getDecisionReason()); + assertEquals(2, decision.getEvaluations().size()); + assertEquals(1.4d, decision.getDetourRatio(), 0.000001d); + } + + @Test + void lowerTupleFieldsDecideOnlyWhenAllHigherFieldsAreEqual() { + GridMap map = map(); + RouteCandidate occupied = new RouteCandidate(Arrays.asList("A", "B", "D"), 2.0d); + RouteCandidate reserved = new RouteCandidate(Arrays.asList("A", "C", "D"), 2.8d); + TrafficRouteSnapshot snapshot = TrafficRouteSnapshot.builder() + .occupyNode("B", "OTHER-AGV") + .reserveEdge("A", "C", "OTHER-TASK") + .build(); + + RoutePlanDecision occupiedLoses = new RouteQualityEvaluator().select( + map, new RouteCandidateSet(occupied.getPath(), 2.0d, + Arrays.asList(occupied, reserved), false), + snapshot, "TASK-1", "AGV-1"); + assertEquals(reserved.getPath(), occupiedLoses.getSelectedPath()); + + RouteCandidate canonicalFirst = new RouteCandidate( + Arrays.asList("A", "B", "D"), 2.0d); + RouteCandidate canonicalSecond = new RouteCandidate( + Arrays.asList("A", "E", "D"), 2.0d); + RoutePlanDecision canonicalDecision = new RouteQualityEvaluator().select( + map, new RouteCandidateSet(canonicalFirst.getPath(), 2.0d, + Arrays.asList(canonicalSecond, canonicalFirst), false), + TrafficRouteSnapshot.empty(), "TASK-1", "AGV-1"); + assertEquals(canonicalFirst.getPath(), canonicalDecision.getSelectedPath()); + } + + @Test + void countsTurnsUsingDirectionChangesAndActiveOverlapAsUndirectedEdges() { + GridMap map = map(); + RouteCandidate turning = new RouteCandidate(Arrays.asList("A", "B", "F"), 2.0d); + TrafficRouteSnapshot snapshot = TrafficRouteSnapshot.builder() + .activeRoute("OTHER", Arrays.asList("F", "B")) + .build(); + + RouteQuality quality = new RouteQualityEvaluator().evaluate( + map, turning, snapshot, "TASK-1", "AGV-1"); + + assertEquals(1, quality.getTurnCount()); + assertEquals(1, quality.getActiveRouteOverlapEdgeCount()); + } + + private GridMap map() { + GridMap map = new GridMap(); + add(map, "A", 0, 0); + add(map, "B", 1, 0); + add(map, "C", 1, 1); + add(map, "D", 2, 0); + add(map, "E", 1, -1); + add(map, "F", 1, 1); + map.connect("A", "B"); + map.connect("B", "D"); + map.connect("A", "C"); + map.connect("C", "D"); + map.connect("A", "E"); + map.connect("E", "D"); + map.connect("B", "F"); + return map; + } + + private void add(GridMap map, String id, int x, int y) { + map.addSite(new Site(id, 1, x, y, "MAIN")); + } +}