# ADS-B proximity algorithm

This document describes the device-side logic for tracking multiple airborne targets,
predicting closest approach, drawing tracks, and raising alerts.

## Coordinate model

Use a local ENU frame centered on the ownship at the current update time:

- `x`: east, meters
- `y`: north, meters
- `z`: up, meters

For screen drawing the ENU frame can be rotated by ownship heading, but the math should
stay in unrotated ENU coordinates.

For ranges below roughly 100 km, an equirectangular conversion is sufficient:

```text
x = (target_lon - own_lon) * 111320 * cos(own_lat)
y = (target_lat - own_lat) * 111320
z = target_alt_m - own_alt_m
```

For a production build, replace this with WGS84/ECEF-to-ENU conversion if we need better
accuracy at larger ranges or higher latitudes.

## Target state

Each ADS-B target keeps:

```text
id
lastSeenMs
position: { x, y, z }
velocity: { x, y, z }
altitudeM
groundSpeedMps
trackDeg
verticalSpeedMps
history[]   // past samples for drawing
forecast[]  // generated samples for drawing
cpa         // latest CPA analysis
status      // ok, watch, alarm, stale
```

Velocity from ADS-B track:

```text
vx = sin(track) * groundSpeed
vy = cos(track) * groundSpeed
vz = verticalSpeed
```

Ownship velocity is computed the same way from GNSS course over ground, speed, and
vertical speed if available.

## Ownship Motion Prediction

Ownship is not a fixed point. Store a rolling local-position history:

```text
ownshipHistory[] = { time, x, y, z, course, groundSpeed, verticalSpeed }
```

Configurable prediction parameters:

```text
mode = auto | straight | thermal-drift | momentary

verticalAverageWindow = 30 s
driftAverageWindow = 60 s
straightAverageWindow = 10 s
straightTurnRate = 3 deg/s
thermalTurnRate = 8 deg/s
minHistoryWindow = 5 s
```

Vertical prediction:

```text
ownVerticalSpeed = (z_now - z_then) / dt
ownAltitude(t) = z_now + ownVerticalSpeed * t
```

This means a sustained `+1 m/s` climb over the averaging window predicts roughly
`+120 m` after two minutes.

Horizontal prediction modes:

```text
straight:
  use smoothed current course/speed or short-window displacement

thermal-drift:
  use displacement over driftAverageWindow
  driftVelocity = (position_now - position_then) / dt

momentary:
  use current GNSS course and ground speed

auto:
  if cumulative average turn rate >= thermalTurnRate -> thermal-drift
  if average turn rate <= straightTurnRate -> straight
  otherwise -> momentary
```

Turn rate should be calculated as the accumulated heading change between neighboring
history samples divided by time. Do not use only `course_now - course_then`, because a
complete spiral can end with almost the same heading and hide the turn.

CPA uses relative velocity:

```text
relativeVelocity = targetPredictedVelocity - ownshipPredictedVelocity
```

So climb, sink, spiral drift, and straight glide all affect the predicted closest
approach.

## CPA calculation

For every target on every UI tick:

```text
r = target.position - ownship.position
v = target.velocity - ownship.velocity

tcpa_raw = -dot(r, v) / dot(v, v)
tcpa = clamp(tcpa_raw, 0, 300)
cpa = r + v * tcpa

hcpa = hypot(cpa.x, cpa.y)
vcpa = cpa.z
dcpa = hypot(hcpa, vcpa)
```

If `dot(v, v)` is very small, use `tcpa = 0`.

CPA is still useful for display, but alerting can use one of two modes:

```text
mode = sphere
mode = separation
```

The device should expose this as a setting. Each mode has separate `ALARM` and `WATCH`
thresholds.

## Alerts

Default alert levels:

```text
ALARM: lookahead <= 180 s
WATCH: lookahead <= 300 s
OK:    otherwise
```

Recommended initial configuration:

```text
sphere mode:
  ALARM sphereRadius = 1000 m
  WATCH sphereRadius = 3000 m

separation mode:
  ALARM horizontal = 1000 m, vertical = 1000 m
  WATCH horizontal = 3000 m, vertical = 3000 m
```

The alert time is the first predicted entry into the protected region, not necessarily
the same as TCPA.

### Sphere Mode

Sphere mode uses one 3D radius. Calculate it by solving the relative-motion sphere
intersection:

```text
a = dot(v, v)
b = 2 * dot(r, v)
c = dot(r, r) - radius^2

discriminant = b^2 - 4*a*c
entry = (-b - sqrt(discriminant)) / (2*a)
exit = (-b + sqrt(discriminant)) / (2*a)
```

If `discriminant >= 0`, `exit >= 0`, and `entry <= lookahead`, the target intersects
the sphere. Use `max(0, entry)` as `riskEntryTime`.

### Separation Mode

Separation mode uses separate horizontal and vertical thresholds. The target must satisfy
both conditions at the same time:

```text
horizontal: hypot(rx + vx*t, ry + vy*t) <= horizontalLimit
vertical:   abs(rz + vz*t) <= verticalLimit
```

Calculate two time windows:

```text
horizontal window:
  (rx + vx*t)^2 + (ry + vy*t)^2 <= horizontalLimit^2

vertical window:
  -verticalLimit <= rz + vz*t <= verticalLimit

alert window:
  intersection(horizontalWindow, verticalWindow, [0, lookahead])
```

If the intersection is non-empty, use the start of the intersection as `riskEntryTime`.

Recommended production additions:

- Add hysteresis, for example clear `ALARM` only after `hcpa > 1200 m` or `tcpa > 210 s`.
- Tune both sphere radii and separation thresholds after flight-log replay.
- Keep a latched alarm until the pilot acknowledges it or the target exits the clear threshold.

## Multiple targets

Analyze all targets independently. Do not stop after finding one threat.
The initial target budget is configurable:

```text
maxTargets = 10
```

If more targets are received, keep the nearest/highest-risk targets in the active
calculation set and leave the rest in a low-rate background pool.

Display priority:

```text
1. ALARM before WATCH before OK
2. smaller riskEntryTime, falling back to smaller tcpa
3. smaller hcpa
4. smaller abs(vcpa)
5. smaller dcpa
6. newest ADS-B sample
```

The main alert panel shows the highest-priority target. The traffic list shows all live
targets sorted by the same priority.

## Recalculation Scheduler

CPA and alert calculations do not need to run at the same rate for every target. Store
these values in a configurable scheduler block:

```text
maxTargets = 10
lowRiskScore = 0.10

distantRange = 150 km
mediumRange = 100 km
nearRange = 20 km
urgentRange = 5 km

intervals:
  lowRiskOrDistant = 10 s
  medium = 5 s
  near = 3 s
  urgentOrThreat = 1 s
```

Selection rule:

```text
if target is ALARM/WATCH or range <= urgentRange:
  recalculate every 1 s
else if range > distantRange or riskScore < lowRiskScore:
  recalculate every 10 s
else if range <= nearRange:
  recalculate every 3 s
else if range <= mediumRange:
  recalculate every 5 s
else:
  recalculate every 10 s
```

Each target stores:

```text
lastAnalysisTime
nextAnalysisTime
lastAnalysis
recalcInterval
riskScore
```

`riskScore` is a normalized 0..1 value. The current prototype derives it from alert
state and closest-approach margin; a production build can replace it with a better
probability model based on sensor quality, trajectory stability, and historical misses.

## Tracks

For each target keep a rolling history buffer, for example 90-180 seconds:

```text
history.push({ timeMs, x, y, z })
remove samples older than HISTORY_SECONDS
```

Draw:

- Past track: solid dim line through `history`.
- Future track: dashed line from current relative position to predicted positions.
- CPA marker: small point at `cpa.x, cpa.y`.
- CPA separation line: optional line between ownship predicted CPA point and target predicted CPA point.

Future track samples:

```text
for t = 0..min(300, tcpa + 60) step 5..15 s:
  p = r + v * t
```

## Data aging

ADS-B updates are not perfectly regular, so every target must age:

```text
fresh: lastSeen <= 5 s
coast: 5 s < lastSeen <= 15 s
stale: lastSeen > 15 s
drop:  lastSeen > 60 s
```

Coasted targets may be predicted forward, but the UI should mark them differently and
avoid starting a new alarm from a stale-only prediction.

## Update loop

```text
onSensorUpdate:
  update ownship from GNSS/baro
  decode ADS-B messages
  update target state and history

onUiTick:
  liveTargets = removeDroppedTargets(targets)
  analyses = liveTargets.map(analyzeTarget)
  active = sortByRisk(analyses)[0]
  drawMap(analyses)
  drawNumbers(active)
  driveBuzzerAndLed(active.status)
```

## Known limits

This is a situational-awareness algorithm, not a certified collision-avoidance system.
It assumes constant velocity during the forecast window. Turns, thermals, GNSS errors,
barometric offsets, transponder latency, and missing ADS-B packets can all change the
real closest approach.
