@sailboat-computer/data-storage
Version:
Shared data storage library for sailboat computer v3
361 lines (308 loc) • 10.4 kB
text/typescript
/**
* Time-based downsampling strategy implementation
*/
import { Downsampler, StoredData, TimeBasedStrategy } from '../../types';
/**
* Time-based downsampler implementation
*/
export class TimeBasedDownsampler implements Downsampler {
readonly type = 'time-based';
/**
* Downsample data using time-based strategy
*
* @param data - Data to downsample
* @param strategy - Time-based strategy
* @returns Downsampled data
*/
async downsample(data: StoredData[], strategy: TimeBasedStrategy): Promise<StoredData[]> {
if (data.length === 0) {
return [];
}
try {
// Sort data by timestamp
const sortedData = [...data].sort((a, b) => {
const aTime = new Date(a.metadata.timestamp).getTime();
const bTime = new Date(b.metadata.timestamp).getTime();
return aTime - bTime;
});
// Group data by age threshold
const now = new Date();
const dataByAgeThreshold: Record<number, StoredData[]> = {};
// Initialize age threshold groups
for (const level of strategy.levels) {
dataByAgeThreshold[level.ageThreshold] = [];
}
// Assign data to age threshold groups
for (const item of sortedData) {
const itemTime = new Date(item.metadata.timestamp).getTime();
const ageInDays = (now.getTime() - itemTime) / (24 * 60 * 60 * 1000);
// Find the appropriate age threshold
let assignedThreshold = -1;
for (const level of strategy.levels) {
if (ageInDays >= level.ageThreshold && level.ageThreshold > assignedThreshold) {
assignedThreshold = level.ageThreshold;
}
}
if (assignedThreshold >= 0) {
dataByAgeThreshold[assignedThreshold].push(item);
} else {
// Data is newer than any threshold, keep as is
dataByAgeThreshold[-1] = dataByAgeThreshold[-1] || [];
dataByAgeThreshold[-1].push(item);
}
}
// Process each age threshold group
const results: StoredData[] = [];
// Add data that doesn't match any threshold (newer than all thresholds)
if (dataByAgeThreshold[-1]) {
results.push(...dataByAgeThreshold[-1]);
}
// Process each threshold group
for (const level of strategy.levels) {
const thresholdData = dataByAgeThreshold[level.ageThreshold];
if (!thresholdData || thresholdData.length === 0) {
continue;
}
// Apply downsampling based on interval
const downsampledData = this.downsampleByInterval(thresholdData, level.interval, level.aggregations);
results.push(...downsampledData);
}
return results;
} catch (error) {
console.error('Failed to apply time-based downsampling:', error);
// Return original data on error
return data;
}
}
/**
* Downsample data by interval
*
* @param data - Data to downsample
* @param interval - Interval string (e.g., '1m', '5m', '1h')
* @param aggregations - Aggregation configurations
* @returns Downsampled data
*/
private downsampleByInterval(
data: StoredData[],
interval: string,
aggregations: Array<{function: string, field: string, outputField?: string, groupBy?: string[]}>
): StoredData[] {
// Parse interval
const intervalMs = this.parseInterval(interval);
if (intervalMs <= 0) {
return data;
}
// Group data by interval
const dataByInterval: Record<number, StoredData[]> = {};
for (const item of data) {
const timestamp = new Date(item.metadata.timestamp).getTime();
const intervalKey = Math.floor(timestamp / intervalMs) * intervalMs;
if (!dataByInterval[intervalKey]) {
dataByInterval[intervalKey] = [];
}
dataByInterval[intervalKey].push(item);
}
// Apply aggregations to each interval
const results: StoredData[] = [];
for (const [intervalKey, intervalData] of Object.entries(dataByInterval)) {
// Group by specified fields if any
const groupByFields = aggregations[0]?.groupBy || [];
if (groupByFields.length > 0) {
const groupedData = this.groupByFields(intervalData, groupByFields);
for (const groupData of Object.values(groupedData)) {
const aggregatedItem = this.applyAggregations(groupData, aggregations);
if (aggregatedItem) {
results.push(aggregatedItem);
}
}
} else {
// No grouping, apply aggregations to all interval data
const aggregatedItem = this.applyAggregations(intervalData, aggregations);
if (aggregatedItem) {
results.push(aggregatedItem);
}
}
}
return results;
}
/**
* Parse interval string to milliseconds
*
* @param interval - Interval string (e.g., '1m', '5m', '1h')
* @returns Interval in milliseconds
*/
private parseInterval(interval: string): number {
const match = interval.match(/^(\d+)([smhd])$/);
if (!match) {
console.warn(`Invalid interval format: ${interval}`);
return 0;
}
const value = parseInt(match[1]);
const unit = match[2];
switch (unit) {
case 's':
return value * 1000;
case 'm':
return value * 60 * 1000;
case 'h':
return value * 60 * 60 * 1000;
case 'd':
return value * 24 * 60 * 60 * 1000;
default:
return 0;
}
}
/**
* Group data by specified fields
*
* @param data - Data to group
* @param fields - Fields to group by
* @returns Grouped data
*/
private groupByFields(data: StoredData[], fields: string[]): Record<string, StoredData[]> {
const result: Record<string, StoredData[]> = {};
for (const item of data) {
const groupKey = fields.map(field => {
const value = item.data[field];
return `${field}:${value}`;
}).join('|');
if (!result[groupKey]) {
result[groupKey] = [];
}
result[groupKey].push(item);
}
return result;
}
/**
* Apply aggregations to data
*
* @param data - Data to aggregate
* @param aggregations - Aggregation configurations
* @returns Aggregated data
*/
private applyAggregations(
data: StoredData[],
aggregations: Array<{function: string, field: string, outputField?: string, groupBy?: string[]}>
): StoredData | null {
if (data.length === 0) {
return null;
}
// Use the first item as a template
const template = data[0];
const result: StoredData = {
data: { ...template.data },
metadata: { ...template.metadata }
};
// Apply each aggregation
for (const agg of aggregations) {
const field = agg.field;
const outputField = agg.outputField || field;
switch (agg.function) {
case 'avg':
result.data[outputField] = this.calculateAverage(data, field);
break;
case 'sum':
result.data[outputField] = this.calculateSum(data, field);
break;
case 'min':
result.data[outputField] = this.calculateMin(data, field);
break;
case 'max':
result.data[outputField] = this.calculateMax(data, field);
break;
case 'count':
result.data[outputField] = data.length;
break;
case 'first':
result.data[outputField] = data[0].data[field];
break;
case 'last':
result.data[outputField] = data[data.length - 1].data[field];
break;
default:
console.warn(`Unknown aggregation function: ${agg.function}`);
}
}
// Update metadata
result.metadata.timestamp = this.calculateAverageTimestamp(data);
// Add downsampling metadata
result.metadata.tags = {
...result.metadata.tags,
downsampled: 'true',
downsampledFrom: data.length.toString(),
downsamplingStrategy: 'time-based'
};
return result;
}
/**
* Calculate average of field values
*
* @param data - Data to calculate average from
* @param field - Field to calculate average of
* @returns Average value
*/
private calculateAverage(data: StoredData[], field: string): number {
const sum = this.calculateSum(data, field);
return sum / data.length;
}
/**
* Calculate sum of field values
*
* @param data - Data to calculate sum from
* @param field - Field to calculate sum of
* @returns Sum value
*/
private calculateSum(data: StoredData[], field: string): number {
return data.reduce((sum, item) => {
const value = item.data[field];
return sum + (typeof value === 'number' ? value : 0);
}, 0);
}
/**
* Calculate minimum of field values
*
* @param data - Data to calculate minimum from
* @param field - Field to calculate minimum of
* @returns Minimum value
*/
private calculateMin(data: StoredData[], field: string): number {
return data.reduce((min, item) => {
const value = item.data[field];
return typeof value === 'number' ? Math.min(min, value) : min;
}, Infinity);
}
/**
* Calculate maximum of field values
*
* @param data - Data to calculate maximum from
* @param field - Field to calculate maximum of
* @returns Maximum value
*/
private calculateMax(data: StoredData[], field: string): number {
return data.reduce((max, item) => {
const value = item.data[field];
return typeof value === 'number' ? Math.max(max, value) : max;
}, -Infinity);
}
/**
* Calculate average timestamp
*
* @param data - Data to calculate average timestamp from
* @returns Average timestamp as ISO string
*/
private calculateAverageTimestamp(data: StoredData[]): string {
const sum = data.reduce((sum, item) => {
return sum + new Date(item.metadata.timestamp).getTime();
}, 0);
const avg = sum / data.length;
return new Date(avg).toISOString();
}
}
/**
* Create a new time-based downsampler
*
* @returns Time-based downsampler
*/
export function createTimeBasedDownsampler(): Downsampler {
return new TimeBasedDownsampler();
}