How to Do Coordinate Math

Domains, TimeUnits, NumberType, Coordinate arithmetic

How to Do Coordinate Math

Domains, TimeUnits, NumberTypes, and type-safe Coordinate arithmetic.

from fractions import Fraction

import pandas as pd

from timetoalign import Coordinate, Domain, IdCoordinate, NumberType, TimeUnit

The Three Domains

Domain Description Examples
Physical Real-world time Seconds, samples
Logical Symbolic/musical Beats, quarters, ticks
Graphical Visual/spatial Pixels, centimetres
list(Domain)
["logical", "physical", "graphical"]
Domain.physical == Domain.ph == Domain("physical") == Domain("ph")
True

TimeUnits

unit_data = [
    {"unit": u.name, "domain": u.domain.name, "discrete": u.is_discrete}
    for u in TimeUnit
]
pd.DataFrame(unit_data).sort_values(["domain", "discrete", "unit"])
unit domain discrete
12 centimeters graphical False
14 inches graphical False
11 meters graphical False
13 millimeters graphical False
15 points graphical False
10 pixels graphical True
1 beats logical False
2 floating_measures logical False
0 number logical False
3 quarters logical False
4 ticks logical True
5 milliseconds physical False
7 minutes physical False
6 seconds physical False
9 frames physical True
8 samples physical True
# Convenient aliases
aliases = {
    "TimeUnit.s": TimeUnit.seconds,
    "TimeUnit.ms": TimeUnit.milliseconds,
    "TimeUnit.q": TimeUnit.quarters,
    "TimeUnit.b": TimeUnit.beats,
    "TimeUnit.px": TimeUnit.pixels,
    "TimeUnit.pulses": TimeUnit.ticks,
    "TimeUnit.divs": TimeUnit.ticks,
}
pd.Series({k: v.name for k, v in aliases.items()}, name="resolves_to")
TimeUnit.s              seconds
TimeUnit.ms        milliseconds
TimeUnit.q             quarters
TimeUnit.b                beats
TimeUnit.px              pixels
TimeUnit.pulses           ticks
TimeUnit.divs             ticks
Name: resolves_to, dtype: str

NumberType

Type Python Type Use Case
int int Discrete units (samples, ticks)
float float Physical time (seconds)
fraction Fraction Exact rationals (beats, quarters)
{
    "from int": NumberType.from_number(42),
    "from float": NumberType.from_number(3.14),
    "from Fraction": NumberType.from_number(Fraction(3, 4)),
}
{'from int': <NumberType.int: <class 'int'>>,
 'from float': <NumberType.float: <class 'float'>>,
 'from Fraction': <NumberType.fraction: <class 'fractions.Fraction'>>}

Why Fractions Matter

float_sum = sum(0.1 for _ in range(10))
fraction_sum = sum(Fraction(1, 10) for _ in range(10))

{
    "10x float": float_sum,
    "10x float == 1": float_sum == 1,
    "10x fraction": fraction_sum,
    "10x fraction == 1": fraction_sum == 1,
}
{'10x float': 0.9999999999999999,
 '10x float == 1': False,
 '10x fraction': Fraction(1, 1),
 '10x fraction == 1': True}

Coordinates

Immutable, hashable, type-safe value+unit pairs.

c1 = Coordinate(120, TimeUnit.ticks)
c2 = Coordinate(1.5, TimeUnit.seconds)
c3 = Coordinate(Fraction(3, 4), TimeUnit.quarters)

c1, c2, c3
(Coordinate(120, ticks),
 Coordinate(1.5, seconds),
 Coordinate(Fraction(3, 4), quarters))
{
    "value": c3.value,
    "unit": c3.unit,
    "number_type": c3.number_type,
    "domain": c3.domain,
}
{'value': Fraction(3, 4),
 'unit': "quarters",
 'number_type': <NumberType.fraction: <class 'fractions.Fraction'>>,
 'domain': "logical"}

Arithmetic

Coordinates are positions, so the arithmetic is deliberately strict. The guiding distinction is between a position (a Coordinate) and a span between two positions (a Duration). The rules below fall straight out of that distinction.

Comparisons. Coordinates of the same unit compare as you would expect.

x = Coordinate(10, TimeUnit.seconds)
y = Coordinate(5, TimeUnit.seconds)

{"x > y": x > y, "x == y": x == y, "x <= y": x <= y}
{'x > y': True, 'x == y': False, 'x <= y': False}

Subtracting two Coordinates gives a Duration. The span between two positions is a duration, not another position. Reverse the operands and the duration is signed — is_negative / is_positive make the direction queryable.

forwards = x - y  # 10s - 5s
backwards = y - x  # 5s - 10s

{
    "x - y": forwards,
    "type": type(forwards).__name__,
    "is_positive": forwards.is_positive(),
    "y - x": backwards,
    "is_negative": backwards.is_negative(),
}
{'x - y': Duration(5, seconds),
 'type': 'Duration',
 'is_positive': True,
 'y - x': Duration(-5, seconds),
 'is_negative': True}

A Coordinate plus or minus a Duration is a Coordinate — shifting a position by a span lands on another position. Shifting by a bare number works the same way.

{
    "x - (x - y)": x - forwards,  # Coordinate - Duration -> Coordinate
    "x + 2": x + 2,  # Coordinate + number -> Coordinate
    "x - 2": x - 2,
}
{'x - (x - y)': Coordinate(5, seconds),
 'x + 2': Coordinate(12, seconds),
 'x - 2': Coordinate(8, seconds)}

Adding two Coordinates is forbidden. The sum of two positions is meaningless; subtract them instead to obtain the span between them.

try:
    x + y  # two positions cannot be added
except TypeError as e:
    print(f"TypeError: {e}")
TypeError: Cannot add two Coordinates; subtract them to obtain a Duration

A Duration on the left of + Coordinate is forbidden too — write coord + dur, not dur + coord.

try:
    forwards + x  # Duration + Coordinate
except TypeError as e:
    print(f"TypeError: {e}")
TypeError: Cannot add a Coordinate to a Duration; use 'coord + dur' instead

Multiplying two TimeScalars is forbidden. Scaling a position by a number is fine; multiplying two positions (or two spans) is not.

try:
    x * y  # Coordinate * Coordinate
except TypeError as e:
    print(f"TypeError: {e}")
TypeError: Cannot multiply two TimeScalars: Coordinate * Coordinate

Scaling by a number preserves the type. A scaled Coordinate is a Coordinate; a scaled Duration is a Duration.

{
    "x * 2": x * 2,
    "x / 2": x / 2,
    "(x - y) * 2": forwards * 2,
    "(x - y) type": type(forwards * 2).__name__,
}
{'x * 2': Coordinate(20, seconds),
 'x / 2': Coordinate(5.0, seconds),
 '(x - y) * 2': Duration(10, seconds),
 '(x - y) type': 'Duration'}

Different units cannot be combined. This is a separate rule from the two-Coordinates ban above: even subtraction — which is allowed between two Coordinates — refuses operands whose units disagree.

try:
    Coordinate(1, TimeUnit.ticks) - Coordinate(1, TimeUnit.seconds)
except TypeError as e:
    print(f"TypeError: {e}")
TypeError: Cannot subtract Coordinate and Coordinate with different units: ticks vs seconds

Id-bearing coordinates preserve their timeline. Subtracting two IdCoordinates on the same timeline yields an IdDuration that keeps the timeline_id. Mixing two different timelines raises.

p = IdCoordinate(10, TimeUnit.quarters, timeline_id="clt1")
q = IdCoordinate(4, TimeUnit.quarters, timeline_id="clt1")
span = p - q

{
    "p - q": span,
    "type": type(span).__name__,
    "timeline_id": span.timeline_id,
}
{'p - q': IdDuration(6, quarters, 'clt1'),
 'type': 'IdDuration',
 'timeline_id': 'clt1'}

Type Conversions

c = Coordinate(Fraction(7, 4), TimeUnit.quarters)

{
    "original": c,
    "to_float()": c.to_float(),
    "to_int()": c.to_int(),
    "to_int('round')": c.to_int("round"),
    "to_fraction()": c.to_fraction(),
}
{'original': Coordinate(Fraction(7, 4), quarters),
 'to_float()': 1.75,
 'to_int()': 1,
 "to_int('round')": 2,
 'to_fraction()': Fraction(7, 4)}
original = Coordinate(100, TimeUnit.ticks)

{
    "original": original,
    "with_value(200)": original.with_value(200),
    "with_unit(samples)": original.with_unit(TimeUnit.samples),
}
{'original': Coordinate(100, ticks),
 'with_value(200)': Coordinate(200, ticks),
 'with_unit(samples)': Coordinate(100, samples)}