diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationApplication.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationApplication.java index 0c79bae..3c598e2 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationApplication.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationApplication.java @@ -22,11 +22,21 @@ public final class HistoricalDispatchSimulationApplication { * @throws Exception 当调用条件不满足或处理无法继续时抛出。 */ public static void main(String[] args) throws Exception { - if (args.length < 1 || args.length > 2) { + if (args.length < 1 || args.length > 3) { throw new IllegalArgumentException( - "usage: HistoricalDispatchSimulationApplication [10|100]"); + "usage: HistoricalDispatchSimulationApplication " + + " [10|100] [business|raw]"); } int acceleration = args.length == 2 ? Integer.parseInt(args[1]) : 100; + if (args.length == 3) acceleration = Integer.parseInt(args[1]); + String requestedMode = args.length == 3 ? args[2] : "business"; + if (!"business".equalsIgnoreCase(requestedMode) + && !"raw".equalsIgnoreCase(requestedMode)) { + throw new IllegalArgumentException("execution mode must be business or raw"); + } + SimulationExecutionMode mode = "raw".equalsIgnoreCase(requestedMode) + ? SimulationExecutionMode.RAW_SNAPSHOT + : SimulationExecutionMode.SOURCE_ALIGNED_BUSINESS; SimulationConfig defaults = SimulationConfig.defaults(); SimulationConfig config = new SimulationConfig(acceleration, defaults.getAgingIntervalMillis(), defaults.getRetryLimit(), @@ -36,7 +46,9 @@ public final class HistoricalDispatchSimulationApplication { HistoricalQueueDataset dataset = classLoader.getResource("shadow/history/manifest.json") != null ? loader.load(classLoader, "shadow/history") : loader.load(Paths.get("src/test/resources/shadow/history")); - HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(dataset); + HistoricalSimulationInputFactory factory = new HistoricalSimulationInputFactory(); + HistoricalSimulationInput input = mode == SimulationExecutionMode.RAW_SNAPSHOT + ? factory.createRawSnapshots(dataset) : factory.create(dataset); DeterministicFaultPlan.Builder faults = DeterministicFaultPlan.builder(); for (int i = 0; i < input.getTasks().size(); i += 37) { faults.plcFailure(input.getTasks().get(i).getSimulationTaskId(), 1, @@ -47,6 +59,7 @@ public final class HistoricalDispatchSimulationApplication { Path output = Paths.get(args[0]); new HistoricalDispatchSimulationReportWriter().write(output, dataset, result); System.out.println("HISTORY-DISPATCH-SIMULATION input=" + result.getTaskResults().size() + + " mode=" + result.getExecutionMode() + " completed=" + result.getCompletedCount() + " waiting=" + result.getWaitingCount() + " failed=" + result.getFailedCount() 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 9f2f673..95483a4 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 @@ -43,21 +43,25 @@ public final class HistoricalDispatchSimulationReportWriter { throw new IllegalArgumentException("output, dataset and result are required"); } Files.createDirectories(outputDir); - // 用仿真时的同一输入地图解释历史路径,确保报告与浏览器采用完全相同的公平比较口径。 - HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(dataset); + // 报告必须复用本次仿真的任务口径:RAW 结果不能用 76 个父任务重新解释,反之亦然。 + HistoricalSimulationInputFactory factory = new HistoricalSimulationInputFactory(); + HistoricalSimulationInput input = result.getExecutionMode() + == SimulationExecutionMode.RAW_SNAPSHOT + ? factory.createRawSnapshots(dataset) : factory.create(dataset); Map routeDecisions = new SimulationVisualizationMapper(dataset).result(input, result); // 摘要回答总体调度效果,决策 JSON 和两个 CSV 保留逐任务、候选路径与逐 tick 证据。 - MAPPER.writeValue(outputDir.resolve("summary.json").toFile(), - summary(dataset, result, routeDecisions)); + Map summary = summary(dataset, result, routeDecisions); + MAPPER.writeValue(outputDir.resolve("summary.json").toFile(), summary); MAPPER.writeValue(outputDir.resolve("route-decisions.json").toFile(), routeDecisions); - writeTaskCsv(outputDir.resolve("task-results.csv"), result, routeDecisions); + writeTaskCsv(outputDir.resolve("task-results.csv"), input, result, routeDecisions); writeEventCsv(outputDir.resolve("tick-events.csv"), result); MAPPER.writeValue(outputDir.resolve("invariant-results.json").toFile(), invariantSummary(result)); + writeMarkdownSummary(outputDir.resolve("summary.md"), summary, result); writeManifest(outputDir, Arrays.asList("invariant-results.json", "route-decisions.json", - "summary.json", "task-results.csv", "tick-events.csv")); + "summary.json", "summary.md", "task-results.csv", "tick-events.csv")); } /** @@ -71,6 +75,13 @@ public final class HistoricalDispatchSimulationReportWriter { Map routeDecisions) { Map value = new LinkedHashMap(); value.put("mode", "REAL_HISTORY_DRIVEN_DISPATCH_SIMULATION"); + value.put("executionMode", result.getExecutionMode()); + HistoricalInputAudit audit = result.getInputAudit(); + value.put("rawSnapshotCount", audit.getRawSnapshotCount()); + value.put("uniqueQueueCount", audit.getUniqueQueueCount()); + value.put("duplicateSnapshotCount", audit.getDuplicateSnapshotCount()); + value.put("businessTaskCount", audit.getBusinessTaskCount()); + value.put("operationalQueueCount", audit.getOperationalQueueCount()); value.put("boundary", "真实历史负载驱动纯内存仿真,不是现场 SHADOW/HIL/ACTIVE 验收"); value.put("database", dataset.getDatabase()); value.put("sourceTable", dataset.getSourceTable()); @@ -94,6 +105,13 @@ public final class HistoricalDispatchSimulationReportWriter { value.put("tickCount", result.getTickCount()); value.put("waitingTime", timeStats(waitingSamples(result))); value.put("completionTime", timeStats(completionSamples(result))); + value.put("businessCompletionTime", result.businessCompletionDistribution()); + value.put("vehicleOccupancyTime", result.vehicleOccupancyDistribution()); + value.put("automaticReturnTime", result.automaticReturnDistribution()); + value.put("holdCountByPhase", result.holdCountByPhase()); + value.put("retryCountByPhase", result.retryCountByPhase()); + value.put("incompleteVehicleReleaseCount", + result.getIncompleteVehicleReleaseCount()); value.put("historicalDuration", timeStats(historicalSamples(result))); value.put("simulationMinusHistoricalDuration", timeStats(durationDifferenceSamples(result))); @@ -192,7 +210,7 @@ public final class HistoricalDispatchSimulationReportWriter { for (SimulationTaskResult task : result.getTaskResults()) { // 只比较具备历史完成时间且本次确实完成的任务,避免把失败/等待误写为耗时差异。 Long historical = historicalDuration(task); - Long simulated = task.getCompletionMillis(); + Long simulated = businessDuration(task); if (historical != null && simulated != null) { values.add(simulated * result.getAcceleration() - historical); } @@ -260,24 +278,28 @@ public final class HistoricalDispatchSimulationReportWriter { * @param result 执行该处理所需的 result 输入。 * @throws IOException 当调用条件不满足或处理无法继续时抛出。 */ - private void writeTaskCsv(Path path, HistoricalDispatchSimulationResult result, + private void writeTaskCsv(Path path, HistoricalSimulationInput input, + HistoricalDispatchSimulationResult result, Map routeDecisions) throws IOException { StringBuilder csv = new StringBuilder("\ufeff"); row(csv, "simulation_task_id", "task_no", "guid", "task_type", "priority", "business_start_site", "business_end_site", "physical_start_site", - "physical_end_site", "historical_agv", "simulated_agv", + "physical_end_site", "source_front", "target_front", "phase_paths", + "historical_agv", "simulated_agv", "historical_path", "planned_path", "assigned_vehicles", "status", "reason", - "arrival_ms", "first_assigned_ms", "completed_ms", "waiting_ms", + "arrival_ms", "first_assigned_ms", "completed_ms", "business_completed_ms", + "vehicle_released_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", "route_comparability", "route_quality_comparison", "reroute_count"); Map> routeTasks = routeTasks(routeDecisions); + Map inputTasks = inputTasks(input); for (SimulationTaskResult task : result.getTaskResults()) { // 任务行同时保留历史车/路径和新车/路径,供判断中心调度到底改变了什么。 Long historical = historicalDuration(task); - Long simulated = task.getCompletionMillis(); + Long simulated = businessDuration(task); Long scaled = simulated == null ? null : simulated * result.getAcceleration(); Long difference = historical == null || scaled == null ? null : scaled - historical; String historicalPath = trimTrailingCommas(text(task.getRecord().getPathInfo())); @@ -286,15 +308,20 @@ public final class HistoricalDispatchSimulationReportWriter { ? "" : task.getPlannedPath().get(0); String physicalEnd = task.getPlannedPath().isEmpty() ? "" : task.getPlannedPath().get(task.getPlannedPath().size() - 1); + HistoricalSimulationTask inputTask = inputTasks.get(task.getSimulationTaskId()); 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(), task.getRecord().getEndSite(), physicalStart, physicalEnd, + inputTask == null ? null : inputTask.getSourceFrontSite(), + inputTask == null ? null : inputTask.getTargetFrontSite(), + phasePaths(task), task.getRecord().getAgvId(), task.getSimulatedVehicleId(), historicalPath, plannedPath, String.join("|", task.getAssignedVehicles()), task.getStatus(), task.getReason(), task.getArrivalMillis(), task.getFirstAssignedMillis(), task.getCompletedMillis(), + task.getBusinessCompletedMillis(), task.getVehicleReleasedMillis(), task.getWaitingMillis(), historical, simulated, scaled, difference, task.getHoldCount(), task.getProtocolAttempts(), task.getRetryCount(), task.getHoldReasons(), task.getRetryReasons(), @@ -308,6 +335,61 @@ public final class HistoricalDispatchSimulationReportWriter { Files.write(path, csv.toString().getBytes(StandardCharsets.UTF_8)); } + /** 建立仿真任务索引,使 CSV 中的通道前端来自输入工厂的权威映射而不是报表猜测。 */ + private Map inputTasks(HistoricalSimulationInput input) { + Map values = + new LinkedHashMap(); + for (HistoricalSimulationTask task : input.getTasks()) { + values.put(task.getSimulationTaskId(), task); + } + return values; + } + + /** 把每个阶段及其中心规划路径压成单个 CSV 字段,阶段之间用竖线明确分隔。 */ + private String phasePaths(SimulationTaskResult task) { + List values = new ArrayList(); + for (TaskPhaseEvidence phase : task.getPhaseEvidence()) { + values.add(phase.getPhase() + ":" + String.join(">", phase.getPlannedPath())); + } + return String.join("|", values); + } + + /** + * 返回可与历史 finish_date 对照的业务耗时。 + * + *

业务模式截止到货物入位和 WMS 回调,不把之后自动回前端的车辆清理时间混入;RAW 模式 + * 没有独立业务完成时间,继续使用旧的任务终态耗时。 + */ + private Long businessDuration(SimulationTaskResult task) { + if (task.getBusinessCompletedMillis() != null) { + return task.getBusinessCompletedMillis() - task.getArrivalMillis(); + } + return task.getCompletionMillis(); + } + + /** 写出便于人工上线前检查的摘要,详细机器证据仍保留在 JSON 和 CSV 中。 */ + private void writeMarkdownSummary(Path path, Map summary, + HistoricalDispatchSimulationResult result) + throws IOException { + StringBuilder markdown = new StringBuilder(); + markdown.append("# Historical dispatch simulation summary\n\n") + .append("- Execution mode: ").append(result.getExecutionMode()).append('\n') + .append("- Raw snapshots: ").append(summary.get("rawSnapshotCount")).append('\n') + .append("- Business tasks: ").append(summary.get("businessTaskCount")).append('\n') + .append("- Simulated inputs: ").append(result.getTaskResults().size()).append('\n') + .append("- Completed / waiting / failed: ") + .append(result.getCompletedCount()).append(" / ") + .append(result.getWaitingCount()).append(" / ") + .append(result.getFailedCount()).append('\n') + .append("- Archive / WMS effects: ").append(result.getArchiveCount()) + .append(" / ").append(result.getWmsBusinessEffects()).append('\n') + .append("- Incomplete vehicle releases: ") + .append(result.getIncompleteVehicleReleaseCount()).append('\n') + .append("- Safety violations: ") + .append(result.getInvariants().totalSafetyViolations()).append('\n'); + Files.write(path, markdown.toString().getBytes(StandardCharsets.UTF_8)); + } + /** * 按仿真任务 ID 建立路线决策索引,供 CSV 在原任务顺序下补充公平比较和重规划结果。 * diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationResult.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationResult.java index 6c7e5cf..b831666 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationResult.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationResult.java @@ -16,6 +16,8 @@ public final class HistoricalDispatchSimulationResult { private final int wmsBusinessEffects; private final long tickCount; private final int acceleration; + private final SimulationExecutionMode executionMode; + private final HistoricalInputAudit inputAudit; /** * 创建 {@code HistoricalDispatchSimulationResult} 实例并初始化其内部状态。 @@ -35,7 +37,9 @@ public final class HistoricalDispatchSimulationResult { SimulationInvariantSummary invariants, int archiveCount, int wmsDeliveryAttempts, int wmsBusinessEffects, long tickCount, - int acceleration) { + int acceleration, + SimulationExecutionMode executionMode, + HistoricalInputAudit inputAudit) { this.taskResults = immutable(taskResults); this.events = immutable(events); this.invariants = invariants; @@ -44,6 +48,11 @@ public final class HistoricalDispatchSimulationResult { this.wmsBusinessEffects = wmsBusinessEffects; this.tickCount = tickCount; this.acceleration = acceleration; + if (executionMode == null || inputAudit == null) { + throw new IllegalArgumentException("execution mode and input audit are required"); + } + this.executionMode = executionMode; + this.inputAudit = inputAudit; } /** @@ -103,6 +112,12 @@ public final class HistoricalDispatchSimulationResult { */ public int getAcceleration() { return acceleration; } + /** 返回本次结果采用的 240 行原始快照或 76 个业务父任务执行口径。 */ + public SimulationExecutionMode getExecutionMode() { return executionMode; } + + /** 返回原始扫描行、去重队列、业务父任务和运行作业的输入审计计数。 */ + public HistoricalInputAudit getInputAudit() { return inputAudit; } + /** * 返回当前对象保存的“completed count”信息。 * @@ -171,6 +186,68 @@ public final class HistoricalDispatchSimulationResult { return count; } + /** + * 统计业务已完成但车辆清理阶段仍未完成或已经失败的任务。 + * + *

该值必须为零才能证明入库任务完成 WMS 回调后,同一辆车也确实回到通道前端并解除占用。 + */ + public int getIncompleteVehicleReleaseCount() { + int count = 0; + for (SimulationTaskResult result : taskResults) { + if (result.getVehicleReleaseStatus() == VehicleReleaseStatus.PENDING + || result.getVehicleReleaseStatus() == VehicleReleaseStatus.FAILED) { + count++; + } + } + return count; + } + + /** 汇总任务从 create_date 到货物完成入位、归档和 WMS 回调的仿真耗时。 */ + public SimulationDurationSummary businessCompletionDistribution() { + List samples = new ArrayList(); + for (SimulationTaskResult result : taskResults) { + if (result.getBusinessCompletedMillis() != null) { + samples.add(result.getBusinessCompletedMillis() - result.getArrivalMillis()); + } + } + return new SimulationMetrics().summarize(samples); + } + + /** 汇总中心首次占用车辆到该车辆完成全部业务阶段并释放的持续时间。 */ + public SimulationDurationSummary vehicleOccupancyDistribution() { + List samples = new ArrayList(); + for (SimulationTaskResult result : taskResults) { + if (result.getFirstAssignedMillis() != null + && result.getVehicleReleasedMillis() != null) { + samples.add(result.getVehicleReleasedMillis() - result.getFirstAssignedMillis()); + } + } + return new SimulationMetrics().summarize(samples); + } + + /** 汇总货物完成后,同一车辆自动返回目标通道前端所额外占用的时间。 */ + public SimulationDurationSummary automaticReturnDistribution() { + List samples = new ArrayList(); + for (SimulationTaskResult result : taskResults) { + if (result.getBusinessCompletedMillis() == null + || result.getVehicleReleasedMillis() == null) continue; + long duration = result.getVehicleReleasedMillis() + - result.getBusinessCompletedMillis(); + if (duration > 0L) samples.add(duration); + } + return new SimulationMetrics().summarize(samples); + } + + /** 按真实业务移动阶段汇总交通等待次数,区分取货、载货和自动回前端的拥堵位置。 */ + public Map holdCountByPhase() { + return phaseCounts(true); + } + + /** 按真实业务移动阶段汇总 PLC 重试次数,定位故障发生在哪一段作业。 */ + public Map retryCountByPhase() { + return phaseCounts(false); + } + /** * 执行“completed by vehicle”所表示的处理流程。 * @@ -224,6 +301,21 @@ public final class HistoricalDispatchSimulationResult { return count; } + /** 把每个任务已经冻结的阶段证据按枚举顺序稳定累加,供 JSON、Markdown 和 UI 共用。 */ + private Map phaseCounts(boolean hold) { + Map counts = + new LinkedHashMap(); + for (SimulationTaskResult result : taskResults) { + for (TaskPhaseEvidence phase : result.getPhaseEvidence()) { + int increment = hold ? phase.getHoldCount() : phase.getRetryCount(); + BusinessTaskPhase name = phase.getPhase(); + counts.put(name, counts.containsKey(name) + ? counts.get(name) + increment : increment); + } + } + return Collections.unmodifiableMap(counts); + } + /** * 执行“immutable”所表示的处理流程。 * 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 5a6ab21..eac3f40 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 @@ -43,8 +43,8 @@ public final class HistoricalDispatchSimulator { * 按中心调度的完整业务链路回放历史任务,而不是复用历史车辆或 {@code path_info}。 * *

先将 {@code create_date} 按配置倍率映射成仿真到达时间,再建立等待队列、车辆运行态、 - * 资源台账、PLC、归档和 WMS 接收器。每个 tick 固定依次处理车辆故障、就绪任务选车与路径预约、 - * 清障移动、活动任务逐边移动以及资源容量校验,因而同一输入总能得到可复现的调度结果。 + * 资源台账、PLC、归档和 WMS 接收器。每个 tick 固定依次处理车辆故障、已有换层/清障/活动任务, + * 最后才为就绪任务选车与预约,保证已绑定货物的阶段连续性优先于可重派空车任务。 * 历史任务号、类型、起终点和优先级作为业务输入;历史完成状态和完成时间不会推进任务。 * 无物理进展时由 watchdog 判死锁,超过 tick 上限时明确失败,避免把未运行的任务误报为完成。 * @param input 执行该处理所需的 input 输入。 @@ -134,15 +134,20 @@ public final class HistoricalDispatchSimulator { SimulationProgressWatchdog watchdog = new SimulationProgressWatchdog( config.getNoProgressLimit(), terminal.size()); while (terminal.size() < expected && tick < config.getTickLimit()) { - // 固定 tick 顺序保证故障、选车、清障和正常移动在相同输入下具有确定性结果。 + // 固定 tick 顺序保证同一输入结果可复现;故障事实始终先于本轮任何新动作生效。 applyVehicleEvents(tick, now, faults, vehicles, active, clearanceMoves, floorTransfers, queue, executions, terminal, plc, ledger, reservations, events); - assignReadyTasks(tick, now, input, queue, vehicles, active, - clearanceMoves, floorTransfers, executions, terminal, - routePlanner, routeEvaluator, assignment, clearancePlanner, - floorTransferGateway, - plc, ledger, reservations, events); + // 上个 tick 已完成业务段、但下一段仍在 HOLD 的父任务必须先续接,等待队列本 tick 暂不抢资源。 + // 其他情况下仍先派车再推进,使 tick 0 的分配能在同 tick 开始移动,并保持既有故障时序合同。 + if (!hasPendingBoundBusinessPhase(active)) { + assignReadyTasks(tick, now, input, queue, vehicles, active, + clearanceMoves, floorTransfers, executions, terminal, + routePlanner, routeEvaluator, assignment, clearancePlanner, + floorTransferGateway, + plc, ledger, reservations, events); + } + // 先推进已绑定车辆和已提交设备作业,使载货任务能在资源释放后优先续接下一业务阶段。 boolean transferProgress = advanceFloorTransfers(tick, now, input, config, queue, vehicles, active, clearanceMoves, floorTransfers, terminal, routePlanner, routeEvaluator, @@ -200,7 +205,29 @@ public final class HistoricalDispatchSimulator { Collections.sort(results, Comparator.comparing(SimulationTaskResult::getSimulationTaskId)); return new HistoricalDispatchSimulationResult(results, events, invariants, archive.size(), wms.getDeliveryAttempts(), wms.getBusinessEffects(), - tick, config.getAcceleration()); + tick, config.getAcceleration(), input.getExecutionMode(), + input.getInputAudit()); + } + + /** + * 判断是否有父任务已经完成当前业务段、但仍在等待同一辆车续接下一业务段。 + * + *

这种状态通常表示载货任务正在等待走廊、终点清障或方向预约。车辆和货物已经绑定,不能像 + * 取货前的空车任务一样撤销后换车。只要它仍留在 {@code active} 且阶段完成标志已经写入,调度中心 + * 就必须暂缓新的队列分配,让既有活动任务先释放资源或让该任务在下一 tick 取得刚释放的资源。 + * 换层作业已经转入 {@code floorTransfers},最终回前端阶段则会直接释放车辆,因此不会误入此判断。 + * + * @param active 当前仍与车辆绑定的业务执行,键为车辆编号。 + * @return 存在等待续接的已绑定业务阶段时返回 {@code true}。 + */ + private boolean hasPendingBoundBusinessPhase(Map active) { + for (ActiveTask current : active.values()) { + // 上一段完成 ACK 后该标志才会置为 true;下一段激活成功时会替换成新的 ActiveTask。 + if (current.execution.isBusinessMode() && current.phaseCompletionApplied) { + return true; + } + } + return false; } /** @@ -433,6 +460,9 @@ public final class HistoricalDispatchSimulator { // 只处理 create_date 已到达的任务;队列已按优先级、等待老化和历史标识完成稳定排序。 List ready = new ArrayList( queue.readyAt(now)); + // 高优先级任务已经选中、但被短暂终点门禁挡住的候选车,本 tick 不再借给后续低序任务。 + // 下一 tick 会重新计算,不形成长期车辆锁;这能防止后续任务跨过 HOLD 抢车并制造载货占点环。 + Set claimedVehicles = new LinkedHashSet(); for (HistoricalSimulationTask task : ready) { if (terminal.containsKey(task.getSimulationTaskId())) continue; Execution execution = executions.get(task.getSimulationTaskId()); @@ -472,7 +502,9 @@ public final class HistoricalDispatchSimulator { Map transferCandidates = new LinkedHashMap(); for (SimulatedVehicleRuntime vehicle : vehicles.values()) { - if (!vehicle.isAssignmentEligible() || active.containsKey(vehicle.getVehicleId()) + if (claimedVehicles.contains(vehicle.getVehicleId()) + || !vehicle.isAssignmentEligible() + || active.containsKey(vehicle.getVehicleId()) || clearanceMoves.containsKey(vehicle.getVehicleId()) || floorTransfers.containsKey(vehicle.getVehicleId())) { continue; @@ -495,6 +527,12 @@ public final class HistoricalDispatchSimulator { } } if (candidates.isEmpty()) { + // 活动车、清障、换层或本 tick 已被高序任务占用的候选都会在后续 tick 改变车队状态。 + // 此时只能判定“暂时无车”,不能为队列中每个任务重复执行昂贵的全图永久可达性扫描。 + boolean fleetStateMayChange = !claimedVehicles.isEmpty() + || !active.isEmpty() || !clearanceMoves.isEmpty() + || !floorTransfers.isEmpty(); + if (fleetStateMayChange) continue; // 暂无可分配车不等于失败;只有全车队都无法抵达源点时才关闭为 NO_ROUTE。 if (!fleetCanReach(task, vehicles, input, routePlanner)) { queue.remove(task.getSimulationTaskId()); @@ -515,6 +553,46 @@ public final class HistoricalDispatchSimulator { dispatchTask, candidates, input.getGridMap().getSites()); if (!decision.getVehicle().isPresent()) continue; String vehicleId = decision.getVehicle().get().getVehicleId(); + // 队列优先级必须约束整个选车尝试,而不只是 for 循环顺序。高序任务一旦完成候选比较, + // 本 tick 的这批候选车不再被后续任务复用;下一 tick 全部重新开放,因此不会形成长期锁车。 + if (execution.isBusinessMode()) { + claimCandidates(candidates, claimedVehicles); + } + + // 空车取货前先看一眼之后必经的载货落点。若另一条在途路线已经承诺、或空闲车仍占住 + // 同一单通道落点,此时取货会把车辆变成不可换车的载货占位,可能形成前端占点闭环。 + // 对占点先发起清障,对承诺继续等待;两种情况都不允许父任务提前绑定货物和车辆。 + if (execution.isBusinessMode() + && execution.currentLeg().getPhase() + == BusinessTaskPhase.EMPTY_TO_PICKUP) { + String deliveryCommitment = destinationCommitmentReason( + vehicleId, task.getPhysicalTarget(), vehicles, + active, floorTransfers, input.getGridMap()); + if (deliveryCommitment != null) { + if ("DESTINATION_OCCUPIED".equals(deliveryCommitment)) { + // 清障终点不能落在未来载货路线上。跨层任务必须保护目标层端口后的真实走廊, + // 否则可能把占车从业务终点挪到换层后的必经主通道,等载货车到达时形成夹持。 + List deliveryRoute = businessDeliveryProtectionRoute( + task, input, routePlanner); + if (deliveryRoute.isEmpty()) { + // 端口或后半程尚不可用时仍保护最终落点;正式阶段激活会按 NO_ROUTE 关闭。 + deliveryRoute = Collections.singletonList( + task.getPhysicalTarget()); + } + progress |= planDestinationClearance(tick, now, + task.getSimulationTaskId(), vehicleId, + deliveryRoute, vehicles, active, clearanceMoves, + floorTransfers, clearancePlanner, ledger, + input.getGridMap(), events); + } + String reason = "DELIVERY_" + deliveryCommitment; + hold(execution, reason); + events.add(event(tick, now, "HOLD", + task.getSimulationTaskId(), vehicleId, reason, + task.getPhysicalSource(), task.getPhysicalTarget())); + continue; + } + } ClearanceMove pendingDestinationClearance = clearanceForTask( task.getSimulationTaskId(), clearanceMoves); if (pendingDestinationClearance != null) { @@ -718,6 +796,51 @@ public final class HistoricalDispatchSimulator { return progress; } + /** 把当前高序任务可用的候选车保留到下一 tick,避免较晚任务跨过短暂 HOLD 抢占同一批车。 */ + private void claimCandidates(List candidates, + Set claimedVehicles) { + for (VehicleSnapshot candidate : candidates) { + claimedVehicles.add(candidate.getVehicleId()); + } + } + + /** + * 生成空车取货前用于清障选址的未来载货路线。 + * + *

同层任务直接保护业务源点到目标点的服务路线;跨层任务不把不存在的跨层边交给普通规划器, + * 而是从目标层换层端口规划到业务目标。该路线只用于禁止清障车停入未来必经走廊,不会作为车辆 + * 的最终执行路径;设备完成后仍以实际出口重新规划,避免预估端口替代真实 WCS 完成事实。 + * + * @param task 等待取货的业务父任务。 + * @param input 提供权威地图和现有 WCS 换层端口目录。 + * @param planner 为同层物理路网生成候选路线。 + * @return 可用于清障保护的目标侧物理路线;端口或路线不可达时返回空列表。 + */ + private List businessDeliveryProtectionRoute( + HistoricalSimulationTask task, HistoricalSimulationInput input, + RouteCandidatePlanner planner) { + Site source = input.getGridMap().getSite(task.getPhysicalSource()); + Site target = input.getGridMap().getSite(task.getPhysicalTarget()); + if (source == null || target == null) return Collections.emptyList(); + + // 同层载货段由普通路径规划器完整表达,直接保护整条服务路线。 + if (source.getFloor() == target.getFloor()) { + return planner.plan(input.getGridMap(), task.getPhysicalSource(), + task.getPhysicalTarget(), Collections.emptySet(), + RoutePlanningPolicy.defaultPolicy()).getShortestPath(); + } + + // 跨层移动由现有 WCS 作业表达;清障只需保护设备目标端口到最终放货点的同层后半程。 + FloorTransferTopology topology = input.getFloorTransferTopology(); + if (!topology.supports(source.getFloor(), target.getFloor())) { + return Collections.emptyList(); + } + String targetPort = topology.portFor(target.getFloor()).get(); + return planner.plan(input.getGridMap(), targetPort, + task.getPhysicalTarget(), Collections.emptySet(), + RoutePlanningPolicy.defaultPolicy()).getShortestPath(); + } + /** 返回业务输入中第一个不属于当前项目地图的端点类型;合法输入返回 {@code null}。 */ private String businessMapFailure(Execution execution, HistoricalSimulationInput input) { @@ -785,10 +908,10 @@ public final class HistoricalDispatchSimulator { /** * 判断业务终点是否已由另一辆车占用或由另一条在途任务承诺。 * - *

走廊预约允许同向跟车,但任务完成后车辆仍会真实占住终点。对于只有一个进出口的取放货口, - * 若第二辆车提前进入同一通道,首车完成后将无法反向离站。该门禁把终点视为任务级承诺:当前占车 - * 返回 {@code DESTINATION_OCCUPIED},普通活动任务或换层父任务将驶入同一点时返回 - * {@code DESTINATION_COMMITTED}。候选车辆自己位于终点不算冲突,因为它可以先执行离站业务。 + *

业务父任务的最终放货点从绑定车辆起就必须保持唯一承诺,即使该节点在地图上有两个邻居; + * 单轨货道的“前车放货、后车同点取货”仍会在后车反向退出时形成闭环。普通路线终点的物理占用 + * 则只提前保护无法侧向让位的单通道节点:当前占车返回 {@code DESTINATION_OCCUPIED},活动路线 + * 或换层后路线将停靠同一点时返回 {@code DESTINATION_COMMITTED}。候选车辆自己不算冲突。 * * @param vehicleId 本次候选车辆编号。 * @param destination 本次服务路线最终停靠的业务站点。 @@ -804,7 +927,27 @@ public final class HistoricalDispatchSimulator { Map active, Map floorTransfers, com.ft546.wcs.demo.traffic.GridMap map) { - // 多出口节点仍由现有清障和交通资源裁决处理;这里只拦截无法绕行或侧向让位的单通道终点。 + // 业务最终落点是货物级唯一承诺,不以节点度数推断能否会车;同点取放必须等前一父任务结束。 + for (Map.Entry entry : active.entrySet()) { + ActiveTask task = entry.getValue(); + if (vehicleId.equals(entry.getKey())) continue; + boolean pendingBusinessDestination = task.execution.isBusinessMode() + && task.execution.businessCompletedMillis == null + && destination.equals( + task.execution.task.getPhysicalTarget()); + if (pendingBusinessDestination) return "DESTINATION_COMMITTED"; + } + for (Map.Entry entry : floorTransfers.entrySet()) { + FloorTransferExecution transfer = entry.getValue(); + if (vehicleId.equals(entry.getKey())) continue; + boolean pendingBusinessDestination = transfer.execution.isBusinessMode() + && transfer.execution.businessCompletedMillis == null + && destination.equals( + transfer.execution.task.getPhysicalTarget()); + if (pendingBusinessDestination) return "DESTINATION_COMMITTED"; + } + + // 多出口普通节点继续交给逐边交通裁决;以下门禁只保护无法侧向让位的停靠点。 if (map.getNeighbors(destination).size() > 1) return null; for (SimulatedVehicleRuntime vehicle : vehicles.values()) { if (!vehicleId.equals(vehicle.getVehicleId()) @@ -814,18 +957,18 @@ public final class HistoricalDispatchSimulator { } for (Map.Entry entry : active.entrySet()) { ActiveTask task = entry.getValue(); - if (!vehicleId.equals(entry.getKey()) - && destination.equals(task.route.get(task.route.size() - 1))) { - return "DESTINATION_COMMITTED"; - } + if (vehicleId.equals(entry.getKey())) continue; + boolean currentRouteDestination = + destination.equals(task.route.get(task.route.size() - 1)); + if (currentRouteDestination) return "DESTINATION_COMMITTED"; } for (Map.Entry entry : floorTransfers.entrySet()) { FloorTransferExecution transfer = entry.getValue(); List route = transfer.preparedPostTransferRoute; - if (!vehicleId.equals(entry.getKey()) && !route.isEmpty() - && destination.equals(route.get(route.size() - 1))) { - return "DESTINATION_COMMITTED"; - } + if (vehicleId.equals(entry.getKey())) continue; + boolean preparedRouteDestination = !route.isEmpty() + && destination.equals(route.get(route.size() - 1)); + if (preparedRouteDestination) return "DESTINATION_COMMITTED"; } return null; } @@ -847,9 +990,78 @@ public final class HistoricalDispatchSimulator { ClearanceMovePlanner clearancePlanner, ResourceLedger ledger, com.ft546.wcs.demo.traffic.GridMap map, List events) { - // 中心一次只编排一条让位路线,避免两个终点占车同时选中同一安全支路而产生二次阻塞。 - if (!clearanceMoves.isEmpty()) return false; String destination = ownerRoute.get(ownerRoute.size() - 1); + List blockerIds = new ArrayList(); + for (SimulatedVehicleRuntime vehicle : vehicles.values()) { + if (!candidateVehicleId.equals(vehicle.getVehicleId()) + && destination.equals(vehicle.getCurrentSite())) { + blockerIds.add(vehicle.getVehicleId()); + } + } + return planClearanceBeforeRouteCommit(tick, now, ownerTaskId, + candidateVehicleId, ownerRoute, blockerIds, "DESTINATION_BLOCKER", + vehicles, active, + clearanceMoves, floorTransfers, clearancePlanner, ledger, + map, events); + } + + /** + * 在载货阶段路线预约前处理路线中间的空闲占位车,并返回父任务本 tick 的 HOLD 原因。 + * + *

此时父路线还没有物理生效,阻塞车可以穿过未来路线离开;若等载货车开到相邻节点再清障, + * 单轨货道两侧都被车辆占住后将不再存在安全让位路径。 + */ + private String prepareRouteClearanceBeforeCommit( + long tick, long now, String ownerTaskId, String candidateVehicleId, + List ownerRoute, + Map vehicles, + Map active, + Map clearanceMoves, + Map floorTransfers, + ClearanceMovePlanner clearancePlanner, ResourceLedger ledger, + com.ft546.wcs.demo.traffic.GridMap map, + List events) { + if (clearanceForTask(ownerTaskId, clearanceMoves) != null) { + return "ROUTE_CLEARANCE_IN_PROGRESS"; + } + Set futureSites = new LinkedHashSet(ownerRoute); + if (!ownerRoute.isEmpty()) futureSites.remove(ownerRoute.get(0)); + List blockerIds = new ArrayList(); + for (SimulatedVehicleRuntime vehicle : vehicles.values()) { + String blockerId = vehicle.getVehicleId(); + if (!candidateVehicleId.equals(blockerId) + && futureSites.contains(vehicle.getCurrentSite()) + && vehicle.isAssignmentEligible() + && !active.containsKey(blockerId) + && !clearanceMoves.containsKey(blockerId) + && !floorTransfers.containsKey(blockerId)) { + blockerIds.add(blockerId); + } + } + if (blockerIds.isEmpty()) return null; + boolean planned = planClearanceBeforeRouteCommit(tick, now, + ownerTaskId, candidateVehicleId, ownerRoute, blockerIds, + "ROUTE_BLOCKER", + vehicles, active, clearanceMoves, floorTransfers, + clearancePlanner, ledger, map, events); + return planned ? "ROUTE_CLEARANCE_IN_PROGRESS" + : "ROUTE_BLOCKED_NO_CLEARANCE_SITE"; + } + + /** 统一执行路线启用前清障;调用方负责确定哪些车辆实际阻塞本次待提交路线。 */ + private boolean planClearanceBeforeRouteCommit( + long tick, long now, String ownerTaskId, String candidateVehicleId, + List ownerRoute, List blockerIds, + String blockerReason, + Map vehicles, + Map active, + Map clearanceMoves, + Map floorTransfers, + ClearanceMovePlanner clearancePlanner, ResourceLedger ledger, + com.ft546.wcs.demo.traffic.GridMap map, + List events) { + // 中心一次只编排一条让位路线,避免多个阻塞车同时选中同一安全支路而产生二次阻塞。 + if (!clearanceMoves.isEmpty()) return false; Set occupiedSites = new LinkedHashSet(); for (SimulatedVehicleRuntime vehicle : vehicles.values()) { occupiedSites.add(vehicle.getCurrentSite()); @@ -859,13 +1071,6 @@ public final class HistoricalDispatchSimulator { for (Map.Entry> entry : ledger.snapshotHolders().entrySet()) { if (!entry.getValue().isEmpty()) reservedResources.add(entry.getKey()); } - List blockerIds = new ArrayList(); - for (SimulatedVehicleRuntime vehicle : vehicles.values()) { - if (!candidateVehicleId.equals(vehicle.getVehicleId()) - && destination.equals(vehicle.getCurrentSite())) { - blockerIds.add(vehicle.getVehicleId()); - } - } Collections.sort(blockerIds); for (String blockerId : blockerIds) { SimulatedVehicleRuntime blocker = vehicles.get(blockerId); @@ -883,7 +1088,7 @@ public final class HistoricalDispatchSimulator { ClearanceMove clearance = planned.get(); clearanceMoves.put(blockerId, clearance); events.add(event(tick, now, "CLEARANCE_PLANNED", ownerTaskId, - blockerId, "DESTINATION_BLOCKER", + blockerId, blockerReason, clearance.getSourceSite(), clearance.getTargetSite())); return true; } @@ -898,11 +1103,23 @@ public final class HistoricalDispatchSimulator { Map active, Map floorTransfers) { for (ActiveTask task : active.values()) { + // 业务车当前可能还在取货或退到源通道前端,但最终载货落点从绑定车辆时就已承诺。 + // 清障车若停到这个未来终点,会迫使已载货任务二次清障,甚至与相邻主干道任务组成环。 + if (task.execution.isBusinessMode() + && task.execution.businessCompletedMillis == null) { + excludedSites.add(task.execution.task.getPhysicalTarget()); + } if (!task.route.isEmpty()) { excludedSites.add(task.route.get(task.route.size() - 1)); } } for (FloorTransferExecution transfer : floorTransfers.values()) { + // 换层中的父任务仍持有同一业务终点承诺,设备作业不能成为清障选址的可见性窗口。 + if (transfer.execution.isBusinessMode() + && transfer.execution.businessCompletedMillis == null) { + excludedSites.add( + transfer.execution.task.getPhysicalTarget()); + } List route = transfer.preparedPostTransferRoute; if (!route.isEmpty()) excludedSites.add(route.get(route.size() - 1)); } @@ -1801,6 +2018,17 @@ public final class HistoricalDispatchSimulator { RoutePlanDecision decision = routeEvaluator.select(input.getGridMap(), candidates, traffic, taskId, vehicle.getVehicleId()); List route = new ArrayList(decision.getSelectedPath()); + String routeClearanceHold = prepareRouteClearanceBeforeCommit( + tick, now, taskId, vehicle.getVehicleId(), route, + vehicles, active, clearanceMoves, floorTransfers, + clearancePlanner, ledger, input.getGridMap(), events); + if (routeClearanceHold != null) { + hold(execution, routeClearanceHold); + events.add(event(tick, now, "PHASE_HOLD", taskId, + vehicle.getVehicleId(), routeClearanceHold, + fromSite, leg.getTargetSite())); + return false; + } String destinationHold = destinationCommitmentReason( vehicle.getVehicleId(), leg.getTargetSite(), vehicles, active, floorTransfers, input.getGridMap()); diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationDurationSummary.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationDurationSummary.java new file mode 100644 index 0000000..226a2ce --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationDurationSummary.java @@ -0,0 +1,28 @@ +package com.ft546.wcs.shadow.history.simulation; + +/** + * 一组仿真时长样本的稳定统计口径。 + * + *

平均值用于观察整体效率,P95/P99 用于暴露少数任务因交通等待、PLC 重试或故障恢复产生的 + * 长尾。空样本保留为 {@code null},避免把“没有发生”错误解释成“耗时为零”。 + */ +public final class SimulationDurationSummary { + private final Double averageMillis; + private final Long p95Millis; + private final Long p99Millis; + + public SimulationDurationSummary(Double averageMillis, Long p95Millis, Long p99Millis) { + this.averageMillis = averageMillis; + this.p95Millis = p95Millis; + this.p99Millis = p99Millis; + } + + /** 返回全部有效任务的平均仿真耗时;没有样本时返回 {@code null}。 */ + public Double getAverageMillis() { return averageMillis; } + + /** 返回 95% 任务不超过的仿真耗时;没有样本时返回 {@code null}。 */ + public Long getP95Millis() { return p95Millis; } + + /** 返回 99% 任务不超过的仿真耗时;没有样本时返回 {@code null}。 */ + public Long getP99Millis() { return p99Millis; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationMetrics.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationMetrics.java index 69e1a16..d2dd094 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationMetrics.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationMetrics.java @@ -5,6 +5,19 @@ import java.util.Collections; import java.util.List; public final class SimulationMetrics { + /** + * 把同一批业务时长一次性汇总为平均值、P95 和 P99,确保报告中的业务完成、车辆占用和 + * 自动回前端使用完全相同的统计规则。 + * + * @param samples 非空元素组成的毫秒时长样本;集合可以为空。 + * @return 不可变的时长统计值;空样本的三个指标均为 {@code null}。 + */ + public SimulationDurationSummary summarize(List samples) { + validate(samples); + return new SimulationDurationSummary(average(samples), + percentile(samples, 0.95), percentile(samples, 0.99)); + } + /** * 计算一组等待或完成耗时样本的算术平均值;没有样本时返回 {@code null}。 * diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalWorkloadSimulationService.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalWorkloadSimulationService.java index d19b370..124be05 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalWorkloadSimulationService.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalWorkloadSimulationService.java @@ -7,7 +7,7 @@ import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput; import com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway; import com.ft546.wcs.shadow.history.simulation.SimulationConfig; -/** 重新运行固化的 240 条历史负载,并保留既定的确定性 PLC 故障注入。 */ +/** 重新运行由 240 条历史快照组装出的业务父任务,并保留既定的确定性 PLC 故障注入。 */ public final class HistoricalWorkloadSimulationService { private final HistoricalSimulationInput input; diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationRealDataTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationRealDataTest.java index 672dad0..6921b1f 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationRealDataTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationRealDataTest.java @@ -10,91 +10,98 @@ import org.junit.jupiter.params.provider.MethodSource; import java.util.stream.Stream; import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.SEND_FAILURE; +import static com.ft546.wcs.shadow.history.simulation.SimulationExecutionMode.RAW_SNAPSHOT; +import static com.ft546.wcs.shadow.history.simulation.SimulationExecutionMode.SOURCE_ALIGNED_BUSINESS; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; class HistoricalDispatchSimulationRealDataTest { - private static HistoricalDispatchSimulationResult result; - private static HistoricalDispatchSimulationResult secondRun; - private static HistoricalSimulationInput simulationInput; + private static HistoricalDispatchSimulationResult rawResult; + private static HistoricalDispatchSimulationResult businessResult; + private static HistoricalSimulationInput businessInput; - /** - * 验证“simulate real history twice”场景,确保相关业务规则和断言成立。 - * - *

该方法属于 {@code HistoricalDispatchSimulationRealDataTest},用于保持当前模块的职责边界清晰。 - */ + /** 分别执行 240 条扫描快照基线和 76 条业务父任务,禁止两种统计口径互相覆盖。 */ @BeforeAll - static void simulateRealHistoryTwice() { + static void simulateRawAndBusinessHistory() { HistoricalQueueDataset dataset = new HistoricalQueueLoader().load( - HistoricalDispatchSimulationRealDataTest.class.getClassLoader(), "shadow/history"); - HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(dataset); - simulationInput = input; - DeterministicFaultPlan.Builder faults = DeterministicFaultPlan.builder(); - for (int i = 0; i < input.getTasks().size(); i += 37) { - faults.plcFailure(input.getTasks().get(i).getSimulationTaskId(), 1, SEND_FAILURE); - } - DeterministicFaultPlan plan = faults.build(); + HistoricalDispatchSimulationRealDataTest.class.getClassLoader(), + "shadow/history"); + HistoricalSimulationInputFactory factory = new HistoricalSimulationInputFactory(); + HistoricalSimulationInput rawInput = factory.createRawSnapshots(dataset); + businessInput = factory.create(dataset); HistoricalDispatchSimulator simulator = new HistoricalDispatchSimulator(); - result = simulator.simulate(input, SimulationConfig.defaults(), plan); - secondRun = simulator.simulate(input, SimulationConfig.defaults(), plan); + rawResult = simulator.simulate(rawInput, SimulationConfig.defaults(), + failuresFor(rawInput)); + businessResult = simulator.simulate(businessInput, SimulationConfig.defaults(), + failuresFor(businessInput)); } - /** - * 验证“real task results”场景,确保相关业务规则和断言成立。 - * - *

该方法属于 {@code HistoricalDispatchSimulationRealDataTest},用于保持当前模块的职责边界清晰。 - * @return 本次处理产生的结果;调用方据此继续后续流程。 - */ - static Stream realTaskResults() { - return result.getTaskResults().stream(); + private static DeterministicFaultPlan failuresFor(HistoricalSimulationInput input) { + DeterministicFaultPlan.Builder faults = DeterministicFaultPlan.builder(); + for (int index = 0; index < input.getTasks().size(); index += 37) { + faults.plcFailure(input.getTasks().get(index).getSimulationTaskId(), + 1, SEND_FAILURE); + } + return faults.build(); } - /** - * 验证“every history row has one auditable outcome”场景,确保相关业务规则和断言成立。 - * - *

该方法属于 {@code HistoricalDispatchSimulationRealDataTest},用于保持当前模块的职责边界清晰。 - * @param task 执行该处理所需的 task 输入。 - */ + static Stream businessTaskResults() { + return businessResult.getTaskResults().stream(); + } + + /** 每个业务父任务都必须留下真实阶段路径;换层只允许出现在阶段边界而不是普通地图边中。 */ @ParameterizedTest(name = "{index} {0}") - @MethodSource("realTaskResults") - void everyHistoryRowHasOneAuditableOutcome(SimulationTaskResult task) { + @MethodSource("businessTaskResults") + void everyBusinessTaskHasOneAuditableOutcome(SimulationTaskResult task) { assertNotNull(task.getStatus()); assertFalse(task.getSimulationTaskId().isEmpty()); assertNotNull(task.getRecord().getTaskNo()); assertNotNull(task.getRecord().getStartSite()); assertNotNull(task.getRecord().getEndSite()); - for (int i = 1; i < task.getPlannedPath().size(); i++) { - assertTrue(simulationInput.getGridMap() - .getNeighbors(task.getPlannedPath().get(i - 1)) - .contains(task.getPlannedPath().get(i))); + for (TaskPhaseEvidence phase : task.getPhaseEvidence()) { + for (int index = 1; index < phase.getPlannedPath().size(); index++) { + assertTrue(businessInput.getGridMap() + .getNeighbors(phase.getPlannedPath().get(index - 1)) + .contains(phase.getPlannedPath().get(index))); + } } } - /** - * 验证“completes the real workload deterministically without safety violations”场景,确保相关业务规则和断言成立。 - * - *

该方法属于 {@code HistoricalDispatchSimulationRealDataTest},用于保持当前模块的职责边界清晰。 - */ + /** 改造前的逐扫描行模式继续给出 240 条完成结果,作为旧算法对照基线。 */ @Test - void completesTheRealWorkloadDeterministicallyWithoutSafetyViolations() { - assertEquals(240, result.getTaskResults().size()); - assertEquals(240, result.getCompletedCount()); - assertEquals(0, result.getWaitingCount()); - assertEquals(0, result.getFailedCount()); - assertTrue(result.getReassignmentCount() > 0); - assertTrue(result.getRetryCount() > 0); - assertEquals(240, result.getArchiveCount()); - assertEquals(240, result.getWmsBusinessEffects()); - assertEquals(480, result.getWmsDeliveryAttempts()); - assertEquals(0, result.getInvariants().getDeadlocks()); - assertEquals(0, result.getInvariants().getCapacityViolations()); - assertEquals(0, result.getInvariants().getNodeDoubleOccupancyViolations()); - assertEquals(0, result.getInvariants().getOppositeEdgeViolations()); - assertEquals(0, result.getInvariants().getDuplicateArchiveAttempts()); - assertEquals(0, result.getInvariants().getDuplicateWmsBusinessEffects()); - assertEquals(0, result.getInvariants().totalSafetyViolations()); - assertEquals(result.canonicalView(), secondRun.canonicalView()); + void rawModeRemainsA240SnapshotCompatibilityBaseline() { + assertEquals(RAW_SNAPSHOT, rawResult.getExecutionMode()); + assertEquals(240, rawResult.getInputAudit().getRawSnapshotCount()); + assertEquals(240, rawResult.getTaskResults().size()); + assertEquals(240, rawResult.getCompletedCount()); + assertEquals(240, rawResult.getArchiveCount()); + assertEquals(240, rawResult.getWmsBusinessEffects()); + assertEquals(0, rawResult.getInvariants().totalSafetyViolations()); + } + + /** 默认业务模式必须让 76 条父任务全部经过中心调度并正确释放原车。 */ + @Test + void businessModeRunsThe76ReconstructedParentTasksSafely() { + assertEquals(SOURCE_ALIGNED_BUSINESS, businessResult.getExecutionMode()); + HistoricalInputAudit audit = businessResult.getInputAudit(); + assertEquals(240, audit.getRawSnapshotCount()); + assertEquals(98, audit.getUniqueQueueCount()); + assertEquals(142, audit.getDuplicateSnapshotCount()); + assertEquals(76, audit.getBusinessTaskCount()); + assertEquals(17, audit.getOperationalQueueCount()); + assertEquals(76, businessResult.getTaskResults().size()); + assertEquals(76, businessResult.getCompletedCount()); + assertEquals(0, businessResult.getWaitingCount()); + assertEquals(0, businessResult.getFailedCount()); + assertEquals(76, businessResult.getArchiveCount()); + assertEquals(76, businessResult.getWmsBusinessEffects()); + assertEquals(0, businessResult.getIncompleteVehicleReleaseCount()); + assertEquals(0, businessResult.getInvariants().totalSafetyViolations()); + assertNotNull(businessResult.businessCompletionDistribution().getP95Millis()); + assertNotNull(businessResult.vehicleOccupancyDistribution().getP99Millis()); + assertFalse(businessResult.holdCountByPhase().isEmpty()); + assertFalse(businessResult.retryCountByPhase().isEmpty()); } } 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 9848ec4..7040829 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 @@ -29,7 +29,8 @@ class HistoricalDispatchSimulationReportWriterTest { void writesAuditableMetricsTaskEventsInvariantsAndHashes() throws Exception { HistoricalQueueDataset dataset = new HistoricalQueueLoader().load( getClass().getClassLoader(), "shadow/history"); - HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(dataset); + HistoricalSimulationInput input = + new HistoricalSimulationInputFactory().createRawSnapshots(dataset); DeterministicFaultPlan.Builder faults = DeterministicFaultPlan.builder(); for (int i = 0; i < input.getTasks().size(); i += 37) { faults.plcFailure(input.getTasks().get(i).getSimulationTaskId(), 1, SEND_FAILURE); @@ -41,11 +42,17 @@ class HistoricalDispatchSimulationReportWriterTest { for (String name : new String[]{"summary.json", "route-decisions.json", "task-results.csv", "tick-events.csv", "invariant-results.json", - "manifest.sha256"}) { + "summary.md", "manifest.sha256"}) { assertTrue(Files.isRegularFile(output.resolve(name)), name); } JsonNode summary = new ObjectMapper().readTree(output.resolve("summary.json").toFile()); assertEquals("REAL_HISTORY_DRIVEN_DISPATCH_SIMULATION", summary.path("mode").asText()); + assertEquals("RAW_SNAPSHOT", summary.path("executionMode").asText()); + assertEquals(240, summary.path("rawSnapshotCount").asInt()); + assertEquals(98, summary.path("uniqueQueueCount").asInt()); + assertEquals(142, summary.path("duplicateSnapshotCount").asInt()); + assertEquals(76, summary.path("businessTaskCount").asInt()); + assertEquals(17, summary.path("operationalQueueCount").asInt()); assertEquals(240, summary.path("inputCount").asInt()); assertEquals(240, summary.path("completedCount").asInt()); assertEquals(0, summary.path("waitingCount").asInt()); @@ -66,6 +73,12 @@ class HistoricalDispatchSimulationReportWriterTest { assertTrue(summary.path("wmsIdempotent").asBoolean()); assertTrue(summary.has("waitingTime")); assertTrue(summary.has("completionTime")); + assertTrue(summary.has("businessCompletionTime")); + assertTrue(summary.has("vehicleOccupancyTime")); + assertTrue(summary.has("automaticReturnTime")); + assertTrue(summary.has("holdCountByPhase")); + assertTrue(summary.has("retryCountByPhase")); + assertEquals(0, summary.path("incompleteVehicleReleaseCount").asInt()); assertTrue(summary.has("historicalDuration")); assertTrue(summary.has("holdReasons")); assertTrue(summary.has("retryReasons")); @@ -86,8 +99,14 @@ class HistoricalDispatchSimulationReportWriterTest { 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")); + assertTrue(taskCsv.contains("source_front,target_front,phase_paths")); + assertTrue(taskCsv.contains("business_completed_ms,vehicle_released_ms")); + String markdown = new String(Files.readAllBytes(output.resolve("summary.md")), + StandardCharsets.UTF_8); + assertTrue(markdown.contains("RAW_SNAPSHOT")); + assertTrue(markdown.contains("240")); List manifest = Files.readAllLines(output.resolve("manifest.sha256"), StandardCharsets.UTF_8); - assertEquals(5, manifest.size()); + assertEquals(6, manifest.size()); } } diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorBusinessDeadlockTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorBusinessDeadlockTest.java new file mode 100644 index 0000000..227827b --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorBusinessDeadlockTest.java @@ -0,0 +1,158 @@ +package com.ft546.wcs.shadow.history.simulation; + +import com.ft546.wcs.shadow.history.HistoricalQueueDataset; +import com.ft546.wcs.shadow.history.HistoricalQueueLoader; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.SEND_FAILURE; + +class HistoricalDispatchSimulatorBusinessDeadlockTest { + /** + * 三个真实父任务共享 010701、050701、070101 时,第三辆车必须在取货前等待已有载货落点承诺。 + * + *

如果调度中心只检查当前空车阶段,第三辆车会载货停在 070101,依次挡住 20207 和 10500, + * 形成无法通过换车恢复的三车占点环;正确前瞻后,三个任务应在 500 tick 内全部完成。 + */ + @Test + void defersPickupWhoseLoadedDestinationIsAlreadyCommitted() { + HistoricalDispatchSimulationResult result = runFocused( + "10500", "20207", "10501"); + + assertEquals(3, result.getCompletedCount()); + assertEquals(0, result.getWaitingCount()); + assertEquals(0, result.getFailedCount()); + assertEquals(0, result.getInvariants().totalSafetyViolations()); + SimulationTaskResult deferred = result.getTaskResults().stream() + .filter(task -> "10501".equals(task.getRecord().getTaskNo())) + .findFirst().orElseThrow(AssertionError::new); + assertTrue(deferred.getHoldReasons() + .containsKey("DELIVERY_DESTINATION_COMMITTED")); + } + + /** + * 两条出库任务共享载货落点、且该点先被空闲车占用时,中心必须在取货前完成清障并串行准入。 + * + *

若调度只在取货后检查最终落点,两台车会各自载货停在源通道前端,并互相等待同一个终点承诺; + * 该测试要求 11 条真实父任务全部结束,用结果证明没有留下这种不可换车的载货闭环。 + */ + @Test + void clearsOccupiedSharedDestinationBeforeAnotherVehiclePicksCargo() { + HistoricalDispatchSimulationResult result = runFirstBusinessTasks(11); + + assertEquals(11, result.getCompletedCount()); + assertEquals(0, result.getWaitingCount()); + assertEquals(0, result.getFailedCount()); + assertEquals(0, result.getInvariants().totalSafetyViolations()); + } + + /** + * 相邻目标任务同时进入主干道时,终点清障不能把空闲车移到另一条活动路线的下一节点。 + * + *

真实任务 20210、20211 分别驶向 070101、050101;错误清障会让三台车首尾等待, + * 因此这里要求包含它们的前 12 条父任务全部完成,证明中心能够解除主干道交通闭环。 + */ + @Test + void avoidsClearanceCycleAcrossAdjacentBusinessRoutes() { + HistoricalDispatchSimulationResult result = runFirstBusinessTasks(12, 250); + + assertEquals(12, result.getCompletedCount()); + assertEquals(0, result.getWaitingCount()); + assertEquals(0, result.getFailedCount()); + assertEquals(0, result.getInvariants().totalSafetyViolations()); + } + + /** + * 完整业务队列中,已绑定车辆的下一阶段必须先于可重派的新任务取得走廊和交通资源。 + * + *

该场景同时覆盖 10520 对 040401 的业务落点承诺、90005 的后续载货阶段,以及 20216 + * 的空车重派;若新任务每 tick 抢先预约,载货任务会永久 HOLD。76 条全部完成才证明阶段连续性。 + */ + @Test + void prioritizesBoundBusinessPhaseBeforeNewQueueAssignments() { + HistoricalDispatchSimulationResult result = runFirstBusinessTasks( + 76, 5_000, true); + + assertEquals(76, result.getCompletedCount()); + assertEquals(0, result.getWaitingCount()); + assertEquals(0, result.getFailedCount()); + assertEquals(0, result.getInvariants().totalSafetyViolations()); + } + + /** 从完整真实输入中抽取指定父任务,同时保留权威地图、初始车队和换层端口。 */ + private HistoricalDispatchSimulationResult runFocused(String... requestedTaskNumbers) { + HistoricalQueueDataset dataset = new HistoricalQueueLoader().load( + getClass().getClassLoader(), "shadow/history"); + HistoricalSimulationInput complete = + new HistoricalSimulationInputFactory().create(dataset); + Set taskNumbers = new LinkedHashSet( + Arrays.asList(requestedTaskNumbers)); + List focusedTasks = + new ArrayList(); + for (HistoricalSimulationTask task : complete.getTasks()) { + if (taskNumbers.contains(task.getRecord().getTaskNo())) { + focusedTasks.add(task); + } + } + HistoricalSimulationInput focused = new HistoricalSimulationInput( + focusedTasks, Collections.emptyList(), + complete.getGridMap(), complete.getTrafficMap(), + complete.getInitialVehicles(), complete.getFloorTransferTopology(), + complete.getExecutionMode(), complete.getInputAudit(), + complete.getChannelFrontTopology()); + SimulationConfig config = new SimulationConfig( + 100, 30_000L, 3, 100, 200); + + return new HistoricalDispatchSimulator().simulate( + focused, config, DeterministicFaultPlan.none()); + } + + /** 回放真实排序中的前 N 个父任务,保留换层、站位和终点承诺形成的必要上下文。 */ + private HistoricalDispatchSimulationResult runFirstBusinessTasks(int count) { + return runFirstBusinessTasks(count, 1_000); + } + + /** 使用指定 tick 上限回放前 N 个父任务,便于把死锁形成事件保留在失败诊断窗口中。 */ + private HistoricalDispatchSimulationResult runFirstBusinessTasks( + int count, int maximumTicks) { + return runFirstBusinessTasks(count, maximumTicks, false); + } + + /** 可选注入真实数据验收使用的周期性 PLC 首发失败,保持故障下的调度时序一致。 */ + private HistoricalDispatchSimulationResult runFirstBusinessTasks( + int count, int maximumTicks, boolean periodicPlcFailures) { + HistoricalQueueDataset dataset = new HistoricalQueueLoader().load( + getClass().getClassLoader(), "shadow/history"); + HistoricalSimulationInput complete = + new HistoricalSimulationInputFactory().create(dataset); + List focusedTasks = new ArrayList( + complete.getTasks().subList(0, count)); + HistoricalSimulationInput focused = new HistoricalSimulationInput( + focusedTasks, Collections.emptyList(), + complete.getGridMap(), complete.getTrafficMap(), + complete.getInitialVehicles(), complete.getFloorTransferTopology(), + complete.getExecutionMode(), complete.getInputAudit(), + complete.getChannelFrontTopology()); + SimulationConfig config = new SimulationConfig( + 100, 30_000L, 3, 100, maximumTicks); + DeterministicFaultPlan faults = DeterministicFaultPlan.none(); + if (periodicPlcFailures) { + DeterministicFaultPlan.Builder builder = DeterministicFaultPlan.builder(); + for (int index = 0; index < focusedTasks.size(); index += 37) { + builder.plcFailure(focusedTasks.get(index).getSimulationTaskId(), + 1, SEND_FAILURE); + } + faults = builder.build(); + } + return new HistoricalDispatchSimulator().simulate( + focused, config, faults); + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulationMetricsTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulationMetricsTest.java index cf31abf..2edf850 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulationMetricsTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulationMetricsTest.java @@ -36,4 +36,21 @@ class SimulationMetricsTest { assertNull(metrics.average(Collections.emptyList())); assertNull(metrics.percentile(Collections.emptyList(), 0.95)); } + + /** 时长汇总复用同一平均值和最近秩分位数口径,供业务完成和车辆占用指标共同使用。 */ + @Test + void summarizesAverageP95AndP99AsOneImmutableValue() { + SimulationDurationSummary summary = new SimulationMetrics().summarize( + Arrays.asList(10L, 20L, 30L, 40L)); + + assertEquals(25.0, summary.getAverageMillis()); + assertEquals(40L, summary.getP95Millis()); + assertEquals(40L, summary.getP99Millis()); + + SimulationDurationSummary empty = new SimulationMetrics().summarize( + Collections.emptyList()); + assertNull(empty.getAverageMillis()); + assertNull(empty.getP95Millis()); + assertNull(empty.getP99Millis()); + } } diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java index 00efb50..2830541 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java @@ -88,8 +88,9 @@ class HistoricalDispatchVisualizerServerTest { Map summary = map(map(response.body).get("summary")); assertEquals(200, response.status); - assertEquals(240, summary.get("inputCount")); - assertEquals(240, summary.get("completedCount")); + // 历史接口默认运行去重组装后的业务父任务;240 条原始快照由输入审计单独展示。 + assertEquals(input.getTasks().size(), summary.get("inputCount")); + assertEquals(input.getTasks().size(), summary.get("completedCount")); assertEquals(0, summary.get("waitingCount")); assertEquals(0, summary.get("failedCount")); assertEquals(Boolean.FALSE, map(response.body).get("pathInfoUsedForPlanning")); 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 e9a179c..7c8e0ad 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 @@ -104,7 +104,8 @@ class SimulationVisualizationMapperTest { assertTrue(serviceEvidence.containsKey("quality")); assertTrue(serviceEvidence.containsKey("candidates")); assertEquals(sourceTask.getRerouteCount(), tasks.get(0).get("rerouteCount")); - assertEquals(Collections.singletonList(1), tasks.get(0).get("pathVersions")); + // A/B 审计也走二段式业务链,每个重新规划的业务阶段都必须作为独立路径版本保留下来。 + assertEquals(Arrays.asList(1, 2, 3), tasks.get(0).get("pathVersions")); assertTrue(tasks.get(0).containsKey("rerouteEvents")); Map comparison = map(tasks.get(0).get("pathComparison")); assertEquals(0, comparison.get("sharedEdgeCount"));