From 31fa643993368f031e2f289349146ea5493a5764 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Fri, 7 Aug 2026 10:50:23 +0800 Subject: [PATCH] feat(javaTest): orchestrate cross-floor dispatch --- .../simulation/ClearanceMovePlanner.java | 52 +- .../HistoricalDispatchSimulator.java | 844 +++++++++++++++++- .../simulation/HistoricalSimulationInput.java | 29 + .../HistoricalSimulationInputFactory.java | 21 +- .../PointToPointSimulationInputFactory.java | 3 +- .../simulation/SimulationTaskResult.java | 18 +- .../simulation/ClearanceMovePlannerTest.java | 24 + .../HistoricalDispatchSimulatorTest.java | 472 ++++++++++ .../HistoricalSimulationInputFactoryTest.java | 4 + 9 files changed, 1441 insertions(+), 26 deletions(-) diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/ClearanceMovePlanner.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/ClearanceMovePlanner.java index fd9cbc7..afc4a31 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/ClearanceMovePlanner.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/ClearanceMovePlanner.java @@ -32,6 +32,42 @@ public final class ClearanceMovePlanner { Set occupiedSites, Set reservedResources, GridMap map) { + return planInternal(vehicleId, currentSite, ownerTaskId, ownerRemainingRoute, + occupiedSites, reservedResources, map, false); + } + + /** + * 在父业务路线尚未预约和激活之前,把占住其终点的空闲车移到路线外的安全支路。 + * + *

与活动任务清障不同,此时父车辆尚未进入通道,所以清障路径可以暂时经过未来业务路线;但是 + * 清障终点仍必须位于该路线之外,避免只把阻塞车从终点挪到前置节点后再次挡住父任务。调用方必须 + * 等清障完成后才能提交父路线。 + * + * @param vehicleId 占住业务终点、需要先让位的车辆。 + * @param currentSite 当前被占用的业务终点。 + * @param ownerTaskId 等待取得路线承诺的父任务。 + * @param ownerRoute 父任务准备提交但尚未预约的完整路线。 + * @param occupiedSites 全车队当前占点。 + * @param reservedResources 已由其他物理动作持有的资源。 + * @param map 当前项目路网。 + * @return 能把车辆停到父路线外时返回逐节点清障动作,否则返回空。 + */ + public Optional planBeforeRouteCommit( + String vehicleId, String currentSite, String ownerTaskId, + List ownerRoute, Set occupiedSites, + Set reservedResources, GridMap map) { + return planInternal(vehicleId, currentSite, ownerTaskId, ownerRoute, + occupiedSites, reservedResources, map, true); + } + + /** + * 统一选择清障终点;只有准入前清障可以穿过尚未生效的父路线。 + */ + private Optional planInternal( + String vehicleId, String currentSite, String ownerTaskId, + List ownerRemainingRoute, Set occupiedSites, + Set reservedResources, GridMap map, + boolean mayTraverseOwnerRoute) { if (vehicleId == null || currentSite == null || ownerTaskId == null || ownerRemainingRoute == null || occupiedSites == null || reservedResources == null || map == null) { @@ -47,6 +83,19 @@ public final class ClearanceMovePlanner { } excluded.remove(currentSite); + // 准入前父路线还没有形成物理承诺,只限制清障终点;真正的占车和节点资源仍是路径硬障碍。 + Set pathObstacles = new LinkedHashSet(excluded); + if (mayTraverseOwnerRoute) { + pathObstacles.removeAll(ownerRemainingRoute); + pathObstacles.addAll(occupiedSites); + for (ResourceKey resource : reservedResources) { + if (resource.value().startsWith("NODE:")) { + pathObstacles.add(resource.value().substring("NODE:".length())); + } + } + pathObstacles.remove(currentSite); + } + GridPathPlanner planner = new GridPathPlanner(); List candidates = new ArrayList(); // 枚举全图而不是只看邻居,使被阻塞车辆能找到最近的可达让路站点。 @@ -54,7 +103,8 @@ public final class ClearanceMovePlanner { Collections.sort(siteIds); for (String target : siteIds) { if (target.equals(currentSite) || excluded.contains(target)) continue; - List route = planner.shortestPath(map, currentSite, target, excluded); + List route = planner.shortestPath( + map, currentSite, target, pathObstacles); if (!route.isEmpty()) { candidates.add(new Candidate(target, route, map.getNeighbors(target).size() <= 1)); 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 2d2f707..72d84a5 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 @@ -55,8 +55,29 @@ public final class HistoricalDispatchSimulator { public HistoricalDispatchSimulationResult simulate( HistoricalSimulationInput input, SimulationConfig config, DeterministicFaultPlan faults) { - if (input == null || config == null || faults == null) { - throw new IllegalArgumentException("input, config and fault plan are required"); + return simulate(input, config, faults, new SimulatedFloorTransferGateway()); + } + + /** + * 使用调用方提供的换层适配器执行同一中心调度流程。 + * + *

离线审计默认传入 {@link SimulatedFloorTransferGateway};未来迁移到真实 WCS 时,项目适配器 + * 可以把 {@link FloorTransferJob} 映射到现有提升机队列,而无需修改选车、锁车、完成后重规划和 + * 普通交通算法。 + * @param input 历史或点到点仿真输入。 + * @param config 仿真倍率、重试和看门狗配置。 + * @param faults 确定性车辆与 PLC 故障计划。 + * @param floorTransferGateway 换层作业的幂等提交与完成事实端口。 + * @return 包含普通移动和换层编排证据的仿真结果。 + */ + public HistoricalDispatchSimulationResult simulate( + HistoricalSimulationInput input, SimulationConfig config, + DeterministicFaultPlan faults, + FloorTransferGateway floorTransferGateway) { + if (input == null || config == null || faults == null + || floorTransferGateway == null) { + throw new IllegalArgumentException( + "input, config, fault plan and floor transfer gateway are required"); } // 收集历史 create_date,以最早任务为零点建立加速后的任务到达时钟。 List arrivalDates = new ArrayList(); @@ -85,12 +106,15 @@ public final class HistoricalDispatchSimulator { Collections.emptyList(), Collections.emptyList(), 0L, null, null, 0, 0, 0, Collections.emptyMap(), Collections.emptyMap(), - Collections.emptyList(), 0)); + Collections.emptyList(), 0, null, Collections.emptyList())); } Map vehicles = createRuntimes(input); Map active = new LinkedHashMap(); Map clearanceMoves = new LinkedHashMap(); + // 换层中的车辆和父任务单独保存,既不属于普通活动路线,也不能被再次派车或用于清障。 + Map floorTransfers = + new LinkedHashMap(); ResourceLedger ledger = new ResourceLedger(input.getTrafficMap().getCapacities()); SimulationInvariantSummary invariants = new SimulationInvariantSummary(); // 初始车位也必须符合资源容量约束;否则把数据问题记录到不变量报告中。 @@ -103,7 +127,6 @@ public final class HistoricalDispatchSimulator { VehicleAssignmentService assignment = new VehicleAssignmentService(); CorridorReservationManager reservations = new CorridorReservationManager(); ClearanceMovePlanner clearancePlanner = new ClearanceMovePlanner(); - long now = 0L; long tick = 0L; int expected = input.getTasks().size() + input.getFailedInputs().size(); @@ -114,8 +137,14 @@ public final class HistoricalDispatchSimulator { applyVehicleEvents(tick, now, faults, vehicles, active, clearanceMoves, queue, executions, reservations, events); assignReadyTasks(tick, now, input, queue, vehicles, active, - clearanceMoves, executions, terminal, routePlanner, routeEvaluator, - assignment, plc, ledger, reservations, events); + 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, + floorTransferGateway, plc, ledger, reservations, events); // 本 tick 已通过的边被共享给两类移动,禁止另一车辆同 tick 重复或反向穿越。 Set traversedEdges = new LinkedHashSet(); ClearanceProgress clearanceProgress = advanceClearanceMoves( @@ -125,21 +154,31 @@ public final class HistoricalDispatchSimulator { tick, now, input, config, queue, vehicles, active, executions, terminal, routePlanner, routeEvaluator, ledger, plc, archive, wms, invariants, reservations, - clearanceMoves, clearancePlanner, events, traversedEdges); + clearanceMoves, floorTransfers, clearancePlanner, events, + traversedEdges); verifyLedger(ledger, invariants); // 尚未到达的未来任务不应被误判为死锁,因为它只是在等待 create_date 对应的时刻。 boolean awaitingFutureArrival = queue.size() > 0 && active.isEmpty() && clearanceMoves.isEmpty() + && floorTransfers.isEmpty() && queue.readyAt(now).isEmpty(); if (watchdog.observe(terminal.size(), - businessProgress || clearanceProgress.moved || awaitingFutureArrival, + businessProgress || transferProgress + || clearanceProgress.moved || awaitingFutureArrival, clearanceProgress.completed)) { invariants.deadlock(); throw new IllegalStateException("simulation made no physical progress at tick " + tick + ", terminal=" + terminal.size() + ", queued=" + queue.size() + ", active=" + active.keySet() - + ", clearance=" + clearanceMoves.keySet()); + + ", activeDetails=" + activeDiagnostics(active, vehicles) + + ", vehicles=" + vehicleDiagnostics(vehicles, ledger) + + ", transfer=" + floorTransfers.keySet() + + ", transferDetails=" + transferDiagnostics( + floorTransfers, vehicles, ledger) + + ", clearance=" + clearanceMoves.keySet() + + ", currentTickHolds=" + currentTickHoldDiagnostics(events, tick) + + ", recentEvents=" + recentEventDiagnostics(events, 80)); } now += STEP_MILLIS; tick++; @@ -148,6 +187,7 @@ public final class HistoricalDispatchSimulator { throw new IllegalStateException("simulation tick limit reached: tick=" + tick + ", terminal=" + terminal.size() + ", expected=" + expected + ", queued=" + queue.size() + ", active=" + active.keySet() + + ", transfer=" + floorTransfers.keySet() + ", clearance=" + clearanceMoves.keySet()); } @@ -281,6 +321,8 @@ public final class HistoricalDispatchSimulator { * @param routePlanner 生成 110% 物理距离内的确定性无环候选。 * @param routeEvaluator 使用同一交通快照按固定业务元组选择候选。 * @param assignment 执行该处理所需的 assignment 输入。 + * @param clearancePlanner 为占住单通道终点的空闲车生成准入前清障路线。 + * @param floorTransferGateway 向现有 WCS 幂等提交换层作业的端口。 * @param plc 执行该处理所需的 plc 输入。 * @param ledger 当前节点和边资源的权威台账。 * @param reservations 执行该处理所需的 reservations 输入。 @@ -292,11 +334,14 @@ public final class HistoricalDispatchSimulator { Map vehicles, Map active, Map clearanceMoves, + Map floorTransfers, Map executions, Map terminal, RouteCandidatePlanner routePlanner, RouteQualityEvaluator routeEvaluator, VehicleAssignmentService assignment, + ClearanceMovePlanner clearancePlanner, + FloorTransferGateway floorTransferGateway, SimulatedPlcGateway plc, ResourceLedger ledger, CorridorReservationManager reservations, @@ -324,9 +369,12 @@ public final class HistoricalDispatchSimulator { List candidates = new ArrayList(); Map approaches = new LinkedHashMap(); + Map transferCandidates = + new LinkedHashMap(); for (SimulatedVehicleRuntime vehicle : vehicles.values()) { if (!vehicle.isAssignmentEligible() || active.containsKey(vehicle.getVehicleId()) - || clearanceMoves.containsKey(vehicle.getVehicleId())) { + || clearanceMoves.containsKey(vehicle.getVehicleId()) + || floorTransfers.containsKey(vehicle.getVehicleId())) { continue; } RouteCandidateSet approach = routePlanner.plan(input.getGridMap(), @@ -335,6 +383,15 @@ public final class HistoricalDispatchSimulator { if (!approach.getCandidates().isEmpty()) { candidates.add(vehicle.snapshot()); approaches.put(vehicle.getVehicleId(), approach); + continue; + } + + // 普通同层路网不可达时,再检查“到本层端口 + 换层 + 从目标端口到源点”的设备作业可行性。 + TransferFeasibility transfer = transferFeasibility( + task, vehicle, input, routePlanner); + if (transfer != null) { + candidates.add(vehicle.snapshot()); + transferCandidates.put(vehicle.getVehicleId(), transfer); } } if (candidates.isEmpty()) { @@ -358,6 +415,129 @@ public final class HistoricalDispatchSimulator { dispatchTask, candidates, input.getGridMap().getSites()); if (!decision.getVehicle().isPresent()) continue; String vehicleId = decision.getVehicle().get().getVehicleId(); + Execution execution = executions.get(task.getSimulationTaskId()); + ClearanceMove pendingDestinationClearance = clearanceForTask( + task.getSimulationTaskId(), clearanceMoves); + if (pendingDestinationClearance != null) { + hold(execution, "DESTINATION_CLEARANCE_IN_PROGRESS"); + events.add(event(tick, now, "HOLD", task.getSimulationTaskId(), + pendingDestinationClearance.getVehicleId(), + "DESTINATION_CLEARANCE_IN_PROGRESS", + pendingDestinationClearance.getSourceSite(), + pendingDestinationClearance.getTargetSite())); + continue; + } + + TransferFeasibility transfer = transferCandidates.get(vehicleId); + if (transfer != null) { + FloorTransferJob job = FloorTransferJob.create( + task.getSimulationTaskId(), vehicleId, + transfer.sourceFloor, transfer.targetFloor, + transfer.sourcePort, transfer.targetPort, + queue.effectivePriorityFor(task, now)); + + // 换层开始前先按目标端口估算完成后的整条路线,并保护目标出口及其走廊方向。 + TrafficRouteSnapshot transferTraffic = trafficSnapshot( + vehicles, active, ledger, reservations); + RoutePlanDecision postTransferApproach = routeEvaluator.select( + input.getGridMap(), routePlanner.plan(input.getGridMap(), + transfer.targetPort, task.getPhysicalSource(), + Collections.emptySet(), + RoutePlanningPolicy.defaultPolicy()), + transferTraffic, task.getSimulationTaskId(), vehicleId); + RoutePlanDecision postTransferService = routeEvaluator.select( + input.getGridMap(), serviceCandidates, transferTraffic, + task.getSimulationTaskId(), vehicleId); + List postTransferRoute = new ArrayList( + postTransferApproach.getSelectedPath()); + for (int index = 1; + index < postTransferService.getSelectedPath().size(); index++) { + postTransferRoute.add(postTransferService.getSelectedPath().get(index)); + } + String destinationHold = destinationCommitmentReason( + vehicleId, postTransferRoute.get(postTransferRoute.size() - 1), + vehicles, active, floorTransfers, input.getGridMap()); + if (destinationHold != null) { + // 换层前就锁住父任务终点,避免设备完成后才发现单通道末端已有车辆且无法退出。 + if ("DESTINATION_OCCUPIED".equals(destinationHold)) { + progress |= planDestinationClearance(tick, now, + task.getSimulationTaskId(), vehicleId, + postTransferRoute, vehicles, active, clearanceMoves, + floorTransfers, clearancePlanner, ledger, + input.getGridMap(), events); + } + hold(execution, destinationHold); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + task.getSimulationTaskId(), vehicleId, + destinationHold, transfer.sourcePort, + postTransferRoute.get(postTransferRoute.size() - 1))); + continue; + } + if (!transferTargetPrepared(transfer.targetPort, active, + floorTransfers, ledger)) { + hold(execution, "TRANSFER_TARGET_NOT_PREPARED"); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + task.getSimulationTaskId(), vehicleId, + "TRANSFER_TARGET_NOT_PREPARED", transfer.sourcePort, + transfer.targetPort)); + continue; + } + CorridorReservationManager.ReservationDecision preparedRoute = + reservations.reserve(task.getSimulationTaskId(), + postTransferRoute, + queue.effectivePriorityFor(task, now), + queue.arrivalMillis(task.getSimulationTaskId())); + if (!preparedRoute.isGranted()) { + hold(execution, preparedRoute.getReason()); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + task.getSimulationTaskId(), vehicleId, + preparedRoute.getReason(), transfer.sourcePort, + transfer.targetPort)); + continue; + } + ResourceLedger.ProtectionResult preparedExit = ledger.evaluate( + vehicleId, Collections.singletonList( + ResourceKey.node(transfer.targetPort))); + if (!preparedExit.isGranted()) { + reservations.releaseTask(task.getSimulationTaskId()); + hold(execution, "TRANSFER_TARGET_NOT_PREPARED"); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + task.getSimulationTaskId(), vehicleId, + "TRANSFER_TARGET_NOT_PREPARED", transfer.sourcePort, + transfer.targetPort)); + continue; + } + FloorTransferGateway.Submission submission = floorTransferGateway.submit(job); + if (!submission.isAccepted()) { + // 设备未接受时撤销尚未形成物理动作的目标出口和走廊保护,允许其他任务继续通行。 + reservations.releaseTask(task.getSimulationTaskId()); + ledger.release(vehicleId, Collections.singletonList( + ResourceKey.node(transfer.targetPort))); + // 提升机忙或明确拒绝只阻断本轮;父任务仍在原优先队列中,可继续老化并重新比较车辆。 + hold(execution, submission.getReason()); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + task.getSimulationTaskId(), vehicleId, + submission.getReason(), transfer.sourcePort, + transfer.targetPort)); + continue; + } + + // 作业被接受后同时锁住父任务和车辆;重复提交返回同一作业,不增加业务效果。 + recordAssignment(execution, vehicleId, now); + execution.assignmentMode = AssignmentMode.FLOOR_TRANSFER; + if (!execution.floorTransferJobs.contains(job)) { + execution.floorTransferJobs.add(job); + } + floorTransfers.put(vehicleId, new FloorTransferExecution( + execution, job, postTransferRoute)); + queue.remove(task.getSimulationTaskId()); + events.add(event(tick, now, "FLOOR_TRANSFER_SUBMITTED", + task.getSimulationTaskId(), vehicleId, + submission.getReason(), transfer.sourcePort, + transfer.targetPort)); + progress = true; + continue; + } // 所有候选共用这一刻的占点、边预约、走廊方向和活动路线,保证比较口径一致。 TrafficRouteSnapshot traffic = trafficSnapshot( @@ -371,8 +551,22 @@ public final class HistoricalDispatchSimulator { List route = new ArrayList(approachDecision.getSelectedPath()); List servicePath = serviceDecision.getSelectedPath(); for (int i = 1; i < servicePath.size(); i++) route.add(servicePath.get(i)); - Execution execution = executions.get(task.getSimulationTaskId()); - + String destinationHold = destinationCommitmentReason( + vehicleId, route.get(route.size() - 1), vehicles, active, + floorTransfers, input.getGridMap()); + if (destinationHold != null) { + // 终点是车辆完成后继续占用的真实节点;先到先服务,跟车不能提前锁死唯一离站方向。 + if ("DESTINATION_OCCUPIED".equals(destinationHold)) { + progress |= planDestinationClearance(tick, now, + task.getSimulationTaskId(), vehicleId, route, + vehicles, active, clearanceMoves, floorTransfers, + clearancePlanner, ledger, input.getGridMap(), events); + } + hold(execution, destinationHold); + events.add(event(tick, now, "HOLD", task.getSimulationTaskId(), vehicleId, + destinationHold, route.get(0), route.get(route.size() - 1))); + continue; + } // 提前预约整条路线的走廊方向。冲突只产生 HOLD,不把任务从等待队列移除。 CorridorReservationManager.ReservationDecision reservation = reservations.reserve( task.getSimulationTaskId(), route, @@ -386,10 +580,9 @@ public final class HistoricalDispatchSimulator { } // 预约成功才正式交接给活动任务;这些字段同时构成“历史方案与新方案”的可审计差异。 - if (execution.firstAssignedMillis == null) execution.firstAssignedMillis = now; - execution.lastVehicleId = vehicleId; + recordAssignment(execution, vehicleId, now); + if (execution.assignmentMode == null) execution.assignmentMode = AssignmentMode.DIRECT; execution.plannedPath = new ArrayList(servicePath); - execution.assignedVehicles.add(vehicleId); execution.pathVersion++; execution.planningEvidence.add(new RoutePlanningEvidence( RoutePlanningEvidence.PlanningStage.APPROACH, @@ -428,10 +621,563 @@ public final class HistoricalDispatchSimulator { if (!planner.plan(input.getGridMap(), vehicle.getCurrentSite(), task.getPhysicalSource(), Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()).getCandidates().isEmpty()) return true; + if (transferFeasibility(task, vehicle, input, planner) != null) return true; } return false; } + /** + * 检查目标出口尚未被占用,也不在其他已承诺普通路线或换层作业中。 + * + *

走廊预约允许同向共享,不能单独保护终点节点,所以换层提交还必须执行这一道端口门禁。 + */ + private boolean transferTargetPrepared( + String targetPort, Map active, + Map floorTransfers, + ResourceLedger ledger) { + if (!ledger.holdersFor(ResourceKey.node(targetPort)).isEmpty()) return false; + for (ActiveTask task : active.values()) { + if (task.route.subList(task.index, task.route.size()).contains(targetPort)) { + return false; + } + } + for (FloorTransferExecution transfer : floorTransfers.values()) { + if (targetPort.equals(transfer.job.getTargetSite())) return false; + } + return true; + } + + /** + * 判断业务终点是否已由另一辆车占用或由另一条在途任务承诺。 + * + *

走廊预约允许同向跟车,但任务完成后车辆仍会真实占住终点。对于只有一个进出口的取放货口, + * 若第二辆车提前进入同一通道,首车完成后将无法反向离站。该门禁把终点视为任务级承诺:当前占车 + * 返回 {@code DESTINATION_OCCUPIED},普通活动任务或换层父任务将驶入同一点时返回 + * {@code DESTINATION_COMMITTED}。候选车辆自己位于终点不算冲突,因为它可以先执行离站业务。 + * + * @param vehicleId 本次候选车辆编号。 + * @param destination 本次服务路线最终停靠的业务站点。 + * @param vehicles 全车队当前位置事实。 + * @param active 已取得路线承诺的普通业务任务。 + * @param floorTransfers 已提交且完成后会接入普通路线的换层父任务。 + * @param map 当前项目真实路网,用于识别只有一个物理进出口的终点。 + * @return 可审计的 HOLD 原因;没有冲突时返回 {@code null}。 + */ + private String destinationCommitmentReason( + String vehicleId, String destination, + Map vehicles, + Map active, + Map floorTransfers, + com.ft546.wcs.demo.traffic.GridMap map) { + // 多出口节点仍由现有清障和交通资源裁决处理;这里只拦截无法绕行或侧向让位的单通道终点。 + if (map.getNeighbors(destination).size() > 1) return null; + for (SimulatedVehicleRuntime vehicle : vehicles.values()) { + if (!vehicleId.equals(vehicle.getVehicleId()) + && destination.equals(vehicle.getCurrentSite())) { + return "DESTINATION_OCCUPIED"; + } + } + 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"; + } + } + 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"; + } + } + return null; + } + + /** + * 在父任务取得路线预约前,为占住单通道终点的空闲车建立一条路线外清障动作。 + * + *

该阶段父路线尚未生效,所以阻塞车允许沿未来通道退出;清障仍使用普通逐节点交通裁决、PLC ACK + * 和资源释放。父任务通过 {@link ClearanceMove#getOwnerTaskId()} 保持等待,直到 + * {@code CLEARANCE_COMPLETE} 后才重新参加本轮选车和路线预约。 + */ + private boolean planDestinationClearance( + 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 (!clearanceMoves.isEmpty()) return false; + String destination = ownerRoute.get(ownerRoute.size() - 1); + Set occupiedSites = new LinkedHashSet(); + for (SimulatedVehicleRuntime vehicle : vehicles.values()) { + occupiedSites.add(vehicle.getCurrentSite()); + } + addCommittedDestinations(occupiedSites, active, floorTransfers); + Set reservedResources = new LinkedHashSet(); + 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); + if (blocker == null || !blocker.isAssignmentEligible() + || active.containsKey(blockerId) + || clearanceMoves.containsKey(blockerId) + || floorTransfers.containsKey(blockerId)) { + continue; + } + java.util.Optional planned = + clearancePlanner.planBeforeRouteCommit( + blockerId, blocker.getCurrentSite(), ownerTaskId, + ownerRoute, occupiedSites, reservedResources, map); + if (!planned.isPresent()) continue; + ClearanceMove clearance = planned.get(); + clearanceMoves.put(blockerId, clearance); + events.add(event(tick, now, "CLEARANCE_PLANNED", ownerTaskId, + blockerId, "DESTINATION_BLOCKER", + clearance.getSourceSite(), clearance.getTargetSite())); + return true; + } + return false; + } + + /** + * 把暂时无人占用、但已有任务承诺最终停靠的节点加入清障禁停集合。 + */ + private void addCommittedDestinations( + Set excludedSites, + Map active, + Map floorTransfers) { + for (ActiveTask task : active.values()) { + if (!task.route.isEmpty()) { + excludedSites.add(task.route.get(task.route.size() - 1)); + } + } + for (FloorTransferExecution transfer : floorTransfers.values()) { + List route = transfer.preparedPostTransferRoute; + if (!route.isEmpty()) excludedSites.add(route.get(route.size() - 1)); + } + } + + /** 返回当前由指定父任务发起、尚未完成的准入前清障动作。 */ + private ClearanceMove clearanceForTask( + String taskId, Map clearanceMoves) { + for (ClearanceMove clearance : clearanceMoves.values()) { + if (taskId.equals(clearance.getOwnerTaskId())) return clearance; + } + return null; + } + + /** + * 判断一台跨层车辆能否由现有 WCS 换层后到达任务源点。 + * + *

普通路径规划只验证车辆到源层端口、目标层端口到业务源点两段同层路线;中间楼层变化只由 + * {@link FloorTransferTopology} 和换层作业表达。任一端口缺失、楼层不匹配或两侧路径无解时, + * 该车辆不会进入中心选车候选。 + */ + private TransferFeasibility transferFeasibility( + HistoricalSimulationTask task, SimulatedVehicleRuntime vehicle, + HistoricalSimulationInput input, RouteCandidatePlanner planner) { + Site taskSource = input.getGridMap().getSite(task.getPhysicalSource()); + if (taskSource == null || vehicle.getFloor() == taskSource.getFloor()) return null; + FloorTransferTopology topology = input.getFloorTransferTopology(); + if (!topology.supports(vehicle.getFloor(), taskSource.getFloor())) return null; + String sourcePort = topology.portFor(vehicle.getFloor()).get(); + String targetPort = topology.portFor(taskSource.getFloor()).get(); + Site sourcePortSite = input.getGridMap().getSite(sourcePort); + Site targetPortSite = input.getGridMap().getSite(targetPort); + if (sourcePortSite == null || targetPortSite == null + || sourcePortSite.getFloor() != vehicle.getFloor() + || targetPortSite.getFloor() != taskSource.getFloor()) return null; + + RouteCandidateSet toTransfer = planner.plan(input.getGridMap(), + vehicle.getCurrentSite(), sourcePort, Collections.emptySet(), + RoutePlanningPolicy.defaultPolicy()); + RouteCandidateSet fromTransfer = planner.plan(input.getGridMap(), + targetPort, task.getPhysicalSource(), Collections.emptySet(), + RoutePlanningPolicy.defaultPolicy()); + if (toTransfer.getCandidates().isEmpty() + || fromTransfer.getCandidates().isEmpty()) return null; + return new TransferFeasibility(vehicle.getFloor(), taskSource.getFloor(), + sourcePort, targetPort); + } + + /** + * 记录一次真正生效的车辆绑定;同一车辆因幂等恢复重复出现时不重复增加分配历史。 + */ + private void recordAssignment(Execution execution, String vehicleId, long now) { + if (execution.firstAssignedMillis == null) execution.firstAssignedMillis = now; + execution.lastVehicleId = vehicleId; + if (execution.assignedVehicles.isEmpty() + || !vehicleId.equals(execution.assignedVehicles.get( + execution.assignedVehicles.size() - 1))) { + execution.assignedVehicles.add(vehicleId); + } + } + + /** + * 轮询已提交的换层作业,并在完成事实到达后把父任务接回普通路径和交通执行链路。 + * + *

车辆断线、故障、低电量或重启未同步时只记录 {@code FLOOR_TRANSFER_HOLD},不会释放车辆锁、 + * 重建作业或把父任务交给另一辆车。设备确认完成后,中心先按实际出口原子迁移车辆占点,再从该 + * 出口重新规划接近段和服务段;只有走廊预约成功才创建 {@link ActiveTask}。 + */ + private boolean advanceFloorTransfers( + long tick, long now, HistoricalSimulationInput input, + SimulationConfig config, SimulationTaskQueue queue, + Map vehicles, + Map active, + Map clearanceMoves, + Map floorTransfers, + Map terminal, + RouteCandidatePlanner routePlanner, + RouteQualityEvaluator routeEvaluator, + FloorTransferGateway gateway, SimulatedPlcGateway plc, + ResourceLedger ledger, CorridorReservationManager reservations, + List events) { + boolean progress = false; + List vehicleIds = new ArrayList(floorTransfers.keySet()); + Collections.sort(vehicleIds); + for (String vehicleId : vehicleIds) { + FloorTransferExecution transfer = floorTransfers.get(vehicleId); + SimulatedVehicleRuntime vehicle = vehicles.get(vehicleId); + if (transfer == null || vehicle == null) continue; + + if (!vehicle.isAssignmentEligible()) { + // 已提交作业可能已到 PLC,无法证明未执行时必须保留原车辆和父任务绑定。 + String reason = unavailableTransferReason(vehicle); + hold(transfer.execution, reason); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + transfer.execution.task.getSimulationTaskId(), vehicleId, + reason, transfer.job.getSourceSite(), transfer.job.getTargetSite())); + continue; + } + + FloorTransferGateway.TransferSnapshot snapshot = gateway.advance( + transfer.job.getIdempotencyKey()); + if (snapshot.getStatus() == FloorTransferStatus.SUBMITTED) continue; + if (snapshot.getStatus() == FloorTransferStatus.RUNNING) { + if (transfer.lastStatus != FloorTransferStatus.RUNNING) { + events.add(event(tick, now, "FLOOR_TRANSFER_RUNNING", + transfer.execution.task.getSimulationTaskId(), vehicleId, + snapshot.getReason(), transfer.job.getSourceSite(), + transfer.job.getTargetSite())); + progress = true; + } + transfer.lastStatus = FloorTransferStatus.RUNNING; + continue; + } + if (snapshot.getStatus() == FloorTransferStatus.REJECTED) { + // 明确未执行的拒绝可以安全释放锁并让父任务保留原到达资历重新参加选车。 + floorTransfers.remove(vehicleId); + reservations.releaseTask(transfer.execution.task.getSimulationTaskId()); + ledger.release(vehicleId, Collections.singletonList( + ResourceKey.node(transfer.job.getTargetSite()))); + queue.requeue(transfer.execution.task); + hold(transfer.execution, snapshot.getReason()); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + transfer.execution.task.getSimulationTaskId(), vehicleId, + snapshot.getReason(), transfer.job.getSourceSite(), + transfer.job.getTargetSite())); + progress = true; + continue; + } + if (snapshot.getStatus() == FloorTransferStatus.UNKNOWN) { + hold(transfer.execution, snapshot.getReason()); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + transfer.execution.task.getSimulationTaskId(), vehicleId, + snapshot.getReason(), transfer.job.getSourceSite(), + transfer.job.getTargetSite())); + continue; + } + + if (!transfer.completionApplied) { + if (!applyTransferCompletion(tick, now, input, transfer, snapshot, + vehicle, ledger, events)) continue; + progress = true; + } + progress |= activateAfterTransfer(tick, now, input, config, queue, + vehicles, active, clearanceMoves, floorTransfers, transfer, + vehicle, terminal, routePlanner, routeEvaluator, plc, ledger, + reservations, events); + } + return progress; + } + + /** + * 校验完成事实并把车辆从换层前占点迁移到实际出口;出口被占用时保持完成待接入状态。 + */ + private boolean applyTransferCompletion( + long tick, long now, HistoricalSimulationInput input, + FloorTransferExecution transfer, + FloorTransferGateway.TransferSnapshot snapshot, + SimulatedVehicleRuntime vehicle, ResourceLedger ledger, + List events) { + FloorTransferJob job = transfer.job; + Site actualExit = input.getGridMap().getSite(snapshot.getActualExitSite()); + if (!job.getVehicleId().equals(vehicle.getVehicleId()) + || actualExit == null + || actualExit.getFloor() != snapshot.getActualFloor() + || snapshot.getActualFloor() != job.getTargetFloor()) { + hold(transfer.execution, "TRANSFER_COMPLETION_FACT_MISMATCH"); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + job.getParentTaskId(), job.getVehicleId(), + "TRANSFER_COMPLETION_FACT_MISMATCH", job.getSourceSite(), + snapshot.getActualExitSite())); + return false; + } + + ResourceKey exitNode = ResourceKey.node(snapshot.getActualExitSite()); + ResourceLedger.ProtectionResult protectedExit = ledger.evaluate( + vehicle.getVehicleId(), Collections.singletonList(exitNode)); + if (!protectedExit.isGranted()) { + // 提升机已到层但出口仍被占用时不能让车辆瞬移或覆盖另一辆车的节点资源。 + hold(transfer.execution, "TRANSFER_EXIT_OCCUPIED"); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + job.getParentTaskId(), job.getVehicleId(), + "TRANSFER_EXIT_OCCUPIED", job.getSourceSite(), + snapshot.getActualExitSite())); + return false; + } + + String previousSite = vehicle.getCurrentSite(); + vehicle.moveTo(snapshot.getActualExitSite(), snapshot.getActualFloor()); + ledger.release(vehicle.getVehicleId(), + Collections.singletonList(ResourceKey.node(previousSite))); + if (!snapshot.getActualExitSite().equals(job.getTargetSite())) { + // 设备返回了合法但不同的实际出口时,撤销提交前保护的预期出口,后续路线从事实位置重算。 + ledger.release(vehicle.getVehicleId(), Collections.singletonList( + ResourceKey.node(job.getTargetSite()))); + } + transfer.completionApplied = true; + transfer.lastStatus = FloorTransferStatus.COMPLETED; + events.add(event(tick, now, "FLOOR_TRANSFER_COMPLETED", + job.getParentTaskId(), job.getVehicleId(), snapshot.getReason(), + job.getSourceSite(), snapshot.getActualExitSite())); + return true; + } + + /** + * 从换层完成事实的实际出口重新生成两段路径并接入现有走廊预约和逐节点执行流程。 + */ + private boolean activateAfterTransfer( + long tick, long now, HistoricalSimulationInput input, + SimulationConfig config, SimulationTaskQueue queue, + Map vehicles, + Map active, + Map clearanceMoves, + Map floorTransfers, + FloorTransferExecution transfer, SimulatedVehicleRuntime vehicle, + Map terminal, + RouteCandidatePlanner routePlanner, + RouteQualityEvaluator routeEvaluator, + SimulatedPlcGateway plc, ResourceLedger ledger, + CorridorReservationManager reservations, + List events) { + Execution execution = transfer.execution; + HistoricalSimulationTask task = execution.task; + RouteCandidateSet approachCandidates = routePlanner.plan(input.getGridMap(), + vehicle.getCurrentSite(), task.getPhysicalSource(), + Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); + RouteCandidateSet serviceCandidates = routePlanner.plan(input.getGridMap(), + task.getPhysicalSource(), task.getPhysicalTarget(), + Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); + if (approachCandidates.getCandidates().isEmpty() + || serviceCandidates.getCandidates().isEmpty()) { + floorTransfers.remove(vehicle.getVehicleId()); + reservations.releaseTask(task.getSimulationTaskId()); + terminal.put(task.getSimulationTaskId(), result(execution, + SimulationTaskStatus.FAILED, "NO_ROUTE_AFTER_TRANSFER", plc, now)); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + task.getSimulationTaskId(), vehicle.getVehicleId(), + "NO_ROUTE_AFTER_TRANSFER", vehicle.getCurrentSite(), + task.getPhysicalSource())); + return true; + } + + TrafficRouteSnapshot traffic = trafficSnapshot(vehicles, active, ledger, reservations); + RoutePlanDecision approachDecision = routeEvaluator.select(input.getGridMap(), + approachCandidates, traffic, task.getSimulationTaskId(), + vehicle.getVehicleId()); + RoutePlanDecision serviceDecision = routeEvaluator.select(input.getGridMap(), + serviceCandidates, traffic, task.getSimulationTaskId(), + vehicle.getVehicleId()); + List route = new ArrayList(approachDecision.getSelectedPath()); + for (int index = 1; index < serviceDecision.getSelectedPath().size(); index++) { + route.add(serviceDecision.getSelectedPath().get(index)); + } + + CorridorReservationManager.ReservationDecision reservation = reservations.replaceRoute( + task.getSimulationTaskId(), route, queuePriority(execution, now, config), + execution.arrivalMillis); + if (!reservation.isGranted()) { + hold(execution, reservation.getReason()); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + task.getSimulationTaskId(), vehicle.getVehicleId(), + reservation.getReason(), route.get(0), route.get(route.size() - 1))); + return false; + } + + // 预约成功后才提交新路径版本;证据中的接近段必定从设备完成事实返回的实际出口开始。 + execution.plannedPath = new ArrayList(serviceDecision.getSelectedPath()); + execution.pathVersion++; + execution.planningEvidence.add(new RoutePlanningEvidence( + RoutePlanningEvidence.PlanningStage.APPROACH, + execution.pathVersion, now, false, approachDecision)); + execution.planningEvidence.add(new RoutePlanningEvidence( + RoutePlanningEvidence.PlanningStage.SERVICE, + execution.pathVersion, now, false, serviceDecision, + historicalReferenceQuality(task, input, traffic, routePlanner, + routeEvaluator, vehicle.getVehicleId()))); + active.put(vehicle.getVehicleId(), new ActiveTask(execution, route, + approachDecision.getSelectedPath().size() - 1)); + floorTransfers.remove(vehicle.getVehicleId()); + events.add(event(tick, now, "POST_TRANSFER_REPLAN", + task.getSimulationTaskId(), vehicle.getVehicleId(), + "ACTUAL_EXIT_REPLAN", vehicle.getCurrentSite(), + route.get(route.size() - 1))); + events.add(event(tick, now, "ASSIGN", task.getSimulationTaskId(), + vehicle.getVehicleId(), "mode=FLOOR_TRANSFER;actualExit=" + + vehicle.getCurrentSite(), route.get(0), + route.get(route.size() - 1))); + return true; + } + + /** 把车辆运行态转换为换层 HOLD 的稳定业务原因。 */ + private String unavailableTransferReason(SimulatedVehicleRuntime vehicle) { + if (!vehicle.isConnected()) return "DISCONNECTED"; + if (vehicle.isFaulted()) return "FAULT"; + if (!vehicle.isSynchronizedAfterBoot()) return "RESTART_NOT_SYNCHRONIZED"; + if (vehicle.getBattery() <= 30) return "LOW_BATTERY"; + return "VEHICLE_NOT_ELIGIBLE"; + } + + /** + * 在 watchdog 失败时输出换层作业、车辆事实和目标出口资源持有者,定位阻塞发生在哪个边界。 + */ + private String transferDiagnostics( + Map floorTransfers, + Map vehicles, + ResourceLedger ledger) { + List details = new ArrayList(); + for (Map.Entry entry : floorTransfers.entrySet()) { + SimulatedVehicleRuntime vehicle = vehicles.get(entry.getKey()); + FloorTransferExecution transfer = entry.getValue(); + details.add(entry.getKey() + "{task=" + + transfer.execution.task.getSimulationTaskId() + + ",status=" + transfer.lastStatus + + ",completionApplied=" + transfer.completionApplied + + ",site=" + (vehicle == null ? null : vehicle.getCurrentSite()) + + ",floor=" + (vehicle == null ? null : vehicle.getFloor()) + + ",eligible=" + (vehicle != null && vehicle.isAssignmentEligible()) + + ",holds=" + transfer.execution.holdReasons + + ",targetHolders=" + ledger.holdersFor( + ResourceKey.node(transfer.job.getTargetSite())) + "}"); + } + return details.toString(); + } + + /** 输出活动任务当前边和 HOLD 原因,和换层出口诊断一起还原等待链。 */ + private String activeDiagnostics( + Map active, + Map vehicles) { + List details = new ArrayList(); + for (Map.Entry entry : active.entrySet()) { + ActiveTask task = entry.getValue(); + SimulatedVehicleRuntime vehicle = vehicles.get(entry.getKey()); + String next = task.index + 1 < task.route.size() + ? task.route.get(task.index + 1) : null; + details.add(entry.getKey() + "{task=" + + task.execution.task.getSimulationTaskId() + + ",site=" + (vehicle == null ? null : vehicle.getCurrentSite()) + + ",index=" + task.index + ",next=" + next + + ",holds=" + task.execution.holdReasons + "}"); + } + return details.toString(); + } + + /** 输出全部车辆的位置、楼层和当前节点持有者,定位活动路线的空闲阻塞车。 */ + private String vehicleDiagnostics( + Map vehicles, + ResourceLedger ledger) { + List details = new ArrayList(); + for (SimulatedVehicleRuntime vehicle : vehicles.values()) { + details.add(vehicle.getVehicleId() + "{site=" + vehicle.getCurrentSite() + + ",floor=" + vehicle.getFloor() + + ",eligible=" + vehicle.isAssignmentEligible() + + ",holders=" + ledger.holdersFor( + ResourceKey.node(vehicle.getCurrentSite())) + "}"); + } + return details.toString(); + } + + /** + * 截取看门狗触发前的最近业务事件,定位是哪条任务把车辆留在阻塞站点以及后续为何持续 HOLD。 + * + *

该信息只进入异常文本,不改变调度顺序、资源占用或仿真结果;按事件产生顺序保留最后 + * {@code limit} 条,便于把车辆位置、任务完成和阻塞原因串成同一条业务时间线。 + * + * @param events 当前回放已产生的全部审计事件。 + * @param limit 异常中最多保留的尾部事件数。 + * @return 适合直接写入看门狗异常的紧凑事件列表。 + */ + private String recentEventDiagnostics(List events, int limit) { + List details = new ArrayList(); + for (int index = events.size() - 1; index >= 0 && details.size() < limit; index--) { + SimulationTickEvent event = events.get(index); + // 等待队列会在每个 tick 产生大量同质 HOLD;排除它们才能看到导致当前占位的最近物理事实。 + if ("HOLD".equals(event.getType()) + || "REROUTE_REJECTED".equals(event.getType())) { + continue; + } + details.add(0, event.getTick() + ":" + event.getType() + + "{task=" + event.getTaskId() + + ",vehicle=" + event.getVehicleId() + + ",reason=" + event.getReason() + + ",from=" + event.getFromSite() + + ",to=" + event.getToSite() + "}"); + } + return details.toString(); + } + + /** + * 汇总看门狗触发 tick 的队列准入 HOLD,区分“车辆无法移动”和“所有任务均被门禁挡住”。 + * + * @param events 当前回放事件。 + * @param tick 看门狗判定无进展的 tick。 + * @return 按原因计数并附带少量代表任务的诊断文本。 + */ + private String currentTickHoldDiagnostics( + List events, long tick) { + Map reasonCounts = new LinkedHashMap(); + List samples = new ArrayList(); + for (SimulationTickEvent event : events) { + if (event.getTick() != tick || !event.getType().contains("HOLD")) continue; + Integer count = reasonCounts.get(event.getReason()); + reasonCounts.put(event.getReason(), count == null ? 1 : count + 1); + if (samples.size() < 12) { + samples.add(event.getTaskId() + "@" + event.getVehicleId() + + ":" + event.getReason() + ":" + + event.getFromSite() + "->" + event.getToSite()); + } + } + return "{reasons=" + reasonCounts + ",samples=" + samples + "}"; + } + /** * 冻结本次规划看到的车辆占点、已预约方向、权威边资源和其他活动任务剩余路线。 */ @@ -551,6 +1297,7 @@ public final class HistoricalDispatchSimulator { SimulationInvariantSummary invariants, CorridorReservationManager reservations, Map clearanceMoves, + Map floorTransfers, ClearanceMovePlanner clearancePlanner, List events, Set traversedEdges) { @@ -587,11 +1334,12 @@ public final class HistoricalDispatchSimulator { ResourceKey.node(from), edge, ResourceKey.node(to)); // 目标节点被空闲车长期占住时,优先让阻塞车执行一条安全清障路线,而非强行抢占资源。 Set idleBlockers = idleVehiclesAt(to, vehicleId, vehicles, - active, clearanceMoves); + active, clearanceMoves, floorTransfers); if (!idleBlockers.isEmpty()) { boolean clearancePlanned = planClearanceForIdleBlockers( tick, now, current, vehicles, active, clearanceMoves, - clearancePlanner, ledger, input, idleBlockers, events); + clearancePlanner, ledger, input, idleBlockers, + floorTransfers, events); progress |= recordTrafficHold(tick, now, current, vehicle, queue, clearancePlanned ? "IDLE_VEHICLE_CLEARANCE" : "NO_CLEARANCE_SITE", @@ -634,7 +1382,7 @@ public final class HistoricalDispatchSimulator { boolean clearancePlanned = planClearanceForIdleBlockers( tick, now, current, vehicles, active, clearanceMoves, clearancePlanner, ledger, input, - protection.getBlockingVehicles(), events); + protection.getBlockingVehicles(), floorTransfers, events); progress |= recordTrafficHold(tick, now, current, vehicle, queue, clearancePlanned ? "IDLE_VEHICLE_CLEARANCE" : "AUTHORITATIVE_RESOURCE_REJECTED", @@ -817,6 +1565,7 @@ public final class HistoricalDispatchSimulator { * @param ledger 执行该处理所需的 ledger 输入。 * @param input 执行该处理所需的 input 输入。 * @param blockingVehicleIds 执行该处理所需的 blocking vehicle ids 输入。 + * @param floorTransfers 换层流程已经锁定、不能被清障调度挪动的车辆。 * @param events 执行该处理所需的 events 输入。 * @return 至少有一辆阻塞车获得清障路线时返回 {@code true}。 */ @@ -829,6 +1578,7 @@ public final class HistoricalDispatchSimulator { ResourceLedger ledger, HistoricalSimulationInput input, Set blockingVehicleIds, + Map floorTransfers, List events) { List blockers = new ArrayList(blockingVehicleIds); Collections.sort(blockers); @@ -837,6 +1587,8 @@ public final class HistoricalDispatchSimulator { for (SimulatedVehicleRuntime vehicle : vehicles.values()) { occupiedSites.add(vehicle.getCurrentSite()); } + // 已承诺的终点即使此刻无人占用,也不能成为另一辆清障车的停车位。 + addCommittedDestinations(occupiedSites, active, floorTransfers); Set reservedResources = new LinkedHashSet(); for (Map.Entry> entry : ledger.snapshotHolders().entrySet()) { if (!entry.getValue().isEmpty()) reservedResources.add(entry.getKey()); @@ -844,7 +1596,8 @@ public final class HistoricalDispatchSimulator { for (String blockerId : blockers) { SimulatedVehicleRuntime blocker = vehicles.get(blockerId); if (blocker == null || !blocker.isAssignmentEligible() - || active.containsKey(blockerId) || clearanceMoves.containsKey(blockerId)) { + || active.containsKey(blockerId) || clearanceMoves.containsKey(blockerId) + || floorTransfers.containsKey(blockerId)) { continue; } // 规划器会从候选阻塞车当前位置出发,避开主任务剩余路线,选择最近的安全让路目标。 @@ -873,12 +1626,14 @@ public final class HistoricalDispatchSimulator { * @param vehicles 执行该处理所需的 vehicles 输入。 * @param active 执行该处理所需的 active 输入。 * @param clearanceMoves 执行该处理所需的 clearance moves 输入。 + * @param floorTransfers 已由换层父任务锁定的车辆。 * @return 可由清障流程处理的阻塞车辆编号集合。 */ private Set idleVehiclesAt(String siteId, String movingVehicleId, Map vehicles, Map active, - Map clearanceMoves) { + Map clearanceMoves, + Map floorTransfers) { Set blockers = new LinkedHashSet(); for (SimulatedVehicleRuntime candidate : vehicles.values()) { String candidateId = candidate.getVehicleId(); @@ -886,7 +1641,8 @@ public final class HistoricalDispatchSimulator { && siteId.equals(candidate.getCurrentSite()) && candidate.isAssignmentEligible() && !active.containsKey(candidateId) - && !clearanceMoves.containsKey(candidateId)) { + && !clearanceMoves.containsKey(candidateId) + && !floorTransfers.containsKey(candidateId)) { blockers.add(candidateId); } } @@ -1189,7 +1945,8 @@ public final class HistoricalDispatchSimulator { execution.holdCount, plc.attemptsFor(execution.task.getSimulationTaskId()), execution.retryCount, execution.holdReasons, execution.retryReasons, - execution.planningEvidence, execution.rerouteCount); + execution.planningEvidence, execution.rerouteCount, + execution.assignmentMode, execution.floorTransferJobs); } /** @@ -1277,6 +2034,9 @@ public final class HistoricalDispatchSimulator { new ArrayList(); private int pathVersion; private int rerouteCount; + private AssignmentMode assignmentMode; + private final List floorTransferJobs = + new ArrayList(); /** * 创建 {@code Execution} 实例并初始化其内部状态。 @@ -1291,6 +2051,46 @@ public final class HistoricalDispatchSimulator { } } + /** + * 保存跨层车辆在提交换层作业前已经通过的永久可行性检查结果。 + * + *

这里只保存源层、目标层和两个设备端口,不保存预估的目标层接近路径;真正的接近路径必须在 + * 完成事实到达后从实际出口重新计算。 + */ + private static final class TransferFeasibility { + private final int sourceFloor; + private final int targetFloor; + private final String sourcePort; + private final String targetPort; + + private TransferFeasibility(int sourceFloor, int targetFloor, + String sourcePort, String targetPort) { + this.sourceFloor = sourceFloor; + this.targetFloor = targetFloor; + this.sourcePort = sourcePort; + this.targetPort = targetPort; + } + } + + /** + * 把换层作业、父任务和完成事实应用标记绑定在一起,形成中心调度的车辆锁。 + */ + private static final class FloorTransferExecution { + private final Execution execution; + private final FloorTransferJob job; + private final List preparedPostTransferRoute; + private FloorTransferStatus lastStatus = FloorTransferStatus.SUBMITTED; + private boolean completionApplied; + + private FloorTransferExecution(Execution execution, FloorTransferJob job, + List preparedPostTransferRoute) { + this.execution = execution; + this.job = job; + this.preparedPostTransferRoute = Collections.unmodifiableList( + new ArrayList(preparedPostTransferRoute)); + } + } + private static final class ActiveTask { private final Execution execution; private List route; diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInput.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInput.java index df0430f..fa716ef 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInput.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInput.java @@ -14,6 +14,7 @@ public final class HistoricalSimulationInput { private final GridMap gridMap; private final EffectiveTrafficMap trafficMap; private final List initialVehicles; + private final FloorTransferTopology floorTransferTopology; /** * 创建 {@code HistoricalSimulationInput} 实例并初始化其内部状态。 @@ -29,11 +30,36 @@ public final class HistoricalSimulationInput { List failedInputs, GridMap gridMap, EffectiveTrafficMap trafficMap, List initialVehicles) { + this(tasks, failedInputs, gridMap, trafficMap, initialVehicles, + new FloorTransferTopology(Collections.emptyMap())); + } + + /** + * 创建带项目换层端口目录的仿真输入。 + * + *

换层端口与普通地图边分开保存。中心调度只能通过该目录生成换层作业,不能在 + * {@link GridMap} 中虚构跨层边;没有配置端口的测试输入继续只支持同层调度。 + * @param tasks 可按 create_date 进入调度队列的业务任务。 + * @param failedInputs 输入阶段已经关闭的非法历史记录。 + * @param gridMap 只包含同层物理邻接关系的完整地图。 + * @param trafficMap 节点、边和交通资源容量定义。 + * @param initialVehicles 本次仿真的初始车队快照。 + * @param floorTransferTopology 项目楼层到换层交接站点的独立配置。 + */ + HistoricalSimulationInput(List tasks, + List failedInputs, + GridMap gridMap, EffectiveTrafficMap trafficMap, + List initialVehicles, + FloorTransferTopology floorTransferTopology) { this.tasks = immutable(tasks); this.failedInputs = immutable(failedInputs); this.gridMap = gridMap; this.trafficMap = trafficMap; this.initialVehicles = immutable(initialVehicles); + if (floorTransferTopology == null) { + throw new IllegalArgumentException("floor transfer topology is required"); + } + this.floorTransferTopology = floorTransferTopology; } /** @@ -72,6 +98,9 @@ public final class HistoricalSimulationInput { */ public List getInitialVehicles() { return initialVehicles; } + /** 返回项目换层端口目录;该目录不向普通路径规划器暴露跨层边。 */ + public FloorTransferTopology getFloorTransferTopology() { return floorTransferTopology; } + /** * 执行“all simulation ids”所表示的处理流程。 * diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactory.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactory.java index 84b1211..f032cd9 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactory.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactory.java @@ -10,7 +10,9 @@ import com.ft546.wcs.shadow.history.HistoricalQueueRecord; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public final class HistoricalSimulationInputFactory { /** @@ -75,7 +77,24 @@ public final class HistoricalSimulationInputFactory { List vehicles = initialVehicles(compiled); return new HistoricalSimulationInput(tasks, failures, compiled.getGridMap(), - compiled.getTrafficMap(), vehicles); + compiled.getTrafficMap(), vehicles, floorTransferTopology(compiled)); + } + + /** + * 按 FT546 现有业务别名规则,把每层 {@code 07050} 登记为换层交接站点。 + * + *

这里只建立项目设备端口配置,不调用 {@code GridMap.connect},因此路径规划器仍然看不到 + * 任何普通跨层边。某层没有该真实站点时保持未配置,让中心调度明确判定跨层不可用。 + */ + private FloorTransferTopology floorTransferTopology( + HistoricalGridMapCompiler.CompiledHistoricalMap compiled) { + Map ports = new LinkedHashMap(); + for (int floor = 1; floor <= 3; floor++) { + String siteId = "07050" + floor; + HistoricalMapCell cell = compiled.getPassableSites().get(siteId); + if (cell != null && cell.getZ() == floor) ports.put(floor, siteId); + } + return new FloorTransferTopology(ports); } /** diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/PointToPointSimulationInputFactory.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/PointToPointSimulationInputFactory.java index 511fe1b..e2fe667 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/PointToPointSimulationInputFactory.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/PointToPointSimulationInputFactory.java @@ -51,7 +51,8 @@ public final class PointToPointSimulationInputFactory { CREATED_AT.plusMinutes(1), sourceSite, targetSite, history, 0); return new HistoricalSimulationInput( Collections.singletonList(task), Collections.emptyList(), - base.getGridMap(), base.getTrafficMap(), base.getInitialVehicles()); + base.getGridMap(), base.getTrafficMap(), base.getInitialVehicles(), + base.getFloorTransferTopology()); } private String taskTypeCode(DispatchTask.TaskType taskType) { diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationTaskResult.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationTaskResult.java index c6e4cab..bd02e98 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationTaskResult.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationTaskResult.java @@ -26,6 +26,8 @@ public final class SimulationTaskResult { private final Map retryReasons; private final List planningEvidence; private final int rerouteCount; + private final AssignmentMode assignmentMode; + private final List floorTransferJobs; /** * 创建 {@code SimulationTaskResult} 实例并初始化其内部状态。 @@ -48,6 +50,8 @@ public final class SimulationTaskResult { * @param retryReasons 执行该处理所需的 retry reasons 输入。 * @param planningEvidence 每个实际采用路径版本的接近段和服务段决策证据。 * @param rerouteCount 运行中成功替换剩余路线的次数。 + * @param assignmentMode 中心采用的直接派车或先换层编排模式;未分配失败任务为 {@code null}。 + * @param floorTransferJobs 父任务实际创建过的幂等换层作业证据。 */ SimulationTaskResult(String simulationTaskId, HistoricalQueueRecord record, SimulationTaskStatus status, String reason, @@ -58,7 +62,8 @@ public final class SimulationTaskResult { Map holdReasons, Map retryReasons, List planningEvidence, - int rerouteCount) { + int rerouteCount, AssignmentMode assignmentMode, + List floorTransferJobs) { this.simulationTaskId = simulationTaskId; this.record = record; this.status = status; @@ -76,6 +81,8 @@ public final class SimulationTaskResult { this.retryReasons = immutableMap(retryReasons); this.planningEvidence = immutable(planningEvidence); this.rerouteCount = rerouteCount; + this.assignmentMode = assignmentMode; + this.floorTransferJobs = immutable(floorTransferJobs); } /** @@ -189,6 +196,15 @@ public final class SimulationTaskResult { /** 返回运行中因连续交通 HOLD 成功切换路线的次数。 */ public int getRerouteCount() { return rerouteCount; } + + /** 返回中心最终采用的直接派车或先换层编排模式。 */ + public AssignmentMode getAssignmentMode() { return assignmentMode; } + + /** 返回该父任务实际创建过的全部换层作业,供幂等和重派审计。 */ + public List getFloorTransferJobs() { return floorTransferJobs; } + + /** 返回该父任务产生的换层业务效果数;重复提交不会增加该计数。 */ + public int getFloorTransferCount() { return floorTransferJobs.size(); } /** * 返回当前对象保存的“waiting millis”信息。 * diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/ClearanceMovePlannerTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/ClearanceMovePlannerTest.java index 0145276..5566694 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/ClearanceMovePlannerTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/ClearanceMovePlannerTest.java @@ -39,4 +39,28 @@ class ClearanceMovePlannerTest { assertEquals(Arrays.asList("B", "X"), planned.get().getRoute()); assertEquals("OWNER", planned.get().getOwnerTaskId()); } + + /** + * 验证业务路线尚未取得预约时,终点占车可以沿未来路线退出,但最终必须停到路线外的安全支路。 + */ + @Test + void clearsADestinationBlockerThroughTheUncommittedRouteToAnOffRouteBranch() { + GridMap map = new GridMap(); + for (String site : Arrays.asList("L", "M", "R", "Y")) { + map.addSite(new Site(site, 1, site.charAt(0), 1, "TEST")); + } + map.connect("L", "M"); + map.connect("M", "R"); + map.connect("M", "Y"); + + Optional planned = new ClearanceMovePlanner() + .planBeforeRouteCommit( + "2", "R", "SWAP", Arrays.asList("L", "M", "R"), + new LinkedHashSet(Arrays.asList("L", "R")), + Collections.emptySet(), map); + + assertTrue(planned.isPresent()); + assertEquals(Arrays.asList("R", "M", "Y"), planned.get().getRoute()); + assertEquals("Y", planned.get().getTargetSite()); + } } diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTest.java index 7a0ed09..22d46c8 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTest.java @@ -13,8 +13,10 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.SEND_FAILURE; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -80,9 +82,147 @@ class HistoricalDispatchSimulatorTest { assertEquals(Arrays.asList("A", "B", "C"), result.getTaskResults().get(0).getPlannedPath()); + assertEquals(AssignmentMode.DIRECT, + result.getTaskResults().get(0).getAssignmentMode()); + assertEquals(0, result.getTaskResults().get(0).getFloorTransferCount()); assertEquals(1, result.getReplannedCount()); } + /** + * 验证中心调度不会因普通地图没有跨层边而排除车辆,而是选车后创建换层作业并从实际出口重规划。 + */ + @Test + void transfersACrossFloorVehicleThenReplansFromTheActualExit() { + SimulatedFloorTransferGateway gateway = new SimulatedFloorTransferGateway(); + HistoricalDispatchSimulationResult result = new HistoricalDispatchSimulator().simulate( + crossFloorInput(true), new SimulationConfig(100, 30_000L, 3, 100, 100), + DeterministicFaultPlan.none(), gateway); + + SimulationTaskResult task = result.getTaskResults().get(0); + assertEquals(SimulationTaskStatus.COMPLETED, task.getStatus()); + assertEquals(AssignmentMode.FLOOR_TRANSFER, task.getAssignmentMode()); + assertEquals(1, task.getFloorTransferCount()); + assertEquals(1, task.getFloorTransferJobs().size()); + FloorTransferJob job = task.getFloorTransferJobs().get(0); + assertEquals("1", job.getVehicleId()); + assertEquals(1, job.getSourceFloor()); + assertEquals(2, job.getTargetFloor()); + assertEquals("LIFT-1", job.getSourceSite()); + assertEquals("LIFT-2", job.getTargetSite()); + assertEquals(Arrays.asList("A", "B"), task.getPlannedPath()); + + assertTrue(result.getEvents().stream().anyMatch(event -> + "FLOOR_TRANSFER_SUBMITTED".equals(event.getType()))); + assertTrue(result.getEvents().stream().anyMatch(event -> + "FLOOR_TRANSFER_RUNNING".equals(event.getType()))); + assertTrue(result.getEvents().stream().anyMatch(event -> + "FLOOR_TRANSFER_COMPLETED".equals(event.getType()) + && "LIFT-1".equals(event.getFromSite()) + && "LIFT-2".equals(event.getToSite()))); + assertTrue(result.getEvents().stream().anyMatch(event -> + "POST_TRANSFER_REPLAN".equals(event.getType()) + && "LIFT-2".equals(event.getFromSite()) + && "B".equals(event.getToSite()))); + assertEquals(1, result.getArchiveCount()); + assertEquals(1, result.getWmsBusinessEffects()); + assertEquals(1, gateway.createdJobCount()); + assertEquals(1, gateway.completedJobCount()); + assertEquals(0, result.getInvariants().totalSafetyViolations()); + } + + /** + * 验证换层作业提交后车辆断线和重启只暂停原作业,恢复同步后仍由同一车辆继续父任务。 + */ + @Test + void keepsTheVehicleLockedAcrossDisconnectAndRestartDuringTransfer() { + DeterministicFaultPlan faults = DeterministicFaultPlan.builder() + .vehicleEvent(1L, "1", DeterministicFaultPlan.VehicleEventType.DISCONNECT) + .vehicleEvent(2L, "1", DeterministicFaultPlan.VehicleEventType.CONNECT) + .vehicleEvent(3L, "1", DeterministicFaultPlan.VehicleEventType.RESTART) + .vehicleEvent(4L, "1", DeterministicFaultPlan.VehicleEventType.SYNCHRONIZE) + .build(); + + HistoricalDispatchSimulationResult result = new HistoricalDispatchSimulator().simulate( + crossFloorInput(true), new SimulationConfig(100, 30_000L, 3, 100, 100), + faults); + + SimulationTaskResult task = result.getTaskResults().get(0); + assertEquals(SimulationTaskStatus.COMPLETED, task.getStatus()); + assertEquals(Collections.singletonList("1"), task.getAssignedVehicles()); + assertEquals(1, task.getFloorTransferCount()); + assertTrue(result.getEvents().stream().anyMatch(event -> + "FLOOR_TRANSFER_HOLD".equals(event.getType()) + && ("DISCONNECTED".equals(event.getReason()) + || "RESTART_NOT_SYNCHRONIZED".equals(event.getReason())))); + assertEquals(1L, result.getEvents().stream() + .filter(event -> "FLOOR_TRANSFER_SUBMITTED".equals(event.getType())) + .count()); + } + + /** + * 验证车辆锁属于中心调度不变量,即使项目适配器支持多台提升机并发,也不能给同一车辆创建第二个换层作业。 + */ + @Test + void doesNotAssignATransferLockedVehicleToASecondParentTask() { + ParallelFloorTransferGateway gateway = new ParallelFloorTransferGateway(); + HistoricalDispatchSimulationResult result = new HistoricalDispatchSimulator().simulate( + twoCrossFloorTasksInput(), new SimulationConfig(100, 30_000L, 3, 100, 200), + DeterministicFaultPlan.none(), gateway); + + assertEquals(2, result.getCompletedCount()); + assertEquals(1, gateway.submittedJobCount()); + assertEquals(1L, result.getEvents().stream() + .filter(event -> "FLOOR_TRANSFER_SUBMITTED".equals(event.getType())) + .count()); + assertEquals(0, result.getInvariants().totalSafetyViolations()); + } + + /** + * 验证目标出口已由普通任务承诺时,跨层作业必须在提交前 HOLD,不能先把车送入出口再形成等待环。 + */ + @Test + void waitsForTheTargetPortAndPostTransferCorridorBeforeSubmitting() { + HistoricalDispatchSimulationResult result = new HistoricalDispatchSimulator().simulate( + committedTransferExitInput(), new SimulationConfig(100, 30_000L, 3, 50, 200), + DeterministicFaultPlan.builder() + .vehicleEvent(3L, "1", + DeterministicFaultPlan.VehicleEventType.DISCONNECT) + .build()); + + assertEquals(2, result.getCompletedCount()); + long directCompletedAt = result.getEvents().stream() + .filter(event -> "DIRECT-FIRST".equals(event.getTaskId()) + && "COMPLETE".equals(event.getType())) + .findFirst().orElseThrow(AssertionError::new).getSimulationMillis(); + long transferSubmittedAt = result.getEvents().stream() + .filter(event -> "TRANSFER-SECOND".equals(event.getTaskId()) + && "FLOOR_TRANSFER_SUBMITTED".equals(event.getType())) + .findFirst().orElseThrow(AssertionError::new).getSimulationMillis(); + assertTrue(transferSubmittedAt >= directCompletedAt); + assertTrue(result.getEvents().stream().anyMatch(event -> + "TRANSFER-SECOND".equals(event.getTaskId()) + && "FLOOR_TRANSFER_HOLD".equals(event.getType()) + && ("TRANSFER_TARGET_NOT_PREPARED".equals(event.getReason()) + || "CORRIDOR_DIRECTION_OWNED".equals(event.getReason())))); + assertEquals(0, result.getInvariants().totalSafetyViolations()); + } + + /** + * 验证项目没有配置源层或目标层提升机端口时,跨层车辆不能被伪装成可达车辆。 + */ + @Test + void closesCrossFloorTaskAsNoRouteWhenTransferPortsAreMissing() { + HistoricalDispatchSimulationResult result = new HistoricalDispatchSimulator().simulate( + crossFloorInput(false), SimulationConfig.defaults(), + DeterministicFaultPlan.none()); + + SimulationTaskResult task = result.getTaskResults().get(0); + assertEquals(SimulationTaskStatus.FAILED, task.getStatus()); + assertEquals("NO_ROUTE", task.getReason()); + assertTrue(result.getEvents().stream().noneMatch(event -> + event.getType().startsWith("FLOOR_TRANSFER"))); + } + @Test void selectsBoundedConflictFreeServiceRouteAndStoresBothPlanningStages() { HistoricalDispatchSimulationResult result = new HistoricalDispatchSimulator().simulate( @@ -205,6 +345,90 @@ class HistoricalDispatchSimulatorTest { assertEquals(0, result.getInvariants().getCapacityViolations()); } + /** + * 验证单通道终点只允许一个尚未清空的业务承诺,避免跟车锁住首车唯一的离站方向。 + * + *

首车完成入站后必须先执行从终点出发的离站任务;第二辆同向来车在此之前只能等待,不能提前 + * 持有整条反向走廊。若删除终点承诺门禁,跟车会停在 B、首车停在 C,离站任务因走廊反向预约 + * 无法分配,最终触发无物理进展看门狗。 + */ + @Test + void waitsForACommittedTerminalToClearBeforeDispatchingTheFollowingVehicle() { + HistoricalDispatchSimulationResult result = new HistoricalDispatchSimulator().simulate( + sharedDeadEndDestinationInput(), + new SimulationConfig(100, 30_000L, 3, 40, 300), + DeterministicFaultPlan.none()); + + assertEquals(3, result.getCompletedCount()); + assertEquals(0, result.getWaitingCount()); + assertEquals(0, result.getFailedCount()); + assertTrue(result.getEvents().stream().anyMatch(event -> + "FOLLOW".equals(event.getTaskId()) + && "HOLD".equals(event.getType()) + && ("DESTINATION_COMMITTED".equals(event.getReason()) + || "DESTINATION_OCCUPIED".equals(event.getReason())))); + long departureCompletedAt = result.getEvents().stream() + .filter(event -> "DEPART".equals(event.getTaskId()) + && "COMPLETE".equals(event.getType())) + .findFirst().orElseThrow(AssertionError::new).getSimulationMillis(); + long followerAssignedAt = result.getEvents().stream() + .filter(event -> "FOLLOW".equals(event.getTaskId()) + && "ASSIGN".equals(event.getType())) + .findFirst().orElseThrow(AssertionError::new).getSimulationMillis(); + assertTrue(followerAssignedAt >= departureCompletedAt); + assertEquals(0, result.getInvariants().totalSafetyViolations()); + } + + /** + * 验证所有车辆空闲但两个单通道终点互相占用时,中心先清出业务终点,再创建路线承诺。 + */ + @Test + void clearsAnOccupiedTerminalBeforeCommittingASwapTask() { + HistoricalDispatchSimulationResult result = new HistoricalDispatchSimulator().simulate( + occupiedTerminalSwapInput(), + new SimulationConfig(100, 30_000L, 3, 40, 300), + DeterministicFaultPlan.none()); + + assertEquals(2, result.getCompletedCount()); + assertEquals(0, result.getWaitingCount()); + assertTrue(result.getEvents().stream().anyMatch(event -> + "CLEARANCE_PLANNED".equals(event.getType()) + && "DESTINATION_BLOCKER".equals(event.getReason()))); + long clearanceCompletedAt = result.getEvents().stream() + .filter(event -> "LEFT-TO-RIGHT".equals(event.getTaskId()) + && "CLEARANCE_COMPLETE".equals(event.getType())) + .findFirst().orElseThrow(AssertionError::new).getSimulationMillis(); + long ownerAssignmentAt = result.getEvents().stream() + .filter(event -> "LEFT-TO-RIGHT".equals(event.getTaskId()) + && "ASSIGN".equals(event.getType())) + .findFirst().orElseThrow(AssertionError::new).getSimulationMillis(); + assertTrue(ownerAssignmentAt >= clearanceCompletedAt); + assertEquals(0, result.getInvariants().totalSafetyViolations()); + } + + /** + * 验证准入前清障不会把车辆停到另一条活动任务已经承诺、但当前暂时空闲的最终停靠点。 + */ + @Test + void keepsDestinationClearanceAwayFromAnotherTasksCommittedTerminal() { + HistoricalDispatchSimulationResult result = new HistoricalDispatchSimulator().simulate( + clearanceAgainstCommittedTerminalInput(), + new SimulationConfig(100, 30_000L, 3, 40, 300), + DeterministicFaultPlan.none()); + + assertEquals(2, result.getCompletedCount()); + SimulationTickEvent clearance = result.getEvents().stream() + .filter(event -> "SWAP".equals(event.getTaskId()) + && "CLEARANCE_PLANNED".equals(event.getType())) + .findFirst().orElseThrow(AssertionError::new); + assertEquals("Y", clearance.getToSite()); + assertTrue(result.getEvents().stream().noneMatch(event -> + "SWAP".equals(event.getTaskId()) + && "CLEARANCE_PLANNED".equals(event.getType()) + && "Z".equals(event.getToSite()))); + assertEquals(0, result.getInvariants().totalSafetyViolations()); + } + /** * 验证“controlled input”场景,确保相关业务规则和断言成立。 * @@ -238,6 +462,113 @@ class HistoricalDispatchSimulatorTest { return new HistoricalSimulationInput(tasks, Collections.emptyList(), grid, traffic, vehicles); } + /** + * 构造 X-A-B-C 单通道并在 A 接出离站支路 D:ARRIVE 和 FOLLOW 都以 C 为终点,DEPART 把首车开往 D。 + */ + private HistoricalSimulationInput sharedDeadEndDestinationInput() { + GridMap grid = new GridMap(); + for (String node : Arrays.asList("X", "A", "B", "C", "D")) { + grid.addSite(new Site(node, 1, node.charAt(0), 1, "TEST")); + } + grid.connect("X", "A"); + grid.connect("A", "B"); + grid.connect("B", "C"); + grid.connect("A", "D"); + + LinkedHashSet nodes = new LinkedHashSet( + Arrays.asList("X", "A", "B", "C", "D")); + LinkedHashSet edges = + new LinkedHashSet(); + edges.add(new TrafficMapCompiler.EdgeDefinition("X", "A")); + edges.add(new TrafficMapCompiler.EdgeDefinition("A", "B")); + edges.add(new TrafficMapCompiler.EdgeDefinition("B", "C")); + edges.add(new TrafficMapCompiler.EdgeDefinition("A", "D")); + EffectiveTrafficMap traffic = new TrafficMapCompiler().compile( + new TrafficMapCompiler.MapDefinition("SHARED-DEAD-END", 1L, "TEST", true, + "test", "test", nodes, edges, Collections.emptyMap(), + Collections.emptySet(), Collections.emptySet())); + List tasks = Arrays.asList( + customTask("ARRIVE", "A", "C", 1, 0, + Arrays.asList("A", "B", "C")), + customTask("FOLLOW", "X", "C", 2, 0, + Arrays.asList("X", "A", "B", "C")), + customTask("DEPART", "C", "D", 0, 200, + Arrays.asList("C", "B", "A", "D"))); + List vehicles = Arrays.asList( + new VehicleSnapshot("1", 1, "A", 5, 1, 100, true, true, false), + new VehicleSnapshot("2", 1, "X", 5, 1, 100, true, true, false)); + return new HistoricalSimulationInput(tasks, Collections.emptyList(), grid, + traffic, vehicles); + } + + /** 构造 L-M-R 主通道和 M-Y 支路,两辆车分别占住两个业务终点。 */ + private HistoricalSimulationInput occupiedTerminalSwapInput() { + GridMap grid = new GridMap(); + for (String node : Arrays.asList("L", "M", "R", "Y")) { + grid.addSite(new Site(node, 1, node.charAt(0), 1, "TEST")); + } + grid.connect("L", "M"); + grid.connect("M", "R"); + grid.connect("M", "Y"); + LinkedHashSet nodes = new LinkedHashSet( + Arrays.asList("L", "M", "R", "Y")); + LinkedHashSet edges = + new LinkedHashSet(); + edges.add(new TrafficMapCompiler.EdgeDefinition("L", "M")); + edges.add(new TrafficMapCompiler.EdgeDefinition("M", "R")); + edges.add(new TrafficMapCompiler.EdgeDefinition("M", "Y")); + EffectiveTrafficMap traffic = new TrafficMapCompiler().compile( + new TrafficMapCompiler.MapDefinition("OCCUPIED-SWAP", 1L, "TEST", true, + "test", "test", nodes, edges, Collections.emptyMap(), + Collections.emptySet(), Collections.emptySet())); + List tasks = Arrays.asList( + customTask("LEFT-TO-RIGHT", "L", "R", 1, 0, + Arrays.asList("L", "M", "R")), + customTask("RIGHT-TO-LEFT", "R", "L", 2, 0, + Arrays.asList("R", "M", "L"))); + List vehicles = Arrays.asList( + new VehicleSnapshot("1", 1, "L", 5, 1, 100, true, true, false), + new VehicleSnapshot("2", 1, "R", 5, 1, 100, true, true, false)); + return new HistoricalSimulationInput(tasks, Collections.emptyList(), grid, + traffic, vehicles); + } + + /** 构造活动任务驶向 Z、另一任务需要把 R 的占车清到 Y 的准入冲突。 */ + private HistoricalSimulationInput clearanceAgainstCommittedTerminalInput() { + GridMap grid = new GridMap(); + for (String node : Arrays.asList("L", "M", "R", "Y", "Z")) { + grid.addSite(new Site(node, 1, node.charAt(0), 1, "TEST")); + } + grid.connect("L", "M"); + grid.connect("M", "R"); + grid.connect("M", "Y"); + grid.connect("M", "Z"); + LinkedHashSet nodes = new LinkedHashSet( + Arrays.asList("L", "M", "R", "Y", "Z")); + LinkedHashSet edges = + new LinkedHashSet(); + edges.add(new TrafficMapCompiler.EdgeDefinition("L", "M")); + edges.add(new TrafficMapCompiler.EdgeDefinition("M", "R")); + edges.add(new TrafficMapCompiler.EdgeDefinition("M", "Y")); + edges.add(new TrafficMapCompiler.EdgeDefinition("M", "Z")); + EffectiveTrafficMap traffic = new TrafficMapCompiler().compile( + new TrafficMapCompiler.MapDefinition("COMMITTED-CLEARANCE", 1L, + "TEST", true, "test", "test", nodes, edges, + Collections.emptyMap(), Collections.emptySet(), + Collections.emptySet())); + List tasks = Arrays.asList( + customTask("STAGE", "Y", "Z", 0, 0, + Arrays.asList("Y", "M", "Z")), + customTask("SWAP", "L", "R", 1, 0, + Arrays.asList("L", "M", "R"))); + List vehicles = Arrays.asList( + new VehicleSnapshot("1", 1, "L", 5, 1, 100, true, true, false), + new VehicleSnapshot("2", 1, "R", 5, 1, 100, true, true, false), + new VehicleSnapshot("3", 1, "Y", 5, 1, 100, true, true, false)); + return new HistoricalSimulationInput(tasks, Collections.emptyList(), grid, + traffic, vehicles); + } + /** * 验证“opposite corridor input”场景,确保相关业务规则和断言成立。 * @@ -422,6 +753,147 @@ class HistoricalDispatchSimulatorTest { "1", 1, "A", 5, 1, 100, true, true, false))); } + /** + * 构造两个完全独立的楼层路网;唯一车辆只能通过显式换层端口到达二层任务源点。 + */ + private HistoricalSimulationInput crossFloorInput(boolean configureTransferPorts) { + GridMap grid = new GridMap(); + grid.addSite(new Site("V1", 1, 0, 0, "TEST")); + grid.addSite(new Site("LIFT-1", 1, 1, 0, "TRANSFER")); + grid.addSite(new Site("LIFT-2", 2, 1, 0, "TRANSFER")); + grid.addSite(new Site("A", 2, 2, 0, "TEST")); + grid.addSite(new Site("B", 2, 3, 0, "TEST")); + grid.connect("V1", "LIFT-1"); + grid.connect("LIFT-2", "A"); + grid.connect("A", "B"); + + LinkedHashSet nodes = new LinkedHashSet( + Arrays.asList("V1", "LIFT-1", "LIFT-2", "A", "B")); + LinkedHashSet edges = + new LinkedHashSet(); + edges.add(new TrafficMapCompiler.EdgeDefinition("V1", "LIFT-1")); + edges.add(new TrafficMapCompiler.EdgeDefinition("LIFT-2", "A")); + edges.add(new TrafficMapCompiler.EdgeDefinition("A", "B")); + EffectiveTrafficMap traffic = new TrafficMapCompiler().compile( + new TrafficMapCompiler.MapDefinition("CROSS-FLOOR", 1L, "TEST", true, + "test", "test", nodes, edges, Collections.emptyMap(), + Collections.emptySet(), Collections.emptySet())); + Map ports = new LinkedHashMap(); + if (configureTransferPorts) { + ports.put(1, "LIFT-1"); + ports.put(2, "LIFT-2"); + } + HistoricalSimulationTask task = customTask( + "CROSS", "A", "B", 1, 0, Arrays.asList("A", "B")); + return new HistoricalSimulationInput(Collections.singletonList(task), + Collections.emptyList(), grid, traffic, + Collections.singletonList(new VehicleSnapshot( + "1", 1, "V1", 5, 1, 100, true, true, false)), + new FloorTransferTopology(ports)); + } + + /** 两个同层父任务共用唯一跨层车辆,用于证明第一个换层期间车辆不会被重复绑定。 */ + private HistoricalSimulationInput twoCrossFloorTasksInput() { + HistoricalSimulationInput base = crossFloorInput(true); + List tasks = Arrays.asList( + base.getTasks().get(0), + customTask("CROSS-2", "A", "B", 2, 0, + Arrays.asList("A", "B"))); + return new HistoricalSimulationInput(tasks, Collections.emptyList(), + base.getGridMap(), base.getTrafficMap(), base.getInitialVehicles(), + base.getFloorTransferTopology()); + } + + /** + * 一层车辆计划进入二层 LIFT-2,同时二层直接任务正沿 X-A-LIFT-2 驶向该出口。 + */ + private HistoricalSimulationInput committedTransferExitInput() { + GridMap grid = new GridMap(); + grid.addSite(new Site("V1", 1, 0, 0, "TEST")); + grid.addSite(new Site("LIFT-1", 1, 1, 0, "TRANSFER")); + grid.addSite(new Site("LIFT-2", 2, 0, 0, "TRANSFER")); + grid.addSite(new Site("A", 2, 1, 0, "TEST")); + grid.addSite(new Site("X", 2, 2, 0, "TEST")); + grid.addSite(new Site("Y", 2, 0, 1, "TEST")); + grid.connect("V1", "LIFT-1"); + grid.connect("LIFT-2", "A"); + grid.connect("A", "X"); + grid.connect("LIFT-2", "Y"); + + LinkedHashSet nodes = new LinkedHashSet( + Arrays.asList("V1", "LIFT-1", "LIFT-2", "A", "X", "Y")); + LinkedHashSet edges = + new LinkedHashSet(); + edges.add(new TrafficMapCompiler.EdgeDefinition("V1", "LIFT-1")); + edges.add(new TrafficMapCompiler.EdgeDefinition("LIFT-2", "A")); + edges.add(new TrafficMapCompiler.EdgeDefinition("A", "X")); + edges.add(new TrafficMapCompiler.EdgeDefinition("LIFT-2", "Y")); + EffectiveTrafficMap traffic = new TrafficMapCompiler().compile( + new TrafficMapCompiler.MapDefinition("TRANSFER-EXIT", 1L, "TEST", true, + "test", "test", nodes, edges, Collections.emptyMap(), + Collections.emptySet(), Collections.emptySet())); + List tasks = Arrays.asList( + customTask("DIRECT-FIRST", "X", "Y", 1, 0, + Arrays.asList("X", "A", "LIFT-2", "Y")), + customTask("TRANSFER-SECOND", "A", "X", 2, 0, + Arrays.asList("A", "X"))); + List vehicles = Arrays.asList( + new VehicleSnapshot("1", 2, "X", 5, 1, 100, true, true, false), + new VehicleSnapshot("2", 1, "V1", 5, 1, 100, true, true, false)); + Map ports = new LinkedHashMap(); + ports.put(1, "LIFT-1"); + ports.put(2, "LIFT-2"); + return new HistoricalSimulationInput(tasks, Collections.emptyList(), grid, + traffic, vehicles, new FloorTransferTopology(ports)); + } + + /** + * 测试用项目适配器允许不同作业并发,确保“同车不能双绑”由中心状态机保证,而不是依赖单提升机忙碌门禁。 + */ + private static final class ParallelFloorTransferGateway + implements FloorTransferGateway { + private final Map snapshots = + new LinkedHashMap(); + + @Override + public Submission submit(FloorTransferJob job) { + TransferSnapshot existing = snapshots.get(job.getIdempotencyKey()); + if (existing != null) { + return new Submission(SubmissionCode.EXISTING, job, "IDEMPOTENT_REPLAY"); + } + snapshots.put(job.getIdempotencyKey(), new TransferSnapshot(job, + FloorTransferStatus.SUBMITTED, null, 0, "SUBMITTED")); + return new Submission(SubmissionCode.CREATED, job, "SUBMITTED"); + } + + @Override + public TransferSnapshot advance(String idempotencyKey) { + TransferSnapshot current = snapshot(idempotencyKey); + FloorTransferJob job = current.getJob(); + TransferSnapshot next; + if (current.getStatus() == FloorTransferStatus.SUBMITTED) { + next = new TransferSnapshot(job, FloorTransferStatus.RUNNING, + null, 0, "DEVICE_ACCEPTED"); + } else if (current.getStatus() == FloorTransferStatus.RUNNING) { + next = new TransferSnapshot(job, FloorTransferStatus.COMPLETED, + job.getTargetSite(), job.getTargetFloor(), "DEVICE_COMPLETED"); + } else { + next = current; + } + snapshots.put(idempotencyKey, next); + return next; + } + + @Override + public TransferSnapshot snapshot(String idempotencyKey) { + TransferSnapshot snapshot = snapshots.get(idempotencyKey); + if (snapshot == null) throw new IllegalArgumentException("unknown transfer job"); + return snapshot; + } + + private int submittedJobCount() { return snapshots.size(); } + } + /** * 验证“blocked three vehicle corridor input”场景,确保相关业务规则和断言成立。 * diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactoryTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactoryTest.java index c22c03e..45f083d 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactoryTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalSimulationInputFactoryTest.java @@ -75,6 +75,10 @@ class HistoricalSimulationInputFactoryTest { assertTrue(input.getTrafficMap().getEdges().size() > 1000); assertEquals(Arrays.asList("1", "2", "3"), input.getInitialVehicles().stream() .map(vehicle -> vehicle.getVehicleId()).collect(Collectors.toList())); + assertEquals("070501", input.getFloorTransferTopology().portFor(1).get()); + assertEquals("070502", input.getFloorTransferTopology().portFor(2).get()); + assertEquals("070503", input.getFloorTransferTopology().portFor(3).get()); + assertFalse(input.getGridMap().getNeighbors("070501").contains("070502")); } /**