diff --git a/.gitignore b/.gitignore index 3642169..39b66bc 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ replay_pid* .project .settings/ nbproject/ + +# OS-specific files +.DS_Store diff --git a/README.md b/README.md index 7e8aa4c..7b75e8f 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,9 @@ cd SimulationModeling # 2. Compile and run tests ant test -# 3. Try the small example simulation +# 3. Try the small example simulations java -cp build/classes simulation.examples.CountdownSimulation +java -cp build/classes simulation.examples.BirthdayCollisionSimulation 23 # 4. Run the full quality pipeline ant quality @@ -122,15 +123,21 @@ public class QueueingSimulation extends Simulation { ``` See `src/main/java/simulation/examples/CountdownSimulation.java` for a -complete runnable example. +complete runnable example of scheduling events. + +See `src/main/java/simulation/examples/BirthdayCollisionSimulation.java` for a +Monte Carlo example that estimates the probability of at least two people +sharing a birthday in a group of size `k`. It runs the estimate for 100, 1,000, +and 10,000 trials. ## Suggested First Student Tasks 1. Run `ant test` and confirm all tests pass. 2. Run `simulation.examples.CountdownSimulation`. -3. Change the countdown length and rerun the example. -4. Create a new simulation package for your assigned model. -5. Add tests for any behavior you add or change. +3. Run `simulation.examples.BirthdayCollisionSimulation` with a few group sizes. +4. Change an example parameter and rerun the example. +5. Create a new simulation package for your assigned model. +6. Add tests for any behavior you add or change. ## Repository Hygiene diff --git a/build.xml b/build.xml index 0397736..6582bd4 100644 --- a/build.xml +++ b/build.xml @@ -268,12 +268,12 @@ classname="edu.umd.cs.findbugs.anttask.FindBugsTask" classpathref="spotbugs.classpath"/> diff --git a/src/main/java/simulation/examples/BirthdayCollisionSimulation.java b/src/main/java/simulation/examples/BirthdayCollisionSimulation.java new file mode 100644 index 0000000..c86c16d --- /dev/null +++ b/src/main/java/simulation/examples/BirthdayCollisionSimulation.java @@ -0,0 +1,207 @@ +package simulation.examples; + +import java.util.Random; +import simulation.Event; +import simulation.Simulation; + +/** + * Estimates the birthday-collision probability with repeated random trials. + * + *

A birthday collision occurs when at least two people in a group share the + * same birthday. This example assumes 365 equally likely birthdays, independent + * birthdays, and no leap years. + * + *

Run it after compiling with: + *

{@code
+ * java -cp build/classes simulation.examples.BirthdayCollisionSimulation 23
+ * }
+ */ +public final class BirthdayCollisionSimulation extends Simulation { + + /** Number of possible birthdays when leap years are ignored. */ + public static final int DAYS_IN_YEAR = 365; + + private static final int DEFAULT_GROUP_SIZE = 23; + private static final int[] DEFAULT_TRIAL_COUNTS = {100, 1_000, 10_000}; + + private final int groupSize; + private final int trialCount; + private final Random random; + + private int completedTrials; + private int collisionCount; + + /** + * Creates a simulation with a new random number generator. + * + * @param groupSize number of people in each trial group + * @param trialCount number of independent trials to run + */ + public BirthdayCollisionSimulation(int groupSize, int trialCount) { + this(groupSize, trialCount, java.util.concurrent.ThreadLocalRandom.current().nextLong()); + } + + /** + * Creates a simulation with a specific random number generator seed. + * + * @param groupSize number of people in each trial group + * @param trialCount number of independent trials to run + * @param seed specific seed value for random source of random birthdays + * @throws IllegalArgumentException if {@code groupSize} or {@code trialCount} + * is less than one + * @throws NullPointerException if {@code random} is {@code null} + */ + public BirthdayCollisionSimulation(int groupSize, int trialCount, long seed) { + if (groupSize < 1) { + throw new IllegalArgumentException("Group size must be at least one: " + groupSize); + } + if (trialCount < 1) { + throw new IllegalArgumentException("Trial count must be at least one: " + trialCount); + } + + this.groupSize = groupSize; + this.trialCount = trialCount; + this.random = new Random(seed); + } + + /** + * Runs estimates for 100, 1,000, and 10,000 trials. + * + *

The optional first argument sets the group size. If no argument is + * provided, the classic group size of 23 is used. + * + * @param args optional group size + */ + public static void main(String[] args) { + int groupSize = parseGroupSize(args); + + for (int trials : DEFAULT_TRIAL_COUNTS) { + BirthdayCollisionSimulation simulation = + new BirthdayCollisionSimulation(groupSize, trials); + simulation.run(); + System.out.printf( + "k=%d, trials=%d, collisions=%d, estimate=%.4f%n", + groupSize, + trials, + simulation.getCollisionCount(), + simulation.getEstimatedProbability()); + } + } + + /** + * Returns whether one generated group contains a shared birthday. + * + *

Each person's birthday is generated independently as an integer from + * {@code 0} through {@code 364}. + * + * @param groupSize number of birthdays to generate + * @param random the source of random birthdays + * @return {@code true} when any birthday appears more than once + * @throws IllegalArgumentException if {@code groupSize} is less than one + * @throws NullPointerException if {@code random} is {@code null} + */ + public static boolean hasSharedBirthday(int groupSize, Random random) { + if (groupSize < 1) { + throw new IllegalArgumentException("Group size must be at least one: " + groupSize); + } + + boolean[] observedBirthdays = new boolean[DAYS_IN_YEAR]; + for (int person = 0; person < groupSize; person++) { + int birthday = random.nextInt(DAYS_IN_YEAR); + if (observedBirthdays[birthday]) { + return true; + } + observedBirthdays[birthday] = true; + } + return false; + } + + /** + * Returns the number of people in each trial group. + * + * @return group size + */ + public int getGroupSize() { + return groupSize; + } + + /** + * Returns the requested number of trials. + * + * @return trial count + */ + public int getTrialCount() { + return trialCount; + } + + /** + * Returns the number of trials that have run. + * + * @return completed trial count + */ + public int getCompletedTrials() { + return completedTrials; + } + + /** + * Returns the number of trials in which a collision occurred. + * + * @return collision count + */ + public int getCollisionCount() { + return collisionCount; + } + + /** + * Returns the estimated collision probability. + * + * @return collisions divided by completed trials, or {@code 0.0} before + * any trials have completed + */ + public double getEstimatedProbability() { + if (completedTrials == 0) { + return 0.0; + } + return (double) collisionCount / completedTrials; + } + + @Override + protected void initialize() { + scheduleEvent(new TrialEvent(0.0)); + } + + private void runTrial() { + if (hasSharedBirthday(groupSize, this.random)) { + collisionCount++; + } + completedTrials++; + + if (completedTrials >= trialCount) { + stop(); + } else { + scheduleEvent(new TrialEvent(getClock() + 1.0)); + } + } + + private static int parseGroupSize(String... args) { + if (args.length == 0) { + return DEFAULT_GROUP_SIZE; + } + if (args.length > 1) { + throw new IllegalArgumentException("Usage: BirthdayCollisionSimulation [groupSize]"); + } + return Integer.parseInt(args[0]); + } + + private class TrialEvent extends Event { + + TrialEvent(double time) { + super(time); + } + + @Override + public void execute(Simulation sim) { + runTrial(); + } + } +} diff --git a/test/java/simulation/examples/BirthdayCollisionSimulationTest.java b/test/java/simulation/examples/BirthdayCollisionSimulationTest.java new file mode 100644 index 0000000..2a45e7d --- /dev/null +++ b/test/java/simulation/examples/BirthdayCollisionSimulationTest.java @@ -0,0 +1,131 @@ +package simulation.examples; + +import java.util.Random; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link BirthdayCollisionSimulation}. + */ +@DisplayName("BirthdayCollisionSimulation") +class BirthdayCollisionSimulationTest { + + private final Random testRandom = new Random(); + + private static final double THEORETICAL_PROBABILITY_FOR_23_PEOPLE = 0.5073; + private static final double TEN_THOUSAND_TRIAL_TOLERANCE = 0.03; + + @Test + @DisplayName("constructor rejects invalid group size") + void constructorRejectsInvalidGroupSize() { + assertThrows(IllegalArgumentException.class, + () -> new BirthdayCollisionSimulation(0, 100, 1L)); + } + + @Test + @DisplayName("constructor rejects invalid trial count") + void constructorRejectsInvalidTrialCount() { + assertThrows(IllegalArgumentException.class, + () -> new BirthdayCollisionSimulation(23, 0, 1L)); + } + + @Test + @DisplayName("hasSharedBirthday rejects invalid group size") + void hasSharedBirthdayRejectsInvalidGroupSize() { + assertThrows(IllegalArgumentException.class, + () -> BirthdayCollisionSimulation.hasSharedBirthday(0, testRandom)); + } + + @Test + @DisplayName("one-person group cannot have a collision") + void onePersonGroupCannotHaveCollision() { + assertFalse(BirthdayCollisionSimulation.hasSharedBirthday(1, testRandom)); + } + + @Test + @DisplayName("group larger than possible birthdays must have a collision") + void groupLargerThanPossibleBirthdaysMustHaveCollision() { + assertTrue(BirthdayCollisionSimulation.hasSharedBirthday( + BirthdayCollisionSimulation.DAYS_IN_YEAR + 1, + testRandom)); + } + + @Test + @DisplayName("run completes the requested number of trials") + void runCompletesRequestedNumberOfTrials() { + BirthdayCollisionSimulation simulation = + new BirthdayCollisionSimulation(23, 100, 2026L); + + simulation.run(); + + assertEquals(23, simulation.getGroupSize()); + assertEquals(100, simulation.getTrialCount()); + assertEquals(100, simulation.getCompletedTrials()); + } + + @Test + @DisplayName("estimate is collisions divided by completed trials") + void estimateIsCollisionsDividedByCompletedTrials() { + BirthdayCollisionSimulation simulation = + new BirthdayCollisionSimulation(23, 100, 2026L); + + simulation.run(); + + assertEquals( + (double) simulation.getCollisionCount() / simulation.getCompletedTrials(), + simulation.getEstimatedProbability(), + 1e-12); + } + + @Test + @DisplayName("estimate is zero before running trials") + void estimateIsZeroBeforeRunningTrials() { + BirthdayCollisionSimulation simulation = + new BirthdayCollisionSimulation(23, 100, 2026L); + + assertEquals(0.0, simulation.getEstimatedProbability()); + } + + @Test + @DisplayName("100 trial estimate is a valid probability") + void oneHundredTrialEstimateIsValidProbability() { + BirthdayCollisionSimulation simulation = + new BirthdayCollisionSimulation(23, 100, 2026L); + + simulation.run(); + + assertTrue(simulation.getEstimatedProbability() >= 0.0); + assertTrue(simulation.getEstimatedProbability() <= 1.0); + } + + @Test + @DisplayName("1000 trial estimate is a valid probability") + void oneThousandTrialEstimateIsValidProbability() { + BirthdayCollisionSimulation simulation = + new BirthdayCollisionSimulation(23, 1_000, 2026L); + + simulation.run(); + + assertTrue(simulation.getEstimatedProbability() >= 0.0); + assertTrue(simulation.getEstimatedProbability() <= 1.0); + } + + @Test + @DisplayName("10000 trial estimate is close to theoretical value") + void tenThousandTrialEstimateIsCloseToTheoreticalValue() { + BirthdayCollisionSimulation simulation = + new BirthdayCollisionSimulation(23, 10_000, 2026L); + + simulation.run(); + System.out.println(simulation.getEstimatedProbability()); + + assertEquals( + THEORETICAL_PROBABILITY_FOR_23_PEOPLE, + simulation.getEstimatedProbability(), + TEN_THOUSAND_TRIAL_TOLERANCE); + } +}