WingbeatFeatureVector.java

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

import java.util.List;
import java.util.Objects;

/**
 * Feature representation of a wingbeat acoustic signature.
 *
 * <p>The feature vector captures spectral and temporal characteristics of a narrowband tonal
 * source. All numeric features use SI units (Hz for frequencies, seconds for duration). Fields that
 * cannot be computed — for example harmonic features when no audio block is available — are
 * represented by empty lists or zero values rather than {@code null} or {@code NaN}.
 *
 * <p>The feature set is intentionally generic and is not limited to mosquitoes. Classifiers using
 * this vector should document which fields they actually rely on.
 *
 * @param fundamentalFrequencyHz estimated fundamental wingbeat frequency in Hz; must be finite and
 *     >= 0
 * @param harmonicAmplitudes linear FFT amplitudes at the fundamental and successive harmonics; may
 *     be empty when audio data is unavailable; never {@code null}
 * @param harmonicRatios ratio of each harmonic amplitude to the fundamental amplitude; may be empty
 *     when {@code harmonicAmplitudes} has fewer than two elements; never {@code null}
 * @param spectralCentroidHz magnitude-weighted mean frequency within the analysis band in Hz; must
 *     be finite and >= 0
 * @param spectralBandwidthHz standard deviation of the spectral distribution around the centroid in
 *     Hz; must be finite and >= 0
 * @param frequencyDriftHzPerSecond estimated linear frequency drift rate in Hz/s; {@code 0} when
 *     insufficient history is available; must be finite
 * @param frequencyJitterHz standard deviation of observed instantaneous frequencies around the
 *     smoothed fundamental in Hz; must be finite and >= 0
 * @param amplitudeModulation normalized amplitude-modulation index in {@code [0,1]}; {@code 0} when
 *     insufficient history is available
 * @param signalToNoiseRatio ratio of peak magnitude to median band noise floor; {@code 0} when
 *     audio data is unavailable; must be finite and >= 0
 * @param trackDurationSeconds elapsed duration since the source was first tracked in seconds; must
 *     be finite and >= 0
 * @param featureConfidence overall confidence in the extracted features in {@code [0,1]}
 */
public record WingbeatFeatureVector(
    double fundamentalFrequencyHz,
    List<Double> harmonicAmplitudes,
    List<Double> harmonicRatios,
    double spectralCentroidHz,
    double spectralBandwidthHz,
    double frequencyDriftHzPerSecond,
    double frequencyJitterHz,
    double amplitudeModulation,
    double signalToNoiseRatio,
    double trackDurationSeconds,
    double featureConfidence) {

  /* Validate fields and defensively copy lists. */
  public WingbeatFeatureVector {
    Objects.requireNonNull(harmonicAmplitudes, "harmonicAmplitudes");
    Objects.requireNonNull(harmonicRatios, "harmonicRatios");
    validateSeries(harmonicAmplitudes, "harmonicAmplitudes");
    validateSeries(harmonicRatios, "harmonicRatios");
    if (harmonicAmplitudes.isEmpty() && !harmonicRatios.isEmpty()) {
      throw new IllegalArgumentException(
          "harmonicRatios must be empty when harmonicAmplitudes is empty");
    }
    if (!Double.isFinite(fundamentalFrequencyHz) || fundamentalFrequencyHz < 0.0) {
      throw new IllegalArgumentException("fundamentalFrequencyHz must be finite and >= 0");
    }
    if (!Double.isFinite(spectralCentroidHz) || spectralCentroidHz < 0.0) {
      throw new IllegalArgumentException("spectralCentroidHz must be finite and >= 0");
    }
    if (!Double.isFinite(spectralBandwidthHz) || spectralBandwidthHz < 0.0) {
      throw new IllegalArgumentException("spectralBandwidthHz must be finite and >= 0");
    }
    if (!Double.isFinite(frequencyDriftHzPerSecond)) {
      throw new IllegalArgumentException("frequencyDriftHzPerSecond must be finite");
    }
    if (!Double.isFinite(frequencyJitterHz) || frequencyJitterHz < 0.0) {
      throw new IllegalArgumentException("frequencyJitterHz must be finite and >= 0");
    }
    if (!Double.isFinite(amplitudeModulation)
        || amplitudeModulation < 0.0
        || amplitudeModulation > 1.0) {
      throw new IllegalArgumentException("amplitudeModulation must be finite and in [0,1]");
    }
    if (!Double.isFinite(signalToNoiseRatio) || signalToNoiseRatio < 0.0) {
      throw new IllegalArgumentException("signalToNoiseRatio must be finite and >= 0");
    }
    if (!Double.isFinite(trackDurationSeconds) || trackDurationSeconds < 0.0) {
      throw new IllegalArgumentException("trackDurationSeconds must be finite and >= 0");
    }
    if (!Double.isFinite(featureConfidence) || featureConfidence < 0.0 || featureConfidence > 1.0) {
      throw new IllegalArgumentException("featureConfidence must be finite and in [0,1]");
    }
    harmonicAmplitudes = List.copyOf(harmonicAmplitudes);
    harmonicRatios = List.copyOf(harmonicRatios);
  }

  private static void validateSeries(List<Double> values, String fieldName) {
    for (int i = 0; i < values.size(); i++) {
      Double value = values.get(i);
      if (value == null) {
        throw new IllegalArgumentException(fieldName + "[" + i + "] must not be null");
      }
      if (Double.isNaN(value)) {
        throw new IllegalArgumentException(fieldName + "[" + i + "] must not be NaN");
      }
      if (Double.isInfinite(value)) {
        throw new IllegalArgumentException(fieldName + "[" + i + "] must be finite");
      }
      if (value < 0.0) {
        throw new IllegalArgumentException(fieldName + "[" + i + "] must be >= 0");
      }
    }
  }
}