chore: rebuild directory structure

This commit is contained in:
js0ny 2025-11-28 07:53:04 +00:00
parent d3ce236672
commit dbf71443c7
91 changed files with 6683 additions and 1 deletions

View file

@ -0,0 +1,12 @@
package io.github.js0ny.ilp_coursework;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class IlpCourseworkApplication {
public static void main(String[] args) {
SpringApplication.run(IlpCourseworkApplication.class, args);
}
}

View file

@ -0,0 +1,104 @@
package io.github.js0ny.ilp_coursework.controller;
import io.github.js0ny.ilp_coursework.data.common.Angle;
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.request.DistanceRequest;
import io.github.js0ny.ilp_coursework.data.request.MovementRequest;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import io.github.js0ny.ilp_coursework.service.GpsCalculationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Main REST Controller for the ILP Coursework 1 application.
*
* <p>This class handles incoming HTTP requests for the API under {@code /api/v1} path (defined in
* CW1) This is responsible for mapping requests to the appropriate service method and returning the
* results as responses. The business logic is delegated to {@link GpsCalculationService}
*/
@RestController
@RequestMapping("/api/v1")
public class ApiController {
private final GpsCalculationService gpsService;
/**
* Constructor of the {@code ApiController} with the business logic dependency {@code
* GpsCalculationService}
*
* @param gpsService The service component that contains all business logic, injected by
* Spring's DI.
*/
public ApiController(GpsCalculationService gpsService) {
this.gpsService = gpsService;
}
/**
* Handles GET requests to retrieve the student's Unique ID
*
* @return A string representing the student ID starting with s
*/
@GetMapping("/uid")
public String getUid() {
return "s2522255";
}
/**
* Handles POST requests to get the distance between two positions
*
* @param request A {@link DistanceRequest} containing the two coordinates
* @return A {@code double} representing the calculated distance
*/
@PostMapping("/distanceTo")
public double getDistance(@RequestBody DistanceRequest request) {
LngLat position1 = request.position1();
LngLat position2 = request.position2();
return gpsService.calculateDistance(position1, position2);
}
/**
* Handles POST requests to check if the two coordinates are close to each other
*
* @param request A {@link DistanceRequest} containing the two coordinates
* @return {@code true} if the distance is less than the predefined threshold, {@code false}
* otherwise
*/
@PostMapping("/isCloseTo")
public boolean getIsCloseTo(@RequestBody DistanceRequest request) {
LngLat position1 = request.position1();
LngLat position2 = request.position2();
return gpsService.isCloseTo(position1, position2);
}
/**
* Handles POST requests to get the next position after an angle of movement
*
* @param request A {@link MovementRequest} containing the start coordinate and angle of the
* movement.
* @return A {@link LngLat} representing the destination
*/
@PostMapping("/nextPosition")
public LngLat getNextPosition(@RequestBody MovementRequest request) {
LngLat start = request.start();
Angle angle = new Angle(request.angle());
return gpsService.nextPosition(start, angle);
}
/**
* Handles POST requests to check if a point is inside a given region
*
* @param request A {@link RegionCheckRequest} containing the coordinate and the region
* @return {@code true} if the coordinate is inside the region, {@code false} otherwise
*/
@PostMapping("/isInRegion")
public boolean getIsInRegion(@RequestBody RegionCheckRequest request) {
LngLat position = request.position();
Region region = request.region();
return gpsService.checkIsInRegion(position, region);
}
}

View file

@ -0,0 +1,111 @@
package io.github.js0ny.ilp_coursework.controller;
import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
import io.github.js0ny.ilp_coursework.service.DroneAttrComparatorService;
import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import io.github.js0ny.ilp_coursework.service.PathFinderService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* Main Rest Controller for the ILP Coursework 2 application.
*
* <p>This class handles incoming HTTP requests for the API under {@code /api/v1} path (defined in
* CW2) The business logic is delegated to {@link DroneInfoService}
*/
@RestController
@RequestMapping("/api/v1")
public class DroneController {
private final DroneInfoService droneInfoService;
private final DroneAttrComparatorService droneAttrComparatorService;
private final PathFinderService pathFinderService;
/**
* Constructor of the {@code DroneController} with the business logic dependency {@code
* DroneInfoService}
*
* <p>We handle the {@code baseUrl} here. Use a predefined URL if the environment variable
* {@code ILP_ENDPOINT} is not given.
*
* @param droneService The service component that contains all business logic
*/
public DroneController(
DroneInfoService droneService,
DroneAttrComparatorService droneAttrComparatorService,
PathFinderService pathFinderService) {
this.droneInfoService = droneService;
this.droneAttrComparatorService = droneAttrComparatorService;
this.pathFinderService = pathFinderService;
}
/**
* Handles GET requests to retrieve an array of drones (identified by id) that has the
* capability of cooling
*
* @param state The path variable that indicates the return should have or not have the
* capability
* @return An array of drone id with cooling capability.
*/
@GetMapping("/dronesWithCooling/{state}")
public List<String> getDronesWithCoolingCapability(@PathVariable boolean state) {
return droneInfoService.dronesWithCooling(state);
}
/**
* Handles GET requests to retrieve the drone detail identified by id
*
* @param id The id of the drone to be queried.
* @return 200 with {@link Drone}-style json if success, 404 if {@code id} not found, 400
* otherwise
*/
@GetMapping("/droneDetails/{id}")
public ResponseEntity<Drone> getDroneDetail(@PathVariable String id) {
try {
Drone drone = droneInfoService.droneDetail(id);
return ResponseEntity.ok(drone);
} catch (IllegalArgumentException ex) {
return ResponseEntity.notFound().build();
}
}
/**
* Handles GET requests to retrieve an array of drone ids that {@code capability.attrName =
* attrVal}
*
* @param attrName The name of the attribute to be queried
* @param attrVal The value of the attribute to be queried
* @return An array of drone id that matches the attribute name and value
*/
@GetMapping("/queryAsPath/{attrName}/{attrVal}")
public List<String> getIdByAttrMap(
@PathVariable String attrName, @PathVariable String attrVal) {
return droneAttrComparatorService.dronesWithAttribute(attrName, attrVal);
}
@PostMapping("/query")
public List<String> getIdByAttrMapPost(@RequestBody AttrQueryRequest[] attrComparators) {
return droneAttrComparatorService.dronesSatisfyingAttributes(attrComparators);
}
@PostMapping("/queryAvailableDrones")
public List<String> queryAvailableDrones(@RequestBody MedDispatchRecRequest[] records) {
return droneInfoService.dronesMatchesRequirements(records);
}
@PostMapping("/calcDeliveryPath")
public DeliveryPathResponse calculateDeliveryPath(@RequestBody MedDispatchRecRequest[] record) {
return pathFinderService.calculateDeliveryPath(record);
}
@PostMapping("/calcDeliveryPathAsGeoJson")
public String calculateDeliveryPathAsGeoJson(@RequestBody MedDispatchRecRequest[] record) {
return pathFinderService.calculateDeliveryPathAsGeoJson(record);
}
}

View file

@ -0,0 +1,25 @@
package io.github.js0ny.ilp_coursework.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1")
public class GeoJsonDataController {
private final DroneInfoService droneInfoService;
public GeoJsonDataController(DroneInfoService droneInfoService) {
this.droneInfoService = droneInfoService;
}
@GetMapping("/getAllRestrictedAreaByGeoJson")
public String getAllRestrictedAreaGeoJson() throws JsonProcessingException {
return droneInfoService.fetchRestrictedAreasInGeoJson().stream().reduce("", String::concat);
}
}

View file

@ -0,0 +1,12 @@
package io.github.js0ny.ilp_coursework.data.common;
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
/**
* Represents a range of altitude values (that is a fly-zone in {@link RestrictedArea}).
*
* @param lower The lower bound of the altitude range.
* @param upper The upper bound of the altitude range. If {@code upper = -1}, then the region is not
* a fly zone.
*/
public record AltitudeRange(double lower, double upper) {}

View file

@ -0,0 +1,64 @@
package io.github.js0ny.ilp_coursework.data.common;
/**
* Represents the data transfer object for angle
*
* @param val value of the angle in degrees
*/
public record Angle(double degrees) {
private static final double STEP = 22.5;
private static final double EPSILON = 1e-10;
public Angle {
if (degrees < 0 || degrees >= 360) {
throw new IllegalArgumentException("Angle must be in range [0, 360). Got: " + degrees);
}
// Should be a multiple of 22.5 (one of the 16 major directions)
double remainder = degrees % STEP;
// Floating point modulo may have tiny errors, e.g. 45.0 % 22.5 could be 0.0 or
// 1.0e-15
// So we need to check if the remainder is small enough, or close enough to STEP
// (handling negative errors)
if (Math.abs(remainder) > EPSILON && Math.abs(remainder - STEP) > EPSILON) {
throw new IllegalArgumentException(
"Angle must be a multiple of 22.5 (one of the 16 major directions). Got: "
+ degrees);
}
}
public static Angle fromIndex(int index) {
if (index < 0 || index > 15) {
throw new IllegalArgumentException("Direction index must be between 0 and 15");
}
return new Angle(index * STEP);
}
public static Angle snap(double rawAngle) {
double normalized = normalize(rawAngle);
double snapped = Math.round(normalized / STEP) * STEP;
return new Angle(normalize(snapped));
}
public Angle offset(int increments) {
double rotated = degrees + increments * STEP;
return new Angle(normalize(rotated));
}
private static double normalize(double angle) {
double normalized = angle % 360;
if (normalized < 0) {
normalized += 360;
}
return normalized;
}
public static double toRadians(double degrees) {
return Math.toRadians(degrees);
}
public double toRadians() {
return Math.toRadians(degrees);
}
}

View file

@ -0,0 +1,20 @@
package io.github.js0ny.ilp_coursework.data.common;
import java.time.DayOfWeek;
import java.time.LocalTime;
public record DroneAvailability(String id, TimeWindow[] availability) {
public boolean checkAvailability(DayOfWeek day, LocalTime time) {
for (var a : availability) {
if (a.dayOfWeek().equals(day)) {
if (!time.isBefore(a.from()) && !time.isAfter(a.until())) {
return true;
}
}
}
return false;
}
}

View file

@ -0,0 +1,10 @@
package io.github.js0ny.ilp_coursework.data.common;
public record DroneCapability(
boolean cooling,
boolean heating,
float capacity,
int maxMoves,
float costPerMove,
float costInitial,
float costFinal) {}

View file

@ -0,0 +1,35 @@
package io.github.js0ny.ilp_coursework.data.common;
/**
* Represents the data transfer object for a point or coordinate that defines by a longitude and
* latitude
*
* @param lng longitude of the coordinate/point
* @param lat latitude of the coordinate/point
*/
public record LngLat(double lng, double lat) {
private static final double EPSILON = 1e-9;
public LngLat {
if (lat < -90 || lat > 90) {
throw new IllegalArgumentException(
"Latitude must be between -90 and +90 degrees. Got: " + lat);
}
if (lng < -180 || lng > 180) {
throw new IllegalArgumentException(
"Longitude must be between -180 and +180 degrees. Got: " + lng);
}
}
public LngLat(LngLatAlt coord) {
this(coord.lng(), coord.lat());
}
public boolean isSamePoint(LngLat other) {
if (other == null) {
return false;
}
return (Math.abs(lng - other.lng()) < EPSILON && Math.abs(lat - other.lat()) < EPSILON);
}
}

View file

@ -0,0 +1,11 @@
package io.github.js0ny.ilp_coursework.data.common;
/**
* Represents the data transfer object for a point or coordinate that defines by a longitude and
* latitude
*
* @param lng longitude of the coordinate/point
* @param lat latitude of the coordinate/point
* @param alt altitude of the coordinate/point
*/
public record LngLatAlt(double lng, double lat, double alt) {}

View file

@ -0,0 +1,75 @@
package io.github.js0ny.ilp_coursework.data.common;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Represents the data transfer object for a region definition
*
* <p>This record encapsulates the data for calculating if a coordinate is inside the region
*
* <p>A built-in method {@code isClosedTo} is defined to check this DTO is valid or not in the mean
* of closing polygon
*
* @param name The human-readable name for the region
* @param vertices list of coordinates that forms a polygon as a region.
* <p>In order to define a valid region, the last element of the list should be the same as the
* first, or known as closed
* @see RegionCheckRequest
* @see io.github.js0ny.ilp_coursework.service.GpsCalculationService#checkIsInRegion(LngLat, Region)
*/
public record Region(String name, List<LngLat> vertices) {
/**
* Magic number 4: For a polygon, 3 edges is required.
*
* <p>In this dto, edges + 1 vertices is required.
*/
private static final int MINIMUM_VERTICES = 4;
/**
* Method to check if the region has a valid polygon by checking if the {@code vertices} forms a
* closed polygon
*
* @return {@code true} if the {@code vertices} are able to form a polygon and form a closed
* polygon
*/
public boolean isClosed() {
if (vertices == null || vertices.size() < MINIMUM_VERTICES) {
return false;
}
LngLat first = vertices.getFirst();
LngLat last = vertices.getLast();
return Objects.equals(last, first);
}
public Map<String, Object> toGeoJson() {
try {
ObjectMapper mapper = new ObjectMapper();
List<List<Double>> ring =
vertices.stream().map(v -> List.of(v.lng(), v.lat())).toList();
return Map.of("type", "Polygon", "coordinates", List.of(ring));
} catch (Exception e) {
throw new RuntimeException("Failed to generate GeoJSON", e);
}
}
public String toGeoJsonString() {
try {
ObjectMapper mapper = new ObjectMapper();
var geoJson = toGeoJson();
return mapper.writeValueAsString(geoJson);
} catch (Exception e) {
throw new RuntimeException("Failed to generate GeoJSON", e);
}
}
}

View file

@ -0,0 +1,6 @@
package io.github.js0ny.ilp_coursework.data.common;
import java.time.DayOfWeek;
import java.time.LocalTime;
public record TimeWindow(DayOfWeek dayOfWeek, LocalTime from, LocalTime until) {}

View file

@ -0,0 +1,15 @@
package io.github.js0ny.ilp_coursework.data.external;
import io.github.js0ny.ilp_coursework.data.common.DroneCapability;
/** Represents the data transfer object for a drone, gained from the endpoints */
public record Drone(String name, String id, DroneCapability capability) {
public int parseId() {
try {
return Integer.parseInt(id);
} catch (NumberFormatException e) {
return id.hashCode();
}
}
}

View file

@ -0,0 +1,19 @@
package io.github.js0ny.ilp_coursework.data.external;
import io.github.js0ny.ilp_coursework.data.common.AltitudeRange;
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.Region;
import java.util.ArrayList;
import java.util.List;
public record RestrictedArea(String name, int id, AltitudeRange limits, LngLatAlt[] vertices) {
public Region toRegion() {
List<LngLat> vertices2D = new ArrayList<>();
for (var vertex : vertices) {
vertices2D.add(new LngLat(vertex.lng(), vertex.lat()));
}
return new Region(name, vertices2D);
}
}

View file

@ -0,0 +1,5 @@
package io.github.js0ny.ilp_coursework.data.external;
import io.github.js0ny.ilp_coursework.data.common.LngLatAlt;
public record ServicePoint(String name, int id, LngLatAlt location) {}

View file

@ -0,0 +1,18 @@
package io.github.js0ny.ilp_coursework.data.external;
import io.github.js0ny.ilp_coursework.data.common.DroneAvailability;
import org.springframework.lang.Nullable;
public record ServicePointDrones(int servicePointId, DroneAvailability[] drones) {
@Nullable
public DroneAvailability locateDroneById(String droneId) {
for (var drone : drones) {
if (drone.id().equals(droneId)) {
return drone;
}
}
return null;
}
}

View file

@ -0,0 +1,6 @@
package io.github.js0ny.ilp_coursework.data.request;
// TODO: Convert operator to Enum
// import io.github.js0ny.ilp_coursework.util.AttrOperator;
public record AttrQueryRequest(String attribute, String operator, String value) {}

View file

@ -0,0 +1,14 @@
package io.github.js0ny.ilp_coursework.data.request;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
/**
* Represents the data transfer object for a distance operation request.
*
* <p>This record encapsulates the data for several endpoints that involves two {@code LngLatDto}
* and serves as the data contract for those API operation
*
* @param position1 Nested object of {@link LngLat}
* @param position2 Nested object of {@link LngLat}
*/
public record DistanceRequest(LngLat position1, LngLat position2) {}

View file

@ -0,0 +1,15 @@
package io.github.js0ny.ilp_coursework.data.request;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import java.time.LocalDate;
import java.time.LocalTime;
@JsonIgnoreProperties(ignoreUnknown = true)
public record MedDispatchRecRequest(
int id, LocalDate date, LocalTime time, MedRequirement requirements, LngLat delivery) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record MedRequirement(float capacity, boolean cooling, boolean heating, float maxCost) {}
}

View file

@ -0,0 +1,16 @@
package io.github.js0ny.ilp_coursework.data.request;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
/**
* Represents the data transfer object for a movement action request.
*
* <p>This record encapsulates the data for endpoint /api/v1/nextPosition and serves as the data
* contract for this API operation
*
* @param start The starting coordinate of the movement
* @param angle The angle to movement in degree. This corresponds to compass directions. For
* example: 0 for East, 90 for North
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getNextPosition(MovementRequest)
*/
public record MovementRequest(LngLat start, double angle) {}

View file

@ -0,0 +1,18 @@
package io.github.js0ny.ilp_coursework.data.request;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.common.Region;
/**
* Represents the data transfer object for a region check request.
*
* <p>This record encapsulates the data for endpoint /api/v1/isInRegion and serves as the data
* contract for this API operation
*
* <p>
*
* @param position The coordinate to be checked
* @param region The region for the check. This is a nested object represented by {@link Region}
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getIsInRegion(RegionCheckRequest)
*/
public record RegionCheckRequest(LngLat position, Region region) {}

View file

@ -0,0 +1,11 @@
package io.github.js0ny.ilp_coursework.data.response;
import io.github.js0ny.ilp_coursework.data.common.LngLat;
import java.util.List;
public record DeliveryPathResponse(float totalCost, int totalMoves, DronePath[] dronePaths) {
public record DronePath(int droneId, List<Delivery> deliveries) {
public record Delivery(int deliveryId, List<LngLat> flightPath) {}
}
}

View file

@ -0,0 +1,52 @@
package io.github.js0ny.ilp_coursework.exception;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
import java.util.Optional;
/** Class that handles exception or failed request. Map all error requests to 400. */
@RestControllerAdvice
public class GlobalExceptionHandler {
/// Use a logger to save logs instead of passing them to user
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final Map<String, String> badRequestMap =
Map.of("status", "400", "error", "Bad Request");
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleHttpMessageNotReadable(HttpMessageNotReadableException ex) {
log.warn("Malformed JSON received: {}", ex.getMessage());
return badRequestMap;
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleIllegalArgument(IllegalArgumentException ex) {
String errorMessage =
Optional.ofNullable(ex.getMessage()).orElse("Invalid argument provided.");
log.warn("Illegal argument in request: {}", errorMessage);
return badRequestMap;
}
@ExceptionHandler(NullPointerException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleNullPointerException(Exception ex) {
log.error("NullPointerException occurred. Return 400 by default.", ex);
return badRequestMap;
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleGeneralException(Exception ex) {
log.error("Fallback exception received: {}", ex.getMessage());
return badRequestMap;
}
}

View file

@ -0,0 +1,129 @@
package io.github.js0ny.ilp_coursework.service;
import static io.github.js0ny.ilp_coursework.util.AttrComparator.isValueMatched;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest;
import io.github.js0ny.ilp_coursework.util.AttrOperator;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class DroneAttrComparatorService {
private final String baseUrl;
private final String dronesEndpoint = "drones";
private final RestTemplate restTemplate = new RestTemplate();
/** Constructor, handles the base url here. */
public DroneAttrComparatorService() {
String baseUrl = System.getenv("ILP_ENDPOINT");
if (baseUrl == null || baseUrl.isBlank()) {
this.baseUrl = "https://ilp-rest-2025-bvh6e9hschfagrgy.ukwest-01.azurewebsites.net/";
} else {
// Defensive: Add '/' to the end of the URL
if (!baseUrl.endsWith("/")) {
baseUrl += "/";
}
this.baseUrl = baseUrl;
}
}
/**
* Return an array of ids of drones with a given attribute name and value.
*
* <p>Associated service method with {@code /queryAsPath/{attrName}/{attrVal}}
*
* @param attrName the attribute name to filter on
* @param attrVal the attribute value to filter on
* @return array of drone ids matching the attribute name and value
* @see #dronesWithAttributeCompared(String, String, AttrOperator)
* @see io.github.js0ny.ilp_coursework.controller.DroneController#getIdByAttrMap
*/
public List<String> dronesWithAttribute(String attrName, String attrVal) {
// Call the helper with EQ operator
return dronesWithAttributeCompared(attrName, attrVal, AttrOperator.EQ);
}
/**
* Return an array of ids of drones which matches all given complex comparing rules
*
* @param attrComparators The filter rule with Name, Value and Operator
* @return array of drone ids that matches all rules
*/
public List<String> dronesSatisfyingAttributes(AttrQueryRequest[] attrComparators) {
Set<String> matchingDroneIds = null;
for (var comparator : attrComparators) {
String attribute = comparator.attribute();
String operator = comparator.operator();
String value = comparator.value();
AttrOperator op = AttrOperator.fromString(operator);
List<String> ids = dronesWithAttributeCompared(attribute, value, op);
if (matchingDroneIds == null) {
matchingDroneIds = new HashSet<>(ids);
} else {
matchingDroneIds.retainAll(ids);
}
}
if (matchingDroneIds == null) {
return new ArrayList<>();
}
return matchingDroneIds.stream().toList();
}
/**
* Helper that wraps the dynamic querying with different comparison operators
*
* <p>This method act as a concatenation of {@link
* io.github.js0ny.ilp_coursework.util.AttrComparator#isValueMatched(JsonNode, String,
* AttrOperator)}
*
* @param attrName the attribute name to filter on
* @param attrVal the attribute value to filter on
* @param op the comparison operator
* @return array of drone ids matching the attribute name and value (filtered by {@code op})
* @see io.github.js0ny.ilp_coursework.util.AttrComparator#isValueMatched(JsonNode, String,
* AttrOperator)
*/
private List<String> dronesWithAttributeCompared(
String attrName, String attrVal, AttrOperator op) {
URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
// This is required to make sure the response is valid
Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class);
if (drones == null) {
return new ArrayList<>();
}
// Use Jackson's ObjectMapper to convert DroneDto to JsonNode for dynamic
// querying
ObjectMapper mapper = new ObjectMapper();
return Arrays.stream(drones)
.filter(
drone -> {
JsonNode node = mapper.valueToTree(drone);
JsonNode attrNode = node.findValue(attrName);
if (attrNode != null) {
// Manually handle different types of JsonNode
return isValueMatched(attrNode, attrVal, op);
} else {
return false;
}
})
.map(Drone::id)
.collect(Collectors.toList());
}
}

View file

@ -0,0 +1,281 @@
package io.github.js0ny.ilp_coursework.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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.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 org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class DroneInfoService {
private final String baseUrl;
private final String dronesForServicePointsEndpoint = "drones-for-service-points";
public static final String servicePointsEndpoint = "service-points";
public static final String restrictedAreasEndpoint = "restricted-areas";
private final RestTemplate restTemplate;
/** Constructor, handles the base url here. */
public DroneInfoService() {
this(new RestTemplate());
}
public DroneInfoService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
String baseUrl = System.getenv("ILP_ENDPOINT");
if (baseUrl == null || baseUrl.isBlank()) {
this.baseUrl = "https://ilp-rest-2025-bvh6e9hschfagrgy.ukwest-01.azurewebsites.net/";
} else {
// Defensive: Add '/' to the end of the URL
if (!baseUrl.endsWith("/")) {
baseUrl += "/";
}
this.baseUrl = baseUrl;
}
}
/**
* Return an array of ids of drones with/without cooling capability
*
* <p>Associated service method with {@code /dronesWithCooling/{state}}
*
* @param state determines the capability filtering
* @return if {@code state} is true, return ids of drones with cooling capability, else without
* cooling
* @see
* io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean)
*/
public List<String> dronesWithCooling(boolean state) {
// URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
// Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class);
List<Drone> drones = fetchAllDrones();
if (drones == null) {
return new ArrayList<>();
}
return drones.stream()
.filter(drone -> drone.capability().cooling() == state)
.map(Drone::id)
.collect(Collectors.toList());
}
/**
* Return a {@link Drone}-style json data structure with the given {@code id}
*
* <p>Associated service method with {@code /droneDetails/{id}}
*
* @param id The id of the drone
* @return drone json body of given id
* @throws NullPointerException when cannot fetch available drones from remote
* @throws IllegalArgumentException when drone with given {@code id} cannot be found this should
* lead to a 404
* @see io.github.js0ny.ilp_coursework.controller.DroneController#getDroneDetail(String)
*/
public Drone droneDetail(String id) {
List<Drone> drones = fetchAllDrones();
for (var drone : drones) {
if (drone.id().equals(id)) {
return drone;
}
}
// This will result in 404
throw new IllegalArgumentException("drone with that ID cannot be found");
}
/**
* Return an array of ids of drones that match all the requirements in the medical dispatch
* records
*
* <p>Associated service method with
*
* @param rec array of medical dispatch records
* @return List of drone ids that match all the requirements
* @see io.github.js0ny.ilp_coursework.controller.DroneController#queryAvailableDrones
*/
public List<String> dronesMatchesRequirements(MedDispatchRecRequest[] rec) {
List<Drone> drones = fetchAllDrones();
if (rec == null || rec.length == 0) {
return drones.stream()
.filter(Objects::nonNull)
.map(Drone::id)
.collect(Collectors.toList());
}
/*
* Traverse and filter drones, pass every record's requirement to helper
*/
return drones.stream()
.filter(d -> d != null && d.capability() != null)
.filter(
d ->
Arrays.stream(rec)
.filter(r -> r != null && r.requirements() != null)
.allMatch(r -> droneMatchesRequirement(d, r)))
.map(Drone::id)
.collect(Collectors.toList());
}
/**
* Helper to check if a drone meets the requirement of a medical dispatch.
*
* @param drone the drone to be checked
* @param record the medical dispatch record containing the requirement
* @return true if the drone meets the requirement, false otherwise
* @throws IllegalArgumentException when record requirements or drone capability is invalid
* (capacity and id cannot be null in {@code MedDispathRecDto})
*/
public boolean droneMatchesRequirement(Drone drone, MedDispatchRecRequest record) {
var requirements = record.requirements();
if (requirements == null) {
throw new IllegalArgumentException("requirements cannot be null");
}
var capability = drone.capability();
if (capability == null) {
throw new IllegalArgumentException("drone capability cannot be null");
}
float requiredCapacity = requirements.capacity();
if (requiredCapacity <= 0 || capability.capacity() < requiredCapacity) {
return false;
}
// Use boolean wrapper to allow null (not specified) values
boolean requiredCooling = requirements.cooling();
boolean requiredHeating = requirements.heating();
// Case 1: required is null: We don't care about it
// Case 2: required is false: We don't care about it (high capability adapts to
// low requirements)
// Case 3: capability is true: Then always matches
// See: https://piazza.com/class/me9vp64lfgf4sn/post/100
boolean matchesCooling = !requiredCooling || capability.cooling();
boolean matchesHeating = !requiredHeating || capability.heating();
// Conditions: All requirements matched + availability matched, use helper
// For minimal privilege, only pass drone id to check availability
return (matchesCooling && matchesHeating && checkAvailability(drone.id(), record)); // &&
// checkCost(drone, record) // checkCost is more expensive than
// checkAvailability
}
/**
* Helper to check if a drone is available at the required date and time
*
* @param droneId the id of the drone to be checked
* @param record the medical dispatch record containing the required date and time
* @return true if the drone is available, false otherwise
*/
private boolean checkAvailability(String droneId, MedDispatchRecRequest record) {
URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePoints =
restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
LocalDate requiredDate = record.date();
DayOfWeek requiredDay = requiredDate.getDayOfWeek();
LocalTime requiredTime = record.time();
assert servicePoints != null;
for (var servicePoint : servicePoints) {
var drone = servicePoint.locateDroneById(droneId); // Nullable
if (drone != null) {
return drone.checkAvailability(requiredDay, requiredTime);
}
}
return false;
}
private LngLat queryServicePointLocationByDroneId(String droneId) {
URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePoints =
restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
assert servicePoints != null;
for (var sp : servicePoints) {
var drone = sp.locateDroneById(droneId); // Nullable
if (drone != null) {
return queryServicePointLocation(sp.servicePointId());
}
}
return null;
}
@Nullable
private LngLat queryServicePointLocation(int id) {
URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
ServicePoint[] servicePoints =
restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
assert servicePoints != null;
for (var sp : servicePoints) {
if (sp.id() == id) {
// We dont consider altitude
return new LngLat(sp.location());
}
}
return null;
}
public List<Drone> fetchAllDrones() {
System.out.println("fetchAllDrones called");
String dronesEndpoint = "drones";
URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
System.out.println("Fetching from URL: " + droneUrl);
Drone[] drones = restTemplate.getForObject(droneUrl, Drone[].class);
return drones == null ? new ArrayList<>() : Arrays.asList(drones);
}
public List<RestrictedArea> fetchRestrictedAreas() {
URI restrictedUrl = URI.create(baseUrl).resolve(restrictedAreasEndpoint);
RestrictedArea[] restrictedAreas =
restTemplate.getForObject(restrictedUrl, RestrictedArea[].class);
assert restrictedAreas != null;
return Arrays.asList(restrictedAreas);
}
public List<String> fetchRestrictedAreasInGeoJson() throws JsonProcessingException {
var mapper = new ObjectMapper();
var ras = fetchRestrictedAreas();
var geoJson = ras.stream().map(RestrictedArea::toRegion).map(Region::toGeoJson).toList();
return Collections.singletonList(mapper.writeValueAsString(geoJson));
}
public List<ServicePoint> fetchServicePoints() {
URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
ServicePoint[] servicePoints =
restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
assert servicePoints != null;
return Arrays.asList(servicePoints);
}
public List<ServicePointDrones> fetchDronesForServicePoints() {
URI servicePointDronesUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePointDrones =
restTemplate.getForObject(servicePointDronesUrl, ServicePointDrones[].class);
assert servicePointDrones != null;
return Arrays.asList(servicePointDrones);
}
}

View file

@ -0,0 +1,189 @@
package io.github.js0ny.ilp_coursework.service;
import io.github.js0ny.ilp_coursework.data.common.Angle;
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.request.DistanceRequest;
import io.github.js0ny.ilp_coursework.data.request.MovementRequest;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Class that handles calculations about Coordinates
*
* @see LngLat
* @see Region
*/
@Service
public class GpsCalculationService {
/**
* Given step size
*
* @see #nextPosition(LngLat, double)
*/
private static final double STEP = 0.00015;
/**
* Given threshold to judge if two points are close to each other
*
* @see #isCloseTo(LngLat, LngLat)
*/
private static final double CLOSE_THRESHOLD = 0.00015;
/**
* Calculate the Euclidean distance between {@code position1} and {@code position2}, which are
* coordinates defined as {@link LngLat}
*
* @param position1 The coordinate of the first position
* @param position2 The coordinate of the second position
* @return The Euclidean distance between {@code position1} and {@code position2}
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getDistance(DistanceRequest)
*/
public double calculateDistance(LngLat position1, LngLat position2) {
double lngDistance = position2.lng() - position1.lng();
double latDistance = position2.lat() - position1.lat();
// Euclidean: \sqrt{a^2 + b^2}
return Math.sqrt(lngDistance * lngDistance + latDistance * latDistance);
}
public double calculateSteps(LngLat position1, LngLat position2) {
double distance = calculateDistance(position1, position2);
return distance / STEP;
}
/**
* Check if {@code position1} and {@code position2} are close to each other, the threshold is <
* 0.00015
*
* <p>Note that = 0.00015 will be counted as not close to and will return {@code false}
*
* @param position1 The coordinate of the first position
* @param position2 The coordinate of the second position
* @return {@code true} if {@code position1} and {@code position2} are close to each other
* @see #CLOSE_THRESHOLD
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getIsCloseTo(DistanceRequest)
*/
public boolean isCloseTo(LngLat position1, LngLat position2) {
double distance = calculateDistance(position1, position2);
return distance < CLOSE_THRESHOLD;
}
/**
* Returns the next position moved from {@code start} in the direction with {@code angle}, with
* step size 0.00015
*
* @param start The coordinate of the original start point.
* @param angle The direction to be moved in angle.
* @return The next position moved from {@code start}
* @see #STEP
* @see io.github.js0ny.ilp_coursework.controller.ApiController#getNextPosition(MovementRequest)
*/
public LngLat nextPosition(LngLat start, Angle angle) {
double rad = angle.toRadians();
double newLng = Math.cos(rad) * STEP + start.lng();
double newLat = Math.sin(rad) * STEP + start.lat();
return new LngLat(newLng, newLat);
}
/**
* Used to check if the given {@code position} is inside the {@code region}, on edge and vertex
* is considered as inside.
*
* @param position The coordinate of the position.
* @param region A {@link Region} that contains name and a list of {@code LngLatDto}
* @return {@code true} if {@code position} is inside the {@code region}.
* @throws IllegalArgumentException If {@code region} is not closed
* @see
* io.github.js0ny.ilp_coursework.controller.ApiController#getIsInRegion(RegionCheckRequest)
* @see Region#isClosed()
*/
public boolean checkIsInRegion(LngLat position, Region region) throws IllegalArgumentException {
if (!region.isClosed()) {
// call method from RegionDto to check if not closed
throw new IllegalArgumentException("Region is not closed.");
}
return rayCasting(position, region.vertices());
}
/**
* Helper function to {@code checkIsInRegion}, use of ray-casting algorithm to check if inside
* the polygon
*
* @param point The point to check
* @param polygon The region that forms a polygon to check if {@code point} sits inside.
* @return If the {@code point} sits inside the {@code polygon} then return {@code true}
* @see #isPointOnEdge(LngLat, LngLat, LngLat)
* @see #checkIsInRegion(LngLat, Region)
*/
private boolean rayCasting(LngLat point, List<LngLat> polygon) {
int intersections = 0;
int n = polygon.size();
for (int i = 0; i < n; ++i) {
LngLat a = polygon.get(i);
LngLat b = polygon.get((i + 1) % n); // Next vertex
if (isPointOnEdge(point, a, b)) {
return true;
}
// Ensure that `a` is norther than `b`, in order to easy classification
if (a.lat() > b.lat()) {
LngLat temp = a;
a = b;
b = temp;
}
// The point is not between a and b in latitude mean, skip this loop
if (point.lat() < a.lat() || point.lat() >= b.lat()) {
continue;
}
// Skip the case of horizontal edge, already handled in `isPointOnEdge`:w
if (a.lat() == b.lat()) {
continue;
}
double xIntersection =
a.lng() + ((point.lat() - a.lat()) * (b.lng() - a.lng())) / (b.lat() - a.lat());
if (xIntersection > point.lng()) {
++intersections;
}
}
// If intersections are odd, ray-casting returns true, which the point sits
// inside the polygon;
// If intersections are even, the point does not sit inside the polygon.
return intersections % 2 == 1;
}
/**
* Helper function from {@code rayCasting} that used to simply calculation <br>
* Used to check if point {@code p} is on the edge formed by {@code a} and {@code b}
*
* @param p point to be checked on the edge
* @param a point that forms the edge
* @param b point that forms the edge
* @return {@code true} if {@code p} is on {@code ab}
* @see #rayCasting(LngLat, List)
*/
private boolean isPointOnEdge(LngLat p, LngLat a, LngLat b) {
// Cross product: (p - a) × (b - a)
double crossProduct =
(p.lng() - a.lng()) * (b.lat() - a.lat())
- (p.lat() - a.lat()) * (b.lng() - a.lng());
if (Math.abs(crossProduct) > 1e-9) {
return false;
}
boolean isWithinLng =
p.lng() >= Math.min(a.lng(), b.lng()) && p.lng() <= Math.max(a.lng(), b.lng());
boolean isWithinLat =
p.lat() >= Math.min(a.lat(), b.lat()) && p.lat() <= Math.max(a.lat(), b.lat());
return isWithinLng && isWithinLat;
}
}

View file

@ -0,0 +1,531 @@
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.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.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.response.DeliveryPathResponse;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath.Delivery;
import org.springframework.stereotype.Service;
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;
/**
* Class that handles calculations about deliverypath
*
* @see DroneInfoService
* @see DroneController
* @see DeliveryPathResponse
*/
@Service
public class PathFinderService {
/**
* 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;
// Services
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) {
continue;
}
droneServicePointMap.put(availability.id(), assignment.servicePointId());
}
}
this.servicePointLocations =
servicePoints.stream()
.collect(
Collectors.toMap(
ServicePoint::id, sp -> new LngLat(sp.location())));
this.restrictedRegions = restrictedAreas.stream().map(RestrictedArea::toRegion).toList();
}
/**
* Produce a delivery plan for the provided dispatch records. Deliveries are grouped per
* compatible drone and per trip to satisfy each drone move limit.
*
* @param records Dispatch records to be fulfilled.
* @return Aggregated path response with cost and move totals.
* @see #calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[])
*/
public DeliveryPathResponse calculateDeliveryPath(MedDispatchRecRequest[] records) {
if (records == null || records.length == 0) {
return new DeliveryPathResponse(0f, 0, new DronePath[0]);
}
for (var r : records) {
if (isRestricted(r.delivery())) {
throw new IllegalStateException(
"Delivery "
+ r.id()
+ " is located within a restricted area and cannot be fulfilled");
}
}
Map<String, List<MedDispatchRecRequest>> assigned = assignDeliveries(records);
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;
}
List<MedDispatchRecRequest> sortedDeliveries =
entry.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;
}
double distance =
gpsCalculationService.calculateDistance(
servicePointLocation, record.delivery());
if (distance < bestScore) {
bestScore = distance;
bestDrone = droneId;
}
}
if (bestDrone == null) {
throw new IllegalStateException("No available drone for delivery " + record.id());
}
return bestDrone;
}
/**
* Break a sequence of deliveries into several trips that each respect the drone move limit. The
* deliveries should already be ordered by proximity for sensible grouping.
*
* @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");
}
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;
}
/**
* Build a single trip for the provided drone, including the entire flight path to every
* delivery and back home. The resulting structure contains the {@link DronePath} representation
* as well as cost and moves consumed.
*
* @param drone Drone executing the trip.
* @param servicePoint Starting/ending location of the trip.
* @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 false;
}
/**
* Representation of a computed path segment wrapping the visited positions and the number of
* moves taken to traverse them.
*
* @param positions Ordered coordinates that describe the path.
* @param moves Number of moves consumed by the path.
*/
private record PathSegment(List<LngLat> positions, int moves) {
/**
* Append the positions from this segment to {@code target}, skipping the 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,63 @@
package io.github.js0ny.ilp_coursework.util;
import com.fasterxml.jackson.databind.JsonNode;
import java.math.BigDecimal;
/**
* Comparator for attribute values in {@code JsonNode}.
*
* <p>This is a helper for dynamic querying.
*/
public class AttrComparator {
/**
* Helper for dynamic querying, to compare the json value with given value in {@code String}.
*
* @param node The {@code JsonNode} to be compared
* @param attrVal The Value passed, in {@code String}
* @param op The comparison operator
* @return {@code true} if given values are equal, otherwise false.
*/
public static boolean isValueMatched(JsonNode node, String attrVal, AttrOperator op) {
if (node.isTextual()) {
return compareStrings(node.asText(), attrVal, op);
} else if (node.isNumber()) {
// return Double.compare(node.asDouble(), Double.parseDouble(attrVal)) == 0;
return compareNumbers(node.decimalValue(), new BigDecimal(attrVal), op);
} else if (node.isBoolean()) {
return compareBooleans(node.asBoolean(), Boolean.parseBoolean(attrVal), op);
} else {
return false;
}
}
private static boolean compareNumbers(BigDecimal nodeVal, BigDecimal attrVal, AttrOperator op) {
int comparison = nodeVal.compareTo(attrVal);
return switch (op) {
case EQ -> comparison == 0;
case GT -> comparison > 0;
case LT -> comparison < 0;
case NE -> comparison != 0;
};
}
private static boolean compareStrings(String nodeVal, String attrVal, AttrOperator op) {
return switch (op) {
case EQ -> nodeVal.equals(attrVal);
default -> !nodeVal.equals(attrVal);
// case NE -> !nodeVal.equals(attrVal);
// case GT -> !nodeVal.equals(attrVal);// > 0;
// case LT -> !nodeVal.equals(attrVal);// < 0;
};
}
private static boolean compareBooleans(boolean nodeVal, boolean attrVal, AttrOperator op) {
return switch (op) {
case EQ -> nodeVal == attrVal;
default -> nodeVal != attrVal;
// case NE -> nodeVal != attrVal;
// case GT -> !nodeVal && attrVal; // false < true
// case LT -> nodeVal && !attrVal; // true > false
};
}
}

View file

@ -0,0 +1,23 @@
package io.github.js0ny.ilp_coursework.util;
public enum AttrOperator {
EQ("="),
NE("!="),
GT(">"),
LT("<");
private final String symbol;
AttrOperator(String symbol) {
this.symbol = symbol;
}
public static AttrOperator fromString(String symbol) {
for (AttrOperator op : AttrOperator.values()) {
if (op.symbol.equals(symbol)) {
return op;
}
}
throw new IllegalArgumentException("Unknown operator: " + symbol);
}
}

View file

@ -0,0 +1 @@
spring.application.name=ilp-coursework