Source code for archeryutils.targets

"""Module for representing a Target for archery applications."""

from collections.abc import Mapping
from functools import partial
from types import MappingProxyType
from typing import Literal, NamedTuple, TypeAlias, get_args

from archeryutils import length

# TypeAlias deprecated. Move to `type` in py3.12+
#: All scoring systems that archeryutils knows how to handle by default.
ScoringSystem: TypeAlias = Literal[
    "5_zone",
    "10_zone",
    "10_zone_compound",
    "10_zone_6_ring",
    "10_zone_5_ring",
    "10_zone_5_ring_compound",
    "11_zone",
    "11_zone_6_ring",
    "11_zone_5_ring",
    "WA_field",
    "IFAA_field",
    "IFAA_field_expert",
    "AA_national_field",
    "Beiter_hit_miss",
    "Worcester",
    "Worcester_2_ring",
    "Custom",
]

# TypeAlias deprecated. Move to `type` in py3.12+
#: A mapping of a target ring diameter to the score for that ring.
FaceSpec: TypeAlias = Mapping[float, int]

_rnd6 = partial(round, ndigits=6)


[docs] class Quantity(NamedTuple): """ Dataclass for a quantity with units. Can be used in place of a plain tuple of (value, units) Parameters ---------- value: float Scalar value of quantity units: str Units of quantity Notes ----- It is recommened to use the `Quantity` type when passing specifyinging lengths in _archeryutils_ for explictness and readability, and to ensure the expected units are indeed being used downstream. Default units are assumed for convinience in interactive use but this could cause breakages if the default unit changes in the future. Examples -------- Define as a simple tuple: >>> worcester_distance = au.Quantity(80, "yard") Or with named keyword arguments: >>> worcester_target_size = au.Quantity(value=122, units="cm") These can then be passed on to any function that accepts a Quantity like tuple: >>> worcester_target = au.Target( ... "Worcester", ... diameter=worcester_target_size, ... distance=worcester_distance, ... indoor=True, ... ) """ value: float units: str
[docs] class Target: """ Class to represent a target. Parameters ---------- scoring_system : ScoringSystem Literal string value of target face/scoring system type. Must be one of the supported values. diameter : float or tuple of float, str Target face diameter default [centimetres]. distance : float or tuple of float, str linear distance from archer to target default [metres]. indoor : bool, default=False is round indoors for arrow diameter purposes? Attributes ---------- indoor : bool, default=False is round indoors? Raises ------ ValueError If inappropriate scoring system or units are requested. Examples -------- A target can be defined simply: >>> my720target = au.Target("10_zone", 122, 70.0) Alternatively the units for diameter and distance can be specified using tuples: >>> my720target = au.Target("10_zone", (122, "cm"), (70.0, "m")) >>> myWorcestertarget = au.Target("Worcester", (16, "inches"), (20.0, "yards")) Indoor rounds can be flagged as such using the `indoor` parameter: >>> myWA18target = au.Target("10_zone", (40, "cm"), (18.0, "m"), indoor=True) Attempting to construct a target with an invalid scoring system will fail in type checking and at runtime. >>> myUnknownTarget = au.Target("Unknown", 100, 50) ValueError: Invalid Target Face Type specified. Please select from '5_zone', '10_zone', '10_zone_compound', '10_zone_6_ring', ... """ _face_spec: FaceSpec #: Allowable scoring systems that this target can utilise. _supported_systems = get_args(ScoringSystem) #: Allowable units and alises for target distances. _supported_distance_units = length.yard | length.metre #: Allowable units and alises for target diameters. _supported_diameter_units = length.cm | length.inch | length.metre def __init__( self, scoring_system: ScoringSystem, diameter: float | tuple[float, str], distance: float | tuple[float, str], indoor: bool = False, ) -> None: if scoring_system not in self._supported_systems: msg = ( f"""Invalid Target Face Type specified.\n""" f"""Please select from '{"', '".join(self._supported_systems)}'.""" ) raise ValueError(msg) diam, native_diam_unit = length.parse_optional_units( diameter, self._supported_diameter_units, "cm" ) dist, native_dist_unit = length.parse_optional_units( distance, self._supported_distance_units, "metre" ) self._scoring_system = scoring_system self._diameter = length.to_metres(diam, native_diam_unit) self._native_diameter = Quantity(diam, native_diam_unit) self._distance = length.to_metres(dist, native_dist_unit) self._native_distance = Quantity(dist, native_dist_unit) self.indoor = indoor if scoring_system != "Custom": self._face_spec = self.gen_face_spec(scoring_system, self._diameter)
[docs] @classmethod def from_face_spec( cls, face_spec: FaceSpec | tuple[FaceSpec, str], diameter: float | tuple[float, str], distance: float | tuple[float, str], indoor: bool = False, ) -> "Target": """ Constuctor to build a target with custom scoring system. Optionally can convert units at the time of construction. Diameter must still be provided as a seperate arguement as it is impossible to know what the nominal diameter would be from the face specification without a known scoring system. However it is superceeded by face_spec and has no effect when calculating handicaps. Parameters ---------- face_spec : FaceSpec or 2-tuple of FaceSpec, str Target face specification, a mapping of target ring sizes to score. Default units are assumed as [metres] but can be provided as the second element of a tuple. diameter : float or tuple of float, str Target face diameter (and units, default [cm]) distance : float or tuple of float, str linear distance from archer to target (and units, default [metres]) indoor : bool Is target indoors for arrow diameter purposes? default = False Returns ------- Target Instance of Target class with scoring system set as "Custom" and face specification stored. Notes ----- Targets created in this way can represent almost any common round target in use, altough archeryutils has built in support for many of the most popular. There are some limitations to the target faces that can be represented however: 1. Targets must be formed of concentric rings 2. The score must monotonically decrease as the rings get larger Examples -------- >>> # Kings of archery recurve scoring triple spot >>> specs = {0.08: 10, 0.12: 8, 0.16: 7, 0.2: 6} >>> target = Target.from_face_spec(specs, 40, 18) >>> assert target.scoring_system == "Custom" """ spec_data, spec_units = length.parse_optional_units( face_spec, cls._supported_diameter_units, "metre" ) spec = { _rnd6(length.to_metres(ring_diam, spec_units)): score for ring_diam, score in spec_data.items() } target = cls("Custom", diameter, distance, indoor) target._face_spec = spec return target
def __repr__(self) -> str: """Return a representation of a Target instance.""" diam, diamunit = self.native_diameter dist, distunit = self.native_distance return ( "Target(" f"'{self.scoring_system}', " f"({diam:.6g}, '{diamunit}'), " f"({dist:.6g}, '{distunit}'), " f"indoor={self.indoor}" ")" ) def __eq__(self, other: object) -> bool: """Check equality of Targets based on parameters.""" if not isinstance(other, Target): return NotImplemented return self._parameters() == other._parameters() def __hash__(self) -> int: """Generate hash for the Target object.""" return hash(self._parameters()) def _parameters(self) -> tuple: """ Shortcut to get all target parameters as a tuple for comparison. Note: Conversion of mutable face_spec dict to a frozenset. """ return ( self._scoring_system, self._diameter, self._native_diameter, self._distance, self._native_distance, self.indoor, frozenset(self.face_spec.items()), ) @property def scoring_system(self) -> ScoringSystem: """Get the target face/scoring system type.""" return self._scoring_system @property def diameter(self) -> float: """Get target diameter in [metres].""" return self._diameter @property def distance(self) -> float: """Get target distance in [metres].""" return self._distance @property def native_distance(self) -> Quantity: """Get target distance in original native units.""" return self._native_distance @property def native_diameter(self) -> Quantity: """Get target diameter in original native units.""" return self._native_diameter @property def face_spec(self) -> FaceSpec: """ Get the targets face specification. Raises ------ ValueError If trying to access the face_spec for a `"Custom"` scoring target but that target was not instantiated correctly and no spec is found. """ # Still have some error handling in here for the case where # users use the wrong initaliser: # eg target = Target("Custom", 10, 10) # As otherwise errors raised are somewhat cryptic try: return MappingProxyType(self._face_spec) except AttributeError as err: msg = ( "Trying to generate face spec for custom target " "but no existing spec found: " "try instantiating with `Target.from_face_spec` instead" ) raise ValueError(msg) from err
[docs] def max_score(self) -> float: """ Return the maximum numerical score possible on this target (i.e. not X). Returns ------- float maximum score possible on this target face. Examples -------- >>> mytarget = au.Target("10_zone", (122, "cm"), (70.0, "m")) >>> mytarget.max_score() 10.0 """ return max(self.face_spec.values(), default=0)
[docs] def min_score(self) -> float: """ Return the minimum numerical score possible on this target (excluding miss/0). Returns ------- float minimum score possible on this target face Examples -------- >>> mytarget = au.Target("10_zone", (122, "cm"), (70.0, "m")) >>> mytarget.min_score() 1.0 """ return min(self.face_spec.values(), default=0)
[docs] @staticmethod def gen_face_spec(system: ScoringSystem, diameter: float) -> FaceSpec: """ Derive specifications for common/supported targets. Parameters ---------- system: ScoringSystem Name of scoring system diameter: Target diameter in [metres] Returns ------- spec : FaceSpec Mapping of target ring sizes in [metres] to score Raises ------ ValueError If no rule for producing a face_spec from the given system is found. Examples -------- >>> Target.gen_face_spec("WA_field", 0.6) {0.06: 6, 0.12: 5, 0.24: 4, 0.36: 3, 0.48: 2, 0.6: 1} >>> Target.gen_face_spec("10_zone_5_ring_compound", 0.4) {0.02: 10, 0.08: 9, 0.12: 8, 0.16: 7, 0.2: 6} """ removed_rings = { "10_zone_6_ring": 4, "10_zone_5_ring": 5, "10_zone_5_ring_compound": 5, "11_zone_6_ring": 4, "11_zone_5_ring": 5, "Worcester_2_ring": 3, } missing = removed_rings.get(system, 0) if system == "5_zone": spec = {_rnd6((n + 1) * diameter / 10): 10 - n for n in range(1, 11, 2)} elif system in ("10_zone", "10_zone_6_ring", "10_zone_5_ring"): spec = {_rnd6(n * diameter / 10): 11 - n for n in range(1, 11 - missing)} elif system in ("10_zone_compound", "10_zone_5_ring_compound"): spec = {_rnd6(diameter / 20): 10} | { _rnd6(n * diameter / 10): 11 - n for n in range(2, 11 - missing) } elif system in ("11_zone", "11_zone_6_ring", "11_zone_5_ring"): spec = {_rnd6(diameter / 20): 11} | { _rnd6(n * diameter / 10): 11 - n for n in range(1, 11 - missing) } elif system == "WA_field": spec = {_rnd6(diameter / 10): 6} | { _rnd6(n * diameter / 5): 6 - n for n in range(1, 6) } elif system == "IFAA_field": spec = {_rnd6(n * diameter / 5): 5 - n // 2 for n in range(1, 6, 2)} elif system == "AA_national_field": spec = {_rnd6(n * diameter / 5): 6 - n for n in range(1, 6)} elif system == "Beiter_hit_miss": spec = {diameter: 1} elif system in ("Worcester", "Worcester_2_ring", "IFAA_field_expert"): spec = {_rnd6(n * diameter / 5): 6 - n for n in range(1, 6 - missing)} # NB: Should be hard (but not impossible) to get here without catching earlier; # Most likely will only occur if a newly supported scoring system doesn't # have an implementation here for generating specs else: msg = f"Scoring system {system!r} is not supported" raise ValueError(msg) return spec