sonar-vision 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 SuperInstance Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: sonar-vision
3
+ Version: 0.1.0
4
+ Summary: Pure-Python sonar ping/echo simulation, signal processing, multi-object tracking, and spatial mapping toolkit
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ License-File: LICENSE
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7; extra == "dev"
10
+ Dynamic: license-file
@@ -0,0 +1,172 @@
1
+ # sonar-vision
2
+
3
+ Pure-Python toolkit for simulating active sonar pings and echoes, processing signals, tracking objects, and building 2-D occupancy maps.
4
+
5
+ > **Documentation:** [`PLUG_AND_PLAY.md`](./PLUG_AND_PLAY.md) · [`GETTING_STARTED.md`](./GETTING_STARTED.md) · [`ARCHITECTURE.md`](./ARCHITECTURE.md) · [`API_REFERENCE.md`](./API_REFERENCE.md) · [`LOW_LEVEL.md`](./LOW_LEVEL.md)
6
+
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](./LICENSE)
8
+ [![Python](https://img.shields.io/badge/Python-%3E%3D3.10-blue?style=flat-square)](https://www.python.org/)
9
+
10
+ ## Quickstart
11
+
12
+ Install from source into a virtual environment:
13
+
14
+ ```bash
15
+ git clone https://github.com/purplepincher/sonar-vision.git
16
+ cd sonar-vision
17
+ python3 -m venv .venv
18
+ source .venv/bin/activate
19
+ pip install -e ".[dev]"
20
+ ```
21
+
22
+ Run the tests:
23
+
24
+ ```bash
25
+ pytest tests/
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Simulate a sonar ping
31
+
32
+ ```python
33
+ from sonar_vision import Sonar
34
+
35
+ sonar = Sonar(frequency=50000, max_range=500)
36
+ result = sonar.ping(distance=100)
37
+ print(f"Distance: {result.distance:.1f}m, SNR: {result.snr_db:.1f} dB")
38
+ ```
39
+
40
+ Output:
41
+
42
+ ```text
43
+ Distance: 99.1m, SNR: 38.0 dB
44
+ ```
45
+
46
+ ### Track objects across detections
47
+
48
+ ```python
49
+ from sonar_vision import ObjectTracker, Detection
50
+
51
+ tracker = ObjectTracker()
52
+ tracker.update([Detection(x=10, y=20)])
53
+ tracker.update([Detection(x=12, y=22)])
54
+ tracker.update([Detection(x=14, y=19)])
55
+
56
+ print(f"Tracks: {len(tracker.tracks)}")
57
+ for t in tracker.tracks:
58
+ print(f" track {t.track_id}: ({t.x}, {t.y}), "
59
+ f"speed {tracker.speed(t.track_id):.2f} m/s")
60
+ ```
61
+
62
+ Output:
63
+
64
+ ```text
65
+ Tracks: 1
66
+ track 1: (14, 19), speed 5.13 m/s
67
+ ```
68
+
69
+ ### Generate and analyse a signal
70
+
71
+ ```python
72
+ from sonar_vision import Signal
73
+
74
+ chirp = Signal.chirp(f0=1000, f1=5000, duration=0.01, sample_rate=44100)
75
+ print(f"Chirp samples: {chirp.n_samples}, "
76
+ f"dominant freq: {chirp.dominant_frequency():.0f} Hz")
77
+ ```
78
+
79
+ Output:
80
+
81
+ ```text
82
+ Chirp samples: 441, dominant freq: 2950 Hz
83
+ ```
84
+
85
+ ### Build an occupancy map
86
+
87
+ ```python
88
+ from sonar_vision import SpatialMap, Obstacle
89
+
90
+ m = SpatialMap(width=20, height=20, resolution=1.0)
91
+ m.add_obstacle(Obstacle(x=5, y=5, radius=1.0))
92
+ print(f"Occupied at (5,5): {m.is_occupied(5, 5)}")
93
+ print(f"Coverage: {m.coverage():.2%}")
94
+ ```
95
+
96
+ Output:
97
+
98
+ ```text
99
+ Occupied at (5,5): True
100
+ Coverage: 1.00%
101
+ ```
102
+
103
+ ## How it works
104
+
105
+ - **`Sonar`** models a single-beam active sonar. It computes round-trip time, applies two-way spreading and configurable absorption loss, adds target strength, and converts the resulting SNR into a detection probability and a noisy distance estimate.
106
+ - **`Signal`** provides discrete-time signal primitives: sine, noise, chirp, resampling, moving-average lowpass/highpass/bandpass filtering, thresholding, envelope, RMS/peak/energy metrics, and a naïve DFT magnitude.
107
+ - **`ObjectTracker`** uses greedy nearest-neighbour association with a constant-velocity prediction gate. Each track stores position and exponentially smoothed velocity; tracks time out after a configurable interval without updates.
108
+ - **`SpatialMap`** maintains a 2-D occupancy grid centred at the origin. Obstacles mark cells as occupied, free-space rays can mark cells as free, and ray casting returns the first occupied cell along a bearing.
109
+
110
+ ## Configuration and options
111
+
112
+ ### `Sonar`
113
+
114
+ ```python
115
+ Sonar(
116
+ sound_speed=1500.0, # m/s
117
+ frequency=50000.0, # Hz
118
+ pulse_duration=0.001, # seconds
119
+ max_range=1000.0, # meters
120
+ beam_width=30.0, # degrees
121
+ source_level=200.0, # arbitrary dB
122
+ noise_level=60.0, # arbitrary dB
123
+ )
124
+ ```
125
+
126
+ Key methods: `ping(distance, target_strength=-20.0)`, `ping_return_signal(...)`, `round_trip_time(distance)`, `spreading_loss(distance)`, `absorption_loss(distance, absorption_db_km=10.0)`, `in_beam(bearing_offset_deg)`.
127
+
128
+ ### `ObjectTracker`
129
+
130
+ ```python
131
+ ObjectTracker(
132
+ association_gate=5.0, # meters
133
+ max_velocity=10.0, # m/s
134
+ lost_timeout=5.0, # seconds
135
+ )
136
+ ```
137
+
138
+ Key methods: `update(detections)`, `predict(track_id, dt)`, `active_tracks()`, `prune_lost()`, `speed(track_id)`, `heading(track_id)`.
139
+
140
+ ### `SpatialMap`
141
+
142
+ ```python
143
+ SpatialMap(
144
+ width=100.0, # meters
145
+ height=100.0, # meters
146
+ resolution=1.0, # meters per cell
147
+ )
148
+ ```
149
+
150
+ Key methods: `add_obstacle(Obstacle(...))`, `get_cell(x, y)`, `set_cell(x, y, state)`, `ray_cast(ox, oy, angle_deg, max_dist)`, `mark_free_ray(...)`, `occupancy_count()`, `coverage()`.
151
+
152
+ See [`API_REFERENCE.md`](./API_REFERENCE.md) for the full API.
153
+
154
+ ## Limitations
155
+
156
+ - **Simulation only.** No hydrophone, audio device, or NMEA interface support.
157
+ - **Single-beam sonar.** No beamforming, phased arrays, or multi-beam reconstruction.
158
+ - **Simplified acoustics.** Propagation uses geometric spreading plus a constant dB/km absorption term; it is not a full ocean-acoustics solver.
159
+ - **No image or video processing.** The toolkit works with 1-D signals and 2-D positions, not camera frames.
160
+ - **Not on PyPI.** Install from source for now.
161
+
162
+ ## Testing
163
+
164
+ ```bash
165
+ pytest tests/
166
+ ```
167
+
168
+ The suite covers unit tests (`tests/test_all.py`) and end-to-end simulation scenarios (`tests/test_simulation_scenarios.py`); all pass under Python 3.10+.
169
+
170
+ ## License
171
+
172
+ MIT. See [`LICENSE`](./LICENSE).
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sonar-vision"
7
+ version = "0.1.0"
8
+ description = "Pure-Python sonar ping/echo simulation, signal processing, multi-object tracking, and spatial mapping toolkit"
9
+ license = {text = "MIT"}
10
+ requires-python = ">=3.10"
11
+
12
+ [project.optional-dependencies]
13
+ dev = ["pytest>=7"]
14
+
15
+ [tool.setuptools.packages.find]
16
+ include = ["sonar_vision*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ """Sonar Vision — signal processing and spatial awareness for agents."""
2
+
3
+ from sonar_vision.signal import Signal
4
+ from sonar_vision.sonar import Sonar, PingResult, SOUND_SPEED_WATER
5
+ from sonar_vision.map import SpatialMap, Obstacle, CellState
6
+ from sonar_vision.tracker import ObjectTracker, Track, Detection
7
+ from sonar_vision.display import SonarDisplay
8
+
9
+ __all__ = [
10
+ "Signal",
11
+ "Sonar",
12
+ "PingResult",
13
+ "SOUND_SPEED_WATER",
14
+ "SpatialMap",
15
+ "Obstacle",
16
+ "CellState",
17
+ "ObjectTracker",
18
+ "Track",
19
+ "Detection",
20
+ "SonarDisplay",
21
+ ]
22
+ __version__ = "0.1.0"
@@ -0,0 +1,165 @@
1
+ """SonarDisplay — ASCII radar rendering for sonar data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ from sonar_vision.map import SpatialMap, CellState, Obstacle
10
+ from sonar_vision.tracker import ObjectTracker, Track
11
+
12
+
13
+ # ASCII intensity ramp for signal strength / proximity
14
+ _RAMP = " .:-=+*#%@"
15
+ _RAMP_LEN = len(_RAMP)
16
+
17
+
18
+ @dataclass
19
+ class SonarDisplay:
20
+ """ASCII-based sonar radar display.
21
+
22
+ Renders a top-down polar or Cartesian view using characters.
23
+
24
+ Attributes:
25
+ width: Display width in characters.
26
+ height: Display height in characters.
27
+ range_m: Maximum display range in meters.
28
+ """
29
+
30
+ width: int = 41
31
+ height: int = 21
32
+ range_m: float = 50.0
33
+
34
+ # ── radar sweep ───────────────────────────────────────────────
35
+
36
+ def render_sweep(
37
+ self,
38
+ bearing_angles: list[float],
39
+ distances: list[float],
40
+ max_range: float | None = None,
41
+ ) -> str:
42
+ """Render a radar sweep from bearing/distance readings.
43
+
44
+ Each (bearing, distance) pair places a blip on the display.
45
+ """
46
+ rng = max_range or self.range_m
47
+ grid: list[list[str]] = [
48
+ [" " for _ in range(self.width)] for _ in range(self.height)
49
+ ]
50
+ cx = self.width // 2
51
+ cy = self.height // 2
52
+
53
+ # Draw crosshairs
54
+ for x in range(self.width):
55
+ grid[cy][x] = "·" if grid[cy][x] == " " else grid[cy][x]
56
+ for y in range(self.height):
57
+ grid[y][cx] = "·" if grid[y][cx] == " " else grid[y][cx]
58
+
59
+ # Range rings (25%, 50%, 75%)
60
+ for frac in (0.25, 0.5, 0.75):
61
+ r_cells = int(frac * min(cx, cy))
62
+ for angle_deg in range(0, 360, 5):
63
+ ar = math.radians(angle_deg)
64
+ px = cx + int(r_cells * math.cos(ar))
65
+ py = cy - int(r_cells * math.sin(ar))
66
+ if 0 <= px < self.width and 0 <= py < self.height:
67
+ grid[py][px] = "·"
68
+
69
+ # Centre marker (draw after range rings to avoid overwrite)
70
+ grid[cy][cx] = "+"
71
+
72
+ # Plot detections
73
+ for bearing, dist in zip(bearing_angles, distances):
74
+ if dist > rng or dist <= 0:
75
+ continue
76
+ ar = math.radians(bearing)
77
+ r_norm = dist / rng
78
+ px = cx + int(r_norm * cx * math.cos(ar))
79
+ py = cy - int(r_norm * cy * math.sin(ar))
80
+ if 0 <= px < self.width and 0 <= py < self.height:
81
+ intensity = int((1.0 - r_norm) * (_RAMP_LEN - 1))
82
+ intensity = max(0, min(_RAMP_LEN - 1, intensity))
83
+ grid[py][px] = _RAMP[intensity]
84
+
85
+ lines = ["".join(row) for row in grid]
86
+ return "\n".join(lines)
87
+
88
+ # ── occupancy map ─────────────────────────────────────────────
89
+
90
+ def render_map(self, smap: SpatialMap) -> str:
91
+ """Render a SpatialMap as ASCII."""
92
+ symbols = {CellState.UNKNOWN: " ", CellState.FREE: ".", CellState.OCCUPIED: "#"}
93
+ # Downsample map to display size
94
+ row_scale = smap.rows / self.height
95
+ col_scale = smap.cols / self.width
96
+
97
+ lines: list[str] = []
98
+ for dr in range(self.height):
99
+ row_start = int(dr * row_scale)
100
+ row_end = min(smap.rows, int((dr + 1) * row_scale))
101
+ line_chars: list[str] = []
102
+ for dc in range(self.width):
103
+ col_start = int(dc * col_scale)
104
+ col_end = min(smap.cols, int((dc + 1) * col_scale))
105
+ # Pick most interesting cell in block
106
+ cell = CellState.UNKNOWN
107
+ for r in range(row_start, row_end):
108
+ for c in range(col_start, col_end):
109
+ s = smap._grid[r][c]
110
+ if s == CellState.OCCUPIED:
111
+ cell = s
112
+ elif s == CellState.FREE and cell == CellState.UNKNOWN:
113
+ cell = s
114
+ line_chars.append(symbols[cell])
115
+ lines.append("".join(line_chars))
116
+ return "\n".join(lines)
117
+
118
+ # ── tracker overlay ───────────────────────────────────────────
119
+
120
+ def render_tracks(
121
+ self,
122
+ tracker: ObjectTracker,
123
+ cx_m: float = 0.0,
124
+ cy_m: float = 0.0,
125
+ now: float | None = None,
126
+ ) -> str:
127
+ """Render active tracks relative to a centre point."""
128
+ grid: list[list[str]] = [
129
+ [" " for _ in range(self.width)] for _ in range(self.height)
130
+ ]
131
+ cx = self.width // 2
132
+ cy = self.height // 2
133
+
134
+ # Crosshairs
135
+ for x in range(self.width):
136
+ grid[cy][x] = "·"
137
+ for y in range(self.height):
138
+ grid[y][cx] = "·"
139
+ grid[cy][cx] = "+"
140
+
141
+ active = tracker.active_tracks(now)
142
+ for t in active:
143
+ dx = t.x - cx_m
144
+ dy = t.y - cy_m
145
+ px = cx + int(dx / self.range_m * cx)
146
+ py = cy - int(dy / self.range_m * cy)
147
+ if 0 <= px < self.width and 0 <= py < self.height:
148
+ # Draw track marker and velocity vector
149
+ marker = str(t.track_id % 10) if t.track_id < 10 else "*"
150
+ grid[py][px] = marker
151
+ # Velocity indicator
152
+ vscale = self.range_m / (tracker.max_velocity or 1.0) * 2
153
+ vx_px = int(t.vx * vscale / self.range_m * cx)
154
+ vy_px = int(t.vy * vscale / self.range_m * cy)
155
+ ex = px + vx_px
156
+ ey = py - vy_px
157
+ # Simple line
158
+ steps = max(abs(ex - px), abs(ey - py), 1)
159
+ for i in range(1, steps + 1):
160
+ lx = px + (ex - px) * i // steps
161
+ ly = py + (ey - py) * i // steps
162
+ if 0 <= lx < self.width and 0 <= ly < self.height and grid[ly][lx] == " ":
163
+ grid[ly][lx] = "~"
164
+
165
+ return "\n".join("".join(row) for row in grid)
@@ -0,0 +1,230 @@
1
+ """SpatialMap — obstacle detection, mapping, and spatial queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from typing import Optional
9
+
10
+
11
+ class CellState(Enum):
12
+ """State of a grid cell."""
13
+ UNKNOWN = 0
14
+ FREE = 1
15
+ OCCUPIED = 2
16
+
17
+
18
+ @dataclass
19
+ class Obstacle:
20
+ """Detected obstacle.
21
+
22
+ Attributes:
23
+ x: X-coordinate in meters.
24
+ y: Y-coordinate in meters.
25
+ radius: Approximate radius in meters (0 for point obstacles).
26
+ confidence: Detection confidence (0–1).
27
+ label: Optional label.
28
+ """
29
+
30
+ x: float
31
+ y: float
32
+ radius: float = 0.0
33
+ confidence: float = 1.0
34
+ label: str = ""
35
+
36
+
37
+ @dataclass
38
+ class SpatialMap:
39
+ """2-D occupancy grid map with obstacle management.
40
+
41
+ The map covers a rectangular region centred at the origin.
42
+
43
+ Attributes:
44
+ width: Map width in meters.
45
+ height: Map height in meters.
46
+ resolution: Cell size in meters.
47
+ """
48
+
49
+ width: float = 100.0
50
+ height: float = 100.0
51
+ resolution: float = 1.0
52
+ _obstacles: list[Obstacle] = field(default_factory=list)
53
+ _grid: list[list[CellState]] = field(init=False, repr=False)
54
+
55
+ def __post_init__(self) -> None:
56
+ cols = max(1, int(self.width / self.resolution))
57
+ rows = max(1, int(self.height / self.resolution))
58
+ self._grid = [[CellState.UNKNOWN for _ in range(cols)] for _ in range(rows)]
59
+
60
+ # ── grid helpers ──────────────────────────────────────────────
61
+
62
+ @property
63
+ def cols(self) -> int:
64
+ return len(self._grid[0]) if self._grid else 0
65
+
66
+ @property
67
+ def rows(self) -> int:
68
+ return len(self._grid)
69
+
70
+ def _world_to_cell(self, x: float, y: float) -> tuple[int, int]:
71
+ """Convert world coords to cell (row, col)."""
72
+ col = int((x + self.width / 2.0) / self.resolution)
73
+ row = int((y + self.height / 2.0) / self.resolution)
74
+ return row, col
75
+
76
+ def _cell_to_world(self, row: int, col: int) -> tuple[float, float]:
77
+ """Convert cell (row, col) to world coords (centre of cell)."""
78
+ x = (col + 0.5) * self.resolution - self.width / 2.0
79
+ y = (row + 0.5) * self.resolution - self.height / 2.0
80
+ return x, y
81
+
82
+ def _in_bounds(self, row: int, col: int) -> bool:
83
+ return 0 <= row < self.rows and 0 <= col < self.cols
84
+
85
+ # ── cell access ───────────────────────────────────────────────
86
+
87
+ def get_cell(self, x: float, y: float) -> CellState:
88
+ """Get the state of the cell at world coordinates (x, y)."""
89
+ row, col = self._world_to_cell(x, y)
90
+ if not self._in_bounds(row, col):
91
+ return CellState.UNKNOWN
92
+ return self._grid[row][col]
93
+
94
+ def set_cell(self, x: float, y: float, state: CellState) -> None:
95
+ """Set the state of the cell at world coordinates (x, y)."""
96
+ row, col = self._world_to_cell(x, y)
97
+ if self._in_bounds(row, col):
98
+ self._grid[row][col] = state
99
+
100
+ # ── obstacles ─────────────────────────────────────────────────
101
+
102
+ def add_obstacle(self, obstacle: Obstacle) -> None:
103
+ """Add an obstacle and mark its cells as occupied."""
104
+ self._obstacles.append(obstacle)
105
+ # Mark cells within the obstacle radius (at least the center cell)
106
+ cx, cy = obstacle.x, obstacle.y
107
+ r_cells = max(1, int(math.ceil(obstacle.radius / self.resolution))) if obstacle.radius > 0 else 0
108
+ center_row, center_col = self._world_to_cell(cx, cy)
109
+ # Always mark the center cell
110
+ if self._in_bounds(center_row, center_col):
111
+ self._grid[center_row][center_col] = CellState.OCCUPIED
112
+ for dr in range(-r_cells, r_cells + 1):
113
+ for dc in range(-r_cells, r_cells + 1):
114
+ nr, nc = center_row + dr, center_col + dc
115
+ if self._in_bounds(nr, nc):
116
+ wx, wy = self._cell_to_world(nr, nc)
117
+ if obstacle.radius <= 0 or math.hypot(wx - cx, wy - cy) <= obstacle.radius:
118
+ self._grid[nr][nc] = CellState.OCCUPIED
119
+
120
+ def remove_obstacle(self, index: int) -> None:
121
+ """Remove obstacle by index and clear its cells."""
122
+ if 0 <= index < len(self._obstacles):
123
+ obs = self._obstacles.pop(index)
124
+ self._mark_area_free(obs)
125
+
126
+ def _mark_area_free(self, obs: Obstacle) -> None:
127
+ r_cells = max(1, int(math.ceil(obs.radius / self.resolution))) if obs.radius > 0 else 1
128
+ center_row, center_col = self._world_to_cell(obs.x, obs.y)
129
+ for dr in range(-r_cells, r_cells + 1):
130
+ for dc in range(-r_cells, r_cells + 1):
131
+ nr, nc = center_row + dr, center_col + dc
132
+ if self._in_bounds(nr, nc):
133
+ wx, wy = self._cell_to_world(nr, nc)
134
+ if obs.radius <= 0 or math.hypot(wx - obs.x, wy - obs.y) <= obs.radius:
135
+ self._grid[nr][nc] = CellState.FREE
136
+
137
+ @property
138
+ def obstacles(self) -> list[Obstacle]:
139
+ return list(self._obstacles)
140
+
141
+ def clear(self) -> None:
142
+ """Clear all obstacles and reset grid."""
143
+ self._obstacles.clear()
144
+ for r in range(self.rows):
145
+ for c in range(self.cols):
146
+ self._grid[r][c] = CellState.UNKNOWN
147
+
148
+ # ── queries ───────────────────────────────────────────────────
149
+
150
+ def is_occupied(self, x: float, y: float) -> bool:
151
+ """Check if position (x, y) is occupied."""
152
+ return self.get_cell(x, y) == CellState.OCCUPIED
153
+
154
+ def is_free(self, x: float, y: float) -> bool:
155
+ """Check if position (x, y) is known free."""
156
+ return self.get_cell(x, y) == CellState.FREE
157
+
158
+ def nearest_obstacle(self, x: float, y: float) -> Optional[Obstacle]:
159
+ """Return the nearest obstacle to (x, y), or None."""
160
+ if not self._obstacles:
161
+ return None
162
+ return min(self._obstacles, key=lambda o: math.hypot(o.x - x, o.y - y))
163
+
164
+ def obstacles_in_radius(self, x: float, y: float, radius: float) -> list[Obstacle]:
165
+ """Return obstacles within *radius* meters of (x, y)."""
166
+ return [o for o in self._obstacles if math.hypot(o.x - x, o.y - y) <= radius]
167
+
168
+ def distance_to_nearest(self, x: float, y: float) -> float:
169
+ """Distance to the nearest occupied cell or obstacle."""
170
+ obs = self.nearest_obstacle(x, y)
171
+ if obs is None:
172
+ return float("inf")
173
+ return math.hypot(obs.x - x, obs.y - y)
174
+
175
+ # ── ray casting ───────────────────────────────────────────────
176
+
177
+ def ray_cast(self, ox: float, oy: float, angle_deg: float, max_dist: float) -> Optional[tuple[float, float]]:
178
+ """Cast a ray from (ox, oy) at *angle_deg* and return first hit (x, y) or None.
179
+
180
+ Uses DDA-style stepping at cell resolution.
181
+ """
182
+ angle_rad = math.radians(angle_deg)
183
+ dx = math.cos(angle_rad) * self.resolution * 0.5
184
+ dy = math.sin(angle_rad) * self.resolution * 0.5
185
+ steps = int(max_dist / (self.resolution * 0.5))
186
+ cx, cy = ox, oy
187
+ for _ in range(steps):
188
+ if self.is_occupied(cx, cy):
189
+ return (cx, cy)
190
+ # Also check out of bounds
191
+ row, col = self._world_to_cell(cx, cy)
192
+ if not self._in_bounds(row, col):
193
+ return None
194
+ cx += dx
195
+ cy += dy
196
+ return None
197
+
198
+ # ── statistics ────────────────────────────────────────────────
199
+
200
+ def occupancy_count(self) -> dict[CellState, int]:
201
+ """Count cells by state."""
202
+ counts = {s: 0 for s in CellState}
203
+ for row in self._grid:
204
+ for cell in row:
205
+ counts[cell] += 1
206
+ return counts
207
+
208
+ def coverage(self) -> float:
209
+ """Fraction of map that is explored (FREE + OCCUPIED)."""
210
+ total = self.rows * self.cols
211
+ if total == 0:
212
+ return 0.0
213
+ counts = self.occupancy_count()
214
+ explored = counts[CellState.FREE] + counts[CellState.OCCUPIED]
215
+ return explored / total
216
+
217
+ # ── mark free from sonar sweep ────────────────────────────────
218
+
219
+ def mark_free_ray(self, ox: float, oy: float, angle_deg: float, distance: float) -> None:
220
+ """Mark cells along a ray as FREE up to *distance* meters."""
221
+ angle_rad = math.radians(angle_deg)
222
+ steps = max(1, int(distance / self.resolution))
223
+ for i in range(steps):
224
+ t = (i + 0.5) * self.resolution
225
+ if t > distance:
226
+ break
227
+ wx = ox + t * math.cos(angle_rad)
228
+ wy = oy + t * math.sin(angle_rad)
229
+ if self.get_cell(wx, wy) != CellState.OCCUPIED:
230
+ self.set_cell(wx, wy, CellState.FREE)