UNPKG

@sailboat-computer/data-storage

Version:

Shared data storage library for sailboat computer v3

361 lines (308 loc) 10.4 kB
/** * 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(); }