Corefunctions/Update_TCX_File_2026-v06.ps1
|
<#
.SYNOPSIS Updates a Garmin TCX activity file by recalculating and rewriting lap and trackpoint distance/elevation values. .DESCRIPTION This script loads an existing TCX activity and updates each lap using values from the $ActivityLaps configuration table. For each lap, it supports two input modes: - Option A: Calculate lap distance and ascent from treadmill speed (km/h) and incline (%), based on the lap duration found in the TCX file. - Option B: Use manually provided lap distance and ascent values directly. After lap-level values are determined, the script distributes distance and ascent evenly across all trackpoints in that lap, then: - Updates each trackpoint's AltitudeMeters and DistanceMeters (cumulative values). - Removes Position nodes from updated trackpoints. - Updates the lap-level DistanceMeters. Finally, the modified activity is saved as a new file with "_updated" appended to the original TCX file name. .NOTES - Input and output file paths are currently hard-coded in the script. - The number and order of entries in $ActivityLaps must match the number and order of laps in the TCX activity. - Existing altitude and distance values are overwritten. - Position data is removed intentionally for each processed trackpoint. .EXAMPLE Run the script directly: PS> .\Update_TCX_File_2026-05-11.ps1 The script reads the configured TCX file, applies lap updates, and writes "*_updated.tcx" next to the source file. #> #$tcxFile = "C:\Users\Holger\OneDrive - mrhozi\#DailyBuild\GarminConnect\Upload\activity_19083016534.tcx" #$tcxFile = "C:\Users\HolgerZimmermann\OneDrive - mrhozi\#DailyBuild\GarminConnect\Upload\activity_22938666273.tcx" #$tcxFile = "C:\Users\Holger\OneDrive - mrhozi\#DailyBuild\GarminConnect\Upload\activity_19083016534.tcx" [CmdletBinding()] param( [Parameter(Position = 0)] [ValidatePattern('(?i)\.tcx$')] [string]$tcxFile ) if (-not $tcxFile) { Add-Type -AssemblyName System.Windows.Forms $dialog = New-Object System.Windows.Forms.OpenFileDialog $dialog.Title = "Select TCX input file" $dialog.Filter = "TCX files (*.tcx)|*.tcx" $dialog.Multiselect = $false $dialog.CheckFileExists = $true $dialog.InitialDirectory = (Get-Location).Path $dialogResult = $dialog.ShowDialog() if ($dialogResult -ne [System.Windows.Forms.DialogResult]::OK -or [string]::IsNullOrWhiteSpace($dialog.FileName)) { throw "No TCX file selected. Script canceled." } $tcxFile = $dialog.FileName } if (-not (Test-Path -LiteralPath $tcxFile -PathType Leaf)) { throw "TCX file not found: $tcxFile" } if ([System.IO.Path]::GetExtension($tcxFile) -notmatch '^(?i)\.tcx$') { throw "Only .tcx files are allowed: $tcxFile" } $tcxFile = (Resolve-Path -LiteralPath $tcxFile).Path $UpdatedtcxFile = $tcxFile.replace(".tcx", "_updated.tcx") <# Option A: Calculate Distance and Ascent based on Speed and Incline Incline in percent, e.g. 10.0 = 10% Speed in km/h, e.g. 8.0 = 8 km/h Option B: Use distance and ascent from cardio machine Distance in meters, e.g. 1000.0 = 1 kilometer Ascent in meters, e.g. 10.0 = 10 meters #> $ActivityLaps = ( [pscustomobject]@{Lap = "01"; Option = "Stairmaster"; Speed = "9.0"; Incline = "3.0"; Distance = "1100"; Ascent = "0.0"; Description = "Warm Up" }, [pscustomobject]@{Lap = "02"; Option = "Stairmaster"; Speed = "8.0"; Incline = "12.5"; Distance = "1400"; Ascent = "187"; Description = "Warm Up" }, [pscustomobject]@{Lap = "03"; Option = "Stairmaster"; Speed = "6.0"; Incline = "15.0"; Distance = "400"; Ascent = "0"; Description = "Exercise" }, [pscustomobject]@{Lap = "04"; Option = "Stairmaster"; Speed = "5.0"; Incline = "15.0"; Distance = "1350"; Ascent = "182"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "05"; Option = "Stairmaster"; Speed = "6.0"; Incline = "15.0"; Distance = "400"; Ascent = "0.0"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "06"; Option = "Stairmaster"; Speed = "5.0"; Incline = "15.0"; Distance = "1450.0"; Ascent = "190.0"; Description = "Einheit 3" }, [pscustomobject]@{Lap = "07"; Option = "Stairmaster"; Speed = "6.0"; Incline = "15.0"; Distance = "200"; Ascent = "0"; Description = "Einheit 3" }, [pscustomobject]@{Lap = "08"; Option = "Stairmaster"; Speed = "5.5"; Incline = "15.0"; Distance = "1000"; Ascent = "80"; Description = "Einheit 4" }, [pscustomobject]@{Lap = "09"; Option = "Stairmaster"; Speed = "6.0"; Incline = "15.0"; Distance = "145"; Ascent = "0"; Description = "Einheit 4" }, [pscustomobject]@{Lap = "10"; Option = "Stairmaster"; Speed = "5.0"; Incline = "15.0"; Distance = "800"; Ascent = "0"; Description = "Einheit 5" }, [pscustomobject]@{Lap = "11"; Option = "Treadmill"; Speed = "6.0"; Incline = "15.0"; Distance = "1100"; Ascent = "227"; Description = "Einheit 5" }, [pscustomobject]@{Lap = "12"; Option = "Treadmill"; Speed = "5.0"; Incline = "15.0"; Distance = "900"; Ascent = "185"; Description = "Einheit 6" }, [pscustomobject]@{Lap = "13"; Option = "Treadmill"; Speed = "6.0"; Incline = "15.0"; Distance = "650"; Ascent = "0"; Description = "Cool Down" }, [pscustomobject]@{Lap = "14"; Option = "Treadmill"; Speed = "5.0"; Incline = "15.0"; Distance = "1000.0"; Ascent = "-44.0"; Description = "Cool Down" }, [pscustomobject]@{Lap = "15"; Option = "Treadmill"; Speed = "6.0"; Incline = "15.0"; Distance = "1000.0"; Ascent = "-6"; Description = "Lap 15" }, [pscustomobject]@{Lap = "16"; Option = "Treadmill"; Speed = "4.5"; Incline = "15.0"; Distance = "640.0"; Ascent = "-12"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "17"; Option = "Treadmill"; Speed = "5.7"; Incline = "15.0"; Distance = "1300.0"; Ascent = "350"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "18"; Option = "Treadmill"; Speed = "3.0"; Incline = "3.0"; Distance = "140.0"; Ascent = "0"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "19"; Option = "Stairmaster"; Speed = "8.0"; Incline = "8.0"; Distance = "1200.0"; Ascent = "318"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "20"; Option = "Stairmaster"; Speed = "8.0"; Incline = "8.0"; Distance = "10"; Ascent = "0"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "21"; Option = "Stairmaster"; Speed = "8.0"; Incline = "8.0"; Distance = "25"; Ascent = "0"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "22"; Option = "Stairmaster"; Speed = "8.0"; Incline = "8.0"; Distance = "0"; Ascent = "0"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "23"; Option = "Stairmaster"; Speed = "8.0"; Incline = "8.0"; Distance = "270.0"; Ascent = "0"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "24"; Option = "Stairmaster"; Speed = "8.0"; Incline = "8.0"; Distance = "0"; Ascent = "0"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "25"; Option = "Stairmaster"; Speed = "8.0"; Incline = "8.0"; Distance = "0"; Ascent = "0"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "26"; Option = "Stairmaster"; Speed = "8.0"; Incline = "8.0"; Distance = "0"; Ascent = "0"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "27"; Option = "Stairmaster"; Speed = "8.0"; Incline = "8.0"; Distance = "0"; Ascent = "0"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "28"; Option = "Stairmaster"; Speed = "8.0"; Incline = "8.0"; Distance = "0"; Ascent = "0"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "29"; Option = "Stairmaster"; Speed = "8.0"; Incline = "8.0"; Distance = "0"; Ascent = "0"; Description = "Einheit 1" }, [pscustomobject]@{Lap = "30"; Option = "Stairmaster"; Speed = "8.0"; Incline = "8.0"; Distance = "0"; Ascent = "0"; Description = "Einheit 1" } ) function Edit-ActivityLapsInGrid { param ( [Parameter(Mandatory = $true)] [object[]]$InputLaps, [object[]]$LapNodes, [int]$NumberOfLaps = 30 ) Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing $form = New-Object System.Windows.Forms.Form $form.Text = "Edit Activity Laps | Option A: Calculate Distance/Ascent from Speed/Incline (Treadmill) | Option B: Use provided Distance/Ascent values (Stairmaster)" $form.StartPosition = "CenterScreen" $form.Size = New-Object System.Drawing.Size(1100, 700) $grid = New-Object System.Windows.Forms.DataGridView $grid.Dock = [System.Windows.Forms.DockStyle]::Fill $grid.AutoSizeColumnsMode = [System.Windows.Forms.DataGridViewAutoSizeColumnsMode]::Fill $grid.AutoGenerateColumns = $false $grid.AllowUserToAddRows = $false $grid.AllowUserToDeleteRows = $false $grid.SelectionMode = [System.Windows.Forms.DataGridViewSelectionMode]::CellSelect $grid.add_DataError({ param($sender, $e) $e.ThrowException = $false }) $dataTable = New-Object System.Data.DataTable foreach ($columnName in @("Lap", "TotalTimeSeconds", "Option", "Speed", "Incline", "Distance", "Ascent", "Description")) { [void]$dataTable.Columns.Add($columnName, [string]) } $counter = 0 foreach ($lap in $InputLaps) { $row = $dataTable.NewRow() $row["Lap"] = [string]$lap.Lap $lapDurationSeconds = 0.0 if ($LapNodes -and $counter -lt $LapNodes.Count) { $rawTotalTimeSeconds = [string]$LapNodes[$counter].TotalTimeSeconds if (-not [double]::TryParse($rawTotalTimeSeconds, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$lapDurationSeconds)) { [void][double]::TryParse($rawTotalTimeSeconds, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::CurrentCulture, [ref]$lapDurationSeconds) } } $row["TotalTimeSeconds"] = [TimeSpan]::FromSeconds($lapDurationSeconds).ToString("hh\:mm\:ss") $optionValue = ([string]$lap.Option) if ($optionValue -notin @("Treadmill", "Stairmaster")) { $optionValue = "Stairmaster" } $row["Option"] = $optionValue $row["Description"] = [string]$lap.Description $row["Speed"] = [string]$lap.Speed $row["Incline"] = [string]$lap.Incline $row["Distance"] = [string]$lap.Distance $row["Ascent"] = [string]$lap.Ascent [void]$dataTable.Rows.Add($row) $counter++ If ($counter -ge $NumberOfLaps) { break } } $grid.Columns.Clear() $lapColumn = New-Object System.Windows.Forms.DataGridViewTextBoxColumn $lapColumn.Name = "Lap" $lapColumn.HeaderText = "Lap" $lapColumn.DataPropertyName = "Lap" [void]$grid.Columns.Add($lapColumn) $timeColumn = New-Object System.Windows.Forms.DataGridViewTextBoxColumn $timeColumn.Name = "TotalTimeSeconds" $timeColumn.HeaderText = "TotalTimeSeconds" $timeColumn.DataPropertyName = "TotalTimeSeconds" $timeColumn.ReadOnly = $true [void]$grid.Columns.Add($timeColumn) $descriptionColumn = New-Object System.Windows.Forms.DataGridViewComboBoxColumn $descriptionColumn.Name = "Description" $descriptionColumn.HeaderText = "Description" $descriptionColumn.DataPropertyName = "Description" [void]$descriptionColumn.Items.Add("Warm Up") [void]$descriptionColumn.Items.Add("Hike") [void]$descriptionColumn.Items.Add("Run") [void]$descriptionColumn.Items.Add("Pause") [void]$descriptionColumn.Items.Add("Cool Down") [void]$grid.Columns.Add($descriptionColumn) $optionColumn = New-Object System.Windows.Forms.DataGridViewComboBoxColumn $optionColumn.Name = "Option" $optionColumn.HeaderText = "Option" $optionColumn.DataPropertyName = "Option" [void]$optionColumn.Items.Add("Treadmill") [void]$optionColumn.Items.Add("Stairmaster") [void]$grid.Columns.Add($optionColumn) foreach ($columnName in @("Speed", "Incline", "Distance", "Ascent")) { $textColumn = New-Object System.Windows.Forms.DataGridViewTextBoxColumn $textColumn.Name = $columnName $textColumn.HeaderText = $columnName $textColumn.DataPropertyName = $columnName [void]$grid.Columns.Add($textColumn) } $grid.DataSource = $dataTable $activeColor = [System.Drawing.Color]::FromArgb(230, 245, 255) $inactiveColor = [System.Drawing.Color]::FromArgb(242, 242, 242) $applyRowStyle = { param($row) if ($null -eq $row -or $row.IsNewRow) { return } $option = [string]$row.Cells["Option"].Value $speedCell = $row.Cells["Speed"] $inclineCell = $row.Cells["Incline"] $distanceCell = $row.Cells["Distance"] $ascentCell = $row.Cells["Ascent"] if ($option -eq "Treadmill") { $speedCell.Style.BackColor = $activeColor $inclineCell.Style.BackColor = $activeColor $distanceCell.Style.BackColor = $inactiveColor $ascentCell.Style.BackColor = $inactiveColor $speedCell.ReadOnly = $false $inclineCell.ReadOnly = $false $distanceCell.ReadOnly = $true $ascentCell.ReadOnly = $true } else { $speedCell.Style.BackColor = $inactiveColor $inclineCell.Style.BackColor = $inactiveColor $distanceCell.Style.BackColor = $activeColor $ascentCell.Style.BackColor = $activeColor $speedCell.ReadOnly = $true $inclineCell.ReadOnly = $true $distanceCell.ReadOnly = $false $ascentCell.ReadOnly = $false } } $grid.add_DataBindingComplete({ param($sender, $e) foreach ($row in $sender.Rows) { & $applyRowStyle $row } }) $grid.add_CellValueChanged({ param($sender, $e) if ($e.RowIndex -lt 0) { return } if ($sender.Columns[$e.ColumnIndex].Name -eq "Option") { & $applyRowStyle $sender.Rows[$e.RowIndex] } }) $grid.add_CurrentCellDirtyStateChanged({ param($sender, $e) if ($sender.IsCurrentCellDirty -and $sender.CurrentCell -and $sender.CurrentCell.OwningColumn.Name -eq "Option") { $sender.CommitEdit([System.Windows.Forms.DataGridViewDataErrorContexts]::Commit) } }) $buttonPanel = New-Object System.Windows.Forms.Panel $buttonPanel.Dock = [System.Windows.Forms.DockStyle]::Bottom $buttonPanel.Height = 50 $okButton = New-Object System.Windows.Forms.Button $okButton.Text = "OK" $okButton.Width = 120 $okButton.Height = 30 $okButton.Left = 10 $okButton.Top = 10 $okButton.DialogResult = [System.Windows.Forms.DialogResult]::OK $cancelButton = New-Object System.Windows.Forms.Button $cancelButton.Text = "Cancel" $cancelButton.Width = 120 $cancelButton.Height = 30 $cancelButton.Left = 140 $cancelButton.Top = 10 $cancelButton.DialogResult = [System.Windows.Forms.DialogResult]::Cancel [void]$buttonPanel.Controls.Add($okButton) [void]$buttonPanel.Controls.Add($cancelButton) [void]$form.Controls.Add($grid) [void]$form.Controls.Add($buttonPanel) $form.AcceptButton = $okButton $form.CancelButton = $cancelButton $dialogResult = $form.ShowDialog() if ($dialogResult -ne [System.Windows.Forms.DialogResult]::OK) { return $null } $result = @() foreach ($row in $dataTable.Rows) { $result += [pscustomobject]@{ Lap = [string]$row.Lap TotalTimeSeconds = [string]$row.TotalTimeSeconds Option = [string]$row.Option Speed = [string]$row.Speed Incline = [string]$row.Incline Distance = [string]$row.Distance Ascent = [string]$row.Ascent Description = [string]$row.Description } } return $result } function Convert-KmhToPace { param ( [double]$SpeedKmh ) if ($SpeedKmh -le 0) { throw "Speed in km/h must be greater than 0" } # Minutes per kilometer $minutesPerKm = 60 / $SpeedKmh # Calculate whole minutes and remaining seconds $minutes = [math]::Floor($minutesPerKm) $seconds = [math]::Round(($minutesPerKm - $minutes) * 60) # Format result return "{0} min {1} sec" -f $minutes, $seconds } function Get-DistanceInMeters { param ( [float]$SpeedKmh, # Speed in km/h [float]$TimeSeconds # Time in seconds ) # Convert km/h to m/s $speedMs = $SpeedKmh * 1000 / 3600 # Calculate distance in meters $distanceMeters = $speedMs * $TimeSeconds # Return distance in meters return $distanceMeters } function Get-HeightGain { param ( [float]$DistanceMeters, # Horizontal distance in meters [float]$SlopePercentage # Incline in percent ) # Convert incline to decimal (percentage / 100) $slopeDecimal = $SlopePercentage / 100 # Calculate elevation gain $heightGainMeters = $DistanceMeters * $slopeDecimal # Return result return $heightGainMeters } function Get-StairMasterMetrics { [CmdletBinding()] param( [Parameter(Mandatory)] [double]$InclineAngle, [Parameter(Mandatory)] [double]$ElevationGain, [Parameter()] [double]$DurationMinutes = 30 ) $angleRad = $InclineAngle * [Math]::PI / 180 $horizontalDistance = $ElevationGain / [Math]::Tan($angleRad) $totalDistance = $ElevationGain / [Math]::Sin($angleRad) $averageGrade = ($ElevationGain / $horizontalDistance) * 100 $verticalSpeed = ($ElevationGain / $DurationMinutes) * 60 [PSCustomObject]@{ InclineAngle_Degrees = [Math]::Round($InclineAngle, 2) ElevationGain_Meters = [Math]::Round($ElevationGain, 2) Duration_Minutes = [Math]::Round($DurationMinutes, 2) HorizontalDistance_Meters = [Math]::Round($horizontalDistance, 2) TotalDistance_Meters = [Math]::Round($totalDistance, 2) AverageGrade_Percent = [Math]::Round($averageGrade, 2) VerticalSpeed_m_per_h = [Math]::Round($verticalSpeed, 2) } } # Check if the file exists if (-not (Test-Path $tcxFile)) { Write-Host "File not found: $tcxFile" -ForegroundColor Red exit } # load TCX-file [xml]$tcx = Get-Content $tcxFile # number of Laps $NoLaps = ($tcx.TrainingCenterDatabase.Activities.Activity.Lap).count Write-host "Number of Laps: $NoLaps" -ForegroundColor Green $allLaps = $tcx.TrainingCenterDatabase.Activities.Activity.Lap $allLaps | Select-Object TotalTimeSeconds, DistanceMeters,Intensity | Format-Table $editedLaps = Edit-ActivityLapsInGrid -InputLaps $ActivityLaps -LapNodes $allLaps -NumberOfLaps $NoLaps if ($null -eq $editedLaps) { Write-Host "Canceled by user. No TCX changes were written." -ForegroundColor Yellow exit } $ActivityLaps = $editedLaps if ($ActivityLaps.Count -ne $NoLaps) { Write-Host "ActivityLaps count ($($ActivityLaps.Count)) does not match TCX lap count ($NoLaps)." -ForegroundColor Red exit } $script:i = 1 $allLaps | Select-Object @{Name='Lab';Expression={$script:i; $script:i++}}, StartTime, TotalTimeSeconds, DistanceMeters | Format-Table [double]$startAltitude = 0 [double]$Distance = 625.0 [double]$StartDistance = 0 $i = 0 foreach ($Lap in $allLaps) { If ($ActivityLaps[$i].Option -eq "Treadmill") { $ActivityLaps[$i].Distance = Get-DistanceInMeters -SpeedKmh $ActivityLaps[$i].Speed -TimeSeconds $Lap.TotalTimeSeconds $ActivityLaps[$i].Ascent = Get-HeightGain -DistanceMeters $ActivityLaps[$i].Distance -SlopePercentage $ActivityLaps[$i].Incline } $i += 1 } $ActivityLaps | Format-Table pause $i = 0 $previousCumulativeAltitude = 0 foreach ($Lap in $allLaps) { [double]$Distance = 0.0 [double]$Ascent = 0.0 [double]$CumulativeAltitude = 0.0 [double]$CumulativeDistance = 0.0 $NoTrackPoints = ($Lap.Track.Trackpoint).count $Ascent = $ActivityLaps[$i].Ascent / $NoTrackPoints $Ascent = [math]::Round($Ascent, 3) $Distance = $ActivityLaps[$i].Distance / $NoTrackPoints $Distance = [math]::Round($Distance, 3) $lab = $i +1 write-host "[>] Updating Lap $lab`/$NoLaps | $($Lap.StartTime) with '$NoTrackPoints' TrackPoints ... " -ForegroundColor Yellow $CumulativeAltitude = $startAltitude $CumulativeDistance = $StartDistance foreach ($tp in $Lap.track.trackpoint) { if ($tp.AltitudeMeters -and $tp.AltitudeMeters -match '^\d+(\.\d+)?$') { $CumulativeAltitude += $Ascent $tp.AltitudeMeters = $CumulativeAltitude.ToString("F6", [System.Globalization.CultureInfo]::InvariantCulture) $CumulativeDistance += $Distance $tp.DistanceMeters = $CumulativeDistance.ToString("F6", [System.Globalization.CultureInfo]::InvariantCulture) $pos = $tp.Position if ($null -ne $pos) { $tp.RemoveChild($pos) | Out-Null } } } $temp = $ActivityLaps[$i].Distance $Lap.DistanceMeters = ([double]$temp).ToString("F6", [System.Globalization.CultureInfo]::InvariantCulture) $startAltitude = $CumulativeAltitude $StartDistance = $CumulativeDistance Write-Host " ... done added $([int]$Lap.DistanceMeters) meters and $($ActivityLaps[$i].Ascent) meters (ascent) [Total elevation: $([int]$CumulativeAltitude) m] to $($Lap.StartTime)" -ForegroundColor Cyan $i += 1 $previousCumulativeAltitude = $CumulativeAltitude } #$tcx.TrainingCenterDatabase.Activities.Activity.Id = [System.Guid]::NewGuid().ToString() $tcx.Save($UpdatedtcxFile) Write-host "[+] Updated TCX file saved as: $UpdatedtcxFile" -ForegroundColor Cyan Write-host "[!] You can now upload the updated TCX file to Garmin Connect and Strava." |