"""
Code for calculating Archery GB indoor classifications.
Routine Listings
----------------
calculate_agb_indoor_classification
agb_indoor_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_INDOOR_ROUNDS = load_rounds.read_json_to_round_dict(
[
"AGB_indoor.json",
"WA_indoor.json",
],
)
indoor_bowstyles = (
AGB_bowstyles.COMPOUND
| AGB_bowstyles.RECURVE
| AGB_bowstyles.BAREBOW
| AGB_bowstyles.LONGBOW
)
class GroupData(TypedDict):
"""Structure for AGB Indoor classification data."""
classes: list[str]
classes_long: list[str]
class_HC: npt.NDArray[np.float64]
def _get_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 indoor_bowstyles:
msg = (
f"{bowstyle} is not a recognised bowstyle for indoor classifications. "
f"Please select from `{indoor_bowstyles}`."
)
raise ValueError(msg)
if gender not in AGB_genders:
msg = (
f"{gender} is not a recognised gender group for indoor 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 indoor classifications. "
"Please select from `archeryutils.AGB_ages`."
)
raise ValueError(msg)
return cls_funcs.get_groupname(bowstyle, gender, age_group)
[docs]
def coax_indoor_group(
bowstyle: AGB_bowstyles,
gender: AGB_genders,
age_group: AGB_ages,
) -> cls_funcs.AGBCategory:
"""
Coax category not conforming to 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
indoor 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_indoor_classification_dict() -> dict[str, GroupData]:
"""
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_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_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"]
# Generate dict of classifications
# loop over all bowstyles, genders, ages
classification_dict = {}
for bowstyle, gender, age in itertools.product(
indoor_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_indoor_groupname(bowstyle, gender, age)
# 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_in"],
agb_bowstyle_data[bowstyle.name]["genderStep_in"],
)
# set handicap threshold values for all classifications in the category
class_hc = (
agb_bowstyle_data[bowstyle.name]["datum_in"]
+ delta_hc_age_gender
+ (np.arange(len(agb_classes_in)) - 1)
* agb_bowstyle_data[bowstyle.name]["classStep_in"]
).astype(np.float64)
groupdata: GroupData = {
"classes": agb_classes_in,
"classes_long": agb_classes_in_long,
"class_HC": class_hc,
}
classification_dict[groupname] = groupdata
return classification_dict
agb_indoor_classifications = _make_agb_indoor_classification_dict()
del _make_agb_indoor_classification_dict
def _check_round_eligibility(archery_round: Round | str) -> Tuple[Round, str]:
"""
Check round is eligible for 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_indoor_classification(
score: float,
archery_round: Round | str,
bowstyle: AGB_bowstyles,
gender: AGB_genders,
age_group: AGB_ages,
strict_rounds: bool = True,
) -> 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
----------
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 2023 Rules of Shooting
ArcheryGB Shooting Administrative Procedures - SAP7 (2023)
Examples
--------
>>> from archeryutils import classifications as cf
>>> from archeryutils import load_rounds
>>> agb_indoor = load_rounds.AGB_indoor
>>> cf.calculate_agb_indoor_classification(
... 547,
... agb_indoor.wa18,
... cf.AGB_bowstyles.COMPOUND,
... cf.AGB_genders.OPEN,
... cf.AGB_ages.OVER_50,
... )
'I-B2'
"""
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_indoor_classification_scores(
archery_round,
bowstyle,
gender,
age_group,
strict_rounds=strict_rounds,
)
groupname = _get_indoor_groupname(bowstyle, gender, age_group)
group_data = agb_indoor_classifications[groupname]
class_data = dict(zip(group_data["classes"], all_class_scores, strict=True))
# 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_indoor_classification_scores(
archery_round: Round | str,
bowstyle: AGB_bowstyles,
gender: AGB_genders,
age_group: AGB_ages,
strict_rounds: bool = True,
) -> list[int]:
"""
Calculate 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
----------
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
References
----------
ArcheryGB Rules of Shooting
ArcheryGB Shooting Administrative Procedures - SAP7
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_indoor
>>> cf.agb_indoor_classification_scores(
... agb_indoor.portsmouth,
... cf.AGB_bowstyles.BAREBOW,
... cf.AGB_genders.OPEN,
... cf.AGB_ages.UNDER_12,
... )
[411, 360, 301, 240, 183, 134, 95, 66]
If a classification cannot be achieved a fill value of `-9999` is returned:
>>> cf.agb_indoor_classification_scores(
... agb_indoor.worcester,
... cf.AGB_bowstyles.COMPOUND,
... cf.AGB_genders.FEMALE,
... cf.AGB_ages.ADULT,
... )
[-9999, -9999, 298, 289, 276, 257, 233, 200]
"""
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[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_indoor_groupname(bowstyle, gender, age_group)
group_data = agb_indoor_classifications[groupname]
hc_scheme = "AGB"
# Get scores required on this round for each classification
# Enforce full size face
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"]))
]
# 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