Repository: jatkinson1000/archeryutils (language: python)

Issue:
HcParams leads to a complicated interface
It'd be good to hide a lot of the `HCParams` stuff, maybe by making it an optional argument to the functions that use it, and falling back to default values if it isn't provided. I think the ideal system would be something like:

```python
from archeryutils import score_from_handicap, handicap_from_score, rounds

# Default to current AGB methods
myscore = score_from_handicap(rounds.york, 38)
myhc = handicap_from_score(rounds.portsmouth, 575)
```

Alternative handicap systems could be used by setting a context using fancy `with` methods, or as optional args to the standard functions. Or we could just have a global context setter:

```python
archeryutils.set_handicap_system("some_other_method")
```
Make some handicap_functions methods private
All except `handicap_from_score` and `﻿﻿print_handicap_table` should be private.
To do this requires removing the explicit tests for any private functions and instead testing them implicitly through the above two.

This will require tests writing for `print_handicap_table` which should bump coverage up.


Discussion / hints:
Agreed. At present it just gets defined from a default file at the start and then passed all the way down.
Therefore makes sense to turn it into a default argument such that it becomes optional for the user to define their own and pass all the way down only if desired.

The magic numbers shouldn't need changing, but I'd perhaps leave the option to set the handicap system, and the indoor and outdoor arrow diameter, as app developers/users are more likely to fiddle with these.
@LiamPattinson after some thought on this I feel the easiest thing might be to make `HcParams` an optional argument, and any function using it spins up a new instance from the default values unless it is provided.

The idea of the context setter is programmatically nice, but I'm not sure how "pythonic" it is, and feel it would be harder for users to get their heads around.

I think I'd still leave the handicap system argument (but maybe make optional defaulting to `"AGB"`).

The arrow diameter I am undecided on at the moment.
It is currently included in `HcParams` data, but is the one thing a user is most likely to change.
However, having it as an optional arg that overrides the `HcParams` value feels a little clunky, and I'm not wild about the ambiguity of being able to set it in two places.

How does that sound/any thoughts/suggestions?
I'd argue that [context setters](https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager) are definitely 'Pythonic', but like many concepts falling under that label that doesn't necessarily mean they're a good idea! Having looked a bit closer, I don't think they are the best solution in this case, and I agree that it might scare away users who aren't so well-versed in Python.

The main issue I see with it is that the user needs to set up `HcParams` -- a fairly complicated class containing a lot of mysterious data, and then pass this to whatever functions need to use it along with the string `hc_sys`. The value of `hc_sys` determines which data are used, and most of it isn't needed for a given set of calculations.

As much as it pains me to say it, I think I'd recommend just going for the classic inheritance tree:

```python
from abc import abstractmethod, ABC

class Handicap(ABC):

    @abstractmethod
    def sigma_t(self, handicap, dist):
        pass

    def sigma_r(self, handicap, dist):
        sig_t = self.sigma_t(handicap, dist)
        sig_r = dist * sig_t
        return sig_r

    def arrow_score(self, target, handicap, arw_d):
        ... # Can define this in base class

    def score_for_passes(self, rnd, handicap, arw_d = None):
            pass_scores = np.array(
                [
                    pass_i.n_arrows
                    * self.arrow_score(pass_i.target, handicap, arw_d=arw_d)
                    for pass_i in rnd.passes
                ]
            )
            return pass_scores

    def score_for_round(self, rnd, handicap, arw_d = None, round_score_up = True):
        round_score = np.sum(self.score_for_passes(rnd, handicap, arw_d), axis=0)
        return self._round_score_up(round_score) if round_score_up else round_score

    @staticmethod
    def _round_score_up(score):
        """Most schemes use plain rounding, new AGB doesn't. Override this in that class"""
        return np.around(score)

    # Also define functions from handicap_functions.py

# Define subclasses:

class HandicapAGB(Handicap):

    def __init__(self, datum=6.0, step=3.5, ang_0=5.0e-4, kd=0.00365):
        self.datum = datum
        self.step = step
        self.ang_0 = ang_0
        self.kd = kd

    def sigma_t(self, handicap, dist):
        return self.ang_0 * ((1.0 + self.step / 100.0) ** (handicap + self.datum)) * np.exp(self.kd * dist)

    @staticmethod
    def _round_score_up(score):
        return np.ceil(score)
```

To simply the user's interface, I like to define 'factory' methods, usually in the `__init__.py` file:

```python
# handicaps/__init__.py

from .handicap import Handicap
from .agb import HandicapAGB, HandicapAGBOld

_CLASSES = {
    "agb": HandicapAGB,
    "agbold": HandicapAGBOld,
}

def handicap(hc_sys: str, **kwargs):
    try:
        return _CLASSES[hc_sys](**kwargs)
    except KeyError:
        raise ValueError(f"{hc_sys} is not a recognised handicap system")

# User code:

>>> from archeryutils.handicaps import handicap
>>> hc = handicap("agb")
>>> score = hc.score_from_handicap(round, 34)
```

This means the user only has to import one function instead of importing multiple classes individually.

There are probably better names for everything here -- the word 'handicap' in particular seems a bit overloaded. I'd recommend defining as much as you can in the base class, and where the schemes differ you can defer to small overrideable helper functions. You might also want to include an intermediate layer of abstract classes to group together the shared functions and data in `AGB`/`AGBold` and `AA`/`AA2`, but shallower inheritance trees tend to be more maintainable in my experience.

---

Regarding the arrow diameter, I think it would be best for users to pass that into functions themselves, since that feels like a feature of the archer rather than a feature of the handicap system. If the handicap schemes have a default arrow diameter built into them, maybe they could have that defined as a function on each class.

---

Hope this helps! No worries if you'd rather go with an alternative approach. Since it looks like you're already pretty deep into solving this issue, it might be better to hold off on my suggestions until after the initial release.
:sob:
I'll digest this a bit more, but something along these lines probably makes sense I think... I guess I know what I'm doing at the weekend.
Thanks for taking a look and making suggestions - I assume you would prefer me to refactor and PR due to being busy, but if you want ownership let me know.

Also going to tag @TomHall2020 to see if he has any thoughts.
I'm a little too busy to work on it right now, but I'm expecting to have a lot of free time in a couple of weeks, so I'd be happy to work on it then.
Thanks @jatkinson1000 
- Agree arrow diameter should be easily adjustable for end use cases. eg a scoring app which does plots with accurate arrow diameters. 
- But also that it should still easily fall back to the defined defaults for a given handicap system. People also want to know what their "official" handicap is for a round, and all the systems make assumptions on arrow diamter for those official handicaps and classificaiton thresholds.
- The other parameters I think will rarely if ever be touched by end users.

The HcParams class is pretty odd to me. The data it contains certainly needs to be provided as default to most of the functions as that is almost invariably how its called. But even then, trying to override one single parameter requires either reassigning after init, or recreating the whole sub-dictionary

```python
hc1 = HcParams(agb_hc_data= {
    "AGB_datum": 6.0,
    "AGB_step": 3.5,
    "AGB_ang_0": 5.0e-4,
    "AGB_kd": 10.00365, # changed value in init
})

hc2 = HcParams()
hc2.agb_hc_data["AGB_kd"] = 10.00365 # changed value after init
```

Either way feels clunky to me, and the definition of the class with all those default parameters being assigned in lambdas is just kind of offputting as well as a potential user, especially as dataclass features arent really being used elsewhere in the code.

@LiamPattinson's idea of factoring out that data into the owner of the handicap calculation functions does help with that. If keeping the current structure it would be good to at least have the sub dictionaries be typed so that their keys can be autocompleted, or even make them their own minature dataclasses.

I think the inheritance route works and would also have the advantages of reducing branch points in `arrow_score` (choosing default arrow diameter), `score_for_round` (choice of rounding up or down), and especially `sigma_t` (where most of the hcparams data is actually used). I also agree that bringing the handicap_functions public methods into those specialised classes would be helpful as well, it becomes clearer what you want to import from where without understanding the internals of how handicap_equations and handicap_functions work. 

Having the convience function to get the right class from a string input is essential, otherwise user code has to be all over the place with importing classes from different places and that is a right pain.

So with that in place, the workflow for a user calling an existing handicap system and overriding parameters might be:
```python
#simple implementation as before with a dict lookup
from archeryutils.handicaps import get_handicap_system
hc = get_handicap_system("AGB")
hc.kd = 10.00365 #adjusted
hc.score_from_handicap("round", 34, arw_d=0.075)

#fancy version with kwargs being passed through??
hc = get_handicap_system("AGB", kd=10.00365)
hc.score_from_handicap(...)
```
I think version 2 here is kind of neat but also really unecessary, again I very much doubt the majority of people will mess around with the existing parameters for existing systems. 

I think we all agree to keep arrow diameter as an optional argument.

@LiamPattinson - I have been thinking about the inheritance approach and it has a lot of merits.
Particularly the fact it would compartmentalise and tidy some of the branching logic.

However, the need to set `hc = handicap("agb")` and then use functions contained in `hc` still feels a bit clunky.
I think it would be nice if if the function calls were explicit as to the scheme being used, i.e.
```python
import archeryutils.handicaps as hc

hc.score_for_round(round, handicap, "agb")
# or
hc.score_for_round_agb(round, handicap)
```

There is also the issue that the functions in `handicap_functions.py` make use of a similar API and can't easily be brought under the handicap class/subclasses (more on this below).

Stepping back and reflecting on the above I think a sensible workflow would be:
- Call all handicap functions with minimum args, a clear identification of 'scheme', and use default values.
  - This 'scheme' could be set once as a variable for users in their code and used where needed, or be part of the function call.
  - setting as an argument I think makes more sense when considering this in conjunction with handicap_functions.
- Update the key parameters for a handicap scheme once, and once only, before calling functions.
  - This would somehow need to modify the behaviour for the handicap_functions as well (which is why HcParams was always a top-down argument for everything)

To this end I think it makes sense if functions are overloaded and can be passed an argument that can be a `Union[str, Handicap]`.
If a `Handicap` then use this directly, if `str` then initialise a `Handicap` of this type with default values.

Something like:
```python
import archeryutils as au
from archeryutils import handicap_equations as hc_eq
from archeryutils import handicap_functions as hc_func

# Uses default values for the "AGB" system
score_from_hc = hc_eq.score_for_round(round, handicap, "AGB")
hc_from_score = hc_func.handicap_from_score(score, round, "AGB")

# Uses custom values for the "AGB" system
custom_sys = hc_eq.handicap("agb", kd=3.14159)
score_from_hc = hc_eq.score_for_round(round, handicap, custom_sys)
hc_from_score = hc_func.handicap_from_score(score, round, custom_sys)
```

I feel this keeps things simple for novice users, whilst also being fairly clean for technical users, of which there will be relatively few (see above).

Some notes:
- `hc_eq.handicap()` would be the guard point against setting inappropriate schemes.
  This would then knock on as a guard when called as a part of any other function.
- I would use the class inheritance approach as set out above as this should give tidy manageable code and clean up the branching

I don't think there is a need to override for the classification schemes since the other numbers involved don't make sense 
when not tied to the default scheme.

Further, it strikes me as odd that `score_for_round` and `handicap_from_score` come from two places (`hc_eq` and `hc_func` respectively). This made sense at the time of writing (and still does in terms of the internals), but to a naïve user I can see that this would be confusing.
I think this is an opportunity to:
- bring all code into one file, or
- take the interface to `score_for_round` into handicap_func, or
- expose things at the subpackage level and just require users to `import archeryutils.handicaps as hc`

of which I prefer the latter.
Agree that calling methods on a system feels less intuitive than a pair of simple functions exposed at the top level and passing the system by string reference.

Maybe then take arrow_diameter as an optional parameter on those functions, and if it is None allow the handicap system to set its own default value with whatever logic it wants (this already mostly happens in the `arrow_score` function.

```python
def score_from_handicap(
    round: Round, 
    handicap: float # for a high level api I think this probably should be a single value and not a numpy array
    system: Literal["AGB", "AA"...]
    arrow_diameter: Optional[float] = None
) -> int:
    ... # get class, dispatch to its methods.
```

Would recomend calling the class `System`, `HandicapScheme` or something similar rather than just `Handicap`, as that sounds more like its a class to model a single handicap value in itself.



Writing to note that this is now WIP by @jatkinson1000 as of today.

Small bump in the road from being quite as 'easy' as I thought above, but getting there.

Base class is indeed called `HandicapScheme` 😉

I will ask you both to take a look and review once I open the PR.
I can see advantages and disadvantages to the plain function interface and the class interface, and in some cases I'd prefer to use the latter. The issue I see with passing the handicap system as a string is that it can result in a lot of repetitive extra function arguments. For example, say you're trying to compare award schemes:

```python
star_purple = hc.handicap_from_score(1400, wa1440, "AGB")
star_gold = hc.handicap_from_score(1350, wa1440, "AGB")
star_red = hc.handicap_from_score(1300, wa1440, "AGB")
star_blue = hc.handicap_from_score(1200, wa1440, "AGB")
star_black = hc.handicap_from_score(1100, wa1440, "AGB")
star_white = hc.handicap_from_score(1000, wa1440, "AGB")

rose_purple = hc.handicap_from_score(1250, york, "AGB")
rose_gold = hc.handicap_from_score(1200, york, "AGB")
rose_red = hc.handicap_from_score(1100, york, "AGB")
rose_blue = hc.handicap_from_score(1000, york, "AGB")
rose_black = hc.handicap_from_score(900, york, "AGB")
rose_white = hc.handicap_from_score(800, york, "AGB")
```

The extra `"AGB"` at every call doesn't really add any new information\*, and it would be cleaner in this case to initialise a `HandicapAGB` class and call `handicap_from_score` from that instance. On the other hand, I agree that the plain function approach is cleaner if you only want to make one or two function calls.

A compromise would be to expose both interfaces to the user, and implement the plain functions as a thin layer on top of the class-based system:

```python
def score_from_handicap(handicap, round, system, arrow_diameter = None):
    return handicap_system(system).score_from_handicap(handicap, round, arrow_diameter)
```

This is a fairly pedantic level of library design though, so no worries if you'd prefer to only expose the plain functions and keep the class interface hidden away as an implementation detail.

---

\* I'm aware that there's already a 'cleaner' way to write this and also avoid repeating the function call and the round name:

```python
star_purple, star_gold, star_red, star_blue, star_black, star_white = [
    hc.handicap_from_score(x, wa1440, "AGB") 
    for x in (1400, 1350, 1300, 1200, 1100, 1000)
]
```

I imagine most people would opt for writing it out in full though, as that's a pretty ugly 'one liner'.
The other option for users that want to keep it clean is just doing a Partial application. 

```python
# I quite like the comprehension approach here
agb_hc_from_score = functools.Partial(hc.handicap_from_score, system="AGB")
star_scores = {'purple': 1400, 'gold': 1350, ...}
star_handicaps = {star: agb_hc_from_score(score, wa1440) for star, score in star_scores.items()}
```

In general though I support keeping things open, so I don't see anything wrong with making the classes accessible for people to work with directly as well. The function is more a convience for interactive use or where users want to wrap it and dispatch on different handicap systems.
Yeah, there's no perfect solution.

I think what you describe here is actually what I am doing - I'll get you to review my PR once I have things in place and push up.
Quick play over lunch.\
Current interface looks something like:

```python
>>> import archeryutils as au
>>> import archeryutils.handicaps as hc
>>> my1440 = au.load_rounds.WA_outdoor.wa1440_90
>>> hc.score_for_round(my1440, 37, "agb")
1110.0
>>> hc.score_for_round(my1440, 37, "aa")
572.0
>>> hc.score_for_passes(my1440, 37, "agb")
array([237., 276., 276., 322.])
>>>
>>> # Or, using the class approach for advanced users:
>>> agb_hc = hc.handicap_scheme("agb")
>>> agb_hc.score_for_round(my1440, 37)
1110.0
```

However, restructuring the tests is going to be a nightmare 😭 
Packaging `__init__` interface is also proving a little tricksy.
Notes to self until PR is opened:

- [ ] Standardise `round_score_up`, `rounded_score`, `int_prec` to a single argument, probably `rounded`.
- [ ] Move rootfinding etc. to be protected static methods of `HandicapScheme` Class.
- [ ] Decide if full docstrings should be supplied in Parent or child classes or both.
- [ ] Decide what is documented where - perhaps need `API Docs` and `Developer Docs`?
- [ ] Standardise argument order: `(handicap, round)` or `(round, handicap)`? Same for `(score, round)`.
I wonder, does it make logical sense to remove the explicit tests? It seems unit testing is a perfectly valid reason to break the private convention and use those functions directly.
This is likely to be addressed in the upcoming PR dealing with #2 
At least, it gives a significant shake up to everything and is likely to render much of this moot, I think.

Fix patch:
diff --git a/archeryutils/__init__.py b/archeryutils/__init__.py
index dc32d0a..a087799 100644
--- a/archeryutils/__init__.py
+++ b/archeryutils/__init__.py
@@ -1,18 +1,15 @@
 """Package providing code for various archery utilities."""
 
-from archeryutils.targets import Target
+from archeryutils import classifications, handicaps
 from archeryutils.rounds import Pass, Round
-from archeryutils.handicaps import handicap_equations, handicap_functions
-from archeryutils import classifications
+from archeryutils.targets import Target
 from archeryutils.utils import versions
 
-
 __all__ = [
-    "Target",
+    "classifications",
+    "handicaps",
     "Pass",
     "Round",
-    "handicap_equations",
-    "handicap_functions",
-    "classifications",
+    "Target",
     "versions",
 ]
diff --git a/archeryutils/classifications/agb_field_classifications.py b/archeryutils/classifications/agb_field_classifications.py
index 68c1592..8e7552c 100644
--- a/archeryutils/classifications/agb_field_classifications.py
+++ b/archeryutils/classifications/agb_field_classifications.py
@@ -108,7 +108,7 @@ def _make_agb_field_classification_dict() -> dict[str, GroupData]:
 
 
 def calculate_agb_field_classification(
-    roundname: str, score: float, bowstyle: str, gender: str, age_group: str
+    score: float, roundname: str, bowstyle: str, gender: str, age_group: str
 ) -> str:
     """
     Calculate AGB field classification from score.
@@ -117,10 +117,10 @@ def calculate_agb_field_classification(
 
     Parameters
     ----------
-    roundname : str
-        name of round shot as given by 'codename' in json
     score : float
         numerical score on the round to calculate classification for
+    roundname : str
+        name of round shot as given by 'codename' in json
     bowstyle : str
         archer's bowstyle under AGB outdoor target rules
     gender : str
@@ -147,8 +147,8 @@ def calculate_agb_field_classification(
     --------
     >>> from archeryutils import classifications as class_func
     >>> class_func.calculate_agb_field_classification(
-    ...     "wa_field_24_red_marked",
     ...     247,
+    ...     "wa_field_24_red_marked",
     ...     "recurve",
     ...     "male",
     ...     "adult",
diff --git a/archeryutils/classifications/agb_indoor_classifications.py b/archeryutils/classifications/agb_indoor_classifications.py
index 9d9f5b2..c771cb9 100644
--- a/archeryutils/classifications/agb_indoor_classifications.py
+++ b/archeryutils/classifications/agb_indoor_classifications.py
@@ -16,7 +16,7 @@
 import numpy.typing as npt
 
 from archeryutils import load_rounds
-from archeryutils.handicaps import handicap_equations as hc_eq
+import archeryutils.handicaps as hc
 import archeryutils.classifications.classification_utils as cls_funcs
 
 
@@ -125,8 +125,8 @@ def _make_agb_indoor_classification_dict() -> dict[str, GroupData]:
 
 
 def calculate_agb_indoor_classification(
-    roundname: str,
     score: float,
+    roundname: str,
     bowstyle: str,
     gender: str,
     age_group: str,
@@ -139,10 +139,10 @@ def calculate_agb_indoor_classification(
 
     Parameters
     ----------
-    roundname : str
-        name of round shot as given by 'codename' in json
     score : int
         numerical score on the round to calculate classification for
+    roundname : str
+        name of round shot as given by 'codename' in json
     bowstyle : str
         archer's bowstyle under AGB outdoor target rules
     gender : str
@@ -169,8 +169,8 @@ def calculate_agb_indoor_classification(
     --------
     >>> from archeryutils import classifications as class_func
     >>> class_func.calculate_agb_indoor_classification(
-    ...     "wa18",
     ...     547,
+    ...     "wa18",
     ...     "compound",
     ...     "male",
     ...     "50+",
@@ -281,17 +281,15 @@ def agb_indoor_classification_scores(
     group_data = agb_indoor_classifications[groupname]
 
     hc_scheme = "AGB"
-    hc_params = hc_eq.HcParams()
 
     # Get scores required on this round for each classification
     # Enforce full size face
     class_scores = [
-        hc_eq.score_for_round(
-            ALL_INDOOR_ROUNDS[cls_funcs.strip_spots(roundname)],
+        hc.score_for_round(
             group_data["class_HC"][i],
+            ALL_INDOOR_ROUNDS[cls_funcs.strip_spots(roundname)],
             hc_scheme,
-            hc_params,
-            round_score_up=True,
+            rounded_score=True,
         )
         for i, class_i in enumerate(group_data["classes"])
     ]
@@ -305,12 +303,11 @@ def agb_indoor_classification_scores(
     for i, (score, handicap) in enumerate(
         zip(int_class_scores, group_data["class_HC"])
     ):
-        next_score = hc_eq.score_for_round(
-            ALL_INDOOR_ROUNDS[cls_funcs.strip_spots(roundname)],
+        next_score = hc.score_for_round(
             np.floor(handicap) + 1,
+            ALL_INDOOR_ROUNDS[cls_funcs.strip_spots(roundname)],
             hc_scheme,
-            hc_params,
-            round_score_up=True,
+            rounded_score=True,
         )
         if next_score == score:
             # If already at max score this classification is impossible
diff --git a/archeryutils/classifications/agb_old_indoor_classifications.py b/archeryutils/classifications/agb_old_indoor_classifications.py
index a989fec..f72ca48 100644
--- a/archeryutils/classifications/agb_old_indoor_classifications.py
+++ b/archeryutils/classifications/agb_old_indoor_classifications.py
@@ -14,7 +14,7 @@
 from typing import TypedDict
 
 from archeryutils import load_rounds
-from archeryutils.handicaps import handicap_equations as hc_eq
+import archeryutils.handicaps as hc
 import archeryutils.classifications.classification_utils as cls_funcs
 
 
@@ -92,8 +92,8 @@ def _make_agb_old_indoor_classification_dict() -> dict[str, GroupData]:
 
 
 def calculate_agb_old_indoor_classification(
-    roundname: str,
     score: float,
+    roundname: str,
     bowstyle: str,
     gender: str,
     age_group: str,
@@ -106,10 +106,10 @@ def calculate_agb_old_indoor_classification(
 
     Parameters
     ----------
-    roundname : str
-        name of round shot as given by 'codename' in json
     score : int
         numerical score on the round to calculate classification for
+    roundname : str
+        name of round shot as given by 'codename' in json
     bowstyle : str
         archer's bowstyle under AGB outdoor target rules
     gender : str
@@ -131,8 +131,8 @@ def calculate_agb_old_indoor_classification(
     --------
     >>> from archeryutils import classifications as class_func
     >>> class_func.calculate_agb_old_indoor_classification(
-    ...     "wa18",
     ...     547,
+    ...     "wa18",
     ...     "compound",
     ...     "male",
     ...     "adult",
@@ -245,16 +245,13 @@ def agb_old_indoor_classification_scores(
     groupname = cls_funcs.get_groupname(bowstyle, gender, age_group)
     group_data = agb_old_indoor_classifications[groupname]
 
-    hc_params = hc_eq.HcParams()
-
     # Get scores required on this round for each classification
     class_scores = [
-        hc_eq.score_for_round(
-            ALL_INDOOR_ROUNDS[roundname],
+        hc.score_for_round(
             group_data["class_HC"][i],
+            ALL_INDOOR_ROUNDS[roundname],
             "AGBold",
-            hc_params,
-            round_score_up=True,
+            rounded_score=True,
         )
         for i, class_i in enumerate(group_data["classes"])
     ]
diff --git a/archeryutils/classifications/agb_outdoor_classifications.py b/archeryutils/classifications/agb_outdoor_classifications.py
index 66f3e8f..79ec6ae 100644
--- a/archeryutils/classifications/agb_outdoor_classifications.py
+++ b/archeryutils/classifications/agb_outdoor_classifications.py
@@ -16,7 +16,7 @@
 import numpy.typing as npt
 
 from archeryutils import load_rounds
-from archeryutils.handicaps import handicap_equations as hc_eq
+import archeryutils.handicaps as hc
 import archeryutils.classifications.classification_utils as cls_funcs
 
 
@@ -339,7 +339,7 @@ def _assign_outdoor_prestige(
 
 
 def calculate_agb_outdoor_classification(
-    roundname: str, score: float, bowstyle: str, gender: str, age_group: str
+    score: float, roundname: str, bowstyle: str, gender: str, age_group: str
 ) -> str:
     """
     Calculate AGB outdoor classification from score.
@@ -349,10 +349,10 @@ def calculate_agb_outdoor_classification(
 
     Parameters
     ----------
-    roundname : str
-        name of round shot as given by 'codename' in json
     score : int
         numerical score on the round to calculate classification for
+    roundname : str
+        name of round shot as given by 'codename' in json
     bowstyle : str
         archer's bowstyle under AGB outdoor target rules
     gender : str
@@ -379,8 +379,8 @@ def calculate_agb_outdoor_classification(
     --------
     >>> from archeryutils import classifications as class_func
     >>> class_func.calculate_agb_outdoor_classification(
-    ...     "hereford",
     ...     858,
+    ...     "hereford",
     ...     "recurve",
     ...     "female",
     ...     "under 18",
@@ -536,16 +536,13 @@ def agb_outdoor_classification_scores(
     groupname = cls_funcs.get_groupname(bowstyle, gender, age_group)
     group_data = agb_outdoor_classifications[groupname]
 
-    hc_params = hc_eq.HcParams()
-
     # Get scores required on this round for each classification
     class_scores = [
-        hc_eq.score_for_round(
-            ALL_OUTDOOR_ROUNDS[cls_funcs.strip_spots(roundname)],
+        hc.score_for_round(
             group_data["class_HC"][i],
+            ALL_OUTDOOR_ROUNDS[cls_funcs.strip_spots(roundname)],
             "AGB",
-            hc_params,
-            round_score_up=True,
+            rounded_score=True,
         )
         for i in range(len(group_data["classes"]))
     ]
diff --git a/archeryutils/handicaps/__init__.py b/archeryutils/handicaps/__init__.py
index 223887d..70fd495 100644
--- a/archeryutils/handicaps/__init__.py
+++ b/archeryutils/handicaps/__init__.py
@@ -1,1 +1,28 @@
 """Module providing various handicap functionalities."""
+
+from .handicap_functions import (
+    arrow_score,
+    handicap_from_score,
+    handicap_scheme,
+    score_for_passes,
+    score_for_round,
+)
+from .handicap_scheme import FloatArray, HandicapScheme
+from .handicap_scheme_agb import HandicapAGB, HandicapAGBold
+from .handicap_scheme_aa import HandicapAA, HandicapAA2
+from .handicap_tables import HandicapTable
+
+__all__ = [
+    "FloatArray",
+    "HandicapTable",
+    "handicap_scheme",
+    "arrow_score",
+    "score_for_passes",
+    "score_for_round",
+    "handicap_from_score",
+    "HandicapScheme",
+    "HandicapAGB",
+    "HandicapAGBold",
+    "HandicapAA",
+    "HandicapAA2",
+]
diff --git a/archeryutils/handicaps/handicap_equations.py b/archeryutils/handicaps/handicap_equations.py
deleted file mode 100644
index 8e2d93f..0000000
--- a/archeryutils/handicaps/handicap_equations.py
+++ /dev/null
@@ -1,650 +0,0 @@
-"""
-Code for archery handicap calculations using various handicap 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 Class
-- sigma_t
-- sigma_r
-- arrow_score
-- score_for_passes
-- score_for_round
-
-References
-----------
-- Old AGB: The construction of the graduated handicap tables for target archery
-  Lane, D (2013)
-  https://www.jackatkinson.net/files/Handicap_Tables_2013.pdf
-- New AGB: Atkinson, J
-- AA: Modelling archers’ scores at different distances to quantify score loss due to
-  equipment selection and technique errors
-  Park, J (2014)
-  https://doi.org/10.1177%2F1754337114539308
-
-"""
-
-import json
-from typing import Optional, TypeVar
-
-from dataclasses import dataclass, field
-import numpy as np
-import numpy.typing as npt
-
-from archeryutils import targets, rounds
-
-FloatArray = TypeVar("FloatArray", float, np.float_, npt.NDArray[np.float_])
-
-
-@dataclass
-class HcParams:
-    """Class to hold information for various handicap schemes."""
-
-    # Key values for the new Archery GB handicap scheme [Atkinson].
-    agb_hc_data: dict[str, float] = field(
-        default_factory=lambda: (
-            {
-                "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 values for the old Archery GB handicap scheme [Lane].
-    agb_old_hc_data: dict[str, float] = field(
-        default_factory=lambda: (
-            {
-                "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 1 used in handicap equation.
-                "AGBo_k2": 1.07,  # Constant 2 used in handicap equation.
-                "AGBo_k3": 4.3,  # Constant 3 used in handicap equation.
-                "AGBo_p1": 2.0,  # Exponent of distance scaling.
-            }
-        )
-    )
-
-    # Key values for the original Archery Australia scheme [Park].
-    aa_hc_data: dict[str, float] = field(
-        default_factory=lambda: (
-            {
-                "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 values for the new Archery Australia scheme [Park].
-    aa2_hc_data: dict[str, float] = field(
-        default_factory=lambda: (
-            {
-                "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].
-            }
-        )
-    )
-
-    # Key values for arrow diameter in the different schemes.
-    arw_d_data: dict[str, float] = field(
-        default_factory=lambda: (
-            {
-                "arw_d_in": 9.3e-3,  # Diameter of an indoor arrow [metres].
-                "arw_d_out": 5.5e-3,  # Diameter of an outdoor arrow for AGB [metres].
-                "AGBo_arw_d": 7.14e-3,  # Diameter of an arrow in the old AGB algorithm [metres].
-                "AA_arw_d_out": 5.0e-3,  # Diameter of an outdoor arrow for AA [metres].
-            }
-        )
-    )
-
-    @classmethod
-    def load_json_params(cls, jsonpath: str) -> "HcParams":
-        """
-        Class method to load params from a json file.
-
-        Parameters
-        ----------
-        jsonpath : str
-            path to json file with handicap parameters
-
-        Returns
-        -------
-        json_hc_params : HcParams
-            dataclass of handicap parameters read from file
-        """
-        json_hc_params: "HcParams" = cls()
-        with open(jsonpath, "r", encoding="utf-8") as read_file:
-            paramsdict = json.load(read_file)
-        json_hc_params.agb_hc_data["AGB_datum"] = paramsdict["AGB_datum"]
-        json_hc_params.agb_hc_data["AGB_step"] = paramsdict["AGB_step"]
-        json_hc_params.agb_hc_data["AGB_ang_0"] = paramsdict["AGB_ang_0"]
-        json_hc_params.agb_hc_data["AGB_kd"] = paramsdict["AGB_kd"]
-        json_hc_params.agb_old_hc_data["AGBo_datum"] = paramsdict["AGBo_datum"]
-        json_hc_params.agb_old_hc_data["AGBo_step"] = paramsdict["AGBo_step"]
-        json_hc_params.agb_old_hc_data["AGBo_ang_0"] = paramsdict["AGBo_ang_0"]
-        json_hc_params.agb_old_hc_data["AGBo_k1"] = paramsdict["AGBo_k1"]
-        json_hc_params.agb_old_hc_data["AGBo_k2"] = paramsdict["AGBo_k2"]
-        json_hc_params.agb_old_hc_data["AGBo_k3"] = paramsdict["AGBo_k3"]
-        json_hc_params.agb_old_hc_data["AGBo_p1"] = paramsdict["AGBo_p1"]
-        json_hc_params.aa_hc_data["AA_k0"] = paramsdict["AA_k0"]
-        json_hc_params.aa_hc_data["AA_ks"] = paramsdict["AA_ks"]
-        json_hc_params.aa_hc_data["AA_kd"] = paramsdict["AA_kd"]
-        json_hc_params.aa2_hc_data["AA2_k0"] = paramsdict["AA2_k0"]
-        json_hc_params.aa2_hc_data["AA2_ks"] = paramsdict["AA2_ks"]
-        json_hc_params.aa2_hc_data["AA2_f1"] = paramsdict["AA2_f1"]
-        json_hc_params.aa2_hc_data["AA2_f2"] = paramsdict["AA2_f2"]
-        json_hc_params.aa2_hc_data["AA2_d0"] = paramsdict["AA2_d0"]
-        json_hc_params.arw_d_data["arw_d_in"] = paramsdict["arrow_diameter_indoors"]
-        json_hc_params.arw_d_data["arw_d_out"] = paramsdict["arrow_diameter_outdoors"]
-        json_hc_params.arw_d_data["AGBo_arw_d"] = paramsdict["AGBo_arw_d"]
-        json_hc_params.arw_d_data["AA_arw_d_out"] = paramsdict["AA_arw_d_out"]
-
-        return json_hc_params
-
-
-def sigma_t(
-    handicap: FloatArray,
-    hc_sys: str,
-    dist: float,
-    hc_dat: HcParams,
-) -> FloatArray:
-    """
-    Calculate angular deviation for given scheme, handicap, and distance.
-
-    Parameters
-    ----------
-    handicap : float or ndarray
-        handicap to calculate sigma_t at
-    hc_sys : str
-        identifier for handicap system
-    dist : float
-        distance to target [metres]
-    hc_dat : HcParams
-        dataclass containing parameters for handicap equations
-
-    Returns
-    -------
-    sig_t : float or ndarray
-        angular deviation [rad]
-
-    Raises
-    ------
-    ValueError
-        If no valid handicap scheme is specified
-
-    Examples
-    --------
-    Angular deviation at a distance of 25m, using the AGB handicap system at a
-    handicap of 10 can be calculated with:
-
-    >>> import archeryutils.handicap_equations as hc_eq
-    >>> hc_params = hc_eq.HcParams()
-    >>> hc_eq.sigma_t(10.0, "AGB", 25.0, hc_params)
-    0.0009498280098103058
-
-    It can also be passed an array of handicaps:
-
-    >>> hc_eq.sigma_t(np.asarray([10.0, 50.0, 100.0]), "AGB", 25.0, hc_params)
-    array([0.00094983, 0.00376062, 0.02100276])
-
-    """
-    if hc_sys == "AGB":
-        # New AGB (Archery GB) System
-        # Written by Jack Atkinson
-        hc_data = hc_dat.agb_hc_data
-        sig_t = (
-            hc_data["AGB_ang_0"]
-            * ((1.0 + hc_data["AGB_step"] / 100.0) ** (handicap + hc_data["AGB_datum"]))
-            * np.exp(hc_data["AGB_kd"] * dist)
-        )
-
-    elif hc_sys == "AGBold":
-        # Old AGB (Archery GB) System
-        # Written by David Lane (2013)
-        hc_data = hc_dat.agb_old_hc_data
-        k_factor = hc_data["AGBo_k1"] * hc_data["AGBo_k2"] ** (
-            handicap + hc_data["AGBo_k3"]
-        )
-        f_factor = 1.0 + k_factor * dist ** hc_data["AGBo_p1"]
-        sig_t = (
-            hc_data["AGBo_ang_0"]
-            * (
-                (1.0 + hc_data["AGBo_step"] / 100.0)
-                ** (handicap + hc_data["AGBo_datum"])
-            )
-            * f_factor
-        )
-
-    elif hc_sys == "AA":
-        # Original Archery Australia (AA) System
-        # Factor of sqrt(2) to deal with factor of 2 in differing definitions of sigma
-        # between AGB and AA
-        # Required so code elsewhere is unchanged
-        # Factor of 1.0e-3 due to AA algorithm specifying sigma t in milliradians, so
-        # convert to rad
-        hc_data = hc_dat.aa_hc_data
-        sig_t = (
-            1.0e-3
-            * np.sqrt(2.0)
-            * np.exp(
-                hc_data["AA_k0"] - hc_data["AA_ks"] * handicap + hc_data["AA_kd"] * dist
-            )
-        )
-
-    elif hc_sys == "AA2":
-        # Updated Archery Australia (AA) System
-        # Factor of sqrt(2) to deal with factor of 2 in differing definitions of sigma
-        # between AGB and AA
-        # Required so code elsewhere is unchanged
-        # Factor of 1.0e-3 due to AA algorithm specifying sigma t in milliradians, so
-        # convert to rad
-        hc_data = hc_dat.aa2_hc_data
-        sig_t = (
-            np.sqrt(2.0)
-            * 1.0e-3
-            * np.exp(hc_data["AA2_k0"] - hc_data["AA2_ks"] * handicap)
-            * (hc_data["AA2_f1"] + hc_data["AA2_f2"] * dist / hc_data["AA2_d0"])
-        )
-
-    else:
-        raise ValueError(
-            "Invalid Handicap System specified.\n"
-            "Please select from 'AGB', 'AGBold', 'AA', or 'AA2'."
-        )
-
-    return sig_t
-
-
-def sigma_r(
-    handicap: FloatArray,
-    hc_sys: str,
-    dist: float,
-    hc_dat: HcParams,
-) -> FloatArray:
-    """
-    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
-    ----------
-    handicap : float or ndarray
-        handicap to calculate sigma_t at
-    hc_sys : str
-        identifier for handicap system
-    dist : float or ndarray
-        distance to target [m]
-    hc_dat : HcParams
-        dataclass containing parameters for handicap equations
-
-    Returns
-    -------
-    sig_r : float or ndarray
-        standard deviation of group size [metres]
-
-    Examples
-    --------
-    Deviation (in metres) at a distance of 25m, using the AGB handicap system at a
-    handicap of 10 can be calculated with:
-
-    >>> import archeryutils.handicap_equations as hc_eq
-    >>> hc_params = hc_eq.HcParams()
-    >>> hc_eq.sigma_r(10.0, "AGB", 25.0, hc_params)
-    0.023745700245257646
-
-    It can also be passed an array of handicaps:
-
-    >>> hc_eq.sigma_t(np.asarray([10.0, 50.0, 100.0]), "AGB", 25.0, hc_params)
-    array([0.0237457 , 0.09401539, 0.5250691 ])
-
-    """
-    sig_t = sigma_t(handicap, hc_sys, dist, hc_dat)
-    sig_r = dist * sig_t
-
-    return sig_r
-
-
-def arrow_score(
-    target: targets.Target,
-    handicap: FloatArray,
-    hc_sys: str,
-    hc_dat: HcParams,
-    arw_d: Optional[float] = None,
-) -> FloatArray:
-    # Six too many branches. Makes sense due to different target faces => disable
-    # pylint: disable=too-many-branches
-    """
-    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
-    handicap : float or ndarray
-        handicap value to calculate score for
-    hc_sys : string
-        identifier for the handicap system
-    hc_dat : HcParams
-        dataclass containing parameters for handicap equations
-    arw_d : float or None, default=None
-        arrow diameter in [metres]
-
-    Returns
-    -------
-    s_bar : float or ndarray
-        average score of the arrow for this set of parameters
-
-    Raises
-    ------
-    ValueError
-        If the target uses a scoring system for which no handicap calculations exist.
-
-    References
-    ----------
-    - The construction of the graduated handicap tables for target archery
-      Lane, D (2013)
-
-    Examples
-    --------
-    Expected arrow score on a WA720 70m target, using the AGB handicap system at a
-    handicap of 10 can be calculated with:
-
-    >>> import archeryutils as au
-    >>> my720target = au.Target("10_zone", 122, 70.0)
-    >>> hc_params = au.handicap_equations.HcParams()
-    >>> au.handicap_equations.arrow_score(my720target, 10.0, "AGB", hc_params)
-    9.401182682963338
-
-    It can also be passed an array of handicaps:
-
-    >>> au.handicap_equations.sigma_t(my720target,
-    ...                               np.asarray([10.0, 50.0, 100.0]),
-    ...                               "AGB",
-    ...                               hc_params)
-    array([9.40118268, 6.05227962, 0.46412515])
-
-    """
-    # 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.arw_d_data["AGBo_arw_d"] / 2.0
-        else:
-            if target.indoor:
-                arw_rad = hc_dat.arw_d_data["arw_d_in"] / 2.0
-            else:
-                if hc_sys in ("AA", "AA2"):
-                    arw_rad = hc_dat.arw_d_data["AA_arw_d_out"] / 2.0
-                else:
-                    arw_rad = hc_dat.arw_d_data["arw_d_out"] / 2.0
-    else:
-        arw_rad = arw_d / 2.0
-
-    tar_dia = target.diameter
-    sig_r = sigma_r(handicap, hc_sys, target.distance, hc_dat)
-
-    if target.scoring_system == "5_zone":
-        s_bar = (
-            9.0
-            - 2.0
-            * sum(
-                np.exp(-((((n * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
-                for n in range(1, 5)
-            )
-            - np.exp(-((((5.0 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
-        )
-
-    elif target.scoring_system == "10_zone":
-        s_bar = 10.0 - sum(
-            np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
-            for n in range(1, 11)
-        )
-
-    elif target.scoring_system == "10_zone_6_ring":
-        s_bar = (
-            10.0
-            - sum(
-                np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
-                for n in range(1, 6)
-            )
-            - 5.0 * np.exp(-((((6.0 * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
-        )
-
-    elif target.scoring_system == "10_zone_compound":
-        s_bar = (
-            10.0
-            - np.exp(-((((tar_dia / 40.0) + arw_rad) / sig_r) ** 2))
-            - sum(
-                np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
-                for n in range(2, 11)
-            )
-        )
-
-    elif target.scoring_system == "10_zone_5_ring":
-        s_bar = (
-            10.0
-            - sum(
-                np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
-                for n in range(1, 5)
-            )
-            - 6.0 * np.exp(-((((5.0 * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
-        )
-
-    elif target.scoring_system == "10_zone_5_ring_compound":
-        s_bar = (
-            10.0
-            - np.exp(-((((tar_dia / 40) + arw_rad) / sig_r) ** 2))
-            - sum(
-                np.exp(-((((n * tar_dia / 20) + arw_rad) / sig_r) ** 2))
-                for n in range(2, 5)
-            )
-            - 6.0 * np.exp(-((((5 * tar_dia / 20) + arw_rad) / sig_r) ** 2))
-        )
-
-    elif target.scoring_system == "WA_field":
-        s_bar = (
-            6.0
-            - np.exp(-((((tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
-            - sum(
-                np.exp(-((((n * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
-                for n in range(2, 7)
-            )
-        )
-
-    elif target.scoring_system == "IFAA_field":
-        s_bar = (
-            5.0
-            - np.exp(-((((tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
-            - np.exp(-((((3.0 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
-            - 3.0 * np.exp(-((((5.0 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
-        )
-
-    elif target.scoring_system == "Beiter_hit_miss":
-        s_bar = 1.0 - np.exp(-((((tar_dia / 2.0) + arw_rad) / sig_r) ** 2))
-
-    elif target.scoring_system in ("Worcester", "IFAA_field_expert"):
-        s_bar = 5.0 - sum(
-            np.exp(-((((n * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
-            for n in range(1, 6)
-        )
-
-    elif target.scoring_system == "Worcester_2_ring":
-        s_bar = (
-            5.0
-            - np.exp(-((((tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
-            - 4.0 * np.exp(-((((2 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
-        )
-
-    else:
-        raise ValueError(
-            f"No rule for calculating scoring for face type {target.scoring_system}."
-        )
-
-    return s_bar
-
-
-def score_for_passes(
-    rnd: rounds.Round,
-    handicap: FloatArray,
-    hc_sys: str,
-    hc_dat: HcParams,
-    arw_d: Optional[float] = None,
-) -> npt.NDArray[np.float_]:
-    """
-    Calculate the expected score for all passes in a round at a given handicap rating.
-
-    Parameters
-    ----------
-    rnd : rounds.Round
-        A Round class specifying the round being shot
-    handicap : float or ndarray
-        handicap value to calculate score for
-    hc_sys : string
-        identifier for the handicap system
-    hc_dat : HcParams
-        dataclass containing parameters for handicap equations
-    arw_d : float or None, default=None
-        arrow diameter in [metres] default = None -> (use defaults)
-
-    Returns
-    -------
-    pass_scores : ndarray
-        average score for each pass in the round (unrounded decimal)
-
-    Examples
-    --------
-    Expected score for each pass on a WA1440 90m, using the AGB handicap system at a
-    handicap of 10 can be calculated with the code below which returns an array with
-    one score for each pass that makes up the round:
-
-    >>> import archeryutils as au
-    >>> wa_outdoor = au.load_rounds.WA_outdoor
-    >>> hc_params = au.handicap_equations.HcParams()
-    >>> au.handicap_equations.score_for_passes(wa_outdoor.wa1440_90, 10.0, "AGB", hc_params)
-    array([322.84091528, 338.44257659, 338.66395001, 355.87959411])
-
-    It can also be passed an array of handicaps:
-
-    >>> au.handicap_equations.score_for_passes(wa_outdoor.wa1440_90,
-    ...                                        np.asarray([10.0, 50.0, 100.0]),
-    ...                                        "AGB",
-    ...                                        hc_params)
-    array([[322.84091528, 162.76200686,   8.90456718],
-           [338.44257659, 217.88206641,  16.70850537],
-           [338.66395001, 216.74407488,  16.41855209],
-           [355.87959411, 288.77185611,  48.47897177]])
-
-    """
-    pass_scores = np.array(
-        [
-            pass_i.n_arrows
-            * arrow_score(pass_i.target, handicap, hc_sys, hc_dat, arw_d=arw_d)
-            for pass_i in rnd.passes
-        ]
-    )
-
-    return pass_scores
-
-
-def score_for_round(
-    rnd: rounds.Round,
-    handicap: FloatArray,
-    hc_sys: str,
-    hc_dat: HcParams,
-    arw_d: Optional[float] = None,
-    round_score_up: bool = True,
-) -> FloatArray:
-    """
-    Calculate the expected score for a round at a given handicap rating.
-
-    Parameters
-    ----------
-    rnd : rounds.Round
-        A Round class specifying the round being shot
-    handicap : float or ndarray
-        handicap value to calculate score for
-    hc_sys : string
-        identifier for the handicap system
-    hc_dat : HcParams
-        dataclass containing parameters for handicap equations
-    arw_d : float or None, default=None
-        arrow diameter in [metres] default = None -> (use defaults)
-    round_score_up : bool, default=True
-        round score up to nearest integer value?
-
-    Returns
-    -------
-    round_score : float or ndarray
-        average score of the round for this set of parameters
-
-    Examples
-    --------
-    Expected score for a WA1440 90m, using the AGB handicap system at a
-    handicap of 10 can be calculated with:
-
-    >>> import archeryutils as au
-    >>> wa_outdoor = au.load_rounds.WA_outdoor
-    >>> hc_params = au.handicap_equations.HcParams()
-    >>> au.handicap_equations.score_for_round(wa_outdoor.wa1440_90, 10.0, "AGB", hc_params)
-    1356.0
-    >>> au.handicap_equations.score_for_round(wa_outdoor.wa1440_90,
-    ...                                       10.0,
-    ...                                       "AGB",
-    ...                                       hc_params,
-    ...                                       round_score_up=False)
-    1355.8270359849505
-
-    It can also be passed an array of handicaps:
-
-    >>> au.handicap_equations.score_for_passes(wa_outdoor.wa1440_90,
-    ...                                        np.asarray([10.0, 50.0, 100.0]),
-    ...                                        "AGB",
-    ...                                        hc_params)
-    array([1356.,  887.,   91.])
-
-    """
-    # Two too many arguments. Makes sense at the moment => disable
-    # Could try and simplify hc_sys and hc_dat in future refactor
-    # pylint: disable=too-many-arguments
-
-    round_score = np.sum(score_for_passes(rnd, handicap, hc_sys, hc_dat, arw_d), axis=0)
-
-    if round_score_up:
-        # Old AGB system uses plain rounding rather than ceil of other schemes
-        if hc_sys in ("AGBold", "AA", "AA2"):
-            round_score = np.around(round_score)
-        else:
-            round_score = np.ceil(round_score)
-
-    return round_score
diff --git a/archeryutils/handicaps/handicap_functions.py b/archeryutils/handicaps/handicap_functions.py
index b784b07..2e91678 100644
--- a/archeryutils/handicaps/handicap_functions.py
+++ b/archeryutils/handicaps/handicap_functions.py
@@ -12,754 +12,314 @@
 
 Routine Listings
 ----------------
-- handicap_from_score
-- get_max_score_handicap
-- rootfind_score_handicap
-- f_root
-- print_handicap_table
-- check_print_table_inputs
-- clean_repeated
-- abbreviate
-- table_as_str
-- format_row
-- table_to_file
+- handicap_scheme()
+- arrow_score()
+- score_for_passes()
+- score_for_round()
+- handicap_from_score()
+
 
 """
 
-from typing import Union, Optional
-import warnings
-from itertools import chain
-import decimal
+from typing import Optional, Union
+
 import numpy as np
-from numpy.typing import NDArray
+import numpy.typing as npt
 
-import archeryutils.handicaps.handicap_equations as hc_eq
-from archeryutils import rounds
+from archeryutils import rounds, targets
 
-_FILL = -9999
+from .handicap_scheme import FloatArray, HandicapScheme
+from .handicap_scheme_aa import HandicapAA, HandicapAA2
+from .handicap_scheme_agb import HandicapAGB, HandicapAGBold
 
+_CLASSES = {
+    "AGB": HandicapAGB,
+    "AGBold": HandicapAGBold,
+    "AA": HandicapAA,
+    "AA2": HandicapAA2,
+}
 
-def handicap_from_score(
-    score: float,
-    rnd: rounds.Round,
-    hc_sys: str,
-    hc_dat: hc_eq.HcParams,
-    arw_d: Optional[float] = None,
-    int_prec: bool = False,
-) -> Union[int, float]:
-    # One too many arguments. Makes sense at the moment => disable
-    # Could try and simplify hc_sys and hc_dat in future refactor
-    # pylint: disable=too-many-arguments
-    """
-    Calculate the handicap of a given score on a given round using root-finding.
+
+def handicap_scheme(
+    handicap_sys: Union[str, HandicapScheme], **kwargs: float
+) -> HandicapScheme:
+    r"""
+    Create a HandicapScheme subclass for a requested handicap scheme.
 
     Parameters
     ----------
-    score : int or float
-        score achieved on the round
-    rnd : rounds.Round
-        a rounds.Round class object that was shot
-    hc_sys : str
-        identifier for the handicap system
-    hc_dat : handicaps.handicap_equations.HcParams
-        dataclass containing parameters for handicap equations
-    arw_d : float or None, default=None
-        arrow diameter in [metres] default = None
-    int_prec : bool, default=False
-        display results as integers? default = False, with decimal to 2dp accuracy from
-        rootfinder
+    handicap_sys : Union[str, HandicapScheme]
+        String identifying the handicap scheme class to be generated, or an existing
+        HandicapScheme
+    \\**kwargs : float
+        Various kwargs defining custom values of parameters as appropriate for hc_sys
 
     Returns
     -------
-    handicap: int or float
-        Handicap. Has type int if int_prec is True, and otherwise has type false.
+    HandicapScheme
+        a subclass of HandicapScheme as appropriate for the inputs
 
     Raises
     ------
     ValueError
-        If an invalid score for the given round is provided.
+        If a handicap scheme is requested for which there is no defined class.
+
+    See Also
+    --------
+    handicap_scheme.HandicapScheme : The HandicapScheme class
+    handicap_scheme_agb.HandicapAGB :
+        The AGB HandicapScheme subclass and associated \\**kwargs
+    handicap_scheme_agb.HandicapAGBold :
+        The AGBold HandicapScheme subclass and associated \\**kwargs
+    handicap_scheme_aa.HandicapAA :
+        The AA HandicapScheme subclass and associated \\**kwargs
+    handicap_scheme_aa.HandicapAA2 :
+        The AA2 HandicapScheme subclass and associated \\**kwargs
 
     Examples
     --------
-    Angular deviation at a distance of 25m, using the AGB handicap system at a
-    handicap of 10 can be calculated with:
+    To generate a HandicapScheme class using the Archery GB (AGB) handicap scheme:
 
-    >>> import archeryutils as au
-    >>> hc_params = au.handicap_equations.HcParams()
-    >>> wa_outdoor = au.load_rounds.WA_outdoor
-    >>> au.handicap_functions.handicap_from_score(999, wa_outdoor.wa1440_90, "AGB", hc_params)
-    43.999964586102706
-    >>> au.handicap_functions.handicap_from_score(
-    ...     999,
-    ...     wa_outdoor.wa1440_90,
-    ...     "AGB", hc_params,
-    ...     int_prec=True
-    ... )
-    44.0
+    >>> from archeryutils import handicaps as hc
+    >>> agb_scheme = hc.handicap_scheme("AGB")
+    >>> agb_scheme
+    HandicapScheme('AGB')
 
     """
-    # Check we have a valid score
-    max_score = rnd.max_score()
-    if score > max_score:
-        raise ValueError(
-            f"The score of {score} provided is greater than the maximum of {max_score} "
-            f"for a {rnd.name}."
-        )
-    if score <= 0.0:
+    if isinstance(handicap_sys, HandicapScheme):
+        return handicap_sys
+    try:
+        return _CLASSES[handicap_sys](**kwargs)
+    except KeyError as exc:
         raise ValueError(
-            f"The score of {score} provided is less than or equal to zero so cannot "
-            "have a handicap."
-        )
-
-    if score == max_score:
-        # Deal with max score before root finding
-        return get_max_score_handicap(rnd, hc_sys, hc_dat, arw_d, int_prec)
-
-    handicap = rootfind_score_handicap(score, rnd, hc_sys, hc_dat, arw_d)
-
-    # Force integer precision if required.
-    if int_prec:
-        if hc_sys in ("AA", "AA2"):
-            handicap = np.floor(handicap)
-        else:
-            handicap = np.ceil(handicap)
-
-        sc_int = hc_eq.score_for_round(
-            rnd, handicap, 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:
-            handicap += hstep
-            sc_int = hc_eq.score_for_round(
-                rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=True
-            )
-            if sc_int < score:
-                handicap -= hstep  # undo the iteration that caused flag to raise
-                min_h_flag = True
-
-    return handicap
-
-
-def get_max_score_handicap(
-    rnd: rounds.Round,
-    hc_sys: str,
-    hc_dat: hc_eq.HcParams,
-    arw_d: Optional[float],
-    int_prec: bool = False,
-) -> Union[int, float]:
-    """
-    Get handicap for maximum score on a round.
+            f"{handicap_sys} is not a recognised handicap system.\n"
+            f"""Please select from '{"', '".join(_CLASSES.keys())}'."""
+        ) from exc
 
-    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.
 
-    Parameters
-    ----------
-    rnd : rounds.Round
-        round being used
-    hc_sys : str
-        identifier for the handicap system
-    hc_dat : handicaps.handicap_equations.HcParams
-        dataclass containing parameters for handicap equations
-    arw_d : float
-        arrow diameter in [metres] default = None
-    int_prec : bool, default=False
-        display results as integers? default = False
-
-    Returns
-    -------
-    handicap : float
-        appropriate handicap for this maximum score
-
-    """
-    max_score = rnd.max_score()
-
-    if hc_sys in ("AA", "AA2"):
-        handicap = 175.0
-        delta_hc = -0.01
-    else:
-        handicap = -75.0
-        delta_hc = 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, handicap, hc_sys, hc_dat, arw_d, round_score_up=False
-    )
-    # Work down to where we would round or ceil to max score
-    while s_max > max_score - round_lim:
-        handicap = handicap + delta_hc
-        s_max = hc_eq.score_for_round(
-            rnd, handicap, hc_sys, hc_dat, arw_d, round_score_up=False
-        )
-    handicap = handicap - delta_hc  # Undo final iteration that overshoots
-    if int_prec:
-        if hc_sys in ("AA", "AA2"):
-            handicap = np.ceil(handicap)
-        else:
-            handicap = np.floor(handicap)
-    else:
-        warnings.warn(
-            "Handicap requested for maximum score without integer precision.\n"
-            "Value returned will be first handicap that achieves this score.\n"
-            "This could cause issues if you are not working in integers.",
-            UserWarning,
-        )
-    return handicap
-
-
-def rootfind_score_handicap(
-    score: float,
-    rnd: rounds.Round,
-    hc_sys: str,
-    hc_dat: hc_eq.HcParams,
-    arw_d: Optional[float],
-) -> float:
+def arrow_score(
+    handicap: FloatArray,
+    target: targets.Target,
+    handicap_sys: Union[str, HandicapScheme],
+    arw_d: Optional[float] = None,
+) -> FloatArray:
     """
-    Get handicap for general score on a round through rootfinding algorithm.
+    Calculate the average arrow score for a given target and handicap rating.
 
     Parameters
     ----------
-    score : float
-        score to get handicap for
-    rnd : rounds.Round
-        round being used
-    hc_sys : str
-        identifier for the handicap system
-    hc_dat : handicaps.handicap_equations.HcParams
-        dataclass containing parameters for handicap equations
-    arw_d : float
-        arrow diameter in [metres] default = None
+    handicap : FloatArray
+        handicap value to calculate score for
+    target : targets.Target
+        A Target class specifying the target to be used
+    handicap_sys : Union[str, HandicapScheme]
+        identifier for the handicap system to use
+    arw_d : float or None, default=None
+        user-specified arrow diameter in [metres]
 
     Returns
     -------
-    handicap : float
-        appropriate accurate handicap for this score
+    FloatArray
+        average expected arrow score for this handicap and target
 
-    References
-    ----------
-    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/dde39b7/scipy/optimize/Zeros/brentq.c
+    Warnings
+    --------
+    Using a custom value for arrow diameter may give values that differ
+    from the official handicap scheme values.
 
-    """
-    # The rootfinding algorithm here raises pylint errors for
-    # too many statements (64/50), branches (17/12), and variables(23/15).
-    # However, it is a single enclosed algorithm => disable
-    # pylint: disable=too-many-locals
-    # pylint: disable=too-many-branches
-    # pylint: disable=too-many-statements
-
-    if hc_sys in ("AA", "AA2"):
-        x_init = [-250.0, 175.0]
-    else:
-        x_init = [-75.0, 300.0]
-
-    f_init = [
-        f_root(x_init[0], score, rnd, hc_sys, hc_dat, arw_d),
-        f_root(x_init[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_init[1]) <= f_init[0]:
-        xcur = x_init[1]
-        xpre = x_init[0]
-        fcur = f_init[1]
-        fpre = f_init[0]
-    else:
-        xpre = x_init[1]
-        xcur = x_init[0]
-        fpre = f_init[1]
-        fcur = f_init[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):
-            handicap = 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
-        else:
-            # bisect
-            spre = sbis
-            scur = sbis
-        xpre = xcur
-        fpre = fcur
-        if abs(scur) > delta:
-            xcur += scur
-        else:
-            if sbis > 0:
-                xcur += delta
-            else:
-                xcur -= delta
-
-        fcur = f_root(xcur, score, rnd, hc_sys, hc_dat, arw_d)
-        handicap = xcur
-
-    return handicap
-
-
-def f_root(
-    hc_est: float,
-    score_est: float,
-    round_est: rounds.Round,
-    sys: str,
-    hc_data: hc_eq.HcParams,
-    arw_dia: Optional[float],
-) -> float:
-    """
-    Return error between predicted score and desired score.
+    Examples
+    --------
+    Expected arrow score on a WA720 70m target, using the AGB handicap system at a
+    handicap of 10 can be calculated with:
 
-    Parameters
-    ----------
-    hc_est : float
-        current estimate of handicap
-    score_est : int or float
-        current estimate of score based on hc_est
-    round_est : rounds.Round
-        round being used
-    sys : str
-        identifier for the handicap system
-    hc_data : handicaps.handicap_equations.HcParams
-        dataclass containing parameters for handicap equations
-    arw_dia : float
-        arrow diameter in [metres] default = None
+    >>> import archeryutils as au
+    >>> from archeryutils import handicaps as hc
+    >>> my720target = au.Target("10_zone", 122, 70.0)
+    >>> hc.arrow_score(10.0, my720target, "AGB")
+    9.401182682963338
 
-    Returns
-    -------
-    float
-        difference between desired value and score estimate
+    It can also be passed an array of handicaps:
 
-    Raises
-    ------
-    TypeError
-        If an array of handicaps was provided instead of a single value.
+    >>> hc.arrow_score(np.array([10.0, 50.0, 100.0]), my720target, "AGB")
+    array([9.40118268, 6.05227962, 0.46412515])
 
     """
-    # One too many arguments. Makes sense at the moment => disable
-    # Could try and simplify hc_sys and hc_dat in future refactor
-    # pylint: disable=too-many-arguments
+    hc_sys = handicap_scheme(handicap_sys)
 
-    val = hc_eq.score_for_round(
-        round_est, hc_est, sys, hc_data, arw_dia, round_score_up=False
-    )
-
-    # Ensure we return float, not np.ndarray
-    # These 8 lines replace `return val-scr` so as to satisfy mypy --strict.
-    # Should never be triggered in reality as h is type float.
-    if isinstance(val, np.float_):
-        val = val.item()
-    if isinstance(val, float):
-        return val - score_est
-    raise TypeError(
-        f"f_root is attempting to return a {type(val)} type but expected float. "
-        f"Was it passed an array of handicaps?"
-    )
+    return hc_sys.arrow_score(handicap, target, arw_d=arw_d)
 
 
-def print_handicap_table(
-    hcs: Union[float, NDArray[np.float_]],
-    hc_sys: str,
-    round_list: list[rounds.Round],
-    hc_dat: hc_eq.HcParams,
-    arrow_d: Optional[float] = None,
-    round_scores_up: bool = True,
-    clean_gaps: bool = True,
-    printout: bool = True,
-    filename: Optional[str] = None,
-    csvfile: Optional[str] = None,
-    int_prec: bool = True,
-) -> None:
+def score_for_passes(
+    handicap: FloatArray,
+    rnd: rounds.Round,
+    handicap_sys: Union[str, HandicapScheme],
+    arw_d: Optional[float] = None,
+    rounded_score: bool = True,
+) -> npt.NDArray[np.float_]:
     """
-    Generate a handicap table to screen and/or file.
+    Calculate the expected score for all passes in a round at a given handicap.
 
     Parameters
     ----------
-    hcs : float or ndarray
-        handicap value(s) to calculate score(s) for
-    hc_sys : string
-        identifier for the handicap system
-    round_list : list of rounds.Round
-        List of Round classes to calculate scores for
-    hc_dat : handicaps.handicap_equations.HcParams
-        dataclass containing parameters for handicap equations
-    arrow_d : float or None, default=None
-        arrow diameter in [metres] default = None
-    round_scores_up : bool, default=True
-        round scores up to nearest integer? default = True
-    clean_gaps : bool, default=True
-        Remove all instances of a score except the first? default = False
-    printout : bool, default=True
-        Print to screen? default = True
-    filename : str or None, default=None
-        filepath to save table to. default = None
-    csvfile : str or None, default=None
-        csv filepath to save to. default = None
-    int_prec : bool, default=True
-        display results as integers? default=True, otherwise prints decimal to 2dp
+    handicap : FloatArray
+        handicap value to calculate score for
+    rnd : rounds.Round
+        A Round class specifying the round being shot
+    handicap_sys : Union[str, HandicapScheme]
+        identifier for the handicap system to use
+    arw_d : float or None, default=None
+        user-specified arrow diameter in [metres]
+    rounded_score : bool, default=True
+        round score to integer value?
+        Note the sum of rounded passes may not be the same as the rounded round score
 
     Returns
     -------
-    None
+    NDArray
+        average score for each pass in the round
 
     Examples
     --------
+    Expected score for each pass on a WA1440 90m, using the AGB handicap system at a
+    handicap of 10 can be calculated with the code below which returns an array with
+    one score for each pass that makes up the round:
+
     >>> import archeryutils as au
-    >>> hc_params = au.handicap_equations.HcParams()
+    >>> from archeryutils import handicaps as hc
     >>> wa_outdoor = au.load_rounds.WA_outdoor
-    >>> au.handicap_functions.print_handicap_table(
-    ...     [1.0, 2.0, 3.0, 4.0, 5.0],
+    >>> hc.score_for_passes(10.0, wa_outdoor.wa1440_90, "AGB")
+    array([322.84091528, 338.44257659, 338.66395001, 355.87959411])
+
+    It can also be passed an array of handicaps:
+
+    >>> hc.score_for_passes(
+    ...     np.array([10.0, 50.0, 100.0]),
+    ...     wa_outdoor.wa1440_90,
     ...     "AGB",
-    ...     [wa_outdoor.wa1440_90, wa_outdoor.wa1440_70, wa_outdoor.wa1440_60],
-    ...     hc_params,
     ... )
-          Handicap WA 1440 (90m) WA 1440 (70m) WA 1440 (60m)
-                 1          1396          1412          1427
-                 2          1393          1409          1425
-                 3          1389          1406          1423
-                 4          1385          1403          1420
-                 5          1380          1399          1418
+    array([[322.84091528, 162.76200686,   8.90456718],
+           [338.44257659, 217.88206641,  16.70850537],
+           [338.66395001, 216.74407488,  16.41855209],
+           [355.87959411, 288.77185611,  48.47897177]])
 
     """
-    # Cannot see any other way to handle the options required here => ignore
-    # pylint: disable=too-many-arguments
-    # Knock-on effect is too many local variables raised => ignore
-
-    # Sanitise inputs
-    hcs = check_print_table_inputs(hcs, round_list, clean_gaps)
+    hc_sys = handicap_scheme(handicap_sys)
 
-    # Set up empty handicap table and populate
-    table: NDArray[Union[np.float_, np.int_]] = np.empty(
-        [len(hcs), len(round_list) + 1]
+    return hc_sys.score_for_passes(
+        handicap, rnd, arw_d=arw_d, rounded_score=rounded_score
     )
-    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,
-        )
-
-    # If rounding scores up we don't want to display trailing zeros, so ensure int_prec
-    if round_scores_up and not int_prec:
-        warnings.warn(
-            "Handicap Table incompatible options.\n"
-            "Requesting scores to be rounded up but without integer precision.\n"
-            "Setting integer precision (`int_prec`) as true.",
-            UserWarning,
-        )
-        int_prec = True
-
-    if int_prec:
-        table[:, 1:] = table[:, 1:].astype(int)
-
-    if clean_gaps:
-        table = clean_repeated(table, int_prec, hc_sys)[1:-1, :]
-
-    # Write to CSV
-    if csvfile is not None:
-        print("Writing handicap table to csv...", end="")
-        np.savetxt(
-            csvfile,
-            table,
-            delimiter=", ",
-            header=f"handicap, {','.join([round_i.name for round_i in round_list])}'",
-        )
-        print("Done.")
-
-    # Write to terminal/string
-    # Return early if this isn't required
-    if filename is None and not printout:
-        return
-
-    # Generate string to output to file or display
-    output_str = table_as_str(round_list, hcs, table, int_prec)
-
-    if printout:
-        print(output_str)
-
-    if filename is not None:
-        table_to_file(filename, output_str)
-
-
-def check_print_table_inputs(
-    hcs: Union[float, NDArray[np.float_]],
-    round_list: list[rounds.Round],
-    clean_gaps: bool = True,
-) -> NDArray[np.float_]:
-    """
-    Sanitise and format inputs to handicap printing code.
-
-    Parameters
-    ----------
-    hcs : float or ndarray
-        handicap value(s) to calculate score(s) for
-    round_list : list of rounds.Round
-        List of Round classes to calculate scores for
-    clean_gaps : bool, default=True
-        Remove all instances of a score except the first? default = False
-
-    Returns
-    -------
-    hcs : ndarray
-        handicaps prepared for use in table printing routines
-
-    Raises
-    ------
-    TypeError
-        If handicaps not provided as float or ndarray
-    ValueError
-        If no rounds are provided for the handicap table
-
-    """
-    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.")
-
-    if len(round_list) == 0:
-        raise ValueError("No rounds provided for handicap table.")
-
-    # if cleaning gaps add row to top/bottom of table to catch out of range repeats
-    if clean_gaps:
-        delta_s = hcs[1] - hcs[0] if len(hcs) > 1 else 1.0
-        delta_e = hcs[-1] - hcs[-2] if len(hcs) > 1 else 1.0
-        hcs = np.insert(hcs, 0, hcs[0] - delta_s)
-        hcs = np.append(hcs, hcs[-1] + delta_e)
 
-    return hcs
 
-
-def clean_repeated(
-    table: NDArray[Union[np.float_, np.int_]],
-    int_prec: bool = False,
-    hc_sys: str = "AGB",
-) -> NDArray[Union[np.float_, np.int_]]:
+def score_for_round(
+    handicap: FloatArray,
+    rnd: rounds.Round,
+    handicap_sys: Union[str, HandicapScheme],
+    arw_d: Optional[float] = None,
+    rounded_score: bool = True,
+):
     """
-    Keep only the first instance of a score in the handicap tables.
+    Calculate the expected score for a round at a given handicap.
 
     Parameters
     ----------
-    table : ndarray
-        handicap table of scores
-    int_prec : bool, default=False
-        return integers, not floats?
-    hc_sys : str, default="AGB"
-        handicap system used - assume AGB (high -> low) unless specified
+    handicap : FloatArray
+        handicap value to calculate score for
+    rnd : rounds.Round
+        A Round class specifying the round being shot
+    handicap_sys : Union[str, HandicapScheme]
+        identifier for the handicap system to use
+    arw_d : float or None, default=None
+        user-specified arrow diameter in [metres]
+    rounded_score : bool, default=True
+        round score to integer value?
 
     Returns
     -------
-    table : ndarray
-        handicap table of scores with repetitions filtered
-
-    """
-    # NB: This assumes scores are running highest to lowest.
-    # :. Flip AA and AA2 tables before operating.
-
-    if hc_sys in ("AA", "AA2"):
-        table = np.flip(table, axis=0)
+    round_score : FloatArray
+        average score of the round for this set of parameters
 
-    for irow, row in enumerate(table[:-1, :]):
-        for jscore in range(len(row)):
-            if table[irow, jscore] == table[irow + 1, jscore]:
-                if int_prec:
-                    table[irow, jscore] = _FILL
-                else:
-                    table[irow, jscore] = np.nan
+    Examples
+    --------
+    Expected score for a WA1440 90m, using the AGB handicap system at a
+    handicap of 10 can be calculated with:
 
-    if hc_sys in ("AA", "AA2"):
-        table = np.flip(table, axis=0)
+    >>> import archeryutils as au
+    >>> from archeryutils import handicaps as hc
+    >>> wa_outdoor = au.load_rounds.WA_outdoor
+    >>> hc.score_for_round(10.0, wa_outdoor.wa1440_90, "AGB")
+    1356.0
+    >>> au.handicap_equations.score_for_round(wa_outdoor.wa1440_90,
+    ...                                       10.0,
+    ...                                       rounded_score=False)
+    1355.8270359849505
 
-    return table
+    It can also be passed an array of handicaps:
 
+    >>> hc.score_for_round(
+    ...     np.array([10.0, 50.0, 100.0]),
+    ...     wa_outdoor.wa1440_90,
+    ... )
+    array([1356.,  887.,   91.])
 
-def abbreviate(name: str) -> str:
     """
-    Replace headings within Handicap Tables with abbreviations to keep concise.
+    hc_sys = handicap_scheme(handicap_sys)
 
-    Parameters
-    ----------
-    name : str
-        full, long round name as appears currently
+    return hc_sys.score_for_round(
+        handicap, rnd, arw_d=arw_d, rounded_score=rounded_score
+    )
 
-    Returns
-    -------
-    str
-        abbreviated round name to replace with
 
-    Warnings
-    --------
-    This function only works with names containing space-separated words.
-
-    """
-    abbreviations = {
-        "Compound": "C",
-        "Recurve": "R",
-        "Triple": "Tr",
-        "Centre": "C",
-        "Portsmouth": "Ports",
-        "Worcester": "Worc",
-        "Short": "St",
-        "Long": "Lg",
-        "Small": "Sm",
-        "Gents": "G",
-        "Ladies": "L",
-    }
-
-    return " ".join(abbreviations.get(i, i) for i in name.split())
-
-
-def table_as_str(
-    round_list: list[rounds.Round],
-    hcs: NDArray[Union[np.float_, np.int_]],
-    table: NDArray[Union[np.float_, np.int_]],
+def handicap_from_score(
+    score: float,
+    rnd: rounds.Round,
+    handicap_sys: Union[str, HandicapScheme],
+    arw_d: Optional[float] = None,
     int_prec: bool = False,
-) -> str:
+) -> Union[int, float]:
     """
-    Convert the handicap table to a string.
+    Calculate the handicap of a given score on a given round.
 
     Parameters
     ----------
-    round_list : list of rounds.Round
-        List of Round classes to calculate scores for
-    hcs : ndarray
-        handicap value(s) to calculate score(s) for
-    table : ndarray
-        handicap table as array
+    score : float
+        score achieved on the round
+    rnd : rounds.Round
+        the rounds.Round object to calculate the handicap for
+    handicap_sys : str or HandicapScheme
+        identifier for the handicap system to use
+    arw_d : float or None, default=None
+        user-specified arrow diameter in [metres]
     int_prec : bool, default=False
-        return integers, not floats?
+        display results as integers? default = False
+        if True decimal results accurate to 2dp
 
     Returns
     -------
-    output_str : str
-        Handicap table formatted as a string
+    int or float
+        Handicap for score. Has type int if int_prec is True, else float.
 
-    """
-    # To ensure both terminal and file output are the same, create a single string
-    round_names = [abbreviate(r.name) for r in round_list]
-    output_header = "".join(name.rjust(14) for name in chain(["Handicap"], round_names))
-    # Auto-set the number of decimal places to display handicaps to
-    if np.max(hcs % 1.0) <= 0.0:
-        hc_dp = 0
-    else:
-        hc_dp = np.max(
-            np.abs([decimal.Decimal(str(d)).as_tuple().exponent for d in hcs])
-        )
-    # Format each row appropriately
-    output_rows = [format_row(row, hc_dp, int_prec) for row in table]
-    output_str = "\n".join(chain([output_header], output_rows))
-
-    return output_str
-
-
-def format_row(
-    row: NDArray[Union[np.float_, np.int_]],
-    hc_dp: int = 0,
-    int_prec: bool = False,
-) -> str:
-    """
-    Fornat appearance of handicap table row to look nice.
+    Examples
+    --------
+    Handicap for a score of 999 on a WA 1440 (90m) using the AGB scheme can be
+    calculated as:
 
-    Parameters
-    ----------
-    row : NDArray
-        numpy array of table row
-    hc_dp : int, default=0
-        handicap decimal places
-    int_prec : bool, default=False
-        return integers, not floats?
+    >>> import archeryutils as au
+    >>> wa_outdoor = au.load_rounds.WA_outdoor
+    >>> hc.handicap_from_score(999, wa_outdoor.wa1440_90, "AGB")
+    43.999964586102706
 
-    Returns
-    -------
-    str
-        pretty string based on input array data
-    """
-    if hc_dp == 0:
-        handicap_str = f"{int(row[0]):14d}"
-    else:
-        handicap_str = f"{row[0]:14.{hc_dp}f}"
-
-    if int_prec:
-        return handicap_str + "".join(
-            "".rjust(14) if x == _FILL else f"{int(x):14d}" for x in row[1:]
-        )
-    return handicap_str + "".join(
-        "".rjust(14) if np.isnan(x) else f"{x:14.8f}" for x in row[1:]
-    )
+    To get an integer value as would appear in the handicap tables use
+    ``int_prec=True``:
 
+    >>> au.handicap_functions.handicap_from_score(
+    ...     999,
+    ...     wa_outdoor.wa1440_90,
+    ...     "AGB", hc_params,
+    ...     int_prec=True
+    ... )
+    44.0
 
-def table_to_file(filename: str, output_str: str) -> None:
     """
-    Fornat appearance of handicap table row to look nice.
+    hc_sys = handicap_scheme(handicap_sys)
 
-    Parameters
-    ----------
-    filename : str
-        name of file to save handicap table to
-    output_str : str
-        handicap table as string to save to file
-
-    """
-    print("Writing handicap table to file...", end="")
-    with open(filename, "w", encoding="utf-8") as table_file:
-        table_file.write(output_str)
-    print("Done.")
+    return hc_sys.handicap_from_score(score, rnd, arw_d=arw_d, int_prec=int_prec)
diff --git a/archeryutils/handicaps/handicap_scheme.py b/archeryutils/handicaps/handicap_scheme.py
new file mode 100644
index 0000000..3f4e9b2
--- /dev/null
+++ b/archeryutils/handicaps/handicap_scheme.py
@@ -0,0 +1,761 @@
+"""Code for a handicap scheme class performing calculations for a  generic handicap scheme.
+
+Extended Summary
+----------------
+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
+
+References
+----------
+- The construction of the graduated handicap tables for target
+  archery
+  Lane, D (2013)
+  https://www.jackatkinson.net/files/Handicap_Tables_2013.pdf
+- New AGB: Atkinson, J
+- Modelling archers’ scores at different distances to quantify
+  score loss due to equipment selection and technique errors
+  Park, J (2014)
+  https://doi.org/10.1177%2F1754337114539308
+
+"""
+
+import warnings
+from abc import ABC, abstractmethod
+from typing import Optional, TypeVar, Union, overload
+
+import numpy as np
+import numpy.typing as npt
+
+from archeryutils import rounds, targets
+
+FloatArray = TypeVar("FloatArray", float, npt.NDArray[np.float_])
+
+
+class HandicapScheme(ABC):
+    r"""
+    Abstract Base Class to represent a generic handicap scheme.
+
+    Attributes
+    ----------
+    name : str
+        The name of the handicap scheme.
+    arw_d_out : float
+        diameter of an outdoor arrow [metres] for this scheme, default 5.5e-3
+    arw_d_in: float
+        diameter of an indoor arrow [metres] for this scheme, default 9.3e-3
+    desc_scale: bool
+        does the scheme work on a descending scale i.e. lower handicap is better, default True
+    scale_bounds: list[int]
+        Reasonable upper and lower bounds on the handicap scale for bounding searches
+    max_score_rounding_lim: float
+        Limit to round the max score to when searching
+        depends on scheme rounding method e.g. round() vs. ceil() etc.
+
+    See Also
+    --------
+    handicap_scheme_agb.HandicapAGB :
+        The AGB HandicapScheme subclass and associated \\**kwargs
+    handicap_scheme_agb.HandicapAGBold :
+        The AGBold HandicapScheme subclass and associated \\**kwargs
+    handicap_scheme_aa.HandicapAA :
+        The AA HandicapScheme subclass and associated \\**kwargs
+    handicap_scheme_aa.HandicapAA2 :
+        The AA2 HandicapScheme subclass and associated \\**kwargs
+
+    """
+
+    def __init__(self) -> None:
+        self.name: str = "unnamed"
+
+        # Arrow diameters outdoor and indoor
+        self.arw_d_out: float
+        self.arw_d_in: float
+
+        # Handicap Scale parameters
+        self.desc_scale: bool
+        self.scale_bounds: list[float]
+        self.max_score_rounding_lim: float
+
+    def __repr__(self) -> str:
+        """Return a representation of a HandicapScheme instance."""
+        return f"<HandicapScheme: '{self.name}'>"
+
+    @overload
+    @abstractmethod
+    def sigma_t(self, handicap: float, dist: float) -> float: ...
+
+    @overload
+    @abstractmethod
+    def sigma_t(
+        self, handicap: npt.NDArray[np.float_], dist: float
+    ) -> npt.NDArray[np.float_]: ...
+
+    @abstractmethod
+    def sigma_t(self, handicap: FloatArray, dist: float) -> FloatArray:
+        """Calculate angular deviation for given handicap and distance.
+
+        Parameters
+        ----------
+        handicap : FloatArray
+            handicap to calculate sigma_t at
+        dist : float
+            distance to target [metres]
+
+        Returns
+        -------
+        sig_t : FloatArray
+            angular deviation [rad]
+
+        """
+
+    def sigma_r(self, handicap: FloatArray, dist: float) -> FloatArray:
+        """Calculate radial deviation for a given handicap and distance.
+
+        Standard deviation as a proxy for 'group size' based on
+        handicap parameters, scheme, and distance.
+        Wraps around sigma_t() and multiplies by distance.
+
+        Parameters
+        ----------
+        handicap : FloatArray
+            handicap to calculate sigma_r at
+        dist : float or ndarray
+            distance to target [metres]
+
+        Returns
+        -------
+        sig_r : FloatArray
+            standard deviation of group size [metres]
+
+        Examples
+        --------
+        Deviation (in metres) at a distance of 25m and a handicap of 10,
+        using the AGB handicap system (via the HandicapSchemeAGB subclass)
+        can be calculated with:
+
+        >>> import archeryutils.handicaps as hc
+        >>> agb_scheme = hc.handicap_scheme("AGB")
+        >>> agb_scheme.sigma_r(10.0, 25.0)
+        0.023745700245257646
+
+        It can also be passed an array of handicaps:
+
+        >>> agb_scheme.sigma_t(np.asarray([10.0, 50.0, 100.0]), "AGB", 25.0)
+        array([0.0237457 , 0.09401539, 0.5250691 ])
+
+        """
+        sig_t = self.sigma_t(handicap, dist)
+        sig_r = dist * sig_t
+        return sig_r
+
+    def arrow_score(
+        self,
+        handicap: FloatArray,
+        target: targets.Target,
+        arw_d: Optional[float] = None,
+    ) -> FloatArray:
+        # Six too many branches. Makes sense due to different target faces => disable
+        # pylint: disable=too-many-branches
+        """Calculate the average arrow score for a given target and handicap rating.
+
+        Parameters
+        ----------
+        handicap : FloatArray
+            handicap value to calculate score for
+        target : targets.Target
+            A Target class specifying the target to be used
+        arw_d : float or None, default=None
+            user-specified arrow diameter in [metres]
+
+        Returns
+        -------
+        s_bar : FloatArray
+            average score of the arrow for this set of parameters
+
+        References
+        ----------
+        - The construction of the graduated handicap tables for target archery
+          Lane, D (2013)
+
+        Examples
+        --------
+        Expected arrow score on a WA720 70m target at a handicap of 10,
+        using the AGB handicap system (via the HandicapSchemeAGB subclass)
+        can be calculated with:
+
+        >>> import archeryutils as au
+        >>> import archeryutils.handicaps as hc
+        >>> my720target = au.Target("10_zone", 122, 70.0)
+        >>> agb_scheme = hc.handicap_scheme("AGB")
+        >>> agb_scheme.arrow_score(10.0, my720target)
+        9.401182682963338
+
+        It can also be passed an array of handicaps:
+
+        >>> agb_scheme.sigma_t(np.array([10.0, 50.0, 100.0]), my720target)
+        array([9.40118268, 6.05227962, 0.46412515])
+
+        """
+        # Set arrow diameter. Use scheme default based on in-/out-doors if none provided.
+        if arw_d is None:
+            if target.indoor:
+                arw_d = self.arw_d_in
+            else:
+                arw_d = self.arw_d_out
+
+        arw_rad = arw_d / 2.0
+
+        tar_dia = target.diameter
+        sig_r = self.sigma_r(handicap, target.distance)
+
+        if target.scoring_system == "5_zone":
+            s_bar = (
+                9.0
+                - 2.0
+                * sum(
+                    np.exp(-((((n * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
+                    for n in range(1, 5)
+                )
+                - np.exp(-((((5.0 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
+            )
+
+        elif target.scoring_system == "10_zone":
+            s_bar = 10.0 - sum(
+                np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
+                for n in range(1, 11)
+            )
+
+        elif target.scoring_system == "10_zone_6_ring":
+            s_bar = (
+                10.0
+                - sum(
+                    np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
+                    for n in range(1, 6)
+                )
+                - 5.0 * np.exp(-((((6.0 * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
+            )
+
+        elif target.scoring_system == "10_zone_compound":
+            s_bar = (
+                10.0
+                - np.exp(-((((tar_dia / 40.0) + arw_rad) / sig_r) ** 2))
+                - sum(
+                    np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
+                    for n in range(2, 11)
+                )
+            )
+
+        elif target.scoring_system == "10_zone_5_ring":
+            s_bar = (
+                10.0
+                - sum(
+                    np.exp(-((((n * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
+                    for n in range(1, 5)
+                )
+                - 6.0 * np.exp(-((((5.0 * tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
+            )
+
+        elif target.scoring_system == "10_zone_5_ring_compound":
+            s_bar = (
+                10.0
+                - np.exp(-((((tar_dia / 40) + arw_rad) / sig_r) ** 2))
+                - sum(
+                    np.exp(-((((n * tar_dia / 20) + arw_rad) / sig_r) ** 2))
+                    for n in range(2, 5)
+                )
+                - 6.0 * np.exp(-((((5 * tar_dia / 20) + arw_rad) / sig_r) ** 2))
+            )
+
+        elif target.scoring_system == "WA_field":
+            s_bar = (
+                6.0
+                - np.exp(-((((tar_dia / 20.0) + arw_rad) / sig_r) ** 2))
+                - sum(
+                    np.exp(-((((n * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
+                    for n in range(2, 7)
+                )
+            )
+
+        elif target.scoring_system == "IFAA_field":
+            s_bar = (
+                5.0
+                - np.exp(-((((tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
+                - np.exp(-((((3.0 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
+                - 3.0 * np.exp(-((((5.0 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
+            )
+
+        elif target.scoring_system == "Beiter_hit_miss":
+            s_bar = 1.0 - np.exp(-((((tar_dia / 2.0) + arw_rad) / sig_r) ** 2))
+
+        elif target.scoring_system in ("Worcester", "IFAA_field_expert"):
+            s_bar = 5.0 - sum(
+                np.exp(-((((n * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
+                for n in range(1, 6)
+            )
+
+        elif target.scoring_system == "Worcester_2_ring":
+            s_bar = (
+                5.0
+                - np.exp(-((((tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
+                - 4.0 * np.exp(-((((2 * tar_dia / 10.0) + arw_rad) / sig_r) ** 2))
+            )
+        # No else raising error needed as invalid scoring systems handled in Target class
+
+        return s_bar
+
+    def score_for_passes(
+        self,
+        handicap: FloatArray,
+        rnd: rounds.Round,
+        arw_d: Optional[float] = None,
+        rounded_score: bool = True,
+    ) -> npt.NDArray[np.float_]:
+        """Calculate the expected score for all passes in a round at a given handicap.
+
+        Parameters
+        ----------
+        handicap : FloatArray
+            handicap value to calculate score for
+        rnd : rounds.Round
+            A Round class specifying the round being shot
+        arw_d : float or None, default=None
+            user-specified arrow diameter in [metres]
+        rounded_score : bool, default=True
+            round score to integer value?
+            Note the sum of rounded passes may not be the same as the rounded round score
+
+        Returns
+        -------
+        pass_scores : NDArray
+            average score for each pass in the round
+
+        Examples
+        --------
+        Expected score for each pass on a WA1440 90m at a handicap of 10,
+        using the AGB handicap system (via the HandicapSchemeAGB subclass)
+        can be calculated with the following code which returns an array with
+        one score for each pass that makes up the round:
+
+        >>> import archeryutils as au
+        >>> import archeryutils.handicaps as hc
+        >>> wa_outdoor = au.load_rounds.WA_outdoor
+        >>> agb_scheme = hc.handicap_scheme("AGB")
+        >>> agb_scheme.score_for_passes(10.0, wa_outdoor.wa1440_90)
+        array([322.84091528, 338.44257659, 338.66395001, 355.87959411])
+
+        It can also be passed an array of handicaps:
+
+        >>> agb_scheme.score_for_passes(np.array([10.0, 50.0, 100.0]), wa_outdoor.wa1440_90)
+        array([[322.84091528, 162.76200686,   8.90456718],
+               [338.44257659, 217.88206641,  16.70850537],
+               [338.66395001, 216.74407488,  16.41855209],
+               [355.87959411, 288.77185611,  48.47897177]])
+
+        """
+        pass_scores = np.array(
+            [
+                pass_i.n_arrows * self.arrow_score(handicap, pass_i.target, arw_d=arw_d)
+                for pass_i in rnd.passes
+            ]
+        )
+
+        return self._rounded_score(pass_scores) if rounded_score else pass_scores
+
+    def score_for_round(
+        self,
+        handicap: FloatArray,
+        rnd: rounds.Round,
+        arw_d: Optional[float] = None,
+        rounded_score: bool = True,
+    ) -> FloatArray:
+        """Calculate the expected score for a round at a given handicap.
+
+        Parameters
+        ----------
+        handicap : FloatArray
+            handicap value to calculate score for
+        rnd : rounds.Round
+            A Round class specifying the round being shot
+        arw_d : float or None, default=None
+            user-specified arrow diameter in [metres]
+        rounded_score : bool, default=True
+            round score to integer value?
+
+        Returns
+        -------
+        round_score : FloatArray
+            average score of the round for this set of parameters
+
+        Examples
+        --------
+        Expected score for a WA1440 90m at a handicap of 10,
+        using the AGB handicap system (via the HandicapSchemeAGB subclass)
+        can be calculated using:
+
+        >>> import archeryutils as au
+        >>> import archeryutils.handicaps as hc
+        >>> wa_outdoor = au.load_rounds.WA_outdoor
+        >>> agb_scheme = hc.handicap_scheme("AGB")
+        >>> agb_scheme.score_for_round(10.0, wa_outdoor.wa1440_90)
+        1356.0
+
+        To get a decimal value of the exact handicap corresponding to the requested
+        score use ``rounded_score=False``:
+
+        >>> agb_scheme.score_for_round(
+        ...     wa_outdoor.wa1440_90,
+        ...     10.0,
+        ...     rounded_score=False,
+        ... )
+        1355.8270359849505
+
+        It can also be passed an array of handicaps:
+
+        >>> agb_scheme.score_for_round(np.array([10.0, 50.0, 100.0]), wa_outdoor.wa1440_90)
+        array([1356.,  887.,   91.])
+
+        """
+        round_score = np.sum(
+            self.score_for_passes(handicap, rnd, arw_d=arw_d, rounded_score=False),
+            axis=0,
+        )
+        return self._rounded_score(round_score) if rounded_score else round_score
+
+    @staticmethod
+    def _rounded_score(score: FloatArray) -> FloatArray:
+        """
+        Round a decimal score to an integer value.
+
+        Most schemes use plain rounding as implemented here.
+        Schemes that use floor or ceil will override in their subclass.
+
+        Parameters
+        ----------
+        score : FloatArray
+            raw scores to be rounded according to the handicap system convention
+
+        Returns
+        -------
+        FloatArray
+            scores after appropriate rounding
+        """
+        return np.around(score)
+
+    def handicap_from_score(
+        self,
+        score: float,
+        rnd: rounds.Round,
+        arw_d: Optional[float] = None,
+        int_prec: bool = False,
+    ) -> Union[int, float]:
+        """Calculate the handicap of a given score on a given round.
+
+        Parameters
+        ----------
+        score : float
+            score achieved on the round
+        rnd : rounds.Round
+            the rounds.Round object to calculate the handicap for
+        arw_d : float or None, default=None
+            user-specified arrow diameter in [metres]
+        int_prec : bool, default=False
+            display results as integers? default = False
+            decimal results accurate to 2dp from rootfinder
+
+        Returns
+        -------
+        handicap: int or float
+            Handicap for score. Has type int if int_prec is True, else float.
+
+        Raises
+        ------
+        ValueError
+            If an invalid score for the given round is provided.
+
+        Examples
+        --------
+        Handicap for a score of 999 on a WA 1440 (90m),
+        using the AGB handicap system (via the HandicapSchemeAGB subclass),
+        can be calculated using:
+
+        >>> import archeryutils as au
+        >>> import archeryutils.handicaps as hc
+        >>> wa_outdoor = au.load_rounds.WA_outdoor
+        >>> agb_scheme = hc.handicap_scheme("AGB")
+        >>> agb_scheme.score_for_round(wa_outdoor.wa1440_90, 10.0)
+        >>> agb_scheme.handicap_from_score(999, wa_outdoor.wa1440_90)
+        43.999964586102706
+
+        To get an integer value as would appear in the handicap tables use
+        ``int_prec=True``:
+
+        >>> agb_scheme.handicap_from_score(999, wa_outdoor.wa1440_90), int_prec=True)
+        44.0
+
+        """
+        # Check we have a valid score
+        max_score = rnd.max_score()
+        if score > max_score:
+            raise ValueError(
+                f"The score of {score} provided is greater than the maximum of {max_score} "
+                f"for a {rnd.name}."
+            )
+        if score <= 0.0:
+            raise ValueError(
+                f"The score of {score} provided is less than or equal to zero so cannot "
+                "have a handicap."
+            )
+
+        if score == max_score:
+            # Deal with max score before root finding
+            return self._get_max_score_handicap(rnd, arw_d, int_prec)
+
+        handicap = self._rootfind_score_handicap(score, rnd, arw_d=arw_d)
+
+        # Force integer precision if required.
+        if int_prec:
+            if self.desc_scale:
+                handicap = np.ceil(handicap)
+            else:
+                handicap = np.floor(handicap)
+
+            sc_int = self.score_for_round(handicap, rnd, arw_d, rounded_score=True)
+
+            # Check that you can't get the same score from a larger handicap when
+            # working in integers
+            min_h_flag = False
+            if self.desc_scale:
+                hstep = 1.0
+            else:
+                hstep = -1.0
+            while not min_h_flag:
+                handicap += hstep
+                sc_int = self.score_for_round(handicap, rnd, arw_d, rounded_score=True)
+                if sc_int < score:
+                    handicap -= hstep  # undo the iteration that caused flag to raise
+                    min_h_flag = True
+
+        return handicap
+
+    def _get_max_score_handicap(
+        self,
+        rnd: rounds.Round,
+        arw_d: Optional[float] = None,
+        int_prec: bool = False,
+    ) -> Union[int, float]:
+        """Get handicap for maximum score on a round.
+
+        Start high and drop down until no longer rounding to max score.
+        i.e. >= max_score - 1.0 for ceil() rounding, and >= max_score - 0.5 for around().
+
+        Parameters
+        ----------
+        rnd : rounds.Round
+            round being used
+        arw_d : float or None, default=None
+            user-specified arrow diameter in [metres]
+        int_prec : bool, default=False
+            display results as integers?
+
+        Returns
+        -------
+        handicap : int or float
+            Handicap for maximum score. Has type int if int_prec is True, else float.
+
+        Warns
+        ------
+        UserWarning
+            If called with int_prec=False as value limited by the numerical scheme delta.
+
+        """
+        max_score = rnd.max_score()
+
+        if self.desc_scale:
+            handicap = min(self.scale_bounds)
+            delta_hc = 0.01
+        else:
+            handicap = max(self.scale_bounds)
+            delta_hc = -0.01
+
+        s_max = self.score_for_round(handicap, rnd, arw_d, rounded_score=False)
+        # Work down to where we would round or ceil to max score
+        while s_max > max_score - self.max_score_rounding_lim:
+            handicap = handicap + delta_hc
+            s_max = self.score_for_round(handicap, rnd, arw_d, rounded_score=False)
+        handicap = handicap - delta_hc  # Undo final iteration that overshoots
+        if int_prec:
+            if self.desc_scale:
+                handicap = np.floor(handicap)
+            else:
+                handicap = np.ceil(handicap)
+        else:
+            warnings.warn(
+                "Handicap requested for maximum score without integer precision.\n"
+                "Value returned will be first handicap that achieves this score.\n"
+                "This could cause issues if you are not working in integers.",
+                UserWarning,
+            )
+        return handicap
+
+    def _rootfind_score_handicap(
+        self,
+        score: float,
+        rnd: rounds.Round,
+        arw_d: Optional[float] = None,
+    ) -> float:
+        """Get handicap for general score on a round through rootfinding algorithm.
+
+        Parameters
+        ----------
+        score : float
+            score to get handicap for
+        rnd : rounds.Round
+            round being used
+        arw_d : float or None, default=None
+            user-specified arrow diameter in [metres]
+
+        Returns
+        -------
+        handicap : float
+            appropriate accurate handicap for this score
+
+        References
+        ----------
+        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/dde39b7/scipy/optimize/Zeros/brentq.c
+
+        """
+        # The rootfinding algorithm here raises pylint errors for
+        # too many statements (64/50), branches (17/12), and variables(23/15).
+        # However, it is a single enclosed algorithm => disable
+        # pylint: disable=too-many-locals
+        # pylint: disable=too-many-branches
+        # pylint: disable=too-many-statements
+
+        x_init = self.scale_bounds
+
+        f_init = [
+            self._f_root(x_init[0], score, rnd, arw_d=arw_d),
+            self._f_root(x_init[1], score, rnd, arw_d=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_init[1]) <= f_init[0]:
+            xcur = x_init[1]
+            xpre = x_init[0]
+            fcur = f_init[1]
+            fpre = f_init[0]
+        else:
+            xpre = x_init[1]
+            xcur = x_init[0]
+            fpre = f_init[1]
+            fcur = f_init[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):
+                handicap = 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
+            else:
+                # bisect
+                spre = sbis
+                scur = sbis
+            xpre = xcur
+            fpre = fcur
+            if abs(scur) > delta:
+                xcur += scur
+            elif sbis > 0:
+                xcur += delta
+            else:
+                xcur -= delta
+
+            fcur = self._f_root(xcur, score, rnd, arw_d)
+            handicap = xcur
+
+        return handicap
+
+    def _f_root(
+        self,
+        hc_est: float,
+        score_est: float,
+        round_est: rounds.Round,
+        arw_d: Optional[float] = None,
+    ) -> float:
+        """Return error between predicted score and desired score.
+
+        Parameters
+        ----------
+        hc_est : float
+            current estimate of handicap
+        score_est : float
+            target score
+        round_est : rounds.Round
+            round being used
+        arw_d : float or None, default=None
+            arrow diameter in [metres]
+
+        Returns
+        -------
+        float
+            difference between desired value and score estimate
+
+        """
+        val = self.score_for_round(hc_est, round_est, arw_d=arw_d, rounded_score=False)
+        return val - score_est
diff --git a/archeryutils/handicaps/handicap_scheme_aa.py b/archeryutils/handicaps/handicap_scheme_aa.py
new file mode 100644
index 0000000..af93d2d
--- /dev/null
+++ b/archeryutils/handicaps/handicap_scheme_aa.py
@@ -0,0 +1,282 @@
+"""Code for handicap calculations using Archery Australia handicap schemes.
+
+Extended Summary
+----------------
+Code to calculate information using a number of schemes:
+
+- Original Archery Australia (J Park)
+- Updated Archery Australia (J Park)
+
+Routine Listings
+----------------
+- HandicapAA Class
+- HandicapAA2 Class
+
+References
+----------
+- Modelling archers’ scores at different distances to quantify
+  score loss due to equipment selection and technique errors
+  Park, J (2014)
+  https://doi.org/10.1177%2F1754337114539308
+
+"""
+
+import numpy as np
+
+from .handicap_scheme import FloatArray, HandicapScheme
+
+
+class HandicapAA(HandicapScheme):
+    """
+    Class to represent the original Archery Australia scheme by J. Park.
+
+    Parameters
+    ----------
+    ang_0 : float
+        datum angle used in scheme, default = 1.0e-3,
+    k0 : float
+        handicap offset to set scratch point, default= 2.37,
+    ks : float
+        change with each handicap step as a geometric progression, default= 0.027,
+    kd : float
+        distance scaling factor, default= 0.004,
+
+    Attributes
+    ----------
+    name : str="AA"
+        The name of the handicap scheme.
+    arw_d_out : float
+        diameter of an outdoor arrow: 5.0e-3 [metres]
+    arw_d_in: float
+        diameter of an indoor arrow 9.3e-3 [metres]
+
+    Warnings
+    --------
+    Using non-default values for the kwargs may produce results that
+    do not match the official values for this scheme.
+    Only change if you are experimenting and know what you are doing!
+
+    See Also
+    --------
+    HandicapScheme : The base class for a handicap scheme from which this is subclassed
+                     containing details of additional methods.
+
+    """
+
+    def __init__(
+        self,
+        ang_0: float = 1.0e-3,
+        k0: float = 2.37,
+        ks: float = 0.027,
+        kd: float = 0.004,
+    ):
+        super().__init__()
+
+        self.name = "AA"
+
+        self.arw_d_out = 5.0e-3
+        self.arw_d_in: float = 9.3e-3
+
+        # AA Uses an ascending scale with round. All numbers typically in [-250, 175]
+        self.desc_scale = False
+        self.scale_bounds = [-250, 175]
+        self.max_score_rounding_lim = 0.5
+
+        self.params = {
+            "ang_0": ang_0,  # Baseline angle used for group size 1.0 [millirad].
+            "k0": k0,  # Offset required to set handicap 100 at desired score.
+            "ks": ks,  # Change with each step of geometric progression.
+            "kd": kd,  # Distance scaling factor [1/metres].
+        }
+
+    def sigma_t(self, handicap: FloatArray, dist: float) -> FloatArray:
+        """Calculate angular deviation for given handicap and distance.
+
+        Parameters
+        ----------
+        handicap : FloatArray
+            handicap to calculate sigma_t at
+        dist : float
+            distance to target [metres]
+
+        Returns
+        -------
+        sig_t : FloatArray
+            angular deviation [rad]
+
+        Notes
+        -----
+        This is the key part of this scheme.
+        The values are taken from Park (2014) [2]_.
+
+        References
+        ----------
+        .. [2] Park, J. L. (2014). "Modelling archers’ scores at different distances
+           to quantify score loss due to equipment selection and technique errors."
+           Proceedings of the Institution of Mechanical Engineers,
+           Part P: Journal of Sports Engineering and Technology, 228(4), 250-258.
+           DOI: 10.1177/175433711453930
+
+        Examples
+        --------
+        Angular deviation at a distance of 25m, using the AGBold handicap system at a
+        handicap of 10 can be calculated with:
+
+        >>> import archeryutils.handicaps as hc
+        >>> aa_scheme = hc.handicap_scheme("AA")
+        >>> aa_scheme.sigma_t(10.0, 25.0)
+        0.012763296491500004
+
+        It can also be passed an array of handicaps:
+
+        >>> import numpy as np
+        >>> aa_scheme.sigma_t(np.array([10.0, 50.0, 100.0]), 25.0)
+        array([0.0127633 , 0.00433436, 0.00112364])
+
+        """
+        # Factor of sqrt(2) to deal with factor of 2 in differing definitions of sigma
+        # between AGB and AA
+        # Required so code elsewhere is unchanged
+        # Factor of 1.0e-3 due to AA algorithm specifying sigma t in milliradians, so
+        # convert to rad
+        return (
+            np.sqrt(2.0)
+            * self.params["ang_0"]
+            * np.exp(
+                self.params["k0"]
+                - self.params["ks"] * handicap
+                + self.params["kd"] * dist
+            )
+        )
+
+
+class HandicapAA2(HandicapScheme):
+    """
+    Class to represent the updated (2014) Archery Australia scheme by J. Park.
+
+    Parameters
+    ----------
+    ang_0 : float
+        datum angle used in scheme, default = 1.0e-3,
+    k0 : float
+        handicap offset to set scratch point, default= 2.57,
+    ks : float
+        change with each handicap step as a geometric progression, default= 0.027,
+    f1 : float
+        'linear' scaling factor, default= 0.815,
+    f2 : float
+        'quadratic' scaling factor, default= 0.185,
+    d0 : float
+        normalisation distance, default = 50.0,
+
+    Attributes
+    ----------
+    name : str="AA2"
+        The name of the handicap scheme.
+    arw_d_out : float
+        diameter of an outdoor arrow: 5.0e-3 [metres]
+    arw_d_in: float
+        diameter of an indoor arrow 9.3e-3 [metres]
+
+    Warnings
+    --------
+    Using non-default values for the kwargs may produce results that
+    do not match the official values for this scheme.
+    Only change if you are experimenting and know what you are doing!
+
+    See Also
+    --------
+    HandicapScheme : The base class for a handicap scheme from which this is subclassed
+                     containing details of additional methods.
+
+    """
+
+    def __init__(
+        self,
+        ang_0: float = 1.0e-3,
+        k0: float = 2.57,
+        ks: float = 0.027,
+        f1: float = 0.815,
+        f2: float = 0.185,
+        d0: float = 50.0,
+    ):
+        # two too many arguments, but all are hc-scheme params => disable
+        # pylint: disable=too-many-arguments
+
+        super().__init__()
+
+        self.name = "AA2"
+
+        self.arw_d_out = 5.0e-3
+        self.arw_d_in = 9.3e-3
+
+        # AA Uses an ascending scale with round. All numbers typically in [-250, 175]
+        self.desc_scale = False
+        self.scale_bounds = [-250, 175]
+        self.max_score_rounding_lim = 0.5
+
+        self.params = {
+            "ang_0": ang_0,
+            "k0": k0,  # Offset required to set handicap 100 at desired score.
+            "ks": ks,  # Change with each step of geometric progression.
+            "f1": f1,  # 'Linear' scaling factor.
+            "f2": f2,  # 'Quadratic' scaling factor.
+            "d0": d0,  # Normalisation distance [metres].
+        }
+
+    def sigma_t(self, handicap: FloatArray, dist: float) -> FloatArray:
+        """Calculate angular deviation for given handicap and distance.
+
+        Parameters
+        ----------
+        handicap : FloatArray
+            handicap to calculate sigma_t at
+        dist : float
+            distance to target [metres]
+
+        Returns
+        -------
+        sig_t : FloatArray
+            angular deviation [rad]
+
+        Notes
+        -----
+        This is the key part of this scheme.
+        The values are taken from Park (2014) [3]_.
+
+        References
+        ----------
+        .. [3] Park, J. L. (2014). "Modelling archers’ scores at different distances
+           to quantify score loss due to equipment selection and technique errors."
+           Proceedings of the Institution of Mechanical Engineers,
+           Part P: Journal of Sports Engineering and Technology, 228(4), 250-258.
+           DOI: 10.1177/175433711453930
+
+        Examples
+        --------
+        Angular deviation at a distance of 25m, using the AGBold handicap system at a
+        handicap of 10 can be calculated with:
+
+        >>> import archeryutils.handicaps as hc
+        >>> aa2_scheme = hc.handicap_scheme("AA2")
+        >>> aa2_scheme.sigma_t(10.0, 25.0)
+        0.012800853871823342
+
+        It can also be passed an array of handicaps:
+
+        >>> import numpy as np
+        >>> aa2_scheme.sigma_t(np.array([10.0, 50.0, 100.0]), 25.0)
+        array([0.01280085, 0.00434711, 0.00112695])
+
+        """
+        # Factor of sqrt(2) to deal with factor of 2 in differing definitions of sigma
+        # between AGB and AA
+        # Required so code elsewhere is unchanged
+        # Factor of 1.0e-3 due to AA algorithm specifying sigma t in milliradians, so
+        # convert to rad
+        return (
+            np.sqrt(2.0)
+            * self.params["ang_0"]
+            * np.exp(self.params["k0"] - self.params["ks"] * handicap)
+            * (self.params["f1"] + self.params["f2"] * dist / self.params["d0"])
+        )
diff --git a/archeryutils/handicaps/handicap_scheme_agb.py b/archeryutils/handicaps/handicap_scheme_agb.py
new file mode 100644
index 0000000..3793d7e
--- /dev/null
+++ b/archeryutils/handicaps/handicap_scheme_agb.py
@@ -0,0 +1,267 @@
+"""Code for handicap calculations using Archery GB handicap schemes.
+
+Extended Summary
+----------------
+Code to calculate information using a number of schemes:
+
+- New Archery GB (J Atkinson, 2023)
+- Old Archery GB (D Lane)
+
+Routine Listings
+----------------
+- HandicapAGB Class
+- HandicapAGBold Class
+
+References
+----------
+- New AGB: Atkinson, J
+- Old AGB: The construction of the graduated handicap tables for target
+  archery
+  Lane, D (2013)
+  https://www.jackatkinson.net/files/Handicap_Tables_2013.pdf
+
+"""
+
+import numpy as np
+
+from .handicap_scheme import FloatArray, HandicapScheme
+
+
+class HandicapAGB(HandicapScheme):
+    """
+    Class to represent the Archery GB handicap scheme.
+
+    This class represents the 2023 handicap scheme by Jack Atkinson.
+
+    Parameters
+    ----------
+    datum : float
+        offset to set scratch point, default = 6.0,
+    step : float
+        percentage change in deviation per handicap step, default = 3.5,
+    ang_0 : float
+        datum angle used in scheme, default = 5.0e-4,
+    kd : float
+        factor controlling excess dispersion scaling with distance, default = 0.00365,
+
+    Attributes
+    ----------
+    name : str="AGB"
+        The name of the handicap scheme.
+    arw_d_out : float
+        diameter of an outdoor arrow: 5.5e-3 [metres]
+    arw_d_in: float
+        diameter of an indoor arrow 9.3e-3 [metres]
+
+    Warnings
+    --------
+    Using non-default values for the kwargs may produce results that
+    do not match the official values for this scheme.
+    Only change if you are experimenting and know what you are doing!
+
+    See Also
+    --------
+    HandicapScheme : The base class for a handicap scheme from which this is subclassed
+                     containing details of additional methods.
+
+    """
+
+    def __init__(
+        self,
+        datum: float = 6.0,
+        step: float = 3.5,
+        ang_0: float = 5.0e-4,
+        kd: float = 0.00365,
+    ):
+        super().__init__()
+
+        self.name = "AGB"
+
+        self.arw_d_out: float = 5.5e-3
+        self.arw_d_in: float = 9.3e-3
+
+        # AGB Uses a descending scale with ceil. All numbers typically in [-75, 300]
+        self.desc_scale: bool = True
+        self.scale_bounds: list[float] = [-75, 300]
+        self.max_score_rounding_lim: float = 1.0
+
+        self.params = {
+            "datum": datum,
+            "step": step,
+            "ang_0": ang_0,
+            "kd": kd,
+        }
+
+    def sigma_t(self, handicap: FloatArray, dist: float) -> FloatArray:
+        """Calculate angular deviation for given handicap and distance.
+
+        Parameters
+        ----------
+        handicap : FloatArray
+            handicap to calculate sigma_t at
+        dist : float
+            distance to target [metres]
+
+        Returns
+        -------
+        sig_t : FloatArray
+            angular deviation [rad]
+
+        Examples
+        --------
+        Angular deviation at a distance of 25m, using the AGB handicap system at a
+        handicap of 10 can be calculated with:
+
+        >>> import archeryutils.handicaps as hc
+        >>> agb_scheme = hc.handicap_scheme("AGB")
+        >>> agb_scheme.sigma_t(10.0, 25.0)
+        0.0009498280098103058
+
+        It can also be passed an array of handicaps:
+
+        >>> import numpy as np
+        >>> agb_scheme.sigma_t(np.array([10.0, 50.0, 100.0]), 25.0)
+        array([0.00094983, 0.00376062, 0.02100276])
+
+        """
+        return (
+            self.params["ang_0"]
+            * ((1.0 + self.params["step"] / 100.0) ** (handicap + self.params["datum"]))
+            * np.exp(self.params["kd"] * dist)
+        )
+
+    # Override rounding method for AGB to always round up to next highest score.
+    @staticmethod
+    def _rounded_score(score):
+        return np.ceil(score)
+
+
+class HandicapAGBold(HandicapScheme):
+    """
+    Class to represent the old (pre-2023) Archery GB handicap scheme by D. Lane.
+
+    Parameters
+    ----------
+    datum : float
+        offset to set scratch point, default = 12.9,
+    step : float
+        percentage change in deviation per handicap step, default = 3.6,
+    ang_0 : float
+        datum angle used in scheme, default = 5.0e-4,
+    k1 : float
+        constant k1 in the handicap equation, default = 1.429e-6,
+    k2 : float
+        constant k2 in the handicap equation, default = 1.07,
+    k3 : float
+        constant k3 in the handicap equation, default = 4.3,
+    p1 : float
+        exponent of distance scaling, default = 2.0,
+
+    Attributes
+    ----------
+    name : str="AGBold"
+        The name of the handicap scheme.
+    arw_d_out : float
+        diameter of an outdoor arrow: 7.14e-3 [metres]
+    arw_d_in: float
+        diameter of an indoor arrow 7.14e-3 [metres]
+
+    Warnings
+    --------
+    Using non-default values for the kwargs may produce results that
+    do not match the official values for this scheme.
+    Only change if you are experimenting and know what you are doing!
+
+    See Also
+    --------
+    HandicapScheme : The base class for a handicap scheme from which this is subclassed
+                     containing details of additional methods.
+
+    """
+
+    def __init__(
+        self,
+        datum: float = 12.9,
+        step: float = 3.6,
+        ang_0: float = 5.0e-4,
+        k1: float = 1.429e-6,
+        k2: float = 1.07,
+        k3: float = 4.3,
+        p1: float = 2.0,
+    ):
+        # three too many arguments, but all are hc-scheme params => disable
+        # pylint: disable=too-many-arguments
+
+        super().__init__()
+
+        self.name = "AGBold"
+
+        self.arw_d_out = 7.14e-3
+        self.arw_d_in = 7.14e-3
+
+        # AGBold Uses a descending scale with round. All numbers typically in [-75, 300]
+        self.desc_scale = True
+        self.scale_bounds = [-75, 300]
+        self.max_score_rounding_lim = 0.5
+
+        self.params = {
+            "datum": datum,  # Offset required to set handicap 0 at desired score.
+            "step": step,  # Percentage change in group size for each handicap step.
+            "ang_0": ang_0,  # Baseline angle used for group size 0.5 [millirad].
+            "k1": k1,  # Constant 1 used in handicap equation.
+            "k2": k2,  # Constant 2 used in handicap equation.
+            "k3": k3,  # Constant 3 used in handicap equation.
+            "p1": p1,  # Exponent of distance scaling.
+        }
+
+    def sigma_t(self, handicap: FloatArray, dist: float) -> FloatArray:
+        """Calculate angular deviation for given handicap and distance.
+
+        Parameters
+        ----------
+        handicap : FloatArray
+            handicap to calculate sigma_t at
+        dist : float
+            distance to target [metres]
+
+        Returns
+        -------
+        sig_t : FloatArray
+            angular deviation [rad]
+
+        Notes
+        -----
+        This is the key part of this scheme.
+        The values are taken from Lane (2013) [1]_.
+
+        References
+        ----------
+        .. [1] Lane, D. (2013). "The Construction of the Graduated Handicap Tables
+           for Target Archery."
+
+        Examples
+        --------
+        Angular deviation at a distance of 25m, using the AGBold handicap system at a
+        handicap of 10 can be calculated with:
+
+        >>> import archeryutils.handicaps as hc
+        >>> agbold_scheme = hc.handicap_scheme("AGBold")
+        >>> agbold_scheme.sigma_t(10.0, 25.0)
+        0.001126491382794861
+
+        It can also be passed an array of handicaps:
+
+        >>> import numpy as np
+        >>> agbold_scheme.sigma_t(np.array([10.0, 50.0, 100.0]), 25.0)
+        array([0.00112649, 0.00478762, 0.05520862])
+
+        """
+        k_factor = self.params["k1"] * self.params["k2"] ** (
+            handicap + self.params["k3"]
+        )
+        f_factor = 1.0 + k_factor * dist ** self.params["p1"]
+        return (
+            self.params["ang_0"]
+            * ((1.0 + self.params["step"] / 100.0) ** (handicap + self.params["datum"]))
+            * f_factor
+        )
diff --git a/archeryutils/handicaps/handicap_tables.py b/archeryutils/handicaps/handicap_tables.py
new file mode 100644
index 0000000..e3dd992
--- /dev/null
+++ b/archeryutils/handicaps/handicap_tables.py
@@ -0,0 +1,382 @@
+"""
+Code providing the HandicapTable class for generating and manipulating handicap tables.
+
+Makes use of the basic handicap equations in .handicap_equations.
+
+"""
+
+import decimal
+import warnings
+from itertools import chain
+from typing import Optional, Union
+
+import numpy as np
+from numpy.typing import NDArray
+
+import archeryutils.handicaps.handicap_functions as hc
+from archeryutils import rounds
+
+from .handicap_scheme import FloatArray, HandicapScheme
+
+_FILL = -9999
+
+
+class HandicapTable:
+    r"""
+    Class to generate and store a handicap table.
+
+    Parameters
+    ----------
+    handicap_sys : Union[str, HandicapScheme]
+        identifier for the handicap system to use
+    hcs : Union[FloatArray, NDArray[np.int\\_]]
+        handicap values to calculate scores for
+    round_list : list[rounds.Round]
+        list of Round classes to show in the handicap table
+    rounded_scores : bool, default=True
+        round scores to integer value?
+    int_prec : bool, default=True
+        display numbers in table as integers rather than decimals to improve appearance
+    clean_gaps : bool, default=True
+        clean out gaps of repeated scores (using only first occurrence)
+        gaps will be filled with -9999 (int_prec=True) or np.nan (int_prec=False)
+    arrow_d : float or None, default=None
+        user-specified arrow diameter in [metres]
+
+
+    Attributes
+    ----------
+    hc_sys : HandicapScheme
+        HandicapScheme class to be used in constructing this table
+    round_list : list[rounds.Round]
+        list of Round classes to show in the handicap table
+    rounded_scores : bool
+        round scores to integer value?
+    int_prec : bool
+        display numbers in table as integers rather than decimals to improve appearance
+    clean_gaps : bool
+        clean out gaps of repeated scores (using only first occurrence)
+    hcs : NDArray[np.float\\_]
+        handicap values to calculate scores for
+    table: NDArray[Union[np.float\\_, np.int\\_]]
+        the generated handicap table containing appropriate score values
+
+    """
+
+    def __init__(
+        self,
+        handicap_sys: Union[str, HandicapScheme],
+        hcs: Union[FloatArray, NDArray[np.int_]],
+        round_list: list[rounds.Round],
+        rounded_scores: bool = True,
+        int_prec: bool = True,
+        clean_gaps: bool = True,
+        arrow_d: Optional[float] = None,
+    ):
+        # three too many arguments, but all are used for table => disable
+        # pylint: disable=too-many-arguments
+
+        self.hc_sys = hc.handicap_scheme(handicap_sys)
+        self.round_list = round_list
+        self.rounded_scores = rounded_scores
+        self.int_prec = int_prec
+        self.clean_gaps = clean_gaps
+
+        # Sanitise inputs
+        self.hcs = self._check_print_table_inputs(hcs)
+
+        # Set up empty handicap table and populate
+        self.table: NDArray[Union[np.float_, np.int_]] = np.empty(
+            [len(self.hcs), len(self.round_list) + 1]
+        )
+        self.table[:, 0] = self.hcs[:]
+        # Assign values to table
+        for i, round_i in enumerate(round_list):
+            self.table[:, i + 1] = self.hc_sys.score_for_round(
+                self.hcs,
+                round_i,
+                arrow_d,
+                rounded_score=self.rounded_scores,
+            )
+
+        # If rounding scores up we don't want to display trailing zeros, so ensure int_prec
+        if self.rounded_scores and not self.int_prec:
+            warnings.warn(
+                "Handicap Table incompatible options.\n"
+                "Requesting scores to be rounded up but without integer precision.\n"
+                "Setting integer precision (`int_prec`) as true.",
+                UserWarning,
+            )
+            self.int_prec = True
+
+        if self.int_prec:
+            self.table[:, 1:] = self.table[:, 1:].astype(int)
+
+        if self.clean_gaps:
+            self._clean_repeated()
+            self.hcs = self.hcs[1:-1]
+            self.table = self.table[1:-1, :]
+
+    def __repr__(self) -> str:
+        """Return a representation of a HandicapTable instance."""
+        return f"<HandicapTable: '{self.hc_sys.name}'>"
+
+    def __str__(
+        self,
+    ) -> str:
+        """
+        Format the handicap table as a string.
+
+        Returns
+        -------
+        output_str : str
+            a string representation of the Handicap table for printing/saving
+
+        """
+        # To ensure both terminal and file output are the same, create a single string
+        round_names = [self._abbreviate(r.name) for r in self.round_list]
+        output_header = "".join(
+            name.rjust(14) for name in chain(["Handicap"], round_names)
+        )
+        # Auto-set the number of decimal places to display handicaps to
+        if np.max(self.hcs % 1.0) <= 0.0:
+            hc_dp = 0
+        else:
+            hc_dp = np.max(
+                np.abs([decimal.Decimal(str(d)).as_tuple().exponent for d in self.hcs])
+            )
+        # Format each row appropriately
+        output_rows = [
+            self._format_row(row, hc_dp, self.int_prec) for row in self.table
+        ]
+        output_str = "\n".join(chain([output_header], output_rows))
+
+        return output_str
+
+    def print(self) -> None:
+        """
+        Print handicap table to screen/stdout.
+
+        Examples
+        --------
+        >>> import archeryutils as au
+        >>> wa_outdoor = au.load_rounds.WA_outdoor
+        >>> from archeryutils import handicaps as hc
+        >>> agb_table = hc.HandicapTable(
+        ...     "AGB",
+        ...     [1.0, 2.0, 3.0, 4.0, 5.0],
+        ...     [wa_outdoor.wa1440_90, wa_outdoor.wa1440_70, wa_outdoor.wa1440_60],
+        ... )
+        >>> agb_table.print()
+              Handicap WA 1440 (90m) WA 1440 (70m) WA 1440 (60m)
+                     1          1396          1412          1427
+                     2          1393          1409          1425
+                     3          1389          1406          1423
+                     4          1385          1403          1420
+                     5          1380          1399          1418
+
+        """
+        print(self)
+
+    def to_csv(
+        self,
+        csvfile: str,
+    ) -> None:
+        """
+        Save handicap table as a csv file.
+
+        Parameters
+        ----------
+        csvfile : str
+            csv filepath to save to.
+
+        Examples
+        --------
+        >>> import archeryutils as au
+        >>> wa_outdoor = au.load_rounds.WA_outdoor
+        >>> from archeryutils import handicaps as hc
+        >>> agb_table = hc.HandicapTable(
+        ...     "AGB",
+        ...     [1.0, 2.0, 3.0, 4.0, 5.0],
+        ...     [wa_outdoor.wa1440_90, wa_outdoor.wa1440_70, wa_outdoor.wa1440_60],
+        ... )
+        >>> agb_table.to_csv("mycsvfile.csv")
+        Writing handicap table to csv...Done.
+
+        """
+        print("Writing handicap table to csv...", end="")
+        np.savetxt(
+            csvfile,
+            self.table,
+            delimiter=", ",
+            header=f"handicap, {','.join([round_i.name for round_i in self.round_list])}'",
+        )
+        print("Done.")
+
+    def to_file(self, filename: str) -> None:
+        """
+        Save handicap table to text file.
+
+        Parameters
+        ----------
+        filename : str
+            filepath to save table to.
+
+        Examples
+        --------
+        >>> import archeryutils as au
+        >>> wa_outdoor = au.load_rounds.WA_outdoor
+        >>> from archeryutils import handicaps as hc
+        >>> agb_table = hc.HandicapTable(
+        ...     "AGB",
+        ...     [1.0, 2.0, 3.0, 4.0, 5.0],
+        ...     [wa_outdoor.wa1440_90, wa_outdoor.wa1440_70, wa_outdoor.wa1440_60],
+        ... )
+        >>> agb_table.to_file("mytextfile.txt")
+        Writing handicap table to file...Done.
+
+        """
+        print("Writing handicap table to file...", end="")
+        # Generate string to output to file or display
+        with open(filename, "w", encoding="utf-8") as table_file:
+            table_file.write(str(self))
+        print("Done.")
+
+    def _check_print_table_inputs(
+        self,
+        hcs_in: Union[FloatArray, NDArray[np.int_]],
+    ) -> NDArray[np.float_]:
+        """
+        Sanitise and format inputs to handicap printing code.
+
+        Parameters
+        ----------
+        hcs_in : float or ndarray
+            handicap value(s) to calculate score(s) for
+
+        Returns
+        -------
+        hcs : ndarray
+            handicaps prepared for use in table printing routines
+
+        Raises
+        ------
+        TypeError
+            If handicaps not provided as float or ndarray
+        ValueError
+            If no rounds are provided for the handicap table
+
+        """
+        if not isinstance(hcs_in, np.ndarray):
+            if isinstance(hcs_in, list):
+                hcs_in = np.array(hcs_in)
+            elif isinstance(hcs_in, (float, int)):
+                hcs_in = np.array([hcs_in])
+            else:
+                raise TypeError("Expected float or ndarray for hcs.")
+        hcs = hcs_in.astype(float)
+
+        if len(self.round_list) == 0:
+            raise ValueError("No rounds provided for handicap table.")
+
+        # if cleaning gaps add row to top/bottom of table to catch out of range repeats
+        if self.clean_gaps:
+            delta_s = hcs[1] - hcs[0] if len(hcs) > 1 else 1.0
+            delta_e = hcs[-1] - hcs[-2] if len(hcs) > 1 else 1.0
+            hcs = np.insert(hcs, 0, np.array([hcs[0] - delta_s]))
+            hcs = np.append(hcs, [hcs[-1] + delta_e])
+
+        return hcs
+
+    def _clean_repeated(
+        self,
+    ) -> None:
+        """Keep only the first instance of a score in the handicap tables."""
+        # NB: This assumes scores are running highest to lowest.
+        # :. Flip schemes with a descending scale (AA and AA2) tables before operating.
+        if not self.hc_sys.desc_scale:
+            self.table = np.flip(self.table, axis=0)
+
+        for irow, row in enumerate(self.table[:-1, :]):
+            for jscore in range(len(row)):
+                if self.table[irow, jscore] == self.table[irow + 1, jscore]:
+                    if self.int_prec:
+                        self.table[irow, jscore] = _FILL
+                    else:
+                        self.table[irow, jscore] = np.nan
+
+        # Undo the initial reversal if required
+        if not self.hc_sys.desc_scale:
+            self.table = np.flip(self.table, axis=0)
+
+    @staticmethod
+    def _abbreviate(name: str) -> str:
+        """
+        Generate headings using abbreviations to keep table output concise.
+
+        Parameters
+        ----------
+        name : str
+            full, long round name as appears currently
+
+        Returns
+        -------
+        str
+            abbreviated round name to replace with
+
+        Warnings
+        --------
+        This function only works with names containing space-separated words.
+
+        """
+        abbreviations = {
+            "Compound": "C",
+            "Recurve": "R",
+            "Triple": "Tr",
+            "Centre": "C",
+            "Portsmouth": "Ports",
+            "Worcester": "Worc",
+            "Short": "St",
+            "Long": "Lg",
+            "Small": "Sm",
+            "Gents": "G",
+            "Ladies": "L",
+        }
+
+        return " ".join(abbreviations.get(i, i) for i in name.split())
+
+    @staticmethod
+    def _format_row(
+        row: NDArray[Union[np.float_, np.int_]],
+        hc_dp: int = 0,
+        int_prec: bool = False,
+    ) -> str:
+        """
+        Fornat appearance of handicap table row to look nice.
+
+        Parameters
+        ----------
+        row : NDArray
+            numpy array of data for one table row
+        hc_dp : int, default=0
+            handicap decimal places
+        int_prec : bool, default=False
+            return integers, not floats?
+
+        Returns
+        -------
+        str
+            pretty string based on input array data
+        """
+        if hc_dp == 0:
+            handicap_str = f"{int(row[0]):14d}"
+        else:
+            handicap_str = f"{row[0]:14.{hc_dp}f}"
+
+        if int_prec:
+            return handicap_str + "".join(
+                "".rjust(14) if x == _FILL else f"{int(x):14d}" for x in row[1:]
+            )
+        return handicap_str + "".join(
+            "".rjust(14) if np.isnan(x) else f"{x:14.8f}" for x in row[1:]
+        )
diff --git a/archeryutils/rounds.py b/archeryutils/rounds.py
index 54d8dd3..4f0d442 100644
--- a/archeryutils/rounds.py
+++ b/archeryutils/rounds.py
@@ -221,7 +221,7 @@ def __init__(
 
     def __repr__(self) -> str:
         """Return a representation of a Round instance."""
-        return f"Round('{self.name}')"
+        return f"<Round: '{self.name}'>"
 
     def __eq__(self, other: object) -> bool:
         """Check equality of Rounds based on name and passes.
diff --git a/docs/api/archeryutils.baseclasses.rst b/docs/api/archeryutils.baseclasses.rst
new file mode 100644
index 0000000..d47a545
--- /dev/null
+++ b/docs/api/archeryutils.baseclasses.rst
@@ -0,0 +1,18 @@
+Base Classes
+============
+
+.. autoclass:: archeryutils.Target
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+.. autoclass:: archeryutils.Pass
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+.. autoclass:: archeryutils.Round
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
diff --git a/docs/api/archeryutils.classifications.rst b/docs/api/archeryutils.classifications.rst
index 2fdfc9f..ab72e44 100644
--- a/docs/api/archeryutils.classifications.rst
+++ b/docs/api/archeryutils.classifications.rst
@@ -1,21 +1,7 @@
-archeryutils.classifications package
-====================================
-
-Module contents
----------------
+Classifications
+===============
 
 .. automodule:: archeryutils.classifications
    :members:
    :undoc-members:
    :show-inheritance:
-
-Submodules
-----------
-
-archeryutils.classifications.classification\_utils module
----------------------------------------------------------
-
-.. automodule:: archeryutils.classifications.classification_utils
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/api/archeryutils.handicaps.rst b/docs/api/archeryutils.handicaps.rst
index cf6d037..2498493 100644
--- a/docs/api/archeryutils.handicaps.rst
+++ b/docs/api/archeryutils.handicaps.rst
@@ -1,29 +1,53 @@
-archeryutils.handicaps package
-==============================
+Handicaps
+=========
 
-Module contents
----------------
+Top level functions
+-------------------
 
 .. automodule:: archeryutils.handicaps
+   :members: arrow_score, score_for_passes, score_for_round, handicap_from_score, handicap_scheme
+   :undoc-members:
+   :show-inheritance:
+   :member-order: groupwise
+
+HandicapTable Class
+-------------------
+
+.. autoclass:: archeryutils.handicaps.HandicapTable
    :members:
    :undoc-members:
    :show-inheritance:
+   :member-order: groupwise
 
-Submodules
-----------
+HandicapScheme Classes
+----------------------
+
+.. autoclass:: archeryutils.handicaps.HandicapScheme
+   :members:
+   :undoc-members:
+   :show-inheritance:
+   :member-order: groupwise
 
-archeryutils.handicaps.handicap\_equations module
--------------------------------------------------
+.. autoclass:: archeryutils.handicaps.HandicapAGB
+   :members:
+   :undoc-members:
+   :show-inheritance:
+   :member-order: groupwise
 
-.. automodule:: archeryutils.handicaps.handicap_equations
+.. autoclass:: archeryutils.handicaps.HandicapAGBold
    :members:
    :undoc-members:
    :show-inheritance:
+   :member-order: groupwise
 
-archeryutils.handicaps.handicap\_functions module
--------------------------------------------------
+.. autoclass:: archeryutils.handicaps.HandicapAA
+   :members:
+   :undoc-members:
+   :show-inheritance:
+   :member-order: groupwise
 
-.. automodule:: archeryutils.handicaps.handicap_functions
+.. autoclass:: archeryutils.handicaps.HandicapAA2
    :members:
    :undoc-members:
    :show-inheritance:
+   :member-order: groupwise
diff --git a/docs/api/archeryutils.preloaded_rounds.rst b/docs/api/archeryutils.preloaded_rounds.rst
new file mode 100644
index 0000000..0a6909d
--- /dev/null
+++ b/docs/api/archeryutils.preloaded_rounds.rst
@@ -0,0 +1,117 @@
+Preloaded Rounds
+================
+
+*archeryutils* comes with a number of rounds pre-loaded through the ``load_rounds``
+module.
+These are stored in a series of dictionaries and can be accessed using the python
+dot-notation.
+
+For example, to load all WA Outdoor rounds and then extract the WA1440 (90m) we can do:
+
+.. ipython:: python
+
+    import archeryutils as au
+    wa_outdoor_rounds = au.load_rounds.WA_outdoor
+    my_wa1440_90 = wa_outdoor_rounds.wa1440_90
+
+    print(my_wa1440_90)
+
+    my_wa1440_90.get_info()
+
+Or, to load the round directly:
+
+.. ipython:: python
+
+    import archeryutils as au
+    au.load_rounds.WA_outdoor.wa1440_90
+
+See below for a list of all available round dictionaries, and the rounds they contain.
+The keys of the dictionary should be used for dot-notation access.
+
+WA Outdoor
+----------
+
+.. ipython:: python
+
+    import archeryutils as au
+    au.load_rounds.AGB_outdoor_imperial
+
+AGB Outdoor Imperial
+--------------------
+
+.. ipython:: python
+
+    import archeryutils as au
+    au.load_rounds.WA_outdoor
+
+AGB Outdoor Metric
+--------------------
+
+.. ipython:: python
+
+    import archeryutils as au
+    au.load_rounds.AGB_outdoor_metric
+
+AGB Outdoor Imperial
+--------------------
+
+.. ipython:: python
+
+    import archeryutils as au
+    au.load_rounds.AGB_outdoor_imperial
+
+WA Indoor
+---------
+
+.. ipython:: python
+
+    import archeryutils as au
+    au.load_rounds.WA_indoor
+
+AGB Indoor
+----------
+
+.. ipython:: python
+
+    import archeryutils as au
+    au.load_rounds.AGB_indoor
+
+WA Field
+--------
+
+.. ipython:: python
+
+    import archeryutils as au
+    au.load_rounds.WA_field
+
+IFAA Field
+----------
+
+.. ipython:: python
+
+    import archeryutils as au
+    au.load_rounds.IFAA_field
+
+WA VI
+-----
+
+.. ipython:: python
+
+    import archeryutils as au
+    au.load_rounds.WA_VI
+
+AGB VI
+------
+
+.. ipython:: python
+
+    import archeryutils as au
+    au.load_rounds.AGB_VI
+
+Custom Rounds
+-------------
+
+.. ipython:: python
+
+    import archeryutils as au
+    au.load_rounds.custom
diff --git a/docs/api/index.rst b/docs/api/index.rst
index 60f2e3d..a146f29 100644
--- a/docs/api/index.rst
+++ b/docs/api/index.rst
@@ -3,48 +3,23 @@
 API Documentation
 =================
 
-Base Classes
-------------
-
-.. autoclass:: archeryutils.Target
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-.. autoclass:: archeryutils.Pass
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-.. autoclass:: archeryutils.Round
-   :members:
-   :undoc-members:
-   :show-inheritance:
+.. toctree::
+   :maxdepth: 2
 
-Subpackages
------------
+   archeryutils.baseclasses
 
 .. toctree::
-   :maxdepth: 2
+   :maxdepth: 3
 
-   archeryutils.classifications
    archeryutils.handicaps
 
-Submodules
-----------
+.. toctree::
+   :maxdepth: 3
 
-archeryutils.constants module
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+   archeryutils.classifications
 
-.. automodule:: archeryutils.constants
-   :members:
-   :undoc-members:
-   :show-inheritance:
+.. toctree::
+   :maxdepth: 2
 
-archeryutils.load\_rounds module
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+   archeryutils.preloaded_rounds
 
-.. automodule:: archeryutils.load_rounds
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/getting-started/quickstart.rst b/docs/getting-started/quickstart.rst
index 3652d8c..e7b11c0 100644
--- a/docs/getting-started/quickstart.rst
+++ b/docs/getting-started/quickstart.rst
@@ -153,29 +153,24 @@ Archery GB (Atkinson (2023), Lane (1978)) and Archery Australia (Park (2014)).
 
 .. ipython:: python
 
-    from archeryutils import handicap_equations as hc_eq
-    from archeryutils import handicap_functions as hc_func
-
-    hcparams = hc_eq.HcParams()
+    from archeryutils import handicaps as hc
 
 Given a handicap and a round we can calculate the score that would be achieved:
 
 .. ipython:: python
 
-    score_from_hc = hc_eq.score_for_round(
-        agb_outdoor.york,
+    score_from_hc = hc.score_for_round(
         38,
+        agb_outdoor.york,
         "AGB",
-        hcparams,
     )
 
     print(f"A handicap of 38 on a York is a score of {score_from_hc}.")
 
-    pass_scores = hc_eq.score_for_passes(
-        agb_outdoor.york,
+    pass_scores = hc.score_for_passes(
         38,
+        agb_outdoor.york,
         "AGB",
-        hcparams,
     )
 
     print(f"A handicap of 38 on a York gives pass scores of {pass_scores}.")
@@ -185,24 +180,22 @@ to a handicap:
 
 .. ipython:: python
 
-    hc_from_score = hc_func.handicap_from_score(
+    hc_from_score = hc.handicap_from_score(
         950,
         agb_outdoor.york,
         "AGB",
-        hcparams,
     )
     print(f"A score of 950 on a York is a continuous handicap of {hc_from_score}.")
 
-    hc_from_score = hc_func.handicap_from_score(
+    hc_from_score = hc.handicap_from_score(
         950,
         agb_outdoor.york,
         "AGB",
-        hcparams,
         int_prec=True,
     )
     print(f"A score of 950 on a York is a discrete handicap of {hc_from_score}.")
 
-There are also inbuilt methods for generating handicap tables:
+There is also the HandicapTable class for generating handicap tables:
 
 .. ipython:: python
 
@@ -213,15 +206,16 @@ There are also inbuilt methods for generating handicap tables:
         agb_outdoor.st_george,
         agb_outdoor.albion,
     ]
-    # The following allows printing of handicap tables for an entire group of rounds:
+    # The following would allow printing of handicap tables for an entire group of rounds:
     # rounds = list(load_rounds.AGB_outdoor_imperial.values())
 
-    hc_func.print_handicap_table(
-        handicaps,
+    my_agb_table = hc.HandicapTable(
         "AGB",
+        handicaps,
         rounds,
-        hcparams,
     )
+    my_agb_table.print()
+
 
 Classifications
 ---------------
@@ -242,8 +236,8 @@ Given a score we can calculate the classification it achieves:
 
     # AGB Outdoor
     class_from_score = class_func.calculate_agb_outdoor_classification(
-        "hereford",
         965,
+        "hereford",
         "recurve",
         "male",
         "50+",
@@ -254,8 +248,8 @@ Given a score we can calculate the classification it achieves:
 
     # AGB Indoor
     class_from_score = class_func.calculate_agb_indoor_classification(
-        "wa18",
         562,
+        "wa18",
         "compound",
         "female",
         "adult",
@@ -266,8 +260,8 @@ Given a score we can calculate the classification it achieves:
 
     # AGB Field
     class_from_score = class_func.calculate_agb_field_classification(
-        "wa_field_24_blue_unmarked",
         168,
+        "wa_field_24_blue_unmarked",
         "traditional",
         "male",
         "under 18",
diff --git a/examples.ipynb b/examples.ipynb
index 7f83a69..a6233c4 100644
--- a/examples.ipynb
+++ b/examples.ipynb
@@ -338,10 +338,7 @@
     "## 2. Handicap Schemes\n",
     "_archeryutils_ provides functionality for calculating various [handicaps/skill ratings](https://jackatkinson.net/post/archery_handicap/) from scores. These include both the popular Archery GB and Archery Australia schemes.\n",
     "\n",
-    "To use these functionalities import the handicap equations and functions modules as below.\n",
-    "\n",
-    "It will also help to instantiate a `HcParams` object that contains key parameters for the handicap schemes.\n",
-    "This comes as a preloaded dataclass, though the values of the variables can be changed or loaded from a `.json` file. For an explanation of the different values consult the [class definition in `archeryutils/handicaps/handicap_equations.py`](https://github.com/jatkinson1000/archeryutils/blob/main/archeryutils/handicaps/handicap_equations.py)."
+    "To use these functionalities import the handicaps module as below."
    ]
   },
   {
@@ -351,11 +348,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from archeryutils import handicap_equations as hc_eq\n",
-    "from archeryutils import handicap_functions as hc_func\n",
-    "\n",
-    "hcparams = hc_eq.HcParams()\n",
-    "print(hcparams)"
+    "from archeryutils import handicaps as hc"
    ]
   },
   {
@@ -385,11 +378,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "score_from_hc = hc_eq.score_for_round(\n",
-    "    agb_outdoor.york,\n",
+    "score_from_hc = hc.score_for_round(\n",
     "    38,\n",
+    "    agb_outdoor.york,\n",
     "    \"AGB\",\n",
-    "    hcparams,\n",
     ")\n",
     "\n",
     "print(f\"A handicap of 38 on a York is a score of {score_from_hc}.\")"
@@ -410,11 +402,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "score_from_hc = hc_eq.score_for_round(\n",
-    "    agb_outdoor.york,\n",
+    "score_from_hc = hc.score_for_round(\n",
     "    38.25,\n",
+    "    agb_outdoor.york,\n",
     "    \"AGB\",\n",
-    "    hcparams,\n",
     ")\n",
     "\n",
     "print(f\"A handicap of 38.25 on a York is a score of {score_from_hc}.\")"
@@ -425,7 +416,7 @@
    "id": "ee060506",
    "metadata": {},
    "source": [
-    "By default this function returns a round score as would appear in handicap tables and is physically attainable when shooting a round. The rounding mechanism (round/floor/ceil) varies by scheme. However, it is possible to return the mathematically continuous score by setting the `round_score_up` optional argument to be `False`:"
+    "By default this function returns a round score as would appear in handicap tables and is physically attainable when shooting a round. The rounding mechanism (round/floor/ceil) varies by scheme. However, it is possible to return the mathematically continuous score by setting the `rounded_score` optional argument to be `False`:"
    ]
   },
   {
@@ -435,12 +426,11 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "score_from_hc = hc_eq.score_for_round(\n",
-    "    agb_outdoor.york,\n",
+    "score_from_hc = hc.score_for_round(\n",
     "    38.25,\n",
+    "    agb_outdoor.york,\n",
     "    \"AGB\",\n",
-    "    hcparams,\n",
-    "    round_score_up=False,\n",
+    "    rounded_score=False,\n",
     ")\n",
     "\n",
     "print(f\"A handicap of 38.25 on a York is a decimal score of {score_from_hc}.\")"
@@ -461,11 +451,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "pass_scores = hc_eq.score_for_passes(\n",
-    "    agb_outdoor.york,\n",
+    "pass_scores = hc.score_for_passes(\n",
     "    38,\n",
+    "    agb_outdoor.york,\n",
     "    \"AGB\",\n",
-    "    hcparams,\n",
     ")\n",
     "\n",
     "print(f\"A handicap of 38 on a York gives pass scores of {pass_scores}.\")"
@@ -480,7 +469,7 @@
     "\n",
     "Mathematically is is easy to define a score for a given handicap, but often the opposite is required, where one wishes to obtain the handicap given a score.\n",
     "\n",
-    "To perform this operation use the `handicap_from_score()` function which takes a score, round, handicap scheme, and set of parameters.\n",
+    "To perform this operation use the `handicap_from_score()` function which takes a score, round, and handicap scheme.\n",
     "By default it returns the decimal handicap corresponding to the provided score exactly.\n",
     "However, it is possible to return the integer handicap value that the score would correspond to in a handicap table by setting the `int_prec` optional argument to `True`. Remember that the rounding mechanism (round/floor/ceil) varies by scheme.\n",
     "\n",
@@ -495,19 +484,17 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "hc_from_score = hc_func.handicap_from_score(\n",
+    "hc_from_score = hc.handicap_from_score(\n",
     "    950,\n",
     "    agb_outdoor.york,\n",
     "    \"AGB\",\n",
-    "    hcparams,\n",
     ")\n",
     "print(f\"A score of 950 on a York is a continuous handicap of {hc_from_score}.\")\n",
     "\n",
-    "hc_from_score = hc_func.handicap_from_score(\n",
+    "hc_from_score = hc.handicap_from_score(\n",
     "    950,\n",
     "    agb_outdoor.york,\n",
     "    \"AGB\",\n",
-    "    hcparams,\n",
     "    int_prec=True,\n",
     ")\n",
     "print(f\"A score of 950 on a York is a discrete handicap of {hc_from_score}.\")"
@@ -520,7 +507,10 @@
    "source": [
     "### Handicap Tables\n",
     "\n",
-    "A further functionality of the "
+    "A further functionality of the code is the ability to generate \"Handicap Tables\" for arbitrary lists of rounds and handicaps.\n",
+    "\n",
+    "To do this use the `HandicapTable` object which can be initialised with the name of a handicap scheme, an array of handicaps to display scores for, and a list of `Round`s to display scores for.\n",
+    "The `HandicapTable` object stores the table, but it can be printed to the output using `print()`:"
    ]
   },
   {
@@ -539,15 +529,16 @@
     "    agb_outdoor.albion,\n",
     "    agb_outdoor.windsor,\n",
     "]\n",
-    "# The following allows printing of handicap tables for an entire group of rounds:\n",
+    "# The following would allow handicap tables for an entire group of rounds to be generated:\n",
     "# rounds = list(load_rounds.AGB_outdoor_imperial.values())\n",
     "\n",
-    "hc_func.print_handicap_table(\n",
-    "    handicaps,\n",
+    "agb_handicap_table = hc.HandicapTable(\n",
     "    \"AGB\",\n",
+    "    handicaps,\n",
     "    rounds,\n",
-    "    hcparams,\n",
-    ")"
+    ")\n",
+    "\n",
+    "print(agb_handicap_table)"
    ]
   },
   {
@@ -555,10 +546,9 @@
    "id": "59749871",
    "metadata": {},
    "source": [
-    "The following optional arguments can be passed to `print_handicap_table()`:\n",
-    "- `round_scores_up` - round scores to discrete values as appropriate for the scheme\n",
-    "- `clean_gaps` - if `True` duplicate scores will be displayed for the first occurrence only\n",
-    "- `filename` - if provided as a string the table will be saved to file\n",
+    "The following optional arguments can be passed to `HandicapTable`:\n",
+    "- `rounded_scores` - if `False`, display decimal scores instead of rounding to discrete values as appropriate for the scheme\n",
+    "- `clean_gaps` - if `True`, duplicate scores will be displayed for the first occurrence only\n",
     "- `int_prec` - if `True` then values will be printed as integers rather than decimals\n",
     "\n",
     "It is also possible to pass an array of non-integer handicaps.\n",
@@ -575,17 +565,38 @@
    "source": [
     "handicaps = np.arange(0.0, 51.0, 0.5)\n",
     "\n",
-    "\n",
-    "hc_func.print_handicap_table(\n",
-    "    handicaps,\n",
+    "agb_decimal_table = hc.HandicapTable(\n",
     "    \"AGB\",\n",
+    "    handicaps,\n",
     "    rounds,\n",
-    "    hcparams,\n",
-    "    round_scores_up=False,\n",
+    "    rounded_scores=False,\n",
     "    clean_gaps=False,\n",
-    "    filename=\"test_handicap_table.txt\",\n",
     "    int_prec=False,\n",
-    ")"
+    ")\n",
+    "\n",
+    "print(agb_decimal_table)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "99076c03-9b22-43c1-8445-ad0e2b55f689",
+   "metadata": {},
+   "source": [
+    "The `HandicapTable` class also contains two methods for saving the table to file:\n",
+    "- `to_file(<filename>)` - which will save the nicely formatted table as seen from `print()` as an ascii file with the provided filename\n",
+    "- `to_csv(<filename>)` - saves the table in a comma-separated-variable format\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "caa3a235-6a2c-425d-bbcc-91cdb6345b94",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "agb_handicap_table.to_file(\"my_saved_table.txt\")\n",
+    "\n",
+    "agb_handicap_table.to_csv(\"my_saved_csv_table.csv\")"
    ]
   },
   {
@@ -619,8 +630,8 @@
     "To get a classification that results from a score use the `calculate_X_classification()` function, where `X` corresponds to the classification scheme being used (`AGB_outdoor`, `AGB_indoor`, `AGB_field`).\n",
     "\n",
     "This takes following arguments:\n",
-    "- a string of a round alias (see [Default Rounds](#Default-Rounds)).\n",
     "- a score\n",
+    "- a string of a round alias (see [Default Rounds](#Default-Rounds)).\n",
     "- a string of bowstyle (`\"compound\"`, `\"recurve\"`, `\"longbow\"`, `\"barebow\"`, `\"traditional\"`, `\"flatbow\"`)\n",
     "- a string of gender under Archery GB (`\"male\"` or `\"female\"`)\n",
     "- an Archery GB age group (`\"50+\"`, `\"adult\"`, `\"under 21\"`, `\"under 18\"`, etc.)\n",
@@ -639,8 +650,8 @@
    "source": [
     "# AGB Outdoor\n",
     "class_from_score = class_func.calculate_agb_outdoor_classification(\n",
-    "    \"hereford\",\n",
     "    965,\n",
+    "    \"hereford\",\n",
     "    \"recurve\",\n",
     "    \"male\",\n",
     "    \"50+\",\n",
@@ -659,8 +670,8 @@
    "source": [
     "# AGB Indoor\n",
     "class_from_score = class_func.calculate_agb_indoor_classification(\n",
-    "    \"wa18\",\n",
     "    562,\n",
+    "    \"wa18\",\n",
     "    \"compound\",\n",
     "    \"female\",\n",
     "    \"adult\",\n",
@@ -679,8 +690,8 @@
    "source": [
     "# AGB Field\n",
     "class_from_score = class_func.calculate_agb_field_classification(\n",
-    "    \"wa_field_24_blue_unmarked\",\n",
     "    168,\n",
+    "    \"wa_field_24_blue_unmarked\",\n",
     "    \"traditional\",\n",
     "    \"male\",\n",
     "    \"under 18\",\n",
@@ -769,7 +780,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.10.13"
+   "version": "3.12.2"
   }
  },
  "nbformat": 4,


Test patch:
diff --git a/archeryutils/classifications/tests/test_agb_field.py b/archeryutils/classifications/tests/test_agb_field.py
index 559d44f..92cd166 100644
--- a/archeryutils/classifications/tests/test_agb_field.py
+++ b/archeryutils/classifications/tests/test_agb_field.py
@@ -310,8 +310,8 @@ def test_calculate_agb_field_classification(
         """
         # pylint: disable=too-many-arguments
         class_returned = class_funcs.calculate_agb_field_classification(
-            roundname=roundname,
             score=score,
+            roundname=roundname,
             bowstyle=bowstyle,
             gender="male",
             age_group=age_group,
@@ -347,8 +347,8 @@ def test_calculate_agb_field_classification_invalid_rounds(
         Check that field classification returns unclassified for inappropriate rounds.
         """
         class_returned = class_funcs.calculate_agb_field_classification(
-            roundname=roundname,
             score=score,
+            roundname=roundname,
             bowstyle=bowstyle,
             gender="male",
             age_group="adult",
@@ -393,8 +393,8 @@ def test_calculate_agb_field_classification_invalid_scores(
             ),
         ):
             _ = class_funcs.calculate_agb_field_classification(
-                roundname=roundname,
                 score=score,
+                roundname=roundname,
                 bowstyle="barebow",
                 gender="male",
                 age_group="adult",
diff --git a/archeryutils/classifications/tests/test_agb_indoor.py b/archeryutils/classifications/tests/test_agb_indoor.py
index 7e6bfc9..b71dffe 100644
--- a/archeryutils/classifications/tests/test_agb_indoor.py
+++ b/archeryutils/classifications/tests/test_agb_indoor.py
@@ -391,8 +391,8 @@ def test_calculate_agb_indoor_classification(
         """
         # pylint: disable=too-many-arguments
         class_returned = class_funcs.calculate_agb_indoor_classification(
-            roundname="portsmouth",
             score=score,
+            roundname="portsmouth",
             bowstyle=bowstyle,
             gender="male",
             age_group=age_group,
@@ -411,8 +411,8 @@ def test_calculate_agb_indoor_classification_invalid_round(
             match=("invalid_roundname"),
         ):
             _ = class_funcs.calculate_agb_indoor_classification(
-                roundname="invalid_roundname",
                 score=400,
+                roundname="invalid_roundname",
                 bowstyle="recurve",
                 gender="male",
                 age_group="adult",
@@ -434,8 +434,8 @@ def test_calculate_agb_indoor_classification_invalid_scores(
             ),
         ):
             _ = class_funcs.calculate_agb_indoor_classification(
-                roundname="portsmouth",
                 score=score,
+                roundname="portsmouth",
                 bowstyle="barebow",
                 gender="male",
                 age_group="adult",
diff --git a/archeryutils/classifications/tests/test_agb_old_indoor.py b/archeryutils/classifications/tests/test_agb_old_indoor.py
index 930ddf7..0bf06f1 100644
--- a/archeryutils/classifications/tests/test_agb_old_indoor.py
+++ b/archeryutils/classifications/tests/test_agb_old_indoor.py
@@ -239,8 +239,8 @@ def test_calculate_agb_old_indoor_classification(
         Check that old_indoor classification returns expected value for a few cases.
         """
         class_returned = class_funcs.calculate_agb_old_indoor_classification(
-            roundname="portsmouth",
             score=score,
+            roundname="portsmouth",
             bowstyle="recurve",
             gender=gender,
             age_group="adult",
@@ -285,8 +285,8 @@ def test_calculate_agb_old_indoor_classification_invalid_scores(
             ),
         ):
             _ = class_funcs.calculate_agb_old_indoor_classification(
-                roundname=roundname,
                 score=score,
+                roundname=roundname,
                 bowstyle="barebow",
                 gender="male",
                 age_group="adult",
diff --git a/archeryutils/classifications/tests/test_agb_outdoor.py b/archeryutils/classifications/tests/test_agb_outdoor.py
index bca1b61..ffe9a44 100644
--- a/archeryutils/classifications/tests/test_agb_outdoor.py
+++ b/archeryutils/classifications/tests/test_agb_outdoor.py
@@ -431,8 +431,8 @@ class TestCalculateAgbOutdoorClassification:
     )
     def test_calculate_agb_outdoor_classification(
         self,
-        roundname: str,
         score: float,
+        roundname: str,
         age_group: str,
         bowstyle: str,
         class_expected: str,
@@ -528,8 +528,8 @@ def test_calculate_agb_outdoor_classification(
     )
     def test_calculate_agb_outdoor_classification_prestige_dist(
         self,
-        roundname: str,
         score: float,
+        roundname: str,
         age_group: str,
         bowstyle: str,
         class_expected: str,
@@ -582,8 +582,8 @@ def test_calculate_agb_outdoor_classification_invalid_scores(
             ),
         ):
             _ = class_funcs.calculate_agb_outdoor_classification(
-                roundname="wa1440_90",
                 score=score,
+                roundname="wa1440_90",
                 bowstyle="barebow",
                 gender="male",
                 age_group="adult",
diff --git a/archeryutils/handicaps/tests/test_handicap_tables.py b/archeryutils/handicaps/tests/test_handicap_tables.py
index 38f6594..643cde6 100644
--- a/archeryutils/handicaps/tests/test_handicap_tables.py
+++ b/archeryutils/handicaps/tests/test_handicap_tables.py
@@ -1,21 +1,16 @@
-"""Tests for handicap table printing"""
+"""Tests for handicap table code."""
 
-# Due to defining some rounds to use in testing duplicate code may trigger.
-# => disable for handicap tests
-# pylint: disable=duplicate-code
-
-from typing import Union
 import numpy as np
-from numpy.typing import NDArray
 import pytest
+from numpy.typing import NDArray
 
-import archeryutils.handicaps.handicap_equations as hc_eq
-import archeryutils.handicaps.handicap_functions as hc_func
+import archeryutils.handicaps.handicap_tables as hc
 from archeryutils.rounds import Pass, Round
 
-hc_params = hc_eq.HcParams()
-
 # Define rounds used in these functions
+# Due to defining some rounds to use in testing duplicate code may trigger.
+# => disable for handicap tests
+# pylint: disable=duplicate-code
 york = Round(
     "York",
     [
@@ -39,112 +34,99 @@
         Pass.at_target(36, "10_zone", 122, 30, False),
     ],
 )
+roundlist = [york, hereford, metric122_30]
 
 
 class TestHandicapTable:
-    """
-    Class to test the handicap table functionalities of handicap_functions.
+    """Class to test the handicap table functionalities of handicap_functions.
 
     Methods
     -------
+    test_format_row
+    test_table_as_str
+    test_print_agb
+    test_print_aa
+    test_check_print_table_inputs_invalid_rounds
+    test_check_print_table_inputs_invalid_handicaps
 
-    References
-    ----------
     """
 
+    def test_repr(self) -> None:
+        """Check HandicapTable representation."""
+        test_table = hc.HandicapTable(
+            "AGB", np.array([1.0, 2.0, 3.0]), [york, hereford]
+        )
+        expected = "<HandicapTable: 'AGB'>"
+        assert repr(test_table) == expected
+
     @pytest.mark.parametrize(
-        "input_arr,hc_dp,int_prec,expected",
+        "hcs,int_prec,rounded,expected",
         [
             (
-                np.array([1, 20.0, 23.0]),
-                0,
+                # Check int_prec true
+                np.array([1, 2, 3]),
                 True,
-                "             1            20            23",
-            ),
-            (
-                np.array([1, 20.0, 23.0]),
-                2,
                 True,
-                "          1.00            20            23",
+                "      Handicap          York      Hereford\n"
+                + "             1          1281              \n"
+                + "             2          1279          1294\n"
+                + "             3          1276          1293",
             ),
             (
-                np.array([1, 20.0, 23.0]),
-                3,
-                False,
-                "         1.000   20.00000000   23.00000000",
-            ),
-        ],
-    )
-    def test_format_row(
-        self,
-        input_arr: NDArray[Union[np.int_, np.float_]],
-        hc_dp: int,
-        int_prec: bool,
-        expected: str,
-    ) -> None:
-        """
-        Check that format_row returns expected results for float and int.
-        """
-        assert hc_func.format_row(input_arr, hc_dp, int_prec) == expected
-
-    @pytest.mark.parametrize(
-        "hcs,table,int_prec,expected",
-        [
-            (
-                # Check int_prec true
+                # Check int_prec false and rounded true (forces int_prec true)
                 np.array([1, 2, 3]),
-                np.array([[1, 20.0, 23.0], [2, 20.0, 23.0], [3, 20.0, 23.0]]),
+                False,
                 True,
                 "      Handicap          York      Hereford\n"
-                + "             1            20            23\n"
-                + "             2            20            23\n"
-                + "             3            20            23",
+                + "             1          1281              \n"
+                + "             2          1279          1294\n"
+                + "             3          1276          1293",
             ),
             (
-                # Check int_prec false
+                # Check int_prec false and rounded false
                 np.array([1, 2, 3]),
-                np.array([[1, 20.0, 23.0], [2, 20.0, 23.0], [3, 20.0, 23.0]]),
+                False,
                 False,
                 "      Handicap          York      Hereford\n"
-                + "             1   20.00000000   23.00000000\n"
-                + "             2   20.00000000   23.00000000\n"
-                + "             3   20.00000000   23.00000000",
+                + "             1 1280.95163666 1293.85033513\n"
+                + "             2 1278.16257099 1293.14707366\n"
+                + "             3 1275.05905507 1292.28105576",
             ),
             (
                 # Check handicap float integers are converted to ints
                 np.array([1.0, 2.0, 3.0]),
-                np.array([[1.0, 20.0, 23.0], [2.0, 20.0, 23.0], [3.0, 20.0, 23.0]]),
+                True,
                 True,
                 "      Handicap          York      Hereford\n"
-                + "             1            20            23\n"
-                + "             2            20            23\n"
-                + "             3            20            23",
+                + "             1          1281              \n"
+                + "             2          1279          1294\n"
+                + "             3          1276          1293",
             ),
             (
                 # Check handicap dp are allocated OK
                 np.array([1.20, 2.0, 3.0]),
-                np.array([[1.20, 20.0, 23.0], [2.0, 20.0, 23.0], [3.0, 20.0, 23.0]]),
+                True,
                 True,
                 "      Handicap          York      Hereford\n"
-                + "           1.2            20            23\n"
-                + "           2.0            20            23\n"
-                + "           3.0            20            23",
+                + "           1.2          1281              \n"
+                + "           2.0          1279          1294\n"
+                + "           3.0          1276          1293",
             ),
         ],
     )
     def test_table_as_str(
         self,
-        hcs: NDArray[Union[np.int_, np.float_]],
-        table: NDArray[Union[np.int_, np.float_]],
+        hcs: NDArray[np.float_],
         int_prec: bool,
+        rounded: bool,
         expected: str,
     ) -> None:
-        """
-        Check that format_row returns expected results for float and int.
-        """
-        print(hc_func.table_as_str([york, hereford], hcs, table, int_prec))
-        print(expected)
-        assert hc_func.table_as_str([york, hereford], hcs, table, int_prec) == expected
+        """Check that format_row returns expected results for float and int."""
+        test_table = hc.HandicapTable(
+            "AGB", hcs, [york, hereford], int_prec=int_prec, rounded_scores=rounded
+        )
+
+        assert str(test_table) == expected
 
     @pytest.mark.parametrize(
         "input_str,expected",
@@ -160,124 +142,132 @@ def test_abbreviate(
         input_str: str,
         expected: str,
     ) -> None:
-        """
-        Check that abbreviate returns expected results.
-        """
-        assert hc_func.abbreviate(input_str) == expected
+        """Check that abbreviate returns expected results."""
+        # Accessing a protected member function, but for testing, not production code
+        # pylint: disable=protected-access
+        assert hc.HandicapTable._abbreviate(input_str) == expected
 
-    @pytest.mark.parametrize(
-        "hcs,clean_gaps,expected",
-        [
-            (
-                # 'Correct' inputs
-                np.array([1.0, 2.0, 3.0]),
-                False,
-                np.array([1.0, 2.0, 3.0]),
-            ),
-            (
-                # List inputs
-                [1.0, 2.0, 3.0],
-                False,
-                np.array([1.0, 2.0, 3.0]),
-            ),
-            (
-                # Clean gaps True
-                np.array([1.0, 2.0, 3.0]),
-                True,
-                np.array([0.0, 1.0, 2.0, 3.0, 4.0]),
-            ),
-            (
-                # Clean gaps True
-                np.array([1.75, 2.0, 2.5]),
-                True,
-                np.array([1.5, 1.75, 2.0, 2.5, 3.0]),
-            ),
-            (
-                # Single float
-                1.0,
-                False,
-                np.array([1.0]),
-            ),
-            (
-                # Single float clean gaps True
-                1.5,
-                True,
-                np.array([0.5, 1.5, 2.5]),
-            ),
-        ],
-    )
-    def test_check_print_table_inputs(
+    def test_print_agb(
+        self,
+        capsys,
+    ) -> None:
+        """Check that inputs processed appropriately."""
+        expected_out = (
+            "      Handicap          York      Hereford\n"
+            + "             1          1281              \n"
+            + "             2          1279          1294\n"
+            + "             3          1276          1293\n"
+        )
+        test_table = hc.HandicapTable(
+            "AGB", np.array([1.0, 2.0, 3.0]), [york, hereford]
+        )
+        test_table.print()
+        captured = capsys.readouterr()
+        assert captured.out == expected_out
+
+    def test_print_aa(
         self,
-        hcs: Union[float, NDArray[np.float_]],
-        clean_gaps: bool,
-        expected: Union[float, NDArray[np.float_]],
+        capsys,
     ) -> None:
-        """
-        Check that inputs processed appropriately.
-        """
-        np.testing.assert_allclose(
-            hc_func.check_print_table_inputs(hcs, [york, metric122_30], clean_gaps),
-            expected,
+        """Check that inputs processed appropriately."""
+        expected_out = (
+            "      Handicap          York      Hereford\n"
+            + "             1            79           136\n"
+            + "             2            83           142\n"
+            + "             3            87           149\n"
         )
+        test_table = hc.HandicapTable("AA", np.array([1.0, 2.0, 3.0]), [york, hereford])
+        test_table.print()
+        captured = capsys.readouterr()
+        assert captured.out == expected_out
+
+    def test_inputs_as_list(
+        self,
+        capsys,
+    ) -> None:
+        """Check that input handicaps as a list are processed appropriately."""
+        expected_out = (
+            "      Handicap          York      Hereford\n"
+            + "             1          1281              \n"
+            + "             2          1279          1294\n"
+            + "             3          1276          1293\n"
+        )
+        test_table = hc.HandicapTable(
+            "AGB",
+            [1.0, 2.0, 3.0],
+            [york, hereford],  # type: ignore
+        )
+        test_table.print()
+        captured = capsys.readouterr()
+        assert captured.out == expected_out
 
     def test_check_print_table_inputs_invalid_rounds(
         self,
     ) -> None:
-        """
-        Check that empty rounds list triggers error.
-        """
+        """Check that empty rounds list triggers error."""
         with pytest.raises(
             ValueError,
             match=("No rounds provided for handicap table."),
         ):
-            hc_func.check_print_table_inputs(1.0, [], True)
+            hc.HandicapTable("AGB", 1.0, [])
 
-    @pytest.mark.parametrize(
-        "input_table,int_prec,sys,expected",
-        [
-            (
-                np.array([[0, 11, 12, 13], [1, 10, 12, 12]]),
-                True,
-                "AGB",
-                np.array([[0, 11, -9999, 13], [1, 10, 12, 12]]),
-            ),
-            (
-                np.array([[0, 13], [5, 12], [10, 12], [15, 11]]),
-                True,
-                "AGB",
-                np.array([[0, 13], [5, -9999], [10, 12], [15, 11]]),
-            ),
-            (
-                np.array([[4, 13], [3, 12], [2, 12], [1, 11]]),
-                True,
-                "AA",
-                np.array([[4, 13], [3, 12], [2, -9999], [1, 11]]),
-            ),
-            (
-                np.array([[0.0, 11.0, 12.0, 13.0], [1.0, 10.0, 12.0, 12.0]]),
-                False,
-                "AGB",
-                np.array([[0.0, 11.0, np.nan, 13.0], [1.0, 10.0, 12.0, 12.0]]),
-            ),
-            (
-                np.array([[0.0, 11.5, 12.5, 13.5], [1.0, 11.5, 12.0, 13.5]]),
-                False,
-                "AGB",
-                np.array([[0.0, np.nan, 12.5, np.nan], [1.0, 11.5, 12.0, 13.5]]),
-            ),
-        ],
-    )
-    def test_clean_repeated(
+    def test_check_print_table_inputs_invalid_handicaps(
         self,
-        input_table: NDArray[Union[np.int_, np.float_]],
-        int_prec: bool,
-        sys: str,
-        expected: NDArray[Union[np.int_, np.float_]],
     ) -> None:
-        """
-        Check that abbreviate returns expected results.
-        """
-        print(hc_func.clean_repeated(input_table, int_prec, sys))
-        np.testing.assert_allclose(
-            hc_func.clean_repeated(input_table, int_prec, sys), expected
-        )
+        """Check that inappropriate handicaps triggers error."""
+        with pytest.raises(
+            TypeError,
+            match=("Expected float or ndarray for hcs."),
+        ):
+            hc.HandicapTable("AGB", "a", [york, hereford])  # type: ignore
+
+
+#     @pytest.mark.parametrize(
+#         "input_table,int_prec,sys,expected",
+#         [
+#             (
+#                 np.array([[0, 11, 12, 13], [1, 10, 12, 12]]),
+#                 True,
+#                 "AGB",
+#                 np.array([[0, 11, -9999, 13], [1, 10, 12, 12]]),
+#             ),
+#             (
+#                 np.array([[0, 13], [5, 12], [10, 12], [15, 11]]),
+#                 True,
+#                 "AGB",
+#                 np.array([[0, 13], [5, -9999], [10, 12], [15, 11]]),
+#             ),
+#             (
+#                 np.array([[4, 13], [3, 12], [2, 12], [1, 11]]),
+#                 True,
+#                 "AA",
+#                 np.array([[4, 13], [3, 12], [2, -9999], [1, 11]]),
+#             ),
+#             (
+#                 np.array([[0.0, 11.0, 12.0, 13.0], [1.0, 10.0, 12.0, 12.0]]),
+#                 False,
+#                 "AGB",
+#                 np.array([[0.0, 11.0, np.nan, 13.0], [1.0, 10.0, 12.0, 12.0]]),
+#             ),
+#             (
+#                 np.array([[0.0, 11.5, 12.5, 13.5], [1.0, 11.5, 12.0, 13.5]]),
+#                 False,
+#                 "AGB",
+#                 np.array([[0.0, np.nan, 12.5, np.nan], [1.0, 11.5, 12.0, 13.5]]),
+#             ),
+#         ],
+#     )
+#     def test_clean_repeated(
+#         self,
+#         input_table: NDArray[Union[np.int_, np.float_]],
+#         int_prec: bool,
+#         sys: str,
+#         expected: NDArray[Union[np.int_, np.float_]],
+#     ) -> None:
+#         """
+#         Check that abbreviate returns expected results.
+#         """
+#         print(hc_func.clean_repeated(input_table, int_prec, sys))
+#         np.testing.assert_allclose(
+#             hc_func.clean_repeated(input_table, int_prec, sys), expected
+#         )
diff --git a/archeryutils/handicaps/tests/test_handicaps.py b/archeryutils/handicaps/tests/test_handicaps.py
index 3e9b932..c2df7c2 100644
--- a/archeryutils/handicaps/tests/test_handicaps.py
+++ b/archeryutils/handicaps/tests/test_handicaps.py
@@ -1,20 +1,15 @@
-"""Tests for handicap equations and functions"""
-
-# Due to defining some rounds to use in testing duplicate code may trigger.
-# => disable for handicap tests
-# pylint: disable=duplicate-code
+"""Tests for handicap equations and functions."""
 
 import numpy as np
 import pytest
-from pytest_mock import MockerFixture
-
-import archeryutils.handicaps.handicap_equations as hc_eq
-import archeryutils.handicaps.handicap_functions as hc_func
-from archeryutils.targets import Target, ScoringSystem
-from archeryutils.rounds import Round, Pass
 
-
-hc_params = hc_eq.HcParams()
+import archeryutils.handicaps as hc
+from archeryutils.handicaps.handicap_functions import handicap_scheme
+from archeryutils.handicaps.handicap_scheme import HandicapScheme
+from archeryutils.handicaps.handicap_scheme_aa import HandicapAA, HandicapAA2
+from archeryutils.handicaps.handicap_scheme_agb import HandicapAGB, HandicapAGBold
+from archeryutils.rounds import Pass, Round
+from archeryutils.targets import ScoringSystem, Target
 
 # Define rounds used in these functions
 york = Round(
@@ -80,65 +75,69 @@
 )
 
 
-@pytest.fixture
-def mocker_hcparams_json(mocker: MockerFixture) -> None:
-    """
-    Override open with a fake HCParams json file.
-    """
-    mocked_json_file = mocker.mock_open(
-        read_data="""{
-    "AGB_datum": 1.0,
-    "AGB_step": 1.0,
-    "AGB_ang_0": 1.0,
-    "AGB_kd": 1.0,
-
-    "AGBo_datum": 1.0,
-    "AGBo_step": 1.0,
-    "AGBo_ang_0": 1.0,
-    "AGBo_k1": 1.0,
-    "AGBo_k2": 1.0,
-    "AGBo_k3": 1.0,
-    "AGBo_p1": 1.0,
-    "AGBo_arw_d": 3.0,
-
-    "AA_k0": 2.0,
-    "AA_ks": 2.0,
-    "AA_kd": 2.0,
-
-    "AA2_k0": 2.0,
-    "AA2_ks": 2.0,
-    "AA2_f1": 2.0,
-    "AA2_f2": 2.0,
-    "AA2_d0": 2.0,
-
-    "AA_arw_d_out": 3.0,
-    "arrow_diameter_indoors": 3.0,
-    "arrow_diameter_outdoors": 3.0
-    }"""
+class TestHandicapScheme:
+    """Class to test the handicap_scheme() function creating a HandicapScheme class."""
+
+    @pytest.mark.parametrize(
+        "scheme",
+        [
+            ("AGB"),
+            ("AGBold"),
+            ("AA"),
+            ("AA2"),
+        ],
     )
-    mocker.patch("builtins.open", mocked_json_file)
+    def test_repr(self, scheme: str) -> None:
+        """Check HandicapScheme subclass string representation."""
+        test_scheme = handicap_scheme(scheme)
+        expected = f"<HandicapScheme: '{scheme}'>"
+        assert repr(test_scheme) == expected
 
+    def test_invalid_system(self) -> None:
+        """Check that error returned if initialising an invalid system."""
+        with pytest.raises(
+            ValueError,
+            match=(
+                "InvalidSystem is not a recognised handicap system.\n"
+                + "Please select from 'AGB', 'AGBold', 'AA', 'AA2'."
+            ),
+        ):
+            hc.handicap_scheme("InvalidSystem")
 
-def test_load_json_hcparams(mocker_hcparams_json: MockerFixture) -> None:
-    """
-    Test loading of HcParams from file using mock.
-    """
-    # pylint cannot understand mocker as variable name used from fixture => disable
-    # pylint: disable=redefined-outer-name
-    # pylint: disable=unused-argument
-    handicap_params = hc_eq.HcParams()
-    handicap_params = handicap_params.load_json_params("fakefile.json")
-
-    for val in handicap_params.agb_hc_data.values():
-        assert val == 1.0
-    for val in handicap_params.agb_old_hc_data.values():
-        assert val == 1.0
-    for val in handicap_params.aa_hc_data.values():
-        assert val == 2.0
-    for val in handicap_params.aa2_hc_data.values():
-        assert val == 2.0
-    for val in handicap_params.arw_d_data.values():
-        assert val == 3.0
+    @pytest.mark.parametrize(
+        "scheme",
+        [
+            ("AGB"),
+            ("AGBold"),
+            ("AA"),
+            ("AA2"),
+        ],
+    )
+    def test_handicap_class_unchanged(self, scheme: str) -> None:
+        """Check that passing HandicapScheme class through handicap_scheme() returns itself."""
+        base_hc = hc.handicap_scheme(scheme)
+        new_hc = hc.handicap_scheme(base_hc)
+
+        assert base_hc == new_hc
+
+    @pytest.mark.parametrize(
+        "scheme,hcclass",
+        # Check all systems, different distances, negative and large handicaps.
+        [
+            ("AGB", HandicapAGB),
+            ("AGBold", HandicapAGBold),
+            ("AA", HandicapAA),
+            ("AA2", HandicapAA2),
+        ],
+    )
+    def test_handicap_scheme_subclass_generation(
+        self, scheme: str, hcclass: HandicapScheme
+    ) -> None:
+        """Check that appropriate subclasses are generated by handicap_scheme()."""
+        # Comparison to type as mypy wouldn't play nice with isinstance() & user-defined cls
+        # But pylint doesn't like this => disable warning here.
+        # pylint: disable=unidiomatic-typecheck
+        assert type(hc.handicap_scheme(scheme)) == hcclass
 
 
 class TestSigmaT:
@@ -151,32 +150,12 @@ class TestSigmaT:
 
     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) -> None:
-        """
-        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="InvalidSystem",
-                dist=10.0,
-                hc_dat=hc_params,
-            )
-
     @pytest.mark.parametrize(
         "handicap,system,distance,theta_expected",
         # Check all systems, different distances, negative and large handicaps.
@@ -202,29 +181,23 @@ def test_float(
         distance: float,
         theta_expected: float,
     ) -> None:
-        """
-        Check that sigma_t(handicap=float) returns expected value for a case.
-        """
-        theta = hc_eq.sigma_t(
+        """Check that sigma_t(handicap=float) returns expected value for a case."""
+        hc_sys = hc.handicap_scheme(system)
+        theta = hc_sys.sigma_t(
             handicap=handicap,
-            hc_sys=system,
             dist=distance,
-            hc_dat=hc_params,
         )
 
         assert theta == pytest.approx(theta_expected)
 
     def test_array(self) -> None:
-        """
-        Check that sigma_t(handicap=ndarray) returns expected value for a case.
-        """
+        """Check that sigma_t(handicap=ndarray) returns expected value for a case."""
         handicap_array = np.array([25.46, -12.0, 200.0])
         theta_expected_array = np.array([0.002125743670, 0.0005859295368, 0.861389448])
-        theta_array = hc_eq.sigma_t(
+        hc_sys = hc.handicap_scheme("AGB")
+        theta_array = hc_sys.sigma_t(
             handicap=handicap_array,
-            hc_sys="AGB",
             dist=100.0,
-            hc_dat=hc_params,
         )
 
         np.testing.assert_allclose(theta_array, theta_expected_array)
@@ -240,32 +213,12 @@ class TestSigmaR:
 
     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) -> None:
-        """
-        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="InvalisHandicapSystem",
-                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.
@@ -291,29 +244,23 @@ def test_float(
         distance: float,
         sigma_r_expected: float,
     ) -> None:
-        """
-        Check that sigma_r(handicap=float) returns expected value for a case.
-        """
-        sigma_r = hc_eq.sigma_r(
+        """Check that sigma_r(handicap=float) returns expected value for a case."""
+        hc_sys = hc.handicap_scheme(system)
+        sigma_r = hc_sys.sigma_r(
             handicap=handicap,
-            hc_sys=system,
             dist=distance,
-            hc_dat=hc_params,
         )
 
         assert sigma_r == pytest.approx(sigma_r_expected)
 
     def test_array(self) -> None:
-        """
-        Check that sigma_r(handicap=ndarray) returns expected value for a case.
-        """
+        """Check that sigma_r(handicap=ndarray) returns expected value for a case."""
         handicap_array = np.array([25.46, -12.0, 200.0])
         sigma_r_expected_array = np.array([0.2125743670, 0.05859295, 86.1389448])
-        sigma_r_array = hc_eq.sigma_r(
+        hc_sys = hc.handicap_scheme("AGB")
+        sigma_r_array = hc_sys.sigma_r(
             handicap=handicap_array,
-            hc_sys="AGB",
             dist=100.0,
-            hc_dat=hc_params,
         )
 
         np.testing.assert_allclose(sigma_r_array, sigma_r_expected_array)
@@ -331,34 +278,12 @@ class TestArrowScore:
 
     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) -> None:
-        """
-        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("5_zone", 122.0, 100.0)
-            # Silence mypy as scoring_system must be a valid literal ScoringSystem
-            target.scoring_system = "InvalidScoringSystem"  # type: ignore[assignment]
-
-            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",
         [
@@ -386,17 +311,26 @@ def test_different_handicap_systems(
         """
         Check arrow scores returned for different handicap systems and arrow diameters.
 
+        Check using the HandicapScheme object and also the wrapper function.
         """
-        arrow_score = hc_eq.arrow_score(
-            target=Target("10_zone_5_ring_compound", 40, 20.0, indoor),
+        hc_sys = hc.handicap_scheme(hc_system)
+        arrow_score = hc_sys.arrow_score(
             handicap=20.0,
-            hc_sys=hc_system,
-            hc_dat=hc_params,
+            target=Target("10_zone_5_ring_compound", 40, 20.0, indoor),
             arw_d=arrow_diameter,
         )
 
         assert arrow_score == pytest.approx(arrow_score_expected)
 
+        arrow_score_direct = hc.arrow_score(
+            handicap=20.0,
+            target=Target("10_zone_5_ring_compound", 40, 20.0, indoor),
+            handicap_sys=hc_system,
+            arw_d=arrow_diameter,
+        )
+
+        assert arrow_score_direct == pytest.approx(arrow_score_expected)
+
     @pytest.mark.parametrize(
         "target_face,arrow_score_expected",
         [
@@ -418,18 +352,130 @@ def test_different_target_faces(
         self, target_face: ScoringSystem, arrow_score_expected: float
     ) -> None:
         """
-        Check correct arrow scores returned for different target faces
+        Check correct arrow scores returned for different target faces.
+
+        Check using the HandicapScheme object and also the wrapper function.
         """
-        arrow_score = hc_eq.arrow_score(
-            target=Target(target_face, 80, 50.0, False),
+        hc_sys = hc.handicap_scheme("AGB")
+        arrow_score = hc_sys.arrow_score(
             handicap=38.0,
-            hc_sys="AGB",
-            hc_dat=hc_params,
+            target=Target(target_face, 80, 50.0, False),
             arw_d=None,
         )
 
         assert arrow_score == pytest.approx(arrow_score_expected)
 
+        arrow_score_direct = hc.arrow_score(
+            handicap=38.0,
+            target=Target(target_face, 80, 50.0, False),
+            handicap_sys="AGB",
+            arw_d=None,
+        )
+
+        assert arrow_score_direct == pytest.approx(arrow_score_expected)
+
+
+class TestScoreForPasses:
+    """
+    Class to test the score_for_passes() 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_pass_scores()
+        test if round_score returns expected results
+    test_rounded_pass_scores
+        test if round_score returns expected results when rounding
+    """
+
+    @pytest.mark.parametrize(
+        "hc_system,pass_scores_expected",
+        [
+            (
+                "AGB",
+                [79.845655370, 76.647150342, 86.889064625],
+            ),
+            (
+                "AGBold",
+                [80.595119295, 76.161067484, 86.013051689],
+            ),
+            (
+                "AA",
+                [7.813903155, 6.287938401, 22.267136351],
+            ),
+            (
+                "AA2",
+                [8.266044100, 6.465486423, 22.394520484],
+            ),
+        ],
+    )
+    def test_float_pass_scores(
+        self, hc_system: str, pass_scores_expected: list[float]
+    ) -> None:
+        """
+        Check appropriate expected round scores are returned not rounding.
+
+        Check using the HandicapScheme object and also the wrapper function.
+        Note: Checks round score only, not the separate distance scores.
+        """
+        test_round = Round(
+            "MyRound",
+            [
+                Pass.at_target(10, "10_zone", 122, 100, False),
+                Pass.at_target(10, "10_zone", 80, 80, False),
+                Pass.at_target(10, "5_zone", 122, 60, False),
+            ],
+        )
+
+        np.testing.assert_allclose(
+            hc.score_for_passes(20.0, test_round, hc_system, None, False),
+            pass_scores_expected,
+        )
+
+        hc_sys = hc.handicap_scheme(hc_system)
+        np.testing.assert_allclose(
+            hc_sys.score_for_passes(20.0, test_round, None, False), pass_scores_expected
+        )
+
+    @pytest.mark.parametrize(
+        "hc_system,pass_scores_expected",
+        [
+            (
+                "AGB",
+                [80.0, 77.0, 87.0],
+            ),
+            (
+                "AGBold",
+                [81.0, 76.0, 86.0],
+            ),
+        ],
+    )
+    def test_rounded_pass_scores(
+        self, hc_system: str, pass_scores_expected: list[float]
+    ) -> None:
+        """Check appropriate expected pass scores are returned for rounding."""
+        test_round = Round(
+            "MyRound",
+            [
+                Pass.at_target(10, "10_zone", 122, 100, False),
+                Pass.at_target(10, "10_zone", 80, 80, False),
+                Pass.at_target(10, "5_zone", 122, 60, False),
+            ],
+        )
+
+        np.testing.assert_allclose(
+            hc.score_for_passes(20.0, test_round, hc_system, None, True),
+            pass_scores_expected,
+        )
+
+        hc_sys = hc.handicap_scheme(hc_system)
+        np.testing.assert_allclose(
+            hc_sys.score_for_passes(20.0, test_round, None, True), pass_scores_expected
+        )
+
 
 class TestScoreForRound:
     """
@@ -452,40 +498,29 @@ class TestScoreForRound:
         [
             (
                 "AGB",
-                (
-                    243.381870343,
-                    [79.845655370, 76.647150342, 86.889064625],
-                ),
+                243.381870343,
             ),
             (
                 "AGBold",
-                (
-                    242.769238469,
-                    [80.595119295, 76.161067484, 86.013051689],
-                ),
+                242.769238469,
             ),
             (
                 "AA",
-                (
-                    36.368977908,
-                    [7.813903155, 6.287938401, 22.267136351],
-                ),
+                36.368977908,
             ),
             (
                 "AA2",
-                (
-                    37.126051009,
-                    [8.266044100, 6.465486423, 22.394520484],
-                ),
+                37.126051009,
             ),
         ],
     )
     def test_float_round_score(
-        self, hc_system: str, round_score_expected: tuple[float, list[float]]
+        self, hc_system: str, round_score_expected: float
     ) -> None:
         """
         Check appropriate expected round scores are returned not rounding.
 
+        Check using the HandicapScheme object and also the wrapper function.
         Note: Checks round score only, not the separate distance scores.
         """
         test_round = Round(
@@ -497,25 +532,30 @@ def test_float_round_score(
             ],
         )
 
-        assert hc_eq.score_for_round(
-            test_round, 20.0, hc_system, hc_eq.HcParams(), None, False
-        ) == pytest.approx(round_score_expected[0])
+        assert hc.score_for_round(
+            20.0, test_round, hc_system, None, False
+        ) == pytest.approx(round_score_expected)
+
+        hc_sys = hc.handicap_scheme(hc_system)
+        assert hc_sys.score_for_round(20.0, test_round, None, False) == pytest.approx(
+            round_score_expected
+        )
 
     @pytest.mark.parametrize(
         "hc_system,round_score_expected",
         [
             (
                 "AGB",
-                (244.0, [79.845655376, 76.647150342, 86.889064625]),
+                244.0,
             ),
             (
                 "AGBold",
-                (243.0, [80.595119295, 76.161067484, 86.013051689]),
+                243.0,
             ),
         ],
     )
     def test_rounded_round_score(
-        self, hc_system: str, round_score_expected: tuple[float, list[float]]
+        self, hc_system: str, round_score_expected: float
     ) -> None:
         """
         Check appropriate expected round scores are returned for rounding.
@@ -533,10 +573,13 @@ def test_rounded_round_score(
         )
 
         assert (
-            hc_eq.score_for_round(
-                test_round, 20.0, hc_system, hc_eq.HcParams(), None, True
-            )
-            == round_score_expected[0]
+            hc.score_for_round(20.0, test_round, hc_system, None, True)
+            == round_score_expected
+        )
+
+        hc_sys = hc.handicap_scheme(hc_system)
+        assert (
+            hc_sys.score_for_round(20.0, test_round, None, True) == round_score_expected
         )
 
 
@@ -576,45 +619,8 @@ class TestHandicapFromScore:
         Currently no easily available data
     """
 
-    @pytest.mark.parametrize(
-        "testround,hc_system,int_prec,handicap_expected",
-        [
-            (metric122_30, "AGB", True, 11),
-            (metric122_30, "AA", True, 107),
-            # (metric122_30, "AA2", True, 107),
-            # ------------------------------
-            (western, "AGB", False, 9.89),
-            (western, "AGBold", True, 6),
-            # ------------------------------
-            (vegas300, "AGB", True, 3),
-            (vegas300, "AA", False, 118.38),
-            # (vegas300, "AA2", True, 119),
-        ],
-    )
-    def test_get_max_score_handicap(
-        self,
-        testround: Round,
-        hc_system: str,
-        int_prec: bool,
-        handicap_expected: float,
-    ) -> None:
-        """
-        Check that get_max_score_handicap() returns expected handicap.
-        """
-        handicap = hc_func.get_max_score_handicap(
-            testround,
-            hc_system,
-            hc_params,
-            None,
-            int_prec,
-        )
-
-        assert pytest.approx(handicap) == handicap_expected
-
     def test_score_over_max(self) -> None:
-        """
-        Check that handicap_from_score() returns error value for too large score.
-        """
+        """Check that handicap_from_score() returns error value for too large score."""
         with pytest.raises(
             ValueError,
             match=(
@@ -630,12 +636,10 @@ def test_score_over_max(self) -> None:
                 ],
             )
 
-            hc_func.handicap_from_score(9999, test_round, "AGB", hc_params)
+            hc.handicap_from_score(9999, test_round, "AGB")
 
     def test_score_of_zero(self) -> None:
-        """
-        Check that handicap_from_score() returns error for zero score.
-        """
+        """Check that handicap_from_score() returns error for zero score."""
         with pytest.raises(
             ValueError,
             match=(
@@ -651,12 +655,10 @@ def test_score_of_zero(self) -> None:
                 ],
             )
 
-            hc_func.handicap_from_score(0, test_round, "AGB", hc_params)
+            hc.handicap_from_score(0, test_round, "AGB")
 
     def test_score_below_zero(self) -> None:
-        """
-        Check that handicap_from_score() returns error for negative score.
-        """
+        """Check that handicap_from_score() returns error for negative score."""
         with pytest.raises(
             ValueError,
             match=(
@@ -672,7 +674,7 @@ def test_score_below_zero(self) -> None:
                 ],
             )
 
-            hc_func.handicap_from_score(-9999, test_round, "AGB", hc_params)
+            hc.handicap_from_score(-9999, test_round, "AGB")
 
     @pytest.mark.parametrize(
         "hc_system,testround,max_score,handicap_expected",
@@ -696,21 +698,42 @@ def test_maximum_score(
         max_score: float,
         handicap_expected: float,
     ) -> None:
-        """
-        Check correct handicap returned for max score.
-        """
-
-        handicap = hc_func.handicap_from_score(
+        """Check correct handicap returned for max score."""
+        handicap = hc.handicap_from_score(
             max_score,
             testround,
             hc_system,
-            hc_params,
             None,
             True,
         )
 
         assert handicap == handicap_expected
 
+    @pytest.mark.parametrize(
+        "hc_system,testround,max_score,handicap_expected",
+        [
+            ("AGB", metric122_30, 720, 11.75),
+            ("AA", metric122_30, 720, 106.98),
+        ],
+    )
+    def test_maximum_score_float(
+        self,
+        hc_system: str,
+        testround: Round,
+        max_score: float,
+        handicap_expected: float,
+    ) -> None:
+        """Check correct handicap returned for max score without int_prec=True."""
+        handicap = hc.handicap_from_score(
+            max_score,
+            testround,
+            hc_system,
+            None,
+            False,
+        )
+
+        assert handicap == pytest.approx(handicap_expected)
+
     @pytest.mark.parametrize(
         "hc_system,testround,testscore,handicap_expected",
         [
@@ -761,14 +784,11 @@ def test_int_precision(
         testscore: float,
         handicap_expected: float,
     ) -> None:
-        """
-        Check correct handicap returned for various scores.
-        """
-        handicap = hc_func.handicap_from_score(
+        """Check correct handicap returned for various scores."""
+        handicap = hc.handicap_from_score(
             testscore,
             testround,
             hc_system,
-            hc_params,
             None,
             True,
         )
@@ -806,11 +826,10 @@ def test_decimal(
         Deliberate changes to the schemes may affect these values and require changes.
 
         """
-        handicap = hc_func.handicap_from_score(
+        handicap = hc.handicap_from_score(
             testscore,
             testround,
             hc_system,
-            hc_params,
             None,
             False,
         )
diff --git a/archeryutils/tests/test_rounds.py b/archeryutils/tests/test_rounds.py
index 4e7f398..f337bf4 100644
--- a/archeryutils/tests/test_rounds.py
+++ b/archeryutils/tests/test_rounds.py
@@ -196,7 +196,7 @@ def test_repr(self) -> None:
         Check Pass string representation
         """
         test_round = Round("Name", [Pass(36, _target)])
-        expected = "Round('Name')"
+        expected = "<Round: 'Name'>"
         assert repr(test_round) == expected
 
     @pytest.mark.parametrize(