Skip to content

Instantly share code, notes, and snippets.

@SweetAsNZ
Last active December 11, 2025 08:25
Show Gist options
  • Select an option

  • Save SweetAsNZ/186271638d8d1a994af2bdc3d68df63b to your computer and use it in GitHub Desktop.

Select an option

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
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