# Architecture: target-relative visual servoing

## Control boundary

The AprilTag is attached to a moving target. It is a relative observation, not
a map landmark or a global reference. There is no Tag-to-EKF, Tag-to-global-pose,
or simulator-truth-to-controller path.

The control process may read AprilTag detection/quality, source image timing,
camera calibration/extrinsics, PX4 attitude/state, and barometric altitude. It
must not read Gazebo model pose, GPS/local/global position, LiDAR/SLAM odometry,
or optical-flow position.

## Frames and transforms

`C` is the ROS camera optical frame (`x` right, `y` down, `z` forward), `B_frd`
is the PX4 body frame, and `N` is a gravity-aligned local NED frame. The detector
provides a metric tag pose at image timestamp `t_i`:

```text
camera pose:       C_T_tag(t_i)
camera extrinsic:  B_T_C
body vector:       B_p_tag = translation(B_T_C * C_T_tag)
attitude:          N_R_B(t_i) = SLERP(attitude buffer, t_i)
compensated:       N_p_tag = N_R_B(t_i) * B_p_tag
```

For the included model, the optical-vector to body-FRD mapping is:

```text
B_p = [-C_p.y, C_p.x, C_p.z] + [0, 0, -0.10] m
```

The translation accounts for the camera center being 0.10 m above `base_link`.
Rotation and translation are applied exactly once. Unit and bench tests verify
every axis instead of inferring signs from the rendered image.

Using the latest attitude at detection callback time is incorrect: rendering,
bridging, and detection introduce delay. Source image timestamps and MAVLink
`ATTITUDE_QUATERNION.time_boot_ms` share simulation time; unsafe end samples,
clock jumps, and stale measurements are rejected.

## Observation gate

The first implementation uses upstream `apriltag_ros` and accepts tag ID 0 only
when hamming is zero, decision margin and range are plausible, timing is fresh,
and frame-to-frame motion is continuous. A low-delay filter supplies position
and finite-difference relative velocity. A second visual/inertial EKF is not
required for this task.

## Outer loop

The desired tag vector in NED is directly below the vehicle at height `h_d`:

```text
p_des = [0, 0, h_d]
e     = p_tag - p_des
a_NE  = clamp(Kp_xy * e_NE + Kd_xy * d(e_NE)/dt, a_xy_max)
```

The bounded horizontal acceleration, gravity, and fixed takeoff yaw determine
the desired thrust direction. Vertical error and rate determine bounded thrust.
The moving-target baseline uses `kp=1.1`, `kd=0.45`, an `8°` flight tilt limit,
and `2.0 m/s²` horizontal acceleration limit. These are simulation-only values.

Output is standard MAVLink `SET_ATTITUDE_TARGET` with `type_mask=7`: attitude
and normalized thrust are active; body rates are ignored. A thin `pymavlink`
adapter sends the message. PX4 retains its attitude and angular-rate loops.

## Loss state machine

```text
WAIT_FOR_TAG -> READY -> TRACK -> SHORT_LOSS -> TRACK
                                  |
                                  +-- 10 s timeout -> DESCEND -> DISARM -> EXIT
```

- By 0.20 s, horizontal prediction is rapidly attenuated.
- By 0.25 s, horizontal attitude returns level and barometric altitude is held.
- Reacquisition before 10.0 s returns to `TRACK`.
- At 10.0 s, controlled descent latches; detection no longer cancels landing.
- After confirmed disarm, zero thrust remains commanded for 3.0 s before exit.

Heartbeat, OFFBOARD, height, attitude freshness, and watchdog failures retain
higher-priority abort/descent behavior. The system never labels this loss state
as horizontal position hold because no horizontal observation exists.

## Why no LiDAR or rangefinder?

A known tag size gives monocular pose metric scale whenever the tag is visible,
which is sufficient for this deliberately bounded relative-following task.
LiDAR becomes relevant if the scope expands to obstacle avoidance, tag-free
navigation, or mapping. A downward rangefinder can improve near-ground landing
robustness, but it is not required for the included takeoff, following, and
ground landing sequence.
