sonar-vision 0.1.0__py3-none-any.whl

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,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)
sonar_vision/map.py ADDED
@@ -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)
sonar_vision/signal.py ADDED
@@ -0,0 +1,206 @@
1
+ """Signal processing — sampling, filtering, and frequency analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from typing import Sequence
8
+
9
+
10
+ @dataclass
11
+ class Signal:
12
+ """A discrete-time signal with uniform sampling.
13
+
14
+ Attributes:
15
+ samples: Amplitude values.
16
+ sample_rate: Samples per second (Hz).
17
+ """
18
+
19
+ samples: list[float] = field(default_factory=list)
20
+ sample_rate: float = 44100.0
21
+
22
+ # ── constructors ──────────────────────────────────────────────
23
+
24
+ @classmethod
25
+ def sine(cls, frequency: float, duration: float, sample_rate: float = 44100.0, amplitude: float = 1.0) -> Signal:
26
+ """Generate a sine-wave signal."""
27
+ n = int(sample_rate * duration)
28
+ samples = [amplitude * math.sin(2.0 * math.pi * frequency * i / sample_rate) for i in range(n)]
29
+ return cls(samples=samples, sample_rate=sample_rate)
30
+
31
+ @classmethod
32
+ def noise(cls, duration: float, sample_rate: float = 44100.0, amplitude: float = 1.0, seed: int | None = None) -> Signal:
33
+ """Generate uniform random noise."""
34
+ import random
35
+ rng = random.Random(seed)
36
+ n = int(sample_rate * duration)
37
+ samples = [amplitude * (rng.random() * 2.0 - 1.0) for _ in range(n)]
38
+ return cls(samples=samples, sample_rate=sample_rate)
39
+
40
+ @classmethod
41
+ def chirp(cls, f0: float, f1: float, duration: float, sample_rate: float = 44100.0, amplitude: float = 1.0) -> Signal:
42
+ """Generate a linear chirp from *f0* to *f1* Hz."""
43
+ n = int(sample_rate * duration)
44
+ rate = (f1 - f0) / duration
45
+ samples = [
46
+ amplitude * math.sin(2.0 * math.pi * (f0 * t + 0.5 * rate * t * t))
47
+ for t in (i / sample_rate for i in range(n))
48
+ ]
49
+ return cls(samples=samples, sample_rate=sample_rate)
50
+
51
+ # ── properties ────────────────────────────────────────────────
52
+
53
+ @property
54
+ def duration(self) -> float:
55
+ """Signal duration in seconds."""
56
+ return len(self.samples) / self.sample_rate if self.sample_rate else 0.0
57
+
58
+ @property
59
+ def n_samples(self) -> int:
60
+ return len(self.samples)
61
+
62
+ # ── sampling / resampling ─────────────────────────────────────
63
+
64
+ def resample(self, target_rate: float) -> Signal:
65
+ """Linear-interpolation resample to *target_rate*."""
66
+ if target_rate <= 0 or not self.samples:
67
+ return Signal(samples=[], sample_rate=target_rate)
68
+ ratio = self.sample_rate / target_rate
69
+ new_n = max(1, int(len(self.samples) / ratio))
70
+ new_samples: list[float] = []
71
+ for i in range(new_n):
72
+ pos = i * ratio
73
+ idx = int(pos)
74
+ frac = pos - idx
75
+ if idx + 1 < len(self.samples):
76
+ new_samples.append(self.samples[idx] * (1.0 - frac) + self.samples[idx + 1] * frac)
77
+ else:
78
+ new_samples.append(self.samples[min(idx, len(self.samples) - 1)])
79
+ return Signal(samples=new_samples, sample_rate=target_rate)
80
+
81
+ # ── filtering ─────────────────────────────────────────────────
82
+
83
+ def lowpass(self, cutoff: float, order: int = 5) -> Signal:
84
+ """Simple moving-average low-pass filter (approximation).
85
+
86
+ Window size is derived from *cutoff* relative to Nyquist.
87
+ """
88
+ nyquist = self.sample_rate / 2.0
89
+ if cutoff <= 0 or cutoff >= nyquist:
90
+ return Signal(samples=list(self.samples), sample_rate=self.sample_rate)
91
+ window = max(1, int(nyquist / cutoff) * order)
92
+ window = min(window, len(self.samples))
93
+ if window <= 1:
94
+ return Signal(samples=list(self.samples), sample_rate=self.sample_rate)
95
+ out: list[float] = []
96
+ half = window // 2
97
+ for i in range(len(self.samples)):
98
+ start = max(0, i - half)
99
+ end = min(len(self.samples), i + half + 1)
100
+ out.append(sum(self.samples[start:end]) / (end - start))
101
+ return Signal(samples=out, sample_rate=self.sample_rate)
102
+
103
+ def highpass(self, cutoff: float, order: int = 5) -> Signal:
104
+ """Cascaded high-pass by subtracting low-pass from original."""
105
+ lp = self.lowpass(cutoff, order)
106
+ return Signal(
107
+ samples=[s - l for s, l in zip(self.samples, lp.samples)],
108
+ sample_rate=self.sample_rate,
109
+ )
110
+
111
+ def bandpass(self, low_cutoff: float, high_cutoff: float, order: int = 5) -> Signal:
112
+ """Band-pass via lowpass then highpass."""
113
+ return self.lowpass(high_cutoff, order).highpass(low_cutoff, order)
114
+
115
+ def threshold(self, level: float) -> Signal:
116
+ """Hard threshold — samples below *level* in absolute value become 0."""
117
+ return Signal(
118
+ samples=[s if abs(s) >= level else 0.0 for s in self.samples],
119
+ sample_rate=self.sample_rate,
120
+ )
121
+
122
+ def envelope(self) -> Signal:
123
+ """Simple magnitude envelope via sliding maximum of absolute value."""
124
+ if not self.samples:
125
+ return Signal(sample_rate=self.sample_rate)
126
+ window = max(1, int(self.sample_rate * 0.005)) # 5 ms window
127
+ out: list[float] = []
128
+ for i in range(len(self.samples)):
129
+ start = max(0, i - window)
130
+ out.append(max(abs(s) for s in self.samples[start : i + 1]))
131
+ return Signal(samples=out, sample_rate=self.sample_rate)
132
+
133
+ # ── analysis ──────────────────────────────────────────────────
134
+
135
+ def rms(self) -> float:
136
+ """Root-mean-square amplitude."""
137
+ if not self.samples:
138
+ return 0.0
139
+ return math.sqrt(sum(s * s for s in self.samples) / len(self.samples))
140
+
141
+ def peak(self) -> float:
142
+ """Peak (max absolute) amplitude."""
143
+ if not self.samples:
144
+ return 0.0
145
+ return max(abs(s) for s in self.samples)
146
+
147
+ def snr_db(self, noise: Signal) -> float:
148
+ """Signal-to-noise ratio in dB relative to *noise*."""
149
+ sig_power = self.rms()
150
+ noise_power = noise.rms()
151
+ if noise_power == 0.0:
152
+ return float("inf") if sig_power > 0 else 0.0
153
+ return 20.0 * math.log10(sig_power / noise_power)
154
+
155
+ def energy(self) -> float:
156
+ """Total signal energy."""
157
+ return sum(s * s for s in self.samples)
158
+
159
+ def dominant_frequency(self) -> float:
160
+ """Estimate dominant frequency via zero-crossing count."""
161
+ if len(self.samples) < 2:
162
+ return 0.0
163
+ crossings = 0
164
+ for i in range(1, len(self.samples)):
165
+ if self.samples[i - 1] * self.samples[i] < 0:
166
+ crossings += 1
167
+ return (crossings / 2.0) * self.sample_rate / len(self.samples)
168
+
169
+ def dft_magnitude(self, n: int | None = None) -> list[float]:
170
+ """Compute DFT magnitude spectrum (naïve O(N²), for small signals).
171
+
172
+ Returns list of magnitudes for bins 0 … N/2.
173
+ """
174
+ N = n or len(self.samples)
175
+ N = min(N, len(self.samples))
176
+ magnitudes: list[float] = []
177
+ half = N // 2 + 1
178
+ for k in range(half):
179
+ re = 0.0
180
+ im = 0.0
181
+ for n_idx in range(N):
182
+ angle = 2.0 * math.pi * k * n_idx / N
183
+ re += self.samples[n_idx] * math.cos(angle)
184
+ im -= self.samples[n_idx] * math.sin(angle)
185
+ magnitudes.append(math.sqrt(re * re + im * im) / N)
186
+ return magnitudes
187
+
188
+ # ── arithmetic ────────────────────────────────────────────────
189
+
190
+ def __add__(self, other: Signal) -> Signal:
191
+ if self.sample_rate != other.sample_rate:
192
+ raise ValueError("Sample rates must match for addition")
193
+ n = max(len(self.samples), len(other.samples))
194
+ a = self.samples + [0.0] * (n - len(self.samples))
195
+ b = other.samples + [0.0] * (n - len(other.samples))
196
+ return Signal(samples=[x + y for x, y in zip(a, b)], sample_rate=self.sample_rate)
197
+
198
+ def __mul__(self, other: float | Signal) -> Signal:
199
+ if isinstance(other, Signal):
200
+ if self.sample_rate != other.sample_rate:
201
+ raise ValueError("Sample rates must match")
202
+ return Signal(
203
+ samples=[a * b for a, b in zip(self.samples, other.samples)],
204
+ sample_rate=self.sample_rate,
205
+ )
206
+ return Signal(samples=[s * other for s in self.samples], sample_rate=self.sample_rate)
sonar_vision/sonar.py ADDED
@@ -0,0 +1,173 @@
1
+ """Sonar — ping/echo simulation and distance estimation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional
8
+
9
+ from sonar_vision.signal import Signal
10
+
11
+
12
+ # Default speed of sound in water (m/s)
13
+ SOUND_SPEED_WATER: float = 1500.0
14
+ SOUND_SPEED_AIR: float = 343.0
15
+
16
+
17
+ @dataclass
18
+ class PingResult:
19
+ """Result of a sonar ping.
20
+
21
+ Attributes:
22
+ distance: Estimated distance in meters.
23
+ travel_time: Round-trip time in seconds.
24
+ signal_strength: Received signal amplitude (0–1 normalised).
25
+ snr_db: Signal-to-noise ratio in decibels.
26
+ frequency: Ping frequency in Hz.
27
+ """
28
+
29
+ distance: float
30
+ travel_time: float
31
+ signal_strength: float
32
+ snr_db: float
33
+ frequency: float
34
+
35
+
36
+ @dataclass
37
+ class Sonar:
38
+ """Active sonar model with configurable medium and transducer.
39
+
40
+ Attributes:
41
+ sound_speed: Speed of sound in the medium (m/s).
42
+ frequency: Transducer centre frequency (Hz).
43
+ pulse_duration: Transmit pulse length (seconds).
44
+ max_range: Maximum detectable range (meters).
45
+ beam_width: Transducer beam width (degrees).
46
+ source_level: Transmit source level (arbitrary dB scale).
47
+ noise_level: Ambient noise floor (arbitrary dB scale).
48
+ """
49
+
50
+ sound_speed: float = SOUND_SPEED_WATER
51
+ frequency: float = 50000.0
52
+ pulse_duration: float = 0.001
53
+ max_range: float = 1000.0
54
+ beam_width: float = 30.0
55
+ source_level: float = 200.0
56
+ noise_level: float = 60.0
57
+
58
+ # ── transmit ──────────────────────────────────────────────────
59
+
60
+ def generate_ping(self, sample_rate: float = 44100.0) -> Signal:
61
+ """Generate a transmit pulse (tone burst)."""
62
+ return Signal.sine(
63
+ frequency=self.frequency,
64
+ duration=self.pulse_duration,
65
+ sample_rate=sample_rate,
66
+ )
67
+
68
+ # ── propagation ───────────────────────────────────────────────
69
+
70
+ def round_trip_time(self, distance: float) -> float:
71
+ """Compute round-trip travel time for a given *distance*."""
72
+ return 2.0 * distance / self.sound_speed
73
+
74
+ def distance_from_time(self, travel_time: float) -> float:
75
+ """Estimate distance from measured round-trip *travel_time*."""
76
+ return travel_time * self.sound_speed / 2.0
77
+
78
+ def spreading_loss(self, distance: float) -> float:
79
+ """Cylindrical + spherical spreading loss in dB (simplified)."""
80
+ if distance <= 0:
81
+ return 0.0
82
+ return 20.0 * math.log10(max(distance, 1e-12))
83
+
84
+ def absorption_loss(self, distance: float, absorption_db_km: float = 10.0) -> float:
85
+ """Absorption loss in dB using a constant dB/km coefficient.
86
+
87
+ The coefficient is configurable but does not vary with frequency in
88
+ this simplified model; pass an appropriate value for the transducer
89
+ frequency and water conditions.
90
+ """
91
+ return absorption_db_km * distance / 1000.0
92
+
93
+ def total_loss(self, distance: float, absorption_db_km: float = 10.0) -> float:
94
+ """Total one-way transmission loss (spreading + absorption) in dB."""
95
+ return self.spreading_loss(distance) + self.absorption_loss(distance, absorption_db_km)
96
+
97
+ # ── detect ────────────────────────────────────────────────────
98
+
99
+ def ping(self, distance: float, target_strength: float = -20.0) -> PingResult:
100
+ """Simulate a ping at *distance* and return detection result.
101
+
102
+ Models two-way transmission loss (outgoing plus return path) plus
103
+ target strength. Returns ``PingResult`` with a distance estimate
104
+ that includes small stochastic jitter proportional to range.
105
+ """
106
+ if distance <= 0 or distance > self.max_range:
107
+ return PingResult(
108
+ distance=float("nan"),
109
+ travel_time=float("nan"),
110
+ signal_strength=0.0,
111
+ snr_db=-float("inf"),
112
+ frequency=self.frequency,
113
+ )
114
+
115
+ rtt = self.round_trip_time(distance)
116
+ # Active sonar: sound travels to the target and back, so use two-way loss.
117
+ two_way_loss = 2.0 * self.total_loss(distance)
118
+ received_level = self.source_level - two_way_loss + target_strength
119
+ snr = received_level - self.noise_level
120
+ strength = max(0.0, min(1.0, 1.0 / (1.0 + math.exp(-(snr - 6.0) / 3.0))))
121
+
122
+ # Small measurement jitter proportional to range
123
+ import random
124
+ rng = random.Random()
125
+ jitter = rng.gauss(0, 0.005 * distance)
126
+ estimated_distance = distance + jitter
127
+ estimated_rtt = self.round_trip_time(estimated_distance)
128
+
129
+ return PingResult(
130
+ distance=estimated_distance,
131
+ travel_time=estimated_rtt,
132
+ signal_strength=strength,
133
+ snr_db=snr,
134
+ frequency=self.frequency,
135
+ )
136
+
137
+ def ping_return_signal(
138
+ self,
139
+ distance: float,
140
+ target_strength: float = -20.0,
141
+ sample_rate: float = 44100.0,
142
+ ) -> Signal:
143
+ """Generate a synthetic echo return signal for a target.
144
+
145
+ Returns a ``Signal`` containing transmit pulse, silence, and attenuated echo.
146
+ """
147
+ if distance <= 0 or distance > self.max_range:
148
+ return Signal(sample_rate=sample_rate)
149
+
150
+ tx = self.generate_ping(sample_rate)
151
+ rtt_samples = int(self.round_trip_time(distance) * sample_rate)
152
+
153
+ # Echo is attenuated by the two-way propagation path; target strength
154
+ # increases received level, so it reduces the effective loss.
155
+ loss_db = 2.0 * self.total_loss(distance) - target_strength
156
+ gain = 10.0 ** (-loss_db / 20.0)
157
+ gain = max(0.0, min(1.0, gain))
158
+ echo_samples = [s * gain for s in tx.samples]
159
+
160
+ # Build return: tx pulse → silence → echo
161
+ silence_len = max(0, rtt_samples - len(tx.samples))
162
+ combined = tx.samples + [0.0] * silence_len + echo_samples
163
+ return Signal(samples=combined, sample_rate=sample_rate)
164
+
165
+ # ── beam geometry ─────────────────────────────────────────────
166
+
167
+ def in_beam(self, bearing_offset_deg: float) -> bool:
168
+ """Check whether a target at *bearing_offset_deg* from boresight is within the beam."""
169
+ return abs(bearing_offset_deg) <= self.beam_width / 2.0
170
+
171
+ def beam_coverage(self) -> float:
172
+ """Return the angular coverage in degrees of the beam."""
173
+ return self.beam_width
@@ -0,0 +1,227 @@
1
+ """ObjectTracker — multi-object tracking with motion prediction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import time as _time
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional
9
+
10
+
11
+ @dataclass
12
+ class Track:
13
+ """A tracked object.
14
+
15
+ Attributes:
16
+ track_id: Unique integer ID.
17
+ x: Current estimated X position (m).
18
+ y: Current estimated Y position (m).
19
+ vx: Estimated X velocity (m/s).
20
+ vy: Estimated Y velocity (m/s).
21
+ last_seen: Timestamp of last update.
22
+ detections: Number of detections received.
23
+ label: Optional semantic label.
24
+ """
25
+
26
+ track_id: int
27
+ x: float = 0.0
28
+ y: float = 0.0
29
+ vx: float = 0.0
30
+ vy: float = 0.0
31
+ last_seen: float = 0.0
32
+ detections: int = 1
33
+ label: str = ""
34
+
35
+
36
+ @dataclass
37
+ class Detection:
38
+ """A single detection observation.
39
+
40
+ Attributes:
41
+ x: Measured X position (m).
42
+ y: Measured Y position (m).
43
+ timestamp: Observation time (epoch seconds). None means use current time.
44
+ confidence: Detection confidence (0–1).
45
+ label: Optional label.
46
+ """
47
+
48
+ x: float
49
+ y: float
50
+ timestamp: float | None = None
51
+ confidence: float = 1.0
52
+ label: str = ""
53
+
54
+
55
+ @dataclass
56
+ class ObjectTracker:
57
+ """Simple multi-object tracker with nearest-neighbour association.
58
+
59
+ Attributes:
60
+ association_gate: Maximum distance for associating a detection
61
+ with an existing track (meters).
62
+ max_velocity: Assumed maximum object velocity (m/s) — used
63
+ for gating and prediction clamping.
64
+ lost_timeout: Seconds without detection before a track is
65
+ considered lost.
66
+ next_id: Counter for assigning track IDs.
67
+ """
68
+
69
+ association_gate: float = 5.0
70
+ max_velocity: float = 10.0
71
+ lost_timeout: float = 5.0
72
+ next_id: int = 1
73
+ _tracks: dict[int, Track] = field(default_factory=dict)
74
+
75
+ # ── track management ──────────────────────────────────────────
76
+
77
+ @property
78
+ def tracks(self) -> list[Track]:
79
+ """Return all active tracks."""
80
+ return list(self._tracks.values())
81
+
82
+ def get_track(self, track_id: int) -> Optional[Track]:
83
+ return self._tracks.get(track_id)
84
+
85
+ def active_tracks(self, now: float | None = None) -> list[Track]:
86
+ """Return tracks that have been seen within *lost_timeout*."""
87
+ now = now or _time.time()
88
+ return [t for t in self._tracks.values() if (now - t.last_seen) <= self.lost_timeout]
89
+
90
+ def lost_tracks(self, now: float | None = None) -> list[Track]:
91
+ """Return tracks that have timed out."""
92
+ now = now or _time.time()
93
+ return [t for t in self._tracks.values() if (now - t.last_seen) > self.lost_timeout]
94
+
95
+ def remove_track(self, track_id: int) -> None:
96
+ self._tracks.pop(track_id, None)
97
+
98
+ def prune_lost(self, now: float | None = None) -> int:
99
+ """Remove all lost tracks. Returns count removed."""
100
+ lost = self.lost_tracks(now)
101
+ for t in lost:
102
+ del self._tracks[t.track_id]
103
+ return len(lost)
104
+
105
+ # ── update ────────────────────────────────────────────────────
106
+
107
+ def _create_track(self, det: Detection) -> Track:
108
+ tid = self.next_id
109
+ self.next_id += 1
110
+ t = Track(
111
+ track_id=tid,
112
+ x=det.x,
113
+ y=det.y,
114
+ last_seen=det.timestamp,
115
+ label=det.label,
116
+ )
117
+ self._tracks[tid] = t
118
+ return t
119
+
120
+ def _update_track(self, track: Track, det: Detection) -> None:
121
+ dt = det.timestamp - track.last_seen
122
+ if dt > 0:
123
+ new_vx = (det.x - track.x) / dt
124
+ new_vy = (det.y - track.y) / dt
125
+ # Clamp velocity
126
+ speed = math.hypot(new_vx, new_vy)
127
+ if speed > self.max_velocity:
128
+ scale = self.max_velocity / speed
129
+ new_vx *= scale
130
+ new_vy *= scale
131
+ # Exponential smoothing on velocity
132
+ alpha = 0.5
133
+ track.vx = alpha * new_vx + (1.0 - alpha) * track.vx
134
+ track.vy = alpha * new_vy + (1.0 - alpha) * track.vy
135
+ track.x = det.x
136
+ track.y = det.y
137
+ track.last_seen = det.timestamp
138
+ track.detections += 1
139
+
140
+ def update(self, detections: list[Detection], now: float | None = None) -> list[int]:
141
+ """Process a batch of detections.
142
+
143
+ Returns list of track IDs that were updated or created.
144
+ """
145
+ now = now or _time.time()
146
+ updated_ids: list[int] = []
147
+
148
+ # Simple greedy nearest-neighbour association
149
+ used_tracks: set[int] = set()
150
+ # Sort detections by confidence descending for greedy matching
151
+ sorted_dets = sorted(detections, key=lambda d: d.confidence, reverse=True)
152
+
153
+ for det in sorted_dets:
154
+ if det.timestamp is None:
155
+ det.timestamp = now
156
+ best_id: Optional[int] = None
157
+ best_dist = self.association_gate
158
+ for t in self._tracks.values():
159
+ if t.track_id in used_tracks:
160
+ continue
161
+ # Predict position
162
+ dt = det.timestamp - t.last_seen
163
+ px = t.x + t.vx * dt
164
+ py = t.y + t.vy * dt
165
+ d = math.hypot(px - det.x, py - det.y)
166
+ if d < best_dist:
167
+ best_dist = d
168
+ best_id = t.track_id
169
+
170
+ if best_id is not None:
171
+ self._update_track(self._tracks[best_id], det)
172
+ used_tracks.add(best_id)
173
+ updated_ids.append(best_id)
174
+ else:
175
+ t = self._create_track(det)
176
+ updated_ids.append(t.track_id)
177
+
178
+ return updated_ids
179
+
180
+ # ── prediction ────────────────────────────────────────────────
181
+
182
+ def predict(self, track_id: int, dt: float) -> Optional[tuple[float, float]]:
183
+ """Predict position of track *track_id* *dt* seconds into the future.
184
+
185
+ Returns (x, y) or None if track doesn't exist.
186
+ """
187
+ t = self._tracks.get(track_id)
188
+ if t is None:
189
+ return None
190
+ return (t.x + t.vx * dt, t.y + t.vy * dt)
191
+
192
+ def predict_all(self, dt: float) -> dict[int, tuple[float, float]]:
193
+ """Predict positions of all tracks *dt* seconds ahead."""
194
+ return {
195
+ tid: (t.x + t.vx * dt, t.y + t.vy * dt)
196
+ for tid, t in self._tracks.items()
197
+ }
198
+
199
+ # ── queries ───────────────────────────────────────────────────
200
+
201
+ def nearest_track(self, x: float, y: float, now: float | None = None) -> Optional[Track]:
202
+ """Return the nearest active track to (x, y)."""
203
+ active = self.active_tracks(now)
204
+ if not active:
205
+ return None
206
+ return min(active, key=lambda t: math.hypot(t.x - x, t.y - y))
207
+
208
+ def tracks_in_radius(self, x: float, y: float, radius: float, now: float | None = None) -> list[Track]:
209
+ """Return active tracks within *radius* meters of (x, y)."""
210
+ return [
211
+ t for t in self.active_tracks(now)
212
+ if math.hypot(t.x - x, t.y - y) <= radius
213
+ ]
214
+
215
+ def speed(self, track_id: int) -> float:
216
+ """Speed of a track in m/s."""
217
+ t = self._tracks.get(track_id)
218
+ if t is None:
219
+ return 0.0
220
+ return math.hypot(t.vx, t.vy)
221
+
222
+ def heading(self, track_id: int) -> float:
223
+ """Heading of a track in degrees (0 = east, 90 = north)."""
224
+ t = self._tracks.get(track_id)
225
+ if t is None or (t.vx == 0 and t.vy == 0):
226
+ return 0.0
227
+ return math.degrees(math.atan2(t.vy, t.vx)) % 360.0
@@ -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,11 @@
1
+ sonar_vision/__init__.py,sha256=cBkyRSBw7J8EfvvkdxtFUoDiwV--9CG69_jqe7e4pgE,584
2
+ sonar_vision/display.py,sha256=fRsZq4P_fA6W9CqzMjOTl_jfD20eYK2uxR4n0ddaJOw,6251
3
+ sonar_vision/map.py,sha256=ewAmIjttyMj9LRS727obNR7osHm8EhbsMAFEY2fMshc,9350
4
+ sonar_vision/signal.py,sha256=07xj_JPIfQNXOSAJwPKggiMmsXdc7GP-XuD04AZ7PaM,8921
5
+ sonar_vision/sonar.py,sha256=Qw-rSIaUgaobKiDu3zr1p4kQ1BJVG9U31FoBQJdzVws,6835
6
+ sonar_vision/tracker.py,sha256=mWZFRYFdCjFu1fDVup_5z2YSeDRQUVkkiYDFo-BtF7w,8011
7
+ sonar_vision-0.1.0.dist-info/licenses/LICENSE,sha256=J5l0Qtagxszkc1jxXLBi6hMd6dutpirm_6GQAnVMlnY,1075
8
+ sonar_vision-0.1.0.dist-info/METADATA,sha256=8M4FRkMNFYstIcEwlIBMFdV55vUSOczs4uuJJZVZkas,317
9
+ sonar_vision-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
10
+ sonar_vision-0.1.0.dist-info/top_level.txt,sha256=NJjD5ZYQRVPR9YgDoHsXxwczqNRxAxaqOUfjmIhuScE,13
11
+ sonar_vision-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ sonar_vision