# Utils/Observables

Geolocation observables for positioning and tracking algorithms.

## What are Observables?

Observables are measurements derived from signals that provide geometric information about the relationship between sensors (collectors) and signal sources. They are the fundamental inputs to geolocation algorithms like least squares estimators, Kalman filters, and other positioning systems.

### Observables vs Conversions

The `conversion` module transforms coordinates between reference frames (XYZ, LLA, ENU, etc.) - these are deterministic geometric transformations.

The `observables` module computes **measurements** that would be obtained by sensors:

| Module | Purpose | Example |
|--------|---------|---------|
| `conversion` | Coordinate frame transforms | XYZ position to LLA position |
| `observables` | Sensor measurements | Direction from antenna to source |

## Import and Use

Generally, we import the library itself:

```python
from gri_utils import observables

aoa = observables.get_aoa_enu(collector_xyz, source_xyz)
```

But to distinguish from other libraries, the [Google Style Guide](https://google.github.io/styleguide/pyguide.html#22-imports) also recommends giving it a more unique name, eg:

```python
from gri_utils import observables as gri_obs

aoa = gri_obs.get_aoa_enu(collector_xyz, source_xyz)
```

## Vectorization

All observable functions support vectorized inputs for efficient batch processing:

- **Single input**: Pass shape `(3,)` arrays for positions, `(4,)` for quaternions, `(2,)` for AOA
- **Batch input**: Pass shape `(N, 3)` arrays for N positions, `(N, 4)` for N quaternions, `(N, 2)` for N AOAs

```python
# Single observation
aoa = get_aoa_enu(collector, source)  # (3,), (3,) -> (2,)

# Batch observations - N sources from one collector
aoa = get_aoa_enu(collector, sources)  # (3,), (N, 3) -> (N, 2)
```

### Broadcast Functions

The quaternion-based functions also have `_broadcast` variants that support full numpy broadcasting semantics for arbitrary batch dimensions:

```python
# Standard vectorization: (N, 3) inputs -> (N, 2) output
aoa = observables.get_aoa_quat(collectors, sources, quats)

# Broadcast: (..., 3) inputs -> (..., 2) output with numpy broadcasting
aoa = observables.get_aoa_quat_broadcast(collector, sources, quat)
# e.g., (3,) + (10, 5, 3) + (4,) -> (10, 5, 2)
```

Available broadcast functions:

- `get_aoa_quat_broadcast`
- `aoa_quat_with_gradient_broadcast`
- `aoa_quat_to_xyz_broadcast`
- `get_pdoa_broadcast`
- `pdoa_with_gradient_broadcast`

## Residual Functions

Each observable provides a `get_*_residual` factory function that returns a vectorized residual callable. The zero-crossing of the residual defines the observable's iso-surface:

| Observable | Iso-surface | Residual function |
|------------|-------------|-------------------|
| Range | Sphere centered on collector | `get_range_residual` |
| TDOA | Hyperboloid between two collectors | `get_tdoa_residual` |
| FDOA | Iso-Doppler surface | `get_fdoa_residual` |
| AOA | Bearing line through collector | `get_aoa_residual` |
| PDOA | Phase-consistent bearing line | `get_pdoa_residual` |

These residuals are designed for use with terrain intersection algorithms in `gri_utils.terrain`:

```python
from gri_utils import observables, terrain

residual_fn = observables.get_tdoa_residual(c1_xyz, c2_xyz, tdoa_s)
contours = terrain.intersect_sheet(sheet, residual_fn)
```

## Observable Types

### Range

Range is the Euclidean distance from collector to emitter. The simplest observable - its iso-surface is a sphere.

| Function | Description |
|----------|-------------|
| `get_range` | Compute range from collector to emitter |
| `get_range_residual` | Factory for range residual (sphere iso-surface) |

```python
range_m = observables.get_range(collector_xyz, source_xyz)
```

### Angle of Arrival (AOA)

AOA computes the direction cosines from a collector to a signal source. The output is the X and Y components of a unit direction vector; the Z component is implicit as `+/-sqrt(1 - x**2 - y**2)`.

Two interfaces are available:

- **ENU-based** (`aoa_enu.py`): Uses ENU frame at collector position. Simpler API, no quaternion needed.
- **Quaternion-based** (`aoa_quat.py`): Uses arbitrary orientation via quaternion. For tilted antennas, aircraft, satellites.

#### ENU-Based Functions

For collectors whose local frame is simply ENU at their position:

| Function | Description |
|----------|-------------|
| `get_aoa_enu` | Get AOA direction cosines from collector to source |
| `get_aoa_residual` | Factory for AOA residual (bearing line iso-surface) |
| `translate_aoa` | Get source position from collector + AOA + range |
| `xyz_to_aoa` | Convert XYZ vector to AOA direction cosines |
| `aoa_to_xyz` | Convert AOA to XYZ unit direction vector |
| `aoa_to_enu` | Convert AOA to full ENU unit vector (E, N, U) |

```python
# Get AOA (East, North direction cosines)
aoa = observables.get_aoa_enu(collector_xyz, source_xyz)

# Convert back to source position with known range
source_recovered = observables.translate_aoa(collector_xyz, aoa, range_m)
```

#### Quaternion-Based Functions

For collectors with arbitrary orientation (tilted antenna, aircraft attitude, satellite pointing):

| Function | Description |
|----------|-------------|
| `get_aoa_quat` | Get AOA direction cosines using quaternion orientation |
| `aoa_quat_with_gradient` | Compute AOA and Jacobian w.r.t. source position |
| `aoa_quat_to_xyz` | Convert AOA back to XYZ unit vector |

```python
from scipy.spatial.transform import Rotation
from gri_utils import conversion, observables

# Create quaternion for ENU frame at collector (or any arbitrary orientation)
rot_matrix = conversion.xyz_enu_rotation(collector_xyz)
quat = Rotation.from_matrix(rot_matrix).as_quat()  # [x, y, z, w]

# Compute AOA
aoa = observables.get_aoa_quat(collector_xyz, source_xyz, quat)

# With gradient for optimization
aoa, gradient = observables.aoa_quat_with_gradient(collector_xyz, source_xyz, quat)
# gradient: (2, 3) Jacobian where gradient[i,j] = dAOA_i/dsource_j
```

#### Z Component Ambiguity

AOA returns only (X, Y) direction cosines. The Z component is implicit:
`z = +/-sqrt(1 - x**2 - y**2)`

The sign ambiguity represents whether the source is "in front" or "behind" the sensor. All reverse conversion functions accept a `z_positive` parameter (default `True`).

### TDOA (Time Difference of Arrival)

TDOA measures the difference in signal arrival time between two collectors. The iso-surface is a hyperboloid.

| Function | Description |
|----------|-------------|
| `get_tdoa` | Compute TDOA between two collectors |
| `get_tdoa_residual` | Factory for TDOA residual (hyperboloid iso-surface) |
| `tdoa_with_gradient` | Compute TDOA and Jacobian w.r.t. emitter position |

```python
tdoa = observables.get_tdoa(emit_xyz, c1_xyz, c2_xyz)

# With gradient for optimization
tdoa, gradient = observables.tdoa_with_gradient(emit_xyz, c1_xyz, c2_xyz)
```

### FDOA (Frequency Difference of Arrival)

FDOA measures the difference in Doppler shift between two collectors. Requires velocities.

| Function | Description |
|----------|-------------|
| `get_fdoa` | Compute FDOA between two collectors |
| `get_fdoa_residual` | Factory for FDOA residual (iso-Doppler surface) |
| `fdoa_with_gradient` | Compute FDOA and Jacobians w.r.t. position and velocity |

```python
fdoa = observables.get_fdoa(emit_xyz, emit_vel, c1_xyz, c2_xyz, freq_hz)

# With gradients for optimization
fdoa, grad_pos, grad_vel = observables.fdoa_with_gradient(
    emit_xyz, emit_vel, c1_xyz, c2_xyz, freq_hz
)
```

### PDOA (Phase Difference of Arrival)

PDOA measures phase differences across interferometer array elements. Used for direction finding with antenna arrays.

| Function | Description |
|----------|-------------|
| `get_pdoa` | Compute phase differences for array elements |
| `get_pdoa_residual` | Factory for PDOA residual |
| `pdoa_with_gradient` | Compute PDOA and Jacobian |
| `pdoa_to_aoa` | Convert phase differences to AOA direction cosines |

```python
# Element positions in array frame (reference at origin)
element_positions = np.array([[0.1, 0, 0], [0, 0.1, 0], [0, 0, 0.1]])

# Get phase differences
pdoa = observables.get_pdoa(collector_xyz, source_xyz, quat, element_positions, freq_hz)

# Convert to AOA (returns K candidates due to phase ambiguity)
aoas = observables.pdoa_to_aoa(pdoa, element_positions, freq_hz)
```

### Quaternion Convention

Quaternions use the **scipy convention**: `[x, y, z, w]` (scalar-last). Create quaternions from rotation matrices using `scipy.spatial.transform.Rotation`.

### Related: AER in conversion

If you need azimuth and elevation angles (rather than direction cosines), see `gri_utils.conversion` which provides AER (Azimuth/Elevation/Range) functions.

| Aspect | AOA | AER |
|--------|-----|-----|
| Format | Direction cosines (X, Y) | Angles (Az, El) + Range |
| Units | Unitless (-1 to 1) | Degrees + meters |
| Range included | No | Yes |
| Singularities | None | Gimbal lock at +/-90 deg elevation |
| Optimization | Better for least squares (linear in direction) | Requires angle wrapping |

## Notes

- Observables do not account for atmospheric effects (tropospheric/ionospheric delays, refraction). Apply corrections separately if needed.
- All positions are assumed to be in ECEF (XYZ) coordinates in meters.
