/****************************************************************************************************** * * * Magnetic levitation program for Arduino Uno/ATMega328 * * * * * * Copyright (C) 2014 Reid Borsuk (reid.borsuk@live.com) * * * * "MIT Three Clause License" * * * * Permission is hereby granted, free of charge, to any person obtaining a copy of this software * * and associated documentation files (the "Software"), to deal in the Software without restriction, * * including without limitation the rights to use, copy, modify, merge, publish, distribute, * * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is * * furnished to do so, subject to the following conditions: * * * * The above copyright notice and this permission notice shall be included in all copies * * or substantial portions of the Software. * * * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING * * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, * * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * * * * * ******************************************************************************************************/ #define QUIETMODE //Quietmode slows the time it takes to update the full PWM duty cycle. This makes it take a LOT longer to stabilize (on the order of 30 seconds), but makes almost no audible noise #define MIN_PWM_VALUE 0 //Minimum PWM duty cycle #define MAX_PWM_VALUE 255 //Maximum PWM duty cycle #define IDLE_TIMEOUT_PERIOD 5000 //Milliseconds. Must be < gIdleTime's maximum value #define MIN_MAG_LIMIT 325 //Trigger point for idle/active mode. If a permanent magnet is in range, hall sensor should read below this value #define PID_UPDATE_INTERVAL 1 //PWM update interval, in milliseconds. 0 = as fast as possible, likely unstable due to conditional branching & timing interrupts. Must be < gNextSensorReadout's maximum value #define DEFAULT_TARGET_VALUE 55 //Default target hall effect readout #define DEFAULT_KP 2.1 //Default Kp, proportional gain parameter #ifndef QUIETMODE #define DEFAULT_KD 19.5 //Default Kd, derivative gain parameter #else //not QUIETMODE #define DEFAULT_KD 23.7 #endif //not QUIETMODE #define DEFAULT_KI 0.0025 //Default Ki, integral gain parameter #define DEFAULT_MAX_INTEGRAL 20000 //Maximum integral term (limited by signed int below, change to long if > (32,767 - 1024) [1024 because that's the maximum that can be inserted before a constrain operation] #define KP_INCREMENT 0.1 //Increment used for serial commands (gKp) #define KD_INCREMENT 0.1 //Increment used for serial commands (gKd) #define KI_INCREMENT 0.0001 //Increment used for serial commands (gKi) #define VALUE_INCREMENT 1 //Increment used for serial commands (gTargetValue) #define FILTERFACTOR 3 //Weighting factor for hall sensor reading. We calculate a running average, with the most recent reading making up 1/FILTERFACTOR of the average. Lower = faster, higher = smoother const int coilPin = 10; //Timer 1B on the Uno (ATmega328), Timer 2A on the Mega (ATmega2560) const int hallSensorPin = 0; const int redLedPin = 12; const int blueLedPin = 13; //Onboard LED on Arduino, used mostly to see what the bootloader is doing so we know when we're booted const int gMidpoint = roundValue((MAX_PWM_VALUE - MIN_PWM_VALUE) / 2); //The midpoint of our PWM range boolean gIdle = false; //Used to track if we're in idle mode (magnet turned off due to no permanent magnet detected for idle time-out period) signed int gIdleTime = 0; //KEEP AS SIGNED. Holds the next time we could go into idle mode. Uses overflow safe arithmetic so does not need to hold the entire output of millis(). Must be able to hold IDLE_TIMEOUT_PERIOD without overflow signed int gNextPIDCycle = 0; ///KEEP AS SIGNED. Holds the next time we need to recalculate PID outputs. Uses overflow safe arithmetic so does not need to hold the entire output of millis(). Must be able to hold PID_UPDATE_INTERVAL without overflow int gCurrentDutyCycle = 0; //Current PWM duty cycle for the coil int gLastSensorReadout = 0; //Last sensor readout to calculate derivative term int gNextSensorReadout = 0; //The "next" sensor value. Declared global so we can use it as a running average and move ot to gLastSensorReadout after PWM calculation int gTargetValue = DEFAULT_TARGET_VALUE; float gKp = DEFAULT_KP; float gKd = DEFAULT_KD; float gKi = DEFAULT_KI; int gIntegralError = 0; //Calculates running error over time void writeCoilPWM(int value) { OCR1B = value; } int roundValue(float value) { return (int)(value + 0.5); } void setup() { //First thing we set up is the blue LED so we can use it for signaling boot status pinMode(blueLedPin, OUTPUT); digitalWrite(blueLedPin, HIGH); /* Setting up coil PWM settings [Only for ATmega 2560] timer 0 (controls pin 13, 4); timer 1 (controls pin 12, 11); timer 2 (controls pin 10, 9); timer 3 (controls pin 5, 3, 2); timer 4 (controls pin 8, 7, 6); [For ATmega328] timer 0 (controls pin 5, 6); timer 1 (controls pin 9, 10); timer 2 (controls pin 1, 3); [Timer 1 (ATmega328) or Timer 2 (ATmega2560)] prescaler = 1 ---> PWM frequency is 31374 Hz prescaler = 2 ---> PWM frequency is 3921 Hz prescaler = 3 ---> PWM frequency is 980.3 Hz prescaler = 4 ---> PWM frequency is 490.1 Hz (default value) prescaler = 5 ---> PWM frequency is 245 Hz prescaler = 6 ---> PWM frequency is 122.5 Hz prescaler = 6 ---> PWM frequency is 122.5 Hz */ // Setup timer 1 as Phase Correct non-inverted PWM, 31372.55 Hz. pinMode(9, OUTPUT); pinMode(10, OUTPUT); // WGM20 is used for Phase Correct PWM, COM2A1/COM2B1 sets output to non-inverted TCCR1A = 0; TCCR1A = _BV(COM2A1) | _BV(COM2B1) | _BV(WGM20); // PWM frequency is 16MHz/255/2/, prescaler is 1 here by using CS20 TCCR1B = 0; TCCR1B = _BV(CS20); pinMode(redLedPin, OUTPUT); pinMode(hallSensorPin, INPUT); Serial.begin(115200); //Boot complete, turn of blue LED digitalWrite(blueLedPin, LOW); } //Used while in idle mode void idleLoop() { digitalWrite(redLedPin, HIGH); //Turn off magnet if(0 != gCurrentDutyCycle) { gCurrentDutyCycle = 0; writeCoilPWM(gCurrentDutyCycle); } int sensorReadout = analogRead(hallSensorPin); //Transition back to active mode if there's a magnet in detection range if(MIN_MAG_LIMIT > sensorReadout) { gIdle = false; gNextSensorReadout = sensorReadout; //Prime the sensor readout so it's not super-stale (higher filtering factors will result in longer time to stabilize) gLastSensorReadout = sensorReadout; //Reduce the derivative term to 0 digitalWrite(redLedPin, LOW); //This seems pointless, but is used to deal with overflow in millis()'s output when downcast. If it overflows while in idle mode, it would stick in filter waiting for millis to go all the way back up to the last calculated point. //Can also set to type's min value to do the same thing, but now is more intuitive. gNextPIDCycle = millis(); gIdleTime = gNextPIDCycle + IDLE_TIMEOUT_PERIOD; } } //Used while in active mode void controlLoop() { //Downcast millis to the type of the stored cycle argument, then calculate the difference as a signed number. If negative, the time hasn't passed yet. This allows us to do overflow-safe arithmetic if(0 <= ((typeof(gNextPIDCycle))millis() - gNextPIDCycle)) { //By default, downcast to signed 16-bit int (safe), but can be changed to use longer intervals by changing the type of gNextPWMCycle gNextPIDCycle = millis() + PID_UPDATE_INTERVAL; //Read the sensor at least once in an update cycle gNextSensorReadout = roundValue(((gNextSensorReadout * (FILTERFACTOR - 1)) + analogRead(hallSensorPin)) / FILTERFACTOR); if(MIN_MAG_LIMIT <= gNextSensorReadout) //We don't see a permanent magnet right now { if(0 <= ((typeof(gIdleTime))millis() - gIdleTime)) //We haven't seen a permanent magnet in IDLE_TIMEOUT_PERIOD. Overflow safe for the same reason as the above calculation for gNextPWMCycle { gIdle = true; return; //Early exit to fall into idle mode } } else //There is a permanent magnet in range during this update { //Cast overflow is acceptable here as long as IDLE_TIMEOUT_PERIOD < typeof(gIdleTime)'s maximum value gIdleTime = millis() + IDLE_TIMEOUT_PERIOD; } int error = gTargetValue - gNextSensorReadout; //Difference between current and expected values (for proportional term) //Slope of the input over time (for derivative term). This is called Derivative on Measurement, as opposed to the more normal Derivative on Error. Used to reduce "derivative kick" when changing the set point, not a huge deal at our frequency int dError = gNextSensorReadout - gLastSensorReadout; gIntegralError = constrain(gIntegralError + error, -DEFAULT_MAX_INTEGRAL, DEFAULT_MAX_INTEGRAL); //Roughly constant error over time (for integral term) //This is the actual PID magic. See http://en.wikipedia.org/wiki/PID_controller #ifdef QUIETMODE //This slows down the change in the electromagnet, making the device substantially quieter but taking a lot longer to stabilize (on the order of 30 seconds) int gNextDutyCycle = gMidpoint - roundValue((gKp*error) - (gKd*dError) + (gKi*gIntegralError)); gCurrentDutyCycle = roundValue(((gCurrentDutyCycle * 2) + gNextDutyCycle) / 3); #else //not QUIETMODE gCurrentDutyCycle = gMidpoint - roundValue((gKp*error) - (gKd*dError) + (gKi*gIntegralError)); #endif //not QUIETMODE //It's possible to overshoot in the above, so constrain to between our max and min gCurrentDutyCycle = constrain(gCurrentDutyCycle, MIN_PWM_VALUE, MAX_PWM_VALUE); writeCoilPWM(gCurrentDutyCycle); //Store for next calculation of dError gLastSensorReadout = gNextSensorReadout; } else //We're waiting for our next PID update cycle, just read the hall sensor for our filtering routine and return. We could also spin on this if we wanted more samples... { //This is a weighted average function. It basically takes FILTERFACTOR samples, replaces one with the current hall sensor value, and averages over that number of inputs. //The higher the FILTERFACTOR, the slower the response (and the less important erroneous readings are) gNextSensorReadout = roundValue(((gNextSensorReadout * (FILTERFACTOR - 1)) + analogRead(hallSensorPin)) / FILTERFACTOR); } } void serialCommand(char command) { char output[255]; switch(command) { case 'P': gKp += KP_INCREMENT; break; case 'p': gKp -= KP_INCREMENT; if(0 > gKp) gKp = 0; break; case 'D': gKd += KD_INCREMENT; break; case 'd': gKd -= KD_INCREMENT; if(0 > gKd) gKd = 0; break; case 'I': gKi += KI_INCREMENT; break; case 'i': gKi -= KI_INCREMENT; if(0 > gKi) gKi = 0; break; case 'T': gTargetValue += VALUE_INCREMENT; break; case 't': gTargetValue -= VALUE_INCREMENT; if(0 > gTargetValue) gTargetValue = 0; break; //Print current settings. Also printed after any of the above cycles. case 'V': case 'v': break; //Ignore unrecognised characters default: return; } //Why so complicated? Arduino doesn't include support for %f by default and requires an additional library, so we rip it open manually. Will not support negative numbers or indefinite precision. //This one line causes 3026 bytes of ROM to be used, almost half the sketch size...so if you run out of space, disable this (or simplify it). sprintf(output, "Target Value: [%3d] Current PWM duty cycle [%3d] Current sensor value [%4d] Kp [%2d.%02d] Kd [%2d.%02d] Ki,Integral Error [.%04d,%d] Idle timeout [%d]\n", gTargetValue, gCurrentDutyCycle, gNextSensorReadout, (int)(gKp+0.0001), roundValue(gKp*100)%100, (int)(gKd+0.0001), roundValue(gKd*100)%100, roundValue(gKi*10000)%10000, gIntegralError, gIdleTime); Serial.print(output); } void loop() { //User commands waiting if(0 < Serial.available()) { //Process one character at a time serialCommand(Serial.read()); } if(gIdle) idleLoop(); else controlLoop(); }