Skip to content

Anemometer

As described in week 11 my final project has changed to a sun tracker. However, I'm going to continue nevertheless with the tilting anemometer and keep documenting here.

In the moulding and casting week (14) I made a flexible joint from silicone.

Flexible joint

Design

I then continued drawing the associated part for the mounting and cable routing/connector.

Here is the 3D model of the design.

The location that is easily accesible and I can use for colocating the anemometer has a 40 mm x 40 mm square tube bracket, so I made a clamp for that size.

Sensor and Software

I got the 9-axis BNO085 breakout board from Adafruit. To test it I used a XIAO SAMD21 and followed their instructions on the UART RVC connection method.

The example from the library was uploaded and worked perfectly fine.

This is the example code from Adafruit for plotting the data:

/* Plotter-friendly version of the test sketch for Adafruit BNO08x sensor in
 * UART-RVC mode */
#include "Adafruit_BNO08x_RVC.h"

Adafruit_BNO08x_RVC rvc = Adafruit_BNO08x_RVC();

void setup() {
  // Wait for serial monitor to open
  Serial.begin(115200);
  while (!Serial)
    delay(10);

  Serial1.begin(115200); // This is the baud rate specified by the datasheet
  while (!Serial1)
    delay(10);

  if (!rvc.begin(&Serial1)) { // connect to the sensor over hardware serial
    while (1)
      delay(10);
  }
  // Print labels for the values
  Serial.print(F("Yaw"));
  Serial.print(F("\tPitch"));
  Serial.print(F("\tRoll"));
  Serial.print(F("\tX"));
  Serial.print(F("\tY"));
  Serial.println(F("\tZ"));
}

void loop() {
  BNO08x_RVC_Data heading;

  if (!rvc.read(&heading)) {
    // Data not available or parsable, keep trying
    return;
  }

  Serial.print(heading.yaw);Serial.print(F(","));
  Serial.print(heading.pitch);Serial.print(F(","));
  Serial.print(heading.roll);Serial.print(F(","));
  Serial.print(heading.x_accel);Serial.print(F(","));
  Serial.print(heading.y_accel);Serial.print(F(","));
  Serial.print(heading.z_accel);
  Serial.println("");
}

When I integrated the SDI-12 library I found it a bit troublesome to get it working together with the BNO08x RVC library. I also realized, that the RVC library has only a very limited output compared to the standard library. I therefore decided to start again from scratch using a different connection method (I2C).

The example code I used is supposed to return the absolute, calibrated values for yaw, pitch and roll as well as a quality indicator.

Report type: SH2_ARVR_STABILIZED_RV

I combined it together with the SDI-12 code from week 11.

#include <SDI12.h>
#include <Adafruit_BNO08x.h>

#define SDI12_DATA_PIN D1
#define BNO08X_RESET -1

int8_t dataPin       = SDI12_DATA_PIN;  // The pin of the SDI-12 data bus
char   sensorAddress = '0';             // The address of the SDI-12 sensor
int    state         = 0;

struct euler_t {
  float yaw;
  float pitch;
  float roll;
} ypr;

Adafruit_BNO08x  bno08x(BNO08X_RESET);
sh2_SensorValue_t sensorValue;

// Top frequency is about 250Hz but this report is more accurate
sh2_SensorId_t reportType = SH2_ARVR_STABILIZED_RV;
long reportIntervalUs = 15000;

#define WAIT 0
#define INITIATE_CONCURRENT 1
#define INITIATE_MEASUREMENT 2
#define PROCESS_COMMAND 3

// Create object by which to communicate with the SDI-12 bus on SDIPIN
SDI12 slaveSDI12(dataPin);


void parseSdi12Cmd(String command, String* dValues) {
  /* Ingests a command from an SDI-12 master, sends the applicable response, and
   * (when applicable) sets a flag to initiate a measurement
   */

  // First char of command is always either (a) the address of the device being
  // probed OR (b) a '?' for address query.
  // Do nothing if this command is addressed to a different device
  if (command.charAt(0) != sensorAddress && command.charAt(0) != '?') { return; }

  // If execution reaches this point, the slave should respond with something in
  // the form:   <address><responseStr><Carriage Return><Line Feed>
  // The following if-switch-case block determines what to put into <responseStr>,
  // and the full response will be constructed afterward. For '?!' (address query)
  // or 'a!' (acknowledge active) commands, responseStr is blank so section is skipped
  String responseStr = "";
  if (command.length() > 1) {
    switch (command.charAt(1)) {
      case 'I':
        // Identify command
        // Slave should respond with ID message: 2-char SDI-12 version + 8-char
        // company name + 6-char sensor model + 3-char sensor version + 0-13 char S/N
        responseStr = "13VI_ANEMO20250430.0001";  // Substitute proper ID String here
        break;
      case 'C':
        // Initiate concurrent measurement command
        // Slave should immediately respond with: "tttnn":
        //    3-digit (seconds until measurement is available) +
        //    2-digit (number of values that will be available)
        // Slave should also start a measurment and relinquish control of the data line
        responseStr =
          "02106";  // 6 values ready in 10 sec; Substitue sensor-specific values here
        // It is not preferred for the actual measurement to occur in this subfunction,
        // because doing to would hold the main program hostage until the measurement
        // is complete.  Instead, we'll just set a flag and handle the measurement
        // elsewhere.
        state = INITIATE_CONCURRENT;
        break;
        // NOTE: "aC1...9!" commands may be added by duplicating this case and adding
        //       additional states to the state flag
      case 'M':
        // Initiate measurement command
        // Slave should immediately respond with: "tttnn":
        //    3-digit (seconds until measurement is available) +
        //    1-digit (number of values that will be available)
        // Slave should also start a measurment but may keep control of the data line
        // until advertised time elapsed OR measurement is complete and service request
        // sent
        responseStr =
          "0216";  // 6 values ready in 10 sec; Substitue sensor-specific values here
        // It is not preferred for the actual measurement to occur in this subfunction,
        // because doing to would hold the main program hostage until the measurement is
        // complete.  Instead, we'll just set a flag and handle the measurement
        // elsewhere. It is preferred though not required that the slave send a service
        // request upon completion of the measurement.  This should be handled in the
        // main loop().
        state = INITIATE_MEASUREMENT;
        break;
        // NOTE: "aM1...9!" commands may be added by duplicating this case and adding
        //       additional states to the state flag

      case 'D':
        // Send data command
        // Slave should respond with a String of values
        // Values to be returned must be split into Strings of 35 characters or fewer
        // (75 or fewer for concurrent).  The number following "D" in the SDI-12 command
        // specifies which String to send
        responseStr = dValues[(int)command.charAt(2) - 48];
        break;
      case 'A':
        // Change address command
        // Slave should respond with blank message (just the [new] address + <CR> +
        // <LF>)
        sensorAddress = command.charAt(2);
        break;
      default:
        // Mostly for debugging; send back UNKN if unexpected command received
        responseStr = "UNKN";
        break;
    }
  }

  // Issue the response specified in the switch-case structure above.
  String fullResponse = String(sensorAddress) + responseStr + "\r\n";
  slaveSDI12.sendResponse(fullResponse);
}

void formatOutputSDI(float* measurementValues, String* dValues, unsigned int maxChar) {
  /* Ingests an array of floats and produces Strings in SDI-12 output format */

  dValues[0] = "";
  int j      = 0;

  // upper limit on i should be number of elements in measurementValues
  for (int i = 0; i < 6; i++) {
    // Read float value "i" as a String with 6 deceimal digits
    // (NOTE: SDI-12 specifies max of 7 digits per value; we can only use 6
    //  decimal place precision if integer part is one digit)
    String valStr = String(measurementValues[i], 6);
    // Explictly add implied + sign if non-negative
    if (valStr.charAt(0) != '-') { valStr = '+' + valStr; }
    // Append dValues[j] if it will not exceed 35 (aM!) or 75 (aC!) characters
    if (dValues[j].length() + valStr.length() < maxChar) {
      dValues[j] += valStr;
    }
    // Start a new dValues "line" if appending would exceed 35/75 characters
    else {
      dValues[++j] = valStr;
    }
  }

  // Fill rest of dValues with blank strings
  while (j < 6) { dValues[++j] = ""; }
}

void setReports(sh2_SensorId_t reportType, long report_interval) {
  Serial.println("Setting desired reports");
  if (! bno08x.enableReport(reportType, report_interval)) {
    Serial.println("Could not enable stabilized remote vector");
  }
}

void setup() {
  Serial.begin(115200);
  while (!Serial) delay(10);     // will pause Zero, Leonardo, etc until serial console opens

  // Try to initialize!
  if (!bno08x.begin_I2C()) {
  //if (!bno08x.begin_UART(&Serial1)) {  // Requires a device with > 300 byte UART buffer!
  //if (!bno08x.begin_SPI(BNO08X_CS, BNO08X_INT)) {
    Serial.println("Failed to find BNO08x chip");
    while (1) { delay(10); }
  }
  Serial.println("BNO08x Found!");  

  setReports(reportType, reportIntervalUs);

  Serial.println("Reading events");
  delay(100);  

  slaveSDI12.begin();
  delay(500);
  slaveSDI12.forceListen();  // sets SDIPIN as input to prepare for incoming message

  Serial.println("SDI12 started");
}

void quaternionToEuler(float qr, float qi, float qj, float qk, euler_t* ypr, bool degrees = false) {

    float sqr = sq(qr);
    float sqi = sq(qi);
    float sqj = sq(qj);
    float sqk = sq(qk);

    ypr->yaw = atan2(2.0 * (qi * qj + qk * qr), (sqi - sqj - sqk + sqr));
    ypr->pitch = asin(-2.0 * (qi * qk - qj * qr) / (sqi + sqj + sqk + sqr));
    ypr->roll = atan2(2.0 * (qj * qk + qi * qr), (-sqi - sqj + sqk + sqr));

    if (degrees) {
      ypr->yaw *= RAD_TO_DEG;
      ypr->pitch *= RAD_TO_DEG;
      ypr->roll *= RAD_TO_DEG;
    }
}

void quaternionToEulerRV(sh2_RotationVectorWAcc_t* rotational_vector, euler_t* ypr, bool degrees = false) {
    quaternionToEuler(rotational_vector->real, rotational_vector->i, rotational_vector->j, rotational_vector->k, ypr, degrees);
}

void quaternionToEulerGI(sh2_GyroIntegratedRV_t* rotational_vector, euler_t* ypr, bool degrees = false) {
    quaternionToEuler(rotational_vector->real, rotational_vector->i, rotational_vector->j, rotational_vector->k, ypr, degrees);
}

void pollSensor(float* measurementValues) {

  if (bno08x.wasReset()) {
    Serial.print("sensor was reset ");
    setReports(reportType, reportIntervalUs);
  }

  if (bno08x.getSensorEvent(&sensorValue)) {
    // in this demo only one report type will be received depending on FAST_MODE define (above)
    switch (sensorValue.sensorId) {
      case SH2_ARVR_STABILIZED_RV:
        quaternionToEulerRV(&sensorValue.un.arvrStabilizedRV, &ypr, true);
      case SH2_GYRO_INTEGRATED_RV:
        // faster (more noise?)
        quaternionToEulerGI(&sensorValue.un.gyroIntegratedRV, &ypr, true);
        break;
    }
    static long last = 0;
    long now = micros();
    Serial.print(now - last);             Serial.print("\t");
    last = now;
    Serial.print(sensorValue.status);     Serial.print("\t");  // This is accuracy in the range of 0 to 3
    Serial.print(ypr.yaw);                Serial.print("\t");
    Serial.print(ypr.pitch);              Serial.print("\t");
    Serial.println(ypr.roll);
  }


  measurementValues[0] = ypr.yaw;
  measurementValues[1] = ypr.pitch;
  measurementValues[2] = ypr.roll;
  measurementValues[3] = sensorValue.status;  

}


void loop() {
  static float measurementValues[6];
  static String
    dValues[10];  // 10 String objects to hold the responses to aD0!-aD9! commands
  static String commandReceived = "";  // String object to hold the incoming command

  // If a byte is available, an SDI message is queued up. Read in the entire message
  // before proceding.  It may be more robust to add a single character per loop()
  // iteration to a static char buffer; however, the SDI-12 spec requires a precise
  // response time, and this method is invariant to the remaining loop() contents.
  int avail = slaveSDI12.available();
  if (avail < 0) {
    slaveSDI12.clearBuffer();
  }  // Buffer is full; clear
  else if (avail > 0) {
    for (int a = 0; a < avail; a++) {
      char charReceived = slaveSDI12.read();
      // Character '!' indicates the end of an SDI-12 command; if the current
      // character is '!', stop listening and respond to the command
      if (charReceived == '!') {
        state = PROCESS_COMMAND;
        // Command string is completed; do something with it
        parseSdi12Cmd(commandReceived, dValues);
        // Clear command string to reset for next command
        commandReceived = "";
        // '!' should be the last available character anyway, but exit the "for" loop
        // just in case there are any stray characters
        slaveSDI12.clearBuffer();
        // eliminate the chance of getting anything else after the '!'
        slaveSDI12.forceHold();
        break;
      }
      // If the current character is anything but '!', it is part of the command
      // string.  Append the commandReceived String object.
      else {
        // Append command string with new character
        commandReceived += String(charReceived);
      }
    }
  }

  // For aM! and aC! commands, parseSdi12Cmd will modify "state" to indicate that
  // a measurement should be taken
  switch (state) {
    case WAIT:
      {
        break;
      }
    case INITIATE_CONCURRENT:
      {
        // Do whatever the sensor is supposed to do here
        // For this example, we will just create arbitrary "simulated" sensor data
        // NOTE: Your application might have a different data type (e.g. int) and
        //       number of values to report!
        pollSensor(measurementValues);
        // Populate the "dValues" String array with the values in SDI-12 format
        formatOutputSDI(measurementValues, dValues, 75);
        state = WAIT;
        slaveSDI12.forceListen();  // sets SDI-12 pin as input to prepare for incoming
                                   // message AGAIN
        break;
      }
    case INITIATE_MEASUREMENT:
      {
        // Do whatever the sensor is supposed to do here
        // For this example, we will just create arbitrary "simulated" sensor data
        // NOTE: Your application might have a different data type (e.g. int) and
        //       number of values to report!
        pollSensor(measurementValues);
        // Populate the "dValues" String array with the values in SDI-12 format
        formatOutputSDI(measurementValues, dValues, 35);
        // For aM!, Send "service request" (<address><CR><LF>) when data is ready
        String fullResponse = String(sensorAddress) + "\r\n";
        slaveSDI12.sendResponse(fullResponse);
        state = WAIT;
        slaveSDI12.forceListen();  // sets SDI-12 pin as input to prepare for incoming
                                   // message AGAIN
        break;
      }
    case PROCESS_COMMAND:
      {
        state = WAIT;
        slaveSDI12.forceListen();
        break;
      }
  }
}

In the datalogger, I set up a test program, that requests the fout values from the sensor and saves them in a data table once every second.

Calibration and other consideration

Compass and orientation

I realized, that the inbuilt compass in the BNO085 makes things a lot easier. Given, that there is no disturbance from ferritic objects I can set up the sensor in any direction and correct for the local declination in the software.

For calibration purpose I need to compare the new anemometer to a standard one.

Assembly and deployment

Eventually I got the M12-connector and 10 m long cable and could finish the assembly and testing.

Assembling

I hooked it up to a data logger in my office and it worked well.

After glueing, taping and screwing the joint to the tupe and the mounting bracket, I took it out to the field and deployed it at a 10 m mast close by, where it is colocated with a WMO standard ultrasonic anemometer.

Installed

The data logger is collecting data every second.

First data evaluation

After a couple of days, I was really curious about the results. We had some varying wind speed between 2 and 12 m/s mostly from the southwest.

I imported all the data into R and plottet the Euler angles. After a while, I could visually identify the correlation between the Euler angles and wind speed and direction.

Applying a moving average filter over 10 min, helped a lot. I had to invert the yaw angle and add 90° to make it fit to the orientation of the mounting bracket towards north and added additional 13° to compensate for the magnetic deviation.

For the wind speed I just guessed on a linear factor of 1.5, which seems to fit pretty well.

Here are the first results, that look very promising:

Anemo vs reference

Considerations for the next spiral

For the next spiral, I have to improve the mounting bracket and make it more universal for different types of masts / tubes.

Another necessary improvement will be the connection between the upper connector and the mounting bracket.

I also thought about adding a thermistor, that would be casted into the silicone and log the temperature inside the joint, to allow for compensation if it gets considerably less flexible during freezing conditions.