During the pandemic, I’ve been spending quite a bit of time playing iRacing in VR. I love the realism of iRacing (and how well it supports VR!), and managed to score a win in the Mazda MX-5 this season at the (sadly) now defunct Oran Park Raceway near Sydney.

First place at Oran Park Raceway

In a previous post, I discussed how I was looking to create an F# library for the iRacing SDK. This proved to be a bigger challenge than I thought as the iRacing API uses a memory mapped file and I’m not yet familiar enough with F# to be tackling the problem yet.

As an alternative (and thanks to F#’s interoperability with C# and the .NET platform), I decided to familiarize myself with this C# iRacing SDK and build a small app that gathers some basic telemetry data and writes it to a CSV file which I could then plot in a .NET Interactive Notebook using Plotly.NET. The end result of my first few tests looked like this:

The entire process was refreshingly simple, but there were a few things that had stumped me as I had still been thinking in C# (which I’m more familiar with). For example, here’s the code which listens to an event from the iRacing SDK and then calls a function which parses and then writes telemetry data to a CSV:

// Get the current speed in m/s and convert to a rounded km/h.
let getSpeed (evArgs: SdkWrapper.TelemetryUpdatedEventArgs) =
    let speed = float evArgs.TelemetryInfo.Speed.Value
    let speedInKMh = speed * 3.6
    let speedRounded = System.Math.Round (speedInKMh, 0) 

// Get the throttle input and round it.
let throttleValue (evArgs: SdkWrapper.TelemetryUpdatedEventArgs) =
    let throttle = float evArgs.TelemetryInfo.Throttle.Value
    let roundedThrottle = System.Math.Round (throttle, 2)

// Get the lapdistance and round it.
let getLapDistance (evArgs: SdkWrapper.TelemetryUpdatedEventArgs) =
    let distance = float evArgs.TelemetryInfo.LapDistPct.Value
    let roundedDistance = System.Math.Round (distance, 2)

// Append the output of the current tick to a CSV file.
let writeToCsv (evArgs: SdkWrapper.TelemetryUpdatedEventArgs) =
    let dataToWrite = $"{getSpeed evArgs},{evArgs.TelemetryInfo.Gear.Value},{throttleValue evArgs},{evArgs.TelemetryInfo.Lap.Value},{getLapDistance evArgs}\n"
    File.AppendAllText ("""C:\LapTimes.csv""", dataToWrite)
    printfn "Wrote to CSV"

let start () =
    // Bind an instance of iRacing SdkWrapper to iRacing
    let iracing = new SdkWrapper(TelemetryUpdateFrequency = 2.0)
    // Listen to the iRacing telemetry updates
    |> Observable.subscribe writeToCsv |> ignore

The first thing to note is the structure of the code. F# code is structured from the bottom up (the same applies for files in a project), and I find that this helps with readability and reasoning about the code.

Another thing to consider is how to set the update frequency (in updates per second). In C#, we would update a variable to set the telemetry update frequency like so:

iracing.TelemetryUpdateFrequency = 2.0; // Updates per second

However, In F#, we bind names to expressions as opposed to assigning variables. Then how can we set an update frequency for the C# library without using a mutable? The answer is simple, pass it in as a parameter to the SdkWrapper constructor:

let iracing = new SdkWrapper(TelemetryUpdateFrequency = 2.0) // Receive events at a rate of two updates/sec.

Next, note the use of Observable.subscribe to listen to an event. In C#, we would likely do something like this to subscribe to the event using an event handler:

class Program 
    private readonly Sdkwrapper iracing;

    static void Main(string[] args)
        iracing = new SdkWrapper;
        iracing.TelemetryUpdated += OnTelemetryUpdated;

    private void OnTelemetryUpdated(object sender, SdkWrapper.TelemetryUpdatedEventArgs e)
        // Use live telemetry

In F#, we subscribe to the event using Observable.subscribe and call our writeToCsv function each time the event is fired:

    |> Observable.subscribe writeToCsv |> ignore

The interop between C# and F# events is nice, and I find this solution to be quite elegant. In addition, you also have Observable.scan which accumulates state each time an event has fired. I haven’t had to do that in this example, but you can see more details here.

Plotting the Telemetry Data

Now that we have a backend system logging some telemetry data from the sim, the next step is to plot the graph we saw above. In order to do so, I created a new .NET Interactive notebook in VS Code Insiders. I then imported the libraries I needed and wrote the following initial code:

let FilePath = """C:\LapTimes.csv"""

type rawCsv = CsvProvider<FilePath, HasHeaders = true>

// CSV File
let lapPerformance = rawCsv.GetSample()

The use of [<Literal>] here is because FilePath must be a constant so that the CsvProvider can read the data while we’re developing. As I mentioned in a previous article on CSV files in F#, “Type providers are a blessing and curse in F#. On one hand, they’re amazing, because you get compile-time types for your data! But, that also means the data must be available at compile time. You can usually work around this by either:

  • Including representative data inside your project’s git repo, so you can build the provider based on sample data and then parse any conforming input data
  • Using a string literal in source code to define sample data and use that for the provider (which is what I’ve done here).”

I also added some column headers into my CSV file (in the future, I plan to write these automatically in the backend code). We also create a new CsvProvider, and then get the data by calling GetSample() on the raw CSV file.

Next, let’s write the createChart function and then pass it out formatted CSV file:

let createChart(lapPerformance: rawCsv) = 

    let speed = lapPerformance.Rows |> Seq.filter (fun row -> row.Lap = 2 )
    // Map Lap Distance and Speed to X and Y on the line chart.
    |> Seq.map (fun row -> row.LapDistance, row.Speed) 
    |> Chart.Spline
    |> Chart.withTitle "Laguna Seca (Ferrari 488 GTE)"
    |> Chart.withX_AxisStyle ("Lap Distance (%)", Showgrid=false,Position=200.0)
    |> Chart.withY_AxisStyle ("Speed (Km/h)", Showgrid=false)
    |> Chart.withMargin(Margin.init(120, 100, 50, 150, 0, true))
    |> Chart.withSize (900.0, 600.0)
    |> Chart.Show
createChart(lapPerformance: rawCsv) 

The first thing I do is make use of a higher order function, Seq.filter in order to only include lap two for plotting. Thanks to type providers, we can reference columns (e.g. row.Lap) directly while developing! I find this super handy as I no longer need to reference the file itself to figure out which columns are which.

Next, we construct a pipeline using the forward piping operator (|>) to map the lap distance and speed variables as X and Y values on a line chart, and then define how the chart should be styled. I really like the forward piping operator, and I find that it produces some really clean and concise code.

Exploratory Data with Deedle

For a project at work, I’ve been exploring the use of Deedle as an alternative to Python’s Pandas, and it could be a good library to make use of here to explore telemetry data in greater detail.

The first thing I tried using Deedle, was seeing what my average lap speed was across laps 1-4 at the Laguna Seca Racetrack:

let lapCsv = Frame.ReadCsv("""C:\LapTimes.csv""", hasHeaders = true)

let speed = lapCsv.GetColumn<int>("Speed")
|> Stats.mean
|> printf "Average speed: %A Km/h"

The results were:

Average speed:
121.036 Km/h

In this example, we’re loading our CSV file into a Deedle Dataframe, and then binding the Speed column to speed. Next, we make use of pipeline operator to get the mean of the column and print that to the console.

Next Steps

Although there are much better telemetry systems out there, working on my own simple version has allowed me to better see the differences between C# and F#, while at the same time seeing how I can use C# libraries in F# in order to accomplish different tasks. In addition, It would be a good idea to buffer the output as opposed to writing to the file each tick, but since its only two updates per second and doesn’t need to scale, I’m okay with leaving it as is for now.

Moving forward, the next step is to parse the CSV file and then plot several laps together to analyze breaking points and speed on the straights to see if I can improve on any specific areas of the track.

Lastly, thanks to F# great interop with C#, I’m not so sure that there’s a need to port the library to F#, and I might instead look towards some other areas of F# where there’s more of a need for a library.