Source code for pyradar.sim.targets

"""Simulation targets and propagation-path models."""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass, field

import numpy as np
from numpy.typing import ArrayLike, NDArray


def _vector3(value: ArrayLike, name: str) -> NDArray[np.float64]:
    result = np.array(value, dtype=np.float64, copy=True)
    if result.shape != (3,) or not np.all(np.isfinite(result)):
        raise ValueError(f"{name} must contain three finite FLU coordinates.")
    result.setflags(write=False)
    return result


[docs] @dataclass(frozen=True, slots=True) class PointTarget: """Ideal isotropic point target in the radar FLU frame. Parameters ---------- position: Target position at time zero as ``[x, y, z]`` in meters. velocity: Constant Cartesian velocity in meters per second. rcs: Monostatic radar cross section in square meters. Its square root is used as the complex field amplitude before optional propagation loss. phase: Additional reflection phase in radians. """ position: NDArray[np.float64] velocity: NDArray[np.float64] = field( default_factory=lambda: np.zeros(3, dtype=np.float64) ) rcs: float = 1.0 phase: float = 0.0 def __post_init__(self) -> None: object.__setattr__(self, "position", _vector3(self.position, "position")) object.__setattr__(self, "velocity", _vector3(self.velocity, "velocity")) if not np.isfinite(self.rcs) or self.rcs < 0.0: raise ValueError("rcs must be finite and nonnegative.") if not np.isfinite(self.phase): raise ValueError("phase must be finite.")
[docs] def position_at(self, time: float) -> NDArray[np.float64]: """Return the FLU position at ``time`` seconds.""" if not np.isfinite(time): raise ValueError("time must be finite.") return self.position + self.velocity * time
@property def reflection(self) -> complex: """Complex field reflection coefficient before path loss.""" return complex(np.sqrt(self.rcs) * np.exp(1j * self.phase))
[docs] @dataclass(frozen=True, slots=True) class PropagationPath: """One bistatic propagation path at the beginning of a frame. ``pathRate`` is positive when the total TX-target-RX path length is increasing. Consequently, an approaching target produces positive Doppler frequency and has a negative path rate. """ pathLength: float txId: int rxId: int pathRate: float = 0.0 amplitude: complex = 1.0 + 0.0j def __post_init__(self) -> None: if not np.isfinite(self.pathLength) or self.pathLength <= 0.0: raise ValueError("pathLength must be finite and positive.") if not np.isfinite(self.pathRate): raise ValueError("pathRate must be finite.") if self.txId < 0 or self.rxId < 0: raise ValueError("txId and rxId cannot be negative.") if not np.isfinite(self.amplitude.real) or not np.isfinite(self.amplitude.imag): raise ValueError("amplitude must be finite.")
[docs] @dataclass(frozen=True, slots=True) class TargetScene: """An immutable collection of point targets.""" targets: tuple[PointTarget, ...] def __post_init__(self) -> None: values = tuple(self.targets) if any(not isinstance(target, PointTarget) for target in values): raise TypeError("TargetScene accepts PointTarget objects only.") object.__setattr__(self, "targets", values)
[docs] @classmethod def from_points( cls, points: ArrayLike, *, velocities: ArrayLike | None = None, rcs: ArrayLike | float = 1.0, phase: ArrayLike | float = 0.0, ) -> TargetScene: """Build one point target per row of an ``(N, 3)`` point cloud.""" positions = np.asarray(points, dtype=float) if positions.ndim != 2 or positions.shape[1] != 3: raise ValueError("points must have shape (N, 3).") count = positions.shape[0] velocityArray = ( np.zeros_like(positions) if velocities is None else np.broadcast_to(np.asarray(velocities, dtype=float), positions.shape) ) rcsArray = np.broadcast_to(np.asarray(rcs, dtype=float), (count,)) phaseArray = np.broadcast_to(np.asarray(phase, dtype=float), (count,)) return cls( tuple( PointTarget(position, velocity, float(targetRcs), float(targetPhase)) for position, velocity, targetRcs, targetPhase in zip( positions, velocityArray, rcsArray, phaseArray, strict=True, ) ) )
[docs] @classmethod def coerce( cls, targets: PointTarget | Sequence[PointTarget] | TargetScene ) -> TargetScene: """Normalize a target or target sequence into a scene.""" if isinstance(targets, TargetScene): return targets if isinstance(targets, PointTarget): return cls((targets,)) return cls(tuple(targets))
def __len__(self) -> int: return len(self.targets)
__all__ = ["PointTarget", "PropagationPath", "TargetScene"]