Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ git submodule update --init --recursive
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt -r requirements-dev.txt
pip install -e ../libs/retina-geolocator -e ../libs/retina-tracker
pip install -e ../libs/retina-geolocator -e ../libs/retina-tracker \
-e ../libs/retina-custody -e ../libs/retina-simulation -e ../libs/retina-analytics
cp .env.example .env # fill in what you need (see below)
RETINA_ENV=dev AUTH_ALLOW_ANONYMOUS_ADMIN=1 SYNTHETIC_FLEET_ENABLED=1 uvicorn main:app --reload
```
Expand Down
15 changes: 9 additions & 6 deletions backend/core/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,15 @@ class NodeConfig(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
node_id: Mapped[str] = mapped_column(String(32), ForeignKey("nodes.node_id"), index=True)
version: Mapped[int] = mapped_column(Integer)
rx_lat: Mapped[float] = mapped_column(Float)
rx_lon: Mapped[float] = mapped_column(Float)
rx_alt_ft: Mapped[float] = mapped_column(Float)
tx_lat: Mapped[float] = mapped_column(Float)
tx_lon: Mapped[float] = mapped_column(Float)
tx_alt_ft: Mapped[float] = mapped_column(Float)
# Nullable since contract 1.1.3: an owner cannot always supply the geometry
# at setup, and such a node is carried without being placed. Latitude and
# longitude are validated as a pair; altitude stands alone.
rx_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
rx_lon: Mapped[float | None] = mapped_column(Float, nullable=True)
rx_alt_ft: Mapped[float | None] = mapped_column(Float, nullable=True)
tx_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
tx_lon: Mapped[float | None] = mapped_column(Float, nullable=True)
tx_alt_ft: Mapped[float | None] = mapped_column(Float, nullable=True)
tx_callsign: Mapped[str] = mapped_column(String(32))
fc_hz: Mapped[float] = mapped_column(Float)
fs_hz: Mapped[float] = mapped_column(Float)
Expand Down
37 changes: 37 additions & 0 deletions backend/migrations/versions/0005_nullable_node_coordinates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""The six coordinate columns become nullable on node_configs.

Revision ID: 0005
Revises: 0004
"""

import sqlalchemy as sa
from alembic import op

revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None

# A downgrade cannot express a null, and code predating 1.1.3 has no
# null-handling for these six columns, so a rollback across this revision must
# be surfaced to a human rather than served as safe.
rollback_safety = "destructive"

_COLUMNS = ("rx_lat", "rx_lon", "rx_alt_ft", "tx_lat", "tx_lon", "tx_alt_ft")


def upgrade() -> None:
# Existing rows are left exactly as they are. A row that declared (0, 0)
# stays as declared: the table is append-only, so this governs new rows
# only, and rewriting history would be guessing at what a node meant.
with op.batch_alter_table("node_configs") as batch:
for column in _COLUMNS:
batch.alter_column(column, existing_type=sa.Float(), nullable=True)


def downgrade() -> None:
# A null cannot be expressed under the old constraint, so a downgrade with
# positionless rows present will fail loudly rather than invent coordinates.
with op.batch_alter_table("node_configs") as batch:
for column in _COLUMNS:
batch.alter_column(column, existing_type=sa.Float(), nullable=False)
3 changes: 2 additions & 1 deletion backend/routes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,8 @@ async def get_tower_config(_admin=Depends(require_admin)):
cfg = info.get("config", {})
tx_lat = cfg.get("tx_lat")
tx_lon = cfg.get("tx_lon")
if tx_lat and tx_lon:
# A transmitter on the equator or the prime meridian is a real tower.
if tx_lat is not None and tx_lon is not None:
key = f"{tx_lat:.4f},{tx_lon:.4f}"
if key not in towers:
towers[key] = {
Expand Down
2 changes: 2 additions & 0 deletions backend/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
get_jwt_strategy,
get_or_create_oauth_user,
)
from services.node_config import position_status

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
Expand Down Expand Up @@ -262,6 +263,7 @@ async def my_nodes(request: Request):
"is_synthetic": info.get("is_synthetic", False),
"rx_lat": cfg.get("rx_lat"),
"rx_lon": cfg.get("rx_lon"),
"position_status": position_status(cfg),
"frequency": cfg.get("FC", cfg.get("frequency")),
}
)
Expand Down
6 changes: 0 additions & 6 deletions backend/routes/node_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,6 @@ async def _hand_to_pipeline(session: AsyncSession, node: Node, version: int) ->
retried. The version is committed, so the node row already reads it, and the next
identical resend finds nothing changed and never reaches here. The alert is
therefore the whole of the recovery path, which is why it carries the version.

One failure is live rather than hypothetical: retina-analytics reads
`config.get("beam_width_deg", 41)`, so an explicit null passes its default by, and
the unchanged-geometry comparison in association.py then subtracts it. Every node
in the fleet sends a null width under contract 1.1.1. Tracked in 86cb5dakr, with
the ordering above; neither is this endpoint's to fix.
"""
from services.alerting import send_alert

Expand Down
12 changes: 5 additions & 7 deletions backend/routes/node_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,11 @@ def _file_frame(node_id: str, frame: DetectionFrame) -> int:
not at all: the count is the array length or nothing.

A node absent from `state.connected_nodes` is declined rather than queued.
frame_processor has no geometry for such a node and falls back to the
process-wide default pipeline, so the frame would be solved against
somebody else's receiver and transmitter and reach the map as a plausible
detection in the wrong place, while the ack claimed it was accepted.
Declining costs at most one heartbeat interval of this node's data, and the
next beat restores it by re-registering the node. Queueing costs
correctness, silently, which is the worse trade.
This server holds no configuration for such a node at all, so there is no
config_hash to check staleness against and nothing to place, count or
attribute the frame under. Declining costs at most one heartbeat interval
of this node's data, and the next beat restores it by re-registering the
node. Queueing costs correctness, silently, which is the worse trade.

Recovery deliberately does not happen here. It needs a database read and a
write to the registries, and this is the path that runs at the fleet's frame
Expand Down
7 changes: 6 additions & 1 deletion backend/routes/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@
#
# Publishing NodeConfig would be the minor bump, since that is the one thing
# here a client cannot already do (86cb6d7he).
NODE_API_VERSION = "1.1.2"
#
# 1.1.3 makes the six coordinate fields of NodeConfig nullable, so a node whose
# owner cannot supply the geometry can still register. A patch rather than a
# minor bump for the same reason as above: NodeConfig is not published, so the
# document gains no field and no capability a client can read (86cb6d7he).
NODE_API_VERSION = "1.1.3"

# No tag here: each sub-router carries the contract's own grouping, since those
# are what a generated client is built around.
Expand Down
10 changes: 8 additions & 2 deletions backend/routes/radar.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from core.users import require_admin
from pipeline.passive_radar import PassiveRadarPipeline
from services import node_registration
from services.node_config import canonical_config
from services.node_pipeline import config_hash
from services.public_location import public_latlon
from services.publication import is_private
Expand Down Expand Up @@ -129,17 +130,20 @@ async def ingest_detections(
frames = body.frames if body.frames is not None else [body_dict]

if node_id not in state.connected_nodes:
# This path carries no geometry at all: the node is counted, and stays
# unplaced until it configures itself over TCP or the v1 API.
legacy_config = canonical_config({"node_id": node_id})
with state.connected_nodes_lock:
state.connected_nodes[node_id] = {
"config_hash": "",
"config": {"node_id": node_id},
"config": legacy_config,
"status": "active",
"last_heartbeat": datetime.now(timezone.utc).isoformat(),
"peer": "http",
"is_synthetic": is_synthetic_node(node_id),
"capabilities": {},
}
await node_registration.register_node(node_id, {"node_id": node_id})
await node_registration.register_node(node_id, legacy_config)
else:
with state.connected_nodes_lock:
state.connected_nodes[node_id]["status"] = "active"
Expand Down Expand Up @@ -189,7 +193,9 @@ async def ingest_detections_bulk(
changed = False
else:
entry_config = entry.config or {"node_id": node_id}
# Hashed as declared, stored canonical: see register_with_pipeline.
entry_hash = config_hash(entry_config)
entry_config = canonical_config(entry_config)
# A hash mismatch only triggers re-registration for a node this
# endpoint itself created. Otherwise a caller holding RADAR_API_KEY
# could strip a live v1 or TCP node's geometry by naming it in a
Expand Down
8 changes: 8 additions & 0 deletions backend/scripts/association_bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
node_beam_params, # noqa: E402
)
from services.geo import haversine_km as _haversine_km # noqa: E402
from services.node_config import position_status # noqa: E402
from services.tasks.solver import ( # noqa: E402
_ewma_smooth_track,
claim_decision,
Expand Down Expand Up @@ -422,6 +423,13 @@ def _beam_gate_ok(out: dict, s_in: dict, node_cfgs: dict, fov_provider) -> bool:
cfg = node_cfgs.get(nid)
if not cfg:
continue
# The same placement guard solver.py applies before its range/bearing
# work: node_beam_params stopped coercing a missing coordinate to 0.0,
# so an unplaced node reaches the haversine below as None. A snapshot
# read from a live server carries only placed nodes, but this leg is
# also pointed at recorded ones.
if position_status(cfg) not in ("positioned", "missing_tx"):
continue
p = node_beam_params(cfg)
rx_lat, rx_lon = p["rx_lat"], p["rx_lon"]
range_km = _haversine_km(rx_lat, rx_lon, out["lat"], out["lon"])
Expand Down
3 changes: 2 additions & 1 deletion backend/services/adsb_regions.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ def is_position_absent(lat, lon) -> bool:
which no node and no aircraft occupies. Only the exact pair reads as
absence: the equator and the prime meridian are each perfectly good
coordinates on their own. This is the convention retina_analytics applies
in _has_receiver_position, and every backend site must agree with it.
in has_full_geometry, which holds it for both ends of the bistatic pair,
and every backend site must agree with it.

A bool is never the sentinel even though `bool` is an `int` subclass and
`False == 0.0`: a node reporting a boolean is sending malformed config,
Expand Down
25 changes: 21 additions & 4 deletions backend/services/blah2_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from core.runtime_config import default_source_path, runtime_path
from core.task_registry import register_task
from services import node_registration
from services.node_config import canonical_config

log = logging.getLogger("blah2_bridge")

Expand All @@ -59,10 +60,14 @@
# Fields every node must supply — without these the bistatic solve is undefined.
_REQUIRED = ("node_id", "detection_url", "rx_lat", "rx_lon", "tx_lat", "tx_lon", "fc_hz")

# Altitude is deliberately absent from the defaults below: it is resolved at
# the geometry boundary (node_config.resolve_altitudes) instead, so a node that
# declares none archives and publishes a null rather than a figure nothing
# downstream could later tell apart from a survey.
_OPTIONAL_ALTITUDES = ("rx_alt_ft", "tx_alt_ft")

# Optional fields and the defaults applied when a node omits them.
_OPTIONAL_DEFAULTS = {
"rx_alt_ft": 0.0,
"tx_alt_ft": 0.0,
"fs_hz": 2_000_000,
"doppler_min": -300,
"doppler_max": 300,
Expand Down Expand Up @@ -144,6 +149,15 @@ def _build_node(entry: dict) -> Blah2Node:
cfg[key] = float(raw)
except (TypeError, ValueError) as exc:
raise Blah2ConfigError(f"{node_id}: {key} is not a number: {raw!r}") from exc
for key in _OPTIONAL_ALTITUDES:
raw = entry.get(key)
if raw is None:
cfg[key] = None
continue
try:
cfg[key] = float(raw)
except (TypeError, ValueError) as exc:
raise Blah2ConfigError(f"{node_id}: {key} is not a number: {raw!r}") from exc

for key, lo, hi in (("rx_lat", -90, 90), ("tx_lat", -90, 90), ("rx_lon", -180, 180), ("tx_lon", -180, 180)):
if not lo <= cfg[key] <= hi:
Expand Down Expand Up @@ -218,18 +232,21 @@ def load_nodes(path: Path | None = None) -> list[Blah2Node]:

async def _register_node(node: Blah2Node):
"""Register a node in state as a real (non-synthetic) connected node."""
# Hashed over the file's own config, so a node's hash tracks the file
# rather than the normaliser.
cfg_hash = hashlib.sha256(json.dumps(node.config, sort_keys=True).encode()).hexdigest()[:16]
config = canonical_config(node.config)
with state.connected_nodes_lock:
state.connected_nodes[node.node_id] = {
"config_hash": cfg_hash,
"config": node.config,
"config": config,
"status": "active",
"last_heartbeat": "",
"peer": node.peer,
"is_synthetic": False,
"capabilities": {"adsb_report": True},
}
await node_registration.register_node(node.node_id, node.config)
await node_registration.register_node(node.node_id, config)
log.info("blah2_bridge: registered node %s", node.node_id)


Expand Down
Loading
Loading