I have a java spring boot Backend application which will fetch unclaimed visits from mongo and then use optaplanner to find best route to cover those visits for a provider/clinician. Also have few constraints similar to VRP in my constraint provider class. Issue is i am getting NPEs in Variable change listener due to departureTime being null even when previousVisit iss not null.
Visits previousVisit = visit.getPreviousVisit();
LocalDateTime departureTime =
previousVisit == null ? visit.getAssignedClinician().getDepartureTime() : previousVisit.getDepartureTime();
Visits nextVisit = visit;
LocalDateTime arrivalTime = previousVisit == null ?
departureTime.plusMinutes(getDrivingTime(visit.getAssignedClinician().getLocation().getCoordinates(), visit.getMember().getMemberLocation().getCoordinates())) :
departureTime.plusMinutes(getDrivingTime(previousVisit.getMember().getMemberLocation().getCoordinates(), nextVisit.getMember().getMemberLocation().getCoordinates()));
**NOTE : Since i want to schedule visits and not make real time changes to provider's schedule, Is Variable Change listener needed for my use case? if not, can u pls let me know where i am making a mistake or if the design needs to be changed. Thanks and really appreciate. **
Below is a basic use case of my backend application.
Use case :
Clinician/Provider has avaialbility in a day and member also has avaialabilty, providers slot can be broken up into 1 hour slots. Provider will start from home and perform visits. We need to find optimum viists for provider. Below is example. It is Vehicle Routing Problem time windowed with additional constraints.
Now instead of distance we need to check driving time and 1 hour visit slot, so lets say provider slots are from 8-11am and an average visit is 55 min and visit window for member is 8-10am, then provider should reach member location in between 8-10am and also complete 55 min visit within that time frame, so basically it is vehicle routing problem with time window of both provider and member and considering driving time and visit time. so instead of haversine distance we will use mapbox api to calculate drive time and assume visit time to be 55 mins.
below example route for provider to clarify more:
provider slot : 8-11am
visit 1(time window 8-10am) : drive time 20 mins leave home at 7:40am reach at 8am and do visit for 55 mins , that is till 8:55am (within visit time window) assigned provider slot : 8-9am
visit 1 to visit 2(time window 9-11am) : drive time 20 mins reach at 9:15am and do visit for 55 mins , that is till 10:05am (within visit time window) assigned provider slot : 9-10am
visit 2 to visit 3(time window 10-11am) : drive time 20 mins reach at 10:25am and do visit for 55 mins , that is till 11:20am (within visit time window) , so this visit should not be assigned to the provider No provider slot assigned
Planning Entity 1:
@PlanningEntity
@Data
public class Visits {
private String _id;
private Double matrixVisitId;
private Long clientId;
private VisitMsa msa;
private Date[] slotPreferences;
private String[] products;
private VisitsMember member;
private String slotType;
private boolean pinned;
private TimeWindow timeWindow;
private ClinicianSlot assignedSlot;
private Providers assignedClinician;
private Visits previousVisit;
private Visits nextVisit;
private LocalDateTime arrivalTime;
@ValueRangeProvider(id = "timeBlockRangeForVisit")
public List<ClinicianSlot> generatePossibleTimeBlocks() {
if (this.timeWindow == null) {
return Collections.emptyList(); // Return an empty list for home visit
}
List<ClinicianSlot> possibleTimeBlocks = new ArrayList<>();
LocalDateTime start = timeWindow.getStart();
while (!start.plusHours(1).isAfter(timeWindow.getEnd())) {
possibleTimeBlocks.add(new ClinicianSlot(start, start.plusHours(1)));
start = start.plusHours(1);
}
return possibleTimeBlocks;
}
@PlanningPin
public boolean isPinned() {
return pinned;
}
@PreviousElementShadowVariable(sourceVariableName = "visits")
public Visits getPreviousVisit() {
return previousVisit;
}
public void setPreviousVisit(Visits previousVisit) {
this.previousVisit = previousVisit;
}
@NextElementShadowVariable(sourceVariableName = "visits")
public Visits getNextVisit() {
return nextVisit;
}
public void setNextVisit(Visits nextVisit) {
this.nextVisit = nextVisit;
}
@InverseRelationShadowVariable(sourceVariableName = "visits")
public Providers getAssignedClinician() {
return assignedClinician;
}
public void setVehicle(Providers assignedClinician) {
this.assignedClinician = assignedClinician;
}
@ShadowVariable(variableListenerClass = VisitChainVariableListener.class, sourceVariableName = "previousVisit")
public LocalDateTime getArrivalTime() {
return arrivalTime;
}
public void setArrivalTime(LocalDateTime arrivalTime) {
this.arrivalTime = arrivalTime;
}
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
public LocalDateTime getDepartureTime() {
if (arrivalTime == null) {
return null;
}
return getStartServiceTime().plusMinutes(55);
}
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
public LocalDateTime getStartServiceTime() {
if (arrivalTime == null) {
return null;
}
return arrivalTime.isBefore(minStartTime) ? minStartTime : arrivalTime;
}
}
**Planning Entity 2 : **
@Data
@PlanningEntity
@AllArgsConstructor
@NoArgsConstructor
public class Providers {
private Integer staffResourceId;
private String providerId;
private ProviderMsa[] msa;
private Client[] clients;
private String[] products;
private List<ClinicianSlot> availableSlots;
@PlanningListVariable(valueRangeProviderRefs = "visitRange")
private List<Visits> visits = new ArrayList<>();
@GeoSpatialIndexed(type = GeoSpatialIndexType.GEO_2DSPHERE)
private Location location;
private LocalDateTime departureTime;
}
Solution Class :
@PlanningSolution
@Data
public class ProviderRouteSolution {
private List<Visits> visitList;
@ProblemFactCollectionProperty
@ValueRangeProvider(id = "visitRange")
public List<Visits> getVisitList() {
return visitList;
}
private List<Providers> clinicianList;
@PlanningScore
private HardSoftScore score;
@ValueRangeProvider(id = "clinicianRange")
@PlanningEntityCollectionProperty
public List<Providers> getClinicianList() {
return clinicianList;
}
}
Variable Change Listener Class :
public class VisitChainVariableListener implements VariableListener<ProviderRouteSolution, Visits> {
@Override
public void afterVariableChanged(ScoreDirector<ProviderRouteSolution> scoreDirector, Visits visit) {
if (visit.getAssignedClinician() == null) {
if (visit.getArrivalTime() != null) {
scoreDirector.beforeVariableChanged(visit, "arrivalTime");
visit.setArrivalTime(null);
scoreDirector.afterVariableChanged(visit, "arrivalTime");
}
return;
}
Visits previousVisit = visit.getPreviousVisit();
LocalDateTime departureTime =
previousVisit == null ? visit.getAssignedClinician().getDepartureTime() : previousVisit.getDepartureTime();
Visits nextVisit = visit;
LocalDateTime arrivalTime = previousVisit == null ?
departureTime.plusMinutes(getDrivingTime(visit.getAssignedClinician().getLocation().getCoordinates(), visit.getMember().getMemberLocation().getCoordinates())) :
departureTime.plusMinutes(getDrivingTime(previousVisit.getMember().getMemberLocation().getCoordinates(), nextVisit.getMember().getMemberLocation().getCoordinates()));
while (nextVisit != null && !Objects.equals(nextVisit.getArrivalTime(), arrivalTime)) {
scoreDirector.beforeVariableChanged(nextVisit, "arrivalTime");
nextVisit.setArrivalTime(arrivalTime);
scoreDirector.afterVariableChanged(nextVisit, "arrivalTime");
departureTime = nextVisit.getDepartureTime();
Double[] departureCoordinates = nextVisit.getMember().getMemberLocation().getCoordinates();
nextVisit = nextVisit.getNextVisit();
arrivalTime = departureTime.plusMinutes(getDrivingTime(departureCoordinates, visit.getMember().getMemberLocation().getCoordinates()));
}
}
}
Provider Route Service :
public List<String> getProviderRouteForTheDay(int staffResourceId, double radiusInMiles, String visitDateString) throws ParseException {
Providers provider = providersRepository.findByStaffResourceId(staffResourceId);
Double longitude = provider.getLocation().getCoordinates()[0];
Double latitude = provider.getLocation().getCoordinates()[1];
double radiusInMeters = radiusInMiles * 1609.34;
Location location = new Location(latitude, longitude);
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
Date visitDate = dateFormat.parse(visitDateString);
Calendar startCal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
startCal.setTime(visitDate);
startCal.set(Calendar.HOUR_OF_DAY, 0);
startCal.set(Calendar.MINUTE, 0);
startCal.set(Calendar.SECOND, 0);
startCal.set(Calendar.MILLISECOND, 0);
Date startOfDay = startCal.getTime();
Calendar endCal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
endCal.setTime(visitDate);
endCal.set(Calendar.HOUR_OF_DAY, 23);
endCal.set(Calendar.MINUTE, 59);
endCal.set(Calendar.SECOND, 59);
endCal.set(Calendar.MILLISECOND, 999);
Date endOfDay = endCal.getTime();
System.out.println("Start of Day: " + startOfDay);
System.out.println("End of Day: " + endOfDay);
List<ProviderSlots> providerSlots = providerSlotsRepository.findSlotsByProviderAndDateRangeAndStatus(provider.getStaffResourceId(), startOfDay, endOfDay, "available");
List<ClinicianSlot> clinicianSlots = new ArrayList<>();
for (ProviderSlots slot : providerSlots) {
clinicianSlots.addAll(generate1HourSlots(convertToLocalDateTime(slot.getSlotTime()), convertToLocalDateTime(slot.getSlotEndTime())));
}
provider.setAvailableSlots(clinicianSlots);
provider.setDepartureTime(LocalDateTime.of(
LocalDateTime.now(ZoneOffset.UTC).getYear(),
LocalDateTime.now(ZoneOffset.UTC).getMonth(),
LocalDateTime.now(ZoneOffset.UTC).getDayOfMonth(),
12, 0
));
List<Visits> visitsList = visitsRepository.findUnclaimedVisitsNear(location.getLongitude(), location.getLatitude(), radiusInMeters, startOfDay, endOfDay);
assignVisitTimeWindows(visitsList);
ProviderRouteSolution unsolvedSolution = new ProviderRouteSolution();
unsolvedSolution.setVisitList(visitsList);
unsolvedSolution.setClinicianList(Collections.singletonList(provider));
initializeDrivingTimeMatrix(provider, visitsList);
try {
ProviderRouteSolution solvedSolution = solver.solve(unsolvedSolution);
} catch(Exception ex) {
ex.printStackTrace();
}
return null;
}
I wanted to find optimal visits route for the provider , getting NPE in Variable Chain Listener Class. I am not sure if my implementation is correct since i am not looking for optaweb-VRP, i am looking for Chaining visits to find optimised path for provider.
The mechanism for implementing shadow variable listeners has been greatly simplified in one of the newest releases. You can check out the blog post about that on the official Timefold site.
As for use in an example, the quickstarts have been adjusted accordingly. I think almost everything you need would be included in the Vehicle Routing example found. This includes chained visits through the @PlanningListVariable annotation. In the quickstart, that annotation is used here.
FYI: Since this question explicitly mentions OptaPlanner: Timefold is a well maintained fork of OptaPlanner.