UNPKG

signalk-parquet

Version:

SignalK plugin to save marine data directly to Parquet files with regimen-based control

592 lines (527 loc) 19.8 kB
import { ServerAPI } from '@signalk/server-api'; import { VesselContext, VesselInfo, VesselContextExtraction } from './types'; import * as fs from 'fs-extra'; import * as path from 'path'; /** * Vessel Context Manager - Extracts vessel information from SignalK data * and manages vessel context document for Claude AI analysis */ export class VesselContextManager { private app?: ServerAPI; private contextFilePath: string; private vesselContext?: VesselContext; // SignalK paths to extract vessel information from private static readonly VESSEL_DATA_PATHS: VesselContextExtraction[] = [ // Basic identification { path: 'name', signalkPath: 'name', displayName: 'Vessel Name', category: 'identification' }, { path: 'callsign', signalkPath: 'communication.callsignVhf', displayName: 'Call Sign', category: 'identification' }, { path: 'mmsi', signalkPath: 'mmsi', displayName: 'MMSI', category: 'identification' }, // Physical characteristics { path: 'length', signalkPath: 'design.length', displayName: 'Length Overall (LOA)', unit: 'm', category: 'physical' }, { path: 'beam', signalkPath: 'design.beam', displayName: 'Beam', unit: 'm', category: 'physical' }, { path: 'draft', signalkPath: 'design.draft', displayName: 'Maximum Draft', unit: 'm', category: 'physical' }, { path: 'height', signalkPath: 'design.airHeight', displayName: 'Air Draft/Height', unit: 'm', category: 'physical' }, { path: 'displacement', signalkPath: 'design.displacement', displayName: 'Displacement', unit: 'kg', category: 'physical' }, // Vessel classification { path: 'vesselType', signalkPath: 'design.aisShipAndCargoType', displayName: 'Vessel Type', category: 'classification' }, { path: 'flag', signalkPath: 'registrations.imo.country', displayName: 'Flag State', category: 'classification' }, // Technical specifications { path: 'grossTonnage', signalkPath: 'registrations.imo.grossTonnage', displayName: 'Gross Tonnage', unit: 'GT', category: 'technical' }, { path: 'netTonnage', signalkPath: 'registrations.imo.netTonnage', displayName: 'Net Tonnage', unit: 'NT', category: 'technical' }, { path: 'deadWeight', signalkPath: 'design.deadweight', displayName: 'Deadweight', unit: 'tonnes', category: 'technical' }, // Build information { path: 'builder', signalkPath: 'design.constructor', displayName: 'Builder/Constructor', category: 'build' }, { path: 'buildYear', signalkPath: 'design.construction.year', displayName: 'Build Year', category: 'build' }, { path: 'hullNumber', signalkPath: 'registrations.other.hullNumber', displayName: 'Hull Number', category: 'build' }, // Contact information { path: 'ownerName', signalkPath: 'registrations.imo.owner', displayName: 'Owner', category: 'contact' }, { path: 'port', signalkPath: 'port', displayName: 'Port of Registry', category: 'contact' } ]; constructor(app?: ServerAPI, dataDirectory?: string) { this.app = app; // Must have a data directory - this should be the plugin's configured directory if (!dataDirectory) { throw new Error('Data directory is required for vessel context manager'); } this.contextFilePath = path.join(dataDirectory, 'vessel-context.json'); this.app?.debug(`Vessel context file path: ${this.contextFilePath}`); this.loadVesselContext(); } /** * Extract vessel information from SignalK data */ async extractVesselInfo(): Promise<VesselInfo> { const vesselInfo: VesselInfo = {}; if (!this.app) { console.debug('No SignalK app available for vessel data extraction'); return vesselInfo; } try { // Get vessel context (defaults to vessels.self) const vesselContext = this.app.getSelfPath('') || 'vessels.self'; this.app.debug(`Extracting vessel info from context: ${vesselContext}`); // Extract data for each defined path for (const extraction of VesselContextManager.VESSEL_DATA_PATHS) { try { // Get value from SignalK path const value = this.app.getSelfPath(extraction.signalkPath); if (value !== null && value !== undefined) { // Convert units and handle different data types let processedValue = value; // Handle specific field processing if (extraction.path === 'vesselType') { // Handle AIS ship type - could be number or object if (typeof value === 'number') { processedValue = this.convertAISShipType(value); vesselInfo.vesselType = processedValue; } else if (typeof value === 'object' && value !== null) { if (value.name) { processedValue = value.name; vesselInfo.vesselType = processedValue; } else if (value.id !== undefined) { processedValue = this.convertAISShipType(value.id); vesselInfo.vesselType = processedValue; } } else if (typeof value === 'string') { vesselInfo.vesselType = value; } } else if (extraction.path === 'displacement' && extraction.unit === 'm') { // Convert kg to tonnes for displacement if (typeof value === 'number') { processedValue = value / 1000; vesselInfo.displacement = processedValue; } } else { // Handle simple numbers and strings if (typeof value === 'number' || typeof value === 'string') { (vesselInfo as any)[extraction.path] = value; processedValue = value; } else if (typeof value === 'object' && value !== null) { // Handle nested objects - try to extract the actual value let extractedValue = null; // Common patterns in SignalK data if (value.overall !== undefined) { extractedValue = value.overall; } else if (value.maximum !== undefined) { extractedValue = value.maximum; } else if (value.minimum !== undefined) { extractedValue = value.minimum; } else if (value.hull !== undefined) { extractedValue = value.hull; } else if (value.value !== undefined) { // Handle nested value objects (like {"value":{"overall":16}}) if (typeof value.value === 'object' && value.value !== null) { if (value.value.overall !== undefined) { extractedValue = value.value.overall; } else if (value.value.maximum !== undefined) { extractedValue = value.value.maximum; } else if (value.value.minimum !== undefined) { extractedValue = value.value.minimum; } else { // Take first available value from nested object const nestedKeys = Object.keys(value.value); if (nestedKeys.length > 0) { extractedValue = value.value[nestedKeys[0]]; } } } else { extractedValue = value.value; } } else { // If it's a simple object with one key, try to extract that value const keys = Object.keys(value); if (keys.length === 1) { extractedValue = value[keys[0]]; } else if (keys.length > 1) { // Take the second value if multiple keys exist extractedValue = value[keys[1]]; } } if (extractedValue !== null && extractedValue !== undefined) { (vesselInfo as any)[extraction.path] = extractedValue; processedValue = extractedValue; } } } this.app.debug(`Extracted ${extraction.displayName}: ${processedValue}`); } } catch (error) { this.app?.debug(`Failed to extract ${extraction.displayName}: ${(error as Error).message}`); } } // Try to extract additional vessel data from other common paths await this.extractAdditionalVesselData(vesselInfo); } catch (error) { this.app?.error(`Failed to extract vessel information: ${(error as Error).message}`); } return vesselInfo; } /** * Extract additional vessel data from other SignalK paths */ private async extractAdditionalVesselData(vesselInfo: VesselInfo): Promise<void> { if (!this.app) return; try { // Try alternative paths for common data if (!vesselInfo.name) { const altName = this.app.getSelfPath('registrations.national.registration') || this.app.getSelfPath('registrations.local.registration'); if (altName) { vesselInfo.name = altName; } } if (!vesselInfo.length) { // Try alternative length paths let altLength = this.app.getSelfPath('design.length.hull') || this.app.getSelfPath('design.length.waterline'); // If we get the whole length object, try to extract a useful value if (!altLength) { const lengthObj = this.app.getSelfPath('design.length'); if (lengthObj && typeof lengthObj === 'object') { altLength = lengthObj.overall || lengthObj.hull || lengthObj.waterline; } } if (altLength && typeof altLength === 'number') { vesselInfo.length = altLength; } } if (!vesselInfo.draft) { // Try alternative draft paths let altDraft = this.app.getSelfPath('design.draft.minimum') || this.app.getSelfPath('design.draft.current'); // If we get the whole draft object, try to extract a useful value if (!altDraft) { const draftObj = this.app.getSelfPath('design.draft'); if (draftObj && typeof draftObj === 'object') { altDraft = draftObj.maximum || draftObj.minimum || draftObj.current; } } if (altDraft && typeof altDraft === 'number') { vesselInfo.draft = altDraft; } } // Extract additional notes from description fields const description = this.app.getSelfPath('design.description'); if (description && !vesselInfo.notes) { vesselInfo.notes = description; } } catch (error) { this.app?.debug(`Failed to extract additional vessel data: ${(error as Error).message}`); } } /** * Convert AIS ship type number to readable string */ private convertAISShipType(shipType: number): string { const aisTypes: { [key: number]: string } = { 36: 'Sailing vessel', 37: 'Pleasure craft', 31: 'Towing vessel', 32: 'Towing vessel (length > 200m)', 33: 'Vessel engaged in dredging', 34: 'Vessel engaged in diving operations', 35: 'Military vessel', 30: 'Fishing vessel', 20: 'Wing in ground craft', 21: 'Cargo vessel', 22: 'Cargo vessel', 23: 'Cargo vessel', 24: 'Cargo vessel', 25: 'Cargo vessel', 26: 'Cargo vessel', 27: 'Cargo vessel', 28: 'Cargo vessel', 29: 'Cargo vessel', 70: 'Passenger vessel', 71: 'Passenger vessel', 72: 'Passenger vessel', 73: 'Passenger vessel', 74: 'Passenger vessel', 75: 'Passenger vessel', 76: 'Passenger vessel', 77: 'Passenger vessel', 78: 'Passenger vessel', 79: 'Passenger vessel', 80: 'Tanker', 81: 'Tanker', 82: 'Tanker', 83: 'Tanker', 84: 'Tanker', 85: 'Tanker', 86: 'Tanker', 87: 'Tanker', 88: 'Tanker', 89: 'Tanker' }; return aisTypes[shipType] || `Unknown vessel type (${shipType})`; } /** * Load vessel context from file */ private async loadVesselContext(): Promise<void> { try { if (await fs.pathExists(this.contextFilePath)) { this.vesselContext = await fs.readJson(this.contextFilePath); this.app?.debug(`Loaded vessel context from ${this.contextFilePath}`); } else { // Create default context this.vesselContext = { vesselInfo: {}, customContext: '', lastUpdated: new Date().toISOString(), autoExtracted: false }; await this.saveVesselContext(); } } catch (error) { this.app?.error(`Failed to load vessel context: ${(error as Error).message}`); // Create default context on error this.vesselContext = { vesselInfo: {}, customContext: '', lastUpdated: new Date().toISOString(), autoExtracted: false }; } } /** * Save vessel context to file */ async saveVesselContext(): Promise<void> { try { if (!this.vesselContext) return; // Ensure directory exists await fs.ensureDir(path.dirname(this.contextFilePath)); // Update last modified time this.vesselContext.lastUpdated = new Date().toISOString(); // Save to file await fs.writeJson(this.contextFilePath, this.vesselContext, { spaces: 2 }); this.app?.debug(`Saved vessel context to ${this.contextFilePath}`); } catch (error) { this.app?.error(`Failed to save vessel context: ${(error as Error).message}`); throw error; } } /** * Get current vessel context - ensure it's loaded first */ async getVesselContext(): Promise<VesselContext | undefined> { // Ensure context is loaded if (!this.vesselContext) { await this.loadVesselContext(); } return this.vesselContext; } /** * Update vessel context with new information */ async updateVesselContext( vesselInfo?: Partial<VesselInfo>, customContext?: string, autoExtracted: boolean = false ): Promise<VesselContext> { // Ensure context is loaded first if (!this.vesselContext) { await this.loadVesselContext(); } // If still no context after loading, create a default one if (!this.vesselContext) { this.vesselContext = { vesselInfo: {}, customContext: '', lastUpdated: new Date().toISOString(), autoExtracted: false }; } // Update vessel info if provided if (vesselInfo) { this.vesselContext.vesselInfo = { ...this.vesselContext.vesselInfo, ...vesselInfo }; } // Update custom context if provided if (customContext !== undefined) { this.vesselContext.customContext = customContext; } // Set auto-extracted flag this.vesselContext.autoExtracted = autoExtracted; // Save changes await this.saveVesselContext(); return this.vesselContext; } /** * Refresh vessel information from SignalK */ async refreshVesselInfo(): Promise<VesselContext> { const extractedInfo = await this.extractVesselInfo(); return await this.updateVesselContext(extractedInfo, undefined, true); } /** * Generate context string for Claude AI */ generateClaudeContext(): string { if (!this.vesselContext) { return '=== VESSEL CONTEXT ===\n\nNo vessel context information available.\n\n=== END VESSEL CONTEXT ===\n'; } const { vesselInfo, customContext } = this.vesselContext; const contextParts: string[] = []; // Add vessel identification contextParts.push('=== VESSEL CONTEXT ==='); if (vesselInfo.name || vesselInfo.callsign || vesselInfo.mmsi) { contextParts.push('\n--- VESSEL IDENTIFICATION ---'); if (vesselInfo.name) contextParts.push(`Vessel Name: ${vesselInfo.name}`); if (vesselInfo.callsign) contextParts.push(`Call Sign: ${vesselInfo.callsign}`); if (vesselInfo.mmsi) contextParts.push(`MMSI: ${vesselInfo.mmsi}`); if (vesselInfo.flag) contextParts.push(`Flag: ${vesselInfo.flag}`); if (vesselInfo.port) contextParts.push(`Port of Registry: ${vesselInfo.port}`); } // Add physical characteristics if (vesselInfo.length || vesselInfo.beam || vesselInfo.draft || vesselInfo.height || vesselInfo.displacement) { contextParts.push('\n--- PHYSICAL CHARACTERISTICS ---'); if (vesselInfo.length) contextParts.push(`Length Overall (LOA): ${vesselInfo.length}m`); if (vesselInfo.beam) contextParts.push(`Beam: ${vesselInfo.beam}m`); if (vesselInfo.draft) contextParts.push(`Draft: ${vesselInfo.draft}m`); if (vesselInfo.height) contextParts.push(`Air Draft/Height: ${vesselInfo.height}m`); if (vesselInfo.displacement) contextParts.push(`Displacement: ${vesselInfo.displacement} tonnes`); } // Add vessel type and classification if (vesselInfo.vesselType || vesselInfo.classification) { contextParts.push('\n--- VESSEL TYPE & CLASSIFICATION ---'); if (vesselInfo.vesselType) contextParts.push(`Vessel Type: ${vesselInfo.vesselType}`); if (vesselInfo.classification) contextParts.push(`Classification: ${vesselInfo.classification}`); } // Add technical specifications if (vesselInfo.grossTonnage || vesselInfo.netTonnage || vesselInfo.deadWeight) { contextParts.push('\n--- TECHNICAL SPECIFICATIONS ---'); if (vesselInfo.grossTonnage) contextParts.push(`Gross Tonnage: ${vesselInfo.grossTonnage} GT`); if (vesselInfo.netTonnage) contextParts.push(`Net Tonnage: ${vesselInfo.netTonnage} NT`); if (vesselInfo.deadWeight) contextParts.push(`Deadweight: ${vesselInfo.deadWeight} tonnes`); } // Add build information if (vesselInfo.builder || vesselInfo.buildYear || vesselInfo.hullNumber) { contextParts.push('\n--- BUILD INFORMATION ---'); if (vesselInfo.builder) contextParts.push(`Builder: ${vesselInfo.builder}`); if (vesselInfo.buildYear) contextParts.push(`Build Year: ${vesselInfo.buildYear}`); if (vesselInfo.hullNumber) contextParts.push(`Hull Number: ${vesselInfo.hullNumber}`); } // Add contact information if (vesselInfo.ownerName) { contextParts.push('\n--- CONTACT INFORMATION ---'); contextParts.push(`Owner: ${vesselInfo.ownerName}`); } // Add additional notes if (vesselInfo.notes) { contextParts.push('\n--- ADDITIONAL VESSEL NOTES ---'); contextParts.push(vesselInfo.notes); } // Add custom context if (customContext && customContext.trim()) { contextParts.push('\n--- CUSTOM OPERATIONAL CONTEXT ---'); contextParts.push(customContext.trim()); } contextParts.push('\n=== END VESSEL CONTEXT ===\n'); return contextParts.join('\n'); } /** * Get available vessel data paths for UI */ static getVesselDataPaths(): VesselContextExtraction[] { return [...VesselContextManager.VESSEL_DATA_PATHS]; } }