From 328344c752135aae73f0086a14b23ff93e3c410e Mon Sep 17 00:00:00 2001 From: ioes Date: Tue, 13 Oct 2026 18:37:34 +0900 Subject: [PATCH 1/2] Continue checked runway and exclude shallow water by 200 metres --- README.ko.md | 13 ++++-- README.md | 14 +++++-- pyproject.toml | 2 +- src/posim_routing/coastal.py | 8 ++-- src/posim_routing/regional.py | 62 +++++++++++++++++++++++++---- src/posim_routing/shallow.py | 68 ++++++++++++++++++++++++++++++++ tests/test_coastal_navigation.py | 57 ++++++++++++++++++++------ tests/test_shallow.py | 34 ++++++++++++++++ 8 files changed, 227 insertions(+), 31 deletions(-) create mode 100644 src/posim_routing/shallow.py create mode 100644 tests/test_shallow.py diff --git a/README.ko.md b/README.ko.md index 72c537e..d8c1202 100644 --- a/README.ko.md +++ b/README.ko.md @@ -69,6 +69,10 @@ path = planner.route(start, goal) 후속 경유점은 검증된 구간의 끝을 표시합니다. 차량이 지나가는 전체 폭의 수심과 지도상 육지를 확인한 뒤 직선 구간의 중간 지점을 줄입니다. +육지와 수심 2 m 미만인 수역을 해안 경계로 보고 200 m를 띄웁니다. +이미 이 범위 안에 있는 차량은 선체 안전을 검사한 경로로 간격을 늘리며 빠져나옵니다. +네이티브 지형에서 실제 로컬 경로의 여유 거리를 다시 확인한 뒤 추진을 승인합니다. + 해안에 합류한 뒤에는 육지에서 1.5 km 이내인 구간을 따릅니다. 서로 떨어진 해안 구간 사이에서는 전체 수상 연결 경로를 검사합니다. 해안 거리 한도는 다음 해안에 합류한 뒤 적용합니다. 먼바다 출구는 연결 검증에 사용하며, 별도 경로를 검사한 뒤 이동합니다. @@ -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 | @@ -124,7 +128,8 @@ path = planner.route(start, goal) 같은 구간에서 일반 경유점을 통과할 때만 `retain_coast=True`를 사용합니다. 로컬 목표가 항로 경유점이면 최종 항구를 `progress_target`으로 전달합니다. `state['regional_waiting']`이 있으면 검증된 다음 경로가 준비될 때까지 추진을 -멈춥니다. 물리 계산과 센서 수신은 계속합니다. 선택적인 해안 경로 준비에 실패하거나 +멈춥니다. 물리 계산과 센서 수신은 계속합니다. 갱신 경로는 검증된 기존 경로의 +끝에서 시작하며, 남아 있는 전진 경유점을 유지합니다. 선택적인 해안 경로 준비에 실패하거나 결과가 비어 있어도 검증된 해안 경로를 외해 목표로 바꾸지 않습니다. 계획 감시는 제한 시간 안의 작업을 유지하고 완료된 결과부터 반영합니다. 경로 준비 지연만으로 차량의 물리적 후퇴를 요청하지 않습니다. diff --git a/README.md b/README.md index 7273331..e7d29a1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | @@ -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. diff --git a/pyproject.toml b/pyproject.toml index b57b4d1..1ad7cf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/src/posim_routing/coastal.py b/src/posim_routing/coastal.py index f498acd..70d2e6c 100644 --- a/src/posim_routing/coastal.py +++ b/src/posim_routing/coastal.py @@ -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. @@ -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(): @@ -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]), diff --git a/src/posim_routing/regional.py b/src/posim_routing/regional.py index a3a0e12..e4b7848 100644 --- a/src/posim_routing/regional.py +++ b/src/posim_routing/regional.py @@ -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] @@ -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) @@ -110,7 +111,7 @@ def active_point(self,state,key): from .course import passed_vertex while 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)) @@ -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') @@ -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 {} @@ -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 separationMAXIMUM_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 @@ -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] @@ -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'): diff --git a/src/posim_routing/shallow.py b/src/posim_routing/shallow.py new file mode 100644 index 0000000..283ccdf --- /dev/null +++ b/src/posim_routing/shallow.py @@ -0,0 +1,68 @@ +"""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;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') diff --git a/tests/test_coastal_navigation.py b/tests/test_coastal_navigation.py index 6e307ea..b787392 100644 --- a/tests/test_coastal_navigation.py +++ b/tests/test_coastal_navigation.py @@ -60,7 +60,7 @@ def assert_coastal_band(self, terrain, start, plan): def test_small_island_transfers_to_a_progressive_mainland_band(self): terrain=grid();x,y=np.meshgrid(terrain['xs'],terrain['ys']) terrain['heights']=np.where((x<=0)|((abs(x-5000)<250)&(abs(y)<250)),20.,-15.).tolist() - start=geo(5000,-500);target=geo(800,-9000) + start=geo(5000,-650);target=geo(800,-9000) plan=coastal_corridor(terrain,SPEC,start,target) self.assertEqual(plan['mode'],'coastal_transfer') self.assertTrue(plan['coastal_transfer_verified']) @@ -110,26 +110,26 @@ def test_stays_near_coast_and_progresses(self): self.assertLessEqual(plan['coastal_through_length_m'], plan['shortest_checked_length_m']*MAXIMUM_COASTAL_DETOUR_RATIO) self.assert_swept_safe(terrain, start, plan) - def test_close_safe_start_is_not_rejected_by_an_extra_grid_ring(self): + def test_start_outside_shallow_buffer_has_a_checked_coastal_connection(self): terrain = grid() - start = geo(250, -4000) + start = geo(500, -4000) plan = coastal_corridor(terrain, SPEC, start, geo(800, 9000)) self.assertIsNotNone(plan) self.assertLess(plan['endpoint_standoff_m'], STANDOFF_M+100.) self.assert_swept_safe(terrain, start, plan) - def test_400m_preference_moves_checked_route_without_an_offshore_exit(self): + def test_80m_closer_preference_keeps_checked_route_and_onward_progress(self): from unittest.mock import patch terrain = grid() start, target = geo(800, -4000), geo(800, 9000) - with patch('posim_routing.coastal.STANDOFF_M', 350.): + with patch('posim_routing.coastal.STANDOFF_M', 400.): before = coastal_corridor(terrain, SPEC, start, target) after = coastal_corridor(terrain, SPEC, start, target) self.assertEqual(after['mode'], 'coastal') - self.assertEqual(after['preferred_standoff_m']-before['preferred_standoff_m'], 50.) + self.assertEqual(after['preferred_standoff_m']-before['preferred_standoff_m'], -80.) shift = after['endpoint_standoff_m']-before['endpoint_standoff_m'] - self.assertGreaterEqual(shift, 0.) - self.assertLessEqual(shift, 100.) + self.assertGreaterEqual(shift, -160.) + self.assertLessEqual(shift, 0.) self.assertEqual(after['maximum_coastal_distance_m'], 1500.) self.assert_coastal_band(terrain, start, after) self.assert_swept_safe(terrain, start, after) @@ -194,7 +194,11 @@ def test_shallow_and_unknown_coast_is_not_a_destination(self): x = np.array(terrain['xs']) heights[:, (x > 0) & (x < 1700)] = -1. terrain['heights'] = heights.tolist() - self.assertIsNone(coastal_corridor(terrain, SPEC, geo(3000, -4000), geo(6500, 9000))) + plan=coastal_corridor(terrain, SPEC, geo(3000,-4000),geo(6500,9000)) + self.assertIsNotNone(plan) + self.assertEqual(plan['shallow_water_buffer_m'],200.) + self.assert_swept_safe(terrain,geo(3000,-4000),plan) + self.assertGreater(FORWARD.transform(*plan['points'][-1])[0],1900.) heights[:, (x > 0) & (x < 1700)] = np.nan terrain['heights'] = heights.tolist() self.assertIsNone(coastal_corridor(terrain, SPEC, geo(3000, -4000), geo(6500, 9000))) @@ -352,7 +356,7 @@ async def rpc(path,body=None,timeout=10): self.assertEqual(widths,[6000,20000]) await navigator.close() - async def test_preference_migration_keeps_checked_runway_during_renewal(self): + async def test_larger_shallow_buffer_requires_rechecking_old_runway(self): from unittest.mock import patch async def rpc(*args,**kwargs):await asyncio.Event().wait() class Near: @@ -362,10 +366,39 @@ def nearest(self,position):return 400.,geo(0,0) regional_navigation=dict(points=[next_point],index=0,mode='coastal',route_key=key, policy='buffered-water-v18-coastal-350m',job='checked',probe_position=start)) navigator=RegionalNavigator(rpc,coastal=True);navigator.coast_index=Near();navigator.key=key - self.assertEqual(navigator.update(sample,geo(400,90000)),next_point) - self.assertNotIn('regional_waiting',sample);self.assertIsNotNone(navigator.task) + self.assertEqual(navigator.update(sample,geo(400,90000)),start) + self.assertIn('regional_waiting',sample);self.assertIsNotNone(navigator.task) await asyncio.sleep(0);await navigator.close() + async def test_coastal_renewal_is_prepared_from_runway_end(self): + from unittest.mock import AsyncMock + navigator=RegionalNavigator(None,coastal=True) + start,end,new=geo(800,0),geo(800,3000),geo(800,5500) + sample=dict(position=start) + navigator.prepare=AsyncMock(return_value=dict(points=[new],index=0,mode='coastal')) + value=await navigator.prepare_continuation(sample,geo(800,90000),[0,0,1], + dict(points=[end],job='runway')) + self.assertEqual(navigator.prepare.call_args.args[0]['position'],end) + self.assertEqual(sample['position'],start) + self.assertEqual(value['continuation_of'],dict(job='runway',endpoint=end)) + + async def test_completed_renewal_retains_forward_bends_before_continuation(self): + from posim_routing.coastal import WAYPOINT_FORMAT + key=[0,0,1];start,first,end,new=geo(800,1000),geo(800,2000),geo(800,3000),geo(800,5500) + navigator=RegionalNavigator(None);navigator.key=key + sample=dict(lap=0,leg=0,waypoint=1,position=start,native={}, + regional_navigation=dict(points=[geo(800,0),first,end],index=1,mode='coastal', + route_key=key,policy=REGIONAL_POLICY,job='runway',probe_position=geo(800,0))) + async def result():return dict(points=[new],index=0,mode='coastal',route_key=key, + policy=REGIONAL_POLICY,job='renewal',probe_position=end,onward_verified=True, + waypoint_format=WAYPOINT_FORMAT,continuation_of=dict(job='runway',endpoint=end)) + navigator.task=asyncio.create_task(result());await navigator.task + self.assertEqual(navigator.update(sample,geo(800,90000)),first) + self.assertEqual(sample['regional_navigation']['points'],[first,end,new]) + self.assertEqual(sample['regional_navigation']['retained_checked_points'],2) + self.assertNotIn('regional_waiting',sample) + await navigator.close() + async def test_exhausted_coast_accepts_checked_replacement_water_route(self): async def rpc(*args,**kwargs):await asyncio.Event().wait() class Near: diff --git a/tests/test_shallow.py b/tests/test_shallow.py new file mode 100644 index 0000000..8d92a5a --- /dev/null +++ b/tests/test_shallow.py @@ -0,0 +1,34 @@ +"""A shallow shelf is a route boundary; pre-existing proximity exits outward.""" +import unittest +import numpy as np +from posim_routing.shallow import exit_corridor,clearance_field,increasing_clearance +from posim_routing.navigation import TerrainField,DetourPlanner +from posim_routing.planning import CachedTerrain +from posim_terrain.terrain import projection + +SPEC=dict(latitude=35.,longitude=129.,width_m=6000,height_m=6000,samples=257) +_,F,I=projection(SPEC) +def geo(x,y):return list(I.transform(x,y)) +def terrain(): + axis=np.linspace(-3000,3000,257);x,y=np.meshgrid(axis,axis) + return dict(xs=axis.tolist(),ys=axis.tolist(),heights=np.where(x<0,-1.,-15.).tolist()) + +class ShallowExit(unittest.TestCase): + def test_already_clear_position_needs_no_departure(self): + self.assertIsNone(exit_corridor(terrain(),SPEC,geo(400,0),geo(1000,2000))) + def test_inside_buffer_exits_under_propulsion_with_increasing_separation(self): + grid=terrain();start=geo(100,0) + value=exit_corridor(grid,SPEC,start,geo(1000,2000)) + self.assertEqual(value['mode'],'shallow_exit');self.assertEqual(value['shallow_water_buffer_m'],200.) + raw=TerrainField(grid['xs'],grid['ys'],grid['heights']);a=[*F.transform(*start),0.];b=[*F.transform(*value['points'][0]),0.] + planner=DetourPlanner(CachedTerrain(raw),a,True,maximum_radius=2000) + planner.radius=200.;planner.clearance=2. + self.assertTrue(planner.safe(b[:2],0.)) + planner.radius=40.;self.assertTrue(planner.segment(a,b)) + self.assertTrue(increasing_clearance(clearance_field(raw),[a,b])) + def test_departure_rejects_shallow_hull_and_a_return_towards_the_shelf(self): + grid=terrain();raw=TerrainField(grid['xs'],grid['ys'],grid['heights']) + with self.assertRaisesRegex(RuntimeError,'shallow_exit_hull_unsafe'): + exit_corridor(grid,SPEC,geo(-100,0),geo(1000,2000)) + self.assertFalse(increasing_clearance(clearance_field(raw),[[100,0,0],[300,0,0],[100,0,0]])) +if __name__=='__main__':unittest.main() From 4e90870a0c6fd16ae049f786b9e80b41f64254ca Mon Sep 17 00:00:00 2001 From: ioes Date: Tue, 13 Oct 2026 18:43:11 +0900 Subject: [PATCH 2/2] Reserve a fine-grid margin when exiting shallow boundaries --- src/posim_routing/shallow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/posim_routing/shallow.py b/src/posim_routing/shallow.py index 283ccdf..f8afe34 100644 --- a/src/posim_routing/shallow.py +++ b/src/posim_routing/shallow.py @@ -40,7 +40,8 @@ def exit_corridor(terrain,spec,start,target,guard=None): 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;planner.clearance=MINIMUM_DEPTH_M + 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.