feat(pathfinder): Implement PathFinderService
This commit is contained in:
parent
88a316c0f0
commit
d6e426d1e3
4 changed files with 736 additions and 106 deletions
|
|
@ -6,6 +6,7 @@ import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
|
||||||
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
|
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
|
||||||
import io.github.js0ny.ilp_coursework.service.DroneAttrComparatorService;
|
import io.github.js0ny.ilp_coursework.service.DroneAttrComparatorService;
|
||||||
import io.github.js0ny.ilp_coursework.service.DroneInfoService;
|
import io.github.js0ny.ilp_coursework.service.DroneInfoService;
|
||||||
|
import io.github.js0ny.ilp_coursework.service.PathFinderService;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
@ -24,6 +25,7 @@ public class DroneController {
|
||||||
|
|
||||||
private final DroneInfoService droneInfoService;
|
private final DroneInfoService droneInfoService;
|
||||||
private final DroneAttrComparatorService droneAttrComparatorService;
|
private final DroneAttrComparatorService droneAttrComparatorService;
|
||||||
|
private final PathFinderService pathFinderService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor of the {@code DroneController} with the business logic dependency
|
* Constructor of the {@code DroneController} with the business logic dependency
|
||||||
|
|
@ -37,10 +39,12 @@ public class DroneController {
|
||||||
*/
|
*/
|
||||||
public DroneController(
|
public DroneController(
|
||||||
DroneInfoService droneService,
|
DroneInfoService droneService,
|
||||||
DroneAttrComparatorService droneAttrComparatorService
|
DroneAttrComparatorService droneAttrComparatorService,
|
||||||
|
PathFinderService pathFinderService
|
||||||
) {
|
) {
|
||||||
this.droneInfoService = droneService;
|
this.droneInfoService = droneService;
|
||||||
this.droneAttrComparatorService = droneAttrComparatorService;
|
this.droneAttrComparatorService = droneAttrComparatorService;
|
||||||
|
this.pathFinderService = pathFinderService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -114,14 +118,13 @@ public class DroneController {
|
||||||
public DeliveryPathResponse calculateDeliveryPath(
|
public DeliveryPathResponse calculateDeliveryPath(
|
||||||
@RequestBody MedDispatchRecRequest[] record
|
@RequestBody MedDispatchRecRequest[] record
|
||||||
) {
|
) {
|
||||||
// return new DeliveryPathResponse(0.0f, 0, new DronePathDto[] {});
|
return pathFinderService.calculateDeliveryPath(record);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/calcDeliveryPathAsGeoJson")
|
@PostMapping("/calcDeliveryPathAsGeoJson")
|
||||||
public String calculateDeliveryPathAsGeoJson(
|
public String calculateDeliveryPathAsGeoJson(
|
||||||
@RequestBody MedDispatchRecRequest[] record
|
@RequestBody MedDispatchRecRequest[] record
|
||||||
) {
|
) {
|
||||||
return "{}";
|
return pathFinderService.calculateDeliveryPathAsGeoJson(record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,26 @@
|
||||||
package io.github.js0ny.ilp_coursework.controller;
|
package io.github.js0ny.ilp_coursework.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import io.github.js0ny.ilp_coursework.service.DroneInfoService;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1")
|
||||||
public class GeoJsonDataController {
|
public class GeoJsonDataController {
|
||||||
|
|
||||||
|
private final DroneInfoService droneInfoService;
|
||||||
|
|
||||||
|
public GeoJsonDataController(DroneInfoService droneInfoService) {
|
||||||
|
this.droneInfoService = droneInfoService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/getRestrictedAreaByGeoJson")
|
||||||
|
public String getRestrictedAreaGeoJson() throws JsonProcessingException {
|
||||||
|
return droneInfoService.fetchRestrictedAreasInGeoJson().stream().reduce("", String::concat);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,143 +1,562 @@
|
||||||
package io.github.js0ny.ilp_coursework.service;
|
package io.github.js0ny.ilp_coursework.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
|
||||||
|
import io.github.js0ny.ilp_coursework.controller.DroneController;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.common.Angle;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.common.DroneAvailability;
|
||||||
import io.github.js0ny.ilp_coursework.data.common.LngLat;
|
import io.github.js0ny.ilp_coursework.data.common.LngLat;
|
||||||
import io.github.js0ny.ilp_coursework.data.common.Region;
|
import io.github.js0ny.ilp_coursework.data.common.Region;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.external.Drone;
|
||||||
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
|
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.external.ServicePoint;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.external.ServicePointDrones;
|
||||||
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
|
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
|
||||||
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
|
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath.Delivery;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
/**
|
||||||
import java.util.Collections;
|
* Class that handles calculations about deliverypath
|
||||||
import java.util.HashMap;
|
*
|
||||||
import java.util.List;
|
* @see DroneInfoService
|
||||||
import java.util.LinkedList;
|
* @see DroneController
|
||||||
import java.util.Map;
|
* @see DeliveryPathResponse
|
||||||
import java.util.PriorityQueue;
|
*/
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class PathFinderService {
|
public class PathFinderService {
|
||||||
|
|
||||||
private final GpsCalculationService service;
|
/**
|
||||||
|
* Hard stop on how many pathfinding iterations we attempt for a single
|
||||||
|
* segment before bailing, useful for preventing infinite loops caused by
|
||||||
|
* precision quirks or unexpected map data.
|
||||||
|
*
|
||||||
|
* @see #computePath(LngLat, LngLat)
|
||||||
|
*/
|
||||||
|
private static final int MAX_SEGMENT_ITERATIONS = 8_000;
|
||||||
|
|
||||||
public PathFinderService(GpsCalculationService gpsCalculationService) {
|
// Services
|
||||||
this.service = gpsCalculationService;
|
private final GpsCalculationService gpsCalculationService;
|
||||||
|
private final DroneInfoService droneInfoService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final List<Drone> drones;
|
||||||
|
private final Map<String, Drone> droneById;
|
||||||
|
private final Map<String, Integer> droneServicePointMap;
|
||||||
|
private final Map<Integer, LngLat> servicePointLocations;
|
||||||
|
private final List<Region> restrictedRegions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor for PathFinderService. The dependencies are injected by
|
||||||
|
* Spring and the constructor pre-computes reference maps used throughout the
|
||||||
|
* request lifecycle.
|
||||||
|
*
|
||||||
|
* @param gpsCalculationService Service handling geometric operations.
|
||||||
|
* @param droneInfoService Service that exposes drone metadata and
|
||||||
|
* capability information.
|
||||||
|
*/
|
||||||
|
public PathFinderService(
|
||||||
|
GpsCalculationService gpsCalculationService,
|
||||||
|
DroneInfoService droneInfoService) {
|
||||||
|
this.gpsCalculationService = gpsCalculationService;
|
||||||
|
this.droneInfoService = droneInfoService;
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
|
objectMapper.registerModule(new JavaTimeModule());
|
||||||
|
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||||
|
objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||||
|
|
||||||
|
this.drones = droneInfoService.fetchAllDrones();
|
||||||
|
List<ServicePoint> servicePoints = droneInfoService.fetchServicePoints();
|
||||||
|
List<ServicePointDrones> servicePointAssignments = droneInfoService.fetchDronesForServicePoints();
|
||||||
|
List<RestrictedArea> restrictedAreas = droneInfoService.fetchRestrictedAreas();
|
||||||
|
|
||||||
|
this.droneById = this.drones.stream().collect(
|
||||||
|
Collectors.toMap(Drone::id, drone -> drone));
|
||||||
|
|
||||||
|
this.droneServicePointMap = new HashMap<>();
|
||||||
|
for (ServicePointDrones assignment : servicePointAssignments) {
|
||||||
|
if (assignment == null || assignment.drones() == null) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
for (DroneAvailability availability : assignment.drones()) {
|
||||||
|
if (availability == null || availability.id() == null) {
|
||||||
private static class Node implements Comparable<Node> {
|
continue;
|
||||||
|
|
||||||
final LngLat point;
|
|
||||||
Node parent;
|
|
||||||
|
|
||||||
double g;
|
|
||||||
double h;
|
|
||||||
double f;
|
|
||||||
|
|
||||||
public Node(LngLat point, Node parent, double g, double h) {
|
|
||||||
this.point = point;
|
|
||||||
this.parent = parent;
|
|
||||||
this.g = g;
|
|
||||||
this.h = h;
|
|
||||||
this.f = g + h;
|
|
||||||
}
|
}
|
||||||
|
droneServicePointMap.put(
|
||||||
@Override
|
availability.id(),
|
||||||
public int compareTo(Node other) {
|
assignment.servicePointId());
|
||||||
return Double.compare(this.f, other.f);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.servicePointLocations = servicePoints
|
||||||
|
.stream()
|
||||||
|
.collect(
|
||||||
|
Collectors.toMap(ServicePoint::id, sp -> new LngLat(sp.location())));
|
||||||
|
|
||||||
public static List<LngLat> findPath(
|
this.restrictedRegions = restrictedAreas
|
||||||
LngLat start,
|
.stream()
|
||||||
LngLat target,
|
.map(RestrictedArea::toRegion)
|
||||||
List<RestrictedArea> restricted
|
.toList();
|
||||||
) {
|
|
||||||
var service = new GpsCalculationService();
|
|
||||||
PriorityQueue<Node> openSet = new PriorityQueue<>();
|
|
||||||
Map<LngLat, Double> allNodesMinG = new HashMap<>();
|
|
||||||
|
|
||||||
if (checkIsInRestrictedAreas(target, restricted)) {
|
|
||||||
return Collections.emptyList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Node startNode = new Node(
|
/**
|
||||||
start,
|
* Produce a delivery plan for the provided dispatch records. Deliveries are
|
||||||
null,
|
* grouped per compatible drone and per trip to satisfy each drone move
|
||||||
0,
|
* limit.
|
||||||
service.calculateDistance(start, target)
|
*
|
||||||
);
|
* @param records Dispatch records to be fulfilled.
|
||||||
openSet.add(startNode);
|
* @return Aggregated path response with cost and move totals.
|
||||||
allNodesMinG.put(start, 0.0);
|
* @see #calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[])
|
||||||
|
*/
|
||||||
while (!openSet.isEmpty()) {
|
public DeliveryPathResponse calculateDeliveryPath(
|
||||||
Node current = openSet.poll();
|
MedDispatchRecRequest[] records) {
|
||||||
|
if (records == null || records.length == 0) {
|
||||||
if (service.isCloseTo(current.point, target)) {
|
return new DeliveryPathResponse(0f, 0, new DronePath[0]);
|
||||||
return reconstructPath(current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
Map<String, List<MedDispatchRecRequest>> assigned = assignDeliveries(
|
||||||
current.g >
|
records);
|
||||||
allNodesMinG.getOrDefault(current.point, Double.MAX_VALUE)
|
|
||||||
) {
|
List<DronePath> paths = new ArrayList<>();
|
||||||
|
float totalCost = 0f;
|
||||||
|
int totalMoves = 0;
|
||||||
|
|
||||||
|
for (Map.Entry<String, List<MedDispatchRecRequest>> entry : assigned.entrySet()) {
|
||||||
|
String droneId = entry.getKey();
|
||||||
|
Drone drone = droneById.get(droneId);
|
||||||
|
if (drone == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Integer spId = droneServicePointMap.get(droneId);
|
||||||
|
if (spId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
LngLat servicePointLocation = servicePointLocations.get(spId);
|
||||||
|
if (servicePointLocation == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (LngLat neighbour : getNeighbours(current.point)) {
|
List<MedDispatchRecRequest> sortedDeliveries = entry
|
||||||
if (checkIsInRestrictedAreas(neighbour, restricted)) {
|
.getValue()
|
||||||
|
.stream()
|
||||||
|
.sorted(
|
||||||
|
Comparator.comparingDouble(rec -> gpsCalculationService.calculateDistance(
|
||||||
|
servicePointLocation,
|
||||||
|
rec.delivery())))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<List<MedDispatchRecRequest>> trips = splitTrips(
|
||||||
|
sortedDeliveries,
|
||||||
|
drone,
|
||||||
|
servicePointLocation);
|
||||||
|
|
||||||
|
for (List<MedDispatchRecRequest> trip : trips) {
|
||||||
|
TripResult result = buildTrip(
|
||||||
|
drone,
|
||||||
|
servicePointLocation,
|
||||||
|
trip);
|
||||||
|
if (result != null) {
|
||||||
|
totalCost += result.cost();
|
||||||
|
totalMoves += result.moves();
|
||||||
|
paths.add(result.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DeliveryPathResponse(
|
||||||
|
totalCost,
|
||||||
|
totalMoves,
|
||||||
|
paths.toArray(new DronePath[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper around {@link #calculateDeliveryPath} that serializes
|
||||||
|
* the result into a GeoJSON FeatureCollection suitable for mapping
|
||||||
|
* visualization.
|
||||||
|
*
|
||||||
|
* @param records Dispatch records to be fulfilled.
|
||||||
|
* @return GeoJSON payload representing every delivery flight path.
|
||||||
|
* @throws IllegalStateException When the payload cannot be serialized.
|
||||||
|
*/
|
||||||
|
public String calculateDeliveryPathAsGeoJson(
|
||||||
|
MedDispatchRecRequest[] records) {
|
||||||
|
DeliveryPathResponse response = calculateDeliveryPath(records);
|
||||||
|
Map<String, Object> featureCollection = new LinkedHashMap<>();
|
||||||
|
featureCollection.put("type", "FeatureCollection");
|
||||||
|
List<Map<String, Object>> features = new ArrayList<>();
|
||||||
|
|
||||||
|
if (response != null && response.dronePaths() != null) {
|
||||||
|
for (DronePath dronePath : response.dronePaths()) {
|
||||||
|
if (dronePath == null || dronePath.deliveries() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (Delivery delivery : dronePath.deliveries()) {
|
||||||
|
Map<String, Object> feature = new LinkedHashMap<>();
|
||||||
|
feature.put("type", "Feature");
|
||||||
|
|
||||||
|
Map<String, Object> properties = new LinkedHashMap<>();
|
||||||
|
properties.put("droneId", dronePath.droneId());
|
||||||
|
properties.put("deliveryId", delivery.deliveryId());
|
||||||
|
feature.put("properties", properties);
|
||||||
|
|
||||||
|
Map<String, Object> geometry = new LinkedHashMap<>();
|
||||||
|
geometry.put("type", "LineString");
|
||||||
|
|
||||||
|
List<List<Double>> coordinates = new ArrayList<>();
|
||||||
|
if (delivery.flightPath() != null) {
|
||||||
|
for (LngLat point : delivery.flightPath()) {
|
||||||
|
coordinates.add(List.of(point.lng(), point.lat()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
geometry.put("coordinates", coordinates);
|
||||||
|
feature.put("geometry", geometry);
|
||||||
|
features.add(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
featureCollection.put("features", features);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(featureCollection);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Failed to generate GeoJSON payload",
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group dispatch records by their assigned drone, ensuring every record is
|
||||||
|
* routed through {@link #findBestDrone(MedDispatchRecRequest)} exactly once
|
||||||
|
* and discarding invalid entries.
|
||||||
|
*
|
||||||
|
* @param records Dispatch records to be grouped.
|
||||||
|
* @return Map keyed by drone ID with the deliveries it should service.
|
||||||
|
*/
|
||||||
|
private Map<String, List<MedDispatchRecRequest>> assignDeliveries(
|
||||||
|
MedDispatchRecRequest[] records) {
|
||||||
|
Map<String, List<MedDispatchRecRequest>> assignments = new LinkedHashMap<>();
|
||||||
|
for (MedDispatchRecRequest record : records) {
|
||||||
|
if (record == null || record.delivery() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String droneId = findBestDrone(record);
|
||||||
|
assignments
|
||||||
|
.computeIfAbsent(droneId, id -> new ArrayList<>())
|
||||||
|
.add(record);
|
||||||
|
}
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose the best drone for the provided record. Currently that equates to
|
||||||
|
* picking the closest compatible drone to the delivery location.
|
||||||
|
*
|
||||||
|
* @param record Dispatch record that needs fulfillment.
|
||||||
|
* @return Identifier of the drone that should fly the mission.
|
||||||
|
* @throws IllegalStateException If no available drone can serve the request.
|
||||||
|
*/
|
||||||
|
private String findBestDrone(MedDispatchRecRequest record) {
|
||||||
|
double bestScore = Double.MAX_VALUE;
|
||||||
|
String bestDrone = null;
|
||||||
|
|
||||||
|
for (Drone drone : drones) {
|
||||||
|
if (!droneInfoService.droneMatchesRequirement(drone, record)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String droneId = drone.id();
|
||||||
|
Integer servicePointId = droneServicePointMap.get(droneId);
|
||||||
|
if (servicePointId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
LngLat servicePointLocation = servicePointLocations.get(
|
||||||
|
servicePointId);
|
||||||
|
if (servicePointLocation == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
double newG = current.g + 0.00015;
|
double distance = gpsCalculationService.calculateDistance(
|
||||||
|
servicePointLocation,
|
||||||
|
record.delivery());
|
||||||
|
|
||||||
if (newG < allNodesMinG.getOrDefault(neighbour, Double.MAX_VALUE)) {
|
if (distance < bestScore) {
|
||||||
double newH = service.calculateDistance(neighbour, target);
|
bestScore = distance;
|
||||||
Node neighbourNode = new Node(neighbour, current, newG, newH);
|
bestDrone = droneId;
|
||||||
allNodesMinG.put(neighbour, newG);
|
|
||||||
openSet.add(neighbourNode);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (bestDrone == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"No available drone for delivery " + record.id());
|
||||||
}
|
}
|
||||||
return Collections.emptyList();
|
return bestDrone;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<LngLat> reconstructPath(Node endNode) {
|
/**
|
||||||
LinkedList<LngLat> path = new LinkedList<>();
|
* Break a sequence of deliveries into several trips that each respect the
|
||||||
Node curr = endNode;
|
* drone move limit. The deliveries should already be ordered by proximity
|
||||||
while (curr != null) {
|
* for sensible grouping.
|
||||||
path.addFirst(curr.point);
|
*
|
||||||
curr = curr.parent;
|
* @param deliveries Deliveries assigned to a drone.
|
||||||
|
* @param drone Drone that will service the deliveries.
|
||||||
|
* @param servicePoint Starting and ending point of every trip.
|
||||||
|
* @return Partitioned trips with at least one delivery each.
|
||||||
|
* @throws IllegalStateException If a single delivery exceeds the drone's
|
||||||
|
* move limit.
|
||||||
|
*/
|
||||||
|
private List<List<MedDispatchRecRequest>> splitTrips(
|
||||||
|
List<MedDispatchRecRequest> deliveries,
|
||||||
|
Drone drone,
|
||||||
|
LngLat servicePoint) {
|
||||||
|
List<List<MedDispatchRecRequest>> trips = new ArrayList<>();
|
||||||
|
List<MedDispatchRecRequest> currentTrip = new ArrayList<>();
|
||||||
|
for (MedDispatchRecRequest delivery : deliveries) {
|
||||||
|
currentTrip.add(delivery);
|
||||||
|
int requiredMoves = estimateTripMoves(servicePoint, currentTrip);
|
||||||
|
if (requiredMoves > drone.capability().maxMoves()) {
|
||||||
|
currentTrip.remove(currentTrip.size() - 1);
|
||||||
|
if (currentTrip.isEmpty()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Delivery " +
|
||||||
|
delivery.id() +
|
||||||
|
" exceeds drone " +
|
||||||
|
drone.id() +
|
||||||
|
" move limit");
|
||||||
}
|
}
|
||||||
return path;
|
trips.add(new ArrayList<>(currentTrip));
|
||||||
|
currentTrip.clear();
|
||||||
|
currentTrip.add(delivery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!currentTrip.isEmpty()) {
|
||||||
|
int requiredMoves = estimateTripMoves(servicePoint, currentTrip);
|
||||||
|
if (requiredMoves > drone.capability().maxMoves()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Delivery plan exceeds move limit for drone " + drone.id());
|
||||||
|
}
|
||||||
|
trips.add(new ArrayList<>(currentTrip));
|
||||||
|
}
|
||||||
|
return trips;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean checkIsInRestrictedAreas(
|
/**
|
||||||
LngLat point,
|
* Build a single trip for the provided drone, including the entire flight
|
||||||
List<RestrictedArea> RestrictedAreas
|
* path to every delivery and back home. The resulting structure contains the
|
||||||
) {
|
* {@link DronePath} representation as well as cost and moves consumed.
|
||||||
var service = new GpsCalculationService();
|
*
|
||||||
for (RestrictedArea area : RestrictedAreas) {
|
* @param drone Drone executing the trip.
|
||||||
Region r = area.toRegion();
|
* @param servicePoint Starting/ending location of the trip.
|
||||||
if (service.checkIsInRegion(point, r)) {
|
* @param deliveries Deliveries to include in the trip in execution order.
|
||||||
|
* @return Trip information or {@code null} if no deliveries are provided.
|
||||||
|
* @see DeliveryPathResponse.DronePath
|
||||||
|
*/
|
||||||
|
private TripResult buildTrip(
|
||||||
|
Drone drone,
|
||||||
|
LngLat servicePoint,
|
||||||
|
List<MedDispatchRecRequest> deliveries) {
|
||||||
|
if (deliveries == null || deliveries.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<Delivery> flightPlans = new ArrayList<>();
|
||||||
|
LngLat current = servicePoint;
|
||||||
|
int moves = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < deliveries.size(); i++) {
|
||||||
|
MedDispatchRecRequest delivery = deliveries.get(i);
|
||||||
|
PathSegment toDelivery = computePath(current, delivery.delivery());
|
||||||
|
List<LngLat> flightPath = new ArrayList<>(toDelivery.positions());
|
||||||
|
if (!flightPath.isEmpty()) {
|
||||||
|
LngLat last = flightPath.get(flightPath.size() - 1);
|
||||||
|
if (!last.isSamePoint(delivery.delivery())) {
|
||||||
|
flightPath.add(delivery.delivery());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flightPath.add(current);
|
||||||
|
flightPath.add(delivery.delivery());
|
||||||
|
}
|
||||||
|
flightPath.add(delivery.delivery());
|
||||||
|
moves += toDelivery.moves();
|
||||||
|
|
||||||
|
if (i == deliveries.size() - 1) {
|
||||||
|
PathSegment backHome = computePath(
|
||||||
|
delivery.delivery(),
|
||||||
|
servicePoint);
|
||||||
|
backHome.appendSkippingStart(flightPath);
|
||||||
|
moves += backHome.moves();
|
||||||
|
current = servicePoint;
|
||||||
|
} else {
|
||||||
|
current = delivery.delivery();
|
||||||
|
}
|
||||||
|
flightPlans.add(new Delivery(delivery.id(), flightPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
float cost = drone.capability().costInitial() +
|
||||||
|
drone.capability().costFinal() +
|
||||||
|
(float) (drone.capability().costPerMove() * moves);
|
||||||
|
|
||||||
|
DronePath path = new DronePath(drone.parseId(), flightPlans);
|
||||||
|
|
||||||
|
return new TripResult(path, moves, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate the number of moves a prospective trip would need by replaying
|
||||||
|
* the path calculation without mutating any persistent state.
|
||||||
|
*
|
||||||
|
* @param servicePoint Trip origin.
|
||||||
|
* @param deliveries Deliveries that would compose the trip.
|
||||||
|
* @return Total moves required to fly the proposed itinerary.
|
||||||
|
*/
|
||||||
|
private int estimateTripMoves(
|
||||||
|
LngLat servicePoint,
|
||||||
|
List<MedDispatchRecRequest> deliveries) {
|
||||||
|
if (deliveries.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int moves = 0;
|
||||||
|
LngLat current = servicePoint;
|
||||||
|
for (MedDispatchRecRequest delivery : deliveries) {
|
||||||
|
PathSegment segment = computePath(current, delivery.delivery());
|
||||||
|
moves += segment.moves();
|
||||||
|
current = delivery.delivery();
|
||||||
|
}
|
||||||
|
moves += computePath(current, servicePoint).moves();
|
||||||
|
return moves;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a path between {@code start} and {@code target} by repeatedly moving
|
||||||
|
* in snapped increments while avoiding restricted zones.
|
||||||
|
*
|
||||||
|
* @param start Start coordinate.
|
||||||
|
* @param target Destination coordinate.
|
||||||
|
* @return Sequence of visited coordinates and move count.
|
||||||
|
* @see #nextPosition(LngLat, LngLat)
|
||||||
|
*/
|
||||||
|
private PathSegment computePath(LngLat start, LngLat target) {
|
||||||
|
List<LngLat> positions = new ArrayList<>();
|
||||||
|
if (start == null || target == null) {
|
||||||
|
return new PathSegment(positions, 0);
|
||||||
|
}
|
||||||
|
positions.add(start);
|
||||||
|
LngLat current = start;
|
||||||
|
int iterations = 0;
|
||||||
|
while (!gpsCalculationService.isCloseTo(current, target) &&
|
||||||
|
iterations < MAX_SEGMENT_ITERATIONS) {
|
||||||
|
LngLat next = nextPosition(current, target);
|
||||||
|
if (next.isSamePoint(current)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
positions.add(next);
|
||||||
|
current = next;
|
||||||
|
iterations++;
|
||||||
|
}
|
||||||
|
if (!positions.get(positions.size() - 1).isSamePoint(target)) {
|
||||||
|
positions.add(target);
|
||||||
|
}
|
||||||
|
return new PathSegment(positions, Math.max(0, positions.size() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the next position on the path from {@code current} toward
|
||||||
|
* {@code target}, preferring the snapped angle closest to the desired
|
||||||
|
* heading that does not infiltrate a restricted region.
|
||||||
|
*
|
||||||
|
* @param current Current coordinate.
|
||||||
|
* @param target Destination coordinate.
|
||||||
|
* @return Next admissible coordinate or the original point if none can be
|
||||||
|
* found.
|
||||||
|
*/
|
||||||
|
private LngLat nextPosition(LngLat current, LngLat target) {
|
||||||
|
double desiredAngle = Math.toDegrees(
|
||||||
|
Math.atan2(
|
||||||
|
target.lat() - current.lat(),
|
||||||
|
target.lng() - current.lng()));
|
||||||
|
List<Angle> candidateAngles = buildAngleCandidates(desiredAngle);
|
||||||
|
for (Angle angle : candidateAngles) {
|
||||||
|
LngLat next = gpsCalculationService.nextPosition(current, angle);
|
||||||
|
if (!isRestricted(next)) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a sequence of candidate angles centered on the desired heading,
|
||||||
|
* expanding symmetrically clockwise and counter-clockwise to explore
|
||||||
|
* alternative headings if the primary path is blocked.
|
||||||
|
*
|
||||||
|
* @param desiredAngle Bearing in degrees between current and target
|
||||||
|
* positions.
|
||||||
|
* @return Ordered list of candidate snapped angles.
|
||||||
|
* @see Angle#snap(double)
|
||||||
|
*/
|
||||||
|
private List<Angle> buildAngleCandidates(double desiredAngle) {
|
||||||
|
List<Angle> angles = new LinkedList<>();
|
||||||
|
Angle snapped = Angle.snap(desiredAngle);
|
||||||
|
angles.add(snapped);
|
||||||
|
for (int offset = 1; offset <= 8; offset++) {
|
||||||
|
angles.add(snapped.offset(offset));
|
||||||
|
angles.add(snapped.offset(-offset));
|
||||||
|
}
|
||||||
|
return angles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the provided coordinate falls inside any restricted region.
|
||||||
|
*
|
||||||
|
* @param position Coordinate to inspect.
|
||||||
|
* @return {@code true} if the position intersects a restricted area.
|
||||||
|
* @see #restrictedRegions
|
||||||
|
*/
|
||||||
|
private boolean isRestricted(LngLat position) {
|
||||||
|
for (Region region : restrictedRegions) {
|
||||||
|
if (gpsCalculationService.checkIsInRegion(position, region)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<LngLat> getNeighbours(LngLat p) {
|
/**
|
||||||
var service = new GpsCalculationService();
|
* Representation of a computed path segment wrapping the visited positions
|
||||||
double angle = 0;
|
* and the number of moves taken to traverse them.
|
||||||
List<LngLat> positions = new ArrayList<>();
|
*
|
||||||
final int directionCount = 8;
|
* @param positions Ordered coordinates that describe the path.
|
||||||
for (int i = 0; i < directionCount; i++) {
|
* @param moves Number of moves consumed by the path.
|
||||||
double directionAngle = angle + (i * 45);
|
*/
|
||||||
LngLat nextPosition = service.nextPosition(p, directionAngle);
|
private record PathSegment(List<LngLat> positions, int moves) {
|
||||||
positions.add(nextPosition);
|
/**
|
||||||
}
|
* Append the positions from this segment to {@code target}, skipping the
|
||||||
return positions;
|
* first coordinate as it is already represented by the last coordinate
|
||||||
|
* in the consumer path.
|
||||||
|
*
|
||||||
|
* @param target Mutable list to append to.
|
||||||
|
*/
|
||||||
|
private void appendSkippingStart(List<LngLat> target) {
|
||||||
|
for (int i = 1; i < positions.size(); i++) {
|
||||||
|
target.add(positions.get(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundle containing the calculated {@link DronePath}, total moves and
|
||||||
|
* financial cost for a single trip.
|
||||||
|
*/
|
||||||
|
private record TripResult(DronePath path, int moves, float cost) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
package io.github.js0ny.ilp_coursework.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.common.DroneAvailability;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.common.DroneCapability;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.common.LngLat;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.common.LngLatAlt;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.common.TimeWindow;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.external.Drone;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.external.ServicePoint;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.external.ServicePointDrones;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest.MedRequirement;
|
||||||
|
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.DayOfWeek;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PathFinderServiceTest {
|
||||||
|
|
||||||
|
private static final String DRONE_ID = "test-drone";
|
||||||
|
private static final LngLat SERVICE_POINT_COORD = new LngLat(0.0, 0.0);
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private DroneInfoService droneInfoService;
|
||||||
|
|
||||||
|
private PathFinderService pathFinderService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUpPathFinder() {
|
||||||
|
GpsCalculationService gpsCalculationService = new GpsCalculationService();
|
||||||
|
|
||||||
|
DroneCapability capability = new DroneCapability(
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
5.0f,
|
||||||
|
10,
|
||||||
|
0.1f,
|
||||||
|
0.5f,
|
||||||
|
0.5f
|
||||||
|
);
|
||||||
|
Drone drone = new Drone("Test Drone", DRONE_ID, capability);
|
||||||
|
ServicePoint servicePoint = new ServicePoint(
|
||||||
|
"Test Point",
|
||||||
|
1,
|
||||||
|
new LngLatAlt(
|
||||||
|
SERVICE_POINT_COORD.lng(),
|
||||||
|
SERVICE_POINT_COORD.lat(),
|
||||||
|
50.0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
DroneAvailability availability = new DroneAvailability(
|
||||||
|
DRONE_ID,
|
||||||
|
new TimeWindow[] {
|
||||||
|
new TimeWindow(
|
||||||
|
DayOfWeek.MONDAY,
|
||||||
|
LocalTime.MIDNIGHT,
|
||||||
|
LocalTime.MAX
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ServicePointDrones servicePointDrones = new ServicePointDrones(
|
||||||
|
servicePoint.id(),
|
||||||
|
new DroneAvailability[] { availability }
|
||||||
|
);
|
||||||
|
|
||||||
|
when(droneInfoService.fetchAllDrones()).thenReturn(List.of(drone));
|
||||||
|
when(droneInfoService.fetchServicePoints()).thenReturn(
|
||||||
|
List.of(servicePoint)
|
||||||
|
);
|
||||||
|
when(droneInfoService.fetchDronesForServicePoints()).thenReturn(
|
||||||
|
List.of(servicePointDrones)
|
||||||
|
);
|
||||||
|
when(droneInfoService.fetchRestrictedAreas()).thenReturn(
|
||||||
|
Collections.<RestrictedArea>emptyList()
|
||||||
|
);
|
||||||
|
when(droneInfoService.droneMatchesRequirement(any(), any())).thenReturn(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
pathFinderService = new PathFinderService(
|
||||||
|
gpsCalculationService,
|
||||||
|
droneInfoService
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calculateDeliveryPath_shouldStayWithinSingleTripBudget() {
|
||||||
|
MedDispatchRecRequest request = createSampleRequest();
|
||||||
|
|
||||||
|
DeliveryPathResponse response = pathFinderService.calculateDeliveryPath(
|
||||||
|
new MedDispatchRecRequest[] { request }
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(response.totalMoves()).isGreaterThan(0);
|
||||||
|
assertThat(response.totalMoves()).isLessThanOrEqualTo(10);
|
||||||
|
assertThat(response.dronePaths()).hasSize(1);
|
||||||
|
DeliveryPathResponse.DronePath dronePath = response.dronePaths()[0];
|
||||||
|
assertThat(dronePath.droneId()).isNotZero();
|
||||||
|
assertThat(dronePath.deliveries()).hasSize(1);
|
||||||
|
var recordedPath = dronePath.deliveries().get(0).flightPath();
|
||||||
|
assertThat(recordedPath.get(0)).isEqualTo(SERVICE_POINT_COORD);
|
||||||
|
assertThat(
|
||||||
|
samePoint(
|
||||||
|
recordedPath.get(recordedPath.size() - 1),
|
||||||
|
SERVICE_POINT_COORD
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.isTrue();
|
||||||
|
assertThat(hasHoverAt(recordedPath, request.delivery())).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calculateDeliveryPathAsGeoJson_shouldReturnFeatureCollection()
|
||||||
|
throws IOException {
|
||||||
|
MedDispatchRecRequest request = createSampleRequest();
|
||||||
|
|
||||||
|
String geoJson = pathFinderService.calculateDeliveryPathAsGeoJson(
|
||||||
|
new MedDispatchRecRequest[] { request }
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root = mapper.readTree(geoJson);
|
||||||
|
assertThat(root.get("type").asText()).isEqualTo("FeatureCollection");
|
||||||
|
JsonNode features = root.get("features");
|
||||||
|
assertThat(features.size()).isEqualTo(1);
|
||||||
|
JsonNode feature = features.get(0);
|
||||||
|
assertThat(feature.get("type").asText()).isEqualTo("Feature");
|
||||||
|
JsonNode props = feature.get("properties");
|
||||||
|
assertThat(props.get("droneId").asInt()).isNotZero();
|
||||||
|
assertThat(props.get("deliveryId").asInt()).isEqualTo(request.id());
|
||||||
|
|
||||||
|
JsonNode geometry = feature.get("geometry");
|
||||||
|
assertThat(geometry.get("type").asText()).isEqualTo("LineString");
|
||||||
|
JsonNode coordinates = geometry.get("coordinates");
|
||||||
|
assertThat(coordinates.size()).isGreaterThanOrEqualTo(2);
|
||||||
|
|
||||||
|
double startLng = coordinates.get(0).get(0).asDouble();
|
||||||
|
double startLat = coordinates.get(0).get(1).asDouble();
|
||||||
|
assertThat(
|
||||||
|
samePoint(new LngLat(startLng, startLat), SERVICE_POINT_COORD)
|
||||||
|
)
|
||||||
|
.isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MedDispatchRecRequest createSampleRequest() {
|
||||||
|
return new MedDispatchRecRequest(
|
||||||
|
101,
|
||||||
|
LocalDate.of(2025, 1, 6),
|
||||||
|
LocalTime.of(12, 0),
|
||||||
|
new MedRequirement(0.5f, false, true, 50.0f),
|
||||||
|
new LngLat(SERVICE_POINT_COORD.lng() + 0.0003, SERVICE_POINT_COORD.lat())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasHoverAt(List<LngLat> path, LngLat target) {
|
||||||
|
for (int i = 0; i < path.size() - 1; i++) {
|
||||||
|
if (samePoint(path.get(i), target) && samePoint(path.get(i + 1), target)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean samePoint(LngLat a, LngLat b) {
|
||||||
|
double threshold = 1e-9;
|
||||||
|
return (
|
||||||
|
Math.abs(a.lng() - b.lng()) < threshold &&
|
||||||
|
Math.abs(a.lat() - b.lat()) < threshold
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue