Add Forte prime form, fix darkest form and Rahn errors, use classmethods

- add the packed_left and prime_form property for Allen Forte prime
   forms
 - add type annotations
 - add from_string convenience class factory method
 - fix darkest_form to always produce the same result using left-packing
 - amend tests to correct values (eliminated duplicates in bright lists)
 - produce lists of cases where Rahn and Forte primes are not the
   "darkest" forms (smallest sum of pitch classes)
This commit is contained in:
Jonathan Harker 2024-09-27 17:57:09 +12:00
parent d6b4b07d86
commit 0bf0531fd6
3 changed files with 152 additions and 67 deletions

View file

@ -1,4 +1,7 @@
#!/usr/bin/env python3
from __future__ import annotations
import re
class cache_property:
"""
@ -19,7 +22,7 @@ class SetClass(list):
Musical set class, containing zero or more pitch classes.
"""
def __init__(self, *args: int, tonality: int = 12):
def __init__(self, *args: int, tonality: int = 12) -> None:
"""
Instantiate a Class Set with a series of integers. Each pitch class is "normalised" by
modulo the tonality; the set is sorted in ascending order; and values "rotated" until the
@ -57,7 +60,7 @@ class SetClass(list):
return sum([hash(i) for i in self.pitch_classes])
@property
def pitch_classes(self):
def pitch_classes(self) -> list:
return list(self)
@cache_property
@ -79,7 +82,7 @@ class SetClass(list):
return sum(self.pitch_classes)
@cache_property
def adjacency_intervals(self):
def adjacency_intervals(self) -> list:
"""Adjacency intervals between the pitch classes, used for Leonard notation subscripts."""
if not self.pitch_classes:
return list()
@ -93,7 +96,7 @@ class SetClass(list):
return intervals
@cache_property
def z_relations(self):
def z_relations(self) -> list:
"""
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 iv1,1,1,1,1,1 but
@ -134,7 +137,7 @@ class SetClass(list):
return min(SetClass.ordered_interval(a, b), SetClass.ordered_interval(b, a))
@cache_property
def versions(self):
def versions(self) -> list:
"""
Returns all possible zero-normalised versions (clock rotations) of this set class,
sorted by brightness. See Rahn (1980) Set types, Tₙ
@ -152,19 +155,12 @@ class SetClass(list):
return versions
@cache_property
def rahn_normal_form(self):
def rahn_normal_form(self) -> SetClass:
"""
Return the Rahn normal form of the set class; Leonard describes this as "most dispersed from
the right". Find the smallest outside interval, and proceed inwards from the right until one
result remains. See Rahn (1980), p. 33
"""
# Set class {0,2,4,5,8,9} has four darkest versions:
# {0,2,4,5,8,9} B = 28
# {0,1,4,6,8,9} B = 28
# {0,2,3,6,7,10} B = 28
# {0,1,4,5,8,10} B = 28
# {0,3,5,7,8,11} B = 34
# {0,3,4,7,9,11} B = 34
def _most_dispersed(versions, n):
return [i for i in versions if i.pitch_classes[-n] == min([i.pitch_classes[-n] for i in versions])]
@ -176,15 +172,69 @@ class SetClass(list):
return versions[0]
@cache_property
def darkest_form(self):
def packed_left(self) -> SetClass:
"""
Returns the version with the smallest brightness value.
TODO: How to break a tie? Or return all matches in a tuple? Sorted by what?
Return the form of the set class that is most packed to the left (smallest adjacency
intervals to the left). Find the smallest adjacency interval, and proceed towards the right
until one result remains.
"""
return self.versions[0] if self.versions else self
def _most_packed(versions, n):
return [i for i in versions if i.pitch_classes[n] == min([i.pitch_classes[n] for i in versions])]
versions = self.versions
n = 0
while len(versions) > 1:
versions = _most_packed(versions, n)
n += 1
return versions[0]
@cache_property
def brightest_form(self):
def prime_form(self) -> SetClass:
"""
Return the prime form of the set class. Find the forms with the smallest outside interval,
and if necessary chose the form most packed to the left (the smallest adjacency intervals
working from left to right).
Allen Forte describes the algorithm in his book *The Structure of Atonal Music* (1973) in
section 1.2 (pp. 3-5), citing Milton Babbitt (1961).
"""
def _most_packed(versions, n):
return [i for i in versions if i.pitch_classes[n] == min([i.pitch_classes[n] for i in versions])]
# Requirement 1: select forms where the largest pitch class has the smallest value
smallest_span = min([i.pitch_classes[-1] for i in self.versions])
versions = [i for i in self.versions if i.pitch_classes[-1] == smallest_span]
# Requirement 2: select forms most packed to the left
n = 0
while len(versions) > 1:
versions = _most_packed(versions, n)
n += 1
return versions[0]
@cache_property
def darkest_form(self) -> SetClass:
"""
Returns the version with the smallest brightness value, most packed to the left.
"""
if not self.versions:
return self
B = min(i.brightness for i in self.versions)
versions = [i for i in self.versions if i.brightness == B]
if len(versions) == 1:
return versions[0]
def _most_packed(versions, n):
return [i for i in versions if i.pitch_classes[n] == min([i.pitch_classes[n] for i in versions])]
n = 0
while len(versions) > 1:
versions = _most_packed(versions, n)
n += 1
return versions[0]
@cache_property
def brightest_form(self) -> SetClass:
"""
Returns the version with the largest brightness value.
TODO: How to break a tie? Or return all matches in a tuple? Sorted by what?
@ -192,7 +242,7 @@ class SetClass(list):
return self.versions[-1] if self.versions else self
@cache_property
def inversion(self):
def inversion(self) -> SetClass:
"""
Returns the inversion of this set class, equivalent of reflection through the 0 axis on a
clock diagram.
@ -200,7 +250,7 @@ class SetClass(list):
return SetClass(*[self.tonality - i for i in self.pitch_classes], tonality=self.tonality)
@cache_property
def is_symmetrical(self):
def is_symmetrical(self) -> bool:
"""
Returns whether this set class is symmetrical upon inversion, for example Forte 5-Z17:
{0,1,2,5,9} {0,4,8,9,11}
@ -208,7 +258,7 @@ class SetClass(list):
return self.darkest_form == self.inversion.darkest_form
@cache_property
def complement(self):
def complement(self) -> SetClass:
"""
Returns the set class containing all pitch classes absent in this one (rotated so the
smallest is 0).
@ -257,7 +307,16 @@ class SetClass(list):
# Class methods -----------------------------------------------------------
def all_of_cardinality(cardinality: int, tonality: int = 12) -> set:
@classmethod
def from_string(this, string: str) -> SetClass:
"""
Attempt to create a SetClass from any string containing a sequence of zero or more integers.
A useful convenience function, e.g. SetClass.from_string('{0,3,7,9}')
"""
return SetClass(*re.findall(r'\d+', string))
@classmethod
def all_of_cardinality(cls, cardinality: int, tonality: int = 12) -> set:
"""
Returns all set classes of a given cardinality. Tonality can be specified, default is 12.
Warning: high values of tonality can take a long time to calculate (T=24 takes about a
@ -273,31 +332,33 @@ class SetClass(list):
return set(SetClass(*i, tonality=tonality) for i in _powerset(range(tonality)) if len(i) == cardinality and 0 in i)
def darkest_of_cardinality(cardinality: int, tonality: int = 12, prime: bool = False) -> set:
@classmethod
def darkest_of_cardinality(this, cardinality: int, tonality: int = 12, prime: bool = False) -> set:
"""
Returns all set classes of a given cardinality, in their darkest forms (ignore rotations).
Tonality can be specified, default is 12.
Warning: high values of tonality can take a long time to calculate (T=24 takes about a
minute on an Intel i7-13700H CPU).
"""
return set(i.darkest_form for i in SetClass.all_of_cardinality(cardinality, tonality))
return set(i.darkest_form for i in this.all_of_cardinality(cardinality, tonality))
def normal_of_cardinality(cardinality: int, tonality: int = 12, prime: bool = False) -> set:
@classmethod
def normal_of_cardinality(this, cardinality: int, tonality: int = 12, prime: bool = False) -> set:
"""
Returns all set classes of a given cardinality, in their (darkest) Rahn normal forms (ignore
rotations). Tonality can be specified, default is 12.
Warning: high values of tonality can take a long time to calculate (T=24 takes about a
minute on an Intel i7-13700H CPU).
"""
return set(i.rahn_normal_form for i in SetClass.all_of_cardinality(cardinality, tonality))
return set(i.rahn_normal_form for i in this.all_of_cardinality(cardinality, tonality))
def bright_rahn_normal_forms():
@classmethod
def bright_rahn_normal_forms(this) -> set:
"""
John Rahn's normal form from _Basic Atonal Theory_ (1980) is an algorithm to produce a unique
form for each set class. Most of the time it is also the "darkest" (smallest brightness value),
except for all the times when that is not the case; this function returns that list, as a set of
(cardinality, darkest, rahn) tuples.
John Rahn's normal form from *Basic Atonal Theory* (1980) is an algorithm to produce a
unique form for each set class. Most of the time it is also the "darkest" (smallest
brightness value), except for all the times when that is not the case; this function returns
that list, as a set of (cardinality, darkest, rahn) tuples.
"""
cases = set()
for C in range(13): # 0 to 12
@ -305,3 +366,19 @@ def bright_rahn_normal_forms():
if sc.darkest_form.brightness < sc.rahn_normal_form.brightness:
cases.add((C, sc.darkest_form, sc.rahn_normal_form))
return cases
@classmethod
def bright_prime_forms(this) -> set:
"""
Allen Forte describes the algorithm for deriving the "prime" or zeroed normal form, in his
book *The Structure of Atonal Music* (1973) in section 1.2 (pp. 3-5), citing Milton Babbitt
(1961). It produces a unique form for each set class, which most of the time is also the
"darkest" (smallest brightness value), except for all the times when that is not the case;
this function returns that list, as a set of (cardinality, darkest, prime) tuples.
"""
cases = set()
for C in range(13): # 0 to 12
for sc in SetClass.all_of_cardinality(C):
if sc.darkest_form.brightness < sc.prime_form.brightness:
cases.add((C, sc.darkest_form, sc.prime_form))
return cases

View file

@ -11,7 +11,7 @@ def test_all_of_cardinality_set_lengths():
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]
assert [len(s) for s in sets] == [1, 6, 19, 43, 66, 80, 66, 43, 19, 6, 1, 1]
def test_normal_of_cardinality_set_lengths():
@ -48,7 +48,7 @@ def test_all_of_cardinality():
assert SetClass(0, 1, 2) in s3
assert SetClass(0, 10, 11) in s3
assert SetClass(0, 4, 6) in s3
assert SetClass(0, 2, 8) in s3
assert SetClass(0, 6, 10) in s3
assert SetClass(0, 1, 3, 4, 8, 10) in s6
@ -58,14 +58,14 @@ def test_all_of_cardinality():
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
assert len(s3) == 19
assert len(s6) == 80
# 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, 2, 8) in s3
assert SetClass(0, 6, 10) not in s3
assert SetClass(0, 1, 3, 4, 8, 10) in s6

View file

@ -1,4 +1,4 @@
from setclass.setclass import SetClass, bright_rahn_normal_forms
from setclass.setclass import SetClass
def test_brightness_conjecture():
@ -23,29 +23,24 @@ def test_brightness_conjecture():
def test_bright_rahn():
"""
There are 63 instances where the Rahn normal form is not also the "darkest" (smallest sum of
There are 61 instances where the Rahn normal form is not also the "darkest" (smallest sum of
pitch class values).
"""
R = bright_rahn_normal_forms()
assert len(R) == 63
counts = {
0: 0,
1: 0,
2: 0,
3: 1,
4: 3,
5: 17,
6: 7,
7: 23,
8: 9,
9: 3,
10: 0,
11: 0,
12: 0
}
for C in range(13): # 0 to 12
bright_rahns = set(i for i in R if i[0] == C)
assert len(bright_rahns) == counts[C]
R = SetClass.bright_rahn_normal_forms()
assert len(R) == 61
sets = [[i for i in R if i[0] == C] for C in range(13)]
assert [len(s) for s in sets] == [0, 0, 0, 1, 3, 17, 6, 23, 8, 3, 0, 0, 0]
def test_bright_prime_forms():
"""
There are 59 instances where the prime form is not also the "darkest" (smallest sum of pitch
class values).
"""
P = SetClass.bright_prime_forms()
assert len(P) == 59
sets = [[i for i in P if i[0] == C] for C in range(13)]
assert [len(s) for s in sets] == [0, 0, 0, 1, 3, 16, 6, 23, 8, 2, 0, 0, 0]
def print_bright_rahn():
@ -56,6 +51,19 @@ def print_bright_rahn():
values).
"""
print("Smallest brightness, Rahn normal form")
set_classes = sorted(bright_rahn_normal_forms(), key=lambda x: x[0] * 1000 + x[1].brightness)
set_classes = sorted(SetClass.bright_rahn_normal_forms(), key=lambda x: x[0] * 1000 + x[1].brightness)
for (C, d, r) in set_classes:
print(f"{d.leonard_notation}, {r.leonard_notation}")
def print_bright_prime():
"""
Convenience function, to print the list of set classes where the Forte prime form is not the
"darkest" form (that with the smallest brightness value), as pairs of "darkest" and Forte prime
form pairs, using Leonard notation, sorted by cardinality then brightness (sum of pitch class
values).
"""
print("Smallest brightness, Forte prime form")
set_classes = sorted(SetClass.bright_prime_forms(), key=lambda x: x[0] * 1000 + x[1].brightness)
for (C, d, r) in set_classes:
print(f"{d.leonard_notation}, {r.leonard_notation}")