Step 3: Turning a Truck into a GraphAgent

To let our Truck be an agent that lives inside a GraphEnvironment and enjoys the logic of agent transportation along the simulation-time road graph, we will use the GraphAgent class as the parent class.

This inheritance will add the following features to our Truck objects:

  1. The Engine instance is available via the GraphAgent parent class (the Truck.engine field can be removed).

  2. A new moveTo() method to move a Truck to a graph node along the shortest graph path at a given speed.

  3. A new onDestinationReached() method that gets called whenever a Truck reaches its target graph node that is set by the call to the moveTo() method.

Changes to the Truck class (tutorial.model package)

Add new import statements to the 'Truck.java' file in the tutorial.model package:

import java.util.function.BiConsumer;
import com.amalgamasimulation.graphagent.GraphAgent;
import com.amalgamasimulation.graphagent.GeometricGraphPosition;

Let the Truck class be inherited from the GraphAgent platform class:

//  ...
public class Truck extends GraphAgent<Node, Arc> {
//  ...

Change the constructor:

Truck.java, changed constructor
public Truck(String id, String name, double speed, Engine engine) {
    super(engine);
    this.id = id;
    this.name = name;
    this.speed = speed;
}

Remove the engine field from the Truck class (such a field is already inherited from the GraphAgent parent class).

Remove the currentAsset field and the getCurrentAsset() method.

Add a new field to the Truck class that contains the handler that will be called whenever a truck reaches its destination graph node:

private BiConsumer<Truck, GeometricGraphPosition<Node, Arc>> destinationReachedHandler;

Update the onTaskStarted() method:

public void onTaskStarted(TransportationTask task,
        BiConsumer<Truck, GraphAgentPosition<Node, Arc>> destinationReachedHandler) {
    currentActivePeriodStartTime = Optional.of(engine.time());
    currentTask = task;
    taskHistory.add(currentTask);
    this.destinationReachedHandler = destinationReachedHandler;
}

The updated method accepts a destinationReachedHandler callback.

Update the onTaskCompleted() method:

public void onTaskCompleted() {
    activePeriods.add(new ActivePeriod(currentActivePeriodStartTime.get(), engine.time()));
    currentActivePeriodStartTime = Optional.empty();
    currentTask = null;
    destinationReachedHandler = null;
}

The updated method clears the previously stored destinationReachedHandler callback.

Add the onDestinationReached() method (overrides the one inherited from the GraphAgent class):

@Override
public void onDestinationReached(GraphAgentPosition<Node, Arc> destPosition) {
    super.onDestinationReached(destPosition);
    destinationReachedHandler.accept(this, destPosition);
}

In the onDestinationReached() method, the current destinationReachedHandler gets called.

Note how we have switched our Truck from 'dead-reckoning' approach to subscription-based way of handling road events.

Earlier we took the (known) travel distance and used it to calculate the moment of time when the truck will come to a node. Here, on the contrary, we subscribe to the Truck’s "I have arrived to a node" event. This approach is more agile, since now the Truck can act in a less predictable environment, such as a road network with randomly appearing traffic jams that affect the travel time to a degree that is unknown in advance.

Update Truck initialization in the Model class

To place all trucks into their graph environment and let them be initially located in their home node, rewrite the initializeTrucks() method:

Model.java, updated initializeTrucks() method
private void initializeTrucks() {
    for (var scenarioTruck : scenario.getTrucks()) {
        Truck truck = new Truck(scenarioTruck.getId(), scenarioTruck.getName(), scenarioTruck.getSpeed(), engine());
        truck.setGraphEnvironment(graphEnvironment);
        Node homeNode = mapping.nodesMap.get(scenarioTruck.getInitialNode());
        truck.jumpTo(homeNode);
        trucks.add(truck);
    }
}

So, now we have mapped all assets, road graph, and resources from the initial data (in a Scenario object) into the simulation data (in the Model object).

How to handle asset arrival events

Recall that in the previous task version, all the Truck had to do is just wait for a certain amount of time. Trucks did not actually move, their locations were ignored.

The updated transportation task will issue two commands to its truck:

  1. Move to the source asset (i.e., to a Warehouse).

  2. Move to the destination (to a Store).

Both source and destination assets are taken from the TransportationRequest object of the task.

How will the TransportationTask object know when the truck has reached the asset we asked it to visit?

The first idea that might come to mind is to go on using the 'dead reckoning' from Step 1: calculate the overall path length and divide it by the truck’s speed. This will give the travel duration, and the engine.scheduleRelative() logic created earlier can be reused.

But here is a shortcoming: we must be completely sure that the truck will get to the asset at the predicted moment of time. This works well in the simplest cases, with no random variables, queues, etc.

In the current step, we will adhere to another option: a transportation task will subscribe to its truck’s asset arrival events. This reactive approach makes it quite simple to enhance the cargo delivery logic.

The arrival event subscription is pretty straightforward. Since the Truck class inherits from the GraphAgent, it can override its onDestinationReached() method and invoke a callback there. That is exactly what happens in our Truck class now: the callback being called is the destinationReachedHandler passed to the Truck.onTransportationStarted() method. All we need to do is to let the destinationReachedHandler inform the respective TransportationTask object that the truck has arrived somewhere.

Updates to the TransportationTask class

The TransportationTask class gets the most important update in the implementation of the new cargo delivery logic.

Add new import statements to the 'TransportationTask.java' file:

import com.amalgamasimulation.graphagent.GeometricGraphPosition;

After that, replace the execute() method with the following code:

    public void execute(Truck truck) {
        this.truck = truck;
        this.beginTime = model.engine().time();
//      System.out.println("%.3f\tTask #%s : TRANSPORTATION_STARTED. Request #%s; Truck #%s; From %s -> To %s"
//      .formatted(model.engine().time(), getId(), request.getId(), truck.getId(), 
//              request.getSourceAsset().getName(), request.getDestAsset().getName()));
        truck.onTaskStarted(this, this::onDestinationReached);
        truck.moveTo(request.getSourceAsset().getNode(), truck.getSpeed());
    }

Add a new field:

private boolean movingWithCargo;

Add a getter for this new field:

public boolean isMovingWithCargo() {
    return movingWithCargo;
}

Now add a new onDestinationReached() method:

    private void onDestinationReached(Truck truck, GraphAgentPosition<Node, Arc> destPosition) {
        boolean truckIsAtSourceNode = destPosition.getNode().getValue().equals(request.getSourceAsset().getNode());
        if (truckIsAtSourceNode) {
            movingWithCargo = true;
            truck.moveTo(request.getDestAsset().getNode(), truck.getSpeed());
        } else {
            truck.onTaskCompleted();
            request.setCompletedTime(model.engine().time());
            taskCompletedHandler.accept(this);
//          System.out.println("%.3f\tTask #%s : TRANSPORTATION_FINISHED".formatted(model.engine().time(), getId()));
        }
    }

The execute() method notifies the Truck that the transportation has started. It then asks the truck to move to the source node, i.e., to some warehouse.

Note how the Truck.onTaskStarted() method is called. This line reads as follows: 'whenever the truck reaches the destination specified in the moveTo() method call, call the TransportationTask.onDestinationReached() method'.

What if several distinct paths exist between two road network nodes? Which path is selected? By default, when a GraphAgent is instructed to moveTo() a graph node, the shortest path is used.

There are two moveTo() method calls in the TransportationTask class:

  • The first moveTo() call is in the execute() method, it instructs the Truck to get to the source node (i.e., to a warehouse).

  • The second moveTo() call appears in the onDestinationReached() method and instructs the Truck to move to the transportation task’s destination node (i.e., to a store).

The word 'destination' in the name of the Truck.onDestinationReached() method inherited from the GraphAgent class might be a little confusing. 'Destination' in this context is the node where the Truck as a GraphAgent arrives, with or without cargo. So, the onDestinationReached() method gets called twice per each transportation task: when the (empty) truck gets to the 'warehouse node’to fetch the cargo, and when it comes with cargo to the 'store node'. To tell one case from another, we analyze the destPosition.getNode().getValue(), i.e., the 'current' graph position where the truck has just arrived.

Mind the data types:

  • destPosition denotes a position of a graph agent in a graph (current arc and/or node).

  • destPosition.getNode() returns a node of the Amalgama Platform Graph.Node class.

  • destPosition.getNode().getValue() returns a node of type tutorial.model.Node. Note that tutorial.model.Node is exactly the class that stands as the first generic type argument in the type of the destPosition parameter of the onDestinationReached() method.

Check the result

Start the program and check the console output for "12 hours" simulation:

Scenario            	Trucks count	SL	Expenses	Expenses/SL
scenario            	           1	57,14%	$ 420,00	$ 7,35

The output has changed: now we have exactly the same results as in Part 1, which is a very good sign.

This time we did not hard-code the distances between graph node, unlike in Part 1 of the tutorial. Instead, we initialized the positions of nodes and connected them with arcs, and the GraphEnvironment was able to do the rest.

Let’s make sure that we also get the same results as before when running scenario analysis for the varying number of trucks.

Go to the Main class and replace the main() method:

Main.java, main() method
public static void main(String[] args) {
    runScenarioAnalysis();
}

This time, this should appear in the console output:

Scenario            	Trucks count	SL	Expenses	Expenses/SL
scenario            	           1	0,27%	$ 26 040,00	$ 96 152,70
scenario            	           2	1,02%	$ 52 008,66	$ 51 211,19
scenario            	           3	2,23%	$ 77 859,98	$ 34 848,24
scenario            	           4	5,35%	$ 103 458,21	$ 19 342,76
scenario            	           5	91,08%	$ 119 586,46	$ 1 313,05
scenario            	           6	99,93%	$ 127 026,46	$ 1 271,12
scenario            	           7	100,00%	$ 134 428,96	$ 1 344,29
scenario            	           8	100,00%	$ 141 806,46	$ 1 418,06
scenario            	           9	100,00%	$ 149 283,96	$ 1 492,84
scenario            	          10	100,00%	$ 156 686,46	$ 1 566,86

Again, the results are the same as in Part 1.

Now the road graph can become more complex, and we can delegate finding the best path and calculating travel duration to the GraphEnvironment.