MicrophoneArrayGeometry.java

package org.hammer.audio.geometry;

import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
 * Calibrated 2D positions of a set of acoustic sensors, decoupled from the audio acquisition layer.
 *
 * <p>{@code MicrophoneArrayGeometry} provides reusable geometry primitives for localization
 * pipelines: pairwise distances, centroid, maximum spacing and lookup by sensor id. It is
 * intentionally independent of the {@code audio-acquisition} {@code MicrophoneArray} type so that
 * geometry utilities can be shared with non-audio sensor arrays (e.g. radar, sonar) and with
 * offline tools that do not depend on the audio stack.
 *
 * <p>Instances are immutable, all stored positions are validated to be finite, and sensor ids must
 * be unique.
 */
public final class MicrophoneArrayGeometry {

  private final List<NamedPosition> positions;
  private final Map<String, NamedPosition> byId;
  private final double maxSpacingMeters;

  /** Create a geometry from sensor ids and 2D positions in meters. */
  public MicrophoneArrayGeometry(List<NamedPosition> positions) {
    Objects.requireNonNull(positions, "positions");
    if (positions.isEmpty()) {
      throw new IllegalArgumentException("positions must not be empty");
    }
    List<NamedPosition> copy = new ArrayList<>(positions.size());
    Map<String, NamedPosition> idMap = new LinkedHashMap<>();
    for (NamedPosition position : positions) {
      Objects.requireNonNull(position, "positions must not contain null entries");
      if (idMap.put(position.id(), position) != null) {
        throw new IllegalArgumentException("duplicate sensor id: " + position.id());
      }
      copy.add(position);
    }
    this.positions = List.copyOf(copy);
    this.byId = Collections.unmodifiableMap(idMap);
    this.maxSpacingMeters = computeMaxSpacing(copy);
  }

  /** Number of sensors in this geometry. */
  public int size() {
    return positions.size();
  }

  /** Named positions in declaration order. */
  public List<NamedPosition> positions() {
    return positions;
  }

  /** Look up a position by sensor id; throws when the id is unknown. */
  public Vector2 position(String id) {
    NamedPosition position = byId.get(Objects.requireNonNull(id, "id"));
    if (position == null) {
      throw new IllegalArgumentException("unknown sensor id: " + id);
    }
    return position.positionMeters();
  }

  /** Distance between two sensors identified by their ids. */
  public double distanceBetween(String firstId, String secondId) {
    return position(firstId).distanceTo(position(secondId));
  }

  /** Geometric centroid of all sensor positions. */
  public Vector2 centroid() {
    double sumX = 0.0;
    double sumY = 0.0;
    for (NamedPosition position : positions) {
      sumX += position.positionMeters().x();
      sumY += position.positionMeters().y();
    }
    int count = positions.size();
    return new Vector2(sumX / count, sumY / count);
  }

  /** Largest pairwise distance between sensors, useful for bounding TDOA search ranges. */
  public double maxSpacingMeters() {
    return maxSpacingMeters;
  }

  /**
   * Maximum physical inter-sensor delay for a given propagation speed.
   *
   * @param speedOfSoundMetersPerSecond positive, finite speed of sound in m/s
   */
  public double maxInterSensorDelaySeconds(double speedOfSoundMetersPerSecond) {
    if (!(speedOfSoundMetersPerSecond > 0.0) || !Double.isFinite(speedOfSoundMetersPerSecond)) {
      throw new IllegalArgumentException("speedOfSoundMetersPerSecond must be finite and > 0");
    }
    return maxSpacingMeters / speedOfSoundMetersPerSecond;
  }

  private static double computeMaxSpacing(List<NamedPosition> positions) {
    double max = 0.0;
    for (int i = 0; i < positions.size(); i++) {
      Vector2 a = positions.get(i).positionMeters();
      for (int j = i + 1; j < positions.size(); j++) {
        double distance = a.distanceTo(positions.get(j).positionMeters());
        if (distance > max) {
          max = distance;
        }
      }
    }
    return max;
  }

  /** Sensor identifier and 2D position. */
  public record NamedPosition(String id, Vector2 positionMeters) {

    /** Validate identifier and position. */
    public NamedPosition {
      if (id == null || id.isBlank()) {
        throw new IllegalArgumentException("id must not be blank");
      }
      Objects.requireNonNull(positionMeters, "positionMeters");
    }
  }
}