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:
-
The
Engine
instance is available via theGraphAgent
parent class (theTruck.engine
field can be removed). -
A new
moveTo()
method to move a Truck to a graph node along the shortest graph path at a given speed. -
A new
onDestinationReached()
method that gets called whenever a Truck reaches its target graph node that is set by the call to themoveTo()
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:
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:
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:
-
Move to the source asset (i.e., to a
Warehouse
). -
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 theexecute()
method, it instructs theTruck
to get to the source node (i.e., to a warehouse). -
The second
moveTo()
call appears in theonDestinationReached()
method and instructs theTruck
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 PlatformGraph.Node
class. -
destPosition.getNode().getValue()
returns a node of typetutorial.model.Node
. Note thattutorial.model.Node
is exactly the class that stands as the first generic type argument in the type of thedestPosition
parameter of theonDestinationReached()
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:
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.