It is not always desirable to go through the full process of creating a dependency graph and pseudo code for each project or change. Here, we will transition directly from the preceding plan to the following code stubs.
Mapping requirements directly to code
Writing the physics simulator
The physics simulator in src/physics.rs is responsible for modeling the physics and layout of the building and elevator operations. The simulator will be provided with one object to handle motor control and another to handle data collection. The physics simulator module will define traits for each of those interfaces, and the motor control and data collection objects should implement each trait, respectively.
Let's start by defining some of the type declarations for the physics module. First, let's look at a key interface—the direct motor input. Until this point, we have assumed that motor input will have simple voltage control that we can represent as a positive or negative floating point integer. This definition is problematic, mainly in the sense that all references to this type will reference f64. This type specifies a very specific data representation with no room for adjustment. If we litter our code with references to this type, then any changes will require us to go back and edit every one of the references.
Instead, for the motor input type, let's provide a name for the type. This could be an alias for the f64 type, which would solve the immediate concern. Though this is acceptable, we will choose to be even more explicit with the type definition and provide enum cases for up and down. The enum type, also known as a tagged union, is useful to define data that may have multiple structures or use cases. Here, the constructors are identical, but the meaning of each voltage field is opposite.
Furthermore, when interacting with the MotorInput type, we should avoid assuming any internal structure. This minimizes our exposure to future interface changes that may change because MotorInput defines an interface with a currently unknown physical component. We will be responsible for software compatibility with that interface. So, to abstract any interaction with MotorInput, we will use traits instead. Traits that do not define intrinsic behavior of a type, but rather associated behavior, are sometimes called data classes.
Here is the enum and a data class defining the calculation of force derived from an input:
#[derive(Clone,Serialize,Deserialize,Debug)]
pub enum MotorInput
{
Up { voltage: f64 },
Down { voltage: f64 }
}
pub trait MotorForce {
fn calculate_force(&self) -> f64;
}
impl MotorForce for MotorInput {
fn calculate_force(&self) -> f64
{
match *self {
MotorInput::Up { voltage: v } => { v * 8.0 }
MotorInput::Down { voltage: v } => { v * -8.0 }
}
}
}
pub trait MotorVoltage {
fn voltage(&self) -> f64;
}
impl MotorVoltage for MotorInput {
fn voltage(&self) -> f64
{
match *self {
MotorInput::Up { voltage: v } => { v }
MotorInput::Down { voltage: v } => { -v }
}
}
}
Next, let's define the elevator information. We will create an ElevatorSpecification, which describes the structure of the building and elevator. We also require an ElevatorState to hold information regarding the current elevator status. To clarify usage of floor requests, we will also create an alias for FloorRequests vectors to make the meaning explicit. We will choose to use a struct instead of tuples here to create explicit field names. Otherwise, structs and tuples are interchangeable for storing miscellaneous data. The definitions are as follows:
#[derive(Clone,Serialize,Deserialize,Debug)]
pub struct ElevatorSpecification
{
pub floor_count: u64,
pub floor_height: f64,
pub carriage_weight: f64
}
#[derive(Clone,Serialize,Deserialize,Debug)]
pub struct ElevatorState
{
pub timestamp: f64,
pub location: f64,
pub velocity: f64,
pub acceleration: f64,
pub motor_input: MotorInput
}
pub type FloorRequests = Vec<u64>;
The traits for MotorController and DataRecorder are almost identical. The only difference is that polling a MotorController expects a MotorInput to be returned. Here, we choose to use init methods instead of constructors to permit additional external initialization of each resource. For example, it may be necessary for DataRecorder to open files or other resources to be accessed during simulation. Here are the trait definitions:
pub trait MotorController
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState);
fn poll(&mut self, est: ElevatorState, dst: u64) -> MotorInput;
}
pub trait DataRecorder
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState);
fn poll(&mut self, est: ElevatorState, dst: u64);
fn summary(&mut self);
}
To simulate the physics of the elevator, we will reproduce the central loop of the simulation from Chapter 2, Functional Control Flow. Some of the state has been organized into structures instead of loose variables. Motor control decisions have been delegated to the MotorController object. Output and data recording has been delegated to the DataRecorder. There is also a new parameter field to specify the elevator's carriage weight. With all of these generalizations, the code becomes as follows:
pub fn simulate_elevator<MC: MotorController, DR: DataRecorder>(esp: ElevatorSpecification, est: ElevatorState, req: FloorRequests,
mc: &mut MC, dr: &mut DR) {
//immutable input becomes mutable local state
let mut esp = esp.clone();
let mut est = est.clone();
let mut req = req.clone();
//initialize MotorController and DataController
mc.init(esp.clone(), est.clone());
dr.init(esp.clone(), est.clone());
//5. Loop while there are remaining floor requests
let original_ts = Instant::now();
thread::sleep(time::Duration::from_millis(1));
while req.len() > 0
{
//5.1. Update location, velocity, and acceleration
let now = Instant::now();
let ts = now.duration_since(original_ts)
.as_fractional_secs();
let dt = ts - est.timestamp;
est.timestamp = ts;
est.location = est.location + est.velocity * dt;
est.velocity = est.velocity + est.acceleration * dt;
est.acceleration = {
let F = est.motor_input.calculate_force();
let m = esp.carriage_weight;
-9.8 + F/m
};
After declaring the state and calculating time-dependent variables, we add the elevator control logic:
//5.2. If next floor request in queue is satisfied,
then remove from queue
let next_floor = req[0];
if (est.location - (next_floor as f64)*esp.floor_height).abs()
< 0.01 &&
est.velocity.abs() < 0.01
{
est.velocity = 0.0;
req.remove(0);
//remove is an O(n) operation
//Vec should not be used like this for large data
}
//5.4. Print realtime statistics
dr.poll(est.clone(), next_floor);
//5.3. Adjust motor control to process next floor request
est.motor_input = mc.poll(est.clone(), next_floor);
thread::sleep(time::Duration::from_millis(1));
}
}
Writing the motor controller
The motor controllers in src/motor.rs will be responsible for making decisions regarding how much force to generate from the motor. The physics driver will supply current state information regarding all known measurements of location, velocity, and so on. Currently, the motor controller uses only the most current information to make control decisions. However, this may change in the future, in which case the controller may store past measurements.
Extracting the same control algorithm from the previous chapter, the new MotorController definition becomes as follows:
pub struct SimpleMotorController
{
pub esp: ElevatorSpecification
}
impl MotorController for SimpleMotorController
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState)
{
self.esp = esp;
}
fn poll(&mut self, est: ElevatorState, dst: u64) -> MotorInput
{
//5.3. Adjust motor control to process next floor request
//it will take t seconds to decelerate from velocity v
at -1 m/s^2
let t = est.velocity.abs() / 1.0;
//during which time, the carriage will travel d=t * v/2 meters
//at an average velocity of v/2 before stopping
let d = t * (est.velocity/2.0);
//l = distance to next floor
let l = (est.location - (dst as
f64)*self.esp.floor_height).abs();
After establishing basic constants and values, we need to determine the target acceleration:
let target_acceleration = {
//are we going up?
let going_up = est.location < (dst as
f64)*self.esp.floor_height;
//Do not exceed maximum velocity
if est.velocity.abs() >= 5.0 {
if going_up==(est.velocity>0.0) {
0.0
//decelerate if going in wrong direction
} else if going_up {
1.0
} else {
-1.0
}
//if within comfortable deceleration range and moving
in right direction, decelerate
} else if l < d && going_up==(est.velocity>0.0) {
if going_up {
-1.0
} else {
1.0
}
//else if not at peak velocity, accelerate
} else {
if going_up {
1.0
} else {
-1.0
}
}
};
After determining the target acceleration, it should be converted into a MotorInput value:
let gravity_adjusted_acceleration = target_acceleration + 9.8;
let target_force = gravity_adjusted_acceleration *
self.esp.carriage_weight;
let target_voltage = target_force / 8.0;
if target_voltage > 0.0 {
MotorInput::Up { voltage: target_voltage }
} else {
MotorInput::Down { voltage: target_voltage.abs() }
}
}
}
Now, let's write a second controller, implementing the proposed improvements. We will compare the two controllers later in the simulation. The first suggestion was to reduce the polling interval. This change must be made in the physics simulator, so we will measure its effect, but we will not tie it to the motor controller. The second suggestion was to smooth the acceleration curve.
After consideration, we realized that the change in acceleration (also called jerk) is what made people uncomfortable, more so than small acceleration forces. Understanding this, we will permit faster acceleration so long as the jerk remains small. We will replace the current target acceleration calculation with the following constraints and objectives:
- Maximum jerk = 0.2 m/s3
- Maximum acceleration = 2.0 m/s2
- Maximum velocity = 5.0 m/s
- Target change in acceleration:
- 0.2 if accelerating up
- -0.2 if accelerating down
- 0.0 if at stable velocity
The resulting controller becomes as follows:
const MAX_JERK: f64 = 0.2;
const MAX_ACCELERATION: f64 = 2.0;
const MAX_VELOCITY: f64 = 5.0;
pub struct SmoothMotorController
{
pub esp: ElevatorSpecification,
pub timestamp: f64
}
impl MotorController for SmoothMotorController
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState)
{
self.esp = esp;
self.timestamp = est.timestamp;
}
fn poll(&mut self, est: ElevatorState, dst: u64) -> MotorInput
{
//5.3. Adjust motor control to process next floor request
//it will take t seconds to reach max from max
let t_accel = MAX_ACCELERATION / MAX_JERK;
let t_veloc = MAX_VELOCITY / MAX_ACCELERATION;
//it may take up to d meters to decelerate from current
let decel_t = if (est.velocity>0.0) == (est.acceleration>0.0) {
//this case deliberately overestimates d to prevent "back up"
(est.acceleration.abs() / MAX_JERK) +
(est.velocity.abs() / (MAX_ACCELERATION / 2.0)) +
2.0 * (MAX_ACCELERATION / MAX_JERK)
} else {
//without the MAX_JERK, this approaches infinity and
decelerates way too soon
//MAX_JERK * 1s = acceleration in m/s^2
est.velocity.abs() / (MAX_JERK + est.acceleration.abs())
};
let d = est.velocity.abs() * decel_t;
//l = distance to next floor
let l = (est.location - (dst as
f64)*self.esp.floor_height).abs();
After determining basic constants and values, we can calculate a target acceleration:
let target_acceleration = {
//are we going up?
let going_up = est.location < (dst as
f64)*self.esp.floor_height;
//time elapsed since last poll
let dt = est.timestamp - self.timestamp;
self.timestamp = est.timestamp;
//Do not exceed maximum acceleration
if est.acceleration.abs() >= MAX_ACCELERATION {
if est.acceleration > 0.0 {
est.acceleration - (dt * MAX_JERK)
} else {
est.acceleration + (dt * MAX_JERK)
}
//Do not exceed maximum velocity
} else if est.velocity.abs() >= MAX_VELOCITY
|| (est.velocity + est.acceleration *
(est.acceleration.abs() / MAX_JERK)).abs() >=
MAX_VELOCITY {
if est.velocity > 0.0 {
est.acceleration - (dt * MAX_JERK)
} else {
est.acceleration + (dt * MAX_JERK)
}
//if within comfortable deceleration range and
moving in right direction, decelerate
} else if l < d && (est.velocity>0.0) == going_up {
if going_up {
est.acceleration - (dt * MAX_JERK)
} else {
est.acceleration + (dt * MAX_JERK)
}
//else if not at peak velocity, accelerate smoothly
} else {
if going_up {
est.acceleration + (dt * MAX_JERK)
} else {
est.acceleration - (dt * MAX_JERK)
}
}
};
After determining a target acceleration, we should calculate a target force:
let gravity_adjusted_acceleration = target_acceleration + 9.8;
let target_force = gravity_adjusted_acceleration
* self.esp.carriage_weight;
let target_voltage = target_force / 8.0;
if !target_voltage.is_finite() {
//divide by zero etc.
//may happen if time delta underflows
MotorInput::Up { voltage: 0.0 }
} else if target_voltage > 0.0 {
MotorInput::Up { voltage: target_voltage }
} else {
MotorInput::Down { voltage: target_voltage.abs() }
}
}
}
Writing the executable to run a simulation
The executable to run a simulation, contained in src/lib.rs, consists of all input and configuration from the previous chapter's simulation. Here is the harness used to configure and run a simulation:
pub fn run_simulation()
{
//1. Store location, velocity, and acceleration state
//2. Store motor input voltage
let mut est = ElevatorState {
timestamp: 0.0,
location: 0.0,
velocity: 0.0,
acceleration: 0.0,
motor_input: MotorInput::Up {
//a positive force is required to counter gravity and
voltage: 9.8 * (120000.0 / 8.0)
}
};
//3. Store input building description and floor requests
let mut esp = ElevatorSpecification {
floor_count: 0,
floor_height: 0.0,
carriage_weight: 120000.0
};
let mut floor_requests = Vec::new();
//4. Parse input and store as building description
and floor requests
let buffer = match env::args().nth(1) {
Some(ref fp) if *fp == "-".to_string() => {
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)
.expect("read_to_string failed");
buffer
},
None => {
let fp = "test1.txt";
let mut buffer = String::new();
File::open(fp)
.expect("File::open failed")
.read_to_string(&mut buffer)
.expect("read_to_string failed");
buffer
},
Some(fp) => {
let mut buffer = String::new();
File::open(fp)
.expect("File::open failed")
.read_to_string(&mut buffer)
.expect("read_to_string failed");
buffer
}
};
for (li,l) in buffer.lines().enumerate() {
if li==0 {
esp.floor_count = l.parse::<u64>().unwrap();
} else if li==1 {
esp.floor_height = l.parse::<f64>().unwrap();
} else {
floor_requests.push(l.parse::<u64>().unwrap());
}
}
After establishing the simulation state and reading the input configuration, we run the simulation:
let termsize = termion::terminal_size().ok();
let mut dr = SimpleDataRecorder {
esp: esp.clone(),
termwidth: termsize.map(|(w,_)| w-2).expect("termwidth")
as u64,
termheight: termsize.map(|(_,h)| h-2).expect("termheight")
as u64,
stdout: &mut io::stdout().into_raw_mode().unwrap(),
log: File::create("simulation.log").expect("log file"),
record_location: Vec::new(),
record_velocity: Vec::new(),
record_acceleration: Vec::new(),
record_voltage: Vec::new()
};
/*
let mut mc = SimpleMotorController {
esp: esp.clone()
};
*/
let mut mc = SmoothMotorController {
timestamp: 0.0,
esp: esp.clone()
};
simulate_elevator(esp, est, floor_requests, &mut mc, &mut dr);
dr.summary();
}
The DataRecorder implementation, also in src/lib.rs, is responsible for outputting real-time information as well as summary information. Additionally, we will serialize and store the simulation data in a log file. Notice the use of the lifetime parameter along with the parameterized trait:
struct SimpleDataRecorder<'a, W: 'a + Write>
{
esp: ElevatorSpecification,
termwidth: u64,
termheight: u64,
stdout: &'a mut raw::RawTerminal<W>,
log: File,
record_location: Vec<f64>,
record_velocity: Vec<f64>,
record_acceleration: Vec<f64>,
record_voltage: Vec<f64>,
}
impl<'a, W: Write> DataRecorder for SimpleDataRecorder<'a, W>
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState)
{
self.esp = esp.clone();
self.log.write_all(serde_json::to_string(&esp).unwrap().as_bytes()).expect("write spec to log");
self.log.write_all(b"\r\n").expect("write spec to log");
}
fn poll(&mut self, est: ElevatorState, dst: u64)
{
let datum = (est.clone(), dst);
self.log.write_all(serde_json::to_string(&datum).unwrap().as_bytes()).expect("write state to log");
self.log.write_all(b"\r\n").expect("write state to log");
self.record_location.push(est.location);
self.record_velocity.push(est.velocity);
self.record_acceleration.push(est.acceleration);
self.record_voltage.push(est.motor_input.voltage());
The DataRecorder is responsible for not only recording simulation data to logs, but also for printing statistics to the Terminal:
//5.4. Print realtime statistics
print!("{}{}{}", clear::All, cursor::Goto(1, 1), cursor::Hide);
let carriage_floor = (est.location / self.esp.floor_height).floor();
let carriage_floor = if carriage_floor < 1.0 { 0 } else { carriage_floor as u64 };
let carriage_floor = cmp::min(carriage_floor, self.esp.floor_count-1);
let mut terminal_buffer = vec![' ' as u8; (self.termwidth*self.termheight) as usize];
for ty in 0..self.esp.floor_count
{
terminal_buffer[ (ty*self.termwidth + 0) as usize ] = '[' as u8;
terminal_buffer[ (ty*self.termwidth + 1) as usize ] =
if (ty as u64)==((self.esp.floor_count-1)-carriage_floor) { 'X' as u8 }
else { ' ' as u8 };
terminal_buffer[ (ty*self.termwidth + 2) as usize ] = ']' as u8;
terminal_buffer[ (ty*self.termwidth + self.termwidth-2) as usize ] = '\r' as u8;
terminal_buffer[ (ty*self.termwidth + self.termwidth-1) as usize ] = '\n' as u8;
}
let stats = vec![
format!("Carriage at floor {}", carriage_floor+1),
format!("Location {:.06}", est.location),
format!("Velocity {:.06}", est.velocity),
format!("Acceleration {:.06}", est.acceleration),
format!("Voltage [up-down] {:.06}", est.motor_input.voltage()),
];
for sy in 0..stats.len()
{
for (sx,sc) in stats[sy].chars().enumerate()
{
terminal_buffer[ sy*(self.termwidth as usize) + 6 + sx ] = sc as u8;
}
}
write!(self.stdout, "{}",
String::from_utf8(terminal_buffer).ok().unwrap());
self.stdout.flush().unwrap();
}
The DataRecorder is also responsible for printing a summary at the end of the simulation:
fn summary(&mut self)
{
//6 Calculate and print summary statistics
write!(self.stdout, "{}{}{}", clear::All, cursor::Goto(1, 1), cursor::Show).unwrap();
variable_summary(&mut self.stdout, "location".to_string(), &self.record_location);
variable_summary(&mut self.stdout, "velocity".to_string(), &self.record_velocity);
variable_summary(&mut self.stdout, "acceleration".to_string(), &self.record_acceleration);
variable_summary(&mut self.stdout, "voltage".to_string(), &self.record_voltage);
self.stdout.flush().unwrap();
}
}
Writing the executable to analyze a simulation
The analysis executable in src/analyze.rs should look at the log file and confirm that all requirements are satisfied—namely the following:
- Jerk is under 0.2 m/s3
- Acceleration is under 2.0 m/s2
- Velocity is under 5.0 m/s
- The elevator does not back up during trips
- All trips are completed within 20% of the physical theoretical limit
The program design here will be to pass through the log file and check that all values are within the specified limits. There also needs to be a directional flag to alert us to backup events. When a trip completes, we will then compare the elapsed time to the theoretical limit. If any requirement is not satisfied, we will fail immediately and print some basic information. The code is as follows:
#[derive(Clone)]
struct Trip {
dst: u64,
up: f64,
down: f64
}
const MAX_JERK: f64 = 0.2;
const MAX_ACCELERATION: f64 = 2.0;
const MAX_VELOCITY: f64 = 5.0;
fn main()
{
let simlog = File::open("simulation.log").expect("read simulation log");
let mut simlog = BufReader::new(&simlog);
let mut jerk = 0.0;
let mut prev_est: Option<ElevatorState> = None;
let mut dst_timing: Vec<Trip> = Vec::new();
let mut start_location = 0.0;
After initializing the analysis state, we will go through the lines in the log to calculate the statistics:
let mut first_line = String::new();
let len = simlog.read_line(&mut first_line).unwrap();
let esp: ElevatorSpecification = serde_json::from_str(&first_line).unwrap();
for line in simlog.lines() {
let l = line.unwrap();
let (est, dst): (ElevatorState,u64) = serde_json::from_str(&l).unwrap();
let dl = dst_timing.len();
if dst_timing.len()==0 || dst_timing[dl-1].dst != dst {
dst_timing.push(Trip { dst:dst, up:0.0, down:0.0 });
}
if let Some(prev_est) = prev_est {
let dt = est.timestamp - prev_est.timestamp;
if est.velocity > 0.0 {
dst_timing[dl-1].up += dt;
} else {
dst_timing[dl-1].down += dt;
}
let da = (est.acceleration - prev_est.acceleration).abs();
jerk = (jerk * (1.0 - dt)) + (da * dt);
if jerk.abs() > 0.22 {
panic!("jerk is outside of acceptable limits: {} {:?}", jerk, est)
}
} else {
start_location = est.location;
}
if est.acceleration.abs() > 2.2 {
panic!("acceleration is outside of acceptable limits: {:?}", est)
}
if est.velocity.abs() > 5.5 {
panic!("velocity is outside of acceptable limits: {:?}", est)
}
prev_est = Some(est);
}
The analysis validates some requirements as it is processing the file; other requirements must be validated only after the entire log has been processed:
//elevator should not backup
let mut total_time = 0.0;
let mut total_direct = 0.0;
for trip in dst_timing.clone()
{
total_time += (trip.up + trip.down);
if trip.up > trip.down {
total_direct += trip.up;
} else {
total_direct += trip.down;
}
}
if (total_direct / total_time) < 0.9 {
panic!("elevator back up is too common: {}", total_direct / total_time)
}
//trips should finish within 20% of theoretical limit
let mut trip_start_location = start_location;
let mut theoretical_time = 0.0;
let floor_height = esp.floor_height;
for trip in dst_timing.clone()
{
let next_floor = (trip.dst as f64) * floor_height;
let d = (trip_start_location - next_floor).abs();
theoretical_time += (
2.0*(MAX_ACCELERATION / MAX_JERK) +
2.0*(MAX_JERK / MAX_ACCELERATION) +
d / MAX_VELOCITY
);
trip_start_location = next_floor;
}
if total_time > (theoretical_time * 1.2) {
panic!("elevator moves to slow {} {}", total_time, theoretical_time * 1.2)
}
println!("All simulation checks passing.");
}