Move initial story test to use tox and pytest
This commit is contained in:
parent
32e381e262
commit
1d8ec111ff
5 changed files with 256 additions and 70 deletions
0
setclass/__init__.py
Normal file
0
setclass/__init__.py
Normal file
170
setclass/setclass.py
Normal file
170
setclass/setclass.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
class cache_property:
|
||||
"""
|
||||
Property that only computes its value once when first accessed, and caches the result.
|
||||
"""
|
||||
def __init__(self, function):
|
||||
self.function = function
|
||||
self.name = function.__name__
|
||||
|
||||
def __get__(self, obj, type=None) -> object:
|
||||
obj.__dict__[self.name] = self.function(obj)
|
||||
return obj.__dict__[self.name]
|
||||
|
||||
|
||||
class SetClass(list):
|
||||
"""
|
||||
Musical set class, containing zero or more pitch classes.
|
||||
"""
|
||||
def __init__(self, *args: int, tonality: int = 12):
|
||||
"""
|
||||
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
|
||||
lowest value is 0. For a number of divisions of the octave other than the default (Western
|
||||
harmony) value of 12, supply an integer value using the 'tonality' keyword. Example:
|
||||
|
||||
sc = SetClass(0, 7, 9)
|
||||
sc = SetClass(0, 2, 3, tonality=7)
|
||||
"""
|
||||
self._tonality = tonality
|
||||
|
||||
# deduplicate arg values, modulo each by tonality
|
||||
pitches = set()
|
||||
for i in set({*args}):
|
||||
try:
|
||||
pitches.add(int(i) % tonality)
|
||||
except ValueError:
|
||||
raise ValueError("Requires integer arguments (use 10 and 11 for 'T' and 'E')")
|
||||
|
||||
# if necessary "rotate" so that the lowest non-zero interval is zero
|
||||
if pitches:
|
||||
n = min(pitches)
|
||||
pitches = [i - n for i in pitches]
|
||||
|
||||
# in ascending order
|
||||
for i in sorted(pitches):
|
||||
super().append(i)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return the Python instance string representation."""
|
||||
return f"SetClass{self.pitch_classes}".replace(' ', '')
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return sum([hash(i) for i in self.pitch_classes])
|
||||
|
||||
@property
|
||||
def pitch_classes(self):
|
||||
return list(self)
|
||||
|
||||
@cache_property
|
||||
def tonality(self) -> int:
|
||||
"""
|
||||
Returns the number of (equal) divisions of the octave. The default value is 12, which
|
||||
represents traditional Western chromatic harmony, the octave divided into twelve semitones.
|
||||
"""
|
||||
return self._tonality
|
||||
|
||||
@cache_property
|
||||
def cardinality(self) -> int:
|
||||
"""Returns the cardinality of the set class, i.e. the number of pitch classes."""
|
||||
return len(self.pitch_classes)
|
||||
|
||||
@cache_property
|
||||
def brightness(self) -> int:
|
||||
"""Returns the brightness of the set class, defined as the sum of the pitch class values."""
|
||||
return sum(self.pitch_classes)
|
||||
|
||||
@cache_property
|
||||
def adjacency_intervals(self):
|
||||
"""Adjacency intervals between the pitch classes, used for Leonard notation subscripts."""
|
||||
if not self.pitch_classes:
|
||||
return list()
|
||||
intervals = list()
|
||||
prev = None
|
||||
for i in self.pitch_classes:
|
||||
if prev is not None:
|
||||
intervals.append(i - prev)
|
||||
prev = i
|
||||
intervals.append(self.tonality - prev)
|
||||
return intervals
|
||||
|
||||
@cache_property
|
||||
def versions(self):
|
||||
"""
|
||||
Returns all possible zero-normalised versions (clock rotations) of this set class,
|
||||
sorted by brightness.
|
||||
"""
|
||||
# The empty set class has one version, itself
|
||||
if not self.pitch_classes:
|
||||
return [self]
|
||||
|
||||
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))
|
||||
versions = list(versions)
|
||||
versions.sort(key=lambda x: x.brightness)
|
||||
return versions
|
||||
|
||||
@cache_property
|
||||
def darkest_form(self):
|
||||
"""
|
||||
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 self.versions[0] if self.versions else self
|
||||
|
||||
@cache_property
|
||||
def brightest_form(self):
|
||||
"""
|
||||
Returns the version with the largest brightness value.
|
||||
TODO: How to break a tie? Or return all matches in a tuple? Sorted by what?
|
||||
"""
|
||||
return self.versions[-1] if self.versions else self
|
||||
|
||||
@cache_property
|
||||
def complement(self):
|
||||
"""
|
||||
Returns the set class containing all pitch classes absent in this one (rotated so the
|
||||
smallest is 0).
|
||||
"""
|
||||
return SetClass(*[i for i in range(self.tonality) if i not in self.pitch_classes], tonality=self.tonality)
|
||||
|
||||
@cache_property
|
||||
def dozenal_notation(self) -> str:
|
||||
"""
|
||||
If tonality is no greater than 12, return a string representation using Dozenal Society
|
||||
characters '↊' for 10 and '↋' for 11 (the Pitman forms from Unicode 8.0 release, 2015).
|
||||
"""
|
||||
return f"{self}" if self.tonality > 12 else f"{self}".replace('10', '↊').replace('11', '↋')
|
||||
|
||||
@cache_property
|
||||
def duodecimal_notation(self) -> str:
|
||||
"""
|
||||
If tonality is no greater than 12, replace 10 and 11 with 'T' and 'E'.
|
||||
"""
|
||||
return f"{self}" if self.tonality > 12 else f"{self}".replace('10', 'T').replace('11', 'E')
|
||||
|
||||
@cache_property
|
||||
def leonard_notation(self) -> str:
|
||||
"""
|
||||
Returns a string representation of this set class using subscripts to denote the adjacency
|
||||
intervals between the pitch classes instead of commas, and the overall brightness (sum of
|
||||
pitch class values) denoted as a superscript. For example, Forte 5-20, {0,1,5,6,8} is
|
||||
notated [0₁1₄5₁6₂8₄]²⁰
|
||||
"""
|
||||
# Return numbers as subscript and superscript strings:
|
||||
def subscript(n: int) -> str:
|
||||
return "".join(["₀₁₂₃₄₅₆₇₈₉"[int(i)] for i in str(n)])
|
||||
|
||||
def superscript(n: int) -> str:
|
||||
return "".join(["⁰¹²³⁴⁵⁶⁷⁸⁹"[int(i)] for i in str(n)])
|
||||
|
||||
pitches = ""
|
||||
for i in range(self.cardinality):
|
||||
pitch = self.pitch_classes[i]
|
||||
sub = subscript(self.adjacency_intervals[i])
|
||||
pitches += f"{pitch}{sub}"
|
||||
sup = superscript(self.brightness)
|
||||
return f"[{pitches}]{sup}"
|
||||
0
setclass/tests/__init__.py
Normal file
0
setclass/tests/__init__.py
Normal file
250
setclass/tests/test_setclass.py
Normal file
250
setclass/tests/test_setclass.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
from setclass.setclass import SetClass
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Functional tests
|
||||
|
||||
|
||||
def test_zero_normalised_intervals():
|
||||
a = SetClass(0, 1, 3)
|
||||
assert a == SetClass(1, 2, 4)
|
||||
assert a == SetClass(4, 5, 7)
|
||||
|
||||
|
||||
def test_modulo_intervals():
|
||||
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():
|
||||
a = SetClass(0, 9, 10)
|
||||
assert a == SetClass(-3, -2, 0)
|
||||
|
||||
|
||||
def test_immutable_intervals():
|
||||
a = SetClass(0, 1, 3)
|
||||
assert a == SetClass(0, 3, 1)
|
||||
assert a == SetClass(3, 0, 1)
|
||||
assert a == SetClass(1, 3, 0)
|
||||
assert a == SetClass(1, 0, 3)
|
||||
assert a == SetClass(3, 1, 0)
|
||||
|
||||
|
||||
def test_repeat_intervals():
|
||||
a = SetClass(0, 1, 3)
|
||||
assert a == SetClass(0, 1, 3, 3, 1)
|
||||
assert a == SetClass(0, 12, 1, -11, 15)
|
||||
assert a == SetClass(12, 1, -11, 15, -9)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Example Forte 5-20 set class
|
||||
f520 = SetClass(0, 1, 5, 6, 8)
|
||||
|
||||
|
||||
def test_tonality():
|
||||
assert f520.tonality == 12
|
||||
|
||||
|
||||
def test_cardinality():
|
||||
assert f520.cardinality == 5
|
||||
|
||||
|
||||
def test_brightness():
|
||||
assert f520.brightness == 20
|
||||
|
||||
|
||||
def test_pitch_classes():
|
||||
assert len(f520) == 5
|
||||
assert len(f520.pitch_classes) == 5
|
||||
|
||||
|
||||
def test_adjacency_intervals():
|
||||
assert len(f520.adjacency_intervals) == 5
|
||||
|
||||
|
||||
def test_versions():
|
||||
assert len(f520.versions) == 5
|
||||
|
||||
|
||||
def test_brightest_form():
|
||||
b = f520.brightest_form
|
||||
assert b in f520.versions
|
||||
assert b == SetClass(0, 4, 5, 9, 10)
|
||||
assert b.leonard_notation == '[0₄4₁5₄9₁10₂]²⁸'
|
||||
|
||||
|
||||
def test_darkest_form():
|
||||
d = f520.darkest_form
|
||||
assert d in f520.versions
|
||||
assert d == SetClass(0, 1, 3, 7, 8)
|
||||
assert d.leonard_notation == '[0₁1₂3₄7₁8₄]¹⁹'
|
||||
|
||||
|
||||
def test_leonard_notation():
|
||||
assert f520.leonard_notation == '[0₁1₄5₁6₂8₄]²⁰'
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Complement of Forte 5-20 set class: Forte 7-20
|
||||
|
||||
def test_complement():
|
||||
assert f520.complement == SetClass(0, 1, 2, 5, 7, 8, 9)
|
||||
|
||||
|
||||
def test_complement_tonality():
|
||||
assert f520.complement.tonality == 12
|
||||
|
||||
|
||||
def test_complement_cardinality():
|
||||
assert f520.complement.cardinality == 7
|
||||
|
||||
|
||||
def test_complement_brightness():
|
||||
assert f520.complement.brightness == 32
|
||||
|
||||
|
||||
def test_complement_pitch_classes():
|
||||
assert len(f520.complement) == 7
|
||||
assert len(f520.complement.pitch_classes) == 7
|
||||
|
||||
|
||||
def test_complement_adjacency_intervals():
|
||||
assert len(f520.complement.adjacency_intervals) == 7
|
||||
|
||||
|
||||
def test_complement_versions():
|
||||
assert len(f520.complement.versions) == 7
|
||||
|
||||
|
||||
def test_complement_brightest_form():
|
||||
b = f520.complement.brightest_form
|
||||
assert b in f520.complement.versions
|
||||
assert b == SetClass(0, 3, 5, 6, 7, 10, 11)
|
||||
assert b.leonard_notation == '[0₃3₂5₁6₁7₃10₁11₁]⁴²'
|
||||
|
||||
|
||||
def test_complement_darkest_form():
|
||||
d = f520.complement.darkest_form
|
||||
assert d in f520.complement.versions
|
||||
assert d == SetClass(0, 1, 2, 5, 6, 7, 10)
|
||||
assert d.leonard_notation == '[0₁1₁2₃5₁6₁7₃10₂]³¹'
|
||||
|
||||
|
||||
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 (full 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₁]⁶⁶'
|
||||
Loading…
Add table
Add a link
Reference in a new issue