Compare commits

..

No commits in common. "f013955bc2358451a5202300b908868aaf31e726" and "15ad7a2fb736e23dfb1e71b32d1629a39eb5bb5c" have entirely different histories.

21 changed files with 209 additions and 882 deletions

View file

@ -1,54 +0,0 @@
#!/usr/bin/env bash
set -e
echo "[INFO] Starting Go Backend..."
cd drone-black-box && go build -o app main.go
cd ..
./drone-black-box/app &
GO_PID=$!
echo " Go PID: $GO_PID"
echo "[INFO] Starting Java Backend..."
cd ilp-rest-service
./gradlew bootRun &
JAVA_PID=$!
echo " Java PID: $JAVA_PID"
cd ..
cleanup() {
echo "[INFO] Stopping services..."
kill $GO_PID || true
kill $JAVA_PID || true
}
trap cleanup EXIT
echo "[INFO] Waiting for services to be ready..."
for i in {1..30}; do
if curl -s http://localhost:8080/actuator/health > /dev/null; then
echo "[INFO] Java is UP!"
break
fi
echo "[DEBUG] Waiting for Java..."
sleep 2
done
echo "[INFO] Running Bruno E2E Collection..."
cd ./ilp-rest-service/ilp-cw-api
bru run
echo "[INFO] E2E Tests Passed!"
rm --force drone_black_box.db
rm --force drone_black_box.db-wal
rm --force drone_black_box.db-shm
cleanup

View file

@ -1,32 +0,0 @@
name: Polyglot CI
on:
push:
branches: [ "main" ]
paths:
- '**.java'
- '**.go'
- '.justfile'
- '**.js'
- '**.svelte'
- '**.css'
pull_request:
branches: [ "main" ]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v25
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v14
with:
name: mycache
- run: chmod +x ./ilp-rest-service/gradlew
- name: Unit Testing and Integration Testing
run: nix develop .#ci --command just all
- name: E2E Test
run: nix develop .#ci --command just e2e

6
.gitignore vendored
View file

@ -47,9 +47,3 @@ drone-black-box/drone-black-box
drone-black-box/drone_black_box.db* drone-black-box/drone_black_box.db*
*.pdf *.pdf
*.out
*.html
drone_black_box.db*
app

View file

@ -1,29 +0,0 @@
all:
@just format
@just static-analysis
@just test
format:
cd ./drone-black-box && go fmt
fd --extension java --exec google-java-format --replace --aosp {}
static-analysis:
cd ./drone-black-box && go vet ./...
test:
cd ./drone-black-box && go test -v ./... -coverprofile=coverage.out
cd ./drone-black-box && go tool cover -html=coverage.out -o coverage.html
rm ./drone-black-box/coverage.out
# Java
cd ./ilp-rest-service/ && ./gradlew check
e2e:
bash .bin/e2e.sh
benchmark:
echo "Make sure the server is running at http://localhost:3000"
oha -z 10s -c 50 -m POST \
-H "Content-Type: application/json" \
-d '{"drone_id": "TEST-DRONE", "latitude": 0.0, "longitude": 0.0, "timestamp": "2025-01-01"}' \
http://localhost:3000/ingest

View file

@ -1,9 +1,5 @@
# Informatics Large Practical Coursework 3 # Informatics Large Practical Coursework 3
This is a temporary repository, if you are viewing on GitHub, just ignore the (randomly generated) repository name.
[![Polyglot CI](https://github.com/js0ny/expert-goggles/actions/workflows/ci.yml/badge.svg)](https://github.com/js0ny/expert-goggles/actions/workflows/ci.yml)
## Installation ## Installation
### Docker Compose ### Docker Compose

View file

@ -125,7 +125,6 @@ func TestIngestBadJSON(t *testing.T) {
// Ensure graceful shutdown path does not hang: start a server and shut it down quickly. // Ensure graceful shutdown path does not hang: start a server and shut it down quickly.
func TestGracefulShutdown(t *testing.T) { func TestGracefulShutdown(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close()
srv := &Server{db: db} srv := &Server{db: db}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /health", srv.healthHandler) mux.HandleFunc("GET /health", srv.healthHandler)
@ -139,75 +138,3 @@ func TestGracefulShutdown(t *testing.T) {
t.Fatalf("shutdown: %v", err) t.Fatalf("shutdown: %v", err)
} }
} }
func TestCORSAllowedOriginAndPreflight(t *testing.T) {
handled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled = true
w.WriteHeader(http.StatusOK)
})
handler := corsMiddleware(next)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.Header.Set("Origin", "http://localhost:5173")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
t.Fatalf("expected allow-origin header, got %q", got)
}
if !handled {
t.Fatalf("expected handler to be called")
}
preflight := httptest.NewRequest(http.MethodOptions, "/health", nil)
preflight.Header.Set("Origin", "http://localhost:5173")
preflightRec := httptest.NewRecorder()
handler.ServeHTTP(preflightRec, preflight)
if preflightRec.Code != http.StatusNoContent {
t.Fatalf("preflight expected 204, got %d", preflightRec.Code)
}
}
func TestIngestDBError(t *testing.T) {
db := newTestDB(t)
db.Close()
srv := &Server{db: db}
ev := DroneEvent{DroneID: "d1", Latitude: 1.0, Longitude: 2.0, Timestamp: "2025-12-06T00:00:00Z"}
body, _ := json.Marshal(ev)
req := httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body))
rec := httptest.NewRecorder()
srv.ingestHandler(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
}
func TestSnapshotQueryError(t *testing.T) {
db := newTestDB(t)
db.Close()
srv := &Server{db: db}
req := httptest.NewRequest(http.MethodGet, "/snapshot?time=2025-12-06T00:00:00Z", nil)
rec := httptest.NewRecorder()
srv.snapshotHandler(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
}
func TestHealthFailure(t *testing.T) {
db := newTestDB(t)
db.Close()
srv := &Server{db: db}
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
srv.healthHandler(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", rec.Code)
}
}

View file

@ -17,42 +17,29 @@
pkgs = import nixpkgs { pkgs = import nixpkgs {
inherit system; inherit system;
}; };
ciDeps = with pkgs; [
jdk21
gradle
google-java-format
go
just
fd
bruno-cli
];
devDeps = with pkgs; [
vscode-langservers-extracted
jdt-language-server
jless
httpie
docker
docker-compose
newman
gron
fx
oha
gopls
bun
svelte-language-server
typescript-language-server
prettier
];
in { in {
default = pkgs.mkShell { default = pkgs.mkShell {
buildInputs = ciDeps ++ devDeps; buildInputs = with pkgs; [
shellHook = '' vscode-langservers-extracted
export JAVA_HOME=${pkgs.jdk21} jdt-language-server
echo "Java: $(java --version | head -n 1)" jless
''; jdk21
}; gradle
ci = pkgs.mkShell { httpie
buildInputs = ciDeps; docker
docker-compose
newman
gron
fx
google-java-format
oha
gopls
go
bun
svelte-language-server
typescript-language-server
prettier
];
shellHook = '' shellHook = ''
export JAVA_HOME=${pkgs.jdk21} export JAVA_HOME=${pkgs.jdk21}
echo "Java: $(java --version | head -n 1)" echo "Java: $(java --version | head -n 1)"

View file

@ -2,7 +2,6 @@ plugins {
id 'java' id 'java'
id 'org.springframework.boot' version '3.5.6' id 'org.springframework.boot' version '3.5.6'
id 'io.spring.dependency-management' version '1.1.7' id 'io.spring.dependency-management' version '1.1.7'
id 'jacoco'
} }
group = 'io.github.js0ny' group = 'io.github.js0ny'
@ -27,58 +26,6 @@ dependencies {
} }
jacoco {
toolVersion = "0.8.12"
}
tasks.named('test') { tasks.named('test') {
useJUnitPlatform() useJUnitPlatform()
finalizedBy jacocoTestReport
} }
jacocoTestReport {
dependsOn test
reports {
xml.required = true
html.required = true
}
afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
'**/IlpCourseworkApplication.class',
'**/config/*',
'**/data/*',
'**/util/*',
'**/TelemetryService.class'
])
}))
}
}
jacocoTestCoverageVerification {
violationRules {
rule {
element = 'CLASS'
excludes = [
'io.github.js0ny.ilp_coursework.IlpCourseworkApplication',
'**.config.**',
'**.data.**',
'**.util.**',
'io.github.js0ny.ilp_coursework.service.TelemetryService'
]
limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.50
}
}
}
}
check.dependsOn jacocoTestCoverageVerification

View file

@ -14,7 +14,7 @@ body:json {
[ [
{ {
"id": 123, "id": 123,
"date": "2025-12-25", "date": "2025-12-22",
"time": "14:30", "time": "14:30",
"requirements": { "requirements": {
"capacity": 0.75, "capacity": 0.75,
@ -28,7 +28,7 @@ body:json {
}, },
{ {
"id": 456, "id": 456,
"date": "2025-12-23", "date": "2025-12-25",
"time": "11:30", "time": "11:30",
"requirements": { "requirements": {
"capacity": 0.75, "capacity": 0.75,

View file

@ -4,17 +4,18 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/** Global CORS configuration so the frontend running on a different port can call the REST API. */ /**
* Global CORS configuration so the frontend running on a different port can call the REST API.
*/
@Configuration @Configuration
public class CorsConfig implements WebMvcConfigurer { public class CorsConfig implements WebMvcConfigurer {
private static final String[] ALLOWED_ORIGINS = private static final String[] ALLOWED_ORIGINS = new String[] {
new String[] { "http://localhost:4173",
"http://localhost:4173", "http://127.0.0.1:4173",
"http://127.0.0.1:4173", "http://localhost:5173",
"http://localhost:5173", "http://127.0.0.1:5173"
"http://127.0.0.1:5173" };
};
@Override @Override
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {

View file

@ -8,8 +8,6 @@ import io.github.js0ny.ilp_coursework.data.request.MovementRequest;
import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest; import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest;
import io.github.js0ny.ilp_coursework.service.GpsCalculationService; import io.github.js0ny.ilp_coursework.service.GpsCalculationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@ -27,8 +25,6 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
public class ApiController { public class ApiController {
private static final Logger log = LoggerFactory.getLogger(ApiController.class);
private final GpsCalculationService gpsService; private final GpsCalculationService gpsService;
/** /**
@ -49,7 +45,6 @@ public class ApiController {
*/ */
@GetMapping("/uid") @GetMapping("/uid")
public String getUid() { public String getUid() {
log.info("GET /api/v1/uid");
return "s2522255"; return "s2522255";
} }
@ -63,7 +58,6 @@ public class ApiController {
public double getDistance(@RequestBody DistanceRequest request) { public double getDistance(@RequestBody DistanceRequest request) {
LngLat position1 = request.position1(); LngLat position1 = request.position1();
LngLat position2 = request.position2(); LngLat position2 = request.position2();
log.info("POST /api/v1/distanceTo position1={} position2={}", position1, position2);
return gpsService.calculateDistance(position1, position2); return gpsService.calculateDistance(position1, position2);
} }
@ -78,7 +72,6 @@ public class ApiController {
public boolean getIsCloseTo(@RequestBody DistanceRequest request) { public boolean getIsCloseTo(@RequestBody DistanceRequest request) {
LngLat position1 = request.position1(); LngLat position1 = request.position1();
LngLat position2 = request.position2(); LngLat position2 = request.position2();
log.info("POST /api/v1/isCloseTo position1={} position2={}", position1, position2);
return gpsService.isCloseTo(position1, position2); return gpsService.isCloseTo(position1, position2);
} }
@ -93,7 +86,6 @@ public class ApiController {
public LngLat getNextPosition(@RequestBody MovementRequest request) { public LngLat getNextPosition(@RequestBody MovementRequest request) {
LngLat start = request.start(); LngLat start = request.start();
Angle angle = new Angle(request.angle()); Angle angle = new Angle(request.angle());
log.info("POST /api/v1/nextPosition start={} angle={}", start, angle);
return gpsService.nextPosition(start, angle); return gpsService.nextPosition(start, angle);
} }
@ -107,7 +99,6 @@ public class ApiController {
public boolean getIsInRegion(@RequestBody RegionCheckRequest request) { public boolean getIsInRegion(@RequestBody RegionCheckRequest request) {
LngLat position = request.position(); LngLat position = request.position();
Region region = request.region(); Region region = request.region();
log.info("POST /api/v1/isInRegion position={} region={}", position, region);
return gpsService.checkIsInRegion(position, region); return gpsService.checkIsInRegion(position, region);
} }
} }

View file

@ -8,8 +8,6 @@ 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 io.github.js0ny.ilp_coursework.service.PathFinderService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -25,8 +23,6 @@ import java.util.List;
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
public class DroneController { public class DroneController {
private static final Logger log = LoggerFactory.getLogger(DroneController.class);
private final DroneInfoService droneInfoService; private final DroneInfoService droneInfoService;
private final DroneAttrComparatorService droneAttrComparatorService; private final DroneAttrComparatorService droneAttrComparatorService;
private final PathFinderService pathFinderService; private final PathFinderService pathFinderService;
@ -59,7 +55,6 @@ public class DroneController {
*/ */
@GetMapping("/dronesWithCooling/{state}") @GetMapping("/dronesWithCooling/{state}")
public List<String> getDronesWithCoolingCapability(@PathVariable boolean state) { public List<String> getDronesWithCoolingCapability(@PathVariable boolean state) {
log.info("GET /api/v1/dronesWithCooling/{}", state);
return droneInfoService.dronesWithCooling(state); return droneInfoService.dronesWithCooling(state);
} }
@ -73,11 +68,9 @@ public class DroneController {
@GetMapping("/droneDetails/{id}") @GetMapping("/droneDetails/{id}")
public ResponseEntity<Drone> getDroneDetail(@PathVariable String id) { public ResponseEntity<Drone> getDroneDetail(@PathVariable String id) {
try { try {
log.info("GET /api/v1/droneDetails/{}", id);
Drone drone = droneInfoService.droneDetail(id); Drone drone = droneInfoService.droneDetail(id);
return ResponseEntity.ok(drone); return ResponseEntity.ok(drone);
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
log.warn("GET /api/v1/droneDetails/{} not found", id);
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
} }
@ -93,35 +86,26 @@ public class DroneController {
@GetMapping("/queryAsPath/{attrName}/{attrVal}") @GetMapping("/queryAsPath/{attrName}/{attrVal}")
public List<String> getIdByAttrMap( public List<String> getIdByAttrMap(
@PathVariable String attrName, @PathVariable String attrVal) { @PathVariable String attrName, @PathVariable String attrVal) {
log.info("GET /api/v1/queryAsPath/{}/{}", attrName, attrVal);
return droneAttrComparatorService.dronesWithAttribute(attrName, attrVal); return droneAttrComparatorService.dronesWithAttribute(attrName, attrVal);
} }
@PostMapping("/query") @PostMapping("/query")
public List<String> getIdByAttrMapPost(@RequestBody AttrQueryRequest[] attrComparators) { public List<String> getIdByAttrMapPost(@RequestBody AttrQueryRequest[] attrComparators) {
int count = attrComparators == null ? 0 : attrComparators.length;
log.info("POST /api/v1/query comparators={}", count);
return droneAttrComparatorService.dronesSatisfyingAttributes(attrComparators); return droneAttrComparatorService.dronesSatisfyingAttributes(attrComparators);
} }
@PostMapping("/queryAvailableDrones") @PostMapping("/queryAvailableDrones")
public List<String> queryAvailableDrones(@RequestBody MedDispatchRecRequest[] records) { public List<String> queryAvailableDrones(@RequestBody MedDispatchRecRequest[] records) {
int count = records == null ? 0 : records.length;
log.info("POST /api/v1/queryAvailableDrones records={}", count);
return droneInfoService.dronesMatchesRequirements(records); return droneInfoService.dronesMatchesRequirements(records);
} }
@PostMapping("/calcDeliveryPath") @PostMapping("/calcDeliveryPath")
public DeliveryPathResponse calculateDeliveryPath(@RequestBody MedDispatchRecRequest[] record) { public DeliveryPathResponse calculateDeliveryPath(@RequestBody MedDispatchRecRequest[] record) {
int count = record == null ? 0 : record.length;
log.info("POST /api/v1/calcDeliveryPath records={}", count);
return pathFinderService.calculateDeliveryPath(record); return pathFinderService.calculateDeliveryPath(record);
} }
@PostMapping("/calcDeliveryPathAsGeoJson") @PostMapping("/calcDeliveryPathAsGeoJson")
public String calculateDeliveryPathAsGeoJson(@RequestBody MedDispatchRecRequest[] record) { public String calculateDeliveryPathAsGeoJson(@RequestBody MedDispatchRecRequest[] record) {
int count = record == null ? 0 : record.length;
log.info("POST /api/v1/calcDeliveryPathAsGeoJson records={}", count);
return pathFinderService.calculateDeliveryPathAsGeoJson(record); return pathFinderService.calculateDeliveryPathAsGeoJson(record);
} }
} }

View file

@ -1,23 +1,19 @@
package io.github.js0ny.ilp_coursework.controller; package io.github.js0ny.ilp_coursework.controller;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
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.ServicePoint;
import io.github.js0ny.ilp_coursework.service.DroneInfoService; import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 @RestController
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
public class MapMetaController { public class MapMetaController {
private static final Logger log = LoggerFactory.getLogger(MapMetaController.class);
private final DroneInfoService droneInfoService; private final DroneInfoService droneInfoService;
public MapMetaController(DroneInfoService droneInfoService) { public MapMetaController(DroneInfoService droneInfoService) {
@ -26,13 +22,11 @@ public class MapMetaController {
@GetMapping("/restrictedAreas") @GetMapping("/restrictedAreas")
public List<RestrictedArea> getRestrictedAreas() { public List<RestrictedArea> getRestrictedAreas() {
log.info("GET /api/v1/restrictedAreas");
return droneInfoService.fetchRestrictedAreas(); return droneInfoService.fetchRestrictedAreas();
} }
@GetMapping("/servicePoints") @GetMapping("/servicePoints")
public List<ServicePoint> getServicePoints() { public List<ServicePoint> getServicePoints() {
log.info("GET /api/v1/servicePoints");
return droneInfoService.fetchServicePoints(); return droneInfoService.fetchServicePoints();
} }
} }

View file

@ -1,11 +1,11 @@
package io.github.js0ny.ilp_coursework.data.common; package io.github.js0ny.ilp_coursework.data.common;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
// Corresponding in Go // Corresponding in Go
// //
@ -18,10 +18,13 @@ import java.util.Map;
* } * }
*/ */
public record DroneEvent(String droneId, double latitude, double longitude, String timestamp) { public record DroneEvent(
String droneId,
static final int STEP = 1; // seconds between events double latitude,
double longitude,
String timestamp) {
final static int STEP = 1; // seconds between events
// Helper method that converts from DeliveryPathResponse to List<DroneEvent> // Helper method that converts from DeliveryPathResponse to List<DroneEvent>
public static List<DroneEvent> fromPathResponse(DeliveryPathResponse resp) { public static List<DroneEvent> fromPathResponse(DeliveryPathResponse resp) {
@ -31,7 +34,11 @@ public record DroneEvent(String droneId, double latitude, double longitude, Stri
for (var d : p.deliveries()) { for (var d : p.deliveries()) {
for (var coord : d.flightPath()) { for (var coord : d.flightPath()) {
String timestamp = java.time.Instant.now().toString(); String timestamp = java.time.Instant.now().toString();
events.add(new DroneEvent(id, coord.lat(), coord.lng(), timestamp)); events.add(new DroneEvent(
id,
coord.lat(),
coord.lng(),
timestamp));
} }
} }
} }
@ -40,15 +47,19 @@ public record DroneEvent(String droneId, double latitude, double longitude, Stri
// Helper method that converts from DeliveryPathResponse to List<DroneEvent> // Helper method that converts from DeliveryPathResponse to List<DroneEvent>
// with base timestamp // with base timestamp
public static List<DroneEvent> fromPathResponseWithTimestamp( public static List<DroneEvent> fromPathResponseWithTimestamp(DeliveryPathResponse resp,
DeliveryPathResponse resp, LocalDateTime baseTimestamp) { LocalDateTime baseTimestamp) {
List<DroneEvent> events = new java.util.ArrayList<>(); List<DroneEvent> events = new java.util.ArrayList<>();
java.time.LocalDateTime timestamp = baseTimestamp; java.time.LocalDateTime timestamp = baseTimestamp;
for (var p : resp.dronePaths()) { for (var p : resp.dronePaths()) {
String id = String.valueOf(p.droneId()); String id = String.valueOf(p.droneId());
for (var d : p.deliveries()) { for (var d : p.deliveries()) {
for (var coord : d.flightPath()) { for (var coord : d.flightPath()) {
events.add(new DroneEvent(id, coord.lat(), coord.lng(), timestamp.toString())); events.add(new DroneEvent(
id,
coord.lat(),
coord.lng(),
timestamp.toString()));
timestamp = timestamp.plusSeconds(STEP); // Increment timestamp for each event timestamp = timestamp.plusSeconds(STEP); // Increment timestamp for each event
} }
} }
@ -64,11 +75,14 @@ public record DroneEvent(String droneId, double latitude, double longitude, Stri
for (var d : p.deliveries()) { for (var d : p.deliveries()) {
LocalDateTime timestamp = deliveryTimestamps.get(d.deliveryId()); LocalDateTime timestamp = deliveryTimestamps.get(d.deliveryId());
// Fallback to current time if the delivery does not carry a timestamp. // Fallback to current time if the delivery does not carry a timestamp.
System.out.println( System.out.println("Generated event for drone " + id + " at " + timestamp.toString());
"Generated event for drone " + id + " at " + timestamp.toString());
LocalDateTime current = timestamp != null ? timestamp : LocalDateTime.now(); LocalDateTime current = timestamp != null ? timestamp : LocalDateTime.now();
for (var coord : d.flightPath()) { for (var coord : d.flightPath()) {
events.add(new DroneEvent(id, coord.lat(), coord.lng(), current.toString())); events.add(new DroneEvent(
id,
coord.lat(),
coord.lng(),
current.toString()));
current = current.plusSeconds(STEP); current = current.plusSeconds(STEP);
} }
} }

View file

@ -1,6 +1,10 @@
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.ObjectMapper;
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.external.Drone; 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.ServicePoint;
@ -50,13 +54,15 @@ public class DroneInfoService {
/** /**
* Return an array of ids of drones with/without cooling capability * Return an array of ids of drones with/without cooling capability
* *
* <p>Associated service method with {@code /dronesWithCooling/{state}} * <p>
* Associated service method with {@code /dronesWithCooling/{state}}
* *
* @param state determines the capability filtering * @param state determines the capability filtering
* @return if {@code state} is true, return ids of drones with cooling capability, else without * @return if {@code state} is true, return ids of drones with cooling
* cooling * capability, else without
* cooling
* @see * @see
* io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean) * io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean)
*/ */
public List<String> dronesWithCooling(boolean state) { public List<String> dronesWithCooling(boolean state) {
// URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint); // URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
@ -76,13 +82,16 @@ public class DroneInfoService {
/** /**
* Return a {@link Drone}-style json data structure with the given {@code id} * Return a {@link Drone}-style json data structure with the given {@code id}
* *
* <p>Associated service method with {@code /droneDetails/{id}} * <p>
* Associated service method with {@code /droneDetails/{id}}
* *
* @param id The id of the drone * @param id The id of the drone
* @return drone json body of given id * @return drone json body of given id
* @throws NullPointerException when cannot fetch available drones from remote * @throws NullPointerException when cannot fetch available drones from
* @throws IllegalArgumentException when drone with given {@code id} cannot be found this should * remote
* lead to a 404 * @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) * @see io.github.js0ny.ilp_coursework.controller.DroneController#getDroneDetail(String)
*/ */
public Drone droneDetail(String id) { public Drone droneDetail(String id) {
@ -99,10 +108,12 @@ public class DroneInfoService {
} }
/** /**
* Return an array of ids of drones that match all the requirements in the medical dispatch * Return an array of ids of drones that match all the requirements in the
* medical dispatch
* records * records
* *
* <p>Associated service method with * <p>
* Associated service method with
* *
* @param rec array of medical dispatch records * @param rec array of medical dispatch records
* @return List of drone ids that match all the requirements * @return List of drone ids that match all the requirements
@ -124,10 +135,9 @@ public class DroneInfoService {
return drones.stream() return drones.stream()
.filter(d -> d != null && d.capability() != null) .filter(d -> d != null && d.capability() != null)
.filter( .filter(
d -> d -> Arrays.stream(rec)
Arrays.stream(rec) .filter(r -> r != null && r.requirements() != null)
.filter(r -> r != null && r.requirements() != null) .allMatch(r -> droneMatchesRequirement(d, r)))
.allMatch(r -> droneMatchesRequirement(d, r)))
.map(Drone::id) .map(Drone::id)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@ -135,11 +145,13 @@ public class DroneInfoService {
/** /**
* Helper to check if a drone meets the requirement of a medical dispatch. * Helper to check if a drone meets the requirement of a medical dispatch.
* *
* @param drone the drone to be checked * @param drone the drone to be checked
* @param record the medical dispatch record containing the requirement * @param record the medical dispatch record containing the requirement
* @return true if the drone meets the requirement, false otherwise * @return true if the drone meets the requirement, false otherwise
* @throws IllegalArgumentException when record requirements or drone capability is invalid * @throws IllegalArgumentException when record requirements or drone capability
* (capacity and id cannot be null in {@code MedDispathRecDto}) * is invalid
* (capacity and id cannot be null in
* {@code MedDispathRecDto})
*/ */
public boolean droneMatchesRequirement(Drone drone, MedDispatchRecRequest record) { public boolean droneMatchesRequirement(Drone drone, MedDispatchRecRequest record) {
var requirements = record.requirements(); var requirements = record.requirements();
@ -179,13 +191,13 @@ public class DroneInfoService {
* Helper to check if a drone is available at the required date and time * 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 droneId the id of the drone to be checked
* @param record the medical dispatch record containing the required date and time * @param record the medical dispatch record containing the required date and
* time
* @return true if the drone is available, false otherwise * @return true if the drone is available, false otherwise
*/ */
private boolean checkAvailability(String droneId, MedDispatchRecRequest record) { private boolean checkAvailability(String droneId, MedDispatchRecRequest record) {
URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint); URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePoints = ServicePointDrones[] servicePoints = restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
LocalDate requiredDate = record.date(); LocalDate requiredDate = record.date();
DayOfWeek requiredDay = requiredDate.getDayOfWeek(); DayOfWeek requiredDay = requiredDate.getDayOfWeek();
@ -204,8 +216,7 @@ public class DroneInfoService {
private LngLat queryServicePointLocationByDroneId(String droneId) { private LngLat queryServicePointLocationByDroneId(String droneId) {
URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint); URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePoints = ServicePointDrones[] servicePoints = restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
assert servicePoints != null; assert servicePoints != null;
for (var sp : servicePoints) { for (var sp : servicePoints) {
@ -222,8 +233,7 @@ public class DroneInfoService {
private LngLat queryServicePointLocation(int id) { private LngLat queryServicePointLocation(int id) {
URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint); URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
ServicePoint[] servicePoints = ServicePoint[] servicePoints = restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
assert servicePoints != null; assert servicePoints != null;
for (var sp : servicePoints) { for (var sp : servicePoints) {
@ -246,24 +256,22 @@ public class DroneInfoService {
public List<RestrictedArea> fetchRestrictedAreas() { public List<RestrictedArea> fetchRestrictedAreas() {
URI restrictedUrl = URI.create(baseUrl).resolve(restrictedAreasEndpoint); URI restrictedUrl = URI.create(baseUrl).resolve(restrictedAreasEndpoint);
RestrictedArea[] restrictedAreas = RestrictedArea[] restrictedAreas = restTemplate.getForObject(restrictedUrl, RestrictedArea[].class);
restTemplate.getForObject(restrictedUrl, RestrictedArea[].class);
assert restrictedAreas != null; assert restrictedAreas != null;
return Arrays.asList(restrictedAreas); return Arrays.asList(restrictedAreas);
} }
public List<ServicePoint> fetchServicePoints() { public List<ServicePoint> fetchServicePoints() {
URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint); URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
ServicePoint[] servicePoints = ServicePoint[] servicePoints = restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
assert servicePoints != null; assert servicePoints != null;
return Arrays.asList(servicePoints); return Arrays.asList(servicePoints);
} }
public List<ServicePointDrones> fetchDronesForServicePoints() { public List<ServicePointDrones> fetchDronesForServicePoints() {
URI servicePointDronesUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint); URI servicePointDronesUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePointDrones = ServicePointDrones[] servicePointDrones = restTemplate.getForObject(servicePointDronesUrl,
restTemplate.getForObject(servicePointDronesUrl, ServicePointDrones[].class); ServicePointDrones[].class);
assert servicePointDrones != null; assert servicePointDrones != null;
return Arrays.asList(servicePointDrones); return Arrays.asList(servicePointDrones);
} }

View file

@ -23,7 +23,6 @@ import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePa
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
@ -32,6 +31,7 @@ import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.time.LocalDateTime;
/** /**
* Class that handles calculations about deliverypath * Class that handles calculations about deliverypath
@ -44,8 +44,10 @@ import java.util.stream.Collectors;
public class PathFinderService { public class PathFinderService {
/** /**
* Hard stop on how many pathfinding iterations we attempt for a single segment before bailing, * Hard stop on how many pathfinding iterations we attempt for a single segment
* useful for preventing infinite loops caused by precision quirks or unexpected map data. * before bailing,
* useful for preventing infinite loops caused by precision quirks or unexpected
* map data.
* *
* @see #computePath(LngLat, LngLat) * @see #computePath(LngLat, LngLat)
*/ */
@ -65,11 +67,14 @@ public class PathFinderService {
private TelemetryService telemetryService; private TelemetryService telemetryService;
/** /**
* Constructor for PathFinderService. The dependencies are injected by Spring and the * Constructor for PathFinderService. The dependencies are injected by Spring
* constructor pre-computes reference maps used throughout the request lifecycle. * and the
* constructor pre-computes reference maps used throughout the request
* lifecycle.
* *
* @param gpsCalculationService Service handling geometric operations. * @param gpsCalculationService Service handling geometric operations.
* @param droneInfoService Service that exposes drone metadata and capability information. * @param droneInfoService Service that exposes drone metadata and
* capability information.
*/ */
public PathFinderService( public PathFinderService(
GpsCalculationService gpsCalculationService, DroneInfoService droneInfoService) { GpsCalculationService gpsCalculationService, DroneInfoService droneInfoService) {
@ -82,8 +87,7 @@ public class PathFinderService {
this.drones = droneInfoService.fetchAllDrones(); this.drones = droneInfoService.fetchAllDrones();
List<ServicePoint> servicePoints = droneInfoService.fetchServicePoints(); List<ServicePoint> servicePoints = droneInfoService.fetchServicePoints();
List<ServicePointDrones> servicePointAssignments = List<ServicePointDrones> servicePointAssignments = droneInfoService.fetchDronesForServicePoints();
droneInfoService.fetchDronesForServicePoints();
List<RestrictedArea> restrictedAreas = droneInfoService.fetchRestrictedAreas(); List<RestrictedArea> restrictedAreas = droneInfoService.fetchRestrictedAreas();
this.droneById = this.drones.stream().collect(Collectors.toMap(Drone::id, drone -> drone)); this.droneById = this.drones.stream().collect(Collectors.toMap(Drone::id, drone -> drone));
@ -101,17 +105,17 @@ public class PathFinderService {
} }
} }
this.servicePointLocations = this.servicePointLocations = servicePoints.stream()
servicePoints.stream() .collect(
.collect( Collectors.toMap(
Collectors.toMap( ServicePoint::id, sp -> new LngLat(sp.location())));
ServicePoint::id, sp -> new LngLat(sp.location())));
this.restrictedRegions = restrictedAreas.stream().map(RestrictedArea::toRegion).toList(); this.restrictedRegions = restrictedAreas.stream().map(RestrictedArea::toRegion).toList();
} }
/** /**
* Produce a delivery plan for the provided dispatch records. Deliveries are grouped per * Produce a delivery plan for the provided dispatch records. Deliveries are
* grouped per
* compatible drone and per trip to satisfy each drone move limit. * compatible drone and per trip to satisfy each drone move limit.
* *
* @param records Dispatch records to be fulfilled. * @param records Dispatch records to be fulfilled.
@ -157,17 +161,14 @@ public class PathFinderService {
continue; continue;
} }
List<MedDispatchRecRequest> sortedDeliveries = List<MedDispatchRecRequest> sortedDeliveries = entry.getValue().stream()
entry.getValue().stream() .sorted(
.sorted( Comparator.comparingDouble(
Comparator.comparingDouble( rec -> gpsCalculationService.calculateDistance(
rec -> servicePointLocation, rec.delivery())))
gpsCalculationService.calculateDistance( .toList();
servicePointLocation, rec.delivery())))
.toList();
List<List<MedDispatchRecRequest>> trips = List<List<MedDispatchRecRequest>> trips = splitTrips(sortedDeliveries, drone, servicePointLocation);
splitTrips(sortedDeliveries, drone, servicePointLocation);
for (List<MedDispatchRecRequest> trip : trips) { for (List<MedDispatchRecRequest> trip : trips) {
TripResult result = buildTrip(drone, servicePointLocation, trip); TripResult result = buildTrip(drone, servicePointLocation, trip);
@ -245,8 +246,10 @@ public class PathFinderService {
} }
/** /**
* Group dispatch records by their assigned drone, ensuring every record is routed through * Group dispatch records by their assigned drone, ensuring every record is
* {@link #findBestDrone(MedDispatchRecRequest)} exactly once and discarding invalid entries. * routed through
* {@link #findBestDrone(MedDispatchRecRequest)} exactly once and discarding
* invalid entries.
* *
* @param records Dispatch records to be grouped. * @param records Dispatch records to be grouped.
* @return Map keyed by drone ID with the deliveries it should service. * @return Map keyed by drone ID with the deliveries it should service.
@ -265,7 +268,8 @@ public class PathFinderService {
} }
/** /**
* Choose the best drone for the provided record. Currently that equates to picking the closest * Choose the best drone for the provided record. Currently that equates to
* picking the closest
* compatible drone to the delivery location. * compatible drone to the delivery location.
* *
* @param record Dispatch record that needs fulfillment. * @param record Dispatch record that needs fulfillment.
@ -290,9 +294,8 @@ public class PathFinderService {
continue; continue;
} }
double distance = double distance = gpsCalculationService.calculateDistance(
gpsCalculationService.calculateDistance( servicePointLocation, record.delivery());
servicePointLocation, record.delivery());
if (distance < bestScore) { if (distance < bestScore) {
bestScore = distance; bestScore = distance;
@ -306,14 +309,16 @@ public class PathFinderService {
} }
/** /**
* Break a sequence of deliveries into several trips that each respect the drone move limit. The * 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. * deliveries should already be ordered by proximity for sensible grouping.
* *
* @param deliveries Deliveries assigned to a drone. * @param deliveries Deliveries assigned to a drone.
* @param drone Drone that will service the deliveries. * @param drone Drone that will service the deliveries.
* @param servicePoint Starting and ending point of every trip. * @param servicePoint Starting and ending point of every trip.
* @return Partitioned trips with at least one delivery each. * @return Partitioned trips with at least one delivery each.
* @throws IllegalStateException If a single delivery exceeds the drone's move limit. * @throws IllegalStateException If a single delivery exceeds the drone's move
* limit.
*/ */
private List<List<MedDispatchRecRequest>> splitTrips( private List<List<MedDispatchRecRequest>> splitTrips(
List<MedDispatchRecRequest> deliveries, Drone drone, LngLat servicePoint) { List<MedDispatchRecRequest> deliveries, Drone drone, LngLat servicePoint) {
@ -349,13 +354,15 @@ public class PathFinderService {
} }
/** /**
* Build a single trip for the provided drone, including the entire flight path to every * Build a single trip for the provided drone, including the entire flight path
* delivery and back home. The resulting structure contains the {@link DronePath} representation * to every
* delivery and back home. The resulting structure contains the
* {@link DronePath} representation
* as well as cost and moves consumed. * as well as cost and moves consumed.
* *
* @param drone Drone executing the trip. * @param drone Drone executing the trip.
* @param servicePoint Starting/ending location of the trip. * @param servicePoint Starting/ending location of the trip.
* @param deliveries Deliveries to include in the trip in execution order. * @param deliveries Deliveries to include in the trip in execution order.
* @return Trip information or {@code null} if no deliveries are provided. * @return Trip information or {@code null} if no deliveries are provided.
* @see DeliveryPathResponse.DronePath * @see DeliveryPathResponse.DronePath
*/ */
@ -395,10 +402,9 @@ public class PathFinderService {
flightPlans.add(new Delivery(delivery.id(), flightPath)); flightPlans.add(new Delivery(delivery.id(), flightPath));
} }
float cost = float cost = drone.capability().costInitial()
drone.capability().costInitial() + drone.capability().costFinal()
+ drone.capability().costFinal() + (float) (drone.capability().costPerMove() * moves);
+ (float) (drone.capability().costPerMove() * moves);
DronePath path = new DronePath(drone.parseId(), flightPlans); DronePath path = new DronePath(drone.parseId(), flightPlans);
@ -406,11 +412,12 @@ public class PathFinderService {
} }
/** /**
* Estimate the number of moves a prospective trip would need by replaying the path calculation * Estimate the number of moves a prospective trip would need by replaying the
* path calculation
* without mutating any persistent state. * without mutating any persistent state.
* *
* @param servicePoint Trip origin. * @param servicePoint Trip origin.
* @param deliveries Deliveries that would compose the trip. * @param deliveries Deliveries that would compose the trip.
* @return Total moves required to fly the proposed itinerary. * @return Total moves required to fly the proposed itinerary.
*/ */
private int estimateTripMoves(LngLat servicePoint, List<MedDispatchRecRequest> deliveries) { private int estimateTripMoves(LngLat servicePoint, List<MedDispatchRecRequest> deliveries) {
@ -429,10 +436,11 @@ public class PathFinderService {
} }
/** /**
* Build a path between {@code start} and {@code target} by repeatedly moving in snapped * Build a path between {@code start} and {@code target} by repeatedly moving in
* snapped
* increments while avoiding restricted zones. * increments while avoiding restricted zones.
* *
* @param start Start coordinate. * @param start Start coordinate.
* @param target Destination coordinate. * @param target Destination coordinate.
* @return Sequence of visited coordinates and move count. * @return Sequence of visited coordinates and move count.
* @see #nextPosition(LngLat, LngLat) * @see #nextPosition(LngLat, LngLat)
@ -462,18 +470,20 @@ public class PathFinderService {
} }
/** /**
* Determine the next position on the path from {@code current} toward {@code target}, * Determine the next position on the path from {@code current} toward
* preferring the snapped angle closest to the desired heading that does not infiltrate a * {@code target},
* preferring the snapped angle closest to the desired heading that does not
* infiltrate a
* restricted region. * restricted region.
* *
* @param current Current coordinate. * @param current Current coordinate.
* @param target Destination coordinate. * @param target Destination coordinate.
* @return Next admissible coordinate or the original point if none can be found. * @return Next admissible coordinate or the original point if none can be
* found.
*/ */
private LngLat nextPosition(LngLat current, LngLat target) { private LngLat nextPosition(LngLat current, LngLat target) {
double desiredAngle = double desiredAngle = Math.toDegrees(
Math.toDegrees( Math.atan2(target.lat() - current.lat(), target.lng() - current.lng()));
Math.atan2(target.lat() - current.lat(), target.lng() - current.lng()));
List<Angle> candidateAngles = buildAngleCandidates(desiredAngle); List<Angle> candidateAngles = buildAngleCandidates(desiredAngle);
for (Angle angle : candidateAngles) { for (Angle angle : candidateAngles) {
LngLat next = gpsCalculationService.nextPosition(current, angle); LngLat next = gpsCalculationService.nextPosition(current, angle);
@ -485,8 +495,10 @@ public class PathFinderService {
} }
/** /**
* Build a sequence of candidate angles centered on the desired heading, expanding symmetrically * Build a sequence of candidate angles centered on the desired heading,
* clockwise and counter-clockwise to explore alternative headings if the primary path is * expanding symmetrically
* clockwise and counter-clockwise to explore alternative headings if the
* primary path is
* blocked. * blocked.
* *
* @param desiredAngle Bearing in degrees between current and target positions. * @param desiredAngle Bearing in degrees between current and target positions.
@ -521,15 +533,17 @@ public class PathFinderService {
} }
/** /**
* Representation of a computed path segment wrapping the visited positions and the number of * Representation of a computed path segment wrapping the visited positions and
* the number of
* moves taken to traverse them. * moves taken to traverse them.
* *
* @param positions Ordered coordinates that describe the path. * @param positions Ordered coordinates that describe the path.
* @param moves Number of moves consumed by the path. * @param moves Number of moves consumed by the path.
*/ */
private record PathSegment(List<LngLat> positions, int moves) { private record PathSegment(List<LngLat> positions, int moves) {
/** /**
* Append the positions from this segment to {@code target}, skipping the first coordinate * 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. * as it is already represented by the last coordinate in the consumer path.
* *
* @param target Mutable list to append to. * @param target Mutable list to append to.
@ -542,8 +556,10 @@ public class PathFinderService {
} }
/** /**
* Bundle containing the calculated {@link DronePath}, total moves and financial cost for a * Bundle containing the calculated {@link DronePath}, total moves and financial
* cost for a
* single trip. * single trip.
*/ */
private record TripResult(DronePath path, int moves, float cost) {} private record TripResult(DronePath path, int moves, float cost) {
}
} }

View file

@ -1,24 +1,20 @@
package io.github.js0ny.ilp_coursework.service; package io.github.js0ny.ilp_coursework.service;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.CompletableFuture;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.DroneEvent; import io.github.js0ny.ilp_coursework.data.common.DroneEvent;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse; import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@Service @Service
public class TelemetryService { public class TelemetryService {
private static final Logger log = LoggerFactory.getLogger(TelemetryService.class);
private final HttpClient client; private final HttpClient client;
private final ObjectMapper mapper; private final ObjectMapper mapper;
@ -28,8 +24,7 @@ public class TelemetryService {
this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
this.mapper = new ObjectMapper(); this.mapper = new ObjectMapper();
this.BLACKBOX_URL = this.BLACKBOX_URL = System.getenv().getOrDefault("BLACKBOX_ENDPOINT", "http://localhost:3000");
System.getenv().getOrDefault("BLACKBOX_ENDPOINT", "http://localhost:3000");
} }
public void sendEventAsyncByPathResponse(DeliveryPathResponse resp) { public void sendEventAsyncByPathResponse(DeliveryPathResponse resp) {
@ -39,16 +34,15 @@ public class TelemetryService {
} }
} }
public void sendEventAsyncByPathResponse( public void sendEventAsyncByPathResponse(DeliveryPathResponse resp, LocalDateTime baseTimestamp) {
DeliveryPathResponse resp, LocalDateTime baseTimestamp) {
var events = DroneEvent.fromPathResponseWithTimestamp(resp, baseTimestamp); var events = DroneEvent.fromPathResponseWithTimestamp(resp, baseTimestamp);
for (var event : events) { for (var event : events) {
sendEventAsync(event); sendEventAsync(event);
} }
} }
public void sendEventAsyncByPathResponse( public void sendEventAsyncByPathResponse(DeliveryPathResponse resp,
DeliveryPathResponse resp, Map<Integer, LocalDateTime> deliveryTimestamps) { Map<Integer, LocalDateTime> deliveryTimestamps) {
var events = DroneEvent.fromPathResponseWithTimestamps(resp, deliveryTimestamps); var events = DroneEvent.fromPathResponseWithTimestamps(resp, deliveryTimestamps);
for (var event : events) { for (var event : events) {
sendEventAsync(event); sendEventAsync(event);
@ -56,24 +50,22 @@ public class TelemetryService {
} }
public void sendEventAsync(DroneEvent event) { public void sendEventAsync(DroneEvent event) {
CompletableFuture.runAsync( CompletableFuture.runAsync(() -> {
() -> { try {
try { String json = mapper.writeValueAsString(event);
String json = mapper.writeValueAsString(event); System.out.println("[INFO] Sending telemetry event: " + json);
log.debug("Sending telemetry event: {}", json); var request = java.net.http.HttpRequest.newBuilder()
var request = .uri(java.net.URI.create(BLACKBOX_URL + "/ingest"))
java.net.http.HttpRequest.newBuilder() .header("Content-Type", "application/json")
.uri(java.net.URI.create(BLACKBOX_URL + "/ingest")) .POST(java.net.http.HttpRequest.BodyPublishers.ofString(json))
.header("Content-Type", "application/json") .build();
.POST(
java.net.http.HttpRequest.BodyPublishers.ofString( client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString());
json)) } catch (Exception e) {
.build(); System.err.println("[ERROR] Failed to send telemetry event: " + e.getMessage());
}
});
client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
log.error("Failed to send telemetry event: {}", e.getMessage());
}
});
} }
} }

View file

@ -15,7 +15,6 @@ import io.github.js0ny.ilp_coursework.data.common.LngLat;
import io.github.js0ny.ilp_coursework.data.external.Drone; 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.AttrQueryRequest;
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.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 io.github.js0ny.ilp_coursework.service.PathFinderService;
@ -410,79 +409,4 @@ public class DroneControllerTest {
.andExpect(content().json(objectMapper.writeValueAsString(expected))); .andExpect(content().json(objectMapper.writeValueAsString(expected)));
} }
} }
@Nested
@DisplayName("POST /calcDeliveryPath")
class PostCalcDeliveryPathTests {
final String API_ENDPOINT = "/api/v1/calcDeliveryPath";
@Test
@DisplayName("Example -> 200 OK")
void postCalcDeliveryPath_shouldReturn200AndJson_whenExampleRequest() throws Exception {
var reqs = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f);
var delivery = new LngLat(-3.00, 55.121);
var record =
new MedDispatchRecRequest(
123,
LocalDate.parse("2025-12-22"),
LocalTime.parse("14:30"),
reqs,
delivery);
MedDispatchRecRequest[] requestBody = {record};
var flightPath = List.of(new LngLat(-3.0, 55.12), new LngLat(-3.01, 55.13));
var deliveryPath =
new DeliveryPathResponse.DronePath.Delivery(123, flightPath);
var dronePath = new DeliveryPathResponse.DronePath(1, List.of(deliveryPath));
DeliveryPathResponse expected =
new DeliveryPathResponse(
12.5f, 42, new DeliveryPathResponse.DronePath[] {dronePath});
when(pathFinderService.calculateDeliveryPath(any(MedDispatchRecRequest[].class)))
.thenReturn(expected);
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
}
@Nested
@DisplayName("POST /calcDeliveryPathAsGeoJson")
class PostCalcDeliveryPathAsGeoJsonTests {
final String API_ENDPOINT = "/api/v1/calcDeliveryPathAsGeoJson";
@Test
@DisplayName("Example -> 200 OK")
void postCalcDeliveryPathAsGeoJson_shouldReturn200AndGeoJson_whenExampleRequest()
throws Exception {
var reqs = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f);
var delivery = new LngLat(-3.00, 55.121);
var record =
new MedDispatchRecRequest(
123,
LocalDate.parse("2025-12-22"),
LocalTime.parse("14:30"),
reqs,
delivery);
MedDispatchRecRequest[] requestBody = {record};
String expected = "{\"type\":\"FeatureCollection\",\"features\":[]}";
when(pathFinderService.calculateDeliveryPathAsGeoJson(
any(MedDispatchRecRequest[].class)))
.thenReturn(expected);
mockMvc.perform(
post(API_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(status().isOk())
.andExpect(content().string(expected));
}
}
} }

View file

@ -1,81 +0,0 @@
package io.github.js0ny.ilp_coursework.controller;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.AltitudeRange;
import io.github.js0ny.ilp_coursework.data.common.LngLatAlt;
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
import io.github.js0ny.ilp_coursework.data.external.ServicePoint;
import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
@WebMvcTest(MapMetaController.class)
public class MapMetaControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@MockitoBean private DroneInfoService droneInfoService;
@Nested
@DisplayName("GET /restrictedAreas")
class RestrictedAreasTests {
@Test
@DisplayName("-> 200 OK")
void getRestrictedAreas_shouldReturn200AndJson() throws Exception {
String endpoint = "/api/v1/restrictedAreas";
RestrictedArea area =
new RestrictedArea(
"Zone A",
1,
new AltitudeRange(0.0, 120.0),
new LngLatAlt[] {
new LngLatAlt(0.0, 0.0, 0.0),
new LngLatAlt(1.0, 0.0, 0.0),
new LngLatAlt(1.0, 1.0, 0.0),
new LngLatAlt(0.0, 1.0, 0.0)
});
List<RestrictedArea> expected = List.of(area);
when(droneInfoService.fetchRestrictedAreas()).thenReturn(expected);
var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk());
mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
}
@Nested
@DisplayName("GET /servicePoints")
class ServicePointsTests {
@Test
@DisplayName("-> 200 OK")
void getServicePoints_shouldReturn200AndJson() throws Exception {
String endpoint = "/api/v1/servicePoints";
ServicePoint point = new ServicePoint("Point A", 1, new LngLatAlt(0.1, 0.2, 12.0));
List<ServicePoint> expected = List.of(point);
when(droneInfoService.fetchServicePoints()).thenReturn(expected);
var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON));
mock.andExpect(status().isOk());
mock.andExpect(content().json(objectMapper.writeValueAsString(expected)));
}
}
}

View file

@ -1,107 +0,0 @@
package io.github.js0ny.ilp_coursework.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.DroneCapability;
import io.github.js0ny.ilp_coursework.data.external.Drone;
import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
@ExtendWith(SpringExtension.class)
public class DroneAttrComparatorServiceTest {
private DroneAttrComparatorService service;
private MockRestServiceServer server;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
service = new DroneAttrComparatorService();
RestTemplate restTemplate =
(RestTemplate) ReflectionTestUtils.getField(service, "restTemplate");
server = MockRestServiceServer.createServer(restTemplate);
ReflectionTestUtils.setField(service, "baseUrl", "http://localhost/");
objectMapper = new ObjectMapper();
}
@AfterEach
void tearDown() {
server.verify();
}
@Nested
@DisplayName("dronesWithAttribute(String, String) tests")
class DronesWithAttributeTests {
@Test
@DisplayName("Should return matching ids for boolean attribute")
void dronesWithAttribute_shouldReturnMatchingIds() throws Exception {
Drone[] drones = {
new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)),
new Drone("Drone 2", "2", new DroneCapability(false, true, 5, 500, 1, 1, 1)),
new Drone("Drone 3", "3", new DroneCapability(true, false, 12, 800, 1, 1, 1))
};
String responseBody = objectMapper.writeValueAsString(drones);
server.expect(requestTo("http://localhost/drones"))
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
var result = service.dronesWithAttribute("cooling", "true");
assertThat(result).containsExactly("1", "3");
}
}
@Nested
@DisplayName("dronesSatisfyingAttributes(AttrQueryRequest[]) tests")
class DronesSatisfyingAttributesTests {
@Test
@DisplayName("Should return intersection of all rules")
void dronesSatisfyingAttributes_shouldReturnIntersection() throws Exception {
Drone[] drones = {
new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)),
new Drone("Drone 2", "2", new DroneCapability(true, true, 3, 1000, 1, 1, 1)),
new Drone("Drone 3", "3", new DroneCapability(true, false, 12, 1000, 1, 1, 1))
};
String responseBody = objectMapper.writeValueAsString(drones);
server.expect(requestTo("http://localhost/drones"))
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
server.expect(requestTo("http://localhost/drones"))
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
AttrQueryRequest[] comparators = {
new AttrQueryRequest("capacity", ">", "5"),
new AttrQueryRequest("heating", "=", "true")
};
var result = service.dronesSatisfyingAttributes(comparators);
assertThat(result).containsExactly("1");
}
@Test
@DisplayName("Should return empty list when no comparators")
void dronesSatisfyingAttributes_shouldReturnEmpty_whenNoComparators() {
AttrQueryRequest[] comparators = {};
var result = service.dronesSatisfyingAttributes(comparators);
assertThat(result).isEmpty();
}
}
}

View file

@ -4,15 +4,11 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import io.github.js0ny.ilp_coursework.data.common.AltitudeRange;
import io.github.js0ny.ilp_coursework.data.common.DroneAvailability; 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.DroneCapability;
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.LngLatAlt;
import io.github.js0ny.ilp_coursework.data.common.TimeWindow; 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.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.external.ServicePointDrones;
import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest; import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest;
@ -234,145 +230,4 @@ public class DroneInfoServiceTest {
assertThat(resultEmpty).containsExactly("1", "2", "3"); assertThat(resultEmpty).containsExactly("1", "2", "3");
} }
} }
@Nested
@DisplayName("droneMatchesRequirement(Drone, MedDispatchRecRequest) tests")
class DroneMatchesRequirementTests {
@Test
@DisplayName("Should throw when requirements are null")
void droneMatchesRequirement_shouldThrow_whenRequirementsNull() {
Drone drone =
new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1));
MedDispatchRecRequest record =
new MedDispatchRecRequest(
1, LocalDate.now(), LocalTime.of(9, 0), null, new LngLat(0, 0));
assertThatThrownBy(() -> droneInfoService.droneMatchesRequirement(drone, record))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("requirements cannot be null");
}
@Test
@DisplayName("Should throw when drone capability is null")
void droneMatchesRequirement_shouldThrow_whenCapabilityNull() {
Drone drone = new Drone("Drone 1", "1", null);
MedDispatchRecRequest record =
new MedDispatchRecRequest(
1,
LocalDate.now(),
LocalTime.of(9, 0),
new MedDispatchRecRequest.MedRequirement(1, false, false, 10),
new LngLat(0, 0));
assertThatThrownBy(() -> droneInfoService.droneMatchesRequirement(drone, record))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("drone capability cannot be null");
}
}
@Nested
@DisplayName("fetchAllDrones() tests")
class FetchAllDronesTests {
@Test
@DisplayName("Should return list when API returns drones")
void fetchAllDrones_shouldReturnList_whenApiReturnsDrones() {
Drone[] drones = getMockDrones();
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(drones);
List<Drone> result = droneInfoService.fetchAllDrones();
assertThat(result).hasSize(3);
assertThat(result.get(0).id()).isEqualTo("1");
}
@Test
@DisplayName("Should return empty list when API returns null")
void fetchAllDrones_shouldReturnEmptyList_whenApiReturnsNull() {
when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class))
.thenReturn(null);
List<Drone> result = droneInfoService.fetchAllDrones();
assertThat(result).isEmpty();
}
}
@Nested
@DisplayName("fetchRestrictedAreas() tests")
class FetchRestrictedAreasTests {
@Test
@DisplayName("Should return restricted areas")
void fetchRestrictedAreas_shouldReturnList() {
RestrictedArea[] areas = {
new RestrictedArea(
"Zone A",
1,
new AltitudeRange(0, 100),
new LngLatAlt[] {
new LngLatAlt(0, 0, 0),
new LngLatAlt(1, 0, 0),
new LngLatAlt(1, 1, 0)
})
};
when(restTemplate.getForObject(
URI.create(baseUrl + "restricted-areas"), RestrictedArea[].class))
.thenReturn(areas);
List<RestrictedArea> result = droneInfoService.fetchRestrictedAreas();
assertThat(result).hasSize(1);
assertThat(result.get(0).name()).isEqualTo("Zone A");
}
}
@Nested
@DisplayName("fetchServicePoints() tests")
class FetchServicePointsTests {
@Test
@DisplayName("Should return service points")
void fetchServicePoints_shouldReturnList() {
ServicePoint[] points = {
new ServicePoint("Point A", 1, new LngLatAlt(0.1, 0.2, 3.0))
};
when(restTemplate.getForObject(
URI.create(baseUrl + "service-points"), ServicePoint[].class))
.thenReturn(points);
List<ServicePoint> result = droneInfoService.fetchServicePoints();
assertThat(result).hasSize(1);
assertThat(result.get(0).name()).isEqualTo("Point A");
}
}
@Nested
@DisplayName("fetchDronesForServicePoints() tests")
class FetchDronesForServicePointsTests {
@Test
@DisplayName("Should return service point drones")
void fetchDronesForServicePoints_shouldReturnList() {
TimeWindow[] timeWindows = {
new TimeWindow(DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.of(17, 0))
};
DroneAvailability drone1Avail = new DroneAvailability("1", timeWindows);
ServicePointDrones spd =
new ServicePointDrones(1, new DroneAvailability[] {drone1Avail});
ServicePointDrones[] servicePointDrones = {spd};
when(restTemplate.getForObject(
URI.create(baseUrl + "drones-for-service-points"),
ServicePointDrones[].class))
.thenReturn(servicePointDrones);
List<ServicePointDrones> result = droneInfoService.fetchDronesForServicePoints();
assertThat(result).hasSize(1);
assertThat(result.get(0).servicePointId()).isEqualTo(1);
}
}
} }