signalk-parquet
Version:
Vessel data Parquet file archive with automated value and geospatial triggers. History API compliant with cloud backups and queries.
263 lines (237 loc) • 6.66 kB
text/typescript
import { ServerAPI, Path } from '@signalk/server-api';
/**
* Data type information for a SignalK path
*/
export interface PathTypeInfo {
dataType:
| 'numeric'
| 'angular'
| 'boolean'
| 'string'
| 'position'
| 'enum'
| 'unknown';
unit?: string;
enumValues?: string[];
description?: string;
rawMetadata?: Record<string, unknown>;
}
/**
* Numeric units that indicate a numeric data type
*/
const NUMERIC_UNITS = [
'm', // meters
'm/s', // meters per second
'knots', // nautical miles per hour
'V', // volts
'A', // amperes
'Hz', // hertz
'K', // kelvin
'Pa', // pascals
'kg', // kilograms
'J', // joules
'ratio', // dimensionless ratio
'deg', // degrees (non-angular context like temperature)
];
/**
* Angular units that require degree/radian conversion
*/
const ANGULAR_UNITS = [
'rad', // radians
];
/**
* Position-specific paths
*/
const POSITION_PATHS = [
'navigation.position',
'navigation.gnss.antennaPosition',
];
/**
* Detect the data type of a SignalK path based on metadata
* Based on schema-service.ts metadata lookup pattern
*/
export async function detectPathType(
path: string,
app?: ServerAPI
): Promise<PathTypeInfo> {
try {
// Special case: all paths ending with "position" are position types
if (path.endsWith('position') || POSITION_PATHS.includes(path)) {
return {
dataType: 'position',
description: 'Geographic position (latitude/longitude)',
};
}
// Try to get metadata from SignalK
let metadata: Record<string, unknown> | undefined;
if (app) {
// Use app's getMetadata method if available
metadata = app.getMetadata(path) as Record<string, unknown> | undefined;
}
if (!metadata) {
app?.debug(`Type detection: metadata not found for ${path}`);
return { dataType: 'unknown' };
}
// Type guard helpers
const getStringProperty = (
obj: Record<string, unknown>,
key: string
): string | undefined => {
const value = obj[key];
return typeof value === 'string' ? value : undefined;
};
const getArrayProperty = (
obj: Record<string, unknown>,
key: string
): string[] | undefined => {
const value = obj[key];
return Array.isArray(value) ? (value as string[]) : undefined;
};
// Check for units to determine numeric vs angular
const unit = getStringProperty(metadata, 'units');
if (unit) {
// Angular data (radians)
if (ANGULAR_UNITS.includes(unit)) {
return {
dataType: 'angular',
unit,
description: getStringProperty(metadata, 'description'),
rawMetadata: metadata,
};
}
// Numeric data
if (NUMERIC_UNITS.includes(unit)) {
return {
dataType: 'numeric',
unit,
description: getStringProperty(metadata, 'description'),
rawMetadata: metadata,
};
}
app?.debug(
`Type detection: unknown unit "${unit}" for ${path}, defaulting to numeric`
);
return {
dataType: 'numeric',
unit,
description: getStringProperty(metadata, 'description'),
rawMetadata: metadata,
};
}
// Check for enum values in metadata
const enumValues =
getArrayProperty(metadata, 'enum') ||
getArrayProperty(metadata, 'values');
if (enumValues && enumValues.length > 0) {
return {
dataType: 'enum',
enumValues,
description: getStringProperty(metadata, 'description'),
rawMetadata: metadata,
};
}
// Try to infer from description or type field
const type = getStringProperty(metadata, 'type');
const description = getStringProperty(metadata, 'description');
if (type === 'boolean' || description?.toLowerCase().includes('boolean')) {
return {
dataType: 'boolean',
description,
rawMetadata: metadata,
};
}
// Try to sample the actual value to detect boolean
if (app) {
try {
const sampleValue = app.getSelfPath(path as Path);
// Handle both raw values and SignalK value objects
const actualValue =
sampleValue &&
typeof sampleValue === 'object' &&
'value' in sampleValue
? sampleValue.value
: sampleValue;
if (typeof actualValue === 'boolean') {
return {
dataType: 'boolean',
description: description || 'Boolean value (detected from sample)',
rawMetadata: metadata,
};
}
} catch (sampleError) {
// Ignore sampling errors, continue with default detection
}
}
// Default to string if no clear type detected
app?.debug(
`Type detection: no clear type for ${path}, defaulting to string`
);
return {
dataType: 'string',
description,
rawMetadata: metadata,
};
} catch (error) {
app?.error(`Type detection error for ${path}: ${(error as Error).message}`);
return { dataType: 'unknown' };
}
}
/**
* Sample the actual data to help determine type when metadata is unavailable
* This is a fallback mechanism
*/
export async function detectPathTypeFromSample(
path: string,
sampleValue: unknown,
app?: ServerAPI
): Promise<PathTypeInfo> {
// Position object detection
if (
typeof sampleValue === 'object' &&
sampleValue !== null &&
'latitude' in sampleValue &&
'longitude' in sampleValue
) {
return {
dataType: 'position',
description: 'Geographic position (detected from sample)',
};
}
// Boolean detection
if (typeof sampleValue === 'boolean') {
return {
dataType: 'boolean',
description: 'Boolean value (detected from sample)',
};
}
// Numeric detection
if (typeof sampleValue === 'number') {
// Check if path suggests angular data
const angularPatterns = [
/heading/i,
/course/i,
/bearing/i,
/angle/i,
/direction/i,
];
if (angularPatterns.some(pattern => pattern.test(path))) {
return {
dataType: 'angular',
description: 'Angular value (detected from path pattern)',
};
}
return {
dataType: 'numeric',
description: 'Numeric value (detected from sample)',
};
}
// String detection
if (typeof sampleValue === 'string') {
return {
dataType: 'string',
description: 'String value (detected from sample)',
};
}
app?.debug(`Type detection: unable to detect type from sample for ${path}`);
return { dataType: 'unknown' };
}