openwind-ci
sync: github 07b4bba
48dce66
Raw
History Blame Contribute Delete
4.01 kB
"""Spherical geometry helpers — all distances in nautical miles, angles in degrees.
Earth radius is taken as 3440.065 NM (mean radius 6371.0088 km / 1.852).
For Mediterranean trips (max ~1000 NM), the WGS84 ellipsoid correction is
under 0.5% and is ignored.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from itertools import pairwise
EARTH_RADIUS_NM = 3440.065
@dataclass(frozen=True, slots=True)
class Point:
lat: float
lon: float
@dataclass(frozen=True, slots=True)
class Segment:
start: Point
end: Point
distance_nm: float
bearing_deg: float
def _angular_distance_rad(a: Point, b: Point) -> float:
lat1, lon1 = math.radians(a.lat), math.radians(a.lon)
lat2, lon2 = math.radians(b.lat), math.radians(b.lon)
dlat = lat2 - lat1
dlon = lon2 - lon1
h = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
return 2 * math.asin(min(1.0, math.sqrt(h)))
def haversine_distance(a: Point, b: Point) -> float:
"""Great-circle distance in nautical miles."""
return EARTH_RADIUS_NM * _angular_distance_rad(a, b)
def bearing(a: Point, b: Point) -> float:
"""Initial true bearing from a to b, in degrees [0, 360)."""
lat1, lon1 = math.radians(a.lat), math.radians(a.lon)
lat2, lon2 = math.radians(b.lat), math.radians(b.lon)
dlon = lon2 - lon1
x = math.sin(dlon) * math.cos(lat2)
y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
return (math.degrees(math.atan2(x, y)) + 360.0) % 360.0
def interpolate_great_circle(a: Point, b: Point, fraction: float) -> Point:
"""Spherical linear interpolation along the great circle from a to b.
fraction=0 returns a, fraction=1 returns b.
"""
delta = _angular_distance_rad(a, b)
if delta < 1e-12:
return a
lat1, lon1 = math.radians(a.lat), math.radians(a.lon)
lat2, lon2 = math.radians(b.lat), math.radians(b.lon)
sin_delta = math.sin(delta)
a_coef = math.sin((1.0 - fraction) * delta) / sin_delta
b_coef = math.sin(fraction * delta) / sin_delta
x = a_coef * math.cos(lat1) * math.cos(lon1) + b_coef * math.cos(lat2) * math.cos(lon2)
y = a_coef * math.cos(lat1) * math.sin(lon1) + b_coef * math.cos(lat2) * math.sin(lon2)
z = a_coef * math.sin(lat1) + b_coef * math.sin(lat2)
lat = math.atan2(z, math.sqrt(x * x + y * y))
lon = math.atan2(y, x)
return Point(lat=math.degrees(lat), lon=math.degrees(lon))
def midpoint(a: Point, b: Point) -> Point:
return interpolate_great_circle(a, b, 0.5)
def normalize_twa(twd: float, course: float) -> float:
"""True wind angle relative to course, in [0, 180].
V1 ignores tack (port/starboard); polars are symmetric around the wind axis.
"""
diff = (twd - course + 540.0) % 360.0 - 180.0
return abs(diff)
def segment_route(waypoints: list[Point], segment_length_nm: float) -> list[Segment]:
"""Split a polyline into segments of approximately segment_length_nm length.
Each leg between consecutive waypoints is divided into n = max(1, ceil(d/L))
sub-segments of equal great-circle length d/n. Endpoints exactly hit the
waypoints (no rounding drift).
"""
if segment_length_nm <= 0:
raise ValueError("segment_length_nm must be > 0")
if len(waypoints) < 2:
raise ValueError("need at least 2 waypoints")
segments: list[Segment] = []
for a, b in pairwise(waypoints):
d = haversine_distance(a, b)
n = max(1, math.ceil(d / segment_length_nm))
for i in range(n):
f1 = i / n
f2 = (i + 1) / n
start = a if i == 0 else interpolate_great_circle(a, b, f1)
end = b if i == n - 1 else interpolate_great_circle(a, b, f2)
seg_d = haversine_distance(start, end)
seg_b = bearing(start, end)
segments.append(Segment(start=start, end=end, distance_nm=seg_d, bearing_deg=seg_b))
return segments