|
31 | 31 |
|
32 | 32 | """
|
33 | 33 |
|
| 34 | +import itertools as itr |
34 | 35 | import warnings
|
35 | 36 | from abc import ABC, abstractmethod
|
36 | 37 | from typing import Optional, TypeVar, Union, overload
|
|
42 | 43 |
|
43 | 44 | FloatArray = TypeVar("FloatArray", float, npt.NDArray[np.float64])
|
44 | 45 |
|
| 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 | + |
45 | 58 |
|
46 | 59 | class HandicapScheme(ABC):
|
47 | 60 | r"""
|
@@ -162,7 +175,7 @@ def sigma_r(self, handicap: FloatArray, dist: float) -> FloatArray:
|
162 | 175 | sig_r = dist * sig_t
|
163 | 176 | return sig_r
|
164 | 177 |
|
165 |
| - def arrow_score( # noqa: PLR0912 Too many branches |
| 178 | + def arrow_score( |
166 | 179 | self,
|
167 | 180 | handicap: FloatArray,
|
168 | 181 | target: targets.Target,
|
@@ -216,104 +229,45 @@ def arrow_score( # noqa: PLR0912 Too many branches
|
216 | 229 | arw_d = self.arw_d_out
|
217 | 230 |
|
218 | 231 | arw_rad = arw_d / 2.0
|
219 |
| - |
220 |
| - tar_dia = target.diameter |
| 232 | + spec = target.face_spec |
221 | 233 | sig_r = self.sigma_r(handicap, target.distance)
|
| 234 | + return self._s_bar(spec, arw_rad, sig_r) |
222 | 235 |
|
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. |
307 | 240 |
|
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] |
315 | 249 |
|
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 | + ) |
317 | 271 |
|
318 | 272 | def score_for_passes(
|
319 | 273 | self,
|
@@ -505,7 +459,7 @@ def handicap_from_score(
|
505 | 459 | To get an integer value as would appear in the handicap tables use
|
506 | 460 | ``int_prec=True``:
|
507 | 461 |
|
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) |
509 | 463 | 44.0
|
510 | 464 |
|
511 | 465 | """
|
|
0 commit comments