diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index f167569..fc9a24d 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -21,7 +21,7 @@ jobs: - uses: actions/setup-python@v4 with: python-version: "3.11" - - run: pip install pytest coverage + - run: pip install pytest pytest-mock coverage pip install . # annotate each step with `if: always` to run all regardless diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 01d51e0..098bb34 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -28,10 +28,10 @@ jobs: include: - os: ubuntu-latest python-version: "3.9" - - os: ubuntu-latest - python-version: "3.8" - - os: ubuntu-latest - python-version: "3.7" + # - os: ubuntu-latest + # python-version: "3.8" + # - os: ubuntu-latest + # python-version: "3.7" steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index 8008d60..ffe4abe 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # archeryutils ![GitHub](https://img.shields.io/github/license/jatkinson1000/archeryutils) -[![Python 3.7+](https://img.shields.io/badge/python-3.7+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/jatkinson1000/archeryutils/testing.yaml) [![codecov](https://codecov.io/gh/jatkinson1000/archeryutils/branch/main/graph/badge.svg?token=AZU7G6H8T0)](https://codecov.io/gh/jatkinson1000/archeryutils) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) diff --git a/archeryutils/__init__.py b/archeryutils/__init__.py index 0f7051b..c7cf251 100644 --- a/archeryutils/__init__.py +++ b/archeryutils/__init__.py @@ -1,7 +1,7 @@ """Package providing code for various archery utilities.""" from archeryutils import load_rounds, rounds, targets from archeryutils.handicaps import handicap_equations, handicap_functions -import archeryutils.classifications as classifications +from archeryutils import classifications __all__ = [ "rounds", diff --git a/archeryutils/classifications/agb_field_classifications.py b/archeryutils/classifications/agb_field_classifications.py index b01fc2a..b0093f2 100644 --- a/archeryutils/classifications/agb_field_classifications.py +++ b/archeryutils/classifications/agb_field_classifications.py @@ -13,6 +13,10 @@ agb_field_classification_scores """ +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + import re from typing import List, Dict, Any import numpy as np @@ -288,6 +292,9 @@ def agb_field_classification_scores( ArcheryGB Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 """ + # Unused roundname argument to keep consistency with other classification functions + # pylint: disable=unused-argument + # deal with reduced categories: if age_group.lower().replace(" ", "") in ("adult", "50+", "under21"): age_group = "Adult" diff --git a/archeryutils/classifications/agb_indoor_classifications.py b/archeryutils/classifications/agb_indoor_classifications.py index ad44d39..127373e 100644 --- a/archeryutils/classifications/agb_indoor_classifications.py +++ b/archeryutils/classifications/agb_indoor_classifications.py @@ -7,6 +7,10 @@ calculate_agb_indoor_classification agb_indoor_classification_scores """ +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List, Dict, Any import numpy as np @@ -59,7 +63,7 @@ def _make_agb_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: # Read in gender info as list of dicts agb_genders = cls_funcs.read_genders_json() # Read in classification names as dict - agb_classes_info_in = cls_funcs.read_classes_in_json() + agb_classes_info_in = cls_funcs.read_classes_json("agb_indoor") agb_classes_in = agb_classes_info_in["classes"] agb_classes_in_long = agb_classes_info_in["classes_long"] @@ -69,40 +73,36 @@ def _make_agb_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: # loop over genders classification_dict = {} for bowstyle in agb_bowstyles: - for age in agb_ages: - for gender in agb_genders: - # Get age steps from Adult - age_steps = age["step"] - - # Get number of gender steps required - # Perform fiddle in age steps where genders diverge at U15/U16 - if gender.lower() == "female" and age["step"] <= 3: - gender_steps = 1 - else: - gender_steps = 0 - + for gender in agb_genders: + for age in agb_ages: groupname = cls_funcs.get_groupname( bowstyle["bowstyle"], gender, age["age_group"] ) - class_hc = np.empty(len(agb_classes_in)) + classification_dict[groupname] = { + "classes": agb_classes_in, + "classes_long": agb_classes_in_long, + } + + # set step from datum based on age and gender steps required + delta_hc_age_gender = cls_funcs.get_age_gender_step( + gender, + age["step"], + bowstyle["ageStep_in"], + bowstyle["genderStep_in"], + ) + + classification_dict[groupname]["class_HC"] = np.empty( + len(agb_classes_in) + ) for i in range(len(agb_classes_in)): # Assign handicap for this classification - class_hc[i] = ( + classification_dict[groupname]["class_HC"][i] = ( bowstyle["datum_in"] - + age_steps * bowstyle["ageStep_in"] - + gender_steps * bowstyle["genderStep_in"] + + delta_hc_age_gender + (i - 1) * bowstyle["classStep_in"] ) - # TODO: class names and long are duplicated many times here - # Consider a method to reduce this (affects other code) - classification_dict[groupname] = { - "classes": agb_classes_in, - "class_HC": class_hc, - "classes_long": agb_classes_in_long, - } - return classification_dict @@ -254,17 +254,19 @@ def agb_indoor_classification_scores( # Handle possibility of gaps in the tables or max scores by checking 1 HC point # above current (floored to handle 0.5) and amending accordingly - for i, (sc, hc) in enumerate(zip(int_class_scores, group_data["class_HC"])): + for i, (score, handicap) in enumerate( + zip(int_class_scores, group_data["class_HC"]) + ): next_score = hc_eq.score_for_round( ALL_INDOOR_ROUNDS[cls_funcs.strip_spots(roundname)], - np.floor(hc) + 1, + np.floor(handicap) + 1, hc_scheme, hc_params, round_score_up=True, )[0] - if next_score == sc: + if next_score == score: # If already at max score this classification is impossible - if sc == ALL_INDOOR_ROUNDS[roundname].max_score(): + if score == ALL_INDOOR_ROUNDS[roundname].max_score(): int_class_scores[i] = -9999 # If gap in table increase to next score # (we assume here that no two classifications are only 1 point apart...) diff --git a/archeryutils/classifications/agb_old_indoor_classifications.py b/archeryutils/classifications/agb_old_indoor_classifications.py index d31dcbf..ca1dcf7 100644 --- a/archeryutils/classifications/agb_old_indoor_classifications.py +++ b/archeryutils/classifications/agb_old_indoor_classifications.py @@ -7,6 +7,10 @@ calculate_AGB_old_indoor_classification AGB_old_indoor_classification_scores """ +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List, Dict, Any import numpy as np diff --git a/archeryutils/classifications/agb_outdoor_classifications.py b/archeryutils/classifications/agb_outdoor_classifications.py index 064337a..18a4108 100644 --- a/archeryutils/classifications/agb_outdoor_classifications.py +++ b/archeryutils/classifications/agb_outdoor_classifications.py @@ -7,7 +7,12 @@ calculate_agb_outdoor_classification agb_outdoor_classification_scores """ +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List, Dict, Any +from collections import OrderedDict import numpy as np from archeryutils import load_rounds @@ -44,13 +49,184 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: a list of prestige rounds eligible for that group, and a list of the maximum distances available to that group + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + # Read in age group info as list of dicts + agb_ages = cls_funcs.read_ages_json() + # Read in bowstyleclass info as list of dicts + agb_bowstyles = cls_funcs.read_bowstyles_json() + # Read in gender info as list of dicts + agb_genders = cls_funcs.read_genders_json() + # Read in classification names as dict + agb_classes_info_out = cls_funcs.read_classes_json("agb_outdoor") + agb_classes_out = agb_classes_info_out["classes"] + agb_classes_out_long = agb_classes_info_out["classes_long"] + + # Generate dict of classifications + # loop over bowstyles + # loop over genders + # loop over ages + classification_dict = {} + for bowstyle in agb_bowstyles: + for gender in agb_genders: + for age in agb_ages: + groupname = cls_funcs.get_groupname( + bowstyle["bowstyle"], gender, age["age_group"] + ) + + # Get max dists for category from json file data + # Use metres as corresponding yards >= metric + max_dist = age[gender.lower()] + + classification_dict[groupname] = { + "classes": agb_classes_out, + "max_distance": max_dist, + "classes_long": agb_classes_out_long, + } + + # set step from datum based on age and gender steps required + delta_hc_age_gender = cls_funcs.get_age_gender_step( + gender, + age["step"], + bowstyle["ageStep_out"], + bowstyle["genderStep_out"], + ) + + classification_dict[groupname]["class_HC"] = np.empty( + len(agb_classes_out) + ) + classification_dict[groupname]["min_dists"] = np.empty( + len(agb_classes_out) + ) + for i in range(len(agb_classes_out)): + # Assign handicap for this classification + classification_dict[groupname]["class_HC"][i] = ( + bowstyle["datum_out"] + + delta_hc_age_gender + + (i - 2) * bowstyle["classStep_out"] + ) + + # Get minimum distance that must be shot for this classification + classification_dict[groupname]["min_dists"][i] = assign_min_dist( + n_class=i, + gender=gender, + age_group=age["age_group"], + max_dists=max_dist, + ) + + # Assign prestige rounds for the category + classification_dict[groupname][ + "prestige_rounds" + ] = assign_outdoor_prestige( + bowstyle=bowstyle["bowstyle"], + age=age["age_group"], + gender=gender, + max_dist=max_dist, + ) + + return classification_dict + + +def assign_min_dist( + n_class: int, + gender: str, + age_group: str, + max_dists: List[int], +) -> int: + """ + Assign appropriate minimum distance required for a category and classification. + + Appropriate for 2023 ArcheryGB age groups and classifications. + + Parameters + ---------- + n_class : int + integer corresponding to classification [0=EMB, 8=A3] + gender : str + string defining gender + age_group : str, + string defining age group + max_dists: List[int] + list of integers defining the maximum distances for category + + Returns + ------- + min_dist : int + minimum distance [m] required for this classification + + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + # List of maximum distances for use in assigning maximum distance [metres] + # Use metres because corresponding yards distances are >= metric ones + dists = [90, 70, 60, 50, 40, 30, 20, 15] + + max_dist_index = dists.index(np.min(max_dists)) + + # B1 and above + if n_class <= 3: + # All MB and B1 require max distance for everyone: + return dists[max_dist_index] + + # Below B1 + # Age group trickery: + # U16 males and above step down for B2 and beyond + if gender.lower() in ("male") and age_group.lower().replace(" ", "") in ( + "adult", + "50+", + "under21", + "under18", + "under16", + ): + return dists[max_dist_index + (n_class - 3)] + + # All other categories require max dist for B1 and B2 then step down + try: + return dists[max_dist_index + (n_class - 3) - 1] + except IndexError: + # Distances stack at the bottom end as we can't go below 15m + return dists[-1] + + +def assign_outdoor_prestige( + bowstyle: str, + gender: str, + age: str, + max_dist: List[int], +) -> List[str]: + """ + Assign appropriate outdoor prestige rounds for a category. + + Appropriate for 2023 ArcheryGB age groups and classifications. + + Parameters + ---------- + bowstyle : str + string defining bowstyle + gender : str + string defining gender + age : str, + string defining age group + max_dist: List[int] + list of integers defining the maximum distances for category + + Returns + ------- + prestige_rounds : list of str + list of perstige rounds for category defined by inputs + References ---------- ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) """ # Lists of prestige rounds defined by 'codename' of 'Round' class - # TODO: convert this to json? + # WARNING: do not change these without also addressing the prestige round code. prestige_imperial = [ "york", "hereford", @@ -92,156 +268,47 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: "metric_122_30", ] - # List of maximum distances for use in assigning maximum distance [metres] - # Use metres because corresponding yards distances are >= metric ones - dists = [90, 70, 60, 50, 40, 30, 20, 15] - padded_dists = [90, 90] + dists - - # Read in age group info as list of dicts - agb_ages = cls_funcs.read_ages_json() - # Read in bowstyleclass info as list of dicts - agb_bowstyles = cls_funcs.read_bowstyles_json() - # Read in gender info as list of dicts - agb_genders = cls_funcs.read_genders_json() - # Read in classification names as dict - agb_classes_info_out = cls_funcs.read_classes_out_json() - agb_classes_out = agb_classes_info_out["classes"] - agb_classes_out_long = agb_classes_info_out["classes_long"] - - # Generate dict of classifications - # loop over bowstyles - # loop over ages - # loop over genders - classification_dict = {} - for bowstyle in agb_bowstyles: - for age in agb_ages: - for gender in agb_genders: - # Get age steps from Adult - age_steps = age["step"] - - # Get number of gender steps required - # Perform fiddle in age steps where genders diverge at U15/U16 - if gender.lower() == "female" and age["step"] <= 3: - gender_steps = 1 - else: - gender_steps = 0 - - groupname = cls_funcs.get_groupname( - bowstyle["bowstyle"], gender, age["age_group"] - ) - - # Get max dists for category from json file data - # Use metres as corresponding yards >= metric - max_dist = age[gender.lower()] - max_dist_index = dists.index(min(max_dist)) - - class_hc = np.empty(len(agb_classes_out)) - min_dists = np.empty((len(agb_classes_out), 3)) - for i in range(len(agb_classes_out)): - # Assign handicap for this classification - class_hc[i] = ( - bowstyle["datum_out"] - + age_steps * bowstyle["ageStep_out"] - + gender_steps * bowstyle["genderStep_out"] - + (i - 2) * bowstyle["classStep_out"] - ) - - # Assign minimum distance [metres] for this classification - if i <= 3: - # All MB and B1 require max distance for everyone: - min_dists[i, :] = padded_dists[ - max_dist_index : max_dist_index + 3 - ] - else: - try: - # Age group trickery: - # U16 males and above step down for B2 and beyond - if gender.lower() in ("male") and age[ - "age_group" - ].lower().replace(" ", "") in ( - "adult", - "50+", - "under21", - "under18", - "under16", - ): - min_dists[i, :] = padded_dists[ - max_dist_index + i - 3 : max_dist_index + i - ] - # All other categories require max dist for B1 and B2 then step down - else: - try: - min_dists[i, :] = padded_dists[ - max_dist_index + i - 4 : max_dist_index + i - 1 - ] - except ValueError: - # Distances stack at the bottom end - min_dists[i, :] = padded_dists[-3:] - except IndexError as err: - # Shouldn't really get here... - print( - f"{err} cannot select minimum distances for " - f"{gender} and {age['age_group']}" - ) - min_dists[i, :] = dists[-3:] - - # Assign prestige rounds for the category - # - check bowstyle, distance, and age - prestige_rounds = [] - - # 720 rounds - bowstyle dependent - if bowstyle["bowstyle"].lower() == "compound": - # Everyone gets the 'adult' 720 - prestige_rounds.append(prestige_720_compound[0]) - # Check for junior eligible shorter rounds - for roundname in prestige_720_compound[1:]: - if ALL_OUTDOOR_ROUNDS[roundname].max_distance() >= min( - max_dist - ): - prestige_rounds.append(roundname) - elif bowstyle["bowstyle"].lower() == "barebow": - # Everyone gets the 'adult' 720 - prestige_rounds.append(prestige_720_barebow[0]) - # Check for junior eligible shorter rounds - for roundname in prestige_720_barebow[1:]: - if ALL_OUTDOOR_ROUNDS[roundname].max_distance() >= min( - max_dist - ): - prestige_rounds.append(roundname) - else: - # Everyone gets the 'adult' 720 - prestige_rounds.append(prestige_720[0]) - # Check for junior eligible shorter rounds - for roundname in prestige_720[1:]: - if ALL_OUTDOOR_ROUNDS[roundname].max_distance() >= min( - max_dist - ): - prestige_rounds.append(roundname) - # Additional fix for Male 50+, U18, and U16 - if gender.lower() == "male": - if age["age_group"].lower() in ("50+", "under 18"): - prestige_rounds.append(prestige_720[1]) - elif age["age_group"].lower() == "under 16": - prestige_rounds.append(prestige_720[2]) - - # Imperial and 1440 rounds - for roundname in prestige_imperial + prestige_metric: - # Compare round dist - if ALL_OUTDOOR_ROUNDS[roundname].max_distance() >= min(max_dist): - prestige_rounds.append(roundname) - - # TODO: class names and long are duplicated many times here - # Consider a method to reduce this (affects other code) - classification_dict[groupname] = { - "classes": agb_classes_out, - "class_HC": class_hc, - "prestige_rounds": prestige_rounds, - "max_distance": max_dist, - "min_dists": min_dists, - "classes_long": agb_classes_out_long, - } - - return classification_dict + # Assign prestige rounds for the category + # - check bowstyle, distance, and age + prestige_rounds = [] + distance_check: List[str] = [] + + # 720 rounds - bowstyle dependent + if bowstyle.lower() == "compound": + # Everyone gets the 'adult' 720 + prestige_rounds.append(prestige_720_compound[0]) + # Check rest for junior eligible shorter rounds + distance_check = distance_check + prestige_720_compound[1:] + + elif bowstyle.lower() == "barebow": + # Everyone gets the 'adult' 720 + prestige_rounds.append(prestige_720_barebow[0]) + # Check rest for junior eligible shorter rounds + distance_check = distance_check + prestige_720_barebow[1:] + + else: + # Everyone gets the 'adult' 720 + prestige_rounds.append(prestige_720[0]) + # Check rest for junior eligible shorter rounds + distance_check = distance_check + prestige_720[1:] + + # Additional fix for Male 50+, U18, and U16 recurve + if gender.lower() == "male": + if age.lower() in ("50+", "under 18"): + prestige_rounds.append(prestige_720[1]) # 60m + elif age.lower() == "under 16": + prestige_rounds.append(prestige_720[2]) # 50m + + # Imperial and 1440 rounds - Check based on distance + distance_check = distance_check + prestige_imperial + distance_check = distance_check + prestige_metric + + # Check all other rounds based on distance + for roundname in distance_check: + if ALL_OUTDOOR_ROUNDS[roundname].max_distance() >= np.min(max_dist): + prestige_rounds.append(roundname) + + return prestige_rounds agb_outdoor_classifications = _make_agb_outdoor_classification_dict() @@ -300,32 +367,19 @@ def calculate_agb_outdoor_classification( groupname = cls_funcs.get_groupname(bowstyle, gender, age_group) group_data = agb_outdoor_classifications[groupname] - class_data: Dict[str, Dict[str, Any]] = {} + # We iterate over class_data keys, so convert use OrderedDict + class_data: OrderedDict[str, Dict[str, Any]] = OrderedDict([]) for i, class_i in enumerate(group_data["classes"]): class_data[class_i] = { - "min_dists": group_data["min_dists"][i, :], + "min_dist": group_data["min_dists"][i], "score": all_class_scores[i], } - # is it a prestige round? If not remove MB as an option - if roundname not in agb_outdoor_classifications[groupname]["prestige_rounds"]: - # TODO: a list of dictionary keys is super dodgy python... - # can this be improved? - for MB_class in list(class_data.keys())[0:3]: - del class_data[MB_class] + # Check if this is a prestige round and appropriate distances + # remove ineligible classes from class_data + class_data = check_prestige_distance(roundname, groupname, class_data) - # If not prestige, what classes are eligible based on category and distance - to_del = [] - round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance() - for class_i in class_data.items(): - if class_i[1]["min_dists"][-1] > round_max_dist: - to_del.append(class_i[0]) - for class_i in to_del: - del class_data[class_i] - - # Classification based on score - accounts for fractional HC - # TODO Make this its own function for later use in generating tables? - # Of those classes remaining, what is the highest classification this score gets? + # Of the classes remaining, what is the highest classification this score gets? to_del = [] for classname, classdata in class_data.items(): if classdata["score"] > score: @@ -334,12 +388,56 @@ def calculate_agb_outdoor_classification( del class_data[item] try: - classification_from_score = list(class_data.keys())[0] - return classification_from_score + return list(class_data.keys())[0] except IndexError: return "UC" +def check_prestige_distance( + roundname: str, groupname: str, class_data: OrderedDict[str, Dict[str, Any]] +) -> OrderedDict[str, Dict[str, Any]]: + """ + Check available classifications for eligibility based on distance and prestige.. + + Remove MB tier if not a prestige round. + Remove any classifications where round is not far enough. + + Parameters + ---------- + roundname : str + name of round shot as given by 'codename' in json + groupname : str + identifier for the category + class_data : OrderedDict + classification information for each category. + + Returns + ------- + class_data : OrderedDict + updated classification information for each category. + + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + # is it a prestige round? If not remove MB as an option + if roundname not in agb_outdoor_classifications[groupname]["prestige_rounds"]: + for mb_class in list(class_data.keys())[0:3]: + del class_data[mb_class] + + # If not prestige, what classes are ineligible based on distance + to_del: List[str] = [] + round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance() + for class_i_name, class_i_data in class_data.items(): + if class_i_data["min_dist"] > round_max_dist: + to_del.append(class_i_name) + for class_i in to_del: + del class_data[class_i] + + return class_data + + def agb_outdoor_classification_scores( roundname: str, bowstyle: str, gender: str, age_group: str ) -> List[int]: @@ -398,7 +496,7 @@ def agb_outdoor_classification_scores( # If not prestige, what classes are eligible based on category and distance round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance() for i in range(3, len(class_scores)): - if min(group_data["min_dists"][i, :]) > round_max_dist: + if group_data["min_dists"][i] > round_max_dist: class_scores[i] = -9999 # Make sure that hc.eq.score_for_round did not return array to satisfy mypy diff --git a/archeryutils/classifications/classification_utils.py b/archeryutils/classifications/classification_utils.py index 230e6f0..381da7e 100644 --- a/archeryutils/classifications/classification_utils.py +++ b/archeryutils/classifications/classification_utils.py @@ -112,16 +112,17 @@ def read_genders_json( ) -def read_classes_out_json( - classes_file: Path = Path(__file__).parent / "AGB_classes_out.json", +def read_classes_json( + class_system: str, ) -> Dict[str, Any]: """ - Read AGB outdoor classes in from neighbouring json file to dict. + Read AGB classes in from neighbouring json file to dict. Parameters ---------- - classes_file : Path - path to json file + class_system : str + string specifying class system to read: + 'agb_indoor', 'agb_outdoor', 'agb_field' Returns ------- @@ -132,38 +133,20 @@ def read_classes_out_json( ---------- Archery GB Rules of Shooting """ - # Read in classification names as dict - with open(classes_file, encoding="utf-8") as json_file: - classes = json.load(json_file) - if isinstance(classes, dict): - return classes - raise TypeError( - f"Unexpected classes input when reading from json file. " - f"Expected dict() but got {type(classes)}. Check {classes_file}." - ) - - -# TODO This could (should) be condensed into one method with the above function -def read_classes_in_json( - classes_file: Path = Path(__file__).parent / "AGB_classes_in.json", -) -> Dict[str, Any]: - """ - Read AGB indoor classes in from neighbouring json file to dict. + if class_system == "agb_indoor": + filename = "AGB_classes_in.json" + elif class_system == "agb_outdoor": + filename = "AGB_classes_out.json" + # elif class_system == 'agb_field': + # filename = "AGB_classes_field.json" + else: + raise ValueError( + "Unexpected classification system specified. " + "Expected one of 'agb_indoor', 'agb_outdoor', 'aqb_field'." + ) + + classes_file = Path(__file__).parent / filename - Parameters - ---------- - classes_file : Path - path to json file - - Returns - ------- - classes : dict - AGB classes data from file - - References - ---------- - Archery GB Rules of Shooting - """ # Read in classification names as dict with open(classes_file, encoding="utf-8") as json_file: classes = json.load(json_file) @@ -202,6 +185,48 @@ def get_groupname(bowstyle: str, gender: str, age_group: str) -> str: return groupname +def get_age_gender_step( + gender: str, + age_cat: int, + age_step: float, + gender_step: float, +) -> float: + """ + Calculate AGB indoor age and gender step for classification dictionaries. + + Contains a tricky fiddle for aligning Male and Female under 15 scores and below, + and a necessary check to ensure that gender step doesnt overtake age step when + doing this. + + Parameters + ---------- + gender : str + gender this classification applies to + age_cat : int + age category as an integer (number of age steps below adult e.g. 50+=1, U14=5) + age_step : float + age group handicap step for this category + gender_step : float + gender handicap step for this category + + Returns + ------- + delta_hc_age_gender : float + age and gender handicap step for this category's MB relative to datum + """ + # There is a danger that gender step overtakes age step at U15/U16 + # interface. If this happens set to age step to align U16 with U16 + if gender.lower() == "female" and age_cat == 3 and age_step < gender_step: + return age_cat * age_step + age_step + + # For females <=3 (Under 16 or older) apply gender step and age steps + if gender.lower() == "female" and age_cat <= 3: + return gender_step + age_cat * age_step + + # Default case for males, and females aged >3 (Under 15 or younger) apply age steps + return age_cat * age_step + + def strip_spots( roundname: str, ) -> str: @@ -224,25 +249,20 @@ def strip_spots( return roundname -def get_compound_codename(round_codenames): +def get_compound_codename(round_codename: str) -> str: """ Convert any indoor rounds with special compound scoring to the compound format. Parameters ---------- - round_codenames : str or list of str - list of str round codenames to check + round_codenames : str + str round codename to check Returns ------- - round_codenames : str or list of str - list of amended round codenames for compound + round_codename : str + amended round codename for compound """ - notlistflag = False - if not isinstance(round_codenames, list): - round_codenames = [round_codenames] - notlistflag = True - convert_dict = { "bray_i": "bray_i_compound", "bray_i_triple": "bray_i_compound_triple", @@ -258,9 +278,7 @@ def get_compound_codename(round_codenames): "wa25_triple": "wa25_compound_triple", } - for i, codename in enumerate(round_codenames): - if codename in convert_dict: - round_codenames[i] = convert_dict[codename] - if notlistflag: - return round_codenames[0] - return round_codenames + if convert_dict.get(round_codename) is not None: + round_codename = convert_dict[round_codename] + + return round_codename diff --git a/archeryutils/classifications/tests/test_agb_field.py b/archeryutils/classifications/tests/test_agb_field.py index 00b883e..9f6d110 100644 --- a/archeryutils/classifications/tests/test_agb_field.py +++ b/archeryutils/classifications/tests/test_agb_field.py @@ -1,4 +1,9 @@ """Tests for agb field classification functions""" +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + +from typing import List import pytest from archeryutils import load_rounds @@ -62,7 +67,7 @@ def test_agb_field_classification_scores_ages( self, roundname: str, age_group: str, - scores_expected: str, + scores_expected: List[int], ) -> None: """ Check that field classification returns expected value for a case. @@ -110,7 +115,7 @@ def test_agb_field_classification_scores_genders( roundname: str, gender: str, age_group: str, - scores_expected: str, + scores_expected: List[int], ) -> None: """ Check that field classification returns expected value for a case. @@ -164,7 +169,7 @@ def test_agb_field_classification_scores_bowstyles( self, roundname: str, bowstyle: str, - scores_expected: str, + scores_expected: List[int], ) -> None: """ Check that field classification returns expected value for a case. diff --git a/archeryutils/classifications/tests/test_agb_indoor.py b/archeryutils/classifications/tests/test_agb_indoor.py index 7a23371..a6a0466 100644 --- a/archeryutils/classifications/tests/test_agb_indoor.py +++ b/archeryutils/classifications/tests/test_agb_indoor.py @@ -1,4 +1,8 @@ """Tests for agb indoor classification functions""" +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List import pytest @@ -166,6 +170,40 @@ def test_agb_indoor_classification_scores_bowstyles( assert scores == scores_expected[::-1] + @pytest.mark.parametrize( + "bowstyle,scores_expected", + [ + ( + "flatbow", + [331, 387, 433, 472, 503, 528, 549, 565], + ), + ( + "traditional", + [331, 387, 433, 472, 503, 528, 549, 565], + ), + ( + "asiatic", + [331, 387, 433, 472, 503, 528, 549, 565], + ), + ], + ) + def test_agb_indoor_classification_scores_nonbowstyles( + self, + bowstyle: str, + scores_expected: List[int], + ) -> None: + """ + Check that barebow scores returned for valid but non-indoor styles. + """ + scores = class_funcs.agb_indoor_classification_scores( + roundname="portsmouth", + bowstyle=bowstyle, + gender="male", + age_group="adult", + ) + + assert scores == scores_expected[::-1] + @pytest.mark.parametrize( "roundname,scores_expected", [ diff --git a/archeryutils/classifications/tests/test_agb_old_indoor.py b/archeryutils/classifications/tests/test_agb_old_indoor.py index e1fcc78..646f2e7 100644 --- a/archeryutils/classifications/tests/test_agb_old_indoor.py +++ b/archeryutils/classifications/tests/test_agb_old_indoor.py @@ -1,4 +1,8 @@ """Tests for old agb indoor classification functions""" +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List import pytest diff --git a/archeryutils/classifications/tests/test_agb_outdoor.py b/archeryutils/classifications/tests/test_agb_outdoor.py index 834f747..1bf3fb1 100644 --- a/archeryutils/classifications/tests/test_agb_outdoor.py +++ b/archeryutils/classifications/tests/test_agb_outdoor.py @@ -1,4 +1,8 @@ """Tests for agb indoor classification functions""" +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List import pytest @@ -207,6 +211,48 @@ def test_agb_outdoor_classification_scores_bowstyles( assert scores == scores_expected[::-1] + @pytest.mark.parametrize( + "roundname,bowstyle,gender,scores_expected", + [ + ( + "wa1440_90", + "flatbow", + "male", + [290, 380, 484, 598, 717, 835, 945, 1042, 1124], + ), + ( + "wa1440_70", + "traditional", + "female", + [252, 338, 441, 558, 682, 806, 921, 1023, 1108], + ), + ( + "wa1440_70", + "asiatic", + "female", + [252, 338, 441, 558, 682, 806, 921, 1023, 1108], + ), + ], + ) + def test_agb_outdoor_classification_scores_nonbowstyles( + self, + roundname: str, + bowstyle: str, + gender: str, + scores_expected: List[int], + ) -> None: + """ + Check that barebow scores returned for valid but non-outdoor bowstyles. + """ + scores = class_funcs.agb_outdoor_classification_scores( + roundname=roundname, + bowstyle=bowstyle, + gender=gender, + age_group="adult", + ) + + assert scores == scores_expected[::-1] + @pytest.mark.parametrize( "roundname,scores_expected", [ diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py index c7343ef..1e23015 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -38,7 +38,7 @@ """ import json from typing import Union, Optional, Tuple -from dataclasses import dataclass +from dataclasses import dataclass, field import numpy as np import numpy.typing as npt @@ -77,8 +77,6 @@ class HcParams: constant used in handicap equation AGBo_p1 : float exponent of distance scaling - AGBo_arw_d : float - arrow diameter used in the old AGB algorithm by D. Lane KEY PARAMETERS AND CONSTANTS FOR THE ARCHERY AUSTRALIA SCHEME AA_k0 : float @@ -105,37 +103,70 @@ class HcParams: Diameter of an indoor arrow [metres] arw_d_out : float Diameter of an outdoor arrow [metres] + AGBo_arw_d : float + arrow diameter used in the old AGB algorithm by D. Lane [metres] + AA_arw_d_out : float + Diameter of an outdoor arrow in the Archery Australia scheme [metres] """ - AGB_datum: float = 6.0 - AGB_step: float = 3.5 - AGB_ang_0: float = 5.0e-4 - AGB_kd: float = 0.00365 - - AGBo_datum: float = 12.9 - AGBo_step: float = 3.6 - AGBo_ang_0: float = 5.0e-4 - AGBo_k1: float = 1.429e-6 - AGBo_k2: float = 1.07 - AGBo_k3: float = 4.3 - AGBo_p1: float = 2.0 - AGBo_arw_d: float = 7.14e-3 + agb_hc_data: dict[str, float] = field( + default_factory=lambda: ( + { + "AGB_datum": 6.0, + "AGB_step": 3.5, + "AGB_ang_0": 5.0e-4, + "AGB_kd": 0.00365, + } + ) + ) - AA_k0: float = 2.37 - AA_ks: float = 0.027 - AA_kd: float = 0.004 + agb_old_hc_data: dict[str, float] = field( + default_factory=lambda: ( + { + "AGBo_datum": 12.9, + "AGBo_step": 3.6, + "AGBo_ang_0": 5.0e-4, + "AGBo_k1": 1.429e-6, + "AGBo_k2": 1.07, + "AGBo_k3": 4.3, + "AGBo_p1": 2.0, + } + ) + ) - AA2_k0: float = 2.57 - AA2_ks: float = 0.027 - AA2_f1: float = 0.815 - AA2_f2: float = 0.185 - AA2_d0: float = 50.0 + aa_hc_data: dict[str, float] = field( + default_factory=lambda: ( + { + "AA_k0": 2.37, + "AA_ks": 0.027, + "AA_kd": 0.004, + } + ) + ) - AA_arw_d_out: float = 5.0e-3 + aa2_hc_data: dict[str, float] = field( + default_factory=lambda: ( + { + "AA2_k0": 2.57, + "AA2_ks": 0.027, + "AA2_f1": 0.815, + "AA2_f2": 0.185, + "AA2_d0": 50.0, + } + ) + ) - arw_d_in: float = 9.3e-3 - arw_d_out: float = 5.5e-3 + arw_d_data: dict[str, float] = field( + default_factory=lambda: ( + { + "arw_d_in": 9.3e-3, + "arw_d_out": 5.5e-3, + "AGBo_arw_d": 7.14e-3, + "AA_arw_d_out": 5.0e-3, + } + ) + ) @classmethod def load_json_params(cls, jsonpath: str) -> "HcParams": @@ -156,29 +187,29 @@ def load_json_params(cls, jsonpath: str) -> "HcParams": json_hc_params: "HcParams" = cls() with open(jsonpath, "r", encoding="utf-8") as read_file: paramsdict = json.load(read_file) - json_hc_params.AGB_datum = paramsdict["AGB_datum"] - json_hc_params.AGB_step = paramsdict["AGB_step"] - json_hc_params.AGB_ang_0 = paramsdict["AGB_ang_0"] - json_hc_params.AGB_kd = paramsdict["AGB_kd"] - json_hc_params.AGBo_datum = paramsdict["AGBo_datum"] - json_hc_params.AGBo_step = paramsdict["AGBo_step"] - json_hc_params.AGBo_ang_0 = paramsdict["AGBo_ang_0"] - json_hc_params.AGBo_k1 = paramsdict["AGBo_k1"] - json_hc_params.AGBo_k2 = paramsdict["AGBo_k2"] - json_hc_params.AGBo_k3 = paramsdict["AGBo_k3"] - json_hc_params.AGBo_p1 = paramsdict["AGBo_p1"] - json_hc_params.AGBo_arw_d = paramsdict["AGBo_arw_d"] - json_hc_params.AA_k0 = paramsdict["AA_k0"] - json_hc_params.AA_ks = paramsdict["AA_ks"] - json_hc_params.AA_kd = paramsdict["AA_kd"] - json_hc_params.AA2_k0 = paramsdict["AA2_k0"] - json_hc_params.AA2_ks = paramsdict["AA2_ks"] - json_hc_params.AA2_f1 = paramsdict["AA2_f1"] - json_hc_params.AA2_f2 = paramsdict["AA2_f2"] - json_hc_params.AA2_d0 = paramsdict["AA2_d0"] - json_hc_params.AA_arw_d_out = paramsdict["AA_arw_d_out"] - json_hc_params.arw_d_in = paramsdict["arrow_diameter_indoors"] - json_hc_params.arw_d_out = paramsdict["arrow_diameter_outdoors"] + json_hc_params.agb_hc_data["AGB_datum"] = paramsdict["AGB_datum"] + json_hc_params.agb_hc_data["AGB_step"] = paramsdict["AGB_step"] + json_hc_params.agb_hc_data["AGB_ang_0"] = paramsdict["AGB_ang_0"] + json_hc_params.agb_hc_data["AGB_kd"] = paramsdict["AGB_kd"] + json_hc_params.agb_old_hc_data["AGBo_datum"] = paramsdict["AGBo_datum"] + json_hc_params.agb_old_hc_data["AGBo_step"] = paramsdict["AGBo_step"] + json_hc_params.agb_old_hc_data["AGBo_ang_0"] = paramsdict["AGBo_ang_0"] + json_hc_params.agb_old_hc_data["AGBo_k1"] = paramsdict["AGBo_k1"] + json_hc_params.agb_old_hc_data["AGBo_k2"] = paramsdict["AGBo_k2"] + json_hc_params.agb_old_hc_data["AGBo_k3"] = paramsdict["AGBo_k3"] + json_hc_params.agb_old_hc_data["AGBo_p1"] = paramsdict["AGBo_p1"] + json_hc_params.aa_hc_data["AA_k0"] = paramsdict["AA_k0"] + json_hc_params.aa_hc_data["AA_ks"] = paramsdict["AA_ks"] + json_hc_params.aa_hc_data["AA_kd"] = paramsdict["AA_kd"] + json_hc_params.aa2_hc_data["AA2_k0"] = paramsdict["AA2_k0"] + json_hc_params.aa2_hc_data["AA2_ks"] = paramsdict["AA2_ks"] + json_hc_params.aa2_hc_data["AA2_f1"] = paramsdict["AA2_f1"] + json_hc_params.aa2_hc_data["AA2_f2"] = paramsdict["AA2_f2"] + json_hc_params.aa2_hc_data["AA2_d0"] = paramsdict["AA2_d0"] + json_hc_params.arw_d_data["arw_d_in"] = paramsdict["arrow_diameter_indoors"] + json_hc_params.arw_d_data["arw_d_out"] = paramsdict["arrow_diameter_outdoors"] + json_hc_params.arw_d_data["AGBo_arw_d"] = paramsdict["AGBo_arw_d"] + json_hc_params.arw_d_data["AA_arw_d_out"] = paramsdict["AA_arw_d_out"] return json_hc_params @@ -224,21 +255,28 @@ def sigma_t( if hc_sys == "AGB": # New AGB (Archery GB) System # Written by Jack Atkinson + hc_data = hc_dat.agb_hc_data sig_t = ( - hc_dat.AGB_ang_0 - * ((1.0 + hc_dat.AGB_step / 100.0) ** (handicap + hc_dat.AGB_datum)) - * np.exp(hc_dat.AGB_kd * dist) + hc_data["AGB_ang_0"] + * ((1.0 + hc_data["AGB_step"] / 100.0) ** (handicap + hc_data["AGB_datum"])) + * np.exp(hc_data["AGB_kd"] * dist) ) elif hc_sys == "AGBold": # Old AGB (Archery GB) System # Written by David Lane (2013) - K = hc_dat.AGBo_k1 * hc_dat.AGBo_k2 ** (handicap + hc_dat.AGBo_k3) - F = 1.0 + K * dist**hc_dat.AGBo_p1 + hc_data = hc_dat.agb_old_hc_data + k_factor = hc_data["AGBo_k1"] * hc_data["AGBo_k2"] ** ( + handicap + hc_data["AGBo_k3"] + ) + f_factor = 1.0 + k_factor * dist ** hc_data["AGBo_p1"] sig_t = ( - hc_dat.AGBo_ang_0 - * ((1.0 + hc_dat.AGBo_step / 100.0) ** (handicap + hc_dat.AGBo_datum)) - * F + hc_data["AGBo_ang_0"] + * ( + (1.0 + hc_data["AGBo_step"] / 100.0) + ** (handicap + hc_data["AGBo_datum"]) + ) + * f_factor ) elif hc_sys == "AA": @@ -248,10 +286,13 @@ def sigma_t( # Required so code elsewhere is unchanged # Factor of 1.0e-3 due to AA algorithm specifying sigma t in milliradians, so # convert to rad + hc_data = hc_dat.aa_hc_data sig_t = ( 1.0e-3 * np.sqrt(2.0) - * np.exp(hc_dat.AA_k0 - hc_dat.AA_ks * handicap + hc_dat.AA_kd * dist) + * np.exp( + hc_data["AA_k0"] - hc_data["AA_ks"] * handicap + hc_data["AA_kd"] * dist + ) ) elif hc_sys == "AA2": @@ -261,11 +302,12 @@ def sigma_t( # Required so code elsewhere is unchanged # Factor of 1.0e-3 due to AA algorithm specifying sigma t in milliradians, so # convert to rad + hc_data = hc_dat.aa2_hc_data sig_t = ( np.sqrt(2.0) * 1.0e-3 - * np.exp(hc_dat.AA2_k0 - hc_dat.AA2_ks * handicap) - * (hc_dat.AA2_f1 + hc_dat.AA2_f2 * dist / hc_dat.AA2_d0) + * np.exp(hc_data["AA2_k0"] - hc_data["AA2_ks"] * handicap) + * (hc_data["AA2_f1"] + hc_data["AA2_f2"] * dist / hc_data["AA2_d0"]) ) else: @@ -312,13 +354,15 @@ def sigma_r( return sig_r -def arrow_score( # pylint: disable=too-many-branches +def arrow_score( target: targets.Target, handicap: Union[float, npt.NDArray[np.float_]], hc_sys: str, hc_dat: HcParams, arw_d: Optional[float] = None, ) -> Union[float, np.float_, npt.NDArray[np.float_]]: + # Six too many branches. Makes sense due to different target faces => disable + # pylint: disable=too-many-branches """ Calculate the average arrow score for a given target and handicap rating. @@ -349,15 +393,15 @@ def arrow_score( # pylint: disable=too-many-branches # otherwise select default from params based on in-/out-doors if arw_d is None: if hc_sys == "AGBold": - arw_rad = hc_dat.AGBo_arw_d / 2.0 + arw_rad = hc_dat.arw_d_data["AGBo_arw_d"] / 2.0 else: if target.indoor: - arw_rad = hc_dat.arw_d_in / 2.0 + arw_rad = hc_dat.arw_d_data["arw_d_in"] / 2.0 else: if hc_sys in ("AA", "AA2"): - arw_rad = hc_dat.AA_arw_d_out / 2.0 + arw_rad = hc_dat.arw_d_data["AA_arw_d_out"] / 2.0 else: - arw_rad = hc_dat.arw_d_out / 2.0 + arw_rad = hc_dat.arw_d_data["arw_d_out"] / 2.0 else: arw_rad = arw_d / 2.0 @@ -499,6 +543,9 @@ def score_for_round( average score for each pass in the round """ + # Two too many arguments. Makes sense at the moment => disable + # Could try and simplify hc_sys and hc_dat in future refactor + # pylint: disable=too-many-arguments pass_score = np.array( [ pass_i.n_arrows diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index 944cfc1..fe8bcb3 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -8,190 +8,207 @@ Routine Listings ---------------- -print_handicap_table handicap_from_score +print_handicap_table +abbreviate +format_row """ from typing import Union, Optional, List import warnings from itertools import chain +import decimal import numpy as np -import numpy.typing as npt +from numpy.typing import NDArray import archeryutils.handicaps.handicap_equations as hc_eq from archeryutils import rounds -FILL = -1000 +FILL = -9999 -def print_handicap_table( - hcs: Union[float, npt.NDArray[np.float_]], +def handicap_from_score( + score: Union[int, float], + rnd: rounds.Round, hc_sys: str, - round_list: List[rounds.Round], hc_dat: hc_eq.HcParams, - arrow_d: Optional[float] = None, - round_scores_up: bool = True, - clean_gaps: bool = True, - printout: bool = True, - filename: Optional[str] = None, - csvfile: Optional[str] = None, - int_prec: Optional[bool] = False, -) -> None: + arw_d: Optional[float] = None, + int_prec: bool = False, +) -> Union[int, float]: + # One too many arguments. Makes sense at the moment => disable + # Could try and simplify hc_sys and hc_dat in future refactor + # pylint: disable=too-many-arguments """ - Generate a handicap table to screen and/or file. + Calculate the handicap of a given score on a given round using root-finding. Parameters ---------- - hcs : ndarray or float - handicap value(s) to calculate score(s) for - hc_sys : string + score : float + score achieved on the round + rnd : rounds.Round + a rounds.Round class object that was shot + hc_sys : str identifier for the handicap system - round_list : list of rounds.Round - List of Round classes to calculate scores for hc_dat : handicaps.handicap_equations.HcParams dataclass containing parameters for handicap equations - arrow_d : float + arw_d : float arrow diameter in [metres] default = None - round_scores_up : bool - round scores up to nearest integer? default = True - clean_gaps : bool - Remove all instances of a score except the first? default = False - printout : bool - Print to screen? default = True - filename : str - filepath to save table to. default = None - csvfile : str - csv filepath to save to. default = None int_prec : bool - display results as integers? default = False, with decimal to 2dp + display results as integers? default = False, with decimal to 2dp accuracy from + rootfinder Returns ------- - None + hc: int or float + Handicap. Has type int if int_prec is True, and otherwise has type false. """ - # Abbreviations to replace headings with in Handicap Tables to keep concise - abbreviations = { - "Compound": "C", - "Recurve": "R", - "Triple": "Tr", - "Centre": "C", - "Portsmouth": "Ports", - "Worcester": "Worc", - "Short": "St", - "Long": "Lg", - "Small": "Sm", - "Gents": "G", - "Ladies": "L", - } - - if not isinstance(hcs, np.ndarray): - if isinstance(hcs, list): - hcs = np.array(hcs) - elif isinstance(hcs, (float, int)): - hcs = np.array([hcs]) - else: - raise TypeError("Expected float or ndarray for hcs.") - - table = np.empty([len(hcs), len(round_list) + 1]) - table[:, 0] = hcs.copy() - for i, round_i in enumerate(round_list): - table[:, i + 1], _ = hc_eq.score_for_round( - round_i, hcs, hc_sys, hc_dat, arrow_d, round_score_up=round_scores_up + # Check we have a valid score + max_score = rnd.max_score() + if score > max_score: + raise ValueError( + f"The score of {score} provided is greater than the maximum of {max_score} " + f"for a {rnd.name}." + ) + if score <= 0.0: + raise ValueError( + f"The score of {score} provided is less than or equal to zero so cannot " + "have a handicap." ) - # If rounding scores up we don't want to display trailing zeros, so ensure int_prec - if round_scores_up: - int_prec = True - - if int_prec: - table = table.astype(int) + if score == max_score: + # Deal with max score before root finding + return get_max_score_handicap(rnd, hc_sys, hc_dat, arw_d, int_prec) - if clean_gaps: - # TODO: This assumes scores are running highest to lowest. - # AA and AA2 will only work if hcs passed in reverse order (large to small) - for irow, row in enumerate(table[:-1, :]): - for jscore in range(len(row)): - if table[irow, jscore] == table[irow + 1, jscore]: - if int_prec: - table[irow, jscore] = FILL - else: - table[irow, jscore] = np.nan + handicap = rootfind_score_handicap(score, rnd, hc_sys, hc_dat, arw_d) - # Write to CSV + # Force integer precision if required. + if int_prec: + if hc_sys in ("AA", "AA2"): + handicap = np.floor(handicap) + else: + handicap = np.ceil(handicap) - if csvfile is not None: - print("Writing handicap table to csv...", end="") - np.savetxt( - csvfile, - table, - delimiter=", ", - header=f"handicap, {','.join([round_i.name for round_i in round_list])}'", + sc_int, _ = hc_eq.score_for_round( + rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=True ) - print("Done.") - # Write to terminal/string - # Return early if this isn't required - if filename is None and not printout: - return + # Check that you can't get the same score from a larger handicap when + # working in integers + min_h_flag = False + if hc_sys in ("AA", "AA2"): + hstep = -1.0 + else: + hstep = 1.0 + while not min_h_flag: + handicap += hstep + sc_int, _ = hc_eq.score_for_round( + rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=True + ) + if sc_int < score: + handicap -= hstep # undo the iteration that caused flag to raise + min_h_flag = True - # To ensure both terminal and file output are the same, create a single string to - # be used in either case + return handicap - def abbreviate(name: str) -> str: - return " ".join(abbreviations.get(i, i) for i in name.split()) - round_names = [abbreviate(r.name) for r in round_list] - output_header = "".join(name.rjust(14) for name in chain(["Handicap"], round_names)) +def get_max_score_handicap( + rnd: rounds.Round, + hc_sys: str, + hc_dat: hc_eq.HcParams, + arw_d: Optional[float], + int_prec: bool = False, +) -> float: + """ + Get handicap for maximum score on a round. - def format_row(row: npt.NDArray[Union[np.float_, np.int_]]) -> str: - if int_prec: - return "".join("".rjust(14) if x == FILL else f"{x:14d}" for x in row) - return "".join("".rjust(14) if np.isnan(x) else f"{x:14.8f}" for x in row) + Start high and drop down until no longer rounding to max score. + i.e. >= max_score - 1.0 for AGB, and >= max_score - 0.5 for AA, AA2, and AGBold. - output_rows = [format_row(row) for row in table] - output_str = "\n".join(chain([output_header], output_rows)) + Parameters + ---------- + rnd : rounds.Round + round being used + hc_sys : str + identifier for the handicap system + hc_dat : handicaps.handicap_equations.HcParams + dataclass containing parameters for handicap equations + arw_d : float + arrow diameter in [metres] default = None + int_prec : bool + display results as integers? default = False - if printout: - print(output_str) + Returns + ------- + handicap : float + appropriate handicap for this maximum score + """ + max_score = rnd.max_score() - if filename is not None: - print("Writing handicap table to file...", end="") - with open(filename, "w") as f: - f.write(output_str) - print("Done.") + if hc_sys in ("AA", "AA2"): + handicap = 175.0 + delta_hc = -0.01 + else: + handicap = -75.0 + delta_hc = 0.01 + # Set rounding limit + if hc_sys in ("AA", "AA2", "AGBold"): + round_lim = 0.5 + else: + round_lim = 1.0 + + s_max, _ = hc_eq.score_for_round( + rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=False + ) + # Work down to where we would round or ceil to max score + while s_max > max_score - round_lim: + handicap = handicap + delta_hc + s_max, _ = hc_eq.score_for_round( + rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=False + ) + handicap = handicap - delta_hc # Undo final iteration that overshoots + if int_prec: + if hc_sys in ("AA", "AA2"): + handicap = np.ceil(handicap) + else: + handicap = np.floor(handicap) + else: + warnings.warn( + "Handicap requested for maximum score without integer precision.\n" + "Value returned will be first handiucap that achieves this score.\n" + "This could cause issues if you are not working in integers.", + UserWarning, + ) + return handicap -def handicap_from_score( - score: Union[int, float], + +def rootfind_score_handicap( + score: float, rnd: rounds.Round, hc_sys: str, hc_dat: hc_eq.HcParams, - arw_d: Optional[float] = None, - int_prec: bool = False, -) -> Union[int, float]: + arw_d: Optional[float], +) -> float: """ - Calculate the handicap of a given score on a given round using root-finding. + Get handicap for general score on a round through rootfinding algorithm. Parameters ---------- score : float - score achieved on the round + score to get handicap for rnd : rounds.Round - a rounds.Round class object that was shot + round being used hc_sys : str identifier for the handicap system hc_dat : handicaps.handicap_equations.HcParams dataclass containing parameters for handicap equations arw_d : float arrow diameter in [metres] default = None - int_prec : bool - display results as integers? default = False, with decimal to 2dp accuracy from - rootfinder Returns ------- - hc: int or float - Handicap. Has type int if int_prec is True, and otherwise has type false. + handicap : float + appropriate accurate handicap for this score References ---------- @@ -199,92 +216,23 @@ def handicap_from_score( - https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.brentq.html - https://github.com/scipy/scipy/blob/dde39b7cc7dc231cec6bf5d882c8a8b5f40e73ad/ scipy/optimize/Zeros/brentq.c - """ - max_score = rnd.max_score() - if score > max_score: - raise ValueError( - f"The score of {score} provided is greater than the maximum of {max_score} " - f"for a {rnd.name}." - ) - if score <= 0.0: - raise ValueError( - f"The score of {score} provided is less than or equal to zero so cannot " - "have a handicap." - ) - - if score == max_score: - # Deal with max score before root finding - # start high and drop down until no longer rounding to max score - # (i.e. >= max_score - 1.0 for AGB, and >= max_score - 0.5 for AA, AA2, and AGBold) - if hc_sys in ("AA", "AA2"): - hc = 175.0 - dhc = -0.01 - else: - hc = -75.0 - dhc = 0.01 - # Set rounding limit - if hc_sys in ("AA", "AA2", "AGBold"): - round_lim = 0.5 - else: - round_lim = 1.0 - - s_max, _ = hc_eq.score_for_round( - rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=False - ) - # Work down to where we would round or ceil to max score - while s_max > max_score - round_lim: - hc = hc + dhc - s_max, _ = hc_eq.score_for_round( - rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=False - ) - hc = hc - dhc # Undo final iteration that overshoots - if int_prec: - if hc_sys in ("AA", "AA2"): - hc = np.ceil(hc) - else: - hc = np.floor(hc) - else: - warnings.warn( - "Handicap requested for maximum score without integer precision.\n" - "Value returned will be first handiucap that achieves this score.\n" - "This could cause issues if you are not working in integers.", - UserWarning, - ) - return hc - - # ROOT FINDING for general case (not max score) - def f_root( - h: float, - scr: Union[int, float], - rd: rounds.Round, - sys: str, - hc_data: hc_eq.HcParams, - arw_dia: Optional[float], - ) -> float: - val, _ = hc_eq.score_for_round( - rd, h, sys, hc_data, arw_dia, round_score_up=False - ) - # Ensure we return float, not np.ndarray - # These 9 lines replace `return val-scr` so as to satisfy mypy --strict. - # Should never be triggered in reality as h is type float. - if isinstance(val, np.float_): - val = val.item() - if isinstance(val, float): - return val - scr - raise TypeError( - f"f_root is attempting to return a {type(val)} type but expected float. " - f"Was it passed an array of handicaps?" - ) + """ + # The rootfinding algorithm here raises pylint errors for + # too many statements (64/50), branches (17/12), and variables(23/15). + # However, it is a single enclosed algorithm => disable + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements if hc_sys in ("AA", "AA2"): - x = [-250.0, 175.0] + x_init = [-250.0, 175.0] else: - x = [-75.0, 300.0] + x_init = [-75.0, 300.0] - f = [ - f_root(x[0], score, rnd, hc_sys, hc_dat, arw_d), - f_root(x[1], score, rnd, hc_sys, hc_dat, arw_d), + f_init = [ + f_root(x_init[0], score, rnd, hc_sys, hc_dat, arw_d), + f_root(x_init[1], score, rnd, hc_sys, hc_dat, arw_d), ] xtol = 1.0e-16 rtol = 0.00 @@ -296,16 +244,16 @@ def f_root( dblk = 0.0 stry = 0.0 - if abs(f[1]) <= f[0]: - xcur = x[1] - xpre = x[0] - fcur = f[1] - fpre = f[0] + if abs(f_init[1]) <= f_init[0]: + xcur = x_init[1] + xpre = x_init[0] + fcur = f_init[1] + fpre = f_init[0] else: - xpre = x[1] - xcur = x[0] - fpre = f[1] - fcur = f[0] + xpre = x_init[1] + xcur = x_init[0] + fpre = f_init[1] + fcur = f_init[0] for _ in range(25): if (fpre != 0.0) and (fcur != 0.0) and (np.sign(fpre) != np.sign(fcur)): @@ -328,7 +276,7 @@ def f_root( sbis = (xblk - xcur) / 2.0 if (fcur == 0.0) or (abs(sbis) < delta): - hc = xcur + handicap = xcur break if (abs(spre) > delta) and (abs(fcur) < abs(fpre)): @@ -362,33 +310,384 @@ def f_root( xcur -= delta fcur = f_root(xcur, score, rnd, hc_sys, hc_dat, arw_d) - hc = xcur + handicap = xcur + + return handicap + + +def f_root( + hc_est: float, + score_est: Union[int, float], + round_est: rounds.Round, + sys: str, + hc_data: hc_eq.HcParams, + arw_dia: Optional[float], +) -> float: + """ + Return error between predicted score and desired score. + + Parameters + ---------- + hc_est : float + current estimate of handicap + score_est : float + current estimate of score based on hc_est + round_est : rounds.Round + round being used + sys : str + identifier for the handicap system + hc_data : handicaps.handicap_equations.HcParams + dataclass containing parameters for handicap equations + arw_dia : float + arrow diameter in [metres] default = None + + Returns + ------- + val-score_est : float + difference between desired value and score estimate + """ + # One too many arguments. Makes sense at the moment => disable + # Could try and simplify hc_sys and hc_dat in future refactor + # pylint: disable=too-many-arguments + + val, _ = hc_eq.score_for_round( + round_est, hc_est, sys, hc_data, arw_dia, round_score_up=False + ) + + # Ensure we return float, not np.ndarray + # These 8 lines replace `return val-scr` so as to satisfy mypy --strict. + # Should never be triggered in reality as h is type float. + if isinstance(val, np.float_): + val = val.item() + if isinstance(val, float): + return val - score_est + raise TypeError( + f"f_root is attempting to return a {type(val)} type but expected float. " + f"Was it passed an array of handicaps?" + ) + + +def print_handicap_table( + hcs: Union[float, NDArray[np.float_]], + hc_sys: str, + round_list: List[rounds.Round], + hc_dat: hc_eq.HcParams, + arrow_d: Optional[float] = None, + round_scores_up: bool = True, + clean_gaps: bool = True, + printout: bool = True, + filename: Optional[str] = None, + csvfile: Optional[str] = None, + int_prec: bool = False, +) -> None: + """ + Generate a handicap table to screen and/or file. + + Parameters + ---------- + hcs : ndarray or float + handicap value(s) to calculate score(s) for + hc_sys : string + identifier for the handicap system + round_list : list of rounds.Round + List of Round classes to calculate scores for + hc_dat : handicaps.handicap_equations.HcParams + dataclass containing parameters for handicap equations + arrow_d : float + arrow diameter in [metres] default = None + round_scores_up : bool + round scores up to nearest integer? default = True + clean_gaps : bool + Remove all instances of a score except the first? default = False + printout : bool + Print to screen? default = True + filename : str + filepath to save table to. default = None + csvfile : str + csv filepath to save to. default = None + int_prec : bool + display results as integers? default = False, with decimal to 2dp + + Returns + ------- + None + """ + # Cannot see any other way to handle the options required here => ignore + # pylint: disable=too-many-arguments + # Knock-on effect is too many local variables raised => ignore + + # Sanitise inputs + hcs = check_print_table_inputs(hcs, round_list, clean_gaps) + + # Set up empty handicap table and populate + table: NDArray[Union[np.float_, np.int_]] = np.empty( + [len(hcs), len(round_list) + 1] + ) + table[:, 0] = hcs.copy() + for i, round_i in enumerate(round_list): + table[:, i + 1], _ = hc_eq.score_for_round( + round_i, + hcs, + hc_sys, + hc_dat, + arrow_d, + round_score_up=round_scores_up, + ) + + # If rounding scores up we don't want to display trailing zeros, so ensure int_prec + if round_scores_up and not int_prec: + warnings.warn( + "Handicap Table incompatible options.\n" + "Requesting scores to be rounded up but without integer precision.\n" + "Setting integer precision (`int_prec`) as true.", + UserWarning, + ) + int_prec = True - # Force integer precision if required. if int_prec: - if hc_sys in ("AA", "AA2"): - hc = np.floor(hc) - else: - hc = np.ceil(hc) + table[:, 1:] = table[:, 1:].astype(int) - sc, _ = hc_eq.score_for_round( - rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=True + if clean_gaps: + table = clean_repeated(table, int_prec, hc_sys)[1:-1, :] + + # Write to CSV + if csvfile is not None: + print("Writing handicap table to csv...", end="") + np.savetxt( + csvfile, + table, + delimiter=", ", + header=f"handicap, {','.join([round_i.name for round_i in round_list])}'", ) + print("Done.") - # Check that you can't get the same score from a larger handicap when - # working in integers - min_h_flag = False - if hc_sys in ("AA", "AA2"): - hstep = -1.0 + # Write to terminal/string + # Return early if this isn't required + if filename is None and not printout: + return + + # Generate string to output to file or display + output_str = table_as_str(round_list, hcs, table, int_prec) + + if printout: + print(output_str) + + if filename is not None: + table_to_file(filename, output_str) + + +def check_print_table_inputs( + hcs: Union[float, NDArray[np.float_]], + round_list: list[rounds.Round], + clean_gaps: bool = True, +) -> NDArray[np.float_]: + """ + Sanitise and format inputs to handicap printing code. + + Parameters + ---------- + hcs : ndarray or float + handicap value(s) to calculate score(s) for + round_list : list of rounds.Round + List of Round classes to calculate scores for + clean_gaps : bool + Remove all instances of a score except the first? default = False + + Returns + ------- + hcs : ndarray + handicaps prepared for use in table printing routines + """ + if not isinstance(hcs, np.ndarray): + if isinstance(hcs, list): + hcs = np.array(hcs) + elif isinstance(hcs, (float, int)): + hcs = np.array([hcs]) else: - hstep = 1.0 - while not min_h_flag: - hc += hstep - sc, _ = hc_eq.score_for_round( - rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=True - ) - if sc < score: - hc -= hstep # undo the iteration that caused the flag to raise - min_h_flag = True + raise TypeError("Expected float or ndarray for hcs.") + + if len(round_list) == 0: + raise ValueError("No rounds provided for handicap table.") + + # if cleaning gaps add row to top/bottom of table to catch out of range repeats + if clean_gaps: + delta_s = hcs[1] - hcs[0] if len(hcs) > 1 else 1.0 + delta_e = hcs[-1] - hcs[-2] if len(hcs) > 1 else 1.0 + hcs = np.insert(hcs, 0, hcs[0] - delta_s) + hcs = np.append(hcs, hcs[-1] + delta_e) + + return hcs + + +def clean_repeated( + table: NDArray[Union[np.float_, np.int_]], + int_prec: bool = False, + hc_sys: str = "AGB", +) -> NDArray[Union[np.float_, np.int_]]: + """ + Keep only the first instance of a score in the handicap tables. + + Parameters + ---------- + table : np.ndarray + handicap table of scores + int_prec : bool + return integers, not floats? + hc_sys : str + handicap system used - assume AGB (high -> low) unless specified + + Returns + ------- + table : np.ndarray + handicap table of scores with repetitions filtered + """ + # NB: This assumes scores are running highest to lowest. + # :. Flip AA and AA2 tables before operating. + + if hc_sys in ("AA", "AA2"): + table = np.flip(table, axis=0) + + for irow, row in enumerate(table[:-1, :]): + for jscore in range(len(row)): + if table[irow, jscore] == table[irow + 1, jscore]: + if int_prec: + table[irow, jscore] = FILL + else: + table[irow, jscore] = np.nan + + if hc_sys in ("AA", "AA2"): + table = np.flip(table, axis=0) + + return table + + +def abbreviate(name: str) -> str: + """ + Replace headings within Handicap Tables with abbreviations to keep concise. + + NB: This function only works with names containing space-separated words. + + Parameters + ---------- + name : str + full, long round name as appears currently + + Returns + ------- + shortname : str + abbreviated round name to replace with + """ + abbreviations = { + "Compound": "C", + "Recurve": "R", + "Triple": "Tr", + "Centre": "C", + "Portsmouth": "Ports", + "Worcester": "Worc", + "Short": "St", + "Long": "Lg", + "Small": "Sm", + "Gents": "G", + "Ladies": "L", + } + + return " ".join(abbreviations.get(i, i) for i in name.split()) + + +def table_as_str( + round_list: List[rounds.Round], + hcs: NDArray[Union[np.float_, np.int_]], + table: NDArray[Union[np.float_, np.int_]], + int_prec: bool = False, +) -> str: + """ + Convert the handicap table to a string. + + Parameters + ---------- + round_list : list of rounds.Round + List of Round classes to calculate scores for + hcs : ndarray + handicap value(s) to calculate score(s) for + table : ndarray + handicap table as array + int_prec : bool + return integers, not floats? + + Returns + ------- + output_str : str + Handicap table formatted as a string + """ + # To ensure both terminal and file output are the same, create a single string + round_names = [abbreviate(r.name) for r in round_list] + output_header = "".join(name.rjust(14) for name in chain(["Handicap"], round_names)) + # Auto-set the number of decimal places to display handicaps to + if np.max(hcs % 1.0) <= 0.0: + hc_dp = 0 + else: + hc_dp = np.max( + np.abs([decimal.Decimal(str(d)).as_tuple().exponent for d in hcs]) + ) + # Format each row appropriately + output_rows = [format_row(row, hc_dp, int_prec) for row in table] + output_str = "\n".join(chain([output_header], output_rows)) + + return output_str + - return hc +def format_row( + row: NDArray[Union[np.float_, np.int_]], + hc_dp: int = 0, + int_prec: bool = False, +) -> str: + """ + Fornat appearance of handicap table row to look nice. + + Parameters + ---------- + row : NDArray + numpy array of table row + hc_dp : int + handicap decimal places + int_prec : bool + return integers, not floats? + + Returns + ------- + formatted_row : str + pretty string based on input array data + """ + if hc_dp == 0: + handicap_str = f"{int(row[0]):14d}" + else: + handicap_str = f"{row[0]:14.{hc_dp}f}" + + if int_prec: + return handicap_str + "".join( + "".rjust(14) if x == FILL else f"{int(x):14d}" for x in row[1:] + ) + return handicap_str + "".join( + "".rjust(14) if np.isnan(x) else f"{x:14.8f}" for x in row[1:] + ) + + +def table_to_file(filename: str, output_str: str) -> None: + """ + Fornat appearance of handicap table row to look nice. + + Parameters + ---------- + filename : str + name of file to save handicap table to + output_str : str + handicap table as string to save to file + + Returns + ------- + None + """ + print("Writing handicap table to file...", end="") + with open(filename, "w", encoding="utf-8") as table_file: + table_file.write(output_str) + print("Done.") diff --git a/archeryutils/handicaps/hc_sys_params.json b/archeryutils/handicaps/hc_sys_params.json index 52fc90d..b595222 100644 --- a/archeryutils/handicaps/hc_sys_params.json +++ b/archeryutils/handicaps/hc_sys_params.json @@ -16,13 +16,13 @@ "AA_k0": 2.37, "AA_ks": 0.027, "AA_kd": 0.004, - + "AA2_k0": 2.57, "AA2_ks": 0.027, "AA2_f1": 0.815, "AA2_f2": 0.185, "AA2_d0": 50.0, - + "AA_arw_d_out": 5.0e-3, "arrow_diameter_indoors": 9.3e-3, "arrow_diameter_outdoors": 5.5e-3 diff --git a/archeryutils/handicaps/tests/test_handicap_tables.py b/archeryutils/handicaps/tests/test_handicap_tables.py new file mode 100644 index 0000000..f26e0e3 --- /dev/null +++ b/archeryutils/handicaps/tests/test_handicap_tables.py @@ -0,0 +1,283 @@ +"""Tests for handicap table printing""" +# Due to defining some rounds to use in testing duplicate code may trigger. +# => disable for handicap tests +# pylint: disable=duplicate-code + +from typing import Union +import numpy as np +from numpy.typing import NDArray +import pytest + +import archeryutils.handicaps.handicap_equations as hc_eq +import archeryutils.handicaps.handicap_functions as hc_func +from archeryutils.rounds import Round, Pass + + +hc_params = hc_eq.HcParams() + +# Define rounds used in these functions +york = Round( + "York", + [ + Pass(72, 1.22, "5_zone", 100, "yard", False), + Pass(48, 1.22, "5_zone", 80, "yard", False), + Pass(24, 1.22, "5_zone", 60, "yard", False), + ], +) +hereford = Round( + "Hereford", + [ + Pass(72, 1.22, "5_zone", 80, "yard", False), + Pass(48, 1.22, "5_zone", 60, "yard", False), + Pass(24, 1.22, "5_zone", 50, "yard", False), + ], +) +metric122_30 = Round( + "Metric 122-30", + [ + Pass(36, 1.22, "10_zone", 30, "metre", False), + Pass(36, 1.22, "10_zone", 30, "metre", False), + ], +) + + +class TestHandicapTable: + """ + Class to test the handicap table functionalities of handicap_functions. + + Methods + ------- + + References + ---------- + """ + + @pytest.mark.parametrize( + "input_arr,hc_dp,int_prec,expected", + [ + ( + np.array([1, 20.0, 23.0]), + 0, + True, + " 1 20 23", + ), + ( + np.array([1, 20.0, 23.0]), + 2, + True, + " 1.00 20 23", + ), + ( + np.array([1, 20.0, 23.0]), + 3, + False, + " 1.000 20.00000000 23.00000000", + ), + ], + ) + def test_format_row( + self, + input_arr: NDArray[Union[np.int_, np.float_]], + hc_dp: int, + int_prec: bool, + expected: str, + ) -> None: + """ + Check that format_row returns expected results for float and int. + """ + assert hc_func.format_row(input_arr, hc_dp, int_prec) == expected + + @pytest.mark.parametrize( + "hcs,table,int_prec,expected", + [ + ( + # Check int_prec true + np.array([1, 2, 3]), + np.array([[1, 20.0, 23.0], [2, 20.0, 23.0], [3, 20.0, 23.0]]), + True, + " Handicap York Hereford\n" + + " 1 20 23\n" + + " 2 20 23\n" + + " 3 20 23", + ), + ( + # Check int_prec false + np.array([1, 2, 3]), + np.array([[1, 20.0, 23.0], [2, 20.0, 23.0], [3, 20.0, 23.0]]), + False, + " Handicap York Hereford\n" + + " 1 20.00000000 23.00000000\n" + + " 2 20.00000000 23.00000000\n" + + " 3 20.00000000 23.00000000", + ), + ( + # Check handicap float integers are converted to ints + np.array([1.0, 2.0, 3.0]), + np.array([[1.0, 20.0, 23.0], [2.0, 20.0, 23.0], [3.0, 20.0, 23.0]]), + True, + " Handicap York Hereford\n" + + " 1 20 23\n" + + " 2 20 23\n" + + " 3 20 23", + ), + ( + # Check handicap dp are allocated OK + np.array([1.20, 2.0, 3.0]), + np.array([[1.20, 20.0, 23.0], [2.0, 20.0, 23.0], [3.0, 20.0, 23.0]]), + True, + " Handicap York Hereford\n" + + " 1.2 20 23\n" + + " 2.0 20 23\n" + + " 3.0 20 23", + ), + ], + ) + def test_table_as_str( + self, + hcs: NDArray[Union[np.int_, np.float_]], + table: NDArray[Union[np.int_, np.float_]], + int_prec: bool, + expected: str, + ) -> None: + """ + Check that format_row returns expected results for float and int. + """ + print(hc_func.table_as_str([york, hereford], hcs, table, int_prec)) + print(expected) + assert hc_func.table_as_str([york, hereford], hcs, table, int_prec) == expected + + @pytest.mark.parametrize( + "input_str,expected", + [ + ("Compound", "C"), + ("Recurve Triple Portsmouth", "R Tr Ports"), + ("Recurve Triple Portsmouth", "R Tr Ports"), + ("Short Gents Worcester", "St G Worc"), + ], + ) + def test_abbreviate( + self, + input_str: str, + expected: str, + ) -> None: + """ + Check that abbreviate returns expected results. + """ + assert hc_func.abbreviate(input_str) == expected + + @pytest.mark.parametrize( + "hcs,clean_gaps,expected", + [ + ( + # 'Correct' inputs + np.array([1.0, 2.0, 3.0]), + False, + np.array([1.0, 2.0, 3.0]), + ), + ( + # List inputs + [1.0, 2.0, 3.0], + False, + np.array([1.0, 2.0, 3.0]), + ), + ( + # Clean gaps True + np.array([1.0, 2.0, 3.0]), + True, + np.array([0.0, 1.0, 2.0, 3.0, 4.0]), + ), + ( + # Clean gaps True + np.array([1.75, 2.0, 2.5]), + True, + np.array([1.5, 1.75, 2.0, 2.5, 3.0]), + ), + ( + # Single float + 1.0, + False, + np.array([1.0]), + ), + ( + # Single float clean gaps True + 1.5, + True, + np.array([0.5, 1.5, 2.5]), + ), + ], + ) + def test_check_print_table_inputs( + self, + hcs: Union[float, NDArray[np.float_]], + clean_gaps: bool, + expected: Union[float, NDArray[np.float_]], + ) -> None: + """ + Check that inputs processed appropriately. + """ + np.testing.assert_allclose( + hc_func.check_print_table_inputs(hcs, [york, metric122_30], clean_gaps), + expected, + ) + + def test_check_print_table_inputs_invalid_rounds( + self, + ) -> None: + """ + Check that empty rounds list triggers error. + """ + with pytest.raises( + ValueError, + match=("No rounds provided for handicap table."), + ): + hc_func.check_print_table_inputs(1.0, [], True) + + @pytest.mark.parametrize( + "input_table,int_prec,sys,expected", + [ + ( + np.array([[0, 11, 12, 13], [1, 10, 12, 12]]), + True, + "AGB", + np.array([[0, 11, -9999, 13], [1, 10, 12, 12]]), + ), + ( + np.array([[0, 13], [5, 12], [10, 12], [15, 11]]), + True, + "AGB", + np.array([[0, 13], [5, -9999], [10, 12], [15, 11]]), + ), + ( + np.array([[4, 13], [3, 12], [2, 12], [1, 11]]), + True, + "AA", + np.array([[4, 13], [3, 12], [2, -9999], [1, 11]]), + ), + ( + np.array([[0.0, 11.0, 12.0, 13.0], [1.0, 10.0, 12.0, 12.0]]), + False, + "AGB", + np.array([[0.0, 11.0, np.nan, 13.0], [1.0, 10.0, 12.0, 12.0]]), + ), + ( + np.array([[0.0, 11.5, 12.5, 13.5], [1.0, 11.5, 12.0, 13.5]]), + False, + "AGB", + np.array([[0.0, np.nan, 12.5, np.nan], [1.0, 11.5, 12.0, 13.5]]), + ), + ], + ) + def test_clean_repeated( + self, + input_table: NDArray[Union[np.int_, np.float_]], + int_prec: bool, + sys: str, + expected: NDArray[Union[np.int_, np.float_]], + ) -> None: + """ + Check that abbreviate returns expected results. + """ + print(hc_func.clean_repeated(input_table, int_prec, sys)) + np.testing.assert_allclose( + hc_func.clean_repeated(input_table, int_prec, sys), expected + ) diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py index 9408ca0..73ff12c 100644 --- a/archeryutils/handicaps/tests/test_handicaps.py +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -1,7 +1,12 @@ """Tests for handicap equations and functions""" +# Due to defining some rounds to use in testing duplicate code may trigger. +# => disable for handicap tests +# pylint: disable=duplicate-code + from typing import Tuple, List import numpy as np import pytest +from pytest_mock import MockerFixture import archeryutils.handicaps.handicap_equations as hc_eq import archeryutils.handicaps.handicap_functions as hc_func @@ -75,6 +80,67 @@ ) +@pytest.fixture +def mocker_hcparams_json(mocker: MockerFixture) -> None: + """ + Override open with a fake HCParams json file. + """ + mocked_json_file = mocker.mock_open( + read_data="""{ + "AGB_datum": 1.0, + "AGB_step": 1.0, + "AGB_ang_0": 1.0, + "AGB_kd": 1.0, + + "AGBo_datum": 1.0, + "AGBo_step": 1.0, + "AGBo_ang_0": 1.0, + "AGBo_k1": 1.0, + "AGBo_k2": 1.0, + "AGBo_k3": 1.0, + "AGBo_p1": 1.0, + "AGBo_arw_d": 3.0, + + "AA_k0": 2.0, + "AA_ks": 2.0, + "AA_kd": 2.0, + + "AA2_k0": 2.0, + "AA2_ks": 2.0, + "AA2_f1": 2.0, + "AA2_f2": 2.0, + "AA2_d0": 2.0, + + "AA_arw_d_out": 3.0, + "arrow_diameter_indoors": 3.0, + "arrow_diameter_outdoors": 3.0 + }""" + ) + mocker.patch("builtins.open", mocked_json_file) + + +def test_load_json_hcparams(mocker_hcparams_json: MockerFixture) -> None: + """ + Test loading of HcParams from file using mock. + """ + # pylint cannot understand mocker as variable name used from fixture => disable + # pylint: disable=redefined-outer-name + # pylint: disable=unused-argument + handicap_params = hc_eq.HcParams() + handicap_params = handicap_params.load_json_params("fakefile.json") + + for val in handicap_params.agb_hc_data.values(): + assert val == 1.0 + for val in handicap_params.agb_old_hc_data.values(): + assert val == 1.0 + for val in handicap_params.aa_hc_data.values(): + assert val == 2.0 + for val in handicap_params.aa2_hc_data.values(): + assert val == 2.0 + for val in handicap_params.arw_d_data.values(): + assert val == 3.0 + + class TestSigmaT: """ Class to test the sigma_t() function of handicap_equations. @@ -509,6 +575,41 @@ class TestHandicapFromScore: Currently no easily available data """ + @pytest.mark.parametrize( + "testround,hc_system,int_prec,handicap_expected", + [ + (metric122_30, "AGB", True, 11), + (metric122_30, "AA", True, 107), + # (metric122_30, "AA2", True, 107), + # ------------------------------ + (western, "AGB", False, 9.89), + (western, "AGBold", True, 6), + # ------------------------------ + (vegas300, "AGB", True, 3), + (vegas300, "AA", False, 118.38), + # (vegas300, "AA2", True, 119), + ], + ) + def test_get_max_score_handicap( + self, + testround: Round, + hc_system: str, + int_prec: bool, + handicap_expected: float, + ) -> None: + """ + Check that get_max_score_handicap() returns expected handicap. + """ + handicap = hc_func.get_max_score_handicap( + testround, + hc_system, + hc_params, + None, + int_prec, + ) + + assert pytest.approx(handicap) == handicap_expected + def test_score_over_max(self) -> None: """ Check that handicap_from_score() returns error value for too large score. diff --git a/archeryutils/load_rounds.py b/archeryutils/load_rounds.py index d7cae07..a01d709 100644 --- a/archeryutils/load_rounds.py +++ b/archeryutils/load_rounds.py @@ -94,7 +94,6 @@ def read_json_to_round_dict(json_filelist: Union[str, List[str]]) -> Dict[str, R "Defaulting to 'custom'." ) round_i["body"] = "custom" - # TODO: Could do sanitisation here e.g. AGB vs agb etc or trust user... # Assign round family if "family" not in round_i: diff --git a/archeryutils/rounds.py b/archeryutils/rounds.py index 68188be..e452d00 100644 --- a/archeryutils/rounds.py +++ b/archeryutils/rounds.py @@ -33,6 +33,9 @@ class Pass: Returns the maximum score for Pass """ + # Two too many arguments, but logically this structure makes sense => disable + # pylint: disable=too-many-arguments + def __init__( self, n_arrows: int, @@ -111,6 +114,9 @@ class Round: """ + # Two too many arguments, but logically this structure makes sense => disable + # pylint: disable=too-many-arguments + def __init__( self, name: str, diff --git a/archeryutils/targets.py b/archeryutils/targets.py index f8d986f..c469276 100644 --- a/archeryutils/targets.py +++ b/archeryutils/targets.py @@ -24,8 +24,13 @@ class Target: ------- max_score() Returns the maximum score ring value + min_score() + Returns the minimum score ring value (excluding miss) """ + # One too many arguments, but logically this structure makes sense => disable + # pylint: disable=too-many-arguments + def __init__( self, diameter: float, @@ -128,3 +133,43 @@ def max_score(self) -> float: raise ValueError( f"Target face '{self.scoring_system}' has no specified maximum score." ) + + def min_score(self) -> float: + """ + Return the minimum numerical score possible on this target (excluding miss/0). + + Returns + ------- + min_score : float + minimum score possible on this target face + """ + if self.scoring_system in ( + "5_zone", + "10_zone", + "10_zone_compound", + "WA_field", + "IFAA_field_expert", + "Worcester", + ): + return 1.0 + if self.scoring_system in ( + "10_zone_6_ring", + "10_zone_6_ring_compound", + ): + return 5.0 + if self.scoring_system in ( + "10_zone_5_ring", + "10_zone_5_ring_compound", + ): + return 6.0 + if self.scoring_system in ("Worcester_2_ring",): + return 4.0 + if self.scoring_system in ("IFAA_field",): + return 3.0 + if self.scoring_system in ("Beiter_hit_miss"): + # For Beiter options are hit and miss, so return 0 here + return 0.0 + # NB: Should be hard (but not impossible) to get here without catching earlier. + raise ValueError( + f"Target face '{self.scoring_system}' has no specified minimum score." + ) diff --git a/archeryutils/tests/test_targets.py b/archeryutils/tests/test_targets.py index 9a580c5..3069b64 100644 --- a/archeryutils/tests/test_targets.py +++ b/archeryutils/tests/test_targets.py @@ -76,14 +76,14 @@ def test_yard_to_m_conversion(self) -> None: ) def test_max_score(self, face_type: str, max_score_expected: float) -> None: """ - Check that Target() returns correct distance in metres when yards provided. + Check that Target() returns correct max score. """ target = Target(1.22, face_type, 50, "metre", False) assert target.max_score() == max_score_expected def test_max_score_invalid_face_type(self) -> None: """ - Check that Target() returns correct distance in metres when yards provided. + Check that Target() raises error for invalid face. """ with pytest.raises( ValueError, @@ -93,3 +93,40 @@ def test_max_score_invalid_face_type(self) -> None: # Requires manual resetting of scoring system to get this error. target.scoring_system = "InvalidScoringSystem" target.max_score() + + @pytest.mark.parametrize( + "face_type,min_score_expected", + [ + ("5_zone", 1), + ("10_zone", 1), + ("10_zone_compound", 1), + ("10_zone_6_ring", 5), + ("10_zone_5_ring", 6), + ("10_zone_5_ring_compound", 6), + ("WA_field", 1), + ("IFAA_field", 3), + ("IFAA_field_expert", 1), + ("Worcester", 1), + ("Worcester_2_ring", 4), + ("Beiter_hit_miss", 0), + ], + ) + def test_min_score(self, face_type: str, min_score_expected: float) -> None: + """ + Check that Target() returns correct min score. + """ + target = Target(1.22, face_type, 50, "metre", False) + assert target.min_score() == min_score_expected + + def test_min_score_invalid_face_type(self) -> None: + """ + Check that Target() raises error for invalid face. + """ + with pytest.raises( + ValueError, + match="Target face '(.+)' has no specified minimum score.", + ): + target = Target(1.22, "5_zone", 50, "metre", False) + # Requires manual resetting of scoring system to get this error. + target.scoring_system = "InvalidScoringSystem" + target.min_score() diff --git a/pyproject.toml b/pyproject.toml index 39ee47f..827e8a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ ] readme = "README.md" license = {file = "LICENSE"} -requires-python = ">=3.7" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -27,6 +27,7 @@ dependencies = [ [project.optional-dependencies] test = [ "pytest>=7.2.0", + "pytest-mock", ] lint = [ "black>=22.12.0", @@ -34,6 +35,7 @@ lint = [ "mypy>=1.0.0", "coverage", "pytest>=7.2.0", + "pytest-mock", ] [project.urls]