Reiniciar Arduino por software

Hay veces que necesitamos reiniciar la placa Arduino desde el propio software. Por ejemplo cuando se produce un error que no podemos gestionar. Sin embargo Arduino no trae ninguna función «reset» para hacerlos. Si recordamos lo que dijimos del watchdog precisamente esa es su función, reiniciar la placa. Podemos aprovechar eso para hacer una función de reset, configuramos el watchdog con el tiempo más corto posible y entramos en un bucle infinito, obligándolo a que pasado el tiempo reinicie la placa. Basta con llamar a la función reset() y la placa se reiniciara sola.

#include <avr/wdt.h> //libreria del watchdog

void reset(){
  wdt_enable(WDTO_15MS);
  while(1){};
}

Puede ser que hayamos sobrescrito el ISR del watchdog o que no queramos incluir la librería del watchdog solo para hacer un reset. La solución es sencilla, imitar lo que hace el watchdog, llamar a la función en la posición 0 de la tabla de vectores de interrupción. Da igual si no sabes lo que es la traducción es que salta la ejecución del código a la posición 0 de memoria. Podemos usar un puntero a función que apunte a esa dirección.

void(* reset) (void) = 0;

Y luego llamar a esa función.

reset();

Es recomendable usar el primer método siempre que se pueda ya que reiniciar la placa es la función del watchdog y seguro que funciona en todas las placas Arduino, la segunda solución depende más del microcontrolador de la placa y aunque deberia de funcionar las placas con microntroladores AVR no es seguro que funcione con otros microcontroladores.

Este texto mejorado y ampliado forma parte de mi libro sobre como mejorar tus programas en Arduino. Puedes echarle un vistazo aquí.

Puedes ver la versión en vídeo en mi canal:

Haz click para ver el vídeo en mi canal de Youtube

Enviar datos desde el ordenador al móvil con KDEConnect

Vamos a ver como mandar desde un script datos al móvil usando la herramienta KDEconnect. Para ello teneos dos comandos: kdeconnect-cli -d <device-id> –ping-msg «mensaje»que envía una notificación al movil con el texto del «mensaje» y kdeconnect-cli -d <device-id> –share <ruta> que envía el fichero que este en la ruta indicada.

Obtener device-id

Para obtener el listado de ids de todos los dispositivos que han sido vinculados a tu ordenador basta con ejecutar el comando:

kdeconnect-cli -l

El resultado es un listado con el nombre del dispostivo, su device-id y el estado. Si se desea obtener solo el el id se puede usar el parámetro –id-only :

kdeconnect-cli -l --id-only

Si solo tenemos un dispositivo vinculado podemos usar este comando para evitarnos tener que apuntar el device-id, en lugar de poner el device-id podemos usar:

$(kdeconnect-cli -l --id-only)

Más adelante veremos su uso dentro de un comando.

Enviar una notificación

Supongamos que queremos volcar el resultado de un comando en una notificación, podemos usar:

kdeconnect-cli -d <device-id> --ping-msg "$(ls -al)"

Como ya hemos dicho si solo tienes un dispositivo vinculado puedes usar:

kdeconnect-cli -d $(kdeconnect-cli -l --id-only) --ping-msg "$(ls -al)"

Podemos mandar un fichero corto:

kdeconnect-cli -d $(kdeconnect-cli -l --id-only) --ping-msg "$(cat fichero.txt)"

Este sistema tiene la ventaja de que la notificación aparece de forma inmediata en el teléfono, pero presenta la desventaja de que no se pueden enviar grandes cantidades de texto porque no «caben» y se cortan.

Enviar un fichero

Para enviar un fichero basta con conocer su ruta y usar el siguiente comando:

kdeconnect-cli -d <device-id> --share <ruta>

Podemos usarlo para enviar la salida de un comando:

ls -al > out.txt && kdeconnect-cli -d <device-id> --share out.txt

Lo podemos combinar con el «truco» de antes del device-id:

ls -al > out.txt && kdeconnect-cli -d $(kdeconnect-cli -l --id-only) --share out.txt

En el móvil aparece una notificación de la descarga del fichero.

Inicializar los servos en Arduino

Cuando se empieza con Arduino y se hace algún proyecto con servoa se llega el desesperante momento en que se reinicia la placa Arduino y los servos comienzan a dar bandazos hasta que llegan a la posición inicial que les marca el programa. ¿Como resolver esto?

Lo primero que hay que saber es que la mayoría de los ejemplos para aprender a usar la librería servo son incorrectos. No inicializan los servos y por se vuelvan locos. Veamoslo con un ejemplo sacado de la web de Arduino.

/* Sweep
 by BARRAGAN <http://barraganstudio.com>
 This example code is in the public domain.

 modified 8 Nov 2013
 by Scott Fitzgerald
 http://www.arduino.cc/en/Tutorial/Sweep
*/

#include <Servo.h>

Servo myservo; 

int pos = 0;

void setup() {
  myservo.attach(9);  
}

void loop() {
  for (pos = 0; pos <= 180; pos += 1) { 
    myservo.write(pos);
    delay(15);         
  }
  for (pos = 180; pos >= 0; pos -= 1) {
    myservo.write(pos);
    delay(15);  
  }
}

Lo primero es que el servo se vuelve «loco» porque toma los valores por defecto de la librería servo. Se pueden encontrar en el archivo Servo.h

#define MIN_PULSE_WIDTH       544     // the shortest pulse sent to a servo  
#define MAX_PULSE_WIDTH      2400     // the longest pulse sent to a servo 
#define DEFAULT_PULSE_WIDTH  1500     // default pulse width when servo is attached

Veamos lo que significan. Los servos ajustan su posición en base a la duración de un pulso que se les envía. En este caso se ajusta el pulso para que tenga una duración mínima de 544 microsegundos y una duración máxima de 2400 microsegundos. Si estáis acostumbrados a posicionar los servos usando grados de 0 a 180 el valor de 0 grados corresponde con un pulso de duración 544 y 180 con uno de 2400. Para los valores intermedios se interpola el valor, de tal forma que para G grados el pulso tiene que durar P microsegundos:

(G * (MAX_PULSE_WIDTH – MIN_PULSE_WIDTH) / 180 ) + MIN_PULSE_WIDTH = P

Sacando cuentas podemos ver que DEFAULT_PULSE_WIDTH corresponde más o menos con 90 grados.

Lo que ocurre es lo siguiente:

  1. Al ejecutar myservo.attach(9) el servo se mueve hasta los 90 grados
  2. Al ejecutar myservo.write(pos) el servo se mueve a la posición indicada, en este caso 0 grados.

No es que el servo se vuelva «loco» es que le decimos que de esos bandazos.

La solución es cambiar el setup para que antes de ejecutar myservo.attach(9) le ponga el valor con el que queremos inicializar el servo.

void setup() {
  myservo.write(pos); //inicializamos la posicion del servo
  myservo.attach(9);  
}

Conservar el valor anterior del servo

Hay casos en que no tenemos un valor por defecto para un servo, tiene que conservar el valor con el que se quedo antes del reinicio. Como no podemos leer el valor del servo (read/readMicroseconds no leen el valor del servo devuelve el último valor pasado a write/writeMicroseconds). ¿Qué podemos hacer? Usar la EEPROM para almacenar los valores de los servos. Podemos almacenar el valor del servo con EEPROM.put() y recuperarlos con EEPROM.get(). Sin embargo esto tiene un problema, la memoria EEPROM del Arduino tiene una vida de unas 100.000 escrituras, que pueden ser poco para algunos casos. Hay algunas posibles soluciones:

  • Crear una posición de apagado de tal manera que antes de apagarse los servos vuelvan a esa posición. No sirve para fallos/reinicios repentinos.
  • Guardar la posición de los servos cada cierto tiempo. En casos donde los servos cambian rápidamente de posición no sirve.
  • Ir cambiando la dirección de memoria EEPROM donde se almacena el estado de los servos. Si cada byte de EEPROM se puede escribir 100000 veces, si rotamos la dirección de byte a lo largo de toda la memoria alargamos esa vida.
  • Guardar solo la posición si cambia lo suficiente. Se compara el valor almacenado con el valor del servo si la diferencia es pequeña no se cambia.

Veamos un ejemplo del ultimo caso.

Mover un servo.

void moveServo(Servo servo, byte angle, int addressEEPROM) {
    byte savedAngle = EEPROM.read(addressEEPROM, angle);//recuperamos el valor 
    if(abs(angle - savedAngle) > 10){//si más de 10 grados de diferencia se guarda
        EEPROM.update(addressEEPROM, angle);
    }
    servo.write(angle);
}

Se incian los servos con el valor recuparado de memoria.

void initServo(Servo servo, int addressEEPROM, uint8_t pin) {
    int savedAngle;
    EEPROM.get(addressEEPROM, saveAngle);//recuperamos el valor guardado
    servo.write(savedAngle);
    servo.attach(pin);
}

Obtener el código en ensamblador de un sketch de Arduino

Una de las cosas que echaba de menos al programar un Arduino era la posibilidad de ver un desemsamblado del código generado. Hay veces que puede ayudarte a entender por qué falla algo o saciar tu curiosidad de cómo funciona. Aunque el IDE de Arduino no trae una opción cómoda para hacerlo es muy sencillo.

Hay que realizar el proceso en dos pasos:

Obtener el volcado binario del sketch de Arduino

Primero vamos a obtener un volcado binario del sketch. Para ello recurrimos a la opción «Exportar Binarios compilados» del menú «Programa».

Programa -> Exportar Binarios compilados

Una vez exportados podemos acceder a ellos con la opción «Mostrar Carpeta de Programa» del menú «Programa».

Como resultado de la exportación obtenemos dos archivos .hex.

Ficheros binarios obtenidos

Se corresponden al binario con el bootloader de Arduino o sin el bootloader. Podemos abrirlo con un editor hexadecimal y ver que contiene.

Primeras lineas de ejemplo.ino.with_bootloader.standard.hex

El fichero con el bootloader es el que se copia a la placa Arduino cuando «cargamos» el sketch

Convertir el volcado binario a ensamblador

Para realizar esta parte necesitaremos el programa avr-objdump el cual se puede encontrar en el directorio donde este instalado el IDE de Arduino en la ruta hardware\tools\avr\bin\avr-objdump. Una vez localizado podemos usar el siguiente comando para obtener el desensamblado del binario:

<ruta-avr-objdump>avr-objdump -j .sec1 -d -m avr5 fichero.hex > fichero.asm

Tras ejecutar ese comando la salida se vuelca en el fichero de nombre ejemplo.asm que podemos abrir con cualquier editor de textos.

Primeras lineas de ejemplo.asm

Como curiosidad las primeras lineas del fichero corresponde a la tabla de vectores de interrupción del microcontrolador, por eso son todo saltos al código que gestiona cada una de las interrupciones.

Este texto mejorado y ampliado forma parte de mi libro sobre como mejorar tus programas en Arduino. Puedes echarle un vistazo aquí.

Puedes ver todo este proceso en vídeo en mi canal de youtube:

Haz click para ver el vídeo en Youtube

Watchdog en Arduino

Un watchdog es un sistema de seguridad que usan muchos microcontroladores y sistemas embebidos. Su funcionamiento es simple, cada vez que se reinicia el watchdog empieza una cuenta atrás hasta cero, si antes de llegar a cero el watchdog se reinicia la cuenta atrás vuelve a empezar. Si no se reinicia y el contador alcanza el valor 0 el sistema se reinicia automáticamente. Así se evita que se quede colgado.

Con el reinicio se pierden los datos que están en la memoria SRAM por lo que todo dato que queramos conservar ha de guardarse en la EEPROM.

Para usar el watchdog hemos de incluir la librería del fabricante del microcontrolador:

#include <avr/wdt.h>

Para deshabilitar el watchdog se puede usar la función:

wdt_disable();

Empezamos aprendiendo a deshabilitarlo porque es lo primero que hay que hacer para evitar problemas durante el inicio de la placa o puede interferir en el proceso de grabación de un nuevo código en la placa. Cuando se vuelca un nuevo código hay que dar tiempo para que se pueda volcar un nuevo programa o se perderá la posibilidad de grabar nuevos programas. La causa de esto es que cuando se empieza a subir un nuevo código desde el IDE se paraliza la ejecución del programa, por lo que no se reinicia el contador del watchdog si este llega a cero antes de que se termine de subir el código la placa se reinicia y la grabación se corta.

Para activar el watchdog y fijar cuanto tiempo tiene que esperar el watchdog antes de reiniciar la placa se usa la función:

wdt_enable(WDTO_1S);

El parámetro que recibe la función indica el tiempo que esperará el watchdog antes de reiniciar el dispositivo. No puede tomar cualquier valor, tiene que ser una de los siguientes valores predefinidos:

  • WDTO_15MS
  • WDTO_30MS
  • WDTO_60MS
  • WDTO_120MS
  • WDTO_250MS
  • WDTO_500MS
  • WDTO_1S
  • WDTO_2S
  • WDTO_4S
  • WDTO_8S

Para evitar que se reinicie la placa hemos de llamar a la siguiente función ante de que pase el tiempo indicado:

wdt_reset();

Tras llamarla la cuenta atrás comienza otra vez.

Un ejemplo de esqueleto de función seria la siguiente:

#include <avr/wdt.h>

setup(){
  wdt_disable();
  //el resto del setup
  delay(3000); //para evitar que no nos da problemas al cargar nuevos programas
  wdt_enable(WDTO_4S); //se activa el watchdog 
}

loop(){
 wdt_reset(); //reiniciar contador del watchdog
 //código del programa
}

Este texto mejorado y ampliado forma parte de mi libro sobre como mejorar tus programas en Arduino. Puedes echarle un vistazo aquí.

También puedes verla versión en vídeo en mi canal de youtube:

Haz click para ver el vídeo en mi canal de youtube

Memoization con tiempo de vida

La memoization ya vimos lo que era y como usarla. Ahora vamos a añadir una característica más que puede resultar útil en algunos casos: «tiempo de vida». Cada respuesta memorizada solo va a ser válida durante un periodo de tiempo. Pasado ese tiempo el resultado deja de ser válido y si se vuelve a llamar

Todo lo que veamos en este texto esta publicado en la librería memo.js

El primer cambio que vamos a realizar sobre lo que vimos en el anterior post de memoization es que tendremos dos variables para almacenar los datos, una con la cache de los parámetros y sus valores y otra con el momento en que se almacenó esa cache.

La forma de funcionar es la misma que la memoization normal solo que cuando se va recuperar un valor de la cache se comprueba cuanto tiempo hace que se almaceno y si ha transcurrido un tiempo mayor que el tiempo de vida fijado se llama a la función y se actualiza la cache con el valor que devuelva.

Veamos algo de código partiendo de la librería de memoization que creamos.

Lo primero es extender la clase original:

class MemoTime extends Memo {

}

Modificamos el constructor para que cree una variable (lifetime) donde guardar cuando fue la última vez que se actualizo esa clave. También se le tiene que pasar al constructor el tiempo de vida (timeOfLife)

 constructor (func, timeOfLife, keyFunc) {
    super(func, keyFunc);
    this.timeOfLife = timeOfLife || 1000;
    this.lifetime = {};        
 }

También hay que modificar la función que añade una clave – valor para que almacene el momento (new Date()) en que se guarda esa clave.

add(key, value){
    this.lifetime[key] = new Date();
    super.add(key, value);
}

Por último la función que verifica si esta en la cache para que responda ‘false’ en el caso de que este pero haya caducado:

 isInCache(key){
   if(key in this.lifetime) {
      if((new Date() - this.lifetime[key]) < this.timeOfLife){ //¿ha caducado?
         return true;
      }
   } else {
      return false;
   }
}

El resultado final es el siguiente:

class MemoTime extends Memo {
    constructor (func, timeOfLife, keyFunc) {
        super(func, keyFunc);
        this.timeOfLife = timeOfLife || 1000;
        this.lifetime = {};        
    }

    isInCache(key){
        if(key in this.lifetime) {
            if((new Date() - this.lifetime[key]) < this.timeOfLife){
                return true;
            }
        } else {
            return false;
        }
    }

    add(key, value){
        this.lifetime[key] = new Date();
        super.add(key, value);
    }
}

Puede parecer contradictorio tener un mecanismo para almacenar los resultados de una función para no tener que volver a llamarla y que caduquen. Esto es útil cuando el resultado de la función cambia con el tiempo o queremos evitar que a una función se le llame muchas veces seguidas.

Por ejemplo si tenemos un servidor que nos devuelve un dato y no queremos saturarlo de peticiones podemos limitar el número de las mismas usando está técnica. Así cada vez que se llame a la función se tendrá una respuesta pero como mucho se le llamara una vez por cada periodo indicado en el tiempo de vida.

Por último vamos a ver un ejemplo de la clase que hemos creado:

function sum(a,b){  
    console.log("calculating "+a+" + "+b);  
    return a+b;  
}  

let memoTime = new MemoTime(sum, 2000);

console.log(memoTime.call(4,5)); //call sum

console.log(memoTime.call(4,5)); //no call sum
setTimeout(memoTime.call(4,5),1000); //no call sum
setTimeout(memoTime.call(4,5),3000); //call sum

Memoization y recursividad

Ya hemos visto como usar la memoization para mejorar el rendimiento del código memorizando los resultados de las llamadas a funciones. sin embargo este sistema tiene un punto débil. Las funciones recursivas. Veamos un ejemplo con la función factorial:

function factorial(n){
    console.log("factorial "+n);
    if(n < 2){
        return 1;
    } else {
        return n*factorial(n-1);
    }
}

Ahora crearemos un objeto Memo donde almacenar las llamadas y los resultados:

let memof = new Memo(factorial);
memof.call(4);
factorial 4
factorial 3
factorial 2
factorial 1
24

memof.call(4);
24

memof.call(5);
factorial 5
factorial 4
factorial 3
factorial 2
factorial 1
120

Se puede ver que tras calcular el valor de factorial(4), cuando se llama a factorial(5) este vuelve a llamar a factorial con valores que factorial(4) ya ha llamado y que no han sido memorizadas. Esto se debe a que dentro de la función factorial se llama a factorial(n-1) en lugar de a memof.call(n-1) por lo que esa llamada no pasa por el proceso de memorización y no se guardan esos resultados. Esto le quita mucha eficacia a la memoization. Sin embargo hay un truco que no es muy elegante pero que puede ayudarnos.

Cuando declaras una función en javascript es como si declararas una variable y le asignaras una función:

function factorial(n){
...
}

Es lo mismo que:

let factorial = function(n){
...
}

Que pasaría si le asignáramos a la variable factorial otra función distinta, pues que el código que llama a factorial llamaría ahora a esa nueva función. Y si esa función llama a memof.call() las llamadas recursivas se memorizarían:

factorial = (n) => memof.call(n);
factorial(4);
factorial 4
factorial 3
factorial 2
factorial 1
24

factorial(4);
24

factorial(5);
factorial 5
120

Pero si la función factorial ya no apunta al código de factorial ¿Como es que sigue funcionando y calculando el valor del factorial?. La respuesta es que la función original sigue referenciada:

class Memo {

    constructor (func, keyFunc) {
        this.func = func;
        this.cache = {}; 
        this.calculateKey = keyFunc || this.calculateKey;
    }
....
}

El código anterior sigue siendo accesible desde la variable func de la clase memo.

Podéis encontrar todo el código y ejemplos en la librería memo.js

Memoization y persistencia

Ya hemos visto como crear nuestra propia librería de memoization. Pero sus beneficios duran solo mientras la aplicación está en memoria. Si por ejemplo recargamos la web donde la usamos perdemos todos los resultados memorizados. Para evitar eso podríamos exportar el mapa de clave valor que guarda los parámetros y su valor. Una vez exportado podemos persistirlo como queramos. En el servidor, en el almacenamiento local del navegador donde se quiera.

Todo lo que se comenta en este texto se puede encontrar en la librería memo.js

Para exportado vamos a convertirlo en una cadena de texto que contenga los datos en formato JSON. En nuestro caso los datos están guardados dentro de la variable cache, la cual convertiremos a cadena usando JSON.stringify()

    getCache(){
        return JSON.stringify(this.cache);
    }

Una vez tenemos la cadena de texto para poder persistirla es necesario poder hacer el paso contrario. Recuperar los datos de la cache partiendo de la cadena para ellos emplearemos JSON.parse()

    loadCache(cache){
        this.cache = JSON.parse(cache);
    }

Vamos a ver un ejemplo sacado de la librería memo.js de como usar estas dos funciones para «clonar» un objeto que guarda la memoization de la función sum:

<html>

<script src="memo.js"> </script>

<body>
Abre la consola!!!!
</body>

<script>
function sum(a,b){
    console.log("calculando "+a+" + "+b);
    return a+b;
}

let memo = new Memo(sum);

console.log(memo.call(1,2)); //invoca sum
console.log(memo.call(1,2)); //no invoca sum
console.log(memo.call(2,3)); //invoca sum
console.log(memo.call(2,1)); //invoca sum

let storeMemoCache = memo.getCache(); //exporta la cache a String

//si se desea persisitir la memoization basta con persistir storeMemoCache

let memo2 = new Memo(sum);
memo2.loadCache(storeMemoCache); //carga la cache
console.log(memo2.call(1,2)); //no invoca sum

</script>

</html>

Uso de memoization para mejorar el rendimiento

Memoization, o memoización en español, es una técnica que permite ahorrar tiempo y recursos en el caso de que tengamos funciones cuyo tiempo de ejecución sea muy largo y se le llame varias veces con los mismos parámetros.

La idea es muy sencilla. En cada llamada a la función realizamos los siguientes pasos:

  1. Con los parámetros calculamos una clave única
  2. Comprobamos si ya tenemos un resultado asociado a esa clave
  3. Si lo tenemos devolvemos ese valor.
  4. Si no lo tenemos llamamos a la función
  5. Almacenamos el resultado de la función asociados a la clave calculada

La idea es calcular una clave única a partir de los parámetros que se pasan a la función y almacenar el resultado de la primera vez que se le llama para luego las las siguientes veces poder devolverlo sin calcularlo recuperándolo de la memoria.

Hay que ser consiente de que esta técnica permite una ventaja: reducir el tiempo de ejecución y pero con dos penalizaciones: la primera es que la mejora solo se consigue a partir de la segunda vez que se llama a la función, la primera vez se incrementa el coste puesto que se añade el tiempo de cálculo de la clave y de almacenar el resultado en memoria. La segunda penalización es que requiere espacio en memoria para guardar los resultados.

Esto solo funciona si la función cumple ciertas condiciones:

  • El resultado de la función depende única y exclusivamente de los parámetros que se le pasan.
  • Siempre devuelve el mismo resultado para los mismos parámetros.
  • La única funcionalidad que realiza la función es calcular el resultado.
  • Se llama varias veces a la función con los mismo parámetros.
  • El coste de calculo del resultado es mayor que el coste de calcular la clave y buscar el resultado almacenado en memoria.
  • Se intenta optimizar el tiempo de ejecución sobre el uso de memoria.

Ejemplo de implementación

Vamos a ver como implementar una clase JS para aplicar memoization.El código completo se puede encontrar en su repositorio de github.

La memoization tiene dos elementos principales, la función sobre la que se va a aplicar y como se calcula clave a partir de los argumentos que se pasan a esta función. En el constructor de la función vamos requerir estos dos argumentos, si bien el segundo tiene un valor por defecto. También es necesario inicializar la cache donde se guardaran los resultados.

    constructor (func, keyFunc) {
        this.func = func;
        this.cache = {}; 
        this.calculateKey = keyFunc || this.calculateKey;
    }

Vamos a ver un ejemplo de función que calcula la clave para almacenar el resultado. En la versión por defecto lo que se hace es generar un array con todos los parámetros y convertirlos a un cadena de texto JSON.

    calculateKey(){
        let args = Array.from(arguments);
        return JSON.stringify(args);
    }

Tenemos, la función y la forma de calcular la clave. Nos queda como gestionamos la cache. Los pasos son:

  1. Calcular la clave
  2. Recuperar el valor asociado a esa clave
  3. Si el valor existe se devuelve
  4. Si no existe se calcula
    call(){ 
        let key = this.calculateKey(...arguments);
        if(this.isInCache(key)){
            return this.cache[key];
        } else {
            let result = this.func(...arguments);
            this.add(key, result);
            return result;           
        }
    }

El código completo:

class Memo {
    constructor (func, keyFunc) {
        this.func = func;
        this.cache = {}; 
        this.calculateKey = keyFunc || this.calculateKey;
    }
    call(){ 
        let key = this.calculateKey(...arguments);
        if(this.isInCache(key)){
            return this.cache[key];
        } else {
            let result = this.func(...arguments);
            this.add(key, result);
            return result;           
        }
    }
    calculateKey(){
        let args = Array.from(arguments);
        return JSON.stringify(args);
    }
    isInCache(key){
        return key in this.cache;
    }
    add(key, value){
        this.cache[key] = value;
    }
}

Estrategias para generar la clave de la cache

Hay casos en que varias configuraciones distintas de parámetros son equivalentes. Cuando estos casos se conocen a priori, sin necesidad de calcular el resultado, una mejora puede ser cambiar la función que genera la clave para que tenga en cuenta esta particularidad. Principalmente hay dos estrategias para hacerlo: que la clave que se genera sea la misma para todos estos casos o generar todas las claves que se sabe que tienen el mismo resultado y añadirlas a la cache. Veamos algunos ejemplos.

Propiedad conmutativa: Hay casos en que puede intercambiar el valor de algunos parámetros sin que afecte al resultado. En estos casos una buena optimización es una vez calculado uno de estos casos se aplique a todos los que son equivalentes.

Por ejemplo si la función sum(a,b) realiza la suma de a y b. El resultado será el mismo aunque intercambien valores. sum(2,5) = sum(5,2). Podríamos generar ambas claves «[2,5]» y «[5,2]» y asignarles el mismo resultado.

Otra forma de hacerlo seria ordenar los parámetros de menor a mayor así sum(2,5) y sum(5,2) generarían la misma clave: «[2,5]».

Aproximaciones de números con decimales: En muchos casos la diferencia entre dos números decimales es tan pequeña que prácticamente no va a afectar al resultado. Para evitar calcular un resultado cuando no es necesario se puede calcular la clave limitando el numero de decimales.

Por ejemplo si fijamos que número máximo de decimales sean 2, tanto sum(3.141592 , 2) como sum(3.1416 , 2) generarían la misma clave «[3.14,2]»

Cadenas de texto: Otro de los casos habituales es que haya cadenas de texto que son equivalentes. Un caso habitual son las mayúsculas y minúsculas, en muchos casos no influye en el resultado. En un caso así search(«juan») y search(«Juan») deberían de generar las dos la misma clave: «[juan]»

Datos estructurados: Un caso especial es el de los argumentos que son alguna estructura de datos y que pueden tener muchas formas equivalentes. Por ejemplo, si uno de los parámetros que recibe es un JSON con dos valores «a» y «b» estas dos formas serian la misma: {«a»:1,»b»:2} y {«b»:2,»a»:1}. Llevando el ejemplo más lejos, si la funciona solo va mirar los campos «a» y «b» el caso {«a»:1,»b»:2,»c»:3} seria idéntico. Otro caso son los arrays cuando el orden de los elementos dentro de él da igual. Estos casos son mucho más difíciles de tratar ya que no se puede poner una regla que siempre sea válida y habrá que buscar soluciones propias para cada caso.

Como ejemplo podemos ver un par de ejemplos de como calcular la clave para la cache.

El primero ordena los argumentos que se le pasan;

function calculateKeySorted(){
    let args = Array.from(arguments).sort();    
    return JSON.stringify(args);
}

El segundo convierte los argumentos a minúsculas:

function calculateKeyToLowerCase(){
    let args = Array.from(arguments);    
    return JSON.stringify(args).toLowerCase();
}

Comparar pantallas para encontrar errores visuales durante los test del software

Uno de los problemas de los test automáticos para probar aplicaciones es que no se guían por el aspecto visual de la aplicación. Puedes tener un botón torcido, un texto que no se lee o un color que no corresponde y los test serán correctos, sin embargo para el usuario es importante que el botón que tiene que pulsar sea visible o que pueda leer ese texto ilegible.

Es una tarea difícil de automatizar. Programar código para comprobar la correcta disposición de todos los elementos es algo muy costoso. Y tener una IA que detecte posible elementos erróneos puede sonar muy interesante pero es aun más costoso y complicado. Así que vamos a optar por una solución más sencilla, comparar una imagen que sabemos que esta bien con una captura de pantalla de la aplicación durante las pruebas. Luego calcularemos las diferencias entre ambas imágenes.

Hay múltiples librerías para realizar la comparación de la imagen como pixelmatch o Resemble.js. Aunque estas librerías tienen bastante funciones el algoritmo básico de comparación es muy sencillo. Se comparan uno a uno los pixel de la imagen de referencia con los de la imagen capturada, se fija un valor de umbral. Si la diferencia entre ambos pixel es mayor que el valor umbral se considera que ese pixel es distinto y se marca.

Para que funcione bien la imagen que se usa como modelo ha de ser idéntica a la que se obtiene de la captura de pantalla. Podéis estar pensando que siempre se puede reescalar una de la dos imágenes pero generalmente eso mete «ruido» en los bordes de los elementos de la imagen que el algoritmo de comparación detecta como diferencias.

Los pasos a segir son los siguientes:

  1. Captura de pantalla
  2. Cargar imagen modelo correspondiente
  3. Crear una tercera imagen comparando los pixeles de las dos anteriores. Cada pixel cuya diferencia supere cierto valor fijado se marcara como «diferente», por ejemplo poniéndolo en rojo.
  4. Buscar si existe alguna diferencia en la imagen resultado de la comparación
  5. Si existe alguna diferencia se deja la imagen con el resultado de la comparación y se avisa al usuario para que la revise.

El principal problemas son los datos que cambian cada vez que se realiza el test. Por ejemplo: ids de elementos, fechas, horas, elementos generados en parte o completamente por procesos (pseudo)aleatorios. Estos elementos dejan «una mancha» en el resultado de la comparación. Una solución para evitar estas manchas es «taparlas» para ello vamos a usar plantillas con una zona de un color especial que cuando el algoritmo las lee ignora.

Otra técnica para ignorar «pequeñas cantidades de error» es dividir la imagen en cuadrados, por ejemplo de 16×16. En cada cuadrado hay un total de 256 pixeles, solo lanzaremos una advertencia si la cantidad de pixeles marcados como diferentes supera cierto número. Por ejemplo el 20%, o lo que es lo mismo 51 pixeles. Con esto logramos que solo diferencias «notables» se consideren.

Este es un sistema realmente simple para ayudar a resolver el problema de comprobar el aspecto visual de la aplicación o la web durante los test. Si bien no resuelve todo el problema y sigue siendo necesario un humano para verificar las imágenes marcadas como errores. Agiliza mucho el proceso de comprobar el correcto aspecto visual de la aplicación o web.