diff --git a/core/src/main/java/com/skeletonarmy/marrow/phases/Phase.java b/core/src/main/java/com/skeletonarmy/marrow/phases/Phase.java new file mode 100644 index 00000000..7d2a3c8e --- /dev/null +++ b/core/src/main/java/com/skeletonarmy/marrow/phases/Phase.java @@ -0,0 +1,118 @@ +package com.skeletonarmy.marrow.phases; + +import androidx.annotation.NonNull; + +import java.util.concurrent.TimeUnit; + +/** + * Represents a named time period within a match. + *

+ * Define phases with a name, duration, and optional time unit (defaults to seconds). + *

+ * Examples: + *

+ * Phase auto = new Phase("Autonomous", 30);
+ * Phase quick = new Phase("Quick", 500, TimeUnit.MILLISECONDS);
+ * 
+ * + * @see PhaseManager + */ +public class Phase { + private final String name; + private final double duration; + private final TimeUnit unit; + + /** + * Creates a phase with a duration in seconds. + * + * @param name the phase name + * @param durationSeconds the duration in seconds + */ + public Phase(@NonNull String name, double durationSeconds) { + this(name, durationSeconds, TimeUnit.SECONDS); + } + + /** + * Creates a phase with a duration and time unit. + * + * @param name the phase name + * @param duration the duration value + * @param unit the time unit (SECONDS, MILLISECONDS, NANOSECONDS) + */ + public Phase(@NonNull String name, double duration, @NonNull TimeUnit unit) { + this.name = name; + this.duration = duration; + this.unit = unit; + } + + /** + * Gets the name of this phase. + * + * @return the phase name + */ + @NonNull + public String getName() { + return name; + } + + /** + * Gets the duration in the original time unit. + * + * @return the duration + */ + public double getDuration() { + return duration; + } + + /** + * Gets the time unit of this phase. + * + * @return the time unit + */ + @NonNull + public TimeUnit getUnit() { + return unit; + } + + /** + * Gets the duration in seconds (converted from the original time unit). + * + * @return the duration in seconds + */ + public double getDurationSeconds() { + return convertToSeconds(duration, unit); + } + + @Override + public String toString() { + return name; + } + + /** + * Converts a duration from the given time unit to seconds. + * + * @param duration the duration value + * @param unit the time unit + * @return the duration in seconds + */ + private static double convertToSeconds(double duration, TimeUnit unit) { + switch (unit) { + case NANOSECONDS: + return duration / 1_000_000_000.0; + case MICROSECONDS: + return duration / 1_000_000.0; + case MILLISECONDS: + return duration / 1_000.0; + case SECONDS: + return duration; + case MINUTES: + return duration * 60.0; + case HOURS: + return duration * 3600.0; + case DAYS: + return duration * 86400.0; + default: + return duration; + } + } +} diff --git a/core/src/main/java/com/skeletonarmy/marrow/phases/PhaseManager.java b/core/src/main/java/com/skeletonarmy/marrow/phases/PhaseManager.java new file mode 100644 index 00000000..cba9442a --- /dev/null +++ b/core/src/main/java/com/skeletonarmy/marrow/phases/PhaseManager.java @@ -0,0 +1,250 @@ +package com.skeletonarmy.marrow.phases; + +import androidx.annotation.NonNull; +import com.qualcomm.robotcore.eventloop.opmode.OpMode; +import com.qualcomm.robotcore.eventloop.opmode.OpModeManagerImpl; +import com.qualcomm.robotcore.robot.RobotState; +import com.skeletonarmy.marrow.OpModeManager; +import com.skeletonarmy.marrow.TimerEx; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Manages match phases and automatically transitions based on elapsed time. + *

+ * Create an instance with phases, then call {@link #update()} each loop iteration. Use + * {@link #getCurrentPhase()} for time-aware logic or {@link #addPhaseListener} for callbacks. + *

+ * Typical usage: + *

+ * PhaseManager manager = new PhaseManager(this, new Phase("Auto", 30), new Phase("Park", 3));
+ * while (opModeIsActive()) {
+ *     manager.update();
+ *     if (manager.isCurrentPhase("Park")) { ... }
+ * }
+ * 
+ * + * @see Phase + */ +public class PhaseManager { + private final OpMode opMode; + private final TimerEx matchTimer; + + // Configured phases in order + private final List phases; + + // Current state + private Phase currentPhase; + private Phase previousPhase; + + // Listeners + private final List phaseListeners; + + /** + * Creates a phase manager with phases in order. + *

+ * Call once before {@code waitForStart()}. + * + * @param opMode the OpMode instance + * @param phasesToRun phases to transition through in order + * @throws IllegalArgumentException if no phases are provided + */ + public PhaseManager(@NonNull OpMode opMode, @NonNull Phase... phasesToRun) { + if (phasesToRun.length == 0) { + throw new IllegalArgumentException("At least one phase must be provided"); + } + + this.opMode = opMode; + this.phases = new ArrayList<>(Arrays.asList(phasesToRun)); + this.matchTimer = new TimerEx(getTotalDuration(phasesToRun), TimeUnit.SECONDS); + this.currentPhase = phases.get(0); + this.previousPhase = null; + this.phaseListeners = new ArrayList<>(); + } + + /** + * Sums phase durations in seconds. + */ + private static double getTotalDuration(@NonNull Phase[] phases) { + double total = 0; + for (Phase phase : phases) { + total += phase.getDurationSeconds(); + } + return total; + } + + /** + * Updates the current phase based on elapsed time. Call once per loop. + */ + public void update() { + if (phases.isEmpty()) { + return; + } + + // Start the timer on first update (when match starts) + if (!matchTimer.isOn()) { + try { + OpModeManagerImpl manager = OpModeManager.getManager(); + RobotState state = manager.getRobotState(); + if (state == RobotState.RUNNING) { + matchTimer.start(); + } + } catch (Exception e) { + // OpModeManager not available, match hasn't started yet + return; + } + } + + // Calculate elapsed time from match start + double elapsedSeconds = matchTimer.getElapsed(); + + // Find which phase we're in based on elapsed time + double timeAccumulated = 0; + Phase newPhase = phases.get(0); + for (Phase phase : phases) { + double phaseEnd = timeAccumulated + phase.getDurationSeconds(); + + if (elapsedSeconds < phaseEnd) { + newPhase = phase; + break; + } + + timeAccumulated = phaseEnd; + newPhase = phase; // Stay on last phase if exceeded + } + + // Notify listeners of phase transitions + if (!newPhase.equals(currentPhase) && previousPhase != null) { + previousPhase = currentPhase; + currentPhase = newPhase; + notifyPhaseChange(); + } else if (previousPhase == null) { + previousPhase = currentPhase; + currentPhase = newPhase; + } else { + currentPhase = newPhase; + } + } + + /** + * Gets the current phase. + */ + public @NonNull Phase getCurrentPhase() { + return currentPhase != null ? currentPhase : phases.get(0); + } + + /** + * Checks if the current phase matches the given phase (by reference). + */ + public boolean isCurrentPhase(@NonNull Phase phase) { + return getCurrentPhase().equals(phase); + } + + /** + * Checks if the current phase name matches the given name. + */ + public boolean isCurrentPhase(@NonNull String phaseName) { + return getCurrentPhase().getName().equals(phaseName); + } + + /** + * Gets elapsed time in seconds since the match started (0 if not started). + */ + public double getElapsedTime() { + return matchTimer.getElapsed(); + } + + /** + * Gets remaining time in the match (in seconds). + */ + public double getTimeRemaining() { + double remaining = matchTimer.getRemaining(); + return Math.max(0, remaining); + } + + /** + * Gets remaining time in the current phase (in seconds). + */ + public double getPhaseTimeRemaining() { + if (currentPhase == null || !matchTimer.isOn()) { + return currentPhase != null ? currentPhase.getDurationSeconds() : 0; + } + + double phaseStartTime = getPhaseStartTime(); + double elapsedInPhase = getElapsedTime() - phaseStartTime; + return Math.max(0, currentPhase.getDurationSeconds() - elapsedInPhase); + } + + /** + * Calculates when the current phase started (elapsed seconds from match start). + */ + private double getPhaseStartTime() { + double start = 0; + for (Phase phase : phases) { + if (phase.equals(currentPhase)) { + break; + } + start += phase.getDurationSeconds(); + } + return start; + } + + /** + * Gets total match duration (sum of all phase durations, in seconds). + */ + public double getTotalMatchDuration() { + double total = 0; + for (Phase phase : phases) { + total += phase.getDurationSeconds(); + } + return total; + } + + /** + * Registers a listener to be notified on phase changes. + */ + public void addPhaseListener(@NonNull PhaseListener listener) { + phaseListeners.add(listener); + } + + /** + * Unregisters a phase listener. + */ + public void removePhaseListener(@NonNull PhaseListener listener) { + phaseListeners.remove(listener); + } + + /** + * Clears all phase listeners. + */ + public void clearPhaseListeners() { + phaseListeners.clear(); + } + + private void notifyPhaseChange() { + for (PhaseListener listener : phaseListeners) { + try { + listener.onPhaseEntered(currentPhase); + } catch (Exception e) { + // Prevent listener exceptions from breaking the match + opMode.telemetry.addLine("ERROR in phase listener: " + e.getMessage()); + } + } + } + + /** + * Callback for phase transitions. + *

+ * Example: {@code addPhaseListener(phase -> { if ("Endgame".equals(phase.getName())) {...} })} + */ + @FunctionalInterface + public interface PhaseListener { + /** + * Called when entering a new phase. + */ + void onPhaseEntered(@NonNull Phase newPhase); + } +} diff --git a/core/src/test/java/com/skeletonarmy/marrow/phases/PhaseTests.java b/core/src/test/java/com/skeletonarmy/marrow/phases/PhaseTests.java new file mode 100644 index 00000000..d1c6576e --- /dev/null +++ b/core/src/test/java/com/skeletonarmy/marrow/phases/PhaseTests.java @@ -0,0 +1,98 @@ +package com.skeletonarmy.marrow.phases; + +import org.junit.Before; +import org.junit.Test; + +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.*; + +public class PhaseTests { + private Phase autonomousPhase; + private Phase teleopPhase; + private Phase endgamePhase; + + @Test + public void matchPhase_creation_withNameAndDuration() { + Phase phase = new Phase("Autonomous", 30); + assertEquals("Should have correct name", "Autonomous", phase.getName()); + assertEquals("Should have correct duration", 30.0, phase.getDurationSeconds(), 0.01); + } + + @Test + public void matchPhase_toString_returnsName() { + Phase phase = new Phase("Teleop", 150); + assertEquals("toString should return name", "Teleop", phase.toString()); + } + + @Test + public void matchPhase_equality_sameObjectAreEqual() { + Phase phase = new Phase("Autonomous", 30); + assertEquals("Same object should be equal to itself", phase, phase); + } + + @Test + public void matchPhase_equality_differentObjectsAreNotEqual() { + Phase phase1 = new Phase("Autonomous", 30); + Phase phase2 = new Phase("Autonomous", 30); + assertNotEquals("Different objects should not be equal even with same name", phase1, phase2); + } + + @Test + public void matchPhase_variableDuration() { + Phase shortPhase = new Phase("Short", 5); + Phase longPhase = new Phase("Long", 120.5); + + assertEquals("Short phase should be 5 seconds", 5.0, shortPhase.getDurationSeconds(), 0.01); + assertEquals("Long phase should be 120.5 seconds", 120.5, longPhase.getDurationSeconds(), 0.01); + } + + @Test + public void matchPhase_canCreateMultiplePhases() { + assertEquals("Auto", "Autonomous", autonomousPhase.getName()); + assertEquals("Teleop", "Teleop", teleopPhase.getName()); + assertEquals("Endgame", "Endgame", endgamePhase.getName()); + } + + @Test + public void matchPhase_defaultTimeUnitIsSeconds() { + Phase phase = new Phase("Test", 30); + assertEquals("Default unit should be SECONDS", TimeUnit.SECONDS, phase.getUnit()); + } + + @Test + public void matchPhase_withMilliseconds() { + Phase phase = new Phase("Quick", 5000, TimeUnit.MILLISECONDS); + assertEquals("Should have correct duration", 5000, phase.getDuration(), 0.01); + assertEquals("Should have correct unit", TimeUnit.MILLISECONDS, phase.getUnit()); + assertEquals("Should convert to 5 seconds", 5.0, phase.getDurationSeconds(), 0.01); + } + + @Test + public void matchPhase_withNanoseconds() { + Phase phase = new Phase("Precise", 1_000_000_000, TimeUnit.NANOSECONDS); + assertEquals("Should have correct duration", 1_000_000_000, phase.getDuration(), 0.01); + assertEquals("Should have correct unit", TimeUnit.NANOSECONDS, phase.getUnit()); + assertEquals("Should convert to 1 second", 1.0, phase.getDurationSeconds(), 0.01); + } + + @Test + public void matchPhase_mixedTimeUnits() { + Phase secondsPhase = new Phase("Seconds", 10); + Phase millisPhase = new Phase("Millis", 10_000, TimeUnit.MILLISECONDS); + Phase nanosPhase = new Phase("Nanos", 10_000_000_000L, TimeUnit.NANOSECONDS); + + assertEquals("Seconds phase", 10.0, secondsPhase.getDurationSeconds(), 0.01); + assertEquals("Millis phase", 10.0, millisPhase.getDurationSeconds(), 0.01); + assertEquals("Nanos phase", 10.0, nanosPhase.getDurationSeconds(), 0.01); + } + + @Test + public void matchPhase_fractionalDurations() { + Phase fractionalSeconds = new Phase("Partial", 2.5); + Phase fractionalMillis = new Phase("PartialMs", 2500, TimeUnit.MILLISECONDS); + + assertEquals("Fractional seconds", 2.5, fractionalSeconds.getDurationSeconds(), 0.01); + assertEquals("Fractional millis", 2.5, fractionalMillis.getDurationSeconds(), 0.01); + } +}