Soaring instrumentation SDK
The soaring instrumentation toolkit is a small library of Nasal classes that you can use for adding specialized soaring instruments to your glider. The library is comprised of several building blocks that you can connect together in different ways in order to get the desired functionality.
In order to use the library, you will need to write a Nasal script that will be loaded together with your aircraft. You do so by referencing this script in the <Nasal> section of your aircraft definition XML file. But don´t be scared, the scripts will be very simple.
Basic concepts
The library includes source components that generate signals by themselves, and filter components that process their input signals to produce an output signal. You define your instrument by connecting these elementary components together in a script. The library also contains other helper functions and objects that address common tasks you will need to perform. Lets see how all this work with a simple example:
io.include("Aircraft/Generic/soaring-instrumentation-sdk.nas");
var probe = TotalEnergyProbe.new();
var needle = Dampener.new(
input: probe,
dampening: 2.7,
on_update: update_prop("instrumentation/vario/te_reading"));
var instrument = Instrument.new(
components: [probe, needle],
enable: 1);
This code implements a basic total energy compensated variometer like the one you can see in the picture above labeled with number two. What the code does is to write the needle reading in meters per second to the property "instrumentation/vario/te_reading". Lets see step by step what the script is doing.
io.include("Aircraft/Generic/soaring-instrumentation-sdk.nas");
This line loads the SDK functionality into your script, making all SDK objects available for you to use. You must add this line to your scripts in order to use the library.
var probe = TotalEnergyProbe.new();
This line creates one object from the library, the TotalEnergyProbe, and assigns it to variable probe
. You will use probe
as a handle to refer to this part of the instrument when interacting with other library components.
The TotalEnergyProbe is a source component that provides the Total Energy variation of the aircraft at its output. It does so by internally reading airspeed and altitude from the property tree, and doing some simple computations. The output value is available inside the object, and in order to access it you can either connect another component or write a property, we will see how in a second.
var needle = Dampener.new(
input: probe,
dampening: 2.7,
on_update: update_prop("instrumentation/vario/te_reading"));
The next block creates a Dampener object and connects it to the TotalEnergyProbe we had created before. Dampener objects implement an exponential filter that can approximate the mechanical dampening of a needle.
We specify three things when creating the dampener:
- input
- where we are connecting the dampener.
- dampening
- time constant of the filter in seconds.
- on_update
- what to do when the filters output gets updated.
As you can see, in this example we are connecting the Dampener to the TotalEnergyProbe. The goal is to apply a dampening filter to the raw TE computation in order to simulate the response of a mechanical gauge. We specify a time constant of 2.7 seconds, which is roughly the response time on typical Winter variometers.
We also specify an action to take on every update, and that is to update a certain property. This is the property that you will use to drive the needle animation in your cockpit.
You don't have to provide the on_update parameter if all you want is to cascade one component into another, all objects in the SDK can handle that case without intervention. Note, for example, that we have not provided it to the TotalEnergyProbe component. That is because we are not really interested in the raw Total Energy value. What we want is the value after the dampening filter, that will give us the values as seen at the needle.
The SDK provides the update_prop() helper function that you can pass as on_update parameter to all SDK components. It will cover your needs most of the time, but of course, you can write your own functions in Nasal for handling more special cases. If you do, your function will be called on every update cycle, and will receive the latest output value as its sole argument.
# Helper function for updating lcd display
var update_lcd_props = func(value) {
setprop("/instrumentation/ilec-sc7/lcd-digits-abs", math.abs(value));
setprop("/instrumentation/ilec-sc7/lcd-digits-sgn", (value < 0) ? 0 : 1);
};
The fuction above, for example, is a custom function that can be passed as on_update parameter. It splits its input into the absolute value and sign, and write those to separate properties.
var instrument = Instrument.new(
components: [probe, needle],
enable: 1);
Finally, we use an Instrument object to wrap the other two components into a working instrument. Under the hood, it will update both the probe and the filter on every frame, bringing the instrument to live. It will also take care of critical sim signals like reboots or simulation speed changes, so you don't need to write special code to account for those either.
More examples
Let's expand the example a little bit, and let's pretend that we want to install a second mechanical vario with a very long time constant, like old mechanical averagers.
io.include("Aircraft/Generic/soaring-instrumentation-sdk.nas");
var probe = TotalEnergyProbe.new();
var fast_needle = Dampener.new(
input: probe,
dampening: 2.7,
on_update: update_prop("instrumentation/vario/te_reading"));
var slow_needle = Dampener.new(
input: probe,
dampening: 20,
on_update: update_prop("instrumentation/vario/avg_reading"));
var vario_instrument = Instrument.new(
components: [probe, slow_needle, fast_needle],
enable: 1);
This second example shows that SDK components can be connected in a one-to-many configuration. Here, one TE probe is feeding two dampening filters at the same time. Also note that, although the intention is to implement two separate gauges, we are using only one Instrument object for updating all our components (i.e. a shared back-end/driver). As a rule of thumb, you should only use separate Instrument objects if you have different components that must be updated at different rates.
Here is an example of such a configuration, combining a mechanical needle that is updated every frame, with a digital averager that produces one reading per second.
io.include("Aircraft/Generic/soaring-instrumentation-sdk.nas");
var probe = TotalEnergyProbe.new();
var needle = Dampener.new(
input: probe,
dampening: 2.7,
on_update: update_prop("instrumentation/vario/te_reading"));
var avg = Averager.new(
input: probe,
buffer_size: 30,
on_update: update_prop("instrumentation/vario/avg_reading"));
var vario_instrument = Instrument.new(
components: [probe, needle],
enable: 1);
var avg_instrument = Instrument.new(
components: [avg],
update_period: 1,
enable: 1);
Advanced variometers: The PolarSolver
If all you want is to add support for Total Energy compensated variometers and perhaps a climb rate averager, you can stop reading here, you already know how to do it. However, if you are aiming at the more advanced types, like the Relative or the Speed Command, then stay with me, we will now be covering these special instruments.
Advanced variometers know how a particular aircraft flies, and use that knowledge together with probe readings to derive what the air around is doing. More so, the Speed Command type of variometers can even tell you what is the optimal speed to fly in order to meet certain performance goals that you set up in the instrument panel.
All this is possible because we can summarize the gliding performance of an aircraft in a simple curve that relates sink speed and airspeed. We call this curve the "polar" curve. There are more than one type of polar curve in aerodynamics, but when talking about gliders, this is the one we are referring to.
The soaring SDK includes an object called PolarSolver, that is designed to hold this kind of performance data, and allows other library components to perform computations on the polar curve. The PolarSolver approximates the polar curve by a parabola, an approximation that is also widely used in real instruments.
When instancing a PolarSolver, you will need to provide the three coefficients that define the polar curve for your aircraft. You will likely be able to find a set of coefficients for your model online, however, unless your flight model is very finely tuned to perform like the real aircraft, you might discover that the "official" set of coefficients do not give you accurate instruments in the simulation at all. The key here is to use a set of coefficients that matches your flight model, rather than the real performance data.
In order to do that, you will need to generate an airspeed vs sink speed polar curve from your flight model, and obtain a quadratic fit on that dataset. [TODO: how?]
io.include("Aircraft/Generic/soaring-instrumentation-sdk.nas");
var probe = TotalEnergyProbe.new();
var solver = PolarSolver.new(
polar_coeffs: [0.00036428, -0.04792, 2.3164],
mass: 380);
var vario = RelativeVario.new(
te_probe: probe,
polar_solver: solver);
var needle = Dampener.new(
input: vario,
dampening: 2.7,
on_update: update_prop("instrumentation/relative_vario/needle_reading"));
var instrument = Instrument.new(
components: [probe, vario, needle],
enable: 1);
The example above implements a Relative variometer, also known as Super Netto. This kind of varios, give you the climb rate you would get if you slowed the aircraft down to the optimal thermaling speed. This is very useful information for a glider pilot, because informed decisions can be made on whether it's worth slowing down for thermaling at a certain location, or if it's better to carry on and wait for the next updraught. The instrument tells you what would happen if you decided to do some spiralling.
For this example, we have created a PolarSolver with a real set of parameters for an ASK13 glider at a reference mass of 380 Kg. Because these polar coefficients match real life and not the flight model used in the simulation, our instrument will exhibit calibration errors at different speeds.
Actually, if you want to test a gliders flight model, you could install a Netto variometer in the cockpit and fly the simulation in still air. A Netto variometer should give you the vertical speed of the airmass surrounding the aircraft. If you provide real life polar coefficients to the Netto, and the needle doesn't stay at zero while flying in sill air, then you know there is a discrepancy between the simulation and the real thing. The Netto reading will be the difference in sink speed between the reference polar and your flight model at your current airspeed.
Extending the library
If the library does not provide a component you need, you can define it yourself and use it together with the other library components. In order to do that, you will have to follow some simple rules that grant interoperability.
First of all, your component shall implement the InstrumentComponent interface. You do it by adding InstrumentComponent to the parents vector when defining your component. The interface looks like this:
var InstrumentComponent = {
output: 0,
init: func { me.output = 0 },
update: func(dt) { },
};
As you can see, there are only three things specified by the interface:
- output
- The output port of your component. Whatever values your component generates, they shall be written to this variable.
- init()
- An initialization function, in case you need to do some preparations before starting normal operation. You don't need to write it yourself if you don't have anything special to do, the InstrumentComponent base class provides a basic implementation.
- update(dt)
- Here is where you add your functionality. This function will be called on every update period, and the dt argument will contain the simulated time elapsed since the last call. This value takes into account pause mode, as well as simulator speed-up.
For the sake of consistency, it is also recommended that your object constructor (the new() method) accepts an optional argument called on_update. You should store internally whatever the user gives you, and call it with output values whenever you generate them.
The following example shows the already familiar Dampener filter as implemented by the library. Here you can see all the described mechanisms and conventions at work.
# Dampener
# Simple IIR exponential filter. Appropriate and efficient for simulating
# mechanical needle dampening.
#
# var needle = Dampener.new(
# input: Object connected to the dampeners input.
# dampening: (optional) Time constant for the filter in seconds
# scale: (optional) Scale factor applied to the input signal before filtering
# on_update: (optional) function to call whenever a new output is available
var Dampener = {
parents: [InstrumentComponent],
dampening: 0, # time constant of the exponential filter (sec)
scale: 1,
new: func(input, dampening = 3, scale = 1, on_update = nil) {
return {
parents: [me],
input: input,
dampening: dampening,
scale: scale,
on_update: on_update,
};
},
update: func(dt) {
var alfa = math.exp(-dt / me.dampening);
me.output = me.output * alfa + me.input.output * me.scale * (1 - alfa);
if (me.on_update != nil) me.on_update(me.output);
}
};
Object Reference
[TODO]
Meanwhile, you can refer directly to $FG_ROOT/Aircraft/Generic/soaring-instrumentation-sdk.nas