feat(pathfinder): Implement PathFinderService

This commit is contained in:
js0ny 2025-11-27 13:57:32 +00:00
parent 88a316c0f0
commit d6e426d1e3
4 changed files with 736 additions and 106 deletions

View file

@ -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);
} }
} }

View file

@ -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);
}
} }

View file

@ -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) {
} }
} }

View file

@ -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
);
}
}