Control loop Issues Python Arduino communication

Hey guys,

im stuck with this one. Im trying to control an inverted pendulum via Python where i use the Arduino only as I/O-Device and to do the complex calculations in Python.
The Idea is that every 50ms the Arduino calculates values and sends them to python. Python receives the values and sends a control value U back every then 50ms aswell. The arduino takes the value executes it and waits for its 50ms cycle to start again. So the idea is to have to independent loops who (asynchron) are sending/receiving data.

The values that the Arduino sends aswell as the calculated value in Python are correct (i did a bunch of debugging on that hoping to find the mistake there).

Problem:
I kinda works in the beginning for small values of theta. But the reaction time is somewhat shitty and the motor turns sometimes in weird directions with weird speeds and shows in general often glitchy/unexpected and stuttering behaviour. I used the same sketch with a different loop for speed tests and it was super smooth.

Ideas:
I think the communication doesnt work all the time so weird values get parsed etc.. Also just for testing the I and D values are not set for testing purposes.
Does anyone has an Idea what should i do better or what is the problem?

here is the (hardcore) shortened Arduino code + the Python code

#include <DueTimer.h>
#include <Wire.h>
#include <AS5600.h>

//AS5600
AS5600 as5600(&Wire);

#define BAUDRATE           115200 // has to be the same as in python // not used currently bcs i use natove portg

//Variables

volatile bool i2c_request = true;

constexpr unsigned long communication_time = 50; // has to be the same as in python

//PID
constexpr int PWM_MAX_CMD   = 100;  
constexpr unsigned CMD_TIMEOUT_MS = 100; // wenn länger nichts kommt -> Stop

static int last_pwm_cmd = 0;
static unsigned long last_cmd_ms = 0;

static char rxbuf[32];
static uint8_t rxidx = 0;
constexpr int PWM_HW_MAX = 100;  // max PWM 
constexpr int CMD_ABS_MAX = 100; // max expected value from Python


//Helpers///////////////////////////////////////////////////////////////

inline void setMotorSignedPWM_direct(float u_cmd) {
  // Richtung aus Vorzeichen
  int dir = (u_cmd >= 0.0f) ? +1 : -1;
  dir *= MOTOR_DIR_POL;

  // |u| -> PWM linear 0..255
  float au = fabsf(u_cmd);
  if (au > CMD_ABS_MAX) au = CMD_ABS_MAX;

  int pwm = (int)roundf(au * (PWM_HW_MAX / (float)CMD_ABS_MAX));
  if (pwm < 0) pwm = 0;
  if (pwm > PWM_HW_MAX) pwm = PWM_HW_MAX;

  // Ausgänge setzen
  digitalWrite(DIR_PIN, (dir > 0) ? LOW : HIGH);
  analogWrite(PWM_PIN, pwm);

  // Debug (optional)
  last_cmd_ms = millis();
}

inline void applyCommandLine(const char* s) {
  if (tripped) return;
  // erwartet DezimalPUNKT und '\n' am Ende
  float u = atof(s);
  setMotorSignedPWM_direct(u);
}


///////////////////////////////////////////////////////////////////
void setup() {
  Serial.begin(BAUDRATE);
  while (!Serial) {}  
  
  //Magnetic Encoder
  pinMode(20, INPUT_PULLUP); 
  pinMode(21, INPUT_PULLUP); 

  //Motor PINS
  pinMode(PWM_PIN, OUTPUT);
  pinMode(DIR_PIN, OUTPUT);

  //Magnetic Encoder init
  Wire.begin();
  Wire.setClock(100000);

  if (!as5600.begin()) {  //test if the sensor go recognized 
    Serial.println("AS5600 not found on Wire. Check wiring.");
    while (1) { delay(1000); }
  }
  delay(5000);
}



void loop() {
  static unsigned long next_ms = 0;
  unsigned long now = millis();

  //collect line from python
  while (Serial.available() > 0) {
    char c = (char)Serial.read();
    if (c == '\n') {               
      if (rxidx > 0) {
        rxbuf[rxidx] = '\0';
        applyCommandLine(rxbuf);   // sets pwm and dir_pin
        rxidx = 0;
      }
    } else if (c == '\r') {
      
    } else if (rxidx < sizeof(rxbuf) - 1) {
      rxbuf[rxidx++] = c;
    } else {
      rxidx = 0;
    }
  }
  // 10ms rate
  if ((long)(now - next_ms) >= 0) {
    // if we are to late:
    do { next_ms += communication_time; } while ((long)(now - next_ms) >= 0);

    // 
    if (i2c_request) {
      i2c_request = false;
      uint16_t raw = readRaw();
      angle_unwrapped += (float)delta_from_raw(raw, last_raw) * step_rad;
      last_raw = raw;
      theta_new = angle_unwrapped;
    }

    // Position/States bilden
    long  counts = absolute_counts();        // im loop ok
    float x_m    = counts_to_meters(counts);
    float theta  = wrap_to_pi(angle_unwrapped);

    // 
    Serial.print(x_m, 1);         Serial.print(',');
    Serial.print(v_filtered, 1);  Serial.print(',');
    Serial.print(theta, 3);       Serial.print(',');
    Serial.println(omega_filtered, 2); 

  }

  // failsafe (optional)
  if (last_cmd_ms != 0 && (millis() - last_cmd_ms > CMD_TIMEOUT_MS)) {
    analogWrite(PWM_PIN, 0);
  }
}

and here the python code

import serial, time
from PID import PIDAngle, PIDParams

PORT = "/dev/ttyACM0"
BAUD = 115200
communication_time = 0.05

ser = serial.Serial(PORT, BAUD, timeout=1)
time.sleep(2)                 
ser.reset_input_buffer()     

params = PIDParams(Kp=300.0, Ki=0.0, Kd=0.0, u_min=-100.0, u_max=100.0)
pid = PIDAngle(params, dt=0.01)
theta_ref = 0.0


def read_latest_line(ser):
    latest = None
    # Solange Bytes im Puffer sind, Zeilen lesen und jeweils die letzte behalten
    while ser.in_waiting:
        line = ser.readline().decode(errors="ignore").strip()
        if line:
            latest = line
    # Falls wir nichts im Buffer hatten, versuche normal eine Zeile zu lesen
    if latest is None:
        latest = ser.readline().decode(errors="ignore").strip()
    return latest if latest else ""

def main():
    try:
        next_t = time.monotonic()
        while True:
            next_t += communication_time

            # read sensor values
            line = read_latest_line(ser)
            if not line:
                #no line
                pass
            else:
                try:
                    x, v , thetampc, thetadotmpc = map(float, line.split(","))
                    
                except ValueError:
                    # skip broken line
                    thetampc = thetadotmpc = None

                # calculate PID and send it
                if thetampc is not None:
                    u = pid.step((thetampc, thetadotmpc))          
                    ser.write(f"{u:.3f}\n".encode())
                    #print(f"{u:.1f}")
                    print(f"theta={thetampc:.3f}, thetaDot={thetadotmpc:.3f}, {u:.1f}")

            # --- Takt einhalten ---
            remain = next_t - time.monotonic()
            if remain > 0:
                time.sleep(remain)
            else:
                next_t = time.monotonic()        

    except KeyboardInterrupt:
        pass
    finally:
        ser.close()


if __name__ == "__main__":
    main()

If that is the case, the first step is to get the communication working correctly with much simpler programs.

Start here: Two ways communication between Python3 and Arduino

I had a quick look at your Arduino code. I got the impression that you don’t really have any packet structure. I just see code looking for line ending to (presumably) detect the end of a message.

Whenever I do inter processor serial comms I always define a packet structure that allows the receiver to detect the start of the message, the length of the message and end it with a checksum so the receiver can detect any problems.

The receive code should be written as a state machine.

If you had such a packet structure you could write test code to exercise the comms with a range of data values and then check whether either of the receivers detected any problems. I’d test with a message rate higher (e.g. 2x) than the rate you intend to normally use.

To check for correct parsing of the packet’s data payload you could send a range of hard coded, known to the reciver, test patterns. Then the reciver could also report any errors due to incorrect parsing of the data part of the packet. I’d include tests for min / max length packets and data payload bytes including 0xFF, 0x00, 0x55, 0xAA.

Once I’d got those tests all passing I’d move on to using the tested comms code in the application.

1 Like

To improve on what you have you could do away with the “read_latest_line” function altogether and read the sensor values as part of the “main” function. The serial timeout looks too large, you need the smallest where the code will rune well, start at 20mS and move up or down from there. A try catch in the small serial read portion of the “main” function will prevent the app hanging on timeout errors and a few other possible errors. Here is an part of what I am thinking.

ser = serial.Serial("COM5", 115200, timeout=0.020)

def main():
try:
next_t = time.monotonic()
while True:
next_t += communication_time

        if ser.in_waiting > 0:
            # read sensor values
            try:
                line = ser.readline().decode("utf-8").strip()
            except:
                pass

        if not line:
            #no line
            pass
        else:

As Dave_Lowther suggests having packet data that can be checked for errors is something to look into.

1 Like

20 Hz is a very low loop frequency for a control system. Do your calculations suggest it should suffice, or is this the best yiu can get thus hybrid to do?

This makes it worse. If the loops are not synchronized, it's easy to see that you might go even longer without good feedback to operate on.

The Arduino should control all the timing and the fastest loop of input, processing and output should be implemented.

I know nothing about the python end. An Arduino by itself might be able to handle the entire problem, maybe needing some clever programming for otherwise time consuming caculations.

a7

1 Like

What is the reason for the conversion from unsigned long to signed long here?

Thanks to all for the help. I will do some research try something new and report back in a moment!

Before i dive into it. What communication protocol would you suggest for my problem. Call and Response? Asynchronus loops like now? Something like @alto777 suggested? Something completely different?
Thanks in advance!

Yes. If the Arduino is off loading processing to the python code then have the Arduino send the data to be processed whenever it is ready to do so and await the response.

In order to send data around my house for weather measurements and related stuff I took inspiration from the GPS NMEA data 'sentence' structure GPS - NMEA sentence information

My version started with $ followed by 3 characters to identify the meaning of the data then comma separated data.

For example

$OST,18.5,18,16,
Where:

  • $ is the start indicator so the receiver knows where to start reading the data
  • OST is OutSide Temperature
  • , is the separator
  • 18.5 is the temperature in degrees as a decimal fraction
  • , is the separator
  • 18 is the temperature in whole degrees
  • , is the separator
  • 16 is the fractions of a degree expressed in thirty secondths of 1 degree
  • , is the separator

Note that I used 3 characters to identify the data type, not the 5 used by GPS, you can use as many as you think you need, the fewer you use the faster the transmission will be. If there's only ever one type of data then just us $ as a start marker followed by the data.

Also there's nothing to indicate how many characters will be sent, the structure is always the same, the commas indicate where each data item starts and ends. If the receiver code knows the structure the that's all that's needed.

so this is the code i came up with. I decided to start with a rather simple protocol only with start and end characters but so far it seems sufficient.

@jremington the tutorial was extremely helpful i think at this point i should @J-M-L thank aswell

@Dave_Lowther and @alto777 i will transfer my protocol to the real application and report back how it worked.

@sumguy i will do test runs with different timeout times

if anyone has suggestions for improvements please let me now. Im a little bit worried since my broken_frame_counter never increases that the communication i had before was not the problem.. but we will see. Lets go to bed and do some testing tomorrow. Cheers.

PS: my handshake didnt work so i commented it out:D

Arduino side:

//communication
constexpr long BAUDRATE = 115200;
constexpr unsigned long COMMUNICATION_TIME_MS = 10;
constexpr byte NUM_CHARS = 32;

volatile bool new_data = false; // if u has been send to motor -> false
volatile char receivedChars[NUM_CHARS];
volatile float u = 0.0f;

// test variables
volatile float x = 1.111f;
volatile float v = 2.222f;
volatile float theta = 3.333f;
volatile float thetadot = 4.444f;



void receive_parse_data() {
  /*
  -parses and receives in the form: <float>
  */

  static bool in_progress = false;
  static byte idx = 0;
  
  while (Serial.available() > 0) {
    char rc = Serial.read();

    if (in_progress == true) {
      if (rc == '>') {
        // frame finished -> reset everything and terminate
        receivedChars[idx] = '\0';
        idx = 0;
        in_progress = false;

        // set the u command and new_data = true
        u = atof((const char*)receivedChars);
        new_data = true;
      }
      else {
        if (rc == '\r' || rc == '\n') continue; // handle errors
        if (idx + 1 < NUM_CHARS) {
          // we currently read the payload
          receivedChars[idx] = rc;
          idx++;
        } 
      }
    }
    else {
      if (rc == '<') {
        in_progress = true;
        idx = 0;
      }
    }
  }
}


void send_state(float x, float v, float theta, float thetadot) {
  /*
  -sends a state as a string in the form */
  Serial.write('<'); // startmarker
  Serial.print(x, 3);         
  Serial.write(',');
  Serial.print(v, 3);
  Serial.write(',');
  Serial.print(theta, 3);
  Serial.write(',');
  Serial.print(thetadot, 3);
  Serial.write('>'); // endmarker
  Serial.write('\n'); // only for debugging  
}


void increment_state() {
  x = x + 1.0f;
  v = v + 1.0f;
  theta = theta + 1.0f;
  thetadot = thetadot + 1.0f;
}


void apply_u() {
  new_data = false;
}


void setup() {
  
  Serial.begin(BAUDRATE);
  while (!Serial) {}
  //Serial.write("<ready>\n");

  delay(1000);
}

void loop() {
  static unsigned long next_ms = 0;
  unsigned long now = millis();


  receive_parse_data();

  if (new_data) {
    // new frame arrived and in the buffer
    apply_u();
  }
  
  

  if ((long)(now - next_ms) >= 0) {
    do { next_ms += COMMUNICATION_TIME_MS; } while ((long)(now - next_ms) >= 0);

    //send the state here 
    send_state(x, v, theta, thetadot);
    increment_state();

  }
}

Python side

import sys, threading, queue, serial, time, itertools

PORT = "/dev/ttyACM0"
BAUD = 115200
FRAME_TIMEOUT_S = 0.05   #if for 50 ms nothing arrived discard this frame
MAX_PAYLOAD_LEN = 64
READ_TIMEOUT_S = 0.002

# init usb communication
ser = serial.Serial(PORT, BAUD, timeout=READ_TIMEOUT_S)
time.sleep(2)                 
ser.reset_input_buffer()  

#init the que
state_que = queue.Queue() #FIFO-QUE

# init test variables
broken_frame_counter = 0
u_counter = 0.55
debug_id = itertools.count(1)



def listen_to_arduino():
    """
    -reads frames in the background with threading
    -frames come in the form  of <x,v,theta,thetadot>\n
    -if full frame got received -> put it in a que as tuple
    """
    
    currently_receiving = False
    buffer = bytearray()
    t0 = None
    global broken_frame_counter
    
    while True:
        b = ser.read(1)
        if not b:
            # probably something wrong with the frame
            if currently_receiving and (time.monotonic() - t0 > FRAME_TIMEOUT_S):
                currently_receiving = False
                buffer.clear()
                t0 = None
                broken_frame_counter += 1
            continue 
        
        c = b[0]
        
        # we are at the beginning of the payload
        if currently_receiving == False: 
            if c == ord('<'):
                currently_receiving = True
                buffer.clear()
                t0 = time.monotonic()
            continue
        
        # we are at the end of the payload
        if c == ord('>'): 
            try:
                payload = buffer.decode('ascii', errors='strict').strip()
                # handshake only once this branch is taken
                if payload.lower() == "ready":
                    state_que.put("ready")
                else:
                    parts = payload.split(',')
                    if len(parts) == 4:
                        x, v, theta, thetadot = map(float, parts)
                        state_que.put((x, v, theta, thetadot))
                    else:
                        broken_frame_counter += 1
                        
            except Exception:
                broken_frame_counter += 1
                    
            currently_receiving = False
            buffer.clear()
            t0 = None
            continue
            
        
        elif c in (ord('\n'), ord('\r')):  
            continue
        
        # we are inside the payload and buffer is smaller than max allowed length
        if len(buffer) < MAX_PAYLOAD_LEN:
            buffer.append(c)
        
        # overshoot of max length -> discard the reading
        else:
            currently_receiving = False
            buffer.clear()
            t0 = None
            broken_frame_counter += 1
            

def send_control(u: float):
    """
    -sends <±xx.xx> + \n 
    -newline only for debugging
    """
    
    ser.write(f"<{u:.2f}>\n".encode('ascii'))
    
    
    
def test_fct(x: float, v: float, theta: float, thetadot: float):
    global u_counter
    u_counter += 1.0
    return u_counter
            


def main():
    # start the background receiver task
    arduinoThread = threading.Thread(target=listen_to_arduino, args=())
    arduinoThread.daemon = True
    arduinoThread.start()
    
    # while True:
    #     print("waiting for Arduino Handshake")
    #     print(broken_frame_counter)
    #     msg = state_que.get() # blocks and waits for the arduino to end the setup
    #     if msg == "ready":
    #         print("Arduino Ready")
    #         break
        
    
    try:
        while True:
            # wait for a new state
            state = state_que.get() # blocks untill the thread adds an item
            
            while True:
                try: 
                    state = state_que.get_nowait()
                
                except queue.Empty:
                    break
            
            # just in case something else is in the que
            if not (isinstance(state, tuple) and len(state) == 4):
                continue
            
            # we finally got the newest state and extract it now
            x, v, theta, thetadot = state 

            # now apply the control application
            u = test_fct(x,v, theta, thetadot)
            
            # and send it to the arduino
            send_control(u)
            
            # for debugging
            idx = next(debug_id)
            print(f"{x:.3f},{v:.3f},{theta:.3f},{thetadot:.3f},{idx},{broken_frame_counter}")
            
            
    except KeyboardInterrupt:
        pass
    
    finally:
        ser.close()



if __name__ == "__main__":
    main()