66import asyncio ,math ,time
77from pyproj import Geod
88GEO = Geod (ellps = 'WGS84' )
9- REGIONAL_POLICY = 'buffered-water-v18-coastal-350m'
9+ REGIONAL_POLICY = 'buffered-water-v19-coastal-400m'
10+ COMPATIBLE_POLICIES = {REGIONAL_POLICY ,'buffered-water-v18-coastal-350m' }
11+ PLANNING_BUDGET_SECONDS = 660.
1012REGIONAL_BUFFER_M = 40.
1113REGIONAL_DEPTH_M = 2.
1214
@@ -61,9 +63,23 @@ def __init__(self,rpc,coastal=False,before_prepare=None):
6163 self .rpc = rpc ;self .task = None ;self .key = None ;self .retry = 0
6264 self .coastal = coastal ;self .coast_index = None ;self .index_task = None ;self .probed = None
6365 self .progress = None ;self .coastal_target = None
64- self .recovery_extent = None
65- def recover_wait (self ,state ):
66- """Retry stalled planning at another resolution without issuing movement."""
66+ self .recovery_extent = None ;self .task_started = None
67+ def start_task (self ,work ):
68+ # The controller watchdog must not cancel terrain preparation before
69+ # its own bounded provider deadline. One budget includes all retries.
70+ self .task_started = time .monotonic ()
71+ work_task = asyncio .create_task (work )
72+ self .task = asyncio .create_task (asyncio .wait_for (work_task ,PLANNING_BUDGET_SECONDS ))
73+ # Cancellation before the wrapper starts must still close its work.
74+ self .task .add_done_callback (lambda _ : work_task .cancel () if not work_task .done () else None )
75+ def recover_wait (self ,state ,preserve_pending = False ):
76+ """Retry expired work; keep bounded pending or completed work consumable."""
77+ if preserve_pending and self .task is not None :
78+ elapsed = time .monotonic ()- self .task_started if self .task_started is not None else PLANNING_BUDGET_SECONDS
79+ if self .task .done () or elapsed < PLANNING_BUDGET_SECONDS :
80+ state .setdefault ('regional_planning' ,{}).update (elapsed_seconds = max (0. ,elapsed ),
81+ budget_seconds = PLANNING_BUDGET_SECONDS )
82+ return dict (pending = True )
6783 if self .task :self .task .cancel ()
6884 self .task = None ;self .retry = 0. ;self .probed = None ;self .progress = None
6985 previous = state .get ('navigation_failsafe' ) or {}
@@ -88,7 +104,7 @@ def active_point(self,state,key):
88104 if plan .get ('mode' )== 'port_approach' and plan .get ('arrival_radius_m' )!= ARRIVAL_RADIUS_M :
89105 self .discard (state ,'port_arrival_region_changed' )
90106 return None
91- if plan .get ('policy' )!= REGIONAL_POLICY :
107+ if plan .get ('policy' ) not in COMPATIBLE_POLICIES :
92108 self .discard (state ,'regional_clearance_policy_changed' )
93109 return None
94110 from .course import passed_vertex
@@ -168,7 +184,7 @@ def update(self,state,target,needed=False,terminal=False,retain_coast=False,prog
168184 and plan .get ('mode' ) in ('coastal' ,'coastal_transfer' ,'coastal_approach' ))
169185 if ((port_handoff or coast_handoff ) and old and len (old )== 3
170186 and old [:2 ]== key [:2 ] and old [2 ]< key [2 ]
171- and plan .get ('policy' )== REGIONAL_POLICY ):
187+ and plan .get ('policy' ) in COMPATIBLE_POLICIES ):
172188 plan ['route_key' ]= list (key );plan .pop ('continuation' ,None )
173189 state ['regional_handoff' ]= dict (previous_key = old ,route_key = list (key ),
174190 position = list (state ['position' ]),reason = 'same_port_course_advance' if port_handoff else 'measured_coastal_shape_advance' )
@@ -219,7 +235,7 @@ def update(self,state,target,needed=False,terminal=False,retain_coast=False,prog
219235 from .coastal import WAYPOINT_FORMAT
220236 renew_coast = renew_coast or (point is not None and nearest is not None
221237 and plan .get ('mode' )== 'coastal'
222- and (not plan .get ('onward_verified' ) or plan .get ('waypoint_format' )!= WAYPOINT_FORMAT ))
238+ and (plan . get ( 'policy' ) != REGIONAL_POLICY or not plan .get ('onward_verified' ) or plan .get ('waypoint_format' )!= WAYPOINT_FORMAT ))
223239 coast_needed = nearest is not None and (self .probed is None or distance (self .probed ,state ['position' ])>= 3000 )
224240 prefer_coast = (point is not None and coast_needed and not needed and
225241 (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
236252 # A failed optional preference must preserve a previously
237253 # checked detour while waiting for a better coastal region.
238254 coast_plan = (plan .get ('route_key' )== key and plan .get ('mode' ) in ('coastal' ,'coastal_transfer' ,'coastal_approach' )
239- and plan .get ('policy' )== REGIONAL_POLICY )
255+ and plan .get ('policy' ) in COMPATIBLE_POLICIES )
240256 preserve_empty = (not value ['points' ] and (point is not None or
241257 (coast_plan and nearest is not None and not terminal and not needed )))
242258 # 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
264280 coast_needed = nearest is not None and (self .probed is None or distance (self .probed ,state ['position' ])>= 3000 )
265281 if (prefer_port or needed or coast_needed or renew_coast ) and not self .task and time .monotonic ()>= self .retry :
266282 state ['regional_planning' ]= dict (status = 'background' ,mode = 'port_approach' if terminal else 'coastal' if coast_needed else 'detour' )
267- 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 ))
283+ 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 ))
268284 self .recovery_extent = None
269285 if point is not None :return point
270286 plan = state .get ('regional_navigation' ) or {}
271- if (plan .get ('route_key' )== key and plan .get ('policy' )== REGIONAL_POLICY
287+ if (plan .get ('route_key' )== key and plan .get ('policy' ) in COMPATIBLE_POLICIES
272288 and (plan .get ('mode' ) in ('coastal' ,'coastal_transfer' ,'coastal_approach' ) or plan .get ('mapped_guidance' )) and plan .get ('points' )
273289 and plan ['index' ]>= len (plan ['points' ])
274290 and distance (state ['position' ],plan ['points' ][- 1 ])< 1500
@@ -305,7 +321,7 @@ def departure_update(self,state,target,key):
305321 self .task = None
306322 if point is not None :return point
307323 if self .task is None and time .monotonic ()>= self .retry :
308- self .task = asyncio . create_task (self .prepare_departure (state ,target ,key ))
324+ self .start_task (self .prepare_departure (state ,target ,key ))
309325 state ['regional_planning' ]= dict (status = 'background' ,mode = 'port_departure' )
310326 state ['regional_waiting' ]= dict (reason = 'resident_departure_corridor_pending' )
311327 return state ['position' ]
@@ -412,7 +428,7 @@ async def terrain_region(self, centre, extent):
412428 except Exception :break
413429 if (sources .get ('baseline_status' ) or {}).get ('status' )!= 'unavailable' :break
414430 raise RuntimeError ('regional_terrain_unavailable' )
415- async def prepare (self ,state ,target ,key ,nearest = None ,needed = True ,extent = None ,progress_target = None ,coastal_target = None ):
431+ async def prepare (self ,state ,target ,key ,nearest = None ,needed = True ,extent = None ,progress_target = None ,coastal_target = None , fine_checked = False ):
416432 from posim_terrain .canal_override import in_region
417433 canal = in_region (state ['position' ])
418434 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
433449 # distant coast. The 1:10M discovery line never supplies depth.
434450 heading ,_ ,_ = GEO .inv (* pos ,* nearest [1 ])
435451 centre = list (GEO .fwd (* pos ,heading ,min (6000. ,nearest [0 ]- 6000. ))[:2 ])
436- extent = extent or (4000 if canal else 20000 )
452+ initial_extent = extent is None
453+ # Fine coastal triangles first: a 20 km grid can close navigable water
454+ # beside a headland and waste an entire provider/planner cycle.
455+ extent = extent or (4000 if canal else 6000 if nearest and nearest [0 ]< 1500. else 20000 )
456+ fine_checked = fine_checked or extent == 6000
437457 if self .before_prepare is not None :await self .before_prepare ()
438458 job = await self .terrain_region (centre ,extent )
439459 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
450470 try :value = await asyncio .to_thread (coastal_corridor ,grid ,job ['spec' ],current ,coastal_goal ,guard = guard ,progress_target = progress_target )
451471 except RuntimeError as error :coast_error = str (error )[:160 ]
452472 water_fallback = value if value is not None and value .get ('mode' )== 'detour' else None
453- if nearest and (value is None or water_fallback is not None ) and extent > 6000 and nearest [0 ]< 1500. :
473+ if nearest and not fine_checked and (value is None or water_fallback is not None ) and extent > 6000 and nearest [0 ]< 1500. :
454474 # A coarse buffered pixel can disconnect otherwise navigable coastal
455475 # water. Resolve the current shoreline before accepting a water exit.
456476 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
506526 # Coarse regional pixels can close a harbour entrance.
507527 # Resolve it on a smaller real terrain grid before retrying;
508528 # never substitute an arbitrary offshore frontier.
509- if not canal and extent > 6000 and str (error ).startswith ('regional_corridor_unavailable' ):
529+ if not canal and not fine_checked and extent > 6000 and str (error ).startswith ('regional_corridor_unavailable' ):
510530 return await self .prepare (state ,course_target ,key ,nearest ,needed ,extent = 6000 ,progress_target = progress_target ,coastal_target = coastal_target )
531+ if not canal and initial_extent and extent == 6000 :
532+ return await self .prepare (state ,course_target ,key ,nearest ,needed ,extent = 20000 ,
533+ progress_target = progress_target ,coastal_target = coastal_target ,fine_checked = True )
511534 raise
512535 if value ['points' ]:value ['mode' ]= 'detour'
513536 if canal and value ['points' ]:value .update (mode = 'canal' ,assumed_depth_m = 10 ,measured_bathymetry = False )
0 commit comments