Sistema de Base de Datos Multimodal con Indexación Avanzada
Este backend simula un sistema de base de datos que interpreta consultas SQL básicas (CREATE TABLE, INSERT, SELECT, CREATE INDEX) y las ejecuta sobre archivos binarios. Usa una estructura de almacenamiento personalizada junto con algoritmos de índices como B+ Tree, con soporte planificado para AVL y secuencial.
Primero es necesario extraer los descriptores (o cargar los archivos si es que ya fueron generados). Para eso es necesario correr los notebooks en Image_descriptors, audio_descriptors y text_descriptors. Note que en el notebook de Image_descriptors es necesario cambiar la ruta del dataset de imagenes.
Luego correr backend con python backend/app.py.
-
CREATE TABLE:
- Guarda la estructura de la tabla en un archivo
.metadentro de/tablas/. - Crea un archivo
.tblbinario para almacenar los registros.
- Guarda la estructura de la tabla en un archivo
-
INSERT INTO:
- Inserta datos en el archivo binario correspondiente.
- Valida claves primarias si están definidas.
- Si existen índices para las columnas, los actualiza automáticamente.
-
CREATE INDEX:
- Soporte actual para índice
BPLUS, pronto paraAVLySEQUENTIAL. - Crea un archivo
.idxen/indices/con estructura binaria. - Permite usar estructuras de árbol para búsquedas rápidas.
- Soporte actual para índice
-
SELECT:
- Soporta:
SELECT * FROM tablaSELECT columna1, columna2 FROM tablaSELECT ... WHERE columna = valorSELECT ... WHERE columna BETWEEN valor1 AND valor2
- Si hay índice creado en la columna usada en
WHERE, se utiliza automáticamente para optimizar el acceso (usasearchorangeSearch). - Si no hay índice, la búsqueda es secuencial.
- Soporta:
| Carpeta | Contenido |
|---|---|
tablas/ |
.meta y .tbl por cada tabla |
indices/ |
Archivos .idx por índice creado |
algoritmos/ |
Implementaciones de índices |
- Header del archivo
.idx: contiene info sobre posición root, libres, eliminados. - Registro binario
.tbl: estructurado construct.packsegún tipos definidos en.meta.
Este frontend es una interfaz web simple para interactuar con el backend simulando una consola SQL. Permite enviar consultas manuales y visualizar resultados en tiempo real.
Entrar al directorio frontend y correr los siguientes comandos:
npm installnpm startIgnorar los warnings. Luego es solo entrar al enlace de localhost.
-
Textarea para consultas SQL:
- Envía la consulta como JSON al backend (
POST /query) - Compatible con todos los comandos mencionados
- Envía la consulta como JSON al backend (
-
Autocompletado básico:
- Sugiere palabras clave SQL (
SELECT,WHERE, etc.) - También sugiere nombres de tablas y columnas conocidas
- Inserta automáticamente la palabra seleccionada
- Sugiere palabras clave SQL (
-
Visualización de resultados:
- Si el resultado es un
SELECT, se renderiza una tabla HTML con los datos. - El resultado crudo (JSON) también se muestra debajo como referencia técnica.
- Si el resultado es un
-
Botón adicional "Ver registros":
- Llama directamente al endpoint
GET /select/{tabla}para obtener todos los registros (búsqueda secuencial).
- Llama directamente al endpoint
- Interfaz responsiva y fácil de extender
- Backend y frontend conectados por defecto en
http://localhost:3000↔http://localhost:8000
Utilizamos el dataset cities que tiene 148061 registros con los siguientes atributos:
id: id de la ciudadname: nombre de la ciudadstate_id: id del estadostate_code: código del estadostate_name: nombre del estadocountry_id: id del paíscountry_code: código del paíscountry_name: nombre del paíslatitude: coordenada latitudlongitude: coordenada longitudwikiDataId: id de la ciudad registrado en wikidata.org
Extendible Hashing que mantiene el índice en RAM y los buckets en disco, permitiendo inserciones, búsquedas, splits y overflows de forma eficiente.
Mientras estamos trabajando sobre el archivo del índice lo almacenamos en RAM para poder hacer operaciones de forma más eficiente. Cada cambio hecho al
índice en RAM también se hace al archivo del índice, así podemos tenerlo en disco y abrirlo nuevamente después. Por ejemplo el índice para 1 millón de
datos con 32768 entradas pesa menos de 1MB.
Se tiene un factor de balanceo fb que indica la cantidad de registros por bucket y una profundidad global D para poder manejar los splits.
Los buckets se construyen sobre el mismo archivo data.bin. Para cada bucket tenemos un header que almacena lo siguiente:
- El número del bucket en decimal
bucket_id - La profundidad local
d - El siguiente bucket de overflow
next - La cantidad de registros no vacíos en el bucket
size - fb registros que se inicializan como registros en blanco
Reservamos fb registros para cada bucket. Simplemente esos registros son el constructor por defecto Registro(), representando un registro vacío. Cada vez que se inserta sobreescribimos esos registros y actualizamos el size del bucket, la cantidad de registros no vacíos.
Se implementó la función get_reg_attributes() que la búsqueda de un elemento en los buckets principales y overflow. Retorna el registro encontrado en
la búsqueda (si existe), la posición del registro y el número del bucket. Así podemos simplemente reutilizar esta función en search() y remove(). El
registro encontrado lo usamos en la búsqueda y los otros dos valores del retorno usamos en el método de borrado para poder acceder directamente al registro
y bucket asociado. Así evitamos repetir código.
La estratégia de borrado que utilizamos es la siguiente:
- Simplemente reemplazamos el registro que queremos borrar por un "registro vacío" (constructor por defecto de
Registro()). - Contamos la cantidad de buckets vacíos (sin registros reales).
- Si la cantidad de buckets vacíos sobrepasa a los 40% hacemos una reconstrucción total del índice y de los buckets.
Aplicamos hash al id numerico de los registros. Simplemente se inserta en el primer espacio vacío que se encuentra del bucket correspondiente a ese hash. Así reutilizamos los espacios que fueron borrados en remove. Si la profundidad local del bucket es menor a la profundidad global hacemos split en caso el bucket esté lleno. En caso ya no se pueda hacer split creamos buckets de overflow y los encadenamos.
Para la experimentación variamos los parámetros fb y D para poder tener una distribución de registros y un índice balanceado, ya que las pruebas varían
en la cantidad de datos. Así logramos tener tiempos eficientes para inserción, búsqueda y borrado. Utilizamos los siguientes valores:
| Registros | fb | D |
|---|---|---|
| 1k | 4 | 8 |
| 10k | 6 | 10 |
| 100k | 6 | 10 |
| 250k | 10 | 12 |
| 500k | 16 | 14 |
| 1M | 18 | 15 |
Con estos parámetros obtuvimos la siguiente cantidad de buckets y entradas en el índice:

Hicimos la inserción de acuerdo a los valores de fb y D en la tabla de arriba:
Hicimos la búsqueda de 100 keys aleatorios del dataset y sacamos el promedio:
Hicimos el borrado de 100 keys aleatorios del dataset y sacamos el promedio:
La implementación del sistema de acceso secuencial indexado (ISAM) se diseñó con el objetivo de simular un sistema de archivos jerárquico eficiente, con soporte para inserción, búsqueda y eliminación. A continuación se detallan las estrategias clave empleadas:
Se utilizó una estructura jerárquica de dos niveles de indexación:
- Root Index (
root_index.bin): Apunta a páginas del índice intermedio. - Index Pages (
index_pages.bin): Contienen referencias a páginas de datos. - Data Pages (
data_pages.bin): Almacenan los registros ordenados de forma secuencial.
Esto permite búsquedas logarítmicas y navegación eficiente por los niveles del índice.
Cada nivel de la estructura se almacena en un archivo binario independiente:
root_index.binindex_pages.bindata_pages.binoverflow.bin(para las inserciones que no se pudieron hacer en el área principal)
Esta segmentación facilita el manejo modular del almacenamiento y el acceso por niveles.
Se utilizó un factor de bloqueo fijo:
BLOCK_FACTOR_DATA = 5: Número máximo de registros por página de datos.BLOCK_FACTOR_INDEX = 4: Número máximo de entradas por página de índice.
Esto asegura una estructura uniforme de páginas, facilita el cálculo de offsets y permite navegación por saltos fijos.
Todos los registros (Registro) e índices (IndexEntry) se serializan usando el módulo struct, con un formato fijo (FORMAT) que asegura un tamaño constante en disco. Esto permite:
- Cálculo directo de posiciones (offsets) sin recorrer todo el archivo.
- Acceso eficiente a cualquier página o registro.
Las inserciones no se hacen en las páginas de datos (por la naturaleza estática del ISAM), sino que se redirigen a un archivo de desborde. Esto mantiene el área principal inalterada y permite realizar operaciones de inserción sin reorganizar el índice.
Para cada búsqueda:
- Se localiza la página adecuada en
root_index.bin. - Luego se baja a la página de índice correspondiente.
- Finalmente se accede a la página de datos que contiene o debería contener el registro.
- Si no se encuentra, se busca en el
overflow.bin.
Esta estrategia imita el comportamiento real del ISAM y permite realizar búsquedas eficientes con costo O(log n) en el mejor caso.
Para mejorar la eficiencia y evitar acceso por registro, se leen y escriben bloques completos (páginas) en lugar de registros individuales.
La implementación del Sequential File se diseñó para que trabaje en un solo archivo (sin metadata) con cabezera, usando las técnicas del espacio auxiliar.
El archivo esta dividido en dos partes: la página principal y el espacio auxiliar. En este último es donde se irán insertando los registros hasta llegar a un threshold (
Una vez alcanzado, reconstruiremos la página principal, donde se reorganizarán todos los registros (incluyendo los del espacio auxiliar) ordenados por una key predeterminada por el usuario.
El propósito de este diseño es para resolver el problema de desborde de espacio, así como ahorrarse las complejidades de mantener ordenados todos los registros constantemente.
En nuestra implementación, la inserción ocurre dentro del espacio auxiliar, donde se apilan los registros. Con cada inserción actualizamos el conteo de registros en la cabezera. Si dicha inserción activa una alerta de desborde, entonces se reconstruye el archivo, limpiando el espacio auxiliar.
Descontando el coste de reconstrucción, las inserciones cuenta con complejidad
El diseño de la implementación le otorga al sequential file la capacidad de búsquedas binarias bajo una key. Este tipo de búsquedas se caracterizan por ser el tipo de búsqueda más eficiente, puesto que cuenta con complejidad de apenas
En nuestra implementación, partimos de una búsqueda binaria a la key, lo que devuelvue la primera aparición del registro con la key objetivo. En caso existan otros registros con la misma key, se iteran sobre los registros siguientes hasta que encuentre un key diferente
Para las búsquedas por rango, se aplicaron dos búsquedas binarias para ubicar las posiciones del elemento iniclal y final del rango. Una vez encontrado, solo se recorría dicho rango se forma linear.
Este diseño nos da una complejidad
Para el borrado, solo basta usar la misma lógica del search(), solo que en vez de almacenar los matches, activamos la flag deleted del registro. El espacio liberado por estos será utilizado en el próximo rebuild().
Para la fase de experimentación de este índice, se utilizó un slice del dataset cities de distintos tamaños id de los registros. Se analizaron los tiempos de ejecución de cada uno de los métodos para cada tamaño del dataset, con el fin de analizar su rendimiento.
Se aplicó un search() a 100 elementos aleatorios, promediando el tiempo final.
Se aplicó un remove() a 100 elementos aleatorios, promediando el tiempo final.
Para la implementación del índice RTree, se diseñó para desacoplarlo del archivo principal, siendo este índice almacenado en múltiples archivos metadata, y se utilizó principalmente la librería rtree de Python.
El archivo principal solo se encargaría de apilar los registros, siendo una interfaz para el índice RTree. Para manejar los borrados, cuenta con una free list almacenada en otro archivo aparte.
Esta librería aportó con la implementación de la estructura de datos, así como sus métodos para las inserciones, queries y borrados. Cuenta con métodos para almacenarse en memoria secundaria a través de archivos .rtree.dat y .rtree.idx. Sin embargo, esta librería contaba con limitaciones que requerían el uso de un archivo metadata personalizado para este proyecto:
- No maneja puntos, por lo que cada registro fue insertado como un rectángulo de area 0.
- La llave de cada una de las hojas requerían un orden incremental, por lo que se utilizó la posición de cada registro en el archivo principal en lugar de su id.
- No cuenta con métodos para búsquedas por radio.
Se creó un archivo Metadata que almacenaba los campos necesarios de cada registro para los métodos del RTree (como posición, longitud, latitud). Principalmente usado por la búsqueda radial y el borrado.
Gracias a la librería, la implementación de los métodos del RTree consistió en convertir los hiperparámetros en los adecuados para ejecutar el correspondiente del rtree. Solo dos métodos necesitaron de lógica extra para su correcto funcionamiento:
-
radius_search((x, y), r): se realizó una query sobre el rectángulo definido por los puntos$(x-r, y-r)$ y$(x+r, y+r)$ . Una vez obtenidos los registros extraídos, se filtraron aquellos que excedían su distancia hacia el centro$(x, y)$ calculado con la distancia euclidiana. -
erase(pos): usando la metadata, se obtenían las coordenadas lon y lat del registro, puesto quertreeno elimina elementos definidos por la key, sino que tambíen necesita de sus coordenadas.
Tanto la inserción como la búsqueda por rectángulo y la búsqueda KNN fueron interfaces de los métodos insert(rectangle), intersects(rectangle) y nearest(rectangle, k) del rtree, siendo los puntos pasados como rectángulos de área 0.
Para la fase de experimentación de este índice, se utilizó un slice del dataset cities de distintos tamaños
Se aplicó un remove() a 100 elementos aleatorios, promediando el tiempo final.
Con el objetivo de mejorar la eficiencia en las búsquedas sobre archivos de datos, se implementó un índice basado en un árbol B+ no agrupado (unclustered). Esta estructura permite mantener las claves ordenadas y enlazadas en nodos hoja, mientras que los datos reales se almacenan en un archivo separado. Las hojas contienen punteros a la posición física del registro en el archivo de datos.
Además, cada nodo hoja mantiene un campo next que enlaza con la siguiente hoja, lo que permite recorridos eficientes y búsquedas por rango. El árbol maneja claves de tipo int o string (hasta 30 caracteres), según el parámetro de configuración.
La estructura está respaldada por un archivo binario que almacena los nodos, con una cabecera que contiene:
Posición del nodo raíz
Posición libre (para insertar nuevos nodos)
Posición del nodo eliminado (para reutilización de espacio)
La inserción sigue el recorrido típico del árbol B+, localizando la hoja adecuada para insertar la nueva clave. Si la hoja tiene espacio, se inserta ordenadamente. En caso de desbordamiento, se realiza una división (split) del nodo y se propaga hacia arriba si es necesario, manteniendo la altura mínima del árbol.
Cada clave insertada en una hoja se enlaza con una posición exacta en el archivo de datos, actuando como índice secundario.
La complejidad de inserción es
Para realizar búsquedas puntuales, el índice recorre desde la raíz hasta una hoja aplicando comparación binaria en cada nodo para determinar el hijo correspondiente. Una vez en la hoja, se verifica si la clave existe y se retorna la posición del registro en el archivo de datos.
Este diseño garantiza una complejidad de búsqueda
Gracias a que las hojas están enlazadas mediante el campo next, la búsqueda por rango es eficiente. Primero, se localiza la primera clave del rango mediante búsqueda estándar. Luego, se recorren las hojas sucesivas mientras las claves estén dentro del rango.
Este diseño ofrece complejidad
Actualmente, el índice no implementa una operación de borrado. Sin embargo, se ha considerado la gestión del espacio libre mediante un campo de posición eliminada en la cabecera, pensado para futuras extensiones que permitan reutilizar nodos liberados.
Para evaluar el rendimiento de nuestro índice B+, se realizaron pruebas de inserción sobre un subconjunto del dataset cities, usando distintos valores del orden del árbol m (5, 10 , 25 y 100). Cada configuración fue evaluada registrando el tiempo total de inserción y el tamaño final del archivo generado.
Los resultados fueron los siguientes:
m = 5: Tiempo de inserción = 36 segundos, Tamaño del archivo = 3.9 MB
m = 10: Tiempo de inserción = 33 segundos, Tamaño del archivo = 3.3 MB
m = 25: Tiempo de inserción = 31 segundos, Tamaño del archivo = 2.8 MB
m = 100: Tiempo de inserción = 28 segundos, Tamaño del archivo = 2.6 MB
Como se puede observar, aumentar el valor de m mejora tanto el tiempo de inserción como el uso de espacio, debido a que se reducen las divisiones de nodos y se mejora la compactación del árbol. Esto confirma que una mayor capacidad de fan-out en los nodos del B+ Tree puede resultar beneficiosa para datasets de tamaño considerable.
Para cerrar la parte 1, se realizaron nuevos tests para comparar el rendimiento de los 5 índices implementados sobre las operaciones de inserción, queries y borrado. Cabe aclarar que dependendiendo de la implementación, ciertos índices no contaban con el soporte de ciertas operaciones. Además, el RTree solo soporta queries espaciales, por lo que su análisis irá separado al resto.
- El tamaño de datasets para esta comparación fueron 1K, 10K, 50K y 100K registros del dataset
cities.csv. - Extendible Hashing: se usó un factor de balanceo
fb = 6, profundidad máximaD = 10y un threshold de buckets vacíosEMPTY_THRESHOLD = 0.4 - B+ Tree: se usó un orden
m = 10
Para las búsquedas, se usaron 100 keys aleatorias, y se obtuvo el tiempo promedio de búsqueda.
Para el borrado, se usaron 100 keys aleatorias, y se obtuvo el tiempo promedio de borrado.
Como alternativa a las inserciones, se midió el tiempo de construcción del índice ISAM usando el método ISAM.build().
Implementamos la busqueda de textos por similitud
Utilizamos el dataset Legal Citation Text Classification que consiste en 24985 casos legales que cuentan con el resultado final, el titulo del caso y de que trataba.
Construimos un sistema de recuperación de información textual a partir de un dataset con columnas: case_id, case_outcome, case_title, y case_text. Los textos fueron preprocesados y luego indexados utilizando un enfoque optimizado del algoritmo SPIMI (Single-Pass In-Memory Indexing) para permitir escalabilidad y eficiencia en almacenamiento y consulta.
Se combinaron los campos textuales por fila y se aplicó la siguiente pipeline:
- Tokenización de palabras
- Eliminación de signos y símbolos no alfabéticos
- Filtrado de stopwords utilizando el corpus de NLTK
- Reducción mediante Stemming usando
PorterStemmer
El resultado fue una lista normalizada de tokens para cada documento, lista para ser indexada.
Se utilizó una versión híbrida de SPIMI adaptada para escritura a disco por bloques, con el fin de evitar saturar la memoria RAM incluso en grandes colecciones de datos.
- Partición por bloques: se procesaron documentos en bloques de 10,000 filas (
block_size=10000). - Postings list por término: para cada término en cada bloque, se almacenó la lista de
(doc_id, tf_weight).
Cada bloque se guardó como un archivo .pkl independiente con la estructura:
{ term1: [(doc_id, tf1), ...], term2: [...], ... }También se guardaron:
- La norma precomputada de cada documento en
norms.pkl. - La frecuencia de documentos (
df) por término endf_counter.pkl.
tal que obtenemos la siguiente estructura en disco:
index_blocks/
├── block_0.pkl
├── block_1.pkl
├── ...
├── norms.pkl
├── df_counter.pkl
La búsqueda se diseñó para:
- Evitar cargar el índice completo en RAM.
- Acceder solo a los bloques que contienen postings de los términos consultados.
Para cada término t de la consulta:
-
Se calcula el peso TF-IDF del término en la query.
-
Se itera sobre todos los bloques
block_i.pkl:-
Se carga el bloque.
-
Si el término
testá presente, se actualizan los scores de los documentos usando:score(q, d) = sum(w_tq * w_td for t in query_terms) / (norm_q * norm_d)
-
-
Se normaliza con la norma de la consulta y la norma del documento precalculada.
-
Se utiliza
heapqpara mantener solo el Top-k documentos más similares.
Se realizaron pruebas con diferentes tamaños de bloque (block_size) y queries variadas para observar el impacto en tiempo y uso de memoria.
Utilizamos un tamaño de bloque de 10000 y realizamos consultas para comparar los resultados con el modelo tsvector y gin de Pgadmin4
| Query | Rank | PostgreSQL (case_id) | PostgreSQL (Score) | SPIMI (case_id) | SPIMI (Score) |
|---|---|---|---|---|---|
| contract | 1 | Case22026 | 0.2569325 | Case22871 | 0.2982 |
| breach | 2 | Case16659 | 0.2491805 | Case24032 | 0.2837 |
| 3 | Case24791 | 0.2438541 | Case24032 | 0.2837 | |
| 4 | Case24786 | 0.2438541 | Case24027 | 0.2794 | |
| 5 | Case24806 | 0.2438541 | Case11880 | 0.2768 | |
| high | 1 | Case24280 | 0.0016323 | Case9 | 0.2427 |
| court | 2 | Case9 | 0.0016129 | Case24281 | 0.2001 |
| defamation | 3 | Case24283 | 0.0013625 | Case540 | 0.1961 |
| plaintiff | 4 | Case540 | 0.0010989 | Case541 | 0.1961 |
| 5 | Case541 | 0.0010989 | Case24295 | 0.1832 |
Implementamos la búsqueda de imagenes por similitud, con indexación de descriptores locales.
Utilizamos el dataset Fashion Products Small que consiste de 45k fotos de articulos de moda (ropa, accesorios, etc). Este dataset es la versión más ligera del dataset Fashion Product Images Dataset. Las imagenes son de menor resolución, para ahorrar espacio y obtener mayor rapidez en la extracción de features.
Utilizamos SIFT para la extracción de características, el cual extrae un vector de descriptores que tiene 128 dimensiones. Utilizamos PCA para reducir la dimensionalidad a 70 dimensiones, capturando el 95% de la varianza de los datos. Hicimos un análisis de la cantidad de componentes y las varianzas acumuladas:
Luego aplicamos KMeans para agrupar los descriptores en clusters. Intentamos utilizar los labels que contiene la columna articleType en el archivo styles.csv para poder realizar un análisis de precisión variando la cantidad de clusters para KMeans entre 10 y 500 clusters. Sin embargo este análisis resulto inefectivo ya que no consideró completamente la similitud visual de los items. Salieron mejores precisiones con 400+ clusters pero visualmente no era así, ya que obtuvimos más similitud visual con la query utilizando menos clusters. Es por ello que hicimos un análisis visual para elegir la cantidad de clusters:
El siguiente paso fue construir histogramas para luego aplicar TF-IDF como técnica de ponderación para cada visual word. En base a eso pudimos aplicar KNN secuencial y KNN con indexación invertida para obtener las Top-k imagenes más similares a una query en base a la similitud coseno.
Utilizamos heapq para las funciones de KNN para mayor eficiencia, de modo que siempre mantenemos los Top-k resultados más similares en el heap y hacemos pop al menos similar en cada iteración. Es por eso que no pusheamos valores negativos al heap.
Utilizando K = 8 y 5 ejecuciones para poder calcular el tiempo promedio. Tiempos en segundos.
| N | KNN Secuencial | KNN Indexado | KNN PostgreSQL |
|---|---|---|---|
| 1000 | 0.0446 | 0.0009 | 0.0018 |
| 2000 | 0.0981 | 0.0018 | 0.0022 |
| 4000 | 0.2029 | 0.004 | 0.0037 |
| 8000 | 0.3573 | 0.0099 | 0.0058 |
| 16000 | 0.9109 | 0.0186 | 0.084 |
| 32000 | 1.538 | 0.0418 | 0.0902 |
| 64000 | 2.9061 | 0.0875 | 0.1231 |
Implementación de búsqueda de canciones por similitud.
Utilizamos el dataset de Music Bench que contiene fragmentos de canciones y grabaciones de música de aproximadamente 10 segundos. Específicamente, usamos la versión ligera FMACaps que contiene 1000 archivos .wav.
Utilizamos MFCC (Mel-Frequency Cepstral Coefficients) para la extracción de descriptores de audio, que comprime frecuencias altas y da más resolución a las bajas, procesando el audio por ventanas de tiempo.
Las demás funciones utilizadas son prácticamente las mismas que utilizamos para image search. Solo que aqui ya no utilizamos PCA y también cambiamos el número de clusters para KMeans.
Utilizando K = 8 y 5 ejecuciones para poder calcular el tiempo de ejecución promedio. Tiempos en segundos.
| N | KNN Secuencial | KNN Indexado | KNN PostgreSQL |
|---|---|---|---|
| 1000 | 0.0356 | 0.0012 | 0.0023 |
| 2000 | 0.0631 | 0.002 | 0.0017 |
| 4000 | 0.1446 | 0.0037 | 0.0028 |
| 8000 | 0.275 | 0.007 | 0.0047 |
| 16000 | 0.49 | 0.0146 | 0.0068 |
| 32000 | 0.9849 | 0.03 | 0.1269 |
| 64000 | 2.0744 | 0.0767 | 0.1293 |





















