glue go and java

This commit is contained in:
js0ny 2025-12-05 06:40:58 +00:00
parent 3bbfed11fa
commit 8e462fedc1
4 changed files with 217 additions and 64 deletions

View file

@ -14,9 +14,9 @@ import (
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
// Define the DroneEvent struct as JSON // DroneEvent defines the DroneEvent struct as JSON
type DroneEvent struct { type DroneEvent struct {
DroneID string `json:"drone_id"` DroneID string `json:"droneId"`
Latitude float64 `json:"latitude"` Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"` Longitude float64 `json:"longitude"`
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`

View file

@ -0,0 +1,65 @@
package io.github.js0ny.ilp_coursework.data.common;
import java.time.LocalDateTime;
import java.util.List;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
// Corresponding in Go
//
/*
* type DroneEvent struct {
* DroneID string `json:"drone_id"`
* Latitude float64 `json:"latitude"`
* Longitude float64 `json:"longitude"`
* Timestamp string `json:"timestamp"`
* }
*/
public record DroneEvent(
String droneId,
double latitude,
double longitude,
String timestamp) {
// Helper method that converts from DeliveryPathResponse to List<DroneEvent>
public static List<DroneEvent> fromPathResponse(DeliveryPathResponse resp) {
List<DroneEvent> events = new java.util.ArrayList<>();
for (var p : resp.dronePaths()) {
String id = p.droneId() + "";
for (var d : p.deliveries()) {
for (var coord : d.flightPath()) {
String timestamp = java.time.Instant.now().toString();
events.add(new DroneEvent(
id,
coord.lat(),
coord.lng(),
timestamp));
}
}
}
return events;
}
// Helper method that converts from DeliveryPathResponse to List<DroneEvent>
// with base timestamp
public static List<DroneEvent> fromPathResponseWithTimestamp(DeliveryPathResponse resp,
LocalDateTime baseTimestamp) {
List<DroneEvent> events = new java.util.ArrayList<>();
java.time.LocalDateTime timestamp = baseTimestamp;
for (var p : resp.dronePaths()) {
String id = String.valueOf(p.droneId());
for (var d : p.deliveries()) {
for (var coord : d.flightPath()) {
events.add(new DroneEvent(
id,
coord.lat(),
coord.lng(),
timestamp.toString()));
timestamp = timestamp.plusSeconds(1); // Increment timestamp for each event
}
}
}
return events;
}
}

View file

@ -20,6 +20,7 @@ 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;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath.Delivery; import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePath.Delivery;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
@ -42,8 +43,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)
*/ */
@ -59,12 +62,18 @@ public class PathFinderService {
private final Map<Integer, LngLat> servicePointLocations; private final Map<Integer, LngLat> servicePointLocations;
private final List<Region> restrictedRegions; private final List<Region> restrictedRegions;
@Autowired
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) {
@ -77,8 +86,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));
@ -96,17 +104,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.
@ -148,17 +156,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);
@ -170,15 +175,22 @@ public class PathFinderService {
} }
} }
return new DeliveryPathResponse(totalCost, totalMoves, paths.toArray(new DronePath[0])); var resp = new DeliveryPathResponse(totalCost, totalMoves, paths.toArray(new DronePath[0]));
telemetryService.sendEventAsyncByPathResponse(resp);
return resp;
} }
/** /*
* Convenience wrapper around {@link #calculateDeliveryPath} that serializes the result into a * Convenience wrapper around {@link #calculateDeliveryPath} that serializes the
* result into a
* GeoJSON FeatureCollection suitable for mapping visualization. * GeoJSON FeatureCollection suitable for mapping visualization.
* *
* @param records Dispatch records to be fulfilled. * @param records Dispatch records to be fulfilled.
*
* @return GeoJSON payload representing every delivery flight path. * @return GeoJSON payload representing every delivery flight path.
*
* @throws IllegalStateException When the payload cannot be serialized. * @throws IllegalStateException When the payload cannot be serialized.
*/ */
public String calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[] records) { public String calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[] records) {
@ -227,8 +239,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.
@ -247,7 +261,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.
@ -272,9 +287,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;
@ -288,14 +302,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) {
@ -331,13 +347,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
*/ */
@ -377,10 +395,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);
@ -388,11 +405,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) {
@ -411,10 +429,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)
@ -444,18 +463,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);
@ -467,8 +488,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.
@ -503,15 +526,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.
@ -524,8 +549,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

@ -0,0 +1,61 @@
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 org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.js0ny.ilp_coursework.data.common.DroneEvent;
import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse;
@Service
public class TelemetryService {
private final HttpClient client;
private final ObjectMapper mapper;
private final String BLACKBOX_URL;
public TelemetryService() {
this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
this.mapper = new ObjectMapper();
this.BLACKBOX_URL = System.getenv().getOrDefault("BLACKBOX_ENDPOINT", "http://localhost:3000");
}
public void sendEventAsyncByPathResponse(DeliveryPathResponse resp) {
var events = DroneEvent.fromPathResponse(resp);
for (var event : events) {
sendEventAsync(event);
}
}
public void sendEventAsyncByPathResponse(DeliveryPathResponse resp, LocalDateTime baseTimestamp) {
var events = DroneEvent.fromPathResponseWithTimestamp(resp, baseTimestamp);
for (var event : events) {
sendEventAsync(event);
}
}
public void sendEventAsync(DroneEvent event) {
CompletableFuture.runAsync(() -> {
try {
String json = mapper.writeValueAsString(event);
var request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(BLACKBOX_URL + "/ingest"))
.header("Content-Type", "application/json")
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(json))
.build();
client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
System.err.println("[ERROR] Failed to send telemetry event: " + e.getMessage());
}
});
}
}