Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions VirtualSpeed/VirtualSpeed/Model/RouteSegment.cs
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
);
}
11 changes: 11 additions & 0 deletions VirtualSpeed/VirtualSpeed/Model/TrackPoint.cs
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
);
}
190 changes: 28 additions & 162 deletions VirtualSpeed/VirtualSpeed/Program.cs
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;
Copy link

Copilot AI Feb 19, 2026

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.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lazy enumeration from Parse() means parsing errors will occur after this try-catch block exits, when Build() materializes the IEnumerable. Consider calling .ToList() on the parsed points within the try-catch to ensure parsing errors are properly caught and reported with the "Error while loading GPX file" message.

Copilot uses AI. Check for mistakes.

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();
}

}


}

52 changes: 52 additions & 0 deletions VirtualSpeed/VirtualSpeed/Services/GpxParser.cs
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
Copy link

Copilot AI Feb 19, 2026

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 latitude and longitude 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 coordinates.

Suggested change
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 uses AI. Check for mistakes.
Comment on lines +41 to +42
Copy link

Copilot AI Feb 19, 2026

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.

Suggested change
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.");

Copilot uses AI. Check for mistakes.
DateTimeOffset? timestamp = null;
var timeNode = node.SelectSingleNode("gpx:time", ns) ?? node.SelectSingleNode("time");
if (timeNode != null && DateTimeOffset.TryParse(timeNode.InnerText, null, DateTimeStyles.RoundtripKind, out var parsedTime))
timestamp = parsedTime;

yield return new TrackPoint(lat, lon, elevation, timestamp);
}
}
}
}
Loading