ScenarioTrajectory.java

package org.hammer.audio.experimental.acoustic.scenario;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.hammer.audio.geometry.Vector2;

/**
 * Trajectory ground truth for an acoustic source in a scenario.
 *
 * <p>{@link #timestamps()} and {@link #positions()} are always required and must have equal length.
 * {@link #velocities()} and {@link #orientations()} may be {@code null} for partial ground truth
 * (e.g. a dataset that records positions but not velocities).
 *
 * <p>Use {@link #linear(Vector2, Vector2, double, int)} to construct a uniformly sampled linear
 * trajectory from a start position and constant velocity.
 *
 * @param timestamps strictly increasing timestamp sequence in seconds
 * @param positions source positions aligned to {@code timestamps}
 * @param velocities optional source velocities aligned to {@code timestamps}
 * @param orientations optional source orientations aligned to {@code timestamps}
 */
public record ScenarioTrajectory(
    List<Double> timestamps,
    List<Vector2> positions,
    List<Vector2> velocities,
    List<Double> orientations) {

  /* Validate sizes and defensively copy all provided lists. */
  public ScenarioTrajectory {
    Objects.requireNonNull(timestamps, "timestamps");
    Objects.requireNonNull(positions, "positions");
    if (timestamps.isEmpty()) {
      throw new IllegalArgumentException("timestamps must not be empty");
    }
    if (timestamps.size() != positions.size()) {
      throw new IllegalArgumentException(
          "timestamps and positions must have the same size; got "
              + timestamps.size()
              + " vs "
              + positions.size());
    }
    if (velocities != null && velocities.size() != timestamps.size()) {
      throw new IllegalArgumentException(
          "velocities must have the same size as timestamps when provided");
    }
    if (orientations != null && orientations.size() != timestamps.size()) {
      throw new IllegalArgumentException(
          "orientations must have the same size as timestamps when provided");
    }
    double previousTimestamp = Double.NEGATIVE_INFINITY;
    for (int i = 0; i < timestamps.size(); i++) {
      Double timestamp = timestamps.get(i);
      if (timestamp == null || !Double.isFinite(timestamp)) {
        throw new IllegalArgumentException("timestamps must contain only finite values");
      }
      if (i > 0 && timestamp <= previousTimestamp) {
        throw new IllegalArgumentException("timestamps must be strictly increasing");
      }
      previousTimestamp = timestamp;
    }
    if (orientations != null) {
      for (Double orientation : orientations) {
        if (orientation == null || !Double.isFinite(orientation)) {
          throw new IllegalArgumentException("orientations must contain only finite values");
        }
      }
    }
    timestamps = List.copyOf(timestamps);
    positions = List.copyOf(positions);
    velocities = velocities != null ? List.copyOf(velocities) : null;
    orientations = orientations != null ? List.copyOf(orientations) : null;
  }

  /**
   * Create a uniformly sampled linear trajectory.
   *
   * <p>The trajectory is sampled at {@code sampleCount} evenly spaced time steps over {@code
   * durationSeconds}, starting at {@code startPositionMeters} and moving with constant {@code
   * velocityMetersPerSecond}. Velocities are included; orientations are not.
   *
   * @param startPositionMeters initial position in meters
   * @param velocityMetersPerSecond constant 2-D velocity in m/s
   * @param durationSeconds total scenario duration; must be positive
   * @param sampleCount number of sample points; must be at least 2
   */
  public static ScenarioTrajectory linear(
      Vector2 startPositionMeters,
      Vector2 velocityMetersPerSecond,
      double durationSeconds,
      int sampleCount) {
    Objects.requireNonNull(startPositionMeters, "startPositionMeters");
    Objects.requireNonNull(velocityMetersPerSecond, "velocityMetersPerSecond");
    if (durationSeconds <= 0.0 || !Double.isFinite(durationSeconds)) {
      throw new IllegalArgumentException("durationSeconds must be finite and > 0");
    }
    if (sampleCount < 2) {
      throw new IllegalArgumentException("sampleCount must be >= 2");
    }
    List<Double> timestamps = new ArrayList<>(sampleCount);
    List<Vector2> positions = new ArrayList<>(sampleCount);
    List<Vector2> velocities = new ArrayList<>(sampleCount);
    double step = durationSeconds / (sampleCount - 1);
    for (int i = 0; i < sampleCount; i++) {
      double t = i * step;
      timestamps.add(t);
      positions.add(startPositionMeters.plus(velocityMetersPerSecond.scale(t)));
      velocities.add(velocityMetersPerSecond);
    }
    return new ScenarioTrajectory(timestamps, positions, velocities, null);
  }
}