Source code for archeryutils.classifications.agb_old_indoor_classifications

"""
Code for calculating old (pre-2023) Archery GB indoor classifications.

Routine Listings
----------------
calculate_AGB_old_indoor_classification
AGB_old_indoor_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_INDOOR_ROUNDS = load_rounds.read_json_to_round_dict(
    [
        "AGB_indoor.json",
        "WA_indoor.json",
    ],
)

old_indoor_bowstyles = AGB_bowstyles.COMPOUND | AGB_bowstyles.RECURVE
old_indoor_ages = AGB_ages.ADULT


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

    classes: list[str]
    class_HC: npt.NDArray[np.float64]


def _get_old_indoor_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_indoor_bowstyles:
        msg = (
            f"{bowstyle} is not a recognised bowstyle for old indoor classifications. "
            f"Please select from `{old_indoor_bowstyles}`."
        )
        raise ValueError(msg)
    if gender not in AGB_genders:
        msg = (
            f"{gender} is not a recognised gender group for old indoor "
            "classifications. Please select from `archeryutils.AGB_genders`."
        )
        raise ValueError(msg)
    if age_group not in AGB_ages or age_group not in old_indoor_ages:
        msg = (
            f"{age_group} is not a recognised age group for old indoor "
            f"classifications. Please select from `{old_indoor_ages}`."
        )
        raise ValueError(msg)
    return cls_funcs.get_groupname(bowstyle, gender, age_group)


[docs] def coax_old_indoor_group( bowstyle: AGB_bowstyles, gender: AGB_genders, age_group: AGB_ages, # noqa: ARG001 - Unused argument for consistency with other classification schemes ) -> cls_funcs.AGBCategory: """ Coax category not conforming to old indoor 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 indoor rules """ if bowstyle in ( AGB_bowstyles.COMPOUND | AGB_bowstyles.COMPOUNDLIMITED | AGB_bowstyles.COMPOUNDBAREBOW ): coax_bowstyle = AGB_bowstyles.COMPOUND else: coax_bowstyle = AGB_bowstyles.RECURVE coax_gender = gender coax_age_group = AGB_ages.ADULT return { "bowstyle": coax_bowstyle, "gender": coax_gender, "age_group": coax_age_group, }
def _make_agb_old_indoor_classification_dict() -> dict[str, GroupData]: """ 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 compound_male_adult: GroupData = { "classes": agb_indoor_classes, "class_HC": np.array([5, 12, 24, 37, 49, 62, 73, 79]), } compound_female_adult: GroupData = { "classes": agb_indoor_classes, "class_HC": np.array([12, 18, 30, 43, 55, 67, 79, 83]), } recurve_male_adult: GroupData = { "classes": agb_indoor_classes, "class_HC": np.array([14, 21, 33, 46, 58, 70, 80, 85]), } recurve_female_adult: GroupData = { "classes": agb_indoor_classes, "class_HC": np.array([21, 27, 39, 51, 64, 75, 85, 90]), } classification_dict = { _get_old_indoor_groupname( AGB_bowstyles.COMPOUND, AGB_genders.MALE, AGB_ages.ADULT ): compound_male_adult, _get_old_indoor_groupname( AGB_bowstyles.COMPOUND, AGB_genders.FEMALE, AGB_ages.ADULT ): compound_female_adult, _get_old_indoor_groupname( AGB_bowstyles.RECURVE, AGB_genders.MALE, AGB_ages.ADULT ): recurve_male_adult, _get_old_indoor_groupname( AGB_bowstyles.RECURVE, AGB_genders.FEMALE, AGB_ages.ADULT ): recurve_female_adult, } return classification_dict agb_old_indoor_classifications = _make_agb_old_indoor_classification_dict() del _make_agb_old_indoor_classification_dict def _check_round_eligibility(archery_round: Round | str) -> Tuple[Round, str]: """ Check round is eligible for old indoor 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_INDOOR_ROUNDS: roundname = archery_round archery_round = ALL_INDOOR_ROUNDS[roundname] elif ( isinstance(archery_round, Round) and archery_round in ALL_INDOOR_ROUNDS.values() ): # Get string key for this round: roundname = list(ALL_INDOOR_ROUNDS.keys())[ list(ALL_INDOOR_ROUNDS.values()).index(archery_round) ] else: error = ( "This round is not recognised for the purposes of indoor classification.\n" "Please select an appropriate option using `archeryutils.load_rounds`." ) raise ValueError(error) return archery_round, roundname
[docs] def calculate_agb_old_indoor_classification( score: float, archery_round: Round | str, bowstyle: AGB_bowstyles, gender: AGB_genders, age_group: AGB_ages, strict_rounds: bool = True, ) -> 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 ---------- 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 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 strict_rounds : bool Whether to enforce valid AGB rounds only Returns ------- classification_from_score : str the classification appropriate for this score 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 References ---------- ArcheryGB Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (pre-2023) Examples -------- >>> from archeryutils import classifications as cf >>> from archeryutils import load_rounds >>> agb_indoor = load_rounds.AGB_indoor >>> cf.calculate_agb_old_indoor_classification( ... 547, ... agb_indoor.wa18, ... AGB_bowstyles.COMPOUND, ... AGB_genders.MALE, ... AGB_ages.ADULT, ... ) 'C' """ 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 class_scores = agb_old_indoor_classification_scores( archery_round, bowstyle, gender, age_group, strict_rounds=strict_rounds, ) groupname = _get_old_indoor_groupname(bowstyle, gender, age_group) group_data = agb_old_indoor_classifications[groupname] class_data = dict(zip(group_data["classes"], class_scores, strict=True)) # What is the highest classification this score gets? # < 0 handles max scores, > score handles higher classifications # NB No fiddle for Worcester required with this logic... # Beware of this later on, however, if we wish to rectify the 'anomaly' for classname, classscore in class_data.items(): if classscore > score: continue else: return classname return "UC"
[docs] def agb_old_indoor_classification_scores( archery_round: Round | str, bowstyle: AGB_bowstyles, gender: AGB_genders, age_group: AGB_ages, strict_rounds: bool = True, ) -> 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 ---------- 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 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 strict_rounds : bool Whether to enforce valid AGB rounds only Returns ------- classification_scores : ndarray scores required for each classification in descending order 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 References ---------- ArcheryGB Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 Examples -------- >>> from archeryutils import classifications as cf >>> from archeryutils import load_rounds >>> agb_outdoor = load_rounds.AGB_indoor >>> cf.agb_old_indoor_classification_scores( ... agb_indoor.portsmouth, ... AGB_bowstyles.RECURVE, ... AGB_genders.MALE, ... AGB_ages.ADULT, ... ) [592, 582, 554, 505, 432, 315, 195, 139] """ if strict_rounds: archery_round, roundname = _check_round_eligibility(archery_round) # enforce compound scoring if bowstyle is AGB_bowstyles.COMPOUND: roundname = cls_funcs.get_compound_codename(roundname) archery_round = ALL_INDOOR_ROUNDS[roundname] # Strip spots from AGB rounds to enforce full-face if any( namecatch in roundname for namecatch in ["portsmouth", "worcester", "bray_i", "bray_ii"] ): archery_round = ALL_INDOOR_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) groupname = _get_old_indoor_groupname(bowstyle, gender, age_group) group_data = agb_old_indoor_classifications[groupname] # Get scores required on this round for each classification class_scores = [ hc.score_for_round( group_data["class_HC"][i], archery_round, "AGBold", rounded_score=True, ) for i in range(len(group_data["classes"])) ] # 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