diff --git a/archeryutils/classifications/classifications.py b/archeryutils/classifications/classifications.py index e33ef5e..42a234b 100644 --- a/archeryutils/classifications/classifications.py +++ b/archeryutils/classifications/classifications.py @@ -1,42 +1,134 @@ -import numpy as np +""" +Code for calculating Archery GB classifications. + +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, 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 - with open(age_file) as json_file: + """ + 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 - with open(bowstyles_file) as json_file: + """ + 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) 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 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) 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 +143,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 +155,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 +178,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 +277,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"] @@ -207,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 ] @@ -231,7 +320,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:] @@ -269,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]) @@ -299,11 +386,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 +406,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 +433,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 +453,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 +589,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 +617,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 @@ -537,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) @@ -546,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)) # ) @@ -571,25 +662,25 @@ 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 = [] 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] @@ -602,8 +693,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 +719,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 = load_rounds.read_json_to_round_dict( @@ -644,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, 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 in range(len(group_data["classes"])) + ] # Reduce list based on other criteria besides handicap # is it a prestige round? If not remove MB scores @@ -674,8 +765,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 +795,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 @@ -716,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) @@ -725,27 +817,26 @@ 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)) # What is the highest classification this score gets? to_del = [] - for item in class_data: - if class_data[item] > score: - to_del.append(item) - for item in to_del: - del class_data[item] + for score_bound in class_data: + if class_data[score_bound] > score: + to_del.append(score_bound) + 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 +853,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 +881,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 = load_rounds.read_json_to_round_dict( @@ -800,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) @@ -809,24 +901,25 @@ 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 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 +944,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 @@ -863,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" @@ -875,29 +967,35 @@ 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: - return "unclassified" if ( - bowstyle.lower() in ["barebow", "longbow", "traditional", "flatbow"] - and "wa_field_24_blue_" not in roundname): + bowstyle.lower() in ("compound", "recurve") + and "wa_field_24_red_" not in roundname + ): 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" + if ( + 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? + 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 +1018,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 = load_rounds.read_json_to_round_dict( @@ -930,7 +1027,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" @@ -943,9 +1040,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/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..c1f0c58 100644 --- a/archeryutils/handicaps/handicap_equations.py +++ b/archeryutils/handicaps/handicap_equations.py @@ -1,100 +1,199 @@ -# 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 + + AA_arw_d_out = 5.0e-3 + + 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.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"] + + 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, - and distance. + Calculate angular deviation for given scheme, handicap, 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 +217,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 +247,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 +260,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 +280,21 @@ 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 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 ---------- - h : ndarray or float + handicap : ndarray or float handicap to calculate sigma_t at hc_sys : str identifier for handicap system @@ -207,32 +307,28 @@ 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 -def arrow_score( +def arrow_score( # pylint: disable=too-many-branches 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,9 +344,11 @@ 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 + # 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 @@ -258,12 +356,15 @@ def arrow_score( 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 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 = ( @@ -344,7 +445,7 @@ def arrow_score( 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) @@ -373,21 +474,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,22 +506,19 @@ 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: - pass_score.append( - Pass_i.n_arrows * arrow_score(Pass_i.target, h, 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 e92d6ae..b8810b0 100644 --- a/archeryutils/handicaps/handicap_functions.py +++ b/archeryutils/handicaps/handicap_functions.py @@ -1,17 +1,21 @@ -# 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. -import numpy as np +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 + +""" +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 @@ -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", @@ -83,8 +83,16 @@ def print_handicap_table( "Ladies": "L", } + if not isinstance(hcs, np.ndarray): + if isinstance(hcs, list): + hcs = np.array(hcs) + elif isinstance(hcs, (float, int)): + hcs = np.array([hcs]) + else: + 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 @@ -97,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 @@ -133,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)) @@ -148,8 +155,6 @@ def format_row(row): f.write(output_str) print("Done.") - return - def handicap_from_score( score: float, @@ -160,7 +165,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 ---------- @@ -187,182 +192,181 @@ 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: 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}." ) - 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) - if hc_sys in ["AA", "AA2"]: - hc = 175 + # 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 + 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: - hc += dhc + # 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 -= dhc # Undo final iteration that overshoots + 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, ) 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, 175] - else: - x = [-75, 300] - - 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) - else: - if hc_sys in ["AA", "AA2"]: - hc = np.floor(hc) - else: - hc = np.ceil(hc) + # Force integer precision if required. + if int_prec: + 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/hc_sys_params.json b/archeryutils/handicaps/hc_sys_params.json index b71783e..52fc90d 100644 --- a/archeryutils/handicaps/hc_sys_params.json +++ b/archeryutils/handicaps/hc_sys_params.json @@ -1,28 +1,29 @@ { - "AGB_datum" : 6.0, - "AGB_step" : 3.5, - "AGB_ang_0" : 5.0e-4, - "AGB_kd" : 0.00365, + "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, + "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 + "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 new file mode 100644 index 0000000..3efcb0c --- /dev/null +++ b/archeryutils/handicaps/tests/test_handicaps.py @@ -0,0 +1,681 @@ +"""Tests for handicap equations and functions""" +import numpy as np +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), + ], +) +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", + [ + Pass(36, 1.22, "10_zone", 30, "metre", False), + Pass(36, 1.22, "10_zone", 30, "metre", False), + ], +) + + +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() + 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_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=handicap, + hc_sys=system, + dist=distance, + hc_dat=hc_params, + ) + + assert theta == theta_expected + + def test_array(self): + """ + Check that sigma_t(handicap=ndarray) returns expected value for a case. + """ + 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=dist_array, + hc_dat=hc_params, + ) + + assert (theta_array == theta_expected_array).all() + + +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() + test if invalid handicap system raises error + test_float() + test if expected sigma_r returned for from float + test_array() + test if expected sigma_r returned for from array of floats + """ + + 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. + """ + sigma_r = hc_eq.sigma_r( + handicap=handicap, + hc_sys=system, + dist=distance, + hc_dat=hc_params, + ) + + 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, -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=dist_array, + hc_dat=hc_params, + ) + + 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. + 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_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 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), + 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", + 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. + + 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() + 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 + ) + + +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- [ ] Classifications + - 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() + 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 + Archery GB New + Published tables for outdoor rounds + https://archerygb.org/files/outdoor-handicap-tables-200123092252.pdf + Archery Australia + Archery Scorepad + https://www.archeryscorepad.com/ratings.php + Archery Australia 2 + Currently no easily available data + """ + + 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,testround,max_score,handicap_expected", + [ + ("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(self, hc_system, testround, max_score, handicap_expected): + """ + Check correct handicap returned for max score. + """ + + handicap = hc_func.handicap_from_score( + max_score, + testround, + hc_system, + hc_params, + None, + True, + ) + + assert handicap == handicap_expected + + @pytest.mark.parametrize( + "hc_system,testround,testscore,handicap_expected", + [ + # ------------------------------ + # 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), + # ------------------------------ + # 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): + """ + Check correct handicap returned for various scores. + """ + handicap = hc_func.handicap_from_score( + testscore, + testround, + hc_system, + hc_params, + None, + True, + ) + + assert handicap == handicap_expected + + @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 diff --git a/archeryutils/load_rounds.py b/archeryutils/load_rounds.py index 3162101..ed965c6 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 @@ -7,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 ---------- @@ -18,11 +18,8 @@ def read_json_to_round_dict(json_filelist): Returns ------- round_dict : dict of str : rounds.Round - - 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,11 +28,10 @@ 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: - # Assign location if "location" not in round_i: warnings.warn( @@ -44,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", @@ -55,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", @@ -69,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: @@ -108,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"], @@ -134,29 +129,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] - else: - raise AttributeError(self._attribute_err_msg(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 d14f11a..5ae854d 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 in [metres] + 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.n_arrows = abs(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,76 +111,34 @@ 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 self.body = body self.family = family - def get_info(self): - """ - method get_info() - Prints information about the round - - Parameters - ---------- - - Returns - ------- - """ - print(f"A {self.name} consists of {len(self.passes)} passes:") - for pass_i in self.passes: - 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, - ) - ) - - return None - 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 ---------- @@ -196,7 +146,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) @@ -215,5 +165,18 @@ def max_distance(self, unit=False): if unit: return (max_dist, d_unit) - else: - return max_dist + 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/targets.py b/archeryutils/targets.py index f1e1401..3442864 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 ------- @@ -38,30 +30,15 @@ def __init__( self, diameter, scoring_system, - distance=None, - native_dist_unit=None, + distance, + native_dist_unit="metre", 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", "10_zone_compound", "10_zone_6_ring", - "10_zone_6_ring_compound", "10_zone_5_ring", "10_zone_5_ring_compound", "WA_field", @@ -74,11 +51,11 @@ 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 [ + if native_dist_unit in ( "Yard", "yard", "Yards", @@ -89,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", @@ -100,11 +77,11 @@ def __init__( "m", "Ms", "ms", - ]: + ): 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'." ) @@ -118,40 +95,36 @@ 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 """ - if self.scoring_system in ["5_zone"]: + 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", "10_zone_6_ring_compound", "10_zone_5_ring", "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" - ) + # 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." + ) 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_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" + ) diff --git a/archeryutils/tests/test_targets.py b/archeryutils/tests/test_targets.py new file mode 100644 index 0000000..42f4444 --- /dev/null +++ b/archeryutils/tests/test_targets.py @@ -0,0 +1,95 @@ +"""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_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() diff --git a/examples.py b/examples.py index 94996c5..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", @@ -152,11 +176,6 @@ int_prec=True, ) - - - - - # Print the continuous score that is comes from this handicap score_from_hc = hc_eq.score_for_round( load_rounds.AGB_outdoor_imperial.york, 51., "AGB", hc_params, round_score_up=False @@ -165,7 +184,7 @@ f"A handicap of 51. on a {load_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( load_rounds.AGB_outdoor_imperial.york, 50.0, "AGB", hc_params, round_score_up=False ) @@ -173,7 +192,7 @@ f"A handicap of 50.0 on a {load_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( load_rounds.AGB_outdoor_imperial.york, 49., "AGB", hc_params, round_score_up=False ) @@ -189,7 +208,7 @@ f"A handicap of 51 on a {load_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( load_rounds.AGB_outdoor_imperial.york, 50, "AGB", hc_params, round_score_up=True ) @@ -197,7 +216,7 @@ f"A handicap of 50 on a {load_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( load_rounds.AGB_outdoor_imperial.york, 49, "AGB", hc_params, round_score_up=True ) @@ -245,7 +264,6 @@ f"A score of 706 on a {load_rounds.AGB_outdoor_imperial.york.name} is a discrete handicap of {hc_from_score}." ) - score_from_hc = hc_eq.score_for_round( load_rounds.AGB_outdoor_imperial.york, 50., "AGB", hc_params, round_score_up=False ) @@ -265,9 +283,6 @@ f"A handicap of 52. on a {load_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, load_rounds.AGB_outdoor_imperial.york, "AGB", hc_params, int_prec=True @@ -288,7 +303,6 @@ f"A score of 682 on a {load_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( @@ -313,29 +327,46 @@ f"A handicap of 52 on a {load_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 {load_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 {load_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 {load_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 {load_rounds.AGB_outdoor_imperial.western_30.name} is class {class_from_score}." ) - hc_from_score = hc_func.handicap_from_score( 1295, load_rounds.AGB_outdoor_imperial.bristol_i, "AGB", hc_params, int_prec=True ) 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, + ) diff --git a/pyproject.toml b/pyproject.toml index 42003d6..63afce8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,9 @@ classifiers = [ ] dependencies = [ "black>=22.12.0", - "flake8==6.0.0", + "mypy>=1.0.0", "numpy>=1.20.3", + "pytest>=7.2.0", ] [project.urls] @@ -52,11 +53,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