From 58de793143ec91be1ee1790e972f63584e47f985 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Thu, 6 Aug 2026 18:16:17 +0800 Subject: [PATCH] feat(javaTest): report auditable route quality evidence --- ...toricalDispatchSimulationReportWriter.java | 93 ++++++++++-- .../HistoricalDispatchSimulator.java | 34 ++++- .../simulation/RoutePlanningEvidence.java | 16 ++ .../visualizer/HistoricalRouteComparison.java | 43 ++++++ .../HistoricalRouteQualityMapper.java | 109 ++++++++++++++ .../SimulationVisualizationMapper.java | 141 +++++++++++++++++- ...calDispatchSimulationReportWriterTest.java | 17 ++- .../HistoricalRouteQualityMapperTest.java | 93 ++++++++++++ .../SimulationVisualizationMapperTest.java | 14 ++ 9 files changed, 542 insertions(+), 18 deletions(-) create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalRouteComparison.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalRouteQualityMapper.java create mode 100644 javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalRouteQualityMapperTest.java diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriter.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriter.java index 3ee62a5..9f2f673 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriter.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriter.java @@ -3,6 +3,7 @@ package com.ft546.wcs.shadow.history.simulation; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.ft546.wcs.shadow.history.HistoricalQueueDataset; +import com.ft546.wcs.shadow.history.visualizer.SimulationVisualizationMapper; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -42,14 +43,21 @@ public final class HistoricalDispatchSimulationReportWriter { throw new IllegalArgumentException("output, dataset and result are required"); } Files.createDirectories(outputDir); - // 摘要回答总体调度效果,两个 CSV 保留逐任务与逐 tick 的可追溯证据。 - MAPPER.writeValue(outputDir.resolve("summary.json").toFile(), summary(dataset, result)); - writeTaskCsv(outputDir.resolve("task-results.csv"), result); + // 用仿真时的同一输入地图解释历史路径,确保报告与浏览器采用完全相同的公平比较口径。 + HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(dataset); + Map routeDecisions = + new SimulationVisualizationMapper(dataset).result(input, result); + + // 摘要回答总体调度效果,决策 JSON 和两个 CSV 保留逐任务、候选路径与逐 tick 证据。 + MAPPER.writeValue(outputDir.resolve("summary.json").toFile(), + summary(dataset, result, routeDecisions)); + MAPPER.writeValue(outputDir.resolve("route-decisions.json").toFile(), routeDecisions); + writeTaskCsv(outputDir.resolve("task-results.csv"), result, routeDecisions); writeEventCsv(outputDir.resolve("tick-events.csv"), result); MAPPER.writeValue(outputDir.resolve("invariant-results.json").toFile(), invariantSummary(result)); - writeManifest(outputDir, Arrays.asList("invariant-results.json", "summary.json", - "task-results.csv", "tick-events.csv")); + writeManifest(outputDir, Arrays.asList("invariant-results.json", "route-decisions.json", + "summary.json", "task-results.csv", "tick-events.csv")); } /** @@ -59,7 +67,8 @@ public final class HistoricalDispatchSimulationReportWriter { * @return 可序列化为 {@code summary.json} 的业务指标字典。 */ private Map summary(HistoricalQueueDataset dataset, - HistoricalDispatchSimulationResult result) { + HistoricalDispatchSimulationResult result, + Map routeDecisions) { Map value = new LinkedHashMap(); value.put("mode", "REAL_HISTORY_DRIVEN_DISPATCH_SIMULATION"); value.put("boundary", "真实历史负载驱动纯内存仿真,不是现场 SHADOW/HIL/ACTIVE 验收"); @@ -70,7 +79,7 @@ public final class HistoricalDispatchSimulationReportWriter { value.put("passableMapRows", dataset.getFullMapCells().stream() .filter(cell -> cell.getValue() == 1 || cell.getValue() == 2).count()); value.put("fullMapSha256", dataset.getFullMapSha256()); - value.put("plannerVersion", "GridPathPlanner-BFS/full-grid-v1"); + value.put("plannerVersion", routeDecisions.get("plannerVersion")); value.put("arrivalTimingPolicy", "create_date-relative-gap/acceleration-no-truncation"); value.put("pathInfoUsedForPlanning", false); @@ -92,6 +101,12 @@ public final class HistoricalDispatchSimulationReportWriter { value.put("holdReasons", aggregateReasons(result, true)); value.put("retryReasons", aggregateReasons(result, false)); value.put("failureReasons", failureReasons(result)); + // 路线比较必须来自已存储的初次规划证据,不能在报表层重新运行规划算法。 + Map routeSummary = mapValue(routeDecisions.get("summary")); + copy(value, routeSummary, "routeComparabilityCounts"); + copy(value, routeSummary, "routeQualityCounts"); + copy(value, routeSummary, "rerouteCount"); + copy(value, routeSummary, "candidateLimitReachedCount"); value.put("archiveCount", result.getArchiveCount()); value.put("wmsDeliveryAttempts", result.getWmsDeliveryAttempts()); value.put("wmsBusinessEffects", result.getWmsBusinessEffects()); @@ -245,7 +260,8 @@ public final class HistoricalDispatchSimulationReportWriter { * @param result 执行该处理所需的 result 输入。 * @throws IOException 当调用条件不满足或处理无法继续时抛出。 */ - private void writeTaskCsv(Path path, HistoricalDispatchSimulationResult result) + private void writeTaskCsv(Path path, HistoricalDispatchSimulationResult result, + Map routeDecisions) throws IOException { StringBuilder csv = new StringBuilder("\ufeff"); row(csv, "simulation_task_id", "task_no", "guid", "task_type", "priority", @@ -255,7 +271,9 @@ public final class HistoricalDispatchSimulationReportWriter { "arrival_ms", "first_assigned_ms", "completed_ms", "waiting_ms", "historical_duration_ms", "simulation_duration_ms", "scaled_source_duration_ms", "duration_difference_ms", "hold_count", "protocol_attempts", "retry_count", - "hold_reasons", "retry_reasons", "vehicle_changed", "path_changed"); + "hold_reasons", "retry_reasons", "vehicle_changed", "path_changed", + "route_comparability", "route_quality_comparison", "reroute_count"); + Map> routeTasks = routeTasks(routeDecisions); for (SimulationTaskResult task : result.getTaskResults()) { // 任务行同时保留历史车/路径和新车/路径,供判断中心调度到底改变了什么。 Long historical = historicalDuration(task); @@ -268,6 +286,7 @@ public final class HistoricalDispatchSimulationReportWriter { ? "" : task.getPlannedPath().get(0); String physicalEnd = task.getPlannedPath().isEmpty() ? "" : task.getPlannedPath().get(task.getPlannedPath().size() - 1); + Map routeTask = routeTasks.get(task.getSimulationTaskId()); row(csv, task.getSimulationTaskId(), task.getRecord().getTaskNo(), task.getRecord().getGuid(), task.getRecord().getTaskType(), task.getRecord().getPriority(), task.getRecord().getStartSite(), @@ -281,11 +300,65 @@ public final class HistoricalDispatchSimulationReportWriter { task.getHoldReasons(), task.getRetryReasons(), task.getSimulatedVehicleId() != null && !task.getSimulatedVehicleId().equals(task.getRecord().getAgvId()), - !historicalPath.equals(plannedPath)); + !historicalPath.equals(plannedPath), + field(routeTask, "routeComparability"), + field(routeTask, "routeQualityComparison"), + field(routeTask, "rerouteCount")); } Files.write(path, csv.toString().getBytes(StandardCharsets.UTF_8)); } + /** + * 按仿真任务 ID 建立路线决策索引,供 CSV 在原任务顺序下补充公平比较和重规划结果。 + * + *

索引只读取 mapper 已形成的证据,不改变任务排序,也不重新计算路径质量。 + * @param routeDecisions 路线决策 JSON 对应的数据结构。 + * @return 以 simulationTaskId 为键的逐任务路线证据。 + */ + private Map> routeTasks(Map routeDecisions) { + Map> values = + new LinkedHashMap>(); + Object tasks = routeDecisions.get("tasks"); + if (!(tasks instanceof List)) return values; + for (Object item : (List) tasks) { + Map task = mapValue(item); + Object id = task.get("simulationTaskId"); + if (id != null) values.put(String.valueOf(id), task); + } + return values; + } + + /** + * 读取路线任务中的单个可选字段;不可比的历史路径会自然输出为空 CSV 单元格。 + * @param values 一条任务的路线证据。 + * @param name JSON 字段名。 + * @return 字段值;字段缺失时返回 {@code null}。 + */ + private Object field(Map values, String name) { + return values == null ? null : values.get(name); + } + + /** + * 将 Jackson 可序列化对象安全视为字符串键字典,处理缺失或非字典值时返回空字典。 + * @param value 待读取的路线证据节点。 + * @return 可供报告读取的字典视图。 + */ + @SuppressWarnings("unchecked") + private Map mapValue(Object value) { + if (!(value instanceof Map)) return Collections.emptyMap(); + return (Map) value; + } + + /** + * 把可视化汇总中的一个权威路线指标复制到文本报告,保持两个出口字段和值一致。 + * @param target summary.json 的汇总字典。 + * @param source 可视化 mapper 形成的汇总字典。 + * @param name 要复制的业务字段名。 + */ + private void copy(Map target, Map source, String name) { + target.put(name, source.get(name)); + } + /** * 写出逐 tick 的 ASSIGN、MOVE、HOLD、清障、PLC 重试和完成事件,支持还原调度过程。 * @param path 执行该处理所需的 path 输入。 diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulator.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulator.java index 5304919..fc64b12 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulator.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulator.java @@ -396,7 +396,9 @@ public final class HistoricalDispatchSimulator { execution.pathVersion, now, false, approachDecision)); execution.planningEvidence.add(new RoutePlanningEvidence( RoutePlanningEvidence.PlanningStage.SERVICE, - execution.pathVersion, now, false, serviceDecision)); + execution.pathVersion, now, false, serviceDecision, + historicalReferenceQuality(task, input, traffic, routePlanner, + routeEvaluator, vehicleId))); active.put(vehicleId, new ActiveTask(execution, route, approachDecision.getSelectedPath().size() - 1)); queue.remove(task.getSimulationTaskId()); @@ -473,6 +475,36 @@ public final class HistoricalDispatchSimulator { return snapshot.build(); } + /** + * 在初次服务段规划时,用同一交通快照评价合法且端点一致的历史参考路径。 + */ + private RouteQuality historicalReferenceQuality( + HistoricalSimulationTask task, + HistoricalSimulationInput input, + TrafficRouteSnapshot snapshot, + RouteCandidatePlanner routePlanner, + RouteQualityEvaluator routeEvaluator, + String vehicleId) { + List historical = task.getHistoricalNodes(); + if (historical.isEmpty() + || !task.getPhysicalSource().equals(historical.get(0)) + || !task.getPhysicalTarget().equals( + historical.get(historical.size() - 1))) { + return null; + } + for (String site : historical) { + if (input.getGridMap().getSite(site) == null) return null; + } + for (int index = 1; index < historical.size(); index++) { + if (!input.getGridMap().getNeighbors(historical.get(index - 1)) + .contains(historical.get(index))) return null; + } + double distance = routePlanner.pathDistance(input.getGridMap(), historical); + return routeEvaluator.evaluate(input.getGridMap(), + new RouteCandidate(historical, distance), snapshot, + task.getSimulationTaskId(), vehicleId); + } + /** * 推进全部已分配任务的下一条边,并在移动前依次执行方向冲突、清障、交通决策、资源台账和 PLC 确认。 * diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/RoutePlanningEvidence.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/RoutePlanningEvidence.java index 87e0a39..668703f 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/RoutePlanningEvidence.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/RoutePlanningEvidence.java @@ -1,6 +1,7 @@ package com.ft546.wcs.shadow.history.simulation; import com.ft546.wcs.demo.traffic.RoutePlanDecision; +import com.ft546.wcs.demo.traffic.RouteQuality; /** 保存一次接近段或服务段路线选择的不可变业务证据。 */ public final class RoutePlanningEvidence { @@ -11,6 +12,7 @@ public final class RoutePlanningEvidence { private final long simulationMillis; private final boolean reroute; private final RoutePlanDecision decision; + private final RouteQuality historicalReferenceQuality; /** * 记录一次已实际采用的中心规划决定。 @@ -24,6 +26,16 @@ public final class RoutePlanningEvidence { public RoutePlanningEvidence(PlanningStage stage, int pathVersion, long simulationMillis, boolean reroute, RoutePlanDecision decision) { + this(stage, pathVersion, simulationMillis, reroute, decision, null); + } + + /** + * 记录服务段决定及历史参考路线在同一交通快照下的质量;不可比较时历史质量为 {@code null}。 + */ + public RoutePlanningEvidence(PlanningStage stage, int pathVersion, + long simulationMillis, boolean reroute, + RoutePlanDecision decision, + RouteQuality historicalReferenceQuality) { if (stage == null || pathVersion <= 0 || simulationMillis < 0L || decision == null) { throw new IllegalArgumentException("valid route planning evidence is required"); } @@ -32,6 +44,7 @@ public final class RoutePlanningEvidence { this.simulationMillis = simulationMillis; this.reroute = reroute; this.decision = decision; + this.historicalReferenceQuality = historicalReferenceQuality; } /** 返回本次决定对应接近段还是服务段。 */ @@ -48,4 +61,7 @@ public final class RoutePlanningEvidence { /** 返回候选、质量和选择原因。 */ public RoutePlanDecision getDecision() { return decision; } + + /** 返回历史参考路线在同一规划快照下的质量,不可比较时为 {@code null}。 */ + public RouteQuality getHistoricalReferenceQuality() { return historicalReferenceQuality; } } diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalRouteComparison.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalRouteComparison.java new file mode 100644 index 0000000..7b69f93 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalRouteComparison.java @@ -0,0 +1,43 @@ +package com.ft546.wcs.shadow.history.visualizer; + +/** 一条历史参考路线与中心调度服务路线的公平比较结论。 */ +public final class HistoricalRouteComparison { + public enum Comparability { + COMPARABLE, ENDPOINT_MISMATCH, INVALID_HISTORY_PATH + } + + public enum QualityCategory { + EXACT_MATCH, + SHORTER_PHYSICAL_DISTANCE, + SAME_DISTANCE_BETTER_QUALITY, + BOUNDED_DETOUR_FEWER_CONFLICTS, + WORSE_QUALITY + } + + private final Comparability comparability; + private final QualityCategory qualityCategory; + private final Double historicalPhysicalDistance; + private final Double plannedPhysicalDistance; + + HistoricalRouteComparison(Comparability comparability, + QualityCategory qualityCategory, + Double historicalPhysicalDistance, + Double plannedPhysicalDistance) { + this.comparability = comparability; + this.qualityCategory = qualityCategory; + this.historicalPhysicalDistance = historicalPhysicalDistance; + this.plannedPhysicalDistance = plannedPhysicalDistance; + } + + /** 返回历史路径是否具备相同业务端点和完整权威地图连续边。 */ + public Comparability getComparability() { return comparability; } + + /** 返回可比较任务的质量分类;不可比较时为 {@code null}。 */ + public QualityCategory getQualityCategory() { return qualityCategory; } + + /** 返回历史路线坐标距离;不可比较时为 {@code null}。 */ + public Double getHistoricalPhysicalDistance() { return historicalPhysicalDistance; } + + /** 返回新服务路线坐标距离;不可比较时为 {@code null}。 */ + public Double getPlannedPhysicalDistance() { return plannedPhysicalDistance; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalRouteQualityMapper.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalRouteQualityMapper.java new file mode 100644 index 0000000..c66be12 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalRouteQualityMapper.java @@ -0,0 +1,109 @@ +package com.ft546.wcs.shadow.history.visualizer; + +import com.ft546.wcs.demo.traffic.GridMap; +import com.ft546.wcs.demo.traffic.RouteCandidatePlanner; +import com.ft546.wcs.demo.traffic.RouteQuality; + +import java.util.List; + +/** 按权威地图和同一规划快照的质量证据对历史服务路径进行公平分类。 */ +final class HistoricalRouteQualityMapper { + private static final double EPSILON = 0.000001d; + private final RouteCandidatePlanner distanceCalculator = new RouteCandidatePlanner(); + + /** + * 比较历史参考路径与本次最终服务路径。 + * + *

先检查路径数据是否合法,再检查历史首尾是否等于解析后的物理业务端点。只有两项都通过才允许 + * 进入优化质量统计,避免把历史端点口径不同或脏路径误算成新算法收益。 + */ + HistoricalRouteComparison compare(GridMap map, String physicalSource, + String physicalTarget, + List historicalPath, + List plannedPath, + RouteQuality historicalQuality, + RouteQuality plannedQuality) { + if (map == null || physicalSource == null || physicalTarget == null + || plannedPath == null) { + throw new IllegalArgumentException("map, physical endpoints and planned path required"); + } + if (!isValidPath(map, historicalPath)) { + return new HistoricalRouteComparison( + HistoricalRouteComparison.Comparability.INVALID_HISTORY_PATH, + null, null, null); + } + if (!physicalSource.equals(historicalPath.get(0)) + || !physicalTarget.equals(historicalPath.get(historicalPath.size() - 1))) { + return new HistoricalRouteComparison( + HistoricalRouteComparison.Comparability.ENDPOINT_MISMATCH, + null, null, null); + } + + double historicalDistance = distanceCalculator.pathDistance(map, historicalPath); + if (!isValidPath(map, plannedPath)) { + return new HistoricalRouteComparison( + HistoricalRouteComparison.Comparability.COMPARABLE, + HistoricalRouteComparison.QualityCategory.WORSE_QUALITY, + historicalDistance, null); + } + double plannedDistance = distanceCalculator.pathDistance(map, plannedPath); + HistoricalRouteComparison.QualityCategory category; + if (historicalPath.equals(plannedPath)) { + category = HistoricalRouteComparison.QualityCategory.EXACT_MATCH; + } else if (plannedDistance < historicalDistance - EPSILON) { + category = HistoricalRouteComparison.QualityCategory.SHORTER_PHYSICAL_DISTANCE; + } else if (Math.abs(plannedDistance - historicalDistance) <= EPSILON + && betterThroughTurns(plannedQuality, historicalQuality)) { + category = HistoricalRouteComparison.QualityCategory.SAME_DISTANCE_BETTER_QUALITY; + } else if (plannedDistance > historicalDistance + EPSILON + && plannedDistance <= historicalDistance * 1.10d + EPSILON + && betterThroughTraffic(plannedQuality, historicalQuality)) { + category = HistoricalRouteComparison.QualityCategory.BOUNDED_DETOUR_FEWER_CONFLICTS; + } else { + category = HistoricalRouteComparison.QualityCategory.WORSE_QUALITY; + } + return new HistoricalRouteComparison( + HistoricalRouteComparison.Comparability.COMPARABLE, + category, historicalDistance, plannedDistance); + } + + /** 历史路径必须有首尾、所有站点存在且每一段都是真实邻接边。 */ + private boolean isValidPath(GridMap map, List path) { + if (path == null || path.isEmpty()) return false; + for (String siteId : path) { + if (map.getSite(siteId) == null) return false; + } + for (int index = 1; index < path.size(); index++) { + if (!map.getNeighbors(path.get(index - 1)).contains(path.get(index))) return false; + } + return true; + } + + /** 比较交通风险和转弯五项,不让距离或站点字典序伪装成“质量改善”。 */ + private boolean betterThroughTurns(RouteQuality planned, RouteQuality historical) { + if (planned == null || historical == null) return false; + int comparison = compareTraffic(planned, historical); + return comparison < 0 || comparison == 0 + && planned.getTurnCount() < historical.getTurnCount(); + } + + /** 绕行只有在四项交通冲突元组严格改善时才算有意义。 */ + private boolean betterThroughTraffic(RouteQuality planned, RouteQuality historical) { + return planned != null && historical != null + && compareTraffic(planned, historical) < 0; + } + + /** 按与调度器相同的顺序比较四项实时交通风险。 */ + private int compareTraffic(RouteQuality left, RouteQuality right) { + int result = Integer.compare(left.getOppositeDirectionConflictCount(), + right.getOppositeDirectionConflictCount()); + if (result != 0) return result; + result = Integer.compare(left.getOccupiedNodeCount(), right.getOccupiedNodeCount()); + if (result != 0) return result; + result = Integer.compare(left.getIncompatibleReservedEdgeCount(), + right.getIncompatibleReservedEdgeCount()); + if (result != 0) return result; + return Integer.compare(left.getActiveRouteOverlapEdgeCount(), + right.getActiveRouteOverlapEdgeCount()); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapper.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapper.java index 4eddc6d..7d65773 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapper.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapper.java @@ -8,11 +8,15 @@ import com.ft546.wcs.shadow.history.HistoricalQueueRecord; import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationResult; import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput; import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationTask; +import com.ft546.wcs.shadow.history.simulation.RoutePlanningEvidence; import com.ft546.wcs.shadow.history.simulation.SimulationInvariantSummary; import com.ft546.wcs.shadow.history.simulation.SimulationMetrics; import com.ft546.wcs.shadow.history.simulation.SimulationTaskResult; import com.ft546.wcs.shadow.history.simulation.SimulationTaskStatus; import com.ft546.wcs.shadow.history.simulation.SimulationTickEvent; +import com.ft546.wcs.demo.traffic.RouteCandidateEvaluation; +import com.ft546.wcs.demo.traffic.RoutePlanDecision; +import com.ft546.wcs.demo.traffic.RouteQuality; import java.time.Duration; import java.time.LocalDateTime; @@ -37,13 +41,16 @@ public final class SimulationVisualizationMapper { private static final String MODE = "OFFLINE_IN_MEMORY_SIMULATION"; private static final String BOUNDARY = "纯内存离线仿真,不连接生产 SQL Server、PLC 或 WMS,也不替代现场 HIL/ACTIVE 验收"; - private static final String PLANNER_VERSION = "GridPathPlanner-BFS/full-grid-v1"; + private static final String PLANNER_VERSION = + "BoundedConflictAwareRoutePlanner/euclidean-110pct-k32-v1"; private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss"); private final HistoricalQueueDataset dataset; private final RouteTopologyEvidenceMapper routeTopology = new RouteTopologyEvidenceMapper(); + private final HistoricalRouteQualityMapper routeQuality = + new HistoricalRouteQualityMapper(); public SimulationVisualizationMapper(HistoricalQueueDataset dataset) { if (dataset == null) throw new IllegalArgumentException("dataset is required"); @@ -112,7 +119,7 @@ public final class SimulationVisualizationMapper { payload.put("plannerVersion", PLANNER_VERSION); payload.put("mapSha256", dataset.getFullMapSha256()); payload.put("pathInfoUsedForPlanning", false); - payload.put("summary", summary(result)); + payload.put("summary", summary(result, definitions, input.getGridMap())); payload.put("tasks", tasks); payload.put("events", events); payload.put("invariants", invariants(result.getInvariants())); @@ -121,7 +128,9 @@ public final class SimulationVisualizationMapper { return payload; } - private Map summary(HistoricalDispatchSimulationResult result) { + private Map summary(HistoricalDispatchSimulationResult result, + Map definitions, + GridMap gridMap) { Map value = new LinkedHashMap(); value.put("inputCount", result.getTaskResults().size()); value.put("acceleration", result.getAcceleration()); @@ -141,6 +150,28 @@ public final class SimulationVisualizationMapper { value.put("holdReasons", aggregateReasons(result, true)); value.put("retryReasons", aggregateReasons(result, false)); value.put("failureReasons", failureReasons(result)); + Map comparabilityCounts = new LinkedHashMap(); + Map qualityCounts = new LinkedHashMap(); + int reroutes = 0; + int candidateLimitHits = 0; + for (SimulationTaskResult task : result.getTaskResults()) { + HistoricalSimulationTask definition = definitions.get(task.getSimulationTaskId()); + HistoricalRouteComparison comparison = comparison(task, definition, gridMap); + increment(comparabilityCounts, comparison.getComparability().name()); + if (comparison.getQualityCategory() != null) { + increment(qualityCounts, comparison.getQualityCategory().name()); + } + reroutes += task.getRerouteCount(); + for (RoutePlanningEvidence evidence : task.getPlanningEvidence()) { + if (evidence.getDecision().getCandidateSet().isCandidateLimitReached()) { + candidateLimitHits++; + } + } + } + value.put("routeComparabilityCounts", comparabilityCounts); + value.put("routeQualityCounts", qualityCounts); + value.put("rerouteCount", reroutes); + value.put("candidateLimitReachedCount", candidateLimitHits); boolean safe = result.getInvariants().totalSafetyViolations() == 0 && result.getInvariants().getDeadlocks() == 0; value.put("acceptancePassed", safe && result.getWaitingCount() == 0 @@ -199,9 +230,113 @@ public final class SimulationVisualizationMapper { value.put("vehicleChanged", task.getSimulatedVehicleId() != null && !task.getSimulatedVehicleId().equals(record.getAgvId())); value.put("pathChanged", !historicalPath.equals(task.getPlannedPath())); + HistoricalRouteComparison routeComparison = comparison(task, definition, gridMap); + value.put("routeComparability", routeComparison.getComparability().name()); + value.put("routeQualityComparison", routeComparison.getQualityCategory() == null + ? null : routeComparison.getQualityCategory().name()); + value.put("historicalPhysicalDistance", + routeComparison.getHistoricalPhysicalDistance()); + value.put("plannedPhysicalDistance", routeComparison.getPlannedPhysicalDistance()); + value.put("approachPlanningEvidence", planningEvidence(task, + RoutePlanningEvidence.PlanningStage.APPROACH)); + value.put("servicePlanningEvidence", planningEvidence(task, + RoutePlanningEvidence.PlanningStage.SERVICE)); + value.put("rerouteCount", task.getRerouteCount()); return value; } + /** 按阶段转换每次实际采用的候选、质量、距离边界和版本证据。 */ + private List> planningEvidence( + SimulationTaskResult task, RoutePlanningEvidence.PlanningStage stage) { + List> values = new ArrayList>(); + for (RoutePlanningEvidence evidence : task.getPlanningEvidence()) { + if (evidence.getStage() != stage) continue; + RoutePlanDecision decision = evidence.getDecision(); + Map value = new LinkedHashMap(); + value.put("stage", stage.name()); + value.put("pathVersion", evidence.getPathVersion()); + value.put("simulationMillis", evidence.getSimulationMillis()); + value.put("reroute", evidence.isReroute()); + value.put("shortestPath", decision.getCandidateSet().getShortestPath()); + value.put("selectedPath", decision.getSelectedPath()); + value.put("shortestPhysicalDistance", + decision.getCandidateSet().getShortestPhysicalDistance()); + value.put("selectedPhysicalDistance", + decision.getSelected().getCandidate().getPhysicalDistance()); + value.put("detourRatio", decision.getDetourRatio()); + value.put("candidateCount", decision.getEvaluations().size()); + value.put("candidateLimitReached", + decision.getCandidateSet().isCandidateLimitReached()); + value.put("decisionReason", decision.getDecisionReason()); + value.put("quality", quality(decision.getSelectedQuality())); + value.put("historicalReferenceQuality", + evidence.getHistoricalReferenceQuality() == null ? null + : quality(evidence.getHistoricalReferenceQuality())); + List> candidates = new ArrayList>(); + for (RouteCandidateEvaluation evaluation : decision.getEvaluations()) { + Map candidate = new LinkedHashMap(); + candidate.put("path", evaluation.getCandidate().getPath()); + candidate.put("physicalDistance", + evaluation.getCandidate().getPhysicalDistance()); + candidate.put("quality", quality(evaluation.getQuality())); + candidate.put("selected", evaluation == decision.getSelected()); + candidates.add(candidate); + } + value.put("candidates", candidates); + values.add(value); + } + return values; + } + + /** 把固定业务质量元组转换为有字段名的 JSON,避免前端猜测数组下标。 */ + private Map quality(RouteQuality quality) { + Map value = new LinkedHashMap(); + value.put("oppositeDirectionConflicts", + quality.getOppositeDirectionConflictCount()); + value.put("occupiedNodes", quality.getOccupiedNodeCount()); + value.put("incompatibleReservedEdges", + quality.getIncompatibleReservedEdgeCount()); + value.put("activeRouteOverlapEdges", + quality.getActiveRouteOverlapEdgeCount()); + value.put("turns", quality.getTurnCount()); + value.put("physicalDistance", quality.getPhysicalDistance()); + value.put("canonicalPath", quality.getCanonicalPath()); + return value; + } + + /** 使用初次完整服务段与其同快照历史质量生成公平比较。 */ + private HistoricalRouteComparison comparison(SimulationTaskResult task, + HistoricalSimulationTask definition, + GridMap gridMap) { + List historical = historicalPath(task.getRecord().getPathInfo()); + RoutePlanningEvidence initialService = null; + for (RoutePlanningEvidence evidence : task.getPlanningEvidence()) { + if (evidence.getStage() == RoutePlanningEvidence.PlanningStage.SERVICE + && !evidence.isReroute()) { + initialService = evidence; + break; + } + } + List planned = initialService == null ? task.getPlannedPath() + : initialService.getDecision().getSelectedPath(); + RouteQuality plannedQuality = initialService == null ? null + : initialService.getDecision().getSelectedQuality(); + RouteQuality historicalQuality = initialService == null ? null + : initialService.getHistoricalReferenceQuality(); + String source = definition == null ? task.getRecord().getStartSite() + : definition.getPhysicalSource(); + String target = definition == null ? task.getRecord().getEndSite() + : definition.getPhysicalTarget(); + return routeQuality.compare(gridMap, source, target, historical, planned, + historicalQuality, plannedQuality); + } + + /** 为稳定汇总表累计一个分类。 */ + private void increment(Map values, String key) { + Integer previous = values.get(key); + values.put(key, previous == null ? 1 : previous + 1); + } + private Map event(SimulationTickEvent event) { Map value = new LinkedHashMap(); value.put("tick", event.getTick()); diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriterTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriterTest.java index 5af3d2f..9848ec4 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriterTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriterTest.java @@ -39,8 +39,9 @@ class HistoricalDispatchSimulationReportWriterTest { new HistoricalDispatchSimulationReportWriter().write(output, dataset, result); - for (String name : new String[]{"summary.json", "task-results.csv", - "tick-events.csv", "invariant-results.json", "manifest.sha256"}) { + for (String name : new String[]{"summary.json", "route-decisions.json", + "task-results.csv", "tick-events.csv", "invariant-results.json", + "manifest.sha256"}) { assertTrue(Files.isRegularFile(output.resolve(name)), name); } JsonNode summary = new ObjectMapper().readTree(output.resolve("summary.json").toFile()); @@ -52,7 +53,7 @@ class HistoricalDispatchSimulationReportWriterTest { assertEquals(2552, summary.path("fullMapRows").asInt()); assertEquals(1175, summary.path("passableMapRows").asInt()); assertEquals(64, summary.path("fullMapSha256").asText().length()); - assertEquals("GridPathPlanner-BFS/full-grid-v1", + assertEquals("BoundedConflictAwareRoutePlanner/euclidean-110pct-k32-v1", summary.path("plannerVersion").asText()); assertEquals("create_date-relative-gap/acceleration-no-truncation", summary.path("arrivalTimingPolicy").asText()); @@ -68,6 +69,13 @@ class HistoricalDispatchSimulationReportWriterTest { assertTrue(summary.has("historicalDuration")); assertTrue(summary.has("holdReasons")); assertTrue(summary.has("retryReasons")); + JsonNode comparability = summary.path("routeComparabilityCounts"); + assertEquals(240, comparability.path("COMPARABLE").asInt() + + comparability.path("ENDPOINT_MISMATCH").asInt() + + comparability.path("INVALID_HISTORY_PATH").asInt()); + assertTrue(summary.has("routeQualityCounts")); + assertTrue(summary.has("rerouteCount")); + assertTrue(summary.has("candidateLimitReachedCount")); String taskCsv = new String(Files.readAllBytes(output.resolve("task-results.csv")), StandardCharsets.UTF_8); @@ -77,8 +85,9 @@ class HistoricalDispatchSimulationReportWriterTest { assertTrue(taskCsv.contains("historical_path,planned_path")); assertTrue(taskCsv.contains("historical_duration_ms,simulation_duration_ms")); assertTrue(taskCsv.contains("hold_reasons,retry_reasons")); + assertTrue(taskCsv.contains("route_comparability,route_quality_comparison,reroute_count")); List manifest = Files.readAllLines(output.resolve("manifest.sha256"), StandardCharsets.UTF_8); - assertEquals(4, manifest.size()); + assertEquals(5, manifest.size()); } } diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalRouteQualityMapperTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalRouteQualityMapperTest.java new file mode 100644 index 0000000..4baa306 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalRouteQualityMapperTest.java @@ -0,0 +1,93 @@ +package com.ft546.wcs.shadow.history.visualizer; + +import com.ft546.wcs.demo.model.Site; +import com.ft546.wcs.demo.traffic.GridMap; +import com.ft546.wcs.demo.traffic.RouteCandidate; +import com.ft546.wcs.demo.traffic.RouteQuality; +import com.ft546.wcs.demo.traffic.RouteQualityEvaluator; +import com.ft546.wcs.demo.traffic.TrafficRouteSnapshot; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HistoricalRouteQualityMapperTest { + + @Test + void classifiesComparableEndpointMismatchAndInvalidHistorySeparately() { + GridMap map = map(); + HistoricalRouteQualityMapper mapper = new HistoricalRouteQualityMapper(); + RouteQuality clean = quality(map, Arrays.asList("A", "B", "D"), + TrafficRouteSnapshot.empty()); + + assertEquals("COMPARABLE", mapper.compare(map, "A", "D", + Arrays.asList("A", "B", "D"), Arrays.asList("A", "B", "D"), + clean, clean).getComparability().name()); + assertEquals("EXACT_MATCH", mapper.compare(map, "A", "D", + Arrays.asList("A", "B", "D"), Arrays.asList("A", "B", "D"), + clean, clean).getQualityCategory().name()); + assertEquals("ENDPOINT_MISMATCH", mapper.compare(map, "A", "D", + Arrays.asList("B", "D"), Arrays.asList("A", "B", "D"), + clean, clean).getComparability().name()); + assertEquals("INVALID_HISTORY_PATH", mapper.compare(map, "A", "D", + Arrays.asList("B", "UNKNOWN"), Arrays.asList("A", "B", "D"), + null, clean).getComparability().name()); + } + + @Test + void recognizesSameDistanceAndBoundedDetourWhenTrafficQualityImproves() { + GridMap map = map(); + TrafficRouteSnapshot snapshot = TrafficRouteSnapshot.builder() + .occupyNode("B", "OTHER-AGV").build(); + RouteQuality historical = quality(map, Arrays.asList("A", "B", "D"), snapshot); + RouteQuality planned = quality(map, Arrays.asList("A", "C", "D"), snapshot); + + HistoricalRouteComparison comparison = new HistoricalRouteQualityMapper().compare( + map, "A", "D", Arrays.asList("A", "B", "D"), + Arrays.asList("A", "C", "D"), historical, planned); + + assertEquals(HistoricalRouteComparison.Comparability.COMPARABLE, + comparison.getComparability()); + assertEquals(HistoricalRouteComparison.QualityCategory.BOUNDED_DETOUR_FEWER_CONFLICTS, + comparison.getQualityCategory()); + } + + @Test + void recognizesShorterPhysicalDistanceBeforeLowerPriorityMetrics() { + GridMap map = map(); + RouteQuality historical = quality(map, Arrays.asList("A", "C", "D"), + TrafficRouteSnapshot.empty()); + RouteQuality planned = quality(map, Arrays.asList("A", "B", "D"), + TrafficRouteSnapshot.empty()); + + HistoricalRouteComparison comparison = new HistoricalRouteQualityMapper().compare( + map, "A", "D", Arrays.asList("A", "C", "D"), + Arrays.asList("A", "B", "D"), historical, planned); + + assertEquals(HistoricalRouteComparison.QualityCategory.SHORTER_PHYSICAL_DISTANCE, + comparison.getQualityCategory()); + } + + private RouteQuality quality(GridMap map, java.util.List path, + TrafficRouteSnapshot snapshot) { + double distance = new com.ft546.wcs.demo.traffic.RouteCandidatePlanner() + .pathDistance(map, path); + return new RouteQualityEvaluator().evaluate(map, + new RouteCandidate(path, distance), snapshot, "TASK", "AGV"); + } + + private GridMap map() { + GridMap map = new GridMap(); + map.addSite(new Site("A", 1, 0, 0, "TEST")); + map.addSite(new Site("B", 1, 5, 0, "TEST")); + map.addSite(new Site("C", 1, 5, 1, "TEST")); + map.addSite(new Site("D", 1, 10, 0, "TEST")); + map.connect("A", "B"); + map.connect("B", "D"); + map.connect("A", "C"); + map.connect("C", "D"); + return map; + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapperTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapperTest.java index bc7c900..c56e92e 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapperTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapperTest.java @@ -92,6 +92,18 @@ class SimulationVisualizationMapperTest { assertEquals(sampleTask.getPhysicalTarget(), tasks.get(0).get("physicalEndSite")); assertEquals(Boolean.TRUE, tasks.get(0).get("vehicleChanged")); assertEquals(Boolean.TRUE, tasks.get(0).get("pathChanged")); + assertEquals("INVALID_HISTORY_PATH", tasks.get(0).get("routeComparability")); + assertEquals(null, tasks.get(0).get("routeQualityComparison")); + assertTrue(listOfMaps(tasks.get(0).get("approachPlanningEvidence")).size() >= 1); + assertTrue(listOfMaps(tasks.get(0).get("servicePlanningEvidence")).size() >= 1); + Map serviceEvidence = + listOfMaps(tasks.get(0).get("servicePlanningEvidence")).get(0); + assertTrue(serviceEvidence.containsKey("shortestPhysicalDistance")); + assertTrue(serviceEvidence.containsKey("selectedPhysicalDistance")); + assertTrue(serviceEvidence.containsKey("detourRatio")); + assertTrue(serviceEvidence.containsKey("quality")); + assertTrue(serviceEvidence.containsKey("candidates")); + assertEquals(sourceTask.getRerouteCount(), tasks.get(0).get("rerouteCount")); Map comparison = map(tasks.get(0).get("pathComparison")); assertEquals(0, comparison.get("sharedEdgeCount")); assertEquals(sourceTask.getPlannedPath().size() - 1, @@ -107,6 +119,8 @@ class SimulationVisualizationMapperTest { assertEquals(1, summary.get("completedCount")); assertEquals(0, summary.get("waitingCount")); assertEquals(0, summary.get("failedCount")); + assertEquals(1, map(summary.get("routeComparabilityCounts")) + .get("INVALID_HISTORY_PATH")); assertEquals(0, invariants.get("totalSafetyViolations")); assertEquals(Boolean.TRUE, map(payload.get("archive")).get("complete")); assertEquals(Boolean.TRUE, map(payload.get("wms")).get("idempotent"));