├── LICENSE ├── OpenGradeSIM_CombinedCode_027.ino ├── OpenGradeSIM_CombinedCode_100.ino └── README.md /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Matt Ockendon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /OpenGradeSIM_CombinedCode_027.ino: -------------------------------------------------------------------------------- 1 | /* 2 | OpenGradeSimulator by Matt Ockendon 2019.11.14 3 | 4 | ____ _____ __ ____ ____ __ ___ 5 | / __ \ ___ ___ ___ / ___/____ ___ _ ___/ /___ / __// _// |/ / 6 | / /_/ // _ \/ -_)/ _ \/ (_ // __// _ `// _ // -_)_\ \ _/ / / /|_/ / 7 | \____// .__/\__//_//_/\___//_/ \_,_/ \_,_/ \__//___//___//_/ /_/ 8 | /_/ 9 | 10 | This is the controller for a 3D printed elevation or 'grade' simulator to use with an indoor trainer 11 | The project in inspired by the Wahoo Kickr Climb but shares none of its underpinnings. 12 | 13 | Elevation is simulated on an indoor trainer by increasing resistance over that generated by frictional 14 | losses. 15 | 16 | I found the equation of a best fit line from points plotted using an online calculator of frictional losses vs speed 17 | and then took the residual power to calculate the incline being simulated 18 | 19 | Rather than using a servo linear actuator (expensive) I'm using the Arduino Nano 33 IoT BLE's built in 20 | accelerometers to find the position of the bicycle. This method is prone to noise and I have tried 21 | some filtering (moving average) to reduce this. 22 | 23 | The circuit: 24 | Arduino Nano 33 BLE 25 | 3.3 to 5v level shifter 26 | L298N H bridge 27 | 750Newton 200mm Linear Actuator 28 | 1x2 pushbutton pad 29 | 128x32 I2C OLED display 30 | 3D printed parts and boxes 31 | At present a NPE CABLE ANT+ to BLE bridge is required 32 | (due to the lack of authentication in the 33 | AdruinoBLE library 1.1.2) 34 | 35 | This code is in the public domain. 36 | Uses the moving average filter of sebnil https://github.com/sebnil/Moving-Avarage-Filter--Arduino-Library- 37 | and the Flash Storage library https://github.com/sebnil/Moving-Avarage-Filter--Arduino-Library- 38 | 39 | */ 40 | 41 | #include 42 | #include 43 | #include 44 | #include 45 | #include 46 | #include 47 | #include 48 | #include 49 | 50 | 51 | #define SCREEN_WIDTH 128 // OLED display width, in pixels 52 | #define SCREEN_HEIGHT 32 // OLED display height, in pixels 53 | #define buttonUpPin 5 // For the keypad 54 | #define buttonDownPin 6 // For the keypad 55 | #define buttonCommonPin 7 // For the keypad 56 | #define actuatorOutPin1 3 // To the level shifter and then to the H Bridge 57 | #define actuatorOutPin2 2 // To the level shifter and then to the H Bridge 58 | #define resetPin 19 // Pin linked to RST to allow software to do hard reset 59 | 60 | // Declare our filters 61 | MovingAverageFilter movingAverageFilter_x(9); // 62 | MovingAverageFilter movingAverageFilter_y(9); // Moving average filters for the accelerometers 63 | MovingAverageFilter movingAverageFilter_z(9); // 64 | MovingAverageFilter movingAverageFilter_power(8); // 3 second power average at 4 samples per sec 65 | MovingAverageFilter movingAverageFilter_speed(6); // 2 second speed average at 4 samples per sec 66 | 67 | 68 | // Declaration for an SSD1306 display connected to I2C (SDA, SCL pins) 69 | #define OLED_RESET -1 // No reset pin on cheap OLED display 70 | Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); 71 | 72 | // For incline declare some variables and set some default values 73 | 74 | float versionNumber = 0.27; // version 75 | long previousMillis = 0; // last time in ms 76 | long weightPrevMillis = 0; // last time for weight setting 77 | long weightMillis = 0; // time for weight setting 78 | long actuatorMillis = 0; // time for moving the actuator 79 | float smoothRadPitch = 0; // variable for the pitch 80 | int incline = 0; // variable for the % incline (actual per accelerometers) 81 | int gradeCalculated = 15; // variable for the calculated grade (aim) 82 | FlashStorage(weight_storage, int);// place to store rider weight 83 | int riderWeight = 95; // variable for combined rider and bike weight 84 | int powerTrainer = 0; // variable for the power (W) read from bluetooth 85 | int speedTrainer = 0; // variable for the speed (kph) read from bluetooth 86 | float speedMpersec = 0; // for calculation 87 | float resistanceWatts = 0; // for calculation 88 | float powerMinusResistance = 0; // for calculation 89 | bool weightIsSet = false; // note whether the weight setting is done 90 | int buttonStateUp = 0; // variable for reading the UP pushbutton status 91 | int buttonStateDown = 0; // variable for reading the UP pushbutton status 92 | 93 | // For power and speed declare some variables and set some default values 94 | 95 | int wheelCircCM = 2350; // Wheel circumference in centimeters (700c 32 road wheel) 96 | long WheelRevs1; // For speed data set 1 97 | long Time_1; // For speed data set 1 98 | long WheelRevs2; // For speed data set 2 99 | long Time_2; // For speed data set 2 100 | bool firstData = true; 101 | int speedKMH; // Calculated speed in KM per Hr 102 | 103 | // Custom Char Bluetooth Logo 104 | 105 | byte customChar[] = { 106 | B00000, 107 | B00110, 108 | B00101, 109 | B10110, 110 | B01100, 111 | B10110, 112 | B00101, 113 | B00110 114 | }; 115 | 116 | // Our BLE peripheral and characteristics 117 | 118 | BLEDevice cablePeripheral; 119 | BLECharacteristic speedCharacteristic; 120 | BLECharacteristic powerCharacteristic; 121 | 122 | ///////////////////////////////// Setup /////////////////////////////////////// 123 | 124 | void setup() { 125 | Serial.begin(9600); 126 | 127 | // riderWeight = weight_storage.read(); // Need to sort out saving the weight in the weightset method 128 | 129 | 130 | delay(2000); 131 | 132 | // setup control pins and set to lower trainer by default 133 | pinMode(actuatorOutPin1, OUTPUT); 134 | pinMode(actuatorOutPin2, OUTPUT); 135 | digitalWrite(actuatorOutPin1, LOW); 136 | digitalWrite(actuatorOutPin2, HIGH); 137 | 138 | // setup input pins for keypad and set adjacent pin to output low to act as a sink 139 | pinMode (buttonUpPin, INPUT_PULLUP); 140 | pinMode (buttonDownPin, INPUT_PULLUP); 141 | pinMode (buttonCommonPin, OUTPUT); 142 | digitalWrite (buttonCommonPin, LOW); 143 | 144 | // setup a pin connected to RST (A5, pin 19) to pull reset low if reset is required 145 | pinMode (resetPin, OUTPUT); 146 | digitalWrite (resetPin, HIGH); 147 | 148 | Serial.begin(9600); 149 | 150 | if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // Address 0x3C for 128x32 151 | Serial.println(F("SSD1306 allocation failed")); 152 | resetSystem(); 153 | } 154 | 155 | // Show initial display buffer contents on the screen -- 156 | // the library initializes this with a splash screen (edit the splash.h in the library). 157 | 158 | display.setRotation(2); 159 | display.display(); 160 | delay(1000); // Pause for 1 seconds 161 | display.clearDisplay(); 162 | 163 | // Show the firmware version 164 | 165 | display.setTextSize(1); 166 | display.setTextColor(SSD1306_WHITE); 167 | display.setCursor(5, 10); 168 | display.print(F("FW Version: ")); 169 | display.print(versionNumber); 170 | display.display(); 171 | delay(1000); // Pause for 2 seconds 172 | display.clearDisplay(); 173 | 174 | 175 | // Check that the accelerometer is up and running else reset 176 | 177 | if (!IMU.begin()) { 178 | Serial.println("Failed to initialize IMU!"); 179 | resetSystem(); 180 | } 181 | 182 | 183 | // begin BLE initialization reset if fails 184 | if (!BLE.begin()) { 185 | Serial.println("starting BLE failed!"); 186 | resetSystem(); 187 | } 188 | 189 | } 190 | 191 | //////////////////////////////// loop /////////////////////////////////////// 192 | 193 | void loop() { 194 | 195 | // BLE setup begins 196 | 197 | while (!cablePeripheral.connected()) { 198 | Serial.println("BLE Central"); 199 | Serial.println("Turn on trainer and CABLE module and check batteries"); 200 | // Scan or rescan for BLE services 201 | 202 | display.setTextSize(1); 203 | display.setTextColor(SSD1306_WHITE); 204 | display.setCursor(5, 10); 205 | display.print(F("BLE Scanning")); 206 | display.setCursor(5, 20); 207 | display.print(F("for CABLE Device")); 208 | display.display(); 209 | display.clearDisplay(); 210 | 211 | 212 | 213 | BLE.scan(); 214 | 215 | // check if a peripheral has been discovered and allocate it 216 | cablePeripheral = BLE.available(); 217 | 218 | if (cablePeripheral) { 219 | // discovered a peripheral, print out address, local name, and advertised service 220 | Serial.print("Found "); 221 | Serial.print(cablePeripheral.address()); 222 | Serial.print(" '"); 223 | Serial.print(cablePeripheral.localName()); 224 | Serial.print("' "); 225 | Serial.print(cablePeripheral.advertisedServiceUuid()); 226 | Serial.println(); 227 | 228 | 229 | if (cablePeripheral.localName() == ">CABLE") { 230 | // stop scanning 231 | BLE.stopScan(); 232 | Serial.println("got CABLE device scan stopped"); 233 | 234 | display.setTextSize(1); 235 | display.setTextColor(SSD1306_WHITE); 236 | display.setCursor(5, 10); 237 | display.print(F("CABLE Found")); 238 | display.setCursor(5, 20); 239 | display.print(F("Authenticating")); 240 | display.display(); 241 | display.clearDisplay(); 242 | 243 | 244 | // Do the BLE niceties and subscribe to speed and power 245 | getsubscribedtoSensor(cablePeripheral); 246 | 247 | } 248 | } 249 | } 250 | 251 | // Get any updated data 252 | refreshSpeedandpower(); 253 | 254 | long currentMillis = millis(); 255 | 256 | // Call the set weight method 257 | 258 | if (weightIsSet==false){ 259 | weightMillis = millis(); 260 | setWeight(); 261 | 262 | if (weightMillis - weightPrevMillis >=5000){weightIsSet = true;} // times up, set weight set 263 | } 264 | 265 | 266 | // if 100ms have passed and weight is set, check the variables and update the system 267 | if ((currentMillis - previousMillis >= 100) && (weightIsSet)){ 268 | previousMillis = currentMillis; 269 | 270 | // read the accelerometer 271 | findTrainerIncline(); 272 | 273 | // Calculate the incline 274 | calculateGrade(); 275 | 276 | // Display the current data 277 | lcdDisplayData(); 278 | 279 | // Update the actuator positon only if the trainer is in use and time is at least 10s since last move 280 | 281 | if ((currentMillis > (actuatorMillis + 5000)) &&(powerTrainer > 40) && (speedTrainer > 5)) 282 | { moveActuator(); 283 | actuatorMillis = currentMillis; 284 | } 285 | 286 | } 287 | } // end of loop 288 | 289 | //////////////////////// method declarations /////////////////////////////// 290 | 291 | void getsubscribedtoSensor(BLEDevice cablePeripheral) { 292 | // connect to the peripheral 293 | Serial.println("Connecting ..."); 294 | if (cablePeripheral.connect()) { 295 | Serial.println("Connected"); 296 | 297 | } else { 298 | Serial.println("Failed to connect to CABLE device"); 299 | return; 300 | } 301 | 302 | // discover Cycle Speed and Cadence attributes 303 | Serial.println("Discovering Cycle Speed and Cadence service ..."); 304 | if (cablePeripheral.discoverService("1816")) { 305 | Serial.println("Cycle Speed and Cadence Service discovered"); 306 | 307 | 308 | } else { 309 | Serial.println("Cycle Speed and Cadence Attribute discovery failed."); 310 | cablePeripheral.disconnect(); 311 | 312 | resetSystem(); 313 | return; 314 | } 315 | 316 | // discover Cycle Power attributes 317 | Serial.println("Discovering Cycle Power service ..."); 318 | if (cablePeripheral.discoverService("1818")) { 319 | Serial.println("Cycle Power Service discovered"); 320 | 321 | 322 | } else { 323 | Serial.println("Cycle Power Attribute discovery failed."); 324 | cablePeripheral.disconnect(); 325 | 326 | resetSystem(); 327 | return; 328 | } 329 | 330 | // retrieve the characteristics 331 | 332 | speedCharacteristic = cablePeripheral.characteristic("2a5B"); 333 | powerCharacteristic = cablePeripheral.characteristic("2a63"); 334 | 335 | 336 | // subscribe to the characteristics (note authentication not supported on ArduinoBLE library v1.1.2) 337 | 338 | if (!speedCharacteristic.subscribe()) { 339 | Serial.println("can not subscribe to speed"); 340 | }else{ 341 | Serial.println("subscribed to speed"); 342 | }; 343 | 344 | 345 | if (!powerCharacteristic.subscribe()) { 346 | Serial.println("can not subscribe to speed and power"); 347 | 348 | // outcome display on OLED 349 | display.setTextSize(1); 350 | display.setTextColor(SSD1306_WHITE); 351 | display.setCursor(5, 10); 352 | display.print(F("Subscribe FAILED")); 353 | display.setCursor(5, 20); 354 | display.print(F("Speed and Power")); 355 | display.display(); 356 | display.clearDisplay(); 357 | 358 | delay(5000); 359 | resetSystem(); 360 | 361 | } else { 362 | Serial.println("subscribed to speed and power"); 363 | 364 | // outcome display on OLED 365 | display.setTextSize(1); 366 | display.setTextColor(SSD1306_WHITE); 367 | display.setCursor(5, 10); 368 | display.print(F("Subscribed to")); 369 | display.setCursor(5, 20); 370 | display.print(F("Speed and Power")); 371 | display.display(); 372 | display.clearDisplay(); 373 | 374 | }; 375 | 376 | // The time consuming BLE setup is done, set timer for the weight setting routine 377 | weightPrevMillis = millis(); 378 | 379 | 380 | } 381 | 382 | void refreshSpeedandpower(void){ 383 | 384 | // Get updated power value 385 | 386 | if (powerCharacteristic.valueUpdated()) { 387 | 388 | // Define an array for the value 389 | 390 | uint8_t holdpowervalues[6] = {0,0,0,0,0,0} ; 391 | 392 | // Read value into array 393 | 394 | powerCharacteristic.readValue(holdpowervalues, 6); 395 | 396 | // Power is returned as watts in location 2 and 3 (loc 0 and 1 is 8 bit flags) 397 | 398 | byte rawpowerValue2 = holdpowervalues[2]; // power least sig byte in HEX 399 | byte rawpowerValue3 = holdpowervalues[3]; // power most sig byte in HEX 400 | 401 | long rawpowerTotal = (rawpowerValue2 + (rawpowerValue3 * 256)); 402 | 403 | // Serial.print("Power: "); 404 | // Serial.println(rawpowerTotal); 405 | 406 | // Use moving average filter to give '3s power' 407 | powerTrainer = movingAverageFilter_power.process(rawpowerTotal); 408 | 409 | Serial.print("rawpowerValue2"); 410 | Serial.println(rawpowerValue2); 411 | Serial.print("rawpowerValue3"); 412 | Serial.println(rawpowerValue3); 413 | 414 | } 415 | 416 | // Get speed - a bit more complication as the GATT specification calls for Cumulative Wheel Rotations and Time since wheel event 417 | // So we'll need to do some maths 418 | 419 | if (speedCharacteristic.valueUpdated()) { 420 | 421 | // This value needs a 16 byte array 422 | 423 | uint8_t holdvalues[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} ; 424 | 425 | // But I'm only going to read the first 7 426 | 427 | speedCharacteristic.readValue(holdvalues, 7); 428 | byte rawValue0 = holdvalues[0]; // binary flags 8 bit int 429 | byte rawValue1 = holdvalues[1]; // revolutions least significant byte in HEX 430 | byte rawValue2 = holdvalues[2]; // revolutions next most significant byte in HEX 431 | byte rawValue3 = holdvalues[3]; // revolutions next most significant byte in HEX 432 | byte rawValue4 = holdvalues[4]; // revolutions most significant byte in HEX 433 | byte rawValue5 = holdvalues[5]; // time since last wheel event least sig byte in HEX 434 | byte rawValue6 = holdvalues[6]; // time since last wheel event most sig byte in HEX 435 | 436 | if (firstData) { 437 | // Get cumulative wheel revolutions as little endian hex in loc 2,3 and 4 (least significant octet first) 438 | WheelRevs1 = (rawValue1 + (rawValue2 * 256) + (rawValue3 * 65536) + (rawValue4 * 16777216)); 439 | // Get time since last wheel event in 1024ths of a second 440 | Time_1 = (rawValue5 + (rawValue6 * 256)); 441 | 442 | firstData = false; 443 | 444 | } else { 445 | 446 | // Get second set of data 447 | long WheelRevsTemp = (rawValue1 + (rawValue2 * 256) + (rawValue3 * 65536) + (rawValue4 * 16777216)); 448 | long TimeTemp = (rawValue5 + (rawValue6 * 256)); 449 | 450 | if (WheelRevsTemp > WheelRevs1) { // make sure the bicycle is moving 451 | WheelRevs2 = WheelRevsTemp; 452 | Time_2 = TimeTemp; 453 | firstData = true; 454 | 455 | // Find distance difference in cm and convert to km 456 | float distanceTravelled = ((WheelRevs2 - WheelRevs1) * wheelCircCM); 457 | float kmTravelled = distanceTravelled / 1000000; 458 | 459 | // Find time in 1024ths of a second and convert to hours 460 | float timeDifference = (Time_2 - Time_1); 461 | float timeSecs = timeDifference / 1024; 462 | float timeHrs = timeSecs / 3600; 463 | 464 | // Find speed kmh 465 | speedKMH = (kmTravelled / timeHrs); 466 | 467 | Serial.print(" speed: "); 468 | Serial.println(speedKMH, DEC); 469 | 470 | 471 | // Reject zero values 472 | if (speedKMH < 0){}else{ 473 | speedTrainer = movingAverageFilter_speed.process(speedKMH); // use moving average filter to find 3s average speed 474 | // speedTrainer = speedKMH; // redundant step to allow experiments with filters 475 | 476 | 477 | 478 | } 479 | } 480 | } 481 | 482 | } 483 | 484 | // we only need to do all this 4 or 5 times a second! 485 | delay(200); 486 | } 487 | 488 | void findTrainerIncline(void){ 489 | //Serial.print("findTrainerIncline"); 490 | float rawx, rawy, rawz; 491 | float x, y, z; 492 | 493 | if (IMU.accelerationAvailable()) { 494 | IMU.readAcceleration(rawx, rawy, rawz); 495 | 496 | x = movingAverageFilter_x.process(rawx); // 497 | y = movingAverageFilter_y.process(rawy); // Apply moving average filters to reduce noise 498 | z = movingAverageFilter_z.process(rawz); // 499 | 500 | // find pitch in radians 501 | float radpitch = atan2((- x) , sqrt(y * y + z * z)) ; 502 | 503 | smoothRadPitch = radpitch; 504 | 505 | // find the % grade from the pitch 506 | incline = tan(smoothRadPitch) * 100; 507 | 508 | }} 509 | 510 | void lcdDisplayData(void) { 511 | 512 | 513 | display.clearDisplay(); 514 | display.setTextSize(1); 515 | display.setTextColor(SSD1306_WHITE); 516 | 517 | // Display power top left 518 | 519 | display.setCursor(0, 0); 520 | display.print(powerTrainer); 521 | display.print(F(" W")); 522 | 523 | // Display speed top right if more than 4kph 524 | 525 | if(speedTrainer>4){ 526 | display.setCursor(80, 0); 527 | display.print(speedTrainer); 528 | display.print(F(" kph"));} 529 | else{ 530 | display.setCursor(80, 0); 531 | display.print("-- "); 532 | display.print(F(" kph")); 533 | } 534 | 535 | // Display weight bottom left 536 | 537 | display.setCursor(0, 24); 538 | display.print(riderWeight); 539 | display.print(F(" kg")); 540 | 541 | // Display target incline bottom right 542 | if(gradeCalculated>0){ 543 | display.setCursor(80, 24); 544 | display.print(gradeCalculated); 545 | display.print(F(" %"));} 546 | else{ 547 | display.setCursor(80, 24); 548 | display.print(F("0 %"));} 549 | 550 | 551 | // Display current incline centred and large 552 | display.setTextSize(2); // Draw 2X-scale text 553 | display.setCursor(50, 9); 554 | display.print(incline); 555 | display.print(F("%")); 556 | 557 | // Update the display 558 | display.display(); 559 | 560 | } 561 | 562 | void moveActuator(void) { 563 | 564 | // This method is ugly - just pausing the script while the actuator moves - there are many better ways - if only I had the time! 565 | // That said there will be more noise whilst moving so maybe some advantage 566 | 567 | int difference = incline-gradeCalculated; // Find the difference 568 | int absDifference = abs(difference); // Find the absolute (like rms) 569 | 570 | 571 | if (incline>gradeCalculated){ 572 | digitalWrite(actuatorOutPin1, LOW); 573 | digitalWrite(actuatorOutPin2, HIGH); 574 | } 575 | else if (incline0) && (absDifference <2)){ 581 | delay(1000); 582 | } 583 | if ((absDifference >=2) && (absDifference <3)){ 584 | delay(2000); 585 | } 586 | if ((absDifference >=3) && (absDifference <4)){ 587 | delay(3000); 588 | } 589 | if (absDifference >=4) { 590 | delay(4000); 591 | } 592 | 593 | digitalWrite(actuatorOutPin1, LOW); 594 | digitalWrite(actuatorOutPin2, LOW); 595 | 596 | 597 | 598 | } 599 | 600 | void calculateGrade(void) { 601 | float speed28 = pow(speedTrainer,2.8); // pow() needed to raise y^x where x is decimal 602 | resistanceWatts = (0.0102*speed28)+9.428; // calculate power from rolling / wind resistance 603 | powerMinusResistance = powerTrainer - resistanceWatts; // find power from climbing 604 | speedMpersec = speedTrainer/3.6; // find speed in SI units 605 | gradeCalculated = ((powerMinusResistance/(riderWeight*9.8))/speedMpersec)*100; // calculate grade of climb in % 606 | 607 | // Sense check 608 | if (gradeCalculated < -10){gradeCalculated = -10;} 609 | if (gradeCalculated > 20){gradeCalculated = 20;} 610 | } 611 | 612 | void resetSystem(void){ 613 | digitalWrite (19, LOW); 614 | } 615 | 616 | void setWeight(void){ 617 | 618 | // Read the buttons 619 | // If button state chaged then update value and reset timer 620 | 621 | buttonStateUp = digitalRead(buttonUpPin); 622 | buttonStateDown = digitalRead(buttonDownPin); 623 | 624 | if (buttonStateUp == LOW) { 625 | // turn LED on: 626 | riderWeight = riderWeight+1; 627 | delay (200); // low tech button debounce and limit autorepeat rate 628 | weightPrevMillis = weightMillis; 629 | } 630 | 631 | if (buttonStateDown == LOW) { 632 | // turn LED on: 633 | riderWeight = riderWeight-1; 634 | delay (200); 635 | weightPrevMillis = weightMillis; 636 | } 637 | 638 | // Update the display 639 | 640 | display.clearDisplay(); 641 | display.setTextSize(2); 642 | display.setTextColor(SSD1306_WHITE); 643 | display.setCursor(50, 9); 644 | display.print(riderWeight); 645 | display.print(F(" Kg")); 646 | display.display(); 647 | 648 | 649 | } 650 | 651 | 652 | 653 | -------------------------------------------------------------------------------- /OpenGradeSIM_CombinedCode_100.ino: -------------------------------------------------------------------------------- 1 | /* 2 | OpenGradeSimulator by Matt Ockendon 2019.11.14 3 | 4 | ____ _____ __ ____ ____ __ ___ 5 | / __ \ ___ ___ ___ / ___/____ ___ _ ___/ /___ / __// _// |/ / 6 | / /_/ // _ \/ -_)/ _ \/ (_ // __// _ `// _ // -_)_\ \ _/ / / /|_/ / 7 | \____// .__/\__//_//_/\___//_/ \_,_/ \_,_/ \__//___//___//_/ /_/ 8 | /_/ 9 | 10 | This is the controller for a 3D printed elevation or 'grade' simulator to use with an indoor trainer 11 | The project in inspired by the Wahoo Kickr Climb but shares none of its underpinnings. 12 | 13 | Elevation is simulated on an indoor trainer by increasing resistance over that generated by frictional 14 | losses. 15 | 16 | I found the equation of a best fit line from points plotted using an online calculator of frictional losses vs speed 17 | and then took the residual power to calculate the incline being simulated 18 | 19 | Rather than using a servo linear actuator (expensive) I'm using the Arduino Nano 33 IoT BLE's built in 20 | accelerometers to find the position of the bicycle. This method is prone to noise and I have tried 21 | some filtering (moving average) to reduce this. 22 | 23 | The circuit: 24 | Arduino Nano 33 BLE 25 | 3.3 to 5v level shifter 26 | L298N H bridge 27 | 750Newton 200mm Linear Actuator 28 | 1x2 pushbutton pad 29 | 128x32 I2C OLED display 30 | 3D printed parts and boxes 31 | At present a NPE CABLE ANT+ to BLE bridge is required 32 | (due to the lack of authentication in the 33 | AdruinoBLE library 1.1.2) 34 | 35 | This code is in the public domain. 36 | Uses the moving average filter of sebnil https://github.com/sebnil/Moving-Avarage-Filter--Arduino-Library- 37 | and the Flash Storage library https://github.com/sebnil/Moving-Avarage-Filter--Arduino-Library- 38 | 39 | */ 40 | 41 | #include 42 | #include 43 | #include 44 | #include 45 | #include 46 | #include 47 | #include 48 | #include 49 | 50 | 51 | #define SCREEN_WIDTH 128 // OLED display width, in pixels 52 | #define SCREEN_HEIGHT 32 // OLED display height, in pixels 53 | #define buttonUpPin 5 // For the keypad 54 | #define buttonDownPin 6 // For the keypad 55 | #define buttonCommonPin 7 // For the keypad 56 | #define actuatorOutPin1 2 // To the level shifter and then to the H Bridge 57 | #define actuatorOutPin2 3 // To the level shifter and then to the H Bridge 58 | #define resetPin 19 // Pin linked to RST to allow software to do hard reset 59 | 60 | // Declare our filters 61 | MovingAverageFilter movingAverageFilter_x(9); // 62 | MovingAverageFilter movingAverageFilter_y(9); // Moving average filters for the accelerometers 63 | MovingAverageFilter movingAverageFilter_z(9); // 64 | MovingAverageFilter movingAverageFilter_power(8); // 2 second power average at 4 samples per sec 65 | MovingAverageFilter movingAverageFilter_speed(2); // 0.5 second speed average at 4 samples per sec 66 | 67 | 68 | // Declaration for an SSD1306 display connected to I2C (SDA, SCL pins) 69 | #define OLED_RESET -1 // No reset pin on cheap OLED display 70 | Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); 71 | 72 | // For incline declare some variables and set some default values 73 | 74 | float versionNumber = 1.01; // version 75 | long previousMillis = 0; // last time in ms 76 | long weightPrevMillis = 0; // last time for weight setting 77 | long weightMillis = 0; // time for weight setting 78 | long actuatorMillis = 0; // time for moving the actuator 79 | float smoothRadPitch = 0; // variable for the pitch 80 | int incline = 0; // variable for the % incline (actual per accelerometers) 81 | int gradeCalculated = 15; // variable for the calculated grade (aim) 82 | FlashStorage(weight_storage, int);// place to store rider weight 83 | int riderWeight = 95; // variable for combined rider and bike weight 84 | int powerTrainer = 0; // variable for the power (W) read from bluetooth 85 | int speedTrainer = 0; // variable for the speed (kph) read from bluetooth 86 | float speedMpersec = 0; // for calculation 87 | float resistanceWatts = 0; // for calculation 88 | float powerMinusResistance = 0; // for calculation 89 | bool weightIsSet = false; // note whether the weight setting is done 90 | int buttonStateUp = 0; // variable for reading the UP pushbutton status 91 | int buttonStateDown = 0; // variable for reading the UP pushbutton status 92 | 93 | // For power and speed declare some variables and set some default values 94 | 95 | int wheelCircCM = 2300; // Wheel circumference in centimeters (700c 32 road wheel) 96 | long WheelRevs1; // For speed data set 1 97 | long Time_1; // For speed data set 1 98 | long WheelRevs2; // For speed data set 2 99 | long Time_2; // For speed data set 2 100 | bool firstData = true; 101 | int speedKMH; // Calculated speed in KM per Hr 102 | 103 | // Custom Char Bluetooth Logo 104 | 105 | byte customChar[] = { 106 | B00000, 107 | B00110, 108 | B00101, 109 | B10110, 110 | B01100, 111 | B10110, 112 | B00101, 113 | B00110 114 | }; 115 | 116 | // Our BLE peripheral and characteristics 117 | 118 | BLEDevice cablePeripheral; 119 | BLECharacteristic speedCharacteristic; 120 | BLECharacteristic powerCharacteristic; 121 | 122 | ///////////////////////////////// Setup /////////////////////////////////////// 123 | 124 | void setup() { 125 | Serial.begin(9600); 126 | 127 | // riderWeight = weight_storage.read(); // Need to sort out saving the weight in the weightset method 128 | 129 | 130 | delay(2000); 131 | 132 | // setup control pins and set to lower trainer by default 133 | pinMode(actuatorOutPin1, OUTPUT); 134 | pinMode(actuatorOutPin2, OUTPUT); 135 | digitalWrite(actuatorOutPin1, LOW); 136 | digitalWrite(actuatorOutPin2, HIGH); 137 | 138 | // setup input pins for keypad and set adjacent pin to output low to act as a sink 139 | pinMode (buttonUpPin, INPUT_PULLUP); 140 | pinMode (buttonDownPin, INPUT_PULLUP); 141 | pinMode (buttonCommonPin, OUTPUT); 142 | digitalWrite (buttonCommonPin, LOW); 143 | 144 | // setup a pin connected to RST (A5, pin 19) to pull reset low if reset is required 145 | pinMode (resetPin, OUTPUT); 146 | digitalWrite (resetPin, HIGH); 147 | 148 | Serial.begin(9600); 149 | 150 | if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // Address 0x3C for 128x32 151 | Serial.println(F("SSD1306 allocation failed")); 152 | resetSystem(); 153 | } 154 | 155 | // Show initial display buffer contents on the screen -- 156 | // the library initializes this with a splash screen (edit the splash.h in the library). 157 | 158 | display.setRotation(0); 159 | display.display(); 160 | delay(1000); // Pause for 1 seconds 161 | display.clearDisplay(); 162 | 163 | // Show the firmware version 164 | 165 | display.setTextSize(1); 166 | display.setTextColor(SSD1306_WHITE); 167 | display.setCursor(5, 10); 168 | display.print(F("FW Version: ")); 169 | display.print(versionNumber); 170 | display.display(); 171 | delay(1000); // Pause for 2 seconds 172 | display.clearDisplay(); 173 | 174 | 175 | // Check that the accelerometer is up and running else reset 176 | 177 | if (!IMU.begin()) { 178 | Serial.println("Failed to initialize IMU!"); 179 | resetSystem(); 180 | } 181 | 182 | 183 | // begin BLE initialization reset if fails 184 | if (!BLE.begin()) { 185 | Serial.println("starting BLE failed!"); 186 | resetSystem(); 187 | } 188 | 189 | } 190 | 191 | //////////////////////////////// loop /////////////////////////////////////// 192 | 193 | void loop() { 194 | 195 | // BLE setup begins 196 | 197 | while (!cablePeripheral.connected()) { 198 | Serial.println("BLE Central"); 199 | Serial.println("Turn on trainer and CABLE module and check batteries"); 200 | // Scan or rescan for BLE services 201 | 202 | display.setTextSize(1); 203 | display.setTextColor(SSD1306_WHITE); 204 | display.setCursor(5, 10); 205 | display.print(F("BLE Scanning")); 206 | display.setCursor(5, 20); 207 | display.print(F("for CABLE Device")); 208 | display.display(); 209 | display.clearDisplay(); 210 | 211 | 212 | 213 | BLE.scan(); 214 | 215 | // check if a peripheral has been discovered and allocate it 216 | cablePeripheral = BLE.available(); 217 | 218 | if (cablePeripheral) { 219 | // discovered a peripheral, print out address, local name, and advertised service 220 | Serial.print("Found "); 221 | Serial.print(cablePeripheral.address()); 222 | Serial.print(" '"); 223 | Serial.print(cablePeripheral.localName()); 224 | Serial.print("' "); 225 | Serial.print(cablePeripheral.advertisedServiceUuid()); 226 | Serial.println(); 227 | 228 | 229 | if (cablePeripheral.localName() == ">CABLE") { 230 | // stop scanning 231 | BLE.stopScan(); 232 | Serial.println("got CABLE device scan stopped"); 233 | 234 | display.setTextSize(1); 235 | display.setTextColor(SSD1306_WHITE); 236 | display.setCursor(5, 10); 237 | display.print(F("CABLE Found")); 238 | display.setCursor(5, 20); 239 | display.print(F("Authenticating")); 240 | display.display(); 241 | display.clearDisplay(); 242 | 243 | 244 | // Do the BLE niceties and subscribe to speed and power 245 | getsubscribedtoSensor(cablePeripheral); 246 | 247 | } 248 | } 249 | } 250 | 251 | // Get any updated data 252 | refreshSpeedandpower(); 253 | 254 | long currentMillis = millis(); 255 | 256 | // Call the set weight method 257 | 258 | if (weightIsSet==false){ 259 | weightMillis = millis(); 260 | setWeight(); 261 | 262 | if (weightMillis - weightPrevMillis >=5000){weightIsSet = true;} // times up, set weight set 263 | } 264 | 265 | 266 | // if 100ms have passed and weight is set, check the variables and update the system 267 | if ((currentMillis - previousMillis >= 100) && (weightIsSet)){ 268 | previousMillis = currentMillis; 269 | 270 | // read the accelerometer 271 | findTrainerIncline(); 272 | 273 | // Calculate the incline 274 | calculateGrade(); 275 | 276 | // Display the current data 277 | lcdDisplayData(); 278 | 279 | // Update the actuator positon only if the trainer is in use and time is at least 2.5s since last move 280 | 281 | if ((currentMillis > (actuatorMillis + 2500)) &&(powerTrainer > 40) && (speedTrainer > 5)) 282 | { moveActuator(); 283 | actuatorMillis = currentMillis; 284 | } 285 | 286 | } 287 | } // end of loop 288 | 289 | //////////////////////// method declarations /////////////////////////////// 290 | 291 | void getsubscribedtoSensor(BLEDevice cablePeripheral) { 292 | // connect to the peripheral 293 | Serial.println("Connecting ..."); 294 | if (cablePeripheral.connect()) { 295 | Serial.println("Connected"); 296 | 297 | } else { 298 | Serial.println("Failed to connect to CABLE device"); 299 | return; 300 | } 301 | 302 | // discover Cycle Speed and Cadence attributes 303 | Serial.println("Discovering Cycle Speed and Cadence service ..."); 304 | if (cablePeripheral.discoverService("1816")) { 305 | Serial.println("Cycle Speed and Cadence Service discovered"); 306 | 307 | 308 | } else { 309 | Serial.println("Cycle Speed and Cadence Attribute discovery failed."); 310 | cablePeripheral.disconnect(); 311 | 312 | resetSystem(); 313 | return; 314 | } 315 | 316 | // discover Cycle Power attributes 317 | Serial.println("Discovering Cycle Power service ..."); 318 | if (cablePeripheral.discoverService("1818")) { 319 | Serial.println("Cycle Power Service discovered"); 320 | 321 | 322 | } else { 323 | Serial.println("Cycle Power Attribute discovery failed."); 324 | cablePeripheral.disconnect(); 325 | 326 | resetSystem(); 327 | return; 328 | } 329 | 330 | // retrieve the characteristics 331 | 332 | speedCharacteristic = cablePeripheral.characteristic("2a5B"); 333 | powerCharacteristic = cablePeripheral.characteristic("2a63"); 334 | 335 | 336 | // subscribe to the characteristics (note authentication not supported on ArduinoBLE library v1.1.2) 337 | 338 | if (!speedCharacteristic.subscribe()) { 339 | Serial.println("can not subscribe to speed"); 340 | }else{ 341 | Serial.println("subscribed to speed"); 342 | }; 343 | 344 | 345 | if (!powerCharacteristic.subscribe()) { 346 | Serial.println("can not subscribe to speed and power"); 347 | 348 | // outcome display on OLED 349 | display.setTextSize(1); 350 | display.setTextColor(SSD1306_WHITE); 351 | display.setCursor(5, 10); 352 | display.print(F("Subscribe FAILED")); 353 | display.setCursor(5, 20); 354 | display.print(F("Speed and Power")); 355 | display.display(); 356 | display.clearDisplay(); 357 | 358 | delay(5000); 359 | resetSystem(); 360 | 361 | } else { 362 | Serial.println("subscribed to speed and power"); 363 | 364 | // outcome display on OLED 365 | display.setTextSize(1); 366 | display.setTextColor(SSD1306_WHITE); 367 | display.setCursor(5, 10); 368 | display.print(F("Subscribed to")); 369 | display.setCursor(5, 20); 370 | display.print(F("Speed and Power")); 371 | display.display(); 372 | display.clearDisplay(); 373 | 374 | }; 375 | 376 | // The time consuming BLE setup is done, set timer for the weight setting routine 377 | weightPrevMillis = millis(); 378 | 379 | 380 | } 381 | 382 | void refreshSpeedandpower(void){ 383 | 384 | // Get updated power value 385 | 386 | if (powerCharacteristic.valueUpdated()) { 387 | 388 | // Define an array for the value 389 | 390 | uint8_t holdpowervalues[6] = {0,0,0,0,0,0} ; 391 | 392 | // Read value into array 393 | 394 | powerCharacteristic.readValue(holdpowervalues, 6); 395 | 396 | // Power is returned as watts in location 2 and 3 (loc 0 and 1 is 8 bit flags) 397 | 398 | byte rawpowerValue2 = holdpowervalues[2]; // power least sig byte in HEX 399 | byte rawpowerValue3 = holdpowervalues[3]; // power most sig byte in HEX 400 | 401 | long rawpowerTotal = (rawpowerValue2 + (rawpowerValue3 * 256)); 402 | 403 | // Serial.print("Power: "); 404 | // Serial.println(rawpowerTotal); 405 | 406 | // Use moving average filter to give '3s power' 407 | powerTrainer = movingAverageFilter_power.process(rawpowerTotal); 408 | 409 | Serial.print("rawpowerValue2"); 410 | Serial.println(rawpowerValue2); 411 | Serial.print("rawpowerValue3"); 412 | Serial.println(rawpowerValue3); 413 | 414 | } 415 | 416 | // Get speed - a bit more complication as the GATT specification calls for Cumulative Wheel Rotations and Time since wheel event 417 | // So we'll need to do some maths 418 | 419 | if (speedCharacteristic.valueUpdated()) { 420 | 421 | // This value needs a 16 byte array 422 | 423 | uint8_t holdvalues[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} ; 424 | 425 | // But I'm only going to read the first 7 426 | 427 | speedCharacteristic.readValue(holdvalues, 7); 428 | byte rawValue0 = holdvalues[0]; // binary flags 8 bit int 429 | byte rawValue1 = holdvalues[1]; // revolutions least significant byte in HEX 430 | byte rawValue2 = holdvalues[2]; // revolutions next most significant byte in HEX 431 | byte rawValue3 = holdvalues[3]; // revolutions next most significant byte in HEX 432 | byte rawValue4 = holdvalues[4]; // revolutions most significant byte in HEX 433 | byte rawValue5 = holdvalues[5]; // time since last wheel event least sig byte in HEX 434 | byte rawValue6 = holdvalues[6]; // time since last wheel event most sig byte in HEX 435 | 436 | if (firstData) { 437 | // Get cumulative wheel revolutions as little endian hex in loc 2,3 and 4 (least significant octet first) 438 | WheelRevs1 = (rawValue1 + (rawValue2 * 256) + (rawValue3 * 65536) + (rawValue4 * 16777216)); 439 | // Get time since last wheel event in 1024ths of a second 440 | Time_1 = (rawValue5 + (rawValue6 * 256)); 441 | 442 | firstData = false; 443 | 444 | } else { 445 | 446 | // Get second set of data 447 | long WheelRevsTemp = (rawValue1 + (rawValue2 * 256) + (rawValue3 * 65536) + (rawValue4 * 16777216)); 448 | long TimeTemp = (rawValue5 + (rawValue6 * 256)); 449 | 450 | if (WheelRevsTemp > WheelRevs1) { // make sure the bicycle is moving 451 | WheelRevs2 = WheelRevsTemp; 452 | Time_2 = TimeTemp; 453 | firstData = true; 454 | 455 | // Find distance difference in cm and convert to km 456 | float distanceTravelled = ((WheelRevs2 - WheelRevs1) * wheelCircCM); 457 | float kmTravelled = distanceTravelled / 1000000; 458 | 459 | // Find time in 1024ths of a second and convert to hours 460 | float timeDifference = (Time_2 - Time_1); 461 | float timeSecs = timeDifference / 1024; 462 | float timeHrs = timeSecs / 3600; 463 | 464 | // Find speed kmh 465 | speedKMH = (kmTravelled / timeHrs); 466 | 467 | Serial.print(" speed: "); 468 | Serial.println(speedKMH, DEC); 469 | 470 | 471 | // Reject zero values 472 | if (speedKMH < 0){}else{ 473 | speedTrainer = movingAverageFilter_speed.process(speedKMH); // use moving average filter to find 3s average speed 474 | // speedTrainer = speedKMH; // redundant step to allow experiments with filters 475 | 476 | 477 | 478 | } 479 | } 480 | } 481 | 482 | } 483 | 484 | // we only need to do all this 4 or 5 times a second! 485 | delay(200); 486 | } 487 | 488 | void findTrainerIncline(void){ 489 | //Serial.print("findTrainerIncline"); 490 | float rawx, rawy, rawz; 491 | float x, y, z; 492 | 493 | if (IMU.accelerationAvailable()) { 494 | IMU.readAcceleration(rawx, rawy, rawz); 495 | 496 | x = movingAverageFilter_x.process(rawx); // 497 | y = movingAverageFilter_y.process(rawy); // Apply moving average filters to reduce noise 498 | z = movingAverageFilter_z.process(rawz); // 499 | 500 | // find pitch in radians 501 | float radpitch = atan2(( y) , sqrt(x * x + z * z)) ; 502 | 503 | smoothRadPitch = radpitch; 504 | 505 | // find the % grade from the pitch 506 | incline = tan(smoothRadPitch) * 100; 507 | 508 | }} 509 | 510 | void lcdDisplayData(void) { 511 | 512 | 513 | display.clearDisplay(); 514 | display.setTextSize(1); 515 | display.setTextColor(SSD1306_WHITE); 516 | 517 | // Display power top left 518 | 519 | display.setCursor(0, 0); 520 | display.print(powerTrainer); 521 | display.print(F(" W")); 522 | 523 | // Display speed top right if more than 4kph 524 | 525 | if(speedTrainer>4){ 526 | display.setCursor(80, 0); 527 | display.print(speedTrainer); 528 | display.print(F(" kph"));} 529 | else{ 530 | display.setCursor(80, 0); 531 | display.print("-- "); 532 | display.print(F(" kph")); 533 | } 534 | 535 | // Display weight bottom left 536 | 537 | display.setCursor(0, 24); 538 | display.print(riderWeight); 539 | display.print(F(" kg")); 540 | 541 | // Display target incline bottom right 542 | if(gradeCalculated>0){ 543 | display.setCursor(80, 24); 544 | display.print(gradeCalculated); 545 | display.print(F(" %"));} 546 | else{ 547 | display.setCursor(80, 24); 548 | display.print(F("0 %"));} 549 | 550 | 551 | // Display current incline centred and large 552 | display.setTextSize(2); // Draw 2X-scale text 553 | display.setCursor(50, 9); 554 | display.print(incline); 555 | display.print(F("%")); 556 | 557 | // Update the display 558 | display.display(); 559 | 560 | } 561 | 562 | void moveActuator(void) { 563 | 564 | // This method is ugly - just pausing the script while the actuator moves - there are many better ways - if only I had the time! 565 | // That said there will be more noise whilst moving so maybe some advantage 566 | 567 | int difference = incline-gradeCalculated; // Find the difference 568 | int absDifference = abs(difference); // Find the absolute (like rms) 569 | 570 | 571 | if (incline>gradeCalculated){ 572 | digitalWrite(actuatorOutPin1, LOW); 573 | digitalWrite(actuatorOutPin2, HIGH); 574 | } 575 | else if (incline0) && (absDifference <2)){ 581 | delay(1000); 582 | } 583 | if ((absDifference >=2) && (absDifference <3)){ 584 | delay(2000); 585 | } 586 | if ((absDifference >=3) && (absDifference <4)){ 587 | delay(3000); 588 | } 589 | if (absDifference >=4) { 590 | delay(4000); 591 | } 592 | 593 | digitalWrite(actuatorOutPin1, LOW); 594 | digitalWrite(actuatorOutPin2, LOW); 595 | 596 | 597 | 598 | } 599 | 600 | void calculateGrade(void) { 601 | float speed28 = pow(speedTrainer,2.8); // pow() needed to raise y^x where x is decimal 602 | resistanceWatts = (0.0102*speed28)+9.428; // calculate power from rolling / wind resistance 603 | powerMinusResistance = powerTrainer - resistanceWatts; // find power from climbing 604 | speedMpersec = speedTrainer/3.6; // find speed in SI units 605 | gradeCalculated = ((powerMinusResistance/(riderWeight*9.8))/speedMpersec)*100; // calculate grade of climb in % 606 | 607 | // Sense check 608 | if (gradeCalculated < -10){gradeCalculated = -10;} 609 | if (gradeCalculated > 20){gradeCalculated = 20;} 610 | } 611 | 612 | void resetSystem(void){ 613 | digitalWrite (19, LOW); 614 | } 615 | 616 | void setWeight(void){ 617 | 618 | // Read the buttons 619 | // If button state chaged then update value and reset timer 620 | 621 | buttonStateUp = digitalRead(buttonUpPin); 622 | buttonStateDown = digitalRead(buttonDownPin); 623 | 624 | if (buttonStateUp == LOW) { 625 | // turn LED on: 626 | riderWeight = riderWeight+1; 627 | delay (200); // low tech button debounce and limit autorepeat rate 628 | weightPrevMillis = weightMillis; 629 | } 630 | 631 | if (buttonStateDown == LOW) { 632 | // turn LED on: 633 | riderWeight = riderWeight-1; 634 | delay (200); 635 | weightPrevMillis = weightMillis; 636 | } 637 | 638 | // Update the display 639 | 640 | display.clearDisplay(); 641 | display.setTextSize(2); 642 | display.setTextColor(SSD1306_WHITE); 643 | display.setCursor(50, 9); 644 | display.print(riderWeight); 645 | display.print(F(" Kg")); 646 | display.display(); 647 | 648 | 649 | } 650 | 651 | 652 | 653 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # opengradesim 2 | Arduino sketch for the Open Grade Simulator - an open hardware and software incline simulator for indoor cycle training 3 | 4 | Matt Ockendon 5 | 6 | This is the controller for a 3D printed elevation or 'grade' simulator to use with an indoor trainer 7 | The project in inspired by the Wahoo Kickr Climb but shares none of its underpinnings. 8 | 9 | Elevation is simulated on an indoor trainer by increasing resistance over that generated by frictional 10 | losses. 11 | 12 | I found the equation of a best fit line from points plotted using an online calculator of frictional losses vs speed 13 | and then took the residual power to calculate the incline being simulated 14 | 15 | Rather than using a servo linear actuator (expensive) I'm using the Arduino Nano 33 IoT BLE's built in 16 | accelerometers to find the position of the bicycle. This method is prone to noise and I have tried 17 | some filtering (moving average) to reduce this. 18 | 19 | The circuit: 20 | Arduino Nano 33 BLE 21 | 3.3 to 5v level shifter 22 | L298N H bridge 23 | 750Newton 200mm Linear Actuator 24 | 1x2 pushbutton pad 25 | 128x32 I2C OLED display 26 | 3D printed parts and boxes 27 | At present a NPE CABLE ANT+ to BLE bridge is required 28 | (due to the lack of authentication in the 29 | AdruinoBLE library 1.1.2) 30 | 31 | 3D files will be pulished on Thingiverse and more details on Instructables 32 | https://www.instructables.com/howto/Opengradesim/ 33 | --------------------------------------------------------------------------------