Finds the landowner under every FCC-registered cell tower in a state and ranks them for ground-lease renegotiation, by joining federal tower registries to county parcel records and economic distress data.
Status: Archived. Built for a ground-lease business that has since closed, and kept as a portfolio project by Daniel Gentile.
Live demo: morphy-demo.fly.dev. Press "Explore the demo" on the sign-in page; it needs no password. The demo is read-only. The backend refuses every write with a 403. The lead board holds six demo leads with made-up triage histories, all on government landowners such as the USDA Forest Service, so no private owner appears to have been contacted (ADR-077, ADR-078).
Cell towers usually sit on land leased from a private owner, often a farmer or a small family trust, under a ground lease signed decades ago. Those leases are often worth renegotiating, but finding the owners is the hard part. The FCC knows where every registered tower stands. Counties know who owns every parcel. No public dataset connects the two.
morphy builds that connection for four states (Arkansas, Montana, Florida and West Virginia): about 15.9 million parcels and 16,000 towers. It places every FCC Antenna Structure Registration (ASR) tower on its parcel, attaches the owner, groups an owner's parcels across counties and states, and layers on distress signals: HUD Opportunity Zones and Qualified Census Tracts, Appalachian Regional Commission distressed counties, and USDA persistent-poverty counties. An analyst then works the result as leads in a map app: filter, rank with tunable scores, open an owner, log contact attempts, and merge or split owner clusters by hand.
Four subsystems, each with its own agent orientation file:
| Subsystem | Path | What it does |
|---|---|---|
| Data TUI | scripts/data_tui/ |
Textual terminal app over the headless fetch library. Pulls raw data, runs preprocess, verifies outputs, and deploys data, all from one screen. |
| Preprocess pipeline | scripts/preprocess/ |
Registry-driven Normalizer → Join → View → Compose framework on DuckDB. Writes per-state composites, PMTiles and the cross-state owners index. |
| Map app | app/ |
Svelte 5 + MapLibre single-page app with Dashboard, Explore and Leads sections. |
| Backend | backend/ |
FastAPI + SQLAlchemy write layer for leads, tasks, activity, scoring presets and owner overrides. |
public sources ──► scripts/fetch ──► data/raw ──► scripts/preprocess ──► data/processed
(FCC, counties, (data TUI) normalize, join, composites,
HUD, ARC, USDA, view, compose PMTiles, owners index
EIA, FAA) │
▼
backend (FastAPI, SQLite) ◄──── app (Svelte, MapLibre,
leads, tasks, overrides PMTiles, DuckDB-WASM)
Deployed, the app, the API and the data files sit behind one Caddy server on a single Fly machine, with SQLite replicated to object storage by Litestream. The full system diagram and data flow are in docs/ARCHITECTURE.md.
- Registry-driven pipeline. Each source is one
DataSourceentry with an automation policy (ALLOWED,TOLERATED,MANUAL_ONLY,CREDENTIALED) that records what the publisher's terms permit. 17 sources are active and 12 more are registered but deferred. Normalizers map each source to a canonical schema, Joins link them, and Views define the outputs. Adding a state means adding a parcel adapter, not a new code path (ADR-002). - Big-state compose on a laptop. Florida has 11.4 million parcels. Loading them in one pass peaked near 19.5 GB, past the 16 GB dev machine, so compose runs in chunks on DuckDB (ADR-051).
- No tile server. Parcels ship as PMTiles read by HTTP range request, and the browser
filters and searches parcel attributes with DuckDB-WASM over parquet. The static file
server only has to honor
Range. - Tenant-scoped API. Every table carries
tenant_idand every route filters by it, with isolation tests markedtenant_isolation(ADR-055). Schema changes ship as Alembic revisions, and the committed OpenAPI schema generates the frontend's TypeScript types, checked in CI. - Cookie auth that re-checks every request. Credentials live in the database with
PBKDF2 hashes. The JWT carries identity only, sits in an httpOnly
morphy_sessioncookie, and is re-authorized against the credential table on every request. Caddy uses the same check (forward_auth) to gate the static data files. WithAUTH_DEV_MODEunset, the app refuses to start without a real secret and at least one credential (ADR-069). - A read-only demo enforced in one place. Read-only is a flag on the credential row.
current_principal, the dependency every authenticated route already uses, refuses that principal's non-GET requests, and a test enumerates every write route to prove none slips past.DEMO_MODEadds a password-less sign-in for that one credential and refuses to boot unless it exists and is read-only (ADR-077). - Owner merge and split. The pipeline clusters owners by normalized name plus state, with
an alias table for the national tower companies
(ADR-075). Analyst overrides resolve in one
expression,
display_cluster_id = coalesce(override_target, raw_cluster_id), so no surface can disagree about who an owner is. - Carrier matches labeled for what they are. FCC spectrum licenses are matched to towers and shown as "licensed in this area", not as confirmed tenants. The plan targeted carriers on 70% of towers; measured coverage was 6-17% per state, because most towers have no per-site license in the source. The app treats an empty carrier list as the normal case instead of padding it with guesses (ADR-074).
- Tests. About 1,100 pytest tests (627 for the pipeline and app-asset scripts, 472 for the backend), about 520 Vitest unit tests and about 40 Playwright end-to-end tests for the map app. GitHub Actions runs lint, type checks, the pytest and Vitest suites, and an OpenAPI type-drift check; the Playwright suite runs locally (docs/TESTING.md).
- Built with coding agents. The root CLAUDE.md and one per subsystem give agents their entry points, contracts and the invariants they most often broke. They are left in place as a record of how the project was built.
Prerequisites: uv (it installs Python 3.11 from uv.lock),
Node.js 22 (the version CI and the Dockerfile use), and tippecanoe for parcel tiles
(brew install tippecanoe). Expect several GB of disk for one state's raw data.
uv sync && source .venv/bin/activate1. Pull one state. Montana is a good first state because its statewide parcel layer downloads automatically.
python -m data_tuiThe launch sweep checks every source. Select a row and press p to pull it. For Montana pull
mt_cadastral_parcels, fcc_asr (national towers) and the overlays (hud_oz, hud_qct,
arc_distressed, usda_persistent_poverty). P pulls every stale source at once, and q
quits. docs/PULLER_UX.md covers the rest.
2. Build the outputs.
python scripts/preprocess.py --state 30 # normalize + compose Montana
python scripts/preprocess.py --app-assets # index.json, facets, slim parquet
python scripts/preprocess.py --tiles # parcels.pmtiles (needs tippecanoe)
python scripts/preprocess.py --owners-index # cross-state owner clusters for Leads3. Run the stack (three terminals, from the repo root):
python scripts/serve.py 8765 # data files
AUTH_DEV_MODE=1 uvicorn backend.app.main:app --reload --port 8000 # API
cd app && npm install && npm run dev # http://localhost:5173AUTH_DEV_MODE=1 uses an ephemeral signing secret and a built-in dev / dev login. Sign
in with that. Vite proxies /data to port 8765 and /api to port 8000.
Optional settings. Copy .env.example to .env and app/.env.example to app/.env
(both git-ignored). The app's file takes a MapTiler key for the satellite basemap. Without
one the app uses a keyless Esri basemap.
Tests:
pytest
ruff check . && ruff format --check .
uv run pyright
cd app && npm run check && npm run test:unit && npm run test:e2eThe MIT license covers the code only. No source data is in this repo: data/ is git-ignored,
and the data TUI pulls each dataset from its publisher. The data comes from federal agencies
(FCC, FAA, HUD, USDA, USGS, the Energy Information Administration and the Appalachian
Regional Commission) and from state parcel publishers in
Arkansas, Florida, Montana and West Virginia. NOTICE.md gives each publisher's
terms and the credits and disclaimers they ask for, including a takedown contact for owner
names, which come from public records. Per-source schemas, licenses and automation policies
are in docs/DATA_SOURCES.md, and data handling is in
docs/SECURITY.md.
Every significant choice is recorded as an architecture decision record in docs/adr/, indexed in docs/DECISIONS.md. Good starting points: why states were the unit of scope (ADR-001), why a lead is an owner cluster (ADR-056), why one Fly machine with SQLite (ADR-064), and why the demo's leads sit on government owners (ADR-078).
The pipeline, the four state composites, carrier identification, tunable scoring, owner resolution, the backend and the triage UI all shipped. Lease document upload with LLM extraction (ADR-059) and tower-company inference were designed but never built. docs/STATUS.md has the full account, and docs/CHANGELOG.md has the dated history.
MIT, see LICENSE. Built by Daniel Gentile.


