diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidate.java b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidate.java new file mode 100644 index 0000000..8440898 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidate.java @@ -0,0 +1,56 @@ +package com.ft546.wcs.demo.traffic; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** 一条地图合法、无环且处于允许绕行范围内的候选路线。 */ +public final class RouteCandidate { + private final List path; + private final double physicalDistance; + + /** + * 保存候选路线及其坐标距离。 + * + * @param path 包含起点和终点的站点序列 + * @param physicalDistance 沿所有相邻边累计的坐标欧氏距离 + */ + public RouteCandidate(List path, double physicalDistance) { + if (path == null || path.isEmpty()) { + throw new IllegalArgumentException("candidate path is required"); + } + if (Double.isNaN(physicalDistance) || Double.isInfinite(physicalDistance) + || physicalDistance < 0.0d) { + throw new IllegalArgumentException("candidate distance must be finite and >= 0"); + } + this.path = Collections.unmodifiableList(new ArrayList(path)); + this.physicalDistance = physicalDistance; + } + + /** 返回车辆可以逐节点执行的不可变站点序列。 */ + public List getPath() { + return path; + } + + /** 返回路线的坐标距离;该值只衡量路线,不使用故障或等待时间。 */ + public double getPhysicalDistance() { + return physicalDistance; + } + + /** 按完整业务值比较候选,便于确定性回放测试直接核对结果。 */ + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof RouteCandidate)) return false; + RouteCandidate that = (RouteCandidate) other; + return Double.compare(physicalDistance, that.physicalDistance) == 0 + && path.equals(that.path); + } + + /** 为候选业务值生成与 {@link #equals(Object)} 一致的哈希值。 */ + @Override + public int hashCode() { + return Objects.hash(path, physicalDistance); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidatePlanner.java b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidatePlanner.java new file mode 100644 index 0000000..efd55e3 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidatePlanner.java @@ -0,0 +1,223 @@ +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.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.PriorityQueue; +import java.util.Set; + +/** 在永久地图约束内生成确定性的 K 条最短无环路线。 */ +public final class RouteCandidatePlanner { + private static final double EPSILON = 0.000000001d; + + private static final Comparator PATH_ORDER = new Comparator() { + @Override + public int compare(PathState left, PathState right) { + int distance = Double.compare(left.distance, right.distance); + return distance != 0 ? distance : comparePaths(left.path, right.path); + } + }; + + /** + * 为接近段或服务段生成物理最短基线及 110% 范围内的候选路线。 + * + *

这里只读取永久地图约束;车辆占点、边预约和活动路线是瞬时交通信息,必须留到质量评价阶段, + * 否则“当前被占用”会被错误解释成业务上的永久无路。 + * + * @param map 权威完整地图 + * @param source 本段路线起点 + * @param target 本段路线终点 + * @param permanentlyBlockedSites 本次业务额外确认的永久禁行站点 + * @param policy 绕行比例和候选数量边界 + * @return 最短基线和确定性候选;永久不可达时返回空候选 + */ + public RouteCandidateSet plan(GridMap map, String source, String target, + Set permanentlyBlockedSites, + RoutePlanningPolicy policy) { + requireInputs(map, source, target, policy); + + // 地图封锁与调用方给出的永久封锁合并;车辆已位于起点时必须允许它驶离。 + Set blocked = new LinkedHashSet(map.getBlockedSites()); + if (permanentlyBlockedSites != null) blocked.addAll(permanentlyBlockedSites); + blocked.remove(source); + if (blocked.contains(target)) return RouteCandidateSet.unreachable(); + + PathState shortest = shortestPath(map, source, target, blocked, + Collections.emptySet()); + if (shortest == null) return RouteCandidateSet.unreachable(); + + double maximumDistance = shortest.distance * policy.getMaximumDetourRatio(); + List accepted = new ArrayList(); + accepted.add(shortest); + PriorityQueue alternatives = new PriorityQueue(PATH_ORDER); + Set> knownPaths = new HashSet>(); + knownPaths.add(shortest.path); + + // Yen 算法逐个偏离上一条最短路线;候选池始终以距离和完整节点序列稳定排序。 + while (accepted.size() <= policy.getMaximumCandidates()) { + PathState previous = accepted.get(accepted.size() - 1); + for (int spurIndex = 0; spurIndex < previous.path.size() - 1; spurIndex++) { + List rootPath = new ArrayList( + previous.path.subList(0, spurIndex + 1)); + String spurNode = rootPath.get(rootPath.size() - 1); + + // 已选路线若共享同一根路径,则临时移除它的下一条有向边,迫使本轮找到新分支。 + Set removedEdges = new HashSet(); + for (PathState path : accepted) { + if (path.path.size() > spurIndex + && hasPrefix(path.path, rootPath) + && path.path.size() > spurIndex + 1) { + removedEdges.add(directedEdge(path.path.get(spurIndex), + path.path.get(spurIndex + 1))); + } + } + + // 根路径中已经经过的点不能在支路再次出现,从算法层面保证车辆路线无环。 + Set removedNodes = new LinkedHashSet(blocked); + removedNodes.addAll(rootPath.subList(0, rootPath.size() - 1)); + PathState spur = shortestPath(map, spurNode, target, + removedNodes, removedEdges); + if (spur == null) continue; + + List complete = new ArrayList(rootPath); + complete.addAll(spur.path.subList(1, spur.path.size())); + double distance = pathDistance(map, complete); + if (distance > maximumDistance + EPSILON || !knownPaths.add(complete)) continue; + alternatives.add(new PathState(complete, distance)); + } + + if (alternatives.isEmpty()) break; + accepted.add(alternatives.remove()); + } + + boolean limitReached = accepted.size() > policy.getMaximumCandidates() + || (!alternatives.isEmpty() + && accepted.size() >= policy.getMaximumCandidates()); + int retainedCount = Math.min(accepted.size(), policy.getMaximumCandidates()); + List candidates = new ArrayList(); + for (int i = 0; i < retainedCount; i++) { + PathState path = accepted.get(i); + candidates.add(new RouteCandidate(path.path, path.distance)); + } + return new RouteCandidateSet(shortest.path, shortest.distance, + candidates, limitReached); + } + + /** + * 累计一条已知合法路径的坐标欧氏距离。 + * + * @param map 提供站点坐标和邻接关系的权威地图 + * @param path 要核算的连续站点序列 + * @return 地图坐标单位下的总距离 + */ + public double pathDistance(GridMap map, List path) { + if (map == null || path == null || path.isEmpty()) { + throw new IllegalArgumentException("map and path are required"); + } + double total = 0.0d; + for (int index = 1; index < path.size(); index++) { + String fromId = path.get(index - 1); + String toId = path.get(index); + Site from = map.getSite(fromId); + Site to = map.getSite(toId); + if (from == null || to == null || !map.getNeighbors(fromId).contains(toId)) { + throw new IllegalArgumentException("path contains unknown or non-adjacent edge: " + + fromId + " -> " + toId); + } + long x = (long) to.getX() - from.getX(); + long y = (long) to.getY() - from.getY(); + total += Math.sqrt((double) x * x + (double) y * y); + } + return total; + } + + /** 用确定性 Dijkstra 求一个支路的物理最短路线。 */ + private PathState shortestPath(GridMap map, String source, String target, + Set removedNodes, Set removedEdges) { + PriorityQueue open = new PriorityQueue(PATH_ORDER); + open.add(new PathState(Collections.singletonList(source), 0.0d)); + Set settled = new HashSet(); + + while (!open.isEmpty()) { + PathState current = open.remove(); + String node = current.last(); + if (!settled.add(node)) continue; + if (node.equals(target)) return current; + + for (String neighbor : map.getNeighbors(node)) { + if (removedNodes.contains(neighbor) + || removedEdges.contains(directedEdge(node, neighbor)) + || current.path.contains(neighbor)) { + continue; + } + List nextPath = new ArrayList(current.path); + nextPath.add(neighbor); + open.add(new PathState(nextPath, + current.distance + edgeDistance(map, node, neighbor))); + } + } + return null; + } + + /** 计算一条权威邻接边的坐标距离。 */ + private double edgeDistance(GridMap map, String fromId, String toId) { + Site from = map.getSite(fromId); + Site to = map.getSite(toId); + long x = (long) to.getX() - from.getX(); + long y = (long) to.getY() - from.getY(); + return Math.sqrt((double) x * x + (double) y * y); + } + + /** 判断已选路线是否与当前偏离位置共享完全相同的根路径。 */ + private static boolean hasPrefix(List path, List prefix) { + if (path.size() < prefix.size()) return false; + for (int index = 0; index < prefix.size(); index++) { + if (!path.get(index).equals(prefix.get(index))) return false; + } + return true; + } + + /** 以有向键区分 A→B 和 B→A,供 Yen 支路搜索临时排除已用分支。 */ + private static String directedEdge(String from, String to) { + return from + "\u0000" + to; + } + + /** 在距离相同时按完整站点 ID 序列决胜,保证相同输入反复回放结果一致。 */ + private static int comparePaths(List left, List right) { + int common = Math.min(left.size(), right.size()); + for (int index = 0; index < common; index++) { + int compared = left.get(index).compareTo(right.get(index)); + if (compared != 0) return compared; + } + return Integer.compare(left.size(), right.size()); + } + + /** 检查规划入口;未知业务端点属于输入错误,不伪装成地图不可达。 */ + private void requireInputs(GridMap map, String source, String target, + RoutePlanningPolicy policy) { + if (map == null || source == null || target == null || policy == null + || map.getSite(source) == null || map.getSite(target) == null) { + throw new IllegalArgumentException("map, known endpoints and policy are required"); + } + } + + /** Dijkstra/Yen 内部使用的不可变路线状态。 */ + private static final class PathState { + private final List path; + private final double distance; + + private PathState(List path, double distance) { + this.path = Collections.unmodifiableList(new ArrayList(path)); + this.distance = distance; + } + + private String last() { + return path.get(path.size() - 1); + } + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidateSet.java b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidateSet.java new file mode 100644 index 0000000..4ba7e8a --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteCandidateSet.java @@ -0,0 +1,48 @@ +package com.ft546.wcs.demo.traffic; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** 一次起终点规划产生的物理最短基线和有界候选集合。 */ +public final class RouteCandidateSet { + private final List shortestPath; + private final double shortestPhysicalDistance; + private final List candidates; + private final boolean candidateLimitReached; + + RouteCandidateSet(List shortestPath, double shortestPhysicalDistance, + List candidates, boolean candidateLimitReached) { + this.shortestPath = Collections.unmodifiableList(new ArrayList(shortestPath)); + this.shortestPhysicalDistance = shortestPhysicalDistance; + this.candidates = Collections.unmodifiableList( + new ArrayList(candidates)); + this.candidateLimitReached = candidateLimitReached; + } + + /** 返回不考虑临时交通冲突时的坐标最短路线。 */ + public List getShortestPath() { + return shortestPath; + } + + /** 返回物理最短路线的坐标距离;永久不可达时为正无穷。 */ + public double getShortestPhysicalDistance() { + return shortestPhysicalDistance; + } + + /** 返回距离边界内按“距离、规范站点序列”排序的无环候选。 */ + public List getCandidates() { + return candidates; + } + + /** 返回是否还有合格候选因数量上限未被保留。 */ + public boolean isCandidateLimitReached() { + return candidateLimitReached; + } + + /** 构造“完整地图永久不可达”的结果,不把临时交通占用误报为无路。 */ + static RouteCandidateSet unreachable() { + return new RouteCandidateSet(Collections.emptyList(), + Double.POSITIVE_INFINITY, Collections.emptyList(), false); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RoutePlanningPolicy.java b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RoutePlanningPolicy.java new file mode 100644 index 0000000..6b47406 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RoutePlanningPolicy.java @@ -0,0 +1,40 @@ +package com.ft546.wcs.demo.traffic; + +/** 定义中心调度生成候选路线时必须遵守的固定业务边界。 */ +public final class RoutePlanningPolicy { + private final double maximumDetourRatio; + private final int maximumCandidates; + + /** + * 创建路线候选策略。 + * + * @param maximumDetourRatio 允许距离相对物理最短路的最大倍率,例如 1.10 表示最多绕行 10% + * @param maximumCandidates 单次决策最多保留的无环候选数,防止大地图搜索量失控 + */ + public RoutePlanningPolicy(double maximumDetourRatio, int maximumCandidates) { + if (Double.isNaN(maximumDetourRatio) || Double.isInfinite(maximumDetourRatio) + || maximumDetourRatio < 1.0d) { + throw new IllegalArgumentException("maximum detour ratio must be finite and >= 1"); + } + if (maximumCandidates <= 0) { + throw new IllegalArgumentException("maximum candidates must be positive"); + } + this.maximumDetourRatio = maximumDetourRatio; + this.maximumCandidates = maximumCandidates; + } + + /** 返回本次规划允许的最大绕行倍率。 */ + public double getMaximumDetourRatio() { + return maximumDetourRatio; + } + + /** 返回本次规划允许保留的最大候选数量。 */ + public int getMaximumCandidates() { + return maximumCandidates; + } + + /** 返回历史任务仿真统一使用的“最多绕行 10%、最多 32 条”策略。 */ + public static RoutePlanningPolicy defaultPolicy() { + return new RoutePlanningPolicy(1.10d, 32); + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/demo/traffic/RouteCandidatePlannerTest.java b/javaTest/src/test/java/com/ft546/wcs/demo/traffic/RouteCandidatePlannerTest.java new file mode 100644 index 0000000..715278a --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/demo/traffic/RouteCandidatePlannerTest.java @@ -0,0 +1,128 @@ +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 java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RouteCandidatePlannerTest { + + @Test + void keepsOnlyLooplessRoutesWithinTenPercentOfPhysicalShortestDistance() { + GridMap map = boundedMap(); + + RouteCandidateSet result = new RouteCandidatePlanner().plan( + map, "A", "Z", Collections.emptySet(), + new RoutePlanningPolicy(1.10d, 32)); + + assertEquals(Arrays.asList("A", "Z"), result.getShortestPath()); + assertEquals(Math.sqrt(101.0d), result.getShortestPhysicalDistance(), 0.000001d); + assertEquals(2, result.getCandidates().size()); + assertEquals(Arrays.asList("A", "Z"), result.getCandidates().get(0).getPath()); + assertEquals(Arrays.asList("A", "B", "C", "Z"), + result.getCandidates().get(1).getPath()); + assertEquals(11.0d, result.getCandidates().get(1).getPhysicalDistance(), 0.000001d); + assertFalse(result.isCandidateLimitReached()); + for (RouteCandidate candidate : result.getCandidates()) { + assertEquals(candidate.getPath().size(), + candidate.getPath().stream().distinct().count()); + assertTrue(candidate.getPhysicalDistance() + <= result.getShortestPhysicalDistance() * 1.10d + 0.000001d); + } + } + + @Test + void ordersEqualDistanceRoutesByCanonicalNodeSequenceAndReportsLimit() { + GridMap map = equalAlternativesMap(); + RouteCandidatePlanner planner = new RouteCandidatePlanner(); + RoutePlanningPolicy policy = new RoutePlanningPolicy(1.10d, 2); + + RouteCandidateSet first = planner.plan( + map, "A", "Z", Collections.emptySet(), policy); + RouteCandidateSet second = planner.plan( + map, "A", "Z", Collections.emptySet(), policy); + + assertEquals(Arrays.asList("A", "B", "Z"), first.getCandidates().get(0).getPath()); + assertEquals(Arrays.asList("A", "C", "Z"), first.getCandidates().get(1).getPath()); + assertTrue(first.isCandidateLimitReached()); + assertEquals(first.getCandidates(), second.getCandidates()); + } + + @Test + void permanentBlocksAreExcludedButTheOccupiedStartRemainsUsable() { + GridMap map = equalAlternativesMap(); + map.block("B"); + + RouteCandidateSet result = new RouteCandidatePlanner().plan( + map, "A", "Z", Collections.singleton("C"), + RoutePlanningPolicy.defaultPolicy()); + + assertEquals(Collections.singletonList( + new RouteCandidate(Arrays.asList("A", "D", "Z"), 10.0d)), + result.getCandidates()); + + RouteCandidateSet fromBlockedStart = new RouteCandidatePlanner().plan( + map, "B", "Z", Collections.singleton("B"), + RoutePlanningPolicy.defaultPolicy()); + assertEquals(Arrays.asList("B", "Z"), fromBlockedStart.getShortestPath()); + } + + @Test + void returnsEmptyForPermanentUnreachabilityAndRejectsUnknownEndpoints() { + GridMap map = equalAlternativesMap(); + + RouteCandidateSet unreachable = new RouteCandidatePlanner().plan( + map, "A", "Z", Arrays.asList("B", "C", "D").stream() + .collect(java.util.stream.Collectors.toSet()), + RoutePlanningPolicy.defaultPolicy()); + + assertTrue(unreachable.getCandidates().isEmpty()); + assertTrue(unreachable.getShortestPath().isEmpty()); + assertThrows(IllegalArgumentException.class, () -> new RouteCandidatePlanner().plan( + map, "UNKNOWN", "Z", Collections.emptySet(), + RoutePlanningPolicy.defaultPolicy())); + } + + private GridMap boundedMap() { + GridMap map = new GridMap(); + add(map, "A", 0, 0); + add(map, "Z", 10, 1); + add(map, "B", 5, 0); + add(map, "C", 5, 1); + add(map, "D", 5, -1); + add(map, "E", 10, -1); + map.connect("A", "Z"); + map.connect("A", "B"); + map.connect("B", "C"); + map.connect("C", "Z"); + map.connect("A", "D"); + map.connect("D", "E"); + map.connect("E", "Z"); + return map; + } + + private GridMap equalAlternativesMap() { + GridMap map = new GridMap(); + add(map, "A", 0, 0); + add(map, "B", 5, 0); + add(map, "C", 5, 0); + add(map, "D", 5, 0); + add(map, "Z", 10, 0); + for (String middle : Arrays.asList("B", "C", "D")) { + map.connect("A", middle); + map.connect(middle, "Z"); + } + return map; + } + + private void add(GridMap map, String id, int x, int y) { + map.addSite(new Site(id, 1, x, y, "MAIN")); + } +}