Howto:Implementing a simple GCA system

From FlightGear wiki
Jump to navigation Jump to search
This article is a stub. You can help the wiki by expanding it.
Canvas GUI dialog showing a simple UI for setting up a GCA approach/controller.
Screenshot showing rleibner's GCA/PAR screen (Canvas based) in conjunction with a Canvas driven GUI dialog to configure the GCA [1]

Objective

use Nasal to script AI/ATC interactions that pilots need to respond to properly. For example, another simple idea to get you started might be a Nasal script to implement a GCA ("ground controlled approach") mission: http://de.wikipedia.org/wiki/Ground_Controlled_Approach This would simulate an air traffic controller "talking you down", i.e. providing all the feedback an ILS would normally provide (vertical/horiontal deviation from the glide path). This could be very easily implemented in Nasal space. You would just need to configure the script (airport, runway, touchdown point, altitude, glide path) and the GCA script could vector you onto the right course and request you to change altitude as required.[2]


the helpers in geo.nas should be more than sufficient to help us come up with a GCA module and a simple "controller" class that controls an aircraft by monitoring its glide path/vector and issuing instructions accordingly. As a matter of fact, it would also not be that far-fetched to hook up the same control logic to a tanker.nas based AI-piloted aircraft[3]

Status

1rightarrow.png See Spoken GCA for the main article about this subject.

You can download it from here.

Please read the Readme file included into the zip. Note this is to be installed as Submodule. Later I'll make the addon.

But prior to that please provide feedback about its 'architecture' and behavior. Thanks in advance for your cooperation.[4]


We also looked up screen shots of actual ATC software to see what kind of parameters can commonly be configured for a GCA, which is why we put together a Canvas based UI dialog to more easily configure the GCA in FlightGear, including the corresponding offsets.

The whole thing is implemented in a fashion so that it can also be told to monitor AI/MP nodes, but also to be able to emit instructions using arbitrary output properties - which is to say that the script is capable of monitoring an AI/MP aircraft, and issuing instructions using the MP "chat" properties (just as well as the festival/flite TTS properties, or Spoken ATC itself).

In other words, once the heuristics are added, you could literally be running the GCA script on a multiplayer client to have it issue instructions to another MP client across the continent. For the time being, there won't be any changes, but it's reached a state where it should be fairly straightforward to add custom heuristics, or even let it track/control AI nodes like tanker.nas[5]

Background

GCA is surprisingly accurate and easy to use, both as a pilot and a controller. In the 1970´s we tried it on the simulator that I ran at BAC Weybridge. My impression was that it was easier than watching ILS /GLS deviations, At decision height (200ft) when the runway light display projector was turned on, I as pilot) or the pilot that I was directing (as ATC operator) were always perfectly lined up. GCA, with the RAF vocabulary that we used, is very good for teaching students how to follow the glidepath.[6]


we may need to add some features, especially for piloting/flying, i.e. route manager/waypoint awareness, and ability to track course/bearing to certain positions (via geo.nas) - e.g. for navigating via VORs, NDBs or DMEs Currently, the tutorial system doesn't have any built-in support for "fly to the SFO VOR" - but once we have that, we could even support flying holding patterns ( "fly to sfo maintain 8000, hold left on the 180 radial"). Such things would be useful also for virtual flight instruction - but also for an ATC adventure, i.e. where a virtual ATC controller guides pilots down a GCA path using radar vectors and AGL altitudes[7]


All this stuff is already exposed to Nasal thanks to the work that Zakalawe & TheTom have done as part of "NasalPositioned" and those flightplan() APIs - so we really only need to expose a handful of building blocks so that people can create route/navaid-aware missions. This would be primarily useful for any "flight instructions"-based scenarios, i.e. perfectly in line with the original purpose of the "tutorials" system, while also allowing additional functionality to be developed on top, i.e. having scripted virtual pilots or ATC controller.[8]

Design

the logic used by glideslope.nas is definitely a good start - for starters, what is needed is a loop that is monitoring an aircraft, and then, it will be polling that aircraft's position and compute the vertical/horiontal offset to the destination airport/runway using a configurable glideslope (gradient, i.e. altitude change over ground per time interval) - once we have a vertical/horizontal "delta", we can convert those to instructions that are issued to the pilot, while monitoring the aircraft to see if the requested changes (in altitude/heading) are implemented or not, and adjust the gradient/vector accordingly.

[9]


We will be implementing a simple class that merely operates using 3 properties:

  • position/altitude-ft
  • position/latitude_deg
  • position/longitude_deg

This approach makes it possible to reuse the same class for both, the main aircraft, as well as arbitrary AI traffic - e.g. imagine controlling a tanker.nas based AI aircraft using this AI controller.

Good to know

Proof of concept

var GCAController = {
# constructor
new: func(timer_interval_secs) {
 var m = {parents:[GCAController] };
 m.aircraft_properties = {altitude_ft: "altitude-ft", latitude_deg: "latitude-deg", longitude_deg: "longitude-deg"};
 m.aircraft_state = {latitude_deg:0.00, longitude_deg:0.00, altitude_ft:0.00 }; 
 m.aircraft_object = geo.Coord.new();

 m.receivers = []; # callbacks to receive instructions from the GCA controller
 m.timer = maketimer(timer_interval_secs, func m.update() );
 
 return m;
}, # new()

# destructor
del: func() {
me.timer.stop();
}, # del()

#####
# this will be called by our timer
update: func() {
print("Updating GCA controller");

me.updatePosition();
me.computeRequiredAltitude();
me.computeRequiredHeading();
# compute offset/delta

var instruction = me.buildInstruction();
# now that we have an instruction, pass it to registered callbacks 
me.notifyReceivers(instruction);

}, # update()

##### our setup APIs: 

setAircraft: func(root) {
me.root = root;
# AI/MP models have their own callsign node:
var callsign_node = props.getNode(root).getNode("callsign");

# the main aircraft's callsign is stored under /sim/multiplay/callsign
if (callsign_node == nil) {
print("Using multiplayer callsign");
callsign_node = props.getNode("/sim/multiplay/callsign");
}
else {
print("Using AI/MP callsign");
}

var callsign = callsign_node.getValue();
me.callsign = callsign;
print("callsign: ", callsign);
}, # setAircraft()

setDestination: func(airport, runway, glidepath) {
 var apt = airportinfo(airport);
 if (apt == nil) die("Invalid ICAO for airport:"~ airport);

 var runways = apt.runways;
 # debug.dump(runways);
 if (typeof(runways)!="hash" or !size(keys(runways))) die ("runways invalid for "~airport);

 if (runways[runway] == nil) die("runway not found at airport:"~runway);
 
 print("Valid airport/runway combo found");
 me.destination = {airport:airport, runway:runway, glidepath:glidepath, runways:runways, rwy_object: runways[runway] };
}, # setDestination()

registerReceiver: func(receiver) {
append(me.receivers, receiver);
}, #registerReceiver()


notifyReceivers: func(instruction) {
foreach(var r; me.receivers) {
  r(instruction);
 }
},

# start the GCA
start: func() {
 me.timer.start();
}, # start()

# stop/interrupt the GCA
stop: func() {
 me.timer.stop();
}, # stop()

###
## Update helpers
###


# TODO: this can be easily optimized
updatePosition: func() {
foreach(var p; keys(me.aircraft_properties)) {
#print(me.aircraft_properties[p]);
me.aircraft_state[p] = getprop(me.root ~'/'~ me.aircraft_properties[p]);
}

#debug.dump( me.aircraft_state);

me.aircraft_object.set_latlon(me.aircraft_state['latitude_deg'], me.aircraft_state['longitude_deg'], me.aircraft_state['altitude_ft']);

}, # updatePosition()

computeRequiredAltitude: func() {
}, # computeRequiredAltitude()

computeRequiredHeading: func() {
}, # computeRequiredHeading()

buildInstruction: func() {

return sprintf("%s Turn left",me.callsign);
}, # buildInstruction

}; # end of GCAController class

###
## now, let's test our new class: 
###

var demo = GCAController.new( timer_interval_secs: 0.5 );
demo.setAircraft( root: "/position", callsign:"foo");
demo.setDestination( airport: "KSFO", runway:  "28R", glidepath: 3.00 );

# this callback will be invoked by the GCA controller when it has a new instruction
# (could be just as well a tooltip or multiplayer chat message)
var receiver = func(instruction) {
print("GCA instruction is: ", instruction);
};


demo.registerReceiver( receiver );
demo.start();

Implementing GCA heuristics

Now that we have the boilerplate in place, we can look into implementing the actual heuristics for a ground controlled approach. For that, we are dealing with a single method only: buildInstruction(), primarily using the helpers available in geo.nas, using glide_slope_tunnel.nas as refernece:

GUI frontend

Extended GCA dialog showing additional configuration options
GCA dialog showing support for AI/MP models
GCA dialog with support for configurabl transmission properties, so that different back-ends can be used - imagine using /sim/messages, vs. /sim/multiplay/chat - basically, this makes it possible for two clients to conduct a GCA session over multiplayer in a p2p fashion, without the script having to run locally. Furthermore, this also means that different TTS back-ends can be easily used (think Festival/FLITE), but also other back-ends like rleibner's Spoken ATC addon.

Once the underlying script is working well enough, we can look into providing a configuration GUI on top of the script using the Canvas GUI library:

var (width,height) = (320,450);
var title = 'GCA Dialog ';

# create a new window, dimensions are WIDTH x HEIGHT, using the dialog decoration (i.e. titlebar)
var window = canvas.Window.new([width,height],"dialog").set('title',title);

##
# the del() function is the destructor of the Window
# which will be called upon termination (dialog closing)
# you can use this to do resource management (clean up timers, listeners or background threads)
window.del = func()
{
print("Cleaning up window:",title,"\n");
call(canvas.Window.del, [], me);
};

# adding a canvas to the new window and setting up background colors/transparency
var myCanvas = window.createCanvas().set("background", canvas.style.getColor("bg_color"));

# creating the top-level/root group which will contain all other elements/group
var root = myCanvas.createGroup();

# create a new layout
var myLayout = canvas.VBoxLayout.new();
# assign it to the Canvas
myCanvas.setLayout(myLayout);

var setupWidgetTooltip = func(widget, tooltip) {
 widget._view._root.addEventListener("mouseover", func gui.popupTip(tooltip) );
} # setupWidgetTooltip


var setupLabeledInput = func(root, layout, input) {
#text, default_value, focus, tooltip, unit) {

var label = canvas.gui.widgets.Label.new(root, canvas.style, {wordWrap: 0}); 
var unit_suffix = sprintf(" (%s):", input.unit);
label.setText(input.text~unit_suffix);
layout.addItem(label);

var field = canvas.gui.widgets.LineEdit.new(root, canvas.style, {});
layout.addItem(field);
field.setText(input.default_value);

## TODO: add widget to hash

if (input.focus)
field.setFocus();

setupWidgetTooltip(widget:field, tooltip: input.tooltip);

var el = field._view._root;
el.addEventListener("keypress", func (e) {

# print("field changed, value:", field.text() );

var color = (validationHelpers[input.validate]( field.text() ) == 0) ? [0,1,0] : [1,0,0];

field._view._root.setColor(color);

});
    

return field; # return to caller
} # setupLabeledInput()

var validationHelpers = {

'AircraftRoot': func(input) {
var root = props.getNode(input);
if (root == nil) return 1; # error

var required_props = ['altitude-ft', 'longitude-deg', 'latitude-deg'];

foreach(var rp; required_props) {
 if (root.getNode(rp) == nil) return 1;
} # foreach

return 0; # valid root node
},

'Airport': func(input) {
var match = airportinfo(input);
if (match == nil or typeof(match) != 'ghost') return 1; 
return 0;


},

'Runway': func(input) {
return 0;
},

'FinalApproach': func(input) {
return 0;
},

'Glidepath': func(input) {
return 0;
},

'SafetySlope': func(input) {
return 0;
}, 

'DecisionHeight': func(input) {
return 0;
},

'TouchdownOffset': func(input) {
return 0;
}, 

'TransmissionInterval': func(input) {
return 0;
}, 

'TransmissionProperty': func(input) {
return 0;
}, 

}; # validationHelpers;


var inputs = [
{text: 'Position root', default_value:'/position', focus:1, callback:nil, tooltip:'property path to position node', validate: 'AircraftRoot', convert:nil, unit: 'property path'},
{text: 'Airport', default_value:'KSFO', focus:0, callback:nil, tooltip:'ICAO ID, e.g. KSFO', validate: 'Airport', convert:nil, unit:'ICAO'},
{text: 'Runway', default_value:'28R', focus:0, callback:nil, tooltip:'runway identifier, e.g. 28L', validate: 'Runway', convert:nil, unit:'rwy'},
{text: 'Final Approach', default_value:'10.00', focus:0, callback:nil, tooltip:'length of final approach leg', validate: 'FinalApproach', convert:nil, unit:'nm'},

{text: 'Glidepath', default_value:'3.00', focus:0, callback:nil, tooltip:'glidepath in degrees, e.g. 3', validate: 'Glidepath', convert:nil, unit:'degrees'},
{text: 'Safety Slope', default_value:'2.00', focus:0, callback:nil, tooltip:'safety slope in degrees', validate: 'SafetySlope', convert:nil, unit:'degrees'},

{text: 'Decision Height', default_value:'200.00', focus:0, callback:nil, tooltip:'decision height (vertical offset)', validate: 'DecisionHeight', convert:nil, unit:'ft'},
{text: 'Touchdown Offset', default_value:'0.00', focus:0, callback:nil, tooltip:'touchdown offset', validate: 'TouchdownOffset', convert:nil, unit:'m'},
{text: 'Transmission interval', default_value:'5.00', focus:0, callback:nil, tooltip:'Controller/timer resolution', validate: 'TransmissionInterval', convert:nil, unit:'secs'},
{text: 'Transmission property', default_value:'/sim/messages/approach', focus:0, callback:nil, tooltip:'property to use for transmissions. For example: /sim/multiplay/chat or /sim/sound/voices/approach', validate: 'TransmissionProperty', convert:nil, unit:'property'},
]; # input fields

foreach(var input; inputs) {
 # TODO: pass input hash 
 var widget = setupLabeledInput(root, myLayout, input);
 input.widget = widget;
}

var validateFields = func() {
foreach(var f; inputs) {

 var result = validationHelpers[f.validate] ( f.widget.text() );
 if (result == 1) {

canvas.MessageBox.critical(
  "Validation error",
  "Error validating "~f.text,
  cb = nil,
  buttons = canvas.MessageBox.Ok
); # MessageBox


 } # error handling
} # foreach
return 0; # all validations passed
} # validateFields()

# click button
var button = canvas.gui.widgets.Button.new(root, canvas.style, {})
	.setText("Start/Stop")
	.setFixedSize(75, 25);

button.listen("clicked", func {
        # add code here to react on click on button.
        # TODO: toggle all fields read-only 
validateFields();
# print("Button clicked !");
});

setupWidgetTooltip(widget:button, tooltip: "toggle GCA on/off");

myLayout.addItem(button);

Visualizing the Approach

screen shot showing a simple Canvas GUI dialog demonstrating how to use OpenVG-path drawing via Nasal and Canvas
modifications as follows:Once into the area, the controller directs a perpendicular path to the app course.At 1.5 nm from app course, "Make half standard rate turns. Turn <left/right> heading <toRwyHdg>".A few seconds latter "Approaching glidepath. Begin descent. "A bit of math to substantiate that:A 90 deg half standard turn takes 60 secs = 0.017 hAt some 110 kts IAS results an arc of 110 x 0.017 = 1.87 nmSuch arc is rxPI/ 2 thus r = 2 x 1.87 / PI = 1.2 nm.Adding some secs to configure attitude, we have 1.5 nm.[11]

Once we have a basic GCAController class working and a corresponding UI to easily set up the GCA, it seems to make sense to explore having a UI to monitor the ongoing approach by plotting a vertical and a horizontal view of the profile flown.

Basically, this is about plotting two diagrams and projecting the approach accordingly - we can do so by using the Canvas path drawing examples from the Canvas Snippets article and adapt those as needed.

For starters, we will simply be passing an instance handle of the GCAController class, so that our little helper script can simply access all internal state directly, instead of having to touch any of the code we previously created.

var GCAVisualization = {
# constructor
new: func(gca) {
var m = {parents:[GCAVisualization]};
return m;
}, # new()

# destructor
del: func() {
}, # del()

}; # GCAVisualization

var display = GCAVisualization.new(demo);

Ideas

  • add features supported by real GCA/PAR/SAR software [1]
  • make the touchdown point configurable ?
  • Implement the whole thing as a GUI dialog for prototyping purposes (e.g. allow the aircraft/airport etc to be entered/changed easily) 60}% completed
  • support multiple instances ?
  • add terrain awareness support (querying the approach profile) ?
  • show a vertical/horizontal map visualizing the approach ?
  • use a subset of tanker.nas to add an ATC-enabled version that actually responds to GCA instructions ?
  • add a GCAManager class to register active GCAs, and implement mutual awareness ?

Roadmap

  • generalize the code to come up with a "TrackingController" for GCA, ASR/PAR respectively
  • add input validation to the GUI dialog 70}% completed
  • show Tooltips 90}% completed
  • add a static label as a status bar to show internal state

References

References
  1. https://forum.flightgear.org/viewtopic.php?f=30&t=32882&p=319257#p319257
  2. Hooray  (Dec 22nd, 2011).  Re: Achievements: New Motivator and Feature Introduction Met .
  3. Hooray  (Aug 14th, 2017).  Re: Spoken .
  4. rleibner  (Sep 7th, 2017).  Spoken GCA .
  5. Hooray  (Aug 20th, 2017).  Re: Spoken ATC .
  6. Alant  (Aug 13th, 2017).  Re: Spoken .
  7. Hooray  (Apr 14th, 2014).  Re: Tutorials/Missions/Adventures: requests for features .
  8. Hooray  (Apr 14th, 2014).  Re: Tutorials/Missions/Adventures: requests for features .
  9. Hooray  (Aug 17th, 2017).  Re: Spoken ATC .
  10. Hooray  (Aug 18th, 2017).  Re: Spoken ATC .
  11. rleibner  (Sep 11th, 2017).  Re: Spoken .

Related content

Wiki articles