RuleBasedWingbeatClassifier.java

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

import java.util.Objects;

/**
 * Rule-based baseline wingbeat classifier.
 *
 * <p>Classification is based on frequency thresholds derived from published mosquito wingbeat
 * literature. The boundaries are approximate and species-dependent; this classifier is intended as
 * a reproducible, transparent baseline rather than a production-grade identifier.
 *
 * <h3>Classification logic</h3>
 *
 * <ol>
 *   <li>If the fundamental frequency falls outside the broadest mosquito-like range, the result is
 *       {@link WingbeatLabel#UNKNOWN}.
 *   <li>Within the mosquito-like range, the frequency is compared against empirical sub-ranges:
 *       <ul>
 *         <li>{@link WingbeatLabel#POSSIBLY_BLOOD_FED_FEMALE}: 300–430 Hz (experimental lowest
 *             female range, capped to low confidence)
 *         <li>{@link WingbeatLabel#FEMALE_LIKELY}: 430–550 Hz (typical female range)
 *         <li>{@link WingbeatLabel#MALE_LIKELY}: above the overlap boundary (≥550 Hz)
 *         <li>{@link WingbeatLabel#MOSQUITO_LIKE}: near the 550 Hz boundary; returned with reduced
 *             confidence because the male/female ranges overlap here
 *       </ul>
 * </ol>
 *
 * <h3>Assumptions and limitations</h3>
 *
 * <ul>
 *   <li>Frequency thresholds represent approximate ranges for <em>Anopheles gambiae</em> and
 *       similar species. Other mosquito species may have different ranges.
 *   <li>The {@link WingbeatLabel#POSSIBLY_BLOOD_FED_FEMALE} branch is intentionally conservative:
 *       it is an exploratory label with a hard confidence cap and must not be read as a confirmed
 *       feeding-state prediction.
 *   <li>Environmental factors such as temperature, humidity and fatigue affect wingbeat frequency
 *       but are not modelled.
 *   <li>The classifier uses only the fundamental frequency and the feature confidence. Future
 *       implementations may incorporate harmonic structure, SNR and temporal features.
 *   <li>Confidence values are heuristic scores and have not been calibrated against a validated
 *       dataset. Do not interpret them as probabilities without empirical calibration.
 * </ul>
 *
 * <h3>Expected accuracy</h3>
 *
 * Without a validated labelled dataset, accuracy cannot be stated quantitatively. Evaluate this
 * classifier using {@link WingbeatDataset} before operational use.
 */
public final class RuleBasedWingbeatClassifier implements WingbeatClassifier {

  /** Lowest frequency considered mosquito-like (Hz). */
  static final double MOSQUITO_BAND_LOW_HZ = 300.0;

  /** Highest frequency considered mosquito-like (Hz). */
  static final double MOSQUITO_BAND_HIGH_HZ = 800.0;

  /** Upper bound of the blood-fed-female sub-range (Hz). */
  static final double BLOOD_FED_FEMALE_HIGH_HZ = 430.0;

  /** Maximum confidence for the experimental lowest-frequency female-like label. */
  static final double MAX_EXPERIMENTAL_BLOOD_FED_CONFIDENCE = 0.35;

  /** Transition frequency between female-likely and male-likely (Hz). */
  static final double FEMALE_MALE_BOUNDARY_HZ = 550.0;

  /**
   * Minimum band score below which the boundary region returns {@link WingbeatLabel#MOSQUITO_LIKE}
   * instead of {@link WingbeatLabel#MALE_LIKELY}.
   */
  private static final double OVERLAP_THRESHOLD = 0.15;

  @Override
  public ClassificationResult classify(WingbeatFeatureVector features) {
    Objects.requireNonNull(features, "features");
    double f = features.fundamentalFrequencyHz();
    if (f < MOSQUITO_BAND_LOW_HZ || f > MOSQUITO_BAND_HIGH_HZ) {
      return new ClassificationResult(WingbeatLabel.UNKNOWN, outOfRangeConfidence(f), features);
    }
    return subClassify(features, f);
  }

  private static ClassificationResult subClassify(WingbeatFeatureVector features, double f) {
    double bandScore = bandScore(f, MOSQUITO_BAND_LOW_HZ, MOSQUITO_BAND_HIGH_HZ);
    double baseConfidence = Math.min(1.0, bandScore * features.featureConfidence());

    if (f < BLOOD_FED_FEMALE_HIGH_HZ) {
      double subScore = bandScore(f, MOSQUITO_BAND_LOW_HZ, BLOOD_FED_FEMALE_HIGH_HZ);
      return new ClassificationResult(
          WingbeatLabel.POSSIBLY_BLOOD_FED_FEMALE,
          Math.min(MAX_EXPERIMENTAL_BLOOD_FED_CONFIDENCE, Math.min(1.0, baseConfidence * subScore)),
          features);
    }
    if (f < FEMALE_MALE_BOUNDARY_HZ) {
      double subScore = bandScore(f, BLOOD_FED_FEMALE_HIGH_HZ, FEMALE_MALE_BOUNDARY_HZ);
      return new ClassificationResult(
          WingbeatLabel.FEMALE_LIKELY, Math.min(1.0, baseConfidence * subScore), features);
    }
    double maleScore = bandScore(f, FEMALE_MALE_BOUNDARY_HZ, MOSQUITO_BAND_HIGH_HZ);
    if (maleScore < OVERLAP_THRESHOLD) {
      return new ClassificationResult(
          WingbeatLabel.MOSQUITO_LIKE, Math.min(1.0, baseConfidence * maleScore), features);
    }
    return new ClassificationResult(
        WingbeatLabel.MALE_LIKELY,
        Math.min(1.0, baseConfidence * (maleScore - OVERLAP_THRESHOLD)),
        features);
  }

  /**
   * Compute a band-position score in {@code [0,1]}: {@code 0} at either edge, {@code 1} at the
   * midpoint.
   */
  private static double bandScore(double value, double low, double high) {
    if (low >= high) {
      return 1.0;
    }
    double mid = (low + high) / 2.0;
    double halfWidth = (high - low) / 2.0;
    return 1.0 - Math.abs(value - mid) / halfWidth;
  }

  /**
   * Confidence for an out-of-range frequency; approaches {@code 1} as the frequency moves further
   * from the mosquito band, and {@code 0} at the band boundaries.
   */
  private static double outOfRangeConfidence(double f) {
    double distanceLow = Math.max(0.0, MOSQUITO_BAND_LOW_HZ - f);
    double distanceHigh = Math.max(0.0, f - MOSQUITO_BAND_HIGH_HZ);
    double distance = distanceLow + distanceHigh;
    double scale = (MOSQUITO_BAND_HIGH_HZ - MOSQUITO_BAND_LOW_HZ) / 2.0;
    return Math.min(1.0, distance / scale);
  }
}