Skip to main content

Integrating Data Rate

Now is where the fun really begins! Although having the data rate in and out of our SSR is useful, we are often more concerned with the total amount of volume we have in our SSR in order to make sure we don't over fill it and have sufficient downlink opportunities to get all the data we collected back to Earth. In order to compute total volume, we must figure out a way to integrate our RecordingRate. It turns out there are many different methods in Aerie you can choose to arrive at the total SSR volume, but each method has its own advantages and drawbacks. We will explore 4 different options for integration with the final option, a derived Polynomial resource, being our recommended approach. As we progress through the options, you'll learn about a few more features of the resource framework that you can use for different use cases in your model including the use of Reactions and daemon tasks.

Method 1 - Increase volume within activity

The simplest method for performing an "integration" of RecordingRate is to compute the integral directly within the effect model of the activities who change the RecordingRate. Before we do this, let's make sure we have a data volume resource in our DataModel class. For each method, we are going to build a different data volume resource so we can eventually compare them in the Aerie UI. As this is our simplest method, let's call this resource SSR_Volume_Simple and make it store volume in Gigabits (Gb). Since we are going to directly effect this resource in our activities, this will need to be a MutableResource. The declaration looks just like RecordingRate

public MutableResource<Discrete<Double>> SSR_Volume_Simple; // Gigabits

as does the definition, initialization, and registration in the constructor:

SSR_Volume_Simple = resource(discrete(0.0));
registrar.discrete("SSR_Volume_Simple", SSR_Volume_Simple, new DoubleValueMapper());

Taking a look at our CollectData activity, we can add the following line of code after the delay() within its effect model (run() method) to compute the data volume resulting from the activity collecting data at a constant duration over the full duration of the activity.

DiscreteEffects.increase(model.dataModel.SSR_Volume_Simple,this.rate*duration.ratioOver(SECONDS)/1000.0);

This line will increase SRR_Volume_Simple at the end of the activity by rate times duration divided by our magic number to convert Mb to Gb. Note that the duration object has some helper functions like ratioOver to help you convert the duration type to a double value.

There are a few notable issues with this approach. The first issue is that when a plan is simulated, the data volume of the SSR will only increase at the very end of the activity even though in reality, the volume is growing linearly in time throughout the duration of the activity. If your planners only need this level of fidelity to do their work, this may be ok. However, if your planners need a little more fidelity during the time span of the activity, you could spread out the data volume accumulation over many steps. That would look something like this in code (for the purposes of this tutorial, there is no need to actually implement this example)

int numSteps = 20;
Duration step_size = Duration.divide(duration, numSteps);
for (int i = 0; i < numSteps; i++) {
delay(step_size);
DiscreteEffects.increase(model.dataModel.SSR_Volume_Simple,this.rate*step_size.ratioOver(SECONDS)/1000.0);
}

which would replace the delay() and the single data volume increase line from above. The resulting timeline for SSR_Volume_Simple would look like a stair step with the number of steps equal to numSteps. It's important to remember we are still using a Discrete resource, so the resource is stored as a constant, "step-function" profile in Aerie. We will show the use of a Polynomial resource in our final method to truly store and view data volume as a linear profile.

Another issue with this approach is that is does not transfer well to activities like ChangeMagMode that alter the RecordingRate and do not return the rate back to its original value at the end of the activity (i.e. activities whose effects on rate are not contained within the time span of the activity). In order to compute the magnetometer's contribution to the data volume in ChangeMagMode, we would need to multiply the currentRate by the duration since the last mode change, or if no mode change has occurred, the beginning of the plan. While this is possible by using a Clock resource to track time between mode changes, the ChangeMagMode activity would now requires additional context about the plan that would otherwise be unnecessary.

A third issue to note is that the computation of RecordingRate and SSR_Volume_Simple are completely separate, and both of them live within the activity effect model. In reality, these quantities are very much related and should be tied together in some way. The relationship between rate and volume is activity independent, and thus it makes more sense to define that relationship in our DataModel class instead of the activity itself.

Given these issues, we will hold off on implementing this approach for ChangeMagMode and move forward to trying out our next approach.

Method 2 - Sample-based volume update

Another method to integration we can take is a numerical approach where we compute data volume by sampling the value of the RecordingRate at a fixed interval across the entire plan. In order to implement this method, we can spawn() a simple task from our top-level Mission class that runs in the background while the plan is being simulated, which is completely independent of activities in the plan. Such tasks are known as daemon tasks, and your mission model can have an arbitrary number of them.

Before we create this task, let's add another discrete MutableResource of type double called SSR_Volume_Sampled to the DataModel class. Just as with other resources we have made, the declaration will look like

public MutableResource<Discrete<Double>> SSR_Volume_Sampled; // Gigabits

and the definition, initialization, and registration in the constructor will be

SSR_Volume_Sampled = resource(discrete(0.0));
registrar.discrete("SSR_Volume_Sampled", SSR_Volume_Sampled, new DoubleValueMapper());

In addition to the resource, let's add another member variable to specify the sampling interval we'd like for our integration. Choosing 60 seconds will result in the follow variable definition

private final Duration INTEGRATION_SAMPLE_INTERVAL = Duration.duration(60, Duration.SECONDS);

Staying in the DataModel class, we can can create a member function called integrateSampledSSR that has no parameters, which we will spawn from the Mission class shortly. For the sake of simplicity, we will define this function to take the "right" Reimann Sum (a "rectangle" rule approximation) of the RecordingRate over time. The implementation of this function looks like this:

public void integrateSampledSSR() {
while(true) {
delay(INTEGRATION_SAMPLE_INTERVAL);
Double currentRecordingRate = currentValue(RecordingRate);
DiscreteEffects.increase(SSR_Volume_Sampled, currentRecordingRate *
INTEGRATION_SAMPLE_INTERVAL.ratioOver(Duration.SECONDS) / 1000.0); // Mbit -> Gbit
}
}

As a programmer, you may be surprised to see an infinite while loop, but Aerie will shut down this task, effectively breaking the loop, once the simulation reaches the end of the plan. Within the loop, the first thing we do is delay() by our sampling interval and then retrieve the current value of RecordingRate. Finally, we sum up our rectangle by multiplying the current rate by the sampling interval. We could have easily chosen to use other numerical methods like the "trapezoid" rule by storing the previous recording rate in addition to the current rate, but what we did is sufficient for now.

The final piece we need to build into our model to get this method to work is a simple spawn within the Mission class constructor to our integrateSampledSSR method.

spawn(dataModel::integrateSampledSSR);

There are some notable issues to this approach for integration. First of all, this approach is truly an approximation, so the resulting volume may not be the actual volume if the sampled points don't align perfectly with the changes in RecordingRate. Secondly, the fact we are sampling at a fixed time interval means we could be computing many more time points than we actually need if the recording rate isn't changing between time points. If you were to try to scale up this approach, you might run into performance issues with your model where simulation takes much longer than it needs to.

Despite these issues daemon tasks are a very effective tool in a modelers tool belt for describing "background" behavior of your system. Examples for a spacecraft model could include the computation of geometry, battery degradation over time, environmental effects, etc.

Method 3 - Update volume upon change to rate

If you are looking for an efficient, yet accurate way to compute data volume from RecordingRate, one method you could take is to set up trigger that calls a function whenever RecordingRate changes and then computes volume by multiplying the rate just before the latest change by the duration that has passed since the last change. Fortunately, there is a fairly easy way to do this in Aerie's modeling framework.

Let's begin by creating one more discrete MutableResource called SSR_Volume_UponRateChange in our DataModel class (refer back to previous instances in this tutorial for how to declare and define one of these). In addition to our volume resource, we are also going to need a Clock resource to help us track the time between changes to RecordingRate. Since this resource is more of a "helper" resource and doesn't need to be exposed to our planners, we'll make it private and not register it to the UI. Declaring and defining a Clock resource is not much different than declaring a Discrete except you don't have to specify a primitive type. The declaration looks like this

private MutableResource<Clock> TimeSinceLastRateChange;

and the definition in the constructor looks like this

TimeSinceLastRateChange = resource(Clock.clock(Duration.ZERO));
tip

The right Clock definition can be referenced with the following import statement at the top of DataModel.java

import gov.nasa.jpl.aerie.contrib.streamline.modeling.clocks.Clock;

This will start a "stopwatch" right at the start of the plan so we can track the time between the start of the plan and the first time RecordingRate is changed. We'll also need one more member variable of type Double, which we'll call previousRate to keep track of the previous value of RecordingRate for us.

private Double previousRecordingRate = 0.0;

Our next step is to build our trigger to react when there is a change to RecordingRate. We can do this by leveraging the wheneverUpdates() static method available within the framework's Reactions class

Reactions.wheneverUpdates(RecordingRate, this::uponRecordingRateUpdate);
note

The Reactions class has a couple more static methods that a modeler may find useful. The every() method allows you to specify a duration to call a recurring action (we could have used this instead of our spawn() for the sampled integration method). The whenever() method allows you to specify a Condition, which when met, would trigger an action of your choosing. An example of a condition could be when a resource reaches a certain threshold.

As you can see, this method takes a resource as its first argument and some Runnable, like a function call, as it's second argument. We have specified that the function uponRecordingRateUpdate be called, so now we have to implement that function within our DataModel class. The implementation of that function is below, which we will walk through line by line.

public void uponRecordingRateUpdate() {
// Determine time elapsed since last update
Duration t = currentValue(TimeSinceLastRateChange);
// Update volume only if time has actually elapsed
if (!t.isZero()) {
DiscreteEffects.increase(this.SSR_Volume_UponRateChange,
previousRecordingRate * t.ratioOver(Duration.SECONDS) / 1000.0); // Mbit -> Gbit
}
previousRecordingRate = currentValue(RecordingRate);
// Restart clock (set back to zero)
ClockEffects.restart(TimeSinceLastRateChange);
}

When the RecordingRate resource changes, the first thing we do is determine how much time has passed since it last changed (or since the beginning of the plan). If no time has passed, we don't want to re-integrate and double count volume, but if time has passed, we do our simple integration by multiplying the previous rate by the elapsed time since the value of rate changed. We then store the new value of rate as the previous rate and restart our stopwatch to we get the right time next time the rate changes.

And that's it! Now, every time RecordingRate changes, the SSR volume will update to the correct volume. However, the volume is still a discrete resource, so volume will only change as a step function at time points where the rate changes. Nonetheless, since RecordingRate is piece-wise constant, you'll get the right answer for volume with no error at those time points.

Method 4 - Derived volume from polynomial resource

We have finally arrived at the final method we'll go through for integrating RecordingRate, and in some ways, this one is the most straightforward. We will define our data volume as polynomial resource, SSR_Volume_Polynomial, which we can build by using an integrate() static method provided by the PolynomialResources class. As a polynomial resource, we will actually see the volume increase linearly over time as opposed to in discrete chunks. Since SSR_Volume_Polynomial will be derived directly from RecordingRate, we can make this a Resource as opposed to a MutableResource. The declaration of our new resource looks like this

public Resource<Polynomial> SSR_Volume_Polynomial;  // Gigabits

while the definition and registration in the constructor of our DataModel class look like this

SSR_Volume_Polynomial = scale(
PolynomialResources.integrate(asPolynomial(this.RecordingRate), 0.0), 1e-3); // Gbit
registrar.real( "SSR_Volume_Polynomial", PolynomialResources.assumeLinear(SSR_Volume_Polynomial));

Breaking down the definition, we see the integrate() function takes the resource to integrate as the first argument, but that argument requires the resource to be polynomial as well. Fortunately, there is a static method in PolynomialResources called asPolynomial() that can convert discrete resources like RecordingRate to polynomial ones. The second argument is the initial value for the resource, which we have been assuming is 0.0 for data volume. The integrate() function is then wrapped by scale(), another handy static method in PolynomialResources to convert our resource from Megabit to Gigabit.

The resource registration is also slightly different than what we have seen thus far as we are using a real() method as opposed to discrete() and we have to wrap our resource with yet another static helper method in PolynomialResources called assumeLinear(). The reason we have to do this is that the UI currently does not have support for Polynomial resources and can only render timelines as linear or constant segments. In our case, SSR_Volume_Polynomial is actually linear anyway, so we are not "degrading" our resource by having to make this down conversion.

Now in reality, our on-board SSR is going to have a max capacity, and if data is removed from the SSR, we want to make sure our model stops decreasing the SSR volume once it reaches 0.0. By good fortune, the Aerie framework includes another static method in PolynomialResources called clampedIntegral() that allows you to build a resource that takes care of all that messy logic to make sure you are adhering to your min/max limits.

If we wanted to build a "clamped" version of SSR_Volume_Polynomial, it would look something like this

var clampedIntegrate = PolynomialResources.clampedIntegrate( scale(
asPolynomial(this.RecordingRate), 1e-3),
PolynomialResources.constant(0.0),
PolynomialResources.constant(250.0),
0.0);
SSR_Volume_Polynomial = clampedIntegrate.integral();

The second and third arguments of clampedIntegrate() are the min and max bounds for the integral and the final argument is the starting value for the resource as it was in integrate(). The clampedIntegrate() method actually returns a record of three resources:

  • integral – The clamped integral value (i.e. the main resource of interest)
  • overflow – The rate of overflow when the integral hits its upper bound. You can integrate this to get cumulative overflow.
  • underflow – The rate of underflow when the integral hits its lower bound. You can integrate this to get cumulative underflow.

As expected, the integral() resource is mapped to SSR_Volume_Polynomial to complete its definition. To make things more interesting for our upcoming comparison of integration methods, use the "clamped" approach above so we can see how that approach enforces a data volume capacity.