diff --git a/archeryutils/classifications/__init__.py b/archeryutils/classifications/__init__.py index 7cda8c4..6e42a56 100644 --- a/archeryutils/classifications/__init__.py +++ b/archeryutils/classifications/__init__.py @@ -21,6 +21,10 @@ calculate_agb_old_indoor_classification, coax_old_indoor_group, ) +from .agb_old_outdoor_classifications import ( + agb_old_outdoor_classification_scores, + calculate_agb_old_outdoor_classification, +) from .agb_outdoor_classifications import ( agb_outdoor_classification_scores, calculate_agb_outdoor_classification, @@ -35,15 +39,18 @@ "agb_indoor_classification_scores", "agb_old_field_classification_scores", "agb_old_indoor_classification_scores", + "agb_old_outdoor_classification_scores", # new "agb_outdoor_classification_scores", "calculate_agb_field_classification", "calculate_agb_indoor_classification", "calculate_agb_old_field_classification", "calculate_agb_old_indoor_classification", + "calculate_agb_old_outdoor_classification", # new "calculate_agb_outdoor_classification", "coax_field_group", "coax_indoor_group", "coax_old_field_group", "coax_old_indoor_group", + # coax_old_outdoor "coax_outdoor_group", ] diff --git a/archeryutils/classifications/agb_old_outdoor_classifications.py b/archeryutils/classifications/agb_old_outdoor_classifications.py new file mode 100644 index 0000000..0b98c8a --- /dev/null +++ b/archeryutils/classifications/agb_old_outdoor_classifications.py @@ -0,0 +1,458 @@ +""" +Code for calculating old (pre-2023) Archery GB outdoor classifications. + +Routine Listings +---------------- +calculate_agb_old_outdoor_classification +agb_old_outdoor_classification_scores +""" + +from typing import Tuple, TypedDict + +import numpy as np +import numpy.typing as npt + +import archeryutils.classifications.classification_utils as cls_funcs +import archeryutils.handicaps as hc +from archeryutils import load_rounds +from archeryutils.classifications.AGB_data import AGB_ages, AGB_bowstyles, AGB_genders +from archeryutils.rounds import Round + +ALL_OUTDOOR_ROUNDS = load_rounds.read_json_to_round_dict( + [ + "AGB_outdoor_imperial.json", + "AGB_outdoor_metric.json", + "WA_outdoor.json", + ], +) + +old_outdoor_bowstyles = ( + AGB_bowstyles.COMPOUND + | AGB_bowstyles.RECURVE + | AGB_bowstyles.BAREBOW + | AGB_bowstyles.LONGBOW +) + +old_outdoor_ages = ( + AGB_ages.AGE_ADULT + | AGB_ages.AGE_UNDER_18 + | AGB_ages.AGE_UNDER_16 + | AGB_ages.AGE_UNDER_14 + | AGB_ages.AGE_UNDER_12 +) + + +class GroupData(TypedDict): + """Structure for old AGB Outdoor classification data.""" + + classes: list[str] + class_HC: npt.NDArray[np.float64] + min_dists: npt.NDArray[np.float64] + min_dozens: npt.NDArray[np.float64] + + +def _get_old_outdoor_groupname( + bowstyle: AGB_bowstyles, + gender: AGB_genders, + age_group: AGB_ages, +) -> str: + """ + Wrap function to generate string id for a particular category with indoor guards. + + Parameters + ---------- + bowstyle : AGB_bowstyles + archer's bowstyle under AGB indoor target rules + gender : AGB_genders + archer's gender under AGB indoor target rules + age_group : AGB_ages + archer's age group under AGB indoor target rules + + Returns + ------- + groupname : str + single str id for this category + """ + if bowstyle not in AGB_bowstyles or bowstyle not in old_outdoor_bowstyles: + msg = ( + f"{bowstyle} is not a recognised bowstyle for old outdoor classifications. " + f"Please select from `{old_outdoor_bowstyles}`." + ) + raise ValueError(msg) + if gender not in AGB_genders: + msg = ( + f"{gender} is not a recognised gender group for old outdoor " + "classifications. Please select from `archeryutils.AGB_genders`." + ) + raise ValueError(msg) + if age_group not in AGB_ages or age_group not in old_outdoor_ages: + msg = ( + f"{age_group} is not a recognised age group for old outdoor " + f"classifications. Please select from `{old_outdoor_ages}`." + ) + raise ValueError(msg) + return cls_funcs.get_groupname(bowstyle, gender, age_group) + + +def _make_agb_old_outdoor_classification_dict() -> dict[str, GroupData]: + # CHECK: happy with abbreviations, correct label for junior bowman? + agb_outdoor_adult_classes = ["GMB", "MB", "B", "1ST", "2ND", "3RD"] + agb_outdoor_junior_classes = ["JMB", "JB", "1ST", "2ND", "3RD"] + + # explicit construction + # no systematic generation of handicap thresholds in the old system, + # all were set manually by attempting to fit to data + B = AGB_bowstyles + G = AGB_genders + A = AGB_ages + + handicap_thresholds = { + (B.COMPOUND, G.MALE, A.AGE_ADULT): [10, 16, 23, 32, 38, 48], + (B.COMPOUND, G.FEMALE, A.AGE_ADULT): [15, 21, 29, 38, 49, 56], + (B.RECURVE, G.MALE, A.AGE_ADULT): [22, 28, 36, 44, 50, 58], + (B.RECURVE, G.FEMALE, A.AGE_ADULT): [27, 33, 41, 50, 57, 65], + (B.BAREBOW, G.MALE, A.AGE_ADULT): [40, 45, 49, 56, 64, 71], + (B.BAREBOW, G.FEMALE, A.AGE_ADULT): [49, 51, 57, 64, 71, 78], + (B.LONGBOW, G.MALE, A.AGE_ADULT): [52, 55, 60, 65, 69, 74], + (B.LONGBOW, G.FEMALE, A.AGE_ADULT): [59, 62, 65, 70, 73, 82], + (B.COMPOUND, G.MALE, A.AGE_UNDER_18): [23, 32, 38, 48, 56], + (B.COMPOUND, G.FEMALE, A.AGE_UNDER_18): [29, 38, 49, 56, 66], + (B.COMPOUND, G.MALE, A.AGE_UNDER_16): [32, 38, 48, 56, 61], + (B.COMPOUND, G.FEMALE, A.AGE_UNDER_16): [38, 49, 56, 66, 74], + (B.COMPOUND, G.MALE, A.AGE_UNDER_14): [38, 48, 56, 61, 69], + (B.COMPOUND, G.FEMALE, A.AGE_UNDER_14): [46, 55, 65, 75, 84], + (B.COMPOUND, G.MALE, A.AGE_UNDER_12): [48, 56, 61, 71, 79], + (B.COMPOUND, G.FEMALE, A.AGE_UNDER_12): [54, 63, 73, 83, 91], + (B.RECURVE, G.MALE, A.AGE_UNDER_18): [31, 39, 50, 58, 68], + (B.RECURVE, G.FEMALE, A.AGE_UNDER_18): [41, 48, 57, 64, 70], + (B.RECURVE, G.MALE, A.AGE_UNDER_16): [40, 48, 56, 62, 71], + (B.RECURVE, G.FEMALE, A.AGE_UNDER_16): [50, 58, 66, 72, 76], + (B.RECURVE, G.MALE, A.AGE_UNDER_14): [50, 58, 66, 71, 79], + (B.RECURVE, G.FEMALE, A.AGE_UNDER_14): [56, 64, 73, 80, 87], + (B.RECURVE, G.MALE, A.AGE_UNDER_12): [61, 69, 77, 83, 92], + (B.RECURVE, G.FEMALE, A.AGE_UNDER_12): [65, 70, 78, 87, 93], + (B.BAREBOW, G.MALE, A.AGE_UNDER_18): [50, 57, 62, 68, 73], + (B.BAREBOW, G.FEMALE, A.AGE_UNDER_18): [54, 59, 64, 69, 73], + (B.BAREBOW, G.MALE, A.AGE_UNDER_16): [53, 60, 65, 70, 75], + (B.BAREBOW, G.FEMALE, A.AGE_UNDER_16): [59, 65, 70, 74, 79], + (B.BAREBOW, G.MALE, A.AGE_UNDER_14): [60, 67, 72, 77, 83], + (B.BAREBOW, G.FEMALE, A.AGE_UNDER_14): [67, 72, 77, 83, 90], + (B.BAREBOW, G.MALE, A.AGE_UNDER_12): [69, 75, 81, 88, 95], + (B.BAREBOW, G.FEMALE, A.AGE_UNDER_12): [73, 78, 84, 90, 96], + (B.LONGBOW, G.MALE, A.AGE_UNDER_18): [56, 62, 67, 73, 79], + (B.LONGBOW, G.FEMALE, A.AGE_UNDER_18): [61, 64, 68, 73, 77], + (B.LONGBOW, G.MALE, A.AGE_UNDER_16): [60, 65, 70, 75, 81], + (B.LONGBOW, G.FEMALE, A.AGE_UNDER_16): [66, 70, 74, 78, 83], + (B.LONGBOW, G.MALE, A.AGE_UNDER_14): [66, 72, 77, 82, 87], + (B.LONGBOW, G.FEMALE, A.AGE_UNDER_14): [72, 77, 82, 87, 95], + (B.LONGBOW, G.MALE, A.AGE_UNDER_12): [75, 81, 87, 93, 99], + (B.LONGBOW, G.FEMALE, A.AGE_UNDER_12): [78, 83, 88, 94, 99], + } + + prerequisites = { + (G.MALE, A.AGE_ADULT): { + "min_dist": [90, 90, 90, 70, 60, 50], + "min_dozen": [12, 12, 0, 0, 0, 0], + }, + (G.FEMALE, A.AGE_ADULT): { + "min_dist": [70, 70, 70, 60, 50, 40], + "min_dozen": [12, 12, 0, 0, 0, 0], + }, + (G.MALE, A.AGE_UNDER_18): { + "min_dist": [70, 70, 60, 50, 40], + "min_dozen": [12, 0, 0, 0, 0], + }, + (G.FEMALE, A.AGE_UNDER_18): { + "min_dist": [60, 60, 50, 40, 30], + "min_dozen": [12, 0, 0, 0, 0], + }, + (G.MALE, A.AGE_UNDER_16): { + "min_dist": [60, 60, 50, 40, 30], + "min_dozen": [12, 0, 0, 0, 0], + }, + (G.FEMALE, A.AGE_UNDER_16): { + "min_dist": [50, 50, 40, 30, 20], + "min_dozen": [12, 0, 0, 0, 0], + }, + (G.MALE, A.AGE_UNDER_14): { + "min_dist": [50, 50, 40, 30, 20], + "min_dozen": [12, 0, 0, 0, 0], + }, + (G.FEMALE, A.AGE_UNDER_14): { + "min_dist": [40, 40, 30, 20, 15], + "min_dozen": [12, 0, 0, 0, 0], + }, + (G.MALE, A.AGE_UNDER_12): { + "min_dist": [40, 40, 30, 20, 15], + "min_dozen": [12, 0, 0, 0, 0], + }, + (G.FEMALE, A.AGE_UNDER_12): { + "min_dist": [30, 30, 20, 15, 10], + "min_dozen": [12, 0, 0, 0, 0], + }, + } + + classification_dict = {} + + for (bowstyle, gender, age), handicaps in handicap_thresholds.items(): + groupname = _get_old_outdoor_groupname(bowstyle, gender, age) + class_names = ( + agb_outdoor_adult_classes + if age in AGB_ages.AGE_ADULT | AGB_ages.AGE_50_PLUS + else agb_outdoor_junior_classes + ) + + requirements = prerequisites[(gender, age)] + groupdata: GroupData = { + "classes": class_names, + "class_HC": np.array(handicaps), + "min_dists": np.array(requirements["min_dist"]), + "min_dozens": np.array(requirements["min_dozen"]), + } + + classification_dict[groupname] = groupdata + + return classification_dict + + +agb_old_outdoor_classifications = _make_agb_old_outdoor_classification_dict() + +del _make_agb_old_outdoor_classification_dict + + +def coax_old_outdoor_group( + bowstyle: AGB_bowstyles, + gender: AGB_genders, + age_group: AGB_ages, +) -> cls_funcs.AGBCategory: + """ + Coax category not conforming to old outdoor classification rules to one that does. + + Parameters + ---------- + bowstyle : AGB_bowstyles + archer's bowstyle + gender : AGB_genders + archer's gender under AGB + age_group : AGB_ages + archer's age group + + Returns + ------- + TypedDict + typed dict of archer's bowstyle, gender, and age_group under AGB coaxed to + old outdoor rules + """ + if bowstyle in (AGB_bowstyles.FLATBOW | AGB_bowstyles.TRADITIONAL): + coax_bowstyle = AGB_bowstyles.BAREBOW + elif bowstyle in (AGB_bowstyles.COMPOUNDLIMITED | AGB_bowstyles.COMPOUNDBAREBOW): + coax_bowstyle = AGB_bowstyles.COMPOUND + else: + coax_bowstyle = bowstyle + + coax_gender = gender + + if age_group in (AGB_ages.AGE_UNDER_21 | AGB_ages.AGE_50_PLUS): + coax_age_group = AGB_ages.AGE_ADULT + elif age_group in (AGB_ages.AGE_UNDER_15,): + coax_age_group = AGB_ages.AGE_UNDER_14 + else: + coax_age_group = age_group + + return { + "bowstyle": coax_bowstyle, + "gender": coax_gender, + "age_group": coax_age_group, + } + + +def _check_round_eligibility(archery_round: Round | str) -> Tuple[Round, str]: + """ + Check round is eligible for old outdoor classifications. + + Parameters + ---------- + archery_round : Round | str + an archeryutils Round object as suitable for this scheme + alternatively the round codename as a str can be used + + Returns + ------- + archery_round : Round + an archeryutils Round from the value passed in + roundname : str + codename of the round as it appears in the rounds dict + + Raises + ------ + ValueError + If requested round is not in the rounds dict for this scheme + + """ + if isinstance(archery_round, str) and archery_round in ALL_OUTDOOR_ROUNDS: + roundname = archery_round + archery_round = ALL_OUTDOOR_ROUNDS[roundname] + elif ( + isinstance(archery_round, Round) + and archery_round in ALL_OUTDOOR_ROUNDS.values() + ): + # Get string key for this round: + roundname = list(ALL_OUTDOOR_ROUNDS.keys())[ + list(ALL_OUTDOOR_ROUNDS.values()).index(archery_round) + ] + else: + error = ( + "This round is not recognised for the purposes of outdoor classification.\n" + "Please select an appropriate option using `archeryutils.load_rounds`." + ) + raise ValueError(error) + + return archery_round, roundname + + +def calculate_agb_old_outdoor_classification( + score: float, + archery_round: Round | str, + bowstyle: AGB_bowstyles, + gender: AGB_genders, + age_group: AGB_ages, +) -> str: + """ + Calculate AGB outdoor classification from score. + + Subroutine to calculate a classification from a score given suitable inputs. + Appropriate ArcheryGB age groups and classifications pre 2023. + + Parameters + ---------- + score : int + numerical score on the round to calculate classification for + archery_round : Round | str + an archeryutils Round object as suitable for this scheme + alternatively the round codename as a str can be used + bowstyle : AGB_bowstyles + archer's bowstyle under AGB outdoor target rules + gender : AGB_genders + archer's gender under AGB outdoor target rules + age_group : AGB_ages + archer's age group under AGB outdoor target rules + + + Returns + ------- + classification_from_score : str + the classification appropriate for this score + + References + ---------- + ArcheryGB Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 + + Examples + -------- + TBC + + """ + archery_round, _ = _check_round_eligibility(archery_round) + # Check score is valid + if score < 0 or score > archery_round.max_score(): + msg = ( + f"Invalid score of {score} for a {archery_round.name}. " + f"Should be in range 0-{archery_round.max_score()}.", + ) + raise ValueError(msg) + + # Get scores required on this round for each classification + class_scores = agb_old_outdoor_classification_scores( + archery_round, + bowstyle, + gender, + age_group, + ) + + groupname = cls_funcs.get_groupname(bowstyle, gender, age_group) + group_data = agb_old_outdoor_classifications[groupname] + class_data = dict(zip(group_data["classes"], class_scores, strict=True)) + + for classname, classscore in class_data.items(): + if classscore > score: + continue + else: + return classname + return "UC" + + +def agb_old_outdoor_classification_scores( + archery_round: Round | str, + bowstyle: AGB_bowstyles, + gender: AGB_genders, + age_group: AGB_ages, +) -> list[int]: + """ + Calculate AGB outdoor classification scores for category. + + Subroutine to calculate classification scores for a specific category and round. + Appropriate ArcheryGB age groups and classifications pre 2023. + + Parameters + ---------- + archery_round : Round | str + an archeryutils Round object as suitable for this scheme + alternatively the round codename as a str can be used + bowstyle : AGB_bowstyles + archer's bowstyle under AGB outdoor target rules + gender : AGB_genders + archer's gender under AGB outdoor target rules + age_group : AGB_ages + archer's age group under AGB outdoor target rules + + Returns + ------- + classification_scores : ndarray + scores required for each classification in descending order + + References + ---------- + ArcheryGB Rules of Shooting + ArcheryGB Shooting Administrative Procedures - SAP7 + + Examples + -------- + TBC + + """ + # map newer age categories to supported subset + archery_round, roundname = _check_round_eligibility(archery_round) + groupname = _get_old_outdoor_groupname(bowstyle, gender, age_group) + group_data = agb_old_outdoor_classifications[groupname] + + # Get scores required on this round for each classification + class_scores = [ + hc.score_for_round( + group_data["class_HC"][i], + ALL_OUTDOOR_ROUNDS[cls_funcs.strip_spots(roundname)], + "AGBold", + rounded_score=True, + ) + for i in range(len(group_data["classes"])) + ] + + # Check distance and round length requirementss + round_max_dist = archery_round.max_distance().value + round_n_dozen = archery_round.n_arrows // 12 + + for i in range(len(class_scores)): + if ( + # must hit minimum distance + group_data["min_dists"][i] > round_max_dist + or + # senior MB/GMB requires 12 doz. JMB?? + group_data["min_dozens"][i] > round_n_dozen + ): + class_scores[i] = -9999 + + # 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/rounds.py b/archeryutils/rounds.py index 9dbcf05..d2884f2 100644 --- a/archeryutils/rounds.py +++ b/archeryutils/rounds.py @@ -272,6 +272,17 @@ def max_distance(self) -> Quantity: longest_pass = max(self.passes, key=lambda p: p.distance) return longest_pass.native_distance + # def n_arrows(self) -> int: + # """ + # Return the total number of arrows shot on this round. + # + # Returns + # ------- + # n_arrows : int + # number of arrows in the round + # """ + # return sum(pass_i.n_arrows for pass_i in self.passes) + def get_info(self) -> None: """ Print information about the Round. diff --git a/tests/unit/classifications/test_agb_old_outdoor.py b/tests/unit/classifications/test_agb_old_outdoor.py new file mode 100644 index 0000000..aec2049 --- /dev/null +++ b/tests/unit/classifications/test_agb_old_outdoor.py @@ -0,0 +1,408 @@ +"""Tests for old agb outdoor classification functions.""" + +import re + +import pytest + +import archeryutils.classifications as cf +from archeryutils import load_rounds +from archeryutils.classifications.AGB_data import AGB_ages, AGB_bowstyles, AGB_genders +from archeryutils.rounds import Pass, Round + +ALL_OUTDOOR_ROUNDS = load_rounds.read_json_to_round_dict( + [ + "AGB_outdoor_imperial.json", + "AGB_outdoor_metric.json", + "WA_outdoor.json", + ], +) + + +class TestAgbOldOutdoorClassificationScores: + """Tests for the old_outdoor classification scores function.""" + + @pytest.mark.parametrize( + "archery_round,bowstyle,gender,age_group,scores_expected", + [ + ( + ALL_OUTDOOR_ROUNDS["wa1440_90"], + AGB_bowstyles.RECURVE, + AGB_genders.MALE, + AGB_ages.AGE_ADULT, + [1259, 1190, 1065, 885, 716, 481], + ), + ( + ALL_OUTDOOR_ROUNDS["wa1440_70"], + AGB_bowstyles.RECURVE, + AGB_genders.FEMALE, + AGB_ages.AGE_ADULT, + [1242, 1169, 1037, 817, 602, 364], + ), + ( + ALL_OUTDOOR_ROUNDS["wa1440_90"], + AGB_bowstyles.COMPOUND, + AGB_genders.MALE, + AGB_ages.AGE_ADULT, + [1352, 1311, 1248, 1134, 1026, 774], + ), + ( + ALL_OUTDOOR_ROUNDS["wa1440_70"], + AGB_bowstyles.COMPOUND, + AGB_genders.FEMALE, + AGB_ages.AGE_ADULT, + [1342, 1298, 1220, 1092, 845, 634], + ), + ], + ) + def test_agb_old_outdoor_classification_scores( + self, + archery_round: Round | str, + bowstyle: AGB_bowstyles, + gender: AGB_genders, + age_group: AGB_ages, + scores_expected: list[int], + ) -> None: + """ + Check that old_outdoor classification returns expected value for a case. + + Checking across age groups, genders and bowstyles. + """ + scores = cf.agb_old_outdoor_classification_scores( + archery_round=archery_round, + bowstyle=bowstyle, + gender=gender, + age_group=age_group, + ) + + assert scores == scores_expected + + @pytest.mark.parametrize( + "archery_round,scores_expected", + [ + ( + ALL_OUTDOOR_ROUNDS["york"], + [1146, 1065, 913, 698, 511, 283], + ), + ( + ALL_OUTDOOR_ROUNDS["hereford"], + [-9999, -9999, -9999, 884, 723, 477], + ), + ( + ALL_OUTDOOR_ROUNDS["bristol_ii"], + [-9999, -9999, -9999, -9999, 911, 695], + ), + ( + ALL_OUTDOOR_ROUNDS["bristol_iii"], + [-9999, -9999, -9999, -9999, -9999, 860], + ), + ], + ) + def test_agb_old_outdoor_classification_distance_limits( + self, + archery_round: Round | str, + scores_expected: list[int], + ) -> None: + """ + Check that old_outdoor classification returns expected value for a case. + + Checking across age groups, genders and bowstyles. + """ + scores = cf.agb_old_outdoor_classification_scores( + archery_round=archery_round, + bowstyle=AGB_bowstyles.RECURVE, + gender=AGB_genders.MALE, + age_group=AGB_ages.AGE_ADULT, + ) + + assert scores == scores_expected + + @pytest.mark.parametrize( + "archery_round,bowstyle,gender,age_group,msg", + [ + ( + ALL_OUTDOOR_ROUNDS["wa1440_90"], + "invalidbowstyle", + AGB_genders.MALE, + AGB_ages.AGE_ADULT, + ( + "invalidbowstyle is not a recognised bowstyle for old outdoor " + "classifications. Please select from " + "`AGB_bowstyles.COMPOUND|RECURVE|BAREBOW|LONGBOW`." + ), + ), + ( + ALL_OUTDOOR_ROUNDS["wa1440_90"], + AGB_bowstyles.RECURVE, + "invalidgender", + AGB_ages.AGE_ADULT, + ( + "invalidgender is not a recognised gender group for old outdoor " + "classifications. Please select from `archeryutils.AGB_genders`." + ), + ), + ( + ALL_OUTDOOR_ROUNDS["wa1440_90"], + AGB_bowstyles.BAREBOW, + AGB_genders.MALE, + "invalidage", + ( + "invalidage is not a recognised age group for old outdoor " + "classifications. Please select from " + "`AGB_ages.AGE_ADULT|AGE_UNDER_18|AGE_UNDER_16|AGE_UNDER_14|AGE_UNDER_12`." + ), + ), + ], + ) + def test_agb_old_outdoor_classification_scores_invalid( + self, + archery_round: Round | str, + bowstyle: AGB_bowstyles, + gender: AGB_genders, + age_group: AGB_ages, + msg: str, + ) -> None: + """Check that old outdoor classification raises errors for invalid categories.""" + with pytest.raises( + ValueError, + match=msg, + ): + _ = cf.agb_old_outdoor_classification_scores( + archery_round=archery_round, + bowstyle=bowstyle, + gender=gender, + age_group=age_group, + ) + + def test_agb_old_outdoor_classification_scores_invalid_round( + self, + ) -> None: + """Check that old outdoor classification raises error for invalid round.""" + with pytest.raises( + ValueError, + match=( + re.escape( + "This round is not recognised for the purposes of " + "outdoor classification.\n" + "Please select an appropriate option using " + "`archeryutils.load_rounds`." + ) + ), + ): + my_round = Round( + "Some Roundname", + [Pass.at_target(36, "10_zone", 122, 70.0)], + ) + _ = cf.agb_old_outdoor_classification_scores( + archery_round=my_round, + bowstyle=AGB_bowstyles.RECURVE, + gender=AGB_genders.FEMALE, + age_group=AGB_ages.AGE_ADULT, + ) + + def test_agb_old_outdoor_classification_scores_invalid_string_round( + self, + ) -> None: + """Check that outdoor classification raises error for invalid string round.""" + with pytest.raises( + ValueError, + match=( + re.escape( + "This round is not recognised for the purposes of " + "outdoor classification.\n" + "Please select an appropriate option using " + "`archeryutils.load_rounds`." + ) + ), + ): + _ = cf.agb_old_outdoor_classification_scores( + archery_round="invalid_roundname", + bowstyle=AGB_bowstyles.RECURVE, + gender=AGB_genders.FEMALE, + age_group=AGB_ages.AGE_ADULT, + ) + + def test_agb_old_outdoor_classification_scores_string_round( + self, + ) -> None: + """Check that outdoor classification can process a string roundname.""" + scores = cf.agb_old_outdoor_classification_scores( + archery_round="wa1440_90", + bowstyle=AGB_bowstyles.COMPOUND, + gender=AGB_genders.MALE, + age_group=AGB_ages.AGE_ADULT, + ) + + assert scores == [1352, 1311, 1248, 1134, 1026, 774] + + +class TestCalculateAgbOldOutdoorClassification: + """Tests for the old_outdoor classification function.""" + + @pytest.mark.parametrize( + "archery_round,score,age_group,bowstyle,class_expected", + [ + ( + ALL_OUTDOOR_ROUNDS["wa1440_90"], + 1353, # 1 above GMB + AGB_ages.AGE_ADULT, + AGB_bowstyles.COMPOUND, + "GMB", + ), + ( + ALL_OUTDOOR_ROUNDS["wa1440_70"], + 1351, # 1 below GMB - Senior + AGB_ages.AGE_ADULT, + AGB_bowstyles.COMPOUND, + "GMB", + ), + ( + ALL_OUTDOOR_ROUNDS["metric_iii"], + 900, + AGB_ages.AGE_UNDER_12, + AGB_bowstyles.RECURVE, + "JMB", + ), + ( + ALL_OUTDOOR_ROUNDS["metric_iii"], + 600, + AGB_ages.AGE_UNDER_12, + AGB_bowstyles.RECURVE, + "JB", + ), + ( + ALL_OUTDOOR_ROUNDS["metric_iii"], + 400, + AGB_ages.AGE_UNDER_12, + AGB_bowstyles.RECURVE, + "1ST", + ), + ( + ALL_OUTDOOR_ROUNDS["metric_iii"], + 300, + AGB_ages.AGE_UNDER_12, + AGB_bowstyles.RECURVE, + "2ND", + ), + ( + ALL_OUTDOOR_ROUNDS["metric_iii"], + 200, + AGB_ages.AGE_UNDER_12, + AGB_bowstyles.RECURVE, + "3RD", + ), + ( + ALL_OUTDOOR_ROUNDS["metric_iii"], + 1, + AGB_ages.AGE_UNDER_12, + AGB_bowstyles.RECURVE, + "UC", + ), + ], + ) + def test_calculate_agb_old_outdoor_classification( + self, + score: float, + archery_round: Round | str, + age_group: AGB_ages, + bowstyle: AGB_bowstyles, + class_expected: str, + ) -> None: + """Check old outdoor classification returns expected value for a few cases.""" + class_returned = cf.calculate_agb_old_outdoor_classification( + archery_round=archery_round, + score=score, + bowstyle=bowstyle, + gender=AGB_genders.MALE, + age_group=age_group, + ) + assert class_returned == class_expected + + @pytest.mark.parametrize( + "score", + [1000, 901, -1, -100], + ) + def test_calculate_agb_old_outdoor_classification_invalid_scores( + self, + score: float, + ) -> None: + """Check that old outdoor classification fails for inappropriate scores.""" + archery_round = ALL_OUTDOOR_ROUNDS["wa900"] + with pytest.raises( + ValueError, + match=( + f"Invalid score of {score} for a " + f"{archery_round.name}. " + f"Should be in range 0-{archery_round.max_score()}." + ), + ): + _ = cf.calculate_agb_old_outdoor_classification( + score=score, + archery_round=archery_round, + bowstyle=AGB_bowstyles.BAREBOW, + gender=AGB_genders.FEMALE, + age_group=AGB_ages.AGE_ADULT, + ) + + def test_agb_old_outdoor_classification_invalid_round( + self, + ) -> None: + """Check that old outdoor classification raises error for invalid round.""" + with pytest.raises( + ValueError, + match=( + re.escape( + "This round is not recognised for the purposes of " + "outdoor classification.\n" + "Please select an appropriate option using " + "`archeryutils.load_rounds`." + ) + ), + ): + my_round = Round( + "Some Roundname", + [Pass.at_target(36, "10_zone", 122, 70.0)], + ) + _ = cf.calculate_agb_old_outdoor_classification( + archery_round=my_round, + score=666, + bowstyle=AGB_bowstyles.RECURVE, + gender=AGB_genders.FEMALE, + age_group=AGB_ages.AGE_ADULT, + ) + + def test_agb_old_outdoor_classification_scores_invalid_string_round( + self, + ) -> None: + """Check that old outdoor classification raises error for invalid string round.""" + with pytest.raises( + ValueError, + match=( + re.escape( + "This round is not recognised for the purposes of " + "outdoor classification.\n" + "Please select an appropriate option using " + "`archeryutils.load_rounds`." + ) + ), + ): + _ = cf.calculate_agb_old_outdoor_classification( + archery_round="invalid_roundname", + score=666, + bowstyle=AGB_bowstyles.RECURVE, + gender=AGB_genders.FEMALE, + age_group=AGB_ages.AGE_ADULT, + ) + + def test_agb_outdoor_classification_scores_string_round( + self, + ) -> None: + """Check that outdoor classification can process a string roundname.""" + my_class = cf.calculate_agb_old_outdoor_classification( + archery_round="hereford", + score=500, + bowstyle=AGB_bowstyles.LONGBOW, + gender=AGB_genders.FEMALE, + age_group=AGB_ages.AGE_ADULT, + ) + + assert my_class == "GMB"