Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ path = planner.route(start, goal)

후속 경유점은 검증된 구간의 끝을 표시합니다. 차량이 지나가는 전체 폭의
수심과 지도상 육지를 확인한 뒤 직선 구간의 중간 지점을 줄입니다.
육지와 수심 2 m 미만인 수역을 해안 경계로 보고 200 m를 띄웁니다.
이미 이 범위 안에 있는 차량은 선체 안전을 검사한 경로로 간격을 늘리며 빠져나옵니다.
네이티브 지형에서 실제 로컬 경로의 여유 거리를 다시 확인한 뒤 추진을 승인합니다.

해안에 합류한 뒤에는 육지에서 1.5 km 이내인 구간을 따릅니다.
서로 떨어진 해안 구간 사이에서는 전체 수상 연결 경로를 검사합니다. 해안 거리 한도는 다음 해안에 합류한 뒤 적용합니다.
먼바다 출구는 연결 검증에 사용하며, 별도 경로를 검사한 뒤 이동합니다.
Expand All @@ -80,10 +84,10 @@ path = planner.route(start, goal)
|---|---|
| 수상 차량 로컬 수심 여유 | 1.5 m |
| 수상 차량 반경 + 추적 여유 | 3.5 m + 4 m |
| 해안 선호 거리 | 400 m |
| 해안 추종 중 육지와의 최대 거리 | 1.5 km |
| 해안 선호 거리 | 320 m |
| 지역 해안 추종 중 육지·얕은 수역과의 최대 거리 | 1.5 km |
| 해안 선호에 따른 추가 거리 한도 | 100% (전체 거리 2.0배) |
| 해안 지역 여유 폭 / 수심 | 40 m / 2 m |
| 얕은 수역 제외 거리 / 경계 수심 | 200 m / 2 m |
| 도착 반경 | 300 m |
| 도착 목표 검사 | 반경 350 m / 수심 3 m |

Expand Down Expand Up @@ -124,7 +128,8 @@ path = planner.route(start, goal)
같은 구간에서 일반 경유점을 통과할 때만 `retain_coast=True`를 사용합니다.
로컬 목표가 항로 경유점이면 최종 항구를 `progress_target`으로 전달합니다.
`state['regional_waiting']`이 있으면 검증된 다음 경로가 준비될 때까지 추진을
멈춥니다. 물리 계산과 센서 수신은 계속합니다. 선택적인 해안 경로 준비에 실패하거나
멈춥니다. 물리 계산과 센서 수신은 계속합니다. 갱신 경로는 검증된 기존 경로의
끝에서 시작하며, 남아 있는 전진 경유점을 유지합니다. 선택적인 해안 경로 준비에 실패하거나
결과가 비어 있어도 검증된 해안 경로를 외해 목표로 바꾸지 않습니다.
계획 감시는 제한 시간 안의 작업을 유지하고 완료된 결과부터 반영합니다.
경로 준비 지연만으로 차량의 물리적 후퇴를 요청하지 않습니다.
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ selecting a local depth-checked goal. The detour can initially increase distance
to the course target. At its checked endpoint, the caller waits for the next
checked segment. Longitude wrapping supports the international date line.

Coastal lookahead treats land and water shallower than 2 m as shoreline boundaries.
Routes keep a 200 m exclusion around these boundaries. A vessel already inside
the buffer first follows a hull-checked outward exit with increasing clearance.
The native resident corridor rechecks the buffer before propulsion is approved.

Coastal lookahead favours shore only while progressing toward the destination
port. Its final stretch must approach both the course target and the port.
The full coastal route may add at most 100% to a shorter checked route to the
Expand All @@ -80,10 +85,10 @@ Optional renewals keep the current checked coastal runway until a coastal contin
|---|---|
| Surface local depth clearance | 1.5 m |
| Surface hull radius + tracking margin | 3.5 m + 4 m |
| Coastal preferred distance | 400 m |
| Maximum coastal-following distance from land | 1.5 km |
| Coastal preferred distance | 320 m |
| Maximum regional coastal-following distance from land or shallow water | 1.5 km |
| Maximum extra distance for coastal preference | 100% (2.0× total length) |
| Coastal regional buffer / depth | 40 m / 2 m |
| Shallow-water exclusion buffer / boundary depth | 200 m / 2 m |
| Arrival radius | 300 m |
| Arrival target screening | 350 m buffer / 3 m depth |

Expand Down Expand Up @@ -125,7 +130,8 @@ Set `retain_coast=True` only for an ordinary shape advance within the same leg.
Pass the final port as `progress_target` when the local target is a course waypoint.
When `state['regional_waiting']` is present, the caller holds thrust until a
checked replacement is ready. Physics and sensor sampling continue during the
wait. Failed or empty optional renewal cannot replace a checked coastal route
wait. Renewal starts at the checked runway endpoint and retains its remaining
forward bends. Failed or empty optional renewal cannot replace a checked coastal route
with an offshore goal. A planning watchdog preserves pending work within its
budget and consumes completed results before retrying. Planning delays do not
request a physical retreat.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "posim-routing"
version = "0.1.5"
version = "0.1.6"
description = "Global maritime and terrain checked coastal route planning"
requires-python = ">=3.10"
license = {text = "Apache-2.0"}
Expand Down
8 changes: 5 additions & 3 deletions src/posim_routing/coastal.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@

GEO = Geod(ellps="WGS84")
COAST_DISCOVERY_M = 80000.
STANDOFF_M = 400.
STANDOFF_M = 320.
MAXIMUM_STANDOFF_M = 600.
MINIMUM_DEPTH_M = 2.
REGIONAL_BUFFER_M = 40.
REGIONAL_BUFFER_M = 200.
WAYPOINT_FORMAT = "checked-segment-ends-v5-coastal-transfer"
MAXIMUM_COASTAL_DETOUR_RATIO = 2.0
MAXIMUM_COASTAL_DISTANCE_M = 1500.
Expand Down Expand Up @@ -139,7 +139,7 @@ def coastal_corridor(terrain, spec, start, target, timeout=25., guard=None, prog
field = TerrainField(terrain["xs"], terrain["ys"], terrain["heights"])
xs, ys, heights = field.xs, field.ys, field.heights
finite = np.isfinite(heights)
land = finite & (heights >= 0)
land = finite & (heights > -MINIMUM_DEPTH_M)
if guard is not None:
land |= ~guard.grid(xs, ys)
if not land.any():
Expand Down Expand Up @@ -423,6 +423,8 @@ def route_length(points):
index=0, mode="coastal_transfer" if transfer_required else "coastal", planning_seconds=time.monotonic()-started,
search_nodes=expanded, minimum_regional_clearance_m=validator.minimum,
preferred_standoff_m=STANDOFF_M,
shallow_water_buffer_m=REGIONAL_BUFFER_M,
shallow_boundary_depth_m=MINIMUM_DEPTH_M,
endpoint_standoff_m=float(coast_distance[endpoint]),
maximum_preferred_standoff_m=MAXIMUM_STANDOFF_M,
progress_to_course_waypoint_m=float(progress[endpoint]),
Expand Down
62 changes: 55 additions & 7 deletions src/posim_routing/regional.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import asyncio,math,time
from pyproj import Geod
GEO=Geod(ellps='WGS84')
REGIONAL_POLICY='buffered-water-v19-coastal-400m'
COMPATIBLE_POLICIES={REGIONAL_POLICY,'buffered-water-v18-coastal-350m'}
REGIONAL_POLICY='buffered-water-v21-shallow-200m-coastal-320m'
COMPATIBLE_POLICIES={REGIONAL_POLICY}
PLANNING_BUDGET_SECONDS=660.
REGIONAL_BUFFER_M=40.
REGIONAL_BUFFER_M=200.
REGIONAL_DEPTH_M=2.

def distance(a,b):return GEO.inv(*a,*b)[2]
Expand Down Expand Up @@ -69,6 +69,7 @@ def start_task(self,work):
# its own bounded provider deadline. One budget includes all retries.
self.task_started=time.monotonic()
work_task=asyncio.create_task(work)
work_task.add_done_callback(lambda task: task.exception() if not task.cancelled() else None)
self.task=asyncio.create_task(asyncio.wait_for(work_task,PLANNING_BUDGET_SECONDS))
# Cancellation before the wrapper starts must still close its work.
self.task.add_done_callback(lambda _: work_task.cancel() if not work_task.done() else None)
Expand Down Expand Up @@ -110,7 +111,7 @@ def active_point(self,state,key):
from .course import passed_vertex
while plan['index']<len(plan['points']):
index=plan['index'];point=plan['points'][index]
reached=distance(state['position'],point)<(5 if plan.get('mode')=='port_departure' else 12 if plan.get('mode')=='port_approach' else 80)
reached=distance(state['position'],point)<(5 if plan.get('mode') in ('port_departure','shallow_exit') else 12 if plan.get('mode')=='port_approach' else 80)
previous=plan['points'][index-1] if index else plan.get('probe_position')
passed=(plan.get('mode') in ('coastal','coastal_transfer','coastal_approach') and previous is not None
and index<len(plan['points'])-1 and passed_vertex(previous,point,plan['points'][index+1],
Expand Down Expand Up @@ -220,7 +221,7 @@ def update(self,state,target,needed=False,terminal=False,retain_coast=False,prog
# cannot fall back to the original straight line across that land.
guide_exhausted=(plan.get('route_key')==key and plan.get('mapped_guidance')
and plan.get('points') and plan['index']>=len(plan['points']))
needed=needed or bool(guide_exhausted)
needed=needed or bool(guide_exhausted) or (plan.get('mode')=='shallow_exit' and plan.get('points') and plan['index']>=len(plan['points']))
approach_runway=(plan.get('mode')=='port_approach_lookahead' and point is not None
and distance(state['position'],plan['probe_position'])<500.)
prefer_port=terminal and (point is None or (plan.get('mode')!='port_approach' and not approach_runway))
Expand All @@ -245,10 +246,28 @@ def update(self,state,target,needed=False,terminal=False,retain_coast=False,prog
# local segment did not itself need a detour or coast discovery.
expired=state.get('regional_planning') or {}
needed=needed or (expired.get('status')=='expired' and expired.get('reason') in
('regional_clearance_policy_changed','regional_goal_progress_stalled','suez_fine_grid','predicted_stopping_corridor','checked_route_wait_stalled'))
('regional_clearance_policy_changed','regional_goal_progress_stalled','suez_fine_grid','predicted_stopping_corridor','checked_route_wait_stalled','shallow_water_clearance'))
if self.task and self.task.done():
try:
value=self.task.result()
connection=value.pop('continuation_of',None)
if connection:
# Renew from the checked runway's END, retaining the
# remaining forward bends. Replanning from the moving hull
# can choose an already passed bay as a new coast entry.
if (plan.get('job')!=connection['job'] or plan.get('route_key')!=key
or connection['endpoint'] not in plan.get('points',[])):
raise RuntimeError('coastal_runway_changed')
end=plan['points'].index(connection['endpoint'])
if plan['index']>end:raise RuntimeError('coastal_runway_already_passed')
retained=plan['points'][plan['index']:end+1]
value['points']=retained+value['points'];value['index']=0
value['retained_checked_points']=len(retained)
value['renewed_from']=connection
value['probe_position']=list(state['position'])
for metric in ('minimum_regional_clearance_m',):
if plan.get(metric) is not None and value.get(metric) is not None:
value[metric]=min(plan[metric],value[metric])
# A failed optional preference must preserve a previously
# checked detour while waiting for a better coastal region.
coast_plan=(plan.get('route_key')==key and plan.get('mode') in ('coastal','coastal_transfer','coastal_approach')
Expand Down Expand Up @@ -280,7 +299,13 @@ def update(self,state,target,needed=False,terminal=False,retain_coast=False,prog
coast_needed=nearest is not None and (self.probed is None or distance(self.probed,state['position'])>=3000)
if (prefer_port or needed or coast_needed or renew_coast) and not self.task and time.monotonic()>=self.retry:
state['regional_planning']=dict(status='background',mode='port_approach' if terminal else 'coastal' if coast_needed else 'detour')
self.start_task(self.prepare_port(state,target,key) if terminal else self.prepare(state,target,key,nearest,needed,extent=self.recovery_extent,progress_target=progress_target,coastal_target=coastal_target))
if terminal:work=self.prepare_port(state,target,key)
elif renew_coast and point is not None:
work=self.prepare_continuation(state,target,key,plan,extent=self.recovery_extent,
progress_target=progress_target,coastal_target=coastal_target)
else:work=self.prepare(state,target,key,nearest,needed,extent=self.recovery_extent,
progress_target=progress_target,coastal_target=coastal_target)
self.start_task(work)
self.recovery_extent=None
if point is not None:return point
plan=state.get('regional_navigation') or {}
Expand Down Expand Up @@ -326,6 +351,23 @@ def departure_update(self,state,target,key):
state['regional_waiting']=dict(reason='resident_departure_corridor_pending')
return state['position']

async def prepare_continuation(self,state,target,key,plan,extent=None,progress_target=None,coastal_target=None):
endpoint=list(plan['points'][-1]);connection=dict(job=plan['job'],endpoint=endpoint)
sample=dict(state,position=endpoint)
nearest=None
if self.coast_index is not None:
from .coastal import COAST_DISCOVERY_M,MAXIMUM_COASTAL_DISTANCE_M
separation,shore=self.coast_index.nearest(endpoint)
if separation<COAST_DISCOVERY_M:
nearest=(separation,shore)
if separation>MAXIMUM_COASTAL_DISTANCE_M and hasattr(self.coast_index,'progressive_shore'):
nearest=self.coast_index.progressive_shore(endpoint,coastal_target or target)
value=await self.prepare(sample,target,key,nearest,True,extent=extent,
progress_target=progress_target,coastal_target=coastal_target)
if not value.get('points'):raise RuntimeError('coastal_continuation_unavailable')
value['continuation_of']=connection
return value

async def prepare_departure(self,state,target,key):
from .port_approach import corridor
from .land import guard_from_spec
Expand Down Expand Up @@ -466,6 +508,9 @@ async def prepare(self,state,target,key,nearest=None,needed=True,extent=None,pro
coastal_goal=coastal_target if nearest is not None and coastal_target is not None else target
value=None;coast_error=None
if not canal and (nearest or needed):
from .shallow import exit_corridor
value=await asyncio.to_thread(exit_corridor,grid,job['spec'],current,progress_target,guard=guard)
if value is None and not canal and (nearest or needed):
from .coastal import coastal_corridor
try:value=await asyncio.to_thread(coastal_corridor,grid,job['spec'],current,coastal_goal,guard=guard,progress_target=progress_target)
except RuntimeError as error:coast_error=str(error)[:160]
Expand Down Expand Up @@ -539,6 +584,9 @@ async def prepare(self,state,target,key,nearest=None,needed=True,extent=None,pro
if not value['points']:value['coastal_preference_error']=coast_error or 'no_verified_progressive_coastal_corridor'
value.update(route_key=key,job=job['id'],source=job['manifest']['source'],policy=REGIONAL_POLICY,
mapped_land_checked=guard is not None)
if not canal and value['points']:
value.setdefault('shallow_water_buffer_m',REGIONAL_BUFFER_M)
value.setdefault('shallow_boundary_depth_m',REGIONAL_DEPTH_M)
value['probe_position']=current
value['destination_progress_target']=list(progress_target)
if coastal_target is not None and value.get('mode') in ('coastal','coastal_transfer','coastal_approach'):
Expand Down
69 changes: 69 additions & 0 deletions src/posim_routing/shallow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Checked outward migration when a hull is already inside a shallow buffer."""
import math
import numpy as np
from scipy.ndimage import distance_transform_edt
from scipy.interpolate import RegularGridInterpolator

MINIMUM_DEPTH_M=2.
SHALLOW_BUFFER_M=200.


def clearance_field(field):
dx,dy=float(np.max(np.diff(field.xs))),float(np.max(np.diff(field.ys)))
shallow=~np.isfinite(field.heights) | (field.heights > -MINIMUM_DEPTH_M)
distances=distance_transform_edt(~shallow,sampling=(dy,dx))
return RegularGridInterpolator((field.ys,field.xs),distances,
bounds_error=False,fill_value=0.)


def increasing_clearance(clearance,points):
"""An outward exception can only leave the existing shallow exclusion."""
previous=None
for a,b in zip(points,points[1:]):
count=max(2,math.ceil(math.dist(a[:2],b[:2])/10.)+1)
samples=np.linspace(a[:2],b[:2],count)
values=clearance(samples[:,::-1])
for value in values:
if previous is not None and value < previous-1.:return False
previous=float(value)
return True


def exit_corridor(terrain,spec,start,target,guard=None):
from posim_terrain.terrain import projection
from .navigation import TerrainField,DetourPlanner
from .planning import CachedTerrain
from .land import LandExcludedField
_,forward,inverse=projection(spec)
raw=TerrainField(terrain['xs'],terrain['ys'],terrain['heights'])
field=CachedTerrain(raw)
if guard is not None:field=LandExcludedField(field,guard)
origin=[*forward.transform(*start),0.];goal=np.array(forward.transform(*target))
planner=DetourPlanner(field,origin,True,maximum_radius=2000,maximum_length=3000)
planner.radius=SHALLOW_BUFFER_M+2*max(float(np.max(np.diff(raw.xs))),float(np.max(np.diff(raw.ys))))
planner.clearance=MINIMUM_DEPTH_M
if planner.safe(origin[:2],0.):return None
# Migration cannot teleport a vessel that was already within 200 m.
# Its bounded outward segment must remain hull-safe and increase separation.
planner.radius=40.
if not planner.safe(origin[:2],0.):raise RuntimeError('shallow_exit_hull_unsafe')
clearance=clearance_field(raw);bearing=math.atan2(goal[1]-origin[1],goal[0]-origin[0])
for travel in (100.,150.,200.,250.,350.,500.,750.,1000.):
candidates=[]
for angle in range(0,360,15):
theta=bearing+math.radians(angle)
end=[origin[0]+travel*math.cos(theta),origin[1]+travel*math.sin(theta),0.]
planner.radius=SHALLOW_BUFFER_M+2*max(float(np.max(np.diff(raw.xs))),float(np.max(np.diff(raw.ys))))
if not planner.safe(end[:2],0.):continue
planner.radius=40.
if not increasing_clearance(clearance,[origin,end]) or not planner.segment(origin,end):continue
progress=float(np.linalg.norm(goal-origin[:2])-np.linalg.norm(goal-end[:2]))
candidates.append((progress,end))
if candidates:
_,end=max(candidates,key=lambda value:value[0])
return dict(points=[list(inverse.transform(*end[:2]))],index=0,mode='shallow_exit',
shallow_water_buffer_m=SHALLOW_BUFFER_M,required_depth_m=MINIMUM_DEPTH_M,
escaping_shallow_buffer=True,maximum_exit_length_m=travel,
exit_start_clearance_m=float(clearance([[origin[1],origin[0]]])[0]),
scope='hull-checked propulsion exit with increasing shallow-water separation')
raise RuntimeError('shallow_exit_unavailable')
Loading