Saltar a contenido

Series temporales

Los datos almacenados como series temporales se caracterizan por su secuencia cronológica y alta frecuencia de creación. Las series temporales se emplean principalmente para la predicción y análisis de tendencias en muchos servicios financieros y aplicaciones IoT.

Desde la versión 5.0, MongoDB permite crear un tipo de colección específica para este tipo de datos. Estas colecciones time series definen una estructura y emplean una serie de índices que reducen el espacio necesario y aceleran las consultas que se realizan sobre los datos.

Las colecciones de series temporales se crean de forma explícita, y no podemos convertir una colección ordinaria a serie temporal. Eso sí, podemos programar un script para migrar los datos de una colección normal a una serie temporal.

Creando una serie temporal

A la hora de crear la colección, necesitamos indicar los siguiente campos:

  • timeField: campo obligatorio que almacena el timestamp del dato. Debe ser un objeto Date().
  • metaField: campo opcional que indica dónde se almacenan los metadatos que describen la serie. Este campo puede contener un documento embebido o el dato único de valor, y normalmente identifica al sensor. Debe ser un valor que raramente cambie de valor y se puede utilizar como la segunda dimensión para filtrar los datos (considerando que la primera será el tiempo). Este campo metaField va a servir para particionar los datos, de manera que aquellos documentos que tengan el mismo metaField estarán en la misma partición.
  • granularity: campo opcional que ayuda a MongoDB a optimizar el almacenamiento al definir la frecuencia de los datos. Sus posibles valores son seconds (valor por defecto que se aplica si no indicamos la granularidad), minutes o hours y debe configurarse al valor más cercano entre dos lectura consecutivas de datos. Por ejemplo, si realizásemos una lectura del sensor cada cinco minutos, la granularidad debería ser minutes.
  • expireAfterSeconds: campo opcional, indicando la cantidad de segundos a partir de la creación del dato que MongoDB esperará para borrarlo automáticamente.

Por ejemplo, vamos a crear una colección llamada serietemporal indicando el campo donde estará el timestamp y el de los metadatos:

db.createCollection( "serietemporal", {
    timeseries: {
        timeField: "timestamp",
        metaField: "metadata"
    }
})

A continuación, ya podemos añadirle datos:

db.serietemporal.insertMany( [
   {
      "metadata": {
         "sensorId": 1234,
         "type": "temperatura"
      },
      "timestamp": ISODate("2023-11-07T00:00:00.000Z"),
      "temp": 16
   },
   {
      "metadata": {
         "sensorId": 1234,
         "type": "temperatura"
      },
      "timestamp": ISODate("2023-11-07T04:00:00.000Z"),
      "temp": 14
   },
   {
      "metadata": {
         "sensorId": 1234,
         "type": "temperatura"
      },
      "timestamp": ISODate("2023-11-07T08:00:00.000Z"),
      "temp": 13
   }
])

Y obtener los datos como en una colección normal:

db.serietemporal.findOne({ "timestamp": ISODate("2023-11-07T00:00:00.000Z") })

Obteniendo el documento:

{
  timestamp: 2023-11-07T00:00:00.000Z,
  metadata: {
    sensorId: 1234,
    type: 'temperatura'
  },
  temp: 16,
  _id: ObjectId("654a35e782c4b36cdaf13b6f")
}

Si visualizamos la colección desde MongoDB Compass o MongoAtlas veremos que aparece una pequeña leyenda informando que la colección es de tipo time-series:

Colección de tipo Time-Series

Modelos de ejemplo

A la hora de modelar los datos, algunos modelos de datos de ejemplo posibles podrían ser:

{
  "ts" : ISODate("2021-05-20T10:24:51.303Z"),
  "metadata" : {
      "sensorId": 123,
      "region": "Alicante"
  },
  "presionAire" : 99 ,
  "velocidadViento" : 22,
  "temperatura" : {
      "gradosF": 39,
      "gradosC": 3.8
  },
}
{
  _id: ObjectId("6166df318f32e5d3ed304fc7"),
  timestamp: ISODate("2023-10-13T00:00:00.000Z"),
  metadata: {
    nombre: 'AMZN',
    moneda: 'Dolar'
  },
  precio: 142.83
},
{
  _id: ObjectId("6166df318f32e5d3ed304fc5"),
  timestamp: ISODate("2023-10-13T00:00:00.000Z"),
  metadata: {
    nombre: 'AAPL',
    moneda: 'Dolar'
  },
  precio: 189.71
}, 

Consideraciones

Las colecciones de series temporales crean y utilizan un índice interno. Además, si fuera necesario, podemos crear índices adicionales, a los que se les conoce como índices secundarios.

Las colecciones de series temporales se asocian a casos de uso donde los datos se insertan una vez, se leen muchas veces y raramente se actualizan.

Es por ello, que las operaciones de modificación y borrado en las series temporales necesitan del operador {multi:true} y {justOne:false} para aplicarse sobre múltiples documentos, y la consulta a modificar o borrar sólo debe hacerlo sobre los campos del subdocumento del atributo metaField. Así pues, sólo podemos modificar el subdocumento del campo metaField y no podemos emplear una operación upsert.

Cuidado

El tamaño máximo de un documento en una serie temporal es de 4MB, no los 16 de un documento ordinario.

Respecto al particionado, podemos emplear timeField o metaField con claves. Si utilizamos el timeField, al tratarse de un timestamp, todas las escrituras irán a la última partición. Por lo tanto, es una buena práctica combinar el timeField con uno o más campos del metaField.

Migrando datos

En los datos de ejemplo cargados en MongoDB, tenemos una colección con datos climáticos. Por ejemplo, uno de los documentos de sample_weatherdata.data es similar a:

{
  "_id": {
    "$oid": "5553a998e4b02cf7151190b8"
  },
  "st": "x+47600-047900",
  "ts": {
    "$date": "1984-03-05T13:00:00.000Z"
  },
  "position": {
    "type": "Point",
    "coordinates": [
      -47.9,
      47.6
    ]
  },
  "elevation": 9999,
  "callLetters": "VCSZ",
  "qualityControlProcess": "V020",
  "dataSource": "4",
  "type": "FM-13",
  "airTemperature": {
    "value": -3.1,
    "quality": "1"
  },
  "dewPoint": {
    "value": 999.9,
    "quality": "9"
  },
  "pressure": {
    "value": 1015.3,
    "quality": "1"
  },
  "wind": {
    "direction": {
      "angle": 999,
      "quality": "9"
    },
    "type": "9",
    "speed": {
      "rate": 999.9,
      "quality": "9"
    }
  },
  "visibility": {
    "distance": {
      "value": 999999,
      "quality": "9"
    },
    "variability": {
      "value": "N",
      "quality": "9"
    }
  },
  "skyCondition": {
    "ceilingHeight": {
      "value": 99999,
      "quality": "9",
      "determination": "9"
    },
    "cavok": "N"
  },
  "sections": [
    "AG1"
  ],
  "precipitationEstimatedObservation": {
    "discrepancy": "2",
    "estimatedWaterDepth": 999
  }
}

Por ejemplo, si quisiéramos trabajar con los datos de la temperatura del aire y colocarlos dentro de una serie temporal, el primero paso sería crear la colección

use iabd
db.createCollection( "ts_weather", {
    timeseries: {
        timeField: "timestamp",
        metaField: "metadata"
    }
})

Y a continuación, mediante el framework de agregación, migramos los datos que nos interesen. Por ejemplo, nos vamos a traer la temperatura del aire y la presión, así como algunos metadatos del sensor para aquellas muestras que no han superado los 100 grados de temperatura (así podríamos filtrar algunos outliers):

A partir de la versión 7.0.3 podemos emplear el operador $out para exportar los datos a una colección de tipo time series:

use sample_weatherdata
db.data.aggregate([
  { $match: {
      "airTemperature.value": {$lt: 100}
    }
  },
  { $project: {
      "timestamp": "$ts",
      "metadata": {
        "posicion" : "$position",
        "elevacion" : "$elevation",
        "tipo": "$type"
      }, 
      "temp_aire": "$airTemperature.value",
      "presion": "$pressure.value"
    }
  },
  { $out: {
      db: "iabd",
      coll: "ts_weather",
      timeseries: {
        timeField: "timestamp",
        metaField: "metadata"
      }
    }
  }
])

Si no, primero crearemos una colección únicamente con los datos deseados:

use sample_weatherdata
db.data.aggregate([
  { $match: {
      "airTemperature.value": {$lt: 100}
    }
  },
  { $project: {
      "timestamp": "$ts",
      "metadata": {
        "posicion" : "$position",
        "elevacion" : "$elevation",
        "tipo": "$type"
      }, 
      "temp_aire": "$airTemperature.value",
      "presion": "$pressure.value"
    }
  },
  { $out: "ts_weather"}
])

Y a continuación, debemos exportar los datos y volver a importarlos. Así pues, exportamos mediante mongodump desde sample_weatherdata.ts_weather:

mongodump --uri="mongodb+srv://usuario:password@host/sample_weatherdata" \
  --collection=ts_weather

e importamos con mongorestore en iabd.ts_weather (que es la colección de tipo serie temporal que hemos creado en un paso anterior):

mongorestore --uri="mongodb+srv://usuario:password@host/iabd" \
  --collection=ts_weather --noIndexRestore --maintainInsertionOrder \
  dump/sample_weatherdata/ts_weather.bson

Si cambiamos a la base de datos de iabd, podemos observar cómo se ha rellenado la colección de la serie temporal:

> use iabd
< switched to db iabd
> db.ts_weather.findOne()
< {
    timestamp: 1984-03-05T12:00:00.000Z,
    metadata: {
      elevacion: 9999,
      posicion: {
        coordinates: [
          14,
          36.6
        ],
        type: 'Point'
      },
      tipo: 'FM-13'
    },
    _id: ObjectId("5553a998e4b02cf715119936"),
    temp_aire: 13,
    presion: 1008.5
  }

Agregando datos

Otros operadores de fecha

Además de $dateToParts, podemos emplear los operadores $hour, $week, $dayOfYear, $year, etc... sobre un campo de tipo fecha / timestamp.

Es muy normal agrupar los datos en rangos de fechas sobre los que realizaremos cálculos. Para ello, podemos utilizar el operador $dateToParts el cual recibe un campo date con un valor de tipo fecha/timestamp, y crea un documento con los diferentes campos por separado (año, mes, día, etc... ) mediante las propiedades year, month, day, hour, minute, second y millisecond. Además, si lo necesitamos, podemos configurar también su timezone.

Por ejemplo, sobre la colección recién importada, vamos a agrupar los datos por días y calcularemos la temperatura media del aire:

db.ts_weather.aggregate( [
   {
      $project: {
         date: {
            $dateToParts: { date: "$timestamp" }
         },
         temp_aire: 1
      }
   },
   {
      $group: {
         _id: {
            date: {
               year: "$date.year",
               month: "$date.month",
               day: "$date.day"
            }
         },
         avgTmp: { $avg: "$temp_aire" }
      }
   }
] )

Obteniendo como resultado documentos similares a:

{
  _id: {
    date: {
      year: 1984,
      month: 3,
      day: 10
    }
  },
  avgTmp: 68.94788047255038
}, 
{
  _id: {
    date: {
      year: 1984,
      month: 3,
      day: 12
    }
  },
  avgTmp: 65.79275964391691
}
...

Creando un gráfico

Si estamos trabajando con MongoAtlas, podemos hacer uso de Graphs y crear diferentes gráficos a modo de cuadro de mandos sobre la información almacenada en nuestras colecciones.

Más información en https://www.mongodb.com/docs/charts/

Referencias