"""
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