Skip to content

Commit ea6c3e7

Browse files
committed
Replace implementation of arrow_score with generic calculation from face specification
arrow_score no longer dispatches on scoring system and does not have magic numbers
1 parent 16976f2 commit ea6c3e7

File tree

2 files changed

+48
-92
lines changed

2 files changed

+48
-92
lines changed

archeryutils/handicaps/handicap_scheme.py

+47-91
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import warnings
3535
from abc import ABC, abstractmethod
3636
from typing import Optional, TypeVar, Union, overload
37+
import itertools as itr
3738

3839
import numpy as np
3940
import numpy.typing as npt
@@ -42,6 +43,17 @@
4243

4344
FloatArray = TypeVar("FloatArray", float, npt.NDArray[np.float_])
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)
4557

4658
class HandicapScheme(ABC):
4759
r"""
@@ -216,104 +228,48 @@ def arrow_score(
216228
arw_d = self.arw_d_out
217229

218230
arw_rad = arw_d / 2.0
219-
220-
tar_dia = target.diameter
231+
spec = target.get_face_spec()
221232
sig_r = self.sigma_r(handicap, target.distance)
233+
s_bar = self._s_bar(spec, arw_rad, sig_r)
222234

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(2, 7)
288-
)
289-
)
235+
return s_bar
290236

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-
)
237+
def _s_bar(
238+
self, target_specs: dict[float, int], arw_rad: float, sig_r: FloatArray
239+
) -> FloatArray:
240+
"""Calculate expected score directly from target ring sizes.
298241
299-
elif target.scoring_system == "Beiter_hit_miss":
300-
s_bar = 1.0 - np.exp(-((((tar_dia / 2.0) + arw_rad) / sig_r) ** 2))
242+
Parameters
243+
----------
244+
target_specs : dict[float, int]
245+
Mapping of target ring *diameters* in [metres], to points scored
246+
arw_d : float
247+
arrow diameter in [metres]
248+
sig_r : float
249+
standard deviation of group size [metres]
301250
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-
)
251+
Returns
252+
-------
253+
s_bar : float
254+
expected average score per arrow
255+
256+
Notes
257+
-----
258+
Assumes that:
259+
- target rings are concentric
260+
- score decreases monotonically as ring sizes increase
261+
"""
262+
target_specs = dict(sorted(target_specs.items()))
263+
ring_sizes = target_specs.keys()
264+
ring_scores = list(itr.chain(target_specs.values(), [0]))
307265

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 else raising error needed as invalid scoring systems handled in Target class
266+
score_drops = (inner - outer for inner, outer in itr.pairwise(ring_scores))
267+
max_score = max(ring_scores)
315268

316-
return s_bar
269+
return max_score - sum(
270+
score_drop * np.exp(-(((arw_rad + (ring_diam / 2)) / sig_r) ** 2))
271+
for ring_diam, score_drop in zip(ring_sizes, score_drops)
272+
)
317273

318274
def score_for_passes(
319275
self,

archeryutils/handicaps/tests/test_handicaps.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ def test_different_handicap_systems(
340340
("10_zone_6_ring", 7.397557278),
341341
("10_zone_5_ring", 7.059965360),
342342
("10_zone_5_ring_compound", 6.993772436),
343-
("WA_field", 4.807397627),
343+
("WA_field", 4.115600784),
344344
("IFAA_field", 4.265744101),
345345
("IFAA_field_expert", 4.021942762),
346346
("Beiter_hit_miss", 0.9998380401),

0 commit comments

Comments
 (0)