Skip to content

Trajectory

trajectory

Trajectory

Trajectory(positions: Positions, rotations: Rotations | None = None, timestamps: ndarray | None = None, name: str = '', path_lengths: ndarray | None = None, velocity_xyz: ndarray | None = None, sorting: Sorting = TIME)

Class representing a trajectory containing synchronized position, orientation, and time data.

Attributes:

  • positions (Positions) –

    Container for spatial coordinates and coordinate reference system (EPSG) data.

  • rotations (Rotations) –

    Container for orientation data (quaternions), or None if not provided.

  • timestamps (ndarray) –

    A 1D array of timestamps corresponding to each pose.

  • name (str) –

    An identifier string for the trajectory.

  • path_lengths (ndarray) –

    A 1D array of cumulative path lengths starting from zero.

  • sorting (Sorting) –

    The current sorting strategy (Sorting.TIME or Sorting.ARC_LENGTH).

Initialize a Trajectory object.

Parameters:

  • positions (Positions) –

    Container for spatial coordinates and coordinate reference system (EPSG) data.

  • rotations (Rotations | None, default: None ) –

    Container for orientation data (quaternions). Defaults to None.

  • timestamps (ndarray | None, default: None ) –

    Array of timestamps. If None, a range index is used.

  • name (str, default: '' ) –

    Name of the trajectory. Defaults to generic counter name.

  • path_lengths (Union[ndarray, None], default: None ) –

    Pre-calculated path lengths. If None, they are computed from xyz.

  • velocity_xyz (Union[ndarray, None], default: None ) –

    Pre-calculated 3D velocities. If None, they are computed via gradient.

  • sorting (Sorting, default: TIME ) –

    Definition of the sorting logic (TIME or ARC_LENGTH). Defaults to Sorting.TIME.

Raises:

  • TrajectoryError

    If the number of positions and rotations do not match.

Source code in trajectopy\core\trajectory.py
def __init__(
    self,
    positions: Positions,
    rotations: Rotations | None = None,
    timestamps: np.ndarray | None = None,
    name: str = "",
    path_lengths: np.ndarray | None = None,
    velocity_xyz: np.ndarray | None = None,
    sorting: Sorting = Sorting.TIME,
) -> None:
    """
    Initialize a Trajectory object.

    Args:
        positions (Positions): Container for spatial coordinates and coordinate reference system (EPSG) data.
        rotations (Rotations | None, optional): Container for orientation data (quaternions). Defaults to None.
        timestamps (np.ndarray | None, optional): Array of timestamps. If None, a range index is used.
        name (str, optional): Name of the trajectory. Defaults to generic counter name.
        path_lengths (Union[np.ndarray, None], optional): Pre-calculated path lengths. If None, they are computed from xyz.
        velocity_xyz (Union[np.ndarray, None], optional): Pre-calculated 3D velocities. If None, they are computed via gradient.
        sorting (Sorting, optional): Definition of the sorting logic (TIME or ARC_LENGTH). Defaults to Sorting.TIME.

    Raises:
        TrajectoryError: If the number of positions and rotations do not match.
    """
    # check dimensions
    if rotations is not None and len(positions) != len(rotations):
        raise TrajectoryError(
            f"Number of positions ({len(positions)}) and rotations ({len(rotations)}) do not match!"
        )

    self._sorting = sorting

    # pose
    self.positions = positions
    self.rotations = rotations

    if not isinstance(timestamps, np.ndarray):
        timestamps = np.array(timestamps) if timestamps is not None else None

    self.timestamps = np.arange(0, len(positions)) if timestamps is None else timestamps

    if velocity_xyz is not None and len(velocity_xyz) == len(self.positions):
        self._velocity_xyz = velocity_xyz
    else:
        self._velocity_xyz = gradient_3d(xyz=self.positions.xyz, tstamps=self.timestamps)
        logger.info("Speeds were not provided or had wrong dimensions. Speeds were computed instead.")

    if path_lengths is not None and len(path_lengths) == len(self.positions):
        self.path_lengths = path_lengths
    else:
        self.path_lengths = self.init_path_lengths()
        logger.info("Path lengths were not provided or had wrong dimensions. Path lengths were computed instead.")

    self.name = name or f"Trajectory {Trajectory._counter}"

    # Sort data according to initial sorting strategy
    self._apply_sorting()

    Trajectory._counter += 1

has_orientation property

has_orientation: bool

Returns True if valid rotation data is available.

is_unix_time property

is_unix_time: bool

Checks if the supplied trajectory has (likely) unix timestamps as seconds.

total_length property

total_length: float

Returns the total cumulative path length of the trajectory in meters.

sorting property

sorting: Sorting

Returns the current sorting strategy (read-only).

To change sorting, use set_sorting() which physically reorders the data.

index property

index: ndarray

Returns the independent variable currently parameterizing the trajectory. This is either the Timestamp vector or the Path Length vector, depending on self.sorting.

datetimes property

datetimes: ndarray

Returns the timestamps converted to Pandas datetime objects (unit='s').

index_unit property

index_unit: str

Returns the unit string of the current index ('s' for Time, 'm' for Path Length).

index_label property

index_label: str

Returns the label string of the current index (e.g., 'time [s]').

data_rate property

data_rate: float

Calculates the average data rate (frequency in Hz) based on timestamp differences.

velocity_xyz property writable

velocity_xyz: ndarray

Returns the 3D velocity vectors. If not set manually, they are computed via gradient of the positions over time.

absolute_velocity property

absolute_velocity: ndarray

Returns the norm (magnitude) of the 3D velocity vectors.

xyz property

xyz: ndarray

Returns the XYZ coordinates.

Since trajectory data is always stored in sorted order (by time or arc length), this is equivalent to self.positions.xyz.

quat property

quat: ndarray

Returns the quaternions. Returns zeros if no rotations are present.

rpy property

rpy: ndarray

Returns the Roll-Pitch-Yaw angles (in radians).

se3 property writable

se3: list[ndarray]

Returns a list of SE3 poses (4x4 homogeneous transformation matrices).

time_start property

time_start: float

Returns the minimum timestamp of the trajectory.

time_end property

time_end: float

Returns the maximum timestamp of the trajectory.

from_file classmethod

from_file(filename: str, io_stream: bool = False) -> Trajectory

Create a trajectory instance from a file.

The file is expected to be a CSV-like format. It handles extraction of timestamps, xyz positions, rotations, path lengths, and velocities via ascii trajectory reader.

Parameters:

  • filename (str) –

    Path to the file or string content if io_stream is True.

  • io_stream (bool, default: False ) –

    If True, filename is treated as the raw string content of the file/stream. Defaults to False.

Returns:

  • Trajectory ( Trajectory ) –

    The loaded trajectory object.

Source code in trajectopy\core\trajectory.py
@classmethod
def from_file(cls, filename: str, io_stream: bool = False) -> "Trajectory":
    """
    Create a trajectory instance from a file.

    The file is expected to be a CSV-like format. It handles extraction of
    timestamps, xyz positions, rotations, path lengths, and velocities via `ascii` trajectory reader.

    Args:
        filename (str): Path to the file or string content if io_stream is True.
        io_stream (bool, optional): If True, `filename` is treated as the raw string content
                                    of the file/stream. Defaults to False.

    Returns:
        Trajectory: The loaded trajectory object.
    """
    if io_stream:
        header_data, trajectory_data = ascii.read_string(filename, dtype=object)
    else:
        header_data, trajectory_data = ascii.read_data(filename, dtype=object)

    tstamps = ascii.extract_trajectory_timestamps(header_data=header_data, trajectory_data=trajectory_data)
    positions = ascii.extract_trajectory_positions(header_data=header_data, trajectory_data=trajectory_data)
    path_lengths = ascii.extract_trajectory_path_lengths(header_data=header_data, trajectory_data=trajectory_data)
    velocity_xyz = ascii.extract_trajectory_velocity_xyz(header_data=header_data, trajectory_data=trajectory_data)
    rotations = ascii.extract_trajectory_rotations(header_data=header_data, trajectory_data=trajectory_data)

    return Trajectory(
        timestamps=tstamps,
        positions=positions,
        rotations=rotations,
        name=header_data.name,
        path_lengths=path_lengths,
        velocity_xyz=velocity_xyz,
        sorting=Sorting.from_str(header_data.sorting),
    )

from_arrays classmethod

from_arrays(xyz: ndarray, quat: ndarray | None = None, rpy: ndarray | None = None, epsg: int = 0, **kwargs) -> Trajectory

Factory: Handles creation from raw numpy arrays.

Source code in trajectopy\core\trajectory.py
@classmethod
def from_arrays(
    cls,
    xyz: np.ndarray,
    quat: np.ndarray | None = None,
    rpy: np.ndarray | None = None,
    epsg: int = 0,
    **kwargs,
) -> "Trajectory":
    """Factory: Handles creation from raw numpy arrays."""

    pos_obj = Positions(xyz=xyz, epsg=epsg)

    rot_obj = None
    if quat is not None and rpy is not None:
        raise TrajectoryError("Provide quat OR rpy, not both.")

    if quat is not None:
        rot_obj = Rotations.from_quat(quat)
    elif rpy is not None:
        rot_obj = Rotations.from_euler(seq="xyz", angles=rpy)

    return cls(positions=pos_obj, rotations=rot_obj, **kwargs)

__str__

__str__() -> str

Returns a formatted string summary of the trajectory, including name, length, EPSG, and data rate.

Source code in trajectopy\core\trajectory.py
def __str__(self) -> str:
    """
    Returns a formatted string summary of the trajectory, including name, length, EPSG, and data rate.
    """
    width = 24
    return (
        f"\n _______________________________________________________\n"
        f"| ------------------ Trajectory Info ------------------ |\n"
        f"| Name:                         {self.name:<{width}}|\n"
        f"| Number of poses:              {len(self):<{width}}|\n"
        f"| Orientation available:        {'yes' if self.has_orientation else 'no':<{width}}|\n"
        f"| EPSG:                         {self.positions.epsg:<{width}}|\n"
        f"| Length [m]:                   {self.total_length:<{width}.3f}|\n"
        f"| Data rate [Hz]:               {self.data_rate:<{width}.3f}|\n"
        f"| Function of:                  {self.index_label:<{width}}|\n"
        f"|_______________________________________________________|\n"
    )

__len__

__len__() -> int

Returns the number of poses in the trajectory.

Source code in trajectopy\core\trajectory.py
def __len__(self) -> int:
    """Returns the number of poses in the trajectory."""
    return len(self.positions.xyz)

__eq__

__eq__(other: Trajectory) -> bool

Checks equality between two trajectories using np.allclose for numerical arrays. Compares positions, rotations, timestamps, path lengths, velocities, and names.

Source code in trajectopy\core\trajectory.py
def __eq__(self, other: "Trajectory") -> bool:
    """
    Checks equality between two trajectories using `np.allclose` for numerical arrays.
    Compares positions, rotations, timestamps, path lengths, velocities, and names.
    """
    if self.rotations is not None and other.rotations is not None:
        rot_equal = np.allclose(self.rotations.as_quat(), other.rotations.as_quat())
    elif self.rotations is None and other.rotations is None:
        rot_equal = True
    else:
        rot_equal = False

    return (
        np.allclose(self.positions.xyz, other.positions.xyz)
        and rot_equal
        and np.allclose(self.timestamps, other.timestamps)
        and np.allclose(self.path_lengths, other.path_lengths)
        and np.allclose(self._velocity_xyz, other._velocity_xyz)
        and self.name == other.name
    )

copy

copy() -> Trajectory

Returns a deep copy of the trajectory instance.

Source code in trajectopy\core\trajectory.py
def copy(self) -> "Trajectory":
    """Returns a deep copy of the trajectory instance."""
    return copy.deepcopy(self)

init_path_lengths

init_path_lengths()

Computes cumulative path lengths based on Euclidean distances between consecutive local coordinates.

Source code in trajectopy\core\trajectory.py
def init_path_lengths(self):
    """Computes cumulative path lengths based on Euclidean distances between consecutive local coordinates."""
    return lengths_from_xyz(self.positions.to_local(inplace=False).xyz)

set_sorting

set_sorting(sorting: Sorting) -> None

Changes the sorting strategy and physically reorders all trajectory data.

After calling this method, all data arrays (positions, rotations, timestamps, path_lengths, velocities) will be reordered according to the new sorting.

Parameters:

  • sorting (Sorting) –

    The new sorting strategy (TIME or ARC_LENGTH).

Source code in trajectopy\core\trajectory.py
def set_sorting(self, sorting: Sorting) -> None:
    """Changes the sorting strategy and physically reorders all trajectory data.

    After calling this method, all data arrays (positions, rotations, timestamps,
    path_lengths, velocities) will be reordered according to the new sorting.

    Args:
        sorting (Sorting): The new sorting strategy (TIME or ARC_LENGTH).
    """
    if sorting == self._sorting:
        return

    self._sorting = sorting
    self._apply_sorting()

overlaps_with

overlaps_with(other: Trajectory) -> bool

Checks if the time span of this trajectory overlaps with another.

Parameters:

  • other (Trajectory) –

    The trajectory to compare against.

Returns:

  • bool ( bool ) –

    True if the time ranges overlap, False otherwise.

Source code in trajectopy\core\trajectory.py
def overlaps_with(self, other: "Trajectory") -> bool:
    """
    Checks if the time span of this trajectory overlaps with another.

    Args:
        other (Trajectory): The trajectory to compare against.

    Returns:
        bool: True if the time ranges overlap, False otherwise.
    """
    if len(self) == 0 or len(other) == 0:
        return False

    return self.time_start <= other.time_end and self.time_end >= other.time_start

crop

crop(t_start: float, t_end: float, inverse: bool = False, inplace: bool = True) -> Trajectory

Crops (or cuts) the trajectory based on a time window.

Parameters:

  • t_start (float) –

    Start timestamp of the window.

  • t_end (float) –

    End timestamp of the window.

  • inverse (bool, default: False ) –

    If True, removes data inside the window (cutting). If False, keeps data inside the window (cropping). Defaults to False.

  • inplace (bool, default: True ) –

    If True, modifies self. If False, returns a new instance. Defaults to True.

Returns:

  • Trajectory ( Trajectory ) –

    The modified or new trajectory instance.

Source code in trajectopy\core\trajectory.py
def crop(self, t_start: float, t_end: float, inverse: bool = False, inplace: bool = True) -> "Trajectory":
    """
    Crops (or cuts) the trajectory based on a time window.

    Args:
        t_start (float): Start timestamp of the window.
        t_end (float): End timestamp of the window.
        inverse (bool, optional): If True, removes data *inside* the window (cutting).
                                  If False, keeps data *inside* the window (cropping). Defaults to False.
        inplace (bool, optional): If True, modifies self. If False, returns a new instance. Defaults to True.

    Returns:
        Trajectory: The modified or new trajectory instance.
    """
    # filter to t_start and t_end
    if inverse:
        filt = [not t_start <= tstamps <= t_end for tstamps in self.timestamps]
    else:
        filt = [t_start <= tstamps <= t_end for tstamps in self.timestamps]

    return self.mask(mask=filt, inplace=inplace)

intersect

intersect(timestamps: ndarray, max_gap_size: float = 10.0, inplace: bool = True) -> Trajectory

Filters the trajectory to overlap with a reference timestamp vector.

This method finds the common time span between self and the reference timestamps, crops self to that span, and then filters points that are either exact matches or exist within valid gaps defined by max_gap_size.

Parameters:

  • timestamps (ndarray) –

    The reference timestamps to intersect with.

  • max_gap_size (float, default: 10.0 ) –

    The maximum allowed time gap (in seconds) between reference timestamps to include trajectory points. Defaults to 10.0.

  • inplace (bool, default: True ) –

    If True, modifies self. Defaults to True.

Raises:

  • ValueError

    If the time spans do not overlap.

Returns:

  • Trajectory ( Trajectory ) –

    The intersected trajectory.

Source code in trajectopy\core\trajectory.py
def intersect(self, timestamps: np.ndarray, max_gap_size: float = 10.0, inplace: bool = True) -> "Trajectory":
    """
    Filters the trajectory to overlap with a reference timestamp vector.

    This method finds the common time span between self and the reference `timestamps`,
    crops self to that span, and then filters points that are either exact matches
    or exist within valid gaps defined by `max_gap_size`.

    Args:
        timestamps (np.ndarray): The reference timestamps to intersect with.
        max_gap_size (float, optional): The maximum allowed time gap (in seconds) between
                                        reference timestamps to include trajectory points. Defaults to 10.0.
        inplace (bool, optional): If True, modifies self. Defaults to True.

    Raises:
        ValueError: If the time spans do not overlap.

    Returns:
        Trajectory: The intersected trajectory.
    """
    traj_self = self if inplace else self.copy()
    time_span = common_time_span(tstamps1=timestamps, tstamps2=traj_self.timestamps)

    if time_span is None:
        raise ValueError("intersect_both: Timespans do not overlap!")

    traj_self.crop(t_start=time_span[0], t_end=time_span[1])

    tstamps_sorted = np.sort(timestamps)
    filter_mask = np.ones(len(traj_self.timestamps), dtype=bool)

    # find where gaps are too large
    gaps = np.diff(tstamps_sorted) > max_gap_size
    gap_starts = tstamps_sorted[:-1][gaps]
    gap_ends = tstamps_sorted[1:][gaps]

    for g_start, g_end in zip(gap_starts, gap_ends):
        idx_start = np.searchsorted(traj_self.timestamps, g_start, side="right")
        idx_end = np.searchsorted(traj_self.timestamps, g_end, side="left")

        # keep one point after gap start and one point before gap end for interpolation
        if idx_end - 1 > idx_start:
            filter_mask[idx_start + 1 : idx_end - 1] = False

    filter_index = np.where(filter_mask)[0]
    traj_self.mask(np.array(filter_index, dtype=int))

    return traj_self

mask

mask(mask: list | ndarray, inplace: bool = True) -> Trajectory

Applies a boolean mask or index array to filter all trajectory components.

Filtered components include: timestamps, positions, rotations, path lengths, and velocities.

Parameters:

  • mask (Union[list, ndarray]) –

    Boolean array or list of indices to keep.

  • inplace (bool, default: True ) –

    If True, modifies self. Defaults to True.

Returns:

  • Trajectory ( Trajectory ) –

    The masked trajectory.

Source code in trajectopy\core\trajectory.py
def mask(self, mask: list | np.ndarray, inplace: bool = True) -> "Trajectory":
    """
    Applies a boolean mask or index array to filter all trajectory components.

    Filtered components include: timestamps, positions, rotations, path lengths, and velocities.

    Args:
        mask (Union[list, np.ndarray]): Boolean array or list of indices to keep.
        inplace (bool, optional): If True, modifies self. Defaults to True.

    Returns:
        Trajectory: The masked trajectory.
    """
    traj_self = self if inplace else self.copy()

    traj_self.timestamps = traj_self.timestamps[mask]
    traj_self.positions.xyz = traj_self.positions.xyz[mask, :]

    if traj_self.rotations:
        quat_filtered = traj_self.rotations.as_quat()[mask, :]
        traj_self.rotations = Rotations.from_quat(quat_filtered) if len(quat_filtered) > 0 else None

    traj_self.path_lengths = traj_self.path_lengths[mask]

    if traj_self.velocity_xyz is not None:
        traj_self.velocity_xyz = traj_self.velocity_xyz[mask]

    return traj_self

transform

transform(transformation: ndarray, inplace: bool = True) -> Trajectory

Applies a rigid body transformation to the trajectory poses.

Parameters:

  • transformation (ndarray) –

    A 4x4 homogeneous transformation matrix.

  • inplace (bool, default: True ) –

    If True, modifies self. Defaults to True.

Returns:

  • Trajectory ( Trajectory ) –

    The transformed trajectory.

Source code in trajectopy\core\trajectory.py
def transform(self, transformation: np.ndarray, inplace: bool = True) -> "Trajectory":
    """
    Applies a rigid body transformation to the trajectory poses.

    Args:
        transformation (np.ndarray): A 4x4 homogeneous transformation matrix.
        inplace (bool, optional): If True, modifies self. Defaults to True.

    Returns:
        Trajectory: The transformed trajectory.
    """
    traj_self = self if inplace else self.copy()
    traj_self.se3 = [np.dot(transformation, p) for p in traj_self.se3]
    return traj_self

to_dataframe

to_dataframe(sort_by: str = '') -> DataFrame

Exports the trajectory to a Pandas DataFrame.

Columns usually include: time, path_length, pos_x, pos_y, pos_z, speed_x, speed_y, speed_z, and rotation columns (rot_x/y/z/w) if available.

Parameters:

  • sort_by (str, default: '' ) –

    Column name to sort by. If empty, uses self.sorting.

Returns:

  • DataFrame

    pd.DataFrame: A dataframe containing the trajectory data.

Source code in trajectopy\core\trajectory.py
def to_dataframe(self, sort_by: str = "") -> pd.DataFrame:
    """
    Exports the trajectory to a Pandas DataFrame.

    Columns usually include: time, path_length, pos_x, pos_y, pos_z, speed_x, speed_y, speed_z,
    and rotation columns (rot_x/y/z/w) if available.

    Args:
        sort_by (str, optional): Column name to sort by. If empty, uses `self.sorting`.

    Returns:
        pd.DataFrame: A dataframe containing the trajectory data.
    """
    sort_by = sort_by or self.sorting
    if self.rotations:
        dataframe = pd.DataFrame(
            np.c_[
                self.timestamps, self.path_lengths, self.positions.xyz, self.rotations.as_quat(), self.velocity_xyz
            ],
            columns=[
                "time",
                "path_length",
                "pos_x",
                "pos_y",
                "pos_z",
                "rot_x",
                "rot_y",
                "rot_z",
                "rot_w",
                "speed_x",
                "speed_y",
                "speed_z",
            ],
        )
    else:
        dataframe = pd.DataFrame(
            np.c_[self.timestamps, self.path_lengths, self.positions.xyz, self.velocity_xyz],
            columns=["time", "path_length", "pos_x", "pos_y", "pos_z", "speed_x", "speed_y", "speed_z"],
        )

    return dataframe.sort_values(by=sort_by)

to_string

to_string() -> str

Serializes the trajectory to a CSV-formatted string with metadata headers.

Headers included: #epsg, #name, #nframe, #sorting, #fields.

Source code in trajectopy\core\trajectory.py
def to_string(self) -> str:
    """
    Serializes the trajectory to a CSV-formatted string with metadata headers.

    Headers included: #epsg, #name, #nframe, #sorting, #fields.
    """

    def write_header() -> str:
        fields = "t,l,px,py,pz,vx,vy,vz" if self.rotations is None else "t,l,px,py,pz,qx,qy,qz,qw,vx,vy,vz"
        header = [
            f"#epsg {self.positions.epsg}",
            f"#name {self.name}",
            "#nframe enu",
            f"#sorting {self.sorting.value}",
            f"#fields {fields}",
        ]
        return "\n".join(header) + "\n"

    if self.rotations is None:
        trajectory_data = np.c_[self.timestamps, self.path_lengths, self.positions.xyz, self.velocity_xyz]
    else:
        trajectory_data = np.c_[
            self.timestamps,
            self.path_lengths,
            self.positions.xyz,
            self.rotations.as_quat(),
            self.velocity_xyz,
        ]

    output = io.StringIO()
    output.write(write_header())
    pd.DataFrame(trajectory_data).to_csv(output, header=False, index=False, float_format="%.9f")

    return output.getvalue()

to_file

to_file(filename: str, mode: str = 'w') -> None

Writes the trajectory to an ASCII file using the format defined in to_string.

Parameters:

  • filename (str) –

    The output file path.

  • mode (str, default: 'w' ) –

    File open mode. Defaults to "w".

Source code in trajectopy\core\trajectory.py
def to_file(self, filename: str, mode: str = "w") -> None:
    """
    Writes the trajectory to an ASCII file using the format defined in `to_string`.

    Args:
        filename (str): The output file path.
        mode (str, optional): File open mode. Defaults to "w".
    """
    with open(filename, mode=mode, newline="\n", encoding="utf-8") as file:
        file.write(self.to_string())

to_kml

to_kml(filename: str, precision: float = 1e-06) -> str

Exports the trajectory to a Google Earth KML file.

Requires the trajectory to have a valid EPSG code so it can be converted to WGS84 (EPSG:4326).

Parameters:

  • filename (str) –

    The output filename (e.g., "track.kml").

  • precision (float, default: 1e-06 ) –

    Coordinate precision in degrees for rounding/simplification. Defaults to 1e-6.

Raises:

  • ValueError

    If the trajectory does not have a known EPSG code.

Source code in trajectopy\core\trajectory.py
def to_kml(self, filename: str, precision: float = 1e-6) -> str:
    """
    Exports the trajectory to a Google Earth KML file.

    Requires the trajectory to have a valid EPSG code so it can be converted to WGS84 (EPSG:4326).

    Args:
        filename (str): The output filename (e.g., "track.kml").
        precision (float, optional): Coordinate precision in degrees for rounding/simplification. Defaults to 1e-6.

    Raises:
        ValueError: If the trajectory does not have a known EPSG code.
    """
    traj = self.copy()
    if traj.positions.local_transformer is None:
        raise ValueError(
            "Trajectory must be defined in a well-known coordinate system (EPSG code) to be exported to KML. "
        )
    traj.positions.to_epsg(4326)

    traj.positions = traj.positions.round_to(precision)
    _, indices = np.unique(traj.positions.xyz[:, 0:2], return_index=True, axis=0)
    traj.mask(np.sort(indices))

    kml_file = ET.Element("kml", xmlns="http://earth.google.com/kml/2.1")
    document = ET.SubElement(kml_file, "Document")

    placemark = ET.SubElement(document, "Placemark")
    name = ET.SubElement(placemark, "name")
    name.text = traj.name

    style = ET.SubElement(placemark, "Style")
    line_style = ET.SubElement(style, "LineStyle")
    color = ET.SubElement(line_style, "color")
    color.text = "ff0000ff"
    width = ET.SubElement(line_style, "width")
    width.text = "2"

    line_string = ET.SubElement(placemark, "LineString")
    coordinates = ET.SubElement(line_string, "coordinates")

    coordinates.text = "\n".join(f"  {pos[1]:.9f},{pos[0]:.9f},{0.00:.3f}" for pos in traj.positions.xyz)

    tree = ET.ElementTree(kml_file)
    ET.indent(tree, space="", level=0)
    tree.write(filename, encoding="utf-8", xml_declaration=True)