From 9a445d9e8e75dbbeb0a787d3b8873fe6fabaabf6 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Fri, 10 Feb 2023 23:51:40 +0000 Subject: [PATCH 01/15] pylint, mypy, remove flake8, add docstrings. --- .../classifications/classifications.py | 150 +++++---- archeryutils/constants.py | 11 +- archeryutils/handicaps/handicap_equations.py | 292 ++++++++++++------ archeryutils/handicaps/handicap_functions.py | 42 +-- archeryutils/rounds.py | 33 +- archeryutils/targets.py | 19 +- examples.py | 48 ++- pyproject.toml | 12 +- 8 files changed, 353 insertions(+), 254 deletions(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index e0fb053..8290152 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -1,6 +1,32 @@ -import numpy as np +""" +Code for doing things with archery handicap equations. + +Extended Summary +---------------- +Code to add functionality to the basic handicap equations code +in handicap_equations.py including inverse function and display. + +Routine Listings +---------------- +read_ages_json +read_bowstyles_json +read_genders_json +read_classes_json +get_groupname +_make_AGB_outdoor_classification_dict +_make_AGB_indoor_classification_dict +_make_AGB_field_classification_dict +calculate_AGB_outdoor_classification +AGB_outdoor_classification_scores +calculate_AGB_indoor_classification +AGB_indoor_classification_scores +calculate_AGB_field_classification +AGB_field_classification_scores + +""" import json from pathlib import Path +import numpy as np from archeryutils import rounds from archeryutils.handicaps import handicap_equations as hc_eq @@ -8,35 +34,35 @@ def read_ages_json(age_file=Path(__file__).parent / "AGB_ages.json"): # Read in age group info as list of dicts - with open(age_file) as json_file: + with open(age_file, encoding="utf-8") as json_file: ages = json.load(json_file) return ages def read_bowstyles_json(bowstyles_file=Path(__file__).parent / "AGB_bowstyles.json"): # Read in bowstyleclass info as list of dicts - with open(bowstyles_file) as json_file: + with open(bowstyles_file, encoding="utf-8") as json_file: bowstyles = json.load(json_file) return bowstyles def read_genders_json(genders_file=Path(__file__).parent / "AGB_genders.json"): # Read in gender info as list - with open(genders_file) as json_file: + with open(genders_file, encoding="utf-8") as json_file: genders = json.load(json_file)["genders"] return genders def read_classes_json(classes_file=Path(__file__).parent / "AGB_classes.json"): # Read in classification names as dict - with open(classes_file) as json_file: + with open(classes_file, encoding="utf-8") as json_file: classes = json.load(json_file) return classes def get_groupname(bowstyle, gender, age_group): """ - Subroutine to generate a single string id for a particular category + Generate a single string id for a particular category. Parameters ---------- @@ -51,11 +77,7 @@ def get_groupname(bowstyle, gender, age_group): ------- groupname : str single, lower case str id for this category - - References - ---------- """ - groupname = ( f"{age_group.lower().replace(' ', '')}_" f"{gender.lower()}_" @@ -67,13 +89,15 @@ def get_groupname(bowstyle, gender, age_group): def _make_AGB_outdoor_classification_dict(): """ - Subroutine to generate a dictionary of dictionaries providing handicaps for - each classification band and a list prestige rounds for each category from - data files. - Appropriate for 2023 ArcheryGB age groups and classifications + Generate AGB outdoor classification data. + + Generate a dictionary of dictionaries providing handicaps for each + classification band and a list prestige rounds for each category from data files. + Appropriate for 2023 ArcheryGB age groups and classifications. Parameters ---------- + None Returns ------- @@ -88,7 +112,6 @@ def _make_AGB_outdoor_classification_dict(): ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) """ - # Lists of prestige rounds defined by 'codename' of 'Round' class # TODO: convert this to json? prestige_imperial = [ @@ -188,7 +211,7 @@ def _make_AGB_outdoor_classification_dict(): class_HC = np.empty(len(AGB_classes)) min_dists = np.empty((len(AGB_classes), 3)) - for i, classification in enumerate(AGB_classes): + for i in range(len(AGB_classes)): # Assign handicap for this classification class_HC[i] = ( bowstyle["datum"] @@ -231,7 +254,8 @@ def _make_AGB_outdoor_classification_dict(): except IndexError as e: # Shouldn't really get here... print( - f"{e} cannot select minimum distances for {gender} and {age['age_group']}" + f"{e} cannot select minimum distances for " + f"{gender} and {age['age_group']}" ) min_dists[i, :] = dists[-3:] @@ -299,11 +323,14 @@ def _make_AGB_outdoor_classification_dict(): def _make_AGB_indoor_classification_dict(): """ - Subroutine to generate a dictionary of dictionaries providing handicaps for - each classification band + Generate AGB outdoor classification data. + + Generate a dictionary of dictionaries providing handicaps for + each classification band. Parameters ---------- + None Returns ------- @@ -316,7 +343,6 @@ def _make_AGB_indoor_classification_dict(): ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) """ - AGB_indoor_classes = ["A", "B", "C", "D", "E", "F", "G", "H"] # Generate dict of classifications @@ -344,11 +370,14 @@ def _make_AGB_indoor_classification_dict(): def _make_AGB_field_classification_dict(): """ - Subroutine to generate a dictionary of dictionaries providing handicaps for - each classification band + Generate AGB outdoor classification data. + + Generate a dictionary of dictionaries providing handicaps for + each classification band. Parameters ---------- + None Returns ------- @@ -361,7 +390,6 @@ def _make_AGB_field_classification_dict(): ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) """ - AGB_field_classes = [ "Grand Master Bowman", "Master Bowman", @@ -498,8 +526,10 @@ def _make_AGB_field_classification_dict(): def calculate_AGB_outdoor_classification(roundname, score, bowstyle, gender, age_group): """ - Subroutine to calculate a classification from a score given suitable inputs - Appropriate for 2023 ArcheryGB age groups and classifications + Calculate AGB outdoor classification from score. + + Calculate a classification from a score given suitable inputs. + Appropriate for 2023 ArcheryGB age groups and classifications. Parameters ---------- @@ -524,7 +554,6 @@ def calculate_AGB_outdoor_classification(roundname, score, bowstyle, gender, age ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) """ - # TODO: Need routines to sanitise/deal with variety of user inputs # TODO: Should this be defined outside the function to reduce I/O or does @@ -571,8 +600,8 @@ def calculate_AGB_outdoor_classification(roundname, score, bowstyle, gender, age if roundname not in AGB_outdoor_classifications[groupname]["prestige_rounds"]: # TODO: a list of dictionary keys is super dodgy python... # can this be improved? - for item in list(class_data.keys())[0:3]: - del class_data[item] + for MB_class in list(class_data.keys())[0:3]: + del class_data[MB_class] # If not prestige, what classes are eligible based on category and distance to_del = [] @@ -602,8 +631,10 @@ def calculate_AGB_outdoor_classification(roundname, score, bowstyle, gender, age def AGB_outdoor_classification_scores(roundname, bowstyle, gender, age_group): """ - Subroutine to calculate classification scores for a specific category and round - Appropriate for 2023 ArcheryGB age groups and classifications + Calculate AGB outdoor classification scores for category. + + Subroutine to calculate classification scores for a specific category and round. + Appropriate for 2023 ArcheryGB age groups and classifications. Parameters ---------- @@ -626,7 +657,6 @@ def AGB_outdoor_classification_scores(roundname, bowstyle, gender, age_group): ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) """ - # TODO: Should this be defined outside the function to reduce I/O or does # it have no effect? all_outdoor_rounds = rounds.read_json_to_round_dict( @@ -645,7 +675,7 @@ def AGB_outdoor_classification_scores(roundname, bowstyle, gender, age_group): # Get scores required on this round for each classification class_scores = [] - for i, class_i in enumerate(group_data["classes"]): + for i in range(len(group_data["classes"])): class_scores.append( hc_eq.score_for_round( all_outdoor_rounds[roundname], @@ -674,8 +704,10 @@ def calculate_AGB_indoor_classification( roundname, score, bowstyle, gender, age_group, hc_scheme="AGBold" ): """ - Subroutine to calculate a classification from a score given suitable inputs - Appropriate for 2023 ArcheryGB age groups and classifications + Calculate AGB indoor classification from score. + + Subroutine to calculate a classification from a score given suitable inputs. + Appropriate for 2023 ArcheryGB age groups and classifications. Parameters ---------- @@ -702,7 +734,6 @@ def calculate_AGB_indoor_classification( ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) """ - # TODO: Need routines to sanitise/deal with variety of user inputs # TODO: Should this be defined outside the function to reduce I/O or does @@ -741,11 +772,11 @@ def calculate_AGB_indoor_classification( # What is the highest classification this score gets? to_del = [] - for item in class_data: - if class_data[item] > score: + for score_bound in class_data: + if class_data[score_bound] > score: to_del.append(item) - for item in to_del: - del class_data[item] + for del_class in to_del: + del class_data[del_class] # NB No fiddle for Worcester required with this logic... # Beware of this later on, however, if we wish to rectify the 'anomaly' @@ -762,8 +793,10 @@ def AGB_indoor_classification_scores( roundname, bowstyle, gender, age_group, hc_scheme="AGBold" ): """ - Subroutine to calculate classification scores for a specific category and round - Appropriate ArcheryGB age groups and classifications + Calculate AGB indoor classification scores for category. + + Subroutine to calculate classification scores for a specific category and round. + Appropriate ArcheryGB age groups and classifications. Parameters ---------- @@ -788,7 +821,6 @@ def AGB_indoor_classification_scores( ArcheryGB Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 """ - # TODO: Should this be defined outside the function to reduce I/O or does # it have no effect? all_indoor_rounds = rounds.read_json_to_round_dict( @@ -826,7 +858,9 @@ def AGB_indoor_classification_scores( def calculate_AGB_field_classification(roundname, score, bowstyle, gender, age_group): """ - Subroutine to calculate a classification from a score given suitable inputs + Calculate AGB field classification from score. + + Subroutine to calculate a classification from a score given suitable inputs. Parameters ---------- @@ -851,7 +885,6 @@ def calculate_AGB_field_classification(roundname, score, bowstyle, gender, age_g ArcheryGB 2023 Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 (2023) """ - # TODO: Need routines to sanitise/deal with variety of user inputs # TODO: Should this be defined outside the function to reduce I/O or does @@ -881,23 +914,25 @@ def calculate_AGB_field_classification(roundname, score, bowstyle, gender, age_g and roundname != "wa_field_24_blue" ): return "unclassified" - else: - # What is the highest classification this score gets? - class_scores = dict(zip(group_data["classes"], group_data["class_scores"])) - for item in class_scores: - if class_scores[item] > score: - pass - else: - return item - - # if lower than 3rd class score return "UC" - return "unclassified" + + # What is the highest classification this score gets? + class_scores = dict(zip(group_data["classes"], group_data["class_scores"])) + for item in class_scores: + if class_scores[item] > score: + pass + else: + return item + + # if lower than 3rd class score return "UC" + return "unclassified" def AGB_field_classification_scores(roundname, bowstyle, gender, age_group): """ - Subroutine to calculate classification scores for a specific category and round - Appropriate ArcheryGB age groups and classifications + Calculate AGB field classification scores for category. + + Subroutine to calculate classification scores for a specific category and round. + Appropriate ArcheryGB age groups and classifications. Parameters ---------- @@ -920,7 +955,6 @@ def AGB_field_classification_scores(roundname, bowstyle, gender, age_group): ArcheryGB Rules of Shooting ArcheryGB Shooting Administrative Procedures - SAP7 """ - # TODO: Should this be defined outside the function to reduce I/O or does # it have no effect? all_field_rounds = rounds.read_json_to_round_dict( diff --git a/archeryutils/constants.py b/archeryutils/constants.py index 332afbd..bb3ef30 100644 --- a/archeryutils/constants.py +++ b/archeryutils/constants.py @@ -1,11 +1,2 @@ -# Author : Jack Atkinson -# -# Contributors : Jack Atkinson -# -# Date Created : 2022-08-18 -# Last Modified : 2022-08-18 by Jack Atkinson -# -# Summary : Constants for use in this package -# - +"""Constants used in the archeryutils package.""" YARD_TO_METRE = 0.9144 diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py index f494152..1b51715 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -1,100 +1,197 @@ -# Author : Jack Atkinson -# -# Contributors : Jack Atkinson -# -# Date Created : 2022-08-15 -# Last Modified : 2022-08-22 by Jack Atkinson -# -# Summary : Code for archery calculating handicaps using various systems -# - -import numpy as np +""" +Code for archery handicap scheme calculations using various schemes. + +Extended Summary +---------------- +Code to calculate information using a number of schemes: + - Old Archery GB (D Lane) + - New Archery GB (J Atkinson, 2023) + - Old Archery Australia (J Park) + - New Archery Australia (J Park) +Calculates arrow and round scores for a variety of target faces of given +distance and diameter: + - 5-zone + - 10-zone + - 10-zone 6-ring + - 10-zone compound + - 10-zone 5-ring + - 10-zone 5-ring compound + - WA_field + - IFAA_field + - Beiter-hit-miss + - Worcester + - Worcester 2-ring) + +Routine Listings +---------------- +HcParams +sigma_t +sigma_r +arrow_score +score_for_round + +References +---------- +Old AGB - D Lane +New AGB - J Atkinson +AA & AA2 - J Park +""" import json from typing import Union, Optional, Tuple, List from dataclasses import dataclass +import numpy as np from archeryutils import targets, rounds @dataclass class HcParams: - # KEY PARAMETERS AND CONSTANTS FOR NEW AGB HANDICAP SCHEME - AGB_datum = 6.0 # offset required to set handicap 0 at desired score - AGB_step = 3.5 # percentage change in group size for each handicap step - AGB_ang_0 = 5.0e-4 # baseline angle used for group size 0.5 [millirad] - AGB_kd = 0.00365 # distance scaling factor [1/metres] - - # KEY PARAMETERS AND CONSTANTS FOR OLD AGB HANDICAP SCHEME - AGBo_datum = 12.9 # offset required to set handicap 0 at desired score - AGBo_step = 3.6 # percentage change in group size for each handicap step - AGBo_ang_0 = 5.0e-4 # baseline angle used for group size 0.5 [millirad] - AGBo_k1 = 1.429e-6 # constant used in handicap equation - AGBo_k2 = 1.07 # constant used in handicap equation - AGBo_k3 = 4.3 # constant used in handicap equation - AGBo_p1 = 2.0 # exponent of distance scaling - AGBo_arw_d = 7.14e-3 # arrow diameter used in the old AGB algorithm by D. Lane - - # KEY PARAMETERS AND CONSTANTS FOR THE ARCHERY AUSTRALIA SCHEME - AA_k0 = 2.37 # offset required to set handicap 100 at desired score - AA_ks = 0.027 # change with each step of geometric progression - AA_kd = 0.004 # distance scaling factor [1/metres] - - # KEY PARAMETERS AND CONSTANTS FOR THE UPDATED ARCHERY AUSTRALIA SCHEME - AA2_k0 = 2.57 # offset required to set handicap 100 at desired score - AA2_ks = 0.027 # change with each step of geometric progression - AA2_f1 = 0.815 # 'linear' scaling factor - AA2_f2 = 0.185 # 'quadratic' scaling factor - AA2_d0 = 50.0 # Normalisation distance [metres] - - # DEFAULT ARROW DIAMETER - arw_d_in = 9.3e-3 # Diameter of an indoor arrow [metres] - arw_d_out = 5.5e-3 # Diameter of an outdoor arrow [metres] + """ + Class to hold information for various handicap schemes. + + Attributes + ---------- + KEY PARAMETERS AND CONSTANTS FOR NEW AGB HANDICAP SCHEME + AGB_datum : float + offset required to set handicap 0 at desired score + AGB_step : float + percentage change in group size for each handicap step + AGB_ang_0 : float + baseline angle used for group size 0.5 [millirad] + AGB_kd : float + distance scaling factor [1/metres] + + KEY PARAMETERS AND CONSTANTS FOR OLD AGB HANDICAP SCHEME + AGBo_datum : float + offset required to set handicap 0 at desired score + AGBo_step : float + percentage change in group size for each handicap step + AGBo_ang_0 : float + baseline angle used for group size 0.5 [millirad] + AGBo_k1 : float + constant used in handicap equation + AGBo_k2 : float + constant used in handicap equation + AGBo_k3 : float + constant used in handicap equation + AGBo_p1 : float + exponent of distance scaling + AGBo_arw_d : float + arrow diameter used in the old AGB algorithm by D. Lane + + KEY PARAMETERS AND CONSTANTS FOR THE ARCHERY AUSTRALIA SCHEME + AA_k0 : float + offset required to set handicap 100 at desired score + AA_ks : float + change with each step of geometric progression + AA_kd : float + distance scaling factor [1/metres] + + KEY PARAMETERS AND CONSTANTS FOR THE UPDATED ARCHERY AUSTRALIA SCHEME + AA2_k0 : float + offset required to set handicap 100 at desired score + AA2_ks : float + change with each step of geometric progression + AA2_f1 : float + 'linear' scaling factor + AA2_f2 : float + 'quadratic' scaling factor + AA2_d0 : float + Normalisation distance [metres] + + DEFAULT ARROW DIAMETER + arw_d_in : float + Diameter of an indoor arrow [metres] + arw_d_out : float + Diameter of an outdoor arrow [metres] + + """ + + AGB_datum = 6.0 + AGB_step = 3.5 + AGB_ang_0 = 5.0e-4 + AGB_kd = 0.00365 + + AGBo_datum = 12.9 + AGBo_step = 3.6 + AGBo_ang_0 = 5.0e-4 + AGBo_k1 = 1.429e-6 + AGBo_k2 = 1.07 + AGBo_k3 = 4.3 + AGBo_p1 = 2.0 + AGBo_arw_d = 7.14e-3 + + AA_k0 = 2.37 + AA_ks = 0.027 + AA_kd = 0.004 + + AA2_k0 = 2.57 + AA2_ks = 0.027 + AA2_f1 = 0.815 + AA2_f2 = 0.185 + AA2_d0 = 50.0 + + arw_d_in = 9.3e-3 + arw_d_out = 5.5e-3 @classmethod def load_json_params(cls, jsonpath): - json_HcParams = cls() - with open(jsonpath, "r") as read_file: + """ + Class method to load params from a json file. + + Parameters + ---------- + jsonpath : str + path to json file with handicap parameters + + Returns + ------- + json_hc_params : dataclass + dataclass of handicap parameters read from file + + """ + json_hc_params = cls() + with open(jsonpath, "r", encoding="utf-8") as read_file: paramsdict = json.load(read_file) - json_HcParams.AGB_datum = paramsdict["AGB_datum"] - json_HcParams.AGB_step = paramsdict["AGB_step"] - json_HcParams.AGB_ang_0 = paramsdict["AGB_ang_0"] - json_HcParams.AGB_kd = paramsdict["AGB_kd"] - json_HcParams.AGBo_datum = paramsdict["AGBo_datum"] - json_HcParams.AGBo_step = paramsdict["AGBo_step"] - json_HcParams.AGBo_ang_0 = paramsdict["AGBo_ang_0"] - json_HcParams.AGBo_k1 = paramsdict["AGBo_k1"] - json_HcParams.AGBo_k2 = paramsdict["AGBo_k2"] - json_HcParams.AGBo_k3 = paramsdict["AGBo_k3"] - json_HcParams.AGBo_p1 = paramsdict["AGBo_p1"] - json_HcParams.AGBo_arw_d = paramsdict["AGBo_arw_d"] - json_HcParams.AA_k0 = paramsdict["AA_k0"] - json_HcParams.AA_ks = paramsdict["AA_ks"] - json_HcParams.AA_kd = paramsdict["AA_kd"] - json_HcParams.AA2_k0 = paramsdict["AA2_k0"] - json_HcParams.AA2_ks = paramsdict["AA2_ks"] - json_HcParams.AA2_f1 = paramsdict["AA2_f1"] - json_HcParams.AA2_f2 = paramsdict["AA2_f2"] - json_HcParams.AA2_d0 = paramsdict["AA2_d0"] - json_HcParams.arw_d_in = paramsdict["arrow_diameter_indoors"] - json_HcParams.arw_d_out = paramsdict["arrow_diameter_outdoors"] - - return json_HcParams + json_hc_params.AGB_datum = paramsdict["AGB_datum"] + json_hc_params.AGB_step = paramsdict["AGB_step"] + json_hc_params.AGB_ang_0 = paramsdict["AGB_ang_0"] + json_hc_params.AGB_kd = paramsdict["AGB_kd"] + json_hc_params.AGBo_datum = paramsdict["AGBo_datum"] + json_hc_params.AGBo_step = paramsdict["AGBo_step"] + json_hc_params.AGBo_ang_0 = paramsdict["AGBo_ang_0"] + json_hc_params.AGBo_k1 = paramsdict["AGBo_k1"] + json_hc_params.AGBo_k2 = paramsdict["AGBo_k2"] + json_hc_params.AGBo_k3 = paramsdict["AGBo_k3"] + json_hc_params.AGBo_p1 = paramsdict["AGBo_p1"] + json_hc_params.AGBo_arw_d = paramsdict["AGBo_arw_d"] + json_hc_params.AA_k0 = paramsdict["AA_k0"] + json_hc_params.AA_ks = paramsdict["AA_ks"] + json_hc_params.AA_kd = paramsdict["AA_kd"] + json_hc_params.AA2_k0 = paramsdict["AA2_k0"] + json_hc_params.AA2_ks = paramsdict["AA2_ks"] + json_hc_params.AA2_f1 = paramsdict["AA2_f1"] + json_hc_params.AA2_f2 = paramsdict["AA2_f2"] + json_hc_params.AA2_d0 = paramsdict["AA2_d0"] + json_hc_params.arw_d_in = paramsdict["arrow_diameter_indoors"] + json_hc_params.arw_d_out = paramsdict["arrow_diameter_outdoors"] + + return json_hc_params def sigma_t( - h: Union[float, np.ndarray], + handicap: Union[float, np.ndarray], hc_sys: str, dist: Union[float, np.ndarray], hc_dat: HcParams, ) -> Union[float, np.ndarray]: """ - function sigma_t - Calculates the angular deviation for a given handicap scheme, handicap value, + Calculate the angular deviation for a given handicap scheme, handicap value, and distance. Parameters ---------- - h : ndarray or float + handicap : ndarray or float handicap to calculate sigma_t at hc_sys : str identifier for handicap system @@ -118,24 +215,23 @@ def sigma_t( Park, J (2014) https://doi.org/10.1177%2F1754337114539308 """ - if hc_sys == "AGB": # New AGB (Archery GB) System # Written by Jack Atkinson sig_t = ( hc_dat.AGB_ang_0 - * ((1.0 + hc_dat.AGB_step / 100.0) ** (h + hc_dat.AGB_datum)) + * ((1.0 + hc_dat.AGB_step / 100.0) ** (handicap + hc_dat.AGB_datum)) * np.exp(hc_dat.AGB_kd * dist) ) elif hc_sys == "AGBold": # Old AGB (Archery GB) System # Written by David Lane (2013) - K = hc_dat.AGBo_k1 * hc_dat.AGBo_k2 ** (h + hc_dat.AGBo_k3) + K = hc_dat.AGBo_k1 * hc_dat.AGBo_k2 ** (handicap + hc_dat.AGBo_k3) F = 1 + K * dist**hc_dat.AGBo_p1 sig_t = ( hc_dat.AGBo_ang_0 - * ((1.0 + hc_dat.AGBo_step / 100.0) ** (h + hc_dat.AGBo_datum)) + * ((1.0 + hc_dat.AGBo_step / 100.0) ** (handicap + hc_dat.AGBo_datum)) * F ) @@ -149,7 +245,7 @@ def sigma_t( sig_t = ( 1.0e-3 * np.sqrt(2) - * np.exp(hc_dat.AA_k0 - hc_dat.AA_ks * h + hc_dat.AA_kd * dist) + * np.exp(hc_dat.AA_k0 - hc_dat.AA_ks * handicap + hc_dat.AA_kd * dist) ) elif hc_sys == "AA2": @@ -162,7 +258,7 @@ def sigma_t( sig_t = ( np.sqrt(2) * 1.0e-3 - * np.exp(hc_dat.AA2_k0 - hc_dat.AA2_ks * h) + * np.exp(hc_dat.AA2_k0 - hc_dat.AA2_ks * handicap) * (hc_dat.AA2_f1 + hc_dat.AA2_f2 * dist / hc_dat.AA2_d0) ) @@ -182,19 +278,18 @@ def sigma_t( def sigma_r( - h: Union[float, np.ndarray], + handicap: Union[float, np.ndarray], hc_sys: str, dist: Union[float, np.ndarray], hc_dat: HcParams, ) -> Union[float, np.ndarray]: """ - function sigma_r - Calculates the angular deviation for a given handicap scheme, handicap value - Wraps around sigma_t() and multiplies by distance + Calculate the angular deviation for a given handicap scheme, handicap value, + and data. Wraps around sigma_t() and multiplies by distance. Parameters ---------- - h : ndarray or float + handicap : ndarray or float handicap to calculate sigma_t at hc_sys : str identifier for handicap system @@ -207,11 +302,8 @@ def sigma_r( ------- sig_r : float or ndarray standard deviation of group size [metres] - - References - ---------- """ - sig_t = sigma_t(h, hc_sys, dist, hc_dat) + sig_t = sigma_t(handicap, hc_sys, dist, hc_dat) sig_r = dist * sig_t return sig_r @@ -219,20 +311,19 @@ def sigma_r( def arrow_score( target: targets.Target, - h: Union[float, np.ndarray], + handicap: Union[float, np.ndarray], hc_sys: str, hc_dat: HcParams, arw_d: Optional[float] = None, ) -> float: """ - Subroutine to calculate the average arrow score for a given - target and handicap rating. + Calculate the average arrow score for a given target and handicap rating. Parameters ---------- target : targets.Target A Target class specifying the target to be used - h : ndarray or float + handicap : ndarray or float handicap value to calculate score for hc_sys : string identifier for the handicap system @@ -248,6 +339,8 @@ def arrow_score( References ---------- + - The construction of the graduated handicap tables for target archery + Lane, D (2013) """ # Set arrow diameter. Use provided, if AGBold scheme set value, otherwise select # default from params based on in/out @@ -263,7 +356,7 @@ def arrow_score( arw_rad = arw_d / 2.0 tar_dia = target.diameter - sig_r = sigma_r(h, hc_sys, target.distance, hc_dat) + sig_r = sigma_r(handicap, hc_sys, target.distance, hc_dat) if target.scoring_system == "5_zone": s_bar = ( @@ -373,21 +466,20 @@ def arrow_score( def score_for_round( rnd: rounds.Round, - h: Union[float, np.ndarray], + handicap: Union[float, np.ndarray], hc_sys: str, hc_dat: HcParams, arw_d: Optional[float] = None, round_score_up: bool = True, ) -> Tuple[float, List[float]]: """ - Subroutine to calculate the average arrow score for a given - target and handicap rating. + Calculate the average arrow score for a given target and handicap rating. Parameters ---------- rnd : rounds.Round A Round class specifying the round being shot - h : ndarray or float + handicap : ndarray or float handicap value to calculate score for hc_sys : string identifier for the handicap system @@ -406,14 +498,12 @@ def score_for_round( pass_score : list of float average score for each pass in the round - References - ---------- """ - pass_score = [] - for Pass_i in rnd.passes: + for pass_i in rnd.passes: pass_score.append( - Pass_i.n_arrows * arrow_score(Pass_i.target, h, hc_sys, hc_dat, arw_d=arw_d) + pass_i.n_arrows + * arrow_score(pass_i.target, handicap, hc_sys, hc_dat, arw_d=arw_d) ) round_score = np.sum(pass_score, axis=0) diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index e92d6ae..e72f336 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -1,13 +1,17 @@ -# Author : Jack Atkinson -# -# Contributors : Jack Atkinson -# -# Date Created : 2022-08-16 -# Last Modified : 2022-08-22 by Jack Atkinson -# -# Summary : Code for doing things with archery handicap code -# +""" +Code for doing things with archery handicap equations. +Extended Summary +---------------- +Code to add functionality to the basic handicap equations code +in handicap_equations.py including inverse function and display. + +Routine Listings +---------------- +print_handicap_table +handicap_from_score + +""" import numpy as np import warnings from itertools import chain @@ -33,7 +37,7 @@ def print_handicap_table( int_prec: bool = False, ) -> None: """ - Subroutine to generate a handicap table + Generate a handicap table to screen and/or file. Parameters ---------- @@ -63,11 +67,7 @@ def print_handicap_table( Returns ------- None - - References - ---------- """ - # Abbreviations to replace headings with in Handicap Tables to keep concise abbreviations = { "Compound": "C", @@ -160,7 +160,7 @@ def handicap_from_score( int_prec: bool = False, ) -> Union[int, float]: """ - Subroutine to return the handicap of a given score on a given round + Calculate the handicap of a given score on a given round using root-finding. Parameters ---------- @@ -206,21 +206,21 @@ def handicap_from_score( # start high and drop down until no longer ceiling to max score # (i.e. >= max_score - 1.0) if hc_sys in ["AA", "AA2"]: - hc = 175 + hc = 175.0 dhc = -0.01 else: - hc = -75 + hc = -75.0 dhc = 0.01 s_max, _ = hc_eq.score_for_round( rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=False ) # Work down to where we would round up (ceil) to max score - ceiling approach while s_max > max_score - 1.0: - hc += dhc + hc = hc + dhc s_max, _ = hc_eq.score_for_round( rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=False ) - hc -= dhc # Undo final iteration that overshoots + hc = hc - dhc # Undo final iteration that overshoots if int_prec: if hc_sys in ["AA", "AA2"]: hc = np.ceil(hc) @@ -244,9 +244,9 @@ def f_root(h, scr, rd, sys, hc_data, arw_dia): return val - scr if hc_sys in ["AA", "AA2"]: - x = [-250, 175] + x = [-250.0, 175.0] else: - x = [-75, 300] + x = [-75.0, 300.0] f = [ f_root(x[0], score, rnd, hc_sys, hc_dat, arw_d), diff --git a/archeryutils/rounds.py b/archeryutils/rounds.py index c9a2cda..0c5838e 100644 --- a/archeryutils/rounds.py +++ b/archeryutils/rounds.py @@ -8,10 +8,10 @@ # Summary : definition of classes to define rounds for archery applications # -import numpy as np import json from pathlib import Path import warnings +import numpy as np from archeryutils.targets import Target from archeryutils.constants import YARD_TO_METRE @@ -157,21 +157,16 @@ def get_info(self): """ print(f"A {self.name} consists of {len(self.passes)} passes:") for pass_i in self.passes: + if pass_i.native_dist_unit == "yard": + native_dist = pass_i.target.distance / YARD_TO_METRE + else: + native_dist = pass_i.distance print( - "\t- {} arrows at a {} cm target at {} {}s.".format( - pass_i.n_arrows, - pass_i.diameter * 100.0, - ( - pass_i.target.distance / YARD_TO_METRE - if pass_i.native_dist_unit == "yard" - else pass_i.distance - ), - pass_i.native_dist_unit, - ) + f"\t- {pass_i.n_arrows} arrows " + f"at a {pass_i.diameter * 100.0} cm target " + f"at {native_dist} {pass_i.native_dist_unit}s." ) - return None - def max_score(self): """ max_score @@ -215,11 +210,10 @@ def max_distance(self, unit=False): if dist > max_dist: max_dist = dist d_unit = pass_i.native_dist_unit - + if unit: return (max_dist, d_unit) - else: - return max_dist + return max_dist def read_json_to_round_dict(json_filelist): @@ -239,7 +233,7 @@ def read_json_to_round_dict(json_filelist): References ---------- """ - if type(json_filelist) is not list: + if not isinstance(json_filelist, list): json_filelist = [json_filelist] round_data_files = Path(__file__).parent.joinpath("round_data_files") @@ -315,7 +309,7 @@ def read_json_to_round_dict(json_filelist): ) round_i["body"] = "custom" # TODO: Could do sanitisation here e.g. AGB vs agb etc or trust user... - + # Assign round family if "family" not in round_i: warnings.warn( @@ -367,8 +361,7 @@ class DotDict(dict): def __getattr__(self, name): if name in self: return self[name] - else: - raise AttributeError(self._attribute_err_msg(name)) + raise AttributeError(self._attribute_err_msg(name)) def __setattr__(self, name, value): self[name] = value diff --git a/archeryutils/targets.py b/archeryutils/targets.py index f1e1401..357460b 100644 --- a/archeryutils/targets.py +++ b/archeryutils/targets.py @@ -74,8 +74,8 @@ def __init__( if scoring_system not in systems: raise ValueError( - "Invalid Target Face Type specified.\n" - "Please select from '{}'.".format("', '".join(systems)) + f"""Invalid Target Face Type specified.\n""" + f"""Please select from '{"', '".join(systems)}'.""" ) if native_dist_unit in [ @@ -131,7 +131,7 @@ def max_score(self): """ if self.scoring_system in ["5_zone"]: return 9.0 - elif self.scoring_system in [ + if self.scoring_system in [ "10_zone", "10_zone_compound", "10_zone_6_ring", @@ -140,18 +140,17 @@ def max_score(self): "10_zone_5_ring_compound", ]: return 10.0 - elif self.scoring_system in ["WA_field"]: + if self.scoring_system in ["WA_field"]: return 6.0 - elif self.scoring_system in [ + if self.scoring_system in [ "IFAA_field", "IFAA_field_expert", "Worcester", "Worcester_2_ring", ]: return 5.0 - elif self.scoring_system in ["Beiter_hit_miss"]: + if self.scoring_system in ["Beiter_hit_miss"]: return 1.0 - else: - raise ValueError( - f"target face '{self.scoring_system}' has no specified maximum score" - ) + raise ValueError( + f"target face '{self.scoring_system}' has no specified maximum score" + ) diff --git a/examples.py b/examples.py index 76843e2..25fe631 100644 --- a/examples.py +++ b/examples.py @@ -151,20 +151,15 @@ int_prec=True, ) - - - - - # Print the continuous score that is comes from this handicap score_from_hc = hc_eq.score_for_round( - rounds.AGB_outdoor_imperial.york, 51., "AGB", hc_params, round_score_up=False + rounds.AGB_outdoor_imperial.york, 51.0, "AGB", hc_params, round_score_up=False ) print( f"A handicap of 51. on a {rounds.AGB_outdoor_imperial.york.name} is a continuous score of {score_from_hc}." ) - # Print the continuous score that is comes from this handicap + # Print the continuous score that is comes from this handicap score_from_hc = hc_eq.score_for_round( rounds.AGB_outdoor_imperial.york, 50.0, "AGB", hc_params, round_score_up=False ) @@ -172,9 +167,9 @@ f"A handicap of 50.0 on a {rounds.AGB_outdoor_imperial.york.name} is a continuous score of {score_from_hc}." ) - # Print the continuous score that is comes from this handicap + # Print the continuous score that is comes from this handicap score_from_hc = hc_eq.score_for_round( - rounds.AGB_outdoor_imperial.york, 49., "AGB", hc_params, round_score_up=False + rounds.AGB_outdoor_imperial.york, 49.0, "AGB", hc_params, round_score_up=False ) print( f"A handicap of 49. on a {rounds.AGB_outdoor_imperial.york.name} is a continuous score of {score_from_hc}." @@ -188,7 +183,7 @@ f"A handicap of 51 on a {rounds.AGB_outdoor_imperial.york.name} requires a minimum score of {score_from_hc}." ) - # Print the minimum discrete score that is required to get this handicap + # Print the minimum discrete score that is required to get this handicap score_from_hc = hc_eq.score_for_round( rounds.AGB_outdoor_imperial.york, 50, "AGB", hc_params, round_score_up=True ) @@ -196,7 +191,7 @@ f"A handicap of 50 on a {rounds.AGB_outdoor_imperial.york.name} requires a minimum score of {score_from_hc}." ) - # Print the minimum discrete score that is required to get this handicap + # Print the minimum discrete score that is required to get this handicap score_from_hc = hc_eq.score_for_round( rounds.AGB_outdoor_imperial.york, 49, "AGB", hc_params, round_score_up=True ) @@ -244,29 +239,25 @@ f"A score of 706 on a {rounds.AGB_outdoor_imperial.york.name} is a discrete handicap of {hc_from_score}." ) - score_from_hc = hc_eq.score_for_round( - rounds.AGB_outdoor_imperial.york, 50., "AGB", hc_params, round_score_up=False + rounds.AGB_outdoor_imperial.york, 50.0, "AGB", hc_params, round_score_up=False ) print( f"A handicap of 50. on a {rounds.AGB_outdoor_imperial.york.name} is a continuous score of {score_from_hc}." ) score_from_hc = hc_eq.score_for_round( - rounds.AGB_outdoor_imperial.york, 51., "AGB", hc_params, round_score_up=False + rounds.AGB_outdoor_imperial.york, 51.0, "AGB", hc_params, round_score_up=False ) print( f"A handicap of 51. on a {rounds.AGB_outdoor_imperial.york.name} is a continuous score of {score_from_hc}." ) score_from_hc = hc_eq.score_for_round( - rounds.AGB_outdoor_imperial.york, 52., "AGB", hc_params, round_score_up=False + rounds.AGB_outdoor_imperial.york, 52.0, "AGB", hc_params, round_score_up=False ) print( f"A handicap of 52. on a {rounds.AGB_outdoor_imperial.york.name} is a continuous score of {score_from_hc}." ) - - - # Print score for a certain handicap - discrete hc_from_score = hc_func.handicap_from_score( 684, rounds.AGB_outdoor_imperial.york, "AGB", hc_params, int_prec=True @@ -287,7 +278,6 @@ f"A score of 682 on a {rounds.AGB_outdoor_imperial.york.name} is a discrete handicap of {hc_from_score}." ) - # Handicaps # Print the continuous score that is comes from this handicap score_from_hc = hc_eq.score_for_round( @@ -312,29 +302,37 @@ f"A handicap of 52 on a {rounds.AGB_outdoor_imperial.york.name} requires a minimum score of {score_from_hc}." ) - # Print the minimum discrete score that is required to get this handicap - class_from_score = class_func.calculate_AGB_outdoor_classification('york', 965, "recurve", "male", "50+") + class_from_score = class_func.calculate_AGB_outdoor_classification( + "york", 965, "recurve", "male", "50+" + ) print( f"A score of 965 on a {rounds.AGB_outdoor_imperial.york.name} is class {class_from_score}." ) - class_from_score = class_func.calculate_AGB_outdoor_classification('york', 964, "recurve", "male", "50+") + class_from_score = class_func.calculate_AGB_outdoor_classification( + "york", 964, "recurve", "male", "50+" + ) print( f"A score of 964 on a {rounds.AGB_outdoor_imperial.york.name} is class {class_from_score}." ) - class_from_score = class_func.calculate_AGB_outdoor_classification('western', 864, "recurve", "male", "Adult") + class_from_score = class_func.calculate_AGB_outdoor_classification( + "western", 864, "recurve", "male", "Adult" + ) print( f"A score of 864 on a {rounds.AGB_outdoor_imperial.western.name} is class {class_from_score}." ) - class_from_score = class_func.calculate_AGB_outdoor_classification('western_30', 864, "recurve", "male", "Adult") + class_from_score = class_func.calculate_AGB_outdoor_classification( + "western_30", 864, "recurve", "male", "Adult" + ) print( f"A score of 864 on a {rounds.AGB_outdoor_imperial.western_30.name} is class {class_from_score}." ) - hc_from_score = hc_func.handicap_from_score( 1295, rounds.AGB_outdoor_imperial.bristol_i, "AGB", hc_params, int_prec=True ) print( f"A score of 1295 on a {rounds.AGB_outdoor_imperial.bristol_i.name} is a discrete handicap of {hc_from_score}." ) + + rounds.AGB_outdoor_imperial.bristol_i.get_info() diff --git a/pyproject.toml b/pyproject.toml index 42003d6..6a99ffd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ ] dependencies = [ "black>=22.12.0", - "flake8==6.0.0", + "mypy>=1.0.0", "numpy>=1.20.3", ] @@ -52,11 +52,5 @@ archeryutils = ["*.json", "round_data_files/*.json", "classifications/*.json"] #[options.extras_require] #tests = pytest -[tool.flake8] -# ignore = ['E231', 'E241'] -per-file-ignores = [ - '__init__.py:F401', -] -max-line-length = 88 -count = true - +[tool.mypy] +warn_unused_configs = true From 048f6ce3848a34adc735a68413e006534d782915 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Thu, 16 Feb 2023 20:58:00 +0000 Subject: [PATCH 02/15] Simple tests for handicap equations. --- .../handicaps/tests/test_handicaps.py | 82 +++++++++++++++++++ pyproject.toml | 1 + 2 files changed, 83 insertions(+) create mode 100644 archeryutils/handicaps/tests/test_handicaps.py diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py new file mode 100644 index 0000000..d407a09 --- /dev/null +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -0,0 +1,82 @@ +import pytest +import numpy as np + +import archeryutils.handicaps.handicap_equations as hc_eq + + +hc_params = hc_eq.HcParams() + + +class TestSigma_t: + def test_AGB_float(self): + # Check that sigma_t returns the value we expect it to for a single float value + theta = hc_eq.sigma_t( + handicap=25.46, + hc_sys="AGB", + dist=100.0, + hc_dat=hc_params, + ) + + theta_expected = ( + hc_params.AGB_ang_0 + * ((1.0 + hc_params.AGB_step / 100.0) ** (25.46 + hc_params.AGB_datum)) + * np.exp(hc_params.AGB_kd * 100.0) + ) + assert theta == theta_expected + + def test_AGB_array(self): + # Check that sigma_t returns the value we expect it to for multiple floats + handicap_array = np.array([25.46, 0.0, 243.9]) + theta = hc_eq.sigma_t( + handicap=handicap_array, + hc_sys="AGB", + dist=100.0, + hc_dat=hc_params, + ) + + theta_expected = ( + hc_params.AGB_ang_0 + * ( + (1.0 + hc_params.AGB_step / 100.0) + ** (handicap_array + hc_params.AGB_datum) + ) + * np.exp(hc_params.AGB_kd * 100.0) + ) + assert (theta == theta_expected).all() + + +class TestSigma_r: + def test_float(self): + # Check that sigma_r returns the value we expect it to for a single float value + sig_r = hc_eq.sigma_r( + handicap=25.46, + hc_sys="AGB", + dist=100.0, + hc_dat=hc_params, + ) + + sig_r_expected = 100.0 * hc_eq.sigma_t( + handicap=25.46, + hc_sys="AGB", + dist=100.0, + hc_dat=hc_params, + ) + assert sig_r == sig_r_expected + + def test_array(self): + # Check that sigma_r returns the value we expect it to for multiple floats + handicap_array = np.array([25.46, 0.0, 243.9]) + sig_r = hc_eq.sigma_r( + handicap=handicap_array, + hc_sys="AGB", + dist=100.0, + hc_dat=hc_params, + ) + + sig_r_expected = 100.0 * hc_eq.sigma_t( + handicap=handicap_array, + hc_sys="AGB", + dist=100.0, + hc_dat=hc_params, + ) + assert (sig_r == sig_r_expected).all() diff --git a/pyproject.toml b/pyproject.toml index 6a99ffd..63afce8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "black>=22.12.0", "mypy>=1.0.0", "numpy>=1.20.3", + "pytest>=7.2.0", ] [project.urls] From e367ffb6f763511881bbb79aa67424c880de4364 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Fri, 17 Feb 2023 21:50:09 +0000 Subject: [PATCH 03/15] Mypy fix in handicap functions and linting. --- archeryutils/classifications/classifications.py | 16 ++++++++-------- archeryutils/handicaps/handicap_functions.py | 8 ++++++++ examples.py | 2 -- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index 66bc305..6d2f42b 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -324,7 +324,7 @@ def _make_AGB_outdoor_classification_dict(): def _make_AGB_indoor_classification_dict(): """ Generate AGB outdoor classification data. - + Generate a dictionary of dictionaries providing handicaps for each classification band. @@ -371,7 +371,7 @@ def _make_AGB_indoor_classification_dict(): def _make_AGB_field_classification_dict(): """ Generate AGB outdoor classification data. - + Generate a dictionary of dictionaries providing handicaps for each classification band. @@ -527,7 +527,7 @@ def _make_AGB_field_classification_dict(): def calculate_AGB_outdoor_classification(roundname, score, bowstyle, gender, age_group): """ Calculate AGB outdoor classification from score. - + Calculate a classification from a score given suitable inputs. Appropriate for 2023 ArcheryGB age groups and classifications. @@ -632,7 +632,7 @@ def calculate_AGB_outdoor_classification(roundname, score, bowstyle, gender, age def AGB_outdoor_classification_scores(roundname, bowstyle, gender, age_group): """ Calculate AGB outdoor classification scores for category. - + Subroutine to calculate classification scores for a specific category and round. Appropriate for 2023 ArcheryGB age groups and classifications. @@ -705,7 +705,7 @@ def calculate_AGB_indoor_classification( ): """ Calculate AGB indoor classification from score. - + Subroutine to calculate a classification from a score given suitable inputs. Appropriate for 2023 ArcheryGB age groups and classifications. @@ -794,7 +794,7 @@ def AGB_indoor_classification_scores( ): """ Calculate AGB indoor classification scores for category. - + Subroutine to calculate classification scores for a specific category and round. Appropriate ArcheryGB age groups and classifications. @@ -859,7 +859,7 @@ def AGB_indoor_classification_scores( def calculate_AGB_field_classification(roundname, score, bowstyle, gender, age_group): """ Calculate AGB field classification from score. - + Subroutine to calculate a classification from a score given suitable inputs. Parameters @@ -930,7 +930,7 @@ def calculate_AGB_field_classification(roundname, score, bowstyle, gender, age_g def AGB_field_classification_scores(roundname, bowstyle, gender, age_group): """ Calculate AGB field classification scores for category. - + Subroutine to calculate classification scores for a specific category and round. Appropriate ArcheryGB age groups and classifications. diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index e72f336..2f8d795 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -83,6 +83,14 @@ def print_handicap_table( "Ladies": "L", } + if not isinstance(hcs, np.ndarray): + if isinstance(hcs, list): + hcs = np.array(hcs) + elif isinstance(hcs, float) or isinstance(hcs, int): + hcs = np.array([hcs]) + else: + raise TypeError(f"Expected float or ndarray for hcs.") + table = np.empty([len(hcs), len(round_list) + 1]) table[:, 0] = hcs[:] for i, round_i in enumerate(round_list): diff --git a/examples.py b/examples.py index 0fe1f49..4be6d63 100644 --- a/examples.py +++ b/examples.py @@ -335,5 +335,3 @@ print( f"A score of 1295 on a {load_rounds.AGB_outdoor_imperial.bristol_i.name} is a discrete handicap of {hc_from_score}." ) - - rounds.AGB_outdoor_imperial.bristol_i.get_info() From 92bfc75d5cae27602a16a21baf1dd63dce60ac19 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Fri, 17 Feb 2023 22:31:34 +0000 Subject: [PATCH 04/15] Docstrings for handicaps and tests. --- archeryutils/handicaps/handicap_equations.py | 12 +- archeryutils/handicaps/handicap_functions.py | 251 +++++++++--------- .../handicaps/tests/test_handicaps.py | 46 +++- 3 files changed, 169 insertions(+), 140 deletions(-) diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py index 1b51715..5694a00 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -186,8 +186,7 @@ def sigma_t( hc_dat: HcParams, ) -> Union[float, np.ndarray]: """ - Calculate the angular deviation for a given handicap scheme, handicap value, - and distance. + Calculate angular deviation for given scheme, handicap, and distance. Parameters ---------- @@ -284,8 +283,11 @@ def sigma_r( hc_dat: HcParams, ) -> Union[float, np.ndarray]: """ - Calculate the angular deviation for a given handicap scheme, handicap value, - and data. Wraps around sigma_t() and multiplies by distance. + Calculate deviation for a given scheme and handicap value. + + Standard deviation as a proxy for 'group size' based on + handicap parameters, scheme, and distance. + Wraps around sigma_t() and multiplies by distance. Parameters ---------- @@ -309,7 +311,7 @@ def sigma_r( return sig_r -def arrow_score( +def arrow_score( # pylint: disable=too-many-branches target: targets.Target, handicap: Union[float, np.ndarray], hc_sys: str, diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index 2f8d795..b514b87 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -12,10 +12,10 @@ handicap_from_score """ -import numpy as np +from typing import Union, Optional, List import warnings from itertools import chain -from typing import Union, Optional, List +import numpy as np import archeryutils.handicaps.handicap_equations as hc_eq from archeryutils import rounds @@ -86,10 +86,10 @@ def print_handicap_table( if not isinstance(hcs, np.ndarray): if isinstance(hcs, list): hcs = np.array(hcs) - elif isinstance(hcs, float) or isinstance(hcs, int): + elif isinstance(hcs, (float, int)): hcs = np.array([hcs]) else: - raise TypeError(f"Expected float or ndarray for hcs.") + raise TypeError("Expected float or ndarray for hcs.") table = np.empty([len(hcs), len(round_list) + 1]) table[:, 0] = hcs[:] @@ -105,7 +105,7 @@ def print_handicap_table( # TODO: This assumes scores are running highest to lowest. # AA and AA2 will only work if hcs passed in reverse order (large to small) for irow, row in enumerate(table[:-1, :]): - for jscore, score in enumerate(row): + for jscore in range(len(row)): if table[irow, jscore] == table[irow + 1, jscore]: if int_prec: table[irow, jscore] = FILL @@ -141,8 +141,7 @@ def abbreviate(name): def format_row(row): if int_prec: return "".join("".rjust(14) if x == FILL else f"{x:14d}" for x in row) - else: - return "".join("".rjust(14) if np.isnan(x) else f"{x:14.8f}" for x in row) + return "".join("".rjust(14) if np.isnan(x) else f"{x:14.8f}" for x in row) output_rows = [format_row(row) for row in table] output_str = "\n".join(chain([output_header], output_rows)) @@ -195,7 +194,8 @@ def handicap_from_score( ---------- Brent's Method for Root Finding in Scipy - https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.brentq.html - - https://github.com/scipy/scipy/blob/dde39b7cc7dc231cec6bf5d882c8a8b5f40e73ad/scipy/optimize/Zeros/brentq.c + - https://github.com/scipy/scipy/blob/dde39b7cc7dc231cec6bf5d882c8a8b5f40e73ad/ + scipy/optimize/Zeros/brentq.c """ max_score = rnd.max_score() if score > max_score: @@ -203,13 +203,13 @@ def handicap_from_score( f"The score of {score} provided is greater that the maximum of {max_score} " f"for a {rnd.name}." ) - elif score <= 0.0: + if score <= 0.0: raise ValueError( f"The score of {score} provided is less than or equal to zero so cannot " "have a handicap." ) - elif score == max_score: + if score == max_score: # Deal with max score before root finding # start high and drop down until no longer ceiling to max score # (i.e. >= max_score - 1.0) @@ -243,134 +243,133 @@ def handicap_from_score( ) return hc + # ROOT FINDING for general case (not max score) + def f_root(h, scr, rd, sys, hc_data, arw_dia): + val, _ = hc_eq.score_for_round( + rd, h, sys, hc_data, arw_dia, round_score_up=False + ) + return val - scr + + if hc_sys in ["AA", "AA2"]: + x = [-250.0, 175.0] else: - # ROOT FINDING - def f_root(h, scr, rd, sys, hc_data, arw_dia): - val, _ = hc_eq.score_for_round( - rd, h, sys, hc_data, arw_dia, round_score_up=False - ) - return val - scr + x = [-75.0, 300.0] + + f = [ + f_root(x[0], score, rnd, hc_sys, hc_dat, arw_d), + f_root(x[1], score, rnd, hc_sys, hc_dat, arw_d), + ] + xtol = 1.0e-16 + rtol = 0.00 + xblk = 0.0 + fblk = 0.0 + scur = 0.0 + spre = 0.0 + dpre = 0.0 + dblk = 0.0 + stry = 0.0 + + if abs(f[1]) <= f[0]: + xcur = x[1] + xpre = x[0] + fcur = f[1] + fpre = f[0] + else: + xpre = x[1] + xcur = x[0] + fpre = f[1] + fcur = f[0] + + for _ in range(25): + if (fpre != 0.0) and (fcur != 0.0) and (np.sign(fpre) != np.sign(fcur)): + xblk = xpre + fblk = fpre + spre = xcur - xpre + scur = xcur - xpre + if abs(fblk) < abs(fcur): + # xpre = xcur + # xcur = xblk + # xblk = xpre + xpre, xcur, xblk = xcur, xblk, xcur + + # fpre = fcur + # fcur = fblk + # fblk = fpre + fpre, fcur, fblk = fcur, fblk, fcur + + delta = (xtol + rtol * abs(xcur)) / 2.0 + sbis = (xblk - xcur) / 2.0 + + if (fcur == 0.0) or (abs(sbis) < delta): + hc = xcur + break - if hc_sys in ["AA", "AA2"]: - x = [-250.0, 175.0] - else: - x = [-75.0, 300.0] - - f = [ - f_root(x[0], score, rnd, hc_sys, hc_dat, arw_d), - f_root(x[1], score, rnd, hc_sys, hc_dat, arw_d), - ] - xtol = 1.0e-16 - rtol = 0.00 - xblk = 0.0 - fblk = 0.0 - scur = 0.0 - spre = 0.0 - dpre = 0.0 - dblk = 0.0 - stry = 0.0 - - if abs(f[1]) <= f[0]: - xcur = x[1] - xpre = x[0] - fcur = f[1] - fpre = f[0] - else: - xpre = x[1] - xcur = x[0] - fpre = f[1] - fcur = f[0] - - for i in range(25): - if (fpre != 0.0) and (fcur != 0.0) and (np.sign(fpre) != np.sign(fcur)): - xblk = xpre - fblk = fpre - spre = xcur - xpre - scur = xcur - xpre - if abs(fblk) < abs(fcur): - xpre = xcur - xcur = xblk - xblk = xpre - - fpre = fcur - fcur = fblk - fblk = fpre - - delta = (xtol + rtol * abs(xcur)) / 2.0 - sbis = (xblk - xcur) / 2.0 - - if (fcur == 0.0) or (abs(sbis) < delta): - hc = xcur - break - - if (abs(spre) > delta) and (abs(fcur) < abs(fpre)): - if xpre == xblk: - stry = -fcur * (xcur - xpre) / (fcur - xpre) - else: - dpre = (fpre - fcur) / (xpre - xcur) - dblk = (fblk - fcur) / (xblk - xcur) - stry = -fcur * (fblk - fpre) / (fblk * dpre - fpre * dblk) - - if 2 * abs(stry) < min(abs(spre), 3 * abs(sbis) - delta): - # accept step - spre = scur - scur = stry - else: - # bisect - spre = sbis - scur = sbis + if (abs(spre) > delta) and (abs(fcur) < abs(fpre)): + if xpre == xblk: + stry = -fcur * (xcur - xpre) / (fcur - xpre) + else: + dpre = (fpre - fcur) / (xpre - xcur) + dblk = (fblk - fcur) / (xblk - xcur) + stry = -fcur * (fblk - fpre) / (fblk * dpre - fpre * dblk) + + if 2 * abs(stry) < min(abs(spre), 3 * abs(sbis) - delta): + # accept step + spre = scur + scur = stry else: # bisect spre = sbis scur = sbis - xpre = xcur - fpre = fcur - if abs(scur) > delta: - xcur += scur + else: + # bisect + spre = sbis + scur = sbis + xpre = xcur + fpre = fcur + if abs(scur) > delta: + xcur += scur + else: + if sbis > 0: + xcur += delta else: - if sbis > 0: - xcur += delta - else: - xcur -= delta + xcur -= delta - fcur = f_root(xcur, score, rnd, hc_sys, hc_dat, arw_d) - hc = xcur + fcur = f_root(xcur, score, rnd, hc_sys, hc_dat, arw_d) + hc = xcur - # Force integer precision if required. - # NB: added complexity - not trivial as we require asymmetric rounding. - # hence the if <0 clause - if int_prec: - if np.sign(hc) < 0: - if hc_sys in ["AA", "AA2"]: - hc = np.ceil(hc) - else: - hc = np.floor(hc) + # Force integer precision if required. + # NB: added complexity - not trivial as we require asymmetric rounding. + # hence the if <0 clause + if int_prec: + if np.sign(hc) < 0: + if hc_sys in ["AA", "AA2"]: + hc = np.ceil(hc) else: - if hc_sys in ["AA", "AA2"]: - hc = np.floor(hc) - else: - hc = np.ceil(hc) + hc = np.floor(hc) + else: + if hc_sys in ["AA", "AA2"]: + hc = np.floor(hc) + else: + hc = np.ceil(hc) + sc, _ = hc_eq.score_for_round( + rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=True + ) + + # Check that you can't get the same score from a larger handicap when + # working in integers + min_h_flag = False + if hc_sys in ["AA", "AA2"]: + hstep = -1.0 + else: + hstep = 1.0 + while not min_h_flag: + hc += hstep sc, _ = hc_eq.score_for_round( rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=True ) + if sc < score: + hc -= hstep # undo the iteration that caused the flag to raise + min_h_flag = True - # Check that you can't get the same score from a larger handicap when - # working in integers - min_h_flag = False - if hc_sys in ["AA", "AA2"]: - hstep = -1.0 - else: - hstep = 1.0 - while not min_h_flag: - hc += hstep - sc, _ = hc_eq.score_for_round( - rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=True - ) - if sc < score: - hc -= hstep # undo the iteration that caused the flag to raise - min_h_flag = True - return hc - - else: - return hc + return hc diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py index d407a09..8e0979e 100644 --- a/archeryutils/handicaps/tests/test_handicaps.py +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -1,4 +1,4 @@ -import pytest +"""Tests for handicap equations and functions""" import numpy as np import archeryutils.handicaps.handicap_equations as hc_eq @@ -7,9 +7,21 @@ hc_params = hc_eq.HcParams() -class TestSigma_t: - def test_AGB_float(self): - # Check that sigma_t returns the value we expect it to for a single float value +class TestSigmaT: + """ + Class to test the sigma_t() function of handicap_equations. + + Methods + ------- + test_agb_float() + test if expected sigma_t returned for AGB from float + test_agb_array() + test if expected sigma_t returned for AGB from array of floats + """ + def test_agb_float(self): + """ + Check that sigma_t(handicap=float) returns expected value for a case. + """ theta = hc_eq.sigma_t( handicap=25.46, hc_sys="AGB", @@ -24,8 +36,10 @@ def test_AGB_float(self): ) assert theta == theta_expected - def test_AGB_array(self): - # Check that sigma_t returns the value we expect it to for multiple floats + def test_agb_array(self): + """ + Check that sigma_t(handicap=ndarray) returns expected value for a case. + """ handicap_array = np.array([25.46, 0.0, 243.9]) theta = hc_eq.sigma_t( handicap=handicap_array, @@ -45,9 +59,21 @@ def test_AGB_array(self): assert (theta == theta_expected).all() -class TestSigma_r: +class TestSigmaR: + """ + Class to test the sigma_r() function of handicap_equations. + + Methods + ------- + test_float() + test if expected sigma_r returned for AGB from float + test_array() + test if expected sigma_r returned for AGB from array of floats + """ def test_float(self): - # Check that sigma_r returns the value we expect it to for a single float value + """ + Check that sigma_r(handicap=float) returns expected value for a case. + """ sig_r = hc_eq.sigma_r( handicap=25.46, hc_sys="AGB", @@ -64,7 +90,9 @@ def test_float(self): assert sig_r == sig_r_expected def test_array(self): - # Check that sigma_r returns the value we expect it to for multiple floats + """ + Check that sigma_r(handicap=ndarray) returns expected value for a case. + """ handicap_array = np.array([25.46, 0.0, 243.9]) sig_r = hc_eq.sigma_r( handicap=handicap_array, From 662ddc0fabb6a2ba84332c87b07ca9da052ed35e Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Fri, 17 Feb 2023 22:39:34 +0000 Subject: [PATCH 05/15] blackify handicaps and link/document load_rounds. --- archeryutils/handicaps/handicap_equations.py | 2 +- archeryutils/handicaps/tests/test_handicaps.py | 2 ++ archeryutils/load_rounds.py | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py index 5694a00..1abfee0 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -284,7 +284,7 @@ def sigma_r( ) -> Union[float, np.ndarray]: """ Calculate deviation for a given scheme and handicap value. - + Standard deviation as a proxy for 'group size' based on handicap parameters, scheme, and distance. Wraps around sigma_t() and multiplies by distance. diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py index 8e0979e..4da2364 100644 --- a/archeryutils/handicaps/tests/test_handicaps.py +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -18,6 +18,7 @@ class TestSigmaT: test_agb_array() test if expected sigma_t returned for AGB from array of floats """ + def test_agb_float(self): """ Check that sigma_t(handicap=float) returns expected value for a case. @@ -70,6 +71,7 @@ class TestSigmaR: test_array() test if expected sigma_r returned for AGB from array of floats """ + def test_float(self): """ Check that sigma_r(handicap=float) returns expected value for a case. diff --git a/archeryutils/load_rounds.py b/archeryutils/load_rounds.py index 3162101..78fc3bc 100644 --- a/archeryutils/load_rounds.py +++ b/archeryutils/load_rounds.py @@ -1,3 +1,4 @@ +"""Module to load round data from json files into DotDicts.""" import json from pathlib import Path import warnings @@ -22,7 +23,7 @@ def read_json_to_round_dict(json_filelist): References ---------- """ - if type(json_filelist) is not list: + if not isinstance(json_filelist, list): json_filelist = [json_filelist] round_data_files = Path(__file__).parent.joinpath("round_data_files") @@ -31,7 +32,7 @@ def read_json_to_round_dict(json_filelist): for json_file in json_filelist: json_filepath = round_data_files.joinpath(json_file) - with open(json_filepath) as json_round_file: + with open(json_filepath, encoding="utf-8") as json_round_file: data = json.load(json_round_file) for round_i in data: @@ -150,8 +151,7 @@ class DotDict(dict): def __getattr__(self, name): if name in self: return self[name] - else: - raise AttributeError(self._attribute_err_msg(name)) + raise AttributeError(self._attribute_err_msg(name)) def __setattr__(self, name, value): self[name] = value From ab149e85ff0ad7bf8403b77a7f254f0b4a16533d Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Fri, 17 Feb 2023 23:07:58 +0000 Subject: [PATCH 06/15] lint and document target and rounds. --- archeryutils/load_rounds.py | 42 ++++++++---- archeryutils/rounds.py | 123 ++++++++++++++---------------------- archeryutils/targets.py | 44 +++---------- 3 files changed, 84 insertions(+), 125 deletions(-) diff --git a/archeryutils/load_rounds.py b/archeryutils/load_rounds.py index 78fc3bc..08b1af4 100644 --- a/archeryutils/load_rounds.py +++ b/archeryutils/load_rounds.py @@ -8,8 +8,7 @@ def read_json_to_round_dict(json_filelist): """ - Subroutine to return round information read in from a json file as a dictionary of - rounds + Read round information from a json file into a dictionary of rounds. Parameters ---------- @@ -19,9 +18,6 @@ def read_json_to_round_dict(json_filelist): Returns ------- round_dict : dict of str : rounds.Round - - References - ---------- """ if not isinstance(json_filelist, list): json_filelist = [json_filelist] @@ -135,28 +131,48 @@ def read_json_to_round_dict(json_filelist): class DotDict(dict): """ - A subclass of dict to provide dot notation access to a dictionary - - Attributes - ---------- - - Methods - ------- + A subclass of dict to provide dot notation access to a dictionary. References - ------- + ---------- https://goodcode.io/articles/python-dict-object/ """ def __getattr__(self, name): + """ + getter. + + Parameters + ---------- + name : str + name of attribute to look for in self + """ if name in self: return self[name] raise AttributeError(self._attribute_err_msg(name)) def __setattr__(self, name, value): + """ + setter. + + Parameters + ---------- + name : str + name of attribute to look for in self + value : any + value to set for attribute matching name + """ self[name] = value def __delattr__(self, name): + """ + delete. + + Parameters + ---------- + name : str + name of attribute to delete from self + """ if name in self: del self[name] else: diff --git a/archeryutils/rounds.py b/archeryutils/rounds.py index c3f0232..6f6a228 100644 --- a/archeryutils/rounds.py +++ b/archeryutils/rounds.py @@ -1,13 +1,4 @@ -# Author : Jack Atkinson -# -# Contributors : Jack Atkinson -# -# Date Created : 2022-08-16 -# Last Modified : 2022-08-22 by Jack Atkinson -# -# Summary : definition of classes to define rounds for archery applications -# - +"""Classes to define a Pass and Round for archery applications.""" import numpy as np from archeryutils.targets import Target @@ -16,15 +7,25 @@ class Pass: """ - A class used to represent a Pass, a subunit of a Round - e.g. a single distance or half + A class used to represent a Pass. + + This class represents a pass of arrows, i.e. a subunit of a Round. + e.g. a single distance or half Attributes ---------- - target : Target - a Target class representing the target used n_arrows : int - the number of arrows shot at the target in this Pass + number of arrows in this pass + diameter : float + face diameter in [metres] + scoring_system : str + target face/scoring system type + distance : float + linear distance from archer to target + dist_unit : str + The unit distance is measured in. default = 'metres' + indoor : bool + is round indoors for arrow diameter purposes? default = False Methods ------- @@ -41,67 +42,52 @@ def __init__( dist_unit="metres", indoor=False, ): - """ - Parameters - ---------- - n_arrows : int - number of arrows in this pass - diameter : float - face diameter in [metres] - scoring_system : str - target face/scoring system type - distance : float - linear distance from archer to target - dist_unit : str - The unit distance is measured in. default = 'metres' - indoor : bool - is round indoors for arrow diameter purposes? default = False - """ - self.n_arrows = n_arrows self.target = Target(diameter, scoring_system, distance, dist_unit, indoor) @property def distance(self): + """Get distance.""" return self.target.distance @property def native_dist_unit(self): + """Get native_dist_unit.""" return self.target.native_dist_unit @property def diameter(self): + """Get diameter.""" return self.target.diameter @property def scoring_system(self): + """Get scoring_system.""" return self.target.scoring_system @property def indoor(self): + """Get indoor.""" return self.target.indoor def max_score(self): """ - max_score - returns the maximum numerical score possible on this pass (not counting x's) - - Parameters - ---------- + Return the maximum numerical score possible on this pass (not counting x's). Returns - ---------- + ------- max_score : float maximum score possible on this pass """ - return self.n_arrows * self.target.max_score() class Round: """ - A class used to represent a Round - Made up of a number of Passes + Class representing a Round. + + Describes an archer round made up of a number of Passes. + e.g. for different distances. Attributes ---------- @@ -109,6 +95,12 @@ class Round: Formal name of the round passes : list of Pass a list of Pass classes making up the round + location : str or None + string identifing where the round is shot + body : str or None + string identifing the governing body the round belongs to + family : str or None + string identifing the family the round belongs to (e.g. wa1440, western, etc.) Methods ------- @@ -119,22 +111,14 @@ class Round: """ - def __init__(self, name, passes, location=None, body=None, family=None): - """ - Parameters - ---------- - name : str - Formal name of the round - passes : list of Pass - a list of Pass classes making up the round - location : str or None - string identifing where the round is shot - body : str or None - string identifing the governing body the round belongs to - family : str or None - string identifing the family the round belongs to (e.g. wa1440, western, etc.) - - """ + def __init__( + self, + name, + passes, + location=None, + body=None, + family=None, + ): self.name = name self.passes = passes self.location = location @@ -142,16 +126,7 @@ def __init__(self, name, passes, location=None, body=None, family=None): self.family = family def get_info(self): - """ - method get_info() - Prints information about the round - - Parameters - ---------- - - Returns - ------- - """ + """Print information about the Round.""" print(f"A {self.name} consists of {len(self.passes)} passes:") for pass_i in self.passes: if pass_i.native_dist_unit == "yard": @@ -166,24 +141,18 @@ def get_info(self): def max_score(self): """ - max_score - returns the maximum numerical score possible on this round (not counting x's) - - Parameters - ---------- + Return the maximum numerical score possible on this round (not counting x's). Returns - ---------- + ------- max_score : float maximum score possible on this round """ - return np.sum([pass_i.max_score() for pass_i in self.passes]) def max_distance(self, unit=False): """ - max_distance - returns the maximum distance shot on this round along with the unit (optional) + Return the maximum distance shot on this round along with the unit (optional). Parameters ---------- @@ -191,7 +160,7 @@ def max_distance(self, unit=False): Return unit as well as numerical value? Returns - ---------- + ------- max_dist : float maximum distance shot in this round (max_dist, unit) : tuple (float, str) diff --git a/archeryutils/targets.py b/archeryutils/targets.py index 357460b..ae94651 100644 --- a/archeryutils/targets.py +++ b/archeryutils/targets.py @@ -1,32 +1,24 @@ -# Author : Jack Atkinson -# -# Contributors : Jack Atkinson -# -# Date Created : 2022-08-16 -# Last Modified : 2022-08-18 by Jack Atkinson -# -# Summary : definition of a target class for archery applications -# +"""Class to represent a Target for archery applications.""" from archeryutils.constants import YARD_TO_METRE class Target: """ - A class used to represent a target + Class to represent a target. Attributes ---------- diameter : float - Target face diameter in [centimetres] + Target face diameter in [metres] + scoring_system : str + target face/scoring system type distance : float - Linear distance from archer to target + linear distance from archer to target native_dist_unit : str The native unit distance is measured in - scoring_system : str - the type of target face (scoring system) used indoor : bool - is this an indoor event (use large arrow diameter) + is round indoors for arrow diameter purposes? default = False Methods ------- @@ -42,20 +34,6 @@ def __init__( native_dist_unit=None, indoor=False, ): - """ - Parameters - ---------- - diameter : float - face diameter in [metres] - scoring_system : str - target face/scoring system type - distance : float - linear distance from archer to target - native_dist_unit : str - The native unit distance is measured in - indoor : bool - is round indoors for arrow diameter purposes? default = False - """ systems = [ "5_zone", "10_zone", @@ -118,14 +96,10 @@ def __init__( def max_score(self): """ - max_score - returns the maximum numerical score possible on this target (i.e. not X) - - Parameters - ---------- + Return the maximum numerical score possible on this target (i.e. not X). Returns - ---------- + ------- max_score : float maximum score possible on this target face """ From 4f0822f267e3ae4fee14f6d6eebe7ea113f607f3 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Sat, 18 Feb 2023 14:26:51 +0000 Subject: [PATCH 07/15] Linting and docs of classifications. --- .../classifications/classifications.py | 97 ++++++++++++++++--- archeryutils/load_rounds.py | 2 +- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index 6d2f42b..530a5e0 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -1,5 +1,5 @@ """ -Code for doing things with archery handicap equations. +Code for calculating Archery GB classifications. Extended Summary ---------------- @@ -28,25 +28,74 @@ from pathlib import Path import numpy as np -from archeryutils import rounds, load_rounds +from archeryutils import load_rounds from archeryutils.handicaps import handicap_equations as hc_eq def read_ages_json(age_file=Path(__file__).parent / "AGB_ages.json"): - # Read in age group info as list of dicts + """ + Read AGB age categories in from neighbouring json file to list of dicts. + + Parameters + ---------- + age_file : Path + path to json file + + Returns + ------- + ages : list of dict + AGB age category data from file + + References + ---------- + Archery GB Rules of Shooting + """ with open(age_file, encoding="utf-8") as json_file: ages = json.load(json_file) return ages def read_bowstyles_json(bowstyles_file=Path(__file__).parent / "AGB_bowstyles.json"): - # Read in bowstyleclass info as list of dicts + """ + Read AGB bowstyles in from neighbouring json file to list of dicts. + + Parameters + ---------- + bowstyles_file : Path + path to json file + + Returns + ------- + bowstyles : list of dict + AGB bowstyle category data from file + + References + ---------- + Archery GB Rules of Shooting + """ with open(bowstyles_file, encoding="utf-8") as json_file: bowstyles = json.load(json_file) return bowstyles def read_genders_json(genders_file=Path(__file__).parent / "AGB_genders.json"): + """ + Read AGB genders in from neighbouring json file to list of dicts. + + Parameters + ---------- + genders_file : Path + path to json file + + Returns + ------- + genders : list of dict + AGB gender data from file + + References + ---------- + Archery GB Rules of Shooting + """ # Read in gender info as list with open(genders_file, encoding="utf-8") as json_file: genders = json.load(json_file)["genders"] @@ -54,6 +103,23 @@ def read_genders_json(genders_file=Path(__file__).parent / "AGB_genders.json"): def read_classes_json(classes_file=Path(__file__).parent / "AGB_classes.json"): + """ + Read AGB classes in from neighbouring json file to list of dicts. + + Parameters + ---------- + classes_file : Path + path to json file + + Returns + ------- + classes : list of dict + AGB classes data from file + + References + ---------- + Archery GB Rules of Shooting + """ # Read in classification names as dict with open(classes_file, encoding="utf-8") as json_file: classes = json.load(json_file) @@ -606,19 +672,19 @@ def calculate_AGB_outdoor_classification(roundname, score, bowstyle, gender, age # If not prestige, what classes are eligible based on category and distance to_del = [] round_max_dist = all_outdoor_rounds[roundname].max_distance() - for item in class_data: - if class_data[item]["min_dists"][-1] > round_max_dist: - to_del.append(item) - for item in to_del: - del class_data[item] + for class_i in class_data.items(): + if class_i[1]["min_dists"][-1] > round_max_dist: + to_del.append(class_i[0]) + for class_i in to_del: + del class_data[class_i] # Classification based on score - accounts for fractional HC # TODO Make this its own function for later use in geberating tables? # Of those classes remaining, what is the highest classification this score gets? to_del = [] - for item in class_data: - if class_data[item]["score"] > score: - to_del.append(item) + for item in class_data.items(): + if item[1]["score"] > score: + to_del.append(item[0]) for item in to_del: del class_data[item] @@ -978,8 +1044,11 @@ def AGB_field_classification_scores(roundname, bowstyle, gender, age_group): if __name__ == "__main__": - for item in AGB_outdoor_classifications: - print(item, AGB_outdoor_classifications[item]["prestige_rounds"]) + for classification in AGB_outdoor_classifications.items(): + print( + classification[0], + classification[1]["prestige_rounds"], + ) print( calculate_AGB_outdoor_classification( diff --git a/archeryutils/load_rounds.py b/archeryutils/load_rounds.py index 08b1af4..7848bc6 100644 --- a/archeryutils/load_rounds.py +++ b/archeryutils/load_rounds.py @@ -154,7 +154,7 @@ def __getattr__(self, name): def __setattr__(self, name, value): """ setter. - + Parameters ---------- name : str From 9d6aa83b2d7d24272b8d39fda89f5b3540668ee6 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Mon, 13 Mar 2023 09:19:04 +0000 Subject: [PATCH 08/15] add tests for Target class. --- archeryutils/targets.py | 7 ++- archeryutils/tests/__init__.py | 0 archeryutils/tests/test_targets.py | 96 ++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 archeryutils/tests/__init__.py create mode 100644 archeryutils/tests/test_targets.py diff --git a/archeryutils/targets.py b/archeryutils/targets.py index ae94651..2438d0e 100644 --- a/archeryutils/targets.py +++ b/archeryutils/targets.py @@ -31,7 +31,7 @@ def __init__( diameter, scoring_system, distance=None, - native_dist_unit=None, + native_dist_unit='metre', indoor=False, ): systems = [ @@ -82,7 +82,7 @@ def __init__( native_dist_unit = "metre" else: raise ValueError( - f"distance unit '{native_dist_unit}' not recognised. " + f"Distance unit '{native_dist_unit}' not recognised. " "Select from 'yard' or 'metre'." ) @@ -125,6 +125,7 @@ def max_score(self): 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. raise ValueError( - f"target face '{self.scoring_system}' has no specified maximum score" + f"Target face '{self.scoring_system}' has no specified maximum score." ) diff --git a/archeryutils/tests/__init__.py b/archeryutils/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/archeryutils/tests/test_targets.py b/archeryutils/tests/test_targets.py new file mode 100644 index 0000000..5340f3e --- /dev/null +++ b/archeryutils/tests/test_targets.py @@ -0,0 +1,96 @@ +"""Tests for Target class""" +import pytest + +from archeryutils.targets import Target + + +class TestTarget: + """ + Class to test the Target class. + + Methods + ------- + def test_invalid_system() + test if invalid target face type system raises an error + test_invalid_distance_unit() + test if invalid distance unit raises an error + """ + + def test_invalid_system(self): + """ + Check that Target() returns error value for invalid system. + """ + with pytest.raises( + ValueError, + match="Invalid Target Face Type specified.\nPlease select from '(.+)'.", + ): + Target(1.22, "InvalidScoringSystem", 50, "m", False) + + def test_invalid_distance_unit(self): + """ + Check that Target() returns error value for invalid distance units. + """ + with pytest.raises( + ValueError, + match="Distance unit '(.+)' not recognised. Select from 'yard' or 'metre'.", + ): + Target(1.22, "5_zone", 50, "InvalidDistanceUnit", False) + + def test_default_distance_unit(self): + """ + Check that Target() returns distance in metres when units not specified. + """ + target = Target(1.22, "5_zone", 50) + assert target.native_dist_unit == "metre" + + def test_default_location(self): + """ + Check that Target() returns indoor=False when indoor not specified. + """ + target = Target(1.22, "5_zone", 50, "m") + assert target.indoor is False + + def test_yard_to_m_conversion(self): + """ + Check that Target() returns correct distance in metres when yards provided. + """ + target = Target(1.22, "5_zone", 50, "yards") + assert target.distance == 50.0 * 0.9144 + + @pytest.mark.parametrize( + "face_type,max_score_expected", + [ + ("5_zone", 9), + ("10_zone", 10), + ("10_zone_compound", 10), + ("10_zone_6_ring", 10), + ("10_zone_6_ring_compound", 10), + ("10_zone_5_ring", 10), + ("10_zone_5_ring_compound", 10), + ("WA_field", 6), + ("IFAA_field", 5), + ("IFAA_field_expert", 5), + ("Worcester", 5), + ("Worcester_2_ring", 5), + ("Beiter_hit_miss", 1), + ], + ) + def test_max_score(self, face_type, max_score_expected): + """ + Check that Target() returns correct distance in metres when yards provided. + """ + target = Target(1.22, face_type, 50, "metre", False) + assert target.max_score() == max_score_expected + + def test_max_score_invalid_face_type(self): + """ + Check that Target() returns correct distance in metres when yards provided. + """ + with pytest.raises( + ValueError, + match="Target face '(.+)' has no specified maximum score.", + ): + target = Target(1.22, "5_zone", 50, "metre", False) + # Requires manual resetting of scoring system to get this error. + target.scoring_system = "InvalidScoringSystem" + target.max_score() From 1d993ce3adb6d0b6017d29f0fea51d40b83b407d Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Mon, 13 Mar 2023 13:17:56 +0000 Subject: [PATCH 09/15] add tests for Pass and Round classes. --- archeryutils/rounds.py | 32 +++--- archeryutils/tests/test_rounds.py | 169 ++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 16 deletions(-) create mode 100644 archeryutils/tests/test_rounds.py diff --git a/archeryutils/rounds.py b/archeryutils/rounds.py index 6f6a228..5ae854d 100644 --- a/archeryutils/rounds.py +++ b/archeryutils/rounds.py @@ -21,7 +21,7 @@ class Pass: scoring_system : str target face/scoring system type distance : float - linear distance from archer to target + linear distance from archer to target in [metres] dist_unit : str The unit distance is measured in. default = 'metres' indoor : bool @@ -42,7 +42,7 @@ def __init__( dist_unit="metres", indoor=False, ): - self.n_arrows = n_arrows + self.n_arrows = abs(n_arrows) self.target = Target(diameter, scoring_system, distance, dist_unit, indoor) @property @@ -125,20 +125,6 @@ def __init__( self.body = body self.family = family - def get_info(self): - """Print information about the Round.""" - print(f"A {self.name} consists of {len(self.passes)} passes:") - for pass_i in self.passes: - if pass_i.native_dist_unit == "yard": - native_dist = pass_i.target.distance / YARD_TO_METRE - else: - native_dist = pass_i.distance - print( - f"\t- {pass_i.n_arrows} arrows " - f"at a {pass_i.diameter * 100.0} cm target " - f"at {native_dist} {pass_i.native_dist_unit}s." - ) - def max_score(self): """ Return the maximum numerical score possible on this round (not counting x's). @@ -180,3 +166,17 @@ def max_distance(self, unit=False): if unit: return (max_dist, d_unit) return max_dist + + def get_info(self): + """Print information about the Round.""" + print(f"A {self.name} consists of {len(self.passes)} passes:") + for pass_i in self.passes: + if pass_i.native_dist_unit == "yard": + native_dist = pass_i.target.distance / YARD_TO_METRE + else: + native_dist = pass_i.distance + print( + f"\t- {pass_i.n_arrows} arrows " + f"at a {pass_i.diameter * 100.0:.1f} cm target " + f"at {native_dist:.1f} {pass_i.native_dist_unit}s." + ) diff --git a/archeryutils/tests/test_rounds.py b/archeryutils/tests/test_rounds.py new file mode 100644 index 0000000..c9eac87 --- /dev/null +++ b/archeryutils/tests/test_rounds.py @@ -0,0 +1,169 @@ +"""Tests for Pass and Round classes""" +import pytest + +from archeryutils.rounds import Pass, Round + + +class TestPass: + """ + Class to test the Pass class. + + Methods + ------- + test_default_distance_unit() + test behaviour of default distance unit + test_default_location() + test behaviour of default location + test_negative_arrows() + test behaviour of negative arrow number + test_properties() + test setting of Pass properties + test_max_score() + test max score functionality of Pass + """ + + def test_default_distance_unit(self): + """ + Check that Pass() returns distance in metres when units not specified. + """ + test_pass = Pass(36, 1.22, "5_zone", 50) + assert test_pass.native_dist_unit == "metre" + + def test_default_location(self): + """ + Check that Pass() returns indoor=False when indoor not specified. + """ + test_pass = Pass(36, 1.22, "5_zone", 50, "metre") + assert test_pass.indoor is False + + def test_negative_arrows(self): + """ + Check that Pass() uses abs(narrows). + """ + test_pass = Pass(-36, 1.22, "5_zone", 50, "metre") + assert test_pass.n_arrows == 36 + + def test_properties(self): + """ + Check that Pass properties are set correctly + """ + test_pass = Pass(36, 1.22, "5_zone", 50, "metre", False) + assert test_pass.distance == 50.0 + assert test_pass.native_dist_unit == "metre" + assert test_pass.diameter == 1.22 + assert test_pass.scoring_system == "5_zone" + assert test_pass.indoor is False + + @pytest.mark.parametrize( + "face_type,max_score_expected", + [ + ("5_zone", 900), + ("10_zone", 1000), + ("WA_field", 600), + ("IFAA_field", 500), + ("Worcester_2_ring", 500), + ("Beiter_hit_miss", 100), + ], + ) + def test_max_score(self, face_type, max_score_expected): + """ + Check that Pass.max_score() method is functioning correctly + """ + test_pass = Pass(100, 1.22, face_type, 50, "metre", False) + assert test_pass.max_score() == max_score_expected + + +class TestRound: + """ + Class to test the Round class. + + Methods + ------- + def test_max_score() + test max score functionality of Round + def test_max_distance() + test max distance functionality of Round + test_max_distance_out_of_order() + test max distance functionality of Round with unsorted Passes + def test_get_info() + test get_info functionality of Round + """ + + def test_max_score(self): + """ + Check that max score is calculated correctly for a Round + """ + + test_round = Round( + "MyRound", + [ + Pass(100, 1.22, "5_zone", 50, "metre", False), + Pass(100, 1.22, "5_zone", 40, "metre", False), + Pass(100, 1.22, "5_zone", 30, "metre", False), + ], + ) + assert test_round.max_score() == 2700 + + @pytest.mark.parametrize( + "unit,get_unit,max_dist_expected", + [ + ("metres", True, (100, "metre")), + ("yards", True, (100, "yard")), + ("metres", False, 100), + ("yards", False, 100), + ], + ) + def test_max_distance(self, unit, get_unit, max_dist_expected): + """ + Check that max distance is calculated correctly for a Round. + + Returns a tuple or float depending on input argument. + Always returns the distance in appropriate units regardless of whether unit + requested or not - i.e. should not convert yards to metres. + """ + + test_round = Round( + "MyRound", + [ + Pass(10, 1.22, "5_zone", 100, unit, False), + Pass(10, 1.22, "5_zone", 80, unit, False), + Pass(10, 1.22, "5_zone", 60, unit, False), + ], + ) + assert test_round.max_distance(unit=get_unit) == max_dist_expected + + def test_max_distance_out_of_order(self): + """ + Check max distance correct for Round where Passes not in descending dist order. + """ + + test_round = Round( + "MyRound", + [ + Pass(10, 1.22, "5_zone", 80, "metre", False), + Pass(10, 1.22, "5_zone", 100, "metre", False), + Pass(10, 1.22, "5_zone", 60, "metre", False), + ], + ) + assert test_round.max_distance() == 100 + + def test_get_info(self, capsys): + """ + Check printing info works as expected. + """ + test_round = Round( + "MyRound", + [ + Pass(10, 1.22, "5_zone", 100, "metre", False), + Pass(20, 1.22, "5_zone", 80, "yards", False), + Pass(30, 0.80, "5_zone", 60, "metre", False), + ], + ) + test_round.get_info() + captured = capsys.readouterr() + assert captured.out == ( + "A MyRound consists of 3 passes:\n" + + "\t- 10 arrows at a 122.0 cm target at 100.0 metres.\n" + + "\t- 20 arrows at a 122.0 cm target at 80.0 yards.\n" + + "\t- 30 arrows at a 80.0 cm target at 60.0 metres.\n" + ) From 1b5fd0f5bad6ba79941e2d949dd9586f5fa19df3 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Mon, 13 Mar 2023 13:27:49 +0000 Subject: [PATCH 10/15] Make distance a required argument for a target. --- archeryutils/targets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/archeryutils/targets.py b/archeryutils/targets.py index 2438d0e..f94b4ad 100644 --- a/archeryutils/targets.py +++ b/archeryutils/targets.py @@ -30,7 +30,7 @@ def __init__( self, diameter, scoring_system, - distance=None, + distance, native_dist_unit='metre', indoor=False, ): From 59bff64cabf95a17c97b861b15b3d12f4627ac90 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Mon, 13 Mar 2023 13:47:10 +0000 Subject: [PATCH 11/15] Added 5mm diameter arrow for Archery Australia calculations to match JP paper. Added 5mm diameter arrow for Archery Australia calculations to match JP paper. Added 5mm diameter arrow for Archery Australia calculations to match JP paper. --- archeryutils/handicaps/handicap_equations.py | 12 +++-- archeryutils/handicaps/hc_sys_params.json | 49 +++++++++----------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py index 1abfee0..d3ad0a2 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -131,6 +131,8 @@ class HcParams: AA2_f2 = 0.185 AA2_d0 = 50.0 + AA_arw_d_out = 5.0e-3 + arw_d_in = 9.3e-3 arw_d_out = 5.5e-3 @@ -173,6 +175,7 @@ def load_json_params(cls, jsonpath): json_hc_params.AA2_f1 = paramsdict["AA2_f1"] json_hc_params.AA2_f2 = paramsdict["AA2_f2"] json_hc_params.AA2_d0 = paramsdict["AA2_d0"] + json_hc_params.AA_arw_d_out = paramsdict["AA_arw_d_out"] json_hc_params.arw_d_in = paramsdict["arrow_diameter_indoors"] json_hc_params.arw_d_out = paramsdict["arrow_diameter_outdoors"] @@ -344,8 +347,8 @@ def arrow_score( # pylint: disable=too-many-branches - The construction of the graduated handicap tables for target archery Lane, D (2013) """ - # Set arrow diameter. Use provided, if AGBold scheme set value, otherwise select - # default from params based on in/out + # Set arrow diameter. Use provided, if AGBold or AA/AA2 scheme set value, + # otherwise select default from params based on in-/out-doors if arw_d is None: if hc_sys == "AGBold": arw_rad = hc_dat.AGBo_arw_d / 2.0 @@ -353,7 +356,10 @@ def arrow_score( # pylint: disable=too-many-branches if target.indoor: arw_rad = hc_dat.arw_d_in / 2.0 else: - arw_rad = hc_dat.arw_d_out / 2.0 + if hc_sys in ["AA", "AA2"]: + arw_rad = hc_dat.AA_arw_d_out / 2.0 + else: + arw_rad = hc_dat.arw_d_out / 2.0 else: arw_rad = arw_d / 2.0 diff --git a/archeryutils/handicaps/hc_sys_params.json b/archeryutils/handicaps/hc_sys_params.json index b71783e..f71b98a 100644 --- a/archeryutils/handicaps/hc_sys_params.json +++ b/archeryutils/handicaps/hc_sys_params.json @@ -1,28 +1,25 @@ { - "AGB_datum" : 6.0, - "AGB_step" : 3.5, - "AGB_ang_0" : 5.0e-4, - "AGB_kd" : 0.00365, - - "AGBo_datum" : 12.9, - "AGBo_step" : 3.6, - "AGBo_ang_0" : 5.0e-4, - "AGBo_k1" : 1.429e-6, - "AGBo_k2" : 1.07, - "AGBo_k3" : 4.3, - "AGBo_p1" : 2.0, - "AGBo_arw_d" : 7.14e-3, - - "AA_k0" : 2.37, - "AA_ks" : 0.027, - "AA_kd" : 0.004, - - "AA2_k0" : 2.57, - "AA2_ks" : 0.027, - "AA2_f1" : 0.815, - "AA2_f2" : 0.185, - "AA2_d0" : 50.0, - - "arrow_diameter_indoors" : 9.3e-3, - "arrow_diameter_outdoors" : 5.5e-3 + "AGB_datum": 6.0, + "AGB_step": 3.5, + "AGB_ang_0": 5.0e-4, + "AGB_kd": 0.00365, + "AGBo_datum": 12.9, + "AGBo_step": 3.6, + "AGBo_ang_0": 5.0e-4, + "AGBo_k1": 1.429e-6, + "AGBo_k2": 1.07, + "AGBo_k3": 4.3, + "AGBo_p1": 2.0, + "AGBo_arw_d": 7.14e-3, + "AA_k0": 2.37, + "AA_ks": 0.027, + "AA_kd": 0.004, + "AA2_k0": 2.57, + "AA2_ks": 0.027, + "AA2_f1": 0.815, + "AA2_f2": 0.185, + "AA2_d0": 50.0, + "AA_arw_d_out": 5.0e-3, + "arrow_diameter_indoors": 9.3e-3, + "arrow_diameter_outdoors": 5.5e-3 } From 858a9628e9681f900e1fb60ba903c4b5013d9101 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Mon, 13 Mar 2023 15:13:14 +0000 Subject: [PATCH 12/15] add tests for handicap equations. --- archeryutils/handicaps/handicap_equations.py | 2 +- .../handicaps/tests/test_handicaps.py | 357 +++++++++++++++--- archeryutils/targets.py | 1 - 3 files changed, 311 insertions(+), 49 deletions(-) diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py index d3ad0a2..b326d90 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -445,7 +445,7 @@ def arrow_score( # pylint: disable=too-many-branches elif target.scoring_system == "Beiter_hit_miss": s_bar = 1 - np.exp(-((((tar_dia / 2) + arw_rad) / sig_r) ** 2)) - elif target.scoring_system == "Worcester": + elif target.scoring_system in ["Worcester", "IFAA_field_expert"]: s_bar = 5 - sum( np.exp(-((((n * tar_dia / 10) + arw_rad) / sig_r) ** 2)) for n in range(1, 6) diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py index 4da2364..5f37b73 100644 --- a/archeryutils/handicaps/tests/test_handicaps.py +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -1,7 +1,10 @@ """Tests for handicap equations and functions""" import numpy as np +import pytest import archeryutils.handicaps.handicap_equations as hc_eq +from archeryutils.targets import Target +from archeryutils.rounds import Round, Pass hc_params = hc_eq.HcParams() @@ -13,51 +16,80 @@ class TestSigmaT: Methods ------- - test_agb_float() - test if expected sigma_t returned for AGB from float - test_agb_array() - test if expected sigma_t returned for AGB from array of floats + def test_invalid_system() + test if invalid handicap system raises error + test_float() + test if expected sigma_t returned from float + test_array() + test if expected sigma_t returned from array of floats """ - def test_agb_float(self): + def test_invalid_system(self): + """ + Check that sigma_t() returns error value for invalid system. + """ + with pytest.raises( + ValueError, + match=( + "Invalid Handicap System specified.\n" + + "Please select from 'AGB', 'AGBold', 'AA', or 'AA2'." + ), + ): + hc_eq.sigma_t( + handicap=10.0, + hc_sys=None, + dist=10.0, + hc_dat=hc_params, + ) + + @pytest.mark.parametrize( + "handicap,system,distance,theta_expected", + # Check all systems, different distances, negative and large handicaps. + [ + (25.46, "AGB", 100.0, 0.002125743670979009), + (25.46, "AGBold", 100.0, 0.002149455433015334), + (25.46, "AA", 100.0, 0.011349271879457612), + (25.46, "AA2", 100.0, 0.011011017526614786), + (-12.0, "AGB", 100.0, 0.0005859295368818659), + (-12.0, "AGBold", 100.0, 0.000520552194308095), + (-12.0, "AA", 100.0, 0.03120485183570297), + (-12.0, "AA2", 100.0, 0.03027482063411134), + (200.0, "AGB", 10.0, 0.6202029252075888), + (200.0, "AGBold", 10.0, 134.96059974543883), + (200.0, "AA", 10.0, 7.111717503148246e-05), + (200.0, "AA2", 10.0, 7.110517486764852e-05), + ], + ) + def test_float(self, handicap, system, distance, theta_expected): """ Check that sigma_t(handicap=float) returns expected value for a case. """ theta = hc_eq.sigma_t( - handicap=25.46, - hc_sys="AGB", - dist=100.0, + handicap=handicap, + hc_sys=system, + dist=distance, hc_dat=hc_params, ) - theta_expected = ( - hc_params.AGB_ang_0 - * ((1.0 + hc_params.AGB_step / 100.0) ** (25.46 + hc_params.AGB_datum)) - * np.exp(hc_params.AGB_kd * 100.0) - ) assert theta == theta_expected - def test_agb_array(self): + def test_array(self): """ Check that sigma_t(handicap=ndarray) returns expected value for a case. """ - handicap_array = np.array([25.46, 0.0, 243.9]) - theta = hc_eq.sigma_t( + handicap_array = np.array([25.46, -12.0, 200.0]) + dist_array = np.array([100.0, 100.0, 10.0]) + theta_expected_array = np.array( + [0.002125743670979009, 0.0005859295368818659, 0.6202029252075888] + ) + theta_array = hc_eq.sigma_t( handicap=handicap_array, hc_sys="AGB", - dist=100.0, + dist=dist_array, hc_dat=hc_params, ) - theta_expected = ( - hc_params.AGB_ang_0 - * ( - (1.0 + hc_params.AGB_step / 100.0) - ** (handicap_array + hc_params.AGB_datum) - ) - * np.exp(hc_params.AGB_kd * 100.0) - ) - assert (theta == theta_expected).all() + assert (theta_array == theta_expected_array).all() class TestSigmaR: @@ -66,47 +98,278 @@ class TestSigmaR: Methods ------- + def test_invalid_system() + test if invalid handicap system raises error test_float() - test if expected sigma_r returned for AGB from float + test if expected sigma_r returned for from float test_array() - test if expected sigma_r returned for AGB from array of floats + test if expected sigma_r returned for from array of floats """ - def test_float(self): + def test_invalid_system(self): + """ + Check that sigma_r() returns error value for invalid system. + """ + with pytest.raises( + ValueError, + match=( + "Invalid Handicap System specified.\n" + + "Please select from 'AGB', 'AGBold', 'AA', or 'AA2'." + ), + ): + hc_eq.sigma_r( + handicap=10.0, + hc_sys=None, + dist=10.0, + hc_dat=hc_params, + ) + + @pytest.mark.parametrize( + "handicap,system,distance,sigma_r_expected", + # Check all systems, different distances, negative and large handicaps. + [ + (25.46, "AGB", 100.0, 0.21257436709790092), + (25.46, "AGBold", 100.0, 0.2149455433015334), + (25.46, "AA", 100.0, 1.1349271879457612), + (25.46, "AA2", 100.0, 1.1011017526614786), + (-12.0, "AGB", 56.54, 0.02826893819014717), + (-12.0, "AGBold", 56.54, 0.029263504818409218), + (-12.0, "AA", 56.54, 1.482791802020098), + (-12.0, "AA2", 56.54, 1.4794590746458498), + (200.0, "AGB", 10.0, 6.202029252075888), + (200.0, "AGBold", 10.0, 1349.6059974543882), + (200.0, "AA", 10.0, 0.0007111717503148246), + (200.0, "AA2", 10.0, 0.0007110517486764853), + ], + ) + def test_float(self, handicap, system, distance, sigma_r_expected): """ Check that sigma_r(handicap=float) returns expected value for a case. """ - sig_r = hc_eq.sigma_r( - handicap=25.46, - hc_sys="AGB", - dist=100.0, + sigma_r = hc_eq.sigma_r( + handicap=handicap, + hc_sys=system, + dist=distance, hc_dat=hc_params, ) - sig_r_expected = 100.0 * hc_eq.sigma_t( - handicap=25.46, - hc_sys="AGB", - dist=100.0, - hc_dat=hc_params, - ) - assert sig_r == sig_r_expected + assert sigma_r == sigma_r_expected def test_array(self): """ Check that sigma_r(handicap=ndarray) returns expected value for a case. """ - handicap_array = np.array([25.46, 0.0, 243.9]) - sig_r = hc_eq.sigma_r( + handicap_array = np.array([25.46, -12.0, 200.0]) + dist_array = np.array([100.0, 56.54, 10.0]) + sigma_r_expected_array = np.array( + [0.21257436709790092, 0.02826893819014717, 6.202029252075888] + ) + sigma_r_array = hc_eq.sigma_r( handicap=handicap_array, hc_sys="AGB", - dist=100.0, + dist=dist_array, hc_dat=hc_params, ) - sig_r_expected = 100.0 * hc_eq.sigma_t( - handicap=handicap_array, + assert (sigma_r_array == sigma_r_expected_array).all() + + +class TestArrowScore: + """ + Class to test the arrow_score() function of handicap_equations. + + Tests all of the different types of target faces. + + Methods + ------- + test_invalid_scoring_system() + test if invalid target raises error + test_different_handicap_systems() + test if expected score returned for different systems + test_different_target_faces() + test if expected score returned for different faces + """ + + def test_invalid_scoring_system(self): + """ + Check that arrow_score() returns error value for invalid system. + """ + with pytest.raises( + ValueError, + match="No rule for calculating scoring for face type (.+).", + ): + target = Target(122.0, "5_zone", 100.0) + target.scoring_system = None + + hc_eq.arrow_score( + target=target, + handicap=12.0, + hc_sys="AGB", + hc_dat=hc_params, + arw_d=None, + ) + + @pytest.mark.parametrize( + "hc_system,indoor,arrow_diameter,arrow_score_expected", + [ + ("AGB", False, None, 9.134460979236048), + ("AGB", True, None, 9.20798112770351), + ("AGB", False, 7.2e-3, 9.167309048169756), + ("AGBold", False, None, 8.983801507994793), + ("AGBold", True, None, 8.983801507994793), + ("AGBold", False, 5.5e-3, 8.95254355127178), + ("AA", False, None, 1.8256148953722988), + ("AA", True, None, 1.9015069522219408), + ("AA", False, 7.2e-3, 1.8642888081318025), + ("AA2", False, None, 1.818143484577794), + ("AA2", True, None, 1.8937675318872254), + ("AA2", False, 7.2e-3, 1.8566804060966389), + ], + ) + def test_different_handicap_systems( + self, hc_system, indoor, arrow_diameter, arrow_score_expected + ): + """ + Check correct arrow scores returned for different handicap systems + """ + arrow_score = hc_eq.arrow_score( + target=Target(0.40, "10_zone_5_ring_compound", 20.0, "metre", indoor), + handicap=20.0, + hc_sys=hc_system, + hc_dat=hc_params, + arw_d=arrow_diameter, + ) + + assert arrow_score == arrow_score_expected + + @pytest.mark.parametrize( + "target_face,arrow_score_expected", + [ + ("5_zone", 7.044047485373609), + ("10_zone", 7.5472101235522695), + ("10_zone_compound", 7.481017199706876), + ("10_zone_6_ring", 7.397557278755085), + ("10_zone_5_ring", 7.059965360625537), + ("10_zone_5_ring_compound", 6.9937724367801435), + ("WA_field", 4.807397627133902), + ("IFAA_field", 4.265744100115446), + ("IFAA_field_expert", 4.0219427627782665), + ("Beiter_hit_miss", 0.999838040182924), + ("Worcester", 4.0219427627782665), + ("Worcester_2_ring", 3.34641459746045), + ], + ) + def test_different_target_faces(self, target_face, arrow_score_expected): + """ + Check correct arrow scores returned for different target faces + """ + arrow_score = hc_eq.arrow_score( + target=Target(0.80, target_face, 50.0, "metre", False), + handicap=38.0, hc_sys="AGB", - dist=100.0, hc_dat=hc_params, + arw_d=None, + ) + + assert arrow_score == arrow_score_expected + + +class TestScoreForRound: + """ + Class to test the score_for_round() function of handicap_equations. + + Methods + ------- + test_float_round_score() + test if round_score returns expected results + test_rounded_round_score + test if round_score returns expected results when rounding + """ + + @pytest.mark.parametrize( + "hc_system,round_score_expected", + [ + ( + "AGB", + ( + 243.38187034389472, + [79.84565537600997, 76.64715034267604, 86.88906462520869], + ), + ), + ( + "AGBold", + ( + 242.76923846935358, + [80.59511929560365, 76.16106748461186, 86.01305168913808], + ), + ), + ( + "AA", + ( + 36.36897790870545, + [7.8139031556465, 6.287938401968809, 22.267136351090144], + ), + ), + ( + "AA2", + ( + 37.12605100927616, + [8.266044100895407, 6.465486423674918, 22.394520484705836], + ), + ), + ], + ) + def test_float_round_score(self, hc_system, round_score_expected): + """ + Check appropriate expected round scores are returned not rounding. + """ + test_round = Round( + "MyRound", + [ + Pass(10, 1.22, "10_zone", 100, "metre", False), + Pass(10, 0.80, "10_zone", 80, "metre", False), + Pass(10, 1.22, "5_zone", 60, "metre", False), + ], + ) + + assert ( + hc_eq.score_for_round( + test_round, 20.0, hc_system, hc_eq.HcParams(), None, False + ) + == round_score_expected + ) + + @pytest.mark.parametrize( + "hc_system,round_score_expected", + [ + ( + "AGB", + (244.0, [79.84565537600997, 76.64715034267604, 86.88906462520869]), + ), + ( + "AGBold", + (243.0, [80.59511929560365, 76.16106748461186, 86.01305168913808]), + ), + ], + ) + def test_rounded_round_score(self, hc_system, round_score_expected): + """ + Check appropriate expected round scores are returned for rounding. + + NB AGBold differs to other schemes in that it rounds rather than rounding up. + """ + test_round = Round( + "MyRound", + [ + Pass(10, 1.22, "10_zone", 100, "metre", False), + Pass(10, 0.80, "10_zone", 80, "metre", False), + Pass(10, 1.22, "5_zone", 60, "metre", False), + ], + ) + + assert ( + hc_eq.score_for_round( + test_round, 20.0, hc_system, hc_eq.HcParams(), None, True + ) + == round_score_expected ) - assert (sig_r == sig_r_expected).all() diff --git a/archeryutils/targets.py b/archeryutils/targets.py index f94b4ad..7e347c1 100644 --- a/archeryutils/targets.py +++ b/archeryutils/targets.py @@ -39,7 +39,6 @@ def __init__( "10_zone", "10_zone_compound", "10_zone_6_ring", - "10_zone_6_ring_compound", "10_zone_5_ring", "10_zone_5_ring_compound", "WA_field", From fdd43bd6a4f2d0f5e03c0bae3bc70582db4c6324 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Sat, 18 Mar 2023 12:45:20 +0000 Subject: [PATCH 13/15] Cleaning and add tests. --- .../classifications/classifications.py | 112 ++++---- archeryutils/handicaps/handicap_equations.py | 19 +- archeryutils/handicaps/handicap_functions.py | 35 +-- archeryutils/handicaps/hc_sys_params.json | 4 + .../handicaps/tests/test_handicaps.py | 245 ++++++++++++++++++ archeryutils/load_rounds.py | 34 ++- archeryutils/targets.py | 24 +- archeryutils/tests/test_targets.py | 1 - examples.py | 35 +++ 9 files changed, 393 insertions(+), 116 deletions(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index 530a5e0..6030bf3 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -296,15 +296,15 @@ def _make_AGB_outdoor_classification_dict(): try: # Age group trickery: # U16 males and above step down for B2 and beyond - if gender.lower() in ["male"] and age[ + if gender.lower() in ("male") and age[ "age_group" - ].lower().replace(" ", "") in [ + ].lower().replace(" ", "") in ( "adult", "50+", "under21", "under18", "under16", - ]: + ): min_dists[i, :] = padded_dists[ max_dist_index + i - 3 : max_dist_index + i ] @@ -359,10 +359,7 @@ def _make_AGB_outdoor_classification_dict(): prestige_rounds.append(roundname) # Additional fix for Male 50+, U18, and U16 if gender.lower() == "male": - if ( - age["age_group"].lower() == "50+" - or age["age_group"].lower() == "under 18" - ): + if age["age_group"].lower() in ("50+", "under 18"): prestige_rounds.append(prestige_720[1]) elif age["age_group"].lower() == "under 16": prestige_rounds.append(prestige_720[2]) @@ -632,7 +629,7 @@ def calculate_AGB_outdoor_classification(roundname, score, bowstyle, gender, age ] ) - if bowstyle.lower() in ["traditional", "flatbow"]: + if bowstyle.lower() in ("traditional", "flatbow"): bowstyle = "Barebow" groupname = get_groupname(bowstyle, gender, age_group) @@ -641,17 +638,16 @@ def calculate_AGB_outdoor_classification(roundname, score, bowstyle, gender, age hc_params = hc_eq.HcParams() # Get scores required on this round for each classification - class_scores = [] - for i, class_i in enumerate(group_data["classes"]): - class_scores.append( - hc_eq.score_for_round( - all_outdoor_rounds[roundname], - group_data["class_HC"][i], - "AGB", - hc_params, - round_score_up=True, - )[0] - ) + class_scores = [ + hc_eq.score_for_round( + all_outdoor_rounds[roundname], + group_data["class_HC"][i], + "AGB", + hc_params, + round_score_up=True, + )[0] + for i, class_i in enumerate(group_data["classes"]) + ] # class_data = dict( # zip(group_data["classes"], zip(group_data["min_dists"], class_scores)) # ) @@ -740,17 +736,16 @@ def AGB_outdoor_classification_scores(roundname, bowstyle, gender, age_group): hc_params = hc_eq.HcParams() # Get scores required on this round for each classification - class_scores = [] - for i in range(len(group_data["classes"])): - class_scores.append( - hc_eq.score_for_round( - all_outdoor_rounds[roundname], - group_data["class_HC"][i], - "AGB", - hc_params, - round_score_up=True, - )[0] - ) + class_scores = [ + hc_eq.score_for_round( + all_outdoor_rounds[roundname], + group_data["class_HC"][i], + "AGB", + hc_params, + round_score_up=True, + )[0] + for i in range(len(group_data["classes"])) + ] # Reduce list based on other criteria besides handicap # is it a prestige round? If not remove MB scores @@ -813,7 +808,7 @@ def calculate_AGB_indoor_classification( # deal with reduced categories: age_group = "Adult" - if bowstyle.lower() not in ["compound"]: + if bowstyle.lower() not in ("compound"): bowstyle = "Recurve" groupname = get_groupname(bowstyle, gender, age_group) @@ -822,17 +817,16 @@ def calculate_AGB_indoor_classification( hc_params = hc_eq.HcParams() # Get scores required on this round for each classification - class_scores = [] - for i, class_i in enumerate(group_data["classes"]): - class_scores.append( - hc_eq.score_for_round( - all_indoor_rounds[roundname], - group_data["class_HC"][i], - hc_scheme, - hc_params, - round_score_up=True, - )[0] - ) + class_scores = [ + hc_eq.score_for_round( + all_indoor_rounds[roundname], + group_data["class_HC"][i], + hc_scheme, + hc_params, + round_score_up=True, + )[0] + for i, class_i in enumerate(group_data["classes"]) + ] class_data = dict(zip(group_data["classes"], class_scores)) @@ -840,7 +834,7 @@ def calculate_AGB_indoor_classification( to_del = [] for score_bound in class_data: if class_data[score_bound] > score: - to_del.append(item) + to_del.append(score_bound) for del_class in to_del: del class_data[del_class] @@ -898,7 +892,7 @@ def AGB_indoor_classification_scores( # deal with reduced categories: age_group = "Adult" - if bowstyle.lower() not in ["compound"]: + if bowstyle.lower() not in ("compound"): bowstyle = "Recurve" groupname = get_groupname(bowstyle, gender, age_group) @@ -907,17 +901,16 @@ def AGB_indoor_classification_scores( hc_params = hc_eq.HcParams() # Get scores required on this round for each classification - class_scores = [] - for i, class_i in enumerate(group_data["classes"]): - class_scores.append( - hc_eq.score_for_round( - all_indoor_rounds[roundname], - group_data["class_HC"][i], - hc_scheme, - hc_params, - round_score_up=True, - )[0] - ) + class_scores = [ + hc_eq.score_for_round( + all_indoor_rounds[roundname], + group_data["class_HC"][i], + hc_scheme, + hc_params, + round_score_up=True, + )[0] + for i, class_i in enumerate(group_data["classes"]) + ] return class_scores @@ -962,7 +955,7 @@ def calculate_AGB_field_classification(roundname, score, bowstyle, gender, age_g ) # deal with reduced categories: - if age_group.lower().replace(" ", "") in ["adult", "50+", "under21"]: + if age_group.lower().replace(" ", "") in ("adult", "50+", "under21"): age_group = "Adult" else: age_group = "Under 18" @@ -973,10 +966,10 @@ def calculate_AGB_field_classification(roundname, score, bowstyle, gender, age_g group_data = AGB_field_classifications[groupname] # Check Round is appropriate: - if bowstyle.lower() in ["compound", "recurve"] and roundname != "wa_field_24_red": + if bowstyle.lower() in ("compound", "recurve") and roundname != "wa_field_24_red": return "unclassified" if ( - bowstyle.lower() in ["barebow", "longbow", "traditional", "flatbow"] + bowstyle.lower() in ("barebow", "longbow", "traditional", "flatbow") and roundname != "wa_field_24_blue" ): return "unclassified" @@ -1030,7 +1023,7 @@ def AGB_field_classification_scores(roundname, bowstyle, gender, age_group): ) # deal with reduced categories: - if age_group.lower().replace(" ", "") in ["adult", "50+", "under21"]: + if age_group.lower().replace(" ", "") in ("adult", "50+", "under21"): age_group = "Adult" else: age_group = "Under 18" @@ -1043,7 +1036,6 @@ def AGB_field_classification_scores(roundname, bowstyle, gender, age_group): if __name__ == "__main__": - for classification in AGB_outdoor_classifications.items(): print( classification[0], diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py index b326d90..c1f0c58 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -356,7 +356,7 @@ def arrow_score( # pylint: disable=too-many-branches if target.indoor: arw_rad = hc_dat.arw_d_in / 2.0 else: - if hc_sys in ["AA", "AA2"]: + if hc_sys in ("AA", "AA2"): arw_rad = hc_dat.AA_arw_d_out / 2.0 else: arw_rad = hc_dat.arw_d_out / 2.0 @@ -445,7 +445,7 @@ def arrow_score( # pylint: disable=too-many-branches elif target.scoring_system == "Beiter_hit_miss": s_bar = 1 - np.exp(-((((tar_dia / 2) + arw_rad) / sig_r) ** 2)) - elif target.scoring_system in ["Worcester", "IFAA_field_expert"]: + elif target.scoring_system in ("Worcester", "IFAA_field_expert"): s_bar = 5 - sum( np.exp(-((((n * tar_dia / 10) + arw_rad) / sig_r) ** 2)) for n in range(1, 6) @@ -507,19 +507,18 @@ def score_for_round( average score for each pass in the round """ - pass_score = [] - for pass_i in rnd.passes: - pass_score.append( - pass_i.n_arrows - * arrow_score(pass_i.target, handicap, hc_sys, hc_dat, arw_d=arw_d) - ) + pass_score = [ + pass_i.n_arrows + * arrow_score(pass_i.target, handicap, hc_sys, hc_dat, arw_d=arw_d) + for pass_i in rnd.passes + ] round_score = np.sum(pass_score, axis=0) if round_score_up: # Old AGB system uses plain rounding rather than ceil of other schemes - if hc_sys == "AGBold": - round_score = np.round(round_score) + if hc_sys in ("AGBold", "AA", "AA2"): + round_score = np.around(round_score) else: round_score = np.ceil(round_score) diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index b514b87..ba590d1 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -92,7 +92,7 @@ def print_handicap_table( raise TypeError("Expected float or ndarray for hcs.") table = np.empty([len(hcs), len(round_list) + 1]) - table[:, 0] = hcs[:] + table[:, 0] = hcs.copy() for i, round_i in enumerate(round_list): table[:, i + 1], _ = hc_eq.score_for_round( round_i, hcs, hc_sys, hc_dat, arrow_d, round_score_up=round_scores_up @@ -155,8 +155,6 @@ def format_row(row): f.write(output_str) print("Done.") - return - def handicap_from_score( score: float, @@ -200,7 +198,7 @@ def handicap_from_score( max_score = rnd.max_score() if score > max_score: raise ValueError( - f"The score of {score} provided is greater that the maximum of {max_score} " + f"The score of {score} provided is greater than the maximum of {max_score} " f"for a {rnd.name}." ) if score <= 0.0: @@ -211,33 +209,40 @@ def handicap_from_score( if score == max_score: # Deal with max score before root finding - # start high and drop down until no longer ceiling to max score - # (i.e. >= max_score - 1.0) - if hc_sys in ["AA", "AA2"]: + # start high and drop down until no longer rounding to max score + # (i.e. >= max_score - 1.0 for AGB, and >= max_score - 0.5 for AA, AA2, and AGBold) + if hc_sys in ("AA", "AA2"): hc = 175.0 dhc = -0.01 else: hc = -75.0 dhc = 0.01 + + # Set rounding limit + if hc_sys in ("AA", "AA2", "AGBold"): + round_lim = 0.5 + else: + round_lim = 1.0 + s_max, _ = hc_eq.score_for_round( rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=False ) - # Work down to where we would round up (ceil) to max score - ceiling approach - while s_max > max_score - 1.0: + # Work down to where we would round or ceil to max score + while s_max > max_score - round_lim: hc = hc + dhc s_max, _ = hc_eq.score_for_round( rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=False ) hc = hc - dhc # Undo final iteration that overshoots if int_prec: - if hc_sys in ["AA", "AA2"]: + if hc_sys in ("AA", "AA2"): hc = np.ceil(hc) else: hc = np.floor(hc) else: warnings.warn( "Handicap requested for maximum score without integer precision.\n" - "Value returned will be handicap required for score > max_score-1.\n" + "Value returned will be first handiucap that achieves this score.\n" "This could cause issues if you are not working in integers.", UserWarning, ) @@ -250,7 +255,7 @@ def f_root(h, scr, rd, sys, hc_data, arw_dia): ) return val - scr - if hc_sys in ["AA", "AA2"]: + if hc_sys in ("AA", "AA2"): x = [-250.0, 175.0] else: x = [-75.0, 300.0] @@ -342,12 +347,12 @@ def f_root(h, scr, rd, sys, hc_data, arw_dia): # hence the if <0 clause if int_prec: if np.sign(hc) < 0: - if hc_sys in ["AA", "AA2"]: + if hc_sys in ("AA", "AA2"): hc = np.ceil(hc) else: hc = np.floor(hc) else: - if hc_sys in ["AA", "AA2"]: + if hc_sys in ("AA", "AA2"): hc = np.floor(hc) else: hc = np.ceil(hc) @@ -359,7 +364,7 @@ def f_root(h, scr, rd, sys, hc_data, arw_dia): # Check that you can't get the same score from a larger handicap when # working in integers min_h_flag = False - if hc_sys in ["AA", "AA2"]: + if hc_sys in ("AA", "AA2"): hstep = -1.0 else: hstep = 1.0 diff --git a/archeryutils/handicaps/hc_sys_params.json b/archeryutils/handicaps/hc_sys_params.json index f71b98a..52fc90d 100644 --- a/archeryutils/handicaps/hc_sys_params.json +++ b/archeryutils/handicaps/hc_sys_params.json @@ -3,6 +3,7 @@ "AGB_step": 3.5, "AGB_ang_0": 5.0e-4, "AGB_kd": 0.00365, + "AGBo_datum": 12.9, "AGBo_step": 3.6, "AGBo_ang_0": 5.0e-4, @@ -11,14 +12,17 @@ "AGBo_k3": 4.3, "AGBo_p1": 2.0, "AGBo_arw_d": 7.14e-3, + "AA_k0": 2.37, "AA_ks": 0.027, "AA_kd": 0.004, + "AA2_k0": 2.57, "AA2_ks": 0.027, "AA2_f1": 0.815, "AA2_f2": 0.185, "AA2_d0": 50.0, + "AA_arw_d_out": 5.0e-3, "arrow_diameter_indoors": 9.3e-3, "arrow_diameter_outdoors": 5.5e-3 diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py index 5f37b73..4c4ae2c 100644 --- a/archeryutils/handicaps/tests/test_handicaps.py +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -3,12 +3,69 @@ import pytest import archeryutils.handicaps.handicap_equations as hc_eq +import archeryutils.handicaps.handicap_functions as hc_func from archeryutils.targets import Target from archeryutils.rounds import Round, Pass hc_params = hc_eq.HcParams() +# Define rounds used in these functions +york = Round( + "York", + [ + Pass(72, 1.22, "5_zone", 100, "yard", False), + Pass(48, 1.22, "5_zone", 80, "yard", False), + Pass(24, 1.22, "5_zone", 60, "yard", False), + ], +) +hereford = Round( + "Hereford", + [ + Pass(72, 1.22, "5_zone", 80, "yard", False), + Pass(48, 1.22, "5_zone", 60, "yard", False), + Pass(24, 1.22, "5_zone", 50, "yard", False), + ], +) +western = Round( + "Western", + [ + Pass(48, 1.22, "5_zone", 60, "yard", False), + Pass(48, 1.22, "5_zone", 50, "yard", False), + ], +) +vegas300 = Round( + "Vegas 300", + [ + Pass(30, 0.4, "10_zone", 20, "yard", True), + ], +) +wa1440_90 = Round( + "WA1440 90m", + [ + Pass(36, 1.22, "10_zone", 90, "metre", False), + Pass(36, 1.22, "10_zone", 70, "metre", False), + Pass(36, 0.8, "10_zone", 50, "metre", False), + Pass(36, 0.8, "10_zone", 30, "metre", False), + ], +) +wa1440_70 = Round( + "WA1440 70m", + [ + Pass(36, 1.22, "10_zone", 70, "metre", False), + Pass(36, 1.22, "10_zone", 60, "metre", False), + Pass(36, 0.8, "10_zone", 50, "metre", False), + Pass(36, 0.8, "10_zone", 30, "metre", False), + ], +) +metric122_30 = Round( + "Metric 122-30", + [ + Pass(36, 1.22, "10_zone", 30, "metre", False), + Pass(36, 1.22, "10_zone", 30, "metre", False), + ], +) + class TestSigmaT: """ @@ -373,3 +430,191 @@ def test_rounded_round_score(self, hc_system, round_score_expected): ) == round_score_expected ) + + +class TestHandicapFromScore: + """ + Class to test the handicap_from_score() function of handicap_functions. + + Test both float and integer values, and maximum score. + Where possible try and perform comparisons using values taken from literature, + not generated by this codebase. + - For Archery GB old use the published handicap tables. + - For Archery GB new use the published handicap tables or values from this code. + - For Archery Australia use Archery Scorepad and + - For Archery Australia 2 there are no published tables and issues exist with + + Methods + ------- + test_score_over_max() + test_score_of_zero() + test_score_below_zero() + test_maximum_score_metric_122_30() + test_maximum_score_western() + test_maximum_score_vegas300() + test_maximum_score_vegas300() + + References + ---------- + Archery GB Old + Published handicap tables by David Lane + http://www.oldbasingarchers.co.uk/wp-content/uploads/2019/01/2011-Handicap-Booklet-Complete.pdf + + Archery GB New + Published tables for outdoor rounds + https://archerygb.org/files/outdoor-handicap-tables-200123092252.pdf + + Archery Australia + Archery Scorepad + + # Test AGB and AA, integer precision and non- + # Check warning? + # Use values pulled from tables, not generated by code! + # AGB from official release + # AGBold from David Lane's old tables + # AA from ArcheryScorepad https://www.archeryscorepad.com/ratings.php + # AA2 from??? + """ + + def test_score_over_max(self): + """ + Check that handicap_from_score() returns error value for too large score. + """ + with pytest.raises( + ValueError, + match=( + "The score of (.+) provided is greater than" + + " the maximum of (.+) for a (.+)." + ), + ): + test_round = Round( + "TestRound", + [ + Pass(10, 1.22, "10_zone", 50, "metre", False), + Pass(10, 0.80, "10_zone", 50, "metre", False), + ], + ) + + hc_func.handicap_from_score(9999, test_round, "AGB", hc_params) + + def test_score_of_zero(self): + """ + Check that handicap_from_score() returns error for zero score. + """ + with pytest.raises( + ValueError, + match=( + "The score of 0 provided is less than or equal to" + + " zero so cannot have a handicap." + ), + ): + test_round = Round( + "TestRound", + [ + Pass(10, 1.22, "10_zone", 50, "metre", False), + Pass(10, 0.80, "10_zone", 50, "metre", False), + ], + ) + + hc_func.handicap_from_score(0, test_round, "AGB", hc_params) + + def test_score_below_zero(self): + """ + Check that handicap_from_score() returns error for negative score. + """ + with pytest.raises( + ValueError, + match=( + "The score of (.+) provided is less than or equal to" + + " zero so cannot have a handicap." + ), + ): + test_round = Round( + "TestRound", + [ + Pass(10, 1.22, "10_zone", 50, "metre", False), + Pass(10, 0.80, "10_zone", 50, "metre", False), + ], + ) + + hc_func.handicap_from_score(-9999, test_round, "AGB", hc_params) + + @pytest.mark.parametrize( + "hc_system,handicap_expected", + [ + ("AGB", 11), + ("AA", 107), + # ("AA2", 107), + ], + ) + def test_maximum_score_metric_122_30(self, hc_system, handicap_expected): + """ + Check correct arrow scores returned for different handicap systems + """ + + handicap = hc_func.handicap_from_score( + 720, metric122_30, hc_system, hc_params, None, True + ) + + assert handicap == handicap_expected + + @pytest.mark.parametrize( + "hc_system,handicap_expected", + [ + ("AGB", 9), + ("AGBold", 6), + ], + ) + def test_maximum_score_western(self, hc_system, handicap_expected): + """ + Check correct arrow scores returned for different handicap systems + """ + handicap = hc_func.handicap_from_score( + 864, western, hc_system, hc_params, None, True + ) + assert handicap == handicap_expected + + @pytest.mark.parametrize( + "hc_system,handicap_expected", + [ + ("AGB", 3), + ("AA", 119), + # ("AA2", 119), + ], + ) + def test_maximum_score_vegas300(self, hc_system, handicap_expected): + """ + Check correct arrow scores returned for different handicap systems + """ + handicap = hc_func.handicap_from_score( + 300, vegas300, hc_system, hc_params, None, True + ) + assert handicap == handicap_expected + + # def test_float_AA2(self): + # """ + # Check + # """ + # handicap = hc_func.handicap_from_score( + # 1080, york, "AA2", hc_params, 5.0e-3, False + # ) + # assert handicap == 82.70 + + # handicap = hc_func.handicap_from_score( + # 1146, york, "AA2", hc_params, 5.0e-3, False + # ) + # assert handicap == 90.16 + + # handicap = hc_func.handicap_from_score( + # 1214, york, "AA2", hc_params, 5.0e-3, False + # ) + # assert handicap == 100.20 + + # score, _ = hc_eq.score_for_round( + # york, 82.70, "AA2", hc_params, 5.0e-3, False) + # assert score == 1080 + # + # handicap = hc_func.handicap_from_score( + # 1165, hereford, "AA2", hc_params, None, False + # ) + # assert handicap == 81.81 diff --git a/archeryutils/load_rounds.py b/archeryutils/load_rounds.py index 7848bc6..ed965c6 100644 --- a/archeryutils/load_rounds.py +++ b/archeryutils/load_rounds.py @@ -32,7 +32,6 @@ def read_json_to_round_dict(json_filelist): data = json.load(json_round_file) for round_i in data: - # Assign location if "location" not in round_i: warnings.warn( @@ -41,7 +40,7 @@ def read_json_to_round_dict(json_filelist): ) round_i["location"] = None round_i["indoor"] = False - elif round_i["location"] in [ + elif round_i["location"] in ( "i", "I", "indoors", @@ -52,10 +51,10 @@ def read_json_to_round_dict(json_filelist): "Indoor", "In", "Inside", - ]: + ): round_i["indoor"] = True round_i["location"] = "indoor" - elif round_i["location"] in [ + elif round_i["location"] in ( "o", "O", "outdoors", @@ -66,17 +65,17 @@ def read_json_to_round_dict(json_filelist): "Outdoor", "Out", "Outside", - ]: + ): round_i["indoor"] = False round_i["location"] = "outdoor" - elif round_i["location"] in [ + elif round_i["location"] in ( "f", "F", "field", "Field", "woods", "Woods", - ]: + ): round_i["indoor"] = False round_i["location"] = "field" else: @@ -105,18 +104,17 @@ def read_json_to_round_dict(json_filelist): round_i["family"] = "" # Assign passes - passes = [] - for pass_i in round_i["passes"]: - passes.append( - Pass( - pass_i["n_arrows"], - pass_i["diameter"] / 100, - pass_i["scoring"], - pass_i["distance"], - dist_unit=pass_i["dist_unit"], - indoor=round_i["indoor"], - ) + passes = [ + Pass( + pass_i["n_arrows"], + pass_i["diameter"] / 100, + pass_i["scoring"], + pass_i["distance"], + dist_unit=pass_i["dist_unit"], + indoor=round_i["indoor"], ) + for pass_i in round_i["passes"] + ] round_dict[round_i["codename"]] = Round( round_i["name"], diff --git a/archeryutils/targets.py b/archeryutils/targets.py index 7e347c1..3442864 100644 --- a/archeryutils/targets.py +++ b/archeryutils/targets.py @@ -31,7 +31,7 @@ def __init__( diameter, scoring_system, distance, - native_dist_unit='metre', + native_dist_unit="metre", indoor=False, ): systems = [ @@ -55,7 +55,7 @@ def __init__( f"""Please select from '{"', '".join(systems)}'.""" ) - if native_dist_unit in [ + if native_dist_unit in ( "Yard", "yard", "Yards", @@ -66,9 +66,9 @@ def __init__( "yd", "Yds", "yds", - ]: + ): native_dist_unit = "yard" - elif native_dist_unit in [ + elif native_dist_unit in ( "Metre", "metre", "Metres", @@ -77,7 +77,7 @@ def __init__( "m", "Ms", "ms", - ]: + ): native_dist_unit = "metre" else: raise ValueError( @@ -102,27 +102,27 @@ def max_score(self): max_score : float maximum score possible on this target face """ - if self.scoring_system in ["5_zone"]: + if self.scoring_system in ("5_zone"): return 9.0 - if self.scoring_system in [ + 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"]: + if self.scoring_system in ("WA_field"): return 6.0 - if self.scoring_system in [ + 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"]: + if self.scoring_system in ("Beiter_hit_miss"): return 1.0 # NB: Should be hard (but not impossible) to get here without catching earlier. raise ValueError( diff --git a/archeryutils/tests/test_targets.py b/archeryutils/tests/test_targets.py index 5340f3e..42f4444 100644 --- a/archeryutils/tests/test_targets.py +++ b/archeryutils/tests/test_targets.py @@ -64,7 +64,6 @@ def test_yard_to_m_conversion(self): ("10_zone", 10), ("10_zone_compound", 10), ("10_zone_6_ring", 10), - ("10_zone_6_ring_compound", 10), ("10_zone_5_ring", 10), ("10_zone_5_ring_compound", 10), ("WA_field", 6), diff --git a/examples.py b/examples.py index 4be6d63..a933a87 100644 --- a/examples.py +++ b/examples.py @@ -104,6 +104,30 @@ int_prec=True, ) + hc_func.print_handicap_table( + np.arange(125.0, 9.0, -1.0), + "AA2", + [load_rounds.WA_outdoor.wa720_70, load_rounds.AGB_outdoor_metric.metric_122_50, load_rounds.AGB_outdoor_metric.metric_122_30, load_rounds.WA_outdoor.wa1440_90, load_rounds.AGB_outdoor_imperial.york], + hc_params, + printout=False, + round_scores_up=False, + clean_gaps=False, + filename="AA2_outdoor_metric_table.dat", + arrow_d=0.0, + int_prec=False, + ) + hc_func.print_handicap_table( + np.arange(0.0, 151.0, 1.0), + "AGBold", + [load_rounds.WA_outdoor.wa720_70, load_rounds.AGB_outdoor_metric.metric_122_50, load_rounds.AGB_outdoor_metric.metric_122_30, load_rounds.WA_outdoor.wa1440_90], + hc_params, + printout=False, + round_scores_up=False, + clean_gaps=True, + filename="AGBold_outdoor_metric_table.dat", + int_prec=True, + ) + hc_func.print_handicap_table( np.arange(0.0, 151.0, 1.0), "AGB", @@ -335,3 +359,14 @@ print( f"A score of 1295 on a {load_rounds.AGB_outdoor_imperial.bristol_i.name} is a discrete handicap of {hc_from_score}." ) + hc_func.print_handicap_table( + np.arange(125.0, 9.0, -1.0), + "AA2", + [load_rounds.WA_outdoor.wa720_70, load_rounds.AGB_outdoor_metric.metric_122_50, load_rounds.AGB_outdoor_metric.metric_122_30, load_rounds.WA_outdoor.wa1440_90, load_rounds.AGB_outdoor_imperial.york], + hc_params, + printout=False, + round_scores_up=False, + clean_gaps=False, + filename="AA2_outdoor_metric_table.dat", + int_prec=False, + ) From 280348c353054049fd79c957be4f64bc8129ed7a Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Sat, 18 Mar 2023 14:34:54 +0000 Subject: [PATCH 14/15] Fix bug in handicap_functions for int(-ve hc) Whilst adding tests. --- .../classifications/classifications.py | 10 +- archeryutils/handicaps/handicap_functions.py | 14 +-- .../handicaps/tests/test_handicaps.py | 103 ++++++++++++------ 3 files changed, 79 insertions(+), 48 deletions(-) diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index b0dfb16..42a234b 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -967,11 +967,15 @@ def calculate_AGB_field_classification(roundname, score, bowstyle, gender, age_g # Check Round is appropriate: # Sighted can have any Red 24, unsightes can have any blue 24 - if bowstyle.lower() in ["compound", "recurve"] and "wa_field_24_red_" not in roundname: + if ( + bowstyle.lower() in ("compound", "recurve") + and "wa_field_24_red_" not in roundname + ): return "unclassified" if ( - bowstyle.lower() in ["barebow", "longbow", "traditional", "flatbow"] - and "wa_field_24_blue_" not in roundname): + bowstyle.lower() in ("barebow", "longbow", "traditional", "flatbow") + and "wa_field_24_blue_" not in roundname + ): return "unclassified" # What is the highest classification this score gets? diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py index ba590d1..b8810b0 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -343,19 +343,11 @@ def f_root(h, scr, rd, sys, hc_data, arw_dia): hc = xcur # Force integer precision if required. - # NB: added complexity - not trivial as we require asymmetric rounding. - # hence the if <0 clause if int_prec: - if np.sign(hc) < 0: - if hc_sys in ("AA", "AA2"): - hc = np.ceil(hc) - else: - hc = np.floor(hc) + if hc_sys in ("AA", "AA2"): + hc = np.floor(hc) else: - if hc_sys in ("AA", "AA2"): - hc = np.floor(hc) - else: - hc = np.ceil(hc) + hc = np.ceil(hc) sc, _ = hc_eq.score_for_round( rnd, hc, hc_sys, hc_dat, arw_d, round_score_up=True diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py index 4c4ae2c..c82ac48 100644 --- a/archeryutils/handicaps/tests/test_handicaps.py +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -58,6 +58,13 @@ Pass(36, 0.8, "10_zone", 30, "metre", False), ], ) +wa720_70 = Round( + "WA 720 70m", + [ + Pass(36, 1.22, "10_zone", 70, "metre", False), + Pass(36, 1.22, "10_zone", 70, "metre", False), + ], +) metric122_30 = Round( "Metric 122-30", [ @@ -71,6 +78,10 @@ class TestSigmaT: """ Class to test the sigma_t() function of handicap_equations. + Uses output of the code when run at a particular point in time and then + 'frozen' to make sure future developments do not introduce unexpected changes. + Deliberate changes to the schemes may affect these values and require changes. + Methods ------- def test_invalid_system() @@ -153,6 +164,10 @@ class TestSigmaR: """ Class to test the sigma_r() function of handicap_equations. + Uses output of the code when run at a particular point in time and then + 'frozen' to make sure future developments do not introduce unexpected changes. + Deliberate changes to the schemes may affect these values and require changes. + Methods ------- def test_invalid_system() @@ -236,6 +251,10 @@ class TestArrowScore: Class to test the arrow_score() function of handicap_equations. Tests all of the different types of target faces. + Uses output of the code when run at a particular point in time and then + 'frozen' to make sure future developments do not introduce unexpected changes. + Deliberate changes to the schemes may affect these values and require changes. + Methods ------- @@ -287,7 +306,8 @@ def test_different_handicap_systems( self, hc_system, indoor, arrow_diameter, arrow_score_expected ): """ - Check correct arrow scores returned for different handicap systems + Check arrow scores returned for different handicap systems and arrow diameters. + """ arrow_score = hc_eq.arrow_score( target=Target(0.40, "10_zone_5_ring_compound", 20.0, "metre", indoor), @@ -335,6 +355,10 @@ class TestScoreForRound: """ Class to test the score_for_round() function of handicap_equations. + Uses output of the code when run at a particular point in time and then + 'frozen' to make sure future developments do not introduce unexpected changes. + Deliberate changes to the schemes may affect these values and require changes. + Methods ------- test_float_round_score() @@ -449,10 +473,7 @@ class TestHandicapFromScore: test_score_over_max() test_score_of_zero() test_score_below_zero() - test_maximum_score_metric_122_30() - test_maximum_score_western() - test_maximum_score_vegas300() - test_maximum_score_vegas300() + test_maximum_score() References ---------- @@ -540,55 +561,69 @@ def test_score_below_zero(self): hc_func.handicap_from_score(-9999, test_round, "AGB", hc_params) @pytest.mark.parametrize( - "hc_system,handicap_expected", + "hc_system,testround,max_score,handicap_expected", [ - ("AGB", 11), - ("AA", 107), - # ("AA2", 107), + ("AGB", metric122_30, 720, 11), + ("AA", metric122_30, 720, 107), + # ("AA2", metric122_30, 720, 107), + ("AGB", western, 864, 9), + ("AGBold", western, 864, 6), + ("AGB", vegas300, 300, 3), + ("AA", vegas300, 300, 119), + # ("AA2", vegas300, 300, 119), ], ) - def test_maximum_score_metric_122_30(self, hc_system, handicap_expected): + def test_maximum_score(self, hc_system, testround, max_score, handicap_expected): """ - Check correct arrow scores returned for different handicap systems + Check correct handicap returned for max score. """ handicap = hc_func.handicap_from_score( - 720, metric122_30, hc_system, hc_params, None, True + max_score, testround, hc_system, hc_params, None, True ) assert handicap == handicap_expected @pytest.mark.parametrize( - "hc_system,handicap_expected", + "hc_system,testround,testscore,handicap_expected", [ - ("AGB", 9), - ("AGBold", 6), + # Generic scores: + ("AGB", wa720_70, 700, 1), + ("AGBold", wa720_70, 700, 1), + ("AA", wa720_70, 700, 119), + # ("AA2", wa720_70, 700, 107), + ("AGB", wa720_70, 500, 44), + ("AGBold", wa720_70, 500, 40), + ("AA", wa720_70, 500, 64), + # ("AA2", wa720_70, 500, 107), + # Score on the lower bound of a band: + ("AGB", wa720_70, 283, 63), + ("AGBold", wa720_70, 286, 53), + ("AA", wa720_70, 280, 39), + # ("AA2", wa720_70, 500, 107), + # Score on upper bound of a band: + ("AGB", wa720_70, 486, 46), + ("AGBold", wa720_70, 488, 41), + ("AA", wa720_70, 491, 62), + # ("AA2", wa720_70, 500, 107), + # Scores that give negative AGB handicaps: + ("AGB", wa720_70, 710, -5), + ("AGBold", wa720_70, 710, -5), + # ("AA", wa720_70, 491, 62) + # ("AA2", wa720_70, 500, 107), + # Scores that give 0 AA handicaps: + ("AA", wa720_70, 52, 0), + # ("AA2", wa720_70, 500, 107), ], ) - def test_maximum_score_western(self, hc_system, handicap_expected): + def test_int_precision(self, hc_system, testround, testscore, handicap_expected): """ - Check correct arrow scores returned for different handicap systems + Check correct handicap returned for various scores. """ handicap = hc_func.handicap_from_score( - 864, western, hc_system, hc_params, None, True + testscore, testround, hc_system, hc_params, None, True ) - assert handicap == handicap_expected - @pytest.mark.parametrize( - "hc_system,handicap_expected", - [ - ("AGB", 3), - ("AA", 119), - # ("AA2", 119), - ], - ) - def test_maximum_score_vegas300(self, hc_system, handicap_expected): - """ - Check correct arrow scores returned for different handicap systems - """ - handicap = hc_func.handicap_from_score( - 300, vegas300, hc_system, hc_params, None, True - ) assert handicap == handicap_expected # def test_float_AA2(self): From ac15ab14931a19cf93a45f448a614e26ba53a929 Mon Sep 17 00:00:00 2001 From: Jack Atkinson Date: Sat, 18 Mar 2023 15:07:08 +0000 Subject: [PATCH 15/15] Tests for multiple distance rounds and decimal handicaps. --- .../handicaps/tests/test_handicaps.py | 116 +++++++++++------- 1 file changed, 71 insertions(+), 45 deletions(-) diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py index c82ac48..3efcb0c 100644 --- a/archeryutils/handicaps/tests/test_handicaps.py +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -465,7 +465,7 @@ class TestHandicapFromScore: not generated by this codebase. - For Archery GB old use the published handicap tables. - For Archery GB new use the published handicap tables or values from this code. - - For Archery Australia use Archery Scorepad and + - For Archery Australia use Archery Scorepad- [ ] Classifications - For Archery Australia 2 there are no published tables and issues exist with Methods @@ -474,27 +474,22 @@ class TestHandicapFromScore: test_score_of_zero() test_score_below_zero() test_maximum_score() + test_int_precision() + test_decimal() References ---------- Archery GB Old - Published handicap tables by David Lane - http://www.oldbasingarchers.co.uk/wp-content/uploads/2019/01/2011-Handicap-Booklet-Complete.pdf - + Published handicap tables by David Lane + http://www.oldbasingarchers.co.uk/wp-content/uploads/2019/01/2011-Handicap-Booklet-Complete.pdf Archery GB New - Published tables for outdoor rounds - https://archerygb.org/files/outdoor-handicap-tables-200123092252.pdf - + Published tables for outdoor rounds + https://archerygb.org/files/outdoor-handicap-tables-200123092252.pdf Archery Australia - Archery Scorepad - - # Test AGB and AA, integer precision and non- - # Check warning? - # Use values pulled from tables, not generated by code! - # AGB from official release - # AGBold from David Lane's old tables - # AA from ArcheryScorepad https://www.archeryscorepad.com/ratings.php - # AA2 from??? + Archery Scorepad + https://www.archeryscorepad.com/ratings.php + Archery Australia 2 + Currently no easily available data """ def test_score_over_max(self): @@ -566,8 +561,10 @@ def test_score_below_zero(self): ("AGB", metric122_30, 720, 11), ("AA", metric122_30, 720, 107), # ("AA2", metric122_30, 720, 107), + # ------------------------------ ("AGB", western, 864, 9), ("AGBold", western, 864, 6), + # ------------------------------ ("AGB", vegas300, 300, 3), ("AA", vegas300, 300, 119), # ("AA2", vegas300, 300, 119), @@ -579,7 +576,12 @@ def test_maximum_score(self, hc_system, testround, max_score, handicap_expected) """ handicap = hc_func.handicap_from_score( - max_score, testround, hc_system, hc_params, None, True + max_score, + testround, + hc_system, + hc_params, + None, + True, ) assert handicap == handicap_expected @@ -587,6 +589,7 @@ def test_maximum_score(self, hc_system, testround, max_score, handicap_expected) @pytest.mark.parametrize( "hc_system,testround,testscore,handicap_expected", [ + # ------------------------------ # Generic scores: ("AGB", wa720_70, 700, 1), ("AGBold", wa720_70, 700, 1), @@ -596,24 +599,34 @@ def test_maximum_score(self, hc_system, testround, max_score, handicap_expected) ("AGBold", wa720_70, 500, 40), ("AA", wa720_70, 500, 64), # ("AA2", wa720_70, 500, 107), + # ------------------------------ # Score on the lower bound of a band: ("AGB", wa720_70, 283, 63), ("AGBold", wa720_70, 286, 53), ("AA", wa720_70, 280, 39), # ("AA2", wa720_70, 500, 107), + # ------------------------------ # Score on upper bound of a band: ("AGB", wa720_70, 486, 46), ("AGBold", wa720_70, 488, 41), ("AA", wa720_70, 491, 62), # ("AA2", wa720_70, 500, 107), + # ------------------------------ # Scores that give negative AGB handicaps: ("AGB", wa720_70, 710, -5), ("AGBold", wa720_70, 710, -5), # ("AA", wa720_70, 491, 62) # ("AA2", wa720_70, 500, 107), + # ------------------------------ # Scores that give 0 AA handicaps: ("AA", wa720_70, 52, 0), # ("AA2", wa720_70, 500, 107), + # ------------------------------ + # Multiple Distance Round: + ("AGB", wa1440_90, 850, 52), + ("AGBold", wa1440_90, 850, 46), + ("AA", wa1440_90, 850, 53), + # ("AA2", wa1440_90, 850, 53), ], ) def test_int_precision(self, hc_system, testround, testscore, handicap_expected): @@ -621,35 +634,48 @@ def test_int_precision(self, hc_system, testround, testscore, handicap_expected) Check correct handicap returned for various scores. """ handicap = hc_func.handicap_from_score( - testscore, testround, hc_system, hc_params, None, True + testscore, + testround, + hc_system, + hc_params, + None, + True, ) assert handicap == handicap_expected - # def test_float_AA2(self): - # """ - # Check - # """ - # handicap = hc_func.handicap_from_score( - # 1080, york, "AA2", hc_params, 5.0e-3, False - # ) - # assert handicap == 82.70 - - # handicap = hc_func.handicap_from_score( - # 1146, york, "AA2", hc_params, 5.0e-3, False - # ) - # assert handicap == 90.16 - - # handicap = hc_func.handicap_from_score( - # 1214, york, "AA2", hc_params, 5.0e-3, False - # ) - # assert handicap == 100.20 - - # score, _ = hc_eq.score_for_round( - # york, 82.70, "AA2", hc_params, 5.0e-3, False) - # assert score == 1080 - # - # handicap = hc_func.handicap_from_score( - # 1165, hereford, "AA2", hc_params, None, False - # ) - # assert handicap == 81.81 + @pytest.mark.parametrize( + "hc_system,testround,testscore,handicap_expected", + [ + # Generic scores: + ("AGB", wa720_70, 500, 43.47488098014149), + ("AGBold", wa720_70, 500, 39.05693137292724), + ("AA", wa720_70, 500, 64.19799339810962), + # ("AA2", wa720_70, 500, 107), + # ------------------------------ + # Multiple Distance Round: + ("AGB", wa1440_90, 850, 51.77551461040738), + ("AGBold", wa1440_90, 850, 45.303733163646996), + ("AA", wa1440_90, 850, 53.545592683112666), + # ("AA2", wa1440_90, 850, 53), + ], + ) + def test_decimal(self, hc_system, testround, testscore, handicap_expected): + """ + Check correct handicap returned for various scores. + + Uses output of the code when run at a particular point in time then + 'frozen' to monitor future developments introducing unexpected changes. + Deliberate changes to the schemes may affect these values and require changes. + + """ + handicap = hc_func.handicap_from_score( + testscore, + testround, + hc_system, + hc_params, + None, + False, + ) + + assert handicap == handicap_expected