-
Notifications
You must be signed in to change notification settings - Fork 1
Refactor into GPX-based route simulation engine #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a5e1a23
b1375fc
83592f4
86df644
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| namespace VirtualSpeed.Model | ||
| { | ||
| public record RouteSegment( | ||
| double StartDistanceMeters, | ||
| double LengthMeters, | ||
| double StartElevationMeters, | ||
| double EndElevationMeters, | ||
| double AverageGradient | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| using System; | ||
|
|
||
| namespace VirtualSpeed.Model | ||
| { | ||
| public record TrackPoint( | ||
| double Latitude, | ||
| double Longitude, | ||
| double ElevationMeters, | ||
| DateTimeOffset? Timestamp | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,193 +1,59 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Globalization; | ||
| using System; | ||
| using System.Linq; | ||
| using System.Text; | ||
| using System.Threading.Tasks; | ||
| using System.Xml; | ||
| using System.IO; | ||
| using VirtualSpeed.Services; | ||
|
|
||
| namespace VirtualSpeed | ||
| { | ||
| class Program | ||
| { | ||
|
|
||
| static void Main(string[] args) | ||
| { | ||
| // abort execution if filename is missing | ||
| if (args.Length == 0) | ||
| if (args.Length < 2 || args[0] != "--gpx") | ||
| { | ||
| Console.WriteLine("Error : tcx file name is missing !"); | ||
| Console.WriteLine("Usage: app.exe --gpx <route.gpx>"); | ||
| return; | ||
| } | ||
|
|
||
| string filename = args[0]; | ||
| var calc = new VirtualSpeedCalculator(); | ||
| string filePath = args[1]; | ||
|
|
||
| XmlDocument myXmlDocument = new XmlDocument(); | ||
| var parser = new GpxParser(); | ||
| var builder = new RouteBuilder(); | ||
|
|
||
| // try to open the xml file and abort if any exception is raised | ||
| System.Collections.Generic.IEnumerable<Model.TrackPoint> points; | ||
| try | ||
| { | ||
| myXmlDocument.Load(filename); | ||
| points = parser.Parse(filePath); | ||
| } | ||
| catch(Exception e) | ||
| catch (Exception e) | ||
| { | ||
| Console.WriteLine("Error while loading xml file : " + e.Message); | ||
| Console.WriteLine("Error while loading GPX file: " + e.Message); | ||
| return; | ||
| } | ||
|
Comment on lines
+22
to
31
|
||
|
|
||
| XmlNode trainingNode; | ||
| trainingNode = myXmlDocument.DocumentElement; | ||
| var segments = builder.Build(points); | ||
|
|
||
| double cumulatedDistance = 0; | ||
| DateTime prevTime = DateTime.MinValue; | ||
|
|
||
| foreach (XmlNode activitiesNode in trainingNode.ChildNodes) | ||
| foreach (XmlNode activityNode in activitiesNode.ChildNodes) | ||
| foreach (XmlNode lapNode in activityNode.ChildNodes) | ||
| { | ||
| if (lapNode.Name == "Lap") | ||
| { | ||
| XmlNode lapDistance = null; | ||
| XmlNode lapMaxSpeedNode = null; | ||
| XmlNode lapAvgSpeed = null; | ||
| XmlNode lapTime = null; | ||
|
|
||
| double lapCumulativeDistance = 0; | ||
| double lapMaxSpeed = 0; | ||
|
|
||
| foreach (XmlNode track in lapNode.ChildNodes) | ||
| { | ||
| if (track.Name == "DistanceMeters") { | ||
| lapDistance = track; | ||
| } | ||
| else if (track.Name == "TotalTimeSeconds") | ||
| { | ||
| lapTime = track; | ||
| } | ||
| else if (track.Name == "MaximumSpeed") | ||
| { | ||
| lapMaxSpeedNode = track; | ||
| } | ||
| else if (track.Name == "Extensions") | ||
| { | ||
| foreach (XmlNode ext in track.ChildNodes) | ||
| foreach (XmlNode LX in ext.ChildNodes) | ||
| if (LX.Name == "AvgSpeed") | ||
| lapAvgSpeed = LX; | ||
| } | ||
| else if (track.Name == "Track") | ||
| foreach (XmlNode point in track.ChildNodes) | ||
| { | ||
| XmlNode distance = null; | ||
| XmlNode time = null; | ||
| XmlNode watt = null; | ||
| XmlNode speed = null; | ||
| foreach (XmlNode value in point.ChildNodes) | ||
| { | ||
| if (value.Name == "Time") | ||
| time = value; | ||
| else if (value.Name == "DistanceMeters") | ||
| distance = value; | ||
| else if (value.Name == "Extensions") | ||
| { | ||
| foreach (XmlNode ext in value.ChildNodes) | ||
| foreach (XmlNode tcxValue in ext.ChildNodes) | ||
| { | ||
| if (tcxValue.Name == "ns3:Watts") | ||
| watt = tcxValue; | ||
| else if (tcxValue.Name == "ns3:Speed") | ||
| speed = tcxValue; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| double newSpeed = 0.0; | ||
|
|
||
| // check if watt information is missing | ||
| if (watt == null) | ||
| { | ||
| // if speed node is present then keep the raw speed without adjusting it | ||
| if (speed != null) | ||
| { | ||
| // speed in tcx is in m/s | ||
| newSpeed = Double.Parse(speed.InnerText, CultureInfo.InvariantCulture) * 3600 / 1000; | ||
| } | ||
| } | ||
| else | ||
| { | ||
| newSpeed = calc.CalculateVelocity(Double.Parse(watt.InnerText)); | ||
| } | ||
|
|
||
| var newSpeedMS = calc.ConvertKmhToMS(newSpeed); | ||
| lapMaxSpeed = Math.Max(lapMaxSpeed, newSpeedMS); | ||
|
|
||
| var pointDistance = calc.CalculateDistance(newSpeed, 1); | ||
| cumulatedDistance = cumulatedDistance + pointDistance; | ||
| lapCumulativeDistance = lapCumulativeDistance + pointDistance; | ||
|
|
||
| // DistanceMeters node might not be present in a Trackpoint node, don't try to replace it if so | ||
| if (distance != null) | ||
| distance.InnerText = cumulatedDistance.ToString(new CultureInfo("en-US")); | ||
|
|
||
| // speed node might not be present in an Extension node, don't try to replace it if so | ||
| if (speed != null) | ||
| speed.InnerText = newSpeedMS.ToString(new CultureInfo("en-US")); | ||
| } | ||
| } | ||
|
|
||
| lapDistance.InnerText= lapCumulativeDistance.ToString(new CultureInfo("en-US")); | ||
| lapMaxSpeedNode.InnerText=lapMaxSpeed.ToString(new CultureInfo("en-US")); | ||
|
|
||
| var avg = lapCumulativeDistance / Double.Parse(lapTime.InnerText); | ||
|
|
||
| lapAvgSpeed.InnerText = avg.ToString(new CultureInfo("en-US")); | ||
|
|
||
| } | ||
| } | ||
| myXmlDocument.Save(Path.GetFileNameWithoutExtension(filename) + "_fixed.tcx"); | ||
|
|
||
| } | ||
|
|
||
| private void Test(string[] args) | ||
| { | ||
| var what = args[0]; | ||
|
|
||
| var calc = new VirtualSpeedCalculator(); | ||
|
|
||
| if (what.Equals("P")) | ||
| if (segments.Count == 0) | ||
| { | ||
| var speed = Double.Parse(args[1]); | ||
|
|
||
| var pwr = calc.CalculatePower(speed); | ||
| Console.WriteLine("Measuring Power needed to go to a given speed"); | ||
| Console.WriteLine("{0} Km/h -> {1}W", speed, pwr); | ||
| Console.WriteLine("No segments found – check that the GPX file contains track points."); | ||
| return; | ||
| } | ||
| else if (what.Equals("V")) | ||
| { | ||
| var pwr = Double.Parse(args[1]); | ||
|
|
||
| var speed = calc.CalculateVelocity(pwr); | ||
| Console.WriteLine("Measuring Speed obtained for a given power"); | ||
| Console.WriteLine("{0} W -> {1} Km/h", pwr, speed); | ||
| } | ||
| double totalDistance = segments.Sum(s => s.LengthMeters); | ||
| double totalElevationGain = segments | ||
| .Where(s => s.EndElevationMeters > s.StartElevationMeters) | ||
| .Sum(s => s.EndElevationMeters - s.StartElevationMeters); | ||
|
|
||
| else if (what.Equals("D")) | ||
| { | ||
| var pwr = Double.Parse(args[1]); | ||
| var time = Double.Parse(args[2]); | ||
| Console.WriteLine($"Total Distance: {totalDistance:F0} m"); | ||
| Console.WriteLine($"Total Elevation Gain: {totalElevationGain:F0} m"); | ||
| Console.WriteLine(); | ||
| Console.WriteLine("Segments:"); | ||
|
|
||
| var speed = calc.CalculateVelocity(pwr); | ||
| var distance = calc.CalculateDistance(speed, time); | ||
| Console.WriteLine("Measuring Distance for given power"); | ||
| Console.WriteLine("{0} W for {1} sec = {2} meters", pwr, time, distance); | ||
| foreach (var seg in segments) | ||
| { | ||
| double gradientPct = seg.AverageGradient * 100; | ||
| Console.WriteLine($"{seg.StartDistanceMeters:F0}m - {seg.StartDistanceMeters + seg.LengthMeters:F0}m | Gradient: {gradientPct:F1}%"); | ||
| } | ||
|
|
||
| Console.ReadLine(); | ||
| } | ||
|
|
||
| } | ||
|
|
||
|
|
||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,52 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| using System; | ||||||||||||||||||||||||||||||||||||||||||||||||
| using System.Collections.Generic; | ||||||||||||||||||||||||||||||||||||||||||||||||
| using System.Globalization; | ||||||||||||||||||||||||||||||||||||||||||||||||
| using System.Xml; | ||||||||||||||||||||||||||||||||||||||||||||||||
| using VirtualSpeed.Model; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| namespace VirtualSpeed.Services | ||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| public class GpxParser | ||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| public IEnumerable<TrackPoint> Parse(string filePath) | ||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| var doc = new XmlDocument(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| doc.Load(filePath); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| var ns = new XmlNamespaceManager(doc.NameTable); | ||||||||||||||||||||||||||||||||||||||||||||||||
| ns.AddNamespace("gpx", "http://www.topografix.com/GPX/1/1"); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Try namespaced query first, fall back to no-namespace | ||||||||||||||||||||||||||||||||||||||||||||||||
| var trkptNodes = doc.SelectNodes("//gpx:trk[1]/gpx:trkseg[1]/gpx:trkpt", ns); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (trkptNodes == null || trkptNodes.Count == 0) | ||||||||||||||||||||||||||||||||||||||||||||||||
| trkptNodes = doc.SelectNodes("//trk[1]/trkseg[1]/trkpt"); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (trkptNodes == null) | ||||||||||||||||||||||||||||||||||||||||||||||||
| yield break; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| foreach (XmlNode node in trkptNodes) | ||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| var latAttr = node.Attributes?["lat"]?.Value; | ||||||||||||||||||||||||||||||||||||||||||||||||
| var lonAttr = node.Attributes?["lon"]?.Value; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (latAttr == null || lonAttr == null) | ||||||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| double lat = double.Parse(latAttr, CultureInfo.InvariantCulture); | ||||||||||||||||||||||||||||||||||||||||||||||||
| double lon = double.Parse(lonAttr, CultureInfo.InvariantCulture); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| var eleNode = node.SelectSingleNode("gpx:ele", ns) ?? node.SelectSingleNode("ele"); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (eleNode == null) | ||||||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||||||
| double elevation = double.Parse(eleNode.InnerText, CultureInfo.InvariantCulture); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+35
to
+42
|
||||||||||||||||||||||||||||||||||||||||||||||||
| double lat = double.Parse(latAttr, CultureInfo.InvariantCulture); | |
| double lon = double.Parse(lonAttr, CultureInfo.InvariantCulture); | |
| var eleNode = node.SelectSingleNode("gpx:ele", ns) ?? node.SelectSingleNode("ele"); | |
| if (eleNode == null) | |
| continue; | |
| double elevation = double.Parse(eleNode.InnerText, CultureInfo.InvariantCulture); | |
| if (!double.TryParse(latAttr, NumberStyles.Float, CultureInfo.InvariantCulture, out double lat)) | |
| throw new FormatException($"Invalid latitude value '{latAttr}' in GPX track point."); | |
| if (!double.TryParse(lonAttr, NumberStyles.Float, CultureInfo.InvariantCulture, out double lon)) | |
| throw new FormatException($"Invalid longitude value '{lonAttr}' in GPX track point."); | |
| var eleNode = node.SelectSingleNode("gpx:ele", ns) ?? node.SelectSingleNode("ele"); | |
| if (eleNode == null) | |
| continue; | |
| if (!double.TryParse(eleNode.InnerText, NumberStyles.Float, CultureInfo.InvariantCulture, out double elevation)) | |
| throw new FormatException($"Invalid elevation value '{eleNode.InnerText}' in GPX track point."); |
Copilot
AI
Feb 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing error handling for malformed elevation values. If double.Parse fails, the exception will propagate up without a descriptive error message. Consider using double.TryParse and either skipping invalid points with a warning or providing a clearer error message indicating which track point has invalid elevation data.
| double elevation = double.Parse(eleNode.InnerText, CultureInfo.InvariantCulture); | |
| if (!double.TryParse(eleNode.InnerText, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var elevation)) | |
| throw new FormatException($"Invalid elevation value '{eleNode.InnerText}' in GPX track point."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unnecessary fully qualified type name. Add 'using System.Collections.Generic;' at the top of the file and change this line to just 'IEnumerable points;' for consistency with the rest of the code.