From e46da363e0e807bf493fbb9edd3a47bf4f6c72f9 Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 12:10:22 +0100 Subject: [PATCH 01/14] feat: imlpement generic Adjuster class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adjusters will be used to modify a list of Features. This could either be: - to modify the overall set (eg to target a time) - to modify individual Features The most important Adjuster will be one that targets an overall time, eg: "modify this list of Features such that their times add up to 1 minute (either ± a % or a hard limit)" @see: feature_extractors.py::FeatureExtractor --- pipeline/adjusters.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 pipeline/adjusters.py diff --git a/pipeline/adjusters.py b/pipeline/adjusters.py new file mode 100644 index 0000000..cb97a7a --- /dev/null +++ b/pipeline/adjusters.py @@ -0,0 +1,25 @@ +"""adjusters.py -- adjust the gathered Features + +This is usually done to either modify to reduce the Features in some way. + +For example: + + - TargetTimeAdjuster: drop Features until the target time is reached + - FeatureCountAdjuster: drop Features until the target number of Features is reached + +TODO: Consider eg a generic PredicateAdjuster -- supply a predicate/lambda that will be used to determine whether to keep a Feature or not. +""" + +class Adjuster(): + """Generic Adjuster class. Expects a list of Features and returns a list of Features.""" + + def __init__(self, features: list=[]): + """Initialize the Adjuster with Features. + + NOTE: an empty feature list is permitted, since a FeatureExtractor may not produce features. Adjusters subclassing should be aware of this. + """ + self.features = features + + + def adjust(self): + """Adjust the Features. Override this method in subclasses.""" From b025f0ea10ca135bcf75a9ab24467e6b047a99bc Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 13:35:28 +0100 Subject: [PATCH 02/14] feat: [wip] TargetTimeAdjuster set up structure & helpers TargetTimeAdjuster will adjust a list of Features until it is within an optional margin of a target total duration. Helper functions: - _determine_margin() :: figure out the max and min cutoff times, considering margin and margin strategy (percent / absolute) - _features_total_time() :: basic sum of list of Features' durations TODO: rename to TargetDurationAdjuster ? rename 'strategy' ?? --- pipeline/adjusters.py | 87 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/pipeline/adjusters.py b/pipeline/adjusters.py index cb97a7a..97afeb3 100644 --- a/pipeline/adjusters.py +++ b/pipeline/adjusters.py @@ -10,6 +10,8 @@ For example: TODO: Consider eg a generic PredicateAdjuster -- supply a predicate/lambda that will be used to determine whether to keep a Feature or not. """ +from enum import Enum + class Adjuster(): """Generic Adjuster class. Expects a list of Features and returns a list of Features.""" @@ -21,5 +23,88 @@ class Adjuster(): self.features = features - def adjust(self): + def adjust(self) -> list: """Adjust the Features. Override this method in subclasses.""" + return self.features + + +class TargetTimeAdjuster(Adjuster): + """Adjuster that drops Features until the target time is reached.""" + + _STRATEGY = Enum("MarginStrategy", ["ABSOLUTE", "PERCENT"]) + _DEFAULT_TARGET_TIME = 60.0 # 1 minute + _DEFAULT_MARGIN = 10 # can be percent or absolute value + + def _determine_margin(self, time: float, margin: float, strategy: _STRATEGY) -> tuple: + """Determine the target time margins. + + If the strategy is ABSOLUTE, the margin is a fixed value in seconds. + If the strategy is PERCENT, the margin is a percentage of the target time. + + Returns a tuple of (min, max) times. + + Pulled out for unit testing + """ + target_time_min = target_time_max = None + + if strategy == self._STRATEGY.ABSOLUTE: + # both specified in seconds + target_time_min = time - margin + target_time_max = time + margin + elif strategy == self._STRATEGY.PERCENT: + target_time_max = time + (time * margin / 100) + target_time_min = time - (time * margin / 100) + + return (target_time_min, target_time_max) + + def _features_total_time(self, features: list) -> float: + """Calculate the total duration of all Features. + + Returns the total time in seconds. + + Pulled out for unit testing. + """ + return float(sum([x.duration for x in features])) + + def __init__(self, features: list=[], + target_time: int|float=_DEFAULT_TARGET_TIME, + margin: int|float=_DEFAULT_MARGIN, + strategy=_STRATEGY.ABSOLUTE): + """Initialize the Adjuster with Features and a target time. + + Default target time is 60 seconds (1 minute). Even if the desired target time is 60s exactly, it is recommended to specify it explicitly. + """ + super().__init__(features) + self.target_time = float(target_time) + self.margin = float(margin) + self.strategy = strategy + + def adjust(self) -> list: + """Drop Features until the target time within the margin is reached. + + Approach: + + Sort list of Features by score (primary) and by time (secondary). + Drop lowest scoring Features until the target time is reached; + if dropping a Feature would result in missing the margin, skip dropping that Feature + if no Features can be dropped without missing the margin, + drop the lowest scoring Feature until we are under the target time (with margin) + + Returns a list of Features, and also modifies the internal list of Features. + """ + # check for early exit + if not self.features: + return [] + + # figure out our margins + target_time_min, target_time_max = self._determine_margin(self.target_time, self.margin, self.strategy) + + # calculate total time of all Features + total_time = self._features_total_time(features=self.features) + + # if we are already within the target time, return the Features as-is + if total_time <= target_time_max: + return self.features + + # sort list of Features by score (primary) and by duration (secondary) + sorted_features = sorted(self.features, key=lambda x: (x.score, x.time)) From 62932f6b087d393107eb333346b4fa5139bd5f74 Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 13:38:54 +0100 Subject: [PATCH 03/14] test: add test of generic Adjuster base class --- test/test_adjusters.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 test/test_adjusters.py diff --git a/test/test_adjusters.py b/test/test_adjusters.py new file mode 100644 index 0000000..4a6e283 --- /dev/null +++ b/test/test_adjusters.py @@ -0,0 +1,17 @@ +"""test_adjusters.py -- test pipeline Adjusters (eg TargetTimeAdjuster)""" +import unittest +import pipeline.adjusters as adjusters + +class TestAdjuster(unittest.TestCase): + """Test the generic Adjuster class""" + + def test_init(self): + """Test the Adjuster can be initialised""" + adjuster = adjusters.Adjuster() + self.assertEqual(adjuster.features, []) + + def test_adjust(self): + """Test the generic adjust""" + adjuster = adjusters.Adjuster() + self.assertEqual(adjuster.adjust(), []) + self.assertEqual(adjuster.features, []) From 43fe240c40ed458a87655b77c267f47fa1c45df0 Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 13:39:40 +0100 Subject: [PATCH 04/14] test: TargetTimeAdjuster - test init and _features_total_time --- test/test_adjusters.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/test_adjusters.py b/test/test_adjusters.py index 4a6e283..f03b7d2 100644 --- a/test/test_adjusters.py +++ b/test/test_adjusters.py @@ -1,5 +1,6 @@ """test_adjusters.py -- test pipeline Adjusters (eg TargetTimeAdjuster)""" import unittest +import unittest.mock as mock import pipeline.adjusters as adjusters class TestAdjuster(unittest.TestCase): @@ -15,3 +16,28 @@ class TestAdjuster(unittest.TestCase): adjuster = adjusters.Adjuster() self.assertEqual(adjuster.adjust(), []) self.assertEqual(adjuster.features, []) + +class TestTargetTimeAdjuster(unittest.TestCase): + """Test the TargetTimeAdjuster + + TTA drops Features until the target time is reached (or within a margin)""" + + def test_init(self): + """Test the TTA can be initialised""" + tta = adjusters.TargetTimeAdjuster() + self.assertEqual(tta.features, []) + + def test_features_total_time(self): + """Test the TTA can calculate the total time of Features + + Test: + - input duration floats: 1.0, 2.0, 3.0, 4.0 == 10.0 + """ + tta = adjusters.TargetTimeAdjuster() + features = [] + for i in range(1, 5): + features.append(mock.Mock(duration=i*1.0)) + + self.assertEqual(tta._features_total_time(features), 10.0) + self.assertEqual(tta._features_total_time([]), 0.0) + self.assertIs(type(tta._features_total_time([])), float) From 15ca7cb6fa0fc30901777f564d2e52a6b816bd76 Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 13:46:22 +0100 Subject: [PATCH 05/14] fix: negative total durations should not be possible --- pipeline/adjusters.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pipeline/adjusters.py b/pipeline/adjusters.py index 97afeb3..ec97595 100644 --- a/pipeline/adjusters.py +++ b/pipeline/adjusters.py @@ -55,6 +55,10 @@ class TargetTimeAdjuster(Adjuster): target_time_max = time + (time * margin / 100) target_time_min = time - (time * margin / 100) + # ensure we don't have negative times + if type(target_time_min) is float and target_time_min < 0: + target_time_min = 0.0 + return (target_time_min, target_time_max) def _features_total_time(self, features: list) -> float: From 4ffe712ce25859a4951199bcd5d2e6f84fd29b85 Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 13:47:10 +0100 Subject: [PATCH 06/14] test: [TTA] add unit tests for _determine_margin -- ABSOLUTE --- test/test_adjusters.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/test_adjusters.py b/test/test_adjusters.py index f03b7d2..7e0f40b 100644 --- a/test/test_adjusters.py +++ b/test/test_adjusters.py @@ -41,3 +41,36 @@ class TestTargetTimeAdjuster(unittest.TestCase): self.assertEqual(tta._features_total_time(features), 10.0) self.assertEqual(tta._features_total_time([]), 0.0) self.assertIs(type(tta._features_total_time([])), float) + + def test_determine_margin(self): + """Test the TTA can determine the target time margins + + Args: time, margin, strategy (strategy in: ABSOLUTE, PERCENT) + + Test: + - margin of zero + - margin of 5.0 + - margin of 10.0 + - margin of 100.0 + - both ABSOLUTE and PERCENT strategies + + TODO: figure out what should be done with negative margins & margins > 100.0 + """ + + tta = adjusters.TargetTimeAdjuster() + with self.subTest("ABSOLUTE"): + strategy = adjusters.TargetTimeAdjuster._STRATEGY.ABSOLUTE + test_cases = [] + # populate test cases with tuples of (time, margin, expected) + # zero margin + test_cases.append((60.0, 0.0, (60.0, 60.0))) + # margin of 5.0 + test_cases.append((60.0, 5.0, (55.0, 65.0))) + # margin of 10.0 + test_cases.append((60.0, 10.0, (50.0, 70.0))) + # margin of 100.0 + test_cases.append((60.0, 100.0, (0.0, 160.0))) + + # test + for time, margin, expected in test_cases: + self.assertEqual(tta._determine_margin(time, margin, strategy), expected) From 764083b0018c029d296b77efdbbdc995782e5100 Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 20:51:28 +0100 Subject: [PATCH 07/14] fix: duration is an attribute of Interval, not Feature --- pipeline/adjusters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipeline/adjusters.py b/pipeline/adjusters.py index ec97595..6da507c 100644 --- a/pipeline/adjusters.py +++ b/pipeline/adjusters.py @@ -68,7 +68,7 @@ class TargetTimeAdjuster(Adjuster): Pulled out for unit testing. """ - return float(sum([x.duration for x in features])) + return float(sum([x.interval.duration for x in features])) def __init__(self, features: list=[], target_time: int|float=_DEFAULT_TARGET_TIME, From f253250239ee4af3ad5db6fbcc61042c72206605 Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 21:16:07 +0100 Subject: [PATCH 08/14] feat: [TTA] add algo for dropping Features in adjust() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tries to drop the lowest-scoring Features until the target time (range) is reached. This is not optimised and a relatively naïve approach- there are many inputs which would result in a non-ideal pruning. --- pipeline/adjusters.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/pipeline/adjusters.py b/pipeline/adjusters.py index 6da507c..69a6672 100644 --- a/pipeline/adjusters.py +++ b/pipeline/adjusters.py @@ -84,7 +84,7 @@ class TargetTimeAdjuster(Adjuster): self.strategy = strategy def adjust(self) -> list: - """Drop Features until the target time within the margin is reached. + """Drop Features until the target time within the margin is reached. Prioritise dropping lower scoring Features. Approach: @@ -111,4 +111,36 @@ class TargetTimeAdjuster(Adjuster): return self.features # sort list of Features by score (primary) and by duration (secondary) - sorted_features = sorted(self.features, key=lambda x: (x.score, x.time)) + sorted_features = self._sort_by_score_time(self.features) + drop_indices = [] # indices of Features to drop + + # first pass- drop lowest scoring Features until we are within the target time + for i in range(len(sorted_features)): + # check if dropping this Feature would put us in the target range: + # if so, drop it and return + if (total_time - sorted_features[i].interval.duration >= target_time_min and + total_time - sorted_features[i].interval.duration <= target_time_max): + drop_indices.append(i) + break + + elif (total_time - sorted_features[i].interval.duration > target_time_max): + drop_indices.append(i) + total_time -= sorted_features[i].interval.duration + + for i in drop_indices: + self.features.remove(sorted_features[i]) + + # if we are now within the target time, return the Features + total_time = self._features_total_time(features=self.features) + if total_time <= target_time_max: + return self.features + + # else: we are still over the target time + # so drop the lowest scoring Features until we are UNDER the target time + for i in range(len(sorted_features)): + self.features.remove(sorted_features[i]) + total_time -= sorted_features[i].interval.duration + if total_time <= target_time_max: + break + + return self.features From 5365d7d1e2ff08498bfa0c0c3e5594e285a9ac2f Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 21:19:44 +0100 Subject: [PATCH 09/14] test: [_determine_margin] add TTA._STRATEGY.PERCENT unit tests Test _determine_margin() with margins specified as percentage --- test/test_adjusters.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/test_adjusters.py b/test/test_adjusters.py index 7e0f40b..756098b 100644 --- a/test/test_adjusters.py +++ b/test/test_adjusters.py @@ -74,3 +74,20 @@ class TestTargetTimeAdjuster(unittest.TestCase): # test for time, margin, expected in test_cases: self.assertEqual(tta._determine_margin(time, margin, strategy), expected) + + with self.subTest("PERCENT"): + strategy = adjusters.TargetTimeAdjuster._STRATEGY.PERCENT + test_cases = [] + # populate test cases with tuples of (time, margin, expected) as above + # zero margin + test_cases.append((60.0, 0.0, (60.0, 60.0))) + # margin of 5.0 + test_cases.append((60.0, 5.0, (57.0, 63.0))) + # margin of 10.0 + test_cases.append((60.0, 10.0, (54.0, 66.0))) + # margin of 100.0 + test_cases.append((60.0, 100.0, (0.0, 120.0))) + + # test + for time, margin, expected in test_cases: + self.assertEqual(tta._determine_margin(time, margin, strategy), expected) From e22ece92ba0a8d7457a7ae2d5ae4cc5087a3cf48 Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 21:20:59 +0100 Subject: [PATCH 10/14] test: [TTA: adjust] test adjustments that result in no change Cases: - no features - features under target duration - features equal to target duration --- test/test_adjusters.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/test_adjusters.py b/test/test_adjusters.py index 756098b..964979c 100644 --- a/test/test_adjusters.py +++ b/test/test_adjusters.py @@ -91,3 +91,31 @@ class TestTargetTimeAdjuster(unittest.TestCase): # test for time, margin, expected in test_cases: self.assertEqual(tta._determine_margin(time, margin, strategy), expected) + + def test_adjust_no_change(self): + """Test adjusting of list of Features using TTA -- no change to list of Features + + Cases: + - no Features --> [] + - [Features] with total time < target time --> unchanged list + - [Features] with total time = target time --> unchanged list + + TODO: test with Features > target + """ + with self.subTest("no Features"): + tta = adjusters.TargetTimeAdjuster() + self.assertEqual(tta.adjust(), []) + + with self.subTest("Features < target time"): + features = [] + for i in range(1, 5): + features.append(make_feature(duration=i*1.0)) + tta = adjusters.TargetTimeAdjuster(features=features, target_time=20.0) + self.assertEqual(tta.adjust(), features) + + with self.subTest("Features = target time"): + features = [] + for i in range(1, 5): + features.append(make_feature(duration=i*1.0)) + tta = adjusters.TargetTimeAdjuster(features=features, target_time=10.0) + self.assertEqual(tta.adjust(), features) From d0935f950fe3a401e1c3ae3c29994eba76d64b09 Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 21:28:48 +0100 Subject: [PATCH 11/14] feat: [TTA] _sort_by_score_time() Pull out a one-liner for test scenarios to facilitate testing adjust() --- pipeline/adjusters.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pipeline/adjusters.py b/pipeline/adjusters.py index 69a6672..f42feb2 100644 --- a/pipeline/adjusters.py +++ b/pipeline/adjusters.py @@ -70,6 +70,16 @@ class TargetTimeAdjuster(Adjuster): """ return float(sum([x.interval.duration for x in features])) + def _sort_by_score_time(self, features: list) -> list: + """Sort Features by score (primary) and by time (secondary). + + Returns a sorted list of Features. + + Pulled out for unit testing as RDH was having issues with adjust() + and wanted to verify sorting was working correctly. + """ + return sorted(features, key=lambda x: (x.score, x.interval.duration)) + def __init__(self, features: list=[], target_time: int|float=_DEFAULT_TARGET_TIME, margin: int|float=_DEFAULT_MARGIN, From dfb5cbd1effe0b5630d4c97d793b661e306e7bdf Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 21:29:59 +0100 Subject: [PATCH 12/14] test: add tests of sorting first by score then by time --- test/test_adjusters.py | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/test/test_adjusters.py b/test/test_adjusters.py index 964979c..1e2220f 100644 --- a/test/test_adjusters.py +++ b/test/test_adjusters.py @@ -119,3 +119,59 @@ class TestTargetTimeAdjuster(unittest.TestCase): features.append(make_feature(duration=i*1.0)) tta = adjusters.TargetTimeAdjuster(features=features, target_time=10.0) self.assertEqual(tta.adjust(), features) + + + def test_sort_by_score_time(self): + """Test sorting of list of Features by score (primary) and time (secondary) + + Cases: + - [(15.0, 1.0), (10.0, 1.0), (12.0, 1.0)] --> [(10.0, 1.0), (12.0, 1.0), (15.0, 1.0)] # score equal, sort by time + - [(15.0, 1.0), (10.0, 4.0), (12.0, 3.0)] --> [(15.0, 1.0), (12.0, 3.0), (10.0, 4.0)] # sort by score + - [(15.0, 1.0), (10.0, 1.0), (12.0, 2.0)] --> [(10.0, 1.0), (15.0, 1.0), (12.0, 2.0)] # mixed: scores below duration + - [] --> [] + - [(15.0, 1.0)] --> [(15.0, 1.0)] + + Cases giving RDH trouble: + - [(16.0, 1.0), (16.0, 1.0), (1.0, 1.0), (1.0, 1.0)] --> [(1.0, 1.0), (1.0, 1.0), (16.0, 1.0), (16.0, 1.0)] # multiple lowest scoring, multiple shortest duration + """ + + tta = adjusters.TargetTimeAdjuster() + with self.subTest("score equal, sort by duration"): + features = [ + make_feature(duration=15.0, score=1.0), + make_feature(duration=10.0, score=1.0), + make_feature(duration=12.0, score=1.0) + ] + self.assertEqual(tta._sort_by_score_time(features), [features[1], features[2], features[0]]) + + with self.subTest("sort by score, duration irrelevant"): + features = [ + make_feature(duration=15.0, score=1.0), + make_feature(duration=10.0, score=4.0), + make_feature(duration=12.0, score=3.0) + ] + self.assertEqual(tta._sort_by_score_time(features), [features[0], features[2], features[1]]) + + with self.subTest("mixed: scores below duration"): + features = [ + make_feature(duration=15.0, score=1.0), + make_feature(duration=10.0, score=1.0), + make_feature(duration=12.0, score=2.0) + ] + self.assertEqual(tta._sort_by_score_time(features), [features[1], features[0], features[2]]) + + with self.subTest("empty"): + self.assertEqual(tta._sort_by_score_time([]), []) + + with self.subTest("single"): + features = [mock.Mock(duration=15.0, score=1.0)] + self.assertEqual(tta._sort_by_score_time(features), features) + + with self.subTest("multiple lowest scoring, multiple shortest duration"): + features = [ + make_feature(duration=16.0, score=1.0), + make_feature(duration=16.0, score=1.0), + make_feature(duration=1.0, score=1.0), + make_feature(duration=1.0, score=1.0) + ] + self.assertEqual(tta._sort_by_score_time(features), [features[2], features[3], features[0], features[1]]) From 4e42137b33a0b4a048c9fddf6310a37b833317cf Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 21:48:59 +0100 Subject: [PATCH 13/14] test: [mocks] add convenience classmethod and __eq__ Facilitates unit tests of TTA --- test/mocks.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/mocks.py b/test/mocks.py index 4cb016c..9ca095e 100644 --- a/test/mocks.py +++ b/test/mocks.py @@ -7,9 +7,16 @@ class MockInterval(): self.end = end self.duration = end - start + @classmethod + def from_duration(cls, duration): + return cls(start=0, end=duration) + def to_json(self): return {"start": self.start, "end": self.end} + def __eq__(self, other): + return self.start == other.start and self.end == other.end + class MockFeature(): """Mock feature object for testing""" def __init__(self, interval, source=None, feature_extractor="mock", score=0.0): @@ -21,6 +28,10 @@ class MockFeature(): def to_json(self): return {"interval": self.interval} + def __eq__(self, other): + return (self.interval == other.interval and self.source == other.source + and self.feature_extractor == other.feature_extractor) + class MockSource(): """Mock Source object for testing Feature""" def __init__(self, source=None, path=None): From a793fe08ce2cd272bfb5170b1f48175a2ddd524e Mon Sep 17 00:00:00 2001 From: Rob Hallam <0504004h@student.gla.ac.uk> Date: Tue, 3 Sep 2024 21:50:06 +0100 Subject: [PATCH 14/14] test: [TTA] add coverage of adjust() Add test cases that require reducing the number of Features --- test/test_adjusters.py | 107 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/test/test_adjusters.py b/test/test_adjusters.py index 1e2220f..e718137 100644 --- a/test/test_adjusters.py +++ b/test/test_adjusters.py @@ -3,6 +3,8 @@ import unittest import unittest.mock as mock import pipeline.adjusters as adjusters +from test.mocks import MockFeature, MockInterval + class TestAdjuster(unittest.TestCase): """Test the generic Adjuster class""" @@ -36,7 +38,7 @@ class TestTargetTimeAdjuster(unittest.TestCase): tta = adjusters.TargetTimeAdjuster() features = [] for i in range(1, 5): - features.append(mock.Mock(duration=i*1.0)) + features.append(make_feature(duration=i*1.0)) self.assertEqual(tta._features_total_time(features), 10.0) self.assertEqual(tta._features_total_time([]), 0.0) @@ -175,3 +177,106 @@ class TestTargetTimeAdjuster(unittest.TestCase): make_feature(duration=1.0, score=1.0) ] self.assertEqual(tta._sort_by_score_time(features), [features[2], features[3], features[0], features[1]]) + + + def test_adjust_changes(self): + """Test adjusting of list of Features using TTA -- changes to list of Features + + All cases have total time > target time. + + In the cases, specification is Feature(duration, score) + Cases: + - target = 30.0, margin = 0.0 + + [(15.0, 1.0), (10.0, 1.0), (12.0, 1.0)] --> [(15.0, 1.0), (12.0, 1.0)] # scores equal, drop smallest + + [(15.0, 2.0), (10.0, 2.0), (12.0, 1.0)] --> [(15.0, 1.0), (10.0, 1.0)] # drop lowest scoring (1) + + [(15.0, 1.0), (10.0, 1.0), (12.0, 2.0)] --> [(15.0, 1.0), (12.0, 2.0)] # drop lowest scoring (2) + + - target = 30.0, margin = 4.0 + + [(15.0, 1.0), (10.0, 2.0), (12.0, 1.0)] --> [(15.0, 1.0), (12.0, 1.0)] # not lowest scoring, but within margin + + [(16.0, 1.0), (16.0, 1.0), (1.0, 1.0), (1.0, 1.0)] --> [(16.0, 1.0), (16.0, 1.0)] # drop multiple lowest scoring, shortest duration + + """ + # target 30.0, margin 0.0 cases + target, margin = 30.0, 0.0 + + with self.subTest(f"target {target} margin {margin}"): + with self.subTest("scores equal"): + features = [ + make_feature(duration=15.0, score=1.0), + make_feature(duration=10.0, score=1.0), + make_feature(duration=12.0, score=1.0) + ] + tta = adjusters.TargetTimeAdjuster(features=features, target_time=target, margin=margin) + expected = [features[0], features[2]] + output = tta.adjust() + self.assertEqual(len(output), 2) + self.assertEqual(output, expected) + self.assertEqual(tta.features, expected) + + with self.subTest("drop lowest scoring (1)"): + features = [ + make_feature(duration=15.0, score=2.0), + make_feature(duration=10.0, score=2.0), + make_feature(duration=12.0, score=1.0) + ] + tta = adjusters.TargetTimeAdjuster(features=features, target_time=target, margin=margin) + expected = [features[0], features[1]] + output = tta.adjust() + self.assertEqual(len(output), 2) + self.assertEqual(output, expected) + self.assertEqual(tta.features, expected) + + with self.subTest("drop lowest scoring (2)"): + features = [ + make_feature(duration=15.0, score=1.0), + make_feature(duration=10.0, score=1.0), + make_feature(duration=12.0, score=2.0) + ] + tta = adjusters.TargetTimeAdjuster(features=features, target_time=target, margin=margin) + expected = [features[0], features[2]] + output = tta.adjust() + self.assertEqual(len(output), 2) + self.assertEqual(output, expected) + self.assertEqual(tta.features, expected) + + # target 30.0, margin 4.0 cases + target, margin, strategy = 30.0, 4.0, adjusters.TargetTimeAdjuster._STRATEGY.ABSOLUTE + + with self.subTest(f"target {target} margin {margin}"): + with self.subTest("not lowest scoring, but within margin"): + # explanation: dropping the 10.0 feature would put us at 27.0, which is within the margin (26.0, 34.0) + features = [ + make_feature(duration=15.0, score=1.0), + make_feature(duration=10.0, score=2.0), + make_feature(duration=12.0, score=1.0) + ] + tta = adjusters.TargetTimeAdjuster(features=features, target_time=target, + margin=margin, strategy=strategy) + expected = [features[0], features[2]] + output = tta.adjust() + self.assertEqual(len(output), 2) + self.assertEqual(output, expected) + self.assertEqual(tta.features, expected) + + with self.subTest("drop multiple lowest scoring, shortest duration"): + # explanation: dropping the 1.0 features would put us at 32.0, which is within the margin (26.0, 34.0) + features = [ + make_feature(duration=16.0, score=1.0), + make_feature(duration=16.0, score=1.0), + make_feature(duration=1.0, score=1.0), + make_feature(duration=1.0, score=1.0), + make_feature(duration=1.0, score=1.0), + make_feature(duration=1.0, score=1.0) + ] + tta = adjusters.TargetTimeAdjuster(features=features, target_time=target, + margin=margin, strategy=strategy) + expected = [features[0], features[1], features[2], features[3]] + output = tta.adjust() + self.assertEqual(len(output), 4) + self.assertEqual(output, expected) + self.assertEqual(tta.features, expected) + + +def make_feature(duration, score=1.0): + """Helper function to create a MockFeature from duration and score""" + return MockFeature(interval=MockInterval.from_duration(duration), score=score)