|
14 | 14 | from types import MappingProxyType |
15 | 15 | from typing import TYPE_CHECKING, ClassVar |
16 | 16 |
|
| 17 | +import numpy as np |
| 18 | + |
17 | 19 | if TYPE_CHECKING: |
18 | 20 | from collections.abc import Mapping |
19 | 21 |
|
20 | | - import numpy as np |
21 | | - |
22 | 22 | from ardupilot_methodic_configurator.log_analysis.data_model_flight_segment import FlightSegment |
23 | 23 | from ardupilot_methodic_configurator.log_analysis.data_model_log_data import LogData |
24 | 24 | from ardupilot_methodic_configurator.log_analysis.data_model_parameter_history import ParameterHistory |
@@ -382,6 +382,143 @@ def _finite_float(value: object) -> float | None: |
382 | 382 | return converted if math.isfinite(converted) else None |
383 | 383 |
|
384 | 384 |
|
| 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 | + |
385 | 522 | @dataclass(frozen=True, slots=True) |
386 | 523 | class PlaneLandingMissionTarget: |
387 | 524 | """One unambiguous mission LAND target applicable to a landing attempt.""" |
|
0 commit comments