carthorse
Version:
A geospatial trail data processing pipeline for building 3D trail databases with elevation data
451 lines ⢠26.9 kB
JavaScript
"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