Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#149](https://github.com/jatkinson1000/archeryutils/pull/149)
- Addition of new WA rounds for Under 15 category and updating of prestige rounds for
outdoor classifications accordingly in [#167](https://github.com/jatkinson1000/archeryutils/pull/167).
- Protection against repeated scores in classification tables in [#170](https://github.com/jatkinson1000/archeryutils/pull/170).
Only changes Under 12 Longbow New Warwick but guards for any future changes.

### Changed

Expand All @@ -36,6 +38,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Consistent treatment of AGB classifications where there are gaps in the handicap tables.
Outdoor and Field updated to match Indoor as part of 2026 Update in
[#168](https://github.com/jatkinson1000/archeryutils/pull/168)
- Score changes in Under 12 Longbow New Warwick removing repeated scores from
[#170](https://github.com/jatkinson1000/archeryutils/pull/170).

### Fixes

Expand Down
5 changes: 5 additions & 0 deletions archeryutils/classifications/agb_field_classifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,4 +595,9 @@ def agb_field_classification_scores(
else:
int_class_scores[i] += 1

# Finally, ensure that there are no repeated scores.
int_class_scores = cls_funcs.fix_repeated_scores(
int_class_scores, archery_round.max_score()
)

return int_class_scores
5 changes: 5 additions & 0 deletions archeryutils/classifications/agb_indoor_classifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,4 +491,9 @@ def agb_indoor_classification_scores(
else:
int_class_scores[i] += 1

# Finally, ensure that there are no repeated scores.
int_class_scores = cls_funcs.fix_repeated_scores(
int_class_scores, archery_round.max_score()
)

return int_class_scores
5 changes: 5 additions & 0 deletions archeryutils/classifications/agb_outdoor_classifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -741,4 +741,9 @@ def agb_outdoor_classification_scores(
else:
int_class_scores[i] += 1

# Finally, ensure that there are no repeated scores.
int_class_scores = cls_funcs.fix_repeated_scores(
int_class_scores, archery_round.max_score()
)

return int_class_scores
36 changes: 36 additions & 0 deletions archeryutils/classifications/classification_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,39 @@ def get_compound_codename(round_codename: str) -> str:
round_codename = convert_dict[round_codename]

return round_codename


def fix_repeated_scores(scores: list[int], max_score: float) -> list[int]:
"""
Check for repeated scores in a classification table and fix where needed.

Forces at least 1 point separation between classes, increasing where needed up to
maximum score.

Parameters
----------
scores : list[int]
A set of classification scores from high to low
max_score : float
The maximum possible score on the round in question

Returns
-------
list[int]
Amended classification scores with no repeated values
"""
for i in range(len(scores) - 1, 0, -1):
# If previous score is invalid then set the rest to invalid
if scores[i] < 0:
scores[i - 1] = -9999
# Is the next score (i-1) valid and equal (or below) the previous score (i)?
# If so handle. Previously
elif scores[i - 1] <= scores[i] and scores[i - 1] >= 0:
# If already at max score set rest to invalid
if scores[i] == max_score:
scores[i - 1] = -9999
# Increment previous score by 1 for new distinct threshold
else:
scores[i - 1] = scores[i] + 1

return scores
4 changes: 2 additions & 2 deletions tests/regression/__snapshots__/test_classifications.ambr

Large diffs are not rendered by default.

75 changes: 75 additions & 0 deletions tests/unit/classifications/test_classification_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for classification utilities."""

import numpy as np
import pytest

import archeryutils.classifications.classification_utils as class_utils
Expand Down Expand Up @@ -82,3 +83,77 @@ def test_strip_spots(
strippedname = class_utils.strip_spots(roundname)

assert strippedname == strippedname_expected


class TestScoreFixing:
"""Tests for the score fixing utilities."""

@pytest.mark.parametrize(
"scores,max_score,expected",
[
# Test case 1: No repeated scores
(
[100, 95, 90, 85, 80],
100,
[100, 95, 90, 85, 80],
),
# Test case 2: Repeated scores in middle
(
[100, 95, 90, 90, 80],
100,
[100, 95, 91, 90, 80],
),
# Test case 3: Multiple repeated scores
(
[100, 95, 90, 90, 90, 80],
100,
[100, 95, 92, 91, 90, 80],
),
# Test case 4: Repeated max scores
(
[100, 100, 100, 95, 90],
100,
[-9999, -9999, 100, 95, 90],
),
# Test case 5: All same scores
(
[80, 80, 80, 80],
100,
[83, 82, 81, 80],
),
# Test case 6: Scores at max
(
[100, 100, 100],
100,
[-9999, -9999, 100],
),
# Test case 7: Single score
(
[90],
100,
[90],
),
# Test case 8: Two identical scores
(
[85, 85],
100,
[86, 85],
),
# Test case 9: Real example that caught edge case in the code
(
[500, 499, 498, 497, 496, 425, 343, 259, 185],
500,
[500, 499, 498, 497, 496, 425, 343, 259, 185],
),
],
)
def test_fix_repeated_scores(
self,
scores: list[int],
max_score: float,
expected: list[int],
) -> None:
"""Test that fix_repeated_scores() correctly handles repeated scores."""
result = class_utils.fix_repeated_scores(scores, max_score)

assert result == expected
Loading