Open (Bicycle) Grade Simulator - OpenGradeSIM




Introduction: Open (Bicycle) Grade Simulator - OpenGradeSIM


A certain well known US based fitness company (Wahoo) recently brought out a great indoor training aid that raises and lowers the front of the bike on the turbo trainer according to the simulated grade of hill that the user is riding (the Kickr Climb).

Looks amazing but sadly this is not available to us all as you will need 1) a top of the range Wahoo trainer and 2) £500 cash to make this yours.

I broke a clavicle (never put a road cyclist on a mountain bike) so I had more miles on the trainer and more time to tinker and thought this could be a fun project.

The commercial unit simulates -5% to +20% so I wanted to come close to that but on 10% of the budget!

This is designed around my Tacx Neo but any trainer that broadcasts its power and speed data via ANT+ or BLE could be made to work (I reckon!).

Since the wheel base on my road bike measures exactly 1000mm I'd need to lift the forks by 200mm to simulate 20% (see pic) so a 200mm linear actuator would do. The bike + rider weight is unlikely to exceed 100kg and since this is distributed between the axles and most is on the back 750N will lift 75kg and should be ok. Faster actuators are available for more money but this one cost me around £20 and manages 10mm/sec. Actuators with potentiometers that can be used as simple servos are also 2 to 3 times more expensive.


3D print (PLA or ABSetc) of the through axle adapter part:

100mm of 3/4 inch 10 swg aluminium tube stock (for a through axle frame)

80mm of 6mm stainless steel bar stock

3D print (PLA or ABSetc) of the shoe for the linear actuator part:

3D print of the Case for the H-bridge

3D print of the Case for Arduino (Version 1 with keypad) (Version 2 as shown (

Laser cut piece of 3mm clear acrylic 32 x 38mm to keep you from sweating all over the electronics (did that, not ideal).

Some bleeding blocks (adapted to leave the pads in) to prevent you accidentally pushing the calliper pistons out of your Shimano disc brakes in your enthusiasm

Linear Actuator 750N 200mm travel eg Al03 Mini Linear Actuators from

L298N H bridge (like:

Arduino Nano IoT 33 order 73-4863

2 key membrane keyboard eg

IIC I2C Logic Level Converter Bi-Directional Module 5V to 3.3V For Arduino eg

12V 3A DC power supply - the ones for LED lighting work great!

NPE CABLE Ant+ to BLE bridge

3D printable clip for the CABLE bridge

1.3" OLED LCD Display Module with IIC I2C Interface 128x32 3.3V

Step 1: Some Mathematics

We need to calculate the incline being simulated. I had hoped that the trainer would advertise this data along with speed, power, cadence etc. however the trainer simply sets resistance to maintain power output according to the software on the tablet, computer etc being used to control it. I had no way to easily capture the 'simulated grade' from the software so I would have to work backwards...

The forces acting on the bike and rider are a combination of resistive losses and the power needed to climb the hill. The trainer reports speed and power. If we can find the resistive losses at a given speed then the remaining power is being used to climb the hill. The power to climb depends on the weight of the bike and rider and the rate of ascent and so we can work back to the incline.

First I used the amazing to find some data points for resistive power loss at typical speeds. Then I transformed the speed domain to produce a linear relationship and found the best fit line. Taking the equation of the line we can now calculate power (W) from resistance = (0.0102*(Speedkmh^2.8))+9.428.

Take the power from resistance from the measured power to give power of 'climbing'.

We know the speed of ascent in km/hr and convert this to SI units of m/s (divide by 3.6).

Incline is found from: Incline (%) =((PowerClimbing/(WeightKg*g))/Speed)*100

where acceleration of free fall g = 9.8m/s/s or 9.8 N/kg

Step 2: Get Some Data

The incline calculation requires speed and power. I used an Arduino Nano 33 IoT to connect to the trainer via BLE to receive this. I got very stuck initially as the current v.1.1.2 version of the native ArduinoBLE library for this module does not handle authentication in any form which means most(?) commercial BLE sensors will not pair with it.

The solution was to use an NPE Cable ANT+ to BLE bridge ( which keeps the built in BLE of the trainer free for the training app to communicate over and requires no authentication on the BLE side.

The BLE power characteristic is pretty straightforward as power in watts is contained in the second and third bytes of the transmitted data as a 16 bit integer (little endian ie least significant octet first) . I applied a moving average filter to give 3s average power - just like my bike computer shows - as this is less erratic.

if (powerCharacteristic.valueUpdated()) {

// Define an array for the value

uint8_t holdpowervalues[6] = {0,0,0,0,0,0} ;

// Read value into array

powerCharacteristic.readValue(holdpowervalues, 6);

// Power is returned as watts in location 2 and 3 (loc 0 and 1 is 8 bit flags)

byte rawpowerValue2 = holdpowervalues[2];       // power least sig byte in HEX

byte rawpowerValue3 = holdpowervalues[3];       // power most sig byte in HEX

long rawpowerTotal = (rawpowerValue2 + (rawpowerValue3 * 256));

 // Use moving average filter to give '3s power'

powerTrainer = movingAverageFilter_power.process(rawpowerTotal);

The BLE speed characteristic (Cycling Speed and Cadence) is one of those things that makes you wonder what on earth the SIG was smoking when they wrote the specification.

The Characteristic returns a 16 byte array that contains neither speed nor cadence. Instead you get wheel revolutions and crank revolutions (totals) and time since last event data in 1024ths of a second. So more maths then. Oh, and the bytes are not always present so there is a flag byte at the start. Oh, and the bytes are little endian HEX so you need to read backwards multiplying the second byte by 256, third by 65536 etc. then adding them together. To find speed you need to assume a standard bike wheel circumference to know distance....

if (speedCharacteristic.valueUpdated()) {

//  This value needs a 16 byte array

      uint8_t holdvalues[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} ;

//  But I'm only going to read the first 7

      speedCharacteristic.readValue(holdvalues, 7);

byte rawValue0 = holdvalues[0];      // binary flags 8 bit int
byte rawValue1 = holdvalues[1];      // revolutions least significant byte in HEX   
byte rawValue2 = holdvalues[2];      // revolutions next most significant byte in HEX
byte rawValue3 = holdvalues[3];      // revolutions next most significant byte in HEX
byte rawValue4 = holdvalues[4];      // revolutions most significant byte in HEX
byte rawValue5 = holdvalues[5];      // time since last wheel event least sig byte 
byte rawValue6 = holdvalues[6];      // time since last wheel event most sig byte

      if (firstData) {

// Get cumulative wheel revolutions as little endian hex in loc 2,3 and 4 (least significant octet first)

WheelRevs1 = (rawValue1 + (rawValue2 * 256) + (rawValue3 * 65536) + (rawValue4 * 16777216));

// Get time since last wheel event in 1024ths of a second

Time_1 = (rawValue5 + (rawValue6 * 256));

        firstData = false;

      } else {
// Get second set of data         

long WheelRevsTemp = (rawValue1 + (rawValue2 * 256) + (rawValue3 * 65536) + (rawValue4 * 16777216));

long TimeTemp = (rawValue5 + (rawValue6 * 256));

if (WheelRevsTemp > WheelRevs1) {           // make sure the bicycle is moving

WheelRevs2 = WheelRevsTemp;

Time_2 = TimeTemp;

firstData = true;}
// Find distance difference in cm and convert to km          <br><br>float distanceTravelled = ((WheelRevs2 - WheelRevs1) * wheelCircCM);
          <br>float kmTravelled = distanceTravelled / 1000000;
 // Find time in 1024ths of a second and convert to hours
          <br>float timeDifference = (Time_2 - Time_1);
          <br>float timeSecs = timeDifference / 1024;
          <br>float timeHrs = timeSecs / 3600;
// Find speed kmh
          <br>speedKMH = (kmTravelled / timeHrs);

The Arduino sketch is hosted at GitHub (

Step 3: Hardware 1 the Linear Actuator

The through axle on my disc brake road bike specifies a 19.2mm axel to clear the 12mm through axle with 100mm between the forks.

Stock 3/4 inch 10swg aluminium tube is a perfect fit and a nice chap called Dave on ebay ( supplied and cut it to length for me for a few pounds.

The actuator has a 20mm bar with a 6mm hole so the 3D printed part links the aluminium tube to a 6mm steel bar and since the forces are 90% compression some PLA / ABS is up to the challenge.

If you run a standard quick release setup then something like this ( would avoid having to redesign this component.

The boot is designed to fit into the raiser block supplied with my Tacx trainer but would probably fit into many similar raisers or you can just edit the TinkerCad file to suit your requirement.

Step 4: Hardware 2 - the H-Bridge

These L298N H bridge board that are very common online have a built in 5V regulator which is great for powering the Arduino from the 12V power supply required for the linear actuator. Unfortunately the Arduino Nano IoT board is 3.3V signalling hence the need for a logical level convertor (or an optoisolator since the signals are unidirectional only).

The case is designed to accept the power connectors commonly used in LED lighting applications. I butchered a USB extension lead to make it possible to connect / disconnect the Arduino head unit easily and whilst I was sure to use the power lines for power and the data lines for the 3.3V signalling I would honestly advise AGAINST this as I'd hate someone to fry their USB ports or peripherals by plugging them in by mistake!

Step 5: Hardware 3 the Control Electronics (Arduino)

The case for the Arduino OLED and logic level converter has a standard 1/2 turn Garmin style mount on the back to allow it to be mounted securely to the bike. An 'out front' mount will allow the unit to be tilted up or down to 'zero' the accelerometer position or a line of code just to auto zero at the start would be easy to add.

The case has a spot for a membrane keypad - this is used to set the combined rider and bike weight. You can just set this programmatically especially if you don't share a trainer with anyone.

It might be nice to implement a 'manual' mode. Perhaps pressing both buttons could initiate a manual mode and then the buttons could increase / decrease incline. I'll add this to the to-do list!

The STL file of the case is, again, available on Thingiverse (see the supplies section for link).

The Arduino sketch is hosted at GitHub (

You can print a neat little clip for your CABLE bridge from here

Step 6: 'The Rear Drop Outs'

Many people have raised the issue of the rear drop out rubbing as the bike moves. Some trainers have an axle that moves (like the Kickr) but many do not.

Currently my best solution for me is to mount some standard 61800-2RS deep groove bearings (about £2 each) on the quick release adapters and then mount the through axel drop outs on these (see pics) with an over size QR skewer

The bearings need a thin shim washer eg M12 16mm 0.3mm between the adapter and the bearing.

They fit perfectly and rotate with the bike and the skewer independently of the trainer.

At the moment this changes the offset on the drive side by a couple of mm so you'll need to re-index

I am designing custom parts (see pdf plan) to machine (on my future brother-in-law's lathe when he has an hour to help!). These are not tested yet!!! But grinding 1mm off the inner surface of the stock drive side QR adapter is a quick fix with no special tools ;)

Made with Math Contest

Participated in the
Made with Math Contest

Be the First to Share


    • Woodworking Contest

      Woodworking Contest
    • Go Big Challenge

      Go Big Challenge
    • Origami Speed Challenge

      Origami Speed Challenge



    1 year ago

    I can connect with my Kickr core, but for some reason it only shows the cycling power service 0x1818, can't seem to finde the CSC service. or does wahoo uses not the standard service?


    Reply 1 year ago

    resolution: The wahoo trainer supports the speed and cadence trough the cycling power service.
    you need to check the flags (first 2 bytes) of the subcribed data to check at which position you can find them


    Reply 4 months ago

    Hi Johnny, how exactely have you fixed that issue?


    Question 4 months ago on Introduction


    after some month of outdoor exercising I want to finish my version of grade simulator, inspired by your great work.

    I struggle a bit with access to speed ab power data and got this output on serial monitor:

    Discovering Cycle Speed and Cadence service ...
    Cycle Speed and Cadence Service discovered
    Discovering Cycle Power service ...
    Cycle Power Service discovered
    can not subscribe to speed
    can not subscribe to speed and power

    I'm using exactely the same hardware and Wahoo kickr core.
    Any idea what the root cause for this issue might be?

    Best regards



    Question 5 months ago

    I have big problem with the OLED display, I bought 3 items and not working (black screen) the examples from the library (hello world etc.) are also not working.
    Connection are checked many times I check on other types of microcontroller (wemos, uno, nano)

    Some one have an idea whats is wrong ?, picture of the old below


    6 months ago

    Great job, i was thinking about how to DIY the elevation simulator and possess it cheaper than devices on the market.


    1 year ago

    A few years ago a colleague explored the ANT+ data packets but could not see the grade information and unlike you, we did not think to get it another way using the other data. Brilliant!
    In the end I went for a web page which you set to 'watch' the zwift screen - i.e. use zwift as a video source. Using just JavaScript and Canvas in a static HTML page (no server, no app, just drop the file onto the browser), I used the % character as a locator and once found the software grabs a part of the screen where the numbers are, cleans up the image to pure black/white of the numbers and then does a simple pattern match. The web page samples once per second (although it only takes about 50-60ms to perform the match) and then sends this number via a web-socket to an ESP8266 board set up on your home wifi to operate a motor and screw thread.
    Different approach to yours but I do get exactly the number the user sees on the display screen but with 1 second lag though - I'm tempted to try your approach now! But I'm not a cyclist and don't have a trainer. Not sure if I would be able to try your approach out. Mine works with just the computer running zwift in the 'just watch' mode.
    After all my work, yours seems neat - it made me smile!


    Reply 11 months ago

    Hi David, I really appreciate your approach. Could you describe more your solution regarding the "watch" the zwift screen ? Could you share it?


    1 year ago

    Just getting this sorted and there are a few additions worth noting. Soft reset jumper is at Pin 20 not Pin 19. Also, wiring diagram doesn't show KeyPad input which get connected to Pins 5, 6 and 7. (Up, Down and Common). Currently stuck with Compile error "NVMCTRL was not declared in this scope" which since I'm new to Arduino is a complete mystery at this point.

    Fantastic job on this BTW! Super fun project!


    Reply 1 year ago

    Hi Surfski, Glad you are enjoying it - still use mine. I left the keypad off my final version so admit I rather forgot about it! Try commenting out or deleting #include <FlashStorage.h> as this seems the likely culprit and is still (!) on the to-do list to implement so doesn't add anything at present.


    Question 1 year ago

    hi, great project and nice inspiration for me. The GradeSim Shoe doesn't seem to be on thingiverse no longer. Can you please send me this file?


    1 year ago

    Sorry - Another question. In the Arduino Sketch files on GitHub there are two different versions:
    What are the two different versions for? I see that the Actuator Out Pins are swapped between the files, the Moving Average Filter power changes, the Float Version number, etc. Which should I be looking to load to my Arduino and do I need to adjust any setting for a slightly more powerful actuator?


    1 year ago

    I'm not clear from the guidance how the wiring for the membrane keypad works? Also, is there guidance on how to program the Arduino with the Sketch code? (I've not programmed an Arduino before). Final question: Once powered up, will the Cable automatically find the Tacx Neo Ant+ data (I have a Tacx Neo, also) and the Arduino automatically pick up on the Cable BLE broadcast? Sorry if these are dumb questions - It's new territory for me.


    Question 1 year ago

    This looks to be a great job.... This my be a stupid question as I have not fully researched yet but will this operate using the Wahoo trainer or does each trainer have specific needs?


    1 year ago

    I congratulate you, you did a great job ..
    I tried to do something similar too, starting from the work you developed.
    As a mechanical part I used a worm screw controlled by a motor for wipers, the motor reverses the direction of travel via a shield with two relays controlled by an arduino.
    At first I had created this simulator using the arduino uno and varied the inclination using a trimmer, but after reading what YOU did, I did it all again with the arduino nano 33 iot.
    I didn't need the Cable, I managed to get my trainer (elite suito) to connect arduino without external interfaces.


    Reply 1 year ago

    Hi Luigi, did you follow the exact steps as in tutorial, apart form the actuator? Do you use it with Zwift? How does it operate in real world. Thank you in advance. I'm in a process of sourcing parts. Hepefully will get this working over the next couple of weeks.


    Reply 1 year ago

    hi, i don't use zwitf, but there should be no problem using it, because arduino is connected via bluetooth with the trainer and not with the computer.
    The slope is calculated by arduino.
    The code that MATT has made available works great. obviously unless you have the same roller as MATT, the program is not plug and play, there are some things to adapt like the wheel circumference used for the calculations, the weight, etc.
    I used a card with two relays to move the motor.
    Arduino feed alone through the notebook's USB, while the relay and the motor are powered by a separate transformer.
    i left the power supplies separate because arduino is delicate.

    Matt Ockendon
    Matt Ockendon

    Reply 1 year ago

    That looks like some very tidy engineering. Fantastic!