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.
@@ -0,0 +1,6 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "github-actions"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
@@ -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,14 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ CLAUDE.md
13
+ .claude
14
+ .idea
@@ -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,5 @@
1
+ from ICOschema.model.python.dataset_bundle import DatasetBundle
2
+ from ICOschema.model.python.recording import Recording
3
+ from ICOschema.schema.generated.python.dataset import DerivedDataset
4
+
5
+ __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