diff --git a/archeryutils/__init__.py b/archeryutils/__init__.py index 2714016..0f7051b 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 -from archeryutils.classifications import classifications +import archeryutils.classifications as classifications __all__ = [ "rounds", diff --git a/archeryutils/classifications/__init__.py b/archeryutils/classifications/__init__.py index d81a951..61792a5 100644 --- a/archeryutils/classifications/__init__.py +++ b/archeryutils/classifications/__init__.py @@ -1 +1,28 @@ """Module providing various classification functionalities.""" +from .agb_outdoor_classifications import ( + calculate_agb_outdoor_classification, + agb_outdoor_classification_scores, +) +from .agb_indoor_classifications import ( + calculate_agb_indoor_classification, + agb_indoor_classification_scores, +) +from .agb_old_indoor_classifications import ( + calculate_agb_old_indoor_classification, + agb_old_indoor_classification_scores, +) +from .agb_field_classifications import ( + calculate_agb_field_classification, + agb_field_classification_scores, +) + +__all__ = [ + "calculate_agb_outdoor_classification", + "agb_outdoor_classification_scores", + "calculate_agb_indoor_classification", + "agb_indoor_classification_scores", + "calculate_agb_old_indoor_classification", + "agb_old_indoor_classification_scores", + "calculate_agb_field_classification", + "agb_field_classification_scores", +] diff --git a/archeryutils/classifications/agb_field_classifications.py b/archeryutils/classifications/agb_field_classifications.py new file mode 100644 index 0000000..b01fc2a --- /dev/null +++ b/archeryutils/classifications/agb_field_classifications.py @@ -0,0 +1,312 @@ +""" +Code for calculating Archery GB classifications. + +Extended Summary +---------------- +Code to add functionality to the basic handicap equations code +in handicap_equations.py including inverse function and display. + +Routine Listings +---------------- +_make_agb_field_classification_dict +calculate_agb_field_classification +agb_field_classification_scores + +""" +import re +from typing import List, Dict, Any +import numpy as np + +from archeryutils import load_rounds +import archeryutils.classifications.classification_utils as cls_funcs + + +ALL_AGBFIELD_ROUNDS = load_rounds.read_json_to_round_dict( + [ + "WA_field.json", + ] +) + + +def _make_agb_field_classification_dict() -> Dict[str, Dict[str, Any]]: + """ + Generate AGB outdoor classification data. + + Generate a dictionary of dictionaries providing handicaps for + each classification band. + + Parameters + ---------- + None + + Returns + ------- + classification_dict : dict of str : dict of str: list + dictionary indexed on group name (e.g 'adult_female_recurve') + containing list of scores associated with each classification + + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + agb_field_classes = [ + "Grand Master Bowman", + "Master Bowman", + "Bowman", + "1st Class", + "2nd Class", + "3rd Class", + ] + + # Generate dict of classifications + # for both bowstyles, for both genders + classification_dict = {} + classification_dict[cls_funcs.get_groupname("Compound", "Male", "Adult")] = { + "classes": agb_field_classes, + "class_scores": [393, 377, 344, 312, 279, 247], + } + classification_dict[cls_funcs.get_groupname("Compound", "Female", "Adult")] = { + "classes": agb_field_classes, + "class_scores": [376, 361, 330, 299, 268, 237], + } + classification_dict[cls_funcs.get_groupname("Recurve", "Male", "Adult")] = { + "classes": agb_field_classes, + "class_scores": [338, 317, 288, 260, 231, 203], + } + classification_dict[cls_funcs.get_groupname("Recurve", "Female", "Adult")] = { + "classes": agb_field_classes, + "class_scores": [322, 302, 275, 247, 220, 193], + } + classification_dict[cls_funcs.get_groupname("Barebow", "Male", "Adult")] = { + "classes": agb_field_classes, + "class_scores": [328, 307, 279, 252, 224, 197], + } + classification_dict[cls_funcs.get_groupname("Barebow", "Female", "Adult")] = { + "classes": agb_field_classes, + "class_scores": [303, 284, 258, 233, 207, 182], + } + classification_dict[cls_funcs.get_groupname("Longbow", "Male", "Adult")] = { + "classes": agb_field_classes, + "class_scores": [201, 188, 171, 155, 137, 121], + } + classification_dict[cls_funcs.get_groupname("Longbow", "Female", "Adult")] = { + "classes": agb_field_classes, + "class_scores": [303, 284, 258, 233, 207, 182], + } + classification_dict[cls_funcs.get_groupname("Traditional", "Male", "Adult")] = { + "classes": agb_field_classes, + "class_scores": [262, 245, 223, 202, 178, 157], + } + classification_dict[cls_funcs.get_groupname("Traditional", "Female", "Adult")] = { + "classes": agb_field_classes, + "class_scores": [197, 184, 167, 152, 134, 118], + } + classification_dict[cls_funcs.get_groupname("Flatbow", "Male", "Adult")] = { + "classes": agb_field_classes, + "class_scores": [262, 245, 223, 202, 178, 157], + } + classification_dict[cls_funcs.get_groupname("Flatbow", "Female", "Adult")] = { + "classes": agb_field_classes, + "class_scores": [197, 184, 167, 152, 134, 118], + } + + # Juniors + classification_dict[cls_funcs.get_groupname("Compound", "Male", "Under 18")] = { + "classes": agb_field_classes, + "class_scores": [385, 369, 337, 306, 273, 242], + } + + classification_dict[cls_funcs.get_groupname("Compound", "Female", "Under 18")] = { + "classes": agb_field_classes, + "class_scores": [357, 343, 314, 284, 255, 225], + } + + classification_dict[cls_funcs.get_groupname("Recurve", "Male", "Under 18")] = { + "classes": agb_field_classes, + "class_scores": [311, 292, 265, 239, 213, 187], + } + + classification_dict[cls_funcs.get_groupname("Recurve", "Female", "Under 18")] = { + "classes": agb_field_classes, + "class_scores": [280, 263, 239, 215, 191, 168], + } + + classification_dict[cls_funcs.get_groupname("Barebow", "Male", "Under 18")] = { + "classes": agb_field_classes, + "class_scores": [298, 279, 254, 229, 204, 179], + } + + classification_dict[cls_funcs.get_groupname("Barebow", "Female", "Under 18")] = { + "classes": agb_field_classes, + "class_scores": [251, 236, 214, 193, 172, 151], + } + + classification_dict[cls_funcs.get_groupname("Longbow", "Male", "Under 18")] = { + "classes": agb_field_classes, + "class_scores": [161, 150, 137, 124, 109, 96], + } + + classification_dict[cls_funcs.get_groupname("Longbow", "Female", "Under 18")] = { + "classes": agb_field_classes, + "class_scores": [122, 114, 103, 94, 83, 73], + } + + classification_dict[cls_funcs.get_groupname("Traditional", "Male", "Under 18")] = { + "classes": agb_field_classes, + "class_scores": [210, 196, 178, 161, 143, 126], + } + + classification_dict[ + cls_funcs.get_groupname("Traditional", "Female", "Under 18") + ] = { + "classes": agb_field_classes, + "class_scores": [158, 147, 134, 121, 107, 95], + } + + classification_dict[cls_funcs.get_groupname("Flatbow", "Male", "Under 18")] = { + "classes": agb_field_classes, + "class_scores": [210, 196, 178, 161, 143, 126], + } + + classification_dict[cls_funcs.get_groupname("Flatbow", "Female", "Under 18")] = { + "classes": agb_field_classes, + "class_scores": [158, 147, 134, 121, 107, 95], + } + + return classification_dict + + +agb_field_classifications = _make_agb_field_classification_dict() + +del _make_agb_field_classification_dict + + +def calculate_agb_field_classification( + roundname: str, score: float, bowstyle: str, gender: str, age_group: str +) -> str: + """ + Calculate AGB field classification from score. + + Subroutine to calculate a classification from a score given suitable inputs. + + Parameters + ---------- + roundname : str + name of round shot as given by 'codename' in json + score : int + numerical score on the round to calculate classification for + bowstyle : str + archer's bowstyle under AGB outdoor target rules + gender : str + archer's gender under AGB outdoor target rules + age_group : str + archer's age group under AGB outdoor target rules + + Returns + ------- + classification_from_score : str + the classification appropriate for this score + + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + # Check score is valid + if score < 0 or score > ALL_AGBFIELD_ROUNDS[roundname].max_score(): + raise ValueError( + f"Invalid score of {score} for a {roundname}. " + f"Should be in range 0-{ALL_AGBFIELD_ROUNDS[roundname].max_score()}." + ) + + # deal with reduced categories: + if age_group.lower().replace(" ", "") in ("adult", "50+", "under21"): + age_group = "Adult" + elif re.compile("under(18|16|15|14|12)").match(age_group.lower().replace(" ", "")): + age_group = "Under 18" + + groupname = cls_funcs.get_groupname(bowstyle, gender, age_group) + + # Get scores required on this round for each classification + group_data = agb_field_classifications[groupname] + + # Check Round is appropriate: + # Sighted can have any Red 24, unsightes can have any blue 24 + if ( + bowstyle.lower() in ("compound", "recurve") + and "wa_field_24_red_" not in roundname + ): + return "unclassified" + if ( + bowstyle.lower() in ("barebow", "longbow", "traditional", "flatbow") + and "wa_field_24_blue_" not in roundname + ): + return "unclassified" + + # What is the highest classification this score gets? + class_scores: Dict[str, Any] = dict( + zip(group_data["classes"], group_data["class_scores"]) + ) + for item in class_scores: + if class_scores[item] > score: + pass + else: + return item + + # if lower than 3rd class score return "UC" + return "unclassified" + + +def agb_field_classification_scores( + roundname: str, bowstyle: str, gender: str, age_group: str +) -> List[int]: + """ + Calculate AGB field classification scores for category. + + Subroutine to calculate classification scores for a specific category and round. + Appropriate ArcheryGB age groups and classifications. + + Parameters + ---------- + roundname : str + name of round shot as given by 'codename' in json + bowstyle : str + archer's bowstyle under AGB outdoor target rules + gender : str + archer's gender under AGB outdoor target rules + age_group : str + archer's age group under AGB outdoor target rules + + Returns + ------- + classification_scores : ndarray + abbreviation of the classification appropriate for this score + + References + ---------- + ArcheryGB Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 + """ + # deal with reduced categories: + if age_group.lower().replace(" ", "") in ("adult", "50+", "under21"): + age_group = "Adult" + elif re.compile("under(18|16|15|14|12)").match(age_group.lower().replace(" ", "")): + age_group = "Under 18" + + groupname = cls_funcs.get_groupname(bowstyle, gender, age_group) + group_data = agb_field_classifications[groupname] + + # Get scores required on this round for each classification + class_scores = group_data["class_scores"] + + # Make sure that hc.eq.score_for_round did not return array to satisfy mypy + if any(isinstance(x, np.ndarray) for x in class_scores): + raise TypeError( + "score_for_round is attempting to return an array when float expected." + ) + # Score threshold should be int (score_for_round called with round=True) + # Enforce this for better code and to satisfy mypy + int_class_scores = [int(x) for x in class_scores] + + return int_class_scores diff --git a/archeryutils/classifications/agb_indoor_classifications.py b/archeryutils/classifications/agb_indoor_classifications.py new file mode 100644 index 0000000..ad44d39 --- /dev/null +++ b/archeryutils/classifications/agb_indoor_classifications.py @@ -0,0 +1,274 @@ +""" +Code for calculating Archery GB indoor classifications. + +Routine Listings +---------------- +_make_agb_old_indoor_classification_dict +calculate_agb_indoor_classification +agb_indoor_classification_scores +""" +from typing import List, Dict, Any +import numpy as np + +from archeryutils import load_rounds +from archeryutils.handicaps import handicap_equations as hc_eq +import archeryutils.classifications.classification_utils as cls_funcs + + +ALL_INDOOR_ROUNDS = load_rounds.read_json_to_round_dict( + [ + "AGB_indoor.json", + "WA_indoor.json", + ] +) + + +def _make_agb_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: + """ + Generate new (2023) AGB indoor classification data. + + Generate a dictionary of dictionaries providing handicaps for each + classification band and a list of prestige rounds for each category from data files. + Appropriate for 2023 ArcheryGB age groups and classifications. + + Parameters + ---------- + None + + Returns + ------- + classification_dict : dict of str : dict of str: list, list, list + dictionary indexed on group name (e.g 'adult_female_barebow') + containing list of handicaps associated with each classification, + 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) + """ + # For score purposes in classifications we use the full face, not the triple. + # Option of having triple is handled in get classification function + # Compound version of rounds is handled below. + + # 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_in = cls_funcs.read_classes_in_json() + agb_classes_in = agb_classes_info_in["classes"] + agb_classes_in_long = agb_classes_info_in["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"] + ) + + class_hc = np.empty(len(agb_classes_in)) + for i in range(len(agb_classes_in)): + # Assign handicap for this classification + class_hc[i] = ( + bowstyle["datum_in"] + + age_steps * bowstyle["ageStep_in"] + + gender_steps * bowstyle["genderStep_in"] + + (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 + + +agb_indoor_classifications = _make_agb_indoor_classification_dict() + +del _make_agb_indoor_classification_dict + + +def calculate_agb_indoor_classification( + roundname: str, + score: float, + bowstyle: str, + gender: str, + age_group: str, +) -> str: + """ + Calculate new (2023) AGB indoor classification from score. + + Subroutine to calculate a classification from a score given suitable inputs. + Appropriate for 2023 ArcheryGB age groups and classifications. + + Parameters + ---------- + roundname : str + name of round shot as given by 'codename' in json + score : int + numerical score on the round to calculate classification for + bowstyle : str + archer's bowstyle under AGB outdoor target rules + gender : str + archer's gender under AGB outdoor target rules + age_group : str + archer's age group under AGB outdoor target rules + + Returns + ------- + classification_from_score : str + the classification appropriate for this score + + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + # Check score is valid + if score < 0 or score > ALL_INDOOR_ROUNDS[roundname].max_score(): + raise ValueError( + f"Invalid score of {score} for a {roundname}. " + f"Should be in range 0-{ALL_INDOOR_ROUNDS[roundname].max_score()}." + ) + + # Get scores required on this round for each classification + # Enforcing full size face and compound scoring (for compounds) + all_class_scores = agb_indoor_classification_scores( + roundname, + bowstyle, + gender, + age_group, + ) + + groupname = cls_funcs.get_groupname(bowstyle, gender, age_group) + group_data = agb_indoor_classifications[groupname] + class_data: Dict[str, Any] = dict(zip(group_data["classes"], all_class_scores)) + + # What is the highest classification this score gets? + # < 0 handles max scores, > score handles higher classifications + to_del = [] + for classname, classscore in class_data.items(): + if classscore < 0 or classscore > score: + to_del.append(classname) + for del_class in to_del: + del class_data[del_class] + + try: + classification_from_score = list(class_data.keys())[0] + return classification_from_score + except IndexError: + return "UC" + + +def agb_indoor_classification_scores( + roundname: str, + bowstyle: str, + gender: str, + age_group: str, +) -> List[int]: + """ + Calculate new (2023) AGB indoor classification scores for category. + + Subroutine to calculate classification scores for a specific category and round. + Appropriate ArcheryGB age groups and classifications. + + Parameters + ---------- + roundname : str + name of round shot as given by 'codename' in json + bowstyle : str + archer's bowstyle under AGB outdoor target rules + gender : str + archer's gender under AGB outdoor target rules + age_group : str + archer's age group under AGB outdoor target rules + + Returns + ------- + classification_scores : ndarray + scores required for each classification band + + References + ---------- + ArcheryGB Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 + """ + # deal with reduced categories: + if bowstyle.lower() in ("flatbow", "traditional", "asiatic"): + bowstyle = "Barebow" + + # enforce compound scoring + if bowstyle.lower() in ("compound"): + roundname = cls_funcs.get_compound_codename(roundname) + + groupname = cls_funcs.get_groupname(bowstyle, gender, age_group) + group_data = agb_indoor_classifications[groupname] + + hc_scheme = "AGB" + hc_params = hc_eq.HcParams() + + # Get scores required on this round for each classification + # Enforce full size face + class_scores = [ + hc_eq.score_for_round( + ALL_INDOOR_ROUNDS[cls_funcs.strip_spots(roundname)], + group_data["class_HC"][i], + hc_scheme, + hc_params, + round_score_up=True, + )[0] + for i, class_i in enumerate(group_data["classes"]) + ] + + # Make sure that hc.eq.score_for_round did not return array to satisfy mypy + if any(isinstance(x, np.ndarray) for x in class_scores): + raise TypeError( + "score_for_round is attempting to return an array when float expected." + ) + # Score threshold should be int (score_for_round called with round=True) + # Enforce this for better code and to satisfy mypy + int_class_scores = [int(x) for x in class_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"])): + next_score = hc_eq.score_for_round( + ALL_INDOOR_ROUNDS[cls_funcs.strip_spots(roundname)], + np.floor(hc) + 1, + hc_scheme, + hc_params, + round_score_up=True, + )[0] + if next_score == sc: + # If already at max score this classification is impossible + if sc == 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...) + else: + int_class_scores[i] += 1 + + return int_class_scores diff --git a/archeryutils/classifications/agb_old_indoor_classifications.py b/archeryutils/classifications/agb_old_indoor_classifications.py new file mode 100644 index 0000000..d31dcbf --- /dev/null +++ b/archeryutils/classifications/agb_old_indoor_classifications.py @@ -0,0 +1,219 @@ +""" +Code for calculating old Archery GB indoor classifications. + +Routine Listings +---------------- +_make_AGB_old_indoor_classification_dict +calculate_AGB_old_indoor_classification +AGB_old_indoor_classification_scores +""" +from typing import List, Dict, Any +import numpy as np + +from archeryutils import load_rounds +from archeryutils.handicaps import handicap_equations as hc_eq +import archeryutils.classifications.classification_utils as cls_funcs + + +ALL_INDOOR_ROUNDS = load_rounds.read_json_to_round_dict( + [ + "AGB_indoor.json", + "WA_indoor.json", + ] +) + + +def _make_agb_old_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: + """ + Generate AGB outdoor classification data. + + Generate a dictionary of dictionaries providing handicaps for + each classification band. + + Parameters + ---------- + None + + Returns + ------- + classification_dict : dict of str : dict of str: list + dictionary indexed on group name (e.g 'adult_female_recurve') + containing list of handicaps associated with each classification + + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + agb_indoor_classes = ["A", "B", "C", "D", "E", "F", "G", "H"] + + # Generate dict of classifications + # for both bowstyles, for both genders + classification_dict = {} + classification_dict[cls_funcs.get_groupname("Compound", "Male", "Adult")] = { + "classes": agb_indoor_classes, + "class_HC": [5, 12, 24, 37, 49, 62, 73, 79], + } + classification_dict[cls_funcs.get_groupname("Compound", "Female", "Adult")] = { + "classes": agb_indoor_classes, + "class_HC": [12, 18, 30, 43, 55, 67, 79, 83], + } + classification_dict[cls_funcs.get_groupname("Recurve", "Male", "Adult")] = { + "classes": agb_indoor_classes, + "class_HC": [14, 21, 33, 46, 58, 70, 80, 85], + } + classification_dict[cls_funcs.get_groupname("Recurve", "Female", "Adult")] = { + "classes": agb_indoor_classes, + "class_HC": [21, 27, 39, 51, 64, 75, 85, 90], + } + + return classification_dict + + +agb_old_indoor_classifications = _make_agb_old_indoor_classification_dict() + +del _make_agb_old_indoor_classification_dict + + +def calculate_agb_old_indoor_classification( + roundname: str, + score: float, + bowstyle: str, + gender: str, + age_group: str, +) -> str: + """ + Calculate AGB indoor classification from score. + + Subroutine to calculate a classification from a score given suitable inputs. + Appropriate for 2023 ArcheryGB age groups and classifications. + + Parameters + ---------- + roundname : str + name of round shot as given by 'codename' in json + score : int + numerical score on the round to calculate classification for + bowstyle : str + archer's bowstyle under AGB outdoor target rules + gender : str + archer's gender under AGB outdoor target rules + age_group : str + archer's age group under AGB outdoor target rules + + Returns + ------- + classification_from_score : str + the classification appropriate for this score + + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + # Check score is valid + if score < 0 or score > ALL_INDOOR_ROUNDS[roundname].max_score(): + raise ValueError( + f"Invalid score of {score} for a {roundname}. " + f"Should be in range 0-{ALL_INDOOR_ROUNDS[roundname].max_score()}." + ) + + # Get scores required on this round for each classification + class_scores = agb_old_indoor_classification_scores( + roundname, + bowstyle, + gender, + age_group, + ) + + groupname = cls_funcs.get_groupname(bowstyle, gender, age_group) + group_data = agb_old_indoor_classifications[groupname] + class_data: Dict[str, Any] = dict(zip(group_data["classes"], class_scores)) + + # What is the highest classification this score gets? + to_del = [] + for classname, classscore in class_data.items(): + if classscore > score: + to_del.append(classname) + for del_class in to_del: + del class_data[del_class] + + # NB No fiddle for Worcester required with this logic... + # Beware of this later on, however, if we wish to rectify the 'anomaly' + + try: + classification_from_score = list(class_data.keys())[0] + return classification_from_score + except IndexError: + return "UC" + + +def agb_old_indoor_classification_scores( + roundname: str, + bowstyle: str, + gender: str, + age_group: str, +) -> List[int]: + """ + Calculate AGB indoor classification scores for category. + + Subroutine to calculate classification scores for a specific category and round. + Appropriate ArcheryGB age groups and classifications. + + Parameters + ---------- + roundname : str + name of round shot as given by 'codename' in json + bowstyle : str + archer's bowstyle under AGB outdoor target rules + gender : str + archer's gender under AGB outdoor target rules + age_group : str + archer's age group under AGB outdoor target rules + + Returns + ------- + classification_scores : ndarray + abbreviation of the classification appropriate for this score + + References + ---------- + ArcheryGB Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 + """ + # enforce compound scoring + if bowstyle.lower() in ("compound"): + roundname = cls_funcs.get_compound_codename(roundname) + + # deal with reduced categories: + age_group = "Adult" + if bowstyle.lower() not in ("compound"): + bowstyle = "Recurve" + + groupname = cls_funcs.get_groupname(bowstyle, gender, age_group) + group_data = agb_old_indoor_classifications[groupname] + + hc_params = hc_eq.HcParams() + + # Get scores required on this round for each classification + class_scores = [ + hc_eq.score_for_round( + ALL_INDOOR_ROUNDS[roundname], + group_data["class_HC"][i], + "AGBold", + hc_params, + round_score_up=True, + )[0] + for i, class_i in enumerate(group_data["classes"]) + ] + + # Make sure that hc.eq.score_for_round did not return array to satisfy mypy + if any(isinstance(x, np.ndarray) for x in class_scores): + raise TypeError( + "score_for_round is attempting to return an array when float expected." + ) + # Score threshold should be int (score_for_round called with round=True) + # Enforce this for better code and to satisfy mypy + int_class_scores = [int(x) for x in class_scores] + + return int_class_scores diff --git a/archeryutils/classifications/agb_outdoor_classifications.py b/archeryutils/classifications/agb_outdoor_classifications.py new file mode 100644 index 0000000..064337a --- /dev/null +++ b/archeryutils/classifications/agb_outdoor_classifications.py @@ -0,0 +1,413 @@ +""" +Code for calculating Archery GB outdoor classifications. + +Routine Listings +---------------- +_make_agb_outdoor_classification_dict +calculate_agb_outdoor_classification +agb_outdoor_classification_scores +""" +from typing import List, Dict, Any +import numpy as np + +from archeryutils import load_rounds +from archeryutils.handicaps import handicap_equations as hc_eq +import archeryutils.classifications.classification_utils as cls_funcs + + +ALL_OUTDOOR_ROUNDS = load_rounds.read_json_to_round_dict( + [ + "AGB_outdoor_imperial.json", + "AGB_outdoor_metric.json", + "WA_outdoor.json", + ] +) + + +def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: + """ + Generate AGB outdoor classification data. + + Generate a dictionary of dictionaries providing handicaps for each + classification band and a list prestige rounds for each category from data files. + Appropriate for 2023 ArcheryGB age groups and classifications. + + Parameters + ---------- + None + + Returns + ------- + classification_dict : dict of str : dict of str: list, list, list + dictionary indexed on group name (e.g 'adult_female_barebow') + containing list of handicaps associated with each classification, + 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) + """ + # Lists of prestige rounds defined by 'codename' of 'Round' class + # TODO: convert this to json? + prestige_imperial = [ + "york", + "hereford", + "bristol_i", + "bristol_ii", + "bristol_iii", + "bristol_iv", + "bristol_v", + ] + prestige_metric = [ + "wa1440_90", + "wa1440_90_small", + "wa1440_70", + "wa1440_70_small", + "wa1440_60", + "wa1440_60_small", + "metric_i", + "metric_ii", + "metric_iii", + "metric_iv", + "metric_v", + ] + prestige_720 = [ + "wa720_70", + "wa720_60", + "metric_122_50", + "metric_122_40", + "metric_122_30", + ] + prestige_720_compound = [ + "wa720_50_c", + "metric_80_40", + "metric_80_30", + ] + prestige_720_barebow = [ + "wa720_50_b", + "metric_122_50", + "metric_122_40", + "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 + + +agb_outdoor_classifications = _make_agb_outdoor_classification_dict() + +del _make_agb_outdoor_classification_dict + + +def calculate_agb_outdoor_classification( + roundname: str, score: float, bowstyle: str, gender: str, age_group: str +) -> str: + """ + Calculate AGB outdoor classification from score. + + Calculate a classification from a score given suitable inputs. + Appropriate for 2023 ArcheryGB age groups and classifications. + + Parameters + ---------- + roundname : str + name of round shot as given by 'codename' in json + score : int + numerical score on the round to calculate classification for + bowstyle : str + archer's bowstyle under AGB outdoor target rules + gender : str + archer's gender under AGB outdoor target rules + age_group : str + archer's age group under AGB outdoor target rules + + Returns + ------- + classification_from_score : str + abbreviation of the classification appropriate for this score + + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + # Check score is valid + if score < 0 or score > ALL_OUTDOOR_ROUNDS[roundname].max_score(): + raise ValueError( + f"Invalid score of {score} for a {roundname}. " + f"Should be in range 0-{ALL_OUTDOOR_ROUNDS[roundname].max_score()}." + ) + + # Get scores required on this round for each classification + # Enforcing full size face and compound scoring (for compounds) + all_class_scores = agb_outdoor_classification_scores( + roundname, + bowstyle, + gender, + age_group, + ) + + groupname = cls_funcs.get_groupname(bowstyle, gender, age_group) + group_data = agb_outdoor_classifications[groupname] + + class_data: Dict[str, Dict[str, Any]] = {} + for i, class_i in enumerate(group_data["classes"]): + class_data[class_i] = { + "min_dists": 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] + + # 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? + to_del = [] + for classname, classdata in class_data.items(): + if classdata["score"] > score: + to_del.append(classname) + for item in to_del: + del class_data[item] + + try: + classification_from_score = list(class_data.keys())[0] + return classification_from_score + except IndexError: + return "UC" + + +def agb_outdoor_classification_scores( + roundname: str, bowstyle: str, gender: str, age_group: str +) -> List[int]: + """ + Calculate AGB outdoor classification scores for category. + + Subroutine to calculate classification scores for a specific category and round. + Appropriate for 2023 ArcheryGB age groups and classifications. + + Parameters + ---------- + roundname : str + name of round shot as given by 'codename' in json + bowstyle : str + archer's bowstyle under AGB outdoor target rules + gender : str + archer's gender under AGB outdoor target rules + age_group : str + archer's age group under AGB outdoor target rules + + Returns + ------- + classification_scores : ndarray + abbreviation of the classification appropriate for this score + + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + if bowstyle.lower() in ("traditional", "flatbow", "asiatic"): + bowstyle = "Barebow" + + groupname = cls_funcs.get_groupname(bowstyle, gender, age_group) + group_data = agb_outdoor_classifications[groupname] + + hc_params = hc_eq.HcParams() + + # Get scores required on this round for each classification + class_scores = [ + hc_eq.score_for_round( + ALL_OUTDOOR_ROUNDS[cls_funcs.strip_spots(roundname)], + group_data["class_HC"][i], + "AGB", + hc_params, + round_score_up=True, + )[0] + for i in range(len(group_data["classes"])) + ] + + # Reduce list based on other criteria besides handicap + # is it a prestige round? If not remove MB scores + if roundname not in agb_outdoor_classifications[groupname]["prestige_rounds"]: + class_scores[0:3] = [-9999] * 3 + + # 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: + class_scores[i] = -9999 + + # Make sure that hc.eq.score_for_round did not return array to satisfy mypy + if any(isinstance(x, np.ndarray) for x in class_scores): + raise TypeError( + "score_for_round is attempting to return an array when float expected." + ) + # Score threshold should be int (score_for_round called with round=True) + # Enforce this for better code and to satisfy mypy + int_class_scores = [int(x) for x in class_scores] + + return int_class_scores diff --git a/archeryutils/classifications/classification_utils.py b/archeryutils/classifications/classification_utils.py new file mode 100644 index 0000000..230e6f0 --- /dev/null +++ b/archeryutils/classifications/classification_utils.py @@ -0,0 +1,266 @@ +""" +Utils for classifications. + +Extended Summary +---------------- +Utilities to assist in calculations of classifications. + +Routine Listings +---------------- +read_ages_json +read_bowstyles_json +read_genders_json +read_classes_out_json +get_groupname +strip_spots +get_compound_codename + +""" +import json +from pathlib import Path +from typing import List, Dict, Any + + +def read_ages_json( + age_file: Path = Path(__file__).parent / "AGB_ages.json", +) -> List[Dict[str, Any]]: + """ + Read AGB age categories in from neighbouring json file to list of dicts. + + Parameters + ---------- + age_file : Path + path to json file + + Returns + ------- + ages : list of dict + AGB age category data from file + + References + ---------- + Archery GB Rules of Shooting + """ + with open(age_file, encoding="utf-8") as json_file: + ages = json.load(json_file) + if isinstance(ages, list): + return ages + raise TypeError( + f"Unexpected ages input when reading from json file. " + f"Expected list(dict()) but got {type(ages)}. Check {age_file}." + ) + + +def read_bowstyles_json( + bowstyles_file: Path = Path(__file__).parent / "AGB_bowstyles.json", +) -> List[Dict[str, Any]]: + """ + Read AGB bowstyles in from neighbouring json file to list of dicts. + + Parameters + ---------- + bowstyles_file : Path + path to json file + + Returns + ------- + bowstyles : list of dict + AGB bowstyle category data from file + + References + ---------- + Archery GB Rules of Shooting + """ + with open(bowstyles_file, encoding="utf-8") as json_file: + bowstyles = json.load(json_file) + if isinstance(bowstyles, list): + return bowstyles + raise TypeError( + f"Unexpected bowstyles input when reading from json file. " + f"Expected list(dict()) but got {type(bowstyles)}. Check {bowstyles_file}." + ) + + +def read_genders_json( + genders_file: Path = Path(__file__).parent / "AGB_genders.json", +) -> List[str]: + """ + Read AGB genders in from neighbouring json file to list of dict. + + Parameters + ---------- + genders_file : Path + path to json file + + Returns + ------- + genders : list of dict + AGB gender data from file + + References + ---------- + Archery GB Rules of Shooting + """ + # Read in gender info as list + with open(genders_file, encoding="utf-8") as json_file: + genders = json.load(json_file)["genders"] + if isinstance(genders, list): + return genders + raise TypeError( + f"Unexpected genders input when reading from json file. " + f"Expected list() but got {type(genders)}. Check {genders_file}." + ) + + +def read_classes_out_json( + classes_file: Path = Path(__file__).parent / "AGB_classes_out.json", +) -> Dict[str, Any]: + """ + Read AGB outdoor classes in from neighbouring json file to dict. + + 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) + 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. + + 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) + 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}." + ) + + +def get_groupname(bowstyle: str, gender: str, age_group: str) -> str: + """ + Generate a single string id for a particular category. + + Parameters + ---------- + bowstyle : str + archer's bowstyle under AGB outdoor target rules + gender : str + archer's gender under AGB outdoor target rules + age_group : str + archer's age group under AGB outdoor target rules + + Returns + ------- + groupname : str + single, lower case str id for this category + """ + groupname = ( + f"{age_group.lower().replace(' ', '')}_" + f"{gender.lower()}_" + f"{bowstyle.lower()}" + ) + + return groupname + + +def strip_spots( + roundname: str, +) -> str: + """ + Calculate AGB indoor classification from score. + + Parameters + ---------- + roundname : str + name of round shot as given by 'codename' in json + + Returns + ------- + roundname : str + name of round shot as given by 'codename' in json + """ + roundname = roundname.replace("_triple", "") + roundname = roundname.replace("_5_centre", "") + roundname = roundname.replace("_small", "") + return roundname + + +def get_compound_codename(round_codenames): + """ + 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 + + Returns + ------- + round_codenames : str or list of str + list of amended round codenames 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", + "bray_ii": "bray_ii_compound", + "bray_ii_triple": "bray_ii_compound_triple", + "stafford": "stafford_compound", + "portsmouth": "portsmouth_compound", + "portsmouth_triple": "portsmouth_compound_triple", + "vegas": "vegas_compound", + "wa18": "wa18_compound", + "wa18_triple": "wa18_compound_triple", + "wa25": "wa25_compound", + "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 diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index f19ee65..87d424f 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -1,5 +1,5 @@ """ -Code for calculating Archery GB classifications. +Code for calculating Archery classifications. Extended Summary ---------------- @@ -8,1518 +8,6 @@ Routine Listings ---------------- -read_ages_json -read_bowstyles_json -read_genders_json -read_classes_out_json -get_groupname -_make_AGB_outdoor_classification_dict -_make_AGB_old_indoor_classification_dict -_make_AGB_field_classification_dict -calculate_AGB_outdoor_classification -AGB_outdoor_classification_scores -calculate_AGB_old_indoor_classification -AGB_old_indoor_classification_scores -calculate_AGB_indoor_classification -AGB_indoor_classification_scores -calculate_AGB_field_classification -AGB_field_classification_scores - +The contents of this module have now been abstracted into several files. +This is to keep them of a manageable size. """ -import json -from pathlib import Path -from typing import List, Dict, Any -import numpy as np - -from archeryutils import load_rounds -from archeryutils.handicaps import handicap_equations as hc_eq - - -def read_ages_json( - age_file: Path = Path(__file__).parent / "AGB_ages.json", -) -> List[Dict[str, Any]]: - """ - Read AGB age categories in from neighbouring json file to list of dicts. - - Parameters - ---------- - age_file : Path - path to json file - - Returns - ------- - ages : list of dict - AGB age category data from file - - References - ---------- - Archery GB Rules of Shooting - """ - with open(age_file, encoding="utf-8") as json_file: - ages = json.load(json_file) - if isinstance(ages, list): - return ages - raise TypeError( - f"Unexpected ages input when reading from json file. " - f"Expected list(dict()) but got {type(ages)}. Check {age_file}." - ) - - -def read_bowstyles_json( - bowstyles_file: Path = Path(__file__).parent / "AGB_bowstyles.json", -) -> List[Dict[str, Any]]: - """ - Read AGB bowstyles in from neighbouring json file to list of dicts. - - Parameters - ---------- - bowstyles_file : Path - path to json file - - Returns - ------- - bowstyles : list of dict - AGB bowstyle category data from file - - References - ---------- - Archery GB Rules of Shooting - """ - with open(bowstyles_file, encoding="utf-8") as json_file: - bowstyles = json.load(json_file) - if isinstance(bowstyles, list): - return bowstyles - raise TypeError( - f"Unexpected bowstyles input when reading from json file. " - f"Expected list(dict()) but got {type(bowstyles)}. Check {bowstyles_file}." - ) - - -def read_genders_json( - genders_file: Path = Path(__file__).parent / "AGB_genders.json", -) -> List[str]: - """ - Read AGB genders in from neighbouring json file to list of dict. - - Parameters - ---------- - genders_file : Path - path to json file - - Returns - ------- - genders : list of dict - AGB gender data from file - - References - ---------- - Archery GB Rules of Shooting - """ - # Read in gender info as list - with open(genders_file, encoding="utf-8") as json_file: - genders = json.load(json_file)["genders"] - if isinstance(genders, list): - return genders - raise TypeError( - f"Unexpected genders input when reading from json file. " - f"Expected list() but got {type(genders)}. Check {genders_file}." - ) - - -def read_classes_out_json( - classes_file: Path = Path(__file__).parent / "AGB_classes_out.json", -) -> Dict[str, Any]: - """ - Read AGB outdoor classes in from neighbouring json file to dict. - - 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) - 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. - - 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) - 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}." - ) - - -def get_groupname(bowstyle: str, gender: str, age_group: str) -> str: - """ - Generate a single string id for a particular category. - - Parameters - ---------- - bowstyle : str - archer's bowstyle under AGB outdoor target rules - gender : str - archer's gender under AGB outdoor target rules - age_group : str - archer's age group under AGB outdoor target rules - - Returns - ------- - groupname : str - single, lower case str id for this category - """ - groupname = ( - f"{age_group.lower().replace(' ', '')}_" - f"{gender.lower()}_" - f"{bowstyle.lower()}" - ) - - return groupname - - -def _make_AGB_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: - """ - Generate AGB outdoor classification data. - - Generate a dictionary of dictionaries providing handicaps for each - classification band and a list prestige rounds for each category from data files. - Appropriate for 2023 ArcheryGB age groups and classifications. - - Parameters - ---------- - None - - Returns - ------- - classification_dict : dict of str : dict of str: list, list, list - dictionary indexed on group name (e.g 'adult_female_barebow') - containing list of handicaps associated with each classification, - 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) - """ - # Lists of prestige rounds defined by 'codename' of 'Round' class - # TODO: convert this to json? - prestige_imperial = [ - "york", - "hereford", - "bristol_i", - "bristol_ii", - "bristol_iii", - "bristol_iv", - "bristol_v", - ] - prestige_metric = [ - "wa1440_90", - "wa1440_90_small", - "wa1440_70", - "wa1440_70_small", - "wa1440_60", - "wa1440_60_small", - "metric_i", - "metric_ii", - "metric_iii", - "metric_iv", - "metric_v", - ] - prestige_720 = [ - "wa720_70", - "wa720_60", - "metric_122_50", - "metric_122_40", - "metric_122_30", - ] - prestige_720_compound = [ - "wa720_50_c", - "metric_80_40", - "metric_80_30", - ] - prestige_720_barebow = [ - "wa720_50_b", - "metric_122_50", - "metric_122_40", - "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 - - all_outdoor_rounds = load_rounds.read_json_to_round_dict( - [ - "AGB_outdoor_imperial.json", - "AGB_outdoor_metric.json", - # "AGB_indoor.json", - "WA_outdoor.json", - # "WA_indoor.json", - # "Custom.json", - ] - ) - - # Read in age group info as list of dicts - AGB_ages = read_ages_json() - # Read in bowstyleclass info as list of dicts - AGB_bowstyles = read_bowstyles_json() - # Read in gender info as list of dicts - AGB_genders = read_genders_json() - # Read in classification names as dict - AGB_classes_info_out = 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 = 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 e: - # Shouldn't really get here... - print( - f"{e} 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 - - -def _make_AGB_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: - """ - Generate new (2023) AGB indoor classification data. - - Generate a dictionary of dictionaries providing handicaps for each - classification band and a list of prestige rounds for each category from data files. - Appropriate for 2023 ArcheryGB age groups and classifications. - - Parameters - ---------- - None - - Returns - ------- - classification_dict : dict of str : dict of str: list, list, list - dictionary indexed on group name (e.g 'adult_female_barebow') - containing list of handicaps associated with each classification, - 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) - """ - # For score purposes in classifications we use the full face, not the triple. - # Option of having triple is handled in get classification function - # Compound version of rounds is handled below. - - all_indoor_rounds = load_rounds.read_json_to_round_dict( - [ - # "AGB_outdoor_imperial.json", - # "AGB_outdoor_metric.json", - "AGB_indoor.json", - # "WA_outdoor.json", - "WA_indoor.json", - # "Custom.json", - ] - ) - - # Read in age group info as list of dicts - AGB_ages = read_ages_json() - # Read in bowstyleclass info as list of dicts - AGB_bowstyles = read_bowstyles_json() - # Read in gender info as list of dicts - AGB_genders = read_genders_json() - # Read in classification names as dict - AGB_classes_info_in = read_classes_in_json() - AGB_classes_in = AGB_classes_info_in["classes"] - AGB_classes_in_long = AGB_classes_info_in["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 = get_groupname( - bowstyle["bowstyle"], gender, age["age_group"] - ) - - class_HC = np.empty(len(AGB_classes_in)) - min_dists = np.empty((len(AGB_classes_in), 3)) - for i in range(len(AGB_classes_in)): - # Assign handicap for this classification - class_HC[i] = ( - bowstyle["datum_in"] - + age_steps * bowstyle["ageStep_in"] - + gender_steps * bowstyle["genderStep_in"] - + (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 - - -def _make_AGB_old_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: - """ - Generate AGB outdoor classification data. - - Generate a dictionary of dictionaries providing handicaps for - each classification band. - - Parameters - ---------- - None - - Returns - ------- - classification_dict : dict of str : dict of str: list - dictionary indexed on group name (e.g 'adult_female_recurve') - containing list of handicaps associated with each classification - - References - ---------- - ArcheryGB 2023 Rules of Shooting - ArcheryGB Shooting Administrative Procedures - SAP7 (2023) - """ - AGB_indoor_classes = ["A", "B", "C", "D", "E", "F", "G", "H"] - - # Generate dict of classifications - # for both bowstyles, for both genders - classification_dict = {} - classification_dict[get_groupname("Compound", "Male", "Adult")] = { - "classes": AGB_indoor_classes, - "class_HC": [5, 12, 24, 37, 49, 62, 73, 79], - } - classification_dict[get_groupname("Compound", "Female", "Adult")] = { - "classes": AGB_indoor_classes, - "class_HC": [12, 18, 30, 43, 55, 67, 79, 83], - } - classification_dict[get_groupname("Recurve", "Male", "Adult")] = { - "classes": AGB_indoor_classes, - "class_HC": [14, 21, 33, 46, 58, 70, 80, 85], - } - classification_dict[get_groupname("Recurve", "Female", "Adult")] = { - "classes": AGB_indoor_classes, - "class_HC": [21, 27, 39, 51, 64, 75, 85, 90], - } - - return classification_dict - - -def _make_AGB_field_classification_dict() -> Dict[str, Dict[str, Any]]: - """ - Generate AGB outdoor classification data. - - Generate a dictionary of dictionaries providing handicaps for - each classification band. - - Parameters - ---------- - None - - Returns - ------- - classification_dict : dict of str : dict of str: list - dictionary indexed on group name (e.g 'adult_female_recurve') - containing list of scores associated with each classification - - References - ---------- - ArcheryGB 2023 Rules of Shooting - ArcheryGB Shooting Administrative Procedures - SAP7 (2023) - """ - AGB_field_classes = [ - "Grand Master Bowman", - "Master Bowman", - "Bowman", - "1st Class", - "2nd Class", - "3rd Class", - ] - - # Generate dict of classifications - # for both bowstyles, for both genders - classification_dict = {} - classification_dict[get_groupname("Compound", "Male", "Adult")] = { - "classes": AGB_field_classes, - "class_scores": [393, 377, 344, 312, 279, 247], - } - classification_dict[get_groupname("Compound", "Female", "Adult")] = { - "classes": AGB_field_classes, - "class_scores": [376, 361, 330, 299, 268, 237], - } - classification_dict[get_groupname("Recurve", "Male", "Adult")] = { - "classes": AGB_field_classes, - "class_scores": [338, 317, 288, 260, 231, 203], - } - classification_dict[get_groupname("Recurve", "Female", "Adult")] = { - "classes": AGB_field_classes, - "class_scores": [322, 302, 275, 247, 220, 193], - } - classification_dict[get_groupname("Barebow", "Male", "Adult")] = { - "classes": AGB_field_classes, - "class_scores": [328, 307, 279, 252, 224, 197], - } - classification_dict[get_groupname("Barebow", "Female", "Adult")] = { - "classes": AGB_field_classes, - "class_scores": [303, 284, 258, 233, 207, 182], - } - classification_dict[get_groupname("Longbow", "Male", "Adult")] = { - "classes": AGB_field_classes, - "class_scores": [201, 188, 171, 155, 137, 121], - } - classification_dict[get_groupname("Longbow", "Female", "Adult")] = { - "classes": AGB_field_classes, - "class_scores": [303, 284, 258, 233, 207, 182], - } - classification_dict[get_groupname("Traditional", "Male", "Adult")] = { - "classes": AGB_field_classes, - "class_scores": [262, 245, 223, 202, 178, 157], - } - classification_dict[get_groupname("Traditional", "Female", "Adult")] = { - "classes": AGB_field_classes, - "class_scores": [197, 184, 167, 152, 134, 118], - } - classification_dict[get_groupname("Flatbow", "Male", "Adult")] = { - "classes": AGB_field_classes, - "class_scores": [262, 245, 223, 202, 178, 157], - } - classification_dict[get_groupname("Flatbow", "Female", "Adult")] = { - "classes": AGB_field_classes, - "class_scores": [197, 184, 167, 152, 134, 118], - } - - # Juniors - classification_dict[get_groupname("Compound", "Male", "Under 18")] = { - "classes": AGB_field_classes, - "class_scores": [385, 369, 337, 306, 273, 242], - } - - classification_dict[get_groupname("Compound", "Female", "Under 18")] = { - "classes": AGB_field_classes, - "class_scores": [357, 343, 314, 284, 255, 225], - } - - classification_dict[get_groupname("Recurve", "Male", "Under 18")] = { - "classes": AGB_field_classes, - "class_scores": [311, 292, 265, 239, 213, 187], - } - - classification_dict[get_groupname("Recurve", "Female", "Under 18")] = { - "classes": AGB_field_classes, - "class_scores": [280, 263, 239, 215, 191, 168], - } - - classification_dict[get_groupname("Barebow", "Male", "Under 18")] = { - "classes": AGB_field_classes, - "class_scores": [298, 279, 254, 229, 204, 179], - } - - classification_dict[get_groupname("Barebow", "Female", "Under 18")] = { - "classes": AGB_field_classes, - "class_scores": [251, 236, 214, 193, 172, 151], - } - - classification_dict[get_groupname("Longbow", "Male", "Under 18")] = { - "classes": AGB_field_classes, - "class_scores": [161, 150, 137, 124, 109, 96], - } - - classification_dict[get_groupname("Longbow", "Female", "Under 18")] = { - "classes": AGB_field_classes, - "class_scores": [122, 114, 103, 94, 83, 73], - } - - classification_dict[get_groupname("Traditional", "Male", "Under 18")] = { - "classes": AGB_field_classes, - "class_scores": [210, 196, 178, 161, 143, 126], - } - - classification_dict[get_groupname("Traditional", "Female", "Under 18")] = { - "classes": AGB_field_classes, - "class_scores": [158, 147, 134, 121, 107, 95], - } - - classification_dict[get_groupname("Flatbow", "Male", "Under 18")] = { - "classes": AGB_field_classes, - "class_scores": [210, 196, 178, 161, 143, 126], - } - - classification_dict[get_groupname("Flatbow", "Female", "Under 18")] = { - "classes": AGB_field_classes, - "class_scores": [158, 147, 134, 121, 107, 95], - } - - return classification_dict - - -AGB_outdoor_classifications = _make_AGB_outdoor_classification_dict() -AGB_old_indoor_classifications = _make_AGB_old_indoor_classification_dict() -AGB_indoor_classifications = _make_AGB_indoor_classification_dict() -AGB_field_classifications = _make_AGB_field_classification_dict() - -del _make_AGB_outdoor_classification_dict -del _make_AGB_old_indoor_classification_dict -del _make_AGB_indoor_classification_dict -del _make_AGB_field_classification_dict - - -def calculate_AGB_outdoor_classification( - roundname: str, score: float, bowstyle: str, gender: str, age_group: str -) -> str: - """ - Calculate AGB outdoor classification from score. - - Calculate a classification from a score given suitable inputs. - Appropriate for 2023 ArcheryGB age groups and classifications. - - Parameters - ---------- - roundname : str - name of round shot as given by 'codename' in json - score : int - numerical score on the round to calculate classification for - bowstyle : str - archer's bowstyle under AGB outdoor target rules - gender : str - archer's gender under AGB outdoor target rules - age_group : str - archer's age group under AGB outdoor target rules - - Returns - ------- - classification_from_score : str - abbreviation of the classification appropriate for this score - - References - ---------- - ArcheryGB 2023 Rules of Shooting - ArcheryGB Shooting Administrative Procedures - SAP7 (2023) - """ - # TODO: Need routines to sanitise/deal with variety of user inputs - - # TODO: Should this be defined outside the function to reduce I/O or does - # it have no effect? - all_outdoor_rounds = load_rounds.read_json_to_round_dict( - [ - "AGB_outdoor_imperial.json", - "AGB_outdoor_metric.json", - "WA_outdoor.json", - ] - ) - - if bowstyle.lower() in ("traditional", "flatbow", "asiatic"): - bowstyle = "Barebow" - - groupname = get_groupname(bowstyle, gender, age_group) - group_data = AGB_outdoor_classifications[groupname] - - hc_params = hc_eq.HcParams() - - # Get scores required on this round for each classification - class_scores = [ - hc_eq.score_for_round( - all_outdoor_rounds[roundname], - group_data["class_HC"][i], - "AGB", - hc_params, - round_score_up=True, - )[0] - for i, class_i in enumerate(group_data["classes"]) - ] - # class_data = dict( - # zip(group_data["classes"], zip(group_data["min_dists"], class_scores)) - # ) - class_data: Dict[str, Dict[str, Any]] = {} - for i, class_i in enumerate(group_data["classes"]): - class_data[class_i] = { - "min_dists": group_data["min_dists"][i, :], - "score": 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] - - # 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? - to_del = [] - for classname, classdata in class_data.items(): - if classdata["score"] > score: - to_del.append(classname) - for item in to_del: - del class_data[item] - - try: - classification_from_score = list(class_data.keys())[0] - return classification_from_score - except IndexError: - return "UC" - - -def AGB_outdoor_classification_scores( - roundname: str, bowstyle: str, gender: str, age_group: str -) -> List[int]: - """ - Calculate AGB outdoor classification scores for category. - - Subroutine to calculate classification scores for a specific category and round. - Appropriate for 2023 ArcheryGB age groups and classifications. - - Parameters - ---------- - roundname : str - name of round shot as given by 'codename' in json - bowstyle : str - archer's bowstyle under AGB outdoor target rules - gender : str - archer's gender under AGB outdoor target rules - age_group : str - archer's age group under AGB outdoor target rules - - Returns - ------- - classification_scores : ndarray - abbreviation of the classification appropriate for this score - - References - ---------- - ArcheryGB 2023 Rules of Shooting - ArcheryGB Shooting Administrative Procedures - SAP7 (2023) - """ - # TODO: Should this be defined outside the function to reduce I/O or does - # it have no effect? - all_outdoor_rounds = load_rounds.read_json_to_round_dict( - [ - "AGB_outdoor_imperial.json", - "AGB_outdoor_metric.json", - "WA_outdoor.json", - "Custom.json", - ] - ) - - if bowstyle.lower() in ("traditional", "flatbow", "asiatic"): - bowstyle = "Barebow" - - groupname = get_groupname(bowstyle, gender, age_group) - group_data = AGB_outdoor_classifications[groupname] - - hc_params = hc_eq.HcParams() - - # Get scores required on this round for each classification - class_scores = [ - hc_eq.score_for_round( - all_outdoor_rounds[roundname], - group_data["class_HC"][i], - "AGB", - hc_params, - round_score_up=True, - )[0] - for i in range(len(group_data["classes"])) - ] - - # Reduce list based on other criteria besides handicap - # is it a prestige round? If not remove MB scores - if roundname not in AGB_outdoor_classifications[groupname]["prestige_rounds"]: - class_scores[0:3] = [-9999] * 3 - - # 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: - class_scores[i] = -9999 - - # Make sure that hc.eq.score_for_round did not return array to satisfy mypy - if any(isinstance(x, np.ndarray) for x in class_scores): - raise TypeError( - "score_for_round is attempting to return an array when float expected." - ) - # Score threshold should be int (score_for_round called with round=True) - # Enforce this for better code and to satisfy mypy - int_class_scores = [int(x) for x in class_scores] - - return int_class_scores - - -def calculate_AGB_old_indoor_classification( - roundname: str, - score: float, - bowstyle: str, - gender: str, - age_group: str, - hc_scheme: str = "AGBold", -) -> str: - """ - Calculate AGB indoor classification from score. - - Subroutine to calculate a classification from a score given suitable inputs. - Appropriate for 2023 ArcheryGB age groups and classifications. - - Parameters - ---------- - roundname : str - name of round shot as given by 'codename' in json - score : int - numerical score on the round to calculate classification for - bowstyle : str - archer's bowstyle under AGB outdoor target rules - gender : str - archer's gender under AGB outdoor target rules - age_group : str - archer's age group under AGB outdoor target rules - hc_scheme : str - handicap scheme to be used for legacy purposes. Default AGBold - - Returns - ------- - classification_from_score : str - the classification appropriate for this score - - References - ---------- - ArcheryGB 2023 Rules of Shooting - ArcheryGB Shooting Administrative Procedures - SAP7 (2023) - """ - # TODO: Need routines to sanitise/deal with variety of user inputs - - # TODO: Should this be defined outside the function to reduce I/O or does - # it have no effect? - all_indoor_rounds = load_rounds.read_json_to_round_dict( - [ - "AGB_indoor.json", - "WA_indoor.json", - ] - ) - - # deal with reduced categories: - age_group = "Adult" - if bowstyle.lower() not in ("compound"): - bowstyle = "Recurve" - - groupname = get_groupname(bowstyle, gender, age_group) - group_data = AGB_old_indoor_classifications[groupname] - - hc_params = hc_eq.HcParams() - - # Get scores required on this round for each classification - class_scores = [ - hc_eq.score_for_round( - all_indoor_rounds[roundname], - group_data["class_HC"][i], - hc_scheme, - hc_params, - round_score_up=True, - )[0] - for i, class_i in enumerate(group_data["classes"]) - ] - - class_data: Dict[str, Any] = dict(zip(group_data["classes"], class_scores)) - - # What is the highest classification this score gets? - to_del = [] - for classname, classscore in class_data.items(): - if classscore > score: - to_del.append(classname) - for del_class in to_del: - del class_data[del_class] - - # NB No fiddle for Worcester required with this logic... - # Beware of this later on, however, if we wish to rectify the 'anomaly' - - try: - classification_from_score = list(class_data.keys())[0] - return classification_from_score - except IndexError: - # return "UC" - return "unclassified" - - -def AGB_old_indoor_classification_scores( - roundname: str, - bowstyle: str, - gender: str, - age_group: str, - hc_scheme: str = "AGBold", -) -> List[int]: - """ - Calculate AGB indoor classification scores for category. - - Subroutine to calculate classification scores for a specific category and round. - Appropriate ArcheryGB age groups and classifications. - - Parameters - ---------- - roundname : str - name of round shot as given by 'codename' in json - bowstyle : str - archer's bowstyle under AGB outdoor target rules - gender : str - archer's gender under AGB outdoor target rules - age_group : str - archer's age group under AGB outdoor target rules - hc_scheme : str - handicap scheme to be used for legacy purposes. Default AGBold - - Returns - ------- - classification_scores : ndarray - abbreviation of the classification appropriate for this score - - References - ---------- - ArcheryGB Rules of Shooting - ArcheryGB Shooting Administrative Procedures - SAP7 - """ - # TODO: Should this be defined outside the function to reduce I/O or does - # it have no effect? - all_indoor_rounds = load_rounds.read_json_to_round_dict( - [ - "AGB_indoor.json", - "WA_indoor.json", - ] - ) - - # deal with reduced categories: - age_group = "Adult" - if bowstyle.lower() not in ("compound"): - bowstyle = "Recurve" - - groupname = get_groupname(bowstyle, gender, age_group) - group_data = AGB_old_indoor_classifications[groupname] - - hc_params = hc_eq.HcParams() - - # Get scores required on this round for each classification - class_scores = [ - hc_eq.score_for_round( - all_indoor_rounds[roundname], - group_data["class_HC"][i], - hc_scheme, - hc_params, - round_score_up=True, - )[0] - for i, class_i in enumerate(group_data["classes"]) - ] - - # Make sure that hc.eq.score_for_round did not return array to satisfy mypy - if any(isinstance(x, np.ndarray) for x in class_scores): - raise TypeError( - "score_for_round is attempting to return an array when float expected." - ) - # Score threshold should be int (score_for_round called with round=True) - # Enforce this for better code and to satisfy mypy - int_class_scores = [int(x) for x in class_scores] - - return int_class_scores - - -def strip_spots( - roundname: str, -) -> str: - """ - Calculate AGB indoor classification from score. - - Parameters - ---------- - roundname : str - name of round shot as given by 'codename' in json - - Returns - ------- - roundname : str - name of round shot as given by 'codename' in json - - """ - roundname = roundname.replace("_triple", "") - roundname = roundname.replace("_5_centre", "") - return roundname - - -def get_compound_codename(round_codenames): - """ - 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 - - Returns - ------- - round_codenames : str or list of str - list of amended round codenames for compound - - References - ---------- - """ - 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", - "bray_ii": "bray_ii_compound", - "bray_ii_triple": "bray_ii_compound_triple", - "stafford": "stafford_compound", - "portsmouth": "portsmouth_compound", - "portsmouth_triple": "portsmouth_compound_triple", - "vegas": "vegas_compound", - "wa18": "wa18_compound", - "wa18_triple": "wa18_compound_triple", - "wa25": "wa25_compound", - "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] - else: - return round_codenames - - -def calculate_AGB_indoor_classification( - roundname: str, - score: float, - bowstyle: str, - gender: str, - age_group: str, - hc_scheme: str = "AGB", -) -> str: - """ - Calculate new (2023) AGB indoor classification from score. - - Subroutine to calculate a classification from a score given suitable inputs. - Appropriate for 2023 ArcheryGB age groups and classifications. - - Parameters - ---------- - roundname : str - name of round shot as given by 'codename' in json - score : int - numerical score on the round to calculate classification for - bowstyle : str - archer's bowstyle under AGB outdoor target rules - gender : str - archer's gender under AGB outdoor target rules - age_group : str - archer's age group under AGB outdoor target rules - hc_scheme : str - handicap scheme to be used for legacy purposes. Default AGBold - - Returns - ------- - classification_from_score : str - the classification appropriate for this score - - References - ---------- - ArcheryGB 2023 Rules of Shooting - ArcheryGB Shooting Administrative Procedures - SAP7 (2023) - """ - # TODO: Need routines to sanitise/deal with variety of user inputs - - if bowstyle.lower() in ("traditional", "flatbow", "asiatic"): - bowstyle = "Barebow" - - # Get scores required on this round for each classification - # Enforcing full size face and compound scoring (for compounds) - all_class_scores = AGB_indoor_classification_scores( - roundname, - bowstyle, - gender, - age_group, - hc_scheme, - ) - - groupname = get_groupname(bowstyle, gender, age_group) - group_data = AGB_indoor_classifications[groupname] - class_data: Dict[str, Any] = dict(zip(group_data["classes"], all_class_scores)) - - # What is the highest classification this score gets? - # < 0 handles max scores, > score handles higher classifications - to_del = [] - for classname, classscore in class_data.items(): - if classscore < 0 or classscore > score: - to_del.append(classname) - for del_class in to_del: - del class_data[del_class] - - try: - classification_from_score = list(class_data.keys())[0] - return classification_from_score - except IndexError: - # return "UC" - return "unclassified" - - -def AGB_indoor_classification_scores( - roundname: str, - bowstyle: str, - gender: str, - age_group: str, - hc_scheme: str = "AGB", -) -> List[int]: - """ - Calculate new (2023) AGB indoor classification scores for category. - - Subroutine to calculate classification scores for a specific category and round. - Appropriate ArcheryGB age groups and classifications. - - Parameters - ---------- - roundname : str - name of round shot as given by 'codename' in json - bowstyle : str - archer's bowstyle under AGB outdoor target rules - gender : str - archer's gender under AGB outdoor target rules - age_group : str - archer's age group under AGB outdoor target rules - hc_scheme : str - handicap scheme to be used for legacy purposes. Default AGBold - - Returns - ------- - classification_scores : ndarray - scores required for each classification band - - References - ---------- - ArcheryGB Rules of Shooting - ArcheryGB Shooting Administrative Procedures - SAP7 - """ - # TODO: Should this be defined outside the function to reduce I/O or does - # it have no effect? - all_indoor_rounds = load_rounds.read_json_to_round_dict( - [ - "AGB_indoor.json", - "WA_indoor.json", - ] - ) - - # deal with reduced categories: - if bowstyle.lower() in ("flatbow", "traditional", "asiatic"): - bowstyle = "Barebow" - - # enforce compound scoring - if bowstyle.lower() in ("compound"): - roundname = get_compound_codename(roundname) - - groupname = get_groupname(bowstyle, gender, age_group) - group_data = AGB_indoor_classifications[groupname] - - hc_params = hc_eq.HcParams() - - # Get scores required on this round for each classification - # Enforce full size face - class_scores = [ - hc_eq.score_for_round( - all_indoor_rounds[strip_spots(roundname)], - group_data["class_HC"][i], - hc_scheme, - hc_params, - round_score_up=True, - )[0] - for i, class_i in enumerate(group_data["classes"]) - ] - - # Make sure that hc.eq.score_for_round did not return array to satisfy mypy - if any(isinstance(x, np.ndarray) for x in class_scores): - raise TypeError( - "score_for_round is attempting to return an array when float expected." - ) - # Score threshold should be int (score_for_round called with round=True) - # Enforce this for better code and to satisfy mypy - int_class_scores = [int(x) for x in class_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"])): - # if sc == all_indoor_rounds[roundname].max_score(): - next_score = hc_eq.score_for_round( - all_indoor_rounds[strip_spots(roundname)], - np.floor(hc) + 1, - hc_scheme, - hc_params, - round_score_up=True, - )[0] - if next_score == sc: - # If already at max score this classification is impossible - if sc == 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...) - else: - int_class_scores[i] += 1 - - return int_class_scores - - -def calculate_AGB_field_classification( - roundname: str, score: float, bowstyle: str, gender: str, age_group: str -) -> str: - """ - Calculate AGB field classification from score. - - Subroutine to calculate a classification from a score given suitable inputs. - - Parameters - ---------- - roundname : str - name of round shot as given by 'codename' in json - score : int - numerical score on the round to calculate classification for - bowstyle : str - archer's bowstyle under AGB outdoor target rules - gender : str - archer's gender under AGB outdoor target rules - age_group : str - archer's age group under AGB outdoor target rules - - Returns - ------- - classification_from_score : str - the classification appropriate for this score - - References - ---------- - ArcheryGB 2023 Rules of Shooting - ArcheryGB Shooting Administrative Procedures - SAP7 (2023) - """ - # TODO: Need routines to sanitise/deal with variety of user inputs - - # TODO: Should this be defined outside the function to reduce I/O or does - # it have no effect? - all_field_rounds = load_rounds.read_json_to_round_dict( - [ - "WA_field.json", - ] - ) - - # deal with reduced categories: - if age_group.lower().replace(" ", "") in ("adult", "50+", "under21"): - age_group = "Adult" - else: - age_group = "Under 18" - - groupname = get_groupname(bowstyle, gender, age_group) - - # Get scores required on this round for each classification - group_data = AGB_field_classifications[groupname] - - # Check Round is appropriate: - # Sighted can have any Red 24, unsightes can have any blue 24 - if ( - bowstyle.lower() in ("compound", "recurve") - and "wa_field_24_red_" not in roundname - ): - return "unclassified" - if ( - bowstyle.lower() in ("barebow", "longbow", "traditional", "flatbow") - and "wa_field_24_blue_" not in roundname - ): - return "unclassified" - - # What is the highest classification this score gets? - class_scores: Dict[str, Any] = dict( - zip(group_data["classes"], group_data["class_scores"]) - ) - for item in class_scores: - if class_scores[item] > score: - pass - else: - return item - - # if lower than 3rd class score return "UC" - return "unclassified" - - -def AGB_field_classification_scores( - roundname: str, bowstyle: str, gender: str, age_group: str -) -> List[int]: - """ - Calculate AGB field classification scores for category. - - Subroutine to calculate classification scores for a specific category and round. - Appropriate ArcheryGB age groups and classifications. - - Parameters - ---------- - roundname : str - name of round shot as given by 'codename' in json - bowstyle : str - archer's bowstyle under AGB outdoor target rules - gender : str - archer's gender under AGB outdoor target rules - age_group : str - archer's age group under AGB outdoor target rules - - Returns - ------- - classification_scores : ndarray - abbreviation of the classification appropriate for this score - - References - ---------- - ArcheryGB Rules of Shooting - ArcheryGB Shooting Administrative Procedures - SAP7 - """ - # TODO: Should this be defined outside the function to reduce I/O or does - # it have no effect? - all_field_rounds = load_rounds.read_json_to_round_dict( - [ - "WA_field.json", - ] - ) - - # deal with reduced categories: - if age_group.lower().replace(" ", "") in ("adult", "50+", "under21"): - age_group = "Adult" - else: - age_group = "Under 18" - - groupname = get_groupname(bowstyle, gender, age_group) - group_data = AGB_field_classifications[groupname] - - # Get scores required on this round for each classification - class_scores = group_data["class_scores"] - - # Make sure that hc.eq.score_for_round did not return array to satisfy mypy - if any(isinstance(x, np.ndarray) for x in class_scores): - raise TypeError( - "score_for_round is attempting to return an array when float expected." - ) - # Score threshold should be int (score_for_round called with round=True) - # Enforce this for better code and to satisfy mypy - int_class_scores = [int(x) for x in class_scores] - - return int_class_scores - - -if __name__ == "__main__": - for classification in AGB_outdoor_classifications.items(): - print( - classification[0], - classification[1]["prestige_rounds"], - ) - - print( - calculate_AGB_outdoor_classification( - "bristol_ii", 1200, "compound", "male", "adult" - ) - ) - print( - calculate_AGB_outdoor_classification( - "bristol_ii", 1200, "compound", "male", "under15" - ) - ) - print( - calculate_AGB_outdoor_classification( - "bristol_ii", 1200, "compound", "male", "under12" - ) - ) - print( - calculate_AGB_indoor_classification( - "portsmouth_compound_triple", 590, "compound", "male", "adult" - ) - ) diff --git a/archeryutils/classifications/tests/__init__.py b/archeryutils/classifications/tests/__init__.py new file mode 100644 index 0000000..78ca2d1 --- /dev/null +++ b/archeryutils/classifications/tests/__init__.py @@ -0,0 +1 @@ +"""Module providing tests for the classification functionalities.""" diff --git a/archeryutils/classifications/tests/test_agb_field.py b/archeryutils/classifications/tests/test_agb_field.py new file mode 100644 index 0000000..00b883e --- /dev/null +++ b/archeryutils/classifications/tests/test_agb_field.py @@ -0,0 +1,396 @@ +"""Tests for agb field classification functions""" +import pytest + +from archeryutils import load_rounds +import archeryutils.classifications as class_funcs + + +ALL_AGBFIELD_ROUNDS = load_rounds.read_json_to_round_dict( + [ + "WA_field.json", + ] +) + + +class TestAgbFieldClassificationScores: + """ + Class to test the field classification scores function. + + Methods + ------- + test_agb_field_classification_scores_ages() + test if expected scores returned for different ages + test_agb_field_classification_scores_genders() + test if expected scores returned for different genders + test_agb_field_classification_scores_bowstyles() + test if expected scores returned for different bowstyles + test_agb_field_classification_scores_invalid() + test invalid inputs + """ + + @pytest.mark.parametrize( + "roundname,age_group,scores_expected", + [ + ( + "wa_field_24_blue_marked", + "adult", + [328, 307, 279, 252, 224, 197], + ), + ( + "wa_field_24_blue_marked", + "50+", + [328, 307, 279, 252, 224, 197], + ), + ( + "wa_field_24_blue_marked", + "under21", + [328, 307, 279, 252, 224, 197], + ), + ( + "wa_field_24_blue_marked", + "Under 18", + [298, 279, 254, 229, 204, 179], + ), + ( + "wa_field_24_blue_marked", + "Under 12", + [298, 279, 254, 229, 204, 179], + ), + ], + ) + def test_agb_field_classification_scores_ages( + self, + roundname: str, + age_group: str, + scores_expected: str, + ) -> None: + """ + Check that field classification returns expected value for a case. + """ + scores = class_funcs.agb_field_classification_scores( + roundname=roundname, + bowstyle="barebow", + gender="male", + age_group=age_group, + ) + + assert scores == scores_expected + + @pytest.mark.parametrize( + "roundname,gender,age_group,scores_expected", + [ + ( + "wa_field_24_blue_marked", + "male", + "adult", + [328, 307, 279, 252, 224, 197], + ), + ( + "wa_field_24_blue_marked", + "female", + "adult", + [303, 284, 258, 233, 207, 182], + ), + ( + "wa_field_24_blue_marked", + "male", + "Under 18", + [298, 279, 254, 229, 204, 179], + ), + ( + "wa_field_24_blue_marked", + "female", + "Under 18", + [251, 236, 214, 193, 172, 151], + ), + ], + ) + def test_agb_field_classification_scores_genders( + self, + roundname: str, + gender: str, + age_group: str, + scores_expected: str, + ) -> None: + """ + Check that field classification returns expected value for a case. + """ + scores = class_funcs.agb_field_classification_scores( + roundname=roundname, + bowstyle="barebow", + gender=gender, + age_group=age_group, + ) + + assert scores == scores_expected + + @pytest.mark.parametrize( + "roundname,bowstyle,scores_expected", + # Check all systems, different distances, negative and large handicaps. + [ + ( + "wa_field_24_red_marked", + "compound", + [393, 377, 344, 312, 279, 247], + ), + ( + "wa_field_24_red_marked", + "recurve", + [338, 317, 288, 260, 231, 203], + ), + ( + "wa_field_24_blue_marked", + "barebow", + [328, 307, 279, 252, 224, 197], + ), + ( + "wa_field_24_blue_marked", + "traditional", + [262, 245, 223, 202, 178, 157], + ), + ( + "wa_field_24_blue_marked", + "flatbow", + [262, 245, 223, 202, 178, 157], + ), + ( + "wa_field_24_blue_marked", + "longbow", + [201, 188, 171, 155, 137, 121], + ), + ], + ) + def test_agb_field_classification_scores_bowstyles( + self, + roundname: str, + bowstyle: str, + scores_expected: str, + ) -> None: + """ + Check that field classification returns expected value for a case. + """ + scores = class_funcs.agb_field_classification_scores( + roundname=roundname, + bowstyle=bowstyle, + gender="male", + age_group="adult", + ) + + assert scores == scores_expected + + @pytest.mark.parametrize( + "roundname,bowstyle,gender,age_group", + # Check all systems, different distances, negative and large handicaps. + [ + ( + "wa_field_24_red_marked", + "invalidbowstyle", + "male", + "adult", + ), + ( + "wa_field_24_red_marked", + "recurve", + "invalidgender", + "adult", + ), + ( + "wa_field_24_blue_marked", + "barebow", + "male", + "invalidage", + ), + ], + ) + def test_agb_field_classification_scores_invalid( + self, + roundname: str, + bowstyle: str, + gender: str, + age_group: str, + ) -> None: + """ + Check that field classification returns expected value for a case. + """ + with pytest.raises( + KeyError, + match=( + f"{age_group.lower().replace(' ','')}_{gender.lower()}_{bowstyle.lower()}" + ), + ): + _ = class_funcs.agb_field_classification_scores( + roundname=roundname, + bowstyle=bowstyle, + gender=gender, + age_group=age_group, + ) + + +class TestCalculateAgbFieldClassification: + """ + Class to test the field classification function. + + Methods + ------- + test_calculate_agb_field_classification_scores() + test if expected sanitised groupname returned + test_calculate_agb_field_classification() + test if expected full-face roundname returned + """ + + @pytest.mark.parametrize( + "roundname,score,age_group,bowstyle,class_expected", + [ + ( + "wa_field_24_red_marked", + 400, + "adult", + "compound", + "Grand Master Bowman", + ), + ( + "wa_field_24_red_marked", + 337, + "50+", + "recurve", + "Master Bowman", + ), + ( + "wa_field_24_blue_marked", + 306, + "under21", + "barebow", + "Bowman", + ), + ( + "wa_field_24_blue_marked", + 177, + "Under 18", + "traditional", + "1st Class", + ), + ( + "wa_field_24_blue_marked", + 143, + "Under 12", + "flatbow", + "2nd Class", + ), + ( + "wa_field_24_blue_marked", + 96, + "Under 12", + "longbow", + "3rd Class", + ), + ( + "wa_field_24_blue_marked", + 1, + "Under 12", + "longbow", + "unclassified", + ), + ], + ) + def test_calculate_agb_field_classification( + self, + roundname: str, + score: float, + age_group: str, + bowstyle: str, + class_expected: str, + ) -> None: + """ + Check that field classification returns expected value for a few cases. + """ + # pylint: disable=too-many-arguments + class_returned = class_funcs.calculate_agb_field_classification( + roundname=roundname, + score=score, + bowstyle=bowstyle, + gender="male", + age_group=age_group, + ) + + assert class_returned == class_expected + + @pytest.mark.parametrize( + "roundname,score,bowstyle,class_expected", + [ + ( + "wa_field_24_blue_marked", + 400, + "compound", + "unclassified", + ), + ( + "wa_field_24_red_marked", + 337, + "barebow", + "unclassified", + ), + ], + ) + def test_calculate_agb_field_classification_invalid_rounds( + self, + roundname: str, + score: float, + bowstyle: str, + class_expected: str, + ) -> None: + """ + Check that field classification returns unclassified for inappropriate rounds. + """ + class_returned = class_funcs.calculate_agb_field_classification( + roundname=roundname, + score=score, + bowstyle=bowstyle, + gender="male", + age_group="adult", + ) + + assert class_returned == class_expected + + @pytest.mark.parametrize( + "roundname,score", + [ + ( + "wa_field_24_blue_marked", + 1000, + ), + ( + "wa_field_24_blue_marked", + 433, + ), + ( + "wa_field_24_blue_marked", + -1, + ), + ( + "wa_field_24_blue_marked", + -100, + ), + ], + ) + def test_calculate_agb_field_classification_invalid_scores( + self, + roundname: str, + score: float, + ) -> None: + """ + Check that field classification fails for inappropriate scores. + """ + with pytest.raises( + ValueError, + match=( + f"Invalid score of {score} for a {roundname}. " + f"Should be in range 0-{ALL_AGBFIELD_ROUNDS[roundname].max_score()}." + ), + ): + _ = class_funcs.calculate_agb_field_classification( + roundname=roundname, + score=score, + bowstyle="barebow", + gender="male", + age_group="adult", + ) diff --git a/archeryutils/classifications/tests/test_agb_indoor.py b/archeryutils/classifications/tests/test_agb_indoor.py new file mode 100644 index 0000000..7a23371 --- /dev/null +++ b/archeryutils/classifications/tests/test_agb_indoor.py @@ -0,0 +1,404 @@ +"""Tests for agb indoor classification functions""" +from typing import List +import pytest + +from archeryutils import load_rounds +import archeryutils.classifications as class_funcs + + +ALL_INDOOR_ROUNDS = load_rounds.read_json_to_round_dict( + [ + "AGB_indoor.json", + "WA_indoor.json", + ] +) + + +class TestAgbIndoorClassificationScores: + """ + Class to test the agb indoor classification scores function. + + This will implicitly check the dictionary creation. + Provided sufficient options are covered across bowstyles, genders, and ages. + + Methods + ------- + test_agb_indoor_classification_scores_ages() + test if expected scores returned for different ages + test_agb_indoor_classification_scores_genders() + test if expected scores returned for different genders + test_agb_indoor_classification_scores_bowstyles() + test if expected scores returned for different bowstyles + test_agb_indoor_classification_scores_triple_faces() + test if triple faces return full face scores + test_agb_indoor_classification_scores_invalid() + test invalid inputs + test_agb_indoor_classification_scores_invalid_round + test invalid roundname + """ + + @pytest.mark.parametrize( + "age_group,scores_expected", + [ + ( + "adult", + [378, 437, 483, 518, 546, 566, 582, 593], + ), + ( + "50+", + [316, 387, 444, 488, 522, 549, 569, 583], + ), + ( + "under21", + [316, 387, 444, 488, 522, 549, 569, 583], + ), + ( + "Under 18", + [250, 326, 395, 450, 493, 526, 552, 571], + ), + ( + "Under 16", + [187, 260, 336, 403, 457, 498, 530, 555], + ), + ( + "Under 15", + [134, 196, 271, 346, 411, 463, 503, 534], + ), + ( + "Under 14", + [92, 141, 206, 281, 355, 419, 469, 508], + ), + ( + "Under 12", + [62, 98, 149, 215, 291, 364, 426, 475], + ), + ], + ) + def test_agb_indoor_classification_scores_ages( + self, + age_group: str, + scores_expected: List[int], + ) -> None: + """ + Check that classification returns expected value for a case. + """ + scores = class_funcs.agb_indoor_classification_scores( + roundname="portsmouth", + bowstyle="recurve", + gender="male", + age_group=age_group, + ) + + assert scores == scores_expected[::-1] + + @pytest.mark.parametrize( + "age_group,scores_expected", + [ + ( + "adult", + [331, 399, 454, 496, 528, 553, 572, 586], + ), + ( + "Under 16", + [145, 211, 286, 360, 423, 472, 510, 539], + ), + ( + "Under 15", + [134, 196, 271, 346, 411, 463, 503, 534], + ), + ( + "Under 12", + [62, 98, 149, 215, 291, 364, 426, 475], + ), + ], + ) + def test_agb_indoor_classification_scores_genders( + self, + age_group: str, + scores_expected: List[int], + ) -> None: + """ + Check that indoor classification returns expected value for a case. + + Male equivalents already checked above. + Also checks that compound rounds are being enforced. + """ + scores = class_funcs.agb_indoor_classification_scores( + roundname="portsmouth", + bowstyle="recurve", + gender="female", + age_group=age_group, + ) + + assert scores == scores_expected[::-1] + + @pytest.mark.parametrize( + "bowstyle,scores_expected", + [ + ( + "compound", + [472, 508, 532, 549, 560, 571, 583, 594], + ), + ( + "barebow", + [331, 387, 433, 472, 503, 528, 549, 565], + ), + ( + "longbow", + [127, 178, 240, 306, 369, 423, 466, 501], + ), + ], + ) + def test_agb_indoor_classification_scores_bowstyles( + self, + bowstyle: str, + scores_expected: List[int], + ) -> None: + """ + Check that indoor classification returns expected value for a case. + """ + 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", + [ + ( + "portsmouth_triple", + [472, 508, 532, 549, 560, 571, 583, 594], + ), + ( + "worcester_5_centre", + [217, 246, 267, 283, 294, 300, -9999, -9999], + ), + ( + "vegas_300_triple", + [201, 230, 252, 269, 281, 290, 297, 300], + ), + ], + ) + def test_agb_indoor_classification_scores_triple_faces( + self, + roundname: str, + scores_expected: List[int], + ) -> None: + """ + Check that indoor classification returns single face scores only. + Includes check that Worcester returns null above max score. + """ + scores = class_funcs.agb_indoor_classification_scores( + roundname=roundname, + bowstyle="compound", + gender="male", + age_group="adult", + ) + + assert scores == scores_expected[::-1] + + @pytest.mark.parametrize( + "roundname,bowstyle,gender,age_group", + # Check all systems, different distances, negative and large handicaps. + [ + ( + "portsmouth", + "invalidbowstyle", + "male", + "adult", + ), + ( + "portsmouth", + "recurve", + "invalidgender", + "adult", + ), + ( + "portsmouth", + "barebow", + "male", + "invalidage", + ), + ], + ) + def test_agb_indoor_classification_scores_invalid( + self, + roundname: str, + bowstyle: str, + gender: str, + age_group: str, + ) -> None: + """ + Check that indoor classification returns expected value for a case. + """ + with pytest.raises( + KeyError, + match=( + f"{age_group.lower().replace(' ','')}_{gender.lower()}_{bowstyle.lower()}" + ), + ): + _ = class_funcs.agb_indoor_classification_scores( + roundname=roundname, + bowstyle=bowstyle, + gender=gender, + age_group=age_group, + ) + + def test_agb_indoor_classification_scores_invalid_round( + self, + ) -> None: + """ + Check that indoor classification raises error for invalid round. + """ + with pytest.raises( + KeyError, + match=("invalid_roundname"), + ): + _ = class_funcs.agb_indoor_classification_scores( + roundname="invalid_roundname", + bowstyle="barebow", + gender="female", + age_group="adult", + ) + + +class TestCalculateAgbIndoorClassification: + """ + Class to test the indoor classification function. + + Methods + ------- + test_calculate_agb_indoor_classification_scores() + test if expected sanitised groupname returned + test_calculate_agb_indoor_classification() + test if expected full-face roundname returned + test_calculate_agb_indoor_classification_invalid_round() + check corrrect error raised for invalid rounds + test_calculate_agb_indoor_classification_invalid_scores() + check corrrect error raised for invalid scores + """ + + @pytest.mark.parametrize( + "score,age_group,bowstyle,class_expected", + [ + ( + 594, # 1 above GMB + "adult", + "compound", + "I-GMB", + ), + ( + 582, # 1 below GMB + "50+", + "recurve", + "I-MB", + ), + ( + 520, # midway to MB + "under21", + "barebow", + "I-B1", + ), + ( + 551, # 1 below + "Under 18", + "recurve", + "I-B1", + ), + ( + 526, # boundary value + "Under 18", + "recurve", + "I-B1", + ), + ( + 449, # Boundary + "Under 12", + "compound", + "I-B2", + ), + ( + 40, # Midway + "Under 12", + "longbow", + "I-A1", + ), + ( + 12, # On boundary + "Under 12", + "longbow", + "UC", + ), + ( + 1, + "Under 12", + "longbow", + "UC", + ), + ], + ) + def test_calculate_agb_indoor_classification( + self, + score: float, + age_group: str, + bowstyle: str, + class_expected: str, + ) -> None: + """ + Check that indoor classification returns expected value for a few cases. + """ + # pylint: disable=too-many-arguments + class_returned = class_funcs.calculate_agb_indoor_classification( + roundname="portsmouth", + score=score, + bowstyle=bowstyle, + gender="male", + age_group=age_group, + ) + + assert class_returned == class_expected + + def test_calculate_agb_indoor_classification_invalid_round( + self, + ) -> None: + """ + Check that indoor classification returns unclassified for inappropriate rounds. + """ + with pytest.raises( + KeyError, + match=("invalid_roundname"), + ): + _ = class_funcs.calculate_agb_indoor_classification( + roundname="invalid_roundname", + score=400, + bowstyle="recurve", + gender="male", + age_group="adult", + ) + + @pytest.mark.parametrize("score", [1000, 601, -1, -100]) + def test_calculate_agb_indoor_classification_invalid_scores( + self, + score: float, + ) -> None: + """ + Check that indoor classification fails for inappropriate scores. + """ + with pytest.raises( + ValueError, + match=( + f"Invalid score of {score} for a portsmouth. " + f"Should be in range 0-{ALL_INDOOR_ROUNDS['portsmouth'].max_score()}." + ), + ): + _ = class_funcs.calculate_agb_indoor_classification( + roundname="portsmouth", + score=score, + bowstyle="barebow", + gender="male", + age_group="adult", + ) diff --git a/archeryutils/classifications/tests/test_agb_old_indoor.py b/archeryutils/classifications/tests/test_agb_old_indoor.py new file mode 100644 index 0000000..e1fcc78 --- /dev/null +++ b/archeryutils/classifications/tests/test_agb_old_indoor.py @@ -0,0 +1,289 @@ +"""Tests for old agb indoor classification functions""" +from typing import List +import pytest + +from archeryutils import load_rounds +import archeryutils.classifications as class_funcs + + +ALL_INDOOR_ROUNDS = load_rounds.read_json_to_round_dict( + [ + "WA_indoor.json", + "AGB_indoor.json", + ] +) + + +class TestAgbOldIndoorClassificationScores: + """ + Class to test the old_indoor classification scores function. + + Methods + ------- + test_agb_old_indoor_classification_scores_ages() + test if expected scores returned for different ages + test_agb_old_indoor_classification_scores_genders() + test if expected scores returned for different genders + test_agb_old_indoor_classification_scores_bowstyles() + test if expected scores returned for different bowstyles + test_agb_old_indoor_classification_scores_gent_compound_worcester + test supposed loophole in worcester for gent compound + test_agb_old_indoor_classification_scores_invalid() + test invalid inputs + """ + + @pytest.mark.parametrize( + "age_group,scores_expected", + [ + ( + "adult", + [592, 582, 554, 505, 432, 315, 195, 139], + ), + ( + "50+", + [592, 582, 554, 505, 432, 315, 195, 139], + ), + ( + "under21", + [592, 582, 554, 505, 432, 315, 195, 139], + ), + ( + "Under 18", + [592, 582, 554, 505, 432, 315, 195, 139], + ), + ( + "Under 12", + [592, 582, 554, 505, 432, 315, 195, 139], + ), + ], + ) + def test_agb_old_indoor_classification_scores_ages( + self, + age_group: str, + scores_expected: List[int], + ) -> None: + """ + Check that old_indoor classification returns expected value for a case. + ALl ages should return the same values. + """ + scores = class_funcs.agb_old_indoor_classification_scores( + roundname="portsmouth", + bowstyle="recurve", + gender="male", + age_group=age_group, + ) + + assert scores == scores_expected + + def test_agb_old_indoor_classification_scores_genders( + self, + ) -> None: + """ + Check that old_indoor classification returns expected value for a case. + """ + scores = class_funcs.agb_old_indoor_classification_scores( + roundname="portsmouth", + bowstyle="recurve", + gender="female", + age_group="adult", + ) + + assert scores == [582, 569, 534, 479, 380, 255, 139, 93] + + @pytest.mark.parametrize( + "bowstyle,gender,scores_expected", + [ + ( + "compound", + "male", + [581, 570, 554, 529, 484, 396, 279, 206], + ), + ( + "compound", + "female", + [570, 562, 544, 509, 449, 347, 206, 160], + ), + ], + ) + def test_agb_old_indoor_classification_scores_bowstyles( + self, + bowstyle: str, + gender: str, + scores_expected: List[int], + ) -> None: + """ + Check that old_indoor classification returns expected value for a case. + Also checks that compound scoring is enforced. + """ + scores = class_funcs.agb_old_indoor_classification_scores( + roundname="portsmouth", + bowstyle=bowstyle, + gender=gender, + age_group="adult", + ) + + assert scores == scores_expected + + def test_agb_old_indoor_classification_scores_gent_compound_worcester( + self, + ) -> None: + """ + Check gent compound worcester supposed loophole. + """ + scores = class_funcs.agb_old_indoor_classification_scores( + roundname="worcester", + bowstyle="compound", + gender="male", + age_group="adult", + ) + + assert scores == [300, 299, 289, 264, 226, 162, 96, 65] + + @pytest.mark.parametrize( + "bowstyle,gender,age_group", + # Check all systems, different distances, negative and large handicaps. + [ + # No invalid bowstyle as anything non-compound returns non-compound. + # No invalid age as only one table for all ages. + ( + "recurve", + "invalidgender", + "adult", + ), + ], + ) + def test_agb_old_indoor_classification_scores_invalid( + self, + bowstyle: str, + gender: str, + age_group: str, + ) -> None: + """ + Check that old_indoor classification returns expected value for a case. + """ + with pytest.raises( + KeyError, + match=( + f"{age_group.lower().replace(' ','')}_{gender.lower()}_{bowstyle.lower()}" + ), + ): + _ = class_funcs.agb_old_indoor_classification_scores( + roundname="portsmouth", + bowstyle=bowstyle, + gender=gender, + age_group=age_group, + ) + + +class TestCalculateAgbOldIndoorClassification: + """ + Class to test the old_indoor classification function. + + Methods + ------- + test_calculate_agb_old_indoor_classification() + test_calculate_agb_old_indoor_classification_invalid_scores() + """ + + @pytest.mark.parametrize( + "score,gender,class_expected", + [ + ( + 400, + "male", + "F", + ), + ( + 337, + "female", + "F", + ), + ( + 592, + "male", + "A", + ), + ( + 582, + "female", + "A", + ), + ( + 581, + "male", + "C", + ), + ( + 120, + "male", + "UC", + ), + ( + 1, + "male", + "UC", + ), + ], + ) + def test_calculate_agb_old_indoor_classification( + self, + score: float, + gender: str, + class_expected: str, + ) -> None: + """ + Check that old_indoor classification returns expected value for a few cases. + """ + class_returned = class_funcs.calculate_agb_old_indoor_classification( + roundname="portsmouth", + score=score, + bowstyle="recurve", + gender=gender, + age_group="adult", + ) + + assert class_returned == class_expected + + @pytest.mark.parametrize( + "roundname,score", + [ + ( + "portsmouth", + 1000, + ), + ( + "portsmouth", + 601, + ), + ( + "portsmouth", + -1, + ), + ( + "portsmouth", + -100, + ), + ], + ) + def test_calculate_agb_old_indoor_classification_invalid_scores( + self, + roundname: str, + score: float, + ) -> None: + """ + Check that old_indoor classification fails for inappropriate scores. + """ + with pytest.raises( + ValueError, + match=( + f"Invalid score of {score} for a {roundname}. " + f"Should be in range 0-{ALL_INDOOR_ROUNDS[roundname].max_score()}." + ), + ): + _ = class_funcs.calculate_agb_old_indoor_classification( + roundname=roundname, + score=score, + bowstyle="barebow", + gender="male", + age_group="adult", + ) diff --git a/archeryutils/classifications/tests/test_agb_outdoor.py b/archeryutils/classifications/tests/test_agb_outdoor.py new file mode 100644 index 0000000..834f747 --- /dev/null +++ b/archeryutils/classifications/tests/test_agb_outdoor.py @@ -0,0 +1,544 @@ +"""Tests for agb indoor classification functions""" +from typing import List +import pytest + +from archeryutils import load_rounds +import archeryutils.classifications as class_funcs + + +ALL_OUTDOOR_ROUNDS = load_rounds.read_json_to_round_dict( + [ + "AGB_outdoor_imperial.json", + "AGB_outdoor_metric.json", + "WA_outdoor.json", + ] +) + + +class TestAgbOutdoorClassificationScores: + """ + Class to test the agb outdoor classification scores function. + + This will implicitly check the dictionary creation. + Provided sufficient options are covered across bowstyles, genders, and ages. + + Methods + ------- + test_agb_outdoor_classification_scores_ages() + test if expected scores returned for different ages + test_agb_outdoor_classification_scores_genders() + test if expected scores returned for different genders + test_agb_outdoor_classification_scores_bowstyles() + test if expected scores returned for different bowstyles + test_agb_outdoor_classification_scores_triple_faces() + test if triple faces return full face scores + test_agb_outdoor_classification_scores_invalid() + test invalid inputs + test_agb_outdoor_classification_scores_invalid_round + test invalid roundname + """ + + @pytest.mark.parametrize( + "roundname,age_group,scores_expected", + [ + ( + "wa1440_90", + "adult", + [426, 566, 717, 866, 999, 1110, 1197, 1266, 1320], + ), + ( + "wa1440_70", + "50+", + [364, 503, 659, 817, 960, 1079, 1173, 1247, 1305], + ), + ( + "wa1440_90", + "under21", + [313, 435, 577, 728, 877, 1008, 1117, 1203, 1270], + ), + ( + "wa1440_70", + "Under 18", + [259, 373, 514, 671, 828, 969, 1086, 1179, 1252], + ), + ( + "wa1440_60", + "Under 16", + [227, 335, 474, 635, 799, 946, 1068, 1165, 1241], + ), + ( + "metric_iii", + "Under 15", + [270, 389, 534, 693, 849, 988, 1101, 1191, 1261], + ), + ( + "metric_iv", + "Under 14", + [396, 524, 666, 814, 952, 1070, 1166, 1242, 1301], + ), + ( + "metric_v", + "Under 12", + [406, 550, 706, 858, 992, 1104, 1193, 1263, 1317], + ), + ], + ) + def test_agb_outdoor_classification_scores_ages( + self, + roundname: str, + age_group: str, + scores_expected: List[int], + ) -> None: + """ + Check that classification returns expected value for a case. + """ + scores = class_funcs.agb_outdoor_classification_scores( + roundname=roundname, + bowstyle="recurve", + gender="male", + age_group=age_group, + ) + + assert scores == scores_expected[::-1] + + @pytest.mark.parametrize( + "roundname,age_group,scores_expected", + [ + ( + "wa1440_70", + "adult", + [392, 536, 693, 849, 988, 1101, 1191, 1261, 1316], + ), + ( + "metric_iii", + "Under 16", + [293, 418, 567, 727, 881, 1014, 1122, 1207, 1274], + ), + ( + "metric_iii", + "Under 15", + [270, 389, 534, 693, 849, 988, 1101, 1191, 1261], + ), + ( + "metric_v", + "Under 12", + [406, 550, 706, 858, 992, 1104, 1193, 1263, 1317], + ), + ], + ) + def test_agb_outdoor_classification_scores_genders( + self, + roundname: str, + age_group: str, + scores_expected: List[int], + ) -> None: + """ + Check that outdoor classification returns expected value for a case. + + Male equivalents already checked above. + Also checks that compound rounds are being enforced. + """ + scores = class_funcs.agb_outdoor_classification_scores( + roundname=roundname, + bowstyle="recurve", + gender="female", + age_group=age_group, + ) + + assert scores == scores_expected[::-1] + + @pytest.mark.parametrize( + "roundname,bowstyle,gender,scores_expected", + [ + ( + "wa1440_90", + "compound", + "male", + [866, 982, 1081, 1162, 1229, 1283, 1327, 1362, 1389], + ), + ( + "wa1440_70", + "compound", + "female", + [870, 988, 1086, 1167, 1233, 1286, 1330, 1364, 1392], + ), + ( + "wa1440_90", + "barebow", + "male", + [290, 380, 484, 598, 717, 835, 945, 1042, 1124], + ), + ( + "wa1440_70", + "barebow", + "female", + [252, 338, 441, 558, 682, 806, 921, 1023, 1108], + ), + ( + "wa1440_90", + "longbow", + "male", + [85, 124, 177, 248, 337, 445, 566, 696, 825], + ), + ( + "wa1440_70", + "longbow", + "female", + [64, 94, 136, 195, 274, 373, 493, 625, 761], + ), + ], + ) + def test_agb_outdoor_classification_scores_bowstyles( + self, + roundname: str, + bowstyle: str, + gender: str, + scores_expected: List[int], + ) -> None: + """ + Check that outdoor classification returns expected value for a case. + """ + 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", + [ + ( + "wa1440_90_small", + [866, 982, 1081, 1162, 1229, 1283, 1327, 1362, 1389], + ), + ], + ) + def test_agb_outdoor_classification_scores_triple_faces( + self, + roundname: str, + scores_expected: List[int], + ) -> None: + """ + Check that outdoor classification returns single face scores only. + Includes check that Worcester returns null above max score. + """ + scores = class_funcs.agb_outdoor_classification_scores( + roundname=roundname, + bowstyle="compound", + gender="male", + age_group="adult", + ) + + assert scores == scores_expected[::-1] + + @pytest.mark.parametrize( + "roundname,bowstyle,gender,age_group", + # Check all systems, different distances, negative and large handicaps. + [ + ( + "wa1440_90", + "invalidbowstyle", + "male", + "adult", + ), + ( + "wa1440_90", + "recurve", + "invalidgender", + "adult", + ), + ( + "wa1440_90", + "barebow", + "male", + "invalidage", + ), + ], + ) + def test_agb_outdoor_classification_scores_invalid( + self, + roundname: str, + bowstyle: str, + gender: str, + age_group: str, + ) -> None: + """ + Check that outdoor classification returns expected value for a case. + """ + with pytest.raises( + KeyError, + match=( + f"{age_group.lower().replace(' ','')}_{gender.lower()}_{bowstyle.lower()}" + ), + ): + _ = class_funcs.agb_outdoor_classification_scores( + roundname=roundname, + bowstyle=bowstyle, + gender=gender, + age_group=age_group, + ) + + def test_agb_outdoor_classification_scores_invalid_round( + self, + ) -> None: + """ + Check that outdoor classification raises error for invalid round. + """ + with pytest.raises( + KeyError, + match=("invalid_roundname"), + ): + _ = class_funcs.agb_outdoor_classification_scores( + roundname="invalid_roundname", + bowstyle="barebow", + gender="female", + age_group="adult", + ) + + +class TestCalculateAgbOutdoorClassification: + """ + Class to test the outdoor classification function. + + Methods + ------- + test_calculate_agb_outdoor_classification() + test if expected full-face roundname returned + test_calculate_agb_outdoor_classification_prestige() + check prestige round are working + test_calculate_agb_outdoor_classification_invalid_round() + check corrrect error raised for invalid rounds + test_calculate_agb_outdoor_classification_invalid_scores() + check corrrect error raised for invalid scores + """ + + @pytest.mark.parametrize( + "roundname,score,age_group,bowstyle,class_expected", + [ + ( + "wa1440_90", + 1390, # 1 above EMB + "adult", + "compound", + "EMB", + ), + ( + "wa1440_70", + 1382, # 1 below EMB + "50+", + "compound", + "GMB", + ), + ( + "wa1440_90", + 900, # midway MB + "under21", + "barebow", + "MB", + ), + ( + "wa1440_70", + 1269, # 1 below MB + "Under 18", + "compound", + "B1", + ), + ( + "wa1440_70", + 969, # boundary value + "Under 18", + "recurve", + "B1", + ), + ( + "metric_v", + 992, # Boundary + "Under 12", + "recurve", + "B2", + ), + ( + "metric_v", + 222, # Midway + "Under 12", + "longbow", + "A1", + ), + ( + "metric_v", + 91, # On boundary + "Under 12", + "longbow", + "UC", + ), + ( + "metric_v", + 1, + "Under 12", + "longbow", + "UC", + ), + ], + ) + def test_calculate_agb_outdoor_classification( + self, + roundname: str, + score: float, + age_group: str, + bowstyle: str, + class_expected: str, + ) -> None: + """ + Check that outdoor classification returns expected value for a few cases. + """ + # pylint: disable=too-many-arguments + class_returned = class_funcs.calculate_agb_outdoor_classification( + roundname=roundname, + score=score, + bowstyle=bowstyle, + gender="male", + age_group=age_group, + ) + + assert class_returned == class_expected + + @pytest.mark.parametrize( + "roundname,score,age_group,bowstyle,class_expected", + [ + ( + "wa720_70", # Not prestige only 70m => B2 + 720, + "adult", + "compound", + "B2", + ), + ( + "wa720_50_b", # Not prestige only 50m => A1 + 720, + "adult", + "compound", + "A1", + ), + ( + "wa720_50_c", # Prestige => EMB + 720, + "adult", + "compound", + "EMB", + ), + ( + "metric_80_30", # This and next 2 check Prestige by age + 720, + "adult", + "compound", + "A3", # 30m for adults gets A3 + ), + ( + "metric_80_30", + 720, + "Under 14", + "compound", + "B3", # Max dist reqd. for B1 and B2 + ), + ( + "metric_80_30", + 720, + "Under 12", + "compound", + "EMB", # Age appropriate + ), + ( + "metric_122_50", + 720, + "Under 16", + "compound", + "B2", # Under 16+ Max dist reqd. for B1 (not B2) + ), + ( + "wa720_60", # Recurve 50+ get 60m 720 + 720, + "50+", + "recurve", + "EMB", + ), + ( + "wa720_60", # Recurve U18 get 60m 720 + 720, + "Under 18", + "recurve", + "EMB", + ), + ( + "metric_122_50", # Recurve U18 get 50m Metric 122 + 720, + "Under 16", + "recurve", + "EMB", + ), + ], + ) + def test_calculate_agb_outdoor_classification_prestige_dist( + self, + roundname: str, + score: float, + age_group: str, + bowstyle: str, + class_expected: str, + ) -> None: + """ + Check that prestige and distanec limitations are working for a few cases. + """ + # pylint: disable=too-many-arguments + class_returned = class_funcs.calculate_agb_outdoor_classification( + roundname=roundname, + score=score, + bowstyle=bowstyle, + gender="male", + age_group=age_group, + ) + + assert class_returned == class_expected + + def test_calculate_agb_outdoor_classification_invalid_round( + self, + ) -> None: + """ + Check that outdoor classification returns unclassified for inappropriate rounds. + """ + with pytest.raises( + KeyError, + match=("invalid_roundname"), + ): + _ = class_funcs.calculate_agb_outdoor_classification( + roundname="invalid_roundname", + score=400, + bowstyle="recurve", + gender="male", + age_group="adult", + ) + + @pytest.mark.parametrize("score", [3000, 1441, -1, -100]) + def test_calculate_agb_outdoor_classification_invalid_scores( + self, + score: float, + ) -> None: + """ + Check that outdoor classification fails for inappropriate scores. + """ + with pytest.raises( + ValueError, + match=( + f"Invalid score of {score} for a wa1440_90. " + f"Should be in range 0-{ALL_OUTDOOR_ROUNDS['wa1440_90'].max_score()}." + ), + ): + _ = class_funcs.calculate_agb_outdoor_classification( + roundname="wa1440_90", + score=score, + bowstyle="barebow", + gender="male", + age_group="adult", + ) diff --git a/archeryutils/classifications/tests/test_classification_utils.py b/archeryutils/classifications/tests/test_classification_utils.py new file mode 100644 index 0000000..e1c27d9 --- /dev/null +++ b/archeryutils/classifications/tests/test_classification_utils.py @@ -0,0 +1,69 @@ +"""Tests for classification utilities""" +import pytest + +import archeryutils.classifications.classification_utils as class_utils + + +class TestStringUtils: + """ + Class to test the get_groupname() function of handicap_equations. + + Methods + ------- + test_get_groupname() + test if expected sanitised groupname returned + test_strip_spots() + test if expected full-face roundname returned + """ + + @pytest.mark.parametrize( + "bowstyle,age_group,gender,groupname_expected", + # Check all systems, different distances, negative and large handicaps. + [ + ("barebow", "adult", "male", "adult_male_barebow"), + ("Barebow", "Adult", "Male", "adult_male_barebow"), + ("Barebow", "Under 18", "Male", "under18_male_barebow"), + ("RECURVE", "UnDeR 18", "femaLe", "under18_female_recurve"), + ], + ) + def test_get_groupname( + self, + age_group: str, + gender: str, + bowstyle: str, + groupname_expected: str, + ) -> None: + """ + Check that get_groupname(handicap=float) returns expected value for a case. + """ + groupname = class_utils.get_groupname( + bowstyle=bowstyle, + gender=gender, + age_group=age_group, + ) + + assert groupname == groupname_expected + + @pytest.mark.parametrize( + "roundname,strippedname_expected", + # Check all systems, different distances, negative and large handicaps. + [ + ("portsmouth", "portsmouth"), + ("portsmouth_triple", "portsmouth"), + ("portsmouth_compound", "portsmouth_compound"), + ("portsmouth_compound_triple", "portsmouth_compound"), + ("portsmouth_triple_compound", "portsmouth_compound"), + ("worcester_5_centre", "worcester"), + ], + ) + def test_strip_spots( + self, + roundname: str, + strippedname_expected: str, + ) -> None: + """ + Check that strip_spots() returns expected value for a round. + """ + strippedname = class_utils.strip_spots(roundname) + + assert strippedname == strippedname_expected