Skip to content

Commit 6652018

Browse files
committed
feat(log-analysis): add Plane rangefinder evidence
Signed-off-by: nflyernz <319460892+nflyernz@users.noreply.github.com>
1 parent 0881c5a commit 6652018

3 files changed

Lines changed: 369 additions & 2 deletions

File tree

ardupilot_methodic_configurator/log_analysis/data_model_availability_plane_landing.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
PlaneLandingFirmwareFlareEvidence,
3232
PlaneLandingFirmwareMessageExtractor,
3333
PlaneLandingMissionTargetExtractor,
34+
PlaneLandingRangefinderEvidence,
35+
PlaneLandingRangefinderEvidenceExtractor,
3436
PlaneLandingStage,
3537
PlaneLandingStageEvidence,
3638
)
@@ -147,6 +149,13 @@ def analyse(self) -> LogAnalysisResult:
147149
outcomes.extend(self._stage_outcomes(attempt_number, stage_evidence))
148150
for firmware_evidence in PlaneLandingFirmwareMessageExtractor.extract(self.log_data, attempt):
149151
outcomes.extend(self._firmware_message_outcomes(attempt_number, firmware_evidence))
152+
rangefinder_evidence = PlaneLandingRangefinderEvidenceExtractor.extract(
153+
self.log_data,
154+
attempt,
155+
self.parameter_history,
156+
)
157+
if rangefinder_evidence is not None:
158+
outcomes.extend(self._rangefinder_outcomes(attempt_number, rangefinder_evidence))
150159
target_distance = PlaneLandingMissionTargetExtractor.distance_at_gps_stop(self.log_data, attempt)
151160
if target_distance is not None:
152161
outcomes.append(
@@ -266,6 +275,59 @@ def _firmware_message_outcomes(
266275
)
267276
]
268277

278+
def _rangefinder_outcomes(
279+
self,
280+
attempt_number: int,
281+
evidence: PlaneLandingRangefinderEvidence,
282+
) -> list[LogAnalysis]:
283+
"""Flatten optional RFND lifecycle evidence into objective findings."""
284+
outcomes: list[LogAnalysis] = []
285+
timed_measurements = (
286+
(
287+
evidence.first_nonzero_time_s,
288+
evidence.first_nonzero_distance_m,
289+
_("first non-zero distance"),
290+
_("m"),
291+
),
292+
(
293+
evidence.first_in_range_time_s,
294+
evidence.first_in_range_distance_m,
295+
_("first in-range distance"),
296+
_("m"),
297+
),
298+
(
299+
evidence.continuous_time_s,
300+
evidence.continuous_samples,
301+
_("continuous acquisition sample count"),
302+
_("samples"),
303+
),
304+
(
305+
evidence.last_disengagement_time_s,
306+
evidence.last_disengagement_distance_m,
307+
_("last disengagement distance"),
308+
_("m"),
309+
),
310+
)
311+
for timestamp_s, value, measurement_name, unit in timed_measurements:
312+
if timestamp_s is not None and value is not None:
313+
outcomes.append(
314+
self._measurement_outcome(
315+
attempt_number,
316+
_("RFND lifecycle"),
317+
round(timestamp_s * 1_000_000),
318+
(measurement_name, float(value), unit),
319+
)
320+
)
321+
outcomes.append(
322+
self._measurement_outcome(
323+
attempt_number,
324+
_("RFND lifecycle"),
325+
round(evidence.attempt.end_s * 1_000_000),
326+
(_("disengagement count"), float(evidence.disengagement_count), _("events")),
327+
)
328+
)
329+
return outcomes
330+
269331
@staticmethod
270332
def _measurement_outcome(
271333
attempt_number: int,

ardupilot_methodic_configurator/log_analysis/data_model_plane_landing.py

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
from types import MappingProxyType
1515
from typing import TYPE_CHECKING, ClassVar
1616

17+
import numpy as np
18+
1719
if TYPE_CHECKING:
1820
from collections.abc import Mapping
1921

20-
import numpy as np
21-
2222
from ardupilot_methodic_configurator.log_analysis.data_model_flight_segment import FlightSegment
2323
from ardupilot_methodic_configurator.log_analysis.data_model_log_data import LogData
2424
from ardupilot_methodic_configurator.log_analysis.data_model_parameter_history import ParameterHistory
@@ -382,6 +382,143 @@ def _finite_float(value: object) -> float | None:
382382
return converted if math.isfinite(converted) else None
383383

384384

385+
@dataclass(frozen=True, slots=True)
386+
class PlaneLandingRangefinderEvidence: # pylint: disable=too-many-instance-attributes
387+
"""APT-compatible RFND lifecycle evidence scoped to one landing attempt."""
388+
389+
attempt: PlaneLandingAttempt
390+
first_nonzero_time_s: float | None
391+
first_nonzero_distance_m: float | None
392+
first_in_range_time_s: float | None
393+
first_in_range_distance_m: float | None
394+
continuous_time_s: float | None
395+
continuous_samples: int | None
396+
disengagement_count: int
397+
last_disengagement_time_s: float | None
398+
last_disengagement_distance_m: float | None
399+
400+
401+
class PlaneLandingRangefinderEvidenceExtractor: # pylint: disable=too-few-public-methods,too-many-locals
402+
"""Extract the optional attempt-scoped RFND lifecycle used by APT."""
403+
404+
ZERO_THRESHOLD_M: ClassVar[float] = 0.05
405+
CONTINUOUS_SECONDS: ClassVar[float] = 1.0
406+
MAX_RANGE_PARAMETER: ClassVar[str] = "RNGFND1_MAX"
407+
408+
@classmethod
409+
def extract(
410+
cls,
411+
log_data: LogData,
412+
attempt: PlaneLandingAttempt,
413+
parameter_history: ParameterHistory,
414+
) -> PlaneLandingRangefinderEvidence | None:
415+
"""Return RFND lifecycle evidence, or ``None`` when scoped RFND is unusable."""
416+
records = log_data.get_message_columns("RFND")
417+
if records is None or not {"TimeUS", "Dist"}.issubset(records.dtype.names or ()):
418+
return None
419+
420+
samples: list[tuple[float, float | None]] = []
421+
for timestamp, distance in zip(
422+
log_data.get_field("RFND", "TimeUS"),
423+
log_data.get_field("RFND", "Dist"),
424+
strict=True,
425+
):
426+
timestamp_s = cls._finite_float(timestamp)
427+
if timestamp_s is None or not attempt.start_s <= timestamp_s <= attempt.end_s:
428+
continue
429+
samples.append((timestamp_s, cls._finite_float(distance)))
430+
431+
if not samples or not any(distance_m is not None for _, distance_m in samples):
432+
return None
433+
434+
required_samples = cls._required_continuous_samples(tuple(timestamp_s for timestamp_s, _ in samples))
435+
first_nonzero_time_s: float | None = None
436+
first_nonzero_distance_m: float | None = None
437+
first_in_range_time_s: float | None = None
438+
first_in_range_distance_m: float | None = None
439+
continuous_time_s: float | None = None
440+
continuous_samples: int | None = None
441+
disengagement_count = 0
442+
last_disengagement_time_s: float | None = None
443+
last_disengagement_distance_m: float | None = None
444+
run_start_time_s: float | None = None
445+
run_samples = 0
446+
rangefinder_active = False
447+
448+
for timestamp_s, distance_m in samples:
449+
if distance_m is None or distance_m <= cls.ZERO_THRESHOLD_M:
450+
if rangefinder_active:
451+
disengagement_count += 1
452+
last_disengagement_time_s = timestamp_s
453+
last_disengagement_distance_m = cls._apt_distance(distance_m)
454+
rangefinder_active = False
455+
run_start_time_s = None
456+
run_samples = 0
457+
continue
458+
459+
rangefinder_active = True
460+
if first_nonzero_time_s is None:
461+
first_nonzero_time_s = timestamp_s
462+
first_nonzero_distance_m = cls._apt_distance(distance_m)
463+
464+
maximum_range_m = parameter_history.value_at(cls.MAX_RANGE_PARAMETER, timestamp_s)
465+
if (
466+
first_in_range_time_s is None
467+
and maximum_range_m is not None
468+
and math.isfinite(maximum_range_m)
469+
and distance_m <= maximum_range_m
470+
):
471+
first_in_range_time_s = timestamp_s
472+
first_in_range_distance_m = cls._apt_distance(distance_m)
473+
474+
if run_samples == 0:
475+
run_start_time_s = timestamp_s
476+
run_samples = 1
477+
else:
478+
run_samples += 1
479+
480+
if continuous_time_s is None and run_samples >= required_samples:
481+
continuous_time_s = run_start_time_s
482+
continuous_samples = run_samples
483+
484+
return PlaneLandingRangefinderEvidence(
485+
attempt=attempt,
486+
first_nonzero_time_s=first_nonzero_time_s,
487+
first_nonzero_distance_m=first_nonzero_distance_m,
488+
first_in_range_time_s=first_in_range_time_s,
489+
first_in_range_distance_m=first_in_range_distance_m,
490+
continuous_time_s=continuous_time_s,
491+
continuous_samples=continuous_samples,
492+
disengagement_count=disengagement_count,
493+
last_disengagement_time_s=last_disengagement_time_s,
494+
last_disengagement_distance_m=last_disengagement_distance_m,
495+
)
496+
497+
@classmethod
498+
def _required_continuous_samples(cls, timestamps_s: tuple[float, ...]) -> int:
499+
"""Apply APT's median-delta sample-rate estimate and rounded count."""
500+
if len(timestamps_s) < 2:
501+
return 1
502+
median_delta_s = float(np.median(np.diff(np.asarray(timestamps_s))))
503+
if not math.isfinite(median_delta_s) or median_delta_s <= 0.0:
504+
return 1
505+
sample_rate_hz = 1.0 / median_delta_s
506+
return max(1, round(sample_rate_hz * cls.CONTINUOUS_SECONDS))
507+
508+
@staticmethod
509+
def _apt_distance(distance_m: float | None) -> float | None:
510+
"""Preserve APT's two-decimal event-detail round trip for distances."""
511+
return None if distance_m is None else float(f"{distance_m:.2f}")
512+
513+
@staticmethod
514+
def _finite_float(value: object) -> float | None:
515+
try:
516+
converted = float(value) # type: ignore[arg-type]
517+
except (TypeError, ValueError, OverflowError):
518+
return None
519+
return converted if math.isfinite(converted) else None
520+
521+
385522
@dataclass(frozen=True, slots=True)
386523
class PlaneLandingMissionTarget:
387524
"""One unambiguous mission LAND target applicable to a landing attempt."""

0 commit comments

Comments
 (0)