Source code for archeryutils.classifications.agb_field_classifications

"""
Code for calculating Archery GB Field classifications.

Routine Listings
----------------
calculate_agb_field_classification
agb_field_classification_scores
"""

import itertools
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_FIELD_ROUNDS = load_rounds.read_json_to_round_dict(
    [
        "WA_field.json",
    ]
)

field_bowstyles = (
    AGB_bowstyles.COMPOUND
    | AGB_bowstyles.RECURVE
    | AGB_bowstyles.BAREBOW
    | AGB_bowstyles.ENGLISHLONGBOW
    | AGB_bowstyles.TRADITIONAL
    | AGB_bowstyles.FLATBOW
    | AGB_bowstyles.COMPOUNDLIMITED
    | AGB_bowstyles.COMPOUNDBAREBOW
)

sighted_bowstyles = (
    AGB_bowstyles.COMPOUND | AGB_bowstyles.RECURVE | AGB_bowstyles.COMPOUNDLIMITED
)

field_ages = (
    AGB_ages.OVER_50
    | AGB_ages.ADULT
    | AGB_ages.UNDER_18
    | AGB_ages.UNDER_16
    | AGB_ages.UNDER_15
    | AGB_ages.UNDER_14
    | AGB_ages.UNDER_12
)


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

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


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

    Parameters
    ----------
    bowstyle : AGB_bowstyles
        archer's bowstyle under AGB field target rules
    gender : AGB_genders
        archer's gender under AGB field target rules
    age_group : AGB_ages
        archer's age group under AGB field target rules

    Returns
    -------
    groupname : str
        single str id for this category
    """
    if bowstyle not in AGB_bowstyles or bowstyle not in field_bowstyles:
        msg = (
            f"{bowstyle} is not a recognised bowstyle for field classifications. "
            f"Please select from `{field_bowstyles}`."
        )
        raise ValueError(msg)
    if gender not in AGB_genders:
        msg = (
            f"{gender} is not a recognised gender group for field classifications. "
            "Please select from `archeryutils.AGB_genders`."
        )
        raise ValueError(msg)
    if age_group not in AGB_ages or age_group not in field_ages:
        msg = (
            f"{age_group} is not a recognised age group for field classifications. "
            f"Please select from `{field_ages}`."
        )
        raise ValueError(msg)
    return cls_funcs.get_groupname(bowstyle, gender, age_group)


[docs] def coax_field_group( bowstyle: AGB_bowstyles, gender: AGB_genders, age_group: AGB_ages, ) -> cls_funcs.AGBCategory: """ Coax category not conforming to field 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 field rules """ coax_bowstyle = bowstyle coax_gender = gender if age_group in (AGB_ages.UNDER_21): coax_age_group = AGB_ages.ADULT else: coax_age_group = age_group return { "bowstyle": coax_bowstyle, "gender": coax_gender, "age_group": coax_age_group, }
def _make_agb_field_classification_dict() -> dict[str, GroupData]: """ Generate AGB field 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 2025 ArcheryGB age groups and classifications. Parameters ---------- None Returns ------- classification_dict : dict of str : GroupData 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 2025 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2025) """ # 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_field = cls_funcs.read_classes_json("agb_field") agb_classes_field = agb_classes_info_field["classes"] agb_classes_field_long = agb_classes_info_field["classes_long"] # Generate dict of classifications # loop over all bowstyles, genders, ages classification_dict = {} for bowstyle, gender, age in itertools.product( field_bowstyles, AGB_genders, field_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_field_groupname(bowstyle, gender, age) # Get max dists for category from json file data # Use metres as corresponding yards >= metric min_dists, max_distance = _assign_dists(bowstyle, agb_age_data[age.name]) # 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_field"], agb_bowstyle_data[bowstyle.name]["genderStep_field"], ) # set handicap threshold values for all classifications in the category class_hc = ( agb_bowstyle_data[bowstyle.name]["datum_field"] + delta_hc_age_gender + (np.arange(len(agb_classes_field)) - 2) * agb_bowstyle_data[bowstyle.name]["classStep_field"] ).astype(np.float64) groupdata: GroupData = { "classes": agb_classes_field, "classes_long": agb_classes_field_long, "class_HC": class_hc, "max_distance": max_distance, "min_dists": min_dists, } classification_dict[groupname] = groupdata return classification_dict def _assign_dists( bowstyle: AGB_bowstyles, age: cls_funcs.AGBAgeData, ) -> tuple[npt.NDArray[np.float64], float]: """ Assign appropriate distance required for a category and classification. Appropriate for 2025 ArcheryGB field age groups and classifications. Parameters ---------- bowstyle : str, string defining bowstyle age : dict[str, any], Typed dict containing age group data Returns ------- tuple ndarray of minimum distances required for each classification for this bowstyle int of maximum distance that is shot by this bowstyle References ---------- ArcheryGB 2024 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2024) World Archery Rulebook """ # WA # Red - R/C/CL # Blue - Barebow, U18 R/C # Yellow - U18 BB # # AGB # U18 R/C/CL Red, Others Blue # U15 All Blue, R/C Red, Others White # U12 R/C/CL Red, All Blue, All White, if bowstyle in sighted_bowstyles: min_d, max_d = age["sighted"] else: min_d, max_d = age["unsighted"] n_classes: int = 9 # [EMB, GMB, MB, B1, B2, B3, A1, A2, A3] # EMB to bowman requires a minimum appropriate distance # Archer tiers can be shot at shorter pegs (min dist reduced by 10m for each tier) min_dists = np.zeros(n_classes, dtype=np.float64) min_dists[0:6] = min_d min_dists[6:9] = np.maximum(min_d - 10 * np.arange(1, 4), 30) return min_dists, max_d agb_field_classifications = _make_agb_field_classification_dict() del _make_agb_field_classification_dict def _check_round_eligibility(archery_round: Round | str) -> Tuple[Round, str]: """ Check round is eligible for field 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_FIELD_ROUNDS: roundname = archery_round elif ( isinstance(archery_round, Round) and archery_round in ALL_FIELD_ROUNDS.values() ): # Get string key for this round: roundname = list(ALL_FIELD_ROUNDS.keys())[ list(ALL_FIELD_ROUNDS.values()).index(archery_round) ] else: error = ( "This round is not recognised for the purposes of field classification.\n" "Please select an appropriate option using `archeryutils.load_rounds`." ) raise ValueError(error) # Enforce unmarked/mixed being same score as marked roundname = roundname.replace("unmarked", "marked") roundname = roundname.replace("mixed", "marked") archery_round = ALL_FIELD_ROUNDS[roundname] return archery_round, roundname
[docs] def calculate_agb_field_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 field classification from score. Calculate a classification from a score given suitable inputs. Appropriate for 2025 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 field rules gender : AGB_genders archer's gender under AGB field rules age_group : AGB_ages archer's age group under AGB field rules strict_rounds : bool, default=True Whether to enforce valid AGB field rounds and apply prestige rounds rules. If False then `archery_round` must be of type `Round` for explicit clarity. Any max-distance rounds return MB-tier classifications (not just 24-target). strict_distance : bool, default=True Whether to enforce age- and bowstyle-dependent upper and lower distance restrictions. Includes removing red-peg (>50m) for unsighted bowstyles and >60m for sighted. Returns ------- classification_from_score : str abbreviation of the classification appropriate for this score References ---------- ArcheryGB 2025 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2025) 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 >>> wa_field = load_rounds.WA_field >>> cf.calculate_agb_field_classification( ... 177, ... wa_field.wa_field_24_blue_marked, ... cf.AGB_bowstyles.TRADITIONAL, ... cf.AGB_genders.OPEN, ... 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 all_class_scores = agb_field_classification_scores( archery_round, bowstyle, gender, age_group, strict_rounds=strict_rounds, strict_distance=strict_distance, ) groupname = _get_field_groupname(bowstyle, gender, age_group) group_data = agb_field_classifications[groupname] class_data = dict(zip(group_data["classes"], all_class_scores, strict=True)) class_data = dict(zip(group_data["classes"], all_class_scores, strict=True)) # Of the classes remaining, what is the highest classification this score gets? # < 0 handles 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_field_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 field classification scores for category. Subroutine to calculate classification scores for a specific category and round. Appropriate for 2025 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 : str archer's bowstyle under AGB field target rules gender : str archer's gender under AGB field target rules age_group : str archer's age group under AGB field target rules strict_rounds : bool, default=True Whether to enforce valid AGB field rounds and apply prestige rounds rules. If False then `archery_round` must be of type `Round` for explicit clarity. Any max-distance rounds return MB-tier classifications (not just 24-target). strict_distance : bool, default=True Whether to enforce age- and bowstyle-dependent upper and lower distance restrictions. Includes removing red-peg (>50m) for unsighted bowstyles and >60m for sighted. Returns ------- classification_scores : ndarray abbreviation of the classification appropriate for this score References ---------- ArcheryGB 2025 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2025) 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 >>> wa_field = load_rounds.WA_field >>> cf.agb_field_classification_scores( ... wa_field.wa_field_24_red_marked, ... cf.AGB_bowstyles.COMPOUND, ... cf.AGB_genders.OPEN, ... cf.AGB_ages.ADULT, ... ) [408, 391, 369, 345, 318, 286, 248, 204, 157] If a classification cannot be achieved a fill value of `-9999` is returned: >>> cf.agb_field_classification_scores( ... wa_field.wa_field_12_red_unmarked, ... cf.AGB_bowstyles.COMPOUND, ... cf.AGB_genders.OPEN, ... cf.AGB_ages.ADULT, ... ) [-9999, -9999, -9999, 173, 159, 143, 124, 102, 79], """ if strict_rounds: archery_round, roundname = _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) 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_field_groupname(bowstyle, gender, age_group) group_data = agb_field_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 # Based on round length (24 targets for MB) if strict_rounds and "wa_field_24_" not in archery_round.codename: class_scores[0:3] = [-9999] * 3 # What classes are eligible based on category and distance if strict_distance: round_max_dist = archery_round.max_distance().value for i in range(len(class_scores)): # What classes are eligible based on category and distance # Is round too short? if group_data["min_dists"][i] > round_max_dist: class_scores[i] = -9999 # Is peg too long (i.e. red peg for unsighted)? if group_data["max_distance"] < 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