Skip to content

Effort

Continuous indices for the qualities of movement, after Rudolf Laban.

What Laban's Effort is

Laban Movement Analysis describes movement through four elements—body, space, shape, and effort—and effort is the one about quality: not what moves or where, but how. A movement can cover the same trajectory hurried or calm, grounded or weightless, and effort names that difference. It is an observational system, approached—as the choreographer Andre Austvold taught it in Oslo—by looking at the intention of the movement, "placing yourself within the body of your subject".

Effort has four motion factors, each a continuum between two poles (Laban & Lawrence, 1947; Hackney, 2002):

factor poles
Weight light ↔ strong/firm
Time sustained ↔ sudden
Space indirect/flexible ↔ direct
Flow free ↔ bound

Two things about the system matter before any computation. Effort describes fluctuation, not level: the analysis is of a movement getting firmer or gentler over a phrase, so its natural output is a contour, and a single number for a whole recording flattens what the concept is about (Haga, 2008). And effort is the qualitative reading on top of an intensity contour: a quantity-of-motion curve says how much movement there is, and famously says it indiscriminately—a dancer moving their whole body reads high however the movement feels (Jensenius, 2007)—where effort says how the movement is performed.

What MGT computes

musicalgestures._effort is MGT's own operationalisation of the four factors—and is named as such, because every computational version of an observational system is somebody's operationalisation. Each function takes plain arrays and a sample rate, so mocap speeds, pose trajectories, and quantity-of-motion tracks all qualify.

factor function index direction
Time effort_time burst concentration of the speed profile higher = more sudden
Weight effort_weight peak (p95) acceleration higher = stronger
Space effort_space path directness per window higher = more direct
Flow effort_flow boundness, from spectral arc length higher = more bound

effort_profile computes all four in windows on one clock—the contour form the concept asks for—and basic_effort_actions condenses each window into one of Laban's eight basic effort actions (thrusting, slashing, pressing, wringing, dabbing, flicking, gliding, floating): his own combinations of the Weight, Time and Space poles, with Flow as a further colouring element outside the combination (Laban, 1971; Haga, 2008). The poles are read against the mover's own medians, so a label says which octant of this mover's range a window falls in. The labels are proposals for looking with, never classifications.

The Effort profile of the bundled dancer, with basic-action proposals

import numpy as np
from musicalgestures._effort import effort_profile, basic_effort_actions

xy = np.load("trajectory.npy")        # (frames, 2) positions, e.g. a wrist
profile = effort_profile(xy, fs=25.0, window_s=10.0)
actions = basic_effort_actions(profile)

skeleton_timeline draws the complementary picture—posture at sampled moments, which the Effort indices deliberately discard:

skeleton_timeline of the bundled dancer

What the indices can and cannot claim

Video sees kinematics—positions, velocities—and some of Laban's factors are about dynamics: the forces in the movement. Dynamics can only be inferred from kinematics (the kinematic-specification-of-dynamics hypothesis; Runeson & Frykholm, 1983), so a video-based Weight is a kinetic proxy for "strong", and the docstrings say so. The factors differ in evidential standing:

  • Flow rests on SPARC (Balasubramanian et al., 2015), implemented from the paper and validated against the canonical minimum-jerk battery; its adaptive cutoff is what makes smoothness readable from tracked data at all, and the reason MGT reports no jerk-based smoothness from pose landmarks.
  • Flow needs an adequate sample rate. Measured on a dance corpus, Flow computed from 5 fps pose disagreed entirely with the same section at 25 fps, while the other three factors agreed across two different pose detectors. Give SPARC 25 fps or better.
  • Weight is the weakest claim, and comparable only within one recording and one mover: as a pixel-unit quantity it inherits every scale difference of its input.
  • Time's direction is validated, its classification power measured and modest. On the 365-clip Sound Actions collection the index orders the label classes as the operationalisation predicts (impulsive median 3.52, sustained 3.01, iterative 2.69—lowest, since continuous repetition raises the mean and burst concentration is a ratio), but impulsive against sustained separates at only ROC AUC 0.645. Read the Time contour as description; it is not a classifier, and MGT makes no classification claim from it.

API reference

The MGT operationalisation of Laban's Effort, in continuous indices.

Laban's Effort is a qualitative observational system with four factors --- Weight, Time, Space, Flow --- and every computational version of it is somebody's operationalisation. This is MGT's, named as such, designed in plans/2026-08-30-effort-layer-design.md and validated in tests/test_effort.py before any docstring made a claim. Each function returns a continuous index with a documented direction, never a category shipped as fact.

Input-agnostic on purpose: the functions take arrays and a sample rate, so mocap speeds, pose-trajectory speeds and quantity-of-motion tracks all qualify. What the index then describes is the mover the array describes --- a full-body QoM track yields ensemble Effort, one wrist yields that wrist's.

The factors differ in evidential standing, and their docstrings say so. Flow rests on SPARC, which carries the strongest evidence; Weight is the weakest claim --- "strong" without mass or force plates is a kinetic proxy --- and is labelled as one. That proxy status has a scholarly frame: dynamics are inferred from kinematics (the kinematic-specification-of-dynamics hypothesis, Runeson & Frykholm 1983), and Laban's factors mix the two --- Time is kinematical, Weight and Flow dynamical --- so a video-based Effort layer necessarily reads dynamics through what it can see (Haga 2008, ch. 4).

Two further points from Haga (2008) shape how these indices are meant to be read. Effort elements denote fluctuation, not level --- "gentler and firmer", the way the qualities change over a phrase --- so the windowed effort_profile contours are the object of analysis, and a single number for a whole recording flattens what the concept is about. And effort is the qualitative reading on top of a neutral intensity contour (Stern's activation, which is what a quantity-of-motion track measures): QoM says how much, Effort says how.

sparc

sparc(speed, fs, padlevel=4, fc=10.0, amp_th=0.05)

Spectral arc length: movement smoothness from the speed profile's spectrum.

Implemented from Balasubramanian, Melendez-Calderon, Roby-Brami & Burdet (2015), On the analysis of movement smoothness: the negative arc length of the normalised Fourier magnitude spectrum below an adaptive cutoff. Values are negative, and closer to zero is smoother: a single minimum-jerk reach measures about -1.45 here, three chained submovements about -2.6, and the test suite holds both.

The adaptive cutoff is the reason to prefer SPARC over jerk from tracked data: under 10 per cent added tracker noise SPARC stays within a few per cent of its clean value while still separating submovement count, where jerk RMS cannot tell that noise from genuinely tripled submovements. Measured in the test suite, and the reason MGT reports no jerk-based smoothness from pose data.

Parameters:

Name Type Description Default
speed

One-dimensional speed profile (non-negative magnitudes).

required
fs float

Sample rate of the profile in Hz.

required
padlevel int

Zero-padding exponent for the FFT. Defaults to 4.

4
fc float

Maximum frequency of interest in Hz. Defaults to 10.

10.0
amp_th float

Amplitude threshold of the adaptive cutoff. Defaults to 0.05.

0.05

Returns:

Name Type Description
float float

The spectral arc length; negative, closer to zero is smoother.

float

NaN for a profile with no movement.

Source code in musicalgestures/_effort.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def sparc(speed, fs: float, padlevel: int = 4, fc: float = 10.0,
          amp_th: float = 0.05) -> float:
    """Spectral arc length: movement smoothness from the speed profile's spectrum.

    Implemented from Balasubramanian, Melendez-Calderon, Roby-Brami & Burdet (2015),
    *On the analysis of movement smoothness*: the negative arc length of the
    normalised Fourier magnitude spectrum below an adaptive cutoff. Values are
    negative, and closer to zero is smoother: a single minimum-jerk reach measures
    about -1.45 here, three chained submovements about -2.6, and the test suite
    holds both.

    The adaptive cutoff is the reason to prefer SPARC over jerk from tracked data:
    under 10 per cent added tracker noise SPARC stays within a few per cent of its
    clean value while still separating submovement count, where jerk RMS cannot
    tell that noise from genuinely tripled submovements. Measured in the test
    suite, and the reason MGT reports no jerk-based smoothness from pose data.

    Args:
        speed: One-dimensional speed profile (non-negative magnitudes).
        fs (float): Sample rate of the profile in Hz.
        padlevel (int): Zero-padding exponent for the FFT. Defaults to 4.
        fc (float): Maximum frequency of interest in Hz. Defaults to 10.
        amp_th (float): Amplitude threshold of the adaptive cutoff. Defaults
            to 0.05.

    Returns:
        float: The spectral arc length; negative, closer to zero is smoother.
        NaN for a profile with no movement.
    """
    speed = np.asarray(speed, dtype=float).ravel()
    if speed.size < 4 or not np.isfinite(speed).any() or np.nanmax(speed) <= 0:
        return float("nan")
    speed = np.nan_to_num(speed)

    n = 2 ** (int(np.ceil(np.log2(speed.size))) + padlevel)
    freq = np.arange(n) * fs / n
    mag = np.abs(np.fft.fft(speed, n))
    mag = mag / mag.max()

    below = freq <= fc
    f_sel, m_sel = freq[below], mag[below]
    #: The adaptive cutoff: within fc, keep up to the last point still above the
    #: amplitude threshold, so trailing noise floor does not add arc length.
    above = np.nonzero(m_sel >= amp_th)[0]
    f_sel, m_sel = f_sel[: above[-1] + 1], m_sel[: above[-1] + 1]

    df = np.diff(f_sel / f_sel[-1])
    dm = np.diff(m_sel)
    return float(-np.sum(np.sqrt(df ** 2 + dm ** 2)))

effort_time

effort_time(speed, fs)

The Time factor: sudden against sustained, as burst concentration.

The MGT operationalisation of Laban's Time. A sudden mover concentrates their movement into brief peaks; a sustained one spreads it evenly. The index is the mean speed of the fastest tenth of samples over the mean speed of all of them: 1.0 for perfectly sustained movement, rising without bound as movement concentrates into bursts. fs is accepted for interface symmetry; the index is rate-invariant.

Returns:

Name Type Description
float float

Burst concentration; higher is more sudden. NaN with no movement.

Source code in musicalgestures/_effort.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def effort_time(speed, fs: float) -> float:
    """The Time factor: sudden against sustained, as burst concentration.

    The MGT operationalisation of Laban's Time. A sudden mover concentrates their
    movement into brief peaks; a sustained one spreads it evenly. The index is the
    mean speed of the fastest tenth of samples over the mean speed of all of them:
    1.0 for perfectly sustained movement, rising without bound as movement
    concentrates into bursts. `fs` is accepted for interface symmetry; the index is
    rate-invariant.

    Returns:
        float: Burst concentration; higher is more sudden. NaN with no movement.
    """
    speed = np.asarray(speed, dtype=float).ravel()
    speed = speed[np.isfinite(speed)]
    if speed.size < 10 or speed.max() <= 0:
        return float("nan")
    top = np.sort(speed)[-max(1, speed.size // 10):]
    return float(top.mean() / speed.mean())

effort_weight

effort_weight(speed, fs)

The Weight factor: strong against light, as peak acceleration.

The weakest claim of the four, and labelled as one: without mass or force plates, "strong" from video is a kinetic proxy. The index is the 95th percentile of the absolute acceleration of the speed profile, in the profile's units per second squared --- a strong mover changes speed hard, a light one gently. Comparable within one recording and one mover; across recordings it inherits every scale difference of the input.

Returns:

Name Type Description
float float

Peak (p95) absolute acceleration; higher is stronger. NaN with

float

fewer than 3 samples.

Source code in musicalgestures/_effort.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def effort_weight(speed, fs: float) -> float:
    """The Weight factor: strong against light, as peak acceleration.

    The weakest claim of the four, and labelled as one: without mass or force
    plates, "strong" from video is a kinetic proxy. The index is the 95th
    percentile of the absolute acceleration of the speed profile, in the profile's
    units per second squared --- a strong mover changes speed hard, a light one
    gently. Comparable within one recording and one mover; across recordings it
    inherits every scale difference of the input.

    Returns:
        float: Peak (p95) absolute acceleration; higher is stronger. NaN with
        fewer than 3 samples.
    """
    speed = np.asarray(speed, dtype=float).ravel()
    speed = speed[np.isfinite(speed)]
    if speed.size < 3:
        return float("nan")
    return float(np.percentile(np.abs(np.diff(speed)) * fs, 95))

effort_space

effort_space(xy, fs, window_s=5.0)

The Space factor: direct against indirect, as windowed path directness.

The MGT operationalisation of Laban's Space, on a substrate with a long history: per window, the straight-line displacement over the path length actually travelled. A dead-straight path scores 1; a path that wanders scores toward 0. Windows with no movement are NaN, not a guess.

Parameters:

Name Type Description Default
xy

Positions of shape (frames, 2).

required
fs float

Sample rate in Hz.

required
window_s float

Window length in seconds. Defaults to 5.

5.0

Returns:

Type Description
ndarray

np.ndarray: Directness in [0, 1] per full window.

Source code in musicalgestures/_effort.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def effort_space(xy, fs: float, window_s: float = 5.0) -> np.ndarray:
    """The Space factor: direct against indirect, as windowed path directness.

    The MGT operationalisation of Laban's Space, on a substrate with a long
    history: per window, the straight-line displacement over the path length
    actually travelled. A dead-straight path scores 1; a path that wanders scores
    toward 0. Windows with no movement are NaN, not a guess.

    Args:
        xy: Positions of shape (frames, 2).
        fs (float): Sample rate in Hz.
        window_s (float): Window length in seconds. Defaults to 5.

    Returns:
        np.ndarray: Directness in [0, 1] per full window.
    """
    xy = np.asarray(xy, dtype=float)
    win = int(round(window_s * fs))
    out: list[float] = []
    for a in range(0, xy.shape[0] - win + 1, win):
        seg = xy[a:a + win]
        steps = np.linalg.norm(np.diff(seg, axis=0), axis=1)
        path = float(np.nansum(steps))
        chord = float(np.linalg.norm(seg[-1] - seg[0]))
        out.append(chord / path if path > 0 else float("nan"))
    return np.array(out, dtype=np.float64)

effort_flow

effort_flow(speed, fs)

The Flow factor: bound against free, as negated SPARC.

The MGT operationalisation of Laban's Flow, on smoothness as its substrate: a free mover's speed profile is smooth, a bound one's is held and corrected. The index is -sparc(speed, fs), so higher is more bound; about 1.45 for a single free reach, rising with every correction. SPARC's noise robustness carries over, which is what makes this readable from tracked data at all.

Returns:

Name Type Description
float float

Boundness; higher is more bound. NaN with no movement.

Source code in musicalgestures/_effort.py
161
162
163
164
165
166
167
168
169
170
171
172
173
def effort_flow(speed, fs: float) -> float:
    """The Flow factor: bound against free, as negated SPARC.

    The MGT operationalisation of Laban's Flow, on smoothness as its substrate: a
    free mover's speed profile is smooth, a bound one's is held and corrected.
    The index is `-sparc(speed, fs)`, so higher is more bound; about 1.45 for a
    single free reach, rising with every correction. SPARC's noise robustness
    carries over, which is what makes this readable from tracked data at all.

    Returns:
        float: Boundness; higher is more bound. NaN with no movement.
    """
    return -sparc(speed, fs)

effort_profile

effort_profile(xy_or_speed, fs, window_s=5.0)

All four factors, windowed onto one clock.

Given positions of shape (frames, 2), speed is their frame-to-frame displacement rate and Space is computable; given a one-dimensional speed or quantity-of-motion track, Space is NaN throughout, since directness needs a path. Each factor is computed per full window; a trailing partial window is dropped rather than guessed.

Parameters:

Name Type Description Default
xy_or_speed

(frames, 2) positions, or a (frames,) speed track.

required
fs float

Sample rate in Hz.

required
window_s float

Window length in seconds. Defaults to 5.

5.0

Returns:

Name Type Description
dict dict

time (window centres, seconds), time_index (sudden),

dict

weight (strong), space (direct), flow (bound) --- one value

dict

per window, NaN where a factor cannot be measured.

Source code in musicalgestures/_effort.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def effort_profile(xy_or_speed, fs: float, window_s: float = 5.0) -> dict:
    """All four factors, windowed onto one clock.

    Given positions of shape (frames, 2), speed is their frame-to-frame
    displacement rate and Space is computable; given a one-dimensional speed or
    quantity-of-motion track, Space is NaN throughout, since directness needs a
    path. Each factor is computed per full window; a trailing partial window is
    dropped rather than guessed.

    Args:
        xy_or_speed: (frames, 2) positions, or a (frames,) speed track.
        fs (float): Sample rate in Hz.
        window_s (float): Window length in seconds. Defaults to 5.

    Returns:
        dict: ``time`` (window centres, seconds), ``time_index`` (sudden),
        ``weight`` (strong), ``space`` (direct), ``flow`` (bound) --- one value
        per window, NaN where a factor cannot be measured.
    """
    a = np.asarray(xy_or_speed, dtype=float)
    if a.ndim == 2 and a.shape[1] == 2:
        speed = np.concatenate([[0.0], np.linalg.norm(np.diff(a, axis=0), axis=1) * fs])
        space = effort_space(a, fs, window_s)
    else:
        speed = a.ravel()
        space = None

    win = int(round(window_s * fs))
    n = speed.size // win
    centres, t_idx, weight, flow = [], [], [], []
    for k in range(n):
        seg = speed[k * win:(k + 1) * win]
        centres.append((k + 0.5) * window_s)
        t_idx.append(effort_time(seg, fs))
        weight.append(effort_weight(seg, fs))
        flow.append(effort_flow(seg, fs))
    return {"time": np.asarray(centres),
            "time_index": np.asarray(t_idx),
            "weight": np.asarray(weight),
            "space": (space[:n] if space is not None
                      else np.full(n, np.nan)),
            "flow": np.asarray(flow)}

basic_effort_actions

basic_effort_actions(profile)

Laban's eight basic effort actions, per window, as proposals.

Laban condensed the Weight, Time and Space poles into eight named actions --- thrusting, slashing, pressing, wringing, dabbing, flicking, gliding, floating --- with Flow as a further colouring element outside the combination (Laban 1971, via Haga 2008, Table 20, whose derivative verbs --- shove, pat, crush, beat, strew, pull, flip, smooth --- give the register these labels live in).

Each pole is read against the mover's own median over the profile, in the house style of median-anchored thresholds: "firm" means firmer than this mover's typical window, so the labels describe one mover's range and never compare two movers. They are proposals for looking with, not classifications --- Laban's categories are observational, and a window's label says which octant of this mover's own space it falls in.

Parameters:

Name Type Description Default
profile dict

As from :func:effort_profile --- needs weight, time_index and space arrays of one length.

required

Returns:

Name Type Description
list list

One of the eight action names per window, or None where any of the

list

three indices is NaN.

Source code in musicalgestures/_effort.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
def basic_effort_actions(profile: dict) -> list:
    """Laban's eight basic effort actions, per window, as proposals.

    Laban condensed the Weight, Time and Space poles into eight named actions ---
    thrusting, slashing, pressing, wringing, dabbing, flicking, gliding, floating
    --- with Flow as a further colouring element outside the combination (Laban
    1971, via Haga 2008, Table 20, whose derivative verbs --- shove, pat, crush,
    beat, strew, pull, flip, smooth --- give the register these labels live in).

    Each pole is read against the mover's own median over the profile, in the
    house style of median-anchored thresholds: "firm" means firmer than this
    mover's typical window, so the labels describe one mover's range and never
    compare two movers. They are proposals for looking with, not classifications
    --- Laban's categories are observational, and a window's label says which
    octant of this mover's own space it falls in.

    Args:
        profile (dict): As from :func:`effort_profile` --- needs ``weight``,
            ``time_index`` and ``space`` arrays of one length.

    Returns:
        list: One of the eight action names per window, or None where any of the
        three indices is NaN.
    """
    w = np.asarray(profile["weight"], dtype=float)
    t = np.asarray(profile["time_index"], dtype=float)
    sp = np.asarray(profile["space"], dtype=float)
    med = [np.nanmedian(a) if np.isfinite(a).any() else float("nan")
           for a in (w, t, sp)]
    out: list[str | None] = []
    for wi, ti, si in zip(w, t, sp):
        if not (np.isfinite(wi) and np.isfinite(ti) and np.isfinite(si)):
            out.append(None)
            continue
        out.append(_BASIC_ACTIONS[(bool(wi > med[0]), bool(ti > med[1]),
                                   bool(si > med[2]))])
    return out