"""Handicap scheme class for performing calculations using a generic handicap scheme.
Extended Summary
----------------
Calculates arrow and round scores for a variety of target faces of
given distance and diameter:
- 5-zone
- 10-zone
- 10-zone 6-ring
- 10-zone compound
- 10-zone 5-ring
- 10-zone 5-ring compound
- WA_field
- IFAA_field
- Beiter-hit-miss
- Worcester
- Worcester 2-ring
References
----------
- The construction of the graduated handicap tables for target
archery
Lane, D (2013)
https://www.jackatkinson.net/files/Handicap_Tables_2013.pdf
- New AGB: Atkinson, J
- Modelling archers’ scores at different distances to quantify
score loss due to equipment selection and technique errors
Park, J (2014)
https://doi.org/10.1177%2F1754337114539308
"""
import itertools as itr
import warnings
from abc import ABC, abstractmethod
import numpy as np
import numpy.typing as npt
from archeryutils import rounds, targets
def _cast_float_array(var_in: npt.ArrayLike) -> npt.NDArray[np.float64]:
"""Ensure we can cast to a np.float64 array and do so.
Parameters
----------
var_in : ArrayLike
input that is an ArrayLike
Returns
-------
NDArray[np.float64]
var_in cast to NDArray[np.float64]
Raises
------
TypeError
If an ArrayLike that cannot be cast is passed in.
"""
try:
return np.asarray(var_in, dtype=np.float64)
except ValueError as exc:
err_msg = f"Inappropriate input for handicaps code. Must be numeric value."
raise TypeError(err_msg) from None
[docs]
class HandicapScheme(ABC):
r"""
Abstract Base Class to represent a generic handicap scheme.
Attributes
----------
name : str
The name of the handicap scheme.
arw_d_out : float
diameter of an outdoor arrow [metres] for this scheme, default 5.5e-3
arw_d_in: float
diameter of an indoor arrow [metres] for this scheme, default 9.3e-3
desc_scale: bool
does the scheme use a descending scale (lower handicap is better), default True
scale_bounds: NDArray[np.float64]
Reasonable upper and lower bounds on the handicap scale for bounding searches
max_score_rounding_lim: float
Limit to round the max score to when searching
depends on scheme rounding method e.g. round() vs. ceil() etc.
See Also
--------
handicap_scheme_agb.HandicapAGB :
The AGB HandicapScheme subclass and associated \\**kwargs
handicap_scheme_agb.HandicapAGBold :
The AGBold HandicapScheme subclass and associated \\**kwargs
handicap_scheme_aa.HandicapAA :
The AA HandicapScheme subclass and associated \\**kwargs
handicap_scheme_aa.HandicapAA2 :
The AA2 HandicapScheme subclass and associated \\**kwargs
"""
def __init__(self) -> None:
self.name: str = "unnamed"
# Arrow diameters outdoor and indoor
self.arw_d_out: float
self.arw_d_in: float
# Handicap Scale parameters
self.desc_scale: bool
self.scale_bounds: npt.NDArray[np.float64]
self.max_score_rounding_lim: float
def __repr__(self) -> str:
"""Return a representation of a HandicapScheme instance."""
return f"<HandicapScheme: '{self.name}'>"
[docs]
@abstractmethod
def sigma_t(self, handicap: npt.ArrayLike, dist: float) -> npt.NDArray[np.float64]:
"""Calculate angular deviation for given handicap and distance.
Parameters
----------
handicap : ArrayLike
handicap(s) to calculate sigma_t at
dist : float
distance to target [metres]
Returns
-------
sig_t : NDArray[np.float64]
angular deviation [rad]
"""
[docs]
def sigma_r(self, handicap: npt.ArrayLike, dist: float) -> npt.NDArray[np.float64]:
"""Calculate radial deviation for a given handicap and distance.
Standard deviation as a proxy for 'group size' based on
handicap parameters, scheme, and distance.
Wraps around sigma_t() and multiplies by distance.
Parameters
----------
handicap : ArrayLike
handicap(s) to calculate sigma_r at
dist : float
distance to target [metres]
Returns
-------
sig_r : NDArray[np.float64]
standard deviation of group size [metres]
Examples
--------
Deviation (in metres) at a distance of 25m and a handicap of 10,
using the AGB handicap system (via the HandicapSchemeAGB subclass)
can be calculated with:
>>> import archeryutils.handicaps as hc
>>> agb_scheme = hc.handicap_scheme("AGB")
>>> agb_scheme.sigma_r(10.0, 25.0)
0.023745700245257646
It can also be passed an array of handicaps:
>>> agb_scheme.sigma_r(np.asarray([10.0, 50.0, 100.0]), 25.0)
array([0.0237457 , 0.09401539, 0.5250691 ])
"""
sig_t = self.sigma_t(handicap, dist)
sig_r = dist * sig_t
return sig_r
[docs]
def arrow_score(
self,
handicap: npt.ArrayLike,
target: targets.Target,
arw_d: float | None = None,
) -> npt.NDArray[np.float64]:
"""Calculate the average arrow score for a given target and handicap rating.
Parameters
----------
handicap : ArrayLike
handicap(s) to calculate score for
target : targets.Target
A Target class specifying the target to be used
arw_d : float | None, default=None
user-specified arrow diameter in [metres]
Returns
-------
s_bar : NDArray[np.float64]
average score of the arrow for this set of parameters
References
----------
- The construction of the graduated handicap tables for target archery
Lane, D (2013)
Examples
--------
Expected arrow score on a WA720 70m target at a handicap of 10,
using the AGB handicap system (via the HandicapSchemeAGB subclass)
can be calculated with:
>>> import archeryutils as au
>>> import archeryutils.handicaps as hc
>>> my720target = au.Target("10_zone", 122, 70.0)
>>> agb_scheme = hc.handicap_scheme("AGB")
>>> agb_scheme.arrow_score(10.0, my720target)
9.401182682963338
It can also be passed an array of handicaps:
>>> agb_scheme.arrow_score(np.array([10.0, 50.0, 100.0]), my720target)
array([9.40118268, 6.05227962, 0.46412515])
"""
# Set arrow diameter. Use scheme default based on in/outdoors if none provided.
if arw_d is None:
if target.indoor:
arw_d = self.arw_d_in
else:
arw_d = self.arw_d_out
arw_rad = arw_d / 2.0
spec = target.face_spec
sig_r = self.sigma_r(handicap, target.distance)
return self._s_bar(spec, arw_rad, sig_r)
def _s_bar(
self,
target_specs: targets.FaceSpec,
arw_rad: float,
sig_r: npt.NDArray[np.float64],
) -> npt.NDArray[np.float64]:
"""Calculate expected score directly from target ring sizes.
Parameters
----------
target_specs : FaceSpec
Mapping of target ring *diameters* in [metres], to points scored
arw_rad : float
arrow radius in [metres]
sig_r : NDArray[np.float64]
standard deviation of group size [metres]
Returns
-------
s_bar : NDArray[np.float64]
expected average score per arrow
Notes
-----
Assumes that:
- target rings are concentric
- score decreases monotonically as ring sizes increase
"""
target_specs = dict(sorted(target_specs.items()))
ring_sizes = target_specs.keys()
ring_scores = list(itr.chain(target_specs.values(), [0]))
score_drops = (inner - outer for inner, outer in itr.pairwise(ring_scores))
max_score = max(ring_scores)
return max_score - np.asarray(
sum(
score_drop * np.exp(-(((arw_rad + (ring_diam / 2)) / sig_r) ** 2))
for ring_diam, score_drop in zip(ring_sizes, score_drops, strict=True)
)
)
[docs]
def score_for_passes(
self,
handicap: npt.ArrayLike,
rnd: rounds.Round,
arw_d: float | None = None,
rounded_score: bool = True,
) -> npt.NDArray[np.float64]:
"""Calculate the expected score for all passes in a round at a given handicap.
Parameters
----------
handicap : ArrayLike
handicap(s) to calculate score for
rnd : rounds.Round
A Round class specifying the round being shot
arw_d : float | None, default=None
user-specified arrow diameter in [metres]
rounded_score : bool, default=True
round score to integer value?
Note: sum of rounded passes may not be the same as the rounded round score
Returns
-------
pass_scores : NDArray[np.float64]
average score for each pass in the round
Examples
--------
Expected score for each pass on a WA1440 90m at a handicap of 10,
using the AGB handicap system (via the HandicapSchemeAGB subclass)
can be calculated with the following code which returns an array with
one score for each pass that makes up the round:
>>> import archeryutils as au
>>> import archeryutils.handicaps as hc
>>> wa_outdoor = au.load_rounds.WA_outdoor
>>> agb_scheme = hc.handicap_scheme("AGB")
>>> agb_scheme.score_for_passes(10.0, wa_outdoor.wa1440_90)
array([322.84091528, 338.44257659, 338.66395001, 355.87959411])
It can also be passed an array of handicaps:
>>> agb_scheme.score_for_passes(
... np.array([10.0, 50.0, 100.0]), wa_outdoor.wa1440_90
... )
array([[322.84091528, 162.76200686, 8.90456718],
[338.44257659, 217.88206641, 16.70850537],
[338.66395001, 216.74407488, 16.41855209],
[355.87959411, 288.77185611, 48.47897177]])
"""
pass_scores = np.array(
[
pass_i.n_arrows * self.arrow_score(handicap, pass_i.target, arw_d=arw_d)
for pass_i in rnd.passes
],
)
return self._rounded_score(pass_scores) if rounded_score else pass_scores
[docs]
def score_for_round(
self,
handicap: npt.ArrayLike,
rnd: rounds.Round,
arw_d: float | None = None,
rounded_score: bool = True,
) -> npt.NDArray[np.float64]:
"""Calculate the expected score for a round at a given handicap.
Parameters
----------
handicap : ArrayLike
handicap(s) to calculate score for
rnd : rounds.Round
A Round class specifying the round being shot
arw_d : float | None, default=None
user-specified arrow diameter in [metres]
rounded_score : bool, default=True
round score to integer value?
Returns
-------
round_score : NDArray[np.float64]
average score of the round for this set of parameters
Examples
--------
Expected score for a WA1440 90m at a handicap of 10,
using the AGB handicap system (via the HandicapSchemeAGB subclass)
can be calculated using:
>>> import archeryutils as au
>>> import archeryutils.handicaps as hc
>>> wa_outdoor = au.load_rounds.WA_outdoor
>>> agb_scheme = hc.handicap_scheme("AGB")
>>> agb_scheme.score_for_round(10.0, wa_outdoor.wa1440_90)
1356.0
To get a decimal value of the exact handicap corresponding to the requested
score use ``rounded_score=False``:
>>> agb_scheme.score_for_round(
... wa_outdoor.wa1440_90,
... 10.0,
... rounded_score=False,
... )
1355.8270359849505
It can also be passed an array of handicaps:
>>> agb_scheme.score_for_round(
... np.array([10.0, 50.0, 100.0]), wa_outdoor.wa1440_90
... )
array([1356., 887., 91.])
"""
round_score = np.sum(
self.score_for_passes(handicap, rnd, arw_d=arw_d, rounded_score=False),
axis=0,
)
return self._rounded_score(round_score) if rounded_score else round_score
@staticmethod
def _rounded_score(score: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
"""
Round a decimal score to an integer value.
Most schemes use plain rounding as implemented here.
Schemes that use floor or ceil will override in their subclass.
Parameters
----------
score : NDArray[np.float64]
raw scores to be rounded according to the handicap system convention
Returns
-------
NDArray[np.float64]
scores after appropriate rounding
"""
return np.around(score)
[docs]
def handicap_from_score(
self,
score: float,
rnd: rounds.Round,
arw_d: float | None = None,
int_prec: bool = False,
) -> int | float:
"""Calculate the handicap of a given score on a given round.
Parameters
----------
score : float
score achieved on the round
rnd : rounds.Round
the rounds.Round object to calculate the handicap for
arw_d : float | None, default=None
user-specified arrow diameter in [metres]
int_prec : bool, default=False
display results as integers? default = False
decimal results accurate to 2dp from rootfinder
Returns
-------
handicap: int | float
Handicap for score. Has type int if int_prec is True, else float.
Raises
------
ValueError
If an invalid score for the given round is provided.
Examples
--------
Handicap for a score of 999 on a WA 1440 (90m),
using the AGB handicap system (via the HandicapSchemeAGB subclass),
can be calculated using:
>>> import archeryutils as au
>>> import archeryutils.handicaps as hc
>>> wa_outdoor = au.load_rounds.WA_outdoor
>>> agb_scheme = hc.handicap_scheme("AGB")
>>> agb_scheme.score_for_round(wa_outdoor.wa1440_90, 10.0)
>>> agb_scheme.handicap_from_score(999, wa_outdoor.wa1440_90)
43.999964586102706
To get an integer value as would appear in the handicap tables use
``int_prec=True``:
>>> agb_scheme.handicap_from_score(999, wa_outdoor.wa1440_90, int_prec=True)
44.0
"""
# Check we have a valid score
max_score = rnd.max_score()
if score > max_score:
msg = (
f"The score of {score} provided is greater than the maximum of "
f"{max_score} for a {rnd.name}."
)
raise ValueError(msg)
if score <= 0.0:
msg = (
f"The score of {score} provided is less than or equal to zero "
"so cannot have a handicap."
)
raise ValueError(msg)
if score == max_score:
# Deal with max score before root finding
return self._get_max_score_handicap(rnd, arw_d, int_prec)
handicap = self._rootfind_score_handicap(score, rnd, arw_d=arw_d)
# Force integer precision if required.
if int_prec:
if self.desc_scale:
handicap = np.ceil(handicap)
else:
handicap = np.floor(handicap)
sc_int = self.score_for_round(handicap, rnd, arw_d, rounded_score=True)
# Check that you can't get the same score from a larger handicap when
# working in integers
min_h_flag = False
if self.desc_scale:
hstep = 1.0
else:
hstep = -1.0
while not min_h_flag:
handicap += hstep
sc_int = self.score_for_round(handicap, rnd, arw_d, rounded_score=True)
if sc_int < score:
handicap -= hstep # undo the iteration that caused flag to raise
min_h_flag = True
return handicap
def _get_max_score_handicap(
self,
rnd: rounds.Round,
arw_d: float | None = None,
int_prec: bool = False,
) -> int | float:
"""Get handicap for maximum score on a round.
Start high and drop down until no longer rounding to max score.
i.e. >= max_score - 1.0 for ceil(), and >= max_score - 0.5 for around().
Parameters
----------
rnd : rounds.Round
round being used
arw_d : float | None, default=None
user-specified arrow diameter in [metres]
int_prec : bool, default=False
display results as integers?
Returns
-------
handicap : int | float
Handicap for maximum score. Has type int if int_prec is True, else float.
Warns
-----
UserWarning
If called with int_prec=False as precision limit of numerical scheme delta.
"""
max_score = rnd.max_score()
if self.desc_scale:
handicap = self.scale_bounds.min()
delta_hc = 1.0
else:
handicap = self.scale_bounds.max()
delta_hc = -1.0
target = max_score - self.max_score_rounding_lim
def check_score(handicap):
return self.score_for_round(handicap, rnd, arw_d, rounded_score=False)
# Work down (coarse) to where we would round or ceil to max score
while check_score(handicap) > target:
handicap += delta_hc
# Step back extra after overshoot and reduce step size
handicap -= 1.01 * delta_hc
delta_hc /= 100
# Work down (fine) to where we would round or ceil to max score
while check_score(handicap) > target:
handicap += delta_hc
handicap -= delta_hc # Undo final iteration that overshoots
if int_prec:
if self.desc_scale:
handicap = np.floor(handicap)
else:
handicap = np.ceil(handicap)
else:
warnings.warn(
"Handicap requested for maximum score without integer precision.\n"
"Value returned will be first handicap that achieves this score.\n"
"This could cause issues if you are not working in integers.",
UserWarning,
stacklevel=3,
)
return handicap
def _rootfind_score_handicap( # noqa: PLR0912, PLR0914, PLR0915, RUF100 Too many: branches, locals, statements
self,
score: float,
rnd: rounds.Round,
arw_d: float | None = None,
) -> float:
"""Get handicap for general score on a round through rootfinding algorithm.
Parameters
----------
score : float
score to get handicap for
rnd : rounds.Round
round being used
arw_d : float | None, default=None
user-specified arrow diameter in [metres]
Returns
-------
handicap : float
appropriate accurate handicap for this score
References
----------
Brent's Method for Root Finding in Scipy:
- https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.brentq.html
- https://github.com/scipy/scipy/blob/dde39b7/scipy/optimize/Zeros/brentq.c
"""
x_init = self.scale_bounds
f_init = [
self._f_root(x_init[0], score, rnd, arw_d=arw_d),
self._f_root(x_init[1], score, rnd, arw_d=arw_d),
]
xtol = 1.0e-16
rtol = 0.00
xblk = 0.0
fblk = 0.0
scur = 0.0
spre = 0.0
dpre = 0.0
dblk = 0.0
stry = 0.0
if abs(f_init[1]) <= f_init[0]:
xcur = x_init[1]
xpre = x_init[0]
fcur = f_init[1]
fpre = f_init[0]
else:
xpre = x_init[1]
xcur = x_init[0]
fpre = f_init[1]
fcur = f_init[0]
for _ in range(25):
if (fpre != 0.0) and (fcur != 0.0) and (np.sign(fpre) != np.sign(fcur)):
xblk = xpre
fblk = fpre
spre = xcur - xpre
scur = xcur - xpre
if abs(fblk) < abs(fcur):
# xpre <- xcu, xcur <- xblk, xblk <- xpre
xpre, xcur, xblk = xcur, xblk, xcur
# fpre <- fcur, fcur <- fblk, fblk <- fpre
fpre, fcur, fblk = fcur, fblk, fcur
delta = (xtol + rtol * abs(xcur)) / 2.0
sbis = (xblk - xcur) / 2.0
if (fcur == 0.0) or (abs(sbis) < delta):
handicap = xcur
break
if (abs(spre) > delta) and (abs(fcur) < abs(fpre)):
if xpre == xblk:
stry = (
-fcur
* (xcur - xpre)
/ ((fcur - xpre) if (fcur - xpre) != 0 else xtol)
)
else:
dpre = (fpre - fcur) / (xpre - xcur)
dblk = (fblk - fcur) / (xblk - xcur)
stry = -fcur * (fblk - fpre) / (fblk * dpre - fpre * dblk)
if 2 * abs(stry) < min(abs(spre), 3 * abs(sbis) - delta):
# accept step
spre = scur
scur = stry
else:
# bisect
spre = sbis
scur = sbis
else:
# bisect
spre = sbis
scur = sbis
xpre = xcur
fpre = fcur
if abs(scur) > delta:
xcur += scur
elif sbis > 0:
xcur += delta
else:
xcur -= delta
fcur = self._f_root(xcur, score, rnd, arw_d)
handicap = xcur
return handicap
def _f_root(
self,
hc_est: float,
score_est: float,
round_est: rounds.Round,
arw_d: float | None = None,
) -> float:
"""Return error between predicted score and desired score.
Parameters
----------
hc_est : float
current estimate of handicap
score_est : float
target score
round_est : rounds.Round
round being used
arw_d : float | None, default=None
arrow diameter in [metres]
Returns
-------
float
difference between desired value and score estimate
"""
val = self.score_for_round(hc_est, round_est, arw_d=arw_d, rounded_score=False)
# val is known to be a 0D array, so cast to float for subsequent use
return float(val) - score_est