05.31.07

Pitch class set operations: pcops.py

Posted in music theory, python at 8:04 am by bmccosar

Previously, I wrote a Python module to implement Pitch Class Sets, pcset.py. I have now extended that module with another: pcops.py, a module of operations for pitch class sets. These are some of the functions:

  • copy(A) — returns a new pitch class set, a copy of A.
  • complement(A) — returns a new pitch class set, the complement of A.
  • Comparison functions: exact_equality, set_equality, same_normal, is_transposition, is_inverse, same_prime.
  • Relationship functions: is_complement, is_prime_complement, is_subset, is_prime_subset.
  • Similarity relations: Rp, Rp_prime, R0, R1, R2, Zpair.

Basically, the term “prime” in the relationship or similarity functions extends the meaning of the base function. For example:

>>> from pcset import PitchClassSet as Pcs
>>> from pcops import *
>>> majorscale = Pcs("C D E F G A B C")
>>> g7 = Pcs("G B D F")
>>> b7 = Pcs("B D# F# A")
>>> is_subset(majorscale,g7)
True
>>> is_subset(majorscale,b7)
False
>>> is_prime_subset(majorscale,b7)
True

The notes in the G7 chord are a strict subset of the C major scale; the notes in B7 are not.  However, the “prime” version of the function checks if any transposition, inversion, or combination of the two operations on B7 will make it a subset of the C major scale. This is useful in analyzing the relationships between sets in prime form.

Here are the listings for two new modules. In order to get this to work, you need version 1.1 of pcset.py (and, optionally, of the test suite, test_pcset.py). Below, I’ve included not only pcops.py, but its unit testing suite as well (test_pcops.py):

pcops.py:

# prototype

from pcset import PitchClassSet as Pcs

# ----------------------------------------------------------------- utility

def copy(a):
    """
    Returns a copy of an existing pitch class set as a new object.
    """
    return Pcs(a.toList())

def complement(a):
    """
    Returns a new pitch class set which is the complement of the
    original set -- it contains all the elements which the original
    does not.
    """
    anti = []
    for n in range(11):
        if n not in a.toList():
            anti.append(n)
    return Pcs(anti)

def _setform(a,b):
    return set(a.toList()),set(b.toList())

# ---------------------------------------------------------------- equality

def exact_equality(a,b):
    """
    Test if two pitch class sets are *exactly* equal,
    including the ordering of the elements.
    """
    return a.toList() == b.toList()

def set_equality(a,b):
    """
    Test if two pitch class sets are equal.  The order
    of the elements is irrelevant, but the elements must
    be the same in both sets.
    """
    return set(a.toList()) == set(b.toList())

def same_normal(a,b):
    """
    Test if two pitch class sets have the same normal form.
    """
    a_norm, b_norm = copy(a), copy(b)
    a_norm.normalize()
    b_norm.normalize()
    return a_norm.toList() == b_norm.toList()

def is_transposition(a,b):
    """
    Tests if two pitch class sets differ only by transposition.
    """
    if a.card() != b.card():
        return False
    if a.card() < 2:
        return True
    differences = []
    for x, y in zip(a.toList(),b.toList()):
        differences.append((x-y) % 12)
    # The corresponding element differences must all be equal.
    diffset = set(differences)
    return len(diffset) == 1

def is_inverse(a,b):
    """
    Tests if two pitch class sets are inverses.
    """
    if a.card() != b.card():
        return False
    if a.card() < 2:
        return True
    sums = []
    for x, y in zip(a.toList(),b.toList()):
        sums.append((x+y) % 12)
    sumset = set(sums)
    return len(sumset) == 1

def same_prime(a,b):
    """
    Test if two pitch class sets have the same prime form.
    """
    a_prime, b_prime = copy(a), copy(b)
    a_prime.prime()
    b_prime.prime()
    return a_prime.toList() == b_prime.toList()

# ----------------------------------------------------------- relationships

def is_complement(a,b):
    """
    Determines if set a and set b are complementary.
    """
    a_set, b_set = _setform(a,b)
    union = a_set.union(b_set)
    return len(union) == 12

def is_prime_complement(a,b):
    """
    Determines if b, or any transposition / inversion of b, is
    complementary to set a.  Note that b may not be a direct
    complement of a.
    """
    not_a = complement(a)
    return same_prime(not_a,b)

def is_subset(a,b):
    """
    Determines if b is a subset of a.  The first set must contain all
    of the elements of the second one.
    """
    if a.card() < b.card():
        return False
    a_set, b_set = _setform(a,b)
    return b_set.issubset(a_set)

def is_prime_subset(a,b):
    """
    Determines if b, or any transposition / inversion of b, is
    a subset of a.  Note that b may not be a direct subset of a.
    """
    if a.card() < b.card():
        return False
    # Brute force algorithm.  I'm hoping to find a better one.
    # Systematically tests if any of the transpositions and inversions
    # of b are subsets of a.
    b_copy = copy(b)
    for t in range(12):
        b_copy.transpose(t)
        if is_subset(a,b_copy):
            return True
        b_copy.invert()
        if is_subset(a,b_copy):
            return True
    return False

# -------------------------------------------------------------- similarity

def Rp(a,b):
    """
    Determines if two sets of the same cardinality differ by only one tone.
    The remaining elements must match up exactly.
    """
    if a.card() != b.card():
        return False
    a_set, b_set = _setform(a,b)
    simset = a_set.intersection(b_set)
    return len(simset) + 1 == a.card()

def Rp_prime(a,b):
    """
    Determines if it is possible, through inversion, transposition, or a
    combination of the two, to match all the elements of sets a and b
    -- except for one element in each set.
    """
    if a.card() != b.card():
        return False
    # brute force algorithm again.  See is_prime_subset.
    b_copy = copy(b)
    for t in range(12):
        b_copy.transpose(t)
        if Rp(a,b_copy):
            return True
        b_copy.invert()
        if Rp(a,b_copy):
            return True
    return False

def R0(a,b):
    """
    Checks for R0, or 'minimum similarity': when the interval vectors
    of two sets have no digits in common.
    """
    if a.card() != b.card():
        return False
    for x, y in zip(a.ivec(),b.ivec()):
        if x == y:
            return False
    return True

def R1(a,b):
    """
    Checks for R1, one type of 'maximum similarity': the interval vectors
    should differ only by interchange of two values.
    """
    if a.card() != b.card():
        return False
    misfits = []
    for x, y in zip(a.ivec(),b.ivec()):
        if x != y:
            misfits.append((x,y))
    if len(misfits) == 2:
        first, second = misfits[0], misfits[1]
        if first[0] == second[1] and first[1] == second[0]:
            return True
    return False

def R2(a,b):
    """
    Checks for R2, another type of 'maximum similarity': the interval vectors
    differ only by two values, but relation R1 does not hold -- the two misfit
    values are not related by interchange.
    """
    if a.card() != b.card():
        return False
    misfits = []
    for x, y in zip(a.ivec(),b.ivec()):
        if x != y:
            misfits.append((x,y))
    if len(misfits) == 2:
        first, second = misfits[0], misfits[1]
        if first[0] == second[1] and first[1] == second[0]:
            return False
        else:
            return True
    return False

def Zpair(a,b):
    """
    Checks if two sets are a Z pair, that is, if they have the same
    interval vector.  Note this does not discriminate against testing
    a set against itself -- Zpair(a,a) will return True.
    """
    return a.ivec() == b.ivec()

test_pcops.py:

#!/usr/bin/env python

from pcops import *
import unittest

class UtilityTests(unittest.TestCase):

    def setUp(self):
        self.allint1 = Pcs([0,1,3,7])
        self.allint2 = Pcs([0,1,4,6])
        self.majorscale = Pcs("C D E F G A B C")
        self.pentatonic = Pcs("A C D E G")

    def test_copy(self):
        a = copy(self.allint1)
        self.assertEqual(a.toList(),self.allint1.toList())

    def test_complement(self):
        a = complement(self.majorscale)
        self.assert_(same_prime(a,self.pentatonic))

class EqualityTests(unittest.TestCase):

    def setUp(self):
        self.cscale = Pcs("C D E F G A B C")
        self.ionian = Pcs("C D E F G A B C")
        self.phrygian = Pcs("E F G A B C D E")
        self.jazzminor = Pcs("C D Eb F G A B C")
        self.ebscale = Pcs("Eb F G Ab Bb C D Eb")
        self.majortriad = Pcs("Eb G Bb")
        self.minortriad = Pcs("C Eb G")
        self.majorcopy = copy(self.majortriad)
        self.diminished = Pcs("B D F")

    def test_exact_equality_yes(self):
        self.assert_(exact_equality(self.cscale,self.ionian))

    def test_exact_equality_no(self):
        self.failIf(exact_equality(self.ionian,self.phrygian))

    def test_set_equality_yes(self):
        self.assert_(set_equality(self.cscale,self.phrygian))

    def test_set_equality_no(self):
        self.failIf(set_equality(self.cscale,self.jazzminor))

    def test_same_normal_yes(self):
        self.assert_(same_normal(self.cscale,self.phrygian))

    def test_same_normal_no(self):
        self.failIf(same_normal(self.cscale,self.jazzminor))

    def test_is_transposition_yes(self):
        self.assert_(is_transposition(self.cscale,self.ebscale))

    def test_is_transposition_no(self):
        self.failIf(is_transposition(self.cscale,self.phrygian))

    def test_is_inverse_yes_easy(self):
        self.majorcopy.invert()
        self.assert_(is_inverse(self.majortriad,self.majorcopy))

    def test_is_inverse_yes_tricky_TnI(self):
        self.majorcopy.TnI(3)
        self.assert_(is_inverse(self.majortriad,self.majorcopy))

    def test_is_inverse_yes_tricky_Ixy(self):
        self.majorcopy.Ixy('A','B')
        self.assert_(is_inverse(self.majortriad,self.majorcopy))

    def test_is_inverse_no(self):
        self.failIf(is_inverse(self.majortriad,self.minortriad))

    def test_same_prime_yes(self):
        self.assert_(same_prime(self.majortriad,self.minortriad))

    def test_same_prime_no(self):
        self.failIf(same_prime(self.majortriad,self.diminished))

class SetRelationTests(unittest.TestCase):

    def setUp(self):
        self.a = Pcs("C D E F G A B C")
        self.b = Pcs("E G B D A")
        self.c = Pcs("F Ab C Eb Bb")
        self.d = Pcs("C Eb G B D")
        self.not_a = complement(self.a)

    def test_is_complement_yes(self):
        self.assert_(is_complement(self.a,self.not_a))

    def test_is_complement_no(self):
        self.failIf(is_complement(self.a,self.b))

    def test_is_prime_complement_yes(self):
        self.assert_(is_prime_complement(self.a,self.b))

    def test_is_prime_complement_no(self):
        self.failIf(is_prime_complement(self.a,self.d))

    def test_is_subset_yes(self):
        self.assert_(is_subset(self.a,self.b))

    def test_is_subset_no(self):
        self.failIf(is_subset(self.a,self.c))

    def test_is_prime_subset_yes(self):
        self.assert_(is_prime_subset(self.a,self.c))

    def test_is_prime_subset_no(self):
        self.failIf(is_prime_subset(self.a,self.d))

class SimilarityTests(unittest.TestCase):

    def setUp(self):
        self.a = Pcs("A C E G B D")
        self.b = Pcs("C E G B D F")
        self.c = copy(self.b)
        self.c.TnI(9)
        self.d = Pcs("C Eb G B D F")
        self.forte42 = Pcs([0,1,2,4])
        self.forte413 = Pcs([0,1,3,6])
        self.forte43 = Pcs([0,1,3,4])
        self.forte510 = Pcs([0,1,3,4,6])
        self.forte5Z12 = Pcs([0,1,3,5,6])
        self.forte5Z36 = Pcs([0,1,2,4,7])

    def test_Rp_yes(self):
        self.assert_(Rp(self.a,self.b))

    def test_Rp_no(self):
        self.failIf(Rp(self.a,self.d))

    def test_Rp_prime_yes(self):
        self.assert_(Rp_prime(self.a,self.c))

    def test_Rp_prime_no(self):
        self.failIf(Rp(self.a,self.d))

    def test_R0_yes(self):
        self.assert_(R0(self.forte42,self.forte413))

    def test_R0_no(self):
        self.failIf(R0(self.forte42,self.forte43))

    def test_R1_yes(self):
        self.assert_(R1(self.forte42,self.forte43))

    def test_R1_no(self):
        self.failIf(R1(self.forte42,self.forte413))

    def test_R2_yes(self):
        self.assert_(R2(self.forte510,self.forte5Z12))

    def test_R2_no_actually_R0(self):
        self.failIf(R2(self.forte42,self.forte413))

    def test_R2_no_actually_R1(self):
        self.failIf(R2(self.forte42,self.forte43))

    def test_Zpair_yes(self):
        self.assert_(Zpair(self.forte5Z12,self.forte5Z36))

    def test_Zpair_no(self):
        self.failIf(Zpair(self.forte5Z12,self.forte510))

class EmptyOperationTests(unittest.TestCase):

    def setUp(self):
        self.a = Pcs([])
        self.b = Pcs([])
        self.chromo = Pcs(range(11))

    def test_copy(self):
        a1 = copy(self.a)
        self.assertEqual(self.a.toList(),a1.toList())

    def test_complement_empty(self):
        c = complement(self.a)
        self.assert_(exact_equality(c,self.chromo))

    def test_complement_chromatic(self):
        c = complement(self.chromo)
        self.assert_(exact_equality(c,self.a))

    def test_exact_equality(self):
        self.assert_(exact_equality(self.a,self.b))

    def test_set_equality(self):
        self.assert_(set_equality(self.a,self.b))

    def test_same_normal(self):
        self.assert_(same_normal(self.a,self.b))

    def test_is_transposition(self):
        self.assert_(is_transposition(self.a,self.b))

    def test_is_inverse(self):
        self.assert_(is_inverse(self.a,self.b))

    def test_same_prime(self):
        self.assert_(same_prime(self.a,self.b))

    def test_is_complement(self):
        self.failIf(is_complement(self.a,self.b))

    def test_is_prime_complement(self):
        self.failIf(is_prime_complement(self.a,self.b))

    def test_is_subset(self):
        self.assert_(is_subset(self.a,self.b))

    def test_is_prime_subset(self):
        self.assert_(is_prime_subset(self.a,self.b))

    def test_Rp(self):
        self.failIf(Rp(self.a,self.b))

    def test_Rp_prime(self):
        self.failIf(Rp_prime(self.a,self.b))

    def test_R0(self):
        self.failIf(R0(self.a,self.b))

    def test_R1(self):
        self.failIf(R1(self.a,self.b))

    def test_R2(self):
        self.failIf(R2(self.a,self.b))

    def test_Zpair(self):
        self.assert_(Zpair(self.a,self.b))

class SubtleAlterationTests(unittest.TestCase):

    """
    Tests for unintentional alteration of original
    values during binary operations.  Also, any function
    that is 'touched' by the normal and prime functions
    is checked for accidental alteration of the originals.
    """

    def setUp(self):
        self.a = Pcs("D F# A")
        self.b = Pcs("A C E")

    def test_copy_change_original(self):
        b = copy(self.a)
        self.a.prime()
        self.assertNotEqual(b.toList(),self.a.toList())

    def test_copy_change_copy(self):
        b = copy(self.a)
        b.prime()
        self.assertNotEqual(b.toList(),self.a.toList())

    def test_same_normal(self):
        throwaway = same_normal(self.a,self.b)
        self.assert_(exact_equality(self.a,Pcs("D F# A")))
        self.assert_(exact_equality(self.b,Pcs("A C E")))

    def test_same_prime(self):
        throwaway = same_prime(self.a,self.b)
        self.assert_(exact_equality(self.a,Pcs("D F# A")))
        self.assert_(exact_equality(self.b,Pcs("A C E")))

    def test_is_prime_subset(self):
        throwaway = is_prime_subset(self.a,self.b)
        self.assert_(exact_equality(self.a,Pcs("D F# A")))
        self.assert_(exact_equality(self.b,Pcs("A C E")))

if __name__ == '__main__':
    unittest.main()

Software examples on this page are licensed under the CC-GNU GPL.

Leave a Comment