feat(javaTest): generate bounded route candidates
This commit is contained in:
@@ -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<String> path;
|
||||
private final double physicalDistance;
|
||||
|
||||
/**
|
||||
* 保存候选路线及其坐标距离。
|
||||
*
|
||||
* @param path 包含起点和终点的站点序列
|
||||
* @param physicalDistance 沿所有相邻边累计的坐标欧氏距离
|
||||
*/
|
||||
public RouteCandidate(List<String> 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<String>(path));
|
||||
this.physicalDistance = physicalDistance;
|
||||
}
|
||||
|
||||
/** 返回车辆可以逐节点执行的不可变站点序列。 */
|
||||
public List<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<PathState> PATH_ORDER = new Comparator<PathState>() {
|
||||
@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% 范围内的候选路线。
|
||||
*
|
||||
* <p>这里只读取永久地图约束;车辆占点、边预约和活动路线是瞬时交通信息,必须留到质量评价阶段,
|
||||
* 否则“当前被占用”会被错误解释成业务上的永久无路。
|
||||
*
|
||||
* @param map 权威完整地图
|
||||
* @param source 本段路线起点
|
||||
* @param target 本段路线终点
|
||||
* @param permanentlyBlockedSites 本次业务额外确认的永久禁行站点
|
||||
* @param policy 绕行比例和候选数量边界
|
||||
* @return 最短基线和确定性候选;永久不可达时返回空候选
|
||||
*/
|
||||
public RouteCandidateSet plan(GridMap map, String source, String target,
|
||||
Set<String> permanentlyBlockedSites,
|
||||
RoutePlanningPolicy policy) {
|
||||
requireInputs(map, source, target, policy);
|
||||
|
||||
// 地图封锁与调用方给出的永久封锁合并;车辆已位于起点时必须允许它驶离。
|
||||
Set<String> blocked = new LinkedHashSet<String>(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.<String>emptySet());
|
||||
if (shortest == null) return RouteCandidateSet.unreachable();
|
||||
|
||||
double maximumDistance = shortest.distance * policy.getMaximumDetourRatio();
|
||||
List<PathState> accepted = new ArrayList<PathState>();
|
||||
accepted.add(shortest);
|
||||
PriorityQueue<PathState> alternatives = new PriorityQueue<PathState>(PATH_ORDER);
|
||||
Set<List<String>> knownPaths = new HashSet<List<String>>();
|
||||
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<String> rootPath = new ArrayList<String>(
|
||||
previous.path.subList(0, spurIndex + 1));
|
||||
String spurNode = rootPath.get(rootPath.size() - 1);
|
||||
|
||||
// 已选路线若共享同一根路径,则临时移除它的下一条有向边,迫使本轮找到新分支。
|
||||
Set<String> removedEdges = new HashSet<String>();
|
||||
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<String> removedNodes = new LinkedHashSet<String>(blocked);
|
||||
removedNodes.addAll(rootPath.subList(0, rootPath.size() - 1));
|
||||
PathState spur = shortestPath(map, spurNode, target,
|
||||
removedNodes, removedEdges);
|
||||
if (spur == null) continue;
|
||||
|
||||
List<String> complete = new ArrayList<String>(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<RouteCandidate> candidates = new ArrayList<RouteCandidate>();
|
||||
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<String> 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<String> removedNodes, Set<String> removedEdges) {
|
||||
PriorityQueue<PathState> open = new PriorityQueue<PathState>(PATH_ORDER);
|
||||
open.add(new PathState(Collections.singletonList(source), 0.0d));
|
||||
Set<String> settled = new HashSet<String>();
|
||||
|
||||
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<String> nextPath = new ArrayList<String>(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<String> path, List<String> 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<String> left, List<String> 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<String> path;
|
||||
private final double distance;
|
||||
|
||||
private PathState(List<String> path, double distance) {
|
||||
this.path = Collections.unmodifiableList(new ArrayList<String>(path));
|
||||
this.distance = distance;
|
||||
}
|
||||
|
||||
private String last() {
|
||||
return path.get(path.size() - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> shortestPath;
|
||||
private final double shortestPhysicalDistance;
|
||||
private final List<RouteCandidate> candidates;
|
||||
private final boolean candidateLimitReached;
|
||||
|
||||
RouteCandidateSet(List<String> shortestPath, double shortestPhysicalDistance,
|
||||
List<RouteCandidate> candidates, boolean candidateLimitReached) {
|
||||
this.shortestPath = Collections.unmodifiableList(new ArrayList<String>(shortestPath));
|
||||
this.shortestPhysicalDistance = shortestPhysicalDistance;
|
||||
this.candidates = Collections.unmodifiableList(
|
||||
new ArrayList<RouteCandidate>(candidates));
|
||||
this.candidateLimitReached = candidateLimitReached;
|
||||
}
|
||||
|
||||
/** 返回不考虑临时交通冲突时的坐标最短路线。 */
|
||||
public List<String> getShortestPath() {
|
||||
return shortestPath;
|
||||
}
|
||||
|
||||
/** 返回物理最短路线的坐标距离;永久不可达时为正无穷。 */
|
||||
public double getShortestPhysicalDistance() {
|
||||
return shortestPhysicalDistance;
|
||||
}
|
||||
|
||||
/** 返回距离边界内按“距离、规范站点序列”排序的无环候选。 */
|
||||
public List<RouteCandidate> getCandidates() {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/** 返回是否还有合格候选因数量上限未被保留。 */
|
||||
public boolean isCandidateLimitReached() {
|
||||
return candidateLimitReached;
|
||||
}
|
||||
|
||||
/** 构造“完整地图永久不可达”的结果,不把临时交通占用误报为无路。 */
|
||||
static RouteCandidateSet unreachable() {
|
||||
return new RouteCandidateSet(Collections.<String>emptyList(),
|
||||
Double.POSITIVE_INFINITY, Collections.<RouteCandidate>emptyList(), false);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.<String>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.<String>emptySet(), policy);
|
||||
RouteCandidateSet second = planner.plan(
|
||||
map, "A", "Z", Collections.<String>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.<String>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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user