From 12d8d224724828b8ea6829420521dedb1d42bb7b Mon Sep 17 00:00:00 2001 From: Jody Date: Sat, 2 May 2026 13:29:23 -0600 Subject: [PATCH 1/3] Added birthday collision simulation example --- .gitignore | 3 + README.md | 17 +- .../examples/BirthdayCollisionSimulation.java | 216 ++++++++++++++++++ .../BirthdayCollisionSimulationTest.java | 142 ++++++++++++ 4 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 src/main/java/simulation/examples/BirthdayCollisionSimulation.java create mode 100644 test/java/simulation/examples/BirthdayCollisionSimulationTest.java 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/src/main/java/simulation/examples/BirthdayCollisionSimulation.java b/src/main/java/simulation/examples/BirthdayCollisionSimulation.java new file mode 100644 index 0000000..86abd24 --- /dev/null +++ b/src/main/java/simulation/examples/BirthdayCollisionSimulation.java @@ -0,0 +1,216 @@ +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 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, new Random()); + } + + /** + * Creates a simulation with an injected random number generator. + * + *

Providing a seeded {@link Random} makes results reproducible for tests + * or demonstrations. + * + * @param groupSize number of people in each trial group + * @param trialCount number of independent trials to run + * @param 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, Random random) { + 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); + } + if (random == null) { + throw new NullPointerException("Random generator must not be null"); + } + + this.groupSize = groupSize; + this.trialCount = trialCount; + this.random = random; + } + + /** + * 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 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); + } + if (random == null) { + throw new NullPointerException("Random generator must not be null"); + } + + 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, 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..db2418d --- /dev/null +++ b/test/java/simulation/examples/BirthdayCollisionSimulationTest.java @@ -0,0 +1,142 @@ +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 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, new Random(1L))); + } + + @Test + @DisplayName("constructor rejects invalid trial count") + void constructorRejectsInvalidTrialCount() { + assertThrows(IllegalArgumentException.class, + () -> new BirthdayCollisionSimulation(23, 0, new Random(1L))); + } + + @Test + @DisplayName("constructor rejects null random generator") + void constructorRejectsNullRandomGenerator() { + assertThrows(NullPointerException.class, + () -> new BirthdayCollisionSimulation(23, 100, null)); + } + + @Test + @DisplayName("hasSharedBirthday rejects invalid group size") + void hasSharedBirthdayRejectsInvalidGroupSize() { + assertThrows(IllegalArgumentException.class, + () -> BirthdayCollisionSimulation.hasSharedBirthday(0, new Random(1L))); + } + + @Test + @DisplayName("hasSharedBirthday rejects null random generator") + void hasSharedBirthdayRejectsNullRandomGenerator() { + assertThrows(NullPointerException.class, + () -> BirthdayCollisionSimulation.hasSharedBirthday(23, null)); + } + + @Test + @DisplayName("one-person group cannot have a collision") + void onePersonGroupCannotHaveCollision() { + assertFalse(BirthdayCollisionSimulation.hasSharedBirthday(1, new Random(1L))); + } + + @Test + @DisplayName("group larger than possible birthdays must have a collision") + void groupLargerThanPossibleBirthdaysMustHaveCollision() { + assertTrue(BirthdayCollisionSimulation.hasSharedBirthday( + BirthdayCollisionSimulation.DAYS_IN_YEAR + 1, + new Random(1L))); + } + + @Test + @DisplayName("run completes the requested number of trials") + void runCompletesRequestedNumberOfTrials() { + BirthdayCollisionSimulation simulation = + new BirthdayCollisionSimulation(23, 100, new Random(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, new Random(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, new Random(2026L)); + + assertEquals(0.0, simulation.getEstimatedProbability()); + } + + @Test + @DisplayName("100 trial estimate is a valid probability") + void oneHundredTrialEstimateIsValidProbability() { + BirthdayCollisionSimulation simulation = + new BirthdayCollisionSimulation(23, 100, new Random(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, new Random(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, new Random(2026L)); + + simulation.run(); + + assertEquals( + THEORETICAL_PROBABILITY_FOR_23_PEOPLE, + simulation.getEstimatedProbability(), + TEN_THOUSAND_TRIAL_TOLERANCE); + } +} From 6f55ab09aca554a83eb1efa4d8c4f53d56317886 Mon Sep 17 00:00:00 2001 From: Jody Date: Sat, 2 May 2026 15:48:14 -0600 Subject: [PATCH 2/3] Updated build.xml to ease up on low-level SpotBugs warnings --- build.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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"/> From 87483d748de88fd0b06baf278053cc1c45aa7084 Mon Sep 17 00:00:00 2001 From: Jody Date: Sat, 2 May 2026 15:49:12 -0600 Subject: [PATCH 3/3] Cleaned up Birthday Collision simulation and associated tests --- .../examples/BirthdayCollisionSimulation.java | 25 ++++-------- .../BirthdayCollisionSimulationTest.java | 39 +++++++------------ 2 files changed, 22 insertions(+), 42 deletions(-) diff --git a/src/main/java/simulation/examples/BirthdayCollisionSimulation.java b/src/main/java/simulation/examples/BirthdayCollisionSimulation.java index 86abd24..c86c16d 100644 --- a/src/main/java/simulation/examples/BirthdayCollisionSimulation.java +++ b/src/main/java/simulation/examples/BirthdayCollisionSimulation.java @@ -16,7 +16,7 @@ * java -cp build/classes simulation.examples.BirthdayCollisionSimulation 23 * } */ -public class BirthdayCollisionSimulation extends Simulation { +public final class BirthdayCollisionSimulation extends Simulation { /** Number of possible birthdays when leap years are ignored. */ public static final int DAYS_IN_YEAR = 365; @@ -38,36 +38,30 @@ public class BirthdayCollisionSimulation extends Simulation { * @param trialCount number of independent trials to run */ public BirthdayCollisionSimulation(int groupSize, int trialCount) { - this(groupSize, trialCount, new Random()); + this(groupSize, trialCount, java.util.concurrent.ThreadLocalRandom.current().nextLong()); } /** - * Creates a simulation with an injected random number generator. - * - *

Providing a seeded {@link Random} makes results reproducible for tests - * or demonstrations. + * 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 random source of random birthdays + * @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, Random random) { + 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); } - if (random == null) { - throw new NullPointerException("Random generator must not be null"); - } this.groupSize = groupSize; this.trialCount = trialCount; - this.random = random; + this.random = new Random(seed); } /** @@ -101,7 +95,7 @@ public static void main(String[] args) { * {@code 0} through {@code 364}. * * @param groupSize number of birthdays to generate - * @param random source of random birthdays + * @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} @@ -110,9 +104,6 @@ public static boolean hasSharedBirthday(int groupSize, Random random) { if (groupSize < 1) { throw new IllegalArgumentException("Group size must be at least one: " + groupSize); } - if (random == null) { - throw new NullPointerException("Random generator must not be null"); - } boolean[] observedBirthdays = new boolean[DAYS_IN_YEAR]; for (int person = 0; person < groupSize; person++) { @@ -180,7 +171,7 @@ protected void initialize() { } private void runTrial() { - if (hasSharedBirthday(groupSize, random)) { + if (hasSharedBirthday(groupSize, this.random)) { collisionCount++; } completedTrials++; diff --git a/test/java/simulation/examples/BirthdayCollisionSimulationTest.java b/test/java/simulation/examples/BirthdayCollisionSimulationTest.java index db2418d..2a45e7d 100644 --- a/test/java/simulation/examples/BirthdayCollisionSimulationTest.java +++ b/test/java/simulation/examples/BirthdayCollisionSimulationTest.java @@ -14,6 +14,8 @@ @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; @@ -21,41 +23,27 @@ class BirthdayCollisionSimulationTest { @DisplayName("constructor rejects invalid group size") void constructorRejectsInvalidGroupSize() { assertThrows(IllegalArgumentException.class, - () -> new BirthdayCollisionSimulation(0, 100, new Random(1L))); + () -> new BirthdayCollisionSimulation(0, 100, 1L)); } @Test @DisplayName("constructor rejects invalid trial count") void constructorRejectsInvalidTrialCount() { assertThrows(IllegalArgumentException.class, - () -> new BirthdayCollisionSimulation(23, 0, new Random(1L))); - } - - @Test - @DisplayName("constructor rejects null random generator") - void constructorRejectsNullRandomGenerator() { - assertThrows(NullPointerException.class, - () -> new BirthdayCollisionSimulation(23, 100, null)); + () -> new BirthdayCollisionSimulation(23, 0, 1L)); } @Test @DisplayName("hasSharedBirthday rejects invalid group size") void hasSharedBirthdayRejectsInvalidGroupSize() { assertThrows(IllegalArgumentException.class, - () -> BirthdayCollisionSimulation.hasSharedBirthday(0, new Random(1L))); - } - - @Test - @DisplayName("hasSharedBirthday rejects null random generator") - void hasSharedBirthdayRejectsNullRandomGenerator() { - assertThrows(NullPointerException.class, - () -> BirthdayCollisionSimulation.hasSharedBirthday(23, null)); + () -> BirthdayCollisionSimulation.hasSharedBirthday(0, testRandom)); } @Test @DisplayName("one-person group cannot have a collision") void onePersonGroupCannotHaveCollision() { - assertFalse(BirthdayCollisionSimulation.hasSharedBirthday(1, new Random(1L))); + assertFalse(BirthdayCollisionSimulation.hasSharedBirthday(1, testRandom)); } @Test @@ -63,14 +51,14 @@ void onePersonGroupCannotHaveCollision() { void groupLargerThanPossibleBirthdaysMustHaveCollision() { assertTrue(BirthdayCollisionSimulation.hasSharedBirthday( BirthdayCollisionSimulation.DAYS_IN_YEAR + 1, - new Random(1L))); + testRandom)); } @Test @DisplayName("run completes the requested number of trials") void runCompletesRequestedNumberOfTrials() { BirthdayCollisionSimulation simulation = - new BirthdayCollisionSimulation(23, 100, new Random(2026L)); + new BirthdayCollisionSimulation(23, 100, 2026L); simulation.run(); @@ -83,7 +71,7 @@ void runCompletesRequestedNumberOfTrials() { @DisplayName("estimate is collisions divided by completed trials") void estimateIsCollisionsDividedByCompletedTrials() { BirthdayCollisionSimulation simulation = - new BirthdayCollisionSimulation(23, 100, new Random(2026L)); + new BirthdayCollisionSimulation(23, 100, 2026L); simulation.run(); @@ -97,7 +85,7 @@ void estimateIsCollisionsDividedByCompletedTrials() { @DisplayName("estimate is zero before running trials") void estimateIsZeroBeforeRunningTrials() { BirthdayCollisionSimulation simulation = - new BirthdayCollisionSimulation(23, 100, new Random(2026L)); + new BirthdayCollisionSimulation(23, 100, 2026L); assertEquals(0.0, simulation.getEstimatedProbability()); } @@ -106,7 +94,7 @@ void estimateIsZeroBeforeRunningTrials() { @DisplayName("100 trial estimate is a valid probability") void oneHundredTrialEstimateIsValidProbability() { BirthdayCollisionSimulation simulation = - new BirthdayCollisionSimulation(23, 100, new Random(2026L)); + new BirthdayCollisionSimulation(23, 100, 2026L); simulation.run(); @@ -118,7 +106,7 @@ void oneHundredTrialEstimateIsValidProbability() { @DisplayName("1000 trial estimate is a valid probability") void oneThousandTrialEstimateIsValidProbability() { BirthdayCollisionSimulation simulation = - new BirthdayCollisionSimulation(23, 1_000, new Random(2026L)); + new BirthdayCollisionSimulation(23, 1_000, 2026L); simulation.run(); @@ -130,9 +118,10 @@ void oneThousandTrialEstimateIsValidProbability() { @DisplayName("10000 trial estimate is close to theoretical value") void tenThousandTrialEstimateIsCloseToTheoreticalValue() { BirthdayCollisionSimulation simulation = - new BirthdayCollisionSimulation(23, 10_000, new Random(2026L)); + new BirthdayCollisionSimulation(23, 10_000, 2026L); simulation.run(); + System.out.println(simulation.getEstimatedProbability()); assertEquals( THEORETICAL_PROBABILITY_FOR_23_PEOPLE,