diff --git a/Makefile b/Makefile index e2f048f..1056b6a 100644 --- a/Makefile +++ b/Makefile @@ -170,6 +170,8 @@ sitl-test: sitl sitl-mavlink-test: sitl python3 sitl/test_mavlink_parameters.py --backend mt11 --build $(SITL_BUILD) python3 sitl/test_roi_motion.py --build $(SITL_BUILD) + python3 sitl/test_roi_motion.py --build $(SITL_BUILD) --jitter + python3 sitl/test_roi_motion.py --build $(SITL_BUILD) --jitter --rate $(SITL_VIDEO_PYTHON) sitl/test_gimbal_angle_hold.py --build $(SITL_BUILD) a8_sitl-mavlink-test: a8_sitl diff --git a/camera_app/.gitignore b/camera_app/.gitignore index 81dd94f..d20e2f7 100644 --- a/camera_app/.gitignore +++ b/camera_app/.gitignore @@ -10,6 +10,7 @@ tests/test_mp4_rollover tests/test_protocol tests/test_mavlink tests/test_mavlink_time +tests/test_telemetry_time tests/test_gimbal_rate tests/test_e5739 tests/test_thermal diff --git a/camera_app/Makefile b/camera_app/Makefile index adf4922..1adfa22 100644 --- a/camera_app/Makefile +++ b/camera_app/Makefile @@ -275,6 +275,11 @@ tests/test_mavlink_time: tests/test_mavlink_time.cpp src/protocol/mavlink_server $(HOST_CXX) $(CPPFLAGS) $(APP_CXXFLAGS) -ffunction-sections -fdata-sections \ -Wl,--gc-sections -o $@ $< src/protocol/mavlink.cpp -lm +tests/test_telemetry_time: tests/test_telemetry_time.cpp src/protocol/mavlink_server.cpp \ + include/camera_app/telemetry_time.h src/protocol/targeting.cpp src/protocol/mavlink.cpp $(MAVLINK_DEPS) + $(HOST_CXX) $(CPPFLAGS) $(APP_CXXFLAGS) -ffunction-sections -fdata-sections \ + -Wl,--gc-sections -o $@ $< src/protocol/targeting.cpp src/protocol/mavlink.cpp -lm + $(CONFIG_TEST_TARGET): tests/test_config.cpp src/config.cpp include/camera_app/config.h $(APCAM_HEADERS) $(HOST_CXX) $(CPPFLAGS) $(APP_CXXFLAGS) -o $@ $(filter %.cpp,$^) @@ -527,7 +532,7 @@ $(LIVE_VIDEO_SERVER_TEST_TARGET): tests/test_live_video_server.cpp \ src/streaming/live_video_server.cpp src/recording/mp4_minimp4.cpp \ src/log.cpp src/recording/video_metadata.cpp src/recording/metadata.cpp -lpthread -lm -test: system-stats-test thermal-mavlink-test media-overlay-test z1-overlay-race-test manual-control-test tests/test_gimbal_rate tests/test_mavlink_time tests/test_support_mavlink $(VIDEO_METADATA_TEST_TARGET) $(TEST_TARGET) $(MAVLINK_TEST_TARGET) $(MP4_TEST_TARGET) $(E5739_TEST_TARGET) \ +test: system-stats-test thermal-mavlink-test media-overlay-test z1-overlay-race-test manual-control-test tests/test_gimbal_rate tests/test_mavlink_time tests/test_telemetry_time tests/test_support_mavlink $(VIDEO_METADATA_TEST_TARGET) $(TEST_TARGET) $(MAVLINK_TEST_TARGET) $(MP4_TEST_TARGET) $(E5739_TEST_TARGET) \ $(MP4_SYNC_TEST_TARGET) \ $(MP4_ROLLOVER_TEST_TARGET) \ $(THERMAL_TEST_TARGET) $(RAW_THERMAL_TEST_TARGET) \ @@ -538,6 +543,7 @@ test: system-stats-test thermal-mavlink-test media-overlay-test z1-overlay-race- $(HOST_TARGET) $(A8_HOST_TARGET) ./$(TEST_TARGET) ./$(MAVLINK_TEST_TARGET) + ./tests/test_telemetry_time ./tests/test_mavlink_time ./tests/test_gimbal_rate ./tests/test_support_mavlink @@ -598,7 +604,7 @@ install-init: $(APP_INIT) $(APP_SELECTION) $(TIMESYNC) clean: rm -f tests/test_gimbal_rate - rm -f tests/test_mavlink_time + rm -f tests/test_telemetry_time tests/test_mavlink_time rm -f tests/test_support_mavlink tests/test_thermal_monitor $(MP4_SYNC_TEST_TARGET) $(MP4_ROLLOVER_TEST_TARGET) rm -f tests/test_system_stats tests/test_binlog_sys tests/test_manual_control $(TARGET) $(COMMON_SOURCES:.cpp=.o) $(COMMON_SOURCES:.cpp=.d) $(TEST_TARGET) $(MAVLINK_TEST_TARGET) $(MP4_TEST_TARGET) $(E5739_TEST_TARGET) $(THERMAL_TEST_TARGET) $(RAW_THERMAL_TEST_TARGET) $(RAW_THERMAL_SERVER_TEST_TARGET) $(LIVE_VIDEO_SERVER_TEST_TARGET) $(VIDEO_METADATA_TEST_TARGET) $(STILL_TEST_TARGET) $(AUTOFOCUS_TEST_TARGET) $(CONFIG_TEST_TARGET) $(TARGETING_TEST_TARGET) $(MT11_IMX586_TEST_TARGET) $(HOST_TARGET) $(A8_HOST_TARGET) rm -f tests/test.h264 tests/test.mp4 tests/test.mp4.fragmented \ diff --git a/camera_app/README.md b/camera_app/README.md index 17d4632..9471614 100644 --- a/camera_app/README.md +++ b/camera_app/README.md @@ -240,6 +240,41 @@ location capability and test ArduPilot-calculated angle targeting instead. After a camera-app restart, its device information is announced again so a connected ArduPilot instance sees the updated capability. +Vehicle boot timestamps are mapped into the camera's monotonic clock using the +minimum-delay estimator from ArduPilot `AP_RTC/JitterCorrection`. Separate +estimators for `AUTOPILOT_STATE_FOR_GIMBAL_DEVICE`, fallback `ATTITUDE`, and +`GLOBAL_POSITION_INT` remove variable transport delay and follow clock drift. +They use a 500 ms maximum estimated lag and 100-sample convergence window. +Duplicate and reordered timestamps do not refresh state; 32-bit timestamp wraps +are unwrapped, and a backwards clock after a stream outage is reacquired. +Legacy streams with zero timestamps use arrival time until a timestamp is known. +The estimator cannot measure a constant one-way delay or a device's internal +sampling delay. + +For earth-frame gimbal status, the camera interpolates vehicle yaw and yaw rate +at the gimbal feedback timestamp, with wrapped-angle interpolation. The yaw +history retains older samples when buffered telemetry shares a corrected +millisecond, replacing only the newest value. The outgoing +`GIMBAL_DEVICE_ATTITUDE_STATUS.time_boot_ms` identifies that feedback sample; +resending cached feedback keeps its timestamp. MT11 feedback has no device clock, +so its receive timestamp remains the best available sample-time estimate. If +matching vehicle history is unavailable, status uses the vehicle frame and marks +it accordingly. Extrapolation is limited to 250 ms; rate tracking stops when +required vehicle data cannot be predicted to the current time within that limit. +`ATTITUDE` takes over as soon as the primary attitude exceeds that prediction +horizon. Near vertical pitch, where its Euler yaw rate is undefined, it still +updates attitude metadata but cannot supply control prediction. Metadata ages +also include the estimated transport lag. + +Rate-based ROI tracking derives earth-frame pitch and yaw LOS rates directly +from `GLOBAL_POSITION_INT` position and NED velocity for a stationary geographic +ROI. It differentiates the great-circle bearing and elevation geometry without +rounding a future position to integer latitude/longitude. Motor yaw feed-forward +is the earth-frame LOS rate minus the vehicle yaw rate. The existing pointing +correction, rate limits, motor quantisation and lag still apply. At an undefined +bearing (within 10 cm horizontally of the ROI, or at a geographic pole), rate +tracking stops instead of producing a singular rate. + `uart.protocol` selects `none`, `siyi` or `mavlink` for the external flight-controller connection on `/dev/ttyAMA4`. The selected protocol uses 230400 baud, 8 data bits, no parity and one stop bit. This UART selection is @@ -275,9 +310,9 @@ by UTF-8 JSON without a trailing NUL. The schema is `apcg.telemetry.v1`: | `pts90k` | Frame timestamp in 90 kHz ticks. RTSP uses the 32-bit RTP timestamp (wraps); MP4 uses its video sample timeline starting at zero. | | `utc_us` | Camera system UTC time in microseconds since the Unix epoch, when telemetry was sampled. | | `position` | `lat_e7`, `lon_e7` in degrees × 10⁷; `alt_amsl_m`, `alt_relative_m` in metres; `age_ms`. | -| `vehicle_attitude` | `roll_rad`, `pitch_rad`, `yaw_rad`, `age_ms` and optional `yaw_rate_rad_s` (earth-frame Euler yaw rate). Prefers `AUTOPILOT_STATE_FOR_GIMBAL_DEVICE`; falls back to `ATTITUDE` after one second without it. | +| `vehicle_attitude` | `roll_rad`, `pitch_rad`, `yaw_rad`, `age_ms` and optional `yaw_rate_rad_s` (earth-frame Euler yaw rate). Prefers `AUTOPILOT_STATE_FOR_GIMBAL_DEVICE`; falls back to `ATTITUDE` after the 250 ms prediction horizon expires. | | `velocity` | `vn_m_s`, `ve_m_s`, `vd_m_s` (North/East/Down metres per second), and `age_ms`, from `GLOBAL_POSITION_INT` or `AUTOPILOT_STATE_FOR_GIMBAL_DEVICE`. | -| `gimbal_attitude` | Mounting-corrected `roll_rad`, `pitch_rad`, `yaw_rad` and `age_ms`; yaw is relative to the vehicle. | +| `gimbal_attitude` | Mounting-corrected `roll_rad`, `pitch_rad`, `yaw_rad`, `age_ms` and optional measured `yaw_rate_rad_s`; yaw and its rate are relative to the vehicle. | | `heading_rad` | Vehicle heading from the position message, in radians. | | `zoom` | Camera zoom factor, or null if unknown. | | `hfov_deg` | Effective horizontal field of view of this video stream in degrees, including optical zoom and digital crop; null if unknown. | diff --git a/camera_app/include/camera_app/metadata.h b/camera_app/include/camera_app/metadata.h index 6c02748..c1ab35b 100644 --- a/camera_app/include/camera_app/metadata.h +++ b/camera_app/include/camera_app/metadata.h @@ -37,6 +37,7 @@ struct ca_metadata { float gimbal_roll_rad; float gimbal_pitch_rad; float gimbal_yaw_rad; + float gimbal_yaw_rate_rad_s; /* 0 when unknown */ float zoom; /* Effective horizontal FOV after optical/digital zoom, degrees; 0 unknown. @@ -45,18 +46,23 @@ struct ca_metadata { }; void ca_metadata_set_model(const char *model); +/* Optional timestamps are sample times in CLOCK_MONOTONIC milliseconds; + * zero retains arrival-time behaviour for local/legacy producers. */ void ca_metadata_set_position(int32_t lat_e7, int32_t lon_e7, float alt_amsl_m, - float alt_relative_m, float heading_rad); + float alt_relative_m, float heading_rad, uint64_t timestamp_ms = 0); void ca_metadata_set_vehicle_attitude(float roll_rad, float pitch_rad, float yaw_rad); void ca_metadata_set_gimbal_attitude(float roll_rad, float pitch_rad, float yaw_rad); -void ca_metadata_set_velocity(float vn_m_s, float ve_m_s, float vd_m_s); +void ca_metadata_set_velocity(float vn_m_s, float ve_m_s, float vd_m_s, uint64_t timestamp_ms = 0); void ca_metadata_set_vehicle_attitude_motion(float roll_rad, float pitch_rad, - float yaw_rad, float yaw_rate_rad_s); + float yaw_rad, float yaw_rate_rad_s, uint64_t timestamp_ms = 0); /* Preserve the backend sample time when polling cached gimbal state. */ void ca_metadata_set_gimbal_attitude_sample(float roll_rad, float pitch_rad, float yaw_rad, uint64_t timestamp_ms); +void ca_metadata_set_gimbal_attitude_motion(float roll_rad, float pitch_rad, + float yaw_rad, float yaw_rate_rad_s, + uint64_t timestamp_ms); void ca_metadata_set_zoom(float zoom); /* copies the current state; sources older than CA_METADATA_MAX_AGE_MS are * reported as absent */ diff --git a/camera_app/include/camera_app/targeting.h b/camera_app/include/camera_app/targeting.h index 71d4352..41814bb 100644 --- a/camera_app/include/camera_app/targeting.h +++ b/camera_app/include/camera_app/targeting.h @@ -17,4 +17,12 @@ bool ca_targeting_global_angles(int32_t vehicle_lat_e7, float *pitch_rad, float *yaw_earth_rad); +/* Instantaneous earth-frame LOS rates to a stationary ROI, using vehicle NED + * velocity. Returns false at undefined bearings (within 10 cm or at a pole). */ +bool ca_targeting_global_rates(int32_t vehicle_lat_e7, int32_t vehicle_lon_e7, + float vehicle_alt_amsl_m, int32_t target_lat_e7, + int32_t target_lon_e7, float target_alt_amsl_m, + float vn, float ve, float vd, + float *pitch_rate, float *yaw_rate); + #endif diff --git a/camera_app/include/camera_app/telemetry_time.h b/camera_app/include/camera_app/telemetry_time.h new file mode 100644 index 0000000..459da06 --- /dev/null +++ b/camera_app/include/camera_app/telemetry_time.h @@ -0,0 +1,118 @@ +/* + * Minimum-delay clock mapping adapted from ArduPilot AP_RTC/JitterCorrection. + * Copyright ArduPilot contributors. SPDX-License-Identifier: GPL-3.0-or-later + */ +#pragma once + +#include +#include + +/* Zero-initialise each instance. Use a separate clock per telemetry stream: + * their transport delays differ, and some senders use different boot epochs. + * This estimates variable delay, not the unknown minimum one-way latency. */ +struct ca_telemetry_clock { + int64_t offset_us, minimum_us; + uint64_t last_raw, remote_ticks, received_us; + unsigned count; + bool initialised; + + bool sample(uint64_t raw, uint32_t tick_us, uint64_t now_us, + uint64_t &sample_ms, bool &reset) + { + reset = false; + // Zero is also used by legacy senders with no usable timestamp. + if (raw == 0 && !initialised) { + sample_ms = now_us / 1000; + return true; + } + if (raw > uint64_t(INT64_MAX) / tick_us) return false; + int64_t delta = initialised ? int64_t(raw) - int64_t(last_raw) : 0; + // Handles 32-bit milliseconds and ArduPilot's uint32 micros() carried + // in the nominally 64-bit AUTOPILOT_STATE time_boot_us field. + if (initialised && raw <= UINT32_MAX && last_raw <= UINT32_MAX) { + if (delta < -INT64_C(0x80000000)) delta += INT64_C(0x100000000); + else if (delta > INT64_C(0x80000000)) delta -= INT64_C(0x100000000); + } + if (initialised && delta <= 0) { + // Ignore duplicates/reordering without refreshing sample age. + // A backwards clock after a stream outage indicates a restart. + if (delta >= -int64_t(1000000 / tick_us) || + now_us - received_us <= 1000000) return false; + *this = {}; + reset = true; + } + remote_ticks = initialised ? remote_ticks + delta : raw; + last_raw = raw; + received_us = now_us; + const int64_t remote_us = int64_t(remote_ticks * tick_us); + const int64_t diff = int64_t(now_us) - remote_us; + if (!initialised || diff < offset_us) offset_us = diff; + initialised = true; + int64_t estimate = remote_us + offset_us; + // The same 500 ms maximum lag and 100-sample drift window as AP_RTC. + if (estimate + 500000 < int64_t(now_us)) { + estimate = int64_t(now_us) - 500000; + offset_us = estimate - remote_us; + } + if (count == 0 || diff < minimum_us) minimum_us = diff; + if (++count == 100) { + offset_us = minimum_us; + count = 0; + } + sample_ms = uint64_t(estimate > 0 ? estimate : 0) / 1000; + return true; + } +}; + +/* Vehicle yaw in the camera's monotonic clock. Rates are Euler yaw rates. + * Keep enough samples to bracket cached gimbal feedback even at 100 Hz. */ +struct ca_yaw_history { + static constexpr unsigned prediction_ms = 250; + struct sample { uint64_t ms; float yaw, rate; } samples[128]; + unsigned count, next; + + void add(uint64_t ms, float yaw, float rate) + { + if (count) { + sample &last = samples[(next + 127) % 128]; + // Buffered samples can share a corrected millisecond. Replace + // the newest value without losing older feedback's brackets. + // Clock resets are handled explicitly by the caller. + if (ms < last.ms) return; + if (ms == last.ms) { + last = {ms, yaw, rate}; + return; + } + } + samples[next] = {ms, yaw, rate}; + next = (next + 1) % 128; + if (count < 128) count++; + } + + bool at(uint64_t ms, float &yaw, float &rate) const + { + if (!count) return false; + const sample &last = samples[(next + 127) % 128]; + if (ms >= last.ms) { + if (ms - last.ms > prediction_ms) return false; + rate = last.rate; + yaw = remainderf(last.yaw + rate * ((ms - last.ms) * .001f), 2 * float(M_PI)); + return true; + } + const sample *previous = &samples[(next + 128 - count) % 128]; + if (ms < previous->ms) return false; + for (unsigned i = 1; i < count; i++) { + const sample &s = samples[(next + 128 - count + i) % 128]; + if (ms <= s.ms) { + if (s.ms - previous->ms > prediction_ms) return false; + float fraction = float(ms - previous->ms) / (s.ms - previous->ms); + yaw = remainderf(previous->yaw + fraction * remainderf(s.yaw - previous->yaw, + 2 * float(M_PI)), 2 * float(M_PI)); + rate = previous->rate + fraction * (s.rate - previous->rate); + return true; + } + previous = &s; + } + return false; + } +}; diff --git a/camera_app/src/protocol/mavlink_server.cpp b/camera_app/src/protocol/mavlink_server.cpp index 9753081..3d1ccb6 100644 --- a/camera_app/src/protocol/mavlink_server.cpp +++ b/camera_app/src/protocol/mavlink_server.cpp @@ -17,6 +17,7 @@ #include "camera_app/media.h" #include "camera_app/metadata.h" #include "camera_app/targeting.h" +#include "camera_app/telemetry_time.h" #include #include @@ -48,8 +49,7 @@ #define CA_EVENT_CLIENT_BASE UINT64_C(0x100) #define CA_MAVLINK_UART_OUTPUT (MAVLINK_MAX_PACKET_LEN * 16U) #define VEHICLE_ATTITUDE_TIMEOUT_MS 1000U -#define VEHICLE_POSITION_TIMEOUT_MS 1500U -#define VEHICLE_PREDICTION_MS 250U +#define VEHICLE_PREDICTION_MS ca_yaw_history::prediction_ms #define TARGET_LOCATION_INTERVAL_MS 100U #define TELEMETRY_INTERVAL_REQUEST_MS 5000U #define THERMAL_DEFAULT_INTERVAL_MS 200U @@ -136,14 +136,16 @@ struct ca_mavlink_server { uint64_t last_target_location_ms; uint64_t vehicle_attitude_updated_ms; uint64_t vehicle_position_updated_ms; + uint64_t primary_attitude_ms; + bool vehicle_attitude_primary; + ca_telemetry_clock attitude_clock, fallback_clock, position_clock; + ca_yaw_history vehicle_yaw_history; uint64_t recording_started_ms; uint64_t next_capture_ms; uint32_t image_count; int32_t next_image_index; int32_t captures_remaining; float capture_interval_s; - float vehicle_yaw_rad; - float vehicle_yaw_rate_rad_s; int32_t vehicle_lat_e7; int32_t vehicle_lon_e7; float vehicle_alt_amsl_m; @@ -1050,14 +1052,10 @@ static bool current_vehicle_attitude(const struct ca_mavlink_server *server, VEHICLE_ATTITUDE_TIMEOUT_MS) { return false; } - float elapsed = (float)(now - server->vehicle_attitude_updated_ms) / - 1000.0f; - elapsed = fminf(elapsed, VEHICLE_PREDICTION_MS * 0.001f); - if (yaw != NULL) { - *yaw = wrap_pi_f(server->vehicle_yaw_rad + - server->vehicle_yaw_rate_rad_s * elapsed); - } - if (yaw_rate != NULL) *yaw_rate = server->vehicle_yaw_rate_rad_s; + float aligned_yaw, aligned_rate; + if (!server->vehicle_yaw_history.at(now, aligned_yaw, aligned_rate)) return false; + if (yaw != NULL) *yaw = aligned_yaw; + if (yaw_rate != NULL) *yaw_rate = aligned_rate; return true; } @@ -1067,8 +1065,7 @@ static bool current_vehicle_position(const struct ca_mavlink_server *server, { uint64_t now = monotonic_ms(); if (!server->have_vehicle_position || - now - server->vehicle_position_updated_ms > - VEHICLE_POSITION_TIMEOUT_MS) { + now - server->vehicle_position_updated_ms > VEHICLE_PREDICTION_MS) { return false; } int32_t lat = server->vehicle_lat_e7, lon = server->vehicle_lon_e7; @@ -1171,21 +1168,19 @@ static void update_target_location(struct ca_mavlink_server *server, uint64_t no stop_tracking_rate(server); return; } - /* Differentiate a short position/yaw prediction, rather than noisy - * differences between incoming telemetry samples, for feed-forward. */ - float future_pitch, future_yaw; - const float horizon = .1f, radians = PI_F / 180.0f; - if (!ca_targeting_predict_position(&lat, &lon, &alt, server->vehicle_vn_m_s, - server->vehicle_ve_m_s, server->vehicle_vd_m_s, horizon) || - !ca_targeting_global_angles(lat, lon, alt, server->target_lat_e7, - server->target_lon_e7, server->target_alt_amsl_m, &future_pitch, &future_yaw)) { + const float radians = PI_F / 180.0f; + float pitch_rate, yaw_rate; + if (!ca_targeting_global_rates(lat, lon, alt, server->target_lat_e7, + server->target_lon_e7, server->target_alt_amsl_m, + server->vehicle_vn_m_s, server->vehicle_ve_m_s, server->vehicle_vd_m_s, + &pitch_rate, &yaw_rate)) { stop_tracking_rate(server); return; } float desired[2] = {pitch, yaw}; float feedback[2] = {attitude.pitch_rad, attitude.yaw_rad}; - float feed_forward[2] = {(future_pitch - pitch) / horizon, - wrap_pi_f(future_yaw - yaw_earth) / horizon - vehicle_yaw_rate}; + // LOS motion is earth-frame. The yaw motor must also cancel vehicle yaw. + float feed_forward[2] = {pitch_rate, yaw_rate - vehicle_yaw_rate}; const float minimum[2] = {APCAM_GIMBAL_PITCH_MIN * radians, APCAM_GIMBAL_YAW_MIN * radians}; const float maximum[2] = {APCAM_GIMBAL_PITCH_MAX * radians, APCAM_GIMBAL_YAW_MAX * radians}; float output[2]; @@ -1247,8 +1242,8 @@ static void pack_gimbal_status(struct ca_mavlink_server *server, uint16_t flags = GIMBAL_DEVICE_FLAGS_ROLL_LOCK | GIMBAL_DEVICE_FLAGS_PITCH_LOCK | GIMBAL_DEVICE_FLAGS_YAW_IN_VEHICLE_FRAME; - bool have_vehicle = current_vehicle_attitude( - server, &vehicle_yaw, &vehicle_yaw_rate); + bool have_vehicle = server->vehicle_yaw_history.at( + attitude->timestamp_ms, vehicle_yaw, vehicle_yaw_rate); if (have_vehicle) { flags |= GIMBAL_DEVICE_FLAGS_ACCEPTS_YAW_IN_EARTH_FRAME; } @@ -1262,7 +1257,8 @@ static void pack_gimbal_status(struct ca_mavlink_server *server, euler_to_quaternion(attitude->roll_rad, attitude->pitch_rad, yaw, q); mavlink_gimbal_device_attitude_status_t status = {}; - status.time_boot_ms = boot_ms(server); + status.time_boot_ms = attitude->timestamp_ms >= server->started_ms ? + uint32_t(attitude->timestamp_ms - server->started_ms) : 0; memcpy(status.q, q, sizeof(q)); status.angular_velocity_x = attitude->roll_rate_rad_s; status.angular_velocity_y = attitude->pitch_rate_rad_s; @@ -1593,6 +1589,21 @@ static void quaternion_to_euler(const float q[4], float *roll, float *pitch, 1.0f - 2.0f * (q[2] * q[2] + q[3] * q[3])); } +static void save_vehicle_attitude(struct ca_mavlink_server *server, + float roll, float pitch, float yaw, float yaw_rate, + uint64_t sample_ms, bool primary, bool reset) +{ + if (reset || primary != server->vehicle_attitude_primary) { + server->vehicle_yaw_history = {}; + } + server->vehicle_attitude_primary = primary; + server->vehicle_attitude_updated_ms = sample_ms; + server->have_vehicle_attitude = true; + if (primary) server->primary_attitude_ms = sample_ms; + server->vehicle_yaw_history.add(sample_ms, yaw, yaw_rate); + ca_metadata_set_vehicle_attitude_motion(roll, pitch, yaw, yaw_rate, sample_ms); +} + static void handle_autopilot_state_for_gimbal( struct ca_mavlink_server *server, const mavlink_message_t *message) @@ -1626,22 +1637,22 @@ static void handle_autopilot_state_for_gimbal( float yaw_rate = message->len >= 57U ? state.angular_velocity_z : NAN; if (!isfinite(yaw_rate)) yaw_rate = state.feed_forward_angular_velocity_z; if (!isfinite(yaw_rate)) yaw_rate = 0.0f; - ca_metadata_set_vehicle_attitude_motion(roll, pitch, yaw, yaw_rate); - ca_metadata_set_velocity(state.vx, state.vy, state.vz); + uint64_t sample_ms; + bool reset; + if (!server->attitude_clock.sample(state.time_boot_us, 1, monotonic_ms() * 1000, + sample_ms, reset)) return; + save_vehicle_attitude(server, roll, pitch, yaw, yaw_rate, sample_ms, true, reset); + ca_metadata_set_velocity(state.vx, state.vy, state.vz, sample_ms); CA_BINLOG(CA_LOG_ATT, ca_log_att, .boot_ms=(uint32_t)(state.time_boot_us/1000), .source=1, .roll=roll*57.295779513f,.pitch=pitch*57.295779513f, .yaw=yaw*57.295779513f,.rollrate=NAN,.pitchrate=NAN,.yawrate=yaw_rate*57.295779513f); - server->vehicle_yaw_rad = yaw; - server->vehicle_yaw_rate_rad_s = yaw_rate; - server->vehicle_attitude_updated_ms = monotonic_ms(); - server->have_vehicle_attitude = true; } static void handle_attitude(struct ca_mavlink_server *server, const mavlink_message_t *message) { - /* Metadata fallback only: never overwrite a fresh gimbal-state quaternion - * with the independently scheduled ATTITUDE stream. */ + /* Never overwrite a fresh gimbal-state quaternion with the independently + * scheduled ATTITUDE stream. Map its clock even while it is a standby. */ mavlink_attitude_t attitude; if (message->sysid != server->autopilot_system_id || message->compid != server->autopilot_component_id) { @@ -1656,15 +1667,27 @@ static void handle_attitude(struct ca_mavlink_server *server, .roll=attitude.roll*57.295779513f,.pitch=attitude.pitch*57.295779513f, .yaw=attitude.yaw*57.295779513f,.rollrate=attitude.rollspeed*57.295779513f, .pitchrate=attitude.pitchspeed*57.295779513f,.yawrate=attitude.yawspeed*57.295779513f); - if (server->have_vehicle_attitude && - monotonic_ms() - server->vehicle_attitude_updated_ms < VEHICLE_ATTITUDE_TIMEOUT_MS) return; + uint64_t sample_ms; + bool reset; + const uint64_t now = monotonic_ms(); + if (!server->fallback_clock.sample(attitude.time_boot_ms, 1000, now * 1000, + sample_ms, reset)) return; + if (server->primary_attitude_ms && + now - server->primary_attitude_ms <= VEHICLE_PREDICTION_MS) return; /* ATTITUDE rates are body rates; convert to Euler yaw rate. */ float cp = cosf(attitude.pitch); float yaw_rate = fabsf(cp) > 0.01f ? (attitude.pitchspeed * sinf(attitude.roll) + attitude.yawspeed * cosf(attitude.roll)) / cp : NAN; - ca_metadata_set_vehicle_attitude_motion(attitude.roll, attitude.pitch, - attitude.yaw, yaw_rate); + if (!isfinite(yaw_rate)) { + // Vertical pitch makes Euler yaw rate undefined, not the measured + // attitude. Preserve metadata without feeding an invalid rate to ROI. + ca_metadata_set_vehicle_attitude_motion(attitude.roll, attitude.pitch, + attitude.yaw, NAN, sample_ms); + return; + } + save_vehicle_attitude(server, attitude.roll, attitude.pitch, attitude.yaw, + yaw_rate, sample_ms, false, reset); } static void handle_system_time(struct ca_mavlink_server *server, @@ -1717,13 +1740,17 @@ static void handle_global_position_int( lon_e7 < -1800000000 || lon_e7 > 1800000000) { return; } + uint64_t sample_ms; + bool reset; + if (!server->position_clock.sample(position.time_boot_ms, 1000, monotonic_ms() * 1000, + sample_ms, reset)) return; ca_metadata_set_position(lat_e7, lon_e7, alt_mm * 0.001f, relative_alt_mm * 0.001f, heading_cdeg == 0xffffU ? NAN - : heading_cdeg * 0.01f * (float)(M_PI / 180.0)); + : heading_cdeg * 0.01f * (float)(M_PI / 180.0), sample_ms); ca_metadata_set_velocity(position.vx * 0.01f, position.vy * 0.01f, - position.vz * 0.01f); + position.vz * 0.01f, sample_ms); CA_BINLOG(CA_LOG_POS, ca_log_pos, .boot_ms=position.time_boot_ms, .lat=lat_e7,.lon=lon_e7,.alt=alt_mm*.001f,.relalt=relative_alt_mm*.001f, .vn=position.vx*.01f,.ve=position.vy*.01f,.vd=position.vz*.01f); @@ -1734,7 +1761,7 @@ static void handle_global_position_int( server->vehicle_vn_m_s = position.vx * 0.01f; server->vehicle_ve_m_s = position.vy * 0.01f; server->vehicle_vd_m_s = position.vz * 0.01f; - server->vehicle_position_updated_ms = monotonic_ms(); + server->vehicle_position_updated_ms = sample_ms; server->have_vehicle_position = true; if (first_position) { ca_log("MAVLink vehicle position available"); @@ -2956,9 +2983,10 @@ void ca_mavlink_server_periodic(struct ca_mavlink_server *server) ca_metadata_set_zoom(ca_media_zoom(server->media)); if (ca_backend_gimbal_attitude(server->backend, &attitude) && now >= attitude.timestamp_ms && now - attitude.timestamp_ms < 1000U) { - ca_metadata_set_gimbal_attitude_sample(attitude.roll_rad, + ca_metadata_set_gimbal_attitude_motion(attitude.roll_rad, attitude.pitch_rad, - attitude.yaw_rad, attitude.timestamp_ms); + attitude.yaw_rad, attitude.yaw_rate_rad_s, + attitude.timestamp_ms); if (have_peer(server) && now - server->last_attitude_status_ms >= 200U) { mavlink_message_t message; server->last_attitude_status_ms = now; diff --git a/camera_app/src/protocol/targeting.cpp b/camera_app/src/protocol/targeting.cpp index cd8da5a..1be0b7b 100644 --- a/camera_app/src/protocol/targeting.cpp +++ b/camera_app/src/protocol/targeting.cpp @@ -70,3 +70,37 @@ bool ca_targeting_global_angles(int32_t vehicle_lat_e7, *pitch_rad = (float)atan2(altitude_m, horizontal_m); return true; } + + +bool ca_targeting_global_rates(int32_t vehicle_lat_e7, int32_t vehicle_lon_e7, + float vehicle_alt_amsl_m, int32_t target_lat_e7, + int32_t target_lon_e7, float target_alt_amsl_m, + float vn, float ve, float vd, + float *pitch_rate, float *yaw_rate) +{ + if (!pitch_rate || !yaw_rate || + !valid_location(vehicle_lat_e7, vehicle_lon_e7, vehicle_alt_amsl_m) || + !valid_location(target_lat_e7, target_lon_e7, target_alt_amsl_m) || + !isfinite(vn) || !isfinite(ve) || !isfinite(vd)) return false; + const double lat = vehicle_lat_e7 * 1.0e-7 * DEG_TO_RAD; + const double target_lat = target_lat_e7 * 1.0e-7 * DEG_TO_RAD; + const double dlon = remainder((double(target_lon_e7) - vehicle_lon_e7) * 1.0e-7 * DEG_TO_RAD, + 2 * PI_D); + const double s = sin(lat), c = cos(lat), st = sin(target_lat), ct = cos(target_lat); + if (fabs(c) < 1.0e-6) return false; + const double x = c * st - s * ct * cos(dlon), y = ct * sin(dlon); + const double norm = hypot(x, y); + if (norm * EARTH_RADIUS_M < 0.1) return false; + const double range = EARTH_RADIUS_M * atan2(norm, s * st + c * ct * cos(dlon)); + const double up = double(target_alt_amsl_m) - vehicle_alt_amsl_m; + const double range_rate = -(vn * x + ve * y) / norm; + // Differentiate the same great-circle bearing used by global_angles. + // Avoid rounding a predicted lat/lon back to integer E7 coordinates. + const double lat_rate = vn / EARTH_RADIUS_M; + const double dlon_rate = -ve / (EARTH_RADIUS_M * c); + const double dx = (-s * st - c * ct * cos(dlon)) * lat_rate + s * ct * sin(dlon) * dlon_rate; + const double dy = ct * cos(dlon) * dlon_rate; + *yaw_rate = float((x * dy - y * dx) / (norm * norm)); + *pitch_rate = float((range * vd - up * range_rate) / (range * range + up * up)); + return isfinite(*yaw_rate) && isfinite(*pitch_rate); +} diff --git a/camera_app/src/recording/metadata.cpp b/camera_app/src/recording/metadata.cpp index 8151fe2..3fe4dae 100644 --- a/camera_app/src/recording/metadata.cpp +++ b/camera_app/src/recording/metadata.cpp @@ -31,6 +31,7 @@ static struct { float gimbal_roll_rad; float gimbal_pitch_rad; float gimbal_yaw_rad; + float gimbal_yaw_rate_rad_s; float zoom; } state = {.lock = PTHREAD_MUTEX_INITIALIZER}; @@ -49,7 +50,7 @@ void ca_metadata_set_model(const char *model) } void ca_metadata_set_position(int32_t lat_e7, int32_t lon_e7, float alt_amsl_m, - float alt_relative_m, float heading_rad) + float alt_relative_m, float heading_rad, uint64_t timestamp_ms) { pthread_mutex_lock(&state.lock); state.lat_e7 = lat_e7; @@ -57,7 +58,7 @@ void ca_metadata_set_position(int32_t lat_e7, int32_t lon_e7, float alt_amsl_m, state.alt_amsl_m = alt_amsl_m; state.alt_relative_m = alt_relative_m; state.heading_rad = heading_rad; - state.position_ms = monotonic_ms(); + state.position_ms = timestamp_ms ? timestamp_ms : monotonic_ms(); pthread_mutex_unlock(&state.lock); } @@ -67,26 +68,26 @@ void ca_metadata_set_vehicle_attitude(float roll_rad, float pitch_rad, ca_metadata_set_vehicle_attitude_motion(roll_rad, pitch_rad, yaw_rad, NAN); } -void ca_metadata_set_velocity(float vn_m_s, float ve_m_s, float vd_m_s) +void ca_metadata_set_velocity(float vn_m_s, float ve_m_s, float vd_m_s, uint64_t timestamp_ms) { if (!isfinite(vn_m_s) || !isfinite(ve_m_s) || !isfinite(vd_m_s)) return; pthread_mutex_lock(&state.lock); state.vn_m_s = vn_m_s; state.ve_m_s = ve_m_s; state.vd_m_s = vd_m_s; - state.velocity_ms = monotonic_ms(); + state.velocity_ms = timestamp_ms ? timestamp_ms : monotonic_ms(); pthread_mutex_unlock(&state.lock); } void ca_metadata_set_vehicle_attitude_motion(float roll_rad, float pitch_rad, - float yaw_rad, float yaw_rate_rad_s) + float yaw_rad, float yaw_rate_rad_s, uint64_t timestamp_ms) { pthread_mutex_lock(&state.lock); state.vehicle_yaw_rate_rad_s = yaw_rate_rad_s; state.vehicle_roll_rad = roll_rad; state.vehicle_pitch_rad = pitch_rad; state.vehicle_yaw_rad = yaw_rad; - state.vehicle_attitude_ms = monotonic_ms(); + state.vehicle_attitude_ms = timestamp_ms ? timestamp_ms : monotonic_ms(); pthread_mutex_unlock(&state.lock); } @@ -98,11 +99,19 @@ void ca_metadata_set_gimbal_attitude(float roll_rad, float pitch_rad, void ca_metadata_set_gimbal_attitude_sample(float roll_rad, float pitch_rad, float yaw_rad, uint64_t timestamp_ms) +{ + ca_metadata_set_gimbal_attitude_motion(roll_rad, pitch_rad, yaw_rad, NAN, timestamp_ms); +} + +void ca_metadata_set_gimbal_attitude_motion(float roll_rad, float pitch_rad, + float yaw_rad, float yaw_rate_rad_s, + uint64_t timestamp_ms) { pthread_mutex_lock(&state.lock); state.gimbal_roll_rad = roll_rad; state.gimbal_pitch_rad = pitch_rad; state.gimbal_yaw_rad = yaw_rad; + state.gimbal_yaw_rate_rad_s = yaw_rate_rad_s; state.gimbal_attitude_ms = timestamp_ms + 1U; pthread_mutex_unlock(&state.lock); } @@ -155,6 +164,7 @@ void ca_metadata_snapshot(struct ca_metadata *snapshot) snapshot->gimbal_roll_rad = state.gimbal_roll_rad; snapshot->gimbal_pitch_rad = state.gimbal_pitch_rad; snapshot->gimbal_yaw_rad = state.gimbal_yaw_rad; + snapshot->gimbal_yaw_rate_rad_s = state.gimbal_yaw_rate_rad_s; } snapshot->zoom = state.zoom; pthread_mutex_unlock(&state.lock); diff --git a/camera_app/src/recording/video_metadata.cpp b/camera_app/src/recording/video_metadata.cpp index 440065c..d28aaf1 100644 --- a/camera_app/src/recording/video_metadata.cpp +++ b/camera_app/src/recording/video_metadata.cpp @@ -58,9 +58,13 @@ size_t ca_video_metadata_json(const struct ca_metadata *m, JSON(",\"gimbal_attitude\":"); if (m->have_gimbal_attitude && isfinite(m->gimbal_roll_rad) && isfinite(m->gimbal_pitch_rad) && isfinite(m->gimbal_yaw_rad)) { - JSON("{\"roll_rad\":%.6f,\"pitch_rad\":%.6f,\"yaw_rad\":%.6f,\"age_ms\":%u}", + JSON("{\"roll_rad\":%.6f,\"pitch_rad\":%.6f,\"yaw_rad\":%.6f,\"age_ms\":%u", (double)m->gimbal_roll_rad, (double)m->gimbal_pitch_rad, (double)m->gimbal_yaw_rad, m->gimbal_attitude_age_ms); + if (isfinite(m->gimbal_yaw_rate_rad_s)) { + JSON(",\"yaw_rate_rad_s\":%.6f", (double)m->gimbal_yaw_rate_rad_s); + } + JSON("}"); } else JSON("null"); JSON(",\"heading_rad\":"); if (m->have_position && isfinite(m->heading_rad)) JSON("%.6f", (double)m->heading_rad); diff --git a/camera_app/tests/test_targeting.cpp b/camera_app/tests/test_targeting.cpp index cced6ef..d40992d 100644 --- a/camera_app/tests/test_targeting.cpp +++ b/camera_app/tests/test_targeting.cpp @@ -88,6 +88,29 @@ int main(void) printf("circling ROI: peak bearing error %.3f -> %.3f degrees\n", degrees(old_worst), degrees(worst)); - puts("targeting tests passed"); + float pr, yr; + // Stationary target north, aircraft translating east: clockwise bearing + // decreases. Northward motion towards an elevated target raises pitch. + assert(ca_targeting_global_rates(latitude, longitude, 600, north_100m, longitude, 650, + 0, 10, 0, &pr, &yr)); + assert(fabsf(yr + .1f) < .0001f && fabsf(pr) < .0001f); + assert(ca_targeting_global_rates(latitude, longitude, 600, north_100m, longitude, 650, + 10, 0, 0, &pr, &yr)); + assert(fabsf(pr - .04f) < .0001f && fabsf(yr) < .0001f); + assert(ca_targeting_global_rates(latitude, longitude, 600, north_100m, longitude, 650, + 0, 0, 5, &pr, &yr)); + assert(fabsf(pr - .04f) < .0001f); + assert(ca_targeting_global_rates(latitude, longitude, 600, north_100m, longitude, 650, + 0, 0, 0, &pr, &yr)); + assert(pr == 0 && yr == 0); + // Date-line crossing is a short eastward LOS, not a nearly-global vector. + assert(ca_targeting_global_rates(latitude, 1799999500, 600, latitude, -1799999500, 600, + 1, 0, 0, &pr, &yr)); + assert(yr > .1f && yr < .12f && fabsf(pr) < .0001f); + assert(!ca_targeting_global_rates(latitude, longitude, 600, latitude, longitude, 500, + 1, 0, 0, &pr, &yr)); + assert(!ca_targeting_global_rates(latitude, longitude, 600, north_100m, longitude, 500, + NAN, 0, 0, &pr, &yr)); + puts("targeting position, LOS angle and analytic rate tests passed"); return 0; } diff --git a/camera_app/tests/test_telemetry_time.cpp b/camera_app/tests/test_telemetry_time.cpp new file mode 100644 index 0000000..8f06a8a --- /dev/null +++ b/camera_app/tests/test_telemetry_time.cpp @@ -0,0 +1,237 @@ +/* Exercise the actual MAVLink handlers with a deterministic local clock. */ +#define clock_gettime test_clock_gettime +#include "../src/protocol/mavlink_server.cpp" +#undef clock_gettime +#include +#include + +static uint64_t now_ms = 100000; +static uint64_t metadata_attitude_ms, metadata_position_ms; +static float metadata_pitch, metadata_yaw_rate; +int test_clock_gettime(clockid_t clock, struct timespec *value) +{ + assert(clock == CLOCK_MONOTONIC); + value->tv_sec = now_ms / 1000; + value->tv_nsec = (now_ms % 1000) * 1000000; + return 0; +} +void ca_log(const char *, ...) {} +uint64_t ca_binlog_time_us() { return now_ms * 1000; } +void ca_binlog_emit(uint8_t, const void *, size_t) {} +void ca_metadata_set_vehicle_attitude_motion(float, float pitch, float, float rate, uint64_t stamp) +{ metadata_attitude_ms = stamp; metadata_pitch = pitch; metadata_yaw_rate = rate; } +void ca_metadata_set_position(int32_t, int32_t, float, float, float, uint64_t stamp) +{ metadata_position_ms = stamp; } +void ca_metadata_set_velocity(float, float, float, uint64_t) {} + +static void attitude(ca_mavlink_server &server, uint64_t remote_us, float yaw, float rate) +{ + float q[4]; + euler_to_quaternion(0, 0, yaw, q); + mavlink_message_t msg; + mavlink_msg_autopilot_state_for_gimbal_device_pack(42, 1, &msg, 42, 154, + remote_us, q, 0, 0, 0, 0, 0, rate, 0, 0, rate); + handle_autopilot_state_for_gimbal(&server, &msg); +} + +static void position(ca_mavlink_server &server, uint32_t remote_ms, int32_t lat) +{ + mavlink_message_t msg; + mavlink_msg_global_position_int_pack(42, 1, &msg, remote_ms, lat, 1490000000, + 600000, 100000, 1000, 0, 0, 0); + handle_global_position_int(&server, &msg); +} + +static void clock_tests() +{ + ca_telemetry_clock clock {}; + uint64_t stamp; + bool reset; + // Different boot epochs, transport delays 0..80 ms, multiple drift windows. + for (unsigned i = 0; i < 350; i++) { + uint64_t remote = 2000000 + i * 100000; + unsigned jitter = (i % 5) * 20000; + assert(clock.sample(remote, 1, remote + 98000000 + jitter, stamp, reset)); + assert(stamp == (remote + 98000000) / 1000 && !reset); + } + assert(!clock.sample(clock.last_raw, 1, clock.received_us + 10000, stamp, reset)); + assert(!clock.sample(clock.last_raw - 100000, 1, clock.received_us + 20000, stamp, reset)); + // A remote restart after an outage reacquires the offset. + assert(clock.sample(1000000, 1, clock.received_us + 2000000, stamp, reset) && reset); + assert(stamp == clock.received_us / 1000); + // uint32 micros carried in uint64 time_boot_us, and uint32 milliseconds. + for (uint32_t tick : {1U, 1000U}) { + clock = {}; + assert(clock.sample(UINT32_MAX - 999, tick, 100000000, stamp, reset)); + assert(clock.sample(1000, tick, 100000000 + 2000ULL * tick, stamp, reset)); + assert(stamp == (100000000 + 2000ULL * tick) / 1000 && !reset); + assert(!clock.sample(UINT32_MAX - 499, tick, clock.received_us + 1000, stamp, reset)); + } + // The AP_RTC maximum-lag bound and clock drift recovery. + clock = {}; + assert(clock.sample(1000000, 1, 100000000, stamp, reset)); + assert(clock.sample(1100000, 1, 101000000, stamp, reset)); + assert(stamp == 100500); +} + +static void history_burst_tests() +{ + ca_telemetry_clock clock {}; + ca_yaw_history history {}; + uint64_t stamp; + bool reset; + assert(clock.sample(990000, 1, 1070000, stamp, reset)); + history.add(stamp, 0, 1); + assert(clock.sample(1000000, 1, 1080000, stamp, reset)); + history.add(stamp, .01f, 1); + // Buffered packets can map to the same millisecond as the offset improves. + assert(clock.sample(1010000, 1, 1080000, stamp, reset)); + history.add(stamp, .02f, 2); + float yaw, rate; + assert(history.count == 2); + assert(history.at(1075, yaw, rate)); + assert(fabsf(yaw - .01f) < 1e-6 && rate == 1.5f); + assert(history.at(1080, yaw, rate) && yaw == .02f && rate == 2); + history.add(1079, 9, 9); // a regressing estimate must not destroy history + assert(history.at(1075, yaw, rate) && fabsf(yaw - .01f) < 1e-6); + + ca_mavlink_server server {}; + server.vehicle_yaw_history = history; + save_vehicle_attitude(&server, 0, 0, 1, 0, 1090, false, true); + assert(!server.vehicle_yaw_history.at(1075, yaw, rate)); + assert(server.vehicle_yaw_history.at(1090, yaw, rate) && yaw == 1); +} + +static void fallback_tests() +{ + ca_mavlink_server server {}; + server.autopilot_system_id = 42; + server.autopilot_component_id = 1; + server.system_id = 42; + server.gimbal_component_id = 154; + now_ms = 200000; + attitude(server, 1000000, 0, 1); + mavlink_message_t msg; + mavlink_msg_attitude_pack(42, 1, &msg, 1000, 0, 0, .5f, 0, 0, .5f); + handle_attitude(&server, &msg); // establish fallback clock while primary is fresh + float yaw, rate; + now_ms = 200250; + mavlink_msg_attitude_pack(42, 1, &msg, 1250, 0, 0, .5f, 0, 0, .5f); + handle_attitude(&server, &msg); + assert(server.vehicle_attitude_primary && current_vehicle_attitude(&server, &yaw, &rate)); + now_ms++; + mavlink_msg_attitude_pack(42, 1, &msg, 1251, 0, 0, .5f, 0, 0, .5f); + handle_attitude(&server, &msg); + assert(!server.vehicle_attitude_primary); + assert(current_vehicle_attitude(&server, &yaw, &rate) && yaw == .5f && rate == .5f); + now_ms = 200260; + attitude(server, 1260000, .26f, 1); + assert(server.vehicle_attitude_primary && current_vehicle_attitude(&server, &yaw, &rate)); + + // Euler yaw rate is undefined at vertical pitch, but the attitude still + // belongs in metadata. It must not supply a NaN to control prediction. + now_ms = 200600; + for (float pitch : {PI_F / 2, -PI_F / 2}) { + mavlink_msg_attitude_pack(42, 1, &msg, now_ms - 199000, + .1f, pitch, .8f, 0, 0, .5f); + handle_attitude(&server, &msg); + assert(metadata_attitude_ms == now_ms && metadata_pitch == pitch); + assert(isnan(metadata_yaw_rate)); + assert(!current_vehicle_attitude(&server, &yaw, &rate)); + now_ms += 100; + } + mavlink_msg_attitude_pack(42, 1, &msg, 1800, 0, 0, .8f, 0, 0, .5f); + handle_attitude(&server, &msg); + assert(current_vehicle_attitude(&server, &yaw, &rate) && rate == .5f); + now_ms = 100000; +} + +int main() +{ + clock_tests(); + history_burst_tests(); + fallback_tests(); + ca_mavlink_server server {}; + server.autopilot_system_id = 42; + server.autopilot_component_id = 1; + server.system_id = 42; + server.gimbal_component_id = 154; + server.started_ms = 99000; + // Steady 1 rad/s turn. Late packets retain their generation time, and the + // control prediction reaches the current yaw instead of lagging reception. + attitude(server, 2000000, 0, 1); + position(server, 2000, -350000000); + now_ms = 100180; + attitude(server, 2100000, .1f, 1); + position(server, 2100, -349999910); + assert(metadata_attitude_ms == 100100 && metadata_position_ms == 100100); + float yaw, rate; + assert(current_vehicle_attitude(&server, &yaw, &rate)); + assert(fabsf(yaw - .18f) < 1e-5 && rate == 1); + int32_t lat; + assert(current_vehicle_position(&server, &lat, nullptr, nullptr)); + assert(lat > server.vehicle_lat_e7 + 70); // includes the 80 ms transport age + // Duplicate/out-of-order samples cannot replace state or refresh its age. + attitude(server, 2100000, 2, 9); + position(server, 2000, 0); + assert(metadata_attitude_ms == 100100 && metadata_position_ms == 100100); + assert(server.vehicle_lat_e7 == -349999910); + + // An unrelated source and the fallback cannot replace fresh primary data. + mavlink_message_t fallback; + mavlink_msg_attitude_pack(42, 1, &fallback, 2180, 0, 0, 2, 0, 0, 9); + handle_attitude(&server, &fallback); + assert(server.vehicle_attitude_primary && metadata_attitude_ms == 100100); + mavlink_msg_global_position_int_pack(43, 1, &fallback, 2180, 0, 0, 0, 0, 0, 0, 0, 0); + handle_global_position_int(&server, &fallback); + assert(server.vehicle_lat_e7 == -349999910); + + // Rate changes after a gimbal sample must not contaminate that sample. + now_ms = 100200; + attitude(server, 2200000, .3f, 3); + ca_gimbal_attitude gimbal {}; + gimbal.timestamp_ms = 100100; + gimbal.yaw_rad = -.1f; + gimbal.yaw_rate_rad_s = -1; + server.yaw_lock = true; + mavlink_message_t msg; + pack_gimbal_status(&server, &msg, &gimbal); + mavlink_gimbal_device_attitude_status_t status; + mavlink_msg_gimbal_device_attitude_status_decode(&msg, &status); + assert(fabsf(status.angular_velocity_z) < 1e-6); + float roll, pitch; + quaternion_to_euler(status.q, &roll, &pitch, &yaw); + assert(fabsf(yaw) < 1e-6); + assert(status.time_boot_ms == 1100); + assert(status.flags & GIMBAL_DEVICE_FLAGS_YAW_IN_EARTH_FRAME); + now_ms += 50; + pack_gimbal_status(&server, &msg, &gimbal); + mavlink_msg_gimbal_device_attitude_status_decode(&msg, &status); + assert(status.time_boot_ms == 1100 && fabsf(status.angular_velocity_z) < 1e-6); + + // Interpolation crosses +/-pi by the short path, including ring wrap. + ca_yaw_history history {}; + for (unsigned i = 0; i < 200; i++) history.add(i * 10, 0, 0); + history.add(2000, 179 * PI_F / 180, 1); + history.add(2100, -179 * PI_F / 180, 3); + assert(history.at(2050, yaw, rate)); + assert(fabsf(fabsf(yaw) - PI_F) < 1e-5 && rate == 2); + assert(!history.at(0, yaw, rate)); + assert(!history.at(2400, yaw, rate)); + gimbal.timestamp_ms = 90000; // outside the available vehicle history + pack_gimbal_status(&server, &msg, &gimbal); + mavlink_msg_gimbal_device_attitude_status_decode(&msg, &status); + assert(status.flags & GIMBAL_DEVICE_FLAGS_YAW_IN_VEHICLE_FRAME); + assert(!(status.flags & GIMBAL_DEVICE_FLAGS_YAW_IN_EARTH_FRAME)); + assert(status.angular_velocity_z == -1); + now_ms = 101400; + assert(!current_vehicle_attitude(&server, &yaw, &rate)); + assert(!current_vehicle_position(&server, &lat, nullptr, nullptr)); + // ATTITUDE takes over only after primary expiry and becomes usable for ROI. + mavlink_msg_attitude_pack(42, 1, &msg, 3400, 0, 0, .8f, 0, 0, .5f); + handle_attitude(&server, &msg); + assert(current_vehicle_attitude(&server, &yaw, &rate)); + assert(fabsf(yaw - .8f) < 1e-6 && rate == .5f); + assert(!server.vehicle_attitude_primary); + puts("telemetry clock mapping, rollover, restart, history and handler tests passed"); +} diff --git a/camera_app/tests/test_video_metadata.cpp b/camera_app/tests/test_video_metadata.cpp index f08c49e..eb38a90 100644 --- a/camera_app/tests/test_video_metadata.cpp +++ b/camera_app/tests/test_video_metadata.cpp @@ -65,6 +65,22 @@ int main(void) ca_metadata_snapshot(&m); assert(ca_video_metadata_json(&m, &utc, 0, json, sizeof(json)) > 0U); assert(strstr(json, "yaw_rate_rad_s") == NULL); + // A real backend sample preserves both measured rate and sample age. + struct timespec now; + assert(clock_gettime(CLOCK_MONOTONIC, &now) == 0); + uint64_t gimbal_ms = uint64_t(now.tv_sec) * 1000 + now.tv_nsec / 1000000 - 200; + ca_metadata_set_gimbal_attitude_motion(0, -.2f, .3f, -.4f, gimbal_ms); + ca_metadata_snapshot(&m); + assert(m.have_gimbal_attitude && m.gimbal_yaw_rate_rad_s == -.4f); + assert(m.gimbal_attitude_age_ms >= 200); + assert(ca_video_metadata_json(&m, &utc, 0, json, sizeof(json)) > 0U); + assert(strstr(json, "\"yaw_rate_rad_s\":-0.400000") != NULL); + // Legacy attitude-only producers must clear any previously measured rate. + ca_metadata_set_gimbal_attitude(0, 0, 0); + ca_metadata_snapshot(&m); + assert(isnan(m.gimbal_yaw_rate_rad_s)); + assert(ca_video_metadata_json(&m, &utc, 0, json, sizeof(json)) > 0U); + assert(strstr(json, "yaw_rate_rad_s") == NULL); m.have_position = m.have_vehicle_attitude = m.have_gimbal_attitude = true; m.lat_e7 = -353632610; m.lon_e7 = 1491652300; m.alt_amsl_m = 620.25f; m.alt_relative_m = 36.5f; @@ -107,6 +123,23 @@ int main(void) &annotated, &length) == 0); assert(annotated == NULL); } + // Transport age must survive insertion into frame metadata. + struct timespec sampled; + assert(clock_gettime(CLOCK_MONOTONIC, &sampled) == 0); + uint64_t sample_ms = uint64_t(sampled.tv_sec) * 1000 + sampled.tv_nsec / 1000000 - 200; + ca_metadata_set_position(-353632610, 1491652300, 600, 20, 0, sample_ms); + ca_metadata_set_velocity(1, 2, 3, sample_ms); + ca_metadata_set_vehicle_attitude_motion(0, 0, 0, .1f, sample_ms); + ca_metadata_snapshot(&m); + assert(m.have_position && m.position_age_ms >= 200); + assert(m.have_velocity && m.velocity_age_ms >= 200); + assert(m.have_vehicle_attitude && m.vehicle_attitude_age_ms >= 200); + sample_ms -= CA_METADATA_MAX_AGE_MS; + ca_metadata_set_position(-353632610, 1491652300, 600, 20, 0, sample_ms); + ca_metadata_set_velocity(1, 2, 3, sample_ms); + ca_metadata_set_vehicle_attitude_motion(0, 0, 0, .1f, sample_ms); + ca_metadata_snapshot(&m); + assert(!m.have_position && !m.have_velocity && !m.have_vehicle_attitude); puts("PASS video telemetry JSON, H.264/H.265 SEI escaping and access-unit insertion"); return 0; } diff --git a/sitl/README.md b/sitl/README.md index 86be4bf..f4c5907 100644 --- a/sitl/README.md +++ b/sitl/README.md @@ -21,6 +21,12 @@ make zr10_sitl-test make sitl-live-tracking-test ``` +`make sitl-mavlink-test` also exercises circling ROI tracking with sparse +position data and injected 0–100 ms delay/reordering, in angle and rate modes. +`make -C camera_app tests/test_telemetry_time tests/test_targeting` builds the +deterministic clock/history and analytic LOS-rate tests; run both executables +from `camera_app/tests/` after building them. + The live tracking test uses isolated ports and files, and requires the usual MAVLink/video test dependencies. It checks web saves against the running app, rate tracking, stale-data stops and video-format changes deferred @@ -249,10 +255,12 @@ jitter without buffering future telemetry. Frames are rendered ahead of their 50 ms presentation deadlines and published on that cadence. Two queued encoded frames plus the frame awaiting display provide about 150 ms of render headroom at 20 fps, so a short texture upload does not pause playback. Prediction remains -capped at 250 ms; the frame queue cannot grow without bound. Gimbal Euler rates are -estimated from successive timestamped samples with angle wrapping. Recorded SEI -also includes optional NED `velocity` and vehicle `yaw_rate_rad_s` fields; old -telemetry readers continue to work. +capped at 250 ms; the frame queue cannot grow without bound. The predictor uses +measured vehicle and gimbal yaw rates, avoiding rate noise from differences of +rounded gimbal angles. Other Euler rates, and legacy gimbal records without a +rate, use successive timestamped samples with angle wrapping. Recorded SEI +includes optional NED `velocity` and `yaw_rate_rad_s` fields in both attitude +objects; old telemetry readers continue to work. Prefetch and performance settings: diff --git a/sitl/terrain_video.py b/sitl/terrain_video.py index 1e95177..3691bab 100644 --- a/sitl/terrain_video.py +++ b/sitl/terrain_video.py @@ -229,7 +229,10 @@ def angles(self, key, sample, now, horizon=None): self.history[key] = (stamp, angles.copy(), rates) rates = rates.copy() # AUTOPILOT_STATE supplies earth-frame yaw rate. ATTITUDE fallback is - # converted from body rates by the MAVLink receiver. + # converted from body rates by the MAVLink receiver. Gimbal metadata + # carries the backend's vehicle-relative yaw rate; prefer it to noisy + # differences of quantised feedback angles. Legacy records still use + # the finite-difference estimate above. yaw_rate = sample.get('yaw_rate_rad_s') if isinstance(yaw_rate, (int, float)) and math.isfinite(yaw_rate): rates[2] = yaw_rate diff --git a/sitl/test_roi_motion.py b/sitl/test_roi_motion.py index c7282bf..255d56a 100644 --- a/sitl/test_roi_motion.py +++ b/sitl/test_roi_motion.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """A centre ROI must stay steady while a simulated plane circles between position samples.""" import argparse +import heapq import math import os from pathlib import Path @@ -22,11 +23,13 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--build', type=Path, default=ROOT / 'build/sitl') + parser.add_argument("--jitter", action="store_true", help="inject 0-100 ms delay and reordered samples") + parser.add_argument("--rate", action="store_true", help="use rate-based ROI tracking") args = parser.parse_args() with tempfile.TemporaryDirectory(prefix='sitl-roi-motion-') as tmp: root = Path(tmp) config = root / 'camera.ini' - config.write_text('[mavlink]\nsystem_id=42\n') + config.write_text('[mavlink]\nsystem_id=42\ntracking_method=%s\n' % ('rate' if args.rate else 'angle')) gimbal_port, tcp_port = port(), port() env = dict(os.environ, CAMERA_APP_CONFIG=str(config), CAMERA_APP_BACKEND='mt11', CAMERA_APP_UART=f'udp://127.0.0.1:{gimbal_port}', CAMERA_APP_PORT=str(port()), @@ -49,8 +52,39 @@ def main(): link.mav.srcSystem, link.mav.srcComponent = 42, 1 link.mav.heartbeat_send(M.MAV_TYPE_FIXED_WING, M.MAV_AUTOPILOT_ARDUPILOTMEGA, 0, 0, 4) lat, lon = -35.2785018, 148.9534632 + if args.rate: + # Acquire the initial centre ROI with an angle command; + # this test measures motion compensation, independently of + # the MT11 rate motor's below-6-deg/s acquisition dead zone. + initial = Quaternion([0, -math.pi / 4, math.pi / 2]) + link.mav.gimbal_device_set_attitude_send(42, 154, + M.GIMBAL_DEVICE_FLAGS_ROLL_LOCK | M.GIMBAL_DEVICE_FLAGS_PITCH_LOCK | + M.GIMBAL_DEVICE_FLAGS_YAW_IN_VEHICLE_FRAME, + initial.q, math.nan, math.nan, math.nan) + deadline = time.monotonic() + 5 + while True: + status = link.recv_match(type='GIMBAL_DEVICE_ATTITUDE_STATUS', blocking=True, timeout=.2) + if status is not None: + roll, pitch, yaw = Quaternion(status.q).euler + if abs(yaw - math.pi / 2) < .001 and abs(pitch + math.pi / 4) < .001: + break + assert time.monotonic() < deadline, 'initial gimbal pointing not achieved' started = time.monotonic() samples = [] + pending = [] + sequence = 0 + + def queue(message, delay): + nonlocal sequence + sequence += 1 + heapq.heappush(pending, (time.monotonic() + delay, sequence, message)) + + def deliver_until(deadline): + while time.monotonic() < deadline: + while pending and pending[0][0] <= time.monotonic(): + link.mav.send(heapq.heappop(pending)[2]) + time.sleep(min(0.002, max(0, deadline - time.monotonic()))) + def receive(): # Receive independently of the sending loop, and retain the @@ -67,18 +101,21 @@ def receive(): receiver.start() for frame in range(200): due = started + frame * 0.05 - time.sleep(max(0, due - time.monotonic())) + deliver_until(due) now = time.monotonic() - started phase = 0.25 * now q = Quaternion([0, 0, math.remainder(phase + math.pi / 2, 2 * math.pi)]) - link.mav.autopilot_state_for_gimbal_device_send(42, 154, + delay = (0, 0.10, 0.02, 0.06)[frame % 4] if args.jitter else 0 + queue(link.mav.autopilot_state_for_gimbal_device_encode(42, 154, round(time.monotonic() * 1e6), q.q, 0, 0, 0, 0, 0, 0.25, 0, - M.MAV_LANDED_STATE_IN_AIR) - if frame % 5 == 0: # deliberately sparse position data - link.mav.global_position_int_send(round(now * 1000), + M.MAV_LANDED_STATE_IN_AIR), delay) + position_interval = 2 if args.jitter else 5 + if frame % position_interval == 0: + queue(link.mav.global_position_int_encode(round(now * 1000), round((lat + math.degrees(100 * math.cos(phase) / 6378137)) * 1e7), round((lon + math.degrees(100 * math.sin(phase) / (6378137 * math.cos(math.radians(lat))))) * 1e7), - 600000, 100000, round(-2500 * math.sin(phase)), round(2500 * math.cos(phase)), 0, 65535) + 600000, 100000, round(-2500 * math.sin(phase)), round(2500 * math.cos(phase)), 0, 65535), + (0, 0.08, 0.03)[(frame // 2) % 3] if args.jitter else 0) if frame == 0: link.mav.command_int_send(42, 154, M.MAV_FRAME_GLOBAL, M.MAV_CMD_DO_SET_ROI_LOCATION, 0, 0, 0, 0, 0, 0, round(lat * 1e7), round(lon * 1e7), 500) @@ -90,10 +127,12 @@ def receive(): 2 * math.pi)) for received, stamp, yaw in samples if stamp + offset > 2] assert len(errors) > 30, errors - assert max(abs(e) for e in errors) < 0.1, errors + # Rate control deliberately has a 0.2-degree pointing deadband. + assert max(abs(e) for e in errors) < (0.2 if args.rate else 0.1), errors assert statistics.pstdev(errors) < 0.04, errors - print('PASS circling ROI with 4 Hz position: yaw error peak %.3f, stddev %.3f degrees' % - (max(map(abs, errors)), statistics.pstdev(errors))) + print('PASS circling ROI (%s, %s): yaw error peak %.3f, stddev %.3f degrees' % + ('rate' if args.rate else 'angle', 'transport jitter' if args.jitter else '4 Hz position', + max(map(abs, errors)), statistics.pstdev(errors))) except Exception: print((root / 'camera.log').read_text()) raise diff --git a/sitl/test_terrain_video.py b/sitl/test_terrain_video.py index 81534f4..9694f23 100644 --- a/sitl/test_terrain_video.py +++ b/sitl/test_terrain_video.py @@ -123,6 +123,32 @@ def prediction(): print('PASS 4 Hz -> 20 Hz prediction, bounded loss, yaw wrap, jitter correction and presentation lead') +def quantised_gimbal_prediction(): + np = video.np + errors = [] + # MT11 rounds angles to 0.1 degree. Sparse rendering with a variable lead + # amplifies the noise if yaw rate is differentiated from those angles. + for measured_rate in (False, True): + predictor = video.PosePredictor() + yaw_errors = [] + for i in range(300): + now = i * .147 + sampled = math.floor(now / .058) * .058 + lead = .15 if i % 3 else .08 + sample = {'roll_rad': 0, 'pitch_rad': 0, + 'yaw_rad': math.radians(round(11 * sampled, 1)), + 'age_ms': round((now - sampled + lead) * 1000)} + if measured_rate: + sample['yaw_rate_rad_s'] = math.radians(11) + yaw = predictor.angles('gimbal', sample, now + lead, .25 + lead)[2] + yaw_errors.append((math.degrees(yaw) - 11 * (now + lead) + 180) % 360 - 180) + errors.append(np.array(yaw_errors[20:])) + assert np.std(errors[1]) < .03 + assert max(abs(errors[1])) < .05 + assert np.std(np.diff(errors[1])) < .3 * np.std(np.diff(errors[0])) + print('PASS measured gimbal yaw rate reduces quantisation-induced prediction jitter') + + def stable_lod(): from types import SimpleNamespace tile = SimpleNamespace(bbox=(149, -35.01, 149.01, -35), @@ -298,6 +324,7 @@ def network(output, seconds, fps, width, height, a8): video.socket.setdefaulttimeout(10) geometry() prediction() + quantised_gimbal_prediction() stable_lod() synthetic() if args.network: