Initial implementation
This commit is contained in:
21
simulation/src/main/java/simulation/I18N.java
Normal file
21
simulation/src/main/java/simulation/I18N.java
Normal file
@ -0,0 +1,21 @@
|
||||
package simulation;
|
||||
|
||||
import java.util.ResourceBundle;
|
||||
|
||||
/**
|
||||
* Convenience class for retrieving translated strings
|
||||
*
|
||||
* @author John Ahlroos
|
||||
*/
|
||||
public interface I18N {
|
||||
|
||||
/**
|
||||
* Get a translated string
|
||||
*
|
||||
* @param key the translation key
|
||||
* @return the translated message
|
||||
*/
|
||||
static String get(String key) {
|
||||
return ResourceBundle.getBundle("simulation-messages").getString(key);
|
||||
}
|
||||
}
|
110
simulation/src/main/java/simulation/Simulation.java
Normal file
110
simulation/src/main/java/simulation/Simulation.java
Normal file
@ -0,0 +1,110 @@
|
||||
package simulation;
|
||||
|
||||
import drones.dispatcher.Dispatcher;
|
||||
import picocli.CommandLine;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Help.Visibility;
|
||||
import picocli.CommandLine.ITypeConverter;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Simulates flying drones by using data form the ./data directory
|
||||
* <p>
|
||||
* Creates log files to the logs/ directory:
|
||||
* - dispatcher.log: Logs from the dispatcher controlling the drones
|
||||
* - drone-<id>.log: Logs from the drones
|
||||
* <p>
|
||||
* The output of the simulation that is printed in the console is a report for traffic conditions of the provided
|
||||
* tube stations. Every line represents the condition of the traffic at a certain point in time and from a certain
|
||||
* waypoint viewpoint.
|
||||
*/
|
||||
|
||||
@Command(name = "drone-simulator", mixinStandardHelpOptions = true,
|
||||
version = "1.0", resourceBundle = Simulation.RESOURCE_BUNDLE, showDefaultValues = true)
|
||||
public class Simulation implements Callable<Integer> {
|
||||
|
||||
public static final String RESOURCE_BUNDLE = "simulation-messages";
|
||||
|
||||
@Parameters(arity = "1", paramLabel = "DRONES", showDefaultValue = Visibility.NEVER)
|
||||
private List<Long> droneIds;
|
||||
|
||||
@Option(names = {"-d", "--data-dir"}, defaultValue = ".")
|
||||
private Path dataDir;
|
||||
|
||||
@Option(names = {"-t", "--tube-stations"}, defaultValue = "./tube.csv")
|
||||
private Path tubeStationsFile;
|
||||
|
||||
@Option(names = {"-b", "--start-time"}, defaultValue = "2011-03-22 07:47:00", converter = TimeConverter.class)
|
||||
private LocalDateTime currentTime;
|
||||
|
||||
@Option(names = {"-e", "--shut-down-time"}, defaultValue = "2011-03-22 08:10:00", converter = TimeConverter.class)
|
||||
private LocalDateTime shutDownTime;
|
||||
|
||||
@Option(names = {"-p", "--simulation-speed"}, defaultValue = "0.0")
|
||||
private double simulationSpeed;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
System.out.println(I18N.get("starting.message"));
|
||||
System.out.println();
|
||||
|
||||
// Use default location for tube file (in the data directory) if not explicitly set
|
||||
if (!tubeStationsFile.toFile().exists()) {
|
||||
tubeStationsFile = dataDir.resolve(Path.of("tube.csv"));
|
||||
}
|
||||
|
||||
final var executor = Executors.newFixedThreadPool(droneIds.size() + 1);
|
||||
try {
|
||||
|
||||
// Setup dispatcher
|
||||
var dispatcher = new Dispatcher(currentTime, shutDownTime, simulationSpeed);
|
||||
dispatcher.registerTubeStations(Files.newBufferedReader(tubeStationsFile));
|
||||
|
||||
// Setup Drones
|
||||
var drones = new ArrayList<Runnable>();
|
||||
for (long id : droneIds) {
|
||||
var droneData = Files.newBufferedReader(dataDir.resolve(Path.of(id + ".csv")));
|
||||
drones.add(dispatcher.registerDroneWithReader(id, droneData));
|
||||
}
|
||||
|
||||
// Power them on
|
||||
executor.execute(dispatcher);
|
||||
drones.forEach(executor::execute);
|
||||
|
||||
} finally {
|
||||
executor.shutdown();
|
||||
executor.awaitTermination(20, TimeUnit.MINUTES);
|
||||
}
|
||||
System.out.println(I18N.get("ended.message"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entrypoint of the simulation
|
||||
*
|
||||
* @param args program arguments
|
||||
*/
|
||||
public static void main(String... args) {
|
||||
int exitCode = new CommandLine(new Simulation()).execute(args);
|
||||
System.exit(exitCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the date formats for input times
|
||||
*/
|
||||
static class TimeConverter implements ITypeConverter<LocalDateTime> {
|
||||
public LocalDateTime convert(String value) throws Exception {
|
||||
return LocalDateTime.parse(value, Dispatcher.DATE_FORMAT);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
package simulation.logging;
|
||||
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.sift.AbstractDiscriminator;
|
||||
|
||||
/**
|
||||
* Provides capability for the logger to log drone logs to different files
|
||||
*
|
||||
* @author John Ahlroos
|
||||
*/
|
||||
public class DroneLogPerFile extends AbstractDiscriminator<ILoggingEvent> {
|
||||
@Override
|
||||
public String getDiscriminatingValue(ILoggingEvent event) {
|
||||
return event.getLoggerName().split("\\.")[1];
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKey() {
|
||||
return "droneId";
|
||||
}
|
||||
}
|
@ -0,0 +1,277 @@
|
||||
package simulation.logging;
|
||||
|
||||
import ch.qos.logback.classic.spi.LoggingEvent;
|
||||
import ch.qos.logback.core.AppenderBase;
|
||||
import drones.drone.Drone;
|
||||
import drones.geo.Point;
|
||||
import drones.geo.TubeStation;
|
||||
import drones.messages.Message.TrafficCondition.Condition;
|
||||
import simulation.I18N;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Combines logs to form a map of the flow route and tube stations
|
||||
*
|
||||
* @author John Ahlroos
|
||||
*/
|
||||
public class RouteMapGenerator<E> extends AppenderBase<E> {
|
||||
|
||||
private static final int IMAGE_PIXEL_SIZE = 800;
|
||||
private static final String DRONE_LOGGER = "Drone";
|
||||
private static final String DISPATCHER_LOGGER = "Dispatcher";
|
||||
private static final String REPORT_LOGGER = "Report";
|
||||
private static final String REPORT_FILE_NAME = "traffic-report.png";
|
||||
|
||||
private static final Pattern ALL_TUBE_STATIONS_PATTERN = Pattern.compile(
|
||||
"Registered\sTube\sStation.*(name=(.*),\\slat=([\\d+\\.]+)),\\s(lon=([\\d+\\.-]+))");
|
||||
private static final Pattern VISITED_TUBE_STATIONS_PATTERN = Pattern.compile(
|
||||
"(.*) @ (\\d\\d:\\d\\d:\\d\\d): (.*) \\(drone: (\\d*),speed: (\\d*)km/h, distanceToStation: (\\d*)m\\)");
|
||||
private static final Pattern ROUTE_POINT_PATTERN = Pattern.compile(
|
||||
"Arrived.*(lat=([\\d+\\.]+)),\\s(lon=([\\d+\\.-]+))");
|
||||
|
||||
private double min_lat = Double.MAX_VALUE;
|
||||
private double min_lon = Double.MAX_VALUE;
|
||||
private double max_lat = Double.MIN_VALUE;
|
||||
private double max_lon = Double.MIN_VALUE;
|
||||
|
||||
private final Map<Long, List<Point>> points = new ConcurrentHashMap<>();
|
||||
private final List<TubeStation> stations = new ArrayList<>();
|
||||
private final Set<TubeStationWithCondition> foundStations = new HashSet<>();
|
||||
private final List<Long> terminated = new ArrayList<>();
|
||||
private boolean mapRendered = false;
|
||||
|
||||
@Override
|
||||
protected void append(E event) {
|
||||
if (simulationTerminated()) {
|
||||
if (!mapRendered) {
|
||||
var file = renderMap();
|
||||
System.out.println();
|
||||
System.out.println(String.format(I18N.get("map.generation.message"), file.toAbsolutePath()));
|
||||
System.out.println();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var loggingEvent = (LoggingEvent) event;
|
||||
var message = loggingEvent.getFormattedMessage();
|
||||
var loggerName = loggingEvent.getLoggerName();
|
||||
|
||||
if (DISPATCHER_LOGGER.equals(loggerName)) {
|
||||
collectAllTubeStation(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (REPORT_LOGGER.equals(loggerName)) {
|
||||
collectVisitedTubeStation(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loggerName.startsWith(DRONE_LOGGER)) {
|
||||
var droneId = Long.parseLong(loggerName.substring(loggerName.indexOf(".") + 1));
|
||||
registerDroneTerminated(droneId, message);
|
||||
collectRoutePoint(droneId, message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void collectAllTubeStation(String message) {
|
||||
var m2 = ALL_TUBE_STATIONS_PATTERN.matcher(message);
|
||||
while (m2.find()) {
|
||||
var name = m2.group(2);
|
||||
var lat = Double.parseDouble(m2.group(3));
|
||||
var lon = Double.parseDouble(m2.group(5));
|
||||
var tb = new TubeStation(new Point(lat, lon, null), name);
|
||||
stations.add(tb);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectVisitedTubeStation(String message) {
|
||||
var m = VISITED_TUBE_STATIONS_PATTERN.matcher(message);
|
||||
while (m.find()) {
|
||||
var name = m.group(1);
|
||||
var time = m.group(2);
|
||||
var condition = m.group(3);
|
||||
var droneId = Long.parseLong(m.group(4));
|
||||
var speed = Integer.parseInt(m.group(5));
|
||||
stations.stream()
|
||||
.filter(tb -> tb.name().equals(name))
|
||||
.map(tb -> new TubeStationWithCondition(tb, time, condition, speed, droneId))
|
||||
.forEach(foundStations::add);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectRoutePoint(long droneId, String message) {
|
||||
var m = ROUTE_POINT_PATTERN.matcher(message);
|
||||
while (m.find()) {
|
||||
var lat = Double.parseDouble(m.group(2));
|
||||
min_lat = Math.min(min_lat, lat);
|
||||
max_lat = Math.max(max_lat, lat);
|
||||
var lon = Double.parseDouble(m.group(4));
|
||||
min_lon = Math.min(min_lon, lon);
|
||||
max_lon = Math.max(max_lon, lon);
|
||||
points.putIfAbsent(droneId, new ArrayList<>());
|
||||
points.get(droneId).add(new Point(lat, lon, null));
|
||||
}
|
||||
}
|
||||
|
||||
private void registerDroneTerminated(long droneId, String message) {
|
||||
if (message.contains("state=TERMINATED")) {
|
||||
terminated.add(droneId);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean simulationTerminated() {
|
||||
return !points.keySet().isEmpty() && terminated.containsAll(points.keySet());
|
||||
}
|
||||
|
||||
private Path renderMap() {
|
||||
mapRendered = true;
|
||||
|
||||
var colors = new ArrayDeque<>(List.of(Color.BLUE, Color.ORANGE));
|
||||
var map = new BufferedImage(IMAGE_PIXEL_SIZE, IMAGE_PIXEL_SIZE, BufferedImage.TYPE_INT_ARGB);
|
||||
var graphics = (Graphics2D) map.getGraphics();
|
||||
|
||||
var font1 = new Font("Arial", Font.ITALIC, 18);
|
||||
var font2 = new Font("Arial", Font.BOLD, 16);
|
||||
var font3 = new Font("Arial", Font.PLAIN, 14);
|
||||
|
||||
// Apply Zoom factor
|
||||
min_lon -= 0.006;
|
||||
max_lon += 0.006;
|
||||
min_lat -= 0.006;
|
||||
max_lat += 0.006;
|
||||
|
||||
renderBackground(graphics);
|
||||
stations.forEach(tb -> renderStation(tb, graphics));
|
||||
foundStations.forEach(tb -> renderFoundStation(tb, graphics, font2, font3));
|
||||
points.forEach((droneId, points) -> renderRoute(droneId, points, graphics, font1, font2, colors));
|
||||
|
||||
try {
|
||||
var file = Path.of(REPORT_FILE_NAME);
|
||||
ImageIO.write(map, "png", file.toFile());
|
||||
return file;
|
||||
} catch (IOException e) {
|
||||
System.err.println(I18N.get("map.file.generation.failed"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void renderBackground(Graphics graphics) {
|
||||
graphics.setColor(Color.LIGHT_GRAY);
|
||||
graphics.fillRect(0, 0, IMAGE_PIXEL_SIZE - 1, IMAGE_PIXEL_SIZE - 1);
|
||||
}
|
||||
|
||||
private void renderStation(TubeStation tb, Graphics graphics) {
|
||||
if (foundStations.stream().map(TubeStationWithCondition::tubeStation).anyMatch(tb::equals)) {
|
||||
return;
|
||||
}
|
||||
var x = getXCoordinate(tb.point());
|
||||
var y = getYCoordinate(tb.point());
|
||||
graphics.setColor(new Color(170, 170, 170));
|
||||
graphics.fillRect(x - 5, y - 5, 10, 10);
|
||||
graphics.drawString(tb.name(), x + 10, y + 5);
|
||||
}
|
||||
|
||||
private void renderFoundStation(TubeStationWithCondition tb, Graphics graphics, Font font2, Font font3) {
|
||||
var x = getXCoordinate(tb.tubeStation.point());
|
||||
var y = getYCoordinate(tb.tubeStation.point());
|
||||
|
||||
graphics.setColor(switch (Condition.valueOf(tb.condition)) {
|
||||
case LIGHT -> Color.GREEN;
|
||||
case MODERATE -> Color.YELLOW;
|
||||
case HEAVY -> Color.RED;
|
||||
});
|
||||
|
||||
graphics.setFont(font2);
|
||||
graphics.fillRect(x - 5, y - 5, 10, 10);
|
||||
graphics.drawString(String.format("%s@%s %s", tb.tubeStation.name(), tb.time, tb.condition), x + 10, y + 5);
|
||||
graphics.setColor(Color.DARK_GRAY);
|
||||
graphics.setFont(font3);
|
||||
graphics.drawString(String.format("(%s@%skm/h)", tb.droneId, tb.speed), x + 10, y + 20);
|
||||
graphics.setFont(font2);
|
||||
}
|
||||
|
||||
private void renderRoute(long droneId, List<Point> data, Graphics graphics, Font font1, Font font2,
|
||||
Deque<Color> colors) {
|
||||
|
||||
// Convert range to pixels
|
||||
var range = data.get(0).moveTowards(data.get(1), Drone.TUBE_STATION_RANGE, 1);
|
||||
var rangeX = Math.abs(getXCoordinate(data.get(0)) - getXCoordinate(range));
|
||||
var rangeY = Math.abs(getYCoordinate(data.get(0)) - getYCoordinate(range));
|
||||
var rangeW = Math.max(rangeX, rangeY);
|
||||
|
||||
var routeColor = colors.poll();
|
||||
var prevPoint = data.get(0);
|
||||
|
||||
var prevX = getXCoordinate(prevPoint);
|
||||
var prevY = getYCoordinate(prevPoint);
|
||||
|
||||
for (int i = 0; i < data.size(); i++) {
|
||||
var point = data.get(i);
|
||||
var x = getXCoordinate(point);
|
||||
var y = getYCoordinate(point);
|
||||
|
||||
var rangeAlpha = (Math.abs(x - prevX + y - prevY) + 255) % 255;
|
||||
var rangeColor = new Color(0, 30, 254, rangeAlpha);
|
||||
|
||||
graphics.setColor(rangeColor);
|
||||
graphics.fillOval(prevX - rangeW / 2, prevY - rangeW / 2, rangeW, rangeW);
|
||||
|
||||
graphics.setColor(routeColor);
|
||||
graphics.drawLine(prevX, prevY, x, y);
|
||||
|
||||
if (i == 0) {
|
||||
graphics.setFont(font1);
|
||||
graphics.fillOval(x - 5, y - 5, 10, 10);
|
||||
graphics.drawString(String.format(I18N.get("map.drone.start"), droneId), x - 20, y - 10);
|
||||
graphics.setFont(font2);
|
||||
} else if (i == data.size() - 1) {
|
||||
graphics.setFont(font1);
|
||||
graphics.fillOval(x - 5, y - 5, 10, 10);
|
||||
graphics.drawString(String.format(I18N.get("map.drone.end"), droneId), x - 20, y + 30);
|
||||
graphics.setFont(font2);
|
||||
}
|
||||
|
||||
prevPoint = point;
|
||||
prevX = getXCoordinate(prevPoint);
|
||||
prevY = getYCoordinate(prevPoint);
|
||||
}
|
||||
}
|
||||
|
||||
private int getXCoordinate(Point p) {
|
||||
var lonExtent = max_lon - min_lon;
|
||||
return (int) ((IMAGE_PIXEL_SIZE * (p.longitude() - min_lon)) / lonExtent);
|
||||
}
|
||||
|
||||
private int getYCoordinate(Point p) {
|
||||
var latExtent = max_lat - min_lat;
|
||||
return (int) ((IMAGE_PIXEL_SIZE * (p.latitude() - min_lat)) / latExtent);
|
||||
}
|
||||
|
||||
private record TubeStationWithCondition(
|
||||
TubeStation tubeStation, String time, String condition, int speed, long droneId) {
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
var that = (TubeStationWithCondition) o;
|
||||
return Objects.equals(tubeStation.name(), that.tubeStation.name());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(tubeStation.name());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
68
simulation/src/main/resources/logback.xml
Normal file
68
simulation/src/main/resources/logback.xml
Normal file
@ -0,0 +1,68 @@
|
||||
<configuration debug="false" scan="true">
|
||||
|
||||
<statusListener class="ch.qos.logback.core.status.NopStatusListener"/>
|
||||
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<withJansi>false</withJansi>
|
||||
<encoder>
|
||||
<pattern>%msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="REPORT_LOG_FILE" class="ch.qos.logback.core.FileAppender">
|
||||
<file>logs/report.log</file>
|
||||
<append>false</append>
|
||||
<encoder>
|
||||
<pattern>%msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="ROUTE_MAP" class="simulation.logging.RouteMapGenerator">
|
||||
<encoder>
|
||||
<pattern>%msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="DISPATCHER_LOG" class="ch.qos.logback.core.FileAppender">
|
||||
<file>logs/dispatcher.log</file>
|
||||
<append>false</append>
|
||||
<encoder>
|
||||
<pattern>Dispatcher - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="DRONE_LOG" class="ch.qos.logback.classic.sift.SiftingAppender">
|
||||
<discriminator class="simulation.logging.DroneLogPerFile">
|
||||
<defaultValue></defaultValue>
|
||||
</discriminator>
|
||||
<sift>
|
||||
<appender name="DRONE_LOG_${contextName}" class="ch.qos.logback.core.FileAppender">
|
||||
<file>logs/drone-${droneId}.log</file>
|
||||
<append>false</append>
|
||||
<encoder>
|
||||
<pattern>Drone-${droneId} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
</sift>
|
||||
</appender>
|
||||
|
||||
<root level="OFF"/>
|
||||
|
||||
<logger name="Dispatcher" level="INFO">
|
||||
<appender-ref ref="DISPATCHER_LOG"/>
|
||||
<appender-ref ref="ROUTE_MAP"/>
|
||||
</logger>
|
||||
|
||||
<logger name="Report" level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
<appender-ref ref="REPORT_LOG_FILE"/>
|
||||
<appender-ref ref="ROUTE_MAP"/>
|
||||
</logger>
|
||||
|
||||
<logger name="Drone" level="INFO">
|
||||
<appender-ref ref="DRONE_LOG"/>
|
||||
<appender-ref ref="ROUTE_MAP"/>
|
||||
</logger>
|
||||
|
||||
|
||||
</configuration>
|
23
simulation/src/main/resources/simulation-messages.properties
Normal file
23
simulation/src/main/resources/simulation-messages.properties
Normal file
@ -0,0 +1,23 @@
|
||||
# General
|
||||
usage.headerHeading=A Simulator for simulating drone routes.%n
|
||||
# Options
|
||||
d=The path to the drone data
|
||||
t=The path to the tube stations data
|
||||
s=At what time should the simulation terminate
|
||||
p.0=The speed of the simulation time.
|
||||
p.1=0 (no time simulation) -> 1.0 (full time simulation)
|
||||
w.0=Should tube conditions only be reported at waypoints
|
||||
w.1=If false then tube conditions will also be reported along the route to a waypoint when a tube station is within range
|
||||
# Parameters
|
||||
DRONES.0=The drones id's to include in the simulation.
|
||||
DRONES.1=Drone data files must exist for these " +"ids in the --data-dir folder
|
||||
# General
|
||||
starting.message=Waiting for traffic reports...
|
||||
ended.message=Simulation complete.
|
||||
# Errors
|
||||
data.file.not.found.error=Data file for drone %d not found in %s
|
||||
# Route Map
|
||||
map.drone.start=Start @ drone-%d
|
||||
map.drone.end=End @ drone-%d
|
||||
map.file.generation.failed=Image generation failed.
|
||||
map.generation.message=The route map was rendered to %s
|
Reference in New Issue
Block a user