Public/Invoke-TCXFileAnalysis.ps1

<#
.SYNOPSIS
Analyzes a Garmin TCX activity file and returns lap-based performance metrics.
 
.DESCRIPTION
`Invoke-TCXFileAnalysis` reads a TCX activity file, evaluates each lap, and returns
a structured list of metrics including distance, lap time, moving time, non-moving time,
ascent, descent, pace, and heart-rate values.
 
If no file path is provided, a file picker dialog is shown to select a `.tcx` file.
The function validates the selected input file, parses activity/lap data, calculates
movement-related metrics from trackpoints, and summarizes cumulative totals across laps.
 
.PARAMETER tcxFile
Path to the input `.tcx` file.
If omitted, an OpenFileDialog is shown.
 
.PARAMETER EnableLogging
Optional switch to enable logging behavior (if supported by the module logging functions).
 
.OUTPUTS
System.Collections.Generic.List[object]
Returns one object per lap with calculated and cumulative activity metrics.
 
.NOTES
- Requires helper functions available in the module scope:
  `Get-FunctionName`, `Write-Log`, `Invoke-Output`, `Convert-SecondsToMinutes`, `Get-RunTime`.
- Moving time is determined using speed and distance delta thresholds between trackpoints.
- Elevation gain/loss is calculated only during moving segments.
- Input file must have `.tcx` extension.
 
.EXAMPLE
Invoke-TCXFileAnalysis -tcxFile "C:\Data\activity.tcx"
 
Analyzes the provided activity file and returns lap metrics.
 
.EXAMPLE
Invoke-TCXFileAnalysis
 
Opens a file picker to select a TCX file, then analyzes it.
#>

Function Invoke-TCXFileAnalysis {

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [ValidatePattern('(?i)\.tcx$')]
        [string]$tcxFile,
        [switch]$EnableLogging
    )

    $CurrentFunction = Get-FunctionName
    Write-Log -Message "### Start Function $CurrentFunction ###"
    $StartRunTime = (Get-Date).ToString($Script:DateFormatLog)
    #################### main code | out- host #####################

    Invoke-Output -Type Header -Message "Analyzing TCX file ..."

    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."
            Write-Host " [!] Canceled by user. No tcx file was selected." -ForegroundColor Yellow
            return
        }

        $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



    # 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
    $Sport = $tcx.TrainingCenterDatabase.Activities.Activity.Sport
    $id = $tcx.TrainingCenterDatabase.Activities.Activity.id
    $device = $tcx.TrainingCenterDatabase.Activities.Activity.Creator.Name  
    $deviceId = $tcx.TrainingCenterDatabase.Activities.Activity.Creator.UnitId        
    
    invoke-output -type Bullet -Message "Device: " -TextMaker "$device ($deviceId)"
    invoke-output -type Bullet -Message "Activity ID: " -TextMaker $id
    invoke-output -type Bullet -Message "Sport: " -TextMaker $Sport
    invoke-output -type Bullet -Message "Number of Laps: " -TextMaker $NoLaps

    $allLaps = $tcx.TrainingCenterDatabase.Activities.Activity.Lap

    function Format-PaceMinPerKm {
        param(
            [Parameter(Mandatory)]
            [double]$Seconds,
            [Parameter(Mandatory)]
            [double]$DistanceMeters
        )

        if ($DistanceMeters -le 0 -or $Seconds -lt 0) {
            return $null
        }

        [double]$paceSecondsPerKm = $Seconds / ($DistanceMeters / 1000.0)
        return [TimeSpan]::FromSeconds($paceSecondsPerKm).ToString("mm\:ss")
    }



    function Get-LapMovementMetrics {
        param(
            [Parameter(Mandatory)]
            $LapNode
        )

        $trackpoints = @($LapNode.Track.Trackpoint)
        if ($trackpoints.Count -lt 2) {
            return [PSCustomObject]@{
                TotalAscentMeters  = 0.0
                TotalDescentMeters = 0.0
                MovingTimeSeconds  = 0.0
            }
        }

        [double]$minimumMovingSpeedMps = 0.6
        [double]$minimumFallbackDistanceDeltaMeters = 0.5
        [double]$minimumElevationDeltaMeters = 0.1
        [double]$maximumTrackpointGapSeconds = 10.0

        [double]$movingSeconds = 0.0
        [double]$ascent = 0.0
        [double]$descent = 0.0

        [double]$previousAltitude = 0.0
        [double]$previousDistance = 0.0
        [datetime]$previousTime = [datetime]::MinValue
        [double]$previousSpeed = 0.0
        [bool]$hasPreviousAltitude = $false
        [bool]$hasPreviousDistance = $false
        [bool]$hasPreviousTime = $false
        [bool]$hasPreviousSpeed = $false

        foreach ($tp in $trackpoints) {
            [double]$currentAltitude = 0.0
            [double]$currentDistance = 0.0
            [datetime]$currentTime = [datetime]::MinValue
            [double]$currentSpeed = 0.0

            $hasCurrentAltitude = [double]::TryParse(
                [string]$tp.AltitudeMeters,
                [System.Globalization.NumberStyles]::Float,
                [System.Globalization.CultureInfo]::InvariantCulture,
                [ref]$currentAltitude
            )

            if (-not $hasCurrentAltitude) {
                $hasCurrentAltitude = [double]::TryParse(
                    [string]$tp.AltitudeMeters,
                    [System.Globalization.NumberStyles]::Float,
                    [System.Globalization.CultureInfo]::CurrentCulture,
                    [ref]$currentAltitude
                )
            }

            $hasCurrentDistance = [double]::TryParse(
                [string]$tp.DistanceMeters,
                [System.Globalization.NumberStyles]::Float,
                [System.Globalization.CultureInfo]::InvariantCulture,
                [ref]$currentDistance
            )

            if (-not $hasCurrentDistance) {
                $hasCurrentDistance = [double]::TryParse(
                    [string]$tp.DistanceMeters,
                    [System.Globalization.NumberStyles]::Float,
                    [System.Globalization.CultureInfo]::CurrentCulture,
                    [ref]$currentDistance
                )
            }

            $hasCurrentTime = [datetime]::TryParse(
                [string]$tp.Time,
                [System.Globalization.CultureInfo]::InvariantCulture,
                [System.Globalization.DateTimeStyles]::RoundtripKind,
                [ref]$currentTime
            )

            $hasCurrentSpeed = [double]::TryParse(
                [string]$tp.Extensions.TPX.Speed,
                [System.Globalization.NumberStyles]::Float,
                [System.Globalization.CultureInfo]::InvariantCulture,
                [ref]$currentSpeed
            )

            if (-not $hasCurrentSpeed) {
                $hasCurrentSpeed = [double]::TryParse(
                    [string]$tp.Extensions.TPX.Speed,
                    [System.Globalization.NumberStyles]::Float,
                    [System.Globalization.CultureInfo]::CurrentCulture,
                    [ref]$currentSpeed
                )
            }

            if ($hasPreviousTime -and $hasCurrentTime -and $hasPreviousDistance -and $hasCurrentDistance) {
                $timeDelta = ($currentTime - $previousTime).TotalSeconds
                $distanceDelta = $currentDistance - $previousDistance

                if ($timeDelta -gt 0 -and $timeDelta -le $maximumTrackpointGapSeconds) {
                    $countsAsMoving = $false

                    if ($hasPreviousSpeed -and $hasCurrentSpeed) {
                        if ($previousSpeed -ge $minimumMovingSpeedMps -or $currentSpeed -ge $minimumMovingSpeedMps) {
                            $countsAsMoving = $true
                        }
                    }
                    elseif ($distanceDelta -ge $minimumFallbackDistanceDeltaMeters) {
                        $countsAsMoving = $true
                    }

                    if ($countsAsMoving) {
                        $movingSeconds += $timeDelta

                        if ($hasPreviousAltitude -and $hasCurrentAltitude) {
                            $altitudeDelta = $currentAltitude - $previousAltitude
                            if ($altitudeDelta -ge $minimumElevationDeltaMeters) {
                                $ascent += $altitudeDelta
                            }
                            elseif ($altitudeDelta -le (-1.0 * $minimumElevationDeltaMeters)) {
                                $descent += [math]::Abs($altitudeDelta)
                            }
                        }
                    }
                }
            }

            if ($hasCurrentAltitude) {
                $previousAltitude = $currentAltitude
                $hasPreviousAltitude = $true
            }

            if ($hasCurrentDistance) {
                $previousDistance = $currentDistance
                $hasPreviousDistance = $true
            }

            if ($hasCurrentTime) {
                $previousTime = $currentTime
                $hasPreviousTime = $true
            }

            if ($hasCurrentSpeed) {
                $previousSpeed = $currentSpeed
                $hasPreviousSpeed = $true
            }
        }

        [double]$lapTotalTimeSeconds = 0.0
        [void][double]::TryParse(
            [string]$LapNode.TotalTimeSeconds,
            [System.Globalization.NumberStyles]::Float,
            [System.Globalization.CultureInfo]::InvariantCulture,
            [ref]$lapTotalTimeSeconds
        )

        if ($lapTotalTimeSeconds -gt 0) {
            $movingSeconds = [math]::Min($movingSeconds, $lapTotalTimeSeconds)
        }

        return [PSCustomObject]@{
            TotalAscentMeters  = [math]::Round($ascent, 0)
            TotalDescentMeters = [math]::Round($descent, 0)
            MovingTimeSeconds  = [math]::Round($movingSeconds, 0)
        }
    }



    $i = 0
    $Results = [System.Collections.Generic.List[object]]::new()
    [double]$totalDistanceMeters = 0.0
    [double]$totalTimeSeconds = 0.0
    [double]$totalMovingTimeSeconds = 0.0
    [double]$totalAscentMeters = 0.0
    [double]$totalDescentMeters = 0.0
    [double]$averageHeartRateSum = 0.0
    [int]$averageHeartRateCount = 0
    [double]$maximumHeartRate = 0.0
    
    foreach ($Lap in $allLaps) {

        $movementMetrics = Get-LapMovementMetrics -LapNode $Lap

        [double]$lapDistanceMeters = 0.0
        [void][double]::TryParse(
            [string]$Lap.DistanceMeters,
            [System.Globalization.NumberStyles]::Float,
            [System.Globalization.CultureInfo]::InvariantCulture,
            [ref]$lapDistanceMeters
        )

        [double]$lapTotalTimeSeconds = 0.0
        [void][double]::TryParse(
            [string]$Lap.TotalTimeSeconds,
            [System.Globalization.NumberStyles]::Float,
            [System.Globalization.CultureInfo]::InvariantCulture,
            [ref]$lapTotalTimeSeconds
        )


        $totalDistanceMeters += $lapDistanceMeters
        $totalTimeSeconds += $lapTotalTimeSeconds
        $totalMovingTimeSeconds += [double]$movementMetrics.MovingTimeSeconds
        $totalAscentMeters += [double]$movementMetrics.TotalAscentMeters
        $totalDescentMeters += [double]$movementMetrics.TotalDescentMeters

        [double]$lapAverageHeartRate = 0.0
        if ([double]::TryParse([string]$Lap.AverageHeartRateBpm.Value, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$lapAverageHeartRate) -or
            [double]::TryParse([string]$Lap.AverageHeartRateBpm.Value, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::CurrentCulture, [ref]$lapAverageHeartRate)) {
            $averageHeartRateSum += $lapAverageHeartRate
            $averageHeartRateCount += 1
        }

        [double]$lapMaximumHeartRate = 0.0
        if ([double]::TryParse([string]$Lap.MaximumHeartRateBpm.Value, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$lapMaximumHeartRate) -or
            [double]::TryParse([string]$Lap.MaximumHeartRateBpm.Value, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::CurrentCulture, [ref]$lapMaximumHeartRate)) {
            if ($lapMaximumHeartRate -gt $maximumHeartRate) {
                $maximumHeartRate = $lapMaximumHeartRate
            }
        }

        $Results.Add(
            [PSCustomObject]@{
                Lap                 = $i + 1
                LapTime             = Convert-SecondsToMinutes -Seconds $Lap.TotalTimeSeconds
                DistanceKm          = [math]::Round(($lapDistanceMeters / 1000.0), 2)
                MovingTime          = Convert-SecondsToMinutes -Seconds $movementMetrics.MovingTimeSeconds
                NonMovingTime       = Convert-SecondsToMinutes -Seconds ($lapTotalTimeSeconds - $movementMetrics.MovingTimeSeconds)
                AscentMeters        = $movementMetrics.TotalAscentMeters
                DescentMeters       = $movementMetrics.TotalDescentMeters
                AveragePaceMinKm    = Format-PaceMinPerKm -Seconds $lapTotalTimeSeconds -DistanceMeters $lapDistanceMeters
                MovingPaceMinKm     = Format-PaceMinPerKm -Seconds $movementMetrics.MovingTimeSeconds -DistanceMeters $lapDistanceMeters
                AverageHeartRateBpm = $Lap.AverageHeartRateBpm.Value
                MaximumHeartRateBpm = $Lap.MaximumHeartRateBpm.Value
                AvgSpeed            = $Lap.Extensions.lx.AvgSpeed
                StartTime           = $Lap.StartTime
                DistanceMeter       = $Lap.DistanceMeters
                TotalDistance       = [math]::Round($totalDistanceMeters / 1000.0, 2)
                TotalTime           = Convert-SecondsToMinutes -Seconds $totalTimeSeconds
                TotalAscentMeters   = [math]::Round($totalAscentMeters, 0)
                TotalDescentMeters  = [math]::Round($totalDescentMeters, 0)
            }
        )
        $i += 1
    }
    <#
    $Results.Add(
        [PSCustomObject]@{
            LapNumber = 'Total'
            TotalTime = Convert-SecondsToMinutes -Seconds $totalTimeSeconds
            DistanceKm = [math]::Round(($totalDistanceMeters / 1000.0), 2)
            MovingTime = Convert-SecondsToMinutes -Seconds $totalMovingTimeSeconds
            TotalAscentMeters = [math]::Round($totalAscentMeters, 0)
            TotalDescentMeters = [math]::Round($totalDescentMeters, 0)
            AveragePaceMinKm = Format-PaceMinPerKm -Seconds $totalTimeSeconds -DistanceMeters $totalDistanceMeters
            MovingPaceMinKm = Format-PaceMinPerKm -Seconds $totalMovingTimeSeconds -DistanceMeters $totalDistanceMeters
            AverageHeartRateBpm = if ($averageHeartRateCount -gt 0) { [math]::Round(($averageHeartRateSum / $averageHeartRateCount), 0) } else { $null }
            MaximumHeartRateBpm = if ($maximumHeartRate -gt 0) { [math]::Round($maximumHeartRate, 0) } else { $null }
            AvgSpeed = $null
            StartTime = $null
            DistanceMeter = [math]::Round($totalDistanceMeters, 2)
            TotalDistanceMeter = [math]::Round($totalDistanceMeters, 2)
        }
    )
#>


    #$Results | Format-Table | Out-Host

    Invoke-output -Type Success -Message "TCX file analysis completed."

    Write-Log -Message " >> using "
    ######################## main code ############################
    $runtime = Get-RunTime -StartRunTime $StartRunTime
    Write-Log -Message " Run Time: $runtime [h] ###"
    Write-Log -Message "### End Function $CurrentFunction ###"

    return $Results

}