A32NX Systems
This page is pulled externally from the A32NX repository.
The systems/
folder contains code for simulating Airbus aircraft systems. Please read through the guidelines and keep yourself up to date with those
guidelines as they might change over time. We also highly recommend reading through this document to get an overview of the software design.
How to build
Follow the steps below if you want to build the content of this folder without using the repository's standard build process.
- Install the
wasm32-wasi
target by running:rustup target add wasm32-wasi
. - Install LLVM 12 which can be found here, ensure to add it to your PATH.
- Run
cargo build --target wasm32-wasi
in the console at the top-level of the a32nx repository. You must have the SDK installed and have the 'Samples' folder of the SDK downloaded (this must be acquired seperately from the core) in order to buildmsfs-rs
. - The
lib.rs
file is built astarget/wasm32-wasi/debug/a320.wasm
.
Software design
Good software design makes implementing new features easier. Good software design should primarily focus on defining the structural concepts that exist in the software. The amount of concepts should be limited, as to not overburden those who develop within it with the continuous question of: "should I use concept x or y to do z?".
It is with this in mind that the systems' design was created.
Requirements
Before going through the requirements, we first list some definitions:
- System: Does not refer to a software system but to a part of the aircraft, i.e. electrical system.
- Model: The part of the software that relates to actual parts of the aircraft, e.g.
ElectricalSystem
,Engine
,EngineGenerator
, etc. - Software: The thing we're building.
1. Runs outside the simulator
The software should be testable outside of the simulator by running it in unit tests or a console application.
2. Simulator interactions outside the model
To aid in achieving requirement 1, interactions between the simulator and the model should be separated such that the model is unaware of the simulator's existence.
3. Guarantee consistent state
Each update of the model should leave the model in a consistent state. Requiring multiple "ticks" to reach the correct state is not acceptable and should only be done when there is absolutely no way around it. The programming model should make it easy to ensure this is guaranteed.
4. Observable state
A subset of systems requires observing the state of parts of the model, without the model itself having to be aware of such requirements. Two examples of such feature requirements are:
- The ability to play different sounds for the opening and closing of contactors in the electrical system.
- Showing faults and actions on the Lower ECAM.
5. Reuse in multiple Airbus aircraft types
Some parts of the model can be reused for multiple Airbus types. For example: while the decision which contactors in the electrical system are opened and closed is a type specific decision, the fact that such an electrical system contains buses, contactors, generators, etc. is true for all aircraft.
6. Starting state for different phases of flight
The model should be easily initialisable to different stages of flight, including cold and dark, on the runway and in flight.
7. Unit tests are mandatory
Testing the software automatically is key to quick and relatively error-free development. Thus, unit and integration (not with MSFS but combining pieces of the model) testing must be fully supported and is mandatory.
8. No confusion about units
The simulator exposes data in various forms, as pound, kilo, liter, gallon, volts, ampere, etc. This might lead to confusion during development and therefore should be mitigated.
Implementation
1. Runs outside the simulator
To handle this requirement, parts of the software that interact with the simulator should be located outside of the part of the software that models the systems. It is therefore an onion-like design.
In the code below, the A320
and Simulation
types are unaware of the fact they're running inside a simulator. Thus those types and all the types they refer to can run without running the simulator.
#[msfs::gauge(name=systems)]
async fn systems(mut gauge: msfs::Gauge) -> Result<(), Box<dyn std::error::Error>> {
let mut reader_writer = A320SimulatorReaderWriter::new()?;
let mut a320 = A320::new();
let mut simulation = Simulation::new(&mut a320, &mut reader_writer);
while let Some(event) = gauge.next_event().await {
if let MSFSEvent::PreDraw(d) = event {
simulation.tick(d.delta_time());
}
}
Ok(())
}
2. Simulator interactions outside the model
A simulation that doesn't interact with the simulator is pointless, thus to enable interaction the Simulation
type runs through various phases. The primary phases are:
- Reading data from the simulator into the simulation model.
- Executing the simulation.
- Writing data from the simulation model into the simulator.
A visitor is used for phase 1 and 3. A visitor is useful because code outside of the model doesn't have to be aware of the internal structure, and thus things are easier to change over time.
Visitor
The SimulationElement
trait is the primary means of making an element visitable and enabling reading and writing of information from it. When you implement the SimulationElement
for a type, by default it will visit that type but nothing else:
pub trait SimulationElement {
fn accept<T: SimulationElementVisitor>(&mut self, visitor: &mut T)
where
Self: Sized,
{
visitor.visit(self);
}
}
If the type you're implementing SimulationElement
for is composed of other SimulationElement
types, be sure to further expand the accept
function:
impl SimulationElement for A320 {
fn accept<T: SimulationElementVisitor>(&mut self, visitor: &mut T) {
// These types are all a SimulationElement as well.
self.apu.accept(visitor);
self.apu_fire_overhead.accept(visitor);
self.apu_overhead.accept(visitor);
// Don't forget to visit yourself
visitor.visit(self);
}
}
Reading information from the simulator
The SimulationElement
trait contains the read
and write
functions. read
can be used to read information from the simulator:
pub struct Engine {
corrected_n2_id: String,
corrected_n2: Ratio,
}
impl Engine {
pub fn new(number: usize) -> Engine {
Engine {
corrected_n2_id: format!("TURB ENG CORRECTED N2:{}", number),
corrected_n2: Ratio::new::<percent>(0.),
}
}
}
impl SimulationElement for Engine {
fn read(&mut self, reader: &mut SimulatorReader) {
// As this function is invoked for every simulation tick
// we try not to format the string here, but instead do it
// once in the constructor function.
self.corrected_n2 = Ratio::new::<percent>(reader.read_f64(&self.corrected_n2_id));
}
}
Writing information to the simulator
write
can be used to write information to the simulator:
impl<T: ApuGenerator, U: ApuStartMotor> SimulationElement for AuxiliaryPowerUnit<T, U> {
fn write(&self, writer: &mut SimulatorWriter) {
writer.write_f64(
"APU_FLAP_OPEN_PERCENTAGE",
self.air_intake_flap.open_amount().get::<percent>(),
);
writer.write_bool("APU_BLEED_AIR_VALVE_OPEN", self.bleed_air_valve_is_open());
}
}
At the moment, we can only read and write the f64
type. As a bool
can be represented as an f64
, we also support that particular type.
A32NX prefix
One doesn't have to prefix the variable name with A32NX_
as this prefix is automatically added by the code in the a320_systems_wasm
project.
Reading aircraft variables
Reading of aircraft variables requires slightly more work. To make that work, define the AircraftVariable
in the A320SimulatorReaderWriter
and add it to the read
function in that type:
fn read(&mut self, name: &str) -> f64 {
match name {
"OVHD_ELEC_APU_GEN_PB_IS_ON" => self.apu_generator_pb_on.get(),
// ...
}
}
3. Guarantee consistent state
Achieving a consistent state requires that any dependencies between calculations are clearly visible. For example, to determine if the APU start motor is powered, we first need to determine the state of the electrical system. The state of the electrical system also requires the APU state to be known, thus creating a circular dependency. We make these dependencies clear as follows:
impl Aircraft for A320 {
fn update_before_power_distribution(&mut self, context: &UpdateContext) {
self.apu.update_before_electrical(
context,
// ...
);
self.electrical.update(
context,
// ...
&mut A320ElectricalUpdateArguments::new(
// ...
&mut self.apu,
// ...
),
);
self.apu.update_after_electrical();
self.electrical_overhead
.update_after_electrical(&self.electrical);
self.apu_overhead.update_after_apu(&self.apu);
By passing around information instead of holding "global" references we can guarantee consistent state.
Module dependencies
The APU and electrical system are defined in separate modules. To ensure ease of testing we try to reduce the number of dependencies of a module. In the above example, the APU gets passed to A320ElectricalUpdateArguments
which in fact doesn't expect an actual APU instance, but an instance implementing the AuxiliaryPowerUnitElectrical
trait. That trait can be found in systems/shared
. As a result the A320 electrical system can be tested without pulling in the full APU implementation.
4. Observable state
My first thought on this was to introduce some form of publish/subscribe which would expose changes made within the model to the outside as events. However, this would introduce another concept and could also increases the number of responsibilities we give to the model itself. I mentioned in the introduction that "the amount of concepts should be limited". Therefore, we should use the visitor pattern for this purpose as well.
Thus far this has worked out fine.
5. Reuse in multiple Airbus aircraft types
By adhering to requirement 1 and 2, we can already try to implement parts of the A380 by composing types we created for the A320 in different ways. Certain minor differences, such as the cooling coefficient of an engine generator which might differ per type of engine can be implemented by providing them as input to the new
(constructor) function.
6. Starting state for different phases of flight
At the moment the various .flt
files are used to start in the correct system state. Should these not be enough, the visitor pattern mentioned earlier can be used to apply different starting states to the model:
fn main() {
let mut airbus = A320::new();
// Ignore boxing for the sake of example simplicity.
let startingStateVisitor = match starting_state {
StartingState::ColdAndDark => ColdAndDarkStartVisitor {},
StartingState::InFlight => InFlightStartVisitor {},
// etc.
}
airbus.accept(&startingStateVisitor);
}
7. Unit tests
Unit tests are mandatory. Contributions without a complete unit test suite are not approved.
Unit tests can easily be implemented due to us having successfully abstracted away the simulator. The project contains various tools to help you in your unit testing. The SimulationTestBed
type can be used to execute a full simulation tick on an Aircraft
or SimulationElement
you pass to it. Read the documentation on that type for more information.
For readability of tests and as the number of situations to test is rather large, I highly recommend using builder-like testing types:
#[test]
fn contactor_opens_after_three_minutes_of_being_closed_for_apu_start_in_emergency_elec() {
let test_bed = test_bed_with()
.emergency_elec()
.available_emergency_generator()
.and()
.apu_master_sw_pb_on()
.run(Duration::from_secs(
ClosedContactorObserver::EMER_ELEC_APU_MASTER_MAXIMUM_CLOSED_SECONDS,
));
assert!(!test_bed.battery_contactor_is_closed());
}
Refer to e.g. battery_charge_limiter.rs
for a full implementation example.
8. No confusion about units
We use the uom crate for handling units.