diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..f3d4fca --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,39 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python application + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/README.md b/README.md index 01b8181..d608807 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,11 @@ Calculate ratings based on the CFC model. This program was created to calculate CFC ratings for our members. A future implementation would be in embeding this information onto a web service that will automatically calculate ratings. Another idea is to build a HHCC rating system to let our members play each other in-person. ## About + Started in Fall 2022 with Hart House Chess Club Executive Board member Victor Zheng. +Updated in December 2023. + ## How This program runs by calculating CFC ratings from the publicly accessible CFC algorithm viewable on the [CFC handbook](https://github.com/Hart-House-Chess-Club/ratings-calculator/blob/main/CFC%20-%20CFC%20Handbook%20(to%202014).pdf). diff --git a/src/ratings_calculator/main.py b/src/ratings_calculator/main.py index 04480be..666228b 100644 --- a/src/ratings_calculator/main.py +++ b/src/ratings_calculator/main.py @@ -53,167 +53,176 @@ as deemed necessary and in consultation with the CFC Executive. """ +""" +Calculates the ratings +""" -def average_rating(ratings: list) -> float: - """ - Average ratings of the list of the ratings - :param ratings: a list of ratings - :return: average rating - """ - ave_rating = 0 - for rating in ratings: - ave_rating += rating - return ave_rating / len(ratings) - - -def provisional_unrated_players(ratings: list, wins: int, losses: int, games_played: int) -> object: - """ - wins is the number of wins, - losses is the number of losses - games_played is the number of games - ratings is the list of opponent's ratings - """ - Rc = average_rating(ratings) - # Rp = Rc + 400 (W - L) / N - Rp = Rc + 400 * (wins - losses) / games_played - return Rp - - -def established_ratings(all_time_high_score: int, old_rating: int, score: float, opponent_ratings: list) -> float: - """ - Returns the established rating from the dictionary of ratings - :param all_time_high_score: the all time high of the player - :param score: the total score from the tournament - :param opponent_ratings: a list of opponent ratings - :param old_rating: the player's old rating - :return: returns the established rating of the player. - >>> expected_dict = expected_scores_init() - >>> established_ratings(1450, 4, [1237, 1511, 1214, 1441, 1579, 2133]) - 1487 - """ - # K=32 for players under 2200 and K=16 for players at or over 2200; - # Rn = Ro + 32 (S - Sx) - if old_rating >= 2199: - multiplier = 16 - else: - multiplier = 32 - - expected_scores_dict = expected_scores_init() - expected_total = expected_total_score(expected_scores_dict, old_rating, opponent_ratings) - new_rating = old_rating + multiplier * (score - expected_total) - if new_rating > all_time_high_score: - all_time_high_valid = 1 - else: - all_time_high_valid = 0 - - multiplier_e = multiplier // 32 # ratio of the multiplier to ratings under 2200. - bonus = bonuses(all_time_high_valid, multiplier_e, new_rating, old_rating, len(opponent_ratings)) - sum_of_scores = new_rating + bonus - return sum_of_scores - - -def bonuses(a: int, k_factor_e: int, r_new: float, r_old: int, n: int) -> int: - """ - Returns the total sum of all bonuses available - :param a: is = 1 if all time high, 0 otherwise - :param k_factor_e: ratio of the player's k factor to the k factor used for players under 2200 - :param r_new: post-event rating - :param r_old: pre-event rating - :param n: number of games played - :return: value of the new rating - >>> bonuses(0, 1, 1487, 1450, 6) - 1496 - """ - if n < 4: # no bonus points awarded if less than 4 games played - return 0 - - R_MAX_BONUS = 20 # constants set - R_CHANGE_BONUS = 1.75 - R_CHANGE_THRESHOLD = 13 - - bonus1 = a * R_MAX_BONUS * k_factor_e - threshold = R_CHANGE_THRESHOLD * k_factor_e * math.sqrt(n) - - if r_new > (r_old + threshold): - b = 1 - else: - b = 0 - - bonus2 = b * R_CHANGE_BONUS * (r_new - r_old - threshold) * k_factor_e - total_bonus = round(bonus1 + bonus2) - return total_bonus - - -def expected_total_score(expected_dict: dict, rating: int, opponent_ratings: list): - """ - Returns the expected total score from the tournament - :param expected_dict: is the expected dictionary of scores based on CFC data - :param rating: is the player's rating - :param opponent_ratings: is the opponent's rating in a lists - >>> expected_dictionary = expected_scores_init() - >>> expected_total_score(expected_dictionary, 1450, [1237, 1511, 1214, 1441, 1579, 2133]) - 2.84 - """ - sum_expected = 0 - for opp_rating in opponent_ratings: - if rating >= opp_rating: # your rating is bigger than the opponents - sum_expected += find_expected_value(expected_dict, rating - opp_rating, 0) +class RatingsCalculator: + """Ratings Calculator is a class that calculates ratings, cfc-style""" + + # default constructor for ratings calculator + def __init__(self) -> None: + self.expected_scores = self.expected_scores_init() + + return + + def average_rating(self, ratings: list) -> float: + """ + Average ratings of the list of the ratings + :param ratings: a list of ratings + :return: average rating + """ + ave_rating = 0 + for rating in ratings: + ave_rating += rating + return ave_rating / len(ratings) + + def provisional_unrated_players(self, ratings: list, wins: int, losses: int, games_played: int) -> object: + """ + wins is the number of wins, + losses is the number of losses + games_played is the number of games + ratings is the list of opponent's ratings + """ + Rc = self.average_rating(ratings) + # Rp = Rc + 400 (W - L) / N + Rp = Rc + 400 * (wins - losses) / games_played + return Rp + + def established_ratings(self, all_time_high_score: int, old_rating: int, score: float, opponent_ratings: list) -> float: + """ + Returns the established rating from the dictionary of ratings + :param all_time_high_score: the all time high of the player + :param score: the total score from the tournament + :param opponent_ratings: a list of opponent ratings + :param old_rating: the player's old rating + :return: returns the established rating of the player. + >>> ratingTest = RatingsCalculator() + >>> # expected_dict = ratingTest.expected_scores_init() + >>> ratingTest.established_ratings(1450, 1450, 4, [1237, 1511, 1214, 1441, 1579, 2133]) + 1487 + """ + # K=32 for players under 2200 and K=16 for players at or over 2200; + # Rn = Ro + 32 (S - Sx) + if old_rating >= 2199: + multiplier = 16 else: - sum_expected += find_expected_value(expected_dict, opp_rating - rating, 1) - - return sum_expected - - -def find_expected_value(expected_dict: dict, difference: int, lower_val: int): - """ - Returns the expected value of the individual based on whether it is a lower or higher score. - :param lower_val: whether the number is a lower value - :param expected_dict: dictionary with the expected scores - :param difference: is the difference in ratings - :return: expected value based on the expected dictionary - """ - expected = expected_dict[int(difference)] - if lower_val == 1: - expected = round(1 - expected, 2) - else: - # keep the older value - pass - - return expected - - -def expected_scores_init() -> dict: - """ - Returns the expected score dictionary - :return: dictionary containing expected scores - """ - # file is recommend to be in the following format: starting rank, Name of Player, CFC ID - # information of the event - file_name = "ExpectedScores.csv" - expected_scores_higher = {} - - with open(file_name, 'r') as f: - csv_reader = reader(f) - next(csv_reader) - - for row in csv_reader: - # iterate through each row - # print(row) - # if it's the last line - max_diff = 2000 - if row[0][0:3] == "735": - for j in range(735, max_diff): - expected_scores_higher[j] = 1 - else: - row_vals = row[0].split("-") - starting_val = int(row_vals[0]) - end_val = int(row_vals[1]) + multiplier = 32 - for j in range(starting_val, end_val + 1): - expected_scores_higher[j] = float(row[1]) # the location of the csv file + # expected_scores_dict = self.expected_scores_init() - return expected_scores_higher + expected_total = self.expected_total_score(old_rating, opponent_ratings) + new_rating = old_rating + multiplier * (score - expected_total) + + if new_rating > all_time_high_score: + all_time_high_valid = 1 + else: + all_time_high_valid = 0 + + multiplier_e = multiplier // 32 # ratio of the multiplier to ratings under 2200. + bonus = self.bonuses(all_time_high_valid, multiplier_e, new_rating, old_rating, len(opponent_ratings)) + sum_of_scores = new_rating + bonus + return sum_of_scores + + def bonuses(self, a: int, k_factor_e: int, r_new: float, r_old: int, n: int) -> int: + """ + Returns the total sum of all bonuses available + :param a: is = 1 if all time high, 0 otherwise + :param k_factor_e: ratio of the player's k factor to the k factor used for players under 2200 + :param r_new: post-event rating + :param r_old: pre-event rating + :param n: number of games played + :return: value of the new rating + >>> ratingTest = RatingsCalculator() + >>> ratingTest.bonuses(0, 1, 1487, 1450, 6) + 1496 + """ + if n < 4: # no bonus points awarded if less than 4 games played + return 0 + + R_MAX_BONUS = 20 # constants set + R_CHANGE_BONUS = 1.75 + R_CHANGE_THRESHOLD = 13 + + bonus1 = a * R_MAX_BONUS * k_factor_e + threshold = R_CHANGE_THRESHOLD * k_factor_e * math.sqrt(n) + + if r_new > (r_old + threshold): + b = 1 + else: + b = 0 + + bonus2 = b * R_CHANGE_BONUS * (r_new - r_old - threshold) * k_factor_e + total_bonus = round(bonus1 + bonus2) + return total_bonus + + def expected_total_score(self, rating: int, opponent_ratings: list): + """ + Returns the expected total score from the tournament + :param rating: is the player's rating + :param opponent_ratings: is the opponent's rating in a lists + >>> ratingTest = RatingsCalculator() + >>> ratingTest.expected_total_score(1450, [1237, 1511, 1214, 1441, 1579, 2133]) + 2.84 + """ + sum_expected = 0 + for opp_rating in opponent_ratings: + if rating >= opp_rating: # your rating is bigger than the opponents + sum_expected += self.find_expected_value(rating - opp_rating, 0) + else: + sum_expected += self.find_expected_value(opp_rating - rating, 1) + + return sum_expected + + def find_expected_value(self, difference: int, lower_val: int): + """ + Returns the expected value of the individual based on whether it is a lower or higher score. + :param lower_val: whether the number is a lower value + :param difference: is the difference in ratings + :return: expected value based on the expected dictionary + """ + expected_dict = self.expected_scores + expected = expected_dict[int(difference)] + if lower_val == 1: + expected = round(1 - expected, 2) + else: + # keep the older value + pass + + return expected + + def expected_scores_init(self) -> dict: + """ + Returns the expected score dictionary + :return: dictionary containing expected scores + """ + # file is recommend to be in the following format: starting rank, Name of Player, CFC ID + # information of the event + file_name = "C:\\Users\\zheng\\PycharmProjects\\ratings-calculator\\ExpectedScores.csv" + expected_scores_higher = {} + + with open(file_name, 'r') as f: + csv_reader = reader(f) + next(csv_reader) + + for row in csv_reader: + # iterate through each row + # print(row) + # if it's the last line + max_diff = 2000 + if row[0][0:3] == "735": + for j in range(735, max_diff): + expected_scores_higher[j] = 1 + else: + row_vals = row[0].split("-") + starting_val = int(row_vals[0]) + end_val = int(row_vals[1]) + + for j in range(starting_val, end_val + 1): + expected_scores_higher[j] = float(row[1]) # the location of the csv file + + return expected_scores_higher if __name__ == "__main__": @@ -229,17 +238,41 @@ def expected_scores_init() -> dict: "Best of luck in your chess endeavours!\n") # initialize expected scores dictionary to begin - expected_scores = expected_scores_init() + + ratingsCalc = RatingsCalculator() + + # expected_scores = ratingsCalc.expected_scores_init() # get data from user - cfc_id = int(input("Enter CFC ID: ")) + cfc_id_input = input("Enter CFC ID: ") + + if not cfc_id_input.isnumeric(): + print("ERROR: input for cfc id must be numeric") + exit(-1) + + # else, convert + cfc_id = int(cfc_id_input) # get number of games played - n = int(input("Number of games played: ")) + numInput = input("Number of games played: ") + if not numInput.isnumeric(): + print("Error: input must be numeric") + exit(-1) # exit with exit code 1 + + # else, convert + n = int(numInput) ratings_list = [] for i in range(1, n+1): - ele = int(input("Rating of player " + str(i) + ": ")) + ele = input("Rating of player " + str(i) + ": ") + + if not ele.isnumeric(): + print("Error: rating input must be numeric") + exit(-1) # exit with exit code 1 + + # convert element to numeric + ele = int(ele) + ratings_list.append(ele) # get total score of player @@ -257,8 +290,8 @@ def expected_scores_init() -> dict: if rating_type == 1: # new_rating = established_ratings(1450, 1450, 4, [1237, 1511, 1214, 1441, 1579, 2133]) - calc_new_rating = established_ratings(all_time_high, current_rating, (wins+draws), ratings_list) + calc_new_rating = ratingsCalc.established_ratings(all_time_high, current_rating, (wins+draws), ratings_list) else: - calc_new_rating = provisional_unrated_players(ratings_list, n, wins, losses) + calc_new_rating = ratingsCalc.provisional_unrated_players(ratings_list, n, wins, losses) print("New Rating is: ", calc_new_rating) diff --git a/src/ratings_calculator/rating_tests.py b/src/ratings_calculator/rating_tests.py new file mode 100644 index 0000000..7062018 --- /dev/null +++ b/src/ratings_calculator/rating_tests.py @@ -0,0 +1,27 @@ +import unittest + +from src.ratings_calculator.main import RatingsCalculator + + +class TestBasicFunctionalities(unittest.TestCase): + def test_default_CFC_ratings(self) -> None: + ratings = RatingsCalculator() + new_rating = ratings.established_ratings(1450, 1450, 4, [1237, 1511, 1214, 1441, 1579, 2133]) + print("New Rating is: ", new_rating) + self.assertEqual(new_rating, 1516.12) # add assertion here + + def test_null_values(self) -> None: + ratings = RatingsCalculator() + new_rating = ratings.established_ratings(0, 0, 0, []) + print("New Rating is: ", new_rating) + self.assertEqual(new_rating, 0) # add assertion here + + def test_one_game(self) -> None: + ratings = RatingsCalculator() + new_rating = ratings.established_ratings(1000, 1000, 1, [1200]) + print("New Rating is: ", new_rating) + self.assertEqual(new_rating, 1200) # add assertion here + + +if __name__ == '__main__': + unittest.main()