Skip to content

Commit fd2f524

Browse files
Merge pull request #59 from TomHall2020/generic-scoring
Generic scoring
2 parents e59da86 + 879e9bc commit fd2f524

21 files changed

+1117
-564
lines changed

archeryutils/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
from archeryutils import classifications, handicaps
44
from archeryutils.rounds import Pass, Round
5-
from archeryutils.targets import Target
5+
from archeryutils.targets import Quantity, Target
66
from archeryutils.utils import versions
77

88
__all__ = [
99
"classifications",
1010
"handicaps",
1111
"Pass",
1212
"Round",
13+
"Quantity",
1314
"Target",
1415
"versions",
1516
]

archeryutils/classifications/agb_outdoor_classifications.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ def _assign_outdoor_prestige(
325325

326326
# Check all other rounds based on distance
327327
for roundname in distance_check:
328-
if ALL_OUTDOOR_ROUNDS[roundname].max_distance() >= np.min(max_dist):
328+
if ALL_OUTDOOR_ROUNDS[roundname].max_distance().value >= np.min(max_dist):
329329
prestige_rounds.append(roundname)
330330

331331
return prestige_rounds
@@ -469,7 +469,7 @@ def _check_prestige_distance(
469469

470470
# If not prestige, what classes are ineligible based on distance
471471
to_del: list[str] = []
472-
round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance()
472+
round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance().value
473473
for class_i_name, class_i_data in class_data.items():
474474
if class_i_data["min_dist"] > round_max_dist:
475475
to_del.append(class_i_name)
@@ -557,7 +557,7 @@ def agb_outdoor_classification_scores(
557557
class_scores[0:3] = [-9999] * 3
558558

559559
# If not prestige, what classes are eligible based on category and distance
560-
round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance()
560+
round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance().value
561561
for i in range(3, len(class_scores)):
562562
if group_data["min_dists"][i] > round_max_dist:
563563
class_scores[i] = -9999

archeryutils/constants.py

-155
This file was deleted.

archeryutils/handicaps/handicap_scheme.py

+50-96
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
3232
"""
3333

34+
import itertools as itr
3435
import warnings
3536
from abc import ABC, abstractmethod
3637
from typing import Optional, TypeVar, Union, overload
@@ -42,6 +43,18 @@
4243

4344
FloatArray = TypeVar("FloatArray", float, npt.NDArray[np.float64])
4445

46+
# itertools.pairwise not available until python 3.10
47+
# workaround can be removed when support for 3.9 is dropped
48+
# ignore for coverage (runner is > 3.10, ci shows this works on 3.9)
49+
if not hasattr(itr, "pairwise"): # pragma: no cover
50+
51+
def _pairwise(iterable):
52+
a, b = itr.tee(iterable)
53+
next(b, None)
54+
return zip(a, b)
55+
56+
setattr(itr, "pairwise", _pairwise) # noqa: B010
57+
4558

4659
class HandicapScheme(ABC):
4760
r"""
@@ -162,7 +175,7 @@ def sigma_r(self, handicap: FloatArray, dist: float) -> FloatArray:
162175
sig_r = dist * sig_t
163176
return sig_r
164177

165-
def arrow_score( # noqa: PLR0912 Too many branches
178+
def arrow_score(
166179
self,
167180
handicap: FloatArray,
168181
target: targets.Target,
@@ -216,104 +229,45 @@ def arrow_score( # noqa: PLR0912 Too many branches
216229
arw_d = self.arw_d_out
217230

218231
arw_rad = arw_d / 2.0
219-
220-
tar_dia = target.diameter
232+
spec = target.face_spec
221233
sig_r = self.sigma_r(handicap, target.distance)
234+
return self._s_bar(spec, arw_rad, sig_r)
222235

223-
if target.scoring_system == "5_zone":
224-
s_bar = (
225-
9.0
226-
- 2.0
227-
* sum(
228-
np.exp(-((((n * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
229-
for n in range(1, 5)
230-
)
231-
- np.exp(-((((5.0 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
232-
)
233-
234-
elif target.scoring_system == "10_zone":
235-
s_bar = 10.0 - sum(
236-
np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
237-
for n in range(1, 11)
238-
)
239-
240-
elif target.scoring_system == "10_zone_6_ring":
241-
s_bar = (
242-
10.0
243-
- sum(
244-
np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
245-
for n in range(1, 6)
246-
)
247-
- 5.0 * np.exp(-((((6.0 * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
248-
)
249-
250-
elif target.scoring_system == "10_zone_compound":
251-
s_bar = (
252-
10.0
253-
- np.exp(-((((tar_dia / 40.0) + arw_rad) / sig_r) ** 2))
254-
- sum(
255-
np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
256-
for n in range(2, 11)
257-
)
258-
)
259-
260-
elif target.scoring_system == "10_zone_5_ring":
261-
s_bar = (
262-
10.0
263-
- sum(
264-
np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
265-
for n in range(1, 5)
266-
)
267-
- 6.0 * np.exp(-((((5.0 * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
268-
)
269-
270-
elif target.scoring_system == "10_zone_5_ring_compound":
271-
s_bar = (
272-
10.0
273-
- np.exp(-((((tar_dia / 40) + arw_rad) / sig_r) ** 2))
274-
- sum(
275-
np.exp(-((((n * tar_dia / 20) + arw_rad) / sig_r) ** 2))
276-
for n in range(2, 5)
277-
)
278-
- 6.0 * np.exp(-((((5 * tar_dia / 20) + arw_rad) / sig_r) ** 2))
279-
)
280-
281-
elif target.scoring_system == "WA_field":
282-
s_bar = (
283-
6.0
284-
- np.exp(-((((tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
285-
- sum(
286-
np.exp(-((((n * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
287-
for n in range(1, 6)
288-
)
289-
)
290-
291-
elif target.scoring_system == "IFAA_field":
292-
s_bar = (
293-
5.0
294-
- np.exp(-((((tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
295-
- np.exp(-((((3.0 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
296-
- 3.0 * np.exp(-((((5.0 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
297-
)
298-
299-
elif target.scoring_system == "Beiter_hit_miss":
300-
s_bar = 1.0 - np.exp(-((((tar_dia / 2.0) + arw_rad) / sig_r) ** 2))
301-
302-
elif target.scoring_system in ("Worcester", "IFAA_field_expert"):
303-
s_bar = 5.0 - sum(
304-
np.exp(-((((n * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
305-
for n in range(1, 6)
306-
)
236+
def _s_bar(
237+
self, target_specs: targets.FaceSpec, arw_rad: float, sig_r: FloatArray
238+
) -> FloatArray:
239+
"""Calculate expected score directly from target ring sizes.
307240
308-
elif target.scoring_system == "Worcester_2_ring":
309-
s_bar = (
310-
5.0
311-
- np.exp(-((((tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
312-
- 4.0 * np.exp(-((((2 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
313-
)
314-
# No need for else with error as invalid scoring systems handled in Target class
241+
Parameters
242+
----------
243+
target_specs : FaceSpec
244+
Mapping of target ring *diameters* in [metres], to points scored
245+
arw_rad : float
246+
arrow radius in [metres]
247+
sig_r : float
248+
standard deviation of group size [metres]
315249
316-
return s_bar
250+
Returns
251+
-------
252+
s_bar : float
253+
expected average score per arrow
254+
255+
Notes
256+
-----
257+
Assumes that:
258+
- target rings are concentric
259+
- score decreases monotonically as ring sizes increase
260+
"""
261+
target_specs = dict(sorted(target_specs.items()))
262+
ring_sizes = target_specs.keys()
263+
ring_scores = list(itr.chain(target_specs.values(), [0]))
264+
score_drops = (inner - outer for inner, outer in itr.pairwise(ring_scores))
265+
max_score = max(ring_scores)
266+
267+
return max_score - sum(
268+
score_drop * np.exp(-(((arw_rad + (ring_diam / 2)) / sig_r) ** 2))
269+
for ring_diam, score_drop in zip(ring_sizes, score_drops)
270+
)
317271

318272
def score_for_passes(
319273
self,
@@ -505,7 +459,7 @@ def handicap_from_score(
505459
To get an integer value as would appear in the handicap tables use
506460
``int_prec=True``:
507461
508-
>>> agb_scheme.handicap_from_score(999, wa_outdoor.wa1440_90), int_prec=True)
462+
>>> agb_scheme.handicap_from_score(999, wa_outdoor.wa1440_90, int_prec=True)
509463
44.0
510464
511465
"""

0 commit comments

Comments
 (0)