Skip to content

Commit b5eecd1

Browse files
committed
fix(log-analysis): harden Plane landing evidence
Signed-off-by: nflyernz <319460892+nflyernz@users.noreply.github.com>
1 parent 6652018 commit b5eecd1

3 files changed

Lines changed: 208 additions & 24 deletions

File tree

ardupilot_methodic_configurator/log_analysis/data_model_availability_plane_landing.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import math
1112
from typing import TYPE_CHECKING
1213

1314
from ardupilot_methodic_configurator import _
@@ -179,7 +180,7 @@ def analyse(self) -> LogAnalysisResult:
179180
)
180181

181182
def _attempt_outcome(self, attempt_number: int, attempt: PlaneLandingAttempt) -> LogAnalysis:
182-
flare_altitude_m = self.parameter_history.value_at("LAND_FLARE_ALT", attempt.start_s)
183+
flare_altitude_m = self._finite_parameter_value(self.parameter_history.value_at("LAND_FLARE_ALT", attempt.start_s))
183184
parameter_evidence = (
184185
_("LAND_FLARE_ALT at attempt start: {value:.2f} m").format(value=flare_altitude_m)
185186
if flare_altitude_m is not None
@@ -249,6 +250,11 @@ def _stage_outcomes(self, attempt_number: int, evidence: PlaneLandingStageEviden
249250
)
250251
return outcomes
251252

253+
@staticmethod
254+
def _finite_parameter_value(value: float | None) -> float | None:
255+
"""Treat non-finite event-time parameter values as unavailable evidence."""
256+
return value if value is not None and math.isfinite(value) else None
257+
252258
def _firmware_message_outcomes(
253259
self,
254260
attempt_number: int,

ardupilot_methodic_configurator/log_analysis/data_model_plane_landing.py

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@
2424
from ardupilot_methodic_configurator.log_analysis.data_model_parameter_history import ParameterHistory
2525

2626

27+
def _active_gps_fields(log_data: LogData, *field_names: str) -> tuple[np.ndarray, ...] | None:
28+
"""Return scaled GPS fields, restricted to the active receiver when ``U`` is available."""
29+
gps = log_data.get_message_columns("GPS")
30+
if gps is None:
31+
return None
32+
names = gps.dtype.names or ()
33+
if not set(field_names).issubset(names):
34+
return None
35+
36+
values = tuple(log_data.get_field("GPS", field_name) for field_name in field_names)
37+
if "U" not in names:
38+
return values
39+
40+
active_receiver_samples = log_data.get_field("GPS", "U") == 1
41+
return tuple(field_values[active_receiver_samples] for field_values in values)
42+
43+
2744
class PlaneLandingEndReason(str, Enum):
2845
"""Objective evidence that bounded a detected AUTO landing attempt."""
2946

@@ -188,16 +205,13 @@ def _first_mode_exit(
188205

189206
@classmethod
190207
def _first_gps_stop(cls, log_data: LogData, flight_segment: FlightSegment, start_s: float) -> float | None:
191-
gps = log_data.get_message_columns("GPS")
192-
if gps is None or not {"TimeUS", "Spd"}.issubset(gps.dtype.names or ()):
208+
gps_fields = _active_gps_fields(log_data, "TimeUS", "Spd")
209+
if gps_fields is None:
193210
return None
211+
time_s, speed_m_s = gps_fields
194212

195213
below_since_s: float | None = None
196-
for timestamp, speed in zip(
197-
log_data.get_field("GPS", "TimeUS"),
198-
log_data.get_field("GPS", "Spd"),
199-
strict=True,
200-
):
214+
for timestamp, speed in zip(time_s, speed_m_s, strict=True):
201215
timestamp_s = float(timestamp)
202216
if not start_s <= timestamp_s <= flight_segment.end_s:
203217
continue
@@ -700,18 +714,13 @@ def _cmd_records(cls, log_data: LogData) -> list[_MissionCommandRecord]:
700714
@classmethod
701715
def _nearest_gps_position(cls, log_data: LogData, target_time_s: float) -> tuple[float, float, float] | None:
702716
"""Return the nearest finite GPS position to the supplied time."""
703-
gps = log_data.get_message_columns("GPS")
704-
if gps is None or not {"TimeUS", "Lat", "Lng"}.issubset(gps.dtype.names or ()):
717+
gps_fields = _active_gps_fields(log_data, "TimeUS", "Lat", "Lng")
718+
if gps_fields is None:
705719
return None
706720

707721
nearest_position: tuple[float, float, float] | None = None
708722
nearest_offset: float | None = None
709-
for timestamp, latitude, longitude in zip(
710-
log_data.get_field("GPS", "TimeUS"),
711-
log_data.get_field("GPS", "Lat"),
712-
log_data.get_field("GPS", "Lng"),
713-
strict=True,
714-
):
723+
for timestamp, latitude, longitude in zip(*gps_fields, strict=True):
715724
timestamp_s = cls._finite_float(timestamp)
716725
latitude_deg = cls._finite_float(latitude)
717726
longitude_deg = cls._finite_float(longitude)
@@ -851,7 +860,7 @@ def _build_stage_evidence(
851860
else None
852861
),
853862
parameter_values={
854-
parameter_name: parameter_history.value_at(parameter_name, time_s)
863+
parameter_name: cls._finite_float(parameter_history.value_at(parameter_name, time_s))
855864
for parameter_name in cls._PARAMETERS_BY_STAGE[stage]
856865
},
857866
)
@@ -864,18 +873,22 @@ def _nearest_value(
864873
telemetry_field: tuple[str, str],
865874
target_time_s: float,
866875
) -> float | None:
867-
message_name, field_name = telemetry_field
876+
message_name = telemetry_field[0]
868877
records = log_data.get_message_columns(message_name)
869-
if records is None or not {"TimeUS", field_name}.issubset(records.dtype.names or ()):
878+
if records is None or not {"TimeUS", telemetry_field[1]}.issubset(records.dtype.names or ()):
879+
return None
880+
881+
telemetry_fields = (
882+
_active_gps_fields(log_data, "TimeUS", telemetry_field[1])
883+
if message_name == "GPS"
884+
else (log_data.get_field(message_name, "TimeUS"), log_data.get_field(message_name, telemetry_field[1]))
885+
)
886+
if telemetry_fields is None:
870887
return None
871888

872889
nearest_value: float | None = None
873890
nearest_offset: float | None = None
874-
for timestamp, value in zip(
875-
log_data.get_field(message_name, "TimeUS"),
876-
log_data.get_field(message_name, field_name),
877-
strict=True,
878-
):
891+
for timestamp, value in zip(*telemetry_fields, strict=True):
879892
timestamp_s = cls._finite_float(timestamp)
880893
measured_value = cls._finite_float(value)
881894
if timestamp_s is None or measured_value is None or not attempt.start_s <= timestamp_s <= attempt.end_s:

tests/test_data_model_plane_landing.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
"""Focused tests for AMC-native ArduPlane landing-attempt analysis."""
44

5+
# pylint: disable=too-many-lines
6+
57
from collections.abc import Sequence
68

79
import numpy as np
@@ -365,6 +367,94 @@ def test_attempt_times_use_scaled_seconds() -> None:
365367
assert evidence[0].time_s == 15.0
366368

367369

370+
def test_inactive_gps_receiver_cannot_create_false_stop() -> None:
371+
log_data = _plane_log(messages=((40.0, "Throttle disarmed"),))
372+
_add_columns(
373+
log_data,
374+
"GPS",
375+
(
376+
(0.0, 6.0, 0, 1),
377+
(2.0, 6.0, 0, 1),
378+
(11.0, 1.0, 1, 0),
379+
(13.0, 1.0, 1, 0),
380+
(20.0, 6.0, 0, 1),
381+
(40.0, 6.0, 0, 1),
382+
),
383+
[("TimeUS", "f8"), ("Spd", "f8"), ("I", "u1"), ("U", "u1")],
384+
)
385+
segment = PlaneFlightSegmentDetector.detect(log_data, {}).segments[0]
386+
387+
attempt = PlaneLandingAttemptDetector.detect(log_data, segment)[0]
388+
389+
assert (attempt.start_s, attempt.end_s, attempt.end_reason) == (10.0, 40.0, PlaneLandingEndReason.DISARM)
390+
391+
392+
def test_inactive_gps_receiver_cannot_supply_stage_speed() -> None:
393+
log_data = _plane_log(
394+
land=((5.0, 0), (10.0, 1), (15.0, 2)),
395+
messages=((40.0, "Throttle disarmed"),),
396+
)
397+
_add_columns(
398+
log_data,
399+
"GPS",
400+
(
401+
(0.0, 6.0, 0, 1),
402+
(2.0, 6.0, 0, 1),
403+
(14.0, 7.0, 0, 1),
404+
(15.0, 1.0, 1, 0),
405+
(16.0, 8.0, 0, 1),
406+
(40.0, 6.0, 0, 1),
407+
),
408+
[("TimeUS", "f8"), ("Spd", "f8"), ("I", "u1"), ("U", "u1")],
409+
)
410+
segment = PlaneFlightSegmentDetector.detect(log_data, {}).segments[0]
411+
attempt = PlaneLandingAttemptDetector.detect(log_data, segment)[0]
412+
413+
evidence = PlaneLandingEvidenceExtractor.extract(log_data, attempt, ParameterHistory())
414+
415+
assert evidence[0].gps_ground_speed_m_s == 7.0
416+
417+
418+
def test_inactive_gps_receiver_cannot_supply_target_distance_position() -> None:
419+
log_data = _gps_stop_log(((1.0, 1, 0, 21, 0.0, 1.0),))
420+
_add_columns(
421+
log_data,
422+
"GPS",
423+
(
424+
(20.0, 2.0, 10.0, 10.0, 1, 0),
425+
(20.0, 2.0, 0.0, 1.001, 0, 1),
426+
),
427+
[("TimeUS", "f8"), ("Spd", "f8"), ("Lat", "f8"), ("Lng", "f8"), ("I", "u1"), ("U", "u1")],
428+
)
429+
segment = FlightSegment(start_s=0.0, end_s=30.0, is_complete=True)
430+
attempt = PlaneLandingAttempt(
431+
flight_segment=segment,
432+
start_s=10.0,
433+
end_s=20.0,
434+
end_reason=PlaneLandingEndReason.GPS_STOP,
435+
)
436+
437+
distance = PlaneLandingMissionTargetExtractor.distance_at_gps_stop(log_data, attempt)
438+
439+
assert distance is not None
440+
assert (distance.aircraft_latitude_deg, distance.aircraft_longitude_deg) == (0.0, 1.001)
441+
assert distance.distance_m == pytest.approx(111.1949266)
442+
443+
444+
def test_gps_without_use_flag_retains_existing_stop_behavior() -> None:
445+
log_data = _plane_log(
446+
gps=((0.0, 6.0), (2.0, 6.0), (20.0, 2.0), (22.0, 2.0), (30.0, 6.0)),
447+
)
448+
gps = log_data.get_message_columns("GPS")
449+
assert gps is not None
450+
assert "U" not in (gps.dtype.names or ())
451+
segment = PlaneFlightSegmentDetector.detect(log_data, {}).segments[0]
452+
453+
attempt = PlaneLandingAttemptDetector.detect(log_data, segment)[0]
454+
455+
assert (attempt.end_s, attempt.end_reason) == (20.0, PlaneLandingEndReason.GPS_STOP)
456+
457+
368458
def test_stage_evidence_uses_land_and_nearest_optional_telemetry() -> None:
369459
log_data = _plane_log(
370460
gps=(
@@ -910,6 +1000,81 @@ def test_rfnd_lifecycle_flat_outcomes_leave_stage_point_measurement_unchanged()
9101000
]
9111001

9121002

1003+
@pytest.mark.parametrize("non_finite_value", [float("nan"), float("inf"), float("-inf")])
1004+
def test_non_finite_event_time_landing_parameters_are_unavailable(non_finite_value: float) -> None:
1005+
log_data = _plane_log(
1006+
land=((5.0, 0), (10.0, 1), (15.0, 2), (20.0, 3)),
1007+
messages=((40.0, "Throttle disarmed"),),
1008+
)
1009+
parameter_names = ("LAND_PF_ALT", "LAND_PF_SEC", "LAND_FLARE_ALT", "LAND_FLARE_SEC", "LAND_PITCH_DEG")
1010+
history = ParameterHistory(dict.fromkeys(parameter_names, non_finite_value))
1011+
segment = PlaneFlightSegmentDetector.detect(log_data, {}).segments[0]
1012+
attempt = PlaneLandingAttemptDetector.detect(log_data, segment)[0]
1013+
1014+
evidence = PlaneLandingEvidenceExtractor.extract(log_data, attempt, history)
1015+
result = PlaneLandingAnalysis(log_data, _context(history)).analyse()
1016+
1017+
assert all(value is None for item in evidence for value in item.parameter_values.values())
1018+
attempt_outcome = next(outcome for outcome in result.outcomes if outcome.message.startswith("AUTO landing attempt"))
1019+
assert attempt_outcome.value is None
1020+
assert "LAND_FLARE_ALT was unavailable" in attempt_outcome.message
1021+
assert not any(
1022+
parameter_name in outcome.message and "effective value" in outcome.message
1023+
for parameter_name in parameter_names
1024+
for outcome in result.outcomes
1025+
)
1026+
1027+
1028+
def test_finite_event_time_landing_parameters_are_emitted() -> None:
1029+
log_data = _plane_log(
1030+
land=((5.0, 0), (10.0, 1), (15.0, 2), (20.0, 3)),
1031+
messages=((40.0, "Throttle disarmed"),),
1032+
)
1033+
parameter_values = {
1034+
"LAND_PF_ALT": 6.0,
1035+
"LAND_PF_SEC": 2.0,
1036+
"LAND_FLARE_ALT": 3.0,
1037+
"LAND_FLARE_SEC": 1.5,
1038+
"LAND_PITCH_DEG": 4.0,
1039+
}
1040+
1041+
result = PlaneLandingAnalysis(log_data, _context(ParameterHistory(parameter_values))).analyse()
1042+
1043+
attempt_outcome = next(outcome for outcome in result.outcomes if outcome.message.startswith("AUTO landing attempt"))
1044+
assert attempt_outcome.value == 3.0
1045+
parameter_outcomes = [outcome for outcome in result.outcomes if "effective value" in outcome.message]
1046+
emitted_parameter_names = {
1047+
parameter_name
1048+
for parameter_name in parameter_values
1049+
if any(parameter_name in item.message for item in parameter_outcomes)
1050+
}
1051+
assert emitted_parameter_names == set(parameter_values)
1052+
1053+
1054+
def test_finite_parameter_change_at_event_time_remains_effective() -> None:
1055+
log_data = _plane_log(
1056+
land=((5.0, 0), (10.0, 1), (15.0, 2), (20.0, 3)),
1057+
messages=((40.0, "Throttle disarmed"),),
1058+
)
1059+
history = ParameterHistory(
1060+
{"LAND_PF_ALT": 5.0, "LAND_FLARE_ALT": 3.0},
1061+
{
1062+
"LAND_PF_ALT": (ParameterChange(time_s=15.0, value=6.0),),
1063+
"LAND_FLARE_ALT": (ParameterChange(time_s=20.0, value=4.0),),
1064+
},
1065+
)
1066+
1067+
result = PlaneLandingAnalysis(log_data, _context(history)).analyse()
1068+
1069+
attempt_outcome = next(outcome for outcome in result.outcomes if outcome.message.startswith("AUTO landing attempt"))
1070+
assert attempt_outcome.value == 3.0
1071+
parameter_outcomes = [outcome for outcome in result.outcomes if "effective value" in outcome.message]
1072+
assert [(outcome.timestamp_us, outcome.value) for outcome in parameter_outcomes] == [
1073+
(15_000_000, 6.0),
1074+
(20_000_000, 4.0),
1075+
]
1076+
1077+
9131078
def test_event_time_parameters_change_between_attempts_without_changing_boundaries() -> None:
9141079
log_data = _plane_log(
9151080
land=(

0 commit comments

Comments
 (0)