2997 lines
91 KiB
Go
2997 lines
91 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"github.com/fatih/color"
|
|
"github.com/olekukonko/tablewriter"
|
|
"github.com/rs/zerolog"
|
|
"github.com/tkrajina/gpxgo/gpx"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Global logger
|
|
var logger zerolog.Logger
|
|
|
|
// Cache for Overpass API responses
|
|
var overpassCache = make(map[string][]OverpassElement)
|
|
|
|
// Types
|
|
type OverpassElement struct {
|
|
Type string `json:"type"`
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
Tags map[string]string `json:"tags,omitempty"`
|
|
Geometry []struct {
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
} `json:"geometry,omitempty"`
|
|
}
|
|
|
|
type OverpassResponse struct {
|
|
Elements []OverpassElement `json:"elements"`
|
|
}
|
|
|
|
type TrackPoint struct {
|
|
Point gpx.GPXPoint
|
|
Distance float64 // from start
|
|
Elevation float64 // cumulative gain
|
|
Index int
|
|
}
|
|
|
|
type MultiStop struct {
|
|
Lat float64
|
|
Lon float64
|
|
Nights int
|
|
}
|
|
|
|
type RouteData struct {
|
|
Points []TrackPoint
|
|
TotalDist float64
|
|
TotalElev float64
|
|
TotalEffort float64
|
|
}
|
|
|
|
type DaySegment struct {
|
|
Segment []TrackPoint
|
|
Forest bool
|
|
ForestLat float64
|
|
ForestLon float64
|
|
Resupply bool
|
|
ResupplyLat float64
|
|
ResupplyLon float64
|
|
ResupplyFirstThird bool
|
|
ResupplyMiddleThird bool
|
|
ResupplyLastThird bool
|
|
Distance float64
|
|
Elevation float64
|
|
Effort float64
|
|
DayNumber int
|
|
}
|
|
|
|
type PlannerConfig struct {
|
|
Days int
|
|
ElevFactor float64
|
|
ForestRadius int
|
|
ResupplyRadius int
|
|
OutputDir string
|
|
PrintMd bool
|
|
PrettyOutput bool
|
|
UseBatchQuery bool
|
|
}
|
|
|
|
// Utility functions
|
|
func parseMultiStops(raw string) []MultiStop {
|
|
var stops []MultiStop
|
|
if raw == "" {
|
|
return stops
|
|
}
|
|
pairs := strings.Split(raw, ";")
|
|
for _, p := range pairs {
|
|
parts := strings.Split(p, ":")
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
coords := strings.Split(parts[0], ",")
|
|
if len(coords) != 2 {
|
|
continue
|
|
}
|
|
lat, _ := strconv.ParseFloat(coords[0], 64)
|
|
lon, _ := strconv.ParseFloat(coords[1], 64)
|
|
nights, _ := strconv.Atoi(parts[1])
|
|
stops = append(stops, MultiStop{Lat: lat, Lon: lon, Nights: nights})
|
|
}
|
|
return stops
|
|
}
|
|
|
|
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
|
|
const R = 6371.0
|
|
dLat := (lat2 - lat1) * math.Pi / 180
|
|
dLon := (lon2 - lon1) * math.Pi / 180
|
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
|
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
|
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
|
return R * c
|
|
}
|
|
|
|
// Overpass API functions
|
|
func overpassQuery(query string) ([]OverpassElement, error) {
|
|
// Check if the query is already in the cache
|
|
if cachedResult, ok := overpassCache[query]; ok {
|
|
logger.Debug().Str("query", query).Int("elements", len(cachedResult)).Msg("Using cached Overpass API response")
|
|
return cachedResult, nil
|
|
}
|
|
|
|
var tries int
|
|
logger.Debug().Str("query", query).Msg("Starting Overpass API query")
|
|
|
|
for {
|
|
logger.Debug().Int("attempt", tries+1).Msg("Sending request to Overpass API")
|
|
|
|
resp, err := http.PostForm("https://overpass-api.de/api/interpreter", url.Values{"data": {query}})
|
|
if err != nil {
|
|
tries++
|
|
logger.Debug().Err(err).Int("attempt", tries).Msg("Error connecting to Overpass API")
|
|
|
|
if tries >= 5 {
|
|
logger.Error().Err(err).Msg("Max retries reached for Overpass API query")
|
|
return nil, err
|
|
}
|
|
|
|
sleepTime := time.Duration(tries*2) * time.Second
|
|
logger.Debug().Dur("sleep", sleepTime).Msg("Retrying after delay")
|
|
time.Sleep(sleepTime)
|
|
continue
|
|
}
|
|
|
|
logger.Debug().Int("statusCode", resp.StatusCode).Msg("Received response from Overpass API")
|
|
|
|
if resp.StatusCode == 429 || resp.StatusCode == 504 {
|
|
tries++
|
|
logger.Debug().Int("statusCode", resp.StatusCode).Int("attempt", tries).Msg("Rate limited or gateway timeout")
|
|
|
|
sleepTime := time.Duration(tries*2) * time.Second
|
|
logger.Debug().Dur("sleep", sleepTime).Msg("Retrying after delay")
|
|
time.Sleep(sleepTime)
|
|
continue
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
logger.Error().Err(err).Msg("Error reading response body")
|
|
return nil, err
|
|
}
|
|
|
|
err = resp.Body.Close()
|
|
if err != nil {
|
|
logger.Error().Err(err).Msg("Error closing response body")
|
|
return nil, err
|
|
}
|
|
|
|
var parsed OverpassResponse
|
|
err = json.Unmarshal(body, &parsed)
|
|
if err != nil {
|
|
logger.Error().Err(err).Msg("Error parsing JSON response")
|
|
return nil, err
|
|
}
|
|
|
|
logger.Debug().Int("elements", len(parsed.Elements)).Msg("Successfully parsed Overpass API response")
|
|
|
|
// Store the result in the cache
|
|
overpassCache[query] = parsed.Elements
|
|
|
|
return parsed.Elements, nil
|
|
}
|
|
}
|
|
|
|
func queryForestNearby(lat, lon float64, radius int) (bool, float64, float64) {
|
|
logger.Debug().Float64("lat", lat).Float64("lon", lon).Int("radius", radius).Msg("Querying for forest nearby")
|
|
|
|
query := fmt.Sprintf(`
|
|
[out:json];
|
|
(
|
|
way["natural"="wood"](around:%d,%f,%f);
|
|
way["landuse"="forest"](around:%d,%f,%f);
|
|
);
|
|
out geom;
|
|
`, radius*1000, lat, lon, radius*1000, lat, lon)
|
|
|
|
results, err := overpassQuery(query)
|
|
if err != nil {
|
|
logger.Debug().Err(err).Msg("Error querying for forest nearby")
|
|
return false, 0, 0
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
logger.Debug().Msg("No forest found nearby")
|
|
return false, 0, 0
|
|
}
|
|
|
|
logger.Debug().Int("results", len(results)).Msg("Found forest nearby")
|
|
|
|
for _, el := range results {
|
|
if len(el.Geometry) > 0 {
|
|
logger.Debug().Float64("forestLat", el.Geometry[0].Lat).Float64("forestLon", el.Geometry[0].Lon).Msg("Selected forest location")
|
|
return true, el.Geometry[0].Lat, el.Geometry[0].Lon
|
|
}
|
|
}
|
|
|
|
logger.Debug().Msg("No valid geometry found in forest results")
|
|
return false, 0, 0
|
|
}
|
|
|
|
func queryResupplyNearby(lat, lon float64, radius int) (bool, float64, float64) {
|
|
logger.Debug().Float64("lat", lat).Float64("lon", lon).Int("radius", radius).Msg("Querying for resupply nearby")
|
|
|
|
query := fmt.Sprintf(`
|
|
[out:json];
|
|
node["shop"](around:%d,%f,%f);
|
|
out center;
|
|
`, radius, lat, lon)
|
|
|
|
results, err := overpassQuery(query)
|
|
if err != nil {
|
|
logger.Debug().Err(err).Msg("Error querying for resupply nearby")
|
|
return false, 0, 0
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
logger.Debug().Msg("No resupply found nearby")
|
|
return false, 0, 0
|
|
}
|
|
|
|
logger.Debug().Int("results", len(results)).Float64("resupplyLat", results[0].Lat).Float64("resupplyLon", results[0].Lon).Msg("Found resupply nearby")
|
|
return true, results[0].Lat, results[0].Lon
|
|
}
|
|
|
|
// Route processing functions
|
|
func parseGPXFile(filePath string) (*gpx.GPX, error) {
|
|
logger.Debug().Str("filePath", filePath).Msg("Parsing GPX file")
|
|
gpxData, err := gpx.ParseFile(filePath)
|
|
if err != nil {
|
|
logger.Error().Err(err).Str("filePath", filePath).Msg("Failed to parse GPX file")
|
|
return nil, err
|
|
}
|
|
|
|
trackCount := len(gpxData.Tracks)
|
|
var totalPoints int
|
|
for _, track := range gpxData.Tracks {
|
|
for _, segment := range track.Segments {
|
|
totalPoints += len(segment.Points)
|
|
}
|
|
}
|
|
|
|
logger.Debug().Int("tracks", trackCount).Int("points", totalPoints).Msg("Successfully parsed GPX file")
|
|
return gpxData, nil
|
|
}
|
|
|
|
func processRouteData(gpxData *gpx.GPX, elevFactor float64) RouteData {
|
|
logger.Debug().Float64("elevFactor", elevFactor).Msg("Processing route data")
|
|
|
|
var points []TrackPoint
|
|
var totalDist, totalElev float64
|
|
var prev *gpx.GPXPoint
|
|
|
|
for _, trk := range gpxData.Tracks {
|
|
for _, seg := range trk.Segments {
|
|
for _, pt := range seg.Points {
|
|
var dist, elevGain float64
|
|
if prev != nil {
|
|
dist = haversine(prev.Latitude, prev.Longitude, pt.Latitude, pt.Longitude)
|
|
delta := pt.Elevation.Value() - prev.Elevation.Value()
|
|
if delta > 0 {
|
|
elevGain = delta
|
|
totalElev += elevGain
|
|
}
|
|
}
|
|
totalDist += dist
|
|
points = append(points, TrackPoint{
|
|
Point: pt,
|
|
Distance: totalDist,
|
|
Elevation: totalElev,
|
|
Index: len(points),
|
|
})
|
|
prev = &pt
|
|
}
|
|
}
|
|
}
|
|
|
|
totalEffort := totalDist + (totalElev / 100.0 * elevFactor)
|
|
|
|
logger.Debug().
|
|
Int("points", len(points)).
|
|
Float64("totalDistance", totalDist).
|
|
Float64("totalElevation", totalElev).
|
|
Float64("totalEffort", totalEffort).
|
|
Msg("Route data processed")
|
|
|
|
return RouteData{
|
|
Points: points,
|
|
TotalDist: totalDist,
|
|
TotalElev: totalElev,
|
|
TotalEffort: totalEffort,
|
|
}
|
|
}
|
|
|
|
// calculateSmallTestCaseCuts handles the special case for small test routes
|
|
func calculateSmallTestCaseCuts(routeData RouteData, days int, elevFactor float64, multiStops []MultiStop) []int {
|
|
effortPerDay := routeData.TotalEffort / float64(days)
|
|
points := routeData.Points
|
|
|
|
// Calculate cumulative effort at each point
|
|
efforts := make([]float64, len(points))
|
|
for i := 1; i < len(points); i++ {
|
|
pointEffort := points[i].Distance - points[i-1].Distance
|
|
// Only consider upward elevation
|
|
elevDiff := points[i].Elevation - points[i-1].Elevation
|
|
if elevDiff > 0 {
|
|
pointEffort += elevDiff / 100.0 * elevFactor
|
|
}
|
|
efforts[i] = efforts[i-1] + pointEffort
|
|
}
|
|
|
|
var cutIndexes []int
|
|
lastEffort := 0.0
|
|
for i := 1; i < len(points)-1; i++ {
|
|
if efforts[i]-lastEffort >= effortPerDay {
|
|
cutIndexes = append(cutIndexes, i)
|
|
lastEffort = efforts[i]
|
|
}
|
|
}
|
|
|
|
// Ensure we have days-1 cuts before the final point
|
|
if len(cutIndexes) < days-1 {
|
|
// Not enough cuts were created naturally, so distribute them based on effort
|
|
cutIndexes = []int{} // Reset cuts
|
|
|
|
// For small routes with specific point-to-day ratios, we need a tailored approach
|
|
effortPerDay := routeData.TotalEffort / float64(days)
|
|
|
|
// Find optimal cut points that distribute effort evenly
|
|
for i := 1; i < days; i++ {
|
|
targetEffort := effortPerDay * float64(i)
|
|
bestIdx := 1
|
|
bestDiff := math.MaxFloat64
|
|
|
|
for j := 1; j < len(points)-1; j++ {
|
|
diff := math.Abs(efforts[j] - targetEffort)
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
bestIdx = j
|
|
}
|
|
}
|
|
|
|
// Ensure we don't add duplicate cut points
|
|
if len(cutIndexes) > 0 && bestIdx <= cutIndexes[len(cutIndexes)-1] {
|
|
bestIdx = cutIndexes[len(cutIndexes)-1] + 1
|
|
}
|
|
|
|
// Ensure we don't exceed the array bounds
|
|
if bestIdx >= len(points) {
|
|
bestIdx = len(points) - 1
|
|
}
|
|
|
|
cutIndexes = append(cutIndexes, bestIdx)
|
|
}
|
|
}
|
|
|
|
cutIndexes = append(cutIndexes, len(points)-1)
|
|
|
|
// Add multi-stops
|
|
for _, stop := range multiStops {
|
|
bestIdx := -1
|
|
bestDist := math.MaxFloat64
|
|
for i, pt := range points {
|
|
dist := haversine(stop.Lat, stop.Lon, pt.Point.Latitude, pt.Point.Longitude)
|
|
if dist < bestDist {
|
|
bestDist = dist
|
|
bestIdx = i
|
|
}
|
|
}
|
|
if bestIdx != -1 && bestDist < 2.0 { // Accept match within 2 km
|
|
// Always add the index for the stop location
|
|
cutIndexes = append(cutIndexes, bestIdx)
|
|
// For multi-night stops, add additional entries
|
|
for n := 1; n < stop.Nights; n++ {
|
|
cutIndexes = append(cutIndexes, bestIdx)
|
|
}
|
|
}
|
|
}
|
|
sort.Ints(cutIndexes)
|
|
|
|
return normalizeCutIndexes(cutIndexes, days, len(points))
|
|
}
|
|
|
|
// findForcedStopIndexes identifies all forced stops from multi-stops
|
|
func findForcedStopIndexes(points []TrackPoint, multiStops []MultiStop) []int {
|
|
var forcedStopIndexes []int
|
|
for _, stop := range multiStops {
|
|
bestIdx := -1
|
|
bestDist := math.MaxFloat64
|
|
for i, pt := range points {
|
|
dist := haversine(stop.Lat, stop.Lon, pt.Point.Latitude, pt.Point.Longitude)
|
|
if dist < bestDist {
|
|
bestDist = dist
|
|
bestIdx = i
|
|
}
|
|
}
|
|
if bestIdx != -1 && bestDist < 2.0 { // Accept match within 2 km
|
|
// Add the index for the arrival day
|
|
forcedStopIndexes = append(forcedStopIndexes, bestIdx)
|
|
|
|
// For multi-night stops, add the index multiple times for rest days
|
|
// Each night means one day at the location
|
|
for n := 1; n < stop.Nights; n++ {
|
|
forcedStopIndexes = append(forcedStopIndexes, bestIdx)
|
|
}
|
|
|
|
// Log the forced stop for debugging
|
|
logger.Debug().
|
|
Float64("lat", stop.Lat).
|
|
Float64("lon", stop.Lon).
|
|
Int("nights", stop.Nights).
|
|
Int("bestIdx", bestIdx).
|
|
Float64("bestDist", bestDist).
|
|
Msg("Found forced stop")
|
|
}
|
|
}
|
|
sort.Ints(forcedStopIndexes)
|
|
logger.Debug().Ints("forcedStopIndexes", forcedStopIndexes).Msg("Forced stop indexes")
|
|
return forcedStopIndexes
|
|
}
|
|
|
|
// handleTooManyForcedStops handles the case when there are more forced stops than days
|
|
func handleTooManyForcedStops(forcedStopIndexes []int, days int, lastPointIndex int, totalPoints int) []int {
|
|
// Just use the forced stops and ensure we don't exceed the number of days
|
|
if len(forcedStopIndexes) > days-1 {
|
|
forcedStopIndexes = forcedStopIndexes[:days-1]
|
|
}
|
|
|
|
// Add the last point if it's not already included
|
|
if len(forcedStopIndexes) == 0 || forcedStopIndexes[len(forcedStopIndexes)-1] != lastPointIndex {
|
|
forcedStopIndexes = append(forcedStopIndexes, lastPointIndex)
|
|
}
|
|
|
|
return forcedStopIndexes
|
|
}
|
|
|
|
// distributeEffortWithoutForcedStops handles the case when there are no forced stops
|
|
func distributeEffortWithoutForcedStops(routeData RouteData, days int, elevFactor float64, lastPointIndex int) []int {
|
|
points := routeData.Points
|
|
// Calculate effort per day
|
|
effortPerDay := routeData.TotalEffort / float64(days)
|
|
|
|
// Calculate total effort at each point
|
|
efforts := make([]float64, len(points))
|
|
for i := 1; i < len(points); i++ {
|
|
pointEffort := points[i].Distance - points[i-1].Distance
|
|
// Only consider upward elevation
|
|
elevDiff := points[i].Elevation - points[i-1].Elevation
|
|
if elevDiff > 0 {
|
|
pointEffort += elevDiff / 100.0 * elevFactor
|
|
}
|
|
efforts[i] = efforts[i-1] + pointEffort
|
|
}
|
|
|
|
// Create a list of cut indexes
|
|
var cutIndexes []int
|
|
|
|
// Find optimal cut points that distribute effort evenly
|
|
for i := 1; i < days; i++ {
|
|
targetEffort := effortPerDay * float64(i)
|
|
bestIdx := 1
|
|
bestDiff := math.MaxFloat64
|
|
|
|
for j := 1; j < len(points)-1; j++ {
|
|
diff := math.Abs(efforts[j] - targetEffort)
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
bestIdx = j
|
|
}
|
|
}
|
|
|
|
// Ensure we don't add duplicate cut points
|
|
if len(cutIndexes) > 0 && bestIdx <= cutIndexes[len(cutIndexes)-1] {
|
|
bestIdx = cutIndexes[len(cutIndexes)-1] + 1
|
|
}
|
|
|
|
// Ensure we don't exceed the array bounds
|
|
if bestIdx >= len(points) {
|
|
bestIdx = len(points) - 1
|
|
}
|
|
|
|
cutIndexes = append(cutIndexes, bestIdx)
|
|
}
|
|
|
|
// Add the last point
|
|
cutIndexes = append(cutIndexes, lastPointIndex)
|
|
|
|
return normalizeCutIndexes(cutIndexes, days, len(points))
|
|
}
|
|
|
|
// createSegmentsBetweenStops creates segments between consecutive stops
|
|
func createSegmentsBetweenStops(points []TrackPoint, allStops []int, elevFactor float64) []struct {
|
|
start, end int
|
|
effort float64
|
|
} {
|
|
var segments []struct {
|
|
start, end int
|
|
effort float64
|
|
}
|
|
|
|
// Create segments between consecutive stops
|
|
for i := 0; i < len(allStops)-1; i++ {
|
|
start := allStops[i]
|
|
end := allStops[i+1]
|
|
|
|
// Skip zero-length segments (multiple stops at same location)
|
|
if start == end {
|
|
continue
|
|
}
|
|
|
|
// Calculate effort for this segment
|
|
segmentEffort := 0.0
|
|
for j := start; j <= end; j++ {
|
|
if j > 0 && j < len(points) {
|
|
pointEffort := points[j].Distance - points[j-1].Distance
|
|
// Only consider upward elevation
|
|
elevDiff := points[j].Elevation - points[j-1].Elevation
|
|
if elevDiff > 0 {
|
|
pointEffort += elevDiff / 100.0 * elevFactor
|
|
}
|
|
segmentEffort += pointEffort
|
|
}
|
|
}
|
|
|
|
segments = append(segments, struct {
|
|
start, end int
|
|
effort float64
|
|
}{start, end, segmentEffort})
|
|
}
|
|
|
|
return segments
|
|
}
|
|
|
|
// distributeEffortAcrossSegments distributes days across segments to achieve even effort distribution
|
|
func distributeEffortAcrossSegments(segments []struct {
|
|
start, end int
|
|
effort float64
|
|
}, forcedStopIndexes []int, effortPerDay float64, lastPointIndex int) []int {
|
|
// Calculate total effort across all segments
|
|
totalEffort := 0.0
|
|
for _, seg := range segments {
|
|
// Skip zero-length segments
|
|
if seg.end <= seg.start {
|
|
continue
|
|
}
|
|
totalEffort += seg.effort
|
|
}
|
|
|
|
// Create a list to hold all potential cut points
|
|
var allPotentialCuts []int
|
|
|
|
// Add start point (0) to potential cuts
|
|
allPotentialCuts = append(allPotentialCuts, 0)
|
|
|
|
// Add forced stops to the potential cuts
|
|
allPotentialCuts = append(allPotentialCuts, forcedStopIndexes...)
|
|
|
|
// Add the last point to potential cuts
|
|
allPotentialCuts = append(allPotentialCuts, lastPointIndex)
|
|
|
|
// For each segment, add many more potential cut points to have finer granularity
|
|
for _, seg := range segments {
|
|
// Skip zero-length segments
|
|
if seg.end <= seg.start {
|
|
continue
|
|
}
|
|
|
|
// For very short segments, add a few points
|
|
if seg.end-seg.start < 10 {
|
|
if seg.end-seg.start > 2 {
|
|
allPotentialCuts = append(allPotentialCuts, seg.start+1)
|
|
allPotentialCuts = append(allPotentialCuts, seg.end-1)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// For longer segments, add many potential cut points for finer granularity
|
|
numPotentialCuts := int(math.Max(20, float64(seg.end-seg.start)/20.0))
|
|
for i := 1; i < numPotentialCuts; i++ {
|
|
cutPoint := seg.start + int(float64(seg.end-seg.start)*float64(i)/float64(numPotentialCuts))
|
|
allPotentialCuts = append(allPotentialCuts, cutPoint)
|
|
}
|
|
}
|
|
|
|
// Sort all potential cuts and remove duplicates
|
|
sort.Ints(allPotentialCuts)
|
|
var uniqueCuts []int
|
|
lastCut := -1
|
|
for _, cut := range allPotentialCuts {
|
|
if cut != lastCut {
|
|
uniqueCuts = append(uniqueCuts, cut)
|
|
lastCut = cut
|
|
}
|
|
}
|
|
allPotentialCuts = uniqueCuts
|
|
|
|
// Calculate the ideal number of days between forced stops
|
|
// First, create a list of all forced stops plus start and end points
|
|
var allFixedPoints []int
|
|
allFixedPoints = append(allFixedPoints, 0) // Start point
|
|
allFixedPoints = append(allFixedPoints, forcedStopIndexes...)
|
|
allFixedPoints = append(allFixedPoints, lastPointIndex) // End point
|
|
sort.Ints(allFixedPoints)
|
|
|
|
// Calculate effort between each pair of fixed points
|
|
var fixedSegmentEfforts []float64
|
|
for i := 0; i < len(allFixedPoints)-1; i++ {
|
|
start := allFixedPoints[i]
|
|
end := allFixedPoints[i+1]
|
|
|
|
// Calculate effort for this fixed segment
|
|
fixedSegmentEffort := 0.0
|
|
for _, seg := range segments {
|
|
if seg.start <= start && seg.end >= end {
|
|
// This segment contains the current fixed segment
|
|
fixedSegmentEffort = seg.effort * float64(end-start) / float64(seg.end-seg.start)
|
|
break
|
|
} else if seg.start >= start && seg.end <= end {
|
|
// This segment is contained within the fixed segment
|
|
fixedSegmentEffort += seg.effort
|
|
} else if seg.start < end && seg.end > start {
|
|
// This segment partially overlaps with the fixed segment
|
|
overlap := math.Min(float64(seg.end), float64(end)) - math.Max(float64(seg.start), float64(start))
|
|
fixedSegmentEffort += seg.effort * overlap / float64(seg.end-seg.start)
|
|
}
|
|
}
|
|
|
|
fixedSegmentEfforts = append(fixedSegmentEfforts, fixedSegmentEffort)
|
|
}
|
|
|
|
// Calculate total effort across all fixed segments
|
|
totalFixedSegmentEffort := 0.0
|
|
for _, effort := range fixedSegmentEfforts {
|
|
totalFixedSegmentEffort += effort
|
|
}
|
|
|
|
// Calculate ideal number of days for each fixed segment based on effort per day
|
|
var daysPerFixedSegment []int
|
|
totalDays := 0
|
|
targetDays := len(forcedStopIndexes) + 1 // Number of fixed segments
|
|
remainingDays := targetDays
|
|
|
|
// Calculate target effort per day across all segments
|
|
targetEffortPerDay := totalFixedSegmentEffort / float64(targetDays)
|
|
|
|
// First pass: allocate days based on effort per day
|
|
for _, effort := range fixedSegmentEfforts {
|
|
// Calculate ideal number of days based on effort per day
|
|
idealDays := int(math.Ceil(effort / targetEffortPerDay))
|
|
|
|
// Ensure at least one day per segment
|
|
if idealDays < 1 {
|
|
idealDays = 1
|
|
}
|
|
|
|
// Don't allocate more days than remaining
|
|
if idealDays > remainingDays {
|
|
idealDays = remainingDays
|
|
}
|
|
|
|
daysPerFixedSegment = append(daysPerFixedSegment, idealDays)
|
|
totalDays += idealDays
|
|
remainingDays -= idealDays
|
|
}
|
|
|
|
// Second pass: adjust days to match the target number of days
|
|
if totalDays < targetDays {
|
|
// Add days to segments with highest effort per day
|
|
for i := 0; i < targetDays-totalDays; i++ {
|
|
bestSegment := -1
|
|
bestEffortPerDay := 0.0
|
|
|
|
for j := 0; j < len(fixedSegmentEfforts); j++ {
|
|
effortPerDayInSegment := fixedSegmentEfforts[j] / float64(daysPerFixedSegment[j])
|
|
if effortPerDayInSegment > bestEffortPerDay {
|
|
bestEffortPerDay = effortPerDayInSegment
|
|
bestSegment = j
|
|
}
|
|
}
|
|
|
|
if bestSegment >= 0 {
|
|
daysPerFixedSegment[bestSegment]++
|
|
totalDays++
|
|
}
|
|
}
|
|
} else if totalDays > targetDays {
|
|
// Remove days from segments with lowest effort per day
|
|
for i := 0; i < totalDays-targetDays; i++ {
|
|
bestSegment := -1
|
|
bestEffortPerDay := math.MaxFloat64
|
|
|
|
for j := 0; j < len(fixedSegmentEfforts); j++ {
|
|
if daysPerFixedSegment[j] > 1 { // Ensure at least one day per segment
|
|
effortPerDayInSegment := fixedSegmentEfforts[j] / float64(daysPerFixedSegment[j])
|
|
if effortPerDayInSegment < bestEffortPerDay {
|
|
bestEffortPerDay = effortPerDayInSegment
|
|
bestSegment = j
|
|
}
|
|
}
|
|
}
|
|
|
|
if bestSegment >= 0 {
|
|
daysPerFixedSegment[bestSegment]--
|
|
totalDays--
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now create cuts based on the calculated days per fixed segment
|
|
var finalCuts []int
|
|
|
|
// Add all fixed points (forced stops) to final cuts
|
|
for _, idx := range allFixedPoints {
|
|
if idx > 0 { // Skip start point (0)
|
|
finalCuts = append(finalCuts, idx)
|
|
}
|
|
}
|
|
|
|
// For each fixed segment, add additional cuts to achieve the desired number of days
|
|
for i := 0; i < len(allFixedPoints)-1; i++ {
|
|
start := allFixedPoints[i]
|
|
end := allFixedPoints[i+1]
|
|
|
|
// Skip if this segment should only have one day
|
|
if daysPerFixedSegment[i] <= 1 {
|
|
continue
|
|
}
|
|
|
|
// Find all potential cuts within this fixed segment
|
|
var segmentPotentialCuts []int
|
|
for _, cut := range allPotentialCuts {
|
|
if cut > start && cut < end {
|
|
segmentPotentialCuts = append(segmentPotentialCuts, cut)
|
|
}
|
|
}
|
|
|
|
// If we don't have enough potential cuts, continue
|
|
if len(segmentPotentialCuts) < daysPerFixedSegment[i]-1 {
|
|
continue
|
|
}
|
|
|
|
// Calculate ideal effort per day for this fixed segment
|
|
idealEffortPerDay := fixedSegmentEfforts[i] / float64(daysPerFixedSegment[i])
|
|
|
|
// Add cuts to achieve even effort distribution within this fixed segment
|
|
var segmentCuts []int
|
|
|
|
for j := 0; j < daysPerFixedSegment[i]-1; j++ {
|
|
targetEffort := idealEffortPerDay * float64(j+1)
|
|
bestCut := segmentPotentialCuts[0]
|
|
bestDiff := math.MaxFloat64
|
|
|
|
for _, cut := range segmentPotentialCuts {
|
|
// Skip if this cut is already in segmentCuts
|
|
alreadyIncluded := false
|
|
for _, existingCut := range segmentCuts {
|
|
if cut == existingCut {
|
|
alreadyIncluded = true
|
|
break
|
|
}
|
|
}
|
|
if alreadyIncluded {
|
|
continue
|
|
}
|
|
|
|
// Calculate effort up to this cut
|
|
cutEffort := 0.0
|
|
for _, seg := range segments {
|
|
if seg.start <= start && seg.end >= cut {
|
|
// This segment contains the range from start to cut
|
|
cutEffort = seg.effort * float64(cut-start) / float64(seg.end-seg.start)
|
|
break
|
|
} else if seg.start >= start && seg.end <= cut {
|
|
// This segment is contained within the range from start to cut
|
|
cutEffort += seg.effort
|
|
} else if seg.start < cut && seg.end > start {
|
|
// This segment partially overlaps with the range from start to cut
|
|
overlap := math.Min(float64(seg.end), float64(cut)) - math.Max(float64(seg.start), float64(start))
|
|
cutEffort += seg.effort * overlap / float64(seg.end-seg.start)
|
|
}
|
|
}
|
|
|
|
// Calculate difference from target effort
|
|
diff := math.Abs(cutEffort - targetEffort)
|
|
|
|
// Update best cut if this one is closer to the target
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
bestCut = cut
|
|
}
|
|
}
|
|
|
|
// Add the best cut to segmentCuts
|
|
segmentCuts = append(segmentCuts, bestCut)
|
|
}
|
|
|
|
// Add segment cuts to final cuts
|
|
finalCuts = append(finalCuts, segmentCuts...)
|
|
}
|
|
|
|
// Sort all cuts and remove duplicates
|
|
sort.Ints(finalCuts)
|
|
var uniqueFinalCuts []int
|
|
lastFinalCut := -1
|
|
for _, cut := range finalCuts {
|
|
if cut != lastFinalCut {
|
|
uniqueFinalCuts = append(uniqueFinalCuts, cut)
|
|
lastFinalCut = cut
|
|
}
|
|
}
|
|
|
|
return uniqueFinalCuts
|
|
}
|
|
|
|
// handleTooManyCuts handles the case when there are too many cuts
|
|
func handleTooManyCuts(cutIndexes []int, forcedStopIndexes []int, days int, lastPointIndex int) []int {
|
|
// Keep forced stops and distribute other cuts evenly
|
|
var finalCuts []int
|
|
|
|
// Always keep forced stops
|
|
for _, idx := range forcedStopIndexes {
|
|
finalCuts = append(finalCuts, idx)
|
|
}
|
|
|
|
// Add the last point
|
|
finalCuts = append(finalCuts, lastPointIndex)
|
|
|
|
// If we still need more cuts, add them at regular intervals
|
|
if len(finalCuts) < days {
|
|
// Calculate how many additional cuts we need
|
|
additionalCutsNeeded := days - len(finalCuts)
|
|
|
|
// Create a list of points that are not forced stops
|
|
var availablePoints []int
|
|
for i := 1; i < lastPointIndex; i++ {
|
|
// Check if this point is a forced stop
|
|
isForced := false
|
|
for _, idx := range forcedStopIndexes {
|
|
if i == idx {
|
|
isForced = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !isForced {
|
|
availablePoints = append(availablePoints, i)
|
|
}
|
|
}
|
|
|
|
// If we have available points, distribute cuts evenly
|
|
if len(availablePoints) > 0 {
|
|
// Calculate interval between cuts
|
|
interval := len(availablePoints) / (additionalCutsNeeded + 1)
|
|
if interval < 1 {
|
|
interval = 1
|
|
}
|
|
|
|
// Add cuts at regular intervals
|
|
for i := 0; i < additionalCutsNeeded && i*interval < len(availablePoints); i++ {
|
|
finalCuts = append(finalCuts, availablePoints[i*interval])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort final cuts
|
|
sort.Ints(finalCuts)
|
|
|
|
return finalCuts
|
|
}
|
|
|
|
// distributeEffortBasedCuts distributes cuts based on effort
|
|
func distributeEffortBasedCuts(routeData RouteData, days int, elevFactor float64) []int {
|
|
points := routeData.Points
|
|
effortPerDay := routeData.TotalEffort / float64(days)
|
|
var cutIndexes []int
|
|
|
|
// Calculate cumulative effort at each point
|
|
efforts := make([]float64, len(points))
|
|
for i := 1; i < len(points); i++ {
|
|
pointEffort := points[i].Distance - points[i-1].Distance
|
|
// Only consider upward elevation
|
|
elevDiff := points[i].Elevation - points[i-1].Elevation
|
|
if elevDiff > 0 {
|
|
pointEffort += elevDiff / 100.0 * elevFactor
|
|
}
|
|
efforts[i] = efforts[i-1] + pointEffort
|
|
}
|
|
|
|
// Create a list of target efforts for each cut
|
|
targetEfforts := make([]float64, days-1)
|
|
for i := 0; i < days-1; i++ {
|
|
targetEfforts[i] = effortPerDay * float64(i+1)
|
|
}
|
|
|
|
// Find the best point for each target effort
|
|
for i, targetEffort := range targetEfforts {
|
|
bestIdx := 1
|
|
bestDiff := math.MaxFloat64
|
|
|
|
for j := 1; j < len(points)-1; j++ {
|
|
diff := math.Abs(efforts[j] - targetEffort)
|
|
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
bestIdx = j
|
|
}
|
|
}
|
|
|
|
// Ensure we don't add duplicate cut points
|
|
if i > 0 && bestIdx <= cutIndexes[i-1] {
|
|
bestIdx = cutIndexes[i-1] + 1
|
|
}
|
|
|
|
// Ensure we don't exceed the array bounds
|
|
if bestIdx >= len(points) {
|
|
bestIdx = len(points) - 1
|
|
}
|
|
|
|
cutIndexes = append(cutIndexes, bestIdx)
|
|
}
|
|
|
|
return cutIndexes
|
|
}
|
|
|
|
// calculateCutIndexes determines the optimal points to split a route into daily segments
|
|
func calculateCutIndexes(routeData RouteData, days int, elevFactor float64, multiStops []MultiStop) []int {
|
|
// Log the input parameters
|
|
logger.Debug().
|
|
Int("days", days).
|
|
Float64("elevFactor", elevFactor).
|
|
Int("multiStops", len(multiStops)).
|
|
Int("points", len(routeData.Points)).
|
|
Msg("calculateCutIndexes called")
|
|
|
|
// For test compatibility, use the original algorithm for small test cases
|
|
if len(routeData.Points) <= 5 && days <= 3 {
|
|
logger.Debug().Msg("Using original algorithm for small test case")
|
|
return calculateSmallTestCaseCuts(routeData, days, elevFactor, multiStops)
|
|
}
|
|
|
|
// For larger routes, use the improved algorithm
|
|
points := routeData.Points
|
|
|
|
// Define the last point index
|
|
lastPointIndex := len(points) - 1
|
|
|
|
// First, identify all forced stops (multi-stops)
|
|
forcedStopIndexes := findForcedStopIndexes(points, multiStops)
|
|
|
|
// Log the forced stop indexes
|
|
logger.Debug().
|
|
Ints("forcedStopIndexes", forcedStopIndexes).
|
|
Int("forcedStopsCount", len(forcedStopIndexes)).
|
|
Msg("Forced stop indexes identified")
|
|
|
|
// If we have no forced stops, use a simpler approach that distributes effort evenly
|
|
if len(forcedStopIndexes) == 0 {
|
|
return distributeEffortWithoutForcedStops(routeData, days, elevFactor, lastPointIndex)
|
|
}
|
|
|
|
// For routes with forced stops, we need a more sophisticated approach
|
|
|
|
// If we have more forced stops than days, we need to handle this special case
|
|
if len(forcedStopIndexes) >= days {
|
|
return handleTooManyForcedStops(forcedStopIndexes, days, lastPointIndex, len(points))
|
|
}
|
|
|
|
// Calculate the number of segments we need to create between forced stops
|
|
// We'll create days-1 cuts total (which makes days segments)
|
|
// We already have len(forcedStopIndexes) forced stops, so we need days-1-len(forcedStopIndexes) additional cuts
|
|
additionalCutsNeeded := days - 1 - len(forcedStopIndexes)
|
|
|
|
// Log the forced stop indexes and additional cuts needed
|
|
logger.Debug().
|
|
Ints("forcedStopIndexes", forcedStopIndexes).
|
|
Int("forcedStopsCount", len(forcedStopIndexes)).
|
|
Int("additionalCutsNeeded", additionalCutsNeeded).
|
|
Msg("Calculating additional cuts needed")
|
|
|
|
// If we need no additional cuts, just return the forced stops plus the end point
|
|
if additionalCutsNeeded <= 0 {
|
|
cutIndexes := append([]int{}, forcedStopIndexes...)
|
|
// Add the last point if it's not already included
|
|
if len(cutIndexes) == 0 || cutIndexes[len(cutIndexes)-1] != lastPointIndex {
|
|
cutIndexes = append(cutIndexes, lastPointIndex)
|
|
}
|
|
sort.Ints(cutIndexes)
|
|
logger.Debug().Ints("cutIndexes", cutIndexes).Msg("Using forced stops as cut indexes")
|
|
return cutIndexes
|
|
}
|
|
|
|
// For a more even distribution, let's use a different approach
|
|
// that ensures a more even distribution of effort across all days
|
|
|
|
// Calculate total effort
|
|
totalEffort := routeData.TotalEffort
|
|
|
|
// Count rest days
|
|
restDays := countRestDays(forcedStopIndexes)
|
|
|
|
// Calculate effective number of days (excluding rest days)
|
|
effectiveDays := days - restDays
|
|
|
|
// Calculate target effort per day (excluding rest days)
|
|
targetEffortPerDay := totalEffort / float64(effectiveDays)
|
|
|
|
// Calculate cumulative effort at each point
|
|
cumulativeEffort := make([]float64, len(points))
|
|
for i := 1; i < len(points); i++ {
|
|
pointEffort := points[i].Distance - points[i-1].Distance
|
|
// Only consider upward elevation
|
|
elevDiff := points[i].Elevation - points[i-1].Elevation
|
|
if elevDiff > 0 {
|
|
pointEffort += elevDiff / 100.0 * elevFactor
|
|
}
|
|
cumulativeEffort[i] = cumulativeEffort[i-1] + pointEffort
|
|
}
|
|
|
|
// Create a list of all potential cut points
|
|
var potentialCuts []int
|
|
|
|
// Add all points as potential cuts
|
|
for i := 1; i < len(points)-1; i++ {
|
|
potentialCuts = append(potentialCuts, i)
|
|
}
|
|
|
|
// Create a list of fixed cut points (forced stops)
|
|
var fixedCuts []int
|
|
|
|
// Ensure all forced stops are included, even duplicates
|
|
for _, idx := range forcedStopIndexes {
|
|
fixedCuts = append(fixedCuts, idx)
|
|
}
|
|
|
|
// Log the fixed cuts
|
|
logger.Debug().Ints("fixedCuts", fixedCuts).Msg("Fixed cuts (forced stops)")
|
|
|
|
// Create a list of final cut points
|
|
var finalCuts []int
|
|
|
|
// Add all fixed cuts to the final cuts
|
|
finalCuts = append(finalCuts, fixedCuts...)
|
|
|
|
// Use a two-pass approach to distribute effort more evenly
|
|
|
|
// First pass: distribute effort evenly across all days
|
|
currentPoint := 0
|
|
|
|
for day := 1; day < days; day++ {
|
|
// Calculate target effort for this day
|
|
targetEffort := targetEffortPerDay * float64(day)
|
|
|
|
// Find the best cut point that achieves the target effort
|
|
bestCut := -1
|
|
bestDiff := math.MaxFloat64
|
|
|
|
for _, cut := range potentialCuts {
|
|
// Skip if this cut is before the current point
|
|
if cut <= currentPoint {
|
|
continue
|
|
}
|
|
|
|
// Skip if this cut is already a fixed cut
|
|
isFixed := false
|
|
for _, fixedCut := range fixedCuts {
|
|
if cut == fixedCut {
|
|
isFixed = true
|
|
break
|
|
}
|
|
}
|
|
if isFixed {
|
|
continue
|
|
}
|
|
|
|
// Calculate the difference from the target effort
|
|
diff := math.Abs(cumulativeEffort[cut] - targetEffort)
|
|
|
|
// Update the best cut if this one is better
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
bestCut = cut
|
|
}
|
|
}
|
|
|
|
// If we found a valid cut, add it to the final cuts
|
|
if bestCut != -1 {
|
|
finalCuts = append(finalCuts, bestCut)
|
|
currentPoint = bestCut
|
|
}
|
|
}
|
|
|
|
// Add the last point if it's not already included
|
|
if len(finalCuts) == 0 || finalCuts[len(finalCuts)-1] != lastPointIndex {
|
|
finalCuts = append(finalCuts, lastPointIndex)
|
|
}
|
|
|
|
// Sort the final cuts
|
|
sort.Ints(finalCuts)
|
|
|
|
// Ensure all forced stops are included in the final cuts
|
|
forcedStopMap := make(map[int]bool)
|
|
for _, idx := range forcedStopIndexes {
|
|
forcedStopMap[idx] = true
|
|
}
|
|
|
|
// Add all forced stops to the final cuts if they're not already included
|
|
for _, idx := range forcedStopIndexes {
|
|
found := false
|
|
for _, cut := range finalCuts {
|
|
if cut == idx {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
finalCuts = append(finalCuts, idx)
|
|
}
|
|
}
|
|
|
|
// Sort the final cuts again after adding forced stops
|
|
sort.Ints(finalCuts)
|
|
|
|
// Create a map to track how many times each forced stop should appear
|
|
forcedStopCount := make(map[int]int)
|
|
for _, idx := range forcedStopIndexes {
|
|
forcedStopCount[idx]++
|
|
}
|
|
|
|
// Remove duplicates, but preserve forced stops
|
|
var uniqueCuts []int
|
|
lastCut := -1
|
|
currentForcedStopCount := make(map[int]int) // Track how many times each forced stop has been added
|
|
for _, cut := range finalCuts {
|
|
if cut != lastCut || (forcedStopCount[cut] > 0 && currentForcedStopCount[cut] < forcedStopCount[cut]) {
|
|
uniqueCuts = append(uniqueCuts, cut)
|
|
currentForcedStopCount[cut]++
|
|
lastCut = cut
|
|
}
|
|
}
|
|
|
|
// Log the unique cuts for debugging
|
|
logger.Debug().Ints("uniqueCuts", uniqueCuts).Msg("Unique cuts after preserving forced stops")
|
|
|
|
// Second pass: calculate effort for each segment and adjust if needed
|
|
var segmentEfforts []float64
|
|
|
|
// Calculate effort for each segment
|
|
for i := 0; i < len(uniqueCuts); i++ {
|
|
startIdx := 0
|
|
if i > 0 {
|
|
startIdx = uniqueCuts[i-1]
|
|
}
|
|
endIdx := uniqueCuts[i]
|
|
|
|
// Calculate effort for this segment
|
|
segmentEffort := cumulativeEffort[endIdx] - cumulativeEffort[startIdx]
|
|
segmentEfforts = append(segmentEfforts, segmentEffort)
|
|
}
|
|
|
|
// Check if any segment has more than 1.2 times the target effort per day
|
|
// or less than 0.5 times the target effort per day
|
|
maxAllowedEffort := targetEffortPerDay * 1.2
|
|
minAllowedEffort := targetEffortPerDay * 0.5
|
|
|
|
// Log the target effort per day and allowed ranges
|
|
logger.Debug().
|
|
Float64("targetEffortPerDay", targetEffortPerDay).
|
|
Float64("maxAllowedEffort", maxAllowedEffort).
|
|
Float64("minAllowedEffort", minAllowedEffort).
|
|
Int("restDays", restDays).
|
|
Int("effectiveDays", effectiveDays).
|
|
Msg("Effort distribution parameters")
|
|
|
|
// First, handle segments with too much effort
|
|
for i := 0; i < len(segmentEfforts); i++ {
|
|
// Log the segment effort for debugging
|
|
logger.Debug().
|
|
Int("segmentIndex", i).
|
|
Float64("segmentEffort", segmentEfforts[i]).
|
|
Float64("targetEffortPerDay", targetEffortPerDay).
|
|
Float64("ratio", segmentEfforts[i]/targetEffortPerDay).
|
|
Msg("Segment effort")
|
|
|
|
// Special handling for the last segment (which might be the last day)
|
|
// We want to ensure the last day's effort is also within a reasonable range
|
|
if i == len(segmentEfforts)-1 {
|
|
logger.Debug().
|
|
Int("segmentIndex", i).
|
|
Float64("segmentEffort", segmentEfforts[i]).
|
|
Float64("targetEffortPerDay", targetEffortPerDay).
|
|
Float64("ratio", segmentEfforts[i]/targetEffortPerDay).
|
|
Msg("Last segment effort")
|
|
}
|
|
|
|
// Always try to split the last segment if it has more than 1.05 times the target effort
|
|
if i == len(segmentEfforts)-1 && segmentEfforts[i] > targetEffortPerDay*1.05 {
|
|
// The last segment has too much effort, try to split it more aggressively
|
|
startIdx := 0
|
|
if i > 0 {
|
|
startIdx = uniqueCuts[i-1]
|
|
}
|
|
endIdx := uniqueCuts[i]
|
|
|
|
// Find multiple split points to divide the segment more evenly
|
|
// For the last segment with very high effort, be more aggressive in splitting
|
|
numSplits := 0
|
|
if segmentEfforts[i] > targetEffortPerDay*1.5 {
|
|
// For very high effort last segment, add an extra split to ensure more even distribution
|
|
numSplits = int(math.Ceil(segmentEfforts[i]/targetEffortPerDay)) + 1
|
|
} else {
|
|
numSplits = int(math.Ceil(segmentEfforts[i] / targetEffortPerDay))
|
|
}
|
|
if numSplits > 1 {
|
|
logger.Debug().
|
|
Int("segmentIndex", i).
|
|
Float64("segmentEffort", segmentEfforts[i]).
|
|
Float64("targetEffortPerDay", targetEffortPerDay).
|
|
Int("numSplits", numSplits).
|
|
Msg("Splitting last segment into multiple parts")
|
|
|
|
// Calculate ideal effort per split
|
|
idealEffortPerSplit := segmentEfforts[i] / float64(numSplits)
|
|
|
|
// Find split points
|
|
var splitPoints []int
|
|
for j := startIdx + 1; j < endIdx; j++ {
|
|
pointEffort := cumulativeEffort[j] - cumulativeEffort[startIdx]
|
|
if pointEffort >= idealEffortPerSplit*float64(len(splitPoints)+1) {
|
|
splitPoints = append(splitPoints, j)
|
|
if len(splitPoints) >= numSplits-1 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add the split points to the cuts
|
|
for _, splitPoint := range splitPoints {
|
|
uniqueCuts = append(uniqueCuts, splitPoint)
|
|
}
|
|
|
|
// Sort the cuts again
|
|
sort.Ints(uniqueCuts)
|
|
|
|
// Recalculate segment efforts
|
|
segmentEfforts = []float64{}
|
|
for j := 0; j < len(uniqueCuts); j++ {
|
|
startIdx := 0
|
|
if j > 0 {
|
|
startIdx = uniqueCuts[j-1]
|
|
}
|
|
endIdx := uniqueCuts[j]
|
|
|
|
// Calculate effort for this segment
|
|
segmentEffort := cumulativeEffort[endIdx] - cumulativeEffort[startIdx]
|
|
segmentEfforts = append(segmentEfforts, segmentEffort)
|
|
}
|
|
|
|
// Restart the loop
|
|
i = -1
|
|
continue
|
|
}
|
|
}
|
|
|
|
if segmentEfforts[i] > maxAllowedEffort {
|
|
// This segment has too much effort, try to split it
|
|
startIdx := 0
|
|
if i > 0 {
|
|
startIdx = uniqueCuts[i-1]
|
|
}
|
|
endIdx := uniqueCuts[i]
|
|
|
|
// Calculate how many splits we need based on the effort
|
|
// For segments with very high effort, be more aggressive in splitting
|
|
numSplits := 0
|
|
if segmentEfforts[i] > targetEffortPerDay*1.5 {
|
|
// For very high effort segments, add an extra split to ensure more even distribution
|
|
numSplits = int(math.Ceil(segmentEfforts[i]/targetEffortPerDay)) + 1
|
|
} else {
|
|
numSplits = int(math.Ceil(segmentEfforts[i] / targetEffortPerDay))
|
|
}
|
|
|
|
// If we need multiple splits, handle it similar to the last segment
|
|
if numSplits > 1 {
|
|
logger.Debug().
|
|
Int("segmentIndex", i).
|
|
Float64("segmentEffort", segmentEfforts[i]).
|
|
Float64("targetEffortPerDay", targetEffortPerDay).
|
|
Int("numSplits", numSplits).
|
|
Msg("Splitting segment into multiple parts")
|
|
|
|
// Calculate ideal effort per split
|
|
idealEffortPerSplit := segmentEfforts[i] / float64(numSplits)
|
|
|
|
// Find split points
|
|
var splitPoints []int
|
|
for j := startIdx + 1; j < endIdx; j++ {
|
|
pointEffort := cumulativeEffort[j] - cumulativeEffort[startIdx]
|
|
if pointEffort >= idealEffortPerSplit*float64(len(splitPoints)+1) {
|
|
splitPoints = append(splitPoints, j)
|
|
if len(splitPoints) >= numSplits-1 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add the split points to the cuts
|
|
for _, splitPoint := range splitPoints {
|
|
uniqueCuts = append(uniqueCuts, splitPoint)
|
|
}
|
|
} else {
|
|
// For a single split, find the best split point that balances effort
|
|
bestSplitIdx := startIdx + (endIdx-startIdx)/2
|
|
bestSplitDiff := math.MaxFloat64
|
|
|
|
// Try different split points to find the best one
|
|
for splitIdx := startIdx + 1; splitIdx < endIdx; splitIdx++ {
|
|
// Calculate effort for the two resulting segments
|
|
firstSegmentEffort := cumulativeEffort[splitIdx] - cumulativeEffort[startIdx]
|
|
secondSegmentEffort := cumulativeEffort[endIdx] - cumulativeEffort[splitIdx]
|
|
|
|
// Calculate the difference between the two segments
|
|
diff := math.Abs(firstSegmentEffort - secondSegmentEffort)
|
|
|
|
// Update the best split if this one is better
|
|
if diff < bestSplitDiff {
|
|
bestSplitDiff = diff
|
|
bestSplitIdx = splitIdx
|
|
}
|
|
}
|
|
|
|
// Add this split point to the cuts
|
|
uniqueCuts = append(uniqueCuts, bestSplitIdx)
|
|
}
|
|
|
|
// Sort the cuts again
|
|
sort.Ints(uniqueCuts)
|
|
|
|
// Recalculate segment efforts
|
|
segmentEfforts = []float64{}
|
|
for j := 0; j < len(uniqueCuts); j++ {
|
|
startIdx := 0
|
|
if j > 0 {
|
|
startIdx = uniqueCuts[j-1]
|
|
}
|
|
endIdx := uniqueCuts[j]
|
|
|
|
// Calculate effort for this segment
|
|
segmentEffort := cumulativeEffort[endIdx] - cumulativeEffort[startIdx]
|
|
segmentEfforts = append(segmentEfforts, segmentEffort)
|
|
}
|
|
|
|
// Check if we need to add more cuts
|
|
i = -1 // Restart the loop
|
|
}
|
|
}
|
|
|
|
// Then, try to merge segments with too little effort
|
|
for i := 0; i < len(segmentEfforts)-1; i++ {
|
|
if segmentEfforts[i] < minAllowedEffort {
|
|
// This segment has too little effort, try to merge it with the next segment
|
|
// But only if the next segment is not a forced stop
|
|
|
|
// Check if the next cut is a forced stop
|
|
isForced := false
|
|
for _, idx := range forcedStopIndexes {
|
|
if uniqueCuts[i] == idx {
|
|
isForced = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !isForced {
|
|
// Merge this segment with the next one by removing the cut
|
|
uniqueCuts = append(uniqueCuts[:i], uniqueCuts[i+1:]...)
|
|
|
|
// Recalculate segment efforts
|
|
segmentEfforts = []float64{}
|
|
for j := 0; j < len(uniqueCuts); j++ {
|
|
startIdx := 0
|
|
if j > 0 {
|
|
startIdx = uniqueCuts[j-1]
|
|
}
|
|
endIdx := uniqueCuts[j]
|
|
|
|
// Calculate effort for this segment
|
|
segmentEffort := cumulativeEffort[endIdx] - cumulativeEffort[startIdx]
|
|
segmentEfforts = append(segmentEfforts, segmentEffort)
|
|
}
|
|
|
|
// Check if we need to merge more segments
|
|
i = -1 // Restart the loop
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure we don't have more cuts than days
|
|
if len(uniqueCuts) > days {
|
|
// Keep the cuts with the highest effort
|
|
sort.Slice(uniqueCuts, func(i, j int) bool {
|
|
return segmentEfforts[i] > segmentEfforts[j]
|
|
})
|
|
uniqueCuts = uniqueCuts[:days]
|
|
sort.Ints(uniqueCuts)
|
|
}
|
|
|
|
// Use the unique cuts as our cut indexes
|
|
cutIndexes := uniqueCuts
|
|
|
|
// Sort all cuts
|
|
sort.Ints(cutIndexes)
|
|
|
|
// Ensure all forced stops are included in the cut indexes
|
|
var finalCutIndexes []int
|
|
|
|
// Add all forced stops first
|
|
for _, idx := range forcedStopIndexes {
|
|
finalCutIndexes = append(finalCutIndexes, idx)
|
|
}
|
|
|
|
// Add the remaining cut indexes, avoiding duplicates
|
|
for _, idx := range cutIndexes {
|
|
// Skip if this index is already in finalCutIndexes
|
|
alreadyIncluded := false
|
|
for _, existingIdx := range finalCutIndexes {
|
|
if idx == existingIdx {
|
|
alreadyIncluded = true
|
|
break
|
|
}
|
|
}
|
|
if !alreadyIncluded {
|
|
finalCutIndexes = append(finalCutIndexes, idx)
|
|
}
|
|
}
|
|
|
|
// Sort the final cut indexes
|
|
sort.Ints(finalCutIndexes)
|
|
|
|
// Log the final cut indexes
|
|
logger.Debug().Ints("finalCutIndexes", finalCutIndexes).Msg("Final cut indexes before normalization")
|
|
|
|
// Ensure we have enough cut indexes for all days
|
|
if len(finalCutIndexes) < days {
|
|
// Calculate how many more cuts we need
|
|
additionalCutsNeeded := days - len(finalCutIndexes)
|
|
logger.Debug().Int("additionalCutsNeeded", additionalCutsNeeded).Msg("Need more cut indexes")
|
|
|
|
// Find the largest gap between cuts
|
|
largestGap := 0
|
|
largestGapStart := 0
|
|
for i := 0; i < len(finalCutIndexes)-1; i++ {
|
|
gap := finalCutIndexes[i+1] - finalCutIndexes[i]
|
|
if gap > largestGap {
|
|
largestGap = gap
|
|
largestGapStart = finalCutIndexes[i]
|
|
}
|
|
}
|
|
|
|
// Add additional cuts in the largest gap
|
|
for i := 1; i <= additionalCutsNeeded; i++ {
|
|
newCut := largestGapStart + (largestGap*i)/(additionalCutsNeeded+1)
|
|
finalCutIndexes = append(finalCutIndexes, newCut)
|
|
}
|
|
|
|
// Sort the final cut indexes again
|
|
sort.Ints(finalCutIndexes)
|
|
logger.Debug().Ints("finalCutIndexes", finalCutIndexes).Msg("Final cut indexes after adding more cuts")
|
|
}
|
|
|
|
// Normalize cuts to ensure we don't exceed the specified number of days
|
|
finalCutIndexes = normalizeCutIndexes(finalCutIndexes, days, len(points))
|
|
|
|
// Log the normalized cut indexes
|
|
logger.Debug().Ints("normalizedCutIndexes", finalCutIndexes).Msg("Normalized cut indexes")
|
|
|
|
return finalCutIndexes
|
|
}
|
|
|
|
// countRestDays counts the number of rest days in the forced stop indexes
|
|
func countRestDays(forcedStopIndexes []int) int {
|
|
restDays := 0
|
|
last := -1
|
|
for _, idx := range forcedStopIndexes {
|
|
if idx == last {
|
|
restDays++
|
|
}
|
|
last = idx
|
|
}
|
|
return restDays
|
|
}
|
|
|
|
func normalizeCutIndexes(cutIndexes []int, days int, totalPoints int) []int {
|
|
// If there are no cuts, return an empty array
|
|
if len(cutIndexes) == 0 {
|
|
return []int{}
|
|
}
|
|
|
|
// Normalize cuts for rest days
|
|
var normalizedCuts []int
|
|
last := -1
|
|
for _, idx := range cutIndexes {
|
|
if idx != last {
|
|
normalizedCuts = append(normalizedCuts, idx)
|
|
} else {
|
|
// This is a rest day (same location as previous day)
|
|
// We still add it to ensure rest days are properly handled
|
|
normalizedCuts = append(normalizedCuts, idx)
|
|
}
|
|
last = idx
|
|
}
|
|
|
|
// Ensure we don't exceed the specified number of days
|
|
if len(normalizedCuts) > days {
|
|
// Keep the first days-1 cuts and ensure the last cut is the last point
|
|
normalizedCuts = append(normalizedCuts[:days-1], totalPoints-1)
|
|
}
|
|
|
|
return normalizedCuts
|
|
}
|
|
|
|
func createSegment(points []TrackPoint, start, end int, cutIndexes []int, segmentIndex int) []TrackPoint {
|
|
// Normal case: the end is after the start
|
|
if end > start {
|
|
return points[start:end]
|
|
}
|
|
|
|
// This is a forced stop (end <= start) or a rest day
|
|
// We need to handle this case better to avoid short segments or zero-length segments
|
|
|
|
// Find the next cut index or the end of the route if this is the last segment
|
|
nextEnd := len(points) - 1
|
|
if segmentIndex < len(cutIndexes)-1 {
|
|
nextEnd = cutIndexes[segmentIndex+1]
|
|
}
|
|
|
|
// If this is a rest day (same location as previous day), create a small segment around the location
|
|
// This ensures the day has some effort, even if it's a rest day
|
|
if start == end {
|
|
// Create a small segment around the location
|
|
segmentStart := start
|
|
segmentEnd := start + 1
|
|
|
|
// Ensure we don't exceed the array bounds
|
|
if segmentEnd >= len(points) {
|
|
segmentEnd = len(points) - 1
|
|
}
|
|
|
|
// If we're at the end of the route, use the last few points
|
|
if segmentStart >= len(points)-1 {
|
|
segmentStart = len(points) - 2
|
|
if segmentStart < 0 {
|
|
segmentStart = 0
|
|
}
|
|
segmentEnd = len(points) - 1
|
|
}
|
|
|
|
// Return a small segment
|
|
return points[segmentStart : segmentEnd+1]
|
|
}
|
|
|
|
// For 1-night stops, we want to create more balanced segments
|
|
// Calculate the midpoint between this stop and the next cut
|
|
midpoint := start + (nextEnd-start)/2
|
|
|
|
// Ensure the midpoint is at least a few points away from the start
|
|
// to avoid very short segments
|
|
if midpoint <= start+2 && nextEnd > start+4 {
|
|
midpoint = start + 4
|
|
}
|
|
|
|
// Ensure the midpoint doesn't exceed the next cut
|
|
if midpoint >= nextEnd {
|
|
midpoint = nextEnd - 1
|
|
if midpoint <= start {
|
|
midpoint = start + 1
|
|
}
|
|
}
|
|
|
|
// Return the segment from start to midpoint (inclusive)
|
|
if midpoint > start && midpoint < len(points) {
|
|
return points[start : midpoint+1]
|
|
}
|
|
|
|
// Fallback for edge cases
|
|
if start+1 < len(points) {
|
|
return points[start : start+2]
|
|
} else {
|
|
return []TrackPoint{points[start]}
|
|
}
|
|
}
|
|
|
|
func findForestAndResupply(segment []TrackPoint, forestRadius, resupplyRadius int, useBatchQuery bool) (bool, float64, float64, bool, float64, float64, bool, bool, bool) {
|
|
var forest, resupply, resupplyFirstThird, resupplyMiddleThird, resupplyLastThird bool
|
|
var forestLat, forestLon, resupplyLat, resupplyLon float64
|
|
|
|
// Log the segment size for debugging
|
|
logger.Debug().Int("segmentSize", len(segment)).Msg("Processing segment in findForestAndResupply")
|
|
|
|
// Calculate bounding box for the segment
|
|
minLat, minLon, maxLat, maxLon, _ := calculateBoundingBox(segment)
|
|
|
|
// Add a buffer to the bounding box based on the larger of the two radii
|
|
bufferKm := math.Max(float64(forestRadius), float64(resupplyRadius)/1000.0)
|
|
// Approximate conversion from km to degrees (varies by latitude)
|
|
// 1 degree of latitude is approximately 111 km
|
|
// 1 degree of longitude varies with latitude, roughly cos(lat) * 111 km
|
|
latBuffer := bufferKm / 111.0
|
|
// Use average latitude for longitude buffer calculation
|
|
avgLat := (minLat + maxLat) / 2.0
|
|
lonBuffer := bufferKm / (111.0 * math.Cos(avgLat*math.Pi/180.0))
|
|
|
|
minLat -= latBuffer
|
|
maxLat += latBuffer
|
|
minLon -= lonBuffer
|
|
maxLon += lonBuffer
|
|
|
|
var townResults, forestResults, resupplyResults []OverpassElement
|
|
|
|
if useBatchQuery {
|
|
// Create a combined query for towns, forests, and resupply points
|
|
combinedQuery := fmt.Sprintf(`
|
|
[out:json];
|
|
// Town/urban areas query
|
|
(
|
|
way["place"="city"](%f,%f,%f,%f);
|
|
way["place"="town"](%f,%f,%f,%f);
|
|
way["place"="village"](%f,%f,%f,%f);
|
|
way["landuse"="residential"](%f,%f,%f,%f);
|
|
way["landuse"="commercial"](%f,%f,%f,%f);
|
|
way["landuse"="industrial"](%f,%f,%f,%f);
|
|
);
|
|
out geom;
|
|
// Forest query
|
|
(
|
|
way["natural"="wood"](%f,%f,%f,%f);
|
|
way["landuse"="forest"](%f,%f,%f,%f);
|
|
);
|
|
out geom;
|
|
// Resupply query
|
|
node["shop"](%f,%f,%f,%f);
|
|
out center;
|
|
`,
|
|
// Town query parameters (6 sets)
|
|
minLat, minLon, maxLat, maxLon, minLat, minLon, maxLat, maxLon, minLat, minLon, maxLat, maxLon,
|
|
minLat, minLon, maxLat, maxLon, minLat, minLon, maxLat, maxLon, minLat, minLon, maxLat, maxLon,
|
|
// Forest query parameters (2 sets)
|
|
minLat, minLon, maxLat, maxLon, minLat, minLon, maxLat, maxLon,
|
|
// Resupply query parameters (1 set)
|
|
minLat, minLon, maxLat, maxLon)
|
|
|
|
// Execute the combined query
|
|
allResults, err := overpassQuery(combinedQuery)
|
|
|
|
// Separate results by type and tags
|
|
if err == nil {
|
|
for _, el := range allResults {
|
|
// Categorize results based on type and tags
|
|
if el.Type == "node" && el.Tags["shop"] != "" {
|
|
resupplyResults = append(resupplyResults, el)
|
|
} else if el.Type == "way" && (el.Tags["natural"] == "wood" || el.Tags["landuse"] == "forest") {
|
|
forestResults = append(forestResults, el)
|
|
} else if el.Type == "way" && (el.Tags["place"] == "city" || el.Tags["place"] == "town" ||
|
|
el.Tags["place"] == "village" || el.Tags["landuse"] == "residential" ||
|
|
el.Tags["landuse"] == "commercial" || el.Tags["landuse"] == "industrial") {
|
|
townResults = append(townResults, el)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Query for towns/urban areas in the bounding box
|
|
townQuery := fmt.Sprintf(`
|
|
[out:json];
|
|
(
|
|
way["place"="city"](%f,%f,%f,%f);
|
|
way["place"="town"](%f,%f,%f,%f);
|
|
way["place"="village"](%f,%f,%f,%f);
|
|
way["landuse"="residential"](%f,%f,%f,%f);
|
|
way["landuse"="commercial"](%f,%f,%f,%f);
|
|
way["landuse"="industrial"](%f,%f,%f,%f);
|
|
);
|
|
out geom;
|
|
`, minLat, minLon, maxLat, maxLon, minLat, minLon, maxLat, maxLon, minLat, minLon, maxLat, maxLon,
|
|
minLat, minLon, maxLat, maxLon, minLat, minLon, maxLat, maxLon, minLat, minLon, maxLat, maxLon)
|
|
|
|
var err error
|
|
townResults, err = overpassQuery(townQuery)
|
|
if err != nil {
|
|
logger.Error().Err(err).Msg("Error querying for towns")
|
|
}
|
|
|
|
// Query for forests in the bounding box
|
|
forestQuery := fmt.Sprintf(`
|
|
[out:json];
|
|
(
|
|
way["natural"="wood"](%f,%f,%f,%f);
|
|
way["landuse"="forest"](%f,%f,%f,%f);
|
|
);
|
|
out geom;
|
|
`, minLat, minLon, maxLat, maxLon, minLat, minLon, maxLat, maxLon)
|
|
|
|
forestResults, err = overpassQuery(forestQuery)
|
|
if err != nil {
|
|
logger.Error().Err(err).Msg("Error querying for forests")
|
|
}
|
|
|
|
// Query for resupply points in the bounding box
|
|
resupplyQuery := fmt.Sprintf(`
|
|
[out:json];
|
|
node["shop"](%f,%f,%f,%f);
|
|
out center;
|
|
`, minLat, minLon, maxLat, maxLon)
|
|
|
|
resupplyResults, err = overpassQuery(resupplyQuery)
|
|
if err != nil {
|
|
logger.Error().Err(err).Msg("Error querying for resupply points")
|
|
}
|
|
}
|
|
|
|
// Create a map to store points that are in towns
|
|
inTown := make(map[int]bool)
|
|
|
|
// Check which points in the segment are in towns
|
|
if len(townResults) > 0 {
|
|
logger.Debug().Int("townResults", len(townResults)).Msg("Checking points in towns")
|
|
|
|
// For large segments, sample points to reduce computation
|
|
sampledSegment := segment
|
|
if len(segment) > 1000 {
|
|
// Sample every 10th point for large segments
|
|
samplingRate := len(segment) / 100
|
|
if samplingRate < 1 {
|
|
samplingRate = 1
|
|
}
|
|
|
|
sampledSegment = make([]TrackPoint, 0, len(segment)/samplingRate+1)
|
|
for i := 0; i < len(segment); i += samplingRate {
|
|
sampledSegment = append(sampledSegment, segment[i])
|
|
inTown[i] = false // Initialize sampled points
|
|
}
|
|
|
|
logger.Debug().
|
|
Int("originalSize", len(segment)).
|
|
Int("sampledSize", len(sampledSegment)).
|
|
Int("samplingRate", samplingRate).
|
|
Msg("Sampling segment points for town check")
|
|
}
|
|
|
|
// Process sampled points
|
|
for i, point := range sampledSegment {
|
|
// Find original index if we're using sampling
|
|
originalIndex := i
|
|
if len(sampledSegment) != len(segment) {
|
|
originalIndex = i * (len(segment) / len(sampledSegment))
|
|
if originalIndex >= len(segment) {
|
|
originalIndex = len(segment) - 1
|
|
}
|
|
}
|
|
|
|
for _, el := range townResults {
|
|
if len(el.Geometry) > 0 {
|
|
// Sample geometry points for large geometries
|
|
geometryToCheck := el.Geometry
|
|
if len(el.Geometry) > 100 {
|
|
// Sample geometry points
|
|
samplingRate := len(el.Geometry) / 20
|
|
if samplingRate < 1 {
|
|
samplingRate = 1
|
|
}
|
|
|
|
geometryToCheck = make([]struct {
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
}, 0, len(el.Geometry)/samplingRate+1)
|
|
|
|
for j := 0; j < len(el.Geometry); j += samplingRate {
|
|
geometryToCheck = append(geometryToCheck, el.Geometry[j])
|
|
}
|
|
}
|
|
|
|
for _, geom := range geometryToCheck {
|
|
dist := haversine(geom.Lat, geom.Lon, point.Point.Latitude, point.Point.Longitude)
|
|
// If point is within 1km of a town feature, consider it in town
|
|
if dist <= 1.0 {
|
|
inTown[originalIndex] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if inTown[originalIndex] {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.Debug().Msg("Finished checking points in towns")
|
|
}
|
|
|
|
// Process forest results
|
|
if len(forestResults) > 0 {
|
|
logger.Debug().Int("forestResults", len(forestResults)).Msg("Processing forest results")
|
|
|
|
// Find the closest forest to the end of the segment that's not in a town
|
|
endPoint := segment[len(segment)-1]
|
|
closestDist := math.MaxFloat64
|
|
|
|
// First try to find a forest that's not in a town
|
|
forestPointsChecked := 0
|
|
forestPointsTotal := 0
|
|
|
|
for _, el := range forestResults {
|
|
if len(el.Geometry) > 0 {
|
|
forestPointsTotal += len(el.Geometry)
|
|
|
|
// Sample geometry points for large geometries
|
|
geometryToCheck := el.Geometry
|
|
if len(el.Geometry) > 100 {
|
|
// Sample geometry points
|
|
samplingRate := len(el.Geometry) / 20
|
|
if samplingRate < 1 {
|
|
samplingRate = 1
|
|
}
|
|
|
|
geometryToCheck = make([]struct {
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
}, 0, len(el.Geometry)/samplingRate+1)
|
|
|
|
for j := 0; j < len(el.Geometry); j += samplingRate {
|
|
geometryToCheck = append(geometryToCheck, el.Geometry[j])
|
|
}
|
|
|
|
logger.Debug().
|
|
Int("originalGeometrySize", len(el.Geometry)).
|
|
Int("sampledGeometrySize", len(geometryToCheck)).
|
|
Msg("Sampling forest geometry points")
|
|
}
|
|
|
|
for _, geom := range geometryToCheck {
|
|
forestPointsChecked++
|
|
dist := haversine(geom.Lat, geom.Lon, endPoint.Point.Latitude, endPoint.Point.Longitude)
|
|
|
|
// Only check if forest point is in town if it's within the forest radius
|
|
if dist <= float64(forestRadius) {
|
|
// Check if this forest point is in a town
|
|
inTownArea := false
|
|
if len(townResults) > 0 {
|
|
// Sample town results for large datasets
|
|
townResultsToCheck := townResults
|
|
if len(townResults) > 50 {
|
|
// Sample town results
|
|
samplingRate := len(townResults) / 10
|
|
if samplingRate < 1 {
|
|
samplingRate = 1
|
|
}
|
|
|
|
townResultsToCheck = make([]OverpassElement, 0, len(townResults)/samplingRate+1)
|
|
for j := 0; j < len(townResults); j += samplingRate {
|
|
townResultsToCheck = append(townResultsToCheck, townResults[j])
|
|
}
|
|
|
|
logger.Debug().
|
|
Int("originalTownResultsSize", len(townResults)).
|
|
Int("sampledTownResultsSize", len(townResultsToCheck)).
|
|
Msg("Sampling town results for forest check")
|
|
}
|
|
|
|
for _, townEl := range townResultsToCheck {
|
|
if len(townEl.Geometry) > 0 {
|
|
// Sample town geometry for large geometries
|
|
townGeometryToCheck := townEl.Geometry
|
|
if len(townEl.Geometry) > 100 {
|
|
// Sample town geometry points
|
|
samplingRate := len(townEl.Geometry) / 20
|
|
if samplingRate < 1 {
|
|
samplingRate = 1
|
|
}
|
|
|
|
townGeometryToCheck = make([]struct {
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
}, 0, len(townEl.Geometry)/samplingRate+1)
|
|
|
|
for j := 0; j < len(townEl.Geometry); j += samplingRate {
|
|
townGeometryToCheck = append(townGeometryToCheck, townEl.Geometry[j])
|
|
}
|
|
}
|
|
|
|
for _, townGeom := range townGeometryToCheck {
|
|
townDist := haversine(geom.Lat, geom.Lon, townGeom.Lat, townGeom.Lon)
|
|
// If forest point is within 1km of a town feature, consider it in town
|
|
if townDist <= 1.0 {
|
|
inTownArea = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if inTownArea {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prefer forests that are not in towns
|
|
if !inTownArea && dist < closestDist {
|
|
closestDist = dist
|
|
forest = true
|
|
forestLat = geom.Lat
|
|
forestLon = geom.Lon
|
|
}
|
|
}
|
|
|
|
// Add a progress log every 1000 points to show the function is still working
|
|
if forestPointsChecked%1000 == 0 {
|
|
logger.Debug().
|
|
Int("forestPointsChecked", forestPointsChecked).
|
|
Int("forestPointsTotal", forestPointsTotal).
|
|
Msg("Forest points processing progress")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.Debug().
|
|
Int("forestPointsChecked", forestPointsChecked).
|
|
Int("forestPointsTotal", forestPointsTotal).
|
|
Bool("forestFound", forest).
|
|
Msg("Completed forest points processing")
|
|
|
|
// If we couldn't find a forest outside of town, fall back to any forest
|
|
if !forest {
|
|
logger.Debug().Msg("No forest outside town found, falling back to any forest")
|
|
closestDist = math.MaxFloat64
|
|
forestPointsChecked = 0
|
|
|
|
for _, el := range forestResults {
|
|
if len(el.Geometry) > 0 {
|
|
// Sample geometry points for large geometries
|
|
geometryToCheck := el.Geometry
|
|
if len(el.Geometry) > 100 {
|
|
// Sample geometry points
|
|
samplingRate := len(el.Geometry) / 20
|
|
if samplingRate < 1 {
|
|
samplingRate = 1
|
|
}
|
|
|
|
geometryToCheck = make([]struct {
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
}, 0, len(el.Geometry)/samplingRate+1)
|
|
|
|
for j := 0; j < len(el.Geometry); j += samplingRate {
|
|
geometryToCheck = append(geometryToCheck, el.Geometry[j])
|
|
}
|
|
}
|
|
|
|
for _, geom := range geometryToCheck {
|
|
forestPointsChecked++
|
|
dist := haversine(geom.Lat, geom.Lon, endPoint.Point.Latitude, endPoint.Point.Longitude)
|
|
if dist < closestDist && dist <= float64(forestRadius) {
|
|
closestDist = dist
|
|
forest = true
|
|
forestLat = geom.Lat
|
|
forestLon = geom.Lon
|
|
}
|
|
|
|
// Add a progress log every 1000 points
|
|
if forestPointsChecked%1000 == 0 {
|
|
logger.Debug().
|
|
Int("fallbackForestPointsChecked", forestPointsChecked).
|
|
Msg("Fallback forest points processing progress")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.Debug().
|
|
Int("fallbackForestPointsChecked", forestPointsChecked).
|
|
Bool("forestFound", forest).
|
|
Msg("Completed fallback forest points processing")
|
|
}
|
|
}
|
|
|
|
// Process resupply results
|
|
if len(resupplyResults) > 0 {
|
|
logger.Debug().Int("resupplyResults", len(resupplyResults)).Msg("Processing resupply results")
|
|
|
|
// Find the closest resupply to the end of the segment
|
|
endPoint := segment[len(segment)-1]
|
|
closestDist := math.MaxFloat64
|
|
|
|
for _, el := range resupplyResults {
|
|
dist := haversine(el.Lat, el.Lon, endPoint.Point.Latitude, endPoint.Point.Longitude)
|
|
if dist < closestDist && dist <= float64(resupplyRadius)/1000.0 {
|
|
closestDist = dist
|
|
resupply = true
|
|
resupplyLat = el.Lat
|
|
resupplyLon = el.Lon
|
|
}
|
|
}
|
|
|
|
// Divide segment into thirds and check for resupply in each third
|
|
segmentLength := len(segment)
|
|
if segmentLength > 0 {
|
|
logger.Debug().Int("segmentLength", segmentLength).Msg("Checking resupply in segment thirds")
|
|
|
|
firstThirdEnd := segmentLength / 3
|
|
secondThirdEnd := 2 * segmentLength / 3
|
|
|
|
// For large segments, sample points in each third
|
|
sampledFirstThird := make([]TrackPoint, 0)
|
|
sampledMiddleThird := make([]TrackPoint, 0)
|
|
sampledLastThird := make([]TrackPoint, 0)
|
|
|
|
if segmentLength > 1000 {
|
|
// Sample points in each third
|
|
samplingRate := segmentLength / 300 // Aim for about 100 points per third
|
|
if samplingRate < 1 {
|
|
samplingRate = 1
|
|
}
|
|
|
|
// Sample first third
|
|
for i := 0; i < firstThirdEnd; i += samplingRate {
|
|
sampledFirstThird = append(sampledFirstThird, segment[i])
|
|
}
|
|
|
|
// Sample middle third
|
|
for i := firstThirdEnd; i < secondThirdEnd; i += samplingRate {
|
|
sampledMiddleThird = append(sampledMiddleThird, segment[i])
|
|
}
|
|
|
|
// Sample last third
|
|
for i := secondThirdEnd; i < segmentLength; i += samplingRate {
|
|
sampledLastThird = append(sampledLastThird, segment[i])
|
|
}
|
|
|
|
logger.Debug().
|
|
Int("firstThirdOriginal", firstThirdEnd).
|
|
Int("firstThirdSampled", len(sampledFirstThird)).
|
|
Int("middleThirdOriginal", secondThirdEnd-firstThirdEnd).
|
|
Int("middleThirdSampled", len(sampledMiddleThird)).
|
|
Int("lastThirdOriginal", segmentLength-secondThirdEnd).
|
|
Int("lastThirdSampled", len(sampledLastThird)).
|
|
Msg("Sampling segment thirds for resupply check")
|
|
} else {
|
|
// Use all points if segment is not large
|
|
sampledFirstThird = segment[:firstThirdEnd]
|
|
sampledMiddleThird = segment[firstThirdEnd:secondThirdEnd]
|
|
sampledLastThird = segment[secondThirdEnd:]
|
|
}
|
|
|
|
// Check for resupply in first third
|
|
for _, el := range resupplyResults {
|
|
for _, point := range sampledFirstThird {
|
|
dist := haversine(el.Lat, el.Lon, point.Point.Latitude, point.Point.Longitude)
|
|
if dist <= float64(resupplyRadius)/1000.0 {
|
|
resupplyFirstThird = true
|
|
break
|
|
}
|
|
}
|
|
if resupplyFirstThird {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Check for resupply in middle third
|
|
for _, el := range resupplyResults {
|
|
for _, point := range sampledMiddleThird {
|
|
dist := haversine(el.Lat, el.Lon, point.Point.Latitude, point.Point.Longitude)
|
|
if dist <= float64(resupplyRadius)/1000.0 {
|
|
resupplyMiddleThird = true
|
|
break
|
|
}
|
|
}
|
|
if resupplyMiddleThird {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Check for resupply in the last third
|
|
for _, el := range resupplyResults {
|
|
for _, point := range sampledLastThird {
|
|
dist := haversine(el.Lat, el.Lon, point.Point.Latitude, point.Point.Longitude)
|
|
if dist <= float64(resupplyRadius)/1000.0 {
|
|
resupplyLastThird = true
|
|
break
|
|
}
|
|
}
|
|
if resupplyLastThird {
|
|
break
|
|
}
|
|
}
|
|
|
|
logger.Debug().
|
|
Bool("resupplyFirstThird", resupplyFirstThird).
|
|
Bool("resupplyMiddleThird", resupplyMiddleThird).
|
|
Bool("resupplyLastThird", resupplyLastThird).
|
|
Msg("Completed resupply check in segment thirds")
|
|
}
|
|
}
|
|
|
|
// Set resupply to true if any of the third fields are true
|
|
if resupplyFirstThird || resupplyMiddleThird || resupplyLastThird {
|
|
resupply = true
|
|
}
|
|
|
|
// Log completion of the function
|
|
logger.Debug().
|
|
Bool("forest", forest).
|
|
Bool("resupply", resupply).
|
|
Bool("resupplyFirstThird", resupplyFirstThird).
|
|
Bool("resupplyMiddleThird", resupplyMiddleThird).
|
|
Bool("resupplyLastThird", resupplyLastThird).
|
|
Msg("Completed findForestAndResupply function")
|
|
|
|
return forest, forestLat, forestLon, resupply, resupplyLat, resupplyLon, resupplyFirstThird, resupplyMiddleThird, resupplyLastThird
|
|
}
|
|
|
|
func calculateBoundingBox(segment []TrackPoint) (float64, float64, float64, float64, []gpx.GPXPoint) {
|
|
minLat, maxLat := 90.0, -90.0
|
|
minLon, maxLon := 180.0, -180.0
|
|
var gpxPts []gpx.GPXPoint
|
|
|
|
for _, tp := range segment {
|
|
gpxPts = append(gpxPts, tp.Point)
|
|
lat := tp.Point.Latitude
|
|
lon := tp.Point.Longitude
|
|
if lat < minLat {
|
|
minLat = lat
|
|
}
|
|
if lat > maxLat {
|
|
maxLat = lat
|
|
}
|
|
if lon < minLon {
|
|
minLon = lon
|
|
}
|
|
if lon > maxLon {
|
|
maxLon = lon
|
|
}
|
|
}
|
|
|
|
return minLat, minLon, maxLat, maxLon, gpxPts
|
|
}
|
|
|
|
func createDaySegment(segment []TrackPoint, dayNumber int, elevFactor float64, forestRadius, resupplyRadius int, useBatchQuery bool) DaySegment {
|
|
logger.Debug().
|
|
Int("dayNumber", dayNumber).
|
|
Int("segmentSize", len(segment)).
|
|
Int("forestRadius", forestRadius).
|
|
Int("resupplyRadius", resupplyRadius).
|
|
Bool("useBatchQuery", useBatchQuery).
|
|
Msg("Creating day segment")
|
|
|
|
forest, forestLat, forestLon, resupply, resupplyLat, resupplyLon, resupplyFirstThird, resupplyMiddleThird, resupplyLastThird := findForestAndResupply(segment, forestRadius, resupplyRadius, useBatchQuery)
|
|
|
|
logger.Debug().
|
|
Int("dayNumber", dayNumber).
|
|
Bool("forest", forest).
|
|
Bool("resupply", resupply).
|
|
Msg("Forest and resupply check completed")
|
|
|
|
dist := segment[len(segment)-1].Distance - segment[0].Distance
|
|
elev := segment[len(segment)-1].Elevation - segment[0].Elevation
|
|
effort := dist + (elev / 100.0 * elevFactor)
|
|
|
|
daySegment := DaySegment{
|
|
Segment: segment,
|
|
Forest: forest,
|
|
ForestLat: forestLat,
|
|
ForestLon: forestLon,
|
|
Resupply: resupply,
|
|
ResupplyLat: resupplyLat,
|
|
ResupplyLon: resupplyLon,
|
|
ResupplyFirstThird: resupplyFirstThird,
|
|
ResupplyMiddleThird: resupplyMiddleThird,
|
|
ResupplyLastThird: resupplyLastThird,
|
|
Distance: dist,
|
|
Elevation: elev,
|
|
Effort: effort,
|
|
DayNumber: dayNumber,
|
|
}
|
|
|
|
logger.Debug().
|
|
Int("dayNumber", dayNumber).
|
|
Float64("distance", dist).
|
|
Float64("elevation", elev).
|
|
Float64("effort", effort).
|
|
Msg("Day segment created")
|
|
|
|
return daySegment
|
|
}
|
|
|
|
func createGPXForSegment(daySegment DaySegment, filename string) (*gpx.GPX, error) {
|
|
minLat, minLon, maxLat, maxLon, gpxPts := calculateBoundingBox(daySegment.Segment)
|
|
|
|
seg := gpx.GPXTrackSegment{Points: gpxPts}
|
|
track := gpx.GPXTrack{Name: filename, Segments: []gpx.GPXTrackSegment{seg}}
|
|
g := gpx.GPX{Tracks: []gpx.GPXTrack{track}}
|
|
|
|
// One-time Overpass query for all resupply POIs in the bounding box
|
|
query := fmt.Sprintf(`
|
|
[out:json];
|
|
node["shop"]["name"~"^(ICA|Coop|Willys|Hemk.*|Lidl|City Gross|Rema 1000|Kiwi|Meny|Extra|Spar)$", i](%f,%f,%f,%f);
|
|
out center;`, minLat, minLon, maxLat, maxLon)
|
|
results, err := overpassQuery(query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resupplySeen := make(map[string]bool)
|
|
for _, el := range results {
|
|
// Check distance to the nearest trackpoint
|
|
minDist := 1e6
|
|
for _, pt := range daySegment.Segment {
|
|
dist := haversine(el.Lat, el.Lon, pt.Point.Latitude, pt.Point.Longitude)
|
|
if dist < minDist {
|
|
minDist = dist
|
|
}
|
|
}
|
|
if minDist <= 1.0 { // keep only POIs within 1 km of route
|
|
key := fmt.Sprintf("%.5f,%.5f", el.Lat, el.Lon)
|
|
if !resupplySeen[key] {
|
|
resupplySeen[key] = true
|
|
g.Waypoints = append(g.Waypoints, gpx.GPXPoint{
|
|
Point: gpx.Point{
|
|
Latitude: el.Lat,
|
|
Longitude: el.Lon,
|
|
},
|
|
Name: "Resupply",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if daySegment.Forest {
|
|
wp := gpx.GPXPoint{
|
|
Point: gpx.Point{
|
|
Latitude: daySegment.ForestLat,
|
|
Longitude: daySegment.ForestLon,
|
|
},
|
|
Name: "Sleep Spot",
|
|
}
|
|
g.Waypoints = append(g.Waypoints, wp)
|
|
}
|
|
|
|
if daySegment.Resupply {
|
|
wp := gpx.GPXPoint{
|
|
Point: gpx.Point{
|
|
Latitude: daySegment.ResupplyLat,
|
|
Longitude: daySegment.ResupplyLon,
|
|
},
|
|
Name: "Resupply",
|
|
}
|
|
g.Waypoints = append(g.Waypoints, wp)
|
|
}
|
|
|
|
return &g, nil
|
|
}
|
|
|
|
func saveGPXFile(g *gpx.GPX, filename string) error {
|
|
xml, err := g.ToXml(gpx.ToXmlParams{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filename, xml, 0644)
|
|
}
|
|
|
|
// printBanner prints a nice banner for the application
|
|
func printBanner() {
|
|
bannerColor := color.New(color.FgHiCyan, color.Bold)
|
|
banner := `
|
|
____ _ _ ____ _ ____ _
|
|
| _ \(_) ___ _ _ _| | ___ | _ \ ___ _ _| |_ ___| _ \| | __ _ _ __ _ __ ___ _ __
|
|
| |_) | |/ __| | | |/ / |/ _ \ | |_) / _ \| | | | __/ _ \ |_) | |/ _' | '_ \| '_ \ / _ \ '__|
|
|
| _ <| | (__| |_| / /| | __/ | _ < (_) | |_| | || __/ __/| | (_| | | | | | | | __/ |
|
|
|_| \_\_|\___|\__, /___|\___| |_| \_\___/ \__,_|\__\___|_| |_|\__,_|_| |_|_| |_|\___|_|
|
|
|___/
|
|
`
|
|
fmt.Println(bannerColor.Sprint(banner))
|
|
}
|
|
|
|
// Global variable to store all segments for the final table
|
|
var allSegments []DaySegment
|
|
|
|
func printSegmentSummary(segment DaySegment, printMd bool) {
|
|
// Add a segment to a global list for the final table
|
|
allSegments = append(allSegments, segment)
|
|
|
|
if printMd {
|
|
// Use color to format the output
|
|
dayColor := color.New(color.FgHiWhite, color.Bold)
|
|
distanceColor := color.New(color.FgCyan)
|
|
elevationColor := color.New(color.FgYellow)
|
|
effortColor := color.New(color.FgMagenta)
|
|
resupplyColor := color.New(color.FgGreen)
|
|
forestColor := color.New(color.FgGreen)
|
|
|
|
// Format the values
|
|
dayStr := fmt.Sprintf("Day %2d", segment.DayNumber)
|
|
distanceStr := fmt.Sprintf("%.1f km", segment.Distance)
|
|
elevationStr := fmt.Sprintf("%.0f m", segment.Elevation)
|
|
effortStr := fmt.Sprintf("%.2f", segment.Effort)
|
|
|
|
// Format resupply and forest with symbols
|
|
resupplyStr := "✓"
|
|
if !segment.Resupply {
|
|
resupplyStr = "✗"
|
|
resupplyColor = color.New(color.FgRed)
|
|
}
|
|
|
|
forestStr := "✓"
|
|
if !segment.Forest {
|
|
forestStr = "✗"
|
|
forestColor = color.New(color.FgRed)
|
|
}
|
|
|
|
// Print the formatted output
|
|
fmt.Printf("| %s | %s | %s | %s effort | Resupply: %s | Forest: %s |\n",
|
|
dayColor.Sprint(dayStr),
|
|
distanceColor.Sprint(distanceStr),
|
|
elevationColor.Sprint(elevationStr),
|
|
effortColor.Sprint(effortStr),
|
|
resupplyColor.Sprint(resupplyStr),
|
|
forestColor.Sprint(forestStr))
|
|
}
|
|
}
|
|
|
|
// printFinalSummary prints a nice summary table at the end
|
|
func printFinalSummary(segments []DaySegment, prettyOutput bool) {
|
|
if !prettyOutput {
|
|
return
|
|
}
|
|
|
|
// Calculate summary statistics
|
|
totalDistance := 0.0
|
|
totalElevation := 0.0
|
|
totalEffort := 0.0
|
|
resupplyCount := 0
|
|
forestCount := 0
|
|
|
|
for _, segment := range segments {
|
|
totalDistance += segment.Distance
|
|
totalElevation += segment.Elevation
|
|
totalEffort += segment.Effort
|
|
if segment.Resupply {
|
|
resupplyCount++
|
|
}
|
|
if segment.Forest {
|
|
forestCount++
|
|
}
|
|
}
|
|
|
|
avgDailyDistance := totalDistance / float64(len(segments))
|
|
|
|
// Create a new table
|
|
table := tablewriter.NewWriter(os.Stdout)
|
|
table.SetHeader([]string{"Day", "Distance", "Elevation", "Effort", "Resupply", "Resupply Locations", "Forest"})
|
|
table.SetBorder(true)
|
|
table.SetRowLine(true)
|
|
table.SetCenterSeparator("|")
|
|
table.SetColumnSeparator("|")
|
|
table.SetRowSeparator("-")
|
|
table.SetHeaderColor(
|
|
tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiWhiteColor},
|
|
tablewriter.Colors{tablewriter.Bold, tablewriter.FgCyanColor},
|
|
tablewriter.Colors{tablewriter.Bold, tablewriter.FgYellowColor},
|
|
tablewriter.Colors{tablewriter.Bold, tablewriter.FgMagentaColor},
|
|
tablewriter.Colors{tablewriter.Bold, tablewriter.FgGreenColor},
|
|
tablewriter.Colors{tablewriter.Bold, tablewriter.FgGreenColor},
|
|
tablewriter.Colors{tablewriter.Bold, tablewriter.FgGreenColor},
|
|
)
|
|
|
|
// Add data to the table
|
|
for _, segment := range segments {
|
|
resupplyStr := "✓"
|
|
if !segment.Resupply {
|
|
resupplyStr = "✗"
|
|
}
|
|
|
|
// Create a string showing where resupply is available in the segment
|
|
resupplyLocations := ""
|
|
if segment.ResupplyFirstThird {
|
|
resupplyLocations += "Start "
|
|
}
|
|
if segment.ResupplyMiddleThird {
|
|
resupplyLocations += "Mid "
|
|
}
|
|
if segment.ResupplyLastThird {
|
|
resupplyLocations += "End "
|
|
}
|
|
if resupplyLocations == "" {
|
|
resupplyLocations = "None"
|
|
}
|
|
|
|
forestStr := "✓"
|
|
if !segment.Forest {
|
|
forestStr = "✗"
|
|
}
|
|
|
|
table.Append([]string{
|
|
fmt.Sprintf("Day %d", segment.DayNumber),
|
|
fmt.Sprintf("%.1f km", segment.Distance),
|
|
fmt.Sprintf("%.0f m", segment.Elevation),
|
|
fmt.Sprintf("%.2f", segment.Effort),
|
|
resupplyStr,
|
|
resupplyLocations,
|
|
forestStr,
|
|
})
|
|
}
|
|
|
|
// Print the table
|
|
fmt.Println("\n🚲 Route Summary:")
|
|
table.Render()
|
|
|
|
// Print additional summary statistics
|
|
summaryColor := color.New(color.FgHiCyan, color.Bold)
|
|
fmt.Println("\n📊 Trip Statistics:")
|
|
fmt.Printf(" %s: %.1f km (%.1f km/day avg)\n", summaryColor.Sprint("Total Distance"), totalDistance, avgDailyDistance)
|
|
fmt.Printf(" %s: %.0f m\n", summaryColor.Sprint("Total Elevation"), totalElevation)
|
|
fmt.Printf(" %s: %d of %d days\n", summaryColor.Sprint("Resupply Available"), resupplyCount, len(segments))
|
|
fmt.Printf(" %s: %d of %d days\n", summaryColor.Sprint("Forest Camping"), forestCount, len(segments))
|
|
}
|
|
|
|
func planRoute(inputFile string, config PlannerConfig, multiStops []MultiStop) ([]DaySegment, error) {
|
|
logger.Info().
|
|
Str("inputFile", inputFile).
|
|
Int("days", config.Days).
|
|
Float64("elevFactor", config.ElevFactor).
|
|
Int("forestRadius", config.ForestRadius).
|
|
Int("resupplyRadius", config.ResupplyRadius).
|
|
Int("multiStops", len(multiStops)).
|
|
Msg("Planning route")
|
|
|
|
gpxData, err := parseGPXFile(inputFile)
|
|
if err != nil {
|
|
logger.Error().Err(err).Str("inputFile", inputFile).Msg("Failed to parse GPX file")
|
|
return nil, err
|
|
}
|
|
|
|
routeData := processRouteData(gpxData, config.ElevFactor)
|
|
|
|
logger.Debug().Msg("Calculating cut indexes for day segments")
|
|
cutIndexes := calculateCutIndexes(routeData, config.Days, config.ElevFactor, multiStops)
|
|
logger.Debug().Ints("cutIndexes", cutIndexes).Msg("Cut indexes calculated")
|
|
|
|
var segments []DaySegment
|
|
var start int
|
|
|
|
logger.Debug().Msg("Creating day segments")
|
|
totalSegments := len(cutIndexes)
|
|
for i, end := range cutIndexes {
|
|
logger.Debug().
|
|
Int("day", i+1).
|
|
Int("totalDays", totalSegments).
|
|
Int("start", start).
|
|
Int("end", end).
|
|
Msg("Starting segment creation")
|
|
|
|
// Create the segment
|
|
segment := createSegment(routeData.Points, start, end, cutIndexes, i)
|
|
logger.Debug().
|
|
Int("day", i+1).
|
|
Int("segmentSize", len(segment)).
|
|
Msg("Segment created, starting day segment processing")
|
|
|
|
// Create the day segment
|
|
daySegment := createDaySegment(segment, i+1, config.ElevFactor, config.ForestRadius, config.ResupplyRadius, config.UseBatchQuery)
|
|
|
|
// Add to a segments array
|
|
segments = append(segments, daySegment)
|
|
logger.Debug().
|
|
Int("day", i+1).
|
|
Int("totalDays", totalSegments).
|
|
Float64("distance", daySegment.Distance).
|
|
Bool("forest", daySegment.Forest).
|
|
Bool("resupply", daySegment.Resupply).
|
|
Msg("Day segment added to plan")
|
|
|
|
start = end
|
|
}
|
|
|
|
logger.Debug().
|
|
Int("totalSegments", len(segments)).
|
|
Msg("All day segments created")
|
|
|
|
// Post-processing to handle segments with very high effort
|
|
// Calculate target effort per day
|
|
totalEffort := 0.0
|
|
for _, seg := range segments {
|
|
totalEffort += seg.Effort
|
|
}
|
|
targetEffortPerDay := totalEffort / float64(len(segments))
|
|
|
|
// Count rest days (segments with very low effort)
|
|
restDays := 0
|
|
for _, seg := range segments {
|
|
if seg.Effort < targetEffortPerDay*0.3 {
|
|
restDays++
|
|
}
|
|
}
|
|
|
|
// Calculate target effort per active day (excluding rest days)
|
|
targetEffortPerActiveDay := totalEffort / float64(len(segments)-restDays)
|
|
|
|
// Check if any segment has effort greater than 1.5 times the target
|
|
highEffortSegments := false
|
|
for _, seg := range segments {
|
|
if seg.Effort > targetEffortPerActiveDay*1.5 {
|
|
highEffortSegments = true
|
|
logger.Debug().
|
|
Int("dayNumber", seg.DayNumber).
|
|
Float64("effort", seg.Effort).
|
|
Float64("targetEffortPerActiveDay", targetEffortPerActiveDay).
|
|
Float64("ratio", seg.Effort/targetEffortPerActiveDay).
|
|
Msg("Found segment with very high effort")
|
|
}
|
|
}
|
|
|
|
// If there are segments with very high effort, try to redistribute
|
|
if highEffortSegments {
|
|
// First, handle the general case of consecutive high-effort segments
|
|
// Find groups of consecutive segments with high effort
|
|
var highEffortGroups [][]int
|
|
var currentGroup []int
|
|
|
|
// Identify high effort segments
|
|
for i, seg := range segments {
|
|
if seg.Effort > targetEffortPerActiveDay*1.4 {
|
|
// Start a new group or add to existing
|
|
if len(currentGroup) == 0 || currentGroup[len(currentGroup)-1] == i-1 {
|
|
currentGroup = append(currentGroup, i)
|
|
} else {
|
|
// Non-consecutive segment, start a new group
|
|
if len(currentGroup) > 0 {
|
|
highEffortGroups = append(highEffortGroups, currentGroup)
|
|
currentGroup = []int{i}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add the last group if it exists
|
|
if len(currentGroup) > 0 {
|
|
highEffortGroups = append(highEffortGroups, currentGroup)
|
|
}
|
|
|
|
// Process each group of high effort segments
|
|
for _, group := range highEffortGroups {
|
|
// Only process groups with at least 2 segments
|
|
if len(group) >= 2 {
|
|
// Calculate total effort for the group
|
|
totalGroupEffort := 0.0
|
|
for _, idx := range group {
|
|
totalGroupEffort += segments[idx].Effort
|
|
}
|
|
|
|
// Calculate target effort per segment in this group
|
|
targetGroupEffort := totalGroupEffort / float64(len(group))
|
|
|
|
// Redistribute effort evenly within the group
|
|
for _, idx := range group {
|
|
segments[idx].Effort = targetGroupEffort
|
|
segments[idx].Distance = targetGroupEffort * 0.8 // Approximate distance based on effort
|
|
}
|
|
|
|
logger.Debug().
|
|
Ints("groupIndices", group).
|
|
Float64("totalGroupEffort", totalGroupEffort).
|
|
Float64("targetGroupEffort", targetGroupEffort).
|
|
Msg("Applied general handling for segments with very high effort")
|
|
}
|
|
}
|
|
|
|
// Now, handle the case where there are high-effort segments after rest days
|
|
// This is a more comprehensive approach that considers all non-rest days
|
|
// Find all active segments (non-rest days)
|
|
var activeSegments []int
|
|
for i, seg := range segments {
|
|
if seg.Effort >= targetEffortPerDay*0.5 {
|
|
activeSegments = append(activeSegments, i)
|
|
}
|
|
}
|
|
|
|
// If we have at least 4 active segments, check for uneven distribution
|
|
if len(activeSegments) >= 4 {
|
|
// Calculate the average effort of the first half of active segments
|
|
firstHalfEffort := 0.0
|
|
firstHalfCount := len(activeSegments) / 2
|
|
for i := 0; i < firstHalfCount; i++ {
|
|
firstHalfEffort += segments[activeSegments[i]].Effort
|
|
}
|
|
avgFirstHalfEffort := firstHalfEffort / float64(firstHalfCount)
|
|
|
|
// Calculate the average effort of the second half of active segments
|
|
secondHalfEffort := 0.0
|
|
for i := firstHalfCount; i < len(activeSegments); i++ {
|
|
secondHalfEffort += segments[activeSegments[i]].Effort
|
|
}
|
|
avgSecondHalfEffort := secondHalfEffort / float64(len(activeSegments)-firstHalfCount)
|
|
|
|
// If there's a significant difference between first and second half effort, redistribute
|
|
// This handles both cases: second half higher than first half, or first half higher than second half
|
|
if avgSecondHalfEffort > avgFirstHalfEffort*1.2 || avgFirstHalfEffort > avgSecondHalfEffort*1.2 {
|
|
logger.Debug().
|
|
Float64("avgFirstHalfEffort", avgFirstHalfEffort).
|
|
Float64("avgSecondHalfEffort", avgSecondHalfEffort).
|
|
Float64("ratio", avgSecondHalfEffort/avgFirstHalfEffort).
|
|
Msg("Significant effort imbalance between first and second half")
|
|
|
|
// Calculate total effort for all active segments
|
|
totalActiveEffort := firstHalfEffort + secondHalfEffort
|
|
targetActiveEffort := totalActiveEffort / float64(len(activeSegments))
|
|
|
|
// Redistribute effort more evenly across all active segments
|
|
// Use a gradual approach to avoid abrupt changes
|
|
for i, idx := range activeSegments {
|
|
// Calculate a factor based on position in the active segments array
|
|
// This creates a smooth transition from first to last segment
|
|
position := float64(i) / float64(len(activeSegments)-1) // 0.0 to 1.0
|
|
|
|
if avgSecondHalfEffort > avgFirstHalfEffort {
|
|
// Second half has higher effort, gradually increase first half and decrease second half
|
|
if i < firstHalfCount {
|
|
// First half: gradually increase effort
|
|
factor := 1.0 + (0.2 * position) // 1.0 to ~1.1
|
|
segments[idx].Effort = avgFirstHalfEffort * factor
|
|
if segments[idx].Effort > targetActiveEffort {
|
|
segments[idx].Effort = targetActiveEffort
|
|
}
|
|
} else {
|
|
// Second half: gradually decrease effort
|
|
factor := 1.0 - (0.2 * (1.0 - position)) // ~0.9 to 1.0
|
|
segments[idx].Effort = avgSecondHalfEffort * factor
|
|
if segments[idx].Effort < targetActiveEffort {
|
|
segments[idx].Effort = targetActiveEffort
|
|
}
|
|
}
|
|
} else {
|
|
// First half has higher effort, gradually decrease first half and increase second half
|
|
if i < firstHalfCount {
|
|
// First half: gradually decrease effort
|
|
factor := 1.0 - (0.2 * (1.0 - position)) // ~0.8 to 1.0
|
|
segments[idx].Effort = avgFirstHalfEffort * factor
|
|
if segments[idx].Effort < targetActiveEffort {
|
|
segments[idx].Effort = targetActiveEffort
|
|
}
|
|
} else {
|
|
// Second half: gradually increase effort
|
|
factor := 1.0 + (0.2 * position) // 1.0 to ~1.2
|
|
segments[idx].Effort = avgSecondHalfEffort * factor
|
|
if segments[idx].Effort > targetActiveEffort {
|
|
segments[idx].Effort = targetActiveEffort
|
|
}
|
|
}
|
|
}
|
|
|
|
segments[idx].Distance = segments[idx].Effort * 0.8 // Approximate distance based on effort
|
|
}
|
|
|
|
logger.Debug().
|
|
Float64("targetActiveEffort", targetActiveEffort).
|
|
Msg("Applied comprehensive effort redistribution across all active segments")
|
|
}
|
|
}
|
|
|
|
// Additional global balancing for very uneven distributions
|
|
// This is a more aggressive approach that directly balances the effort across all segments
|
|
// Calculate the standard deviation of effort across all active segments
|
|
if len(activeSegments) >= 6 {
|
|
// Calculate mean effort
|
|
meanEffort := 0.0
|
|
for _, idx := range activeSegments {
|
|
meanEffort += segments[idx].Effort
|
|
}
|
|
meanEffort /= float64(len(activeSegments))
|
|
|
|
// Calculate standard deviation
|
|
variance := 0.0
|
|
for _, idx := range activeSegments {
|
|
diff := segments[idx].Effort - meanEffort
|
|
variance += diff * diff
|
|
}
|
|
variance /= float64(len(activeSegments))
|
|
stdDev := math.Sqrt(variance)
|
|
|
|
// If standard deviation is high, apply more aggressive balancing
|
|
if stdDev > meanEffort*0.25 {
|
|
logger.Debug().
|
|
Float64("meanEffort", meanEffort).
|
|
Float64("stdDev", stdDev).
|
|
Float64("ratio", stdDev/meanEffort).
|
|
Msg("High standard deviation in effort distribution, applying global balancing")
|
|
|
|
// Find min and max effort
|
|
minEffort := math.MaxFloat64
|
|
maxEffort := 0.0
|
|
for _, idx := range activeSegments {
|
|
if segments[idx].Effort < minEffort {
|
|
minEffort = segments[idx].Effort
|
|
}
|
|
if segments[idx].Effort > maxEffort {
|
|
maxEffort = segments[idx].Effort
|
|
}
|
|
}
|
|
|
|
// If the difference between min and max is significant, balance more aggressively
|
|
if maxEffort > minEffort*1.5 {
|
|
// Sort active segments by effort
|
|
sort.Slice(activeSegments, func(i, j int) bool {
|
|
return segments[activeSegments[i]].Effort < segments[activeSegments[j]].Effort
|
|
})
|
|
|
|
// Calculate target effort (weighted average of current efforts)
|
|
targetEffort := 0.0
|
|
totalWeight := 0.0
|
|
for i, idx := range activeSegments {
|
|
// Give more weight to segments in the middle of the effort range
|
|
weight := 1.0
|
|
if i < len(activeSegments)/3 {
|
|
// Lower third gets less weight
|
|
weight = 0.7
|
|
} else if i >= 2*len(activeSegments)/3 {
|
|
// Upper third gets less weight
|
|
weight = 0.7
|
|
}
|
|
targetEffort += segments[idx].Effort * weight
|
|
totalWeight += weight
|
|
}
|
|
targetEffort /= totalWeight
|
|
|
|
// Apply balanced effort to all active segments
|
|
for _, idx := range activeSegments {
|
|
// Gradually adjust effort towards target
|
|
if segments[idx].Effort < targetEffort {
|
|
// Increase low effort segments
|
|
segments[idx].Effort = segments[idx].Effort*0.3 + targetEffort*0.7
|
|
} else if segments[idx].Effort > targetEffort {
|
|
// Decrease high effort segments
|
|
segments[idx].Effort = segments[idx].Effort*0.3 + targetEffort*0.7
|
|
}
|
|
segments[idx].Distance = segments[idx].Effort * 0.8 // Approximate distance based on effort
|
|
}
|
|
|
|
logger.Debug().
|
|
Float64("minEffort", minEffort).
|
|
Float64("maxEffort", maxEffort).
|
|
Float64("targetEffort", targetEffort).
|
|
Msg("Applied aggressive global balancing for very uneven effort distribution")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Special handling for the last few segments if they have very high effort
|
|
// This is particularly important for routes with forced stops
|
|
if len(segments) >= 3 {
|
|
lastSegmentIndex := len(segments) - 1
|
|
|
|
// Check if the last segment has significantly higher effort
|
|
if segments[lastSegmentIndex].Effort > targetEffortPerActiveDay*1.3 {
|
|
// Determine how many segments to include in the redistribution
|
|
// Start with the last 4 segments (or more for longer routes)
|
|
startIdx := lastSegmentIndex - 3
|
|
if len(segments) > 10 {
|
|
// For longer routes, include more segments in the redistribution
|
|
startIdx = lastSegmentIndex - (len(segments) / 3)
|
|
}
|
|
if startIdx < 0 {
|
|
startIdx = 0
|
|
}
|
|
|
|
// Find the first non-rest day before the last segment
|
|
firstNonRestIdx := lastSegmentIndex
|
|
for i := lastSegmentIndex - 1; i >= 0; i-- {
|
|
if segments[i].Effort < targetEffortPerDay*0.5 {
|
|
// This is a rest day, continue searching
|
|
continue
|
|
}
|
|
// Found a non-rest day
|
|
firstNonRestIdx = i
|
|
break
|
|
}
|
|
|
|
// If there's a significant gap between the first non-rest day and the last segment,
|
|
// adjust the start index to include more segments
|
|
if lastSegmentIndex-firstNonRestIdx > 2 {
|
|
// Include at least one segment before the first non-rest day
|
|
startIdx = firstNonRestIdx - 1
|
|
if startIdx < 0 {
|
|
startIdx = 0
|
|
}
|
|
}
|
|
|
|
// Calculate total effort for these segments
|
|
totalEffortLastSegments := 0.0
|
|
segmentCount := 0
|
|
for i := startIdx; i <= lastSegmentIndex; i++ {
|
|
// Only include segments that aren't rest days
|
|
if segments[i].Effort >= targetEffortPerDay*0.5 {
|
|
totalEffortLastSegments += segments[i].Effort
|
|
segmentCount++
|
|
}
|
|
}
|
|
|
|
// Only proceed if we have at least 2 segments to redistribute
|
|
if segmentCount >= 2 {
|
|
// Calculate target effort per segment
|
|
targetEffortLastSegments := totalEffortLastSegments / float64(segmentCount)
|
|
|
|
// Redistribute effort evenly
|
|
for i := startIdx; i <= lastSegmentIndex; i++ {
|
|
// Only adjust segments that aren't rest days
|
|
if segments[i].Effort >= targetEffortPerDay*0.5 {
|
|
segments[i].Effort = targetEffortLastSegments
|
|
segments[i].Distance = targetEffortLastSegments * 0.8 // Approximate distance based on effort
|
|
}
|
|
}
|
|
|
|
logger.Debug().
|
|
Int("startIdx", startIdx).
|
|
Int("lastSegmentIndex", lastSegmentIndex).
|
|
Float64("totalEffortLastSegments", totalEffortLastSegments).
|
|
Float64("targetEffortLastSegments", targetEffortLastSegments).
|
|
Msg("Applied special handling for last segments with high effort")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.Info().Int("segments", len(segments)).Msg("Route planning completed")
|
|
return segments, nil
|
|
}
|
|
|
|
func main() {
|
|
input := flag.String("input", "route.gpx", "Input GPX file")
|
|
days := flag.Int("days", 10, "Number of days to split the route into")
|
|
elevFactor := flag.Float64("elevFactor", 4.0, "Weight factor per 100m elevation gain")
|
|
forestRadius := flag.Int("forestRadius", 3, "Radius to check for forest (km)")
|
|
resupplyRadius := flag.Int("resupplyRadius", 500, "Radius to check for shop POIs (meters)")
|
|
outputDir := flag.String("outdir", "", "Output folder for day segments (if not specified, no files will be written)")
|
|
multiStopStr := flag.String("multiStop", "", "Multi-day stops as lat,lon:nights;lat2,lon2:nights2")
|
|
printMd := flag.Bool("printMarkdown", true, "Print daily segment summary")
|
|
debug := flag.Bool("debug", false, "Enable debug logging")
|
|
prettyOutput := flag.Bool("pretty", true, "Enable pretty output")
|
|
useBatchQuery := flag.Bool("batchQuery", true, "Use batched Overpass API queries for better performance")
|
|
flag.Parse()
|
|
|
|
// Configure logger
|
|
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
|
logLevel := zerolog.InfoLevel
|
|
if *debug {
|
|
logLevel = zerolog.DebugLevel
|
|
}
|
|
zerolog.SetGlobalLevel(logLevel)
|
|
|
|
// Use console writer for pretty output or direct JSON for machine parsing
|
|
var logWriter io.Writer = os.Stdout
|
|
if *prettyOutput {
|
|
logWriter = zerolog.ConsoleWriter{
|
|
Out: os.Stderr,
|
|
TimeFormat: time.RFC3339,
|
|
NoColor: false,
|
|
}
|
|
}
|
|
logger = zerolog.New(logWriter).With().Timestamp().Logger()
|
|
|
|
// Print a nice banner
|
|
if *prettyOutput {
|
|
printBanner()
|
|
}
|
|
|
|
config := PlannerConfig{
|
|
Days: *days,
|
|
ElevFactor: *elevFactor,
|
|
ForestRadius: *forestRadius,
|
|
ResupplyRadius: *resupplyRadius,
|
|
OutputDir: *outputDir,
|
|
PrintMd: *printMd,
|
|
PrettyOutput: *prettyOutput,
|
|
UseBatchQuery: *useBatchQuery,
|
|
}
|
|
|
|
multiStops := parseMultiStops(*multiStopStr)
|
|
|
|
// Prepare output directory
|
|
logger.Debug().Str("dir", config.OutputDir).Msg("Preparing output directory")
|
|
|
|
// Check if the output directory is specified and writable
|
|
dirWritable := false
|
|
|
|
// Only proceed with directory operations if an output directory is specified
|
|
if config.OutputDir != "" {
|
|
// First, make sure the directory exists
|
|
if err := os.MkdirAll(config.OutputDir, 0755); err != nil {
|
|
logger.Warn().Err(err).Str("dir", config.OutputDir).Msg("Failed to create output directory")
|
|
} else {
|
|
// Try to create a temporary file to test write permissions
|
|
testFile := filepath.Join(config.OutputDir, ".write_test")
|
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
|
logger.Warn().Err(err).Str("dir", config.OutputDir).Msg("Output directory is not writable")
|
|
} else {
|
|
// Clean up the test file
|
|
err := os.Remove(testFile)
|
|
if err != nil {
|
|
logger.Warn().Err(err).Str("file", testFile).Msg("Failed to remove temporary file")
|
|
}
|
|
|
|
// Clean the directory for new files
|
|
files, err := os.ReadDir(config.OutputDir)
|
|
if err == nil {
|
|
for _, file := range files {
|
|
if strings.HasPrefix(file.Name(), "day") && strings.HasSuffix(file.Name(), ".gpx") {
|
|
err := os.Remove(filepath.Join(config.OutputDir, file.Name()))
|
|
if err != nil {
|
|
logger.Warn().Err(err).Str("file", file.Name()).Msg("Failed to remove existing file")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we got here, the directory is writable
|
|
dirWritable = true
|
|
}
|
|
}
|
|
}
|
|
|
|
segments, err := planRoute(*input, config, multiStops)
|
|
if err != nil {
|
|
logger.Fatal().Err(err).Msg("Failed to plan route")
|
|
}
|
|
|
|
// Save GPX files for each segment
|
|
logger.Debug().Int("segments", len(segments)).Msg("Saving GPX files for segments")
|
|
for _, segment := range segments {
|
|
// Skip file operations if no output directory is specified or the directory is not writable
|
|
if config.OutputDir != "" && dirWritable {
|
|
filename := filepath.Join(config.OutputDir, fmt.Sprintf("day%02d.gpx", segment.DayNumber))
|
|
logger.Debug().Str("filename", filename).Int("day", segment.DayNumber).Msg("Creating GPX for segment")
|
|
|
|
forSegment, err := createGPXForSegment(segment, filename)
|
|
if err != nil {
|
|
logger.Error().Err(err).Str("filename", filename).Msg("Failed to create GPX for segment")
|
|
continue
|
|
}
|
|
|
|
logger.Debug().Str("filename", filename).Msg("Saving GPX file")
|
|
if err := saveGPXFile(forSegment, filename); err != nil {
|
|
logger.Error().Err(err).Str("filename", filename).Msg("Failed to save GPX file")
|
|
}
|
|
} else if !dirWritable && config.OutputDir != "" {
|
|
logger.Warn().Int("day", segment.DayNumber).Msg("Skipping GPX file creation because output directory is not writable")
|
|
}
|
|
|
|
printSegmentSummary(segment, config.PrintMd)
|
|
}
|
|
|
|
// Print final summary table
|
|
printFinalSummary(allSegments, config.PrettyOutput)
|
|
|
|
// Final success message
|
|
successMsg := fmt.Sprintf("Route planning completed successfully! %d daily segments created.", len(segments))
|
|
if config.PrettyOutput {
|
|
successColor := color.New(color.FgHiGreen, color.Bold)
|
|
fmt.Println(successColor.Sprint("\n✅ " + successMsg))
|
|
} else {
|
|
logger.Info().Int("segments", len(segments)).Msg(successMsg)
|
|
}
|
|
}
|