LocalizationExperiment.java

package org.hammer.audio.acquisition;

import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Reproducible experiment identity shared by simulation, replay and live localization.
 *
 * @param experimentId stable experiment identity
 * @param displayName human-readable experiment name
 * @param profile complete array, hardware and calibration profile
 * @param inputMode selected sample origin
 * @param sourceReference deterministic source, recording or live-session reference
 * @param createdAt experiment creation time
 * @param stage current lifecycle stage
 * @param metadata ordered additional experiment metadata
 */
public record LocalizationExperiment(
    String experimentId,
    String displayName,
    MicrophoneArrayProfile profile,
    LocalizationInputMode inputMode,
    String sourceReference,
    Instant createdAt,
    LocalizationExperimentStage stage,
    Map<String, String> metadata) {

  // Validate and defensively copy one experiment.
  public LocalizationExperiment {
    requireText(experimentId, "experimentId");
    requireText(displayName, "displayName");
    Objects.requireNonNull(profile, "profile");
    Objects.requireNonNull(inputMode, "inputMode");
    requireText(sourceReference, "sourceReference");
    Objects.requireNonNull(createdAt, "createdAt");
    Objects.requireNonNull(stage, "stage");
    if (!profile.supports(inputMode)) {
      throw new IllegalArgumentException("profile does not support experiment input mode");
    }
    Map<String, String> requiredMetadata = Objects.requireNonNull(metadata, "metadata");
    Map<String, String> validated = new ConcurrentHashMap<>();
    for (Map.Entry<String, String> entry : requiredMetadata.entrySet()) {
      requireText(entry.getKey(), "metadata key");
      Objects.requireNonNull(entry.getValue(), "metadata value");
      validated.put(entry.getKey(), entry.getValue());
    }
    metadata = Collections.unmodifiableMap(new TreeMap<>(validated));
  }

  /** Create a newly defined experiment. */
  public static LocalizationExperiment defined(
      String experimentId,
      String displayName,
      MicrophoneArrayProfile profile,
      LocalizationInputMode inputMode,
      String sourceReference,
      Instant createdAt) {
    return new LocalizationExperiment(
        experimentId,
        displayName,
        profile,
        inputMode,
        sourceReference,
        createdAt,
        LocalizationExperimentStage.DEFINED,
        Map.of());
  }

  /** Advance exactly one lifecycle stage. */
  public LocalizationExperiment advance() {
    LocalizationExperimentStage[] stages = LocalizationExperimentStage.values();
    if (stage.ordinal() + 1 >= stages.length) {
      return this;
    }
    return withStage(stages[stage.ordinal() + 1]);
  }

  /** Advance to the next expected lifecycle stage. */
  public LocalizationExperiment advanceTo(LocalizationExperimentStage nextStage) {
    Objects.requireNonNull(nextStage, "nextStage");
    if (nextStage.ordinal() != stage.ordinal() + 1) {
      throw new IllegalArgumentException(
          "experiment stage must advance exactly once from " + stage + " to " + nextStage);
    }
    return withStage(nextStage);
  }

  /** Return a copy with one deterministic metadata entry. */
  public LocalizationExperiment withMetadata(String key, String value) {
    requireText(key, "key");
    Objects.requireNonNull(value, "value");
    Map<String, String> changed = new ConcurrentHashMap<>(metadata);
    changed.put(key, value);
    return new LocalizationExperiment(
        experimentId, displayName, profile, inputMode, sourceReference, createdAt, stage, changed);
  }

  private LocalizationExperiment withStage(LocalizationExperimentStage nextStage) {
    return new LocalizationExperiment(
        experimentId,
        displayName,
        profile,
        inputMode,
        sourceReference,
        createdAt,
        nextStage,
        metadata);
  }

  private static void requireText(String value, String name) {
    if (value == null || value.isBlank()) {
      throw new IllegalArgumentException(name + " must not be blank");
    }
  }
}