Capteur de respiration utilisant une ceinture élastique

Conception

J’ai décidé de redonner un tour de roue à mon projet philati (https://hackaday.io/project/9641-lati), plus précisément au capteur de respiration le composant. J’ai principalement fait un PCB qui contient le pont de Wheatstone, l’ampli d’instrumentation et les filtres analogiques. Un Adafruit Feather nrf52840 Express est utilisé comme microcontrôleur.

La ceinture est faite à partir d’une bande élastique et de « buckles » (attache? j’ai aucune idée du mot en français) que j’ai achetés sur un site de matériel de plein air. La pièce qui tient le PCB et les moteurs haptiques est imprimée en TPU. Voici le code OpenSCAD :

$fn=100;
re = 150; //rayon extérieur
ep = 4; 

difference(){
    cylinder(d=re,h=ep);
    cylinder(d=re-50,h=ep);
    
    //Trous pour la sangle
    translate([re/2-10,-26/2,0]) cube([4,26,ep]);
    translate([re/2-10-8,-26/2,0]) cube([4,26,ep]);
    translate([-re/2+10-4,-26/2,0]) cube([4,26,ep]);
    translate([-re/2+10-4+8,-26/2,0]) cube([4,26,ep]);
    
    //Trous pour les moteurs
    for (theta = [22.5 : 45 : 337.5]){
        rotate([0,0,theta]) translate([(re-25)/2,0,0.3]) cylinder(d=16,h=ep);
    }
}

difference(){
    union(){
        translate([-5,-70]) cube([10,140,ep]);
        translate([-(43+ep)/2, -(56+ep)/2]) cube([43+ep,56+ep,7]);
    }
    translate([-43/2, -56/2,1]) cube([43,56,7]);
}

Le capteur de respiration est toujours le fil en caoutchouc conducteur trouvé sur Adafruit (https://www.adafruit.com/product/519). Il est simplement serré autour de deux broches branchées sur chaque bout du fil. Je ne m’attarderai pas sur la conception du circuit, parce que je l’ai déjà documenté dans mon projet philati. Je mets tout de même ici le schéma et le dessin du PCB, comme référence.

J’ai réussi à à peu près tout rentrer en-dessous du board du feather, pour un design compact et en utilisant uniquement des composantes trough hole.

L’autre partie du projet inclut une évolution de ma ceinture haptique, qui contient 8 moteurs haptiques de vibration en rotation, placées dans l’anneau de TPU, collés sur une mince couche de 0,3 mm d’épais. L’élasticité du matériau permet au moteur de bouger assez librement, tout en étant fixé à la colle chaude. La surface lisse en-dessous de l’anneau peut ainsi être placée directement sur la peau si on veut. J’utilise le PWM sur les pins et un simple buffer logique pour alimenter les moteurs. La spécification en courant ne permet pas d’alimenter tous les moteurs en même temps, mais le maximum que j’ai besoin pour les effets est de seulement deux moteurs à la fois.

Je n’ai pas encore mélangé les deux programmes du microcontrôleur, j’ai mis une pause sur le projet après des tests préliminaires pour les raisons que j’explique plus loin. Le code arduino pour transmettre les données du capteur de respiration à un ordi ressemble à ceci :

/*********************************************************************
 This is an example for our nRF52 based Bluefruit LE modules

 Pick one up today in the adafruit shop!

 Adafruit invests time and resources providing this open source code,
 please support Adafruit and open-source hardware by purchasing
 products from Adafruit!

 MIT license, check LICENSE for more information
 All text above, and the splash screen below must be included in
 any redistribution
*********************************************************************/
#include <bluefruit.h>
#include <Adafruit_LittleFS.h>
#include <InternalFileSystem.h>
#include "elapsedMillis.h"

// BLE Service
BLEDfu  bledfu;  // OTA DFU service
BLEDis  bledis;  // device information
BLEUart bleuart; // uart over ble
BLEBas  blebas;  // battery

const byte numChars = 16;
char receivedChars[numChars]; // an array to store the received data
boolean newData = false;
int peltierpin = 6;

#define SAMPLE_LEN 32 //Tel que configuré actuellement, bleuart peut seulement envoyer 64 bytes à chaque 1s
char sendBuffer[2*SAMPLE_LEN]; //Le buffer à envoyer par bluetooth
uint16_t* samplept = (uint16_t*)sendBuffer; //Lit le buffer en le considérant comme du 16 bits
uint8_t samplecnt = 0;
elapsedMicros bufferTimer; // Timer qui surveille le temps entre les enregistrements

void setup()
{
  pinMode(peltierpin, OUTPUT);
  digitalWrite(peltierpin, LOW);
  analogWriteResolution(15); //Met la fréquence du PWM à 514kHz, avec 5 bits de résolution (0-31)
  //analogWrite(peltierpin, 16384);
  analogReadResolution(14); //Les ADC lisent en 14 bits
  Serial.begin(115200);

#if CFG_DEBUG
  // Blocking wait for connection when debug mode is enabled via IDE
  while ( !Serial ) yield();
#endif
  
  Serial.println("Bluefruit52 BLEUART Example");
  Serial.println("---------------------------\n");

  // Setup the BLE LED to be enabled on CONNECT
  // Note: This is actually the default behavior, but provided
  // here in case you want to control this LED manually via PIN 19
  Bluefruit.autoConnLed(true);

  // Config the peripheral connection with maximum bandwidth 
  // more SRAM required by SoftDevice
  // Note: All config***() function must be called before begin()
  Bluefruit.configPrphBandwidth(BANDWIDTH_MAX);
  
  Bluefruit.begin();
  Bluefruit.setTxPower(4);    // Check bluefruit.h for supported values
  //Bluefruit.setName(getMcuUniqueID()); // useful testing with multiple central connections
  Bluefruit.Periph.setConnectCallback(connect_callback);
  Bluefruit.Periph.setDisconnectCallback(disconnect_callback);

  // To be consistent OTA DFU should be added first if it exists
  bledfu.begin();

  // Configure and Start Device Information Service
  bledis.setManufacturer("Adafruit Industries");
  bledis.setModel("Bluefruit Feather52");
  bledis.begin();

  // Configure and Start BLE Uart Service
  bleuart.begin();

  // Start BLE Battery Service
  blebas.begin();
  blebas.write(100);

  // Set up and start advertising
  startAdv();

  Serial.println("Please use Adafruit's Bluefruit LE app to connect in UART mode");
  Serial.println("Once connected, enter character(s) that you wish to send");
}

void startAdv(void)
{
  // Advertising packet
  Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
  Bluefruit.Advertising.addTxPower();

  // Include bleuart 128-bit uuid
  Bluefruit.Advertising.addService(bleuart);

  // Secondary Scan Response packet (optional)
  // Since there is no room for 'Name' in Advertising packet
  Bluefruit.ScanResponse.addName();
  
  /* Start Advertising
   * - Enable auto advertising if disconnected
   * - Interval:  fast mode = 20 ms, slow mode = 152.5 ms
   * - Timeout for fast mode is 30 seconds
   * - Start(timeout) with timeout = 0 will advertise forever (until connected)
   * 
   * For recommended advertising interval
   * https://developer.apple.com/library/content/qa/qa1931/_index.html   
   */
  Bluefruit.Advertising.restartOnDisconnect(true);
  Bluefruit.Advertising.setInterval(32, 244);    // in unit of 0.625 ms
  Bluefruit.Advertising.setFastTimeout(30);      // number of seconds in fast mode
  Bluefruit.Advertising.start(0);                // 0 = Don't stop advertising after n seconds  
}

void loop()
{

  if (bufferTimer >= 100) { //parce que le code BLE est asynchrone, on doit utiliser un timer au lieu du delay
    //À 50ms, on sample à 20Hz
    samplept[samplecnt] = analogRead(A0);
    samplecnt = samplecnt + 1;
    bufferTimer = 0;
  }
  if (samplecnt == SAMPLE_LEN) {
    bleuart.write( sendBuffer, SAMPLE_LEN * 2 );
    samplecnt = 0;
  }
  //pollbleuart();
  //updatePeltier();
}

void pollbleuart() {
  static byte ndx = 0;
  char endMarker = '\n';
  char rc;
  
  while (bleuart.available() > 0 && newData == false) {
    rc = bleuart.read();

    if (rc != endMarker) {
      receivedChars[ndx] = rc;
      ndx++;
      if (ndx >= numChars) {
        ndx = numChars - 1;
      }
    }
    else {
      receivedChars[ndx] = '\0'; // terminate the string
      ndx = 0;
      newData = true;
    }
  }
}

void updatePeltier() {
  if (newData == true) {
    uint16_t intensity = uint16_t(receivedChars[0]-48); //Temps où le Peltier est allumé en millisecondes
    if ((intensity < 1) || (intensity > 8)) intensity = 0; //protection de calculs bizarres
    analogWrite(peltierpin, 4095 * intensity);
    delay(2000); //Le pulse dure 2 secondes
    analogWrite(peltierpin, 0);    
    newData = false;
  }
}

// callback invoked when central connects
void connect_callback(uint16_t conn_handle)
{
  // Get the reference to current connection
  BLEConnection* connection = Bluefruit.Connection(conn_handle);

  char central_name[32] = { 0 };
  connection->getPeerName(central_name, sizeof(central_name));

  Serial.print("Connected to ");
  Serial.println(central_name);
}

/**
 * Callback invoked when a connection is dropped
 * @param conn_handle connection where this event happens
 * @param reason is a BLE_HCI_STATUS_CODE which can be found in ble_hci.h
 */
void disconnect_callback(uint16_t conn_handle, uint8_t reason)
{
  (void) conn_handle;
  (void) reason;

  Serial.println();
  Serial.print("Disconnected, reason = 0x"); Serial.println(reason, HEX);
}

Le programme qui roule sur l’ordi en python est le suivant :

from adafruit_ble import BLERadio
from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
from adafruit_ble.services.nordic import UARTService
import array
import numpy as np
import matplotlib.pyplot as plt
import time

ble = BLERadio()

uart_connection = None

plotbuffer = []

#plt.ion()
countdatalen = 0

while True:
    if not uart_connection:
        print("Trying to connect...")
        for adv in ble.start_scan(ProvideServicesAdvertisement):
            if UARTService in adv.services:
                uart_connection = ble.connect(adv)
                print("Connected")
                break
        ble.stop_scan()

    if uart_connection and uart_connection.connected:
        uart_service = uart_connection[UARTService]
        starttime = time.time()
        while uart_connection.connected:
            #Pour envoyer des données vers l'appareil
            #s = input("Delay in s : ")
            #uart_service.write(s.encode("utf-8"))
            #uart_service.write(b'\n')
            #Pour recevoir des données
            receiveddata = uart_service.read()
            countdatalen = countdatalen + len(receiveddata)
            print(countdatalen)
            if countdatalen >= 10000:
                print(time.time()-starttime)
                break
            #print(receiveddata)
            #if receiveddata is not None:
                #print(len(receiveddata))
                #plotbuffer.append(array.array('H',receiveddata)) #convertit le bytearray en array de 16bits unsigned
                #plt.plot(array.array('H',receiveddata))
                #plt.show()

Pour tester le feedback de respiration, j’utilise le code arduino suivant :

int mot8 = 12;
int mot7 = 13;
int mot6 = 6;
int mot5 = 5;
int mot4 = 9;
int mot3 = 10;
int mot2 = 11;
int mot1 = 22;

int motarray[8] = {mot1,mot2,mot3,mot4,mot6,mot5,mot7,mot8};

int intensity = 150;

void setup() {
  delay(3000);
}

void loop() {
  //rotations
  for (byte i = 0; i < 8; i = i + 1) {
    //anti-horaire
    //analogWrite(motarray[7 - i],intensity);
    //delay(200);
    //analogWrite(motarray[7 - i],0);
    
    //horaire
    //analogWrite(motarray[i],intensity);
    //delay(200);
    //analogWrite(motarray[i],0);
  }
  
  //random (gouttes de pluie)
  /*
  delay(random(1000));
  int randompin = random(8);
  analogWrite(motarray[randompin],intensity);
  delay(100);
  analogWrite(motarray[randompin],0);
  */
  //vagues (pourrait être cool si rythmé sur la respiration)
  
  analogWrite(mot1, intensity);
  analogWrite(mot8, intensity);
  delay(800);
  analogWrite(mot1, 0);
  analogWrite(mot8, 0);
  analogWrite(mot2, intensity);
  analogWrite(mot7, intensity);
  delay(800);
  analogWrite(mot2, 0);
  analogWrite(mot7, 0);
  analogWrite(mot3, intensity);
  analogWrite(mot5, intensity);
  delay(800);
  analogWrite(mot3, 0);
  analogWrite(mot5, 0);
  analogWrite(mot4, intensity);
  analogWrite(mot6, intensity);
  delay(800);
  analogWrite(mot4, 0);
  analogWrite(mot6, 0);
  analogWrite(mot3, intensity);
  analogWrite(mot5, intensity);
  delay(800);
  analogWrite(mot3, 0);
  analogWrite(mot5, 0);
  analogWrite(mot2, intensity);
  analogWrite(mot7, intensity);
  delay(800);
  analogWrite(mot2, 0);
  analogWrite(mot7, 0);
 
  //pulsation (même chose, synchronisé sur la respiration ça serait cool)
  //(Ca ressemble un peu aux papillons dans le ventre comme effet
  /*
  for (byte value = 50; value < intensity; value++){
    for (byte i = 0; i < 8; i = i + 1) {
      analogWrite(motarray[i],value);
    }
    delay(50);
  }
  */
}

Discussion

Je commence de plus en plus à avoir la question qui trotte en arrière pensée lorsque je travaille sur mes créations : est-ce que ce projet pourraît être un produit? Dans le cas présent, la réponse est non, pour plusieurs raisons. Il y a trop de broches à foin et de colle chaude pour que ça ne demande pas un redesign assez substantiel.

Le capteur de respiration basé sur le caoutchouc conducteur est peu fiable, peu robuste, peu précis, comporte une bonne part d’hystérésis, est difficile à calibrer. Ça marche quand on prend le temps de le faire marcher. Il faut que la ceinture soit fermée avec la bonne tension, pour que l’extension du fil soit maximale et directement relié à la respiration. Cela implique donc que la ceinture soit placée à la bonne place sur les abdominaux à la hauteur du diaphragme, avec une pression assez serée, qui peut rapidement être inconfortable. Le caoutchouc ne peut pas être soudé, il doit être pris en serre contre du métal. Dans philati, j’utilisais des pinces crocodiles. Ici j’utilise un simple noeud autour de la broche métallique. Ça peut tomber facilement et ce n’est pas robuste. Pour une version plus fiable, il faudrait utiliser un crimp avec une ferrule. La meilleure option serait de le coudre directement sur le tissu élastique, afin de rendre le positionnement plus répétable.

Ensuite, la résistance dépend de la longueur, j’ai donc prévu un trimpot afin d’ajuster la plage du pont de Wheatstone à la résistance médiance. Cet ajustement est assez artisanal et doit être fait en portant la ceinture et en regardant la pleine amplitude de la respiration. J’utilise un amplificateur d’instrumentation pour limiter le bruit. Le circuit doit être alimenté à batterie pour éviter de capter le bruit provenant de l’ordinateur par un câble USB.

Le gain est fixé par une résistance, et les fréquences de coupure des filtres analogiques sont aussi fixées par les composantes physiques. C’est donc peu flexible et peu performant, mais c’est facile à faire. Le signal provenant du fil de caoutchouc est assez faible, on parle d’une variation de résistance d’environ 100 Ohm.

En conclusion, le capteur de respiration est difficile à utiliser, doit être potentiellement réajusté à chaque fois qu’on le porte, peu confortable et peu précis.

Pour ce qui est de la partie haptique du projet, plusieurs problèmes ont également été soulevés. Le moteur vient déjà avec un fil, mais est néamoins difficile à souder et peu solide car très petit. J’ai dû souder des rallonges avec des câbles socket female 2,54mm pour brancher dans mon connecteur soudé sur mon PCB. L’assemblage a été assez fastidieux. L’ordre des moteurs a aussi été changé, mais ça s’ajuste facilement dans le firmware.

Le problème principal des moteurs en rotation est le flou artistique dans la vibration produite. Leur accélération est pas toujours pareille, la vibration se couple à l’anneau au complet, même s’il est en TPU et même si le moteur est collé sur une mince bande. Il est difficile de discerner la source exacte, d’autant plus que le couplage mécanique avec la peau et les muscles est plutôt lousse. Pour un meilleur effet, je crois qu’il faudrait carrément coller le moteur à la peau (avec du tape genre) mais ça devient beaucoup trop invasif. De plus, la vibration devient vite désagréable, un peu comme un chatouillement raté, c’est plus gossant qu’autre chose. Le fréquence de la rotation n’est pas la bonne, c’est trop rapide pour être précisément détecté, et trop lent pour être confortable. Le muscle et le cerveau ne savent pas trop comment réagir à ce stimuli.

Ce qui est intéressant comme essai, c’est de pouvoir donner une phase au moteur, et ainsi créer un mouvement. Ici, j’ai essayé de faire un mouvement de haut en bas sur le ventre, partir du diaphragme et descendre les abdominaux, puis remonter, dans le but de donner un cue sur la respiration. C’est possible d’utiliser cela pour synchroniser sa respiration sur ce rythme, mais ça demande tout de même une bonne concentration. Puisque la vibration ne correspond pas du tout au serrement/desserrement des abdominaux, elle crée une sorte de bruit biomécanique, qui désoriente un peu ses perceptions. C’est trop grossier, pas assez confortable. Tout bouge trop. Je ne sais pas non plus quel serait l’effet d’une couche de graisse, mais d’après moi ça ne serait probablement pas glorieux. Finalement, c’est un endroit assez intime le ventre, je ne suis vraiment pas certain que les gens acceptent de porter une ceinture à cet endroit. On n’est pas si loin du bas du ventre, et mon projet n’a pas de prétention érotique.

Conclusion

L’idée derrière ce projet, qui est de mesurer la respiration et d’offrir un feedback haptique pour la synchroniser sur un rythme quelconque, ou entre différents porteurs de l’appareil, est toujours intéressante et mérite d’être explorée davantage. Je vais devoir toutefois repasser sur la planche à dessin et repenser à peu près tout de zéro. Rien dans cette version ne rencontre les critères minimaux pour être intégré dans un prototype viable. On lâche pas! 🙂

Conception optique open source avec FreeCAD Optics Workbench

Je viens de faire un tutoriel vidéo pour montrer le potentiel d’un outil de conception optique open source sur lequel je suis tombé dernièrement : FreeCAD Optics Workbench. En quelques minutes, on peut convertir un simple CAD 3D du fabricant en lentille, faire un tracé de rayons et obtenir un scatter plot. Ça se limite à l’optique géométrique, mais c’est bien suffisant dans bon nombre de situations.

Comment ressusciter une batterie Li-po « neuve » qui est à 0V

À cause du taux de décharge de l’ordre de quelques microampères des batteries Lithium-polymère, si celles-ci trainent sur les étagères pendant plusieurs mois — voir plusieurs anées — il arrive que leur voltage tombe à 0V. Elles semblent mortes à tout égard, puisqu’elles refusent de se recharger lorsqu’on les branche dans un chargeur. Pourtant, cela peut arriver à des batteries supposéement neuves (i.e. qui n’ont jamais vécu de cycles de charge et de décharge). Et je me demande même si c’est volontaire, puisque leur envoi par la poste est ainsi beaucoup plus sécuritaire : la batterie ne risque pas de prendre feu si elle est complètement à plat. Peu importe la cause, il y a une façon simple de régler le problème.

Il suffit de brancher la batterie dans un chargeur pour n minutes (durée encore indéterminée, peut-être environ 30 minutes, peut-être moins). La batterie ne se recharge pas et l’indicateur de charge du chargeur ne s’allume pas, mais une certaine magie opère. La batterie se met en quelque sorte dans un état de pré-recharge.

Ensuite, on débranche et on rebranche la batterie pour activer la recharge. Normalement, l’indicateur de charge devrait s’allumer, et la batterie va se recharger à pleine capacité. Si ça ne fonctionne pas, on peut répéter un autre cycle de précharge.

Les raisons profondes de ce mécanisme sont encore à investiguer, mais je pense que cette méthode fonctionne bien, j’ai déjà récupéré trois batteries de la mort de cette façon.

Interface avec une mémoire MRAM

Dans mon exploration des mémoires, je suis tombé sur la technologie MRAM. Elle est à ce jour plutôt dispendieuse (63$ pour la 16Mbit telle que testée (le prix est descendu à 60$, il y a espoir que ça devienne abordable)). Elle a la particularité d’être permanente (l’information reste dessus même lorsqu’elle n’est plus alimentée, comme un mémoire FLASH) et de supporter un nombre illimité de cycles d’écriture (min 10^14). Sa fréquence d’opération peut aller jusqu’à 108MHz. Son interface est SPI et fonctionne à 3,3V, ce qui fait qu’on peut l’utiliser avec n’importe quel microcontrôleur.

Dans mon exemple, je voulais tester un mode d’opération bien particulier. Dans un premier temps, le microcontrôleur initialise la mémoire et envoie le signal pour activer un write d’une durée indéterminée. Ensuite, un circuit externe (ici un micro PDM) envoie des données en continu. La mémoire se comporte comme un buffer circulaire, avec le pointer qui s’incrémente à chaque écriture de bit et revient automatiquement à 0 lorsqu’il atteint la fin de la profondeur. Cela fait en sorte que le circuit est autonome et ne dépend plus des commandes du microcontrôleur, ce dernier pouvant même entrer en sleep, ou gérer à 100% d’autres tâches. La seule autre mémoire que j’ai trouvée qui peut faire ça est la SRAM (article précédent). Cette propriété a le potentiel de simplifier certains designs digitaux, et de limiter la consommation de courant. C’était donc une solution intéressante à bien des égards, outre son coût prohibitif pour bien des applications commerciales.

Voici le code arduino complet :

#include <SPI.h>
int sram_CSn = A2;
int mic_sel = A0;
int mic_clk = 36;

void setup() {
  pinMode(mic_sel, OUTPUT);
  pinMode(sram_CSn, OUTPUT);
  pinMode(mic_clk, INPUT);
  digitalWrite(mic_sel, LOW); 
  digitalWrite(sram_CSn, HIGH);
  Serial.begin(2000000);
  analogWriteResolution(7);
  analogWriteFrequency(200000);

  //Enable write
  SPI.begin();
  SPI.beginTransaction(SPISettings(10000000, MSBFIRST, SPI_MODE0)); //10MHz
  digitalWrite(sram_CSn, LOW);
  SPI.transfer(0x06); //write enable command
  SPI.endTransaction();
  digitalWrite(sram_CSn, HIGH);
  SPI.end();

  //Test sequential pdm mic
  SPI.begin();
  SPI.beginTransaction(SPISettings(10000000, MSBFIRST, SPI_MODE0)); //10MHz
  digitalWrite(sram_CSn, LOW);
  SPI.transfer(0x02); //write command
  SPI.transfer(0x00); //address (24 bits)
  SPI.transfer(0x00);
  SPI.transfer(0x00);
  /*
  //Signal triangulaire pour tester
  for (int i = 0; i < 2000000; i++){
    SPI.transfer(byte(i % 256));
    //SPI.transfer(0x00);
    //SPI.transfer(0x00);
  }*/
  for (int i = 0; i < 2097152; i++){ //les adresses vont de 0x000000 à 0x1FFFFF
    //SPI.transfer(0x0F);
    SPI.transfer(0x00);
  }
  SPI.endTransaction();
  SPI.end();
  //digitalWrite(sram_CSn, HIGH);

  //start le micro
  pinMode(mic_clk,OUTPUT);
  analogWrite(mic_clk, 64);
}

void loop() {
  int cmd = Serial.read();
    if (cmd == 114){
      //Ferme le micro et le write infini
      pinMode(mic_clk, INPUT);
      digitalWrite(sram_CSn, HIGH);
      delay(1);

      //Une seonde commande complète de write est nécessaire pour éviter de corrompre les données (fouille-moi pourquoi)
      SPI.begin();
      SPI.beginTransaction(SPISettings(10000000, MSBFIRST, SPI_MODE0)); //10MHz
      digitalWrite(sram_CSn, LOW);
      SPI.transfer(0x02); //write command
      // Magiquement, la mémoire garde son pointeur et écrit à l'adresse où elle était rendue, même si on input 000
      SPI.transfer(0x00); //address (24 bits)
      SPI.transfer(0x00);
      SPI.transfer(0x00);
      for (int i = 0; i < 1000; i++){ //marqueur pour la fin de l'échantillon
        SPI.transfer(0xFF);
      }
      SPI.endTransaction();
      SPI.end();
      digitalWrite(sram_CSn, HIGH);
      delay(1);
      
      //Read
      SPI.begin();
      SPI.beginTransaction(SPISettings(10000000, MSBFIRST, SPI_MODE0)); //10MHz
      digitalWrite(sram_CSn, LOW);
      SPI.transfer(0x03); //read command
      SPI.transfer(0x00); //address (24 bits)
      SPI.transfer(0x00);
      SPI.transfer(0x00);

      uint8_t charbuf[64];
      for (int i = 0; i < 65536; i++){ //lit la mémoire au complet (2Mo (62500*32octets))
        for (byte i = 0; i < 64; i = i + 2){
          charbuf[i] = SPI.transfer(0x00);
          charbuf[i+1] = 0x00;
        }
        Serial.write(charbuf, sizeof(charbuf));
      }

      SPI.endTransaction();
      SPI.end();
      digitalWrite(sram_CSn, HIGH);
      Serial.write(0xCC); //mmh il va falloir que je repense ce mécanisme
      Serial.write(0xCC); //send the stop bytes
    }

}

J’utilise le PWM pour simuler une clock qui alimente le micro PDM. Avec la commande analogWriteFrequency, je choisis une fréquence d’opération de 200kHz. Pour monter aussi haut, il faut jouer avec la résolution à l’aide de la commande analogWriteResolution, que je mets à 7. On active la clock avec la commande analogWrite(pin, 64), puisque la résolution est de 7 bits (0-128).

Pour initialiser la mémoire MRAM, il faut d’abord envoyer la commande 0x06 qui permet d’activer l’écriture dans la mémoire. On a seulement besoin d’envoyer cette commande une seule fois, c’est la protection en écriture. La commande write est 0x02, suivie de l’adresse de 24 bits. Je commence à 0x000000 pour l’adresse.

Pour faire mes tests, j’ai envoyé un signal triangulaire, ainsi qu’écrit la mémoire au complet à zéro pour m’assurer que le test est répétable.

Tant que la pin sram_CSn reste à LOW, le write se poursuit indéfiniment. La mémoire n’a pas besoin de rafraîchissement comme une PSRAM, ce qui fait qu’elle peut se comporter en buffer circulaire sans intervention externe.

Pour éviter que les données soient corrompues, je dois envoyer une seconde commande de write avant de lire la mémoire. Je n’ai pas investigué davantage pourquoi, c’est un bug qui m’a fait perdre beaucoup de temps et j’ai juste été content de le contourner. J’en profite pour écrire une série de 1000 points à la valeur 0xFF, ce qui me permet de distinguer la position de la fin de l’échantillon dans le buffer circulaire plus tard, en assumant que les vraies données ne contiendront jamais un tel signal saturé. C’est utile, puisque je n’ai pas trouvé de manière de savoir où le pointeur d’adresse est rendu lorsque la mémoire est opérée de manière autonome.

La commande de read est 0x03, suivie de l’adresse de 24 bits. Les données sont reçues lors d’un SPI.transmit(), le contenu du write est ignoré, je mets 0x00. Je crée un buffer de 64 octets pour transférer 32 octets de données à la fois dans un serial.print. Cela permet d’optimiser un peu la transmission usb. La fin de la transmission est signalée par le doublon 0xCC 0xCC, qui est détecté dans mon code python. Toutes les autres données sont sur le format 0x00 0xXX, le premier octet est vide et jeté lors de la lecture. Cette méthode est simple et me permet de transmettre 32 octets distincts par paquet, au prix d’une efficacité de 50%.

À 200kHz et même à 1MHz, la consommation en écriture est d’environ 9mA. Ce qui est plus élevé que la mémoire SRAM (1mA) mais plus bas que la mémoire flash (30mA et plus).

Interface avec une mémoire serial SRAM

J’ai testé la puce IS62WVS2568GBLL, qui est une mémoire Serial SRAM de 2Mb. Elle coûte 3,68$ à l’unité. C’est la plus grande capacité que j’ai pu trouver. Son interface est un port SPI. Voici le code que j’ai utilisé pour la tester. J’écris un signal triangulaire de 256 samples, qui se répète sur l’ensemble de la mémoire. J’utilise un fichier .wav pour visualiser les données. La consommation en write continu est d’environ 1mA, à 3.3V d’alimentation et une clock de 200kHz. On peut écrire avec une clock qui peut aller jusqu’à 45MHz (pour une consommation en courant supérieure). On peut écrire en continu sans toggler la pin CS#, ce qui fait que l’écriture peut être contrôlée simplement par la clock, une fois la commande de write envoyée, ce qui en fait un candidat intéressant pour un buffer circulaire indépendant d’un microcontrôleur.

Code arduino :

#include <SPI.h>
int sram_CSn = A2;
int mic_sel = A0;
int mic_clk = A1;

void setup() {
  pinMode(mic_enable, OUTPUT);
  pinMode(sram_CSn, OUTPUT);
  pinMode(mic_clk, INPUT);
  digitalWrite(mic_enable, LOW);
  digitalWrite(sram_CSn, HIGH);
  Serial.begin(115200);

  //Set SRAM to sequential operation
  SPI.beginTransaction(SPISettings(100000, MSBFIRST, SPI_MODE0)); //100kHz
  digitalWrite(sram_CSn, LOW);
  SPI.transfer(0x01); //write mode register
  SPI.transfer(0x40); //01 : sequential mode + bits 0-5 set to 0
  SPI.endTransaction();
  digitalWrite(sram_CSn, HIGH);
  SPI.end();

  //Test sequential write
  SPI.begin();
  SPI.beginTransaction(SPISettings(100000, MSBFIRST, SPI_MODE0)); //100kHz
  digitalWrite(sram_CSn, LOW);
  SPI.transfer(0x02); //write command
  SPI.transfer(0x00); //address (24 bits)
  SPI.transfer(0x00);
  SPI.transfer(0x00);
  for (int i = 0; i < 44100; i++){
    SPI.transfer(byte(i % 256));
    SPI.transfer(0x00);
  }
  SPI.endTransaction();
  digitalWrite(sram_CSn, HIGH);
  SPI.end();

}

void loop() {
  int cmd = Serial.read();
    if (cmd == 114){
      SPI.begin();
      SPI.beginTransaction(SPISettings(100000, MSBFIRST, SPI_MODE0)); //100kHz
      digitalWrite(sram_CSn, LOW);
      SPI.transfer(0x03); //read command
      SPI.transfer(0x00); //address (24 bits)
      SPI.transfer(0x00);
      SPI.transfer(0x00);

      SPI.transfer(0x00); //dummy 8 bytes
      for (int i = 0; i < 44100; i++){
        Serial.write(SPI.transfer(0x00));
      }
      SPI.endTransaction();
      digitalWrite(sram_CSn, HIGH);
      SPI.end();
      Serial.write(0xCC);
      Serial.write(0xCC); //send the stop bytes
    }
}

Code python sur l’ordi :

import serial
import wave, struct

ser = serial.Serial("/dev/ttyACM0", 115200)

ser.write(bytes("r", encoding = 'utf8')) #send read command to get flash memory content

buf = []
while(True):
    char = ser.read(2)
    sample = int.from_bytes(char, byteorder='little') #- 63000 #convert to signed int 16 bits
    if (sample > 32767) or (sample < -32768):
        sample = 0
    if char == b'\xcc\xcc':
        print(len(buf))
        break
    else:
        buf.append(sample)
        #if not (len(buf) % 44100):
        #    print('{}%'.format(int(len(buf) / sizeoffile * 100)), end='\r', flush=True)

#plt.plot(buf)
#plt.show()
with wave.open('sound.wav','w') as obj:
    obj.setnchannels(1) # mono
    obj.setsampwidth(2) #16 bits
    obj.setframerate(22050.0) #sampling frequency
    for value in buf:
        data = struct.pack('<h', value)
        obj.writeframes(data)

Transfert bluetooth d’un feather nRF52840 à un PC Linux

Dans un article précédent, https://coinfocal.com/2022/10/16/bracelet-haptique-a-effet-peltier/, j’explique comment communiquer des données de commandes d’un ordi à un feather nRF52840. Récemment, j’ai eu besoin de transmettre des données dans le sens inverse, soit du microcontrôleur à l’ordi. C’est un peu plus compliqué, parce que je me suis rendu compte que la fonction bleuart.write(), de la librairie bluefruit d’Adafruit, a un comportement asynchrone. On ne peut donc pas utiliser la fonction delay() car cette dernière n’est plus bloquante. Il faut utiliser un timer, soit avec la fonction millis(), soit avec elapsedMillis(). Je préfère cette dernière, parce que je suis un fan de Teensy, mais les deux fonctionnent. AInsi, la seule manière de créer un délai, est de lire le temps du timer dans la boucle et d’agir si la valeur dépasse le temps souhaité.

Du côté de la réception, la librairie python contient probablement de l’asynchronisme en-dessous du capot, mais l’interface avec la fonction read() est bloquante. Fait intéressant, même si aucune donnée n’est transmise, un packet vide est reçu à chaque seconde.

Le code arduino est le suivant :

/*********************************************************************
 This is an example for our nRF52 based Bluefruit LE modules

 Pick one up today in the adafruit shop!

 Adafruit invests time and resources providing this open source code,
 please support Adafruit and open-source hardware by purchasing
 products from Adafruit!

 MIT license, check LICENSE for more information
 All text above, and the splash screen below must be included in
 any redistribution
*********************************************************************/
#include <bluefruit.h>
#include <Adafruit_LittleFS.h>
#include <InternalFileSystem.h>
#include "elapsedMillis.h"

// BLE Service
BLEDfu  bledfu;  // OTA DFU service
BLEDis  bledis;  // device information
BLEUart bleuart; // uart over ble
BLEBas  blebas;  // battery

#define SAMPLE_LEN 32 //Tel que configuré actuellement, bleuart peut seulement envoyer 64 bytes à chaque 1s
char sendBuffer[2*SAMPLE_LEN]; //Le buffer à envoyer par bluetooth
uint16_t* samplept = (uint16_t*)sendBuffer; //Lit le buffer en le considérant comme du 16 bits
uint8_t samplecnt = 0;
elapsedMicros bufferTimer; // Timer qui surveille le temps entre les enregistrements

void setup()
{
  analogReadResolution(14); //Les ADC lisent en 14 bits
  Serial.begin(115200);

#if CFG_DEBUG
  // Blocking wait for connection when debug mode is enabled via IDE
  while ( !Serial ) yield();
#endif
  
  Serial.println("Bluefruit52 BLEUART Example");
  Serial.println("---------------------------\n");

  // Setup the BLE LED to be enabled on CONNECT
  // Note: This is actually the default behavior, but provided
  // here in case you want to control this LED manually via PIN 19
  Bluefruit.autoConnLed(true);

  // Config the peripheral connection with maximum bandwidth 
  // more SRAM required by SoftDevice
  // Note: All config***() function must be called before begin()
  Bluefruit.configPrphBandwidth(BANDWIDTH_MAX);
  
  Bluefruit.begin();
  Bluefruit.setTxPower(4);    // Check bluefruit.h for supported values
  //Bluefruit.setName(getMcuUniqueID()); // useful testing with multiple central connections
  Bluefruit.Periph.setConnectCallback(connect_callback);
  Bluefruit.Periph.setDisconnectCallback(disconnect_callback);

  // To be consistent OTA DFU should be added first if it exists
  bledfu.begin();

  // Configure and Start Device Information Service
  bledis.setManufacturer("Adafruit Industries");
  bledis.setModel("Bluefruit Feather52");
  bledis.begin();

  // Configure and Start BLE Uart Service
  bleuart.begin();

  // Start BLE Battery Service
  blebas.begin();
  blebas.write(100);

  // Set up and start advertising
  startAdv();

  Serial.println("Please use Adafruit's Bluefruit LE app to connect in UART mode");
  Serial.println("Once connected, enter character(s) that you wish to send");
}

void startAdv(void)
{
  // Advertising packet
  Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
  Bluefruit.Advertising.addTxPower();

  // Include bleuart 128-bit uuid
  Bluefruit.Advertising.addService(bleuart);

  // Secondary Scan Response packet (optional)
  // Since there is no room for 'Name' in Advertising packet
  Bluefruit.ScanResponse.addName();
  
  /* Start Advertising
   * - Enable auto advertising if disconnected
   * - Interval:  fast mode = 20 ms, slow mode = 152.5 ms
   * - Timeout for fast mode is 30 seconds
   * - Start(timeout) with timeout = 0 will advertise forever (until connected)
   * 
   * For recommended advertising interval
   * https://developer.apple.com/library/content/qa/qa1931/_index.html   
   */
  Bluefruit.Advertising.restartOnDisconnect(true);
  Bluefruit.Advertising.setInterval(32, 244);    // in unit of 0.625 ms
  Bluefruit.Advertising.setFastTimeout(30);      // number of seconds in fast mode
  Bluefruit.Advertising.start(0);                // 0 = Don't stop advertising after n seconds  
}

void loop()
{
  if (bufferTimer >= 100) { //parce que le code BLE est asynchrone, on doit utiliser un timer au lieu du delay
    //À 50ms, on sample à 20Hz
    samplept[samplecnt] = analogRead(A0);
    samplecnt = samplecnt + 1;
    bufferTimer = 0;
  }
  if (samplecnt == SAMPLE_LEN) {
    bleuart.write( sendBuffer, SAMPLE_LEN * 2 );
    samplecnt = 0;
  }
}

// callback invoked when central connects
void connect_callback(uint16_t conn_handle)
{
  // Get the reference to current connection
  BLEConnection* connection = Bluefruit.Connection(conn_handle);

  char central_name[32] = { 0 };
  connection->getPeerName(central_name, sizeof(central_name));

  Serial.print("Connected to ");
  Serial.println(central_name);
}

/**
 * Callback invoked when a connection is dropped
 * @param conn_handle connection where this event happens
 * @param reason is a BLE_HCI_STATUS_CODE which can be found in ble_hci.h
 */
void disconnect_callback(uint16_t conn_handle, uint8_t reason)
{
  (void) conn_handle;
  (void) reason;

  Serial.println();
  Serial.print("Disconnected, reason = 0x"); Serial.println(reason, HEX);
}

Le code python ressemble à ça :

from adafruit_ble import BLERadio
from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
from adafruit_ble.services.nordic import UARTService
import array
import numpy as np
import matplotlib.pyplot as plt
import time

ble = BLERadio()

uart_connection = None

plotbuffer = []

countdatalen = 0

while True:
    if not uart_connection:
        print("Trying to connect...")
        for adv in ble.start_scan(ProvideServicesAdvertisement):
            if UARTService in adv.services:
                uart_connection = ble.connect(adv)
                print("Connected")
                break
        ble.stop_scan()

    if uart_connection and uart_connection.connected:
        uart_service = uart_connection[UARTService]
        starttime = time.time()
        while uart_connection.connected:
            receiveddata = uart_service.read()
            countdatalen = countdatalen + len(receiveddata)
            print(countdatalen)
            #Test de bandwidth 
            if countdatalen >= 10000:
                print(time.time()-starttime)
                break
            #if receiveddata is not None:
                #print(len(receiveddata))
                #plotbuffer.append(array.array('H',receiveddata)) #convertit le bytearray en array de 16bits unsigned
                #plt.plot(array.array('H',receiveddata))
                #plt.show()

Pour une raison obscure, le packet envoyé doit être de 64 octets ou moins. En creusant un peu, je crois que c’est une limitation du bord de l’ordi. La librairie bleuart sur le nRF52840 est supposée supporter un packet de 247 octets. Le microcontrôleur agit comme un périphérique, il s’adapte donc aux limites du contrôleur (ici l’ordi). Cela limite également la bande passante. Dans un test, j’arrive à environ 640 octets/s. Je reçois plutôt exactement un paket de 64 octets à chaque 100ms. J’ai investigué un peu pour comprendre d’oû viennent ces limites.

Selon le standard BLE, l’intervalle de connexion peut descendre jusquà 7.5ms. Il est défini par le contrôleur. En essayant de changer cet intervalle dans le code python, cela me sort l’erreur suivante : NotImplemented pour la méthode setConnInterval. Probablement qu’il est par défaut à 100ms, afin de supporter le plus grand nombre possible d’appareils. Une autre façon de le calculer, c’est que dans le standard qui date d’avant 4.2, la taille du packet était limitée à 20 octets. Pour un intervalle de connection de 30ms, cela fait 1 packet * 20 bytes * 1/0.030 s = 667 octets/s. C’est le cas par exemple de la connxion entre un iPhone 5/6 + IOS 8.x et un nRF8001 (pas du tout ce que j’utilise, mais à des fins de comparaison). Les chiffres proviennent de https://learn.adafruit.com/introducing-adafruit-ble-bluetooth-low-energy-friend/faq

Probablement qu’en utilisant deux nrf52840 pour communiquer, on peut augmenter la bande passante de manière significative. Une autre piste serait d’utiliser le nrf52840 en mode contrôleur. Les nombreuses couches de wrapper (adafruit_ble, qui est en fait la librairie python adafruit-blinka-bleio, qui est elle basée sur la librairie bleak.) Bref, ça devient vite le cauchemar à déboguer, comme d’habitude. C’est pas pour rien que j’avais abandonné bluetooth en 2016 après plusieurs essais infructueux de la configuration du kernel, etc. Heureusement, Adafruit est venu à la rescousse entre temps.

En conclusion, je suis content que ça fonctionne à 640 octets/s. Ça fait partie de la technologie, d’avoir une bande passante limitée, mais une très faible consommation énergétique et une robustesse dans la transmission.

Calcul de la résolution spectrale d’un spectromètre

Dispersion spectrale du système

Une fente de hauteur h est placée au plan focal d’une lentille convexe ou d’un miroir concave. En utilisant l’approximation paraxiale, l’angle produit par cette hateur est donnée par theta ~= h/f, où h est la hauteur de la fente et f la longueur focale. On peut le voir selon l’optique de Fourier : la fente de largeur h produit un angle de largeur theta à la sortie de la lentille. On place le réseau à cet endroit, et il crée lui aussi une dispersion angulaire, selon l’équation sin(theta)=m*d*lambda, où m est l’orde de diffraction, d le pas du réseau (lignes/mm) et lambda la longueur d’onde. Pour un angle plus petit que h/f, on ne saura pas si la dispersion provient du réseau (donc de la longueur d’onde) ou bien de l’optique d’entrée (indépendante de la longueur d’onde). Puisque nous souhaitons différencier les longueurs d’onde pour obtenir un spectre, cela nous donne l’équation de la limite de résolution du système (en posant dans les deux cas sin(theta)=theta ) : h/f = m*d*delta_lambda => delta_lambda = h/(m*d*f).

À titre d’exemple, pour une fente de 63 um, une focale de 100 mm, un pas de 600 lignes/mm et l’ordre 1, on obtient une résolution de 1nm.

Largeur spectrale

Il y a un repliement spectral inhérent à l’équation du réseau. J’avais oublié ceci, c’est expliqué dans le Hecht à la page 481. En bref, il n’est pas possible de faire la distinction entre l’ordre m+1 et la longueur d’onde lambda/(m+1). Par exemple, un réseau produira la même dispersion angulaire pour une longueur d’onde de 600nm à l’ordre 1, que 300nm à l’ordre 2. Cela limite donc la largeur spectrale à lambda_max /2. Des techniques supplémentaires doivent être utilisées pour tenir compte de cet effet. Pour l’instant, il suffit de le garder en tête et je verrai quoi faire selon l’application.

Optique et fente de sortie

On doit s’assurer que h2f2 <= h1f1, autrement dit, que la fente de sortie et la focale de la lentille de sortie soient égales ou plus petites que les paramètres d’entrée. Le plus simple est souvent de garder les mêmes valeurs, soit ici 63 um et 100 mm. Si pour une raison ou une autre, cela n’est pas le cas, ce sera ce paramètre qui limitera la résolution finale du système. Par exemple, si je mets une photodiode de 1mm sans fente à la sortie du spectromètre, même si la fente d’entrée est de 63 um, la résolution spectrale sera de 17nm.

Aberrations du système optique

Jusqu’à maintenant, on utilisait des optiques sans défaut (et l’équation paraxiale!). Dans la vraie vie, les aberrations peuvent souvent limiter la résolution specrtale d’un système. La meilleure approche est toujours de diminuer l’ouverture numérique : augmenter la focale et diminuer les hauteurs des composants. Pour réduire les coûts, j’ai choisi un design à un seul miroir concave avec une focale de 100mm, et le plus petit réseau, soit 12,7mm de large. On verra plus loin que l’on a encore une marge de manoeuvre pour la limite posée par la diffraction de ces ouvertures. J’utilise OSLO pour simuler le tout. Je suis parti de la lentille d’exemple du monochromateur et je l’ai modifié comme suit :

// OSLO 22.2.0.22257 20041     0 34476
LEN NEW "Spectrometre 300-1700nm" -1e+20 4
NAO  0.08
OBH  1.0e-06
DES  "Fred"
UNI  1.0
SNO1 "This is a very simple but interesting spectrograph. It is telecentric and"
SNO2 "afocal, but is used as a focal system. The grating is square, so the ebr must"
SNO3 "be oversized by sqrt(2) to fill it. The default drawing rays have been set at"
SNO4 "fy=0.6 so they pass within the checked grating aperture. Try interactive design"
SNO5 "and vary the object decenter and the grating tilt to see how this system works."
SNO10 "DIFFRACTIVE"
// SRF 0
AIR 
TH   100.0
AP  1.0e-06
DT   1
DCY  -15.0
NXT  // SRF 1
RFH 
RD   -200.0
TH   -100.0
AP  25.0
NXT  // SRF 2
RFH 
TH   100.0
AP  6.4
AST 
DT   1
TLA  -31.0
APN  1
AY1 0 -6.3
AY2 0 6.3
AX1 0 -6.3
AX2 0 6.3
ATP 0 2
AAC 0 4
GOR  1
GSP  0.0016667
RCO 0
NXT  // SRF 3
RFL 
TCE  236.0
PK CV   -2 0.0 
TH   -100.0
PK  AP  -2
NXT  // SRF 4
AIR 
AP  25.0
CBK  1
RAIM Tel
WV 1.7
WW 1.0
END  4
DLID 100.0
DLFD 100.0
DLMN  0 -0.65
DLMX  0 0.65
DLNR  1 3
DLFP  1 0.0
DLMN  1 -0.65
DLMX  1 0.65
DLWN  1 2
DLFP  2 0.0
DLMN  2 -0.65
DLMX  2 0.65
DLWN  2 3

L’ouverture numérique du système est de 0,08. Dans la construction, il faudra rajouter des stops quelque part pour la respecter, sinon la lumière pourrait se rendre jusqu’au détecteur par d’autres chemins. L’utilisation d’une fibre optique à NA=0,1 pourrait aussi être une bonne solution. Le schéma ressemble à ceci :

J’ai mis la longueur d’onde maximale, soit 1700nm, pour que le réseau ait l’angle maximal et produise ainsi le plus d’aberration. Je m’attends à ce qu’il y ait de l’aberration sphérique, de la coma et de l’astigmatisme, et sûrement autre chose. L’utilisation d’un miroir élimine les aberrations chromatiques, qui seraient ingérables sur une aussi grande bande spectrale. Dans mon cas, ce qui m’intéresse, c’est la largeur de la tache (spot size) produit par la somme de toutes les aberrations. Selon la simulation, cela donne ceci :

Le spectre sera selon l’axe y, donc la valeur à retenir est 0,017mm. On pourrait donc théoriquement avoir une fente aussi petite que cela, pour une résolution du sytème à ~0,27nm. Par contre, on perdrait le signal qui s’étalerait tout de même sur 62um en x. Notons que le résutat est très proche de la limite de la diffraction, soit 0,014mm. Cela veut dire que si l’on voulait aller chercher une meilleure résolution que 0,27nm, il faudrait grossir les composantes. J’utilie déjà le miroir standard le plus large (50mm), il faudrait donc utiliser deux miroirs, ou bien un miroir plus large et donc beaucoup plus dispendieux. Pour conserver une ouverture numérrique basse, le système devra également avoir des focales plus grandes, etc. Je souhaite produire un instrument portable et peu dispendieux, ces contraintes sont à prendre en considération.

Diffraction du réseau

Il est important que le réseau soit le plus possible en condition de pleine illumination, car la diffraction produite par N fentes pose également une limite à la résolution. L’équation est donnée dans le Hecht à la page 481 :

lambda / delta_lamba = mN, où N = dL est le nombre de lignes total du réseau, d est le pas du réseau et L la largeur

En réarrangeant l’équation, on a :

delta_lambda = lambda / (m * d * L)

Prenons le cas extrême dans mon exemple, 1700nm : on a une diffraction de 1700nm / (1 * 600mm^-1*12,7mm) = 0,22nm. Pour descendre en résolution, il faudrait un réseau plus large ou avec un pas plus dense. Comme je m’en suis rendu compte dans mon article prédédent, 600 lignes/mm est le maximum pour obtenir des angles raisonnables à 1700nm. La seule solution est encore une fois d’avoir un réseau plus large, donc un système optique plus large. Je vais me satisfaire du design actuel, puisque ce n’est pas cet effet qui limite la résolution.

Précision mécanique de l’angle du réseau

C’est bien beau les simulations, mais dans les faits, il va falloir aligner tout ça comme il faut. On verra comment je réussirai à assembler tout ça. Pour les fins de la simulation, le paramètre important est la résolution angulaire du moteur. Dans ce type de spectromètre, la fente d’entrée et de sortie gardent leur position, et on scanne les longueurs d’onde en faisant tourner le réseau. Par exemple, selon la simulation OSLO, pour une longueur d’onde de 300nm, il faudra mettre le réseau à un angle de -5,5deg, et pour 1700nm, ce sera -31deg. En approximant le tout comme étant linéaire, on obtient environ 0,018deg/nm. L’encodeur que je souhaite utiliser est spécifié à 0,011deg de résolution. Je pourrai donc garantir une résolution de 1nm. Je pourrais aller plus bas en microstepping, mais je n’aurais pas la certitude que mécaniquement l’angle soit réellement changé. Je pourrais aussi utiliser une boîte d’engrenages, mais cela vient avec des problèmes supplémentaires : backlash et répétabilité.

Conclusion

On voit que pour plein de raisons, la limite de la résolution spectrale tourne autour de 1nm dans mon design. Aller au-delà demande une complexité dans laquelle je ne suis pas prês de m’aventurer. Ce sera déjà un bon défi d’atteindre ce niveau de performance avec les pièces que j’ai choisies.

Plage d’utilisation d’un réseau de diffraction

Je suis en train de concevoir un spectromètre de type Czerny–Turner. En le simulant sur OSLO, je me suis rendu compte d’une propriété que j’avais oubliée : pour chaque valeur de pas (lignes par mm), il y a une longueur d’onde maximale au-delà de laquelle l’angle de diffraction de l’ordre 1 dépasse physiquement les dimensions du spectromètre.

Dans mon cas, j’utilise un réseau en réflexion et un miroir concave. L’équation de la diffraction est :

sin(theta_m) – sin(theta_i) = m*d*lambda

où theta_m est l’angle diffracté, theta_i l’angle d’incidence, m l’ordre de diffraction, d le pas du réseau et lambda la longueur d’onde de la lumière.

En postulant theta_m = 2*theta_i (le faisceau réfracté revient sur le parcours du faisceau incident après une réflexion sur le réseau tourné à un angle -theta_i), approximation qui vient du fait que l’on souhaite avoir une longue focale pour les optiques pour avoir une bonne résolution, on trouve :

sin(2*theta_i) – sint(theta_i) = m*d*lambda

Si l’on trouve le maximum de la partie gauche de l’équation (https://www.wolframalpha.com/input?i=max%28sin%282x%29-sin%28x%29%29), on obtient 1,76.

J’étais vraiment curieux de la manière dont on obtient cette solution, alors j’ai payé la version pro de wolfram juste pour le savoir. On commence évidemment par faire la dérivée de l’expression et la mettre égale à zéro, ce qui fait 2cos(2x)-cos(x)=0. Rendu là, j’étais bloqué, parce que je ne trouvais aucune identité trigonométrique pour m’avancer en quoi que ce soit. Bien sûr, un calcul numérique serait possible, mais j’étais sûr qu’une solution analytique existe. L’identité magique à utiliser est en fait cos(2x) = 2cos^2(x) – 1. En l’utilisant, on obtient une fonction quadratique de cos(x) : -2-cos(x)+4cos^2(x)=0, qui se résout de la manière usuelle, en faisant attention aux signes et aux cadrant des angles. Bref, toutes ces mathématiques commençaient à être loin, un petit rappel a fait du bien.

Pour un réseau de 1200 lignes/mm (que je prévoyais utiliser initialement), la longueur d’onde maximale pouvant être diffractée vers l’optique est de :

1,76 / (1*1200mm⁻1) = 1,47um.

Étant donné que j’ai l’intention de mesurer des sources à 1550nm avec ce spectromètre, je dois modérer mes ardeurs et descendre à 600 lignes/mm. Ce qui fait prendre un coup à ma résolution spectrale, mais au moins, les angles sur le réseau sont plus réalistes. À 600 lignes/mm, la longueur d’onde maximale est le double, soit 2,94um. Pour l’instant, je prévois utiliser une photodiode InGaAs, qui peut détecter la lumière juqu’à 1700nm, ce qui posera la limite supérieure de mon spectromètre.

Bugs de luminosité et de volume de speaker sur un ordi dual-boot

Note à moi-mème :

Windows considère toujours qu’il est le seul OS sur une machine. L’une des fâcheuses conséquences, c’est de penser qu’il y a un problème avec les speakers de son ordi pendant des mois, alors que tout fonctionne bien. Si on mute les speakers sur Windows et qu’on boote en linux ensuite, le setting reste dans le bios et linux ne peut plus le changer. Il faut donc toujours laisser les speakers avec du volume au moment de fermer Windows. Merci la vie.

Mesure des performances de vitesse de PySerial

J’ai recommencé à jouer avec la communication USB du TinyFPGA, à l’aide de ce programme : https://github.com/davidthings/tinyfpga_bx_usbserial

Je n’ai pas tout saisi le fonctionnement du pipeline, mais ce que j’ai pu observer, c’est qu’en maintenant le signal « uart_valid » de la transmission à 1, le caractère placé dans uart_data est transmis à l’infini, le plus rapidement possible. (J’envoie ici 97, ou « a » en ASCII).

Pour n’envoyer qu’une seule fois le caractère, je compte 2 transitions d’horloge où je maintiens uart_valid à 1. Voici la section du code Verilog (code complet à la fin de l’article) :

    reg[26:0] count1hz;

    always @(posedge clk_48mhz) begin
        if (count1hz == 0)
            uart_in_valid <= 1'b1;
        if (count1hz >= 10000) //valid doit être à 1 pendant n+1 cycles complets de clock pour transférer n byte
            uart_in_valid <= 1'b0;
    end

    always @(posedge clk_48mhz) begin
        if (count1hz >= 47999999) //tick 1Hz
            count1hz <= 0;
        else
            count1hz <= count1hz + 1;
    end

    assign uart_in_data = 97;

Et voici le programme python . J’utilise python 3.11.3 sur Fedora 38 et pyserial 3.5

import serial

ser = serial.Serial("/dev/ttyACM0")

idx = 0
while(True):
    msg_lenght = ser.in_waiting
    if msg_lenght > 0:
        idx = idx + 1
        print(idx)
        ser.read(msg_lenght)

Première constatation, le buffer est optimisé pour 64 caractères. Si je maintiens uart_valid pendant plus de 64 transitions d’horloge, seulement 64 caractères se rendent jusqu’à python. Pour arriver à 128 (donc 2 paquets USB), il faut attendre entre 5000 et 10000 transitions. En approximant vers le haut, cela signifie une fréquence de ~5kHz (L’horloge va à 48Mhz). Donc le taux de répétition des paquets est de 10kHz, ce qui fait 640ko/s, ou 6,4 Mbaud.

Honnêtement, je m’attendais à pire. Je lui ai quand même donné une chance en lisant d’un seul coup les 64 octets de son buffer, à l’aide de la commande in_waiting, au lieu d’une boucle for et d’un read sur chaque octet individuel (à proscrire).

Ma mesure concorde avec d’autres sur internet (voir par exemple https://stackoverflow.com/a/56240817, où cela donne 790ko/s).

Comparé au potentiel théorique de l’USB 2.0 de 48Mo/s, on est loin du compte. Toutefois, pour des applications où le débit d’information est bas, pyserial (et python en général) est très simple à utiliser.

Je suis à la recherche d’alternatives pour aller plus vite. Il y a la librairie pyserialtranfer (https://github.com/PowerBroker2/pySerialTransfer), mais je ne suis même pas sûr qu’il y aura un gain de vitesse. Sinon, essayer de voir ce qu’il se fait avec c++. Autre truc intéressant à comprendre, comment être sûr que le protocole à 480Mbit/s est utilisé. Parce que USB1 a la spécification d’aller à 12Mbit/s, ce qui est beaucoup plus dans l’ordre de grandeur que ce que je mesure (6Mbit/s). Donc peut-être que c’est aussi un problème de kernel et d’identification de la part du tinyFPGA. Peut-être (probablement) que l’implémentation de davidthings se base sur le standard USB1. Je ne connais pas les différences fondamentales au niveau du hardware.

EDIT : j’ai vérifié dans le kernel (avec la commande lsusb, puis lsusb -t) et le tinyFPGA est listé comme un device avec 12M de vitesse (USB1) :

Port 3: Dev 102, If 1, Class=CDC Data, Driver=cdc_acm, 12M

Ceci explique cela. La quête vers l’USB2 sera peut-être plus ardue que je le pensais. Cela change un peu ma conclusion : pyserial est une librairie parfaitement adaptée pour l’utilisation avec des devices suivant le protocole USB1.

Le code verilog complet :

/*
Voir le code pour le usb serial ici
https://github.com/davidthings/tinyfpga_bx_usbserial
*/

module uart_top (
        input  pin_clk,

        inout  pin_usb_p,
        inout  pin_usb_n,
        output pin_pu,

        output pin_led,
    );

    wire clk_48mhz;
    wire clk_locked;

    // Use an icepll generated pll
    pll pll48( .clock_in(pin_clk), .clock_out(clk_48mhz), .locked( clk_locked ) );

    // // LED
    // reg [22:0] ledCounter;
    // always @(posedge clk_48mhz) begin
    //     ledCounter <= ledCounter + 1;
    // end
    // assign pin_led = ledCounter[ 22 ];

    // Generate reset signal
    reg [5:0] reset_cnt = 0;
    wire reset = ~reset_cnt[5];
    always @(posedge clk_48mhz)
        if ( clk_locked )
            reset_cnt <= reset_cnt + reset;

    // uart pipeline in
    reg [7:0] uart_in_data;
    reg       uart_in_valid;
    reg       uart_in_ready;
    
    wire [7:0] uart_out_data;
    wire       uart_out_valid;
    wire       uart_out_ready;
    

    // usb uart - this instanciates the entire USB device.
    usb_uart uart (
        .clk_48mhz  (clk_48mhz),
        .reset      (reset),

        // pins
        .pin_usb_p( pin_usb_p ),
        .pin_usb_n( pin_usb_n ),

        // uart pipeline in
        .uart_in_data(uart_in_data),
        .uart_in_valid(uart_in_valid),
        .uart_in_ready(uart_in_ready),

        //.uart_out_data(uart_in_data),
        //.uart_out_valid(uart_in_valid),
        //.uart_out_ready(uart_in_ready)
    );

    // USB Host Detect Pull Up
    assign pin_pu = 1'b1;

    reg[26:0] count1khz;

    always @(posedge clk_48mhz) begin
        if (count1khz == 0)
            uart_in_valid <= 1'b1;
        if (count1khz >= 10000) //valid doit être à 1 pendant n+1 cycles complets de clock pour transférer n byte
            uart_in_valid <= 1'b0;
    end

    always @(posedge clk_48mhz) begin
        if (count1khz >= 47999999) //tick 1Hz
            count1khz <= 0;
        else
            count1khz <= count1khz + 1;
    end

    assign uart_in_data = 97;


endmodule