diff --git a/archeryutils/__init__.py b/archeryutils/__init__.py index a087799..4addec5 100644 --- a/archeryutils/__init__.py +++ b/archeryutils/__init__.py @@ -2,7 +2,7 @@ from archeryutils import classifications, handicaps from archeryutils.rounds import Pass, Round -from archeryutils.targets import Target +from archeryutils.targets import Quantity, Target from archeryutils.utils import versions __all__ = [ @@ -10,6 +10,7 @@ "handicaps", "Pass", "Round", + "Quantity", "Target", "versions", ] diff --git a/archeryutils/classifications/agb_outdoor_classifications.py b/archeryutils/classifications/agb_outdoor_classifications.py index 4fa46b0..cdcc966 100644 --- a/archeryutils/classifications/agb_outdoor_classifications.py +++ b/archeryutils/classifications/agb_outdoor_classifications.py @@ -325,7 +325,7 @@ def _assign_outdoor_prestige( # Check all other rounds based on distance for roundname in distance_check: - if ALL_OUTDOOR_ROUNDS[roundname].max_distance() >= np.min(max_dist): + if ALL_OUTDOOR_ROUNDS[roundname].max_distance().value >= np.min(max_dist): prestige_rounds.append(roundname) return prestige_rounds @@ -469,7 +469,7 @@ def _check_prestige_distance( # If not prestige, what classes are ineligible based on distance to_del: list[str] = [] - round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance() + round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance().value for class_i_name, class_i_data in class_data.items(): if class_i_data["min_dist"] > round_max_dist: to_del.append(class_i_name) @@ -557,7 +557,7 @@ def agb_outdoor_classification_scores( class_scores[0:3] = [-9999] * 3 # If not prestige, what classes are eligible based on category and distance - round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance() + round_max_dist = ALL_OUTDOOR_ROUNDS[roundname].max_distance().value for i in range(3, len(class_scores)): if group_data["min_dists"][i] > round_max_dist: class_scores[i] = -9999 diff --git a/archeryutils/constants.py b/archeryutils/constants.py deleted file mode 100644 index c3af139..0000000 --- a/archeryutils/constants.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Constants used in the archeryutils package.""" - -from typing import ClassVar - -_CONVERSIONS_TO_M = { - "metre": 1.0, - "yard": 0.9144, - "cm": 0.01, - "inch": 0.0254, -} - -_YARD_ALIASES = { - "Yard", - "yard", - "Yards", - "yards", - "Y", - "y", - "Yd", - "yd", - "Yds", - "yds", -} - -_METRE_ALIASES = { - "Metre", - "metre", - "Metres", - "metres", - "M", - "m", - "Ms", - "ms", -} - -_CM_ALIASES = { - "Centimetre", - "centimetre", - "Centimetres", - "centimetres", - "CM", - "cm", - "CMs", - "cms", -} - -_INCH_ALIASES = { - "Inch", - "inch", - "Inches", - "inches", -} - -_ALIASES = { - "yard": _YARD_ALIASES, - "metre": _METRE_ALIASES, - "cm": _CM_ALIASES, - "inch": _INCH_ALIASES, -} - - -class Length: - """ - Utility class for Length unit conversions. - - Contains common abbreviations, pluralisations and capitilizations for supported - units as sets to allow easy membership checks in combination. - Methods for conversions to and from metres are provided as classmethods. - - """ - - yard = _YARD_ALIASES - metre = _METRE_ALIASES - cm = _CM_ALIASES - inch = _INCH_ALIASES - - _reversed: ClassVar = { - alias: name for name in _CONVERSIONS_TO_M for alias in _ALIASES[name] - } - - _conversions: ClassVar = { - alias: factor - for name, factor in _CONVERSIONS_TO_M.items() - for alias in _ALIASES[name] - } - - @classmethod - def to_metres(cls, value: float, unit: str) -> float: - """ - Convert distance in any supported unit to metres. - - Parameters - ---------- - value : float - scalar value of distance to be converted to metres - unit : str - units of distance to be converted to metres - - Returns - ------- - float - scalar value of converted distance in metres - - Examples - -------- - >>> Length.to_metres(10, "inches") - 0.254 - """ - return cls._conversions[unit] * value - - @classmethod - def from_metres(cls, metre_value: float, unit: str) -> float: - """ - Convert distance in metres to specified unit. - - Parameters - ---------- - metre_value : float - scalar value of distance in metres to be converted - unit : str - units distance is to be converted TO - - Returns - ------- - float - scalar value of converted distance in the provided unit - - Examples - -------- - >>> Length.from_metres(18.3, "yards") - 20.0131 - """ - return metre_value / cls._conversions[unit] - - @classmethod - def definitive_unit(cls, alias: str) -> str: - """ - Convert any string alias representing a distance unit to a single version. - - Parameters - ---------- - alias : str - name of unit to be converted - - Returns - ------- - str - definitive name of unit - - Examples - -------- - >>> Length.definitive_unit("Metres") - "metre" - """ - return cls._reversed[alias] diff --git a/archeryutils/handicaps/handicap_scheme.py b/archeryutils/handicaps/handicap_scheme.py index 2f66bfe..9a58f2f 100644 --- a/archeryutils/handicaps/handicap_scheme.py +++ b/archeryutils/handicaps/handicap_scheme.py @@ -31,6 +31,7 @@ """ +import itertools as itr import warnings from abc import ABC, abstractmethod from typing import Optional, TypeVar, Union, overload @@ -42,6 +43,18 @@ FloatArray = TypeVar("FloatArray", float, npt.NDArray[np.float64]) +# itertools.pairwise not available until python 3.10 +# workaround can be removed when support for 3.9 is dropped +# ignore for coverage (runner is > 3.10, ci shows this works on 3.9) +if not hasattr(itr, "pairwise"): # pragma: no cover + + def _pairwise(iterable): + a, b = itr.tee(iterable) + next(b, None) + return zip(a, b) + + setattr(itr, "pairwise", _pairwise) # noqa: B010 + class HandicapScheme(ABC): r""" @@ -162,7 +175,7 @@ def sigma_r(self, handicap: FloatArray, dist: float) -> FloatArray: sig_r = dist * sig_t return sig_r - def arrow_score( # noqa: PLR0912 Too many branches + def arrow_score( self, handicap: FloatArray, target: targets.Target, @@ -216,104 +229,45 @@ def arrow_score( # noqa: PLR0912 Too many branches arw_d = self.arw_d_out arw_rad = arw_d / 2.0 - - tar_dia = target.diameter + spec = target.face_spec sig_r = self.sigma_r(handicap, target.distance) + return self._s_bar(spec, arw_rad, sig_r) - if target.scoring_system == "5_zone": - s_bar = ( - 9.0 - - 2.0 - * sum( - np.exp(-((((n * tar_dia / 10.0) + arw_rad) / sig_r) ** 2)) - for n in range(1, 5) - ) - - np.exp(-((((5.0 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2)) - ) - - elif target.scoring_system == "10_zone": - s_bar = 10.0 - sum( - np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2)) - for n in range(1, 11) - ) - - elif target.scoring_system == "10_zone_6_ring": - s_bar = ( - 10.0 - - sum( - np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2)) - for n in range(1, 6) - ) - - 5.0 * np.exp(-((((6.0 * tar_dia / 20.0) + arw_rad) / sig_r) ** 2)) - ) - - elif target.scoring_system == "10_zone_compound": - s_bar = ( - 10.0 - - np.exp(-((((tar_dia / 40.0) + arw_rad) / sig_r) ** 2)) - - sum( - np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2)) - for n in range(2, 11) - ) - ) - - elif target.scoring_system == "10_zone_5_ring": - s_bar = ( - 10.0 - - sum( - np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2)) - for n in range(1, 5) - ) - - 6.0 * np.exp(-((((5.0 * tar_dia / 20.0) + arw_rad) / sig_r) ** 2)) - ) - - elif target.scoring_system == "10_zone_5_ring_compound": - s_bar = ( - 10.0 - - np.exp(-((((tar_dia / 40) + arw_rad) / sig_r) ** 2)) - - sum( - np.exp(-((((n * tar_dia / 20) + arw_rad) / sig_r) ** 2)) - for n in range(2, 5) - ) - - 6.0 * np.exp(-((((5 * tar_dia / 20) + arw_rad) / sig_r) ** 2)) - ) - - elif target.scoring_system == "WA_field": - s_bar = ( - 6.0 - - np.exp(-((((tar_dia / 20.0) + arw_rad) / sig_r) ** 2)) - - sum( - np.exp(-((((n * tar_dia / 10.0) + arw_rad) / sig_r) ** 2)) - for n in range(1, 6) - ) - ) - - elif target.scoring_system == "IFAA_field": - s_bar = ( - 5.0 - - np.exp(-((((tar_dia / 10.0) + arw_rad) / sig_r) ** 2)) - - np.exp(-((((3.0 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2)) - - 3.0 * np.exp(-((((5.0 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2)) - ) - - elif target.scoring_system == "Beiter_hit_miss": - s_bar = 1.0 - np.exp(-((((tar_dia / 2.0) + arw_rad) / sig_r) ** 2)) - - elif target.scoring_system in ("Worcester", "IFAA_field_expert"): - s_bar = 5.0 - sum( - np.exp(-((((n * tar_dia / 10.0) + arw_rad) / sig_r) ** 2)) - for n in range(1, 6) - ) + def _s_bar( + self, target_specs: targets.FaceSpec, arw_rad: float, sig_r: FloatArray + ) -> FloatArray: + """Calculate expected score directly from target ring sizes. - elif target.scoring_system == "Worcester_2_ring": - s_bar = ( - 5.0 - - np.exp(-((((tar_dia / 10.0) + arw_rad) / sig_r) ** 2)) - - 4.0 * np.exp(-((((2 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2)) - ) - # No need for else with error as invalid scoring systems handled in Target class + Parameters + ---------- + target_specs : FaceSpec + Mapping of target ring *diameters* in [metres], to points scored + arw_rad : float + arrow radius in [metres] + sig_r : float + standard deviation of group size [metres] - return s_bar + Returns + ------- + s_bar : float + 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 - sum( + score_drop * np.exp(-(((arw_rad + (ring_diam / 2)) / sig_r) ** 2)) + for ring_diam, score_drop in zip(ring_sizes, score_drops) + ) def score_for_passes( self, @@ -505,7 +459,7 @@ def handicap_from_score( 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) + >>> agb_scheme.handicap_from_score(999, wa_outdoor.wa1440_90, int_prec=True) 44.0 """ diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py index 800b1fc..501e86a 100644 --- a/archeryutils/handicaps/tests/test_handicaps.py +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -73,6 +73,16 @@ Pass.at_target(36, "10_zone", 122, 30, False), ], ) +kings_900_rec = Round( + "Kings 900 (recurve)", + [ + Pass( + 30, + Target.from_face_spec({0.08: 10, 0.12: 8, 0.16: 7, 0.20: 6}, 40, 18, True), + ) + ] + * 3, +) class TestHandicapScheme: @@ -376,6 +386,45 @@ def test_different_target_faces( assert arrow_score_direct == pytest.approx(arrow_score_expected) + def test_empty_spec(self): + """Check expected score is zero when no target rings are defined.""" + target = Target.from_face_spec({}, 10, 10) + s_bar = hc.arrow_score(50, target, "AGB") + assert s_bar == 0 + + def test_unsorted_spec(self): + """Check expected score is insensitive to order of input spec.""" + + def _target(spec): + return Target.from_face_spec(spec, 10, 10) + + s_bar = hc.arrow_score(50, _target({0.1: 1, 0.2: 2, 0.3: 3}), "AA") + s_bar_reversed = hc.arrow_score(50, _target({0.3: 3, 0.2: 2, 0.1: 1}), "AA") + s_bar_unordered = hc.arrow_score(50, _target({0.1: 1, 0.3: 3, 0.2: 2}), "AA") + + assert s_bar_unordered == s_bar_reversed == s_bar + + def test_decimal_ring_scores(self): + """ + Check expected score can be calculated for non integer ring scores. + + Uses a target with integer ring scores at twice the value for comparison + """ + target_int = Target.from_face_spec({0.1: 3, 0.2: 5}, 10, 10) + target_dec = Target.from_face_spec({0.1: 1.5, 0.2: 2.5}, 10, 10) + + s_bar_int = hc.arrow_score(30, target_int, "AGB") + s_bar_dec = hc.arrow_score(30, target_dec, "AGB") + + assert s_bar_int == 2 * s_bar_dec + + def test_array_handicaps(self): + """Check expected score can be calculated for an array of handicap values.""" + handicaps = np.array([10, 20, 30, 40]) + target = Target.from_face_spec({0.1: 3, 0.2: 5}, 10, 10) + s_bar = hc.arrow_score(handicaps, target, "AGB") + assert len(s_bar) == len(handicaps) + class TestScoreForPasses: """ @@ -598,6 +647,10 @@ def test_rounded_round_score( hc_sys.score_for_round(20.0, test_round, None, True) == round_score_expected ) + def test_calculation_custom_scoring(self): + """Check that score can be calculated for a round with custom scoring.""" + assert hc.score_for_round(20.0, kings_900_rec, "AGB", None, True) == 896.0 + class TestHandicapFromScore: """ @@ -851,3 +904,7 @@ def test_decimal( ) assert handicap == pytest.approx(handicap_expected) + + def test_calculation_custom_scoring(self): + """Check that handicap can be calculated for a round with custom scoring.""" + assert hc.handicap_from_score(896, kings_900_rec, "AGB", int_prec=True) == 20 diff --git a/archeryutils/length.py b/archeryutils/length.py new file mode 100644 index 0000000..b1c192d --- /dev/null +++ b/archeryutils/length.py @@ -0,0 +1,257 @@ +"""Utility module for conversion of qunatities and unit aliases. + +Contains common abbreviations, pluralisations and capitilizations for supported +units as sets to allow easy membership checks in combination. +Supported units are provided as module attributes for easy autocompletion, +""" + +from collections.abc import Collection, Set +from typing import TypeVar, Union + +__all__ = [ + "yard", + "metre", + "inch", + "cm", + "to_metres", + "from_metres", + "definitive_unit", + "definitive_units", + "parse_optional_units", + "known_units", +] + +T = TypeVar("T") + +# Add aliases to any new supported units here +yard = { + "Yard", + "yard", + "Yards", + "yards", + "Y", + "y", + "Yd", + "yd", + "Yds", + "yds", +} + +metre = { + "Metre", + "metre", + "Metres", + "metres", + "M", + "m", + "Ms", + "ms", +} + +cm = { + "Centimetre", + "centimetre", + "Centimetres", + "centimetres", + "CM", + "cm", + "CMs", + "cms", +} + +inch = { + "Inch", + "inch", + "Inches", + "inches", +} + +# Update _ALIASES and _CONVERSIONSTO_M for any new supported units +# And they will be automatically incorporated +_ALIASES = { + "yard": yard, + "metre": metre, + "cm": cm, + "inch": inch, +} + + +_CONVERSIONSTO_M = { + "metre": 1.0, + "yard": 0.9144, + "cm": 0.01, + "inch": 0.0254, +} + + +_reversed = {alias: name for name in _CONVERSIONSTO_M for alias in _ALIASES[name]} + +_conversions = { + alias: factor + for name, factor in _CONVERSIONSTO_M.items() + for alias in _ALIASES[name] +} + + +def to_metres(value: float, unit: str) -> float: + """ + Convert distance in any supported unit to metres. + + Parameters + ---------- + value : float + scalar value of distance to be converted to metres + unit : str + units of distance to be converted to metres + + Returns + ------- + float + scalar value of converted distance in metres + + Examples + -------- + >>> length.to_metres(10, "inches") + 0.254 + """ + return _conversions[unit] * value + + +def from_metres(metre_value: float, unit: str) -> float: + """ + Convert distance in metres to specified unit. + + Parameters + ---------- + metre_value : float + scalar value of distance in metres to be converted + unit : str + units distance is to be converted TO + + Returns + ------- + float + scalar value of converted distance in the provided unit + + Examples + -------- + >>> length.from_metres(18.3, "yards") + 20.0131 + """ + return metre_value / _conversions[unit] + + +def definitive_unit(alias: str) -> str: + """ + Convert any string alias representing a distance unit to a single version. + + Parameters + ---------- + alias : str + name of unit to be converted + + Returns + ------- + str + definitive name of unit + + Examples + -------- + >>> length.definitive_unit("Metres") + "metre" + """ + return _reversed[alias] + + +def definitive_units(aliases: Collection[str]) -> set[str]: + """ + Reduce a set of string unit aliases to just their definitive names. + + Parameters + ---------- + aliases : set of str + names of units to be converted + + Returns + ------- + set of str + definitive names of unit + + Examples + -------- + >>> length.definitive_units(length.metre | length.yard) + {'metre', 'yard'} + """ + return {_reversed[alias] for alias in aliases} + + +def parse_optional_units( + value: Union[T, tuple[T, str]], + supported: Set[str], + default: str = "metre", +) -> tuple[T, str]: + """ + Parse single value or tuple of value and units. + + Always returns a tuple of value and units + + Parameters + ---------- + value : Any or tuple of Any, str + Either a single object, or a tuple with the desired units. + supported: set of str + Set of units (and aliases) that are expected/supported. + default: str + Default unit to be used when value does not specify units. + + Raises + ------ + ValueError + If default units or parsed values units + are not contained in supported units. + + Returns + ------- + tuple of Any, str + original value, definitive name of unit + + Notes + ----- + The supported parameter encodes both which units can be used, + and also which aliases are acceptable for those units. + If your downstream functionality for example only works with imperial units. + Then pass supported = {'inch', 'inches', 'yard', 'yards'} etc. + The units parsed from value will be checked against this set. + The default parameter is what will be provided in the result if the input value + is a scalar, so this must also be present in the set of supported units. + + Examples + -------- + >>> m_and_yd = length.metre | length.yard + >>> length.parse_optional_units(10, m_and_yd, "metre") + (10, 'metre') + >>> length.parse_optional_units((10, "yards"), m_and_yd, "metre") + (10, 'yard') + >>> length.parse_optional_units((10, "banana"), m_and_yd, "metre") + ValueError: Unit 'banana' not recognised. Select from {'yard', 'metre'}. + """ + if default not in supported: + msg = f"Default unit {default!r} must be in supported units" + raise ValueError(msg) + if isinstance(value, tuple) and len(value) == 2: # noqa: PLR2004 + quantity, units = value + else: + quantity = value + units = default + + if units not in supported: + msg = ( + f"Unit {units!r} not recognised. " + f"Select from {definitive_units(supported)}." + ) + raise ValueError(msg) + return quantity, definitive_unit(units) + + +known_units: set[str] = definitive_units(_conversions) +"""Display all units that can be converted by this module.""" diff --git a/archeryutils/load_rounds.py b/archeryutils/load_rounds.py index a3a0c51..609d591 100644 --- a/archeryutils/load_rounds.py +++ b/archeryutils/load_rounds.py @@ -188,7 +188,7 @@ def _make_rounds_dict(json_name: str) -> DotDict: IFAA_field = _make_rounds_dict("IFAA_field.json") WA_VI = _make_rounds_dict("WA_VI.json") AGB_VI = _make_rounds_dict("AGB_VI.json") -custom = _make_rounds_dict("Custom.json") +misc = _make_rounds_dict("Miscellaneous.json") del _make_rounds_dict diff --git a/archeryutils/round_data_files/Custom.json b/archeryutils/round_data_files/Miscellaneous.json similarity index 100% rename from archeryutils/round_data_files/Custom.json rename to archeryutils/round_data_files/Miscellaneous.json diff --git a/archeryutils/rounds.py b/archeryutils/rounds.py index 86e7b47..1a1a7ca 100644 --- a/archeryutils/rounds.py +++ b/archeryutils/rounds.py @@ -3,8 +3,7 @@ from collections.abc import Iterable from typing import Optional, Union -from archeryutils.constants import Length -from archeryutils.targets import ScoringSystem, Target +from archeryutils.targets import Quantity, ScoringSystem, Target class Pass: @@ -63,22 +62,14 @@ def at_target( # noqa: PLR0913 ---------- n_arrows : int number of arrows in this pass - scoring_system : {\ - ``"5_zone"`` ``"10_zone"`` ``"10_zone_compound"`` ``"10_zone_6_ring"``\ - ``"10_zone_5_ring"`` ``"10_zone_5_ring_compound"`` ``"WA_field"``\ - ``"IFAA_field"`` ``"IFAA_field_expert"`` ``"Beiter_hit_miss"`` ``"Worcester"``\ - ``"Worcester_2_ring"``} - target face/scoring system type - diameter : float - face diameter in [centimetres] - distance : float - linear distance from archer to target in [metres] - dist_unit : str - The unit distance is measured in. default = 'metres' + scoring_system : ScoringSystem + Literal string value of target face/scoring system type. + diameter : float or tuple of float, str + Target diameter size (and units, default [cm]) + distance : float or tuple of float, str + Target distance (and units, default [metres]) indoor : bool is round indoors for arrow diameter purposes? default = False - diameter_unit : str - The unit face diameter is measured in. default = 'centimetres' Returns ------- @@ -93,8 +84,8 @@ def at_target( # noqa: PLR0913 explicitly specified using tuples: >>> myWA18pass = au.Pass.at_target( - 30, "10_zone", (40, "cm"), (18.0, "m"), indoor=True - ) + ... 30, "10_zone", (40, "cm"), (18.0, "m"), indoor=True + ... ) """ target = Target(scoring_system, diameter, distance, indoor) return cls(n_arrows, target) @@ -105,9 +96,9 @@ def __repr__(self) -> str: def __eq__(self, other: object) -> bool: """Check equality of Passes based on parameters.""" - if isinstance(other, Pass): - return self.n_arrows == other.n_arrows and self.target == other.target - return NotImplemented + if not isinstance(other, Pass): + return NotImplemented + return self.n_arrows == other.n_arrows and self.target == other.target @property def scoring_system(self) -> ScoringSystem: @@ -116,23 +107,23 @@ def scoring_system(self) -> ScoringSystem: @property def diameter(self) -> float: - """Get target diameter [metres].""" + """Get target diameter in [metres].""" return self.target.diameter - @property - def native_diameter_unit(self) -> str: - """Get native_diameter_unit attribute of target.""" - return self.target.native_diameter_unit - @property def distance(self) -> float: """Get target distance in [metres].""" return self.target.distance @property - def native_dist_unit(self) -> str: - """Get native_dist_unit attribute of target.""" - return self.target.native_dist_unit + def native_diameter(self) -> Quantity: + """Get diameter of target in native units.""" + return self.target.native_diameter + + @property + def native_distance(self) -> Quantity: + """Get distance of target in native units.""" + return self.target.native_distance @property def indoor(self) -> bool: @@ -225,9 +216,9 @@ def __eq__(self, other: object) -> bool: Does not consider optional labels of location/body/family as these do not affect behaviour. """ - if isinstance(other, Round): - return self.name == other.name and self.passes == other.passes - return NotImplemented + if not isinstance(other, Round): + return NotImplemented + return self.name == other.name and self.passes == other.passes def max_score(self) -> float: """ @@ -240,32 +231,23 @@ def max_score(self) -> float: """ return sum(pass_i.max_score() for pass_i in self.passes) - def max_distance(self, unit: bool = False) -> Union[float, tuple[float, str]]: + def max_distance(self) -> Quantity: """ Return the maximum distance shot on this round along with the unit (optional). - Parameters - ---------- - unit : bool - Return unit as well as numerical value? - Returns ------- - max_dist : float - maximum distance shot in this round - (max_dist, unit) : tuple (float, str) - tuple of max_dist and string of unit + max_dist : Quantity + maximum distance and units shot in this round + + Notes + ----- + This does not convert the units of the result. + Rather the maximum distance shot in the round is returned in + whatever units it was defined in. """ - max_dist = 0.0 - for pass_i in self.passes: - if pass_i.distance > max_dist: - max_dist = pass_i.distance - d_unit = pass_i.native_dist_unit - - max_dist = Length.from_metres(max_dist, d_unit) - if unit: - return (max_dist, d_unit) - return max_dist + longest_pass = max(self.passes, key=lambda p: p.distance) + return longest_pass.native_distance def get_info(self) -> None: """ @@ -277,8 +259,8 @@ def get_info(self) -> None: """ print(f"A {self.name} consists of {len(self.passes)} passes:") for pass_i in self.passes: - diam, diam_units = pass_i.target.native_diameter - dist, dist_units = pass_i.target.native_distance + diam, diam_units = pass_i.native_diameter + dist, dist_units = pass_i.native_distance print( f"\t- {pass_i.n_arrows} arrows " f"at a {diam:.1f} {diam_units} target " diff --git a/archeryutils/targets.py b/archeryutils/targets.py index 49602fa..a538689 100644 --- a/archeryutils/targets.py +++ b/archeryutils/targets.py @@ -1,10 +1,14 @@ """Module for representing a Target for archery applications.""" -from typing import Literal, Union, get_args +from collections.abc import Mapping +from functools import partial +from types import MappingProxyType +from typing import Literal, NamedTuple, Union, get_args -from archeryutils.constants import Length +from archeryutils import length # TypeAlias (annotate explicitly in py3.10+) +#: All scoring systems that archeryutils knows how to handle by default. ScoringSystem = Literal[ "5_zone", "10_zone", @@ -18,8 +22,60 @@ "Beiter_hit_miss", "Worcester", "Worcester_2_ring", + "Custom", ] +# TypeAlias (annotate explicitly in py3.10+) +#: A mapping of a target ring diameter to the score for that ring. +FaceSpec = Mapping[float, int] + +_rnd6 = partial(round, ndigits=6) + + +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 + class Target: """ @@ -27,12 +83,9 @@ class Target: Parameters ---------- - scoring_system : {\ - ``"5_zone"`` ``"10_zone"`` ``"10_zone_compound"`` ``"10_zone_6_ring"``\ - ``"10_zone_5_ring"`` ``"10_zone_5_ring_compound"`` ``"WA_field"``\ - ``"IFAA_field"`` ``"IFAA_field_expert"`` ``"Beiter_hit_miss"`` ``"Worcester"``\ - ``"Worcester_2_ring"``} - target face/scoring system type. Must be one of the supported values. + 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 @@ -42,16 +95,6 @@ class Target: Attributes ---------- - scoring_system : ScoringSystem - target face/scoring system type. - diameter : float - Target face diameter [metres]. - native_diameter_unit : str - Native unit the target size is measured in before conversion to [metres]. - distance : float - linear distance from archer to target [metres]. - native_dist_unit : str - Native unit the target distance is measured in before conversion to [metres]. indoor : bool, default=False is round indoors? @@ -84,6 +127,17 @@ class Target: """ + _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, @@ -91,46 +145,92 @@ def __init__( distance: Union[float, tuple[float, str]], indoor: bool = False, ) -> None: - systems = get_args(ScoringSystem) - - if scoring_system not in systems: + if scoring_system not in self._supported_systems: msg = ( f"""Invalid Target Face Type specified.\n""" - f"""Please select from '{"', '".join(systems)}'.""" + f"""Please select from '{"', '".join(self._supported_systems)}'.""" ) - raise ValueError(msg) - if isinstance(distance, tuple): - (distance, native_dist_unit) = distance - else: - native_dist_unit = "metre" - if native_dist_unit not in Length.yard | Length.metre: - msg = ( - f"Distance unit '{native_dist_unit}' not recognised. " - "Select from 'yard' or 'metre'." - ) - raise ValueError(msg) - distance = Length.to_metres(distance, native_dist_unit) + 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 isinstance(diameter, tuple): - (diameter, native_diameter_unit) = diameter - else: - native_diameter_unit = "cm" - if native_diameter_unit not in Length.cm | Length.inch | Length.metre: - msg = ( - f"Diameter unit '{native_diameter_unit}' not recognised. " - "Select from 'cm', 'inch' or 'metre'" - ) - raise ValueError(msg) - diameter = Length.to_metres(diameter, native_diameter_unit) + if scoring_system != "Custom": + self._face_spec = self.gen_face_spec(scoring_system, self._diameter) - self.scoring_system = scoring_system - self.diameter = diameter - self.native_diameter_unit = Length.definitive_unit(native_diameter_unit) - self.distance = distance - self.native_dist_unit = Length.definitive_unit(native_dist_unit) - self.indoor = indoor + @classmethod + def from_face_spec( + cls, + face_spec: Union[FaceSpec, tuple[FaceSpec, str]], + diameter: Union[float, tuple[float, str]], + distance: Union[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 # noqa: SLF001 private member access + return target def __repr__(self) -> str: """Return a representation of a Target instance.""" @@ -147,36 +247,72 @@ def __repr__(self) -> str: def __eq__(self, other: object) -> bool: """Check equality of Targets based on parameters.""" - if isinstance(other, Target): - return self._parameters() == other._parameters() - return NotImplemented + if not isinstance(other, Target): + return NotImplemented + + return self._parameters() == other._parameters() def _parameters(self): """Shortcut to get all target parameters as a tuple for comparison.""" return ( - self.scoring_system, - self.diameter, - self.native_diameter_unit, - self.distance, - self.native_dist_unit, + self._scoring_system, + self._diameter, + self._native_diameter, + self._distance, + self._native_distance, self.indoor, + self.face_spec, ) @property - def native_distance(self) -> tuple[float, str]: + 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 ( - Length.from_metres(self.distance, self.native_dist_unit), - self.native_dist_unit, - ) + return self._native_distance @property - def native_diameter(self) -> tuple[float, str]: + def native_diameter(self) -> Quantity: """Get target diameter in original native units.""" - return ( - Length.from_metres(self.diameter, self.native_diameter_unit), - self.native_diameter_unit, - ) + 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 def max_score(self) -> float: """ @@ -187,42 +323,13 @@ def max_score(self) -> float: float maximum score possible on this target face. - Raises - ------ - ValueError - If a scoring system is not accounted for in the function. - Examples -------- >>> mytarget = au.Target("10_zone", (122, "cm"), (70.0, "m")) >>> mytarget.max_score() 10.0 """ - if self.scoring_system in ("5_zone"): - return 9.0 - if self.scoring_system in ( - "10_zone", - "10_zone_compound", - "10_zone_6_ring", - "10_zone_6_ring_compound", - "10_zone_5_ring", - "10_zone_5_ring_compound", - ): - return 10.0 - if self.scoring_system in ("WA_field"): - return 6.0 - if self.scoring_system in ( - "IFAA_field", - "IFAA_field_expert", - "Worcester", - "Worcester_2_ring", - ): - return 5.0 - if self.scoring_system in ("Beiter_hit_miss"): - return 1.0 - # NB: Should be hard (but not impossible) to get here without catching earlier. - msg = f"Target face '{self.scoring_system}' has no specified maximum score." - raise ValueError(msg) + return max(self.face_spec.values(), default=0) def min_score(self) -> float: """ @@ -233,43 +340,81 @@ def min_score(self) -> float: 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) + + @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 a scoring system is not accounted for in the function. + If no rule for producing a face_spec from the given system is found. Examples -------- - >>> mytarget = au.Target("10_zone", (122, "cm"), (70.0, "m")) - >>> mytarget.min_score() - 1.0 + >>> 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} """ - if self.scoring_system in ( - "5_zone", - "10_zone", - "10_zone_compound", - "WA_field", - "IFAA_field_expert", - "Worcester", - ): - return 1.0 - if self.scoring_system in ( - "10_zone_6_ring", - "10_zone_6_ring_compound", - ): - return 5.0 - if self.scoring_system in ( - "10_zone_5_ring", - "10_zone_5_ring_compound", - ): - return 6.0 - if self.scoring_system in ("Worcester_2_ring",): - return 4.0 - if self.scoring_system in ("IFAA_field",): - return 3.0 - if self.scoring_system in ("Beiter_hit_miss"): - # For Beiter options are hit and miss, so return 0 here - return 0.0 - # NB: Should be hard (but not impossible) to get here without catching earlier. - msg = f"Target face '{self.scoring_system}' has no specified minimum score." - raise ValueError(msg) + removed_rings = { + "10_zone_6_ring": 4, + "10_zone_5_ring": 5, + "10_zone_5_ring_compound": 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 == "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 == "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 diff --git a/archeryutils/tests/test_constants.py b/archeryutils/tests/test_constants.py deleted file mode 100644 index 8f32fa8..0000000 --- a/archeryutils/tests/test_constants.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Tests for constants, including Length class.""" - -import pytest - -from archeryutils.constants import Length - -CM = "cm" -INCH = "inch" -METRE = "metre" -YARD = "yard" - - -class TestLengths: - """Tests for Length class.""" - - def test_units_available(self): - """Test common length unit names.""" - assert CM in Length.cm - assert INCH in Length.inch - assert METRE in Length.metre - assert YARD in Length.yard - - def test_pluralised_unit_alises_available(self): - """Test plurailised versions of common length unit names.""" - assert CM + "s" in Length.cm - assert INCH + "es" in Length.inch - assert METRE + "s" in Length.metre - assert YARD + "s" in Length.yard - - @pytest.mark.parametrize( - "value,unit,result", - [ - (10, "metre", 10), - (10, "cm", 0.1), - (10, "inch", 0.254), - (10, "yard", 9.144), - ], - ) - def test_conversion_to_metres(self, value, unit, result): - """Test conversion from other units to metres.""" - assert Length.to_metres(value, unit) == result - - @pytest.mark.parametrize( - "value,unit,result", - [ - (10, "metre", 10), - (10, "cm", 1000), - (10, "inch", 393.7008), - (10, "yard", 10.93613), - ], - ) - def test_conversion_from_metres(self, value, unit, result): - """Test conversion from metres to other units.""" - assert Length.from_metres(value, unit) == pytest.approx(result) - - @pytest.mark.parametrize( - "unit,result", - [ - ("m", "metre"), - ("centimetre", "cm"), - ("Inch", "inch"), - ("yd", "yard"), - ], - ) - def test_unit_name_coercion(self, unit, result): - """Test unit name standardisation available on Length class.""" - assert Length.definitive_unit(unit) == result diff --git a/archeryutils/tests/test_length.py b/archeryutils/tests/test_length.py new file mode 100644 index 0000000..8079af1 --- /dev/null +++ b/archeryutils/tests/test_length.py @@ -0,0 +1,120 @@ +"""Tests for length conversion module.""" + +import pytest + +from archeryutils import length + +CM = "cm" +INCH = "inch" +METRE = "metre" +YARD = "yard" + + +class TestLengths: + """Tests for length module.""" + + def test_units_available(self): + """Check and document currently supported units.""" + assert length.known_units == {CM, INCH, METRE, YARD} + + def test_units_available_on_attributes(self): + """Test common length unit names.""" + assert CM in length.cm + assert INCH in length.inch + assert METRE in length.metre + assert YARD in length.yard + + def test_pluralised_unit_alises_available(self): + """Test plurailised versions of common length unit names.""" + assert CM + "s" in length.cm + assert INCH + "es" in length.inch + assert METRE + "s" in length.metre + assert YARD + "s" in length.yard + + @pytest.mark.parametrize( + "value,unit,result", + [ + (10, "metre", 10), + (10, "cm", 0.1), + (10, "inch", 0.254), + (10, "yard", 9.144), + ], + ) + def test_conversion_to_metres(self, value, unit, result): + """Test conversion from other units to metres.""" + assert length.to_metres(value, unit) == result + + @pytest.mark.parametrize( + "value,unit,result", + [ + (10, "metre", 10), + (10, "cm", 1000), + (10, "inch", 393.7008), + (10, "yard", 10.93613), + ], + ) + def test_conversion_from_metres(self, value, unit, result): + """Test conversion from metres to other units.""" + assert length.from_metres(value, unit) == pytest.approx(result) + + @pytest.mark.parametrize( + "unit,result", + [ + ("m", "metre"), + ("centimetre", "cm"), + ("Inch", "inch"), + ("yd", "yard"), + ], + ) + def test_unit_name_coercion(self, unit, result): + """Test unit name standardisation available on Length class.""" + assert length.definitive_unit(unit) == result + + def test_unit_alias_reduction(self): + """Test full set of unit alises can be reduced to just definitive names.""" + assert length.definitive_units(length.inch | length.cm) == {"inch", "cm"} + + @pytest.mark.parametrize( + "value,expected", + [ + pytest.param( + 10, + (10, "metre"), + id="int-scalar", + ), + pytest.param( + 10.1, + (10.1, "metre"), + id="float-scalar", + ), + pytest.param( + (10, "Metres"), + (10, "metre"), + id="default-units", + ), + pytest.param( + (10, "yds"), + (10, "yard"), + id="other-units", + ), + ], + ) + def test_optional_unit_parsing(self, value, expected): + """Test parsing of quantities with and without units.""" + supported = length.metre | length.yard + default = "metre" + assert length.parse_optional_units(value, supported, default) == expected + + def test_optional_unit_parsing_units_not_supported(self): + """Test parsing of quantities with and without units.""" + with pytest.raises(ValueError, match="Unit (.+) not recognised. Select from"): + assert length.parse_optional_units( + (10, "bannana"), length.metre | length.yard, "metre" + ) + + def test_optional_unit_parsing_default_not_supported(self): + """Test parsing of quantities with and without units.""" + with pytest.raises( + ValueError, match="Default unit (.+) must be in supported units" + ): + assert length.parse_optional_units(10, length.metre | length.yard, "inch") diff --git a/archeryutils/tests/test_rounds.py b/archeryutils/tests/test_rounds.py index 5540812..625383f 100644 --- a/archeryutils/tests/test_rounds.py +++ b/archeryutils/tests/test_rounds.py @@ -40,8 +40,7 @@ def test_at_target_constructor(self) -> None: test_pass = Pass.at_target(36, "5_zone", 122, 50) assert test_pass.n_arrows == 36 - # cannot test for equality between targets as __eq__ not implemented - # assert test_pass.target == _target + assert test_pass.target == _target def test_repr(self) -> None: """Check Pass string representation.""" @@ -82,21 +81,21 @@ def test_equality(self, other, result) -> None: def test_default_distance_unit(self) -> None: """Check that Pass returns distance in metres when units not specified.""" test_pass = Pass.at_target(36, "5_zone", 122, 50) - assert test_pass.native_dist_unit == "metre" + assert test_pass.native_distance.units == "metre" def test_default_diameter_unit(self) -> None: """Check that Pass has same default diameter units as Target.""" test_pass = Pass.at_target(36, "5_zone", 122, 50) assert ( - test_pass.native_diameter_unit - == test_pass.target.native_diameter_unit + test_pass.native_diameter.units + == test_pass.target.native_diameter.units == "cm" ) def test_diameter_units_passed_to_target(self) -> None: """Check that Pass passes on diameter units to Target object.""" test_pass = Pass.at_target(60, "Worcester", (16, "inches"), (20, "yards")) - assert test_pass.target.native_diameter_unit == "inch" + assert test_pass.target.native_diameter.units == "inch" def test_default_location(self) -> None: """Check that Pass returns indoor=False when indoor not specified.""" @@ -112,11 +111,17 @@ def test_properties(self) -> None: """Check that Pass properties are set correctly.""" test_pass = Pass(36, Target("5_zone", (122, "cm"), (50, "metre"), False)) assert test_pass.distance == 50.0 - assert test_pass.native_dist_unit == "metre" + assert test_pass.native_distance == (50, "metre") assert test_pass.diameter == 1.22 assert test_pass.scoring_system == "5_zone" assert test_pass.indoor is False - assert test_pass.native_diameter_unit == "cm" + assert test_pass.native_diameter == (122, "cm") + + def test_custom_target(self) -> None: + """Check that pass can be constructed from a custom target specification.""" + target = Target.from_face_spec({0.1: 3, 0.5: 1}, 80, (50, "yard")) + test_pass = Pass(30, target) + assert test_pass @pytest.mark.parametrize( "face_type,max_score_expected", @@ -280,7 +285,10 @@ def test_max_distance( Pass.at_target(10, "5_zone", 122, (60, unit), False), ], ) - assert test_round.max_distance(unit=get_unit) == max_dist_expected + result = ( + test_round.max_distance() if get_unit else test_round.max_distance().value + ) + assert result == max_dist_expected def test_max_distance_out_of_order(self) -> None: """Check max distance correct when Passes not in descending distance order.""" @@ -292,7 +300,7 @@ def test_max_distance_out_of_order(self) -> None: Pass.at_target(10, "5_zone", 122, 60, False), ], ) - assert test_round.max_distance() == 100 + assert test_round.max_distance().value == 100 def test_max_distance_mixed_units(self) -> None: """Check that max distance accounts for different units in round.""" @@ -301,7 +309,7 @@ def test_max_distance_mixed_units(self) -> None: test_round = Round("test", [pyards, pmetric]) assert pmetric.distance > pyards.distance - assert test_round.max_distance() == 75 + assert test_round.max_distance().value == 75 def test_get_info(self, capsys: pytest.CaptureFixture[str]) -> None: """Check printing info works as expected.""" diff --git a/archeryutils/tests/test_targets.py b/archeryutils/tests/test_targets.py index 77894a4..83cfac2 100644 --- a/archeryutils/tests/test_targets.py +++ b/archeryutils/tests/test_targets.py @@ -1,5 +1,7 @@ """Tests for Target class.""" +from typing import Final + import pytest from archeryutils.targets import ScoringSystem, Target @@ -97,30 +99,24 @@ def test_invalid_system(self) -> None: def test_invalid_distance_unit(self) -> None: """Check that Target() returns error value for invalid distance units.""" - with pytest.raises( - ValueError, - match="Distance unit '(.+)' not recognised. Select from 'yard' or 'metre'.", - ): + with pytest.raises(ValueError, match="Unit '(.+)' not recognised. Select from"): Target("5_zone", 122, (50, "InvalidDistanceUnit"), False) def test_default_distance_unit(self) -> None: """Check that Target() returns distance in metres when units not specified.""" target = Target("5_zone", 122, 50) - assert target.native_dist_unit == "metre" + assert target.native_distance == (50, "metre") def test_yard_to_m_conversion(self) -> None: """Check Target() returns correct distance in metres when yards provided.""" target = Target("5_zone", 122, (50, "yards")) + assert target.native_distance == (50, "yard") assert target.distance == 50.0 * 0.9144 - def test_unsupported_diameter_unit(self) -> None: + def test_invalid_diameter_unit(self) -> None: """Check Target() raises error when called with unsupported diameter units.""" - with pytest.raises( - ValueError, - match="Diameter unit '(.+)' not recognised." - " Select from 'cm', 'inch' or 'metre'", - ): - Target("5_zone", (122, "feet"), (50, "yards")) + with pytest.raises(ValueError, match="Unit '(.+)' not recognised. Select from"): + Target("5_zone", (122, "bananas"), (50, "yards")) def test_default_diameter_unit(self) -> None: """Check that Target() is using centimetres by default for diameter.""" @@ -135,6 +131,7 @@ def test_diameter_metres_not_converted(self) -> None: def test_diameter_inches_supported(self) -> None: """Check that Target() converts diameters in inches correctly.""" target = Target("Worcester", (16, "inches"), (20, "yards"), indoor=True) + assert target.native_diameter == (16, "inch") assert target.diameter == 16 * 0.0254 def test_diameter_distance_units_coerced_to_definitive_names(self) -> None: @@ -151,10 +148,10 @@ def test_diameter_distance_units_coerced_to_definitive_names(self) -> None: (30, "Metres"), ) - assert imperial_target.native_dist_unit == "yard" - assert imperial_target.native_diameter_unit == "inch" - assert metric_target.native_dist_unit == "metre" - assert metric_target.native_diameter_unit == "cm" + assert imperial_target.native_distance.units == "yard" + assert imperial_target.native_diameter.units == "inch" + assert metric_target.native_distance.units == "metre" + assert metric_target.native_diameter.units == "cm" def test_default_location(self) -> None: """Check that Target() returns indoor=False when indoor not specified.""" @@ -187,18 +184,6 @@ def test_max_score( target = Target(face_type, 122, (50, "metre"), False) assert target.max_score() == max_score_expected - def test_max_score_invalid_face_type(self) -> None: - """Check that Target() raises error for invalid face.""" - with pytest.raises( - ValueError, - match="Target face '(.+)' has no specified maximum score.", - ): - target = Target("5_zone", 122, 50, False) - # Requires manual resetting of scoring system to get this error. - # Silence mypy as scoring_system must be a valid literal ScoringSystem - target.scoring_system = "InvalidScoringSystem" # type: ignore[assignment] - target.max_score() - @pytest.mark.parametrize( "face_type,min_score_expected", [ @@ -213,7 +198,7 @@ def test_max_score_invalid_face_type(self) -> None: ("IFAA_field_expert", 1), ("Worcester", 1), ("Worcester_2_ring", 4), - ("Beiter_hit_miss", 0), + ("Beiter_hit_miss", 1), ], ) def test_min_score( @@ -225,14 +210,177 @@ def test_min_score( target = Target(face_type, 122, 50, False) assert target.min_score() == min_score_expected - def test_min_score_invalid_face_type(self) -> None: - """Check that Target() raises error for invalid face.""" + @pytest.mark.parametrize( + "scoring_system, diam, expected_spec", + [ + ( + "5_zone", + 122, + { + 0.244: 9, + 0.488: 7, + 0.732: 5, + 0.976: 3, + 1.22: 1, + }, + ), + ( + "10_zone", + 80, + { + 0.08: 10, + 0.16: 9, + 0.24: 8, + 0.32: 7, + 0.4: 6, + 0.48: 5, + 0.56: 4, + 0.64: 3, + 0.72: 2, + 0.8: 1, + }, + ), + ( + "WA_field", + 80, + { + 0.08: 6, + 0.16: 5, + 0.32: 4, + 0.48: 3, + 0.64: 2, + 0.8: 1, + }, + ), + ( + "IFAA_field", + 50, + { + 0.1: 5, + 0.3: 4, + 0.5: 3, + }, + ), + ( + "Beiter_hit_miss", + 6, + { + 0.06: 1, + }, + ), + ( + "Worcester", + (16, "inch"), + { + 0.08128: 5, + 0.16256: 4, + 0.24384: 3, + 0.32512: 2, + 0.4064: 1, + }, + ), + ( + "10_zone_6_ring", + 80, + { + 0.08: 10, + 0.16: 9, + 0.24: 8, + 0.32: 7, + 0.4: 6, + 0.48: 5, + }, + ), + ( + "10_zone_5_ring_compound", + 40, + {0.02: 10, 0.08: 9, 0.12: 8, 0.16: 7, 0.2: 6}, + ), + ], + ) + def test_face_spec(self, scoring_system, diam, expected_spec) -> None: + """Check that target returns face specs from supported scoring systems.""" + target = Target(scoring_system, diam, 30) + assert target.face_spec == expected_spec + + def test_face_spec_wrong_constructor(self) -> None: + """ + Accessing face spec raises an error for custom target from standard init. + + Custom targets should be made using the `from_face_spec` classmethod + """ + target = Target("Custom", 122, 50) with pytest.raises( ValueError, - match="Target face '(.+)' has no specified minimum score.", + match=( + "Trying to generate face spec for custom target " + "but no existing spec found" + ), ): - target = Target("5_zone", 122, 50, False) - # Requires manual resetting of scoring system to get this error. - # Silence mypy as scoring_system must be a valid literal ScoringSystem - target.scoring_system = "InvalidScoringSystem" # type: ignore[assignment] - target.min_score() + assert target.face_spec + + def test_gen_face_spec_unsupported_system(self) -> None: + """Check that generating face spec for an unsupported system raises error.""" + with pytest.raises( + ValueError, + match="Scoring system '(.+)' is not supported", + ): + # Silence mypy as using known invalid scoring system for test + assert Target.gen_face_spec("Dartchery", 100) # type: ignore[arg-type] + + +class TestCustomScoringTarget: + """Tests for Target class with custom scoring.""" + + _11zone_spec: Final = {0.02: 11, 0.04: 10, 0.8: 9, 0.12: 8, 0.16: 7, 0.2: 6} + + def test_constructor(self) -> None: + """Check initialisation of Target with a custom scoring system and spec.""" + target = Target.from_face_spec({0.1: 3, 0.5: 1}, 80, (50, "yard")) + assert target.distance == 50.0 * 0.9144 + assert target.diameter == 0.8 + assert target.scoring_system == "Custom" + assert target.face_spec == {0.1: 3, 0.5: 1} + + def test_face_spec_units(self) -> None: + """Check custom Target can be constructed with alternative units.""" + target = Target.from_face_spec(({10: 5, 20: 4, 30: 3}, "cm"), 50, 30) + assert target.face_spec == {0.1: 5, 0.2: 4, 0.3: 3} + + def test_invalid_face_spec_units(self) -> None: + """Check custom Target cannot be constructed with unsupported units.""" + with pytest.raises(ValueError): + Target.from_face_spec(({10: 5, 20: 4, 30: 3}, "bananas"), 50, 30) + + @pytest.mark.parametrize( + "spec, args, result", + [ + pytest.param( + {0.2: 2, 0.4: 1}, + (40, 20, True), + True, + id="duplicate", + ), + pytest.param( + {0.4: 5}, + (40, 20, True), + False, + id="different_spec", + ), + ], + ) + def test_equality(self, spec, args, result) -> None: + """Check custom Target equality comparison is supported.""" + target = Target.from_face_spec({0.2: 2, 0.4: 1}, 40, 20, indoor=True) + comparison = target == Target.from_face_spec(spec, *args) + assert comparison == result + + def test_max_score(self) -> None: + """Check that Target with custom scoring system returns correct max score.""" + target = Target.from_face_spec(self._11zone_spec, 40, 18) + assert target.max_score() == 11 + + def test_min_score(self) -> None: + """Check that Target with custom scoring system returns correct min score.""" + target = Target.from_face_spec(self._11zone_spec, 40, 18) + assert target.min_score() == 6 diff --git a/docs/api/archeryutils.baseclasses.rst b/docs/api/archeryutils.baseclasses.rst index d47a545..c100323 100644 --- a/docs/api/archeryutils.baseclasses.rst +++ b/docs/api/archeryutils.baseclasses.rst @@ -1,11 +1,18 @@ Base Classes ============ +.. autoclass:: archeryutils.targets.Quantity .. autoclass:: archeryutils.Target :members: :undoc-members: :show-inheritance: +The following units and aliases are recognised as valid for specifiying target diameters and distances: + +.. autoattribute:: archeryutils.Target._supported_diameter_units + +.. autoattribute:: archeryutils.Target._supported_distance_units + .. autoclass:: archeryutils.Pass :members: :undoc-members: diff --git a/docs/api/archeryutils.preloaded_rounds.rst b/docs/api/archeryutils.preloaded_rounds.rst index e060b76..474a71b 100644 --- a/docs/api/archeryutils.preloaded_rounds.rst +++ b/docs/api/archeryutils.preloaded_rounds.rst @@ -120,11 +120,11 @@ AGB VI au.load_rounds.AGB_VI -Custom Rounds +Miscellaneous ------------- .. ipython:: python import archeryutils as au - au.load_rounds.custom + au.load_rounds.misc diff --git a/docs/api/archeryutils.types.rst b/docs/api/archeryutils.types.rst new file mode 100644 index 0000000..d9a316c --- /dev/null +++ b/docs/api/archeryutils.types.rst @@ -0,0 +1,8 @@ +Types +===== + +.. autodata:: archeryutils.targets.ScoringSystem + +.. autodata:: archeryutils.targets.FaceSpec + +See :py:meth:`~archeryutils.Target.from_face_spec` for more details what targets can be modeled with this. diff --git a/docs/api/index.rst b/docs/api/index.rst index a146f29..8bb47c4 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -3,6 +3,11 @@ API Documentation ================= +.. toctree:: + :maxdepth: 3 + + archeryutils.types + .. toctree:: :maxdepth: 2 diff --git a/docs/conf.py b/docs/conf.py index 4f324c1..244b1b4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,3 +45,9 @@ html_theme = "sphinx_rtd_theme" html_static_path = ["_static"] +autodoc_member_order = "bysource" +napoleon_preprocess_types = True +napoleon_type_aliases = { + "FaceSpec": "~archeryutils.targets.FaceSpec", + "ScoringSystem": "~archeryutils.targets.ScoringSystem", +} diff --git a/docs/getting-started/quickstart.rst b/docs/getting-started/quickstart.rst index e7b11c0..4f78726 100644 --- a/docs/getting-started/quickstart.rst +++ b/docs/getting-started/quickstart.rst @@ -51,20 +51,56 @@ It can be invoked by specifying a scoring system, face size (cm) and distance (m mycompound720target = au.Target("10_zone_6_ring", 80, 50.0) In more complicated cases specific units can be passed in with the diameter and distance -as a tuple: +as a plain tuple, or (recommeneded for clarity) as a :py:class:`~archeryutils.targets.Quantity`: .. ipython:: python myWorcesterTarget = au.Target( "Worcester", diameter=(16, "inches"), distance=(20.0, "yards"), indoor=True ) - myIFAATarget = au.Target("IFAA_field", diameter=80, distance=(80.0, "yards")) + myIFAATarget = au.Target( + "IFAA_field", diameter=80, distance=au.Quantity(80.0, "yards") + ) + +If the target you want is not supported, you can manually supply the target ring sizes +and scores as a `FaceSpec` and construct a target as so. + +.. ipython:: python + + # Kings of archery recurve scoring target + face_spec = {8: 10, 12: 8, 16: 7, 20: 6} + myKingsTarget = au.Target.from_face_spec((face_spec, "cm"), 40, 18, indoor=True) + print(myKingsTarget.scoring_system) + +.. note:: + Although we can provide the face_spec in any unit listed under :py:attr:`~archeryutils.Target._supported_diameter_units`, + the sizes in the specification are converted to metres and stored in this form. + Therefore unlike the target diameter paramater, the default unit for specifications is [metres] + +The only limitations to the target faces you can represent in this way are: + +1. Targets must be formed of concentric rings +2. The score must monotonically decrease as the rings get larger + +Under the hood, all standard scoring systems autogenerate their own `FaceSpec` and this is used +internally when calculating handicaps and classifications. You can see this stored under the +:py:attr:`~archeryutils.Target.face_spec` property: + +.. ipython:: python + + print(my720target.face_spec) The target features `max_score()` and `min_score()` methods: .. ipython:: python - for target in [my720target, mycompound720target, myIFAATarget, myWorcesterTarget]: + for target in [ + my720target, + mycompound720target, + myIFAATarget, + myWorcesterTarget, + myKingsTarget, + ]: print( f"{target.scoring_system} has max score {target.max_score()} ", f"and min score {target.min_score()}.", @@ -142,7 +178,7 @@ Possible options for round collections are: * ``IFAA_field`` - IFAA indoor and outdoor rounds * ``AGB_VI`` - Archery GB Visually Impaired rounds * ``WA_VI`` - World Archery Visually Impaired rounds -* ``custom`` - custom rounds such as individual distances, 252 awards, frostbites etc. +* ``misc`` - Miscellaneous rounds such as individual distances, 252 awards, frostbites etc. Handicap Schemes ---------------- diff --git a/examples.ipynb b/examples.ipynb index a6233c4..c978f82 100644 --- a/examples.ipynb +++ b/examples.ipynb @@ -134,6 +134,47 @@ "myIFAATarget = au.Target(\"IFAA_field\", diameter=80, distance=(80.0, \"yards\"))" ] }, + { + "cell_type": "markdown", + "id": "5227ae9b", + "metadata": {}, + "source": [ + "Sometimes you might want to represent a target that isn't represented by the built in scoring systems.\n", + "In this case you can manually supply the target ring sizes and scores as a `FaceSpec`, which a mapping (commonly a dict) of ring diameters to scores\n", + "and construct a target as so:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b64c8ced", + "metadata": {}, + "outputs": [], + "source": [ + "# Kings of archery recurve scoring target\n", + "face_spec = {8: 10, 12: 8, 16: 7, 20: 6}\n", + "myKingsTarget = au.Target.from_face_spec((face_spec, \"cm\"), 40, 18, indoor=True)\n", + "print(myKingsTarget.scoring_system)" + ] + }, + { + "cell_type": "markdown", + "id": "f44592a2", + "metadata": {}, + "source": [ + "Under the hood, all standard scoring systems autogenerate their own `FaceSpec` and this is used internally when calculating handicaps and classifications. You can see this stored under the `Target.face_spec` property:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c113e587", + "metadata": {}, + "outputs": [], + "source": [ + "print(my720target.face_spec)" + ] + }, { "cell_type": "markdown", "id": "c46e1fda",