Source code for archeryutils.classifications.agb_outdoor_classifications

"""
Code for calculating Archery GB outdoor classifications.

Routine Listings
----------------
calculate_agb_outdoor_classification
agb_outdoor_classification_scores
"""

import itertools
from typing import Any, Literal, Tuple, TypedDict, cast

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",
    ],
)

outdoor_bowstyles = (
    AGB_bowstyles.COMPOUND
    | AGB_bowstyles.RECURVE
    | AGB_bowstyles.BAREBOW
    | AGB_bowstyles.LONGBOW
)


class GroupData(TypedDict):
    """Structure for AGB Outdoor classification data."""

    classes: list[str]
    max_distances: list[float]
    classes_long: list[str]
    class_HC: npt.NDArray[np.float64]
    min_dists: npt.NDArray[np.float64]
    prestige_rounds: list[str]


def _get_outdoor_groupname(
    bowstyle: AGB_bowstyles,
    gender: AGB_genders,
    age_group: AGB_ages,
) -> str:
    """
    Wrap function to generate string id for a particular category with outdoor guards.

    Parameters
    ----------
    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
    -------
    groupname : str
        single str id for this category
    """
    if bowstyle not in AGB_bowstyles or bowstyle not in outdoor_bowstyles:
        msg = (
            f"{bowstyle} is not a recognised bowstyle for outdoor classifications. "
            f"Please select from `{outdoor_bowstyles}`."
        )
        raise ValueError(msg)
    if gender not in AGB_genders:
        msg = (
            f"{gender} is not a recognised gender group for outdoor classifications. "
            "Please select from `archeryutils.AGB_genders`."
        )
        raise ValueError(msg)
    if age_group not in AGB_ages:
        msg = (
            f"{age_group} is not a recognised age group for outdoor classifications. "
            "Please select from `archeryutils.AGB_ages`."
        )
        raise ValueError(msg)
    return cls_funcs.get_groupname(bowstyle, gender, age_group)


[docs] def coax_outdoor_group( bowstyle: AGB_bowstyles, gender: AGB_genders, age_group: AGB_ages, ) -> cls_funcs.AGBCategory: """ Coax category not conforming to 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 ------- dict[str, AGB_bowstyles | AGB_genders | AGB_ages] dict of archer's bowstyle, gender, and age_group under AGB coaxed to outdoor target 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 coax_age_group = age_group return { "bowstyle": coax_bowstyle, "gender": coax_gender, "age_group": coax_age_group, }
def _make_agb_outdoor_classification_dict() -> dict[str, GroupData]: """ 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) """ # Read in age group info as list of dicts agb_age_data = cls_funcs.read_ages_json() # Read in bowstyleclass info as list of dicts agb_bowstyle_data = cls_funcs.read_bowstyles_json() # Read in classification names as dict agb_classes_info_out = cls_funcs.read_classes_json("agb_outdoor") agb_classes_out = agb_classes_info_out["classes"] agb_classes_out_long = agb_classes_info_out["classes_long"] # Generate dict of classifications # loop over all bowstyles, genders, ages classification_dict = {} for bowstyle, gender, age in itertools.product( outdoor_bowstyles, AGB_genders, AGB_ages ): # Generate groupname # The following satisfies mypy that names are all valid strings # Cannot currently be reached, so ignore for coverage if gender.name is None: # pragma: no cover errmsg = f"Gender {gender} does not have a name." raise ValueError(errmsg) if age.name is None: # pragma: no cover errmsg = f"Age {age} does not have a name." raise ValueError(errmsg) if bowstyle.name is None: # pragma: no cover errmsg = f"Bowstyle {bowstyle} does not have a name." raise ValueError(errmsg) groupname = _get_outdoor_groupname(bowstyle, gender, age) # Get max dists for category from json file data # Use metres as corresponding yards >= metric gender_key = cast(Literal["open", "female"], gender.name.lower()) max_dists = agb_age_data[age.name][gender_key] # set step from datum based on age and gender steps required delta_hc_age_gender = cls_funcs.get_age_gender_step( gender, agb_age_data[age.name]["step"], agb_bowstyle_data[bowstyle.name]["ageStep_out"], agb_bowstyle_data[bowstyle.name]["genderStep_out"], ) # set handicap threshold values for all classifications in the category class_hc = ( agb_bowstyle_data[bowstyle.name]["datum_out"] + delta_hc_age_gender + (np.arange(len(agb_classes_out)) - 2) * agb_bowstyle_data[bowstyle.name]["classStep_out"] ).astype(np.float64) # get minimum distances to be shot for all classifications in the category min_dists = _assign_min_dist( gender=gender, age_group=age, max_dists=max_dists, ) # Assign prestige rounds for the category prestige_rounds = _assign_outdoor_prestige( bowstyle=bowstyle, age=age, gender=gender, max_dists=max_dists, ) groupdata: GroupData = { "classes": agb_classes_out, "max_distances": max_dists, "classes_long": agb_classes_out_long, "class_HC": class_hc, "min_dists": min_dists, "prestige_rounds": prestige_rounds, } classification_dict[groupname] = groupdata return classification_dict def _assign_min_dist( gender: AGB_genders, age_group: AGB_ages, max_dists: list[float], ) -> npt.NDArray[np.float64]: """ Assign appropriate minimum distance required for a category and classification. Appropriate for 2023 ArcheryGB age groups and classifications. Anything B1 and beyond requires max distance for men U16 and older Anything B2 and beyond requires max distance for women, and men U15 and younger Then step down a distance with each classification Parameters ---------- gender : str string defining gender age_group : str, string defining age group max_dists: List[float] list of integers defining the maximum distances for category Returns ------- min_dists : array of int minimum distance [m] required by category for each classification (EMB -> A3) References ---------- ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) 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)) # Age group trickery: # U15 opens and younger step down for B2 and below to align with female scores/hcs if ( gender is AGB_genders.OPEN and age_group not in AGB_ages.UNDER_15 | AGB_ages.UNDER_14 | AGB_ages.UNDER_12 ): idxs = np.array([0, 0, 0, 0, 1, 2, 3, 4, 5]) # All other categories require max dist for B1 and B2 then step down else: idxs = np.array([0, 0, 0, 0, 0, 1, 2, 3, 4]) # Extract relevant distances for each classification from the dists array return np.take(dists, idxs + max_dist_index, mode="clip") def _assign_outdoor_prestige( bowstyle: AGB_bowstyles, gender: AGB_genders, age: AGB_ages, max_dists: list[float], ) -> list[str]: """ Assign appropriate outdoor prestige rounds for a category. Appropriate for 2023 ArcheryGB age groups and classifications. Parameters ---------- bowstyle : AGB_bowstyles enum defining bowstyle gender : AGB_genders enum defining gender age : AGB_ages, enum defining age group max_dists: List[int] list of integers defining the maximum distances for category in [m] and [yds] 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", "wa720_40", "metric_122_40", "metric_122_30", ] prestige_720_compound = [ "wa720_50_c", "metric_80_50", "wa720_40_c", "metric_80_40", "metric_80_30", ] prestige_720_barebow = [ "wa720_50_b", "metric_122_50", "wa720_40", "metric_122_40", "wa720_30_b", "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 is AGB_bowstyles.COMPOUND: # Everyone gets the 'adult' 720 prestige_rounds.extend(prestige_720_compound[0:2]) # Check rest for junior eligible shorter rounds distance_check = distance_check + prestige_720_compound[2:] # Additional fix for U15 who get the 40m round # By extension this also applies to U14 and U12 (though also covered by dist) if age in AGB_ages.UNDER_15 | AGB_ages.UNDER_14 | AGB_ages.UNDER_12: prestige_rounds.extend(prestige_720_compound[2:4]) # 40m C elif bowstyle is AGB_bowstyles.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:] # Additional fix for U15 who get the 30m round # By extension they also get the 40m and this also applies to U14 and U12 if age in AGB_ages.UNDER_15 | AGB_ages.UNDER_14 | AGB_ages.UNDER_12: prestige_rounds.extend(prestige_720_barebow[2:]) # 40m and 30m B 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 U15 who get the 40m round # By extension this also applies to U14 and U12 (though also covered by dist) if age in AGB_ages.UNDER_15 | AGB_ages.UNDER_14 | AGB_ages.UNDER_12: prestige_rounds.extend(prestige_720[3:5]) # 40m # Additional fix for Open 50+, U18, and U16 recurve/longbow if gender is AGB_genders.OPEN: if age in AGB_ages.OVER_50 | AGB_ages.UNDER_18: prestige_rounds.append(prestige_720[1]) # 60m elif age is AGB_ages.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().value >= np.min(max_dists): prestige_rounds.append(roundname) return prestige_rounds agb_outdoor_classifications = _make_agb_outdoor_classification_dict() del _make_agb_outdoor_classification_dict def _check_round_eligibility(archery_round: Round | str) -> Tuple[Round, str]: """ Check round is eligible for 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
[docs] def calculate_agb_outdoor_classification( # noqa: PLR0913 Too many arguments score: float, archery_round: Round | str, bowstyle: AGB_bowstyles, gender: AGB_genders, age_group: AGB_ages, strict_rounds: bool = True, strict_distance: bool = True, ) -> 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 ---------- 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 (provided strict_rounds) 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 strict_rounds : bool, default=True Whether to enforce valid AGB outdoor rounds and apply prestige rounds rules. If False then `archery_round` must be of type `Round` for explicit clarity. Prestige rounds will no longer default to allow MB classifications and any max-distance rounds will return MB-tier classifications. strict_distance : bool, default=True Whether to enforce age-dependent distance restrictions 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) Raises ------ ValueError If requested round is not valid for this scheme (when strict_rounds enabled) If an invalid score for the requested round is provided TypeError If archery_round is passed as a string when strict_rounds disabled Examples -------- >>> from archeryutils import classifications as cf >>> from archeryutils import load_rounds >>> agb_outdoor = load_rounds.AGB_outdoor_imperial >>> cf.calculate_agb_outdoor_classification( ... 858, ... agb_outdoor.hereford, ... cf.AGB_bowstyles.RECURVE, ... cf.AGB_genders.FEMALE, ... cf.AGB_ages.UNDER_18, ... ) 'B1' """ if strict_rounds: archery_round, _ = _check_round_eligibility(archery_round) elif isinstance(archery_round, str): msg = ( "strict_rounds is False so archery_round must be explicitly specified as " "a Round type instead of a string." ) raise TypeError(msg) # 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 # Enforcing full size face and compound scoring (for compounds) all_class_scores = agb_outdoor_classification_scores( archery_round, bowstyle, gender, age_group, strict_rounds=strict_rounds, strict_distance=strict_distance, ) groupname = _get_outdoor_groupname(bowstyle, gender, age_group) group_data = agb_outdoor_classifications[groupname] class_data = dict(zip(group_data["classes"], all_class_scores, strict=True)) # Of the classes available, what is the highest classification this score gets? # < 0 handles invalid classes & max scores, > score handles higher classifications for classname, classscore in class_data.items(): if classscore < 0 or classscore > score: continue else: return classname return "UC"
[docs] def agb_outdoor_classification_scores( archery_round: Round | str, bowstyle: AGB_bowstyles, gender: AGB_genders, age_group: AGB_ages, strict_rounds: bool = True, strict_distance: bool = True, ) -> 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 ---------- 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 strict_rounds : bool, default=True Whether to enforce valid AGB rounds only and prestige rounds rules If False prestige rounds will no longer default to give all classifications and max-distance rounds will return scores for MB classifications. strict_distance : bool, default=True Whether to enforce age-dependent distance restrictions Returns ------- classification_scores : ndarray scores required for each classification in descending order References ---------- ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) Raises ------ ValueError If requested round is not valid for this scheme (when strict_rounds enabled) TypeError If archery_round is passed as a string when strict_rounds disabled Examples -------- >>> from archeryutils import classifications as cf >>> from archeryutils import load_rounds >>> agb_outdoor = load_rounds.AGB_outdoor_imperial >>> cf.agb_outdoor_classification_scores( ... agb_outdoor.hereford, ... cf.AGB_bowstyles.RECURVE, ... cf.AGB_genders.FEMALE, ... cf.AGB_ages.ADULT, ... ) [1232, 1178, 1107, 1015, 900, 763, 614, 466, 336] If a classification cannot be achieved a fill value of `-9999` is returned: >>> cf.agb_outdoor_classification_scores( ... agb_outdoor.bristol_ii, ... cf.AGB_bowstyles.RECURVE, ... cf.AGB_genders.FEMALE, ... cf.AGB_ages.ADULT, ... ) [-9999, -9999, -9999, -9999, -9999, 931, 797, 646, 493] """ if strict_rounds: archery_round, roundname = _check_round_eligibility(archery_round) archery_round = ALL_OUTDOOR_ROUNDS[cls_funcs.strip_spots(roundname)] elif isinstance(archery_round, str): msg = ( "strict_rounds is False so archery_round must be explicitly specified as " "a Round type instead of a string." ) raise TypeError(msg) else: # If a custom round has been passed use codename for prestige checks # This assumes users do not set the codename to an alreay existing codename roundname = archery_round.codename groupname = _get_outdoor_groupname(bowstyle, gender, age_group) group_data = agb_outdoor_classifications[groupname] hc_scheme = "AGB" # Get scores required on this round for each classification class_scores = [ hc.score_for_round( group_data["class_HC"][i], archery_round, hc_scheme, rounded_score=True, ) 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 ( strict_rounds and roundname not in agb_outdoor_classifications[groupname]["prestige_rounds"] ): class_scores[0:3] = [-9999] * 3 # What classes are eligible based on category and distance # Restrict scores based on distance, unless we are enforcing strict round rules and # this is a prestige round in which case all classifications are available. if strict_distance and not ( strict_rounds and roundname in agb_outdoor_classifications[groupname]["prestige_rounds"] ): round_max_dist = archery_round.max_distance().value for i in range(len(class_scores)): if group_data["min_dists"][i] > round_max_dist: 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] # 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 # i.e. one must exceed (be lower than) the handicap threshold, not be awarded if # the same score is achievable at a higher handicap. for i, (score, handicap) in enumerate( zip(int_class_scores, group_data["class_HC"], strict=True), ): next_score = hc.score_for_round( np.floor(handicap) + 1, archery_round, hc_scheme, rounded_score=True, ) if next_score == score: # If already at max score this classification is impossible if score == archery_round.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 # Finally, ensure that there are no repeated scores. int_class_scores = cls_funcs.fix_repeated_scores( int_class_scores, archery_round.max_score() ) return int_class_scores