broutes 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,15 @@
1
+ ---
2
+ !binary "U0hBMQ==":
3
+ metadata.gz: !binary |-
4
+ NzQyYjM1NDdiMDY2Njk3ZmJiOGY0NGM5YmMwYjU3MGVlNGZmMmE4Mg==
5
+ data.tar.gz: !binary |-
6
+ YTJiZjQ3Nzk5NTA1YTE1NzYwODA2YTlkNjQzYmJiMDQxNTljYzM0Yg==
7
+ !binary "U0hBNTEy":
8
+ metadata.gz: !binary |-
9
+ ZWRmMTYwMGY1OTQ1ODdhZWY2NjE1MzkyNmFjZDA5MzNlMjMxNmVkMzViMTA5
10
+ MGI4Yjk0ZTI2YTRkMjgwMDkxZjE5ZWZkMzE3MTQxN2M2YWU2OTAzYTNlMmQ4
11
+ YmYzM2JlOThiMTkxYzUzNmI2MjlmNDBmNmM5M2YzZWFkYmYzZjI=
12
+ data.tar.gz: !binary |-
13
+ Y2U5NmE5OWIyMzVhYzA3Y2FlMmNhN2M0MjA5YjdlYTU2NjVjMWQ2MmVkZGUw
14
+ NzQ4NWY0MTFmMDQwYzNlMjNjZTZlNzVhNTU0ZmNlOTZkNTBlMjZlY2ZkMTM1
15
+ Y2E5NjEzZWFiNGFmYmM5OTAzNDUxZDc0NDVkNGI4OGFhYTc5MDE=
@@ -0,0 +1,22 @@
1
+ # Public : provides factory method for loading appropriate route file parser
2
+ module Broutes::Formats
3
+ class Factory
4
+ # Public : factory method
5
+ #
6
+ # format - Symbol describing the format [:gpx_track, :tcx, :fit]
7
+ #
8
+ # Returns a route file parser
9
+ def get(format)
10
+ case format
11
+ when :gpx_track, 'application/gpx+xml', /\.gpx$/
12
+ GpxTrack.new
13
+ when :tcx, 'application/vnd.garmin.tcx+xml', /\.tcx$/
14
+ Tcx.new
15
+ when :fit, 'application/vnd.ant.fit', /\.fit$/
16
+ FitFile.new
17
+ else
18
+ raise ArgumentError.new("Unrecognised format #{format}. Supported formats are :gpx_track, :tcx, :fit")
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,41 @@
1
+ require 'fit'
2
+
3
+ module Broutes::Formats
4
+ class FitFile
5
+
6
+ def load(file, route)
7
+ fit_file = Fit::File.read(file)
8
+ Broutes.logger.info {"Started fit processing"}
9
+ i = 0
10
+ fit_file.records.select {|r| r.content && r.content.record_type == :record }.each do |r|
11
+ begin
12
+ pr = r.content
13
+ data = { time: record_time(r) }
14
+ data[:lat] = convert_position(pr.position_lat) if pr.respond_to?(:position_lat)
15
+ data[:lon] = convert_position(pr.position_long) if pr.respond_to?(:position_long)
16
+ data[:elevation] = pr.altitude if pr.respond_to?(:altitude)
17
+ [:distance, :heart_rate, :power, :speed, :cadence, :temperature].each do |m|
18
+ data[m] = pr.send(m) if pr.respond_to?(m)
19
+ end
20
+
21
+ route.add_point(data)
22
+ i += 1
23
+ rescue => e
24
+ Broutes.logger.debug {"#{e.message} for #{r}"}
25
+ end
26
+ end
27
+ Broutes.logger.info {"Loaded #{i} data points"}
28
+ end
29
+
30
+ def convert_position(value)
31
+ (8.381903171539307e-08 * value).round(5)
32
+ end
33
+
34
+ def record_time(record)
35
+ utc_seconds = record.content.timestamp
36
+ utc_seconds += record.header.time_offset if record.header.compressed_timestamp?
37
+ Time.new(1989, 12, 31) + utc_seconds
38
+ end
39
+
40
+ end
41
+ end
@@ -0,0 +1,9 @@
1
+ require 'nokogiri'
2
+
3
+ module Broutes::Formats
4
+ class GpxRoute
5
+ def load(file, route)
6
+
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,30 @@
1
+ require 'nokogiri'
2
+
3
+ module Broutes::Formats
4
+ class GpxTrack
5
+
6
+ def load(file, route)
7
+ doc = Nokogiri::XML(file)
8
+ Broutes.logger.info {"Loaded #{file} into #{doc.to_s.slice(0, 10)}"}
9
+
10
+ i = 0
11
+ doc.css('trkpt').each do |node|
12
+ p = route.add_point(lat: node['lat'].to_f, lon: node['lon'].to_f, elevation: point_elevation(node), time: point_time(node))
13
+ i += 1
14
+ end
15
+ Broutes.logger.info {"Loaded #{i} data points"}
16
+ end
17
+
18
+ def point_elevation(node)
19
+ if elevation_node = node.at_css('ele')
20
+ elevation_node.inner_text.to_f
21
+ end
22
+ end
23
+
24
+ def point_time(node)
25
+ if time_node = node.at_css('time')
26
+ DateTime.parse(time_node.inner_text).to_time
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,96 @@
1
+ require 'nokogiri'
2
+
3
+ module Broutes::Formats
4
+ class Tcx
5
+ def load(file, route)
6
+ doc = Nokogiri::XML(file)
7
+ Broutes.logger.info {"Loaded #{file} into #{doc.to_s.slice(0, 10)}"}
8
+
9
+ i = 0
10
+ doc.css('Trackpoint').each do |node|
11
+ data = {
12
+ elevation: point_elevation(node),
13
+ time: point_time(node),
14
+ distance: point_distance(node),
15
+ heart_rate: point_heart_rate(node),
16
+ power: point_power(node),
17
+ speed: point_speed(node),
18
+ cadence: point_cadence(node)
19
+ }
20
+ if location = point_location(node)
21
+ data[:lat] = location[0]
22
+ data[:lon] = location[1]
23
+ end
24
+
25
+ p = route.add_point(data)
26
+ i += 1
27
+ end
28
+ Broutes.logger.info {"Loaded #{i} data points"}
29
+
30
+ # Load in summary values if time and distance nil, ie no points
31
+ unless route.total_time
32
+ route.total_time = doc.css('Activities > Activity > Lap > TotalTimeSeconds').reduce(0) { |sum, node|
33
+ sum + node.inner_text.to_i
34
+ }
35
+ end
36
+
37
+ unless route.total_distance
38
+ route.total_distance = doc.css('Activities > Activity > Lap > DistanceMeters').reduce(0) { |sum, node|
39
+ sum + node.inner_text.to_i
40
+ }
41
+ end
42
+
43
+ unless route.started_at
44
+ route.started_at = DateTime.parse(doc.css('Activities > Activity > Lap').first['StartTime']).to_time
45
+ end
46
+ end
47
+
48
+ def point_location(node)
49
+ if position_node = node.at_css('Position')
50
+ [ position_node.at_css('LatitudeDegrees').inner_text.to_f, position_node.at_css('LongitudeDegrees').inner_text.to_f ]
51
+ end
52
+ end
53
+
54
+ def point_distance(node)
55
+ if distance_node = node.at_css('DistanceMeters')
56
+ distance_node.inner_text.to_f
57
+ end
58
+ end
59
+
60
+ def point_elevation(node)
61
+ if elevation_node = node.at_css('AltitudeMeters')
62
+ elevation_node.inner_text.to_f
63
+ end
64
+ end
65
+
66
+ def point_time(node)
67
+ if time_node = node.at_css('Time')
68
+ DateTime.parse(time_node.inner_text).to_time
69
+ end
70
+ end
71
+
72
+ def point_heart_rate(node)
73
+ if hr_node = node.at_css('HeartRateBpm')
74
+ hr_node.inner_text.to_i
75
+ end
76
+ end
77
+
78
+ def point_cadence(node)
79
+ if cadence_node = node.at_css('Cadence')
80
+ cadence_node.inner_text.to_i
81
+ end
82
+ end
83
+
84
+ def point_power(node)
85
+ if power_node = node.at_xpath('.//tpx:Watts', 'tpx' => 'http://www.garmin.com/xmlschemas/ActivityExtension/v2')
86
+ power_node.inner_text.to_i
87
+ end
88
+ end
89
+
90
+ def point_speed(node)
91
+ if speed_node = node.at_xpath('.//tpx:Speed', 'tpx' => 'http://www.garmin.com/xmlschemas/ActivityExtension/v2')
92
+ speed_node.inner_text.to_f
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,9 @@
1
+ module Broutes
2
+ module Formats
3
+ require_relative 'formats/factory'
4
+ require_relative 'formats/gpx_track'
5
+ require_relative 'formats/gpx_route'
6
+ require_relative 'formats/tcx'
7
+ require_relative 'formats/fit_file'
8
+ end
9
+ end
@@ -0,0 +1,56 @@
1
+ require 'time'
2
+
3
+ module Broutes
4
+ class GeoPoint
5
+ attr_accessor :lat, :lon, :elevation, :distance, :heart_rate, :power, :speed, :cadence, :temperature
6
+ attr_reader :time
7
+
8
+ def initialize(args={})
9
+ args.each_pair do |key, value| send("#{key}=", value) if respond_to?("#{key}=") end
10
+ end
11
+
12
+ def self.from_hash(h)
13
+ GeoPoint.new(h)
14
+ end
15
+
16
+ def has_location?
17
+ lat && lon
18
+ end
19
+
20
+ def time=(value)
21
+ if value.is_a?(String)
22
+ @time = DateTime.parse(value).to_time
23
+ else
24
+ @time = value
25
+ end
26
+ end
27
+
28
+ def ==(other)
29
+ lat == other.lat &&
30
+ lon == other.lon &&
31
+ elevation == other.elevation &&
32
+ distance == other.distance &&
33
+ time == other.time &&
34
+ heart_rate == other.heart_rate &&
35
+ power == other.power &&
36
+ speed == other.speed &&
37
+ cadence == other.cadence &&
38
+ temperature == other.temperature
39
+ end
40
+
41
+ def to_hash
42
+ h = {}
43
+ h['lat'] = lat if lat
44
+ h['lon'] = lon if lon
45
+ h['elevation'] = elevation if elevation
46
+ h['distance'] = distance if distance
47
+ h['time'] = time if time
48
+ h['heart_rate'] = heart_rate if heart_rate
49
+ h['power'] = power if power
50
+ h['speed'] = speed if speed
51
+ h['cadence'] = cadence if cadence
52
+ h['temperature'] = temperature if temperature
53
+ h
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,109 @@
1
+ module Broutes
2
+ class GeoRoute
3
+
4
+ attr_reader :start_point, :end_point, :started_at, :ended_at, :total_time
5
+ attr_writer :total_distance, :started_at
6
+ attr_accessor :total_time
7
+
8
+ def points
9
+ get_points.to_enum
10
+ end
11
+
12
+ def initialize(args={})
13
+ args.each_pair do |key, value|
14
+ if key.to_sym == :points
15
+ value.each { |p| add_point(p) }
16
+ else
17
+ send("#{key}=", value) if respond_to?("#{key}=")
18
+ end
19
+ end
20
+ end
21
+
22
+ def self.from_hash(h)
23
+ route = GeoRoute.new h
24
+ end
25
+
26
+ def to_hash
27
+ h = {
28
+ 'total_distance' => total_distance,
29
+ 'total_time' => total_time,
30
+ 'total_ascent' => total_ascent,
31
+ 'total_descent' => total_ascent,
32
+ 'points' => points.collect { |p| p.to_hash }
33
+ }
34
+ h['start_point'] = start_point.to_hash if start_point
35
+ h['end_point'] = end_point.to_hash if end_point
36
+ h['started_at'] = @started_at if @started_at
37
+ h
38
+ end
39
+
40
+ def add_point(args)
41
+ point = GeoPoint.new(args)
42
+ if @start_point
43
+ if point.distance
44
+ @total_distance = point.distance
45
+ else
46
+ if distance = Maths.haversine_distance(@end_point, point)
47
+ @total_distance += distance
48
+ end
49
+ end
50
+
51
+ @total_time = point.time - @start_point.time if point.time
52
+ else
53
+ @start_point = point
54
+ @total_distance = point.distance || 0
55
+ end
56
+
57
+ point.distance = @total_distance
58
+ process_elevation_delta(@end_point, point)
59
+
60
+ @end_point = point
61
+ get_points << point
62
+ end
63
+
64
+ def process_elevation_delta(last_point, new_point)
65
+ if last_point && new_point && last_point.elevation && new_point.elevation
66
+ delta = new_point.elevation - last_point.elevation
67
+ @_total_ascent = self.total_ascent + (delta > 0 ? delta : 0)
68
+ @_total_descent = self.total_descent - (delta < 0 ? delta : 0)
69
+ end
70
+ end
71
+
72
+ def started_at
73
+ return @started_at if @started_at
74
+ @start_point.time if @start_point
75
+ end
76
+
77
+ def ended_at
78
+ @end_point.time if @end_point
79
+ end
80
+
81
+ def total_ascent
82
+ @_total_ascent ||= 0
83
+ end
84
+
85
+ def total_descent
86
+ @_total_descent ||= 0
87
+ end
88
+
89
+ # Public : Total distance measured between points in whole metres
90
+ #
91
+ # Returns Float distance in m
92
+ def total_distance
93
+ @total_distance.round if @total_distance
94
+ end
95
+
96
+ # Public : Measure of how hilly the route is. Measured as total ascent (m) / distance (km)
97
+ #
98
+ # Returns Float measure
99
+ def hilliness
100
+ (total_distance > 0) ? (total_ascent * 1000 / total_distance) : 0
101
+ end
102
+
103
+ private
104
+
105
+ def get_points
106
+ @_points ||= []
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,26 @@
1
+ module Broutes
2
+ module Maths
3
+ class << self
4
+ def haversine_distance(p1, p2)
5
+ return unless p1.has_location? && p2.has_location?
6
+
7
+ dlat = p2.lat - p1.lat
8
+ dlon = p2.lon - p1.lon
9
+
10
+ dlon_rad = dlon * RAD_PER_DEG
11
+ dlat_rad = dlat * RAD_PER_DEG
12
+
13
+ lat1_rad = p1.lat * RAD_PER_DEG
14
+ lon1_rad = p1.lon * RAD_PER_DEG
15
+
16
+ lat2_rad = p2.lat * RAD_PER_DEG
17
+ lon2_rad = p2.lon * RAD_PER_DEG
18
+
19
+ a = (Math.sin(dlat_rad/2))**2 + Math.cos(lat1_rad) * Math.cos(lat2_rad) * (Math.sin(dlon_rad/2))**2
20
+ c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
21
+
22
+ EARTH_RADIUS * c
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,3 @@
1
+ module Broutes
2
+ VERSION = "0.1.3".freeze
3
+ end
data/lib/broutes.rb ADDED
@@ -0,0 +1,26 @@
1
+ module Broutes
2
+ require 'logger'
3
+ require_relative 'broutes/geo_route'
4
+ require_relative 'broutes/geo_point'
5
+ require_relative 'broutes/maths'
6
+ require_relative 'broutes/formats'
7
+
8
+ RAD_PER_DEG = 0.017453293 # PI/180
9
+ EARTH_RADIUS = 6371000 #m
10
+
11
+ def self.from_file(file, format)
12
+ raise "unable to interpret format #{format}" unless processor = Formats::Factory.new.get(format)
13
+ Broutes.logger.debug {"found processor #{processor} for #{file}"}
14
+ route = GeoRoute.new
15
+ processor.load(file, route)
16
+ route
17
+ end
18
+
19
+ class << self
20
+ attr_writer :logger
21
+
22
+ def logger
23
+ @logger ||= Logger.new(STDOUT)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,34 @@
1
+ require 'spec_helper'
2
+
3
+ describe Formats::Factory do
4
+ describe "#get" do
5
+
6
+ let(:factory) { Formats::Factory.new }
7
+
8
+ context "when :gpx_track passed" do
9
+ it "returns GpxTrack" do
10
+ factory.get(:gpx_track).should be_an_instance_of(Formats::GpxTrack)
11
+ end
12
+ end
13
+ context "when 'application/vnd.ant.fit' passed" do
14
+ it "returns FitFile" do
15
+ factory.get('application/vnd.ant.fit').should be_an_instance_of(Formats::FitFile)
16
+ end
17
+ end
18
+ context "when :fit passed" do
19
+ it "returns FitFile" do
20
+ factory.get(:fit).should be_an_instance_of(Formats::FitFile)
21
+ end
22
+ end
23
+ context "when unrecognised" do
24
+ it "raises ArgumentError nil" do
25
+ expect { factory.get(random_string) }.to raise_error(ArgumentError)
26
+ end
27
+ end
28
+ context "tcx filename" do
29
+ it "returns Tcx" do
30
+ factory.get("2012-12-30-12-23.tcx").should be_an_instance_of(Formats::Tcx)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,54 @@
1
+ require 'spec_helper'
2
+
3
+ describe Formats::FitFile do
4
+ describe "#load" do
5
+ before(:all) do
6
+ @file = open_file('sample.fit')
7
+ @target = Formats::FitFile.new
8
+ @route = GeoRoute.new
9
+
10
+ @target.load(@file, @route)
11
+ end
12
+
13
+ it "sets the start point lat" do
14
+ @route.start_point.lat.should eq(52.93066)
15
+ end
16
+ it "sets the start point lon" do
17
+ @route.start_point.lon.should eq(-1.22402)
18
+ end
19
+ it "sets the heart rate" do
20
+ @route.start_point.heart_rate.should eq(88)
21
+ end
22
+ it "sets the power" do
23
+ @route.start_point.power.should eq(96)
24
+ end
25
+ it "extracts the cadence" do
26
+ @route.start_point.cadence.should eq(51)
27
+ end
28
+ it "extracts the speed" do
29
+ @route.start_point.speed.should eq(4.024)
30
+ end
31
+ it "extracts the speed" do
32
+ @route.start_point.temperature.should eq(11.0)
33
+ end
34
+ it "sets the total distance" do
35
+ @route.total_distance.should eq(92068)
36
+ end
37
+ it "sets the total ascent" do
38
+ @route.total_ascent.round.should eq(1176)
39
+ end
40
+ it "sets the total descent" do
41
+ @route.total_descent.round.should eq(1177)
42
+ end
43
+ it "sets the total time" do
44
+ @route.total_time.round.should eq(13675)
45
+ end
46
+ it "sets the started_at" do
47
+ @route.started_at.to_i.should eq(Time.new(2012, 5, 12, 7, 29, 45).to_i)
48
+ end
49
+ it "sets the ended_at" do
50
+ @route.ended_at.to_i.should eq(Time.new(2012, 5, 12, 11, 17, 40).to_i)
51
+ end
52
+
53
+ end
54
+ end
@@ -0,0 +1,7 @@
1
+ require 'spec_helper'
2
+
3
+ describe Formats::GpxRoute do
4
+ describe "#load" do
5
+ it "loads it properly"
6
+ end
7
+ end
@@ -0,0 +1,63 @@
1
+ require 'spec_helper'
2
+
3
+ describe Formats::GpxTrack do
4
+ describe "#load" do
5
+ before(:all) do
6
+ @file = open_file('single_lap_gpx_track.gpx')
7
+ @target = Formats::GpxTrack.new
8
+ @route = GeoRoute.new
9
+
10
+ @target.load(@file, @route)
11
+ end
12
+
13
+ it "sets the start point lat" do
14
+ @route.start_point.lat.should eq(52.9552055)
15
+ end
16
+ it "sets the start point lon" do
17
+ @route.start_point.lon.should eq(-1.1558583)
18
+ end
19
+ it "sets the total distance" do
20
+ @route.total_distance.should eq(7088)
21
+ end
22
+ it "sets the total ascent" do
23
+ @route.total_ascent.round.should eq(34)
24
+ end
25
+ it "sets the total descent" do
26
+ @route.total_descent.round.should eq(37)
27
+ end
28
+ it "sets the total time" do
29
+ @route.total_time.round.should eq(1231)
30
+ end
31
+ it "sets the started_at" do
32
+ @route.started_at.to_i.should eq(Time.new(2011, 5, 19, 17, 57, 21).to_i)
33
+ end
34
+ it "sets the ended_at" do
35
+ @route.ended_at.to_i.should eq(Time.new(2011, 5, 19, 18, 17, 52).to_i)
36
+ end
37
+ end
38
+ describe "when file doesn't have elevation" do
39
+ before(:all) do
40
+ @file = open_file('single_lap_gpx_track_no_elevation.gpx')
41
+ @target = Formats::GpxTrack.new
42
+ @route = GeoRoute.new
43
+
44
+ @target.load(@file, @route)
45
+ end
46
+
47
+ it "sets the start point lat" do
48
+ @route.start_point.lat.should eq(52.926467718938)
49
+ end
50
+ it "sets the start point lon" do
51
+ @route.start_point.lon.should eq(-1.216092432889)
52
+ end
53
+ it "sets the total distance" do
54
+ @route.total_distance.should eq(123955)
55
+ end
56
+ it "sets the total ascent" do
57
+ @route.total_ascent.should eq(0)
58
+ end
59
+ it "sets the total descent" do
60
+ @route.total_descent.should eq(0)
61
+ end
62
+ end
63
+ end