UNPKG

carthorse

Version:

A geospatial trail data processing pipeline for building 3D trail databases with elevation data

451 lines • 26.9 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.KspRouteGeneratorService = void 0; const route_pattern_sql_helpers_1 = require("../sql/route-pattern-sql-helpers"); const route_generation_business_logic_1 = require("../business/route-generation-business-logic"); const constituent_trail_analysis_service_1 = require("./constituent-trail-analysis-service"); const route_discovery_config_loader_1 = require("../../config/route-discovery-config-loader"); const fs = __importStar(require("fs")); const path = __importStar(require("path")); class KspRouteGeneratorService { constructor(pgClient, config) { this.pgClient = pgClient; this.config = config; this.generatedTrailCombinations = new Set(); // Track unique trail combinations this.generatedEndpointCombinations = new Map(); // Track endpoint combinations with their longest route distance this.generatedIdenticalRoutes = new Set(); // Track truly identical routes (same edge sequence) this.sqlHelpers = new route_pattern_sql_helpers_1.RoutePatternSqlHelpers(pgClient); this.constituentAnalysisService = new constituent_trail_analysis_service_1.ConstituentTrailAnalysisService(pgClient); this.configLoader = route_discovery_config_loader_1.RouteDiscoveryConfigLoader.getInstance(); // Create log file path - use single consistent filename this.logFile = path.join(process.cwd(), 'logs', 'route-generation.log'); // Ensure logs directory exists const logsDir = path.dirname(this.logFile); if (!fs.existsSync(logsDir)) { fs.mkdirSync(logsDir, { recursive: true }); } } /** * Log message to both console and file */ log(message) { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] ${message}`; // Write to console console.log(message); // Write to file try { fs.appendFileSync(this.logFile, logMessage + '\n'); } catch (error) { console.warn(`āš ļø Failed to write to log file ${this.logFile}:`, error); } } /** * Generate KSP routes for all patterns */ async generateKspRoutes() { this.log('[RECOMMENDATIONS] šŸŽÆ Generating KSP routes...'); const patterns = await this.sqlHelpers.loadOutAndBackPatterns(); const allRecommendations = []; this.log(`[RECOMMENDATIONS] šŸ“Š ROUTE GENERATION SUMMARY:`); this.log(`[RECOMMENDATIONS] - Total patterns to process: ${patterns.length}`); this.log(`[RECOMMENDATIONS] - Target routes per pattern: ${this.config.targetRoutesPerPattern}`); this.log(`[RECOMMENDATIONS] - KSP K value: ${this.config.kspKValue}`); this.log(`[RECOMMENDATIONS] - Use trailheads only: ${this.config.useTrailheadsOnly}`); // Track all unique routes across all patterns to prevent duplicates const allGeneratedTrailCombinations = new Set(); for (const pattern of patterns) { this.log(`[RECOMMENDATIONS] \nšŸŽÆ Processing out-and-back pattern: ${pattern.pattern_name} (${pattern.target_distance_km}km, ${pattern.target_elevation_gain}m)`); // Reset endpoint tracking for each pattern to allow different patterns to use same endpoints this.resetEndpointTracking(); // Generate routes specifically for this pattern's distance/elevation targets const patternRoutes = await this.generateRoutesForPattern(pattern, allGeneratedTrailCombinations); // Add all routes from this pattern (don't limit per pattern, let them accumulate) allRecommendations.push(...patternRoutes); this.log(`[RECOMMENDATIONS] āœ… Generated ${patternRoutes.length} out-and-back routes for ${pattern.pattern_name}`); // Log route details for this pattern patternRoutes.forEach((route, index) => { this.log(`[RECOMMENDATIONS] ${index + 1}. ${route.route_name} (${route.recommended_length_km.toFixed(2)}km, ${route.recommended_elevation_gain.toFixed(0)}m, score: ${route.route_score})`); }); } this.log(`[RECOMMENDATIONS] \nšŸ“Š FINAL ROUTE GENERATION SUMMARY:`); this.log(`[RECOMMENDATIONS] - Total routes generated: ${allRecommendations.length}`); this.log(`[RECOMMENDATIONS] - Routes by pattern:`); const routesByPattern = allRecommendations.reduce((acc, route) => { const patternName = route.route_name.split(' - ')[0] || 'Unknown'; acc[patternName] = (acc[patternName] || 0) + 1; return acc; }, {}); Object.entries(routesByPattern).forEach(([pattern, count]) => { this.log(`[RECOMMENDATIONS] - ${pattern}: ${count} routes`); }); return allRecommendations; } /** * Reset endpoint tracking for new pattern */ resetEndpointTracking() { this.generatedEndpointCombinations.clear(); this.log('[RECOMMENDATIONS] šŸ”„ Reset endpoint tracking for new pattern'); } /** * Generate routes for a specific pattern */ async generateRoutesForPattern(pattern, allGeneratedTrailCombinations) { const { halfTargetDistance, halfTargetElevation } = route_generation_business_logic_1.RouteGenerationBusinessLogic.calculateTargetMetrics(pattern); this.log(`[RECOMMENDATIONS] šŸ“ Targeting half-distance: ${halfTargetDistance.toFixed(1)}km, half-elevation: ${halfTargetElevation.toFixed(0)}m`); // Load trailhead configuration from YAML const routeDiscoveryConfig = this.configLoader.loadConfig(); const trailheadConfig = routeDiscoveryConfig.trailheads; // Determine if we should use trailheads based on config // If CLI explicitly sets useTrailheadsOnly, use that value; otherwise fall back to YAML config const shouldUseTrailheads = this.config.useTrailheadsOnly !== undefined ? this.config.useTrailheadsOnly : trailheadConfig.enabled; this.log(`[RECOMMENDATIONS] šŸ” Trailhead usage: useTrailheadsOnly=${this.config.useTrailheadsOnly}, config.enabled=${trailheadConfig.enabled}, shouldUseTrailheads=${shouldUseTrailheads}`); // Get network entry points (trailheads or default) this.log(`[RECOMMENDATIONS] šŸ” Finding network entry points...`); const nodesResult = await this.sqlHelpers.getNetworkEntryPoints(this.config.stagingSchema, shouldUseTrailheads, trailheadConfig.maxTrailheads, this.config.trailheadLocations); this.log(`[RECOMMENDATIONS] šŸ“ Found ${nodesResult.length} network entry points`); if (this.config.trailheadLocations && this.config.trailheadLocations.length > 0) { this.log(`[RECOMMENDATIONS] - Trailhead locations configured: ${this.config.trailheadLocations.length}`); this.config.trailheadLocations.forEach((th, index) => { this.log(`[RECOMMENDATIONS] ${index + 1}. ${th.name || `Trailhead ${index + 1}`}: (${th.lat}, ${th.lng}) ±${th.tolerance_meters || 50}m`); }); } if (nodesResult.length < 2) { this.log('[RECOMMENDATIONS] āš ļø Not enough nodes for routing'); return []; } const patternRoutes = []; const usedAreas = []; const toleranceLevels = route_generation_business_logic_1.RouteGenerationBusinessLogic.getToleranceLevels(pattern); this.log(`[RECOMMENDATIONS] šŸ” Will try ${toleranceLevels.length} tolerance levels for this pattern`); // Generate routes specifically for this pattern's targets // Each pattern should generate different routes that match its distance/elevation criteria for (const tolerance of toleranceLevels) { this.log(`[RECOMMENDATIONS] šŸ” Trying ${tolerance.name} tolerance (${tolerance.distance}% distance, ${tolerance.elevation}% elevation) for pattern "${pattern.pattern_name}"`); await this.generateRoutesWithTolerance(pattern, tolerance, nodesResult, halfTargetDistance, patternRoutes, usedAreas, allGeneratedTrailCombinations); this.log(`[RECOMMENDATIONS] šŸ“Š After ${tolerance.name} tolerance for "${pattern.pattern_name}": ${patternRoutes.length} routes found`); } this.log(`[RECOMMENDATIONS] šŸ“Š Pattern ${pattern.pattern_name} complete: ${patternRoutes.length} total routes generated`); return patternRoutes; } /** * Generate routes with specific tolerance level */ async generateRoutesWithTolerance(pattern, tolerance, nodesResult, halfTargetDistance, patternRoutes, usedAreas, allGeneratedTrailCombinations) { // Generate out-and-back routes from each node with geographic diversity // Use YAML configuration for max starting nodes, or all available nodes if not specified const maxStartingNodes = this.configLoader.loadConfig().routeGeneration?.ksp?.maxStartingNodes || nodesResult.length; const actualMaxStartingNodes = maxStartingNodes === -1 ? nodesResult.length : Math.min(maxStartingNodes, nodesResult.length); this.log(`šŸ” Processing ${actualMaxStartingNodes} starting nodes (from ${nodesResult.length} total nodes)`); let routesFoundThisTolerance = 0; let nodesProcessed = 0; let nodesWithRoutes = 0; for (const startNode of nodesResult.slice(0, actualMaxStartingNodes)) { // Remove per-pattern limit to allow accumulation across all patterns nodesProcessed++; const nodeRoutesBefore = patternRoutes.length; await this.generateRoutesFromNode(pattern, tolerance, startNode.id, startNode.lon, startNode.lat, halfTargetDistance, patternRoutes, usedAreas, allGeneratedTrailCombinations); const nodeRoutesAfter = patternRoutes.length; const routesFromThisNode = nodeRoutesAfter - nodeRoutesBefore; if (routesFromThisNode > 0) { nodesWithRoutes++; routesFoundThisTolerance += routesFromThisNode; this.log(` šŸ“ Node ${startNode.id} (${startNode.lat.toFixed(4)}, ${startNode.lon.toFixed(4)}): ${routesFromThisNode} routes found`); } } this.log(`šŸ“Š ${tolerance.name} tolerance complete:`); this.log(` - Nodes processed: ${nodesProcessed}/${actualMaxStartingNodes}`); this.log(` - Nodes with routes: ${nodesWithRoutes}`); this.log(` - Routes found this tolerance: ${routesFoundThisTolerance}`); this.log(` - Total routes so far: ${patternRoutes.length}`); } /** * Generate routes from a specific starting node */ async generateRoutesFromNode(pattern, tolerance, startNode, startLon, startLat, halfTargetDistance, patternRoutes, usedAreas, allGeneratedTrailCombinations) { // Find reachable nodes within reasonable distance for this specific pattern // Use pattern-specific search distance to target routes that match the pattern const maxSearchDistance = Math.max(halfTargetDistance * 2, pattern.target_distance_km * 1.5); this.log(` šŸ” Finding nodes reachable within ${maxSearchDistance.toFixed(1)}km from node ${startNode} for pattern ${pattern.pattern_name}...`); const reachableNodes = await this.sqlHelpers.findReachableNodes(this.config.stagingSchema, startNode, maxSearchDistance); if (reachableNodes.length === 0) { this.log(` āŒ No reachable nodes found from node ${startNode} within ${maxSearchDistance.toFixed(1)}km`); return; } this.log(` āœ… Found ${reachableNodes.length} reachable nodes from node ${startNode}`); // Try each reachable node as a destination for (const reachableNode of reachableNodes) { // Remove per-pattern limit to allow more routes const endNode = reachableNode.node_id; const oneWayDistance = reachableNode.distance_km; this.log(` šŸ›¤ļø Trying out-and-back route: ${startNode} → ${endNode} → ${startNode} (one-way: ${oneWayDistance.toFixed(2)}km)`); await this.generateRouteBetweenNodes(pattern, tolerance, startNode, endNode, startLon, startLat, oneWayDistance, patternRoutes, usedAreas, allGeneratedTrailCombinations); } } /** * Generate route between two specific nodes */ async generateRouteBetweenNodes(pattern, tolerance, startNode, endNode, startLon, startLat, oneWayDistance, patternRoutes, usedAreas, allGeneratedTrailCombinations) { // Check if the one-way distance is reasonable for our target const { minDistance, maxDistance } = route_generation_business_logic_1.RouteGenerationBusinessLogic.calculateDistanceToleranceRange(pattern.target_distance_km / 2, tolerance); if (oneWayDistance < minDistance || oneWayDistance > maxDistance) { this.log(` āŒ One-way distance ${oneWayDistance.toFixed(2)}km outside tolerance range [${minDistance.toFixed(2)}km, ${maxDistance.toFixed(2)}km]`); return; } try { // Use KSP to find multiple routes for the outbound journey const kspRows = await this.sqlHelpers.executeKspRouting(this.config.stagingSchema, startNode, endNode, this.config.kspKValue); this.log(`āœ… KSP found ${kspRows.length} candidate routes from node ${startNode} to node ${endNode}`); // Process each KSP route const routeGroups = route_generation_business_logic_1.RouteGenerationBusinessLogic.groupKspRouteSteps(kspRows); for (const [pathId, routeSteps] of routeGroups) { // Remove per-pattern limit to allow accumulation across all patterns await this.processKspRoute(pattern, tolerance, pathId, routeSteps, startLon, startLat, patternRoutes, usedAreas, allGeneratedTrailCombinations); } } catch (error) { this.log(`āŒ KSP routing failed: ${error.message}`); } } /** * Process a single KSP route */ async processKspRoute(pattern, tolerance, pathId, routeSteps, startLon, startLat, patternRoutes, usedAreas, allGeneratedTrailCombinations) { this.log(` šŸ” DEBUG: Processing KSP route path ${pathId} with ${routeSteps.length} steps`); // Extract edge IDs from the route steps const edgeIds = route_generation_business_logic_1.RouteGenerationBusinessLogic.extractEdgeIds(routeSteps); if (edgeIds.length === 0) { this.log(` āš ļø No valid edges found for path ${pathId}`); return; } // Get the edges for this route with UUID mapping const routeEdges = await this.sqlHelpers.getRouteEdges(this.config.stagingSchema, edgeIds); if (routeEdges.length === 0) { this.log(` āš ļø No edges found for route path`); return; } // Load deduplication configuration const config = this.configLoader.loadConfig(); const deduplicationConfig = config.deduplication; const shouldDeduplicate = deduplicationConfig?.enabled !== false; if (shouldDeduplicate) { // Check for exact route duplicates (same edge sequence) if (deduplicationConfig?.strategies?.exactRoute !== false) { const identicalRouteHash = this.createExactRouteHash(routeEdges); if (this.generatedIdenticalRoutes.has(identicalRouteHash)) { if (deduplicationConfig?.logging?.enabled) { this.log(` ā­ļø Skipping exact duplicate route: ${identicalRouteHash}`); } return; } } // Check for trail combination duplicates (same trails, different order) if (deduplicationConfig?.strategies?.trailCombination === true) { const trailCombinationHash = this.createTrailCombinationHash(routeEdges); if (this.generatedTrailCombinations.has(trailCombinationHash)) { if (deduplicationConfig?.logging?.enabled) { this.log(` ā­ļø Skipping trail combination duplicate: ${trailCombinationHash}`); } return; } } // Check for endpoint combination duplicates (same start/end points) if (deduplicationConfig?.strategies?.endpointCombination === true) { const endpointHash = this.createEndpointHash(routeEdges); if (this.generatedEndpointCombinations.has(endpointHash)) { if (deduplicationConfig?.logging?.enabled) { this.log(` ā­ļø Skipping endpoint combination duplicate: ${endpointHash}`); } return; } } } // Calculate route metrics for outbound journey const { totalDistance, totalElevationGain } = route_generation_business_logic_1.RouteGenerationBusinessLogic.calculateRouteMetrics(routeEdges); // For out-and-back routes, we need to reverse the edges to create the return journey // This ensures the route follows actual trails both ways, not straight lines const reversedEdges = this.createReversedEdges(routeEdges); const completeOutAndBackEdges = [...routeEdges, ...reversedEdges]; const { outAndBackDistance, outAndBackElevation } = route_generation_business_logic_1.RouteGenerationBusinessLogic.calculateOutAndBackMetrics(totalDistance, totalElevationGain); this.log(` šŸ“ Route metrics: ${totalDistance.toFixed(2)}km → ${outAndBackDistance.toFixed(2)}km (out-and-back), ${totalElevationGain.toFixed(0)}m → ${outAndBackElevation.toFixed(0)}m elevation`); // Check if route meets tolerance criteria const { distanceOk, elevationOk } = route_generation_business_logic_1.RouteGenerationBusinessLogic.meetsToleranceCriteria(outAndBackDistance, outAndBackElevation, pattern, tolerance); this.log(` šŸ” DEBUG: Route tolerance check - distance: ${distanceOk}, elevation: ${elevationOk}`); this.log(` šŸ” DEBUG: Route metrics vs target - distance: ${outAndBackDistance.toFixed(2)}km vs ${pattern.target_distance_km}km, elevation: ${outAndBackElevation.toFixed(0)}m vs ${pattern.target_elevation_gain}m`); if (distanceOk && elevationOk) { // Calculate quality score with improved metrics const finalScore = route_generation_business_logic_1.RouteGenerationBusinessLogic.calculateRouteScore(outAndBackDistance, outAndBackElevation, pattern, tolerance, routeEdges); this.log(` āœ… Route meets criteria! Score: ${finalScore.toFixed(3)}`); // Analyze constituent trails const constituentAnalysis = await this.constituentAnalysisService.analyzeRouteConstituentTrails(this.config.stagingSchema, routeEdges); this.log(` šŸ›¤ļø Constituent trails: ${constituentAnalysis.unique_trail_count} unique trails`); // Create route recommendation with complete out-and-back edges const recommendation = route_generation_business_logic_1.RouteGenerationBusinessLogic.createRouteRecommendation(pattern, pathId, routeSteps, completeOutAndBackEdges, // Use complete out-and-back edges instead of just outbound outAndBackDistance, outAndBackElevation, finalScore, this.config.region); this.log(` šŸ” Created recommendation: ${recommendation ? 'SUCCESS' : 'NULL'}`); if (recommendation) { this.log(` šŸ” Recommendation details: ${recommendation.route_uuid}, ${recommendation.route_name}, ${recommendation.route_score}`); } else { this.log(` āŒ ERROR: Route recommendation is null!`); } // Add to results patternRoutes.push(recommendation); // Track routes to prevent duplicates based on configuration if (shouldDeduplicate) { // Track exact route duplicates if (deduplicationConfig?.strategies?.exactRoute !== false) { const identicalRouteHash = this.createExactRouteHash(routeEdges); this.generatedIdenticalRoutes.add(identicalRouteHash); } // Track trail combination duplicates if (deduplicationConfig?.strategies?.trailCombination === true) { const trailCombinationHash = this.createTrailCombinationHash(routeEdges); this.generatedTrailCombinations.add(trailCombinationHash); } // Track endpoint combination duplicates if (deduplicationConfig?.strategies?.endpointCombination === true) { const endpointHash = this.createEndpointHash(routeEdges); this.generatedEndpointCombinations.set(endpointHash, outAndBackDistance); } } this.log(` āœ… Added route: ${recommendation.route_name} (${outAndBackDistance.toFixed(2)}km, ${outAndBackElevation.toFixed(0)}m, score: ${finalScore.toFixed(1)})`); } else { this.log(` āŒ Route does not meet tolerance criteria`); } } /** * Create a unique hash for a trail combination to prevent duplicates */ createTrailCombinationHash(routeEdges) { // Sort trail IDs to ensure consistent hash regardless of order const trailIds = routeEdges .map(edge => edge.trail_id || edge.trail_uuid) .filter(id => id) // Remove null/undefined .sort(); // Create a hash from the sorted trail IDs return trailIds.join('|'); } /** * Create a unique hash for exact edge sequence to detect truly identical routes */ createExactRouteHash(routeEdges) { // Create hash based on exact edge sequence (order matters) const edgeSequence = routeEdges .map(edge => edge.id) // Use edge ID for exact sequence .filter(id => id) // Remove null/undefined .join('|'); return edgeSequence; } /** * Create a unique hash for an endpoint combination to prevent duplicates */ createEndpointHash(routeEdges) { if (routeEdges.length === 0) { return ''; } // Get the start and end nodes of the route const firstEdge = routeEdges[0]; const lastEdge = routeEdges[routeEdges.length - 1]; // For out-and-back routes, we need to identify the unique endpoints // Sort node IDs to ensure consistent hash regardless of direction const startNode = Math.min(firstEdge.source || firstEdge.from_node_id, firstEdge.target || firstEdge.to_node_id); const endNode = Math.max(lastEdge.source || lastEdge.from_node_id, lastEdge.target || lastEdge.to_node_id); return `${startNode}|${endNode}`; } /** * Store route recommendations in database */ async storeRouteRecommendations(recommendations) { this.log(`\nšŸ’¾ Storing ${recommendations.length} route recommendations...`); // Check for null recommendations const nullRecommendations = recommendations.filter(rec => !rec); if (nullRecommendations.length > 0) { this.log(`āŒ WARNING: Found ${nullRecommendations.length} null recommendations!`); } for (const rec of recommendations) { if (!rec) { this.log(` āŒ SKIPPING null recommendation`); continue; } try { this.log(` šŸ“ Storing route: ${rec.route_uuid} (${rec.route_name})`); await this.sqlHelpers.storeRouteRecommendation(this.config.stagingSchema, rec); this.log(` āœ… Stored route: ${rec.route_uuid}`); } catch (error) { this.log(` āŒ Failed to store route ${rec.route_uuid}: ${error}`); throw error; } } this.log(`āœ… Successfully stored ${recommendations.filter(rec => rec).length} route recommendations`); } /** * Create reversed edges for out-and-back routes * This ensures the return journey follows actual trails, not straight lines */ createReversedEdges(routeEdges) { return routeEdges.map(edge => ({ ...edge, source: edge.target, target: edge.source, // Reverse the geometry if it exists the_geom: edge.the_geom ? this.reverseGeometry(edge.the_geom) : edge.the_geom, // Keep other properties the same id: edge.id, app_uuid: edge.app_uuid, name: edge.name, length_km: edge.length_km, elevation_gain: edge.elevation_loss, // Swap elevation gain/loss for return journey elevation_loss: edge.elevation_gain, trail_name: edge.trail_name })); } /** * Reverse a WKB geometry (for out-and-back routes) */ reverseGeometry(wkbGeometry) { // For now, return the original geometry // In a full implementation, we would reverse the coordinate order // This is a placeholder - the actual reversal should be done in PostGIS return wkbGeometry; } } exports.KspRouteGeneratorService = KspRouteGeneratorService; //# sourceMappingURL=ksp-route-generator-service.js.map