Last active
December 11, 2025 08:25
-
-
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 | |
| .EXAMPLE | |
| Get-SpeedToMakeWithCourseDeviation -SOG 10 -CourseTrue 45 -AWS 90 -AWA 120 -CourseDeviation 10 -DistanceToWaypoint 1000 -WaypointBearingTrue 50 -CurrentSpeed 6.0 -CurrentAngleTrue 25 -TideDirectionTrue 90 -TideSpeed 7 | |
| .EXAMPLE | |
| # Use Get-OptimalTackingStrategy for downwind gybing analysis (120/240 AWA) | |
| Get-OptimalTackingStrategy -SOG 6 -CourseTrue 180 -AWS 15 -AWA 180 -TargetAWA1 120 -TargetAWA2 240 -DistanceToWaypoint 200 -WaypointBearingTrue 130 | |
| .NOTES | |
| Author: Tim West | |
| Company: Sweet As Chocolate Ltd | |
| Created: 2025-12-08 | |
| Updated: 2025-12-11 | |
| Status: WIP - Work In Progress | |
| Version: 0.2.5 | |
| License: This project is licensed under the MIT License (free to use and modify) | |
| .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 | |
| 2025-12-11: Added Get-OptimalTackingStrategy function for tacking/gybing analysis with timing | |
| .TODO | |
| - Improve polar data interpolation | |
| - Add error handling for edge cases | |
| - Validate input ranges | |
| - Test with real polar data and boat data | |
| #> | |
| [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" | |
| ) | |
| # Track whether optional values were actually provided (so 0° headings are allowed) | |
| $waypointBearingProvided = $PSBoundParameters.ContainsKey('WaypointBearingTrue') | |
| $tideDirProvided = $PSBoundParameters.ContainsKey('TideDirectionTrue') | |
| $tideSpeedProvided = $PSBoundParameters.ContainsKey('TideSpeed') | |
| $currentAngleProvided = $PSBoundParameters.ContainsKey('CurrentAngleTrue') | |
| # Configure output glyphs and console encoding (force Unicode for clean symbols) | |
| $degreeGlyph = 'deg' # '°' | |
| $arrowGlyph = '->' # '→' | |
| [Console]::OutputEncoding = [System.Text.Encoding]::Unicode | |
| # 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) { | |
| # Create GUI form | |
| 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 ($degreeGlyph) *" $CourseTrue $y | |
| $y += 30 | |
| $txtAWS = Add-TextBox 'AWS (knots) *' $AWS $y | |
| $y += 30 | |
| $txtAWA = Add-TextBox "AWA ($degreeGlyph) *" $AWA $y | |
| $y += 30 | |
| $txtCourseDeviation = Add-TextBox "Course Deviation ($degreeGlyph) *" $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 ($degreeGlyph)" $(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 ($degreeGlyph)" $(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 ($degreeGlyph)" $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) | |
| # Text box for polar file path | |
| $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) | |
| # Browse button | |
| $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 | |
| # Cancel button | |
| $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; $currentAngleProvided = $true } | |
| if ($txtCurrentSpeed.Text) { $CurrentSpeed = [double]$txtCurrentSpeed.Text } | |
| if ($txtTideDirection.Text) { $TideDirectionTrue = [double]$txtTideDirection.Text; $tideDirProvided = $true } | |
| if ($txtTideSpeed.Text) { $TideSpeed = [double]$txtTideSpeed.Text; $tideSpeedProvided = $true } | |
| if ($txtDistance.Text) { $DistanceToWaypoint = [double]$txtDistance.Text } | |
| if ($txtWaypointBearing.Text) { $WaypointBearingTrue = [double]$txtWaypointBearing.Text; $waypointBearingProvided = $true } | |
| 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) { | |
| $requiredParams = @('SOG','CourseTrue','AWS','AWA','CourseDeviation') | |
| $missingParams = $requiredParams | Where-Object { -not $PSBoundParameters.ContainsKey($_) } | |
| if ($missingParams.Count -gt 0) { | |
| Write-Error "Required parameters missing: $($missingParams -join ', '). Provide them 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 | |
| } | |
| # Enforce a realistic minimum sailing angle (no-go zone) | |
| $minTWAForSailing = 40 | |
| if ($normalizedAngle -lt $minTWAForSailing) { | |
| Write-Warning "True wind angle $([Math]::Round($normalizedAngle,1))$degreeGlyph is inside the no-go zone; clamping to $minTWAForSailing$degreeGlyph." | |
| $normalizedAngle = $minTWAForSailing | |
| } | |
| # 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 | |
| $angleHeader = $headers | Select-Object -First 1 | |
| # 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/angle column explicitly) | |
| $angleRow = $PolarData | Where-Object { | |
| try { [double]($_.$angleHeader) -eq $roundedAngle } catch { $false } | |
| } | |
| # If exact angle row not found, try to interpolate | |
| if (-not $angleRow) { | |
| # Try to interpolate between nearby angles | |
| $allAngles = @() | |
| foreach ($row in $PolarData) { | |
| try { $allAngles += [double]$row.$angleHeader } catch { } | |
| } | |
| $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 { try { [double]$_."$angleHeader" -eq $lowerAngle } catch { $false } } | |
| $upperRow = $PolarData | Where-Object { try { [double]$_."$angleHeader" -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 | |
| } | |
| } | |
| # Interpolate speed between lower and upper angle rows | |
| 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$degreeGlyph (interpolated) TWS=$([Math]::Round([double]$closestTWSColumn,1)) $arrowGlyph 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 $minTWAForSailing) { | |
| 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 | |
| # Find the closest TWS column | |
| 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 | |
| } | |
| } | |
| # Get boat speed from polar data | |
| if ($closestTWSColumn -and $angleRow.$closestTWSColumn) { | |
| try { | |
| $speed = [double]$angleRow.$closestTWSColumn | |
| Write-Host " Polar lookup: TWA=$roundedAngle$degreeGlyph TWS=$([Math]::Round([double]$closestTWSColumn,1)) $arrowGlyph 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 $minTWAForSailing) { | |
| 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))$degreeGlyph" -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 degrees) | |
| $waveBonusFactor = 1.0 | |
| if ($polarCurrentSpeed -gt 0 -and $SOG -gt $polarCurrentSpeed) { | |
| $speedDifference = $SOG - $polarCurrentSpeed | |
| $normalizedTWA = [Math]::Abs($TWA) | |
| if ($normalizedTWA -gt 180) { $normalizedTWA = 360 - $normalizedTWA } | |
| # Maximum wave effect around 120 degrees TWA, tapering off towards 90 and 150 degrees | |
| 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 deg, 0 at 90 and 150 deg | |
| 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$degreeGlyph 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 (normalize TWA before combining) | |
| $normalizedTWAForDir = ConvertTo-NormalizedAngle $TWA | |
| $trueWindDirection = ConvertTo-NormalizedAngle ($CourseTrue + $normalizedTWAForDir) | |
| $newTWA = ConvertTo-NormalizedAngle ($trueWindDirection - $newCourse) | |
| if ($newTWA -gt 180) { $newTWA = 360 - $newTWA } | |
| Write-Host " New course TWA: $([Math]::Round($newTWA, 2))$degreeGlyph" -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 $expectedSpeed -gt 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 ($tideSpeedProvided -and $tideDirProvided -and $TideSpeed -gt 0) { | |
| Write-Host "`nTide/Current Effects:" -ForegroundColor Cyan | |
| Write-Host " Tide flowing to: $TideDirectionTrue$degreeGlyph at $TideSpeed knots" -ForegroundColor Gray | |
| # Calculate tide component toward waypoint for current course | |
| if ($waypointBearingProvided) { | |
| $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 ($currentAngleProvided) { | |
| # Use the provided current angle to waypoint | |
| $currentAngleToWaypoint = $CurrentAngleTrue | |
| } elseif (-not $waypointBearingProvided) { | |
| # 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 = if ($waypointBearingProvided) { [Math]::Abs($WaypointBearingTrue - $newCourse) } else { 0 } | |
| 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) | |
| # Prepare recommendation based on time saved | |
| 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 ( $degreeGlyph)" | |
| 'Current' = $result.CurrentCourse | |
| 'Proposed' = "$($result.NewCourse) (${CourseDeviation} $degreeGlyph deviation)" | |
| 'Additional Info' = "Distance to WP: $($result.DistanceToWaypoint) nm" | |
| } | |
| $tableData = @($comparisonData) | |
| # Add Speed row | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'Speed (knots)' | |
| 'Current' = $result.CurrentSOG | |
| 'Proposed' = $result.ExpectedSpeed | |
| 'Additional Info' = "WP Bearing: $($result.WaypointBearing) $degreeGlyph" | |
| } | |
| # Add AWA row | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = "AWA ($degreeGlyph)" | |
| 'Current' = $result.CurrentAWA | |
| 'Proposed' = $result.NewAWA | |
| 'Additional Info' = "AWS: $($result.CurrentAWS) knots" | |
| } | |
| # Add Angle to WP row | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = "Angle to WP ($degreeGlyph)" | |
| 'Current' = $result.CurrentAngleToWaypoint | |
| 'Proposed' = [Math]::Round($newAngleToWaypoint, 2) | |
| 'Additional Info' = "TWS: $($result.TrueWindSpeed) knots" | |
| } | |
| if ($tideSpeedProvided -and $tideDirProvided -and $TideSpeed -gt 0) { | |
| # Add Boat VMG row | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'Boat VMG (knots)' | |
| 'Current' = $result.CurrentBoatVMG | |
| 'Proposed' = [Math]::Round($boatVMGNew, 2) | |
| 'Additional Info' = "TWA: $($result.TrueWindAngle) $degreeGlyph" | |
| } | |
| # Add Tide VMG row | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'Tide VMG (knots)' | |
| 'Current' = $result.TideVMG | |
| 'Proposed' = [Math]::Round($tideVMGNew, 2) | |
| 'Additional Info' = "Tide: $TideDirectionTrue $degreeGlyph @ $TideSpeed kts" | |
| } | |
| # Add empty row for separation | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'Total VMG (knots)' | |
| 'Current' = $result.CurrentVMG | |
| 'Proposed' = $result.NewVMG | |
| 'Additional Info' = "Time Diff: $($result.TimeDifference)" | |
| } | |
| } else { | |
| # Add VMG without tide info | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = 'VMG (knots)' | |
| 'Current' = $result.CurrentVMG | |
| 'Proposed' = $result.NewVMG | |
| 'Additional Info' = "TWA: $($result.TrueWindAngle) $degreeGlyph" | |
| } | |
| # Add empty row for tide info | |
| $tableData += [PSCustomObject]@{ | |
| 'Metric' = '' | |
| 'Current' = '' | |
| 'Proposed' = '' | |
| 'Additional Info' = "Time Diff: $($result.TimeDifference)" | |
| } | |
| } | |
| # Add arrival times | |
| $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 "" | |
| } | |
| function Get-OptimalTackingStrategy { | |
| <# | |
| .SYNOPSIS | |
| Calculate optimal tacking/gybing strategy with timing for course corrections. | |
| .DESCRIPTION | |
| Analyzes alternating AWA scenarios (e.g., 120° then 210°) to find the optimal | |
| tacking or gybing pattern that maximizes VMG to the waypoint. Calculates: | |
| - Optimal angles to sail on each tack/gybe | |
| - Time to sail on each leg before course correction | |
| - Overall VMG improvement vs direct course | |
| - Total time savings | |
| .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 | |
| Current Apparent Wind Angle in degrees relative to boat heading | |
| .PARAMETER TargetAWA1 | |
| First target AWA for tacking strategy (e.g., 120 for starboard gybe) | |
| .PARAMETER TargetAWA2 | |
| Second target AWA for tacking strategy (e.g., 240 or -120 for port gybe) | |
| .PARAMETER DistanceToWaypoint | |
| Distance to waypoint in nautical miles | |
| .PARAMETER WaypointBearingTrue | |
| True bearing to waypoint in degrees (0-360) | |
| .PARAMETER TideDirectionTrue | |
| Direction the tide is flowing TO in degrees (0-360) - optional | |
| .PARAMETER TideSpeed | |
| Speed of the tide/current in knots - optional | |
| .PARAMETER PolarDiagramData | |
| Path to CSV file containing polar diagram data | |
| .EXAMPLE | |
| # Downwind dead run - waypoint directly downwind, gybe between 120° and 240° AWA | |
| # Wind from north (0°), heading south to waypoint at 180° | |
| Get-OptimalTackingStrategy -SOG 6 -CourseTrue 180 -AWS 15 -AWA 180 -TargetAWA1 120 -TargetAWA2 240 -DistanceToWaypoint 20 -WaypointBearingTrue 180 | |
| .EXAMPLE | |
| # Downwind with offset waypoint - waypoint at 150°, wind from north | |
| # Gybing between 120° (starboard) and 240° (port) AWA | |
| Get-OptimalTackingStrategy -SOG 6.5 -CourseTrue 150 -AWS 18 -AWA 150 -TargetAWA1 120 -TargetAWA2 240 -DistanceToWaypoint 15 -WaypointBearingTrue 150 | |
| .EXAMPLE | |
| # Classic upwind tacking - waypoint directly upwind, tack between 45° and 315° AWA | |
| # Wind from north, beating to waypoint at 0° | |
| Get-OptimalTackingStrategy -SOG 5 -CourseTrue 45 -AWS 12 -AWA 45 -TargetAWA1 45 -TargetAWA2 315 -DistanceToWaypoint 10 -WaypointBearingTrue 0 | |
| .EXAMPLE | |
| # Tight upwind tacking - racing scenario with 40° tacking angles | |
| Get-OptimalTackingStrategy -SOG 5.5 -CourseTrue 40 -AWS 14 -AWA 40 -TargetAWA1 40 -TargetAWA2 320 -DistanceToWaypoint 8 -WaypointBearingTrue 0 | |
| .EXAMPLE | |
| # Asymmetric downwind - waypoint at 210°, using 120° and 210° AWA (your scenario) | |
| # Favoring one gybe angle closer to rhumb line | |
| Get-OptimalTackingStrategy -SOG 7 -CourseTrue 210 -AWS 16 -AWA 160 -TargetAWA1 120 -TargetAWA2 210 -DistanceToWaypoint 25 -WaypointBearingTrue 210 | |
| .EXAMPLE | |
| # Hot angles downwind - aggressive VMG angles at 135° and 225° AWA | |
| Get-OptimalTackingStrategy -SOG 7.5 -CourseTrue 180 -AWS 20 -AWA 180 -TargetAWA1 135 -TargetAWA2 225 -DistanceToWaypoint 30 -WaypointBearingTrue 180 | |
| .EXAMPLE | |
| # Reaching with slight gybes - waypoint at 90°, using 100° and 80° AWA for optimization | |
| Get-OptimalTackingStrategy -SOG 8 -CourseTrue 90 -AWS 18 -AWA 90 -TargetAWA1 100 -TargetAWA2 80 -DistanceToWaypoint 12 -WaypointBearingTrue 90 | |
| .EXAMPLE | |
| # Downwind with tide - gybing with favorable tide pushing toward waypoint | |
| Get-OptimalTackingStrategy -SOG 6 -CourseTrue 180 -AWS 15 -AWA 180 -TargetAWA1 120 -TargetAWA2 240 -DistanceToWaypoint 20 -WaypointBearingTrue 180 -TideDirectionTrue 180 -TideSpeed 1.5 | |
| .EXAMPLE | |
| # Upwind with adverse tide - tacking against foul current | |
| Get-OptimalTackingStrategy -SOG 5 -CourseTrue 45 -AWS 12 -AWA 45 -TargetAWA1 45 -TargetAWA2 315 -DistanceToWaypoint 10 -WaypointBearingTrue 0 -TideDirectionTrue 180 -TideSpeed 2.0 | |
| .EXAMPLE | |
| # Light wind downwind - wider angles needed (130° and 230°) | |
| Get-OptimalTackingStrategy -SOG 4 -CourseTrue 180 -AWS 8 -AWA 180 -TargetAWA1 130 -TargetAWA2 230 -DistanceToWaypoint 15 -WaypointBearingTrue 180 | |
| .EXAMPLE | |
| # Strong wind narrow angles - tighter gybes in heavy air (110° and 250°) | |
| Get-OptimalTackingStrategy -SOG 9 -CourseTrue 180 -AWS 28 -AWA 180 -TargetAWA1 110 -TargetAWA2 250 -DistanceToWaypoint 25 -WaypointBearingTrue 180 | |
| .NOTES | |
| Author: Tim West | |
| Company: Sweet As Chocolate Ltd | |
| Created: 2025-12-11 | |
| Version: 0.1.0 | |
| License: MIT License | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory = $true, HelpMessage = "Current Speed Over Ground in knots")] | |
| [double]$SOG, | |
| [Parameter(Mandatory = $true, HelpMessage = "Current course heading in degrees")] | |
| [double]$CourseTrue, | |
| [Parameter(Mandatory = $true, HelpMessage = "Apparent Wind Speed in knots")] | |
| [double]$AWS, | |
| [Parameter(Mandatory = $true, HelpMessage = "Apparent Wind Angle in degrees")] | |
| [double]$AWA, | |
| [Parameter(Mandatory = $true, HelpMessage = "First target AWA (e.g., 120 for starboard gybe)")] | |
| [double]$TargetAWA1, | |
| [Parameter(Mandatory = $true, HelpMessage = "Second target AWA (e.g., 240 for port gybe)")] | |
| [double]$TargetAWA2, | |
| [Parameter(Mandatory = $true, HelpMessage = "Distance to waypoint in nautical miles")] | |
| [double]$DistanceToWaypoint, | |
| [Parameter(Mandatory = $true, HelpMessage = "True bearing to waypoint in degrees")] | |
| [double]$WaypointBearingTrue, | |
| [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(HelpMessage = "Path to polar diagram CSV file")] | |
| [string]$PolarDiagramData = "$ENV:OneDriveConsumer\Documents\Boating\Example_Polar_With_Correct_Headings_29-04-25.csv" | |
| ) | |
| $tideDirProvided = $PSBoundParameters.ContainsKey('TideDirectionTrue') | |
| $tideSpeedProvided = $PSBoundParameters.ContainsKey('TideSpeed') | |
| $degreeGlyph = 'deg' | |
| $arrowGlyph = '->' | |
| [Console]::OutputEncoding = [System.Text.Encoding]::Unicode | |
| # Validate inputs | |
| if (-not (Test-Path $PolarDiagramData)) { | |
| Write-Error "Polar diagram file not found: $PolarDiagramData" | |
| return | |
| } | |
| Write-Host "`n=== Optimal Tacking/Gybing Strategy Analysis ===" -ForegroundColor Cyan | |
| Write-Host "Analyzing AWA patterns: $TargetAWA1$degreeGlyph and $TargetAWA2$degreeGlyph" -ForegroundColor Gray | |
| # 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 (same as main function) | |
| function Get-BoatSpeedFromPolar { | |
| param( | |
| [object[]]$PolarData, | |
| [double]$WindSpeed, | |
| [double]$WindAngle | |
| ) | |
| $normalizedAngle = [Math]::Abs($WindAngle) | |
| if ($normalizedAngle -gt 180) { $normalizedAngle = 360 - $normalizedAngle } | |
| $minTWAForSailing = 40 | |
| if ($normalizedAngle -lt $minTWAForSailing) { | |
| $normalizedAngle = $minTWAForSailing | |
| } | |
| $roundedAngle = [Math]::Round($normalizedAngle / 5) * 5 | |
| $headers = $PolarData[0].PSObject.Properties.Name | |
| $angleHeader = $headers | Select-Object -First 1 | |
| $twsValues = @() | |
| foreach ($header in $headers) { | |
| try { $twsValues += [double]$header } catch { } | |
| } | |
| $twsValues = $twsValues | Sort-Object | |
| $maxTWS = if ($twsValues.Count -gt 0) { $twsValues[-1] } else { 30 } | |
| $cappedWindSpeed = [Math]::Min($WindSpeed, $maxTWS) | |
| $angleRow = $PolarData | Where-Object { | |
| try { [double]($_.$angleHeader) -eq $roundedAngle } catch { $false } | |
| } | |
| $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 ($angleRow -and $closestTWSColumn -and $angleRow.$closestTWSColumn) { | |
| try { return [double]$angleRow.$closestTWSColumn } catch { } | |
| } | |
| # Fallback estimation | |
| if ($roundedAngle -lt $minTWAForSailing) { return 0 } | |
| 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 } | |
| } | |
| # Helper function to find best VMG angles from polar diagram | |
| function Get-BestVMGAngles { | |
| param( | |
| [object[]]$PolarData, | |
| [double]$WindSpeed | |
| ) | |
| $headers = $PolarData[0].PSObject.Properties.Name | |
| $angleHeader = $headers | Select-Object -First 1 | |
| # Find closest TWS column | |
| $twsValues = @() | |
| foreach ($header in $headers) { | |
| try { $twsValues += [double]$header } catch { } | |
| } | |
| $twsValues = $twsValues | Sort-Object | |
| $maxTWS = if ($twsValues.Count -gt 0) { $twsValues[-1] } else { 30 } | |
| $cappedWindSpeed = [Math]::Min($WindSpeed, $maxTWS) | |
| $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 } | |
| } | |
| $bestUpwindVMG = 0 | |
| $bestUpwindAngle = 45 | |
| $bestDownwindVMG = 0 | |
| $bestDownwindAngle = 150 | |
| foreach ($row in $PolarData) { | |
| try { | |
| $angle = [double]$row.$angleHeader | |
| $speed = [double]$row.$closestTWSColumn | |
| # Calculate VMG for this angle | |
| $vmg = $speed * [Math]::Cos($angle * [Math]::PI / 180) | |
| # Best upwind VMG (angles 30-80, positive VMG toward wind) | |
| if ($angle -ge 30 -and $angle -le 80) { | |
| if ($vmg -gt $bestUpwindVMG) { | |
| $bestUpwindVMG = $vmg | |
| $bestUpwindAngle = $angle | |
| } | |
| } | |
| # Best downwind VMG (angles 100-180, negative VMG = toward downwind) | |
| if ($angle -ge 100 -and $angle -le 180) { | |
| $downwindVMG = [Math]::Abs($vmg) # Take absolute for comparison | |
| if ($downwindVMG -gt $bestDownwindVMG) { | |
| $bestDownwindVMG = $downwindVMG | |
| $bestDownwindAngle = $angle | |
| } | |
| } | |
| } catch { continue } | |
| } | |
| return @{ | |
| BestUpwindAngle = $bestUpwindAngle | |
| BestUpwindVMG = $bestUpwindVMG | |
| BestDownwindAngle = $bestDownwindAngle | |
| BestDownwindVMG = $bestDownwindVMG | |
| } | |
| } | |
| # 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))$degreeGlyph" -ForegroundColor Gray | |
| # Get best VMG angles from polar diagram | |
| $bestAngles = Get-BestVMGAngles -PolarData $polarData -WindSpeed $TWS | |
| Write-Host " Best Upwind Angle: $($bestAngles.BestUpwindAngle)$degreeGlyph (VMG: $([Math]::Round($bestAngles.BestUpwindVMG, 2)) kts)" -ForegroundColor Gray | |
| Write-Host " Best Downwind Angle: $($bestAngles.BestDownwindAngle)$degreeGlyph (VMG: $([Math]::Round($bestAngles.BestDownwindVMG, 2)) kts)" -ForegroundColor Gray | |
| # Calculate true wind direction (global reference) | |
| $normalizedTWAForDir = ConvertTo-NormalizedAngle $TWA | |
| $trueWindDirection = ConvertTo-NormalizedAngle ($CourseTrue + $normalizedTWAForDir) | |
| Write-Host " True Wind Direction: $([Math]::Round($trueWindDirection, 2))$degreeGlyph" -ForegroundColor Gray | |
| # Calculate courses for each target AWA | |
| # AWA is relative to heading, so course = wind_direction - target_AWA (adjusted) | |
| # For target AWA 1 (e.g., 120° - starboard gybe/tack) | |
| $course1 = ConvertTo-NormalizedAngle ($trueWindDirection - $TargetAWA1 + 180) | |
| $twa1 = ConvertTo-NormalizedAngle ($trueWindDirection - $course1) | |
| if ($twa1 -gt 180) { $twa1 = 360 - $twa1 } | |
| $speed1 = Get-BoatSpeedFromPolar -PolarData $polarData -WindSpeed $TWS -WindAngle $twa1 | |
| # For target AWA 2 (e.g., 240° - port gybe/tack) | |
| $course2 = ConvertTo-NormalizedAngle ($trueWindDirection - $TargetAWA2 + 180) | |
| $twa2 = ConvertTo-NormalizedAngle ($trueWindDirection - $course2) | |
| if ($twa2 -gt 180) { $twa2 = 360 - $twa2 } | |
| $speed2 = Get-BoatSpeedFromPolar -PolarData $polarData -WindSpeed $TWS -WindAngle $twa2 | |
| Write-Host "`nLeg Analysis:" -ForegroundColor Cyan | |
| Write-Host " Leg 1: Course $([Math]::Round($course1,1))$degreeGlyph @ AWA $TargetAWA1$degreeGlyph (TWA $([Math]::Round($twa1,1))$degreeGlyph) $arrowGlyph $([Math]::Round($speed1,2)) knots" -ForegroundColor Gray | |
| Write-Host " Leg 2: Course $([Math]::Round($course2,1))$degreeGlyph @ AWA $TargetAWA2$degreeGlyph (TWA $([Math]::Round($twa2,1))$degreeGlyph) $arrowGlyph $([Math]::Round($speed2,2)) knots" -ForegroundColor Gray | |
| # Calculate angle differences from waypoint bearing | |
| $angle1ToWP = [Math]::Abs($WaypointBearingTrue - $course1) | |
| if ($angle1ToWP -gt 180) { $angle1ToWP = 360 - $angle1ToWP } | |
| $angle2ToWP = [Math]::Abs($WaypointBearingTrue - $course2) | |
| if ($angle2ToWP -gt 180) { $angle2ToWP = 360 - $angle2ToWP } | |
| # Calculate VMG for each leg | |
| $vmg1 = $speed1 * [Math]::Cos($angle1ToWP * [Math]::PI / 180) | |
| $vmg2 = $speed2 * [Math]::Cos($angle2ToWP * [Math]::PI / 180) | |
| # Add tide effects if provided | |
| $tideVMG = 0 | |
| if ($tideSpeedProvided -and $tideDirProvided -and $TideSpeed -gt 0) { | |
| $tideAngleToWP = [Math]::Abs($TideDirectionTrue - $WaypointBearingTrue) | |
| if ($tideAngleToWP -gt 180) { $tideAngleToWP = 360 - $tideAngleToWP } | |
| $tideVMG = $TideSpeed * [Math]::Cos($tideAngleToWP * [Math]::PI / 180) | |
| Write-Host " Tide VMG contribution: $([Math]::Round($tideVMG, 2)) knots" -ForegroundColor Gray | |
| $vmg1 += $tideVMG | |
| $vmg2 += $tideVMG | |
| } | |
| Write-Host "`n Leg 1 VMG: $([Math]::Round($vmg1, 2)) knots (angle to WP: $([Math]::Round($angle1ToWP, 1))$degreeGlyph)" -ForegroundColor Gray | |
| Write-Host " Leg 2 VMG: $([Math]::Round($vmg2, 2)) knots (angle to WP: $([Math]::Round($angle2ToWP, 1))$degreeGlyph)" -ForegroundColor Gray | |
| # Calculate direct course VMG for comparison | |
| $directAngle = [Math]::Abs($WaypointBearingTrue - $CourseTrue) | |
| if ($directAngle -gt 180) { $directAngle = 360 - $directAngle } | |
| $directVMG = $SOG * [Math]::Cos($directAngle * [Math]::PI / 180) + $tideVMG | |
| # Calculate optimal leg ratio for tacking | |
| # The ratio depends on how far off the rhumb line each course takes us | |
| # For symmetric tacking, we need to solve for the proportions that get us to the waypoint | |
| # Calculate the lateral deviation per nm sailed on each leg | |
| $lateralRate1 = $speed1 * [Math]::Sin($angle1ToWP * [Math]::PI / 180) | |
| $lateralRate2 = $speed2 * [Math]::Sin($angle2ToWP * [Math]::PI / 180) | |
| # Determine if courses diverge in opposite directions (valid tacking scenario) | |
| $deviation1 = $course1 - $WaypointBearingTrue | |
| if ($deviation1 -gt 180) { $deviation1 -= 360 } | |
| if ($deviation1 -lt -180) { $deviation1 += 360 } | |
| $deviation2 = $course2 - $WaypointBearingTrue | |
| if ($deviation2 -gt 180) { $deviation2 -= 360 } | |
| if ($deviation2 -lt -180) { $deviation2 += 360 } | |
| # Calculate leg proportions to reach waypoint | |
| # We need: time1 * lateral1 + time2 * lateral2 = 0 (net zero lateral displacement) | |
| # And: time1 * vmg1 + time2 * vmg2 = DistanceToWaypoint | |
| $totalLateralBalance = [Math]::Abs($lateralRate1) + [Math]::Abs($lateralRate2) | |
| if ($totalLateralBalance -gt 0.1) { | |
| # Calculate time ratio to balance lateral displacement | |
| $ratio1 = [Math]::Abs($lateralRate2) / $totalLateralBalance | |
| $ratio2 = [Math]::Abs($lateralRate1) / $totalLateralBalance | |
| # Calculate combined VMG | |
| $combinedVMG = ($ratio1 * $vmg1) + ($ratio2 * $vmg2) | |
| if ($combinedVMG -gt 0) { | |
| # Total time to reach waypoint | |
| $totalTime = $DistanceToWaypoint / $combinedVMG | |
| # Time on each leg | |
| $time1 = $totalTime * $ratio1 | |
| $time2 = $totalTime * $ratio2 | |
| # Distance sailed on each leg | |
| $distance1 = $speed1 * $time1 | |
| $distance2 = $speed2 * $time2 | |
| $totalDistanceSailed = $distance1 + $distance2 | |
| # Compare to direct course | |
| $directTime = if ($directVMG -gt 0) { $DistanceToWaypoint / $directVMG } else { [double]::PositiveInfinity } | |
| $timeSaved = $directTime - $totalTime | |
| } else { | |
| $combinedVMG = 0 | |
| $totalTime = [double]::PositiveInfinity | |
| $time1 = [double]::PositiveInfinity | |
| $time2 = [double]::PositiveInfinity | |
| $distance1 = 0 | |
| $distance2 = 0 | |
| $totalDistanceSailed = 0 | |
| $directTime = [double]::PositiveInfinity | |
| $timeSaved = 0 | |
| } | |
| } else { | |
| # Nearly parallel courses - use equal time split | |
| $ratio1 = 0.5 | |
| $ratio2 = 0.5 | |
| $combinedVMG = ($vmg1 + $vmg2) / 2 | |
| $totalTime = if ($combinedVMG -gt 0) { $DistanceToWaypoint / $combinedVMG } else { [double]::PositiveInfinity } | |
| $time1 = $totalTime / 2 | |
| $time2 = $totalTime / 2 | |
| $distance1 = $speed1 * $time1 | |
| $distance2 = $speed2 * $time2 | |
| $totalDistanceSailed = $distance1 + $distance2 | |
| $directTime = if ($directVMG -gt 0) { $DistanceToWaypoint / $directVMG } else { [double]::PositiveInfinity } | |
| $timeSaved = $directTime - $totalTime | |
| } | |
| # Format times | |
| function Format-Time { | |
| param([double]$hours) | |
| if ($hours -eq [double]::PositiveInfinity -or $hours -lt 0) { return "N/A" } | |
| $h = [Math]::Floor($hours) | |
| $m = [Math]::Round(($hours - $h) * 60) | |
| return "${h}h ${m}m" | |
| } | |
| # Display Results | |
| Write-Host "`n=== OPTIMAL TACKING STRATEGY ===" -ForegroundColor Green | |
| Write-Host "" | |
| # Determine if tacking (upwind) or gybing (downwind) based on average TWA | |
| $avgTWA = ($twa1 + $twa2) / 2 | |
| $maneuverType = if ($avgTWA -lt 90) { "Tack" } else { "Gybe" } | |
| # Create columnar table layout | |
| $resultsTable = @() | |
| # Header row with column titles | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = '=== LEG 1 ===' | |
| 'Leg 2' = '=== LEG 2 ===' | |
| 'Current/Direct' = '=== CURRENT ===' | |
| 'Optimal' = '=== OPTIMAL ===' | |
| } | |
| # Waypoint info | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "WP: $WaypointBearingTrue$degreeGlyph" | |
| 'Leg 2' = "WP: $WaypointBearingTrue$degreeGlyph" | |
| 'Current/Direct' = "Dist: $DistanceToWaypoint nm" | |
| 'Optimal' = "Up: $($bestAngles.BestUpwindAngle)deg" | |
| } | |
| # Wind info | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "TWS: $([Math]::Round($TWS,0)) kts" | |
| 'Leg 2' = "TWS: $([Math]::Round($TWS,0)) kts" | |
| 'Current/Direct' = "Wind From: $([Math]::Round($trueWindDirection,0))deg" | |
| 'Optimal' = "Dn: $($bestAngles.BestDownwindAngle)deg" | |
| } | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = '---' | |
| 'Leg 2' = '---' | |
| 'Current/Direct' = '---' | |
| 'Optimal' = '---' | |
| } | |
| # Course | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "Course: $([Math]::Round($course1,0))deg" | |
| 'Leg 2' = "Course: $([Math]::Round($course2,0))deg" | |
| 'Current/Direct' = "Course: $CourseTrue$degreeGlyph" | |
| 'Optimal' = '' | |
| } | |
| # AWA | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "AWA: $TargetAWA1$degreeGlyph" | |
| 'Leg 2' = "AWA: $TargetAWA2$degreeGlyph" | |
| 'Current/Direct' = "AWA: $AWA$degreeGlyph" | |
| 'Optimal' = '' | |
| } | |
| # TWA | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "TWA: $([Math]::Round($twa1,1))deg" | |
| 'Leg 2' = "TWA: $([Math]::Round($twa2,1))deg" | |
| 'Current/Direct' = '' | |
| 'Optimal' = '' | |
| } | |
| # Angle to WP | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "To WP: $([Math]::Round($angle1ToWP,1))deg" | |
| 'Leg 2' = "To WP: $([Math]::Round($angle2ToWP,1))deg" | |
| 'Current/Direct' = '' | |
| 'Optimal' = '' | |
| } | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = '---' | |
| 'Leg 2' = '---' | |
| 'Current/Direct' = '---' | |
| 'Optimal' = '---' | |
| } | |
| # Speed | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "Speed: $([Math]::Round($speed1,2)) kts" | |
| 'Leg 2' = "Speed: $([Math]::Round($speed2,2)) kts" | |
| 'Current/Direct' = "SOG: $SOG kts" | |
| 'Optimal' = '' | |
| } | |
| # VMG | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "VMG: $([Math]::Round($vmg1,1)) kts" | |
| 'Leg 2' = "VMG: $([Math]::Round($vmg2,1)) kts" | |
| 'Current/Direct' = "VMG: $([Math]::Round($directVMG,1)) kts" | |
| 'Optimal' = "Up: $([Math]::Round($bestAngles.BestUpwindVMG,1)) kts" | |
| } | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = '' | |
| 'Leg 2' = '' | |
| 'Current/Direct' = '' | |
| 'Optimal' = "Dn: $([Math]::Round($bestAngles.BestDownwindVMG,1)) kts" | |
| } | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = '---' | |
| 'Leg 2' = '---' | |
| 'Current/Direct' = '---' | |
| 'Optimal' = '---' | |
| } | |
| # Duration | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "Duration: $(Format-Time $time1)" | |
| 'Leg 2' = "Duration: $(Format-Time $time2)" | |
| 'Current/Direct' = "Direct: $(Format-Time $directTime)" | |
| 'Optimal' = "$maneuverType @ Leg 1 end" | |
| } | |
| # Distance | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "Distance: $([Math]::Round($distance1,1)) nm" | |
| 'Leg 2' = "Distance: $([Math]::Round($distance2,1)) nm" | |
| 'Current/Direct' = '' | |
| 'Optimal' = "Next @ midpoint" | |
| } | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = '---' | |
| 'Leg 2' = '---' | |
| 'Current/Direct' = '---' | |
| 'Optimal' = '---' | |
| } | |
| # Totals | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "TOTAL TIME:" | |
| 'Leg 2' = Format-Time $totalTime | |
| 'Current/Direct' = "Direct: $(Format-Time $directTime)" | |
| 'Optimal' = '' | |
| } | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "TOTAL DIST:" | |
| 'Leg 2' = "$([Math]::Round($totalDistanceSailed,1)) nm" | |
| 'Current/Direct' = "Direct: $DistanceToWaypoint nm" | |
| 'Optimal' = '' | |
| } | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "COMBINED VMG:" | |
| 'Leg 2' = "$([Math]::Round($combinedVMG,1)) kts" | |
| 'Current/Direct' = "Direct: $([Math]::Round($directVMG,1)) kts" | |
| 'Optimal' = '' | |
| } | |
| # Add tide info if provided | |
| if ($tideSpeedProvided -and $tideDirProvided -and $TideSpeed -gt 0) { | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = '---' | |
| 'Leg 2' = '---' | |
| 'Current/Direct' = '---' | |
| 'Optimal' = '---' | |
| } | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Leg 1' = "Tide: $TideDirectionTrue$degreeGlyph" | |
| 'Leg 2' = "Tide: $TideDirectionTrue$degreeGlyph" | |
| 'Current/Direct' = "Speed: $TideSpeed kts" | |
| 'Optimal' = '' | |
| } | |
| $resultsTable += [PSCustomObject]@{ | |
| 'Parameter' = 'Tide VMG Contribution' | |
| 'Leg 1' = "$([Math]::Round($tideVMG,1)) kts" | |
| 'Leg 2' = "$([Math]::Round($tideVMG,1)) kts" | |
| 'Additional Info' = '' | |
| 'Best Angle' = '' | |
| "Time to $maneuverType" = '' | |
| } | |
| } | |
| $resultsTable | Format-Table -AutoSize | |
| # Strategy comparison table | |
| Write-Host " === STRATEGY COMPARISON ===" -ForegroundColor Cyan | |
| $strategyTable = @() | |
| $strategyTable += [PSCustomObject]@{ | |
| 'Strategy' = 'Tacking/Gybing' | |
| 'VMG (knots)' = "$([Math]::Round($combinedVMG,2))" | |
| 'ETA' = Format-Time $totalTime | |
| 'Distance' = "$([Math]::Round($totalDistanceSailed,2)) nm" | |
| } | |
| $strategyTable += [PSCustomObject]@{ | |
| 'Strategy' = 'Direct Course' | |
| 'VMG (knots)' = "$([Math]::Round($directVMG,2))" | |
| 'ETA' = Format-Time $directTime | |
| 'Distance' = "$DistanceToWaypoint nm" | |
| } | |
| $strategyTable | Format-Table -AutoSize | |
| # Recommendation | |
| Write-Host "" | |
| if ($combinedVMG -gt $directVMG -and $combinedVMG -gt 0) { | |
| $improvement = (($combinedVMG / $directVMG) - 1) * 100 | |
| $timeSavedH = [Math]::Floor([Math]::Abs($timeSaved)) | |
| $timeSavedM = [Math]::Round(([Math]::Abs($timeSaved) - $timeSavedH) * 60) | |
| Write-Host " RECOMMENDATION: USE TACKING STRATEGY" -ForegroundColor Green | |
| Write-Host " VMG Improvement: +$([Math]::Round($improvement,1))%" -ForegroundColor Green | |
| Write-Host " Time Saved: ${timeSavedH}h ${timeSavedM}m" -ForegroundColor Green | |
| Write-Host "" | |
| Write-Host " Strategy:" -ForegroundColor Cyan | |
| Write-Host " 1. Sail on course $([Math]::Round($course1,0))$degreeGlyph at $TargetAWA1$degreeGlyph AWA for $(Format-Time $time1)" -ForegroundColor White | |
| Write-Host " 2. Gybe/Tack to course $([Math]::Round($course2,0))$degreeGlyph at $TargetAWA2$degreeGlyph AWA for $(Format-Time $time2)" -ForegroundColor White | |
| Write-Host " 3. Arrive at waypoint" -ForegroundColor White | |
| } elseif ($combinedVMG -gt 0 -and $directVMG -gt 0) { | |
| Write-Host " RECOMMENDATION: SAIL DIRECT COURSE" -ForegroundColor Yellow | |
| Write-Host " The direct course provides better or equal VMG." -ForegroundColor Yellow | |
| Write-Host " Direct VMG: $([Math]::Round($directVMG,2)) kts vs Tacking VMG: $([Math]::Round($combinedVMG,2)) kts" -ForegroundColor Yellow | |
| } else { | |
| Write-Host " RECOMMENDATION: UNABLE TO CALCULATE" -ForegroundColor Red | |
| Write-Host " Check that your target AWAs allow progress toward the waypoint." -ForegroundColor Red | |
| } | |
| Write-Host "" | |
| # Return result object (suppressed - data shown in table above) | |
| [void]([PSCustomObject]@{ | |
| Leg1Course = [Math]::Round($course1, 1) | |
| Leg1AWA = $TargetAWA1 | |
| Leg1Speed = [Math]::Round($speed1, 2) | |
| Leg1VMG = [Math]::Round($vmg1, 2) | |
| Leg1Duration = $time1 | |
| Leg1DurationFormatted = Format-Time $time1 | |
| Leg1Distance = [Math]::Round($distance1, 2) | |
| Leg2Course = [Math]::Round($course2, 1) | |
| Leg2AWA = $TargetAWA2 | |
| Leg2Speed = [Math]::Round($speed2, 2) | |
| Leg2VMG = [Math]::Round($vmg2, 2) | |
| Leg2Duration = $time2 | |
| Leg2DurationFormatted = Format-Time $time2 | |
| Leg2Distance = [Math]::Round($distance2, 2) | |
| CombinedVMG = [Math]::Round($combinedVMG, 2) | |
| DirectVMG = [Math]::Round($directVMG, 2) | |
| TotalTime = $totalTime | |
| TotalTimeFormatted = Format-Time $totalTime | |
| DirectTime = $directTime | |
| DirectTimeFormatted = Format-Time $directTime | |
| TimeSaved = $timeSaved | |
| TotalDistanceSailed = [Math]::Round($totalDistanceSailed, 2) | |
| DistanceToWaypoint = $DistanceToWaypoint | |
| WaypointBearing = $WaypointBearingTrue | |
| TrueWindSpeed = [Math]::Round($TWS, 2) | |
| TrueWindDirection = [Math]::Round($trueWindDirection, 1) | |
| IsTackingBetter = ($combinedVMG -gt $directVMG) | |
| }) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment