Simon Fell > Its just code > February 2022
Thursday, February 17, 2022
This is part 1 in a series of posts about writing an app that does fuel calculator/strategy for iRacing.
Fuel Calculator
What’s a fuel calculator anyway? Imagine a 30 lap race in a car that can only run 20 laps on a full tank of gas. At some point in the race you need to stop and get more gas. You could stop at lap 10, top up and run to the end, or stop at lap 20 and take half a tank to get you to the end. That one is easy enough to do in your head, but throw in caution laps, fuel saving from drafting, and timed races and it gets harder to keep track of. A Fuel calculator app tracks the status of the car & race from iRacing and reports information the driver can use to help determine strategy, when to stop, when to fuel save.
There are of course a number of existing apps for this with varying degress of sophisitaction but I wanted something to help specifically around multi stop races and fuel saving. In this recent TNT race at Pheonix it would of been useful to know exactly how much fuel was needed to be saved to be able to skip the last pitstop that was only a couple of laps from the end of the race. It’d also be useful to know if you’re hitting that fuel save target or if you can’t, and should stop trying. The worst outcome is you try to fuel save, costing you time and potentially positions, but don’t save enough and have to stop anyway. Which is what happened to me, and quite a few other folks as well.
Why Rust?
iRacing provides a c++ based SDK for accessing telemetry data, and some people have built .NET versions as well. I could of written it in C#, but i find the VS2019 winforms tooling super annoying to use. I was going to use go, but then remembered that Windows Defender seems to consider anything built with go as having a virus in it. This is apparently beyond the ability of Microsoft and Google to fix. But I’ve been learning rust over the last year or so, so thought it’d be an interesting exercise to write it in Rust. The iRacing SDK is based around memory mapped data and a bunch of c structs that include a description of the data as well as the actual telemetry data. Given that there’s going to be a lot of unsafe Rust code to deal with that it’ll be interesting to see how much Rust helps vs gets in the way.
Parts
I broke the problem up into 3 main areas
- Getting data out of and into iRacing.
- Collecting the relevant data from iRacing to construct the current state of the race, as well as calculating additional needed stats like average fuel usage per lap.
- Calculating strategy options given the current state of the race.
We’ll start by looking at the strategy options, and cover the other areas in later posts.
Fuel Strategy
We need various peices of information to be able to generate a guestimate of the later laps. If you want to see the whole code for this part, see strat.rs
- The fuel used and time taken for a typical lap.
- The fuel used and time taken for a lap under yellow flag conditions.
- How much fuel is currently in the car.
- What’s the maximum amount of fuel the car can hold.
- How the race ends. (laps, time, both)
- If we’re currently under yellow, and if so, how many more yellow laps are expected.
A struct to hold this is easy enough, getting some of this data not so much. To know when you can pit, you need to know when the race will end. Races can be setup in different ways, a fixed number of laps, a fixed amount of time, or a combo laps & time, where the race ends when the first of either is reached. My first instinct was to capute this with a pair of Option fields, e.g.
pub struct EndsWith {
laps: Option<usize>,
time: Option<Duration>,
}
The one downside to this is that this struct can be constructed in an invalid state, e.g. EndsWith{None,None}
. Ideally
we’d use the type system to not let this happen at all, compile time checks are better than runtime checks. We can use
enum’s for this, enums in Rust are much more powerful than in other c’ish languages. In my Rust journey so far, I find
that enums are often the answer.
pub enum EndsWith {
Laps(usize),
Time(Duration),
LapsOrTime(usize, Duration),
}
Now you can’t construct an EndsWith that doesn’t specify one of the valid options.
So here’s the struct that defines a strategy calculation request.
pub struct Rate {
pub fuel: f32,
pub time: Duration,
}
pub struct StratRequest {
pub fuel_left: f32,
pub tank_size: f32,
pub yellow_togo: usize,
pub ends: EndsWith,
pub green: Rate,
pub yellow: Rate,
}
First up we need to work out how many laps we can do with the current fuel, then fill up and repeat until the end of the race. We also need to deal with being under yellow flag conditions, where much less fuel is used, and the laps take much longer. So the first few laps we apply might be yellow flag laps before the race goes back to racing. Or there’s the edge case where the race will finish under the yellow flag. I had a few goes at this, the simple case where there’s no yellows and a known number of laps is pretty simple (divide fuel by rate to get number of laps). But then dealing with timed races is a challenge, you’d have to calculate the expected number of laps first. And then dealing with yellows makes it even messier. I finally settled on an approach that just walks forward one lap at a time applying the relevant rate. This can also accumulate time to determine when the end is.
I built an Iterator chain that provides the stream of future laps, this can then be iterated over and broken up into stints. I like how the code ended up, it seems much tidier to me than the previous attempts that had various loops and a bunch of variables tracking different states.
let yellow = iter::repeat(self.yellow).take(self.yellow_togo);
let mut tm = Duration::ZERO;
let mut laps = 0;
let laps = yellow.chain(iter::repeat(self.green)).take_while(|lap| {
tm = tm.add(lap.time);
laps += 1;
match self.ends {
EndsWith::Laps(l) => laps <= l,
EndsWith::Time(d) => tm <= d,
EndsWith::LapsOrTime(l, d) => laps <= l && tm <= d,
}
});
Now we can iterate over laps accumulating time & fuel and determine where each run between pitstops occurs (called stints)
let mut stints = Vec::with_capacity(4);
let mut f = self.fuel_left;
let mut stint = Stint::new();
for lap in laps {
if f < lap.fuel {
stints.push(stint);
stint = Stint::new();
f = self.tank_size;
}
stint.add(&lap);
f -= lap.fuel;
}
if stint.laps > 0 {
stints.push(stint);
}
There’s probably a way to write that as part of the iterator chain. I find that thinking about it in this 2 peices easier to think about though.
From here its straight forward enough to create a Pitstop at the end of each stint (except the last one). If the last stint doesn’t require a full fuel load, then the “spare” capacity can be used to pull a pitstop forward. This creates the Pitstop windows, the earlest and latest that you can make the pitstop. For the 30 lap race, 20 lap tank example at the start, this would generate a Pitstop window that begins at lap 10 and ends at lap 20.
Now we have enough information to answer the fuel save question. If I can run laps that use less fuel, can I get rid of a pitstop? If you want to skip the last pitstop, then you’d need to have saved enough fuel to complete the last stint. We know what that is from when we built the Stints. We can include that in our results. Later on we’ll take that info to compute a fuel usage target we can display.
As all the input needed to generate this strategy result is managed externally, it easy to write unit tests for this.
#[test]
fn strat_two_stops() {
let d = Duration::new(40, 0);
let r = StratRequest {
fuel_left: 9.3,
tank_size: 10.0,
max_fuel_save: 0.0,
yellow_togo: 0,
ends: EndsWith::Laps(49),
green: Rate { fuel: 0.5, time: d },
yellow: Rate { fuel: 0.1, time: d },
};
let s = r.compute();
assert_eq!(vec![18, 20, 11], s.laps());
assert_eq!(vec![Pitstop::new(9, 18), Pitstop::new(29, 38)], s.stops);
}
Next time we’ll cover the calculator, this tracks the state of the race, the fuel usage on prior laps to feed into the strategy builder.