feat(javaTest): generate bounded route candidates

This commit is contained in:
zgw
2026-08-06 17:15:04 +08:00
parent bc3bfcabf1
commit fe079a10a4
5 changed files with 495 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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"));
}
}