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 extends FlexMap> getHolesByNineTee(long nineteeId);
+
+ List extends FlexMap> 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 extends FlexMap> 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 extends FlexMap> 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 extends FlexMap> 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 extends FlexMap> 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 extends FlexMap> 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 extends FlexMap> 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 extends FlexMap> 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 extends FlexMap> 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 extends FlexMap> 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 extends FlexMap> 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 extends FlexMap> holes = this.courseHoleService.getHolesByEighteenTee(ntrating.getLong("nineteeID"));
+ return this.computeRatingIndex(ntrating, holes);
+ }
+
+ protected Byte computeRatingIndex(FlexMap netrating, List extends FlexMap> 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 extends FlexMap> 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.teedate "
+ + " AND (P.healthSetback IS NULL OR R.teedate>P.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.date "
+ + " AND (P.healthSetback IS NULL OR ER.date>P.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.date "
+ + " AND (P.healthSetback IS NULL OR ER.date>P.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));
+ }
+
+}