Metadata-Version: 2.4
Name: georoutelib
Version: 0.4.0
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Rust
Classifier: Topic :: Scientific/Engineering :: GIS
Requires-Dist: pytest>=9.0 ; extra == 'dev'
Provides-Extra: dev
License-File: LICENSE
Summary: Maritime route geometry and ECA intersect distance (Rust core)
License: MIT
Requires-Python: >=3.12
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM

# georoutelib

Maritime route calculation: waypoints → route geometry → along-route analysis and editing.
Built in **Rust** with **PyO3**, exposed as a Python native extension.

| PyPI | `georoutelib` |
|------|-------------------------|
| import | `import georoutelib` |

Requires Python 3.12+.

> **Mutation semantics:** `RouteLine` and `GeoRoute` mutation methods (`merge_point`,
> `deduplicate`, `simplify`, `remove_point`, …) **modify the underlying data in place**
> and also return the instance for chaining. This matches the Python reference implementation
> (`wr-lane`) — the underlying `points` / `segments` list is shared and mutated directly.
>
> ```python
> # Both styles work equivalently:
> route.merge_point(p)              # in-place, return value optional
> route = route.merge_point(p)      # also fine, enables chaining
> route = route.deduplicate().simplify()
> ```

## Install

```bash
pip install georoutelib
```

Development:

```bash
uv sync --extra dev
env -u CONDA_PREFIX maturin develop
.venv/bin/pytest
```

## Units

| Quantity | Unit |
|----------|------|
| Distance | nautical miles (nm) |
| Speed | knots (kts) |
| Bearing | degrees (0–360°) |
| Time | hours (h) or Unix timestamp (s) |

## Quick start

### Create points and measure distance

```python
from georoutelib import RoutePoint, GeoCalc

a = RoutePoint(lon=-50.9, lat=12.6)
b = RoutePoint(lon=-52.3, lat=12.9)

rhumb_nm = GeoCalc.distance(a, b, rhumb=True)
gc_nm = GeoCalc.distance(a, b, rhumb=False)
bearing_deg = GeoCalc.bearing(a, b, rhumb=True)
print(f"Rhumb: {rhumb_nm:.1f} nm  GC: {gc_nm:.1f} nm  Brng: {bearing_deg:.1f}°")
```

### Destination from bearing & distance

```python
from georoutelib import RoutePoint, GeoCalc

start = RoutePoint(lon=116.4, lat=39.9)     
dest = GeoCalc.destination(start, bearing=45.0, dist_nm=100.0, rhumb=False)
print(f"Destination: ({dest.lon:.2f}, {dest.lat:.2f})")
```

### Build a route from waypoints (great-circle interpolation)

```python
from georoutelib import RoutePoint, GeoRoute

points = [
    RoutePoint(lon=103.8, lat=1.2, is_great_circle=True),
    RoutePoint(lon=104.5, lat=1.5, is_great_circle=True),
    RoutePoint(lon=105.0, lat=2.0),           
]

route = GeoRoute.from_points(points, spacing=200.0)
print(f"Segments: {len(route.segments)}")       # split at ±180° if crossed
print(f"Total: {route.total_distance():.1f} nm")
```

### Flat route operations (chainable)

```python
from georoutelib import RouteLine, RoutePoint

points = [
    RoutePoint(lon=0, lat=0),
    RoutePoint(lon=1, lat=1),
    RoutePoint(lon=2, lat=0),
]
line = RouteLine(points).deduplicate().simplify()

total_nm = line.total_distance()
center_pt = line.center()        # RoutePoint
bbox = line.bbox()               # [min_lon, min_lat, max_lon, max_lat]
segments = line.to_multi_line()  # List[List[Tuple]]
print(f"Distance={total_nm:.1f}nm  Center=({center_pt.lon:.2f},{center_pt.lat:.2f})")
```

### Advance along a route (vessel tracking)

```python
from georoutelib import RoutePoint, GeoRoute

route = GeoRoute.from_points(points, spacing=200.0)
current = RoutePoint(lon=104.0, lat=1.3, custom_speed=14.0)

result = route.advance_along(current, dist=50.0)
next_pos = result["point"]              # RoutePoint at +50 nm ahead
remaining = result["remaining"]         # untraveled segments (GeoRoute segments format)
traveled = result["traveled"]           # List[RoutePoint] passed en route
print(f"Next waypoint: ({next_pos.lon:.3f}, {next_pos.lat:.3f})")
```

### Minimum distance from a point to a route

```python
result = route.min_distance_to(RoutePoint(lon=104.2, lat=1.4))
# {"min_dist": float, "seg_index": int, "min_index": int}
print(f"Cross-track error: {result['min_dist']:.2f} nm")
```

## More scenarios

### Simplify routes (reduce point count)

Three strategies available via `GeoCalc` static methods:

```python
from georoutelib import RoutePoint, RouteLine, GeoCalc

points = [RoutePoint(lon=i * 0.5, lat=(i % 10) * 0.1) for i in range(50)]
line = RouteLine(points)

# Angle-based: remove points where turn angle > min_angle (default 170°)
simple = line.simplify()

# Turn-based: combine distance threshold + angle threshold
simple = GeoCalc.simplify_by_turn(line.points, min_distance=0.5, min_angle=175.0)

# Collinearity-based: drop mid-points on near-straight legs
simple = GeoCalc.simplify_by_collinearity(
    line.points, min_distance=1.0, min_angle=180.0, warn=True,
)
```

### Dateline handling (crossing ±180°)

Routes that cross the antimeridian are automatically split into separate segments.
Both `RouteLine.to_multi_line()` and `GeoRoute.from_points()` handle this internally:

```python
from georoutelib import RouteLine, RoutePoint, GeoCalc

# A route crossing the Pacific dateline
points = [
    RoutePoint(lon=179.0, lat=20.0),
    RoutePoint(lon=-179.0, lat=22.0),   # crosses ±180°
    RoutePoint(lon=-175.0, lat=25.0),
]
line = RouteLine(points)
segments = line.to_multi_line(rhumb=True)
# Returns [[...], [...]] — two segments on each side of the date line
print(f"Split into {len(segments)} segment(s)")

# Manual split via GeoCalc:
segments = GeoCalc.split_at_dateline(line.points, rhumb=False)
```

### Convert between GeoRoute ↔ RouteLine

```python
from georoutelib import RoutePoint, GeoRoute, RouteLine

route = GeoRoute.from_points(my_points, spacing=200.0)

# GeoRoute → RouteLine (flatten all segments into one list)
line = route.to_line()

# RouteLine → coordinate array
coords = line.to_multi_line()   # List[List[(lon, lat)]]
```

### Slice a route between two waypoints

```python
from georoutelib import RoutePoint, GeoRoute

start_wp = RoutePoint(lon=104.0, lat=1.3)
end_wp = RoutePoint(lon=104.8, lat=1.7)

# Get sub-route as coordinates
sub_segments = route.slice_route(start_wp, end_wp)

# Or get as flat RouteLine
sub_line = route.slice_line(start_wp, end_wp)
```

### Find nearest point on route

```python
from georoutelib import RoutePoint, GeoRoute

off_route = RoutePoint(lon=104.3, lat=1.55)
nearest = route.nearest_point(off_route)
# nearest: RoutePoint — closest point on any segment
```

### Fill waypoint distances along a route

```python
from georoutelib import RoutePoint, GeoRoute

waypoints = [RoutePoint(lon=103.8+i*0.3, lat=1.2+i*0.15) for i in range(5)]

# Populate distance_from_previous / distance_from_start on each WP
route.fill_point_distances(waypoints)

for wp in waypoints:
    print(f"WP ({wp.lon:.1f}, {wp.lat:.1f}): "
          f"d_prev={wp.distance_from_previous:.1f}nm  "
          f"d_start={wp.distance_from_start:.1f}nm")
```

### Compress dense great-circle points back to reference waypoints

```python
from georoutelib import RoutePoint, GeoRoute

dense = route.to_points(min_distance_nm=0.1)  # hundreds of interpolated points
ref_points = [                                  # your original waypoints
    RoutePoint(lon=103.8, lat=1.2),
    RoutePoint(lon=105.0, lat=2.0),
]
compressed = route.compress_points(ref_points, min_distance_nm=1.0)
# Returns simplified list preserving reference waypoints
```

### Time-based queries (DR / dead reckoning)

When `RoutePoint.position_time` (Unix timestamp) is populated:

```python
from georoutelib import RoutePoint, RouteLine

pts = []
for i, t in enumerate([1700000000, 1700003600, 1700007200, 1700010800]):
    pts.append(RoutePoint(lon=120 + i * 0.5, lat=25 + i * 0.1, position_time=t))

line = RouteLine(pts)

# Interpolate position at a given timestamp
pos = line.position_at(1700005000)  # Option[RoutePoint]

# Reverse lookup: time when vessel passes a location
ts = line.time_at(target_point)     # Option[float] — Unix ts

# Nearest point by temporal window
pt = line.nearest_point_by_time(ts=1700006000, step_hours=2.0)

# Remaining points from a given position
ahead = line.remaining_points_from(some_point)
prev = line.prev_point_at(some_point)
```

### Cross-track distance (how far off-course?)

```python
from georoutelib import RoutePoint, GeoCalc

p = RoutePoint(lon=104.2, lat=1.4)   # vessel position
a = RoutePoint(lon=104.0, lat=1.3)   # leg start
b = RoutePoint(lon=104.5, lat=1.6)   # leg end

xtd = GeoCalc.cross_track_distance(p, a, b)  # perpendicular offset in nm
foot = GeoCalc.closest_point_on_segment(p, a, b)
# foot: {"lon", "lat", "inline"} — projection onto segment
```

### Remaining route from current position

```python
from georoutelib import RoutePoint, GeoRoute

current = RoutePoint(lon=104.1, lat=1.35)
remaining = route.remaining_route(current)
# remaining: GeoRoute-style segments from current position to destination
```

### Reverse a route

```python
reversed_line = line.reverse()  # reverses in place + returns self
```

### Append / prepend / remove points

All modify in place and return the instance:

```python
from georoutelib import RoutePoint, RouteLine, GeoRoute

# Add points
line = line.append_point(RoutePoint(lon=3.0, lat=0.5))
route = route.append_point(RoutePoint(lon=106.0, lat=2.5))
route = route.prepend_point(RoutePoint(lon=102.0, lat=0.8))

# Remove by value
line = line.remove_point(target_point, precision=6)
route = route.remove_point(target_point)

# Merge (project onto nearest segment)
route = route.merge_point(target_point)
route = route.merge_points([p1, p2, p3])
```

### Round coordinates

```python
from georoutelib import round_lon, round_lat

rounded_lon = round_lon(120.123456789, precision=6)  # 120.123457
rounded_lat = round_lat(35.987654321, precision=6)   # 35.987654
```

## ECA (Emission Control Area)

```python
from georoutelib import ECAProcessor

# Load from URL (auto-cached for 30 minutes)
processor = ECAProcessor("https://example.com/eca-zones.geojson")

# Or load offline / test with raw GeoJSON dict
processor = ECAProcessor.from_geojson(url="test", data=my_geojson_dict)

# Calculate ECA intersects
results = processor.calculate_eca_intersects([
    [120.0, 30.0],
    [122.0, 31.0],
])
for item in results:
    print(f"ECA '{item['eca']}': {item['distance']:.1f} nm")
    for wp in item["waypoints"]:
        print(f"  WP ({wp.lon:.2f}, {wp.lat:.2f})")

# Clear HTTP cache (e.g., between tests)
ECAProcessor.clear_cache()
```

Accepts `GeoRoute`, `list[RoutePoint]`, or raw coordinate segments as input.
Optional `geojson_loader` property overrides HTTP fetch (useful for testing):

```python
processor.geojson_loader = lambda: load_my_geojson()
```

## Logging

The library uses [`log`](https://docs.rs/log) + [`env_logger`](https://docs.rs/env_logger) internally.
Logging is initialized automatically on module import (first import wins; subsequent calls are no-ops).

Control log level via environment variable:

```bash
# Default: warn (only warnings and errors)
RUST_LOG=warn python my_script.py

# Debug everything (module loading, HTTP requests, cache hits/misses, dateline splits)
RUST_LOG=debug python my_script.py

# Silence entirely
RUST_LOG=off python my_script.py
```

Log output goes to stderr by default. In Python you can also redirect:

```python
import logging
import os
os.environ["RUST_LOG"] = "info"
import georoutelib  # logging init happens here
```

## Constants exported to Python

| Constant | Value | Description |
|----------|-------|-------------|
| `NM_TO_KM` | `1.852` | Nautical mile → kilometer conversion |
| `EPS_ZERO` | `~1e-9` | Floating-point equality tolerance |
| `DATELINE_EPS` | `~0.01` | Longitude tolerance for dateline detection (~180°) |

## Native dependencies

| Crate | Role |
|-------|------|
| `pyo3` 0.22 | Python bindings / extension module |
| `geo` 0.29 | Rhumb / haversine measures; ECA polygon predicates |
| `geojson` 0.24 | Parse ECA FeatureCollection |
| `geographiclib-rs` 0.2 | High-precision geodesic calculations |
| `serde_json` 1 | JSON serialization |
| `ureq` 2.10 | Minimal HTTP client for ECA GeoJSON fetch |
| `log` 0.4 | Logging facade |
| `env_logger` 0.11 | Log output initialization |

## License

MIT — see [LICENSE](LICENSE).

