Skip to content

Commit 2ede794

Browse files
committed
Keep coastal routes on checked onward water corridors
1 parent e3f5bcf commit 2ede794

8 files changed

Lines changed: 235 additions & 48 deletions

File tree

‎README.ko.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ path = planner.route(start, goal)
7373
- 기본 육지 여유 거리: 80 m
7474
- 기본 탐색 제한 시간: 10초
7575

76+
해안 경로는 지역 격자 안의 다음 목표나 전방 출구까지 검증된 수역을 따라갑니다.
77+
경로 끝에서도 만과 곶을 지나 계속 갈 수 있어야 합니다. 일반 해안 경유점은 항로
78+
진행을 안내합니다. 항구와 필수 통과점은 실제 도착 위치를 검사합니다.
79+
7680
## 구조
7781

7882
| 경로 | 내용 |
@@ -90,6 +94,11 @@ path = planner.route(start, goal)
9094

9195
`RegionalNavigator`는 `/terrain/jobs`와 지형 작업 조회를 위한 비동기 `rpc(path, body=None, timeout=...)` 함수를 받습니다. 선택적인 `before_prepare` 함수로 호출 앱의 캐시를 관리합니다. Gazebo 실행·종료는 호출 앱이 담당합니다.
9296

97+
같은 구간에서 일반 경유점을 통과할 때만 `retain_coast=True`를 사용합니다.
98+
`state['regional_waiting']`이 있으면 검증된 다음 경로가 준비될 때까지 추진을
99+
멈춥니다. 물리 계산과 센서 수신은 계속합니다. 선택적인 해안 경로 준비에 실패하거나
100+
결과가 비어 있어도 검증된 해안 경로를 외해 목표로 바꾸지 않습니다.
101+
93102
## 시험과 이용 조건
94103

95104
```sh

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ path = planner.route(start, goal)
5050

5151
Choose start and goal inside confirmed water. The planner rejects unknown coverage and continuously checks the swept footprint against the piecewise planar collision field.
5252

53+
Coastal lookahead follows a checked water route to the onward target or a forward
54+
exit from the regional grid. Its endpoint has an onward route around bays and
55+
headlands. Ordinary coastal course vertices guide progress; ports and required
56+
passage points retain their arrival checks.
57+
5358
| Policy | Default |
5459
|---|---|
5560
| Surface local depth clearance | 1.5 m |
@@ -90,6 +95,12 @@ search retains the supplied route. Numeric terrain checks remain necessary.
9095

9196
`RegionalNavigator` accepts an async `rpc(path, body=None, timeout=...)` callback for `/terrain/jobs` and job reads. The optional `before_prepare` callback lets the caller manage its cache. It does not start or stop Gazebo.
9297

98+
Set `retain_coast=True` only for an ordinary shape advance within the same leg.
99+
When `state['regional_waiting']` is present, the caller holds thrust until a
100+
checked replacement is ready. Physics and sensor sampling continue during the
101+
wait. Failed or empty optional renewal cannot replace a checked coastal route
102+
with an offshore goal.
103+
93104
## Tests and terms
94105

95106
```sh

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "posim-routing"
7-
version = "0.1.1"
7+
version = "0.1.2"
88
description = "Global maritime and terrain checked coastal route planning"
99
requires-python = ">=3.10"
1010
license = {text = "Apache-2.0"}

‎src/posim_routing/coastal.py‎

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -151,29 +151,32 @@ def coastal_corridor(terrain, spec, start, target, timeout=25.):
151151
safe &= travel <= 18000
152152
to_target = np.hypot(xx-destination[0], yy-destination[1])
153153
progress = remaining-to_target
154-
# Never select a coast on the other side of an island solely for its view.
155-
candidates = safe & (coast_distance >= 200) & (coast_distance <= MAXIMUM_STANDOFF_M)
156-
candidates &= (travel >= 1000) & (travel <= 15000)
157-
candidates &= progress >= min(1000., remaining*.2)
158-
if not candidates.any():
154+
coast = safe & (coast_distance >= 200) & (coast_distance <= MAXIMUM_STANDOFF_M)
155+
coast &= (travel >= 1000) & (travel <= 15000)
156+
if not (coast & (progress >= min(1000., remaining*.2))).any():
159157
return None
160-
heading = (destination-origin)/remaining
161-
nominal = origin + heading*min(7500., remaining)
162-
# Rank stand-off bands before along-course convenience. Otherwise a
163-
# waypoint a few kilometres nearer the nominal route can win even when
164-
# an equally safe 250 m coastal corridor exists. One grid cell reflects
165-
# the shoreline resolution; it is not permission to relax hull clearance.
166-
band = np.floor(np.abs(coast_distance-STANDOFF_M)/max(dx, dy))
167-
score = band*100000. + np.hypot(xx-nominal[0], yy-nominal[1])
168-
scores = np.where(candidates, score, math.inf)
158+
# A near-coast endpoint can be the head of a closed bay. First establish
159+
# a through route to the onward target or a forward water exit, then take
160+
# its coastal prefix. Distance gain at an isolated endpoint is insufficient.
161+
goal_node = (int(np.argmin(abs(ys-destination[1]))), int(np.argmin(abs(xs-destination[0]))))
162+
target_inside = (xs[0]+margin < destination[0] < xs[-1]-margin
163+
and ys[0]+margin < destination[1] < ys[-1]-margin
164+
and safe[goal_node])
165+
edge = ((xx <= xs[0]+margin+2*dx) | (xx >= xs[-1]-margin-2*dx)
166+
| (yy <= ys[0]+margin+2*dy) | (yy >= ys[-1]-margin-2*dy))
167+
exits = safe & edge & (travel >= 1000) & (progress >= min(1000., remaining*.2))
168+
scores = np.where(exits, to_target, math.inf)
169169
goals = []
170-
for _ in range(6):
170+
if target_inside:
171+
goals.append(goal_node)
172+
for _ in range(0 if target_inside else 6):
171173
node = tuple(map(int, np.unravel_index(np.argmin(scores), scores.shape)))
172174
if not math.isfinite(float(scores[node])):
173175
break
174176
goals.append(node)
175177
scores[np.hypot(xx-xs[node[1]], yy-ys[node[0]]) < 600] = math.inf
176-
penalty = 1 + 8*np.minimum(6, np.maximum(0, coast_distance-(STANDOFF_M+150))/500)
178+
penalty = (1 + np.minimum(1,np.abs(coast_distance-STANDOFF_M)/STANDOFF_M)
179+
+ 8*np.minimum(6,np.maximum(0,coast_distance-(STANDOFF_M+150))/500))
177180
validator = DetourPlanner(CachedTerrain(field), [*origin, 0.], True,
178181
timeout=timeout, maximum_radius=18000, maximum_length=40000)
179182
validator.radius = REGIONAL_BUFFER_M
@@ -221,6 +224,23 @@ def xy(node):
221224
while nodes[-1] != start_node:
222225
nodes.append(parents[nodes[-1]])
223226
nodes.reverse()
227+
coastal_nodes = [index for index,node in enumerate(nodes) if coast[node]]
228+
if not coastal_nodes:
229+
continue
230+
if not any(progress[nodes[index]] >= min(1000., remaining*.2) for index in coastal_nodes):
231+
continue
232+
through = [[*origin, 0.], *[xy(node) for node in nodes[1:]]]
233+
if sum(math.dist(a, b) for a, b in zip(through, through[1:])) > 40000:
234+
continue
235+
if not all(validator.segment(a,b,record=True) for a,b in zip(through,through[1:])):
236+
continue
237+
bands = {index: math.floor(abs(coast_distance[nodes[index]]-STANDOFF_M)/max(dx,dy))
238+
for index in coastal_nodes}
239+
closest_band = min(bands.values())
240+
end = max(index for index in coastal_nodes if bands[index]==closest_band)
241+
onward_nodes = nodes[end+1:]
242+
endpoint = nodes[end]
243+
nodes = nodes[:end+1]
224244
# Shorten the grid path without erasing its coastal preference. Long
225245
# offshore shortcuts across bays have a higher coast-weighted cost.
226246
points, index = [[*origin, 0.]], 0
@@ -247,8 +267,12 @@ def xy(node):
247267
index=0, mode="coastal", planning_seconds=time.monotonic()-started,
248268
search_nodes=expanded, minimum_regional_clearance_m=validator.minimum,
249269
preferred_standoff_m=STANDOFF_M,
250-
endpoint_standoff_m=float(coast_distance[goal]),
270+
endpoint_standoff_m=float(coast_distance[endpoint]),
251271
maximum_preferred_standoff_m=MAXIMUM_STANDOFF_M,
252-
progress_to_course_waypoint_m=float(progress[goal]),
272+
progress_to_course_waypoint_m=float(progress[endpoint]),
273+
onward_points=[list(inverse.transform(*xy(node)[:2])) for node in onward_nodes],
274+
onward_exit=list(inverse.transform(*xy(goal)[:2])),
275+
onward_exit_progress_m=float(progress[goal]),
276+
onward_verified=True,
253277
scope="numeric terrain coastal preference; native corridors checked separately")
254278
return None

‎src/posim_routing/course.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
GEO = Geod(ellps='WGS84')
1010

1111

12-
def passed_vertex(previous, vertex, following, position, maximum_offset=80000., minimum_leg=2000., corner_margin=2000.):
12+
def passed_vertex(previous, vertex, following, position, maximum_offset=80000., minimum_leg=2000., corner_margin=2000., adjacent_limit=True):
1313
incoming_back, _, incoming = GEO.inv(*vertex, *previous)
1414
outgoing_bearing, _, outgoing = GEO.inv(*vertex, *following)
1515
bearing, _, separation = GEO.inv(*vertex, *position)
@@ -20,7 +20,8 @@ def passed_vertex(previous, vertex, following, position, maximum_offset=80000.,
2020
# that its vertex has been passed. Only round ordinary progressive bends.
2121
if math.cos(math.radians(outgoing_bearing-incoming_bearing)) < -.25:
2222
return False
23-
if separation > min(maximum_offset, min(incoming, outgoing)*.5):
23+
horizon = min(maximum_offset, min(incoming, outgoing)*.5) if adjacent_limit else maximum_offset
24+
if separation > horizon:
2425
return False
2526
along_in = separation*math.cos(math.radians(bearing-incoming_bearing))
2627
along_out = separation*math.cos(math.radians(bearing-outgoing_bearing))
@@ -48,7 +49,7 @@ def coastal_shape(leg, index, state):
4849
def passed_coastal_shape(leg, index, state):
4950
points=leg['coordinates']
5051
return coastal_shape(leg,index,state) and passed_vertex(
51-
points[index-1],points[index],points[index+1],state['position'])
52+
points[index-1],points[index],points[index+1],state['position'],adjacent_limit=False)
5253

5354

5455
def course_target(leg,index,state):
@@ -58,5 +59,9 @@ def course_target(leg,index,state):
5859
incoming_back,_,incoming=GEO.inv(*vertex,*points[index-1])
5960
bearing,_,outgoing=GEO.inv(*vertex,*points[index+1])
6061
if min(incoming,outgoing)<2000 or math.cos(math.radians(bearing-incoming_back-180.))<-.25:return vertex
61-
if GEO.inv(*state['position'],*vertex)[2]>min(80000.,min(incoming,outgoing)*.5):return vertex
62-
return list(GEO.fwd(*vertex,bearing,min(25000.,outgoing*.25))[:2])
62+
if GEO.inv(*state['position'],*vertex)[2]>80000.:return vertex
63+
# Coastal travel can pass well inside an offshore graph bend. A short
64+
# offset from that bend still pulls the vessel out to sea. Plan towards
65+
# the onward vertex; measured passage advances the shape index separately.
66+
# Neither this goal proposal nor passage records arrival at the final port.
67+
return list(points[index+1])

‎src/posim_routing/regional.py‎

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,12 @@ def active_point(self,state,key):
7878
state['position'],maximum_offset=200.,minimum_leg=0.,corner_margin=40.))
7979
if not reached and not passed:break
8080
plan['index']+=1
81-
if plan['index']>=len(plan['points']):return None
81+
if plan['index']>=len(plan['points']):
82+
# Only consume an explicitly validated finite onward route. This
83+
# supplies runway during renewal without extrapolating a tangent.
84+
if plan.get('onward_verified') and plan.get('onward_points'):
85+
plan['points'].extend(plan.pop('onward_points'));plan['onward_used']=True
86+
else:return None
8287
point=plan['points'][plan['index']]
8388
# A local detour may bypass the coarse frontier without entering its
8489
# 80 m arrival circle. Reusing that persisted goal hundreds of km
@@ -101,16 +106,26 @@ def active_point(self,state,key):
101106
self.discard(state,'regional_goal_progress_stalled')
102107
return None
103108
return point
104-
def update(self,state,target,needed=False,terminal=False):
109+
def update(self,state,target,needed=False,terminal=False,retain_coast=False):
105110
from posim_terrain.canal_override import in_region
106111
canal=in_region(state['position'])
107112
key=[state['lap'],state['leg'],state['waypoint']]
108113
from .port_approach import APPROACH_DISTANCE_M
109114
terminal=terminal and distance(state['position'],target)<APPROACH_DISTANCE_M
115+
state.pop('regional_waiting',None)
110116
if self.key!=key:
111117
if self.task:self.task.cancel()
112118
self.task=None;self.key=key;self.retry=0
113119
self.probed=None
120+
plan=state.get('regional_navigation') or {};old=plan.get('route_key')
121+
if (retain_coast and not terminal and not canal and old and len(old)==3
122+
and old[:2]==key[:2] and old[2]<key[2]
123+
and plan.get('policy')==REGIONAL_POLICY
124+
and plan.get('mode') in ('coastal','coastal_approach')):
125+
plan['route_key']=list(key);plan.pop('continuation',None)
126+
state['regional_handoff']=dict(previous_key=old,route_key=list(key),
127+
position=list(state['position']),reason='measured_coastal_shape_advance')
128+
self.progress=None
114129
nearest=None
115130
if self.coastal and not canal:
116131
if self.index_task is None and self.coast_index is None:
@@ -145,6 +160,8 @@ def update(self,state,target,needed=False,terminal=False):
145160
and distance(state['position'],plan['points'][-1])<2500
146161
and distance(state['position'],plan.get('probe_position',state['position']))>=1000
147162
and (self.probed is None or distance(self.probed,state['position'])>=1000))
163+
renew_coast=renew_coast or (point is not None and nearest is not None
164+
and plan.get('mode')=='coastal' and not plan.get('onward_verified'))
148165
coast_needed=nearest is not None and (self.probed is None or distance(self.probed,state['position'])>=3000)
149166
prefer_coast=(point is not None and coast_needed and not needed and
150167
(state.get('regional_navigation') or {}).get('mode') not in ('coastal','coastal_approach'))
@@ -160,10 +177,17 @@ def update(self,state,target,needed=False,terminal=False):
160177
value=self.task.result()
161178
# A failed optional preference must preserve a previously
162179
# checked detour while waiting for a better coastal region.
163-
if not (point is not None and not value['points']):state['regional_navigation']=value
180+
coast_plan=(plan.get('route_key')==key and plan.get('mode') in ('coastal','coastal_approach')
181+
and plan.get('policy')==REGIONAL_POLICY)
182+
preserve_empty=(not value['points'] and (point is not None or
183+
(coast_plan and nearest is not None and not terminal and not needed)))
184+
if not preserve_empty:state['regional_navigation']=value
164185
self.probed=list(value['probe_position']) if value.get('mode') not in ('coastal','coastal_approach') else None
165186
state['regional_planning']=dict(status='ready' if value['points'] else 'course_retained',
166187
job=value['job'],mode=value.get('mode','detour'),coastal_preference_error=value.get('coastal_preference_error'))
188+
if preserve_empty and coast_plan:
189+
self.probed=None;self.retry=time.monotonic()+60
190+
state['regional_planning']['status']='retrying'
167191
except (Exception,asyncio.CancelledError) as e:
168192
state['regional_planning']=dict(status='retrying',reason=str(e)[:160]);self.retry=time.monotonic()+60
169193
self.task=None
@@ -179,18 +203,19 @@ def update(self,state,target,needed=False,terminal=False):
179203
state['regional_planning']=dict(status='background',mode='port_approach' if terminal else 'coastal' if coast_needed else 'detour')
180204
self.task=asyncio.create_task(self.prepare_port(state,target,key) if terminal else self.prepare(state,target,key,nearest,needed))
181205
if point is not None:return point
182-
if self.task and plan.get('route_key')==key and plan.get('mode') in ('coastal','coastal_approach'):
183-
points=plan.get('points',[])
184-
if len(points)>=2 and distance(state['position'],points[-1])<1500:
185-
continuation=plan.get('continuation')
186-
if continuation is None or distance(state['position'],continuation)<80:
187-
heading=GEO.inv(*points[-2],*points[-1])[0]
188-
continuation=list(GEO.fwd(*state['position'],heading,750.)[:2])
189-
plan['continuation']=continuation
190-
if distance(continuation,target)<distance(state['position'],target):
191-
# Only a goal proposal: the native resident-terrain planner
192-
# must approve it before thrust is commanded.
193-
return continuation
206+
plan=state.get('regional_navigation') or {}
207+
if (plan.get('route_key')==key and plan.get('policy')==REGIONAL_POLICY
208+
and plan.get('mode') in ('coastal','coastal_approach') and plan.get('points')
209+
and plan['index']>=len(plan['points'])
210+
and distance(state['position'],plan['points'][-1])<1500
211+
and (nearest is not None or self.task or self.index_task)):
212+
# An exhausted checked path is not permission to steer offshore.
213+
# The caller holds thrust, keeping physics and sensors running.
214+
# Retry backoff and optional empty results retain this boundary.
215+
plan.pop('continuation',None)
216+
state['regional_waiting']=dict(reason='coastal_renewal',job=plan['job'],
217+
endpoint=plan['points'][-1])
218+
return plan['points'][-1]
194219
return target
195220
async def prepare_port(self,state,target,key):
196221
# A port's narrow entrance is resolved by the same triangles already

0 commit comments

Comments
 (0)