FlexTail Sensor Evaluation Series
Sitting Position Change: Detecting State Transitions in Continuous Posture Data
A technical report on the two-tier sitting-position-change workflow: sticky-reference detection within a day, day-to-day posture-vector change scores, exposure-normalised rates, and sensitivity analysis.
Published
The question behind a position-change count
Mean posture is not the same as postural behaviour. Two office workers can have comparable mean lumbar flexion while one changes position frequently and the other remains in a near-static pose for long periods. The sitting-position-change (SPC) analysis turns this behavioural difference into an explicit, reproducible event definition.
The playbook applies two related but distinct analyses to office-worker data across 31 available recording days:
- Intra-day SPC: detect discrete posture changes in raw sensor data within a recording session.
- Day-to-day SPC: detect changes in the daily posture profile between consecutive days.
They answer different questions and should not be pooled into one event count. The first identifies a within-day transition relative to a recent reference pose. The second identifies a changed daily profile after normalising each axis by a chosen sensitivity threshold. Neither is a direct measure of discomfort, break-taking, or musculoskeletal risk; both are operational measures whose validity depends on their thresholds and movement gate.
Tier 1: the sticky-reference detector
At time $t$, define a three-dimensional pose vector in degrees:
$$ \mathbf{p}(t) = \begin{bmatrix} L(t) \ \mathrm{Lat}(t) \ S_{\mathrm{approx}}(t) \end{bmatrix}, $$
where $L$ is lumbar flexion, $\mathrm{Lat}$ is lateral lean, and $S_{\mathrm{approx}}$ is a sagittal approximation channel. Let $\mathbf{p}_{\mathrm{ref}}$ be the reference pose stored after the last accepted position change. The detector computes Euclidean distance:
$$ d(t) = \left|\mathbf{p}(t) - \mathbf{p}_{\mathrm{ref}}\right|2 = \sqrt{\Delta L(t)^2 + \Delta \mathrm{Lat}(t)^2 + \Delta S{\mathrm{approx}}(t)^2}. $$
An event is accepted only if all of the following hold:
$$ d(t) \geq 15^\circ, \qquad t-t_{\mathrm{last}} \geq 120\ \mathrm{s}, \qquad m(t) \leq m_{\mathrm{gate}}, $$
where $m(t)$ is the movement-magnitude gate. The reference is sticky: it advances on a detected event rather than at every sample. This makes the question "has the person reached a materially different settled pose?" rather than "did the signal change compared with the previous frame?"
The two-minute holdoff prevents multiple events from being emitted during the same slow transition. The movement gate excludes periods when movement is too large for the event to be interpreted as a settled sitting posture. The exact movement threshold belongs in the analysis metadata because it changes the set of eligible samples.
The plot pipeline calls the library implementation directly rather than recreating it in plotting code:
from flexlib import FlexReader, create_dataframe
from ergo_analysis.spc import SittingPositionChangesCalculator
recording = FlexReader.parse(str(rsf_path))
df = create_dataframe(recording.measurements)
spc = SittingPositionChangesCalculator.compute(df)
duration_h = spc.sitting_duration.total_seconds() / 3600
print(
f"{spc.position_change_count} changes over {duration_h:.1f} h "
f"({spc.position_changes_per_hour:.1f}/h)"
)
This is an important reproducibility decision. If a figure uses the library calculator, the package version and its configuration should be locked with the analysis environment. Reimplementing the logic independently can produce subtly different counts around thresholds.
Why distance alone is not enough
Euclidean distance has intuitive units of degrees, but it treats one degree in each component as equally consequential:
$$ d^2 = \Delta L^2 + \Delta \mathrm{Lat}^2 + \Delta S^2. $$
This is a modelling choice. If the channels have different noise levels, a noise-normalised distance may be preferable:
$$ d_{\Sigma}(t) = \sqrt{\left(\mathbf{p}(t)-\mathbf{p}{\mathrm{ref}}\right)^\mathsf{T} \boldsymbol\Sigma^{-1} \left(\mathbf{p}(t)-\mathbf{p}{\mathrm{ref}}\right)}, $$
where $\boldsymbol\Sigma$ is a covariance matrix estimated from stable sitting. This Mahalanobis form downweights naturally noisy dimensions and accounts for correlation. It is not used in the playbook pipeline, but it is a sensible sensitivity analysis for a new cohort. The current 15 degree detector is intentionally transparent and easy to communicate; transparency should not be confused with universal optimality.
Tier 2: change between consecutive daily profiles
The day-level analysis has a different input. It uses daily summary statistics for lumbar, sagittal, and twist channels. For day $t$, define the difference from the preceding day:
$$ \Delta L_t=L_t-L_{t-1}, \qquad \Delta S_t=S_t-S_{t-1}, \qquad \Delta T_t=T_t-T_{t-1}. $$
Each difference is divided by a channel-specific scale, then combined:
$$ C_t = \sqrt{ \left(\frac{\Delta L_t}{\tau_L}\right)^2+ \left(\frac{\Delta S_t}{\tau_S}\right)^2+ \left(\frac{\Delta T_t}{\tau_T}\right)^2 }. $$
The playbook sets $\tau_L=5^\circ$, $\tau_S=8^\circ$, and $\tau_T=10^\circ$, then identifies a change when $C_t>1$. The code also applies a one-window holdoff between accepted changes.
TAU_L, TAU_S, TAU_T = 5.0, 8.0, 10.0
HOLDOFF = 1
dl = np.diff(L, prepend=np.nan)
ds = np.diff(S, prepend=np.nan)
dt = np.diff(TW, prepend=np.nan)
score = np.sqrt((dl / TAU_L) ** 2 + (ds / TAU_S) ** 2 + (dt / TAU_T) ** 2)
score[0] = 0.0
changes = np.zeros(len(group), dtype=bool)
last_index = -HOLDOFF - 1
for index in range(1, len(group)):
if score[index] > 1.0 and (index - last_index) > HOLDOFF:
changes[index] = True
last_index = index
This is a normalised distance in daily-summary space. A value of $C_t=1$ can be reached by a five-degree lumbar shift alone, an eight-degree sagittal shift alone, a ten-degree twist shift alone, or a combination of smaller shifts. The thresholds therefore encode relative sensitivity, not biological equivalence between axes.
Rates require exposure denominators
Raw event counts cannot be compared across clock hours or workers with unequal recording duration. The hourly rate normalises events by eligible sitting exposure:
$$ \mathrm{rate}(h,p) = \frac{n_{\mathrm{events}}(h,p)}{t_{\mathrm{sitting}}(h,p),[\mathrm{min}]} \times 60 \quad [\mathrm{events/hour}]. $$
Hours with fewer than five minutes of sitting exposure are excluded in the playbook. This prevents a single event in a short fragment from becoming an implausibly large hourly rate. The time-of-day loader computes the event results for every RSF file, then passes the collection to the shared aggregation routine:
all_days = load_all_days()
total_events = sum(item["result"].position_change_count for item in all_days)
hourly = build_hourly_stats(all_days)
tod_rate(hourly, out_path=None)
tod_intervals(hourly, out_path=None)
Use both rate and inter-event interval. The rate answers how frequently changes occur per unit sitting time. The interval distribution shows whether some periods contain unusually long static stretches. Median intervals are robust to a small number of unusually long gaps, while quantiles make the upper tail visible.
Sensitivity analysis is part of the result
SPC counts can change substantially when the distance threshold, holdoff, or movement gate changes. Treat these as analysis parameters, not immutable facts. A simple grid is:
| Parameter | Low sensitivity | Baseline | High specificity |
|---|---|---|---|
| Distance threshold | 10 degrees | 15 degrees | 20 degrees |
| Holdoff | 60 s | 120 s | 180 s |
| Minimum hourly exposure | 2 min | 5 min | 10 min |
For each setting, report total events, events per hour, median interval, and the rank ordering of participants. If the central conclusion changes under reasonable settings, the conclusion should be framed as threshold-sensitive. If it remains stable, that is stronger evidence that the pattern is not an artefact of one configuration.
Manual annotation of a small, blinded subset is the next validation step. Annotators can mark visibly settled posture changes from synchronised video or a detailed activity log. Agreement should be reported separately for event onset and event count because a detector may identify the correct number of changes while being offset in time.
Interpretation boundaries
A position change is not automatically beneficial, and stillness is not automatically harmful. The metric describes how the measured posture vector evolves under its specified gate and thresholds. It can support comparisons such as "participant A had fewer detected settled-pose changes per hour than participant B in this recording protocol." It cannot independently establish fatigue, pain, work quality, or ergonomic adequacy.
For a research report, include raw recording duration, number of eligible sitting minutes, sensor sampling rate, all thresholds, exclusion reasons, and the full distribution of person-level rates. Avoid a group mean without the individual values: within-person variation and incomplete time-of-day coverage are central to this question.
Method references
The implementation excerpts above use flexlib DataFrame processing and the ergo_analysis sitting-position-change API. The event definition, normalisation, and sensitivity parameters are documented in this report so that researchers can reproduce or adapt the method without access to internal project files.