gpx-utils/pkg/placenames/summarize.go
Ray Miller bd7eb246d5 Improvements to duplicate suppression, cafe stop search, and configurability.
When suppressing duplicate cafes and place names, look back a certain distance along
the route rather than just the previous point of interest.

When searching for cafes, use SearchIntersect() to return all entries in the
bounding rectangle, not just the nearest.

Remove (most) hard-coded constants and allow these to be overriden by options
to the NewGPXSummarizer() constructor.
2023-01-19 16:21:32 +00:00

338 lines
8.4 KiB
Go

package placenames
import (
"fmt"
"io"
"math"
"strings"
"time"
"github.com/dhconnelly/rtreego"
"github.com/fofanov/go-osgb"
"github.com/ray1729/gpx-utils/pkg/cafes"
"github.com/twpayne/go-gpx"
)
var populatedPlaceRank = map[string]int{
"City": 5,
"Town": 4,
"Village": 3,
"Hamlet": 3,
"Other Settlement": 1,
}
// GPXSummarizerConfig allows override of defaults used by the search algorithm.
type GPXSummarizerConfig struct {
CoffeeStopSearchRectangleSize float64
CoffeeStopDuplicateDistance float64
PointOfInterestDuplicateDistance float64
PointOfInterestMinimumDistance float64
MinimumSettlementRank int
}
var DefaultGPXSummarizerConfig = GPXSummarizerConfig{
CoffeeStopSearchRectangleSize: 500.0, // m
CoffeeStopDuplicateDistance: 2.0, // km
PointOfInterestDuplicateDistance: 1.0, // km
PointOfInterestMinimumDistance: 0.2, // km
MinimumSettlementRank: 1, // "Other Settlement"
}
type Option func(*GPXSummarizerConfig)
// WithCoffeeStopSearchRectangleSize overrides the size (in metres) of the rectangle searched
// for coffee stops near the route. Default 500m.
func WithCoffeeStopSearchRectangleSize(d float64) Option {
return func(c *GPXSummarizerConfig) {
c.CoffeeStopSearchRectangleSize = d
}
}
// WithCoffeeStopDuplicateDistance overrides the distance (in kilometers) we look back along the
// route when suppressing duplicate coffee stop entries. This should be at least twice the
// CoffeeStopSearchRectangleSize. Default 2km.
func WithCoffeeStopDuplicateDistance(d float64) Option {
return func(c *GPXSummarizerConfig) {
c.CoffeeStopDuplicateDistance = d
}
}
// WithPointOfInterestDuplicateDistance overrides the distance (in km) we look back along
// the route when suppressing duplicate points of interest.
func WithPointOfInterestDuplicateDistance(d float64) Option {
return func(c *GPXSummarizerConfig) {
c.PointOfInterestDuplicateDistance = d
}
}
// WithPointOfInterestMinimumDistance overrides the minimum distance (in km) between points
// of interest (if two POI appear within this distance, the second one is suppressed). Default
// 0km (no suppression).
func WithPointOfInterestMinimumDistance(d float64) Option {
return func(c *GPXSummarizerConfig) {
c.PointOfInterestMinimumDistance = d
}
}
func WithMinimumSettlement(s string) Option {
rank, ok := populatedPlaceRank[s]
if !ok {
panic(fmt.Sprintf("invalid settlement type: %s", s))
}
return func(c *GPXSummarizerConfig) {
c.MinimumSettlementRank = rank
}
}
type GPXSummarizer struct {
poi *rtreego.Rtree
trans osgb.CoordinateTransformer
conf GPXSummarizerConfig
}
func NewGPXSummarizer(opts ...Option) (*GPXSummarizer, error) {
conf := DefaultGPXSummarizerConfig
for _, f := range opts {
f(&conf)
}
trans, err := osgb.NewOSTN15Transformer()
if err != nil {
return nil, err
}
rt, err := RestoreIndex()
if err != nil {
return nil, err
}
return &GPXSummarizer{poi: rt, trans: trans, conf: conf}, nil
}
func distance(p1, p2 rtreego.Point) float64 {
if len(p1) != len(p2) {
panic("Length mismatch")
}
var s float64
for i := range p1 {
d := p1[i] - p2[i]
s += d * d
}
return math.Sqrt(s) / 1000.0
}
type POI struct {
Name string
Type string
Distance float64
}
type RefreshmentStop struct {
Name string
Url string
Distance float64
}
type TrackSummary struct {
Name string
Direction string
Time time.Time
Link string
Start string
Finish string
Distance float64
Ascent float64
Descent float64
PointsOfInterest []POI
RefreshmentStops []RefreshmentStop `json:",omitempty"`
Counties map[string]int
}
func (gs *GPXSummarizer) SummarizeTrack(r io.Reader, stops *rtreego.Rtree) (*TrackSummary, error) {
g, err := gpx.Read(r)
if err != nil {
return nil, err
}
var s TrackSummary
s.Name = g.Metadata.Name
s.Time = g.Metadata.Time
s.Counties = make(map[string]int)
for _, l := range g.Metadata.Link {
if strings.HasPrefix(l.HREF, "http") {
s.Link = l.HREF
break
}
}
var elevations []float64
var prevPlace string
var prevPlacePoint rtreego.Point
var prevPoint rtreego.Point
var start rtreego.Point
var dN, dE float64
init := true
for _, trk := range g.Trk {
for _, seg := range trk.TrkSeg {
for _, p := range seg.TrkPt {
gpsCoord := osgb.NewETRS89Coord(p.Lon, p.Lat, p.Ele)
ngCoord, err := gs.trans.ToNationalGrid(gpsCoord)
if err != nil {
return nil, err
}
elevations = append(elevations, p.Ele)
thisPoint := rtreego.Point{ngCoord.Easting, ngCoord.Northing}
nn, _ := gs.poi.NearestNeighbor(thisPoint).(*NamedBoundary)
if init {
if !nn.NearEnough(thisPoint, 500.0) {
return nil, fmt.Errorf("start point out of range")
}
start = thisPoint
s.Start = nn.Name
prevPlace = nn.Name
prevPlacePoint = thisPoint
prevPoint = thisPoint
s.PointsOfInterest = append(s.PointsOfInterest, POI{Name: nn.Name, Type: nn.Type, Distance: 0.0})
s.Counties[nn.County]++
init = false
continue
}
s.Distance += distance(thisPoint, prevPoint)
dE += thisPoint[0] - start[0]
dN += thisPoint[1] - start[1]
if nn.Contains(thisPoint) && populatedPlaceRank[nn.Type] >= gs.conf.MinimumSettlementRank {
s.Counties[nn.County]++
seenRecently := false
for i := len(s.PointsOfInterest) - 1; i >= 0; i-- {
if i < len(s.PointsOfInterest)-1 && s.Distance-s.PointsOfInterest[i].Distance > gs.conf.PointOfInterestDuplicateDistance {
break
}
if nn.Name == s.PointsOfInterest[i].Name {
seenRecently = true
break
}
}
if !seenRecently && distance(thisPoint, prevPlacePoint) > gs.conf.PointOfInterestMinimumDistance {
s.PointsOfInterest = append(s.PointsOfInterest, POI{Name: nn.Name, Type: nn.Type, Distance: s.Distance})
prevPlace = nn.Name
prevPlacePoint = thisPoint
}
}
if stops != nil {
for _, nearbyStop := range stops.SearchIntersect(thisPoint.ToRect(gs.conf.CoffeeStopSearchRectangleSize)) {
stop := nearbyStop.(*cafes.RefreshmentStop)
seenRecently := false
for i := len(s.RefreshmentStops) - 1; i >= 0; i-- {
if i < len(s.RefreshmentStops)-1 && s.Distance-s.RefreshmentStops[i].Distance > gs.conf.CoffeeStopDuplicateDistance {
break
}
if s.RefreshmentStops[i].Name == stop.Name {
seenRecently = true
break
}
}
if !seenRecently {
s.RefreshmentStops = append(s.RefreshmentStops, RefreshmentStop{
Name: stop.Name,
Url: stop.Url,
Distance: s.Distance,
})
}
}
}
prevPoint = thisPoint
}
}
}
s.Finish = prevPlace
s.Direction = calcDirection(dE, dN)
s.Ascent, s.Descent = calcUphillDownhill(elevations)
s.Counties = toPercentages(s.Counties)
return &s, nil
}
func toPercentages(m map[string]int) map[string]int {
t := 0
for _, v := range m {
t += v
}
for k, v := range m {
m[k] = v * 100 / t
if m[k] == 0 {
delete(m, k)
}
}
return m
}
func calcDirection(dE, dN float64) string {
if dN == 0 {
if dE >= 0 {
return "east"
}
return "west"
}
t := math.Abs(dE) / math.Abs(dN)
if dN > 0 {
if t < math.Tan(math.Pi/8) {
return "north"
}
if t < math.Tan(3*math.Pi/8) {
if dE > 0 {
return "north-east"
}
return "north-west"
}
if dE > 0 {
return "east"
}
return "west"
}
if t < math.Tan(math.Pi/8) {
return "south"
}
if t < math.Tan(3*math.Pi/8) {
if dE > 0 {
return "south-east"
}
return "south-west"
}
if dE > 0 {
return "east"
}
return "west"
}
// calcUphillDownhill calculates uphill/downhill data
// Implementation from https://github.com/ptrv/go-gpx
func calcUphillDownhill(elevations []float64) (float64, float64) {
elevsLen := len(elevations)
if elevsLen == 0 {
return 0.0, 0.0
}
smoothElevations := make([]float64, elevsLen)
for i, elev := range elevations {
var currEle float64
if 0 < i && i < elevsLen-1 {
prevEle := elevations[i-1]
nextEle := elevations[i+1]
currEle = prevEle*0.3 + elev*0.4 + nextEle*0.3
} else {
currEle = elev
}
smoothElevations[i] = currEle
}
var uphill float64
var downhill float64
for i := 1; i < len(smoothElevations); i++ {
d := smoothElevations[i] - smoothElevations[i-1]
if d > 0.0 {
uphill += d
} else {
downhill -= d
}
}
return uphill, downhill
}