icoschema 0.1.0b1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- icoschema-0.1.0b1/.github/dependabot.yml +6 -0
- icoschema-0.1.0b1/.github/workflows/publish.yaml +59 -0
- icoschema-0.1.0b1/.github/workflows/test.yaml +44 -0
- icoschema-0.1.0b1/.gitignore +14 -0
- icoschema-0.1.0b1/.python-version +1 -0
- icoschema-0.1.0b1/ICOschema/__init__.py +23 -0
- icoschema-0.1.0b1/ICOschema/model/__init__.py +5 -0
- icoschema-0.1.0b1/ICOschema/model/python/dataset_bundle.py +80 -0
- icoschema-0.1.0b1/ICOschema/model/python/recording.py +173 -0
- icoschema-0.1.0b1/ICOschema/py.typed +0 -0
- icoschema-0.1.0b1/ICOschema/schema/generated/python/dataset.py +644 -0
- icoschema-0.1.0b1/ICOschema/schema/linkml/dataset.yaml +296 -0
- icoschema-0.1.0b1/ICOschema/storage/python/hdf5.py +340 -0
- icoschema-0.1.0b1/PKG-INFO +231 -0
- icoschema-0.1.0b1/README.md +220 -0
- icoschema-0.1.0b1/doc/releases/0.1.0.md +3 -0
- icoschema-0.1.0b1/justfile +30 -0
- icoschema-0.1.0b1/pyproject.toml +25 -0
- icoschema-0.1.0b1/test/hdf5_helpers.py +77 -0
- icoschema-0.1.0b1/test/test_hdf5_adapter.py +268 -0
- icoschema-0.1.0b1/test/test_model.py +219 -0
- icoschema-0.1.0b1/test/test_with_sensors.hdf5 +0 -0
- icoschema-0.1.0b1/uv.lock +1540 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Source: https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/
|
|
2
|
+
|
|
3
|
+
name: Build Python Package
|
|
4
|
+
|
|
5
|
+
on: push
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
name: Build package
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v6
|
|
14
|
+
with:
|
|
15
|
+
persist-credentials: false
|
|
16
|
+
|
|
17
|
+
- name: Set up Python
|
|
18
|
+
uses: actions/setup-python@v6
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.13"
|
|
21
|
+
|
|
22
|
+
- name: Install build package
|
|
23
|
+
run: >
|
|
24
|
+
python3 -m
|
|
25
|
+
pip install
|
|
26
|
+
build
|
|
27
|
+
--user
|
|
28
|
+
|
|
29
|
+
- name: Build a binary wheel and a source tarball
|
|
30
|
+
run: python3 -m build
|
|
31
|
+
|
|
32
|
+
- name: Store the distribution packages
|
|
33
|
+
uses: actions/upload-artifact@v7
|
|
34
|
+
with:
|
|
35
|
+
name: python-package-distributions
|
|
36
|
+
path: dist/
|
|
37
|
+
|
|
38
|
+
publish:
|
|
39
|
+
name: Publish package
|
|
40
|
+
# Only publish to PyPI on tag pushes
|
|
41
|
+
if: startsWith(github.ref, 'refs/tags/')
|
|
42
|
+
needs:
|
|
43
|
+
- build
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
environment:
|
|
46
|
+
name: pypi
|
|
47
|
+
url: https://pypi.org/p/icoschema
|
|
48
|
+
permissions:
|
|
49
|
+
id-token: write
|
|
50
|
+
|
|
51
|
+
steps:
|
|
52
|
+
- name: Download all the distribution packages
|
|
53
|
+
uses: actions/download-artifact@v8
|
|
54
|
+
with:
|
|
55
|
+
name: python-package-distributions
|
|
56
|
+
path: dist/
|
|
57
|
+
|
|
58
|
+
- name: Publish distribution to PyPI
|
|
59
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: Run tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
- push
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
os-python-matrix:
|
|
8
|
+
strategy:
|
|
9
|
+
matrix:
|
|
10
|
+
os:
|
|
11
|
+
- name: macos
|
|
12
|
+
icon: 🍏
|
|
13
|
+
- name: ubuntu
|
|
14
|
+
icon: 🐧
|
|
15
|
+
- name: windows
|
|
16
|
+
icon: 🪟
|
|
17
|
+
python-version:
|
|
18
|
+
- "3.13"
|
|
19
|
+
- "3.14"
|
|
20
|
+
|
|
21
|
+
runs-on: ${{ matrix.os.name }}-latest
|
|
22
|
+
name: ${{ matrix.os.icon }} Python ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
steps:
|
|
25
|
+
- name: Checkout code
|
|
26
|
+
uses: actions/checkout@v7
|
|
27
|
+
|
|
28
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
29
|
+
uses: actions/setup-python@v7
|
|
30
|
+
with:
|
|
31
|
+
python-version: ${{ matrix.python-version }}
|
|
32
|
+
|
|
33
|
+
- name: Install uv
|
|
34
|
+
uses: astral-sh/setup-uv@v7
|
|
35
|
+
with:
|
|
36
|
+
python-version: ${{ matrix.python-version }}
|
|
37
|
+
|
|
38
|
+
- name: Install package
|
|
39
|
+
run: |
|
|
40
|
+
uv venv
|
|
41
|
+
uv sync
|
|
42
|
+
|
|
43
|
+
- name: Test code
|
|
44
|
+
run: uv run pytest
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""ICOschema: a backend-agnostic data model for sensor/hardware recordings
|
|
2
|
+
and their derived computations.
|
|
3
|
+
|
|
4
|
+
from ICOschema import Recording, DatasetBundle
|
|
5
|
+
|
|
6
|
+
recording = Recording.from_hdf5("some_file.hdf5")
|
|
7
|
+
df = recording.to_dataframe()
|
|
8
|
+
|
|
9
|
+
bundle = DatasetBundle.from_hdf5("some_file.hdf5")
|
|
10
|
+
bundle = bundle.with_computation("wavelet_coefficients/channel1/details", derived)
|
|
11
|
+
bundle.to_hdf5("some_file.hdf5")
|
|
12
|
+
|
|
13
|
+
See ICOschema.model.python.recording.Recording and
|
|
14
|
+
ICOschema.model.python.dataset_bundle.DatasetBundle for the full API.
|
|
15
|
+
`DerivedDataset` is a component type with no behavior beyond what's
|
|
16
|
+
generated (see ICOschema.schema.generated.python.dataset.DerivedDataset).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from ICOschema.model.python.dataset_bundle import DatasetBundle
|
|
20
|
+
from ICOschema.model.python.recording import Recording
|
|
21
|
+
from ICOschema.schema.generated.python.dataset import DerivedDataset
|
|
22
|
+
|
|
23
|
+
__all__ = ["DatasetBundle", "DerivedDataset", "Recording"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The public API for a `DatasetBundle`: a `Recording` together with every
|
|
2
|
+
`DerivedDataset` computed from it (see ICOschema/schema/generated/python).
|
|
3
|
+
Adds loading from / writing to storage and immutable-update helpers on top
|
|
4
|
+
of the generated fields (`recording`, `computations`).
|
|
5
|
+
|
|
6
|
+
This file should never be imported directly, but rather only from the main
|
|
7
|
+
entrypoint:
|
|
8
|
+
|
|
9
|
+
from ICOschema import DatasetBundle
|
|
10
|
+
|
|
11
|
+
bundle = DatasetBundle.from_hdf5("some_file.hdf5")
|
|
12
|
+
bundle = bundle.with_computation("wavelet_coefficients/channel1/details", derived)
|
|
13
|
+
bundle.to_hdf5("some_file.hdf5")
|
|
14
|
+
|
|
15
|
+
`DerivedDataset` itself has no model-layer wrapper -- like `Sensor` or
|
|
16
|
+
`ChannelMetadata`, it's a component type with no behavior beyond what's
|
|
17
|
+
generated, used directly via
|
|
18
|
+
`ICOschema.schema.generated.python.dataset.DerivedDataset`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
from ICOschema.model.python.recording import Recording
|
|
26
|
+
from ICOschema.schema.generated.python import dataset as generated
|
|
27
|
+
from ICOschema.schema.generated.python.dataset import DerivedDataset
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DatasetBundle(generated.DatasetBundle):
|
|
31
|
+
"""A Recording together with every DerivedDataset computed from it.
|
|
32
|
+
|
|
33
|
+
Has every field of `ICOschema.schema.generated.python.dataset.DatasetBundle`
|
|
34
|
+
(`recording`, `computations`), plus the loading/writing and
|
|
35
|
+
immutable-update methods defined below. Construct via a `from_XXX`
|
|
36
|
+
classmethod (e.g. `from_hdf5`) rather than the bare constructor when
|
|
37
|
+
loading from storage.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_hdf5(cls, path: str, *, on_error: Literal["skip", "raise"] = "skip") -> DatasetBundle:
|
|
42
|
+
"""Load a DatasetBundle from an HDF5 file.
|
|
43
|
+
|
|
44
|
+
Always succeeds -- `computations` is empty if the file has none.
|
|
45
|
+
A malformed computation entry is logged and skipped by default;
|
|
46
|
+
pass `on_error="raise"` to fail the whole read instead. See
|
|
47
|
+
ICOschema.storage.python.hdf5 for the accepted file layout.
|
|
48
|
+
"""
|
|
49
|
+
from ICOschema.storage.python import hdf5
|
|
50
|
+
|
|
51
|
+
loaded = hdf5.load_dataset_bundle(path, on_error=on_error)
|
|
52
|
+
return cls(
|
|
53
|
+
recording=Recording(
|
|
54
|
+
hardware_metadata=loaded.recording.hardware_metadata,
|
|
55
|
+
recording_metadata=loaded.recording.recording_metadata,
|
|
56
|
+
recording_data=loaded.recording.recording_data,
|
|
57
|
+
),
|
|
58
|
+
computations=dict(loaded.computations),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def to_hdf5(self, path: str) -> None:
|
|
62
|
+
"""Write this bundle (recording + every computation) to a fresh HDF5 file.
|
|
63
|
+
|
|
64
|
+
See ICOschema.storage.python.hdf5 for the file layout this
|
|
65
|
+
produces -- each computation is stored in its true N-D shape under
|
|
66
|
+
a `/computations/` group.
|
|
67
|
+
"""
|
|
68
|
+
from ICOschema.storage.python import hdf5
|
|
69
|
+
|
|
70
|
+
hdf5.save_dataset_bundle(self, path)
|
|
71
|
+
|
|
72
|
+
def with_recording(self, recording: Recording) -> DatasetBundle:
|
|
73
|
+
"""A copy of this bundle with `recording` replaced (e.g. after dataloss-fill)."""
|
|
74
|
+
return DatasetBundle(recording=recording, computations=dict(self.computations))
|
|
75
|
+
|
|
76
|
+
def with_computation(self, key: str, derived: DerivedDataset) -> DatasetBundle:
|
|
77
|
+
"""A copy of this bundle with one computation added/replaced under `key`."""
|
|
78
|
+
computations = dict(self.computations)
|
|
79
|
+
computations[key] = derived
|
|
80
|
+
return DatasetBundle(recording=self.recording, computations=computations)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The public API of this repository: a `Recording` that inherits all
|
|
2
|
+
fields from the generated `dataset.Recording` (see
|
|
3
|
+
ICOschema/schema/generated/python) and adds a few things that aren't
|
|
4
|
+
auto-generated: loading from / writing to a specific storage backend,
|
|
5
|
+
converting to a pandas DataFrame, quick pass-through getters for the raw
|
|
6
|
+
arrays (timestamps, counter, channel1/2/3), as numpy arrays, that plotting
|
|
7
|
+
code typically wants directly instead of going through recording_data or a
|
|
8
|
+
DataFrame, signal_loss_percentage, and a JSON-encodable summary() of the
|
|
9
|
+
whole recording.
|
|
10
|
+
|
|
11
|
+
This file should never be imported directly, but rather only from the main
|
|
12
|
+
entrypoint:
|
|
13
|
+
|
|
14
|
+
from ICOschema import Recording
|
|
15
|
+
|
|
16
|
+
recording = Recording.from_hdf5("some_file.hdf5")
|
|
17
|
+
df = recording.to_dataframe()
|
|
18
|
+
|
|
19
|
+
ICOschema.schema.generated (plain data, no behavior) and ICOschema.storage
|
|
20
|
+
(storage-format-specific I/O) are internal implementation details of this
|
|
21
|
+
module and should not be imported directly by other codebases.
|
|
22
|
+
|
|
23
|
+
Note: Compensation/Conversion (on HardwareMetadata.channelN_metadata) is
|
|
24
|
+
purely descriptive of what has already been applied to a channel's raw
|
|
25
|
+
RecordingData values -- it is not applied by this class, or by anything
|
|
26
|
+
else here.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
import pandas as pd
|
|
35
|
+
|
|
36
|
+
from ICOschema.schema.generated.python import dataset as generated
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Recording(generated.Recording):
|
|
40
|
+
"""A complete recording: hardware/sensor metadata plus its sample data.
|
|
41
|
+
|
|
42
|
+
Has every field of `ICOschema.schema.generated.python.dataset.Recording`
|
|
43
|
+
(`hardware_metadata`, `recording_metadata`, `recording_data`), plus the
|
|
44
|
+
loading/writing and convenience methods defined below. Construct via a
|
|
45
|
+
`from_XXX` classmethod (e.g. `from_hdf5`) rather than the bare
|
|
46
|
+
constructor when loading from storage.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_hdf5(cls, path: str) -> Recording:
|
|
51
|
+
"""Load a Recording from a PyTables-style HDF5 file.
|
|
52
|
+
|
|
53
|
+
See ICOschema.storage.python.hdf5 for the accepted file layout and
|
|
54
|
+
the legacy/versioned format dispatch. Oblivious to any
|
|
55
|
+
`/computations/` group the file may also contain -- use
|
|
56
|
+
ICOschema.DatasetBundle.from_hdf5() to read those too.
|
|
57
|
+
"""
|
|
58
|
+
from ICOschema.storage.python import hdf5
|
|
59
|
+
|
|
60
|
+
loaded = hdf5.load_recording(path)
|
|
61
|
+
return cls(
|
|
62
|
+
hardware_metadata=loaded.hardware_metadata,
|
|
63
|
+
recording_metadata=loaded.recording_metadata,
|
|
64
|
+
recording_data=loaded.recording_data,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def to_hdf5(self, path: str) -> None:
|
|
68
|
+
"""Write this recording to a fresh HDF5 file in the current format.
|
|
69
|
+
|
|
70
|
+
See ICOschema.storage.python.hdf5 for the file layout this
|
|
71
|
+
produces.
|
|
72
|
+
"""
|
|
73
|
+
from ICOschema.storage.python import hdf5
|
|
74
|
+
|
|
75
|
+
hdf5.save_recording(self, path)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def timestamps(self) -> np.ndarray:
|
|
79
|
+
"""recording_data.timestamp as a numpy array (integer microseconds since recording_metadata.start_time)."""
|
|
80
|
+
return np.asarray(self.recording_data.timestamp)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def channel1(self) -> np.ndarray:
|
|
84
|
+
"""recording_data.channel1 as a numpy array. Always present."""
|
|
85
|
+
return np.asarray(self.recording_data.channel1)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def channel2(self) -> np.ndarray:
|
|
89
|
+
"""recording_data.channel2 as a numpy array. Empty if this recording has no second channel."""
|
|
90
|
+
return np.asarray(self.recording_data.channel2)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def channel3(self) -> np.ndarray:
|
|
94
|
+
"""recording_data.channel3 as a numpy array. Empty if this recording has no third channel."""
|
|
95
|
+
return np.asarray(self.recording_data.channel3)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def counter(self) -> np.ndarray:
|
|
99
|
+
"""recording_data.counter as a numpy array (an 8-bit wrapping packet counter)."""
|
|
100
|
+
return np.asarray(self.recording_data.counter, dtype=int)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def signal_loss_percentage(self) -> float:
|
|
104
|
+
"""Percentage of packets lost, inferred from gaps/wraparounds in `counter`.
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
`counter` is an 8-bit counter (wraps 255 -> 0) that increments once per
|
|
108
|
+
packet; a jump of more than 1 between consecutive samples (accounting
|
|
109
|
+
for wraparound) means packets were dropped in between.
|
|
110
|
+
"""
|
|
111
|
+
counter = self.counter
|
|
112
|
+
gaps = np.diff(counter) - 1
|
|
113
|
+
gaps[gaps == -1] = 0 # counter repeated (no increment)
|
|
114
|
+
gaps[gaps == -256] = 0 # clean wraparound 255 -> 0
|
|
115
|
+
gaps[gaps < 0] += 256 # wraparound with loss
|
|
116
|
+
total_lost = int(np.sum(gaps))
|
|
117
|
+
total_received = len(counter)
|
|
118
|
+
return (total_lost / (total_received + total_lost)) * 100
|
|
119
|
+
|
|
120
|
+
def summary(self) -> dict[str, Any]:
|
|
121
|
+
"""A compact, JSON-encodable summary of this recording.
|
|
122
|
+
|
|
123
|
+
Every value is a plain `str`/`int`/`float`/`None` (never a numpy
|
|
124
|
+
scalar or array) so `json.dumps(recording.summary())` always
|
|
125
|
+
succeeds without a custom encoder -- this is meant to be safe to
|
|
126
|
+
hand directly to a future web API response.
|
|
127
|
+
"""
|
|
128
|
+
channels: dict[str, dict[str, Any]] = {}
|
|
129
|
+
channel_metadata = {
|
|
130
|
+
"channel1": self.hardware_metadata.channel1_metadata,
|
|
131
|
+
"channel2": self.hardware_metadata.channel2_metadata,
|
|
132
|
+
"channel3": self.hardware_metadata.channel3_metadata,
|
|
133
|
+
}
|
|
134
|
+
for name, metadata in channel_metadata.items():
|
|
135
|
+
values = getattr(self, name)
|
|
136
|
+
if values.size == 0:
|
|
137
|
+
continue
|
|
138
|
+
sensor = metadata.sensor if metadata is not None else None
|
|
139
|
+
channels[name] = {
|
|
140
|
+
"sensor_id": sensor.sensor_id if sensor is not None else None,
|
|
141
|
+
"sensor_type": sensor.sensor_type if sensor is not None else None,
|
|
142
|
+
"unit": sensor.unit if sensor is not None else None,
|
|
143
|
+
"sample_count": int(values.size),
|
|
144
|
+
"min": float(np.min(values)),
|
|
145
|
+
"max": float(np.max(values)),
|
|
146
|
+
"mean": float(np.mean(values)),
|
|
147
|
+
"std": float(np.std(values)),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"start_time": self.recording_metadata.start_time,
|
|
152
|
+
"sample_count": int(len(self.counter)),
|
|
153
|
+
"signal_loss_percentage": float(self.signal_loss_percentage),
|
|
154
|
+
"channels": channels,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
158
|
+
"""recording_data as a DataFrame (columns: timestamp, counter, channel1, and channel2/channel3 if present).
|
|
159
|
+
|
|
160
|
+
Values are raw and uncompensated -- see the module docstring's note
|
|
161
|
+
on Compensation/Conversion.
|
|
162
|
+
"""
|
|
163
|
+
data = self.recording_data
|
|
164
|
+
columns = {
|
|
165
|
+
"timestamp": data.timestamp,
|
|
166
|
+
"counter": data.counter,
|
|
167
|
+
"channel1": data.channel1,
|
|
168
|
+
}
|
|
169
|
+
if data.channel2:
|
|
170
|
+
columns["channel2"] = data.channel2
|
|
171
|
+
if data.channel3:
|
|
172
|
+
columns["channel3"] = data.channel3
|
|
173
|
+
return pd.DataFrame(columns)
|
|
File without changes
|