feat(wcs): add deterministic vehicle assignment
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
package com.ruoyi.wcs.domain.dispatch;
|
||||
|
||||
import com.ruoyi.wcs.domain.equipment.DeviceCapability;
|
||||
import com.ruoyi.wcs.domain.equipment.DeviceType;
|
||||
import com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
|
||||
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyNode;
|
||||
import com.ruoyi.wcs.domain.traffic.RoutePlan;
|
||||
import com.ruoyi.wcs.domain.traffic.RoutePlanner;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class DefaultVehicleAssignmentPolicy implements VehicleAssignmentPolicy {
|
||||
private final RoutePlanner routePlanner;
|
||||
private final DispatchPolicy dispatchPolicy;
|
||||
|
||||
public DefaultVehicleAssignmentPolicy(RoutePlanner routePlanner,
|
||||
DispatchPolicy dispatchPolicy) {
|
||||
this.routePlanner = Objects.requireNonNull(routePlanner, "routePlanner");
|
||||
this.dispatchPolicy = Objects.requireNonNull(dispatchPolicy, "dispatchPolicy");
|
||||
}
|
||||
|
||||
@Override
|
||||
public VehicleAssignmentDecision assign(DispatchTask task,
|
||||
List<VehicleSnapshot> vehicles,
|
||||
EffectiveTopology topology) {
|
||||
Objects.requireNonNull(task, "task");
|
||||
Objects.requireNonNull(vehicles, "vehicles");
|
||||
Objects.requireNonNull(topology, "topology");
|
||||
if (!topology.containsNode(task.getSourceNodeId())
|
||||
|| !topology.containsNode(task.getDestinationNodeId())) {
|
||||
throw new DomainRuleViolation("dispatch task references unknown topology node");
|
||||
}
|
||||
|
||||
List<ScoredVehicle> eligible = new ArrayList<ScoredVehicle>();
|
||||
for (VehicleSnapshot vehicle : vehicles) {
|
||||
ScoredVehicle scored = scoreIfEligible(task, vehicle, topology);
|
||||
if (scored != null) {
|
||||
eligible.add(scored);
|
||||
}
|
||||
}
|
||||
if (eligible.isEmpty()) {
|
||||
return VehicleAssignmentDecision.unassigned("NO_ELIGIBLE_VEHICLE");
|
||||
}
|
||||
Collections.sort(eligible, Comparator
|
||||
.comparingLong(ScoredVehicle::getScore).reversed()
|
||||
.thenComparing(ScoredVehicle::getVehicleId));
|
||||
ScoredVehicle selected = eligible.get(0);
|
||||
return VehicleAssignmentDecision.assigned(selected.getVehicleId(),
|
||||
selected.route, selected.score, selected.reasonCodes);
|
||||
}
|
||||
|
||||
private ScoredVehicle scoreIfEligible(DispatchTask task,
|
||||
VehicleSnapshot vehicle,
|
||||
EffectiveTopology topology) {
|
||||
if (vehicle == null
|
||||
|| vehicle.getDescriptor().getDeviceType() != DeviceType.FOUR_WAY_SHUTTLE
|
||||
|| !vehicle.isDispatchable(task.getMinimumBattery())
|
||||
|| !supportsAll(vehicle, task)
|
||||
|| !topology.containsNode(vehicle.getLocationNodeId())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
RoutePlan route;
|
||||
try {
|
||||
RoutePlan approach = routePlanner.plan(topology,
|
||||
vehicle.getLocationNodeId(), task.getSourceNodeId(),
|
||||
Collections.<String>emptySet());
|
||||
RoutePlan execution = routePlanner.plan(topology,
|
||||
task.getSourceNodeId(), task.getDestinationNodeId(),
|
||||
Collections.<String>emptySet());
|
||||
route = combine(approach, execution, topology);
|
||||
} catch (DomainRuleViolation noRoute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
TopologyNode source = topology.node(task.getSourceNodeId());
|
||||
boolean sameFloor = vehicle.getFloorCode().equals(source.getFloorCode());
|
||||
long score = ((long) task.getPriority() * 1_000_000L)
|
||||
+ Math.min(task.getWaitingAgeSeconds(), 100_000L) * 100L
|
||||
+ (sameFloor ? 10_000L : 0L)
|
||||
- ((long) route.getDistanceCost() * 1_000L)
|
||||
+ ((long) (vehicle.getBatteryPercent() - task.getMinimumBattery()) * 10L)
|
||||
+ dispatchPolicy.adjustment(task, vehicle);
|
||||
List<String> reasons = new ArrayList<String>(Arrays.asList(
|
||||
"DISPATCHABLE", "CAPABILITY_MATCH", "REACHABLE"));
|
||||
if (sameFloor) {
|
||||
reasons.add("SAME_FLOOR");
|
||||
}
|
||||
reasons.add("STABLE_SCORE");
|
||||
return new ScoredVehicle(vehicle.getVehicleId(), route, score, reasons);
|
||||
}
|
||||
|
||||
private static boolean supportsAll(VehicleSnapshot vehicle, DispatchTask task) {
|
||||
for (DeviceCapability capability : task.getRequiredCapabilities()) {
|
||||
if (!vehicle.supports(capability)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static RoutePlan combine(RoutePlan approach, RoutePlan execution,
|
||||
EffectiveTopology topology) {
|
||||
List<String> nodes = new ArrayList<String>(approach.getNodeIds());
|
||||
nodes.addAll(execution.getNodeIds().subList(1, execution.getNodeIds().size()));
|
||||
List<String> resources = new ArrayList<String>(approach.getResourceKeys());
|
||||
resources.addAll(execution.getResourceKeys().subList(
|
||||
1, execution.getResourceKeys().size()));
|
||||
return new RoutePlan(nodes, resources,
|
||||
approach.getDistanceCost() + execution.getDistanceCost(),
|
||||
countTurns(nodes, topology), topology.version());
|
||||
}
|
||||
|
||||
private static int countTurns(List<String> nodes, EffectiveTopology topology) {
|
||||
int turns = 0;
|
||||
int previousX = 0;
|
||||
int previousY = 0;
|
||||
for (int i = 1; i < nodes.size(); i++) {
|
||||
TopologyNode from = topology.node(nodes.get(i - 1));
|
||||
TopologyNode to = topology.node(nodes.get(i));
|
||||
int directionX = Integer.compare(to.getX(), from.getX());
|
||||
int directionY = Integer.compare(to.getY(), from.getY());
|
||||
if (i > 1 && (previousX != directionX || previousY != directionY)) {
|
||||
turns++;
|
||||
}
|
||||
previousX = directionX;
|
||||
previousY = directionY;
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
private static final class ScoredVehicle {
|
||||
private final String vehicleId;
|
||||
private final RoutePlan route;
|
||||
private final long score;
|
||||
private final List<String> reasonCodes;
|
||||
|
||||
private ScoredVehicle(String vehicleId, RoutePlan route,
|
||||
long score, List<String> reasonCodes) {
|
||||
this.vehicleId = vehicleId;
|
||||
this.route = route;
|
||||
this.score = score;
|
||||
this.reasonCodes = reasonCodes;
|
||||
}
|
||||
|
||||
private String getVehicleId() {
|
||||
return vehicleId;
|
||||
}
|
||||
|
||||
private long getScore() {
|
||||
return score;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.ruoyi.wcs.domain.dispatch;
|
||||
|
||||
import com.ruoyi.wcs.domain.equipment.DeviceCapability;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
public final class DispatchTask {
|
||||
private final String orderId;
|
||||
private final String stepId;
|
||||
private final String sourceNodeId;
|
||||
private final String destinationNodeId;
|
||||
private final int priority;
|
||||
private final long waitingAgeSeconds;
|
||||
private final int minimumBattery;
|
||||
private final Set<DeviceCapability> requiredCapabilities;
|
||||
|
||||
public DispatchTask(String orderId, String stepId,
|
||||
String sourceNodeId, String destinationNodeId,
|
||||
int priority, long waitingAgeSeconds, int minimumBattery,
|
||||
Set<DeviceCapability> requiredCapabilities) {
|
||||
this.orderId = requireText(orderId, "orderId");
|
||||
this.stepId = requireText(stepId, "stepId");
|
||||
this.sourceNodeId = requireText(sourceNodeId, "sourceNodeId");
|
||||
this.destinationNodeId = requireText(destinationNodeId, "destinationNodeId");
|
||||
this.priority = priority;
|
||||
if (waitingAgeSeconds < 0L) {
|
||||
throw new IllegalArgumentException("waitingAgeSeconds must not be negative");
|
||||
}
|
||||
this.waitingAgeSeconds = waitingAgeSeconds;
|
||||
if (minimumBattery < 0 || minimumBattery > 100) {
|
||||
throw new IllegalArgumentException("minimumBattery must be between 0 and 100");
|
||||
}
|
||||
this.minimumBattery = minimumBattery;
|
||||
Objects.requireNonNull(requiredCapabilities, "requiredCapabilities");
|
||||
EnumSet<DeviceCapability> copied = requiredCapabilities.isEmpty()
|
||||
? EnumSet.noneOf(DeviceCapability.class)
|
||||
: EnumSet.copyOf(requiredCapabilities);
|
||||
this.requiredCapabilities = Collections.unmodifiableSet(copied);
|
||||
}
|
||||
|
||||
public String getOrderId() {
|
||||
return orderId;
|
||||
}
|
||||
|
||||
public String getStepId() {
|
||||
return stepId;
|
||||
}
|
||||
|
||||
public String getSourceNodeId() {
|
||||
return sourceNodeId;
|
||||
}
|
||||
|
||||
public String getDestinationNodeId() {
|
||||
return destinationNodeId;
|
||||
}
|
||||
|
||||
public int getPriority() {
|
||||
return priority;
|
||||
}
|
||||
|
||||
public long getWaitingAgeSeconds() {
|
||||
return waitingAgeSeconds;
|
||||
}
|
||||
|
||||
public int getMinimumBattery() {
|
||||
return minimumBattery;
|
||||
}
|
||||
|
||||
public Set<DeviceCapability> getRequiredCapabilities() {
|
||||
return requiredCapabilities;
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.ruoyi.wcs.domain.dispatch;
|
||||
|
||||
import com.ruoyi.wcs.domain.traffic.RoutePlan;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class VehicleAssignmentDecision {
|
||||
private final String vehicleId;
|
||||
private final RoutePlan route;
|
||||
private final long totalScore;
|
||||
private final List<String> reasonCodes;
|
||||
|
||||
private VehicleAssignmentDecision(String vehicleId, RoutePlan route,
|
||||
long totalScore, List<String> reasonCodes) {
|
||||
this.vehicleId = vehicleId;
|
||||
this.route = route;
|
||||
this.totalScore = totalScore;
|
||||
this.reasonCodes = Collections.unmodifiableList(
|
||||
new ArrayList<String>(reasonCodes));
|
||||
}
|
||||
|
||||
public static VehicleAssignmentDecision assigned(String vehicleId, RoutePlan route,
|
||||
long totalScore,
|
||||
List<String> reasonCodes) {
|
||||
if (vehicleId == null || route == null) {
|
||||
throw new IllegalArgumentException("assigned decision requires vehicle and route");
|
||||
}
|
||||
return new VehicleAssignmentDecision(vehicleId, route, totalScore, reasonCodes);
|
||||
}
|
||||
|
||||
public static VehicleAssignmentDecision unassigned(String reasonCode) {
|
||||
return new VehicleAssignmentDecision(null, null, Long.MIN_VALUE,
|
||||
Collections.singletonList(reasonCode));
|
||||
}
|
||||
|
||||
public boolean isAssigned() {
|
||||
return vehicleId != null;
|
||||
}
|
||||
|
||||
public String getVehicleId() {
|
||||
return vehicleId;
|
||||
}
|
||||
|
||||
public RoutePlan getRoute() {
|
||||
return route;
|
||||
}
|
||||
|
||||
public long getTotalScore() {
|
||||
return totalScore;
|
||||
}
|
||||
|
||||
public List<String> getReasonCodes() {
|
||||
return reasonCodes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.ruoyi.wcs.domain.dispatch;
|
||||
|
||||
import com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
|
||||
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface VehicleAssignmentPolicy {
|
||||
VehicleAssignmentDecision assign(DispatchTask task,
|
||||
List<VehicleSnapshot> vehicles,
|
||||
EffectiveTopology topology);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ruoyi.wcs.port.out;
|
||||
|
||||
import com.ruoyi.wcs.domain.dispatch.DispatchTask;
|
||||
import com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
|
||||
|
||||
public interface DispatchPolicy {
|
||||
DispatchPolicy NONE = (task, vehicle) -> 0;
|
||||
|
||||
int adjustment(DispatchTask task, VehicleSnapshot vehicle);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.ruoyi.wcs.domain.dispatch;
|
||||
|
||||
import com.ruoyi.wcs.domain.equipment.DeviceCapability;
|
||||
import com.ruoyi.wcs.domain.equipment.DeviceDescriptor;
|
||||
import com.ruoyi.wcs.domain.equipment.DeviceType;
|
||||
import com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
|
||||
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||
import com.ruoyi.wcs.domain.topology.ResourceDefinition;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyEdge;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyNode;
|
||||
import com.ruoyi.wcs.domain.traffic.AStarRoutePlanner;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class DefaultVehicleAssignmentPolicyTest {
|
||||
private final EffectiveTopology topology = topology();
|
||||
private final VehicleAssignmentPolicy policy = new DefaultVehicleAssignmentPolicy(
|
||||
new AStarRoutePlanner(), DispatchPolicy.NONE);
|
||||
|
||||
@Test
|
||||
void rejectsCloserVehicleWithoutRequiredCapability() {
|
||||
VehicleAssignmentDecision decision = policy.assign(taskRequiringLoad(),
|
||||
Arrays.asList(
|
||||
vehicle("V1", "B", 90, false, VehicleSnapshot.ConnectionState.ONLINE),
|
||||
vehicle("V2", "A", 70, true, VehicleSnapshot.ConnectionState.ONLINE)),
|
||||
topology);
|
||||
|
||||
assertEquals("V2", decision.getVehicleId());
|
||||
assertTrue(decision.getReasonCodes().contains("CAPABILITY_MATCH"));
|
||||
assertEquals(Arrays.asList("A", "B", "C", "D"),
|
||||
decision.getRoute().getNodeIds());
|
||||
}
|
||||
|
||||
@Test
|
||||
void breaksEqualScoresByVehicleId() {
|
||||
VehicleAssignmentDecision decision = policy.assign(taskWithoutLoad(),
|
||||
Arrays.asList(
|
||||
vehicle("V2", "A", 80, true, VehicleSnapshot.ConnectionState.ONLINE),
|
||||
vehicle("V1", "A", 80, true, VehicleSnapshot.ConnectionState.ONLINE)),
|
||||
topology);
|
||||
|
||||
assertEquals("V1", decision.getVehicleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesHardDispatchabilityFilterBeforeScoring() {
|
||||
VehicleAssignmentDecision decision = policy.assign(taskWithoutLoad(),
|
||||
Arrays.asList(
|
||||
vehicle("V1", "C", 100, true, VehicleSnapshot.ConnectionState.OFFLINE),
|
||||
vehicle("V2", "A", 40, true, VehicleSnapshot.ConnectionState.ONLINE)),
|
||||
topology);
|
||||
|
||||
assertEquals("V2", decision.getVehicleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsNoAssignmentWhenEveryVehicleFailsAHardCondition() {
|
||||
VehicleAssignmentDecision decision = policy.assign(taskRequiringLoad(),
|
||||
Collections.singletonList(vehicle("V1", "B", 90, false,
|
||||
VehicleSnapshot.ConnectionState.ONLINE)), topology);
|
||||
|
||||
assertFalse(decision.isAssigned());
|
||||
assertEquals(Collections.singletonList("NO_ELIGIBLE_VEHICLE"),
|
||||
decision.getReasonCodes());
|
||||
}
|
||||
|
||||
private static DispatchTask taskRequiringLoad() {
|
||||
return new DispatchTask("O1", "S1", "C", "D", 10, 60L, 30,
|
||||
Collections.singleton(DeviceCapability.LOAD_HANDLING));
|
||||
}
|
||||
|
||||
private static DispatchTask taskWithoutLoad() {
|
||||
return new DispatchTask("O1", "S1", "C", "D", 10, 60L, 30,
|
||||
Collections.<DeviceCapability>emptySet());
|
||||
}
|
||||
|
||||
private static VehicleSnapshot vehicle(String id, String location, int battery,
|
||||
boolean loadCapability,
|
||||
VehicleSnapshot.ConnectionState connection) {
|
||||
EnumSet<DeviceCapability> capabilities =
|
||||
EnumSet.of(DeviceCapability.FOUR_WAY_MOVE);
|
||||
if (loadCapability) {
|
||||
capabilities.add(DeviceCapability.LOAD_HANDLING);
|
||||
}
|
||||
DeviceDescriptor descriptor = new DeviceDescriptor(id,
|
||||
DeviceType.FOUR_WAY_SHUTTLE, "F1", capabilities);
|
||||
return new VehicleSnapshot(descriptor, connection,
|
||||
VehicleSnapshot.OperatingMode.AUTO,
|
||||
VehicleSnapshot.HealthState.NORMAL,
|
||||
VehicleSnapshot.OccupationState.IDLE,
|
||||
location, null, battery, null, "F1",
|
||||
Instant.parse("2026-08-04T00:00:00Z"));
|
||||
}
|
||||
|
||||
private static EffectiveTopology topology() {
|
||||
TopologyDefinition definition = new TopologyDefinition("FT546", 7L, true,
|
||||
Arrays.asList(node("A", 0), node("B", 1), node("C", 2), node("D", 3)),
|
||||
Arrays.asList(edge("A", "B"), edge("B", "C"), edge("C", "D")),
|
||||
Collections.<ResourceDefinition>emptyList(),
|
||||
Collections.<String>emptyList(), Collections.<String>emptyList());
|
||||
return new TopologyCompiler().compile(definition);
|
||||
}
|
||||
|
||||
private static TopologyNode node(String id, int x) {
|
||||
return new TopologyNode(id, "F1", x, 0, 1, false, false);
|
||||
}
|
||||
|
||||
private static TopologyEdge edge(String source, String target) {
|
||||
return new TopologyEdge(source, target, 1, 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user