From 6f9cfb62b535d76b07e0f6a8f8721c7ebab53f4f Mon Sep 17 00:00:00 2001 From: Jonathan Harker Date: Tue, 24 Sep 2024 19:06:20 +1200 Subject: [PATCH] Add inversions, is_symmetrical, z_relations, split test files --- setclass/setclass.py | 38 ++++- setclass/tests/test_aggregate.py | 60 ++++++++ setclass/tests/test_cardinalities.py | 47 ++++++ setclass/tests/test_empty.py | 56 ++++++++ setclass/tests/test_setclass.py | 205 ++++----------------------- 5 files changed, 221 insertions(+), 185 deletions(-) create mode 100644 setclass/tests/test_aggregate.py create mode 100644 setclass/tests/test_cardinalities.py create mode 100644 setclass/tests/test_empty.py diff --git a/setclass/setclass.py b/setclass/setclass.py index 5aabdcf..dc62532 100644 --- a/setclass/setclass.py +++ b/setclass/setclass.py @@ -48,7 +48,8 @@ class SetClass(list): def __repr__(self) -> str: """Return the Python instance string representation.""" - return f"SetClass{set(self.pitch_classes) or '{}'}".replace(' ', '') + s = f"SetClass{{{','.join(str(i) for i in self.pitch_classes)}}}" + return s if self.tonality == 12 else f"{s} (T={self.tonality})" def __hash__(self) -> int: return sum([hash(i) for i in self.pitch_classes]) @@ -90,7 +91,16 @@ class SetClass(list): return intervals @cache_property - def interval_vector(self): + def z_relations(self): + """ + Return all distinct set classes with the same interval vector (Allan Forte: "Z-related"). + For example, Forte 4-Z15 {0,1,4,6} and Forte 4-Z29 {0,1,3,7} both have iv⟨1,1,1,1,1,1⟩ but + are not inversions, complements, or transpositions (rotations) of each other. + """ + return [i for i in SetClass.darkest_of_cardinality(self.cardinality) if i.interval_vector == self.interval_vector] + + @cache_property + def interval_vector(self) -> list: """ An ordered tuple containing the multiplicities of each interval class in the set class. Denoted in angle-brackets, e.g. @@ -125,7 +135,7 @@ class SetClass(list): def versions(self): """ Returns all possible zero-normalised versions (clock rotations) of this set class, - sorted by brightness. + sorted by brightness. See Rahn (1987) Set types, Tₙ """ # The empty set class has one version, itself if not self.pitch_classes: @@ -133,8 +143,8 @@ class SetClass(list): versions = set() for i in self.pitch_classes: - translate = self.tonality - i - versions.add(SetClass(*[j + translate for j in self.pitch_classes], tonality=self.tonality)) + transpose = self.tonality - i + versions.add(SetClass(*[j + transpose for j in self.pitch_classes], tonality=self.tonality)) versions = list(versions) versions.sort(key=lambda x: x.brightness) return versions @@ -155,6 +165,22 @@ class SetClass(list): """ return self.versions[-1] if self.versions else self + @cache_property + def inversion(self): + """ + Returns the inversion of this set class, equivalent of reflection through the 0 axis on a + clock diagram. + """ + return SetClass(*[self.tonality - i for i in self.pitch_classes], tonality=self.tonality) + + @cache_property + def is_symmetrical(self): + """ + Returns whether this set class is symmetrical upon inversion, for example Forte 5-Z17: + {0,1,2,5,9} → {0,4,8,9,11} + """ + return self.darkest_form == self.inversion.darkest_form + @cache_property def complement(self): """ @@ -217,7 +243,7 @@ class SetClass(list): if tonality < cardinality: return ValueError("Set classes of cardinality {cardinality} are not possible in a {tonality}-tone octave.") - a = set(SetClass(*i) for i in _powerset(range(tonality)) if len(i) == cardinality and 0 in i) + a = set(SetClass(*i, tonality=tonality) for i in _powerset(range(tonality)) if len(i) == cardinality and 0 in i) return a def darkest_of_cardinality(cardinality: int, tonality: int = 12, prime: bool = False) -> set: diff --git a/setclass/tests/test_aggregate.py b/setclass/tests/test_aggregate.py new file mode 100644 index 0000000..0541071 --- /dev/null +++ b/setclass/tests/test_aggregate.py @@ -0,0 +1,60 @@ +from setclass.setclass import SetClass + + +# ---------------------------------------------------------------------------- +# Forte 12-1, the complement of empty is the "aggregate" set class +f121 = SetClass().complement + + +def test_empty_complement(): + assert f121 == SetClass(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) + + +def test_empty_complement_tonality(): + assert f121.tonality == 12 + + +def test_empty_complement_cardinality(): + assert f121.cardinality == 12 + + +def test_empty_complement_brightness(): + assert f121.brightness == 66 + + +def test_empty_complement_pitch_classes(): + assert len(f121) == 12 + assert len(f121.pitch_classes) == 12 + + +def test_empty_complement_adjacency_intervals(): + assert len(f121.adjacency_intervals) == 12 + + +def test_empty_complement_versions(): + assert len(f121.versions) == 1 + assert f121.versions[0] == f121 + + +def test_empty_complement_brightest_form(): + assert f121.brightest_form == f121 + + +def test_empty_complement_darkest_form(): + assert f121.darkest_form == f121 + + +def test_empty_complement_bright_dark_forms_equal(): + assert f121.brightest_form == f121.darkest_form + + +def test_empty_complement_duodecimal_notation(): + assert f121.duodecimal_notation == 'SetClass{0,1,2,3,4,5,6,7,8,9,T,E}' + + +def test_empty_complement_dozenal_notation(): + assert f121.dozenal_notation == 'SetClass{0,1,2,3,4,5,6,7,8,9,↊,↋}' + + +def test_empty_complement_leonard_notation(): + assert f121.leonard_notation == '[0₁1₁2₁3₁4₁5₁6₁7₁8₁9₁10₁11₁]⁽⁶⁶⁾' diff --git a/setclass/tests/test_cardinalities.py b/setclass/tests/test_cardinalities.py new file mode 100644 index 0000000..c9c5e79 --- /dev/null +++ b/setclass/tests/test_cardinalities.py @@ -0,0 +1,47 @@ +from setclass.setclass import SetClass + + +def test_all_of_cardinality_set_lengths(): + "Confirm expected lengths of the sets of set classes, for cardinality 1 through 12" + sets = [SetClass.all_of_cardinality(i + 1) for i in range(12)] + assert [len(s) for s in sets] == [1, 11, 55, 165, 330, 462, 462, 330, 165, 55, 11, 1] + + +def test_darkest_of_cardinality_set_lengths(): + "Confirm expected lengths of the sets of set classes, for cardinality 1 through 12" + sets = [SetClass.darkest_of_cardinality(i + 1) for i in range(12)] + assert [len(s) for s in sets] == [1, 6, 22, 55, 66, 127, 66, 55, 22, 6, 1, 1] + + +def test_all_of_cardinality(): + s3 = SetClass.all_of_cardinality(3) + s6 = SetClass.all_of_cardinality(6) + assert len(s3) == 55 + assert len(s6) == 462 + + # all versions (rotations) of each set class are present + assert SetClass(0, 1, 2) in s3 + assert SetClass(0, 10, 11) in s3 + + assert SetClass(0, 4, 6) in s3 + assert SetClass(0, 6, 10) in s3 + + assert SetClass(0, 1, 3, 4, 8, 10) in s6 + assert SetClass(0, 4, 6, 8, 9, 11) in s6 + + +def test_darkest_of_cardinality(): + s3 = SetClass.darkest_of_cardinality(3) + s6 = SetClass.darkest_of_cardinality(6) + assert len(s3) == 22 + assert len(s6) == 127 + + # only one version (rotation) of each set class is present + assert SetClass(0, 1, 2) in s3 + assert SetClass(0, 10, 11) not in s3 + + assert SetClass(0, 4, 6) in s3 + assert SetClass(0, 6, 10) not in s3 + + assert SetClass(0, 1, 3, 4, 8, 10) in s6 + assert SetClass(0, 4, 6, 8, 9, 11) not in s6 diff --git a/setclass/tests/test_empty.py b/setclass/tests/test_empty.py new file mode 100644 index 0000000..8532fee --- /dev/null +++ b/setclass/tests/test_empty.py @@ -0,0 +1,56 @@ +from setclass.setclass import SetClass + + +# ---------------------------------------------------------------------------- +# Forte 0-1, the empty set class +empty = SetClass() + + +def test_empty_tonality(): + assert empty.tonality == 12 + + +def test_empty_cardinality(): + assert empty.cardinality == 0 + + +def test_empty_brightness(): + assert empty.brightness == 0 + + +def test_empty_pitch_classes(): + assert len(empty) == 0 + assert len(empty.pitch_classes) == 0 + + +def test_empty_ajdacency_intervals(): + assert len(empty.adjacency_intervals) == 0 + + +def test_empty_versions(): + assert len(empty.versions) == 1 + assert empty.versions[0] == empty + + +def test_empty_brightest_form(): + assert empty.brightest_form == empty + + +def test_empty_darkest_form(): + assert empty.darkest_form == empty + + +def test_empty_bright_dark_forms_equal(): + assert empty.brightest_form == empty.darkest_form + + +def test_empty_duodecimal_notation(): + assert empty.duodecimal_notation == 'SetClass{}' + + +def test_empty_dozenal_notation(): + assert empty.dozenal_notation == 'SetClass{}' + + +def test_empty_leonard_notation(): + assert empty.leonard_notation == '[]⁽⁰⁾' diff --git a/setclass/tests/test_setclass.py b/setclass/tests/test_setclass.py index ad3cbac..8f8b4f7 100644 --- a/setclass/tests/test_setclass.py +++ b/setclass/tests/test_setclass.py @@ -5,24 +5,28 @@ from setclass.setclass import SetClass # Functional tests -def test_zero_normalised_intervals(): +def test_zero_normalised_pc(): + "Pitch classes are normalised to start at zero" a = SetClass(0, 1, 3) assert a == SetClass(1, 2, 4) assert a == SetClass(4, 5, 7) -def test_modulo_intervals(): +def test_modulo_pc(): + "Pitch classes are normalised to modulo tonality (12)" a = SetClass(0, 1, 3) assert a == SetClass(12, 13, 15) # modulo down assert a == SetClass(-11, -9, -12) # modulo up -def test_negative_intervals(): +def test_negative_pc(): + "Negative pitch classes are modulo tonality (12)" a = SetClass(0, 9, 10) assert a == SetClass(-3, -2, 0) -def test_immutable_intervals(): +def test_unordered_pc(): + "Set classes are unordered; normalised to incremental sort" a = SetClass(0, 1, 3) assert a == SetClass(0, 3, 1) assert a == SetClass(3, 0, 1) @@ -31,7 +35,8 @@ def test_immutable_intervals(): assert a == SetClass(3, 1, 0) -def test_repeat_intervals(): +def test_resolve_duplicate_pc(): + "Set classes are sets, so duplicates (including after modulo) are eliminated" a = SetClass(0, 1, 3) assert a == SetClass(0, 1, 3, 3, 1) assert a == SetClass(0, 12, 1, -11, 15) @@ -39,8 +44,9 @@ def test_repeat_intervals(): def test_interval_vector(): + "Set classes have an interval vector" test = [ - # pairs of set class, expected interval vector + # 2-tuple of set class, expected interval vector (SetClass(0, 3, 4, 7, 8, 11), [3, 0, 3, 6, 3, 0]), (SetClass(0, 1, 3, 5, 6, 8, 10), [2, 5, 4, 3, 6, 1]), ] @@ -48,19 +54,16 @@ def test_interval_vector(): assert sc.interval_vector == iv -def test_interval_vector_invarance(): +def test_interval_vector_invarant(): + "The interval vector is invariant under inversion and transposition (rotation)" test = [ - SetClass(0, 1, 3, 5, 6, 8, 10), - SetClass(0, 1, 3, 5, 7, 8, 10), - SetClass(0, 2, 3, 5, 7, 8, 10), - SetClass(0, 2, 3, 5, 7, 9, 10), - SetClass(0, 2, 4, 5, 7, 9, 10), - SetClass(0, 2, 4, 5, 7, 9, 11), - SetClass(0, 2, 4, 6, 7, 9, 11), + # 2-tuple of set class, expected interval vector + (SetClass(0, 3, 4, 7, 8, 11), [3, 0, 3, 6, 3, 0]), + (SetClass(0, 1, 3, 5, 6, 8, 10), [2, 5, 4, 3, 6, 1]), ] - iv = [2, 5, 4, 3, 6, 1] - for sc in test: - assert sc.interval_vector == iv + for (sc, iv) in test: + for version in (sc.versions + sc.inversion.versions): + assert version.interval_vector == iv # ---------------------------------------------------------------------------- @@ -69,27 +72,33 @@ f520 = SetClass(0, 1, 5, 6, 8) def test_tonality(): + "Set classes has a default tonality of 12" assert f520.tonality == 12 def test_cardinality(): + "Set classes has a set cardinality" assert f520.cardinality == 5 def test_brightness(): + "Set classes have a brightness (sum of normalised pitch class values)" assert f520.brightness == 20 def test_pitch_classes(): + "Set classes have pitch classes" assert len(f520) == 5 assert len(f520.pitch_classes) == 5 def test_adjacency_intervals(): + "Set classes have a set of adjacency intervals" assert len(f520.adjacency_intervals) == 5 def test_versions(): + "Set classes have a set of transpositions (rotations)" assert len(f520.versions) == 5 @@ -159,165 +168,3 @@ def test_complement_darkest_form(): def test_complement_leonard_notation(): assert f520.complement.leonard_notation == '[0₁1₁2₃5₂7₁8₁9₃]⁽³²⁾' - - -# ---------------------------------------------------------------------------- -# Forte 0-1, the empty set class -empty = SetClass() - - -def test_empty_tonality(): - assert empty.tonality == 12 - - -def test_empty_cardinality(): - assert empty.cardinality == 0 - - -def test_empty_brightness(): - assert empty.brightness == 0 - - -def test_empty_pitch_classes(): - assert len(empty) == 0 - assert len(empty.pitch_classes) == 0 - - -def test_empty_ajdacency_intervals(): - assert len(empty.adjacency_intervals) == 0 - - -def test_empty_versions(): - assert len(empty.versions) == 1 - assert empty.versions[0] == empty - - -def test_empty_brightest_form(): - assert empty.brightest_form == empty - - -def test_empty_darkest_form(): - assert empty.darkest_form == empty - - -def test_empty_bright_dark_forms_equal(): - assert empty.brightest_form == empty.darkest_form - - -def test_empty_duodecimal_notation(): - assert empty.duodecimal_notation == 'SetClass{}' - - -def test_empty_dozenal_notation(): - assert empty.dozenal_notation == 'SetClass{}' - - -def test_empty_leonard_notation(): - assert empty.leonard_notation == '[]⁽⁰⁾' - - -# ---------------------------------------------------------------------------- -# Forte 12-1, the complement of empty is the "aggregate" set class -f121 = empty.complement - - -def test_empty_complement(): - assert f121 == SetClass(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) - - -def test_empty_complement_tonality(): - assert f121.tonality == 12 - - -def test_empty_complement_cardinality(): - assert f121.cardinality == 12 - - -def test_empty_complement_brightness(): - assert f121.brightness == 66 - - -def test_empty_complement_pitch_classes(): - assert len(f121) == 12 - assert len(f121.pitch_classes) == 12 - - -def test_empty_complement_adjacency_intervals(): - assert len(f121.adjacency_intervals) == 12 - - -def test_empty_complement_versions(): - assert len(f121.versions) == 1 - assert f121.versions[0] == f121 - - -def test_empty_complement_brightest_form(): - assert f121.brightest_form == f121 - - -def test_empty_complement_darkest_form(): - assert f121.darkest_form == f121 - - -def test_empty_complement_bright_dark_forms_equal(): - assert f121.brightest_form == f121.darkest_form - - -def test_empty_complement_duodecimal_notation(): - assert f121.duodecimal_notation == 'SetClass{0,1,2,3,4,5,6,7,8,9,T,E}' - - -def test_empty_complement_dozenal_notation(): - assert f121.dozenal_notation == 'SetClass{0,1,2,3,4,5,6,7,8,9,↊,↋}' - - -def test_empty_complement_leonard_notation(): - assert f121.leonard_notation == '[0₁1₁2₁3₁4₁5₁6₁7₁8₁9₁10₁11₁]⁽⁶⁶⁾' - - -# ---------------------------------------------------------------------------- - -def test_all_of_cardinality_set_lengths(): - "Confirm expected lengths of the sets of set classes, for cardinality 1 through 12" - sets = [SetClass.all_of_cardinality(i + 1) for i in range(12)] - assert [len(s) for s in sets] == [1, 11, 55, 165, 330, 462, 462, 330, 165, 55, 11, 1] - - -def test_darkest_of_cardinality_set_lengths(): - "Confirm expected lengths of the sets of set classes, for cardinality 1 through 12" - sets = [SetClass.darkest_of_cardinality(i + 1) for i in range(12)] - assert [len(s) for s in sets] == [1, 6, 22, 55, 66, 127, 66, 55, 22, 6, 1, 1] - - -def test_all_of_cardinality(): - s3 = SetClass.all_of_cardinality(3) - s6 = SetClass.all_of_cardinality(6) - assert len(s3) == 55 - assert len(s6) == 462 - - # all versions (rotations) of each set class are present - assert SetClass(0, 1, 2) in s3 - assert SetClass(0, 10, 11) in s3 - - assert SetClass(0, 4, 6) in s3 - assert SetClass(0, 6, 10) in s3 - - assert SetClass(0, 1, 3, 4, 8, 10) in s6 - assert SetClass(0, 4, 6, 8, 9, 11) in s6 - - -def test_darkest_of_cardinality(): - s3 = SetClass.darkest_of_cardinality(3) - s6 = SetClass.darkest_of_cardinality(6) - assert len(s3) == 22 - assert len(s6) == 127 - - # only one version (rotation) of each set class is present - assert SetClass(0, 1, 2) in s3 - assert SetClass(0, 10, 11) not in s3 - - assert SetClass(0, 4, 6) in s3 - assert SetClass(0, 6, 10) not in s3 - - assert SetClass(0, 1, 3, 4, 8, 10) in s6 - assert SetClass(0, 4, 6, 8, 9, 11) not in s6