mirror of
https://github.com/smittix/intercept.git
synced 2026-07-09 10:08:12 -07:00
docs: add signal identification design spec
Spec for bundled JSON signal database (~500 entries), POST /signalid/match route with 4-criterion scorer, and modal overlay accessible from waterfall and global nav. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
|||||||
|
# Signal Identification — Design Spec
|
||||||
|
**Date:** 2026-07-02
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Extend Intercept's existing signal identification capability with a bundled local signal database (~500 signals) and a rich modal overlay that works both from the waterfall (pre-populated) and standalone from the global nav (manual entry). The goal is Artemis-like offline signal lookup integrated directly into Intercept's workflow — no manual browsing required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Intercept already has two signal identification mechanisms:
|
||||||
|
|
||||||
|
| Mechanism | Location | Coverage | Offline? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Local heuristic engine | `utils/signal_guess.py` | ~20 signal types | Yes |
|
||||||
|
| SigID Wiki API proxy | `routes/signalid.py` | ~500+ signals | No |
|
||||||
|
|
||||||
|
Both surface results in the waterfall sidebar Signal ID panel. The heuristic engine is limited in coverage; the Wiki proxy requires internet and has latency. This design replaces the heuristic engine's role with a bundled database and adds a richer modal UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
data/signals.json ← bundled signal database (~500 entries)
|
||||||
|
↓ loaded at startup, cached in memory
|
||||||
|
routes/signalid.py ← new POST /signalid/match route
|
||||||
|
↓ JSON response with ranked matches + match_reasons
|
||||||
|
static/js/signal-id-modal.js ← standalone modal component
|
||||||
|
↑ called from waterfall.js (pre-populated) and nav (blank)
|
||||||
|
```
|
||||||
|
|
||||||
|
### What stays unchanged
|
||||||
|
- `utils/signal_guess.py` — left in place but no longer called from the new modal. Its role is superseded by the database-driven matcher. The existing `/receiver/signal/guess` route remains registered.
|
||||||
|
- `routes/signalid.py /signalid/sigidwiki` — untouched. SigID Wiki links in the modal results make it redundant as a parallel lookup; matched results carry a `sigidwiki_url` field instead.
|
||||||
|
- Waterfall SSE streaming, SDR process management, all other routes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
**File:** `data/signals.json`
|
||||||
|
**Format:** JSON array of signal objects
|
||||||
|
**Source:** Seeded from SigID Wiki (CC BY-SA), hand-curated, version-controlled in the repo
|
||||||
|
|
||||||
|
### Signal object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "fm-broadcast",
|
||||||
|
"name": "FM Broadcast Radio",
|
||||||
|
"description": "Commercial FM radio stations. Wideband stereo audio, typically 87.5–108 MHz. Used worldwide for public broadcasting.",
|
||||||
|
"categories": ["broadcast", "commercial", "audio"],
|
||||||
|
"frequency_ranges": [
|
||||||
|
{ "min_hz": 87500000, "max_hz": 108000000 }
|
||||||
|
],
|
||||||
|
"bandwidth_range": { "min_hz": 150000, "max_hz": 250000 },
|
||||||
|
"modulations": ["WFM", "FM"],
|
||||||
|
"regions": ["GLOBAL"],
|
||||||
|
"sigidwiki_url": "https://www.sigidwiki.com/wiki/FM_Broadcast"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field definitions
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | string | yes | Unique kebab-case slug |
|
||||||
|
| `name` | string | yes | Display name |
|
||||||
|
| `description` | string | yes | Plain-English, one paragraph max |
|
||||||
|
| `categories` | string[] | yes | e.g. `broadcast`, `aviation`, `maritime`, `utility`, `amateur`, `military`, `telemetry` |
|
||||||
|
| `frequency_ranges` | `{min_hz, max_hz}`[] | yes | List — some signals appear on multiple non-contiguous bands |
|
||||||
|
| `bandwidth_range` | `{min_hz, max_hz}` \| null | yes | Null if unknown or highly variable |
|
||||||
|
| `modulations` | string[] | yes | Uppercase tokens matching waterfall mode selector: `WFM`, `FM`, `AM`, `USB`, `LSB`, `FSK`, `OOK`, `PSK`, etc. |
|
||||||
|
| `regions` | string[] | yes | `GLOBAL`, `EU`, `US`, `UK`, `AU` — used to deprioritise region-mismatched results |
|
||||||
|
| `sigidwiki_url` | string \| null | yes | Direct link shown as "View reference" in modal. Null if no page exists. |
|
||||||
|
|
||||||
|
### Initial seed
|
||||||
|
The ~20 signals already in `utils/signal_guess.py` are migrated to this schema as the starting point. The file grows to ~500 signals seeded from SigID Wiki structured data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend: `/signalid/match`
|
||||||
|
|
||||||
|
**Route:** `POST /signalid/match` added to the existing `signalid_bp` blueprint in `routes/signalid.py`
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"frequency_mhz": 100.1,
|
||||||
|
"bandwidth_hz": 200000,
|
||||||
|
"modulation": "WFM",
|
||||||
|
"limit": 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Default | Constraints |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `frequency_mhz` | yes | — | > 0 |
|
||||||
|
| `bandwidth_hz` | no | null | > 0 if provided |
|
||||||
|
| `modulation` | no | null | Truncated to 16 chars, uppercased |
|
||||||
|
| `limit` | no | 8 | Clamped to 1–20 |
|
||||||
|
|
||||||
|
### Matching algorithm
|
||||||
|
|
||||||
|
**Step 1 — Hard filter (frequency overlap)**
|
||||||
|
Discard any signal where the query frequency does not fall within at least one `frequency_range`. Reduces ~500 candidates to typically 3–15.
|
||||||
|
|
||||||
|
**Step 2 — Score each candidate (0–100 points)**
|
||||||
|
|
||||||
|
| Criterion | Max pts | Logic |
|
||||||
|
|---|---|---|
|
||||||
|
| Frequency centrality | 40 | How centred the query is within the matched range. Dead centre = 40, at the range boundary = 10. |
|
||||||
|
| Bandwidth match | 30 | Only if `bandwidth_hz` provided. Within `bandwidth_range` = 30, within 2× = 15, outside = 0. Signal has null `bandwidth_range` = 10 (neutral). If `bandwidth_hz` not provided, all signals score 15 (neutral). |
|
||||||
|
| Modulation match | 20 | Query modulation in signal's `modulations` = 20. No modulation provided = 10 (neutral). Mismatch = 0. |
|
||||||
|
| Region match | 10 | Signal `regions` includes user's configured region or `GLOBAL` = 10, else 5. User region read from `config.py` (`INTERCEPT_REGION`, default `GLOBAL`). |
|
||||||
|
|
||||||
|
**Step 3 — Sort and annotate**
|
||||||
|
Sort by score descending. Attach `match_reasons` list to each result (e.g. `["frequency: centre of range", "bandwidth: within typical", "modulation: exact match"]`). Return top `limit` results.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"frequency_mhz": 100.1,
|
||||||
|
"bandwidth_hz": 200000,
|
||||||
|
"modulation": "WFM",
|
||||||
|
"matches": [
|
||||||
|
{
|
||||||
|
"id": "fm-broadcast",
|
||||||
|
"name": "FM Broadcast Radio",
|
||||||
|
"description": "Commercial FM radio stations...",
|
||||||
|
"categories": ["broadcast", "commercial", "audio"],
|
||||||
|
"frequency_ranges": [{ "min_hz": 87500000, "max_hz": 108000000 }],
|
||||||
|
"bandwidth_range": { "min_hz": 150000, "max_hz": 250000 },
|
||||||
|
"modulations": ["WFM", "FM"],
|
||||||
|
"regions": ["GLOBAL"],
|
||||||
|
"sigidwiki_url": "https://www.sigidwiki.com/wiki/FM_Broadcast",
|
||||||
|
"score": 87,
|
||||||
|
"match_reasons": [
|
||||||
|
"frequency: centre of range",
|
||||||
|
"bandwidth: within typical",
|
||||||
|
"modulation: exact match"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"match_count": 1,
|
||||||
|
"cached": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
Results cached in-process for 60 seconds keyed by `{frequency_mhz}|{bandwidth_hz}|{modulation}|{limit}`. The database itself is loaded once at startup and never re-read during a session.
|
||||||
|
|
||||||
|
### Error cases
|
||||||
|
- `frequency_mhz` missing or invalid → 400
|
||||||
|
- `data/signals.json` missing or malformed at startup → route returns 503 with message
|
||||||
|
- No matches → 200 with `matches: []`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend: Modal Component
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `static/js/signal-id-modal.js` — standalone IIFE module (`SignalIdModal`)
|
||||||
|
- Modal HTML injected into DOM on first call (not in any template)
|
||||||
|
- Styled with existing CSS variables, no new stylesheet required
|
||||||
|
|
||||||
|
### Entry points
|
||||||
|
|
||||||
|
**From the waterfall** (`waterfall.js`):
|
||||||
|
```js
|
||||||
|
SignalIdModal.open({ frequency_mhz: _monitorFreqMhz, modulation: _getMonitorMode() });
|
||||||
|
```
|
||||||
|
Replaces the current inline Signal ID sidebar panel. The "Identify Signal" button in the waterfall sidebar triggers this.
|
||||||
|
|
||||||
|
**From the global nav** (`templates/partials/nav.html`):
|
||||||
|
A "Signal ID" nav link calls `SignalIdModal.open({})` — opens with blank fields.
|
||||||
|
|
||||||
|
### Modal layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Signal Identification ──────────────────── [×] ─┐
|
||||||
|
│ │
|
||||||
|
│ Frequency [ 100.0000 ] MHz │
|
||||||
|
│ Bandwidth [ optional ] kHz (improves match) │
|
||||||
|
│ Modulation [ WFM ▾ ] │
|
||||||
|
│ [Search] │
|
||||||
|
├────────────────────────────────────────────────── │
|
||||||
|
│ ● FM Broadcast Radio ████████ 87 │
|
||||||
|
│ 87.5–108 MHz · WFM · Wideband │
|
||||||
|
│ Commercial FM radio. Stereo audio broadcast… │
|
||||||
|
│ [broadcast] [commercial] [audio] │
|
||||||
|
│ Frequency: centre of range · Modulation: exact │
|
||||||
|
│ ↗ View on SigID Wiki │
|
||||||
|
│ ───────────────────────────────────────────── │
|
||||||
|
│ ○ RDS Data (FM subcarrier) ████░░░░ 52 │
|
||||||
|
│ … │
|
||||||
|
└────────────────────────────────────────────────── ┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behaviour
|
||||||
|
- Frequency pre-filled from caller; blank if opened from nav
|
||||||
|
- Bandwidth field placeholder: "optional — improves matching", value in kHz (converted to Hz before API call)
|
||||||
|
- Modulation pre-filled from caller or defaults to `WFM`
|
||||||
|
- Search button disabled if frequency field is empty or invalid
|
||||||
|
- "Search" fires `POST /signalid/match` — results render inline, modal stays open
|
||||||
|
- Top result shows filled dot (●), rest show open dot (○)
|
||||||
|
- Score shown as a proportional bar + integer (0–100)
|
||||||
|
- `match_reasons` shown as a compact line of text below the signal name
|
||||||
|
- `sigidwiki_url` shown as "↗ View on SigID Wiki" link (opens new tab); not rendered if null
|
||||||
|
- `[×]` and clicking the backdrop close the modal
|
||||||
|
- Waterfall continues running behind the modal; no state is lost
|
||||||
|
|
||||||
|
### Error states in modal
|
||||||
|
| Condition | Display |
|
||||||
|
|---|---|
|
||||||
|
| Frequency empty/invalid | Search button disabled, field outlined red |
|
||||||
|
| No matches returned | "No signals match [X] MHz — try adjusting the frequency or leaving bandwidth blank" |
|
||||||
|
| Network/server error | "Search failed" + Retry button |
|
||||||
|
| `sigidwiki_url` null | Link not rendered |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### `tests/test_signalid_match.py`
|
||||||
|
Unit tests for the matching algorithm as a pure function (no Flask test client needed for most):
|
||||||
|
|
||||||
|
- Frequency exactly at range boundary → included
|
||||||
|
- Frequency 1 Hz outside range → excluded
|
||||||
|
- Signal with multiple `frequency_ranges` → matched by whichever range contains the query
|
||||||
|
- Bandwidth within range → score 30; at 2× → score 15; outside 2× → score 0
|
||||||
|
- Bandwidth not provided → all signals score 15 neutral
|
||||||
|
- Modulation exact match → score 20; not provided → 10; mismatch → 0
|
||||||
|
- No matches → empty list, 200 response
|
||||||
|
- `limit` clamping (0 → 1, 25 → 20)
|
||||||
|
|
||||||
|
### `tests/test_signals_json.py`
|
||||||
|
Schema validation test — loads `data/signals.json` and asserts every entry has:
|
||||||
|
- Required fields present and correct types
|
||||||
|
- `min_hz < max_hz` in all frequency and bandwidth ranges
|
||||||
|
- `min_hz > 0` in all ranges
|
||||||
|
- `id` is unique across all entries
|
||||||
|
- `modulations` tokens are uppercase strings
|
||||||
|
|
||||||
|
### No mocking required
|
||||||
|
The matcher is a pure function over the in-memory database. Flask route tests use the real JSON file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Auto-bandwidth measurement from FFT
|
||||||
|
- Audio sample upload and DSP analysis
|
||||||
|
- Waterfall screenshot / image analysis
|
||||||
|
- Periodic database sync or remote update
|
||||||
|
- Audio sample or waterfall image hosting
|
||||||
|
- Region auto-detection from IP
|
||||||
|
- Standalone Signal Library browse mode (natural phase 2)
|
||||||
Reference in New Issue
Block a user