diff --git a/README.ko.md b/README.ko.md index 91a6eff..72c537e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -80,7 +80,7 @@ path = planner.route(start, goal) |---|---| | 수상 차량 로컬 수심 여유 | 1.5 m | | 수상 차량 반경 + 추적 여유 | 3.5 m + 4 m | -| 해안 선호 거리 | 350 m | +| 해안 선호 거리 | 400 m | | 해안 추종 중 육지와의 최대 거리 | 1.5 km | | 해안 선호에 따른 추가 거리 한도 | 100% (전체 거리 2.0배) | | 해안 지역 여유 폭 / 수심 | 40 m / 2 m | @@ -126,11 +126,16 @@ path = planner.route(start, goal) `state['regional_waiting']`이 있으면 검증된 다음 경로가 준비될 때까지 추진을 멈춥니다. 물리 계산과 센서 수신은 계속합니다. 선택적인 해안 경로 준비에 실패하거나 결과가 비어 있어도 검증된 해안 경로를 외해 목표로 바꾸지 않습니다. +계획 감시는 제한 시간 안의 작업을 유지하고 완료된 결과부터 반영합니다. +경로 준비 지연만으로 차량의 물리적 후퇴를 요청하지 않습니다. 기본 지형 자료 서버에 연결할 수 없으면 더 작은 구역으로 경로를 계산합니다. 검증된 자료를 사용하며 수심과 육지 확인 기준은 같습니다. -- 일반 구역: 20 km × 20 km +- 해안에서 1.5 km 이내인 구역: 6 km × 6 km부터 검사 +- 정밀 구역에서 안전한 연결 경로를 찾지 못하면: 20 km × 20 km로 확장 +- 일반 지역 검사 범위: 20 km × 20 km +- 전체 백그라운드 경로 준비 제한 시간: 660초 - 자료 서버 장애 시 재시도 구역: 6 km × 6 km - 두 구역 모두 자료 부족: 추진을 멈추고 재시도 diff --git a/README.md b/README.md index 176f37d..7273331 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ 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 | 350 m | +| Coastal preferred distance | 400 m | | Maximum coastal-following distance from land | 1.5 km | | Maximum extra distance for coastal preference | 100% (2.0× total length) | | Coastal regional buffer / depth | 40 m / 2 m | @@ -126,12 +126,17 @@ Pass the final port as `progress_target` when the local target is a course waypo 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 -with an offshore goal. +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. When the baseline terrain provider is unavailable, regional planning tries a smaller region. It uses verified source data and the same depth and land checks. -- Normal region: 20 km × 20 km +- Near-shore region, within 1.5 km of shore: 6 km × 6 km first +- Wider region when the fine grid has no checked connection: 20 km × 20 km +- Ordinary regional coverage: 20 km × 20 km +- Total background route preparation budget: 660 seconds - Source outage retry: 6 km × 6 km - Missing data in both regions: hold thrust and retry diff --git a/pyproject.toml b/pyproject.toml index a01f3fb..b57b4d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "posim-routing" -version = "0.1.4" +version = "0.1.5" 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 7770dfb..f498acd 100644 --- a/src/posim_routing/coastal.py +++ b/src/posim_routing/coastal.py @@ -20,7 +20,7 @@ GEO = Geod(ellps="WGS84") COAST_DISCOVERY_M = 80000. -STANDOFF_M = 350. +STANDOFF_M = 400. MAXIMUM_STANDOFF_M = 600. MINIMUM_DEPTH_M = 2. REGIONAL_BUFFER_M = 40. diff --git a/src/posim_routing/regional.py b/src/posim_routing/regional.py index 7ee515a..a3a0e12 100644 --- a/src/posim_routing/regional.py +++ b/src/posim_routing/regional.py @@ -6,7 +6,9 @@ import asyncio,math,time from pyproj import Geod GEO=Geod(ellps='WGS84') -REGIONAL_POLICY='buffered-water-v18-coastal-350m' +REGIONAL_POLICY='buffered-water-v19-coastal-400m' +COMPATIBLE_POLICIES={REGIONAL_POLICY,'buffered-water-v18-coastal-350m'} +PLANNING_BUDGET_SECONDS=660. REGIONAL_BUFFER_M=40. REGIONAL_DEPTH_M=2. @@ -61,9 +63,23 @@ def __init__(self,rpc,coastal=False,before_prepare=None): self.rpc=rpc;self.task=None;self.key=None;self.retry=0 self.coastal=coastal;self.coast_index=None;self.index_task=None;self.probed=None self.progress=None;self.coastal_target=None - self.recovery_extent=None - def recover_wait(self,state): - """Retry stalled planning at another resolution without issuing movement.""" + self.recovery_extent=None;self.task_started=None + def start_task(self,work): + # The controller watchdog must not cancel terrain preparation before + # its own bounded provider deadline. One budget includes all retries. + self.task_started=time.monotonic() + work_task=asyncio.create_task(work) + 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) + def recover_wait(self,state,preserve_pending=False): + """Retry expired work; keep bounded pending or completed work consumable.""" + if preserve_pending and self.task is not None: + elapsed=time.monotonic()-self.task_started if self.task_started is not None else PLANNING_BUDGET_SECONDS + if self.task.done() or elapsed=3000) prefer_coast=(point is not None and coast_needed and not needed and (state.get('regional_navigation') or {}).get('mode') not in ('coastal','coastal_transfer','coastal_approach')) @@ -236,7 +252,7 @@ def update(self,state,target,needed=False,terminal=False,retain_coast=False,prog # 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') - and plan.get('policy')==REGIONAL_POLICY) + and plan.get('policy') in COMPATIBLE_POLICIES) preserve_empty=(not value['points'] and (point is not None or (coast_plan and nearest is not None and not terminal and not needed))) # An optional scenic renewal cannot replace a checked coastal @@ -264,11 +280,11 @@ 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.task=asyncio.create_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)) + 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)) self.recovery_extent=None if point is not None:return point plan=state.get('regional_navigation') or {} - if (plan.get('route_key')==key and plan.get('policy')==REGIONAL_POLICY + if (plan.get('route_key')==key and plan.get('policy') in COMPATIBLE_POLICIES and (plan.get('mode') in ('coastal','coastal_transfer','coastal_approach') or plan.get('mapped_guidance')) and plan.get('points') and plan['index']>=len(plan['points']) and distance(state['position'],plan['points'][-1])<1500 @@ -305,7 +321,7 @@ def departure_update(self,state,target,key): self.task=None if point is not None:return point if self.task is None and time.monotonic()>=self.retry: - self.task=asyncio.create_task(self.prepare_departure(state,target,key)) + self.start_task(self.prepare_departure(state,target,key)) state['regional_planning']=dict(status='background',mode='port_departure') state['regional_waiting']=dict(reason='resident_departure_corridor_pending') return state['position'] @@ -412,7 +428,7 @@ async def terrain_region(self, centre, extent): except Exception:break if (sources.get('baseline_status') or {}).get('status')!='unavailable':break raise RuntimeError('regional_terrain_unavailable') - async def prepare(self,state,target,key,nearest=None,needed=True,extent=None,progress_target=None,coastal_target=None): + async def prepare(self,state,target,key,nearest=None,needed=True,extent=None,progress_target=None,coastal_target=None,fine_checked=False): from posim_terrain.canal_override import in_region canal=in_region(state['position']) if self.coastal and not canal and nearest is None and self.index_task is not None: @@ -433,7 +449,11 @@ async def prepare(self,state,target,key,nearest=None,needed=True,extent=None,pro # distant coast. The 1:10M discovery line never supplies depth. heading,_,_=GEO.inv(*pos,*nearest[1]) centre=list(GEO.fwd(*pos,heading,min(6000.,nearest[0]-6000.))[:2]) - extent=extent or (4000 if canal else 20000) + initial_extent=extent is None + # Fine coastal triangles first: a 20 km grid can close navigable water + # beside a headland and waste an entire provider/planner cycle. + extent=extent or (4000 if canal else 6000 if nearest and nearest[0]<1500. else 20000) + fine_checked=fine_checked or extent==6000 if self.before_prepare is not None:await self.before_prepare() job=await self.terrain_region(centre,extent) grid=await self.rpc('/terrain/jobs/'+job['id']+'/files/terrain.json',timeout=20) @@ -450,7 +470,7 @@ async def prepare(self,state,target,key,nearest=None,needed=True,extent=None,pro 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] water_fallback=value if value is not None and value.get('mode')=='detour' else None - if nearest and (value is None or water_fallback is not None) and extent>6000 and nearest[0]<1500.: + if nearest and not fine_checked and (value is None or water_fallback is not None) and extent>6000 and nearest[0]<1500.: # A coarse buffered pixel can disconnect otherwise navigable coastal # water. Resolve the current shoreline before accepting a water exit. return await self.prepare(state,course_target,key,nearest,needed,extent=6000, @@ -506,8 +526,11 @@ async def prepare(self,state,target,key,nearest=None,needed=True,extent=None,pro # Coarse regional pixels can close a harbour entrance. # Resolve it on a smaller real terrain grid before retrying; # never substitute an arbitrary offshore frontier. - if not canal and extent>6000 and str(error).startswith('regional_corridor_unavailable'): + if not canal and not fine_checked and extent>6000 and str(error).startswith('regional_corridor_unavailable'): return await self.prepare(state,course_target,key,nearest,needed,extent=6000,progress_target=progress_target,coastal_target=coastal_target) + if not canal and initial_extent and extent==6000: + return await self.prepare(state,course_target,key,nearest,needed,extent=20000, + progress_target=progress_target,coastal_target=coastal_target,fine_checked=True) raise if value['points']:value['mode']='detour' if canal and value['points']:value.update(mode='canal',assumed_depth_m=10,measured_bathymetry=False) diff --git a/tests/test_coastal_navigation.py b/tests/test_coastal_navigation.py index 457aa3d..6e307ea 100644 --- a/tests/test_coastal_navigation.py +++ b/tests/test_coastal_navigation.py @@ -118,18 +118,18 @@ def test_close_safe_start_is_not_rejected_by_an_extra_grid_ring(self): self.assertLess(plan['endpoint_standoff_m'], STANDOFF_M+100.) self.assert_swept_safe(terrain, start, plan) - def test_extra_hundred_metres_moves_preference_without_an_offshore_exit(self): + def test_400m_preference_moves_checked_route_without_an_offshore_exit(self): from unittest.mock import patch terrain = grid() start, target = geo(800, -4000), geo(800, 9000) - with patch('posim_routing.coastal.STANDOFF_M', 250.): + with patch('posim_routing.coastal.STANDOFF_M', 350.): 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'], 100.) + self.assertEqual(after['preferred_standoff_m']-before['preferred_standoff_m'], 50.) shift = after['endpoint_standoff_m']-before['endpoint_standoff_m'] - self.assertGreaterEqual(shift, 50.) - self.assertLessEqual(shift, 150.) + self.assertGreaterEqual(shift, 0.) + self.assertLessEqual(shift, 100.) self.assertEqual(after['maximum_coastal_distance_m'], 1500.) self.assert_coastal_band(terrain, start, after) self.assert_swept_safe(terrain, start, after) @@ -298,6 +298,74 @@ def nearest(self,position):return 800.,geo(0,0) self.assertEqual(navigator.recover_wait(sample)['extent_m'],40000.) await navigator.close() + async def test_wait_watch_preserves_pending_work_until_its_own_deadline(self): + from unittest.mock import patch + from posim_routing.regional import PLANNING_BUDGET_SECONDS + gate=asyncio.Event() + async def prepare():await gate.wait();return {'points': [[1,2]]} + navigator=RegionalNavigator(None);sample=dict(regional_planning=dict(status='background')) + navigator.start_task(prepare());task=navigator.task + await asyncio.sleep(0) + with patch('posim_routing.regional.time.monotonic',return_value=navigator.task_started+120): + self.assertEqual(navigator.recover_wait(sample,preserve_pending=True),dict(pending=True)) + self.assertIs(navigator.task,task);self.assertFalse(task.cancelled()) + self.assertNotIn('navigation_failsafe',sample) + gate.set();await task + self.assertEqual(navigator.recover_wait(sample,preserve_pending=True),dict(pending=True)) + self.assertEqual(task.result()['points'],[[1,2]]) + await navigator.close() + + async def test_wait_watch_replaces_work_that_exceeds_total_budget(self): + from unittest.mock import patch + from posim_routing.regional import PLANNING_BUDGET_SECONDS + async def prepare():await asyncio.Event().wait() + navigator=RegionalNavigator(None);sample={} + navigator.start_task(prepare());task=navigator.task;await asyncio.sleep(0) + with patch('posim_routing.regional.time.monotonic',return_value=navigator.task_started+PLANNING_BUDGET_SECONDS+1): + value=navigator.recover_wait(sample,preserve_pending=True) + await asyncio.gather(task,return_exceptions=True) + self.assertTrue(task.cancelled());self.assertEqual(value['attempt'],1) + self.assertEqual(value['extent_m'],6000.) + await navigator.close() + + async def test_near_shore_uses_fine_grid_first_and_grows_only_once(self): + from unittest.mock import patch + widths=[] + async def rpc(path,body=None,timeout=10): + if body: + widths.append(body['width_m']) + return dict(id='fixture',status='ready',spec=SPEC,manifest=dict(source='fixture')) + return grid() + navigator=RegionalNavigator(rpc,coastal=True) + start=geo(800,0);target=geo(800,90000) + checked=dict(points=[geo(800,5000)],index=0,mode='coastal') + with patch('posim_routing.coastal.coastal_corridor',return_value=checked): + await navigator.prepare(dict(position=start),target,[0,0,1],(800.,geo(0,0))) + self.assertEqual(widths,[6000]) + widths.clear() + with patch('posim_routing.coastal.coastal_corridor',return_value=None), \ + patch('posim_routing.coastal.coastal_approach',return_value=None), \ + patch('posim_routing.land_guidance.detour',return_value=[]), \ + patch('posim_routing.regional.corridor',side_effect=RuntimeError('regional_corridor_unavailable')): + with self.assertRaisesRegex(RuntimeError,'regional_corridor_unavailable'): + await navigator.prepare(dict(position=start),target,[0,0,1],(800.,geo(0,0))) + self.assertEqual(widths,[6000,20000]) + await navigator.close() + + async def test_preference_migration_keeps_checked_runway_during_renewal(self): + from unittest.mock import patch + async def rpc(*args,**kwargs):await asyncio.Event().wait() + class Near: + def nearest(self,position):return 400.,geo(0,0) + key=[0,0,1];start=geo(400,0);next_point=geo(400,2000) + sample=dict(lap=0,leg=0,waypoint=1,position=start,native={}, + 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) + await asyncio.sleep(0);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: