Radio Station Integration Plan¶
Executive Summary¶
Integrate FCC radio station data from cbradio project into cbdistricts, enabling spatial analysis of radio coverage against congressional districts. This creates a powerful targeting tool: "Which radio stations reach voters in District X?"
Data Overview¶
Source: ../cbradio/¶
| Asset | Location | Size | Count |
|---|---|---|---|
| Station DB | db/cbradio.db |
54 MB | 1,908 stations |
| Coverage Contours | fcc/data/contours/*.json |
~23 KB each | Up to 1,563 files |
Station Schema (Key Fields)¶
-- From cbradio.db station table
facility_id -- FCC unique ID (PK)
callsign -- e.g., "WBCH-AM"
service_type -- "am" or "fm"
fcc_frequency -- kHz (AM) or MHz (FM)
fcc_power_kw -- Transmit power
fcc_station_class -- A/B/C/D
fcc_hours_operation -- U/D/N
fcc_antenna_lat/lon -- Point location
fcc_service_contour -- Filename if contour exists
state -- Two-letter code
nielsen_dma -- Market name
owner -- Licensee
Coverage Contour Schema (GeoJSON)¶
{
"type": "Feature",
"properties": {
"callsign": "WBCH",
"facility_id": 3990,
"service_type": "am",
"frequency": 1220.0,
"power_kw": 0.25
},
"geometry": {
"type": "Polygon",
"coordinates": [[...361 points...]]
}
}
Architecture Decision¶
Option Considered: Extend Layer System¶
The existing /api/v1/layers/* system is designed for tessellating political boundaries where:
- Polygons don't overlap
- Each point belongs to exactly one district
- Features are stored in separate DuckDB files per layer
Radio coverage is fundamentally different: - Coverage polygons overlap (multiple stations cover same area) - Points (stations) + Polygons (coverage) hybrid data - Spatial relationships matter more than boundaries
Chosen Approach: Dedicated Radio Module¶
Create a separate radio domain with:
1. Dedicated tables in main cbdistricts.duckdb
2. Spatial join tables pre-computing district coverage
3. New API endpoints under /api/v1/radio/*
4. Cross-domain endpoints like /api/v1/districts/{geoid}/radio
This enables powerful queries while keeping the layer system clean.
Data Model¶
Tables in cbdistricts.duckdb¶
-- Radio stations (imported from cbradio.db)
CREATE TABLE radio_stations (
facility_id VARCHAR PRIMARY KEY,
callsign VARCHAR NOT NULL,
service_type VARCHAR, -- 'am' or 'fm'
frequency DECIMAL(10,3),
power_kw DECIMAL(10,3),
station_class VARCHAR,
hours_operation VARCHAR,
antenna_mode VARCHAR,
state VARCHAR,
nielsen_dma VARCHAR,
owner VARCHAR,
contour_file VARCHAR, -- Filename if exists
point GEOMETRY, -- Point from lat/lon
last_synced TIMESTAMP
);
-- Coverage contours (imported from GeoJSON files)
CREATE TABLE radio_contours (
facility_id VARCHAR PRIMARY KEY
REFERENCES radio_stations(facility_id),
callsign VARCHAR NOT NULL,
service_type VARCHAR,
contour GEOMETRY NOT NULL -- Polygon from GeoJSON
);
-- Spatial index for fast lookups
CREATE INDEX idx_stations_point ON radio_stations USING RTREE (point);
CREATE INDEX idx_contours_geom ON radio_contours USING RTREE (contour);
-- Pre-computed: which districts does each station cover?
CREATE TABLE radio_district_coverage (
facility_id VARCHAR REFERENCES radio_stations(facility_id),
geoid VARCHAR, -- Congressional district GEOID
overlap_sq_km DECIMAL(10,2), -- Area of intersection
district_pct DECIMAL(5,2), -- % of district covered
PRIMARY KEY (facility_id, geoid)
);
-- View for easy station queries
CREATE VIEW v_radio_full AS
SELECT
s.*,
c.contour IS NOT NULL AS has_contour,
ST_AsGeoJSON(s.point) AS point_geojson
FROM radio_stations s
LEFT JOIN radio_contours c ON s.facility_id = c.facility_id;
API Endpoints¶
Radio Endpoints (/api/v1/radio/)¶
| Method | Endpoint | Description |
|---|---|---|
| GET | /radio/stations |
List stations with filtering |
| GET | /radio/stations/{facility_id} |
Station detail |
| GET | /radio/stations/{facility_id}/contour |
Coverage GeoJSON |
| GET | /radio/stations/{facility_id}/districts |
Districts this station covers |
| GET | /radio/stats |
Aggregate statistics |
Cross-Domain Endpoints¶
| Method | Endpoint | Description |
|---|---|---|
| GET | /districts/{geoid}/radio |
Radio stations covering this district |
| GET | /districts/{geoid}/radio/geojson |
Coverage polygons as FeatureCollection |
Query Parameters¶
GET /api/v1/radio/stations
?state=MI # Filter by state
?service_type=fm # AM or FM only
?min_power=10 # Minimum power (kW)
?has_contour=true # Only stations with coverage data
?limit=50&offset=0 # Pagination
Response Examples¶
// GET /api/v1/radio/stations/55492
{
"facility_id": "55492",
"callsign": "WBCH-AM",
"service_type": "am",
"frequency": 1220.0,
"power_kw": 0.25,
"station_class": "D",
"hours_operation": "D",
"state": "MI",
"nielsen_dma": "Grand Rapids-Kalamazoo-Battle Creek",
"owner": "Midwest Communications Inc.",
"has_contour": true,
"location": {
"lat": 42.626111,
"lon": -85.278056
},
"districts_covered": 3
}
// GET /api/v1/districts/2603/radio
{
"geoid": "2603",
"district_name": "Michigan's 3rd Congressional District",
"stations": [
{
"facility_id": "55492",
"callsign": "WBCH-AM",
"frequency": 1220.0,
"power_kw": 0.25,
"coverage_pct": 45.2
},
...
],
"total_stations": 12,
"by_service_type": {"am": 5, "fm": 7}
}
Implementation Phases¶
Phase 1: Data Import Script¶
File: scripts/import_radio_data.py
- Connect to
../cbradio/db/cbradio.db - Import station table →
radio_stations - Convert lat/lon to Point geometry
- Import contour GeoJSON files →
radio_contours - Build spatial indexes
Usage:
python scripts/import_radio_data.py
--cbradio-db ../cbradio/db/cbradio.db
--contours-dir ../cbradio/fcc/data/contours/
--output data/output/cbdistricts.duckdb
Phase 2: Spatial Join Computation¶
File: scripts/compute_radio_coverage.py
- For each station with contour:
- ST_Intersection with each congressional district
- Calculate overlap area and percentage
- Store in
radio_district_coverage
SQL:
INSERT INTO radio_district_coverage
SELECT
c.facility_id,
d.geoid,
ST_Area(ST_Intersection(c.contour, d.geometry)) / 1000000 AS overlap_sq_km,
ST_Area(ST_Intersection(c.contour, d.geometry)) /
ST_Area(d.geometry) * 100 AS district_pct
FROM radio_contours c
CROSS JOIN districts d
WHERE ST_Intersects(c.contour, d.geometry);
Phase 3: API Layer¶
Files:
- api/models/radio.py - Pydantic models
- api/services/radio_service.py - Business logic
- api/routes/radio.py - Endpoints
Integration:
- Add radio_router to api/main.py
- Extend api/routes/districts.py with /districts/{geoid}/radio
Phase 4: Web Visualization¶
Updates to web/server.py:
- Add radio layer toggle
- Station markers (colored by service type)
- Contour polygons on hover/click
- Station info popup
File Summary¶
scripts/
├── import_radio_data.py # NEW: Import from cbradio
└── compute_radio_coverage.py # NEW: Spatial join computation
api/
├── models/
│ └── radio.py # NEW: RadioStation, RadioContour models
├── services/
│ └── radio_service.py # NEW: Radio data access
├── routes/
│ ├── radio.py # NEW: /radio/* endpoints
│ └── districts.py # MODIFY: Add /districts/{geoid}/radio
web/
└── server.py # MODIFY: Add radio layer to map
data/
└── output/
└── cbdistricts.duckdb # MODIFY: Add radio_* tables
Sync Strategy¶
Initial Load¶
- Full import from cbradio.db
- Import all existing contour files
- Compute spatial joins (may take 10-15 minutes for 1,563 × 441 intersections)
Ongoing Sync¶
- FCC data changes infrequently (weekly/monthly)
- Add
scripts/sync_radio_data.pyfor incremental updates - Track
last_syncedtimestamp
Contour Generation¶
If contours don't exist yet:
Dependencies¶
Already Available¶
- DuckDB with spatial extension ✓
- Pydantic models pattern ✓
- Redis caching infrastructure ✓
- MapLibre GL JS in web viewer ✓
May Need¶
- None - all dependencies already in place
Estimated Effort¶
| Phase | Tasks | Estimate |
|---|---|---|
| Phase 1 | Import script | 2-3 hours |
| Phase 2 | Spatial joins | 1-2 hours |
| Phase 3 | API layer | 3-4 hours |
| Phase 4 | Web visualization | 2-3 hours |
| Total | 8-12 hours |
Open Questions¶
- Contour availability: Only 1 contour file exists currently. Run FCC sync first?
-
Recommendation: Yes, run
python -m fcc.sync --allin cbradio before import -
Storage: Same DuckDB or separate
radio.db? -
Recommendation: Same file for easier spatial joins with districts
-
State scope: Import all 50 states or start with focus states?
-
Recommendation: Import all - it's only 1,908 stations
-
Power threshold: Filter out very low power stations?
- Recommendation: Import all, filter in API queries
Success Metrics¶
- All 1,908 stations imported
- All available contours loaded
- Spatial joins computed for all contour×district pairs
- API returns stations for any district in <100ms
- Map displays station markers and coverage polygons
- Redis caching reduces repeat queries to <10ms
Plan created: 2025-12-24