feat: add deterministic vehicle assignment demo
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
package com.ft546.wcs.demo.center;
|
||||||
|
|
||||||
|
import com.ft546.wcs.demo.model.DecisionCode;
|
||||||
|
import com.ft546.wcs.demo.model.VehicleSnapshot;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public final class VehicleAssignmentDecision {
|
||||||
|
private final DecisionCode code;
|
||||||
|
private final VehicleSnapshot vehicle;
|
||||||
|
private final String reason;
|
||||||
|
|
||||||
|
private VehicleAssignmentDecision(
|
||||||
|
DecisionCode code,
|
||||||
|
VehicleSnapshot vehicle,
|
||||||
|
String reason
|
||||||
|
) {
|
||||||
|
this.code = code;
|
||||||
|
this.vehicle = vehicle;
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static VehicleAssignmentDecision assigned(
|
||||||
|
VehicleSnapshot vehicle,
|
||||||
|
String reason
|
||||||
|
) {
|
||||||
|
return new VehicleAssignmentDecision(DecisionCode.ASSIGNED, vehicle, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static VehicleAssignmentDecision rejected(
|
||||||
|
DecisionCode code,
|
||||||
|
String reason
|
||||||
|
) {
|
||||||
|
if (code == DecisionCode.ASSIGNED) {
|
||||||
|
throw new IllegalArgumentException("rejected decision cannot be ASSIGNED");
|
||||||
|
}
|
||||||
|
return new VehicleAssignmentDecision(code, null, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DecisionCode getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<VehicleSnapshot> getVehicle() {
|
||||||
|
return Optional.ofNullable(vehicle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.ft546.wcs.demo.center;
|
||||||
|
|
||||||
|
import com.ft546.wcs.demo.model.DecisionCode;
|
||||||
|
import com.ft546.wcs.demo.model.DispatchTask;
|
||||||
|
import com.ft546.wcs.demo.model.Site;
|
||||||
|
import com.ft546.wcs.demo.model.VehicleSnapshot;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class VehicleAssignmentService {
|
||||||
|
|
||||||
|
public VehicleAssignmentDecision select(
|
||||||
|
DispatchTask task,
|
||||||
|
List<VehicleSnapshot> vehicles,
|
||||||
|
Map<String, Site> sites
|
||||||
|
) {
|
||||||
|
if (task == null || vehicles == null || sites == null) {
|
||||||
|
throw new IllegalArgumentException("task, vehicles and sites are required");
|
||||||
|
}
|
||||||
|
Site source = sites.get(task.getSourceSite());
|
||||||
|
if (source == null) {
|
||||||
|
return VehicleAssignmentDecision.rejected(
|
||||||
|
DecisionCode.REJECT_INVALID_SITE,
|
||||||
|
"unknown task source site: " + task.getSourceSite()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Candidate> candidates = new ArrayList<Candidate>();
|
||||||
|
boolean eligibleWithUnknownSite = false;
|
||||||
|
for (VehicleSnapshot vehicle : vehicles) {
|
||||||
|
if (!isEligible(vehicle)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Site current = sites.get(vehicle.getCurrentSite());
|
||||||
|
if (current == null) {
|
||||||
|
eligibleWithUnknownSite = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.add(new Candidate(vehicle, current, source));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
if (eligibleWithUnknownSite) {
|
||||||
|
return VehicleAssignmentDecision.rejected(
|
||||||
|
DecisionCode.REJECT_INVALID_SITE,
|
||||||
|
"eligible vehicle references an unknown current site"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return VehicleAssignmentDecision.rejected(
|
||||||
|
DecisionCode.HOLD_NO_ELIGIBLE_VEHICLE,
|
||||||
|
"no connected automatic idle vehicle with battery > 30 is available"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Collections.sort(candidates, Candidate.ORDER);
|
||||||
|
Candidate selected = candidates.get(0);
|
||||||
|
return VehicleAssignmentDecision.assigned(
|
||||||
|
selected.vehicle,
|
||||||
|
"vehicle=" + selected.vehicle.getVehicleId()
|
||||||
|
+ ",sameFloorPenalty=" + selected.sameFloorPenalty
|
||||||
|
+ ",floorDistance=" + selected.floorDistance
|
||||||
|
+ ",yDistance=" + selected.yDistance
|
||||||
|
+ ",xDistance=" + selected.xDistance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isEligible(VehicleSnapshot vehicle) {
|
||||||
|
return vehicle != null
|
||||||
|
&& vehicle.isConnected()
|
||||||
|
&& vehicle.getWorkMode() == 5
|
||||||
|
&& vehicle.getStatus() == 1
|
||||||
|
&& vehicle.isAllowSend()
|
||||||
|
&& vehicle.getBattery() > 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Candidate {
|
||||||
|
private static final Comparator<Candidate> ORDER = new Comparator<Candidate>() {
|
||||||
|
@Override
|
||||||
|
public int compare(Candidate left, Candidate right) {
|
||||||
|
int result = Integer.compare(
|
||||||
|
left.sameFloorPenalty,
|
||||||
|
right.sameFloorPenalty
|
||||||
|
);
|
||||||
|
if (result != 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result = Integer.compare(left.floorDistance, right.floorDistance);
|
||||||
|
if (result != 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result = Integer.compare(left.yDistance, right.yDistance);
|
||||||
|
if (result != 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result = Integer.compare(left.xDistance, right.xDistance);
|
||||||
|
if (result != 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return left.vehicle.getVehicleId().compareTo(right.vehicle.getVehicleId());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private final VehicleSnapshot vehicle;
|
||||||
|
private final int sameFloorPenalty;
|
||||||
|
private final int floorDistance;
|
||||||
|
private final int yDistance;
|
||||||
|
private final int xDistance;
|
||||||
|
|
||||||
|
private Candidate(VehicleSnapshot vehicle, Site current, Site source) {
|
||||||
|
this.vehicle = vehicle;
|
||||||
|
this.sameFloorPenalty = vehicle.getFloor() == source.getFloor() ? 0 : 1;
|
||||||
|
this.floorDistance = Math.abs(vehicle.getFloor() - source.getFloor());
|
||||||
|
this.yDistance = Math.abs(current.getY() - source.getY());
|
||||||
|
this.xDistance = Math.abs(current.getX() - source.getX());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.ft546.wcs.demo.model;
|
||||||
|
|
||||||
|
public enum DecisionCode {
|
||||||
|
ASSIGNED,
|
||||||
|
PATH_READY,
|
||||||
|
AVOIDANCE_CREATED,
|
||||||
|
HOLD_NO_ELIGIBLE_VEHICLE,
|
||||||
|
REJECT_INVALID_SITE,
|
||||||
|
HOLD_NO_PATH,
|
||||||
|
HOLD_CHANNEL_BLOCKED,
|
||||||
|
HOLD_PROTOCOL_FAILURE,
|
||||||
|
SENT,
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.ft546.wcs.demo;
|
||||||
|
|
||||||
|
import com.ft546.wcs.demo.center.VehicleAssignmentDecision;
|
||||||
|
import com.ft546.wcs.demo.center.VehicleAssignmentService;
|
||||||
|
import com.ft546.wcs.demo.model.DecisionCode;
|
||||||
|
import com.ft546.wcs.demo.model.DispatchTask;
|
||||||
|
import com.ft546.wcs.demo.model.Site;
|
||||||
|
import com.ft546.wcs.demo.model.VehicleSnapshot;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
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 VehicleAssignmentServiceTest {
|
||||||
|
private final VehicleAssignmentService service = new VehicleAssignmentService();
|
||||||
|
private Map<String, Site> sites;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
sites = new LinkedHashMap<String, Site>();
|
||||||
|
sites.put("SOURCE", new Site("SOURCE", 1, 10, 10, "PICK"));
|
||||||
|
sites.put("SAME", new Site("SAME", 1, 1, 1, "AISLE-1"));
|
||||||
|
sites.put("NEAR", new Site("NEAR", 2, 10, 9, "AISLE-2"));
|
||||||
|
sites.put("TIE", new Site("TIE", 1, 1, 1, "AISLE-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameFloorVehicleWinsBeforeCloserCrossFloorVehicle() {
|
||||||
|
DispatchTask task = task("SOURCE");
|
||||||
|
VehicleSnapshot crossFloor = vehicle("1", 2, "NEAR", 80, true, true, 5, 1);
|
||||||
|
VehicleSnapshot sameFloor = vehicle("2", 1, "SAME", 80, true, true, 5, 1);
|
||||||
|
|
||||||
|
VehicleAssignmentDecision decision =
|
||||||
|
service.select(task, Arrays.asList(crossFloor, sameFloor), sites);
|
||||||
|
|
||||||
|
assertEquals(DecisionCode.ASSIGNED, decision.getCode());
|
||||||
|
assertEquals("2", decision.getVehicle().get().getVehicleId());
|
||||||
|
assertTrue(decision.getReason().contains("sameFloorPenalty=0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lowerVehicleIdBreaksAnExactDistanceTie() {
|
||||||
|
VehicleSnapshot vehicle2 = vehicle("2", 1, "TIE", 80, true, true, 5, 1);
|
||||||
|
VehicleSnapshot vehicle1 = vehicle("1", 1, "SAME", 80, true, true, 5, 1);
|
||||||
|
|
||||||
|
VehicleAssignmentDecision decision =
|
||||||
|
service.select(task("SOURCE"), Arrays.asList(vehicle2, vehicle1), sites);
|
||||||
|
|
||||||
|
assertEquals("1", decision.getVehicle().get().getVehicleId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onlyConnectedAutomaticIdleAllowedAndChargedVehicleIsEligible() {
|
||||||
|
VehicleSnapshot offline = vehicle("1", 1, "SAME", 80, false, true, 5, 1);
|
||||||
|
VehicleSnapshot lowBattery = vehicle("2", 1, "SAME", 30, true, true, 5, 1);
|
||||||
|
VehicleSnapshot manual = vehicle("3", 1, "SAME", 80, true, true, 4, 1);
|
||||||
|
VehicleSnapshot busy = vehicle("4", 1, "SAME", 80, true, true, 5, 2);
|
||||||
|
VehicleSnapshot blocked = vehicle("5", 1, "SAME", 80, true, false, 5, 1);
|
||||||
|
VehicleSnapshot eligible = vehicle("6", 1, "SAME", 31, true, true, 5, 1);
|
||||||
|
|
||||||
|
VehicleAssignmentDecision decision = service.select(
|
||||||
|
task("SOURCE"),
|
||||||
|
Arrays.asList(offline, lowBattery, manual, busy, blocked, eligible),
|
||||||
|
sites
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals("6", decision.getVehicle().get().getVehicleId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingTaskSiteIsRejectedInsteadOfUsingZeroCoordinates() {
|
||||||
|
VehicleAssignmentDecision decision =
|
||||||
|
service.select(task("UNKNOWN"), Arrays.asList(
|
||||||
|
vehicle("1", 1, "SAME", 80, true, true, 5, 1)
|
||||||
|
), sites);
|
||||||
|
|
||||||
|
assertEquals(DecisionCode.REJECT_INVALID_SITE, decision.getCode());
|
||||||
|
assertFalse(decision.getVehicle().isPresent());
|
||||||
|
assertTrue(decision.getReason().contains("UNKNOWN"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DispatchTask task(String sourceSite) {
|
||||||
|
return new DispatchTask(
|
||||||
|
"TASK-1",
|
||||||
|
DispatchTask.TaskType.OUTBOUND,
|
||||||
|
sourceSite,
|
||||||
|
"SOURCE",
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private VehicleSnapshot vehicle(
|
||||||
|
String id,
|
||||||
|
int floor,
|
||||||
|
String site,
|
||||||
|
int battery,
|
||||||
|
boolean connected,
|
||||||
|
boolean allowSend,
|
||||||
|
int workMode,
|
||||||
|
int status
|
||||||
|
) {
|
||||||
|
return new VehicleSnapshot(
|
||||||
|
id, floor, site, workMode, status, battery, connected, allowSend, false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user