11"""
2- ship.py — optimized
2+ ship.py — turbo (dataset-speed edition)
33- Fast local-goal search (squared distances, incremental index)
4- - Lazy STRtree index for speed-limit zones (supports both index- and geometry-returns)
5- - Cheaper docking check (no sqrt)
6- - Zero-allocation move_position + DEBUG-only logging
4+ - Lazy STRtree speed-zone lookup (indices or geometries)
5+ - Cheaper docking check (no sqrt) + *early* docking precheck (skips DWA when close)
6+ - Adds lightweight x/y/v properties for fast DataCollector
7+ - Queues arrived ships for removal (model cleans them up after the step)
78"""
89import logging
910import math
1213from shapely .geometry import Point , Polygon
1314try :
1415 from shapely .strtree import STRtree
15- except Exception : # STRtree may be unavailable on very old Shapely or missing GEOS
16+ except Exception : # STRtree may be unavailable
1617 STRtree = None
1718
1819from a_star import astar
2223
2324
2425def _get_rng (agent ):
25- """Return an RNG: prefer agent.random, then model.random, else Python's random module."""
2626 try :
2727 import random as _pr
28- except Exception : # pragma: no cover
28+ except Exception :
2929 _pr = None
3030 return getattr (agent , "random" , None ) or getattr (agent .model , "random" , None ) or _pr
3131
3232
3333class Ship (Agent ):
3434 def __init__ (self , model , id , start_port , all_ports , dwa_config ):
35- # Mesa 2.x signature: Agent(unique_id, model)
3635 super ().__init__ (id , model )
3736 self .destination_port = self .assign_destination (all_ports , start_port )
3837 self .global_path = self .calculate_global_path (start_port .pos , self .destination_port .pos )
39- # Per-ship copy so edits (speed limits/noise) don't mutate a shared dict
40- self .dwa_config = dict (dwa_config )
38+ self .dwa_config = dict (dwa_config ) # per-ship copy
4139
4240 self .heading_deviation = 0.0
4341 self .heading_drift_duration = 0
4442
4543 rng = _get_rng (self )
46- # Assign a random max speed within the speed range
4744 self .dwa_config ["max_speed" ] = rng .uniform (self .model .max_speed_range [0 ], self .model .max_speed_range [1 ])
4845 self .original_max_speed = self .dwa_config ["max_speed" ]
4946 if log .isEnabledFor (logging .DEBUG ):
50- log .debug ("Ship %s has a maximum speed of %.3f." , self .unique_id , self .dwa_config ["max_speed" ])
47+ log .debug ("Ship %s max speed %.3f." , self .unique_id , self .dwa_config ["max_speed" ])
5148
5249 if self .global_path and len (self .global_path ) > 1 :
53- first_waypoint = self .global_path [1 ] # Ensure it doesn't use the port position
50+ first_waypoint = self .global_path [1 ]
5451 dx = first_waypoint [0 ] - start_port .pos [0 ]
5552 dy = first_waypoint [1 ] - start_port .pos [1 ]
56- # Set initial heading (theta) towards the first waypoint
5753 initial_theta = math .atan2 (dy , dx )
5854 else :
5955 initial_theta = 0.0
6056
61- # Ship's state (x, y, theta, v, w)
57+ # state = (x, y, theta, v, w)
6258 self .state = (start_port .pos [0 ], start_port .pos [1 ], initial_theta , 0.0 , 0.0 )
63-
6459 self .current_wp_idx = 0
60+ self .arrived = False # mark when we reach destination
61+
62+ # --- lightweight attributes for fast DataCollector ---
63+ @property
64+ def x (self ):
65+ p = getattr (self , "pos" , None )
66+ return float (p [0 ]) if p is not None else None
67+
68+ @property
69+ def y (self ):
70+ p = getattr (self , "pos" , None )
71+ return float (p [1 ]) if p is not None else None
72+
73+ @property
74+ def v (self ):
75+ return float (self .state [3 ])
6576
6677 def step (self ):
67- # Early exit if already docked (relies on model to have set .pos initially)
78+ if self .arrived :
79+ return
80+ # early exit if position equals destination
6881 if getattr (self , "pos" , None ) is not None :
6982 if self .pos [0 ] == self .destination_port .pos [0 ] and self .pos [1 ] == self .destination_port .pos [1 ]:
83+ self ._queue_removal ()
7084 return
7185
72- # Move the ship along the calculated global path.
7386 if self .global_path and len (self .global_path ) > 1 :
7487 dt = self .dwa_config ["dt" ]
7588
89+ # Early docking precheck: skip DWA when we're within one max step of destination
90+ max_spd_here = self .get_speed_limit ()
91+ if self .model .speed_variation .get ("enabled" , False ):
92+ var = self .model .max_speed_variation
93+ rng = _get_rng (self )
94+ max_spd_here = max (self .dwa_config ["min_speed" ],
95+ min (max_spd_here + rng .uniform (- max_spd_here * var , max_spd_here * var ),
96+ self .dwa_config ["max_speed" ]))
97+ if self ._close_enough_to_dock (max_spd_here , dt ):
98+ self .move_to_destination ()
99+ self ._queue_removal ()
100+ return
101+
76102 local_goal = self .get_local_goal (self .state , self .global_path , lookahead = self .model .lookahead )
77- # Speed limit via spatial index (built lazily & cached on model)
78- self .dwa_config ["max_speed" ] = self .get_speed_limit ()
79103
80- if self . model . speed_variation [ "enabled" ]:
81- self .dwa_config ["max_speed" ] = self . get_noisy_speed ()
104+ # Speed limit via spatial index (built lazily & cached on model)
105+ self .dwa_config ["max_speed" ] = max_spd_here
82106
83107 rng = _get_rng (self )
84108 # Directional (heading) variation
85109 if self .model .directional_variation ["enabled" ]:
86- # Randomly trigger heading deviation
87110 if self .heading_drift_duration > 0 :
88- # Continue existing deviation
89111 noisy_theta = self .state [2 ] + self .heading_deviation
90112 self .heading_drift_duration -= 1
91- if log .isEnabledFor (logging .DEBUG ):
92- log .debug ("Continue deviation. Ship %s, Theta=%.4f" , self .unique_id , self .heading_deviation )
93113 else :
94- # Random chance to start a new deviation
95114 if rng .random () < self .model .deviation_chance :
96115 self .heading_deviation = rng .uniform (- self .model .max_heading_deviation , self .model .max_heading_deviation )
97116 self .heading_drift_duration = self .model .deviation_duration
98117 noisy_theta = self .state [2 ] + self .heading_deviation
99- if log .isEnabledFor (logging .DEBUG ):
100- log .debug ("Starting directional deviation. Ship %s, Theta=%.4f" , self .unique_id , self .heading_deviation )
101118 else :
102119 noisy_theta = self .state [2 ]
103-
104- # Normalize heading
105120 noisy_theta = (noisy_theta + math .pi ) % (2 * math .pi ) - math .pi
106121 noisy_state = (self .state [0 ], self .state [1 ], noisy_theta , self .state [3 ], self .state [4 ])
107122 else :
108123 noisy_state = self .state
109124
110- control , predicted_trajectory , cost_info = dwa_control (
125+ control , _ , _ = dwa_control (
111126 noisy_state , self .dwa_config , self .model .obstacle_tree ,
112127 self .model .buffered_obstacles , local_goal
113128 )
114129
115- # Check if we should dock
116130 if self .should_dock (control [0 ]):
117131 self .move_to_destination ()
132+ self ._queue_removal ()
118133 else :
119134 self .state = motion (self .state , control [0 ], control [1 ], dt )
120135 self .move_position ()
121136
122- # After motion, restore true max speed
137+ # restore true max speed
123138 self .dwa_config ["max_speed" ] = self .original_max_speed
124139
140+ def _close_enough_to_dock (self , speed , dt ):
141+ dx = self .state [0 ] - self .destination_port .pos [0 ]
142+ dy = self .state [1 ] - self .destination_port .pos [1 ]
143+ return (dx * dx + dy * dy ) <= (speed * dt )* (speed * dt )
144+
145+ def _queue_removal (self ):
146+ """Mark ship for removal by the model at end-of-step (safe w.r.t. scheduler iteration)."""
147+ self .arrived = True
148+ q = getattr (self .model , "_arrived_to_remove" , None )
149+ if q is None :
150+ self .model ._arrived_to_remove = [self ]
151+ else :
152+ q .append (self )
153+
125154 def get_noisy_speed (self ):
126- # Assign ± speed variation as a fraction of max_speed
127155 max_speed_variation = self .model .max_speed_variation
128156 variation_amount = self .dwa_config ["max_speed" ] * max_speed_variation
129157 rng = _get_rng (self )
130158 noisy_speed = self .dwa_config ["max_speed" ] + rng .uniform (- variation_amount , variation_amount )
131-
132- # Clamp to valid range
133- noisy_speed = max (self .dwa_config ["min_speed" ], min (noisy_speed , self .dwa_config ["max_speed" ]))
134- return noisy_speed
159+ return max (self .dwa_config ["min_speed" ], min (noisy_speed , self .dwa_config ["max_speed" ]))
135160
136161 def should_dock (self , current_speed ):
137- """Determine if the ship should dock at its destination (squared-distance, no sqrt)."""
138162 dx = self .state [0 ] - self .destination_port .pos [0 ]
139163 dy = self .state [1 ] - self .destination_port .pos [1 ]
140164 dist2 = dx * dx + dy * dy
141165 step_move = current_speed * self .dwa_config ['dt' ]
142166 return dist2 <= step_move * step_move
143167
144168 def move_position (self ):
145- """Update the ship's position in the simulation space (avoids numpy allocation)."""
146169 self .pos = (float (self .state [0 ]), float (self .state [1 ]))
147170 self .model .space .move_agent (self , self .pos )
148- if log .isEnabledFor (logging .DEBUG ):
149- log .debug (
150- "Ship %s moving towards %s. Current position: (%.3f, %.3f). Speed: %.3f." ,
151- self .unique_id , self .destination_port .pos , self .pos [0 ], self .pos [1 ], self .state [3 ]
152- )
153171
154172 def move_to_destination (self ):
155- """Move the ship directly to its destination."""
156173 self .pos = self .destination_port .pos
157174 self .model .space .move_agent (self , self .destination_port .pos )
158- if log .isEnabledFor (logging .DEBUG ):
159- log .debug ("Ship %s arrived at port %s." , self .unique_id , self .destination_port .pos )
160175
161176 def assign_destination (self , all_ports , start_port ):
162- """Select a destination port different from the starting port."""
163177 possible_destinations = [port for port in all_ports if port != start_port ]
164178 rng = _get_rng (self )
165179 return rng .choice (possible_destinations ) if possible_destinations else start_port
166180
167181 def calculate_global_path (self , start , destination ):
168- """Calculate a path using A* algorithm."""
169182 grid_start = (int (start [0 ] / self .model .resolution ), int (start [1 ] / self .model .resolution ))
170183 grid_goal = (int (destination [0 ] / self .model .resolution ), int (destination [1 ] / self .model .resolution ))
171184 global_path_indices = astar (self .model .occupancy_grid , grid_start , grid_goal )
172-
173185 if global_path_indices is None :
174- if log .isEnabledFor (logging .DEBUG ):
175- log .debug ("No global path for ship %s." , self .unique_id )
176186 return
177-
178- global_path = [((i ) * self .model .resolution , (j ) * self .model .resolution )
179- for (i , j ) in global_path_indices ]
180- return global_path
187+ return [((i ) * self .model .resolution , (j ) * self .model .resolution ) for (i , j ) in global_path_indices ]
181188
182189 # --- Speed-limit zones: lazy-built STRtree on the model ---
183190 def _build_speed_zone_index_if_needed (self ):
184- """Build and cache a spatial index for speed-limit zones on the model (lazy)."""
185191 if getattr (self .model , "_speed_zone_tree" , None ) is not None :
186- return # already built
192+ return
187193 zones = getattr (self .model , "speed_limit_zones" , None )
188194 if not zones :
189- # no zones configured
190195 self .model ._speed_zone_tree = None
191196 self .model ._speed_zone_polys = []
192197 self .model ._speed_zone_speed_by_id = {}
@@ -195,7 +200,6 @@ def _build_speed_zone_index_if_needed(self):
195200
196201 polys = [Polygon (z ["zone" ]) for z in zones ]
197202 self .model ._speed_zone_polys = polys
198- # store speeds in parallel list for index-based lookups
199203 self .model ._speed_zone_speeds = [z ["max_speed" ] for z in zones ]
200204
201205 if STRtree is not None and polys :
@@ -204,54 +208,40 @@ def _build_speed_zone_index_if_needed(self):
204208 except Exception :
205209 self .model ._speed_zone_tree = None
206210 else :
207- self .model ._speed_zone_tree = None # fallback path will be used
208- # map polygon id -> speed for geometry-based lookups
211+ self .model ._speed_zone_tree = None
209212 self .model ._speed_zone_speed_by_id = {id (p ): z ["max_speed" ] for p , z in zip (polys , zones )}
210213
211214 def get_speed_limit (self ):
212- """Return max speed at current position.
213- Works with STRtree returning indices or geometries.
214- """
215215 zones = getattr (self .model , "speed_limit_zones" , None )
216216 if not zones :
217217 return self .original_max_speed
218-
219218 self ._build_speed_zone_index_if_needed ()
220219
221220 pt = Point (self .state [0 ], self .state [1 ])
222221 tree = getattr (self .model , "_speed_zone_tree" , None )
223-
224222 if tree is not None :
225- # Try to use a predicate if supported (Shapely 2.x). It may return indices.
226223 try :
227224 candidates = tree .query (pt , predicate = "contains" )
228225 except TypeError :
229- # Older Shapely: no predicate argument
230226 candidates = tree .query (pt )
231-
232- # `candidates` can be a list of indices OR a list of Polygon objects.
233227 for cand in candidates :
234- if isinstance (cand , Integral ): # index path (e.g., numpy.int64)
228+ if isinstance (cand , Integral ):
235229 idx = int (cand )
236230 poly = self .model ._speed_zone_polys [idx ]
237231 if poly .contains (pt ):
238232 return self .model ._speed_zone_speeds [idx ]
239- else : # geometry path
233+ else :
240234 poly = cand
241235 if poly .contains (pt ):
242236 return self .model ._speed_zone_speed_by_id .get (id (poly ), self .original_max_speed )
243-
244237 return self .original_max_speed
245238
246- # Fallback: no STRtree available; scan the prebuilt polygons once
247239 for idx , poly in enumerate (self .model ._speed_zone_polys ):
248240 if poly .contains (pt ):
249241 return self .model ._speed_zone_speeds [idx ]
250-
251242 return self .original_max_speed
252243
253244 def get_local_goal (self , state , global_path , lookahead = 3.0 ):
254- """Return the first waypoint at least 'lookahead' away (squared distance, O(1–few))."""
255245 x , y = state [0 ], state [1 ]
256246 la2 = lookahead * lookahead
257247 i = self .current_wp_idx
0 commit comments