From c2fabd26a19fd93de12327b58e0ebaea486023ad Mon Sep 17 00:00:00 2001 From: ioes Date: Fri, 9 Oct 2026 18:49:16 +0900 Subject: [PATCH 1/2] Shorten checked coastal onward waypoint runs --- README.ko.md | 8 ++++++ README.md | 4 +++ pyproject.toml | 2 +- src/posim_routing/__init__.py | 2 +- src/posim_routing/coastal.py | 47 ++++++++++++++++++++------------ tests/test_coastal_navigation.py | 11 ++++++++ tests/test_land_exclusion.py | 16 +++++++++++ 7 files changed, 70 insertions(+), 20 deletions(-) diff --git a/README.ko.md b/README.ko.md index 90e13d9..883dc5e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -61,6 +61,14 @@ path = planner.route(start, goal) 구간의 끝에서는 다음 구간이 준비될 때까지 기다립니다. 날짜 변경선에서도 경도가 이어지도록 처리합니다. +해안 경로는 다음 목표나 지역 지형의 출구까지 검증된 수역을 따라갑니다. +끝점에는 만과 곶을 돌아가는 후속 경로가 이어집니다. 일반 해안 경유점은 +진행 방향을 안내하며, 항구와 필수 통과점은 도착 조건을 유지합니다. + +후속 경유점은 검증된 구간의 끝을 표시합니다. 차량이 지나가는 전체 폭의 +수심과 지도상 육지를 확인한 뒤 직선 구간의 중간 지점을 줄입니다. +출구와 해안 선호 경로는 유지합니다. + | 기준 | 기본값 | |---|---| | 수상 차량 로컬 수심 여유 | 1.5 m | diff --git a/README.md b/README.md index 4796c12..8e1c5da 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,10 @@ exit from the regional grid. Its endpoint has an onward route around bays and headlands. Ordinary coastal course vertices guide progress; ports and required passage points retain their arrival checks. +Onward waypoints mark checked segment ends. Straight grid runs are shortened +when the complete swept corridor passes depth and mapped-land checks. The +shortened route preserves its exit and coastline preference. + | Policy | Default | |---|---| | Surface local depth clearance | 1.5 m | diff --git a/pyproject.toml b/pyproject.toml index 464d61e..a01f3fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "posim-routing" -version = "0.1.3" +version = "0.1.4" description = "Global maritime and terrain checked coastal route planning" requires-python = ">=3.10" license = {text = "Apache-2.0"} diff --git a/src/posim_routing/__init__.py b/src/posim_routing/__init__.py index 930ef3d..ef43253 100644 --- a/src/posim_routing/__init__.py +++ b/src/posim_routing/__init__.py @@ -1,2 +1,2 @@ """POSIM-ROUTING: reusable ocean simulation components.""" -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/src/posim_routing/coastal.py b/src/posim_routing/coastal.py index 36a0402..e143d2e 100644 --- a/src/posim_routing/coastal.py +++ b/src/posim_routing/coastal.py @@ -259,25 +259,36 @@ def xy(node): nodes = nodes[:end+1] # Shorten the grid path without erasing its coastal preference. Long # offshore shortcuts across bays have a higher coast-weighted cost. - points, index = [[*origin, 0.]], 0 - while index < len(nodes)-1: - chosen = index+1 - for end in range(min(len(nodes)-1, index+10), index, -1): - a, b = xy(nodes[index]), xy(nodes[end]) - length = math.dist(a, b) - count = max(2, math.ceil(length/min(dx, dy)*3)) - ix = np.rint(np.linspace(nodes[index][1], nodes[end][1], count)).astype(int) - iy = np.rint(np.linspace(nodes[index][0], nodes[end][0], count)).astype(int) - weighted = length*float(np.mean(penalty[iy, ix])) - old = costs[nodes[end]]-costs[nodes[index]] - if safe[iy, ix].all() and weighted <= old*1.08: - chosen = end - break - points.append(xy(nodes[chosen])) - index = chosen + def shorten(route, lookahead=None): + points, index = [xy(route[0])], 0 + while index < len(route)-1: + chosen = index+1 + last = min(len(route)-1, index+lookahead) if lookahead else len(route)-1 + for candidate in range(last, index, -1): + if time.monotonic() > deadline: + raise RuntimeError("coastal_planning_budget_exceeded") + a, b = xy(route[index]), xy(route[candidate]) + length = math.dist(a, b) + count = max(2, math.ceil(length/min(dx, dy)*3)) + ix = np.rint(np.linspace(route[index][1], route[candidate][1], count)).astype(int) + iy = np.rint(np.linspace(route[index][0], route[candidate][0], count)).astype(int) + weighted = length*float(np.mean(penalty[iy, ix])) + old = costs[route[candidate]]-costs[route[index]] + if (safe[iy, ix].all() and weighted <= old*1.08 + and validator.segment(a, b)): + chosen = candidate + break + points.append(xy(route[chosen])) + index = chosen + return points + + points = shorten(nodes, lookahead=10) + points[0] = [*origin, 0.] + onward = shorten([endpoint, *onward_nodes]) if sum(math.dist(a, b) for a, b in zip(points, points[1:])) > 40000: continue - if not all(validator.segment(a, b, record=True) for a, b in zip(points, points[1:])): + if not all(validator.segment(a, b, record=True) for route in (points, onward) + for a, b in zip(route, route[1:])): continue return dict(points=[list(inverse.transform(*point[:2])) for point in points[1:]], index=0, mode="coastal", planning_seconds=time.monotonic()-started, @@ -286,7 +297,7 @@ def xy(node): endpoint_standoff_m=float(coast_distance[endpoint]), maximum_preferred_standoff_m=MAXIMUM_STANDOFF_M, progress_to_course_waypoint_m=float(progress[endpoint]), - onward_points=[list(inverse.transform(*xy(node)[:2])) for node in onward_nodes], + onward_points=[list(inverse.transform(*point[:2])) for point in onward[1:]], onward_exit=list(inverse.transform(*xy(goal)[:2])), onward_exit_progress_m=float(progress[goal]), onward_verified=True, diff --git a/tests/test_coastal_navigation.py b/tests/test_coastal_navigation.py index 7584cdd..2b8c252 100644 --- a/tests/test_coastal_navigation.py +++ b/tests/test_coastal_navigation.py @@ -95,6 +95,17 @@ def test_closed_bay_requires_an_onward_water_exit(self): self.assertLess(end_y,-1000) self.assert_swept_safe(terrain,start,dict(points=plan['points']+plan['onward_points'])) + def test_long_onward_runs_use_segment_ends_and_keep_the_exit(self): + terrain = grid() + start = geo(800, -4000) + plan = coastal_corridor(terrain, SPEC, start, geo(6500, 9000)) + self.assertIsNotNone(plan) + self.assertGreater(distance(plan['points'][-1], plan['onward_exit']), 5000.) + self.assertGreater(len(plan['onward_points']), 0) + self.assertLessEqual(len(plan['onward_points']), 4) + np.testing.assert_allclose(plan['onward_points'][-1], plan['onward_exit'], atol=1e-10) + self.assert_swept_safe(terrain, start, dict(points=plan['points']+plan['onward_points'])) + def test_shallow_and_unknown_coast_is_not_a_destination(self): terrain = grid() heights = np.array(terrain['heights']) diff --git a/tests/test_land_exclusion.py b/tests/test_land_exclusion.py index 95c8f43..51cb0c9 100644 --- a/tests/test_land_exclusion.py +++ b/tests/test_land_exclusion.py @@ -62,6 +62,22 @@ def test_unknown_coverage_and_missing_dataset_are_rejected(self): with self.assertRaisesRegex(RuntimeError,'data_unavailable'): guard_from_spec(SPEC) + def test_shortened_onward_segments_keep_a_required_mapped_land_turn(self): + axis=np.linspace(-10000.,10000.,257);xx,yy=np.meshgrid(axis,axis) + grid=dict(xs=axis.tolist(),ys=axis.tolist(),heights=np.where(xx<=0,20.,-20.).tolist()) + # Both numeric terrain and unbuffered grid vertices miss this thin bank. + land=shapely.union_all([shapely.box(-11000,-11000,0,11000), + shapely.box(3000,8000,3002,9500)]) + guard=LandGuard(land,shapely.box(-11000,-11000,11000,11000)) + start=list(INVERSE.transform(800,-4000));target=list(INVERSE.transform(6500,9000)) + plan=coastal_corridor(grid,SPEC,start,target,guard=guard) + self.assertIsNotNone(plan) + onward=[[*FORWARD.transform(*p),0.] for p in [plan['points'][-1],*plan['onward_points']]] + self.assertFalse(guard.segment(onward[0],onward[-1],40.)) + self.assertGreater(len(plan['onward_points']),1) + self.assertTrue(all(guard.segment(a,b,40.) for a,b in zip(onward,onward[1:]))) + np.testing.assert_allclose(plan['onward_points'][-1],plan['onward_exit'],atol=1e-10) + def test_nearer_disconnected_exits_do_not_hide_reachable_water_exit(self): axis=np.linspace(-10000.,10000.,257) grid=dict(xs=axis.tolist(),ys=axis.tolist(),heights=np.full((257,257),-20.).tolist()) From 88b9f109c21e01667fb88d2c1fbd22694221292a Mon Sep 17 00:00:00 2001 From: ioes Date: Fri, 9 Oct 2026 18:52:47 +0900 Subject: [PATCH 2/2] Renew persisted coastal grid lookahead in the background --- src/posim_routing/coastal.py | 2 ++ src/posim_routing/regional.py | 4 +++- tests/test_coastal_navigation.py | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/posim_routing/coastal.py b/src/posim_routing/coastal.py index e143d2e..e9b449d 100644 --- a/src/posim_routing/coastal.py +++ b/src/posim_routing/coastal.py @@ -24,6 +24,7 @@ MAXIMUM_STANDOFF_M = 800. MINIMUM_DEPTH_M = 2. REGIONAL_BUFFER_M = 40. +WAYPOINT_FORMAT = "checked-segment-ends-v1" def sphere(coordinates): @@ -301,5 +302,6 @@ def shorten(route, lookahead=None): onward_exit=list(inverse.transform(*xy(goal)[:2])), onward_exit_progress_m=float(progress[goal]), onward_verified=True, + waypoint_format=WAYPOINT_FORMAT, scope="numeric terrain coastal preference; native corridors checked separately") return None diff --git a/src/posim_routing/regional.py b/src/posim_routing/regional.py index 7d5db43..e636764 100644 --- a/src/posim_routing/regional.py +++ b/src/posim_routing/regional.py @@ -168,8 +168,10 @@ def update(self,state,target,needed=False,terminal=False,retain_coast=False): and distance(state['position'],plan['points'][-1])<2500 and distance(state['position'],plan.get('probe_position',state['position']))>=1000 and (self.probed is None or distance(self.probed,state['position'])>=1000)) + from .coastal import WAYPOINT_FORMAT renew_coast=renew_coast or (point is not None and nearest is not None - and plan.get('mode')=='coastal' and not plan.get('onward_verified')) + and plan.get('mode')=='coastal' + and (not plan.get('onward_verified') or plan.get('waypoint_format')!=WAYPOINT_FORMAT)) coast_needed=nearest is not None and (self.probed is None or distance(self.probed,state['position'])>=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_approach')) diff --git a/tests/test_coastal_navigation.py b/tests/test_coastal_navigation.py index 2b8c252..a4c59a0 100644 --- a/tests/test_coastal_navigation.py +++ b/tests/test_coastal_navigation.py @@ -216,6 +216,23 @@ def nearest(self,position):return 1000.,geo(0,0) self.assertNotIn('continuation',sample['regional_navigation']) await navigator.close() + async def test_saved_grid_lookahead_renews_without_discarding_its_checked_target(self): + async def rpc(*args,**kwargs):await asyncio.Event().wait() + class Near: + def nearest(self,position):return 1000.,geo(0,0) + key=[0,0,1];point=geo(800,9000);position=geo(800,0) + sample=dict(lap=0,leg=0,waypoint=1,position=position,status='sailing',native={}, + regional_navigation=dict(points=[point],index=0,mode='coastal',route_key=key, + policy=REGIONAL_POLICY,probe_position=geo(800,-5000),onward_verified=True, + onward_points=[geo(6500,9000)],job='checked')) + navigator=RegionalNavigator(rpc,coastal=True);navigator.coast_index=Near() + self.assertEqual(navigator.update(sample,geo(6500,9000)),point) + self.assertIsNotNone(navigator.task) + self.assertEqual(sample['regional_navigation']['job'],'checked') + self.assertEqual(sample['position'],position) + self.assertEqual(sample['status'],'sailing') + await navigator.close() + async def test_recorded_shape_handoff_preserves_checked_coast(self): async def rpc(*args,**kwargs):await asyncio.Event().wait() class Near: