diff --git a/pom.xml b/pom.xml index 0d780c4..aba9aff 100644 --- a/pom.xml +++ b/pom.xml @@ -35,9 +35,9 @@ - junit - junit - 4.12 + org.junit.jupiter + junit-jupiter-api + 5.9.2 test diff --git a/src/main/java/com/poststats/golf/service/CourseHoleService.java b/src/main/java/com/poststats/golf/service/CourseHoleService.java new file mode 100644 index 0000000..c90ce1e --- /dev/null +++ b/src/main/java/com/poststats/golf/service/CourseHoleService.java @@ -0,0 +1,13 @@ +package com.poststats.golf.service; + +import java.util.List; + +import com.brianlong.util.FlexMap; + +public interface CourseHoleService { + + List getHolesByNineTee(long nineteeId); + + List getHolesByEighteenTee(long eighteenteeId); + +} diff --git a/src/main/java/com/poststats/golf/service/CourseRatingService.java b/src/main/java/com/poststats/golf/service/CourseRatingService.java new file mode 100644 index 0000000..3b91f37 --- /dev/null +++ b/src/main/java/com/poststats/golf/service/CourseRatingService.java @@ -0,0 +1,11 @@ +package com.poststats.golf.service; + +import com.brianlong.util.FlexMap; + +public interface CourseRatingService { + + FlexMap getNineTeeRating(long ntratingId); + + FlexMap getEighteenTeeRating(long etratingId); + +} diff --git a/src/main/java/com/poststats/golf/service/HandicapIndexService.java b/src/main/java/com/poststats/golf/service/HandicapIndexService.java new file mode 100644 index 0000000..c95cbba --- /dev/null +++ b/src/main/java/com/poststats/golf/service/HandicapIndexService.java @@ -0,0 +1,63 @@ +package com.poststats.golf.service; + +import java.time.LocalDate; + +public interface HandicapIndexService { + + /** + * This method computes a golfer's handicap index as of today, inclusive. + * + * This will consider the use of all individually played non-event and event + * rounds. Each implementation may exclude more rounds, like unsigned or unrated + * courses. + * + * @param personId A unique identifier for the golfer. + * @return A handicap for the golfer. + */ + default I computeGolferIndex(long personId) { + return this.computeGolferIndex(personId, LocalDate.now().plusDays(1L)); + } + + /** + * This method computes a golfer's handicap index as of the specified date, + * inclusive. + * + * This will consider the use of all individually played non-event and event + * rounds. Each implementation may exclude more rounds, like unsigned or unrated + * courses. + * + * @param personId A unique identifier for the golfer. + * @param beforeDay A date to exclude all rounds on or after. + * @return A handicap for the golfer. + */ + I computeGolferIndex(long personId, LocalDate beforeDay); + + /** + * This method computes a golf course eighteen/tee combination's difficulty + * rating as of today, inclusive. + * + * In some cases, there is no computation, but a simple fetch from an external + * resource. In other cases the ratings will need to be computed. In other + * cases, a rating cannot be determined. + * + * @param etratingId A unique identifier for a golf course eighteen/tee + * combination (gender) rating. + * @return A rating for the course; `null` if one cannot be determined. + */ + R computeEighteenTeeRatingIndex(long etratingId); + + /** + * This method computes a golf course nine/tee combination's difficulty rating + * as of today, inclusive. + * + * In some cases, there is no computation, but a simple fetch from an external + * resource. In other cases the ratings will need to be computed. In other + * cases, a rating cannot be determined. + * + * @param ntratingId A unique identifier for a golf course nine/tee combination + * (gender) rating. + * @return A rating for the course; `null` if one cannot be determined. + */ + R computeNineTeeRatingIndex(long ntratingId); + +} diff --git a/src/main/java/com/poststats/golf/service/PersonRoundService.java b/src/main/java/com/poststats/golf/service/PersonRoundService.java new file mode 100644 index 0000000..1a7cb1e --- /dev/null +++ b/src/main/java/com/poststats/golf/service/PersonRoundService.java @@ -0,0 +1,49 @@ +package com.poststats.golf.service; + +import java.time.LocalDate; +import java.util.List; + +import com.brianlong.util.FlexMap; + +public interface PersonRoundService { + + public enum Selection { ScoreToPar, StrokeHandicapIndex } + + public enum Filter { AttestedOnly, CourseRatedOnly } + + /** + * This method retrieves recent round meta-data about the specified golfer. + * + * The rounds are retrieved in reverse chronological order. Both Non-event and + * event rounds are included. + * + * @param personId A unique identifier for the golfer. + * @param roundCount A maximum number of rounds to return. + * @param selection Include `ScoreToPar` or `StrokeHandicapIndex` values in the + * results. + * @param filter An array of filters. + * @return A list of recent rounds played by the golfer. + */ + default List findRecent(long personId, short roundCount, Selection selection, + Filter... filters) { + return this.findBefore(personId, LocalDate.now().plusDays(1L), roundCount, selection, filters); + } + + /** + * This method retrieves recent round meta-data about the specified golfer. + * + * The rounds are retrieved in reverse chronological order. Both Non-event and + * event rounds are included. + * + * @param personId A unique identifier for the golfer. + * @param beforeDay A date to start excluding rounds on or after. + * @param roundCount A maximum number of rounds to return. + * @param selection Include `ScoreToPar` or `StrokeHandicapIndex` values in the + * results. + * @param filter An array of filters. + * @return A list of recent rounds played by the golfer. + */ + List findBefore(long personId, LocalDate beforeDay, short roundCount, Selection selection, + Filter... filters); + +} diff --git a/src/main/java/com/poststats/golf/service/compute/AbstractHandicapIndexService.java b/src/main/java/com/poststats/golf/service/compute/AbstractHandicapIndexService.java new file mode 100644 index 0000000..da944d4 --- /dev/null +++ b/src/main/java/com/poststats/golf/service/compute/AbstractHandicapIndexService.java @@ -0,0 +1,43 @@ +package com.poststats.golf.service.compute; + +import java.time.LocalDate; +import java.util.List; + +import com.brianlong.util.FlexMap; +import com.poststats.golf.service.HandicapIndexService; +import com.poststats.golf.service.PersonRoundService; +import com.poststats.golf.service.PersonRoundService.Filter; +import com.poststats.golf.service.PersonRoundService.Selection; + +import jakarta.inject.Inject; + +public abstract class AbstractHandicapIndexService implements HandicapIndexService { + + @Inject + private PersonRoundService personRoundService; + + protected short getMinimumRounds() { + return 1; + } + + protected abstract short getMaximumRounds(); + + protected abstract Selection getRoundSelection(); + + protected Filter[] getRoundFilter() { + return new Filter[0]; + } + + @Override + public I computeGolferIndex(long personId, LocalDate beforeDay) { + List rounds = this.personRoundService.findBefore(personId, beforeDay, + this.getMaximumRounds(), this.getRoundSelection(), this.getRoundFilter()); + if (rounds.size() < this.getMinimumRounds()) + throw new IllegalStateException("The person does not have enough rounds to compute a handicap index"); + + return this.compute(rounds); + } + + public abstract I compute(List rounds); + +} diff --git a/src/main/java/com/poststats/golf/service/compute/AbstractPointHandicapIndexService.java b/src/main/java/com/poststats/golf/service/compute/AbstractPointHandicapIndexService.java new file mode 100644 index 0000000..6ab5f29 --- /dev/null +++ b/src/main/java/com/poststats/golf/service/compute/AbstractPointHandicapIndexService.java @@ -0,0 +1,225 @@ +package com.poststats.golf.service.compute; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.apache.commons.lang3.tuple.Pair; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.brianlong.util.FlexMap; +import com.brianlong.util.MapUtil; +import com.poststats.golf.service.PersonRoundService.Selection; +import com.poststats.golf.service.model.PointHandicapIndex; + +public abstract class AbstractPointHandicapIndexService extends AbstractHandicapIndexService { + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + @Override + protected short getMaximumRounds() { + return 15; + } + + protected short getBestRoundsToToss(short roundCount) { + return 0; + } + + protected abstract short getWorstRoundsToToss(short roundCount); + + protected abstract short getPointTarget(); + + protected abstract float getAccelerant(); + + protected float getHomeCourseZeroRate() { + return 0.25f; + } + + protected float getHomeCourseMultiplier() { + return 4f; + } + + @Override + protected Selection getRoundSelection() { + return Selection.ScoreToPar; + } + + @Override + public PointHandicapIndex compute(List rounds) { + short target = this.getPointTarget(); + short bestRoundsToToss = this.getBestRoundsToToss((short) rounds.size()); + short worstRoundsToToss = this.getWorstRoundsToToss((short) rounds.size()); + short roundsToCount = (short) (rounds.size() - bestRoundsToToss - worstRoundsToToss); + float forgiveness = 0.5f; + + Map computedPoints = new HashMap<>(); + PointHandicapIndex bestLowPhi = null; + PointHandicapIndex bestHighPhi = null; + + Pair homeCourseAdj = this.computeHomeCourseAdjustment(rounds); + this.logger.debug("Computed home course adjustment: {}", homeCourseAdj); + + short[] scoreToParCounts = this.computeScoreToParCounts(rounds); + byte[] mostCommonScoreToPar = this.determineMostCommonStoresToPar(scoreToParCounts, (byte) 2); + this.logger.debug("Determined most common score-to-par: {}", mostCommonScoreToPar); + + PointHandicapIndex phi = this.generateIndexBy2MostCommon(mostCommonScoreToPar[0], + scoreToParCounts[(mostCommonScoreToPar[0] + 10) % 10], mostCommonScoreToPar[1], + scoreToParCounts[(mostCommonScoreToPar[1] + 10) % 10], target, this.getAccelerant()); + this.logger.debug("Base point handicap index: {}", phi); + + byte lastHighScoreToPar = 6; + byte lastLowScoreToPar = -4; + + while (true) { + List roundsPoints = new ArrayList<>(rounds.size()); + for (FlexMap round : rounds) { + int points = round.getByte("bogey5") * phi.getQuintupleBogeyPoints() + + round.getByte("bogey4") * phi.getQuadrupleBogeyPoints() + + round.getByte("bogey3") * phi.getTripleBogeyPoints() + + round.getByte("bogey2") * phi.getDoubleBogeyPoints() + + round.getByte("bogey") * phi.getBogeyPoints() + round.getByte("par") * phi.getParPoints() + + round.getByte("birdie") * phi.getBirdiePoints() + + round.getByte("eagle") * phi.getEaglePoints() + + round.getByte("alby") * phi.getAlbatrossPoints() + round.getByte("pointAdj"); + if (round.getInteger("courseID").equals(homeCourseAdj.getKey())) { + // always negative, so we are making the course seem easier for the home course + // player + points += homeCourseAdj.getValue().intValue(); + } + + roundsPoints.add(points); + } + + Collections.sort(roundsPoints); + float points = 0f; + for (Integer roundPoints : roundsPoints.subList(worstRoundsToToss, roundsPoints.size() - bestRoundsToToss)) + points += roundPoints.floatValue(); + points /= roundsToCount; + computedPoints.put(phi.getId(), points); + this.logger.debug("Computed {} points with index: {}", points, phi); + + if (Math.abs(points - target) < forgiveness) { + System.out.println(points); + return phi; + } + forgiveness += 0.02f; + + if (points > target) { + this.logger.debug("{} points are higher than the target {}; trying something lower", points, target); + if (lastHighScoreToPar < -2) + lastHighScoreToPar = 6; + lastHighScoreToPar--; + phi = phi.decrement(lastHighScoreToPar); + } else { + this.logger.debug("{} points are lower than the target {}; trying something higher", points, target); + if (lastLowScoreToPar > 4) + lastLowScoreToPar = -4; + lastLowScoreToPar++; + phi = phi.increment(lastLowScoreToPar); + } + + // make sure we are not guaranteed to be outside the bounds of the best low/high + // if the best low/high with nothing possible in between, pick the one that is + // closest + } + } + + private PointHandicapIndex generateIndexBy2MostCommon(byte mostCommonScoreToParIndex, + short mostCommonScoreToParCount, byte nextMostCommonScoreToParIndex, short nextMostCommonScoreToParCount, + short pointTarget, float accelerant) { + boolean roundDown = nextMostCommonScoreToParIndex < mostCommonScoreToParCount; + float targetPerHole = pointTarget / 18f; + int[] pointsForScoreToPar = new int[10]; + int targetScoreToParIndex = (mostCommonScoreToParIndex + 10) % 10; + double factor = Math.pow(Math.E, accelerant); + + // floor the target, so we target low + byte lastPointsForScoreToPar = roundDown ? (byte) targetPerHole : (byte) Math.ceil(targetPerHole); + pointsForScoreToPar[targetScoreToParIndex] = lastPointsForScoreToPar; + + // go forward: higher scores; lower points; eventually 0 points + for (byte scoreToPar = (byte) (mostCommonScoreToParIndex + 1); scoreToPar < 6; scoreToPar++) { + int scoreToParIndex = (scoreToPar + 10) % 10; + lastPointsForScoreToPar = (byte) Math.max(0, + Math.min(lastPointsForScoreToPar - 1, Math.ceil(lastPointsForScoreToPar / factor))); + pointsForScoreToPar[scoreToParIndex] = lastPointsForScoreToPar; + } + + lastPointsForScoreToPar = (byte) pointsForScoreToPar[targetScoreToParIndex]; + + // go backwards; lower scores; higher points + for (byte scoreToPar = (byte) (mostCommonScoreToParIndex - 1); scoreToPar > -4; scoreToPar--) { + int scoreToParIndex = (scoreToPar + 10) % 10; + lastPointsForScoreToPar = (byte) Math.max(lastPointsForScoreToPar + 1, + Math.floor(lastPointsForScoreToPar * factor)); + pointsForScoreToPar[scoreToParIndex] = lastPointsForScoreToPar; + } + + return new PointHandicapIndex(pointsForScoreToPar); + } + + protected Pair computeHomeCourseAdjustment(List rounds) { + Pair maxHomeCourseAdj = Pair.of(null, 0f); + float zeroRate = this.getHomeCourseMultiplier() * this.getHomeCourseZeroRate(); + + Map courseIdCounts = MapUtil.countKeys(rounds, "courseID", Integer.class); + for (Entry courseIdCount : courseIdCounts.entrySet()) { + float homeCourseAdj = courseIdCount.getValue() * -this.getHomeCourseMultiplier() / rounds.size() + zeroRate; + if (homeCourseAdj < maxHomeCourseAdj.getRight()) + maxHomeCourseAdj = Pair.of(courseIdCount.getKey(), homeCourseAdj); + if (homeCourseAdj <= -1f) + // over 50%; we are done + break; + } + + return maxHomeCourseAdj; + } + + protected short[] computeScoreToParCounts(List rounds) { + short[] scoreToParTotalHoles = new short[10]; + + for (FlexMap round : rounds) { + scoreToParTotalHoles[5] += round.getByte("bogey5"); + scoreToParTotalHoles[4] += round.getByte("bogey4"); + scoreToParTotalHoles[3] += round.getByte("bogey3"); + scoreToParTotalHoles[2] += round.getByte("bogey2"); + scoreToParTotalHoles[1] += round.getByte("bogey"); + scoreToParTotalHoles[0] += round.getByte("par"); + scoreToParTotalHoles[9] += round.getByte("birdie"); + scoreToParTotalHoles[8] += round.getByte("eagle"); + scoreToParTotalHoles[7] += round.getByte("alby"); + } + + return scoreToParTotalHoles; + } + + protected byte[] determineMostCommonStoresToPar(short[] scoreToParCounts, byte xNumberOfMostCommonIndex) { + byte[] maxScoreToPar = new byte[xNumberOfMostCommonIndex]; + for (byte i = 0; i < xNumberOfMostCommonIndex; i++) + maxScoreToPar[i] = 6; // unused and always 0 + + for (byte scoreToPar = 0; scoreToPar < 10; scoreToPar++) { + for (int i = 0; i < xNumberOfMostCommonIndex; i++) { + if (scoreToParCounts[maxScoreToPar[i]] < scoreToParCounts[scoreToPar]) { + for (int i2 = xNumberOfMostCommonIndex - 1; i2 > 0; i2--) { + maxScoreToPar[i2] = maxScoreToPar[i2 - 1]; + break; + } + + maxScoreToPar[i] = scoreToPar; + break; + } + } + } + + for (int i = 0; i < maxScoreToPar.length; i++) + maxScoreToPar[i] = maxScoreToPar[i] < 0 ? (byte) (maxScoreToPar[i] - 10) : maxScoreToPar[i]; + return maxScoreToPar; + } + +} diff --git a/src/main/java/com/poststats/golf/service/compute/AbstractStrokeHandicapIndexService.java b/src/main/java/com/poststats/golf/service/compute/AbstractStrokeHandicapIndexService.java new file mode 100644 index 0000000..2249024 --- /dev/null +++ b/src/main/java/com/poststats/golf/service/compute/AbstractStrokeHandicapIndexService.java @@ -0,0 +1,117 @@ +package com.poststats.golf.service.compute; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import com.brianlong.util.FlexMap; +import com.poststats.golf.service.PersonRoundService.Selection; +import com.poststats.golf.service.model.StrokeCourseRating; + +public abstract class AbstractStrokeHandicapIndexService + extends AbstractHandicapIndexService { + + private final Comparator roundComparator; + + public AbstractStrokeHandicapIndexService() { + this.roundComparator = new Comparator() { + @Override + public int compare(FlexMap round1, FlexMap round2) { + Float sh1 = round1.getFloat(getStrokeHandicapColumn()); + Float sh2 = round2.getFloat(getStrokeHandicapColumn()); + return sh1.compareTo(sh2); + } + }; + } + + protected abstract String getStrokeHandicapColumn(); + + @Override + protected Selection getRoundSelection() { + return Selection.StrokeHandicapIndex; + } + + protected short getRoundsToCount(short rounds) { + switch (rounds) { + case 0: + throw new IllegalArgumentException(); + case 1: + case 2: + case 3: + case 4: + case 5: + return 1; + case 6: + case 7: + case 8: + return 2; + case 9: + case 10: + case 11: + return 3; + case 12: + case 13: + case 14: + return 4; + case 15: + case 16: + return 5; + case 17: + case 18: + return 6; + case 19: + return 7; + case 20: + return 8; + default: + throw new IllegalArgumentException(); + } + } + + @Override + public Float compute(List rounds) { + Collections.sort(rounds, this.roundComparator); + short roundsToCount = this.getRoundsToCount((short) rounds.size()); + return this.compute(rounds.subList(0, roundsToCount), (short) rounds.size()); + } + + public Float compute(List rounds, short roundsConsidered) { + float sh = 0f; + for (FlexMap round : rounds) + sh += round.getFloat(this.getStrokeHandicapColumn()); + sh /= rounds.size(); + + float adjustment = this.getHandicapAdjustment(roundsConsidered); + return sh + adjustment; + } + + @Override + public StrokeCourseRating computeEighteenTeeRatingIndex(long etratingId) { + // TODO call WHS service for the information + return null; + } + + @Override + public StrokeCourseRating computeNineTeeRatingIndex(long ntratingId) { + // TODO call WHS service for the information + return null; + } + + protected float getHandicapAdjustment(short rounds) { + switch (rounds) { + case 0: + throw new IllegalArgumentException(); + case 1: + return -3f; + case 2: + case 3: + return -2f; + case 4: + case 6: + return -1f; + default: + return 0f; + } + } + +} diff --git a/src/main/java/com/poststats/golf/service/compute/LegacyPostStatsPointHandicapIndexService.java b/src/main/java/com/poststats/golf/service/compute/LegacyPostStatsPointHandicapIndexService.java new file mode 100644 index 0000000..b4b65a3 --- /dev/null +++ b/src/main/java/com/poststats/golf/service/compute/LegacyPostStatsPointHandicapIndexService.java @@ -0,0 +1,122 @@ +package com.poststats.golf.service.compute; + +import com.brianlong.util.FlexMap; +import com.poststats.golf.service.CourseRatingService; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class LegacyPostStatsPointHandicapIndexService extends AbstractPointHandicapIndexService { + + @Inject + private CourseRatingService courseRatingService; + + @Override + protected float getAccelerant() { + return 0.6f; + } + + @Override + protected short getPointTarget() { + return 60; + } + + @Override + protected short getWorstRoundsToToss(short roundCount) { + switch (roundCount) { + case 0: + case 1: + case 2: + case 3: + return 0; + case 4: + case 5: + case 6: + case 7: + return 1; + case 8: + case 9: + case 10: + case 11: + return 2; + case 12: + case 13: + case 14: + return 3; + case 15: + return 4; + default: + throw new IllegalArgumentException(); + } + } + + @Override + protected short getBestRoundsToToss(short roundCount) { + return (short) (roundCount < 13 ? 0 : 1); + } + + @Override + public Byte computeEighteenTeeRatingIndex(long etratingId) { + FlexMap etrating = this.courseRatingService.getEighteenTeeRating(etratingId); + return this.computeEighteenTeeRatingIndex(etrating); + } + + protected Byte computeEighteenTeeRatingIndex(FlexMap etrating) { + Short slopeRating = etrating.getShort("slopeRating"); + Float courseRating = etrating.getFloat("courseRating"); + char gender = etrating.getString("gender").charAt(0); + short yards = etrating.getShort("yards"); + byte par = etrating.getByte("par"); + return this.computeRatingIndex(slopeRating, courseRating, gender, yards, par); + } + + @Override + public Byte computeNineTeeRatingIndex(long ntratingId) { + FlexMap ntrating = this.courseRatingService.getNineTeeRating(ntratingId); + return this.computeNineTeeRatingIndex(ntrating); + } + + protected Byte computeNineTeeRatingIndex(FlexMap ntrating) { + Short slopeRating = ntrating.getShort("slopeRating"); + Float courseRating = ntrating.getFloat("courseRating"); + char gender = ntrating.getString("gender").charAt(0); + short yards = ntrating.getShort("yards"); + byte par = ntrating.getByte("par"); + return this.computeRatingIndex(slopeRating, courseRating, gender, yards, par); + } + + protected Byte computeRatingIndex(Short slopeRating, Float courseRating, char gender, short yards, byte par) { + // A par X hole assumes (X-2) non-putts and 2 putts to complete par + // So an 18 hole course assumes 36 putts and (X-36) non-putts to complete par + int nonPuttPar = par - 36; + // we normalize those (X-2) full strokes, so par 60 courses get a normalization + // ratio of 36/24 or 1.5 + // that means we need to 1.5x any yards/par/ratings to make it look like a + // standard par 72 course + double normalizedRatioNonPuttPar = 36.0 / nonPuttPar; + // we normalize the yards to look like a standard par 72 course + double normalizedYards = yards * normalizedRatioNonPuttPar; + + // we are linearly applying the yards, where: + // 6000 yd par 72 has 0 points + // 6350 yd par 72 has 6 points + // 6500 yd par 70 has 9 points + // 7000 yd par 71 has 18 points + // 3000 yd par 54 has 0 points + // 2000 yd par 54 has -34 points + int genderYards = gender == 'M' ? 6000 : 5000; + double adjYards = normalizedYards - genderYards; + byte yardBonus = (byte) (adjYards * 3.0 / 175.0); + + if (slopeRating == null) + return (byte) (yardBonus * 2); + + // a 130 slope course has 10 points + // a 155 slope (max) course has 21 points + // a 55 slope (min) course has -21 points + byte slopeBonus = (byte) ((slopeRating - 105) * 3 / 7); + return (byte) (yardBonus + slopeBonus); + } + +} diff --git a/src/main/java/com/poststats/golf/service/compute/PostStatsPointHandicapIndexService.java b/src/main/java/com/poststats/golf/service/compute/PostStatsPointHandicapIndexService.java new file mode 100644 index 0000000..5e2a985 --- /dev/null +++ b/src/main/java/com/poststats/golf/service/compute/PostStatsPointHandicapIndexService.java @@ -0,0 +1,136 @@ +package com.poststats.golf.service.compute; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.brianlong.util.FlexMap; +import com.poststats.golf.service.CourseHoleService; +import com.poststats.golf.service.CourseRatingService; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class PostStatsPointHandicapIndexService extends AbstractPointHandicapIndexService { + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + @Inject + private CourseRatingService courseRatingService; + + @Inject + private CourseHoleService courseHoleService; + + @Override + protected float getAccelerant() { + return 0.5f; + } + + @Override + protected short getPointTarget() { + return 100; + } + + @Override + protected short getWorstRoundsToToss(short roundCount) { + switch (roundCount) { + case 0: + case 1: + case 2: + return 0; + case 3: + case 4: + case 5: + case 6: + return 1; + case 7: + case 8: + case 9: + case 10: + return 2; + case 11: + case 12: + case 13: + case 14: + return 3; + case 15: + return 4; + default: + throw new IllegalArgumentException(); + } + } + + @Override + protected short getBestRoundsToToss(short roundCount) { + return (short) (roundCount < 10 ? 0 : 1); + } + + @Override + public Byte computeEighteenTeeRatingIndex(long etratingId) { + FlexMap etrating = this.courseRatingService.getEighteenTeeRating(etratingId); + List holes = this.courseHoleService.getHolesByEighteenTee(etrating.getLong("eighteenteeID")); + return this.computeRatingIndex(etrating, holes); + } + + @Override + public Byte computeNineTeeRatingIndex(long ntratingId) { + FlexMap ntrating = this.courseRatingService.getNineTeeRating(ntratingId); + List holes = this.courseHoleService.getHolesByEighteenTee(ntrating.getLong("nineteeID")); + return this.computeRatingIndex(ntrating, holes); + } + + protected Byte computeRatingIndex(FlexMap netrating, List holes) { + // M normalized to 150 yd (150 + 3 * 8) par 3s + // F normalized to 125 yd (125 + 3 * 8) par 3s + char gender = netrating.getString("gender").charAt(0); + float divisorBase = gender == 'M' ? 165f : 140f; + + // capture running total for points + float points = 0; + + for (FlexMap hole : holes) { + short yards = hole.getShort("yards"); + byte par = hole.getByte("par"); + + // par assumes 2 putts per hole + int nonPuttPar = par - 2; + float nonPuttYardsPerStroke = 1f * yards / nonPuttPar; + + // make longer par 4s/5s correlate with shorter par 3s, as it is harder to + // "score" (birdies/eagles) on par 3s than par 5s + float exponentDivisor = divisorBase - (par * 5); + + // 80 yd par 3 will be -3.89 pts + // 100 yd par 3 will be -3.16 pts + // 125 yd par 3 (272/4 or 446/5) will be -2.1 pts + // 150 yd par 3 (326/4 or 536/5) will be -0.85 pts + // 200 yd par 3 (435/4 or 714/5) will be +2.38 pts + // 225 yd par 3 (489/4 or 804/5) will be +4.45 pts + points += Math.pow(Math.E, nonPuttYardsPerStroke / exponentDivisor) * 3f - 9f; + } + + if (netrating.isNotEmpty("etratingID")) { + this.logger.debug("computed course rating: et #{} => {}", netrating.get("etratingID"), points); + } else { + this.logger.debug("computed course rating: nt #{} => {}", netrating.get("ntratingID"), points); + } + + Float courseRating = netrating.getFloat("courseRating"); + if (courseRating == null) + return (byte) points; + + byte par = netrating.getByte("par"); + float difficulty = courseRating - par; + + // 69 rating on par 72 gets -9 pts + // 72 rating on par 72 gets +0 pts + // 73 rating on par 72 gets +3 pts + // 75 rating on par 72 gets +9 pts + double ratingAdj = 3 * difficulty; + + return (byte) (points + ratingAdj); + } + +} diff --git a/src/main/java/com/poststats/golf/service/compute/PostStatsStrokeHandicapIndexService.java b/src/main/java/com/poststats/golf/service/compute/PostStatsStrokeHandicapIndexService.java new file mode 100644 index 0000000..2527dd7 --- /dev/null +++ b/src/main/java/com/poststats/golf/service/compute/PostStatsStrokeHandicapIndexService.java @@ -0,0 +1,18 @@ +package com.poststats.golf.service.compute; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class PostStatsStrokeHandicapIndexService extends AbstractStrokeHandicapIndexService { + + @Override + protected short getMaximumRounds() { + return 20; + } + + @Override + protected String getStrokeHandicapColumn() { + return "strokeHandicapIndex"; + } + +} diff --git a/src/main/java/com/poststats/golf/service/compute/WhsStrokeHandicapIndexService.java b/src/main/java/com/poststats/golf/service/compute/WhsStrokeHandicapIndexService.java new file mode 100644 index 0000000..757d75c --- /dev/null +++ b/src/main/java/com/poststats/golf/service/compute/WhsStrokeHandicapIndexService.java @@ -0,0 +1,32 @@ +package com.poststats.golf.service.compute; + +import com.poststats.golf.service.PersonRoundService.Filter; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class WhsStrokeHandicapIndexService extends AbstractStrokeHandicapIndexService { + + @Override + protected short getMinimumRounds() { + return 3; + } + + @Override + protected short getMaximumRounds() { + return 20; + } + + @Override + protected String getStrokeHandicapColumn() { + return "whsStrokeHandicapIndex"; + } + + @Override + protected Filter[] getRoundFilter() { + return new Filter[] { + Filter.AttestedOnly, Filter.CourseRatedOnly + }; + } + +} diff --git a/src/main/java/com/poststats/golf/service/db/PersonRoundServiceDAO.java b/src/main/java/com/poststats/golf/service/db/PersonRoundServiceDAO.java new file mode 100644 index 0000000..84f6c34 --- /dev/null +++ b/src/main/java/com/poststats/golf/service/db/PersonRoundServiceDAO.java @@ -0,0 +1,320 @@ +package com.poststats.golf.service.db; + +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.brianlong.sql.FlexPreparedStatement; +import com.brianlong.util.FlexMap; +import com.poststats.golf.provider.GolfProvider; +import com.poststats.golf.service.PersonRoundService; +import com.poststats.provider.NonTransactionalProvider; +import com.poststats.provider.Statement; +import com.poststats.provider.StatementProvider; +import com.poststats.service.ServiceException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class PersonRoundServiceDAO implements PersonRoundService { + + @Override + public List findBefore(long personId, LocalDate beforeDay, short roundCount, Selection selection, + Filter... filters) { + StatementProvider stmt = this.getRoundStatementProvider(selection, filters); + + try { + FlexPreparedStatement fps = stmt.buildPreparedStatement(); + try { + fps.setIntegerU(1, personId); // for non-event rounds + fps.setDate(2, beforeDay); + fps.setIntegerU(3, personId); // for 18-hole event rounds + fps.setDate(4, beforeDay); + fps.setIntegerU(5, personId); // for 9-hole event rounds + fps.setDate(6, beforeDay); + fps.setSmallint(7, roundCount); // limit + return fps.executeQuery().getAllRows(); + } finally { + fps.close(); + } + } catch (SQLException se) { + throw new ServiceException(se); + } + } + + private StatementProvider getRoundStatementProvider(Selection selection, Filter... filters) { + Set filterSet = new HashSet<>(); + for (Filter filter : filters) + filterSet.add(filter); + + switch (selection) { + case StrokeHandicapIndex: + if (filterSet.contains(Filter.AttestedOnly)) { + if (filterSet.contains(Filter.CourseRatedOnly)) { + return this.sqlSelectSignedRatedRoundsWithStrokeHandicap; + } else { + return this.sqlSelectSignedRoundsWithStrokeHandicap; + } + } else { + if (filterSet.contains(Filter.CourseRatedOnly)) { + return this.sqlSelectRatedRoundsWithStrokeHandicap; + } else { + return this.sqlSelectRoundsWithStrokeHandicap; + } + } + case ScoreToPar: + if (filterSet.contains(Filter.AttestedOnly)) { + if (filterSet.contains(Filter.CourseRatedOnly)) { + return this.sqlSelectSignedRatedRoundsWithScoreToPar; + } else { + return this.sqlSelectSignedRoundsWithScoreToPar; + } + } else { + if (filterSet.contains(Filter.CourseRatedOnly)) { + return this.sqlSelectRatedRoundsWithScoreToPar; + } else { + return this.sqlSelectRoundsWithScoreToPar; + } + } + default: + throw new IllegalArgumentException(); + } + } + + private final static String nonEventRoundSqlSelectClause = "SELECT R.roundID, NULL proundID, NULL linkProundID, R.etratingID, R.courseID, R.teedate, R.teetime, R.strokes, "; + private final static String nonEventRoundSqlFromClause = "FROM ~g~.Round R " + + " INNER JOIN ~p~.Person P ON (R.personID=P.personID) "; + private final static String nonEventRoundSqlWhereClause = "WHERE R.personID=? AND R.complete IS TRUE AND R.teedateP.healthSetback) "; + + private final static String event18RoundSqlSelectClause = "SELECT NULL roundID, EPR.proundID, NULL linkProundID, EPR.etratingID, EPR.courseID, ER.date, ERP.teetime, EPR.strokes, "; + private final static String event18RoundSqlFromClause = "FROM ~g~.EventPersonRound EPR " + + " INNER JOIN ~g~.EventRound ER ON (EPR.eroundID=ER.eroundID) " + + " INNER JOIN ~g~.EventPerson EP ON (EPR.epersonID=EP.epersonID) " + + " INNER JOIN ~p~.Person P ON (EP.personID=P.personID) " + + " LEFT JOIN ~g~.EventRoundPairing ERP ON (EPR.pairingID=ERP.pairingID) "; + private final static String event18RoundSqlWhereClause = "WHERE EP.personID=? AND EPR.complete IS TRUE AND EPR.etratingID IS NOT NULL AND ER.dateP.healthSetback) "; + + private final static String event9RoundSqlSelectClause = "SELECT NULL roundID, EPR.proundID, EPR.linkProundID, CETR.etratingID, EPR.courseID, ER.date, ERP.teetime, " + + " (EPR.strokes+EPR2.strokes) strokes, "; + private final static String event9RoundSqlFromClause = "FROM ~g~.EventPersonRound EPR " + + " INNER JOIN ~g~.EventPersonRound EPR2 ON (EPR.linkProundID=EPR2.proundID AND EPR.proundID>EPR2.proundID) " // don't + // include + // 9-hole + // twice + + " INNER JOIN ~g~.CourseNineTeeRating CNTR1 ON (EPR.ntratingID=CNTR1.ntratingID) " + + " INNER JOIN ~g~.CourseNineTeeRating CNTR2 ON (EPR2.ntratingID=CNTR2.ntratingID) " + + " INNER JOIN ~g~.CourseEighteenTee CET ON ((CNTR1.nineteeID=CET.nineteeID1 AND CNTR2.nineteeID=CET.nineteeID2) " + + " OR (CNTR2.nineteeID=CET.nineteeID1 AND CNTR1.nineteeID=CET.nineteeID2)) " + + " INNER JOIN ~g~.CourseEighteenTeeRating CETR ON (CET.eighteenteeID=CETR.eighteenteeID " + + " AND CNTR1.gender=CETR.gender " + + " AND CETR.liveline<=ER.date AND (CETR.deadline IS NULL OR ER.date<=CETR.deadline)) " + + " INNER JOIN ~g~.EventRound ER ON (EPR.eroundID=ER.eroundID) " + + " INNER JOIN ~g~.EventPerson EP ON (EPR.epersonID=EP.epersonID) " + + " INNER JOIN ~p~.Person P ON (EP.personID=P.personID) " + + " LEFT JOIN ~g~.EventRoundPairing ERP ON (EPR.pairingID=ERP.pairingID) "; + private final static String event9RoundSqlWhereClause = "WHERE EP.personID=? AND EPR.complete IS TRUE AND ER.dateP.healthSetback) "; + + private final static String nonEventRoundSqlSelectStrokeHandicap = nonEventRoundSqlSelectClause + + " R.strokeHandicapIndex, R.whsStrokeHandicapIndex " + + nonEventRoundSqlFromClause + + nonEventRoundSqlWhereClause; + + private final static String event18RoundSqlSelectStrokeHandicap = event18RoundSqlSelectClause + + " EPR.strokeHandicapIndex, EPR.whsStrokeHandicapIndex " + + event18RoundSqlFromClause + + event18RoundSqlWhereClause; + + private final static String event9RoundSqlSelectStrokeHandicap = event9RoundSqlSelectClause + + " (EPR.strokeHandicapIndex+EPR2.strokeHandicapIndex) strokeHandicapIndex, " + + " (EPR.whsStrokeHandicapIndex+EPR2.whsStrokeHandicapIndex) whsStrokeHandicapIndex " + + event9RoundSqlFromClause + + event9RoundSqlWhereClause; + + private final static String nonEventRoundSqlSelectScoreToPar = nonEventRoundSqlSelectClause + + " SUM(CASE WHEN (RX.strokes - CNTH.par) = 5 THEN 1 ELSE 0 END) bogey5, " + + " SUM(CASE WHEN (RX.strokes - CNTH.par) = 4 THEN 1 ELSE 0 END) bogey4, " + + " SUM(CASE WHEN (RX.strokes - CNTH.par) = 3 THEN 1 ELSE 0 END) bogey3, " + + " SUM(CASE WHEN (RX.strokes - CNTH.par) = 2 THEN 1 ELSE 0 END) bogey2, " + + " SUM(CASE WHEN (RX.strokes - CNTH.par) = 1 THEN 1 ELSE 0 END) bogey, " + + " SUM(CASE WHEN RX.strokes = CNTH.par THEN 1 ELSE 0 END) par, " + + " SUM(CASE WHEN (CNTH.par - RX.strokes) = 1 THEN 1 ELSE 0 END) birdie, " + + " SUM(CASE WHEN (CNTH.par - RX.strokes) = 2 THEN 1 ELSE 0 END) eagle, " + + " SUM(CASE WHEN (CNTH.par - RX.strokes) = 3 THEN 1 ELSE 0 END) alby, " + + " CETR.pointAdj " + + nonEventRoundSqlFromClause + + " INNER JOIN ~g~.RoundScore RX ON (R.roundID=RX.roundID) " + + " INNER JOIN ~g~.CourseNineTeeHole CNTH ON (RX.holeID=CNTH.holeID) " + + " INNER JOIN ~g~.CourseEighteenTeeRating CETR ON (R.etratingID=CETR.etratingID) " + + nonEventRoundSqlWhereClause + + "GROUP BY R.roundID "; + + private final static String event18RoundSqlSelectScoreToPar = event18RoundSqlSelectClause + + " SUM(CASE WHEN (EPRS.strokes - CNTH.par) = 5 THEN 1 ELSE 0 END) bogey5, " + + " SUM(CASE WHEN (EPRS.strokes - CNTH.par) = 4 THEN 1 ELSE 0 END) bogey4, " + + " SUM(CASE WHEN (EPRS.strokes - CNTH.par) = 3 THEN 1 ELSE 0 END) bogey3, " + + " SUM(CASE WHEN (EPRS.strokes - CNTH.par) = 2 THEN 1 ELSE 0 END) bogey2, " + + " SUM(CASE WHEN (EPRS.strokes - CNTH.par) = 1 THEN 1 ELSE 0 END) bogey, " + + " SUM(CASE WHEN EPRS.strokes = CNTH.par THEN 1 ELSE 0 END) par, " + + " SUM(CASE WHEN (CNTH.par - EPRS.strokes) = 1 THEN 1 ELSE 0 END) birdie, " + + " SUM(CASE WHEN (CNTH.par - EPRS.strokes) = 2 THEN 1 ELSE 0 END) eagle, " + + " SUM(CASE WHEN (CNTH.par - EPRS.strokes) = 3 THEN 1 ELSE 0 END) alby," + + " CETR.pointAdj " + + event18RoundSqlFromClause + + " INNER JOIN ~g~.EventPersonRoundScore EPRS ON (EPR.proundID=EPRS.proundID) " + + " INNER JOIN ~g~.CourseNineTeeHole CNTH ON (EPRS.holeID=CNTH.holeID) " + + " INNER JOIN ~g~.CourseEighteenTeeRating CETR ON (EPR.etratingID=CETR.etratingID) " + + event18RoundSqlWhereClause + + "GROUP BY EPR.proundID "; + + private final static String event9RoundSqlSelectScoreToPar = event9RoundSqlSelectClause + + " SUM(CASE WHEN (EPRS.strokes - CNTH.par) = 5 THEN 1 ELSE 0 END) bogey5, " + + " SUM(CASE WHEN (EPRS.strokes - CNTH.par) = 4 THEN 1 ELSE 0 END) bogey4, " + + " SUM(CASE WHEN (EPRS.strokes - CNTH.par) = 3 THEN 1 ELSE 0 END) bogey3, " + + " SUM(CASE WHEN (EPRS.strokes - CNTH.par) = 2 THEN 1 ELSE 0 END) bogey2, " + + " SUM(CASE WHEN (EPRS.strokes - CNTH.par) = 1 THEN 1 ELSE 0 END) bogey, " + + " SUM(CASE WHEN EPRS.strokes = CNTH.par THEN 1 ELSE 0 END) par, " + + " SUM(CASE WHEN (CNTH.par - EPRS.strokes) = 1 THEN 1 ELSE 0 END) birdie, " + + " SUM(CASE WHEN (CNTH.par - EPRS.strokes) = 2 THEN 1 ELSE 0 END) eagle, " + + " SUM(CASE WHEN (CNTH.par - EPRS.strokes) = 3 THEN 1 ELSE 0 END) alby," + + " CETR.pointAdj " + + event9RoundSqlFromClause + + " INNER JOIN ~g~.EventPersonRoundScore EPRS ON (EPR.proundID=EPRS.proundID OR EPR.linkProundID=EPRS.proundID) " + + " INNER JOIN ~g~.CourseNineTeeHole CNTH ON (EPRS.holeID=CNTH.holeID) " + + event9RoundSqlWhereClause + + "GROUP BY EPR.proundID "; + + @Inject + @NonTransactionalProvider + @GolfProvider + @Statement( + sql = nonEventRoundSqlSelectStrokeHandicap + + "UNION " + + event18RoundSqlSelectStrokeHandicap + + "UNION " + + event9RoundSqlSelectStrokeHandicap + + "ORDER BY teedate DESC, teetime DESC " + + "LIMIT ?" + ) + private StatementProvider sqlSelectRoundsWithStrokeHandicap; + + @Inject + @NonTransactionalProvider + @GolfProvider + @Statement( + sql = nonEventRoundSqlSelectStrokeHandicap + + " AND R.whsStrokeHandicapIndex IS NOT NULL " + + "UNION " + + event18RoundSqlSelectStrokeHandicap + + " AND EPR.whsStrokeHandicapIndex IS NOT NULL " + + "UNION " + + event9RoundSqlSelectStrokeHandicap + + " AND EPR.whsStrokeHandicapIndex IS NOT NULL " + + "ORDER BY teedate DESC, teetime DESC " + + "LIMIT ?" + ) + private StatementProvider sqlSelectRatedRoundsWithStrokeHandicap; + + @Inject + @NonTransactionalProvider + @GolfProvider + @Statement( + sql = nonEventRoundSqlSelectStrokeHandicap + + " AND EXISTS (SELECT RS.signerID FROM ~g~.RoundSigning RS WHERE RS.roundID=R.roundID) " + + "UNION " + + event18RoundSqlSelectStrokeHandicap + + "UNION " + + event9RoundSqlSelectStrokeHandicap + + "ORDER BY teedate DESC, teetime DESC " + + "LIMIT ?" + ) + private StatementProvider sqlSelectSignedRoundsWithStrokeHandicap; + + @Inject + @NonTransactionalProvider + @GolfProvider + @Statement( + sql = nonEventRoundSqlSelectStrokeHandicap + + " AND R.whsStrokeHandicapIndex IS NOT NULL " + + " AND EXISTS (SELECT RS.signerID FROM ~g~.RoundSigning RS WHERE RS.roundID=R.roundID) " + + "UNION " + + event18RoundSqlSelectStrokeHandicap + + " AND EPR.whsStrokeHandicapIndex IS NOT NULL " + + "UNION " + + event9RoundSqlSelectStrokeHandicap + + " AND EPR.whsStrokeHandicapIndex IS NOT NULL " + + "ORDER BY teedate DESC, teetime DESC " + + "LIMIT ?" + ) + private StatementProvider sqlSelectSignedRatedRoundsWithStrokeHandicap; + + @Inject + @NonTransactionalProvider + @GolfProvider + @Statement( + sql = nonEventRoundSqlSelectScoreToPar + + "UNION " + + event18RoundSqlSelectScoreToPar + + "UNION " + + event9RoundSqlSelectScoreToPar + + "ORDER BY teedate DESC, teetime DESC " + + "LIMIT ?" + ) + private StatementProvider sqlSelectRoundsWithScoreToPar; + + @Inject + @NonTransactionalProvider + @GolfProvider + @Statement( + sql = nonEventRoundSqlSelectScoreToPar + + " AND R.whsStrokeHandicapIndex IS NOT NULL " + + "UNION " + + event18RoundSqlSelectScoreToPar + + " AND EPR.whsStrokeHandicapIndex IS NOT NULL " + + "UNION " + + event9RoundSqlSelectScoreToPar + + " AND EPR.whsStrokeHandicapIndex IS NOT NULL " + + "ORDER BY teedate DESC, teetime DESC " + + "LIMIT ?" + ) + private StatementProvider sqlSelectRatedRoundsWithScoreToPar; + + @Inject + @NonTransactionalProvider + @GolfProvider + @Statement( + sql = nonEventRoundSqlSelectScoreToPar + + " AND EXISTS (SELECT RS.signerID FROM ~g~.RoundSigning RS WHERE RS.roundID=R.roundID) " + + "UNION " + + event18RoundSqlSelectScoreToPar + + "UNION " + + event9RoundSqlSelectScoreToPar + + "ORDER BY teedate DESC, teetime DESC " + + "LIMIT ?" + ) + private StatementProvider sqlSelectSignedRoundsWithScoreToPar; + + @Inject + @NonTransactionalProvider + @GolfProvider + @Statement( + sql = nonEventRoundSqlSelectScoreToPar + + " AND R.whsStrokeHandicapIndex IS NOT NULL " + + " AND EXISTS (SELECT RS.signerID FROM ~g~.RoundSigning RS WHERE RS.roundID=R.roundID) " + + "UNION " + + event18RoundSqlSelectScoreToPar + + " AND EPR.whsStrokeHandicapIndex IS NOT NULL " + + "UNION " + + event9RoundSqlSelectScoreToPar + + " AND EPR.whsStrokeHandicapIndex IS NOT NULL " + + "ORDER BY teedate DESC, teetime DESC " + + "LIMIT ?" + ) + private StatementProvider sqlSelectSignedRatedRoundsWithScoreToPar; + +} diff --git a/src/main/java/com/poststats/golf/service/model/PointHandicapIndex.java b/src/main/java/com/poststats/golf/service/model/PointHandicapIndex.java new file mode 100644 index 0000000..bba2419 --- /dev/null +++ b/src/main/java/com/poststats/golf/service/model/PointHandicapIndex.java @@ -0,0 +1,404 @@ +package com.poststats.golf.service.model; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class PointHandicapIndex implements Comparable { + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + private final int pointIndexId; + + public PointHandicapIndex(int pointIndexId) { + this.pointIndexId = pointIndexId; + } + + public PointHandicapIndex(byte alby, byte eagle, byte birdie, byte par, byte bogey, byte bogey2, byte bogey3, + byte bogey4, byte bogey5) { + int pointIndexId = 0; + + if (bogey5 > 1) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) 5); + pointIndexId += bogey5; + + if (bogey4 > 3 || bogey4 > 0 && bogey5 >= bogey4) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) 4); + pointIndexId += bogey4; + + if (bogey3 > 3 || bogey3 > 0 && bogey4 >= bogey3) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) 3); + pointIndexId += bogey3; + + if (bogey2 > 7 || bogey2 > 0 && bogey3 >= bogey2) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) 2); + pointIndexId += bogey2; + + if (bogey > 7 || bogey > 0 && bogey2 >= bogey) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) 1); + pointIndexId += bogey; + + if (par > 15 || par > 0 && bogey >= par) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) 0); + pointIndexId += par; + + if (birdie > 15 || birdie > 0 && par >= birdie) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) -1); + pointIndexId += birdie; + + if (eagle > 31 || eagle > 0 && birdie >= eagle) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) -2); + pointIndexId += eagle; + + if (alby > 63 || alby > 0 && eagle >= alby) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) -3); + pointIndexId += alby; + + this.pointIndexId = pointIndexId; + } + + public PointHandicapIndex(int alby, int eagle, int birdie, int par, int bogey, int bogey2, int bogey3, int bogey4, + int bogey5) { + int pointIndexId = 0; + + if (bogey5 > 1) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) 5); + pointIndexId += bogey5; + + if (bogey4 > 3 || bogey4 > 0 && bogey5 >= bogey4) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) 4); + pointIndexId += bogey4; + + if (bogey3 > 3 || bogey3 > 0 && bogey4 >= bogey3) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) 3); + pointIndexId += bogey3; + + if (bogey2 > 7 || bogey2 > 0 && bogey3 >= bogey2) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) 2); + pointIndexId += bogey2; + + if (bogey > 7 || bogey > 0 && bogey2 >= bogey) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) 1); + pointIndexId += bogey; + + if (par > 15 || par > 0 && bogey >= par) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) 0); + pointIndexId += par; + + if (birdie > 15 || birdie > 0 && par >= birdie) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) -1); + pointIndexId += birdie; + + if (eagle > 31 || eagle > 0 && birdie >= eagle) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) -2); + pointIndexId += eagle; + + if (alby > 63 || alby > 0 && eagle >= alby) + throw new IllegalArgumentException(); + pointIndexId <<= this.getBits((byte) -3); + pointIndexId += alby; + + this.pointIndexId = pointIndexId; + } + + public PointHandicapIndex(int[] scoresToPar) { + this(scoresToPar[7], scoresToPar[8], scoresToPar[9], scoresToPar[0], scoresToPar[1], scoresToPar[2], + scoresToPar[3], scoresToPar[4], scoresToPar[5]); + } + + public int getId() { + return this.pointIndexId; + } + + public byte getPointsWithScoreToPar(byte scoreToPar) { + int shift = this.getShift(scoreToPar); + int bits = this.getBits(scoreToPar); + int mask = ((1 << bits) - 1) << (shift - bits); + return (byte) ((this.pointIndexId & mask) >> (shift - bits)); + } + + public byte getQuintupleBogeyPoints() { + return this.getPointsWithScoreToPar((byte) 5); + } + + public byte getQuadrupleBogeyPoints() { + return this.getPointsWithScoreToPar((byte) 4); + } + + public byte getTripleBogeyPoints() { + return this.getPointsWithScoreToPar((byte) 3); + } + + public byte getDoubleBogeyPoints() { + return this.getPointsWithScoreToPar((byte) 2); + } + + public byte getBogeyPoints() { + return this.getPointsWithScoreToPar((byte) 1); + } + + public byte getParPoints() { + return this.getPointsWithScoreToPar((byte) 0); + } + + public byte getBirdiePoints() { + return this.getPointsWithScoreToPar((byte) -1); + } + + public byte getEaglePoints() { + return this.getPointsWithScoreToPar((byte) -2); + } + + public byte getAlbatrossPoints() { + return this.getPointsWithScoreToPar((byte) -3); + } + + private int getShift(byte scoreToPar) { + if (scoreToPar < -2) { + return 6; // 0-63 + } else switch (scoreToPar) { + case -2: + return 11; // 0-31 + case -1: + return 15; // 0-15 + case 0: + return 19; // 0-15 + case 1: + return 22; // 0-7 + case 2: + return 25; // 0-7 + case 3: + return 27; // 0-3 + case 4: + return 29; // 0-3 + case 5: + return 30; // 0-1 + default: + return 31; // 0 + } + } + + private int getBits(byte scoreToPar) { + if (scoreToPar < -2) { + return 6; // 0-63 + } else switch (scoreToPar) { + case -2: + return 5; // 0-31 + case -1: + return 4; // 0-15 + case 0: + return 4; // 0-15 + case 1: + return 3; // 0-7 + case 2: + return 3; // 0-7 + case 3: + return 2; // 0-3 + case 4: + return 2; // 0-3 + case 5: + return 1; // 0-1 + default: + return 0; // 0 + } + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof PointHandicapIndex)) + return false; + return this.getId() == ((PointHandicapIndex) obj).getId(); + } + + @Override + public int hashCode() { + return this.getId(); + } + + @Override + public String toString() { + return new StringBuilder().append(this.getQuintupleBogeyPoints()).append('/') + .append(this.getQuadrupleBogeyPoints()).append('/').append(this.getTripleBogeyPoints()).append('/') + .append(this.getDoubleBogeyPoints()).append('/').append(this.getBogeyPoints()).append('/') + .append(this.getParPoints()).append('/').append(this.getBirdiePoints()).append('/') + .append(this.getEaglePoints()).append('/').append(this.getAlbatrossPoints()).toString(); + } + + @Override + public int compareTo(PointHandicapIndex o) { + float compare = this.diff(o); + if (Float.isNaN(compare)) { + return 0; + } else if (compare == 0f) { + return 0; + } else if (compare < 0f) { + return (int) Math.floor(compare); + } else { + return (int) Math.ceil(compare); + } + } + + private float diff(PointHandicapIndex o) { + if (this.getId() == o.getId()) + return 0f; + + boolean pos = false; + boolean neg = false; + + int compare = Byte.compare(this.getAlbatrossPoints(), o.getAlbatrossPoints()); + if (compare < 0) + neg = true; + else if (compare > 0) + pos = true; + + compare = Byte.compare(this.getEaglePoints(), o.getEaglePoints()); + if (compare < 0) + neg = true; + else if (compare > 0) + pos = true; + if (neg && pos) + return Float.NaN; + + compare = Byte.compare(this.getBirdiePoints(), o.getBirdiePoints()); + if (compare < 0) + neg = true; + else if (compare > 0) + pos = true; + if (neg && pos) + return Float.NaN; + + compare = Byte.compare(this.getParPoints(), o.getParPoints()); + if (compare < 0) + neg = true; + else if (compare > 0) + pos = true; + if (neg && pos) + return Float.NaN; + + compare = Byte.compare(this.getBogeyPoints(), o.getBogeyPoints()); + if (compare < 0) + neg = true; + else if (compare > 0) + pos = true; + if (neg && pos) + return Float.NaN; + + compare = Byte.compare(this.getDoubleBogeyPoints(), o.getDoubleBogeyPoints()); + if (compare < 0) + neg = true; + else if (compare > 0) + pos = true; + if (neg && pos) + return Float.NaN; + + compare = Byte.compare(this.getTripleBogeyPoints(), o.getTripleBogeyPoints()); + if (compare < 0) + neg = true; + else if (compare > 0) + pos = true; + if (neg && pos) + return Float.NaN; + + compare = Byte.compare(this.getQuadrupleBogeyPoints(), o.getQuadrupleBogeyPoints()); + if (compare < 0) + neg = true; + else if (compare > 0) + pos = true; + if (neg && pos) + return Float.NaN; + + compare = Byte.compare(this.getQuintupleBogeyPoints(), o.getQuintupleBogeyPoints()); + if (compare < 0) + neg = true; + else if (compare > 0) + pos = true; + + if (neg && pos) { + return Float.NaN; + } else if (neg) { + return -1; + } else if (pos) { + return 1; + } else { + this.logger.warn("This should never happen"); + return 0; + } + } + + public PointHandicapIndex decrement(byte scoreToPar) { + return this.minus(scoreToPar, (byte) 1); + } + + public PointHandicapIndex increment(byte scoreToPar) { + return this.plus(scoreToPar, (byte) 1); + } + + public PointHandicapIndex minus(byte scoreToPar, byte points) { + return this.plus(scoreToPar, (byte) -points); + } + + public PointHandicapIndex plus(byte scoreToPar, byte points) { + int shift = this.getShift(scoreToPar); + int bits = this.getBits(scoreToPar); + int mask = ((1 << bits) - 1) << (shift - bits); + int oldPointIndexMasked = this.pointIndexId & mask; + byte oldPoints = (byte) (oldPointIndexMasked >> (shift - bits)); + + if (points < 0) { + if (oldPoints == 0) + return this; + } else { + if (oldPoints + points >= this.getPointsWithScoreToPar((byte) (scoreToPar - 1))) + return this; + } + + if (oldPoints + points >= Math.pow(2, bits)) + throw new IllegalArgumentException(); + int pointIndexMasked = (oldPoints + points) << (shift - bits); + + return new PointHandicapIndex(this.pointIndexId - oldPointIndexMasked + pointIndexMasked); + } + + public PointHandicapIndex avg(PointHandicapIndex phi, int roundingBias) { + return new PointHandicapIndex( + this.computeAvg(this.getAlbatrossPoints(), phi.getAlbatrossPoints(), roundingBias), + this.computeAvg(this.getEaglePoints(), phi.getEaglePoints(), roundingBias), + this.computeAvg(this.getBirdiePoints(), phi.getBirdiePoints(), roundingBias), + this.computeAvg(this.getParPoints(), phi.getParPoints(), roundingBias), + this.computeAvg(this.getBogeyPoints(), phi.getBogeyPoints(), roundingBias), + this.computeAvg(this.getDoubleBogeyPoints(), phi.getDoubleBogeyPoints(), roundingBias), + this.computeAvg(this.getTripleBogeyPoints(), phi.getTripleBogeyPoints(), roundingBias), + this.computeAvg(this.getQuadrupleBogeyPoints(), phi.getQuadrupleBogeyPoints(), roundingBias), + this.computeAvg(this.getQuintupleBogeyPoints(), phi.getQuintupleBogeyPoints(), roundingBias)); + } + + private byte computeAvg(byte points1, byte points2, int roundingBias) { + if (points1 == points2) + return points1; + float avg = (points1 + points2) / 2f; + if (roundingBias == 0) { + return (byte) Math.round(avg); + } else if (roundingBias < 0) { + return (byte) Math.floor(avg); + } else { + return (byte) Math.ceil(avg); + } + } + +} diff --git a/src/main/java/com/poststats/golf/service/model/StrokeCourseRating.java b/src/main/java/com/poststats/golf/service/model/StrokeCourseRating.java new file mode 100644 index 0000000..65debbb --- /dev/null +++ b/src/main/java/com/poststats/golf/service/model/StrokeCourseRating.java @@ -0,0 +1,21 @@ +package com.poststats.golf.service.model; + +public class StrokeCourseRating { + + private final short slopeRating; + private final float courseRating; + + public StrokeCourseRating(short slopeRating, float courseRating) { + this.slopeRating = slopeRating; + this.courseRating = courseRating; + } + + public short getSlopeRating() { + return this.slopeRating; + } + + public float getCourseRating() { + return this.courseRating; + } + +} diff --git a/src/test/java/com/poststats/golf/service/compute/ComputeLegacyPointCourseRatingUnitTest.java b/src/test/java/com/poststats/golf/service/compute/ComputeLegacyPointCourseRatingUnitTest.java new file mode 100644 index 0000000..cc75d89 --- /dev/null +++ b/src/test/java/com/poststats/golf/service/compute/ComputeLegacyPointCourseRatingUnitTest.java @@ -0,0 +1,31 @@ +package com.poststats.golf.service.compute; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class ComputeLegacyPointCourseRatingUnitTest { + + private static LegacyPostStatsPointHandicapIndexService service; + + @BeforeAll + public static void stage() { + service = new LegacyPostStatsPointHandicapIndexService(); + } + + @Test + public void cypressLandingGold() { + Assertions.assertEquals(-4f, service.computeRatingIndex((short) 117, 66.8f, 'M', (short) 5473, (byte) 72), 1f); + } + + @Test + public void cypressLandingWhite() { + Assertions.assertEquals(12f, service.computeRatingIndex((short) 127, 69.7f, 'M', (short) 6124, (byte) 72), 1f); + } + + @Test + public void cypressLandingBlue() { + Assertions.assertEquals(19f, service.computeRatingIndex((short) 131, 71.1f, 'M', (short) 6442, (byte) 72), 1f); + } + +} diff --git a/src/test/java/com/poststats/golf/service/compute/ComputePointCourseRatingUnitTest.java b/src/test/java/com/poststats/golf/service/compute/ComputePointCourseRatingUnitTest.java new file mode 100644 index 0000000..d9e7764 --- /dev/null +++ b/src/test/java/com/poststats/golf/service/compute/ComputePointCourseRatingUnitTest.java @@ -0,0 +1,124 @@ +package com.poststats.golf.service.compute; + +import java.util.Arrays; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.brianlong.util.FlexMap; + +public class ComputePointCourseRatingUnitTest { + + private static PostStatsPointHandicapIndexService service; + + @BeforeAll + public static void stage() { + service = new PostStatsPointHandicapIndexService(); + } + + @Test + public void cypressLandingGold() { + Assertions.assertEquals(-25f, service.computeRatingIndex(this.mockEighteenTeeRating('M', (byte) 72, 66.8f), + Arrays.asList(this.mockHole(301, 4), this.mockHole(295, 4), this.mockHole(136, 3), + this.mockHole(297, 4), this.mockHole(460, 5), this.mockHole(349, 4), this.mockHole(140, 3), + this.mockHole(492, 5), this.mockHole(323, 4), this.mockHole(314, 4), this.mockHole(118, 3), + this.mockHole(461, 5), this.mockHole(301, 4), this.mockHole(351, 4), this.mockHole(113, 3), + this.mockHole(279, 4), this.mockHole(450, 5), this.mockHole(293, 4))), + 1f); + } + + @Test + public void cypressLandingWhite() { + Assertions.assertEquals(5f, service.computeRatingIndex(this.mockEighteenTeeRating('M', (byte) 72, 69.7f), + Arrays.asList(this.mockHole(345, 4), this.mockHole(316, 4), this.mockHole(136, 3), + this.mockHole(371, 4), this.mockHole(503, 5), this.mockHole(404, 4), this.mockHole(184, 3), + this.mockHole(501, 5), this.mockHole(348, 4), this.mockHole(341, 4), this.mockHole(166, 3), + this.mockHole(532, 5), this.mockHole(318, 4), this.mockHole(385, 4), this.mockHole(129, 3), + this.mockHole(303, 4), this.mockHole(489, 5), this.mockHole(353, 4))), + 1f); + } + + @Test + public void cypressLandingBlue() { + Assertions.assertEquals(22f, service.computeRatingIndex(this.mockEighteenTeeRating('M', (byte) 72, 71.1f), + Arrays.asList(this.mockHole(373, 4), this.mockHole(316, 4), this.mockHole(166, 3), + this.mockHole(389, 4), this.mockHole(520, 5), this.mockHole(412, 4), this.mockHole(203, 3), + this.mockHole(520, 5), this.mockHole(366, 4), this.mockHole(374, 4), this.mockHole(185, 3), + this.mockHole(554, 5), this.mockHole(319, 4), this.mockHole(406, 4), this.mockHole(144, 3), + this.mockHole(318, 4), this.mockHole(489, 5), this.mockHole(388, 4))), + 1f); + } + + @Test + public void heronGlenBlue() { + Assertions.assertEquals(26f, service.computeRatingIndex(this.mockEighteenTeeRating('M', (byte) 72, 71.7f), + Arrays.asList(this.mockHole(395, 4), this.mockHole(581, 5), this.mockHole(424, 4), + this.mockHole(361, 4), this.mockHole(179, 3), this.mockHole(371, 4), this.mockHole(146, 3), + this.mockHole(512, 5), this.mockHole(378, 4), this.mockHole(545, 5), this.mockHole(308, 4), + this.mockHole(155, 3), this.mockHole(514, 5), this.mockHole(378, 4), this.mockHole(204, 3), + this.mockHole(473, 5), this.mockHole(158, 3), this.mockHole(449, 4))), + 1f); + } + + @Test + public void metamoreFieldsBlue() { + Assertions.assertEquals(17f, service.computeRatingIndex(this.mockEighteenTeeRating('M', (byte) 71, 70f), + Arrays.asList(this.mockHole(388, 4), this.mockHole(121, 3), this.mockHole(523, 5), + this.mockHole(326, 4), this.mockHole(170, 3), this.mockHole(526, 5), this.mockHole(338, 4), + this.mockHole(181, 3), this.mockHole(427, 4), this.mockHole(392, 4), this.mockHole(152, 3), + this.mockHole(485, 5), this.mockHole(344, 4), this.mockHole(164, 3), this.mockHole(385, 4), + this.mockHole(504, 5), this.mockHole(382, 4), this.mockHole(401, 4))), + 1f); + } + + @Test + public void pinonHillsBlue() { + Assertions.assertEquals(35f, service.computeRatingIndex(this.mockEighteenTeeRating('M', (byte) 72, 71.7f), + Arrays.asList(this.mockHole(387, 4), this.mockHole(398, 4), this.mockHole(412, 4), + this.mockHole(178, 3), this.mockHole(331, 4), this.mockHole(198, 3), this.mockHole(395, 4), + this.mockHole(537, 5), this.mockHole(572, 5), this.mockHole(404, 4), this.mockHole(397, 4), + this.mockHole(200, 3), this.mockHole(505, 5), this.mockHole(321, 4), this.mockHole(140, 3), + this.mockHole(405, 4), this.mockHole(520, 5), this.mockHole(446, 4))), + 1f); + } + + @Test + public void worthingtonHillsBlue() { + Assertions.assertEquals(48f, service.computeRatingIndex(this.mockEighteenTeeRating('M', (byte) 71, 72.7f), + Arrays.asList(this.mockHole(419, 4), this.mockHole(388, 4), this.mockHole(187, 3), + this.mockHole(520, 5), this.mockHole(420, 4), this.mockHole(560, 5), this.mockHole(200, 3), + this.mockHole(351, 4), this.mockHole(430, 4), this.mockHole(441, 4), this.mockHole(345, 4), + this.mockHole(192, 3), this.mockHole(365, 4), this.mockHole(398, 4), this.mockHole(172, 3), + this.mockHole(448, 4), this.mockHole(364, 4), this.mockHole(521, 5))), + 1f); + } + + @Test + public void darbyCreekBlue() { + Assertions.assertEquals(34f, service.computeRatingIndex(this.mockEighteenTeeRating('M', (byte) 72, 72.2f), + Arrays.asList(this.mockHole(350, 4), this.mockHole(374, 4), this.mockHole(169, 3), + this.mockHole(529, 5), this.mockHole(339, 4), this.mockHole(486, 5), this.mockHole(144, 3), + this.mockHole(418, 4), this.mockHole(438, 4), this.mockHole(309, 4), this.mockHole(168, 3), + this.mockHole(411, 4), this.mockHole(435, 4), this.mockHole(492, 5), this.mockHole(419, 4), + this.mockHole(188, 3), this.mockHole(540, 5), this.mockHole(452, 4))), + 1f); + } + + private FlexMap mockEighteenTeeRating(char gender, int par, float courseRating) { + FlexMap map = new FlexMap(); + map.put("etratingID", 0L); + map.put("gender", String.valueOf(gender)); + map.put("par", (byte) par); + map.put("courseRating", courseRating); + return map; + } + + private FlexMap mockHole(int yards, int par) { + FlexMap map = new FlexMap(); + map.put("yards", (short) yards); + map.put("par", (byte) par); + return map; + } + +} diff --git a/src/test/java/com/poststats/golf/service/compute/ComputePointHandicapIndexUnitTest.java b/src/test/java/com/poststats/golf/service/compute/ComputePointHandicapIndexUnitTest.java new file mode 100644 index 0000000..dc8ca50 --- /dev/null +++ b/src/test/java/com/poststats/golf/service/compute/ComputePointHandicapIndexUnitTest.java @@ -0,0 +1,59 @@ +package com.poststats.golf.service.compute; + +import java.util.Arrays; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.brianlong.util.FlexMap; +import com.poststats.golf.service.model.PointHandicapIndex; + +public class ComputePointHandicapIndexUnitTest { + + private static PostStatsPointHandicapIndexService service; + + @BeforeAll + public static void stage() { + service = new PostStatsPointHandicapIndexService(); + } + + @Test + public void brian() { + PointHandicapIndex phi = service.compute(Arrays.asList(this.mockRound(0, 22, 0, 0, 1, 5, 5, 6, 1, 0, 0), + this.mockRound(0, 22, 0, 1, 2, 5, 6, 4, 0, 0, 0), this.mockRound(0, 22, 0, 0, 1, 3, 7, 7, 0, 0, 0), + this.mockRound(0, 22, 0, 2, 2, 4, 8, 1, 1, 0, 0), this.mockRound(1, 26, 0, 0, 1, 5, 5, 6, 1, 0, 0), + this.mockRound(2, 17, 0, 1, 0, 4, 7, 6, 0, 0, 0), this.mockRound(2, 17, 0, 1, 2, 4, 5, 6, 0, 0, 0), + this.mockRound(2, 17, 0, 2, 0, 2, 7, 6, 1, 0, 0), this.mockRound(2, 17, 1, 0, 0, 3, 4, 8, 2, 0, 0), + this.mockRound(3, 35, 0, 3, 2, 2, 6, 4, 1, 0, 0), this.mockRound(3, 35, 0, 0, 1, 5, 4, 8, 0, 0, 0), + this.mockRound(3, 35, 1, 1, 3, 3, 3, 6, 1, 0, 0), this.mockRound(3, 35, 0, 2, 2, 5, 5, 4, 0, 0, 0))); + System.out.println(phi); + } + + @Test + public void ridgeway() { + PointHandicapIndex phi = service.compute(Arrays.asList(this.mockRound(0, 22, 0, 0, 0, 0, 7, 9, 2, 0, 0), + this.mockRound(0, 22, 0, 0, 0, 4, 5, 7, 2, 0, 0), this.mockRound(0, 22, 0, 0, 0, 0, 5, 11, 2, 0, 0), + this.mockRound(1, 48, 0, 0, 0, 2, 5, 9, 2, 0, 0), this.mockRound(2, 34, 0, 0, 0, 1, 3, 8, 6, 0, 0), + this.mockRound(1, 48, 0, 0, 0, 1, 4, 11, 2, 0, 0), this.mockRound(2, 34, 0, 0, 0, 1, 2, 12, 3, 0, 0), + this.mockRound(2, 34, 0, 0, 0, 2, 4, 8, 4, 0, 0), this.mockRound(3, 17, 0, 0, 0, 0, 4, 10, 4, 0, 0), + this.mockRound(3, 17, 0, 0, 0, 0, 3, 11, 4, 0, 0), this.mockRound(3, 17, 0, 0, 0, 0, 5, 10, 3, 0, 0))); + System.out.println(phi); + } + + private FlexMap mockRound(int courseId, int pointAdj, int... scoreToParCounts) { + FlexMap map = new FlexMap(); + map.put("courseID", courseId); + map.put("pointAdj", (byte) pointAdj); + map.put("bogey5", (byte) scoreToParCounts[0]); + map.put("bogey4", (byte) scoreToParCounts[1]); + map.put("bogey3", (byte) scoreToParCounts[2]); + map.put("bogey2", (byte) scoreToParCounts[3]); + map.put("bogey", (byte) scoreToParCounts[4]); + map.put("par", (byte) scoreToParCounts[5]); + map.put("birdie", (byte) scoreToParCounts[6]); + map.put("eagle", (byte) scoreToParCounts[7]); + map.put("alby", (byte) scoreToParCounts[8]); + return map; + } + +} diff --git a/src/test/java/com/poststats/golf/service/model/PointHandicapIndexUnitTest.java b/src/test/java/com/poststats/golf/service/model/PointHandicapIndexUnitTest.java new file mode 100644 index 0000000..597ec3e --- /dev/null +++ b/src/test/java/com/poststats/golf/service/model/PointHandicapIndexUnitTest.java @@ -0,0 +1,90 @@ +package com.poststats.golf.service.model; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class PointHandicapIndexUnitTest { + + @Test + public void zero() { + PointHandicapIndex phi = new PointHandicapIndex(0, 0, 0, 0, 0, 0, 0, 0, 0); + Assertions.assertEquals(0, phi.getId()); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) -10)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) -3)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 5)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 10)); + } + + @Test + public void alby1() { + PointHandicapIndex phi = new PointHandicapIndex(1, 0, 0, 0, 0, 0, 0, 0, 0); + Assertions.assertEquals(1, phi.getPointsWithScoreToPar((byte) -3)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) -2)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 5)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 10)); + } + + @Test + public void alby2eagle1() { + PointHandicapIndex phi = new PointHandicapIndex(2, 1, 0, 0, 0, 0, 0, 0, 0); + Assertions.assertEquals(2, phi.getPointsWithScoreToPar((byte) -3)); + Assertions.assertEquals(1, phi.getPointsWithScoreToPar((byte) -2)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) -1)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 5)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 10)); + } + + @Test + public void typicalScratch() { + PointHandicapIndex phi = new PointHandicapIndex(25, 14, 9, 2, 0, 0, 0, 0, 0); + Assertions.assertEquals(25, phi.getPointsWithScoreToPar((byte) -3)); + Assertions.assertEquals(14, phi.getPointsWithScoreToPar((byte) -2)); + Assertions.assertEquals(9, phi.getPointsWithScoreToPar((byte) -1)); + Assertions.assertEquals(2, phi.getPointsWithScoreToPar((byte) 0)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 1)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 5)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 10)); + } + + @Test + public void typicalBogey() { + PointHandicapIndex phi = new PointHandicapIndex(25, 16, 7, 4, 2, 1, 0, 0, 0); + Assertions.assertEquals(25, phi.getPointsWithScoreToPar((byte) -3)); + Assertions.assertEquals(16, phi.getPointsWithScoreToPar((byte) -2)); + Assertions.assertEquals(7, phi.getPointsWithScoreToPar((byte) -1)); + Assertions.assertEquals(4, phi.getPointsWithScoreToPar((byte) 0)); + Assertions.assertEquals(2, phi.getPointsWithScoreToPar((byte) 1)); + Assertions.assertEquals(1, phi.getPointsWithScoreToPar((byte) 2)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 4)); + } + + @Test + public void typicalBad() { + PointHandicapIndex phi = new PointHandicapIndex(40, 18, 10, 6, 4, 3, 1, 0, 0); + Assertions.assertEquals(40, phi.getPointsWithScoreToPar((byte) -3)); + Assertions.assertEquals(18, phi.getPointsWithScoreToPar((byte) -2)); + Assertions.assertEquals(10, phi.getPointsWithScoreToPar((byte) -1)); + Assertions.assertEquals(6, phi.getPointsWithScoreToPar((byte) 0)); + Assertions.assertEquals(4, phi.getPointsWithScoreToPar((byte) 1)); + Assertions.assertEquals(3, phi.getPointsWithScoreToPar((byte) 2)); + Assertions.assertEquals(1, phi.getPointsWithScoreToPar((byte) 3)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 4)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 5)); + Assertions.assertEquals(0, phi.getPointsWithScoreToPar((byte) 10)); + } + + @Test + public void plusBogey() { + PointHandicapIndex basephi = new PointHandicapIndex(40, 18, 10, 6, 4, 3, 1, 0, 0); + PointHandicapIndex phi = basephi.plus((byte) 1, (byte) 1); + Assertions.assertEquals(5, phi.getPointsWithScoreToPar((byte) 1)); + } + + @Test + public void plus2Birdie() { + PointHandicapIndex basephi = new PointHandicapIndex(40, 18, 10, 6, 4, 3, 1, 0, 0); + PointHandicapIndex phi = basephi.plus((byte) -1, (byte) 2); + Assertions.assertEquals(12, phi.getPointsWithScoreToPar((byte) -1)); + } + +}