Step 1: Run server-side simulation asynchronously

Add WebFlux dependency

The web application backend we are building now should be able to give back the current state of the ongoing server-side simulation. We will use Spring WebFlux to stream data to frontend in real time.

Find the spring-boot-starter-web dependency in the <dependencies> list of your pom.xml file. Replace it with spring-boot-starter-webflux.

The updated dependency should look like this:

pom.xml, WebFlux dependency
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Make sure the code still compiles and runs:

  1. To start the application, use your IDE to run the web service using its BackendApplication class, or execute the mvn spring-boot:run command from the console.

  2. Open the http://localhost:8080/hello URL in your browser - it should display the 'Hello, World' message.

Implement the API

We will implement API endpoints mentioned in the introduction page of this tutorial.

First, create a new ExperimentService class in the web.tutorial.backend.service package:

@Service
public class ExperimentService {

  private static final int UPDATE_ANIMATION_INTERVAL_MS = 100;

  @Autowired
  private ExperimentService experimentService;

  @Autowired
  private ScenarioMapper scenarioMapper;

  @Autowired
  private EmbeddedScenarioProvider embeddedScenarioProvider;
}

Then, create a new ExperimentController class in the web.tutorial.backend.controller package:

@RestController
@RequestMapping("/")
@CrossOrigin
public class ExperimentController {
}

Add appropriate import statements to both files.

Embedded scenarios API

Add a new method to the ExperimentController class:

ExperimentController, GET /scenarios
@GetMapping(value = "/scenarios")
public List<String> getScenarios() {
  return embeddedScenarioProvider.readAllDTO().stream().map(s -> s.name()).toList();
}

Simulation initialization API

ExperimentService

In the ExperimentService class, add a new method that will initialize the active simulation experiment associated with the ExperimentService instance:

ExperimentService class, initSimulation() method
public void initSimulation(Scenario scenario) {
  simulationId.incrementAndGet();
  simulationResetHandlers.forEach(Runnable::run);
  simulationResetHandlers.clear();
  try {
    this.scenario = scenario;
    if (engine != null) {
      engine.stop();
      while (engine.isRunning()) {
        Thread.sleep(1000);
      }
      engine.reset();
    }
    engine = new Engine();
    engine.setFastMode(false);
    experimentRun = new ExperimentRun(scenario, engine);
    model = experimentRun.getModel();
  } catch (Exception e) {
    stopSimulation();
    throw new ScenarioErrorException(scenario.getName(), e.getMessage());
  }
}

ExperimentController

Add these new methods to the ExperimentController class:

ExperimentController class, initialization API
@PostMapping(value = "/init/embedded/{name}")
public ResponseEntity<ScenarioDTO> initSimulation(@PathVariable String name) {
  List<ScenarioDTO> scenarios = embeddedScenarioProvider.readAllDTO();
  ScenarioDTO scenarioDTO = scenarios.stream().filter(s -> s.name().equals(name)).findAny().orElseThrow();
  experimentService.initSimulation(scenarioMapper.readFromDTO(scenarioDTO));
  return new ResponseEntity<>(scenarioDTO, HttpStatus.OK);
}

@PostMapping(value = "/init/custom")
public ScenarioDTO initSimulationByUploadedScenario(@RequestPart("file") FilePart file) throws IOException {
  Path tempFile = Files.createTempFile(null, ".xlsx");
  file.transferTo(tempFile).subscribe();
  EMFExcelLoader<Scenario> loader = createExcelTransform().createLoader(tempFile.toString());
  loader.load();
  Scenario scenario = loader.getRootObject();
  experimentService.initSimulation(scenario);
  return scenarioMapper.convertToDTO(scenario);
}

private EMFExcelTransform<Scenario> createExcelTransform() {
  EMFExcelTransform<Scenario> emfExcelTransform =
      new EMFExcelTransform<Scenario>()
          .setRootClass(DatamodelPackage.eINSTANCE.getScenario())
          .addPackage(EcoreutilsPackage.eINSTANCE)
          .addPackage(CalendardatamodelPackage.eINSTANCE)
          .addPackage(RandomdatamodelPackage.eINSTANCE)
          .addPackage(TimeseriesdatamodelPackage.eINSTANCE)
      ;
  return emfExcelTransform;
}

Simulation flow API

Here we are adding methods to start (resume), stop, and reset the simulation and to change the simulation speed.

ExperimentService

Add these methods to the ExperimentService class:

ExperimentService class, simulation flow control methods
public void startSimulation() {
  if (engine != null && engine.time() < engine.dateToTime(scenario.getEndDate())) {
    engine.run(false);
  }
}

public void stopSimulation() {
  if (engine == null) {
    return;
  }
  engine.stop();
  while (engine.isRunning()) {
    try {
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

public void resetSimulation() {
  initSimulation(this.scenario);
}

public SimulationSpeedDTO runFaster() {
  if (engine == null) {
    return new SimulationSpeedDTO(1.0);
  }
  double newTimeScale = Math.min(MAX_SIMULATION_SPEED, engine.timeScale() * 2);
  engine.setTimeScale(newTimeScale);
  return new SimulationSpeedDTO(newTimeScale);
}

public SimulationSpeedDTO runSlower() {
  if (engine == null) {
    return new SimulationSpeedDTO(1.0);
  }
  double newTimeScale = Math.max(MIN_SIMULATION_SPEED, engine.timeScale() / 2);
  engine.setTimeScale(newTimeScale);
  return new SimulationSpeedDTO(newTimeScale);
}

ExperimentController

Add new methods to the ExperimentController class:

ExperimentController class, simulation flow API
@PostMapping(value = "/start")
public void startSimulation() {
  experimentService.startSimulation();
}

@PostMapping(value = "/stop")
public void stopSimulation() {
  experimentService.stopSimulation();
}

@PostMapping(value = "/resume")
public void resumeSimulation() {
  experimentService.startSimulation();
}

@PostMapping(value = "/reset")
public void reset() {
  experimentService.resetSimulation();
}

@PostMapping(value = "/faster")
public SimulationSpeedDTO faster() {
  return experimentService.runFaster();
}

@PostMapping(value = "/slower")
public SimulationSpeedDTO slower() {
  return experimentService.runSlower();
}

Real time simulation status API

To see what is going in in the model during simulation, we will add new methods that supply real time information.

This will be the first time that reactive approach is applied in our application. Namely, these new API methods will not return just a single value, but rather a stream of values that is populated in real time as simulation goes on.

DTO

Let’s start with new state transfer objects (DTOs).

Create a new package: web.tutorial.backend.dto.state.

Add the TruckDTO class:

TruckDTO.java
package web.tutorial.backend.dto.state;

public record TruckDTO(
    String id,
    String name,
    double currentPositionX,
    double currentPositionY,
    double currentHeading,
    boolean withCargo,
    double expenses,
    double distanceTraveled) {}

A TruckDTO object contains the overall statistics for a truck. Sounds familiar? Yes, we’ve seen it before in the 'Trucks' table in the desktop application from Part 3 of this tutorial.

Besides, TruckDTO contains current coordinates of a Truck that will let us show the road graph animation, much like in the map animation in the desktop application.

Add the TaskDTO class:

TaskDTO.java
package web.tutorial.backend.dto.state;

import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;

public record TaskDTO(
    String id,
    String truck,
    String source,
    String destination,
    @JsonFormat(pattern="yyyy-MM-dd'T'HH:mm:ss")
    LocalDateTime created,
    @JsonFormat(pattern="yyyy-MM-dd'T'HH:mm:ss")
    LocalDateTime started,
    @JsonFormat(pattern="yyyy-MM-dd'T'HH:mm:ss")
    LocalDateTime deadline,
    @JsonFormat(pattern="yyyy-MM-dd'T'HH:mm:ss")
    LocalDateTime completed,
    String status) {}

Remember the Gantt Chart from Part 3? A TaskDTO object, when it denotes a completed task, contains information for one Gantt Chart item.

We will also display all tasks (not only completed ones) in a tab similar to the Tasks part in the desktop application.

Add the StatsDTO class:

StatsDTO.java
package web.tutorial.backend.dto.state;

public record StatsDTO(
    int serviceLevelPct,
    double expenses) {}

Objects of this class contain the data that we can see in the Summary statistics part in the desktop application.

Add the SimulationStateUpdateDTO class:

SimulationStateUpdateDTO.java
package web.tutorial.backend.dto.state;

import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;
import java.util.List;

public record SimulationStateUpdateDTO (
    Integer simulationProgressPct,
    @JsonFormat(pattern="yyyy-MM-dd'T'HH:mm:ss")
    LocalDateTime modelDateTime,
    StatsDTO stats,
    List<TruckDTO> truckStats
){}

Instances of this class hold all information that will be sent to frontend regularly.

ExperimentService

Let’s switch to the ExperimentService class.

Add the following code to it:

ExperimentService class, simulation info methods
 (1)
 public int getSimulationId() {
   return simulationId.get();
 }

 (2)
 public SimulationStateUpdateDTO getSimulationState() {
   if (engine == null) {
     return null;
   }
   Long start = System.currentTimeMillis();
   SimulationStateUpdateDTO result;
   if (engine.isRunning()) {
     SynchronousQueue<SimulationStateUpdateDTO> stateUpdateObject = new SynchronousQueue<>();
     engine.visualize(start, () -> {
       try {
         var stateUpdateDTO = new SimulationStateUpdateDTO(
             getSimulationProgressPct(),
             getModelDateTime(),
             getStats(),
             getTrucksStats());
         stateUpdateObject.put(stateUpdateDTO);
       } catch (Exception e) { }
     });
     try {
       result = stateUpdateObject.take();
     } catch (Exception e) {
       return null;
     }
   } else {
     result = new SimulationStateUpdateDTO(
         getSimulationProgressPct(),
         getModelDateTime(),
         getStats(),
         getTrucksStats());
   }
   return result;
}

 public double getModelTime() {
   return model == null ? 0.0 : model.engine().time();
 }

 private int getSimulationProgressPct() {
   if (model == null) {
     return 0;
   }
   return (int) Math.ceil(Math.min(100, 100 * engine.time() / engine.dateToTime(scenario.getEndDate())));
 }

 private LocalDateTime getModelDateTime() {
   if (engine == null) {
     return null;
   }
   return engine.timeToDate(engine.time());
 }

 private StatsDTO getStats() {
   if (engine == null) {
     return null;
   }
   return new StatsDTO(
       (int)Math.round(model.getStatistics().getServiceLevel() * 100.0),
       Math.round(model.getStatistics().getExpenses() * 100.0) / 100.0);
 }

 private List<TruckDTO> getTrucksStats() {
   if (engine == null) {
     return List.of();
   }
   return model.getTrucks()
       .stream()
       .map(truck -> new TruckDTO( truck.getId(),
           truck.getName(),
           truck.getCurrentAnimationPoint().getX(),
           truck.getCurrentAnimationPoint().getY(),
           truck.getCurrentAnimationHeading(),
           truck.getCurrentTask() != null && truck.getCurrentTask().isMovingWithCargo(),
           Math.round(truck.getExpenses() * 100.0) / 100.0,
           Math.round(truck.getDistanceTraveled() * 100.0) / 100.0))
       .toList();
 }

(3)
 public void addSimulationResetHandler(Runnable handler) {
   simulationResetHandlers.add(handler);
 }

 public void addTaskStateChangeHandler(Consumer<TaskDTO> handler) {
   if (model != null) {
     model.addTaskStateChangeHandler(task -> handler.accept(convertToDto(task)));
   }
 }

 public void clearTaskStateChangeHandlers() {
   if (model != null) {
     model.clearTaskStateChangeHandlers();
   }
 }

 (4)
 public List<TaskDTO> getAllTasks() {
   if (model == null) {
     return List.of();
   }
   return model.getTransportationTasks().stream().map(this::convertToDto).toList();
 }

 private TaskDTO convertToDto(TransportationTask task) {
   var request = task.getRequest();
   var truck = task.getTruck();
   return new TaskDTO(task.getId(),
       truck == null ? "" : truck.getName(),
       request.getSourceAsset().getName(),
       request.getDestAsset().getName(),
       engine.timeToDate(request.getCreatedTime()),
       engine.timeToDate(task.getBeginTime()),
       engine.timeToDate(request.getDeadlineTime()),
       request.isCompleted() ? engine.timeToDate(request.getCompletedTime()) : null,
       task.getStatus().toString());
 }
1 The getSimulationId() method denotes the current integer-valued ID of the active simulation experiment. Each time a simulation is reset, this ID is incremented. We will use this ID in the controller to detect an outdated simulation so as to stop streaming its real-time information (statistics, etc.).
2 The getSimulationState() method returns current simulation statistics, including truck positions, model time, etc. This method is called many times per second to update the frontend animation and tabular data in real time.
3 The addSimulationResetHandler() method adds a callback for simulation resetting. The addTaskStateChangeHandler() and clearTaskStateChangeHandlers methods add/remove callbacks for any task-related events happening inside the simulation model. All three methods will help us create a reactive data stream for the frontend and populate it with tasks in real time.
4 The getAllTasks() method will be used once in the /taskstream endpoint to fetch all tasks that were created since the user first called the /start endpoint. That way, users won’t miss any task data they might not have seen if they called /start before /taskstream. Note that there will be exactly one getAllTasks() call per /taskstream execution; all new task information that appears thereafter will be supplied by the simulation model sequentially (we added the addTaskStateChangeHandler() method to subscribe to task events).

ExperimentController

We will now publish this information via the ExperimentController class.

Add the following code to this class:

ExperimentController class, simulation info API
(1)
@GetMapping(value = "/updatestream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<SimulationStateUpdateDTO> getSimulationStateUpdates() {
  final int simulationId = experimentService.getSimulationId();
  return Flux .interval(Duration.ofMillis(UPDATE_ANIMATION_INTERVAL_MS))
              .limitRate(1)
              .onBackpressureDrop()
              .takeWhile(t -> simulationId == experimentService.getSimulationId())
              .distinctUntilChanged(p -> experimentService.getModelTime())
              .flatMap(this::mapLongCounterToMonoWithSimulationState);
}

private Mono<SimulationStateUpdateDTO> mapLongCounterToMonoWithSimulationState(Long t) {
  return Mono .just(t)
              .publishOn(Schedulers.boundedElastic())
              .mapNotNull(v -> experimentService.getSimulationState())
              .timeout(Duration.ofMillis(UPDATE_ANIMATION_INTERVAL_MS * 2), Mono.empty());
}

(2)
@GetMapping(value = "/taskstream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<TaskDTO> getTaskUpdates() {
  Flux<TaskDTO> existingTasksFlux = Flux.fromIterable(experimentService.getAllTasks());
  Flux<TaskDTO> taskUpdatesFlux = Flux .<TaskDTO>create(sink -> {
                  experimentService.addTaskStateChangeHandler(taskDto -> sendTaskMessage(taskDto, sink));
                  experimentService.addSimulationResetHandler(() -> sink.complete());
                })
              .doFinally(signalType -> experimentService.clearTaskStateChangeHandlers());
  return existingTasksFlux.concatWith(taskUpdatesFlux);
}

private void sendTaskMessage(TaskDTO taskDto, FluxSink<TaskDTO> sink) {
  sink.next(taskDto);
}
1 The getSimulationStateUpdates() pushes new data to the stream regularly, no matter if there were any changes.
2 The getTaskUpdates(), on the other hand, only posts an event to the stream when some transportation task is created, finished, or otherwise changed.

Exception handling

Add these two methods to the ExperimentController class:

ExperimentController class, exception handling
@ExceptionHandler(ScenarioErrorException.class)
public ResponseEntity<?> handleScenarioErrorException(ScenarioErrorException ex) {
  return new ResponseEntity<>(
    new ExceptionDTO(ex.getScenarioName(), ex.getMessage()), HttpStatus.BAD_REQUEST);
}

@ExceptionHandler
public <X extends Exception> ResponseEntity<?> handleAnyException(X ex) {
  return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST);
}

Generating API documentation

To make backend debugging easier, add the following dependency to your pom.xml file:

pom.xml, SpringDoc dependency
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
    <version>2.1.0</version>
</dependency>

After restarting, the following URLs will be available:

  1. http://localhost:8080/v3/api-docs - lists backend API as a JSON.

  2. http://localhost:8080/webjars/swagger-ui/index.html - shows Swagger UI for the backend API.

Swagger UI will let you see the API your backend is publishing and also connect to them directly from your browser.

Check the result

  1. Start the backend application.

  2. Use an API tool (such as Postman or Curl) to send the simulation initialization request:

    POST http://localhost:8080/init/embedded/scenario
  3. Send a request to get real-time tasks information. Note that this request receives a data stream, so do not expect it to complete instantly. Keep the window/tab with this request open in your API tool and do not terminate the request.

    GET http://localhost:8080/taskstream
  4. Send a request to get real-time simulation updates. As with task information, keep the window/tab with this request open in your API tool and do not terminate the request.

    GET http://localhost:8080/updatestream
  5. Send another request to start the simulation:

    POST http://localhost:8080/start

You will start receiving messages about tasks and simulation events.

Here is what /taskstream output should look like:

Tasks updated online

And this is the /updatestream expected data:

Simulation information updated online