From 6fb300e75ec85e35369d2fee4b0ef1379a60c0a0 Mon Sep 17 00:00:00 2001 From: saltyeg <31107399+saltyeg@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:20:34 -0700 Subject: [PATCH] Give an OpMode run a battery when the map has no voltage sensor Robot code that compensates for the battery reads hardwareMap.voltageSensor.iterator().next(). A real robot's map always answers (its hubs' voltage sensors); an empty FakeHardwareMap threw NoSuchElementException at INIT. The runner now registers a 12 V FakeVoltageSensor named "Control Hub" when the map has none, and leaves a map with its own sensor alone. --- SimLoop/docs/packages/opmode.md | 4 ++ .../simloop/opmode/OpModeRunner.java | 22 +++++++ .../simloop/opmode/DefaultBatteryTest.java | 64 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 SimLoop/src/test/java/org/horizon36596/simloop/opmode/DefaultBatteryTest.java diff --git a/SimLoop/docs/packages/opmode.md b/SimLoop/docs/packages/opmode.md index 36941a5..d14b3c1 100644 --- a/SimLoop/docs/packages/opmode.md +++ b/SimLoop/docs/packages/opmode.md @@ -81,6 +81,10 @@ A `LinearOpMode`'s `runOpMode()` runs on its own thread in lockstep with the sim recording telemetry. What it cannot swap is listed in `result.warnings()`. - **Threads the OpMode starts run in real time beside the sim.** They are listed in `result.warnings()`. `.failOnOpModeThreads(true)` fails the run instead. +- **The map always has a battery.** A map with no voltage sensor gets a 12 V `FakeVoltageSensor` named + `"Control Hub"` when the run starts, as a real robot's map holds its hubs' sensors, so + `hardwareMap.voltageSensor.iterator().next()` works. Register your own `FakeVoltageSensor` to set another + voltage; the runner then adds none. - **A runner runs once.** Build a new runner and a new OpMode to run again, as the SDK builds a fresh OpMode for every INIT. diff --git a/SimLoop/src/main/java/org/horizon36596/simloop/opmode/OpModeRunner.java b/SimLoop/src/main/java/org/horizon36596/simloop/opmode/OpModeRunner.java index 389e10f..7909de9 100644 --- a/SimLoop/src/main/java/org/horizon36596/simloop/opmode/OpModeRunner.java +++ b/SimLoop/src/main/java/org/horizon36596/simloop/opmode/OpModeRunner.java @@ -7,6 +7,7 @@ import org.firstinspires.ftc.robotcore.internal.opmode.OpModeServices; import org.horizon36596.simloop.config.SimRobotConfig; import org.horizon36596.simloop.fakehardware.FakeHardwareMap; +import org.horizon36596.simloop.fakehardware.FakeVoltageSensor; import org.horizon36596.simloop.sim.FakeTimer; import org.horizon36596.simloop.sim.ScenarioRunner; import org.horizon36596.simloop.simclock.SimClockLoader; @@ -122,6 +123,12 @@ public final class OpModeRunner { /** The default tick, in seconds: 50 Hz, about what a real FTC control loop manages. */ public static final double DEFAULT_TICK_SECONDS = 0.02; + /** + * The name the runner gives the battery it adds to a map that has no voltage sensor: a Control Hub's own + * voltage sensor is configured under the hub's name, which is {@code "Control Hub"} unless the team renamed it. + */ + public static final String DEFAULT_VOLTAGE_SENSOR_NAME = "Control Hub"; + /** * The default for {@link #stuckTimeoutMillis(int)}: how long, in milliseconds of real time, an OpMode * may run within one tick before the run is failed as stuck. @@ -497,6 +504,20 @@ private static int requireNotNegative(int ticks, String name) { return ticks; } + /** + * A real robot's map always holds its hubs' voltage sensors, and robot code that compensates for the battery + * reads one as {@code hardwareMap.voltageSensor.iterator().next()}, which throws + * {@code NoSuchElementException} on a map with none. So a map with no voltage sensor gets a + * {@link FakeVoltageSensor} at its nominal 12 V under {@link #DEFAULT_VOLTAGE_SENSOR_NAME} before the OpMode + * sees it. It stays in the map after the run. A map that has a voltage sensor of its own is left alone, so a + * test that wants another voltage registers one. + */ + private void addBatteryIfMissing() { + if (hardwareMap.voltageSensor.size() == 0) { + hardwareMap.register(DEFAULT_VOLTAGE_SENSOR_NAME, new FakeVoltageSensor()); + } + } + /** One run's state: which tick it is on, the stepper driving the OpMode, and what the run noticed. */ private final class Run { final int totalTicks = 1 + initLoopTicks + activeTicks + 1; @@ -522,6 +543,7 @@ private final class Run { ? new LinearOpModeStepper((LinearOpMode) opMode, context, tickSeconds, stuckTimeoutMillis, threads.group()) : new IterativeOpModeStepper(opMode, context, stuckTimeoutMillis, threads.group()); + addBatteryIfMissing(); rewiring = context.inject(hardwareMap, telemetry, services()); simClockBridge = simClock != null ? new SimClockBridge(opModeName, stepper) : null; if (simClock != null) { diff --git a/SimLoop/src/test/java/org/horizon36596/simloop/opmode/DefaultBatteryTest.java b/SimLoop/src/test/java/org/horizon36596/simloop/opmode/DefaultBatteryTest.java new file mode 100644 index 0000000..6b0ee5b --- /dev/null +++ b/SimLoop/src/test/java/org/horizon36596/simloop/opmode/DefaultBatteryTest.java @@ -0,0 +1,64 @@ +package org.horizon36596.simloop.opmode; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import com.qualcomm.robotcore.eventloop.opmode.OpMode; +import com.qualcomm.robotcore.hardware.VoltageSensor; + +import org.horizon36596.simloop.fakehardware.FakeHardwareMap; +import org.horizon36596.simloop.fakehardware.FakeVoltageSensor; +import org.junit.jupiter.api.Test; + +/** + * Pins the battery the runner adds: robot code that compensates for the battery reads + * {@code hardwareMap.voltageSensor.iterator().next()}, which a real robot's map always answers (its hubs' voltage + * sensors) and an empty fake map used to answer with {@code NoSuchElementException} at INIT. + * + *
Implementation tier: the 12 V and the name are the runner's documented defaults. + */ +class DefaultBatteryTest { + + /** Reads the battery in init() the way voltage-compensated robot code does. */ + static class ReadsTheBattery extends OpMode { + VoltageSensor battery; + double volts = Double.NaN; + + @Override + public void init() { + battery = hardwareMap.voltageSensor.iterator().next(); + volts = battery.getVoltage(); + } + + @Override + public void loop() { + } + } + + @Test + void aMapWithNoVoltageSensorGetsATwelveVoltBatteryNamedForTheControlHub() { + FakeHardwareMap hardwareMap = new FakeHardwareMap(); + ReadsTheBattery opMode = new ReadsTheBattery(); + + new OpModeRunner(opMode, hardwareMap).initLoopTicks(1).activeTicks(1).run(); + + assertEquals(12.0, opMode.volts, 0.0, "volts"); + assertEquals(1, hardwareMap.voltageSensor.size(), "one battery, the runner's"); + assertSame(opMode.battery, hardwareMap.voltageSensor.get(OpModeRunner.DEFAULT_VOLTAGE_SENSOR_NAME), + "under the Control Hub's name"); + } + + @Test + void aMapWithItsOwnVoltageSensorIsLeftAlone() { + FakeHardwareMap hardwareMap = new FakeHardwareMap(); + FakeVoltageSensor low = new FakeVoltageSensor(11.2); // volts: a test of a tired battery + hardwareMap.register("Expansion Hub 2", low); + ReadsTheBattery opMode = new ReadsTheBattery(); + + new OpModeRunner(opMode, hardwareMap).initLoopTicks(1).activeTicks(1).run(); + + assertSame(low, opMode.battery, "the test's sensor, not one the runner made"); + assertEquals(11.2, opMode.volts, 0.0, "volts"); + assertEquals(1, hardwareMap.voltageSensor.size(), "the runner added none"); + } +}