Last active
December 10, 2025 08:34
-
-
Save SweetAsNZ/186271638d8d1a994af2bdc3d68df63b to your computer and use it in GitHub Desktop.
Calculate a boat's current and new arrival time and average VMG for course changes using polar diagram data supply recommendations as to whether to change course to that angle or not
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| function Get-SpeedToMakeWithCourseDeviation { | |
| <# | |
| .SYNOPSIS | |
| Calculate arrival time and average VMG for course changes using polar diagram data. | |
| .DESCRIPTION | |
| Calculates whether a course deviation is worthwhile by determining: | |
| - Expected boat speed at the new course angle using polar diagram | |
| - Velocity Made Good (VMG) toward the waypoint | |
| - Estimated arrival time for current course vs altered course | |
| .PARAMETER SOG | |
| Current Speed Over Ground in knots | |
| .PARAMETER CourseTrue | |
| Current course heading in degrees (0-360) relative to true north | |
| .PARAMETER AWS | |
| Apparent Wind Speed in knots | |
| .PARAMETER AWA | |
| Apparent Wind Angle in degrees relative to boat heading | |
| .PARAMETER CourseDeviation | |
| Course change angle in degrees (positive = turn to starboard, negative = turn to port) | |
| .PARAMETER CurrentAngleTrue | |
| Current angle to waypoint in degrees (0-360) - optional, calculated from CourseTrue and WaypointBearingTrue if not provided | |
| .PARAMETER CurrentSpeed | |
| Current boat speed in knots - optional, uses SOG if not provided | |
| .PARAMETER TideDirectionTrue | |
| Direction the tide is flowing TO in degrees (0-360) - optional | |
| .PARAMETER TideSpeed | |
| Speed of the tide/current in knots - optional | |
| .PARAMETER DistanceToWaypoint | |
| Distance to waypoint in nautical miles | |
| .PARAMETER WaypointBearingTrue | |
| True bearing to waypoint in degrees (0-360) | |
| .PARAMETER PolarDiagramData | |
| Path to CSV file containing polar diagram data with columns for wind angles and speeds | |
| .EXAMPLE | |
| Get-SpeedToMakeWithCourseDeviation # Launches GUI for input | |
| .EXAMPLE | |
| Get-SpeedToMakeWithCourseDeviation -SOG 6.5 -CourseTrue 45 -AWS 12 -AWA 70 -CourseDeviation 10 | |
| .EXAMPLE | |
| Get-SpeedToMakeWithCourseDeviation -SOG 5 -CourseTrue 45 -AWS 95 -AWA 45 -CourseDeviation -45 -DistanceToWaypoint 5000 -WaypointBearingTrue 0 | |
| .EXAMPLE | |
| Get-SpeedToMakeWithCourseDeviation -SOG 6.5 -CourseTrue 45 -AWS 12 -AWA 70 -CourseDeviation 10 -DistanceToWaypoint 50 -WaypointBearingTrue 50 -CurrentSpeed 1.0 -CurrentAngleTrue 5 | |
| .EXAMPLE | |
| Get-SpeedToMakeWithCourseDeviation -SOG 6.5 -CourseTrue 45 -AWS 12 -AWA 70 -CourseDeviation 10 -DistanceToWaypoint 50 -WaypointBearingTrue 50 -CurrentSpeed 1.0 -CurrentAngleTrue 5 -TideDirectionTrue 90 -TideSpeed 1.5 | |
| .NOTES | |
| Author: Tim West | |
| Company: Sweet As Chocolate Ltd | |
| Created: 2025-12-08 | |
| Updated: 2025-12-08 | |
| Status: WIP - Work In Progress | |
| Version: 0.2.1 | |
| License: This project is licensed under the MIT License (free to use and modify) | |
| .TODO | |
| - Improve polar data interpolation | |
| - Add error handling for edge cases | |
| - Validate input ranges | |
| - Test with real polar data and boat data | |
| .CHANGELOG | |
| 2025-12-08: Initial version | |
| 2025-12-10: Added tide/current effects to VMG calculation | |
| 2025-12-11: Improved GUI input handling and validation | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory = $false, HelpMessage = "Current Speed Over Ground in knots")] | |
| [double]$SOG = 0, | |
| [Parameter(Mandatory = $false, HelpMessage = "Current course heading in degrees")] | |
| [double]$CourseTrue = 0, | |
| [Parameter(Mandatory = $false, HelpMessage = "Apparent Wind Speed in knots")] | |
| [double]$AWS = 0, | |
| [Parameter(Mandatory = $false, HelpMessage = "Apparent Wind Angle in degrees")] | |
| [double]$AWA = 0, | |
| [Parameter(Mandatory = $false, HelpMessage = "Course deviation angle in degrees (+ starboard, - port)")] | |
| [double]$CourseDeviation = 0, | |
| [Parameter(Mandatory = $false, HelpMessage = "Current angle to waypoint in degrees")] | |
| [double]$CurrentAngleTrue = 0, | |
| [Parameter(Mandatory = $false, HelpMessage = "Current boat speed in knots")] | |
| [double]$CurrentSpeed = 0, | |
| [Parameter(Mandatory = $false, HelpMessage = "Direction tide is flowing TO in degrees")] | |
| [double]$TideDirectionTrue = 0, | |
| [Parameter(Mandatory = $false, HelpMessage = "Speed of tide/current in knots")] | |
| [double]$TideSpeed = 0, | |
| [Parameter(Mandatory = $false, HelpMessage = "Distance to waypoint in nautical miles")] | |
| [double]$DistanceToWaypoint = 0, | |
| [Parameter(Mandatory = $false, HelpMessage = "True bearing to waypoint in degrees")] | |
| [double]$WaypointBearingTrue = 0, | |
| [Parameter(HelpMessage = "Path to polar diagram CSV file")] | |
| [string]$PolarDiagramData = "$ENV:OneDriveConsumer\Documents\Boating\Example_Polar_With_Correct_Headings_29-04-25.csv" | |
| ) | |
| # Check if we should show GUI (only if NO parameters provided) | |
| $showGUI = ($PSBoundParameters.Count -eq 0) | |
| # Show GUI for input if no parameters are provided | |
| if ($showGUI) { | |
| Add-Type -AssemblyName System.Windows.Forms | |
| Add-Type -AssemblyName System.Drawing | |
| $form = New-Object System.Windows.Forms.Form | |
| $form.Text = 'Course Deviation Calculator' | |
| $form.Size = New-Object System.Drawing.Size(500, 650) | |
| $form.StartPosition = 'CenterScreen' | |
| $form.FormBorderStyle = 'FixedDialog' | |
| $form.MaximizeBox = $false | |
| $form.MinimizeBox = $false | |
| $form.TopMost = $true | |
| $form.ShowInTaskbar = $true | |
| $y = 20 | |
| # Helper function to create labeled text box | |
| function Add-TextBox { | |
| param($labelText, $defaultValue, $yPosition) | |
| $label = New-Object System.Windows.Forms.Label | |
| $label.Location = New-Object System.Drawing.Point(20, $yPosition) | |
| $label.Size = New-Object System.Drawing.Size(200, 20) | |
| $label.Text = $labelText | |
| $form.Controls.Add($label) | |
| $textBox = New-Object System.Windows.Forms.TextBox | |
| $textBox.Location = New-Object System.Drawing.Point(230, $yPosition) | |
| $textBox.Size = New-Object System.Drawing.Size(220, 20) | |
| $textBox.Text = $defaultValue | |
| $form.Controls.Add($textBox) | |
| return $textBox | |
| } | |
| # Required fields | |
| $txtSOG = Add-TextBox 'SOG (knots) *' $SOG $y | |
| $y += 30 | |
| $txtCourseTrue = Add-TextBox 'Course True (°) *' $CourseTrue $y | |
| $y += 30 | |
| $txtAWS = Add-TextBox 'AWS (knots) *' $AWS $y | |
| $y += 30 | |
| $txtAWA = Add-TextBox 'AWA (°) *' $AWA $y | |
| $y += 30 | |
| $txtCourseDeviation = Add-TextBox 'Course Deviation (°) *' $CourseDeviation $y | |
| $y += 30 | |
| # Separator | |
| $separator = New-Object System.Windows.Forms.Label | |
| $separator.Location = New-Object System.Drawing.Point(20, $y) | |
| $separator.Size = New-Object System.Drawing.Size(430, 2) | |
| $separator.BorderStyle = 'Fixed3D' | |
| $form.Controls.Add($separator) | |
| $y += 10 | |
| # Optional fields | |
| $lblOptional = New-Object System.Windows.Forms.Label | |
| $lblOptional.Location = New-Object System.Drawing.Point(20, $y) | |
| $lblOptional.Size = New-Object System.Drawing.Size(200, 20) | |
| $lblOptional.Text = 'Optional Parameters:' | |
| $lblOptional.Font = New-Object System.Drawing.Font("Arial", 9, [System.Drawing.FontStyle]::Bold) | |
| $form.Controls.Add($lblOptional) | |
| $y += 25 | |
| # Optional fields | |
| $txtCurrentAngle = Add-TextBox 'Current Angle to WP (°)' $(if ($CurrentAngleTrue -ge 0) { $CurrentAngleTrue } else { '' }) $y | |
| $y += 30 | |
| $txtCurrentSpeed = Add-TextBox 'Current Speed (knots)' $(if ($CurrentSpeed -gt 0) { $CurrentSpeed } else { '' }) $y | |
| $y += 30 | |
| $txtTideDirection = Add-TextBox 'Tide Direction True (°)' $(if ($TideDirectionTrue -ge 0) { $TideDirectionTrue } else { '' }) $y | |
| $y += 30 | |
| $txtTideSpeed = Add-TextBox 'Tide Speed (knots)' $TideSpeed $y | |
| $y += 30 | |
| $txtDistance = Add-TextBox 'Distance to WP (nm)' $DistanceToWaypoint $y | |
| $y += 30 | |
| $txtWaypointBearing = Add-TextBox 'Waypoint Bearing True (°)' $WaypointBearingTrue $y | |
| $y += 30 | |
| # Polar file selector GUI | |
| $lblPolar = New-Object System.Windows.Forms.Label | |
| $lblPolar.Location = New-Object System.Drawing.Point(20, $y) | |
| $lblPolar.Size = New-Object System.Drawing.Size(200, 20) | |
| $lblPolar.Text = 'Polar Diagram CSV:' | |
| $form.Controls.Add($lblPolar) | |
| $txtPolarPath = New-Object System.Windows.Forms.TextBox | |
| $txtPolarPath.Location = New-Object System.Drawing.Point(20, ($y + 25)) | |
| $txtPolarPath.Size = New-Object System.Drawing.Size(350, 20) | |
| $txtPolarPath.Text = $PolarDiagramData | |
| $form.Controls.Add($txtPolarPath) | |
| $btnBrowse = New-Object System.Windows.Forms.Button | |
| $btnBrowse.Location = New-Object System.Drawing.Point(380, ($y + 23)) | |
| $btnBrowse.Size = New-Object System.Drawing.Size(70, 25) | |
| $btnBrowse.Text = 'Browse...' | |
| $btnBrowse.Add_Click({ | |
| $openFileDialog = New-Object System.Windows.Forms.OpenFileDialog | |
| $openFileDialog.Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*" | |
| $openFileDialog.Title = "Select Polar Diagram CSV" | |
| if ($openFileDialog.ShowDialog() -eq 'OK') { | |
| $txtPolarPath.Text = $openFileDialog.FileName | |
| } | |
| }) | |
| $form.Controls.Add($btnBrowse) | |
| $y += 60 | |
| # OK and Cancel buttons | |
| $btnOK = New-Object System.Windows.Forms.Button | |
| $btnOK.Location = New-Object System.Drawing.Point(200, $y) | |
| $btnOK.Size = New-Object System.Drawing.Size(100, 30) | |
| $btnOK.Text = 'Calculate' | |
| $btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK | |
| $form.Controls.Add($btnOK) | |
| $form.AcceptButton = $btnOK | |
| $btnCancel = New-Object System.Windows.Forms.Button | |
| $btnCancel.Location = New-Object System.Drawing.Point(310, $y) | |
| $btnCancel.Size = New-Object System.Drawing.Size(100, 30) | |
| $btnCancel.Text = 'Cancel' | |
| $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel | |
| $form.Controls.Add($btnCancel) | |
| $form.CancelButton = $btnCancel | |
| $result = $form.ShowDialog() | |
| # Process form result | |
| if ($result -eq [System.Windows.Forms.DialogResult]::OK) { | |
| # Parse inputs from GUI | |
| try { | |
| $SOG = [double]$txtSOG.Text | |
| $CourseTrue = [double]$txtCourseTrue.Text | |
| $AWS = [double]$txtAWS.Text | |
| $AWA = [double]$txtAWA.Text | |
| $CourseDeviation = [double]$txtCourseDeviation.Text | |
| if ($txtCurrentAngle.Text) { $CurrentAngleTrue = [double]$txtCurrentAngle.Text } | |
| if ($txtCurrentSpeed.Text) { $CurrentSpeed = [double]$txtCurrentSpeed.Text } | |
| if ($txtTideDirection.Text) { $TideDirectionTrue = [double]$txtTideDirection.Text } | |
| if ($txtTideSpeed.Text) { $TideSpeed = [double]$txtTideSpeed.Text } | |
| if ($txtDistance.Text) { $DistanceToWaypoint = [double]$txtDistance.Text } | |
| if ($txtWaypointBearing.Text) { $WaypointBearingTrue = [double]$txtWaypointBearing.Text } | |
| if ($txtPolarPath.Text) { $PolarDiagramData = $txtPolarPath.Text } | |
| } | |
| catch { | |
| Write-Error "Invalid input: $_" | |
| return | |
| } | |
| } | |
| else { | |
| Write-Host "Cancelled by user" -ForegroundColor Yellow | |
| return | |
| } | |
| $form.Dispose() | |
| } | |
| # Validate required inputs (if not using GUI) | |
| if (-not $showGUI) { | |
| if ($SOG -eq 0 -or $CourseTrue -eq 0 -or $AWS -eq 0 -or $AWA -eq 0 -or $CourseDeviation -eq 0) { | |
| Write-Error "Required parameters missing. Provide SOG, CourseTrue, AWS, AWA, and CourseDeviation, or call without parameters to use GUI." | |
| return | |
| } | |
| } | |
| # Validate inputs | |
| if (-not (Test-Path $PolarDiagramData)) { | |
| Write-Error "Polar diagram file not found: $PolarDiagramData" | |
| return | |
| } | |
| # Load Polar Diagram Data | |
| Write-Host "`nLoading polar diagram data..." -ForegroundColor Cyan | |
| $polarData = Import-Csv -Path $PolarDiagramData | |
| # Helper function to normalize angles to 0-360 | |
| function ConvertTo-NormalizedAngle { | |
| param([double]$angle) | |
| $normalized = $angle % 360 | |
| if ($normalized -lt 0) { $normalized += 360 } | |
| return $normalized | |
| } | |
| # Helper function to get boat speed from polar diagram | |
| function Get-BoatSpeedFromPolar { | |
| param( | |
| [object[]]$PolarData, | |
| [double]$WindSpeed, | |
| [double]$WindAngle | |
| ) | |
| # Normalize wind angle to 0-180 (polar diagrams are typically symmetric) | |
| $normalizedAngle = [Math]::Abs($WindAngle) | |
| if ($normalizedAngle -gt 180) { | |
| $normalizedAngle = 360 - $normalizedAngle | |
| } | |
| # Round to nearest angle in polar (typically 5-degree increments) | |
| $roundedAngle = [Math]::Round($normalizedAngle / 5) * 5 | |
| # Get column headers (TWS values are in the header row) | |
| $headers = $PolarData[0].PSObject.Properties.Name | |
| # Get all numeric TWS values from headers | |
| $twsValues = @() | |
| foreach ($header in $headers) { | |
| try { | |
| $twsValues += [double]$header | |
| } catch { | |
| # Skip non-numeric headers | |
| } | |
| } | |
| $twsValues = $twsValues | Sort-Object | |
| $maxTWS = $twsValues[-1] | |
| # If wind speed exceeds polar data, cap it and warn | |
| $cappedWindSpeed = $WindSpeed | |
| if ($WindSpeed -gt $maxTWS) { | |
| Write-Warning "Wind speed $([Math]::Round($WindSpeed,1)) knots exceeds polar data max ($maxTWS knots). Using max available." | |
| $cappedWindSpeed = $maxTWS | |
| } | |
| # Find the wind angle row (look for the angle in the first column) | |
| $angleRow = $PolarData | Where-Object { | |
| $firstCol = $_.PSObject.Properties.Value | Select-Object -First 1 | |
| try { | |
| [double]$firstCol -eq $roundedAngle | |
| } catch { | |
| $false | |
| } | |
| } | |
| if (-not $angleRow) { | |
| # Try to interpolate between nearby angles | |
| $allAngles = @() | |
| foreach ($row in $PolarData) { | |
| $firstCol = $row.PSObject.Properties.Value | Select-Object -First 1 | |
| try { | |
| $allAngles += [double]$firstCol | |
| } catch { | |
| # Skip non-numeric | |
| } | |
| } | |
| $allAngles = $allAngles | Sort-Object | |
| # Find closest angles | |
| $lowerAngle = $allAngles | Where-Object { $_ -le $roundedAngle } | Select-Object -Last 1 | |
| $upperAngle = $allAngles | Where-Object { $_ -ge $roundedAngle } | Select-Object -First 1 | |
| if ($lowerAngle -and $upperAngle -and $lowerAngle -ne $upperAngle) { | |
| # Interpolate between angles | |
| $lowerRow = $PolarData | Where-Object { | |
| $firstCol = $_.PSObject.Properties.Value | Select-Object -First 1 | |
| try { [double]$firstCol -eq $lowerAngle } catch { $false } | |
| } | |
| $upperRow = $PolarData | Where-Object { | |
| $firstCol = $_.PSObject.Properties.Value | Select-Object -First 1 | |
| try { [double]$firstCol -eq $upperAngle } catch { $false } | |
| } | |
| # Find closest TWS column | |
| $closestTWSColumn = $null | |
| $closestDiff = [double]::MaxValue | |
| foreach ($header in $headers) { | |
| try { | |
| $headerValue = [double]$header | |
| $diff = [Math]::Abs($headerValue - $cappedWindSpeed) | |
| if ($diff -lt $closestDiff) { | |
| $closestDiff = $diff | |
| $closestTWSColumn = $header | |
| } | |
| } catch { | |
| continue | |
| } | |
| } | |
| if ($closestTWSColumn -and $lowerRow.$closestTWSColumn -and $upperRow.$closestTWSColumn) { | |
| try { | |
| $lowerSpeed = [double]$lowerRow.$closestTWSColumn | |
| $upperSpeed = [double]$upperRow.$closestTWSColumn | |
| # Linear interpolation | |
| $ratio = ($roundedAngle - $lowerAngle) / ($upperAngle - $lowerAngle) | |
| $interpolatedSpeed = $lowerSpeed + ($upperSpeed - $lowerSpeed) * $ratio | |
| Write-Host " Polar lookup: TWA=$roundedAngle° (interpolated) TWS=$([Math]::Round([double]$closestTWSColumn,1)) → Speed=$([Math]::Round($interpolatedSpeed,2)) knots" -ForegroundColor DarkGray | |
| return $interpolatedSpeed | |
| } catch { | |
| # Fall through to estimation | |
| } | |
| } | |
| } | |
| Write-Warning "Wind angle $roundedAngle not found in polar data. Using estimation based on typical boat performance." | |
| # Better estimation: can't sail directly into wind, reaching is fastest | |
| if ($roundedAngle -lt 30) { | |
| return 0 # Cannot sail directly upwind (no-go zone) | |
| } elseif ($roundedAngle -lt 50) { | |
| return $SOG * 0.85 # Close hauled - typically slower | |
| } elseif ($roundedAngle -lt 100) { | |
| return $SOG * 1.1 # Reaching - typically fastest | |
| } elseif ($roundedAngle -lt 140) { | |
| return $SOG * 1.0 # Broad reach | |
| } else { | |
| return $SOG * 0.9 # Running | |
| } | |
| } | |
| # Find closest TWS column (headers contain wind speeds) | |
| $closestTWSColumn = $null | |
| $closestDiff = [double]::MaxValue | |
| foreach ($header in $headers) { | |
| try { | |
| $headerValue = [double]$header | |
| $diff = [Math]::Abs($headerValue - $cappedWindSpeed) | |
| if ($diff -lt $closestDiff) { | |
| $closestDiff = $diff | |
| $closestTWSColumn = $header | |
| } | |
| } catch { | |
| # Skip non-numeric headers (like angle column) | |
| continue | |
| } | |
| } | |
| if ($closestTWSColumn -and $angleRow.$closestTWSColumn) { | |
| try { | |
| $speed = [double]$angleRow.$closestTWSColumn | |
| Write-Host " Polar lookup: TWA=$roundedAngle° TWS=$([Math]::Round([double]$closestTWSColumn,1)) → Speed=$([Math]::Round($speed,2)) knots" -ForegroundColor DarkGray | |
| return $speed | |
| } catch { | |
| Write-Warning "Could not parse boat speed from polar data." | |
| } | |
| } | |
| # Fallback: estimate based on current SOG and angle | |
| Write-Warning "Polar data not found for TWS=$WindSpeed TWA=$roundedAngle. Using estimation." | |
| if ($roundedAngle -lt 30) { | |
| return 0 # Cannot sail directly upwind (no-go zone) | |
| } elseif ($roundedAngle -lt 50) { | |
| return $SOG * 0.85 | |
| } elseif ($roundedAngle -lt 100) { | |
| return $SOG * 1.1 | |
| } elseif ($roundedAngle -lt 140) { | |
| return $SOG * 1.0 | |
| } else { | |
| return $SOG * 0.9 | |
| } | |
| } | |
| # Calculate True Wind from Apparent Wind | |
| Write-Host "`nCalculating true wind..." -ForegroundColor Cyan | |
| $awaRadians = $AWA * [Math]::PI / 180 | |
| $awsX = $AWS * [Math]::Cos($awaRadians) | |
| $awsY = $AWS * [Math]::Sin($awaRadians) | |
| $twsX = $awsX - $SOG | |
| $twsY = $awsY | |
| $TWS = [Math]::Sqrt([Math]::Pow($twsX, 2) + [Math]::Pow($twsY, 2)) | |
| $twaRadians = [Math]::Atan2($twsY, $twsX) | |
| $TWA = $twaRadians * 180 / [Math]::PI | |
| Write-Host " True Wind Speed: $([Math]::Round($TWS, 2)) knots" -ForegroundColor Gray | |
| Write-Host " True Wind Angle: $([Math]::Round($TWA, 2))°" -ForegroundColor Gray | |
| # Look up expected speed from polar for current conditions | |
| $polarCurrentSpeed = Get-BoatSpeedFromPolar -PolarData $polarData -WindSpeed $TWS -WindAngle $TWA | |
| # Calculate wave-assisted speed bonus if SOG exceeds polar speed | |
| # Wave action typically adds speed when sailing downwind (TWA 90-150°) | |
| $waveBonusFactor = 1.0 | |
| if ($SOG -gt $polarCurrentSpeed) { | |
| $speedDifference = $SOG - $polarCurrentSpeed | |
| $normalizedTWA = [Math]::Abs($TWA) | |
| if ($normalizedTWA -gt 180) { $normalizedTWA = 360 - $normalizedTWA } | |
| # Maximum wave effect around 120° TWA, tapering off towards 90° and 150° | |
| if ($normalizedTWA -ge 90 -and $normalizedTWA -le 150) { | |
| # Calculate wave effect coefficient (peaks at 120°) | |
| $angleFrom120 = [Math]::Abs($normalizedTWA - 120) | |
| $waveCoefficient = 1.0 - ($angleFrom120 / 30.0) # 1.0 at 120°, 0 at 90° and 150° | |
| if ($waveCoefficient -gt 0) { | |
| # Calculate bonus factor based on how much SOG exceeds polar | |
| $bonusPercentage = ($speedDifference / $polarCurrentSpeed) * $waveCoefficient | |
| $waveBonusFactor = 1.0 + $bonusPercentage | |
| Write-Host " Wave-assisted sailing detected (TWA favorable for surfing/wave action)" -ForegroundColor Cyan | |
| Write-Host " Wave bonus factor: $([Math]::Round($waveBonusFactor, 3))x (peak at 120° TWA)" -ForegroundColor Cyan | |
| } | |
| } | |
| } | |
| Write-Host " Polar expected speed at current TWA: $([Math]::Round($polarCurrentSpeed, 2)) knots vs SOG: $SOG knots" -ForegroundColor Gray | |
| # Calculate new course and apparent wind angle after deviation | |
| $newCourse = ConvertTo-NormalizedAngle ($CourseTrue + $CourseDeviation) | |
| $newAWA = ConvertTo-NormalizedAngle ($AWA - $CourseDeviation) | |
| # We need to estimate the new boat speed from polar | |
| # For now, use TWS and calculate new TWA based on new course | |
| # True wind direction remains constant | |
| $trueWindDirection = ConvertTo-NormalizedAngle ($CourseTrue + $TWA) | |
| $newTWA = ConvertTo-NormalizedAngle ($trueWindDirection - $newCourse) | |
| if ($newTWA -gt 180) { $newTWA = 360 - $newTWA } | |
| Write-Host " New course TWA: $([Math]::Round($newTWA, 2))°" -ForegroundColor Gray | |
| # Get expected boat speed from polar diagram for new TWA | |
| $expectedSpeed = Get-BoatSpeedFromPolar -PolarData $polarData -WindSpeed $TWS -WindAngle $newTWA | |
| # Apply wave bonus factor to new course if in favorable wave angle | |
| $normalizedNewTWA = [Math]::Abs($newTWA) | |
| if ($normalizedNewTWA -gt 180) { $normalizedNewTWA = 360 - $normalizedNewTWA } | |
| # Apply wave bonus only if original wave bonus was applicable | |
| if ($waveBonusFactor -gt 1.0 -and $normalizedNewTWA -ge 90 -and $normalizedNewTWA -le 150) { | |
| # Calculate wave effect for new TWA | |
| $angleFrom120 = [Math]::Abs($normalizedNewTWA - 120) | |
| $newWaveCoefficient = 1.0 - ($angleFrom120 / 30.0) | |
| if ($newWaveCoefficient -gt 0) { | |
| # Scale the bonus based on new TWA's wave favorability | |
| $normalizedCurrentTWA = [Math]::Abs($TWA) | |
| if ($normalizedCurrentTWA -gt 180) { $normalizedCurrentTWA = 360 - $normalizedCurrentTWA } | |
| $currentAngleFrom120 = [Math]::Abs($normalizedCurrentTWA - 120) | |
| $currentWaveCoefficient = [Math]::Max(0, 1.0 - ($currentAngleFrom120 / 30.0)) | |
| # Adjust wave bonus proportionally | |
| if ($currentWaveCoefficient -gt 0) { | |
| $adjustedWaveBonusFactor = 1.0 + (($waveBonusFactor - 1.0) * ($newWaveCoefficient / $currentWaveCoefficient)) | |
| $expectedSpeed = $expectedSpeed * $adjustedWaveBonusFactor | |
| Write-Host " Applied wave bonus to new course: $([Math]::Round($adjustedWaveBonusFactor, 3))x" -ForegroundColor DarkGray | |
| } | |
| } | |
| } | |
| # Use provided CurrentSpeed or fall back to SOG | |
| $actualCurrentSpeed = if ($CurrentSpeed -gt 0) { $CurrentSpeed } else { $SOG } | |
| # Calculate tide effects if provided | |
| $tideVMGCurrent = 0 | |
| $tideVMGNew = 0 | |
| if ($TideSpeed -gt 0 -and $TideDirectionTrue -ge 0) { | |
| Write-Host "`nTide/Current Effects:" -ForegroundColor Cyan | |
| Write-Host " Tide flowing to: $TideDirectionTrue° at $TideSpeed knots" -ForegroundColor Gray | |
| # Calculate tide component toward waypoint for current course | |
| if ($WaypointBearingTrue -ne 0) { | |
| $tideAngleToCurrent = [Math]::Abs($TideDirectionTrue - $WaypointBearingTrue) | |
| if ($tideAngleToCurrent -gt 180) { $tideAngleToCurrent = 360 - $tideAngleToCurrent } | |
| $tideVMGCurrent = $TideSpeed * [Math]::Cos($tideAngleToCurrent * [Math]::PI / 180) | |
| Write-Host " Tide VMG contribution (current): $([Math]::Round($tideVMGCurrent, 2)) knots" -ForegroundColor Gray | |
| } | |
| # Tide effect is same for new course (tide doesn't change) | |
| $tideVMGNew = $tideVMGCurrent | |
| } | |
| # Calculate VMG (Velocity Made Good toward waypoint) | |
| # VMG = Boat Speed × cos(angle between course and waypoint) + Tide contribution | |
| # Current VMG - use provided CurrentAngleTrue if available | |
| if ($CurrentAngleTrue -ge 0) { | |
| # Use the provided current angle to waypoint | |
| $currentAngleToWaypoint = $CurrentAngleTrue | |
| } elseif ($WaypointBearingTrue -eq 0) { | |
| # If no waypoint bearing specified, assume we're heading directly to it | |
| $currentAngleToWaypoint = 0 | |
| } else { | |
| # Calculate from course and waypoint bearing | |
| $currentAngleToWaypoint = [Math]::Abs($WaypointBearingTrue - $CourseTrue) | |
| if ($currentAngleToWaypoint -gt 180) { | |
| $currentAngleToWaypoint = 360 - $currentAngleToWaypoint | |
| } | |
| } | |
| # Current VMG | |
| $boatVMGCurrent = $actualCurrentSpeed * [Math]::Cos($currentAngleToWaypoint * [Math]::PI / 180) | |
| $currentVMG = $boatVMGCurrent + $tideVMGCurrent | |
| # New VMG after course change | |
| $newAngleToWaypoint = [Math]::Abs($WaypointBearingTrue - $newCourse) | |
| if ($newAngleToWaypoint -gt 180) { | |
| $newAngleToWaypoint = 360 - $newAngleToWaypoint | |
| } | |
| # New VMG | |
| $boatVMGNew = $expectedSpeed * [Math]::Cos($newAngleToWaypoint * [Math]::PI / 180) | |
| $newVMG = $boatVMGNew + $tideVMGNew | |
| # Calculate arrival times | |
| if ($currentVMG -le 0) { | |
| $currentArrivalTime = [double]::PositiveInfinity | |
| $currentArrivalTimeFormatted = "Never (sailing away)" | |
| } else { | |
| $currentArrivalTime = $DistanceToWaypoint / $currentVMG | |
| $currentHours = [Math]::Floor($currentArrivalTime) | |
| $currentMinutes = [Math]::Round(($currentArrivalTime - $currentHours) * 60) | |
| $currentArrivalTimeFormatted = "${currentHours}h ${currentMinutes}m" | |
| } | |
| # New arrival time | |
| if ($newVMG -le 0) { | |
| $newArrivalTime = [double]::PositiveInfinity | |
| $newArrivalTimeFormatted = "Never (sailing away)" | |
| } else { | |
| $newArrivalTime = $DistanceToWaypoint / $newVMG | |
| $newHours = [Math]::Floor($newArrivalTime) | |
| $newMinutes = [Math]::Round(($newArrivalTime - $newHours) * 60) | |
| $newArrivalTimeFormatted = "${newHours}h ${newMinutes}m" | |
| } | |
| # Calculate time saved or lost | |
| $timeSaved = $currentArrivalTime - $newArrivalTime | |
| $timeSavedHours = [Math]::Floor([Math]::Abs($timeSaved)) | |
| $timeSavedMinutes = [Math]::Round(([Math]::Abs($timeSaved) - $timeSavedHours) * 60) | |
| if ($timeSaved -gt 0) { | |
| $timeSavedFormatted = "Save ${timeSavedHours}h ${timeSavedMinutes}m" | |
| $recommendation = "YES - Course change is worthwhile" | |
| } elseif ($timeSaved -lt 0) { | |
| $timeSavedFormatted = "Lose ${timeSavedHours}h ${timeSavedMinutes}m" | |
| $recommendation = "NO - Stay on current course" | |
| } else { | |
| $timeSavedFormatted = "No difference" | |
| $recommendation = "NEUTRAL - No significant difference" | |
| } | |
| # Create result object | |
| $result = [PSCustomObject]@{ | |
| CurrentCourse = $CourseTrue | |
| CurrentSOG = $SOG | |
| CurrentSpeed = $actualCurrentSpeed | |
| CurrentAngleToWaypoint = [Math]::Round($currentAngleToWaypoint, 2) | |
| CurrentAWA = $AWA | |
| CurrentAWS = $AWS | |
| CurrentVMG = [Math]::Round($currentVMG, 2) | |
| CurrentBoatVMG = [Math]::Round($boatVMGCurrent, 2) | |
| TideVMG = [Math]::Round($tideVMGCurrent, 2) | |
| CurrentArrivalTime = $currentArrivalTimeFormatted | |
| NewCourse = $newCourse | |
| CourseDeviation = $CourseDeviation | |
| NewAWA = [Math]::Round($newAWA, 2) | |
| ExpectedSpeed = [Math]::Round($expectedSpeed, 2) | |
| NewVMG = [Math]::Round($newVMG, 2) | |
| NewArrivalTime = $newArrivalTimeFormatted | |
| TimeDifference = $timeSavedFormatted | |
| DistanceToWaypoint = $DistanceToWaypoint | |
| WaypointBearing = $WaypointBearingTrue | |
| Recommendation = $recommendation | |
| TrueWindSpeed = [Math]::Round($TWS, 2) | |
| TrueWindAngle = [Math]::Round($TWA, 2) | |
| } | |
| # Display results in table format | |
| Write-Host "`n=== Course Deviation Analysis ===" -ForegroundColor Cyan | |
| Write-Host "" | |
| # Create comparison table with additional info column | |
| $comparisonData = [PSCustomObject]@{ | |
| 'Metric' = 'Course (°)' | |
| 'Current' = $result.CurrentCourse | |
| 'Proposed' = "$($result.NewCourse) (${CourseDeviation}° deviation)" | |
| 'Additional Info' = "Distance to WP: $($result.DistanceToWaypoint) nm" | |
| } | |
| $tableData = @($comparisonData) | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'Speed (knots)' | |
| 'Current' = $result.CurrentSOG | |
| 'Proposed' = $result.ExpectedSpeed | |
| 'Additional Info' = "WP Bearing: $($result.WaypointBearing)°" | |
| } | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'AWA (°)' | |
| 'Current' = $result.CurrentAWA | |
| 'Proposed' = $result.NewAWA | |
| 'Additional Info' = "AWS: $($result.CurrentAWS) knots" | |
| } | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'Angle to WP (°)' | |
| 'Current' = $result.CurrentAngleToWaypoint | |
| 'Proposed' = [Math]::Round($newAngleToWaypoint, 2) | |
| 'Additional Info' = "TWS: $($result.TrueWindSpeed) knots" | |
| } | |
| if ($TideSpeed -gt 0 -and $TideDirectionTrue -ge 0) { | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'Boat VMG (knots)' | |
| 'Current' = $result.CurrentBoatVMG | |
| 'Proposed' = [Math]::Round($boatVMGNew, 2) | |
| 'Additional Info' = "TWA: $($result.TrueWindAngle)°" | |
| } | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'Tide VMG (knots)' | |
| 'Current' = $result.TideVMG | |
| 'Proposed' = [Math]::Round($tideVMGNew, 2) | |
| 'Additional Info' = "Tide: $TideDirectionTrue° @ $TideSpeed kts" | |
| } | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'Total VMG (knots)' | |
| 'Current' = $result.CurrentVMG | |
| 'Proposed' = $result.NewVMG | |
| 'Additional Info' = "Time Diff: $($result.TimeDifference)" | |
| } | |
| } else { | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'VMG (knots)' | |
| 'Current' = $result.CurrentVMG | |
| 'Proposed' = $result.NewVMG | |
| 'Additional Info' = "TWA: $($result.TrueWindAngle)°" | |
| } | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = '' | |
| 'Current' = '' | |
| 'Proposed' = '' | |
| 'Additional Info' = "Time Diff: $($result.TimeDifference)" | |
| } | |
| } | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'ETA' | |
| 'Current' = $result.CurrentArrivalTime | |
| 'Proposed' = $result.NewArrivalTime | |
| 'Additional Info' = '' | |
| } | |
| # Display table | |
| $tableData | Format-Table -AutoSize | |
| # Display recommendation | |
| Write-Host " RECOMMENDATION: $($result.Recommendation)" -ForegroundColor $(if ($timeSaved -gt 0) { "Green" } elseif ($timeSaved -lt 0) { "Red" } else { "Yellow" }) | |
| Write-Host "" | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment