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+ * 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); + } +}