From a5e1a23d56ee443cba110f1818165635a14a5ce1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:50:52 +0000 Subject: [PATCH 1/4] Initial plan From b1375fc53de7c4d497ef5516e1e8e8784d596f54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:56:04 +0000 Subject: [PATCH 2/4] Refactor App into Route-Based Simulation (GPX First Step) Co-authored-by: simonech <61557+simonech@users.noreply.github.com> --- .../VirtualSpeed/Model/RouteSegment.cs | 10 + VirtualSpeed/VirtualSpeed/Model/TrackPoint.cs | 11 + VirtualSpeed/VirtualSpeed/Program.cs | 190 +++--------------- .../VirtualSpeed/Services/GpxParser.cs | 52 +++++ .../VirtualSpeed/Services/RouteBuilder.cs | 105 ++++++++++ VirtualSpeed/VirtualSpeed/VirtualSpeed.csproj | 2 +- VirtualSpeed/VirtualSpeed/route.gpx | 34 ++++ 7 files changed, 241 insertions(+), 163 deletions(-) create mode 100644 VirtualSpeed/VirtualSpeed/Model/RouteSegment.cs create mode 100644 VirtualSpeed/VirtualSpeed/Model/TrackPoint.cs create mode 100644 VirtualSpeed/VirtualSpeed/Services/GpxParser.cs create mode 100644 VirtualSpeed/VirtualSpeed/Services/RouteBuilder.cs create mode 100644 VirtualSpeed/VirtualSpeed/route.gpx diff --git a/VirtualSpeed/VirtualSpeed/Model/RouteSegment.cs b/VirtualSpeed/VirtualSpeed/Model/RouteSegment.cs new file mode 100644 index 0000000..c60153c --- /dev/null +++ b/VirtualSpeed/VirtualSpeed/Model/RouteSegment.cs @@ -0,0 +1,10 @@ +namespace VirtualSpeed.Model +{ + public record RouteSegment( + double StartDistanceMeters, + double LengthMeters, + double StartElevationMeters, + double EndElevationMeters, + double AverageGradient + ); +} diff --git a/VirtualSpeed/VirtualSpeed/Model/TrackPoint.cs b/VirtualSpeed/VirtualSpeed/Model/TrackPoint.cs new file mode 100644 index 0000000..3778b83 --- /dev/null +++ b/VirtualSpeed/VirtualSpeed/Model/TrackPoint.cs @@ -0,0 +1,11 @@ +using System; + +namespace VirtualSpeed.Model +{ + public record TrackPoint( + double Latitude, + double Longitude, + double ElevationMeters, + DateTime? Timestamp + ); +} diff --git a/VirtualSpeed/VirtualSpeed/Program.cs b/VirtualSpeed/VirtualSpeed/Program.cs index 48018c8..192db02 100644 --- a/VirtualSpeed/VirtualSpeed/Program.cs +++ b/VirtualSpeed/VirtualSpeed/Program.cs @@ -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 "); 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 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; } - 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(); } - } - - } + diff --git a/VirtualSpeed/VirtualSpeed/Services/GpxParser.cs b/VirtualSpeed/VirtualSpeed/Services/GpxParser.cs new file mode 100644 index 0000000..0dc1edd --- /dev/null +++ b/VirtualSpeed/VirtualSpeed/Services/GpxParser.cs @@ -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 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); + + double elevation = 0; + var eleNode = node.SelectSingleNode("gpx:ele", ns) ?? node.SelectSingleNode("ele"); + if (eleNode != null) + elevation = double.Parse(eleNode.InnerText, CultureInfo.InvariantCulture); + + DateTime? timestamp = null; + var timeNode = node.SelectSingleNode("gpx:time", ns) ?? node.SelectSingleNode("time"); + if (timeNode != null && DateTime.TryParse(timeNode.InnerText, null, DateTimeStyles.RoundtripKind, out var parsedTime)) + timestamp = parsedTime; + + yield return new TrackPoint(lat, lon, elevation, timestamp); + } + } + } +} diff --git a/VirtualSpeed/VirtualSpeed/Services/RouteBuilder.cs b/VirtualSpeed/VirtualSpeed/Services/RouteBuilder.cs new file mode 100644 index 0000000..8e91405 --- /dev/null +++ b/VirtualSpeed/VirtualSpeed/Services/RouteBuilder.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using VirtualSpeed.Model; + +namespace VirtualSpeed.Services +{ + public class RouteBuilder + { + private readonly double _segmentLengthMeters; + + public RouteBuilder(double segmentLengthMeters = 100.0) + { + _segmentLengthMeters = segmentLengthMeters; + } + + public IReadOnlyList Build(IEnumerable points) + { + var pointList = points.ToList(); + + // Sort by timestamp if all points have one + if (pointList.All(p => p.Timestamp.HasValue)) + pointList = pointList.OrderBy(p => p.Timestamp!.Value).ToList(); + + var segments = new List(); + + if (pointList.Count < 2) + return segments; + + double segmentStart = 0.0; + double segmentStartElevation = pointList[0].ElevationMeters; + double accumulatedDistance = 0.0; + + for (int i = 1; i < pointList.Count; i++) + { + var prev = pointList[i - 1]; + var curr = pointList[i]; + + double stepDistance = HaversineDistance(prev, curr); + accumulatedDistance += stepDistance; + + while (accumulatedDistance >= _segmentLengthMeters) + { + // How far into this step does the segment boundary fall? + double overshoot = accumulatedDistance - _segmentLengthMeters; + double fraction = (stepDistance - overshoot) / stepDistance; + + double boundaryElevation = prev.ElevationMeters + fraction * (curr.ElevationMeters - prev.ElevationMeters); + + double gradient = _segmentLengthMeters > 0 + ? (boundaryElevation - segmentStartElevation) / _segmentLengthMeters + : 0.0; + + segments.Add(new RouteSegment( + segmentStart, + _segmentLengthMeters, + segmentStartElevation, + boundaryElevation, + gradient + )); + + segmentStart += _segmentLengthMeters; + segmentStartElevation = boundaryElevation; + accumulatedDistance -= _segmentLengthMeters; + } + } + + // Final (possibly shorter) segment + double lastElevation = pointList[pointList.Count - 1].ElevationMeters; + double remaining = accumulatedDistance; + if (remaining > 0) + { + double gradient = (lastElevation - segmentStartElevation) / remaining; + + segments.Add(new RouteSegment( + segmentStart, + remaining, + segmentStartElevation, + lastElevation, + gradient + )); + } + + return segments; + } + + private static double HaversineDistance(TrackPoint a, TrackPoint b) + { + const double R = 6371000.0; // Earth radius in meters + + double lat1 = ToRadians(a.Latitude); + double lat2 = ToRadians(b.Latitude); + double dLat = ToRadians(b.Latitude - a.Latitude); + double dLon = ToRadians(b.Longitude - a.Longitude); + + double h = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(lat1) * Math.Cos(lat2) + * Math.Sin(dLon / 2) * Math.Sin(dLon / 2); + + return R * 2 * Math.Atan2(Math.Sqrt(h), Math.Sqrt(1 - h)); + } + + private static double ToRadians(double degrees) => degrees * Math.PI / 180.0; + } +} diff --git a/VirtualSpeed/VirtualSpeed/VirtualSpeed.csproj b/VirtualSpeed/VirtualSpeed/VirtualSpeed.csproj index 7fd79c5..ef3cf61 100644 --- a/VirtualSpeed/VirtualSpeed/VirtualSpeed.csproj +++ b/VirtualSpeed/VirtualSpeed/VirtualSpeed.csproj @@ -8,7 +8,7 @@ - + Always diff --git a/VirtualSpeed/VirtualSpeed/route.gpx b/VirtualSpeed/VirtualSpeed/route.gpx new file mode 100644 index 0000000..00e2261 --- /dev/null +++ b/VirtualSpeed/VirtualSpeed/route.gpx @@ -0,0 +1,34 @@ + + + + Sample Route + + + Sample Track + + 300.0 + 303.0 + 306.5 + 310.0 + 314.0 + 318.5 + 323.0 + 327.5 + 331.0 + 334.0 + 336.5 + 338.0 + 339.0 + 340.0 + 341.5 + 343.0 + 345.0 + 347.5 + 350.0 + 352.0 + + + From 83592f4eaf79e0141535bfb249d272fc43e6e37c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:15:01 +0000 Subject: [PATCH 3/4] Address review feedback: DateTimeOffset, duplicate point guard, workflow update Co-authored-by: simonech <61557+simonech@users.noreply.github.com> --- .github/workflows/build.yml | 10 +++++----- VirtualSpeed/VirtualSpeed/Model/TrackPoint.cs | 2 +- VirtualSpeed/VirtualSpeed/Services/GpxParser.cs | 4 ++-- VirtualSpeed/VirtualSpeed/Services/RouteBuilder.cs | 2 ++ 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c8ce1f..c4e1db1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,11 +30,11 @@ jobs: - name: Test application runs run: | cd VirtualSpeed/VirtualSpeed - dotnet run --no-build --configuration Release -- powerData.tcx - if [ -f powerData_fixed.tcx ]; then - echo "✓ Application successfully generated output file" - ls -lh powerData_fixed.tcx + output=$(dotnet run --no-build --configuration Release -- --gpx route.gpx) + echo "$output" + if echo "$output" | grep -q "Total Distance:"; then + echo "✓ Application produced expected output" else - echo "✗ Application did not generate expected output file" + echo "✗ Application did not produce expected output" exit 1 fi diff --git a/VirtualSpeed/VirtualSpeed/Model/TrackPoint.cs b/VirtualSpeed/VirtualSpeed/Model/TrackPoint.cs index 3778b83..3c2c2aa 100644 --- a/VirtualSpeed/VirtualSpeed/Model/TrackPoint.cs +++ b/VirtualSpeed/VirtualSpeed/Model/TrackPoint.cs @@ -6,6 +6,6 @@ public record TrackPoint( double Latitude, double Longitude, double ElevationMeters, - DateTime? Timestamp + DateTimeOffset? Timestamp ); } diff --git a/VirtualSpeed/VirtualSpeed/Services/GpxParser.cs b/VirtualSpeed/VirtualSpeed/Services/GpxParser.cs index 0dc1edd..aff1234 100644 --- a/VirtualSpeed/VirtualSpeed/Services/GpxParser.cs +++ b/VirtualSpeed/VirtualSpeed/Services/GpxParser.cs @@ -40,9 +40,9 @@ public IEnumerable Parse(string filePath) if (eleNode != null) elevation = double.Parse(eleNode.InnerText, CultureInfo.InvariantCulture); - DateTime? timestamp = null; + DateTimeOffset? timestamp = null; var timeNode = node.SelectSingleNode("gpx:time", ns) ?? node.SelectSingleNode("time"); - if (timeNode != null && DateTime.TryParse(timeNode.InnerText, null, DateTimeStyles.RoundtripKind, out var parsedTime)) + if (timeNode != null && DateTimeOffset.TryParse(timeNode.InnerText, null, DateTimeStyles.RoundtripKind, out var parsedTime)) timestamp = parsedTime; yield return new TrackPoint(lat, lon, elevation, timestamp); diff --git a/VirtualSpeed/VirtualSpeed/Services/RouteBuilder.cs b/VirtualSpeed/VirtualSpeed/Services/RouteBuilder.cs index 8e91405..ae7249c 100644 --- a/VirtualSpeed/VirtualSpeed/Services/RouteBuilder.cs +++ b/VirtualSpeed/VirtualSpeed/Services/RouteBuilder.cs @@ -37,6 +37,8 @@ public IReadOnlyList Build(IEnumerable points) var curr = pointList[i]; double stepDistance = HaversineDistance(prev, curr); + if (stepDistance <= 0) + continue; accumulatedDistance += stepDistance; while (accumulatedDistance >= _segmentLengthMeters) From 86df64455b3f7753f2bc5e50e0e0da4edfeafc6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:28:01 +0000 Subject: [PATCH 4/4] Skip track points with missing elevation in GpxParser Co-authored-by: simonech <61557+simonech@users.noreply.github.com> --- VirtualSpeed/VirtualSpeed/Services/GpxParser.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/VirtualSpeed/VirtualSpeed/Services/GpxParser.cs b/VirtualSpeed/VirtualSpeed/Services/GpxParser.cs index aff1234..ccc59a9 100644 --- a/VirtualSpeed/VirtualSpeed/Services/GpxParser.cs +++ b/VirtualSpeed/VirtualSpeed/Services/GpxParser.cs @@ -35,10 +35,10 @@ public IEnumerable Parse(string filePath) double lat = double.Parse(latAttr, CultureInfo.InvariantCulture); double lon = double.Parse(lonAttr, CultureInfo.InvariantCulture); - double elevation = 0; var eleNode = node.SelectSingleNode("gpx:ele", ns) ?? node.SelectSingleNode("ele"); - if (eleNode != null) - elevation = double.Parse(eleNode.InnerText, CultureInfo.InvariantCulture); + if (eleNode == null) + continue; + double elevation = double.Parse(eleNode.InnerText, CultureInfo.InvariantCulture); DateTimeOffset? timestamp = null; var timeNode = node.SelectSingleNode("gpx:time", ns) ?? node.SelectSingleNode("time");