From 4d5bd4b35324d3822ff8f5401d80bb2ba555a1cf Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Sat, 17 Jun 2023 17:02:36 +0100 Subject: [PATCH 01/56] Refactor for new indoor classifications (in development). --- .../classifications/AGB_bowstyles.json | 51 ++- .../classifications/AGB_classes_in.json | 10 + ...{AGB_classes.json => AGB_classes_out.json} | 0 .../classifications/classifications.py | 380 +++++++++++++++++- 4 files changed, 403 insertions(+), 38 deletions(-) create mode 100644 archeryutils/classifications/AGB_classes_in.json rename archeryutils/classifications/{AGB_classes.json => AGB_classes_out.json} (100%) diff --git a/archeryutils/classifications/AGB_bowstyles.json b/archeryutils/classifications/AGB_bowstyles.json index 972db59..0122d46 100644 --- a/archeryutils/classifications/AGB_bowstyles.json +++ b/archeryutils/classifications/AGB_bowstyles.json @@ -1,26 +1,45 @@ [ {"bowstyle" : "Compound", - "datum" : 15, - "classStep" : 6, - "genderStep" : 4, - "ageStep" : 6 + "datum_out" : 15, + "classStep_out" : 6, + "genderStep_out" : 4, + "ageStep_out" : 6, + "datum_in" : 15, + "classStep_in" : 6, + "genderStep_in" : 4, + "ageStep_in" : 6 }, {"bowstyle" : "Recurve", - "datum" : 30, - "classStep" : 7, - "genderStep" : 5, - "ageStep" : 6.5 + "datum_out" : 30, + "classStep_out" : 7, + "genderStep_out" : 5, + "ageStep_out" : 6.5, + "datum_in" : 30, + "classStep_in" : 7, + "genderStep_in" : 5, + "ageStep_in" : 6.5 + }, {"bowstyle" : "Barebow", - "datum" : 47, - "classStep" : 5.5, - "genderStep" : 5.5, - "ageStep" : 5.5 + "datum_out" : 47, + "classStep_out" : 5.5, + "genderStep_out" : 5.5, + "ageStep_out" : 5.5, + "datum_in" : 47, + "classStep_in" : 5.5, + "genderStep_in" : 5.5, + "ageStep_in" : 5.5 + }, {"bowstyle" : "Longbow", - "datum" : 65, - "classStep" : 6, - "genderStep" : 7, - "ageStep" : 6 + "datum_out" : 65, + "classStep_out" : 6, + "genderStep_out" : 7, + "ageStep_out" : 6, + "datum_in" : 65, + "classStep_in" : 6, + "genderStep_in" : 7, + "ageStep_in" : 6 + } ] diff --git a/archeryutils/classifications/AGB_classes_in.json b/archeryutils/classifications/AGB_classes_in.json new file mode 100644 index 0000000..2e45d14 --- /dev/null +++ b/archeryutils/classifications/AGB_classes_in.json @@ -0,0 +1,10 @@ +{ + "classes" : ["MB1", "MB2", "B1", "B2", "A1", "A2"], + "classes_long" : ["Master Bowman 1", + "Master Bowman 2", + "Bowman 1", + "Bowman 2", + "Archer 1", + "Archer 2" + ] +} diff --git a/archeryutils/classifications/AGB_classes.json b/archeryutils/classifications/AGB_classes_out.json similarity index 100% rename from archeryutils/classifications/AGB_classes.json rename to archeryutils/classifications/AGB_classes_out.json diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index 039f4b9..2fdf832 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -11,13 +11,15 @@ read_ages_json read_bowstyles_json read_genders_json -read_classes_json +read_classes_out_json get_groupname _make_AGB_outdoor_classification_dict -_make_AGB_indoor_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 @@ -124,11 +126,11 @@ def read_genders_json( ) -def read_classes_json( - classes_file: Path = Path(__file__).parent / "AGB_classes.json", +def read_classes_out_json( + classes_file: Path = Path(__file__).parent / "AGB_classes_out.json", ) -> Dict[str, Any]: """ - Read AGB classes in from neighbouring json file to dict. + Read AGB outdoor classes in from neighbouring json file to dict. Parameters ---------- @@ -150,7 +152,39 @@ def read_classes_json( if isinstance(classes, dict): return classes raise TypeError( - f"Unexpected genders input when reading from json file. " + 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}." ) @@ -273,9 +307,9 @@ def _make_AGB_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: # Read in gender info as list of dicts AGB_genders = read_genders_json() # Read in classification names as dict - AGB_classes_info = read_classes_json() - AGB_classes = AGB_classes_info["classes"] - AGB_classes_long = AGB_classes_info["classes_long"] + 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 @@ -304,15 +338,15 @@ def _make_AGB_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: max_dist = age[gender.lower()] max_dist_index = dists.index(min(max_dist)) - class_HC = np.empty(len(AGB_classes)) - min_dists = np.empty((len(AGB_classes), 3)) - for i in range(len(AGB_classes)): + 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"] - + age_steps * bowstyle["ageStep"] - + gender_steps * bowstyle["genderStep"] - + (i - 2) * bowstyle["classStep"] + 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 @@ -402,18 +436,139 @@ def _make_AGB_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: # 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, + "classes": AGB_classes_out, "class_HC": class_HC, "prestige_rounds": prestige_rounds, "max_distance": max_dist, "min_dists": min_dists, - "classes_long": AGB_classes_long, + "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) + """ + # Lists of prestige rounds defined by 'codename' of 'Round' class + # TODO: convert this to json? + # 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. + prestige_imperial = [ + "portsmouth", + "portsmouth_triple", + ] + prestige_metric = [ + "wa18", + "wa18_triple", + "wa25", + "wa25_triple", + ] + + 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 - 2) * bowstyle["classStep_in"] + ) + + # Assign prestige rounds for the category + # - no dependence on bowstyle, distance, and age + prestige_rounds = prestige_imperial + prestige_metric + if bowstyle["bowstyle"].lower() == "compound": + prestige_rounds = [ + i + "_compound" if "triple" not in i else i + for i in prestige_rounds + ] + prestige_rounds = [ + i.replace("_triple", "_compound_triple") + for i in prestige_rounds + ] + print(prestige_rounds) + # 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, + "prestige_rounds": prestige_rounds, + "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. @@ -608,10 +763,12 @@ def _make_AGB_field_classification_dict() -> Dict[str, Dict[str, Any]]: 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 @@ -660,7 +817,7 @@ def calculate_AGB_outdoor_classification( ] ) - if bowstyle.lower() in ("traditional", "flatbow"): + if bowstyle.lower() in ("traditional", "flatbow", "asiatic"): bowstyle = "Barebow" groupname = get_groupname(bowstyle, gender, age_group) @@ -763,6 +920,9 @@ def AGB_outdoor_classification_scores( ] ) + if bowstyle.lower() in ("traditional", "flatbow", "asiatic"): + bowstyle = "Barebow" + groupname = get_groupname(bowstyle, gender, age_group) group_data = AGB_outdoor_classifications[groupname] @@ -803,7 +963,7 @@ def AGB_outdoor_classification_scores( return int_class_scores -def calculate_AGB_indoor_classification( +def calculate_AGB_old_indoor_classification( roundname: str, score: float, bowstyle: str, @@ -859,7 +1019,7 @@ def calculate_AGB_indoor_classification( bowstyle = "Recurve" groupname = get_groupname(bowstyle, gender, age_group) - group_data = AGB_indoor_classifications[groupname] + group_data = AGB_old_indoor_classifications[groupname] hc_params = hc_eq.HcParams() @@ -896,7 +1056,7 @@ def calculate_AGB_indoor_classification( return "unclassified" -def AGB_indoor_classification_scores( +def AGB_old_indoor_classification_scores( roundname: str, bowstyle: str, gender: str, @@ -946,6 +1106,177 @@ def AGB_indoor_classification_scores( 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 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 + + # 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" + + 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 + 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' + # TODO Treat indoor maximum scores + + 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 + 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: + if bowstyle.lower() in ("flatbow", "traditional", "asiatic"): + bowstyle = "Barebow" + groupname = get_groupname(bowstyle, gender, age_group) group_data = AGB_indoor_classifications[groupname] @@ -1138,3 +1469,8 @@ def AGB_field_classification_scores( "bristol_ii", 1200, "compound", "male", "under12" ) ) + print( + calculate_AGB_indoor_classification( + "portsmouth_compound_triple", 590, "compound", "male", "adult" + ) + ) From ef4211cf0712e67340a08704b39bb995ac38dd07 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Tue, 20 Jun 2023 21:18:12 +0100 Subject: [PATCH 02/56] Add location attribute to classes files. --- archeryutils/classifications/AGB_classes_in.json | 1 + archeryutils/classifications/AGB_classes_out.json | 1 + 2 files changed, 2 insertions(+) diff --git a/archeryutils/classifications/AGB_classes_in.json b/archeryutils/classifications/AGB_classes_in.json index 2e45d14..22f06d4 100644 --- a/archeryutils/classifications/AGB_classes_in.json +++ b/archeryutils/classifications/AGB_classes_in.json @@ -1,4 +1,5 @@ { + "location": "indoor", "classes" : ["MB1", "MB2", "B1", "B2", "A1", "A2"], "classes_long" : ["Master Bowman 1", "Master Bowman 2", diff --git a/archeryutils/classifications/AGB_classes_out.json b/archeryutils/classifications/AGB_classes_out.json index eb52d29..4caac12 100644 --- a/archeryutils/classifications/AGB_classes_out.json +++ b/archeryutils/classifications/AGB_classes_out.json @@ -1,4 +1,5 @@ { + "location": "outdoor", "classes" : ["EMB", "GMB", "MB", "B1", "B2", "B3", "A1", "A2", "A3"], "classes_long" : ["Elite Master Bowman", "Grand Master Bowman", From 93faf58b8abfad323c7f1cf7ba75ca7a26f79d3b Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Wed, 21 Jun 2023 10:50:25 +0100 Subject: [PATCH 03/56] Update indoor classifications to may across current outdoor range. --- archeryutils/classifications/AGB_bowstyles.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/archeryutils/classifications/AGB_bowstyles.json b/archeryutils/classifications/AGB_bowstyles.json index 0122d46..824f37a 100644 --- a/archeryutils/classifications/AGB_bowstyles.json +++ b/archeryutils/classifications/AGB_bowstyles.json @@ -4,8 +4,8 @@ "classStep_out" : 6, "genderStep_out" : 4, "ageStep_out" : 6, - "datum_in" : 15, - "classStep_in" : 6, + "datum_in" : 18, + "classStep_in" : 9, "genderStep_in" : 4, "ageStep_in" : 6 }, @@ -14,8 +14,8 @@ "classStep_out" : 7, "genderStep_out" : 5, "ageStep_out" : 6.5, - "datum_in" : 30, - "classStep_in" : 7, + "datum_in" : 33.5, + "classStep_in" : 10.5, "genderStep_in" : 5, "ageStep_in" : 6.5 @@ -25,8 +25,8 @@ "classStep_out" : 5.5, "genderStep_out" : 5.5, "ageStep_out" : 5.5, - "datum_in" : 47, - "classStep_in" : 5.5, + "datum_in" : 49.75, + "classStep_in" : 8.25, "genderStep_in" : 5.5, "ageStep_in" : 5.5 @@ -36,8 +36,8 @@ "classStep_out" : 6, "genderStep_out" : 7, "ageStep_out" : 6, - "datum_in" : 65, - "classStep_in" : 6, + "datum_in" : 68, + "classStep_in" : 9, "genderStep_in" : 7, "ageStep_in" : 6 From 5edc1f52794b4f291798fc04d3e9cb735cb43dc6 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Wed, 21 Jun 2023 11:44:32 +0100 Subject: [PATCH 04/56] Update indoor classifications pinning MB together. --- archeryutils/classifications/AGB_bowstyles.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/archeryutils/classifications/AGB_bowstyles.json b/archeryutils/classifications/AGB_bowstyles.json index 824f37a..de5a389 100644 --- a/archeryutils/classifications/AGB_bowstyles.json +++ b/archeryutils/classifications/AGB_bowstyles.json @@ -4,7 +4,7 @@ "classStep_out" : 6, "genderStep_out" : 4, "ageStep_out" : 6, - "datum_in" : 18, + "datum_in" : 15, "classStep_in" : 9, "genderStep_in" : 4, "ageStep_in" : 6 @@ -14,7 +14,7 @@ "classStep_out" : 7, "genderStep_out" : 5, "ageStep_out" : 6.5, - "datum_in" : 33.5, + "datum_in" : 30, "classStep_in" : 10.5, "genderStep_in" : 5, "ageStep_in" : 6.5 @@ -25,7 +25,7 @@ "classStep_out" : 5.5, "genderStep_out" : 5.5, "ageStep_out" : 5.5, - "datum_in" : 49.75, + "datum_in" : 47, "classStep_in" : 8.25, "genderStep_in" : 5.5, "ageStep_in" : 5.5 @@ -36,7 +36,7 @@ "classStep_out" : 6, "genderStep_out" : 7, "ageStep_out" : 6, - "datum_in" : 68, + "datum_in" : 65, "classStep_in" : 9, "genderStep_in" : 7, "ageStep_in" : 6 From 93ded8cb6ea589c94b65bd66cff8b219e92c75c8 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Wed, 21 Jun 2023 12:17:35 +0100 Subject: [PATCH 05/56] Update indoor classifications to only have 2 MB tiers and remove non-prestige. --- archeryutils/classifications/classifications.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index 2fdf832..567b46a 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -540,7 +540,7 @@ def _make_AGB_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: bowstyle["datum_in"] + age_steps * bowstyle["ageStep_in"] + gender_steps * bowstyle["genderStep_in"] - + (i - 2) * bowstyle["classStep_in"] + + (i - 1) * bowstyle["classStep_in"] ) # Assign prestige rounds for the category @@ -555,7 +555,7 @@ def _make_AGB_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: i.replace("_triple", "_compound_triple") for i in prestige_rounds ] - print(prestige_rounds) + # TODO: class names and long are duplicated many times here # Consider a method to reduce this (affects other code) classification_dict[groupname] = { @@ -1208,6 +1208,13 @@ def calculate_AGB_indoor_classification( class_data: Dict[str, Any] = dict(zip(group_data["classes"], class_scores)) + # is it a prestige round? If not remove MB as an option + if roundname not in AGB_indoor_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:2]: + del class_data[MB_class] + # What is the highest classification this score gets? to_del = [] for classname, classscore in class_data.items(): @@ -1294,6 +1301,11 @@ def AGB_indoor_classification_scores( for i, class_i in enumerate(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_indoor_classifications[groupname]["prestige_rounds"]: + class_scores[0:2] = [-9999] * 2 + # 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( From d6a999189ceeb4ebbe8c5f1f4e76e539779ebe55 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Tue, 1 Aug 2023 22:43:10 +0100 Subject: [PATCH 06/56] Update indoor classifications as discussed with AGB. 8 tiers, no prestige rounds. --- .../classifications/AGB_bowstyles.json | 8 ++-- .../classifications/AGB_classes_in.json | 16 ++++---- .../classifications/classifications.py | 38 ------------------- 3 files changed, 13 insertions(+), 49 deletions(-) diff --git a/archeryutils/classifications/AGB_bowstyles.json b/archeryutils/classifications/AGB_bowstyles.json index de5a389..0122d46 100644 --- a/archeryutils/classifications/AGB_bowstyles.json +++ b/archeryutils/classifications/AGB_bowstyles.json @@ -5,7 +5,7 @@ "genderStep_out" : 4, "ageStep_out" : 6, "datum_in" : 15, - "classStep_in" : 9, + "classStep_in" : 6, "genderStep_in" : 4, "ageStep_in" : 6 }, @@ -15,7 +15,7 @@ "genderStep_out" : 5, "ageStep_out" : 6.5, "datum_in" : 30, - "classStep_in" : 10.5, + "classStep_in" : 7, "genderStep_in" : 5, "ageStep_in" : 6.5 @@ -26,7 +26,7 @@ "genderStep_out" : 5.5, "ageStep_out" : 5.5, "datum_in" : 47, - "classStep_in" : 8.25, + "classStep_in" : 5.5, "genderStep_in" : 5.5, "ageStep_in" : 5.5 @@ -37,7 +37,7 @@ "genderStep_out" : 7, "ageStep_out" : 6, "datum_in" : 65, - "classStep_in" : 9, + "classStep_in" : 6, "genderStep_in" : 7, "ageStep_in" : 6 diff --git a/archeryutils/classifications/AGB_classes_in.json b/archeryutils/classifications/AGB_classes_in.json index 22f06d4..8ed79f2 100644 --- a/archeryutils/classifications/AGB_classes_in.json +++ b/archeryutils/classifications/AGB_classes_in.json @@ -1,11 +1,13 @@ { "location": "indoor", - "classes" : ["MB1", "MB2", "B1", "B2", "A1", "A2"], - "classes_long" : ["Master Bowman 1", - "Master Bowman 2", - "Bowman 1", - "Bowman 2", - "Archer 1", - "Archer 2" + "classes" : ["GMB-I", "MB-I", "B1-I", "B2-I", "B3-I", "A1-I", "A2-I", "A3-I"], + "classes_long" : ["Grand Master Bowman (Indoor)", + "Master Bowman (Indoor)", + "Bowman 1 (Indoor)", + "Bowman 2 (Indoor)", + "Bowman 3 (Indoor)", + "Archer 1 (Indoor)", + "Archer 2 (Indoor)", + "Archer 3 (Indoor)" ] } diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index 567b46a..2b60a99 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -472,21 +472,9 @@ def _make_AGB_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: 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? # 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. - prestige_imperial = [ - "portsmouth", - "portsmouth_triple", - ] - prestige_metric = [ - "wa18", - "wa18_triple", - "wa25", - "wa25_triple", - ] all_indoor_rounds = load_rounds.read_json_to_round_dict( [ @@ -543,25 +531,11 @@ def _make_AGB_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: + (i - 1) * bowstyle["classStep_in"] ) - # Assign prestige rounds for the category - # - no dependence on bowstyle, distance, and age - prestige_rounds = prestige_imperial + prestige_metric - if bowstyle["bowstyle"].lower() == "compound": - prestige_rounds = [ - i + "_compound" if "triple" not in i else i - for i in prestige_rounds - ] - prestige_rounds = [ - i.replace("_triple", "_compound_triple") - for i in prestige_rounds - ] - # 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, - "prestige_rounds": prestige_rounds, "classes_long": AGB_classes_in_long, } @@ -1208,13 +1182,6 @@ def calculate_AGB_indoor_classification( class_data: Dict[str, Any] = dict(zip(group_data["classes"], class_scores)) - # is it a prestige round? If not remove MB as an option - if roundname not in AGB_indoor_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:2]: - del class_data[MB_class] - # What is the highest classification this score gets? to_del = [] for classname, classscore in class_data.items(): @@ -1301,11 +1268,6 @@ def AGB_indoor_classification_scores( for i, class_i in enumerate(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_indoor_classifications[groupname]["prestige_rounds"]: - class_scores[0:2] = [-9999] * 2 - # 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( From fcc26d09ed011aeea795b212f6898731926e0d64 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Wed, 2 Aug 2023 15:12:32 +0100 Subject: [PATCH 07/56] Enforce full face for indoor classification scores. --- .../classifications/classifications.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index 2b60a99..cc0b393 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -1109,6 +1109,28 @@ def AGB_old_indoor_classification_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 calculate_AGB_indoor_classification( roundname: str, score: float, @@ -1169,9 +1191,10 @@ def calculate_AGB_indoor_classification( 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[roundname], + all_indoor_rounds[strip_spots(roundname)], group_data["class_HC"][i], hc_scheme, hc_params, @@ -1257,9 +1280,10 @@ def AGB_indoor_classification_scores( 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[roundname], + all_indoor_rounds[strip_spots(roundname)], group_data["class_HC"][i], hc_scheme, hc_params, From 545681516ab5a0db1e7ab87dab5be963c257b360 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Wed, 2 Aug 2023 17:08:49 +0100 Subject: [PATCH 08/56] Handle maximum score on round for indoor classifications. --- .../classifications/classifications.py | 59 ++++++++----------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index cc0b393..43a43fe 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -1172,51 +1172,29 @@ def calculate_AGB_indoor_classification( """ # 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", - ] + # Get scores required on this round for each classification + # Enforcing full size face + all_class_scores = AGB_indoor_classification_scores( + roundname, + bowstyle, + gender, + age_group, + hc_scheme, ) - # deal with reduced categories: - if bowstyle.lower() in ("flatbow", "traditional", "asiatic"): - bowstyle = "Barebow" - 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"]) - ] - - class_data: Dict[str, Any] = dict(zip(group_data["classes"], class_scores)) + 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 > score: + if classscore < 0 or 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' - # TODO Treat indoor maximum scores - try: classification_from_score = list(class_data.keys())[0] return classification_from_score @@ -1254,7 +1232,7 @@ def AGB_indoor_classification_scores( Returns ------- classification_scores : ndarray - abbreviation of the classification appropriate for this score + scores required for each classification band References ---------- @@ -1301,6 +1279,19 @@ def AGB_indoor_classification_scores( # Enforce this for better code and to satisfy mypy int_class_scores = [int(x) for x in class_scores] + # Handle possibility of max scores by checking 1 HC point above current (floored to handle 0.5) + 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: + int_class_scores[i] = -9999 + return int_class_scores From cd799d8b4bd4290c5cd10c1cc0ccaf57c38ce281 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Mon, 14 Aug 2023 16:09:37 +0100 Subject: [PATCH 09/56] Handle asiatic bow indoors. --- archeryutils/classifications/classifications.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index 43a43fe..372b48e 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -1172,6 +1172,9 @@ def calculate_AGB_indoor_classification( """ # 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 all_class_scores = AGB_indoor_classification_scores( From 2bb68304e546ec270731df7757943e2e8676e20b Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Mon, 14 Aug 2023 16:10:03 +0100 Subject: [PATCH 10/56] New indoor classification numbers as set at meeting. --- archeryutils/classifications/AGB_bowstyles.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/archeryutils/classifications/AGB_bowstyles.json b/archeryutils/classifications/AGB_bowstyles.json index 0122d46..ae9afb3 100644 --- a/archeryutils/classifications/AGB_bowstyles.json +++ b/archeryutils/classifications/AGB_bowstyles.json @@ -4,8 +4,8 @@ "classStep_out" : 6, "genderStep_out" : 4, "ageStep_out" : 6, - "datum_in" : 15, - "classStep_in" : 6, + "datum_in" : 11, + "classStep_in" : 8, "genderStep_in" : 4, "ageStep_in" : 6 }, @@ -14,7 +14,7 @@ "classStep_out" : 7, "genderStep_out" : 5, "ageStep_out" : 6.5, - "datum_in" : 30, + "datum_in" : 29, "classStep_in" : 7, "genderStep_in" : 5, "ageStep_in" : 6.5 @@ -25,8 +25,8 @@ "classStep_out" : 5.5, "genderStep_out" : 5.5, "ageStep_out" : 5.5, - "datum_in" : 47, - "classStep_in" : 5.5, + "datum_in" : 42, + "classStep_in" : 6.0, "genderStep_in" : 5.5, "ageStep_in" : 5.5 @@ -36,8 +36,8 @@ "classStep_out" : 6, "genderStep_out" : 7, "ageStep_out" : 6, - "datum_in" : 65, - "classStep_in" : 6, + "datum_in" : 61, + "classStep_in" : 6.5, "genderStep_in" : 7, "ageStep_in" : 6 From 59a8db438ba309e6e3044353c9741274e24f0203 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Mon, 14 Aug 2023 18:32:22 +0100 Subject: [PATCH 11/56] enforce compound scoring for indoor classifications. --- .../classifications/classifications.py | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index 372b48e..a9a751a 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -1129,7 +1129,53 @@ def strip_spots( 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, @@ -1174,9 +1220,9 @@ def calculate_AGB_indoor_classification( if bowstyle.lower() in ("traditional", "flatbow", "asiatic"): bowstyle = "Barebow" - + # Get scores required on this round for each classification - # Enforcing full size face + # Enforcing full size face and compound scoring (for compounds) all_class_scores = AGB_indoor_classification_scores( roundname, bowstyle, @@ -1255,6 +1301,10 @@ def AGB_indoor_classification_scores( if bowstyle.lower() in ("flatbow", "traditional", "asiatic"): bowstyle = "Barebow" + # enforce compound scoring + if bowstyle.lower() in ("compound"): + roundname = get_compound_codename(round_codenames) + groupname = get_groupname(bowstyle, gender, age_group) group_data = AGB_indoor_classifications[groupname] From a3bc864f6946df901e4e4ffbd0671ffa5e35082e Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Mon, 14 Aug 2023 18:37:00 +0100 Subject: [PATCH 12/56] Apply black. --- archeryutils/classifications/classifications.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index a9a751a..6a818cb 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -1336,12 +1336,12 @@ def AGB_indoor_classification_scores( 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] + all_indoor_rounds[strip_spots(roundname)], + np.floor(hc) + 1, + hc_scheme, + hc_params, + round_score_up=True, + )[0] if next_score == sc: int_class_scores[i] = -9999 From 683df6c4378ff7cd8f07174830e52d865ce7593a Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Mon, 14 Aug 2023 18:57:39 +0100 Subject: [PATCH 13/56] Bugfix classification compound scoring enforcement. --- archeryutils/classifications/classifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index 6a818cb..4ac34b9 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -1303,7 +1303,7 @@ def AGB_indoor_classification_scores( # enforce compound scoring if bowstyle.lower() in ("compound"): - roundname = get_compound_codename(round_codenames) + roundname = get_compound_codename(roundname) groupname = get_groupname(bowstyle, gender, age_group) group_data = AGB_indoor_classifications[groupname] From f3502386e15d1c3f57023f54522f5892228cbff0 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Wed, 16 Aug 2023 10:09:20 +0100 Subject: [PATCH 14/56] Bug fix to handle gaps in handicap tables being reflected in classification tables to avoid loopholes. --- .../classifications/classifications.py | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index 4ac34b9..f19ee65 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -1332,18 +1332,25 @@ def AGB_indoor_classification_scores( # Enforce this for better code and to satisfy mypy int_class_scores = [int(x) for x in class_scores] - # Handle possibility of max scores by checking 1 HC point above current (floored to handle 0.5) + # 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 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 From 0ba70b1833bf12b5814f0d1488a5a532c5cc211d Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 19 Aug 2023 19:31:24 +0100 Subject: [PATCH 15/56] Add vegas 300 full face round. --- archeryutils/round_data_files/AGB_indoor.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/archeryutils/round_data_files/AGB_indoor.json b/archeryutils/round_data_files/AGB_indoor.json index 0970fdf..becbf9f 100644 --- a/archeryutils/round_data_files/AGB_indoor.json +++ b/archeryutils/round_data_files/AGB_indoor.json @@ -133,6 +133,12 @@ "codename" : "vegas_300", "location" : "indoor", "body" : "AGB", + "passes" : [{"n_arrows" : 30, "diameter" : 40, "scoring" : "10_zone", "distance" : 20, "dist_unit" : "yard"}] + }, + {"name" : "Vegas 300 Triple", + "codename" : "vegas_300_triple", + "location" : "indoor", + "body" : "AGB", "passes" : [{"n_arrows" : 30, "diameter" : 40, "scoring" : "10_zone_5_ring", "distance" : 20, "dist_unit" : "yard"}] } ] From 411be6098b4303c0c985a24387d53da1c9ebc8cb Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Thu, 24 Aug 2023 16:55:30 +0100 Subject: [PATCH 16/56] Final tweak to Indoor classification handicaps. --- archeryutils/classifications/AGB_bowstyles.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/archeryutils/classifications/AGB_bowstyles.json b/archeryutils/classifications/AGB_bowstyles.json index ae9afb3..49cf037 100644 --- a/archeryutils/classifications/AGB_bowstyles.json +++ b/archeryutils/classifications/AGB_bowstyles.json @@ -14,8 +14,8 @@ "classStep_out" : 7, "genderStep_out" : 5, "ageStep_out" : 6.5, - "datum_in" : 29, - "classStep_in" : 7, + "datum_in" : 28, + "classStep_in" : 7.5, "genderStep_in" : 5, "ageStep_in" : 6.5 From b192fe4cfa70263b94182293c1a7e7f3d4f1e4fa Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Thu, 24 Aug 2023 17:00:02 +0100 Subject: [PATCH 17/56] Final indoor classification names. --- .../classifications/AGB_classes_in.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/archeryutils/classifications/AGB_classes_in.json b/archeryutils/classifications/AGB_classes_in.json index 8ed79f2..f9e2b41 100644 --- a/archeryutils/classifications/AGB_classes_in.json +++ b/archeryutils/classifications/AGB_classes_in.json @@ -1,13 +1,13 @@ { "location": "indoor", - "classes" : ["GMB-I", "MB-I", "B1-I", "B2-I", "B3-I", "A1-I", "A2-I", "A3-I"], - "classes_long" : ["Grand Master Bowman (Indoor)", - "Master Bowman (Indoor)", - "Bowman 1 (Indoor)", - "Bowman 2 (Indoor)", - "Bowman 3 (Indoor)", - "Archer 1 (Indoor)", - "Archer 2 (Indoor)", - "Archer 3 (Indoor)" + "classes" : ["I-GMB", "I-MB", "I-B1", "I-B2", "I-B3", "I-A1", "I-A2", "I-A3"], + "classes_long" : ["Indoor Grand Master Bowman", + "Indoor Master Bowman", + "Indoor Bowman 1", + "Indoor Bowman 2", + "Indoor Bowman 3", + "Indoor Archer 1", + "Indoor Archer 2", + "Indoor Archer 3" ] } From 62da86f40117fdf0937a20c60d337fc315d939f7 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sun, 20 Aug 2023 21:22:54 +0100 Subject: [PATCH 18/56] refactor classifications into separate files. --- .../agb_field_classifications.py | 298 ++++++++++++ .../agb_indoor_classifications.py | 274 +++++++++++ .../agb_old_indoor_classifications.py | 239 ++++++++++ .../agb_outdoor_classifications.py | 432 ++++++++++++++++++ .../classifications/classification_utils.py | 269 +++++++++++ 5 files changed, 1512 insertions(+) create mode 100644 archeryutils/classifications/agb_field_classifications.py create mode 100644 archeryutils/classifications/agb_indoor_classifications.py create mode 100644 archeryutils/classifications/agb_old_indoor_classifications.py create mode 100644 archeryutils/classifications/agb_outdoor_classifications.py create mode 100644 archeryutils/classifications/classification_utils.py diff --git a/archeryutils/classifications/agb_field_classifications.py b/archeryutils/classifications/agb_field_classifications.py new file mode 100644 index 0000000..bcbec0a --- /dev/null +++ b/archeryutils/classifications/agb_field_classifications.py @@ -0,0 +1,298 @@ +""" +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 + +""" +from typing import List, Dict, Any +import numpy as np + +import archeryutils.classifications.classification_utils as cls_funcs + + +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) + """ + # TODO: Need routines to sanitise/deal with variety of user inputs + + # deal with reduced categories: + if age_group.lower().replace(" ", "") in ("adult", "50+", "under21"): + age_group = "Adult" + else: + 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" + else: + 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..7f842a8 --- /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 + + +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) + """ + # 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, + ) + + 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" + return "unclassified" + + +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 + """ + # 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 = 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..f1b6070 --- /dev/null +++ b/archeryutils/classifications/agb_old_indoor_classifications.py @@ -0,0 +1,239 @@ +""" +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 + + +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, + 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 = 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], + 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 = 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], + 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 diff --git a/archeryutils/classifications/agb_outdoor_classifications.py b/archeryutils/classifications/agb_outdoor_classifications.py new file mode 100644 index 0000000..a431868 --- /dev/null +++ b/archeryutils/classifications/agb_outdoor_classifications.py @@ -0,0 +1,432 @@ +""" +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 + + +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", + "WA_outdoor.json", + ] + ) + + # 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) + """ + # 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" + + # 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) + """ + # 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 = cls_funcs.get_groupname(bowstyle, gender, age_group) + group_data = agb_outdoor_classifications[groupname] + + hc_scheme = "AGB" + 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], + hc_scheme, + 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..7fd6c50 --- /dev/null +++ b/archeryutils/classifications/classification_utils.py @@ -0,0 +1,269 @@ +""" +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", "") + 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] + return round_codenames From c17ab89a38391129b243bf927d805aa3d8eba8ab Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sun, 20 Aug 2023 21:23:14 +0100 Subject: [PATCH 19/56] empty classifications.py --- .../classifications/classifications.py | 1513 ----------------- 1 file changed, 1513 deletions(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index f19ee65..410e97a 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -8,1518 +8,5 @@ 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 """ -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" - ) - ) From 795895fe7c04d5e3e55639caea798163b51cf1a5 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sun, 20 Aug 2023 21:23:33 +0100 Subject: [PATCH 20/56] Update init of classifications wip. --- archeryutils/classifications/__init__.py | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) 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", +] From d03c0d9af2a4eb1f950b9df73f322098a81cabd1 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Thu, 24 Aug 2023 19:30:09 +0100 Subject: [PATCH 21/56] Fix import classifications issue thanks to @LiamPattinson. --- archeryutils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 30fe280017cd7cfc25f83d7675e908ab4f89414d Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Thu, 24 Aug 2023 21:28:40 +0100 Subject: [PATCH 22/56] Tidying and linting of refactored classification files. --- .../agb_field_classifications.py | 8 +-- .../agb_indoor_classifications.py | 37 +++++----- .../agb_old_indoor_classifications.py | 42 ++++------- .../agb_outdoor_classifications.py | 69 +++++++------------ .../classifications/classification_utils.py | 6 +- .../classifications/classifications.py | 5 +- 6 files changed, 59 insertions(+), 108 deletions(-) diff --git a/archeryutils/classifications/agb_field_classifications.py b/archeryutils/classifications/agb_field_classifications.py index bcbec0a..8ea6429 100644 --- a/archeryutils/classifications/agb_field_classifications.py +++ b/archeryutils/classifications/agb_field_classifications.py @@ -8,9 +8,9 @@ Routine Listings ---------------- -_make_AGB_field_classification_dict -calculate_AGB_field_classification -AGB_field_classification_scores +_make_agb_field_classification_dict +calculate_agb_field_classification +agb_field_classification_scores """ from typing import List, Dict, Any @@ -204,8 +204,6 @@ def calculate_agb_field_classification( ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) """ - # TODO: Need routines to sanitise/deal with variety of user inputs - # deal with reduced categories: if age_group.lower().replace(" ", "") in ("adult", "50+", "under21"): age_group = "Adult" diff --git a/archeryutils/classifications/agb_indoor_classifications.py b/archeryutils/classifications/agb_indoor_classifications.py index 7f842a8..f226fe9 100644 --- a/archeryutils/classifications/agb_indoor_classifications.py +++ b/archeryutils/classifications/agb_indoor_classifications.py @@ -3,9 +3,9 @@ Routine Listings ---------------- -_make_AGB_old_indoor_classification_dict -calculate_AGB_indoor_classification -AGB_indoor_classification_scores +_make_agb_old_indoor_classification_dict +calculate_agb_indoor_classification +agb_indoor_classification_scores """ from typing import List, Dict, Any import numpy as np @@ -15,6 +15,14 @@ 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. @@ -77,10 +85,10 @@ def _make_agb_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: bowstyle["bowstyle"], gender, age["age_group"] ) - class_HC = np.empty(len(agb_classes_in)) + class_hc = np.empty(len(agb_classes_in)) for i in range(len(agb_classes_in)): # Assign handicap for this classification - class_HC[i] = ( + class_hc[i] = ( bowstyle["datum_in"] + age_steps * bowstyle["ageStep_in"] + gender_steps * bowstyle["genderStep_in"] @@ -91,7 +99,7 @@ def _make_agb_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: # Consider a method to reduce this (affects other code) classification_dict[groupname] = { "classes": agb_classes_in, - "class_HC": class_HC, + "class_HC": class_hc, "classes_long": agb_classes_in_long, } @@ -139,8 +147,6 @@ def calculate_agb_indoor_classification( 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" @@ -207,15 +213,6 @@ def agb_indoor_classification_scores( 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" @@ -234,7 +231,7 @@ def agb_indoor_classification_scores( # Enforce full size face class_scores = [ hc_eq.score_for_round( - all_indoor_rounds[cls_funcs.strip_spots(roundname)], + ALL_INDOOR_ROUNDS[cls_funcs.strip_spots(roundname)], group_data["class_HC"][i], hc_scheme, hc_params, @@ -256,7 +253,7 @@ def agb_indoor_classification_scores( # 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)], + ALL_INDOOR_ROUNDS[cls_funcs.strip_spots(roundname)], np.floor(hc) + 1, hc_scheme, hc_params, @@ -264,7 +261,7 @@ def agb_indoor_classification_scores( )[0] if next_score == sc: # If already at max score this classification is impossible - if sc == all_indoor_rounds[roundname].max_score(): + 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...) diff --git a/archeryutils/classifications/agb_old_indoor_classifications.py b/archeryutils/classifications/agb_old_indoor_classifications.py index f1b6070..26caf27 100644 --- a/archeryutils/classifications/agb_old_indoor_classifications.py +++ b/archeryutils/classifications/agb_old_indoor_classifications.py @@ -15,6 +15,14 @@ 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. @@ -73,7 +81,6 @@ def calculate_agb_old_indoor_classification( bowstyle: str, gender: str, age_group: str, - hc_scheme: str = "AGBold", ) -> str: """ Calculate AGB indoor classification from score. @@ -93,8 +100,6 @@ def calculate_agb_old_indoor_classification( 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 ------- @@ -106,17 +111,6 @@ def calculate_agb_old_indoor_classification( 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"): @@ -130,9 +124,9 @@ def calculate_agb_old_indoor_classification( # Get scores required on this round for each classification class_scores = [ hc_eq.score_for_round( - all_indoor_rounds[roundname], + ALL_INDOOR_ROUNDS[roundname], group_data["class_HC"][i], - hc_scheme, + "AGBold", hc_params, round_score_up=True, )[0] @@ -165,7 +159,6 @@ def agb_old_indoor_classification_scores( bowstyle: str, gender: str, age_group: str, - hc_scheme: str = "AGBold", ) -> List[int]: """ Calculate AGB indoor classification scores for category. @@ -183,8 +176,6 @@ def agb_old_indoor_classification_scores( 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 ------- @@ -196,15 +187,6 @@ def agb_old_indoor_classification_scores( 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"): @@ -218,9 +200,9 @@ def agb_old_indoor_classification_scores( # Get scores required on this round for each classification class_scores = [ hc_eq.score_for_round( - all_indoor_rounds[roundname], + ALL_INDOOR_ROUNDS[roundname], group_data["class_HC"][i], - hc_scheme, + "AGBold", hc_params, round_score_up=True, )[0] diff --git a/archeryutils/classifications/agb_outdoor_classifications.py b/archeryutils/classifications/agb_outdoor_classifications.py index a431868..224411c 100644 --- a/archeryutils/classifications/agb_outdoor_classifications.py +++ b/archeryutils/classifications/agb_outdoor_classifications.py @@ -3,9 +3,9 @@ Routine Listings ---------------- -_make_AGB_outdoor_classification_dict -calculate_AGB_outdoor_classification -AGB_outdoor_classification_scores +_make_agb_outdoor_classification_dict +calculate_agb_outdoor_classification +agb_outdoor_classification_scores """ from typing import List, Dict, Any import numpy as np @@ -15,6 +15,15 @@ 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. @@ -88,14 +97,6 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: 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", - "WA_outdoor.json", - ] - ) - # Read in age group info as list of dicts agb_ages = cls_funcs.read_ages_json() # Read in bowstyleclass info as list of dicts @@ -134,11 +135,11 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: max_dist = age[gender.lower()] max_dist_index = dists.index(min(max_dist)) - class_HC = np.empty(len(agb_classes_out)) + 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] = ( + class_hc[i] = ( bowstyle["datum_out"] + age_steps * bowstyle["ageStep_out"] + gender_steps * bowstyle["genderStep_out"] @@ -194,7 +195,7 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: 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( + if ALL_OUTDOOR_ROUNDS[roundname].max_distance() >= min( max_dist ): prestige_rounds.append(roundname) @@ -203,7 +204,7 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: 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( + if ALL_OUTDOOR_ROUNDS[roundname].max_distance() >= min( max_dist ): prestige_rounds.append(roundname) @@ -212,7 +213,7 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: 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( + if ALL_OUTDOOR_ROUNDS[roundname].max_distance() >= min( max_dist ): prestige_rounds.append(roundname) @@ -226,14 +227,14 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: # Imperial and 1440 rounds for roundname in prestige_imperial + prestige_metric: # Compare round dist - if all_outdoor_rounds[roundname].max_distance() >= min(max_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, + "class_HC": class_hc, "prestige_rounds": prestige_rounds, "max_distance": max_dist, "min_dists": min_dists, @@ -280,18 +281,6 @@ def calculate_agb_outdoor_classification( 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" @@ -323,7 +312,7 @@ def calculate_agb_outdoor_classification( # If not prestige, what classes are eligible based on category and distance to_del = [] - round_max_dist = all_outdoor_rounds[roundname].max_distance() + 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]) @@ -377,32 +366,20 @@ def agb_outdoor_classification_scores( 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 = cls_funcs.get_groupname(bowstyle, gender, age_group) group_data = agb_outdoor_classifications[groupname] - hc_scheme = "AGB" 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], + ALL_OUTDOOR_ROUNDS[roundname], group_data["class_HC"][i], - hc_scheme, + "AGB", hc_params, round_score_up=True, )[0] @@ -415,7 +392,7 @@ def agb_outdoor_classification_scores( 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() + 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 diff --git a/archeryutils/classifications/classification_utils.py b/archeryutils/classifications/classification_utils.py index 7fd6c50..eca6cad 100644 --- a/archeryutils/classifications/classification_utils.py +++ b/archeryutils/classifications/classification_utils.py @@ -217,7 +217,6 @@ def strip_spots( ------- roundname : str name of round shot as given by 'codename' in json - """ roundname = roundname.replace("_triple", "") roundname = roundname.replace("_5_centre", "") @@ -226,7 +225,7 @@ def strip_spots( def get_compound_codename(round_codenames): """ - convert any indoor rounds with special compound scoring to the compound format + Convert any indoor rounds with special compound scoring to the compound format. Parameters ---------- @@ -237,9 +236,6 @@ def get_compound_codename(round_codenames): ------- round_codenames : str or list of str list of amended round codenames for compound - - References - ---------- """ notlistflag = False if not isinstance(round_codenames, list): diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index 410e97a..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,5 +8,6 @@ Routine Listings ---------------- - +The contents of this module have now been abstracted into several files. +This is to keep them of a manageable size. """ From eff760c324a68becd0145bcebeda66e5efa916ba Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Thu, 24 Aug 2023 22:02:59 +0100 Subject: [PATCH 23/56] Add tests for classification utilities. --- .../tests/test_classification_utils.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 archeryutils/classifications/tests/test_classification_utils.py 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 From ac9f2f307d95684d5912c3335deeff785f850817 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Thu, 24 Aug 2023 22:28:54 +0100 Subject: [PATCH 24/56] Add __init__.py for classification tests. --- archeryutils/classifications/tests/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 archeryutils/classifications/tests/__init__.py diff --git a/archeryutils/classifications/tests/__init__.py b/archeryutils/classifications/tests/__init__.py new file mode 100644 index 0000000..fd6bd4c --- /dev/null +++ b/archeryutils/classifications/tests/__init__.py @@ -0,0 +1 @@ +"Module providing tests for the classification functionalities." From e2f9b66033313de53c7aafcf1e088b7f302818ca Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 26 Aug 2023 18:51:14 +0100 Subject: [PATCH 25/56] Add tests for agb field classifications. --- .../agb_field_classifications.py | 20 +- .../classifications/tests/test_agb_field.py | 396 ++++++++++++++++++ 2 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 archeryutils/classifications/tests/test_agb_field.py diff --git a/archeryutils/classifications/agb_field_classifications.py b/archeryutils/classifications/agb_field_classifications.py index 8ea6429..b01fc2a 100644 --- a/archeryutils/classifications/agb_field_classifications.py +++ b/archeryutils/classifications/agb_field_classifications.py @@ -13,12 +13,21 @@ 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. @@ -204,10 +213,17 @@ def calculate_agb_field_classification( 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" - else: + 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) @@ -275,7 +291,7 @@ def agb_field_classification_scores( # deal with reduced categories: if age_group.lower().replace(" ", "") in ("adult", "50+", "under21"): age_group = "Adult" - else: + 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) 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", + ) From cacb3c9673cf3001b8fc142f09d64090d795f160 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sun, 27 Aug 2023 12:19:51 +0100 Subject: [PATCH 26/56] Add tests for agb indoor classifications. --- .../agb_indoor_classifications.py | 8 +- .../classifications/tests/__init__.py | 2 +- .../classifications/tests/test_agb_indoor.py | 404 ++++++++++++++++++ 3 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 archeryutils/classifications/tests/test_agb_indoor.py diff --git a/archeryutils/classifications/agb_indoor_classifications.py b/archeryutils/classifications/agb_indoor_classifications.py index f226fe9..ccd314d 100644 --- a/archeryutils/classifications/agb_indoor_classifications.py +++ b/archeryutils/classifications/agb_indoor_classifications.py @@ -147,8 +147,12 @@ def calculate_agb_indoor_classification( ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) """ - if bowstyle.lower() in ("traditional", "flatbow", "asiatic"): - bowstyle = "Barebow" + # 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) diff --git a/archeryutils/classifications/tests/__init__.py b/archeryutils/classifications/tests/__init__.py index fd6bd4c..78ca2d1 100644 --- a/archeryutils/classifications/tests/__init__.py +++ b/archeryutils/classifications/tests/__init__.py @@ -1 +1 @@ -"Module providing tests for the classification functionalities." +"""Module providing tests for the classification functionalities.""" diff --git a/archeryutils/classifications/tests/test_agb_indoor.py b/archeryutils/classifications/tests/test_agb_indoor.py new file mode 100644 index 0000000..7484fa9 --- /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", + "unclassified", + ), + ( + 1, + "Under 12", + "longbow", + "unclassified", + ), + ], + ) + 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", + ) From c38edfde899379cc03687410e546df53b9b857ec Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sun, 27 Aug 2023 17:52:02 +0100 Subject: [PATCH 27/56] Fix unclassified return value in indoor.w --- archeryutils/classifications/agb_indoor_classifications.py | 3 +-- archeryutils/classifications/tests/test_agb_indoor.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/archeryutils/classifications/agb_indoor_classifications.py b/archeryutils/classifications/agb_indoor_classifications.py index ccd314d..ad44d39 100644 --- a/archeryutils/classifications/agb_indoor_classifications.py +++ b/archeryutils/classifications/agb_indoor_classifications.py @@ -180,8 +180,7 @@ def calculate_agb_indoor_classification( classification_from_score = list(class_data.keys())[0] return classification_from_score except IndexError: - # return "UC" - return "unclassified" + return "UC" def agb_indoor_classification_scores( diff --git a/archeryutils/classifications/tests/test_agb_indoor.py b/archeryutils/classifications/tests/test_agb_indoor.py index 7484fa9..7a23371 100644 --- a/archeryutils/classifications/tests/test_agb_indoor.py +++ b/archeryutils/classifications/tests/test_agb_indoor.py @@ -331,13 +331,13 @@ class TestCalculateAgbIndoorClassification: 12, # On boundary "Under 12", "longbow", - "unclassified", + "UC", ), ( 1, "Under 12", "longbow", - "unclassified", + "UC", ), ], ) From 671a25518eda3f8b7ae63bb5990a6255327dafe7 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sun, 27 Aug 2023 18:23:31 +0100 Subject: [PATCH 28/56] Add outdoor classification tests. --- .../agb_outdoor_classifications.py | 10 +- .../classifications/classification_utils.py | 1 + .../classifications/tests/test_agb_outdoor.py | 544 ++++++++++++++++++ 3 files changed, 552 insertions(+), 3 deletions(-) create mode 100644 archeryutils/classifications/tests/test_agb_outdoor.py diff --git a/archeryutils/classifications/agb_outdoor_classifications.py b/archeryutils/classifications/agb_outdoor_classifications.py index 224411c..064337a 100644 --- a/archeryutils/classifications/agb_outdoor_classifications.py +++ b/archeryutils/classifications/agb_outdoor_classifications.py @@ -281,8 +281,12 @@ def calculate_agb_outdoor_classification( ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) """ - if bowstyle.lower() in ("traditional", "flatbow", "asiatic"): - bowstyle = "Barebow" + # 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) @@ -377,7 +381,7 @@ def agb_outdoor_classification_scores( # Get scores required on this round for each classification class_scores = [ hc_eq.score_for_round( - ALL_OUTDOOR_ROUNDS[roundname], + ALL_OUTDOOR_ROUNDS[cls_funcs.strip_spots(roundname)], group_data["class_HC"][i], "AGB", hc_params, diff --git a/archeryutils/classifications/classification_utils.py b/archeryutils/classifications/classification_utils.py index eca6cad..230e6f0 100644 --- a/archeryutils/classifications/classification_utils.py +++ b/archeryutils/classifications/classification_utils.py @@ -220,6 +220,7 @@ def strip_spots( """ roundname = roundname.replace("_triple", "") roundname = roundname.replace("_5_centre", "") + roundname = roundname.replace("_small", "") return roundname 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", + ) From bbacf5064fc9e27f5d543e592aae8172e09eaff4 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sun, 27 Aug 2023 22:49:12 +0100 Subject: [PATCH 29/56] Add tests for old indoor classifications. --- .../agb_old_indoor_classifications.py | 40 ++- .../tests/test_agb_old_indoor.py | 289 ++++++++++++++++++ 2 files changed, 308 insertions(+), 21 deletions(-) create mode 100644 archeryutils/classifications/tests/test_agb_old_indoor.py diff --git a/archeryutils/classifications/agb_old_indoor_classifications.py b/archeryutils/classifications/agb_old_indoor_classifications.py index 26caf27..d31dcbf 100644 --- a/archeryutils/classifications/agb_old_indoor_classifications.py +++ b/archeryutils/classifications/agb_old_indoor_classifications.py @@ -111,28 +111,23 @@ def calculate_agb_old_indoor_classification( ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) """ - # 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() + # 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 = [ - 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"]) - ] + 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? @@ -150,8 +145,7 @@ def calculate_agb_old_indoor_classification( classification_from_score = list(class_data.keys())[0] return classification_from_score except IndexError: - # return "UC" - return "unclassified" + return "UC" def agb_old_indoor_classification_scores( @@ -187,6 +181,10 @@ def agb_old_indoor_classification_scores( 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"): 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", + ) From 09ce12781e6b9ce548c4db8343eaa34974dca1c1 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Mon, 28 Aug 2023 10:11:24 +0100 Subject: [PATCH 30/56] Update examples for new classification functions. --- examples.ipynb | 549 +++++-------------------------------------------- 1 file changed, 51 insertions(+), 498 deletions(-) diff --git a/examples.ipynb b/examples.ipynb index 1206a67..85c6d53 100644 --- a/examples.ipynb +++ b/examples.ipynb @@ -18,7 +18,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": null, "id": "785be58b", "metadata": { "scrolled": true @@ -64,7 +64,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": null, "id": "061d6aee", "metadata": {}, "outputs": [], @@ -82,7 +82,7 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": null, "id": "473c3b7d", "metadata": {}, "outputs": [], @@ -106,7 +106,7 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": null, "id": "d72fc06d", "metadata": {}, "outputs": [], @@ -124,7 +124,7 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": null, "id": "63979a1f", "metadata": {}, "outputs": [], @@ -144,21 +144,10 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": null, "id": "c1fd3bbc", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "10.0\n", - "10.0\n", - "5.0\n", - "10.0\n" - ] - } - ], + "outputs": [], "source": [ "for target in [my720target, mycompound720target, myIFAATarget, myPortsmouthTarget]:\n", " print(target.max_score())" @@ -180,7 +169,7 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": null, "id": "a877c384", "metadata": {}, "outputs": [], @@ -198,20 +187,12 @@ }, { "cell_type": "code", - "execution_count": 34, + "execution_count": null, "id": "dc431f7a", "metadata": { "scrolled": true }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "360.0\n" - ] - } - ], + "outputs": [], "source": [ "print(my70mPass.max_score())" ] @@ -236,7 +217,7 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": null, "id": "56587b23", "metadata": {}, "outputs": [], @@ -260,18 +241,10 @@ }, { "cell_type": "code", - "execution_count": 36, + "execution_count": null, "id": "43894dd3", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "720.0\n" - ] - } - ], + "outputs": [], "source": [ "print(my720Round.max_score())" ] @@ -288,50 +261,10 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": null, "id": "3e5974f9", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "York\n", - "Hereford\n", - "Bristol I\n", - "Bristol II\n", - "Bristol III\n", - "Bristol IV\n", - "Bristol V\n", - "St. George\n", - "Albion\n", - "Windsor\n", - "Windsor 50\n", - "Windsor 40\n", - "Windsor 30\n", - "New Western\n", - "Long Western\n", - "Western\n", - "Western 50\n", - "Western 40\n", - "Western 30\n", - "American\n", - "St Nicholas\n", - "New National\n", - "Long National\n", - "National\n", - "National 50\n", - "National 40\n", - "National 30\n", - "New Warwick\n", - "Long Warwick\n", - "Warwick\n", - "Warwick 50\n", - "Warwick 40\n", - "Warwick 30\n" - ] - } - ], + "outputs": [], "source": [ "from archeryutils import load_rounds\n", "\n", @@ -351,31 +284,10 @@ }, { "cell_type": "code", - "execution_count": 38, + "execution_count": null, "id": "cb287016", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "A York consists of 3 passes:\n", - "\t- 72 arrows at a 122.0 cm target at 100.0 yards.\n", - "\t- 48 arrows at a 122.0 cm target at 80.0 yards.\n", - "\t- 24 arrows at a 122.0 cm target at 60.0 yards.\n" - ] - }, - { - "data": { - "text/plain": [ - "1296.0" - ] - }, - "execution_count": 38, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "agb_outdoor.york.get_info()\n", "\n", @@ -416,18 +328,10 @@ }, { "cell_type": "code", - "execution_count": 39, + "execution_count": null, "id": "de89712b", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "HcParams(AGB_datum=6.0, AGB_step=3.5, AGB_ang_0=0.0005, AGB_kd=0.00365, AGBo_datum=12.9, AGBo_step=3.6, AGBo_ang_0=0.0005, AGBo_k1=1.429e-06, AGBo_k2=1.07, AGBo_k3=4.3, AGBo_p1=2.0, AGBo_arw_d=0.00714, AA_k0=2.37, AA_ks=0.027, AA_kd=0.004, AA2_k0=2.57, AA2_ks=0.027, AA2_f1=0.815, AA2_f2=0.185, AA2_d0=50.0, AA_arw_d_out=0.005, arw_d_in=0.0093, arw_d_out=0.0055)\n" - ] - } - ], + "outputs": [], "source": [ "from archeryutils import handicap_equations as hc_eq\n", "from archeryutils import handicap_functions as hc_func\n", @@ -458,18 +362,10 @@ }, { "cell_type": "code", - "execution_count": 40, + "execution_count": null, "id": "c627dccc", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "A handicap of 38 on a York is a score of 940.0.\n" - ] - } - ], + "outputs": [], "source": [ "score_from_hc, _ = hc_eq.score_for_round(\n", " agb_outdoor.york,\n", @@ -491,18 +387,10 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": null, "id": "6d4de971", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "A handicap of 38.25 on a York is a score of 936.0.\n" - ] - } - ], + "outputs": [], "source": [ "score_from_hc, _ = hc_eq.score_for_round(\n", " agb_outdoor.york,\n", @@ -524,18 +412,10 @@ }, { "cell_type": "code", - "execution_count": 42, + "execution_count": null, "id": "dbde138c", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "A handicap of 38.25 on a York is a decimal score of 935.1392717845214.\n" - ] - } - ], + "outputs": [], "source": [ "score_from_hc, _ = hc_eq.score_for_round(\n", " agb_outdoor.york,\n", @@ -567,19 +447,10 @@ }, { "cell_type": "code", - "execution_count": 43, + "execution_count": null, "id": "47e4ee24", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "A score of 950 on a York is a continuous handicap of 37.374134403932686.\n", - "A score of 950 on a York is a discrete handicap of 38.0.\n" - ] - } - ], + "outputs": [], "source": [ "hc_from_score = hc_func.handicap_from_score(\n", " 950,\n", @@ -611,169 +482,10 @@ }, { "cell_type": "code", - "execution_count": 44, + "execution_count": null, "id": "bce21c56", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " Handicap York Hereford Albion Windsor\n", - " 0.00000000 1284.00000000 1295.00000000 972.00000000 972.00000000\n", - " 1.00000000 1281.00000000 1294.00000000 971.00000000 972.00000000\n", - " 2.00000000 1279.00000000 1294.00000000 971.00000000 972.00000000\n", - " 3.00000000 1276.00000000 1293.00000000 971.00000000 972.00000000\n", - " 4.00000000 1272.00000000 1292.00000000 970.00000000 972.00000000\n", - " 5.00000000 1268.00000000 1290.00000000 969.00000000 972.00000000\n", - " 6.00000000 1264.00000000 1289.00000000 969.00000000 972.00000000\n", - " 7.00000000 1260.00000000 1287.00000000 968.00000000 972.00000000\n", - " 8.00000000 1255.00000000 1285.00000000 967.00000000 972.00000000\n", - " 9.00000000 1250.00000000 1283.00000000 966.00000000 972.00000000\n", - " 10.00000000 1245.00000000 1281.00000000 964.00000000 972.00000000\n", - " 11.00000000 1239.00000000 1278.00000000 963.00000000 971.00000000\n", - " 12.00000000 1233.00000000 1275.00000000 961.00000000 971.00000000\n", - " 13.00000000 1226.00000000 1271.00000000 959.00000000 971.00000000\n", - " 14.00000000 1220.00000000 1267.00000000 957.00000000 970.00000000\n", - " 15.00000000 1212.00000000 1263.00000000 955.00000000 969.00000000\n", - " 16.00000000 1205.00000000 1259.00000000 952.00000000 968.00000000\n", - " 17.00000000 1197.00000000 1254.00000000 949.00000000 967.00000000\n", - " 18.00000000 1189.00000000 1249.00000000 946.00000000 966.00000000\n", - " 19.00000000 1180.00000000 1244.00000000 943.00000000 965.00000000\n", - " 20.00000000 1171.00000000 1238.00000000 939.00000000 963.00000000\n", - " 21.00000000 1162.00000000 1232.00000000 936.00000000 961.00000000\n", - " 22.00000000 1153.00000000 1225.00000000 932.00000000 959.00000000\n", - " 23.00000000 1142.00000000 1218.00000000 927.00000000 957.00000000\n", - " 24.00000000 1132.00000000 1211.00000000 922.00000000 954.00000000\n", - " 25.00000000 1121.00000000 1204.00000000 917.00000000 952.00000000\n", - " 26.00000000 1110.00000000 1196.00000000 912.00000000 949.00000000\n", - " 27.00000000 1098.00000000 1187.00000000 907.00000000 945.00000000\n", - " 28.00000000 1086.00000000 1178.00000000 901.00000000 942.00000000\n", - " 29.00000000 1074.00000000 1169.00000000 895.00000000 938.00000000\n", - " 30.00000000 1061.00000000 1160.00000000 888.00000000 934.00000000\n", - " 31.00000000 1047.00000000 1150.00000000 882.00000000 929.00000000\n", - " 32.00000000 1033.00000000 1140.00000000 875.00000000 924.00000000\n", - " 33.00000000 1019.00000000 1129.00000000 868.00000000 919.00000000\n", - " 34.00000000 1004.00000000 1118.00000000 860.00000000 914.00000000\n", - " 35.00000000 989.00000000 1107.00000000 852.00000000 909.00000000\n", - " 36.00000000 973.00000000 1095.00000000 844.00000000 903.00000000\n", - " 37.00000000 957.00000000 1083.00000000 836.00000000 897.00000000\n", - " 38.00000000 940.00000000 1070.00000000 827.00000000 890.00000000\n", - " 39.00000000 923.00000000 1057.00000000 818.00000000 884.00000000\n", - " 40.00000000 905.00000000 1044.00000000 809.00000000 877.00000000\n", - " 41.00000000 887.00000000 1030.00000000 800.00000000 870.00000000\n", - " 42.00000000 868.00000000 1015.00000000 790.00000000 862.00000000\n", - " 43.00000000 849.00000000 1000.00000000 779.00000000 855.00000000\n", - " 44.00000000 829.00000000 985.00000000 769.00000000 847.00000000\n", - " 45.00000000 809.00000000 969.00000000 758.00000000 839.00000000\n", - " 46.00000000 789.00000000 952.00000000 747.00000000 830.00000000\n", - " 47.00000000 768.00000000 936.00000000 735.00000000 821.00000000\n", - " 48.00000000 747.00000000 918.00000000 723.00000000 812.00000000\n", - " 49.00000000 726.00000000 900.00000000 711.00000000 802.00000000\n", - " 50.00000000 705.00000000 882.00000000 698.00000000 793.00000000\n", - " 51.00000000 684.00000000 863.00000000 685.00000000 782.00000000\n", - " 52.00000000 662.00000000 844.00000000 672.00000000 772.00000000\n", - " 53.00000000 640.00000000 824.00000000 658.00000000 761.00000000\n", - " 54.00000000 619.00000000 804.00000000 644.00000000 750.00000000\n", - " 55.00000000 597.00000000 784.00000000 629.00000000 738.00000000\n", - " 56.00000000 576.00000000 763.00000000 615.00000000 726.00000000\n", - " 57.00000000 555.00000000 742.00000000 600.00000000 714.00000000\n", - " 58.00000000 534.00000000 721.00000000 585.00000000 701.00000000\n", - " 59.00000000 513.00000000 700.00000000 569.00000000 688.00000000\n", - " 60.00000000 492.00000000 679.00000000 554.00000000 674.00000000\n", - " 61.00000000 472.00000000 657.00000000 538.00000000 661.00000000\n", - " 62.00000000 452.00000000 635.00000000 522.00000000 646.00000000\n", - " 63.00000000 432.00000000 614.00000000 506.00000000 632.00000000\n", - " 64.00000000 413.00000000 592.00000000 490.00000000 617.00000000\n", - " 65.00000000 394.00000000 571.00000000 474.00000000 602.00000000\n", - " 66.00000000 376.00000000 549.00000000 458.00000000 587.00000000\n", - " 67.00000000 359.00000000 528.00000000 442.00000000 571.00000000\n", - " 68.00000000 341.00000000 507.00000000 426.00000000 555.00000000\n", - " 69.00000000 325.00000000 487.00000000 410.00000000 539.00000000\n", - " 70.00000000 309.00000000 466.00000000 394.00000000 523.00000000\n", - " 71.00000000 293.00000000 446.00000000 378.00000000 506.00000000\n", - " 72.00000000 278.00000000 427.00000000 363.00000000 490.00000000\n", - " 73.00000000 263.00000000 407.00000000 348.00000000 473.00000000\n", - " 74.00000000 250.00000000 389.00000000 333.00000000 457.00000000\n", - " 75.00000000 236.00000000 370.00000000 318.00000000 441.00000000\n", - " 76.00000000 223.00000000 353.00000000 304.00000000 424.00000000\n", - " 77.00000000 211.00000000 336.00000000 290.00000000 408.00000000\n", - " 78.00000000 199.00000000 319.00000000 276.00000000 392.00000000\n", - " 79.00000000 188.00000000 303.00000000 263.00000000 376.00000000\n", - " 80.00000000 178.00000000 287.00000000 250.00000000 360.00000000\n", - " 81.00000000 167.00000000 272.00000000 237.00000000 345.00000000\n", - " 82.00000000 158.00000000 258.00000000 225.00000000 330.00000000\n", - " 83.00000000 149.00000000 244.00000000 214.00000000 315.00000000\n", - " 84.00000000 140.00000000 231.00000000 203.00000000 301.00000000\n", - " 85.00000000 132.00000000 218.00000000 192.00000000 286.00000000\n", - " 86.00000000 124.00000000 206.00000000 181.00000000 273.00000000\n", - " 87.00000000 116.00000000 194.00000000 172.00000000 259.00000000\n", - " 88.00000000 109.00000000 183.00000000 162.00000000 246.00000000\n", - " 89.00000000 103.00000000 173.00000000 153.00000000 234.00000000\n", - " 90.00000000 96.00000000 163.00000000 144.00000000 222.00000000\n", - " 91.00000000 91.00000000 153.00000000 136.00000000 210.00000000\n", - " 92.00000000 85.00000000 144.00000000 128.00000000 199.00000000\n", - " 93.00000000 80.00000000 136.00000000 121.00000000 189.00000000\n", - " 94.00000000 75.00000000 128.00000000 114.00000000 178.00000000\n", - " 95.00000000 70.00000000 120.00000000 107.00000000 168.00000000\n", - " 96.00000000 66.00000000 113.00000000 101.00000000 159.00000000\n", - " 97.00000000 62.00000000 106.00000000 95.00000000 150.00000000\n", - " 98.00000000 58.00000000 99.00000000 89.00000000 142.00000000\n", - " 99.00000000 54.00000000 93.00000000 84.00000000 133.00000000\n", - " 100.00000000 51.00000000 88.00000000 79.00000000 126.00000000\n", - " 101.00000000 47.00000000 82.00000000 74.00000000 118.00000000\n", - " 102.00000000 44.00000000 77.00000000 69.00000000 111.00000000\n", - " 103.00000000 42.00000000 72.00000000 65.00000000 105.00000000\n", - " 104.00000000 39.00000000 68.00000000 61.00000000 99.00000000\n", - " 105.00000000 37.00000000 64.00000000 57.00000000 93.00000000\n", - " 106.00000000 34.00000000 60.00000000 54.00000000 87.00000000\n", - " 107.00000000 32.00000000 56.00000000 50.00000000 82.00000000\n", - " 108.00000000 30.00000000 52.00000000 47.00000000 77.00000000\n", - " 109.00000000 28.00000000 49.00000000 44.00000000 72.00000000\n", - " 110.00000000 26.00000000 46.00000000 41.00000000 68.00000000\n", - " 111.00000000 25.00000000 43.00000000 39.00000000 63.00000000\n", - " 112.00000000 23.00000000 40.00000000 36.00000000 59.00000000\n", - " 113.00000000 22.00000000 38.00000000 34.00000000 56.00000000\n", - " 114.00000000 20.00000000 35.00000000 32.00000000 52.00000000\n", - " 115.00000000 19.00000000 33.00000000 30.00000000 49.00000000\n", - " 116.00000000 18.00000000 31.00000000 28.00000000 46.00000000\n", - " 117.00000000 17.00000000 29.00000000 26.00000000 43.00000000\n", - " 118.00000000 15.00000000 27.00000000 24.00000000 40.00000000\n", - " 119.00000000 15.00000000 25.00000000 23.00000000 38.00000000\n", - " 120.00000000 14.00000000 24.00000000 21.00000000 35.00000000\n", - " 121.00000000 13.00000000 22.00000000 20.00000000 33.00000000\n", - " 122.00000000 12.00000000 21.00000000 19.00000000 31.00000000\n", - " 123.00000000 11.00000000 19.00000000 18.00000000 29.00000000\n", - " 124.00000000 10.00000000 18.00000000 16.00000000 27.00000000\n", - " 125.00000000 10.00000000 17.00000000 15.00000000 25.00000000\n", - " 126.00000000 9.00000000 16.00000000 14.00000000 24.00000000\n", - " 127.00000000 9.00000000 15.00000000 14.00000000 22.00000000\n", - " 128.00000000 8.00000000 14.00000000 13.00000000 21.00000000\n", - " 129.00000000 8.00000000 13.00000000 12.00000000 20.00000000\n", - " 130.00000000 7.00000000 12.00000000 11.00000000 18.00000000\n", - " 131.00000000 7.00000000 11.00000000 10.00000000 17.00000000\n", - " 132.00000000 6.00000000 11.00000000 10.00000000 16.00000000\n", - " 133.00000000 6.00000000 10.00000000 9.00000000 15.00000000\n", - " 134.00000000 6.00000000 9.00000000 9.00000000 14.00000000\n", - " 135.00000000 5.00000000 9.00000000 8.00000000 13.00000000\n", - " 136.00000000 5.00000000 8.00000000 8.00000000 12.00000000\n", - " 137.00000000 5.00000000 8.00000000 7.00000000 12.00000000\n", - " 138.00000000 4.00000000 7.00000000 7.00000000 11.00000000\n", - " 139.00000000 4.00000000 7.00000000 6.00000000 10.00000000\n", - " 140.00000000 4.00000000 6.00000000 6.00000000 10.00000000\n", - " 141.00000000 4.00000000 6.00000000 6.00000000 9.00000000\n", - " 142.00000000 3.00000000 6.00000000 5.00000000 8.00000000\n", - " 143.00000000 3.00000000 5.00000000 5.00000000 8.00000000\n", - " 144.00000000 3.00000000 5.00000000 5.00000000 7.00000000\n", - " 145.00000000 3.00000000 5.00000000 4.00000000 7.00000000\n", - " 146.00000000 3.00000000 4.00000000 4.00000000 6.00000000\n", - " 147.00000000 3.00000000 4.00000000 4.00000000 6.00000000\n", - " 148.00000000 2.00000000 4.00000000 4.00000000 6.00000000\n", - " 149.00000000 2.00000000 4.00000000 3.00000000 5.00000000\n", - " 150.00000000 2.00000000 3.00000000 3.00000000 5.00000000\n" - ] - } - ], + "outputs": [], "source": [ "import numpy as np\n", "\n", @@ -813,121 +525,10 @@ }, { "cell_type": "code", - "execution_count": 45, + "execution_count": null, "id": "03084231", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " Handicap York Hereford Albion Windsor\n", - " 0.00000000 1283.43539112 1294.41128340 971.20310600 971.99244624\n", - " 0.50000000 1282.23101439 1294.14732559 971.07019646 971.98968696\n", - " 1.00000000 1280.95163666 1293.85033513 970.92047684 971.98606417\n", - " 1.50000000 1279.59589344 1293.51777056 970.75259933 971.98135501\n", - " 2.00000000 1278.16257099 1293.14707366 970.56519185 971.97529244\n", - " 2.50000000 1276.65060131 1292.73568446 970.35686337 971.96755961\n", - " 3.00000000 1275.05905507 1292.28105576 970.12620874 971.95778415\n", - " 3.50000000 1273.38713286 1291.78066714 969.87181326 971.94553236\n", - " 4.00000000 1271.63415490 1291.23203803 969.59225663 971.93030358\n", - " 4.50000000 1269.79954959 1290.63273977 969.28611639 971.91152478\n", - " 5.00000000 1267.88284129 1289.98040647 968.95197074 971.88854538\n", - " 5.50000000 1265.88363751 1289.27274454 968.58840071 971.86063266\n", - " 6.00000000 1263.80161600 1288.50754076 968.19399174 971.82696765\n", - " 6.50000000 1261.63651198 1287.68266884 967.76733456 971.78664174\n", - " 7.00000000 1259.38810585 1286.79609443 967.30702550 971.73865408\n", - " 7.50000000 1257.05621161 1285.84587854 966.81166634 971.68190988\n", - " 8.00000000 1254.64066616 1284.83017941 966.27986356 971.61521966\n", - " 8.50000000 1252.14131966 1283.74725285 965.71022729 971.53729945\n", - " 9.00000000 1249.55802711 1282.59545119 965.10136999 971.44677220\n", - " 9.50000000 1246.89064104 1281.37322091 964.45190492 971.34217014\n", - " 10.00000000 1244.13900540 1280.07909904 963.76044465 971.22193825\n", - " 10.50000000 1241.30295059 1278.71170870 963.02559962 971.08443886\n", - " 11.00000000 1238.38228948 1277.26975371 962.24597707 970.92795713\n", - " 11.50000000 1235.37681438 1275.75201276 961.42018024 970.75070762\n", - " 12.00000000 1232.28629476 1274.15733318 960.54680823 970.55084157\n", - " 12.50000000 1229.11047561 1272.48462469 959.62445654 970.32645508\n", - " 13.00000000 1225.84907633 1270.73285332 958.65171831 970.07559784\n", - " 13.50000000 1222.50179002 1268.90103573 957.62718661 969.79628238\n", - " 14.00000000 1219.06828300 1266.98823415 956.54945752 969.48649372\n", - " 14.50000000 1215.54819461 1264.99355219 955.41713429 969.14419924\n", - " 15.00000000 1211.94113709 1262.91613155 954.22883245 968.76735867\n", - " 15.50000000 1208.24669560 1260.75514978 952.98318580 968.35393398\n", - " 16.00000000 1204.46442825 1258.50981916 951.67885329 967.90189915\n", - " 16.50000000 1200.59386620 1256.17938659 950.31452658 967.40924961\n", - " 17.00000000 1196.63451377 1253.76313446 948.88893803 966.87401125\n", - " 17.50000000 1192.58584855 1251.26038241 947.40086917 966.29424899\n", - " 18.00000000 1188.44732145 1248.67048959 945.84915909 965.66807468\n", - " 18.50000000 1184.21835675 1245.99285749 944.23271280 964.99365440\n", - " 19.00000000 1179.89835201 1243.22693276 942.55050905 964.26921500\n", - " 19.50000000 1175.48667789 1240.37220999 940.80160756 963.49305000\n", - " 20.00000000 1170.98267777 1237.42823404 938.98515525 962.66352457\n", - " 20.50000000 1166.38566715 1234.39460163 937.10039133 961.77907995\n", - " 21.00000000 1161.69493289 1231.27096212 935.14665107 960.83823697\n", - " 21.50000000 1156.90973211 1228.05701704 933.12336797 959.83959892\n", - " 22.00000000 1152.02929092 1224.75251846 931.03007442 958.78185371\n", - " 22.50000000 1147.05280288 1221.35726594 928.86640055 957.66377540\n", - " 23.00000000 1141.97942738 1217.87110221 926.63207151 956.48422509\n", - " 23.50000000 1136.80828789 1214.29390740 924.32690296 955.24215129\n", - " 24.00000000 1131.53847032 1210.62559214 921.95079509 953.93658975\n", - " 24.50000000 1126.16902149 1206.86608958 919.50372510 952.56666287\n", - " 25.00000000 1120.69894802 1203.01534649 916.98573850 951.13157859\n", - " 25.50000000 1115.12721563 1199.07331378 914.39693926 949.63062907\n", - " 26.00000000 1109.45274917 1195.03993663 911.73747909 948.06318881\n", - " 26.50000000 1103.67443339 1190.91514439 909.00754614 946.42871264\n", - " 27.00000000 1097.79111470 1186.69884076 906.20735332 944.72673326\n", - " 27.50000000 1091.80160403 1182.39089414 903.33712637 942.95685855\n", - " 28.00000000 1085.70468077 1177.99112874 900.39709216 941.11876861\n", - " 28.50000000 1079.49909800 1173.49931627 897.38746721 939.21221244\n", - " 29.00000000 1073.18358894 1168.91516865 894.30844667 937.23700435\n", - " 29.50000000 1066.75687465 1164.23833168 891.16019413 935.19302006\n", - " 30.00000000 1060.21767295 1159.46837981 887.94283213 933.08019246\n", - " 30.50000000 1053.56470844 1154.60481212 884.65643373 930.89850701\n", - " 31.00000000 1046.79672359 1149.64704941 881.30101519 928.64799679\n", - " 31.50000000 1039.91249077 1144.59443249 877.87652975 926.32873720\n", - " 32.00000000 1032.91082511 1139.44622169 874.38286272 923.94084025\n", - " 32.50000000 1025.79059791 1134.20159745 870.81982786 921.48444854\n", - " 33.00000000 1018.55075074 1128.85966211 867.18716503 918.95972878\n", - " 33.50000000 1011.19030976 1123.41944266 863.48453912 916.36686516\n", - " 34.00000000 1003.70840038 1117.87989466 859.71154034 913.70605226\n", - " 34.50000000 996.10426188 1112.23990710 855.86768563 910.97748786\n", - " 35.00000000 988.37726209 1106.49830817 851.95242137 908.18136552\n", - " 35.50000000 980.52691175 1100.65387209 847.96512700 905.31786708\n", - " 36.00000000 972.55287861 1094.70532665 843.90511984 902.38715520\n", - " 36.50000000 964.45500106 1088.65136167 839.77166063 899.38936598\n", - " 37.00000000 956.23330110 1082.49063823 835.56395996 896.32460174\n", - " 37.50000000 947.88799670 1076.22179857 831.28118529 893.19292422\n", - " 38.00000000 939.41951330 1069.84347667 826.92246850 889.99434806\n", - " 38.50000000 930.82849440 1063.35430941 822.48691395 886.72883494\n", - " 39.00000000 922.11581108 1056.75294822 817.97360669 883.39628828\n", - " 39.50000000 913.28257052 1050.03807123 813.38162098 879.99654866\n", - " 40.00000000 904.33012324 1043.20839568 808.71002886 876.52939008\n", - " 40.50000000 895.26006908 1036.26269067 803.95790869 872.99451707\n", - " 41.00000000 886.07426191 1029.19979012 799.12435372 869.39156268\n", - " 41.50000000 876.77481293 1022.01860573 794.20848042 865.72008752\n", - " 42.00000000 867.36409257 1014.71814007 789.20943672 861.97957961\n", - " 42.50000000 857.84473094 1007.29749954 784.12641004 858.16945533\n", - " 43.00000000 848.21961692 999.75590718 778.95863497 854.28906110\n", - " 43.50000000 838.49189572 992.09271526 773.70540089 850.33767613\n", - " 44.00000000 828.66496511 984.30741751 768.36605910 846.31451576\n", - " 44.50000000 818.74247025 976.39966093 762.94002983 842.21873567\n", - " 45.00000000 808.72829716 968.36925712 757.42680887 838.04943664\n", - " 45.50000000 798.62656501 960.21619300 751.82597395 833.80566990\n", - " 46.00000000 788.44161709 951.94064087 746.13719074 829.48644295\n", - " 46.50000000 778.17801080 943.54296768 740.36021864 825.09072569\n", - " 47.00000000 767.84050643 935.02374353 734.49491614 820.61745697\n", - " 47.50000000 757.43405519 926.38374927 728.54124591 816.06555133\n", - " 48.00000000 746.96378623 917.62398313 722.49927960 811.43390594\n", - " 48.50000000 736.43499304 908.74566637 716.36920221 806.72140774\n", - " 49.00000000 725.85311910 899.75024792 710.15131620 801.92694077\n", - " 49.50000000 715.22374312 890.63940798 703.84604525 797.04939353\n", - " 50.00000000 704.55256375 881.41506049 697.45393773 792.08766664\n", - " 50.50000000 693.84538405 872.07935460 690.97566980 787.04068056\n", - "Writing handicap table to file...Done.\n" - ] - } - ], + "outputs": [], "source": [ "handicaps = np.arange(0.0, 51.0, 0.5)\n", "\n", @@ -957,7 +558,7 @@ }, { "cell_type": "code", - "execution_count": 46, + "execution_count": null, "id": "3492598b", "metadata": {}, "outputs": [], @@ -988,21 +589,13 @@ }, { "cell_type": "code", - "execution_count": 47, + "execution_count": null, "id": "24eee19c", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "A score of 965 on a Hereford is class B2 for a 50+ male recurve.\n" - ] - } - ], + "outputs": [], "source": [ "# AGB Outdoor\n", - "class_from_score = class_func.calculate_AGB_outdoor_classification(\n", + "class_from_score = class_func.calculate_agb_outdoor_classification(\n", " \"hereford\",\n", " 965,\n", " \"recurve\",\n", @@ -1016,21 +609,13 @@ }, { "cell_type": "code", - "execution_count": 48, + "execution_count": null, "id": "c3b7d25c", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "A score of 562 on a WA 18 is class C for adult female compound.\n" - ] - } - ], + "outputs": [], "source": [ "# AGB Indoor\n", - "class_from_score = class_func.calculate_AGB_indoor_classification(\n", + "class_from_score = class_func.calculate_agb_indoor_classification(\n", " \"wa18\",\n", " 562,\n", " \"compound\",\n", @@ -1044,21 +629,13 @@ }, { "cell_type": "code", - "execution_count": 49, + "execution_count": null, "id": "d221a62c", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "A score of 168 on a WA Unmarked 24 is class 1st Class for an under 18 male traditional.\n" - ] - } - ], + "outputs": [], "source": [ "# AGB Field\n", - "class_from_score = class_func.calculate_AGB_field_classification(\n", + "class_from_score = class_func.calculate_agb_field_classification(\n", " \"wa_field_24_blue_unmarked\",\n", " 168,\n", " \"traditional\",\n", @@ -1086,20 +663,12 @@ }, { "cell_type": "code", - "execution_count": 50, + "execution_count": null, "id": "da99fc4b", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[-9999, -9999, -9999, -9999, 985, 863, 721, 571, 427]\n" - ] - } - ], + "outputs": [], "source": [ - "class_scores = class_func.AGB_outdoor_classification_scores(\n", + "class_scores = class_func.agb_outdoor_classification_scores(\n", " \"hereford\",\n", " \"recurve\",\n", " \"male\",\n", @@ -1110,20 +679,12 @@ }, { "cell_type": "code", - "execution_count": 51, + "execution_count": null, "id": "9e1bad8e", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[594, 586, 562, 518, 453, 349, 207, 160]\n" - ] - } - ], + "outputs": [], "source": [ - "class_scores = class_func.AGB_indoor_classification_scores(\n", + "class_scores = class_func.agb_indoor_classification_scores(\n", " \"portsmouth\",\n", " \"compound\",\n", " \"female\",\n", @@ -1134,20 +695,12 @@ }, { "cell_type": "code", - "execution_count": 52, + "execution_count": null, "id": "5220a9b0", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[158, 147, 134, 121, 107, 95]\n" - ] - } - ], + "outputs": [], "source": [ - "class_scores = class_func.AGB_field_classification_scores(\n", + "class_scores = class_func.agb_field_classification_scores(\n", " \"wa_field_24_blue_marked\",\n", " \"flatbow\",\n", " \"female\",\n", @@ -1173,7 +726,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.10" + "version": "3.10.12" } }, "nbformat": 4, From 796beacb1c73c1dce32f4ea1abe3acc198ea46c0 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 19 Aug 2023 20:50:03 +0100 Subject: [PATCH 31/56] Disable too many arguments in pylint for target and rounds. --- archeryutils/rounds.py | 4 ++++ archeryutils/targets.py | 45 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/archeryutils/rounds.py b/archeryutils/rounds.py index 68188be..c1fba9e 100644 --- a/archeryutils/rounds.py +++ b/archeryutils/rounds.py @@ -32,6 +32,8 @@ class Pass: max_score() Returns the maximum score for Pass """ + # Two too many arguments, but logically this structure makes sense => disable + # pylint: disable=too-many-arguments def __init__( self, @@ -110,6 +112,8 @@ class Round: Returns the maximum score for Round """ + # Two too many arguments, but logically this structure makes sense => disable + # pylint: disable=too-many-arguments def __init__( self, diff --git a/archeryutils/targets.py b/archeryutils/targets.py index f8d986f..24874cb 100644 --- a/archeryutils/targets.py +++ b/archeryutils/targets.py @@ -24,7 +24,11 @@ class Target: ------- max_score() Returns the maximum score ring value + min_score() + Returns the minimum score ring value (excluding miss) """ + # One too many arguments, but logically this structure makes sense => disable + # pylint: disable=too-many-arguments def __init__( self, @@ -128,3 +132,44 @@ def max_score(self) -> float: raise ValueError( f"Target face '{self.scoring_system}' has no specified maximum score." ) + + def min_score(self) -> float: + """ + Return the minimum numerical score possible on this target (excluding miss/0). + + Returns + ------- + min_score : float + minimum score possible on this target face + """ + if self.scoring_system in ( + "5_zone", + "10_zone", + "10_zone_compound", + "WA_field", + "IFAA_field", + "IFAA_field_expert", + "Worcester", + ): + return 1.0 + if self.scoring_system in ( + "10_zone_6_ring", + "10_zone_6_ring_compound", + ): + return 5.0 + if self.scoring_system in ( + "10_zone_5_ring", + "10_zone_5_ring_compound", + ): + return 6.0 + if self.scoring_system in ( + "Worcester_2_ring", + ): + return 4.0 + if self.scoring_system in ("Beiter_hit_miss"): + # For Beiter options are hit and miss, so return 0 here + return 0.0 + # NB: Should be hard (but not impossible) to get here without catching earlier. + raise ValueError( + f"Target face '{self.scoring_system}' has no specified minimum score." + ) From 7e0308574c0c71b237cd3c3613fc3c5a9265bd1c Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 19 Aug 2023 20:50:36 +0100 Subject: [PATCH 32/56] Refactor handicap equations to simplify parameters dataclass. --- archeryutils/handicaps/handicap_equations.py | 173 ++++++++++++------- 1 file changed, 110 insertions(+), 63 deletions(-) diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py index c7343ef..7f5572e 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -38,7 +38,7 @@ """ import json from typing import Union, Optional, Tuple -from dataclasses import dataclass +from dataclasses import dataclass, field import numpy as np import numpy.typing as npt @@ -108,34 +108,63 @@ class HcParams: """ - AGB_datum: float = 6.0 - AGB_step: float = 3.5 - AGB_ang_0: float = 5.0e-4 - AGB_kd: float = 0.00365 - - AGBo_datum: float = 12.9 - AGBo_step: float = 3.6 - AGBo_ang_0: float = 5.0e-4 - AGBo_k1: float = 1.429e-6 - AGBo_k2: float = 1.07 - AGBo_k3: float = 4.3 - AGBo_p1: float = 2.0 - AGBo_arw_d: float = 7.14e-3 + AGB_HC_DATA: dict[str, float] = field( + default_factory=lambda: ( + { + "AGB_datum": 6.0, + "AGB_step": 3.5, + "AGB_ang_0": 5.0e-4, + "AGB_kd": 0.00365, + } + ) + ) - AA_k0: float = 2.37 - AA_ks: float = 0.027 - AA_kd: float = 0.004 + AGBo_HC_DATA: dict[str, float] = field( + default_factory=lambda: ( + { + "AGBo_datum": 12.9, + "AGBo_step": 3.6, + "AGBo_ang_0": 5.0e-4, + "AGBo_k1": 1.429e-6, + "AGBo_k2": 1.07, + "AGBo_k3": 4.3, + "AGBo_p1": 2.0, + } + ) + ) - AA2_k0: float = 2.57 - AA2_ks: float = 0.027 - AA2_f1: float = 0.815 - AA2_f2: float = 0.185 - AA2_d0: float = 50.0 + AA_HC_DATA: dict[str, float] = field( + default_factory=lambda: ( + { + "AA_k0": 2.37, + "AA_ks": 0.027, + "AA_kd": 0.004, + } + ) + ) - AA_arw_d_out: float = 5.0e-3 + AA2_HC_DATA: dict[str, float] = field( + default_factory=lambda: ( + { + "AA2_k0": 2.57, + "AA2_ks": 0.027, + "AA2_f1": 0.815, + "AA2_f2": 0.185, + "AA2_d0": 50.0, + } + ) + ) - arw_d_in: float = 9.3e-3 - arw_d_out: float = 5.5e-3 + ARW_D_DATA: dict[str, float] = field( + default_factory=lambda: ( + { + "arw_d_in": 9.3e-3, + "arw_d_out": 5.5e-3, + "AGBo_arw_d": 7.14e-3, + "AA_arw_d_out": 5.0e-3, + } + ) + ) @classmethod def load_json_params(cls, jsonpath: str) -> "HcParams": @@ -156,29 +185,33 @@ def load_json_params(cls, jsonpath: str) -> "HcParams": json_hc_params: "HcParams" = cls() with open(jsonpath, "r", encoding="utf-8") as read_file: paramsdict = json.load(read_file) - json_hc_params.AGB_datum = paramsdict["AGB_datum"] - json_hc_params.AGB_step = paramsdict["AGB_step"] - json_hc_params.AGB_ang_0 = paramsdict["AGB_ang_0"] - json_hc_params.AGB_kd = paramsdict["AGB_kd"] - json_hc_params.AGBo_datum = paramsdict["AGBo_datum"] - json_hc_params.AGBo_step = paramsdict["AGBo_step"] - json_hc_params.AGBo_ang_0 = paramsdict["AGBo_ang_0"] - json_hc_params.AGBo_k1 = paramsdict["AGBo_k1"] - json_hc_params.AGBo_k2 = paramsdict["AGBo_k2"] - json_hc_params.AGBo_k3 = paramsdict["AGBo_k3"] - json_hc_params.AGBo_p1 = paramsdict["AGBo_p1"] - json_hc_params.AGBo_arw_d = paramsdict["AGBo_arw_d"] - json_hc_params.AA_k0 = paramsdict["AA_k0"] - json_hc_params.AA_ks = paramsdict["AA_ks"] - json_hc_params.AA_kd = paramsdict["AA_kd"] - json_hc_params.AA2_k0 = paramsdict["AA2_k0"] - json_hc_params.AA2_ks = paramsdict["AA2_ks"] - json_hc_params.AA2_f1 = paramsdict["AA2_f1"] - json_hc_params.AA2_f2 = paramsdict["AA2_f2"] - json_hc_params.AA2_d0 = paramsdict["AA2_d0"] - json_hc_params.AA_arw_d_out = paramsdict["AA_arw_d_out"] - json_hc_params.arw_d_in = paramsdict["arrow_diameter_indoors"] - json_hc_params.arw_d_out = paramsdict["arrow_diameter_outdoors"] + json_hc_params.AGB_HC_DATA["AGB_datum"] = paramsdict["AGB_datum"] + json_hc_params.AGB_HC_DATA["AGB_step"] = paramsdict["AGB_step"] + json_hc_params.AGB_HC_DATA["AGB_ang_0"] = paramsdict["AGB_ang_0"] + json_hc_params.AGB_HC_DATA["AGB_kd"] = paramsdict["AGB_kd"] + json_hc_params.AGBo_HC_DATA["AGBo_datum"] = paramsdict["AGBo_datum"] + json_hc_params.AGBo_HC_DATA["AGBo_step"] = paramsdict["AGBo_step"] + json_hc_params.AGBo_HC_DATA["AGBo_ang_0"] = paramsdict["AGBo_ang_0"] + json_hc_params.AGBo_HC_DATA["AGBo_k1"] = paramsdict["AGBo_k1"] + json_hc_params.AGBo_HC_DATA["AGBo_k2"] = paramsdict["AGBo_k2"] + json_hc_params.AGBo_HC_DATA["AGBo_k3"] = paramsdict["AGBo_k3"] + json_hc_params.AGBo_HC_DATA["AGBo_p1"] = paramsdict["AGBo_p1"] + json_hc_params.AA_HC_DATA["AA_k0"] = paramsdict["AA_k0"] + json_hc_params.AA_HC_DATA["AA_ks"] = paramsdict["AA_ks"] + json_hc_params.AA_HC_DATA["AA_kd"] = paramsdict["AA_kd"] + json_hc_params.AA2_HC_DATA["AA2_k0"] = paramsdict["AA2_k0"] + json_hc_params.AA2_HC_DATA["AA2_ks"] = paramsdict["AA2_ks"] + json_hc_params.AA2_HC_DATA["AA2_f1"] = paramsdict["AA2_f1"] + json_hc_params.AA2_HC_DATA["AA2_f2"] = paramsdict["AA2_f2"] + json_hc_params.AA2_HC_DATA["AA2_d0"] = paramsdict["AA2_d0"] + json_hc_params.ARW_D_DATA["arrow_diameter_indoors"] = paramsdict[ + "arrow_diameter_indoors" + ] + json_hc_params.ARW_D_DATA["arrow_diameter_outdoors"] = paramsdict[ + "arrow_diameter_outdoors" + ] + json_hc_params.ARW_D_DATA["AGBo_arw_d"] = paramsdict["AGBo_arw_d"] + json_hc_params.ARW_D_DATA["AA_arw_d_out"] = paramsdict["AA_arw_d_out"] return json_hc_params @@ -224,21 +257,28 @@ def sigma_t( if hc_sys == "AGB": # New AGB (Archery GB) System # Written by Jack Atkinson + hc_data = hc_dat.AGB_HC_DATA sig_t = ( - hc_dat.AGB_ang_0 - * ((1.0 + hc_dat.AGB_step / 100.0) ** (handicap + hc_dat.AGB_datum)) - * np.exp(hc_dat.AGB_kd * dist) + hc_data["AGB_ang_0"] + * ((1.0 + hc_data["AGB_step"] / 100.0) ** (handicap + hc_data["AGB_datum"])) + * np.exp(hc_data["AGB_kd"] * dist) ) elif hc_sys == "AGBold": # Old AGB (Archery GB) System # Written by David Lane (2013) - K = hc_dat.AGBo_k1 * hc_dat.AGBo_k2 ** (handicap + hc_dat.AGBo_k3) - F = 1.0 + K * dist**hc_dat.AGBo_p1 + hc_data = hc_dat.AGBo_HC_DATA + k_factor = hc_data["AGBo_k1"] * hc_data["AGBo_k2"] ** ( + handicap + hc_data["AGBo_k3"] + ) + f_factor = 1.0 + k_factor * dist ** hc_data["AGBo_p1"] sig_t = ( - hc_dat.AGBo_ang_0 - * ((1.0 + hc_dat.AGBo_step / 100.0) ** (handicap + hc_dat.AGBo_datum)) - * F + hc_data["AGBo_ang_0"] + * ( + (1.0 + hc_data["AGBo_step"] / 100.0) + ** (handicap + hc_data["AGBo_datum"]) + ) + * f_factor ) elif hc_sys == "AA": @@ -248,10 +288,13 @@ def sigma_t( # Required so code elsewhere is unchanged # Factor of 1.0e-3 due to AA algorithm specifying sigma t in milliradians, so # convert to rad + hc_data = hc_dat.AA_HC_DATA sig_t = ( 1.0e-3 * np.sqrt(2.0) - * np.exp(hc_dat.AA_k0 - hc_dat.AA_ks * handicap + hc_dat.AA_kd * dist) + * np.exp( + hc_data["AA_k0"] - hc_data["AA_ks"] * handicap + hc_data["AA_kd"] * dist + ) ) elif hc_sys == "AA2": @@ -261,11 +304,12 @@ def sigma_t( # Required so code elsewhere is unchanged # Factor of 1.0e-3 due to AA algorithm specifying sigma t in milliradians, so # convert to rad + hc_data = hc_dat.AA2_HC_DATA sig_t = ( np.sqrt(2.0) * 1.0e-3 - * np.exp(hc_dat.AA2_k0 - hc_dat.AA2_ks * handicap) - * (hc_dat.AA2_f1 + hc_dat.AA2_f2 * dist / hc_dat.AA2_d0) + * np.exp(hc_data["AA2_k0"] - hc_data["AA2_ks"] * handicap) + * (hc_data["AA2_f1"] + hc_data["AA2_f2"] * dist / hc_data["AA2_d0"]) ) else: @@ -349,15 +393,15 @@ def arrow_score( # pylint: disable=too-many-branches # otherwise select default from params based on in-/out-doors if arw_d is None: if hc_sys == "AGBold": - arw_rad = hc_dat.AGBo_arw_d / 2.0 + arw_rad = hc_dat.ARW_D_DATA["AGBo_arw_d"] / 2.0 else: if target.indoor: - arw_rad = hc_dat.arw_d_in / 2.0 + arw_rad = hc_dat.ARW_D_DATA["arw_d_in"] / 2.0 else: if hc_sys in ("AA", "AA2"): - arw_rad = hc_dat.AA_arw_d_out / 2.0 + arw_rad = hc_dat.ARW_D_DATA["AA_arw_d_out"] / 2.0 else: - arw_rad = hc_dat.arw_d_out / 2.0 + arw_rad = hc_dat.ARW_D_DATA["arw_d_out"] / 2.0 else: arw_rad = arw_d / 2.0 @@ -499,6 +543,9 @@ def score_for_round( average score for each pass in the round """ + # Two too many arguments. Makes sense at the moment => disable + # Could try and simplify hc_sys and hc_dat in future refactor + # pylint: disable=too-many-arguments pass_score = np.array( [ pass_i.n_arrows From 6b6da18fc6f576977bc801fdd9eb9234faf51e52 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sun, 20 Aug 2023 12:11:47 +0100 Subject: [PATCH 33/56] Revert HC_DATA variables to lowercase --- archeryutils/handicaps/handicap_equations.py | 78 ++++++++++---------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py index 7f5572e..b036466 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -77,8 +77,6 @@ class HcParams: constant used in handicap equation AGBo_p1 : float exponent of distance scaling - AGBo_arw_d : float - arrow diameter used in the old AGB algorithm by D. Lane KEY PARAMETERS AND CONSTANTS FOR THE ARCHERY AUSTRALIA SCHEME AA_k0 : float @@ -105,10 +103,14 @@ class HcParams: Diameter of an indoor arrow [metres] arw_d_out : float Diameter of an outdoor arrow [metres] + AGBo_arw_d : float + arrow diameter used in the old AGB algorithm by D. Lane [metres] + AA_arw_d_out : float + Diameter of an outdoor arrow in the Archery Australia scheme [metres] """ - AGB_HC_DATA: dict[str, float] = field( + agb_hc_data: dict[str, float] = field( default_factory=lambda: ( { "AGB_datum": 6.0, @@ -119,7 +121,7 @@ class HcParams: ) ) - AGBo_HC_DATA: dict[str, float] = field( + agb_old_hc_data: dict[str, float] = field( default_factory=lambda: ( { "AGBo_datum": 12.9, @@ -133,7 +135,7 @@ class HcParams: ) ) - AA_HC_DATA: dict[str, float] = field( + aa_hc_data: dict[str, float] = field( default_factory=lambda: ( { "AA_k0": 2.37, @@ -143,7 +145,7 @@ class HcParams: ) ) - AA2_HC_DATA: dict[str, float] = field( + aa2_hc_data: dict[str, float] = field( default_factory=lambda: ( { "AA2_k0": 2.57, @@ -155,7 +157,7 @@ class HcParams: ) ) - ARW_D_DATA: dict[str, float] = field( + arw_d_data: dict[str, float] = field( default_factory=lambda: ( { "arw_d_in": 9.3e-3, @@ -185,33 +187,33 @@ def load_json_params(cls, jsonpath: str) -> "HcParams": json_hc_params: "HcParams" = cls() with open(jsonpath, "r", encoding="utf-8") as read_file: paramsdict = json.load(read_file) - json_hc_params.AGB_HC_DATA["AGB_datum"] = paramsdict["AGB_datum"] - json_hc_params.AGB_HC_DATA["AGB_step"] = paramsdict["AGB_step"] - json_hc_params.AGB_HC_DATA["AGB_ang_0"] = paramsdict["AGB_ang_0"] - json_hc_params.AGB_HC_DATA["AGB_kd"] = paramsdict["AGB_kd"] - json_hc_params.AGBo_HC_DATA["AGBo_datum"] = paramsdict["AGBo_datum"] - json_hc_params.AGBo_HC_DATA["AGBo_step"] = paramsdict["AGBo_step"] - json_hc_params.AGBo_HC_DATA["AGBo_ang_0"] = paramsdict["AGBo_ang_0"] - json_hc_params.AGBo_HC_DATA["AGBo_k1"] = paramsdict["AGBo_k1"] - json_hc_params.AGBo_HC_DATA["AGBo_k2"] = paramsdict["AGBo_k2"] - json_hc_params.AGBo_HC_DATA["AGBo_k3"] = paramsdict["AGBo_k3"] - json_hc_params.AGBo_HC_DATA["AGBo_p1"] = paramsdict["AGBo_p1"] - json_hc_params.AA_HC_DATA["AA_k0"] = paramsdict["AA_k0"] - json_hc_params.AA_HC_DATA["AA_ks"] = paramsdict["AA_ks"] - json_hc_params.AA_HC_DATA["AA_kd"] = paramsdict["AA_kd"] - json_hc_params.AA2_HC_DATA["AA2_k0"] = paramsdict["AA2_k0"] - json_hc_params.AA2_HC_DATA["AA2_ks"] = paramsdict["AA2_ks"] - json_hc_params.AA2_HC_DATA["AA2_f1"] = paramsdict["AA2_f1"] - json_hc_params.AA2_HC_DATA["AA2_f2"] = paramsdict["AA2_f2"] - json_hc_params.AA2_HC_DATA["AA2_d0"] = paramsdict["AA2_d0"] - json_hc_params.ARW_D_DATA["arrow_diameter_indoors"] = paramsdict[ + json_hc_params.agb_hc_data["AGB_datum"] = paramsdict["AGB_datum"] + json_hc_params.agb_hc_data["AGB_step"] = paramsdict["AGB_step"] + json_hc_params.agb_hc_data["AGB_ang_0"] = paramsdict["AGB_ang_0"] + json_hc_params.agb_hc_data["AGB_kd"] = paramsdict["AGB_kd"] + json_hc_params.agb_old_hc_data["AGBo_datum"] = paramsdict["AGBo_datum"] + json_hc_params.agb_old_hc_data["AGBo_step"] = paramsdict["AGBo_step"] + json_hc_params.agb_old_hc_data["AGBo_ang_0"] = paramsdict["AGBo_ang_0"] + json_hc_params.agb_old_hc_data["AGBo_k1"] = paramsdict["AGBo_k1"] + json_hc_params.agb_old_hc_data["AGBo_k2"] = paramsdict["AGBo_k2"] + json_hc_params.agb_old_hc_data["AGBo_k3"] = paramsdict["AGBo_k3"] + json_hc_params.agb_old_hc_data["AGBo_p1"] = paramsdict["AGBo_p1"] + json_hc_params.aa_hc_data["AA_k0"] = paramsdict["AA_k0"] + json_hc_params.aa_hc_data["AA_ks"] = paramsdict["AA_ks"] + json_hc_params.aa_hc_data["AA_kd"] = paramsdict["AA_kd"] + json_hc_params.aa2_hc_data["AA2_k0"] = paramsdict["AA2_k0"] + json_hc_params.aa2_hc_data["AA2_ks"] = paramsdict["AA2_ks"] + json_hc_params.aa2_hc_data["AA2_f1"] = paramsdict["AA2_f1"] + json_hc_params.aa2_hc_data["AA2_f2"] = paramsdict["AA2_f2"] + json_hc_params.aa2_hc_data["AA2_d0"] = paramsdict["AA2_d0"] + json_hc_params.arw_d_data["arrow_diameter_indoors"] = paramsdict[ "arrow_diameter_indoors" ] - json_hc_params.ARW_D_DATA["arrow_diameter_outdoors"] = paramsdict[ + json_hc_params.arw_d_data["arrow_diameter_outdoors"] = paramsdict[ "arrow_diameter_outdoors" ] - json_hc_params.ARW_D_DATA["AGBo_arw_d"] = paramsdict["AGBo_arw_d"] - json_hc_params.ARW_D_DATA["AA_arw_d_out"] = paramsdict["AA_arw_d_out"] + json_hc_params.arw_d_data["AGBo_arw_d"] = paramsdict["AGBo_arw_d"] + json_hc_params.arw_d_data["AA_arw_d_out"] = paramsdict["AA_arw_d_out"] return json_hc_params @@ -257,7 +259,7 @@ def sigma_t( if hc_sys == "AGB": # New AGB (Archery GB) System # Written by Jack Atkinson - hc_data = hc_dat.AGB_HC_DATA + hc_data = hc_dat.agb_hc_data sig_t = ( hc_data["AGB_ang_0"] * ((1.0 + hc_data["AGB_step"] / 100.0) ** (handicap + hc_data["AGB_datum"])) @@ -267,7 +269,7 @@ def sigma_t( elif hc_sys == "AGBold": # Old AGB (Archery GB) System # Written by David Lane (2013) - hc_data = hc_dat.AGBo_HC_DATA + hc_data = hc_dat.agb_old_hc_data k_factor = hc_data["AGBo_k1"] * hc_data["AGBo_k2"] ** ( handicap + hc_data["AGBo_k3"] ) @@ -288,7 +290,7 @@ def sigma_t( # Required so code elsewhere is unchanged # Factor of 1.0e-3 due to AA algorithm specifying sigma t in milliradians, so # convert to rad - hc_data = hc_dat.AA_HC_DATA + hc_data = hc_dat.aa_hc_data sig_t = ( 1.0e-3 * np.sqrt(2.0) @@ -304,7 +306,7 @@ def sigma_t( # Required so code elsewhere is unchanged # Factor of 1.0e-3 due to AA algorithm specifying sigma t in milliradians, so # convert to rad - hc_data = hc_dat.AA2_HC_DATA + hc_data = hc_dat.aa2_hc_data sig_t = ( np.sqrt(2.0) * 1.0e-3 @@ -393,15 +395,15 @@ def arrow_score( # pylint: disable=too-many-branches # otherwise select default from params based on in-/out-doors if arw_d is None: if hc_sys == "AGBold": - arw_rad = hc_dat.ARW_D_DATA["AGBo_arw_d"] / 2.0 + arw_rad = hc_dat.arw_d_data["AGBo_arw_d"] / 2.0 else: if target.indoor: - arw_rad = hc_dat.ARW_D_DATA["arw_d_in"] / 2.0 + arw_rad = hc_dat.arw_d_data["arw_d_in"] / 2.0 else: if hc_sys in ("AA", "AA2"): - arw_rad = hc_dat.ARW_D_DATA["AA_arw_d_out"] / 2.0 + arw_rad = hc_dat.arw_d_data["AA_arw_d_out"] / 2.0 else: - arw_rad = hc_dat.ARW_D_DATA["arw_d_out"] / 2.0 + arw_rad = hc_dat.arw_d_data["arw_d_out"] / 2.0 else: arw_rad = arw_d / 2.0 From bca504dc456258f0471289eba4dead53fd11e033 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sun, 20 Aug 2023 12:42:55 +0100 Subject: [PATCH 34/56] linting of handicaps wip --- archeryutils/handicaps/handicap_functions.py | 9 +++++++-- archeryutils/handicaps/hc_sys_params.json | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index 944cfc1..302db89 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -69,6 +69,8 @@ def print_handicap_table( ------- None """ + # Cannot see any other way to handle the options required here => ignore + # pylint: ignore=too-many-arguments # Abbreviations to replace headings with in Handicap Tables to keep concise abbreviations = { "Compound": "C", @@ -156,8 +158,8 @@ def format_row(row: npt.NDArray[Union[np.float_, np.int_]]) -> str: if filename is not None: print("Writing handicap table to file...", end="") - with open(filename, "w") as f: - f.write(output_str) + with open(filename, "w", encoding="utf-8") as table_file: + table_file.write(output_str) print("Done.") @@ -265,6 +267,9 @@ def f_root( val, _ = hc_eq.score_for_round( rd, h, sys, hc_data, arw_dia, round_score_up=False ) + # Cannot see alt way to handle options required here at present => ignore + # pylint: ignore=too-many-arguments + # Ensure we return float, not np.ndarray # These 9 lines replace `return val-scr` so as to satisfy mypy --strict. # Should never be triggered in reality as h is type float. diff --git a/archeryutils/handicaps/hc_sys_params.json b/archeryutils/handicaps/hc_sys_params.json index 52fc90d..b595222 100644 --- a/archeryutils/handicaps/hc_sys_params.json +++ b/archeryutils/handicaps/hc_sys_params.json @@ -16,13 +16,13 @@ "AA_k0": 2.37, "AA_ks": 0.027, "AA_kd": 0.004, - + "AA2_k0": 2.57, "AA2_ks": 0.027, "AA2_f1": 0.815, "AA2_f2": 0.185, "AA2_d0": 50.0, - + "AA_arw_d_out": 5.0e-3, "arrow_diameter_indoors": 9.3e-3, "arrow_diameter_outdoors": 5.5e-3 From 1834e4f42b9da188517af1da56cdd71f3a880081 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Thu, 31 Aug 2023 11:08:59 +0100 Subject: [PATCH 35/56] Simple linting of files in archeryutils/ --- archeryutils/__init__.py | 2 +- archeryutils/rounds.py | 2 ++ archeryutils/targets.py | 5 ++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/archeryutils/__init__.py b/archeryutils/__init__.py index 0f7051b..c7cf251 100644 --- a/archeryutils/__init__.py +++ b/archeryutils/__init__.py @@ -1,7 +1,7 @@ """Package providing code for various archery utilities.""" from archeryutils import load_rounds, rounds, targets from archeryutils.handicaps import handicap_equations, handicap_functions -import archeryutils.classifications as classifications +from archeryutils import classifications __all__ = [ "rounds", diff --git a/archeryutils/rounds.py b/archeryutils/rounds.py index c1fba9e..e452d00 100644 --- a/archeryutils/rounds.py +++ b/archeryutils/rounds.py @@ -32,6 +32,7 @@ class Pass: max_score() Returns the maximum score for Pass """ + # Two too many arguments, but logically this structure makes sense => disable # pylint: disable=too-many-arguments @@ -112,6 +113,7 @@ class Round: Returns the maximum score for Round """ + # Two too many arguments, but logically this structure makes sense => disable # pylint: disable=too-many-arguments diff --git a/archeryutils/targets.py b/archeryutils/targets.py index 24874cb..b1e62c2 100644 --- a/archeryutils/targets.py +++ b/archeryutils/targets.py @@ -27,6 +27,7 @@ class Target: min_score() Returns the minimum score ring value (excluding miss) """ + # One too many arguments, but logically this structure makes sense => disable # pylint: disable=too-many-arguments @@ -162,9 +163,7 @@ def min_score(self) -> float: "10_zone_5_ring_compound", ): return 6.0 - if self.scoring_system in ( - "Worcester_2_ring", - ): + if self.scoring_system in ("Worcester_2_ring",): return 4.0 if self.scoring_system in ("Beiter_hit_miss"): # For Beiter options are hit and miss, so return 0 here From 7338403ca9663d60811b5dd8825d52e822ebf474 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Thu, 31 Aug 2023 11:27:07 +0100 Subject: [PATCH 36/56] Refactor outdoor classifications to abstract distance assignments. --- .../agb_outdoor_classifications.py | 340 +++++++++++------- .../classifications/classification_utils.py | 78 ++-- 2 files changed, 228 insertions(+), 190 deletions(-) diff --git a/archeryutils/classifications/agb_outdoor_classifications.py b/archeryutils/classifications/agb_outdoor_classifications.py index 064337a..fee1d82 100644 --- a/archeryutils/classifications/agb_outdoor_classifications.py +++ b/archeryutils/classifications/agb_outdoor_classifications.py @@ -49,54 +49,6 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: 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 @@ -104,7 +56,7 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: # Read in gender info as list of dicts agb_genders = cls_funcs.read_genders_json() # Read in classification names as dict - agb_classes_info_out = cls_funcs.read_classes_out_json() + agb_classes_info_out = cls_funcs.read_classes_json("agb_outdoor") agb_classes_out = agb_classes_info_out["classes"] agb_classes_out_long = agb_classes_info_out["classes_long"] @@ -133,10 +85,9 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: # 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)) + min_dists = np.empty((len(agb_classes_out))) for i in range(len(agb_classes_out)): # Assign handicap for this classification class_hc[i] = ( @@ -146,89 +97,21 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: + (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:] + # Get a list of the minimum distances that must be shot for this classification + min_dists[i] = assign_min_dist( + n_class=i, + gender=gender, + age_group=age["age_group"], + max_dists=max_dist, + ) # Assign prestige rounds for the category - # - 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) + prestige_rounds = assign_outdoor_prestige( + bowstyle=bowstyle["bowstyle"], + age=age["age_group"], + gender=gender, + max_dist=max_dist, + ) # TODO: class names and long are duplicated many times here # Consider a method to reduce this (affects other code) @@ -244,6 +127,187 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: return classification_dict +def assign_min_dist( + n_class: int, + gender: str, + age_group: str, + max_dists: List[int], +) -> int: + """ + Assign appropriate minimum distance required for a category and classification. + + Appropriate for 2023 ArcheryGB age groups and classifications. + + Parameters + ---------- + n_class : int + integer corresponding to classification [0=EMB, 8=A3] + gender : str + string defining gender + age_group : str, + string defining age group + max_dists: List[int] + list of integers defining the maximum distances for category + + Returns + ------- + min_dist : int + minimum distance [m] required for this classification + + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + # List of maximum distances for use in assigning maximum distance [metres] + # Use metres because corresponding yards distances are >= metric ones + dists = [90, 70, 60, 50, 40, 30, 20, 15] + + max_dist_index = dists.index(np.min(max_dists)) + + # B1 and above + if n_class <= 3: + # All MB and B1 require max distance for everyone: + return dists[max_dist_index] + + # Below B1 + # Age group trickery: + # U16 males and above step down for B2 and beyond + if gender.lower() in ("male") and age_group.lower().replace(" ", "") in ( + "adult", + "50+", + "under21", + "under18", + "under16", + ): + return dists[max_dist_index + (n_class - 3)] + + # All other categories require max dist for B1 and B2 then step down + try: + return dists[max_dist_index + (n_class - 3) - 1] + except IndexError: + # Distances stack at the bottom end as we can't go below 15m + return dists[-1] + + +def assign_outdoor_prestige( + bowstyle: str, + gender: str, + age: str, + max_dist: List[int], +) -> List[str]: + """ + Assign appropriate outdoor prestige rounds for a category. + + Appropriate for 2023 ArcheryGB age groups and classifications. + + Parameters + ---------- + bowstyle : str + string defining bowstyle + gender : str + string defining gender + age : str, + string defining age group + max_dist: List[int] + list of integers defining the maximum distances for category + + Returns + ------- + prestige_rounds : list of str + list of perstige rounds for category defined by inputs + + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + # Lists of prestige rounds defined by 'codename' of 'Round' class + # WARNING: do not change these without also addressing the prestige round code. + 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", + ] + + # Assign prestige rounds for the category + # - check bowstyle, distance, and age + prestige_rounds = [] + distance_check: List[str] = [] + + # 720 rounds - bowstyle dependent + if bowstyle.lower() == "compound": + # Everyone gets the 'adult' 720 + prestige_rounds.append(prestige_720_compound[0]) + # Check rest for junior eligible shorter rounds + distance_check = distance_check + prestige_720_compound[1:] + + elif bowstyle.lower() == "barebow": + # Everyone gets the 'adult' 720 + prestige_rounds.append(prestige_720_barebow[0]) + # Check rest for junior eligible shorter rounds + distance_check = distance_check + prestige_720_barebow[1:] + + else: + # Everyone gets the 'adult' 720 + prestige_rounds.append(prestige_720[0]) + # Check rest for junior eligible shorter rounds + distance_check = distance_check + prestige_720[1:] + + # Additional fix for Male 50+, U18, and U16 recurve + if gender.lower() == "male": + if age.lower() in ("50+", "under 18"): + prestige_rounds.append(prestige_720[1]) # 60m + elif age.lower() == "under 16": + prestige_rounds.append(prestige_720[2]) # 50m + + # Imperial and 1440 rounds - Check based on distance + distance_check = distance_check + prestige_imperial + distance_check = distance_check + prestige_metric + + # Check all other rounds based on distance + for roundname in distance_check: + if ALL_OUTDOOR_ROUNDS[roundname].max_distance() >= np.min(max_dist): + prestige_rounds.append(roundname) + + return prestige_rounds + + agb_outdoor_classifications = _make_agb_outdoor_classification_dict() del _make_agb_outdoor_classification_dict @@ -303,7 +367,7 @@ def calculate_agb_outdoor_classification( 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, :], + "min_dist": group_data["min_dists"][i], "score": all_class_scores[i], } @@ -311,20 +375,18 @@ def calculate_agb_outdoor_classification( 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] + 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: + if class_i[1]["min_dist"] > 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(): @@ -398,7 +460,7 @@ def agb_outdoor_classification_scores( # If not prestige, what classes are eligible based on category and distance round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance() for i in range(3, len(class_scores)): - if min(group_data["min_dists"][i, :]) > round_max_dist: + if group_data["min_dists"][i] > round_max_dist: class_scores[i] = -9999 # Make sure that hc.eq.score_for_round did not return array to satisfy mypy diff --git a/archeryutils/classifications/classification_utils.py b/archeryutils/classifications/classification_utils.py index 230e6f0..66a4da8 100644 --- a/archeryutils/classifications/classification_utils.py +++ b/archeryutils/classifications/classification_utils.py @@ -112,16 +112,17 @@ def read_genders_json( ) -def read_classes_out_json( - classes_file: Path = Path(__file__).parent / "AGB_classes_out.json", +def read_classes_json( + class_system: str, ) -> Dict[str, Any]: """ - Read AGB outdoor classes in from neighbouring json file to dict. + Read AGB classes in from neighbouring json file to dict. Parameters ---------- - classes_file : Path - path to json file + class_system : str + string specifying class system to read: + 'agb_indoor', 'agb_outdoor', 'agb_field' Returns ------- @@ -132,38 +133,20 @@ def read_classes_out_json( ---------- Archery GB Rules of Shooting """ - # Read in classification names as dict - with open(classes_file, encoding="utf-8") as json_file: - classes = json.load(json_file) - if isinstance(classes, dict): - return classes - raise TypeError( - f"Unexpected classes input when reading from json file. " - f"Expected dict() but got {type(classes)}. Check {classes_file}." - ) - - -# TODO This could (should) be condensed into one method with the above function -def read_classes_in_json( - classes_file: Path = Path(__file__).parent / "AGB_classes_in.json", -) -> Dict[str, Any]: - """ - Read AGB indoor classes in from neighbouring json file to dict. + if class_system == "agb_indoor": + filename = "AGB_classes_in.json" + elif class_system == "agb_outdoor": + filename = "AGB_classes_out.json" + # elif class_system == 'agb_field': + # filename = "AGB_classes_field.json" + else: + raise ValueError( + "Unexpected classification system specified. " + "Expected one of 'agb_indoor', 'agb_outdoor', 'aqb_field'." + ) - Parameters - ---------- - classes_file : Path - path to json file - - Returns - ------- - classes : dict - AGB classes data from file + classes_file = Path(__file__).parent / filename - 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) @@ -224,25 +207,20 @@ def strip_spots( return roundname -def get_compound_codename(round_codenames): +def get_compound_codename(round_codename: str) -> str: """ Convert any indoor rounds with special compound scoring to the compound format. Parameters ---------- - round_codenames : str or list of str - list of str round codenames to check + round_codenames : str + str round codename to check Returns ------- - round_codenames : str or list of str - list of amended round codenames for compound + round_codename : str + amended round codename for compound """ - notlistflag = False - if not isinstance(round_codenames, list): - round_codenames = [round_codenames] - notlistflag = True - convert_dict = { "bray_i": "bray_i_compound", "bray_i_triple": "bray_i_compound_triple", @@ -258,9 +236,7 @@ def get_compound_codename(round_codenames): "wa25_triple": "wa25_compound_triple", } - for i, codename in enumerate(round_codenames): - if codename in convert_dict: - round_codenames[i] = convert_dict[codename] - if notlistflag: - return round_codenames[0] - return round_codenames + if convert_dict.get(round_codename) is not None: + round_codename = convert_dict[round_codename] + + return round_codename From a0250e47e1e1714aa4b314cd8c71fdb978ca12fd Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Thu, 31 Aug 2023 12:20:49 +0100 Subject: [PATCH 37/56] Bug fix reading json in indoor, mypy typo fix in field_tests. --- archeryutils/classifications/agb_indoor_classifications.py | 2 +- archeryutils/classifications/tests/test_agb_field.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/archeryutils/classifications/agb_indoor_classifications.py b/archeryutils/classifications/agb_indoor_classifications.py index ad44d39..093cd4a 100644 --- a/archeryutils/classifications/agb_indoor_classifications.py +++ b/archeryutils/classifications/agb_indoor_classifications.py @@ -59,7 +59,7 @@ def _make_agb_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: # Read in gender info as list of dicts agb_genders = cls_funcs.read_genders_json() # Read in classification names as dict - agb_classes_info_in = cls_funcs.read_classes_in_json() + agb_classes_info_in = cls_funcs.read_classes_json("agb_indoor") agb_classes_in = agb_classes_info_in["classes"] agb_classes_in_long = agb_classes_info_in["classes_long"] diff --git a/archeryutils/classifications/tests/test_agb_field.py b/archeryutils/classifications/tests/test_agb_field.py index 00b883e..42560fb 100644 --- a/archeryutils/classifications/tests/test_agb_field.py +++ b/archeryutils/classifications/tests/test_agb_field.py @@ -1,4 +1,5 @@ """Tests for agb field classification functions""" +from typing import List import pytest from archeryutils import load_rounds @@ -62,7 +63,7 @@ def test_agb_field_classification_scores_ages( self, roundname: str, age_group: str, - scores_expected: str, + scores_expected: List[int], ) -> None: """ Check that field classification returns expected value for a case. @@ -110,7 +111,7 @@ def test_agb_field_classification_scores_genders( roundname: str, gender: str, age_group: str, - scores_expected: str, + scores_expected: List[int], ) -> None: """ Check that field classification returns expected value for a case. @@ -164,7 +165,7 @@ def test_agb_field_classification_scores_bowstyles( self, roundname: str, bowstyle: str, - scores_expected: str, + scores_expected: List[int], ) -> None: """ Check that field classification returns expected value for a case. From d54aaa2f6f9d2f5f1bf5dddba31b67a8d731d938 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Thu, 31 Aug 2023 18:11:56 +0100 Subject: [PATCH 38/56] Classifications refactor to address U16 Lady Longbow Bug. --- .../agb_field_classifications.py | 3 + .../agb_indoor_classifications.py | 43 ++++++------ .../agb_outdoor_classifications.py | 67 ++++++++++--------- .../classifications/classification_utils.py | 42 ++++++++++++ 4 files changed, 100 insertions(+), 55 deletions(-) diff --git a/archeryutils/classifications/agb_field_classifications.py b/archeryutils/classifications/agb_field_classifications.py index b01fc2a..ebe2a98 100644 --- a/archeryutils/classifications/agb_field_classifications.py +++ b/archeryutils/classifications/agb_field_classifications.py @@ -288,6 +288,9 @@ def agb_field_classification_scores( ArcheryGB Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 """ + # Unused roundname argument to keep consistency with other classification functions + # pylint: disable=unused-argument + # deal with reduced categories: if age_group.lower().replace(" ", "") in ("adult", "50+", "under21"): age_group = "Adult" diff --git a/archeryutils/classifications/agb_indoor_classifications.py b/archeryutils/classifications/agb_indoor_classifications.py index 093cd4a..49a460f 100644 --- a/archeryutils/classifications/agb_indoor_classifications.py +++ b/archeryutils/classifications/agb_indoor_classifications.py @@ -69,40 +69,39 @@ def _make_agb_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: # loop over genders classification_dict = {} for bowstyle in agb_bowstyles: - for age in agb_ages: - for gender in agb_genders: - # Get age steps from Adult - age_steps = age["step"] - - # Get number of gender steps required - # Perform fiddle in age steps where genders diverge at U15/U16 - if gender.lower() == "female" and age["step"] <= 3: - gender_steps = 1 - else: - gender_steps = 0 + for gender in agb_genders: + for age in agb_ages: groupname = cls_funcs.get_groupname( bowstyle["bowstyle"], gender, age["age_group"] ) - class_hc = np.empty(len(agb_classes_in)) - 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, } + # set step from datum based on age and gender steps required + delta_hc_age_gender = cls_funcs.get_age_gender_step( + gender, + age["step"], + bowstyle["ageStep_in"], + bowstyle["genderStep_in"], + ) + + classification_dict[groupname]["class_HC"] = np.empty( + len(agb_classes_in) + ) + for i in range(len(agb_classes_in)): + # Assign handicap for this classification + classification_dict[groupname]["class_HC"][i] = ( + bowstyle["datum_in"] + + delta_hc_age_gender + + (i - 1) * bowstyle["classStep_in"] + ) + return classification_dict diff --git a/archeryutils/classifications/agb_outdoor_classifications.py b/archeryutils/classifications/agb_outdoor_classifications.py index fee1d82..f8b4844 100644 --- a/archeryutils/classifications/agb_outdoor_classifications.py +++ b/archeryutils/classifications/agb_outdoor_classifications.py @@ -62,21 +62,12 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: # Generate dict of classifications # loop over bowstyles - # loop over ages # loop over genders + # loop over ages classification_dict = {} for bowstyle in agb_bowstyles: - for age in agb_ages: - for gender in agb_genders: - # Get age steps from Adult - age_steps = age["step"] - - # Get number of gender steps required - # Perform fiddle in age steps where genders diverge at U15/U16 - if gender.lower() == "female" and age["step"] <= 3: - gender_steps = 1 - else: - gender_steps = 0 + for gender in agb_genders: + for age in agb_ages: groupname = cls_funcs.get_groupname( bowstyle["bowstyle"], gender, age["age_group"] @@ -86,19 +77,38 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: # Use metres as corresponding yards >= metric max_dist = age[gender.lower()] - class_hc = np.empty(len(agb_classes_out)) - min_dists = np.empty((len(agb_classes_out))) + # 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, + "max_distance": max_dist, + "classes_long": agb_classes_out_long, + } + + # set step from datum based on age and gender steps required + delta_hc_age_gender = cls_funcs.get_age_gender_step( + gender, + age["step"], + bowstyle["ageStep_out"], + bowstyle["genderStep_out"], + ) + + classification_dict[groupname]["class_HC"] = np.empty( + len(agb_classes_out) + ) + classification_dict[groupname]["min_dists"] = np.empty( + len(agb_classes_out) + ) for i in range(len(agb_classes_out)): # Assign handicap for this classification - class_hc[i] = ( + classification_dict[groupname]["class_HC"][i] = ( bowstyle["datum_out"] - + age_steps * bowstyle["ageStep_out"] - + gender_steps * bowstyle["genderStep_out"] + + delta_hc_age_gender + (i - 2) * bowstyle["classStep_out"] ) - # Get a list of the minimum distances that must be shot for this classification - min_dists[i] = assign_min_dist( + # Get minimum distance that must be shot for this classification + classification_dict[groupname]["min_dists"][i] = assign_min_dist( n_class=i, gender=gender, age_group=age["age_group"], @@ -106,24 +116,15 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: ) # Assign prestige rounds for the category - prestige_rounds = assign_outdoor_prestige( + classification_dict[groupname][ + "prestige_rounds" + ] = assign_outdoor_prestige( bowstyle=bowstyle["bowstyle"], age=age["age_group"], gender=gender, max_dist=max_dist, ) - # 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 @@ -396,8 +397,8 @@ def calculate_agb_outdoor_classification( del class_data[item] try: - classification_from_score = list(class_data.keys())[0] - return classification_from_score + # TODO Remove classification_from_score = list(class_data.keys())[0] + return list(class_data.keys())[0] except IndexError: return "UC" diff --git a/archeryutils/classifications/classification_utils.py b/archeryutils/classifications/classification_utils.py index 66a4da8..381da7e 100644 --- a/archeryutils/classifications/classification_utils.py +++ b/archeryutils/classifications/classification_utils.py @@ -185,6 +185,48 @@ def get_groupname(bowstyle: str, gender: str, age_group: str) -> str: return groupname +def get_age_gender_step( + gender: str, + age_cat: int, + age_step: float, + gender_step: float, +) -> float: + """ + Calculate AGB indoor age and gender step for classification dictionaries. + + Contains a tricky fiddle for aligning Male and Female under 15 scores and below, + and a necessary check to ensure that gender step doesnt overtake age step when + doing this. + + Parameters + ---------- + gender : str + gender this classification applies to + age_cat : int + age category as an integer (number of age steps below adult e.g. 50+=1, U14=5) + age_step : float + age group handicap step for this category + gender_step : float + gender handicap step for this category + + Returns + ------- + delta_hc_age_gender : float + age and gender handicap step for this category's MB relative to datum + """ + # There is a danger that gender step overtakes age step at U15/U16 + # interface. If this happens set to age step to align U16 with U16 + if gender.lower() == "female" and age_cat == 3 and age_step < gender_step: + return age_cat * age_step + age_step + + # For females <=3 (Under 16 or older) apply gender step and age steps + if gender.lower() == "female" and age_cat <= 3: + return gender_step + age_cat * age_step + + # Default case for males, and females aged >3 (Under 15 or younger) apply age steps + return age_cat * age_step + + def strip_spots( roundname: str, ) -> str: From e9269ec2bc466c1e1ed051ef7229efad695c1ede Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 14:29:54 +0100 Subject: [PATCH 39/56] Clean linting errors in load_rounds.py --- .../agb_indoor_classifications.py | 12 ++-- .../agb_outdoor_classifications.py | 72 +++++++++++++------ archeryutils/load_rounds.py | 1 - 3 files changed, 58 insertions(+), 27 deletions(-) diff --git a/archeryutils/classifications/agb_indoor_classifications.py b/archeryutils/classifications/agb_indoor_classifications.py index 49a460f..a56d66d 100644 --- a/archeryutils/classifications/agb_indoor_classifications.py +++ b/archeryutils/classifications/agb_indoor_classifications.py @@ -76,8 +76,6 @@ def _make_agb_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: bowstyle["bowstyle"], gender, age["age_group"] ) - # 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, "classes_long": agb_classes_in_long, @@ -253,17 +251,19 @@ def agb_indoor_classification_scores( # Handle possibility of gaps in the tables or max scores by checking 1 HC point # above current (floored to handle 0.5) and amending accordingly - for i, (sc, hc) in enumerate(zip(int_class_scores, group_data["class_HC"])): + for i, (score, handicap) in enumerate( + zip(int_class_scores, group_data["class_HC"]) + ): next_score = hc_eq.score_for_round( ALL_INDOOR_ROUNDS[cls_funcs.strip_spots(roundname)], - np.floor(hc) + 1, + np.floor(handicap) + 1, hc_scheme, hc_params, round_score_up=True, )[0] - if next_score == sc: + if next_score == score: # If already at max score this classification is impossible - if sc == ALL_INDOOR_ROUNDS[roundname].max_score(): + if score == ALL_INDOOR_ROUNDS[roundname].max_score(): int_class_scores[i] = -9999 # If gap in table increase to next score # (we assume here that no two classifications are only 1 point apart...) diff --git a/archeryutils/classifications/agb_outdoor_classifications.py b/archeryutils/classifications/agb_outdoor_classifications.py index f8b4844..abd879b 100644 --- a/archeryutils/classifications/agb_outdoor_classifications.py +++ b/archeryutils/classifications/agb_outdoor_classifications.py @@ -8,6 +8,7 @@ agb_outdoor_classification_scores """ from typing import List, Dict, Any +from collections import OrderedDict import numpy as np from archeryutils import load_rounds @@ -77,8 +78,6 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: # Use metres as corresponding yards >= metric max_dist = age[gender.lower()] - # 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, "max_distance": max_dist, @@ -365,30 +364,19 @@ def calculate_agb_outdoor_classification( groupname = cls_funcs.get_groupname(bowstyle, gender, age_group) group_data = agb_outdoor_classifications[groupname] - class_data: Dict[str, Dict[str, Any]] = {} + # We iterate over class_data keys, so convert use OrderedDict + class_data: OrderedDict[str, Dict[str, Any]] = OrderedDict([]) for i, class_i in enumerate(group_data["classes"]): class_data[class_i] = { "min_dist": group_data["min_dists"][i], "score": all_class_scores[i], } - # is it a prestige round? If not remove MB as an option - if roundname not in agb_outdoor_classifications[groupname]["prestige_rounds"]: - # TODO: a list of dictionary keys is super dodgy python... - # can this be improved? - for mb_class in list(class_data.keys())[0:3]: - del class_data[mb_class] - - # 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_dist"] > round_max_dist: - to_del.append(class_i[0]) - for class_i in to_del: - del class_data[class_i] + # Check if this is a prestige round and appropriate distances + # remove ineligible classes from class_data + class_data = check_prestige_distance(roundname, groupname, class_data) - # Of those classes remaining, what is the highest classification this score gets? + # Of the classes remaining, what is the highest classification this score gets? to_del = [] for classname, classdata in class_data.items(): if classdata["score"] > score: @@ -397,12 +385,56 @@ def calculate_agb_outdoor_classification( del class_data[item] try: - # TODO Remove classification_from_score = list(class_data.keys())[0] return list(class_data.keys())[0] except IndexError: return "UC" +def check_prestige_distance( + roundname: str, groupname: str, class_data: OrderedDict[str, Dict[str, Any]] +) -> OrderedDict[str, Dict[str, Any]]: + """ + Check available classifications for eligibility based on distance and prestige.. + + Remove MB tier if not a prestige round. + Remove any classifications where round is not far enough. + + Parameters + ---------- + roundname : str + name of round shot as given by 'codename' in json + groupname : str + identifier for the category + class_data : OrderedDict + classification information for each category. + + Returns + ------- + class_data : OrderedDict + updated classification information for each category. + + References + ---------- + ArcheryGB 2023 Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 (2023) + """ + # is it a prestige round? If not remove MB as an option + if roundname not in agb_outdoor_classifications[groupname]["prestige_rounds"]: + for mb_class in list(class_data.keys())[0:3]: + del class_data[mb_class] + + # If not prestige, what classes are ineligible based on distance + to_del: List[str] = [] + round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance() + for class_i_name, class_i_data in class_data.items(): + if class_i_data["min_dist"] > round_max_dist: + to_del.append(class_i_name) + for class_i in to_del: + del class_data[class_i] + + return class_data + + def agb_outdoor_classification_scores( roundname: str, bowstyle: str, gender: str, age_group: str ) -> List[int]: diff --git a/archeryutils/load_rounds.py b/archeryutils/load_rounds.py index d7cae07..a01d709 100644 --- a/archeryutils/load_rounds.py +++ b/archeryutils/load_rounds.py @@ -94,7 +94,6 @@ def read_json_to_round_dict(json_filelist: Union[str, List[str]]) -> Dict[str, R "Defaulting to 'custom'." ) round_i["body"] = "custom" - # TODO: Could do sanitisation here e.g. AGB vs agb etc or trust user... # Assign round family if "family" not in round_i: From 28e37c27e9c4053d0bd34f754317c70e1914ef47 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 15:35:55 +0100 Subject: [PATCH 40/56] Linting refactor of handicap code. --- archeryutils/handicaps/handicap_equations.py | 4 +- archeryutils/handicaps/handicap_functions.py | 503 +++++++++++-------- 2 files changed, 302 insertions(+), 205 deletions(-) diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py index b036466..dac5fa9 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -358,13 +358,15 @@ def sigma_r( return sig_r -def arrow_score( # pylint: disable=too-many-branches +def arrow_score( target: targets.Target, handicap: Union[float, npt.NDArray[np.float_]], hc_sys: str, hc_dat: HcParams, arw_d: Optional[float] = None, ) -> Union[float, np.float_, npt.NDArray[np.float_]]: + # Six too many branches. Makes sense due to different target faces => disable + # pylint: disable=too-many-branches """ Calculate the average arrow score for a given target and handicap rating. diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index 302db89..fed4d5b 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -8,8 +8,10 @@ Routine Listings ---------------- -print_handicap_table handicap_from_score +print_handicap_table +abbreviate +format_row """ from typing import Union, Optional, List @@ -21,146 +23,7 @@ import archeryutils.handicaps.handicap_equations as hc_eq from archeryutils import rounds -FILL = -1000 - - -def print_handicap_table( - hcs: Union[float, npt.NDArray[np.float_]], - hc_sys: str, - round_list: List[rounds.Round], - hc_dat: hc_eq.HcParams, - arrow_d: Optional[float] = None, - round_scores_up: bool = True, - clean_gaps: bool = True, - printout: bool = True, - filename: Optional[str] = None, - csvfile: Optional[str] = None, - int_prec: Optional[bool] = False, -) -> None: - """ - Generate a handicap table to screen and/or file. - - Parameters - ---------- - hcs : ndarray or float - handicap value(s) to calculate score(s) for - hc_sys : string - identifier for the handicap system - round_list : list of rounds.Round - List of Round classes to calculate scores for - hc_dat : handicaps.handicap_equations.HcParams - dataclass containing parameters for handicap equations - arrow_d : float - arrow diameter in [metres] default = None - round_scores_up : bool - round scores up to nearest integer? default = True - clean_gaps : bool - Remove all instances of a score except the first? default = False - printout : bool - Print to screen? default = True - filename : str - filepath to save table to. default = None - csvfile : str - csv filepath to save to. default = None - int_prec : bool - display results as integers? default = False, with decimal to 2dp - - Returns - ------- - None - """ - # Cannot see any other way to handle the options required here => ignore - # pylint: ignore=too-many-arguments - # Abbreviations to replace headings with in Handicap Tables to keep concise - abbreviations = { - "Compound": "C", - "Recurve": "R", - "Triple": "Tr", - "Centre": "C", - "Portsmouth": "Ports", - "Worcester": "Worc", - "Short": "St", - "Long": "Lg", - "Small": "Sm", - "Gents": "G", - "Ladies": "L", - } - - if not isinstance(hcs, np.ndarray): - if isinstance(hcs, list): - hcs = np.array(hcs) - elif isinstance(hcs, (float, int)): - hcs = np.array([hcs]) - else: - raise TypeError("Expected float or ndarray for hcs.") - - table = np.empty([len(hcs), len(round_list) + 1]) - table[:, 0] = hcs.copy() - for i, round_i in enumerate(round_list): - table[:, i + 1], _ = hc_eq.score_for_round( - round_i, hcs, hc_sys, hc_dat, arrow_d, round_score_up=round_scores_up - ) - - # If rounding scores up we don't want to display trailing zeros, so ensure int_prec - if round_scores_up: - int_prec = True - - if int_prec: - table = table.astype(int) - - if clean_gaps: - # TODO: This assumes scores are running highest to lowest. - # AA and AA2 will only work if hcs passed in reverse order (large to small) - for irow, row in enumerate(table[:-1, :]): - for jscore in range(len(row)): - if table[irow, jscore] == table[irow + 1, jscore]: - if int_prec: - table[irow, jscore] = FILL - else: - table[irow, jscore] = np.nan - - # Write to CSV - - if csvfile is not None: - print("Writing handicap table to csv...", end="") - np.savetxt( - csvfile, - table, - delimiter=", ", - header=f"handicap, {','.join([round_i.name for round_i in round_list])}'", - ) - print("Done.") - - # Write to terminal/string - # Return early if this isn't required - if filename is None and not printout: - return - - # To ensure both terminal and file output are the same, create a single string to - # be used in either case - - def abbreviate(name: str) -> str: - return " ".join(abbreviations.get(i, i) for i in name.split()) - - round_names = [abbreviate(r.name) for r in round_list] - output_header = "".join(name.rjust(14) for name in chain(["Handicap"], round_names)) - - def format_row(row: npt.NDArray[Union[np.float_, np.int_]]) -> str: - if int_prec: - return "".join("".rjust(14) if x == FILL else f"{x:14d}" for x in row) - return "".join("".rjust(14) if np.isnan(x) else f"{x:14.8f}" for x in row) - - output_rows = [format_row(row) for row in table] - output_str = "\n".join(chain([output_header], output_rows)) - - if printout: - print(output_str) - - if filename is not None: - print("Writing handicap table to file...", end="") - with open(filename, "w", encoding="utf-8") as table_file: - table_file.write(output_str) - print("Done.") +FILL = -9999 def handicap_from_score( @@ -171,6 +34,9 @@ def handicap_from_score( arw_d: Optional[float] = None, int_prec: bool = False, ) -> Union[int, float]: + # One too many arguments. Makes sense at the moment => disable + # Could try and simplify hc_sys and hc_dat in future refactor + # pylint: disable=too-many-arguments """ Calculate the handicap of a given score on a given round using root-finding. @@ -219,11 +85,11 @@ def handicap_from_score( # start high and drop down until no longer rounding to max score # (i.e. >= max_score - 1.0 for AGB, and >= max_score - 0.5 for AA, AA2, and AGBold) if hc_sys in ("AA", "AA2"): - hc = 175.0 - dhc = -0.01 + handicap = 175.0 + delta_hc = -0.01 else: - hc = -75.0 - dhc = 0.01 + handicap = -75.0 + delta_hc = 0.01 # Set rounding limit if hc_sys in ("AA", "AA2", "AGBold"): @@ -232,20 +98,20 @@ def handicap_from_score( round_lim = 1.0 s_max, _ = hc_eq.score_for_round( - rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=False + rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=False ) # Work down to where we would round or ceil to max score while s_max > max_score - round_lim: - hc = hc + dhc + handicap = handicap + delta_hc s_max, _ = hc_eq.score_for_round( - rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=False + rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=False ) - hc = hc - dhc # Undo final iteration that overshoots + handicap = handicap - delta_hc # Undo final iteration that overshoots if int_prec: if hc_sys in ("AA", "AA2"): - hc = np.ceil(hc) + handicap = np.ceil(handicap) else: - hc = np.floor(hc) + handicap = np.floor(handicap) else: warnings.warn( "Handicap requested for maximum score without integer precision.\n" @@ -253,43 +119,17 @@ def handicap_from_score( "This could cause issues if you are not working in integers.", UserWarning, ) - return hc + return handicap # ROOT FINDING for general case (not max score) - def f_root( - h: float, - scr: Union[int, float], - rd: rounds.Round, - sys: str, - hc_data: hc_eq.HcParams, - arw_dia: Optional[float], - ) -> float: - val, _ = hc_eq.score_for_round( - rd, h, sys, hc_data, arw_dia, round_score_up=False - ) - # Cannot see alt way to handle options required here at present => ignore - # pylint: ignore=too-many-arguments - - # Ensure we return float, not np.ndarray - # These 9 lines replace `return val-scr` so as to satisfy mypy --strict. - # Should never be triggered in reality as h is type float. - if isinstance(val, np.float_): - val = val.item() - if isinstance(val, float): - return val - scr - raise TypeError( - f"f_root is attempting to return a {type(val)} type but expected float. " - f"Was it passed an array of handicaps?" - ) - if hc_sys in ("AA", "AA2"): - x = [-250.0, 175.0] + x_init = [-250.0, 175.0] else: - x = [-75.0, 300.0] + x_init = [-75.0, 300.0] - f = [ - f_root(x[0], score, rnd, hc_sys, hc_dat, arw_d), - f_root(x[1], score, rnd, hc_sys, hc_dat, arw_d), + f_init = [ + f_root(x_init[0], score, rnd, hc_sys, hc_dat, arw_d), + f_root(x_init[1], score, rnd, hc_sys, hc_dat, arw_d), ] xtol = 1.0e-16 rtol = 0.00 @@ -301,16 +141,16 @@ def f_root( dblk = 0.0 stry = 0.0 - if abs(f[1]) <= f[0]: - xcur = x[1] - xpre = x[0] - fcur = f[1] - fpre = f[0] + if abs(f_init[1]) <= f_init[0]: + xcur = x_init[1] + xpre = x_init[0] + fcur = f_init[1] + fpre = f_init[0] else: - xpre = x[1] - xcur = x[0] - fpre = f[1] - fcur = f[0] + xpre = x_init[1] + xcur = x_init[0] + fpre = f_init[1] + fcur = f_init[0] for _ in range(25): if (fpre != 0.0) and (fcur != 0.0) and (np.sign(fpre) != np.sign(fcur)): @@ -333,7 +173,7 @@ def f_root( sbis = (xblk - xcur) / 2.0 if (fcur == 0.0) or (abs(sbis) < delta): - hc = xcur + handicap = xcur break if (abs(spre) > delta) and (abs(fcur) < abs(fpre)): @@ -367,17 +207,17 @@ def f_root( xcur -= delta fcur = f_root(xcur, score, rnd, hc_sys, hc_dat, arw_d) - hc = xcur + handicap = xcur # Force integer precision if required. if int_prec: if hc_sys in ("AA", "AA2"): - hc = np.floor(hc) + handicap = np.floor(handicap) else: - hc = np.ceil(hc) + handicap = np.ceil(handicap) - sc, _ = hc_eq.score_for_round( - rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=True + sc_int, _ = hc_eq.score_for_round( + rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=True ) # Check that you can't get the same score from a larger handicap when @@ -388,12 +228,267 @@ def f_root( else: hstep = 1.0 while not min_h_flag: - hc += hstep - sc, _ = hc_eq.score_for_round( - rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=True + handicap += hstep + sc_int, _ = hc_eq.score_for_round( + rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=True ) - if sc < score: - hc -= hstep # undo the iteration that caused the flag to raise + if sc_int < score: + handicap -= hstep # undo the iteration that caused flag to raise min_h_flag = True - return hc + return handicap + + +def f_root( + hc_est: float, + score_est: Union[int, float], + round_est: rounds.Round, + sys: str, + hc_data: hc_eq.HcParams, + arw_dia: Optional[float], +) -> float: + """ + Return error between predicted score and desired score. + + Parameters + ---------- + hc_est : float + current estimate of handicap + score_est : float + current estimate of score based on hc_est + round_est : rounds.Round + round being used + sys : str + identifier for the handicap system + hc_data : handicaps.handicap_equations.HcParams + dataclass containing parameters for handicap equations + arw_dia : float + arrow diameter in [metres] default = None + + Returns + ------- + val-score_est : float + difference between desired value and score estimate + """ + # One too many arguments. Makes sense at the moment => disable + # Could try and simplify hc_sys and hc_dat in future refactor + # pylint: disable=too-many-arguments + + val, _ = hc_eq.score_for_round( + round_est, hc_est, sys, hc_data, arw_dia, round_score_up=False + ) + + # Ensure we return float, not np.ndarray + # These 8 lines replace `return val-scr` so as to satisfy mypy --strict. + # Should never be triggered in reality as h is type float. + if isinstance(val, np.float_): + val = val.item() + if isinstance(val, float): + return val - score_est + raise TypeError( + f"f_root is attempting to return a {type(val)} type but expected float. " + f"Was it passed an array of handicaps?" + ) + + +def print_handicap_table( + hcs: Union[float, npt.NDArray[np.float_]], + hc_sys: str, + round_list: List[rounds.Round], + hc_dat: hc_eq.HcParams, + arrow_d: Optional[float] = None, + round_scores_up: bool = True, + clean_gaps: bool = True, + printout: bool = True, + filename: Optional[str] = None, + csvfile: Optional[str] = None, + int_prec: Optional[bool] = False, +) -> None: + """ + Generate a handicap table to screen and/or file. + + Parameters + ---------- + hcs : ndarray or float + handicap value(s) to calculate score(s) for + hc_sys : string + identifier for the handicap system + round_list : list of rounds.Round + List of Round classes to calculate scores for + hc_dat : handicaps.handicap_equations.HcParams + dataclass containing parameters for handicap equations + arrow_d : float + arrow diameter in [metres] default = None + round_scores_up : bool + round scores up to nearest integer? default = True + clean_gaps : bool + Remove all instances of a score except the first? default = False + printout : bool + Print to screen? default = True + filename : str + filepath to save table to. default = None + csvfile : str + csv filepath to save to. default = None + int_prec : bool + display results as integers? default = False, with decimal to 2dp + + Returns + ------- + None + """ + # Cannot see any other way to handle the options required here => ignore + # pylint: disable=too-many-arguments + # Knock-on effect is too many local variables raised => ignore + # pylint: disable=too-many-locals + + if not isinstance(hcs, np.ndarray): + if isinstance(hcs, list): + hcs = np.array(hcs) + elif isinstance(hcs, (float, int)): + hcs = np.array([hcs]) + else: + raise TypeError("Expected float or ndarray for hcs.") + + table: npt.NDArray[Union[np.float_, np.int_]] = np.empty( + [len(hcs), len(round_list) + 1] + ) + table[:, 0] = hcs.copy() + for i, round_i in enumerate(round_list): + table[:, i + 1], _ = hc_eq.score_for_round( + round_i, + hcs, + hc_sys, + hc_dat, + arrow_d, + round_score_up=round_scores_up, + ) + + # If rounding scores up we don't want to display trailing zeros, so ensure int_prec + if round_scores_up: + int_prec = True + + if int_prec: + table = table.astype(int) + + if clean_gaps: + table = clean_repeated(table, int_prec) + + # Write to CSV + if csvfile is not None: + print("Writing handicap table to csv...", end="") + np.savetxt( + csvfile, + table, + delimiter=", ", + header=f"handicap, {','.join([round_i.name for round_i in round_list])}'", + ) + print("Done.") + + # Write to terminal/string + # Return early if this isn't required + if filename is None and not printout: + return + + # To ensure both terminal and file output are the same, create a single string to + # be used in either case + + round_names = [abbreviate(r.name) for r in round_list] + output_header = "".join(name.rjust(14) for name in chain(["Handicap"], round_names)) + + output_rows = [format_row(row, int_prec) for row in table] + output_str = "\n".join(chain([output_header], output_rows)) + + if printout: + print(output_str) + + if filename is not None: + print("Writing handicap table to file...", end="") + with open(filename, "w", encoding="utf-8") as table_file: + table_file.write(output_str) + print("Done.") + + +def clean_repeated( + table: npt.NDArray[Union[np.float_, np.int_]], + int_prec: Optional[bool] = False, +) -> npt.NDArray[Union[np.float_, np.int_]]: + """ + Keep only the first instance of a score in the handicap tables. + + Parameters + ---------- + table : np.ndarray + handicap table of scores + int_prec : bool + return integers, not floats? + + Returns + ------- + table : np.ndarray + handicap table of scores with repetitions filtered + """ + # TODO: This assumes scores are running highest to lowest. + # AA and AA2 will only work if hcs passed in reverse order (large to small) + for irow, row in enumerate(table[:-1, :]): + for jscore in range(len(row)): + if table[irow, jscore] == table[irow + 1, jscore]: + if int_prec: + table[irow, jscore] = FILL + else: + table[irow, jscore] = np.nan + return table + + +def abbreviate(name: str) -> str: + """ + Replace headings within Handicap Tables with abbreviations to keep concise. + + Parameters + ---------- + name : str + full, long round name as appears currently + + Returns + ------- + shortname : str + abbreviated round name to replace with + """ + abbreviations = { + "Compound": "C", + "Recurve": "R", + "Triple": "Tr", + "Centre": "C", + "Portsmouth": "Ports", + "Worcester": "Worc", + "Short": "St", + "Long": "Lg", + "Small": "Sm", + "Gents": "G", + "Ladies": "L", + } + + return " ".join(abbreviations.get(i, i) for i in name.split()) + + +def format_row( + row: npt.NDArray[Union[np.float_, np.int_]], + int_prec: Optional[bool] = False, +) -> str: + """ + Fornat appearance of handicap table row to look nice. + + Parameters + ---------- + row : NDArray + numpy array of table row + int_prec : bool + return integers, not floats? + + Returns + ------- + formatted_row : str + pretty string based on input array data + """ + if int_prec: + return "".join("".rjust(14) if x == FILL else f"{x:14d}" for x in row) + return "".join("".rjust(14) if np.isnan(x) else f"{x:14.8f}" for x in row) From 0c05209c685f952ff600c38e833d24713b747c9a Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 15:55:57 +0100 Subject: [PATCH 41/56] Bump to python 3.9+. --- .github/workflows/testing.yaml | 8 ++++---- README.md | 2 +- pyproject.toml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 01d51e0..098bb34 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -28,10 +28,10 @@ jobs: include: - os: ubuntu-latest python-version: "3.9" - - os: ubuntu-latest - python-version: "3.8" - - os: ubuntu-latest - python-version: "3.7" + # - os: ubuntu-latest + # python-version: "3.8" + # - os: ubuntu-latest + # python-version: "3.7" steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index 8008d60..ffe4abe 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # archeryutils ![GitHub](https://img.shields.io/github/license/jatkinson1000/archeryutils) -[![Python 3.7+](https://img.shields.io/badge/python-3.7+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/jatkinson1000/archeryutils/testing.yaml) [![codecov](https://codecov.io/gh/jatkinson1000/archeryutils/branch/main/graph/badge.svg?token=AZU7G6H8T0)](https://codecov.io/gh/jatkinson1000/archeryutils) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) diff --git a/pyproject.toml b/pyproject.toml index 39ee47f..db19d1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ ] readme = "README.md" license = {file = "LICENSE"} -requires-python = ">=3.7" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", From 88763797d4e4bda3eb1e53d5fa04e9c22b649137 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 16:05:23 +0100 Subject: [PATCH 42/56] Fix IFAA min score error and add min_score tests. --- archeryutils/targets.py | 3 ++- archeryutils/tests/test_targets.py | 41 ++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/archeryutils/targets.py b/archeryutils/targets.py index b1e62c2..c469276 100644 --- a/archeryutils/targets.py +++ b/archeryutils/targets.py @@ -148,7 +148,6 @@ def min_score(self) -> float: "10_zone", "10_zone_compound", "WA_field", - "IFAA_field", "IFAA_field_expert", "Worcester", ): @@ -165,6 +164,8 @@ def min_score(self) -> float: return 6.0 if self.scoring_system in ("Worcester_2_ring",): return 4.0 + if self.scoring_system in ("IFAA_field",): + return 3.0 if self.scoring_system in ("Beiter_hit_miss"): # For Beiter options are hit and miss, so return 0 here return 0.0 diff --git a/archeryutils/tests/test_targets.py b/archeryutils/tests/test_targets.py index 9a580c5..3069b64 100644 --- a/archeryutils/tests/test_targets.py +++ b/archeryutils/tests/test_targets.py @@ -76,14 +76,14 @@ def test_yard_to_m_conversion(self) -> None: ) def test_max_score(self, face_type: str, max_score_expected: float) -> None: """ - Check that Target() returns correct distance in metres when yards provided. + Check that Target() returns correct max score. """ target = Target(1.22, face_type, 50, "metre", False) assert target.max_score() == max_score_expected def test_max_score_invalid_face_type(self) -> None: """ - Check that Target() returns correct distance in metres when yards provided. + Check that Target() raises error for invalid face. """ with pytest.raises( ValueError, @@ -93,3 +93,40 @@ def test_max_score_invalid_face_type(self) -> None: # Requires manual resetting of scoring system to get this error. target.scoring_system = "InvalidScoringSystem" target.max_score() + + @pytest.mark.parametrize( + "face_type,min_score_expected", + [ + ("5_zone", 1), + ("10_zone", 1), + ("10_zone_compound", 1), + ("10_zone_6_ring", 5), + ("10_zone_5_ring", 6), + ("10_zone_5_ring_compound", 6), + ("WA_field", 1), + ("IFAA_field", 3), + ("IFAA_field_expert", 1), + ("Worcester", 1), + ("Worcester_2_ring", 4), + ("Beiter_hit_miss", 0), + ], + ) + def test_min_score(self, face_type: str, min_score_expected: float) -> None: + """ + Check that Target() returns correct min score. + """ + target = Target(1.22, face_type, 50, "metre", False) + assert target.min_score() == min_score_expected + + def test_min_score_invalid_face_type(self) -> None: + """ + Check that Target() raises error for invalid face. + """ + with pytest.raises( + ValueError, + match="Target face '(.+)' has no specified minimum score.", + ): + target = Target(1.22, "5_zone", 50, "metre", False) + # Requires manual resetting of scoring system to get this error. + target.scoring_system = "InvalidScoringSystem" + target.min_score() From 68f7aaf265e7266aa0d179f5e427028f1a1e8e37 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 16:11:45 +0100 Subject: [PATCH 43/56] Add tests for non-indoor and -outdoor bowstyles. --- .../classifications/tests/test_agb_indoor.py | 34 +++++++++++++++ .../classifications/tests/test_agb_outdoor.py | 42 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/archeryutils/classifications/tests/test_agb_indoor.py b/archeryutils/classifications/tests/test_agb_indoor.py index 7a23371..644b084 100644 --- a/archeryutils/classifications/tests/test_agb_indoor.py +++ b/archeryutils/classifications/tests/test_agb_indoor.py @@ -166,6 +166,40 @@ def test_agb_indoor_classification_scores_bowstyles( assert scores == scores_expected[::-1] + @pytest.mark.parametrize( + "bowstyle,scores_expected", + [ + ( + "flatbow", + [331, 387, 433, 472, 503, 528, 549, 565], + ), + ( + "traditional", + [331, 387, 433, 472, 503, 528, 549, 565], + ), + ( + "asiatic", + [331, 387, 433, 472, 503, 528, 549, 565], + ), + ], + ) + def test_agb_indoor_classification_scores_nonbowstyles( + self, + bowstyle: str, + scores_expected: List[int], + ) -> None: + """ + Check that barebow scores returned for valid but non-indoor styles. + """ + scores = class_funcs.agb_indoor_classification_scores( + roundname="portsmouth", + bowstyle=bowstyle, + gender="male", + age_group="adult", + ) + + assert scores == scores_expected[::-1] + @pytest.mark.parametrize( "roundname,scores_expected", [ diff --git a/archeryutils/classifications/tests/test_agb_outdoor.py b/archeryutils/classifications/tests/test_agb_outdoor.py index 834f747..dfc8de9 100644 --- a/archeryutils/classifications/tests/test_agb_outdoor.py +++ b/archeryutils/classifications/tests/test_agb_outdoor.py @@ -207,6 +207,48 @@ def test_agb_outdoor_classification_scores_bowstyles( assert scores == scores_expected[::-1] + @pytest.mark.parametrize( + "roundname,bowstyle,gender,scores_expected", + [ + ( + "wa1440_90", + "flatbow", + "male", + [290, 380, 484, 598, 717, 835, 945, 1042, 1124], + ), + ( + "wa1440_70", + "traditional", + "female", + [252, 338, 441, 558, 682, 806, 921, 1023, 1108], + ), + ( + "wa1440_70", + "asiatic", + "female", + [252, 338, 441, 558, 682, 806, 921, 1023, 1108], + ), + ], + ) + def test_agb_outdoor_classification_scores_nonbowstyles( + self, + roundname: str, + bowstyle: str, + gender: str, + scores_expected: List[int], + ) -> None: + """ + Check that barebow scores returned for valid but non-outdoor bowstyles. + """ + scores = class_funcs.agb_outdoor_classification_scores( + roundname=roundname, + bowstyle=bowstyle, + gender=gender, + age_group="adult", + ) + + assert scores == scores_expected[::-1] + @pytest.mark.parametrize( "roundname,scores_expected", [ From 787951c25963f2d097fd5bea8683883411d79fa7 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 16:19:18 +0100 Subject: [PATCH 44/56] Apply black to satisy older version. --- archeryutils/classifications/agb_indoor_classifications.py | 1 - archeryutils/classifications/agb_outdoor_classifications.py | 1 - 2 files changed, 2 deletions(-) diff --git a/archeryutils/classifications/agb_indoor_classifications.py b/archeryutils/classifications/agb_indoor_classifications.py index a56d66d..8b88888 100644 --- a/archeryutils/classifications/agb_indoor_classifications.py +++ b/archeryutils/classifications/agb_indoor_classifications.py @@ -71,7 +71,6 @@ def _make_agb_indoor_classification_dict() -> Dict[str, Dict[str, Any]]: for bowstyle in agb_bowstyles: for gender in agb_genders: for age in agb_ages: - groupname = cls_funcs.get_groupname( bowstyle["bowstyle"], gender, age["age_group"] ) diff --git a/archeryutils/classifications/agb_outdoor_classifications.py b/archeryutils/classifications/agb_outdoor_classifications.py index abd879b..f547bd5 100644 --- a/archeryutils/classifications/agb_outdoor_classifications.py +++ b/archeryutils/classifications/agb_outdoor_classifications.py @@ -69,7 +69,6 @@ def _make_agb_outdoor_classification_dict() -> Dict[str, Dict[str, Any]]: for bowstyle in agb_bowstyles: for gender in agb_genders: for age in agb_ages: - groupname = cls_funcs.get_groupname( bowstyle["bowstyle"], gender, age["age_group"] ) From 81f1aeccf57119bdd552eda84f29ee7b2a2097d5 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 16:54:10 +0100 Subject: [PATCH 45/56] linting and refactoring of handicap files. --- archeryutils/handicaps/handicap_functions.py | 221 +++++++++++++------ 1 file changed, 154 insertions(+), 67 deletions(-) diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index fed4d5b..4cab2b8 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -60,14 +60,8 @@ def handicap_from_score( ------- hc: int or float Handicap. Has type int if int_prec is True, and otherwise has type false. - - References - ---------- - Brent's Method for Root Finding in Scipy - - https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.brentq.html - - https://github.com/scipy/scipy/blob/dde39b7cc7dc231cec6bf5d882c8a8b5f40e73ad/ - scipy/optimize/Zeros/brentq.c """ + # Check we have a valid score max_score = rnd.max_score() if score > max_score: raise ValueError( @@ -82,46 +76,155 @@ def handicap_from_score( if score == max_score: # Deal with max score before root finding - # start high and drop down until no longer rounding to max score - # (i.e. >= max_score - 1.0 for AGB, and >= max_score - 0.5 for AA, AA2, and AGBold) + return get_max_score_handicap(rnd, hc_sys, hc_dat, arw_d, int_prec) + + handicap = rootfind_score_handicap(score, rnd, hc_sys, hc_dat, arw_d) + + # Force integer precision if required. + if int_prec: if hc_sys in ("AA", "AA2"): - handicap = 175.0 - delta_hc = -0.01 + handicap = np.floor(handicap) else: - handicap = -75.0 - delta_hc = 0.01 + handicap = np.ceil(handicap) - # Set rounding limit - if hc_sys in ("AA", "AA2", "AGBold"): - round_lim = 0.5 + sc_int, _ = hc_eq.score_for_round( + rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=True + ) + + # Check that you can't get the same score from a larger handicap when + # working in integers + min_h_flag = False + if hc_sys in ("AA", "AA2"): + hstep = -1.0 else: - round_lim = 1.0 + hstep = 1.0 + while not min_h_flag: + handicap += hstep + sc_int, _ = hc_eq.score_for_round( + rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=True + ) + if sc_int < score: + handicap -= hstep # undo the iteration that caused flag to raise + min_h_flag = True + return handicap + + +def get_max_score_handicap( + rnd: rounds.Round, + hc_sys: str, + hc_dat: hc_eq.HcParams, + arw_d: Optional[float], + int_prec: bool = False, +) -> float: + """ + Get handicap for maximum score on a round. + + Start high and drop down until no longer rounding to max score. + i.e. >= max_score - 1.0 for AGB, and >= max_score - 0.5 for AA, AA2, and AGBold. + + Parameters + ---------- + rnd : rounds.Round + round being used + hc_sys : str + identifier for the handicap system + hc_dat : handicaps.handicap_equations.HcParams + dataclass containing parameters for handicap equations + arw_d : float + arrow diameter in [metres] default = None + int_prec : bool + display results as integers? default = False + + Returns + ------- + handicap : float + appropriate handicap for this maximum score + """ + + max_score = rnd.max_score() + + if hc_sys in ("AA", "AA2"): + handicap = 175.0 + delta_hc = -0.01 + else: + handicap = -75.0 + delta_hc = 0.01 + + # Set rounding limit + if hc_sys in ("AA", "AA2", "AGBold"): + round_lim = 0.5 + else: + round_lim = 1.0 + + s_max, _ = hc_eq.score_for_round( + rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=False + ) + # Work down to where we would round or ceil to max score + while s_max > max_score - round_lim: + handicap = handicap + delta_hc s_max, _ = hc_eq.score_for_round( rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=False ) - # Work down to where we would round or ceil to max score - while s_max > max_score - round_lim: - handicap = handicap + delta_hc - s_max, _ = hc_eq.score_for_round( - rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=False - ) - handicap = handicap - delta_hc # Undo final iteration that overshoots - if int_prec: - if hc_sys in ("AA", "AA2"): - handicap = np.ceil(handicap) - else: - handicap = np.floor(handicap) + handicap = handicap - delta_hc # Undo final iteration that overshoots + if int_prec: + if hc_sys in ("AA", "AA2"): + handicap = np.ceil(handicap) else: - warnings.warn( - "Handicap requested for maximum score without integer precision.\n" - "Value returned will be first handiucap that achieves this score.\n" - "This could cause issues if you are not working in integers.", - UserWarning, - ) - return handicap + handicap = np.floor(handicap) + else: + warnings.warn( + "Handicap requested for maximum score without integer precision.\n" + "Value returned will be first handiucap that achieves this score.\n" + "This could cause issues if you are not working in integers.", + UserWarning, + ) + return handicap + + +def rootfind_score_handicap( + score: float, + rnd: rounds.Round, + hc_sys: str, + hc_dat: hc_eq.HcParams, + arw_d: Optional[float], +) -> float: + """ + Get handicap for general score on a round through rootfinding algorithm. + + Parameters + ---------- + score : float + score to get handicap for + rnd : rounds.Round + round being used + hc_sys : str + identifier for the handicap system + hc_dat : handicaps.handicap_equations.HcParams + dataclass containing parameters for handicap equations + arw_d : float + arrow diameter in [metres] default = None + + Returns + ------- + handicap : float + appropriate accurate handicap for this score + + References + ---------- + Brent's Method for Root Finding in Scipy + - https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.brentq.html + - https://github.com/scipy/scipy/blob/dde39b7cc7dc231cec6bf5d882c8a8b5f40e73ad/ + scipy/optimize/Zeros/brentq.c + + """ + # The rootfinding algorithm here raises pylint errors for + # too many statements (64/50), branches (17/12), and variables(23/15). + # However, it is a single enclosed algorithm => disable + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements - # ROOT FINDING for general case (not max score) if hc_sys in ("AA", "AA2"): x_init = [-250.0, 175.0] else: @@ -209,33 +312,6 @@ def handicap_from_score( fcur = f_root(xcur, score, rnd, hc_sys, hc_dat, arw_d) handicap = xcur - # Force integer precision if required. - if int_prec: - if hc_sys in ("AA", "AA2"): - handicap = np.floor(handicap) - else: - handicap = np.ceil(handicap) - - sc_int, _ = hc_eq.score_for_round( - rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=True - ) - - # Check that you can't get the same score from a larger handicap when - # working in integers - min_h_flag = False - if hc_sys in ("AA", "AA2"): - hstep = -1.0 - else: - hstep = 1.0 - while not min_h_flag: - handicap += hstep - sc_int, _ = hc_eq.score_for_round( - rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=True - ) - if sc_int < score: - handicap -= hstep # undo the iteration that caused flag to raise - min_h_flag = True - return handicap @@ -371,7 +447,7 @@ def print_handicap_table( table = table.astype(int) if clean_gaps: - table = clean_repeated(table, int_prec) + table = clean_repeated(table, int_prec, hc_sys) # Write to CSV if csvfile is not None: @@ -411,6 +487,7 @@ def print_handicap_table( def clean_repeated( table: npt.NDArray[Union[np.float_, np.int_]], int_prec: Optional[bool] = False, + hc_sys: Optional[str] = "AGB", ) -> npt.NDArray[Union[np.float_, np.int_]]: """ Keep only the first instance of a score in the handicap tables. @@ -421,14 +498,20 @@ def clean_repeated( handicap table of scores int_prec : bool return integers, not floats? + hc_sys : str + handicap system used - assume AGB (high -> low) unless specified Returns ------- table : np.ndarray handicap table of scores with repetitions filtered """ - # TODO: This assumes scores are running highest to lowest. - # AA and AA2 will only work if hcs passed in reverse order (large to small) + # NB: This assumes scores are running highest to lowest. + # :. Flip AA and AA2 tables before operating. + + if hc_sys in ("AA", "AA2"): + table = np.flip(table, axis=1) + for irow, row in enumerate(table[:-1, :]): for jscore in range(len(row)): if table[irow, jscore] == table[irow + 1, jscore]: @@ -436,6 +519,10 @@ def clean_repeated( table[irow, jscore] = FILL else: table[irow, jscore] = np.nan + + if hc_sys in ("AA", "AA2"): + table = np.flip(table, axis=1) + return table From d0153566bf363fad55f9d043af20d8089d3f888a Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 16:56:28 +0100 Subject: [PATCH 46/56] Enforce pydocstyle. --- archeryutils/handicaps/handicap_functions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index 4cab2b8..4e898f2 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -141,7 +141,6 @@ def get_max_score_handicap( handicap : float appropriate handicap for this maximum score """ - max_score = rnd.max_score() if hc_sys in ("AA", "AA2"): From 6d830952a04c5699da18284c38862a2e679a9575 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 17:04:55 +0100 Subject: [PATCH 47/56] Disable duplicate code warning for classifications and tests due to similar structure. --- archeryutils/classifications/agb_field_classifications.py | 4 ++++ archeryutils/classifications/agb_indoor_classifications.py | 4 ++++ .../classifications/agb_old_indoor_classifications.py | 4 ++++ archeryutils/classifications/agb_outdoor_classifications.py | 4 ++++ archeryutils/classifications/tests/test_agb_field.py | 4 ++++ archeryutils/classifications/tests/test_agb_indoor.py | 4 ++++ archeryutils/classifications/tests/test_agb_old_indoor.py | 4 ++++ archeryutils/classifications/tests/test_agb_outdoor.py | 4 ++++ 8 files changed, 32 insertions(+) diff --git a/archeryutils/classifications/agb_field_classifications.py b/archeryutils/classifications/agb_field_classifications.py index ebe2a98..b0093f2 100644 --- a/archeryutils/classifications/agb_field_classifications.py +++ b/archeryutils/classifications/agb_field_classifications.py @@ -13,6 +13,10 @@ agb_field_classification_scores """ +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + import re from typing import List, Dict, Any import numpy as np diff --git a/archeryutils/classifications/agb_indoor_classifications.py b/archeryutils/classifications/agb_indoor_classifications.py index 8b88888..127373e 100644 --- a/archeryutils/classifications/agb_indoor_classifications.py +++ b/archeryutils/classifications/agb_indoor_classifications.py @@ -7,6 +7,10 @@ calculate_agb_indoor_classification agb_indoor_classification_scores """ +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List, Dict, Any import numpy as np diff --git a/archeryutils/classifications/agb_old_indoor_classifications.py b/archeryutils/classifications/agb_old_indoor_classifications.py index d31dcbf..ca1dcf7 100644 --- a/archeryutils/classifications/agb_old_indoor_classifications.py +++ b/archeryutils/classifications/agb_old_indoor_classifications.py @@ -7,6 +7,10 @@ calculate_AGB_old_indoor_classification AGB_old_indoor_classification_scores """ +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List, Dict, Any import numpy as np diff --git a/archeryutils/classifications/agb_outdoor_classifications.py b/archeryutils/classifications/agb_outdoor_classifications.py index f547bd5..18a4108 100644 --- a/archeryutils/classifications/agb_outdoor_classifications.py +++ b/archeryutils/classifications/agb_outdoor_classifications.py @@ -7,6 +7,10 @@ calculate_agb_outdoor_classification agb_outdoor_classification_scores """ +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List, Dict, Any from collections import OrderedDict import numpy as np diff --git a/archeryutils/classifications/tests/test_agb_field.py b/archeryutils/classifications/tests/test_agb_field.py index 42560fb..9f6d110 100644 --- a/archeryutils/classifications/tests/test_agb_field.py +++ b/archeryutils/classifications/tests/test_agb_field.py @@ -1,4 +1,8 @@ """Tests for agb field classification functions""" +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List import pytest diff --git a/archeryutils/classifications/tests/test_agb_indoor.py b/archeryutils/classifications/tests/test_agb_indoor.py index 644b084..a6a0466 100644 --- a/archeryutils/classifications/tests/test_agb_indoor.py +++ b/archeryutils/classifications/tests/test_agb_indoor.py @@ -1,4 +1,8 @@ """Tests for agb indoor classification functions""" +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List import pytest diff --git a/archeryutils/classifications/tests/test_agb_old_indoor.py b/archeryutils/classifications/tests/test_agb_old_indoor.py index e1fcc78..646f2e7 100644 --- a/archeryutils/classifications/tests/test_agb_old_indoor.py +++ b/archeryutils/classifications/tests/test_agb_old_indoor.py @@ -1,4 +1,8 @@ """Tests for old agb indoor classification functions""" +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List import pytest diff --git a/archeryutils/classifications/tests/test_agb_outdoor.py b/archeryutils/classifications/tests/test_agb_outdoor.py index dfc8de9..1bf3fb1 100644 --- a/archeryutils/classifications/tests/test_agb_outdoor.py +++ b/archeryutils/classifications/tests/test_agb_outdoor.py @@ -1,4 +1,8 @@ """Tests for agb indoor classification functions""" +# Due to structure of similar classification schemes they may trigger duplicate code. +# => disable for classification files and tests +# pylint: disable=duplicate-code + from typing import List import pytest From 85ab8d9c9c94a7c60a4607e937de159699ac0dac Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 17:20:39 +0100 Subject: [PATCH 48/56] Bugfix for clearing gaps in AA tables. --- archeryutils/handicaps/handicap_functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index 4e898f2..824fc43 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -509,7 +509,7 @@ def clean_repeated( # :. Flip AA and AA2 tables before operating. if hc_sys in ("AA", "AA2"): - table = np.flip(table, axis=1) + table = np.flip(table, axis=0) for irow, row in enumerate(table[:-1, :]): for jscore in range(len(row)): @@ -520,7 +520,7 @@ def clean_repeated( table[irow, jscore] = np.nan if hc_sys in ("AA", "AA2"): - table = np.flip(table, axis=1) + table = np.flip(table, axis=0) return table From 5590291bbdce75bc8684108f9bf9ad04879366cd Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 20:23:28 +0100 Subject: [PATCH 49/56] Add tests for abstracted handicap functions and table functions. --- archeryutils/handicaps/handicap_functions.py | 4 +- .../handicaps/tests/test_handicaps.py | 145 +++++++++++++++++- 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index 824fc43..1416e65 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -529,6 +529,8 @@ def abbreviate(name: str) -> str: """ Replace headings within Handicap Tables with abbreviations to keep concise. + NB: This function only works with names containing space-separated words. + Parameters ---------- name : str @@ -576,5 +578,5 @@ def format_row( pretty string based on input array data """ if int_prec: - return "".join("".rjust(14) if x == FILL else f"{x:14d}" for x in row) + return "".join("".rjust(14) if x == FILL else f"{int(x):14d}" for x in row) return "".join("".rjust(14) if np.isnan(x) else f"{x:14.8f}" for x in row) diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py index 9408ca0..ab004e6 100644 --- a/archeryutils/handicaps/tests/test_handicaps.py +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -1,6 +1,7 @@ """Tests for handicap equations and functions""" -from typing import Tuple, List +from typing import Tuple, List, Union import numpy as np +from numpy.typing import NDArray import pytest import archeryutils.handicaps.handicap_equations as hc_eq @@ -509,6 +510,41 @@ class TestHandicapFromScore: Currently no easily available data """ + @pytest.mark.parametrize( + "testround,hc_system,int_prec,handicap_expected", + [ + (metric122_30, "AGB", True, 11), + (metric122_30, "AA", True, 107), + # (metric122_30, "AA2", True, 107), + # ------------------------------ + (western, "AGB", False, 9.89), + (western, "AGBold", True, 6), + # ------------------------------ + (vegas300, "AGB", True, 3), + (vegas300, "AA", False, 118.38), + # (vegas300, "AA2", True, 119), + ], + ) + def test_get_max_score_handicap( + self, + testround: Round, + hc_system: str, + int_prec: bool, + handicap_expected: float, + ) -> None: + """ + Check that get_max_score_handicap() returns expected handicap. + """ + handicap = hc_func.get_max_score_handicap( + testround, + hc_system, + hc_params, + None, + int_prec, + ) + + assert pytest.approx(handicap) == handicap_expected + def test_score_over_max(self) -> None: """ Check that handicap_from_score() returns error value for too large score. @@ -714,3 +750,110 @@ def test_decimal( ) assert handicap == pytest.approx(handicap_expected) + + +class TestHandicapTable: + """ + Class to test the handicap table functionalities of handicap_functions. + + Methods + ------- + + References + ---------- + """ + + @pytest.mark.parametrize( + "input_arr,int_prec,expected", + [ + ( + np.array([1, 20.0, 23.0]), + False, + " 1.00000000 20.00000000 23.00000000", + ), + ( + np.array([1, 20.0, 23.0]), + True, + " 1 20 23", + ), + ], + ) + def test_format_row( + self, + input_arr: NDArray[Union[np.int_, np.float_]], + int_prec: bool, + expected: str, + ) -> None: + """ + Check that format_row returns expected results for float and int. + """ + assert hc_func.format_row(input_arr, int_prec) == expected + + @pytest.mark.parametrize( + "input_str,expected", + [ + ("Compound", "C"), + ("Recurve Triple Portsmouth", "R Tr Ports"), + ("Recurve Triple Portsmouth", "R Tr Ports"), + ("Short Gents Worcester", "St G Worc"), + ], + ) + def test_abbreviate( + self, + input_str: str, + expected: str, + ) -> None: + """ + Check that abbreviate returns expected results. + """ + assert hc_func.abbreviate(input_str) == expected + + @pytest.mark.parametrize( + "input_table,int_prec,sys,expected", + [ + ( + np.array([[0, 11, 12, 13], [1, 10, 12, 12]]), + True, + "AGB", + np.array([[0, 11, -9999, 13], [1, 10, 12, 12]]), + ), + ( + np.array([[0, 13], [5, 12], [10, 12], [15, 11]]), + True, + "AGB", + np.array([[0, 13], [5, -9999], [10, 12], [15, 11]]), + ), + ( + np.array([[4, 13], [3, 12], [2, 12], [1, 11]]), + True, + "AA", + np.array([[4, 13], [3, 12], [2, -9999], [1, 11]]), + ), + ( + np.array([[0.0, 11.0, 12.0, 13.0], [1.0, 10.0, 12.0, 12.0]]), + False, + "AGB", + np.array([[0.0, 11.0, np.nan, 13.0], [1.0, 10.0, 12.0, 12.0]]), + ), + ( + np.array([[0.0, 11.5, 12.5, 13.5], [1.0, 11.5, 12.0, 13.5]]), + False, + "AGB", + np.array([[0.0, np.nan, 12.5, np.nan], [1.0, 11.5, 12.0, 13.5]]), + ), + ], + ) + def test_clean_repeated( + self, + input_table: NDArray[Union[np.int_, np.float_]], + int_prec: bool, + sys: str, + expected: NDArray[Union[np.int_, np.float_]], + ) -> None: + """ + Check that abbreviate returns expected results. + """ + print(hc_func.clean_repeated(input_table, int_prec, sys)) + np.testing.assert_allclose( + hc_func.clean_repeated(input_table, int_prec, sys), expected + ) From 82b7fe0fd0ad4f25ae9797efb9eec20fda2ec483 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 22:25:39 +0100 Subject: [PATCH 50/56] Use mocker to test opening HcParams json file. --- archeryutils/handicaps/handicap_equations.py | 4 +- .../handicaps/tests/test_handicaps.py | 59 +++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py index dac5fa9..ee743ed 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -206,10 +206,10 @@ def load_json_params(cls, jsonpath: str) -> "HcParams": json_hc_params.aa2_hc_data["AA2_f1"] = paramsdict["AA2_f1"] json_hc_params.aa2_hc_data["AA2_f2"] = paramsdict["AA2_f2"] json_hc_params.aa2_hc_data["AA2_d0"] = paramsdict["AA2_d0"] - json_hc_params.arw_d_data["arrow_diameter_indoors"] = paramsdict[ + json_hc_params.arw_d_data["arw_d_in"] = paramsdict[ "arrow_diameter_indoors" ] - json_hc_params.arw_d_data["arrow_diameter_outdoors"] = paramsdict[ + json_hc_params.arw_d_data["arw_d_out"] = paramsdict[ "arrow_diameter_outdoors" ] json_hc_params.arw_d_data["AGBo_arw_d"] = paramsdict["AGBo_arw_d"] diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py index ab004e6..4d099f2 100644 --- a/archeryutils/handicaps/tests/test_handicaps.py +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -76,6 +76,65 @@ ) +@pytest.fixture +def mocker_HcParams_json(mocker): + """ + Override open with a fake HCParams json file. + """ + mocked_json_file = mocker.mock_open( + read_data="""{ + "AGB_datum": 1.0, + "AGB_step": 1.0, + "AGB_ang_0": 1.0, + "AGB_kd": 1.0, + + "AGBo_datum": 1.0, + "AGBo_step": 1.0, + "AGBo_ang_0": 1.0, + "AGBo_k1": 1.0, + "AGBo_k2": 1.0, + "AGBo_k3": 1.0, + "AGBo_p1": 1.0, + "AGBo_arw_d": 3.0, + + "AA_k0": 2.0, + "AA_ks": 2.0, + "AA_kd": 2.0, + + "AA2_k0": 2.0, + "AA2_ks": 2.0, + "AA2_f1": 2.0, + "AA2_f2": 2.0, + "AA2_d0": 2.0, + + "AA_arw_d_out": 3.0, + "arrow_diameter_indoors": 3.0, + "arrow_diameter_outdoors": 3.0 + }""" + ) + mocker.patch("builtins.open", mocked_json_file) + + +class TestHcParams: + def test_load_json_params(self, mocker_HcParams_json) -> None: + """ + Check that sigma_t() returns error value for invalid system. + """ + handicap_params = hc_eq.HcParams() + handicap_params = handicap_params.load_json_params("fakefile.json") + + for val in handicap_params.agb_hc_data.values(): + assert val == 1.0 + for val in handicap_params.agb_old_hc_data.values(): + assert val == 1.0 + for val in handicap_params.aa_hc_data.values(): + assert val == 2.0 + for val in handicap_params.aa2_hc_data.values(): + assert val == 2.0 + for val in handicap_params.arw_d_data.values(): + assert val == 3.0 + + class TestSigmaT: """ Class to test the sigma_t() function of handicap_equations. diff --git a/pyproject.toml b/pyproject.toml index db19d1a..cedcbd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ lint = [ "mypy>=1.0.0", "coverage", "pytest>=7.2.0", + "pytest-mock", ] [project.urls] From 6758e859324f5cbb7fbabe29e941d68d1850163f Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 22:31:03 +0100 Subject: [PATCH 51/56] bug fix in pyproject.toml add pytest-mock in correct dep set. --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cedcbd3..4527214 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,14 +27,13 @@ dependencies = [ [project.optional-dependencies] test = [ "pytest>=7.2.0", + "pytest-mock", ] lint = [ "black>=22.12.0", "pylint", "mypy>=1.0.0", "coverage", - "pytest>=7.2.0", - "pytest-mock", ] [project.urls] From be3e6b2ee15729580064d7bf27a99ce1d94012b2 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 23:00:47 +0100 Subject: [PATCH 52/56] Update handicap mocking to satisfy mypy and ignore pylint. --- archeryutils/handicaps/handicap_equations.py | 8 +--- .../handicaps/tests/test_handicaps.py | 41 ++++++++++--------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py index ee743ed..1e23015 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -206,12 +206,8 @@ def load_json_params(cls, jsonpath: str) -> "HcParams": json_hc_params.aa2_hc_data["AA2_f1"] = paramsdict["AA2_f1"] json_hc_params.aa2_hc_data["AA2_f2"] = paramsdict["AA2_f2"] json_hc_params.aa2_hc_data["AA2_d0"] = paramsdict["AA2_d0"] - json_hc_params.arw_d_data["arw_d_in"] = paramsdict[ - "arrow_diameter_indoors" - ] - json_hc_params.arw_d_data["arw_d_out"] = paramsdict[ - "arrow_diameter_outdoors" - ] + json_hc_params.arw_d_data["arw_d_in"] = paramsdict["arrow_diameter_indoors"] + json_hc_params.arw_d_data["arw_d_out"] = paramsdict["arrow_diameter_outdoors"] json_hc_params.arw_d_data["AGBo_arw_d"] = paramsdict["AGBo_arw_d"] json_hc_params.arw_d_data["AA_arw_d_out"] = paramsdict["AA_arw_d_out"] diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py index 4d099f2..a03f70e 100644 --- a/archeryutils/handicaps/tests/test_handicaps.py +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -3,6 +3,7 @@ import numpy as np from numpy.typing import NDArray import pytest +from pytest_mock import MockerFixture import archeryutils.handicaps.handicap_equations as hc_eq import archeryutils.handicaps.handicap_functions as hc_func @@ -77,7 +78,7 @@ @pytest.fixture -def mocker_HcParams_json(mocker): +def mocker_hcparams_json(mocker: MockerFixture) -> None: """ Override open with a fake HCParams json file. """ @@ -115,24 +116,26 @@ def mocker_HcParams_json(mocker): mocker.patch("builtins.open", mocked_json_file) -class TestHcParams: - def test_load_json_params(self, mocker_HcParams_json) -> None: - """ - Check that sigma_t() returns error value for invalid system. - """ - handicap_params = hc_eq.HcParams() - handicap_params = handicap_params.load_json_params("fakefile.json") - - for val in handicap_params.agb_hc_data.values(): - assert val == 1.0 - for val in handicap_params.agb_old_hc_data.values(): - assert val == 1.0 - for val in handicap_params.aa_hc_data.values(): - assert val == 2.0 - for val in handicap_params.aa2_hc_data.values(): - assert val == 2.0 - for val in handicap_params.arw_d_data.values(): - assert val == 3.0 +def test_load_json_hcparams(mocker_hcparams_json: MockerFixture) -> None: + """ + Test loading of HcParams from file using mock. + """ + # pylint cannot understand mocker as variable name used from fixture => disable + # pylint: disable=redefined-outer-name + # pylint: disable=unused-argument + handicap_params = hc_eq.HcParams() + handicap_params = handicap_params.load_json_params("fakefile.json") + + for val in handicap_params.agb_hc_data.values(): + assert val == 1.0 + for val in handicap_params.agb_old_hc_data.values(): + assert val == 1.0 + for val in handicap_params.aa_hc_data.values(): + assert val == 2.0 + for val in handicap_params.aa2_hc_data.values(): + assert val == 2.0 + for val in handicap_params.arw_d_data.values(): + assert val == 3.0 class TestSigmaT: From 4a75e5ca23f35727a76a71ec247ca8097101af68 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 23:02:59 +0100 Subject: [PATCH 53/56] Add pytest and mock to linting requirements. --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 4527214..827e8a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ lint = [ "pylint", "mypy>=1.0.0", "coverage", + "pytest>=7.2.0", + "pytest-mock", ] [project.urls] From 32f737048923c892ce1fc3dffe7779e24368912f Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sat, 2 Sep 2023 23:14:33 +0100 Subject: [PATCH 54/56] Update coverage to use pytest-mock. --- .github/workflows/coverage.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index f167569..fc9a24d 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -21,7 +21,7 @@ jobs: - uses: actions/setup-python@v4 with: python-version: "3.11" - - run: pip install pytest coverage + - run: pip install pytest pytest-mock coverage pip install . # annotate each step with `if: always` to run all regardless From 2d81c8f321e12cf0c59c6915562a867c67ec324f Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sun, 3 Sep 2023 11:54:03 +0100 Subject: [PATCH 55/56] Refactor and improve handicap printing routines to generate better output. --- archeryutils/handicaps/handicap_functions.py | 177 +++++++++++++++---- 1 file changed, 144 insertions(+), 33 deletions(-) diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index 1416e65..98da3f6 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -17,8 +17,9 @@ from typing import Union, Optional, List import warnings from itertools import chain +import decimal import numpy as np -import numpy.typing as npt +from numpy.typing import NDArray import archeryutils.handicaps.handicap_equations as hc_eq from archeryutils import rounds @@ -367,14 +368,14 @@ def f_root( def print_handicap_table( - hcs: Union[float, npt.NDArray[np.float_]], + hcs: Union[float, NDArray[np.float_]], hc_sys: str, round_list: List[rounds.Round], hc_dat: hc_eq.HcParams, arrow_d: Optional[float] = None, round_scores_up: bool = True, - clean_gaps: bool = True, - printout: bool = True, + clean_gaps: Optional[bool] = True, + printout: Optional[bool] = True, filename: Optional[str] = None, csvfile: Optional[str] = None, int_prec: Optional[bool] = False, @@ -414,17 +415,12 @@ def print_handicap_table( # Cannot see any other way to handle the options required here => ignore # pylint: disable=too-many-arguments # Knock-on effect is too many local variables raised => ignore - # pylint: disable=too-many-locals - if not isinstance(hcs, np.ndarray): - if isinstance(hcs, list): - hcs = np.array(hcs) - elif isinstance(hcs, (float, int)): - hcs = np.array([hcs]) - else: - raise TypeError("Expected float or ndarray for hcs.") + # Sanitise inputs + hcs = check_print_table_inputs(hcs, round_list, clean_gaps) - table: npt.NDArray[Union[np.float_, np.int_]] = np.empty( + # Set up empty handicap table and populate + table: NDArray[Union[np.float_, np.int_]] = np.empty( [len(hcs), len(round_list) + 1] ) table[:, 0] = hcs.copy() @@ -439,14 +435,20 @@ def print_handicap_table( ) # If rounding scores up we don't want to display trailing zeros, so ensure int_prec - if round_scores_up: + if round_scores_up and not int_prec: + warnings.warn( + "Handicap Table incompatible options.\n" + "Requesting scores to be rounded up but without integer precision.\n" + "Setting integer precision (`int_prec`) as true.", + UserWarning, + ) int_prec = True if int_prec: - table = table.astype(int) + table[:, 1:] = table[:, 1:].astype(int) if clean_gaps: - table = clean_repeated(table, int_prec, hc_sys) + table = clean_repeated(table, int_prec, hc_sys)[1:-1, :] # Write to CSV if csvfile is not None: @@ -464,30 +466,64 @@ def print_handicap_table( if filename is None and not printout: return - # To ensure both terminal and file output are the same, create a single string to - # be used in either case - - round_names = [abbreviate(r.name) for r in round_list] - output_header = "".join(name.rjust(14) for name in chain(["Handicap"], round_names)) - - output_rows = [format_row(row, int_prec) for row in table] - output_str = "\n".join(chain([output_header], output_rows)) + # Generate string to output to file or display + output_str = table_as_str(round_list, hcs, table, int_prec) if printout: print(output_str) if filename is not None: - print("Writing handicap table to file...", end="") - with open(filename, "w", encoding="utf-8") as table_file: - table_file.write(output_str) - print("Done.") + table_to_file(filename, output_str) + + +def check_print_table_inputs( + hcs: Union[float, NDArray[np.float_]], + round_list: list[rounds.Round], + clean_gaps: Optional[bool] = True, +) -> NDArray[np.float_]: + """ + Sanitise and format inputs to handicap printing code. + + Parameters + ---------- + hcs : ndarray or float + handicap value(s) to calculate score(s) for + round_list : list of rounds.Round + List of Round classes to calculate scores for + clean_gaps : bool + Remove all instances of a score except the first? default = False + + Returns + ------- + hcs : ndarray + handicaps prepared for use in table printing routines + """ + if not isinstance(hcs, np.ndarray): + if isinstance(hcs, list): + hcs = np.array(hcs) + elif isinstance(hcs, (float, int)): + hcs = np.array([hcs]) + else: + raise TypeError("Expected float or ndarray for hcs.") + + if len(round_list) == 0: + raise ValueError("No rounds provided for handicap table.") + + # if cleaning gaps add row to top/bottom of table to catch out of range repeats + if clean_gaps: + delta_s = hcs[1] - hcs[0] if len(hcs) > 1 else 1.0 + delta_e = hcs[-1] - hcs[-2] if len(hcs) > 1 else 1.0 + hcs = np.insert(hcs, 0, hcs[0] - delta_s) + hcs = np.append(hcs, hcs[-1] + delta_e) + + return hcs def clean_repeated( - table: npt.NDArray[Union[np.float_, np.int_]], + table: NDArray[Union[np.float_, np.int_]], int_prec: Optional[bool] = False, hc_sys: Optional[str] = "AGB", -) -> npt.NDArray[Union[np.float_, np.int_]]: +) -> NDArray[Union[np.float_, np.int_]]: """ Keep only the first instance of a score in the handicap tables. @@ -558,8 +594,51 @@ def abbreviate(name: str) -> str: return " ".join(abbreviations.get(i, i) for i in name.split()) +def table_as_str( + round_list: List[rounds.Round], + hcs: NDArray[Union[np.float_, np.int_]], + table: NDArray[Union[np.float_, np.int_]], + int_prec: Optional[bool] = False, +) -> str: + """ + Convert the handicap table to a string. + + Parameters + ---------- + round_list : list of rounds.Round + List of Round classes to calculate scores for + hcs : ndarray + handicap value(s) to calculate score(s) for + table : ndarray + handicap table as array + int_prec : bool + return integers, not floats? + + Returns + ------- + output_str : str + Handicap table formatted as a string + """ + # To ensure both terminal and file output are the same, create a single string + round_names = [abbreviate(r.name) for r in round_list] + output_header = "".join(name.rjust(14) for name in chain(["Handicap"], round_names)) + # Auto-set the number of decimal places to display handicaps to + if np.max(hcs % 1.0) <= 0.0: + hc_dp = 0 + else: + hc_dp = np.max( + np.abs([decimal.Decimal(str(d)).as_tuple().exponent for d in hcs]) + ) + # Format each row appropriately + output_rows = [format_row(row, hc_dp, int_prec) for row in table] + output_str = "\n".join(chain([output_header], output_rows)) + + return output_str + + def format_row( - row: npt.NDArray[Union[np.float_, np.int_]], + row: NDArray[Union[np.float_, np.int_]], + hc_dp: Optional[int] = 0, int_prec: Optional[bool] = False, ) -> str: """ @@ -569,6 +648,8 @@ def format_row( ---------- row : NDArray numpy array of table row + hc_dp : int + handicap decimal places int_prec : bool return integers, not floats? @@ -577,6 +658,36 @@ def format_row( formatted_row : str pretty string based on input array data """ + if hc_dp == 0: + handicap_str = f"{int(row[0]):14d}" + else: + handicap_str = f"{row[0]:14.{hc_dp}f}" + if int_prec: - return "".join("".rjust(14) if x == FILL else f"{int(x):14d}" for x in row) - return "".join("".rjust(14) if np.isnan(x) else f"{x:14.8f}" for x in row) + return handicap_str + "".join( + "".rjust(14) if x == FILL else f"{int(x):14d}" for x in row[1:] + ) + return handicap_str + "".join( + "".rjust(14) if np.isnan(x) else f"{x:14.8f}" for x in row[1:] + ) + + +def table_to_file(filename: str, output_str: str) -> None: + """ + Fornat appearance of handicap table row to look nice. + + Parameters + ---------- + filename : str + name of file to save handicap table to + output_str : str + handicap table as string to save to file + + Returns + ------- + None + """ + print("Writing handicap table to file...", end="") + with open(filename, "w", encoding="utf-8") as table_file: + table_file.write(output_str) + print("Done.") From df7cfa228ea9a344016bed7ea997d0dfdf063d74 Mon Sep 17 00:00:00 2001 From: jatkinson1000 <109271713+jatkinson1000@users.noreply.github.com> Date: Sun, 3 Sep 2023 13:05:23 +0100 Subject: [PATCH 56/56] Add handicap table tests and refactor into separate file. --- archeryutils/handicaps/handicap_functions.py | 18 +- .../handicaps/tests/test_handicap_tables.py | 283 ++++++++++++++++++ .../handicaps/tests/test_handicaps.py | 114 +------ 3 files changed, 297 insertions(+), 118 deletions(-) create mode 100644 archeryutils/handicaps/tests/test_handicap_tables.py diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index 98da3f6..fe8bcb3 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -374,11 +374,11 @@ def print_handicap_table( hc_dat: hc_eq.HcParams, arrow_d: Optional[float] = None, round_scores_up: bool = True, - clean_gaps: Optional[bool] = True, - printout: Optional[bool] = True, + clean_gaps: bool = True, + printout: bool = True, filename: Optional[str] = None, csvfile: Optional[str] = None, - int_prec: Optional[bool] = False, + int_prec: bool = False, ) -> None: """ Generate a handicap table to screen and/or file. @@ -479,7 +479,7 @@ def print_handicap_table( def check_print_table_inputs( hcs: Union[float, NDArray[np.float_]], round_list: list[rounds.Round], - clean_gaps: Optional[bool] = True, + clean_gaps: bool = True, ) -> NDArray[np.float_]: """ Sanitise and format inputs to handicap printing code. @@ -521,8 +521,8 @@ def check_print_table_inputs( def clean_repeated( table: NDArray[Union[np.float_, np.int_]], - int_prec: Optional[bool] = False, - hc_sys: Optional[str] = "AGB", + int_prec: bool = False, + hc_sys: str = "AGB", ) -> NDArray[Union[np.float_, np.int_]]: """ Keep only the first instance of a score in the handicap tables. @@ -598,7 +598,7 @@ def table_as_str( round_list: List[rounds.Round], hcs: NDArray[Union[np.float_, np.int_]], table: NDArray[Union[np.float_, np.int_]], - int_prec: Optional[bool] = False, + int_prec: bool = False, ) -> str: """ Convert the handicap table to a string. @@ -638,8 +638,8 @@ def table_as_str( def format_row( row: NDArray[Union[np.float_, np.int_]], - hc_dp: Optional[int] = 0, - int_prec: Optional[bool] = False, + hc_dp: int = 0, + int_prec: bool = False, ) -> str: """ Fornat appearance of handicap table row to look nice. diff --git a/archeryutils/handicaps/tests/test_handicap_tables.py b/archeryutils/handicaps/tests/test_handicap_tables.py new file mode 100644 index 0000000..f26e0e3 --- /dev/null +++ b/archeryutils/handicaps/tests/test_handicap_tables.py @@ -0,0 +1,283 @@ +"""Tests for handicap table printing""" +# Due to defining some rounds to use in testing duplicate code may trigger. +# => disable for handicap tests +# pylint: disable=duplicate-code + +from typing import Union +import numpy as np +from numpy.typing import NDArray +import pytest + +import archeryutils.handicaps.handicap_equations as hc_eq +import archeryutils.handicaps.handicap_functions as hc_func +from archeryutils.rounds import Round, Pass + + +hc_params = hc_eq.HcParams() + +# Define rounds used in these functions +york = Round( + "York", + [ + Pass(72, 1.22, "5_zone", 100, "yard", False), + Pass(48, 1.22, "5_zone", 80, "yard", False), + Pass(24, 1.22, "5_zone", 60, "yard", False), + ], +) +hereford = Round( + "Hereford", + [ + Pass(72, 1.22, "5_zone", 80, "yard", False), + Pass(48, 1.22, "5_zone", 60, "yard", False), + Pass(24, 1.22, "5_zone", 50, "yard", False), + ], +) +metric122_30 = Round( + "Metric 122-30", + [ + Pass(36, 1.22, "10_zone", 30, "metre", False), + Pass(36, 1.22, "10_zone", 30, "metre", False), + ], +) + + +class TestHandicapTable: + """ + Class to test the handicap table functionalities of handicap_functions. + + Methods + ------- + + References + ---------- + """ + + @pytest.mark.parametrize( + "input_arr,hc_dp,int_prec,expected", + [ + ( + np.array([1, 20.0, 23.0]), + 0, + True, + " 1 20 23", + ), + ( + np.array([1, 20.0, 23.0]), + 2, + True, + " 1.00 20 23", + ), + ( + np.array([1, 20.0, 23.0]), + 3, + False, + " 1.000 20.00000000 23.00000000", + ), + ], + ) + def test_format_row( + self, + input_arr: NDArray[Union[np.int_, np.float_]], + hc_dp: int, + int_prec: bool, + expected: str, + ) -> None: + """ + Check that format_row returns expected results for float and int. + """ + assert hc_func.format_row(input_arr, hc_dp, int_prec) == expected + + @pytest.mark.parametrize( + "hcs,table,int_prec,expected", + [ + ( + # Check int_prec true + np.array([1, 2, 3]), + np.array([[1, 20.0, 23.0], [2, 20.0, 23.0], [3, 20.0, 23.0]]), + True, + " Handicap York Hereford\n" + + " 1 20 23\n" + + " 2 20 23\n" + + " 3 20 23", + ), + ( + # Check int_prec false + np.array([1, 2, 3]), + np.array([[1, 20.0, 23.0], [2, 20.0, 23.0], [3, 20.0, 23.0]]), + False, + " Handicap York Hereford\n" + + " 1 20.00000000 23.00000000\n" + + " 2 20.00000000 23.00000000\n" + + " 3 20.00000000 23.00000000", + ), + ( + # Check handicap float integers are converted to ints + np.array([1.0, 2.0, 3.0]), + np.array([[1.0, 20.0, 23.0], [2.0, 20.0, 23.0], [3.0, 20.0, 23.0]]), + True, + " Handicap York Hereford\n" + + " 1 20 23\n" + + " 2 20 23\n" + + " 3 20 23", + ), + ( + # Check handicap dp are allocated OK + np.array([1.20, 2.0, 3.0]), + np.array([[1.20, 20.0, 23.0], [2.0, 20.0, 23.0], [3.0, 20.0, 23.0]]), + True, + " Handicap York Hereford\n" + + " 1.2 20 23\n" + + " 2.0 20 23\n" + + " 3.0 20 23", + ), + ], + ) + def test_table_as_str( + self, + hcs: NDArray[Union[np.int_, np.float_]], + table: NDArray[Union[np.int_, np.float_]], + int_prec: bool, + expected: str, + ) -> None: + """ + Check that format_row returns expected results for float and int. + """ + print(hc_func.table_as_str([york, hereford], hcs, table, int_prec)) + print(expected) + assert hc_func.table_as_str([york, hereford], hcs, table, int_prec) == expected + + @pytest.mark.parametrize( + "input_str,expected", + [ + ("Compound", "C"), + ("Recurve Triple Portsmouth", "R Tr Ports"), + ("Recurve Triple Portsmouth", "R Tr Ports"), + ("Short Gents Worcester", "St G Worc"), + ], + ) + def test_abbreviate( + self, + input_str: str, + expected: str, + ) -> None: + """ + Check that abbreviate returns expected results. + """ + assert hc_func.abbreviate(input_str) == expected + + @pytest.mark.parametrize( + "hcs,clean_gaps,expected", + [ + ( + # 'Correct' inputs + np.array([1.0, 2.0, 3.0]), + False, + np.array([1.0, 2.0, 3.0]), + ), + ( + # List inputs + [1.0, 2.0, 3.0], + False, + np.array([1.0, 2.0, 3.0]), + ), + ( + # Clean gaps True + np.array([1.0, 2.0, 3.0]), + True, + np.array([0.0, 1.0, 2.0, 3.0, 4.0]), + ), + ( + # Clean gaps True + np.array([1.75, 2.0, 2.5]), + True, + np.array([1.5, 1.75, 2.0, 2.5, 3.0]), + ), + ( + # Single float + 1.0, + False, + np.array([1.0]), + ), + ( + # Single float clean gaps True + 1.5, + True, + np.array([0.5, 1.5, 2.5]), + ), + ], + ) + def test_check_print_table_inputs( + self, + hcs: Union[float, NDArray[np.float_]], + clean_gaps: bool, + expected: Union[float, NDArray[np.float_]], + ) -> None: + """ + Check that inputs processed appropriately. + """ + np.testing.assert_allclose( + hc_func.check_print_table_inputs(hcs, [york, metric122_30], clean_gaps), + expected, + ) + + def test_check_print_table_inputs_invalid_rounds( + self, + ) -> None: + """ + Check that empty rounds list triggers error. + """ + with pytest.raises( + ValueError, + match=("No rounds provided for handicap table."), + ): + hc_func.check_print_table_inputs(1.0, [], True) + + @pytest.mark.parametrize( + "input_table,int_prec,sys,expected", + [ + ( + np.array([[0, 11, 12, 13], [1, 10, 12, 12]]), + True, + "AGB", + np.array([[0, 11, -9999, 13], [1, 10, 12, 12]]), + ), + ( + np.array([[0, 13], [5, 12], [10, 12], [15, 11]]), + True, + "AGB", + np.array([[0, 13], [5, -9999], [10, 12], [15, 11]]), + ), + ( + np.array([[4, 13], [3, 12], [2, 12], [1, 11]]), + True, + "AA", + np.array([[4, 13], [3, 12], [2, -9999], [1, 11]]), + ), + ( + np.array([[0.0, 11.0, 12.0, 13.0], [1.0, 10.0, 12.0, 12.0]]), + False, + "AGB", + np.array([[0.0, 11.0, np.nan, 13.0], [1.0, 10.0, 12.0, 12.0]]), + ), + ( + np.array([[0.0, 11.5, 12.5, 13.5], [1.0, 11.5, 12.0, 13.5]]), + False, + "AGB", + np.array([[0.0, np.nan, 12.5, np.nan], [1.0, 11.5, 12.0, 13.5]]), + ), + ], + ) + def test_clean_repeated( + self, + input_table: NDArray[Union[np.int_, np.float_]], + int_prec: bool, + sys: str, + expected: NDArray[Union[np.int_, np.float_]], + ) -> None: + """ + Check that abbreviate returns expected results. + """ + print(hc_func.clean_repeated(input_table, int_prec, sys)) + np.testing.assert_allclose( + hc_func.clean_repeated(input_table, int_prec, sys), expected + ) diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py index a03f70e..73ff12c 100644 --- a/archeryutils/handicaps/tests/test_handicaps.py +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -1,7 +1,10 @@ """Tests for handicap equations and functions""" -from typing import Tuple, List, Union +# Due to defining some rounds to use in testing duplicate code may trigger. +# => disable for handicap tests +# pylint: disable=duplicate-code + +from typing import Tuple, List import numpy as np -from numpy.typing import NDArray import pytest from pytest_mock import MockerFixture @@ -812,110 +815,3 @@ def test_decimal( ) assert handicap == pytest.approx(handicap_expected) - - -class TestHandicapTable: - """ - Class to test the handicap table functionalities of handicap_functions. - - Methods - ------- - - References - ---------- - """ - - @pytest.mark.parametrize( - "input_arr,int_prec,expected", - [ - ( - np.array([1, 20.0, 23.0]), - False, - " 1.00000000 20.00000000 23.00000000", - ), - ( - np.array([1, 20.0, 23.0]), - True, - " 1 20 23", - ), - ], - ) - def test_format_row( - self, - input_arr: NDArray[Union[np.int_, np.float_]], - int_prec: bool, - expected: str, - ) -> None: - """ - Check that format_row returns expected results for float and int. - """ - assert hc_func.format_row(input_arr, int_prec) == expected - - @pytest.mark.parametrize( - "input_str,expected", - [ - ("Compound", "C"), - ("Recurve Triple Portsmouth", "R Tr Ports"), - ("Recurve Triple Portsmouth", "R Tr Ports"), - ("Short Gents Worcester", "St G Worc"), - ], - ) - def test_abbreviate( - self, - input_str: str, - expected: str, - ) -> None: - """ - Check that abbreviate returns expected results. - """ - assert hc_func.abbreviate(input_str) == expected - - @pytest.mark.parametrize( - "input_table,int_prec,sys,expected", - [ - ( - np.array([[0, 11, 12, 13], [1, 10, 12, 12]]), - True, - "AGB", - np.array([[0, 11, -9999, 13], [1, 10, 12, 12]]), - ), - ( - np.array([[0, 13], [5, 12], [10, 12], [15, 11]]), - True, - "AGB", - np.array([[0, 13], [5, -9999], [10, 12], [15, 11]]), - ), - ( - np.array([[4, 13], [3, 12], [2, 12], [1, 11]]), - True, - "AA", - np.array([[4, 13], [3, 12], [2, -9999], [1, 11]]), - ), - ( - np.array([[0.0, 11.0, 12.0, 13.0], [1.0, 10.0, 12.0, 12.0]]), - False, - "AGB", - np.array([[0.0, 11.0, np.nan, 13.0], [1.0, 10.0, 12.0, 12.0]]), - ), - ( - np.array([[0.0, 11.5, 12.5, 13.5], [1.0, 11.5, 12.0, 13.5]]), - False, - "AGB", - np.array([[0.0, np.nan, 12.5, np.nan], [1.0, 11.5, 12.0, 13.5]]), - ), - ], - ) - def test_clean_repeated( - self, - input_table: NDArray[Union[np.int_, np.float_]], - int_prec: bool, - sys: str, - expected: NDArray[Union[np.int_, np.float_]], - ) -> None: - """ - Check that abbreviate returns expected results. - """ - print(hc_func.clean_repeated(input_table, int_prec, sys)) - np.testing.assert_allclose( - hc_func.clean_repeated(input_table, int_prec, sys), expected - )