Sources¶
A Source is a device, sensor, or subsystem inside a Record. It sits between the
Record and its Signals, and it groups the Signals that one device produces. For
example, a Record can keep a chest strap apart from a wrist watch. It can also keep an
accelerometer apart from a gyroscope inside one IMU.
A Source can hold Signals, child Sources, or both. A Signal is always a leaf.
Create a Source¶
Build the tree from the leaves upward. This example is one night of a sleep study. The participant wears a Polar H10 chest strap, which records ECG at 130 Hz. They also wear an Empatica EmbracePlus watch. Its accelerometer records three axes at 64 Hz.
import numpy as np
from timenet.dataset import Record, Signal, Source
from timenet.dataset.axis import RegularAxis
from timenet.types import TimeSeriesSpec, ureg
# Stand-in for 60 s of a device export. A connector reads these values.
rng = np.random.default_rng(17)
ecg_values = rng.normal(0.0, 0.2, 60 * 130).astype(np.float32)
wrist_values = rng.normal(0.0, 0.05, (3, 60 * 64)).astype(np.float32)
ecg = TimeSeriesSpec(
spec_type="ecg",
name="ECG",
unit_value=ureg.millivolt,
dtype="float32",
)
acceleration = TimeSeriesSpec(
spec_type="acceleration",
name="Acceleration",
unit_value=ureg.standard_gravity,
dtype="float32",
)
chest_strap = Source(
name="Chest strap",
signals=(
Signal(
name="ECG",
spec=ecg,
time_axis=RegularAxis.from_rate_hz(130),
data=ecg_values,
),
),
metadata={"model": "Polar H10"},
)
accelerometer = Source(
name="Accelerometer",
signals=tuple(
Signal(
name=axis,
spec=acceleration,
time_axis=RegularAxis.from_rate_hz(64),
data=values,
)
for axis, values in zip("xyz", wrist_values)
),
)
watch = Source(
name="Wrist watch",
sources=(accelerometer,),
metadata={"model": "Empatica EmbracePlus"},
)
record = Record(
sources=(chest_strap, watch),
metadata={"participant": "P017", "session": "night-1"},
)
print([source.name for source in record.walk_sources()])
print([signal.name for signal in record.signals])
The device model goes in the metadata of its Source. The participant and the session go in the
metadata of the Record.
All fields are keyword-only:
| Field | Type | Default | Description |
|---|---|---|---|
name |
str |
required | Human-readable name. It must not be empty. |
sources |
tuple[Source, ...] |
() |
Direct child Sources. Their order has no meaning. |
signals |
tuple[Signal, ...] |
() |
Signals that this Source produces directly. |
annotations |
tuple[Annotation, ...] |
() |
Annotation occurrences on this Source. |
metadata |
JSON-compatible mapping | {} |
Untyped device details. |
Nest Sources¶
walk_sources() yields the Source and every descendant. It walks depth-first and sorts by name at
each level. walk_signals() yields every Signal below the Source, sorted by name within each
Source. Record.signals and Record.walk_sources() use the same order. Thus the order in which
you pass children has no effect.
Names do not have to be unique. If two Signals below one Source have the same name,
select(signal_names=...) rejects that name as ambiguous. In that case, select the objects.
Annotate a Source¶
source.annotate(annotation) attaches one occurrence and returns it. A static annotation
describes the whole device, for example its firmware version. A timed annotation covers an
interval, for example a time when an electrode lost contact with the skin:
from timenet.types import Annotation, TimeInterval
chest_strap.annotate(Annotation(key="firmware", value="3.1.1"))
chest_strap.annotate(
Annotation(
key="electrode_contact",
value="lost",
span=TimeInterval.seconds(12.0, 15.5),
)
)
A timed annotation covers the subtree of the Source. TimeNet compares its span with the union of
the windows of all Signals below the Source. If the span is outside that window, TimeNet gives a
SpanOutsideWindowWarning and keeps the span as given. A Source with no timed Signals rejects a
timed annotation. The span cannot set time_series. To annotate a subset of Signals, use
source.select(...). See Annotations.
Sources have no ID¶
A Source has no id. The dataset numbers Records, Signals, and Tasks, but not Sources. Refer to a
Source by the object, for example from record.sources or walk_sources(). Keep a serial number
or an upstream device code in metadata or in a static annotation.
Ownership¶
Each Source has one parent. The parent is a Source, or the Record that lists it as a root. Each Signal belongs to one Source. If the participant wears the same chest strap on the next night, build a new Source for the new Record.
walk_sources() rejects a Source under two parents, and it rejects a cycle in the tree. When you
construct a Record, it does the same checks. add_record() rejects a Source or Signal that
another Record already owns. It also rejects a Signal that one Record attaches twice.
from timenet.dataset import TimeFDataset
from timenet.types import DatasetMetadata
dataset = TimeFDataset(
metadata=DatasetMetadata(
dataset_id="demo/sleep-wearables",
dataset_version="1.0.0",
name="Sleep wearables demo",
description="One night of one participant.",
license="CC-BY-4.0",
)
)
dataset.add_record(record=record)
night_2 = Record(
sources=(chest_strap,),
metadata={"participant": "P017", "session": "night-2"},
)
dataset.add_record(record=night_2)
TimeFValidationError: Sources ['Chest strap'] already belong to another
record; each Source has one owner
Storage and read-back¶
The writer stores each Source as one row of the sources table in control.duckdb. The row has
source_id, record_id, parent_source_id (NULL for a root), name, and metadata. The
writer assigns source_id again on each write. This number is internal to storage. Each signals
row and each Source annotation occurrence refers to it. The writer rejects a Record without
Sources, because a Record stores its Signals under Sources. See
On-disk format.
When you read a version, TimeNet builds new Source objects from these rows. The objects have their annotations and metadata. Signal values stay lazy until you read them.
version = dataset.write(path="./registry")
(stored,) = TimeFDataset.open(path=version).records
strap, stored_watch = stored.sources
print(strap.name, [a.key for a in strap.annotations])
print(stored_watch.metadata, stored_watch.sources[0].name)