Diary Context Mapping — Design
Date: 2026-07-05
Status: Approved (brainstorm), pending implementation plan
Related: GitHub issue #6, prior attempt in dev/diaries.ipynb
Purpose
Let users annotate the 1-second activity time series with contexts (domains)
drawn from a diary: self-reported time intervals such as sleep, work,
commute, or a whole-day work-day. Each context becomes a boolean column on
the activity DataFrame, so downstream analysis (e.g. Exposures.compute) can be
sliced by context — "how much MVPA during work?", "what activity happened during
the reported sleep window?".
Contexts are not mutually exclusive: a single epoch can belong to several at
once (e.g. commute ∧ work-day). The model must handle arbitrary overlap.
Scope
In scope: validating a clean diary, and applying it to the activity DataFrame to produce boolean context columns.
Out of scope: parsing the messy app-export CSV (temp/export_diary_*.csv
with entry_type/HH:MM/midnight-crossing). Converting that export into the
clean diary shape is the caller's responsibility, done outside the library —
not shipped as part of acti-motus at all. The feature always receives an
already-clean diary. This keeps the core matching logic clean and vendor-neutral.
Explicitly not included (YAGNI): relabeling / overriding the activity
column with a context value; a dimension/grouping layer over contexts;
context-stratified exposure summaries as a built-in. All of these remain trivial
one-liners downstream because the boolean columns sit next to the untouched
activity column.
Data model
Diary
A "flat named intervals" table — a pd.DataFrame with columns:
| column | type | notes |
|---|---|---|
start |
tz-aware datetime | interval start (inclusive) |
end |
tz-aware datetime | interval end (exclusive) |
context |
str | context/domain name, e.g. sleep, work |
activities |
list[str] | None |
optional per-row activity gate |
Example:
start end context activities
2024-09-02 23:00:00+02:00 2024-09-03 06:30:00+02:00 sleep ['lie', 'sit']
2024-09-02 08:00:00+02:00 2024-09-02 16:00:00+02:00 work None
2024-09-02 07:15:00+02:00 2024-09-02 08:00:00+02:00 commute None
2024-09-02 00:00:00+02:00 2024-09-03 00:00:00+02:00 work-day None
- Overlap is allowed and expected (
commutesits insidework-day). - A whole-day context (
day_typein issue #6) is just a context whose interval spans the day — no special-casing. activitiesis a property of the row (each interval may differ), not of the context. Consistency across rows of the same context is by convention only.- A context may appear in multiple rows (e.g. two
sleepperiods in one day). These do not create multiple columns — they collapse into the singlecontext__sleepcolumn,Truefor any epoch inside either interval (union).
Activity DataFrame (input, unchanged)
The output of Activities.compute: tz-aware DatetimeIndex named datetime at
1-second epochs, with an activity string column (plus steps/inclination columns).
Output
A copy of the input activity DataFrame with one added boolean column per
distinct context name, prefixed context__:
datetime activity ... context__sleep context__work context__commute context__work-day
2024-09-02 07:30:00 walk False False True True
2024-09-02 08:15:00 sit False True False True
2024-09-02 23:30:00 lie True False False False
The context__ prefix prevents collisions and makes the columns selectable via
df.filter(like='context__'). Overlap is naturally represented — an epoch can be
True in several context__ columns.
Components
Exposures._validate_diary(diary) -> None (private, raises on invalid)
Private helper, invoked automatically at the top of context(). Not part of the
public API or docs (tests call it directly by convention). Checks (raises
ValueError on failure):
- Required columns present:
start,end,context.activitiesoptional; if absent, treated as all-None. start < endfor every row.startandendare tz-aware (raise if naive).
The same-zone check is done in context() (which has the activity index):
diary zone must equal the activity index zone, else raise. Rule is strict — no
warnings, no localization, no cross-zone tolerance. Both tz-aware and identical
zone → proceed; anything else → error.
Exposures.context(df, diary) -> pd.DataFrame (public)
Replaces the current single-context Exposures.context (breaking change to a
documented method — acceptable).
- Copy
df(never mutate the caller's frame). _validate_diary(diary)(structure + tz-aware), then assert diary zone ==df.indexzone, else raise.- Group
diarybycontext. - For each context, call
_context_maskover that context's rows and assign the result tocontext__<name>. Multiple rows for one context union into one column. - Return the enriched copy.
Exposures._context_mask(df, intervals) -> pd.Series (private)
Single-context matcher (generalizes today's context internals). intervals is
the diary sub-frame for one context (start, end, activities). Returns a
boolean pd.Series aligned to df.index:
- For each interval row: epoch is
Truewhen its timestamp is in[start, end)and, if that row'sactivitiesis a non-empty list, itsactivityis in that list.None/emptyactivities→ pure interval membership. - Union across the context's rows.
This is the existing loop, extended so the activity gate is read per interval row instead of a single list for the whole call.
Data flow
export_diary_*.csv --(separate upstream adapter, out of scope)--> clean diary [start,end,context,activities]
|
Activities.compute(...) --> activity df (1s epochs, datetime index) ------+
v
Exposures.context(df, diary) (copy + validate + loop)
v
df + context__<name> boolean columns
v
downstream: Exposures.compute slices, activity∧context one-liners
Behavior notes & edge cases
- Interval matching is half-open
[start, end), consistent with existing code. - Activity-gated context (e.g. sleep as interval ∧ lie/sit) is expressed via
the diary's
activitiescolumn; it is not lossy relabeling. Theactivitycolumn is untouched, so any activity-conditioned question remains recoverable downstream. - Empty diary → returns a copy of
dfwith no context columns added. - Context name collisions with existing columns are avoided by the
context__prefix. - Thigh-only sensor limitation (kneel/squat undetectable) is a diary-authoring concern, not a code concern here.
Testing
_validate_diary: missing columns;start >= end; naive timestamps raise.contexttz: same-zone proceeds; different zone raises; diary-vs-index zone mismatch raises._context_mask: single interval; multiple intervals same context union into one column; per-rowactivitiesgate vsNone; overlapping intervals; half-open boundary (epoch exactly atendexcluded).context: multi-context diary → correctcontext__*columns; overlap reflected in multiple columns; input frame not mutated (copy semantics); empty diary.
Open items for the implementation plan
None — the design is fully specified. Producing a clean diary from any raw export is the caller's job, outside the library.